feat: web, websocket 어댑터 추가 구현

This commit is contained in:
DongHyeonka
2026-08-28 17:01:27 +09:00
parent 0137263441
commit a24ece9cf7
883 changed files with 100584 additions and 2623 deletions
+59
View File
@@ -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`.
+57
View File
@@ -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.
+78
View File
@@ -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.
+357
View File
@@ -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`.
+116
View File
@@ -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
```
+78
View File
@@ -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.
+78
View File
@@ -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.