feat: grpc 기능 deep 구현

This commit is contained in:
DongHyeonka
2026-08-31 07:28:39 +09:00
parent a24ece9cf7
commit 21234e38cd
400 changed files with 40217 additions and 4 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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.
@@ -0,0 +1,62 @@
# gRPC advanced capability support matrix
Every capability in `:grpc-advanced:*`, its grade, and what it would take to raise it.
`GrpcAdvancedSupportMatrix` is the machine-readable form; `GrpcAdvancedCapability.defaultGrade`
carries the same values.
All capabilities are off by default. Flags are `ca-skeleton.grpc.advanced.<capability>.enabled`.
## Grades
| Grade | May start | Production needs a separate approval |
| --- | --- | --- |
| `ADVANCED_STABLE` | Yes | No |
| `EXPERIMENTAL` | Yes | Yes |
| `WATCH` | No | — |
| `DISABLED` | No | — |
## Capabilities
| Capability | Flag | Grade | Real infrastructure its evidence needs |
| --- | --- | --- | --- |
| Protobuf Edition 2024 | `edition-2024` | `ADVANCED_STABLE` | — |
| Protobuf Edition 2026 | `edition-2026` | `WATCH` | — |
| Client streaming | `client-streaming` | `ADVANCED_STABLE` | — |
| Bidirectional streaming | `bidi-streaming` | `ADVANCED_STABLE` | — |
| Manual flow control | `manual-flow-control` | `ADVANCED_STABLE` | — |
| Read-only unary hedging | `hedging` | `EXPERIMENTAL` | — |
| Custom name resolver | `custom-resolver` | `ADVANCED_STABLE` | — |
| Custom load balancer | `custom-load-balancer` | `EXPERIMENTAL` | — |
| Proxyless xDS | `xds` | `EXPERIMENTAL` | xDS control plane |
| gRPC-Web | `grpc-web` | `ADVANCED_STABLE` | gRPC-Web proxy |
| Servlet HTTP/2 | `servlet-compat` | `ADVANCED_STABLE` | Servlet container |
| Spring Integration bridge | `integration-bridge` | `ADVANCED_STABLE` | — |
| Reactor adapter | `reactor` | `ADVANCED_STABLE` | — |
| Kotlin coroutine / Flow | `kotlin` | `ADVANCED_STABLE` | Kotlin toolchain |
| Channelz / CSDS diagnostics | `channel-diagnostics` | `ADVANCED_STABLE` | — |
## What the grades mean here, concretely
**Grade is a statement about the contract, not about a deployment.** Every capability's contract is
implemented and tested in this repository. What no capability has is evidence from a real deployment:
`GrpcAdvancedPromotionEvidence` for each one is empty, and no promotion has been granted.
**Four capabilities cannot produce meaningful evidence here at all**, because the infrastructure they
need is absent. `GrpcAdvancedInfrastructureTestkit.missingInfrastructure` names them, and a suite that
runs without its infrastructure passes and establishes nothing.
**Kotlin is the sharpest case.** This repository has no Kotlin toolchain, so
`GrpcKotlinCompatibilityGate.supportableHere()` returns false and always will until one exists. The
four contract requirements — one schema source shared with Java, coroutine cancellation propagated,
Flow backpressure inside the Stable buffer bounds, platform evidence types preserved — are checkable
without a toolchain and are checked. The compile lane is not.
## Promotion thresholds
| To | Soak | Also required |
| --- | --- | --- |
| `ADVANCED_STABLE` | 7 days | compatibility evidence, security review, fault evidence, performance evidence, ADR, runbook, real-environment test |
| Stable default | 30 days | all of the above, plus a dependency, security and operational-cost review |
`WATCH` becomes `EXPERIMENTAL` before anything else. Promotions are independent: promoting one
capability changes no other's grade.
+75
View File
@@ -0,0 +1,75 @@
# gRPC platform support matrix
What the Stable gRPC platform (`:grpc:*`) is certified against, what it is only checked against, and
what is merely watched. The distinction is the point: "works with Spring Boot" is not a statement
anyone can act on.
`GrpcCompatibilityMatrix.caSkeleton()` is the machine-readable form of this table, and
`GrpcStableReleaseGate` blocks a release when a certified lane has no result or a failing one.
## Lanes
| Lane | Grade | Failure blocks a release |
| --- | --- | --- |
| Boot-managed platform (Spring Boot 4.0.8 BOM) | Certified | Yes |
| proto3 with explicit `optional` | Certified | Yes |
| `grpc-netty-shaded` | Certified | Yes |
| `grpc-netty` (unshaded) | Compatibility | No |
| Upstream gRPC Java version override | Compatibility | No |
| Protobuf Edition 2024 | Watch | No |
| Protobuf Edition 2026 | Watch | No |
## Runtime baseline
| | |
| --- | --- |
| Java | 21 |
| Spring Boot | 4.0.8 (the repository baseline; the plans assume 4.1) |
| io.grpc | `ext.grpcVersion` in `src/build.gradle` |
| Protobuf | `ext.protobufVersion` in `src/build.gradle` |
| Stable transport | Netty (shaded) |
| Stable RPC shapes | Unary, Server Streaming |
| Stable resolvers | Static, DNS, Unix domain socket |
| Stable load balancing | `pick_first`, `round_robin` |
## Evidence grades
A capability may only be advertised on evidence of a grade that can establish it.
`GrpcEvidenceGrade.requireCertifies` enforces this, and `GrpcReleaseEvidence.supports` refuses a
claim backed by the wrong lane.
| Grade | Lane | Establishes |
| --- | --- | --- |
| `CONTRACT` | `grpcInProcessContractTest` | adapter, interceptor order, status mapping, validation, idempotency replay, context propagation |
| `TRANSPORT` | `grpcNettyContractTest` | HTTP/2, TLS, mTLS, metadata limit, message limit, GOAWAY, keepalive, graceful shutdown |
| `FAULT` | `grpcFaultTest` | connection loss, completion unknown, partial stream, evidence classifier |
| `PERFORMANCE` | `grpcPerformanceTest` | latency, stream saturation, executor saturation, drain budget |
In-process results are never transport evidence. The in-process transport does not negotiate TLS,
does not frame HTTP/2 and does not enforce transport-level limits, so a suite that passes there has
tested the adapter and not the transport.
## What is not supported
| | Where it lives |
| --- | --- |
| Client streaming, bidirectional streaming | `grpc-advanced-streaming` |
| Manual flow control | `grpc-advanced-streaming` |
| Hedging | `grpc-advanced-resilience` |
| Custom name resolver, custom load balancer | `grpc-advanced-resilience` |
| xDS | `grpc-advanced-resilience` |
| gRPC-Web, Servlet HTTP/2, Spring Integration, Reactor, Kotlin | `grpc-advanced-compat` |
| Channelz / CSDS diagnostics | `grpc-advanced-diagnostics` |
## Current release status
Not released. Every `:grpc:*` leaf is `runtime_memberships: []` in the module registry, so the
platform is build-only: it compiles, its lanes run, and no deployed artifact carries it.
Two release gate inputs are outstanding and are the work between here and a release:
- **Performance baseline.** The performance lane runs and asserts shape — ordered percentiles, a gate
that reads them — rather than absolute numbers. A recorded baseline on a known runner is what turns
it into a regression gate.
- **Schema codegen.** No `protoc` runs in this build (ADR-GRPC-002), so the descriptor artifact and
the consumer-compile fixture are governed as policy rather than produced from a compiled schema.
@@ -0,0 +1,98 @@
# Runbook: gRPC advanced capabilities
Scope: the `:grpc-advanced:*` family. Everything here is off by default and stays off until a
deployment names it. Nothing in this family ships in a runtime composition today.
Flags are `ca-skeleton.grpc.advanced.<capability>.enabled`. `GrpcAdvancedCapability` owns the list of
capability names; `GrpcAdvancedSupportMatrix` owns their current grades.
---
## A capability refuses to start
`GrpcAdvancedModuleGuard` gives three different refusals, and the remedy differs:
| Message contains | Meaning | Remedy |
| --- | --- | --- |
| "its feature flag is not set" | Nobody enabled it | Set the property named in the message |
| "tracked rather than implemented" | Grade is `WATCH` | Nothing to do here; the capability is not implemented |
| "uncharacterised failure modes" | Grade is `EXPERIMENTAL` and this is production | Record a production approval, or run it outside production |
The refusal message always names the property key, so the first case is a configuration line rather
than a support question.
---
## xDS: the control plane went away
**What you are seeing.** The control plane is unreachable and clients are still routing.
**What it means.** `GrpcXdsFailurePolicy` serves the last-known-good snapshot, up to its staleness
bound.
**What to do.**
1. Check the snapshot's age. `SERVE_LAST_KNOWN_GOOD` is the healthy degraded state.
2. `STALE_BEYOND_BOUND` means the snapshot is older than the policy allows and is no longer trusted.
Beyond that bound, a decommissioned backend would otherwise keep receiving traffic indefinitely.
3. `NO_SNAPSHOT_YET` on a starting instance means it never reached the control plane. It fails after
the initial fetch timeout rather than starting with no routing.
**Do not** add application-level retry policy while xDS is enabled. `GrpcXdsStartupGuard` refuses it,
because retry defined in two places has a winner that depends on resolution order rather than on a
decision.
---
## gRPC-Web: a browser call hangs and then fails with no status
**Almost always the proxy.** A gRPC status arrives as a trailer, and a browser cannot read a trailer
the proxy did not expose. Check that the proxy's CORS `expose_headers` includes `grpc-status` and
`grpc-message`; `GrpcWebProxyContract.violations` reports exactly this, and the reference
configuration in `envoy/envoy.yaml` shows it in place.
**If the method is client- or bidirectional-streaming**, it cannot work over gRPC-Web at all — a
browser has no way to send a stream of messages. `GrpcWebCompatibilityGate` reports such a method
before it is exposed.
---
## Servlet: a transport setting appears to be ignored
It is ignored. The container owns the socket, so keepalive tuning, maximum connection age and
flow-control window tuning belong to it. `GrpcServletStartupValidator` refuses those settings at
startup rather than accepting and dropping them, because a setting that is silently ignored sends the
investigation somewhere else.
A Servlet run never substitutes for Netty certification.
---
## Hedging: backend load doubled
**Expected, within a bound.** Hedging trades duplicate load for tail latency.
`GrpcHedgingResult` records both `duplicateBackendCalls` and `cancelledLoserAttempts`; a dashboard
showing only the latency improvement makes the trade look free.
**What to check.** `GrpcHedgingBudget` caps hedges as a fraction of completed calls. If duplicate
load is above that fraction, the budget is not being consumed — which means something is issuing
hedges outside the coordinator.
**Hedging is refused** for anything but a read-only unary method. A hedged mutation runs twice by
design, and an idempotency key does not help: the second attempt duplicates a success in progress
rather than retrying a failure.
---
## Promoting a capability
`GrpcAdvancedPromotionGate.evaluate` names every missing item. Promotion to `ADVANCED_STABLE` needs
compatibility evidence, a security review, fault evidence, performance evidence, an ADR, a runbook, a
real-environment test and seven days of soak. A Stable default needs thirty.
Promotions are independent: promoting one capability changes no other's grade, and
`GrpcAdvancedSupportMatrix.apply` refuses a decision made against a different matrix state.
Before citing a suite as evidence, check `GrpcAdvancedInfrastructureTestkit.missingInfrastructure`.
A suite that ran without the proxy, the container, the control plane or the toolchain it needs passed
and established nothing.
+142
View File
@@ -0,0 +1,142 @@
# Runbook: gRPC platform operations
Scope: the `:grpc:*` family. All of it is build-only today — every leaf's `runtime_memberships` is
empty — so nothing here fires in production yet. It is written now because the states it covers are
the ones an on-call cannot work out from first principles at three in the morning, and shipping the
behaviour before the runbook means the first person to meet one is doing that.
Configuration lives under `ca-skeleton.grpc.platform.*` and is bound by `GrpcPlatformProperties`.
The platform does not start unless `ca-skeleton.grpc.platform.enabled=true`.
---
## COMPLETION_UNKNOWN on a mutation
**What you are seeing.** A client received `DEADLINE_EXCEEDED`, `UNAVAILABLE` or `INTERNAL` on a
state-changing call, and the response trailer `completion-outcome` reads `COMPLETION_UNKNOWN`.
**What it means.** The server may have committed. This is not a failure and not a success; the status
code cannot distinguish them, which is why the outcome is carried separately.
**What not to do.** Do not re-issue the call. Do not tell the caller it failed. Both are wrong half
the time, and which half is not knowable from the status.
**What to do.**
1. Take the `error-execution-id` from the trailers. It is the only link between what the client saw
and what the server did.
2. If the method is `IDEMPOTENCY_KEY_REQUIRED`, query the operation ledger with the caller
fingerprint, the full method name and the caller's key. `GrpcOperationStatusQuery` returns one of
`IN_PROGRESS`, `COMMITTED`, `FAILED_TERMINAL`, `NOT_FOUND` or `UNKNOWN`.
3. `COMMITTED` means return the stored outcome reference, not a freshly computed answer — the resource
may have changed since, and a new answer would describe the state at reconciliation time rather
than the state the caller's own call produced.
4. `NOT_FOUND` means the operation never started and is safe to re-issue. `FAILED_TERMINAL` means the
same.
5. `UNKNOWN` means the ledger could not be consulted. Nothing may be concluded. The case is queued by
`GrpcCompletionReconciler` and retried later.
6. If the method is not keyed, there is no ledger row. Resolve it against the business resource, or
escalate to the service owner. This is the case the keyed profile exists to avoid.
**Escalate when** the reconciler's pending list grows across passes. That means the ledger is
unreachable rather than slow.
---
## A stream ended with FULL_RESYNC_REQUIRED
**What you are seeing.** A client's resume was refused and it was told to resynchronise.
**What it means.** The server can no longer replay from the client's cursor. Either the snapshot
version moved, or the cursor predates retained history.
**What to do.** Nothing on the server. The client is expected to discard its position and start a new
stream from a fresh snapshot. A client that instead retries the same token will keep receiving the
same answer.
**Escalate when** it is happening to many clients at once. That usually means history retention was
reduced, or snapshots are rotating faster than clients reconnect.
---
## A stream ended with SLOW_CONSUMER
**What you are seeing.** Streams terminating with `SLOW_CONSUMER`, and
`grpc.stream.flow_control_stalls` rising.
**What it means.** The consumer could not keep up with the bounded queue. The stream was terminated
rather than silently dropping messages, because a client cannot detect drops — the sequence numbers
it sees are the ones it was sent.
**What to do.**
1. Check whether the consumer is slow or the producer is fast. `grpc.stream.messages` against
`grpc.stream.lifetime` tells you the rate.
2. If the consumer is slow, the fix is on the consumer. Raising the queue bound moves the failure
later and makes it larger.
3. A resume is not available after this ending: the messages that overflowed the queue are gone, so
continuing from the last delivered sequence would silently skip them. The client resynchronises.
---
## RESOURCE_EXHAUSTED under load
**What you are seeing.** Calls refused with `RESOURCE_EXHAUSTED` and `GrpcAdmissionController`
reporting rejections.
**What it means.** The server is at its concurrency and queue bounds and is shedding rather than
queueing. This is the designed behaviour: accepting work whose callers have already given up spends
capacity on nothing.
**What to do.**
1. Read `grpc.rpc.duration` and `grpc.rpc.queue_wait` separately. Queue time rising with duration flat
means the bottleneck is admission, not the work.
2. Check which saturation counter is moving — executor, channel or flow control. They look identical
in a latency graph and have different fixes.
3. Raising `ca-skeleton.grpc.platform.executor-queue-capacity` defers the problem; it does not remove
it. `GrpcExecutorProfile` refuses a queue above ten thousand for that reason.
---
## A rollout is producing errors at every deploy
**What you are seeing.** A burst of `UNAVAILABLE` or `CANCELLED` each time an instance goes away.
**What it means.** The drain sequence is not completing, or is running out of order.
**What to do.**
1. `GrpcDrainResult` records what each drain achieved: completed and cancelled unary calls, signalled
and cancelled streams, and which phases ran. A drain that routinely force-cancels is the cause.
2. The order matters. Readiness flips first and nothing is refused during that window, because there
is a gap between an instance reporting unready and routing acting on it. Refusing during that gap
turns a clean rollout into a burst of errors at every deploy.
3. For long streams, check the Kubernetes profile's `streamReconnectBudget` and
`readinessDrainGrace`. A stream is pinned to one pod for its whole life, so every rollout ends it;
a profile with long streams and no reconnect budget has not decided what clients do next.
---
## Verifying a deployment's configuration
`GrpcPlatformSnapshotService` produces a secret-free snapshot for a caller on the admin network
holding an admin role. Both gates are required.
`GrpcPlatformSnapshotService.driftAgainstRelease` compares a running snapshot with the release
manifest and reports schema version, method policy hash and per-channel profile differences. An
instance running a configuration the release did not ship is behind a whole class of incidents that
are otherwise diagnosed by reading logs.
The snapshot carries hashes and names only. A field whose name looks like a credential is refused at
construction rather than redacted.
---
## Things that are deliberately off
- **Reflection in production.** `GrpcReflectionMode.defaultFor` returns `DISABLED` for `STAGE` and
`PROD`. Reflection publishes the whole schema to anyone who can open a connection.
- **Every advanced capability.** See `docs/runbooks/grpc-advanced-capabilities.md`.
- **The platform itself.** `ca-skeleton.grpc.platform.enabled` defaults to false, and every `:grpc:*`
leaf is build-only in the registry.
@@ -0,0 +1,117 @@
# 타입 안전 gRPC 실행 플랫폼 — 실행 계획과 실행 결과
설계 SSOT: [docs/superpowers/specs/2026-08-30-grpc-platform-adaptation-design.md](../specs/2026-08-30-grpc-platform-adaptation-design.md)
원본:
- `docs/2026-08-13-grpc-type-safe-rpc-platform-implementation-plan.md` (Stable, Task 153)
- `docs/2026-08-13-grpc-advanced-capabilities-expansion-plan.md` (Advanced, Task 118)
이 문서는 실행 전 계획이자 실행 결과 기록이다. 각 phase는 leaf 단위로 닫혔고, 닫힘 조건은
`./gradlew <gradle-path>:check` 통과다 — 즉 test + spotless + checkstyle + SpotBugs(HIGH) +
Error Prone/`-Werror` + 저장소 전역 게이트 전이 실행이다.
## Phase 0 — 레지스트리와 스캐폴딩
- [x] `src/config/architecture/modules.json`에 18개 leaf 등록 (`grpc:*` 12, `grpc-advanced:*` 6)
- [x] leaf별 `build.gradle` 18개. io.grpc를 쓰는 leaf는 `grpc-bom`을 module scope로 import
- [x] `src/build.gradle`: `:grpc:` / `:grpc-advanced:` 를 plain JUnit+AssertJ 테스트 분기에 추가
(`messaging:*`와 같은 이유 — core-api가 Spring도 io.grpc도 이름 부르지 않는다는 주장을
검증 가능하게 만든다)
- [x] `./gradlew --write-locks ...resolveAndLockAll` 로 lockfile 18개 생성
- [x] `verifyCleanArchitectureDependencies` 통과
## Phase 1 — Foundation (`grpc-core-api`, Stable Task 17 + ledger port)
- [x] Task 1 `GrpcStableModuleCatalog` / `GrpcStableBuildInvariant`
- [x] Task 2 `GrpcMethodName` / `GrpcServiceName` / `GrpcChannelProfileName` / `RpcType` / `GrpcStatusCode`
- [x] Task 3 `RpcIdempotencyProfile` / `WaitForReadyPolicy` / `GrpcMethodPolicy` / `GrpcMethodPolicyCatalog`
- [x] Task 4 `GrpcTransportEvidence` / `GrpcBusinessEvidence` / `GrpcStreamEvidence` / `GrpcExecutionEvidence`
- [x] Task 5 `GrpcFailureCategory` / `GrpcCompletionOutcome` / `GrpcFailureContext` / `GrpcPlatformException`
- [x] Task 6 `GrpcDeadlineProfile` / `GrpcDeadlineBudget` / `GrpcCancellationToken` / `GrpcDeadlineExceededException`
- [x] Task 7 `GrpcRequestContext` / `GrpcMetadataKey` / `GrpcMetadataBudget` / `GrpcClientIdentity`
- [x] 추가: `dev.caskeleton.grpc.ledger` port (`GrpcOperationLedger` 외 3) — 정책 계층이 DB에 의존하지
않고 durable idempotency를 요구할 수 있게 하기 위해 core-api에 둔다
## Phase 2 — Contract governance (`grpc-proto-contract`, `grpc-codegen`, Task 811)
- [x] Task 8 `.proto` 2개 + `buf.yaml` + `GrpcProtoStyleManifest` / `GrpcProtoRuleViolation` / `GrpcProtoContractValidator`
- [x] Task 9 `GrpcBufPolicy` / `GrpcBreakingCategory` / `GrpcSchemaBaseline`
- [x] Task 10 `GrpcCodegenManifest` / `GrpcGeneratedPackagePolicy` / `GrpcCodegenOutput`
- [x] Task 11 `GrpcDescriptorArtifact` / `GrpcConsumerFixture` / `GrpcSchemaArtifactPublisher`
- 편차: protoc·Buf CLI 미실행. 근거는 ADR-GRPC-002
## Phase 3 — Policy (`grpc-policy`, Task 12·16·17·20·2831·33·34·3743)
- [x] Task 12 validation, Task 16 context propagation, Task 17 status/rich error, Task 20 TLS/credential rotation
- [x] Task 28 deadline calculator, Task 29 cancellation coordinator
- [x] Task 30 service config/retry owner, Task 31 retry eligibility/budget/coordinator, Task 42 wait-for-ready
- [x] Task 33 idempotency interceptor, Task 34 completion reconciliation
- [x] Task 3741 stream envelope·writer·flow control·resume token·lifetime
- [x] Task 43 message size / compression / payload boundary
## Phase 4 — Server boundary (`grpc-server`, Task 1315·1819)
- [x] Task 13 boundary rules + raw API import rule, Task 14 typed service adapter SPI
- [x] Task 15 interceptor 순서 계약, Task 18 Netty profile/executor/admission, Task 19 shaded parity
## Phase 5 — Client / discovery / admin / observability / ledger
- [x] `grpc-client` Task 2427
- [x] `grpc-discovery` Task 3536
- [x] `grpc-admin` Task 2123·45
- [x] `grpc-observability` Task 44
- [x] `grpc-operation-ledger-jpa` Task 32 (entity + repository + migration + port impl)
## Phase 6 — Composition과 인증 (`grpc-spring-boot-starter`, `grpc-testkit`)
- [x] Task 52 properties / auto-configuration / startup validator
- [x] Task 46 in-process fixture, Task 47 실제 Netty + TLS/mTLS fixture
- [x] Task 48 fault point / scenario / evidence classifier
- [x] Task 4950 unary·streaming contract suite, Task 51 performance budget/gate
- [x] Task 53 compatibility matrix / release evidence / release gate
- [x] strict test lane 4개 등록 및 실행: `grpcInProcessContractTest`, `grpcNettyContractTest`,
`grpcFaultTest`, `grpcPerformanceTest`
## Phase 7 — Advanced (`grpc-advanced:*`, Advanced Task 118)
- [x] A1·A18 `grpc-advanced-bootstrap`
- [x] A2·A3 `grpc-advanced-edition`
- [x] A4A7 `grpc-advanced-streaming`
- [x] A8A11 `grpc-advanced-resilience`
- [x] A12A16 `grpc-advanced-compat`
- [x] A17 `grpc-advanced-diagnostics`
## Phase 8 — 문서와 게이트
- [x] `src/grpc/CLAUDE.md`, `src/grpc-advanced/CLAUDE.md`
- [x] ADR-GRPC-001..005, ADR-GRPC-ADV-001
- [x] `docs/runbooks/grpc-platform-operations.md`, `docs/runbooks/grpc-advanced-capabilities.md`
- [x] `docs/compatibility/grpc-support-matrix.md`, `docs/compatibility/grpc-advanced-support-matrix.md`
- [x] `verifyRunbookReferences`, `verifyDocumentedLeafCount` 통과
## Phase 9 — 2026-08-31 계획 대조 감사
사용자가 "빠짐없이 반영한게 맞나"를 물어 계획의 `Files:` 절 전체를 기계 대조했다. 결과와 조치는
설계 문서 §6이 SSOT다. 요약:
- [x] 대조 스크립트 실행 → 초기 결과 present=316 / missing=38
- [x] 실제 누락 6건 보완: `GrpcKubernetesProfileValidator`, `ADR-GRPC-006`(discovery/Kubernetes),
xDS `bootstrap.json`, `control-plane-snapshot.json`, `DocumentClientFixture`,
`buf.gen.yaml`+`buf.lock`
- [x] 추가한 픽스처는 전부 실제 검사에 물렸다 — 장식이 되지 않도록:
`GrpcXdsStartupGuard.bootstrapMismatches`(namespace 불일치·비TLS control plane),
redactor가 실제 모양의 CSDS 데이터로 검증, `GrpcConsumerFixture.fromJavaSource`가 fixture
소스에서 요구사항을 역산
- [x] 테스트 클래스 17건을 계획이 명시한 이름으로 분리
- [x] 재대조 → present=341 / missing=13, 잔여 13건은 전부 §6.1–6.4의 기록된 편차
(ADR 파일명 6, codegen convention plugin 3, TLS 인증서 3, Kotlin `.kt` 1)
## 남은 작업 (이 계획 범위 밖, 별도 결정 필요)
1. **런타임 투입.** 신규 leaf 전부 `runtime_memberships: []`다. `app-bootstrap`에 배선하려면 registry
membership 변경 + `verifyRuntimeModuleMembership` 통과가 선행이며, 그것은 별도 결정이다.
2. **`adapter:inbound:grpc` 브리지.** registry의 `allowed_dependencies``grpc-core-api`·`grpc-server`
추가하고 typed service adapter를 배선하는 작업. 플랫폼이 green이 된 지금이 시작점이다.
3. **protoc / Buf CLI 활성화.** ADR-GRPC-002가 조건과 비용을 기록한다.
4. **performance baseline 기록.** 현재 lane은 shape만 검증한다. 알려진 runner에서의 baseline이
regression gate를 만든다.
@@ -0,0 +1,208 @@
# 타입 안전 gRPC 실행 플랫폼 — 이 저장소 규약으로의 어댑팅 설계
두 계획 문서를 이 저장소(`ca-skeleton`)의 실제 구조·정책·빌드 게이트에 맞춰 실행 가능한 형태로
옮기는 설계다.
- 원본 A: `docs/2026-08-13-grpc-type-safe-rpc-platform-implementation-plan.md` (Stable, Task 153)
- 원본 B: `docs/2026-08-13-grpc-advanced-capabilities-expansion-plan.md` (Advanced, Task 118)
원본은 `modules/grpc/**` · Gradle Kotlin DSL · 패키지 `io.backend.skeleton.grpc` · Spring Boot 4.1을
전제한다. 이 저장소는 `src/**` · Groovy DSL · `dev.caskeleton` · Spring Boot 4.0.8 · fail-closed
module registry를 쓴다. 원본 Global Constraint의 마지막 항목이 이 어댑팅을 명시적으로 허용한다:
"실제 저장소 구조가 예상 경로와 다르면 파일 경로만 매핑하고 공개 계약·불변 조건·테스트 의미는
변경하지 않는다."
## 1. 패밀리 위치 — 왜 `adapter:inbound:grpc` 확장이 아닌가
원본이 기술하는 것은 인바운드 어댑터 하나가 아니라 **자기 API·SPI·adapter·조립 경계를 가진 벤더드
RPC 플랫폼**이다. 이 저장소에는 그 형태의 선례가 이미 있다 — `messaging:*`. root `CLAUDE.md`
직접 그렇게 규정한다: "vendored messaging platform: a product with its own API, SPI, adapters and
composition boundary, not a layer of this application".
따라서 gRPC 플랫폼도 같은 자리에 둔다.
```text
src/grpc/ → :grpc:* Stable 플랫폼 (12 leaf)
src/grpc-advanced/ → :grpc-advanced:* Advanced/Experimental (6 leaf)
src/adapter/inbound/grpc 기존 인바운드 전송 어댑터 (그대로 유지)
```
`grpc-advanced`를 별도 디렉터리·별도 Gradle prefix로 分離하는 이유는 원본 Stable Task 1의 불변
조건 "Stable starter가 `modules/grpc-advanced`를 참조하면 build를 실패시킨다"를 registry의
`allowed_dependencies`만으로 기계 검증할 수 있게 만들기 위해서다. `verifyCleanArchitectureDependencies`
그 게이트다.
패키지 루트는 `dev.caskeleton.grpc` / `dev.caskeleton.grpc.advanced`다.
## 2. Leaf 매핑 — 원본 31개 모듈 → 이 저장소 18개 leaf
원본 모듈 경계 중 **이 저장소가 이미 다른 메커니즘으로 표현하는 것**만 합친다. 능력(capability)은
하나도 버리지 않는다.
### Stable — `src/grpc/`
| leaf | 원본 모듈 | 담는 Task | 의존 |
| --- | --- | --- | --- |
| `grpc-core-api` | grpc-core-api | 27 | (없음, Java stdlib) |
| `grpc-proto-contract` | grpc-proto-contract | 8 | core-api |
| `grpc-codegen` | grpc-codegen | 911 | core-api, proto-contract |
| `grpc-policy` | grpc-policy | 12, 16, 17, 20, 2831, 33, 34, 3743 | core-api |
| `grpc-server` | grpc-server | 1315, 18, 19 | core-api, policy |
| `grpc-client` | grpc-client | 2427 | core-api, policy |
| `grpc-discovery` | grpc-discovery | 35, 36 | core-api, client |
| `grpc-admin` | grpc-admin | 2123, 45 | core-api, server |
| `grpc-observability` | grpc-observability | 44 | core-api |
| `grpc-operation-ledger-jpa` | grpc-operation-ledger-jpa | 32 | core-api |
| `grpc-spring-boot-starter` | grpc-spring-boot-starter | 52 | 위 전부 |
| `grpc-testkit` | testkit-core + -inprocess + -netty + -fault | 4651, 53 | 위 전부 |
**testkit 4개를 1개로 합친 근거.** 원본이 testkit을 넷으로 쪼갠 목적은 "in-process 증거를 실제
네트워크 증거로 오인하지 않게 한다"이다. 이 저장소는 그 목적을 모듈 경계가 아니라 **strict test
lane** 컨벤션(`ca.strict-test-lane`)으로 이미 표현한다 — lane은 태그/소스셋/명시 테스트 중 하나로만
선택하고, 아무것도 실행하지 않으면 실패하며, up-to-date 결과를 제공하지 않는다. 그래서 네 모듈은
`grpc-testkit` 한 leaf 안의 네 lane이 된다:
`grpcInProcessContractTest`, `grpcNettyContractTest`, `grpcFaultTest`, `grpcPerformanceTest`.
원본의 "in-process는 HTTP/2·TLS 증거가 아니다"는 lane 분리와 `GrpcEvidenceGrade`로 강제한다.
### Advanced — `src/grpc-advanced/`
| leaf | 원본 모듈 | 담는 Task |
| --- | --- | --- |
| `grpc-advanced-bootstrap` | grpc-advanced-bootstrap | A1, A18 |
| `grpc-advanced-edition` | grpc-edition-2024 + grpc-edition-2026-experimental | A2, A3 |
| `grpc-advanced-streaming` | grpc-client-streaming + grpc-bidi-streaming + grpc-manual-flow-control | A4A7 |
| `grpc-advanced-resilience` | grpc-hedging + grpc-custom-resolver + grpc-custom-load-balancer + grpc-xds | A8A11 |
| `grpc-advanced-compat` | grpc-web + grpc-servlet-compat + grpc-integration-bridge + grpc-reactor + grpc-kotlin | A12A16 |
| `grpc-advanced-diagnostics` | grpc-channel-diagnostics | A17 |
Advanced 모듈은 capability마다 leaf를 나누는 대신 **capability grade와 feature flag**
(`GrpcAdvancedCapability` / `GrpcAdvancedFeatureFlags`)로 분리한다. A18의 요구사항
"Edition, streaming, xDS, gRPC-Web, Servlet, language adapter가 서로의 승격을 묶지 않는다"는
leaf 경계가 아니라 capability별 독립 promotion evidence로 표현되므로, 합쳐도 그 불변 조건은 유지된다.
## 3. 원본과 달라지는 지점 (deviation)과 근거
| # | 원본 | 이 저장소 | 근거 |
| --- | --- | --- | --- |
| D1 | Spring Boot 4.1 BOM | Spring Boot 4.0.8 BOM | 저장소 실제 baseline(`src/build.gradle:13`). BOM이 SSOT라는 계약 자체는 유지 |
| D2 | Boot-managed Spring gRPC starter | self-managed `io.grpc` (`ext.grpcVersion`) | 기존 기록된 결정(`adapter/inbound/grpc/README.md` "왜 self-managed Netty 인가"). starter 커플링 회피 |
| D3 | Gradle Kotlin DSL, `settings.gradle.kts` | Groovy DSL + `config/architecture/modules.json` | registry가 leaf 목록의 SSOT이고 settings는 그것을 읽기만 한다 |
| D4 | `io.backend.skeleton.grpc` | `dev.caskeleton.grpc` | 저장소 기본 패키지 |
| D5 | Buf CLI (`bufLint`/`bufBreaking` 등) | 저장소 소유 규칙 엔진 + Gradle verify task | Buf CLI 바이너리가 이 환경에 없다. lint/format/breaking **규칙**을 Java로 구현해 동일 판정을 내리고, CLI는 동일 규칙을 재확인하는 선택 경로로 남긴다 |
| D6 | protoc/grpc-java codegen을 빌드에서 실행 | `.proto` 소스 + codegen **정책·descriptor 계약**만 실행, protoc 실행은 명시적 확장점 | 아래 별도 절 |
| D7 | `grpc-kotlin``.kt` 소스 | Java 쪽 coroutine/Flow **경계 계약**만 | 저장소에 Kotlin 플러그인·소스셋이 없다. A16의 5개 요구사항 중 4개(스키마 단일 소스, cancellation 전파 계약, backpressure 우회 금지, evidence 타입 보존)는 Java 계약으로 표현 가능하고, Kotlin 툴체인 lane은 `GrpcKotlinCompatibilityGate`가 미충족으로 fail-closed 판정한다 |
| D8 | 새 leaf가 곧 런타임 | 신규 leaf 전부 `runtime_memberships: []` (build-only) | `messaging:*`가 처음 착지한 방식과 동일. 런타임 투입은 registry membership 변경 + `verifyRuntimeModuleMembership` 통과가 선행 조건이며, 그것은 별도 결정이다 |
### D6 — protoc 실행을 지금 켜지 않는 이유
이 저장소의 모든 leaf는 예외 없이 spotless(google-java-format) · checkstyle · spotbugs(HIGH) ·
errorprone · `-Werror`를 통과해야 한다(`src/build.gradle``configure(subprojects)` 블록). protoc가
만든 소스는 그 어느 것도 통과하지 못하므로, 실제 codegen을 켜려면 해당 소스셋에서 다섯 게이트를
모두 끄는 carve-out이 필요하다. 저장소에 선례는 있다(`jmh` 소스셋). 하지만 그 carve-out은 Task
10 하나를 위해 품질 게이트를 여는 결정이고, 그 결정은 이 작업 범위 밖의 승인 사항이다.
그래서 Task 8–11의 **불변 조건**은 전부 실행 가능한 형태로 구현한다:
proto style 규칙 검증, 삭제 필드 `reserved` 이력 대조, Buf breaking category(`FILE`) 판정,
generated package와 hand-written package 겹침 금지, descriptor+schema hash 릴리스 아티팩트,
consumer fixture 실패 시 릴리스 차단. 빠지는 것은 protoc 프로세스 호출 하나이고,
`GrpcCodegenManifest`가 그 지점을 단일 owner로 고정한 채 비워둔다.
## 4. 유지되는 원본 불변 조건 (변경 없음)
- 실행 증거 3축(Transport/Business/Stream) 분리, `RESPONSE_HEADERS_SEEN``COMMIT_CONFIRMED` 자동 승격 금지
- `DEADLINE_EXCEEDED` mutation = `COMPLETION_UNKNOWN` 후보, `UNAVAILABLE`만으로 상태 변경 RPC 재호출 금지
- `NON_IDEMPOTENT`에 explicit retry·hedging 금지, explicit retry owner는 하나
- Stable Unary는 positive deadline 필수
- Server Streaming: bounded queue + single serialized writer, partial delivery 후 whole-call retry 금지
- Stable RPC 유형은 Unary·Server Streaming, Client/Bidi는 Advanced
- production reflection 기본 비활성, dev·stage·prod TLS 필수, trust-all 금지
- Stable resolver = Static·DNS, Stable LB = pick_first·round_robin
- metric tag에 raw metadata/payload/actor/tenant/object/stream/idempotency ID 금지
- Netty가 Stable certification transport, in-process는 network/TLS 증거가 아님
## 5. 애플리케이션이 이 패밀리에 도달하는 경로
`messaging:*`의 MSG-015(bridge 부재)를 반복하지 않는다. 계약상의 경로는
```text
application-owned port → adapter:inbound:grpc (typed service adapter) → :grpc:grpc-server SPI
```
이고, Stable Task 13/14가 그 경계를 소유한다(`GrpcApplicationBoundaryRules`,
`GrpcRawApiImportRule`, `GrpcServiceAdapter`). 플랫폼이 green이 된 뒤 마지막 단계에서
`adapter-inbound-grpc``allowed_dependencies``grpc-core-api`·`grpc-server`를 추가하고
브리지를 배선한다. 그 전까지 플랫폼은 self-contained build-only다.
## 6. 원본 파일 목록 대비 대조 (2026-08-31 감사)
두 계획이 `Files:` 절에 명시한 산출물 전체를 기계적으로 대조했다. 감사 스크립트는 각 Task의 파일
basename이 저장소에 존재하는지 확인한다(build 산출물 제외, 단 annotation processor가 생성하는
`spring-configuration-metadata.json`은 build 출력에서 확인).
```text
STABLE (Task 153) : present=250 missing=11
ADVANCED (Task 118) : present= 91 missing= 2
TOTAL : present=341 missing=13
```
잔여 13건은 전부 아래 편차로 설명된다. **행동이 빠진 것은 없다.**
### 6.1 ADR 파일명 (6건) — 저장소 명명 규약
계획은 `ADR-060` ~ `ADR-065` 연번을 쓴다. 이 저장소의 `docs/adr/`는 접두사 규약을 쓴다
(`ADR-WS-001`, `ADR-MONGO-004`, `ADR-WEB-ADV-003`). 내용은 1:1이다.
| 계획 | 이 저장소 |
| --- | --- |
| ADR-060 platform-boundary | `ADR-GRPC-001-platform-family-and-registry-shape.md` |
| ADR-061 execution-evidence | `ADR-GRPC-003-three-axis-execution-evidence.md` |
| ADR-062 retry-idempotency | `ADR-GRPC-004-retry-ownership-and-durable-idempotency.md` |
| ADR-063 streaming-resume | `ADR-GRPC-005-server-streaming-single-writer-and-resume.md` |
| ADR-064 discovery-kubernetes | `ADR-GRPC-006-stable-discovery-and-kubernetes-routing.md` |
| ADR-065 advanced-promotion | `ADR-GRPC-ADV-001-capability-promotion-is-per-capability.md` |
계획에 없던 `ADR-GRPC-002-schema-governance-without-protoc.md`가 추가로 있다 — D6 결정을 기록한다.
### 6.2 codegen convention plugin 3건 — D6
`io.backend.grpc-buf-conventions.gradle.kts`, `io.backend.grpc-codegen-conventions.gradle.kts`,
`.properties`. protoc를 실행하지 않으므로 실행할 convention plugin이 없다. 이들이 고정했을 결정은
`GrpcCodegenManifest`·`GrpcCodegenOutput`·`GrpcGeneratedPackagePolicy`가 Java로 강제하고,
`buf.yaml`·`buf.gen.yaml`·`buf.lock`이 CLI가 있는 환경에서 같은 판정을 내리도록 커밋되어 있다.
### 6.3 TLS 인증서 3건 — 런타임 생성으로 대체
`ca.crt`, `server.crt`, `client.crt`. 커밋된 인증서는 만료되고, 커밋된 개인키는 개인키다.
`GrpcTlsTestMaterial`이 JDK `keytool`로 fixture마다 PKCS12를 생성하고 `close()`가 지운다. Netty의
`SelfSignedCertificate`는 JDK 21에서 `sun.security.x509` 미export로 실패하므로 쓰지 않았다.
### 6.4 Kotlin 소스 1건 — D7
`GrpcCoroutineAdapter.kt`. 저장소에 Kotlin 툴체인이 없다. 계약 요구 4건은 Java로 검증하고
(`GrpcKotlinProfile`, `GrpcCoroutineContextBridge`), compile lane은
`GrpcKotlinCompatibilityGate.supportableHere()``false`로 fail-closed다.
### 6.5 2026-08-31 감사에서 실제로 메꾼 것 (6건)
감사가 아니었으면 남았을 것들이다.
| 항목 | 조치 |
| --- | --- |
| `GrpcKubernetesProfileValidator` | 이름 있는 타입으로 분리. VIP+장기스트림, drain grace < reconnect budget 두 규칙 추가 |
| ADR-064 상당 (discovery/Kubernetes) | `ADR-GRPC-006` 작성 |
| xDS `bootstrap.json` | 테스트 리소스로 추가 + `GrpcXdsStartupGuard.bootstrapMismatches`가 실제로 대조 (namespace 불일치·비TLS control plane 검출) |
| `control-plane-snapshot.json` | 테스트 리소스로 추가 + redactor가 실제 모양의 데이터로 검증됨 |
| `DocumentClientFixture.java` + fixture `build.gradle.kts` | 추가. `GrpcConsumerFixture.fromJavaSource`가 fixture 소스에서 service·method·package 요구사항을 **역산**하므로 손으로 적은 목록이 아니다 |
| `buf.gen.yaml`, `buf.lock` | 추가 + proto contract 테스트가 내용을 검증 |
### 6.6 테스트 클래스 17건 — 감사 후 계획대로 분리
감사 시점에 leaf별로 통합돼 있던 테스트를 계획이 명시한 이름으로 분리했다. 분리 전에도 모든
타입이 실제로 검증되고 있었으나(통합 클래스가 해당 타입을 참조), 계획과의 추적성을 위해 나눴다:
`GrpcNettyVariantSelectorTest`, `GrpcReflectionPolicyTest`, `GrpcClientMetadataPolicyTest`,
`GrpcKubernetesProfileTest`, `GrpcEdition2026GuardTest`, `GrpcClientStreamPolicyTest`,
`GrpcClientMessageDeduplicatorTest`, `GrpcBidiSequenceTrackerTest`, `GrpcDemandControllerTest`,
`GrpcHedgingEligibilityTest`, `GrpcResolverSafetyPolicyTest`, `GrpcLoadBalancerSafetyPolicyTest`,
`GrpcXdsStartupGuardTest`, `GrpcWebCompatibilityGateTest`, `GrpcServletStartupValidatorTest`,
`GrpcIntegrationBridgePolicyTest`, `GrpcReactorContextBridgeTest`, `GrpcKotlinCompatibilityGateTest`.
+7 -4
View File
@@ -477,12 +477,15 @@ configure(subprojects.findAll { it.childProjects.isEmpty() }) {
} }
dependencies { dependencies {
// The messaging platform leaves own a broker-neutral public contract. Keeping their test // The messaging and gRPC platform leaves own a transport-neutral public contract. Keeping
// classpath on plain JUnit + AssertJ is what makes "messaging-core-api has no Spring // their test classpath on plain JUnit + AssertJ is what makes "messaging-core-api has no
// dependency" verifiable rather than aspirational; leaves that genuinely need a Spring // Spring dependency" — and the same claim for grpc-core-api, which additionally may not
// name io.grpc — verifiable rather than aspirational; leaves that genuinely need a Spring
// test context add it in their own build file. // test context add it in their own build file.
if (project.path in [':domain-core', ':application-core', ':shared-contract'] || if (project.path in [':domain-core', ':application-core', ':shared-contract'] ||
project.path.startsWith(':messaging:')) { project.path.startsWith(':messaging:') ||
project.path.startsWith(':grpc:') ||
project.path.startsWith(':grpc-advanced:')) {
testImplementation 'org.junit.jupiter:junit-jupiter' testImplementation 'org.junit.jupiter:junit-jupiter'
testImplementation 'org.assertj:assertj-core' testImplementation 'org.assertj:assertj-core'
} else { } else {
+197
View File
@@ -587,6 +587,203 @@
"messaging-transport-spi" "messaging-transport-spi"
], ],
"runtime_memberships": [] "runtime_memberships": []
},
{
"id": "grpc-core-api",
"gradle_path": ":grpc:grpc-core-api",
"source_path": "src/grpc/grpc-core-api",
"allowed_dependencies": [],
"runtime_memberships": []
},
{
"id": "grpc-proto-contract",
"gradle_path": ":grpc:grpc-proto-contract",
"source_path": "src/grpc/grpc-proto-contract",
"allowed_dependencies": [
"grpc-core-api"
],
"runtime_memberships": []
},
{
"id": "grpc-codegen",
"gradle_path": ":grpc:grpc-codegen",
"source_path": "src/grpc/grpc-codegen",
"allowed_dependencies": [
"grpc-core-api",
"grpc-proto-contract"
],
"runtime_memberships": []
},
{
"id": "grpc-policy",
"gradle_path": ":grpc:grpc-policy",
"source_path": "src/grpc/grpc-policy",
"allowed_dependencies": [
"grpc-core-api"
],
"runtime_memberships": []
},
{
"id": "grpc-server",
"gradle_path": ":grpc:grpc-server",
"source_path": "src/grpc/grpc-server",
"allowed_dependencies": [
"grpc-core-api",
"grpc-policy"
],
"runtime_memberships": []
},
{
"id": "grpc-client",
"gradle_path": ":grpc:grpc-client",
"source_path": "src/grpc/grpc-client",
"allowed_dependencies": [
"grpc-core-api",
"grpc-policy"
],
"runtime_memberships": []
},
{
"id": "grpc-discovery",
"gradle_path": ":grpc:grpc-discovery",
"source_path": "src/grpc/grpc-discovery",
"allowed_dependencies": [
"grpc-core-api",
"grpc-client"
],
"runtime_memberships": []
},
{
"id": "grpc-admin",
"gradle_path": ":grpc:grpc-admin",
"source_path": "src/grpc/grpc-admin",
"allowed_dependencies": [
"grpc-core-api",
"grpc-server"
],
"runtime_memberships": []
},
{
"id": "grpc-observability",
"gradle_path": ":grpc:grpc-observability",
"source_path": "src/grpc/grpc-observability",
"allowed_dependencies": [
"grpc-core-api"
],
"runtime_memberships": []
},
{
"id": "grpc-operation-ledger-jpa",
"gradle_path": ":grpc:grpc-operation-ledger-jpa",
"source_path": "src/grpc/grpc-operation-ledger-jpa",
"allowed_dependencies": [
"grpc-core-api"
],
"runtime_memberships": []
},
{
"id": "grpc-spring-boot-starter",
"gradle_path": ":grpc:grpc-spring-boot-starter",
"source_path": "src/grpc/grpc-spring-boot-starter",
"allowed_dependencies": [
"grpc-core-api",
"grpc-proto-contract",
"grpc-codegen",
"grpc-policy",
"grpc-server",
"grpc-client",
"grpc-discovery",
"grpc-admin",
"grpc-observability",
"grpc-operation-ledger-jpa"
],
"runtime_memberships": []
},
{
"id": "grpc-testkit",
"gradle_path": ":grpc:grpc-testkit",
"source_path": "src/grpc/grpc-testkit",
"allowed_dependencies": [
"grpc-core-api",
"grpc-proto-contract",
"grpc-codegen",
"grpc-policy",
"grpc-server",
"grpc-client",
"grpc-discovery",
"grpc-admin",
"grpc-observability",
"grpc-operation-ledger-jpa"
],
"runtime_memberships": []
},
{
"id": "grpc-advanced-bootstrap",
"gradle_path": ":grpc-advanced:grpc-advanced-bootstrap",
"source_path": "src/grpc-advanced/grpc-advanced-bootstrap",
"allowed_dependencies": [
"grpc-core-api"
],
"runtime_memberships": []
},
{
"id": "grpc-advanced-edition",
"gradle_path": ":grpc-advanced:grpc-advanced-edition",
"source_path": "src/grpc-advanced/grpc-advanced-edition",
"allowed_dependencies": [
"grpc-core-api",
"grpc-proto-contract",
"grpc-advanced-bootstrap"
],
"runtime_memberships": []
},
{
"id": "grpc-advanced-streaming",
"gradle_path": ":grpc-advanced:grpc-advanced-streaming",
"source_path": "src/grpc-advanced/grpc-advanced-streaming",
"allowed_dependencies": [
"grpc-core-api",
"grpc-policy",
"grpc-advanced-bootstrap"
],
"runtime_memberships": []
},
{
"id": "grpc-advanced-resilience",
"gradle_path": ":grpc-advanced:grpc-advanced-resilience",
"source_path": "src/grpc-advanced/grpc-advanced-resilience",
"allowed_dependencies": [
"grpc-core-api",
"grpc-policy",
"grpc-client",
"grpc-discovery",
"grpc-advanced-bootstrap"
],
"runtime_memberships": []
},
{
"id": "grpc-advanced-compat",
"gradle_path": ":grpc-advanced:grpc-advanced-compat",
"source_path": "src/grpc-advanced/grpc-advanced-compat",
"allowed_dependencies": [
"grpc-core-api",
"grpc-policy",
"grpc-server",
"grpc-client",
"grpc-advanced-bootstrap"
],
"runtime_memberships": []
},
{
"id": "grpc-advanced-diagnostics",
"gradle_path": ":grpc-advanced:grpc-advanced-diagnostics",
"source_path": "src/grpc-advanced/grpc-advanced-diagnostics",
"allowed_dependencies": [
"grpc-core-api",
"grpc-client",
"grpc-advanced-bootstrap"
],
"runtime_memberships": []
} }
] ]
} }
+77
View File
@@ -0,0 +1,77 @@
# grpc-advanced — local authority for the advanced gRPC capabilities
이 문서는 `grpc-advanced:*` family의 **local authority**다. leaf 목록·gradle path·허용 의존성은
`src/config/architecture/modules.json`이 SSOT다. Root 정책(`CLAUDE.md` / `AGENTS.md`)과 충돌하면
root가 이긴다.
이 family는 Stable gRPC 플랫폼(`grpc:*`)이 **의도적으로 제외한** 능력들을 담는다. 별도 디렉터리와
별도 Gradle prefix인 이유는 하나다: "Stable starter가 advanced module을 참조하면 build가 실패한다"는
불변 조건을 registry의 `allowed_dependencies`만으로 기계 검증할 수 있게 하기 위해서다.
## 의존 방향
```text
grpc-advanced:* → grpc:* (허용)
grpc:* → grpc-advanced:* (금지 — registry가 거부한다)
```
`grpc-spring-boot-starter`의 registry 엔트리에는 어떤 advanced id도 없다.
`verifyCleanArchitectureDependencies`가 build time에, `GrpcStableBuildInvariant`
`GrpcAdvancedModuleGuard.requireStableStarterIsClean`이 runtime에 같은 규칙을 강제한다.
## Capability grade와 feature flag
capability마다 별도 flag를 갖는다. 하나의 "advanced" 스위치로 묶지 않는 이유는, gRPC-Web을 켜는
결정(프록시 하나)과 xDS를 켜는 결정(control plane과 그 장애 모드 전체)이 같은 결정이 아니기
때문이다. 하나의 스위치는 두 번째 결정을 실수로 내리게 만든다.
| grade | 시작 가능 | production 추가 승인 |
| --- | --- | --- |
| `ADVANCED_STABLE` | O | 불필요 |
| `EXPERIMENTAL` | O | **필요** |
| `WATCH` | X (추적만) | — |
| `DISABLED` | X | — |
property key는 `ca-skeleton.grpc.advanced.<capability>.enabled`이고 전부 기본 off다.
`GrpcAdvancedModuleGuard`가 세 조건(flag 미설정 / grade가 시작 불가 / production 승인 없음)을
구분해서 거부하며, 세 경우의 조치가 다르므로 메시지도 다르다.
## Leaf별 담당 capability
| leaf | capability |
| --- | --- |
| `grpc-advanced-bootstrap` | capability grade, feature flag, module guard, capability별 promotion gate |
| `grpc-advanced-edition` | Protobuf Edition 2024 opt-in lane, Edition 2026 watch lane |
| `grpc-advanced-streaming` | client streaming(session/dedup/checkpoint), bidi(dual sequence), manual flow control |
| `grpc-advanced-resilience` | read-only hedging, custom name resolver SPI, custom load balancer SPI, proxyless xDS |
| `grpc-advanced-compat` | gRPC-Web, Servlet HTTP/2, Spring Integration bridge, Reactor adapter, Kotlin 경계 |
| `grpc-advanced-diagnostics` | Channelz/CSDS 진단, advanced infrastructure testkit 요구사항 |
## Promotion은 capability별로 독립이다
`GrpcAdvancedPromotionEvidence`는 capability마다 별도 레코드다. 공유 레코드였다면 하나를 승격할 때
같은 시점에 측정된 다른 것들이 함께 승격된다. `GrpcAdvancedPromotionGate.capabilitiesDraggedAlong`
항상 빈 리스트인 것은 주석이 아니라 테스트되는 속성이다.
승격 문턱은 두 개다: `ADVANCED_STABLE`은 7일 soak + 전체 증거, Stable default는 30일 soak. 두 번째가
더 높은 이유는 모든 배포가 그 의존성과 장애 모드를 갖게 되기 때문이다.
## 이 저장소에서 검증할 수 없는 것
`GrpcAdvancedInfrastructureTestkit`이 capability별로 필요한 실제 인프라를 명시한다.
- `grpc-web` → gRPC-Web 프록시
- `servlet-compat` → Servlet 컨테이너
- `xds` → 중지 가능한 xDS control plane
- `kotlin` → Kotlin 툴체인 (**이 저장소에 없다**)
인프라 없이 도는 suite는 통과하면서 아무것도 증명하지 않으므로, suite가 없는 것보다 나쁘다.
`GrpcKotlinCompatibilityGate.supportableHere()``false`를 반환하는 것은 그 사실의 코드 표현이다 —
Kotlin 계약 요구사항 4개는 검증되지만 compile lane은 존재하지 않는다.
## 금지
- Stable leaf(`grpc:*`)가 이 family를 참조하는 것.
- capability grade 없이, 또는 flag 없이 advanced 코드 경로를 실행하는 것.
- `WATCH` capability를 스키마 소스로 사용하는 것 (`GrpcEdition2026Guard`가 무조건 거부한다).
- 실제 인프라 없이 실행한 suite를 promotion evidence로 인용하는 것.
@@ -0,0 +1,12 @@
apply plugin: 'java-library'
// The Advanced boundary itself: capability grades, the `ca-skeleton.grpc.advanced.*` feature-flag
// contract, the module guard that refuses an unflagged capability, and the per-capability
// promotion gate.
//
// This leaf depends on Stable public types and never the other way round. The Stable starter's
// registry entry names no advanced id, so `verifyCleanArchitectureDependencies` is what makes
// "Advanced never leaks into Stable" a build failure rather than a review note.
dependencies {
api project(':grpc:grpc-core-api')
}
@@ -0,0 +1,84 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.21.0=spotbugs
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.6=spotbugs
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.5=spotbugs
org.apache.logging.log4j:log4j-core:2.25.5=spotbugs
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.javassist:javassist:3.28.0-GA=checkstyle
org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath
org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath
org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=mockitoAgent
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.reflections:reflections:0.10.2=checkstyle
org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j
org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j
org.slf4j:slf4j-simple:2.0.18=checkstyle
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
empty=compileClasspath,runtimeClasspath
@@ -0,0 +1,65 @@
package dev.caskeleton.grpc.advanced.bootstrap;
/**
* Every capability the Stable platform deliberately excludes, and how ready each one is.
*
* <p>Grading them individually is the design. Bundling them under one "advanced" flag makes
* enabling gRPC-Web — a compatibility bridge with a proxy in front of it — the same decision as
* enabling xDS, which brings a control plane and its outage modes. They are not the same decision,
* and a single switch is how the second one gets made by accident.
*/
public enum GrpcAdvancedCapability {
/** Protobuf Edition 2024 as an opt-in schema lane. */
EDITION_2024("edition-2024", GrpcCapabilityGrade.ADVANCED_STABLE),
/** Protobuf Edition 2026. Recorded, not usable. */
EDITION_2026("edition-2026", GrpcCapabilityGrade.WATCH),
/** Client streaming with session, dedup and checkpoint. */
CLIENT_STREAMING("client-streaming", GrpcCapabilityGrade.ADVANCED_STABLE),
/** Bidirectional streaming with independent per-direction sequences. */
BIDI_STREAMING("bidi-streaming", GrpcCapabilityGrade.ADVANCED_STABLE),
/** Manual flow control, for approved streaming methods. */
MANUAL_FLOW_CONTROL("manual-flow-control", GrpcCapabilityGrade.ADVANCED_STABLE),
/** Read-only unary hedging. */
HEDGING("hedging", GrpcCapabilityGrade.EXPERIMENTAL),
/** A custom name resolver. */
CUSTOM_RESOLVER("custom-resolver", GrpcCapabilityGrade.ADVANCED_STABLE),
/** A custom load balancer. */
CUSTOM_LOAD_BALANCER("custom-load-balancer", GrpcCapabilityGrade.EXPERIMENTAL),
/** Proxyless xDS. */
XDS("xds", GrpcCapabilityGrade.EXPERIMENTAL),
/** The gRPC-Web bridge. */
GRPC_WEB("grpc-web", GrpcCapabilityGrade.ADVANCED_STABLE),
/** A Servlet container owning the HTTP/2 socket. */
SERVLET_COMPAT("servlet-compat", GrpcCapabilityGrade.ADVANCED_STABLE),
/** The Spring Integration bridge. */
INTEGRATION_BRIDGE("integration-bridge", GrpcCapabilityGrade.ADVANCED_STABLE),
/** The Reactor adapter. */
REACTOR("reactor", GrpcCapabilityGrade.ADVANCED_STABLE),
/** The Kotlin coroutine and Flow adapter. */
KOTLIN("kotlin", GrpcCapabilityGrade.ADVANCED_STABLE),
/** Channelz and CSDS diagnostics. */
CHANNEL_DIAGNOSTICS("channel-diagnostics", GrpcCapabilityGrade.ADVANCED_STABLE);
private final String flagName;
private final GrpcCapabilityGrade defaultGrade;
GrpcAdvancedCapability(String flagName, GrpcCapabilityGrade defaultGrade) {
this.flagName = flagName;
this.defaultGrade = defaultGrade;
}
/** The property suffix under {@code ca-skeleton.grpc.advanced}. */
public String flagName() {
return flagName;
}
/** The full property key that enables this capability. */
public String propertyKey() {
return "ca-skeleton.grpc.advanced." + flagName + ".enabled";
}
/** How ready this capability is today. */
public GrpcCapabilityGrade defaultGrade() {
return defaultGrade;
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.grpc.advanced.bootstrap;
/**
* A capability was used without being enabled.
*
* <p>The message carries the property key. An advanced capability is off by default and the refusal
* is the first thing a developer meets when trying it; telling them which key to set turns a
* support question into a configuration line.
*/
public class GrpcAdvancedCapabilityDisabledException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final transient GrpcAdvancedCapability capability;
/** Refuses use of a disabled capability. */
public GrpcAdvancedCapabilityDisabledException(GrpcAdvancedCapability capability, String reason) {
super(render(capability, reason));
this.capability = capability;
}
private static String render(GrpcAdvancedCapability capability, String reason) {
if (capability == null) {
throw new IllegalArgumentException("a capability is required");
}
if (reason == null || reason.isBlank()) {
throw new IllegalArgumentException("a refusal explains itself");
}
return "advanced capability '"
+ capability.flagName()
+ "' is not available: "
+ reason
+ " (set "
+ capability.propertyKey()
+ "=true to enable it)";
}
/** The capability that was refused. */
public GrpcAdvancedCapability capability() {
return capability;
}
}
@@ -0,0 +1,105 @@
package dev.caskeleton.grpc.advanced.bootstrap;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.Map;
import java.util.Set;
/**
* Which advanced capabilities a deployment has turned on.
*
* <p>Everything is off unless named. A capability that switches itself on because its jar is
* present is a capability nobody decided to run, and the ones here bring proxies, control planes
* and duplicate request load with them.
*/
public final class GrpcAdvancedFeatureFlags {
private final Map<GrpcAdvancedCapability, Boolean> enabled =
new EnumMap<>(GrpcAdvancedCapability.class);
private final Map<GrpcAdvancedCapability, GrpcCapabilityGrade> grades =
new EnumMap<>(GrpcAdvancedCapability.class);
private final boolean production;
private final Set<GrpcAdvancedCapability> productionApprovals;
private GrpcAdvancedFeatureFlags(
boolean production, Set<GrpcAdvancedCapability> productionApprovals) {
this.production = production;
this.productionApprovals =
EnumSet.copyOf(
productionApprovals.isEmpty()
? EnumSet.noneOf(GrpcAdvancedCapability.class)
: EnumSet.copyOf(productionApprovals));
for (GrpcAdvancedCapability capability : GrpcAdvancedCapability.values()) {
enabled.put(capability, false);
grades.put(capability, capability.defaultGrade());
}
}
/** Flags for a non-production environment. */
public static GrpcAdvancedFeatureFlags forDevelopment() {
return new GrpcAdvancedFeatureFlags(false, Set.of());
}
/**
* Flags for production.
*
* @param productionApprovals the experimental capabilities somebody has accepted the risk of
*/
public static GrpcAdvancedFeatureFlags forProduction(
Set<GrpcAdvancedCapability> productionApprovals) {
if (productionApprovals == null) {
throw new IllegalArgumentException("an approval set is required, even if empty");
}
return new GrpcAdvancedFeatureFlags(true, productionApprovals);
}
/** Turns a capability on. */
public GrpcAdvancedFeatureFlags enable(GrpcAdvancedCapability capability) {
if (capability == null) {
throw new IllegalArgumentException("a capability is required");
}
enabled.put(capability, true);
return this;
}
/** Overrides a capability's grade, for a deployment that has its own evidence. */
public GrpcAdvancedFeatureFlags withGrade(
GrpcAdvancedCapability capability, GrpcCapabilityGrade grade) {
if (capability == null || grade == null) {
throw new IllegalArgumentException("a grade override needs both parts");
}
grades.put(capability, grade);
return this;
}
/** Whether the flag is set, regardless of whether the capability may actually start. */
public boolean flagSet(GrpcAdvancedCapability capability) {
return Boolean.TRUE.equals(enabled.get(capability));
}
/** The grade in force for a capability. */
public GrpcCapabilityGrade gradeOf(GrpcAdvancedCapability capability) {
return grades.get(capability);
}
/** Whether this deployment is production. */
public boolean production() {
return production;
}
/** Whether an experimental capability has been separately approved for production. */
public boolean productionApproved(GrpcAdvancedCapability capability) {
return productionApprovals.contains(capability);
}
/** The capabilities that are both flagged and permitted to start. */
public Set<GrpcAdvancedCapability> active() {
Set<GrpcAdvancedCapability> running = EnumSet.noneOf(GrpcAdvancedCapability.class);
for (GrpcAdvancedCapability capability : GrpcAdvancedCapability.values()) {
if (GrpcAdvancedModuleGuard.available(this, capability)) {
running.add(capability);
}
}
return Set.copyOf(running);
}
}
@@ -0,0 +1,84 @@
package dev.caskeleton.grpc.advanced.bootstrap;
import dev.caskeleton.grpc.core.GrpcStableBuildInvariant;
import dev.caskeleton.grpc.core.GrpcStableModuleCatalog;
import java.util.Set;
/**
* The single gate every advanced capability passes through.
*
* <p>Three conditions, checked in this order because each explains a different refusal: the flag is
* not set, the grade cannot start at all, or production has not separately approved an experimental
* capability. Collapsing them into one boolean produces a "not enabled" message for three
* situations with three different remedies.
*/
public final class GrpcAdvancedModuleGuard {
private GrpcAdvancedModuleGuard() {}
/** Whether {@code capability} may run under {@code flags}. */
public static boolean available(
GrpcAdvancedFeatureFlags flags, GrpcAdvancedCapability capability) {
if (flags == null || capability == null) {
throw new IllegalArgumentException("availability needs flags and a capability");
}
if (!flags.flagSet(capability)) {
return false;
}
GrpcCapabilityGrade grade = flags.gradeOf(capability);
if (!grade.startable()) {
return false;
}
return !(flags.production()
&& grade.requiresProductionApproval()
&& !flags.productionApproved(capability));
}
/**
* Fails when {@code capability} may not run.
*
* @throws GrpcAdvancedCapabilityDisabledException naming which of the three conditions failed
*/
public static void require(GrpcAdvancedFeatureFlags flags, GrpcAdvancedCapability capability) {
if (flags == null || capability == null) {
throw new IllegalArgumentException("a guard needs flags and a capability");
}
if (!flags.flagSet(capability)) {
throw new GrpcAdvancedCapabilityDisabledException(capability, "its feature flag is not set");
}
GrpcCapabilityGrade grade = flags.gradeOf(capability);
if (!grade.startable()) {
throw new GrpcAdvancedCapabilityDisabledException(
capability,
"it is graded "
+ grade
+ ", which cannot start; a WATCH capability is tracked rather than implemented");
}
if (flags.production()
&& grade.requiresProductionApproval()
&& !flags.productionApproved(capability)) {
throw new GrpcAdvancedCapabilityDisabledException(
capability,
"it is "
+ grade
+ " and production needs a separate approval; the flag says somebody wanted it, not "
+ "that somebody accepted its uncharacterised failure modes");
}
}
/**
* Fails when the Stable starter's dependency set reaches an advanced module.
*
* <p>The same invariant the registry enforces at build time, asserted here so a runtime that was
* assembled some other way — a fat jar, a shaded artifact, a test harness — is checked too.
*/
public static void requireStableStarterIsClean(Set<String> starterDependencies) {
GrpcStableBuildInvariant.requireNoAdvancedDependency(
"grpc-spring-boot-starter", starterDependencies);
}
/** The advanced module ids, for a runtime classpath check. */
public static Set<String> advancedModules() {
return GrpcStableModuleCatalog.advancedModules();
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.grpc.advanced.bootstrap;
/**
* How much a capability has been established, and what that permits.
*
* <p>{@link #EXPERIMENTAL} in production needs a second, separate approval rather than the
* capability flag alone. The flag says somebody wanted the feature; the approval says somebody
* accepted that its failure modes are not fully characterised, which is a different person's
* decision on most teams.
*/
public enum GrpcCapabilityGrade {
/** Contract, fault and operational evidence exist. Enable with the capability flag. */
ADVANCED_STABLE(true, false),
/** Works, but its failure modes are not fully characterised. Needs a production approval too. */
EXPERIMENTAL(true, true),
/** Tracked, not implemented. Cannot be enabled. */
WATCH(false, false),
/** Withdrawn or refused. Cannot be enabled. */
DISABLED(false, false);
private final boolean startable;
private final boolean requiresProductionApproval;
GrpcCapabilityGrade(boolean startable, boolean requiresProductionApproval) {
this.startable = startable;
this.requiresProductionApproval = requiresProductionApproval;
}
/** Whether a deployment may run this capability at all. */
public boolean startable() {
return startable;
}
/** Whether production additionally requires an explicit approval. */
public boolean requiresProductionApproval() {
return requiresProductionApproval;
}
}
@@ -0,0 +1,50 @@
package dev.caskeleton.grpc.advanced.release;
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
import dev.caskeleton.grpc.advanced.bootstrap.GrpcCapabilityGrade;
import java.util.List;
/**
* Whether one capability moves to a new grade.
*
* <p>Carries the grade it would move to as well as the blockers, so a refusal says what was being
* asked for. "Not promoted" is ambiguous between a failed promotion to Advanced Stable and a failed
* promotion to a Stable default, and the second is a much larger decision.
*/
public record GrpcAdvancedPromotionDecision(
GrpcAdvancedCapability capability,
GrpcCapabilityGrade from,
GrpcCapabilityGrade to,
boolean promoted,
List<String> blockers) {
/** Requires blockers exactly when refused. */
public GrpcAdvancedPromotionDecision {
if (capability == null || from == null || to == null || blockers == null) {
throw new IllegalArgumentException(
"a promotion decision names its capability and both grades");
}
blockers = List.copyOf(blockers);
if (promoted && !blockers.isEmpty()) {
throw new IllegalArgumentException("a granted promotion has no blockers");
}
if (!promoted && blockers.isEmpty()) {
throw new IllegalArgumentException("a refused promotion says why");
}
}
/** A granted promotion. */
public static GrpcAdvancedPromotionDecision grant(
GrpcAdvancedCapability capability, GrpcCapabilityGrade from, GrpcCapabilityGrade to) {
return new GrpcAdvancedPromotionDecision(capability, from, to, true, List.of());
}
/** A refused promotion. */
public static GrpcAdvancedPromotionDecision refuse(
GrpcAdvancedCapability capability,
GrpcCapabilityGrade from,
GrpcCapabilityGrade to,
List<String> blockers) {
return new GrpcAdvancedPromotionDecision(capability, from, to, false, blockers);
}
}
@@ -0,0 +1,75 @@
package dev.caskeleton.grpc.advanced.release;
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
import java.time.Duration;
import java.util.Set;
/**
* What one capability has behind it.
*
* <p>Per capability, never shared. The Stable plan's requirement that Edition, streaming, xDS,
* gRPC-Web, Servlet and the language adapters do not gate each other only means something if their
* evidence is separate: a shared record makes promoting one of them promote whichever others
* happened to be measured at the same time.
*/
public record GrpcAdvancedPromotionEvidence(
GrpcAdvancedCapability capability,
boolean compatibilityEvidence,
boolean securityReview,
boolean faultEvidence,
boolean performanceEvidence,
Duration soakDuration,
boolean architectureDecisionRecord,
boolean runbook,
boolean realEnvironmentTest) {
/** Requires a capability and a non-negative soak. */
public GrpcAdvancedPromotionEvidence {
if (capability == null) {
throw new IllegalArgumentException("promotion evidence names its capability");
}
if (soakDuration == null || soakDuration.isNegative()) {
throw new IllegalArgumentException("a soak duration must be present and non-negative");
}
}
/** No evidence at all, which is where a capability starts. */
public static GrpcAdvancedPromotionEvidence none(GrpcAdvancedCapability capability) {
return new GrpcAdvancedPromotionEvidence(
capability, false, false, false, false, Duration.ZERO, false, false, false);
}
/** Everything a promotion to Advanced Stable needs. */
public static GrpcAdvancedPromotionEvidence complete(
GrpcAdvancedCapability capability, Duration soakDuration) {
return new GrpcAdvancedPromotionEvidence(
capability, true, true, true, true, soakDuration, true, true, true);
}
/** Which required items are absent, as a set a report can print. */
public Set<String> missing() {
Set<String> missing = new java.util.LinkedHashSet<>();
if (!compatibilityEvidence) {
missing.add("compatibility evidence");
}
if (!securityReview) {
missing.add("security review");
}
if (!faultEvidence) {
missing.add("fault evidence");
}
if (!performanceEvidence) {
missing.add("performance evidence");
}
if (!architectureDecisionRecord) {
missing.add("architecture decision record");
}
if (!runbook) {
missing.add("runbook");
}
if (!realEnvironmentTest) {
missing.add("real environment test");
}
return Set.copyOf(missing);
}
}
@@ -0,0 +1,85 @@
package dev.caskeleton.grpc.advanced.release;
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
import dev.caskeleton.grpc.advanced.bootstrap.GrpcCapabilityGrade;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
/**
* Decides one capability's promotion, on its own evidence.
*
* <p>Two thresholds rather than one. Reaching Advanced Stable means the capability works and is
* documented; becoming a Stable default means every deployment gets it, which additionally puts its
* dependencies on every classpath and its failure modes in every on-call rotation. The second needs
* the first plus a longer soak, because a capability that has run in one deployment for a week is
* not the same claim as one that ships to all of them.
*/
public final class GrpcAdvancedPromotionGate {
/** The soak a promotion to Advanced Stable requires. */
public static final Duration ADVANCED_STABLE_SOAK = Duration.ofDays(7);
/** The soak a promotion to a Stable default requires. */
public static final Duration STABLE_DEFAULT_SOAK = Duration.ofDays(30);
private GrpcAdvancedPromotionGate() {}
/**
* Whether {@code capability} may move from {@code from} to {@code to}.
*
* @throws IllegalArgumentException when the transition is not one this gate governs
*/
public static GrpcAdvancedPromotionDecision evaluate(
GrpcAdvancedPromotionEvidence evidence, GrpcCapabilityGrade from, GrpcCapabilityGrade to) {
if (evidence == null || from == null || to == null) {
throw new IllegalArgumentException("a promotion needs evidence and both grades");
}
if (from == to) {
throw new IllegalArgumentException("a promotion changes the grade");
}
GrpcAdvancedCapability capability = evidence.capability();
List<String> blockers = new ArrayList<>();
evidence.missing().stream()
.sorted()
.forEach(missing -> blockers.add(capability.flagName() + " has no " + missing));
Duration requiredSoak =
to == GrpcCapabilityGrade.ADVANCED_STABLE ? ADVANCED_STABLE_SOAK : STABLE_DEFAULT_SOAK;
if (evidence.soakDuration().compareTo(requiredSoak) < 0) {
blockers.add(
capability.flagName()
+ " soaked for "
+ evidence.soakDuration().toDays()
+ " day(s); promotion to "
+ to
+ " requires "
+ requiredSoak.toDays());
}
if (from == GrpcCapabilityGrade.WATCH && to != GrpcCapabilityGrade.EXPERIMENTAL) {
blockers.add(
capability.flagName()
+ " is WATCH, which is tracked rather than implemented; it becomes EXPERIMENTAL "
+ "before anything else");
}
return blockers.isEmpty()
? GrpcAdvancedPromotionDecision.grant(capability, from, to)
: GrpcAdvancedPromotionDecision.refuse(capability, from, to, blockers);
}
/**
* Whether promoting {@code promoted} would drag another capability with it.
*
* <p>Always empty, and the method exists so a test can assert that rather than a comment claiming
* it: each capability's evidence is its own record, so there is no path by which one promotion
* changes another's grade.
*/
public static List<GrpcAdvancedCapability> capabilitiesDraggedAlong(
GrpcAdvancedCapability promoted) {
if (promoted == null) {
throw new IllegalArgumentException("a capability is required");
}
return List.of();
}
}
@@ -0,0 +1,66 @@
package dev.caskeleton.grpc.advanced.release;
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
import dev.caskeleton.grpc.advanced.bootstrap.GrpcCapabilityGrade;
import java.util.EnumMap;
import java.util.Map;
/**
* Every advanced capability's current grade, in one place an adopter can read.
*
* <p>The published answer to "is this supported". Without it the answer is inferred from whether a
* class exists, which says only that somebody wrote it.
*/
public final class GrpcAdvancedSupportMatrix {
private final Map<GrpcAdvancedCapability, GrpcCapabilityGrade> grades =
new EnumMap<>(GrpcAdvancedCapability.class);
/** A matrix at each capability's default grade. */
public GrpcAdvancedSupportMatrix() {
for (GrpcAdvancedCapability capability : GrpcAdvancedCapability.values()) {
grades.put(capability, capability.defaultGrade());
}
}
/** The grade of one capability. */
public GrpcCapabilityGrade gradeOf(GrpcAdvancedCapability capability) {
if (capability == null) {
throw new IllegalArgumentException("a capability is required");
}
return grades.get(capability);
}
/**
* Applies a granted promotion.
*
* @throws IllegalArgumentException when the decision's starting grade is not the current one,
* which means two promotions raced or one was replayed
*/
public GrpcAdvancedSupportMatrix apply(GrpcAdvancedPromotionDecision decision) {
if (decision == null) {
throw new IllegalArgumentException("a decision is required");
}
if (!decision.promoted()) {
return this;
}
GrpcCapabilityGrade current = grades.get(decision.capability());
if (current != decision.from()) {
throw new IllegalArgumentException(
"capability '"
+ decision.capability().flagName()
+ "' is "
+ current
+ ", not "
+ decision.from()
+ "; this decision was made against a different matrix");
}
grades.put(decision.capability(), decision.to());
return this;
}
/** The whole matrix. */
public Map<GrpcAdvancedCapability, GrpcCapabilityGrade> snapshot() {
return Map.copyOf(grades);
}
}
@@ -0,0 +1,129 @@
package dev.caskeleton.grpc.advanced.bootstrap;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcAdvancedModuleGuardTest {
@Test
@DisplayName("every advanced capability is off unless a deployment names it")
void everythingIsOffByDefault() {
GrpcAdvancedFeatureFlags flags = GrpcAdvancedFeatureFlags.forDevelopment();
assertThat(flags.active()).isEmpty();
for (GrpcAdvancedCapability capability : GrpcAdvancedCapability.values()) {
assertThat(GrpcAdvancedModuleGuard.available(flags, capability)).isFalse();
}
}
@Test
@DisplayName("each capability has its own flag, not a shared one")
void eachCapabilityHasItsOwnFlag() {
assertThat(GrpcAdvancedCapability.XDS.propertyKey())
.isEqualTo("ca-skeleton.grpc.advanced.xds.enabled");
assertThat(GrpcAdvancedCapability.GRPC_WEB.propertyKey())
.isNotEqualTo(GrpcAdvancedCapability.XDS.propertyKey());
GrpcAdvancedFeatureFlags flags =
GrpcAdvancedFeatureFlags.forDevelopment().enable(GrpcAdvancedCapability.GRPC_WEB);
assertThat(GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.GRPC_WEB)).isTrue();
assertThat(GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.XDS)).isFalse();
}
@Test
@DisplayName("an unflagged capability is refused, and the message names the key to set")
void anUnflaggedCapabilityNamesItsKey() {
assertThatThrownBy(
() ->
GrpcAdvancedModuleGuard.require(
GrpcAdvancedFeatureFlags.forDevelopment(), GrpcAdvancedCapability.REACTOR))
.isInstanceOf(GrpcAdvancedCapabilityDisabledException.class)
.hasMessageContaining("ca-skeleton.grpc.advanced.reactor.enabled=true");
}
@Test
@DisplayName("a WATCH capability cannot be enabled, however loudly it is flagged")
void aWatchCapabilityCannotStart() {
GrpcAdvancedFeatureFlags flags =
GrpcAdvancedFeatureFlags.forDevelopment().enable(GrpcAdvancedCapability.EDITION_2026);
assertThat(GrpcAdvancedCapability.EDITION_2026.defaultGrade())
.isEqualTo(GrpcCapabilityGrade.WATCH);
assertThat(GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.EDITION_2026))
.isFalse();
assertThatThrownBy(
() -> GrpcAdvancedModuleGuard.require(flags, GrpcAdvancedCapability.EDITION_2026))
.isInstanceOf(GrpcAdvancedCapabilityDisabledException.class)
.hasMessageContaining("tracked rather than implemented");
}
@Test
@DisplayName("an experimental capability needs a second approval in production")
void experimentalCapabilitiesNeedAProductionApproval() {
GrpcAdvancedFeatureFlags unapproved =
GrpcAdvancedFeatureFlags.forProduction(Set.of()).enable(GrpcAdvancedCapability.XDS);
GrpcAdvancedFeatureFlags approved =
GrpcAdvancedFeatureFlags.forProduction(Set.of(GrpcAdvancedCapability.XDS))
.enable(GrpcAdvancedCapability.XDS);
assertThat(GrpcAdvancedModuleGuard.available(unapproved, GrpcAdvancedCapability.XDS)).isFalse();
assertThat(GrpcAdvancedModuleGuard.available(approved, GrpcAdvancedCapability.XDS)).isTrue();
assertThatThrownBy(
() -> GrpcAdvancedModuleGuard.require(unapproved, GrpcAdvancedCapability.XDS))
.isInstanceOf(GrpcAdvancedCapabilityDisabledException.class)
.hasMessageContaining("uncharacterised failure modes");
}
@Test
@DisplayName("an experimental capability runs outside production on its flag alone")
void experimentalCapabilitiesRunInDevelopment() {
GrpcAdvancedFeatureFlags flags =
GrpcAdvancedFeatureFlags.forDevelopment().enable(GrpcAdvancedCapability.HEDGING);
assertThat(GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.HEDGING)).isTrue();
GrpcAdvancedModuleGuard.require(flags, GrpcAdvancedCapability.HEDGING);
}
@Test
@DisplayName("the Stable starter reaching an advanced module is refused")
void theStableStarterMayNotReachAnAdvancedModule() {
GrpcAdvancedModuleGuard.requireStableStarterIsClean(Set.of("grpc-core-api", "grpc-policy"));
assertThatThrownBy(
() ->
GrpcAdvancedModuleGuard.requireStableStarterIsClean(
Set.of("grpc-core-api", "grpc-advanced-resilience")))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("grpc-advanced-resilience");
assertThat(GrpcAdvancedModuleGuard.advancedModules())
.contains("grpc-advanced-bootstrap", "grpc-advanced-streaming", "grpc-advanced-compat");
}
@Test
@DisplayName("only flagged and permitted capabilities appear as active")
void activeReflectsBothFlagAndGrade() {
GrpcAdvancedFeatureFlags flags =
GrpcAdvancedFeatureFlags.forProduction(Set.of())
.enable(GrpcAdvancedCapability.GRPC_WEB)
.enable(GrpcAdvancedCapability.XDS)
.enable(GrpcAdvancedCapability.EDITION_2026);
assertThat(flags.active()).containsExactly(GrpcAdvancedCapability.GRPC_WEB);
}
@Test
@DisplayName("a deployment may raise a capability's grade on its own evidence")
void aDeploymentMayOverrideAGrade() {
GrpcAdvancedFeatureFlags flags =
GrpcAdvancedFeatureFlags.forProduction(Set.of())
.enable(GrpcAdvancedCapability.HEDGING)
.withGrade(GrpcAdvancedCapability.HEDGING, GrpcCapabilityGrade.ADVANCED_STABLE);
assertThat(GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.HEDGING)).isTrue();
}
}
@@ -0,0 +1,150 @@
package dev.caskeleton.grpc.advanced.release;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
import dev.caskeleton.grpc.advanced.bootstrap.GrpcCapabilityGrade;
import java.time.Duration;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcAdvancedPromotionGateTest {
@Test
@DisplayName("a capability with complete evidence and a full soak is promoted")
void completeEvidencePromotes() {
GrpcAdvancedPromotionDecision decision =
GrpcAdvancedPromotionGate.evaluate(
GrpcAdvancedPromotionEvidence.complete(
GrpcAdvancedCapability.HEDGING, Duration.ofDays(7)),
GrpcCapabilityGrade.EXPERIMENTAL,
GrpcCapabilityGrade.ADVANCED_STABLE);
assertThat(decision.promoted()).isTrue();
assertThat(decision.blockers()).isEmpty();
}
@Test
@DisplayName("every missing piece of evidence is named")
void everyMissingPieceIsNamed() {
GrpcAdvancedPromotionDecision decision =
GrpcAdvancedPromotionGate.evaluate(
GrpcAdvancedPromotionEvidence.none(GrpcAdvancedCapability.XDS),
GrpcCapabilityGrade.EXPERIMENTAL,
GrpcCapabilityGrade.ADVANCED_STABLE);
assertThat(decision.blockers())
.anySatisfy(blocker -> assertThat(blocker).contains("compatibility evidence"))
.anySatisfy(blocker -> assertThat(blocker).contains("security review"))
.anySatisfy(blocker -> assertThat(blocker).contains("fault evidence"))
.anySatisfy(blocker -> assertThat(blocker).contains("runbook"))
.anySatisfy(blocker -> assertThat(blocker).contains("real environment test"))
.anySatisfy(blocker -> assertThat(blocker).contains("soaked for"));
}
@Test
@DisplayName("becoming a Stable default needs a longer soak than becoming Advanced Stable")
void theStableDefaultThresholdIsHigher() {
GrpcAdvancedPromotionEvidence weekLongSoak =
GrpcAdvancedPromotionEvidence.complete(GrpcAdvancedCapability.GRPC_WEB, Duration.ofDays(7));
assertThat(
GrpcAdvancedPromotionGate.evaluate(
weekLongSoak,
GrpcCapabilityGrade.EXPERIMENTAL,
GrpcCapabilityGrade.ADVANCED_STABLE)
.promoted())
.isTrue();
assertThat(
GrpcAdvancedPromotionGate.evaluate(
weekLongSoak, GrpcCapabilityGrade.ADVANCED_STABLE, GrpcCapabilityGrade.DISABLED)
.blockers())
.anySatisfy(blocker -> assertThat(blocker).contains("requires 30"));
}
@Test
@DisplayName("a WATCH capability becomes EXPERIMENTAL before anything else")
void watchPromotesOnlyToExperimental() {
assertThat(
GrpcAdvancedPromotionGate.evaluate(
GrpcAdvancedPromotionEvidence.complete(
GrpcAdvancedCapability.EDITION_2026, Duration.ofDays(60)),
GrpcCapabilityGrade.WATCH,
GrpcCapabilityGrade.ADVANCED_STABLE)
.blockers())
.anySatisfy(blocker -> assertThat(blocker).contains("becomes EXPERIMENTAL"));
assertThat(
GrpcAdvancedPromotionGate.evaluate(
GrpcAdvancedPromotionEvidence.complete(
GrpcAdvancedCapability.EDITION_2026, Duration.ofDays(60)),
GrpcCapabilityGrade.WATCH,
GrpcCapabilityGrade.EXPERIMENTAL)
.promoted())
.isTrue();
}
@Test
@DisplayName("promoting one capability drags none of the others with it")
void promotionsAreIndependent() {
GrpcAdvancedSupportMatrix matrix = new GrpcAdvancedSupportMatrix();
GrpcCapabilityGrade webBefore = matrix.gradeOf(GrpcAdvancedCapability.GRPC_WEB);
matrix.apply(
GrpcAdvancedPromotionGate.evaluate(
GrpcAdvancedPromotionEvidence.complete(
GrpcAdvancedCapability.HEDGING, Duration.ofDays(7)),
GrpcCapabilityGrade.EXPERIMENTAL,
GrpcCapabilityGrade.ADVANCED_STABLE));
assertThat(matrix.gradeOf(GrpcAdvancedCapability.HEDGING))
.isEqualTo(GrpcCapabilityGrade.ADVANCED_STABLE);
assertThat(matrix.gradeOf(GrpcAdvancedCapability.GRPC_WEB)).isEqualTo(webBefore);
assertThat(GrpcAdvancedPromotionGate.capabilitiesDraggedAlong(GrpcAdvancedCapability.HEDGING))
.isEmpty();
}
@Test
@DisplayName("a decision made against a different matrix state is refused")
void aStaleDecisionIsRefused() {
GrpcAdvancedSupportMatrix matrix = new GrpcAdvancedSupportMatrix();
assertThatThrownBy(
() ->
matrix.apply(
GrpcAdvancedPromotionGate.evaluate(
GrpcAdvancedPromotionEvidence.complete(
GrpcAdvancedCapability.GRPC_WEB, Duration.ofDays(7)),
GrpcCapabilityGrade.EXPERIMENTAL,
GrpcCapabilityGrade.ADVANCED_STABLE)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("made against a different matrix");
}
@Test
@DisplayName("a refused promotion leaves the matrix alone")
void aRefusedPromotionChangesNothing() {
GrpcAdvancedSupportMatrix matrix = new GrpcAdvancedSupportMatrix();
matrix.apply(
GrpcAdvancedPromotionGate.evaluate(
GrpcAdvancedPromotionEvidence.none(GrpcAdvancedCapability.XDS),
GrpcCapabilityGrade.EXPERIMENTAL,
GrpcCapabilityGrade.ADVANCED_STABLE));
assertThat(matrix.gradeOf(GrpcAdvancedCapability.XDS))
.isEqualTo(GrpcCapabilityGrade.EXPERIMENTAL);
}
@Test
@DisplayName("a promotion that changes nothing is refused")
void aNoOpPromotionIsRefused() {
assertThatThrownBy(
() ->
GrpcAdvancedPromotionGate.evaluate(
GrpcAdvancedPromotionEvidence.none(GrpcAdvancedCapability.XDS),
GrpcCapabilityGrade.EXPERIMENTAL,
GrpcCapabilityGrade.EXPERIMENTAL))
.isInstanceOf(IllegalArgumentException.class);
}
}
@@ -0,0 +1,23 @@
apply plugin: 'java-library'
// Compatibility bridges: gRPC-Web, the Servlet HTTP/2 profile, the Spring Integration bridge, the
// Reactor adapter, and the Kotlin coroutine/Flow boundary.
//
// No Kotlin source set (adaptation D7): this repository has no Kotlin toolchain, so the Kotlin lane
// is expressed as a Java-side boundary contract whose compatibility gate fails closed until a real
// toolchain lane exists. Everything the gate would otherwise assert — one schema source, coroutine
// cancellation propagation, Flow backpressure inside the Stable buffer limits, evidence type
// preservation — is a checkable contract without it.
dependencies {
api project(':grpc:grpc-core-api')
api project(':grpc:grpc-policy')
api project(':grpc:grpc-server')
api project(':grpc:grpc-client')
api project(':grpc-advanced:grpc-advanced-bootstrap')
// api: the Reactor adapter's public signatures are Mono/Flux, and the Integration gateways name
// Spring Integration's Message. Hiding either would only stop an adopter compiling against the
// API this module documents.
api 'io.projectreactor:reactor-core'
api 'org.springframework.integration:spring-integration-core'
}
@@ -0,0 +1,104 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.28.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.guava:guava:33.2.1-android=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
io.grpc:grpc-api:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.6=spotbugs
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.5=spotbugs
org.apache.logging.log4j:log4j-core:2.25.5=spotbugs
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath
org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.javassist:javassist:3.28.0-GA=checkstyle
org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath
org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=mockitoAgent
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.reflections:reflections:0.10.2=checkstyle
org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j
org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j
org.slf4j:slf4j-simple:2.0.18=checkstyle
org.springframework.integration:spring-integration-core:7.0.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-messaging:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-tx:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
empty=
@@ -0,0 +1,78 @@
package dev.caskeleton.grpc.advanced.integration;
import dev.caskeleton.grpc.context.GrpcMetadataKey;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
/**
* What a Spring Integration flow may exchange with a gRPC call.
*
* <p>The header allowlist is the whole policy. A Spring Integration {@code Message} accumulates
* headers as it moves through a flow — routing keys, correlation ids, errors channels, whatever a
* transformer added — and copying them onto gRPC metadata sends a service's internal plumbing
* across the network, where it counts against the metadata budget and occasionally carries
* something sensitive.
*
* <p>The bridge does not add durability. Spring Integration channels can look like a broker, and a
* bridge that implied acknowledgement or redelivery semantics would be promising something gRPC
* does not do.
*/
public record GrpcIntegrationBridgePolicy(
Set<GrpcMetadataKey> headerAllowlist, Set<String> payloadConverters) {
/** Copies both sets and refuses a bridge with no converter. */
public GrpcIntegrationBridgePolicy {
if (headerAllowlist == null || payloadConverters == null) {
throw new IllegalArgumentException("a bridge policy states its allowlist and converters");
}
headerAllowlist = Set.copyOf(headerAllowlist);
payloadConverters = Set.copyOf(payloadConverters);
if (payloadConverters.isEmpty()) {
throw new IllegalArgumentException(
"a bridge with no registered converter cannot turn a Message payload into a request; "
+ "leaving it to reflection is how an unexpected type reaches the wire");
}
}
/** The metadata that survives from a Message's headers. */
public Map<GrpcMetadataKey, String> metadataFrom(Map<String, Object> messageHeaders) {
if (messageHeaders == null) {
throw new IllegalArgumentException("message headers must not be null");
}
Map<GrpcMetadataKey, String> metadata = new LinkedHashMap<>();
headerAllowlist.forEach(
key -> {
Object value = messageHeaders.get(key.name());
if (value != null) {
metadata.put(key, String.valueOf(value));
}
});
return Map.copyOf(metadata);
}
/** Whether a payload type has a registered converter. */
public boolean converterRegistered(String payloadType) {
return payloadConverters.contains(payloadType);
}
/**
* Whether the bridge provides broker-style acknowledgement or redelivery.
*
* <p>Always false. A bridge that implied either would be promising a delivery guarantee gRPC does
* not make.
*/
public boolean providesBrokerSemantics() {
return false;
}
/**
* Whether the bridge replaces the generated stub and service APIs.
*
* <p>Always false. It is one way to reach a gRPC call from an existing integration flow, not the
* way an application is meant to call one.
*/
public boolean replacesGeneratedApis() {
return false;
}
}
@@ -0,0 +1,58 @@
package dev.caskeleton.grpc.advanced.integration;
import dev.caskeleton.grpc.context.GrpcRequestContext;
import java.util.Map;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
/**
* Turns an inbound gRPC call into a Spring Integration {@code Message}.
*
* <p>The request context travels as one header holding the immutable context object, rather than as
* a scattering of actor, tenant and deadline headers. Flattening it would let a transformer in the
* middle of a flow change the tenant of a request that has already been authenticated.
*
* @param <Q> the request payload type
*/
public final class GrpcIntegrationInboundGateway<Q> {
/** The header the immutable request context travels under. */
public static final String CONTEXT_HEADER = "grpcRequestContext";
private final GrpcIntegrationBridgePolicy policy;
/** Binds a gateway to its bridge policy. */
public GrpcIntegrationInboundGateway(GrpcIntegrationBridgePolicy policy) {
if (policy == null) {
throw new IllegalArgumentException("an inbound gateway needs a bridge policy");
}
this.policy = policy;
}
/**
* Builds the Message a flow receives.
*
* @throws IllegalArgumentException when the payload type has no registered converter
*/
public Message<Q> toMessage(Q payload, GrpcRequestContext context) {
if (payload == null || context == null) {
throw new IllegalArgumentException("an inbound message needs a payload and a context");
}
String payloadType = payload.getClass().getName();
if (!policy.converterRegistered(payloadType)) {
throw new IllegalArgumentException(
"no converter is registered for '"
+ payloadType
+ "'; converting by reflection is how an unexpected type reaches a flow");
}
Map<String, Object> headers = new java.util.LinkedHashMap<>();
headers.put(CONTEXT_HEADER, context);
context.metadata().forEach((key, value) -> headers.put(key.name(), value));
return MessageBuilder.withPayload(payload).copyHeaders(headers).build();
}
/** The policy in force. */
public GrpcIntegrationBridgePolicy policy() {
return policy;
}
}
@@ -0,0 +1,57 @@
package dev.caskeleton.grpc.advanced.integration;
import dev.caskeleton.grpc.context.GrpcMetadataKey;
import java.util.Map;
import org.springframework.messaging.Message;
/**
* Turns an outbound Spring Integration {@code Message} into a gRPC call's inputs.
*
* <p>Applies the header allowlist rather than copying what the flow accumulated. A flow's headers
* are its own bookkeeping; putting them on the wire spends the metadata budget on another service's
* plumbing and occasionally sends something that should not leave the process.
*
* @param <C> the request payload type
*/
public final class GrpcIntegrationOutboundGateway<C> {
private final GrpcIntegrationBridgePolicy policy;
/** Binds a gateway to its bridge policy. */
public GrpcIntegrationOutboundGateway(GrpcIntegrationBridgePolicy policy) {
if (policy == null) {
throw new IllegalArgumentException("an outbound gateway needs a bridge policy");
}
this.policy = policy;
}
/** The metadata this message contributes to the call. */
public Map<GrpcMetadataKey, String> metadataFrom(Message<C> message) {
if (message == null) {
throw new IllegalArgumentException("an outbound message is required");
}
return policy.metadataFrom(message.getHeaders());
}
/**
* The payload to send.
*
* @throws IllegalArgumentException when the payload type has no registered converter
*/
public C payloadFrom(Message<C> message) {
if (message == null) {
throw new IllegalArgumentException("an outbound message is required");
}
C payload = message.getPayload();
String payloadType = payload.getClass().getName();
if (!policy.converterRegistered(payloadType)) {
throw new IllegalArgumentException("no converter is registered for '" + payloadType + "'");
}
return payload;
}
/** The policy in force. */
public GrpcIntegrationBridgePolicy policy() {
return policy;
}
}
@@ -0,0 +1,60 @@
package dev.caskeleton.grpc.advanced.kotlin;
import dev.caskeleton.grpc.deadline.GrpcCancellationCoordinator;
import dev.caskeleton.grpc.deadline.GrpcCancellationReason;
import java.time.Instant;
import java.util.function.Supplier;
/**
* The contract a Kotlin coroutine adapter has to satisfy, expressed as Java callbacks.
*
* <p>Callbacks rather than coroutine types, so the rule is checkable without a Kotlin toolchain. A
* Kotlin adapter wires its {@code Job} completion handler to {@link #onCoroutineCancelled} and its
* cancellation source to {@link #cancelCoroutineScope}; what the platform needs is that both
* directions exist, and that is what this makes assertable.
*/
public final class GrpcCoroutineContextBridge {
private GrpcCoroutineContextBridge() {}
/**
* What a Kotlin adapter calls when its coroutine scope is cancelled.
*
* <p>Coroutine cancellation is cooperative and structured: cancelling a scope cancels its
* children, and a gRPC call started inside it is not one of them unless something says so.
*/
public static Runnable onCoroutineCancelled(
GrpcCancellationCoordinator coordinator, Supplier<Instant> now) {
if (coordinator == null || now == null) {
throw new IllegalArgumentException("the bridge needs a coordinator and a clock");
}
return () -> coordinator.cancel(GrpcCancellationReason.CLIENT_CANCELLED, now.get());
}
/**
* Registers the coroutine scope so a platform cancellation reaches it.
*
* @param cancelScope what the Kotlin side does to cancel its scope
*/
public static void cancelCoroutineScope(
GrpcCancellationCoordinator coordinator, Runnable cancelScope, String operationName) {
if (coordinator == null || cancelScope == null) {
throw new IllegalArgumentException("the bridge needs a coordinator and a cancel action");
}
if (operationName == null || operationName.isBlank()) {
throw new IllegalArgumentException("a cancellable operation needs a name");
}
coordinator.register(
new dev.caskeleton.grpc.deadline.GrpcCancellableOperation() {
@Override
public String name() {
return operationName;
}
@Override
public void cancel(GrpcCancellationReason reason) {
cancelScope.run();
}
});
}
}
@@ -0,0 +1,69 @@
package dev.caskeleton.grpc.advanced.kotlin;
import java.util.ArrayList;
import java.util.List;
/**
* Whether a Kotlin adapter may be advertised as supported.
*
* <p>Fails closed in this repository, and says so rather than reporting a pass it cannot justify.
* There is no Kotlin toolchain here (adaptation design D7), so the compile lane that would
* establish the last requirement has never run; a gate that reported success anyway would put an
* unverified claim in the support matrix.
*
* <p>The other four requirements are checkable and are checked, so turning the toolchain on later
* is a lane to add rather than a contract to write.
*/
public final class GrpcKotlinCompatibilityGate {
private GrpcKotlinCompatibilityGate() {}
/**
* Every reason a Kotlin adapter is not yet supportable.
*
* @param toolchainLaneRan whether a Kotlin compile lane actually ran against this profile
* @return an empty list only when the profile is complete and its lane has run
*/
public static List<String> blockers(GrpcKotlinProfile profile, boolean toolchainLaneRan) {
if (profile == null) {
throw new IllegalArgumentException("a Kotlin profile is required");
}
List<String> blockers = new ArrayList<>();
if (!profile.sharesSchemaSourceWithJava()) {
blockers.add(
"the Kotlin contract does not share one schema source with the Java contract; two "
+ "sources diverge where only somebody reading both would notice");
}
if (!profile.propagatesCoroutineCancellation()) {
blockers.add(
"coroutine cancellation does not reach the gRPC call; a cancelled scope would leave the "
+ "call running");
}
if (!profile.respectsStableFlowControl()) {
blockers.add(
"Flow backpressure bypasses the Stable buffer bounds; a slow collector would buffer "
+ "without a limit rather than terminating the stream");
}
if (!profile.preservesPlatformEvidenceTypes()) {
blockers.add(
"the adapter does not preserve the platform's evidence, status and deadline types; a "
+ "Kotlin-idiomatic re-creation is a second model of the same facts");
}
if (!toolchainLaneRan) {
blockers.add(
"no Kotlin toolchain lane has run against "
+ profile.kotlinToolchainVersion()
+ "; this repository has no Kotlin toolchain, so the compile evidence does not exist");
}
return List.copyOf(blockers);
}
/**
* Whether the Kotlin adapter is supportable in this repository as it stands.
*
* <p>False. The four contract requirements can be satisfied; the toolchain lane cannot.
*/
public static boolean supportableHere() {
return false;
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.grpc.advanced.kotlin;
/**
* How a Kotlin adapter must behave, stated from the Java side.
*
* <p>Java-side because this repository has no Kotlin toolchain (adaptation design D7). What can be
* expressed without one is every rule that constrains the adapter: one schema source shared with
* the Java contract, coroutine cancellation propagated to the call, Flow backpressure inside the
* Stable buffer bounds, and the platform's evidence and status types preserved rather than
* re-created in Kotlin idiom.
*
* <p>Each is a component so the gate can check them individually. A single "compatible" flag would
* let a partially compliant adapter through, and the most likely partial failure — a Flow that
* buffers without a bound — is the one with the worst production behaviour.
*/
public record GrpcKotlinProfile(
boolean sharesSchemaSourceWithJava,
boolean propagatesCoroutineCancellation,
boolean respectsStableFlowControl,
boolean preservesPlatformEvidenceTypes,
String kotlinToolchainVersion) {
/** Refuses a profile that claims support without naming a toolchain. */
public GrpcKotlinProfile {
if (kotlinToolchainVersion == null || kotlinToolchainVersion.isBlank()) {
throw new IllegalArgumentException(
"a Kotlin profile names the toolchain it was verified against; 'Kotlin' is not a version");
}
}
/** A profile for a toolchain this repository has not verified. */
public static GrpcKotlinProfile unverified(String kotlinToolchainVersion) {
return new GrpcKotlinProfile(false, false, false, false, kotlinToolchainVersion);
}
}
@@ -0,0 +1,61 @@
package dev.caskeleton.grpc.advanced.reactor;
import dev.caskeleton.grpc.deadline.GrpcCancellationCoordinator;
import dev.caskeleton.grpc.deadline.GrpcCancellationReason;
import java.time.Instant;
import java.util.function.Supplier;
import reactor.core.Disposable;
/**
* Turns a Reactor subscription's cancellation into the platform's.
*
* <p>Both directions, and both are needed. A client that disposes its {@code Mono} has stopped
* caring, and without this bridge the server keeps computing an answer nobody will read; a call the
* platform cancelled — deadline, drain, revoked credential — has to stop the reactive pipeline, or
* the work continues after the response has been closed.
*/
public final class GrpcReactorCancellationBridge {
private GrpcReactorCancellationBridge() {}
/**
* A callback for {@code doOnCancel} that cancels the platform call.
*
* @param now supplies the moment, so a test does not depend on the wall clock
*/
public static Runnable onReactorCancel(
GrpcCancellationCoordinator coordinator, Supplier<Instant> now) {
if (coordinator == null || now == null) {
throw new IllegalArgumentException("a cancellation bridge needs a coordinator and a clock");
}
return () -> coordinator.cancel(GrpcCancellationReason.CLIENT_CANCELLED, now.get());
}
/**
* Disposes the reactive pipeline when the platform cancels.
*
* <p>Registered as a cancellable operation, so it is reached by the same cancellation that stops
* the database query and the stream writer rather than by a second mechanism.
*/
public static void bindPlatformCancellation(
GrpcCancellationCoordinator coordinator, Disposable subscription, String operationName) {
if (coordinator == null || subscription == null) {
throw new IllegalArgumentException("binding needs a coordinator and a subscription");
}
if (operationName == null || operationName.isBlank()) {
throw new IllegalArgumentException("a cancellable operation needs a name");
}
coordinator.register(
new dev.caskeleton.grpc.deadline.GrpcCancellableOperation() {
@Override
public String name() {
return operationName;
}
@Override
public void cancel(GrpcCancellationReason reason) {
subscription.dispose();
}
});
}
}
@@ -0,0 +1,58 @@
package dev.caskeleton.grpc.advanced.reactor;
import dev.caskeleton.grpc.context.GrpcContextSnapshot;
import java.util.Optional;
import reactor.util.context.Context;
import reactor.util.context.ContextView;
/**
* Carries the call context between gRPC's {@code Context} and Reactor's.
*
* <p>Explicitly, because neither propagates into the other. gRPC's context is a thread-local
* mechanism and Reactor's travels with the subscription, so an operator that hops threads leaves
* the gRPC context behind and a gRPC interceptor cannot see the Reactor one. Work downstream of
* that boundary then runs with no actor, no tenant and no deadline, which is the failure that
* produces a change attributed to nobody.
*/
public final class GrpcReactorContextBridge {
private static final String CONTEXT_KEY = "dev.caskeleton.grpc.contextSnapshot";
private GrpcReactorContextBridge() {}
/** Puts the snapshot into a Reactor context. */
public static Context write(Context context, GrpcContextSnapshot snapshot) {
if (context == null || snapshot == null) {
throw new IllegalArgumentException("bridging needs a Reactor context and a snapshot");
}
return context.put(CONTEXT_KEY, snapshot);
}
/** Reads the snapshot out of a Reactor context, if it is there. */
public static Optional<GrpcContextSnapshot> read(ContextView context) {
if (context == null) {
throw new IllegalArgumentException("a Reactor context view is required");
}
return context.hasKey(CONTEXT_KEY) ? Optional.of(context.get(CONTEXT_KEY)) : Optional.empty();
}
/**
* Reads the snapshot, failing when it is absent.
*
* @throws IllegalStateException because reactive work with no call context has no actor, no
* tenant and no deadline
*/
public static GrpcContextSnapshot require(ContextView context) {
return read(context)
.orElseThrow(
() ->
new IllegalStateException(
"no gRPC call context is in the Reactor context; reactive work that runs "
+ "without one produces a change attributed to nobody"));
}
/** The key the snapshot travels under, for a test or a diagnostic to look at. */
public static String contextKey() {
return CONTEXT_KEY;
}
}
@@ -0,0 +1,57 @@
package dev.caskeleton.grpc.advanced.reactor;
import dev.caskeleton.grpc.context.GrpcContextSnapshot;
import java.util.List;
import java.util.function.Supplier;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* Exposes a unary call as a {@code Mono} and a server stream as a {@code Flux}.
*
* <p>Only here. The Stable contract types stay free of Reactor, so a deployment that does not use
* it does not carry it, and the reactive shape is a view over the platform rather than the
* platform's own vocabulary.
*
* <p>The {@code Flux} respects the Stable bounded flow control rather than replacing it. Reactor's
* backpressure and gRPC's are two mechanisms over one connection; letting a subscriber's {@code
* request(n)} drive the writer directly would bypass the bounded queue that decides what happens
* when a consumer falls behind.
*/
public final class ReactiveGrpcClient {
private ReactiveGrpcClient() {}
/**
* A unary call as a {@code Mono}, with the call context carried in the Reactor context.
*
* @param call the blocking invocation, run on {@code boundedElasticScheduler} rather than on the
* calling thread. A blocking call on an event loop stalls every other call sharing it, and
* the mistake is invisible until load arrives.
*/
public static <R> Mono<R> unary(
Supplier<R> call, GrpcContextSnapshot snapshot, reactor.core.scheduler.Scheduler blocking) {
if (call == null || snapshot == null || blocking == null) {
throw new IllegalArgumentException(
"a reactive unary call needs an invocation, a context and a scheduler");
}
return Mono.fromSupplier(call)
.subscribeOn(blocking)
.contextWrite(context -> GrpcReactorContextBridge.write(context, snapshot));
}
/**
* A server stream as a {@code Flux}.
*
* @param messages the already-bounded message source. Taking a list rather than a producer is
* deliberate: the bound belongs to the Stable stream writer, and a producer here would be a
* second place to get it wrong.
*/
public static <R> Flux<R> serverStream(List<R> messages, GrpcContextSnapshot snapshot) {
if (messages == null || snapshot == null) {
throw new IllegalArgumentException("a reactive stream needs its messages and a context");
}
return Flux.fromIterable(messages)
.contextWrite(context -> GrpcReactorContextBridge.write(context, snapshot));
}
}
@@ -0,0 +1,57 @@
package dev.caskeleton.grpc.advanced.reactor;
import dev.caskeleton.grpc.context.GrpcContextSnapshot;
import dev.caskeleton.grpc.context.GrpcRequestContext;
import java.util.function.BiFunction;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Scheduler;
/**
* Runs a reactive use case behind a gRPC service adapter.
*
* <p>The blocking scheduler is a required argument rather than a default. A reactive pipeline that
* touches JPA or a blocking SDK and does not say where that happens runs it on the event loop, and
* the symptom — every call on the connection slowing together — appears only under load and points
* at the wrong component.
*
* @param <C> the application command type
* @param <R> the application result type
*/
public final class ReactiveGrpcServerAdapter<C, R> {
private final BiFunction<C, GrpcRequestContext, Mono<R>> useCase;
private final Scheduler blockingScheduler;
/** Binds an adapter to its use case and the scheduler blocking work runs on. */
public ReactiveGrpcServerAdapter(
BiFunction<C, GrpcRequestContext, Mono<R>> useCase, Scheduler blockingScheduler) {
if (useCase == null || blockingScheduler == null) {
throw new IllegalArgumentException(
"a reactive adapter needs a use case and the scheduler its blocking work runs on");
}
this.useCase = useCase;
this.blockingScheduler = blockingScheduler;
}
/**
* Invokes the use case with the context in both the Reactor context and the argument.
*
* <p>Both, because they are read by different code: application operators read the Reactor
* context, and the use case signature reads the argument. Supplying only one leaves the other
* empty at a boundary nobody expects.
*/
public Mono<R> invoke(C command, GrpcRequestContext context, GrpcContextSnapshot snapshot) {
if (command == null || context == null || snapshot == null) {
throw new IllegalArgumentException(
"an invocation needs a command, a request context and the snapshot to carry");
}
return Mono.defer(() -> useCase.apply(command, context))
.subscribeOn(blockingScheduler)
.contextWrite(reactorContext -> GrpcReactorContextBridge.write(reactorContext, snapshot));
}
/** The scheduler blocking work runs on. */
public Scheduler blockingScheduler() {
return blockingScheduler;
}
}
@@ -0,0 +1,50 @@
package dev.caskeleton.grpc.advanced.servlet;
import java.util.Set;
/**
* What a Servlet container can and cannot do compared with Netty.
*
* <p>The gaps are not incidental. A Servlet container owns the socket, so everything below the
* request abstraction belongs to it: keepalive, connection age, and the flow-control window are the
* container's settings, not gRPC's. Naming them is what stops a deployment from configuring a
* keepalive that is silently ignored and concluding the client is at fault.
*/
public enum GrpcServletCapabilityMatrix {
/** HTTP/2 request and response. Available. */
HTTP2(true),
/** TLS. Available, terminated by the container. */
TLS(true),
/** Inbound message and metadata limits. Available. */
MESSAGE_LIMITS(true),
/** The standard health service. Available. */
HEALTH(true),
/** Server reflection. Available. */
REFLECTION(true),
/** Graceful shutdown. Available through the container's lifecycle. */
GRACEFUL_SHUTDOWN(true),
/** gRPC-level keepalive tuning. The container owns the connection. */
KEEPALIVE_TUNING(false),
/** Maximum connection age. The container owns the connection. */
MAX_CONNECTION_AGE(false),
/** HTTP/2 flow-control window tuning. The container owns the transport. */
FLOW_CONTROL_WINDOW_TUNING(false);
private final boolean available;
GrpcServletCapabilityMatrix(boolean available) {
this.available = available;
}
/** Whether the Servlet transport provides this. */
public boolean available() {
return available;
}
/** Everything the Servlet transport cannot do. */
public static Set<GrpcServletCapabilityMatrix> unavailable() {
return java.util.Arrays.stream(values())
.filter(capability -> !capability.available())
.collect(java.util.stream.Collectors.toUnmodifiableSet());
}
}
@@ -0,0 +1,51 @@
package dev.caskeleton.grpc.advanced.servlet;
import dev.caskeleton.grpc.server.GrpcServerTransport;
/**
* A deployment that serves gRPC from its Servlet container, on the web server's own port.
*
* <p>Attractive because it is one port, one TLS configuration and one lifecycle. The cost is that
* the container owns the transport, so the Netty settings a Stable profile carries have no effect —
* and this profile refuses to pretend otherwise.
*
* <p>Its evidence never counts as Netty certification. A suite that passes here has established
* that the container serves gRPC, not that the platform's Netty profile is correct.
*/
public record GrpcServletCompatibilityProfile(
String contextPath, boolean containerOwnsTls, boolean asyncSupported) {
/** Refuses a configuration the container cannot honour. */
public GrpcServletCompatibilityProfile {
if (contextPath == null || !contextPath.startsWith("/")) {
throw new IllegalArgumentException(
"a servlet profile needs a context path starting with '/'");
}
if (!asyncSupported) {
throw new IllegalArgumentException(
"gRPC over Servlet requires async support; without it every streaming call blocks a "
+ "container thread for its lifetime");
}
if (!containerOwnsTls) {
throw new IllegalArgumentException(
"the container owns the socket, so it owns TLS; a profile that claims otherwise "
+ "configures a setting nothing reads");
}
}
/** The transport this profile runs on. */
public GrpcServerTransport transport() {
return GrpcServerTransport.SERVLET;
}
/**
* Whether a run under this profile certifies the platform's Netty transport.
*
* <p>Always false. The container's HTTP/2 is real, but it is the container's: its keepalive, its
* connection age and its flow-control window. Stable certification is a statement about the
* platform's own Netty profile, and a Servlet run establishes nothing about it.
*/
public boolean certifiesNettyTransport() {
return false;
}
}
@@ -0,0 +1,51 @@
package dev.caskeleton.grpc.advanced.servlet;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* Refuses a Servlet deployment that asks for something the container will not do.
*
* <p>Refuses rather than warns, because the setting would otherwise be accepted and ignored. A
* keepalive configured on a Servlet deployment does nothing, the connections behave as the
* container decides, and the investigation starts from the assumption that the setting is in force.
*/
public final class GrpcServletStartupValidator {
private GrpcServletStartupValidator() {}
/**
* Every requested capability the Servlet transport cannot provide.
*
* @param requestedCapabilities the transport settings the deployment configured
* @return an empty list when everything requested is available
*/
public static List<String> violations(
GrpcServletCompatibilityProfile profile,
Set<GrpcServletCapabilityMatrix> requestedCapabilities) {
if (profile == null || requestedCapabilities == null) {
throw new IllegalArgumentException("validation needs the profile and the requested set");
}
List<String> violations = new ArrayList<>();
requestedCapabilities.stream()
.filter(capability -> !capability.available())
.sorted()
.forEach(
capability ->
violations.add(
capability
+ " is not available on the Servlet transport; the container owns the "
+ "connection, so this setting would be accepted and ignored"));
return List.copyOf(violations);
}
/**
* Whether a Servlet run may stand in for Netty certification.
*
* <p>Always false.
*/
public static boolean substitutesForNettyCertification() {
return false;
}
}
@@ -0,0 +1,56 @@
package dev.caskeleton.grpc.advanced.web;
import dev.caskeleton.grpc.core.GrpcMethodName;
import dev.caskeleton.grpc.core.RpcType;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Checks that everything exposed to browsers can actually be served to them, on the same schema as
* the native clients.
*
* <p>The same schema is the point. Two schemas — one for browsers, one for services — is how a
* field ends up meaning something different depending on which client asked, and the divergence is
* only visible to whoever reads both files.
*/
public final class GrpcWebCompatibilityGate {
private GrpcWebCompatibilityGate() {}
/**
* Every method this profile could not serve.
*
* @param exposedMethods the methods a browser is meant to be able to call
* @return an empty list when every exposed method is servable
*/
public static List<String> violations(Map<GrpcMethodName, RpcType> exposedMethods) {
if (exposedMethods == null) {
throw new IllegalArgumentException("an exposed method map is required");
}
List<String> violations = new ArrayList<>();
exposedMethods.entrySet().stream()
.sorted(java.util.Comparator.comparing(entry -> entry.getKey().canonical()))
.forEach(
entry -> {
if (!GrpcWebRpcSupport.supported(entry.getValue())) {
violations.add(
"method '"
+ entry.getKey().canonical()
+ "' is "
+ entry.getValue()
+ ", which gRPC-Web cannot carry");
}
});
return List.copyOf(violations);
}
/**
* Whether the browser and native suites must run against one schema.
*
* <p>Always true. Stated as a method so the property is asserted rather than described.
*/
public static boolean sharesOneSchemaWithNativeClients() {
return true;
}
}
@@ -0,0 +1,62 @@
package dev.caskeleton.grpc.advanced.web;
import java.util.Set;
/**
* How a browser reaches the platform, and under what browser-specific rules.
*
* <p>Cookie credentials and bearer credentials are separated because their attack surfaces differ.
* A cookie is attached by the browser to every request to the origin, which makes CSRF a real
* concern and a CSRF defence mandatory; a bearer token the application attaches explicitly is not
* sent automatically, and requiring a CSRF token there is ceremony. A profile that treats them the
* same either leaves the first exposed or burdens the second.
*/
public record GrpcWebProfile(
CredentialStyle credentialStyle,
Set<String> allowedOrigins,
boolean csrfProtection,
boolean tlsTerminatedAtProxy) {
/** How the browser presents its credential. */
public enum CredentialStyle {
/** No credential. */
NONE,
/** A cookie the browser attaches automatically. Needs a CSRF defence. */
COOKIE,
/** A bearer token the application attaches explicitly. */
BEARER
}
/** Refuses a profile a browser could be tricked into using. */
public GrpcWebProfile {
if (credentialStyle == null) {
throw new IllegalArgumentException("a gRPC-Web profile names its credential style");
}
if (allowedOrigins == null) {
throw new IllegalArgumentException("a gRPC-Web profile states its origin allowlist");
}
allowedOrigins = Set.copyOf(allowedOrigins);
if (allowedOrigins.contains("*")) {
throw new IllegalArgumentException(
"a wildcard origin lets any site call this API with the browser's ambient credentials");
}
if (credentialStyle != CredentialStyle.NONE && allowedOrigins.isEmpty()) {
throw new IllegalArgumentException(
"a credentialed gRPC-Web profile needs an origin allowlist");
}
if (credentialStyle == CredentialStyle.COOKIE && !csrfProtection) {
throw new IllegalArgumentException(
"cookie credentials are attached by the browser to every request to this origin, so a "
+ "CSRF defence is not optional");
}
if (!tlsTerminatedAtProxy && credentialStyle != CredentialStyle.NONE) {
throw new IllegalArgumentException(
"a credentialed browser call needs TLS terminated at the proxy");
}
}
/** A bearer-token profile for a single origin. */
public static GrpcWebProfile bearer(String origin) {
return new GrpcWebProfile(CredentialStyle.BEARER, Set.of(origin), false, true);
}
}
@@ -0,0 +1,64 @@
package dev.caskeleton.grpc.advanced.web;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* What a gRPC-Web proxy must be configured to do.
*
* <p>The exposed-trailer rule is the one that costs the most time when it is missing. A gRPC status
* arrives as a trailer, a browser cannot read a trailer the proxy did not expose, and the symptom
* is a call that appears to succeed at the network level and produces no status at all — which
* looks like an application bug and is a proxy configuration.
*/
public final class GrpcWebProxyContract {
/** The trailers a browser client has to be able to read. */
private static final Set<String> REQUIRED_EXPOSED_HEADERS = Set.of("grpc-status", "grpc-message");
private GrpcWebProxyContract() {}
/** The headers a proxy must expose. */
public static Set<String> requiredExposedHeaders() {
return REQUIRED_EXPOSED_HEADERS;
}
/**
* Every problem with a proxy configuration.
*
* @param exposedHeaders the CORS {@code expose_headers} the proxy is configured with
* @param allowedOrigins the CORS origin allowlist the proxy is configured with
* @return an empty list when the proxy would work for a browser client
*/
public static List<String> violations(
GrpcWebProfile profile, Set<String> exposedHeaders, Set<String> allowedOrigins) {
if (profile == null || exposedHeaders == null || allowedOrigins == null) {
throw new IllegalArgumentException("a proxy check needs the profile and both CORS sets");
}
List<String> violations = new ArrayList<>();
REQUIRED_EXPOSED_HEADERS.stream()
.sorted()
.filter(header -> !exposedHeaders.contains(header))
.forEach(
header ->
violations.add(
"the proxy does not expose '"
+ header
+ "'; a browser cannot read a trailer it was not given, so the call "
+ "produces no status at all"));
if (allowedOrigins.contains("*")) {
violations.add("the proxy allows any origin, which defeats the profile's allowlist");
}
profile.allowedOrigins().stream()
.sorted()
.filter(origin -> !allowedOrigins.contains(origin))
.forEach(
origin ->
violations.add(
"origin '" + origin + "' is in the profile but not in the proxy's allowlist"));
return List.copyOf(violations);
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.grpc.advanced.web;
import dev.caskeleton.grpc.core.RpcType;
/**
* Which RPC shapes gRPC-Web can actually carry.
*
* <p>Two, and the limit is the protocol's rather than this platform's: gRPC-Web has no way for a
* browser to send a stream of messages, so client and bidirectional streaming are not slow or
* partial there — they do not exist. Declaring support for them produces a schema a browser client
* cannot use and a discovery that happens in the browser.
*/
public final class GrpcWebRpcSupport {
private GrpcWebRpcSupport() {}
/** Whether {@code rpcType} can travel over gRPC-Web. */
public static boolean supported(RpcType rpcType) {
if (rpcType == null) {
throw new IllegalArgumentException("an RPC type is required");
}
return rpcType == RpcType.UNARY || rpcType == RpcType.SERVER_STREAMING;
}
/**
* Fails when a method shape cannot be served over gRPC-Web.
*
* @throws IllegalArgumentException naming what the browser cannot do
*/
public static void require(RpcType rpcType) {
if (!supported(rpcType)) {
throw new IllegalArgumentException(
rpcType
+ " cannot travel over gRPC-Web: a browser has no way to send a stream of messages, "
+ "so declaring support for it produces a schema no browser client can use");
}
}
}
@@ -0,0 +1,70 @@
# The gRPC-Web proxy contract, as a reference Envoy configuration.
#
# Shipped as a resource rather than as documentation prose because GrpcWebProxyContract asserts
# against it: the CORS allowlist, the exposed trailer headers and the TLS termination are the three
# things a browser client silently fails without, and a contract nobody checks is a contract that
# drifts from whatever is actually deployed.
#
# The exposed headers matter most and are the least obvious. gRPC statuses arrive as trailers, and a
# browser cannot read a trailer the proxy did not expose; the symptom is a call that appears to hang
# and then fails with no status at all.
static_resources:
listeners:
- name: grpc_web_listener
address:
socket_address: { address: 0.0.0.0, port_value: 8443 }
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: grpc_web
codec_type: AUTO
route_config:
name: grpc_web_route
virtual_hosts:
- name: grpc_web_host
domains: ["*"]
routes:
- match: { prefix: "/" }
route:
cluster: grpc_backend
timeout: 30s
cors:
allow_origin_string_match:
- exact: "https://app.example.com"
allow_methods: "POST,OPTIONS"
allow_headers: "content-type,x-grpc-web,x-correlation-id,authorization"
expose_headers: "grpc-status,grpc-message,error-code,error-category"
max_age: "1728000"
http_filters:
- name: envoy.filters.http.grpc_web
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb
- name: envoy.filters.http.cors
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
transport_socket:
name: envoy.transport_sockets.tls
typed_config:
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
clusters:
- name: grpc_backend
connect_timeout: 1s
type: STRICT_DNS
lb_policy: ROUND_ROBIN
typed_extension_protocol_options:
envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
"@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
explicit_http_config:
http2_protocol_options: {}
load_assignment:
cluster_name: grpc_backend
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address: { address: documents, port_value: 9090 }
@@ -0,0 +1,112 @@
package dev.caskeleton.grpc.advanced.compat;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.grpc.advanced.integration.GrpcIntegrationBridgePolicy;
import dev.caskeleton.grpc.advanced.integration.GrpcIntegrationInboundGateway;
import dev.caskeleton.grpc.advanced.integration.GrpcIntegrationOutboundGateway;
import dev.caskeleton.grpc.context.GrpcClientIdentity;
import dev.caskeleton.grpc.context.GrpcMetadataBudget;
import dev.caskeleton.grpc.context.GrpcMetadataKey;
import dev.caskeleton.grpc.context.GrpcRequestContext;
import dev.caskeleton.grpc.core.GrpcMethodName;
import dev.caskeleton.grpc.core.RpcType;
import dev.caskeleton.grpc.deadline.GrpcCancellationToken;
import dev.caskeleton.grpc.deadline.GrpcDeadlineBudget;
import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile;
import java.time.Duration;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
class GrpcIntegrationBridgePolicyTest {
private static final GrpcMetadataKey CORRELATION = GrpcMetadataKey.ascii("x-correlation-id");
private static final GrpcMethodName GET =
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument");
private static GrpcRequestContext requestContext() {
return GrpcRequestContext.create(
GET,
RpcType.UNARY,
GrpcClientIdentity.fromVerifiedAuthentication("actor-1", "tenant-1", "jwt-issuer"),
GrpcDeadlineBudget.forEntryPoint(
Duration.ofSeconds(1), GrpcDeadlineProfile.of(Duration.ofSeconds(2))),
new GrpcCancellationToken(),
Map.of(CORRELATION, "corr-1"),
Set.of(CORRELATION),
GrpcMetadataBudget.standard(),
null);
}
private static GrpcIntegrationBridgePolicy policy() {
return new GrpcIntegrationBridgePolicy(Set.of(CORRELATION), Set.of(String.class.getName()));
}
@Test
@DisplayName("the bridge copies only allowlisted headers onto metadata")
void theBridgeCopiesOnlyAllowlistedHeaders() {
GrpcIntegrationOutboundGateway<String> outbound =
new GrpcIntegrationOutboundGateway<>(policy());
Message<String> message =
MessageBuilder.withPayload("payload")
.setHeader(CORRELATION.name(), "corr-1")
.setHeader("errorChannel", "internal-errors")
.setHeader("replyChannel", "internal-replies")
.build();
assertThat(outbound.metadataFrom(message)).containsOnlyKeys(CORRELATION);
assertThat(outbound.payloadFrom(message)).isEqualTo("payload");
assertThat(outbound.policy()).isNotNull();
}
@Test
@DisplayName("an unregistered payload type is refused rather than converted by reflection")
void anUnregisteredPayloadIsRefused() {
GrpcIntegrationBridgePolicy otherType =
new GrpcIntegrationBridgePolicy(Set.of(CORRELATION), Set.of("some.other.Type"));
GrpcIntegrationInboundGateway<String> inbound = new GrpcIntegrationInboundGateway<>(otherType);
assertThatThrownBy(() -> inbound.toMessage("payload", requestContext()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("converting by reflection");
Message<String> message = MessageBuilder.withPayload("payload").build();
GrpcIntegrationOutboundGateway<String> outbound =
new GrpcIntegrationOutboundGateway<>(otherType);
assertThatThrownBy(() -> outbound.payloadFrom(message))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("a bridge with no registered converter is refused at construction")
void aBridgeWithNoConverterIsRefused() {
assertThatThrownBy(() -> new GrpcIntegrationBridgePolicy(Set.of(), Set.of()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("leaving it to reflection");
}
@Test
@DisplayName("the request context travels as one immutable header, not as scattered fields")
void theContextTravelsAsOneHeader() {
GrpcIntegrationInboundGateway<String> inbound = new GrpcIntegrationInboundGateway<>(policy());
Message<String> message = inbound.toMessage("payload", requestContext());
assertThat(message.getHeaders())
.containsKey(GrpcIntegrationInboundGateway.CONTEXT_HEADER)
.containsKey(CORRELATION.name())
.doesNotContainKey("tenantId")
.doesNotContainKey("actorId");
}
@Test
@DisplayName("the bridge adds no broker semantics and replaces no generated API")
void theBridgeClaimsNothingItCannotDo() {
assertThat(policy().providesBrokerSemantics()).isFalse();
assertThat(policy().replacesGeneratedApis()).isFalse();
}
}
@@ -0,0 +1,80 @@
package dev.caskeleton.grpc.advanced.compat;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.grpc.advanced.kotlin.GrpcCoroutineContextBridge;
import dev.caskeleton.grpc.advanced.kotlin.GrpcKotlinCompatibilityGate;
import dev.caskeleton.grpc.advanced.kotlin.GrpcKotlinProfile;
import dev.caskeleton.grpc.deadline.GrpcCancellationCoordinator;
import dev.caskeleton.grpc.deadline.GrpcCancellationReason;
import dev.caskeleton.grpc.deadline.GrpcCancellationToken;
import java.time.Instant;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcKotlinCompatibilityGateTest {
private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z");
@Test
@DisplayName("the Kotlin adapter fails closed here, and says which lane is missing")
void theGateFailsClosed() {
GrpcKotlinProfile complete = new GrpcKotlinProfile(true, true, true, true, "2.1.0");
assertThat(GrpcKotlinCompatibilityGate.blockers(complete, false))
.singleElement()
.satisfies(blocker -> assertThat(blocker).contains("no Kotlin toolchain"));
assertThat(GrpcKotlinCompatibilityGate.blockers(complete, true)).isEmpty();
assertThat(GrpcKotlinCompatibilityGate.supportableHere()).isFalse();
}
@Test
@DisplayName("each Kotlin contract requirement is checked on its own")
void eachRequirementIsCheckedSeparately() {
assertThat(GrpcKotlinCompatibilityGate.blockers(GrpcKotlinProfile.unverified("2.1.0"), true))
.hasSize(4)
.anySatisfy(blocker -> assertThat(blocker).contains("one schema source"))
.anySatisfy(blocker -> assertThat(blocker).contains("cancelled scope"))
.anySatisfy(blocker -> assertThat(blocker).contains("buffer without a limit"))
.anySatisfy(blocker -> assertThat(blocker).contains("second model of the same facts"));
}
@Test
@DisplayName("a profile that claims support without naming a toolchain is refused")
void aProfileNamesItsToolchain() {
assertThatThrownBy(() -> GrpcKotlinProfile.unverified(" "))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("not a version");
}
@Test
@DisplayName("coroutine cancellation reaches the platform, and the platform reaches the scope")
void cancellationCrossesBothWays() {
GrpcCancellationCoordinator inbound =
new GrpcCancellationCoordinator(new GrpcCancellationToken());
GrpcCoroutineContextBridge.onCoroutineCancelled(inbound, () -> NOW).run();
assertThat(inbound.cancelled()).isTrue();
GrpcCancellationCoordinator outbound =
new GrpcCancellationCoordinator(new GrpcCancellationToken());
AtomicBoolean scopeCancelled = new AtomicBoolean();
GrpcCoroutineContextBridge.cancelCoroutineScope(
outbound, () -> scopeCancelled.set(true), "document-flow");
outbound.cancel(GrpcCancellationReason.SERVER_DRAIN, NOW);
assertThat(scopeCancelled).isTrue();
}
@Test
@DisplayName("binding a coroutine scope without a name is refused")
void bindingNeedsAnOperationName() {
GrpcCancellationCoordinator coordinator =
new GrpcCancellationCoordinator(new GrpcCancellationToken());
assertThatThrownBy(
() -> GrpcCoroutineContextBridge.cancelCoroutineScope(coordinator, () -> {}, " "))
.isInstanceOf(IllegalArgumentException.class);
}
}
@@ -0,0 +1,108 @@
package dev.caskeleton.grpc.advanced.compat;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.grpc.advanced.reactor.GrpcReactorCancellationBridge;
import dev.caskeleton.grpc.advanced.reactor.GrpcReactorContextBridge;
import dev.caskeleton.grpc.context.GrpcClientIdentity;
import dev.caskeleton.grpc.context.GrpcContextSnapshot;
import dev.caskeleton.grpc.context.GrpcMetadataBudget;
import dev.caskeleton.grpc.context.GrpcRequestContext;
import dev.caskeleton.grpc.core.GrpcMethodName;
import dev.caskeleton.grpc.core.RpcType;
import dev.caskeleton.grpc.deadline.GrpcCancellationCoordinator;
import dev.caskeleton.grpc.deadline.GrpcCancellationReason;
import dev.caskeleton.grpc.deadline.GrpcCancellationToken;
import dev.caskeleton.grpc.deadline.GrpcDeadlineBudget;
import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import reactor.util.context.Context;
class GrpcReactorContextBridgeTest {
private static final GrpcMethodName GET =
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument");
private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z");
private static GrpcContextSnapshot snapshot() {
GrpcRequestContext request =
GrpcRequestContext.create(
GET,
RpcType.UNARY,
GrpcClientIdentity.fromVerifiedAuthentication("actor-1", "tenant-1", "jwt-issuer"),
GrpcDeadlineBudget.forEntryPoint(
Duration.ofSeconds(1), GrpcDeadlineProfile.of(Duration.ofSeconds(2))),
new GrpcCancellationToken(),
Map.of(),
Set.of(),
GrpcMetadataBudget.standard(),
null);
return GrpcContextSnapshot.of(request, null);
}
@Test
@DisplayName("the call context crosses into and out of a Reactor context")
void theContextCrossesIntoReactor() {
GrpcContextSnapshot snapshot = snapshot();
Context context = GrpcReactorContextBridge.write(Context.empty(), snapshot);
assertThat(GrpcReactorContextBridge.read(context)).contains(snapshot);
assertThat(GrpcReactorContextBridge.require(context).identity().actorId()).isEqualTo("actor-1");
assertThat(GrpcReactorContextBridge.contextKey()).isNotBlank();
}
@Test
@DisplayName("reactive work with no call context fails closed")
void contextlessReactiveWorkFailsClosed() {
assertThat(GrpcReactorContextBridge.read(Context.empty())).isEmpty();
assertThatThrownBy(() -> GrpcReactorContextBridge.require(Context.empty()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("attributed to nobody");
}
@Test
@DisplayName("a Reactor cancellation reaches the platform coordinator")
void reactorCancellationReachesThePlatform() {
GrpcCancellationCoordinator coordinator =
new GrpcCancellationCoordinator(new GrpcCancellationToken());
GrpcReactorCancellationBridge.onReactorCancel(coordinator, () -> NOW).run();
assertThat(coordinator.cancelled()).isTrue();
assertThat(coordinator.reason()).contains(GrpcCancellationReason.CLIENT_CANCELLED);
}
@Test
@DisplayName("a platform cancellation disposes the reactive subscription")
void platformCancellationDisposesTheSubscription() {
GrpcCancellationCoordinator coordinator =
new GrpcCancellationCoordinator(new GrpcCancellationToken());
AtomicBoolean disposed = new AtomicBoolean();
GrpcReactorCancellationBridge.bindPlatformCancellation(
coordinator, () -> disposed.set(true), "document-query");
coordinator.cancel(GrpcCancellationReason.DEADLINE_EXCEEDED, NOW);
assertThat(disposed).isTrue();
}
@Test
@DisplayName("binding a subscription without a name is refused")
void bindingNeedsAnOperationName() {
GrpcCancellationCoordinator coordinator =
new GrpcCancellationCoordinator(new GrpcCancellationToken());
assertThatThrownBy(
() ->
GrpcReactorCancellationBridge.bindPlatformCancellation(coordinator, () -> {}, " "))
.isInstanceOf(IllegalArgumentException.class);
}
}
@@ -0,0 +1,79 @@
package dev.caskeleton.grpc.advanced.compat;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.grpc.advanced.servlet.GrpcServletCapabilityMatrix;
import dev.caskeleton.grpc.advanced.servlet.GrpcServletCompatibilityProfile;
import dev.caskeleton.grpc.advanced.servlet.GrpcServletStartupValidator;
import dev.caskeleton.grpc.server.GrpcServerTransport;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcServletStartupValidatorTest {
private static final GrpcServletCompatibilityProfile PROFILE =
new GrpcServletCompatibilityProfile("/grpc", true, true);
@Test
@DisplayName("the Servlet transport names what it cannot do")
void theMatrixNamesItsGaps() {
assertThat(GrpcServletCapabilityMatrix.unavailable())
.containsExactlyInAnyOrder(
GrpcServletCapabilityMatrix.KEEPALIVE_TUNING,
GrpcServletCapabilityMatrix.MAX_CONNECTION_AGE,
GrpcServletCapabilityMatrix.FLOW_CONTROL_WINDOW_TUNING);
assertThat(GrpcServletCapabilityMatrix.HTTP2.available()).isTrue();
assertThat(GrpcServletCapabilityMatrix.GRACEFUL_SHUTDOWN.available()).isTrue();
}
@Test
@DisplayName("a deployment asking for a Netty-only setting is refused, not warned")
void aNettyOnlySettingIsRefused() {
assertThat(
GrpcServletStartupValidator.violations(
PROFILE, Set.of(GrpcServletCapabilityMatrix.KEEPALIVE_TUNING)))
.singleElement()
.satisfies(violation -> assertThat(violation).contains("accepted and ignored"));
assertThat(
GrpcServletStartupValidator.violations(
PROFILE,
Set.of(
GrpcServletCapabilityMatrix.KEEPALIVE_TUNING,
GrpcServletCapabilityMatrix.MAX_CONNECTION_AGE)))
.hasSize(2);
}
@Test
@DisplayName("an available capability passes")
void anAvailableCapabilityPasses() {
assertThat(
GrpcServletStartupValidator.violations(
PROFILE,
Set.of(GrpcServletCapabilityMatrix.HTTP2, GrpcServletCapabilityMatrix.TLS)))
.isEmpty();
}
@Test
@DisplayName("a Servlet run never substitutes for Netty certification")
void servletIsNotNettyCertification() {
assertThat(GrpcServletStartupValidator.substitutesForNettyCertification()).isFalse();
assertThat(PROFILE.certifiesNettyTransport()).isFalse();
assertThat(PROFILE.transport()).isEqualTo(GrpcServerTransport.SERVLET);
assertThat(GrpcServerTransport.SERVLET.certifiesNetworkBehaviour()).isFalse();
}
@Test
@DisplayName("a profile without async support or container-owned TLS is refused")
void anIncoherentProfileIsRefused() {
assertThatThrownBy(() -> new GrpcServletCompatibilityProfile("/grpc", true, false))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("blocks a container thread");
assertThatThrownBy(() -> new GrpcServletCompatibilityProfile("/grpc", false, true))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("a setting nothing reads");
assertThatThrownBy(() -> new GrpcServletCompatibilityProfile("grpc", true, true))
.isInstanceOf(IllegalArgumentException.class);
}
}
@@ -0,0 +1,144 @@
package dev.caskeleton.grpc.advanced.compat;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.grpc.advanced.web.GrpcWebCompatibilityGate;
import dev.caskeleton.grpc.advanced.web.GrpcWebProfile;
import dev.caskeleton.grpc.advanced.web.GrpcWebProxyContract;
import dev.caskeleton.grpc.advanced.web.GrpcWebRpcSupport;
import dev.caskeleton.grpc.core.GrpcMethodName;
import dev.caskeleton.grpc.core.RpcType;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcWebCompatibilityGateTest {
private static final GrpcMethodName GET =
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument");
private static final GrpcMethodName UPLOAD =
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/UploadDocument");
@Test
@DisplayName("gRPC-Web carries unary and server streaming, and nothing else")
void grpcWebCarriesTwoShapes() {
assertThat(GrpcWebRpcSupport.supported(RpcType.UNARY)).isTrue();
assertThat(GrpcWebRpcSupport.supported(RpcType.SERVER_STREAMING)).isTrue();
assertThat(GrpcWebRpcSupport.supported(RpcType.CLIENT_STREAMING)).isFalse();
assertThat(GrpcWebRpcSupport.supported(RpcType.BIDI_STREAMING)).isFalse();
assertThatThrownBy(() -> GrpcWebRpcSupport.require(RpcType.BIDI_STREAMING))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("no way to send a stream of messages");
}
@Test
@DisplayName("a client-streaming method exposed to browsers is reported")
void anUnservableMethodIsReported() {
assertThat(
GrpcWebCompatibilityGate.violations(
Map.of(GET, RpcType.UNARY, UPLOAD, RpcType.CLIENT_STREAMING)))
.singleElement()
.satisfies(violation -> assertThat(violation).contains("UploadDocument"));
assertThat(GrpcWebCompatibilityGate.violations(Map.of(GET, RpcType.UNARY))).isEmpty();
assertThat(GrpcWebCompatibilityGate.sharesOneSchemaWithNativeClients()).isTrue();
}
@Test
@DisplayName("a wildcard origin is refused, and cookie credentials require CSRF protection")
void browserCredentialRulesDifferByStyle() {
assertThatThrownBy(
() ->
new GrpcWebProfile(GrpcWebProfile.CredentialStyle.BEARER, Set.of("*"), false, true))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("ambient credentials");
assertThatThrownBy(
() ->
new GrpcWebProfile(
GrpcWebProfile.CredentialStyle.COOKIE,
Set.of("https://app.example.com"),
false,
true))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("CSRF defence is not optional");
assertThat(GrpcWebProfile.bearer("https://app.example.com").csrfProtection()).isFalse();
}
@Test
@DisplayName("a credentialed profile needs an origin allowlist and TLS at the proxy")
void aCredentialedProfileNeedsBoth() {
assertThatThrownBy(
() -> new GrpcWebProfile(GrpcWebProfile.CredentialStyle.BEARER, Set.of(), false, true))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("origin allowlist");
assertThatThrownBy(
() ->
new GrpcWebProfile(
GrpcWebProfile.CredentialStyle.BEARER,
Set.of("https://app.example.com"),
false,
false))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("TLS terminated at the proxy");
}
@Test
@DisplayName("a proxy that hides the status trailers is reported")
void aProxyMustExposeTheStatusTrailers() {
GrpcWebProfile profile = GrpcWebProfile.bearer("https://app.example.com");
assertThat(
GrpcWebProxyContract.violations(
profile, Set.of("grpc-status"), Set.of("https://app.example.com")))
.anySatisfy(violation -> assertThat(violation).contains("grpc-message"));
assertThat(
GrpcWebProxyContract.violations(
profile,
GrpcWebProxyContract.requiredExposedHeaders(),
Set.of("https://app.example.com")))
.isEmpty();
}
@Test
@DisplayName("a proxy allowing any origin, or missing the profile's, is reported")
void proxyOriginMismatchesAreReported() {
GrpcWebProfile profile = GrpcWebProfile.bearer("https://app.example.com");
assertThat(
GrpcWebProxyContract.violations(
profile, GrpcWebProxyContract.requiredExposedHeaders(), Set.of("*")))
.anySatisfy(violation -> assertThat(violation).contains("defeats the profile's allowlist"));
assertThat(
GrpcWebProxyContract.violations(
profile, GrpcWebProxyContract.requiredExposedHeaders(), Set.of()))
.anySatisfy(violation -> assertThat(violation).contains("not in the proxy's allowlist"));
}
@Test
@DisplayName("the reference Envoy configuration exposes the trailers and pins the origin")
void theReferenceProxyConfigurationIsCorrect() {
String envoy = resource("envoy/envoy.yaml");
assertThat(envoy)
.contains("expose_headers: \"grpc-status,grpc-message")
.contains("exact: \"https://app.example.com\"")
.contains("envoy.filters.http.grpc_web");
}
private static String resource(String path) {
try (InputStream stream =
GrpcWebCompatibilityGateTest.class.getClassLoader().getResourceAsStream(path)) {
if (stream == null) {
throw new IllegalStateException("missing resource " + path);
}
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
@@ -0,0 +1,10 @@
apply plugin: 'java-library'
// Channelz/CSDS diagnostics for administrators, with the redactor that keeps socket authority,
// credentials, certificate material, metadata and payload out of a snapshot, plus the advanced
// infrastructure testkit contract (gRPC-Web proxy, Servlet container, xDS control-plane failure).
dependencies {
api project(':grpc:grpc-core-api')
api project(':grpc:grpc-client')
api project(':grpc-advanced:grpc-advanced-bootstrap')
}
@@ -0,0 +1,91 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.28.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.guava:guava:33.2.1-android=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.21.0=spotbugs
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
io.grpc:grpc-api:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.6=spotbugs
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.5=spotbugs
org.apache.logging.log4j:log4j-core:2.25.5=spotbugs
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath
org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.javassist:javassist:3.28.0-GA=checkstyle
org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath
org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath
org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=mockitoAgent
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.reflections:reflections:0.10.2=checkstyle
org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j
org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j
org.slf4j:slf4j-simple:2.0.18=checkstyle
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
empty=
@@ -0,0 +1,87 @@
package dev.caskeleton.grpc.advanced.diagnostics;
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
/**
* What an advanced capability has to be exercised against before it counts as verified.
*
* <p>Real infrastructure, named per capability. gRPC-Web without a proxy tests a code path no
* browser will take; a Servlet profile without a container tests the profile object; xDS without a
* control plane cannot exercise the case that matters, which is the control plane going away. In
* all three, a suite that runs without the infrastructure passes and establishes nothing, which is
* worse than not having one.
*/
public final class GrpcAdvancedInfrastructureTestkit {
private GrpcAdvancedInfrastructureTestkit() {}
/** A piece of infrastructure a capability's suite needs. */
public enum Infrastructure {
/** An Envoy or equivalent gRPC-Web proxy. */
GRPC_WEB_PROXY,
/** A Servlet container serving HTTP/2. */
SERVLET_CONTAINER,
/** An xDS control plane that can be stopped. */
XDS_CONTROL_PLANE,
/** A Kotlin toolchain. */
KOTLIN_TOOLCHAIN
}
/** What {@code capability} needs before its evidence means anything. */
public static Set<Infrastructure> requiredFor(GrpcAdvancedCapability capability) {
if (capability == null) {
throw new IllegalArgumentException("a capability is required");
}
return switch (capability) {
case GRPC_WEB -> Set.of(Infrastructure.GRPC_WEB_PROXY);
case SERVLET_COMPAT -> Set.of(Infrastructure.SERVLET_CONTAINER);
case XDS -> Set.of(Infrastructure.XDS_CONTROL_PLANE);
case KOTLIN -> Set.of(Infrastructure.KOTLIN_TOOLCHAIN);
case EDITION_2024,
EDITION_2026,
CLIENT_STREAMING,
BIDI_STREAMING,
MANUAL_FLOW_CONTROL,
HEDGING,
CUSTOM_RESOLVER,
CUSTOM_LOAD_BALANCER,
INTEGRATION_BRIDGE,
REACTOR,
CHANNEL_DIAGNOSTICS ->
Set.of();
};
}
/**
* Every piece of infrastructure a capability needs and does not have.
*
* @return an empty list when the suite can produce evidence that means something
*/
public static List<String> missingInfrastructure(
GrpcAdvancedCapability capability, Set<Infrastructure> available) {
if (available == null) {
throw new IllegalArgumentException("the available infrastructure set is required");
}
Set<Infrastructure> required = requiredFor(capability);
if (required.isEmpty()) {
return List.of();
}
Set<Infrastructure> missing = EnumSet.copyOf(required);
missing.removeAll(available);
List<String> gaps = new ArrayList<>();
missing.stream()
.sorted()
.forEach(
infrastructure ->
gaps.add(
capability.flagName()
+ " needs "
+ infrastructure
+ "; a suite that runs without it passes and establishes nothing"));
return List.copyOf(gaps);
}
}
@@ -0,0 +1,55 @@
package dev.caskeleton.grpc.advanced.diagnostics;
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedFeatureFlags;
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedModuleGuard;
import java.util.Set;
/**
* Who may read diagnostics, and which services are registered at all.
*
* <p>CSDS is registered only when xDS is enabled. A CSDS service on a deployment that does not use
* xDS answers every query with nothing, which is harmless, and advertises a control-plane surface
* that does not exist, which is not: it is one more endpoint to scan and one more thing whose
* absence of authorization nobody notices.
*/
public record GrpcChannelDiagnosticsPolicy(Set<String> adminNetworks, Set<String> adminRoles) {
/** Refuses a policy with only one gate. */
public GrpcChannelDiagnosticsPolicy {
if (adminNetworks == null || adminRoles == null) {
throw new IllegalArgumentException("a diagnostics policy states both gates");
}
adminNetworks = Set.copyOf(adminNetworks);
adminRoles = Set.copyOf(adminRoles);
if (adminNetworks.isEmpty() || adminRoles.isEmpty()) {
throw new IllegalArgumentException(
"diagnostics need both a network and a role gate; Channelz holds every socket's peer and "
+ "security detail, so either gate alone is the whole surface");
}
}
/** This repository's default. */
public static GrpcChannelDiagnosticsPolicy standard() {
return new GrpcChannelDiagnosticsPolicy(Set.of("admin"), Set.of("ROLE_PLATFORM_ADMIN"));
}
/** Whether this caller may read a snapshot. */
public boolean mayRead(String callerNetwork, Set<String> callerRoles) {
return callerNetwork != null
&& adminNetworks.contains(callerNetwork)
&& callerRoles != null
&& callerRoles.stream().anyMatch(adminRoles::contains);
}
/** Whether the Channelz service should be registered. */
public static boolean registerChannelz(GrpcAdvancedFeatureFlags flags) {
return GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.CHANNEL_DIAGNOSTICS);
}
/** Whether the CSDS service should be registered. Only when xDS is actually in use. */
public static boolean registerCsds(GrpcAdvancedFeatureFlags flags) {
return registerChannelz(flags)
&& GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.XDS);
}
}
@@ -0,0 +1,63 @@
package dev.caskeleton.grpc.advanced.diagnostics;
import java.time.Instant;
import java.util.List;
import java.util.Map;
/**
* What Channelz and CSDS report, after redaction.
*
* <p>Counters and states rather than call contents. The questions a diagnostics endpoint exists to
* answer — which subchannels are connected, how many calls are in flight, which xDS resources the
* control plane last sent — are all answerable from aggregates, and aggregates cannot leak a
* caller's data.
*/
public record GrpcChannelDiagnosticsSnapshot(
String channelProfile,
String connectivityState,
int subchannelCount,
long callsStarted,
long callsSucceeded,
long callsFailed,
List<String> maskedSocketAddresses,
Map<String, String> xdsResourceVersions,
Instant capturedAt) {
/** Refuses a snapshot carrying something it should not. */
public GrpcChannelDiagnosticsSnapshot {
if (channelProfile == null || channelProfile.isBlank()) {
throw new IllegalArgumentException("a diagnostics snapshot names its channel profile");
}
if (connectivityState == null || connectivityState.isBlank()) {
throw new IllegalArgumentException("a diagnostics snapshot carries a connectivity state");
}
if (subchannelCount < 0 || callsStarted < 0 || callsSucceeded < 0 || callsFailed < 0) {
throw new IllegalArgumentException("diagnostics counters must not be negative");
}
if (maskedSocketAddresses == null || xdsResourceVersions == null || capturedAt == null) {
throw new IllegalArgumentException("every snapshot section must be present");
}
maskedSocketAddresses = List.copyOf(maskedSocketAddresses);
xdsResourceVersions = Map.copyOf(xdsResourceVersions);
maskedSocketAddresses.stream()
.filter(address -> !address.equals(GrpcDiagnosticsRedactor.maskAddress(address)))
.findFirst()
.ifPresent(
unmasked -> {
throw new IllegalArgumentException(
"socket address '"
+ unmasked
+ "' is not masked; a diagnostics endpoint that publishes peer addresses "
+ "publishes every tenant's connection");
});
xdsResourceVersions.keySet().stream()
.filter(GrpcDiagnosticsRedactor::forbiddenField)
.findFirst()
.ifPresent(
forbidden -> {
throw new IllegalArgumentException(
"field '" + forbidden + "' may not appear in a diagnostics snapshot");
});
}
}
@@ -0,0 +1,72 @@
package dev.caskeleton.grpc.advanced.diagnostics;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Pattern;
/**
* Removes from a diagnostics snapshot everything that is not a diagnostic.
*
* <p>Channelz is unusually dangerous to expose because it is genuinely useful: it holds every
* socket's local and remote address, the security details of each connection, and per-call state.
* An administrator debugging a routing problem needs the shape of that; nobody needs the peer
* addresses of every tenant's connection, and once the endpoint exists the whole of it is one
* authorization mistake away from being readable.
*
* <p>Addresses are masked rather than dropped. An operator has to be able to tell two subchannels
* apart, and a stable mask does that without publishing where they point.
*/
public final class GrpcDiagnosticsRedactor {
private static final Pattern SENSITIVE_FIELD =
Pattern.compile(
"(?i).*(authorization|bearer|password|secret|token|private[_-]?key|certificate|"
+ "credential|payload|metadata).*");
private static final Pattern IPV4_WITH_PORT =
Pattern.compile("\\b(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})(:\\d{1,5})?\\b");
private GrpcDiagnosticsRedactor() {}
/** Whether a field name may appear in a snapshot at all. */
public static boolean forbiddenField(String fieldName) {
return fieldName != null && SENSITIVE_FIELD.matcher(fieldName).matches();
}
/**
* Masks an address so two of them stay distinguishable without being resolvable.
*
* <p>The last two octets go; the first two stay, because "which subnet" is a real diagnostic
* question and "which host" is not one the diagnostics endpoint should answer.
*/
public static String maskAddress(String address) {
if (address == null || address.isBlank()) {
return "unknown";
}
return IPV4_WITH_PORT
.matcher(address)
.replaceAll(matchResult -> matchResult.group(1) + "." + matchResult.group(2) + ".x.x");
}
/**
* A snapshot map with forbidden fields removed and addresses masked.
*
* @param addressFields which keys hold addresses, since a mask applied to everything would mangle
* version strings and counters
*/
public static Map<String, String> redact(
Map<String, String> raw, java.util.Set<String> addressFields) {
if (raw == null || addressFields == null) {
throw new IllegalArgumentException("redaction needs the map and the address field set");
}
Map<String, String> redacted = new LinkedHashMap<>();
raw.forEach(
(key, value) -> {
if (forbiddenField(key)) {
return;
}
redacted.put(key, addressFields.contains(key) ? maskAddress(value) : value);
});
return Map.copyOf(redacted);
}
}
@@ -0,0 +1,229 @@
package dev.caskeleton.grpc.advanced.diagnostics;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedFeatureFlags;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcChannelDiagnosticsPolicyTest {
private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z");
@Test
@DisplayName("diagnostics need both an admin network and an admin role")
void diagnosticsNeedBothGates() {
GrpcChannelDiagnosticsPolicy policy = GrpcChannelDiagnosticsPolicy.standard();
assertThat(policy.mayRead("admin", Set.of("ROLE_PLATFORM_ADMIN"))).isTrue();
assertThat(policy.mayRead("public", Set.of("ROLE_PLATFORM_ADMIN"))).isFalse();
assertThat(policy.mayRead("admin", Set.of("ROLE_USER"))).isFalse();
assertThatThrownBy(() -> new GrpcChannelDiagnosticsPolicy(Set.of("admin"), Set.of()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("either gate alone is the whole surface");
}
@Test
@DisplayName("CSDS is registered only when xDS is actually enabled")
void csdsFollowsXds() {
GrpcAdvancedFeatureFlags diagnosticsOnly =
GrpcAdvancedFeatureFlags.forDevelopment()
.enable(GrpcAdvancedCapability.CHANNEL_DIAGNOSTICS);
GrpcAdvancedFeatureFlags withXds =
GrpcAdvancedFeatureFlags.forDevelopment()
.enable(GrpcAdvancedCapability.CHANNEL_DIAGNOSTICS)
.enable(GrpcAdvancedCapability.XDS);
assertThat(GrpcChannelDiagnosticsPolicy.registerChannelz(diagnosticsOnly)).isTrue();
assertThat(GrpcChannelDiagnosticsPolicy.registerCsds(diagnosticsOnly)).isFalse();
assertThat(GrpcChannelDiagnosticsPolicy.registerCsds(withXds)).isTrue();
assertThat(
GrpcChannelDiagnosticsPolicy.registerChannelz(
GrpcAdvancedFeatureFlags.forDevelopment()))
.isFalse();
}
@Test
@DisplayName("credential, certificate, metadata and payload fields are dropped outright")
void sensitiveFieldsAreDropped() {
Map<String, String> raw = new LinkedHashMap<>();
raw.put("connectivityState", "READY");
raw.put("authorization", "Bearer abc");
raw.put("peerCertificate", "-----BEGIN CERTIFICATE-----");
raw.put("lastCallMetadata", "x-tenant=acme");
raw.put("requestPayload", "{...}");
Map<String, String> redacted = GrpcDiagnosticsRedactor.redact(raw, Set.of());
assertThat(redacted).containsOnlyKeys("connectivityState");
assertThat(GrpcDiagnosticsRedactor.forbiddenField("privateKeyRef")).isTrue();
assertThat(GrpcDiagnosticsRedactor.forbiddenField("subchannelCount")).isFalse();
}
@Test
@DisplayName("addresses are masked so two subchannels stay distinguishable but unresolvable")
void addressesAreMaskedRatherThanDropped() {
assertThat(GrpcDiagnosticsRedactor.maskAddress("10.4.13.201:9090")).isEqualTo("10.4.x.x");
assertThat(GrpcDiagnosticsRedactor.maskAddress("10.9.13.201")).isEqualTo("10.9.x.x");
assertThat(GrpcDiagnosticsRedactor.maskAddress(null)).isEqualTo("unknown");
Map<String, String> redacted =
GrpcDiagnosticsRedactor.redact(
Map.of("remoteAddress", "10.4.13.201:9090"), Set.of("remoteAddress"));
assertThat(redacted).containsEntry("remoteAddress", "10.4.x.x");
}
@Test
@DisplayName("a snapshot carrying an unmasked address is refused")
void anUnmaskedAddressIsRefused() {
assertThatThrownBy(
() ->
new GrpcChannelDiagnosticsSnapshot(
"documents-read",
"READY",
3,
100L,
98L,
2L,
List.of("10.4.13.201:9090"),
Map.of(),
NOW))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("every tenant's connection");
}
@Test
@DisplayName("a snapshot carries counters and states, and is accepted when masked")
void aMaskedSnapshotIsAccepted() {
GrpcChannelDiagnosticsSnapshot snapshot =
new GrpcChannelDiagnosticsSnapshot(
"documents-read",
"READY",
3,
100L,
98L,
2L,
List.of("10.4.x.x", "10.5.x.x"),
Map.of("documents-cluster", "v7"),
NOW);
assertThat(snapshot.subchannelCount()).isEqualTo(3);
assertThat(snapshot.xdsResourceVersions()).containsEntry("documents-cluster", "v7");
assertThat(snapshot.capturedAt()).isEqualTo(NOW);
}
@Test
@DisplayName("a snapshot carrying a forbidden field name is refused")
void aForbiddenFieldNameIsRefused() {
assertThatThrownBy(
() ->
new GrpcChannelDiagnosticsSnapshot(
"documents-read",
"READY",
1,
1L,
1L,
0L,
List.of(),
Map.of("controlPlaneToken", "abc"),
NOW))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("may not appear in a diagnostics snapshot");
}
@Test
@DisplayName("the committed CSDS fixture is redacted into something publishable")
void theCommittedCsdsFixtureIsRedacted() {
String raw = resource("xds/control-plane-snapshot.json");
// The fixture deliberately contains the three shapes a snapshot must never carry, so the
// redactor is exercised against data shaped like the real thing.
assertThat(raw)
.contains("controlPlaneToken")
.contains("peerCertificate")
.contains("10.4.13.201:9090");
Map<String, String> fields = new LinkedHashMap<>();
fields.put("connectivityState", "READY");
fields.put("subchannelCount", "3");
fields.put("controlPlaneToken", "must-not-appear-in-a-snapshot");
fields.put("peerCertificate", "-----BEGIN CERTIFICATE-----");
fields.put("lastCallMetadata", "x-tenant=acme");
fields.put("remoteAddress", "10.4.13.201:9090");
Map<String, String> redacted = GrpcDiagnosticsRedactor.redact(fields, Set.of("remoteAddress"));
assertThat(redacted).containsOnlyKeys("connectivityState", "subchannelCount", "remoteAddress");
assertThat(redacted.get("remoteAddress")).isEqualTo("10.4.x.x");
assertThat(String.join("|", redacted.values())).doesNotContain("must-not-appear-in-a-snapshot");
}
@Test
@DisplayName("a snapshot built from the fixture's resource versions is accepted")
void aSnapshotFromTheFixtureIsAccepted() {
assertThat(resource("xds/control-plane-snapshot.json")).contains("documents-cluster");
GrpcChannelDiagnosticsSnapshot snapshot =
new GrpcChannelDiagnosticsSnapshot(
"documents-read",
"READY",
3,
100L,
98L,
2L,
List.of("10.4.x.x", "10.4.x.x", "10.5.x.x"),
Map.of("documents-cluster", "v7", "documents-route", "v7", "documents-listener", "v6"),
NOW);
assertThat(snapshot.xdsResourceVersions()).hasSize(3);
}
@Test
@DisplayName("each capability names the real infrastructure its evidence depends on")
void capabilitiesNameTheirInfrastructure() {
assertThat(GrpcAdvancedInfrastructureTestkit.requiredFor(GrpcAdvancedCapability.GRPC_WEB))
.containsExactly(GrpcAdvancedInfrastructureTestkit.Infrastructure.GRPC_WEB_PROXY);
assertThat(GrpcAdvancedInfrastructureTestkit.requiredFor(GrpcAdvancedCapability.XDS))
.containsExactly(GrpcAdvancedInfrastructureTestkit.Infrastructure.XDS_CONTROL_PLANE);
assertThat(GrpcAdvancedInfrastructureTestkit.requiredFor(GrpcAdvancedCapability.HEDGING))
.isEmpty();
}
@Test
@DisplayName("a suite without its infrastructure is reported as establishing nothing")
void missingInfrastructureIsReported() {
assertThat(
GrpcAdvancedInfrastructureTestkit.missingInfrastructure(
GrpcAdvancedCapability.XDS, Set.of()))
.singleElement()
.satisfies(gap -> assertThat(gap).contains("establishes nothing"));
assertThat(
GrpcAdvancedInfrastructureTestkit.missingInfrastructure(
GrpcAdvancedCapability.XDS,
Set.of(GrpcAdvancedInfrastructureTestkit.Infrastructure.XDS_CONTROL_PLANE)))
.isEmpty();
assertThat(
GrpcAdvancedInfrastructureTestkit.missingInfrastructure(
GrpcAdvancedCapability.HEDGING, Set.of()))
.isEmpty();
}
private static String resource(String path) {
try (java.io.InputStream stream =
GrpcChannelDiagnosticsPolicyTest.class.getClassLoader().getResourceAsStream(path)) {
if (stream == null) {
throw new IllegalStateException("missing test resource " + path);
}
return new String(stream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
} catch (java.io.IOException e) {
throw new java.io.UncheckedIOException(e);
}
}
}
@@ -0,0 +1,24 @@
{
"_comment": [
"What CSDS reports for one client, as the diagnostics layer receives it before redaction.",
"The fixture deliberately contains three things a snapshot must not publish - a control-plane",
"token, a peer certificate and a raw socket address - so the redactor is tested against data",
"shaped like the real thing rather than against a string somebody invented for the assertion."
],
"version_info": "v7",
"resources": {
"documents-cluster": "v7",
"documents-route": "v7",
"documents-listener": "v6"
},
"connectivityState": "READY",
"subchannelCount": 3,
"sockets": [
{ "remoteAddress": "10.4.13.201:9090", "state": "READY" },
{ "remoteAddress": "10.4.19.87:9090", "state": "READY" },
{ "remoteAddress": "10.5.2.44:9090", "state": "TRANSIENT_FAILURE" }
],
"controlPlaneToken": "must-not-appear-in-a-snapshot",
"peerCertificate": "-----BEGIN CERTIFICATE----- must-not-appear-in-a-snapshot",
"lastCallMetadata": "x-tenant=acme"
}
@@ -0,0 +1,10 @@
apply plugin: 'java-library'
// Protobuf Edition lanes. Edition 2024 is an opt-in Advanced lane that must produce cross-consumer
// compile evidence before anything public moves onto it; Edition 2026 is a watch lane that records
// release/toolchain status and is refused as a Stable contract source.
dependencies {
api project(':grpc:grpc-core-api')
api project(':grpc:grpc-proto-contract')
api project(':grpc-advanced:grpc-advanced-bootstrap')
}
@@ -0,0 +1,84 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.21.0=spotbugs
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.6=spotbugs
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.5=spotbugs
org.apache.logging.log4j:log4j-core:2.25.5=spotbugs
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.javassist:javassist:3.28.0-GA=checkstyle
org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath
org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath
org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=mockitoAgent
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.reflections:reflections:0.10.2=checkstyle
org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j
org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j
org.slf4j:slf4j-simple:2.0.18=checkstyle
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
empty=compileClasspath,runtimeClasspath
@@ -0,0 +1,61 @@
package dev.caskeleton.grpc.advanced.edition;
import java.util.ArrayList;
import java.util.List;
/**
* Decides whether Edition 2024 may be adopted, and keeps its failures out of the Stable release.
*
* <p>The separation the Stable plan asks for, in one place: an Edition 2024 failure blocks the
* edition's own promotion and does not block a proto3 release. Without that split, an opt-in lane
* that nobody depends on can hold up every release, and the first response to that is to stop
* running the lane.
*/
public final class GrpcEdition2024Gate {
private GrpcEdition2024Gate() {}
/**
* Every reason Edition 2024 may not be promoted.
*
* @return an empty list when the edition is ready to adopt
*/
public static List<String> promotionBlockers(
GrpcEditionCompatibilityReport report,
boolean consumerMigrationPlanned,
boolean promotionAdr) {
if (report == null) {
throw new IllegalArgumentException("a compatibility report is required");
}
List<String> blockers = new ArrayList<>(report.incompatibilities());
if (!consumerMigrationPlanned) {
blockers.add(
"no consumer migration is planned; moving a public service to an edition breaks whichever "
+ "consumer's generator treats its features differently");
}
if (!promotionAdr) {
blockers.add(
"no promotion ADR records the decision to move onto Edition "
+ GrpcEdition2024Policy.EDITION);
}
return List.copyOf(blockers);
}
/**
* Whether an Edition 2024 failure blocks a proto3 Stable release.
*
* <p>Always false. Stated as a method so the property is tested rather than described.
*/
public static boolean blocksStableRelease() {
return false;
}
/**
* Whether an Edition 2024 failure blocks the edition's own promotion.
*
* <p>Always true, for the same reason.
*/
public static boolean blocksEditionPromotion() {
return true;
}
}
@@ -0,0 +1,47 @@
package dev.caskeleton.grpc.advanced.edition;
import java.util.Set;
/**
* Where Edition 2024 may be used, and where it may not.
*
* <p>Module-level opt-in, and never for a public service without a promotion decision. The reason
* is that an edition change is invisible to the schema's owner and consequential for its consumers:
* the wire bytes are usually identical, so nothing fails locally, and the breakage appears in
* whichever consumer's generator handles the edition's features differently.
*/
public record GrpcEdition2024Policy(
Set<String> optedInModules, Set<String> publicServices, boolean promotionApproved) {
/** The edition this policy governs. */
public static final String EDITION = "2024";
/** Copies both sets and refuses an approval nobody recorded. */
public GrpcEdition2024Policy {
if (optedInModules == null || publicServices == null) {
throw new IllegalArgumentException("an edition policy states both sets");
}
optedInModules = Set.copyOf(optedInModules);
publicServices = Set.copyOf(publicServices);
}
/** The default: nothing opted in, no promotion. */
public static GrpcEdition2024Policy notAdopted() {
return new GrpcEdition2024Policy(Set.of(), Set.of(), false);
}
/** Whether {@code moduleId} may use Edition 2024 schema sources. */
public boolean allowedIn(String moduleId) {
return optedInModules.contains(moduleId);
}
/**
* Whether {@code serviceName} may move onto Edition 2024.
*
* <p>False for any public service until a promotion is approved, regardless of module opt-in: the
* opt-in is a build decision and the promotion is a consumer-migration decision.
*/
public boolean serviceMayMove(String serviceName) {
return !publicServices.contains(serviceName) || promotionApproved;
}
}
@@ -0,0 +1,48 @@
package dev.caskeleton.grpc.advanced.edition;
/**
* Refuses Edition 2026 as a schema source, whatever a report says.
*
* <p>The guard is deliberately not conditional on the watch report. A watch lane's purpose is to
* record what is true, and letting the same record also authorise use means the moment somebody
* marks four fields SUPPORTED, a schema can move onto an edition with no promotion decision, no
* consumer migration and no ADR. Turning the watch into a lane that can be used is a code change
* here, and that is the point.
*/
public final class GrpcEdition2026Guard {
private GrpcEdition2026Guard() {}
/** Whether Edition 2026 may be used as a Stable contract source. Always false. */
public static boolean allowedAsStableSource() {
return false;
}
/**
* Fails when Edition 2026 is used as a schema source.
*
* @throws IllegalStateException naming what is still outstanding, so the refusal is actionable
*/
public static void requireNotUsedAsSource(GrpcEdition2026WatchReport report) {
if (report == null) {
throw new IllegalArgumentException("a watch report is required");
}
throw new IllegalStateException(
"Edition "
+ GrpcEdition2026WatchReport.EDITION
+ " is a watch lane, not a schema source"
+ (report.outstanding().isEmpty()
? "; every toolchain gate is satisfied, so the next step is a promotion decision "
+ "rather than a schema change"
: "; outstanding: " + report.outstanding()));
}
/**
* Whether an Edition 2026 CI failure blocks the Stable build.
*
* <p>False. A watch lane that can break the build is a watch lane somebody deletes.
*/
public static boolean blocksStableBuild() {
return false;
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.grpc.advanced.edition;
/**
* The four independent things that have to be true before an edition is usable, tracked separately.
*
* <p>Separately, because they land at different times and in different projects. An edition can be
* released by the specification while {@code protoc} does not emit it, or emitted while Buf cannot
* lint it, or lintable while the Java runtime does not implement its features. A single "supported
* yes/no" flag collapses four different waiting states into one, and the answer to "what are we
* waiting for" is then nobody's.
*/
public enum GrpcEdition2026Status {
/** Nothing is known yet. */
UNKNOWN,
/** Announced or drafted, not released. */
DRAFT,
/** Released by the specification. */
RELEASED,
/** Supported by the toolchain component in question. */
SUPPORTED,
/** Explicitly not supported, and not expected to be. */
UNSUPPORTED;
/** Whether this status permits use. */
public boolean usable() {
return this == SUPPORTED;
}
}
@@ -0,0 +1,70 @@
package dev.caskeleton.grpc.advanced.edition;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
/**
* What is currently true about Edition 2026 across the four things that gate it.
*
* <p>Recorded with a date. A watch report without one is indistinguishable from a stale note, and
* the whole purpose of a watch lane is to be re-read later by somebody deciding whether the wait is
* over.
*/
public record GrpcEdition2026WatchReport(
GrpcEdition2026Status specificationStatus,
GrpcEdition2026Status protocStatus,
GrpcEdition2026Status bufStatus,
GrpcEdition2026Status javaRuntimeStatus,
Instant observedAt) {
/** The edition this report tracks. */
public static final String EDITION = "2026";
/** Requires every status and a date. */
public GrpcEdition2026WatchReport {
if (specificationStatus == null
|| protocStatus == null
|| bufStatus == null
|| javaRuntimeStatus == null) {
throw new IllegalArgumentException("a watch report records all four statuses");
}
if (observedAt == null) {
throw new IllegalArgumentException(
"a watch report is dated; without a date it cannot be told from a stale note");
}
}
/** Nothing known yet, as of {@code observedAt}. */
public static GrpcEdition2026WatchReport nothingKnown(Instant observedAt) {
return new GrpcEdition2026WatchReport(
GrpcEdition2026Status.UNKNOWN,
GrpcEdition2026Status.UNKNOWN,
GrpcEdition2026Status.UNKNOWN,
GrpcEdition2026Status.UNKNOWN,
observedAt);
}
/** What is still missing, named. */
public List<String> outstanding() {
List<String> waiting = new ArrayList<>();
if (!specificationStatus.usable()) {
waiting.add("specification is " + specificationStatus);
}
if (!protocStatus.usable()) {
waiting.add("protoc support is " + protocStatus);
}
if (!bufStatus.usable()) {
waiting.add("Buf support is " + bufStatus);
}
if (!javaRuntimeStatus.usable()) {
waiting.add("Java runtime support is " + javaRuntimeStatus);
}
return List.copyOf(waiting);
}
/** Whether every gate is satisfied. */
public boolean readyToEvaluate() {
return outstanding().isEmpty();
}
}
@@ -0,0 +1,72 @@
package dev.caskeleton.grpc.advanced.edition;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* How an Edition schema compares with its proto3 twin, per consumer toolchain.
*
* <p>Three comparisons rather than one. Wire compatibility says stored and in-flight messages keep
* decoding; JSON compatibility says a REST transcoder or a browser client keeps working; source
* compatibility says the generated code still compiles. An edition migration can preserve the first
* two and break the third for a language whose generator handles the edition's features differently
* — which is exactly the failure this lane exists to find before a public service moves.
*/
public record GrpcEditionCompatibilityReport(
String editionName,
boolean wireCompatible,
boolean jsonCompatible,
Map<String, Boolean> sourceCompatibleByToolchain) {
/** Requires an edition and at least one toolchain result. */
public GrpcEditionCompatibilityReport {
if (editionName == null || editionName.isBlank()) {
throw new IllegalArgumentException("a compatibility report names its edition");
}
if (sourceCompatibleByToolchain == null || sourceCompatibleByToolchain.isEmpty()) {
throw new IllegalArgumentException(
"a report with no toolchain result compares nothing; Java alone is not cross-language "
+ "evidence");
}
sourceCompatibleByToolchain = Map.copyOf(sourceCompatibleByToolchain);
}
/** The toolchains whose generated code stopped compiling. */
public Set<String> brokenToolchains() {
return sourceCompatibleByToolchain.entrySet().stream()
.filter(entry -> !entry.getValue())
.map(Map.Entry::getKey)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
}
/** Every incompatibility, described. */
public List<String> incompatibilities() {
List<String> problems = new ArrayList<>();
if (!wireCompatible) {
problems.add(
editionName + " is not wire-compatible with proto3; stored messages would break");
}
if (!jsonCompatible) {
problems.add(
editionName
+ " is not JSON-compatible with proto3; transcoded and browser clients would break");
}
brokenToolchains().stream()
.sorted()
.forEach(
toolchain ->
problems.add(
editionName
+ " generated source does not compile for toolchain '"
+ toolchain
+ "'"));
return List.copyOf(problems);
}
/** Whether every comparison passed. */
public boolean fullyCompatible() {
return incompatibilities().isEmpty();
}
}
@@ -0,0 +1,28 @@
edition = "2024";
package hyeonworks.grpc.edition.v1;
option java_multiple_files = true;
option java_package = "hyeonworks.grpc.edition.v1.generated";
// The Edition 2024 comparison fixture.
//
// It exists to be compiled beside its proto3 twin and compared: same fields, same numbers, same JSON
// names, with presence expressed by the edition's features rather than by `optional`. The lane's
// question is whether the two produce the same wire bytes and the same JSON, and answering it needs
// both files to exist.
//
// Not a Stable contract source. No public service moves onto an edition until the lane has produced
// cross-consumer compile evidence and a promotion ADR — see GrpcEdition2024Gate.
message DocumentSummary {
string id = 1;
string title = 2 [features.field_presence = EXPLICIT];
int64 revision = 3;
repeated string labels = 4;
}
enum DocumentState {
DOCUMENT_STATE_UNSPECIFIED = 0;
DOCUMENT_STATE_DRAFT = 1;
DOCUMENT_STATE_PUBLISHED = 2;
}
@@ -0,0 +1,118 @@
package dev.caskeleton.grpc.advanced.edition;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcEdition2024GateTest {
private static GrpcEditionCompatibilityReport report(
boolean wire, boolean json, Map<String, Boolean> toolchains) {
return new GrpcEditionCompatibilityReport("2024", wire, json, toolchains);
}
@Test
@DisplayName("Edition 2024 is module-level opt-in, and nothing is opted in by default")
void editionIsOptInPerModule() {
GrpcEdition2024Policy policy = GrpcEdition2024Policy.notAdopted();
assertThat(policy.allowedIn("grpc-proto-contract")).isFalse();
assertThat(
new GrpcEdition2024Policy(Set.of("grpc-advanced-edition"), Set.of(), false)
.allowedIn("grpc-advanced-edition"))
.isTrue();
}
@Test
@DisplayName("a public service may not move onto the edition without a promotion")
void publicServicesNeedAPromotion() {
GrpcEdition2024Policy withoutPromotion =
new GrpcEdition2024Policy(
Set.of("grpc-advanced-edition"),
Set.of("hyeonworks.document.v1.DocumentService"),
false);
GrpcEdition2024Policy withPromotion =
new GrpcEdition2024Policy(
Set.of("grpc-advanced-edition"),
Set.of("hyeonworks.document.v1.DocumentService"),
true);
assertThat(withoutPromotion.serviceMayMove("hyeonworks.document.v1.DocumentService")).isFalse();
assertThat(withPromotion.serviceMayMove("hyeonworks.document.v1.DocumentService")).isTrue();
assertThat(withoutPromotion.serviceMayMove("hyeonworks.internal.v1.ScratchService")).isTrue();
}
@Test
@DisplayName("Java compiling alone is not cross-language evidence")
void javaAloneIsNotEvidence() {
GrpcEditionCompatibilityReport javaOnlyBroken =
report(true, true, Map.of("java", true, "go", false, "python", true));
assertThat(javaOnlyBroken.fullyCompatible()).isFalse();
assertThat(javaOnlyBroken.brokenToolchains()).containsExactly("go");
assertThatThrownBy(() -> report(true, true, Map.of()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Java alone is not cross-language");
}
@Test
@DisplayName("wire, JSON and source compatibility are compared separately")
void threeComparisonsAreSeparate() {
assertThat(report(false, true, Map.of("java", true)).incompatibilities())
.singleElement()
.satisfies(problem -> assertThat(problem).contains("stored messages"));
assertThat(report(true, false, Map.of("java", true)).incompatibilities())
.singleElement()
.satisfies(problem -> assertThat(problem).contains("browser clients"));
}
@Test
@DisplayName("promotion needs compatibility, a consumer migration and an ADR")
void promotionNeedsAllThree() {
GrpcEditionCompatibilityReport clean = report(true, true, Map.of("java", true, "go", true));
assertThat(GrpcEdition2024Gate.promotionBlockers(clean, true, true)).isEmpty();
assertThat(GrpcEdition2024Gate.promotionBlockers(clean, false, true))
.anySatisfy(blocker -> assertThat(blocker).contains("consumer migration"));
assertThat(GrpcEdition2024Gate.promotionBlockers(clean, true, false))
.anySatisfy(blocker -> assertThat(blocker).contains("promotion ADR"));
}
@Test
@DisplayName("an Edition failure blocks the edition's promotion and not a proto3 release")
void editionFailuresAreIsolatedFromStable() {
assertThat(GrpcEdition2024Gate.blocksStableRelease()).isFalse();
assertThat(GrpcEdition2024Gate.blocksEditionPromotion()).isTrue();
}
@Test
@DisplayName("the Edition 2024 comparison fixture ships beside its proto3 twin")
void theComparisonFixtureShips() {
String source = resource("proto/edition2024/compatibility.proto");
assertThat(source)
.startsWith("edition = \"2024\";")
.contains("features.field_presence = EXPLICIT")
.contains("DOCUMENT_STATE_UNSPECIFIED = 0");
}
private static String resource(String path) {
try (InputStream stream =
GrpcEdition2024GateTest.class.getClassLoader().getResourceAsStream(path)) {
if (stream == null) {
throw new IllegalStateException("missing resource " + path);
}
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
@@ -0,0 +1,93 @@
package dev.caskeleton.grpc.advanced.edition;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Instant;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcEdition2026GuardTest {
private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z");
@Test
@DisplayName("the watch tracks four gates separately, because they land at different times")
void theWatchTracksFourGates() {
GrpcEdition2026WatchReport partial =
new GrpcEdition2026WatchReport(
GrpcEdition2026Status.RELEASED,
GrpcEdition2026Status.DRAFT,
GrpcEdition2026Status.UNKNOWN,
GrpcEdition2026Status.UNSUPPORTED,
NOW);
assertThat(partial.outstanding())
.hasSize(4)
.anySatisfy(item -> assertThat(item).contains("protoc support is DRAFT"))
.anySatisfy(item -> assertThat(item).contains("Buf support is UNKNOWN"))
.anySatisfy(item -> assertThat(item).contains("Java runtime support is UNSUPPORTED"));
assertThat(partial.readyToEvaluate()).isFalse();
assertThat(GrpcEdition2026WatchReport.nothingKnown(NOW).outstanding()).hasSize(4);
}
@Test
@DisplayName("a watch report is dated, so it cannot be told from a stale note")
void aWatchReportIsDated() {
assertThatThrownBy(
() ->
new GrpcEdition2026WatchReport(
GrpcEdition2026Status.SUPPORTED,
GrpcEdition2026Status.SUPPORTED,
GrpcEdition2026Status.SUPPORTED,
GrpcEdition2026Status.SUPPORTED,
null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("stale note");
}
@Test
@DisplayName("only SUPPORTED counts as usable")
void onlySupportedIsUsable() {
assertThat(GrpcEdition2026Status.SUPPORTED.usable()).isTrue();
assertThat(GrpcEdition2026Status.RELEASED.usable()).isFalse();
assertThat(GrpcEdition2026Status.DRAFT.usable()).isFalse();
assertThat(GrpcEdition2026Status.UNKNOWN.usable()).isFalse();
assertThat(GrpcEdition2026Status.UNSUPPORTED.usable()).isFalse();
}
@Test
@DisplayName("Edition 2026 is refused as a schema source even when every gate is satisfied")
void theGuardIsNotConditionalOnTheReport() {
GrpcEdition2026WatchReport allSupported =
new GrpcEdition2026WatchReport(
GrpcEdition2026Status.SUPPORTED,
GrpcEdition2026Status.SUPPORTED,
GrpcEdition2026Status.SUPPORTED,
GrpcEdition2026Status.SUPPORTED,
NOW);
assertThat(allSupported.readyToEvaluate()).isTrue();
assertThat(GrpcEdition2026Guard.allowedAsStableSource()).isFalse();
assertThatThrownBy(() -> GrpcEdition2026Guard.requireNotUsedAsSource(allSupported))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("promotion decision rather than a schema change");
}
@Test
@DisplayName("a refusal names what is still outstanding, so it is actionable")
void aRefusalNamesWhatIsOutstanding() {
assertThatThrownBy(
() ->
GrpcEdition2026Guard.requireNotUsedAsSource(
GrpcEdition2026WatchReport.nothingKnown(NOW)))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("outstanding:");
}
@Test
@DisplayName("a watch lane failure does not break the Stable build")
void theWatchLaneDoesNotBlockTheStableBuild() {
assertThat(GrpcEdition2026Guard.blocksStableBuild()).isFalse();
}
}
@@ -0,0 +1,11 @@
apply plugin: 'java-library'
// Resilience and discovery capabilities that Stable refuses: read-only unary hedging, the custom
// name resolver SPI, the custom load balancer SPI, and the proxyless xDS experimental profile.
dependencies {
api project(':grpc:grpc-core-api')
api project(':grpc:grpc-policy')
api project(':grpc:grpc-client')
api project(':grpc:grpc-discovery')
api project(':grpc-advanced:grpc-advanced-bootstrap')
}
@@ -0,0 +1,91 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.28.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.guava:guava:33.2.1-android=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.21.0=spotbugs
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
io.grpc:grpc-api:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.6=spotbugs
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.5=spotbugs
org.apache.logging.log4j:log4j-core:2.25.5=spotbugs
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath
org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.javassist:javassist:3.28.0-GA=checkstyle
org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath
org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath
org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=mockitoAgent
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.reflections:reflections:0.10.2=checkstyle
org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j
org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j
org.slf4j:slf4j-simple:2.0.18=checkstyle
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
empty=
@@ -0,0 +1,75 @@
package dev.caskeleton.grpc.advanced.discovery;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
/**
* A custom name resolver, with the safety rules applied on the way in.
*
* <p>Everything an update can do wrong is checked here rather than by the listener, because the
* listener is the channel and the channel will believe whatever it is told. Stale revisions and
* empty endpoint sets are dropped rather than propagated, and once closed the resolver accepts
* nothing at all.
*/
public final class GrpcCustomResolver implements AutoCloseable {
private final String authority;
private final Consumer<GrpcResolverUpdate> listener;
private final AtomicReference<GrpcEndpointSnapshot> applied = new AtomicReference<>();
private final AtomicBoolean closed = new AtomicBoolean();
/** Binds a resolver to the authority it resolves and the listener it feeds. */
public GrpcCustomResolver(String authority, Consumer<GrpcResolverUpdate> listener) {
if (authority == null || authority.isBlank()) {
throw new IllegalArgumentException("a resolver names the authority it resolves");
}
if (listener == null) {
throw new IllegalArgumentException("a resolver needs a listener to deliver updates to");
}
this.authority = authority;
this.listener = listener;
}
/**
* Offers an update.
*
* @return the violations that stopped it, empty when it was applied
*/
public List<String> offer(GrpcResolverUpdate update) {
if (closed.get()) {
return List.of(
"the resolver is closed; an update after close resurrects routing for a channel nobody "
+ "is using");
}
List<String> violations = GrpcResolverSafetyPolicy.violations(update, applied.get());
if (!violations.isEmpty()) {
return violations;
}
applied.set(update.snapshot());
listener.accept(update);
return List.of();
}
/** The snapshot currently in force. */
public Optional<GrpcEndpointSnapshot> currentSnapshot() {
return Optional.ofNullable(applied.get());
}
/** The authority this resolver answers for. */
public String authority() {
return authority;
}
/** Whether the resolver has been closed. */
public boolean closed() {
return closed.get();
}
@Override
public void close() {
closed.set(true);
}
}
@@ -0,0 +1,36 @@
package dev.caskeleton.grpc.advanced.discovery;
/**
* One endpoint a picker may choose, described only by what routing is allowed to consider.
*
* <p>The field list is the allowlist. Health, connectivity, weight and ejection are properties of
* the endpoint; a tenant id or a request field is a property of the caller, and routing on those
* turns a load balancer into a router with an authorization decision buried in it.
*/
public record GrpcEndpointCandidate(
String address, boolean healthy, boolean connected, int weight, boolean ejected) {
/** Requires an address and a sane weight. */
public GrpcEndpointCandidate {
if (address == null || address.isBlank()) {
throw new IllegalArgumentException("an endpoint candidate needs an address");
}
if (weight < 0) {
throw new IllegalArgumentException("a weight must not be negative");
}
if (weight > 1000) {
throw new IllegalArgumentException(
"a weight above 1000 is a scale nobody can reason about against the others");
}
}
/** A healthy, connected endpoint at the default weight. */
public static GrpcEndpointCandidate ready(String address) {
return new GrpcEndpointCandidate(address, true, true, 100, false);
}
/** Whether this endpoint may receive a request. */
public boolean selectable() {
return healthy && connected && !ejected && weight > 0;
}
}
@@ -0,0 +1,46 @@
package dev.caskeleton.grpc.advanced.discovery;
import java.util.List;
import java.util.Set;
/**
* One resolver update: a monotonic revision and the complete endpoint set at that revision.
*
* <p>Complete, not a delta. A delta protocol needs both sides to agree on what they last saw, and a
* resolver that reconnects to its discovery source has no way to establish that; a full set at each
* revision makes a missed update harmless.
*
* <p>The revision is what makes a late update safe to drop. Without it, an update that arrives out
* of order replaces newer endpoints with older ones, and the channel routes to instances that were
* removed.
*/
public record GrpcEndpointSnapshot(long revision, String authority, List<String> endpoints) {
/** Requires a positive revision, an authority and a non-empty endpoint set. */
public GrpcEndpointSnapshot {
if (revision < 1) {
throw new IllegalArgumentException("resolver revisions are 1-based; got " + revision);
}
if (authority == null || authority.isBlank()) {
throw new IllegalArgumentException("a snapshot names the authority it resolves");
}
if (endpoints == null || endpoints.isEmpty()) {
throw new IllegalArgumentException(
"an empty endpoint set is refused; a resolver that reports zero endpoints during its own "
+ "outage would take the channel down with it");
}
if (endpoints.stream().anyMatch(endpoint -> endpoint == null || endpoint.isBlank())) {
throw new IllegalArgumentException("every endpoint must be a non-blank address");
}
endpoints = List.copyOf(endpoints);
if (Set.copyOf(endpoints).size() != endpoints.size()) {
throw new IllegalArgumentException(
"duplicate endpoints skew a round-robin picker towards whichever address is repeated");
}
}
/** Whether this snapshot supersedes {@code other}. */
public boolean supersedes(GrpcEndpointSnapshot other) {
return other == null || (authority.equals(other.authority()) && revision > other.revision());
}
}
@@ -0,0 +1,58 @@
package dev.caskeleton.grpc.advanced.discovery;
import java.util.Optional;
/**
* Which endpoint a picker chose, or why it chose none.
*
* <p>{@link Verdict#DETERMINISTIC_FALLBACK} is separate from {@link Verdict#NO_ENDPOINT_AVAILABLE}
* because they say different things about the picker. The first means custom logic failed and the
* platform took over, which is a defect to fix; the second means there was genuinely nowhere to
* send the request, which is an outage. A picker that reports both the same way hides its own bugs
* inside the backend's.
*/
public record GrpcLoadBalancerDecision(
Verdict verdict, Optional<GrpcEndpointCandidate> chosen, String reason) {
/** What the picker decided. */
public enum Verdict {
/** The picker chose an endpoint. */
PICKED,
/** The picker failed; the platform chose deterministically instead. */
DETERMINISTIC_FALLBACK,
/** Nothing was selectable. */
NO_ENDPOINT_AVAILABLE
}
/** Requires an endpoint on the two verdicts that have one. */
public GrpcLoadBalancerDecision {
if (verdict == null || chosen == null) {
throw new IllegalArgumentException("a picker decision has a verdict and the Optional");
}
if (reason == null || reason.isBlank()) {
throw new IllegalArgumentException("a picker decision explains itself");
}
if (verdict == Verdict.NO_ENDPOINT_AVAILABLE && chosen.isPresent()) {
throw new IllegalArgumentException("a decision with no endpoint available carries none");
}
if (verdict != Verdict.NO_ENDPOINT_AVAILABLE && chosen.isEmpty()) {
throw new IllegalArgumentException("a decision that picked carries the endpoint it picked");
}
}
/** The picker's own choice. */
public static GrpcLoadBalancerDecision picked(GrpcEndpointCandidate endpoint, String reason) {
return new GrpcLoadBalancerDecision(Verdict.PICKED, Optional.of(endpoint), reason);
}
/** The platform's fallback after a picker failure. */
public static GrpcLoadBalancerDecision fallback(GrpcEndpointCandidate endpoint, String reason) {
return new GrpcLoadBalancerDecision(
Verdict.DETERMINISTIC_FALLBACK, Optional.of(endpoint), reason);
}
/** Nothing was selectable. */
public static GrpcLoadBalancerDecision none(String reason) {
return new GrpcLoadBalancerDecision(Verdict.NO_ENDPOINT_AVAILABLE, Optional.empty(), reason);
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.grpc.advanced.discovery;
import java.util.List;
/**
* Chooses one endpoint from the candidates the resolver supplied.
*
* <p>The signature is the safety property. A picker receives a list of candidates and nothing else:
* no request, no metadata, no caller. It therefore cannot route on a tenant, and the rule "business
* data is not a routing input" is enforced by there being no business data to reach.
*/
@FunctionalInterface
public interface GrpcLoadBalancerPicker {
/**
* Picks an endpoint.
*
* @param candidates the selectable endpoints, never empty
* @return the chosen endpoint, which must be one of {@code candidates}
*/
GrpcEndpointCandidate pick(List<GrpcEndpointCandidate> candidates);
/** Round-robin, as the deterministic default and the fallback. */
static GrpcLoadBalancerPicker roundRobin() {
java.util.concurrent.atomic.AtomicInteger cursor =
new java.util.concurrent.atomic.AtomicInteger();
return candidates -> candidates.get(Math.floorMod(cursor.getAndIncrement(), candidates.size()));
}
}
@@ -0,0 +1,76 @@
package dev.caskeleton.grpc.advanced.discovery;
import java.util.List;
/**
* Runs a custom picker and refuses to let it do something the resolver did not authorise.
*
* <p>Two rules. A picker may only return an endpoint the resolver supplied, because a picker that
* can invent an address can send a request anywhere; and a picker that throws produces a
* deterministic fallback rather than a failed call, because a picker bug should degrade the
* balancing rather than the availability.
*
* <p>Weighted and load-aware pickers are not refused here, but the plan requires evidence before
* they ship: {@link #requiresLoadEvidence} names which shapes those are.
*/
public final class GrpcLoadBalancerSafetyPolicy {
private final GrpcLoadBalancerPicker picker;
private final GrpcLoadBalancerPicker fallback;
/** Wraps a custom picker with the platform's deterministic fallback. */
public GrpcLoadBalancerSafetyPolicy(GrpcLoadBalancerPicker picker) {
this(picker, GrpcLoadBalancerPicker.roundRobin());
}
/** Wraps a custom picker with an explicit fallback. */
public GrpcLoadBalancerSafetyPolicy(
GrpcLoadBalancerPicker picker, GrpcLoadBalancerPicker fallback) {
if (picker == null || fallback == null) {
throw new IllegalArgumentException("a safety policy needs a picker and a fallback");
}
this.picker = picker;
this.fallback = fallback;
}
/** Picks an endpoint, or explains why none was chosen. */
public GrpcLoadBalancerDecision pick(List<GrpcEndpointCandidate> candidates) {
if (candidates == null) {
throw new IllegalArgumentException("a candidate list is required");
}
List<GrpcEndpointCandidate> selectable =
candidates.stream().filter(GrpcEndpointCandidate::selectable).toList();
if (selectable.isEmpty()) {
return GrpcLoadBalancerDecision.none(
"no endpoint is healthy, connected, un-ejected and non-zero weight");
}
GrpcEndpointCandidate chosen;
try {
chosen = picker.pick(selectable);
} catch (RuntimeException pickerFailure) {
return GrpcLoadBalancerDecision.fallback(
fallback.pick(selectable),
"the custom picker threw ("
+ pickerFailure.getClass().getSimpleName()
+ "); falling back deterministically rather than failing the call");
}
if (chosen == null || !selectable.contains(chosen)) {
return GrpcLoadBalancerDecision.fallback(
fallback.pick(selectable),
"the custom picker returned an endpoint the resolver did not supply; a picker that can "
+ "invent an address can send a request anywhere");
}
return GrpcLoadBalancerDecision.picked(chosen, "chosen by the custom picker");
}
/**
* Whether a picker of this shape needs performance, fairness and failover evidence before it
* ships.
*
* @param loadAware whether the picker uses reported load or latency
* @param weighted whether the picker uses endpoint weights
*/
public static boolean requiresLoadEvidence(boolean loadAware, boolean weighted) {
return loadAware || weighted;
}
}
@@ -0,0 +1,92 @@
package dev.caskeleton.grpc.advanced.discovery;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.regex.Pattern;
/**
* What a custom resolver is allowed to say, and what it may never carry.
*
* <p>A resolver runs inside the channel and speaks to something outside the deployment. Everything
* it can put into an update is therefore attacker-influenced in the worst case and
* operator-influenced in the ordinary one, so the safety rules are about limiting what an update
* can change: addresses and a validated service config, never a credential and never business
* metadata.
*/
public final class GrpcResolverSafetyPolicy {
private static final Pattern AUTHORITY =
Pattern.compile("[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\\d{1,5})?");
private static final Pattern CREDENTIAL_SHAPED =
Pattern.compile("(?i).*(authorization|bearer|password|secret|token|api[_-]?key).*");
private GrpcResolverSafetyPolicy() {}
/**
* Every problem with an update, given what was last accepted.
*
* @param lastAccepted the newest snapshot already applied, or null when none has been
* @return an empty list when the update is safe to apply
*/
public static List<String> violations(
GrpcResolverUpdate update, GrpcEndpointSnapshot lastAccepted) {
if (update == null) {
throw new IllegalArgumentException("an update is required");
}
List<String> violations = new ArrayList<>();
GrpcEndpointSnapshot snapshot = update.snapshot();
if (!AUTHORITY.matcher(snapshot.authority()).matches()) {
violations.add(
"authority '"
+ snapshot.authority()
+ "' is not a plain host or host:port; a resolver that can change the authority can "
+ "change which certificate the channel accepts");
}
if (!snapshot.supersedes(lastAccepted)) {
violations.add(
"revision "
+ snapshot.revision()
+ " does not supersede the applied revision "
+ (lastAccepted == null ? "none" : lastAccepted.revision())
+ "; applying it would replace newer endpoints with older ones");
}
update
.serviceConfigJson()
.ifPresent(
config -> {
if (CREDENTIAL_SHAPED.matcher(config).find()) {
violations.add(
"the pushed service config contains a credential-shaped field; a resolver "
+ "supplies addresses and policy, never authentication material");
}
});
return List.copyOf(violations);
}
/**
* Whether a closed resolver's update should be applied.
*
* <p>Always false. A resolver that keeps delivering after close is one whose discovery source has
* not noticed the channel is gone, and applying its updates resurrects routing for a channel
* nobody is using.
*/
public static boolean acceptAfterClose() {
return false;
}
/** Whether a resolver may supply caller identity or business metadata. Always false. */
public static boolean mayCarryBusinessMetadata() {
return false;
}
/** The service config an update may contribute, once validated. */
public static Optional<String> acceptedServiceConfig(
GrpcResolverUpdate update, GrpcEndpointSnapshot lastAccepted) {
return violations(update, lastAccepted).isEmpty()
? update.serviceConfigJson()
: Optional.empty();
}
}
@@ -0,0 +1,34 @@
package dev.caskeleton.grpc.advanced.discovery;
import java.util.Optional;
/**
* A resolver's report: endpoints, and optionally the service config that goes with them.
*
* <p>The service config is optional and, when present, is validated as if a human had written it. A
* resolver that can push retry policy is a resolver that can turn on retries for a non-idempotent
* method from outside the codebase, and the fact that a control plane sent it is not evidence that
* anyone reviewed it.
*/
public record GrpcResolverUpdate(
GrpcEndpointSnapshot snapshot, Optional<String> serviceConfigJson) {
/** Requires a snapshot and the Optional. */
public GrpcResolverUpdate {
if (snapshot == null || serviceConfigJson == null) {
throw new IllegalArgumentException("a resolver update carries a snapshot and the Optional");
}
serviceConfigJson.ifPresent(
config -> {
if (config.isBlank()) {
throw new IllegalArgumentException(
"a present service config must not be blank; absent and empty are different states");
}
});
}
/** An update with endpoints only. */
public static GrpcResolverUpdate endpointsOnly(GrpcEndpointSnapshot snapshot) {
return new GrpcResolverUpdate(snapshot, Optional.empty());
}
}
@@ -0,0 +1,70 @@
package dev.caskeleton.grpc.advanced.resilience;
import java.util.concurrent.atomic.AtomicLong;
/**
* Caps duplicate attempts as a fraction of real traffic.
*
* <p>Necessary for the same reason a retry budget is, and more urgently. A retry happens after a
* failure; a hedge happens on a call that might have succeeded, so a fleet that hedges without a
* budget doubles its backend load in the steady state and doubles it again the moment latency
* rises.
*/
public final class GrpcHedgingBudget {
private final long maxTokens;
private final long tokensPerHedge;
private final AtomicLong tokens;
/**
* A budget that starts full.
*
* @param ratio hedges permitted per completed call, e.g. 0.1 for one hedge in ten
* @param maxTokens how much credit may accumulate, which bounds a burst after a quiet period
*/
public static GrpcHedgingBudget of(double ratio, long maxTokens) {
if (ratio <= 0.0d || ratio > 0.5d) {
throw new IllegalArgumentException(
"a hedging ratio above 0.5 means more than half of all calls are duplicated, which is a "
+ "load decision rather than a latency one");
}
if (maxTokens < 1) {
throw new IllegalArgumentException("a budget needs at least one token");
}
return new GrpcHedgingBudget(maxTokens, Math.round(1.0d / ratio));
}
private GrpcHedgingBudget(long maxTokens, long tokensPerHedge) {
this.maxTokens = maxTokens;
this.tokensPerHedge = tokensPerHedge;
this.tokens = new AtomicLong(maxTokens);
}
/** Takes the credit for one hedge, if there is any. */
public boolean tryConsume() {
while (true) {
long observed = tokens.get();
if (observed < tokensPerHedge) {
return false;
}
if (tokens.compareAndSet(observed, observed - tokensPerHedge)) {
return true;
}
}
}
/** Records a completed call, which earns credit back. */
public void recordCompletion() {
tokens.updateAndGet(observed -> Math.min(maxTokens, observed + 1L));
}
/** How much credit is left. */
public long availableTokens() {
return tokens.get();
}
/** Whether another hedge could be afforded. */
public boolean exhausted() {
return tokens.get() < tokensPerHedge;
}
}
@@ -0,0 +1,72 @@
package dev.caskeleton.grpc.advanced.resilience;
import dev.caskeleton.grpc.core.RpcType;
import dev.caskeleton.grpc.policy.GrpcMethodPolicy;
import dev.caskeleton.grpc.policy.RpcIdempotencyProfile;
import dev.caskeleton.grpc.resilience.GrpcRetryOwner;
import java.util.Optional;
/**
* Whether a method may be hedged at all.
*
* <p>Read-only unary, and nothing else. A hedged mutation runs twice by design rather than by
* accident — both attempts are in flight, both may reach the server, and an idempotency key does
* not help because the second attempt is not a retry of a failure but a duplicate of a success in
* progress. A hedged stream is worse still: two streams deliver two prefixes.
*/
public final class GrpcHedgingEligibility {
private GrpcHedgingEligibility() {}
/**
* Why {@code policy} may not be hedged, or empty when it may.
*
* @return a refusal reason, or empty when hedging is permitted
*/
public static Optional<String> refusalReason(GrpcMethodPolicy policy, GrpcRetryOwner retryOwner) {
if (policy == null || retryOwner == null) {
throw new IllegalArgumentException("eligibility needs a method policy and a retry owner");
}
if (policy.rpcType() != RpcType.UNARY) {
return Optional.of(
"method '"
+ policy.method().canonical()
+ "' is "
+ policy.rpcType()
+ "; two hedged streams deliver two prefixes");
}
if (policy.idempotency() != RpcIdempotencyProfile.READ_ONLY) {
return Optional.of(
"method '"
+ policy.method().canonical()
+ "' is "
+ policy.idempotency()
+ "; a hedged mutation runs twice by design, and an idempotency key does not help "
+ "because the second attempt duplicates a success in progress rather than retrying a "
+ "failure");
}
if (!retryOwner.hedgingAllowed()) {
return Optional.of(
"retry owner is " + retryOwner + ", which does not permit in-process hedging");
}
return Optional.empty();
}
/** Whether {@code policy} may be hedged. */
public static boolean eligible(GrpcMethodPolicy policy, GrpcRetryOwner retryOwner) {
return refusalReason(policy, retryOwner).isEmpty();
}
/**
* Fails when a method may not be hedged.
*
* @throws IllegalStateException with the reason
*/
public static void require(GrpcMethodPolicy policy, GrpcRetryOwner retryOwner) {
refusalReason(policy, retryOwner)
.ifPresent(
reason -> {
throw new IllegalStateException("hedging refused: " + reason);
});
}
}
@@ -0,0 +1,51 @@
package dev.caskeleton.grpc.advanced.resilience;
import java.time.Duration;
/**
* Duplicate in-flight attempts for a read, and the two bounds that keep them affordable.
*
* <p>Hedging trades backend load for tail latency: a second attempt goes out before the first has
* failed, so a slow replica stops mattering. The cost is that every hedged call may cost two, and
* it costs two precisely when the backend is already slow — which is why the attempt cap starts at
* 2 and the delay is required to be meaningfully above the median.
*/
public record GrpcHedgingPolicy(int maxAttempts, Duration hedgingDelay, Duration totalDeadline) {
/** The initial cap. Raising it is a deliberate decision with load evidence behind it. */
public static final int INITIAL_MAX_ATTEMPTS = 2;
/** Refuses a policy whose duplicate load is unbounded or whose delay is meaningless. */
public GrpcHedgingPolicy {
if (maxAttempts < 2) {
throw new IllegalArgumentException("hedging means at least two attempts; got " + maxAttempts);
}
if (maxAttempts > INITIAL_MAX_ATTEMPTS) {
throw new IllegalArgumentException(
"hedging is capped at "
+ INITIAL_MAX_ATTEMPTS
+ " attempts until load evidence justifies more; each extra attempt multiplies "
+ "backend load exactly when the backend is already slow");
}
if (hedgingDelay == null || hedgingDelay.isNegative()) {
throw new IllegalArgumentException("a hedging delay must be present and non-negative");
}
if (hedgingDelay.isZero()) {
throw new IllegalArgumentException(
"a zero hedging delay sends every attempt at once, which doubles load for every call "
+ "rather than for the slow ones");
}
if (totalDeadline == null || totalDeadline.isZero() || totalDeadline.isNegative()) {
throw new IllegalArgumentException("hedging needs a total deadline to fit inside");
}
if (hedgingDelay.compareTo(totalDeadline) >= 0) {
throw new IllegalArgumentException(
"the hedging delay is at or above the total deadline, so the second attempt never starts");
}
}
/** A policy that hedges once after {@code hedgingDelay}. */
public static GrpcHedgingPolicy hedgeOnce(Duration hedgingDelay, Duration totalDeadline) {
return new GrpcHedgingPolicy(INITIAL_MAX_ATTEMPTS, hedgingDelay, totalDeadline);
}
}
@@ -0,0 +1,49 @@
package dev.caskeleton.grpc.advanced.resilience;
import java.time.Duration;
/**
* What a hedged call cost and what it saved.
*
* <p>Both numbers, because hedging is a trade and a dashboard that shows only the latency
* improvement makes it look free. {@code duplicateBackendCalls} is what the backend team sees, and
* {@code cancelledLoserAttempts} is how much of that work was thrown away.
*/
public record GrpcHedgingResult(
int attemptsIssued,
int winningAttempt,
int cancelledLoserAttempts,
int duplicateBackendCalls,
Duration observedLatency) {
/** Requires coherent counts. */
public GrpcHedgingResult {
if (attemptsIssued < 1) {
throw new IllegalArgumentException("a hedged call issues at least one attempt");
}
if (winningAttempt < 1 || winningAttempt > attemptsIssued) {
throw new IllegalArgumentException("the winning attempt is one of the attempts issued");
}
if (cancelledLoserAttempts < 0 || cancelledLoserAttempts > attemptsIssued - 1) {
throw new IllegalArgumentException(
"at most every attempt but the winner can be a cancelled loser");
}
if (duplicateBackendCalls < 0 || duplicateBackendCalls > attemptsIssued - 1) {
throw new IllegalArgumentException(
"duplicate backend calls are the attempts beyond the first");
}
if (observedLatency == null || observedLatency.isNegative()) {
throw new IllegalArgumentException("a hedged call records its latency");
}
}
/** A call that did not need to hedge. */
public static GrpcHedgingResult firstAttemptWon(Duration latency) {
return new GrpcHedgingResult(1, 1, 0, 0, latency);
}
/** Whether this call actually issued a duplicate. */
public boolean hedged() {
return attemptsIssued > 1;
}
}
@@ -0,0 +1,60 @@
package dev.caskeleton.grpc.advanced.xds;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
/**
* What happens when the control plane goes away.
*
* <p>Last-known-good, with a bound. Serving forever from a stale snapshot means a decommissioned
* backend keeps receiving traffic indefinitely; failing immediately means a control-plane restart
* takes every client down with it. The bound is where the deployment decides which risk it prefers,
* and it has to be stated rather than inherited.
*/
public record GrpcXdsFailurePolicy(
Duration maxStaleness, boolean failFastOnMissingResource, Duration initialFetchTimeout) {
/** Refuses a policy without a staleness bound. */
public GrpcXdsFailurePolicy {
if (maxStaleness == null || maxStaleness.isZero() || maxStaleness.isNegative()) {
throw new IllegalArgumentException(
"last-known-good needs a staleness bound; without one a decommissioned backend keeps "
+ "receiving traffic indefinitely");
}
if (initialFetchTimeout == null
|| initialFetchTimeout.isZero()
|| initialFetchTimeout.isNegative()) {
throw new IllegalArgumentException(
"a client with no snapshot yet needs a bound on how long it waits before failing");
}
}
/** The default: fifteen minutes of last-known-good, fail fast on a resource that vanished. */
public static GrpcXdsFailurePolicy standard() {
return new GrpcXdsFailurePolicy(Duration.ofMinutes(15), true, Duration.ofSeconds(15));
}
/** What a client should do given the newest snapshot it holds. */
public Decision decide(Optional<GrpcXdsResourceSnapshot> snapshot, Instant now) {
if (snapshot == null || now == null) {
throw new IllegalArgumentException("a decision needs the snapshot Optional and a moment");
}
if (snapshot.isEmpty()) {
return Decision.NO_SNAPSHOT_YET;
}
return snapshot.get().ageAt(now).compareTo(maxStaleness) > 0
? Decision.STALE_BEYOND_BOUND
: Decision.SERVE_LAST_KNOWN_GOOD;
}
/** What the client does about a control-plane outage. */
public enum Decision {
/** Nothing has arrived yet; wait until the initial fetch timeout, then fail. */
NO_SNAPSHOT_YET,
/** Keep routing on the snapshot in hand. */
SERVE_LAST_KNOWN_GOOD,
/** The snapshot is older than the bound; stop trusting it. */
STALE_BEYOND_BOUND
}
}
@@ -0,0 +1,52 @@
package dev.caskeleton.grpc.advanced.xds;
import dev.caskeleton.grpc.resilience.GrpcRetryOwner;
import java.net.URI;
/**
* A proxyless xDS deployment's configuration.
*
* <p>xDS moves routing, load balancing, retries and often mTLS out of the application and into a
* control plane. The consequence this profile encodes is that the application must stop configuring
* them: retry policy defined in both places is defined twice, and which one wins depends on
* resolution order rather than on anyone's decision.
*/
public record GrpcXdsProfile(
URI target,
String bootstrapReference,
String resourceNamespace,
GrpcRetryOwner retryOwner,
boolean controlPlaneMutualTls) {
/** The only scheme an xDS target may use. */
public static final String XDS_SCHEME = "xds";
/** Refuses a profile that would leave retries or routing owned in two places. */
public GrpcXdsProfile {
if (target == null || !XDS_SCHEME.equals(target.getScheme())) {
throw new IllegalArgumentException(
"an xDS profile needs an 'xds:///' target; got '" + target + "'");
}
if (bootstrapReference == null || bootstrapReference.isBlank()) {
throw new IllegalArgumentException(
"xDS needs a bootstrap reference; without one the client has no control plane to ask");
}
if (resourceNamespace == null || resourceNamespace.isBlank()) {
throw new IllegalArgumentException(
"an xDS profile names its resource namespace; a client that subscribes to everything "
+ "receives another team's routing");
}
if (retryOwner != GrpcRetryOwner.SERVICE_MESH) {
throw new IllegalArgumentException(
"xDS routing means the control plane owns retries; a retry owner of "
+ retryOwner
+ " would define retry policy in two places, and which wins depends on resolution "
+ "order rather than on a decision");
}
if (!controlPlaneMutualTls) {
throw new IllegalArgumentException(
"the control-plane connection carries routing and often certificates; it is authenticated "
+ "in both directions or it is a channel that can be impersonated");
}
}
}
@@ -0,0 +1,40 @@
package dev.caskeleton.grpc.advanced.xds;
import java.time.Instant;
import java.util.List;
/**
* What the control plane last said, and when.
*
* <p>The timestamp is what makes last-known-good usable. A snapshot with no age cannot answer
* whether the control plane has been silent for a minute or a day, and those are a transient blip
* and a serious incident.
*/
public record GrpcXdsResourceSnapshot(
String versionInfo, List<String> resourceNames, Instant receivedAt) {
/** Requires a version, at least one resource and a receipt time. */
public GrpcXdsResourceSnapshot {
if (versionInfo == null || versionInfo.isBlank()) {
throw new IllegalArgumentException("an xDS snapshot carries the control plane's version");
}
if (resourceNames == null || resourceNames.isEmpty()) {
throw new IllegalArgumentException(
"an empty resource set is not a snapshot; a control plane that returns nothing has not "
+ "told the client its routing was removed");
}
if (receivedAt == null) {
throw new IllegalArgumentException(
"a snapshot records when it arrived; without it, last-known-good cannot say how old it is");
}
resourceNames = List.copyOf(resourceNames);
}
/** How old this snapshot is at {@code now}. */
public java.time.Duration ageAt(Instant now) {
if (now == null) {
throw new IllegalArgumentException("an age needs a moment");
}
return java.time.Duration.between(receivedAt, now);
}
}
@@ -0,0 +1,100 @@
package dev.caskeleton.grpc.advanced.xds;
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedFeatureFlags;
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedModuleGuard;
import java.util.ArrayList;
import java.util.List;
/**
* Refuses to start an xDS channel that is not fully configured, and refuses to let xDS be described
* as Stable support.
*
* <p>The second refusal is the one worth having in code. xDS working in a deployment is not the
* same claim as the platform supporting it: it brings a control plane, its outage modes, its own
* security boundary and its own version skew, and the Stable support statement covers DNS and
* static targets. A support matrix that quietly widens is a support matrix nobody can rely on.
*/
public final class GrpcXdsStartupGuard {
private GrpcXdsStartupGuard() {}
/**
* Every reason an xDS channel may not start.
*
* @return an empty list when the profile and flags permit it
*/
public static List<String> startupBlockers(
GrpcXdsProfile profile, GrpcAdvancedFeatureFlags flags, boolean applicationDefinesRetries) {
if (profile == null || flags == null) {
throw new IllegalArgumentException("startup validation needs a profile and the flags");
}
List<String> blockers = new ArrayList<>();
if (!GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.XDS)) {
blockers.add(
"the xds capability is not available; it is "
+ flags.gradeOf(GrpcAdvancedCapability.XDS)
+ " and "
+ (flags.flagSet(GrpcAdvancedCapability.XDS)
? "production has not approved it"
: "its flag is not set"));
}
if (applicationDefinesRetries) {
blockers.add(
"the application also defines retry policy; with xDS the control plane owns it, and "
+ "defining it in both places makes the winner depend on resolution order");
}
return List.copyOf(blockers);
}
/**
* Every disagreement between a deployment's profile and the bootstrap file its client will read.
*
* <p>Checked because the two are written by different people in different repositories, and the
* failure is silent: a client whose bootstrap names a namespace the deployment did not configure
* subscribes successfully and receives another team's routing. Nothing errors — the control plane
* answers, the resources parse, and traffic goes somewhere nobody chose.
*
* <p>Matched textually rather than with a JSON parser, deliberately. This leaf's test classpath
* is plain JUnit and AssertJ, and adding a JSON library to check three fields would put a parser
* on the runtime classpath of every deployment that enables xDS.
*
* @param bootstrapJson the bootstrap document's contents
* @return an empty list when the bootstrap and the profile agree
*/
public static List<String> bootstrapMismatches(GrpcXdsProfile profile, String bootstrapJson) {
if (profile == null) {
throw new IllegalArgumentException("a profile is required");
}
if (bootstrapJson == null || bootstrapJson.isBlank()) {
throw new IllegalArgumentException(
"the bootstrap document is required; a client with no bootstrap has no control plane to ask");
}
List<String> mismatches = new ArrayList<>();
if (!bootstrapJson.contains("\"xds_servers\"")) {
mismatches.add("the bootstrap declares no xds_servers");
}
if (!bootstrapJson.contains("\"channel_creds\"") || !bootstrapJson.contains("\"tls\"")) {
mismatches.add(
"the bootstrap's control-plane channel is not TLS; that connection carries routing and "
+ "often certificates, so an unauthenticated one can be impersonated");
}
if (!bootstrapJson.contains(profile.resourceNamespace())) {
mismatches.add(
"the bootstrap does not name the profile's resource namespace '"
+ profile.resourceNamespace()
+ "'; a client that subscribes outside its namespace receives another team's routing, "
+ "and nothing about that fails");
}
return List.copyOf(mismatches);
}
/**
* Whether xDS may be advertised as part of Stable discovery support.
*
* <p>Always false. Stable support is DNS and static.
*/
public static boolean advertisableAsStableSupport() {
return false;
}
}
@@ -0,0 +1,146 @@
package dev.caskeleton.grpc.advanced.resilience;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.grpc.core.GrpcMethodName;
import dev.caskeleton.grpc.core.RpcType;
import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile;
import dev.caskeleton.grpc.policy.GrpcMethodPolicy;
import dev.caskeleton.grpc.policy.RpcIdempotencyProfile;
import dev.caskeleton.grpc.policy.WaitForReadyPolicy;
import dev.caskeleton.grpc.resilience.GrpcRetryOwner;
import java.time.Duration;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcHedgingEligibilityTest {
private static final GrpcMethodName GET =
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument");
private static final GrpcMethodName CREATE =
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/CreateDocument");
private static final GrpcMethodName WATCH =
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/WatchDocuments");
private static final GrpcDeadlineProfile TWO_SECONDS =
GrpcDeadlineProfile.of(Duration.ofSeconds(2));
@Test
@DisplayName("only a read-only unary method may be hedged")
void onlyReadOnlyUnaryMayBeHedged() {
assertThat(
GrpcHedgingEligibility.eligible(
GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS), GrpcRetryOwner.GRPC_PLATFORM))
.isTrue();
assertThat(
GrpcHedgingEligibility.refusalReason(
GrpcMethodPolicy.nonIdempotentUnary(CREATE, TWO_SECONDS),
GrpcRetryOwner.GRPC_PLATFORM))
.hasValueSatisfying(
reason -> assertThat(reason).contains("duplicates a success in progress"));
}
@Test
@DisplayName("a keyed mutation is still refused; an idempotency key does not make hedging safe")
void aKeyedMutationIsStillRefused() {
GrpcMethodPolicy keyed =
new GrpcMethodPolicy(
CREATE,
RpcType.UNARY,
RpcIdempotencyProfile.IDEMPOTENCY_KEY_REQUIRED,
TWO_SECONDS,
WaitForReadyPolicy.DISABLED,
false,
1024,
1024);
assertThat(GrpcHedgingEligibility.eligible(keyed, GrpcRetryOwner.GRPC_PLATFORM)).isFalse();
}
@Test
@DisplayName("a streaming method may not be hedged")
void streamingMayNotBeHedged() {
GrpcMethodPolicy streaming =
new GrpcMethodPolicy(
WATCH,
RpcType.SERVER_STREAMING,
RpcIdempotencyProfile.READ_ONLY,
TWO_SECONDS,
WaitForReadyPolicy.DISABLED,
false,
1024,
1024);
assertThatThrownBy(
() -> GrpcHedgingEligibility.require(streaming, GrpcRetryOwner.GRPC_PLATFORM))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("two prefixes");
}
@Test
@DisplayName("a mesh-owned channel may not hedge in-process")
void aMeshOwnedChannelMayNotHedge() {
assertThat(
GrpcHedgingEligibility.eligible(
GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS), GrpcRetryOwner.SERVICE_MESH))
.isFalse();
assertThat(
GrpcHedgingEligibility.eligible(
GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS), GrpcRetryOwner.APPLICATION))
.isFalse();
}
@Test
@DisplayName("hedging is capped at two attempts and needs a meaningful delay")
void hedgingIsCappedAndDelayed() {
assertThat(
GrpcHedgingPolicy.hedgeOnce(Duration.ofMillis(50), Duration.ofSeconds(2)).maxAttempts())
.isEqualTo(2);
assertThatThrownBy(() -> new GrpcHedgingPolicy(3, Duration.ofMillis(50), Duration.ofSeconds(2)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("capped at 2");
assertThatThrownBy(() -> new GrpcHedgingPolicy(2, Duration.ZERO, Duration.ofSeconds(2)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("doubles load for every call");
assertThatThrownBy(() -> new GrpcHedgingPolicy(2, Duration.ofSeconds(3), Duration.ofSeconds(2)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("never starts");
}
@Test
@DisplayName("the hedging budget bounds duplicate load and refuses an absurd ratio")
void theHedgingBudgetBoundsDuplicateLoad() {
// ratio 0.5 costs two tokens per hedge, so a four-token budget affords two hedges and then
// needs two completions before it can afford another.
GrpcHedgingBudget budget = GrpcHedgingBudget.of(0.5d, 4L);
assertThat(budget.tryConsume()).isTrue();
assertThat(budget.tryConsume()).isTrue();
assertThat(budget.tryConsume()).isFalse();
assertThat(budget.availableTokens()).isZero();
budget.recordCompletion();
assertThat(budget.exhausted()).isTrue();
budget.recordCompletion();
assertThat(budget.exhausted()).isFalse();
assertThatThrownBy(() -> GrpcHedgingBudget.of(0.9d, 10L))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("load decision rather than a latency one");
}
@Test
@DisplayName("a hedged result records both the saving and the duplicate load")
void aHedgedResultRecordsBothSides() {
GrpcHedgingResult hedged = new GrpcHedgingResult(2, 2, 1, 1, Duration.ofMillis(40));
assertThat(hedged.hedged()).isTrue();
assertThat(hedged.duplicateBackendCalls()).isEqualTo(1);
assertThat(hedged.cancelledLoserAttempts()).isEqualTo(1);
assertThat(GrpcHedgingResult.firstAttemptWon(Duration.ofMillis(10)).hedged()).isFalse();
assertThatThrownBy(() -> new GrpcHedgingResult(2, 3, 0, 0, Duration.ZERO))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new GrpcHedgingResult(2, 1, 2, 0, Duration.ZERO))
.isInstanceOf(IllegalArgumentException.class);
}
}
@@ -0,0 +1,124 @@
package dev.caskeleton.grpc.advanced.resilience;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.grpc.advanced.discovery.GrpcEndpointCandidate;
import dev.caskeleton.grpc.advanced.discovery.GrpcLoadBalancerDecision;
import dev.caskeleton.grpc.advanced.discovery.GrpcLoadBalancerPicker;
import dev.caskeleton.grpc.advanced.discovery.GrpcLoadBalancerSafetyPolicy;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcLoadBalancerSafetyPolicyTest {
@Test
@DisplayName("a picker may only choose an endpoint the resolver supplied")
void aPickerMayNotInventAnEndpoint() {
GrpcLoadBalancerSafetyPolicy policy =
new GrpcLoadBalancerSafetyPolicy(candidates -> GrpcEndpointCandidate.ready("10.9.9.9"));
GrpcLoadBalancerDecision decision =
policy.pick(List.of(GrpcEndpointCandidate.ready("10.0.0.1")));
assertThat(decision.verdict())
.isEqualTo(GrpcLoadBalancerDecision.Verdict.DETERMINISTIC_FALLBACK);
assertThat(decision.reason()).contains("can send a request anywhere");
assertThat(decision.chosen())
.hasValueSatisfying(endpoint -> assertThat(endpoint.address()).isEqualTo("10.0.0.1"));
}
@Test
@DisplayName("a picker that throws degrades balancing, not availability")
void aThrowingPickerFallsBack() {
GrpcLoadBalancerSafetyPolicy policy =
new GrpcLoadBalancerSafetyPolicy(
candidates -> {
throw new IllegalStateException("picker bug");
});
GrpcLoadBalancerDecision decision =
policy.pick(List.of(GrpcEndpointCandidate.ready("10.0.0.1")));
assertThat(decision.verdict())
.isEqualTo(GrpcLoadBalancerDecision.Verdict.DETERMINISTIC_FALLBACK);
assertThat(decision.reason()).contains("IllegalStateException");
}
@Test
@DisplayName("a picker returning null falls back rather than failing the call")
void aNullPickFallsBack() {
GrpcLoadBalancerSafetyPolicy policy = new GrpcLoadBalancerSafetyPolicy(candidates -> null);
assertThat(policy.pick(List.of(GrpcEndpointCandidate.ready("10.0.0.1"))).verdict())
.isEqualTo(GrpcLoadBalancerDecision.Verdict.DETERMINISTIC_FALLBACK);
}
@Test
@DisplayName("no selectable endpoint is a different verdict from a picker failure")
void noEndpointDiffersFromAPickerFailure() {
GrpcLoadBalancerSafetyPolicy policy =
new GrpcLoadBalancerSafetyPolicy(GrpcLoadBalancerPicker.roundRobin());
assertThat(
policy
.pick(List.of(new GrpcEndpointCandidate("10.0.0.1", false, true, 100, false)))
.verdict())
.isEqualTo(GrpcLoadBalancerDecision.Verdict.NO_ENDPOINT_AVAILABLE);
assertThat(
policy
.pick(List.of(new GrpcEndpointCandidate("10.0.0.1", true, true, 100, true)))
.verdict())
.isEqualTo(GrpcLoadBalancerDecision.Verdict.NO_ENDPOINT_AVAILABLE);
assertThat(
policy
.pick(List.of(new GrpcEndpointCandidate("10.0.0.1", true, true, 0, false)))
.verdict())
.isEqualTo(GrpcLoadBalancerDecision.Verdict.NO_ENDPOINT_AVAILABLE);
}
@Test
@DisplayName("a healthy endpoint is picked and reported as the picker's own choice")
void aHealthyEndpointIsPicked() {
GrpcLoadBalancerSafetyPolicy policy =
new GrpcLoadBalancerSafetyPolicy(GrpcLoadBalancerPicker.roundRobin());
assertThat(
policy
.pick(
List.of(
GrpcEndpointCandidate.ready("10.0.0.1"),
GrpcEndpointCandidate.ready("10.0.0.2")))
.verdict())
.isEqualTo(GrpcLoadBalancerDecision.Verdict.PICKED);
}
@Test
@DisplayName("a picker sees endpoints only, never a request or a caller")
void aPickerSeesEndpointsOnly() {
assertThat(GrpcLoadBalancerPicker.class.getMethods())
.filteredOn(method -> "pick".equals(method.getName()))
.singleElement()
.satisfies(method -> assertThat(method.getParameterCount()).isEqualTo(1));
}
@Test
@DisplayName("a candidate carries only routing-relevant state, bounded")
void aCandidateCarriesOnlyRoutingState() {
assertThat(GrpcEndpointCandidate.ready("10.0.0.1").selectable()).isTrue();
assertThatThrownBy(() -> new GrpcEndpointCandidate("10.0.0.1", true, true, 5000, false))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("scale nobody can reason about");
assertThatThrownBy(() -> new GrpcEndpointCandidate(" ", true, true, 100, false))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("a load-aware or weighted picker requires evidence before it ships")
void loadAwarePickersRequireEvidence() {
assertThat(GrpcLoadBalancerSafetyPolicy.requiresLoadEvidence(true, false)).isTrue();
assertThat(GrpcLoadBalancerSafetyPolicy.requiresLoadEvidence(false, true)).isTrue();
assertThat(GrpcLoadBalancerSafetyPolicy.requiresLoadEvidence(false, false)).isFalse();
}
}
@@ -0,0 +1,119 @@
package dev.caskeleton.grpc.advanced.resilience;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.grpc.advanced.discovery.GrpcCustomResolver;
import dev.caskeleton.grpc.advanced.discovery.GrpcEndpointSnapshot;
import dev.caskeleton.grpc.advanced.discovery.GrpcResolverSafetyPolicy;
import dev.caskeleton.grpc.advanced.discovery.GrpcResolverUpdate;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcResolverSafetyPolicyTest {
@Test
@DisplayName("an empty or duplicate endpoint set is refused")
void anEmptyOrDuplicateEndpointSetIsRefused() {
assertThatThrownBy(() -> new GrpcEndpointSnapshot(1L, "documents", List.of()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("take the channel down with it");
assertThatThrownBy(
() -> new GrpcEndpointSnapshot(1L, "documents", List.of("10.0.0.1", "10.0.0.1")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("skew a round-robin picker");
assertThatThrownBy(() -> new GrpcEndpointSnapshot(0L, "documents", List.of("10.0.0.1")))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("a stale revision is dropped rather than applied")
void aStaleRevisionIsDropped() {
List<GrpcResolverUpdate> delivered = new ArrayList<>();
try (GrpcCustomResolver resolver = new GrpcCustomResolver("documents", delivered::add)) {
assertThat(
resolver.offer(
GrpcResolverUpdate.endpointsOnly(
new GrpcEndpointSnapshot(2L, "documents", List.of("10.0.0.1")))))
.isEmpty();
assertThat(
resolver.offer(
GrpcResolverUpdate.endpointsOnly(
new GrpcEndpointSnapshot(1L, "documents", List.of("10.0.0.9")))))
.anySatisfy(violation -> assertThat(violation).contains("older ones"));
assertThat(delivered).hasSize(1);
assertThat(resolver.currentSnapshot())
.hasValueSatisfying(snapshot -> assertThat(snapshot.revision()).isEqualTo(2L));
assertThat(resolver.authority()).isEqualTo("documents");
}
}
@Test
@DisplayName("a resolver accepts nothing after close")
void aClosedResolverAcceptsNothing() {
List<GrpcResolverUpdate> delivered = new ArrayList<>();
GrpcCustomResolver resolver = new GrpcCustomResolver("documents", delivered::add);
resolver.close();
assertThat(resolver.closed()).isTrue();
assertThat(
resolver.offer(
GrpcResolverUpdate.endpointsOnly(
new GrpcEndpointSnapshot(1L, "documents", List.of("10.0.0.1")))))
.anySatisfy(violation -> assertThat(violation).contains("resolver is closed"));
assertThat(delivered).isEmpty();
assertThat(GrpcResolverSafetyPolicy.acceptAfterClose()).isFalse();
}
@Test
@DisplayName("a resolver may not push credentials or business metadata")
void aResolverMayNotPushCredentials() {
GrpcResolverUpdate withCredential =
new GrpcResolverUpdate(
new GrpcEndpointSnapshot(1L, "documents", List.of("10.0.0.1")),
Optional.of("{\"authorization\":\"Bearer abc\"}"));
assertThat(GrpcResolverSafetyPolicy.violations(withCredential, null))
.anySatisfy(violation -> assertThat(violation).contains("never authentication material"));
assertThat(GrpcResolverSafetyPolicy.mayCarryBusinessMetadata()).isFalse();
assertThat(GrpcResolverSafetyPolicy.acceptedServiceConfig(withCredential, null)).isEmpty();
}
@Test
@DisplayName("a clean service config survives validation")
void aCleanServiceConfigSurvives() {
GrpcResolverUpdate clean =
new GrpcResolverUpdate(
new GrpcEndpointSnapshot(1L, "documents", List.of("10.0.0.1")),
Optional.of("{\"loadBalancingConfig\":[{\"round_robin\":{}}]}"));
assertThat(GrpcResolverSafetyPolicy.violations(clean, null)).isEmpty();
assertThat(GrpcResolverSafetyPolicy.acceptedServiceConfig(clean, null)).isPresent();
}
@Test
@DisplayName("an invalid authority is refused, because it decides which certificate is accepted")
void anInvalidAuthorityIsRefused() {
assertThat(
GrpcResolverSafetyPolicy.violations(
GrpcResolverUpdate.endpointsOnly(
new GrpcEndpointSnapshot(1L, "Documents Service", List.of("10.0.0.1"))),
null))
.anySatisfy(violation -> assertThat(violation).contains("which certificate"));
}
@Test
@DisplayName("a present service config may not be blank")
void aPresentServiceConfigMayNotBeBlank() {
assertThatThrownBy(
() ->
new GrpcResolverUpdate(
new GrpcEndpointSnapshot(1L, "documents", List.of("10.0.0.1")),
Optional.of(" ")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("different states");
}
}
@@ -0,0 +1,188 @@
package dev.caskeleton.grpc.advanced.resilience;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedFeatureFlags;
import dev.caskeleton.grpc.advanced.xds.GrpcXdsFailurePolicy;
import dev.caskeleton.grpc.advanced.xds.GrpcXdsProfile;
import dev.caskeleton.grpc.advanced.xds.GrpcXdsResourceSnapshot;
import dev.caskeleton.grpc.advanced.xds.GrpcXdsStartupGuard;
import dev.caskeleton.grpc.resilience.GrpcRetryOwner;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class GrpcXdsStartupGuardTest {
private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z");
private static GrpcXdsProfile profile(String namespace) {
return new GrpcXdsProfile(
URI.create("xds:///documents"),
"classpath:/xds/bootstrap.json",
namespace,
GrpcRetryOwner.SERVICE_MESH,
true);
}
@Test
@DisplayName("an xDS profile requires an xds target, a namespace, mesh retries and mTLS")
void anXdsProfileRequiresItsFourConditions() {
assertThat(profile("hyeonworks/documents").target().getScheme()).isEqualTo("xds");
assertThatThrownBy(
() ->
new GrpcXdsProfile(
URI.create("dns:///documents"),
"file:/etc/grpc/bootstrap.json",
"ns",
GrpcRetryOwner.SERVICE_MESH,
true))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(
() ->
new GrpcXdsProfile(
URI.create("xds:///documents"), " ", "ns", GrpcRetryOwner.SERVICE_MESH, true))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("no control plane to ask");
assertThatThrownBy(
() ->
new GrpcXdsProfile(
URI.create("xds:///documents"),
"file:/etc/grpc/bootstrap.json",
"ns",
GrpcRetryOwner.APPLICATION,
true))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("two places");
assertThatThrownBy(
() ->
new GrpcXdsProfile(
URI.create("xds:///documents"),
"file:/etc/grpc/bootstrap.json",
"ns",
GrpcRetryOwner.SERVICE_MESH,
false))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("impersonated");
}
@Test
@DisplayName("xDS needs its capability approved and refuses duplicate retry ownership")
void startupRequiresApprovalAndSingleRetryOwner() {
GrpcAdvancedFeatureFlags unapproved =
GrpcAdvancedFeatureFlags.forProduction(Set.of()).enable(GrpcAdvancedCapability.XDS);
GrpcAdvancedFeatureFlags approved =
GrpcAdvancedFeatureFlags.forProduction(Set.of(GrpcAdvancedCapability.XDS))
.enable(GrpcAdvancedCapability.XDS);
assertThat(
GrpcXdsStartupGuard.startupBlockers(profile("hyeonworks/documents"), unapproved, false))
.anySatisfy(blocker -> assertThat(blocker).contains("production has not approved it"));
assertThat(
GrpcXdsStartupGuard.startupBlockers(profile("hyeonworks/documents"), approved, false))
.isEmpty();
assertThat(GrpcXdsStartupGuard.startupBlockers(profile("hyeonworks/documents"), approved, true))
.anySatisfy(blocker -> assertThat(blocker).contains("resolution order"));
}
@Test
@DisplayName("an unflagged capability is reported as unflagged rather than unapproved")
void anUnflaggedCapabilityIsReportedAsSuch() {
assertThat(
GrpcXdsStartupGuard.startupBlockers(
profile("hyeonworks/documents"),
GrpcAdvancedFeatureFlags.forProduction(Set.of()),
false))
.anySatisfy(blocker -> assertThat(blocker).contains("its flag is not set"));
}
@Test
@DisplayName("the committed bootstrap fixture agrees with the profile it is meant to serve")
void theBootstrapFixtureAgreesWithItsProfile() {
assertThat(
GrpcXdsStartupGuard.bootstrapMismatches(
profile("hyeonworks/documents"), resource("xds/bootstrap.json")))
.isEmpty();
}
@Test
@DisplayName("a bootstrap naming another namespace is reported, since nothing else would fail")
void aNamespaceMismatchIsReported() {
assertThat(
GrpcXdsStartupGuard.bootstrapMismatches(
profile("hyeonworks/billing"), resource("xds/bootstrap.json")))
.anySatisfy(mismatch -> assertThat(mismatch).contains("another team's routing"));
}
@Test
@DisplayName("a bootstrap with an unauthenticated control-plane channel is reported")
void anUnauthenticatedControlPlaneIsReported() {
String insecure =
resource("xds/bootstrap.json")
.replace("{ \"type\": \"tls\" }", "{ \"type\": \"insecure\" }");
assertThat(GrpcXdsStartupGuard.bootstrapMismatches(profile("hyeonworks/documents"), insecure))
.anySatisfy(mismatch -> assertThat(mismatch).contains("can be impersonated"));
}
@Test
@DisplayName("a missing bootstrap is refused rather than treated as an empty one")
void aMissingBootstrapIsRefused() {
assertThatThrownBy(
() -> GrpcXdsStartupGuard.bootstrapMismatches(profile("hyeonworks/documents"), " "))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("no control plane to ask");
}
@Test
@DisplayName("last-known-good is bounded, and an empty resource set is not a snapshot")
void lastKnownGoodIsBounded() {
GrpcXdsFailurePolicy policy = GrpcXdsFailurePolicy.standard();
GrpcXdsResourceSnapshot snapshot =
new GrpcXdsResourceSnapshot("v7", List.of("documents-cluster"), NOW);
assertThat(policy.decide(Optional.of(snapshot), NOW.plusSeconds(60)))
.isEqualTo(GrpcXdsFailurePolicy.Decision.SERVE_LAST_KNOWN_GOOD);
assertThat(policy.decide(Optional.of(snapshot), NOW.plusSeconds(1000)))
.isEqualTo(GrpcXdsFailurePolicy.Decision.STALE_BEYOND_BOUND);
assertThat(policy.decide(Optional.empty(), NOW))
.isEqualTo(GrpcXdsFailurePolicy.Decision.NO_SNAPSHOT_YET);
assertThat(snapshot.ageAt(NOW.plusSeconds(60))).isEqualTo(java.time.Duration.ofSeconds(60));
assertThatThrownBy(() -> new GrpcXdsResourceSnapshot("v7", List.of(), NOW))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(
() ->
new GrpcXdsFailurePolicy(
java.time.Duration.ZERO, true, java.time.Duration.ofSeconds(1)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("receiving traffic indefinitely");
}
@Test
@DisplayName("xDS is not part of the Stable discovery support statement")
void xdsIsNotStableSupport() {
assertThat(GrpcXdsStartupGuard.advertisableAsStableSupport()).isFalse();
}
private static String resource(String path) {
try (InputStream stream =
GrpcXdsStartupGuardTest.class.getClassLoader().getResourceAsStream(path)) {
if (stream == null) {
throw new IllegalStateException("missing test resource " + path);
}
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}
@@ -0,0 +1,40 @@
{
"_comment": [
"An xDS bootstrap fixture. The client reads this file to learn where its control plane is and",
"who it claims to be; GrpcXdsProfile validates the deployment settings that must agree with it.",
"The two fields the profile actually checks against are server_uri (the control plane must be",
"reached over authenticated mTLS) and the node id's namespace (a client that subscribes outside",
"its namespace receives another team's routing)."
],
"xds_servers": [
{
"server_uri": "xds-control-plane.hyeonworks.internal:15010",
"channel_creds": [
{ "type": "tls" }
],
"server_features": ["xds_v3"]
}
],
"node": {
"id": "hyeonworks/documents/documents-7f9c4",
"cluster": "documents",
"metadata": {
"NAMESPACE": "hyeonworks/documents"
},
"locality": {
"region": "ap-northeast-2",
"zone": "ap-northeast-2a"
}
},
"authorities": {
"hyeonworks.internal": {
"xds_servers": [
{
"server_uri": "xds-control-plane.hyeonworks.internal:15010",
"channel_creds": [{ "type": "tls" }],
"server_features": ["xds_v3"]
}
]
}
}
}
@@ -0,0 +1,10 @@
apply plugin: 'java-library'
// The streaming shapes the Stable plan deliberately excludes: client streaming sessions with
// dedup/checkpoint/resume, bidirectional sessions with independent per-direction sequences, and the
// manual flow-control approval API.
dependencies {
api project(':grpc:grpc-core-api')
api project(':grpc:grpc-policy')
api project(':grpc-advanced:grpc-advanced-bootstrap')
}
@@ -0,0 +1,91 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.28.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.guava:guava:33.2.1-android=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.21.0=spotbugs
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
io.grpc:grpc-api:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.6=spotbugs
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.5=spotbugs
org.apache.logging.log4j:log4j-core:2.25.5=spotbugs
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath
org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.javassist:javassist:3.28.0-GA=checkstyle
org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath
org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath
org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=mockitoAgent
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.reflections:reflections:0.10.2=checkstyle
org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j
org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j
org.slf4j:slf4j-simple:2.0.18=checkstyle
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
empty=

Some files were not shown because too many files have changed in this diff Show More