Skip to content

Dead-lettered events vanish: no durable store and no subscriber for the dead-letter event #525

Description

@Xhristin3

Problem

Events that exhaust the publish retry budget are lost with no durable trace. In StreamSession.pump() (xstreamroll-processing/src/session.ts), when handlers.publish fails maxPublishRetries + 1 times the event is "dead-lettered":

this.logger.error(`[${this.workerId}] session ${this.id} publish FAILED after ${attempts} attempt(s) — dead-lettering event: ${error.message}`)
this.emit("dead-letter", next, error)
break // skip this event, carry on with the queue

The only consumer of the dead-letter event would be code that subscribed to the session's EventEmitter — a repo-wide search shows nothing does. SessionRegistry.spawn() (xstreamroll-processing/src/session-registry.ts) registers a passive session.on("error", ...) listener and a state listener, but no dead-letter listener. So a dead-lettered event produces one log line and then ceases to exist: it is not persisted, not retried later, and not visible in any API surface.

The event is also not recoverable from the source: the stream_data row that produced it stays in the pending table (nothing drains it today), so the same event will be re-fetched and re-dead-lettered on the next poll, permanently. With the missing POST /streams/processed endpoint this is masked (everything 404s), but once the drain endpoint lands, a genuinely bad event (e.g. one that violates a future constraint) would be re-processed and re-dead-lettered in a tight loop with no operator-visible artifact beyond log lines.

Consequence: the platform's only "permanently failed event" signal is a log line. Operators cannot inspect what failed, why, or how often; a poison event can spin the worker forever; and any at-least-once consumer expecting a DLQ-style record has nothing to read.

Root cause

// xstreamroll-processing/src/session.ts — pump(), retry exhausted
this.emit("dead-letter", next, error)   // ← emitted, but no subscriber anywhere

Why this is architecturally hard

  1. The worker is deliberately stateless about failures ("The session keeps running after a dead-letter" — session JSDoc), so a durable dead-letter store is a new persistence concern for the processing service. The codebase precedent is the API's webhook_deliveries table (database/schema.sql): a statused row with last_error, attempt_count, and next_attempt_at, written by the API which owns Postgres. The worker has no database dependency in its default memory lock config — it only talks to the API over HTTP — so the natural design is to POST dead-lettered events back to the API (mirroring the POST /streams/processed publish path) rather than giving the worker its own DB connection. That decision — worker-owned store vs API-owned store — is the core design question.
  2. StreamEvent has no stable id today (streamId, data, timestamp), so a dead-letter record cannot be deduplicated. Whatever store is chosen needs the same idempotency key the processed-event endpoint defines, or a poison event floods the store on every poll.
  3. The retry budget and backoff live in StreamSession (constructor params maxPublishRetries); the dead-letter signal must escape the session abstraction (through the registry's handlers or an explicit listener wiring in worker.ts) without re-entangling the deliberately dependency-free StreamSession class.
  4. Operators need a way to see the queue. If the store is API-side, that means an endpoint or the admin surface; if worker-side, a metrics/file surface. Pick one so the acceptance criteria can be verified.

Acceptance criteria

Behaviour

  • An event whose publish retry budget is exhausted is recorded durably (not just logged) with: event payload, stream id, error message, attempt count, and timestamp.
  • The same poison event is not recorded repeatedly on subsequent polls (the record is deduplicated by the idempotency key, or the source row is marked so it is not re-fetched).
  • A healthy event that succeeds after transient failures is never dead-lettered.

Observability

  • The dead-letter store is queryable by an operator through the chosen surface (API endpoint or worker metrics/file) and shows attempt_count and last_error for each record.

Tests

  • xstreamroll-processing/__tests__/session.test.ts (or worker.test.ts): a publish that always fails produces exactly one dead-letter record after maxPublishRetries + 1 attempts, and processing continues for subsequent events.
  • A test proves the dedupe: the same failing event across two polls yields one store record.
  • The existing publish-retry tests (__tests__/integration/pipeline.integration.test.ts) pass unchanged.

Documentation

  • The StreamSession JSDoc's dead-letter description matches the durable behaviour.

Out of scope

Automatic replay of dead-lettered events, and the POST /streams/processed drain endpoint itself (this issue depends on the idempotency-key decision there and should be sequenced after it).

Getting started

Real files in scope: xstreamroll-processing/src/session.ts, xstreamroll-processing/src/session-registry.ts, xstreamroll-processing/src/worker.ts, api/src/webhooks/webhook-delivery.entity.ts + webhook-deliveries-db.repository.ts (store pattern), database/schema.sql (webhook_deliveries as the schema pattern).

Verify with:

cd xstreamroll-processing && npm run typecheck && npm test
cd ../api && npm run typecheck && npm test

Good first files to read: xstreamroll-processing/src/session.ts (the dead-letter branch in pump), api/src/webhooks/webhook-deliveries-db.repository.ts (statused-delivery store pattern).

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardThird CampaignCampaign: Third CampaignbugSomething isn't workingprocessingRelated to xstreamroll-processing/ workerreliabilityAvailability, fault tolerance, graceful degradation

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions