litedbmodel-runtime 2.2.11__tar.gz → 2.2.13__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.
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/PKG-INFO +1 -1
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/litedbmodel_runtime/__init__.py +3 -2
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/litedbmodel_runtime/driver.py +18 -7
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/litedbmodel_runtime/errors.py +22 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/litedbmodel_runtime/exec_context.py +95 -10
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/litedbmodel_runtime.egg-info/PKG-INFO +1 -1
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/pyproject.toml +1 -1
- litedbmodel_runtime-2.2.13/tests/test_transaction_boundary.py +616 -0
- litedbmodel_runtime-2.2.11/tests/test_transaction_boundary.py +0 -250
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/README.md +0 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/litedbmodel_runtime/connection_routing.py +0 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/litedbmodel_runtime/dialect.py +0 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/litedbmodel_runtime/grouping.py +0 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/litedbmodel_runtime/leaves.py +0 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/litedbmodel_runtime/middleware.py +0 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/litedbmodel_runtime/tx_options.py +0 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/litedbmodel_runtime.egg-info/SOURCES.txt +0 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/litedbmodel_runtime.egg-info/dependency_links.txt +0 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/litedbmodel_runtime.egg-info/requires.txt +0 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/litedbmodel_runtime.egg-info/top_level.txt +0 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/setup.cfg +0 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/tests/test_conformance_corpus.py +0 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/tests/test_connection_routing.py +0 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/tests/test_connection_routing_livedb.py +0 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/tests/test_consumes_bc_core.py +0 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/tests/test_dialect.py +0 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/tests/test_dynamic_where.py +0 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/tests/test_exec_context.py +0 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/tests/test_grouping.py +0 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/tests/test_hard_limit_guard.py +0 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/tests/test_middleware.py +0 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/tests/test_middleware_livedb.py +0 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/tests/test_orm_bench_native.py +0 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/tests/test_render.py +0 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/tests/test_tx_boundary_livedb.py +0 -0
- {litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/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.
|
|
3
|
+
Version: 2.2.13
|
|
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.
|
|
107
|
+
__version__ = "2.2.13"
|
|
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
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
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
|
-
|
|
800
|
-
|
|
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
|
{litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/litedbmodel_runtime/exec_context.py
RENAMED
|
@@ -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
|
-
|
|
578
|
-
|
|
579
|
-
#
|
|
580
|
-
#
|
|
581
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
{litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/litedbmodel_runtime.egg-info/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: litedbmodel-runtime
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.13
|
|
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.
|
|
7
|
+
version = "2.2.13"
|
|
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
|
|
File without changes
|
{litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/litedbmodel_runtime/connection_routing.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/litedbmodel_runtime.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/litedbmodel_runtime.egg-info/requires.txt
RENAMED
|
File without changes
|
{litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/litedbmodel_runtime.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{litedbmodel_runtime-2.2.11 → litedbmodel_runtime-2.2.13}/tests/test_connection_routing_livedb.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|