durably 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.
- durably-0.1.0/.gitignore +9 -0
- durably-0.1.0/CHANGELOG.md +60 -0
- durably-0.1.0/LICENSE +21 -0
- durably-0.1.0/PKG-INFO +343 -0
- durably-0.1.0/README.md +310 -0
- durably-0.1.0/examples/research_agent/README.md +46 -0
- durably-0.1.0/pyproject.toml +62 -0
- durably-0.1.0/src/durably/__init__.py +26 -0
- durably-0.1.0/src/durably/activity_context.py +55 -0
- durably-0.1.0/src/durably/cli.py +41 -0
- durably-0.1.0/src/durably/client.py +104 -0
- durably-0.1.0/src/durably/context.py +276 -0
- durably-0.1.0/src/durably/db.py +46 -0
- durably-0.1.0/src/durably/errors.py +30 -0
- durably-0.1.0/src/durably/failpoints.py +42 -0
- durably-0.1.0/src/durably/py.typed +0 -0
- durably-0.1.0/src/durably/registry.py +44 -0
- durably-0.1.0/src/durably/retry.py +24 -0
- durably-0.1.0/src/durably/schema.sql +74 -0
- durably-0.1.0/src/durably/store.py +347 -0
- durably-0.1.0/src/durably/testing.py +117 -0
- durably-0.1.0/src/durably/worker.py +247 -0
- durably-0.1.0/tests/conftest.py +168 -0
- durably-0.1.0/tests/crash_app.py +44 -0
- durably-0.1.0/tests/test_continue_as_new.py +79 -0
- durably-0.1.0/tests/test_crash.py +102 -0
- durably-0.1.0/tests/test_engine.py +142 -0
- durably-0.1.0/tests/test_failpoints.py +13 -0
- durably-0.1.0/tests/test_fencing.py +66 -0
- durably-0.1.0/tests/test_heartbeat.py +65 -0
- durably-0.1.0/tests/test_model.py +166 -0
- durably-0.1.0/tests/test_network.py +37 -0
- durably-0.1.0/tests/test_replay.py +194 -0
- durably-0.1.0/tests/test_schema.py +64 -0
- durably-0.1.0/tests/test_signals.py +78 -0
- durably-0.1.0/tests/test_store.py +116 -0
- durably-0.1.0/tests/test_timers.py +33 -0
durably-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to durably are listed here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
|
|
5
|
+
[Semantic Versioning](https://semver.org/). Until 1.0, minor versions may change the API
|
|
6
|
+
or the database schema; such changes and their migration steps are listed here.
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-11-07
|
|
11
|
+
|
|
12
|
+
First release.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- Workflow engine: workflows replay against an append-only event history in Postgres;
|
|
16
|
+
recorded activity results are returned instead of re-run.
|
|
17
|
+
- Task claiming with `FOR UPDATE SKIP LOCKED` and epoch fencing on every history write.
|
|
18
|
+
- Activity workers with retries (exponential backoff and jitter), timeouts and a stable
|
|
19
|
+
idempotency key (`workflow_id:activity_seq`).
|
|
20
|
+
- `Client.start` (idempotent by key), `Client.result`, `Client.describe`, `Client.history`.
|
|
21
|
+
- CLI: `durably init-db`, `durably worker --kind workflow|activity`.
|
|
22
|
+
- `durably.testing.FakeLLM` for tests.
|
|
23
|
+
- Failpoints (`FAILPOINT=<name>` / `<name>:pause`) for crash-testing workers.
|
|
24
|
+
- `durably.testing.SharedFakeLLM`: a fake provider whose counters live in Postgres, so
|
|
25
|
+
they are shared across worker processes and survive crashes.
|
|
26
|
+
- Crash-recovery test suite with real worker processes: resume after a crash, a
|
|
27
|
+
failpoint matrix, and a frozen-worker test with fencing on and off.
|
|
28
|
+
- Activity heartbeats: `await durably.heartbeat(details)` extends the lease;
|
|
29
|
+
`heartbeat_timeout=` on `ctx.activity` retries hung attempts; the next attempt reads the
|
|
30
|
+
last details from `durably.activity_info().heartbeat_details`.
|
|
31
|
+
- Durable timers: `await ctx.sleep(seconds)` survives crashes and deploys; each timer
|
|
32
|
+
fires exactly once. Workflow workers fire due timers.
|
|
33
|
+
- Signals: `client.signal(id, name, payload)` and `await ctx.wait_signal(name, timeout=)`.
|
|
34
|
+
Signals are received in send order, each exactly once; signalling a finished workflow
|
|
35
|
+
raises `WorkflowNotRunningError`.
|
|
36
|
+
- `ctx.now()`, `ctx.random()` and `ctx.uuid4()`: values are recorded as markers the first
|
|
37
|
+
time, so every replay sees the same value.
|
|
38
|
+
- Continue-As-New: `ctx.should_continue_as_new()` turns True once a run's history reaches
|
|
39
|
+
`--max-history` events (default 1000); `ctx.continue_as_new(input)` starts a fresh run,
|
|
40
|
+
carrying over untaken signals. `client.result` and `client.signal` follow the chain;
|
|
41
|
+
`client.runs(id)` lists every run.
|
|
42
|
+
- Workers back off exponentially (up to 2 s) while the database is unreachable.
|
|
43
|
+
- Database calls time out (60 s by default, `durably worker --db-timeout`) instead of
|
|
44
|
+
waiting forever on a connection that stopped answering.
|
|
45
|
+
- Tests for recovery from dropped database connections (Toxiproxy) and a Hypothesis
|
|
46
|
+
state-machine test against a reference model.
|
|
47
|
+
- `FAILPOINT=<name>@<n>` fires on the n-th hit.
|
|
48
|
+
- Benchmark scripts in `experiments/` (`python -m experiments.run_all`).
|
|
49
|
+
- Runnable research agent example in `examples/research_agent`.
|
|
50
|
+
- Packaging: PyPI metadata, `py.typed` for type checkers, mypy-clean source.
|
|
51
|
+
- CI on GitHub Actions (type check, tests, example, package build; install check on
|
|
52
|
+
Linux, macOS and Windows with Python 3.12 and 3.13) and a release workflow that
|
|
53
|
+
publishes to PyPI when a version tag is pushed.
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
- A deadlock between delivering an event (activity result, timer, signal) and claiming
|
|
57
|
+
the same workflow's task: all writes now take task locks before workflow locks.
|
|
58
|
+
|
|
59
|
+
[Unreleased]: https://github.com/tarang802/Durable-agent-runtime/compare/v0.1.0...HEAD
|
|
60
|
+
[0.1.0]: https://github.com/tarang802/Durable-agent-runtime/releases/tag/v0.1.0
|
durably-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tarang802
|
|
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.
|
durably-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: durably
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Durable execution for AI agents, with just Postgres.
|
|
5
|
+
Project-URL: Homepage, https://github.com/tarang802/Durable-agent-runtime
|
|
6
|
+
Project-URL: Repository, https://github.com/tarang802/Durable-agent-runtime
|
|
7
|
+
Project-URL: Issues, https://github.com/tarang802/Durable-agent-runtime/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/tarang802/Durable-agent-runtime/blob/main/CHANGELOG.md
|
|
9
|
+
Author: tarang802
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai agents,asyncio,durable execution,llm,postgres,workflow
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Database
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.12
|
|
24
|
+
Requires-Dist: asyncpg>=0.30
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
27
|
+
Requires-Dist: hypothesis>=6.112; extra == 'dev'
|
|
28
|
+
Requires-Dist: mypy>=1.13; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=8.3; extra == 'dev'
|
|
31
|
+
Requires-Dist: twine>=6.0; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# durably
|
|
35
|
+
|
|
36
|
+
[](https://github.com/tarang802/Durable-agent-runtime/actions/workflows/ci.yml)
|
|
37
|
+
|
|
38
|
+
**Durable execution for AI agents, with just Postgres.**
|
|
39
|
+
|
|
40
|
+
Your agent crashes after a paid LLM call. durably resumes it on another worker
|
|
41
|
+
without repeating the call — and without running a separate workflow server.
|
|
42
|
+
|
|
43
|
+
> **Early release (0.x):** the core guarantees are tested; the API may change
|
|
44
|
+
> before 1.0. See the [changelog](https://github.com/tarang802/Durable-agent-runtime/blob/main/CHANGELOG.md).
|
|
45
|
+
|
|
46
|
+
## What it is
|
|
47
|
+
|
|
48
|
+
`durably` is a small workflow engine for long-running agent loops. A workflow is an
|
|
49
|
+
ordinary async Python function that calls activities (an LLM call, a tool call). The
|
|
50
|
+
engine records every step in an append-only history in PostgreSQL. If a worker dies,
|
|
51
|
+
another worker reruns the function against that history: recorded steps return their
|
|
52
|
+
saved result instead of running again, and execution continues from the first
|
|
53
|
+
unrecorded step.
|
|
54
|
+
|
|
55
|
+
**Example.** A research agent plans, runs three searches, summarizes with a paid LLM call,
|
|
56
|
+
then sends an email. The worker is killed after the summary. A naive restart repeats the
|
|
57
|
+
paid call and may send the email twice. With `durably`, the restart skips everything
|
|
58
|
+
recorded and sends the email once.
|
|
59
|
+
|
|
60
|
+
## Install
|
|
61
|
+
|
|
62
|
+
Requires Python 3.12+ and PostgreSQL. CI tests against PostgreSQL 16.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install durably
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Then create durably's tables in your database (safe to run again):
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
durably init-db --database-url postgres://user:pass@host:5432/db
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Every command and `durably.Client.connect()` read `DATABASE_URL` when no URL is given.
|
|
75
|
+
|
|
76
|
+
To see it work first, run the [research agent example](https://github.com/tarang802/Durable-agent-runtime/tree/main/examples/research_agent),
|
|
77
|
+
which plans, searches, summarizes, waits for approval and sends an email:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
git clone https://github.com/tarang802/Durable-agent-runtime && cd Durable-agent-runtime
|
|
81
|
+
docker compose run --rm tests python -m examples.research_agent.run
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Usage
|
|
85
|
+
|
|
86
|
+
Activities do the real work. Each one receives a stable `idempotency_key`
|
|
87
|
+
(`workflow_id:activity_seq`) to pass to the provider:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
import durably
|
|
91
|
+
|
|
92
|
+
@durably.activity("llm")
|
|
93
|
+
async def llm(prompt, *, idempotency_key):
|
|
94
|
+
return await my_llm_client.complete(prompt, idempotency_key=idempotency_key)
|
|
95
|
+
|
|
96
|
+
@durably.activity("send_email")
|
|
97
|
+
async def send_email(to, body, *, idempotency_key):
|
|
98
|
+
await my_email_client.send(to, body, idempotency_key=idempotency_key)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
A workflow is plain async code that calls activities through `ctx`:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
@durably.workflow("research_agent")
|
|
105
|
+
async def research_agent(ctx, input):
|
|
106
|
+
plan = await ctx.activity("llm", [f"Plan research on {input['topic']}"])
|
|
107
|
+
summary = await ctx.activity("llm", [f"Summarize: {plan}"], retry={"max_attempts": 5}, timeout=60)
|
|
108
|
+
await ctx.activity("send_email", [input["email"], summary])
|
|
109
|
+
return {"summary": summary}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Workflows can sleep for minutes or days and wait for outside events. Both survive
|
|
113
|
+
crashes and deploys:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
@durably.workflow("publish_with_approval")
|
|
117
|
+
async def publish_with_approval(ctx, draft_id):
|
|
118
|
+
draft = await ctx.activity("llm", [f"Write post {draft_id}"])
|
|
119
|
+
decision = await ctx.wait_signal("review", timeout=3 * 24 * 3600) # None if nobody answers in 3 days
|
|
120
|
+
if decision is None or not decision["approved"]:
|
|
121
|
+
return "not published"
|
|
122
|
+
await ctx.sleep(3600) # publish an hour later
|
|
123
|
+
await ctx.activity("publish", [draft])
|
|
124
|
+
return "published"
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
await client.signal(wf_id, "review", {"approved": True})
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Long-running activities heartbeat so a hung attempt is detected and retried, resuming
|
|
132
|
+
from the last saved progress:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
@durably.activity("embed_documents")
|
|
136
|
+
async def embed_documents(doc_ids, *, idempotency_key):
|
|
137
|
+
start = durably.activity_info().heartbeat_details or 0 # progress from a previous attempt
|
|
138
|
+
for i in range(start, len(doc_ids)):
|
|
139
|
+
await embed(doc_ids[i])
|
|
140
|
+
await durably.heartbeat(i + 1)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
await ctx.activity("embed_documents", [doc_ids], heartbeat_timeout=30)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Use `ctx.now()`, `ctx.random()` and `ctx.uuid4()` instead of `datetime.now()`, `random`
|
|
148
|
+
and `uuid`: the first value is recorded, so replays see the same one.
|
|
149
|
+
|
|
150
|
+
Agents that run indefinitely (a chat assistant, a monitor) roll over to a fresh run before
|
|
151
|
+
their history grows large, so replay stays fast:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
@durably.workflow("assistant")
|
|
155
|
+
async def assistant(ctx, state):
|
|
156
|
+
while True:
|
|
157
|
+
message = await ctx.wait_signal("message")
|
|
158
|
+
reply = await ctx.activity("llm", [state["memory"], message])
|
|
159
|
+
state["memory"] = await ctx.activity("summarize", [state["memory"], message, reply])
|
|
160
|
+
if ctx.should_continue_as_new(): # history reached --max-history events
|
|
161
|
+
ctx.continue_as_new(state) # unread messages carry over to the new run
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Run workers, then start workflows from your app:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
durably init-db
|
|
168
|
+
durably worker --kind workflow --module my_app.workflows
|
|
169
|
+
durably worker --kind activity --module my_app.activities
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
client = await durably.Client.connect()
|
|
174
|
+
wf_id = await client.start(research_agent, {"topic": "durable execution", "email": "me@example.com"},
|
|
175
|
+
idempotency_key="research:42")
|
|
176
|
+
print(await client.result(wf_id, timeout=300))
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Guarantees
|
|
180
|
+
|
|
181
|
+
Each guarantee is checked by a test, most of them with real worker processes that are
|
|
182
|
+
killed or frozen mid-run.
|
|
183
|
+
|
|
184
|
+
| | Guarantee | Test |
|
|
185
|
+
|---|---|---|
|
|
186
|
+
| G1 | History is append-only and `(workflow_id, seq)` is unique | `test_events_are_append_only`, `test_event_seq_is_unique_per_workflow` |
|
|
187
|
+
| G2 | A write from a stale worker is rejected | `test_stale_worker_write_rejected` (paired with `test_without_fencing_stale_worker_corrupts_history`) |
|
|
188
|
+
| G3 | Replay never re-runs an activity recorded as completed | `test_resume_does_not_repeat_completed_activity` |
|
|
189
|
+
| G4 | At most one terminal event per workflow | `test_second_terminal_event_is_rejected` |
|
|
190
|
+
| G5 | A changed workflow body is detected as non-determinism | `test_changed_code_is_nondeterminism` |
|
|
191
|
+
| G6 | With an idempotent provider, every step is billed exactly once, whatever the crash point | `test_crash_matrix` |
|
|
192
|
+
| G7 | A durable timer fires exactly once, even if a worker dies while firing it | `test_timer_fires_once_after_crash_while_firing` |
|
|
193
|
+
| G8 | Each signal is received exactly once, in send order | `test_signals_are_taken_in_order_once_each`, `test_signals_sent_before_any_worker_runs_are_kept_in_order` |
|
|
194
|
+
|
|
195
|
+
### Crash matrix
|
|
196
|
+
|
|
197
|
+
A worker is killed at each failpoint, a replacement finishes the workflow, and the fake
|
|
198
|
+
provider counts calls:
|
|
199
|
+
|
|
200
|
+
| Worker | Killed at | Workflow finishes | Duplicate provider calls | Steps billed twice |
|
|
201
|
+
|---|---|---|---|---|
|
|
202
|
+
| activity | `before_claim` | ✅ | 0 | 0 |
|
|
203
|
+
| activity | `after_claim` | ✅ | 0 | 0 |
|
|
204
|
+
| activity | `before_activity` | ✅ | 0 | 0 |
|
|
205
|
+
| activity | `after_effect_before_record` | ✅ | 1 | 0 |
|
|
206
|
+
| activity | `after_record_before_ack` | ✅ | 0 | 0 |
|
|
207
|
+
| workflow | `before_claim` | ✅ | 0 | 0 |
|
|
208
|
+
| workflow | `after_claim` | ✅ | 0 | 0 |
|
|
209
|
+
| workflow | `before_append` | ✅ | 0 | 0 |
|
|
210
|
+
| workflow | `after_record_before_ack` | ✅ | 0 | 0 |
|
|
211
|
+
|
|
212
|
+
The one duplicate is unavoidable: the provider did the work but the worker died before
|
|
213
|
+
recording it, so the retry calls again. It carries the same idempotency key, so an
|
|
214
|
+
idempotent provider does not bill it twice.
|
|
215
|
+
|
|
216
|
+
Side effects run **at least once**, with a stable idempotency key
|
|
217
|
+
(`workflow_id:activity_seq`). Exactly-once is impossible in general, so activities should
|
|
218
|
+
be idempotent.
|
|
219
|
+
|
|
220
|
+
## Benchmarks
|
|
221
|
+
|
|
222
|
+
Generated by the scripts in [`experiments/`](https://github.com/tarang802/Durable-agent-runtime/tree/main/experiments) on a laptop (Docker Desktop on
|
|
223
|
+
Windows, 16 CPUs, Python 3.12, PostgreSQL 16). Full output: [RESULTS.md](https://github.com/tarang802/Durable-agent-runtime/blob/main/experiments/RESULTS.md).
|
|
224
|
+
Regenerate with:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
docker compose run --rm tests python -m experiments.run_all
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
**Paid LLM calls when a 5-step agent's worker is killed after step k.** A naive restart
|
|
231
|
+
pays again for every completed step; durably pays for each step once.
|
|
232
|
+
|
|
233
|
+
| Killed after step | Naive restart: paid calls | durably: paid calls |
|
|
234
|
+
|---|---|---|
|
|
235
|
+
| 1 | 6 | 5 |
|
|
236
|
+
| 2 | 7 | 5 |
|
|
237
|
+
| 3 | 8 | 5 |
|
|
238
|
+
| 4 | 9 | 5 |
|
|
239
|
+
|
|
240
|
+
**Recovery time after a workflow worker dies**, from its claim to the workflow finishing on
|
|
241
|
+
a healthy worker. It tracks the lease length, so the lease is the knob.
|
|
242
|
+
|
|
243
|
+
| Lease (s) | Recovery (s) |
|
|
244
|
+
|---|---|
|
|
245
|
+
| 0.5 | 0.57 |
|
|
246
|
+
| 1 | 1.08 |
|
|
247
|
+
| 2 | 2.06 |
|
|
248
|
+
| 5 | 5.07 |
|
|
249
|
+
|
|
250
|
+
**Replay time vs history length** (load from Postgres plus replay). Continue-As-New keeps a
|
|
251
|
+
run at or below its limit, 1,000 events by default.
|
|
252
|
+
|
|
253
|
+
| Events in history | Load (ms) | Replay (ms) | Total (ms) |
|
|
254
|
+
|---|---|---|---|
|
|
255
|
+
| 99 | 0.7 | 0.3 | 1.0 |
|
|
256
|
+
| 999 | 3.3 | 2.0 | 5.4 |
|
|
257
|
+
| 4,999 | 17.6 | 10.2 | 27.8 |
|
|
258
|
+
| 9,999 | 32.2 | 21.5 | 53.7 |
|
|
259
|
+
|
|
260
|
+
**Activity throughput vs worker processes** (2,000 no-op activities, each a claim, run and
|
|
261
|
+
record). In this run two processes measured below one; throughput then grows with more
|
|
262
|
+
processes.
|
|
263
|
+
|
|
264
|
+
| Worker processes | Tasks per second |
|
|
265
|
+
|---|---|
|
|
266
|
+
| 1 | 177 |
|
|
267
|
+
| 2 | 151 |
|
|
268
|
+
| 4 | 296 |
|
|
269
|
+
| 8 | 530 |
|
|
270
|
+
|
|
271
|
+
## When to use durably vs. Temporal
|
|
272
|
+
|
|
273
|
+
| | durably | Temporal |
|
|
274
|
+
|---|---|---|
|
|
275
|
+
| Infrastructure | The Postgres you already run | A Temporal server cluster (or Temporal Cloud) |
|
|
276
|
+
| Languages | Python | Go, Java, TypeScript, Python, .NET, PHP |
|
|
277
|
+
| Scale | One Postgres; thousands of workflows | Very large fleets, sharded history |
|
|
278
|
+
| Workflow versioning | Detects changed code as non-determinism | Mature versioning and worker build IDs |
|
|
279
|
+
| Setup | `pip install` + `durably init-db` | Deploy and operate a server |
|
|
280
|
+
|
|
281
|
+
Choose durably when you want crash-safe agents inside an existing Python + Postgres
|
|
282
|
+
backend without another service to run. Choose Temporal when you need multi-language
|
|
283
|
+
workflows or scale beyond a single database.
|
|
284
|
+
|
|
285
|
+
## Known limitations
|
|
286
|
+
|
|
287
|
+
- Activities in one workflow run one at a time; `asyncio.gather` over activities is not
|
|
288
|
+
supported.
|
|
289
|
+
- An activity that runs longer than the worker lease (30 s by default) without a
|
|
290
|
+
`heartbeat_timeout` is treated as crashed and run again. Heartbeat long activities.
|
|
291
|
+
- Workflow code must make the same calls on every replay: use `ctx.now()`, `ctx.random()`
|
|
292
|
+
and `ctx.sleep()` rather than the clock, `random` or `asyncio.sleep`, and do network I/O
|
|
293
|
+
in activities.
|
|
294
|
+
- Replay reads a run's whole history, so workflows that loop indefinitely should use
|
|
295
|
+
Continue-As-New.
|
|
296
|
+
- No schema migrations: upgrading between 0.x releases may require re-creating tables.
|
|
297
|
+
- Side effects are at-least-once; make activities idempotent with the key provided.
|
|
298
|
+
|
|
299
|
+
## Stack
|
|
300
|
+
|
|
301
|
+
Python 3.12+, asyncio and asyncpg (the only runtime dependency), PostgreSQL 16. Tests use
|
|
302
|
+
pytest, Hypothesis, Toxiproxy and Docker Compose.
|
|
303
|
+
|
|
304
|
+
## Development
|
|
305
|
+
|
|
306
|
+
Tests run in a Linux container next to Postgres 16 and Toxiproxy, so they behave the same
|
|
307
|
+
on every machine (crash tests rely on POSIX signals). You only need Docker.
|
|
308
|
+
|
|
309
|
+
```bash
|
|
310
|
+
docker compose run --rm --build tests
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Crash tests use failpoints: `FAILPOINT=<name>` makes a worker exit abruptly at that point,
|
|
314
|
+
and `FAILPOINT=<name>:pause` freezes it with SIGSTOP. You can use them to crash-test your
|
|
315
|
+
own workflows the same way.
|
|
316
|
+
|
|
317
|
+
What the suite covers:
|
|
318
|
+
|
|
319
|
+
- **Crash recovery** with real worker processes killed or frozen at each failpoint.
|
|
320
|
+
- **Dropped connections:** Toxiproxy cuts the workers' Postgres connections mid-activity;
|
|
321
|
+
they back off, reconnect and finish without billing a step twice.
|
|
322
|
+
- **Model-based testing:** a Hypothesis state machine runs random interleavings of
|
|
323
|
+
starts, signals, worker steps and crashes, and checks history against a reference model
|
|
324
|
+
after every step.
|
|
325
|
+
|
|
326
|
+
When the model test fails it prints the minimal failing steps and a
|
|
327
|
+
`@reproduce_failure(...)` line to paste above the test. To rerun a whole session
|
|
328
|
+
deterministically, pass a seed (more examples with `DURABLY_MODEL_EXAMPLES`):
|
|
329
|
+
|
|
330
|
+
```bash
|
|
331
|
+
docker compose run --rm tests pytest tests/test_model.py --hypothesis-seed=12345
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
To create the schema in your own Postgres:
|
|
335
|
+
|
|
336
|
+
```bash
|
|
337
|
+
pip install -e .
|
|
338
|
+
durably init-db --database-url postgres://user:pass@host:5432/db
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
## License
|
|
342
|
+
|
|
343
|
+
MIT
|