Skip to content

Commit 90ff4bc

Browse files
committed
fix(examples): bound durable workflow retries
1 parent aa40105 commit 90ff4bc

6 files changed

Lines changed: 826 additions & 27 deletions

File tree

docs/production-seller.md

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,15 @@ The reference's `IdempotencyStore` wrapping is intentionally paired with its
6262
inline terminal responses. Do not put the same method-level wrapper around a
6363
method that returns a raw `TaskHandoff` or `WorkflowHandoff`: the wrapper runs
6464
before framework task issuance and therefore cannot cache the projected
65-
`{status: "submitted", task_id}` envelope. A durable workflow adapter must
66-
deduplicate task issuance in its queue/business transaction (and advertise
67-
idempotency only when it does so) until that projection boundary is supported
68-
directly by the SDK.
65+
`{status: "submitted", task_id}` envelope. A durable workflow adapter must not
66+
advertise method-level idempotency for that method unless an external durable
67+
request-to-task mapping can reuse the prior task id. The reference queue's
68+
uniqueness constraint is only on the framework-issued `task_id`, not the
69+
buyer's idempotency key. A web-process
70+
crash after enqueue commits but before the `submitted` response reaches the
71+
buyer can therefore cause a retried request to issue a second task and queue
72+
row. Fully closing that window requires SDK support for looking up or reusing
73+
a workflow task id by buyer idempotency key.
6974

7075
For a mixed adapter, make the split explicit with
7176
`method_level_idempotency_methods`: include only methods that return terminal
@@ -98,7 +103,10 @@ artifact.
98103
[`workflow_queue.py`](../examples/v3_reference_seller/src/workflow_queue.py)
99104
is a PostgreSQL-backed adopter queue with expiring leases. The enqueue callback
100105
stores the framework task id before `WorkflowHandoff` returns `submitted`; a
101-
replacement worker reclaims an expired lease after a crash.
106+
replacement worker reclaims an expired lease after a crash. Handler failures
107+
retry with capped exponential backoff; after the configured attempt limit the
108+
registry task fails and the queue row moves to `dead_lettered`. Jobs without a
109+
matching account-scoped registry task dead-letter immediately.
102110

103111
```python
104112
queue = task_wiring.workflow_queue
@@ -198,8 +206,9 @@ missing field names. The retry horizon is projected into capabilities and must
198206
match the outbox value.
199207

200208
`DurableTaskWiring.startup()` calls `create_schema()` for a convenient local
201-
bootstrap. Those idempotent `CREATE TABLE IF NOT EXISTS` calls are not a schema
202-
migration system: they do not detect or evolve a table with the wrong shape.
209+
bootstrap. The workflow example performs the one additive upgrade shown here,
210+
but these runtime DDL calls are not a general schema migration system and do
211+
not detect or safely evolve an arbitrarily mismatched table.
203212
For production, copy the SDK-owned SQL files (`decisioning_tasks.sql` and
204213
`task_webhook_outbox.sql`), the `PgBackend.create_schema()` DDL, and the
205214
reference workflow-queue DDL into reviewed, versioned migrations and apply

examples/v3_reference_seller/README.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,16 @@ The bundled mock upstream resolves approvals quickly, so
9696
`create_media_buy` completes inline. When adapting the example to a human or
9797
long-running approval system, return `ctx.handoff_to_workflow(...)` and let a
9898
durable queue consumer call `registry.complete()` or `registry.fail()`; do not
99-
move a long polling loop into an in-process `TaskHandoff`. That queue must also
100-
deduplicate workflow issuance before enabling the idempotency capability; the
101-
reference's method wrapper is for its inline terminal-response path.
99+
move a long polling loop into an in-process `TaskHandoff`. Leave method-level
100+
idempotency unadvertised for that method unless an external durable
101+
request-to-task mapping can reuse the prior task id. The reference's method
102+
wrapper is for its inline terminal-response path.
103+
The reference queue deduplicates only the framework-issued `task_id`. It does
104+
not close the crash window between committing enqueue and returning
105+
`submitted`, because a buyer retry can receive a newly issued task id. That
106+
requires SDK support for reusing a workflow task id by buyer idempotency key.
107+
Worker failures use capped exponential backoff and a bounded attempt count;
108+
exhausted or account-mismatched jobs move to `dead_lettered` for operations.
102109
The queue and restart-recovery test are runnable reference infrastructure;
103110
adopters supply the workflow handler that talks to their approval system.
104111

examples/v3_reference_seller/src/durable_tasks.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from __future__ import annotations
99

10+
import asyncio
1011
import base64
1112
import binascii
1213
import logging
@@ -187,6 +188,23 @@ async def startup(self) -> None:
187188
await self.workflow_queue.create_schema()
188189
if self.idempotency_backend is not None:
189190
await self.idempotency_backend.create_schema()
191+
except asyncio.CancelledError:
192+
cleanup = asyncio.create_task(self.shutdown())
193+
# Once startup owns resources, repeated cancellation must not
194+
# strand them. Shield the cleanup task and preserve cancellation
195+
# for the caller after every close has had a chance to run.
196+
while not cleanup.done():
197+
try:
198+
await asyncio.shield(cleanup)
199+
except asyncio.CancelledError:
200+
continue
201+
try:
202+
cleanup.result()
203+
except asyncio.CancelledError:
204+
logger.exception("Durable resource cleanup was cancelled")
205+
except Exception:
206+
logger.exception("Durable resource cleanup failed after startup cancellation")
207+
raise
190208
except Exception:
191209
try:
192210
await self.shutdown()
@@ -196,14 +214,18 @@ async def startup(self) -> None:
196214

197215
async def shutdown(self) -> None:
198216
"""Best-effort close every resource owned by this process."""
199-
first_error: Exception | None = None
217+
first_error: BaseException | None = None
200218
closers = [self.sender.aclose]
201219
if self.lock_pool is not None:
202220
closers.append(self.lock_pool.close)
203221
closers.append(self.pool.close)
204222
for close in closers:
205223
try:
206224
await close()
225+
except asyncio.CancelledError as exc:
226+
logger.exception("Durable task resource close was cancelled")
227+
if first_error is None:
228+
first_error = exc
207229
except Exception as exc:
208230
logger.exception("Failed to close a durable task resource")
209231
if first_error is None:

examples/v3_reference_seller/src/workflow_queue.py

Lines changed: 149 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@
2626

2727
_SAFE_IDENTIFIER = re.compile(r"^[a-z_][a-z0-9_]{0,62}$")
2828
DEFAULT_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

Comments
 (0)