pgflows 0.1.0__py3-none-any.whl
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.dist-info/METADATA +304 -0
- pgflows-0.1.0.dist-info/RECORD +24 -0
- pgflows-0.1.0.dist-info/WHEEL +4 -0
- pyflows/__init__.py +82 -0
- pyflows/app.py +156 -0
- pyflows/backends/__init__.py +3 -0
- pyflows/backends/base.py +110 -0
- pyflows/backends/pg_cron.py +86 -0
- pyflows/backends/pg_durable.py +57 -0
- pyflows/backends/pg_state.py +284 -0
- pyflows/backends/pgmq.py +125 -0
- pyflows/config.py +14 -0
- pyflows/context.py +155 -0
- pyflows/exceptions.py +36 -0
- pyflows/logger.py +35 -0
- pyflows/migrations.py +89 -0
- pyflows/plugins.py +103 -0
- pyflows/py.typed +0 -0
- pyflows/registry.py +105 -0
- pyflows/schema.sql +42 -0
- pyflows/sql_exporter.py +165 -0
- pyflows/telemetry.py +69 -0
- pyflows/types.py +56 -0
- pyflows/worker.py +123 -0
|
@@ -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
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
pyflows/__init__.py,sha256=z9Sclcgd5OWyuQsppxZWH2eDwG84cM2qHjxSZu7UnKE,2191
|
|
2
|
+
pyflows/app.py,sha256=iUnC_YfG2Ga7cOI_DHtfvKkJBB6Z6TnyLM4hJgvS9Hk,5902
|
|
3
|
+
pyflows/backends/__init__.py,sha256=pPWf7S-HmjOy4mcTh9uGGoU2h7pFW3x3ELH8nK06szI,157
|
|
4
|
+
pyflows/backends/base.py,sha256=Zl7UHlCE2ubjIphrnXGG1evJt8p4NSDgMVjB6IWkAWw,3292
|
|
5
|
+
pyflows/backends/pg_cron.py,sha256=APXO_lC853mWfo0VE2eRmlMAIdp85DW8WcQKDsqVJB0,3136
|
|
6
|
+
pyflows/backends/pg_durable.py,sha256=bVYcnrK6wDZgS0-u4X07Yd949w-zttQf3MrpSAtlAGM,1642
|
|
7
|
+
pyflows/backends/pg_state.py,sha256=eJl1KDoGMhiayU89PiKRipkVZJRWw9THqcWC8WZQS0A,9913
|
|
8
|
+
pyflows/backends/pgmq.py,sha256=vlLAEPN9eEPmEZ6QNUXWr3XBDbgK4mFkOZpxfJamji8,4400
|
|
9
|
+
pyflows/config.py,sha256=zndsVOAi0MBN30MZbF4KlgHwfF_lCsaXVgQLSVj-X_w,371
|
|
10
|
+
pyflows/context.py,sha256=9ttwWU8IRsATGHSBn4255uAcUmUVXK9NeTIVsPDAS1c,6027
|
|
11
|
+
pyflows/exceptions.py,sha256=ahSRjq5LCdkldkgGMCqRJOCIApXEyZJNUbXcU2cwYLU,1174
|
|
12
|
+
pyflows/logger.py,sha256=6Y0NRBfRIPGlPN3QewlUdReAvTgj4mztWT6nEgqYva8,1052
|
|
13
|
+
pyflows/migrations.py,sha256=iP_jLMakah0MFI4Cys_8NwYj_xgY5vClvhud6XnVRck,3235
|
|
14
|
+
pyflows/plugins.py,sha256=J5QUitkSh4hsMdIo2IdzZHT9JoCYlAXNiFs3dxglq-w,3140
|
|
15
|
+
pyflows/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
pyflows/registry.py,sha256=-QH59xWBlK2KQCk0yvmhgGbNVmv3kL0nfjVhtmDj60M,3262
|
|
17
|
+
pyflows/schema.sql,sha256=8wqGYgUjgHmgmrrdaF4kkewE5xEdX-ri36R36k5Suvs,1550
|
|
18
|
+
pyflows/sql_exporter.py,sha256=flitEX9_RGw3d_bGHwNYq5a8ADcalyLT-OF2CAHsELM,6175
|
|
19
|
+
pyflows/telemetry.py,sha256=CR0-H1weY3P-j0q5-q8M9dHbWu0tylbkFY1oqnZCbjo,2711
|
|
20
|
+
pyflows/types.py,sha256=Li9EDMo9qdg8HdirXOCcru7Gf_fEWcCt0U7dp7tEtf0,1188
|
|
21
|
+
pyflows/worker.py,sha256=tK2DrYYkZPSWltKbzaUYOELJ0dswfwu2XjxGH04rg9s,4727
|
|
22
|
+
pgflows-0.1.0.dist-info/WHEEL,sha256=jROcLULcdzropX2J55opKw4UHhPFREZax2XzS-Mvpxs,80
|
|
23
|
+
pgflows-0.1.0.dist-info/METADATA,sha256=JCS3If463lRhZmquJggN8S96w9mspXHks2VAl-OLAxs,10778
|
|
24
|
+
pgflows-0.1.0.dist-info/RECORD,,
|
pyflows/__init__.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""pyflows — durable workflow engine SDK for Python + Postgres."""
|
|
2
|
+
|
|
3
|
+
from pyflows.app import WorkflowApp
|
|
4
|
+
from pyflows.backends import OrchestratorBackend, QueueBackend, SchedulerBackend
|
|
5
|
+
from pyflows.backends.pg_cron import PgCronBackend
|
|
6
|
+
from pyflows.backends.pg_durable import PgDurableBackend
|
|
7
|
+
from pyflows.backends.pg_state import PgStateBackend
|
|
8
|
+
from pyflows.backends.pgmq import PgmqBackend
|
|
9
|
+
from pyflows.config import PyflowsConfig
|
|
10
|
+
from pyflows.context import StepContext, WorkflowContext
|
|
11
|
+
from pyflows.exceptions import (
|
|
12
|
+
BackendNotInitializedError,
|
|
13
|
+
PyflowsError,
|
|
14
|
+
SchedulerJobNotFoundError,
|
|
15
|
+
StepExecutionError,
|
|
16
|
+
WorkflowAlreadyExistsError,
|
|
17
|
+
WorkflowNotFoundError,
|
|
18
|
+
)
|
|
19
|
+
from pyflows.logger import configure_default_logging, get_logger
|
|
20
|
+
from pyflows.plugins import LoggingPlugin, PyflowsPlugin, StepEvent, WorkflowEvent
|
|
21
|
+
from pyflows.registry import WorkflowRegistry
|
|
22
|
+
from pyflows.sql_exporter import DryRunResult, SqlExporter, StepSql
|
|
23
|
+
from pyflows.telemetry import PyflowsTelemetry
|
|
24
|
+
from pyflows.types import (
|
|
25
|
+
QueueMessage,
|
|
26
|
+
RetryConfig,
|
|
27
|
+
ScheduledJob,
|
|
28
|
+
StepConfig,
|
|
29
|
+
WorkflowState,
|
|
30
|
+
WorkflowStatus,
|
|
31
|
+
)
|
|
32
|
+
from pyflows.worker import WorkflowWorker
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
# Main entry point
|
|
36
|
+
"WorkflowApp",
|
|
37
|
+
# Context
|
|
38
|
+
"WorkflowContext",
|
|
39
|
+
"StepContext",
|
|
40
|
+
# Registry
|
|
41
|
+
"WorkflowRegistry",
|
|
42
|
+
# Config + telemetry
|
|
43
|
+
"PyflowsConfig",
|
|
44
|
+
"PyflowsTelemetry",
|
|
45
|
+
# Worker
|
|
46
|
+
"WorkflowWorker",
|
|
47
|
+
# SQL exporter
|
|
48
|
+
"SqlExporter",
|
|
49
|
+
"DryRunResult",
|
|
50
|
+
"StepSql",
|
|
51
|
+
# Plugin system
|
|
52
|
+
"PyflowsPlugin",
|
|
53
|
+
"LoggingPlugin",
|
|
54
|
+
"WorkflowEvent",
|
|
55
|
+
"StepEvent",
|
|
56
|
+
# Logger
|
|
57
|
+
"get_logger",
|
|
58
|
+
"configure_default_logging",
|
|
59
|
+
# ABCs
|
|
60
|
+
"OrchestratorBackend",
|
|
61
|
+
"QueueBackend",
|
|
62
|
+
"SchedulerBackend",
|
|
63
|
+
# Concrete backends
|
|
64
|
+
"PgCronBackend",
|
|
65
|
+
"PgDurableBackend",
|
|
66
|
+
"PgmqBackend",
|
|
67
|
+
"PgStateBackend",
|
|
68
|
+
# Types
|
|
69
|
+
"WorkflowState",
|
|
70
|
+
"WorkflowStatus",
|
|
71
|
+
"QueueMessage",
|
|
72
|
+
"ScheduledJob",
|
|
73
|
+
"RetryConfig",
|
|
74
|
+
"StepConfig",
|
|
75
|
+
# Exceptions
|
|
76
|
+
"PyflowsError",
|
|
77
|
+
"WorkflowNotFoundError",
|
|
78
|
+
"WorkflowAlreadyExistsError",
|
|
79
|
+
"StepExecutionError",
|
|
80
|
+
"BackendNotInitializedError",
|
|
81
|
+
"SchedulerJobNotFoundError",
|
|
82
|
+
]
|
pyflows/app.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import urllib.parse
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from pyflows.backends.pg_state import PgStateBackend
|
|
9
|
+
from pyflows.backends.pgmq import PgmqBackend
|
|
10
|
+
from pyflows.config import PyflowsConfig
|
|
11
|
+
from pyflows.migrations import run_migrations
|
|
12
|
+
from pyflows.plugins import PyflowsPlugin
|
|
13
|
+
from pyflows.registry import WorkflowRegistry
|
|
14
|
+
from pyflows.telemetry import PyflowsTelemetry
|
|
15
|
+
from pyflows.types import RetryConfig, WorkflowState, WorkflowStatus
|
|
16
|
+
from pyflows.worker import WorkflowWorker
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class WorkflowApp:
|
|
20
|
+
"""Main entry point for the pyflows SDK."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, config: PyflowsConfig) -> None:
|
|
23
|
+
self.config = config
|
|
24
|
+
self.registry = WorkflowRegistry()
|
|
25
|
+
self._plugins: list[PyflowsPlugin] = []
|
|
26
|
+
self._telemetry: PyflowsTelemetry | None = None
|
|
27
|
+
self._state: PgStateBackend | None = None
|
|
28
|
+
self._queue: PgmqBackend | None = None
|
|
29
|
+
self._worker: WorkflowWorker | None = None
|
|
30
|
+
self._initialized = False
|
|
31
|
+
self._pg_durable_available: bool = False
|
|
32
|
+
|
|
33
|
+
async def initialize(self) -> None:
|
|
34
|
+
"""Apply pending DB migrations, open connection pools, register workflows."""
|
|
35
|
+
await run_migrations(self.config.dsn, ssl=self.config.db_ssl)
|
|
36
|
+
|
|
37
|
+
self._state = PgStateBackend(dsn=self.config.dsn, ssl=self.config.db_ssl)
|
|
38
|
+
await self._state.initialize()
|
|
39
|
+
|
|
40
|
+
for name in self.registry.list_workflows():
|
|
41
|
+
await self._state.register_workflow(name, config={})
|
|
42
|
+
|
|
43
|
+
parsed = urllib.parse.urlparse(self.config.dsn)
|
|
44
|
+
self._queue = PgmqBackend(
|
|
45
|
+
host=parsed.hostname or "localhost",
|
|
46
|
+
port=str(parsed.port or 5432),
|
|
47
|
+
database=(parsed.path or "/postgres").lstrip("/"),
|
|
48
|
+
username=parsed.username or "postgres",
|
|
49
|
+
password=parsed.password or "postgres",
|
|
50
|
+
)
|
|
51
|
+
await self._queue.initialize()
|
|
52
|
+
await self._queue._ensure_queue(self.config.workflow_queue)
|
|
53
|
+
|
|
54
|
+
self._telemetry = (
|
|
55
|
+
PyflowsTelemetry.from_env(self.config.otel_service_name)
|
|
56
|
+
if self.config.otel_enabled
|
|
57
|
+
else PyflowsTelemetry.noop()
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
self._pg_durable_available = await self._state.check_extension("df")
|
|
61
|
+
|
|
62
|
+
self._worker = WorkflowWorker(
|
|
63
|
+
registry=self.registry,
|
|
64
|
+
state_backend=self._state,
|
|
65
|
+
queue_backend=self._queue,
|
|
66
|
+
telemetry=self._telemetry,
|
|
67
|
+
queue_name=self.config.workflow_queue,
|
|
68
|
+
plugins=self._plugins,
|
|
69
|
+
)
|
|
70
|
+
self._initialized = True
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def pg_durable_available(self) -> bool:
|
|
74
|
+
"""True if the pg_durable (df) extension is installed in the connected database."""
|
|
75
|
+
return self._pg_durable_available
|
|
76
|
+
|
|
77
|
+
async def start(self, workflow_fn: Callable, input_model: BaseModel) -> str:
|
|
78
|
+
"""Enqueue a workflow run. Returns instance_id."""
|
|
79
|
+
self._assert_initialized()
|
|
80
|
+
defn = self.registry.get_workflow(
|
|
81
|
+
getattr(workflow_fn, "_pyflows_name", workflow_fn.__name__)
|
|
82
|
+
)
|
|
83
|
+
input_dict = input_model.model_dump()
|
|
84
|
+
instance_id = await self._state.create_instance(defn.name, input_dict) # type: ignore[union-attr]
|
|
85
|
+
await self._queue.enqueue( # type: ignore[union-attr]
|
|
86
|
+
self.config.workflow_queue,
|
|
87
|
+
{"workflow_name": defn.name, "instance_id": instance_id, "input": input_dict},
|
|
88
|
+
)
|
|
89
|
+
return instance_id
|
|
90
|
+
|
|
91
|
+
async def get_status(self, instance_id: str) -> WorkflowStatus:
|
|
92
|
+
self._assert_initialized()
|
|
93
|
+
return await self._state.get_instance(instance_id) # type: ignore[union-attr]
|
|
94
|
+
|
|
95
|
+
async def list_workflows(
|
|
96
|
+
self,
|
|
97
|
+
workflow_name: str | None = None,
|
|
98
|
+
state: WorkflowState | None = None,
|
|
99
|
+
limit: int = 100,
|
|
100
|
+
) -> list[WorkflowStatus]:
|
|
101
|
+
self._assert_initialized()
|
|
102
|
+
return await self._state.list_instances(workflow_name, state, limit) # type: ignore[union-attr]
|
|
103
|
+
|
|
104
|
+
async def cancel(self, instance_id: str) -> None:
|
|
105
|
+
self._assert_initialized()
|
|
106
|
+
await self._state.cancel_workflow(instance_id) # type: ignore[union-attr]
|
|
107
|
+
|
|
108
|
+
async def run_worker(self) -> None:
|
|
109
|
+
"""Run the worker loop (blocking). Use asyncio.create_task for background."""
|
|
110
|
+
self._assert_initialized()
|
|
111
|
+
await self._worker.run() # type: ignore[union-attr]
|
|
112
|
+
|
|
113
|
+
async def process_once(self) -> int:
|
|
114
|
+
"""Process one batch of pending workflows. Useful for tests."""
|
|
115
|
+
self._assert_initialized()
|
|
116
|
+
return await self._worker.process_batch() # type: ignore[union-attr]
|
|
117
|
+
|
|
118
|
+
async def close(self) -> None:
|
|
119
|
+
if self._worker:
|
|
120
|
+
self._worker.shutdown()
|
|
121
|
+
if self._state:
|
|
122
|
+
await self._state.close()
|
|
123
|
+
if self._queue:
|
|
124
|
+
await self._queue.close()
|
|
125
|
+
self._initialized = False
|
|
126
|
+
|
|
127
|
+
def workflow(
|
|
128
|
+
self,
|
|
129
|
+
name: str | None = None,
|
|
130
|
+
step_defaults: RetryConfig | None = None,
|
|
131
|
+
) -> Callable:
|
|
132
|
+
def decorator(fn: Callable) -> Callable:
|
|
133
|
+
self.registry.register_workflow(fn, name=name, step_defaults=step_defaults)
|
|
134
|
+
return fn
|
|
135
|
+
|
|
136
|
+
return decorator
|
|
137
|
+
|
|
138
|
+
def register_plugin(self, plugin: PyflowsPlugin) -> None:
|
|
139
|
+
"""Register a plugin to receive workflow and step lifecycle hooks."""
|
|
140
|
+
self._plugins.append(plugin)
|
|
141
|
+
|
|
142
|
+
def step(
|
|
143
|
+
self,
|
|
144
|
+
name: str | None = None,
|
|
145
|
+
retry: RetryConfig | None = None,
|
|
146
|
+
timeout_seconds: float | None = None,
|
|
147
|
+
) -> Callable:
|
|
148
|
+
def decorator(fn: Callable) -> Callable:
|
|
149
|
+
self.registry.register_step(fn, name=name, retry=retry, timeout_seconds=timeout_seconds)
|
|
150
|
+
return fn
|
|
151
|
+
|
|
152
|
+
return decorator
|
|
153
|
+
|
|
154
|
+
def _assert_initialized(self) -> None:
|
|
155
|
+
if not self._initialized:
|
|
156
|
+
raise RuntimeError("WorkflowApp not initialized — call await app.initialize() first")
|
pyflows/backends/base.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pyflows.types import QueueMessage, ScheduledJob, WorkflowStatus
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OrchestratorBackend(ABC):
|
|
10
|
+
"""Drives durable workflow execution (start, signal, query, cancel)."""
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
async def initialize(self) -> None:
|
|
14
|
+
"""Set up extensions, schemas, and connection pools."""
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
async def start_workflow(
|
|
18
|
+
self,
|
|
19
|
+
workflow_id: str,
|
|
20
|
+
name: str,
|
|
21
|
+
payload: dict[str, Any],
|
|
22
|
+
) -> str:
|
|
23
|
+
"""Enqueue a new workflow run. Returns the workflow_id."""
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
async def signal_workflow(
|
|
27
|
+
self,
|
|
28
|
+
workflow_id: str,
|
|
29
|
+
signal: str,
|
|
30
|
+
data: dict[str, Any] | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Send a signal to a running workflow (e.g. step-completed)."""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
async def get_workflow_status(self, workflow_id: str) -> WorkflowStatus:
|
|
36
|
+
"""Return the current status of a workflow."""
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
async def cancel_workflow(self, workflow_id: str) -> None:
|
|
40
|
+
"""Request cancellation of a running workflow."""
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
async def close(self) -> None:
|
|
44
|
+
"""Release connections and clean up resources."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class QueueBackend(ABC):
|
|
48
|
+
"""Manages the Python step queue (enqueue, dequeue, ack/nack, listen)."""
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
async def initialize(self) -> None:
|
|
52
|
+
"""Create queues and install extensions if needed."""
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
async def enqueue(
|
|
56
|
+
self,
|
|
57
|
+
queue: str,
|
|
58
|
+
message: dict[str, Any],
|
|
59
|
+
delay_seconds: int = 0,
|
|
60
|
+
) -> str:
|
|
61
|
+
"""Push a message onto the queue. Returns the message_id."""
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
async def dequeue(self, queue: str, batch_size: int = 1) -> list[QueueMessage]:
|
|
65
|
+
"""Pull up to batch_size messages from the queue."""
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
async def ack(self, queue: str, message_id: str) -> None:
|
|
69
|
+
"""Acknowledge successful processing of a message."""
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
async def nack(self, queue: str, message_id: str) -> None:
|
|
73
|
+
"""Return a message to the queue for redelivery."""
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
async def archive(self, queue: str, message_id: str) -> None:
|
|
77
|
+
"""Move a message to the dead-letter archive (permanently removes from live queue)."""
|
|
78
|
+
|
|
79
|
+
@abstractmethod
|
|
80
|
+
async def close(self) -> None:
|
|
81
|
+
"""Release connections and clean up resources."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class SchedulerBackend(ABC):
|
|
85
|
+
"""Manages recurring workflow triggers via cron scheduling."""
|
|
86
|
+
|
|
87
|
+
@abstractmethod
|
|
88
|
+
async def initialize(self) -> None:
|
|
89
|
+
"""Install pg_cron extension and create schema if needed."""
|
|
90
|
+
|
|
91
|
+
@abstractmethod
|
|
92
|
+
async def schedule(
|
|
93
|
+
self,
|
|
94
|
+
job_name: str,
|
|
95
|
+
cron: str,
|
|
96
|
+
command: str,
|
|
97
|
+
) -> str:
|
|
98
|
+
"""Register a cron job. Returns the job_id (pg_durable instance_id)."""
|
|
99
|
+
|
|
100
|
+
@abstractmethod
|
|
101
|
+
async def unschedule(self, job_id: str) -> None:
|
|
102
|
+
"""Remove a previously registered cron job."""
|
|
103
|
+
|
|
104
|
+
@abstractmethod
|
|
105
|
+
async def list_jobs(self) -> list[ScheduledJob]:
|
|
106
|
+
"""Return all registered cron jobs."""
|
|
107
|
+
|
|
108
|
+
@abstractmethod
|
|
109
|
+
async def close(self) -> None:
|
|
110
|
+
"""Release connections and clean up resources."""
|