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
@@ -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.