@@ -2,7 +2,9 @@ import { db } from '@sim/db'
22import { document } from '@sim/db/schema'
33import { createLogger } from '@sim/logger'
44import { toStringOrNull } from '@sim/utils/coerce'
5+ import { getTransientDatabaseFailure } from '@sim/utils/errors'
56import { toRecord } from '@sim/utils/object'
7+ import { backoffWithJitter } from '@sim/utils/retry'
68import { and , eq , isNotNull , isNull } from 'drizzle-orm'
79import {
810 type DeferredOutboxHandlerResult ,
@@ -23,6 +25,17 @@ const logger = createLogger('KnowledgeDeferredRetryCheck')
2325/** How often a document whose run still looks live, or could not be looked up, is checked again. */
2426export const DEFERRED_RETRY_RECHECK_MS = 60 * 60 * 1000
2527
28+ /**
29+ * How long after the deferral a database failure may still postpone the check without spending
30+ * an attempt. The check itself stops asking about liveness at {@link RECOVERY_WINDOW_MS}; the extra
31+ * day lets that final write outlast a slow database window too. Past it, a database failure spends
32+ * attempts like any other error, so the event always reaches completion or dead letter.
33+ */
34+ export const DEFERRED_RETRY_CHECK_TERMINAL_MS = RECOVERY_WINDOW_MS + 24 * 60 * 60 * 1000
35+
36+ /** First delay after a database failure; later ones grow with how overdue the check is. */
37+ const DATABASE_BACKOFF_STEP_MS = 2 * 60 * 1000
38+
2639export const DEFERRED_RETRY_LOST_ERROR =
2740 'The scheduled retry for this document did not run. Retry the document to process it again.'
2841
@@ -78,13 +91,43 @@ function isSameDeferral(
7891 * without spending an attempt. Past {@link RECOVERY_WINDOW_MS} after the deferral no retry of that
7992 * generation can still be running (a database retry is due within minutes and each run is bounded),
8093 * so the check stops asking and fails the document, which is what guarantees the event ends.
94+ *
95+ * A transient database failure while checking, most likely the same slow window that deferred the
96+ * document, postpones the check without spending an attempt, so the watchdog cannot dead-letter
97+ * during the outage it exists to outlast. That stops at {@link DEFERRED_RETRY_CHECK_TERMINAL_MS};
98+ * any other error spends an attempt as before.
8199 */
82100export const checkDeferredDocumentRetry : OutboxHandler < unknown > = async (
83101 rawPayload ,
84102 context
85103) : Promise < DeferredOutboxHandlerResult | undefined > => {
86104 const payload = parsePayload ( rawPayload )
87- context . signal . throwIfAborted ( )
105+ try {
106+ return await checkDeferral ( payload , context . signal )
107+ } catch ( error ) {
108+ context . signal . throwIfAborted ( )
109+ const failure = getTransientDatabaseFailure ( error )
110+ const deferredUntil = Date . parse ( payload . processingDeferredUntil )
111+ const now = Date . now ( )
112+ if ( ! failure || now >= deferredUntil + DEFERRED_RETRY_CHECK_TERMINAL_MS ) throw error
113+ /** Paced by how long the check has been overdue, since a deferral spends no attempt to count. */
114+ const overdueMs = Math . max ( 0 , now - deferredUntil - QUEUED_DISPATCH_GRACE_MS )
115+ return deferOutboxHandler (
116+ `Database ${ failure } failure while checking the deferred retry` ,
117+ backoffWithJitter ( 1 + Math . floor ( overdueMs / DATABASE_BACKOFF_STEP_MS ) , null , {
118+ baseMs : DATABASE_BACKOFF_STEP_MS ,
119+ maxMs : DEFERRED_RETRY_RECHECK_MS ,
120+ } ) ,
121+ false
122+ )
123+ }
124+ }
125+
126+ async function checkDeferral (
127+ payload : DeferredRetryCheckPayload ,
128+ signal : AbortSignal
129+ ) : Promise < DeferredOutboxHandlerResult | undefined > {
130+ signal . throwIfAborted ( )
88131 const [ row ] = await db
89132 . select ( {
90133 ...processingSnapshotColumns ,
@@ -109,7 +152,7 @@ export const checkDeferredDocumentRetry: OutboxHandler<unknown> = async (
109152 return deferOutboxHandler ( 'Deferred retry is not overdue yet' , dueAt - now , false )
110153 }
111154 if ( now < deferredUntil + RECOVERY_WINDOW_MS ) {
112- const { abandoned } = await inspectDocumentProcessingLiveness ( [ row ] , context . signal )
155+ const { abandoned } = await inspectDocumentProcessingLiveness ( [ row ] , signal )
113156 if ( abandoned . length === 0 ) {
114157 return deferOutboxHandler (
115158 'Deferred retry may still be running' ,
@@ -118,7 +161,7 @@ export const checkDeferredDocumentRetry: OutboxHandler<unknown> = async (
118161 )
119162 }
120163 }
121- context . signal . throwIfAborted ( )
164+ signal . throwIfAborted ( )
122165
123166 const failed = await db
124167 . update ( document )
0 commit comments