nl2data-workflow-postgres 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.
- nl2data_workflow_postgres/__init__.py +25 -0
- nl2data_workflow_postgres/client.py +138 -0
- nl2data_workflow_postgres/config.py +83 -0
- nl2data_workflow_postgres/fake_postgres.py +834 -0
- nl2data_workflow_postgres/py.typed +0 -0
- nl2data_workflow_postgres/schema.py +90 -0
- nl2data_workflow_postgres/store.py +1166 -0
- nl2data_workflow_postgres-0.1.0.dist-info/METADATA +110 -0
- nl2data_workflow_postgres-0.1.0.dist-info/RECORD +11 -0
- nl2data_workflow_postgres-0.1.0.dist-info/WHEEL +5 -0
- nl2data_workflow_postgres-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
"""In-memory fake of the psycopg pool + PostgreSQL dialect for tests.
|
|
2
|
+
|
|
3
|
+
Executes the exact statement set produced by :data:`SQL_TEMPLATES` against
|
|
4
|
+
plain Python dictionaries, with per-connection transactions, row locks
|
|
5
|
+
(``FOR UPDATE`` / conflict waits), a mutable server clock driving ``NOW()``,
|
|
6
|
+
and failure injection so store behavior under backend outages can be tested
|
|
7
|
+
without a real database or the optional driver.
|
|
8
|
+
|
|
9
|
+
The fake mirrors the semantics the store relies on:
|
|
10
|
+
|
|
11
|
+
- every ``connection()`` checkout is one transaction; ``commit`` publishes
|
|
12
|
+
mutations, ``rollback`` restores the pre-transaction values of touched rows;
|
|
13
|
+
- rows locked by one connection block conflicting statements from another
|
|
14
|
+
connection (bounded by the statement timeout), matching PostgreSQL row
|
|
15
|
+
locking closely enough for concurrency tests;
|
|
16
|
+
- ``NOW()`` always reads the shared :class:`FakeClock`, so lease expiry,
|
|
17
|
+
takeover, and cleanup can be advanced deterministically;
|
|
18
|
+
- injected failure exceptions surface with the same class names the lazy
|
|
19
|
+
driver boundary classifies (``OperationalError``, ``TimeoutError``,
|
|
20
|
+
``UniqueViolation``, ``SerializationFailure``), so the store's error
|
|
21
|
+
normalization path is exercised for real.
|
|
22
|
+
|
|
23
|
+
Unrecognized statements fail loudly with ``AssertionError`` so template
|
|
24
|
+
drift between the store and the fake is caught at the first test run.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import builtins
|
|
30
|
+
import contextlib
|
|
31
|
+
import re
|
|
32
|
+
import threading
|
|
33
|
+
from collections.abc import Callable, Iterator
|
|
34
|
+
from datetime import UTC, datetime, timedelta
|
|
35
|
+
from typing import Any
|
|
36
|
+
from uuid import uuid4
|
|
37
|
+
|
|
38
|
+
from nl2data_core.workflow.models import WorkflowStatus
|
|
39
|
+
|
|
40
|
+
from .store import SQL_TEMPLATES
|
|
41
|
+
|
|
42
|
+
#: Terminal status values can never be overwritten by a CAS update.
|
|
43
|
+
_TERMINAL_STATUS_VALUES = frozenset(
|
|
44
|
+
status.value
|
|
45
|
+
for status in (
|
|
46
|
+
WorkflowStatus.SUCCEEDED,
|
|
47
|
+
WorkflowStatus.FAILED,
|
|
48
|
+
WorkflowStatus.CLOSED,
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
#: Fake driver exception class names intentionally match the real psycopg
|
|
54
|
+
#: classes so the store's class-name-first error classification works.
|
|
55
|
+
class OperationalError(Exception):
|
|
56
|
+
"""Fake ``psycopg.OperationalError``: connection-level failure."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TimeoutError(builtins.TimeoutError): # noqa: A001 - fake driver class by design
|
|
60
|
+
"""Fake ``psycopg.errors.QueryCanceled``: statement timed out."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class UniqueViolation(Exception):
|
|
64
|
+
"""Fake ``psycopg.errors.UniqueViolation``: unique-key conflict."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SerializationFailure(Exception):
|
|
68
|
+
"""Fake ``psycopg.errors.SerializationFailure``: retryable conflict."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class PoolClosed(Exception):
|
|
72
|
+
"""Fake ``psycopg_pool.PoolClosed``: pool was closed."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_SCHEMA_TOKEN = re.compile(r'"[A-Za-z0-9_]+"')
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _normalize(sql: str) -> str:
|
|
79
|
+
"""Collapse whitespace and mask the quoted schema for template matching."""
|
|
80
|
+
return _SCHEMA_TOKEN.sub('"S"', " ".join(sql.split()))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _as_dt(value: Any) -> datetime | None:
|
|
84
|
+
"""Accept datetimes or ISO strings (as the store sends them)."""
|
|
85
|
+
if value is None or isinstance(value, datetime):
|
|
86
|
+
return value
|
|
87
|
+
return datetime.fromisoformat(str(value))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class FakeClock:
|
|
91
|
+
"""Mutable deterministic clock standing in for PostgreSQL ``NOW()``."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, start: datetime | None = None) -> None:
|
|
94
|
+
self._now = start or datetime(2026, 1, 1, tzinfo=UTC)
|
|
95
|
+
|
|
96
|
+
def now(self) -> datetime:
|
|
97
|
+
"""The current fake server time (timezone-aware, UTC)."""
|
|
98
|
+
return self._now
|
|
99
|
+
|
|
100
|
+
def advance(self, seconds: float) -> None:
|
|
101
|
+
"""Move the clock forward by ``seconds`` (may be negative)."""
|
|
102
|
+
self._now += timedelta(seconds=seconds)
|
|
103
|
+
|
|
104
|
+
def set(self, value: datetime) -> None:
|
|
105
|
+
"""Pin the clock to an exact instant (tests seed expiry)."""
|
|
106
|
+
self._now = value
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class FakePostgresPool:
|
|
110
|
+
"""Thread-safe in-memory pool executing the store's SQL statement set.
|
|
111
|
+
|
|
112
|
+
Tables are keyed by ``(namespace, workflow_id)`` / ``(namespace, key)``
|
|
113
|
+
so tenant-scoped and unscoped records stay isolated exactly as the real
|
|
114
|
+
schema enforces. Expose ``states``/``idempotency``/``leases``/``clock``
|
|
115
|
+
for direct assertions in tests.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(self, *, start: datetime | None = None) -> None:
|
|
119
|
+
self.clock = FakeClock(start)
|
|
120
|
+
self.states: dict[tuple[str, str], dict[str, Any]] = {}
|
|
121
|
+
self.idempotency: dict[tuple[str, str], dict[str, Any]] = {}
|
|
122
|
+
self.leases: dict[tuple[str, str], dict[str, Any]] = {}
|
|
123
|
+
self.schema_metadata: dict[str, str] = {}
|
|
124
|
+
self._closed = False
|
|
125
|
+
self._struct_lock = threading.RLock()
|
|
126
|
+
self._lock_owner: dict[tuple[str, str, str], str] = {}
|
|
127
|
+
self._lock_cond = threading.Condition(self._struct_lock)
|
|
128
|
+
self._pending_failures: list[BaseException] = []
|
|
129
|
+
self._statement_names = {
|
|
130
|
+
_normalize(template.format(schema='"shared"')): name
|
|
131
|
+
for name, template in SQL_TEMPLATES.items()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
# -- lifecycle --------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
@contextlib.contextmanager
|
|
137
|
+
def connection(self) -> Iterator[_FakeConnection]:
|
|
138
|
+
"""One transaction-backed connection; ``commit``/``rollback`` explicit."""
|
|
139
|
+
with self._struct_lock:
|
|
140
|
+
if self._closed:
|
|
141
|
+
raise PoolClosed("fake pool is closed")
|
|
142
|
+
connection = _FakeConnection(self)
|
|
143
|
+
try:
|
|
144
|
+
yield connection
|
|
145
|
+
finally:
|
|
146
|
+
connection._finish()
|
|
147
|
+
|
|
148
|
+
def close(self) -> None:
|
|
149
|
+
"""Close the pool; later checkouts fail with ``PoolClosed``."""
|
|
150
|
+
with self._struct_lock:
|
|
151
|
+
self._closed = True
|
|
152
|
+
|
|
153
|
+
def set_schema_version(self, version: int) -> None:
|
|
154
|
+
"""Seed ``schema_metadata`` so store initialization sees a version."""
|
|
155
|
+
with self._struct_lock:
|
|
156
|
+
self.schema_metadata["schema_version"] = str(version)
|
|
157
|
+
|
|
158
|
+
def fail_next(self, error: BaseException, *, count: int = 1) -> None:
|
|
159
|
+
"""Make the next ``count`` executed statements raise ``error``."""
|
|
160
|
+
with self._struct_lock:
|
|
161
|
+
for _ in range(count):
|
|
162
|
+
self._pending_failures.append(error)
|
|
163
|
+
|
|
164
|
+
# -- row locking ------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def _lock(
|
|
167
|
+
self, connection: _FakeConnection, key: tuple[str, str, str], timeout: float
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Acquire a row lock, blocking while another connection holds it."""
|
|
170
|
+
deadline = _monotonic() + timeout
|
|
171
|
+
with self._lock_cond:
|
|
172
|
+
while self._lock_owner.get(key) not in (None, connection.id):
|
|
173
|
+
remaining = deadline - _monotonic()
|
|
174
|
+
if remaining <= 0:
|
|
175
|
+
raise OperationalError("lock wait timeout exceeded")
|
|
176
|
+
self._lock_cond.wait(remaining)
|
|
177
|
+
self._lock_owner[key] = connection.id
|
|
178
|
+
connection._owned_keys.add(key)
|
|
179
|
+
|
|
180
|
+
def _release(self, connection: _FakeConnection) -> None:
|
|
181
|
+
"""Release every row lock owned by the connection."""
|
|
182
|
+
with self._lock_cond:
|
|
183
|
+
for key in connection._owned_keys:
|
|
184
|
+
if self._lock_owner.get(key) == connection.id:
|
|
185
|
+
del self._lock_owner[key]
|
|
186
|
+
connection._owned_keys.clear()
|
|
187
|
+
self._lock_cond.notify_all()
|
|
188
|
+
|
|
189
|
+
# -- statement dispatch -----------------------------------------------
|
|
190
|
+
|
|
191
|
+
def _execute(
|
|
192
|
+
self,
|
|
193
|
+
connection: _FakeConnection,
|
|
194
|
+
sql: str,
|
|
195
|
+
params: tuple[Any, ...],
|
|
196
|
+
timeout: float,
|
|
197
|
+
) -> _FakeCursor:
|
|
198
|
+
with self._struct_lock:
|
|
199
|
+
if self._pending_failures:
|
|
200
|
+
raise self._pending_failures.pop(0)
|
|
201
|
+
normalized = _normalize(sql)
|
|
202
|
+
if normalized.startswith("CREATE "):
|
|
203
|
+
# DDL is emulated: tables exist by construction.
|
|
204
|
+
return _FakeCursor(self, connection, [], 0)
|
|
205
|
+
name = self._statement_names.get(normalized)
|
|
206
|
+
if name is None:
|
|
207
|
+
raise AssertionError(f"fake pool does not recognize statement: {sql[:240]}")
|
|
208
|
+
handler = _HANDLERS[name]
|
|
209
|
+
rows, rowcount = handler(self, connection, params or (), timeout)
|
|
210
|
+
return _FakeCursor(self, connection, rows, rowcount)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _monotonic() -> float:
|
|
214
|
+
import time
|
|
215
|
+
|
|
216
|
+
return time.monotonic()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class _FakeConnection:
|
|
220
|
+
"""One transaction; mutations are published on commit, undone on rollback."""
|
|
221
|
+
|
|
222
|
+
def __init__(self, pool: FakePostgresPool) -> None:
|
|
223
|
+
self._pool = pool
|
|
224
|
+
self._active = False
|
|
225
|
+
self._touched: dict[tuple[str, str, str], Any] = {}
|
|
226
|
+
self._owned_keys: set[tuple[str, str, str]] = set()
|
|
227
|
+
self.id = f"conn-{uuid4().hex[:12]}"
|
|
228
|
+
|
|
229
|
+
def cursor(self) -> _FakeCursor:
|
|
230
|
+
return _FakeCursor(self._pool, self, [], 0)
|
|
231
|
+
|
|
232
|
+
def execute(
|
|
233
|
+
self, sql: str, params: tuple[Any, ...] = (), timeout: float = 30.0
|
|
234
|
+
) -> _FakeCursor:
|
|
235
|
+
return self._pool._execute(self, sql, params, timeout)
|
|
236
|
+
|
|
237
|
+
def commit(self) -> None:
|
|
238
|
+
"""Publish the transaction; touched rows become visible to all."""
|
|
239
|
+
self._active = False
|
|
240
|
+
self._touched.clear()
|
|
241
|
+
self._pool._release(self)
|
|
242
|
+
|
|
243
|
+
def rollback(self) -> None:
|
|
244
|
+
"""Undo every mutation this transaction made."""
|
|
245
|
+
if not self._active:
|
|
246
|
+
self._pool._release(self)
|
|
247
|
+
return
|
|
248
|
+
tables = {
|
|
249
|
+
"states": self._pool.states,
|
|
250
|
+
"idempotency": self._pool.idempotency,
|
|
251
|
+
"leases": self._pool.leases,
|
|
252
|
+
}
|
|
253
|
+
for (kind, first, second), previous in self._touched.items():
|
|
254
|
+
table = tables[kind]
|
|
255
|
+
key = (first, second)
|
|
256
|
+
if previous is None:
|
|
257
|
+
table.pop(key, None)
|
|
258
|
+
else:
|
|
259
|
+
table[key] = previous
|
|
260
|
+
self._active = False
|
|
261
|
+
self._touched.clear()
|
|
262
|
+
self._pool._release(self)
|
|
263
|
+
|
|
264
|
+
def _finish(self) -> None:
|
|
265
|
+
"""Context-manager exit: drop any uncommitted transaction."""
|
|
266
|
+
if self._active:
|
|
267
|
+
self.rollback()
|
|
268
|
+
self._pool._release(self)
|
|
269
|
+
|
|
270
|
+
def _touch(self, key: tuple[str, str, str], value: Any) -> None:
|
|
271
|
+
"""Remember the pre-transaction value of a key before mutating it."""
|
|
272
|
+
if key not in self._touched:
|
|
273
|
+
kind, first, second = key
|
|
274
|
+
table = {
|
|
275
|
+
"states": self._pool.states,
|
|
276
|
+
"idempotency": self._pool.idempotency,
|
|
277
|
+
"leases": self._pool.leases,
|
|
278
|
+
}[kind]
|
|
279
|
+
self._touched[key] = table.get((first, second))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class _FakeCursor:
|
|
283
|
+
"""Cursor-shaped result of one executed statement."""
|
|
284
|
+
|
|
285
|
+
def __init__(
|
|
286
|
+
self,
|
|
287
|
+
pool: FakePostgresPool,
|
|
288
|
+
connection: _FakeConnection,
|
|
289
|
+
rows: list[dict[str, Any]],
|
|
290
|
+
rowcount: int,
|
|
291
|
+
) -> None:
|
|
292
|
+
self._pool = pool
|
|
293
|
+
self._connection = connection
|
|
294
|
+
self._rows = list(rows)
|
|
295
|
+
self.rowcount = rowcount
|
|
296
|
+
self.timeout: float = 30.0
|
|
297
|
+
|
|
298
|
+
def execute(
|
|
299
|
+
self, sql: str, params: tuple[Any, ...] = ()
|
|
300
|
+
) -> _FakeCursor:
|
|
301
|
+
return self._pool._execute(self._connection, sql, params, self.timeout)
|
|
302
|
+
|
|
303
|
+
def fetchone(self) -> dict[str, Any] | None:
|
|
304
|
+
return self._rows.pop(0) if self._rows else None
|
|
305
|
+
|
|
306
|
+
def fetchall(self) -> list[dict[str, Any]]:
|
|
307
|
+
rows, self._rows = self._rows, []
|
|
308
|
+
return rows
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# -- row-key helpers -------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
def _state_key(namespace: str, workflow_id: str) -> tuple[str, str, str]:
|
|
314
|
+
return ("states", namespace, workflow_id)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _idem_key(namespace: str, key: str) -> tuple[str, str, str]:
|
|
318
|
+
return ("idempotency", namespace, key)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _lease_key(namespace: str, workflow_id: str) -> tuple[str, str, str]:
|
|
322
|
+
return ("leases", namespace, workflow_id)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _state_columns() -> list[str]:
|
|
326
|
+
return [
|
|
327
|
+
"workflow_id",
|
|
328
|
+
"request_id",
|
|
329
|
+
"tenant_scope_fingerprint",
|
|
330
|
+
"scope_namespace",
|
|
331
|
+
"status",
|
|
332
|
+
"revision",
|
|
333
|
+
"schema_version",
|
|
334
|
+
"snapshot",
|
|
335
|
+
"created_at",
|
|
336
|
+
"updated_at",
|
|
337
|
+
"expires_at",
|
|
338
|
+
]
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _idem_columns() -> list[str]:
|
|
342
|
+
return [
|
|
343
|
+
"idempotency_key",
|
|
344
|
+
"request_id",
|
|
345
|
+
"tenant_scope_fingerprint",
|
|
346
|
+
"scope_namespace",
|
|
347
|
+
"workflow_id",
|
|
348
|
+
"status",
|
|
349
|
+
"terminal_outcome_fingerprint",
|
|
350
|
+
"created_at",
|
|
351
|
+
"updated_at",
|
|
352
|
+
"expires_at",
|
|
353
|
+
]
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _lease_columns() -> list[str]:
|
|
357
|
+
return [
|
|
358
|
+
"scope_namespace",
|
|
359
|
+
"workflow_id",
|
|
360
|
+
"owner_id",
|
|
361
|
+
"fencing_token",
|
|
362
|
+
"expires_at",
|
|
363
|
+
"updated_at",
|
|
364
|
+
]
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _lock_or_fail(
|
|
368
|
+
pool: FakePostgresPool,
|
|
369
|
+
connection: _FakeConnection,
|
|
370
|
+
key: tuple[str, str, str],
|
|
371
|
+
timeout: float,
|
|
372
|
+
) -> None:
|
|
373
|
+
pool._lock(connection, key, timeout)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _lease_valid(pool: FakePostgresPool, namespace: str, workflow_id: str, **match: Any) -> bool:
|
|
377
|
+
row = pool.leases.get((namespace, workflow_id))
|
|
378
|
+
if row is None:
|
|
379
|
+
return False
|
|
380
|
+
return all(row.get(field) == expected for field, expected in match.items())
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# -- statement handlers ----------------------------------------------------
|
|
384
|
+
#
|
|
385
|
+
# Each handler receives (pool, connection, params, timeout) and returns
|
|
386
|
+
# ``(rows, rowcount)`` where ``rows`` are dict rows for ``RETURNING`` /
|
|
387
|
+
# ``SELECT`` statements.
|
|
388
|
+
|
|
389
|
+
def _h_read_schema_version(
|
|
390
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
391
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
392
|
+
value = pool.schema_metadata.get("schema_version")
|
|
393
|
+
return ([{"value": value}] if value is not None else [], 0)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _h_write_schema_version(
|
|
397
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
398
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
399
|
+
pool.schema_metadata["schema_version"] = str(params[0])
|
|
400
|
+
return ([], 1)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _h_create_workflow(
|
|
404
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
405
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
406
|
+
namespace, workflow_id = params[3], params[0]
|
|
407
|
+
key = _state_key(namespace, workflow_id)
|
|
408
|
+
# Lock the key slot before checking existence so concurrent creates of the
|
|
409
|
+
# same workflow serialize like the real unique index (second one no-ops).
|
|
410
|
+
_lock_or_fail(pool, conn, key, timeout)
|
|
411
|
+
if (namespace, workflow_id) in pool.states:
|
|
412
|
+
return ([], 0)
|
|
413
|
+
row = {
|
|
414
|
+
"workflow_id": workflow_id,
|
|
415
|
+
"request_id": params[1],
|
|
416
|
+
"tenant_scope_fingerprint": params[2],
|
|
417
|
+
"scope_namespace": namespace,
|
|
418
|
+
"status": params[4],
|
|
419
|
+
"revision": 1,
|
|
420
|
+
"schema_version": params[5],
|
|
421
|
+
"snapshot": params[6],
|
|
422
|
+
"created_at": _as_dt(params[7]),
|
|
423
|
+
"updated_at": _as_dt(params[8]),
|
|
424
|
+
"expires_at": None,
|
|
425
|
+
}
|
|
426
|
+
_lock_or_fail(pool, conn, key, timeout)
|
|
427
|
+
conn._touch(key, None)
|
|
428
|
+
pool.states[(namespace, workflow_id)] = row
|
|
429
|
+
return ([], 1)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _h_read_snapshot(
|
|
433
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
434
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
435
|
+
row = pool.states.get((params[0], params[1]))
|
|
436
|
+
return ([{"snapshot": row["snapshot"]}] if row is not None else [], 0)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _h_read_checkpoint(
|
|
440
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
441
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
442
|
+
row = pool.states.get((params[0], params[1]))
|
|
443
|
+
if row is not None and row["request_id"] == params[2]:
|
|
444
|
+
return ([{"snapshot": row["snapshot"]}], 0)
|
|
445
|
+
return ([], 0)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _h_read_revision(
|
|
449
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
450
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
451
|
+
row = pool.states.get((params[0], params[1]))
|
|
452
|
+
return ([{"revision": row["revision"]}] if row is not None else [], 0)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _h_read_state_row(
|
|
456
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
457
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
458
|
+
key = _state_key(params[0], params[1])
|
|
459
|
+
row = pool.states.get((params[0], params[1]))
|
|
460
|
+
if row is not None:
|
|
461
|
+
_lock_or_fail(pool, conn, key, timeout)
|
|
462
|
+
return ([dict(row)] if row is not None else [], 0)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _h_update_state(
|
|
466
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
467
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
468
|
+
namespace, workflow_id = params[5], params[6]
|
|
469
|
+
key = _state_key(namespace, workflow_id)
|
|
470
|
+
_lock_or_fail(pool, conn, key, timeout)
|
|
471
|
+
row = pool.states.get((namespace, workflow_id))
|
|
472
|
+
if row is None:
|
|
473
|
+
return ([], 0)
|
|
474
|
+
if row["status"] != params[7]:
|
|
475
|
+
return ([], 0)
|
|
476
|
+
if row["tenant_scope_fingerprint"] != params[8]:
|
|
477
|
+
return ([], 0)
|
|
478
|
+
if params[9] is not None and row["revision"] != params[10]:
|
|
479
|
+
return ([], 0)
|
|
480
|
+
if row["status"] in _TERMINAL_STATUS_VALUES:
|
|
481
|
+
return ([], 0)
|
|
482
|
+
conn._touch(key, dict(row))
|
|
483
|
+
row.update(
|
|
484
|
+
request_id=params[0],
|
|
485
|
+
tenant_scope_fingerprint=params[1],
|
|
486
|
+
status=params[2],
|
|
487
|
+
revision=row["revision"] + 1,
|
|
488
|
+
snapshot=params[3],
|
|
489
|
+
updated_at=_as_dt(params[4]),
|
|
490
|
+
)
|
|
491
|
+
return ([], 1)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _h_update_state_fenced(
|
|
495
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
496
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
497
|
+
namespace, workflow_id = params[5], params[6]
|
|
498
|
+
key = _state_key(namespace, workflow_id)
|
|
499
|
+
_lock_or_fail(pool, conn, key, timeout)
|
|
500
|
+
row = pool.states.get((namespace, workflow_id))
|
|
501
|
+
if row is None:
|
|
502
|
+
return ([], 0)
|
|
503
|
+
if row["status"] != params[7]:
|
|
504
|
+
return ([], 0)
|
|
505
|
+
if params[8] is not None and row["revision"] != params[9]:
|
|
506
|
+
return ([], 0)
|
|
507
|
+
if row["status"] in _TERMINAL_STATUS_VALUES:
|
|
508
|
+
return ([], 0)
|
|
509
|
+
lease = pool.leases.get((params[14], params[15]))
|
|
510
|
+
if lease is None:
|
|
511
|
+
return ([], 0)
|
|
512
|
+
if lease["owner_id"] != params[16] or lease["fencing_token"] != params[17]:
|
|
513
|
+
return ([], 0)
|
|
514
|
+
tolerance = timedelta(seconds=float(params[18]))
|
|
515
|
+
if lease["expires_at"] <= pool.clock.now() + tolerance:
|
|
516
|
+
return ([], 0)
|
|
517
|
+
conn._touch(key, dict(row))
|
|
518
|
+
row.update(
|
|
519
|
+
request_id=params[0],
|
|
520
|
+
tenant_scope_fingerprint=params[1],
|
|
521
|
+
status=params[2],
|
|
522
|
+
revision=row["revision"] + 1,
|
|
523
|
+
snapshot=params[3],
|
|
524
|
+
updated_at=_as_dt(params[4]),
|
|
525
|
+
)
|
|
526
|
+
return ([], 1)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _h_list_ids(
|
|
530
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
531
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
532
|
+
namespace = params[0]
|
|
533
|
+
rows = [
|
|
534
|
+
{"workflow_id": workflow_id}
|
|
535
|
+
for (ns, workflow_id) in sorted(pool.states)
|
|
536
|
+
if ns == namespace
|
|
537
|
+
]
|
|
538
|
+
return (rows, len(rows))
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _h_delete_states_batch(
|
|
542
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
543
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
544
|
+
terminal = set(params[0:3])
|
|
545
|
+
cutoff = _as_dt(params[3])
|
|
546
|
+
limit = int(params[4])
|
|
547
|
+
candidates = [
|
|
548
|
+
((row["updated_at"], workflow_id), (namespace, workflow_id))
|
|
549
|
+
for (namespace, workflow_id), row in pool.states.items()
|
|
550
|
+
if row["status"] in terminal and row["updated_at"] <= cutoff
|
|
551
|
+
]
|
|
552
|
+
removed = 0
|
|
553
|
+
for _, key in sorted(candidates)[:limit]:
|
|
554
|
+
_lock_or_fail(pool, conn, _state_key(*key), timeout)
|
|
555
|
+
row = pool.states.get(key)
|
|
556
|
+
if row is None:
|
|
557
|
+
continue
|
|
558
|
+
conn._touch(_state_key(*key), dict(row))
|
|
559
|
+
del pool.states[key]
|
|
560
|
+
removed += 1
|
|
561
|
+
return ([], removed)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _h_delete_idempotency_batch(
|
|
565
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
566
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
567
|
+
cutoff = _as_dt(params[0])
|
|
568
|
+
limit = int(params[1])
|
|
569
|
+
candidates = [
|
|
570
|
+
((row["expires_at"], key), (namespace, key))
|
|
571
|
+
for (namespace, key), row in pool.idempotency.items()
|
|
572
|
+
if row["expires_at"] is not None and row["expires_at"] <= cutoff
|
|
573
|
+
]
|
|
574
|
+
removed = 0
|
|
575
|
+
for _, key in sorted(candidates)[:limit]:
|
|
576
|
+
_lock_or_fail(pool, conn, _idem_key(*key), timeout)
|
|
577
|
+
row = pool.idempotency.get(key)
|
|
578
|
+
if row is None:
|
|
579
|
+
continue
|
|
580
|
+
conn._touch(_idem_key(*key), dict(row))
|
|
581
|
+
del pool.idempotency[key]
|
|
582
|
+
removed += 1
|
|
583
|
+
return ([], removed)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _h_delete_leases_batch(
|
|
587
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
588
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
589
|
+
cutoff = _as_dt(params[0])
|
|
590
|
+
limit = int(params[1])
|
|
591
|
+
candidates = [
|
|
592
|
+
((row["expires_at"], workflow_id), (namespace, workflow_id))
|
|
593
|
+
for (namespace, workflow_id), row in pool.leases.items()
|
|
594
|
+
if row["expires_at"] <= cutoff
|
|
595
|
+
]
|
|
596
|
+
removed = 0
|
|
597
|
+
for _, key in sorted(candidates)[:limit]:
|
|
598
|
+
_lock_or_fail(pool, conn, _lease_key(*key), timeout)
|
|
599
|
+
row = pool.leases.get(key)
|
|
600
|
+
if row is None:
|
|
601
|
+
continue
|
|
602
|
+
conn._touch(_lease_key(*key), dict(row))
|
|
603
|
+
del pool.leases[key]
|
|
604
|
+
removed += 1
|
|
605
|
+
return ([], removed)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _h_insert_idempotency(
|
|
609
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
610
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
611
|
+
namespace, key = params[3], params[0]
|
|
612
|
+
lock_key = _idem_key(namespace, key)
|
|
613
|
+
# Lock the key slot before checking existence so concurrent reservations
|
|
614
|
+
# of the same key serialize like the real unique index (second one no-ops).
|
|
615
|
+
_lock_or_fail(pool, conn, lock_key, timeout)
|
|
616
|
+
if (namespace, key) in pool.idempotency:
|
|
617
|
+
return ([], 0)
|
|
618
|
+
row = {
|
|
619
|
+
"idempotency_key": key,
|
|
620
|
+
"request_id": params[1],
|
|
621
|
+
"tenant_scope_fingerprint": params[2],
|
|
622
|
+
"scope_namespace": namespace,
|
|
623
|
+
"workflow_id": params[4],
|
|
624
|
+
"status": params[5],
|
|
625
|
+
"terminal_outcome_fingerprint": None,
|
|
626
|
+
"created_at": _as_dt(params[6]),
|
|
627
|
+
"updated_at": _as_dt(params[7]),
|
|
628
|
+
"expires_at": _as_dt(params[8]),
|
|
629
|
+
}
|
|
630
|
+
conn._touch(lock_key, None)
|
|
631
|
+
pool.idempotency[(namespace, key)] = row
|
|
632
|
+
return ([dict(row)], 1)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _h_read_idempotency_row(
|
|
636
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
637
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
638
|
+
namespace, key = params[0], params[1]
|
|
639
|
+
row = pool.idempotency.get((namespace, key))
|
|
640
|
+
if row is not None:
|
|
641
|
+
_lock_or_fail(pool, conn, _idem_key(namespace, key), timeout)
|
|
642
|
+
return ([dict(row)] if row is not None else [], 0)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _h_read_idempotency_plain(
|
|
646
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
647
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
648
|
+
row = pool.idempotency.get((params[0], params[1]))
|
|
649
|
+
return ([dict(row)] if row is not None else [], 0)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _h_delete_idempotency(
|
|
653
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
654
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
655
|
+
namespace, key = params[0], params[1]
|
|
656
|
+
lock_key = _idem_key(namespace, key)
|
|
657
|
+
_lock_or_fail(pool, conn, lock_key, timeout)
|
|
658
|
+
row = pool.idempotency.get((namespace, key))
|
|
659
|
+
if row is None:
|
|
660
|
+
return ([], 0)
|
|
661
|
+
conn._touch(lock_key, dict(row))
|
|
662
|
+
del pool.idempotency[(namespace, key)]
|
|
663
|
+
return ([], 1)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _h_complete_idempotency(
|
|
667
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
668
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
669
|
+
namespace, key = params[4], params[5]
|
|
670
|
+
lock_key = _idem_key(namespace, key)
|
|
671
|
+
_lock_or_fail(pool, conn, lock_key, timeout)
|
|
672
|
+
row = pool.idempotency.get((namespace, key))
|
|
673
|
+
if row is None or row["status"] != "reserved":
|
|
674
|
+
return ([], 0)
|
|
675
|
+
conn._touch(lock_key, dict(row))
|
|
676
|
+
row.update(
|
|
677
|
+
workflow_id=params[0],
|
|
678
|
+
status=params[1],
|
|
679
|
+
terminal_outcome_fingerprint=params[2],
|
|
680
|
+
updated_at=_as_dt(params[3]),
|
|
681
|
+
)
|
|
682
|
+
return ([dict(row)], 1)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _h_complete_idempotency_fenced(
|
|
686
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
687
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
688
|
+
namespace, key = params[4], params[5]
|
|
689
|
+
lock_key = _idem_key(namespace, key)
|
|
690
|
+
_lock_or_fail(pool, conn, lock_key, timeout)
|
|
691
|
+
row = pool.idempotency.get((namespace, key))
|
|
692
|
+
if row is None or row["status"] != "reserved":
|
|
693
|
+
return ([], 0)
|
|
694
|
+
lease = pool.leases.get((params[6], params[7]))
|
|
695
|
+
if lease is None:
|
|
696
|
+
return ([], 0)
|
|
697
|
+
if lease["owner_id"] != params[8] or lease["fencing_token"] != params[9]:
|
|
698
|
+
return ([], 0)
|
|
699
|
+
tolerance = timedelta(seconds=float(params[10]))
|
|
700
|
+
if lease["expires_at"] <= pool.clock.now() + tolerance:
|
|
701
|
+
return ([], 0)
|
|
702
|
+
conn._touch(lock_key, dict(row))
|
|
703
|
+
row.update(
|
|
704
|
+
workflow_id=params[0],
|
|
705
|
+
status=params[1],
|
|
706
|
+
terminal_outcome_fingerprint=params[2],
|
|
707
|
+
updated_at=_as_dt(params[3]),
|
|
708
|
+
)
|
|
709
|
+
return ([dict(row)], 1)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _h_acquire_lease(
|
|
713
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
714
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
715
|
+
namespace, workflow_id = params[0], params[1]
|
|
716
|
+
key = _lease_key(namespace, workflow_id)
|
|
717
|
+
_lock_or_fail(pool, conn, key, timeout)
|
|
718
|
+
tolerance = timedelta(seconds=float(params[4]))
|
|
719
|
+
now = pool.clock.now()
|
|
720
|
+
row = pool.leases.get((namespace, workflow_id))
|
|
721
|
+
if row is None:
|
|
722
|
+
new_row = {
|
|
723
|
+
"scope_namespace": namespace,
|
|
724
|
+
"workflow_id": workflow_id,
|
|
725
|
+
"owner_id": params[2],
|
|
726
|
+
"fencing_token": 1,
|
|
727
|
+
"expires_at": _as_dt(params[3]),
|
|
728
|
+
"updated_at": now,
|
|
729
|
+
}
|
|
730
|
+
conn._touch(key, None)
|
|
731
|
+
pool.leases[(namespace, workflow_id)] = new_row
|
|
732
|
+
return ([dict(new_row)], 1)
|
|
733
|
+
if row["expires_at"] <= now - tolerance:
|
|
734
|
+
conn._touch(key, dict(row))
|
|
735
|
+
previous_token = row["fencing_token"]
|
|
736
|
+
row.update(
|
|
737
|
+
owner_id=params[2],
|
|
738
|
+
fencing_token=previous_token + 1,
|
|
739
|
+
expires_at=_as_dt(params[3]),
|
|
740
|
+
updated_at=now,
|
|
741
|
+
)
|
|
742
|
+
return ([dict(row)], 1)
|
|
743
|
+
return ([], 0)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _h_renew_lease(
|
|
747
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
748
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
749
|
+
namespace, workflow_id = params[1], params[2]
|
|
750
|
+
key = _lease_key(namespace, workflow_id)
|
|
751
|
+
_lock_or_fail(pool, conn, key, timeout)
|
|
752
|
+
row = pool.leases.get((namespace, workflow_id))
|
|
753
|
+
if row is None:
|
|
754
|
+
return ([], 0)
|
|
755
|
+
if row["owner_id"] != params[3] or row["fencing_token"] != params[4]:
|
|
756
|
+
return ([], 0)
|
|
757
|
+
tolerance = timedelta(seconds=float(params[5]))
|
|
758
|
+
if row["expires_at"] <= pool.clock.now() + tolerance:
|
|
759
|
+
return ([], 0)
|
|
760
|
+
conn._touch(key, dict(row))
|
|
761
|
+
row.update(expires_at=_as_dt(params[0]), updated_at=pool.clock.now())
|
|
762
|
+
return ([dict(row)], 1)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _h_release_lease(
|
|
766
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
767
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
768
|
+
namespace, workflow_id = params[0], params[1]
|
|
769
|
+
key = _lease_key(namespace, workflow_id)
|
|
770
|
+
_lock_or_fail(pool, conn, key, timeout)
|
|
771
|
+
row = pool.leases.get((namespace, workflow_id))
|
|
772
|
+
if row is None:
|
|
773
|
+
return ([], 0)
|
|
774
|
+
if row["owner_id"] != params[2] or row["fencing_token"] != params[3]:
|
|
775
|
+
return ([], 0)
|
|
776
|
+
conn._touch(key, dict(row))
|
|
777
|
+
del pool.leases[(namespace, workflow_id)]
|
|
778
|
+
return ([], 1)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _h_inspect_lease(
|
|
782
|
+
pool: FakePostgresPool, conn: _FakeConnection, params: tuple[Any, ...], timeout: float
|
|
783
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
784
|
+
row = pool.leases.get((params[0], params[1]))
|
|
785
|
+
if row is None:
|
|
786
|
+
return ([], 0)
|
|
787
|
+
return (
|
|
788
|
+
[
|
|
789
|
+
{
|
|
790
|
+
"owner_id": row["owner_id"],
|
|
791
|
+
"fencing_token": row["fencing_token"],
|
|
792
|
+
"expires_at": row["expires_at"],
|
|
793
|
+
"updated_at": row["updated_at"],
|
|
794
|
+
}
|
|
795
|
+
],
|
|
796
|
+
0,
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
_HANDLERS: dict[str, Callable[..., tuple[list[dict[str, Any]], int]]] = {
|
|
801
|
+
"read_schema_version": _h_read_schema_version,
|
|
802
|
+
"write_schema_version": _h_write_schema_version,
|
|
803
|
+
"create_workflow": _h_create_workflow,
|
|
804
|
+
"read_snapshot": _h_read_snapshot,
|
|
805
|
+
"read_checkpoint": _h_read_checkpoint,
|
|
806
|
+
"read_revision": _h_read_revision,
|
|
807
|
+
"read_state_row": _h_read_state_row,
|
|
808
|
+
"update_state": _h_update_state,
|
|
809
|
+
"update_state_fenced": _h_update_state_fenced,
|
|
810
|
+
"list_ids": _h_list_ids,
|
|
811
|
+
"delete_states_batch": _h_delete_states_batch,
|
|
812
|
+
"delete_idempotency_batch": _h_delete_idempotency_batch,
|
|
813
|
+
"delete_leases_batch": _h_delete_leases_batch,
|
|
814
|
+
"insert_idempotency": _h_insert_idempotency,
|
|
815
|
+
"read_idempotency_row": _h_read_idempotency_row,
|
|
816
|
+
"read_idempotency_plain": _h_read_idempotency_plain,
|
|
817
|
+
"delete_idempotency": _h_delete_idempotency,
|
|
818
|
+
"complete_idempotency": _h_complete_idempotency,
|
|
819
|
+
"complete_idempotency_fenced": _h_complete_idempotency_fenced,
|
|
820
|
+
"acquire_lease": _h_acquire_lease,
|
|
821
|
+
"renew_lease": _h_renew_lease,
|
|
822
|
+
"release_lease": _h_release_lease,
|
|
823
|
+
"inspect_lease": _h_inspect_lease,
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
__all__ = [
|
|
827
|
+
"FakeClock",
|
|
828
|
+
"FakePostgresPool",
|
|
829
|
+
"OperationalError",
|
|
830
|
+
"PoolClosed",
|
|
831
|
+
"SerializationFailure",
|
|
832
|
+
"TimeoutError",
|
|
833
|
+
"UniqueViolation",
|
|
834
|
+
]
|