feat: web, websocket 어댑터 추가 구현
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
# ADR-WEB-ADV-001: Streaming is live delivery, and the web module stores no history
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-25
|
||||
- Scope: `adapter:inbound:web` — `advanced.stream.**`
|
||||
|
||||
## Context
|
||||
|
||||
Advanced Tasks 6–13 add SSE, NDJSON and JSON text sequences, with a `Last-Event-ID` resume path.
|
||||
|
||||
One fact drives every decision here: **after the first byte, the HTTP status is 200 and cannot
|
||||
change.** A stream that ends because a dependency failed and one that ends because it finished are
|
||||
identical at the transport layer — both are a closed connection after a 200. So is a stream that was
|
||||
cut off mid-flight.
|
||||
|
||||
The second fact is that a resume path invites the web module to remember things. It must not: the
|
||||
messaging platform already owns durable event history, and a second copy would have its own
|
||||
retention, its own eviction and its own opinion about ordering.
|
||||
|
||||
## Decision
|
||||
|
||||
**Three outcomes, expressed in the stream rather than in the status.** `WebStreamEnvelope` is sealed
|
||||
over `Item`, `Failure` and `Complete`. A client that sees neither terminal envelope has been cut off,
|
||||
and that third case is recorded as `ABRUPT_CLOSE` rather than counted as a completion — which is
|
||||
where a rising rate of mid-stream failures would otherwise hide.
|
||||
|
||||
**Nothing writes a problem document onto a committed response.** `WebStreamTerminationMapper`
|
||||
branches on whether any byte has been written. Before commit, an RFC 9457 problem with a real
|
||||
status; after, a terminal record. Attempting both produces a body that is half stream and half JSON,
|
||||
which no client parses and every proxy caches as a success.
|
||||
|
||||
**Positions are monotonic, and it is enforced.** `WebStreamEvidence.recordDelivered` refuses a
|
||||
repeated or regressing position. A client deduplicating on position would silently drop the second
|
||||
item.
|
||||
|
||||
**A slow consumer is disconnected, not buffered.** `WebStreamPolicy.maxBufferedItems` is a hard
|
||||
bound. Backpressure protects the reactive pipeline; it does not protect the server's heap from a
|
||||
consumer that reads slowly for an hour.
|
||||
|
||||
**Every stream is in a registry, and shutdown drains it.** A node with a hundred open streams and no
|
||||
other traffic looks idle by request rate. `WebStreamDrainCoordinator` stops accepting first, asks
|
||||
clients to reconnect, and only then forces the remainder — because a client whose socket is cut
|
||||
retries immediately, and if every socket is cut at once, every client retries at once.
|
||||
|
||||
**The web module stores no durable history.** `WebStreamReplaySource` is an interface this module
|
||||
implements nowhere. An expired cursor raises `ReplayCursorExpiredException` rather than resuming from
|
||||
the oldest retained position, because that delivers a stream with a hole the client cannot see.
|
||||
|
||||
**The replay-to-live seam is watched.** `GapAndDuplicateGuard` detects both directions. Neither is
|
||||
visible in either half on its own.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Clients must handle three outcomes. A client that treats a closed connection as completion will be
|
||||
wrong, and no server change can fix that for it.
|
||||
- An expired `Last-Event-ID` costs the client a full re-read. That is the honest answer.
|
||||
- JSON-seq is preferred over NDJSON where truncation matters: its separator comes first, so a parser
|
||||
resynchronises at the next record. NDJSON's delimiter is the thing that gets truncated away.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Emit a problem document when a stream fails after commit.** Rejected: the body becomes
|
||||
unparseable and the 200 is cached.
|
||||
- **Resume from the oldest retained position when the cursor expires.** Rejected: positions are
|
||||
contiguous from where the replay started, so nothing in the data says events are missing.
|
||||
- **Store replay history in the web module.** Rejected: a second source of truth that drifts
|
||||
invisibly.
|
||||
- **Unbounded buffering for slow consumers.** Rejected: it moves the client's slowness into the
|
||||
server's heap.
|
||||
@@ -0,0 +1,66 @@
|
||||
# ADR-WEB-ADV-002: Virtual threads change scheduling, not the concurrency budget
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-25
|
||||
- Scope: `adapter:inbound:web` — `advanced.virtualthread`, `advanced.blockingbridge`
|
||||
|
||||
## Context
|
||||
|
||||
Advanced Task 2 offers a virtual-thread executor for MVC; Task 3 offers a bounded blocking bridge
|
||||
for WebFlux.
|
||||
|
||||
A platform-thread MVC deployment has an implicit concurrency limit — the thread pool — and that
|
||||
limit is usually what has been protecting the database pool, the outbound HTTP bulkhead and every
|
||||
downstream service from the full arrival rate. Nobody wrote it down as an admission policy; it was a
|
||||
side effect of the pool size.
|
||||
|
||||
Switching to virtual threads deletes that limit without deleting anything that depended on it.
|
||||
|
||||
## Decision
|
||||
|
||||
**An explicit admission limit is required when virtual threads are enabled.**
|
||||
`VirtualThreadProfile` refuses construction without one. Without it the deployment accepts every
|
||||
arrival, queues all of them on the downstream budgets, and times out work that would have succeeded
|
||||
had it been refused. The load that used to be shed at the front door is shed at the back, after the
|
||||
cost of accepting it.
|
||||
|
||||
**The limit bounds concurrent use cases, not threads.** `VirtualThreadAdmissionGuard` is a fair
|
||||
semaphore, not a pool. Bounding threads would put the waiting back and throw away what virtual
|
||||
threads bought. Ten thousand virtual threads may exist while a hundred hold permits.
|
||||
|
||||
**The downstream budgets are carried in the profile and stated as unchanged.** The whole point is
|
||||
that they did not grow. `admissionFitsDownstreamBudgets()` reports when the admission limit exceeds
|
||||
them, without refusing — a deployment can legitimately admit more than its pool when the work is not
|
||||
all database-bound, and that should be a choice rather than an accident.
|
||||
|
||||
**Blocking offloads are registered, bounded and timed out.** `boundedElastic()` is available from
|
||||
anywhere and unbounded in practice, so a controller that calls it has silently opted the whole
|
||||
application into an unbounded pool. `BlockingBridgeProfile` names the operations permitted to
|
||||
offload; `BlockingBridgeBudget` bounds the concurrency and refuses a caller that cannot get a slot
|
||||
in time, because otherwise a slow dependency's callers accumulate until the heap does and the fast
|
||||
dependencies starve behind them.
|
||||
|
||||
**Pinning is observed, not assumed away.** `VirtualThreadProfile.requiredObservations()` lists what
|
||||
has to be watched — `jdk.VirtualThreadPinned` above all. A synchronized block held across a blocking
|
||||
call pins the carrier thread, the carrier pool is bounded by CPU count, and enough pinned carriers is
|
||||
a deadlock a thread dump does not obviously show.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Enabling virtual threads is a two-part change: the executor and the admission limit. The profile
|
||||
will not let it be one.
|
||||
- Refusals rise under load, and that is correct. A request refused in a millisecond is better for
|
||||
the client than the same request accepted and timed out thirty seconds later behind a full pool.
|
||||
An operator seeing 503s climb should read them as the limit working.
|
||||
- `VirtualThreadAdmissionGuard.peakActive()` exists so a load test can assert the limit was applied.
|
||||
It is invisible from throughput, which is why a load test that only measures throughput would pass
|
||||
with the guard removed.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Enable virtual threads and raise the downstream budgets to match.** Rejected: the budgets are
|
||||
sized to what the dependencies can serve, not to what the web tier can accept.
|
||||
- **Bound the virtual threads themselves with a fixed-size executor.** Rejected: that is a platform
|
||||
thread pool with extra steps.
|
||||
- **Let controllers call `boundedElastic()` directly.** Rejected: every such call site is invisible
|
||||
until the pool is the thing consuming the heap.
|
||||
@@ -0,0 +1,55 @@
|
||||
# ADR-WEB-ADV-003: OpenAPI 3.2 is generated in parallel and stays experimental
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-25
|
||||
- Scope: `adapter:inbound:web` — `advanced.openapi`
|
||||
|
||||
## Context
|
||||
|
||||
Advanced Task 17 adds an OpenAPI 3.2 generation lane beside the Stable 3.1.2 snapshot.
|
||||
|
||||
Generating 3.2 is cheap. Adopting it is not, and the two get conflated because the generated
|
||||
document looks fine. The value of an API description is entirely in what consumes it, and a document
|
||||
in a version a client generator does not fully understand produces a client that compiles and is
|
||||
wrong — which is worse than no document at all.
|
||||
|
||||
## Decision
|
||||
|
||||
**3.1.2 remains the release artifact.** `OpenApiVersionLane.STABLE_3_1.releaseArtifact()` is true and
|
||||
`EXPERIMENTAL_3_2`'s is false. This is a property of the type, not a configuration setting.
|
||||
|
||||
**Generating 3.2 must not change the 3.1 snapshot.** Both are produced from the same model, so a
|
||||
contributor that mutates it on the way to 3.2 changes the artifact that is actually shipped —
|
||||
silently, and only when the experimental lane runs. `OpenApi32CompatibilityReport` compares the
|
||||
snapshot hash before and after and makes a difference a promotion blocker.
|
||||
|
||||
**Four kinds of tool are checked separately.** A parser reports structural errors; a linter applies
|
||||
style rules and accepts documents a parser rejects; a generator produces client code, and this is
|
||||
where an unsupported construct surfaces — not as an error but as a method with the wrong signature;
|
||||
a compile of that generated code is the only step that catches it. "OpenAPI 3.2 works" is not a
|
||||
statement anybody can make. "This document is read correctly by these four tools at these versions"
|
||||
is.
|
||||
|
||||
**Promotion requires an accepted ADR regardless of how green the matrix is.**
|
||||
`promotionBlockers(false)` always contains that blocker. A machine-checkable matrix cannot decide
|
||||
whether the consumer population is ready.
|
||||
|
||||
**Streaming description differences are reported separately.** They are the substantive difference
|
||||
between the two versions for this application, and folding them into a pass/fail hides what
|
||||
changed.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The 3.2 document is published as an artifact of the experimental workflow, never of the release
|
||||
workflow.
|
||||
- A client generator that only understands 3.1 is unaffected, which is the point.
|
||||
- Adopting 3.2 later is a documented decision with a named consumer matrix behind it.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Switch to 3.2 and keep a 3.1 downgrade.** Rejected: the downgrade is lossy in exactly the
|
||||
constructs 3.2 was wanted for, so it would ship a description that is wrong for both audiences.
|
||||
- **Generate only 3.2 and let consumers cope.** Rejected: the failure mode is a generated client
|
||||
that compiles and misbehaves.
|
||||
- **Skip the client-compile step in the matrix.** Rejected: it is the only one that catches the
|
||||
failure the others miss.
|
||||
@@ -0,0 +1,56 @@
|
||||
# ADR-WS-001: The WebSocket platform ships as packages in one leaf, with machine-checked boundaries
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-25
|
||||
- Scope: `:adapter:inbound:websocket`
|
||||
|
||||
## Context
|
||||
|
||||
The realtime connection platform design models itself as eighteen Gradle modules under
|
||||
`modules/websocket`, each with a declared purity grade and a declared set of allowed dependencies.
|
||||
This repository's `src/config/architecture/modules.json` is a fail-closed registry that owns the
|
||||
leaf list; adding eighteen leaves is a registry change of a size that needs its own decision, and
|
||||
HARD-STOP #5 forbids doing it implicitly.
|
||||
|
||||
Three earlier platforms in this repository — JPA, GraphQL, and the HTTP platform — met the same
|
||||
situation and resolved it the same way.
|
||||
|
||||
## Decision
|
||||
|
||||
The eighteen design modules ship as packages inside the single registered leaf. `WebSocketStableModule`
|
||||
declares each one's package, purity grade and exact allowed edges, and `WebSocketModuleBoundaryTest`
|
||||
scans the production tree and fails when the declaration and the tree disagree in either direction.
|
||||
|
||||
Three deviations from the design's module map were forced by the check and are recorded in
|
||||
`docs/websocket/repository-adaptation.md`: `WebSocketSubprotocolName` moved to `core` and the codec
|
||||
moved to its own FRAMEWORK_BOUND module, both to avoid cycles the design's placement created here;
|
||||
and the `budget -> core` edge was inverted because `budget` imports nothing from `core`.
|
||||
|
||||
## Consequences
|
||||
|
||||
**The boundary is enforced, not documented.** Six violations were caught during implementation that
|
||||
a document would not have: two would-be cycles, a duplicate module declaration where two ids claimed
|
||||
one package, and three undeclared edges. The duplicate is the instructive one — with two ids on one
|
||||
package, ownership depends on iteration order and one module's rules silently apply to nothing. A
|
||||
guard against it is now part of the boundary test.
|
||||
|
||||
**The detector had a hole.** Its framework-import list named `com.fasterxml` (Jackson 2) and not
|
||||
`tools.jackson` (Jackson 3), which is what Spring 7 actually uses — so a CORE module could have
|
||||
imported a mapper unnoticed. Fixed here and in the HTTP platform, which shared the list.
|
||||
|
||||
**Promotion stays cheap.** Each enum constant is already shaped like a leaf specification, so
|
||||
splitting one out later is a registry edit rather than an archaeology exercise.
|
||||
|
||||
**The design's own rules were kept where they cost something.** `core` names no framework, so the
|
||||
same decisions serve both runtimes and are testable without a server; no Java class name reaches the
|
||||
wire; the payload is an encoded string rather than a map; and handlers are given no way to write,
|
||||
which is what makes ordering and backpressure guarantees rather than conventions.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Register eighteen leaves.** Faithful to the design and a large change to a fail-closed registry
|
||||
for a platform that ships as one artifact either way. Rejected as disproportionate; the boundary
|
||||
test provides the property the modules were for.
|
||||
|
||||
**Ship the modules as packages with no enforcement.** Cheapest, and it makes the boundary a claim.
|
||||
The six violations found during implementation are the argument against it.
|
||||
@@ -0,0 +1,60 @@
|
||||
# ADR-WS-002: Resume and cluster state are caches, and are treated as caches
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-25
|
||||
- Scope: `adapter:inbound:websocket` — `advanced.resume`, `advanced.cluster`, `advanced.presence`
|
||||
|
||||
## Context
|
||||
|
||||
Advanced Tasks 2–8 add three things that all look like state and are not: a resume token that says
|
||||
where a client got to, a cluster index that says which node holds a session, and a presence summary
|
||||
derived from that index.
|
||||
|
||||
Each is a statement about the past. The resume token was minted before the disconnect; the index
|
||||
entry was written by a node that may since have died; presence is a read of the index and inherits
|
||||
everything wrong with it. The failure this ADR exists to prevent is treating any of them as current
|
||||
fact, because each reads as one at the call site.
|
||||
|
||||
## Decision
|
||||
|
||||
**Resume is bounded by what the replay store actually holds, not by what the token claims.**
|
||||
`ResumeCoordinator` consults `ReplayAvailability` before honouring a position. A token that names a
|
||||
position the store has evicted produces a resynchronise, not a gap-filled stream. The alternative —
|
||||
trusting the token — silently delivers a stream with a hole in it, which is worse than an explicit
|
||||
resynchronise because the client believes it is complete.
|
||||
|
||||
**Cluster index entries carry an observation time and are checked against it on every read.**
|
||||
`ExternalSessionSummary.staleAt` exists so that "the index says edge-2" cannot be used without also
|
||||
answering "as of when". An entry whose node stopped reporting is not evidence that the node holds
|
||||
the session.
|
||||
|
||||
**Durable fan-out is deduplicated by stream position, not by message id.** At-least-once is the
|
||||
contract, so redelivery is normal operation: a redeploy, a slow consumer or a broker rebalance all
|
||||
produce it. `FanoutDeduplicator` keys on `(stream, position)` and advances a high-water mark under
|
||||
`compute`, so two consumer threads cannot both deliver the same position.
|
||||
|
||||
**Presence has four states, not two.** `OFFLINE` is a reported fact; `STALE` is the absence of one.
|
||||
Collapsing them reports every user as disconnected during a Redis partition, when what happened is
|
||||
that the index went dark and the connections are fine.
|
||||
|
||||
**Nothing security-relevant may depend on presence.** An attacker who can make a node stop reporting
|
||||
can move the platform's belief about who is present. Presence answers "show a green dot".
|
||||
|
||||
## Consequences
|
||||
|
||||
- A resume that cannot be honoured is visible to the client as a resynchronise. Clients must
|
||||
implement one; there is no mode in which the platform silently pretends.
|
||||
- Every read of the cluster index needs a clock. This is deliberate friction.
|
||||
- `PresenceSummary.classify` refuses an idle window at or past the stale window, because otherwise
|
||||
`IDLE` is unreachable and the caller believes it has a four-state model when it has three.
|
||||
- Fan-out envelopes carry a bounded reference and the catalog-encoded document, never a business
|
||||
object. A rolling deploy has two versions of the code reading the same envelope.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Trust the resume token.** Rejected: it makes a gap indistinguishable from a complete stream.
|
||||
- **Deduplicate by message id.** Rejected: a broker that redelivers may re-mint ids, and a producer
|
||||
that retries certainly does. Position is the property the ordering actually has.
|
||||
- **A single `online` boolean.** Rejected for the partition case above.
|
||||
- **Write presence separately from the session index.** Rejected: two sources of truth for "who is
|
||||
connected" drift, and the drift is invisible — both look plausible and nothing reconciles them.
|
||||
@@ -0,0 +1,74 @@
|
||||
# ADR-WS-003: STOMP is an Advanced adapter with a declared destination catalog
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-25
|
||||
- Scope: `adapter:inbound:websocket` — `advanced.stomp`, `advanced.stomp.rabbit`
|
||||
|
||||
## Context
|
||||
|
||||
Advanced Tasks 9–13 add STOMP 1.2 alongside the platform's own protocol, plus a RabbitMQ broker
|
||||
relay and cross-node user destinations.
|
||||
|
||||
This leaf already ships an older STOMP-over-SockJS channel (`stomp`, gated on
|
||||
`ca-skeleton.websocket.enabled`). Two `@EnableWebSocketMessageBroker` configurations in one context
|
||||
do not conflict loudly — both contribute a configurer, both call `configureMessageBroker`, and the
|
||||
broker that results is whichever ran last. Nothing errors and nothing logs.
|
||||
|
||||
STOMP also brings a destination model that is a free string from the client. Without a catalog, the
|
||||
set of reachable destinations is whatever the broker accepts, which for the simple broker is every
|
||||
string.
|
||||
|
||||
## Decision
|
||||
|
||||
**The Advanced adapter is its own module (`advanced-stomp`), separate from `advanced`.** It is the
|
||||
one Advanced capability that cannot be pure — STOMP here *is* the Spring Messaging types — and
|
||||
folding it into `advanced` would relax that module's purity for every capability in it.
|
||||
|
||||
**The relay is a further module (`advanced-stomp-rabbit`).** The adapter parses a protocol; the
|
||||
relay opens a TCP connection to somebody else's broker and makes every delivery depend on it.
|
||||
Different blast radius, different decision, different module.
|
||||
|
||||
**Destinations are declared, per operation.** `StompDestinationCatalog` maps `(operation,
|
||||
destination)` to a required permission. Undeclared is refused. `SUBSCRIBE` and `SEND` are separate
|
||||
declarations, because reading a feed and publishing into it are different rights.
|
||||
|
||||
**The authorization decision is a value, not an interceptor method.** `StompAuthorizationPolicy`
|
||||
returns a `StompAuthorizationDecision`; `StompSecurityInterceptor` only extracts and enforces. A rule
|
||||
reachable only through a `MessageChannel` gets tested for the cases somebody built a channel for.
|
||||
|
||||
**Only one STOMP runtime may run.** `StompBrokerExclusivity` fails the context when both channels
|
||||
are enabled, when both brokers are, or when the adapter is enabled with no broker behind it.
|
||||
|
||||
**A `RECEIPT` is never promoted to a commit.** `StompEvidence` has six stages and
|
||||
`StompAckPolicy.evidenceForReceipt()` is fixed at `PROTOCOL_RECEIPT`. The receipt is written by the
|
||||
protocol layer, which knows nothing about whether the work succeeded.
|
||||
|
||||
**The simple broker declares what it cannot do.** `SimpleBrokerProfile` cannot be constructed
|
||||
claiming cluster support or durable acks, and refuses activation outside local/test — in a
|
||||
multi-node deployment it does not error, it delivers to whichever fraction of users is on the
|
||||
publishing node.
|
||||
|
||||
**Unresolved user destinations are broadcast once and then dead-lettered.**
|
||||
`MultiNodeUserDestination` distinguishes a message that arrived *via* the broadcast from one that did
|
||||
not. Without that, every node rebroadcasts every unresolvable message on receipt.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Enabling Advanced STOMP requires disabling the legacy channel. There is no migration path that
|
||||
runs both; the exclusivity check makes that explicit at startup rather than at 3am.
|
||||
- A deployment must write its own catalog. There is deliberately no default: an empty one refuses
|
||||
every frame and reads as a broken adapter, and a non-empty one publishes destinations nobody chose.
|
||||
- The relay's cost is one broker connection per authenticated session plus one system connection.
|
||||
`brokerConnectionsFor` exists so this is computed before the first outage.
|
||||
- User-destination metrics are tagged with `UserDestinationAction`, never the destination — a user
|
||||
destination contains a user identifier by construction.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Extend the existing `stomp` package.** Rejected: it is Stable, and WS-ARCH-6 forbids a Stable
|
||||
module naming an Advanced one. Making the legacy channel profile-driven would have required that
|
||||
edge.
|
||||
- **One `advanced-stomp` module including the relay.** Rejected: the relay is a separate operational
|
||||
decision and deserves to be refusable on its own.
|
||||
- **Allow undeclared destinations with a wildcard permission.** Rejected: the wildcard becomes the
|
||||
default and the catalog becomes documentation.
|
||||
Reference in New Issue
Block a user