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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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