2626
2727_SAFE_IDENTIFIER = re .compile (r"^[a-z_][a-z0-9_]{0,62}$" )
2828DEFAULT_WORKFLOW_TABLE = "adcp_reference_workflows"
29+ DEFAULT_MAX_ATTEMPTS = 8
30+ DEFAULT_MAX_RETRY_SECONDS = 300.0
31+ DEFAULT_RETRY_BASE_SECONDS = 1.0
32+
33+ _RETRY_EXHAUSTED_ERROR : dict [str , str ] = {
34+ "code" : "INTERNAL_ERROR" ,
35+ "message" : "Workflow processing exhausted its retry budget." ,
36+ "recovery" : "terminal" ,
37+ }
2938
3039
3140@dataclass (frozen = True )
@@ -61,15 +70,29 @@ def __init__(
6170 registry : TaskRegistry ,
6271 table : str = DEFAULT_WORKFLOW_TABLE ,
6372 lease_seconds : int = 60 ,
73+ max_attempts : int = DEFAULT_MAX_ATTEMPTS ,
74+ retry_base_seconds : float = DEFAULT_RETRY_BASE_SECONDS ,
75+ max_retry_seconds : float = DEFAULT_MAX_RETRY_SECONDS ,
6476 ) -> None :
6577 if not _SAFE_IDENTIFIER .fullmatch (table ):
6678 raise ValueError ("workflow table must be a safe lowercase PostgreSQL identifier" )
6779 if lease_seconds < 2 :
6880 raise ValueError ("workflow lease_seconds must be at least 2" )
81+ if max_attempts < 1 :
82+ raise ValueError ("workflow max_attempts must be at least 1" )
83+ if retry_base_seconds <= 0 :
84+ raise ValueError ("workflow retry_base_seconds must be positive" )
85+ if max_retry_seconds < retry_base_seconds :
86+ raise ValueError ("workflow max_retry_seconds must be at least retry_base_seconds" )
6987 self ._pool = pool
7088 self ._registry = registry
7189 self ._table = table
90+ suffix = "_state_check"
91+ self ._state_constraint = f"{ table [: 63 - len (suffix )]} { suffix } "
7292 self ._lease_seconds = lease_seconds
93+ self ._max_attempts = max_attempts
94+ self ._retry_base_seconds = retry_base_seconds
95+ self ._max_retry_seconds = max_retry_seconds
7396
7497 self ._sql_claim = ( # noqa: S608 - table is validated above
7598 f"WITH candidate AS ("
@@ -108,13 +131,20 @@ async def create_schema(self) -> None:
108131 created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
109132 updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
110133 completed_at TIMESTAMPTZ,
111- CHECK (state IN ('pending', 'in_flight', 'completed')) ,
134+ dead_lettered_at TIMESTAMPTZ ,
112135 CHECK (attempt_count >= 0),
113136 CHECK (
114137 (state = 'in_flight') =
115138 (lease_token IS NOT NULL AND lease_expires_at IS NOT NULL)
116139 )
117140 )""" ,
141+ f"""ALTER TABLE { self ._table }
142+ ADD COLUMN IF NOT EXISTS dead_lettered_at TIMESTAMPTZ""" ,
143+ f"""ALTER TABLE { self ._table }
144+ DROP CONSTRAINT IF EXISTS { self ._state_constraint } """ ,
145+ f"""ALTER TABLE { self ._table }
146+ ADD CONSTRAINT { self ._state_constraint }
147+ CHECK (state IN ('pending', 'in_flight', 'completed', 'dead_lettered'))""" ,
118148 f"""CREATE INDEX IF NOT EXISTS { self ._table } _work_idx
119149 ON { self ._table } (available_at, created_at)
120150 WHERE state = 'pending'""" ,
@@ -123,8 +153,15 @@ async def create_schema(self) -> None:
123153 WHERE state = 'in_flight'""" ,
124154 ]
125155 async with self ._pool .connection () as conn :
126- for statement in statements :
127- await conn .execute (statement )
156+ async with conn .transaction ():
157+ # Web and worker processes may bootstrap concurrently in the
158+ # reference deployment. Serialize this example-owned DDL.
159+ await conn .execute (
160+ "SELECT pg_advisory_xact_lock(hashtext(%s))" ,
161+ (f"adcp.workflow_queue.schema:{ self ._table } " ,),
162+ )
163+ for statement in statements :
164+ await conn .execute (statement )
128165
129166 async def enqueue (
130167 self ,
@@ -207,18 +244,79 @@ async def claim(self) -> WorkflowJob | None:
207244 attempt_count = int (attempts ),
208245 )
209246
247+ def _retry_delay (self , attempt_count : int ) -> float :
248+ exponent = min (max (attempt_count - 1 , 0 ), 30 )
249+ return min (
250+ self ._max_retry_seconds ,
251+ self ._retry_base_seconds * (2 ** exponent ),
252+ )
253+
210254 async def _release (self , job : WorkflowJob , exc : Exception ) -> None :
211255 # Persist the class for operations without writing arbitrary exception
212256 # text (which may contain request data or credentials) into PostgreSQL.
213257 message = type (exc ).__name__
258+ retry_delay = self ._retry_delay (job .attempt_count )
214259 async with self ._pool .connection () as conn :
215260 await conn .execute (
216261 f"UPDATE { self ._table } SET state = 'pending', " # noqa: S608
217- "available_at = now() + interval '1 second', lease_token = NULL, "
262+ "available_at = now() + (%s * interval '1 second'), "
263+ "lease_token = NULL, "
218264 "lease_expires_at = NULL, last_error = %s, updated_at = now() "
219265 "WHERE task_id = %s AND state = 'in_flight' AND lease_token = %s" ,
220- (message , job .task_id , job .lease_token ),
266+ (retry_delay , message , job .task_id , job .lease_token ),
267+ )
268+
269+ async def _dead_letter (self , job : WorkflowJob , reason : str ) -> None :
270+ async with self ._pool .connection () as conn :
271+ cursor = await conn .execute (
272+ f"UPDATE { self ._table } SET state = 'dead_lettered', " # noqa: S608
273+ "lease_token = NULL, lease_expires_at = NULL, last_error = %s, "
274+ "dead_lettered_at = now(), updated_at = now() "
275+ "WHERE task_id = %s AND state = 'in_flight' AND lease_token = %s" ,
276+ (reason , job .task_id , job .lease_token ),
277+ )
278+ if cursor .rowcount != 1 :
279+ raise RuntimeError ("workflow lease was lost before dead-lettering" )
280+
281+ async def _handle_failure (self , job : WorkflowJob , exc : Exception ) -> None :
282+ if job .attempt_count < self ._max_attempts :
283+ await self ._release (job , exc )
284+ logger .warning (
285+ "Workflow job %s failed on attempt %d with %s; released for retry" ,
286+ job .task_id ,
287+ job .attempt_count ,
288+ type (exc ).__name__ ,
289+ )
290+ return
291+
292+ await self ._registry .fail (job .task_id , dict (_RETRY_EXHAUSTED_ERROR ))
293+ await self ._dead_letter (job , type (exc ).__name__ )
294+ logger .error (
295+ "Workflow job %s exhausted %d attempts with %s and was dead-lettered" ,
296+ job .task_id ,
297+ job .attempt_count ,
298+ type (exc ).__name__ ,
299+ )
300+
301+ async def _handle_unverified_failure (self , job : WorkflowJob , exc : Exception ) -> None :
302+ """Bound failures before account ownership has been established."""
303+ if job .attempt_count < self ._max_attempts :
304+ await self ._release (job , exc )
305+ logger .warning (
306+ "Workflow task lookup failed on attempt %d with %s; released for retry" ,
307+ job .attempt_count ,
308+ type (exc ).__name__ ,
221309 )
310+ return
311+
312+ # Never call the task-id-only registry mutation methods until the
313+ # account-scoped get above has positively established ownership.
314+ await self ._dead_letter (job , type (exc ).__name__ )
315+ logger .error (
316+ "Workflow task lookup exhausted %d attempts with %s; dead-lettered" ,
317+ job .attempt_count ,
318+ type (exc ).__name__ ,
319+ )
222320
223321 async def _acknowledge (self , job : WorkflowJob ) -> None :
224322 async with self ._pool .connection () as conn :
@@ -242,22 +340,54 @@ async def process_one(self, handler: WorkflowHandler) -> bool:
242340 job .task_id ,
243341 expected_account_id = job .account_id ,
244342 )
245- if task is None :
246- raise ValueError ("workflow job does not match an account-scoped task" )
247- if task .get ("state" ) in {"completed" , "failed" }:
248- # A prior worker may have committed terminal task state and
249- # died before acknowledging this queue lease. Do not rerun the
250- # business effect; reconcile the queue row to terminal state.
251- await self ._acknowledge (job )
252- return True
343+ except asyncio .CancelledError :
344+ raise
345+ except Exception as exc :
346+ await self ._handle_unverified_failure (job , exc )
347+ return True
348+
349+ if task is None :
350+ # Keep this transition outside the generic failure path. If the
351+ # queue write fails, a later lease retries only dead-lettering and
352+ # can never mutate a task belonging to another account.
353+ await self ._dead_letter (job , "task_missing_or_account_mismatch" )
354+ logger .error (
355+ "Workflow job %s has no matching account-scoped task; dead-lettered" ,
356+ job .task_id ,
357+ )
358+ return True
359+ if task .get ("state" ) == "failed" and task .get ("error" ) == _RETRY_EXHAUSTED_ERROR :
360+ # Registry failure and queue dead-lettering are separate commits.
361+ # Finish the second transition after a crash between them without
362+ # running the handler again.
363+ await self ._dead_letter (job , "retry_budget_exhausted" )
364+ return True
365+ if task .get ("state" ) in {"completed" , "failed" }:
366+ # A prior worker may have committed terminal task state and died
367+ # before acknowledging this queue lease. Do not rerun the business
368+ # effect; reconcile the queue row to terminal state.
369+ await self ._acknowledge (job )
370+ return True
371+ if job .attempt_count > self ._max_attempts :
372+ # A prior attempt exhausted the handler budget but could not
373+ # persist terminal registry state. Retry only finalization; never
374+ # execute the business handler beyond max_attempts.
375+ await self ._registry .fail (job .task_id , dict (_RETRY_EXHAUSTED_ERROR ))
376+ await self ._dead_letter (job , "retry_budget_exhausted" )
377+ return True
378+
379+ try :
253380 result = await handler (job )
254381 await self ._registry .complete (job .task_id , result )
255- await self ._acknowledge (job )
256382 except asyncio .CancelledError :
257383 raise
258384 except Exception as exc :
259- await self ._release (job , exc )
260- logger .exception ("Workflow job %s failed; released for retry" , job .task_id )
385+ await self ._handle_failure (job , exc )
386+ return True
387+
388+ # A failed acknowledgement must not enter the handler retry path: the
389+ # registry is already terminal, and lease recovery reconciles the row.
390+ await self ._acknowledge (job )
261391 return True
262392
263393 async def run_worker (
@@ -302,6 +432,9 @@ async def get(self, task_id: str) -> dict[str, Any] | None:
302432
303433
304434__all__ = [
435+ "DEFAULT_MAX_ATTEMPTS" ,
436+ "DEFAULT_MAX_RETRY_SECONDS" ,
437+ "DEFAULT_RETRY_BASE_SECONDS" ,
305438 "DEFAULT_WORKFLOW_TABLE" ,
306439 "PgWorkflowQueue" ,
307440 "WorkflowHandler" ,
0 commit comments