senpuki 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.
- senpuki-0.1.0/PKG-INFO +258 -0
- senpuki-0.1.0/README.md +247 -0
- senpuki-0.1.0/examples/batch_processing.py +114 -0
- senpuki-0.1.0/examples/complex_workflow.py +151 -0
- senpuki-0.1.0/examples/failing_flow.py +49 -0
- senpuki-0.1.0/examples/media_pipeline.py +312 -0
- senpuki-0.1.0/examples/parallel_scraper.py +141 -0
- senpuki-0.1.0/examples/saga_trip_booking.py +131 -0
- senpuki-0.1.0/examples/simple_flow.py +65 -0
- senpuki-0.1.0/pyproject.toml +21 -0
- senpuki-0.1.0/senpuki/__init__.py +5 -0
- senpuki-0.1.0/senpuki/backend/__init__.py +0 -0
- senpuki-0.1.0/senpuki/backend/base.py +55 -0
- senpuki-0.1.0/senpuki/backend/postgres.py +476 -0
- senpuki-0.1.0/senpuki/backend/sqlite.py +511 -0
- senpuki-0.1.0/senpuki/core.py +135 -0
- senpuki-0.1.0/senpuki/executor.py +1047 -0
- senpuki-0.1.0/senpuki/notifications/__init__.py +0 -0
- senpuki-0.1.0/senpuki/notifications/base.py +19 -0
- senpuki-0.1.0/senpuki/notifications/redis.py +93 -0
- senpuki-0.1.0/senpuki/registry.py +33 -0
- senpuki-0.1.0/senpuki/utils/__init__.py +0 -0
- senpuki-0.1.0/senpuki/utils/async_sqlite.py +91 -0
- senpuki-0.1.0/senpuki/utils/idempotency.py +35 -0
- senpuki-0.1.0/senpuki/utils/serialization.py +75 -0
- senpuki-0.1.0/senpuki/utils/time.py +38 -0
- senpuki-0.1.0/senpuki.egg-info/PKG-INFO +258 -0
- senpuki-0.1.0/senpuki.egg-info/SOURCES.txt +43 -0
- senpuki-0.1.0/senpuki.egg-info/dependency_links.txt +1 -0
- senpuki-0.1.0/senpuki.egg-info/requires.txt +4 -0
- senpuki-0.1.0/senpuki.egg-info/top_level.txt +4 -0
- senpuki-0.1.0/setup.cfg +4 -0
- senpuki-0.1.0/tests/__init__.py +0 -0
- senpuki-0.1.0/tests/test_core.py +53 -0
- senpuki-0.1.0/tests/test_deadlock.py +49 -0
- senpuki-0.1.0/tests/test_execution.py +266 -0
- senpuki-0.1.0/tests/test_helpers.py +68 -0
- senpuki-0.1.0/tests/test_map.py +80 -0
- senpuki-0.1.0/tests/test_max_duration.py +56 -0
- senpuki-0.1.0/tests/test_parallel.py +79 -0
- senpuki-0.1.0/tests/test_rate_limit.py +110 -0
- senpuki-0.1.0/tests/test_scenarios.py +110 -0
- senpuki-0.1.0/tests/test_scheduling.py +98 -0
- senpuki-0.1.0/tests/test_wait_for.py +72 -0
- senpuki-0.1.0/tests/utils.py +30 -0
senpuki-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: senpuki
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Distributed Durable Functions in Python
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: aiosqlite>=0.21.0
|
|
8
|
+
Requires-Dist: asyncpg>=0.31.0
|
|
9
|
+
Requires-Dist: pyrefly>=0.43.1
|
|
10
|
+
Requires-Dist: redis>=7.1.0
|
|
11
|
+
|
|
12
|
+
# Senpuki: Distributed Durable Functions for Python
|
|
13
|
+
|
|
14
|
+
Senpuki is a lightweight, asynchronous, distributed task orchestration library for Python. It allows you to write stateful, reliable workflows ("durable functions") using standard Python async/await syntax. Senpuki handles the complexity of persisting state, retrying failures, and distributing work across a pool of workers.
|
|
15
|
+
|
|
16
|
+
## Table of Contents
|
|
17
|
+
|
|
18
|
+
- [Core Concepts](#core-concepts)
|
|
19
|
+
- [Installation](#installation)
|
|
20
|
+
- [Quick Start](#quick-start)
|
|
21
|
+
- [Features Guide](#features-guide)
|
|
22
|
+
- [Defining Durable Functions](#defining-durable-functions)
|
|
23
|
+
- [Orchestration & Activities](#orchestration--activities)
|
|
24
|
+
- [Retries & Error Handling](#retries--error-handling)
|
|
25
|
+
- [Idempotency & Caching](#idempotency--caching)
|
|
26
|
+
- [Parallel Execution (Fan-out/Fan-in)](#parallel-execution-fan-outfan-in)
|
|
27
|
+
- [Timeouts & Expirys](#timeouts--expirys)
|
|
28
|
+
- [Architecture & Backends](#architecture--backends)
|
|
29
|
+
- [Running Workers](#running-workers)
|
|
30
|
+
- [Examples](#examples)
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Core Concepts
|
|
35
|
+
|
|
36
|
+
* **Durable Functions**: Python async functions decorated with `@Senpuki.durable()`. They can be orchestrators (calling other functions) or activities (doing work).
|
|
37
|
+
* **Orchestrator**: A durable function that schedules other durable functions. It sleeps while waiting for sub-tasks to complete, freeing up worker resources.
|
|
38
|
+
* **Activity**: A leaf-node durable function that performs a specific action (e.g., API call, DB operation).
|
|
39
|
+
* **Execution**: A single run of a workflow. It has a unique ID and persistent state.
|
|
40
|
+
* **Worker**: A process that polls the backend storage for pending tasks and executes them.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install senpuki
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**Requirements:**
|
|
51
|
+
* Python 3.12+
|
|
52
|
+
* `aiosqlite` (optional, for SQLite backend async support)
|
|
53
|
+
* `asyncpg` (optional, for PostgreSQL backend async support)
|
|
54
|
+
* `redis` (optional, for Redis notification support)
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Quick Start
|
|
59
|
+
|
|
60
|
+
1. **Define your workflow**:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import asyncio
|
|
64
|
+
from senpuki import Senpuki, Result
|
|
65
|
+
|
|
66
|
+
# 1. Define an activity
|
|
67
|
+
@Senpuki.durable()
|
|
68
|
+
async def greet(name: str) -> str:
|
|
69
|
+
await asyncio.sleep(0.1) # Simulate work
|
|
70
|
+
return f"Hello, {name}!"
|
|
71
|
+
|
|
72
|
+
# 2. Define an orchestrator
|
|
73
|
+
@Senpuki.durable()
|
|
74
|
+
async def workflow(names: list[str]) -> Result[list[str], Exception]:
|
|
75
|
+
results = []
|
|
76
|
+
for name in names:
|
|
77
|
+
# Call activity (awaiting it schedules it and waits for result)
|
|
78
|
+
res = await greet(name)
|
|
79
|
+
results.append(res)
|
|
80
|
+
return Result.Ok(results)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
2. **Run the system**:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
async def main():
|
|
87
|
+
# Setup Backend
|
|
88
|
+
backend = Senpuki.backends.SQLiteBackend("senpuki.sqlite")
|
|
89
|
+
await backend.init_db()
|
|
90
|
+
executor = Senpuki(backend=backend)
|
|
91
|
+
|
|
92
|
+
# Start a Worker (in background)
|
|
93
|
+
worker = asyncio.create_task(executor.serve())
|
|
94
|
+
|
|
95
|
+
# Dispatch Workflow
|
|
96
|
+
exec_id = await executor.dispatch(workflow, ["Alice", "Bob"])
|
|
97
|
+
print(f"Started execution: {exec_id}")
|
|
98
|
+
|
|
99
|
+
# Wait for Result
|
|
100
|
+
while True:
|
|
101
|
+
state = await executor.state_of(exec_id)
|
|
102
|
+
if state.state in ("completed", "failed"):
|
|
103
|
+
break
|
|
104
|
+
await asyncio.sleep(0.5)
|
|
105
|
+
|
|
106
|
+
result = await executor.result_of(exec_id)
|
|
107
|
+
print(result.value) # ['Hello, Alice!', 'Hello, Bob!']
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
asyncio.run(main())
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Features Guide
|
|
116
|
+
|
|
117
|
+
### Defining Durable Functions
|
|
118
|
+
|
|
119
|
+
Use the `@Senpuki.durable` decorator. You can configure retry policies, caching, and queues here.
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from senpuki import Senpuki, RetryPolicy
|
|
123
|
+
|
|
124
|
+
@Senpuki.durable(
|
|
125
|
+
retry_policy=RetryPolicy(max_attempts=3, initial_delay=1.0),
|
|
126
|
+
queue="high_priority",
|
|
127
|
+
tags=["billing"]
|
|
128
|
+
)
|
|
129
|
+
async def charge_card(amount: int):
|
|
130
|
+
...
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Orchestration & Activities
|
|
134
|
+
|
|
135
|
+
When a durable function calls another durable function (e.g., `await other_func()`), Senpuki intercepts this call.
|
|
136
|
+
* It persists a **Task** record for the child function.
|
|
137
|
+
* The parent function "sleeps" (suspends) until the child task is completed by a worker.
|
|
138
|
+
* This allows workflows to run over days or weeks without consuming memory while waiting.
|
|
139
|
+
|
|
140
|
+
### Retries & Error Handling
|
|
141
|
+
|
|
142
|
+
Failures happen. Senpuki allows declarative retry policies.
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
policy = RetryPolicy(
|
|
146
|
+
max_attempts=5,
|
|
147
|
+
backoff_factor=2.0, # Exponential backoff
|
|
148
|
+
jitter=0.1, # Add randomness to prevent thundering herd
|
|
149
|
+
retry_for=(ConnectionError, ExpiryError) # Only retry these exceptions
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
@Senpuki.durable(retry_policy=policy)
|
|
153
|
+
async def unstable_api_call():
|
|
154
|
+
...
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
If the function fails after all retries, the Execution is marked as `failed`, and the error is propagated to the parent orchestrator (if any), which can catch it using standard `try/except`.
|
|
158
|
+
|
|
159
|
+
### Idempotency & Caching
|
|
160
|
+
|
|
161
|
+
To prevent duplicate side-effects (like charging a card twice) or re-doing expensive work:
|
|
162
|
+
|
|
163
|
+
1. **Idempotency**: Results are stored permanently. If a task is scheduled again with the same arguments (and version), the stored result is returned immediately without running the function.
|
|
164
|
+
2. **Caching**: Similar to idempotency but implies the result can be reused across different executions if the key matches.
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
@Senpuki.durable(idempotent=True)
|
|
168
|
+
async def send_email(user_id: str, subject: str):
|
|
169
|
+
# Safe to call multiple times; will only execute once per unique arguments
|
|
170
|
+
...
|
|
171
|
+
|
|
172
|
+
@Senpuki.durable(cached=True, version="v1")
|
|
173
|
+
async def heavy_compute(data_hash: str):
|
|
174
|
+
# Result stored in cache table; subsequent calls return immediately
|
|
175
|
+
...
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Parallel Execution (Fan-out/Fan-in)
|
|
179
|
+
|
|
180
|
+
Use standard `asyncio.gather` to run tasks in parallel. Senpuki schedules them all, and the worker pool executes them concurrently.
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
@Senpuki.durable()
|
|
184
|
+
async def batch_processor(items: list[int]):
|
|
185
|
+
tasks = []
|
|
186
|
+
for item in items:
|
|
187
|
+
# Schedule all tasks
|
|
188
|
+
tasks.append(process_item(item))
|
|
189
|
+
|
|
190
|
+
# Wait for all to complete
|
|
191
|
+
results = await asyncio.gather(*tasks)
|
|
192
|
+
return sum(results)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Timeouts & Expirys
|
|
196
|
+
|
|
197
|
+
You can set a expiry for the entire execution. If it exceeds this duration, it is cancelled.
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
exec_id = await executor.dispatch(long_workflow, expiry="1h 30m")
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## Architecture & Backends
|
|
206
|
+
|
|
207
|
+
Senpuki is backend-agnostic.
|
|
208
|
+
|
|
209
|
+
### SQLite Backend
|
|
210
|
+
Included by default. Stores state in a local SQLite file.
|
|
211
|
+
* **Best for**: Development, testing, single-node deployments, embedded workflows.
|
|
212
|
+
* **Features**: Full persistence, async support.
|
|
213
|
+
|
|
214
|
+
### Postgres Backend
|
|
215
|
+
* **Best for**: Production environments, concurrent access, high reliability.
|
|
216
|
+
* **Features**: Uses `asyncpg` for high performance.
|
|
217
|
+
|
|
218
|
+
### Mongo Backend (Planned)
|
|
219
|
+
* **Best for**: Distributed production clusters, high availability.
|
|
220
|
+
|
|
221
|
+
### Redis (Notifications)
|
|
222
|
+
Optional. Uses Redis Pub/Sub to notify orchestrators immediately when a task finishes, reducing polling latency.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## Running Workers
|
|
227
|
+
|
|
228
|
+
The `executor.serve()` method runs the worker loop. In production, you typically run this in a separate process or container.
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
# worker.py
|
|
232
|
+
async def run_worker():
|
|
233
|
+
backend = Senpuki.backends.SQLiteBackend("prod.db")
|
|
234
|
+
executor = Senpuki(backend=backend)
|
|
235
|
+
|
|
236
|
+
# Consume only specific queues
|
|
237
|
+
await executor.serve(
|
|
238
|
+
queues=["default", "high_priority"],
|
|
239
|
+
max_concurrency=50
|
|
240
|
+
)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
You can scale horizontally by running multiple worker instances pointing to the same database.
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## Examples
|
|
248
|
+
|
|
249
|
+
See the `examples/` folder for complete code:
|
|
250
|
+
|
|
251
|
+
1. **`simple_flow.py`**: Basic parent-child function calls.
|
|
252
|
+
2. **`failing_flow.py`**: Demonstrates automatic retries and Dead Letter Queue (DLQ) behavior.
|
|
253
|
+
3. **`complex_workflow.py`**: A data pipeline showcasing caching, retries, and expirys.
|
|
254
|
+
* `batch_processing.py`: Fan-out/fan-in pattern (processing multiple items in parallel).
|
|
255
|
+
* `saga_trip_booking.py`: Saga pattern with compensation (rollback) logic.
|
|
256
|
+
* `media_pipeline.py`: A complex 5-minute simulation of a media processing pipeline (Validation -> Safety -> Transcode/AI -> Package) with a live progress dashboard.
|
|
257
|
+
|
|
258
|
+
## Requirements
|
senpuki-0.1.0/README.md
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# Senpuki: Distributed Durable Functions for Python
|
|
2
|
+
|
|
3
|
+
Senpuki is a lightweight, asynchronous, distributed task orchestration library for Python. It allows you to write stateful, reliable workflows ("durable functions") using standard Python async/await syntax. Senpuki handles the complexity of persisting state, retrying failures, and distributing work across a pool of workers.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [Core Concepts](#core-concepts)
|
|
8
|
+
- [Installation](#installation)
|
|
9
|
+
- [Quick Start](#quick-start)
|
|
10
|
+
- [Features Guide](#features-guide)
|
|
11
|
+
- [Defining Durable Functions](#defining-durable-functions)
|
|
12
|
+
- [Orchestration & Activities](#orchestration--activities)
|
|
13
|
+
- [Retries & Error Handling](#retries--error-handling)
|
|
14
|
+
- [Idempotency & Caching](#idempotency--caching)
|
|
15
|
+
- [Parallel Execution (Fan-out/Fan-in)](#parallel-execution-fan-outfan-in)
|
|
16
|
+
- [Timeouts & Expirys](#timeouts--expirys)
|
|
17
|
+
- [Architecture & Backends](#architecture--backends)
|
|
18
|
+
- [Running Workers](#running-workers)
|
|
19
|
+
- [Examples](#examples)
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Core Concepts
|
|
24
|
+
|
|
25
|
+
* **Durable Functions**: Python async functions decorated with `@Senpuki.durable()`. They can be orchestrators (calling other functions) or activities (doing work).
|
|
26
|
+
* **Orchestrator**: A durable function that schedules other durable functions. It sleeps while waiting for sub-tasks to complete, freeing up worker resources.
|
|
27
|
+
* **Activity**: A leaf-node durable function that performs a specific action (e.g., API call, DB operation).
|
|
28
|
+
* **Execution**: A single run of a workflow. It has a unique ID and persistent state.
|
|
29
|
+
* **Worker**: A process that polls the backend storage for pending tasks and executes them.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install senpuki
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Requirements:**
|
|
40
|
+
* Python 3.12+
|
|
41
|
+
* `aiosqlite` (optional, for SQLite backend async support)
|
|
42
|
+
* `asyncpg` (optional, for PostgreSQL backend async support)
|
|
43
|
+
* `redis` (optional, for Redis notification support)
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Quick Start
|
|
48
|
+
|
|
49
|
+
1. **Define your workflow**:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import asyncio
|
|
53
|
+
from senpuki import Senpuki, Result
|
|
54
|
+
|
|
55
|
+
# 1. Define an activity
|
|
56
|
+
@Senpuki.durable()
|
|
57
|
+
async def greet(name: str) -> str:
|
|
58
|
+
await asyncio.sleep(0.1) # Simulate work
|
|
59
|
+
return f"Hello, {name}!"
|
|
60
|
+
|
|
61
|
+
# 2. Define an orchestrator
|
|
62
|
+
@Senpuki.durable()
|
|
63
|
+
async def workflow(names: list[str]) -> Result[list[str], Exception]:
|
|
64
|
+
results = []
|
|
65
|
+
for name in names:
|
|
66
|
+
# Call activity (awaiting it schedules it and waits for result)
|
|
67
|
+
res = await greet(name)
|
|
68
|
+
results.append(res)
|
|
69
|
+
return Result.Ok(results)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
2. **Run the system**:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
async def main():
|
|
76
|
+
# Setup Backend
|
|
77
|
+
backend = Senpuki.backends.SQLiteBackend("senpuki.sqlite")
|
|
78
|
+
await backend.init_db()
|
|
79
|
+
executor = Senpuki(backend=backend)
|
|
80
|
+
|
|
81
|
+
# Start a Worker (in background)
|
|
82
|
+
worker = asyncio.create_task(executor.serve())
|
|
83
|
+
|
|
84
|
+
# Dispatch Workflow
|
|
85
|
+
exec_id = await executor.dispatch(workflow, ["Alice", "Bob"])
|
|
86
|
+
print(f"Started execution: {exec_id}")
|
|
87
|
+
|
|
88
|
+
# Wait for Result
|
|
89
|
+
while True:
|
|
90
|
+
state = await executor.state_of(exec_id)
|
|
91
|
+
if state.state in ("completed", "failed"):
|
|
92
|
+
break
|
|
93
|
+
await asyncio.sleep(0.5)
|
|
94
|
+
|
|
95
|
+
result = await executor.result_of(exec_id)
|
|
96
|
+
print(result.value) # ['Hello, Alice!', 'Hello, Bob!']
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
asyncio.run(main())
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Features Guide
|
|
105
|
+
|
|
106
|
+
### Defining Durable Functions
|
|
107
|
+
|
|
108
|
+
Use the `@Senpuki.durable` decorator. You can configure retry policies, caching, and queues here.
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from senpuki import Senpuki, RetryPolicy
|
|
112
|
+
|
|
113
|
+
@Senpuki.durable(
|
|
114
|
+
retry_policy=RetryPolicy(max_attempts=3, initial_delay=1.0),
|
|
115
|
+
queue="high_priority",
|
|
116
|
+
tags=["billing"]
|
|
117
|
+
)
|
|
118
|
+
async def charge_card(amount: int):
|
|
119
|
+
...
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Orchestration & Activities
|
|
123
|
+
|
|
124
|
+
When a durable function calls another durable function (e.g., `await other_func()`), Senpuki intercepts this call.
|
|
125
|
+
* It persists a **Task** record for the child function.
|
|
126
|
+
* The parent function "sleeps" (suspends) until the child task is completed by a worker.
|
|
127
|
+
* This allows workflows to run over days or weeks without consuming memory while waiting.
|
|
128
|
+
|
|
129
|
+
### Retries & Error Handling
|
|
130
|
+
|
|
131
|
+
Failures happen. Senpuki allows declarative retry policies.
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
policy = RetryPolicy(
|
|
135
|
+
max_attempts=5,
|
|
136
|
+
backoff_factor=2.0, # Exponential backoff
|
|
137
|
+
jitter=0.1, # Add randomness to prevent thundering herd
|
|
138
|
+
retry_for=(ConnectionError, ExpiryError) # Only retry these exceptions
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
@Senpuki.durable(retry_policy=policy)
|
|
142
|
+
async def unstable_api_call():
|
|
143
|
+
...
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
If the function fails after all retries, the Execution is marked as `failed`, and the error is propagated to the parent orchestrator (if any), which can catch it using standard `try/except`.
|
|
147
|
+
|
|
148
|
+
### Idempotency & Caching
|
|
149
|
+
|
|
150
|
+
To prevent duplicate side-effects (like charging a card twice) or re-doing expensive work:
|
|
151
|
+
|
|
152
|
+
1. **Idempotency**: Results are stored permanently. If a task is scheduled again with the same arguments (and version), the stored result is returned immediately without running the function.
|
|
153
|
+
2. **Caching**: Similar to idempotency but implies the result can be reused across different executions if the key matches.
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
@Senpuki.durable(idempotent=True)
|
|
157
|
+
async def send_email(user_id: str, subject: str):
|
|
158
|
+
# Safe to call multiple times; will only execute once per unique arguments
|
|
159
|
+
...
|
|
160
|
+
|
|
161
|
+
@Senpuki.durable(cached=True, version="v1")
|
|
162
|
+
async def heavy_compute(data_hash: str):
|
|
163
|
+
# Result stored in cache table; subsequent calls return immediately
|
|
164
|
+
...
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Parallel Execution (Fan-out/Fan-in)
|
|
168
|
+
|
|
169
|
+
Use standard `asyncio.gather` to run tasks in parallel. Senpuki schedules them all, and the worker pool executes them concurrently.
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
@Senpuki.durable()
|
|
173
|
+
async def batch_processor(items: list[int]):
|
|
174
|
+
tasks = []
|
|
175
|
+
for item in items:
|
|
176
|
+
# Schedule all tasks
|
|
177
|
+
tasks.append(process_item(item))
|
|
178
|
+
|
|
179
|
+
# Wait for all to complete
|
|
180
|
+
results = await asyncio.gather(*tasks)
|
|
181
|
+
return sum(results)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Timeouts & Expirys
|
|
185
|
+
|
|
186
|
+
You can set a expiry for the entire execution. If it exceeds this duration, it is cancelled.
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
exec_id = await executor.dispatch(long_workflow, expiry="1h 30m")
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Architecture & Backends
|
|
195
|
+
|
|
196
|
+
Senpuki is backend-agnostic.
|
|
197
|
+
|
|
198
|
+
### SQLite Backend
|
|
199
|
+
Included by default. Stores state in a local SQLite file.
|
|
200
|
+
* **Best for**: Development, testing, single-node deployments, embedded workflows.
|
|
201
|
+
* **Features**: Full persistence, async support.
|
|
202
|
+
|
|
203
|
+
### Postgres Backend
|
|
204
|
+
* **Best for**: Production environments, concurrent access, high reliability.
|
|
205
|
+
* **Features**: Uses `asyncpg` for high performance.
|
|
206
|
+
|
|
207
|
+
### Mongo Backend (Planned)
|
|
208
|
+
* **Best for**: Distributed production clusters, high availability.
|
|
209
|
+
|
|
210
|
+
### Redis (Notifications)
|
|
211
|
+
Optional. Uses Redis Pub/Sub to notify orchestrators immediately when a task finishes, reducing polling latency.
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## Running Workers
|
|
216
|
+
|
|
217
|
+
The `executor.serve()` method runs the worker loop. In production, you typically run this in a separate process or container.
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
# worker.py
|
|
221
|
+
async def run_worker():
|
|
222
|
+
backend = Senpuki.backends.SQLiteBackend("prod.db")
|
|
223
|
+
executor = Senpuki(backend=backend)
|
|
224
|
+
|
|
225
|
+
# Consume only specific queues
|
|
226
|
+
await executor.serve(
|
|
227
|
+
queues=["default", "high_priority"],
|
|
228
|
+
max_concurrency=50
|
|
229
|
+
)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
You can scale horizontally by running multiple worker instances pointing to the same database.
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## Examples
|
|
237
|
+
|
|
238
|
+
See the `examples/` folder for complete code:
|
|
239
|
+
|
|
240
|
+
1. **`simple_flow.py`**: Basic parent-child function calls.
|
|
241
|
+
2. **`failing_flow.py`**: Demonstrates automatic retries and Dead Letter Queue (DLQ) behavior.
|
|
242
|
+
3. **`complex_workflow.py`**: A data pipeline showcasing caching, retries, and expirys.
|
|
243
|
+
* `batch_processing.py`: Fan-out/fan-in pattern (processing multiple items in parallel).
|
|
244
|
+
* `saga_trip_booking.py`: Saga pattern with compensation (rollback) logic.
|
|
245
|
+
* `media_pipeline.py`: A complex 5-minute simulation of a media processing pipeline (Validation -> Safety -> Transcode/AI -> Package) with a live progress dashboard.
|
|
246
|
+
|
|
247
|
+
## Requirements
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import logging
|
|
4
|
+
import random
|
|
5
|
+
from senpuki import Senpuki, Result
|
|
6
|
+
|
|
7
|
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s: %(message)s', datefmt='%H:%M:%S')
|
|
8
|
+
logger = logging.getLogger("BatchExample")
|
|
9
|
+
|
|
10
|
+
# --- Activities ---
|
|
11
|
+
|
|
12
|
+
@Senpuki.durable()
|
|
13
|
+
async def download_image(image_id: int) -> str:
|
|
14
|
+
# Simulate network IO
|
|
15
|
+
delay = random.uniform(0.1, 0.5)
|
|
16
|
+
await asyncio.sleep(delay)
|
|
17
|
+
# Simulate occasional network failure
|
|
18
|
+
if random.random() < 0.1:
|
|
19
|
+
raise ConnectionError(f"Network error downloading image {image_id}")
|
|
20
|
+
|
|
21
|
+
path = f"/tmp/img_{image_id}.jpg"
|
|
22
|
+
logger.info(f"Downloaded image {image_id} to {path} ({delay:.2f}s)")
|
|
23
|
+
return path
|
|
24
|
+
|
|
25
|
+
@Senpuki.durable()
|
|
26
|
+
async def process_image(path: str) -> str:
|
|
27
|
+
# Simulate CPU intensive work
|
|
28
|
+
await asyncio.sleep(0.2)
|
|
29
|
+
processed_path = path.replace(".jpg", "_bw.jpg")
|
|
30
|
+
logger.info(f"Processed {path} -> {processed_path}")
|
|
31
|
+
return processed_path
|
|
32
|
+
|
|
33
|
+
@Senpuki.durable()
|
|
34
|
+
async def create_gallery(image_paths: list[str]) -> str:
|
|
35
|
+
await asyncio.sleep(0.5)
|
|
36
|
+
logger.info(f"Creating gallery from {len(image_paths)} images")
|
|
37
|
+
return f"http://example.com/gallery/{len(image_paths)}_images"
|
|
38
|
+
|
|
39
|
+
# --- Orchestrator ---
|
|
40
|
+
|
|
41
|
+
@Senpuki.durable()
|
|
42
|
+
async def batch_image_workflow(image_ids: list[int]) -> Result[str, Exception]:
|
|
43
|
+
logger.info(f"Starting batch workflow for {len(image_ids)} images")
|
|
44
|
+
|
|
45
|
+
# Fan-out: Download all images in parallel
|
|
46
|
+
download_tasks = []
|
|
47
|
+
for img_id in image_ids:
|
|
48
|
+
# We can fire off tasks. If one fails, we might want to handle it.
|
|
49
|
+
# asyncio.gather will raise the first exception by default.
|
|
50
|
+
# In a real batch, maybe we want return_exceptions=True to process partials.
|
|
51
|
+
download_tasks.append(download_image(img_id))
|
|
52
|
+
|
|
53
|
+
# Wait for downloads
|
|
54
|
+
# To handle partial failures, we would wrap download_image in a safe version or use return_exceptions.
|
|
55
|
+
# Here we assume we want all or nothing for simplicity, but let's use return_exceptions=True to show robustness.
|
|
56
|
+
download_results = await asyncio.gather(*download_tasks, return_exceptions=True)
|
|
57
|
+
|
|
58
|
+
successful_downloads = []
|
|
59
|
+
for i, res in enumerate(download_results):
|
|
60
|
+
if isinstance(res, Exception):
|
|
61
|
+
logger.warning(f"Failed to download image {image_ids[i]}: {res}")
|
|
62
|
+
else:
|
|
63
|
+
successful_downloads.append(res)
|
|
64
|
+
|
|
65
|
+
if not successful_downloads:
|
|
66
|
+
return Result.Error(Exception("No images downloaded successfully"))
|
|
67
|
+
|
|
68
|
+
# Fan-out: Process downloaded images
|
|
69
|
+
process_tasks = [process_image(path) for path in successful_downloads]
|
|
70
|
+
processed_paths = await asyncio.gather(*process_tasks)
|
|
71
|
+
|
|
72
|
+
# Fan-in: Create gallery
|
|
73
|
+
gallery_url = await create_gallery(processed_paths)
|
|
74
|
+
|
|
75
|
+
return Result.Ok(gallery_url)
|
|
76
|
+
|
|
77
|
+
# --- Runner ---
|
|
78
|
+
|
|
79
|
+
async def main():
|
|
80
|
+
db_path = "batch_example.sqlite"
|
|
81
|
+
if os.path.exists(db_path):
|
|
82
|
+
os.remove(db_path)
|
|
83
|
+
|
|
84
|
+
backend = Senpuki.backends.SQLiteBackend(db_path)
|
|
85
|
+
await backend.init_db()
|
|
86
|
+
|
|
87
|
+
executor = Senpuki(backend=backend)
|
|
88
|
+
|
|
89
|
+
# Start worker with concurrency
|
|
90
|
+
worker_task = asyncio.create_task(executor.serve(poll_interval=0.1, max_concurrency=20))
|
|
91
|
+
|
|
92
|
+
logger.info("Dispatching workflow...")
|
|
93
|
+
# Process 10 images
|
|
94
|
+
ids = list(range(1, 11))
|
|
95
|
+
exec_id = await executor.dispatch(batch_image_workflow, ids)
|
|
96
|
+
|
|
97
|
+
# Monitor
|
|
98
|
+
while True:
|
|
99
|
+
state = await executor.state_of(exec_id)
|
|
100
|
+
if state.state in ("completed", "failed", "timed_out"):
|
|
101
|
+
break
|
|
102
|
+
await asyncio.sleep(0.5)
|
|
103
|
+
|
|
104
|
+
result = await executor.result_of(exec_id)
|
|
105
|
+
logger.info(f"Final Result: {result}")
|
|
106
|
+
|
|
107
|
+
worker_task.cancel()
|
|
108
|
+
try:
|
|
109
|
+
await worker_task
|
|
110
|
+
except asyncio.CancelledError:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
asyncio.run(main())
|