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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/pyflows)](https://pypi.org/project/pyflows/)
33
+ [![Python](https://img.shields.io/pypi/pyversions/pyflows)](https://pypi.org/project/pyflows/)
34
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.10.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
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")
@@ -0,0 +1,3 @@
1
+ from pyflows.backends.base import OrchestratorBackend, QueueBackend, SchedulerBackend
2
+
3
+ __all__ = ["OrchestratorBackend", "QueueBackend", "SchedulerBackend"]
@@ -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."""