@@ -15,7 +15,12 @@ export const DELIVERY_WINDOW_MS = 24 * 60 * 60 * 1000;
1515export const ATTEMPT_TIMEOUT_MS = 2_000 ;
1616export const MAX_BACKOFF_MS = 60 * 60 * 1000 ;
1717
18- export type DeliveryStatus = 'pending' | 'delivering' | 'delivered' | 'failed' ;
18+ export type DeliveryStatus =
19+ | 'pending'
20+ | 'delivering'
21+ | 'delivered'
22+ | 'failed'
23+ | 'dead_letter' ;
1924
2025export interface PaymentPayload {
2126 tx_hash : string ;
@@ -274,7 +279,9 @@ export async function deliverDue(
274279 transportError,
275280 } ) ;
276281 if ( terminal . status === 'delivered' ) delivered ++ ;
277- else if ( terminal . status === 'failed' ) failed ++ ;
282+ // A dead-lettered row is terminal, not a retry — count it as failed here
283+ // so the run's tallies reflect deliveries that gave up (#165).
284+ else if ( terminal . status === 'failed' || terminal . status === 'dead_letter' ) failed ++ ;
278285 else retried ++ ;
279286 }
280287
@@ -308,7 +315,11 @@ async function recordAttempt(
308315 let status : DeliveryStatus ;
309316 if ( ok ) status = 'delivered' ;
310317 else if ( next ) status = 'pending' ;
311- else status = 'failed' ;
318+ // A delivery that exhausts its attempt budget (or its 24h delivery window)
319+ // is dead-lettered: it is kept for operator inspection and never retried
320+ // again (#165). This is the queue's explicit dead-letter state, distinct
321+ // from a transient 'failed' row.
322+ else status = 'dead_letter' ;
312323
313324 await client . query (
314325 `INSERT INTO webhook_attempts (delivery_id, attempt_number, status_code, error)
@@ -332,10 +343,37 @@ async function recordAttempt(
332343 return { id : input . id , status, statusCode : input . statusCode , error : input . error } ;
333344}
334345
346+ /**
347+ * The queue's lag: deliveries that are due for (re)delivery right now.
348+ *
349+ * This is the signal a consumer fleet auto-scales on (#165) — when lag stays
350+ * high, schedule more frequent or overlapping `/api/webhooks/deliver` runs;
351+ * when it is zero, the queue is drained. Rows with a null `next_retry_at`
352+ * (never attempted) are always due; the rest are due once their retry time
353+ * has passed.
354+ */
355+ export async function pendingDue (
356+ client : Client ,
357+ opts : { now ?: Date } = { } ,
358+ ) : Promise < number > {
359+ const now = ( opts . now ?? new Date ( ) ) . toISOString ( ) ;
360+ const res = await client . query < { count : string } > (
361+ `SELECT count(*)::text AS count
362+ FROM webhook_deliveries
363+ WHERE status = 'pending'
364+ AND (next_retry_at IS NULL OR next_retry_at <= $1::timestamptz)` ,
365+ [ now ] ,
366+ ) ;
367+ return Number ( res . rows [ 0 ] ?. count ?? 0 ) ;
368+ }
369+
335370export async function webhookSummary ( client : Client ) : Promise < {
336371 pending : number ;
337372 failed : number ;
373+ deadLetter : number ;
338374 delivered : number ;
375+ /** Deliveries due right now — the lag a consumer fleet scales on (#165). */
376+ lag : number ;
339377 recentFailed : Array < {
340378 id : number ;
341379 paymentTxHash : string ;
@@ -349,7 +387,12 @@ export async function webhookSummary(client: Client): Promise<{
349387 const counts = await client . query < { status : string ; n : string } > (
350388 `SELECT status, count(*)::text AS n FROM webhook_deliveries GROUP BY status` ,
351389 ) ;
352- const byStatus : Record < string , number > = { pending : 0 , failed : 0 , delivered : 0 } ;
390+ const byStatus : Record < string , number > = {
391+ pending : 0 ,
392+ failed : 0 ,
393+ delivered : 0 ,
394+ dead_letter : 0 ,
395+ } ;
353396 for ( const row of counts . rows ) byStatus [ row . status ] = Number ( row . n ) ;
354397
355398 const recent = await client . query < {
@@ -363,15 +406,17 @@ export async function webhookSummary(client: Client): Promise<{
363406 } > (
364407 `SELECT id, payment_tx_hash, status, attempts, last_status_code, last_error, updated_at
365408 FROM webhook_deliveries
366- WHERE status = 'failed'
409+ WHERE status IN ( 'failed', 'dead_letter')
367410 ORDER BY updated_at DESC
368411 LIMIT 20` ,
369412 ) ;
370413
371414 return {
372415 pending : ( byStatus . pending ?? 0 ) + ( byStatus . delivering ?? 0 ) ,
373416 failed : byStatus . failed ?? 0 ,
417+ deadLetter : byStatus . dead_letter ?? 0 ,
374418 delivered : byStatus . delivered ?? 0 ,
419+ lag : await pendingDue ( client ) ,
375420 recentFailed : recent . rows . map ( ( row ) => ( {
376421 id : Number ( row . id ) ,
377422 paymentTxHash : row . payment_tx_hash ,
0 commit comments