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.
Files changed (45) hide show
  1. senpuki-0.1.0/PKG-INFO +258 -0
  2. senpuki-0.1.0/README.md +247 -0
  3. senpuki-0.1.0/examples/batch_processing.py +114 -0
  4. senpuki-0.1.0/examples/complex_workflow.py +151 -0
  5. senpuki-0.1.0/examples/failing_flow.py +49 -0
  6. senpuki-0.1.0/examples/media_pipeline.py +312 -0
  7. senpuki-0.1.0/examples/parallel_scraper.py +141 -0
  8. senpuki-0.1.0/examples/saga_trip_booking.py +131 -0
  9. senpuki-0.1.0/examples/simple_flow.py +65 -0
  10. senpuki-0.1.0/pyproject.toml +21 -0
  11. senpuki-0.1.0/senpuki/__init__.py +5 -0
  12. senpuki-0.1.0/senpuki/backend/__init__.py +0 -0
  13. senpuki-0.1.0/senpuki/backend/base.py +55 -0
  14. senpuki-0.1.0/senpuki/backend/postgres.py +476 -0
  15. senpuki-0.1.0/senpuki/backend/sqlite.py +511 -0
  16. senpuki-0.1.0/senpuki/core.py +135 -0
  17. senpuki-0.1.0/senpuki/executor.py +1047 -0
  18. senpuki-0.1.0/senpuki/notifications/__init__.py +0 -0
  19. senpuki-0.1.0/senpuki/notifications/base.py +19 -0
  20. senpuki-0.1.0/senpuki/notifications/redis.py +93 -0
  21. senpuki-0.1.0/senpuki/registry.py +33 -0
  22. senpuki-0.1.0/senpuki/utils/__init__.py +0 -0
  23. senpuki-0.1.0/senpuki/utils/async_sqlite.py +91 -0
  24. senpuki-0.1.0/senpuki/utils/idempotency.py +35 -0
  25. senpuki-0.1.0/senpuki/utils/serialization.py +75 -0
  26. senpuki-0.1.0/senpuki/utils/time.py +38 -0
  27. senpuki-0.1.0/senpuki.egg-info/PKG-INFO +258 -0
  28. senpuki-0.1.0/senpuki.egg-info/SOURCES.txt +43 -0
  29. senpuki-0.1.0/senpuki.egg-info/dependency_links.txt +1 -0
  30. senpuki-0.1.0/senpuki.egg-info/requires.txt +4 -0
  31. senpuki-0.1.0/senpuki.egg-info/top_level.txt +4 -0
  32. senpuki-0.1.0/setup.cfg +4 -0
  33. senpuki-0.1.0/tests/__init__.py +0 -0
  34. senpuki-0.1.0/tests/test_core.py +53 -0
  35. senpuki-0.1.0/tests/test_deadlock.py +49 -0
  36. senpuki-0.1.0/tests/test_execution.py +266 -0
  37. senpuki-0.1.0/tests/test_helpers.py +68 -0
  38. senpuki-0.1.0/tests/test_map.py +80 -0
  39. senpuki-0.1.0/tests/test_max_duration.py +56 -0
  40. senpuki-0.1.0/tests/test_parallel.py +79 -0
  41. senpuki-0.1.0/tests/test_rate_limit.py +110 -0
  42. senpuki-0.1.0/tests/test_scenarios.py +110 -0
  43. senpuki-0.1.0/tests/test_scheduling.py +98 -0
  44. senpuki-0.1.0/tests/test_wait_for.py +72 -0
  45. 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
@@ -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())