|
1 | 1 | # Duron |
2 | 2 |
|
3 | | -[](https://github.com/brian14708/duron/actions/workflows/ci.yaml) |
4 | | -[](https://pypi.org/project/duron) |
5 | | -[](https://pypi.org/project/duron) |
6 | | -[](https://github.com/brian14708/duron/blob/main/LICENSE) |
7 | | - |
8 | | -**Durable workflows for modern Python.** Build resilient async applications with native support for streaming and interruption. |
9 | | - |
10 | | -- 💬 **Interactive workflows** — AI agents, chatbots, and human-in-the-loop automation with bidirectional streaming |
11 | | -- ⚡ **Crash recovery** — Deterministic replay from append-only logs means workflows survive restarts |
12 | | -- 🎯 **Graceful interruption** — Cancel or redirect operations mid-execution with signals |
13 | | -- 🔌 **Zero dependencies** — Pure Python built on asyncio, fully typed |
14 | | -- 🧩 **Pluggable storage** — Bring your own database or filesystem backend |
15 | | - |
16 | | -Duron replays completed operations from their persisted log position; it does not |
17 | | -provide content-based memoization. External effects have at-least-once semantics: |
18 | | -use idempotency keys or transactions for side effects that must not be duplicated. |
19 | | - |
20 | | -## Install |
21 | | - |
22 | | -Duron requires **Python 3.10+**. |
23 | | - |
24 | | -```bash |
25 | | -uv pip install duron |
26 | | -``` |
27 | | - |
28 | | -## Quickstart |
| 3 | +Duron is a typed durable-workflow runtime for Python 3.10+. |
29 | 4 |
|
30 | 5 | ```python |
31 | | -# /// script |
32 | | -# dependencies = ["duron"] |
33 | | -# /// |
34 | | - |
35 | | -import asyncio |
36 | | -from pathlib import Path |
37 | | -from typing import Optional, TypedDict |
38 | 6 | import duron |
39 | | -from duron.contrib.storage import FileLogStorage |
40 | | - |
41 | | - |
42 | | -class Event(TypedDict): |
43 | | - """ |
44 | | - Event type for communicating workflow progress and approvals. |
45 | | -
|
46 | | - Fields: |
47 | | - message: Log or status message for the user. |
48 | | - approval_id: Durable future ID to request approval (None for normal logs). |
49 | | - """ |
50 | | - |
51 | | - message: str |
52 | | - approval_id: Optional[str] |
53 | | - |
54 | | - |
55 | | -# ----------------------- |
56 | | -# Effect definitions |
57 | | -# ----------------------- |
58 | | - |
59 | | - |
60 | | -@duron.effect |
61 | | -async def check_fraud(amount: float, recipient: str) -> float: |
62 | | - """Simulate a risk engine returning a fraud probability.""" |
63 | | - print("Executing risk check...") |
64 | | - await asyncio.sleep(0.5) |
65 | | - return 0.85 |
| 7 | +from duron.storage import MemoryStorage |
66 | 8 |
|
| 9 | +events = duron.Output[str]("events") |
67 | 10 |
|
68 | 11 | @duron.effect |
69 | | -async def execute_transfer(amount: float, recipient: str) -> str: |
70 | | - """Simulate a real transfer execution.""" |
71 | | - print("Executing transfer...") |
72 | | - await asyncio.sleep(1) |
73 | | - return f"Transferred ${amount} to {recipient}" |
74 | | - |
75 | | - |
76 | | -# ----------------------- |
77 | | -# Durable workflow |
78 | | -# ----------------------- |
79 | | - |
80 | | - |
81 | | -@duron.durable |
82 | | -async def transfer_workflow( |
83 | | - ctx: duron.Context, |
84 | | - amount: float, |
85 | | - recipient: str, |
86 | | - events: duron.StreamWriter[Event] = duron.Provided, |
87 | | -) -> str: |
88 | | - """ |
89 | | - Durable workflow to execute a transfer with fraud detection |
90 | | - and optional manager approval. |
91 | | - """ |
92 | | - async with events: |
93 | | - # Log start of transfer |
94 | | - await events.send({ |
95 | | - "message": f"Checking transfer: ${amount} → {recipient}", |
96 | | - "approval_id": None, |
97 | | - }) |
98 | | - |
99 | | - # Step 1: Fraud check |
100 | | - risk = await ctx.run(check_fraud, amount, recipient) |
101 | | - |
102 | | - # Step 2: Approval required if high risk |
103 | | - if risk > 0.8: |
104 | | - approval_id, approval = await ctx.create_future(bool) |
105 | | - await events.send({ |
106 | | - "message": "⚠️ High risk - approval required", |
107 | | - "approval_id": approval_id, |
108 | | - }) |
109 | | - |
110 | | - if not await approval: |
111 | | - await events.send({ |
112 | | - "message": "❌ Transfer rejected by manager", |
113 | | - "approval_id": None, |
114 | | - }) |
115 | | - return "Transfer rejected" |
116 | | - |
117 | | - # Step 3: Execute transfer |
118 | | - result = await ctx.run(execute_transfer, amount, recipient) |
119 | | - await events.send({"message": f"✓ {result}", "approval_id": None}) |
120 | | - return result |
121 | | - |
122 | | - |
123 | | -# ----------------------- |
124 | | -# Host process |
125 | | -# ----------------------- |
126 | | - |
127 | | - |
128 | | -async def main(): |
129 | | - """ |
130 | | - Run the workflow locally with file-based state storage. |
131 | | - """ |
132 | | - async with duron.Session(FileLogStorage(Path("transfer.jsonl"))) as session: |
133 | | - task = await session.start(transfer_workflow, 10000.0, "suspicious-account") |
134 | | - stream = await task.open_stream("events", "r") |
135 | | - |
136 | | - async def handle_events(): |
137 | | - async for event in stream: |
138 | | - # Always print message |
139 | | - print(event["message"]) |
140 | | - |
141 | | - # If approval_id is present, prompt for manager decision |
142 | | - # If the future is not pending, it means it was already resolved (e.g., workflow resumed) |
143 | | - if event["approval_id"] and task.is_future_pending( |
144 | | - event["approval_id"] |
145 | | - ): |
146 | | - decision = await asyncio.to_thread(input, "Approve? (y/n): ") |
147 | | - await task.complete_future( |
148 | | - event["approval_id"], result=(decision.lower() == "y") |
149 | | - ) |
| 12 | +async def fetch_user(user_id: int) -> str: |
| 13 | + return f"user-{user_id}" |
| 14 | + |
| 15 | +@duron.workflow |
| 16 | +async def greet(ctx: duron.WorkflowContext, user_id: int) -> str: |
| 17 | + name = await ctx.call(fetch_user, user_id) |
| 18 | + await ctx.emit(events, name) |
| 19 | + return f"Hello, {name}" |
| 20 | + |
| 21 | +async with duron.Runtime(MemoryStorage()) as runtime: |
| 22 | + run = await runtime.start(greet, id="greeting-42", user_id=42) |
| 23 | + assert await run.result() == "Hello, user-42" |
| 24 | +``` |
150 | 25 |
|
151 | | - await asyncio.gather(task.result(), handle_events()) |
| 26 | +Workflows are deterministic orchestration functions. External work is declared |
| 27 | +with `@duron.effect`; durable host interactions use typed module-level |
| 28 | +`Input`, `Output`, `Signal`, and `Request` declarations. A `Runtime` manages |
| 29 | +many independently identified `Run` objects. |
152 | 30 |
|
| 31 | +## Install |
153 | 32 |
|
154 | | -if __name__ == "__main__": |
155 | | - asyncio.run(main()) |
| 33 | +```bash |
| 34 | +uv pip install duron |
156 | 35 | ``` |
157 | 36 |
|
158 | | -Duron also provides `MemoryLogStorage` for tests and `SQLiteLogManager` for |
159 | | -multiple named workflow logs in one SQLite database. Writable sessions hold an |
160 | | -opaque fencing lease; readonly sessions may verify completed histories but cannot |
161 | | -start or resume live work. |
162 | | - |
163 | | -## Next steps |
164 | | - |
165 | | -- Read the [getting started guide](https://brian14708.github.io/duron/getting-started/) |
166 | | -- Explore a more advanced example with streams and signals: [examples/agent.py](https://github.com/brian14708/duron/blob/main/examples/agent.py) |
| 37 | +See the [getting started guide](https://brian14708.github.io/duron/getting-started/) |
| 38 | +and the API reference for storage, codecs, tracing, and testing. |
0 commit comments