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.
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/PKG-INFO +1 -1
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime/__init__.py +4 -39
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime/connection_routing.py +64 -7
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime/driver.py +85 -9
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime/errors.py +6 -6
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime/exec_context.py +46 -24
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime/grouping.py +1 -1
- litedbmodel_runtime-2.2.4/litedbmodel_runtime/leaves.py +364 -0
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime.egg-info/PKG-INFO +1 -1
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime.egg-info/SOURCES.txt +1 -6
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/pyproject.toml +1 -1
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/tests/test_connection_routing.py +85 -0
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/tests/test_connection_routing_livedb.py +6 -4
- litedbmodel_runtime-2.2.4/tests/test_consumes_bc_core.py +72 -0
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/tests/test_dialect.py +3 -3
- litedbmodel_runtime-2.2.4/tests/test_dynamic_where.py +620 -0
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/tests/test_hard_limit_guard.py +4 -5
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/tests/test_middleware.py +35 -50
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/tests/test_orm_bench_native.py +16 -14
- litedbmodel_runtime-2.2.4/tests/test_render.py +16 -0
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/tests/test_transaction_boundary.py +38 -101
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/tests/test_tx_boundary_livedb.py +13 -23
- litedbmodel_runtime-2.2.2/litedbmodel_runtime/leaves.py +0 -193
- litedbmodel_runtime-2.2.2/litedbmodel_runtime/relation.py +0 -211
- litedbmodel_runtime-2.2.2/litedbmodel_runtime/runtime.py +0 -320
- litedbmodel_runtime-2.2.2/litedbmodel_runtime/static_bundle.py +0 -420
- litedbmodel_runtime-2.2.2/tests/test_consumes_bc_core.py +0 -68
- litedbmodel_runtime-2.2.2/tests/test_parallel_read.py +0 -125
- litedbmodel_runtime-2.2.2/tests/test_render.py +0 -104
- litedbmodel_runtime-2.2.2/tests/test_runtime.py +0 -208
- litedbmodel_runtime-2.2.2/tests/test_tx_isolation.py +0 -338
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/README.md +0 -0
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime/dialect.py +0 -0
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime/middleware.py +0 -0
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime/tx_options.py +0 -0
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime.egg-info/dependency_links.txt +0 -0
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime.egg-info/requires.txt +0 -0
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime.egg-info/top_level.txt +0 -0
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/setup.cfg +0 -0
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/tests/test_conformance_corpus.py +0 -0
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/tests/test_exec_context.py +0 -0
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/tests/test_grouping.py +0 -0
- {litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/tests/test_middleware_livedb.py +0 -0
- {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.
|
|
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 .
|
|
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.
|
|
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
|
]
|
{litedbmodel_runtime-2.2.2 → litedbmodel_runtime-2.2.4}/litedbmodel_runtime/connection_routing.py
RENAMED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
965
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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`.
|
|
357
|
-
|
|
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
|
|
71
|
-
``hasMany`` relation batch (``context='relation'``) returns MORE rows than
|
|
72
|
-
portable artifact
|
|
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:`
|
|
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
|
-
(
|
|
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
|
|
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
|
|
15
|
-
transaction body runs on it
|
|
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
|
|
154
|
-
|
|
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
|
|
244
|
-
|
|
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
|
|
259
|
-
|
|
260
|
-
the registry; the driver-only ctors keep working
|
|
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 ⇒
|
|
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`
|
|
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
|
|
329
|
-
|
|
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
|
|
379
|
-
|
|
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
|
|
477
|
-
|
|
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
|
|
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
|
|
706
|
-
|
|
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.
|
|
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
|
-
-
|
|
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
|