Skip to content

POST /streams/processed does not exist: the worker 404s on every publish and stream_data never drains #513

Description

@Xhristin3

Problem

The processing worker publishes every processed event to POST ${API_URL}/streams/processed (xstreamroll-processing/src/worker.ts), but the API exposes no such route. The only stream routes are GET /streams/pending, POST /streams, GET /streams, GET /streams/:id/analytics, GET /streams/:id/events, GET /streams/:id, PATCH /streams/:id, and DELETE /streams/:id (api/src/streams/streams.controller.ts). A POST to /streams/processed returns 404.

The consequence is total: every event the worker processes hits 404, the publish retry budget (PROCESSING_PUBLISH_MAX_RETRIES, default 3, exponential backoff in xstreamroll-processing/src/session.ts) exhausts, and the event is dead-lettered in memory and lost. stream_events is never written, so GET /streams/:id/events (event replay, issue #396) and GET /streams/:id/analytics are always empty in any real deployment, and stream_data rows are never drained — the worker re-fetches and re-processes the same rows on every poll forever, re-publishing duplicates each cycle.

This is a documented contract, not a hypothetical: ADR-0003 (docs/adr/0003-polling-based-processing.md) states:

Processing status is reported back to the API via standard HTTP POST requests (POST /streams/processed).
POST /streams/processed is idempotent on the server.

The worker integration tests (xstreamroll-processing/__tests__/integration/worker.integration.test.ts) mock the endpoint with nock (.post("/streams/processed")), so CI is green while production is broken.

Root cause

// xstreamroll-processing/src/worker.ts
await axiosInstance.post(`${API_URL}/streams/processed`, event)   // ← 404: route does not exist

api/src/streams/repository/streams-db.repository.ts has getPendingEvents() (reads stream_data) but no counterpart that records a processed event into stream_events or removes the drained stream_data row — the in-memory StreamsRepository.recordEvent() exists, the DB-backed one has no equivalent.

Why this is architecturally hard

  1. The endpoint must define the drain semantics. getPendingEvents returns rows from stream_data with no claimed/acked state; a correct POST /streams/processed must make the row disappear (delete or flag) atomically with the stream_events insert, or the worker reprocesses everything forever. That means a transaction — the naive shortcut (insert into stream_events, delete from stream_data, in two statements) can lose data or double-publish under a crash.
  2. ADR-0003 promises idempotency, which needs a key to dedupe on (e.g. (stream_id, timestamp, payload) or a client-supplied event id). The worker currently sends no idempotency key — ProcessedStreamEvent (xstreamroll-processing/src/session.ts) has streamId, data, timestamp, processedAt, processingLatencyMs, workerId, sessionId, none of which is a stable event id.
  3. Concurrency: with the Postgres lock backend, only one worker owns a stream at a time, but the endpoint itself must still be safe against double-delivery from the in-memory fallback and from retry-after-timeout (worker retries the POST when the API timed out after committing — the classic at-least-once hole).
  4. The endpoint also needs a home for the auth story: GET /streams/pending is intentionally unauthenticated, and STREAM_API_KEY is required by api/src/config/env.ts but enforced nowhere — this endpoint is the natural place to start enforcing it, and the decision affects the worker's axios config (xstreamroll-processing/src/worker.ts).

Proposed design

  • POST /streams/processed accepts a batch or single ProcessedStreamEvent, runs one transaction: INSERT INTO stream_events (...), DELETE FROM stream_data WHERE id = $eventId (or UPDATE stream_data SET processed = true), and returns 2xx. Resolve the stream_data row identity (add an id to the pending-event payload) so the delete is exact.
  • Idempotency: reject or no-op duplicates keyed on the event id (unique index on the chosen key), satisfying ADR-0003.
  • Protect the endpoint with STREAM_API_KEY (shared-secret header) and send it from the worker; keep GET /streams/pending consistent with the same policy.
  • Replace the nock mocks in worker.integration.test.ts with a provider-style check (or an API integration test that actually exercises the route against the test database) so a regression surfaces in CI.

Downstream impact

  • xstreamroll-processing/src/worker.ts and xstreamroll-processing/src/session.ts: the publish target exists once the endpoint lands; no worker change strictly required beyond the auth header.
  • xstreamroll-sdk: the SDK's StreamEventRecord/StreamEvent types (packages/types/src/stream-event.ts) already model the replay shape; no type change needed unless the wire payload gains an event id.
  • api/src/streams/repository/streams-db.repository.ts: add the processed-event write alongside the existing getPendingEvents.
  • Docs: docs/adr/0003-polling-based-processing.md describes the endpoint; the ADR should be updated to match the implemented contract (or the implementation must match the ADR — either way they must agree).

Acceptance criteria

Contract

  • POST /streams/processed exists, is documented in Swagger, and returns 2xx for a valid ProcessedStreamEvent.
  • After a successful publish, the corresponding stream_data row is no longer returned by GET /streams/pending (drain works).
  • The stream_events row inserted by the publish is returned by GET /streams/:id/events and counted by GET /streams/:id/analytics.
  • Re-delivering the same event (same idempotency key) does not create a duplicate stream_events row or error (ADR-0003 idempotency).

Security

  • POST /streams/processed rejects requests without the shared STREAM_API_KEY secret with 401; the worker sends the key.
  • GET /streams/pending is protected by the same policy (or the decision to keep it open is documented in the controller JSDoc and an ADR).

Reliability

  • A simulated crash between the stream_events insert and the stream_data delete cannot lose or duplicate an event (single transaction or equivalent recovery).
  • Worker publishes no longer fail with 404 in an end-to-end test against the real API (replace the nock mock in xstreamroll-processing/__tests__/integration/worker.integration.test.ts with a route-level assertion, or add an API integration test that covers the same contract).

Documentation

  • docs/adr/0003-polling-based-processing.md and the controller JSDoc accurately describe the implemented endpoint (idempotency key, auth, drain semantics).

Out of scope

A claims/lease mechanism to prevent two workers from processing the same event concurrently (the distributed lock in xstreamroll-processing/src/leader-election.ts already covers the common case), and out-of-order replay guarantees.

Getting started

Real files in scope: api/src/streams/streams.controller.ts, api/src/streams/streams.service.ts, api/src/streams/repository/streams-db.repository.ts, api/src/config/env.ts, xstreamroll-processing/src/worker.ts, xstreamroll-processing/__tests__/integration/worker.integration.test.ts, xstreamroll-processing/__tests__/integration/pipeline.integration.test.ts, database/schema.sql (index for the idempotency key).

Verify with:

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

Good first files to read: xstreamroll-processing/src/worker.ts (the publish handler in start()), api/src/streams/repository/streams-db.repository.ts (getPendingEvents), docs/adr/0003-polling-based-processing.md.

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardThird CampaignCampaign: Third CampaignapiREST API design and endpointsarchitectureStructural design decisionsbugSomething isn't workinghigh impactprocessingRelated to xstreamroll-processing/ workerstreamingCore streaming functionality

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions