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
@@ -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 613 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.
+60
View File
@@ -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 28 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 913 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.