Skip to content

Commit 5a12c6b

Browse files
committed
feat(write-queue): outbox drain worker
Completes the outbox pattern. drainOutboxTick scans for unsent envelopes older than 1s and XADDs each to its stream, marking the row sent on success. Any envelope the inline submitWrite XADD couldn't deliver eventually lands via this loop. Wired into apps/api alongside the main processor and the demo processor. Runs only when PROCESSOR_ENABLED is true; moves to apps/worker once that deploys separately. Duplicate XADDs under contention are absorbed by the skip-if-completed guard in processNext.
1 parent 4ec7e01 commit 5a12c6b

3 files changed

Lines changed: 113 additions & 0 deletions

File tree

apps/api/src/index.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createDb } from '@sheetforge/shared-db';
33
import { createLogger } from '@sheetforge/shared-logger';
44
import { createIoredisQueueClient, createUpstashQueueClient } from '@sheetforge/shared-redis';
55
import { createRouter } from '@sheetforge/slice-rest-api';
6+
import { drainOutboxTick } from '@sheetforge/slice-write-queue';
67
import { demoProcessorTick } from './demo-processor.js';
78
import { loadEnv } from './env.js';
89
import { processorTick } from './processor.js';
@@ -89,6 +90,30 @@ if (env.PROCESSOR_ENABLED) {
8990
// its blockMs (or the Upstash adapter's setTimeout fallback).
9091
}
9192
})();
93+
94+
// Outbox drain — catches envelopes whose inline XADD from submitWrite
95+
// didn't land (process death between the ledger commit and Redis, or a
96+
// transient Redis outage). Without this loop those rows would sit in
97+
// Postgres forever and the corresponding writes would never reach the
98+
// handler.
99+
(async () => {
100+
log.info({}, 'outbox-drain-starting');
101+
while (true) {
102+
try {
103+
const outcome = await drainOutboxTick({ db, redis });
104+
if (outcome.failed > 0) {
105+
await new Promise((res) => setTimeout(res, 1000));
106+
}
107+
} catch (err) {
108+
log.error(
109+
{ err: err instanceof Error ? err.message : String(err) },
110+
'outbox-drain-tick-failed',
111+
);
112+
await new Promise((res) => setTimeout(res, 5000));
113+
}
114+
await new Promise((res) => setTimeout(res, 500));
115+
}
116+
})();
92117
}
93118

94119
process.on('SIGTERM', () => {

slices/write-queue/src/drain.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { type QueueRedisClient, enqueue } from '@sheetforge/queue';
2+
import { type Db, schema } from '@sheetforge/shared-db';
3+
import { type Logger, createLogger } from '@sheetforge/shared-logger';
4+
import { and, asc, isNull, lt } from 'drizzle-orm';
5+
import { markOutboxSent } from './repo.js';
6+
7+
export interface DrainOutcome {
8+
claimed: number;
9+
sent: number;
10+
failed: number;
11+
}
12+
13+
/**
14+
* One tick of the outbox drain loop. Scans for unsent envelopes older than
15+
* `staleMs` and XADDs each to its stream. Marks the row sent on success;
16+
* leaves it for the next tick on failure.
17+
*
18+
* The staleness threshold deliberately skips rows the inline submitWrite
19+
* XADD is still trying to deliver. Without it the drain would race the
20+
* inline path and produce a duplicate stream message on every write.
21+
*
22+
* Duplicates remain possible if two drain workers run against the same
23+
* database (or if mark-sent lost a write after a successful inline XADD).
24+
* processNext's skip-if-completed guard absorbs them harmlessly — the row
25+
* lands once, later stream deliveries short-circuit to ack.
26+
*/
27+
export async function drainOutboxTick({
28+
db,
29+
redis,
30+
batchSize = 50,
31+
staleMs = 1000,
32+
logger,
33+
}: {
34+
db: Db;
35+
redis: QueueRedisClient;
36+
batchSize?: number;
37+
staleMs?: number;
38+
logger?: Logger;
39+
}): Promise<DrainOutcome> {
40+
const log = logger ?? createLogger({ service: 'write-queue-drain' });
41+
const cutoff = new Date(Date.now() - staleMs);
42+
43+
const candidates = await db
44+
.select({
45+
id: schema.writeOutbox.id,
46+
writeId: schema.writeOutbox.writeId,
47+
streamKey: schema.writeOutbox.streamKey,
48+
envelope: schema.writeOutbox.envelope,
49+
})
50+
.from(schema.writeOutbox)
51+
.where(and(isNull(schema.writeOutbox.sentAt), lt(schema.writeOutbox.createdAt, cutoff)))
52+
.orderBy(asc(schema.writeOutbox.createdAt))
53+
.limit(batchSize);
54+
55+
let sent = 0;
56+
let failed = 0;
57+
for (const row of candidates) {
58+
try {
59+
// envelope is jsonb on the column. submitWrite wrote it as an
60+
// EnqueueMessage<WritePayload>; trust that shape round-trips cleanly.
61+
await enqueue({
62+
redis,
63+
streamKey: row.streamKey,
64+
message: row.envelope as { writeId: string; idempotencyKey?: string; payload: unknown },
65+
});
66+
await markOutboxSent({ db, id: row.id });
67+
sent++;
68+
} catch (err) {
69+
log.warn(
70+
{
71+
err: err instanceof Error ? err.message : String(err),
72+
outboxId: row.id,
73+
writeId: row.writeId,
74+
},
75+
'drain-xadd-failed',
76+
);
77+
failed++;
78+
}
79+
}
80+
81+
if (candidates.length > 0) {
82+
log.debug({ claimed: candidates.length, sent, failed }, 'drain-tick');
83+
}
84+
85+
return { claimed: candidates.length, sent, failed };
86+
}

slices/write-queue/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ export {
55
getLedgerStats,
66
} from './service.js';
77
export type { ProcessOutcome, LedgerStats } from './service.js';
8+
export { drainOutboxTick } from './drain.js';
9+
export type { DrainOutcome } from './drain.js';
810
export { WritePayloadSchema } from './types.js';
911
export type {
1012
SubmitResult,

0 commit comments

Comments
 (0)