litedbmodel-runtime 2.2.9__tar.gz → 2.2.11__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 (35) hide show
  1. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/PKG-INFO +2 -2
  2. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/litedbmodel_runtime/__init__.py +1 -1
  3. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/litedbmodel_runtime/connection_routing.py +88 -25
  4. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/litedbmodel_runtime/driver.py +125 -88
  5. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/litedbmodel_runtime/exec_context.py +2 -1
  6. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/litedbmodel_runtime.egg-info/PKG-INFO +2 -2
  7. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/litedbmodel_runtime.egg-info/requires.txt +1 -1
  8. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/pyproject.toml +2 -2
  9. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/tests/test_connection_routing.py +101 -3
  10. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/tests/test_connection_routing_livedb.py +318 -0
  11. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/README.md +0 -0
  12. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/litedbmodel_runtime/dialect.py +0 -0
  13. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/litedbmodel_runtime/errors.py +0 -0
  14. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/litedbmodel_runtime/grouping.py +0 -0
  15. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/litedbmodel_runtime/leaves.py +0 -0
  16. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/litedbmodel_runtime/middleware.py +0 -0
  17. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/litedbmodel_runtime/tx_options.py +0 -0
  18. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/litedbmodel_runtime.egg-info/SOURCES.txt +0 -0
  19. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/litedbmodel_runtime.egg-info/dependency_links.txt +0 -0
  20. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/litedbmodel_runtime.egg-info/top_level.txt +0 -0
  21. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/setup.cfg +0 -0
  22. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/tests/test_conformance_corpus.py +0 -0
  23. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/tests/test_consumes_bc_core.py +0 -0
  24. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/tests/test_dialect.py +0 -0
  25. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/tests/test_dynamic_where.py +0 -0
  26. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/tests/test_exec_context.py +0 -0
  27. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/tests/test_grouping.py +0 -0
  28. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/tests/test_hard_limit_guard.py +0 -0
  29. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/tests/test_middleware.py +0 -0
  30. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/tests/test_middleware_livedb.py +0 -0
  31. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/tests/test_orm_bench_native.py +0 -0
  32. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/tests/test_render.py +0 -0
  33. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/tests/test_transaction_boundary.py +0 -0
  34. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/tests/test_tx_boundary_livedb.py +0 -0
  35. {litedbmodel_runtime-2.2.9 → litedbmodel_runtime-2.2.11}/tests/test_tx_options.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: litedbmodel-runtime
3
- Version: 2.2.9
3
+ Version: 2.2.11
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
7
7
  Keywords: orm,sql,ir,runtime,conformance,litedbmodel
8
8
  Requires-Python: >=3.9
9
9
  Description-Content-Type: text/markdown
10
- Requires-Dist: behavior-contracts==0.11.17
10
+ Requires-Dist: behavior-contracts==0.11.20
11
11
  Provides-Extra: test
12
12
  Requires-Dist: pytest>=7.0; extra == "test"
13
13
  Provides-Extra: livedb
@@ -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.9"
107
+ __version__ = "2.2.11"
108
108
 
109
109
  __all__ = [
110
110
  "__version__",
@@ -15,7 +15,8 @@ A statement's connection is resolved in THIS priority (first match wins):
15
15
  DIFFERENT database than the tx opened on is REJECTED there rather than routed (Phase A;
16
16
  resolved by :class:`ExecutionContext` BEFORE the routing steps, since only it holds the pin).
17
17
  2. **writer scope / writer-sticky** — inside :func:`with_writer`, or within ``writer_sticky_duration``
18
- after a transaction (read-your-writes), a READ goes to the WRITER pool (Phase C — here).
18
+ after a transaction the same chain committed (read-your-writes), a READ goes to the WRITER pool
19
+ (Phase C — here).
19
20
  3. **read=reader / write=writer** — otherwise a read → the reader pool, a write → the writer pool
20
21
  (reader/writer separation; single-pool config ⇒ reader IS writer, Phase C).
21
22
  4. **named-DB routing** — the target pool is selected by ``intent.db`` (the connection NAME) against
@@ -49,6 +50,8 @@ import time
49
50
  from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
50
51
 
51
52
  from .driver import (
53
+ DEFAULT_ACQUIRE_TIMEOUT_SECONDS,
54
+ DEFAULT_IDLE_TIMEOUT_SECONDS,
52
55
  RunInfo,
53
56
  TxConnection,
54
57
  _conn_all,
@@ -70,6 +73,8 @@ _DEFAULT_KEEP_ALIVE = False
70
73
  _DEFAULT_KEEP_ALIVE_INITIAL_DELAY_MS = 10000
71
74
  _DEFAULT_MIN_POOL = 0
72
75
  _DEFAULT_MAX_POOL = 10
76
+ _DEFAULT_CONNECT_TIMEOUT_MS = int(DEFAULT_ACQUIRE_TIMEOUT_SECONDS * 1000)
77
+ _DEFAULT_IDLE_TIMEOUT_MS = int(DEFAULT_IDLE_TIMEOUT_SECONDS * 1000)
73
78
 
74
79
 
75
80
  class ConnectionConfig:
@@ -91,6 +96,10 @@ class ConnectionConfig:
91
96
  - ``keep_alive_initial_delay_millis``: ms before the first keepalive probe (@default 10000).
92
97
  - ``min_pool``: minimum pooled connections kept warm (@default 0).
93
98
  - ``max_pool``: maximum pooled connections (@default 10).
99
+ - ``connect_timeout_millis``: upper bound in ms on getting a connection — opening a new one, and
100
+ waiting for a free one when the pool is at ``max_pool``. ``0`` ⇒ no limit (@default 30000).
101
+ - ``idle_timeout_millis``: ms an unused pooled connection stays open before it is closed
102
+ (@default 10000).
94
103
  - ``search_path``: PG ``search_path`` set on each pooled connection at checkout (schema routing).
95
104
  - ``charset``: MySQL connection charset / PG ``client_encoding`` set on each pooled connection.
96
105
  """
@@ -107,6 +116,8 @@ class ConnectionConfig:
107
116
  "keep_alive_initial_delay_millis",
108
117
  "min_pool",
109
118
  "max_pool",
119
+ "connect_timeout_millis",
120
+ "idle_timeout_millis",
110
121
  "search_path",
111
122
  "charset",
112
123
  )
@@ -126,6 +137,8 @@ class ConnectionConfig:
126
137
  max_pool: Optional[int] = None,
127
138
  search_path: Optional[str] = None,
128
139
  charset: Optional[str] = None,
140
+ connect_timeout_millis: Optional[int] = None,
141
+ idle_timeout_millis: Optional[int] = None,
129
142
  ) -> None:
130
143
  self.driver = driver
131
144
  self.host = host
@@ -140,12 +153,14 @@ class ConnectionConfig:
140
153
  self.max_pool = max_pool
141
154
  self.search_path = search_path
142
155
  self.charset = charset
156
+ self.connect_timeout_millis = connect_timeout_millis
157
+ self.idle_timeout_millis = idle_timeout_millis
143
158
 
144
159
 
145
160
  class ResolvedConnectionConfig:
146
161
  """The resolved (defaults-applied) config the pool builder consumes — no ``None`` holes on the
147
162
  knobs (``query_timeout`` / ``keep_alive`` / ``keep_alive_initial_delay_millis`` / ``min_pool`` /
148
- ``max_pool`` are always concrete). Connection-target fields (``host`` … ``password``) and the
163
+ ``max_pool`` / ``connect_timeout_millis`` / ``idle_timeout_millis`` are always concrete). Connection-target fields (``host`` … ``password``) and the
149
164
  optional session knobs (``search_path`` / ``charset``) stay ``None`` when unset (so a factory omits
150
165
  them). Mirrors the TS ``ResolvedConnectionConfig``.
151
166
  """
@@ -162,6 +177,8 @@ class ResolvedConnectionConfig:
162
177
  "keep_alive_initial_delay_millis",
163
178
  "min_pool",
164
179
  "max_pool",
180
+ "connect_timeout_millis",
181
+ "idle_timeout_millis",
165
182
  "search_path",
166
183
  "charset",
167
184
  )
@@ -174,6 +191,8 @@ class ResolvedConnectionConfig:
174
191
  keep_alive_initial_delay_millis: int,
175
192
  min_pool: int,
176
193
  max_pool: int,
194
+ connect_timeout_millis: int,
195
+ idle_timeout_millis: int,
177
196
  host: Optional[str] = None,
178
197
  port: Optional[int] = None,
179
198
  database: Optional[str] = None,
@@ -188,6 +207,8 @@ class ResolvedConnectionConfig:
188
207
  self.keep_alive_initial_delay_millis = keep_alive_initial_delay_millis
189
208
  self.min_pool = min_pool
190
209
  self.max_pool = max_pool
210
+ self.connect_timeout_millis = connect_timeout_millis
211
+ self.idle_timeout_millis = idle_timeout_millis
191
212
  self.host = host
192
213
  self.port = port
193
214
  self.database = database
@@ -199,7 +220,8 @@ class ResolvedConnectionConfig:
199
220
 
200
221
  def resolve_connection_config(config: Optional[ConnectionConfig] = None) -> ResolvedConnectionConfig:
201
222
  """Apply the C3 defaults (``query_timeout=0``, ``keep_alive=False``,
202
- ``keep_alive_initial_delay_millis=10000``, ``min_pool=0``, ``max_pool=10``, ``driver='postgres'``).
223
+ ``keep_alive_initial_delay_millis=10000``, ``min_pool=0``, ``max_pool=10``,
224
+ ``connect_timeout_millis=30000``, ``idle_timeout_millis=10000``, ``driver='postgres'``).
203
225
  Mirrors the TS ``resolveConnectionConfig`` — identical field names + defaults."""
204
226
  c = config if config is not None else ConnectionConfig()
205
227
  return ResolvedConnectionConfig(
@@ -218,6 +240,10 @@ def resolve_connection_config(config: Optional[ConnectionConfig] = None) -> Reso
218
240
  ),
219
241
  min_pool=c.min_pool if c.min_pool is not None else _DEFAULT_MIN_POOL,
220
242
  max_pool=c.max_pool if c.max_pool is not None else _DEFAULT_MAX_POOL,
243
+ connect_timeout_millis=(
244
+ c.connect_timeout_millis if c.connect_timeout_millis is not None else _DEFAULT_CONNECT_TIMEOUT_MS
245
+ ),
246
+ idle_timeout_millis=c.idle_timeout_millis if c.idle_timeout_millis is not None else _DEFAULT_IDLE_TIMEOUT_MS,
221
247
  search_path=c.search_path,
222
248
  charset=c.charset,
223
249
  )
@@ -610,11 +636,16 @@ def with_writer(fn: Callable[[], Any]) -> Any:
610
636
 
611
637
 
612
638
  class WriterStickyClock:
613
- """A writer-sticky CLOCK (C1, read-your-writes; v1 ``_shouldUseWriterSticky`` + ``_lastTransactionTime``).
614
- After a transaction (or a bare write) COMMITs, reads within ``sticky_duration_ms`` route to the
615
- WRITER pool so a just-committed row is visible despite reader-replica lag. The ctx owns ONE clock
616
- instance; the tx runtime :meth:`mark` s it on every successful write/commit; :func:`resolve_pool`
617
- reads :meth:`is_sticky`.
639
+ """A writer-sticky CLOCK (C1, read-your-writes). After a transaction COMMITs, the reads of the
640
+ execution chain that committed route to the WRITER pool for ``writer_sticky_duration`` ms, so the
641
+ just-committed row is visible despite reader-replica lag. Other chains — concurrent requests on other
642
+ threads — keep reading from the reader: they did not write, so they have no lag to hide.
643
+
644
+ The chain is the current :mod:`contextvars` Context: each thread has its own, and a transaction body
645
+ runs in its caller's Context, so the tx runtime's :meth:`mark` after COMMIT is seen by the caller's
646
+ later reads and by no other thread. The clock is per routing config (per connection setup); the time
647
+ it holds is per chain, in a ``ContextVar`` this instance owns. :func:`resolve_pool` reads
648
+ :meth:`is_sticky`.
618
649
 
619
650
  ``use_writer_after_transaction=False`` disables it entirely (:meth:`is_sticky` always False). A
620
651
  single-pool deployment (reader IS writer) is unaffected — the diverted pool is the same object.
@@ -630,30 +661,32 @@ class WriterStickyClock:
630
661
  writer_sticky_duration: int = 5000,
631
662
  now: Optional[Callable[[], int]] = None,
632
663
  ) -> None:
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
664
+ # The current chain's last commit time: ``None`` until that chain's first ``mark()`` — absence,
665
+ # NOT a value sentinel. The injectable clock legitimately returns 0 (rust's SystemClock does right
666
+ # after process start), so encoding "never marked" as the value 0 would mis-classify a ``mark()``
667
+ # at clock t=0 as unmarked and leak the read-your-writes read to the reader replica.
668
+ self._last_write_at: "contextvars.ContextVar[Optional[int]]" = contextvars.ContextVar(
669
+ "litedbmodel_writer_sticky_last_write_at", default=None
670
+ )
638
671
  self._enabled = use_writer_after_transaction
639
672
  self._sticky_duration_ms = writer_sticky_duration
640
673
  # Default clock: monotonic ms (Date.now analogue). An injectable clock makes expiry deterministic.
641
674
  self._now: Callable[[], int] = now if now is not None else (lambda: int(time.monotonic() * 1000))
642
675
 
643
676
  def mark(self) -> None:
644
- """Record that a write/commit just happened (the tx runtime calls this on success)."""
677
+ """Record that the current chain just committed (the tx runtime calls this on success)."""
645
678
  if self._enabled:
646
- self._last_write_at = self._now()
679
+ self._last_write_at.set(self._now())
647
680
 
648
681
  def is_sticky(self) -> bool:
649
- """Is a read currently sticky-to-writer (within ``writer_sticky_duration`` of the last write)?"""
650
- if not self._enabled or self._last_write_at is None:
682
+ """Is a read in the current chain sticky-to-writer (within ``writer_sticky_duration`` of its
683
+ last commit)?"""
684
+ if not self._enabled:
651
685
  return False
652
- return (self._now() - self._last_write_at) < self._sticky_duration_ms
653
-
654
- def reset(self) -> None:
655
- """Reset the clock (e.g. between tests / on :func:`close_all_pools`)."""
656
- self._last_write_at = None
686
+ last = self._last_write_at.get()
687
+ if last is None:
688
+ return False
689
+ return (self._now() - last) < self._sticky_duration_ms
657
690
 
658
691
 
659
692
  # ── The routing config a ctx carries (C1+C2+C3) ────────────────────────────────
@@ -680,7 +713,8 @@ def resolve_pool(intent: StatementIntent, routing: RoutingConfig) -> ConnectionP
680
713
 
681
714
  1. **named-DB** (``intent.db``) selects the :class:`ReaderWriterPools` pair (loud on unknown name).
682
715
  2. within that pair: a WRITE ⇒ the writer pool.
683
- 3. a READ in a :func:`with_writer` scope OR within writer-sticky ⇒ the writer pool (read-your-writes).
716
+ 3. a READ in a :func:`with_writer` scope OR within the current chain's writer-sticky window ⇒ the
717
+ writer pool (read-your-writes).
684
718
  4. otherwise a READ ⇒ the reader pool.
685
719
 
686
720
  Single-pool (reader IS writer) ⇒ every branch returns the same pool (backward-compat). Mirrors the
@@ -887,6 +921,31 @@ def close_all_pools(closers: Sequence[PoolCloser]) -> None:
887
921
  # - min_pool: the dependency-free `_ConnectionPool` opens connections LAZILY up to `max_pool`; it has
888
922
  # no warm-idle floor, so `min_pool` is a documented NO-OP for both drivers (same as mysql2's absent
889
923
  # `min`). The SIZING CAP that matters — `max_pool` → the pool ceiling — IS honored at construction.
924
+ #
925
+ # ── connect_timeout_millis / idle_timeout_millis (both drivers) ──
926
+ # - The wait for a free connection at `max_pool` and the idle close are the `_ConnectionPool`'s own
927
+ # (`acquire_timeout` / `idle_timeout`), so both are millisecond-exact on PG and MySQL alike.
928
+ # - The connect itself is bounded by the driver's `connect_timeout`. PyMySQL takes fractional seconds.
929
+ # psycopg takes WHOLE seconds and raises anything under 2 to 2 (libpq's rule, which psycopg
930
+ # enforces in `conninfo.timeout_from_conninfo`), so on PG the connect bound is
931
+ # `max(2, ceil(connect_timeout_millis / 1000))` seconds.
932
+ # - `connect_timeout_millis = 0` is "no limit". The pool's wait honors it exactly. Neither driver can
933
+ # connect without a limit: psycopg reads a `connect_timeout` of 0 as its own 130s bound
934
+ # (`conninfo._DEFAULT_CONNECT_TIMEOUT`), and PyMySQL accepts only `0 < connect_timeout <= 31536000`,
935
+ # so 0 is passed to it as that maximum (one year).
936
+ _PYMYSQL_MAX_CONNECT_TIMEOUT_SECONDS = 31536000
937
+
938
+
939
+ def _pool(conn_factory: Callable[[], Any], config: ResolvedConnectionConfig) -> _ConnectionPool:
940
+ """The `_ConnectionPool` a factory builds: sized to ``max_pool`` (the SOLE cap source), waiting at
941
+ most ``connect_timeout_millis`` for a free connection (``0`` ⇒ no limit) and closing one unused for
942
+ ``idle_timeout_millis``."""
943
+ return _ConnectionPool(
944
+ conn_factory,
945
+ config.max_pool,
946
+ acquire_timeout=config.connect_timeout_millis / 1000.0 or None,
947
+ idle_timeout=config.idle_timeout_millis / 1000.0,
948
+ )
890
949
 
891
950
 
892
951
  def _pg_connect_kwargs(config: ResolvedConnectionConfig) -> Dict[str, Any]:
@@ -903,6 +962,9 @@ def _pg_connect_kwargs(config: ResolvedConnectionConfig) -> Dict[str, Any]:
903
962
  kwargs["user"] = config.user
904
963
  if config.password is not None:
905
964
  kwargs["password"] = config.password
965
+ # Whole seconds, rounded UP so the bound is never shorter than configured (psycopg raises < 2 to 2;
966
+ # 0 stays 0, which psycopg reads as its 130s default).
967
+ kwargs["connect_timeout"] = -(-config.connect_timeout_millis // 1000)
906
968
  if config.keep_alive:
907
969
  # libpq TCP keepalive (SECONDS for the idle delay; convert the ms knob, min 1s).
908
970
  kwargs["keepalives"] = 1
@@ -929,7 +991,7 @@ def pg_pool_factory() -> PoolFactory:
929
991
  def conn_factory() -> Any:
930
992
  return psycopg.connect(**kwargs)
931
993
 
932
- raw = _ConnectionPool(conn_factory, config.max_pool) # SIZING at construction: max_pool = the cap
994
+ raw = _pool(conn_factory, config)
933
995
  pool = RawConnectionPool(raw, _dollar_to_pyformat, emulate_returning=False)
934
996
  return pool, raw.close
935
997
 
@@ -948,6 +1010,7 @@ def _mysql_connect_kwargs(config: ResolvedConnectionConfig) -> Dict[str, Any]:
948
1010
  kwargs["user"] = config.user
949
1011
  if config.password is not None:
950
1012
  kwargs["password"] = config.password
1013
+ kwargs["connect_timeout"] = config.connect_timeout_millis / 1000.0 or _PYMYSQL_MAX_CONNECT_TIMEOUT_SECONDS
951
1014
  # NB: PyMySQL exposes no TCP-keepalive connect option ⇒ keep_alive is a documented NO-OP on MySQL
952
1015
  # (mirror the TS mysqlPoolFactory's "mysql2 has no min idle floor" per-driver deviation note).
953
1016
  return kwargs
@@ -972,7 +1035,7 @@ def mysql_pool_factory() -> PoolFactory:
972
1035
  def conn_factory() -> Any:
973
1036
  return pymysql.connect(**kwargs)
974
1037
 
975
- raw = _ConnectionPool(conn_factory, config.max_pool) # SIZING at construction: max_pool = the cap
1038
+ raw = _pool(conn_factory, config)
976
1039
  pool = RawConnectionPool(raw, _qmark_to_pyformat, emulate_returning=True)
977
1040
  return pool, raw.close
978
1041
 
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  import re
15
15
  import sqlite3
16
- from typing import Any, Callable, Dict, List, Protocol, Sequence, Tuple
16
+ from typing import Any, Callable, Dict, List, Optional, Protocol, Sequence, Tuple
17
17
 
18
18
 
19
19
  # ── Row materialization: per-column-set dict builder (read-path hot loop, #63 §② perf) ──────────
@@ -248,119 +248,156 @@ 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.
251
+ # Bound EVERY acquire so an UNREACHABLE / non-responding DB fails in FINITE time instead of hanging.
252
+ # `acquire` has two sub-waits, each bounded to this budget: the factory's connect (`connect_timeout`,
253
+ # set in the pg/mysql factories) and the wait for a released connection when the pool is at capacity.
254
+ # 30s is the codebase default (the v1 pg/mysql drivers' `config.timeout || 30`) and the
255
+ # `connectTimeoutMillis` default of the connection config. Only Python hand-rolls its pool, so it supplies
256
+ # the bound the other runtimes take from their pool libraries.
259
257
  DEFAULT_ACQUIRE_TIMEOUT_SECONDS = 30.0
260
258
 
259
+ # How long a released connection may sit unused in the pool before the pool closes it — the
260
+ # `idleTimeoutMillis` default of the connection config (and of the v1 pg/mysql drivers).
261
+ DEFAULT_IDLE_TIMEOUT_SECONDS = 10.0
262
+
261
263
 
262
264
  class _ConnectionPool:
263
265
  """A minimal thread-safe, bounded pool of DB-API connections (dependency-free).
264
266
 
265
- A bounded ``queue`` of live connections created lazily up to ``max_size``. ``acquire`` blocks
266
- for a free connection (or opens a new one below the ceiling); ``release`` returns it. This keeps
267
- the parallel-read seam dependency-free (no psycopg_pool / DBUtils needed) while giving each
268
- concurrent sibling its own connection.
267
+ Connections are opened lazily up to ``max_size``. ``acquire`` hands out the most recently released
268
+ free connection, opens a new one below the ceiling, or waits — at most ``acquire_timeout`` seconds,
269
+ without limit when it is ``None`` — for a release or a freed slot. ``release`` returns a connection; one left unused for
270
+ ``idle_timeout`` seconds is closed by a timer (a daemon ``threading.Timer`` armed while free
271
+ connections exist), so a quiet application does not hold server sessions open. This keeps the
272
+ parallel-read seam dependency-free while giving each concurrent sibling its own connection.
269
273
  """
270
274
 
271
- __slots__ = ("_factory", "_max", "_free", "_opened", "_lock", "_closed", "_acquire_timeout")
275
+ __slots__ = ("_factory", "_max", "_free", "_opened", "_cond", "_closed", "_acquire_timeout", "_idle_timeout", "_reaper")
272
276
 
273
- def __init__(self, factory, max_size: int, acquire_timeout: float = DEFAULT_ACQUIRE_TIMEOUT_SECONDS) -> None:
274
- import queue as _queue
277
+ def __init__(
278
+ self,
279
+ factory,
280
+ max_size: int,
281
+ acquire_timeout: Optional[float] = DEFAULT_ACQUIRE_TIMEOUT_SECONDS,
282
+ idle_timeout: float = DEFAULT_IDLE_TIMEOUT_SECONDS,
283
+ ) -> None:
275
284
  import threading as _threading
276
285
 
277
286
  self._factory = factory
278
287
  self._max = max_size
279
- self._free: "Any" = _queue.LifoQueue()
288
+ # Free connections with the monotonic time each was released, oldest first: ``acquire`` takes
289
+ # from the end (most recently used), the idle timer closes from the front.
290
+ self._free: List[Tuple[Any, float]] = []
280
291
  self._opened = 0
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).
292
+ self._cond = _threading.Condition()
285
293
  self._acquire_timeout = acquire_timeout
286
- # Fail-fast after close(): a post-close acquire must RAISE, not block forever on `_free.get()`
287
- # (the pool is drained and nothing will be released). Additive — the Phase A/B paths never
288
- # acquire after close, so behavior there is unchanged; Phase C's close_all_pools relies on it so
289
- # a query on a closed pool fails loudly (mirror the TS pool.end() → query rejects).
294
+ self._idle_timeout = idle_timeout
295
+ self._reaper: Any = None
296
+ # Fail-fast after close(): a post-close acquire RAISES instead of waiting on a drained pool
297
+ # (close_all_pools relies on it so a query on a closed pool fails loudly).
290
298
  self._closed = False
291
299
 
292
300
  def acquire(self) -> Any:
293
- import queue as _queue
294
-
295
- if self._closed:
296
- raise RuntimeError("scp connection pool: acquire after close (the pool has been closed)")
297
- # Fast path: reuse a free connection.
298
- try:
299
- return self._free.get_nowait()
300
- except _queue.Empty:
301
- pass
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
305
- with self._lock:
306
- if self._closed:
307
- raise RuntimeError("scp connection pool: acquire after close (the pool has been closed)")
308
- if self._opened < self._max:
309
- self._opened += 1
310
- reserved = True
311
- if reserved:
312
- try:
313
- return self._factory()
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).
301
+ import time as _time
302
+
303
+ deadline = None if self._acquire_timeout is None else _time.monotonic() + self._acquire_timeout
304
+ with self._cond:
305
+ while True:
306
+ if self._closed:
307
+ raise RuntimeError("scp connection pool: acquire after close (the pool has been closed)")
308
+ if self._free:
309
+ return self._free.pop()[0]
310
+ if self._opened < self._max:
311
+ # Reserve the slot, then open OUTSIDE the lock (a slow connect must not serialize
312
+ # every other acquire/release on this pool).
313
+ self._opened += 1
314
+ break
315
+ # At capacity: wait for a release (or a discarded/closed slot), bounded by the deadline
316
+ # when there is one.
317
+ remaining = None if deadline is None else deadline - _time.monotonic()
318
+ if remaining is not None and remaining <= 0:
319
+ raise TimeoutError(
320
+ f"scp connection pool: acquire timed out after {self._acquire_timeout}s "
321
+ f"(pool at capacity {self._max}, no connection released)"
322
+ )
323
+ self._cond.wait(remaining)
325
324
  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
325
+ return self._factory()
326
+ except BaseException:
327
+ # The open FAILED — give the reserved slot back. Without this a failed connect permanently
328
+ # consumes capacity, and once every slot is consumed each later acquire waits for a
329
+ # connection that never comes.
330
+ self._free_slots(1)
331
+ raise
332
332
 
333
333
  def release(self, conn: Any) -> None:
334
- self._free.put(conn)
334
+ import time as _time
335
+
336
+ with self._cond:
337
+ if not self._closed:
338
+ self._free.append((conn, _time.monotonic()))
339
+ self._cond.notify()
340
+ self._arm_reaper()
341
+ return
342
+ _close_quietly(conn) # released after close(): nothing will hand it out again
335
343
 
336
344
  def discard(self, conn: Any) -> None:
337
345
  """Permanently drop a POISONED connection (a tx whose COMMIT/ROLLBACK itself raised): close it
338
- and DECREMENT the opened count so a fresh connection can be opened in its place. Without the
339
- decrement the pool's ``_opened < _max`` ceiling would count the dead connection forever and
340
- capacity would shrink by one per discard — eventual exhaustion under repeated commit failures
341
- (the deeper half of the #78 leak: releasing wasn't enough; the destroy path must re-open a slot).
342
- """
343
- try:
344
- conn.close()
345
- except Exception:
346
- pass
347
- with self._lock:
348
- if self._opened > 0:
349
- self._opened -= 1
346
+ and free its slot so a fresh connection can be opened in its place — otherwise capacity would
347
+ shrink by one per discard until the pool is exhausted."""
348
+ _close_quietly(conn)
349
+ self._free_slots(1)
350
350
 
351
351
  def close(self) -> None:
352
- import queue as _queue
353
-
354
- self._closed = True # fail-fast: a subsequent acquire raises instead of blocking on a drained pool
355
- while True:
356
- try:
357
- conn = self._free.get_nowait()
358
- except _queue.Empty:
359
- break
360
- try:
361
- conn.close()
362
- except Exception:
363
- pass
352
+ with self._cond:
353
+ self._closed = True
354
+ free, self._free = self._free, []
355
+ if self._reaper is not None:
356
+ self._reaper.cancel()
357
+ self._reaper = None
358
+ self._cond.notify_all() # waiters raise "acquire after close" instead of timing out
359
+ for conn, _ in free:
360
+ _close_quietly(conn)
361
+
362
+ def _free_slots(self, n: int) -> None:
363
+ with self._cond:
364
+ self._opened = max(0, self._opened - n)
365
+ self._cond.notify(n) # a waiter may now open a connection in a freed slot
366
+
367
+ def _arm_reaper(self) -> None:
368
+ """(Under the lock.) Arm the idle timer for the oldest free connection, unless it is armed."""
369
+ import threading as _threading
370
+ import time as _time
371
+
372
+ if self._reaper is not None or not self._free or self._closed:
373
+ return
374
+ delay = max(0.0, self._free[0][1] + self._idle_timeout - _time.monotonic())
375
+ self._reaper = _threading.Timer(delay, self._reap)
376
+ self._reaper.daemon = True
377
+ self._reaper.start()
378
+
379
+ def _reap(self) -> None:
380
+ """Close every free connection that has been unused for ``idle_timeout``, then re-arm."""
381
+ import time as _time
382
+
383
+ with self._cond:
384
+ self._reaper = None
385
+ cutoff = _time.monotonic() - self._idle_timeout
386
+ expired = [c for c, at in self._free if at <= cutoff]
387
+ self._free = [(c, at) for c, at in self._free if at > cutoff]
388
+ self._opened = max(0, self._opened - len(expired))
389
+ self._cond.notify(len(expired))
390
+ self._arm_reaper()
391
+ for conn in expired:
392
+ _close_quietly(conn)
393
+
394
+
395
+ def _close_quietly(conn: Any) -> None:
396
+ try:
397
+ conn.close()
398
+ except Exception:
399
+ pass
400
+
364
401
 
365
402
  # `$1`, `$2`, … (Postgres render output).
366
403
  _DOLLAR_RE = re.compile(r"\$\d+")
@@ -413,7 +413,8 @@ class ExecutionContext:
413
413
 
414
414
  def mark_sticky(self) -> None:
415
415
  """Mark the writer-sticky clock (Phase C-1, read-your-writes): the tx runtime calls this on a
416
- successful COMMIT so subsequent reads within ``writer_sticky_duration`` route to the writer pool.
416
+ successful COMMIT so the current chain's (this Context's) subsequent reads within
417
+ ``writer_sticky_duration`` route to the writer pool; other threads keep reading from the reader.
417
418
  A no-op on the single-driver path (no routing ⇒ no sticky clock)."""
418
419
  if self._routing is not None:
419
420
  self._routing.sticky.mark()
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: litedbmodel-runtime
3
- Version: 2.2.9
3
+ Version: 2.2.11
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
7
7
  Keywords: orm,sql,ir,runtime,conformance,litedbmodel
8
8
  Requires-Python: >=3.9
9
9
  Description-Content-Type: text/markdown
10
- Requires-Dist: behavior-contracts==0.11.17
10
+ Requires-Dist: behavior-contracts==0.11.20
11
11
  Provides-Extra: test
12
12
  Requires-Dist: pytest>=7.0; extra == "test"
13
13
  Provides-Extra: livedb
@@ -1,4 +1,4 @@
1
- behavior-contracts==0.11.17
1
+ behavior-contracts==0.11.20
2
2
 
3
3
  [livedb]
4
4
  psycopg[binary]>=3.1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "litedbmodel-runtime"
7
- version = "2.2.9"
7
+ version = "2.2.11"
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"
@@ -16,7 +16,7 @@ keywords = ["orm", "sql", "ir", "runtime", "conformance", "litedbmodel"]
16
16
  # evaluation (ref/refOpt/coalesce/eq/… — the SKIP guards + param slots) to the shared common
17
17
  # core, exactly as the TS reference imports `behavior-contracts` from npm. HARD dependency,
18
18
  # consumed from the published PyPI registry (NO local path — the no-local-deps gate forbids it).
19
- dependencies = ["behavior-contracts==0.11.17"]
19
+ dependencies = ["behavior-contracts==0.11.20"]
20
20
 
21
21
  [project.optional-dependencies]
22
22
  test = ["pytest>=7.0"]
@@ -77,7 +77,11 @@ def test_resolve_connection_config_defaults():
77
77
  assert r.keep_alive_initial_delay_millis == 10000
78
78
  assert r.min_pool == 0
79
79
  assert r.max_pool == 10
80
+ assert r.connect_timeout_millis == 30000
81
+ assert r.idle_timeout_millis == 10000
80
82
  assert r.search_path is None and r.charset is None
83
+ r3 = resolve_connection_config(ConnectionConfig(connect_timeout_millis=250, idle_timeout_millis=400))
84
+ assert (r3.connect_timeout_millis, r3.idle_timeout_millis) == (250, 400)
81
85
  # Overrides pass through with the same field names.
82
86
  r2 = resolve_connection_config(
83
87
  ConnectionConfig(driver="mysql", max_pool=2, min_pool=1, query_timeout=250, keep_alive=True, search_path="app", charset="utf8mb4")
@@ -219,9 +223,6 @@ def test_writer_sticky_clock_expiry_deterministic():
219
223
  assert c.is_sticky() is True # 400ms < 1000ms window
220
224
  clock["t"] = 1600
221
225
  assert c.is_sticky() is False # 1100ms ≥ window
222
- c.reset()
223
- clock["t"] = 1700
224
- assert c.is_sticky() is False
225
226
 
226
227
 
227
228
  def test_writer_sticky_armed_at_clock_zero():
@@ -240,6 +241,60 @@ def test_writer_sticky_armed_at_clock_zero():
240
241
  assert resolve_pool(StatementIntent(write=False), routing) is writer # read-your-writes → writer
241
242
 
242
243
 
244
+ def test_writer_sticky_is_per_thread():
245
+ # Two threads alive at the same time on ONE routing config: A commits (marks), B does not. A's reads
246
+ # go to the writer; B's reads — before AND after A's mark, while A is still in its window — stay on
247
+ # the reader. A second clock on another routing config is not armed by A's mark.
248
+ import threading
249
+
250
+ reader, writer = _FakePool("reader"), _FakePool("writer")
251
+ sticky = WriterStickyClock(use_writer_after_transaction=True, writer_sticky_duration=5000, now=lambda: 1_000_000)
252
+ other = WriterStickyClock(use_writer_after_transaction=True, writer_sticky_duration=5000, now=lambda: 1_000_000)
253
+ routing = _routing(reader, writer, sticky=sticky)
254
+ b_first, a_marked, b_checked = (threading.Barrier(2, timeout=10) for _ in range(3))
255
+ seen, errors = {}, []
256
+
257
+ def read():
258
+ return resolve_pool(StatementIntent(write=False), routing).label
259
+
260
+ def thread_a():
261
+ b_first.wait() # B has read once, before this mark
262
+ sticky.mark()
263
+ seen["a_after_mark"] = read()
264
+ seen["other_armed"] = other.is_sticky()
265
+ a_marked.wait()
266
+ b_checked.wait()
267
+ seen["a_later"] = read()
268
+
269
+ def thread_b():
270
+ seen["b_before"] = read()
271
+ b_first.wait()
272
+ a_marked.wait()
273
+ seen["b_after"] = read()
274
+ b_checked.wait()
275
+
276
+ def guarded(fn):
277
+ def body():
278
+ try:
279
+ fn()
280
+ except BaseException as e: # surfaced below: a thread's exception never fails pytest itself
281
+ errors.append(e)
282
+
283
+ return body
284
+
285
+ threads = [threading.Thread(target=guarded(f)) for f in (thread_a, thread_b)]
286
+ for t in threads:
287
+ t.start()
288
+ for t in threads:
289
+ t.join(10)
290
+ assert not any(t.is_alive() for t in threads) and errors == []
291
+ assert seen["a_after_mark"] == "writer" # the committing thread reads its own write from the writer
292
+ assert seen["other_armed"] is False # a mark on one clock does not arm another clock
293
+ assert seen["a_later"] == "writer" # still within the window on the committing thread
294
+ assert (seen["b_before"], seen["b_after"]) == ("reader", "reader") # the concurrent thread did not commit
295
+ assert sticky.is_sticky() is False # the test thread never committed
296
+
297
+
243
298
  # ── C3: configured_pool session apply/reset (no session leak) — fake conn ────────
244
299
 
245
300
 
@@ -479,3 +534,46 @@ def test_acquire_wait_succeeds_when_a_connection_is_released_in_time():
479
534
  releaser.join()
480
535
  assert got is held # the SAME connection came back through the wait
481
536
  assert time.monotonic() - t0 < 5.0
537
+
538
+
539
+ def test_connect_timeout_reaches_both_drivers_connect():
540
+ """``connect_timeout_millis`` bounds the driver's own connect: PyMySQL takes fractional seconds,
541
+ psycopg whole seconds (rounded UP, so the bound is never shorter than configured); 0 is no limit."""
542
+ from litedbmodel_runtime.connection_routing import _mysql_connect_kwargs, _pg_connect_kwargs
543
+
544
+ pg = resolve_connection_config(ConnectionConfig(driver="postgres", connect_timeout_millis=2500))
545
+ my = resolve_connection_config(ConnectionConfig(driver="mysql", connect_timeout_millis=2500))
546
+ assert _pg_connect_kwargs(pg)["connect_timeout"] == 3
547
+ assert _mysql_connect_kwargs(my)["connect_timeout"] == 2.5
548
+ # 0 ⇒ no limit: psycopg's own reading of 0, and PyMySQL's largest accepted value.
549
+ pg0 = resolve_connection_config(ConnectionConfig(driver="postgres", connect_timeout_millis=0))
550
+ my0 = resolve_connection_config(ConnectionConfig(driver="mysql", connect_timeout_millis=0))
551
+ assert _pg_connect_kwargs(pg0)["connect_timeout"] == 0
552
+ assert _mysql_connect_kwargs(my0)["connect_timeout"] == 31536000
553
+
554
+
555
+ def test_idle_connection_is_closed_by_the_pool():
556
+ """A released connection left unused for ``idle_timeout`` is closed and its slot freed, with no
557
+ acquire needed to notice; a connection that is still checked out is not touched."""
558
+ import time
559
+
560
+ from litedbmodel_runtime.driver import _ConnectionPool
561
+
562
+ class _Conn:
563
+ closed = False
564
+
565
+ def close(self):
566
+ self.closed = True
567
+
568
+ pool = _ConnectionPool(_Conn, max_size=2, idle_timeout=0.2)
569
+ idle, held = pool.acquire(), pool.acquire()
570
+ pool.release(idle)
571
+ deadline = time.monotonic() + 5.0
572
+ while not idle.closed and time.monotonic() < deadline:
573
+ time.sleep(0.02)
574
+ assert idle.closed and not held.closed
575
+ assert pool._opened == 1 and pool._free == [] # the idle one's slot is free again
576
+ pool.release(held)
577
+ assert pool.acquire() is held # reused before its idle time ran out ⇒ still open
578
+ assert not held.closed
579
+ pool.close()
@@ -323,6 +323,228 @@ def test_c1_with_writer_scope():
323
323
  raw.close()
324
324
 
325
325
 
326
+ # ════════════════════════════════════════════════════════════════════════════════
327
+ # C1 — the same routing under REAL concurrent threads (PG + MySQL)
328
+ # ════════════════════════════════════════════════════════════════════════════════
329
+
330
+ # Per dialect: the table, the placeholder style, the server-side session id, a server-side sleep, and the
331
+ # tx dialect name. Each test runs the SAME scenario on both servers.
332
+ _DIALECTS = {
333
+ "postgres": dict(
334
+ setup=_setup_pg,
335
+ raw=_pg_raw_pool,
336
+ factory=pg_pool_factory,
337
+ config=_pg_conn_config,
338
+ session_alive="SELECT count(*) FROM pg_stat_activity WHERE pid = %s",
339
+ pool=_pg_pool,
340
+ insert="INSERT INTO %s (id, val) VALUES ($1, $2)" % TBL,
341
+ select_ids="SELECT id FROM %s WHERE id >= $1 AND id < $2 ORDER BY id" % TBL,
342
+ select_val="SELECT val FROM %s WHERE id = $1" % TBL,
343
+ session_id="SELECT pg_backend_pid() AS sid",
344
+ sleep="SELECT pg_sleep(0.05) AS d",
345
+ ),
346
+ "mysql": dict(
347
+ setup=_setup_my,
348
+ raw=_my_raw_pool,
349
+ factory=mysql_pool_factory,
350
+ config=_my_conn_config,
351
+ session_alive="SELECT COUNT(*) FROM information_schema.PROCESSLIST WHERE ID = %s",
352
+ pool=_my_pool,
353
+ insert="INSERT INTO %s (id, val) VALUES (?, ?)" % MY_TBL,
354
+ select_ids="SELECT id FROM %s WHERE id >= ? AND id < ? ORDER BY id" % MY_TBL,
355
+ select_val="SELECT val FROM %s WHERE id = ?" % MY_TBL,
356
+ session_id="SELECT CONNECTION_ID() AS sid",
357
+ sleep="SELECT SLEEP(0.05) AS d",
358
+ ),
359
+ }
360
+
361
+ _READ = StatementIntent(write=False)
362
+ _WRITE = StatementIntent(write=True)
363
+
364
+
365
+ class _ThreadLog:
366
+ """A ``_RecordingPool`` log that files each acquire under the thread that made it, so a test can
367
+ say WHICH thread's statement was served by which pool."""
368
+
369
+ def __init__(self):
370
+ self._lock = threading.Lock()
371
+ self.by_thread = {}
372
+
373
+ def append(self, label):
374
+ with self._lock:
375
+ self.by_thread.setdefault(threading.current_thread().name, []).append(label)
376
+
377
+ def last(self, name):
378
+ with self._lock:
379
+ return self.by_thread[name][-1]
380
+
381
+
382
+ def _run_threads(targets, timeout=60):
383
+ """Start one named thread per ``(name, fn)``, all at once, and join them. A thread that is still
384
+ alive after ``timeout`` is a hang (deadlock / lost wake-up); an exception in any thread fails the
385
+ test (a bare ``threading.Thread`` would only print it). Returns ``{name: fn's return value}``."""
386
+ results, errors = {}, []
387
+
388
+ def wrap(name, fn):
389
+ def body():
390
+ try:
391
+ results[name] = fn()
392
+ except BaseException as e:
393
+ errors.append((name, e))
394
+
395
+ return body
396
+
397
+ threads = [threading.Thread(target=wrap(n, f), name=n) for n, f in targets]
398
+ for t in threads:
399
+ t.start()
400
+ deadline = time.monotonic() + timeout
401
+ for t in threads:
402
+ t.join(max(0.0, deadline - time.monotonic()))
403
+ hung = [t.name for t in threads if t.is_alive()]
404
+ assert hung == [], "threads still running after %ss (hang): %r" % (timeout, hung)
405
+ assert errors == [], "thread failures: %r" % errors
406
+ return results
407
+
408
+
409
+ def test_c1_writer_sticky_is_per_thread_live():
410
+ """Writer-sticky is read-your-writes for the thread that committed. One thread commits and then reads
411
+ (→ WRITER, and it sees its row); five other threads, alive at the same time, read inside that same
412
+ sticky window (→ READER, each of them). On PG and on MySQL, with two distinct pools."""
413
+ for name, d in _DIALECTS.items():
414
+ raw = d["setup"]()
415
+ try:
416
+ tlog = _ThreadLog()
417
+ reader = _RecordingPool(d["pool"](raw), "reader", tlog)
418
+ writer = _RecordingPool(d["pool"](raw), "writer", tlog)
419
+ # A 60s window: every read below happens inside it, however slow the machine.
420
+ sticky = WriterStickyClock(use_writer_after_transaction=True, writer_sticky_duration=60_000)
421
+ ctx = _ctx(RoutingConfig(ConnectionRegistry.from_default(reader_writer_pair(reader, writer)).build(), sticky))
422
+ readers = ["reader-%d" % i for i in range(5)]
423
+ started = threading.Barrier(1 + len(readers), timeout=30)
424
+ committed = threading.Event()
425
+ read_done = threading.Barrier(1 + len(readers), timeout=30)
426
+
427
+ def committer():
428
+ started.wait()
429
+ transaction(ctx, lambda: run_guarded(current_context(), d["insert"], [100, "mine"], "INSERT"), TransactionOptions(), name)
430
+ committed.set()
431
+ first = execute(ctx, d["select_val"], [100], _READ)
432
+ first_pool = tlog.last("committer")
433
+ read_done.wait() # the other threads have read inside the window
434
+ later = execute(ctx, d["select_val"], [100], _READ)
435
+ return first, first_pool, later, tlog.last("committer")
436
+
437
+ def other(me):
438
+ def body():
439
+ started.wait()
440
+ assert committed.wait(30)
441
+ execute(ctx, "SELECT 1 AS one", [], _READ)
442
+ pool = tlog.last(me)
443
+ read_done.wait()
444
+ return pool
445
+
446
+ return body
447
+
448
+ res = _run_threads([("committer", committer)] + [(r, other(r)) for r in readers])
449
+ first, first_pool, later, later_pool = res["committer"]
450
+ assert (first_pool, later_pool) == ("writer", "writer"), name
451
+ assert first[0]["val"] == "mine" and later[0]["val"] == "mine", name
452
+ assert {r: res[r] for r in readers} == {r: "reader" for r in readers}, name
453
+ assert sticky.is_sticky() is False, name # the test's own thread never committed
454
+ finally:
455
+ raw.close()
456
+
457
+
458
+ def test_c1_concurrent_transactions_each_own_a_connection():
459
+ """Four threads hold a transaction open at the same time while a fifth runs plain statements. Each
460
+ transaction is on its own server session and sees only its own uncommitted row; the fifth thread's
461
+ statements run on none of those sessions and see none of those rows. After COMMIT every row is
462
+ there. On PG and on MySQL."""
463
+ for name, d in _DIALECTS.items():
464
+ raw = d["setup"]()
465
+ try:
466
+ ctx = _ctx(RoutingConfig(ConnectionRegistry.single_default(d["pool"](raw)), WriterStickyClock(use_writer_after_transaction=False)))
467
+ ids = [200, 201, 202, 203]
468
+ all_open = threading.Barrier(len(ids) + 1, timeout=30)
469
+ checked = threading.Barrier(len(ids) + 1, timeout=30)
470
+
471
+ def tx_thread(my_id):
472
+ def body():
473
+ tx_ctx = current_context()
474
+ run_guarded(tx_ctx, d["insert"], [my_id, "tx"], "INSERT")
475
+ sid = execute(tx_ctx, d["session_id"], [], _READ)[0]["sid"]
476
+ all_open.wait() # every transaction is open and has written, none committed
477
+ seen = [int(r["id"]) for r in execute(tx_ctx, d["select_ids"], [200, 300], _READ)]
478
+ checked.wait()
479
+ return sid, seen
480
+
481
+ return lambda: transaction(ctx, body, TransactionOptions(), name)
482
+
483
+ def outside():
484
+ assert current_context() is None # another thread's tx pin is not visible here
485
+ all_open.wait()
486
+ sid = execute(ctx, d["session_id"], [], _READ)[0]["sid"]
487
+ seen = [int(r["id"]) for r in execute(ctx, d["select_ids"], [200, 300], _READ)]
488
+ checked.wait()
489
+ return sid, seen
490
+
491
+ res = _run_threads([("tx-%d" % i, tx_thread(i)) for i in ids] + [("outside", outside)])
492
+ tx_sids = [res["tx-%d" % i][0] for i in ids]
493
+ assert len(set(tx_sids)) == len(ids), "%s: concurrent transactions shared a session: %r" % (name, tx_sids)
494
+ for i in ids:
495
+ assert res["tx-%d" % i][1] == [i], "%s: tx %d saw %r" % (name, i, res["tx-%d" % i][1])
496
+ out_sid, out_seen = res["outside"]
497
+ assert out_sid not in tx_sids, "%s: a non-tx statement ran on a transaction's connection" % name
498
+ assert out_seen == [], "%s: a non-tx read saw uncommitted rows %r" % (name, out_seen)
499
+ committed = [int(r["id"]) for r in execute(ctx, d["select_ids"], [200, 300], _READ)]
500
+ assert committed == ids, name
501
+ finally:
502
+ raw.close()
503
+
504
+
505
+ def test_c1_pool_contention_waits_then_succeeds():
506
+ """More threads than connections: twelve threads (six transactions, six plain reads) on a pool of
507
+ TWO. Every acquire past the second waits for a release and then succeeds — no hang, no lost result,
508
+ never more than two connections open. On PG and on MySQL."""
509
+ for name, d in _DIALECTS.items():
510
+ d["setup"]().close() # a fresh table
511
+ raw = d["raw"](size=2)
512
+ try:
513
+ ctx = _ctx(RoutingConfig(ConnectionRegistry.single_default(d["pool"](raw)), WriterStickyClock(use_writer_after_transaction=False)))
514
+ peak = {"v": 0}
515
+ lock = threading.Lock()
516
+
517
+ def observe():
518
+ with lock:
519
+ peak["v"] = max(peak["v"], raw._opened)
520
+
521
+ def tx_thread(my_id):
522
+ def body():
523
+ tx_ctx = current_context()
524
+ run_guarded(tx_ctx, d["insert"], [my_id, "c%d" % my_id], "INSERT")
525
+ execute(tx_ctx, d["sleep"], [], _READ) # hold the connection so others must wait
526
+ observe()
527
+ return execute(tx_ctx, d["select_val"], [my_id], _READ)[0]["val"]
528
+
529
+ return lambda: transaction(ctx, body, TransactionOptions(), name)
530
+
531
+ def reader_thread():
532
+ execute(ctx, d["sleep"], [], _READ)
533
+ observe()
534
+ return int(execute(ctx, "SELECT 1 AS one", [], _READ)[0]["one"])
535
+
536
+ tx_ids = list(range(300, 306))
537
+ targets = [("tx-%d" % i, tx_thread(i)) for i in tx_ids] + [("read-%d" % i, reader_thread) for i in range(6)]
538
+ res = _run_threads(targets, timeout=60)
539
+ assert {i: res["tx-%d" % i] for i in tx_ids} == {i: "c%d" % i for i in tx_ids}, name
540
+ assert [res["read-%d" % i] for i in range(6)] == [1] * 6, name
541
+ assert peak["v"] <= 2 and raw._opened <= 2, "%s: pool of 2 opened %d" % (name, peak["v"])
542
+ committed = [int(r["id"]) for r in execute(ctx, d["select_ids"], [300, 400], _READ)]
543
+ assert committed == tx_ids, name
544
+ finally:
545
+ raw.close()
546
+
547
+
326
548
  # ════════════════════════════════════════════════════════════════════════════════
327
549
  # C2 — multi-DB name routing (PG = A, MySQL = B) + tx-pin precedence
328
550
  # ════════════════════════════════════════════════════════════════════════════════
@@ -603,3 +825,99 @@ def test_c3_pg_and_mysql_factories_end_to_end():
603
825
  mr = execute(mctx, "SELECT 13 AS n", [], StatementIntent(write=False))
604
826
  assert int(mr[0]["n"]) == 13
605
827
  my_close()
828
+
829
+
830
+ def _built(d, **knobs):
831
+ """Build a routing config through the dialect's SHIPPED factory with ``knobs`` set on its
832
+ connection config, and capture the ``_ConnectionPool`` the factory constructed."""
833
+ captured = {}
834
+ base = d["factory"]()
835
+
836
+ def factory(config, role):
837
+ pool, close = base(config, role)
838
+ captured["raw"] = pool._pool
839
+ return pool, close
840
+
841
+ config = d["config"]()
842
+ for k, v in knobs.items():
843
+ setattr(config, k, v)
844
+ routing, close = build_routing_config([ConnectionSetup(pool_factory=factory, config=config)])
845
+ return _ctx(routing), captured["raw"], close
846
+
847
+
848
+ def test_c3_connect_timeout_bounds_the_wait_for_a_free_connection():
849
+ """With the pool at ``max_pool`` and nothing released, getting a connection fails after
850
+ ``connect_timeout_millis`` — not sooner, and not never. Once a connection is released the same
851
+ statement succeeds. On PG and on MySQL, through the shipped factories."""
852
+ for name, d in _DIALECTS.items():
853
+ ctx, raw, close = _built(d, max_pool=1, connect_timeout_millis=400)
854
+ try:
855
+ held = raw.acquire() # the only connection is checked out
856
+ t0 = time.monotonic()
857
+ with pytest.raises(TimeoutError, match="acquire timed out after 0.4s"):
858
+ execute(ctx, "SELECT 1 AS one", [], _READ)
859
+ elapsed = time.monotonic() - t0
860
+ assert 0.4 <= elapsed < 3.0, "%s: waited %.2fs for a 400ms bound" % (name, elapsed)
861
+ raw.release(held)
862
+ assert int(execute(ctx, "SELECT 1 AS one", [], _READ)[0]["one"]) == 1, name
863
+ finally:
864
+ close()
865
+
866
+
867
+
868
+ def test_c3_connect_timeout_zero_waits_without_limit():
869
+ """``connect_timeout_millis=0`` is no limit: with the pool exhausted, a statement keeps waiting —
870
+ well past any short bound — and completes once the connection is released. On PG and on MySQL,
871
+ through the shipped factories."""
872
+ for name, d in _DIALECTS.items():
873
+ ctx, raw, close = _built(d, max_pool=1, connect_timeout_millis=0)
874
+ try:
875
+ held = raw.acquire()
876
+ got = {}
877
+
878
+ def waiter():
879
+ got["one"] = int(execute(ctx, "SELECT 1 AS one", [], _READ)[0]["one"])
880
+
881
+ t = threading.Thread(target=waiter)
882
+ t.start()
883
+ t.join(1.5)
884
+ assert t.is_alive() and got == {}, "%s: the waiter gave up with no limit configured" % name
885
+ raw.release(held)
886
+ t.join(30)
887
+ assert not t.is_alive() and got == {"one": 1}, name
888
+ finally:
889
+ close()
890
+
891
+
892
+ def test_c3_idle_connection_is_closed_on_the_server():
893
+ """A pooled connection unused for ``idle_timeout_millis`` is closed — its server session is gone
894
+ (checked from a separate connection), with no further query needed — and the next statement opens
895
+ a new session. On PG and on MySQL, through the shipped factories."""
896
+ for name, d in _DIALECTS.items():
897
+ ctx, raw, close = _built(d, idle_timeout_millis=300)
898
+ probe = d["raw"](size=1)
899
+ try:
900
+
901
+ def alive(sid):
902
+ conn = probe.acquire()
903
+ try:
904
+ cur = conn.cursor()
905
+ cur.execute(d["session_alive"], [sid])
906
+ n = cur.fetchone()[0]
907
+ cur.close()
908
+ return int(n)
909
+ finally:
910
+ probe.release(conn)
911
+
912
+ sid = execute(ctx, d["session_id"], [], _READ)[0]["sid"]
913
+ assert raw._opened == 1 and alive(sid) == 1, name
914
+ deadline = time.monotonic() + 5.0
915
+ while alive(sid) and time.monotonic() < deadline:
916
+ time.sleep(0.05)
917
+ assert alive(sid) == 0, "%s: the idle session %s is still open on the server" % (name, sid)
918
+ assert raw._opened == 0, name
919
+ again = execute(ctx, d["session_id"], [], _READ)[0]["sid"]
920
+ assert again != sid, name
921
+ finally:
922
+ probe.close()
923
+ close()