feat: web, websocket 어댑터 추가 구현
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
# ADR-WEB-ADV-001: Streaming is live delivery, and the web module stores no history
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-25
|
||||
- Scope: `adapter:inbound:web` — `advanced.stream.**`
|
||||
|
||||
## Context
|
||||
|
||||
Advanced Tasks 6–13 add SSE, NDJSON and JSON text sequences, with a `Last-Event-ID` resume path.
|
||||
|
||||
One fact drives every decision here: **after the first byte, the HTTP status is 200 and cannot
|
||||
change.** A stream that ends because a dependency failed and one that ends because it finished are
|
||||
identical at the transport layer — both are a closed connection after a 200. So is a stream that was
|
||||
cut off mid-flight.
|
||||
|
||||
The second fact is that a resume path invites the web module to remember things. It must not: the
|
||||
messaging platform already owns durable event history, and a second copy would have its own
|
||||
retention, its own eviction and its own opinion about ordering.
|
||||
|
||||
## Decision
|
||||
|
||||
**Three outcomes, expressed in the stream rather than in the status.** `WebStreamEnvelope` is sealed
|
||||
over `Item`, `Failure` and `Complete`. A client that sees neither terminal envelope has been cut off,
|
||||
and that third case is recorded as `ABRUPT_CLOSE` rather than counted as a completion — which is
|
||||
where a rising rate of mid-stream failures would otherwise hide.
|
||||
|
||||
**Nothing writes a problem document onto a committed response.** `WebStreamTerminationMapper`
|
||||
branches on whether any byte has been written. Before commit, an RFC 9457 problem with a real
|
||||
status; after, a terminal record. Attempting both produces a body that is half stream and half JSON,
|
||||
which no client parses and every proxy caches as a success.
|
||||
|
||||
**Positions are monotonic, and it is enforced.** `WebStreamEvidence.recordDelivered` refuses a
|
||||
repeated or regressing position. A client deduplicating on position would silently drop the second
|
||||
item.
|
||||
|
||||
**A slow consumer is disconnected, not buffered.** `WebStreamPolicy.maxBufferedItems` is a hard
|
||||
bound. Backpressure protects the reactive pipeline; it does not protect the server's heap from a
|
||||
consumer that reads slowly for an hour.
|
||||
|
||||
**Every stream is in a registry, and shutdown drains it.** A node with a hundred open streams and no
|
||||
other traffic looks idle by request rate. `WebStreamDrainCoordinator` stops accepting first, asks
|
||||
clients to reconnect, and only then forces the remainder — because a client whose socket is cut
|
||||
retries immediately, and if every socket is cut at once, every client retries at once.
|
||||
|
||||
**The web module stores no durable history.** `WebStreamReplaySource` is an interface this module
|
||||
implements nowhere. An expired cursor raises `ReplayCursorExpiredException` rather than resuming from
|
||||
the oldest retained position, because that delivers a stream with a hole the client cannot see.
|
||||
|
||||
**The replay-to-live seam is watched.** `GapAndDuplicateGuard` detects both directions. Neither is
|
||||
visible in either half on its own.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Clients must handle three outcomes. A client that treats a closed connection as completion will be
|
||||
wrong, and no server change can fix that for it.
|
||||
- An expired `Last-Event-ID` costs the client a full re-read. That is the honest answer.
|
||||
- JSON-seq is preferred over NDJSON where truncation matters: its separator comes first, so a parser
|
||||
resynchronises at the next record. NDJSON's delimiter is the thing that gets truncated away.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Emit a problem document when a stream fails after commit.** Rejected: the body becomes
|
||||
unparseable and the 200 is cached.
|
||||
- **Resume from the oldest retained position when the cursor expires.** Rejected: positions are
|
||||
contiguous from where the replay started, so nothing in the data says events are missing.
|
||||
- **Store replay history in the web module.** Rejected: a second source of truth that drifts
|
||||
invisibly.
|
||||
- **Unbounded buffering for slow consumers.** Rejected: it moves the client's slowness into the
|
||||
server's heap.
|
||||
@@ -0,0 +1,66 @@
|
||||
# ADR-WEB-ADV-002: Virtual threads change scheduling, not the concurrency budget
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-25
|
||||
- Scope: `adapter:inbound:web` — `advanced.virtualthread`, `advanced.blockingbridge`
|
||||
|
||||
## Context
|
||||
|
||||
Advanced Task 2 offers a virtual-thread executor for MVC; Task 3 offers a bounded blocking bridge
|
||||
for WebFlux.
|
||||
|
||||
A platform-thread MVC deployment has an implicit concurrency limit — the thread pool — and that
|
||||
limit is usually what has been protecting the database pool, the outbound HTTP bulkhead and every
|
||||
downstream service from the full arrival rate. Nobody wrote it down as an admission policy; it was a
|
||||
side effect of the pool size.
|
||||
|
||||
Switching to virtual threads deletes that limit without deleting anything that depended on it.
|
||||
|
||||
## Decision
|
||||
|
||||
**An explicit admission limit is required when virtual threads are enabled.**
|
||||
`VirtualThreadProfile` refuses construction without one. Without it the deployment accepts every
|
||||
arrival, queues all of them on the downstream budgets, and times out work that would have succeeded
|
||||
had it been refused. The load that used to be shed at the front door is shed at the back, after the
|
||||
cost of accepting it.
|
||||
|
||||
**The limit bounds concurrent use cases, not threads.** `VirtualThreadAdmissionGuard` is a fair
|
||||
semaphore, not a pool. Bounding threads would put the waiting back and throw away what virtual
|
||||
threads bought. Ten thousand virtual threads may exist while a hundred hold permits.
|
||||
|
||||
**The downstream budgets are carried in the profile and stated as unchanged.** The whole point is
|
||||
that they did not grow. `admissionFitsDownstreamBudgets()` reports when the admission limit exceeds
|
||||
them, without refusing — a deployment can legitimately admit more than its pool when the work is not
|
||||
all database-bound, and that should be a choice rather than an accident.
|
||||
|
||||
**Blocking offloads are registered, bounded and timed out.** `boundedElastic()` is available from
|
||||
anywhere and unbounded in practice, so a controller that calls it has silently opted the whole
|
||||
application into an unbounded pool. `BlockingBridgeProfile` names the operations permitted to
|
||||
offload; `BlockingBridgeBudget` bounds the concurrency and refuses a caller that cannot get a slot
|
||||
in time, because otherwise a slow dependency's callers accumulate until the heap does and the fast
|
||||
dependencies starve behind them.
|
||||
|
||||
**Pinning is observed, not assumed away.** `VirtualThreadProfile.requiredObservations()` lists what
|
||||
has to be watched — `jdk.VirtualThreadPinned` above all. A synchronized block held across a blocking
|
||||
call pins the carrier thread, the carrier pool is bounded by CPU count, and enough pinned carriers is
|
||||
a deadlock a thread dump does not obviously show.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Enabling virtual threads is a two-part change: the executor and the admission limit. The profile
|
||||
will not let it be one.
|
||||
- Refusals rise under load, and that is correct. A request refused in a millisecond is better for
|
||||
the client than the same request accepted and timed out thirty seconds later behind a full pool.
|
||||
An operator seeing 503s climb should read them as the limit working.
|
||||
- `VirtualThreadAdmissionGuard.peakActive()` exists so a load test can assert the limit was applied.
|
||||
It is invisible from throughput, which is why a load test that only measures throughput would pass
|
||||
with the guard removed.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Enable virtual threads and raise the downstream budgets to match.** Rejected: the budgets are
|
||||
sized to what the dependencies can serve, not to what the web tier can accept.
|
||||
- **Bound the virtual threads themselves with a fixed-size executor.** Rejected: that is a platform
|
||||
thread pool with extra steps.
|
||||
- **Let controllers call `boundedElastic()` directly.** Rejected: every such call site is invisible
|
||||
until the pool is the thing consuming the heap.
|
||||
@@ -0,0 +1,55 @@
|
||||
# ADR-WEB-ADV-003: OpenAPI 3.2 is generated in parallel and stays experimental
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-25
|
||||
- Scope: `adapter:inbound:web` — `advanced.openapi`
|
||||
|
||||
## Context
|
||||
|
||||
Advanced Task 17 adds an OpenAPI 3.2 generation lane beside the Stable 3.1.2 snapshot.
|
||||
|
||||
Generating 3.2 is cheap. Adopting it is not, and the two get conflated because the generated
|
||||
document looks fine. The value of an API description is entirely in what consumes it, and a document
|
||||
in a version a client generator does not fully understand produces a client that compiles and is
|
||||
wrong — which is worse than no document at all.
|
||||
|
||||
## Decision
|
||||
|
||||
**3.1.2 remains the release artifact.** `OpenApiVersionLane.STABLE_3_1.releaseArtifact()` is true and
|
||||
`EXPERIMENTAL_3_2`'s is false. This is a property of the type, not a configuration setting.
|
||||
|
||||
**Generating 3.2 must not change the 3.1 snapshot.** Both are produced from the same model, so a
|
||||
contributor that mutates it on the way to 3.2 changes the artifact that is actually shipped —
|
||||
silently, and only when the experimental lane runs. `OpenApi32CompatibilityReport` compares the
|
||||
snapshot hash before and after and makes a difference a promotion blocker.
|
||||
|
||||
**Four kinds of tool are checked separately.** A parser reports structural errors; a linter applies
|
||||
style rules and accepts documents a parser rejects; a generator produces client code, and this is
|
||||
where an unsupported construct surfaces — not as an error but as a method with the wrong signature;
|
||||
a compile of that generated code is the only step that catches it. "OpenAPI 3.2 works" is not a
|
||||
statement anybody can make. "This document is read correctly by these four tools at these versions"
|
||||
is.
|
||||
|
||||
**Promotion requires an accepted ADR regardless of how green the matrix is.**
|
||||
`promotionBlockers(false)` always contains that blocker. A machine-checkable matrix cannot decide
|
||||
whether the consumer population is ready.
|
||||
|
||||
**Streaming description differences are reported separately.** They are the substantive difference
|
||||
between the two versions for this application, and folding them into a pass/fail hides what
|
||||
changed.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The 3.2 document is published as an artifact of the experimental workflow, never of the release
|
||||
workflow.
|
||||
- A client generator that only understands 3.1 is unaffected, which is the point.
|
||||
- Adopting 3.2 later is a documented decision with a named consumer matrix behind it.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Switch to 3.2 and keep a 3.1 downgrade.** Rejected: the downgrade is lossy in exactly the
|
||||
constructs 3.2 was wanted for, so it would ship a description that is wrong for both audiences.
|
||||
- **Generate only 3.2 and let consumers cope.** Rejected: the failure mode is a generated client
|
||||
that compiles and misbehaves.
|
||||
- **Skip the client-compile step in the matrix.** Rejected: it is the only one that catches the
|
||||
failure the others miss.
|
||||
@@ -0,0 +1,56 @@
|
||||
# ADR-WS-001: The WebSocket platform ships as packages in one leaf, with machine-checked boundaries
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-25
|
||||
- Scope: `:adapter:inbound:websocket`
|
||||
|
||||
## Context
|
||||
|
||||
The realtime connection platform design models itself as eighteen Gradle modules under
|
||||
`modules/websocket`, each with a declared purity grade and a declared set of allowed dependencies.
|
||||
This repository's `src/config/architecture/modules.json` is a fail-closed registry that owns the
|
||||
leaf list; adding eighteen leaves is a registry change of a size that needs its own decision, and
|
||||
HARD-STOP #5 forbids doing it implicitly.
|
||||
|
||||
Three earlier platforms in this repository — JPA, GraphQL, and the HTTP platform — met the same
|
||||
situation and resolved it the same way.
|
||||
|
||||
## Decision
|
||||
|
||||
The eighteen design modules ship as packages inside the single registered leaf. `WebSocketStableModule`
|
||||
declares each one's package, purity grade and exact allowed edges, and `WebSocketModuleBoundaryTest`
|
||||
scans the production tree and fails when the declaration and the tree disagree in either direction.
|
||||
|
||||
Three deviations from the design's module map were forced by the check and are recorded in
|
||||
`docs/websocket/repository-adaptation.md`: `WebSocketSubprotocolName` moved to `core` and the codec
|
||||
moved to its own FRAMEWORK_BOUND module, both to avoid cycles the design's placement created here;
|
||||
and the `budget -> core` edge was inverted because `budget` imports nothing from `core`.
|
||||
|
||||
## Consequences
|
||||
|
||||
**The boundary is enforced, not documented.** Six violations were caught during implementation that
|
||||
a document would not have: two would-be cycles, a duplicate module declaration where two ids claimed
|
||||
one package, and three undeclared edges. The duplicate is the instructive one — with two ids on one
|
||||
package, ownership depends on iteration order and one module's rules silently apply to nothing. A
|
||||
guard against it is now part of the boundary test.
|
||||
|
||||
**The detector had a hole.** Its framework-import list named `com.fasterxml` (Jackson 2) and not
|
||||
`tools.jackson` (Jackson 3), which is what Spring 7 actually uses — so a CORE module could have
|
||||
imported a mapper unnoticed. Fixed here and in the HTTP platform, which shared the list.
|
||||
|
||||
**Promotion stays cheap.** Each enum constant is already shaped like a leaf specification, so
|
||||
splitting one out later is a registry edit rather than an archaeology exercise.
|
||||
|
||||
**The design's own rules were kept where they cost something.** `core` names no framework, so the
|
||||
same decisions serve both runtimes and are testable without a server; no Java class name reaches the
|
||||
wire; the payload is an encoded string rather than a map; and handlers are given no way to write,
|
||||
which is what makes ordering and backpressure guarantees rather than conventions.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Register eighteen leaves.** Faithful to the design and a large change to a fail-closed registry
|
||||
for a platform that ships as one artifact either way. Rejected as disproportionate; the boundary
|
||||
test provides the property the modules were for.
|
||||
|
||||
**Ship the modules as packages with no enforcement.** Cheapest, and it makes the boundary a claim.
|
||||
The six violations found during implementation are the argument against it.
|
||||
@@ -0,0 +1,60 @@
|
||||
# ADR-WS-002: Resume and cluster state are caches, and are treated as caches
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-25
|
||||
- Scope: `adapter:inbound:websocket` — `advanced.resume`, `advanced.cluster`, `advanced.presence`
|
||||
|
||||
## Context
|
||||
|
||||
Advanced Tasks 2–8 add three things that all look like state and are not: a resume token that says
|
||||
where a client got to, a cluster index that says which node holds a session, and a presence summary
|
||||
derived from that index.
|
||||
|
||||
Each is a statement about the past. The resume token was minted before the disconnect; the index
|
||||
entry was written by a node that may since have died; presence is a read of the index and inherits
|
||||
everything wrong with it. The failure this ADR exists to prevent is treating any of them as current
|
||||
fact, because each reads as one at the call site.
|
||||
|
||||
## Decision
|
||||
|
||||
**Resume is bounded by what the replay store actually holds, not by what the token claims.**
|
||||
`ResumeCoordinator` consults `ReplayAvailability` before honouring a position. A token that names a
|
||||
position the store has evicted produces a resynchronise, not a gap-filled stream. The alternative —
|
||||
trusting the token — silently delivers a stream with a hole in it, which is worse than an explicit
|
||||
resynchronise because the client believes it is complete.
|
||||
|
||||
**Cluster index entries carry an observation time and are checked against it on every read.**
|
||||
`ExternalSessionSummary.staleAt` exists so that "the index says edge-2" cannot be used without also
|
||||
answering "as of when". An entry whose node stopped reporting is not evidence that the node holds
|
||||
the session.
|
||||
|
||||
**Durable fan-out is deduplicated by stream position, not by message id.** At-least-once is the
|
||||
contract, so redelivery is normal operation: a redeploy, a slow consumer or a broker rebalance all
|
||||
produce it. `FanoutDeduplicator` keys on `(stream, position)` and advances a high-water mark under
|
||||
`compute`, so two consumer threads cannot both deliver the same position.
|
||||
|
||||
**Presence has four states, not two.** `OFFLINE` is a reported fact; `STALE` is the absence of one.
|
||||
Collapsing them reports every user as disconnected during a Redis partition, when what happened is
|
||||
that the index went dark and the connections are fine.
|
||||
|
||||
**Nothing security-relevant may depend on presence.** An attacker who can make a node stop reporting
|
||||
can move the platform's belief about who is present. Presence answers "show a green dot".
|
||||
|
||||
## Consequences
|
||||
|
||||
- A resume that cannot be honoured is visible to the client as a resynchronise. Clients must
|
||||
implement one; there is no mode in which the platform silently pretends.
|
||||
- Every read of the cluster index needs a clock. This is deliberate friction.
|
||||
- `PresenceSummary.classify` refuses an idle window at or past the stale window, because otherwise
|
||||
`IDLE` is unreachable and the caller believes it has a four-state model when it has three.
|
||||
- Fan-out envelopes carry a bounded reference and the catalog-encoded document, never a business
|
||||
object. A rolling deploy has two versions of the code reading the same envelope.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Trust the resume token.** Rejected: it makes a gap indistinguishable from a complete stream.
|
||||
- **Deduplicate by message id.** Rejected: a broker that redelivers may re-mint ids, and a producer
|
||||
that retries certainly does. Position is the property the ordering actually has.
|
||||
- **A single `online` boolean.** Rejected for the partition case above.
|
||||
- **Write presence separately from the session index.** Rejected: two sources of truth for "who is
|
||||
connected" drift, and the drift is invisible — both look plausible and nothing reconciles them.
|
||||
@@ -0,0 +1,74 @@
|
||||
# ADR-WS-003: STOMP is an Advanced adapter with a declared destination catalog
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-25
|
||||
- Scope: `adapter:inbound:websocket` — `advanced.stomp`, `advanced.stomp.rabbit`
|
||||
|
||||
## Context
|
||||
|
||||
Advanced Tasks 9–13 add STOMP 1.2 alongside the platform's own protocol, plus a RabbitMQ broker
|
||||
relay and cross-node user destinations.
|
||||
|
||||
This leaf already ships an older STOMP-over-SockJS channel (`stomp`, gated on
|
||||
`ca-skeleton.websocket.enabled`). Two `@EnableWebSocketMessageBroker` configurations in one context
|
||||
do not conflict loudly — both contribute a configurer, both call `configureMessageBroker`, and the
|
||||
broker that results is whichever ran last. Nothing errors and nothing logs.
|
||||
|
||||
STOMP also brings a destination model that is a free string from the client. Without a catalog, the
|
||||
set of reachable destinations is whatever the broker accepts, which for the simple broker is every
|
||||
string.
|
||||
|
||||
## Decision
|
||||
|
||||
**The Advanced adapter is its own module (`advanced-stomp`), separate from `advanced`.** It is the
|
||||
one Advanced capability that cannot be pure — STOMP here *is* the Spring Messaging types — and
|
||||
folding it into `advanced` would relax that module's purity for every capability in it.
|
||||
|
||||
**The relay is a further module (`advanced-stomp-rabbit`).** The adapter parses a protocol; the
|
||||
relay opens a TCP connection to somebody else's broker and makes every delivery depend on it.
|
||||
Different blast radius, different decision, different module.
|
||||
|
||||
**Destinations are declared, per operation.** `StompDestinationCatalog` maps `(operation,
|
||||
destination)` to a required permission. Undeclared is refused. `SUBSCRIBE` and `SEND` are separate
|
||||
declarations, because reading a feed and publishing into it are different rights.
|
||||
|
||||
**The authorization decision is a value, not an interceptor method.** `StompAuthorizationPolicy`
|
||||
returns a `StompAuthorizationDecision`; `StompSecurityInterceptor` only extracts and enforces. A rule
|
||||
reachable only through a `MessageChannel` gets tested for the cases somebody built a channel for.
|
||||
|
||||
**Only one STOMP runtime may run.** `StompBrokerExclusivity` fails the context when both channels
|
||||
are enabled, when both brokers are, or when the adapter is enabled with no broker behind it.
|
||||
|
||||
**A `RECEIPT` is never promoted to a commit.** `StompEvidence` has six stages and
|
||||
`StompAckPolicy.evidenceForReceipt()` is fixed at `PROTOCOL_RECEIPT`. The receipt is written by the
|
||||
protocol layer, which knows nothing about whether the work succeeded.
|
||||
|
||||
**The simple broker declares what it cannot do.** `SimpleBrokerProfile` cannot be constructed
|
||||
claiming cluster support or durable acks, and refuses activation outside local/test — in a
|
||||
multi-node deployment it does not error, it delivers to whichever fraction of users is on the
|
||||
publishing node.
|
||||
|
||||
**Unresolved user destinations are broadcast once and then dead-lettered.**
|
||||
`MultiNodeUserDestination` distinguishes a message that arrived *via* the broadcast from one that did
|
||||
not. Without that, every node rebroadcasts every unresolvable message on receipt.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Enabling Advanced STOMP requires disabling the legacy channel. There is no migration path that
|
||||
runs both; the exclusivity check makes that explicit at startup rather than at 3am.
|
||||
- A deployment must write its own catalog. There is deliberately no default: an empty one refuses
|
||||
every frame and reads as a broken adapter, and a non-empty one publishes destinations nobody chose.
|
||||
- The relay's cost is one broker connection per authenticated session plus one system connection.
|
||||
`brokerConnectionsFor` exists so this is computed before the first outage.
|
||||
- User-destination metrics are tagged with `UserDestinationAction`, never the destination — a user
|
||||
destination contains a user identifier by construction.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Extend the existing `stomp` package.** Rejected: it is Stable, and WS-ARCH-6 forbids a Stable
|
||||
module naming an Advanced one. Making the legacy channel profile-driven would have required that
|
||||
edge.
|
||||
- **One `advanced-stomp` module including the relay.** Rejected: the relay is a separate operational
|
||||
decision and deserves to be refusable on its own.
|
||||
- **Allow undeclared destinations with a wildcard permission.** Rejected: the wildcard becomes the
|
||||
default and the catalog becomes documentation.
|
||||
@@ -5,7 +5,7 @@
|
||||
# split into capability artifacts.
|
||||
# Update only after review with:
|
||||
# ./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange
|
||||
# types: 398
|
||||
# types: 408
|
||||
dev.caskeleton.adapter.inbound.graphql.HealthGraphqlController
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlAdminPrincipal
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAdminAuthorization
|
||||
@@ -100,6 +100,7 @@ dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketAdmission
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketAuthentication
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketCapability
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketErrorMapper
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketHandlerFactory
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketProperties
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketRoutePolicy
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketRouteRejectedException
|
||||
@@ -110,8 +111,16 @@ dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketCloseRe
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketCredentialExpiry
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketPrincipal
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketRevocationSignal
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryAllowlist
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryArgumentPolicy
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryExposure
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryExposureRejectedException
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryExposureValidator
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryPaginationPolicy
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryProjectionPolicy
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseAdmission
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseConnectionPolicy
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseHandlerFactory
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseHeartbeat
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseProperties
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseRejectedException
|
||||
@@ -134,6 +143,7 @@ dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscription
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionTermination
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketAdmission
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketConnectionId
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketHandlerFactory
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketLifecycle
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProperties
|
||||
dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProtocol
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# root yet.
|
||||
# Update only after review with:
|
||||
# ./gradlew :adapter:outbound:persistence-jpa:updateJpaApiSurface -PapproveJpaApiSurfaceChange
|
||||
# types: 332
|
||||
# types: 338
|
||||
dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName
|
||||
dev.caskeleton.adapter.outbound.persistence.api.capability.CapabilitySupport
|
||||
dev.caskeleton.adapter.outbound.persistence.api.capability.JpaCapability
|
||||
@@ -165,6 +165,9 @@ dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyResponseObjec
|
||||
dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyStoreAdapter
|
||||
dev.caskeleton.adapter.outbound.persistence.idempotency.entity.IdempotencyRecordEntity
|
||||
dev.caskeleton.adapter.outbound.persistence.idempotency.mapper.IdempotencyRecordEntityMapper
|
||||
dev.caskeleton.adapter.outbound.persistence.liveevent.JpaLiveEventReplayAdapter
|
||||
dev.caskeleton.adapter.outbound.persistence.liveevent.LiveEventJpaRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.liveevent.entity.LiveEventEntity
|
||||
dev.caskeleton.adapter.outbound.persistence.lock.DistributedLockPersistenceConfig
|
||||
dev.caskeleton.adapter.outbound.persistence.lock.LockRegistryDistributedLockAdapter
|
||||
dev.caskeleton.adapter.outbound.persistence.lock.LockSettings
|
||||
@@ -235,6 +238,9 @@ dev.caskeleton.adapter.outbound.persistence.observation.JpaTransactionObservatio
|
||||
dev.caskeleton.adapter.outbound.persistence.observation.LowCardinality
|
||||
dev.caskeleton.adapter.outbound.persistence.observation.MicrometerQueryObservation
|
||||
dev.caskeleton.adapter.outbound.persistence.observation.SqlDiagnosticRedactor
|
||||
dev.caskeleton.adapter.outbound.persistence.operation.DurableOperationJpaRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.operation.DurableOperationStoreAdapter
|
||||
dev.caskeleton.adapter.outbound.persistence.operation.entity.DurableOperationEntity
|
||||
dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.outbox.OutboxEventJpaRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.outbox.OutboxReaper
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
44ba9931722364a53fcb3b5f31a1d539eabcaf42db775f5a33fb558f558c7504 README.md
|
||||
d064f0ac6c3be0e5c76ef22454db2a97e1d78ed287bd22f4c125f19aba3ad8e3 VALIDATION.md
|
||||
1ef15812f33dc998a6332b87523ed5942ba46d79d984a0ca776b05bb9247a06a docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md
|
||||
5ae70b53e22cdb852b2bb0df171dec868bfe99b15bb8e71fb2b0b3431cd7e2cd docs/superpowers/plans/2026-08-12-graphql-api-execution-platform-implementation-plan.md
|
||||
8d0203203f6bfe4b2e18625eff23bb308ba6454703a4ca4cd3236dab31ecafc3 docs/superpowers/specs/2026-08-12-graphql-api-execution-platform-design.md
|
||||
8048fe6a536de67d2cf5b0df05d35128f2c68ba8f0dd615831b40430fc76277b validate_graphql_docs.py
|
||||
@@ -0,0 +1,43 @@
|
||||
# GraphQL Superpowers 설계 패키지
|
||||
|
||||
이 패키지는 `GraphQL API 실행 플랫폼 심층 리서치`를 구현 기준선으로 변환한 설계서와 실행 계획서다.
|
||||
|
||||
## 문서
|
||||
|
||||
- `docs/superpowers/specs/2026-08-12-graphql-api-execution-platform-design.md`
|
||||
- Stable·Advanced 전체 아키텍처, 공개 계약, 경계, 실패 의미론, 테스트와 지원 등급
|
||||
- 입력 심층 리서치 원문을 추적 부록으로 포함
|
||||
- `docs/superpowers/plans/2026-08-12-graphql-api-execution-platform-implementation-plan.md`
|
||||
- Stable 구현 Task 1–48
|
||||
- `docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md`
|
||||
- Stable Release Gate 이후 실행하는 Advanced·Experimental Task 1–19
|
||||
- `VALIDATION.md`
|
||||
- 정적 검증 결과와 검증 범위
|
||||
- `validate_graphql_docs.py`
|
||||
- 패키지 내부 문서 재검증 스크립트
|
||||
- `MANIFEST.sha256`
|
||||
- 패키지 파일 무결성 목록
|
||||
|
||||
## 구현 순서
|
||||
|
||||
```text
|
||||
Stable Task 1–48
|
||||
→ Stable Release Gate
|
||||
→ Advanced Task 1–19
|
||||
→ Capability별 Promotion Gate
|
||||
```
|
||||
|
||||
## 명시적 전제
|
||||
|
||||
```text
|
||||
Java 21
|
||||
Gradle Kotlin DSL
|
||||
Spring Boot 4.1 BOM
|
||||
Spring for GraphQL 2.0
|
||||
Boot-managed GraphQL Java v25 계열
|
||||
Stable module root: modules/graphql
|
||||
Advanced module root: modules/graphql-advanced
|
||||
Root package: io.backend.skeleton.graphql
|
||||
```
|
||||
|
||||
실제 저장소에 적용할 때 기존 package·version catalog·module naming에 맞춰 경로만 조정하고, 문서의 공개 계약·불변 조건·테스트 의미는 유지한다.
|
||||
@@ -0,0 +1,98 @@
|
||||
# GraphQL Superpowers 문서 정적 검증 결과
|
||||
|
||||
- **검증 시각 기준:** 2026-08-12
|
||||
- **검증 대상:** 설계서 1개, Stable 구현 계획서 1개, Advanced·Experimental 확장 계획서 1개
|
||||
- **검증 명령:** `python3 validate_graphql_docs.py`
|
||||
- **결과:** **PASS**
|
||||
- **실행 검사:** 1,475
|
||||
- **통과:** 1,475
|
||||
- **실패:** 0
|
||||
|
||||
## 문서 규모
|
||||
|
||||
| 문서 | 행 수 | 크기 |
|
||||
|---|---:|---:|
|
||||
| GraphQL API 실행 플랫폼 설계서 | 2,553 | 93,359 bytes |
|
||||
| Stable 구현 계획서 | 4,560 | 209,041 bytes |
|
||||
| Advanced 확장 계획서 | 1,976 | 105,717 bytes |
|
||||
|
||||
## 계획 구조
|
||||
|
||||
| 항목 | Stable | Advanced |
|
||||
|---|---:|---:|
|
||||
| Task 수 | 48 | 19 |
|
||||
| Create 경로 수 | 227 | 113 |
|
||||
| Task 번호 연속성 | PASS | PASS |
|
||||
| 모든 Task의 `Files`·`Interfaces` | PASS | PASS |
|
||||
| 모든 Task의 Implementation Requirements | PASS | PASS |
|
||||
| 모든 Task의 Step 1–5 | PASS | PASS |
|
||||
| 실패·통과 예상 결과 | PASS | PASS |
|
||||
| Task별 Git commit 명령 | PASS | PASS |
|
||||
| Create 경로 중복 | 없음 | 없음 |
|
||||
| Stable·Advanced 경로 충돌 | 없음 | 없음 |
|
||||
|
||||
## 핵심 계약 검증
|
||||
|
||||
```text
|
||||
SDL-first external contract
|
||||
Single Executable Schema Stable default
|
||||
HTTP POST Stable profile
|
||||
application/graphql-response+json preferred
|
||||
Validation 이후 Field Error·Partial Data는 HTTP 200
|
||||
Draft 294는 Stable에서 제외
|
||||
JPA Entity·MongoDB Document 직접 노출 금지
|
||||
GraphQL Multipart Upload 미지원·Fileserver 사용
|
||||
request-wide database transaction 금지
|
||||
DataLoader request scope
|
||||
Finite Fetch Profile
|
||||
HMAC-signed cursor
|
||||
Mutation idempotency·expected version 분리
|
||||
Parser·shape·complexity·runtime response budget
|
||||
Actor·Field·Object·Tenant authorization
|
||||
Low-cardinality observability
|
||||
Stable/Advanced dependency isolation
|
||||
Persisted Operation·WebSocket·SSE·Federation 분리
|
||||
RSocket·HTTP GET·Incremental Delivery Experimental
|
||||
```
|
||||
|
||||
위 계약은 설계서와 계획서의 필수 문자열·모듈 경로·Task별 파일·테스트를 대조해 검증했습니다.
|
||||
|
||||
## 입력 리서치 추적성
|
||||
|
||||
- 첨부된 `GraphQL API 실행 플랫폼 심층 리서치` 원문 전체가 설계서의 `부록 B`에 포함되어 있습니다.
|
||||
- 설계 본문은 원문의 용어와 결론을 유지하면서 구현 판단을 Stable·Advanced·Experimental로 고정합니다.
|
||||
- 설계서와 입력 원문의 exact text 포함 검사를 별도로 통과했습니다.
|
||||
|
||||
## 패키지 검증 항목
|
||||
|
||||
```text
|
||||
문서 파일 존재
|
||||
Markdown code fence 균형
|
||||
Task 1–48 / 1–19 연속성
|
||||
Task별 테스트·명령·commit
|
||||
정확한 Create 경로
|
||||
Placeholder 금지
|
||||
Stable module에 WebSocket·Federation·Persisted Operation 경로 부재
|
||||
Advanced module에 feature flag와 capability 경로 존재
|
||||
금지 API pattern 부재
|
||||
문서 SHA-256 계산
|
||||
```
|
||||
|
||||
## 검증 범위의 한계
|
||||
|
||||
현재 PASS는 **문서의 정적 구조, 요구사항 추적성, 내부 계약과 실행 계획의 완결성**을 의미합니다. 실제 Backend Skeleton 저장소가 입력으로 제공되지 않았으므로 다음은 실행하지 않았습니다.
|
||||
|
||||
```text
|
||||
Gradle configuration·compile
|
||||
Spring Boot ApplicationContext 기동
|
||||
SchemaMappingInspector 실제 결과
|
||||
GraphQlTester HTTP·WebFlux contract
|
||||
JPA·MongoDB statement/query-count integration
|
||||
query bomb·complexity load test
|
||||
Virtual Thread·event-loop blocking test
|
||||
WebSocket·SSE soak test
|
||||
Federation composition·router integration
|
||||
actual Git commit
|
||||
```
|
||||
|
||||
실제 구현에서는 Stable Task 1–48을 먼저 수행해 Stable Release Gate를 통과한 뒤 Advanced Task 1–19를 시작해야 합니다.
|
||||
+1976
File diff suppressed because it is too large
Load Diff
+4560
File diff suppressed because it is too large
Load Diff
+2553
File diff suppressed because it is too large
Load Diff
+249
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import hashlib
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
DESIGN = ROOT / "docs/superpowers/specs/2026-08-12-graphql-api-execution-platform-design.md"
|
||||
STABLE = ROOT / "docs/superpowers/plans/2026-08-12-graphql-api-execution-platform-implementation-plan.md"
|
||||
ADVANCED = ROOT / "docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md"
|
||||
|
||||
checks: list[tuple[str, bool, str]] = []
|
||||
|
||||
def check(name: str, condition: bool, detail: str = "") -> None:
|
||||
checks.append((name, bool(condition), detail))
|
||||
|
||||
def read(path: Path) -> str:
|
||||
check(f"file exists: {path.name}", path.exists(), str(path))
|
||||
return path.read_text(encoding="utf-8") if path.exists() else ""
|
||||
|
||||
design = read(DESIGN)
|
||||
stable = read(STABLE)
|
||||
advanced = read(ADVANCED)
|
||||
|
||||
# Basic document integrity
|
||||
check("design line floor", len(design.splitlines()) >= 2000, str(len(design.splitlines())))
|
||||
check("stable plan line floor", len(stable.splitlines()) >= 4000, str(len(stable.splitlines())))
|
||||
check("advanced plan line floor", len(advanced.splitlines()) >= 1500, str(len(advanced.splitlines())))
|
||||
for label, text in [("design", design), ("stable", stable), ("advanced", advanced)]:
|
||||
check(f"{label} code fences balanced", text.count("```") % 2 == 0, str(text.count("```")))
|
||||
for marker in ["TODO", "TBD", "FIXME", "implement later", "fill in details"]:
|
||||
check(f"{label} no placeholder {marker}", marker.lower() not in text.lower())
|
||||
|
||||
# Design required sections and source traceability
|
||||
required_design_terms = [
|
||||
"# GraphQL API 실행 플랫폼 설계서",
|
||||
"GraphQL Platform owns",
|
||||
"Domain/Application owns",
|
||||
"G1 Standard GraphQL API",
|
||||
"G2 Advanced Execution",
|
||||
"G3 GraphQL Extension",
|
||||
"G4 Admin Plane",
|
||||
"SDL",
|
||||
"September 2025",
|
||||
"application/graphql-response+json",
|
||||
"HTTP `200`",
|
||||
"GraphQlRequestContext",
|
||||
"DataLoader",
|
||||
"GraphQlFetchProfile",
|
||||
"HMAC",
|
||||
"Idempotency",
|
||||
"Partial Data",
|
||||
"Persisted Operation",
|
||||
"Subscription",
|
||||
"Federation",
|
||||
"GraphQL Multipart Upload",
|
||||
"Fileserver",
|
||||
"부록 B. 입력 심층 리서치 원문",
|
||||
"# GraphQL API 실행 플랫폼 심층 리서치",
|
||||
]
|
||||
for term in required_design_terms:
|
||||
check(f"design contains {term}", term in design)
|
||||
|
||||
# Critical design invariants
|
||||
critical_pairs = [
|
||||
("field error uses HTTP 200", "field error" in design.lower() and "HTTP `200`" in design),
|
||||
("no draft 294 stable", "294" in design and "Stable" in design),
|
||||
("dataloader request scope", "request" in design.lower() and "DataLoader" in design),
|
||||
("cursor HMAC", "Cursor" in design and "HMAC" in design),
|
||||
("no multipart upload", "Multipart Upload" in design and "Fileserver" in design),
|
||||
("single schema default", "Single Executable Schema" in design),
|
||||
("request-wide transaction prohibited", "request-wide" in design.lower() and "transaction" in design.lower()),
|
||||
("entity/document boundary", "JPA Entity" in design and "MongoDB Document" in design),
|
||||
]
|
||||
for name, condition in critical_pairs:
|
||||
check(name, condition)
|
||||
|
||||
# Plan headers and global constraints
|
||||
stable_header_terms = [
|
||||
"# GraphQL API 실행 플랫폼 Implementation Plan",
|
||||
"REQUIRED SUB-SKILL",
|
||||
"**Goal:**",
|
||||
"**Architecture:**",
|
||||
"**Tech Stack:**",
|
||||
"## Global Constraints",
|
||||
"Stable Task",
|
||||
]
|
||||
advanced_header_terms = [
|
||||
"# GraphQL Advanced Capability Expansion Implementation Plan",
|
||||
"REQUIRED SUB-SKILL",
|
||||
"backend.graphql.advanced.*",
|
||||
"Stable 구현 계획 Task `1–48`",
|
||||
]
|
||||
for term in stable_header_terms:
|
||||
check(f"stable header contains {term}", term in stable)
|
||||
for term in advanced_header_terms:
|
||||
check(f"advanced header contains {term}", term in advanced)
|
||||
|
||||
# Task sequence and per-task structure
|
||||
def task_sections(text: str) -> list[tuple[int, str]]:
|
||||
matches = list(re.finditer(r"^### Task (\d+): .+$", text, re.MULTILINE))
|
||||
result = []
|
||||
for i, match in enumerate(matches):
|
||||
start = match.start()
|
||||
end = matches[i+1].start() if i+1 < len(matches) else len(text)
|
||||
result.append((int(match.group(1)), text[start:end]))
|
||||
return result
|
||||
|
||||
stable_tasks = task_sections(stable)
|
||||
advanced_tasks = task_sections(advanced)
|
||||
check("stable task count", len(stable_tasks) == 48, str(len(stable_tasks)))
|
||||
check("advanced task count", len(advanced_tasks) == 19, str(len(advanced_tasks)))
|
||||
check("stable task sequence", [n for n, _ in stable_tasks] == list(range(1, 49)))
|
||||
check("advanced task sequence", [n for n, _ in advanced_tasks] == list(range(1, 20)))
|
||||
|
||||
def validate_tasks(label: str, tasks: list[tuple[int, str]]) -> None:
|
||||
required = [
|
||||
"**Files:**",
|
||||
"**Interfaces:**",
|
||||
"**Implementation requirements:**",
|
||||
"**Step 1: Write the failing test**",
|
||||
"**Step 2: Run the focused test and verify the failure**",
|
||||
"**Step 3: Implement the smallest complete production contract**",
|
||||
"**Step 4: Run the focused test and the owning suite**",
|
||||
"**Step 5: Commit the independently reviewable change**",
|
||||
"Expected: FAIL",
|
||||
"Expected: PASS",
|
||||
"git commit -m",
|
||||
]
|
||||
for number, section in tasks:
|
||||
for token in required:
|
||||
check(f"{label} task {number} contains {token}", token in section)
|
||||
check(f"{label} task {number} has test path", "- Test: `" in section)
|
||||
check(f"{label} task {number} has production file", "- Create: `" in section)
|
||||
check(f"{label} task {number} fences balanced", section.count("```") % 2 == 0)
|
||||
check(f"{label} task {number} has gradle test", "./gradlew" in section and ":test" in section)
|
||||
|
||||
validate_tasks("stable", stable_tasks)
|
||||
validate_tasks("advanced", advanced_tasks)
|
||||
|
||||
# Create paths
|
||||
def create_paths(text: str) -> list[str]:
|
||||
return re.findall(r"^- Create: `([^`]+)`$", text, re.MULTILINE)
|
||||
|
||||
stable_paths = create_paths(stable)
|
||||
advanced_paths = create_paths(advanced)
|
||||
check("stable create paths exist", len(stable_paths) >= 150, str(len(stable_paths)))
|
||||
check("advanced create paths exist", len(advanced_paths) >= 80, str(len(advanced_paths)))
|
||||
check("stable create paths unique", len(stable_paths) == len(set(stable_paths)))
|
||||
check("advanced create paths unique", len(advanced_paths) == len(set(advanced_paths)))
|
||||
check("stable and advanced paths disjoint", set(stable_paths).isdisjoint(advanced_paths))
|
||||
for index, path in enumerate(stable_paths, 1):
|
||||
check(f"stable create path {index} exact", "*" not in path and "..." not in path and (path.startswith("modules/graphql/") or path.startswith("build-logic/")))
|
||||
for index, path in enumerate(advanced_paths, 1):
|
||||
check(f"advanced create path {index} exact", "*" not in path and "..." not in path and path.startswith("modules/graphql-advanced/"))
|
||||
|
||||
# Stable/Advanced separation
|
||||
for forbidden in [
|
||||
"modules/graphql/graphql-websocket/",
|
||||
"modules/graphql/graphql-federation/",
|
||||
"modules/graphql/graphql-persisted-operation/",
|
||||
"modules/graphql/graphql-rsocket/",
|
||||
]:
|
||||
check(f"stable excludes {forbidden}", forbidden not in stable)
|
||||
|
||||
for required in [
|
||||
"modules/graphql-advanced/graphql-persisted-operation/",
|
||||
"modules/graphql-advanced/graphql-websocket/",
|
||||
"modules/graphql-advanced/graphql-subscription/",
|
||||
"modules/graphql-advanced/graphql-federation/",
|
||||
"modules/graphql-advanced/graphql-rsocket/",
|
||||
]:
|
||||
check(f"advanced includes {required}", required in advanced)
|
||||
|
||||
# Stable coverage
|
||||
stable_required_terms = [
|
||||
"GraphQlRequestContext",
|
||||
"GraphQlClientPolicy",
|
||||
"GraphQlSchemaContract",
|
||||
"SchemaMappingInspector",
|
||||
"@oneOf",
|
||||
"GraphQlHttpProfile",
|
||||
"application/graphql-response+json",
|
||||
"GraphQlExecutionProfile",
|
||||
"GraphQlWireError",
|
||||
"GraphQlTenantIsolationPolicy",
|
||||
"GraphQlParserLimits",
|
||||
"GraphQlComplexityCalculator",
|
||||
"GraphQlRuntimeBudget",
|
||||
"GraphQlPreparsedCacheKey",
|
||||
"GraphQlBatchPolicy",
|
||||
"GraphQlFetchProfile",
|
||||
"HmacGraphQlCursorCodec",
|
||||
"GraphQlConnection",
|
||||
"GraphQlMutationIdempotencyContext",
|
||||
"GraphQlMetricCardinalityPolicy",
|
||||
"GraphQlPlatformStartupValidator",
|
||||
"GraphQlReleaseGate",
|
||||
]
|
||||
for term in stable_required_terms:
|
||||
check(f"stable coverage {term}", term in stable)
|
||||
|
||||
advanced_required_terms = [
|
||||
"GraphQlPersistedOperation",
|
||||
"GraphQlWebSocketProtocol",
|
||||
"GraphQlSubscriptionBufferPolicy",
|
||||
"GraphQlSubscriptionOrderingProfile",
|
||||
"GraphQlSseConnectionPolicy",
|
||||
"GraphQlReplayPosition",
|
||||
"GraphQlDataLoaderDependencyGraph",
|
||||
"GraphQlFederationEntityKey",
|
||||
"GraphQlFederationCompositionGate",
|
||||
"GraphQlGeneratedSourceBoundary",
|
||||
"GraphQlRepositoryAllowlist",
|
||||
"GraphQlRSocketRoutePolicy",
|
||||
"GraphQlHttpGetOperationPolicy",
|
||||
"GraphQlIncrementalCompatibilityGate",
|
||||
"GraphQlAdvancedReleaseGate",
|
||||
]
|
||||
for term in advanced_required_terms:
|
||||
check(f"advanced coverage {term}", term in advanced)
|
||||
|
||||
# Prohibited API patterns
|
||||
prohibited_patterns = [
|
||||
(r"interface\s+GenericGraphQlRepository", "no generic graphql repository"),
|
||||
(r"public\s+.*\bEntityManager\b", "no public entity manager"),
|
||||
(r"public\s+.*\bMongoTemplate\b", "no public mongo template"),
|
||||
(r"scalar\s+Upload\b", "no upload scalar declaration"),
|
||||
(r"@Transactional\s+.*GraphQL request", "no request-wide transaction implementation"),
|
||||
]
|
||||
for pattern, name in prohibited_patterns:
|
||||
check(name, re.search(pattern, stable, re.IGNORECASE | re.MULTILINE) is None)
|
||||
|
||||
# File hashes can be printed for package evidence
|
||||
for path in [DESIGN, STABLE, ADVANCED]:
|
||||
if path.exists():
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
check(f"sha256 computed: {path.name}", len(digest) == 64, digest)
|
||||
|
||||
failed = [(n, d) for n, ok, d in checks if not ok]
|
||||
print(f"CHECKS={len(checks)}")
|
||||
print(f"PASSED={len(checks)-len(failed)}")
|
||||
print(f"FAILED={len(failed)}")
|
||||
for name, detail in failed:
|
||||
print(f"FAIL: {name}" + (f" :: {detail}" if detail else ""))
|
||||
|
||||
sys.exit(1 if failed else 0)
|
||||
@@ -63,7 +63,7 @@ Root package: `io.backend.skeleton.httpclient` → `dev.caskeleton.adapter.outbo
|
||||
| Design assumption | Repository reality | Adaptation |
|
||||
|---|---|---|
|
||||
| Gradle Kotlin DSL, `build-logic` convention plugin | Groovy DSL, root `build.gradle` conventions, `LockMode.STRICT` dependency locking | Dependencies declared in `src/adapter/outbound/httpclient/build.gradle`; `gradle.lockfile` regenerated. |
|
||||
| Spring Framework 6.2 baseline with 7.0 compatibility | Spring Boot 4.0.0 / Spring Framework 7.0 is the repository baseline | Common code targets the Spring 6.2 **API surface** (no 6.2-only or 7.0-only classes in common packages). The Spring 7 HTTP Service Group integration stays isolated in `…httpclient.spring7`, exactly as the design requires. |
|
||||
| Spring Framework 6.2 baseline with 7.0 compatibility | Spring Boot 4.0.8 / Spring Framework 7.0 is the repository baseline | Common code targets the Spring 6.2 **API surface** (no 6.2-only or 7.0-only classes in common packages). The Spring 7 HTTP Service Group integration stays isolated in `…httpclient.spring7`, exactly as the design requires. |
|
||||
| `settings.gradle.kts` module registration | Fail-closed registry | No registry change; leaf identity, gradle path, allowed dependencies unchanged. |
|
||||
| Design §6.2 grades Apache HttpClient 5 as HTTP/2-capable | Spring's blocking factory drives Apache's **classic** client, which is HTTP/1.1 only; HTTP/2 lives in Apache's async client | `ApacheBlockingTransportProvider` declares HTTP/1.1 and rejects an HTTP/2 profile at startup. Blocking HTTP/2 is served by the JDK transport, measured by `NegotiatedProtocolContractTest`. |
|
||||
| Design §28.1 names WireMock for stateful fixtures | WireMock's Jetty modules bind a different Jetty 12 ABI than the Boot-managed one this module already needs for HTTP/3, and fail at server start | `StatefulUpstream` provides path-keyed stateful responses on the existing fixture server; the WireMock dependency was removed rather than worked around with a shaded jar |
|
||||
|
||||
@@ -95,7 +95,7 @@ Docker-dependent lanes fail closed rather than skipping, matching the existing
|
||||
| Plan assumption | Repository reality | Adaptation |
|
||||
|---|---|---|
|
||||
| Gradle Kotlin DSL, `build-logic` convention plugin, `jpa-library-conventions.gradle.kts` | Groovy DSL, root `src/build.gradle` conventions (spotless google-java-format, checkstyle, SpotBugs + FindSecBugs, ErrorProne, `-Werror`, one-type-per-file), `LockMode.STRICT` dependency locking | Source sets and dependencies declared in `src/adapter/outbound/persistence-jpa/build.gradle`; `gradle.lockfile` regenerated with `resolveAndLockAll --write-locks`. |
|
||||
| Spring Boot 4.1 dependency management, Spring Data JPA 4.1 | Repository baseline is Spring Boot 4.0.0 | Versions are inherited from the repository BOM and never pinned per module, exactly as the plan requires ("do not override Hibernate/Flyway/Hikari versions outside the Boot BOM"). |
|
||||
| Spring Boot 4.1 dependency management, Spring Data JPA 4.1 | Repository baseline is Spring Boot 4.0.8 | Versions are inherited from the repository BOM and never pinned per module, exactly as the plan requires ("do not override Hibernate/Flyway/Hikari versions outside the Boot BOM"). |
|
||||
| Hibernate ORM 7.4 is the Stable provider | Boot 4.0.0 resolves `org.hibernate.orm:hibernate-core:7.1.8.Final` | The *declared* Stable provider baseline of the design stays 7.4 in `HibernateProviderPolicy`; the runtime provider version is read from Hibernate itself and reported. The collection-fetch-pagination gate runs against whatever provider the BOM resolves, and `HibernateProviderPolicy.driftsFromDeclaredBaseline()` makes the difference visible instead of hiding it behind a green check. |
|
||||
| PostgreSQL 16·17·18 Stable matrix | This leaf's existing evidence image is `postgres:16-alpine` | `PostgreSqlVersion` declares exactly PG 16, 17, 18. The default lane runs the repository's existing 16 image; 17 and 18 are selected by `-Pjpa.matrix.versions=16,17,18`, and an unknown or empty selection is an error rather than a skip. |
|
||||
| `settings.gradle.kts` module registration | Fail-closed 19-leaf registry | No registry change: leaf identity, Gradle path, allowed dependencies, and runtime memberships are unchanged. |
|
||||
|
||||
@@ -91,7 +91,7 @@ otherwise. Being on the classpath is not being enabled.
|
||||
|---|---|---|
|
||||
| Gradle Kotlin DSL under `modules/mongodb*` | Groovy DSL, root `build.gradle` conventions, `LockMode.STRICT` locking | Dependencies declared in `src/adapter/outbound/persistence-mongo/build.gradle`; `gradle.lockfile` regenerated. |
|
||||
| `mongodb-spring-boot-starter` is a separate module the app depends on | `modules.json` gives `adapter-outbound-persistence-mongo` `runtime_memberships: []` and does **not** list it among `app-bootstrap`'s allowed dependencies | The `autoconfigure` package stays inside the leaf and registers through the leaf's own `META-INF/spring/…AutoConfiguration.imports`. This differs from the httpclient precedent, where the starter moved to `:app-bootstrap`; here the registry forbids that edge. |
|
||||
| Spring Boot 4.1 / Spring Data MongoDB 5.1 baseline | Repository baseline is Spring Boot 4.0.0 / Spring Data MongoDB 5.0.0 | The platform targets the Spring Data MongoDB **API surface** common to both; no 5.1-only type is referenced. The support matrix records the actual pinned versions. |
|
||||
| Spring Boot 4.1 / Spring Data MongoDB 5.1 baseline | Repository baseline is Spring Boot 4.0.8 / Spring Data MongoDB 5.0.x | The platform targets the Spring Data MongoDB **API surface** common to both; no 5.1-only type is referenced. The support matrix records the actual pinned versions. |
|
||||
| `MongoRetryScope` lives in `mongodb-transaction` | The `mongodb-spring-data` failure translator must classify retry scope, and it cannot depend on `mongodb-transaction` | `MongoRetryScope` lives in `…api.error` (core-api), which both packages already depend on. Same values, same meaning, one legal position in the DAG. |
|
||||
| `mongodb-migration-flamingock` depends on Flamingock | Adding an unvetted external dependency is out of scope for this task, and the design itself requires the public contract not to depend on Flamingock types | The adapter is provider-neutral: it consumes a platform-owned `FlamingockChangeUnitView`. Wiring an actual Flamingock distribution is a one-file change behind that view. |
|
||||
| Testkit as its own Gradle module | The design forbids production modules depending on the testkit | A dedicated `testkit` source set whose output is on the test compile/runtime classpaths only. ArchUnit rule `productionNeverDependsOnTestkit` enforces the direction. |
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
f3e98b6e72a3a43c38799aac7a333387afa12d7d007dc6966e58a3d725c0f4b7 README.md
|
||||
c04f805a3f05bee50cf3d62fc531dfc9a33664ee2621b5281b1f3c0ec0885dca VALIDATION.md
|
||||
27c0ddd7ab0f390fe074f14d0cb14e815c60e7544eabe8aa7a2e4ae462f91cad docs/superpowers/plans/2026-08-13-web-advanced-capabilities-expansion-plan.md
|
||||
c780fb635da38b7a1c2f2f73969e129c5ac72d1852133dcb29fc10701fa453c5 docs/superpowers/plans/2026-08-13-web-inbound-http-api-execution-platform-implementation-plan.md
|
||||
7b82fd9850878a5f43828243ddec992b3fae0066d31eaa898b9e1e13528bace7 docs/superpowers/specs/2026-08-13-web-inbound-http-api-execution-platform-design.md
|
||||
b8c9f6d7ac055653a425f62458ece3c68ee8d7dc48827c7c28266947fadb5daf research/source-web-deep-research.md
|
||||
9af756a602f320abee5f40ad7dd71936644ee28aef30b442e72f85e0b8086d0f validate_web_docs.py
|
||||
@@ -0,0 +1,73 @@
|
||||
# Web Superpowers Package
|
||||
|
||||
Spring Boot 기반 **인바운드 HTTP API 실행 플랫폼 `web`**의 설계, Stable 구현 계획, Advanced 확장 계획, 원본 심층 리서치, 정적 검증 도구를 묶은 패키지입니다.
|
||||
|
||||
## 구성
|
||||
|
||||
```text
|
||||
web-superpowers-package/
|
||||
├── docs/
|
||||
│ └── superpowers/
|
||||
│ ├── specs/
|
||||
│ │ └── 2026-08-13-web-inbound-http-api-execution-platform-design.md
|
||||
│ └── plans/
|
||||
│ ├── 2026-08-13-web-inbound-http-api-execution-platform-implementation-plan.md
|
||||
│ └── 2026-08-13-web-advanced-capabilities-expansion-plan.md
|
||||
├── research/
|
||||
│ └── source-web-deep-research.md
|
||||
├── README.md
|
||||
├── VALIDATION.md
|
||||
├── validate_web_docs.py
|
||||
└── MANIFEST.sha256
|
||||
```
|
||||
|
||||
## 문서 규모
|
||||
|
||||
| 항목 | 규모 |
|
||||
|---|---:|
|
||||
| 설계서 | 1,656행 |
|
||||
| Stable 구현 계획 | 4,762행 / 58 Task |
|
||||
| Advanced 계획 | 1,532행 / 19 Task |
|
||||
| 원본 리서치 | 1,597행 |
|
||||
|
||||
## 핵심 구현 순서
|
||||
|
||||
```text
|
||||
Stable Task 1~58
|
||||
→ Stable Release Gate
|
||||
→ Advanced Task 1~19
|
||||
→ Advanced Promotion Gate
|
||||
```
|
||||
|
||||
Stable 구현 완료 전 Advanced module을 적용하지 않습니다.
|
||||
|
||||
## 정적 검증
|
||||
|
||||
```bash
|
||||
./validate_web_docs.py
|
||||
sha256sum -c MANIFEST.sha256
|
||||
```
|
||||
|
||||
## 실행 방식
|
||||
|
||||
구현 시 `superpowers:subagent-driven-development`를 권장합니다.
|
||||
|
||||
각 Task마다 다음 review를 분리합니다.
|
||||
|
||||
```text
|
||||
1. Specification review
|
||||
2. Code quality·test evidence review
|
||||
```
|
||||
|
||||
## 명시적 가정
|
||||
|
||||
```text
|
||||
Java 21
|
||||
Spring Boot 4.1 BOM
|
||||
Gradle Kotlin DSL
|
||||
root package: io.backend.skeleton.web
|
||||
stable modules: modules/web
|
||||
advanced modules: modules/web-advanced
|
||||
```
|
||||
|
||||
실제 저장소가 포함되지 않았으므로 이 패키지는 설계·계획·정적 검증 산출물이며, Gradle compile과 서버·DB·Redis·Nginx 통합 시험 결과를 포함하지 않습니다.
|
||||
@@ -0,0 +1,193 @@
|
||||
# Web Superpowers 문서 정적 검증 결과
|
||||
|
||||
검증 기준일: 2026-08-13
|
||||
|
||||
## 1. 검증 대상
|
||||
|
||||
| 문서 | 행 수 |
|
||||
|---|---:|
|
||||
| `web-inbound-http-api-execution-platform-design.md` | 1,656 |
|
||||
| `web-inbound-http-api-execution-platform-implementation-plan.md` | 4,762 |
|
||||
| `web-advanced-capabilities-expansion-plan.md` | 1,532 |
|
||||
| 원본 심층 리서치 | 1,597 |
|
||||
|
||||
## 2. 구조 검증
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---:|
|
||||
| 실행 검사 | 1,097 |
|
||||
| 통과 | 1,097 |
|
||||
| 실패 | 0 |
|
||||
| Stable Task | 58 |
|
||||
| Advanced Task | 19 |
|
||||
| Stable Create 경로 | 289 |
|
||||
| Advanced Create 경로 | 94 |
|
||||
| Stable Task 번호 연속성 | PASS |
|
||||
| Advanced Task 번호 연속성 | PASS |
|
||||
| 모든 Task의 Files·Interfaces | PASS |
|
||||
| 모든 Task의 Implementation requirements | PASS |
|
||||
| 모든 Task의 Step 1~5 | PASS |
|
||||
| 모든 Task의 실패·통과 예상 결과 | PASS |
|
||||
| 모든 Task의 Git commit 명령 | PASS |
|
||||
| Stable Create 경로 중복 | 없음 |
|
||||
| Advanced Create 경로 중복 | 없음 |
|
||||
| Stable·Advanced Create 경로 충돌 | 없음 |
|
||||
| Markdown code fence 균형 | PASS |
|
||||
| `TODO`·`TBD`·`FIXME` | 없음 |
|
||||
|
||||
검증 명령:
|
||||
|
||||
```bash
|
||||
cd /mnt/data
|
||||
./validate_web_docs.py
|
||||
```
|
||||
|
||||
실행 결과:
|
||||
|
||||
```text
|
||||
checks=1097 passed=1097 failed=0
|
||||
```
|
||||
|
||||
## 3. 설계 핵심 계약 검증
|
||||
|
||||
다음 계약이 설계서와 계획서에 모두 존재하는지 확인했습니다.
|
||||
|
||||
```text
|
||||
W1 / W2 / W3 / W4 공개 계층
|
||||
MVC와 WebFlux Starter 상호 배타성
|
||||
Request / Application / Response Evidence 분리
|
||||
APPLICATION_COMMITTED와 HTTP response delivery 분리
|
||||
RFC 9457 Problem Details
|
||||
400 / 422 분리
|
||||
409 / 412 분리
|
||||
OpenAPI 3.1.2 Stable
|
||||
Path major API version
|
||||
HMAC keyset cursor
|
||||
ETag / If-Match
|
||||
Idempotency semantic fingerprint
|
||||
Redis가 DB commit evidence의 유일한 source가 아님
|
||||
Business mutation + JPA idempotency evidence same transaction
|
||||
Application commit 후 response-loss fault test
|
||||
Durable acceptance 이후에만 202
|
||||
Trusted Nginx forwarded-header boundary
|
||||
Tomcat / Jetty / Reactor Netty / Nginx 실제 gate
|
||||
Rate limit 429와 Admission 503 분리
|
||||
Low-cardinality metric·access log
|
||||
Stable·Advanced dependency 격리
|
||||
```
|
||||
|
||||
## 4. 계획 완결성 검증
|
||||
|
||||
Stable 계획은 다음 단계로 구성됩니다.
|
||||
|
||||
```text
|
||||
Task 1~14
|
||||
→ Core·HTTP·JSON·Problem·Architecture Foundation
|
||||
|
||||
Task 15~22
|
||||
→ MVC·Tomcat·Jetty·WebFlux·Reactor Netty
|
||||
|
||||
Task 23~30
|
||||
→ Security·Proxy·Versioning·Route·OpenAPI
|
||||
|
||||
Task 31~36
|
||||
→ Collection Query·Cursor·Conditional·Evidence
|
||||
|
||||
Task 37~43
|
||||
→ Idempotency·JPA Evidence·Redis Gate·Response Loss
|
||||
|
||||
Task 44~48
|
||||
→ Durable Operation·Outbox·HTTP Resource·Cache
|
||||
|
||||
Task 49~55
|
||||
→ Budget·Rate·Admission·CORS/CSRF·Order·Observability·Nginx
|
||||
|
||||
Task 56~58
|
||||
→ Cross-stack Contract·Performance·Stable Release
|
||||
```
|
||||
|
||||
Advanced 계획은 다음 단계로 구성됩니다.
|
||||
|
||||
```text
|
||||
Task 1~3
|
||||
→ Module isolation·Virtual Thread·Controlled Blocking Bridge
|
||||
|
||||
Task 4~5
|
||||
→ JSON Merge Patch·JSON Patch
|
||||
|
||||
Task 6~13
|
||||
→ Streaming Core·SSE·NDJSON·JSON Sequence·Drain·Replay
|
||||
|
||||
Task 14~18
|
||||
→ Functional WebFlux·CBOR·XML·OpenAPI 3.2·RateLimit Draft
|
||||
|
||||
Task 19
|
||||
→ Soak·Rollback·Promotion Gate
|
||||
```
|
||||
|
||||
## 5. 검증 범위의 한계
|
||||
|
||||
현재 검증은 **문서의 구조, 요구사항 추적성, 내부 계약, 파일 경로, 작업 순서에 대한 정적 검증**입니다.
|
||||
|
||||
실제 Backend Skeleton 저장소가 이번 입력에 포함되지 않았으므로 다음은 실행한 상태가 아닙니다.
|
||||
|
||||
```text
|
||||
Gradle configuration·compile
|
||||
Spring Boot ApplicationContext 기동
|
||||
Tomcat·Jetty·Reactor Netty 실제 contract
|
||||
PostgreSQL JPA idempotency transaction
|
||||
Redis concurrent gate
|
||||
Messaging outbox operation
|
||||
Nginx TLS·Forwarded topology
|
||||
commit 후 TCP reset fault injection
|
||||
OpenAPI generated client compile
|
||||
abuse·load·graceful shutdown
|
||||
Git commit
|
||||
```
|
||||
|
||||
계획서의 경로와 package는 다음 명시적 가정을 사용합니다.
|
||||
|
||||
```text
|
||||
Java 21
|
||||
Gradle Kotlin DSL
|
||||
Spring Boot 4.1 BOM
|
||||
root package: io.backend.skeleton.web
|
||||
stable module root: modules/web
|
||||
advanced module root: modules/web-advanced
|
||||
```
|
||||
|
||||
|
||||
## 6. 패키지 무결성 검증
|
||||
|
||||
패키지 조립 후 다음 검증을 추가로 수행했습니다.
|
||||
|
||||
```text
|
||||
Package-local validator
|
||||
→ checks=1104 passed=1104 failed=0
|
||||
|
||||
MANIFEST.sha256
|
||||
→ 모든 7개 파일 OK
|
||||
|
||||
ZIP CRC
|
||||
→ No errors detected
|
||||
|
||||
독립 문서와 패키지 내부 문서
|
||||
→ byte 단위 일치
|
||||
```
|
||||
|
||||
검증 명령:
|
||||
|
||||
```bash
|
||||
cd /mnt/data/web-superpowers-package
|
||||
./validate_web_docs.py
|
||||
sha256sum -c MANIFEST.sha256
|
||||
|
||||
cd /mnt/data
|
||||
unzip -t web-superpowers-package.zip
|
||||
cmp web-inbound-http-api-execution-platform-design.md \
|
||||
web-superpowers-package/docs/superpowers/specs/2026-08-13-web-inbound-http-api-execution-platform-design.md
|
||||
cmp web-inbound-http-api-execution-platform-implementation-plan.md \
|
||||
web-superpowers-package/docs/superpowers/plans/2026-08-13-web-inbound-http-api-execution-platform-implementation-plan.md
|
||||
cmp web-advanced-capabilities-expansion-plan.md \
|
||||
web-superpowers-package/docs/superpowers/plans/2026-08-13-web-advanced-capabilities-expansion-plan.md
|
||||
```
|
||||
+1532
File diff suppressed because it is too large
Load Diff
+4762
File diff suppressed because it is too large
Load Diff
+1656
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+286
@@ -0,0 +1,286 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
|
||||
if (ROOT / "docs").exists():
|
||||
DESIGN = ROOT / "docs/superpowers/specs/2026-08-13-web-inbound-http-api-execution-platform-design.md"
|
||||
STABLE = ROOT / "docs/superpowers/plans/2026-08-13-web-inbound-http-api-execution-platform-implementation-plan.md"
|
||||
ADVANCED = ROOT / "docs/superpowers/plans/2026-08-13-web-advanced-capabilities-expansion-plan.md"
|
||||
RESEARCH = ROOT / "research/source-web-deep-research.md"
|
||||
else:
|
||||
DESIGN = ROOT / "web-inbound-http-api-execution-platform-design.md"
|
||||
STABLE = ROOT / "web-inbound-http-api-execution-platform-implementation-plan.md"
|
||||
ADVANCED = ROOT / "web-advanced-capabilities-expansion-plan.md"
|
||||
RESEARCH = ROOT / "붙여넣은 마크다운(1)(20260813-120656).md"
|
||||
|
||||
checks: list[tuple[str, bool, str]] = []
|
||||
|
||||
def check(name: str, condition: bool, detail: str = "") -> None:
|
||||
checks.append((name, bool(condition), detail))
|
||||
|
||||
def read(path: Path) -> str:
|
||||
check(f"file exists: {path.name}", path.exists(), str(path))
|
||||
return path.read_text(encoding="utf-8") if path.exists() else ""
|
||||
|
||||
design = read(DESIGN)
|
||||
stable = read(STABLE)
|
||||
advanced = read(ADVANCED)
|
||||
research = read(RESEARCH)
|
||||
|
||||
check("design line floor", len(design.splitlines()) >= 1000, str(len(design.splitlines())))
|
||||
check("stable plan line floor", len(stable.splitlines()) >= 3000, str(len(stable.splitlines())))
|
||||
check("advanced plan line floor", len(advanced.splitlines()) >= 900, str(len(advanced.splitlines())))
|
||||
check("research line floor", len(research.splitlines()) >= 1000, str(len(research.splitlines())))
|
||||
|
||||
for name, text in [
|
||||
("design", design),
|
||||
("stable", stable),
|
||||
("advanced", advanced),
|
||||
]:
|
||||
check(f"{name} markdown fence balanced", text.count("```") % 2 == 0, str(text.count("```")))
|
||||
for forbidden in ["TODO", "TBD", "FIXME", "implement later", "fill in details"]:
|
||||
check(f"{name} has no placeholder {forbidden}", forbidden not in text, forbidden)
|
||||
|
||||
def task_sections(text: str) -> list[tuple[int, str]]:
|
||||
matches = list(re.finditer(r"(?m)^### Task (\d+): .+$", text))
|
||||
result: list[tuple[int, str]] = []
|
||||
for index, match in enumerate(matches):
|
||||
start = match.start()
|
||||
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
|
||||
result.append((int(match.group(1)), text[start:end]))
|
||||
return result
|
||||
|
||||
stable_tasks = task_sections(stable)
|
||||
advanced_tasks = task_sections(advanced)
|
||||
|
||||
check("stable task count", len(stable_tasks) == 58, str(len(stable_tasks)))
|
||||
check("advanced task count", len(advanced_tasks) == 19, str(len(advanced_tasks)))
|
||||
check(
|
||||
"stable task numbering consecutive",
|
||||
[number for number, _ in stable_tasks] == list(range(1, 59)),
|
||||
str([number for number, _ in stable_tasks]),
|
||||
)
|
||||
check(
|
||||
"advanced task numbering consecutive",
|
||||
[number for number, _ in advanced_tasks] == list(range(1, 20)),
|
||||
str([number for number, _ in advanced_tasks]),
|
||||
)
|
||||
|
||||
required_markers = [
|
||||
"**Files:**",
|
||||
"**Interfaces:**",
|
||||
"**Implementation requirements:**",
|
||||
"**Step 1: Write the failing test**",
|
||||
"**Step 2: Run the focused test and verify the expected failure**",
|
||||
"**Step 3: Implement the minimum production contract**",
|
||||
"**Step 4: Run the task test and its module contract suite**",
|
||||
"**Step 5: Commit the independently reviewable change**",
|
||||
"git commit -m",
|
||||
]
|
||||
|
||||
for plan_name, sections in [("stable", stable_tasks), ("advanced", advanced_tasks)]:
|
||||
for number, section in sections:
|
||||
for marker in required_markers:
|
||||
check(
|
||||
f"{plan_name} task {number} contains {marker}",
|
||||
marker in section,
|
||||
marker,
|
||||
)
|
||||
check(
|
||||
f"{plan_name} task {number} has test path",
|
||||
"- Test: `" in section,
|
||||
"",
|
||||
)
|
||||
check(
|
||||
f"{plan_name} task {number} has exact run command",
|
||||
"Run: `" in section,
|
||||
"",
|
||||
)
|
||||
check(
|
||||
f"{plan_name} task {number} has expected failure",
|
||||
"Expected:" in section and "FAIL" in section,
|
||||
"",
|
||||
)
|
||||
check(
|
||||
f"{plan_name} task {number} has expected pass",
|
||||
"Expected: PASS" in section,
|
||||
"",
|
||||
)
|
||||
|
||||
def create_paths(text: str) -> list[str]:
|
||||
return re.findall(r"(?m)^- Create: `([^`]+)`$", text)
|
||||
|
||||
stable_creates = create_paths(stable)
|
||||
advanced_creates = create_paths(advanced)
|
||||
|
||||
check(
|
||||
"stable create paths unique",
|
||||
len(stable_creates) == len(set(stable_creates)),
|
||||
f"{len(stable_creates)} paths",
|
||||
)
|
||||
check(
|
||||
"advanced create paths unique",
|
||||
len(advanced_creates) == len(set(advanced_creates)),
|
||||
f"{len(advanced_creates)} paths",
|
||||
)
|
||||
check(
|
||||
"stable and advanced create paths do not collide",
|
||||
set(stable_creates).isdisjoint(set(advanced_creates)),
|
||||
str(set(stable_creates) & set(advanced_creates)),
|
||||
)
|
||||
|
||||
design_terms = [
|
||||
"W1",
|
||||
"W2",
|
||||
"W3",
|
||||
"W4",
|
||||
"APPLICATION_COMMITTED",
|
||||
"CLIENT_OBSERVATION_UNKNOWN",
|
||||
"RFC 9457",
|
||||
"OpenAPI 3.1.2",
|
||||
"If-Match",
|
||||
"Idempotency",
|
||||
"202 Accepted",
|
||||
"Tomcat",
|
||||
"Jetty",
|
||||
"Reactor Netty",
|
||||
"Nginx",
|
||||
"business mutation + authoritative evidence same DB transaction",
|
||||
"Redis",
|
||||
"Request Evidence",
|
||||
"Application Evidence",
|
||||
"Response Evidence",
|
||||
]
|
||||
for term in design_terms:
|
||||
check(f"design contains key term: {term}", term in design, term)
|
||||
|
||||
stable_terms = [
|
||||
"same-PostgreSQL-transaction",
|
||||
"Application Commit 후 HTTP Response 유실 Fault Test",
|
||||
"Redis Concurrent Gate와 Replay Cache Adapter",
|
||||
"실제 Nginx Trusted Proxy",
|
||||
"실제 Tomcat MVC HTTP 계약 Gate",
|
||||
"Jetty MVC 호환성 Gate",
|
||||
"실제 Reactor Netty WebFlux 계약 Gate",
|
||||
"OpenAPI 3.1.2 Snapshot",
|
||||
"OpenAPI Breaking Diff",
|
||||
"Durable Operation",
|
||||
"same-PostgreSQL-transaction",
|
||||
"DB commit evidence의 유일한 source가 아니다",
|
||||
"webStableCheck",
|
||||
]
|
||||
for term in stable_terms:
|
||||
check(f"stable plan contains key term: {term}", term in stable, term)
|
||||
|
||||
advanced_terms = [
|
||||
"Virtual Thread",
|
||||
"Controlled Blocking Bridge",
|
||||
"JSON Merge Patch RFC 7396",
|
||||
"JSON Patch RFC 6902",
|
||||
"MVC SSE",
|
||||
"WebFlux SSE",
|
||||
"NDJSON",
|
||||
"JSON Text Sequence",
|
||||
"Messaging-backed SSE Replay",
|
||||
"OpenAPI 3.2 Experimental",
|
||||
"RateLimit Draft",
|
||||
"10k",
|
||||
"rollback",
|
||||
]
|
||||
for term in advanced_terms:
|
||||
check(f"advanced plan contains key term: {term}", term in advanced, term)
|
||||
|
||||
# Stable/advanced dependency boundary.
|
||||
check(
|
||||
"stable module map excludes modules/web-advanced",
|
||||
"modules/web-advanced/" not in stable.split("## 1. Stable 파일·모듈 구조", 1)[1].split("## 2.", 1)[0],
|
||||
"",
|
||||
)
|
||||
check(
|
||||
"advanced plan requires stable completion",
|
||||
"Stable Task 1~58" in advanced,
|
||||
"",
|
||||
)
|
||||
|
||||
# Evidence and idempotency invariants.
|
||||
for text_name, text in [("design", design), ("stable", stable)]:
|
||||
check(
|
||||
f"{text_name} separates ETag and idempotency",
|
||||
"ETag/If-Match" in text and "Idempotency" in text,
|
||||
"",
|
||||
)
|
||||
check(
|
||||
f"{text_name} says Redis is not sole commit evidence",
|
||||
("sole DB commit evidence" in text)
|
||||
or ("유일한 source" in text)
|
||||
or ("유일한 Source" in text),
|
||||
"",
|
||||
)
|
||||
check(
|
||||
f"{text_name} includes commit-response-loss",
|
||||
("Response Loss" in text)
|
||||
or ("response-loss" in text)
|
||||
or ("response write 전 TCP reset" in text),
|
||||
"",
|
||||
)
|
||||
|
||||
# No unsupported architecture in design/plan.
|
||||
for name, text in [("design", design), ("stable", stable)]:
|
||||
check(
|
||||
f"{name} forbids controller transaction",
|
||||
"Controller transaction" in text or "Controller @Transactional" in text or "Controller 또는 HTTP adapter에 업무 `@Transactional`" in text,
|
||||
"",
|
||||
)
|
||||
check(
|
||||
f"{name} forbids entity/document wire types",
|
||||
"Entity/Document" in text or "Entity·Document" in text or "JPA Entity·MongoDB Document" in text,
|
||||
"",
|
||||
)
|
||||
check(
|
||||
f"{name} does not declare Idempotency-Key as final RFC",
|
||||
"IETF 표준" not in text or "금지" in text,
|
||||
"",
|
||||
)
|
||||
|
||||
# Research grounding.
|
||||
check(
|
||||
"design title matches research topic",
|
||||
"인바운드 HTTP API 실행 플랫폼" in design and "인바운드 HTTP API 실행 플랫폼" in research,
|
||||
"",
|
||||
)
|
||||
check(
|
||||
"research includes execution evidence chain",
|
||||
"HTTP_RECEIVED" in research and "CLIENT_OBSERVATION_UNKNOWN" in research,
|
||||
"",
|
||||
)
|
||||
check(
|
||||
"research includes actual server matrix",
|
||||
"MVC + Tomcat" in research and "WebFlux + Reactor Netty" in research,
|
||||
"",
|
||||
)
|
||||
|
||||
# Optional package integrity.
|
||||
manifest = ROOT / "MANIFEST.sha256"
|
||||
if manifest.exists():
|
||||
for line in manifest.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
digest, relative = line.split(" ", 1)
|
||||
target = ROOT / relative
|
||||
actual = hashlib.sha256(target.read_bytes()).hexdigest() if target.exists() else ""
|
||||
check(f"manifest: {relative}", actual == digest, actual)
|
||||
|
||||
passed = sum(1 for _, ok, _ in checks if ok)
|
||||
failed = [(name, detail) for name, ok, detail in checks if not ok]
|
||||
|
||||
print(f"checks={len(checks)} passed={passed} failed={len(failed)}")
|
||||
for name, detail in failed:
|
||||
print(f"FAIL: {name} :: {detail}")
|
||||
|
||||
sys.exit(1 if failed else 0)
|
||||
@@ -0,0 +1,59 @@
|
||||
# Web Advanced: support matrix
|
||||
|
||||
Every capability is off unless named. `WebAdvancedPromotionGate.forFeature` is the machine-checked
|
||||
form of the last two columns; if this table disagrees with it, the code wins.
|
||||
|
||||
| Capability | Flag | What it adds | Required suites | Soak |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `MVC_VIRTUAL_THREADS` | `backend.web.advanced.mvc-virtual-threads.enabled` | A different scheduling model for **every** request | `web:test`, `webCrossStackParityTest`, `virtual-thread-admission`, `pinning-jfr` | 24h |
|
||||
| `WEBFLUX_BLOCKING_BRIDGE` | `…webflux-blocking-bridge.enabled` | A bounded offload; affects the shared event loop | `web:test`, `blocking-bridge-bounded`, `event-loop-guard` | 24h |
|
||||
| `JSON_MERGE_PATCH` | `…json-merge-patch.enabled` | RFC 7396, partial documents | `web:test`, `patch-security`, `patch-atomicity` | 8h |
|
||||
| `JSON_PATCH` | `…json-patch.enabled` | RFC 6902, arbitrary pointers | as merge patch | 8h |
|
||||
| `SSE` | `…sse.enabled` | Long-lived connections | `web:test`, `streaming-soak-10k`, `slow-consumer-bounded`, `cancellation-propagation`, `pod-drain` | 24h |
|
||||
| `NDJSON` | `…ndjson.enabled` | Long-lived connections | as SSE | 24h |
|
||||
| `JSON_SEQUENCE` | `…json-sequence.enabled` | Long-lived connections | as SSE | 24h |
|
||||
| `FUNCTIONAL_WEBFLUX` | `…functional-webflux.enabled` | Routes with no annotation to scan | `web:test`, `functional-route-parity` | 8h |
|
||||
| `CBOR` | `…cbor.enabled` | A second decoder | `web:test`, `codec-security`, `codec-budget` | 8h |
|
||||
| `XML` | `…xml.enabled` | A decoder with dangerous defaults | as CBOR | 8h |
|
||||
| `OPENAPI_32` | `…openapi-32.enabled` | A parallel description artifact | `web:test`, `openapi-32-toolchain-matrix` | 8h |
|
||||
| `RATELIMIT_DRAFT_HEADERS` | `…ratelimit-draft-headers.enabled` | Additive response headers | `web:test`, `ratelimit-draft-headers` | 8h |
|
||||
|
||||
## Two capabilities change requests that do not use them
|
||||
|
||||
`WebAdvancedFeature.affectsUnrelatedRequests()` is true for exactly `MVC_VIRTUAL_THREADS` and
|
||||
`WEBFLUX_BLOCKING_BRIDGE`. A codec affects only requests that negotiate it; a virtual-thread
|
||||
executor affects every request in the process, and the blocking bridge affects the event loop they
|
||||
all share. `WebAdvancedFeatureFlags.stableBehaviourPreserved()` reports whether either is on.
|
||||
|
||||
That distinction is why those two soak for 24 hours and why their rollback test is the one that
|
||||
matters most.
|
||||
|
||||
## What is refused, and why the row is here
|
||||
|
||||
| Refused | Reason |
|
||||
| --- | --- |
|
||||
| Virtual threads without an admission limit | The pool size *was* the admission policy. Removing it accepts every arrival and queues them on downstream budgets that did not grow. |
|
||||
| A blocking offload for an unregistered operation | `boundedElastic()` is reachable from anywhere and unbounded in practice; an unenumerable set of offloads is invisible until the pool is the heap. |
|
||||
| A merge patch outside its field allowlist | A merge patch is partial, so no DTO's absent fields say "not permitted". Without an allowlist the writable set grows every time somebody adds a field. |
|
||||
| A JSON Patch pointer above its allowed prefix | Replacing a parent deletes every sibling, so permission on a child cannot grant it. |
|
||||
| A `move` whose source is unauthorized | Checking only the destination lets a caller relocate data out of a field they may not touch. |
|
||||
| XML with a DTD or an external entity | Billion-laughs and XXE. Neither errors when it fires; the parse succeeds and the document contains something it should not. |
|
||||
| A non-JSON representation without all three gates | Flag, route `produces`, and client allowlist. An `Accept` header is not evidence the route was tested against that codec. |
|
||||
| Compression-style silent fallback on a 406 | A client that asked only for CBOR and gets JSON parses the bytes as CBOR and fails somewhere far away. |
|
||||
| Changing HTTP status after commit | The body becomes half stream, half JSON, and the 200 is cached. |
|
||||
| Unbounded buffering for a slow consumer | It moves the client's slowness into the server's heap. |
|
||||
|
||||
## Promotion
|
||||
|
||||
Each capability is promoted on its own evidence. Two conditions apply to all and are not waivable:
|
||||
|
||||
- **Rollback exercised.** A flag nobody has turned off is not known to turn off.
|
||||
- **Stable behaviour unchanged with the feature off.** If it is not, the feature was never optional
|
||||
and every deployment has it.
|
||||
|
||||
Parsers and patch appliers additionally require a security review —
|
||||
`WebAdvancedPromotionGate.needsSecurityReview` names them.
|
||||
|
||||
See `docs/adr/ADR-WEB-ADV-001-streaming-is-live-delivery.md`,
|
||||
`ADR-WEB-ADV-002-virtual-threads-do-not-remove-admission.md`, and
|
||||
`ADR-WEB-ADV-003-openapi-32-remains-experimental.md`.
|
||||
@@ -0,0 +1,57 @@
|
||||
# OpenAPI 3.2: the experimental lane
|
||||
|
||||
## Why it is a lane rather than an upgrade
|
||||
|
||||
Generating 3.2 is cheap. Adopting it is not, and the two get conflated because the generated
|
||||
document looks fine.
|
||||
|
||||
The value of an API description is entirely in what consumes it. A document in a version a client
|
||||
generator does not fully understand does not fail — it produces a client that compiles and is wrong,
|
||||
which is worse than no document at all.
|
||||
|
||||
So 3.1.2 stays the release artifact and 3.2 is generated beside it, with a report.
|
||||
|
||||
## The invariant
|
||||
|
||||
**Generating 3.2 must not change the 3.1 snapshot.** Both come from the same model, so a contributor
|
||||
that mutates it on the way to 3.2 changes the artifact that is actually shipped — silently, and only
|
||||
when the experimental lane runs.
|
||||
|
||||
`OpenApi32CompatibilityReport` hashes the snapshot before and after, and a difference is a promotion
|
||||
blocker with its own message.
|
||||
|
||||
## The toolchain matrix
|
||||
|
||||
Four kinds of tool, checked separately, because passing one says nothing about the others:
|
||||
|
||||
| Kind | What it catches | What it misses |
|
||||
| --- | --- | --- |
|
||||
| Parser | Structural errors | Anything semantically odd |
|
||||
| Linter | Style rule violations | Accepts documents a parser rejects |
|
||||
| Generator | Unsupported constructs — sometimes | Usually emits a wrong-but-valid signature instead of failing |
|
||||
| Client compile | The wrong signature the generator emitted | Runtime behaviour |
|
||||
|
||||
`OpenApiToolchainMatrix.complete()` requires at least one passing tool of each kind.
|
||||
`gaps()` names the kinds that have none.
|
||||
|
||||
"OpenAPI 3.2 works" is not a statement anybody can make. "This document is read correctly by these
|
||||
four tools at these versions" is.
|
||||
|
||||
## Streaming descriptions
|
||||
|
||||
The substantive difference between 3.1 and 3.2 for this application is how streaming responses are
|
||||
described. Those differences are reported separately rather than folded into a pass/fail, because a
|
||||
green pass hides what changed.
|
||||
|
||||
## Promotion
|
||||
|
||||
`promotionBlockers(adrAccepted)` always includes the ADR blocker until one is accepted, however
|
||||
green the matrix is. A machine-checkable matrix cannot decide whether the consumer population is
|
||||
ready; that is a judgement, and it belongs in
|
||||
`docs/adr/ADR-WEB-ADV-003-openapi-32-remains-experimental.md`.
|
||||
|
||||
## Running it
|
||||
|
||||
The experimental generation runs in its own workflow and publishes the 3.2 document and the
|
||||
compatibility report as artifacts. It never runs in the release workflow, so the release artifact
|
||||
cannot depend on whether it ran.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Web patch: the client contract
|
||||
|
||||
Two patch formats, and they are not interchangeable.
|
||||
|
||||
## Choosing one
|
||||
|
||||
A route accepts exactly one, and sending the other is a 415 rather than a best-effort guess. The
|
||||
reason is that a mismatch is silent in the worst direction:
|
||||
|
||||
- `{"a": null}` as a **merge patch** deletes `a`. As a **JSON Patch** it is not a patch at all — it
|
||||
is an object where an array was required.
|
||||
- A JSON Patch array read as a merge patch is a document whose fields are array indices. It merges
|
||||
nothing and reports success.
|
||||
|
||||
| | Merge patch | JSON Patch |
|
||||
| --- | --- | --- |
|
||||
| Media type | `application/merge-patch+json` | `application/json-patch+json` |
|
||||
| RFC | 7396 | 6902 |
|
||||
| Shape | A partial document | An ordered array of operations |
|
||||
| Delete | `"field": null` | `{"op":"remove","path":"/field"}` |
|
||||
| Arrays | Replaced whole | Addressable per index |
|
||||
| Preconditions | `If-Match` only | `If-Match`, plus per-field `test` |
|
||||
|
||||
Use merge patch for "change these fields". Use JSON Patch when you need array element edits or a
|
||||
precondition on one field that `If-Match` cannot express.
|
||||
|
||||
## What is refused
|
||||
|
||||
**Fields outside the allowlist.** The server declares which fields a merge patch may modify and
|
||||
which pointers a JSON Patch may address. Anything else is refused, and *every* refused path is
|
||||
named — you will not discover them one round trip at a time.
|
||||
|
||||
The allowlist is checked **before** anything is applied. A refusal tells you nothing about the
|
||||
current values, which is deliberate: deciding afterwards whether a refused field "actually changed
|
||||
anything" would answer a question about data you may not read.
|
||||
|
||||
**Pointer permission does not travel upwards.** Permission on `/profile` covers
|
||||
`/profile/displayName`; permission on `/profile/displayName` does not cover `/profile`, because
|
||||
replacing the parent deletes every sibling.
|
||||
|
||||
**A `move` needs permission on both ends.** Checking only the destination would let you relocate
|
||||
data out of a field you may not touch.
|
||||
|
||||
**Limits.** At most 100 operations, pointer depth at most 16, merge-patch nesting depth at most 32.
|
||||
The first two multiply — each operation walks its pointer over a document the server deep-copied
|
||||
first.
|
||||
|
||||
## Atomicity
|
||||
|
||||
A JSON Patch document applies entirely or not at all. Every operation runs against a working copy;
|
||||
the resource is not touched until all of them have succeeded and the result has passed full
|
||||
validation.
|
||||
|
||||
This is what makes `test` useful. A client putting a `test` first is relying on nothing after it
|
||||
having happened when the test fails, and that reliance holds:
|
||||
|
||||
```json
|
||||
[
|
||||
{"op": "test", "path": "/version", "value": 4},
|
||||
{"op": "replace", "path": "/displayName", "value": "new"}
|
||||
]
|
||||
```
|
||||
|
||||
A failed `test` is **409**, not 400: the document was well-formed and permitted, and the resource
|
||||
simply was not in the state you expected. Re-read and retry. The response names the pointer that
|
||||
failed and does not return the server's value — returning it would make `test` a read primitive for
|
||||
fields you may not read.
|
||||
|
||||
## Validation
|
||||
|
||||
The patched result is validated as a whole document, not field by field. A patch whose individual
|
||||
fields are all valid can produce an object that is not — two fields that must agree, a state
|
||||
transition that is not allowed — and validating only what changed sees none of it.
|
||||
|
||||
## Preconditions
|
||||
|
||||
Patch routes require `If-Match`. A partial update against a resource that moved underneath you
|
||||
applies your changes to a version you never saw.
|
||||
@@ -0,0 +1,357 @@
|
||||
# Web platform — repository adaptation
|
||||
|
||||
The inbound HTTP API execution platform design
|
||||
(`docs/web-superpowers-package/docs/superpowers/specs/2026-08-13-web-inbound-http-api-execution-platform-design.md`)
|
||||
was written against a standalone repository. Four of its assumptions do not hold here, and this
|
||||
document records what each became and why, so a reader comparing the design to the tree is not left
|
||||
guessing whether a difference is a decision or a mistake.
|
||||
|
||||
## 1. Twenty-three Gradle modules became sub-packages of one leaf
|
||||
|
||||
The design lays the platform out as `modules/web/web-core-api`, `web-contract`, `web-mvc` and so on.
|
||||
This repository's `src/config/architecture/modules.json` is a fail-closed registry that owns the leaf
|
||||
list, and adding twenty-three leaves to it is an architecture decision the design cannot make on the
|
||||
repository's behalf. The JPA and GraphQL platforms reached the same fork and resolved it the same
|
||||
way, so this is the established answer rather than a new one.
|
||||
|
||||
The substitution only holds if the boundaries are enforced, because a Gradle dependency gate cannot
|
||||
see inside a leaf. `WebStableModule` declares each module's identity, package, purity grade and
|
||||
allowed edges; `WebModuleBoundaryTest` scans the real source tree and fails when the tree and the
|
||||
declaration disagree **in either direction** — an undeclared edge, an undeclared package, or a
|
||||
declared module with no source. Four negative fixtures prove each rule can fail.
|
||||
|
||||
Each enum constant is already shaped like a leaf specification, so promoting a module to its own
|
||||
Gradle path later is a registry edit rather than an archaeology exercise.
|
||||
|
||||
| Design module | Package under `dev.caskeleton.adapter.inbound.web` | Status |
|
||||
| --- | --- | --- |
|
||||
| `web-core-api` | `core` | planned (Task 2+) |
|
||||
| `web-contract` | `contract` | planned |
|
||||
| `web-validation` | `validation` | planned |
|
||||
| `web-error` | `error` | present |
|
||||
| `web-pagination` | `pagination` | present |
|
||||
| `web-idempotency` | `idempotency` | present |
|
||||
| `web-idempotency-jpa` | `idempotency.jpa` | planned |
|
||||
| `web-idempotency-redis` | `idempotency.redis` | planned |
|
||||
| `web-versioning` | `versioning` | planned |
|
||||
| `web-security-integration` | `auth`, `authz` | present |
|
||||
| `web-observability` | `observability` | present |
|
||||
| `web-openapi` | `openapi` | planned |
|
||||
| `web-mvc` | `mvc` | planned |
|
||||
| `web-webflux` | `webflux` | planned |
|
||||
| `web-admin` | `admin` | planned |
|
||||
| `web-operation-jpa` | `operation.jpa` | planned |
|
||||
| `web-operation-messaging` | `operation.messaging` | planned |
|
||||
| `web-spring-boot-starter-mvc` | `autoconfigure.mvc` | planned |
|
||||
| `web-spring-boot-starter-webflux` | `autoconfigure.webflux` | planned |
|
||||
| `web-testkit-*` | `testkit`, `testkit.mvc`, `testkit.webflux`, `testkit.contract` | planned |
|
||||
| — | `conditional`, `cursor`, `http`, `filter`, `envelope`, `config`, `controller`, `ratelimit`, `settings` | present, pre-dates the design |
|
||||
| — | `fileserver.*`, `notification.*` | present, feature integrations rather than platform modules |
|
||||
|
||||
A module is added to `WebStableModule` at the moment its package gains its first file, never before:
|
||||
a declared module with no source is a claim about a rename that has not happened.
|
||||
|
||||
## 2. Root package
|
||||
|
||||
The design uses `io.backend.skeleton.web`. This repository's package root is
|
||||
`dev.caskeleton.adapter.inbound.web`, and the registry, the ArchUnit rules and the composition root's
|
||||
component scan are all written against it.
|
||||
|
||||
## 3. Gradle DSL
|
||||
|
||||
The design's snippets are Kotlin DSL. Every build file in this repository is Groovy DSL, and
|
||||
`src/build-logic` carries the shared convention plugins the leaf inherits. The build logic is
|
||||
translated, not copied.
|
||||
|
||||
## 4. Spring Boot baseline
|
||||
|
||||
The design names "Spring Boot 4.1 BOM". This repository's baseline is **4.0.8** — see
|
||||
`.vscode/settings.json` for why the 4.1 minor line is a planned migration rather than a currency
|
||||
fix. Where the design depends on a 4.1-only API the difference is recorded at the call site rather
|
||||
than silently absorbed.
|
||||
|
||||
## 5. Sample application
|
||||
|
||||
The design's `examples/web-platform-sample` is the existing `sample-portfolio` leaf, which already
|
||||
carries the OpenAPI drift gate and the contract lanes the design's sample is specified to provide.
|
||||
|
||||
## 6. Idempotency: the design's store SPI is already owned by `application-core`
|
||||
|
||||
The design gives `web-idempotency` its own `IdempotencyStore`, `IdempotencyRecord`,
|
||||
`IdempotencyState`, `IdempotencyScope` and `RequestFingerprint`. This repository already has all
|
||||
five in `application-core/idempotency`, with a JPA adapter
|
||||
(`PostgreSqlOwnerSafeIdempotencyStore`, `IdempotencyStoreAdapter`) and a Redis cache behind them —
|
||||
which is what the design's Tasks 39 and 40 ask for, built in the direction the registry permits.
|
||||
|
||||
Implementing the design's SPI literally would have required
|
||||
`adapter-outbound-persistence-jpa → adapter-inbound-web`, an edge the registry forbids and one that
|
||||
points the wrong way regardless: the store is an application concern that two transports could
|
||||
share, not something the HTTP layer owns.
|
||||
|
||||
So the web leaf keeps only the parts that are genuinely HTTP and have no application-core
|
||||
equivalent, and everything else binds to the existing port:
|
||||
|
||||
| Design type | Here |
|
||||
| --- | --- |
|
||||
| `IdempotencyStore`, `IdempotencyRecord`, `IdempotencyState`, `IdempotencyScope` | `application-core/idempotency` (already present) |
|
||||
| `RequestFingerprint` | `application-core/idempotency` — the web factory *produces* it |
|
||||
| `ResponseSnapshot` | `application-core/idempotency/StoredResponse` |
|
||||
| `IdempotencyKey` | web: the `Idempotency-Key` header's grammar and bounds |
|
||||
| `FingerprintHeaderPolicy` | web: which HTTP headers change what a request means |
|
||||
| `DeterministicCommandEncoder` | web: canonical JSON, because the application port's `ofSha256(byte[])` hashes raw bytes |
|
||||
| `SemanticRequestFingerprintFactory` | web: builds the application type from operation, path identifiers, canonical body and selected headers |
|
||||
|
||||
The last three are the substance the design adds over what was here. The application port's
|
||||
`RequestFingerprint.ofSha256(byte[])` digests the raw request body, and raw bytes are not stable
|
||||
across a client library that reorders JSON members or a proxy that reformats — the design names that
|
||||
explicitly, and the semantic factory is the fix.
|
||||
|
||||
|
||||
## 7. Durable operations: the design's `web-operation-jpa` cannot exist here
|
||||
|
||||
The design gives the web platform its own persistence module — `modules/web/web-operation-jpa` —
|
||||
holding a JPA store for long-running operations, with the port
|
||||
(`DurableOperationStore`) in `web-core-api` beside `WebProblem`.
|
||||
|
||||
`src/config/architecture/modules.json` forbids both halves of that:
|
||||
|
||||
- `:adapter:inbound:web` may depend only on `domain-core`, `application-core` and
|
||||
`shared-contract`, so the web leaf cannot take a JPA dependency.
|
||||
- `:adapter:outbound:persistence-jpa` is an outbound adapter and cannot depend on an inbound one,
|
||||
so a JPA store cannot see a web type.
|
||||
|
||||
This is the same shape as §6 and takes the same resolution — the one the repository already used
|
||||
for idempotency, where `IdempotencyStorePort` lives in `application-core` and
|
||||
`IdempotencyStoreAdapter` in `persistence-jpa`:
|
||||
|
||||
| Design type | Here |
|
||||
| --- | --- |
|
||||
| `DurableOperationStore` | `application-core/operation/DurableOperationStorePort` |
|
||||
| `OperationSubmission` | `application-core/operation/DurableOperationSubmission` |
|
||||
| the stored operation | `application-core/operation/DurableOperation` |
|
||||
| the lease | `application-core/operation/OperationLease` |
|
||||
| the stored failure | `application-core/operation/OperationFailure` — a code and an already-safe message, never an exception |
|
||||
| `JpaDurableOperationStore`, `JpaOperationEntity`, `V002__web_operation.sql` | `persistence-jpa/operation/*`, `db/migration/postgresql/V11__durable_operation.sql` |
|
||||
| `OperationResource`, `OperationStatus`, `OperationProgress`, `OperationId` | web `operationasync` — the HTTP projection |
|
||||
| — | web `operationasync/OperationResourceFactory`, which is the projection itself |
|
||||
|
||||
The split is not only a registry workaround; it puts each half where its invariants belong. A
|
||||
problem document is an HTTP concept and has no business being persisted, so the stored failure is a
|
||||
code plus a safe message and the factory turns it into a `WebProblem` through the same catalog and
|
||||
the same sanitiser as every synchronous error. An async failure therefore cannot publish something
|
||||
a synchronous one would have redacted, and a worker cannot extend the published code vocabulary by
|
||||
writing a row — an unrecognised code becomes `INTERNAL_ERROR`.
|
||||
|
||||
The state machine is enforced three times on purpose: in `DurableOperation`'s constructor, in the
|
||||
`CHECK` constraints of `V11__durable_operation.sql`, and in the `WHERE` clause of every statement
|
||||
that changes a row. The constructor catches application bugs, the constraints catch any other
|
||||
writer, and the predicates make each transition atomic — a worker whose lease lapsed while it was
|
||||
still working cannot record a result over the worker that took over, because its `UPDATE` matches
|
||||
no row and it learns that from the affected count.
|
||||
|
||||
## 8. Budget enforcement: what the edge stops and what the application stops
|
||||
|
||||
The design (Task 49) requires that the difference between an edge proxy's limits and the
|
||||
application's own be written down rather than discovered during an incident. Two limits on the same
|
||||
dimension always exist, they are never equal, and which one fires decides what the caller sees.
|
||||
|
||||
| Dimension | Edge (nginx) | Application | Who answers when crossed |
|
||||
| --- | --- | --- | --- |
|
||||
| Request line / URI | `large_client_header_buffers` (default 8k) | `maxUriBytes` | Edge first — nginx answers 414 with its own HTML |
|
||||
| Request headers | `large_client_header_buffers` | `maxHeaderBytes` | Edge first — nginx 400, no problem document |
|
||||
| Request body | `client_max_body_size` (default 1m) | `maxBodyBytes` (platform ceiling 8 MiB) | Whichever is smaller; nginx answers 413 with HTML |
|
||||
| Query parameter count | not enforced | `maxQueryParameters` | Application, always |
|
||||
| JSON depth / array size | not enforced | `maxJsonDepth`, `maxArrayElements` | Application, always |
|
||||
| Execution time | `proxy_read_timeout` (default 60s) | `maxExecutionTime` (ceiling 2 min) | Edge first if the platform's is larger |
|
||||
| Response size | not enforced | `maxResponseBytes` (ceiling 32 MiB) | Application, always |
|
||||
|
||||
Two consequences worth stating, because both are counterintuitive:
|
||||
|
||||
**An edge limit below the application's means clients never see a problem document.** nginx answers
|
||||
its own HTML error page, so a client that branches on `ProblemCode` gets a body it cannot parse.
|
||||
Where a dimension matters to clients, the edge limit must be set *above* the application's so the
|
||||
application is the one that refuses.
|
||||
|
||||
**An edge limit above the application's is not redundant.** It is the only thing standing between
|
||||
the process and a body large enough to matter before the application's own meter has counted it.
|
||||
Both belong; only their ordering is a decision.
|
||||
|
||||
The enforcement itself never materializes a body to measure it. `WebBudgetMeter` counts bytes as
|
||||
they move — through a wrapped `ServletInputStream` on the servlet side and a `doOnNext` on the
|
||||
buffer flux on the reactive side — and throws at the byte that crosses. A check that read the body
|
||||
in order to size it would be the heap exhaustion the check exists to prevent, so the contract asserts
|
||||
directly that an oversized body never reaches the handler in full.
|
||||
|
||||
On the way out the split is between committed and not:
|
||||
|
||||
- **Not committed** — the response is reset, which discards the partial body *and* the meter's
|
||||
count of it, and the overrun is answered as a problem document. Resetting only the buffer was the
|
||||
first implementation and it failed: the meter still held the count of the discarded bytes, so the
|
||||
small problem document was refused too and the client got the container's error page.
|
||||
- **Committed** — there is no status left to send. The exception propagates, the connection ends
|
||||
mid-document, and the client sees a truncated response. That is worse for the client than a clean
|
||||
error and better than a short response it would accept as complete.
|
||||
|
||||
Statuses are never restated. `BudgetProblemMapper.statusFor` reads `ProblemCatalog` through the
|
||||
violation's code; an earlier draft kept its own violation-to-status table and disagreed with the
|
||||
catalog on two entries, which `requireStatusAgreement` turned into a 500. One table, no drift.
|
||||
|
||||
## 9. The Nginx lane: what a real proxy caught that no unit test could
|
||||
|
||||
Task 55 asks for the proxy contract to be verified against a real Nginx. It is implemented as a
|
||||
separate `nginxProxyTest` source set with a Testcontainers-managed `nginx:1.27-alpine`, run by
|
||||
`./gradlew :adapter:inbound:web:webNginxProxyTest`. Its own lane because it is the only one that
|
||||
needs Docker; folded into `test`, every developer's `check` would depend on a container runtime,
|
||||
and the usual end of that is an `@Disabled` nobody notices.
|
||||
|
||||
**TLS is terminated in configuration, not in the container.** The design describes an `ssl` listener.
|
||||
What the application can observe about TLS is exactly one thing — that the edge set
|
||||
`X-Forwarded-Proto: https` authoritatively — and a proxy that terminates TLS and one that declares
|
||||
the scheme produce an identical request upstream. Generating a certificate per run would add a
|
||||
second failure mode to a lane whose subject is header handling. The lane asserts the observable
|
||||
property; it does not assert that Nginx can do TLS.
|
||||
|
||||
**The defect the lane found on its first run.** The first configuration set the forwarded headers
|
||||
once at the `server` level and added only `X-Forwarded-Prefix` per `location`. Six of the ten cases
|
||||
failed. Nginx's inheritance rule for array directives is *replacement*: a single `proxy_set_header`
|
||||
inside a `location` discards every `proxy_set_header` inherited from `server`. So none of the
|
||||
security headers were sent, the application fell back to the upstream's own `Host`, and a client's
|
||||
`X-Forwarded-Host` would have been trusted.
|
||||
|
||||
That configuration reads as correct, is a shape found throughout the wild, and no test of the
|
||||
application could detect it — the application's forwarded-header handling is thoroughly unit-tested
|
||||
and every one of those tests still passed. The bug lived entirely in the seam. The headers now live
|
||||
in `proxy_headers.conf` and are `include`d by each location.
|
||||
|
||||
| Bound | Where it fires | What the client sees |
|
||||
| --- | --- | --- |
|
||||
| `client_max_body_size 2m` | Nginx, before the application | Nginx's HTML 413, no problem document |
|
||||
| application `maxBodyBytes` | the application | RFC 9457 problem, `REQUEST_TOO_LARGE` |
|
||||
| unknown prefix | Nginx | 404; only `/api/` and `/dev-api/` are routed at all |
|
||||
|
||||
The first row is the §8 table's consequence made concrete: an edge limit below the application's
|
||||
means clients never see a problem document for that dimension.
|
||||
|
||||
## Advanced capabilities
|
||||
|
||||
The Advanced expansion plan asks for eleven Gradle modules under `modules/web-advanced/`. They are
|
||||
packages under `advanced.**` in this leaf, for the reason the Stable platform is one leaf:
|
||||
`src/config/architecture/modules.json` is fail-closed and owns the leaf list, and eleven entries to
|
||||
satisfy a directory layout is a registry change rather than an architecture one.
|
||||
|
||||
What the design wanted from the separation is enforced instead by two machine checks:
|
||||
|
||||
- **`WEB-ARCH-ADV`** in `WebArchitectureRules.stableDoesNotDependOnAdvanced()` fails the build when a
|
||||
Stable class names an Advanced type. A feature flag decides whether a bean is created; it does
|
||||
nothing about a compile-time edge, and one such edge makes Stable unbuildable without Advanced.
|
||||
- **`WebStableModule`** declares eleven Advanced module identities with their own purity and edge
|
||||
sets, checked by `WebModuleBoundaryTest`. Nine of the eleven are `CORE` — pure policy with no
|
||||
framework import — which is stricter than the design's Gradle layout would have been.
|
||||
|
||||
Two modules are `FRAMEWORK_BOUND` and had to be: `advanced-patch` and `advanced-codec`. Jackson's
|
||||
tree model is the reason for the first — a merge patch's null-means-delete has no representation in
|
||||
a Java object, so the applier works on nodes — and `XMLInputFactory` is the reason for the second.
|
||||
A third, `advanced-stream-encoding`, is separated from the pure `advanced-stream` for the same
|
||||
reason: framing serializes, and serializing binds to Jackson.
|
||||
|
||||
### What was adapted rather than copied
|
||||
|
||||
**`WebStreamEnvelope.Error` is named `Failure`.** A nested type called `Error` shadows
|
||||
`java.lang.Error` inside its own file, so an unrelated `catch (Error e)` there would catch the wrong
|
||||
thing. Error Prone's `JavaLangClash` refuses it outright, and the rename is the only difference from
|
||||
the design's sealed hierarchy.
|
||||
|
||||
**`WebStreamErrorPolicy`'s factory methods are `startOver` / `resumeFromPosition` / `nothingToDo`.**
|
||||
The design's names collided with the record's own accessors, which Java rejects.
|
||||
|
||||
**The codec backends are compile-only, and finding out why cost a broken composition root.**
|
||||
`WebCborMapperFactory` and `WebXmlMapperFactory` are implemented, along with the parts that carry
|
||||
the failure modes: `SecureXmlInputFactory` (the DTD and external-entity defaults, both of which
|
||||
produce no error when they fire), `CodecBudget` (per representation, because a megabyte of CBOR can
|
||||
declare an array of a billion elements in a handful of bytes), and `RepresentationNegotiationPolicy`
|
||||
(three gates, because an `Accept` header is not evidence the route was tested against that codec).
|
||||
|
||||
`jackson-dataformat-cbor` and `jackson-dataformat-xml` were declared `implementation` first, so that
|
||||
a missing backend could not surface as a `NoClassDefFoundError` at the first request that negotiated
|
||||
one. That reasoning was wrong about what the jars do. Spring Boot's Jackson auto-configuration
|
||||
registers an `xmlMapper` and a `cborMapper` bean the moment each backend is on the runtime
|
||||
classpath, and Spring registers an XML message converter with it. Two things followed, and only the
|
||||
first was noisy:
|
||||
|
||||
- The composition root held three `ObjectMapper` beans — `webStrictObjectMapper`, `xmlMapper`,
|
||||
`cborMapper` — so every `@Autowired ObjectMapper` became ambiguous and the application would not
|
||||
start. Six `app-bootstrap` tests failed with `UnsatisfiedDependencyException`.
|
||||
- Every deployment silently began accepting `application/xml` request bodies. An XXE surface,
|
||||
acquired by adding a dependency, on a capability that is supposed to be off unless a deployment
|
||||
names it.
|
||||
|
||||
So both are `compileOnly` plus `testImplementation`: the factories compile, their tests run against
|
||||
real backends, and the runtime classpath belongs to the deployment that enables the capability.
|
||||
`WebRepresentation.available()` turns an absent backend into a sentence naming the missing
|
||||
coordinate. `RepresentationBackendScopeTest` reads `build.gradle` and fails if either coordinate
|
||||
returns to `implementation`, because nothing else catches it — the codec's own tests pass either
|
||||
way, and the failure only appears in whatever composes this leaf.
|
||||
|
||||
**The framing and the writers are separate, and both exist.** `NdjsonFraming` and
|
||||
`JsonSequenceFraming` hold the contract; `MvcStreamWriter`, `WebFluxStreamWriter` and
|
||||
`WebFluxSseAdapter` are what actually put it on a response. There is no MVC SSE writer: this
|
||||
repository's `NO_SSE_EMITTER` ArchUnit rule forbids `SseEmitter` outright, so the servlet stack
|
||||
streams NDJSON and JSON-seq and SSE is reactive-only. An earlier pass
|
||||
stopped after the framing, which left the platform advertising SSE while nothing could serve it —
|
||||
the same "control reached by nothing" shape this leaf has caught five other times.
|
||||
|
||||
**`MessagingReplayBridge` is `WebStreamReplaySource`, an interface this module implements nowhere.**
|
||||
The design's requirement is that the web module store no durable event history; an implementation
|
||||
here would be the thing it forbids.
|
||||
|
||||
### What the execution layer had to get right
|
||||
|
||||
Three of these were only found by building the adapter rather than the policy.
|
||||
|
||||
**A merged heartbeat means a finished source never completes.** `Flux.interval` is infinite, so
|
||||
`source.mergeWith(heartbeat)` holds the connection for the full `maxStreamAge` after the last item —
|
||||
thirty minutes of keepalives on a stream that ended. `WebFluxSseAdapter` therefore ends at the
|
||||
terminal envelope (`takeUntil`), and supplies one for a source that finished without emitting its
|
||||
own.
|
||||
|
||||
**`onBackpressureBuffer(n)` does not close anything.** It propagates demand, so a subscriber that
|
||||
stops requesting simply stops the source and the bound never fires. That is correct for a
|
||||
well-behaved source and useless as a slow-consumer policy, because the sources this carries push
|
||||
whether or not anybody asked. The working composition is `onBackpressureBuffer(n)` followed by
|
||||
`onBackpressureError()`, verified by mutation: removing the second half makes both slow-consumer
|
||||
tests fail. The merge prefetch is set to the same `n`, because its default of 256 would otherwise
|
||||
be a larger backlog sitting behind the configured one.
|
||||
|
||||
**The blocking bridge's permit cannot be released in `doFinally`.** That fires on the subscriber's
|
||||
cancel signal, which arrives while the callable is still blocked on a thread — releasing there hands
|
||||
the permit to another caller while the first still holds the database connection it was accounting
|
||||
for. Acquire and release are in the same `try`/`finally` inside the callable, and the consequence,
|
||||
stated rather than hidden, is that cancellation does not interrupt a blocking call.
|
||||
|
||||
On the servlet side the equivalent is that there is no disconnect event at all: the first sign a
|
||||
client is gone is a write that throws, which is why the heartbeat is the probe rather than a
|
||||
courtesy and why a failed beat is recorded as disconnect evidence.
|
||||
|
||||
### One pre-existing flake was fixed
|
||||
|
||||
`HttpThrottleFixture.awaitSlotTaken` polled for a 503 while each probe spent quota, and refilled the
|
||||
quota only after the loop. On a machine loaded enough that the holding request took a while to
|
||||
occupy the slot, the quota ran out first and every remaining probe answered 429 — so the loop never
|
||||
saw its 503 and failed on the deadline, which reads as a capacity bug and is a fixture bug. It now
|
||||
refills before every probe and fails loudly if a probe is refused for quota immediately after a
|
||||
refill. Found because the new virtual-thread and blocking-bridge tests load the machine enough to
|
||||
trigger it.
|
||||
|
||||
### Verification
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:inbound:web:test # 861 tests, Advanced included
|
||||
./gradlew :adapter:inbound:web:webAdvancedTest # 147, the Advanced lane on its own
|
||||
./gradlew :adapter:inbound:web:webCrossStackParityTest
|
||||
./gradlew :adapter:inbound:web:webNginxProxyTest # Docker
|
||||
```
|
||||
|
||||
See `docs/web/advanced-capabilities.md`, `docs/web/streaming-contract.md`,
|
||||
`docs/web/patch-contract.md`, `docs/web/virtual-thread-profile.md`,
|
||||
`docs/web/openapi-32-compatibility.md`, and `docs/adr/ADR-WEB-ADV-00{1,2,3}-*.md`.
|
||||
@@ -0,0 +1,116 @@
|
||||
# Web platform runbook
|
||||
|
||||
What an operator needs when the HTTP boundary misbehaves. Organised by what you observe, because
|
||||
that is what you have at 3am — not by which module owns it.
|
||||
|
||||
## Reading the two "too much traffic" answers
|
||||
|
||||
The platform never answers these two interchangeably, and the difference tells you where to look.
|
||||
|
||||
| You see | It means | Where to look |
|
||||
| --- | --- | --- |
|
||||
| `429 RATE_LIMITED` climbing for one caller | that caller exceeded its quota | the caller. Nothing is wrong with the service. |
|
||||
| `429` climbing across **all** callers | quota is being charged for requests the service then shed | look at 503 first; see below |
|
||||
| `503 ADMISSION_REJECTED` | the service has no capacity | saturation: check the admission profile's concurrency and what is holding slots |
|
||||
|
||||
The second row is a real consequence of the design and worth knowing before it confuses you. Quota
|
||||
is charged *before* capacity is requested, deliberately — the reverse order lets a caller who is
|
||||
about to be rate-limited occupy a slot on the way to being told so. Under sustained overload every
|
||||
caller's quota therefore drains on requests that never ran. **Rising 429 during an incident is a
|
||||
symptom of shedding, not of callers misbehaving.** Read the 503 rate first.
|
||||
|
||||
## A control that appears configured and does nothing
|
||||
|
||||
This has happened here, and the failure is silent by construction. A configured-but-unwired control
|
||||
behaves exactly like a working one until it is needed.
|
||||
|
||||
- **Check first:** the platform snapshot's `uninstalledControls()`. `WebPlatformStartupValidator`
|
||||
fails startup on a missing required control, so a running instance with one missing means it was
|
||||
not in the required list.
|
||||
- **The instance found the hard way:** the problem catalog was complete, fully unit-tested, and
|
||||
reached by nothing on the framework's error path. Spring answered failures with its own
|
||||
`ProblemDetail` — RFC 9457-shaped, so it looked correct — carrying no `code` field. It was found
|
||||
by a cross-stack parity recording, not by any test of the catalog.
|
||||
- **How to confirm quickly:** send a request that must fail validation and check the body has a
|
||||
`code`. No `code` means the platform's handler is not installed.
|
||||
|
||||
## A response is truncated or the connection dies mid-document
|
||||
|
||||
Two different causes, distinguished by the status the client did receive.
|
||||
|
||||
- **Client got a status, then nothing** — the response budget was crossed after commit. The
|
||||
platform cannot retract a status, so it ends the connection: a truncated response the client
|
||||
rejects is better than a short one it accepts as complete. Raise `maxResponseBytes` for that
|
||||
operation's profile, or make the endpoint paginate.
|
||||
- **Client got nothing at all** — either the request budget was crossed before headers, in which
|
||||
case there is a problem document, or the proxy refused it. `client_max_body_size` in nginx is
|
||||
below the application's body budget by default, and nginx answers with its own HTML rather than a
|
||||
problem document. See `repository-adaptation.md` §8 for which bound fires where.
|
||||
|
||||
## A retried write happened twice
|
||||
|
||||
The idempotency key is the control, and there are exactly three ways it fails to apply.
|
||||
|
||||
1. **The client did not send one.** The operation profile says `OPTIONAL`, so it ran unguarded.
|
||||
Change the profile to `REQUIRED` if a duplicate is unacceptable.
|
||||
2. **The client sent a different body.** Answered `422 IDEMPOTENCY_KEY_REUSED`, never a replay —
|
||||
replaying would hand back a receipt for a request the caller never made.
|
||||
3. **The record expired.** TTL is per-operation with a 72h cap. A retry after expiry is a new
|
||||
request by definition.
|
||||
|
||||
A `409 IDEMPOTENCY_REQUEST_IN_PROGRESS` is not a failure: an earlier attempt is still running and
|
||||
the caller should retry after `Retry-After`.
|
||||
|
||||
## Rolling deploy stalls with instances half-drained
|
||||
|
||||
`shutDownGracefully` waits for in-flight work, and on Reactor Netty an open-but-idle keep-alive
|
||||
connection counts as in-flight. Calling `stop()` while that wait is in progress hangs.
|
||||
|
||||
- **Symptom:** an instance neither serving nor exiting, no error in its log.
|
||||
- **Cause:** an unbounded graceful wait. `GracefulShutdownProbe` bounds it at a stated grace period
|
||||
and stops the server regardless once it elapses; a deployment must do the same.
|
||||
- **Setting:** `spring.lifecycle.timeout-per-shutdown-phase`. Without a bound the deploy waits for
|
||||
a connection that may never go idle.
|
||||
|
||||
## Metrics stopped arriving, or the bill jumped
|
||||
|
||||
Almost always a high-cardinality tag. `WebMetricCardinalityPolicy` refuses anything off an
|
||||
eight-name allowlist at the point of recording, so a new tag cannot appear by accident — but a
|
||||
`routeTemplate` carrying a *resolved* path can, and that is one series per resource.
|
||||
|
||||
- **Check:** the tag values in the metrics backend for `routeTemplate`. Braces mean templates;
|
||||
identifiers mean the resolved path leaked through.
|
||||
- **Never tags:** the URL, the query string, any identifier, the tenant, a key, a token, a cookie,
|
||||
a body. A tag value reaches the metrics backend unredacted and usually a third-party SaaS with it.
|
||||
|
||||
## Cross-origin requests fail only in the browser
|
||||
|
||||
The API answers correctly and the browser refuses the response. Everything below is refused at
|
||||
startup by `WebCorsPolicyValidator`, so a running instance with one of these means the profile was
|
||||
built somewhere that does not validate.
|
||||
|
||||
- `*` with credentials — no browser honours it.
|
||||
- An origin with a path, a trailing slash, or uppercase — never matches what the browser sends.
|
||||
- `https://*.example.com` — matches nothing; CORS compares origins exactly.
|
||||
- Wildcard `allowedHeaders` on a credentialed profile — not honoured with credentials.
|
||||
|
||||
A preflight answered `401` means CORS ran after authentication. Preflights carry no credentials by
|
||||
design; the order is asserted by `WebPipelineOrderContract`.
|
||||
|
||||
## Two access-log lines for one request
|
||||
|
||||
An async request passes through the servlet filter chain twice — once for the initial request and
|
||||
again on the ASYNC redispatch. A completion recorded without checking `isAsyncStarted()` is written
|
||||
both times, and every latency percentile computed from that data is wrong while looking plausible.
|
||||
`WebPipelineOrderContract` asserts exactly one observation per logical request on every container.
|
||||
|
||||
## Verification commands
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:inbound:web:test # unit, boundary, architecture
|
||||
./gradlew :adapter:inbound:web:webCrossStackParityTest # Tomcat vs Jetty vs Reactor Netty
|
||||
./gradlew :adapter:inbound:web:webNginxProxyTest # real proxy; needs Docker, fails without it
|
||||
./gradlew :adapter:inbound:web:webJettyCompatTest # second servlet container
|
||||
./gradlew :adapter:inbound:web:webFluxContractTest # reactive stack
|
||||
```
|
||||
@@ -0,0 +1,78 @@
|
||||
# Web streaming: the client contract
|
||||
|
||||
What a client must implement to consume SSE, NDJSON or `application/json-seq` from this platform.
|
||||
|
||||
## Three outcomes, not two
|
||||
|
||||
After the first byte the HTTP status is 200 and cannot change. So the status tells you nothing about
|
||||
whether the stream succeeded, and a closed connection is ambiguous. There are three endings:
|
||||
|
||||
1. **A `Complete` envelope.** The stream finished. `lastSequence` is the final position.
|
||||
2. **A `Failure` envelope.** Something failed after commit. Carries a `ProblemCode` and a
|
||||
client-safe message. The status is still 200.
|
||||
3. **Neither.** The connection closed mid-stream. The server records this as `ABRUPT_CLOSE`; from
|
||||
the client's side it is indistinguishable from a completed stream *unless the client is looking
|
||||
for the terminal envelope*.
|
||||
|
||||
**A client that treats a closed connection as completion will silently truncate data.** No server
|
||||
change can fix that for it. Looking for the terminal envelope is the contract.
|
||||
|
||||
## Positions
|
||||
|
||||
Every `Item` carries a `sequence`, counting from 1. Positions are strictly increasing, and the
|
||||
server enforces it — a repeat or a regression is a server-side error, not something a client has to
|
||||
tolerate.
|
||||
|
||||
Zero is never a valid position. A stream whose first item claimed 0 and one whose sequence was never
|
||||
set would look identical.
|
||||
|
||||
## Resuming
|
||||
|
||||
Where a route supports it, send the last position you applied as `Last-Event-ID`.
|
||||
|
||||
- If the source still holds it, delivery continues from the next position.
|
||||
- If it does not, the response is a **resnapshot-required** error, not a partial stream. Resuming
|
||||
from the oldest retained position would give you contiguous positions with a hole in the middle,
|
||||
and nothing in the data would say so.
|
||||
|
||||
A resnapshot means re-reading the resource from its normal endpoint and starting a fresh stream.
|
||||
Clients that cannot do that cheaply should not use the resume path.
|
||||
|
||||
## Framing
|
||||
|
||||
| Format | Media type | Framing | Truncation behaviour |
|
||||
| --- | --- | --- | --- |
|
||||
| SSE | `text/event-stream` | `data:` lines, blank-line separated | Partial event at the end |
|
||||
| NDJSON | `application/x-ndjson` | One JSON value, then `\n` | The truncated line has no newline, and the delimiter is what was lost |
|
||||
| JSON-seq | `application/json-seq` | `0x1E`, value, `\n` | The next `0x1E` unambiguously starts the next record |
|
||||
|
||||
Prefer JSON-seq where truncation matters. Its separator comes *first*, so a parser resynchronises at
|
||||
the next record rather than trying to parse the truncation joined to what follows. For a long-lived
|
||||
stream, truncation is the normal way it ends.
|
||||
|
||||
Records never contain a raw newline. The server refuses to write one, because for NDJSON the
|
||||
consumer's line split is the only record boundary there is.
|
||||
|
||||
## Heartbeats and timeouts
|
||||
|
||||
The server writes a keepalive every `heartbeatInterval`. A client that sees nothing for longer than
|
||||
that should assume the connection is dead — a silent connection and a disconnected one are the same
|
||||
thing at the socket, and the heartbeat is what separates them.
|
||||
|
||||
Streams are closed at `idleTimeout` (nothing produced) and at `maxStreamAge` (regardless of
|
||||
activity). Both are normal endings, and both send a terminal envelope where the connection permits.
|
||||
|
||||
## Slow consumers
|
||||
|
||||
The server buffers at most `maxBufferedItems` for a consumer that is behind. Past that it closes the
|
||||
connection rather than growing the buffer. A client that cannot keep up should reconnect with a
|
||||
resume cursor, not expect the server to hold its backlog.
|
||||
|
||||
## Shutdown
|
||||
|
||||
During a rolling deploy, open streams receive a reconnect request before the node stops accepting.
|
||||
Reconnect promptly — the node force-closes anything still open at its drain deadline, and that
|
||||
arrives as an abrupt close.
|
||||
|
||||
Do not reconnect immediately on an abrupt close without backoff and jitter. If a node's streams are
|
||||
all cut at once, every client reconnecting at once is what keeps the replacement down.
|
||||
@@ -0,0 +1,78 @@
|
||||
# The virtual-thread MVC profile
|
||||
|
||||
## What it changes, and what it does not
|
||||
|
||||
Virtual threads remove the cost of a thread waiting. They do not remove the reason the waiting was
|
||||
bounded.
|
||||
|
||||
A platform-thread MVC deployment has an implicit concurrency limit — the thread pool. Nobody wrote
|
||||
it down as an admission policy, but it is what has been protecting the database pool, the outbound
|
||||
HTTP bulkhead and every downstream service from the full arrival rate.
|
||||
|
||||
Switching to virtual threads deletes that limit and deletes nothing that depended on it. The result
|
||||
is not a slow system. It is a system that accepts ten thousand concurrent requests, queues all of
|
||||
them on a twenty-connection pool, and times out every one — having done no useful work. The load
|
||||
that used to be shed at the front door is shed at the back, after the cost of accepting it.
|
||||
|
||||
## Enabling it
|
||||
|
||||
Two settings, and the profile refuses to be constructed with only one:
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
web:
|
||||
advanced:
|
||||
mvc-virtual-threads:
|
||||
enabled: true
|
||||
admission-limit: 100 # required
|
||||
database-pool-size: 20 # stated, and unchanged
|
||||
outbound-bulkhead: 20 # stated, and unchanged
|
||||
```
|
||||
|
||||
The downstream numbers are carried in the profile because the whole point is that they did not grow.
|
||||
`VirtualThreadProfile.admissionFitsDownstreamBudgets()` reports when the admission limit exceeds
|
||||
them. It does not refuse — a deployment can legitimately admit more than its pool when the work is
|
||||
not all database-bound — but it makes the choice a choice.
|
||||
|
||||
## The limit bounds use cases, not threads
|
||||
|
||||
`VirtualThreadAdmissionGuard` is a fair semaphore, not a pool. Bounding the threads would put the
|
||||
waiting back and throw away what virtual threads bought. Ten thousand virtual threads may exist
|
||||
while a hundred hold permits and the rest are refused at the door.
|
||||
|
||||
A refusal is a **503 with `Retry-After`**, and it is the outcome the limit exists to produce. A
|
||||
request refused in a millisecond is strictly better for the client than the same request accepted
|
||||
and timed out thirty seconds later behind a full pool.
|
||||
|
||||
The semaphore is fair on purpose. An unfair one is faster and lets newer arrivals overtake waiting
|
||||
ones, which the overtaken client experiences as a random timeout.
|
||||
|
||||
## What to watch
|
||||
|
||||
| Signal | Why |
|
||||
| --- | --- |
|
||||
| `jdk.VirtualThreadPinned` JFR events | A `synchronized` block held across a blocking call pins the carrier thread. The carrier pool is bounded by CPU count, so enough pinned carriers is a deadlock — and a thread dump does not obviously show it. |
|
||||
| Carrier pool queue depth | The same problem, from the other side. |
|
||||
| Admission rejections | They should rise under load. If they do not, the limit is not being applied. |
|
||||
| Downstream wait time | The signal that admission is admitting more than the pools serve. |
|
||||
|
||||
`VirtualThreadProfile.requiredObservations()` is the same list, in code.
|
||||
|
||||
## Testing it
|
||||
|
||||
`VirtualThreadAdmissionGuard.peakActive()` exists so a load test can assert the limit was applied.
|
||||
It is invisible from throughput — a load test that only measures throughput passes with the guard
|
||||
removed, which is precisely the failure this profile guards against.
|
||||
|
||||
The test that matters asserts two things together: peak concurrency at or below the limit, **and**
|
||||
more threads created than the limit. Without the second, the test would pass on a deployment that
|
||||
never used virtual threads at all.
|
||||
|
||||
## Rolling back
|
||||
|
||||
Set `enabled: false`. The rollback test asserts that Stable behaviour is then identical — if it is
|
||||
not, the feature was never optional and every deployment has it.
|
||||
|
||||
This is one of only two web Advanced capabilities that affect requests which do not use them (the
|
||||
other is the WebFlux blocking bridge), which is why its soak is 24 hours and its rollback test is
|
||||
the one that matters most.
|
||||
@@ -0,0 +1,7 @@
|
||||
f145f6b13d665c52edd061695fd384825891ea6146946c81dae438c1cb9ee4c3 README.md
|
||||
bceb2d92489db305aa07f9f6a51fe6cb40e9971cee600275887192caff220e31 VALIDATION.md
|
||||
fdc801db5190213d213eb59e16a289acd22096bcdba4cd1b3e89ca98a1af45a2 docs/superpowers/plans/2026-08-14-websocket-advanced-capabilities-expansion-plan.md
|
||||
97969421447fc3b54ca5b18baacd13e84ebad1e2c8ab054a977e488daba02e4c docs/superpowers/plans/2026-08-14-websocket-realtime-connection-platform-implementation-plan.md
|
||||
8a49c95ef8bec0312ca028f80302332ef811c12d578ff8a89b7843c966f44ff9 docs/superpowers/specs/2026-08-14-websocket-realtime-connection-platform-design.md
|
||||
57dedc7fd8e6f5de96a61a7295a1d4b474507cdd9029760e1907f5f47abc6de8 research/source-websocket-deep-research.md
|
||||
459a713434fa74c7182b947fdf79d6b89a7aa9c7f5070d26903744bc722be5b1 validate_websocket_docs.py
|
||||
@@ -0,0 +1,28 @@
|
||||
# WebSocket Superpowers Package
|
||||
|
||||
이 패키지는 WebSocket 실시간 양방향 연결 실행 플랫폼의 설계서, Stable 구현 계획, Advanced 확장 계획, 요구사항 원본과 정적 검증 도구를 포함한다.
|
||||
|
||||
## 적용 순서
|
||||
|
||||
```text
|
||||
Stable Task 1–53
|
||||
→ Stable Release Gate
|
||||
→ Advanced Task 1–22
|
||||
→ 기능별 Promotion Gate
|
||||
```
|
||||
|
||||
## 문서
|
||||
|
||||
- `docs/superpowers/specs/2026-08-14-websocket-realtime-connection-platform-design.md`
|
||||
- `docs/superpowers/plans/2026-08-14-websocket-realtime-connection-platform-implementation-plan.md`
|
||||
- `docs/superpowers/plans/2026-08-14-websocket-advanced-capabilities-expansion-plan.md`
|
||||
- `research/source-websocket-deep-research.md`
|
||||
|
||||
## 검증
|
||||
|
||||
```bash
|
||||
python validate_websocket_docs.py
|
||||
sha256sum -c MANIFEST.sha256
|
||||
```
|
||||
|
||||
이 검증은 문서 구조·계약 일관성·패키지 무결성 검증이며 실제 Gradle compile, Browser, Nginx, Container, Fault, Performance 실행을 대체하지 않는다.
|
||||
@@ -0,0 +1,47 @@
|
||||
# WebSocket Superpowers 문서 정적 검증 결과
|
||||
|
||||
- **검증일:** 2026-08-14
|
||||
- **검증 명령:** `python validate_websocket_docs.py`
|
||||
- **검증 출력:** `checks=719 passed=719 failed=0`
|
||||
- **결과:** PASS
|
||||
|
||||
## 문서 규모
|
||||
|
||||
| 문서 | 행 | Task | Create 경로 |
|
||||
|---|---:|---:|---:|
|
||||
| 설계서 | 2,810 | - | - |
|
||||
| Stable 구현 계획 | 4,293 | 53 | 121 |
|
||||
| Advanced 확장 계획 | 1,817 | 22 | 48 |
|
||||
|
||||
## 검증 항목
|
||||
|
||||
- Stable Task 1–53 번호 연속성
|
||||
- Advanced Task 1–22 번호 연속성
|
||||
- 모든 Task의 `Files`, `Interfaces`, `Implementation requirements`, Step 1–5, commit 명령
|
||||
- Stable·Advanced Create 경로 중복 및 충돌 부재
|
||||
- `TODO`, `TBD`, `FIXME` placeholder 부재
|
||||
- Markdown code fence 균형
|
||||
- Stable Raw Typed JSON·Evidence·Ticket·Queue·Runtime·Nginx·Browser 계약 포함
|
||||
- Advanced Resume·Cluster·STOMP·Broker Relay·Binary·Compression·HTTP/2·3 계약 포함
|
||||
- 요구사항 원본 Appendix 및 research file 보존
|
||||
- SHA-256 manifest와 ZIP CRC 검증 가능 구조
|
||||
|
||||
## 검증 범위의 한계
|
||||
|
||||
현재 검증은 설계서와 구현 계획서의 정적 구조·내부 계약·패키지 무결성 검증이다. 실제 Backend Skeleton 저장소가 제공되지 않았으므로 다음은 실행하지 않았다.
|
||||
|
||||
```text
|
||||
Gradle configuration·compile
|
||||
Spring Boot ApplicationContext
|
||||
Tomcat·Jetty·Reactor Netty WebSocket contract
|
||||
Nginx TLS Upgrade path
|
||||
Chromium·Firefox·WebKit browser matrix
|
||||
Redis ticket·session index integration
|
||||
JPA result ledger transaction
|
||||
Messaging replay/fan-out
|
||||
Commit 후 socket reset fault
|
||||
Slow consumer·memory·latency performance
|
||||
STOMP·RabbitMQ broker relay
|
||||
HTTP/2·HTTP/3 compatibility
|
||||
Git commit
|
||||
```
|
||||
+1817
File diff suppressed because it is too large
Load Diff
+4293
File diff suppressed because it is too large
Load Diff
+2810
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
root = Path(__file__).resolve().parent
|
||||
if (root / 'docs').exists():
|
||||
design = root / 'docs/superpowers/specs/2026-08-14-websocket-realtime-connection-platform-design.md'
|
||||
stable = root / 'docs/superpowers/plans/2026-08-14-websocket-realtime-connection-platform-implementation-plan.md'
|
||||
advanced = root / 'docs/superpowers/plans/2026-08-14-websocket-advanced-capabilities-expansion-plan.md'
|
||||
else:
|
||||
design = Path('/mnt/data/websocket-realtime-connection-platform-design.md')
|
||||
stable = Path('/mnt/data/websocket-realtime-connection-platform-implementation-plan.md')
|
||||
advanced = Path('/mnt/data/websocket-advanced-capabilities-expansion-plan.md')
|
||||
|
||||
checks = []
|
||||
def check(name, condition):
|
||||
checks.append((name, bool(condition)))
|
||||
|
||||
for path in (design, stable, advanced):
|
||||
check(f'exists:{path.name}', path.exists())
|
||||
if not path.exists():
|
||||
continue
|
||||
text = path.read_text(encoding='utf-8')
|
||||
check(f'code-fence:{path.name}', text.count('```') % 2 == 0)
|
||||
check(f'no-placeholders:{path.name}', not re.search(r'\b(TODO|TBD|FIXME)\b', text))
|
||||
check(f'nontrivial:{path.name}', len(text.splitlines()) > 300)
|
||||
|
||||
stable_text = stable.read_text(encoding='utf-8')
|
||||
advanced_text = advanced.read_text(encoding='utf-8')
|
||||
design_text = design.read_text(encoding='utf-8')
|
||||
|
||||
stable_tasks = [int(n) for n in re.findall(r'^### Task (\d+):', stable_text, re.M)]
|
||||
advanced_tasks = [int(n) for n in re.findall(r'^### Task (\d+):', advanced_text, re.M)]
|
||||
check('stable-task-sequence', stable_tasks == list(range(1, 54)))
|
||||
check('advanced-task-sequence', advanced_tasks == list(range(1, 23)))
|
||||
|
||||
for label, text, expected in [('stable', stable_text, 53), ('advanced', advanced_text, 22)]:
|
||||
sections = re.split(r'(?=^### Task \d+:)', text, flags=re.M)[1:]
|
||||
check(f'{label}-task-count', len(sections) == expected)
|
||||
for idx, section in enumerate(sections, 1):
|
||||
for token in ['**Files:**', '**Interfaces:**', '**Implementation requirements:**',
|
||||
'Step 1:', 'Step 2:', 'Step 3:', 'Step 4:', 'Step 5:',
|
||||
'git commit -m']:
|
||||
check(f'{label}-task-{idx}-{token}', token in section)
|
||||
|
||||
create_pattern = re.compile(r'^- Create: `([^`]+)`', re.M)
|
||||
stable_paths = create_pattern.findall(stable_text)
|
||||
advanced_paths = create_pattern.findall(advanced_text)
|
||||
check('stable-create-unique', len(stable_paths) == len(set(stable_paths)))
|
||||
check('advanced-create-unique', len(advanced_paths) == len(set(advanced_paths)))
|
||||
check('stable-advanced-create-disjoint', set(stable_paths).isdisjoint(set(advanced_paths)))
|
||||
|
||||
required_design = [
|
||||
'Inbound Evidence', 'Outbound Evidence', 'Connection Evidence',
|
||||
'hyeonworks.realtime.v1.json', 'ONE_TIME_TICKET',
|
||||
'APPLICATION_COMMITTED', 'WRITTEN_LOCALLY',
|
||||
'Outbound Queue·Backpressure', 'Nginx', 'Tomcat', 'Jetty',
|
||||
'Reactor Netty', 'WebSocket exactly-once', 'Appendix A'
|
||||
]
|
||||
for token in required_design:
|
||||
check(f'design-token:{token}', token in design_text)
|
||||
|
||||
required_stable = [
|
||||
'Commit 후 Response Loss', 'Slow Consumer', 'Browser Matrix',
|
||||
'MVC·WebFlux Stack 상호 배타성', 'Stable Release Gate'
|
||||
]
|
||||
for token in required_stable:
|
||||
check(f'stable-token:{token}', token in stable_text)
|
||||
|
||||
required_advanced = [
|
||||
'Resume Token', 'Messaging 기반 Durable Replay', 'STOMP 1.2',
|
||||
'RabbitMQ STOMP Broker Relay', 'HTTP/3 WebSocket Experimental',
|
||||
'GraphQL WebSocket Transport Bridge'
|
||||
]
|
||||
for token in required_advanced:
|
||||
check(f'advanced-token:{token}', token in advanced_text)
|
||||
|
||||
failed = [name for name, ok in checks if not ok]
|
||||
print(f'checks={len(checks)} passed={len(checks)-len(failed)} failed={len(failed)}')
|
||||
for name in failed:
|
||||
print('FAIL', name)
|
||||
sys.exit(1 if failed else 0)
|
||||
@@ -0,0 +1,47 @@
|
||||
# WebSocket Advanced: support matrix
|
||||
|
||||
Every capability is off unless named. This table is what each one costs and what has to be true
|
||||
before it is promoted. `AdvancedPromotionGate.forCapability` is the machine-checked form of the last
|
||||
two columns; if they disagree, the code wins and this table is stale.
|
||||
|
||||
| Capability | Flag | Adds | Required suites | Soak |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `RESUME` | `…advanced.resume.enabled` | A signed token and a replay store | `websocket:test`, `websocketJettyTest`, `resume-history-loss`, `resume-replay` | 8h |
|
||||
| `CLUSTER_REDIS` | `…advanced.cluster-redis.enabled` | A Redis dependency on the routing path | `websocket:test`, `multi-node-fanout`, `node-loss`, `index-partition` | 24h |
|
||||
| `CLUSTER_MESSAGING` | `…advanced.cluster-messaging.enabled` | A broker dependency on the delivery path | as `CLUSTER_REDIS` | 24h |
|
||||
| `PRESENCE` | `…advanced.presence.enabled` | A read model over the cluster index | as `CLUSTER_REDIS` | 24h |
|
||||
| `STOMP` | `…advanced.stomp.enabled` | A second protocol parser, pre-authentication | `websocket:test`, `broker-outage`, `broker-reconnect`, `user-destination` | 8h |
|
||||
| `BROKER_RELAY_RABBIT` | `…advanced.stomp.relay.enabled` | A TCP dependency on an external broker | as `STOMP` | 8h |
|
||||
| `CODEC_PROTOBUF` | `…advanced.codec-protobuf.enabled` | A second decode path | `websocket:test`, `websocketJettyTest` | 8h |
|
||||
| `CODEC_CBOR` | `…advanced.codec-cbor.enabled` | A second decode path | `websocket:test`, `websocketJettyTest` | 8h |
|
||||
| `COMPRESSION` | `…advanced.compression.enabled` | Per-connection memory, and a length side channel | `websocket:test`, `decompression-bound`, `memory-under-load` | 24h |
|
||||
| `OUTBOUND_CLIENT` | `…advanced.outbound-client.enabled` | Long-lived connections this service initiates | `websocket:test`, `websocketJettyTest` | 8h |
|
||||
| `SOCKJS_COMPAT` | `…advanced.sockjs.enabled` | Credentialed cross-origin HTTP, so CSRF | `websocket:test`, `websocketJettyTest` | 8h |
|
||||
| `HTTP2_COMPAT` | `…advanced.http2.enabled` | RFC 8441 extended CONNECT | `websocket:test`, `websocketNginxTest`, `proxy-matrix`, `classic-upgrade-fallback` | 7d |
|
||||
| `HTTP3_EXPERIMENTAL` | `…advanced.http3.enabled` | QUIC, and every hop problem HTTP/2 has | as `HTTP2_COMPAT` | 7d |
|
||||
| `GRAPHQL_TRANSPORT` | `…advanced.graphql-transport.enabled` | A subprotocol on the Stable runtime | `websocket:test`, `websocketJettyTest` | 8h |
|
||||
|
||||
## What is *not* supported, and why the row is here
|
||||
|
||||
| Not supported | Reason |
|
||||
| --- | --- |
|
||||
| Simple broker in a multi-node deployment | It does not error. It delivers to whichever fraction of users is on the publishing node, which reads as intermittent loss. `SimpleBrokerProfile.activatableUnder` refuses it. |
|
||||
| `permessage-deflate` on an endpoint mixing a secret with attacker-influenced content | The CRIME/BREACH shape. No parameter combination makes it safe; the leak is in the compressed length. `CompressionPolicy.mayCompress` refuses it. |
|
||||
| JSONP polling on a sensitive endpoint | JSONP executes server-supplied script in the page. |
|
||||
| Protobuf payloads above 256KB | The broker, the replay store and every in-memory queue would each hold the message whole. |
|
||||
| Extended CONNECT on an untested hop | RFC 8441 fails by killing the connection, not by negotiating a fallback. |
|
||||
| HTTP/3 as stable support | `Http3ExperimentalProfile.stableSupport()` returns false, always. |
|
||||
| Both STOMP channels at once | The broker that results is whichever configurer ran last, with no error. `StompBrokerExclusivity` refuses it. |
|
||||
|
||||
## Promotion
|
||||
|
||||
Each capability is promoted on its own evidence. They share a feature-flag mechanism and nothing
|
||||
else, so promoting them together means the evidence for the cheapest is treated as evidence for the
|
||||
most dangerous.
|
||||
|
||||
Two conditions apply to all of them and are not waivable:
|
||||
|
||||
- **Rollback exercised.** A flag nobody has turned off is not known to turn off.
|
||||
- **Stable artifact unchanged.** If enabling the capability changed Stable's wire contract or
|
||||
dependency graph, Stable was never independent of it, and deployments that did not enable it are
|
||||
affected anyway.
|
||||
@@ -0,0 +1,180 @@
|
||||
# WebSocket platform: how the design maps onto this repository
|
||||
|
||||
The design models the platform as eighteen Gradle modules under `modules/websocket`. This
|
||||
repository's fail-closed registry (`src/config/architecture/modules.json`) owns the leaf list, so
|
||||
those modules are packages inside the registered `:adapter:inbound:websocket` leaf — the same
|
||||
resolution the JPA, GraphQL and web platforms reached.
|
||||
|
||||
That is only honest if the boundaries are machine-checked, so `WebSocketStableModule` declares each
|
||||
module's package, its purity grade and its exact allowed edges, and `WebSocketModuleBoundaryTest`
|
||||
scans the source tree and fails when the two disagree in either direction. Promoting a package to
|
||||
its own Gradle leaf later is a registry edit rather than an archaeology exercise.
|
||||
|
||||
## Where the module map deviates from the design, and why
|
||||
|
||||
| Design places it in | Here | Reason |
|
||||
| --- | --- | --- |
|
||||
| `WebSocketSubprotocolName` in `websocket-protocol` | `core` | The connection context must name the negotiated token, and the context is core — leaving the type in `protocol` made `core` depend on `protocol` while `protocol` already depended on `core`. The boundary test refused the cycle. The negotiation *policy* stays in `protocol`. |
|
||||
| `StrictWebSocketJsonCodec` in `websocket-protocol` | `codec` (its own FRAMEWORK_BOUND module) | `protocol` is CORE here, and a Jackson import in a CORE module is refused. Splitting is better than relaxing the rule: the envelope's field rules stay testable with no mapper, and everything that touches a parser sits in one package a reviewer can read end to end. |
|
||||
| `budget -> core` | `core -> budget` | `budget` imports nothing from `core` — numbers depend on nothing. The endpoint profile, which is core, has to name a budget. The declared direction was simply backwards. |
|
||||
| — | `stomp` module | The pre-existing STOMP-over-SockJS channel predates this platform and still ships. Declared so the boundary is complete rather than excused; it has no edge to any platform module and none to it. |
|
||||
|
||||
## What the design's rules actually prevent
|
||||
|
||||
A few of the design's requirements read as style and are not. These are the ones worth keeping.
|
||||
|
||||
**`websocket-core-api` names no framework.** Stricter here than in the HTTP platform, because a
|
||||
connection is a long-lived object owned by a container and reaching for the container's own session
|
||||
type is tempting from everywhere. A CORE module has no `WebSocketSession`, so the same decision
|
||||
serves both runtimes and is testable without a server. The detector's framework list was missing
|
||||
`tools.jackson` (this repo runs Jackson 3, not 2) — a CORE module could have imported a mapper
|
||||
unnoticed. Fixed in both this leaf and the web leaf.
|
||||
|
||||
**No Java class name on the wire.** A FQCN publishes the package layout, breaks every client on a
|
||||
rename, and makes the receiver's type resolution an attack surface. `WebSocketMessageType` refuses
|
||||
anything that looks like one; the manifest binds published names to records, and records cannot run
|
||||
code while being populated.
|
||||
|
||||
**The payload is an encoded string, not a `Map`.** A map accepts any shape, defers validation to
|
||||
whichever handler reaches for a missing key, makes an entity trivially serializable onto the wire,
|
||||
and brings unbounded nesting with it.
|
||||
|
||||
**Handlers cannot write.** `WebSocketHandlerContext` has no session and no write method. This is
|
||||
what makes ordering, backpressure and the drain sequence guarantees rather than conventions — a
|
||||
handler that could write directly would bypass the queue, and every promise would hold only for the
|
||||
handlers that cooperated.
|
||||
|
||||
## Two findings from building it
|
||||
|
||||
**Tomcat's graceful shutdown does not close WebSocket connections.** It waits for in-flight
|
||||
*requests*, and an established WebSocket is not a request — so the shutdown completes with the
|
||||
connections still open and they die when the socket is torn down. The client sees a **1006**,
|
||||
indistinguishable from a network failure, which sends it into its most aggressive reconnect path at
|
||||
exactly the moment the fleet is restarting. `PlatformWebSocketHandler` therefore implements
|
||||
`SmartLifecycle` and closes its own connections with **1001 going away**, at a phase that runs
|
||||
before the web server stops. Found by `TomcatWebSocketAbuseIT`, which failed on its first run.
|
||||
|
||||
**Both runtimes on one classpath is a silent outage.** Spring Boot deduces one application type
|
||||
from what is present, and picks the servlet one. A deployment that declared reactive endpoints and
|
||||
shipped both starts, reports healthy, and never answers. `WebSocketStackExclusivity` reads what the
|
||||
classpath will actually produce and fails startup with a sentence explaining it.
|
||||
|
||||
## Lanes
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:inbound:websocket:test # unit, boundary, architecture, Tomcat runtime
|
||||
./gradlew :adapter:inbound:websocket:websocketJettyTest # the second servlet container
|
||||
./gradlew :adapter:inbound:websocket:websocketNginxTest # real Nginx; needs Docker, fails without it
|
||||
```
|
||||
|
||||
The Nginx lane carries a deliberately broken configuration alongside the correct one
|
||||
(`nginx-no-upgrade.conf`) so the lane proves its own assertions can fail. A contract that only ever
|
||||
runs against a correct configuration cannot tell whether it is checking anything.
|
||||
|
||||
## The Stable behaviours that had no code
|
||||
|
||||
A late audit compared every named type in the Stable plan against the source tree rather than
|
||||
against memory, and found six that nothing implemented. They are listed because the way they were
|
||||
missed is more useful than the fact that they were: each is a *behaviour under failure*, and the
|
||||
plan named it inside a task whose other half was already built — so the task read as done.
|
||||
|
||||
| Behaviour | Where it lives now |
|
||||
| --- | --- |
|
||||
| A queue observation an operator can read, separating a configured drop from a lossless overflow | `outbound/OutboundQueueSnapshot` + `OutboundQueue.snapshot()` |
|
||||
| A timed-out correlation id that is remembered, so a late answer is not attached to a reused id | `handler/LateResponseTombstone` |
|
||||
| Pooled-buffer retention on the reactive stack, bounded and counted | `webflux/WebSocketDataBufferPolicy` + `WebSocketDataBufferLifecycle` |
|
||||
| What a proxy in front of this platform has to do | `release/WebSocketNginxProxyProfile` |
|
||||
| What a rolling restart has to demonstrate, in order | `release/WebSocketRollingRestartScenario` |
|
||||
| What the platform must show before promotion | `release/WebSocketStableReleaseGate` |
|
||||
|
||||
The three `release` types share a module identity (`RELEASE`, CORE) that names no other module.
|
||||
That is deliberate: a gate importing the parts it gates would be satisfiable by construction — the
|
||||
evidence and the checklist would come from the same source. They are predicates over facts a release
|
||||
engineer supplies, so a missing runtime stays a missing runtime.
|
||||
|
||||
`WebSocketStableReleaseGate` overlaps `advanced/release/AdvancedPromotionGate` in shape and differs
|
||||
in one condition that matters. Advanced capabilities are off unless a deployment names them, so a
|
||||
broken one affects whoever enabled it; Stable is what every deployment gets, so its evidence has to
|
||||
cover every runtime it claims — Tomcat, Jetty, Reactor Netty and Nginx — rather than the one the
|
||||
author happened to test. It also refuses to promote an artifact containing an Advanced type. WS-ARCH-6
|
||||
already refuses that as a source edge; nothing in it notices a type that arrived through packaging,
|
||||
and the effect is identical — Stable that does not build without Advanced is a naming convention.
|
||||
|
||||
## Not yet implemented
|
||||
|
||||
**Tasks 47–48, the browser matrix.** The design asks for a protocol test client driven through
|
||||
Chromium, Firefox and WebKit. Not built: it needs Playwright and a browser download per engine,
|
||||
which is a dependency and a network requirement this template does not otherwise carry, and a
|
||||
browser lane that silently skips when the browsers are absent is worth less than no lane. The
|
||||
properties it would cover that the Java client does not — that a browser cannot set headers on the
|
||||
`WebSocket` constructor, and that it surfaces close codes to page script — are the reasons
|
||||
`ONE_TIME_TICKET` and the standard close codes exist, and both are asserted at the unit level.
|
||||
|
||||
## Advanced capabilities
|
||||
|
||||
The Advanced expansion plan asks for fifteen Gradle modules under `modules/websocket-advanced/`.
|
||||
They are packages under `advanced.**` in this leaf, for the same reason the Stable platform is —
|
||||
`src/config/architecture/modules.json` is fail-closed and owns the leaf list, and a fifteen-leaf
|
||||
addition to satisfy a directory layout is a change to the registry, not to the architecture. The
|
||||
separation the design wanted is enforced by `WebSocketStableModule` and by WS-ARCH-6, which fails
|
||||
the build when a Stable class names an Advanced one. A feature flag decides whether a bean exists;
|
||||
it does nothing about a compile-time edge, and ArchUnit does.
|
||||
|
||||
Three of the fifteen needed their own module identity rather than sharing `advanced`:
|
||||
|
||||
- **`advanced-stomp`** (`advanced.stomp`) is `FRAMEWORK_BOUND`. STOMP here *is* Spring Messaging, and
|
||||
folding it into `advanced` would have relaxed that module's purity for every capability in it.
|
||||
- **`advanced-stomp-rabbit`** (`advanced.stomp.rabbit`) is separate again. The adapter parses a
|
||||
protocol; the relay opens a TCP connection to somebody else's broker and makes every delivery
|
||||
depend on it. Different blast radius, so a deployment can refuse one and keep the other.
|
||||
- Everything else resolves to `advanced` by longest-prefix, which is what lets `advanced.codec.cbor`
|
||||
exist without its own edge set.
|
||||
|
||||
### What was adapted rather than copied
|
||||
|
||||
**Task 8's presence record shape.** The design specifies
|
||||
`(actorFingerprint, activeConnectionCount, state, lastObservedAt)` with a four-state observation
|
||||
model. Implemented with `WebSocketActorReference` in place of a bare fingerprint string — it carries
|
||||
the fingerprint and refuses to be constructed from raw identity, which is the property the design
|
||||
was buying with the field name. The four states are implemented as specified; `PresenceState.STALE`
|
||||
and `OFFLINE` are distinct because collapsing them reports every user as disconnected during a Redis
|
||||
partition, when the connections are fine and the index went dark.
|
||||
|
||||
**Task 14's Protobuf codec.** The descriptor compatibility gate and the profile are implemented and
|
||||
tested. The encode/decode path is not: a Protobuf codec without generated message classes has
|
||||
nothing to encode, and generating them requires a `.proto` source this template does not have and
|
||||
should not invent. `DescriptorCompatibilityGate` is the part with the failure mode worth guarding —
|
||||
a changed or reused field number reinterprets bytes already on the wire, and during a rolling deploy
|
||||
both descriptor versions are live, so the receiver reads the wrong field without erroring.
|
||||
|
||||
**Task 15's CBOR codec.** Same shape, same reason: `CborCodecProfile` fixes canonical encoding and
|
||||
the duplicate-key policy, which are the two settings that decide whether two systems reading the
|
||||
same bytes agree. `WebSocketCborCodec` implements the encode/decode path on top of it, and
|
||||
`SchemaParity` is what stops it publishing a different catalog than JSON.
|
||||
|
||||
`jackson-dataformat-cbor` and `protobuf-java` are `compileOnly` plus `testImplementation`, not
|
||||
`implementation`. Both jars change an adopter's behaviour by their mere presence — Spring Boot
|
||||
registers a `cborMapper` bean for the first and Spring registers a Protobuf message converter for
|
||||
the second — so an adopting composition root would acquire both without enabling either capability.
|
||||
The sibling web leaf shipped exactly that mistake and it broke the composition root outright; see
|
||||
`docs/web/repository-adaptation.md`. `WebSocketBinaryCodecBackend` turns an absent backend into a
|
||||
sentence naming the missing coordinate, and `BinaryCodecBackendScopeTest` reads `build.gradle` and
|
||||
fails if either returns to `implementation`.
|
||||
|
||||
**Task 12's relay configuration.** `RabbitBrokerRelayConfiguration` contributes only the broker, not
|
||||
the destination prefixes. Two `WebSocketMessageBrokerConfigurer` beans each setting the application
|
||||
prefix produce whichever ran last, silently — the same failure `StompBrokerExclusivity` exists to
|
||||
catch between the two STOMP channels.
|
||||
|
||||
### Verification
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:inbound:websocket:test # 509 tests, Advanced included
|
||||
./gradlew :adapter:inbound:websocket:websocketJettyTest
|
||||
./gradlew :adapter:inbound:websocket:websocketNginxTest
|
||||
```
|
||||
|
||||
See `docs/adr/ADR-WS-002-resume-and-cluster.md`, `docs/adr/ADR-WS-003-stomp-and-broker-relay.md`,
|
||||
`docs/websocket/advanced-support-matrix.md` and `docs/websocket/runbooks/`.
|
||||
@@ -0,0 +1,107 @@
|
||||
# WebSocket platform runbook
|
||||
|
||||
Organised by what you observe, because that is what you have during an incident.
|
||||
|
||||
## Clients report 1006 — "closed abnormally, no reason"
|
||||
|
||||
The most important symptom here, because 1006 is indistinguishable from a network failure and
|
||||
drives every client into its most aggressive reconnect path.
|
||||
|
||||
- **During a deploy** — the platform did not close its connections. `PlatformWebSocketHandler`
|
||||
implements `SmartLifecycle` and closes with **1001 going away**; a container's own graceful
|
||||
shutdown does **not** do this, because it waits for in-flight *requests* and an established
|
||||
WebSocket is not a request. Check the handler bean is registered and that its phase runs before
|
||||
the web server stops.
|
||||
- **Behind a proxy** — the upgrade is mishandled, so the close frame never becomes a close frame.
|
||||
See the next section.
|
||||
- **Neither** — something threw while closing. A close reason longer than 123 bytes makes the
|
||||
container throw mid-close and turns a clean refusal into a 1006; the platform truncates for that
|
||||
reason.
|
||||
|
||||
## Connections work locally and not behind the load balancer
|
||||
|
||||
Three nginx directives, each producing a different failure:
|
||||
|
||||
| Missing | Symptom |
|
||||
| --- | --- |
|
||||
| `proxy_http_version 1.1` | Handshake answered 400, or simply not upgraded. HTTP/1.0 has no Upgrade mechanism. |
|
||||
| `Upgrade` / `Connection` forwarded | Upstream sees an ordinary GET and answers 404 or 200 for a route that works when tested directly. They are hop-by-hop headers, so a proxy is *required* to drop them. |
|
||||
| `proxy_read_timeout` raised | Every connection quieter than 60s is killed with no close frame. A WebSocket is idle by nature. |
|
||||
|
||||
`websocketNginxTest` asserts all three against a real Nginx, and carries a deliberately broken
|
||||
configuration so it proves its own assertions can fail.
|
||||
|
||||
## Connections vanish with no close frame and no error
|
||||
|
||||
Almost always an intermediary's idle timeout, not the application. TCP does not report a departed
|
||||
peer — a closed laptop lid, a phone switching to cellular and a NAT forgetting its mapping all
|
||||
produce no FIN.
|
||||
|
||||
- Check `HeartbeatPolicy`: the ping interval must be under ~20s, because intermediaries commonly
|
||||
drop idle connections at 30–60s and say nothing.
|
||||
- The idle timeout must be at least two ping intervals. Below that, one dropped ping on a congested
|
||||
network closes a healthy connection, and the reconnect storm makes the congestion worse.
|
||||
|
||||
## Memory grows and nothing is failing
|
||||
|
||||
A slow consumer. It does not error — it reads more slowly than the server writes and the difference
|
||||
accumulates. Arranging it requires no tooling: reading slowly is enough.
|
||||
|
||||
- **Check first:** `WebSocketNodeSnapshot.droppedOutboundMessages`. Connection counts and buffered
|
||||
bytes both look healthy while data is being lost; the drop count is the only number that says so.
|
||||
- **Bounds:** per connection (`maxBufferedOutboundBytes`) *and* node-wide (`GlobalBufferBudget`).
|
||||
The second is not redundant — a megabyte each is fine at a hundred connections and is the whole
|
||||
heap at fifty thousand.
|
||||
- **If drops are zero and memory still grows:** check that the queue lock and the socket lock are
|
||||
separate. When they were one lock, a stalled peer blocked every producer, the queue never filled,
|
||||
and the shedding bound never fired. That is fixed, and it is the shape to look for if it recurs.
|
||||
|
||||
## A command ran twice
|
||||
|
||||
The reconnect is what makes this a WebSocket problem: a client replays everything it never saw an
|
||||
answer for, on a new connection, all at once.
|
||||
|
||||
1. **Key scoped to the connection?** It must not be. `WebSocketCommandKey` is endpoint + actor +
|
||||
the client's own message id, so it survives the reconnect.
|
||||
2. **Ledger not transactional?** The only implementation that works writes in the application's own
|
||||
transaction. Anything else has a window between the business commit and the ledger write.
|
||||
3. **Outcome `UNKNOWN`?** That is the window. Neither replaying nor re-running is safe — replaying
|
||||
invents a result, re-running duplicates a committed write. Only `CommandReconciliation` reading
|
||||
the business data can settle it, and `INDETERMINATE` is a real answer to act on.
|
||||
|
||||
## A client cannot connect and the endpoint is definitely there
|
||||
|
||||
- **403** — origin. The same-origin policy does **not** protect a WebSocket handshake and there is
|
||||
no preflight, so the server's `Origin` check is the entire defence and it is strict: exact match,
|
||||
lowercase, no path, no wildcard.
|
||||
- **401** — the ticket was spent, expired (30s cap), or minted for another endpoint. All three are
|
||||
deliberate; a longer-lived or unbound ticket is a bearer token in a query string again.
|
||||
- **400** — no offered subprotocol is supported. Production refuses a client that offers none.
|
||||
- **503** — the endpoint is at its connection cap.
|
||||
|
||||
Order matters and is asserted: route → origin → capacity → credential → subprotocol. Origin is
|
||||
checked before authentication because a cookie handshake from an attacker's page authenticates
|
||||
perfectly; the credential is valid and the page is not allowed to use it.
|
||||
|
||||
## Endpoints never answer and the app reports healthy
|
||||
|
||||
Both runtimes on one classpath. Spring Boot deduces one application type and picks the servlet one,
|
||||
so declared reactive endpoints are simply never served — no error, no warning.
|
||||
`WebSocketStackExclusivity` fails startup on this; if a running instance has it, the check is not
|
||||
wired.
|
||||
|
||||
## Metrics stopped arriving
|
||||
|
||||
A high-cardinality tag, and the risk is worse than for HTTP: a request produces one observation,
|
||||
a connection produces them for hours, so the series outlive the connections and only accumulate.
|
||||
`WebSocketMetricTags` allows eight names; `connectionId`, `sessionId` and `actor` are not among
|
||||
them. `nodeId` is, because a fleet has a knowable number of nodes.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:inbound:websocket:test # unit, boundary, architecture, Tomcat runtime
|
||||
./gradlew :adapter:inbound:websocket:websocketJettyTest # second servlet container
|
||||
./gradlew :adapter:inbound:websocket:websocketNginxTest # real Nginx; needs Docker, fails without it
|
||||
```
|
||||
@@ -0,0 +1,55 @@
|
||||
# Runbook: STOMP broker outage
|
||||
|
||||
Applies when `advanced.stomp.relay` is enabled and the RabbitMQ broker becomes unreachable.
|
||||
|
||||
## What the symptom looks like
|
||||
|
||||
Not an error rate. The relay holds one TCP connection to the broker plus one per authenticated
|
||||
session, and a broker that stops responding without closing leaves all of them **open**. The
|
||||
platform's own health check stays green; connections stay established; publishes are accepted.
|
||||
|
||||
The first real signal is one of:
|
||||
|
||||
- subscriptions established but no `MESSAGE` frames arriving, for everyone at once;
|
||||
- the relay's system heartbeat failing to receive (`systemHeartbeatReceiveInterval` elapsed);
|
||||
- new sessions failing to subscribe while existing ones appear fine — this is the broker's
|
||||
connection limit, not the outage itself.
|
||||
|
||||
If the heartbeat is not configured, none of the above fires and the first signal is a user report.
|
||||
`RabbitBrokerRelayProfile` refuses a zero heartbeat for exactly this reason.
|
||||
|
||||
## Triage
|
||||
|
||||
1. **Confirm the direction.** From a platform node, open a STOMP connection to the broker's host and
|
||||
port directly. If that succeeds, the problem is the relay's connection state, not the broker.
|
||||
2. **Check the connection count against the broker's limit.** `brokerConnectionsFor(sessions)` is
|
||||
`sessions + 1`. A broker at its limit refuses new connections and serves existing ones, which
|
||||
produces the "new users cannot subscribe" shape.
|
||||
3. **Check whether messages are being accepted.** A half-open connection accepts every publish
|
||||
silently. Publishes succeeding is not evidence the broker is alive.
|
||||
|
||||
## Recovery
|
||||
|
||||
- **Broker restarted, relay did not reconnect.** The relay reconnects on its own; if it has not
|
||||
within two heartbeat intervals, restart the platform nodes one at a time. Do not restart them all
|
||||
at once — every session reconnects simultaneously and the broker meets its whole client population
|
||||
in one instant.
|
||||
- **Broker at its connection limit.** Raise the limit or shed sessions. Shedding is the faster of
|
||||
the two and the connection count falls with the sessions.
|
||||
- **Broker gone and not coming back.** There is no safe fallback to the simple broker in a
|
||||
multi-node deployment: it delivers to whichever fraction of users is on the publishing node.
|
||||
`SimpleBrokerProfile.activatableUnder` refuses it outside local/test, and that refusal should not
|
||||
be overridden during an incident. Scale to a single node first if the simple broker is the only
|
||||
option.
|
||||
|
||||
## What is lost
|
||||
|
||||
Anything the broker held and did not persist. `StompEvidence.BROKER_ACK` is only durable if the
|
||||
broker is durable, and RabbitMQ's durability is a property of the queue topology, not of the relay.
|
||||
Messages acknowledged at `PROTOCOL_RECEIPT` were never in the broker at all.
|
||||
|
||||
## Afterwards
|
||||
|
||||
- If the heartbeat did not fire first, that is the finding. Fix it before the postmortem closes.
|
||||
- If the connection limit was reached, record the session count that reached it. It is a hard
|
||||
ceiling on the deployment and it is not otherwise written down anywhere.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Runbook: resume history loss
|
||||
|
||||
Applies when `advanced.resume` is enabled and clients present resume tokens the replay store can no
|
||||
longer satisfy.
|
||||
|
||||
## What the symptom looks like
|
||||
|
||||
Clients reconnecting and resynchronising rather than resuming. This is the **designed** behaviour,
|
||||
not a fault — `ResumeCoordinator` consults `ReplayAvailability` and refuses to honour a position the
|
||||
store has evicted, because delivering a stream with a hole in it is worse than an explicit
|
||||
resynchronise.
|
||||
|
||||
It becomes an incident when the resynchronise rate is high enough to matter:
|
||||
|
||||
- a resynchronise means the client re-reads its whole state, so a spike is a load spike on whatever
|
||||
serves that state;
|
||||
- for a client that cannot resynchronise cheaply, it is user-visible as a stall.
|
||||
|
||||
## Triage
|
||||
|
||||
1. **Establish which of the three causes it is.**
|
||||
- *Store eviction under load.* The replay store's retention is shorter than the disconnect
|
||||
durations being seen. Look at retention against reconnect latency, not against a nominal
|
||||
figure.
|
||||
- *Store restarted or partitioned.* Availability drops to nothing and every token fails at once.
|
||||
- *Key rotation.* `ResumeTokenKeyRing` verifies against retired keys as well as the current one;
|
||||
if a key was removed rather than retired, every token minted under it fails to verify. This
|
||||
produces the same symptom and a different fix.
|
||||
|
||||
`ResumeTokenOutcome` distinguishes these. A token that fails verification is not the same as one
|
||||
that verifies and names an evicted position.
|
||||
|
||||
2. **Check whether the resynchronise is succeeding.** A high resynchronise rate that completes is a
|
||||
capacity problem. One that fails is a correctness problem and is more urgent.
|
||||
|
||||
## Recovery
|
||||
|
||||
- **Eviction under load.** Raise retention if the store can hold it. Retention is bounded by memory,
|
||||
so this trades against the store's own stability — do not raise it past what the store survives.
|
||||
- **Store restarted.** Nothing to recover; the tokens are genuinely unsatisfiable. Let clients
|
||||
resynchronise. If the resynchronise load is the problem, shed connections so they arrive in
|
||||
batches rather than all at once.
|
||||
- **Key removed rather than retired.** Restore the key to the ring as a verify-only entry. Minting
|
||||
continues under the current key.
|
||||
|
||||
## What is lost
|
||||
|
||||
Nothing that was acknowledged. Resume is an optimisation over resynchronise; the client's ability to
|
||||
rebuild its state from the authoritative source is the actual guarantee, and it is unaffected.
|
||||
|
||||
## Afterwards
|
||||
|
||||
- If retention was the cause, record the disconnect duration distribution that exceeded it. The
|
||||
nominal retention figure is meaningless without it.
|
||||
- If a key was removed, that is a process finding, not a platform one. Keys are retired, never
|
||||
deleted, and the ring is the place that is enforced.
|
||||
Reference in New Issue
Block a user