feat: grpc 기능 deep 구현
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
# ADR-GRPC-001: The gRPC platform ships as a registered family, not as one adapter leaf
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-30
|
||||
- Scope: `:grpc:*`, `:grpc-advanced:*`, `src/config/architecture/modules.json`
|
||||
|
||||
## Context
|
||||
|
||||
The two source plans describe a type-safe gRPC execution platform with its own API, SPI, adapters and
|
||||
composition root: fifteen Stable modules under `modules/grpc` and sixteen Advanced ones under
|
||||
`modules/grpc-advanced`, on Gradle Kotlin DSL, in package `io.backend.skeleton.grpc`, against
|
||||
Spring Boot 4.1.
|
||||
|
||||
None of that layout exists here. This repository uses Groovy DSL, a fail-closed module registry that
|
||||
owns the leaf list, package root `dev.caskeleton`, and Spring Boot 4.0.8. The plans anticipate this:
|
||||
their last Global Constraint says that when the repository structure differs, file paths are remapped
|
||||
and the public contracts, invariants and test meanings are not changed.
|
||||
|
||||
Two shapes were available. Fold the platform into the existing `:adapter:inbound:grpc` leaf as
|
||||
packages — which is what the JPA, GraphQL, WebSocket and HTTP platforms did here — or register it as
|
||||
a family the way `messaging:*` is registered.
|
||||
|
||||
## Decision
|
||||
|
||||
Register it as a family: twelve Stable leaves under `src/grpc/` and six Advanced ones under
|
||||
`src/grpc-advanced/`.
|
||||
|
||||
The deciding property is that this is not a layer of this application. Root `CLAUDE.md` already
|
||||
describes `messaging:*` as "a vendored messaging platform: a product with its own API, SPI, adapters
|
||||
and composition boundary, not a layer of this application", and the gRPC platform is the same shape
|
||||
for the same reason — the application is meant to reach it the way it reaches a library, through an
|
||||
application-owned port. The four platforms that became packages are all layers of this application;
|
||||
this one is not.
|
||||
|
||||
The split between `src/grpc/` and `src/grpc-advanced/` is not organisational. The Stable plan
|
||||
requires that the Stable starter's build fail if it reaches an Advanced module, and separate Gradle
|
||||
path prefixes make that a `verifyCleanArchitectureDependencies` failure rather than a review note:
|
||||
`grpc-spring-boot-starter`'s registry entry names no advanced id, and it cannot acquire one silently.
|
||||
|
||||
## Consequences
|
||||
|
||||
**The registry grew from 44 leaves to 62.** That is a large registry change, made deliberately and in
|
||||
one place. Every new leaf is `runtime_memberships: []`, so nothing ships until a second, explicit
|
||||
decision moves it.
|
||||
|
||||
**The advanced boundary is checked twice.** Once by the registry at build time, and once by
|
||||
`GrpcStableBuildInvariant` at runtime, because a fat jar or a shaded artifact is assembled by
|
||||
something the registry never sees.
|
||||
|
||||
**Four testkit modules became four test lanes.** The plan's split exists so in-process results cannot
|
||||
be mistaken for network results; this repository expresses that with `ca.strict-test-lane`, whose
|
||||
lanes fail when they discover nothing and never serve an up-to-date result. `GrpcEvidenceGrade` keeps
|
||||
the same rule inside the code, so a report cannot cite a contract run as transport evidence.
|
||||
|
||||
**Codegen is not wired.** See ADR-GRPC-002.
|
||||
@@ -0,0 +1,62 @@
|
||||
# ADR-GRPC-002: Schema governance runs without protoc and without the Buf CLI
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-30
|
||||
- Scope: `:grpc:grpc-proto-contract`, `:grpc:grpc-codegen`
|
||||
|
||||
## Context
|
||||
|
||||
Stable Tasks 8 through 11 require proto style rules, Buf format/lint/breaking governance, a single
|
||||
Java codegen owner, and a descriptor artifact whose consumer-compile result gates a release.
|
||||
|
||||
Two of the tools those tasks name are absent from this toolchain. The Buf CLI is not installed. And
|
||||
`protoc` is available through the Gradle protobuf plugin, but every leaf in this repository passes
|
||||
spotless with google-java-format, checkstyle, SpotBugs at HIGH confidence, Error Prone and `-Werror`
|
||||
— and generated protobuf sources pass none of them. Turning codegen on means excluding a source set
|
||||
from five quality gates.
|
||||
|
||||
There is precedent for such an exclusion: the `jmh` source set has `spotbugsJmh` and `checkstyleJmh`
|
||||
disabled and Error Prone off. So the carve-out is available. It is also a decision about the quality
|
||||
baseline of a leaf, taken for one task, and outside what this work was asked to change.
|
||||
|
||||
`adapter:inbound:grpc` also carries a recorded decision in the opposite direction: its `CLAUDE.md`
|
||||
forbids the protobuf plugin and `.proto` in that leaf, on the grounds that a consuming feature module
|
||||
should own its schema.
|
||||
|
||||
## Decision
|
||||
|
||||
Commit the `.proto` sources and implement every rule the tasks require as executable Java, with no
|
||||
protoc run and no Buf CLI invocation.
|
||||
|
||||
`GrpcProtoContractValidator` reads `.proto` text and enforces proto3 syntax, the
|
||||
`{organization}.{domain}.v{major}` package rule, `java_multiple_files`, a generated Java package
|
||||
disjoint from the hand-written one, `_UNSPECIFIED` enum zero values, `reserved` declarations checked
|
||||
against a supplied removal history, a well-known-type allowlist and a map-field allowlist. It runs
|
||||
against the committed schema in its own test, so the shipped `.proto` files are live rather than
|
||||
decorative.
|
||||
|
||||
`GrpcBufPolicy` fixes the breaking gate at Buf's `FILE` category and names the four lifecycle stages
|
||||
a compliant pipeline registers. `GrpcCodegenManifest` fixes one codegen owner and refuses a literal
|
||||
generator version. `GrpcDescriptorArtifact`, `GrpcConsumerFixture` and `GrpcSchemaArtifactPublisher`
|
||||
carry the schema hash, the descriptor digest and the per-consumer source-break report, and refuse a
|
||||
publish that breaks a consumer or republishes a released version with different bytes.
|
||||
|
||||
The committed `buf.yaml` states the same rules, so running the CLI in an environment that has it
|
||||
reaches the same verdict.
|
||||
|
||||
## Consequences
|
||||
|
||||
**The invariants are enforced; the process is not run.** Everything Tasks 8 to 11 are about — which
|
||||
schema changes are refused, which consumer breaks block a release, who owns generation — is a
|
||||
build-checkable rule here. What is missing is the protoc invocation and the Buf binary.
|
||||
|
||||
**Turning codegen on is a bounded change.** `GrpcCodegenManifest.caSkeleton()` already names the
|
||||
owner, the managed version source, the build-directory output paths and the disjoint package policy
|
||||
that a real plugin configuration has to satisfy. The work is a source-set carve-out and a plugin
|
||||
block, not a redesign.
|
||||
|
||||
**The fixtures use a text codec.** `GrpcTextCodec` gives the testkit a UTF-8 marshaller so the
|
||||
in-process and Netty lanes can exercise interceptors, status mapping, metadata limits and stream
|
||||
sequencing without generated stubs. Those contracts are properties of the platform and the transport,
|
||||
not of any message shape, so the substitution costs nothing — and the lanes run today rather than
|
||||
after codegen lands.
|
||||
@@ -0,0 +1,50 @@
|
||||
# ADR-GRPC-003: Transport, business and stream evidence are three axes, and none implies another
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-30
|
||||
- Scope: `:grpc:grpc-core-api`, `:grpc:grpc-policy`, `:grpc:grpc-testkit`
|
||||
|
||||
## Context
|
||||
|
||||
A failed RPC produces a status code, and a status code is not an answer to the question the caller
|
||||
actually has. `DEADLINE_EXCEEDED` on a mutation does not say whether the mutation happened;
|
||||
`UNAVAILABLE` after the request was sent does not say the server never saw it; response headers
|
||||
arriving does not say a transaction committed.
|
||||
|
||||
Every one of those is a place where a plausible inference produces a duplicate write or a lost one,
|
||||
and none of them is visible in a test that only exercises the happy path.
|
||||
|
||||
## Decision
|
||||
|
||||
Model what happened as three independent axes, and refuse the inferences between them.
|
||||
|
||||
`GrpcTransportEvidence` records what the client observed on the wire, and distinguishes `NOT_SENT` —
|
||||
the client watched its own send fail — from `UNOBSERVED`, which is every other case where nothing is
|
||||
known. `GrpcBusinessEvidence` records what the application confirmed, with `COMMIT_UNKNOWN` as a real
|
||||
state rather than a placeholder. `GrpcStreamEvidence` is a sealed hierarchy whose non-empty cases all
|
||||
carry a position, because "partial" without a last sequence can be neither resumed nor reconciled.
|
||||
|
||||
`GrpcExecutionEvidence` holds all three and rejects combinations nobody could have observed: a unary
|
||||
call with stream evidence, or a request the client watched fail to send that nonetheless carries
|
||||
business evidence. Promoting response headers to a confirmed commit is possible only by editing
|
||||
`withResponseHeadersSeen`, which is one method rather than a plausible line in an interceptor.
|
||||
|
||||
`GrpcCompletionOutcome.forMutation` derives what a caller may conclude, and defaults
|
||||
`DEADLINE_EXCEEDED` and post-send `UNAVAILABLE` on a mutation to `COMPLETION_UNKNOWN`.
|
||||
|
||||
The same types are used by the failure model and by the observation convention, so an incident has
|
||||
one account of a call rather than two.
|
||||
|
||||
## Consequences
|
||||
|
||||
**A whole class of retry bug becomes unrepresentable.** `GrpcRetryEligibility` reads all three axes
|
||||
plus the idempotency profile; a caller cannot reach "retry" from a status alone because the status
|
||||
alone is not an input.
|
||||
|
||||
**The fault lane has something to check.** `GrpcTransportEvidenceClassifier` turns a client's
|
||||
observations into evidence and refuses to infer `NOT_SENT` from an unobserved state — and the lane
|
||||
exercises it against a real connection dropped mid-call, not against a mock.
|
||||
|
||||
**Callers must handle a third outcome.** `COMPLETION_UNKNOWN` is not a failure and not a success, and
|
||||
a caller that treats it as either is wrong. `GrpcOperationStatusQuery` and `GrpcCompletionReconciler`
|
||||
exist so that resolving it is a supported path rather than an exercise for the caller.
|
||||
@@ -0,0 +1,52 @@
|
||||
# ADR-GRPC-004: One retry owner, and keyed mutations need a durable ledger
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-30
|
||||
- Scope: `:grpc:grpc-policy`, `:grpc:grpc-operation-ledger-jpa`, `:grpc:grpc-core-api`
|
||||
|
||||
## Context
|
||||
|
||||
Three layers can retry a gRPC call: the application, the channel's service config, and a service
|
||||
mesh. Their effects multiply. Three attempts at each layer is twenty-seven requests for one call, and
|
||||
the load arrives exactly when the dependency is already failing.
|
||||
|
||||
Separately, a mutation that is safe to repeat needs somewhere to record that it ran. Without one, a
|
||||
retry after a lost response either duplicates the effect or drops it, and nothing distinguishes the
|
||||
two afterwards.
|
||||
|
||||
## Decision
|
||||
|
||||
**Exactly one retry owner per channel.** `GrpcRetryOwner` has four values including `NONE`, which is a
|
||||
decision rather than an omission. `GrpcServiceConfigPolicy` refuses an in-process retry entry when the
|
||||
owner is the mesh or nobody, and `GrpcRetryOwnershipValidator` compares the service config's method
|
||||
names against the policy catalog — a renamed method leaves its retry entry matching nothing, silently,
|
||||
and the method then runs with channel defaults.
|
||||
|
||||
**Retry eligibility reads the method, the evidence and the status together.**
|
||||
`GrpcRetryEligibility` refuses a non-idempotent method outright, refuses any call whose stream
|
||||
delivered a prefix, and turns a `DEADLINE_EXCEEDED` or post-send `UNAVAILABLE` mutation into
|
||||
"resolve the completion first" rather than a retry.
|
||||
|
||||
**A keyed mutation is retryable only with both a caller key and a durable ledger.**
|
||||
`GrpcOperationLedger` is a port in `grpc-core-api`, so the policy layer can require durable
|
||||
idempotency without depending on a database. Its `claim` contract is a single atomic insert-or-read
|
||||
against a unique constraint: `JpaGrpcOperationLedger` inserts first and reads on constraint violation,
|
||||
because a read-then-insert implementation has a window exactly as wide as the race it closes and
|
||||
passes every test that does not run two attempts concurrently.
|
||||
|
||||
The identity is caller fingerprint plus full method plus hashed key. All three are load-bearing:
|
||||
without the caller, one tenant's key suppresses another's write; without the method, a key reused
|
||||
across operations makes the second a replay of the first.
|
||||
|
||||
## Consequences
|
||||
|
||||
**A budget bounds retries as a fraction of traffic.** `GrpcRetryBudget` degrades to roughly no
|
||||
retries when everything is failing, which is the behaviour that lets a dependency recover.
|
||||
|
||||
**The ledger and the mutation should commit together.** `JpaGrpcOperationLedger` carries no
|
||||
transaction annotations, deliberately: a `REQUIRES_NEW` would put the claim in its own transaction and
|
||||
reintroduce the window where the write is durable and the claim is not.
|
||||
|
||||
**A key reused for a different request is a caller error, not a duplicate.** The stored request
|
||||
fingerprint turns that into `FAILED_PRECONDITION` rather than silently returning the first request's
|
||||
answer.
|
||||
@@ -0,0 +1,50 @@
|
||||
# ADR-GRPC-005: One writer per stream, a bounded queue, and resume that refuses to guess
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-30
|
||||
- Scope: `:grpc:grpc-policy`
|
||||
|
||||
## Context
|
||||
|
||||
`StreamObserver` is not thread-safe, and the failure when two producers call `onNext` concurrently is
|
||||
not an exception — it is interleaved bytes, which a client decodes as a corrupt message or, worse, as
|
||||
a valid one it should never have received.
|
||||
|
||||
Two further properties of server streams are easy to get wrong in ways that look healthy. A consumer
|
||||
that falls behind either terminates the stream or silently loses messages, and the second leaves a
|
||||
client with a stream that appears fine and is missing changes. And a reconnect either continues from
|
||||
a position the server can still replay, or skips whatever is no longer there.
|
||||
|
||||
## Decision
|
||||
|
||||
**A bounded queue drained by one writer.** `GrpcSerializedStreamWriter` accepts messages from any
|
||||
thread and hands them to the transport only from `flush`, which is synchronized. `write` returning
|
||||
`ACCEPTED` means queued, and the name is deliberately not `sent`: the transport call returns as soon
|
||||
as bytes are handed over, so no method here can honestly report delivery.
|
||||
|
||||
**Both a message bound and a byte bound.** Either alone is unbounded in the other dimension.
|
||||
`GrpcFlowControlPolicy` also takes the transport's own readiness signal, because a writer that relies
|
||||
only on its queue bound produces as fast as it can allocate.
|
||||
|
||||
**Termination is the default for a slow consumer.** `GrpcSlowConsumerPolicy.DROP_OLDEST` exists for
|
||||
feeds whose business meaning tolerates loss, and is not the default, because a client cannot detect
|
||||
dropped messages: the sequence numbers it sees are the ones it was sent.
|
||||
|
||||
**Resume is refused rather than faked.** `GrpcStreamGapDetector` requires a signed, unexpired token
|
||||
whose caller and filter fingerprints match the current request, refuses one whose snapshot version
|
||||
moved, and returns `FULL_RESYNC_REQUIRED` when the cursor predates retained history. `GrpcResumeToken`
|
||||
carries a key id so the signing key can rotate without invalidating every outstanding token.
|
||||
|
||||
## Consequences
|
||||
|
||||
**A stream carries an envelope, not a bare payload.** `GrpcStreamEnvelope` holds the stream id,
|
||||
generation, sequence, snapshot version and resume token, because resume, gap detection and drain all
|
||||
need a position and a generation.
|
||||
|
||||
**Four clocks, not one.** `GrpcStreamLifetimePolicy` separates setup deadline, idle timeout, max
|
||||
duration and heartbeat interval, and refuses combinations where one can never fire. Merging any pair
|
||||
produces a familiar bug: an idle timeout used as a max duration kills healthy busy streams.
|
||||
|
||||
**A heartbeat is a liveness signal and nothing else.** It is not an application acknowledgement and
|
||||
not an ordering guarantee; `GrpcStreamHeartbeat` says so in the place somebody would otherwise reuse
|
||||
it.
|
||||
@@ -0,0 +1,68 @@
|
||||
# ADR-GRPC-006: Stable discovery is DNS and static, and a Kubernetes profile names who balances
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-31
|
||||
- Scope: `:grpc:grpc-discovery`, `:grpc:grpc-client`
|
||||
|
||||
## Context
|
||||
|
||||
A gRPC channel's discovery configuration has a failure mode with no runtime symptom: it works, and
|
||||
it does not do what the dashboard says it does.
|
||||
|
||||
The specific case is `round_robin` over a Kubernetes Service ClusterIP. The Service is one virtual
|
||||
address, so the resolver returns one endpoint and the client-side balancer has nothing to rotate
|
||||
across; kube-proxy picks a pod at connect time, and an HTTP/2 connection is long-lived, so every
|
||||
request from that client goes to the same pod for the life of the connection. Nothing fails. The
|
||||
configuration says `round_robin`, the metrics show requests spread across clients rather than pods,
|
||||
and the conclusion "we have client-side load balancing" is wrong in a way nobody is prompted to
|
||||
check.
|
||||
|
||||
The mirror-image mistake is `pick_first` over a headless record, which pins a client to one pod out
|
||||
of many.
|
||||
|
||||
Separately, a service mesh changes who owns retries, and a deployment that adds mesh routing without
|
||||
removing its own retry policy has two retriers whose effects multiply.
|
||||
|
||||
## Decision
|
||||
|
||||
**Stable resolvers are Static, DNS and Unix domain socket; Stable load balancing is `pick_first` and
|
||||
`round_robin`.** `GrpcDiscoveryPolicyValidator.requireStableScheme` refuses `xds`, `consul`, `etcd`
|
||||
and `eureka` by name, with a message saying they are Advanced capabilities with their own control
|
||||
plane and promotion gate rather than unknown schemes.
|
||||
|
||||
**The pairing is checked against the resolved address count, not against intent.**
|
||||
`GrpcResolverProfile` carries `expectedAddressCount`, and `GrpcStableLoadBalancer.effective` answers
|
||||
whether the policy distributes anything over that many endpoints. A `round_robin` profile over one
|
||||
address is a reported violation whose message says it describes spreading that is not happening.
|
||||
|
||||
**A Kubernetes deployment names its routing mode**, and the mode implies both the balancer and the
|
||||
retry owner. `GrpcKubernetesRoutingMode` has three values — `K8S_VIP`, `K8S_HEADLESS`, `MESH` — and
|
||||
`GrpcKubernetesProfile` refuses a mesh profile whose retry owner retries in-process.
|
||||
|
||||
**A profile that carries long-lived streams must state a reconnect budget and a readiness drain
|
||||
grace.** A stream pins a client to one pod for its whole life, so every rollout, eviction and
|
||||
scale-down ends it. `GrpcKubernetesProfileValidator` additionally reports a VIP profile carrying
|
||||
long streams, and a drain grace shorter than the reconnect budget — the second means the pod stops
|
||||
serving before its clients have finished reconnecting elsewhere.
|
||||
|
||||
**A DNS profile must refresh.** `GrpcResolverProfile` refuses a zero refresh interval on DNS,
|
||||
because a channel that resolved once at startup keeps sending to addresses that stopped existing an
|
||||
hour ago, and the resulting `UNAVAILABLE` looks like an unhealthy deployment long after the rollout
|
||||
finished.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Two validators, not one.** `GrpcDiscoveryPolicyValidator` asks whether a balancer does anything
|
||||
over the addresses it will see; `GrpcKubernetesProfileValidator` asks whether the deployment shape,
|
||||
the retry owner and the stream obligations agree. A deployment can have a coherent resolver profile
|
||||
and still have put retries in two places, so merging them would let one answer hide the other.
|
||||
|
||||
**`expectedAddressCount` has to come from somewhere.** It is a declared number, and a declaration can
|
||||
be wrong. It is still better than the alternative, which is not comparing anything: a wrong
|
||||
declaration is a wrong statement somebody wrote down, and a missing one is a question nobody asked.
|
||||
`GrpcChannelProfileValidator` takes resolved counts where they are known at startup and skips the
|
||||
check where they are not, rather than guessing and failing on a name that cannot be resolved yet.
|
||||
|
||||
**xDS is reachable, and not by this route.** It lives in `grpc-advanced-resilience` behind its
|
||||
capability flag and its production approval, and `GrpcXdsStartupGuard.advertisableAsStableSupport()`
|
||||
returns false so the Stable support statement cannot widen quietly. See ADR-GRPC-ADV-001.
|
||||
@@ -0,0 +1,55 @@
|
||||
# ADR-GRPC-ADV-001: Each advanced capability has its own flag, its own grade and its own promotion
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-30
|
||||
- Scope: `:grpc-advanced:*`
|
||||
|
||||
## Context
|
||||
|
||||
The advanced plan covers sixteen capabilities that differ by orders of magnitude in what they bring
|
||||
with them. gRPC-Web adds a proxy. Reactor adds a dependency. xDS adds a control plane, its outage
|
||||
modes, its own security boundary and its own version skew. Hedging duplicates production traffic.
|
||||
|
||||
Bundling them under one flag makes enabling the cheapest of those the same decision as enabling the
|
||||
most consequential.
|
||||
|
||||
## Decision
|
||||
|
||||
**One flag per capability**, under `ca-skeleton.grpc.advanced.<capability>.enabled`, all off by
|
||||
default.
|
||||
|
||||
**Four grades.** `ADVANCED_STABLE` starts on its flag; `EXPERIMENTAL` additionally needs a separate
|
||||
production approval, because the flag says somebody wanted the feature and the approval says somebody
|
||||
accepted that its failure modes are not fully characterised; `WATCH` cannot start at all; `DISABLED`
|
||||
is withdrawn.
|
||||
|
||||
`GrpcAdvancedModuleGuard` distinguishes the three refusals — flag unset, grade unstartable,
|
||||
production unapproved — because the remedy differs in each case.
|
||||
|
||||
**Promotion evidence is per capability.** `GrpcAdvancedPromotionEvidence` is one record per
|
||||
capability, so no promotion can drag another along;
|
||||
`GrpcAdvancedPromotionGate.capabilitiesDraggedAlong` returns an empty list, and that is a tested
|
||||
property rather than a claim. Two thresholds: seven days of soak plus complete evidence for
|
||||
`ADVANCED_STABLE`, thirty for a Stable default, because the second means every deployment gets the
|
||||
capability's dependencies and its failure modes.
|
||||
|
||||
**Infrastructure is named per capability.** `GrpcAdvancedInfrastructureTestkit` records that
|
||||
gRPC-Web needs a proxy, Servlet needs a container, xDS needs a stoppable control plane and Kotlin
|
||||
needs a toolchain. A suite that runs without its infrastructure passes and establishes nothing, which
|
||||
is worse than not having one.
|
||||
|
||||
## Consequences
|
||||
|
||||
**The Kotlin adapter fails closed here, and says why.** This repository has no Kotlin toolchain, so
|
||||
`GrpcKotlinCompatibilityGate.supportableHere()` returns false. The four contract requirements — one
|
||||
schema source, coroutine cancellation propagation, Flow backpressure inside the Stable bounds,
|
||||
platform evidence types preserved — are checkable and are checked; only the compile lane is missing.
|
||||
|
||||
**Edition 2026 cannot be used however its watch report reads.** `GrpcEdition2026Guard` is not
|
||||
conditional on the report, because letting a status record also authorise use means a schema moves
|
||||
onto an edition the moment somebody marks four fields SUPPORTED, with no promotion decision, no
|
||||
consumer migration and no ADR.
|
||||
|
||||
**xDS is not part of the Stable support statement.** It works, behind its flag and its approval;
|
||||
`GrpcXdsStartupGuard.advertisableAsStableSupport()` returns false so a support matrix cannot widen
|
||||
quietly.
|
||||
Reference in New Issue
Block a user