Files
clean-architecture-backend-…/docs/adr/ADR-WEB-ADV-001-streaming-is-live-delivery.md
T

3.8 KiB
Raw Blame History

ADR-WEB-ADV-001: Streaming is live delivery, and the web module stores no history

  • Status: Accepted
  • Date: 2026-08-25
  • Scope: adapter:inbound:webadvanced.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.