litedbmodel-runtime 2.2.12__tar.gz → 2.2.14__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.
Files changed (36) hide show
  1. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/PKG-INFO +1 -1
  2. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/litedbmodel_runtime/__init__.py +3 -2
  3. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/litedbmodel_runtime/driver.py +18 -7
  4. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/litedbmodel_runtime/errors.py +22 -0
  5. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/litedbmodel_runtime/exec_context.py +95 -10
  6. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/litedbmodel_runtime.egg-info/PKG-INFO +1 -1
  7. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/pyproject.toml +1 -1
  8. litedbmodel_runtime-2.2.14/tests/test_transaction_boundary.py +616 -0
  9. litedbmodel_runtime-2.2.12/tests/test_transaction_boundary.py +0 -250
  10. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/README.md +0 -0
  11. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/litedbmodel_runtime/connection_routing.py +0 -0
  12. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/litedbmodel_runtime/dialect.py +0 -0
  13. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/litedbmodel_runtime/grouping.py +0 -0
  14. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/litedbmodel_runtime/leaves.py +0 -0
  15. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/litedbmodel_runtime/middleware.py +0 -0
  16. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/litedbmodel_runtime/tx_options.py +0 -0
  17. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/litedbmodel_runtime.egg-info/SOURCES.txt +0 -0
  18. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/litedbmodel_runtime.egg-info/dependency_links.txt +0 -0
  19. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/litedbmodel_runtime.egg-info/requires.txt +0 -0
  20. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/litedbmodel_runtime.egg-info/top_level.txt +0 -0
  21. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/setup.cfg +0 -0
  22. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/tests/test_conformance_corpus.py +0 -0
  23. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/tests/test_connection_routing.py +0 -0
  24. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/tests/test_connection_routing_livedb.py +0 -0
  25. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/tests/test_consumes_bc_core.py +0 -0
  26. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/tests/test_dialect.py +0 -0
  27. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/tests/test_dynamic_where.py +0 -0
  28. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/tests/test_exec_context.py +0 -0
  29. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/tests/test_grouping.py +0 -0
  30. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/tests/test_hard_limit_guard.py +0 -0
  31. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/tests/test_middleware.py +0 -0
  32. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/tests/test_middleware_livedb.py +0 -0
  33. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/tests/test_orm_bench_native.py +0 -0
  34. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/tests/test_render.py +0 -0
  35. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/tests/test_tx_boundary_livedb.py +0 -0
  36. {litedbmodel_runtime-2.2.12 → litedbmodel_runtime-2.2.14}/tests/test_tx_options.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: litedbmodel-runtime
3
- Version: 2.2.12
3
+ Version: 2.2.14
4
4
  Summary: Thin multi-language runtime for litedbmodel v2 SCP makeSQL bundles (Python port). Executes the published static-makeSQL artifacts (a read ReadGraph = a bc surrogate ComponentGraphIR + per-node static statement templates, or a gate-first TransactionPlan) against a SQL driver — semantics-identical to the TS reference. Delegates the Expression-IR evaluation + map/Φ/wiring orchestration to behavior-contracts.
5
5
  Author: foo-ogawa
6
6
  License: MIT
@@ -14,7 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  from .dialect import SQLITE, POSTGRES, MYSQL, Dialect, dialect_for, to_dollar_placeholders
16
16
  from .driver import Driver, MysqlDriver, PostgresDriver, PreparedStatement, RunInfo, SqliteDriver, TxConnection
17
- from .errors import LimitExceededError, SqlFailure, check_find_hard_limit, map_sqlite_error
17
+ from .errors import LimitExceededError, SqlFailure, TransactionClosedError, check_find_hard_limit, map_sqlite_error
18
18
  from .exec_context import (
19
19
  Connection,
20
20
  ExecutionContext,
@@ -104,7 +104,7 @@ from .connection_routing import (
104
104
  )
105
105
  from .leaves import make_handlers, render_placeholders
106
106
 
107
- __version__ = "2.2.12"
107
+ __version__ = "2.2.14"
108
108
 
109
109
  __all__ = [
110
110
  "__version__",
@@ -126,6 +126,7 @@ __all__ = [
126
126
  # errors
127
127
  "SqlFailure",
128
128
  "LimitExceededError",
129
+ "TransactionClosedError",
129
130
  "check_find_hard_limit",
130
131
  "map_sqlite_error",
131
132
  # ExecutionContext + central execute/run seam (Phase A / #78)
@@ -15,6 +15,8 @@ import re
15
15
  import sqlite3
16
16
  from typing import Any, Callable, Dict, List, Optional, Protocol, Sequence, Tuple
17
17
 
18
+ from .tx_options import is_connection_error
19
+
18
20
 
19
21
  # ── Row materialization: per-column-set dict builder (read-path hot loop, #63 §② perf) ──────────
20
22
  #
@@ -785,19 +787,28 @@ class _PooledDriver:
785
787
  """Run ``op(conn)`` on a freshly checked-out pooled connection (the non-tx read/write path)."""
786
788
  conn = self._pool.acquire()
787
789
  try:
788
- return op(conn)
789
- finally:
790
- self._pool.release(conn)
790
+ result = op(conn)
791
+ except BaseException as error:
792
+ # The pool's connections are autocommit, so a SQL error (constraint violation, syntax error,
793
+ # statement_timeout) leaves the session usable: return it. DISCARD only a connection error
794
+ # (dropped mid-statement) or a non-Exception interruption (KeyboardInterrupt etc. can land
795
+ # mid-statement), whose state is unknown. #330 defect 1.
796
+ if is_connection_error(error) or not isinstance(error, Exception):
797
+ self._pool.discard(conn)
798
+ else:
799
+ self._pool.release(conn)
800
+ raise
801
+ self._pool.release(conn)
802
+ return result
791
803
 
792
804
  def exec_ddl(self, statements: Sequence[str]) -> None:
793
- conn = self._pool.acquire()
794
- try:
805
+ def run_all(conn):
795
806
  cur = conn.cursor()
796
807
  for stmt in statements:
797
808
  cur.execute(stmt)
798
809
  cur.close()
799
- finally:
800
- self._pool.release(conn)
810
+
811
+ self._with_conn(run_all)
801
812
 
802
813
  def prepare(self, sql: str) -> _PooledPrepared:
803
814
  return _PooledPrepared(self, sql)
@@ -131,6 +131,28 @@ class LimitExceededError(Exception):
131
131
  raise cls(limit, count, context, model, relation)
132
132
 
133
133
 
134
+ class TransactionClosedError(Exception):
135
+ """A statement was issued on a transaction's connection AFTER the transaction settled (Python
136
+ counterpart of the TS ``TransactionClosedError``, #330 defect 3). The connection has already been
137
+ returned to the pool (or destroyed), so running the statement would hit a connection that now belongs
138
+ to another caller. Typically a statement started inside the transaction body (e.g. on a thread) that
139
+ was not awaited/joined before the body returned.
140
+
141
+ ``.name`` mirrors the JS ``Error.name`` like :class:`LimitExceededError`.
142
+ """
143
+
144
+ def __init__(self, unfinished: bool = False) -> None:
145
+ # ``unfinished``: a query started inside the transaction had not finished when the body settled
146
+ # (the transaction was rolled back); otherwise a query arrived after the body settled.
147
+ lead = (
148
+ "A query started inside the transaction had not finished when the transaction function settled."
149
+ if unfinished
150
+ else "Query issued after the transaction function settled."
151
+ )
152
+ super().__init__(f"{lead} Await every query started inside DBModel.transaction() before returning.")
153
+ self.name = "TransactionClosedError"
154
+
155
+
134
156
  def check_find_hard_limit(limit: int, count: int, model: Optional[str] = None) -> None:
135
157
  """The FIND-context runaway guard (Python port of the rust ``check_find_hard_limit`` / go
136
158
  ``CheckFindHardLimit``). The compile injects ``LIMIT hardLimit + 1``, so a fetched ``count`` of
@@ -58,17 +58,19 @@ the PG/MySQL drivers was exactly the shared-slot model that corrupts concurrent
58
58
  from __future__ import annotations
59
59
 
60
60
  import contextvars
61
+ import threading
61
62
  import time
62
63
  from typing import Any, Callable, List, Optional, Sequence, TypeVar, Union
63
64
 
64
65
  from .driver import Driver, RunInfo, TxConnection
65
- from .errors import SqlFailure, map_sqlite_error
66
+ from .errors import SqlFailure, TransactionClosedError, map_sqlite_error
66
67
  from .tx_options import (
67
68
  IsolationLevel,
68
69
  TransactionOptions,
69
70
  WriteInReadOnlyContextError,
70
71
  WriteOutsideTransactionError,
71
72
  check_write_allowed,
73
+ is_connection_error,
72
74
  is_retryable_tx_error,
73
75
  isolation_prelude,
74
76
  )
@@ -170,6 +172,52 @@ class _TxConnectionAdapter(Connection):
170
172
  return self._tx.run(sql, params)
171
173
 
172
174
 
175
+ class _SettleGuardConnection(Connection):
176
+ """The :class:`Connection` the transaction BODY's ctx pins: it forwards to the wrapped connection until
177
+ :meth:`settle`, then refuses every statement with :class:`TransactionClosedError` (#330). The lock is
178
+ held while a statement runs, so :meth:`settle` waits for one in flight and none slips in after it
179
+ returns; :meth:`settle` reports whether any statement was still unfinished (running or waiting for the
180
+ lock) at that moment. The runtime's own BEGIN/COMMIT/ROLLBACK go through an unwrapped connection."""
181
+
182
+ __slots__ = ("_conn", "_lock", "_state", "_closed", "_unfinished")
183
+
184
+ def __init__(self, conn: Connection) -> None:
185
+ self._conn = conn
186
+ self._lock = threading.Lock()
187
+ self._state = threading.Lock() # guards _closed and _unfinished
188
+ self._closed = False
189
+ self._unfinished = 0
190
+
191
+ def _guarded(self, call: Callable[[], _T]) -> _T:
192
+ with self._state:
193
+ if self._closed:
194
+ raise TransactionClosedError()
195
+ self._unfinished += 1
196
+ try:
197
+ with self._lock:
198
+ if self._closed:
199
+ raise TransactionClosedError()
200
+ return call()
201
+ finally:
202
+ with self._state:
203
+ self._unfinished -= 1
204
+
205
+ def execute(self, sql: str, params: Sequence[Any]) -> Rows:
206
+ return self._guarded(lambda: self._conn.execute(sql, params))
207
+
208
+ def run(self, sql: str, params: Sequence[Any]) -> RunInfo:
209
+ return self._guarded(lambda: self._conn.run(sql, params))
210
+
211
+ def settle(self) -> bool:
212
+ """Close the guard; return True when a statement was unfinished at that moment (idempotent)."""
213
+ with self._state:
214
+ self._closed = True
215
+ unfinished = self._unfinished > 0
216
+ with self._lock: # wait for the statement in flight
217
+ pass
218
+ return unfinished
219
+
220
+
173
221
  # ── Middleware chain (§4) — the hook point (empty in Phase A) ──────────────────
174
222
 
175
223
  # The terminal of a middleware chain: resolve the connection + execute (the seam's ②③).
@@ -574,13 +622,20 @@ def with_transaction_decided(
574
622
  rust ``with_transaction_decided`` (#76) / go ``WithTransactionDecided`` (#77).
575
623
  """
576
624
  tx = ctx.begin_tx()
577
- tx_ctx = ctx.with_connection(_TxConnectionAdapter(tx), True)
578
-
579
- # tx-control is issued THROUGH the seam on the PINNED ctx (so it resolves the owned tx connection AND
580
- # a registered middleware observes it — Phase D / #95). Uses the UNGUARDED `run` (not `run_guarded`)
581
- # ⇒ BEGIN/COMMIT/ROLLBACK/SET are exempt from the write=tx guard.
625
+ # The body's ctx pins the connection through a settle guard: once the body has settled, a ctx it
626
+ # leaked (e.g. onto a thread left running) is refused with TransactionClosedError instead of reaching
627
+ # the connection — in the COMMIT/ROLLBACK window it would run inside the tx and be committed. The
628
+ # runtime's own tx-control uses `control_ctx` (the unwrapped pin). #330.
629
+ adapter = _TxConnectionAdapter(tx)
630
+ guard = _SettleGuardConnection(adapter)
631
+ tx_ctx = ctx.with_connection(guard, True)
632
+ control_ctx = ctx.with_connection(adapter, True)
633
+
634
+ # tx-control is issued THROUGH the seam on the owned connection (so it resolves the owned tx connection
635
+ # AND a registered middleware observes it — Phase D / #95). Uses the UNGUARDED `run` (not
636
+ # `run_guarded`) ⇒ BEGIN/COMMIT/ROLLBACK/SET are exempt from the write=tx guard.
582
637
  def _tx_control(stmt: str) -> None:
583
- run(tx_ctx, stmt, [], WRITE_INTENT)
638
+ run(control_ctx, stmt, [], WRITE_INTENT)
584
639
 
585
640
  def scoped() -> Any:
586
641
  # `destroy` starts True: the connection is only proven clean once a COMMIT/ROLLBACK completes
@@ -598,7 +653,12 @@ def with_transaction_decided(
598
653
  _tx_control(stmt)
599
654
  try:
600
655
  decision = body(tx_ctx)
601
- except BaseException:
656
+ # The body returned: from here only the runtime's tx-control runs. settle() waits for a
657
+ # statement still in flight and reports whether one was unfinished.
658
+ unfinished = guard.settle()
659
+ except BaseException as body_exc:
660
+ # The body raised: settle() waits for a statement still in flight (idempotent).
661
+ body_unfinished = guard.settle()
602
662
  # A body error rolls back (BEST-EFFORT) then re-raises the ORIGINAL failure. A rollback
603
663
  # that itself raises must NOT mask the body error — swallow it but keep destroy=True so
604
664
  # the finally drops the poisoned connection. A clean rollback ⇒ back to the pool.
@@ -607,7 +667,18 @@ def with_transaction_decided(
607
667
  destroy = False
608
668
  except Exception:
609
669
  pass # poisoned; destroy stays True. Original body error surfaces via `raise`.
670
+ if body_unfinished:
671
+ # A statement started inside the body had not finished when it failed: surface
672
+ # TransactionClosedError (the body error is its __cause__) so the retry decision does
673
+ # not depend on the body's error and the body is never re-run. #330.
674
+ raise TransactionClosedError(unfinished=True) from body_exc
610
675
  raise
676
+ if unfinished:
677
+ # A statement started inside the body had not finished when it returned: never COMMIT it
678
+ # (the settle waited for it, so the connection is idle) — roll back and fail. #330.
679
+ _tx_control("ROLLBACK")
680
+ destroy = False
681
+ raise TransactionClosedError(unfinished=True)
611
682
  if decision.rollback:
612
683
  # A legitimate non-error rollback (e.g. a gate short-circuit): roll back, return value.
613
684
  # A rollbackOnly (dry-run) tx committed NOTHING ⇒ it does NOT arm writer-stickiness.
@@ -763,8 +834,12 @@ def transaction(
763
834
  # ONE attempt on a FRESH owned connection (a retry after a connection error thus RECONNECTS).
764
835
  # `fn` reads the pinned tx ctx from the ambient contextvar (run_with_pinned_context inside
765
836
  # with_transaction_decided), so every op JOINs this one connection.
837
+ body_returned = False
838
+
766
839
  def body(_tx_ctx: ExecutionContext) -> TxDecision:
840
+ nonlocal body_returned
767
841
  value = fn()
842
+ body_returned = True
768
843
  # rollback_only (dry-run): ROLLBACK but still return the body value — no committed change.
769
844
  return rollback(value) if opts.rollback_only else commit(value)
770
845
 
@@ -772,7 +847,7 @@ def transaction(
772
847
  return with_transaction_decided(
773
848
  tx_ctx, body, before, after, opts.use_writer_after_transaction
774
849
  )
775
- except (WriteOutsideTransactionError, WriteInReadOnlyContextError):
850
+ except (WriteOutsideTransactionError, WriteInReadOnlyContextError, TransactionClosedError):
776
851
  # A guard rejection is a programming error, never retryable — re-raise immediately.
777
852
  raise
778
853
  except Exception as error:
@@ -784,7 +859,17 @@ def transaction(
784
859
  # live retry path (neuter `.wrapped` → this classification goes RED). An already-mapped
785
860
  # `SqlFailure` (e.g. surfaced by the guarded write seam) is left as-is (no re-map).
786
861
  failure = error if isinstance(error, SqlFailure) else map_sqlite_error(error)
787
- if attempt < retry_limit and opts.retry_on_error and is_retryable_tx_error(failure):
862
+ # A failure after the body returned is the COMMIT stage (a rollback_only tx has none). A
863
+ # connection error there may have reached the server after the COMMIT took effect, so
864
+ # re-running the body could write twice: surface it. A serialization failure / deadlock at
865
+ # COMMIT means the tx was not committed and stays retryable. #330 defect 2.
866
+ commit_connection_error = body_returned and not opts.rollback_only and is_connection_error(failure)
867
+ if (
868
+ attempt < retry_limit
869
+ and opts.retry_on_error
870
+ and not commit_connection_error
871
+ and is_retryable_tx_error(failure)
872
+ ):
788
873
  # Exponential backoff before RETRYing the whole transaction on a fresh connection.
789
874
  backoff_ms = opts.retry_duration * (2 ** (attempt - 1))
790
875
  if backoff_ms > 0:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: litedbmodel-runtime
3
- Version: 2.2.12
3
+ Version: 2.2.14
4
4
  Summary: Thin multi-language runtime for litedbmodel v2 SCP makeSQL bundles (Python port). Executes the published static-makeSQL artifacts (a read ReadGraph = a bc surrogate ComponentGraphIR + per-node static statement templates, or a gate-first TransactionPlan) against a SQL driver — semantics-identical to the TS reference. Delegates the Expression-IR evaluation + map/Φ/wiring orchestration to behavior-contracts.
5
5
  Author: foo-ogawa
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "litedbmodel-runtime"
7
- version = "2.2.12"
7
+ version = "2.2.14"
8
8
  description = "Thin multi-language runtime for litedbmodel v2 SCP makeSQL bundles (Python port). Executes the published static-makeSQL artifacts (a read ReadGraph = a bc surrogate ComponentGraphIR + per-node static statement templates, or a gate-first TransactionPlan) against a SQL driver — semantics-identical to the TS reference. Delegates the Expression-IR evaluation + map/Φ/wiring orchestration to behavior-contracts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -0,0 +1,616 @@
1
+ """Phase B-core (#84/#86, python) — UNIT tests for the public transaction() boundary (in-proc SQLite).
2
+
3
+ No live DB — an in-proc stdlib ``sqlite3`` driver (wrapped in a recording shim that counts begin_tx /
4
+ commit / rollback) proves the boundary mechanics that DON'T need PG/MySQL. Every write is issued
5
+ through the PRODUCTION guarded write seam (:func:`run_guarded` on the ambient pinned tx ctx) — the same
6
+ seam a user-facing write inside ``transaction()`` rides:
7
+
8
+ (1) MULTI-OP ATOMICITY — transaction(lambda: [opA_insert(); opB_insert()]) → both commit; the
9
+ recording driver asserts EXACTLY ONE begin_tx / ONE commit / ONE tx handle for the whole boundary
10
+ (the ambient JOIN — opB opens no second BEGIN). opB PK-collides → opA's row ALSO rolls back (ONE
11
+ begin_tx + ONE rollback, zero commit), verified by reading real rows.
12
+ (2) GUARD — a write OUTSIDE transaction() → WriteOutsideTransactionError; a read-only write inside →
13
+ WriteInReadOnlyContextError; inside a boundary → ok.
14
+ (3) NESTED — one begin_tx/commit; an inner error rolls back the whole tx.
15
+ (4) rollback_only — the body runs + returns its value, but NOTHING commits (dry-run).
16
+
17
+ The live-PG/MySQL isolation + real-contention-retry proof lives in test_tx_boundary_livedb.py.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import sqlite3
23
+ import threading
24
+
25
+ import pytest
26
+
27
+ from litedbmodel_runtime import (
28
+ IsolationLevel,
29
+ TransactionOptions,
30
+ WriteInReadOnlyContextError,
31
+ WriteOutsideTransactionError,
32
+ context_for_driver,
33
+ current_context,
34
+ run_guarded,
35
+ run_with_pinned_context,
36
+ transaction,
37
+ )
38
+ from litedbmodel_runtime.driver import SqliteDriver, _SqliteTxConnection
39
+
40
+
41
+ ISO_TBL = "scp_tx_boundary_py"
42
+ _INSERT_SQL = f"INSERT INTO {ISO_TBL} (id, worker, seq) VALUES (?, ?, ?)"
43
+
44
+
45
+ # ── A recording SQLite driver: counts begin_tx / commit / rollback / distinct handles ──
46
+
47
+
48
+ class _RecTx(_SqliteTxConnection):
49
+ """New contract (Phase D / #95): tx-control flows through :meth:`run` (issued by the combinator via
50
+ the seam), so COMMIT/ROLLBACK are counted HERE when their SQL passes through — not in removed
51
+ ``commit``/``rollback`` methods. BEGIN is likewise a ``run`` call (counted in the driver's begin_tx
52
+ sink for the ambient-JOIN count, and issued via the seam)."""
53
+
54
+ def __init__(self, conn, sink):
55
+ super().__init__(conn)
56
+ self._sink = sink
57
+
58
+ def run(self, sql, params):
59
+ head = sql.strip().split(" ", 1)[0].upper()
60
+ if head == "COMMIT":
61
+ self._sink["commits"] += 1
62
+ elif head == "ROLLBACK":
63
+ self._sink["rolls"] += 1
64
+ return super().run(sql, params)
65
+
66
+
67
+ class _RecSqliteDriver(SqliteDriver):
68
+ """A SqliteDriver that records tx lifecycle into a shared sink dict (begin_tx / commit / rollback /
69
+ the set of distinct tx handles). Single-conn SQLite, so the whole boundary shares ONE conn — the
70
+ counts prove the ambient JOIN (N ops = ONE begin_tx)."""
71
+
72
+ def __init__(self, conn, sink):
73
+ super().__init__(conn)
74
+ self._sink = sink
75
+
76
+ def begin_tx(self):
77
+ # New contract: acquire + OWN the conn only; BEGIN is issued by the combinator THROUGH the seam
78
+ # (via _RecTx.run). The begin_tx COUNT still proves the ambient JOIN (N ops = ONE begin_tx).
79
+ self._sink["begin_tx"] += 1
80
+ tx = _RecTx(self.conn, self._sink)
81
+ self._sink["distinct"].add(id(tx))
82
+ return tx
83
+
84
+
85
+ def _fresh_sink():
86
+ return {"begin_tx": 0, "commits": 0, "rolls": 0, "distinct": set()}
87
+
88
+
89
+ def _make_driver(sink):
90
+ conn = sqlite3.connect(":memory:")
91
+ conn.execute(f"CREATE TABLE {ISO_TBL} (id INTEGER PRIMARY KEY, worker INTEGER NOT NULL, seq INTEGER NOT NULL)")
92
+ conn.commit()
93
+ return _RecSqliteDriver(conn, sink)
94
+
95
+
96
+ def _do_op(id_, worker, seq):
97
+ """One guarded INSERT through the PRODUCTION guarded write seam (:func:`run_guarded`) on the ambient
98
+ pinned tx ctx — the same path a user-facing write inside ``transaction()`` rides. Outside a boundary
99
+ the ambient pin is absent, so the guard fires (:class:`WriteOutsideTransactionError`)."""
100
+ return run_guarded(current_context(), _INSERT_SQL, [id_, worker, seq], "WRITE", ISO_TBL)
101
+
102
+
103
+ def _read_rows(driver):
104
+ rows = driver.prepare(f"SELECT id, worker FROM {ISO_TBL} WHERE worker <> 999").all([])
105
+ return sorted((int(r["id"]), int(r["worker"])) for r in rows)
106
+
107
+
108
+ # ── (1) MULTI-OP ATOMICITY — commit path ───────────────────────────────────────
109
+
110
+
111
+ def test_multi_op_boundary_one_begin_one_commit():
112
+ sink = _fresh_sink()
113
+ driver = _make_driver(sink)
114
+ ctx = context_for_driver(driver)
115
+
116
+ result = transaction(ctx, lambda: [_do_op(100, 1, 0), _do_op(101, 1, 1)], TransactionOptions(), "sqlite")
117
+
118
+ assert [r.changes for r in result] == [1, 1]
119
+ # N ops in one boundary ⇒ ONE begin_tx + ONE commit on ONE tx handle (the ambient JOIN).
120
+ assert sink["begin_tx"] == 1, f"expected 1 begin_tx, got {sink['begin_tx']}"
121
+ assert sink["commits"] == 1, f"expected 1 commit, got {sink['commits']}"
122
+ assert sink["rolls"] == 0
123
+ assert len(sink["distinct"]) == 1, f"expected 1 distinct tx handle, got {len(sink['distinct'])}"
124
+ assert _read_rows(driver) == [(100, 1), (101, 1)]
125
+
126
+
127
+ # ── (1) MULTI-OP ATOMICITY — rollback path (B fails ⇒ A also rolls back) ────────
128
+
129
+
130
+ def test_multi_op_boundary_opB_fail_rolls_back_opA():
131
+ sink = _fresh_sink()
132
+ driver = _make_driver(sink)
133
+ driver.conn.execute(f"INSERT INTO {ISO_TBL} (id, worker, seq) VALUES (201, 999, 9)") # pre-seed collision
134
+ driver.conn.commit()
135
+ ctx = context_for_driver(driver)
136
+
137
+ with pytest.raises(Exception):
138
+ transaction(ctx, lambda: [_do_op(200, 2, 0), _do_op(201, 2, 1)], TransactionOptions(retry_on_error=False), "sqlite")
139
+
140
+ assert sink["begin_tx"] == 1
141
+ assert sink["commits"] == 0
142
+ assert sink["rolls"] == 1, f"opB failure ⇒ ONE rollback, got {sink['rolls']}"
143
+ # opA (id=200) must ALSO have rolled back — the whole boundary is atomic. Both ops JOINed the ONE
144
+ # ambient tx via run_guarded on the pinned ctx, so opB's PK collision rolls the whole boundary back.
145
+ assert _read_rows(driver) == [], "opA must roll back when opB fails (cross-op atomicity)"
146
+
147
+
148
+ # ── (2) write=tx GUARD ──────────────────────────────────────────────────────────
149
+
150
+
151
+ def test_guard_outside_boundary_rejects_write():
152
+ sink = _fresh_sink()
153
+ driver = _make_driver(sink)
154
+ context_for_driver(driver)
155
+ # A bare guarded write OUTSIDE any transaction() → WriteOutsideTransactionError (the ambient pin is
156
+ # absent, so current_context() is None and the guard fires before any SQL), nothing written.
157
+ with pytest.raises(WriteOutsideTransactionError):
158
+ _do_op(300, 3, 0)
159
+ assert _read_rows(driver) == []
160
+
161
+
162
+ def test_guard_read_only_inside_boundary_rejects_write():
163
+ sink = _fresh_sink()
164
+ driver = _make_driver(sink)
165
+ ctx = context_for_driver(driver)
166
+ # Read-only-scoped write inside a boundary → WriteInReadOnlyContextError (read-only checked first).
167
+
168
+ def body():
169
+ ambient = current_context() # the pinned tx ctx
170
+ ro = ambient.with_read_only()
171
+ # Pin the read-only ctx as the ambient so the guard (which reads the ambient markers) sees it.
172
+ return run_with_pinned_context(ro, lambda: _do_op(301, 3, 0))
173
+
174
+ with pytest.raises(WriteInReadOnlyContextError):
175
+ transaction(ctx, body, TransactionOptions(retry_on_error=False), "sqlite")
176
+
177
+
178
+ def test_guard_inside_boundary_allows_write():
179
+ sink = _fresh_sink()
180
+ driver = _make_driver(sink)
181
+ ctx = context_for_driver(driver)
182
+ r = transaction(ctx, lambda: _do_op(302, 3, 0), TransactionOptions(), "sqlite")
183
+ assert r.changes == 1
184
+ assert _read_rows(driver) == [(302, 3)]
185
+
186
+
187
+ # ── (3) NESTED transaction = one begin/commit ──────────────────────────────────
188
+
189
+
190
+ def test_nested_transaction_one_begin_commit():
191
+ sink = _fresh_sink()
192
+ driver = _make_driver(sink)
193
+ ctx = context_for_driver(driver)
194
+
195
+ def outer():
196
+ _do_op(500, 5, 0)
197
+ # A NESTED transaction() JOINs the outer — no new begin_tx/commit.
198
+ return transaction(ctx, lambda: _do_op(501, 5, 1), TransactionOptions(), "sqlite")
199
+
200
+ transaction(ctx, outer, TransactionOptions(), "sqlite")
201
+ assert sink["begin_tx"] == 1
202
+ assert sink["commits"] == 1
203
+ assert sink["rolls"] == 0
204
+ assert _read_rows(driver) == [(500, 5), (501, 5)]
205
+
206
+
207
+ def test_nested_inner_error_rolls_back_whole_tx():
208
+ sink = _fresh_sink()
209
+ driver = _make_driver(sink)
210
+ driver.conn.execute(f"INSERT INTO {ISO_TBL} (id, worker, seq) VALUES (601, 999, 9)") # collision for inner
211
+ driver.conn.commit()
212
+ ctx = context_for_driver(driver)
213
+
214
+ def outer():
215
+ _do_op(600, 6, 0)
216
+ return transaction(ctx, lambda: _do_op(601, 6, 1), TransactionOptions(), "sqlite")
217
+
218
+ with pytest.raises(Exception):
219
+ transaction(ctx, outer, TransactionOptions(retry_on_error=False), "sqlite")
220
+ assert sink["commits"] == 0
221
+ assert sink["rolls"] == 1
222
+ assert _read_rows(driver) == [], "an inner error rolls back the WHOLE tx (id=600 absent)"
223
+
224
+
225
+ # ── (4) rollback_only (dry-run) ────────────────────────────────────────────────
226
+
227
+
228
+ def test_rollback_only_returns_value_but_commits_nothing():
229
+ sink = _fresh_sink()
230
+ driver = _make_driver(sink)
231
+ ctx = context_for_driver(driver)
232
+ r = transaction(ctx, lambda: _do_op(700, 7, 0), TransactionOptions(rollback_only=True), "sqlite")
233
+ assert r.changes == 1 # the body's own view: its statement ran (before the boundary rolled back)
234
+ # …but the boundary ROLLED BACK, so nothing persisted.
235
+ assert sink["begin_tx"] == 1
236
+ assert sink["commits"] == 0
237
+ assert sink["rolls"] == 1
238
+ assert _read_rows(driver) == [], "rollback_only must commit nothing"
239
+
240
+
241
+ # ── SQLite isolation is a hard error at the boundary ───────────────────────────
242
+
243
+
244
+ def test_sqlite_isolation_request_is_a_hard_error():
245
+ sink = _fresh_sink()
246
+ driver = _make_driver(sink)
247
+ ctx = context_for_driver(driver)
248
+ with pytest.raises(ValueError):
249
+ transaction(ctx, lambda: None, TransactionOptions(isolation=IsolationLevel.SERIALIZABLE), "sqlite")
250
+ # The hard-error fires BEFORE any connection is acquired.
251
+ assert sink["begin_tx"] == 0
252
+
253
+
254
+ # ── #330 transaction-safety defects (2.x Python) ───────────────────────────────
255
+
256
+
257
+ class _CommitFailsTx(_SqliteTxConnection):
258
+ """A tx handle whose COMMIT raises the next error in ``errors`` (then commits normally)."""
259
+
260
+ def __init__(self, conn, errors):
261
+ super().__init__(conn)
262
+ self._errors = errors
263
+
264
+ def run(self, sql, params):
265
+ if sql.strip().split(" ", 1)[0].upper() == "COMMIT" and self._errors:
266
+ super().run("ROLLBACK", params) # a real connection error leaves no open tx to retry into
267
+ raise self._errors.pop(0)
268
+ return super().run(sql, params)
269
+
270
+
271
+ class _CommitFailsDriver(SqliteDriver):
272
+ def __init__(self, conn, errors):
273
+ super().__init__(conn)
274
+ self._errors = errors
275
+
276
+ def begin_tx(self):
277
+ return _CommitFailsTx(self.conn, self._errors)
278
+
279
+
280
+ class _FakeSerializationError(Exception):
281
+ sqlstate = "40001"
282
+
283
+
284
+ def _run_with_commit_errors(errors):
285
+ conn = sqlite3.connect(":memory:")
286
+ conn.execute(f"CREATE TABLE {ISO_TBL} (id INTEGER PRIMARY KEY, worker INTEGER NOT NULL, seq INTEGER NOT NULL)")
287
+ conn.commit()
288
+ ctx = context_for_driver(_CommitFailsDriver(conn, errors))
289
+ runs = []
290
+
291
+ def body():
292
+ runs.append(1)
293
+ return _do_op(800 + len(runs), 8, 0)
294
+
295
+ opts = TransactionOptions(retry_limit=3, retry_duration=0)
296
+ try:
297
+ transaction(ctx, body, opts, "sqlite")
298
+ return len(runs), None
299
+ except Exception as e:
300
+ return len(runs), e
301
+
302
+
303
+ def test_commit_connection_error_does_not_rerun_the_body():
304
+ # The COMMIT may have reached the server before the connection dropped: re-running the body could
305
+ # write twice. The error surfaces after ONE run of the body.
306
+ runs, error = _run_with_commit_errors([RuntimeError("server closed the connection unexpectedly")] * 3)
307
+ assert runs == 1, f"the body must not re-run after a COMMIT connection error, ran {runs}x"
308
+ assert error is not None and "server closed the connection" in str(error)
309
+
310
+
311
+ def test_commit_serialization_failure_is_still_retried():
312
+ runs, error = _run_with_commit_errors([_FakeSerializationError("could not serialize access")])
313
+ assert error is None
314
+ assert runs == 2
315
+
316
+
317
+ def _recording_pool():
318
+ closed = []
319
+
320
+ class _Conn:
321
+ def close(self):
322
+ closed.append(self)
323
+
324
+ from litedbmodel_runtime.driver import _ConnectionPool
325
+
326
+ return _ConnectionPool(_Conn, 2), closed
327
+
328
+
329
+ def test_errored_connection_outside_a_tx_is_discarded_not_returned_to_the_pool():
330
+ from litedbmodel_runtime.driver import _PooledDriver
331
+
332
+ pool, closed = _recording_pool()
333
+ driver = _PooledDriver(pool, lambda s: s, False)
334
+
335
+ def failing(conn):
336
+ raise RuntimeError("server closed the connection unexpectedly")
337
+
338
+ with pytest.raises(RuntimeError):
339
+ driver._with_conn(failing)
340
+ assert len(closed) == 1, "the errored connection must be closed"
341
+ assert pool._free == [], "the errored connection must not go back to the pool"
342
+ assert pool._opened == 0, "its slot must be freed"
343
+ # A clean statement still returns its connection to the pool.
344
+ driver._with_conn(lambda conn: None)
345
+ assert len(pool._free) == 1
346
+
347
+
348
+ def test_sql_error_outside_a_tx_returns_the_connection_to_the_pool_for_reuse():
349
+ from litedbmodel_runtime.driver import _PooledDriver
350
+
351
+ pool, closed = _recording_pool()
352
+ driver = _PooledDriver(pool, lambda s: s, False)
353
+ seen = []
354
+
355
+ class _ConstraintViolation(Exception):
356
+ sqlstate = "23505"
357
+
358
+ def violating(conn):
359
+ seen.append(conn)
360
+ raise _ConstraintViolation('duplicate key value violates unique constraint "t_pkey"')
361
+
362
+ # The pool's connections are autocommit, so the session is still usable after a SQL error.
363
+ with pytest.raises(_ConstraintViolation):
364
+ driver._with_conn(violating)
365
+ assert closed == [], "a SQL error must not close the connection"
366
+ assert [c for c, _ in pool._free] == seen, "the connection must go back to the pool"
367
+ assert pool._opened == 1
368
+ driver._with_conn(lambda conn: seen.append(conn))
369
+ assert seen[1] is seen[0], "the next statement must reuse the same connection"
370
+ assert pool._opened == 1
371
+
372
+
373
+ def test_interrupted_statement_outside_a_tx_discards_the_connection():
374
+ from litedbmodel_runtime.driver import _PooledDriver
375
+
376
+ pool, closed = _recording_pool()
377
+ driver = _PooledDriver(pool, lambda s: s, False)
378
+
379
+ def interrupted(conn):
380
+ raise KeyboardInterrupt() # not an Exception: it can land mid-statement
381
+
382
+ with pytest.raises(KeyboardInterrupt):
383
+ driver._with_conn(interrupted)
384
+ assert len(closed) == 1 and pool._free == [] and pool._opened == 0
385
+
386
+
387
+ # ── A statement from a ctx that outlived the body is refused (sqlite and pg paths) ──────────────────
388
+
389
+
390
+ class _WindowTx(_SqliteTxConnection):
391
+ """A sqlite tx handle that runs ``window()`` just before the COMMIT statement reaches the DB."""
392
+
393
+ def __init__(self, conn, window, gate):
394
+ super().__init__(conn)
395
+ self._window = window
396
+ self._gate = gate
397
+
398
+ def run(self, sql, params):
399
+ if sql.strip().upper().startswith("COMMIT"):
400
+ self._window()
401
+ self._gate(sql, params)
402
+ return super().run(sql, params)
403
+
404
+
405
+ class _WindowSqliteDriver(SqliteDriver):
406
+ def __init__(self, conn, window, gate):
407
+ super().__init__(conn)
408
+ self._window = window
409
+ self._gate = gate
410
+
411
+ def begin_tx(self):
412
+ return _WindowTx(self.conn, self._window, self._gate)
413
+
414
+
415
+ class _FakeCursor:
416
+ rowcount = 1
417
+ lastrowid = None
418
+
419
+ def __init__(self, conn):
420
+ self._conn = conn
421
+
422
+ def execute(self, sql, params=()):
423
+ if sql.strip().upper().startswith("COMMIT"):
424
+ self._conn.window()
425
+ self._conn.gate(sql, params)
426
+ self._conn.sent.append((sql, tuple(params)))
427
+
428
+ def close(self):
429
+ pass
430
+
431
+
432
+ class _FakePgConn:
433
+ """A DB-API connection (the pg/mysql pooled path) that records every statement it is sent."""
434
+
435
+ def __init__(self, sent, window, gate):
436
+ self.sent = sent
437
+ self.window = window
438
+ self.gate = gate
439
+
440
+ def cursor(self):
441
+ return _FakeCursor(self)
442
+
443
+ def close(self):
444
+ pass
445
+
446
+
447
+ def _no_gate(sql, params):
448
+ pass
449
+
450
+
451
+ def _sqlite_leak_ctx(window, gate=_no_gate):
452
+ conn = sqlite3.connect(":memory:", check_same_thread=False)
453
+ conn.execute(f"CREATE TABLE {ISO_TBL} (id INTEGER PRIMARY KEY, worker INTEGER NOT NULL, seq INTEGER NOT NULL)")
454
+ conn.commit()
455
+
456
+ def sent_ids():
457
+ return [int(r[0]) for r in conn.execute(f"SELECT id FROM {ISO_TBL}")]
458
+
459
+ return context_for_driver(_WindowSqliteDriver(conn, window, gate)), "sqlite", sent_ids
460
+
461
+
462
+ def _pg_leak_ctx(window, gate=_no_gate):
463
+ from litedbmodel_runtime.driver import _ConnectionPool, _PooledDriver
464
+
465
+ sent = []
466
+ pool = _ConnectionPool(lambda: _FakePgConn(sent, window, gate), 2)
467
+ driver = _PooledDriver(pool, lambda s: s, False)
468
+
469
+ def sent_ids():
470
+ return [params[0] for sql, params in sent if sql.startswith("INSERT")]
471
+
472
+ return context_for_driver(driver), "postgres", sent_ids
473
+
474
+
475
+ @pytest.mark.parametrize("make_ctx", [_sqlite_leak_ctx, _pg_leak_ctx], ids=["sqlite", "pg"])
476
+ def test_statement_from_a_leaked_ctx_is_refused_between_the_body_and_the_commit(make_ctx):
477
+ from litedbmodel_runtime import WRITE_INTENT, run
478
+ from litedbmodel_runtime.errors import TransactionClosedError
479
+
480
+ leaked = []
481
+ outcomes = []
482
+
483
+ def attempt():
484
+ try:
485
+ run(leaked[0], _INSERT_SQL, [999, 9, 9], WRITE_INTENT)
486
+ outcomes.append(None)
487
+ except Exception as e:
488
+ outcomes.append(e)
489
+
490
+ ctx, dialect, sent_ids = make_ctx(attempt)
491
+
492
+ def body():
493
+ leaked.append(current_context()) # the ctx escapes the body (e.g. onto a thread left running)
494
+ return _do_op(1, 1, 0)
495
+
496
+ transaction(ctx, body, TransactionOptions(), dialect)
497
+
498
+ # Between the body's return and the COMMIT (the window), and again after the connection was released.
499
+ attempt()
500
+ expected = (
501
+ "Query issued after the transaction function settled. "
502
+ "Await every query started inside DBModel.transaction() before returning."
503
+ )
504
+ assert len(outcomes) == 2
505
+ for outcome in outcomes:
506
+ assert isinstance(outcome, TransactionClosedError), f"got {outcome!r}"
507
+ assert str(outcome) == expected
508
+ assert 999 not in sent_ids(), "the refused statement must never reach the DB"
509
+ assert 1 in sent_ids()
510
+
511
+
512
+ # ── A statement still running when the body settles fails the transaction (sqlite and pg paths) ──────
513
+
514
+
515
+ def _gated_ctx(make_ctx):
516
+ """A ctx whose statement for row id 777 blocks inside the DB until ``release`` is set; ``statements``
517
+ records the verb of every statement the DB is sent."""
518
+ started = threading.Event()
519
+ release = threading.Event()
520
+ statements = []
521
+
522
+ def gate(sql, params):
523
+ statements.append(sql.strip().split(" ", 1)[0].upper())
524
+ if params and params[0] == 777:
525
+ started.set()
526
+ assert release.wait(10)
527
+
528
+ ctx, dialect, sent_ids = make_ctx(lambda: None, gate)
529
+ return ctx, dialect, sent_ids, started, release, statements
530
+
531
+
532
+ @pytest.mark.parametrize("make_ctx", [_sqlite_leak_ctx, _pg_leak_ctx], ids=["sqlite", "pg"])
533
+ def test_statement_still_running_when_the_body_returns_rolls_back_and_raises(make_ctx):
534
+ from litedbmodel_runtime import WRITE_INTENT, run
535
+ from litedbmodel_runtime.errors import TransactionClosedError
536
+
537
+ ctx, dialect, sent_ids, started, release, statements = _gated_ctx(make_ctx)
538
+ worker_errors = []
539
+ body_calls = []
540
+
541
+ def body():
542
+ body_calls.append(1)
543
+ _do_op(1, 1, 0)
544
+ leaked = current_context()
545
+
546
+ def worker():
547
+ try:
548
+ run(leaked, _INSERT_SQL, [777, 7, 7], WRITE_INTENT)
549
+ except Exception as e: # pragma: no cover - the statement is awaited by the settle, it must not fail
550
+ worker_errors.append(e)
551
+
552
+ threading.Thread(target=worker).start()
553
+ assert started.wait(10) # the statement is now running in the DB; the body returns without awaiting it
554
+ threading.Timer(0.2, release.set).start()
555
+ return "done"
556
+
557
+ with pytest.raises(TransactionClosedError) as raised:
558
+ transaction(ctx, body, TransactionOptions(retry_limit=3, retry_duration=0), dialect)
559
+ assert len(body_calls) == 1, "an unfinished statement must not be retried"
560
+
561
+ assert str(raised.value) == (
562
+ "A query started inside the transaction had not finished when the transaction function settled. "
563
+ "Await every query started inside DBModel.transaction() before returning."
564
+ )
565
+ assert worker_errors == []
566
+ assert "COMMIT" not in statements and "ROLLBACK" in statements
567
+ if dialect == "sqlite":
568
+ assert sent_ids() == [], "neither the awaited nor the unfinished write may remain"
569
+
570
+
571
+ @pytest.mark.parametrize("make_ctx", [_sqlite_leak_ctx, _pg_leak_ctx], ids=["sqlite", "pg"])
572
+ def test_statement_still_running_when_the_body_raises_a_retryable_error_is_not_retried(make_ctx):
573
+ from litedbmodel_runtime import WRITE_INTENT, run
574
+ from litedbmodel_runtime.errors import TransactionClosedError
575
+
576
+ ctx, dialect, sent_ids, started, release, statements = _gated_ctx(make_ctx)
577
+ body_error = _FakeSerializationError("could not serialize access")
578
+ body_calls = []
579
+
580
+ def body():
581
+ body_calls.append(1)
582
+ leaked = current_context()
583
+ threading.Thread(target=lambda: run(leaked, _INSERT_SQL, [777, 7, 7], WRITE_INTENT)).start()
584
+ assert started.wait(10) # the statement is running in the DB when the body fails
585
+ threading.Timer(0.2, release.set).start()
586
+ raise body_error
587
+
588
+ with pytest.raises(TransactionClosedError) as raised:
589
+ transaction(ctx, body, TransactionOptions(retry_limit=3, retry_duration=0), dialect)
590
+
591
+ assert raised.value.__cause__ is body_error
592
+ assert len(body_calls) == 1, "the body must not be re-run: its statement was still unfinished"
593
+ assert "COMMIT" not in statements and "ROLLBACK" in statements
594
+ if dialect == "sqlite":
595
+ assert sent_ids() == []
596
+
597
+
598
+ @pytest.mark.parametrize("make_ctx", [_sqlite_leak_ctx, _pg_leak_ctx], ids=["sqlite", "pg"])
599
+ def test_every_statement_awaited_inside_the_body_still_commits(make_ctx):
600
+ from litedbmodel_runtime import WRITE_INTENT, run
601
+
602
+ ctx, dialect, sent_ids, started, release, statements = _gated_ctx(make_ctx)
603
+
604
+ def body():
605
+ _do_op(1, 1, 0)
606
+ leaked = current_context()
607
+ worker = threading.Thread(target=lambda: run(leaked, _INSERT_SQL, [777, 7, 7], WRITE_INTENT))
608
+ worker.start()
609
+ assert started.wait(10)
610
+ release.set()
611
+ worker.join()
612
+ return "done"
613
+
614
+ assert transaction(ctx, body, TransactionOptions(), dialect) == "done"
615
+ assert "COMMIT" in statements and "ROLLBACK" not in statements
616
+ assert sorted(sent_ids()) == [1, 777]
@@ -1,250 +0,0 @@
1
- """Phase B-core (#84/#86, python) — UNIT tests for the public transaction() boundary (in-proc SQLite).
2
-
3
- No live DB — an in-proc stdlib ``sqlite3`` driver (wrapped in a recording shim that counts begin_tx /
4
- commit / rollback) proves the boundary mechanics that DON'T need PG/MySQL. Every write is issued
5
- through the PRODUCTION guarded write seam (:func:`run_guarded` on the ambient pinned tx ctx) — the same
6
- seam a user-facing write inside ``transaction()`` rides:
7
-
8
- (1) MULTI-OP ATOMICITY — transaction(lambda: [opA_insert(); opB_insert()]) → both commit; the
9
- recording driver asserts EXACTLY ONE begin_tx / ONE commit / ONE tx handle for the whole boundary
10
- (the ambient JOIN — opB opens no second BEGIN). opB PK-collides → opA's row ALSO rolls back (ONE
11
- begin_tx + ONE rollback, zero commit), verified by reading real rows.
12
- (2) GUARD — a write OUTSIDE transaction() → WriteOutsideTransactionError; a read-only write inside →
13
- WriteInReadOnlyContextError; inside a boundary → ok.
14
- (3) NESTED — one begin_tx/commit; an inner error rolls back the whole tx.
15
- (4) rollback_only — the body runs + returns its value, but NOTHING commits (dry-run).
16
-
17
- The live-PG/MySQL isolation + real-contention-retry proof lives in test_tx_boundary_livedb.py.
18
- """
19
-
20
- from __future__ import annotations
21
-
22
- import sqlite3
23
-
24
- import pytest
25
-
26
- from litedbmodel_runtime import (
27
- IsolationLevel,
28
- TransactionOptions,
29
- WriteInReadOnlyContextError,
30
- WriteOutsideTransactionError,
31
- context_for_driver,
32
- current_context,
33
- run_guarded,
34
- run_with_pinned_context,
35
- transaction,
36
- )
37
- from litedbmodel_runtime.driver import SqliteDriver, _SqliteTxConnection
38
-
39
-
40
- ISO_TBL = "scp_tx_boundary_py"
41
- _INSERT_SQL = f"INSERT INTO {ISO_TBL} (id, worker, seq) VALUES (?, ?, ?)"
42
-
43
-
44
- # ── A recording SQLite driver: counts begin_tx / commit / rollback / distinct handles ──
45
-
46
-
47
- class _RecTx(_SqliteTxConnection):
48
- """New contract (Phase D / #95): tx-control flows through :meth:`run` (issued by the combinator via
49
- the seam), so COMMIT/ROLLBACK are counted HERE when their SQL passes through — not in removed
50
- ``commit``/``rollback`` methods. BEGIN is likewise a ``run`` call (counted in the driver's begin_tx
51
- sink for the ambient-JOIN count, and issued via the seam)."""
52
-
53
- def __init__(self, conn, sink):
54
- super().__init__(conn)
55
- self._sink = sink
56
-
57
- def run(self, sql, params):
58
- head = sql.strip().split(" ", 1)[0].upper()
59
- if head == "COMMIT":
60
- self._sink["commits"] += 1
61
- elif head == "ROLLBACK":
62
- self._sink["rolls"] += 1
63
- return super().run(sql, params)
64
-
65
-
66
- class _RecSqliteDriver(SqliteDriver):
67
- """A SqliteDriver that records tx lifecycle into a shared sink dict (begin_tx / commit / rollback /
68
- the set of distinct tx handles). Single-conn SQLite, so the whole boundary shares ONE conn — the
69
- counts prove the ambient JOIN (N ops = ONE begin_tx)."""
70
-
71
- def __init__(self, conn, sink):
72
- super().__init__(conn)
73
- self._sink = sink
74
-
75
- def begin_tx(self):
76
- # New contract: acquire + OWN the conn only; BEGIN is issued by the combinator THROUGH the seam
77
- # (via _RecTx.run). The begin_tx COUNT still proves the ambient JOIN (N ops = ONE begin_tx).
78
- self._sink["begin_tx"] += 1
79
- tx = _RecTx(self.conn, self._sink)
80
- self._sink["distinct"].add(id(tx))
81
- return tx
82
-
83
-
84
- def _fresh_sink():
85
- return {"begin_tx": 0, "commits": 0, "rolls": 0, "distinct": set()}
86
-
87
-
88
- def _make_driver(sink):
89
- conn = sqlite3.connect(":memory:")
90
- conn.execute(f"CREATE TABLE {ISO_TBL} (id INTEGER PRIMARY KEY, worker INTEGER NOT NULL, seq INTEGER NOT NULL)")
91
- conn.commit()
92
- return _RecSqliteDriver(conn, sink)
93
-
94
-
95
- def _do_op(id_, worker, seq):
96
- """One guarded INSERT through the PRODUCTION guarded write seam (:func:`run_guarded`) on the ambient
97
- pinned tx ctx — the same path a user-facing write inside ``transaction()`` rides. Outside a boundary
98
- the ambient pin is absent, so the guard fires (:class:`WriteOutsideTransactionError`)."""
99
- return run_guarded(current_context(), _INSERT_SQL, [id_, worker, seq], "WRITE", ISO_TBL)
100
-
101
-
102
- def _read_rows(driver):
103
- rows = driver.prepare(f"SELECT id, worker FROM {ISO_TBL} WHERE worker <> 999").all([])
104
- return sorted((int(r["id"]), int(r["worker"])) for r in rows)
105
-
106
-
107
- # ── (1) MULTI-OP ATOMICITY — commit path ───────────────────────────────────────
108
-
109
-
110
- def test_multi_op_boundary_one_begin_one_commit():
111
- sink = _fresh_sink()
112
- driver = _make_driver(sink)
113
- ctx = context_for_driver(driver)
114
-
115
- result = transaction(ctx, lambda: [_do_op(100, 1, 0), _do_op(101, 1, 1)], TransactionOptions(), "sqlite")
116
-
117
- assert [r.changes for r in result] == [1, 1]
118
- # N ops in one boundary ⇒ ONE begin_tx + ONE commit on ONE tx handle (the ambient JOIN).
119
- assert sink["begin_tx"] == 1, f"expected 1 begin_tx, got {sink['begin_tx']}"
120
- assert sink["commits"] == 1, f"expected 1 commit, got {sink['commits']}"
121
- assert sink["rolls"] == 0
122
- assert len(sink["distinct"]) == 1, f"expected 1 distinct tx handle, got {len(sink['distinct'])}"
123
- assert _read_rows(driver) == [(100, 1), (101, 1)]
124
-
125
-
126
- # ── (1) MULTI-OP ATOMICITY — rollback path (B fails ⇒ A also rolls back) ────────
127
-
128
-
129
- def test_multi_op_boundary_opB_fail_rolls_back_opA():
130
- sink = _fresh_sink()
131
- driver = _make_driver(sink)
132
- driver.conn.execute(f"INSERT INTO {ISO_TBL} (id, worker, seq) VALUES (201, 999, 9)") # pre-seed collision
133
- driver.conn.commit()
134
- ctx = context_for_driver(driver)
135
-
136
- with pytest.raises(Exception):
137
- transaction(ctx, lambda: [_do_op(200, 2, 0), _do_op(201, 2, 1)], TransactionOptions(retry_on_error=False), "sqlite")
138
-
139
- assert sink["begin_tx"] == 1
140
- assert sink["commits"] == 0
141
- assert sink["rolls"] == 1, f"opB failure ⇒ ONE rollback, got {sink['rolls']}"
142
- # opA (id=200) must ALSO have rolled back — the whole boundary is atomic. Both ops JOINed the ONE
143
- # ambient tx via run_guarded on the pinned ctx, so opB's PK collision rolls the whole boundary back.
144
- assert _read_rows(driver) == [], "opA must roll back when opB fails (cross-op atomicity)"
145
-
146
-
147
- # ── (2) write=tx GUARD ──────────────────────────────────────────────────────────
148
-
149
-
150
- def test_guard_outside_boundary_rejects_write():
151
- sink = _fresh_sink()
152
- driver = _make_driver(sink)
153
- context_for_driver(driver)
154
- # A bare guarded write OUTSIDE any transaction() → WriteOutsideTransactionError (the ambient pin is
155
- # absent, so current_context() is None and the guard fires before any SQL), nothing written.
156
- with pytest.raises(WriteOutsideTransactionError):
157
- _do_op(300, 3, 0)
158
- assert _read_rows(driver) == []
159
-
160
-
161
- def test_guard_read_only_inside_boundary_rejects_write():
162
- sink = _fresh_sink()
163
- driver = _make_driver(sink)
164
- ctx = context_for_driver(driver)
165
- # Read-only-scoped write inside a boundary → WriteInReadOnlyContextError (read-only checked first).
166
-
167
- def body():
168
- ambient = current_context() # the pinned tx ctx
169
- ro = ambient.with_read_only()
170
- # Pin the read-only ctx as the ambient so the guard (which reads the ambient markers) sees it.
171
- return run_with_pinned_context(ro, lambda: _do_op(301, 3, 0))
172
-
173
- with pytest.raises(WriteInReadOnlyContextError):
174
- transaction(ctx, body, TransactionOptions(retry_on_error=False), "sqlite")
175
-
176
-
177
- def test_guard_inside_boundary_allows_write():
178
- sink = _fresh_sink()
179
- driver = _make_driver(sink)
180
- ctx = context_for_driver(driver)
181
- r = transaction(ctx, lambda: _do_op(302, 3, 0), TransactionOptions(), "sqlite")
182
- assert r.changes == 1
183
- assert _read_rows(driver) == [(302, 3)]
184
-
185
-
186
- # ── (3) NESTED transaction = one begin/commit ──────────────────────────────────
187
-
188
-
189
- def test_nested_transaction_one_begin_commit():
190
- sink = _fresh_sink()
191
- driver = _make_driver(sink)
192
- ctx = context_for_driver(driver)
193
-
194
- def outer():
195
- _do_op(500, 5, 0)
196
- # A NESTED transaction() JOINs the outer — no new begin_tx/commit.
197
- return transaction(ctx, lambda: _do_op(501, 5, 1), TransactionOptions(), "sqlite")
198
-
199
- transaction(ctx, outer, TransactionOptions(), "sqlite")
200
- assert sink["begin_tx"] == 1
201
- assert sink["commits"] == 1
202
- assert sink["rolls"] == 0
203
- assert _read_rows(driver) == [(500, 5), (501, 5)]
204
-
205
-
206
- def test_nested_inner_error_rolls_back_whole_tx():
207
- sink = _fresh_sink()
208
- driver = _make_driver(sink)
209
- driver.conn.execute(f"INSERT INTO {ISO_TBL} (id, worker, seq) VALUES (601, 999, 9)") # collision for inner
210
- driver.conn.commit()
211
- ctx = context_for_driver(driver)
212
-
213
- def outer():
214
- _do_op(600, 6, 0)
215
- return transaction(ctx, lambda: _do_op(601, 6, 1), TransactionOptions(), "sqlite")
216
-
217
- with pytest.raises(Exception):
218
- transaction(ctx, outer, TransactionOptions(retry_on_error=False), "sqlite")
219
- assert sink["commits"] == 0
220
- assert sink["rolls"] == 1
221
- assert _read_rows(driver) == [], "an inner error rolls back the WHOLE tx (id=600 absent)"
222
-
223
-
224
- # ── (4) rollback_only (dry-run) ────────────────────────────────────────────────
225
-
226
-
227
- def test_rollback_only_returns_value_but_commits_nothing():
228
- sink = _fresh_sink()
229
- driver = _make_driver(sink)
230
- ctx = context_for_driver(driver)
231
- r = transaction(ctx, lambda: _do_op(700, 7, 0), TransactionOptions(rollback_only=True), "sqlite")
232
- assert r.changes == 1 # the body's own view: its statement ran (before the boundary rolled back)
233
- # …but the boundary ROLLED BACK, so nothing persisted.
234
- assert sink["begin_tx"] == 1
235
- assert sink["commits"] == 0
236
- assert sink["rolls"] == 1
237
- assert _read_rows(driver) == [], "rollback_only must commit nothing"
238
-
239
-
240
- # ── SQLite isolation is a hard error at the boundary ───────────────────────────
241
-
242
-
243
- def test_sqlite_isolation_request_is_a_hard_error():
244
- sink = _fresh_sink()
245
- driver = _make_driver(sink)
246
- ctx = context_for_driver(driver)
247
- with pytest.raises(ValueError):
248
- transaction(ctx, lambda: None, TransactionOptions(isolation=IsolationLevel.SERIALIZABLE), "sqlite")
249
- # The hard-error fires BEFORE any connection is acquired.
250
- assert sink["begin_tx"] == 0