You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@dubbo.apache.org by xu...@apache.org on 2026/09/22 07:34:18 UTC
(dubbo-go) branch develop updated: feat(metadata): publish interface-level service definitions (#3711)
This is an automated email from the ASF dual-hosted git repository.
Alanxtl pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/dubbo-go.git
The following commit(s) were added to refs/heads/develop by this push:
new 11f64ce64 feat(metadata): publish interface-level service definitions (#3711)
11f64ce64 is described below
commit 11f64ce645896cf53ac12e124ea1e96879edfda1
Author: Oxidaner <80...@users.noreply.github.com>
AuthorDate: Tue Sep 22 15:34:12 2026 +0800
feat(metadata): publish interface-level service definitions (#3711)
* feat(metadata): publish interface-level service definitions
Dubbo Admin discovers callable services by reading interface-level service
definitions from the metadata center. Java providers publish these via
MetadataUtils.publishServiceDefinition; dubbo-go had no equivalent, so Go
services were invisible to Admin's console even when fully registered.
This adds the publish path. A new metadata/definition package builds a
contract from the reflection data already held in common.ServiceMap and
emits JSON that is wire-compatible with Java's FullServiceDefinition, since
Admin deserializes both into one structure.
Publishing is a capability, not a new MetadataReport method:
report.ServiceDefinitionPublisher is an optional interface queried through
DelegateMetadataReport.ServiceDefinitionPublisher. Adding a method to
MetadataReport would break every third-party report at compile time for a
feature only some backends can serve. Nacos implements it; zookeeper, etcd
and external reports are unaffected.
Three decisions deviate from the design proposal:
- Triple non-IDL is detected by the absence of the ServiceInfo URL
attribute rather than the IDL-mode parameter. That parameter works
today but constant.IDLMode, constant.NONIDL and WithIDLMode are all
marked for removal in the next version.
- A publish failure logs and continues instead of blocking instance
registration. A provider whose definition did not land still serves
traffic correctly; gating registration on it would make the metadata
center a startup hard-dependency, which Java does not do either.
- Publishing is switchable via metadata-report.publish-service-definition,
defaulting to on. The proposal has no switch; existing deployments that
do not want interface-level configs appearing in their metadata center
need a way out.
Method name conflicts (two methods contesting one wire name through
MethodMapper or the first-rune alias) are detected by shared logic in
common. The builder refuses to publish either side, but suitableMethods
only warns, so services that already run with a silent collision keep
starting after an upgrade.
Unsupported constructs are excluded rather than degraded: variadic methods,
chan/func/complex/unsafe types, non-string map keys, bare interfaces, and
time.Time/math/big. Also excluded for now are the field-shape cases where
MapGeneralizer's Generalize and Realize disagree — m:"-", tag options,
canonical/legacy name collisions, non-ASCII field names. Those lift once
the shared field-name resolver lands; publishing a schema only one
direction honours would produce requests the provider cannot realize.
Triple IDL and streaming are out of scope: a protobuf contract has to come
from the descriptor, not from the lossy generated struct.
Ref: https://github.com/apache/dubbo-go/issues/3499
Signed-off-by: Oxidaner <18...@163.com>
* feat(metadata): refresh service definitions in the daily cycle report
Nothing ever deletes a service definition. Its key holds no instance and
no revision, and a provider killed with SIGKILL gets no cleanup hook, so
the metadata center accumulates entries for interfaces that no longer
exist. Java has the same leak and has for years.
What makes it survivable there is not cleanup but a reliable death test:
AbstractMetadataReport re-publishes everything the process currently
exports once a day, in a 02:00-06:00 window. A service still being served
gets its timestamp refreshed; one deleted from the code is never
re-published and its timestamp freezes. "Last updated more than about two
days ago and no live instance" then identifies garbage safely.
Without that refresh, never-deleting is an unbounded leak with no way to
tell the garbage apart — so this is a prerequisite for the publishing
design, not the optional extra the proposal listed it as.
dubbo-go already runs the same daily pass over the same window for
application metadata, so definitions now ride along with it.
The pass previously only started for remote metadata storage, because
that was the only thing it refreshed. Definitions publish regardless of
metadataType, so the gate moves inside: the timer starts when either half
has work, and each half checks for itself. Application metadata and its
GC stay remote-only.
Also corrects two claims about Java in the comments this feature added.
Java does not publish unconditionally — report-definition is a switch with
the same default this code uses — and its publish is not merely
non-fatal but asynchronous, since sync-report defaults to false and the
write goes to an executor. Neither the switch nor the swallowed error is
a divergence from Java, as the comments implied. The switch is renamed to
metadata-report.report-definition to match Java's spelling.
GetMetadataType is now guarded. It reads a package global written by
Options.Init, which was already racy under the detector and became more
so once the background renew goroutine started calling it. Fixing it also
clears two pre-existing races in registry/servicediscovery.
Ref: https://github.com/apache/dubbo-go/issues/3499
Signed-off-by: Oxidaner <18...@163.com>
* feat(metadata): retry a failed service definition publish
A definition that fails to publish is invisible to Admin until something
re-attempts it. Until now the only thing that did was the daily cycle
report, so a metadata center that happened to be bouncing while a provider
started left that provider's contract missing for up to a day.
Failures are now retried with capped exponential backoff and jitter, one
pending retry per service key so repeated failures share a state instead
of stacking timers and resetting the backoff. Pending retries are
cancelled on Destroy.
The retry is bounded, unlike the deliberately unlimited subscribe retry
next to it, because their backstops differ: a subscription that never
establishes leaves a permanently stale consumer and nothing else will fix
it, whereas a definition that never publishes is re-attempted daily.
Retrying past the point where the metadata center is plainly not coming
back would only add noise to a failure the daily pass already owns. Java
bounds it too, via retry-times.
PublishServiceDefinitions now returns the URLs worth retrying. Only write
failures come back: a service that is unregistered, unbuildable, or has no
publishable methods is logged and dropped, since retrying cannot change
any of those.
Both publish sites — export and the daily pass — go through the same
helper, so a failure during the daily pass is retried on the same terms as
one during export.
scheduleDefinitionRetry initializes its map lazily. Registries built as
struct literals rather than through newServiceDiscoveryRegistry would
otherwise panic on the first failed publish.
Ref: https://github.com/apache/dubbo-go/issues/3499
Signed-off-by: Oxidaner <18...@163.com>
* feat(metadata): publish the contract in Java's type vocabulary
The definition described types in Go's own spelling — string, int64,
[]T, map[string]T, *T — on the reasoning that each language should
publish natively and consumers would normalize. That reasoning does not
survive contact with dubbo-go itself.
filter/generic already matches caller-supplied $invoke type names against
protocol/dubbo/hessian2.GetJavaName output when it decides whether to
unwrap a packed variadic tail. Names that helper does not recognize fall
through to "leave the tail packed", silently passing a slice where the
method expects its elements. So Java's vocabulary is not one consumer's
preference; it is the vocabulary this runtime already speaks, and a
Go-spelled contract describes names dubbo-go cannot match.
Scalars now map to their Java spelling, and a pointer selects the boxed
form: Go's T versus *T is exactly Java's primitive versus wrapper, which
is how a reader tells a nullable value from one that cannot be null.
Provider metadata has never carried a nullability flag, so expressing it
in the type name costs nothing and adds no field.
Two consequences worth stating.
Containers use array syntax rather than List<T>. Java generics cannot
hold a primitive, so List<byte> names no type; the array form is valid
for every element kind, is what GetJavaName produces, and lands a Go
[]byte on Java's byte[] — the shape hessian2 already writes (GetClassDesc
returns "[B"). Map values are boxed for the same reason, since they do sit
inside a generic argument list.
Maps carry both type arguments as items, keyed first. Java's
MapTypeBuilder appends one entry per actual type argument, so consumers
tell a collection from a map by arity: one item versus two. The previous
single-item map entry read as an array of the value type — wrong shape,
no error.
uint and uint64 are refused. Their range runs past every Java integer
type, and the existing hessian helper's answer is the non-Java "unsigned
long", which names nothing a consumer can map. Smaller unsigned kinds
widen to a signed type that holds their whole range; the schema is then
looser on the low end, which realization rejects rather than wraps.
A struct that declares a Java class name is published under it, since that
is the name hessian2 writes into the wire form's "class" key. Everything
else keeps the Go import path — no Java name exists to borrow, and the
path does the same disambiguating job a Java FQN does.
Field names are untouched and stay Go-derived. They are wire names that
Realize matches on, unlike type names, which are descriptive. A Java and a
Go provider of one interface will therefore disagree on field spelling and
fail a consumer's structural-consistency check — correctly, because their
wire contracts genuinely differ.
Ref: https://github.com/apache/dubbo-go/issues/3499
Signed-off-by: Oxidaner <18...@163.com>
* style: satisfy the repository formatter and linter
make fmt rewrites reflect.TypeOf(T{}) to reflect.TypeFor[T](); the CI
Format job runs it and fails on any resulting diff. golangci-lint wants US
spelling and require, not assert, for error assertions.
Signed-off-by: Oxidaner <18...@163.com>
* fix(metadata): address review findings on definition publishing
Four fixes and one warning, from review on #3711.
report-definition:false had no effect. reportConfigToReportOptions builds
ReportOptions field by field, and this one was not listed, so the parsed
value never reached the report URL and the switch silently did nothing.
Any field added to MetadataReportConfig has to be repeated there.
Definitions no longer broadcast. Publishing walked every configured report
via GetMetadataReports, but reports are per registry and two registries
routinely point at different namespaces or clusters. One registry's
contracts were written into another's environment, and a failure on the
unrelated target pulled the whole set into retry. PublishServiceDefinitions
and ServiceDefinitionsEnabled now take the caller's own report.
Registration no longer waits on the metadata center. The first publish ran
inline before instance registration, one synchronous write per service, so
a slow or unreachable Nacos held an otherwise healthy provider out of
traffic for as long as the writes took. It now runs on a timer that Destroy
cancels, after registration rather than before. Failures land in the same
per-service retry as any other publish. The daily cycle report still
publishes synchronously — it is already off the registration path.
uint8 is published as short, not byte. Java's byte is signed, so a consumer
range-checking against it rejects 128..255 before the call leaves. []byte
and []uint8 are one type in Go, so byte slices follow; spelling them byte[]
would have matched hessian2's wire form, but that buys nothing when half
the element values cannot be expressed either way.
The review also asked that methods shaped like net/rpc's
func(args, reply *T) error not be published, since dubbo-go discards the
reply pointer and a caller receives an empty result. Verified: the provider
builds its response from return values only, and the consumer allocates a
reply only for a two-value signature — so the shape is already broken for
every caller, not just through a definition, and no method in this
repository uses it. void is therefore an accurate contract, and excluding
it would also exclude func(ctx, from, to *Account) error, which reflection
cannot tell apart and which behaves exactly as declared.
Warning at registration instead, next to the variadic one, so the author
sees it in their own startup log rather than after a caller gets nothing
back. Whether suiteMethod should reject the shape outright is a separate
question from publishing.
Ref: https://github.com/apache/dubbo-go/issues/3499
Signed-off-by: Oxidaner <18...@163.com>
* fix(metadata): harden service definition publishing
Preserve Java byte-array wire compatibility while validating unsigned bounds during generic realization so out-of-range values fail instead of truncating. Serialize definition writes per registry, coalesce repeated full snapshots, and drop queued work during destroy without waiting for an in-flight backend call.
Add public-option, report-isolation, lifecycle, byte conversion, and race-oriented regression coverage. Keep possible reply-pointer methods behaviorally unchanged while making their advisory warning conditional, and remove definition-builder bookkeeping that had no observable effect.
Fixes: #3499
Signed-off-by: Oxidaner <18...@163.com>
* fix(metadata): align definition group and JSON schema
Honor metadata-report.group for Nacos service definitions while preserving the default dubbo group and the application-metadata compatibility default.
Omit Java MethodDefinition's deprecated method-level parameters field instead of publishing a different name/type schema under the same JSON key. Consumers continue to derive positional arguments from parameterTypes.
Fixes: #3499
Signed-off-by: Oxidaner <18...@163.com>
* fix(metadata): align definition field schemas
Signed-off-by: Oxidaner <18...@163.com>
* fix(metadata): normalize Nacos definition keys
Signed-off-by: Oxidaner <18...@163.com>
* fix(metadata): reject pointer squash definitions
---------
Signed-off-by: Oxidaner <18...@163.com>
---
common/constant/key.go | 8 +
common/rpc_service.go | 237 +++++++-
common/rpc_service_test.go | 52 ++
dubbo_test.go | 15 +
filter/generic/generalizer/bean.go | 3 +-
filter/generic/generalizer/map.go | 198 +++++--
filter/generic/generalizer/map_test.go | 59 ++
global/metadata_report_config.go | 38 +-
internal/genericfield/tag.go | 77 +++
internal/genericfield/tag_test.go | 64 +++
metadata/definition/builder.go | 194 +++++++
metadata/definition/builder_test.go | 599 +++++++++++++++++++++
metadata/definition/definition.go | 104 ++++
metadata/definition/identifier.go | 78 +++
metadata/definition/identifier_test.go | 104 ++++
metadata/definition/json_test.go | 115 ++++
metadata/definition/types.go | 557 +++++++++++++++++++
metadata/options.go | 27 +-
metadata/options_test.go | 11 +
metadata/report/nacos/report.go | 59 +-
metadata/report/nacos/report_test.go | 55 ++
metadata/report/report.go | 24 +
metadata/report_instance.go | 19 +
metadata/report_instance_test.go | 12 +-
metadata/service_definition.go | 214 ++++++++
metadata/service_definition_test.go | 432 +++++++++++++++
options.go | 7 +
registry/servicediscovery/definition_retry_test.go | 464 ++++++++++++++++
.../servicediscovery/service_discovery_registry.go | 329 ++++++++++-
.../service_discovery_registry_test.go | 48 ++
server/server.go | 6 +-
31 files changed, 4090 insertions(+), 119 deletions(-)
diff --git a/common/constant/key.go b/common/constant/key.go
index 8b24dbde3..4c5710a60 100644
--- a/common/constant/key.go
+++ b/common/constant/key.go
@@ -195,6 +195,14 @@ const (
MetadataReportUsernameKey = "metadata-report.username"
MetadataReportPasswordKey = "metadata-report.password"
MetadataReportProtocolKey = "metadata-report.protocol"
+
+ // MetadataReportReportDefinitionKey toggles publishing of interface-level
+ // service definitions to this metadata report. Defaults to true.
+ //
+ // Named after Java's REPORT_DEFINITION_KEY, which is the same switch with
+ // the same default (AbstractMetadataReport reads "report-definition" from
+ // the metadata-report URL), so an operator's mental model carries over.
+ MetadataReportReportDefinitionKey = "metadata-report.report-definition"
)
// registry keys
diff --git a/common/rpc_service.go b/common/rpc_service.go
index 6e8ae09d4..900a621bf 100644
--- a/common/rpc_service.go
+++ b/common/rpc_service.go
@@ -338,7 +338,10 @@ func VariadicRPCMethodNames(svc RPCService) []string {
// WarnVariadicRPCMethods emits guidance for exported variadic RPC methods while
// keeping existing services compatible.
func WarnVariadicRPCMethods(serviceName string, svc RPCService) {
- methodNames := VariadicRPCMethodNames(svc)
+ warnVariadicRPCMethods(serviceName, VariadicRPCMethodNames(svc))
+}
+
+func warnVariadicRPCMethods(serviceName string, methodNames []string) {
if len(methodNames) == 0 {
return
}
@@ -351,47 +354,227 @@ func WarnVariadicRPCMethods(serviceName string, svc RPCService) {
}
func variadicRPCMethodNames(typ reflect.Type) []string {
+ return inspectRPCMethods(typ).variadic
+}
+
+// WarnRPCMethods emits advisory diagnostics for RPC method shapes that are
+// accepted for compatibility but need care in generic/cross-language APIs. It
+// scans the handler once so adding a diagnostic does not repeatedly invoke
+// suiteMethod for every exported method.
+func WarnRPCMethods(serviceName string, svc RPCService) {
+ diagnostics := inspectRPCMethods(reflect.TypeOf(svc))
+ warnVariadicRPCMethods(serviceName, diagnostics.variadic)
+ warnPossibleReplyPointerMethods(serviceName, diagnostics.possibleReplyPointer)
+}
+
+// warnPossibleReplyPointerMethods flags methods shaped like net/rpc's
+// func(args, reply *T) error, whose reply pointer dubbo-go does not send back.
+//
+// The shape is a leftover from net/rpc, which dubbo-go's early API borrowed —
+// suiteMethod still accepts a lone error return, and MethodType.argsType's
+// comment still says the reply is among the args. Neither proxy honors it any
+// more: the provider builds its response from the method's return values only
+// (proxy_factory.ProxyInvoker), and the consumer only allocates a reply when the
+// method declares two return values (proxy.Proxy). Whatever the method writes
+// into that pointer stays in the provider's memory.
+//
+// Advisory, not a rejection: func(ctx, from *Account, to *Account) error is the
+// same shape and is perfectly correct — it just does not return anything, which
+// is what dubbo-go will do.
+func warnPossibleReplyPointerMethods(serviceName string, methodNames []string) {
+ if len(methodNames) == 0 {
+ return
+ }
+
+ logger.Warnf(
+ "[RPCService] service %s exports method(s) with multiple inputs and a trailing pointer: %s. "+
+ "If that pointer is intended as a net/rpc-style reply, dubbo-go will not return its "+
+ "mutated value; return the value explicitly as (T, error). If every pointer is an input, "+
+ "no action is required.",
+ serviceName,
+ strings.Join(methodNames, ", "),
+ )
+}
+
+// possibleReplyPointerRPCMethodNames returns exported RPC methods that return only an
+// error and take at least two parameters, the last of them a pointer.
+//
+// Two parameters, not one: net/rpc's shape is (args, reply), so requiring a
+// preceding argument keeps the very common func(ctx, req *Req) error — a
+// genuinely void call such as a delete — out of the warning.
+func possibleReplyPointerRPCMethodNames(typ reflect.Type) []string {
+ return inspectRPCMethods(typ).possibleReplyPointer
+}
+
+type rpcMethodDiagnostics struct {
+ variadic []string
+ possibleReplyPointer []string
+}
+
+func inspectRPCMethods(typ reflect.Type) rpcMethodDiagnostics {
if typ == nil {
- return nil
+ return rpcMethodDiagnostics{}
}
- methodNames := make([]string, 0)
+ diagnostics := rpcMethodDiagnostics{}
for i := 0; i < typ.NumMethod(); i++ {
method := typ.Method(i)
- if suiteMethod(method) != nil && method.Type.IsVariadic() {
- methodNames = append(methodNames, method.Name)
+ mt := suiteMethod(method)
+ if mt == nil {
+ continue
+ }
+ if method.Type.IsVariadic() {
+ diagnostics.variadic = append(diagnostics.variadic, method.Name)
+ }
+ if mt.ReplyType() != nil {
+ continue
+ }
+ args := mt.ArgsType()
+ if len(args) >= 2 && args[len(args)-1].Kind() == reflect.Pointer {
+ diagnostics.possibleReplyPointer = append(diagnostics.possibleReplyPointer, method.Name)
+ }
+ }
+
+ return diagnostics
+}
+
+// CanonicalMethod pairs an exported RPC method with the canonical wire name
+// dubbo-go advertises for it.
+type CanonicalMethod struct {
+ // Name is the MethodMapper mapping when one exists, otherwise the Go method
+ // name. It never holds the first-rune-swapped alias.
+ Name string
+ // GoName is the Go method name. Two entries with the same GoName are the
+ // same method; this is what distinguishes "one method, two spellings" from
+ // "two methods fighting over one name".
+ GoName string
+ Method *MethodType
+}
+
+// MethodNameConflict records two distinct Go methods that resolve to the same
+// runtime wire name.
+type MethodNameConflict struct {
+ First string
+ Second string
+ WireName string
+}
+
+func (c MethodNameConflict) String() string {
+ return c.First + " and " + c.Second + " are both routable as " + c.WireName
+}
+
+// MethodWireNames returns every name a canonical method is routable under at
+// runtime: the name itself, plus its first-rune-swapped alias when that differs.
+func MethodWireNames(canonical string) []string {
+ alias := dubboutil.SwapCaseFirstRune(canonical)
+ if alias == canonical {
+ return []string{canonical}
+ }
+ return []string{canonical, alias}
+}
+
+// CanonicalMethods returns typ's exported RPC methods in reflect's stable method
+// order, each resolved through MethodMapper.
+//
+// suitableMethods additionally registers a first-rune-swapped alias for every
+// name, so Java callers can invoke sayHello on a Go SayHello. Callers that want
+// the advertised contract rather than the runtime lookup table need this
+// alias-free view — iterating the map suitableMethods returns would publish each
+// method twice under two spellings.
+func CanonicalMethods(typ reflect.Type) []CanonicalMethod {
+ methodMapper := methodMapperOf(typ)
+
+ methods := make([]CanonicalMethod, 0, typ.NumMethod())
+ for m := range typ.NumMethod() {
+ method := typ.Method(m)
+ mt := suiteMethod(method)
+ if mt == nil {
+ continue
+ }
+ name, mapped := methodMapper[method.Name]
+ if !mapped {
+ name = method.Name
}
+ methods = append(methods, CanonicalMethod{Name: name, GoName: method.Name, Method: mt})
}
+ return methods
+}
- return methodNames
+// MethodNameConflicts reports canonical names whose runtime wire-name sets
+// intersect.
+//
+// Both MethodMapper and the alias mechanism can produce these: two Go methods
+// mapped onto one name, or onto names that are first-rune-case variants of each
+// other. Either way the later registration silently overwrites the earlier one
+// in the lookup map, so one method becomes unreachable while calls to its name
+// land on the other.
+func MethodNameConflicts(methods []CanonicalMethod) []MethodNameConflict {
+ owner := make(map[string]CanonicalMethod, len(methods)*2)
+ reported := make(map[string]bool)
+ var conflicts []MethodNameConflict
+ for _, m := range methods {
+ for _, wire := range MethodWireNames(m.Name) {
+ prev, taken := owner[wire]
+ if taken && prev.GoName != m.GoName {
+ // One entry per pair of methods, not per contested name. Two
+ // methods whose canonical names are first-rune variants of each
+ // other contest both spellings, and reporting each would log the
+ // same problem twice. Iteration follows the caller's stable
+ // slice order, so prev is always the earlier method and the pair
+ // key is consistently ordered.
+ pair := prev.GoName + "\x00" + m.GoName
+ if !reported[pair] {
+ reported[pair] = true
+ conflicts = append(conflicts, MethodNameConflict{
+ First: prev.GoName,
+ Second: m.GoName,
+ WireName: wire,
+ })
+ }
+ continue
+ }
+ owner[wire] = m
+ }
+ }
+ return conflicts
+}
+
+// methodMapperOf invokes the service's MethodMapper hook, if it declares one.
+func methodMapperOf(typ reflect.Type) map[string]string {
+ method, ok := typ.MethodByName(METHOD_MAPPER)
+ if !ok || method.Type.NumIn() != 1 || method.Type.NumOut() != 1 ||
+ method.Type.Out(0).String() != "map[string]string" {
+ return nil
+ }
+ return method.Func.Call([]reflect.Value{reflect.New(typ.Elem())})[0].Interface().(map[string]string)
}
// suitableMethods returns suitable Rpc methods of typ
func suitableMethods(typ reflect.Type) (string, map[string]*MethodType) {
- methods := make(map[string]*MethodType)
- var mts []string
logger.Debugf("[RPCService] NumMethod is %d, type=%s", typ.NumMethod(), typ.String())
- method, ok := typ.MethodByName(METHOD_MAPPER)
- var methodMapper map[string]string
- if ok && method.Type.NumIn() == 1 && method.Type.NumOut() == 1 && method.Type.Out(0).String() == "map[string]string" {
- methodMapper = method.Func.Call([]reflect.Value{reflect.New(typ.Elem())})[0].Interface().(map[string]string)
+
+ canonical := CanonicalMethods(typ)
+
+ // Conflicts are reported but not fatal: services that already run with an
+ // overwriting name collision must keep starting after an upgrade. The
+ // contract builder refuses to publish the affected methods, so the warning
+ // is the only signal a maintainer gets here — worth keeping loud.
+ for _, conflict := range MethodNameConflicts(canonical) {
+ logger.Warnf("[RPCService] method name conflict on type %s: %s. "+
+ "Only one of them is reachable at runtime, and neither is published "+
+ "in the service definition.", typ.String(), conflict)
}
- for m := 0; m < typ.NumMethod(); m++ {
- method = typ.Method(m)
- if mt := suiteMethod(method); mt != nil {
- methodName, ok := methodMapper[method.Name]
- if !ok {
- methodName = method.Name
- }
- methods[methodName] = mt
- mts = append(mts, methodName)
- //For better interoperability with java class,
- //we convert the first letter in methodName between
- //upper and lower case
- methods[dubboutil.SwapCaseFirstRune(methodName)] = mt
- mts = append(mts, dubboutil.SwapCaseFirstRune(methodName))
- }
+ methods := make(map[string]*MethodType, len(canonical)*2)
+ mts := make([]string, 0, len(canonical)*2)
+ for _, m := range canonical {
+ methods[m.Name] = m.Method
+ mts = append(mts, m.Name)
+ // For better interoperability with java class, we convert the first
+ // letter in methodName between upper and lower case.
+ alias := dubboutil.SwapCaseFirstRune(m.Name)
+ methods[alias] = m.Method
+ mts = append(mts, alias)
}
return strings.Join(mts, ","), methods
}
diff --git a/common/rpc_service_test.go b/common/rpc_service_test.go
index caa7b5acf..fd146181a 100644
--- a/common/rpc_service_test.go
+++ b/common/rpc_service_test.go
@@ -699,3 +699,55 @@ func TestSuiteMethodTooManyReturns(t *testing.T) {
mt := suiteMethod(method)
assert.Nil(t, mt) // Should be nil because too many return values
}
+
+// possibleReplyPointerService covers the net/rpc reply-pointer shape and the two
+// shapes that must not be mistaken for it.
+type possibleReplyPointerService struct{}
+
+// Looks like net/rpc: the trailing pointer is where the author expects the
+// result, and dubbo-go discards it.
+func (s *possibleReplyPointerService) Fetch(ctx context.Context, id string, reply *string) error {
+ return nil
+}
+
+// A single-parameter void call — a delete, say. Nothing is expected back.
+func (s *possibleReplyPointerService) Remove(ctx context.Context, id *string) error { return nil }
+
+// Two pointers but genuinely void: both are inputs.
+func (s *possibleReplyPointerService) Transfer(ctx context.Context, from *string, to *string) error {
+ return nil
+}
+
+// Returns its result properly.
+func (s *possibleReplyPointerService) Get(ctx context.Context, id string) (*string, error) {
+ return nil, nil
+}
+
+func TestPossibleReplyPointerRPCMethodNames(t *testing.T) {
+ names := possibleReplyPointerRPCMethodNames(reflect.TypeFor[*possibleReplyPointerService]())
+
+ assert.Contains(t, names, "Fetch")
+ assert.NotContains(t, names, "Remove",
+ "a single-parameter void call is the common delete shape, not a reply pointer")
+ assert.NotContains(t, names, "Get", "a declared return value is returned properly")
+
+ // Transfer is a false positive by construction: it has the same signature
+ // as Fetch and reflection cannot tell them apart. The warning is advisory
+ // for exactly this reason — it never blocks registration.
+ assert.Contains(t, names, "Transfer")
+}
+
+func TestWarnRPCMethodsQualifiesPossibleReplyPointer(t *testing.T) {
+ prev := gostlogger.GetLogger()
+ capture := &captureCommonWarnLogger{Logger: prev}
+ gostlogger.SetLogger(capture)
+ t.Cleanup(func() {
+ gostlogger.SetLogger(prev)
+ })
+
+ WarnRPCMethods("com.example.PointerInputs", &possibleReplyPointerService{})
+
+ require.Len(t, capture.warns, 1)
+ assert.Contains(t, capture.warns[0], "If that pointer is intended")
+ assert.Contains(t, capture.warns[0], "no action is required")
+}
diff --git a/dubbo_test.go b/dubbo_test.go
index 612b32fe5..e0a999647 100644
--- a/dubbo_test.go
+++ b/dubbo_test.go
@@ -35,6 +35,7 @@ import (
"dubbo.apache.org/dubbo-go/v3/common/extension"
"dubbo.apache.org/dubbo-go/v3/config_center"
"dubbo.apache.org/dubbo-go/v3/global"
+ "dubbo.apache.org/dubbo-go/v3/metadata"
"dubbo.apache.org/dubbo-go/v3/registry"
"dubbo.apache.org/dubbo-go/v3/server"
)
@@ -623,6 +624,20 @@ func TestInstanceProcessDoesNotMutateLiveOptionsOnInvalidPayload(t *testing.T) {
assert.Equal(t, "stable-app", ins.insOpts.Application.Name)
}
+func TestWithMetadataReportPreservesReportDefinitionThroughConversion(t *testing.T) {
+ opts := defaultInstanceOptions()
+ WithMetadataReport(
+ metadata.WithNacos(),
+ metadata.WithAddress("127.0.0.1:8848"),
+ metadata.WithReportDefinition(false),
+ )(opts)
+
+ reportOpts, err := reportConfigToReportOptions(opts.MetadataReport)
+ require.NoError(t, err)
+ require.NotNil(t, reportOpts.ReportDefinition)
+ assert.False(t, *reportOpts.ReportDefinition)
+}
+
func TestSetProviderServiceRegistersByReference(t *testing.T) {
proLock.Lock()
original := cloneServiceDefinitions(providerServices)
diff --git a/filter/generic/generalizer/bean.go b/filter/generic/generalizer/bean.go
index a8f778d30..103f7bf9f 100644
--- a/filter/generic/generalizer/bean.go
+++ b/filter/generic/generalizer/bean.go
@@ -27,6 +27,7 @@ import (
)
import (
+ "dubbo.apache.org/dubbo-go/v3/internal/genericfield"
"dubbo.apache.org/dubbo-go/v3/protocol/dubbo/hessian2"
)
@@ -160,7 +161,7 @@ func (g *BeanGeneralizer) toDescriptor(obj any, visited map[uintptr]bool) *JavaB
desc.Type = TypeBean
for i := 0; i < t.NumField(); i++ {
if fv := v.Field(i); fv.CanInterface() {
- desc.Properties[toUnexport(t.Field(i).Name)] = g.toDescriptor(fv.Interface(), visited)
+ desc.Properties[genericfield.DefaultName(t.Field(i).Name)] = g.toDescriptor(fv.Interface(), visited)
}
}
diff --git a/filter/generic/generalizer/map.go b/filter/generic/generalizer/map.go
index de67b92a7..3e7135b12 100644
--- a/filter/generic/generalizer/map.go
+++ b/filter/generic/generalizer/map.go
@@ -18,15 +18,14 @@
package generalizer
import (
+ "encoding/json"
"fmt"
"maps"
+ "math"
"reflect"
"strconv"
- "strings"
"sync"
"time"
- "unicode"
- "unicode/utf8"
)
import (
@@ -40,6 +39,7 @@ import (
import (
"dubbo.apache.org/dubbo-go/v3/common/config"
"dubbo.apache.org/dubbo-go/v3/common/constant"
+ "dubbo.apache.org/dubbo-go/v3/internal/genericfield"
"dubbo.apache.org/dubbo-go/v3/protocol/dubbo/hessian2"
)
@@ -74,8 +74,9 @@ func (g *MapGeneralizer) Realize(obj any, typ reflect.Type) (any, error) {
}
newobj := reflect.New(typ).Interface()
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
- Result: newobj,
- TagName: "m",
+ DecodeHook: genericRealizeDecodeHook,
+ Result: newobj,
+ TagName: "m",
})
if err != nil {
return nil, fmt.Errorf("creating map decoder failed, %v", err)
@@ -89,6 +90,139 @@ func (g *MapGeneralizer) Realize(obj any, typ reflect.Type) (any, error) {
return reflect.ValueOf(newobj).Elem().Interface(), nil
}
+// genericRealizeDecodeHook closes two gaps in mapstructure's numeric
+// conversion that matter to generic RPC contracts:
+// - SetUint truncates values above the target type's maximum;
+// - Java byte[] carries signed bytes while Go []byte carries the same bits as
+// uint8 values.
+//
+// The first must fail loudly rather than silently change an RPC argument. The
+// second is a deliberate bit-preserving conversion and only applies inside a
+// byte slice/array, never to a scalar uint8 (which definitions publish as Java
+// short so 0..255 stays directly representable).
+func genericRealizeDecodeHook(from, to reflect.Type, data any) (any, error) {
+ if from == nil || to == nil || data == nil {
+ return data, nil
+ }
+
+ if isByteSequence(to) && (from.Kind() == reflect.Slice || from.Kind() == reflect.Array) {
+ return realizeJavaByteSequence(data, to)
+ }
+
+ if isUnsignedKind(to.Kind()) {
+ if err := validateUnsignedValue(data, to); err != nil {
+ return nil, err
+ }
+ }
+ return data, nil
+}
+
+func isByteSequence(t reflect.Type) bool {
+ return (t.Kind() == reflect.Slice || t.Kind() == reflect.Array) &&
+ t.Elem().Kind() == reflect.Uint8
+}
+
+func realizeJavaByteSequence(data any, target reflect.Type) (any, error) {
+ source := reflect.ValueOf(data)
+ result := make([]byte, source.Len())
+ byteType := reflect.TypeFor[byte]()
+ for i := range source.Len() {
+ value := source.Index(i)
+ for value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer {
+ if value.IsNil() {
+ return nil, fmt.Errorf("nil byte at index %d cannot be realized as %s", i, target)
+ }
+ value = value.Elem()
+ }
+ if value.Kind() == reflect.Int8 {
+ // Java byte and Go byte carry the same eight bits under different
+ // signedness. Preserve those bits instead of treating -1 as an
+ // out-of-range numeric value.
+ result[i] = byte(int8(value.Int()))
+ continue
+ }
+ converted, recognized, err := checkedUnsignedValue(value.Interface(), byteType)
+ if err != nil {
+ return nil, fmt.Errorf("byte at index %d: %w", i, err)
+ }
+ if !recognized {
+ return nil, fmt.Errorf("byte at index %d has unsupported type %s", i, value.Type())
+ }
+ result[i] = byte(converted)
+ }
+ return result, nil
+}
+
+func isUnsignedKind(kind reflect.Kind) bool {
+ switch kind {
+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
+ return true
+ default:
+ return false
+ }
+}
+
+func validateUnsignedValue(data any, target reflect.Type) error {
+ _, _, err := checkedUnsignedValue(data, target)
+ return err
+}
+
+func checkedUnsignedValue(data any, target reflect.Type) (uint64, bool, error) {
+ if number, ok := data.(json.Number); ok {
+ value, err := strconv.ParseUint(string(number), 10, target.Bits())
+ if err != nil {
+ return 0, true, fmt.Errorf("value %s overflows %s: %w", number, target, err)
+ }
+ if target.OverflowUint(value) {
+ return 0, true, fmt.Errorf("value %s overflows %s", number, target)
+ }
+ return value, true, nil
+ }
+
+ value := reflect.ValueOf(data)
+ for value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer {
+ if value.IsNil() {
+ return 0, false, nil
+ }
+ value = value.Elem()
+ }
+
+ switch value.Kind() {
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+ signed := value.Int()
+ if signed < 0 || target.OverflowUint(uint64(signed)) {
+ return 0, true, fmt.Errorf("value %d overflows %s", signed, target)
+ }
+ return uint64(signed), true, nil
+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
+ if target.OverflowUint(value.Uint()) {
+ return 0, true, fmt.Errorf("value %d overflows %s", value.Uint(), target)
+ }
+ return value.Uint(), true, nil
+ case reflect.Float32, reflect.Float64:
+ floating := value.Float()
+ overflow := floating > float64(unsignedMax(target.Bits()))
+ if target.Bits() >= 64 {
+ // float64(MaxUint64) rounds to 2^64, so compare against the
+ // exclusive upper bound instead of the rounded integer maximum.
+ overflow = floating >= math.Exp2(64)
+ }
+ if math.IsNaN(floating) || math.IsInf(floating, 0) || floating < 0 ||
+ math.Trunc(floating) != floating || overflow {
+ return 0, true, fmt.Errorf("value %v overflows %s", floating, target)
+ }
+ return uint64(floating), true, nil
+ }
+ return 0, false, nil
+}
+
+func unsignedMax(bits int) uint64 {
+ if bits >= 64 {
+ return math.MaxUint64
+ }
+ return 1<<bits - 1
+}
+
func (g *MapGeneralizer) GetType(obj any) (typ string, err error) {
typ, err = hessian2.GetJavaName(obj)
// no error or error is not NilError
@@ -188,8 +322,8 @@ func objToMap(obj any) (any, error) {
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
value := v.Field(i)
- tag := parseMTag(field)
- if tag.ignore || tag.omitEmpty && isEmptyValue(value) {
+ tag := genericfield.ParseMTag(field)
+ if tag.Ignore || tag.OmitEmpty && isEmptyValue(value) {
continue
}
kind := value.Kind()
@@ -221,7 +355,7 @@ func objToMap(obj any) (any, error) {
if err != nil {
return nil, err
}
- if tag.squash {
+ if tag.Squash {
squashed, ok := generalizedValue.(map[string]any)
if !ok {
return nil, fmt.Errorf("cannot squash non-struct type '%s'", value.Type())
@@ -230,7 +364,7 @@ func objToMap(obj any) (any, error) {
maps.Copy(result, squashed)
continue
}
- result[tag.name] = generalizedValue
+ result[tag.Name] = generalizedValue
}
return result, nil
case reflect.Array, reflect.Slice:
@@ -285,39 +419,6 @@ func mapKey(key reflect.Value) any {
}
}
-type mTag struct {
- name string
- ignore bool
- omitEmpty bool
- squash bool
-}
-
-func parseMTag(field reflect.StructField) mTag {
- tag := mTag{name: toUnexport(field.Name)}
- tagValue := field.Tag.Get("m")
- name, options, hasOptions := strings.Cut(tagValue, ",")
- if name == "-" {
- tag.ignore = true
- return tag
- }
- if name != "" {
- tag.name = name
- }
- if !hasOptions {
- return tag
- }
-
- for option := range strings.SplitSeq(options, ",") {
- switch option {
- case "omitempty":
- tag.omitEmpty = true
- case "squash":
- tag.squash = true
- }
- }
- return tag
-}
-
func isEmptyValue(value reflect.Value) bool {
switch value.Kind() {
case reflect.Array, reflect.Map, reflect.Slice, reflect.String:
@@ -337,19 +438,6 @@ func isEmptyValue(value reflect.Value) bool {
}
}
-// toUnexport lowercases the first rune of a.
-//
-// Rune-based rather than byte-based: strings.ToLower(a[:1]) splits a multi-byte
-// leading rune, so an exported field named with a non-ASCII letter used to
-// generalize to a key containing an invalid UTF-8 fragment.
-func toUnexport(a string) string {
- if a == "" {
- return a
- }
- first, size := utf8.DecodeRuneInString(a)
- return string(unicode.ToLower(first)) + a[size:]
-}
-
// isPrimitive determines if the object is primitive
func isPrimitive(obj any) bool {
if _, ok := obj.(time.Time); ok {
diff --git a/filter/generic/generalizer/map_test.go b/filter/generic/generalizer/map_test.go
index e4fb40bd9..6b43a2592 100644
--- a/filter/generic/generalizer/map_test.go
+++ b/filter/generic/generalizer/map_test.go
@@ -18,6 +18,7 @@
package generalizer
import (
+ "math"
"reflect"
"strconv"
"testing"
@@ -217,6 +218,64 @@ func TestUntaggedNameRoundTripsForUnicode(t *testing.T) {
assert.Equal(t, original, realized)
}
+func TestRealizeRejectsUnsignedOverflow(t *testing.T) {
+ tests := []struct {
+ name string
+ data any
+ typ reflect.Type
+ want any
+ }{
+ {name: "uint8 max", data: int16(math.MaxUint8), typ: reflect.TypeFor[uint8](), want: uint8(math.MaxUint8)},
+ {name: "uint16 max", data: int32(math.MaxUint16), typ: reflect.TypeFor[uint16](), want: uint16(math.MaxUint16)},
+ {name: "uint32 max", data: int64(math.MaxUint32), typ: reflect.TypeFor[uint32](), want: uint32(math.MaxUint32)},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := mockMapGeneralizer.Realize(tt.data, tt.typ)
+ require.NoError(t, err)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+
+ overflows := []struct {
+ name string
+ data any
+ typ reflect.Type
+ }{
+ {name: "negative uint8", data: int16(-1), typ: reflect.TypeFor[uint8]()},
+ {name: "uint8 overflow", data: int16(math.MaxUint8 + 1), typ: reflect.TypeFor[uint8]()},
+ {name: "uint16 overflow", data: int32(math.MaxUint16 + 1), typ: reflect.TypeFor[uint16]()},
+ {name: "uint32 overflow", data: int64(math.MaxUint32 + 1), typ: reflect.TypeFor[uint32]()},
+ }
+ for _, tt := range overflows {
+ t.Run(tt.name, func(t *testing.T) {
+ _, err := mockMapGeneralizer.Realize(tt.data, tt.typ)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "overflows")
+ })
+ }
+}
+
+type bytePayload struct {
+ Data []byte
+}
+
+func TestRealizeJavaSignedByteArray(t *testing.T) {
+ got, err := mockMapGeneralizer.Realize(
+ map[string]any{"data": []int8{0, 127, -128, -1}},
+ reflect.TypeFor[bytePayload](),
+ )
+ require.NoError(t, err)
+ assert.Equal(t, bytePayload{Data: []byte{0, 127, 128, 255}}, got)
+
+ _, err = mockMapGeneralizer.Realize(
+ map[string]any{"data": []int16{0, math.MaxUint8 + 1}},
+ reflect.TypeFor[bytePayload](),
+ )
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "overflows")
+}
+
type testStruct struct {
AaAa string
BaBa string `m:"baBa"`
diff --git a/global/metadata_report_config.go b/global/metadata_report_config.go
index f069d7aa0..f0e81d64b 100644
--- a/global/metadata_report_config.go
+++ b/global/metadata_report_config.go
@@ -31,6 +31,21 @@ type MetadataReportConfig struct {
Group string `yaml:"group" json:"group,omitempty"`
Namespace string `yaml:"namespace" json:"namespace,omitempty"`
Params map[string]string `yaml:"params" json:"parameters,omitempty"`
+
+ // ReportDefinition toggles publishing interface-level service definitions to
+ // this report, which is what makes services visible to Dubbo Admin's
+ // generic-invocation console.
+ //
+ // nil means enabled. This mirrors Java's MetadataReportConfig.reportDefinition,
+ // which is the same switch with the same default. It is a pointer so an
+ // explicit `false` is distinguishable from an unset field; with a plain bool
+ // the zero value would silently disable the feature for everyone who does
+ // not mention it.
+ //
+ // Turning this off is worth considering for a provider whose application,
+ // version or group carries a per-deploy value, since each distinct
+ // combination becomes a separate key that nothing ever deletes.
+ ReportDefinition *bool `yaml:"report-definition" json:"report-definition,omitempty"`
}
func DefaultMetadataReportConfig() *MetadataReportConfig {
@@ -47,14 +62,21 @@ func (c *MetadataReportConfig) Clone() *MetadataReportConfig {
newParams := make(map[string]string, len(c.Params))
maps.Copy(newParams, c.Params)
+ var reportDefinition *bool
+ if c.ReportDefinition != nil {
+ value := *c.ReportDefinition
+ reportDefinition = &value
+ }
+
return &MetadataReportConfig{
- Protocol: c.Protocol,
- Address: c.Address,
- Username: c.Username,
- Password: c.Password,
- Timeout: c.Timeout,
- Group: c.Group,
- Namespace: c.Namespace,
- Params: newParams,
+ Protocol: c.Protocol,
+ Address: c.Address,
+ Username: c.Username,
+ Password: c.Password,
+ Timeout: c.Timeout,
+ Group: c.Group,
+ Namespace: c.Namespace,
+ Params: newParams,
+ ReportDefinition: reportDefinition,
}
}
diff --git a/internal/genericfield/tag.go b/internal/genericfield/tag.go
new file mode 100644
index 000000000..794009484
--- /dev/null
+++ b/internal/genericfield/tag.go
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Package genericfield defines the field-name contract shared by generic
+// value conversion and interface-level service definitions.
+package genericfield
+
+import (
+ "reflect"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+)
+
+// Tag is the interpreted form of a struct field's m tag.
+type Tag struct {
+ Name string
+ Ignore bool
+ OmitEmpty bool
+ Squash bool
+ UnknownOptions []string
+}
+
+// ParseMTag interprets the m tag options understood by dubbo-go's generic
+// conversion. Unknown options are preserved so schema producers can reject
+// options they cannot represent while value conversion remains compatible.
+func ParseMTag(field reflect.StructField) Tag {
+ tag := Tag{Name: DefaultName(field.Name)}
+ tagValue := field.Tag.Get("m")
+ name, options, hasOptions := strings.Cut(tagValue, ",")
+ if name == "-" {
+ tag.Ignore = true
+ return tag
+ }
+ if name != "" {
+ tag.Name = name
+ }
+ if !hasOptions {
+ return tag
+ }
+
+ for option := range strings.SplitSeq(options, ",") {
+ switch option {
+ case "":
+ case "omitempty":
+ tag.OmitEmpty = true
+ case "squash":
+ tag.Squash = true
+ default:
+ tag.UnknownOptions = append(tag.UnknownOptions, option)
+ }
+ }
+ return tag
+}
+
+// DefaultName returns the generic wire name for an untagged Go field.
+func DefaultName(name string) string {
+ if name == "" {
+ return name
+ }
+ first, size := utf8.DecodeRuneInString(name)
+ return string(unicode.ToLower(first)) + name[size:]
+}
diff --git a/internal/genericfield/tag_test.go b/internal/genericfield/tag_test.go
new file mode 100644
index 000000000..a28f640ca
--- /dev/null
+++ b/internal/genericfield/tag_test.go
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package genericfield_test
+
+import (
+ "reflect"
+ "testing"
+)
+
+import (
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+import (
+ "dubbo.apache.org/dubbo-go/v3/internal/genericfield"
+)
+
+type taggedFields struct {
+ Ünicode string
+ Named string `m:"display_name"`
+ Optional string `m:"optional,omitempty"`
+ Ignored string `m:"-"`
+ Embedded struct{} `m:",squash"`
+ Remaining map[string]string `m:",remain"`
+}
+
+func TestParseMTagDefinesTheSharedGenericFieldContract(t *testing.T) {
+ typ := reflect.TypeFor[taggedFields]()
+ tests := []struct {
+ field string
+ want genericfield.Tag
+ }{
+ {"Ünicode", genericfield.Tag{Name: "ünicode"}},
+ {"Named", genericfield.Tag{Name: "display_name"}},
+ {"Optional", genericfield.Tag{Name: "optional", OmitEmpty: true}},
+ {"Ignored", genericfield.Tag{Name: "ignored", Ignore: true}},
+ {"Embedded", genericfield.Tag{Name: "embedded", Squash: true}},
+ {"Remaining", genericfield.Tag{Name: "remaining", UnknownOptions: []string{"remain"}}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.field, func(t *testing.T) {
+ field, ok := typ.FieldByName(tt.field)
+ require.True(t, ok)
+ assert.Equal(t, tt.want, genericfield.ParseMTag(field))
+ })
+ }
+}
diff --git a/metadata/definition/builder.go b/metadata/definition/builder.go
new file mode 100644
index 000000000..14747240b
--- /dev/null
+++ b/metadata/definition/builder.go
@@ -0,0 +1,194 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package definition
+
+import (
+ "fmt"
+ "reflect"
+ "sort"
+)
+
+import (
+ perrors "github.com/pkg/errors"
+)
+
+import (
+ "dubbo.apache.org/dubbo-go/v3/common"
+)
+
+// SkippedMethod records a method the builder declined to publish.
+//
+// The MVP expresses "unsupported" by omission: the method simply is not in the
+// definition, so Admin never offers it as a callable operation. Publishing it
+// with a flag would need a new Admin proto field, which is deferred. These
+// records exist so the omission is at least visible in provider logs.
+type SkippedMethod struct {
+ Name string
+ Reason string
+}
+
+// BuildFromURL builds the interface-level definition for one exported service.
+//
+// svcType is the reflect type of the service handler as registered in
+// common.ServiceMap, and u is the fully post-processed export URL. Every
+// identifying field — interface, version, group — comes from u rather than from
+// reflection, so the definition, the instance registration, and Admin's
+// BuildServiceKey all describe the same service. Re-deriving the interface name
+// here would silently diverge whenever a config post-processor rewrote the URL.
+//
+// The returned skips explain each omitted method; they are diagnostics, not
+// errors. A hard error is returned only when the service cannot be described at
+// all.
+func BuildFromURL(u *common.URL, svcType reflect.Type) (*ServiceDefinition, []SkippedMethod, error) {
+ if u == nil {
+ return nil, nil, perrors.New("cannot build a service definition from a nil URL")
+ }
+ if svcType == nil {
+ return nil, nil, perrors.New("cannot build a service definition from a nil service type")
+ }
+ canonicalName := u.Interface()
+ if canonicalName == "" {
+ return nil, nil, perrors.New("cannot build a service definition without an interface name")
+ }
+
+ canonical := common.CanonicalMethods(svcType)
+ conflicted := conflictedMethods(canonical)
+
+ types := newTypeCollector()
+ methods := make([]MethodDefinition, 0, len(canonical))
+ var skips []SkippedMethod
+
+ for _, m := range canonical {
+ if reason, dropped := conflicted[m.GoName]; dropped {
+ skips = append(skips, SkippedMethod{Name: m.Name, Reason: reason})
+ continue
+ }
+
+ // Resolve into a staging collector so a rejection partway through does
+ // not leave this method's already-resolved types behind.
+ staging := newTypeCollector()
+ method, err := buildMethod(m, staging)
+ if err != nil {
+ if !IsUnsupported(err) {
+ return nil, nil, perrors.WithMessagef(err, "building method %q", m.Name)
+ }
+ skips = append(skips, SkippedMethod{Name: m.Name, Reason: err.Error()})
+ continue
+ }
+
+ types.merge(staging)
+ methods = append(methods, *method)
+ }
+
+ // Sort by name so restarting a provider republishes byte-identical content.
+ // Reflection's method order is already stable, but MethodMapper can rename
+ // methods into a different order, and idempotent republishing is what keeps
+ // the metadata center from churning on every deploy.
+ sort.Slice(methods, func(i, j int) bool { return methods[i].Name < methods[j].Name })
+ sort.Slice(skips, func(i, j int) bool { return skips[i].Name < skips[j].Name })
+
+ return &ServiceDefinition{
+ CanonicalName: canonicalName,
+ Methods: methods,
+ Parameters: urlParameters(u),
+ Types: types.definitions(),
+ }, skips, nil
+}
+
+// buildMethod converts one canonical method into its published form.
+func buildMethod(m common.CanonicalMethod, types *typeCollector) (*MethodDefinition, error) {
+ if m.Method.IsVariadic() {
+ // dubbo-go exports variadic methods with only a warning
+ // (WarnRPCMethods), so they do reach this point. They are
+ // excluded here because a variadic tail has no fixed arity to publish:
+ // the generic path packs a variable number of trailing args into the
+ // slice, which no fixed parameterTypes list can describe.
+ return nil, unsupported(m.Name, "variadic methods have no fixed parameter arity")
+ }
+
+ // ArgsType is exactly the generic-invocation arity: genericServiceFilter
+ // requires len(args) == len(ArgsType) for a non-variadic method. Note this
+ // includes the trailing output pointer of the older
+ // func(ctx, req, resp) error style, because a generic caller genuinely has
+ // to supply a slot for it.
+ argsType := m.Method.ArgsType()
+ parameterTypes := make([]string, 0, len(argsType))
+
+ for _, arg := range argsType {
+ expr, err := types.resolve(arg)
+ if err != nil {
+ return nil, err
+ }
+ parameterTypes = append(parameterTypes, expr)
+ }
+
+ returnType := VoidReturnType
+ if reply := m.Method.ReplyType(); reply != nil {
+ expr, err := types.resolve(reply)
+ if err != nil {
+ return nil, err
+ }
+ returnType = expr
+ }
+
+ return &MethodDefinition{
+ Name: m.Name,
+ ParameterTypes: parameterTypes,
+ ReturnType: returnType,
+ }, nil
+}
+
+// conflictedMethods maps the Go name of every method involved in a wire-name
+// collision to an explanation.
+//
+// Both sides of a collision are dropped rather than picking a winner. At runtime
+// the last registration wins, but that order is an implementation detail of map
+// insertion; publishing either name would advertise a contract that routes
+// somewhere the reader cannot predict.
+func conflictedMethods(methods []common.CanonicalMethod) map[string]string {
+ conflicts := common.MethodNameConflicts(methods)
+ if len(conflicts) == 0 {
+ return nil
+ }
+ dropped := make(map[string]string, len(conflicts)*2)
+ for _, c := range conflicts {
+ reason := fmt.Sprintf(
+ "methods %s and %s are both routable as %q; neither can be published unambiguously",
+ c.First, c.Second, c.WireName)
+ dropped[c.First] = reason
+ dropped[c.Second] = reason
+ }
+ return dropped
+}
+
+// urlParameters flattens the export URL's parameters, matching Java's
+// serviceDefinition.setParameters(url.getParameters()).
+//
+// Everything is copied, not a curated subset: Admin reads application, release,
+// version and group from here, and extra keys are inert. Notably no "language"
+// key is added — Admin already identifies Go providers by the "dubbo-golang-"
+// prefix on release, and inventing a Go-only parameter would fork the provider
+// metadata dialect.
+func urlParameters(u *common.URL) map[string]string {
+ values := u.GetParams()
+ params := make(map[string]string, len(values))
+ for key := range values {
+ params[key] = values.Get(key)
+ }
+ return params
+}
diff --git a/metadata/definition/builder_test.go b/metadata/definition/builder_test.go
new file mode 100644
index 000000000..48d5e5b6b
--- /dev/null
+++ b/metadata/definition/builder_test.go
@@ -0,0 +1,599 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package definition
+
+import (
+ "context"
+ "reflect"
+ "testing"
+ "time"
+)
+
+import (
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+import (
+ "dubbo.apache.org/dubbo-go/v3/common"
+ "dubbo.apache.org/dubbo-go/v3/common/constant"
+)
+
+// ---------------------------------------------------------------------------
+// fixtures
+// ---------------------------------------------------------------------------
+type Address struct {
+ City string
+ Zip string `m:"zipCode"`
+ hidden string //nolint:unused // asserts unexported fields stay out of the contract
+}
+
+type User struct {
+ Name string
+ Age int32
+ Home *Address
+ Tags []string
+ Scores map[string]float64
+ Quadrant [4]int
+}
+
+// Node is self-referential, to prove the collector terminates on cycles.
+type Node struct {
+ Label string
+ Next *Node
+}
+
+type basicService struct{}
+
+func (s *basicService) GetUser(ctx context.Context, id string) (*User, error) {
+ return nil, nil
+}
+
+// Ping returns only an error, so its returnType must be the explicit void marker.
+func (s *basicService) Ping(ctx context.Context) error { return nil }
+
+// NoContext omits context.Context entirely; the receiver is still skipped.
+func (s *basicService) NoContext(a string, b int64) (bool, error) { return false, nil }
+
+// Walk exercises the recursive type.
+func (s *basicService) Walk(ctx context.Context, n *Node) (*Node, error) { return nil, nil }
+
+type variadicService struct{}
+
+func (s *variadicService) Sum(ctx context.Context, nums ...int) (int, error) { return 0, nil }
+func (s *variadicService) Fine(ctx context.Context, n int) (int, error) { return 0, nil }
+
+type chanService struct{}
+
+func (s *chanService) Stream(ctx context.Context, ch chan string) error { return nil }
+func (s *chanService) Fine(ctx context.Context, n int) (int, error) { return 0, nil }
+
+type intMapService struct{}
+
+func (s *intMapService) Lookup(ctx context.Context, m map[int]string) error { return nil }
+
+type timeService struct{}
+
+func (s *timeService) At(ctx context.Context, t time.Time) error { return nil }
+
+type ifaceService struct{}
+
+func (s *ifaceService) Any(ctx context.Context, v any) error { return nil }
+
+// mapperService renames methods; only the mapped name may be published.
+type mapperService struct{}
+
+func (s *mapperService) MethodMapper() map[string]string {
+ return map[string]string{"OriginalName": "renamed"}
+}
+func (s *mapperService) OriginalName(ctx context.Context, a string) error { return nil }
+
+// collidingService maps two distinct methods onto first-rune-case variants of
+// one another, so their runtime wire-name sets intersect.
+type collidingService struct{}
+
+func (s *collidingService) MethodMapper() map[string]string {
+ return map[string]string{"Alpha": "echo", "Beta": "Echo"}
+}
+func (s *collidingService) Alpha(ctx context.Context, a string) error { return nil }
+func (s *collidingService) Beta(ctx context.Context, a string) error { return nil }
+func (s *collidingService) Gamma(ctx context.Context, a string) error { return nil }
+
+// ---------------------------------------------------------------------------
+// helpers
+// ---------------------------------------------------------------------------
+
+func testURL(t *testing.T, iface, version, group string) *common.URL {
+ t.Helper()
+ opts := []common.Option{
+ common.WithProtocol("dubbo"),
+ common.WithIp("127.0.0.1"),
+ common.WithPort("20000"),
+ common.WithParamsValue(constant.InterfaceKey, iface),
+ common.WithParamsValue(constant.ApplicationKey, "test-app"),
+ common.WithParamsValue(constant.ReleaseKey, "dubbo-golang-3.3.0"),
+ common.WithParamsValue(constant.SideKey, "provider"),
+ }
+ if version != "" {
+ opts = append(opts, common.WithParamsValue(constant.VersionKey, version))
+ }
+ if group != "" {
+ opts = append(opts, common.WithParamsValue(constant.GroupKey, group))
+ }
+ return common.NewURLWithOptions(opts...)
+}
+
+func build(t *testing.T, svc any) (*ServiceDefinition, []SkippedMethod) {
+ t.Helper()
+ def, skips, err := BuildFromURL(testURL(t, "org.example.Svc", "", ""), reflect.TypeOf(svc))
+ require.NoError(t, err)
+ return def, skips
+}
+
+func methodByName(t *testing.T, def *ServiceDefinition, name string) MethodDefinition {
+ t.Helper()
+ for _, m := range def.Methods {
+ if m.Name == name {
+ return m
+ }
+ }
+ t.Fatalf("method %q not found in %+v", name, def.Methods)
+ return MethodDefinition{}
+}
+
+func typeByName(t *testing.T, def *ServiceDefinition, name string) TypeDefinition {
+ t.Helper()
+ for _, ty := range def.Types {
+ if ty.Type == name {
+ return ty
+ }
+ }
+ t.Fatalf("type %q not found; have %v", name, typeNames(def))
+ return TypeDefinition{}
+}
+
+func typeNames(def *ServiceDefinition) []string {
+ names := make([]string, 0, len(def.Types))
+ for _, ty := range def.Types {
+ names = append(names, ty.Type)
+ }
+ return names
+}
+
+func skipReasons(skips []SkippedMethod) map[string]string {
+ out := make(map[string]string, len(skips))
+ for _, s := range skips {
+ out[s.Name] = s.Reason
+ }
+ return out
+}
+
+// ---------------------------------------------------------------------------
+// signature trimming
+// ---------------------------------------------------------------------------
+
+func TestBuildTrimsReceiverAndContext(t *testing.T) {
+ def, _ := build(t, &basicService{})
+
+ get := methodByName(t, def, "GetUser")
+ assert.Equal(t, []string{"java.lang.String"}, get.ParameterTypes,
+ "receiver and leading context.Context must not appear as parameters")
+
+ noCtx := methodByName(t, def, "NoContext")
+ assert.Equal(t, []string{"java.lang.String", "long"}, noCtx.ParameterTypes,
+ "a method without context.Context keeps all its declared parameters")
+}
+
+func TestBuildReturnTypes(t *testing.T) {
+ def, _ := build(t, &basicService{})
+
+ // *User is published as User: the pointer only says the value may be
+ // absent, which Java reads off the type being a reference type.
+ assert.Equal(t, userType, methodByName(t, def, "GetUser").ReturnType)
+ assert.Equal(t, "boolean", methodByName(t, def, "NoContext").ReturnType)
+
+ // An error-only method must be distinguishable from a failed resolution.
+ ping := methodByName(t, def, "Ping")
+ assert.Equal(t, VoidReturnType, ping.ReturnType)
+ assert.NotEmpty(t, ping.ReturnType)
+ assert.Empty(t, ping.ParameterTypes)
+}
+
+// ---------------------------------------------------------------------------
+// type expression and container entries
+// ---------------------------------------------------------------------------
+
+const (
+ userType = "dubbo.apache.org/dubbo-go/v3/metadata/definition.User"
+ addrType = "dubbo.apache.org/dubbo-go/v3/metadata/definition.Address"
+ nodeType = "dubbo.apache.org/dubbo-go/v3/metadata/definition.Node"
+)
+
+func TestBuildScalarsUseJavaSpelling(t *testing.T) {
+ // The contract speaks Java's type vocabulary, which is the vocabulary
+ // dubbo-go's own generic runtime matches against.
+ def, _ := build(t, &basicService{})
+
+ user := typeByName(t, def, userType)
+ assert.Equal(t, "java.lang.String", user.Properties["name"])
+ assert.Equal(t, "int", user.Properties["age"], "Go int32 is Java int")
+}
+
+func TestBuildPointerBecomesWrapperOrReference(t *testing.T) {
+ def, _ := build(t, &basicService{})
+
+ // A pointer to a struct is just the struct: Java objects are already
+ // nullable, so there is nothing extra to say.
+ assert.Equal(t, addrType, typeByName(t, def, userType).Properties["home"])
+ for _, ty := range def.Types {
+ assert.NotContains(t, ty.Type, "*", "pointers must not survive into type names")
+ }
+
+ // A pointer to a scalar boxes it, which is exactly how Java distinguishes
+ // a nullable value from a primitive.
+ c := newTypeCollector()
+ expr, err := c.resolve(reflect.TypeFor[*int64]())
+ require.NoError(t, err)
+ assert.Equal(t, "java.lang.Long", expr)
+
+ expr, err = c.resolve(reflect.TypeFor[int64]())
+ require.NoError(t, err)
+ assert.Equal(t, "long", expr)
+}
+
+// TestBuildContainerArity is the distinction consumers rely on: Java's
+// MapTypeBuilder appends one entry per actual type argument, so a collection
+// carries one item and a map carries two. Publishing a map with a single item
+// would have it read as an array of that item.
+func TestBuildContainerArity(t *testing.T) {
+ def, _ := build(t, &basicService{})
+
+ user := typeByName(t, def, userType)
+ assert.Equal(t, "java.lang.String[]", user.Properties["tags"])
+ assert.Equal(t, "java.util.Map<java.lang.String,java.lang.Double>", user.Properties["scores"])
+ assert.Equal(t, "long[]", user.Properties["quadrant"], "a Go array has no length in Java")
+
+ list := typeByName(t, def, "java.lang.String[]")
+ require.Len(t, list.Items, 1, "a collection carries exactly one item")
+ assert.Equal(t, []string{"java.lang.String"}, list.Items)
+
+ m := typeByName(t, def, "java.util.Map<java.lang.String,java.lang.Double>")
+ require.Len(t, m.Items, 2, "a map carries key and value")
+ assert.Equal(t, []string{"java.lang.String", "java.lang.Double"}, m.Items,
+ "the value is boxed because Java generics admit only reference types")
+}
+
+func TestBuildUint8AndByteSliceUseTheirJavaWireTypes(t *testing.T) {
+ // A scalar uint8 needs Java short to keep 0..255 representable. A byte slice
+ // is different: Hessian writes it as Java byte[]/[B, so the container keeps
+ // that wire identity and realization translates signed bytes back to Go.
+ c := newTypeCollector()
+
+ expr, err := c.resolve(reflect.TypeFor[uint8]())
+ require.NoError(t, err)
+ assert.Equal(t, "short", expr)
+
+ expr, err = c.resolve(reflect.TypeFor[[]byte]())
+ require.NoError(t, err)
+ assert.Equal(t, "byte[]", expr)
+ assert.Equal(t, []string{"byte"}, c.defs["byte[]"].Items)
+}
+
+func TestBuildRejectsUnsigned64(t *testing.T) {
+ // No Java integer type holds the range, and publishing the existing
+ // hessian helper's non-Java "unsigned long" would name nothing.
+ for _, typ := range []reflect.Type{
+ reflect.TypeFor[uint64](),
+ reflect.TypeFor[uint](),
+ } {
+ _, err := newTypeCollector().resolve(typ)
+ require.Error(t, err, "%s", typ)
+ assert.True(t, IsUnsupported(err))
+ assert.Contains(t, err.Error(), "exceed the range")
+ }
+}
+
+func TestBuildWidensSmallUnsigned(t *testing.T) {
+ // Widening keeps the whole Go range representable; realization validates
+ // both bounds of the narrower Go target rather than wrapping.
+ for typ, want := range map[reflect.Type]string{
+ reflect.TypeFor[uint8](): "short",
+ reflect.TypeFor[uint16](): "int",
+ reflect.TypeFor[uint32](): "long",
+ } {
+ expr, err := newTypeCollector().resolve(typ)
+ require.NoError(t, err, "%s", typ)
+ assert.Equal(t, want, expr, "%s", typ)
+ }
+}
+
+func TestBuildUsesDeclaredJavaClassName(t *testing.T) {
+ // A type that declares a Java class name is published under it: that is the
+ // name hessian2 writes into the wire form and the name a Java peer knows.
+ c := newTypeCollector()
+ expr, err := c.resolve(reflect.TypeFor[javaNamed]())
+ require.NoError(t, err)
+ assert.Equal(t, "com.example.Named", expr)
+ assert.Equal(t, map[string]string{"label": "java.lang.String"}, c.defs[expr].Properties)
+}
+
+func TestBuildStructPropertiesFollowGeneralizerNaming(t *testing.T) {
+ def, _ := build(t, &basicService{})
+
+ addr := typeByName(t, def, addrType)
+ assert.Equal(t, map[string]string{
+ "city": "java.lang.String", // no tag: first rune lowercased
+ "zipCode": "java.lang.String", // m tag wins verbatim
+ }, addr.Properties)
+ assert.NotContains(t, addr.Properties, "hidden", "unexported fields are not on the wire")
+ assert.NotContains(t, addr.Properties, "Zip", "the Go name is not the wire name when a tag exists")
+}
+
+func TestBuildRecursiveTypeTerminates(t *testing.T) {
+ def, _ := build(t, &basicService{})
+
+ node := typeByName(t, def, nodeType)
+ assert.Equal(t, map[string]string{
+ "label": "java.lang.String",
+ "next": nodeType,
+ }, node.Properties, "the cycle is preserved as a reference, not expanded")
+}
+
+func TestBuildNamedScalarUsesUnderlyingType(t *testing.T) {
+ // A named scalar travels the generic wire as its underlying value, so the
+ // contract names that rather than the Go type name.
+ c := newTypeCollector()
+ expr, err := c.resolve(reflect.TypeFor[time.Month]())
+ require.NoError(t, err)
+ assert.Equal(t, "long", expr, "Go int is 64-bit here, matching getBasicJavaName")
+}
+
+// ---------------------------------------------------------------------------
+// canonical names and conflicts
+// ---------------------------------------------------------------------------
+
+func TestBuildCanonicalNameComesFromURL(t *testing.T) {
+ u := testURL(t, "org.example.UserService", "1.0.0", "g1")
+ def, _, err := BuildFromURL(u, reflect.TypeFor[*basicService]())
+ require.NoError(t, err)
+
+ assert.Equal(t, "org.example.UserService", def.CanonicalName,
+ "the interface name is taken from the URL, never re-derived from the struct")
+ assert.Equal(t, "1.0.0", def.Parameters[constant.VersionKey])
+ assert.Equal(t, "g1", def.Parameters[constant.GroupKey])
+ assert.Equal(t, "test-app", def.Parameters[constant.ApplicationKey])
+ assert.Equal(t, "dubbo-golang-3.3.0", def.Parameters[constant.ReleaseKey],
+ "Admin identifies Go providers by this prefix")
+ assert.NotContains(t, def.Parameters, "language", "no Go-only metadata dialect")
+}
+
+func TestBuildPublishesMappedNameOnceWithoutAlias(t *testing.T) {
+ def, _ := build(t, &mapperService{})
+
+ names := make([]string, 0, len(def.Methods))
+ for _, m := range def.Methods {
+ names = append(names, m.Name)
+ }
+ assert.Equal(t, []string{"renamed"}, names,
+ "the mapped name is published once; neither the Go name nor the swapped-case alias appears")
+}
+
+func TestBuildDropsBothSidesOfANameCollision(t *testing.T) {
+ def, skips := build(t, &collidingService{})
+
+ names := make([]string, 0, len(def.Methods))
+ for _, m := range def.Methods {
+ names = append(names, m.Name)
+ }
+ assert.Equal(t, []string{"Gamma"}, names,
+ "echo and Echo collide through the alias mechanism; neither may be published")
+
+ reasons := skipReasons(skips)
+ assert.Contains(t, reasons, "echo")
+ assert.Contains(t, reasons, "Echo")
+ assert.Contains(t, reasons["echo"], "routable")
+}
+
+func TestRuntimeStillRegistersCollidingMethods(t *testing.T) {
+ // The builder refuses to publish a collision, but registration must keep
+ // working so existing services still start after an upgrade.
+ methods := common.CanonicalMethods(reflect.TypeFor[*collidingService]())
+ conflicts := common.MethodNameConflicts(methods)
+ require.Len(t, conflicts, 1)
+ assert.Equal(t, "Echo", conflicts[0].WireName)
+}
+
+// ---------------------------------------------------------------------------
+// unsupported types
+// ---------------------------------------------------------------------------
+
+func TestBuildRejectsUnsupportedMethods(t *testing.T) {
+ cases := []struct {
+ name string
+ svc any
+ method string
+ wantMsg string
+ }{
+ {"variadic", &variadicService{}, "Sum", "variadic"},
+ {"chan", &chanService{}, "Stream", "cannot cross an RPC boundary"},
+ {"non-string map key", &intMapService{}, "Lookup", "string-keyed"},
+ {"time.Time", &timeService{}, "At", "time.Time"},
+ {"empty interface", &ifaceService{}, "Any", "no declarable structure"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ def, skips := build(t, tc.svc)
+
+ for _, m := range def.Methods {
+ assert.NotEqual(t, tc.method, m.Name, "unsupported method must not be published")
+ }
+ reasons := skipReasons(skips)
+ require.Contains(t, reasons, tc.method)
+ assert.Contains(t, reasons[tc.method], tc.wantMsg)
+ })
+ }
+}
+
+func TestBuildKeepsSupportedSiblingsOfRejectedMethods(t *testing.T) {
+ // One bad method must not take the whole service down with it.
+ def, skips := build(t, &variadicService{})
+
+ assert.Equal(t, []string{"Fine"}, []string{def.Methods[0].Name})
+ assert.Len(t, def.Methods, 1)
+ assert.Contains(t, skipReasons(skips), "Sum")
+}
+
+func TestRejectedMethodLeavesNoOrphanTypes(t *testing.T) {
+ def, _ := build(t, &chanService{})
+ assert.NotContains(t, typeNames(def), "chan string")
+}
+
+// ---------------------------------------------------------------------------
+// generic field names
+// ---------------------------------------------------------------------------
+
+type SquashedFields struct {
+ City string `m:"city_name"`
+}
+
+type SupportedFieldShape struct {
+ Name string `m:"display_name,omitempty"`
+ Drop string `m:"-"`
+ Embedded SquashedFields `m:",squash"`
+ Ünicode string
+}
+
+type supportedFieldService struct{}
+
+func (s *supportedFieldService) Save(ctx context.Context, value SupportedFieldShape) error {
+ return nil
+}
+
+type PointerSquashedFieldShape struct {
+ Embedded *SquashedFields `m:",squash"`
+}
+
+type pointerSquashedFieldService struct{}
+
+func (s *pointerSquashedFieldService) Save(ctx context.Context, value PointerSquashedFieldShape) error {
+ return nil
+}
+
+type RemainingFields struct {
+ Extra map[string]string `m:",remain"`
+}
+
+type remainingFieldService struct{}
+
+func (s *remainingFieldService) Save(ctx context.Context, value RemainingFields) error {
+ return nil
+}
+
+type AliasColliding struct {
+ Name string
+ Other string `m:"name"`
+}
+
+type collidingFieldService struct{}
+
+func (s *collidingFieldService) Save(ctx context.Context, value AliasColliding) error {
+ return nil
+}
+
+func TestBuildMatchesSupportedGenericFieldNames(t *testing.T) {
+ def, skips := build(t, &supportedFieldService{})
+ require.Empty(t, skips)
+
+ method := methodByName(t, def, "Save")
+ require.Len(t, method.ParameterTypes, 1)
+ shape := typeByName(t, def, method.ParameterTypes[0])
+ assert.Equal(t, map[string]string{
+ "city_name": "java.lang.String", // squash flattens the embedded shape
+ "display_name": "java.lang.String", // omitempty affects values, not the schema
+ "ünicode": "java.lang.String", // untagged names lowercase the first rune
+ }, shape.Properties)
+}
+
+func TestBuildRejectsUnrepresentableGenericFieldNames(t *testing.T) {
+ tests := []struct {
+ name string
+ svc any
+ wantMsg string
+ }{
+ {"nil pointer squash", &pointerSquashedFieldService{}, "pointer"},
+ {"remain option", &remainingFieldService{}, "remain"},
+ {"canonical/legacy collision", &collidingFieldService{}, "both reachable as"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ def, skips := build(t, tt.svc)
+ assert.Empty(t, def.Methods)
+ reasons := skipReasons(skips)
+ require.Contains(t, reasons, "Save")
+ assert.Contains(t, reasons["Save"], tt.wantMsg)
+ })
+ }
+}
+
+func TestUnsupportedIsDistinguishableFromInternalError(t *testing.T) {
+ _, err := newTypeCollector().resolve(reflect.TypeFor[chan int]())
+ require.Error(t, err)
+ assert.True(t, IsUnsupported(err))
+}
+
+// ---------------------------------------------------------------------------
+// determinism
+// ---------------------------------------------------------------------------
+
+func TestBuildIsDeterministic(t *testing.T) {
+ // Republishing identical content on every restart is what keeps the
+ // metadata center from churning, so map iteration must not leak into output.
+ first, _, err := BuildFromURL(testURL(t, "org.example.Svc", "", ""), reflect.TypeFor[*basicService]())
+ require.NoError(t, err)
+
+ for range 20 {
+ next, _, err := BuildFromURL(testURL(t, "org.example.Svc", "", ""), reflect.TypeFor[*basicService]())
+ require.NoError(t, err)
+ assert.Equal(t, first.Types, next.Types)
+ assert.Equal(t, first.Methods, next.Methods)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// guards
+// ---------------------------------------------------------------------------
+
+func TestBuildRejectsUnusableInput(t *testing.T) {
+ _, _, err := BuildFromURL(nil, reflect.TypeFor[*basicService]())
+ require.Error(t, err)
+
+ _, _, err = BuildFromURL(testURL(t, "org.example.Svc", "", ""), nil)
+ require.Error(t, err)
+
+ _, _, err = BuildFromURL(testURL(t, "", "", ""), reflect.TypeFor[*basicService]())
+ require.Error(t, err, "an empty interface name cannot identify a definition")
+}
+
+// javaNamed declares a Java class name, as a hessian POJO does.
+type javaNamed struct {
+ Label string
+}
+
+func (javaNamed) JavaClassName() string { return "com.example.Named" }
diff --git a/metadata/definition/definition.go b/metadata/definition/definition.go
new file mode 100644
index 000000000..2c6619982
--- /dev/null
+++ b/metadata/definition/definition.go
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Package definition builds and publishes interface-level service definitions.
+//
+// A service definition describes the RPC contract of a single exported service
+// interface: its methods, their signatures, and the structure of every type
+// reachable from those signatures. Dubbo Admin consumes these definitions to
+// render service documentation and to build generic-invocation request schemas.
+//
+// The JSON produced here is wire-compatible with Java's FullServiceDefinition
+// (dubbo-common/.../definition/model/), because Admin deserializes both into the
+// same ServiceProviderMetadata structure. Nothing in this package imports Admin
+// code — the compatibility contract is the JSON shape alone, pinned by the
+// golden tests in json_test.go.
+package definition
+
+// ServiceDefinition is the interface-level contract published for one exported
+// service. It mirrors Java's FullServiceDefinition.
+//
+// Java's codeSource field is deliberately omitted: Admin never reads it, and Go
+// has no equivalent notion of a class file origin.
+type ServiceDefinition struct {
+ // CanonicalName is the service interface name, always taken verbatim from
+ // the exported URL's Interface(). See BuildFromURL for why this must not be
+ // re-derived by reflection.
+ CanonicalName string `json:"canonicalName"`
+ // Methods holds one entry per canonical method the builder considers safe to
+ // expose. This is not the same set as Parameters["methods"]; see Parameters.
+ Methods []MethodDefinition `json:"methods"`
+ // Parameters carries the full provider URL parameter map, matching Java's
+ // serviceDefinition.setParameters(url.getParameters()).
+ //
+ // Parameters["methods"] is the runtime method set from the exported URL and
+ // includes the SwapCaseFirstRune aliases dubbo-go registers for Java
+ // interop. Methods above holds only canonical names. The two intentionally
+ // differ in size; consumers must not assume they match.
+ Parameters map[string]string `json:"parameters"`
+ // Types holds one entry for every composite type reachable from a method
+ // signature, including struct, slice, array and map shapes. Pointers express
+ // nullability and are folded into Java reference/wrapper spellings.
+ Types []TypeDefinition `json:"types"`
+}
+
+// MethodDefinition describes a single RPC method's signature.
+type MethodDefinition struct {
+ // Name is the canonical wire name: the MethodMapper mapping when one exists,
+ // otherwise the Go exported method name. The SwapCaseFirstRune alias is
+ // never published here even though it is routable at runtime.
+ Name string `json:"name"`
+ // ParameterTypes holds the type expression of each parameter, in order,
+ // excluding the receiver and a leading context.Context. Java's deprecated
+ // MethodDefinition.parameters field is deliberately omitted: it contains
+ // TypeDefinition values, not source parameter names, and consumers derive
+ // positional argN labels from this field when names are unavailable.
+ ParameterTypes []string `json:"parameterTypes"`
+ // ReturnType is the type expression of the non-error return value, or
+ // VoidReturnType for a method that returns only error.
+ ReturnType string `json:"returnType"`
+}
+
+// TypeDefinition describes one type reachable from a method signature.
+//
+// The three optional fields are mutually exclusive in practice: Properties for
+// structs, Items for slice/array/map containers, Enums for enumerated
+// values (never populated by Go, retained for Java JSON compatibility).
+type TypeDefinition struct {
+ // Type is the Java-vocabulary type expression used by the generic runtime.
+ // It is the key Admin uses to resolve a ParameterTypes or ReturnType entry,
+ // so it must match those strings byte for byte.
+ Type string `json:"type"`
+ // Items holds one element type for slices/arrays, or the key and value types
+ // for maps, matching Java TypeDefinition's generic-argument layout.
+ Items []string `json:"items,omitempty"`
+ // Enums is never populated by the Go builder. Go has no enum type that
+ // reflection can enumerate; named integer constants are indistinguishable
+ // from any other named integer at runtime.
+ Enums []string `json:"enums,omitempty"`
+ // Properties maps a struct field's wire name to its type expression. The
+ // wire name follows the same rule MapGeneralizer uses when serializing, so
+ // that a schema built from this map round-trips through generic invocation.
+ Properties map[string]string `json:"properties,omitempty"`
+}
+
+// VoidReturnType is the ReturnType of a method that returns only error.
+//
+// An empty string would be indistinguishable from "the builder failed to
+// resolve this type", so void gets an explicit marker. The spelling matches
+// Java, where a void method's returnType is literally "void".
+const VoidReturnType = "void"
diff --git a/metadata/definition/identifier.go b/metadata/definition/identifier.go
new file mode 100644
index 000000000..4b5d6e507
--- /dev/null
+++ b/metadata/definition/identifier.go
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package definition
+
+import (
+ "strings"
+)
+
+const (
+ // keySeparator matches Java's MetadataConstants.KEY_SEPARATOR.
+ keySeparator = ":"
+
+ // providerSide is the fixed side segment of the identifier. Consumer-side
+ // definitions are not published by dubbo-go.
+ providerSide = "provider"
+
+ // DefaultMetadataGroup is the default metadata-center group for
+ // interface-level definitions.
+ //
+ // Nacos reports override it with metadata-report.group when configured,
+ // matching Java's NacosMetadataReport. Dubbo Admin watches "dubbo" by
+ // default, so deployments choosing another group must configure the
+ // consumer side consistently.
+ DefaultMetadataGroup = "dubbo"
+)
+
+// DataID builds the metadata-center key for one interface-level definition.
+//
+// The layout matches Java's MetadataIdentifier.getUniqueKey(UNIQUE_KEY):
+//
+// {serviceInterface}:{version}:{group}:provider:{application}
+//
+// Empty segments are preserved, never collapsed, so a service with no version
+// and no group yields consecutive separators:
+//
+// org.example.UserService:::provider:app
+//
+// Admin matches on the "*:provider:*" substring and reconstructs every field
+// from the JSON body, so the dataId only has to be findable — but it still has
+// to be byte-identical to Java's, or the same service published by a Go and a
+// Java provider would occupy two different keys.
+func DataID(serviceInterface, version, group, application string) string {
+ return joinKey(serviceInterface, version, group, providerSide, application)
+}
+
+// joinKey concatenates parts with the Java separator, mirroring
+// KeyTypeEnum.build: every part contributes a segment unconditionally, and a
+// blank part contributes an empty one.
+//
+// Blank (not merely empty) is the Java test — StringUtils.isBlank treats a
+// whitespace-only value as absent — so a group of " " must produce the same key
+// as a group of "".
+func joinKey(parts ...string) string {
+ segments := make([]string, len(parts))
+ for i, part := range parts {
+ if strings.TrimSpace(part) == "" {
+ segments[i] = ""
+ continue
+ }
+ segments[i] = part
+ }
+ return strings.Join(segments, keySeparator)
+}
diff --git a/metadata/definition/identifier_test.go b/metadata/definition/identifier_test.go
new file mode 100644
index 000000000..f53fda859
--- /dev/null
+++ b/metadata/definition/identifier_test.go
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package definition
+
+import (
+ "strings"
+ "testing"
+)
+
+import (
+ "github.com/stretchr/testify/assert"
+)
+
+func TestDataIDMatchesJavaUniqueKey(t *testing.T) {
+ cases := []struct {
+ name string
+ args [4]string
+ want string
+ }{
+ {
+ name: "fully populated",
+ args: [4]string{"org.example.UserService", "1.0.0", "g1", "app"},
+ want: "org.example.UserService:1.0.0:g1:provider:app",
+ },
+ {
+ // Java's KeyTypeEnum.build appends a separator for every argument
+ // unconditionally, so empty version and group leave empty segments
+ // rather than collapsing.
+ name: "no version or group",
+ args: [4]string{"org.example.UserService", "", "", "app"},
+ want: "org.example.UserService:::provider:app",
+ },
+ {
+ name: "version only",
+ args: [4]string{"org.example.UserService", "1.0.0", "", "app"},
+ want: "org.example.UserService:1.0.0::provider:app",
+ },
+ {
+ name: "group only",
+ args: [4]string{"org.example.UserService", "", "g1", "app"},
+ want: "org.example.UserService::g1:provider:app",
+ },
+ {
+ // StringUtils.isBlank treats whitespace as absent on the Java side.
+ name: "whitespace is blank",
+ args: [4]string{"org.example.UserService", " ", "\t", "app"},
+ want: "org.example.UserService:::provider:app",
+ },
+ {
+ // The logical identifier is backend-neutral. The Nacos report applies
+ // Java's slash/inner-class normalization at its client boundary.
+ name: "go style interface name",
+ args: [4]string{"github.com/example/api.UserService", "", "", "app"},
+ want: "github.com/example/api.UserService:::provider:app",
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := DataID(tc.args[0], tc.args[1], tc.args[2], tc.args[3])
+ assert.Equal(t, tc.want, got)
+ })
+ }
+}
+
+func TestDataIDIsMatchedByAdminWatcher(t *testing.T) {
+ // Admin's Nacos watcher fuzzy-searches "*:provider:*" under the dubbo group.
+ // A dataId that does not contain that literal is invisible no matter how
+ // well-formed the JSON body is.
+ for _, version := range []string{"", "1.0.0"} {
+ for _, group := range []string{"", "g1"} {
+ id := DataID("org.example.UserService", version, group, "app")
+ assert.Contains(t, id, ":provider:",
+ "dataId must contain the substring Admin searches for")
+ }
+ }
+}
+
+func TestDataIDAlwaysHasFiveSegments(t *testing.T) {
+ // Admin does not parse the dataId, but Java's identifier is fixed-arity and
+ // divergence here would put a Go provider on a different key than a Java one
+ // exporting the same service.
+ id := DataID("org.example.UserService", "", "", "app")
+ assert.Len(t, strings.Split(id, ":"), 5)
+}
+
+func TestDefaultMetadataGroup(t *testing.T) {
+ assert.Equal(t, "dubbo", DefaultMetadataGroup)
+}
diff --git a/metadata/definition/json_test.go b/metadata/definition/json_test.go
new file mode 100644
index 000000000..79646c2c5
--- /dev/null
+++ b/metadata/definition/json_test.go
@@ -0,0 +1,115 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package definition
+
+import (
+ "encoding/json"
+ "sort"
+ "testing"
+)
+
+import (
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestJSONShapeMatchesJavaContract pins the wire format.
+//
+// Compatibility with Dubbo Admin rests entirely on these key names: Admin
+// deserializes both Java's FullServiceDefinition and this document into one
+// ServiceProviderMetadata structure. Renaming any key here silently makes Go
+// providers unreadable, and nothing else in the build would catch it.
+func TestJSONShapeMatchesJavaContract(t *testing.T) {
+ def := &ServiceDefinition{
+ CanonicalName: "org.example.UserService",
+ Methods: []MethodDefinition{{
+ Name: "getUser",
+ ParameterTypes: []string{"string"},
+ ReturnType: "*org.example.User",
+ }},
+ Parameters: map[string]string{
+ "application": "demo",
+ "interface": "org.example.UserService",
+ "release": "dubbo-golang-3.3.0",
+ "side": "provider",
+ },
+ Types: []TypeDefinition{
+ {Type: "*org.example.User", Items: []string{"org.example.User"}},
+ {Type: "org.example.User", Properties: map[string]string{"name": "string"}},
+ },
+ }
+
+ raw, err := json.Marshal(def)
+ require.NoError(t, err)
+
+ var decoded map[string]any
+ require.NoError(t, json.Unmarshal(raw, &decoded))
+
+ assert.Equal(t, []string{"canonicalName", "methods", "parameters", "types"}, sortedKeys(decoded))
+
+ method := decoded["methods"].([]any)[0].(map[string]any)
+ assert.Equal(t, []string{"name", "parameterTypes", "returnType"}, sortedKeys(method),
+ "Java's deprecated method-level parameters field is omitted; consumers use parameterTypes")
+
+ wrapper := decoded["types"].([]any)[0].(map[string]any)
+ assert.Equal(t, []string{"items", "type"}, sortedKeys(wrapper))
+
+ named := decoded["types"].([]any)[1].(map[string]any)
+ assert.Equal(t, []string{"properties", "type"}, sortedKeys(named))
+}
+
+// TestJSONOmitsEmptyOptionalTypeFields keeps documents compact and avoids
+// publishing empty enums/properties that would read as "this type has no
+// fields" rather than "this field does not apply".
+func TestJSONOmitsEmptyOptionalTypeFields(t *testing.T) {
+ raw, err := json.Marshal(TypeDefinition{Type: "string"})
+ require.NoError(t, err)
+ assert.JSONEq(t, `{"type":"string"}`, string(raw))
+}
+
+// TestJSONAlwaysEmitsRequiredTopLevelFields guards the other direction: a
+// service with no types must still present the key, so Admin's decoder does not
+// have to distinguish absent from empty.
+func TestJSONAlwaysEmitsRequiredTopLevelFields(t *testing.T) {
+ raw, err := json.Marshal(&ServiceDefinition{CanonicalName: "org.example.Empty"})
+ require.NoError(t, err)
+
+ var decoded map[string]any
+ require.NoError(t, json.Unmarshal(raw, &decoded))
+ assert.Equal(t, []string{"canonicalName", "methods", "parameters", "types"}, sortedKeys(decoded))
+}
+
+func TestJSONRoundTrip(t *testing.T) {
+ def, _ := build(t, &basicService{})
+
+ raw, err := json.Marshal(def)
+ require.NoError(t, err)
+
+ var back ServiceDefinition
+ require.NoError(t, json.Unmarshal(raw, &back))
+ assert.Equal(t, *def, back)
+}
+
+func sortedKeys(m map[string]any) []string {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
diff --git a/metadata/definition/types.go b/metadata/definition/types.go
new file mode 100644
index 000000000..5925981f9
--- /dev/null
+++ b/metadata/definition/types.go
@@ -0,0 +1,557 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package definition
+
+import (
+ "errors"
+ "fmt"
+ "reflect"
+ "sort"
+ "strings"
+)
+
+import (
+ "dubbo.apache.org/dubbo-go/v3/internal/genericfield"
+)
+
+// maxTypeDepth bounds structural nesting. The visited set already terminates
+// reference cycles, so this only catches pathologically deep composite types
+// (a [][][]...[]T built by code generation, say) before they exhaust the stack.
+const maxTypeDepth = 64
+
+// unsupportedError marks a type or method the builder refuses to publish.
+//
+// Refusing is deliberate: a schema that silently degrades an unrepresentable
+// type produces requests the provider cannot realize, and the failure surfaces
+// at call time on the Admin side rather than at publish time here. Java has no
+// equivalent problem — erasure yields an incomplete TypeDefinition, but Java has
+// no chan or func to describe in the first place.
+type unsupportedError struct {
+ subject string
+ reason string
+}
+
+func (e *unsupportedError) Error() string {
+ return fmt.Sprintf("%s is not supported: %s", e.subject, e.reason)
+}
+
+func unsupported(subject, reason string) error {
+ return &unsupportedError{subject: subject, reason: reason}
+}
+
+// IsUnsupported reports whether err marks a type or method the builder
+// deliberately declined to publish, as opposed to an internal failure.
+func IsUnsupported(err error) bool {
+ var target *unsupportedError
+ return errors.As(err, &target)
+}
+
+// blockedNamedTypes are named types the Generalizer or hessian2 codec treats
+// specially, but for which the MCP proposal has not yet defined a JSON wire
+// representation. Expanding them as plain structs would publish their internal
+// layout (time.Time's wall/ext/loc, big.Int's neg/abs) as if it were the
+// contract, which is worse than not publishing the method at all.
+//
+// Named scalars are deliberately absent: time.Duration is an int64 that
+// round-trips as a number like any other, so the scalar branch below handles it
+// correctly. Only types whose structure would be misrepresented belong here.
+var blockedNamedTypes = map[string]string{
+ "time.Time": "no JSON wire representation is defined for time.Time yet",
+ "math/big.Int": "no JSON wire representation is defined for math/big types yet",
+ "math/big.Float": "no JSON wire representation is defined for math/big types yet",
+ "math/big.Rat": "no JSON wire representation is defined for math/big types yet",
+ "encoding/json.RawMessage": "raw JSON has no declarable structure",
+}
+
+// Java type names used by the published contract.
+//
+// The definition speaks Java's type vocabulary rather than Go's. That is not a
+// concession to any particular consumer: it is the vocabulary dubbo-go's own
+// generic runtime already uses. filter/generic matches the caller-supplied
+// $invoke types against protocol/dubbo/hessian2.GetJavaName output when it
+// decides whether to unwrap a packed variadic tail, so a Go-spelled contract
+// would describe names that dubbo-go itself does not recognize.
+//
+// Struct names are the exception and stay Go-derived unless the type declares a
+// Java class name; see resolveStruct.
+const (
+ javaBoolean = "boolean"
+ javaByte = "byte"
+ javaShort = "short"
+ javaInt = "int"
+ javaLong = "long"
+ javaFloat = "float"
+ javaDouble = "double"
+ javaString = "java.lang.String"
+ javaMap = "java.util.Map"
+)
+
+// javaWrappers maps each primitive spelling to its boxed counterpart.
+//
+// Go's T versus *T is exactly Java's primitive versus wrapper distinction, and
+// consumers read nullability off that: a primitive rejects null, a reference
+// type accepts it. Expressing it through the type name means the contract needs
+// no separate nullability flag, which Provider metadata has never carried.
+var javaWrappers = map[string]string{
+ javaBoolean: "java.lang.Boolean",
+ javaByte: "java.lang.Byte",
+ javaShort: "java.lang.Short",
+ javaInt: "java.lang.Integer",
+ javaLong: "java.lang.Long",
+ javaFloat: "java.lang.Float",
+ javaDouble: "java.lang.Double",
+}
+
+// javaScalarName returns the Java spelling of a Go scalar.
+//
+// Unsigned kinds widen to the next signed type that holds their whole range.
+// The Java schema is then wider than the Go value on both sides; generic
+// realization validates the target Go bit width so negative or oversized
+// inputs fail instead of wrapping.
+//
+// uint and uint64 have no such landing spot: their range runs past Java's long.
+// They are refused rather than published under an invented name, which is what
+// the existing hessian helper does with its non-Java "unsigned long".
+func javaScalarName(t reflect.Type, nullable bool) (string, error) {
+ var primitive string
+ switch t.Kind() {
+ case reflect.Bool:
+ primitive = javaBoolean
+ case reflect.Int8:
+ primitive = javaByte
+ case reflect.Uint8:
+ // short, not byte. Java's byte is signed, so a consumer decoding
+ // against it accepts only -128..127 and rejects a Go uint8 of 128..255
+ // before the call ever leaves. Widening costs nothing and keeps the
+ // whole range callable.
+ //
+ // This also decides []byte, since []byte and []uint8 are one type in Go.
+ // Spelling it byte[] would match what hessian2 writes on the wire, but
+ // that alignment buys nothing when half the element values cannot be
+ // expressed in the schema either way.
+ primitive = javaShort
+ case reflect.Int16:
+ primitive = javaShort
+ case reflect.Uint16, reflect.Int32:
+ primitive = javaInt
+ case reflect.Uint32, reflect.Int, reflect.Int64:
+ primitive = javaLong
+ case reflect.Uint, reflect.Uint64:
+ return "", unsupported(t.String(),
+ "unsigned 64-bit integers exceed the range of every Java integer type")
+ case reflect.Float32:
+ primitive = javaFloat
+ case reflect.Float64:
+ primitive = javaDouble
+ case reflect.String:
+ // Already a reference type; there is no unboxed spelling to choose.
+ return javaString, nil
+ default:
+ return "", unsupported(t.String(), "not a scalar kind")
+ }
+
+ if nullable {
+ return javaWrappers[primitive], nil
+ }
+ return primitive, nil
+}
+
+// typeCollector resolves reflect.Types into type expressions and accumulates a
+// TypeDefinition for every composite type it walks through.
+type typeCollector struct {
+ defs map[string]*TypeDefinition
+}
+
+func newTypeCollector() *typeCollector {
+ return &typeCollector{defs: make(map[string]*TypeDefinition)}
+}
+
+// resolve returns the type expression for t, recording definitions for t and
+// everything reachable from it.
+func (c *typeCollector) resolve(t reflect.Type) (string, error) {
+ return c.resolveAt(t, 0)
+}
+
+func (c *typeCollector) resolveAt(t reflect.Type, depth int) (string, error) {
+ if t == nil {
+ return "", unsupported("<nil type>", "type is nil")
+ }
+ if depth > maxTypeDepth {
+ return "", unsupported(t.String(), fmt.Sprintf("type nesting exceeds %d levels", maxTypeDepth))
+ }
+
+ // Pointers carry nullability, not structure. Java expresses the same
+ // distinction with primitive versus wrapper class rather than in the type
+ // expression, so strip the indirection here and let the scalar branch pick
+ // the right spelling. For struct, list and map the wrapper form is already
+ // nullable, so the flag changes nothing.
+ nullable := false
+ for t.Kind() == reflect.Pointer {
+ t = t.Elem()
+ nullable = true
+ depth++
+ if depth > maxTypeDepth {
+ return "", unsupported(t.String(),
+ fmt.Sprintf("type nesting exceeds %d levels", maxTypeDepth))
+ }
+ }
+
+ if name := namedTypeKey(t); name != "" {
+ if reason, blocked := blockedNamedTypes[name]; blocked {
+ return "", unsupported(name, reason)
+ }
+ }
+
+ switch t.Kind() {
+ case reflect.Bool, reflect.String,
+ reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
+ reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
+ reflect.Float32, reflect.Float64:
+ // Scalars are published under their Java spelling even when the Go type
+ // is named. A `type UserID int64` travels the generic wire as a bare
+ // 64-bit integer, so publishing "UserID" would name something the caller
+ // can neither construct nor recognize.
+ return javaScalarName(t, nullable)
+
+ case reflect.Slice, reflect.Array:
+ // Array syntax, not List<T>. Java generics cannot hold a primitive, so
+ // List<byte> would not be a type anyone could write; the array form is
+ // valid for every element kind. It is also what
+ // protocol/dubbo/hessian2.GetJavaName produces, which keeps the
+ // published contract and the runtime's own spelling in agreement — and
+ // it is what makes a Go []byte land on Java's byte[], the form hessian2
+ // already puts on the wire.
+ //
+ // A Go array's length has no Java counterpart, so [N]T and []T are
+ // spelled the same.
+ // Go []byte is Hessian binary and Java byte[] regardless of uint8's
+ // scalar spelling. Scalar uint8 widens to short so 0..255 is directly
+ // representable; inside a byte container the wire identity wins, and the
+ // generic realizer preserves Java's signed byte bits on the way back.
+ if t.Elem().Kind() == reflect.Uint8 {
+ return c.recordContainer(javaByte)
+ }
+ return c.resolveContainer(t.Elem(), depth)
+
+ case reflect.Map:
+ if t.Key().Kind() != reflect.String {
+ return "", unsupported(t.String(),
+ "only string-keyed maps can be expressed as JSON objects")
+ }
+ // The key is fixed at String rather than resolved: a named string key
+ // still serializes as a JSON object key, and admitting map[UserID]T
+ // would imply a key schema JSON cannot carry.
+ return c.resolveMap(t.Elem(), depth)
+
+ case reflect.Struct:
+ return c.resolveStruct(t, depth)
+
+ case reflect.Interface:
+ // Including the empty interface would publish a field whose schema is
+ // "anything", which Admin cannot turn into a request form and the
+ // provider cannot realize into a concrete Go value.
+ return "", unsupported(t.String(), "interface types have no declarable structure")
+
+ case reflect.Chan, reflect.Func, reflect.UnsafePointer:
+ return "", unsupported(t.String(), "type cannot cross an RPC boundary")
+
+ case reflect.Complex64, reflect.Complex128:
+ return "", unsupported(t.String(), "complex numbers have no cross-language representation")
+
+ case reflect.Uintptr:
+ return "", unsupported(t.String(), "uintptr is a process-local address")
+
+ default:
+ return "", unsupported(t.String(), "unhandled reflect kind "+t.Kind().String())
+ }
+}
+
+// resolveContainer resolves a slice or array element and records a list entry
+// whose single item is the element type.
+//
+// Container entries exist because Admin resolves a parameter by looking up its
+// exact ParameterTypes string in types[], then walks items/properties from
+// there. Publishing only the element T while the parameter reads List<T> leaves
+// Admin with no path to T's fields.
+//
+// Exactly one item is what marks this as a collection rather than a map: Java's
+// MapTypeBuilder appends one entry per actual type argument, so a Map yields two
+// and consumers tell the two apart by arity, not by name.
+func (c *typeCollector) resolveContainer(
+ elem reflect.Type,
+ depth int,
+) (string, error) {
+ elemExpr, err := c.resolveAt(elem, depth+1)
+ if err != nil {
+ return "", err
+ }
+ return c.recordContainer(elemExpr)
+}
+
+func (c *typeCollector) recordContainer(elemExpr string) (string, error) {
+ expr := elemExpr + "[]"
+ if _, seen := c.defs[expr]; !seen {
+ c.put(expr, &TypeDefinition{Type: expr, Items: []string{elemExpr}})
+ }
+ return expr, nil
+}
+
+// resolveMap records a map entry carrying both type arguments.
+//
+// Two items, key first. A single item would be read as a collection of that
+// item, silently turning an object schema into an array one.
+//
+// The value is boxed: it sits inside a generic argument list, where Java admits
+// only reference types. Map<java.lang.String,long> is not a type.
+func (c *typeCollector) resolveMap(value reflect.Type, depth int) (string, error) {
+ valueExpr, err := c.resolveAt(value, depth+1)
+ if err != nil {
+ return "", err
+ }
+ valueExpr = boxed(valueExpr)
+ expr := javaMap + "<" + javaString + "," + valueExpr + ">"
+
+ if _, seen := c.defs[expr]; !seen {
+ c.put(expr, &TypeDefinition{Type: expr, Items: []string{javaString, valueExpr}})
+ }
+ return expr, nil
+}
+
+// boxed returns the wrapper spelling of a primitive, or expr unchanged when it
+// is already a reference type.
+func boxed(expr string) string {
+ if wrapper, primitive := javaWrappers[expr]; primitive {
+ return wrapper
+ }
+ return expr
+}
+
+func (c *typeCollector) resolveStruct(t reflect.Type, depth int) (string, error) {
+ expr := namedTypeKey(t)
+ if expr == "" {
+ // An anonymous struct literal in a signature has no name for Admin to
+ // key on, and no stable identity across builds.
+ return "", unsupported(t.String(), "anonymous struct types cannot be named in a contract")
+ }
+
+ // Reserve the key before descending so a self-referential type terminates.
+ // The placeholder is replaced below once fields resolve; on failure it is
+ // removed again so a later, successful path can retry the same type.
+ if _, seen := c.defs[expr]; seen {
+ return expr, nil
+ }
+ c.put(expr, &TypeDefinition{Type: expr})
+
+ properties, err := c.structProperties(t, expr, depth)
+ if err != nil {
+ c.remove(expr)
+ return "", err
+ }
+ c.defs[expr].Properties = properties
+ return expr, nil
+}
+
+func (c *typeCollector) structProperties(t reflect.Type, expr string, depth int) (map[string]string, error) {
+ properties := make(map[string]string)
+ // matchSets records, per wire name, the field that claimed it. mapstructure
+ // matches case-insensitively, so collisions are detected on the folded name.
+ matchSets := make(map[string]string)
+ flattening := map[reflect.Type]bool{t: true}
+ if err := c.collectStructProperties(t, expr, "", depth, properties, matchSets, flattening); err != nil {
+ return nil, err
+ }
+ return properties, nil
+}
+
+func (c *typeCollector) collectStructProperties(
+ t reflect.Type,
+ expr string,
+ path string,
+ depth int,
+ properties map[string]string,
+ matchSets map[string]string,
+ flattening map[reflect.Type]bool,
+) error {
+ for i := range t.NumField() {
+ field := t.Field(i)
+ if field.PkgPath != "" {
+ // Unexported. MapGeneralizer skips these too (CanInterface is
+ // false), so they are genuinely absent from the wire format.
+ continue
+ }
+
+ tag := genericfield.ParseMTag(field)
+ if tag.Ignore {
+ continue
+ }
+ if len(tag.UnknownOptions) > 0 {
+ return unsupported(field.Name,
+ fmt.Sprintf("m tag option %q has no declarable schema", tag.UnknownOptions[0]))
+ }
+
+ owner := path + field.Name
+ if tag.Squash {
+ fieldType := field.Type
+ if fieldType.Kind() == reflect.Pointer {
+ return unsupported(fieldType.String(),
+ "pointer m squash cannot round-trip through generic invocation")
+ }
+ fieldDepth := depth + 1
+ if fieldDepth > maxTypeDepth {
+ return unsupported(field.Type.String(),
+ fmt.Sprintf("type nesting exceeds %d levels", maxTypeDepth))
+ }
+ if fieldType.Kind() != reflect.Struct {
+ return unsupported(field.Type.String(), "m squash requires a struct")
+ }
+ if name := namedTypeKey(fieldType); name != "" {
+ if reason, blocked := blockedNamedTypes[name]; blocked {
+ return unsupported(name, reason)
+ }
+ }
+ if flattening[fieldType] {
+ return unsupported(field.Type.String(), "recursive m squash cannot be represented as a finite schema")
+ }
+ flattening[fieldType] = true
+ err := c.collectStructProperties(
+ fieldType, expr, owner+".", fieldDepth, properties, matchSets, flattening,
+ )
+ delete(flattening, fieldType)
+ if err != nil {
+ return err
+ }
+ continue
+ }
+
+ // A field is reachable under its wire name and, for backwards
+ // compatibility with generic callers written before m tags existed, its
+ // original Go field name — both case-insensitively. Two fields whose
+ // reachable sets overlap make both the schema property and the Realize
+ // target depend on field iteration order.
+ for _, alias := range []string{tag.Name, field.Name} {
+ folded := strings.ToLower(alias)
+ if previous, taken := matchSets[folded]; taken && previous != owner {
+ return unsupported(expr, fmt.Sprintf(
+ "fields %q and %q are both reachable as %q", previous, owner, alias))
+ }
+ matchSets[folded] = owner
+ }
+
+ fieldExpr, err := c.resolveAt(field.Type, depth+1)
+ if err != nil {
+ return err
+ }
+ properties[tag.Name] = fieldExpr
+ }
+
+ return nil
+}
+
+// javaClassNamed is implemented by types that declare their own Java class
+// name. It is hessian.POJO, restated here so this package does not take a
+// dependency on the codec just to read one method.
+type javaClassNamed interface {
+ JavaClassName() string
+}
+
+// namedTypeKey returns the name a struct is published under.
+//
+// A type that declares a Java class name is published under it: that is the
+// name hessian2 puts in the wire form's "class" key and the name a Java peer
+// knows the type by, so using anything else would describe a different type
+// than the one on the wire. Everything else falls back to the Go import path
+// plus type name — no Java name exists to borrow, and the path keeps two
+// same-named types from different packages distinct, which is the same job
+// Java's FQN does.
+//
+// Returns "" for an unnamed type.
+func namedTypeKey(t reflect.Type) string {
+ if declared := declaredJavaClassName(t); declared != "" {
+ return declared
+ }
+ if t.Name() == "" {
+ return ""
+ }
+ if t.PkgPath() == "" {
+ return t.Name()
+ }
+ return t.PkgPath() + "." + t.Name()
+}
+
+// declaredJavaClassName reads JavaClassName from a type or its pointer,
+// covering both receiver forms, and returns "" when the type does not declare
+// one or panics trying.
+func declaredJavaClassName(t reflect.Type) (name string) {
+ if t.Kind() != reflect.Struct {
+ return ""
+ }
+ named, ok := reflect.New(t).Interface().(javaClassNamed)
+ if !ok {
+ return ""
+ }
+ defer func() {
+ // A JavaClassName that dereferences its receiver's fields would panic on
+ // the zero value. Fall back to the Go name rather than fail the build.
+ if recover() != nil {
+ name = ""
+ }
+ }()
+ return named.JavaClassName()
+}
+
+func (c *typeCollector) put(expr string, def *TypeDefinition) {
+ c.defs[expr] = def
+}
+
+// merge copies other's definitions into c without overwriting existing entries.
+//
+// The builder resolves each method into a throwaway collector and merges only on
+// success, so a method rejected halfway through — after some of its parameter
+// types already resolved — leaves no orphan entries describing types no
+// published method can reach.
+func (c *typeCollector) merge(other *typeCollector) {
+ for expr, def := range other.defs {
+ if _, seen := c.defs[expr]; !seen {
+ c.put(expr, def)
+ }
+ }
+}
+
+func (c *typeCollector) remove(expr string) {
+ delete(c.defs, expr)
+}
+
+// definitions returns the collected types sorted by expression.
+//
+// Sorting rather than preserving insertion order keeps the published JSON
+// byte-stable across runs: Go randomizes map iteration, and method resolution
+// order already varies, so an unsorted slice would produce a different document
+// on every restart and defeat idempotent republishing.
+func (c *typeCollector) definitions() []TypeDefinition {
+ exprs := make([]string, 0, len(c.defs))
+ for expr := range c.defs {
+ exprs = append(exprs, expr)
+ }
+ sort.Strings(exprs)
+
+ out := make([]TypeDefinition, 0, len(exprs))
+ for _, expr := range exprs {
+ out = append(out, *c.defs[expr])
+ }
+ return out
+}
diff --git a/metadata/options.go b/metadata/options.go
index e9dcfab34..245d776ad 100644
--- a/metadata/options.go
+++ b/metadata/options.go
@@ -37,7 +37,12 @@ import (
var (
metadataOptions *Options
- exportOnce sync.Once
+ // metadataOptionsMu guards metadataOptions. In production it is written once
+ // at startup and only read afterwards, but the daily metadata renew reads it
+ // from a background goroutine, so the pairing needs to be explicit rather
+ // than relying on that ordering holding.
+ metadataOptionsMu sync.RWMutex
+ exportOnce sync.Once
)
// Options holds the configuration for the metadata service.
@@ -69,7 +74,9 @@ func NewOptions(opts ...Option) *Options {
// Init registers opts as the global metadata options and, for local storage,
// exports the metadata service only once.
func (opts *Options) Init() error {
+ metadataOptionsMu.Lock()
metadataOptions = opts
+ metadataOptionsMu.Unlock()
var err error
exportOnce.Do(func() {
if opts.metadataType != constant.RemoteMetadataStorageType {
@@ -197,6 +204,15 @@ func (opts *ReportOptions) toUrl() (*common.URL, error) {
return nil, errors.New("invalid MetadataReport Config")
}
res.SetParam("metadata", res.Protocol)
+ // Only set when explicitly configured. Leaving the key absent lets readers
+ // apply their own default (enabled), so an unset field and an explicit
+ // `true` behave identically.
+ if opts.ReportDefinition != nil {
+ res.SetParam(constant.MetadataReportReportDefinitionKey,
+ strconv.FormatBool(*opts.ReportDefinition))
+ }
+ // Params is the raw escape hatch and is applied last, so it can override the
+ // typed fields above — the same precedence group/namespace already have.
for key, val := range opts.Params {
res.SetParam(key, val)
}
@@ -308,3 +324,12 @@ func WithRegistryId(id string) ReportOption {
opts.registryId = id
}
}
+
+// WithReportDefinition toggles publishing of interface-level service
+// definitions to this report. Definitions are published by default; pass false
+// to opt out. Mirrors Java's MetadataReportConfig.reportDefinition.
+func WithReportDefinition(report bool) ReportOption {
+ return func(opts *ReportOptions) {
+ opts.ReportDefinition = &report
+ }
+}
diff --git a/metadata/options_test.go b/metadata/options_test.go
index 7fe741533..06df9ce9a 100644
--- a/metadata/options_test.go
+++ b/metadata/options_test.go
@@ -228,6 +228,17 @@ func TestReportOptionsToUrl(t *testing.T) {
assert.Equal(t, "zookeeper", url.GetParam("metadata", ""))
assert.Equal(t, "value", url.GetParam("key", ""))
+ // The typed switch must survive URL construction so the publisher can
+ // distinguish an explicit false from the default-on absent value.
+ disabled := NewReportOptions(
+ WithNacos(),
+ WithAddress("127.0.0.1:8848"),
+ WithReportDefinition(false),
+ )
+ disabledURL, err := disabled.toUrl()
+ require.NoError(t, err)
+ assert.False(t, disabledURL.GetParamBool(constant.MetadataReportReportDefinitionKey, true))
+
// Invalid options - empty protocol
opts = NewReportOptions(WithAddress("127.0.0.1:2181"))
url, err = opts.toUrl()
diff --git a/metadata/report/nacos/report.go b/metadata/report/nacos/report.go
index a66b5a825..c5cc04f9d 100644
--- a/metadata/report/nacos/report.go
+++ b/metadata/report/nacos/report.go
@@ -37,6 +37,7 @@ import (
"dubbo.apache.org/dubbo-go/v3/common"
"dubbo.apache.org/dubbo-go/v3/common/constant"
"dubbo.apache.org/dubbo-go/v3/common/extension"
+ "dubbo.apache.org/dubbo-go/v3/metadata/definition"
"dubbo.apache.org/dubbo-go/v3/metadata/info"
"dubbo.apache.org/dubbo-go/v3/metadata/mapping"
"dubbo.apache.org/dubbo-go/v3/metadata/report"
@@ -54,9 +55,10 @@ func init() {
// nacosMetadataReport is the implementation
// of MetadataReport based on nacos.
type nacosMetadataReport struct {
- client *nacosClient.NacosConfigClient
- group string
- url *common.URL
+ client *nacosClient.NacosConfigClient
+ group string
+ definitionGroup string
+ url *common.URL
}
// URL returns the URL used to create this metadata report.
@@ -306,13 +308,57 @@ func (n *nacosMetadataReport) ListAppRevisions(application string) ([]report.App
return result, nil
}
+// PublishServiceDefinition stores one interface-level service definition,
+// implementing report.ServiceDefinitionPublisher.
+//
+// The logical dataId is byte-compatible with Java's
+// MetadataIdentifier.getUniqueKey. At the Nacos boundary, dataId and group get
+// the same special-character normalization as Java's
+// NacosConfigServiceWrapper. The definition group defaults to "dubbo", which
+// Dubbo Admin watches by default, and follows metadata-report.group when
+// configured. It is kept separate from n.group because the
+// application-metadata compatibility path still defaults that field to
+// DEFAULT_GROUP.
+//
+// PublishConfig overwrites in place, so republishing on every provider start is
+// idempotent: the key is derived only from the service identity, never from the
+// instance.
+func (n *nacosMetadataReport) PublishServiceDefinition(serviceInterface, version, group, application, definitionJSON string) error {
+ dataID := normalizeNacosMetadataKey(definition.DataID(serviceInterface, version, group, application))
+ definitionGroup := normalizeNacosMetadataKey(n.definitionGroup)
+ if err := n.storeMetadata(vo.ConfigParam{
+ DataId: dataID,
+ Group: definitionGroup,
+ Content: definitionJSON,
+ }); err != nil {
+ return fmt.Errorf("publishing service definition %s: %w", dataID, err)
+ }
+ logger.Debugf("[Metadata][Nacos] published service definition, dataId=%s group=%s",
+ dataID, definitionGroup)
+ return nil
+}
+
+// normalizeNacosMetadataKey mirrors Java's NacosConfigServiceWrapper boundary:
+// Nacos config keys cannot carry the inner-class marker or a path separator.
+// Keep definition.DataID backend-neutral and adapt only the values sent to
+// Nacos, including a configured metadata group.
+func normalizeNacosMetadataKey(value string) string {
+ value = strings.ReplaceAll(value, "$", "___")
+ return strings.ReplaceAll(value, "/", "-")
+}
+
type nacosMetadataReportFactory struct{}
+func serviceDefinitionGroup(url *common.URL) string {
+ return url.GetParam(constant.MetadataReportGroupKey, definition.DefaultMetadataGroup)
+}
+
// CreateMetadataReport creates the nacos-based metadata report implementation.
func (n *nacosMetadataReportFactory) CreateMetadataReport(url *common.URL) report.MetadataReport {
url.SetParam(constant.NacosNamespaceID, url.GetParam(constant.MetadataReportNamespaceKey, ""))
url.SetParam(constant.TimeoutKey, url.GetParam(constant.TimeoutKey, constant.DefaultRegTimeout))
group := url.GetParam(constant.MetadataReportGroupKey, constant.ServiceDiscoveryDefaultGroup)
+ definitionGroup := serviceDefinitionGroup(url)
url.SetParam(constant.NacosGroupKey, group)
url.SetParam(constant.NacosUsername, url.Username)
url.SetParam(constant.NacosPassword, url.Password)
@@ -321,5 +367,10 @@ func (n *nacosMetadataReportFactory) CreateMetadataReport(url *common.URL) repor
logger.Errorf("[Metadata][Nacos] could not create nacos metadata report, url=%s", url.String())
return nil
}
- return &nacosMetadataReport{client: client, group: group, url: url}
+ return &nacosMetadataReport{
+ client: client,
+ group: group,
+ definitionGroup: definitionGroup,
+ url: url,
+ }
}
diff --git a/metadata/report/nacos/report_test.go b/metadata/report/nacos/report_test.go
index 828089b52..667329747 100644
--- a/metadata/report/nacos/report_test.go
+++ b/metadata/report/nacos/report_test.go
@@ -37,6 +37,9 @@ import (
)
import (
+ "dubbo.apache.org/dubbo-go/v3/common"
+ "dubbo.apache.org/dubbo-go/v3/common/constant"
+ "dubbo.apache.org/dubbo-go/v3/metadata/definition"
"dubbo.apache.org/dubbo-go/v3/metadata/info"
)
@@ -259,6 +262,58 @@ func Test_nacosMetadataReport_PublishAppMetadata(t *testing.T) {
}
}
+func Test_nacosMetadataReport_PublishServiceDefinitionUsesConfiguredGroup(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ mnc := NewMockIConfigClient(ctrl)
+ nc := &nacosClient.NacosConfigClient{}
+ nc.SetClient(mnc)
+
+ const dataID = "org.example.Service:1.0.0:g1:provider:demo"
+ mnc.EXPECT().PublishConfig(vo.ConfigParam{
+ DataId: dataID,
+ Group: "foo",
+ Content: "{}",
+ }).Return(true, nil)
+ mnc.EXPECT().PublishConfig(vo.ConfigParam{
+ DataId: dataID,
+ Group: "bar",
+ Content: "{}",
+ }).Return(true, nil)
+
+ for _, group := range []string{"foo", "bar"} {
+ n := &nacosMetadataReport{client: nc, definitionGroup: group}
+ require.NoError(t, n.PublishServiceDefinition(
+ "org.example.Service", "1.0.0", "g1", "demo", "{}",
+ ))
+ }
+}
+
+func Test_nacosMetadataReport_PublishServiceDefinitionNormalizesNacosKeyAndGroup(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ mnc := NewMockIConfigClient(ctrl)
+ nc := &nacosClient.NacosConfigClient{}
+ nc.SetClient(mnc)
+
+ mnc.EXPECT().PublishConfig(vo.ConfigParam{
+ DataId: "github.com-example-api.Outer___Service:1.0.0:g-1:provider:demo-app___blue",
+ Group: "metadata-isolated___blue",
+ Content: "{}",
+ }).Return(true, nil)
+
+ n := &nacosMetadataReport{client: nc, definitionGroup: "metadata/isolated$blue"}
+ require.NoError(t, n.PublishServiceDefinition(
+ "github.com/example/api.Outer$Service", "1.0.0", "g/1", "demo/app$blue", "{}",
+ ))
+}
+
+func TestServiceDefinitionGroupDefaultsAndFollowsConfiguration(t *testing.T) {
+ url := common.NewURLWithOptions()
+ assert.Equal(t, definition.DefaultMetadataGroup, serviceDefinitionGroup(url))
+
+ url.SetParam(constant.MetadataReportGroupKey, "isolated")
+ assert.Equal(t, "isolated", serviceDefinitionGroup(url))
+}
+
func Test_nacosMetadataReport_RegisterServiceAppMapping(t *testing.T) {
ctrl := gomock.NewController(t)
mnc := NewMockIConfigClient(ctrl)
diff --git a/metadata/report/report.go b/metadata/report/report.go
index 2334cc10c..c0ead8358 100644
--- a/metadata/report/report.go
+++ b/metadata/report/report.go
@@ -74,3 +74,27 @@ type MetadataReport interface {
// URL returns the URL used to create this metadata report instance.
URL() *common.URL
}
+
+// ServiceDefinitionPublisher is an optional capability for metadata reports
+// that can store interface-level service definitions.
+//
+// It is deliberately separate from MetadataReport rather than a new method on
+// it. MetadataReport is a fixed interface implemented outside this repository
+// too, and adding a method would break every third-party report at compile
+// time for a feature only some backends can serve. Java has no equivalent
+// problem: storeProviderMetadata is a required MetadataReport method there, so
+// every backend must implement it.
+//
+// Callers must not type-assert the values returned by GetMetadataReports
+// against this interface — those are *DelegateMetadataReport wrappers, which
+// would hide a capable backend. Use DelegateMetadataReport.ServiceDefinitionPublisher
+// instead.
+type ServiceDefinitionPublisher interface {
+ // PublishServiceDefinition idempotently stores one interface-level service
+ // definition. definitionJSON is the serialized definition; the backend
+ // chooses its own key layout from the identifying arguments.
+ //
+ // Republishing the same service must overwrite in place rather than
+ // accumulate, since providers republish on every start.
+ PublishServiceDefinition(serviceInterface, version, group, application, definitionJSON string) error
+}
diff --git a/metadata/report_instance.go b/metadata/report_instance.go
index 19e973e6e..2dfb1d2fa 100644
--- a/metadata/report_instance.go
+++ b/metadata/report_instance.go
@@ -124,6 +124,8 @@ func GetMetadataReports() []report.MetadataReport {
}
func GetMetadataType() string {
+ metadataOptionsMu.RLock()
+ defer metadataOptionsMu.RUnlock()
if metadataOptions == nil || metadataOptions.metadataType == "" {
return constant.DefaultMetadataStorageType
}
@@ -199,3 +201,20 @@ func (d *DelegateMetadataReport) ListAppRevisions(application string) ([]report.
}
return revisions, nil
}
+
+// ServiceDefinitionPublisher reports whether the wrapped backend can store
+// interface-level service definitions, returning it if so.
+//
+// This exists because the instance table holds *DelegateMetadataReport values:
+// asserting report.ServiceDefinitionPublisher against one would always fail,
+// hiding a Nacos backend that does implement it. Making the wrapper itself
+// implement the interface unconditionally would be worse — every backend would
+// then pass the assertion and only report "unsupported" after a caller had
+// already tried to publish.
+//
+// Backends without the capability (zookeeper, etcd, third-party reports) simply
+// return false and are unaffected.
+func (d *DelegateMetadataReport) ServiceDefinitionPublisher() (report.ServiceDefinitionPublisher, bool) {
+ publisher, ok := d.instance.(report.ServiceDefinitionPublisher)
+ return publisher, ok
+}
diff --git a/metadata/report_instance_test.go b/metadata/report_instance_test.go
index 5724b72e0..6a964c38e 100644
--- a/metadata/report_instance_test.go
+++ b/metadata/report_instance_test.go
@@ -315,12 +315,18 @@ func TestGetMetadataReports(t *testing.T) {
}
func TestGetMetadataType(t *testing.T) {
+ setOptions := func(opts *Options) {
+ metadataOptionsMu.Lock()
+ defer metadataOptionsMu.Unlock()
+ metadataOptions = opts
+ }
+
assert.Equal(t, constant.DefaultMetadataStorageType, GetMetadataType())
- metadataOptions = &Options{}
+ setOptions(&Options{})
assert.Equal(t, constant.DefaultMetadataStorageType, GetMetadataType())
- metadataOptions = &Options{
+ setOptions(&Options{
metadataType: constant.RemoteMetadataStorageType,
- }
+ })
assert.Equal(t, constant.RemoteMetadataStorageType, GetMetadataType())
}
diff --git a/metadata/service_definition.go b/metadata/service_definition.go
new file mode 100644
index 000000000..55dae1b7f
--- /dev/null
+++ b/metadata/service_definition.go
@@ -0,0 +1,214 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package metadata
+
+import (
+ "encoding/json"
+ "sort"
+)
+
+import (
+ "github.com/dubbogo/gost/log/logger"
+)
+
+import (
+ "dubbo.apache.org/dubbo-go/v3/common"
+ "dubbo.apache.org/dubbo-go/v3/common/constant"
+ "dubbo.apache.org/dubbo-go/v3/metadata/definition"
+ "dubbo.apache.org/dubbo-go/v3/metadata/report"
+)
+
+// PublishServiceDefinitions builds and publishes an interface-level service
+// definition for each exported URL that carries a describable contract, and
+// returns the URLs whose publish failed for a reason worth retrying.
+//
+// The backend call is synchronous; registration paths must schedule it outside
+// the instance-registration call. No error is returned upward: a provider whose
+// definition did not reach the metadata center is still a working provider — it
+// is only invisible to Admin's console. Java reaches the same non-fatal outcome
+// by queuing the write when sync-report is false.
+//
+// Only write failures come back. A service that has no describable contract —
+// unregistered, unbuildable, or with no publishable methods — is reported in the
+// log and dropped, because retrying cannot change any of those.
+//
+// Publishing is idempotent and keyed only by service identity, so calling this
+// on every start, on each cycle-report pass, and on every retry simply
+// overwrites the previous document.
+//
+// The target is the caller's own report, not every configured one. Reports are
+// per registry, and two registries routinely point at different namespaces or
+// even different clusters; broadcasting would write one registry's contracts
+// into another's environment and multiply the retry traffic when the unrelated
+// target is down.
+func PublishServiceDefinitions(r report.MetadataReport, urls []*common.URL) []*common.URL {
+ publisher, ok := serviceDefinitionPublisher(r)
+ if !ok {
+ return nil
+ }
+
+ var failed []*common.URL
+ for _, u := range dedupeByService(urls) {
+ if !publishServiceDefinition(u, publisher) {
+ failed = append(failed, u)
+ }
+ }
+ return failed
+}
+
+// publishServiceDefinition reports whether the caller should retry.
+func publishServiceDefinition(u *common.URL, publisher report.ServiceDefinitionPublisher) bool {
+ svc := common.ServiceMap.GetServiceByServiceKey(u.Protocol, u.ServiceKey())
+ if svc == nil {
+ logger.Warnf("[Metadata][Definition] no registered service for %s/%s, skipping definition",
+ u.Protocol, u.ServiceKey())
+ return true
+ }
+
+ def, skips, err := definition.BuildFromURL(u, svc.ServiceType())
+ if err != nil {
+ logger.Errorf("[Metadata][Definition] could not build definition for %s: %v", u.ServiceKey(), err)
+ return true
+ }
+ for _, skip := range skips {
+ logger.Warnf("[Metadata][Definition] method %s.%s is not published: %s",
+ def.CanonicalName, skip.Name, skip.Reason)
+ }
+ if len(def.Methods) == 0 {
+ logger.Warnf("[Metadata][Definition] %s has no publishable methods, skipping definition",
+ def.CanonicalName)
+ return true
+ }
+
+ payload, err := json.Marshal(def)
+ if err != nil {
+ logger.Errorf("[Metadata][Definition] could not serialize definition for %s: %v",
+ def.CanonicalName, err)
+ return true
+ }
+
+ application := u.GetParam(constant.ApplicationKey, "")
+ if err := publisher.PublishServiceDefinition(
+ def.CanonicalName, u.Version(), u.Group(), application, string(payload),
+ ); err != nil {
+ logger.Errorf("[Metadata][Definition] could not publish definition for %s: %v",
+ def.CanonicalName, err)
+ return false
+ }
+ logger.Infof("[Metadata][Definition] published definition for %s, methods=%d types=%d",
+ def.CanonicalName, len(def.Methods), len(def.Types))
+ return true
+}
+
+// ServiceDefinitionsEnabled reports whether r will accept interface-level
+// service definitions.
+//
+// Callers use this to decide whether work that only exists to serve definitions
+// — the daily re-publish, for one — is worth scheduling at all.
+func ServiceDefinitionsEnabled(r report.MetadataReport) bool {
+ _, ok := serviceDefinitionPublisher(r)
+ return ok
+}
+
+// serviceDefinitionPublisher resolves r's publish capability, honoring the
+// report-definition switch.
+//
+// The instance table stores *DelegateMetadataReport, so the capability has to be
+// queried through the wrapper rather than type-asserted off the interface value.
+func serviceDefinitionPublisher(r report.MetadataReport) (report.ServiceDefinitionPublisher, bool) {
+ delegate, ok := r.(*DelegateMetadataReport)
+ if !ok {
+ return nil, false
+ }
+ publisher, supported := delegate.ServiceDefinitionPublisher()
+ if !supported {
+ return nil, false
+ }
+ if url := delegate.URL(); url != nil &&
+ !url.GetParamBool(constant.MetadataReportReportDefinitionKey, true) {
+ return nil, false
+ }
+ return publisher, true
+}
+
+// dedupeByService reduces the exported URLs to one per service identity.
+//
+// A service exported over several protocols produces several URLs, but the
+// definition key holds no protocol, so publishing each would have them
+// overwrite one another in an order that depends on map iteration. The contract
+// is identical across protocols anyway — the method set comes from one handler's
+// reflection — so one document per service is the whole truth. Protocol belongs
+// to the instance's exported-services revision, which Admin reads separately.
+//
+// Selection is by lowest protocol name so restarts converge on the same URL,
+// which puts "dubbo" ahead of "tri" for a dual-exported service.
+func dedupeByService(urls []*common.URL) []*common.URL {
+ selected := make(map[string]*common.URL, len(urls))
+ for _, u := range urls {
+ if u == nil || !describable(u) {
+ continue
+ }
+ key := u.ServiceKey()
+ if current, seen := selected[key]; seen && current.Protocol <= u.Protocol {
+ continue
+ }
+ selected[key] = u
+ }
+
+ keys := make([]string, 0, len(selected))
+ for key := range selected {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+
+ out := make([]*common.URL, 0, len(keys))
+ for _, key := range keys {
+ out = append(out, selected[key])
+ }
+ return out
+}
+
+// describable reports whether a URL's export carries a contract this package can
+// derive from Go reflection.
+//
+// Only two exports qualify: Dubbo/Hessian2, and Triple in non-IDL mode. An IDL
+// (protobuf) service must not be published from reflection — the generated
+// struct is a lossy view of the proto, missing real field names, enums, oneofs
+// and well-known type semantics — so its contract needs a descriptor-based
+// builder that does not exist yet.
+//
+// Non-IDL is detected by the absence of the ServiceInfo attribute rather than
+// the IDL-mode URL parameter the proposal suggested. Server.Register threads a
+// *common.ServiceInfo through for IDL services and Server.RegisterService
+// passes nil for non-IDL ones, and enhanceServiceInfo preserves that nil, so the
+// attribute is set exactly for IDL exports. The IDL-mode parameter would work
+// today but is marked for removal along with constant.IDLMode, constant.NONIDL
+// and WithIDLMode.
+func describable(u *common.URL) bool {
+ switch u.Protocol {
+ case constant.DubboProtocol:
+ return true
+ case constant.TriProtocol:
+ _, isIDL := u.GetAttribute(constant.ServiceInfoKey)
+ return !isIDL
+ default:
+ // REST, gRPC and anything else either has its own contract format or no
+ // generic-invocation path for Admin to call through.
+ return false
+ }
+}
diff --git a/metadata/service_definition_test.go b/metadata/service_definition_test.go
new file mode 100644
index 000000000..7fee94975
--- /dev/null
+++ b/metadata/service_definition_test.go
@@ -0,0 +1,432 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package metadata
+
+import (
+ "context"
+ "encoding/json"
+ "sync"
+ "testing"
+)
+
+import (
+ gxset "github.com/dubbogo/gost/container/set"
+
+ perrors "github.com/pkg/errors"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+import (
+ "dubbo.apache.org/dubbo-go/v3/common"
+ "dubbo.apache.org/dubbo-go/v3/common/constant"
+ "dubbo.apache.org/dubbo-go/v3/metadata/definition"
+ "dubbo.apache.org/dubbo-go/v3/metadata/info"
+ "dubbo.apache.org/dubbo-go/v3/metadata/mapping"
+ "dubbo.apache.org/dubbo-go/v3/metadata/report"
+)
+
+// ---------------------------------------------------------------------------
+// fakes
+// ---------------------------------------------------------------------------
+type publishedDefinition struct {
+ ServiceInterface string
+ Version string
+ Group string
+ Application string
+ JSON string
+}
+
+// baseReport satisfies report.MetadataReport without the definition capability.
+type baseReport struct {
+ url *common.URL
+}
+
+func (r *baseReport) GetAppMetadata(string, string) (*info.MetadataInfo, error) { return nil, nil }
+func (r *baseReport) PublishAppMetadata(string, string, *info.MetadataInfo) error {
+ return nil
+}
+func (r *baseReport) RegisterServiceAppMapping(string, string, string) error { return nil }
+func (r *baseReport) GetServiceAppMapping(string, string, mapping.MappingListener) (*gxset.HashSet, error) {
+ return nil, nil
+}
+func (r *baseReport) RemoveServiceAppMappingListener(string, string) error { return nil }
+func (r *baseReport) UnPublishAppMetadata(string, string) error { return nil }
+func (r *baseReport) ListAppRevisions(string) ([]report.AppRevision, error) { return nil, nil }
+func (r *baseReport) URL() *common.URL { return r.url }
+
+// capableReport additionally implements report.ServiceDefinitionPublisher.
+type capableReport struct {
+ baseReport
+ mu sync.Mutex
+ published []publishedDefinition
+ err error
+}
+
+func (r *capableReport) PublishServiceDefinition(serviceInterface, version, group, application, definitionJSON string) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.err != nil {
+ return r.err
+ }
+ r.published = append(r.published, publishedDefinition{
+ ServiceInterface: serviceInterface,
+ Version: version,
+ Group: group,
+ Application: application,
+ JSON: definitionJSON,
+ })
+ return nil
+}
+
+func (r *capableReport) snapshot() []publishedDefinition {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return append([]publishedDefinition(nil), r.published...)
+}
+
+// ---------------------------------------------------------------------------
+// fixtures
+// ---------------------------------------------------------------------------
+
+type EchoService struct{}
+
+func (s *EchoService) Echo(ctx context.Context, msg string) (string, error) { return msg, nil }
+
+const echoInterface = "org.example.EchoService"
+
+func reportURL(t *testing.T, params map[string]string) *common.URL {
+ t.Helper()
+ u := common.NewURLWithOptions(
+ common.WithProtocol("nacos"),
+ common.WithIp("127.0.0.1"),
+ common.WithPort("8848"),
+ )
+ for k, v := range params {
+ u.SetParam(k, v)
+ }
+ return u
+}
+
+// installReports replaces the package instance table for the duration of a test.
+func installReports(t *testing.T, table map[string]report.MetadataReport) {
+ t.Helper()
+ instancesMu.Lock()
+ instances = table
+ instancesMu.Unlock()
+ t.Cleanup(func() {
+ instancesMu.Lock()
+ instances = make(map[string]report.MetadataReport)
+ instancesMu.Unlock()
+ })
+}
+
+func installCapableReport(t *testing.T, params map[string]string) *capableReport {
+ t.Helper()
+ backend := &capableReport{baseReport: baseReport{url: reportURL(t, params)}}
+ installReports(t, map[string]report.MetadataReport{
+ constant.DefaultKey: &DelegateMetadataReport{instance: backend},
+ })
+ return backend
+}
+
+// exportService registers a handler in ServiceMap the way Export does and
+// returns its exported URL.
+func exportService(t *testing.T, protocol string, idl bool) *common.URL {
+ t.Helper()
+ _, err := common.ServiceMap.Register(echoInterface, protocol, "", "", &EchoService{})
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ _ = common.ServiceMap.UnRegister(echoInterface, protocol,
+ common.ServiceKey(echoInterface, "", ""))
+ })
+
+ u := common.NewURLWithOptions(
+ common.WithProtocol(protocol),
+ common.WithIp("127.0.0.1"),
+ common.WithPort("20000"),
+ common.WithParamsValue(constant.InterfaceKey, echoInterface),
+ common.WithParamsValue(constant.ApplicationKey, "demo-app"),
+ common.WithParamsValue(constant.ReleaseKey, "dubbo-golang-3.3.0"),
+ common.WithParamsValue(constant.SideKey, "provider"),
+ )
+ if idl {
+ // Export sets this attribute only when Server.Register threaded a
+ // ServiceInfo through, which is exactly the IDL path.
+ u.SetAttribute(constant.ServiceInfoKey, &common.ServiceInfo{InterfaceName: echoInterface})
+ }
+ return u
+}
+
+// ---------------------------------------------------------------------------
+// capability discovery
+// ---------------------------------------------------------------------------
+
+func TestCapabilityIsVisibleThroughTheDelegateWrapper(t *testing.T) {
+ // The instance table stores wrappers, so a direct type assertion on the
+ // value returned by GetMetadataReports would hide a capable backend.
+ backend := installCapableReport(t, nil)
+
+ reports := GetMetadataReports()
+ require.Len(t, reports, 1)
+
+ _, direct := reports[0].(report.ServiceDefinitionPublisher)
+ assert.False(t, direct, "the wrapper itself must not satisfy the capability interface")
+
+ delegate, ok := reports[0].(*DelegateMetadataReport)
+ require.True(t, ok)
+ publisher, supported := delegate.ServiceDefinitionPublisher()
+ require.True(t, supported, "capability must be reachable through the wrapper")
+
+ require.NoError(t, publisher.PublishServiceDefinition("i", "v", "g", "a", "{}"))
+ assert.Len(t, backend.snapshot(), 1)
+}
+
+func TestBackendWithoutCapabilityReportsUnsupported(t *testing.T) {
+ installReports(t, map[string]report.MetadataReport{
+ constant.DefaultKey: &DelegateMetadataReport{instance: &baseReport{url: reportURL(t, nil)}},
+ })
+
+ delegate := GetMetadataReports()[0].(*DelegateMetadataReport)
+ _, supported := delegate.ServiceDefinitionPublisher()
+ assert.False(t, supported, "zookeeper/etcd/third-party reports must be unaffected")
+ assert.False(t, ServiceDefinitionsEnabled(GetMetadataReports()[0]))
+}
+
+// ---------------------------------------------------------------------------
+// publishing
+// ---------------------------------------------------------------------------
+
+func TestPublishServiceDefinitions(t *testing.T) {
+ backend := installCapableReport(t, nil)
+ u := exportService(t, constant.DubboProtocol, false)
+
+ PublishServiceDefinitions(GetMetadataReports()[0], []*common.URL{u})
+
+ published := backend.snapshot()
+ require.Len(t, published, 1)
+ assert.Equal(t, echoInterface, published[0].ServiceInterface)
+ assert.Equal(t, "demo-app", published[0].Application)
+ assert.Empty(t, published[0].Version)
+ assert.Empty(t, published[0].Group)
+
+ var def definition.ServiceDefinition
+ require.NoError(t, json.Unmarshal([]byte(published[0].JSON), &def))
+ assert.Equal(t, echoInterface, def.CanonicalName)
+ require.Len(t, def.Methods, 1)
+ assert.Equal(t, "Echo", def.Methods[0].Name)
+ assert.Equal(t, "java.lang.String", def.Methods[0].ReturnType)
+}
+
+func TestPublishIsIdempotent(t *testing.T) {
+ backend := installCapableReport(t, nil)
+ u := exportService(t, constant.DubboProtocol, false)
+
+ PublishServiceDefinitions(GetMetadataReports()[0], []*common.URL{u})
+ PublishServiceDefinitions(GetMetadataReports()[0], []*common.URL{u})
+
+ published := backend.snapshot()
+ require.Len(t, published, 2, "each call publishes; the backend overwrites in place")
+ assert.Equal(t, published[0].JSON, published[1].JSON,
+ "republishing must produce byte-identical content or the metadata center churns")
+}
+
+func TestPublishFailureDoesNotPanicOrBlock(t *testing.T) {
+ backend := installCapableReport(t, nil)
+ backend.err = perrors.New("nacos unavailable")
+ u := exportService(t, constant.DubboProtocol, false)
+
+ // A metadata-center outage must not keep the provider out of traffic, so
+ // this returns normally and the caller proceeds to register instances.
+ assert.NotPanics(t, func() { PublishServiceDefinitions(GetMetadataReports()[0], []*common.URL{u}) })
+}
+
+func TestPublishSkippedWhenSwitchedOff(t *testing.T) {
+ backend := installCapableReport(t, map[string]string{
+ constant.MetadataReportReportDefinitionKey: "false",
+ })
+ u := exportService(t, constant.DubboProtocol, false)
+
+ PublishServiceDefinitions(GetMetadataReports()[0], []*common.URL{u})
+ assert.Empty(t, backend.snapshot())
+}
+
+func TestPublishEnabledByDefault(t *testing.T) {
+ // An absent key must behave as enabled, matching Java's report-definition,
+ // which defaults to true.
+ backend := installCapableReport(t, nil)
+ u := exportService(t, constant.DubboProtocol, false)
+
+ PublishServiceDefinitions(GetMetadataReports()[0], []*common.URL{u})
+ assert.Len(t, backend.snapshot(), 1)
+}
+
+func TestPublishTargetsOnlyTheRequestedReport(t *testing.T) {
+ first := &capableReport{baseReport: baseReport{url: reportURL(t, nil)}}
+ second := &capableReport{baseReport: baseReport{url: reportURL(t, nil)}}
+ installReports(t, map[string]report.MetadataReport{
+ "registry-a": &DelegateMetadataReport{instance: first},
+ "registry-b": &DelegateMetadataReport{instance: second},
+ })
+ u := exportService(t, constant.DubboProtocol, false)
+
+ PublishServiceDefinitions(GetMetadataReportByRegistry("registry-a"), []*common.URL{u})
+
+ assert.Len(t, first.snapshot(), 1)
+ assert.Empty(t, second.snapshot(), "a registry must never broadcast definitions to another report")
+}
+
+// TestServiceDefinitionsEnabled covers the predicate the daily re-publish uses
+// to decide whether it has anything to do. That pass is what keeps a live
+// contract's timestamp fresh, so scheduling it must not depend on where
+// application metadata happens to live.
+func TestServiceDefinitionsEnabled(t *testing.T) {
+ t.Run("capable and switched on", func(t *testing.T) {
+ installCapableReport(t, nil)
+ assert.True(t, ServiceDefinitionsEnabled(GetMetadataReports()[0]))
+ })
+
+ t.Run("capable but switched off", func(t *testing.T) {
+ installCapableReport(t, map[string]string{
+ constant.MetadataReportReportDefinitionKey: "false",
+ })
+ assert.False(t, ServiceDefinitionsEnabled(GetMetadataReports()[0]))
+ })
+
+ t.Run("backend without the capability", func(t *testing.T) {
+ installReports(t, map[string]report.MetadataReport{
+ constant.DefaultKey: &DelegateMetadataReport{instance: &baseReport{url: reportURL(t, nil)}},
+ })
+ assert.False(t, ServiceDefinitionsEnabled(GetMetadataReports()[0]))
+ })
+
+ t.Run("no report configured", func(t *testing.T) {
+ // A registry without a metadata report passes nil here.
+ assert.False(t, ServiceDefinitionsEnabled(nil))
+ })
+}
+
+// ---------------------------------------------------------------------------
+// protocol gating
+// ---------------------------------------------------------------------------
+
+func TestDescribableProtocols(t *testing.T) {
+ cases := []struct {
+ name string
+ protocol string
+ idl bool
+ want bool
+ }{
+ {"dubbo", constant.DubboProtocol, false, true},
+ {"triple non-IDL", constant.TriProtocol, false, true},
+ {"triple IDL", constant.TriProtocol, true, false},
+ {"rest", "rest", false, false},
+ {"grpc", "grpc", false, false},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ u := common.NewURLWithOptions(
+ common.WithProtocol(tc.protocol),
+ common.WithParamsValue(constant.InterfaceKey, echoInterface),
+ )
+ if tc.idl {
+ u.SetAttribute(constant.ServiceInfoKey, &common.ServiceInfo{})
+ }
+ assert.Equal(t, tc.want, describable(u))
+ })
+ }
+}
+
+func TestTripleIDLIsNotPublished(t *testing.T) {
+ // A protobuf service's contract must come from the descriptor, not from the
+ // generated struct, so it is excluded until that builder exists.
+ backend := installCapableReport(t, nil)
+ u := exportService(t, constant.TriProtocol, true)
+
+ PublishServiceDefinitions(GetMetadataReports()[0], []*common.URL{u})
+ assert.Empty(t, backend.snapshot())
+}
+
+func TestTripleNonIDLIsPublished(t *testing.T) {
+ backend := installCapableReport(t, nil)
+ u := exportService(t, constant.TriProtocol, false)
+
+ PublishServiceDefinitions(GetMetadataReports()[0], []*common.URL{u})
+ assert.Len(t, backend.snapshot(), 1)
+}
+
+// ---------------------------------------------------------------------------
+// multi-protocol de-duplication
+// ---------------------------------------------------------------------------
+
+func TestMultiProtocolPublishesOneDefinition(t *testing.T) {
+ // The definition key holds no protocol, so publishing per-protocol would
+ // have the documents overwrite one another non-deterministically.
+ backend := installCapableReport(t, nil)
+ dubboURL := exportService(t, constant.DubboProtocol, false)
+ tripleURL := exportService(t, constant.TriProtocol, false)
+
+ PublishServiceDefinitions(GetMetadataReports()[0], []*common.URL{tripleURL, dubboURL})
+
+ published := backend.snapshot()
+ require.Len(t, published, 1)
+ assert.Equal(t, echoInterface, published[0].ServiceInterface)
+}
+
+func TestDedupeSelectionIsOrderIndependent(t *testing.T) {
+ dubboURL := exportService(t, constant.DubboProtocol, false)
+ tripleURL := exportService(t, constant.TriProtocol, false)
+
+ forward := dedupeByService([]*common.URL{dubboURL, tripleURL})
+ reverse := dedupeByService([]*common.URL{tripleURL, dubboURL})
+
+ require.Len(t, forward, 1)
+ require.Len(t, reverse, 1)
+ assert.Equal(t, forward[0].Protocol, reverse[0].Protocol,
+ "input order must not decide which URL wins, or restarts would flap")
+ assert.Equal(t, constant.DubboProtocol, forward[0].Protocol)
+}
+
+func TestDedupeKeepsDistinctServices(t *testing.T) {
+ a := exportService(t, constant.DubboProtocol, false)
+ b := common.NewURLWithOptions(
+ common.WithProtocol(constant.DubboProtocol),
+ common.WithParamsValue(constant.InterfaceKey, "org.example.Other"),
+ )
+ assert.Len(t, dedupeByService([]*common.URL{a, b}), 2)
+}
+
+func TestUnregisteredServiceIsSkipped(t *testing.T) {
+ backend := installCapableReport(t, nil)
+ u := common.NewURLWithOptions(
+ common.WithProtocol(constant.DubboProtocol),
+ common.WithParamsValue(constant.InterfaceKey, "org.example.NeverExported"),
+ )
+
+ assert.NotPanics(t, func() { PublishServiceDefinitions(GetMetadataReports()[0], []*common.URL{u}) })
+ assert.Empty(t, backend.snapshot())
+}
+
+func TestNoReportIsANoOp(t *testing.T) {
+ // A registry with no metadata report passes nil rather than skipping the
+ // call, so publishing must tolerate it.
+ u := exportService(t, constant.DubboProtocol, false)
+ assert.NotPanics(t, func() { PublishServiceDefinitions(nil, []*common.URL{u}) })
+}
diff --git a/options.go b/options.go
index edf1c17cc..5d5e4fcf3 100644
--- a/options.go
+++ b/options.go
@@ -155,6 +155,13 @@ func reportConfigToReportOptions(mc *global.MetadataReportConfig) (*metadata.Rep
}
metadata.WithTimeout(timeout)(opts)
}
+ // Only forwarded when set, so an unset field keeps the default rather than
+ // pinning it. This conversion builds ReportOptions field by field, so a new
+ // config field that is not listed here is silently dropped — which is what
+ // happened to this one, leaving report-definition:false with no effect.
+ if mc.ReportDefinition != nil {
+ metadata.WithReportDefinition(*mc.ReportDefinition)(opts)
+ }
return opts, nil
}
diff --git a/registry/servicediscovery/definition_retry_test.go b/registry/servicediscovery/definition_retry_test.go
new file mode 100644
index 000000000..9e8d20f3a
--- /dev/null
+++ b/registry/servicediscovery/definition_retry_test.go
@@ -0,0 +1,464 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package servicediscovery
+
+import (
+ "sync"
+ "testing"
+ "time"
+)
+
+import (
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+import (
+ "dubbo.apache.org/dubbo-go/v3/common"
+ "dubbo.apache.org/dubbo-go/v3/common/constant"
+ "dubbo.apache.org/dubbo-go/v3/metadata/report"
+)
+
+// definitionPublishStub records calls and replays a scripted outcome.
+type definitionPublishStub struct {
+ mu sync.Mutex
+ // failUntil is the number of leading calls that report failure.
+ failUntil int
+ calls int
+ done chan struct{}
+ // signalAt fires done once this many calls have been made.
+ signalAt int
+}
+
+func (p *definitionPublishStub) publish(_ report.MetadataReport, urls []*common.URL) []*common.URL {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ p.calls++
+ if p.signalAt > 0 && p.calls == p.signalAt && p.done != nil {
+ close(p.done)
+ }
+ if p.calls <= p.failUntil {
+ return urls
+ }
+ return nil
+}
+
+func (p *definitionPublishStub) callCount() int {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return p.calls
+}
+
+// installDefinitionPublishStub swaps the publish seam and shrinks the backoff so
+// the retry loop runs in test time rather than minutes.
+func installDefinitionPublishFunc(
+ t *testing.T,
+ publish func(report.MetadataReport, []*common.URL) []*common.URL,
+) {
+ t.Helper()
+
+ prevPublish := publishDefinitions
+ prevInitial := definitionRetryInitialDelay
+ prevMax := definitionRetryMaxDelay
+ prevAttempts := definitionRetryMaxAttempts
+
+ publishDefinitions = publish
+ definitionRetryInitialDelay = time.Millisecond
+ definitionRetryMaxDelay = 4 * time.Millisecond
+
+ t.Cleanup(func() {
+ publishDefinitions = prevPublish
+ definitionRetryInitialDelay = prevInitial
+ definitionRetryMaxDelay = prevMax
+ definitionRetryMaxAttempts = prevAttempts
+ })
+}
+
+func installDefinitionPublishStub(t *testing.T, stub *definitionPublishStub) {
+ t.Helper()
+ installDefinitionPublishFunc(t, stub.publish)
+}
+
+// newDefinitionRetryRegistry builds a registry and cancels whatever retries it
+// still holds when the test ends. Without that, a pending timer outlives its
+// test and lands on the next test's stub — which is exactly the leak
+// cancelDefinitionRetriesLocked exists to prevent in production.
+func newDefinitionRetryRegistry(t *testing.T) *serviceDiscoveryRegistry {
+ t.Helper()
+ reg := &serviceDiscoveryRegistry{
+ url: common.NewURLWithOptions(),
+ subscribeRetries: make(map[string]*subscribeRetry),
+ definitionRetries: make(map[string]*definitionRetry),
+ }
+ t.Cleanup(func() {
+ reg.lock.Lock()
+ reg.destroyed = true
+ reg.cancelDefinitionRetriesLocked()
+ reg.cancelDefinitionPublishLocked()
+ reg.lock.Unlock()
+ })
+ return reg
+}
+
+func definitionTestURL(iface string) *common.URL {
+ return common.NewURLWithOptions(
+ common.WithProtocol(constant.DubboProtocol),
+ common.WithParamsValue(constant.InterfaceKey, iface),
+ )
+}
+
+func (s *serviceDiscoveryRegistry) pendingDefinitionRetries() int {
+ s.lock.RLock()
+ defer s.lock.RUnlock()
+ return len(s.definitionRetries)
+}
+
+// TestDefinitionRetrySucceedsAfterTransientFailure covers the case this
+// mechanism exists for: the metadata center is briefly unavailable while the
+// provider starts, and the definition lands on a later attempt instead of
+// waiting for the next daily pass.
+func TestDefinitionRetrySucceedsAfterTransientFailure(t *testing.T) {
+ stub := &definitionPublishStub{failUntil: 2, signalAt: 3, done: make(chan struct{})}
+ installDefinitionPublishStub(t, stub)
+
+ reg := newDefinitionRetryRegistry(t)
+ reg.publishServiceDefinitions([]*common.URL{definitionTestURL("org.test.Retry")})
+
+ select {
+ case <-stub.done:
+ case <-time.After(5 * time.Second):
+ t.Fatal("definition publish was never retried to success")
+ }
+
+ assert.Eventually(t, func() bool { return reg.pendingDefinitionRetries() == 0 },
+ 2*time.Second, 5*time.Millisecond,
+ "a successful retry must drop its state instead of rescheduling")
+}
+
+// TestDefinitionRetryGivesUpAfterMaxAttempts pins the bound. Retrying forever
+// would only add noise to a failure the daily cycle report already owns.
+func TestDefinitionRetryGivesUpAfterMaxAttempts(t *testing.T) {
+ stub := &definitionPublishStub{failUntil: 1 << 30}
+ installDefinitionPublishStub(t, stub)
+ definitionRetryMaxAttempts = 3
+
+ reg := newDefinitionRetryRegistry(t)
+ reg.publishServiceDefinitions([]*common.URL{definitionTestURL("org.test.Doomed")})
+
+ assert.Eventually(t, func() bool { return reg.pendingDefinitionRetries() == 0 },
+ 2*time.Second, 5*time.Millisecond, "the retry must eventually give up")
+
+ // One initial publish plus definitionRetryMaxAttempts retries.
+ assert.Equal(t, 1+3, stub.callCount())
+}
+
+// TestDefinitionRetryIsOnePerService keeps repeated failures from stacking
+// timers, which would both multiply the load and reset the backoff.
+func TestDefinitionRetryIsOnePerService(t *testing.T) {
+ stub := &definitionPublishStub{failUntil: 1 << 30}
+ installDefinitionPublishStub(t, stub)
+ definitionRetryMaxAttempts = 1 << 30 // keep the entry pending for the assertion
+
+ reg := newDefinitionRetryRegistry(t)
+ u := definitionTestURL("org.test.Duplicate")
+
+ for range 5 {
+ reg.scheduleDefinitionRetry(u)
+ }
+ assert.Equal(t, 1, reg.pendingDefinitionRetries())
+}
+
+func TestDefinitionRetryTracksServicesSeparately(t *testing.T) {
+ stub := &definitionPublishStub{failUntil: 1 << 30}
+ installDefinitionPublishStub(t, stub)
+ definitionRetryMaxAttempts = 1 << 30
+
+ reg := newDefinitionRetryRegistry(t)
+ reg.scheduleDefinitionRetry(definitionTestURL("org.test.A"))
+ reg.scheduleDefinitionRetry(definitionTestURL("org.test.B"))
+
+ assert.Equal(t, 2, reg.pendingDefinitionRetries())
+}
+
+// TestDefinitionRetryCancelledOnDestroy guards against a timer outliving the
+// registry it belongs to.
+func TestDefinitionRetryCancelledOnDestroy(t *testing.T) {
+ stub := &definitionPublishStub{failUntil: 1 << 30}
+ installDefinitionPublishStub(t, stub)
+ definitionRetryMaxAttempts = 1 << 30
+ // Long enough that the first timer cannot have fired before the cancel
+ // below, so the assertion measures cancellation rather than timing.
+ definitionRetryInitialDelay = 300 * time.Millisecond
+ definitionRetryMaxDelay = time.Second
+
+ reg := newDefinitionRetryRegistry(t)
+ reg.scheduleDefinitionRetry(definitionTestURL("org.test.Destroyed"))
+ require.Equal(t, 1, reg.pendingDefinitionRetries())
+ require.Equal(t, 0, stub.callCount(), "scheduling alone must not publish")
+
+ reg.lock.Lock()
+ reg.destroyed = true
+ reg.cancelDefinitionRetriesLocked()
+ reg.lock.Unlock()
+
+ assert.Equal(t, 0, reg.pendingDefinitionRetries())
+
+ time.Sleep(500 * time.Millisecond)
+ assert.Equal(t, 0, stub.callCount(), "a canceled retry must not fire")
+}
+
+// TestDefinitionRetryNotArmedAfterDestroy covers the race where a publish
+// started before Destroy reports its failure after it.
+func TestDefinitionRetryNotArmedAfterDestroy(t *testing.T) {
+ stub := &definitionPublishStub{failUntil: 1 << 30}
+ installDefinitionPublishStub(t, stub)
+
+ reg := newDefinitionRetryRegistry(t)
+ reg.lock.Lock()
+ reg.destroyed = true
+ reg.lock.Unlock()
+
+ reg.scheduleDefinitionRetry(definitionTestURL("org.test.Late"))
+ assert.Equal(t, 0, reg.pendingDefinitionRetries())
+}
+
+func TestDefinitionRetryDelayBackoff(t *testing.T) {
+ prevInitial, prevMax := definitionRetryInitialDelay, definitionRetryMaxDelay
+ definitionRetryInitialDelay = time.Second
+ definitionRetryMaxDelay = 30 * time.Second
+ defer func() {
+ definitionRetryInitialDelay, definitionRetryMaxDelay = prevInitial, prevMax
+ }()
+
+ // Jitter is up to 25% on top, so each attempt lands in [base, base*1.25].
+ for attempt, base := range map[int]time.Duration{
+ 0: time.Second,
+ 1: 2 * time.Second,
+ 2: 4 * time.Second,
+ } {
+ delay := definitionRetryDelay(attempt)
+ assert.GreaterOrEqual(t, delay, base, "attempt %d", attempt)
+ assert.LessOrEqual(t, delay, base+base/4, "attempt %d", attempt)
+ }
+
+ // Far past the cap, including values that would overflow a naive shift.
+ for _, attempt := range []int{10, 29, 30, 1000} {
+ delay := definitionRetryDelay(attempt)
+ assert.GreaterOrEqual(t, delay, definitionRetryMaxDelay, "attempt %d", attempt)
+ assert.LessOrEqual(t, delay, definitionRetryMaxDelay+definitionRetryMaxDelay/4,
+ "attempt %d", attempt)
+ }
+}
+
+// TestPublishServiceDefinitionsArmsNoRetryOnSuccess keeps the happy path free of
+// timers.
+func TestPublishServiceDefinitionsArmsNoRetryOnSuccess(t *testing.T) {
+ stub := &definitionPublishStub{}
+ installDefinitionPublishStub(t, stub)
+
+ reg := newDefinitionRetryRegistry(t)
+ reg.publishServiceDefinitions([]*common.URL{definitionTestURL("org.test.Fine")})
+
+ assert.Equal(t, 1, stub.callCount())
+ assert.Equal(t, 0, reg.pendingDefinitionRetries())
+}
+
+type serializedDefinitionPublishStub struct {
+ mu sync.Mutex
+ calls int
+ active int
+ maxActive int
+ reports []report.MetadataReport
+ batches [][]*common.URL
+ firstStarted chan struct{}
+ releaseFirst chan struct{}
+ secondDone chan struct{}
+}
+
+func (p *serializedDefinitionPublishStub) publish(
+ r report.MetadataReport,
+ urls []*common.URL,
+) []*common.URL {
+ p.mu.Lock()
+ p.calls++
+ call := p.calls
+ p.active++
+ if p.active > p.maxActive {
+ p.maxActive = p.active
+ }
+ p.reports = append(p.reports, r)
+ p.batches = append(p.batches, append([]*common.URL(nil), urls...))
+ if call == 1 {
+ close(p.firstStarted)
+ }
+ p.mu.Unlock()
+
+ if call == 1 {
+ <-p.releaseFirst
+ }
+
+ p.mu.Lock()
+ p.active--
+ if call == 2 {
+ close(p.secondDone)
+ }
+ p.mu.Unlock()
+ return nil
+}
+
+func (p *serializedDefinitionPublishStub) snapshot() (
+ calls int,
+ maxActive int,
+ reports []report.MetadataReport,
+ batches [][]*common.URL,
+) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ batches = make([][]*common.URL, len(p.batches))
+ for i := range p.batches {
+ batches[i] = append([]*common.URL(nil), p.batches[i]...)
+ }
+ return p.calls, p.maxActive, append([]report.MetadataReport(nil), p.reports...), batches
+}
+
+func TestDefinitionPublishWorkerSerializesAndCoalesces(t *testing.T) {
+ stub := &serializedDefinitionPublishStub{
+ firstStarted: make(chan struct{}),
+ releaseFirst: make(chan struct{}),
+ secondDone: make(chan struct{}),
+ }
+ installDefinitionPublishFunc(t, stub.publish)
+
+ reportA := &mockMetadataReportForGC{}
+ reg := newDefinitionRetryRegistry(t)
+ reg.metadataReport = reportA
+ first := definitionTestURL("org.test.First")
+ second := definitionTestURL("org.test.Second")
+ latest := definitionTestURL("org.test.Latest")
+
+ reg.scheduleServiceDefinitionPublish([]*common.URL{first})
+ select {
+ case <-stub.firstStarted:
+ case <-time.After(2 * time.Second):
+ t.Fatal("initial definition publish did not start")
+ }
+
+ // Both calls must return while the first backend write is blocked. The
+ // worker keeps only the latest full snapshot for the next pass.
+ reg.scheduleServiceDefinitionPublish([]*common.URL{second})
+ reg.scheduleServiceDefinitionPublish([]*common.URL{latest})
+ close(stub.releaseFirst)
+
+ select {
+ case <-stub.secondDone:
+ case <-time.After(2 * time.Second):
+ t.Fatal("coalesced definition publish did not run")
+ }
+
+ assert.Eventually(t, func() bool {
+ reg.lock.RLock()
+ defer reg.lock.RUnlock()
+ return !reg.definitionPublishRunning
+ }, 2*time.Second, 5*time.Millisecond)
+
+ calls, maxActive, reports, batches := stub.snapshot()
+ assert.Equal(t, 2, calls)
+ assert.Equal(t, 1, maxActive, "one registry must never write definitions concurrently")
+ assert.Equal(t, []report.MetadataReport{reportA, reportA}, reports)
+ require.Len(t, batches, 2)
+ assert.Equal(t, []*common.URL{first}, batches[0])
+ assert.Equal(t, []*common.URL{latest}, batches[1], "intermediate full snapshots should be coalesced")
+}
+
+func TestDefinitionPublishWorkerDropsPendingWorkOnDestroy(t *testing.T) {
+ stub := &serializedDefinitionPublishStub{
+ firstStarted: make(chan struct{}),
+ releaseFirst: make(chan struct{}),
+ secondDone: make(chan struct{}),
+ }
+ installDefinitionPublishFunc(t, stub.publish)
+
+ reg := newDefinitionRetryRegistry(t)
+ reg.scheduleServiceDefinitionPublish([]*common.URL{definitionTestURL("org.test.InFlight")})
+ select {
+ case <-stub.firstStarted:
+ case <-time.After(2 * time.Second):
+ t.Fatal("initial definition publish did not start")
+ }
+ reg.scheduleServiceDefinitionPublish([]*common.URL{definitionTestURL("org.test.Pending")})
+
+ reg.lock.Lock()
+ reg.destroyed = true
+ reg.cancelDefinitionPublishLocked()
+ reg.lock.Unlock()
+ close(stub.releaseFirst)
+
+ assert.Eventually(t, func() bool {
+ reg.lock.RLock()
+ defer reg.lock.RUnlock()
+ return !reg.definitionPublishRunning
+ }, 2*time.Second, 5*time.Millisecond)
+ calls, maxActive, _, _ := stub.snapshot()
+ assert.Equal(t, 1, calls, "destroy must discard a full publish that has not started")
+ assert.Equal(t, 1, maxActive)
+}
+
+func TestDefinitionRetrySerializesWithFullPublish(t *testing.T) {
+ stub := &serializedDefinitionPublishStub{
+ firstStarted: make(chan struct{}),
+ releaseFirst: make(chan struct{}),
+ secondDone: make(chan struct{}),
+ }
+ installDefinitionPublishFunc(t, stub.publish)
+
+ reg := newDefinitionRetryRegistry(t)
+ reg.scheduleServiceDefinitionPublish([]*common.URL{definitionTestURL("org.test.Full")})
+ select {
+ case <-stub.firstStarted:
+ case <-time.After(2 * time.Second):
+ t.Fatal("full definition publish did not start")
+ }
+
+ retryURL := definitionTestURL("org.test.RetryWhileFull")
+ key := retryURL.ServiceKey()
+ reg.lock.Lock()
+ reg.definitionRetries[key] = &definitionRetry{url: retryURL}
+ reg.lock.Unlock()
+ go reg.retryPublishDefinition(key)
+
+ // The retry goroutine may be waiting, but it must not enter the backend
+ // while the full publish is in flight.
+ assert.Eventually(t, func() bool {
+ reg.lock.RLock()
+ defer reg.lock.RUnlock()
+ return reg.definitionRetries[key].inFlight
+ }, 2*time.Second, 5*time.Millisecond)
+ calls, maxActive, _, _ := stub.snapshot()
+ assert.Equal(t, 1, calls)
+ assert.Equal(t, 1, maxActive)
+
+ close(stub.releaseFirst)
+ select {
+ case <-stub.secondDone:
+ case <-time.After(2 * time.Second):
+ t.Fatal("serialized definition retry did not run")
+ }
+ calls, maxActive, _, _ = stub.snapshot()
+ assert.Equal(t, 2, calls)
+ assert.Equal(t, 1, maxActive)
+}
diff --git a/registry/servicediscovery/service_discovery_registry.go b/registry/servicediscovery/service_discovery_registry.go
index df41e5453..d4cc013e2 100644
--- a/registry/servicediscovery/service_discovery_registry.go
+++ b/registry/servicediscovery/service_discovery_registry.go
@@ -72,8 +72,17 @@ type serviceDiscoveryRegistry struct {
serviceMappingListeners map[string]mapping.MappingListener
// subscribeRetries holds at most one pending AddListener retry per
// serviceNamesKey. Guarded by lock.
- subscribeRetries map[string]*subscribeRetry
- renewAppMetadataTimer *time.Timer
+ subscribeRetries map[string]*subscribeRetry
+ // definitionRetries holds at most one pending service-definition publish
+ // retry per service key. Guarded by lock.
+ definitionRetries map[string]*definitionRetry
+ // Full definition publishes are coalesced behind one worker per registry.
+ // The state is guarded by lock; definitionPublishMu serializes the backend
+ // call with per-service retries as well.
+ definitionPublishRunning bool
+ definitionPublishPending []*common.URL
+ definitionPublishMu sync.Mutex
+ renewAppMetadataTimer *time.Timer
// destroyed is set by Destroy. SubscribeURL re-checks it under lock right
// before installing a listener, so a subscribe whose initial GetInstances /
// metadata phase outlives Destroy is discarded instead of being installed
@@ -98,6 +107,7 @@ func newServiceDiscoveryRegistry(url *common.URL) (registry.Registry, error) {
metadataReport: metadata.GetMetadataReportByRegistry(url.GetParam(constant.RegistryIdKey, "")),
serviceListeners: make(map[string]registry.ServiceInstancesChangedListener),
subscribeRetries: make(map[string]*subscribeRetry),
+ definitionRetries: make(map[string]*definitionRetry),
// cache for mapping listener
serviceMappingListeners: make(map[string]mapping.MappingListener),
}, nil
@@ -107,13 +117,19 @@ func isPublishableRevision(revision string) bool {
return len(revision) > 0 && revision != "0" && revision != "N/A"
}
-// startMetadataTimers starts the renewAppMetadata timer if metadata type is remote.
-// GC runs after each renew cycle inside doRenewAppMetadata.
+// startMetadataTimers starts the daily renew timer when there is anything for
+// it to refresh. GC runs after each renew cycle inside doRenewAppMetadata.
+//
+// Two independent things want the timer. Remote application metadata needs
+// re-publishing, and so do interface-level service definitions — which are
+// published regardless of metadataType, so the timer must start for them even
+// when application metadata lives locally. Each half guards itself inside
+// doRenewAppMetadata.
func (s *serviceDiscoveryRegistry) startMetadataTimers() {
- if metadata.GetMetadataType() != constant.RemoteMetadataStorageType {
+ if s.metadataReport == nil {
return
}
- if s.metadataReport == nil {
+ if !renewsAppMetadata() && !metadata.ServiceDefinitionsEnabled(s.metadataReport) {
return
}
metaInfo := metadata.GetMetadataInfo(s.url.GetParam(constant.RegistryIdKey, ""))
@@ -123,6 +139,12 @@ func (s *serviceDiscoveryRegistry) startMetadataTimers() {
s.startRenewAppMetadataTimer()
}
+// renewsAppMetadata reports whether application metadata lives in the metadata
+// center, and therefore needs the daily re-publish.
+func renewsAppMetadata() bool {
+ return metadata.GetMetadataType() == constant.RemoteMetadataStorageType
+}
+
func (s *serviceDiscoveryRegistry) RegisterService() error {
registryId := s.url.GetParam(constant.RegistryIdKey, constant.DefaultKey)
metaInfo := metadata.GetMetadataInfo(registryId)
@@ -166,6 +188,18 @@ func (s *serviceDiscoveryRegistry) RegisterService() error {
s.lock.Unlock()
}
+ // Interface-level service definitions are a separate data path from the
+ // application-level metadata above: they describe the RPC contract rather
+ // than what this instance currently exports, and Admin discovers them by
+ // their own key. They are therefore published regardless of metadataType,
+ // which only governs where application metadata lives.
+ //
+ // Scheduled rather than run inline, and after registration rather than
+ // before, so the metadata center is never in the path of an instance
+ // entering traffic. Failures retry with backoff; the daily cycle report is
+ // the backstop past that.
+ s.scheduleServiceDefinitionPublish(urls)
+
s.lock.Lock()
if s.renewAppMetadataTimer == nil {
s.startMetadataTimers()
@@ -376,6 +410,9 @@ func (s *serviceDiscoveryRegistry) Destroy() {
// Cancel pending AddListener retries so their timers cannot leak.
s.cancelSubscribeRetryLocked(key)
}
+ // Same for pending definition publish retries and queued full publishes.
+ s.cancelDefinitionRetriesLocked()
+ s.cancelDefinitionPublishLocked()
for _, l := range s.serviceListeners {
// Destroy drops listeners without RemoveListener; cancel any pending
// metadata retry so its timer cannot leak.
@@ -399,6 +436,242 @@ func (s *serviceDiscoveryRegistry) stopMetadataTimers() {
}
}
+// ========== service definitions: publish with bounded retry ==========
+
+var (
+ // definitionRetryInitialDelay is the first backoff delay before retrying a
+ // failed definition publish. Package-level so tests can shrink it.
+ definitionRetryInitialDelay = time.Second
+ // definitionRetryMaxDelay caps the backoff.
+ definitionRetryMaxDelay = 30 * time.Second
+ // definitionRetryMaxAttempts bounds the retries, unlike the deliberately
+ // unlimited subscribe retry next door. The two differ because their
+ // backstops do: a subscription that never establishes leaves a permanently
+ // stale consumer and nothing else will fix it, whereas a definition that
+ // never publishes is re-attempted by the daily cycle report. Retrying past
+ // the point where the metadata center is plainly not coming back would only
+ // add log noise to a failure the daily pass already owns.
+ //
+ // With the delays above this spans roughly four minutes, which covers the
+ // case this exists for: the metadata center bouncing while a provider
+ // happens to start.
+ definitionRetryMaxAttempts = 10
+
+ // publishDefinitions indirects the publish call so tests can drive the
+ // retry loop without a live metadata center.
+ publishDefinitions = metadata.PublishServiceDefinitions
+)
+
+// definitionRetry is a pending or in-flight definition publish retry for one
+// service key. Mirrors subscribeRetry: the entry stays in the map while an
+// attempt is in flight so a concurrent failure cannot reset the backoff.
+type definitionRetry struct {
+ url *common.URL
+ timer *time.Timer
+ attempts int
+ inFlight bool
+ canceled bool
+}
+
+// publishServiceDefinitions publishes definitions for urls and arms a retry for
+// whatever failed to reach the metadata center.
+//
+// Synchronous. The full-publish worker uses this after removing the work from
+// the registration path; callers on that path must use
+// scheduleServiceDefinitionPublish instead.
+func (s *serviceDiscoveryRegistry) publishServiceDefinitions(urls []*common.URL) {
+ for _, u := range s.publishServiceDefinitionsNow(urls) {
+ s.scheduleDefinitionRetry(u)
+ }
+}
+
+// publishServiceDefinitionsNow serializes every backend definition write for
+// this registry, including full publishes and per-service retries.
+//
+// Destroy deliberately does not wait for a call that has already entered the
+// backend: the Nacos API has no context/cancellation parameter. The call may
+// finish within the backend timeout, but no retry or queued full publish will
+// follow it.
+func (s *serviceDiscoveryRegistry) publishServiceDefinitionsNow(urls []*common.URL) []*common.URL {
+ s.definitionPublishMu.Lock()
+ defer s.definitionPublishMu.Unlock()
+
+ s.lock.RLock()
+ destroyed := s.destroyed
+ reportInstance := s.metadataReport
+ s.lock.RUnlock()
+ if destroyed {
+ return nil
+ }
+ return publishDefinitions(reportInstance, urls)
+}
+
+// scheduleServiceDefinitionPublish hands a full publish to the registry's
+// single worker instead of running it inline.
+//
+// Publishing reaches the metadata center synchronously, once per service. On the
+// registration path that cost lands between "the process is ready" and "the
+// instance is discoverable": a slow or unreachable Nacos delays every service in
+// turn, holding an otherwise healthy provider out of traffic for as long as the
+// writes take. Definitions are console metadata, so nothing about them justifies
+// that.
+//
+// Repeated schedules are coalesced to the latest full URL snapshot. Destroy
+// discards a snapshot that has not started, while an already-running backend
+// call is allowed to finish without blocking shutdown. Failures land in the
+// same per-service retry as any other publish.
+func (s *serviceDiscoveryRegistry) scheduleServiceDefinitionPublish(urls []*common.URL) {
+ s.lock.Lock()
+ if s.destroyed {
+ s.lock.Unlock()
+ return
+ }
+ s.definitionPublishPending = append([]*common.URL(nil), urls...)
+ if s.definitionPublishRunning {
+ s.lock.Unlock()
+ return
+ }
+ s.definitionPublishRunning = true
+ s.lock.Unlock()
+
+ go s.runDefinitionPublishWorker()
+}
+
+func (s *serviceDiscoveryRegistry) runDefinitionPublishWorker() {
+ for {
+ s.lock.Lock()
+ if s.destroyed || len(s.definitionPublishPending) == 0 {
+ s.definitionPublishPending = nil
+ s.definitionPublishRunning = false
+ s.lock.Unlock()
+ return
+ }
+ urls := s.definitionPublishPending
+ s.definitionPublishPending = nil
+ s.lock.Unlock()
+
+ s.publishServiceDefinitions(urls)
+ }
+}
+
+// cancelDefinitionPublishLocked drops a full snapshot that has not started.
+// Caller must hold s.lock. An in-flight backend call cannot be interrupted, but
+// the worker observes destroyed and exits without consuming another snapshot.
+func (s *serviceDiscoveryRegistry) cancelDefinitionPublishLocked() {
+ s.definitionPublishPending = nil
+}
+
+// scheduleDefinitionRetry arms the retry timer for one service. One pending
+// retry per service key: repeated failures share the same state rather than
+// stacking new ones, so the backoff is not reset by a later attempt.
+func (s *serviceDiscoveryRegistry) scheduleDefinitionRetry(u *common.URL) {
+ key := u.ServiceKey()
+
+ s.lock.Lock()
+ defer s.lock.Unlock()
+ if s.destroyed {
+ return
+ }
+ if _, pending := s.definitionRetries[key]; pending {
+ return
+ }
+ if s.definitionRetries == nil {
+ // A registry built as a struct literal rather than through
+ // newServiceDiscoveryRegistry still has to be safe to publish from.
+ s.definitionRetries = make(map[string]*definitionRetry)
+ }
+ state := &definitionRetry{url: u}
+ s.definitionRetries[key] = state
+ s.armDefinitionRetryLocked(key, state)
+}
+
+// armDefinitionRetryLocked computes the next backoff delay and arms the timer;
+// caller must hold s.lock and the state must be in the map.
+func (s *serviceDiscoveryRegistry) armDefinitionRetryLocked(key string, state *definitionRetry) {
+ if state.attempts >= definitionRetryMaxAttempts {
+ logger.Warnf("[Metadata][Definition] giving up on publishing %s after %d attempts; "+
+ "the daily cycle report will try again", key, state.attempts)
+ delete(s.definitionRetries, key)
+ return
+ }
+
+ delay := definitionRetryDelay(state.attempts)
+ state.attempts++
+ state.timer = time.AfterFunc(delay, func() {
+ s.retryPublishDefinition(key)
+ })
+ logger.Debugf("[Metadata][Definition] definition for %s not published, retry in %s", key, delay)
+}
+
+func (s *serviceDiscoveryRegistry) retryPublishDefinition(key string) {
+ s.lock.Lock()
+ state, ok := s.definitionRetries[key]
+ if !ok {
+ s.lock.Unlock()
+ return
+ }
+ state.timer = nil
+ state.inFlight = true
+ active := !s.destroyed && !state.canceled
+ s.lock.Unlock()
+
+ if !active {
+ s.lock.Lock()
+ if s.definitionRetries[key] == state {
+ delete(s.definitionRetries, key)
+ }
+ s.lock.Unlock()
+ return
+ }
+
+ failed := s.publishServiceDefinitionsNow([]*common.URL{state.url})
+
+ s.lock.Lock()
+ defer s.lock.Unlock()
+ state.inFlight = false
+ if s.definitionRetries[key] != state {
+ // Superseded or canceled while in flight.
+ return
+ }
+ if len(failed) == 0 || s.destroyed || state.canceled {
+ delete(s.definitionRetries, key)
+ return
+ }
+ // Re-arm in place: the entry never left the map, so the backoff continues
+ // from where it was instead of restarting.
+ s.armDefinitionRetryLocked(key, state)
+}
+
+// cancelDefinitionRetriesLocked stops every pending definition retry; caller
+// must hold s.lock. An in-flight attempt is not interrupted but will not
+// reschedule.
+func (s *serviceDiscoveryRegistry) cancelDefinitionRetriesLocked() {
+ for key, state := range s.definitionRetries {
+ state.canceled = true
+ if state.timer != nil {
+ state.timer.Stop()
+ state.timer = nil
+ }
+ if !state.inFlight {
+ delete(s.definitionRetries, key)
+ }
+ }
+}
+
+// definitionRetryDelay grows exponentially from definitionRetryInitialDelay,
+// capped at definitionRetryMaxDelay, plus up to 25% jitter so providers
+// restarting together do not retry in lockstep.
+func definitionRetryDelay(attempt int) time.Duration {
+ delay := definitionRetryMaxDelay
+ if attempt >= 0 && attempt < 30 { // guard against shift overflow
+ delay = definitionRetryInitialDelay << attempt
+ if delay <= 0 || delay > definitionRetryMaxDelay {
+ delay = definitionRetryMaxDelay
+ }
+ }
+ return delay + time.Duration(rand.Int64N(int64(delay/4)+1))
+}
+
// ========== renewAppMetadata: daily app-level metadata re-publish ==========
// metadataReportURL returns the URL from the metadata report instance.
@@ -439,20 +712,40 @@ func (s *serviceDiscoveryRegistry) doRenewAppMetadata() {
return
}
- // Copy snapshot to avoid data race
- snapshot := metaInfo.Snapshot()
- snapshot.LastUpdatedTime = time.Now().UnixMilli()
- if err := s.metadataReport.PublishAppMetadata(snapshot.App, snapshot.Revision, &snapshot); err != nil {
- logger.Errorf("[Metadata][renewAppMetadata] failed to re-publish metadata for app=%s revision=%s: %v", snapshot.App, snapshot.Revision, err)
- } else {
- logger.Infof("[Metadata][renewAppMetadata] refreshed metadata for app=%s revision=%s", snapshot.App, snapshot.Revision)
- }
+ if renewsAppMetadata() {
+ // Copy snapshot to avoid data race
+ snapshot := metaInfo.Snapshot()
+ snapshot.LastUpdatedTime = time.Now().UnixMilli()
+ if err := s.metadataReport.PublishAppMetadata(snapshot.App, snapshot.Revision, &snapshot); err != nil {
+ logger.Errorf("[Metadata][renewAppMetadata] failed to re-publish metadata for app=%s revision=%s: %v", snapshot.App, snapshot.Revision, err)
+ } else {
+ logger.Infof("[Metadata][renewAppMetadata] refreshed metadata for app=%s revision=%s", snapshot.App, snapshot.Revision)
+ }
- // Run garbage collection if enabled, after each renew cycle
- reportURL := s.metadataReportURL()
- if reportURL != nil && reportURL.GetParamBool(constant.MetadataGCEnabledKey, true) {
- s.doGarbageCollect()
+ // Run garbage collection if enabled, after each renew cycle. It reasons
+ // about application metadata revisions, so it only applies here.
+ reportURL := s.metadataReportURL()
+ if reportURL != nil && reportURL.GetParamBool(constant.MetadataGCEnabledKey, true) {
+ s.doGarbageCollect()
+ }
}
+
+ // Re-publish interface-level definitions.
+ //
+ // Nothing ever deletes a definition — its key holds no instance and no
+ // revision, and a provider killed with SIGKILL gets no cleanup hook — so
+ // operators need some way to tell a live contract from an abandoned one.
+ // This pass is that way: every service a running provider still exports
+ // gets its timestamp refreshed daily, while a service that was removed from
+ // the code is never republished and its timestamp freezes. "Last updated
+ // more than about two days ago and no live instance" then becomes a safe
+ // death test.
+ //
+ // Without the refresh, never-deleting would be an unbounded leak with no
+ // way to distinguish the garbage. Java relies on exactly this property, via
+ // AbstractMetadataReport's daily publishAll over the same 02:00–06:00
+ // window.
+ s.scheduleServiceDefinitionPublish(metaInfo.GetExportedServiceURLs())
}
func (s *serviceDiscoveryRegistry) calculateRenewAppMetadataDelay() time.Duration {
diff --git a/registry/servicediscovery/service_discovery_registry_test.go b/registry/servicediscovery/service_discovery_registry_test.go
index bc6740558..8347527d6 100644
--- a/registry/servicediscovery/service_discovery_registry_test.go
+++ b/registry/servicediscovery/service_discovery_registry_test.go
@@ -1074,6 +1074,16 @@ func TestServiceDiscoveryRegistry_DoGarbageCollect_SkipsReferencedStaleRevision(
}
func TestServiceDiscoveryRegistry_DoRenewAppMetadata(t *testing.T) {
+ // doRenewAppMetadata refreshes application metadata only when it lives in
+ // the metadata center; the definition half of the pass runs regardless.
+ prevType := metadata.GetMetadataType()
+ opts := metadata.NewOptions(metadata.WithMetadataType(constant.RemoteMetadataStorageType))
+ _ = opts.Init()
+ defer func() {
+ restoreOpts := metadata.NewOptions(metadata.WithMetadataType(prevType))
+ _ = restoreOpts.Init()
+ }()
+
mockReport := &mockMetadataReportForGC{}
regID := fmt.Sprintf("renew-reg-%d", time.Now().UnixNano())
@@ -1100,6 +1110,44 @@ func TestServiceDiscoveryRegistry_DoRenewAppMetadata(t *testing.T) {
assert.Equal(t, 1, mockReport.published)
}
+// TestServiceDiscoveryRegistry_DoRenewSkipsAppMetadataWhenLocal pins the guard
+// that moved from startMetadataTimers into doRenewAppMetadata. The timer now
+// also starts for interface-level definitions, which publish regardless of
+// metadataType, so the pass can run while application metadata lives locally —
+// and must not push it to the metadata center then.
+func TestServiceDiscoveryRegistry_DoRenewSkipsAppMetadataWhenLocal(t *testing.T) {
+ prevType := metadata.GetMetadataType()
+ opts := metadata.NewOptions(metadata.WithMetadataType(constant.DefaultMetadataStorageType))
+ _ = opts.Init()
+ defer func() {
+ restoreOpts := metadata.NewOptions(metadata.WithMetadataType(prevType))
+ _ = restoreOpts.Init()
+ }()
+
+ mockReport := &mockMetadataReportForGC{}
+ regID := fmt.Sprintf("renew-local-reg-%d", time.Now().UnixNano())
+ reg := &serviceDiscoveryRegistry{
+ url: common.NewURLWithOptions(
+ common.WithParamsValue(constant.RegistryIdKey, regID),
+ ),
+ metadataReport: mockReport,
+ }
+
+ serviceURL, _ := common.NewURL("dubbo://127.0.0.1:20880/org.test.RenewLocal",
+ common.WithParamsValue(constant.ApplicationKey, "test-app"),
+ common.WithParamsValue(constant.SideKey, constant.SideProvider),
+ )
+ metadata.AddService(regID, serviceURL)
+ metaInfo := metadata.GetMetadataInfo(regID)
+ require.NotNil(t, metaInfo)
+ metaInfo.Revision = "abc123"
+
+ assert.NotPanics(t, func() { reg.doRenewAppMetadata() })
+ assert.Equal(t, 0, mockReport.published,
+ "application metadata is not the metadata center's business in local mode")
+ assert.Empty(t, mockReport.deleted, "GC reasons about revisions, so it is skipped too")
+}
+
// TestServiceDiscoveryRegistry_StartMetadataTimersSkipsEmptyRevision verifies that the
// renewAppMetadata timer is not started while the metadata revision is an empty
// marker ("" initial state, "0" after exports are cleared, or "N/A" customizer
diff --git a/server/server.go b/server/server.go
index 743cad34d..5ed250445 100644
--- a/server/server.go
+++ b/server/server.go
@@ -186,8 +186,10 @@ func (s *Server) genSvcOpts(handler any, info *common.ServiceInfo, opts ...Servi
newSvcOpts.Id = interfaceName
newSvcOpts.Implement(handler)
newSvcOpts.info = enhanceServiceInfo(info)
- // Warn on exported variadic RPC methods without blocking registration.
- common.WarnVariadicRPCMethods(serviceNameForWarning(interfaceName, svcConf.Interface), handler)
+ // Warn on accepted RPC shapes that need care in generic/cross-language APIs,
+ // without blocking registration.
+ warnName := serviceNameForWarning(interfaceName, svcConf.Interface)
+ common.WarnRPCMethods(warnName, handler)
return newSvcOpts, nil
}