70 lines
3.8 KiB
Markdown
70 lines
3.8 KiB
Markdown
# 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.
|