pgflows 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.
- pgflows-0.1.0/PKG-INFO +304 -0
- pgflows-0.1.0/README.md +275 -0
- pgflows-0.1.0/pyproject.toml +62 -0
- pgflows-0.1.0/src/pyflows/__init__.py +82 -0
- pgflows-0.1.0/src/pyflows/app.py +156 -0
- pgflows-0.1.0/src/pyflows/backends/__init__.py +3 -0
- pgflows-0.1.0/src/pyflows/backends/base.py +110 -0
- pgflows-0.1.0/src/pyflows/backends/pg_cron.py +86 -0
- pgflows-0.1.0/src/pyflows/backends/pg_durable.py +57 -0
- pgflows-0.1.0/src/pyflows/backends/pg_state.py +284 -0
- pgflows-0.1.0/src/pyflows/backends/pgmq.py +125 -0
- pgflows-0.1.0/src/pyflows/config.py +14 -0
- pgflows-0.1.0/src/pyflows/context.py +155 -0
- pgflows-0.1.0/src/pyflows/exceptions.py +36 -0
- pgflows-0.1.0/src/pyflows/logger.py +35 -0
- pgflows-0.1.0/src/pyflows/migrations.py +89 -0
- pgflows-0.1.0/src/pyflows/plugins.py +103 -0
- pgflows-0.1.0/src/pyflows/py.typed +0 -0
- pgflows-0.1.0/src/pyflows/registry.py +105 -0
- pgflows-0.1.0/src/pyflows/schema.sql +42 -0
- pgflows-0.1.0/src/pyflows/sql_exporter.py +165 -0
- pgflows-0.1.0/src/pyflows/telemetry.py +69 -0
- pgflows-0.1.0/src/pyflows/types.py +56 -0
- pgflows-0.1.0/src/pyflows/worker.py +123 -0
pgflows-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pgflows
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Durable workflow engine SDK for Python + Postgres
|
|
5
|
+
Keywords: workflow,durable,postgres,async
|
|
6
|
+
Author: Nir Adler
|
|
7
|
+
Author-email: Nir Adler <me@niradler.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Dist: pydantic
|
|
16
|
+
Requires-Dist: asyncpg
|
|
17
|
+
Requires-Dist: tembo-pgmq-python
|
|
18
|
+
Requires-Dist: opentelemetry-api
|
|
19
|
+
Requires-Dist: opentelemetry-sdk
|
|
20
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc
|
|
21
|
+
Requires-Dist: fastapi ; extra == 'fastapi'
|
|
22
|
+
Requires-Dist: uvicorn[standard] ; extra == 'fastapi'
|
|
23
|
+
Requires-Python: >=3.13
|
|
24
|
+
Project-URL: Homepage, https://github.com/niradler/pyflows
|
|
25
|
+
Project-URL: Repository, https://github.com/niradler/pyflows
|
|
26
|
+
Project-URL: Issues, https://github.com/niradler/pyflows/issues
|
|
27
|
+
Provides-Extra: fastapi
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# pyflows
|
|
31
|
+
|
|
32
|
+
[](https://pypi.org/project/pyflows/)
|
|
33
|
+
[](https://pypi.org/project/pyflows/)
|
|
34
|
+
[](LICENSE)
|
|
35
|
+
|
|
36
|
+
> Durable workflow engine SDK for Python + Postgres
|
|
37
|
+
|
|
38
|
+
pyflows lets you write long-running, fault-tolerant workflows as plain async Python functions — backed entirely by your existing Postgres database. No extra infrastructure, no separate orchestration service, no new runtime to operate.
|
|
39
|
+
|
|
40
|
+
> [!WARNING]
|
|
41
|
+
> **Early development (alpha).** The core API is stabilizing but not yet 1.0. Expect breaking changes before the first stable release.
|
|
42
|
+
|
|
43
|
+
## How it works
|
|
44
|
+
|
|
45
|
+
Each workflow step is persisted to Postgres before execution. If the process crashes mid-run, the worker replays from the last checkpoint — re-executing only the steps that haven't completed. All state, retries, and scheduling live in the database.
|
|
46
|
+
|
|
47
|
+
```text
|
|
48
|
+
@workflow fn → WorkflowApp.start()
|
|
49
|
+
↓ enqueues to pgmq
|
|
50
|
+
Python async worker
|
|
51
|
+
↓ executes steps
|
|
52
|
+
PgStateBackend ←→ Postgres
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Quick start
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
docker compose up -d # start Postgres with pgmq
|
|
59
|
+
uv add pyflows
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import asyncio
|
|
64
|
+
from pydantic import BaseModel
|
|
65
|
+
from pyflows import PyflowsConfig, RetryConfig, StepContext, WorkflowApp, WorkflowContext
|
|
66
|
+
|
|
67
|
+
config = PyflowsConfig(
|
|
68
|
+
dsn="postgresql://pyflows:pyflows@127.0.0.1:5433/pyflows_test",
|
|
69
|
+
otel_enabled=False,
|
|
70
|
+
db_ssl=False,
|
|
71
|
+
)
|
|
72
|
+
app = WorkflowApp(config=config)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class OrderInput(BaseModel):
|
|
76
|
+
order_id: str
|
|
77
|
+
amount: float
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class OrderResult(BaseModel):
|
|
81
|
+
charged: bool
|
|
82
|
+
confirmation: str
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.step(retry=RetryConfig(max_retries=3, initial_delay_seconds=1.0))
|
|
86
|
+
async def charge_payment(ctx: StepContext, input: OrderInput) -> OrderResult:
|
|
87
|
+
# call your payment API here
|
|
88
|
+
return OrderResult(charged=True, confirmation=f"CHG-{input.order_id}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.workflow()
|
|
92
|
+
async def process_order(ctx: WorkflowContext, input: OrderInput) -> OrderResult:
|
|
93
|
+
return await ctx.step(charge_payment, input)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def main() -> None:
|
|
97
|
+
await app.initialize()
|
|
98
|
+
instance_id = await app.start(process_order, OrderInput(order_id="ORD-1", amount=99.0))
|
|
99
|
+
await app.process_once()
|
|
100
|
+
status = await app.get_status(instance_id)
|
|
101
|
+
print(status.state, status.output)
|
|
102
|
+
await app.close()
|
|
103
|
+
|
|
104
|
+
asyncio.run(main())
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Features
|
|
108
|
+
|
|
109
|
+
- **Checkpoint replay** — workflows survive crashes; completed steps are never re-executed
|
|
110
|
+
- **Typed end-to-end** — step inputs and outputs are Pydantic models; no `dict[str, Any]` at the boundary
|
|
111
|
+
- **Configurable retries** — per-step `RetryConfig` with exponential or linear backoff and jitter
|
|
112
|
+
- **Plugin hooks** — `before_workflow`, `after_workflow`, `on_workflow_error`, `before_step`, `after_step`, `on_step_error`
|
|
113
|
+
- **Automatic migrations** — `await app.initialize()` applies schema migrations; no manual SQL required
|
|
114
|
+
- **Cron scheduler** — trigger recurring workflows via `PgCronBackend` (backed by pg_durable `df.wait_for_schedule`)
|
|
115
|
+
- **Dead-letter queue** — failed workflows are archived to `pgmq.a_{queue}` instead of being re-queued indefinitely
|
|
116
|
+
- **Worker coordination** — atomic `pending→running` claim prevents duplicate processing when multiple workers race on the same instance
|
|
117
|
+
- **Swappable backends** — orchestrator, queue, and scheduler implement ABCs; swap without touching workflow code
|
|
118
|
+
- **OpenTelemetry** — built-in span management for workflows and steps
|
|
119
|
+
|
|
120
|
+
## Plugin system
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from pyflows import LoggingPlugin, PyflowsPlugin, StepEvent, WorkflowEvent
|
|
124
|
+
|
|
125
|
+
# Built-in: log all lifecycle events
|
|
126
|
+
app.register_plugin(LoggingPlugin())
|
|
127
|
+
|
|
128
|
+
# Custom: implement any subset of hooks
|
|
129
|
+
class MetricsPlugin(PyflowsPlugin):
|
|
130
|
+
async def after_step(self, event: StepEvent, result: object) -> None:
|
|
131
|
+
metrics.record("step.completed", tags={"step": event.step_name})
|
|
132
|
+
|
|
133
|
+
async def on_workflow_error(self, event: WorkflowEvent, error: Exception) -> None:
|
|
134
|
+
metrics.record("workflow.failed", tags={"workflow": event.workflow_name})
|
|
135
|
+
|
|
136
|
+
app.register_plugin(MetricsPlugin())
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Plugins are called in registration order. A plugin that raises never affects other plugins or the workflow itself.
|
|
140
|
+
|
|
141
|
+
## Retry configuration
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from pyflows import RetryConfig
|
|
145
|
+
|
|
146
|
+
# Per-step retry (backoff can be "exponential" or "linear")
|
|
147
|
+
@app.step(retry=RetryConfig(max_retries=5, initial_delay_seconds=2.0, max_delay_seconds=60.0, backoff="exponential"))
|
|
148
|
+
async def my_step(ctx, input: MyInput) -> MyOutput: ...
|
|
149
|
+
|
|
150
|
+
# Workflow-level defaults (applied to all steps unless overridden)
|
|
151
|
+
@app.workflow(step_defaults=RetryConfig(max_retries=2))
|
|
152
|
+
async def my_workflow(ctx, input: MyInput) -> MyOutput: ...
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## SQL export and runtime workflows
|
|
156
|
+
|
|
157
|
+
pyflows can export any registered workflow to a [pg_durable](https://github.com/microsoft/pg_durable) SQL DSL. Use this to:
|
|
158
|
+
|
|
159
|
+
- Transfer workflow definitions from dev → prod without code deployment
|
|
160
|
+
- Create workflows at runtime from config, API payloads, or external systems
|
|
161
|
+
- Inspect the step sequence of any workflow before executing it
|
|
162
|
+
|
|
163
|
+
### Export a Python workflow to SQL
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
from pyflows import SqlExporter
|
|
167
|
+
|
|
168
|
+
exporter = SqlExporter(registry=app.registry, base_url="http://my-app:8000")
|
|
169
|
+
|
|
170
|
+
# Full SQL ready to run against a Postgres database with pg_durable
|
|
171
|
+
sql = exporter.export_workflow("process_order")
|
|
172
|
+
|
|
173
|
+
# Dry-run: inspect steps without producing runnable SQL
|
|
174
|
+
result = exporter.dry_run("process_order")
|
|
175
|
+
print(result.steps) # [StepSql(step_name='charge_payment', ...)]
|
|
176
|
+
print(result.sql) # pg_durable DSL
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Compose a workflow at runtime from step names
|
|
180
|
+
|
|
181
|
+
When you want to define a workflow without writing a Python function — from a config file, an API request, or a database record — use `compose()`. Each step name must already be registered with the app.
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
# No Python workflow function needed — compose step sequences dynamically
|
|
185
|
+
sql = exporter.compose(
|
|
186
|
+
workflow_name="on_call_response",
|
|
187
|
+
steps=["check_service_health", "diagnose_incident", "apply_remediation"],
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Execute sql against Postgres with pg_durable to start the workflow
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The `compose()` call validates that every step name is registered, so typos raise a `KeyError` immediately rather than failing silently at runtime.
|
|
194
|
+
|
|
195
|
+
### Export all workflows
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
# All registered workflows in one SQL file (dev → prod migration)
|
|
199
|
+
sql = exporter.export_all()
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Scheduling
|
|
203
|
+
|
|
204
|
+
`PgCronBackend` schedules recurring workflows using pg_durable's `df.wait_for_schedule()` — no `pg_cron` extension required, only the `df` extension from [pg_durable](https://github.com/microsoft/pg_durable).
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
from pyflows import PgCronBackend
|
|
208
|
+
|
|
209
|
+
scheduler = PgCronBackend(dsn=config.dsn)
|
|
210
|
+
await scheduler.initialize()
|
|
211
|
+
|
|
212
|
+
# Schedule a workflow to run every hour (job_id is a pg_durable instance ID string)
|
|
213
|
+
job_id = await scheduler.schedule(
|
|
214
|
+
job_name="hourly_health_check",
|
|
215
|
+
cron="0 * * * *",
|
|
216
|
+
command="SELECT pyflows.enqueue_workflow('health_check', '{}')",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
jobs = await scheduler.list_jobs()
|
|
220
|
+
await scheduler.unschedule(job_id)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Check whether pg_durable is installed at runtime:
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
await app.initialize()
|
|
227
|
+
if app.pg_durable_available:
|
|
228
|
+
# scheduler and push-mode SQL export are usable
|
|
229
|
+
...
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Backend abstraction
|
|
233
|
+
|
|
234
|
+
Every infrastructure concern is behind an ABC in `backends/base.py`. Swap backends without touching workflow code:
|
|
235
|
+
|
|
236
|
+
| Component | Default | Interface |
|
|
237
|
+
| --------- | ------- | --------- |
|
|
238
|
+
| State + checkpoints | `PgStateBackend` | `OrchestratorBackend` |
|
|
239
|
+
| Step queue | `PgmqBackend` | `QueueBackend` |
|
|
240
|
+
| Cron scheduling | `PgCronBackend` | `SchedulerBackend` |
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
# Bring your own queue backend
|
|
244
|
+
class RedisQueueBackend(QueueBackend):
|
|
245
|
+
...
|
|
246
|
+
|
|
247
|
+
app = WorkflowApp(config=config)
|
|
248
|
+
# Use custom backend by injecting into WorkflowWorker directly
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## Requirements
|
|
252
|
+
|
|
253
|
+
**Python:** 3.13+
|
|
254
|
+
|
|
255
|
+
**Postgres extensions** (15+):
|
|
256
|
+
|
|
257
|
+
| Extension | Purpose | Required |
|
|
258
|
+
| --------- | ------- | -------- |
|
|
259
|
+
| [`pgmq`](https://github.com/tembo-io/pgmq) | Step queue | Yes |
|
|
260
|
+
| [`pg_durable` (`df`)](https://github.com/microsoft/pg_durable) | Cron scheduling, push-mode SQL export | Optional |
|
|
261
|
+
|
|
262
|
+
The bundled `docker-compose.yml` starts a Postgres instance with `pgmq` pre-installed:
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
docker compose up -d
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## Installation
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
pip install pyflows
|
|
272
|
+
# or
|
|
273
|
+
uv add pyflows
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## Development
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
uv sync # install deps
|
|
280
|
+
uv run pytest tests/unit/ # unit tests (no DB needed)
|
|
281
|
+
docker compose up -d # start Postgres
|
|
282
|
+
uv run pytest tests/e2e/ # E2E tests
|
|
283
|
+
uv run ruff check src/ tests/ # lint
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
## AI SRE example
|
|
287
|
+
|
|
288
|
+
See [`examples/ai_sre/workflow.py`](examples/ai_sre/workflow.py) for a full incident response workflow: health check → AI diagnosis → auto-remediation, with retries, plugin hooks, and typed I/O.
|
|
289
|
+
|
|
290
|
+
## Roadmap
|
|
291
|
+
|
|
292
|
+
- [x] M1 — Project scaffold: backend ABCs, Pydantic types, exception hierarchy
|
|
293
|
+
- [x] M2 — Core SDK: `WorkflowApp`, `@step`, `@workflow`, `WorkflowContext`, replay engine
|
|
294
|
+
- [x] M3 — SqlExporter: Python workflow → pg_durable DSL (AST-based)
|
|
295
|
+
- [x] M4 — E2E test suite: basic, retry, monitor/cancel (Docker-based)
|
|
296
|
+
- [x] M6 — Plugin system: `PyflowsPlugin` ABC, `LoggingPlugin`, lifecycle hooks
|
|
297
|
+
- [x] M7 — Migrations + scheduler: versioned schema migrations, `PgCronBackend` via pg_durable
|
|
298
|
+
- [x] M8 — AI SRE example, README, production hardening: DLQ, worker coordination, linear backoff, pg_durable detection
|
|
299
|
+
- [ ] M5 — FastAPI integration: push endpoint (deferred; pull mode works without it)
|
|
300
|
+
- [ ] M9 — PyPI release + full documentation
|
|
301
|
+
|
|
302
|
+
## License
|
|
303
|
+
|
|
304
|
+
MIT
|
pgflows-0.1.0/README.md
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# pyflows
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/pyflows/)
|
|
4
|
+
[](https://pypi.org/project/pyflows/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
> Durable workflow engine SDK for Python + Postgres
|
|
8
|
+
|
|
9
|
+
pyflows lets you write long-running, fault-tolerant workflows as plain async Python functions — backed entirely by your existing Postgres database. No extra infrastructure, no separate orchestration service, no new runtime to operate.
|
|
10
|
+
|
|
11
|
+
> [!WARNING]
|
|
12
|
+
> **Early development (alpha).** The core API is stabilizing but not yet 1.0. Expect breaking changes before the first stable release.
|
|
13
|
+
|
|
14
|
+
## How it works
|
|
15
|
+
|
|
16
|
+
Each workflow step is persisted to Postgres before execution. If the process crashes mid-run, the worker replays from the last checkpoint — re-executing only the steps that haven't completed. All state, retries, and scheduling live in the database.
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
@workflow fn → WorkflowApp.start()
|
|
20
|
+
↓ enqueues to pgmq
|
|
21
|
+
Python async worker
|
|
22
|
+
↓ executes steps
|
|
23
|
+
PgStateBackend ←→ Postgres
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quick start
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
docker compose up -d # start Postgres with pgmq
|
|
30
|
+
uv add pyflows
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import asyncio
|
|
35
|
+
from pydantic import BaseModel
|
|
36
|
+
from pyflows import PyflowsConfig, RetryConfig, StepContext, WorkflowApp, WorkflowContext
|
|
37
|
+
|
|
38
|
+
config = PyflowsConfig(
|
|
39
|
+
dsn="postgresql://pyflows:pyflows@127.0.0.1:5433/pyflows_test",
|
|
40
|
+
otel_enabled=False,
|
|
41
|
+
db_ssl=False,
|
|
42
|
+
)
|
|
43
|
+
app = WorkflowApp(config=config)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class OrderInput(BaseModel):
|
|
47
|
+
order_id: str
|
|
48
|
+
amount: float
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class OrderResult(BaseModel):
|
|
52
|
+
charged: bool
|
|
53
|
+
confirmation: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.step(retry=RetryConfig(max_retries=3, initial_delay_seconds=1.0))
|
|
57
|
+
async def charge_payment(ctx: StepContext, input: OrderInput) -> OrderResult:
|
|
58
|
+
# call your payment API here
|
|
59
|
+
return OrderResult(charged=True, confirmation=f"CHG-{input.order_id}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.workflow()
|
|
63
|
+
async def process_order(ctx: WorkflowContext, input: OrderInput) -> OrderResult:
|
|
64
|
+
return await ctx.step(charge_payment, input)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def main() -> None:
|
|
68
|
+
await app.initialize()
|
|
69
|
+
instance_id = await app.start(process_order, OrderInput(order_id="ORD-1", amount=99.0))
|
|
70
|
+
await app.process_once()
|
|
71
|
+
status = await app.get_status(instance_id)
|
|
72
|
+
print(status.state, status.output)
|
|
73
|
+
await app.close()
|
|
74
|
+
|
|
75
|
+
asyncio.run(main())
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Features
|
|
79
|
+
|
|
80
|
+
- **Checkpoint replay** — workflows survive crashes; completed steps are never re-executed
|
|
81
|
+
- **Typed end-to-end** — step inputs and outputs are Pydantic models; no `dict[str, Any]` at the boundary
|
|
82
|
+
- **Configurable retries** — per-step `RetryConfig` with exponential or linear backoff and jitter
|
|
83
|
+
- **Plugin hooks** — `before_workflow`, `after_workflow`, `on_workflow_error`, `before_step`, `after_step`, `on_step_error`
|
|
84
|
+
- **Automatic migrations** — `await app.initialize()` applies schema migrations; no manual SQL required
|
|
85
|
+
- **Cron scheduler** — trigger recurring workflows via `PgCronBackend` (backed by pg_durable `df.wait_for_schedule`)
|
|
86
|
+
- **Dead-letter queue** — failed workflows are archived to `pgmq.a_{queue}` instead of being re-queued indefinitely
|
|
87
|
+
- **Worker coordination** — atomic `pending→running` claim prevents duplicate processing when multiple workers race on the same instance
|
|
88
|
+
- **Swappable backends** — orchestrator, queue, and scheduler implement ABCs; swap without touching workflow code
|
|
89
|
+
- **OpenTelemetry** — built-in span management for workflows and steps
|
|
90
|
+
|
|
91
|
+
## Plugin system
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from pyflows import LoggingPlugin, PyflowsPlugin, StepEvent, WorkflowEvent
|
|
95
|
+
|
|
96
|
+
# Built-in: log all lifecycle events
|
|
97
|
+
app.register_plugin(LoggingPlugin())
|
|
98
|
+
|
|
99
|
+
# Custom: implement any subset of hooks
|
|
100
|
+
class MetricsPlugin(PyflowsPlugin):
|
|
101
|
+
async def after_step(self, event: StepEvent, result: object) -> None:
|
|
102
|
+
metrics.record("step.completed", tags={"step": event.step_name})
|
|
103
|
+
|
|
104
|
+
async def on_workflow_error(self, event: WorkflowEvent, error: Exception) -> None:
|
|
105
|
+
metrics.record("workflow.failed", tags={"workflow": event.workflow_name})
|
|
106
|
+
|
|
107
|
+
app.register_plugin(MetricsPlugin())
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Plugins are called in registration order. A plugin that raises never affects other plugins or the workflow itself.
|
|
111
|
+
|
|
112
|
+
## Retry configuration
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from pyflows import RetryConfig
|
|
116
|
+
|
|
117
|
+
# Per-step retry (backoff can be "exponential" or "linear")
|
|
118
|
+
@app.step(retry=RetryConfig(max_retries=5, initial_delay_seconds=2.0, max_delay_seconds=60.0, backoff="exponential"))
|
|
119
|
+
async def my_step(ctx, input: MyInput) -> MyOutput: ...
|
|
120
|
+
|
|
121
|
+
# Workflow-level defaults (applied to all steps unless overridden)
|
|
122
|
+
@app.workflow(step_defaults=RetryConfig(max_retries=2))
|
|
123
|
+
async def my_workflow(ctx, input: MyInput) -> MyOutput: ...
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## SQL export and runtime workflows
|
|
127
|
+
|
|
128
|
+
pyflows can export any registered workflow to a [pg_durable](https://github.com/microsoft/pg_durable) SQL DSL. Use this to:
|
|
129
|
+
|
|
130
|
+
- Transfer workflow definitions from dev → prod without code deployment
|
|
131
|
+
- Create workflows at runtime from config, API payloads, or external systems
|
|
132
|
+
- Inspect the step sequence of any workflow before executing it
|
|
133
|
+
|
|
134
|
+
### Export a Python workflow to SQL
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from pyflows import SqlExporter
|
|
138
|
+
|
|
139
|
+
exporter = SqlExporter(registry=app.registry, base_url="http://my-app:8000")
|
|
140
|
+
|
|
141
|
+
# Full SQL ready to run against a Postgres database with pg_durable
|
|
142
|
+
sql = exporter.export_workflow("process_order")
|
|
143
|
+
|
|
144
|
+
# Dry-run: inspect steps without producing runnable SQL
|
|
145
|
+
result = exporter.dry_run("process_order")
|
|
146
|
+
print(result.steps) # [StepSql(step_name='charge_payment', ...)]
|
|
147
|
+
print(result.sql) # pg_durable DSL
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Compose a workflow at runtime from step names
|
|
151
|
+
|
|
152
|
+
When you want to define a workflow without writing a Python function — from a config file, an API request, or a database record — use `compose()`. Each step name must already be registered with the app.
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
# No Python workflow function needed — compose step sequences dynamically
|
|
156
|
+
sql = exporter.compose(
|
|
157
|
+
workflow_name="on_call_response",
|
|
158
|
+
steps=["check_service_health", "diagnose_incident", "apply_remediation"],
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Execute sql against Postgres with pg_durable to start the workflow
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The `compose()` call validates that every step name is registered, so typos raise a `KeyError` immediately rather than failing silently at runtime.
|
|
165
|
+
|
|
166
|
+
### Export all workflows
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
# All registered workflows in one SQL file (dev → prod migration)
|
|
170
|
+
sql = exporter.export_all()
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Scheduling
|
|
174
|
+
|
|
175
|
+
`PgCronBackend` schedules recurring workflows using pg_durable's `df.wait_for_schedule()` — no `pg_cron` extension required, only the `df` extension from [pg_durable](https://github.com/microsoft/pg_durable).
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
from pyflows import PgCronBackend
|
|
179
|
+
|
|
180
|
+
scheduler = PgCronBackend(dsn=config.dsn)
|
|
181
|
+
await scheduler.initialize()
|
|
182
|
+
|
|
183
|
+
# Schedule a workflow to run every hour (job_id is a pg_durable instance ID string)
|
|
184
|
+
job_id = await scheduler.schedule(
|
|
185
|
+
job_name="hourly_health_check",
|
|
186
|
+
cron="0 * * * *",
|
|
187
|
+
command="SELECT pyflows.enqueue_workflow('health_check', '{}')",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
jobs = await scheduler.list_jobs()
|
|
191
|
+
await scheduler.unschedule(job_id)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Check whether pg_durable is installed at runtime:
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
await app.initialize()
|
|
198
|
+
if app.pg_durable_available:
|
|
199
|
+
# scheduler and push-mode SQL export are usable
|
|
200
|
+
...
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Backend abstraction
|
|
204
|
+
|
|
205
|
+
Every infrastructure concern is behind an ABC in `backends/base.py`. Swap backends without touching workflow code:
|
|
206
|
+
|
|
207
|
+
| Component | Default | Interface |
|
|
208
|
+
| --------- | ------- | --------- |
|
|
209
|
+
| State + checkpoints | `PgStateBackend` | `OrchestratorBackend` |
|
|
210
|
+
| Step queue | `PgmqBackend` | `QueueBackend` |
|
|
211
|
+
| Cron scheduling | `PgCronBackend` | `SchedulerBackend` |
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
# Bring your own queue backend
|
|
215
|
+
class RedisQueueBackend(QueueBackend):
|
|
216
|
+
...
|
|
217
|
+
|
|
218
|
+
app = WorkflowApp(config=config)
|
|
219
|
+
# Use custom backend by injecting into WorkflowWorker directly
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Requirements
|
|
223
|
+
|
|
224
|
+
**Python:** 3.13+
|
|
225
|
+
|
|
226
|
+
**Postgres extensions** (15+):
|
|
227
|
+
|
|
228
|
+
| Extension | Purpose | Required |
|
|
229
|
+
| --------- | ------- | -------- |
|
|
230
|
+
| [`pgmq`](https://github.com/tembo-io/pgmq) | Step queue | Yes |
|
|
231
|
+
| [`pg_durable` (`df`)](https://github.com/microsoft/pg_durable) | Cron scheduling, push-mode SQL export | Optional |
|
|
232
|
+
|
|
233
|
+
The bundled `docker-compose.yml` starts a Postgres instance with `pgmq` pre-installed:
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
docker compose up -d
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Installation
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
pip install pyflows
|
|
243
|
+
# or
|
|
244
|
+
uv add pyflows
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## Development
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
uv sync # install deps
|
|
251
|
+
uv run pytest tests/unit/ # unit tests (no DB needed)
|
|
252
|
+
docker compose up -d # start Postgres
|
|
253
|
+
uv run pytest tests/e2e/ # E2E tests
|
|
254
|
+
uv run ruff check src/ tests/ # lint
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## AI SRE example
|
|
258
|
+
|
|
259
|
+
See [`examples/ai_sre/workflow.py`](examples/ai_sre/workflow.py) for a full incident response workflow: health check → AI diagnosis → auto-remediation, with retries, plugin hooks, and typed I/O.
|
|
260
|
+
|
|
261
|
+
## Roadmap
|
|
262
|
+
|
|
263
|
+
- [x] M1 — Project scaffold: backend ABCs, Pydantic types, exception hierarchy
|
|
264
|
+
- [x] M2 — Core SDK: `WorkflowApp`, `@step`, `@workflow`, `WorkflowContext`, replay engine
|
|
265
|
+
- [x] M3 — SqlExporter: Python workflow → pg_durable DSL (AST-based)
|
|
266
|
+
- [x] M4 — E2E test suite: basic, retry, monitor/cancel (Docker-based)
|
|
267
|
+
- [x] M6 — Plugin system: `PyflowsPlugin` ABC, `LoggingPlugin`, lifecycle hooks
|
|
268
|
+
- [x] M7 — Migrations + scheduler: versioned schema migrations, `PgCronBackend` via pg_durable
|
|
269
|
+
- [x] M8 — AI SRE example, README, production hardening: DLQ, worker coordination, linear backoff, pg_durable detection
|
|
270
|
+
- [ ] M5 — FastAPI integration: push endpoint (deferred; pull mode works without it)
|
|
271
|
+
- [ ] M9 — PyPI release + full documentation
|
|
272
|
+
|
|
273
|
+
## License
|
|
274
|
+
|
|
275
|
+
MIT
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pgflows"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Durable workflow engine SDK for Python + Postgres"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
authors = [{ name = "Nir Adler", email = "me@niradler.com" }]
|
|
8
|
+
keywords = ["workflow", "durable", "postgres", "async"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 3 - Alpha",
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.13",
|
|
15
|
+
"Typing :: Typed",
|
|
16
|
+
]
|
|
17
|
+
requires-python = ">=3.13"
|
|
18
|
+
dependencies = [
|
|
19
|
+
"pydantic",
|
|
20
|
+
"asyncpg",
|
|
21
|
+
"tembo-pgmq-python",
|
|
22
|
+
"opentelemetry-api",
|
|
23
|
+
"opentelemetry-sdk",
|
|
24
|
+
"opentelemetry-exporter-otlp-proto-grpc",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
fastapi = ["fastapi", "uvicorn[standard]"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/niradler/pyflows"
|
|
32
|
+
Repository = "https://github.com/niradler/pyflows"
|
|
33
|
+
Issues = "https://github.com/niradler/pyflows/issues"
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["uv_build>=0.10.0,<0.11.0"]
|
|
37
|
+
build-backend = "uv_build"
|
|
38
|
+
|
|
39
|
+
[tool.uv.build-backend]
|
|
40
|
+
module-name = "pyflows"
|
|
41
|
+
|
|
42
|
+
[dependency-groups]
|
|
43
|
+
dev = [
|
|
44
|
+
"pytest>=8.0",
|
|
45
|
+
"pytest-asyncio",
|
|
46
|
+
"pytest-timeout",
|
|
47
|
+
"anyio",
|
|
48
|
+
"ruff>=0.9",
|
|
49
|
+
"fastapi",
|
|
50
|
+
"uvicorn[standard]",
|
|
51
|
+
"httpx",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[tool.ruff]
|
|
55
|
+
line-length = 100
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint]
|
|
58
|
+
select = ["E", "F", "I", "UP"]
|
|
59
|
+
|
|
60
|
+
[tool.pytest.ini_options]
|
|
61
|
+
asyncio_mode = "auto"
|
|
62
|
+
timeout = 30
|