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.
@@ -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
+ ]