stitch-durable 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. stitch_durable-0.1.0/LICENSE +21 -0
  2. stitch_durable-0.1.0/PKG-INFO +665 -0
  3. stitch_durable-0.1.0/README.md +638 -0
  4. stitch_durable-0.1.0/pyproject.toml +47 -0
  5. stitch_durable-0.1.0/setup.cfg +4 -0
  6. stitch_durable-0.1.0/stitch_durable/__init__.py +65 -0
  7. stitch_durable-0.1.0/stitch_durable/alerts.py +15 -0
  8. stitch_durable-0.1.0/stitch_durable/critical.py +46 -0
  9. stitch_durable-0.1.0/stitch_durable/errors.py +76 -0
  10. stitch_durable-0.1.0/stitch_durable/execution/__init__.py +0 -0
  11. stitch_durable-0.1.0/stitch_durable/execution/batch_runner.py +200 -0
  12. stitch_durable-0.1.0/stitch_durable/execution/context.py +98 -0
  13. stitch_durable-0.1.0/stitch_durable/execution/item_processor.py +169 -0
  14. stitch_durable-0.1.0/stitch_durable/execution/reporting.py +39 -0
  15. stitch_durable-0.1.0/stitch_durable/execution/run_session.py +67 -0
  16. stitch_durable-0.1.0/stitch_durable/execution/step_decorator.py +89 -0
  17. stitch_durable-0.1.0/stitch_durable/execution/step_definition.py +15 -0
  18. stitch_durable-0.1.0/stitch_durable/execution/step_executor.py +307 -0
  19. stitch_durable-0.1.0/stitch_durable/providers/__init__.py +0 -0
  20. stitch_durable-0.1.0/stitch_durable/providers/breaker_tuning.py +10 -0
  21. stitch_durable-0.1.0/stitch_durable/providers/provider_pool.py +274 -0
  22. stitch_durable-0.1.0/stitch_durable/py.typed +0 -0
  23. stitch_durable-0.1.0/stitch_durable/records.py +70 -0
  24. stitch_durable-0.1.0/stitch_durable/retry_policy.py +25 -0
  25. stitch_durable-0.1.0/stitch_durable/serialization.py +15 -0
  26. stitch_durable-0.1.0/stitch_durable/storage/__init__.py +0 -0
  27. stitch_durable-0.1.0/stitch_durable/storage/audit_repository.py +65 -0
  28. stitch_durable-0.1.0/stitch_durable/storage/breaker_repository.py +42 -0
  29. stitch_durable-0.1.0/stitch_durable/storage/checkpoint_store.py +32 -0
  30. stitch_durable-0.1.0/stitch_durable/storage/database.py +112 -0
  31. stitch_durable-0.1.0/stitch_durable/storage/item_repository.py +114 -0
  32. stitch_durable-0.1.0/stitch_durable/storage/provider_repository.py +43 -0
  33. stitch_durable-0.1.0/stitch_durable/storage/run_repository.py +50 -0
  34. stitch_durable-0.1.0/stitch_durable/storage/step_repository.py +123 -0
  35. stitch_durable-0.1.0/stitch_durable/tracing.py +141 -0
  36. stitch_durable-0.1.0/stitch_durable/workflows/__init__.py +0 -0
  37. stitch_durable-0.1.0/stitch_durable/workflows/recovery.py +112 -0
  38. stitch_durable-0.1.0/stitch_durable/workflows/registry.py +20 -0
  39. stitch_durable-0.1.0/stitch_durable/workflows/workflow.py +97 -0
  40. stitch_durable-0.1.0/stitch_durable.egg-info/PKG-INFO +665 -0
  41. stitch_durable-0.1.0/stitch_durable.egg-info/SOURCES.txt +44 -0
  42. stitch_durable-0.1.0/stitch_durable.egg-info/dependency_links.txt +1 -0
  43. stitch_durable-0.1.0/stitch_durable.egg-info/top_level.txt +1 -0
  44. stitch_durable-0.1.0/tests/test_stitch_critical.py +179 -0
  45. stitch_durable-0.1.0/tests/test_stitch_engine.py +171 -0
  46. stitch_durable-0.1.0/tests/test_stitch_providers.py +333 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 batman004
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,665 @@
1
+ Metadata-Version: 2.4
2
+ Name: stitch-durable
3
+ Version: 0.1.0
4
+ Summary: Resumable async Python workflows from the standard library alone: checkpoints, retries, audit trail, provider fallbacks
5
+ Author: Yuvraj Singh Pathania
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/batman004/stitch
8
+ Project-URL: Issues, https://github.com/batman004/stitch/issues
9
+ Project-URL: Changelog, https://github.com/batman004/stitch/blob/main/CHANGELOG.md
10
+ Keywords: durable-execution,checkpoint,asyncio,workflow,retry,circuit-breaker
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Framework :: AsyncIO
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.11
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Dynamic: license-file
27
+
28
+ <p align="center">
29
+ <h1 align="center">⛓️ stitch</h1>
30
+ <p align="center">
31
+ <strong>Durable, resumable async workflows for Python — zero dependencies.</strong>
32
+ </p>
33
+ <p align="center">
34
+ <a href="#quick-start">Quick Start</a> ·
35
+ <a href="#features">Features</a> ·
36
+ <a href="#system-design">System Design</a> ·
37
+ <a href="#samples">Samples</a> ·
38
+ <a href="#api-reference">API Reference</a>
39
+ </p>
40
+ </p>
41
+
42
+ ---
43
+
44
+ **stitch** gives every async step a checkpoint, a retry budget, and a fallback — using nothing but the Python standard library. If a process crashes, a rerun picks up where it left off. If a dependency goes down, a circuit breaker parks or degrades the call and the batch keeps moving.
45
+
46
+ ```bash
47
+ pip install stitch-durable
48
+ ```
49
+
50
+ > [!NOTE]
51
+ > Python 3.11+. Distribution name: `stitch-durable`. Import name: `stitch_durable`. **Zero third-party dependencies.**
52
+
53
+ ---
54
+
55
+ ## Quick Start
56
+
57
+ ```python
58
+ from stitch_durable import CheckpointStore, RetryPolicy, Workflow, step
59
+
60
+ @step(retry_policy=RetryPolicy(retry_on=(OSError,)))
61
+ async def download(article): ...
62
+
63
+ @step(degrade=quick_fallback)
64
+ async def analyse(article, text): ...
65
+
66
+ async def process(article):
67
+ text = await download(article)
68
+ await analyse(article, text)
69
+
70
+ store = CheckpointStore("state.db")
71
+ await Workflow(process, name="demo").run_many(articles, store)
72
+ ```
73
+
74
+ Every completed step is checkpointed to SQLite before the next one starts. A rerun skips what's already done. When retries run out, `degrade` supplies a flagged fallback and the item still finishes. `WorkflowRecovery(store).heal()` redoes those degraded steps later.
75
+
76
+ ---
77
+
78
+ ## Features
79
+
80
+ ### 🔖 Checkpoints
81
+
82
+ Each `@step` result is persisted to SQLite before the next step runs. On a later `run_many`:
83
+
84
+ - **Done items** → skipped entirely
85
+ - **Done/degraded steps** → result reused from the checkpoint
86
+ - **Interrupted steps** (left `running`) → re-executed, audit records the interruption
87
+
88
+ Calling a step twice in one item stores `name` then `name#2`. Outside a workflow, `@step` calls the function directly — perfect for unit tests.
89
+
90
+ ### 🔁 Retries
91
+
92
+ `RetryPolicy` controls which exceptions trigger retries and how long to wait between them.
93
+
94
+ | Parameter | Default | Description |
95
+ |---|---|---|
96
+ | `maximum_attempts` | 4 | Total tries before giving up |
97
+ | `retry_on` | `(TransientError, TimeoutError)` | Exception types that trigger a retry |
98
+ | `base_delay_seconds` | 0.25 | Initial backoff delay |
99
+ | `maximum_delay_seconds` | 4.0 | Cap on backoff growth |
100
+ | `timeout_seconds` | `None` | Per-attempt timeout (raises `TimeoutError`) |
101
+
102
+ Delay is jittered between 50–100% of the computed backoff. An exception with a `retry_after` attribute overrides the delay. `PermanentError` always fails immediately. Pass `item_retry_policy` to `Workflow` to retry the entire item after a step failure — finished steps inside that retry are preserved.
103
+
104
+ ### ↓ Fallbacks & Degradation
105
+
106
+ When retries are exhausted or no provider is free, the `degrade` function produces a flagged substitute result:
107
+
108
+ ```python
109
+ def fallback(article, cause=None):
110
+ return {"summary": article["id"], "source": "fallback"}
111
+
112
+ @step(degrade=fallback)
113
+ async def summarise(article): ...
114
+ ```
115
+
116
+ - The step is stored as `degraded`, and later steps still run
117
+ - A plain rerun keeps the fallback — the item is marked done
118
+ - `WorkflowRecovery(store).heal()` deletes checkpoints from the first degraded step onward and retries
119
+
120
+ ### 🅿️ Parking
121
+
122
+ When every provider is cooling down and there's no `degrade` function, the item is **parked** instead of failed:
123
+
124
+ ```python
125
+ # Explicit parking
126
+ raise Park(time.time() + 30, "waiting on a file")
127
+
128
+ # Automatic parking — wait inside run_many
129
+ await workflow.run_many(items, store, wait_for_parked=True)
130
+
131
+ # Resume parked items later
132
+ await WorkflowRecovery(store).resume()
133
+ ```
134
+
135
+ ### 🛑 Critical Steps
136
+
137
+ A critical step halts the **entire batch** when a transient failure exhausts retries:
138
+
139
+ ```python
140
+ @step(
141
+ critical=CriticalPolicy(health_check=database_ok),
142
+ retry_policy=RetryPolicy(maximum_attempts=2),
143
+ )
144
+ async def save(article, text): ...
145
+ ```
146
+
147
+ - In-flight work is cancelled, one `Alerter` alert fires, the run is stored as `halted`
148
+ - Next `run_many` calls the health check first — if it fails, raises `HealthCheckFailed`
149
+ - Once healthy: a `recovered` alert fires, the run continues, finished steps are reused
150
+ - `PermanentError` in a critical step fails only that item
151
+ - A step **cannot** be both `critical` and `degrade` (raises `ValueError`)
152
+
153
+ ### 🔌 Provider Pools & Circuit Breakers
154
+
155
+ `ProviderPool` manages multiple backends with automatic failover and per-provider circuit breakers:
156
+
157
+ ```python
158
+ pool = ProviderPool(
159
+ "llm",
160
+ Client,
161
+ default_providers=[("primary", 1, {}), ("backup", 2, {"region": "eu"})],
162
+ tuning=BreakerTuning(quota_cooldown_seconds=60),
163
+ )
164
+
165
+ @step
166
+ async def summarise(article):
167
+ return await pool.call(lambda client: client.complete(article["title"]))
168
+ ```
169
+
170
+ | Trigger | Breaker Opens After | Cooldown |
171
+ |---|---|---|
172
+ | `QuotaExhausted` | 1 failure | `quota_cooldown_seconds` (60s) |
173
+ | `RateLimited` | 1 failure | `rate_limit_cooldown_seconds` (1s) |
174
+ | `ProviderDown`, `ConnectionError`, timeout | `failure_threshold` (3) | Exponential from `base_cooldown_seconds` (10s), capped at `maximum_cooldown_seconds` (120s) |
175
+
176
+ After cooldown, the next call **probes** the provider. A failed probe extends the cooldown; a success closes the breaker. Use `pool.snapshot(store)` to inspect each provider's state: `ready`, `cooling`, `probe`, or `disabled`.
177
+
178
+ ### 📊 Audit Trail & Reporters
179
+
180
+ Every event is appended to the `audit` table. Use `TraceRenderer` for a formatted timeline, or subclass `Reporter` for live streaming:
181
+
182
+ ```python
183
+ class LineReporter(Reporter):
184
+ def on_step_retry(self, step, attempt, error, delay):
185
+ print(f"{step} attempt {attempt} failed ({error}); retry in {delay:.2f}s")
186
+
187
+ stats = await workflow.run_many(items, store, reporter=LineReporter())
188
+ print(f"{stats.items_done} done, {stats.items_failed} failed, {stats.step_retries} retries")
189
+ ```
190
+
191
+ `run_many` returns `RunStatistics` with counts: done, skipped, failed, parked, degraded, retried, failed-over.
192
+
193
+ ---
194
+
195
+ ## System Design
196
+
197
+ ### Architecture
198
+
199
+ ![Architecture: the engine calls back into your workflow and step functions, and every status change is written to one SQLite file](https://raw.githubusercontent.com/batman004/stitch/main/docs/diagrams/architecture.svg)
200
+
201
+ The highlighted path is the route one item takes. The engine calls back into your workflow and step functions, and every status change is written to one SQLite file.
202
+
203
+ ### One Step Call
204
+
205
+ ![One step call: reuse a checkpoint, otherwise run with retries, then fall back, park, halt or fail](https://raw.githubusercontent.com/batman004/stitch/main/docs/diagrams/step-flow.svg)
206
+
207
+ What happens each time a `@step` function is called. The gates are checked top to bottom, and the first one that says yes decides the outcome.
208
+
209
+ ### Item Lifecycle
210
+
211
+ ![Item lifecycle: running items end done, degraded, parked or failed; heal(), resume() and run_many() send them back to running](https://raw.githubusercontent.com/batman004/stitch/main/docs/diagrams/item-lifecycle.svg)
212
+
213
+ Item status as stored in the `items` table. A degraded item is done with a flag; `heal()` redoes the first degraded step and everything after it.
214
+
215
+ ### Key Design Decisions
216
+
217
+ | Decision | Rationale |
218
+ |---|---|
219
+ | **SQLite in WAL mode** | Single-file state, no external services, crash-safe writes |
220
+ | **Context variables** | The active run, item, and step propagate without threading them through every call |
221
+ | **Registry pattern** | `resume` and `heal` look up workflows by name — import the module before calling them |
222
+ | **One process, one file** | Simplifies concurrency; async workers share the connection |
223
+ | **JSON-serializable I/O** | Step results and inputs must round-trip through `json.dumps` |
224
+
225
+ ---
226
+
227
+ ## Samples
228
+
229
+ > Each program is self-contained. Run it where `import stitch_durable` works. It writes `state.db` in the working directory. Delete that file before re-running — finished items are skipped by design.
230
+
231
+ ### 1 · Retry a step, then resume the failed item
232
+
233
+ `a` loses its connection twice then succeeds. `b` downloads fine, but indexing rejects it permanently. Clearing the fault and re-running the same batch skips `a` entirely and reuses `b`'s saved download.
234
+
235
+ ```python
236
+ import asyncio
237
+
238
+ from stitch_durable import CheckpointStore, PermanentError, Reporter, RetryPolicy, Workflow, step
239
+
240
+ download_attempts = {}
241
+ search_down = {"b": True}
242
+
243
+
244
+ class LineReporter(Reporter):
245
+ def on_step_retry(self, step, attempt, error, delay):
246
+ print(f"{step} attempt {attempt} failed ({error}); retry in {delay:.2f}s")
247
+
248
+ def on_item_failed(self, item, error):
249
+ print(f"{item} failed: {error}")
250
+
251
+ def on_step_skipped(self, step, item):
252
+ print(f"{item}: {step} skipped")
253
+
254
+
255
+ @step(
256
+ retry_policy=RetryPolicy(
257
+ maximum_attempts=3,
258
+ base_delay_seconds=0.01,
259
+ maximum_delay_seconds=0.01,
260
+ retry_on=(OSError,),
261
+ )
262
+ )
263
+ async def download(article):
264
+ key = article["id"]
265
+ download_attempts[key] = download_attempts.get(key, 0) + 1
266
+ if key == "a" and download_attempts[key] < 3:
267
+ raise OSError("connection reset")
268
+ return {"id": key, "text": f"body of {key}"}
269
+
270
+
271
+ @step
272
+ async def index(article, document):
273
+ if article["id"] == "b" and search_down["b"]:
274
+ raise PermanentError("search rejected the document")
275
+ return {"id": document["id"], "words": len(document["text"].split())}
276
+
277
+
278
+ async def process(article):
279
+ document = await download(article)
280
+ return await index(article, document)
281
+
282
+
283
+ async def main():
284
+ store = CheckpointStore("state.db")
285
+ articles = [{"id": "a"}, {"id": "b"}]
286
+ workflow = Workflow(process, name="demo")
287
+ first = await workflow.run_many(articles, store, reporter=LineReporter())
288
+ print(f"first run: {first.items_done} done, {first.items_failed} failed, {first.step_retries} retries")
289
+ search_down["b"] = False
290
+ second = await workflow.run_many(articles, store, reporter=LineReporter())
291
+ print(
292
+ f"second run: {second.items_skipped} skipped, {second.items_done} done, "
293
+ f"{second.steps_skipped} steps reused"
294
+ )
295
+ store.close()
296
+
297
+
298
+ asyncio.run(main())
299
+ ```
300
+
301
+ **Output:**
302
+
303
+ ```text
304
+ b failed: index: search rejected the document
305
+ first run: 1 done, 1 failed, 2 retries
306
+ b: download skipped
307
+ second run: 1 skipped, 1 done, 1 steps reused
308
+ ```
309
+
310
+ ---
311
+
312
+ ### 2 · Publish a fallback, then heal it
313
+
314
+ The only model is out of credits, so `summarise` stores a fallback and `publish` still runs. After the provider recovers, `heal()` deletes those checkpoints and re-runs both steps with real data.
315
+
316
+ ```python
317
+ import asyncio
318
+
319
+ from stitch_durable import (
320
+ BreakerTuning,
321
+ CheckpointStore,
322
+ ProviderPool,
323
+ QuotaExhausted,
324
+ TraceRenderer,
325
+ WorkflowRecovery,
326
+ step,
327
+ workflow,
328
+ )
329
+
330
+
331
+ class Client:
332
+ def __init__(self, name, config):
333
+ self.name = name
334
+
335
+ async def complete(self, text):
336
+ if not healthy["ok"]:
337
+ raise QuotaExhausted("insufficient credits")
338
+ return f"{self.name}: {text}"
339
+
340
+
341
+ healthy = {"ok": False}
342
+ published = []
343
+ pool = ProviderPool(
344
+ "llm",
345
+ Client,
346
+ default_providers=[("primary", 1, {})],
347
+ tuning=BreakerTuning(quota_cooldown_seconds=0.2),
348
+ )
349
+
350
+
351
+ def fallback(article, cause=None):
352
+ return {"summary": article["id"], "source": "fallback"}
353
+
354
+
355
+ @step(degrade=fallback)
356
+ async def summarise(article):
357
+ text = await pool.call(lambda client: client.complete(article["id"]))
358
+ return {"summary": text, "source": "llm"}
359
+
360
+
361
+ @step
362
+ async def publish(article, summary):
363
+ published.append((article["id"], summary["source"]))
364
+ return {"ok": True}
365
+
366
+
367
+ @workflow(name="triage")
368
+ async def handle(article):
369
+ summary = await summarise(article)
370
+ await publish(article, summary)
371
+ return summary["source"]
372
+
373
+
374
+ async def main():
375
+ store = CheckpointStore("state.db")
376
+ first = await handle.run_many([{"id": "a"}], store)
377
+ print("published", published, "degraded", first.items_degraded)
378
+ healthy["ok"] = True
379
+ await asyncio.sleep(0.35)
380
+ healed = await WorkflowRecovery(store).heal()
381
+ print("after heal", published, "degraded", healed["triage:default"].items_degraded)
382
+ print(TraceRenderer().render(store.audit.list_records(item_key="a")))
383
+ store.close()
384
+
385
+
386
+ asyncio.run(main())
387
+ ```
388
+
389
+ **Trace output:**
390
+
391
+ ```text
392
+ 23:07:12.555 + 0.00s ● started
393
+ 23:07:12.555 + 0.00s ▸ summarise started
394
+ 23:07:12.555 + 0.00s ! summarise: primary failed (QuotaExhausted): insufficient credits
395
+ 23:07:12.555 + 0.00s ⛔ breaker OPEN on primary for 0.2s: insufficient credits
396
+ 23:07:12.555 + 0.00s ↓ summarise used its fallback: no provider available in pool 'llm' (primary all cooling down)
397
+ 23:07:12.555 + 0.00s ✓ summarise done in 0.00s DEGRADED
398
+ 23:07:12.555 + 0.00s ▸ publish started
399
+ 23:07:12.555 + 0.00s ✓ publish done in 0.00s
400
+ 23:07:12.555 + 0.00s ■ finished (degraded: fallback results published)
401
+ 23:07:12.907 + 0.35s ♻ heal: redoing summarise,publish
402
+ 23:07:12.908 + 0.35s ● started again (was running)
403
+ 23:07:12.908 + 0.35s ▸ summarise started
404
+ 23:07:12.909 + 0.35s ? probing primary after its cooldown
405
+ 23:07:12.909 + 0.35s ✔ breaker closed on primary: it is healthy again
406
+ 23:07:12.909 + 0.35s → summarise: answered by primary
407
+ 23:07:12.909 + 0.35s ✓ summarise done in 0.00s
408
+ 23:07:12.909 + 0.35s ▸ publish started
409
+ 23:07:12.909 + 0.35s ✓ publish done in 0.00s
410
+ 23:07:12.909 + 0.35s ■ finished
411
+ ```
412
+
413
+ ---
414
+
415
+ ### 3 · Fail over between providers
416
+
417
+ `primary` has no credits, so the pool automatically routes to `backup`. A snapshot confirms `primary` is cooling:
418
+
419
+ ```python
420
+ import asyncio
421
+
422
+ from stitch_durable import CheckpointStore, ProviderPool, QuotaExhausted, Workflow, step
423
+
424
+
425
+ class Client:
426
+ def __init__(self, name, config):
427
+ self.name = name
428
+
429
+ async def complete(self, text):
430
+ if self.name == "primary":
431
+ raise QuotaExhausted("insufficient credits")
432
+ return f"{self.name}: {text}"
433
+
434
+
435
+ pool = ProviderPool(
436
+ "llm",
437
+ Client,
438
+ default_providers=[("primary", 1, {}), ("backup", 2, {"region": "backup"})],
439
+ )
440
+
441
+
442
+ @step
443
+ async def summarise(article):
444
+ return await pool.call(lambda client: client.complete(article["title"]))
445
+
446
+
447
+ async def handle(article):
448
+ return await summarise(article)
449
+
450
+
451
+ async def main():
452
+ store = CheckpointStore("state.db")
453
+ workflow = Workflow(handle, name="summaries")
454
+ await workflow.run_many([{"id": "a", "title": "durable workflows"}], store)
455
+ print(store.items.get("summaries:default", "a").result)
456
+ for row in pool.snapshot(store):
457
+ print(row["name"], row["state"])
458
+ store.close()
459
+
460
+
461
+ asyncio.run(main())
462
+ ```
463
+
464
+ ```text
465
+ backup: durable workflows
466
+ primary cooling
467
+ backup ready
468
+ ```
469
+
470
+ ---
471
+
472
+ ### 4 · Park when all providers are down, then resume
473
+
474
+ With a single provider and no fallback, the item is parked. Once the provider recovers, `resume()` picks it back up:
475
+
476
+ ```python
477
+ import asyncio
478
+
479
+ from stitch_durable import (
480
+ BreakerTuning,
481
+ CheckpointStore,
482
+ ProviderPool,
483
+ QuotaExhausted,
484
+ Workflow,
485
+ WorkflowRecovery,
486
+ step,
487
+ )
488
+
489
+
490
+ class Client:
491
+ def __init__(self, name, config):
492
+ self.name = name
493
+
494
+ async def complete(self, text):
495
+ if not recovered["ok"]:
496
+ raise QuotaExhausted("insufficient credits")
497
+ return text
498
+
499
+
500
+ recovered = {"ok": False}
501
+ pool = ProviderPool(
502
+ "llm",
503
+ Client,
504
+ default_providers=[("only", 1, {})],
505
+ tuning=BreakerTuning(quota_cooldown_seconds=0.2),
506
+ )
507
+
508
+
509
+ @step
510
+ async def summarise(article):
511
+ return await pool.call(lambda client: client.complete(article["id"]))
512
+
513
+
514
+ async def handle(article):
515
+ return await summarise(article)
516
+
517
+
518
+ async def main():
519
+ store = CheckpointStore("state.db")
520
+ workflow = Workflow(handle, name="strict")
521
+ first = await workflow.run_many([{"id": "a"}], store)
522
+ print(f"parked {first.items_parked}, still parked {first.parked_remaining}")
523
+ recovered["ok"] = True
524
+ await asyncio.sleep(0.35)
525
+ resumed = await WorkflowRecovery(store).resume()
526
+ print(f"resumed {resumed['strict:default'].items_done}")
527
+ store.close()
528
+
529
+
530
+ asyncio.run(main())
531
+ ```
532
+
533
+ ```text
534
+ parked 1, still parked 1
535
+ resumed 1
536
+ ```
537
+
538
+ ---
539
+
540
+ ### 5 · Halt the run when a critical step stays down
541
+
542
+ `save` retries a transient database error then halts the batch. A second attempt is refused while the health check fails. Once the resource recovers, the run continues and reuses the already-checkpointed `gather` step:
543
+
544
+ ```python
545
+ import asyncio
546
+
547
+ from stitch_durable import (
548
+ Alert,
549
+ Alerter,
550
+ CheckpointStore,
551
+ CriticalPolicy,
552
+ CriticalStepFailed,
553
+ HealthCheckFailed,
554
+ RetryPolicy,
555
+ TransientError,
556
+ Workflow,
557
+ step,
558
+ )
559
+
560
+ resource = {"down": True}
561
+
562
+
563
+ class PrintAlerter(Alerter):
564
+ def send(self, alert: Alert) -> None:
565
+ print(f"{alert.severity}: {alert.title}")
566
+
567
+
568
+ def database_ok() -> bool:
569
+ return not resource["down"]
570
+
571
+
572
+ @step
573
+ async def gather(article):
574
+ return article["id"]
575
+
576
+
577
+ @step(
578
+ retry_policy=RetryPolicy(maximum_attempts=2, base_delay_seconds=0.01, maximum_delay_seconds=0.01),
579
+ critical=CriticalPolicy(health_check=database_ok),
580
+ )
581
+ async def save(article, text):
582
+ if resource["down"]:
583
+ raise TransientError("database unavailable")
584
+ return {"saved": text}
585
+
586
+
587
+ async def handle(article):
588
+ text = await gather(article)
589
+ return await save(article, text)
590
+
591
+
592
+ async def main():
593
+ store = CheckpointStore("state.db")
594
+ workflow = Workflow(handle, name="archive")
595
+ articles = [{"id": "a"}]
596
+ alerter = PrintAlerter()
597
+ try:
598
+ await workflow.run_many(articles, store, alerter=alerter)
599
+ except CriticalStepFailed as error:
600
+ print(error)
601
+ try:
602
+ await workflow.run_many(articles, store, alerter=alerter)
603
+ except HealthCheckFailed as error:
604
+ print(error)
605
+ resource["down"] = False
606
+ stats = await workflow.run_many(articles, store, alerter=alerter)
607
+ print(f"resumed {stats.items_done} done, {stats.steps_skipped} steps reused")
608
+ store.close()
609
+
610
+
611
+ asyncio.run(main())
612
+ ```
613
+
614
+ ```text
615
+ critical: save is down, the run is halted
616
+ critical step save failed for a: database unavailable
617
+ the run is halted and the resource behind save is still unhealthy: the health check returned False
618
+ recovered: save is healthy again, the run resumes
619
+ resumed 1 done, 1 steps reused
620
+ ```
621
+
622
+ ---
623
+
624
+ ## API Reference
625
+
626
+ ### Audit Events
627
+
628
+ | Area | Events |
629
+ |---|---|
630
+ | **Run** | `run_started` · `run_finished` · `run_halted` · `run_blocked` · `run_recovered` |
631
+ | **Item** | `item_started` · `item_done` · `item_skipped` · `item_failed` · `item_parked` · `item_retry` |
632
+ | **Step** | `step_started` · `step_done` · `step_skipped` · `step_failed` · `step_retry` · `step_degraded` · `step_waiting` |
633
+ | **Provider** | `provider_used` · `provider_failed` · `provider_registered` · `failover` · `breaker_opened` · `breaker_closed` · `breaker_probe` |
634
+ | **Other** | `heal_started` · `cooldown_wait` · `cooldown_over` · `critical_failure` · `alert_sent` · `alert_failed` |
635
+
636
+ ### Item Status After Recovery
637
+
638
+ | Status | `run_many` (same items) | `resume` | `heal` |
639
+ |---|---|---|---|
640
+ | `running` | continues, finished steps skipped | continues | included if degraded flag set |
641
+ | `parked` | tries again | tries again | included if degraded flag set |
642
+ | `failed` | tries again | left failed | included if degraded flag set |
643
+ | `done` | skipped | left done | redone if degraded flag set |
644
+
645
+ ---
646
+
647
+ ## Tests
648
+
649
+ ```bash
650
+ python -m unittest
651
+ ```
652
+
653
+ No dependencies needed — no virtual environment required.
654
+
655
+ To lint with ruff and run the tests automatically before every `git commit`:
656
+
657
+ ```bash
658
+ pre-commit install
659
+ ```
660
+
661
+ ---
662
+
663
+ ## License
664
+
665
+ MIT