11import { randomUUID } from 'node:crypto' ;
22import {
33 type ClaimedMessage ,
4+ type EnqueueMessage ,
45 type QueueRedisClient ,
56 ackMessage ,
67 claimNext ,
@@ -14,7 +15,10 @@ import {
1415 countLedgerByStatus ,
1516 findLedgerByIdempotencyKey ,
1617 findRecentLedger ,
18+ getLedgerStatus ,
1719 insertLedger ,
20+ insertOutbox ,
21+ markOutboxSent ,
1822 tryAdvisoryXactLock ,
1923 updateLedgerStatus ,
2024} from './repo.js' ;
@@ -66,17 +70,41 @@ export async function submitWrite({
6670 }
6771
6872 const writeId = randomUUID ( ) ;
73+ const streamKey = streamKeyForSheet ( sheetId ) ;
74+ const envelope : EnqueueMessage < WritePayload > = { writeId, idempotencyKey, payload } ;
75+
76+ // Atomic commit of the ledger row + outbox envelope. The outbox closes
77+ // the gap where a process death between the INSERT and the Redis XADD
78+ // would leave a "pending" ledger row with no stream entry — permanently
79+ // lost. By staging the envelope in Postgres within the same transaction,
80+ // a background drain worker can later re-XADD anything the inline path
81+ // didn't manage to deliver.
82+ let outboxId : string ;
6983 try {
70- await insertLedger ( {
71- db,
72- sheetId,
73- idempotencyKey : idempotencyKey ?? null ,
74- writeId,
84+ outboxId = await (
85+ db as unknown as {
86+ transaction : < T > ( cb : ( tx : Db ) => Promise < T > ) => Promise < T > ;
87+ }
88+ ) . transaction ( async ( tx ) => {
89+ await insertLedger ( {
90+ db : tx ,
91+ sheetId,
92+ idempotencyKey : idempotencyKey ?? null ,
93+ writeId,
94+ } ) ;
95+ return await insertOutbox ( {
96+ db : tx ,
97+ writeId,
98+ sheetId,
99+ streamKey,
100+ envelope,
101+ } ) ;
75102 } ) ;
76103 } catch ( err ) {
77- // Concurrent submitWrite() raced us between the find above and the insert
78- // here. The partial unique index on (sheet_id, idempotency_key) catches
79- // it; treat as a replay so the caller still gets ONE writeId per key.
104+ // Concurrent submitWrite() raced us between the find above and the
105+ // transaction here. The partial unique index on
106+ // (sheet_id, idempotency_key) catches it; treat as a replay so the
107+ // caller still gets ONE writeId per key.
80108 if ( idempotencyKey !== undefined && isUniqueViolation ( err ) ) {
81109 const prior = await findLedgerByIdempotencyKey ( {
82110 db,
@@ -90,21 +118,46 @@ export async function submitWrite({
90118 ) ;
91119 return { writeId : prior . writeId , status : 'replayed' } ;
92120 }
93- // Race-loser found nothing — winner must have rolled back. Don't leak
94- // the raw constraint name to clients via the 500 message.
121+ // Race-loser found nothing — winner must have rolled back. Don't
122+ // leak the raw constraint name to clients via the 500 message.
95123 throw new InternalError ( 'write submission failed; please retry' ) ;
96124 }
97125 throw err ;
98126 }
99127
100- const streamKey = streamKeyForSheet ( sheetId ) ;
101- const { messageId } = await enqueue ( {
102- redis,
103- streamKey,
104- message : { writeId, idempotencyKey, payload } ,
105- } ) ;
128+ // Best-effort inline XADD — keeps happy-path latency low. On success
129+ // we mark the outbox row sent so the drain skips it. On failure the
130+ // drain worker will pick it up within ~1s; the extra stream entry it
131+ // might later produce is absorbed by processNext's skip-if-completed
132+ // guard.
133+ let messageId : string | undefined ;
134+ try {
135+ const { messageId : mid } = await enqueue ( { redis, streamKey, message : envelope } ) ;
136+ messageId = mid ;
137+ try {
138+ await markOutboxSent ( { db, id : outboxId } ) ;
139+ } catch ( markErr ) {
140+ log . warn (
141+ {
142+ err : markErr instanceof Error ? markErr . message : String ( markErr ) ,
143+ outboxId,
144+ writeId,
145+ } ,
146+ 'outbox-mark-sent-failed' ,
147+ ) ;
148+ }
149+ } catch ( enqueueErr ) {
150+ log . warn (
151+ {
152+ err : enqueueErr instanceof Error ? enqueueErr . message : String ( enqueueErr ) ,
153+ writeId,
154+ outboxId,
155+ } ,
156+ 'inline-xadd-failed-drain-will-retry' ,
157+ ) ;
158+ }
106159
107- log . debug ( { sheetId, writeId, messageId } , 'enqueued' ) ;
160+ log . debug ( { sheetId, writeId, messageId, outboxId } , 'enqueued' ) ;
108161 return { writeId, status : 'enqueued' , messageId } ;
109162}
110163
@@ -178,6 +231,17 @@ export async function processNext<P extends WritePayload>({
178231 return ; // transaction commits without touching the ledger
179232 }
180233
234+ // Guard against duplicate stream delivery: Redis PEL redelivery or
235+ // the outbox drain re-XADDing the same writeId can land a message
236+ // for work already completed. Running the handler again would
237+ // write the same row twice. Short-circuit to "safe to ack" instead.
238+ const currentStatus = await getLedgerStatus ( { db : tx , writeId : msg . writeId } ) ;
239+ if ( currentStatus === 'completed' || currentStatus === 'dead_lettered' ) {
240+ log . debug ( { streamKey, writeId : msg . writeId , currentStatus } , 'skip-already-terminal' ) ;
241+ processedInsideTx = true ;
242+ return ;
243+ }
244+
181245 await updateLedgerStatus ( {
182246 db : tx ,
183247 writeId : msg . writeId ,
0 commit comments