feat: web, websocket 어댑터 추가 구현
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user