litedbmodel-runtime 2.2.2__tar.gz → 2.2.4__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 (44) hide show
  1. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/PKG-INFO +1 -1
  2. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime/__init__.py +4 -39
  3. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime/connection_routing.py +64 -7
  4. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime/driver.py +85 -9
  5. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime/errors.py +6 -6
  6. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime/exec_context.py +46 -24
  7. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime/grouping.py +1 -1
  8. litedbmodel_runtime-2.2.4/litedbmodel_runtime/leaves.py +364 -0
  9. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime.egg-info/PKG-INFO +1 -1
  10. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime.egg-info/SOURCES.txt +1 -6
  11. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/pyproject.toml +1 -1
  12. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/tests/test_connection_routing.py +85 -0
  13. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/tests/test_connection_routing_livedb.py +6 -4
  14. litedbmodel_runtime-2.2.4/tests/test_consumes_bc_core.py +72 -0
  15. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/tests/test_dialect.py +3 -3
  16. litedbmodel_runtime-2.2.4/tests/test_dynamic_where.py +620 -0
  17. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/tests/test_hard_limit_guard.py +4 -5
  18. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/tests/test_middleware.py +35 -50
  19. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/tests/test_orm_bench_native.py +16 -14
  20. litedbmodel_runtime-2.2.4/tests/test_render.py +16 -0
  21. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/tests/test_transaction_boundary.py +38 -101
  22. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/tests/test_tx_boundary_livedb.py +13 -23
  23. litedbmodel_runtime-2.2.2/litedbmodel_runtime/leaves.py +0 -193
  24. litedbmodel_runtime-2.2.2/litedbmodel_runtime/relation.py +0 -211
  25. litedbmodel_runtime-2.2.2/litedbmodel_runtime/runtime.py +0 -320
  26. litedbmodel_runtime-2.2.2/litedbmodel_runtime/static_bundle.py +0 -420
  27. litedbmodel_runtime-2.2.2/tests/test_consumes_bc_core.py +0 -68
  28. litedbmodel_runtime-2.2.2/tests/test_parallel_read.py +0 -125
  29. litedbmodel_runtime-2.2.2/tests/test_render.py +0 -104
  30. litedbmodel_runtime-2.2.2/tests/test_runtime.py +0 -208
  31. litedbmodel_runtime-2.2.2/tests/test_tx_isolation.py +0 -338
  32. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/README.md +0 -0
  33. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime/dialect.py +0 -0
  34. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime/middleware.py +0 -0
  35. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime/tx_options.py +0 -0
  36. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime.egg-info/dependency_links.txt +0 -0
  37. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime.egg-info/requires.txt +0 -0
  38. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime.egg-info/top_level.txt +0 -0
  39. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/setup.cfg +0 -0
  40. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/tests/test_conformance_corpus.py +0 -0
  41. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/tests/test_exec_context.py +0 -0
  42. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/tests/test_grouping.py +0 -0
  43. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/tests/test_middleware_livedb.py +0 -0
  44. {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/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.2
3
+ Version: 2.2.4
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
@@ -102,29 +102,9 @@ from .connection_routing import (
102
102
  single_pool_pair,
103
103
  with_writer,
104
104
  )
105
- from .static_bundle import (
106
- assemble_make_sql,
107
- compose_make_sql,
108
- execute_read_graph,
109
- render_placeholders,
110
- render_read_primary,
111
- render_statements,
112
- )
113
- from .runtime import (
114
- ENTITY_ROOT,
115
- execute_bundle,
116
- execute_transaction_bundle,
117
- order_by_nulls,
118
- )
119
- from .relation import (
120
- dedupe_keys,
121
- distribute_to_parent,
122
- read_bundle,
123
- run_relation_op,
124
- )
125
- from .leaves import make_handlers
105
+ from .leaves import make_handlers, render_placeholders
126
106
 
127
- __version__ = "2.2.2"
107
+ __version__ = "2.2.4"
128
108
 
129
109
  __all__ = [
130
110
  "__version__",
@@ -231,23 +211,8 @@ __all__ = [
231
211
  "session_statements",
232
212
  "single_pool_pair",
233
213
  "with_writer",
234
- # static makeSQL bundle runtime (the sole read/render path)
235
- "assemble_make_sql",
236
- "compose_make_sql",
237
- "execute_read_graph",
238
- "render_placeholders",
239
- "render_read_primary",
240
- "render_statements",
241
- # runtime
242
- "ENTITY_ROOT",
243
- "execute_bundle",
244
- "execute_transaction_bundle",
245
- "order_by_nulls",
246
- # read-relation batch execution + hydration (#43)
247
- "dedupe_keys",
248
- "distribute_to_parent",
249
- "read_bundle",
250
- "run_relation_op",
251
214
  # op-agnostic leaf transport for the bc-generated native (ir-exec) modules (#141 / epic #123)
215
+ # + the render-layer placeholder resolution (`?`→`$N`) the leaf transport applies
252
216
  "make_handlers",
217
+ "render_placeholders",
253
218
  ]
@@ -11,7 +11,8 @@ resolution (steps 2-4).
11
11
 
12
12
  A statement's connection is resolved in THIS priority (first match wins):
13
13
 
14
- 1. **active tx connection** — inside a transaction, always the tx-owned connection (Phase A;
14
+ 1. **active tx connection** — inside a transaction, the tx-owned connection a statement naming a
15
+ DIFFERENT database than the tx opened on is REJECTED there rather than routed (Phase A;
15
16
  resolved by :class:`ExecutionContext` BEFORE the routing steps, since only it holds the pin).
16
17
  2. **writer scope / writer-sticky** — inside :func:`with_writer`, or within ``writer_sticky_duration``
17
18
  after a transaction (read-your-writes), a READ goes to the WRITER pool (Phase C — here).
@@ -418,6 +419,57 @@ def reader_writer_pair(reader: ConnectionPool, writer: ConnectionPool) -> Reader
418
419
  DEFAULT_CONNECTION = "default"
419
420
 
420
421
 
422
+ def assert_routable_named_db(db: Optional[str], context_description: str) -> None:
423
+ """Reject a statement that NAMES a database on a context that holds NO connection registry (the
424
+ single-driver :class:`~litedbmodel_runtime.exec_context.ExecutionContext`). The companion of
425
+ :meth:`ConnectionRegistry.pair_for`'s unknown-name failure, and for the same reason: an unresolvable
426
+ name must be LOUD, because the alternative is running the statement against whatever single driver
427
+ the context happens to hold — a DIFFERENT database than the statement's model declares (#217).
428
+ ``None`` (the default connection) is the single-driver case itself and passes. Mirrors the TS
429
+ ``assertRoutableNamedDb`` / go ``namedDBUnroutable`` / rust ``assert_routable_named_db``."""
430
+ if _effective_connection(db) == DEFAULT_CONNECTION:
431
+ return
432
+ raise ValueError(
433
+ "scp connection routing: a statement names connection '%s', but it is executing on %s — there "
434
+ "is no connection registry to resolve the name against. Build the context from a RoutingConfig "
435
+ "(set_config/ConnectionRegistry), or drop the connection tag on the model."
436
+ % (db, context_description)
437
+ )
438
+
439
+
440
+ def _effective_connection(db: Optional[str]) -> str:
441
+ """A connection name reduced to its effective identity: unnamed ⇒ :data:`DEFAULT_CONNECTION`."""
442
+ return DEFAULT_CONNECTION if db is None else db
443
+
444
+
445
+ def assert_tx_db_agrees(db: Optional[str], tx_db: Optional[str]) -> None:
446
+ """Reject a statement whose named database is NOT the one the active transaction opened on.
447
+
448
+ A transaction is ONE connection on ONE database: a statement that names a DIFFERENT database cannot be
449
+ executed atomically with it, by any amount of routing. So there are exactly two possible behaviors and
450
+ no third — run it on the transaction's database (silently the WRONG one) or refuse — and the first is
451
+ the silent default the tx pin used to produce: the pin is resolved BEFORE routing (it must be —
452
+ per-execution ownership depends on it), so ``intent.db`` was dropped unread and even an UNREGISTERED
453
+ name never surfaced (#217).
454
+
455
+ ``tx_db`` is the transaction's own connection name
456
+ (:meth:`~litedbmodel_runtime.exec_context.ExecutionContext.with_connection_name`, the same attribute
457
+ ``begin_tx`` reads). An UNNAMED statement agrees with any transaction — the ordinary in-body statement,
458
+ which every named-DB tx gate pins — and one naming the SAME database agrees too. Mirrors the TS
459
+ ``assertTxDbAgrees``."""
460
+ if db is None:
461
+ return
462
+ want, open_ = _effective_connection(db), _effective_connection(tx_db)
463
+ if want == open_:
464
+ return
465
+ raise ValueError(
466
+ "scp connection routing: a statement names connection '%s', but it is executing inside a "
467
+ "transaction opened on '%s' — a transaction is ONE connection on ONE database, so the two cannot "
468
+ "both be honored. Open the transaction on '%s', or issue the statement outside it."
469
+ % (want, open_, want)
470
+ )
471
+
472
+
421
473
  class ConnectionRegistry:
422
474
  """The multi-DB connection registry (C2): a map from a connection NAME → its
423
475
  :class:`ReaderWriterPools`. :func:`resolve_pool` selects the pair by ``intent.db`` (the connection
@@ -449,7 +501,7 @@ class ConnectionRegistry:
449
501
  def pair_for(self, name: Optional[str]) -> ReaderWriterPools:
450
502
  """The reader/writer pair for ``name`` (or :data:`DEFAULT_CONNECTION` when ``None``). LOUD on a
451
503
  missing name."""
452
- key = name if name is not None else DEFAULT_CONNECTION
504
+ key = _effective_connection(name)
453
505
  pair = self._connections.get(key)
454
506
  if pair is None:
455
507
  known = ", ".join("'%s'" % k for k in self._connections.keys())
@@ -578,7 +630,11 @@ class WriterStickyClock:
578
630
  writer_sticky_duration: int = 5000,
579
631
  now: Optional[Callable[[], int]] = None,
580
632
  ) -> None:
581
- self._last_write_at = 0
633
+ # ``None`` until the first ``mark()`` — absence, NOT a value sentinel. The injectable clock
634
+ # legitimately returns 0 (rust's SystemClock does right after process start), so encoding
635
+ # "never marked" as the value 0 would mis-classify a ``mark()`` at clock t=0 as unmarked and
636
+ # leak the read-your-writes read to the reader replica. Absence must be distinct from value 0.
637
+ self._last_write_at: Optional[int] = None
582
638
  self._enabled = use_writer_after_transaction
583
639
  self._sticky_duration_ms = writer_sticky_duration
584
640
  # Default clock: monotonic ms (Date.now analogue). An injectable clock makes expiry deterministic.
@@ -591,13 +647,13 @@ class WriterStickyClock:
591
647
 
592
648
  def is_sticky(self) -> bool:
593
649
  """Is a read currently sticky-to-writer (within ``writer_sticky_duration`` of the last write)?"""
594
- if not self._enabled or self._last_write_at == 0:
650
+ if not self._enabled or self._last_write_at is None:
595
651
  return False
596
652
  return (self._now() - self._last_write_at) < self._sticky_duration_ms
597
653
 
598
654
  def reset(self) -> None:
599
655
  """Reset the clock (e.g. between tests / on :func:`close_all_pools`)."""
600
- self._last_write_at = 0
656
+ self._last_write_at = None
601
657
 
602
658
 
603
659
  # ── The routing config a ctx carries (C1+C2+C3) ────────────────────────────────
@@ -961,8 +1017,9 @@ def routed_begin_tx(
961
1017
  ) -> TxConnection:
962
1018
  """Acquire + OWN one :class:`litedbmodel_runtime.driver._PooledTxConnection` for a transaction on the
963
1019
  NAMED connection ``connection``'s WRITER pool (Phase C-2; ``None`` ⇒ the default connection). The
964
- tx runs entirely on this ONE connection the active-tx pin then wins over routing for every
965
- statement in the body (Phase B unbroken). tx-control (the isolation SET / BEGIN / COMMIT / ROLLBACK)
1020
+ tx runs entirely on this ONE connection: every UNNAMED in-body statement resolves the pin, one that
1021
+ names THIS connection does too, and one naming a DIFFERENT database is rejected
1022
+ (:func:`assert_tx_db_agrees`) — a transaction cannot span two databases. tx-control (the isolation SET / BEGIN / COMMIT / ROLLBACK)
966
1023
  is issued THROUGH the seam on this pinned connection by the combinator (Phase D / #95,
967
1024
  middleware-visible), NOT here. The writer pool's ``xform`` (``$N``/``?`` → ``%s``) +
968
1025
  ``emulate_returning`` flag drive the tx statements byte-identically to the Phase A
@@ -112,7 +112,7 @@ class Driver(Protocol):
112
112
 
113
113
  def begin_tx(self) -> TxConnection:
114
114
  """Acquire + OWN a :class:`TxConnection` for a transaction (per-execution connection ownership,
115
- §3). The central seam's ``with_transaction`` pins the returned handle so every statement in the
115
+ §3). The central seam's ``with_transaction`` pins the returned handle so every statement of the tx's own database in the
116
116
  tx body runs on it, and issues the isolation SET + BEGIN/COMMIT/ROLLBACK THROUGH the seam on this
117
117
  connection (Phase D / #95, middleware-visible) — this method only acquires the owned connection.
118
118
  Empty prelude ⇒ a bare ``BEGIN`` (the Phase A behavior, byte-identical statements + connection)."""
@@ -248,6 +248,16 @@ class _SqliteTxConnection:
248
248
  # relations can each hold a live connection without starving.
249
249
  DEFAULT_POOL_SIZE = 16
250
250
 
251
+ # Bound EVERY acquire so an UNREACHABLE / non-responding DB fails in FINITE time instead of hanging
252
+ # (#225). `acquire` has two sub-waits, both bounded to this budget: the factory's TCP connect
253
+ # (`connect_timeout`, set in the pg/mysql factories) and the wait for a released connection when the
254
+ # pool is at capacity (`_free.get(timeout=…)`). 30s matches the established codebase default — the v1
255
+ # pg/mysql drivers' `config.timeout || 30` (src/drivers/postgres.ts, src/drivers/mysql.ts) — and the
256
+ # other runtimes' pool-library connect/acquire timeouts (rust sqlx ~30s / deadpool wait, go database/sql
257
+ # Ping, TS pg `connectionTimeoutMillis`). Only Python hand-rolls its pool, so it must supply the bound
258
+ # those libraries give the other four legs for free.
259
+ DEFAULT_ACQUIRE_TIMEOUT_SECONDS = 30.0
260
+
251
261
 
252
262
  class _ConnectionPool:
253
263
  """A minimal thread-safe, bounded pool of DB-API connections (dependency-free).
@@ -258,9 +268,9 @@ class _ConnectionPool:
258
268
  concurrent sibling its own connection.
259
269
  """
260
270
 
261
- __slots__ = ("_factory", "_max", "_free", "_opened", "_lock", "_closed")
271
+ __slots__ = ("_factory", "_max", "_free", "_opened", "_lock", "_closed", "_acquire_timeout")
262
272
 
263
- def __init__(self, factory, max_size: int) -> None:
273
+ def __init__(self, factory, max_size: int, acquire_timeout: float = DEFAULT_ACQUIRE_TIMEOUT_SECONDS) -> None:
264
274
  import queue as _queue
265
275
  import threading as _threading
266
276
 
@@ -269,6 +279,10 @@ class _ConnectionPool:
269
279
  self._free: "Any" = _queue.LifoQueue()
270
280
  self._opened = 0
271
281
  self._lock = _threading.Lock()
282
+ # The bound on the at-capacity wait for a released connection (see acquire). Finite so an
283
+ # exhausted pool that will never be replenished (e.g. every open failed) raises instead of
284
+ # blocking forever (#225).
285
+ self._acquire_timeout = acquire_timeout
272
286
  # Fail-fast after close(): a post-close acquire must RAISE, not block forever on `_free.get()`
273
287
  # (the pool is drained and nothing will be released). Additive — the Phase A/B paths never
274
288
  # acquire after close, so behavior there is unchanged; Phase C's close_all_pools relies on it so
@@ -285,14 +299,36 @@ class _ConnectionPool:
285
299
  return self._free.get_nowait()
286
300
  except _queue.Empty:
287
301
  pass
288
- # Open a new one if below the ceiling; else wait for a release.
302
+ # Reserve a slot if below the ceiling, then open OUTSIDE the lock (a slow/blocked connect must
303
+ # not serialize every other acquire/release on this pool).
304
+ reserved = False
289
305
  with self._lock:
290
306
  if self._closed:
291
307
  raise RuntimeError("scp connection pool: acquire after close (the pool has been closed)")
292
308
  if self._opened < self._max:
293
309
  self._opened += 1
310
+ reserved = True
311
+ if reserved:
312
+ try:
294
313
  return self._factory()
295
- return self._free.get() # block until a connection is released
314
+ except BaseException:
315
+ # The open FAILED — RELEASE the reserved slot (mirror `discard`'s decrement). Without
316
+ # this a failed connect permanently consumes capacity; once `_opened` hits the ceiling
317
+ # every later acquire falls through to the wait below with no connection ever coming, so
318
+ # an unreachable DB turns each fast connect-refused into an unbounded hang (#225).
319
+ with self._lock:
320
+ self._opened -= 1
321
+ raise
322
+ # Pool at capacity: wait for a released connection, but BOUND the wait. An unbounded get() hangs
323
+ # forever when nothing will ever be released; on timeout raise a clear error instead (#225 — the
324
+ # parity target: the other runtimes' pool libraries bound this acquire wait).
325
+ try:
326
+ return self._free.get(timeout=self._acquire_timeout)
327
+ except _queue.Empty:
328
+ raise TimeoutError(
329
+ f"scp connection pool: acquire timed out after {self._acquire_timeout}s "
330
+ f"(pool at capacity {self._max}, no connection released)"
331
+ ) from None
296
332
 
297
333
  def release(self, conn: Any) -> None:
298
334
  self._free.put(conn)
@@ -352,9 +388,42 @@ def _dollar_to_pyformat(sql: str) -> str:
352
388
  return _DOLLAR_RE.sub("%s", sql.replace("%", "%%"))
353
389
 
354
390
 
391
+ def rewrite_unquoted_placeholders(sql: str, render: Callable[[int], str]) -> str:
392
+ """Rewrite every ``?`` that is NOT inside a single-quoted SQL string literal, in order, to
393
+ ``render(n)`` (``n`` = 1-based placeholder ordinal). A ``?`` inside a literal is TEXT, not a
394
+ placeholder, and is left exactly as it is.
395
+
396
+ The ONE quote-aware ``?`` walk on this plane: both the dialect placeholder render
397
+ (:func:`litedbmodel_runtime.leaves.render_placeholders` — PG ``$N``) and the DB-API
398
+ paramstyle conversion (:func:`_qmark_to_pyformat` — PyMySQL ``%s``) go through it, so the two
399
+ cannot disagree about which ``?`` is a placeholder. They did: a naive ``str.replace`` rewrote a
400
+ quoted ``?`` as a placeholder and bound a value into a string literal, while the render skipped
401
+ it (the same class of split #202 names).
402
+ """
403
+ out: List[str] = []
404
+ index = 0
405
+ in_string = False
406
+ for ch in sql:
407
+ if in_string:
408
+ out.append(ch)
409
+ if ch == "'":
410
+ in_string = False
411
+ elif ch == "'":
412
+ out.append(ch)
413
+ in_string = True
414
+ elif ch == "?":
415
+ index += 1
416
+ out.append(render(index))
417
+ else:
418
+ out.append(ch)
419
+ return "".join(out)
420
+
421
+
355
422
  def _qmark_to_pyformat(sql: str) -> str:
356
- """MySQL render keeps `?`; PyMySQL binds `%s`. Replace each `?` with `%s` (literal `%` doubled)."""
357
- return sql.replace("%", "%%").replace("?", "%s")
423
+ """MySQL render keeps `?`; PyMySQL binds `%s`. Rewrite each PLACEHOLDER `?` to `%s` (literal `%`
424
+ doubled). A `?` inside a string literal is TEXT — PyMySQL substitutes positionally over the whole
425
+ text, so rewriting one would bind a value INSIDE the literal and shift every later value."""
426
+ return rewrite_unquoted_placeholders(sql.replace("%", "%%"), lambda _n: "%s")
358
427
 
359
428
 
360
429
  def _parse_pk_hint(hint_region: str):
@@ -730,8 +799,12 @@ class PostgresDriver(_PooledDriver):
730
799
  import psycopg # imported lazily so the SQLite conformance never needs the driver installed
731
800
 
732
801
  def factory():
802
+ # connect_timeout bounds the TCP connect so an unreachable/non-responding host fails in
803
+ # finite time (psycopg has NO default connect timeout — a blackhole host would block the
804
+ # acquire forever, #225). Matches the v1 driver's 30s default and the other runtimes.
733
805
  return psycopg.connect(
734
- host=host, port=port, user=user, password=password, dbname=dbname, autocommit=True
806
+ host=host, port=port, user=user, password=password, dbname=dbname, autocommit=True,
807
+ connect_timeout=int(DEFAULT_ACQUIRE_TIMEOUT_SECONDS),
735
808
  )
736
809
 
737
810
  pool = _ConnectionPool(factory, pool_size)
@@ -761,8 +834,11 @@ class MysqlDriver(_PooledDriver):
761
834
  import pymysql # lazy import (conformance bar never needs it)
762
835
 
763
836
  def factory():
837
+ # connect_timeout bounds the TCP connect so an unreachable host fails in finite time (#225).
838
+ # PyMySQL defaults this to 10s; pin it to the shared budget so both live drivers agree.
764
839
  return pymysql.connect(
765
- host=host, port=port, user=user, password=password, database=dbname, autocommit=True
840
+ host=host, port=port, user=user, password=password, database=dbname, autocommit=True,
841
+ connect_timeout=int(DEFAULT_ACQUIRE_TIMEOUT_SECONDS),
766
842
  )
767
843
 
768
844
  pool = _ConnectionPool(factory, pool_size)
@@ -67,12 +67,12 @@ class LimitExceededError(Exception):
67
67
  """The SHARED cross-language runaway-prevention error (Phase E-2, epic #74; Python port of the TS
68
68
  ``LimitExceededError`` reference in ``src/scp/errors.ts``, #99).
69
69
 
70
- Raised by the native read / relation post-fetch guard when a read (``context='find'``) or a
71
- ``hasMany`` relation batch (``context='relation'``) returns MORE rows than the cap BAKED onto the
72
- portable artifact (``ReadGraph.findGuard.hardLimit`` / ``RelationOp.hardLimit``), so an accidental
73
- missing-WHERE / N+1 pattern fails LOUD instead of loading an unbounded result. NOT a
70
+ Raised by the leaf transport's post-fetch guard (:func:`check_find_hard_limit`) when a read
71
+ (``context='find'``) or a ``hasMany`` relation batch (``context='relation'``) returns MORE rows than
72
+ the cap BAKED onto the portable artifact and carried in the leaf payload's guard record, so an
73
+ accidental missing-WHERE / N+1 pattern fails LOUD instead of loading an unbounded result. NOT a
74
74
  :class:`SqlFailure` (a runaway guard is a litedbmodel-level policy error, carrying no ``SQLITE_*``
75
- code — so :func:`litedbmodel_runtime.static_bundle._re_error_to_sql_failure` propagates it
75
+ code — so the ``SQLITE_*``-keyed driver-error mapping (:func:`map_sqlite_error`) propagates it
76
76
  unchanged). Byte-for-byte with the TS reference:
77
77
 
78
78
  - fields: ``limit`` (the cap), ``count`` (rows fetched), ``context`` (``'find'`` | ``'relation'``),
@@ -124,7 +124,7 @@ class LimitExceededError(Exception):
124
124
  ) -> None:
125
125
  """The SHARED post-fetch runaway check (SSoT) — the ONE ``count > limit ⇒ raise`` primitive
126
126
  both the FIND-context guard (:func:`check_find_hard_limit`) and the RELATION-context guard
127
- (:func:`litedbmodel_runtime.relation.run_relation_op`) call, so no path re-implements the
127
+ (the ``guard`` control field the emitter bakes) call, so no path re-implements the
128
128
  comparison or the error assembly (Python port of the rust ``LimitExceededError::check`` /
129
129
  go ``CheckLimit`` SSoT). ``None`` (returns) when within the cap; raises otherwise."""
130
130
  if count > limit:
@@ -3,7 +3,7 @@
3
3
  The Python port of the TS contract-defining artifact ``src/scp/exec-context.ts`` (#75), mirroring the
4
4
  rust port ``rust/litedbmodel_runtime/src/exec_context.rs`` (#76) and the go port
5
5
  ``go/litedbmodel_runtime/exec_context.go`` (#77). It replaces the raw ``driver: Driver`` threaded
6
- through ``execute_bundle`` / ``execute_read_graph`` / the relation walker / ``execute_transaction_bundle``
6
+ through the leaf transport (:func:`execute` / :func:`run` / :func:`run_guarded`)
7
7
  with an :class:`ExecutionContext` that carries:
8
8
 
9
9
  1. a **connection provider** — :meth:`ExecutionContext.connection_for` ``(intent)`` resolves WHICH
@@ -11,8 +11,9 @@ with an :class:`ExecutionContext` that carries:
11
11
  only the tx-owned + single-DB cases, reader/writer/named-DB are B/C/D on this seam);
12
12
  2. a **middleware chain** — :attr:`ExecutionContext.middleware`, wrapping every SQL (empty in
13
13
  Phase A = passthrough; the registration API is Phase D — this is only the hook point);
14
- 3. a **pinned tx connection** — a tx-scoped ctx pins ONE owned connection so every statement in a
15
- transaction body runs on it (per-execution connection ownership, §3).
14
+ 3. a **pinned tx connection** — a tx-scoped ctx pins ONE owned connection so every statement of the tx's own database
15
+ in a transaction body runs on it a statement naming a DIFFERENT database is rejected
16
+ (per-execution connection ownership, §3).
16
17
 
17
18
  ## The central seam (§2) — ALL SQL funnels through here
18
19
 
@@ -150,8 +151,9 @@ class DriverConnection(Connection):
150
151
 
151
152
  class _TxConnectionAdapter(Connection):
152
153
  """A :class:`Connection` view over a tx's OWNED :class:`TxConnection` handle. The seam resolves
153
- this (via ``connection_for``) for every statement inside a tx, so all of them run on the SAME
154
- owned connection. Concurrent transactions each hold a DISTINCT handle over a DISTINCT pooled
154
+ this (via ``connection_for``) for every statement inside a tx of the tx's own database an unnamed one, or one naming
155
+ that database so all of THOSE run on the SAME owned connection (a statement naming a DIFFERENT database is rejected
156
+ instead). Concurrent transactions each hold a DISTINCT handle over a DISTINCT pooled
155
157
  connection, so their writes never cross-talk — the isolation the removed driver-global ``_writer``
156
158
  slot violated.
157
159
  """
@@ -240,8 +242,8 @@ _EMPTY_CHAIN = MiddlewareChain()
240
242
 
241
243
 
242
244
  class ExecutionContext:
243
- """The execution context threaded through ``execute_bundle`` / ``execute_read_graph`` / the
244
- relation walker / ``execute_transaction_bundle`` in place of a raw :class:`Driver`. It carries the
245
+ """The execution context threaded through the leaf transport and the central execute/run/run_guarded
246
+ seam in place of a raw :class:`Driver`. It carries the
245
247
  connection provider (the primary driver + an optional pinned tx connection), the middleware chain,
246
248
  and derives a tx-scoped ctx via :meth:`with_connection`.
247
249
 
@@ -255,9 +257,10 @@ class ExecutionContext:
255
257
  An OPTIONAL ``routing`` (a ``litedbmodel_runtime.connection_routing.RoutingConfig``) completes
256
258
  ``connection_for``'s resolution steps 2-4 (reader/writer split, named-DB, writer-sticky). Absent ⇒
257
259
  the byte-identical Phase A/B single-``driver`` path (``context_for_driver`` builds NO routing). The
258
- active-tx pin STILL wins over routing (step 1) — a named-DB transaction runs entirely on ONE pinned
259
- writer connection (Phase B unbroken). Only when routing IS present does ``connection_for`` consult
260
- the registry; the driver-only ctors keep working unchanged.
260
+ active-tx pin STILL comes first (step 1) — a transaction runs entirely on ONE pinned writer
261
+ connection, so a statement naming a DIFFERENT database is rejected there rather than routed. Only when
262
+ routing IS present does ``connection_for`` consult the registry; the driver-only ctors keep working
263
+ unchanged.
261
264
  """
262
265
 
263
266
  __slots__ = ("_driver", "middleware", "_pinned", "_read_only", "_routing", "_connection")
@@ -273,7 +276,8 @@ class ExecutionContext:
273
276
  ) -> None:
274
277
  self._driver = driver
275
278
  self.middleware = middleware
276
- # The pinned tx connection (present ⇒ this is a tx-scoped ctx; every statement resolves it).
279
+ # The pinned tx connection (present ⇒ a tx-scoped ctx; every statement
280
+ # of the tx's own database (an unnamed one, or one naming that database) resolves it).
277
281
  self._pinned = pinned
278
282
  # The READ-ONLY marker (Phase B / #84 write=tx guard — mirror v1 `withWriter` / the TS
279
283
  # `withReadOnly` ALS marker / rust/go `read_only`): a write in a read-only-scoped ctx is
@@ -315,7 +319,7 @@ class ExecutionContext:
315
319
  def with_read_only(self) -> "ExecutionContext":
316
320
  """Derive a READ-ONLY-scoped ctx (mirror v1 ``withWriter`` / the TS ``withReadOnly`` / rust/go
317
321
  ``with_read_only``): reads are allowed, but ANY write funneled through the GUARDED write seam
318
- (:func:`run_guarded` / a guarded ``execute_transaction_bundle``) is rejected with
322
+ (:func:`run_guarded`) is rejected with
319
323
  :class:`WriteInReadOnlyContextError`. A tx-scoped ctx INHERITS its pinned connection + driver +
320
324
  middleware; a Transaction() opened inside a read-only scope stays read-only (v1 parity)."""
321
325
  return ExecutionContext(
@@ -325,14 +329,24 @@ class ExecutionContext:
325
329
  def connection_for(self, intent: StatementIntent = READ_INTENT) -> Connection:
326
330
  """Resolve WHICH connection a statement runs on (§3). Resolution order (first match wins):
327
331
 
328
- 1. the tx-owned (pinned) connection — inside a tx it ALWAYS wins (Phase A / B). A named-DB
329
- transaction runs entirely on this ONE pinned writer connection (Phase B unbroken).
332
+ 1. the tx-owned (pinned) connection — inside a tx it serves every statement of the tx's own database,
333
+ because a transaction is ONE
334
+ connection. A statement naming a DIFFERENT database than the transaction opened on therefore
335
+ cannot be honored and is LOUD (:func:`~litedbmodel_runtime.connection_routing.assert_tx_db_agrees`);
336
+ an unnamed one, and one naming the SAME database, run on the pin.
330
337
  2-4. when Phase C routing is present: named-DB → reader/writer split → writer-sticky/withWriter
331
338
  (:func:`litedbmodel_runtime.connection_routing.resolve_pool`), running the statement on ONE
332
339
  pooled connection per statement (the read fan-out).
333
340
  otherwise (no routing): the primary driver — the byte-identical Phase A/B single-DB path.
334
341
  """
335
342
  if self._pinned is not None:
343
+ # A statement that names a DIFFERENT database than the transaction opened on cannot be honored
344
+ # on the pinned connection — a transaction is ONE connection on ONE database — so it is LOUD
345
+ # rather than silently executed against the transaction's database (#217). The name the tx
346
+ # opened on is this ctx's own (``with_connection_name``, the same attribute ``begin_tx`` reads).
347
+ from .connection_routing import assert_tx_db_agrees
348
+
349
+ assert_tx_db_agrees(intent.db, self._connection)
336
350
  return self._pinned
337
351
  if self._routing is not None:
338
352
  # Phase C (#90): named-DB → reader/writer → writer-sticky. Lazy import avoids the circular
@@ -340,10 +354,17 @@ class ExecutionContext:
340
354
  from .connection_routing import PoolConnection, resolve_pool
341
355
 
342
356
  return PoolConnection(resolve_pool(intent, self._routing))
357
+ # No pin: a statement that NAMES a database has nowhere to go on a single-driver ctx either
358
+ # (there is no registry to resolve the name against), so it is LOUD — exactly as an unregistered name is on a
359
+ # routed ctx (``ConnectionRegistry.pair_for``). Running it on the primary driver instead would
360
+ # execute it against a DIFFERENT database than its model declares, silently (#217).
361
+ from .connection_routing import assert_routable_named_db
362
+
363
+ assert_routable_named_db(intent.db, "a single-driver (non-routed) execution context")
343
364
  return DriverConnection(self._driver)
344
365
 
345
366
  def with_connection(self, conn: Connection, tx: bool) -> "ExecutionContext":
346
- """Derive a tx-scoped ctx pinning ``conn`` (every statement resolves it while ``tx`` is True).
367
+ """Derive a tx-scoped ctx pinning ``conn`` (every statement of the tx's own database (an unnamed one, or one naming that database) resolves it while ``tx`` is True).
347
368
  The derived ctx shares the primary driver + middleware chain + routing, and INHERITS the
348
369
  read-only marker (a tx opened inside a read-only scope stays read-only — v1 parity). This is the
349
370
  Python analogue of the TS ``withConnection(conn, tx)`` / go ``WithTxConnection`` / rust
@@ -375,8 +396,9 @@ class ExecutionContext:
375
396
  """Acquire + OWN the tx connection for THIS ctx (§3). On the single-driver Phase A/B path this
376
397
  delegates to ``driver.begin_tx``. When Phase C routing is present the tx acquires ONE connection
377
398
  from the target NAMED connection's WRITER pool (:attr:`_connection` → the writer pool of that
378
- registry pair), so a named-DB transaction runs entirely on ONE pinned writer connection — the
379
- active-tx pin then wins over routing for every statement in the body (Phase B unbroken).
399
+ registry pair), so a named-DB transaction runs entirely on ONE pinned writer connection: every
400
+ UNNAMED in-body statement resolves the pin, one that names THIS connection does too, and one naming
401
+ a DIFFERENT database is rejected (a transaction cannot span two databases).
380
402
 
381
403
  tx-control (the isolation SET / BEGIN / COMMIT / ROLLBACK) is issued THROUGH the seam on the
382
404
  pinned connection by :func:`with_transaction_decided` (Phase D / #95, middleware-visible), NOT
@@ -473,9 +495,8 @@ def context_for_driver(driver: Driver) -> ExecutionContext:
473
495
 
474
496
  def as_context(driver_or_ctx: Union[Driver, ExecutionContext]) -> ExecutionContext:
475
497
  """Accept EITHER a raw :class:`Driver` (wrap it via :func:`context_for_driver` — the byte-identical
476
- backward-compat path) OR an already-built :class:`ExecutionContext` (pass through). The public
477
- runtime entry points (``execute_bundle`` / ``execute_transaction_bundle`` / ``run_relation_op`` /
478
- ``read_bundle``) take this union so every existing caller that threads a raw driver keeps working
498
+ backward-compat path) OR an already-built :class:`ExecutionContext` (pass through). The leaf
499
+ transport (:func:`make_handlers`) takes this union so a caller that threads a raw driver keeps working
479
500
  while the ctx-threaded internals funnel every SQL through the seam.
480
501
  """
481
502
  if isinstance(driver_or_ctx, ExecutionContext):
@@ -525,7 +546,8 @@ def with_transaction_decided(
525
546
  exclusive connection), the Python analogue of v1 ``PoolTransaction``. **No tx-control is issued
526
547
  at acquire** (Phase D / #95): the connection is only owned here.
527
548
  2. pin it into a tx-scoped :class:`ExecutionContext` (and the ambient contextvar) so EVERY
528
- statement resolves THAT connection via the seam — never a fresh pooled one;
549
+ statement of the tx's own database resolves THAT connection via the seam — never a fresh
550
+ pooled one, and a statement naming a DIFFERENT database is rejected instead;
529
551
  3. issue the isolation SET + ``BEGIN`` (``before`` → BEGIN → ``after``) THROUGH the seam
530
552
  (:func:`run` on the PINNED ctx) — so a registered middleware OBSERVES the runtime BEGIN (full TS
531
553
  parity, Phase D / #95). tx-control goes through the UNGUARDED :func:`run` seam (never
@@ -702,8 +724,8 @@ def transaction(
702
724
  ## The ambient-tx JOIN — how operations participate (the core #86 fix; python = contextvars)
703
725
 
704
726
  ``fn`` takes NO connection argument. Instead the pinned tx ctx lives in the ambient contextvar
705
- (:func:`current_context`). Every operation ``fn`` issues — a live-DB write via
706
- ``execute_transaction_bundle``, a read via ``execute_bundle``detects that ambient pinned ctx and
727
+ (:func:`current_context`). Every operation ``fn`` issues — a write via the guarded write seam
728
+ (:func:`run_guarded`), a read via the read seam resolves that ambient pinned ctx and
707
729
  runs its statements on THAT connection **without opening its own BEGIN/COMMIT** (the nested-join,
708
730
  below). So N operations inside one ``transaction(fn)`` produce exactly ONE BEGIN + ONE COMMIT on
709
731
  ONE connection. Outside a ``transaction(fn)`` the ambient pin is absent, so a bare guarded write's
@@ -759,7 +781,7 @@ def transaction(
759
781
  # A live PG 40001 / MySQL 1213 (raised at COMMIT as a raw psycopg/PyMySQL error) thus flows
760
782
  # through `map_sqlite_error` here, making the `.wrapped` chain genuinely load-bearing on the
761
783
  # live retry path (neuter `.wrapped` → this classification goes RED). An already-mapped
762
- # `SqlFailure` (e.g. from a nested `execute_transaction_bundle`) is left as-is (no re-map).
784
+ # `SqlFailure` (e.g. surfaced by the guarded write seam) is left as-is (no re-map).
763
785
  failure = error if isinstance(error, SqlFailure) else map_sqlite_error(error)
764
786
  if attempt < retry_limit and opts.retry_on_error and is_retryable_tx_error(failure):
765
787
  # Exponential backoff before RETRYing the whole transaction on a fresh connection.
@@ -7,7 +7,7 @@ a single source of truth (no duplicated grouping logic):
7
7
 
8
8
  - the op-INDEPENDENT ``pluck`` / ``group`` leaves (``./leaves``) — the eager N+1-free graph
9
9
  (``parents → pluck → executeSQL(WHERE fk = ANY(?)) → group``);
10
- - the RUNTIME batch relation path (``./relation`` ``run_relation_op`` / ``distribute_to_parent``),
10
+ - already-fetched rows, grouped over the SAME algorithm,
11
11
  which groups already-fetched rows over the SAME core.
12
12
 
13
13
  Nothing here touches SQL or a driver: it is pure in-memory grouping over already-fetched rows