Files
growqr-backend/scripts/event-dedupe-routing.test.ts
-Puter 5f5b08bfda fix(events): prevent userEventActor queue saturation from duplicate routing
Root cause: recordGrowEvent returned the existing row on dedupe hits, and
routeGrowEventToUserActor was called unconditionally — so every duplicate
ingest re-enqueued the same event to the actor queue, saturating it.

Fix at the persistence boundary:
- recordGrowEventWithResult returns {event, inserted} using atomic
  onConflictDoNothing().returning() (row present = fresh insert, no row =
  dedupe hit). No clock heuristics, no xmax probe.
- Conflict lookup tries dedupeKey first, then id PK fallback, covering both
  constraint types.
- shouldRouteGrowEvent(event, inserted) gates routing to only newly-inserted,
  user-resolved, pending events. Dedupe hits never re-enqueue.
- recordGrowEvent kept as thin .event wrapper — zero changes to ~10 callers.

Fix the actor run loop (mirrors workflow-run-actor.ts):
- tryStep with catch:['timeout','exhausted'] replaces step+rethrow. Per-event
  failure is marked terminal in a separate record-failure step, loop advances.
- isProcessableStatus guard allows pending/processing/processed — only skips
  failed/unresolved. This ensures mission reducers (actor-only) still run on
  events that got synchronous projections.
- Status guard + claim moved inside tryStep's run for atomic retry unit.
- onError handler added to workflow().

Ingress callers migrated (3 paths):
- redis-consumer recordAndRoute: shouldRouteGrowEvent gate + always mark
  processed after projections.
- routes/events.ts ingest: shouldRouteGrowEvent gate + always mark processed.
- v1/events/events-routes.ts /track: inserted flag for dedupe skip.

Tests: 3 new (event-dedupe-routing, user-event-actor-advance,
event-ingress-route-integration with injectable enqueue proving 5x duplicate
ingest enqueues exactly once). All existing touched-scope tests green.

Co-authored-by: independent review (2 rounds)
2026-07-11 06:19:38 +05:30

106 lines
4.4 KiB
TypeScript

import assert from "node:assert/strict";
import { shouldRouteGrowEvent } from "../src/events/record-grow-event.js";
import type { GrowEventRow } from "../src/db/schema.js";
/**
* Regression: duplicate/idempotent ingest must not enqueue the same event twice.
*
* Before the fix, recordGrowEvent returned the existing row on a dedupe hit,
* and routeGrowEventToUserActor was called unconditionally — so every duplicate
* ingest re-enqueued the event to the actor queue, causing saturation.
*
* After the fix, routing is gated by shouldRouteGrowEvent(event, inserted):
* only newly inserted, user-resolved, pending events are routed.
*/
// ── 1. Freshly inserted, user-resolved, pending → route ──────────────────────
{
const event = { userId: "user_a", processingStatus: "pending" } as Pick<GrowEventRow, "userId" | "processingStatus">;
assert.equal(
shouldRouteGrowEvent(event, true),
true,
"newly inserted pending event with a userId must route",
);
}
// ── 2. Dedupe hit (inserted=false) → DO NOT route ────────────────────────────
// This is the core regression: a duplicate ingest returns the existing row
// with inserted=false. The gate must refuse to re-enqueue.
{
const event = { userId: "user_a", processingStatus: "processed" } as Pick<GrowEventRow, "userId" | "processingStatus">;
assert.equal(
shouldRouteGrowEvent(event, false),
false,
"dedupe hit (inserted=false) must NOT route even if status looks processable",
);
}
// ── 3. Freshly inserted but already processed → DO NOT route ─────────────────
// Edge case: the row was just inserted but somehow already processed (e.g. the
// caller did in-process projection synchronously). No re-enqueue.
{
const event = { userId: "user_a", processingStatus: "processed" } as Pick<GrowEventRow, "userId" | "processingStatus">;
assert.equal(
shouldRouteGrowEvent(event, true),
false,
"inserted but already-processed event must NOT route",
);
}
// ── 4. Freshly inserted but unresolved (no userId) → DO NOT route ────────────
{
const event = { userId: null, processingStatus: "unresolved" } as Pick<GrowEventRow, "userId" | "processingStatus">;
assert.equal(
shouldRouteGrowEvent(event, true),
false,
"unresolved event (no userId) must NOT route",
);
}
// ── 5. Freshly inserted, user-resolved, but failed → DO NOT route ────────────
// A terminal-failed event should not be re-enqueued by a duplicate ingest.
{
const event = { userId: "user_a", processingStatus: "failed" } as Pick<GrowEventRow, "userId" | "processingStatus">;
assert.equal(
shouldRouteGrowEvent(event, true),
false,
"failed event must NOT route on replay",
);
}
// ── 6. Dedupe hit on a pending event → DO NOT route ──────────────────────────
// Critical: even if the existing row is still pending (not yet processed), a
// dedupe hit means another caller already inserted it and likely already routed
// it. Re-routing would create a duplicate queue message.
{
const event = { userId: "user_a", processingStatus: "pending" } as Pick<GrowEventRow, "userId" | "processingStatus">;
assert.equal(
shouldRouteGrowEvent(event, false),
false,
"dedupe hit on a pending event must NOT re-route (already enqueued by first caller)",
);
}
// ── 7. Idempotent double-ingest simulation ───────────────────────────────────
// Simulate two calls to recordGrowEventWithResult for the same event:
// first returns inserted=true, second returns inserted=false.
// The first routes, the second does not — proving no double-enqueue.
{
const event = { userId: "user_a", processingStatus: "pending" } as Pick<GrowEventRow, "userId" | "processingStatus">;
// First ingest: newly inserted
const firstRoute = shouldRouteGrowEvent(event, true);
// Second ingest: dedupe hit
const secondRoute = shouldRouteGrowEvent(event, false);
assert.equal(firstRoute, true, "first ingest must route");
assert.equal(secondRoute, false, "second (dedupe) ingest must NOT route");
assert.ok(
firstRoute && !secondRoute,
"duplicate ingest must not enqueue twice",
);
}
console.log("event-dedupe-routing tests passed");
process.exit(0);