litedbmodel-runtime 2.2.10__tar.gz → 2.2.12__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.10 → litedbmodel_runtime-2.2.12}/PKG-INFO +2 -2
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/litedbmodel_runtime/__init__.py +1 -1
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/litedbmodel_runtime/connection_routing.py +88 -25
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/litedbmodel_runtime/driver.py +125 -88
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/litedbmodel_runtime/exec_context.py +2 -1
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/litedbmodel_runtime.egg-info/PKG-INFO +2 -2
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/litedbmodel_runtime.egg-info/requires.txt +1 -1
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/pyproject.toml +2 -2
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/tests/test_connection_routing.py +101 -3
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/tests/test_connection_routing_livedb.py +318 -0
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/README.md +0 -0
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/litedbmodel_runtime/dialect.py +0 -0
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/litedbmodel_runtime/errors.py +0 -0
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/litedbmodel_runtime/grouping.py +0 -0
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/litedbmodel_runtime/leaves.py +0 -0
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/litedbmodel_runtime/middleware.py +0 -0
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/litedbmodel_runtime/tx_options.py +0 -0
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/litedbmodel_runtime.egg-info/SOURCES.txt +0 -0
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/litedbmodel_runtime.egg-info/dependency_links.txt +0 -0
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/litedbmodel_runtime.egg-info/top_level.txt +0 -0
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/setup.cfg +0 -0
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/tests/test_conformance_corpus.py +0 -0
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/tests/test_consumes_bc_core.py +0 -0
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/tests/test_dialect.py +0 -0
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/tests/test_dynamic_where.py +0 -0
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/tests/test_exec_context.py +0 -0
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/tests/test_grouping.py +0 -0
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/tests/test_hard_limit_guard.py +0 -0
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/tests/test_middleware.py +0 -0
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/tests/test_middleware_livedb.py +0 -0
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/tests/test_orm_bench_native.py +0 -0
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/tests/test_render.py +0 -0
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/tests/test_transaction_boundary.py +0 -0
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/tests/test_tx_boundary_livedb.py +0 -0
- {litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/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.
|
|
3
|
+
Version: 2.2.12
|
|
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.
|
|
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
|
{litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/litedbmodel_runtime/connection_routing.py
RENAMED
|
@@ -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
|
|
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``,
|
|
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
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
|
634
|
-
# legitimately returns 0 (rust's SystemClock does right
|
|
635
|
-
# "never marked" as the value 0 would mis-classify a ``mark()``
|
|
636
|
-
# leak the read-your-writes read to the reader replica.
|
|
637
|
-
self._last_write_at: Optional[int] =
|
|
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
|
|
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
|
|
679
|
+
self._last_write_at.set(self._now())
|
|
647
680
|
|
|
648
681
|
def is_sticky(self) -> bool:
|
|
649
|
-
"""Is a read
|
|
650
|
-
|
|
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
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
#
|
|
253
|
-
#
|
|
254
|
-
#
|
|
255
|
-
#
|
|
256
|
-
#
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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", "
|
|
275
|
+
__slots__ = ("_factory", "_max", "_free", "_opened", "_cond", "_closed", "_acquire_timeout", "_idle_timeout", "_reaper")
|
|
272
276
|
|
|
273
|
-
def __init__(
|
|
274
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
#
|
|
289
|
-
# a query on a closed pool fails loudly
|
|
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
|
|
294
|
-
|
|
295
|
-
if self.
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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.
|
|
327
|
-
except
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
)
|
|
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
|
-
|
|
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
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
(
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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+")
|
{litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/litedbmodel_runtime/exec_context.py
RENAMED
|
@@ -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
|
|
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()
|
{litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/litedbmodel_runtime.egg-info/PKG-INFO
RENAMED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: litedbmodel-runtime
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.12
|
|
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.
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "litedbmodel-runtime"
|
|
7
|
-
version = "2.2.
|
|
7
|
+
version = "2.2.12"
|
|
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.
|
|
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()
|
{litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/tests/test_connection_routing_livedb.py
RENAMED
|
@@ -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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/litedbmodel_runtime.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/litedbmodel_runtime.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{litedbmodel_runtime-2.2.10 → litedbmodel_runtime-2.2.12}/tests/test_transaction_boundary.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|