nl2data-workflow-postgres 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.
- nl2data_workflow_postgres-0.1.0/PKG-INFO +110 -0
- nl2data_workflow_postgres-0.1.0/README.md +86 -0
- nl2data_workflow_postgres-0.1.0/pyproject.toml +56 -0
- nl2data_workflow_postgres-0.1.0/setup.cfg +4 -0
- nl2data_workflow_postgres-0.1.0/src/nl2data_workflow_postgres/__init__.py +25 -0
- nl2data_workflow_postgres-0.1.0/src/nl2data_workflow_postgres/client.py +138 -0
- nl2data_workflow_postgres-0.1.0/src/nl2data_workflow_postgres/config.py +83 -0
- nl2data_workflow_postgres-0.1.0/src/nl2data_workflow_postgres/fake_postgres.py +834 -0
- nl2data_workflow_postgres-0.1.0/src/nl2data_workflow_postgres/py.typed +0 -0
- nl2data_workflow_postgres-0.1.0/src/nl2data_workflow_postgres/schema.py +90 -0
- nl2data_workflow_postgres-0.1.0/src/nl2data_workflow_postgres/store.py +1166 -0
- nl2data_workflow_postgres-0.1.0/src/nl2data_workflow_postgres.egg-info/PKG-INFO +110 -0
- nl2data_workflow_postgres-0.1.0/src/nl2data_workflow_postgres.egg-info/SOURCES.txt +21 -0
- nl2data_workflow_postgres-0.1.0/src/nl2data_workflow_postgres.egg-info/dependency_links.txt +1 -0
- nl2data_workflow_postgres-0.1.0/src/nl2data_workflow_postgres.egg-info/requires.txt +8 -0
- nl2data_workflow_postgres-0.1.0/src/nl2data_workflow_postgres.egg-info/top_level.txt +1 -0
- nl2data_workflow_postgres-0.1.0/tests/test_workflow_postgres.py +409 -0
- nl2data_workflow_postgres-0.1.0/tests/test_workflow_postgres_build.py +33 -0
- nl2data_workflow_postgres-0.1.0/tests/test_workflow_postgres_concurrency.py +310 -0
- nl2data_workflow_postgres-0.1.0/tests/test_workflow_postgres_contract.py +533 -0
- nl2data_workflow_postgres-0.1.0/tests/test_workflow_postgres_import_boundary.py +48 -0
- nl2data_workflow_postgres-0.1.0/tests/test_workflow_postgres_integration.py +356 -0
- nl2data_workflow_postgres-0.1.0/tests/test_workflow_postgres_security.py +111 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nl2data-workflow-postgres
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PostgreSQL-backed durable workflow state backend for nl2data-core.
|
|
5
|
+
Author: NL2Data Contributors
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Keywords: nl2data,postgresql,workflow,state,lease,idempotency
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: nl2data-core>=0.1.0
|
|
18
|
+
Requires-Dist: psycopg[binary,pool]<4,>=3.1
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
22
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
23
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
24
|
+
|
|
25
|
+
# nl2data-workflow-postgres
|
|
26
|
+
|
|
27
|
+
Optional PostgreSQL-backed durable workflow state backend for `nl2data-core`.
|
|
28
|
+
|
|
29
|
+
## Overview
|
|
30
|
+
|
|
31
|
+
This package provides a production-ready PostgreSQL implementation of the core workflow state-store contracts (`StateStore`, `IdempotencyStore`, `WorkflowLeaseStore`, and `FencedStateStore`) from `nl2data-core`. It persists safe workflow snapshots, idempotency records, and execution leases in PostgreSQL while keeping `psycopg` lazy and optional.
|
|
32
|
+
|
|
33
|
+
Base `nl2data` / `nl2data-core` imports remain PostgreSQL-free; the driver is loaded only when a `PostgreSQLStateStore` is constructed.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install nl2data-workflow-postgres
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This also installs `psycopg[binary,pool]>=3.1,<4` and `nl2data-core>=0.1.0`.
|
|
42
|
+
|
|
43
|
+
## Quick Start
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from nl2data_core.workflow import WorkflowState
|
|
47
|
+
from nl2data_workflow_postgres import PostgreSQLStateStore, WorkflowPostgresConfig
|
|
48
|
+
|
|
49
|
+
store = PostgreSQLStateStore(
|
|
50
|
+
dsn="postgresql://user:pass@localhost/db",
|
|
51
|
+
config=WorkflowPostgresConfig(namespace="workflow"),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# store.create(state), store.update(...), store.acquire_lease(...), etc.
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Public surface
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from nl2data_workflow_postgres import (
|
|
61
|
+
MIGRATIONS,
|
|
62
|
+
SUPPORTED_SCHEMA_VERSION,
|
|
63
|
+
SQL_TEMPLATES,
|
|
64
|
+
PostgreSQLStateStore,
|
|
65
|
+
WorkflowPostgresConfig,
|
|
66
|
+
build_pool,
|
|
67
|
+
driver_available,
|
|
68
|
+
)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- `PostgreSQLStateStore` — the core store-contract implementation
|
|
72
|
+
(`StateStore`, `IdempotencyStore`, `WorkflowLeaseStore`, `FencedStateStore`)
|
|
73
|
+
with compare-and-set updates and fenced lease checks.
|
|
74
|
+
- `WorkflowPostgresConfig` — bounded pool/timeout/TTL configuration with a
|
|
75
|
+
derived tenant-scope namespace.
|
|
76
|
+
- `MIGRATIONS` / `SUPPORTED_SCHEMA_VERSION` — versioned DDL in a package-owned
|
|
77
|
+
schema namespace.
|
|
78
|
+
- `build_pool` / `driver_available` — lazy psycopg pool construction and
|
|
79
|
+
driver availability probes that never import the driver.
|
|
80
|
+
|
|
81
|
+
## Compatibility
|
|
82
|
+
|
|
83
|
+
The in-core `nl2data_core.workflow.postgres_*` modules were removed. All
|
|
84
|
+
PostgreSQL workflow state ships from this package; import from
|
|
85
|
+
`nl2data_workflow_postgres` directly. The core distribution no longer ships a
|
|
86
|
+
PostgreSQL workflow backend.
|
|
87
|
+
|
|
88
|
+
## Features
|
|
89
|
+
|
|
90
|
+
- **Durable workflow state** with versioned safe snapshots and compare-and-set semantics
|
|
91
|
+
- **Idempotency records** with reservation, conflict detection, and terminal outcome references
|
|
92
|
+
- **Lease ownership** with monotonic fencing tokens, renewal, release, and stale-worker takeover
|
|
93
|
+
- **Tenant-scope isolation** via opaque fingerprints and derived schema namespaces
|
|
94
|
+
- **Versioned migrations** in a package-owned schema namespace
|
|
95
|
+
- **Normalized errors** without leaking DSNs, credentials, or raw backend text
|
|
96
|
+
- **Lazy driver loading** so the base framework never imports `psycopg`
|
|
97
|
+
|
|
98
|
+
## Development
|
|
99
|
+
|
|
100
|
+
Install in editable mode from the repository root:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
pip install -e packages/nl2data-workflow-postgres
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Run package tests:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
pytest packages/nl2data-workflow-postgres/tests -q
|
|
110
|
+
```
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# nl2data-workflow-postgres
|
|
2
|
+
|
|
3
|
+
Optional PostgreSQL-backed durable workflow state backend for `nl2data-core`.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
This package provides a production-ready PostgreSQL implementation of the core workflow state-store contracts (`StateStore`, `IdempotencyStore`, `WorkflowLeaseStore`, and `FencedStateStore`) from `nl2data-core`. It persists safe workflow snapshots, idempotency records, and execution leases in PostgreSQL while keeping `psycopg` lazy and optional.
|
|
8
|
+
|
|
9
|
+
Base `nl2data` / `nl2data-core` imports remain PostgreSQL-free; the driver is loaded only when a `PostgreSQLStateStore` is constructed.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install nl2data-workflow-postgres
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
This also installs `psycopg[binary,pool]>=3.1,<4` and `nl2data-core>=0.1.0`.
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from nl2data_core.workflow import WorkflowState
|
|
23
|
+
from nl2data_workflow_postgres import PostgreSQLStateStore, WorkflowPostgresConfig
|
|
24
|
+
|
|
25
|
+
store = PostgreSQLStateStore(
|
|
26
|
+
dsn="postgresql://user:pass@localhost/db",
|
|
27
|
+
config=WorkflowPostgresConfig(namespace="workflow"),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# store.create(state), store.update(...), store.acquire_lease(...), etc.
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Public surface
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from nl2data_workflow_postgres import (
|
|
37
|
+
MIGRATIONS,
|
|
38
|
+
SUPPORTED_SCHEMA_VERSION,
|
|
39
|
+
SQL_TEMPLATES,
|
|
40
|
+
PostgreSQLStateStore,
|
|
41
|
+
WorkflowPostgresConfig,
|
|
42
|
+
build_pool,
|
|
43
|
+
driver_available,
|
|
44
|
+
)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- `PostgreSQLStateStore` — the core store-contract implementation
|
|
48
|
+
(`StateStore`, `IdempotencyStore`, `WorkflowLeaseStore`, `FencedStateStore`)
|
|
49
|
+
with compare-and-set updates and fenced lease checks.
|
|
50
|
+
- `WorkflowPostgresConfig` — bounded pool/timeout/TTL configuration with a
|
|
51
|
+
derived tenant-scope namespace.
|
|
52
|
+
- `MIGRATIONS` / `SUPPORTED_SCHEMA_VERSION` — versioned DDL in a package-owned
|
|
53
|
+
schema namespace.
|
|
54
|
+
- `build_pool` / `driver_available` — lazy psycopg pool construction and
|
|
55
|
+
driver availability probes that never import the driver.
|
|
56
|
+
|
|
57
|
+
## Compatibility
|
|
58
|
+
|
|
59
|
+
The in-core `nl2data_core.workflow.postgres_*` modules were removed. All
|
|
60
|
+
PostgreSQL workflow state ships from this package; import from
|
|
61
|
+
`nl2data_workflow_postgres` directly. The core distribution no longer ships a
|
|
62
|
+
PostgreSQL workflow backend.
|
|
63
|
+
|
|
64
|
+
## Features
|
|
65
|
+
|
|
66
|
+
- **Durable workflow state** with versioned safe snapshots and compare-and-set semantics
|
|
67
|
+
- **Idempotency records** with reservation, conflict detection, and terminal outcome references
|
|
68
|
+
- **Lease ownership** with monotonic fencing tokens, renewal, release, and stale-worker takeover
|
|
69
|
+
- **Tenant-scope isolation** via opaque fingerprints and derived schema namespaces
|
|
70
|
+
- **Versioned migrations** in a package-owned schema namespace
|
|
71
|
+
- **Normalized errors** without leaking DSNs, credentials, or raw backend text
|
|
72
|
+
- **Lazy driver loading** so the base framework never imports `psycopg`
|
|
73
|
+
|
|
74
|
+
## Development
|
|
75
|
+
|
|
76
|
+
Install in editable mode from the repository root:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install -e packages/nl2data-workflow-postgres
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Run package tests:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
pytest packages/nl2data-workflow-postgres/tests -q
|
|
86
|
+
```
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nl2data-workflow-postgres"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "PostgreSQL-backed durable workflow state backend for nl2data-core."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "NL2Data Contributors" }]
|
|
13
|
+
keywords = ["nl2data", "postgresql", "workflow", "state", "lease", "idempotency"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Software Development :: Libraries",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"nl2data-core>=0.1.0",
|
|
25
|
+
"psycopg[binary,pool]>=3.1,<4",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = [
|
|
30
|
+
"pytest>=8.0",
|
|
31
|
+
"pytest-asyncio>=0.23",
|
|
32
|
+
"mypy>=1.10",
|
|
33
|
+
"ruff>=0.5",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
where = ["src"]
|
|
38
|
+
|
|
39
|
+
[tool.mypy]
|
|
40
|
+
python_version = "3.11"
|
|
41
|
+
files = ["src"]
|
|
42
|
+
mypy_path = ["../../src"]
|
|
43
|
+
check_untyped_defs = true
|
|
44
|
+
disallow_untyped_defs = true
|
|
45
|
+
no_implicit_optional = true
|
|
46
|
+
warn_redundant_casts = true
|
|
47
|
+
warn_unused_ignores = true
|
|
48
|
+
warn_return_any = true
|
|
49
|
+
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
line-length = 100
|
|
52
|
+
target-version = "py311"
|
|
53
|
+
src = ["src"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint]
|
|
56
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""PostgreSQL-backed durable workflow state backend for nl2data-core.
|
|
2
|
+
|
|
3
|
+
The durable workflow runtime composes this backend through the replaceable
|
|
4
|
+
store contract. Optional dependencies (``psycopg``/``psycopg_pool``) are
|
|
5
|
+
loaded lazily; importing this module never imports the driver.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .client import build_pool, driver_available
|
|
11
|
+
from .config import WorkflowPostgresConfig
|
|
12
|
+
from .schema import MIGRATIONS, SUPPORTED_SCHEMA_VERSION
|
|
13
|
+
from .store import SQL_TEMPLATES, PostgreSQLStateStore
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"MIGRATIONS",
|
|
19
|
+
"SUPPORTED_SCHEMA_VERSION",
|
|
20
|
+
"SQL_TEMPLATES",
|
|
21
|
+
"PostgreSQLStateStore",
|
|
22
|
+
"WorkflowPostgresConfig",
|
|
23
|
+
"build_pool",
|
|
24
|
+
"driver_available",
|
|
25
|
+
]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Lazy optional psycopg driver boundary for the PostgreSQL workflow backend.
|
|
2
|
+
|
|
3
|
+
The ``psycopg``/``psycopg_pool`` packages are loaded only inside this module
|
|
4
|
+
through :func:`importlib.import_module`, so importing
|
|
5
|
+
``nl2data_workflow_postgres`` or the store module never imports a database
|
|
6
|
+
driver. The store accepts an injected pool (fake or host-managed) or a DSN;
|
|
7
|
+
the DSN path is constructed here with bounded connect/command timeouts and a
|
|
8
|
+
bounded pool, and DSNs are never included in errors. Driver errors are
|
|
9
|
+
classified by class name first (so injected fake clients work without the
|
|
10
|
+
driver installed) and by the real driver only when present.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from importlib import import_module
|
|
16
|
+
from importlib.util import find_spec
|
|
17
|
+
from typing import Any, cast
|
|
18
|
+
|
|
19
|
+
from nl2data_core.workflow.shared_errors import SharedStoreError, SharedStoreErrorCode
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def driver_available() -> bool:
|
|
23
|
+
"""Whether the optional ``psycopg`` driver (and pool) is installed."""
|
|
24
|
+
return find_spec("psycopg") is not None and find_spec("psycopg_pool") is not None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_pool(
|
|
28
|
+
dsn: str,
|
|
29
|
+
*,
|
|
30
|
+
pool_size: int,
|
|
31
|
+
connect_timeout_seconds: float,
|
|
32
|
+
command_timeout_seconds: float,
|
|
33
|
+
acquire_timeout_seconds: float,
|
|
34
|
+
schema: str,
|
|
35
|
+
) -> Any:
|
|
36
|
+
"""Lazily import the driver and build a bounded PostgreSQL connection pool.
|
|
37
|
+
|
|
38
|
+
Every checkout is pinned to the configured schema namespace through the
|
|
39
|
+
connection ``options`` so all unqualified table names resolve inside the
|
|
40
|
+
deployment's own schema. Raises a normalized ``STORE_UNAVAILABLE``
|
|
41
|
+
error when the driver is missing or the pool cannot be constructed; the
|
|
42
|
+
DSN and any driver exception text are never included in the error.
|
|
43
|
+
"""
|
|
44
|
+
if not driver_available():
|
|
45
|
+
raise SharedStoreError(
|
|
46
|
+
SharedStoreErrorCode.STORE_UNAVAILABLE,
|
|
47
|
+
"the psycopg driver is not installed; install the 'postgres' extra",
|
|
48
|
+
details={"cause_type": "ImportError"},
|
|
49
|
+
)
|
|
50
|
+
try:
|
|
51
|
+
pool_module = cast(Any, import_module("psycopg_pool"))
|
|
52
|
+
driver = cast(Any, import_module("psycopg"))
|
|
53
|
+
pool = pool_module.ConnectionPool(
|
|
54
|
+
dsn,
|
|
55
|
+
min_size=1,
|
|
56
|
+
max_size=pool_size,
|
|
57
|
+
open=True,
|
|
58
|
+
timeout=acquire_timeout_seconds,
|
|
59
|
+
kwargs={
|
|
60
|
+
"connect_timeout": connect_timeout_seconds,
|
|
61
|
+
"options": f"-c search_path={schema}",
|
|
62
|
+
"row_factory": driver.rows.dict_row,
|
|
63
|
+
},
|
|
64
|
+
)
|
|
65
|
+
except SharedStoreError:
|
|
66
|
+
raise
|
|
67
|
+
except Exception as error:
|
|
68
|
+
raise SharedStoreError(
|
|
69
|
+
SharedStoreErrorCode.STORE_UNAVAILABLE,
|
|
70
|
+
"the PostgreSQL connection pool could not be constructed",
|
|
71
|
+
details={"cause_type": type(error).__name__},
|
|
72
|
+
) from error
|
|
73
|
+
return pool
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_connect_error(error: BaseException) -> bool:
|
|
77
|
+
"""Whether the exception signals a lost or unavailable connection."""
|
|
78
|
+
if error.__class__.__name__ in {
|
|
79
|
+
"OperationalError",
|
|
80
|
+
"InterfaceError",
|
|
81
|
+
"ConnectionError",
|
|
82
|
+
"PoolTimeout",
|
|
83
|
+
"PoolClosed",
|
|
84
|
+
}:
|
|
85
|
+
return True
|
|
86
|
+
try:
|
|
87
|
+
pool_module = cast(Any, import_module("psycopg_pool"))
|
|
88
|
+
if isinstance(error, pool_module.PoolTimeout):
|
|
89
|
+
return True
|
|
90
|
+
except ImportError:
|
|
91
|
+
pass
|
|
92
|
+
try:
|
|
93
|
+
exceptions = cast(Any, import_module("psycopg"))
|
|
94
|
+
except ImportError:
|
|
95
|
+
return False
|
|
96
|
+
return isinstance(
|
|
97
|
+
error, (exceptions.OperationalError, exceptions.InterfaceError)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def is_timeout_error(error: BaseException) -> bool:
|
|
102
|
+
"""Whether the exception signals a query canceled by its timeout."""
|
|
103
|
+
if error.__class__.__name__ in {
|
|
104
|
+
"TimeoutError",
|
|
105
|
+
"QueryCanceledError",
|
|
106
|
+
"QueryCanceled",
|
|
107
|
+
"Timeout",
|
|
108
|
+
}:
|
|
109
|
+
return True
|
|
110
|
+
try:
|
|
111
|
+
exceptions = cast(Any, import_module("psycopg.errors"))
|
|
112
|
+
except ImportError:
|
|
113
|
+
return False
|
|
114
|
+
return isinstance(error, exceptions.QueryCanceled)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def is_duplicate_key_error(error: BaseException) -> bool:
|
|
118
|
+
"""Whether the exception signals a unique-key violation."""
|
|
119
|
+
if error.__class__.__name__ in {"UniqueViolation", "IntegrityError"}:
|
|
120
|
+
return True
|
|
121
|
+
try:
|
|
122
|
+
exceptions = cast(Any, import_module("psycopg.errors"))
|
|
123
|
+
except ImportError:
|
|
124
|
+
return False
|
|
125
|
+
return isinstance(error, exceptions.UniqueViolation)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def is_serialization_error(error: BaseException) -> bool:
|
|
129
|
+
"""Whether the exception signals a retryable transaction conflict."""
|
|
130
|
+
if error.__class__.__name__ in {"SerializationFailure", "DeadlockDetected"}:
|
|
131
|
+
return True
|
|
132
|
+
try:
|
|
133
|
+
exceptions = cast(Any, import_module("psycopg.errors"))
|
|
134
|
+
except ImportError:
|
|
135
|
+
return False
|
|
136
|
+
return isinstance(
|
|
137
|
+
error, (exceptions.SerializationFailure, exceptions.DeadlockDetected)
|
|
138
|
+
)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Bounded configuration for the PostgreSQL workflow state backend.
|
|
2
|
+
|
|
3
|
+
The configuration carries only behavior bounds - never connection strings
|
|
4
|
+
or credentials. A DSN (when the host manages its own pool) is passed to the
|
|
5
|
+
store constructor and is never part of the model, so a dumped configuration
|
|
6
|
+
cannot leak PostgreSQL endpoints or secrets. Every bound is validated at
|
|
7
|
+
construction so an empty schema namespace, an unsafe pool/timeout/TTL value,
|
|
8
|
+
or an impossible renewal margin fails before any client is built.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
14
|
+
|
|
15
|
+
from .schema import SUPPORTED_SCHEMA_VERSION
|
|
16
|
+
|
|
17
|
+
#: Schema namespace: bounded identifier so every derived table name stays safe.
|
|
18
|
+
_SCHEMA_PATTERN = r"^[A-Za-z][A-Za-z0-9_]{0,63}$"
|
|
19
|
+
|
|
20
|
+
#: Hard limits for the validated bounds below.
|
|
21
|
+
_MAX_POOL_SIZE = 64
|
|
22
|
+
_MAX_CONNECT_TIMEOUT_SECONDS = 30.0
|
|
23
|
+
_MAX_COMMAND_TIMEOUT_SECONDS = 120.0
|
|
24
|
+
_MAX_ACQUIRE_TIMEOUT_SECONDS = 60.0
|
|
25
|
+
_MAX_CLEANUP_BATCH = 10_000
|
|
26
|
+
_MAX_LEASE_TTL_SECONDS = 86_400.0 # one day
|
|
27
|
+
_MAX_RENEWAL_MARGIN_SECONDS = 3_600.0
|
|
28
|
+
_MAX_CLOCK_TOLERANCE_SECONDS = 60.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class WorkflowPostgresConfig(BaseModel):
|
|
32
|
+
"""Immutable bounded configuration for the PostgreSQL workflow backend.
|
|
33
|
+
|
|
34
|
+
``namespace`` is required and must be unique per application and
|
|
35
|
+
environment: it names the PostgreSQL schema that owns every workflow,
|
|
36
|
+
idempotency, and lease table, so two deployments sharing one database
|
|
37
|
+
service never observe each other's records. All other fields bound the
|
|
38
|
+
store's work so a pathological pool, timeout, cleanup pass, or lease
|
|
39
|
+
timing can never produce unbounded behavior.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
43
|
+
|
|
44
|
+
#: PostgreSQL schema owning all shared records (never a raw tenant id).
|
|
45
|
+
namespace: str = Field(pattern=_SCHEMA_PATTERN)
|
|
46
|
+
#: Bounded connection pool size of the lazy psycopg pool.
|
|
47
|
+
pool_size: int = Field(default=5, ge=1, le=_MAX_POOL_SIZE)
|
|
48
|
+
#: Bounded connect timeout for lazy pool connections (seconds).
|
|
49
|
+
connect_timeout_seconds: float = Field(
|
|
50
|
+
default=5.0, ge=0.1, le=_MAX_CONNECT_TIMEOUT_SECONDS
|
|
51
|
+
)
|
|
52
|
+
#: Bounded per-command timeout for every statement (seconds).
|
|
53
|
+
command_timeout_seconds: float = Field(
|
|
54
|
+
default=10.0, ge=0.1, le=_MAX_COMMAND_TIMEOUT_SECONDS
|
|
55
|
+
)
|
|
56
|
+
#: Bounded pool checkout timeout (seconds).
|
|
57
|
+
pool_acquire_timeout_seconds: float = Field(
|
|
58
|
+
default=5.0, ge=0.1, le=_MAX_ACQUIRE_TIMEOUT_SECONDS
|
|
59
|
+
)
|
|
60
|
+
#: Maximum schema version this store operates on; newer schemas reject.
|
|
61
|
+
schema_version: int = Field(
|
|
62
|
+
default=SUPPORTED_SCHEMA_VERSION, ge=1, le=SUPPORTED_SCHEMA_VERSION
|
|
63
|
+
)
|
|
64
|
+
#: Maximum records removed by one bounded cleanup pass.
|
|
65
|
+
cleanup_batch_size: int = Field(default=500, ge=1, le=_MAX_CLEANUP_BATCH)
|
|
66
|
+
#: Default lease TTL applied when an acquire call omits ``ttl_seconds``.
|
|
67
|
+
lease_ttl_seconds: float = Field(default=120.0, ge=1.0, le=_MAX_LEASE_TTL_SECONDS)
|
|
68
|
+
#: Renewal is attempted when less than this margin remains on the lease.
|
|
69
|
+
lease_renewal_margin_seconds: float = Field(
|
|
70
|
+
default=20.0, ge=0.1, le=_MAX_RENEWAL_MARGIN_SECONDS
|
|
71
|
+
)
|
|
72
|
+
#: Conservative clock-skew tolerance applied to every server-time check.
|
|
73
|
+
clock_tolerance_seconds: float = Field(
|
|
74
|
+
default=2.0, ge=0.0, le=_MAX_CLOCK_TOLERANCE_SECONDS
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@model_validator(mode="after")
|
|
78
|
+
def _consistent_lease_timing(self) -> WorkflowPostgresConfig:
|
|
79
|
+
if self.lease_renewal_margin_seconds >= self.lease_ttl_seconds:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
"lease renewal margin must be smaller than the lease TTL"
|
|
82
|
+
)
|
|
83
|
+
return self
|