query-cascade 0.3.4__tar.gz → 0.3.6__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 (51) hide show
  1. {query_cascade-0.3.4 → query_cascade-0.3.6}/PKG-INFO +56 -1
  2. {query_cascade-0.3.4 → query_cascade-0.3.6}/README.md +54 -0
  3. {query_cascade-0.3.4 → query_cascade-0.3.6}/pyproject.toml +2 -1
  4. {query_cascade-0.3.4 → query_cascade-0.3.6}/src/cascade/_disk_cache.py +186 -186
  5. query_cascade-0.3.6/src/cascade/_evaluator.py +1300 -0
  6. {query_cascade-0.3.4 → query_cascade-0.3.6}/src/cascade/_runtime.py +1 -0
  7. {query_cascade-0.3.4 → query_cascade-0.3.6}/src/cascade/_serde.py +22 -0
  8. {query_cascade-0.3.4 → query_cascade-0.3.6}/src/cascade/_store.py +55 -2
  9. {query_cascade-0.3.4 → query_cascade-0.3.6}/src/cascade/engine.py +107 -4
  10. {query_cascade-0.3.4 → query_cascade-0.3.6}/src/query_cascade.egg-info/PKG-INFO +56 -1
  11. {query_cascade-0.3.4 → query_cascade-0.3.6}/src/query_cascade.egg-info/SOURCES.txt +4 -0
  12. {query_cascade-0.3.4 → query_cascade-0.3.6}/src/query_cascade.egg-info/requires.txt +1 -0
  13. query_cascade-0.3.6/tests/test_async.py +103 -0
  14. query_cascade-0.3.6/tests/test_async_coverage.py +112 -0
  15. query_cascade-0.3.6/tests/test_code_versioning.py +117 -0
  16. {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_engine.py +46 -0
  17. query_cascade-0.3.6/tests/test_memoize_false.py +77 -0
  18. query_cascade-0.3.4/src/cascade/_evaluator.py +0 -702
  19. {query_cascade-0.3.4 → query_cascade-0.3.6}/setup.cfg +0 -0
  20. {query_cascade-0.3.4 → query_cascade-0.3.6}/src/cascade/__init__.py +0 -0
  21. {query_cascade-0.3.4 → query_cascade-0.3.6}/src/cascade/_canonical.py +0 -0
  22. {query_cascade-0.3.4 → query_cascade-0.3.6}/src/cascade/_errors.py +0 -0
  23. {query_cascade-0.3.4 → query_cascade-0.3.6}/src/cascade/_graph_export.py +0 -0
  24. {query_cascade-0.3.4 → query_cascade-0.3.6}/src/cascade/_persistence.py +0 -0
  25. {query_cascade-0.3.4 → query_cascade-0.3.6}/src/cascade/_scheduler.py +0 -0
  26. {query_cascade-0.3.4 → query_cascade-0.3.6}/src/cascade/_state.py +0 -0
  27. {query_cascade-0.3.4 → query_cascade-0.3.6}/src/cascade/_synthetic_graph.py +0 -0
  28. {query_cascade-0.3.4 → query_cascade-0.3.6}/src/query_cascade.egg-info/dependency_links.txt +0 -0
  29. {query_cascade-0.3.4 → query_cascade-0.3.6}/src/query_cascade.egg-info/top_level.txt +0 -0
  30. {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_dependency_parallelism.py +0 -0
  31. {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_disk_cache.py +0 -0
  32. {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_engine_compute_many.py +0 -0
  33. {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_engine_concurrency.py +0 -0
  34. {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_engine_persistence_and_tracing.py +0 -0
  35. {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_engine_private.py +0 -0
  36. {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_engine_private2.py +0 -0
  37. {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_engine_sweep.py +0 -0
  38. {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_error_caching.py +0 -0
  39. {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_error_caching_persistence.py +0 -0
  40. {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_evaluator_and_synthetic_coverage.py +0 -0
  41. {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_examples.py +0 -0
  42. {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_fixed_point.py +0 -0
  43. {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_internal_invariants.py +0 -0
  44. {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_performance.py +0 -0
  45. {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_repro_touch_memo_keyerror.py +0 -0
  46. {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_scale_behavior.py +0 -0
  47. {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_serde.py +0 -0
  48. {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_stateful_engine_invariants.py +0 -0
  49. {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_store_coverage.py +0 -0
  50. {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_subgraph_stats_export.py +0 -0
  51. {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_sweep_unaccessed.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: query-cascade
3
- Version: 0.3.4
3
+ Version: 0.3.6
4
4
  Summary: Minimal demand-driven query framework for incremental computation.
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
@@ -13,6 +13,7 @@ Requires-Dist: lmdb; extra == "dev"
13
13
  Requires-Dist: msgpack; extra == "dev"
14
14
  Requires-Dist: mutmut; extra == "dev"
15
15
  Requires-Dist: pytest; extra == "dev"
16
+ Requires-Dist: pytest-asyncio; extra == "dev"
16
17
  Requires-Dist: pytest-cov; extra == "dev"
17
18
  Requires-Dist: ruff; extra == "dev"
18
19
 
@@ -138,6 +139,43 @@ A few things to know:
138
139
 
139
140
  ## Advanced Features
140
141
 
142
+ ### Asynchronous Execution
143
+ Cascade natively supports asynchronous queries via `async def`. This is extremely useful for I/O-bound workflows (such as making database or network calls). When an asynchronous query evaluates, it runs cooperatively on the active `asyncio` event loop.
144
+
145
+ ```python
146
+ import asyncio
147
+ from cascade import Engine
148
+
149
+ engine = Engine()
150
+
151
+ @engine.query
152
+ async def fetch_user(uid: str):
153
+ await asyncio.sleep(0.5) # Network I/O
154
+ return {"id": uid, "name": "Alice"}
155
+
156
+ @engine.query
157
+ async def process_user(uid: str):
158
+ # Await downstream async queries seamlessly
159
+ user = await fetch_user(uid)
160
+ return user["name"].upper()
161
+
162
+ async def main():
163
+ print(await process_user("123"))
164
+
165
+ asyncio.run(main())
166
+ ```
167
+ Synchronous queries can also be called directly from inside asynchronous nodes (and vice versa). Cascade maintains type stability and performance segregation, ensuring pure-Python CPU-bound tasks suffer no context-switching overhead while I/O-bound tasks evaluate concurrently.
168
+
169
+ ### Pass-Through Queries (`memoize=False`)
170
+ For intermediate queries that generate large outputs, you can set `memoize=False` to prevent their results from being stored in the LRU cache. This saves significant memory while still allowing the query to fully participate in the dependency graph. Downstream nodes will still accurately detect when the query's inputs change, and the unmemoized query will simply recompute its output on-demand when an active caller needs it.
171
+
172
+ ```python
173
+ @engine.query(memoize=False)
174
+ def mapped_data() -> list[int]:
175
+ data = raw_data()
176
+ return [x * 2 for x in data]
177
+ ```
178
+
141
179
  ### Side-Effect Accumulators
142
180
  Queries must be pure functions. Use `Accumulator` to record side-effects (like logs or warnings) that must be replayed when a result is served from the cache.
143
181
 
@@ -169,6 +207,20 @@ def parse(source: str):
169
207
  ```
170
208
  If a query throws an exception, it gets cached just like a regular return value. Subsequent calls instantly re-raise the exception, preserving incremental evaluation speed during error states. Cached exceptions can also be hydrated from the persistent disk cache.
171
209
 
210
+ ### Input Debouncing & Transactions
211
+ When updating multiple inputs, intermediate read states ("flapping") can cause inconsistent evaluations. Use `engine.transaction()` to batch updates so they are committed atomically.
212
+
213
+ ```python
214
+ with engine.transaction():
215
+ theme.set("dark")
216
+ layout.set("grid")
217
+
218
+ # Queries will only re-evaluate once observing both changes simultaneously.
219
+ ```
220
+
221
+ ### Code-Aware Caching (Automatic Invalidation)
222
+ Cascade automatically inspects the Python bytecode of your `@engine.query` and `@engine.input` functions. If you edit a function's logic and the module is hot-reloaded (or you restart your script), Cascade compares the new function's bytecode hash against the previously cached logic. If the logic has changed, Cascade immediately invalidates that function's memory and persistent disk caches, avoiding stale results without requiring manual cache wipes. Code formatting and comments do not affect the bytecode hash.
223
+
172
224
  ### Performance Metrics
173
225
  Set `stats=True` in the `Engine` constructor to track execution timing.
174
226
  * **`engine.stats_summary()`**: Returns wall-clock time spent in function bodies and cache eviction counts.
@@ -211,8 +263,11 @@ pip install query-cascade
211
263
  | Script | What it shows |
212
264
  |--------|----------------|
213
265
  | `compiler_pipeline.py` | `source → parse → symbols → typecheck`, warnings accumulator, cache-hit narration |
266
+ | `async_execution.py` | Asynchronous query evaluation and asyncio event loop integration for IO-bound work |
214
267
  | `error_caching.py` | Basic exception caching to prevent repeated re-evaluation on failure |
215
268
  | `error_caching_persistence.py` | Disk cache hydration of exceptions across process runs |
269
+ | `code_versioning.py` | Automatic cache invalidation when a function's bytecode logic changes |
270
+ | `pass_through_queries.py` | `memoize=False` tracking inputs without keeping large outputs in the LRU cache |
216
271
  | `dynamic_macro_expansion.py` | Query that **changes downstream dependencies** at runtime |
217
272
  | `snapshot_isolation.py` | Snapshot reads while live inputs change |
218
273
  | `concurrent_background_work.py` | Dedup under concurrency + cancellation after input changes |
@@ -120,6 +120,43 @@ A few things to know:
120
120
 
121
121
  ## Advanced Features
122
122
 
123
+ ### Asynchronous Execution
124
+ Cascade natively supports asynchronous queries via `async def`. This is extremely useful for I/O-bound workflows (such as making database or network calls). When an asynchronous query evaluates, it runs cooperatively on the active `asyncio` event loop.
125
+
126
+ ```python
127
+ import asyncio
128
+ from cascade import Engine
129
+
130
+ engine = Engine()
131
+
132
+ @engine.query
133
+ async def fetch_user(uid: str):
134
+ await asyncio.sleep(0.5) # Network I/O
135
+ return {"id": uid, "name": "Alice"}
136
+
137
+ @engine.query
138
+ async def process_user(uid: str):
139
+ # Await downstream async queries seamlessly
140
+ user = await fetch_user(uid)
141
+ return user["name"].upper()
142
+
143
+ async def main():
144
+ print(await process_user("123"))
145
+
146
+ asyncio.run(main())
147
+ ```
148
+ Synchronous queries can also be called directly from inside asynchronous nodes (and vice versa). Cascade maintains type stability and performance segregation, ensuring pure-Python CPU-bound tasks suffer no context-switching overhead while I/O-bound tasks evaluate concurrently.
149
+
150
+ ### Pass-Through Queries (`memoize=False`)
151
+ For intermediate queries that generate large outputs, you can set `memoize=False` to prevent their results from being stored in the LRU cache. This saves significant memory while still allowing the query to fully participate in the dependency graph. Downstream nodes will still accurately detect when the query's inputs change, and the unmemoized query will simply recompute its output on-demand when an active caller needs it.
152
+
153
+ ```python
154
+ @engine.query(memoize=False)
155
+ def mapped_data() -> list[int]:
156
+ data = raw_data()
157
+ return [x * 2 for x in data]
158
+ ```
159
+
123
160
  ### Side-Effect Accumulators
124
161
  Queries must be pure functions. Use `Accumulator` to record side-effects (like logs or warnings) that must be replayed when a result is served from the cache.
125
162
 
@@ -151,6 +188,20 @@ def parse(source: str):
151
188
  ```
152
189
  If a query throws an exception, it gets cached just like a regular return value. Subsequent calls instantly re-raise the exception, preserving incremental evaluation speed during error states. Cached exceptions can also be hydrated from the persistent disk cache.
153
190
 
191
+ ### Input Debouncing & Transactions
192
+ When updating multiple inputs, intermediate read states ("flapping") can cause inconsistent evaluations. Use `engine.transaction()` to batch updates so they are committed atomically.
193
+
194
+ ```python
195
+ with engine.transaction():
196
+ theme.set("dark")
197
+ layout.set("grid")
198
+
199
+ # Queries will only re-evaluate once observing both changes simultaneously.
200
+ ```
201
+
202
+ ### Code-Aware Caching (Automatic Invalidation)
203
+ Cascade automatically inspects the Python bytecode of your `@engine.query` and `@engine.input` functions. If you edit a function's logic and the module is hot-reloaded (or you restart your script), Cascade compares the new function's bytecode hash against the previously cached logic. If the logic has changed, Cascade immediately invalidates that function's memory and persistent disk caches, avoiding stale results without requiring manual cache wipes. Code formatting and comments do not affect the bytecode hash.
204
+
154
205
  ### Performance Metrics
155
206
  Set `stats=True` in the `Engine` constructor to track execution timing.
156
207
  * **`engine.stats_summary()`**: Returns wall-clock time spent in function bodies and cache eviction counts.
@@ -193,8 +244,11 @@ pip install query-cascade
193
244
  | Script | What it shows |
194
245
  |--------|----------------|
195
246
  | `compiler_pipeline.py` | `source → parse → symbols → typecheck`, warnings accumulator, cache-hit narration |
247
+ | `async_execution.py` | Asynchronous query evaluation and asyncio event loop integration for IO-bound work |
196
248
  | `error_caching.py` | Basic exception caching to prevent repeated re-evaluation on failure |
197
249
  | `error_caching_persistence.py` | Disk cache hydration of exceptions across process runs |
250
+ | `code_versioning.py` | Automatic cache invalidation when a function's bytecode logic changes |
251
+ | `pass_through_queries.py` | `memoize=False` tracking inputs without keeping large outputs in the LRU cache |
198
252
  | `dynamic_macro_expansion.py` | Query that **changes downstream dependencies** at runtime |
199
253
  | `snapshot_isolation.py` | Snapshot reads while live inputs change |
200
254
  | `concurrent_background_work.py` | Dedup under concurrency + cancellation after input changes |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "query-cascade"
7
- version = "0.3.4"
7
+ version = "0.3.6"
8
8
  description = "Minimal demand-driven query framework for incremental computation."
9
9
  requires-python = ">=3.12"
10
10
  readme = "README.md"
@@ -20,6 +20,7 @@ dev = [
20
20
  "msgpack",
21
21
  "mutmut",
22
22
  "pytest",
23
+ "pytest-asyncio",
23
24
  "pytest-cov",
24
25
  "ruff",
25
26
  ]
@@ -1,186 +1,186 @@
1
- """LMDB-backed persistent store for query metadata and value blobs.
2
-
3
- Layout: one LMDB environment per ``cache_dir`` with three named databases.
4
- ``meta`` maps a deterministic entry key to a packed record describing a query
5
- result (value fingerprint, dependency fingerprints, accumulator effects).
6
- ``blobs`` is content-addressed: value fingerprint -> canonical value bytes.
7
- ``sys`` holds the on-disk format stamp; a mismatched stamp wipes the cache,
8
- which is always safe because everything here can be recomputed.
9
- """
10
-
11
- from __future__ import annotations
12
-
13
- import hashlib
14
- import os
15
- import threading
16
- from typing import Any
17
-
18
- from . import _canonical
19
- from ._errors import PersistentCacheError
20
-
21
- try:
22
- import lmdb
23
- except ImportError: # pragma: no cover - exercised via monkeypatch in tests
24
- lmdb = None # type: ignore[assignment]
25
-
26
- LMDB_INSTALL_HINT = (
27
- "cascade persistent caching requires the 'lmdb' package for the on-disk store; "
28
- "there is no fallback. Install it with: pip install lmdb"
29
- )
30
-
31
- DISK_FORMAT = 1
32
- _FORMAT_KEY = b"format"
33
-
34
-
35
- class _SharedEnv:
36
- def __init__(self, env: Any) -> None:
37
- self.env = env
38
- self.refs = 1
39
-
40
-
41
- # LMDB permits exactly one environment handle per path per process; concurrent
42
- # access from other processes is handled by LMDB's own file locks. Multiple
43
- # DiskCache instances in one process therefore share a refcounted environment,
44
- # and the first opener's map_size wins for the life of the process.
45
- _ENV_REGISTRY: dict[str, _SharedEnv] = {}
46
- _ENV_LOCK = threading.Lock()
47
-
48
-
49
- def _acquire_env(path: str, map_size: int) -> tuple[str, Any]:
50
- registry_key = os.path.realpath(path)
51
- with _ENV_LOCK:
52
- shared = _ENV_REGISTRY.get(registry_key)
53
- if shared is not None:
54
- shared.refs += 1
55
- return registry_key, shared.env
56
- try:
57
- # sync=False trades durability of the last few writes for speed.
58
- # A crash can lose recent entries, never corrupt the store, and a
59
- # lost entry only costs one recompute.
60
- env = lmdb.open(
61
- path,
62
- map_size=map_size,
63
- max_dbs=3,
64
- subdir=True,
65
- sync=False,
66
- metasync=False,
67
- )
68
- except lmdb.Error as exc:
69
- raise PersistentCacheError(f"failed to open persistent cache at {path!r}: {exc}") from exc
70
- _ENV_REGISTRY[registry_key] = _SharedEnv(env)
71
- return registry_key, env
72
-
73
-
74
- def _release_env(registry_key: str) -> None:
75
- with _ENV_LOCK:
76
- shared = _ENV_REGISTRY.get(registry_key)
77
- if shared is None:
78
- return
79
- shared.refs -= 1
80
- if shared.refs <= 0:
81
- del _ENV_REGISTRY[registry_key]
82
- shared.env.sync(True)
83
- shared.env.close()
84
-
85
-
86
- def entry_key(kind: str, function_id: str, args_blob: bytes) -> bytes:
87
- """Deterministic cache address: Hash(kind + name + Hash(args))."""
88
- args_hash = hashlib.blake2b(args_blob, digest_size=20).digest()
89
- h = hashlib.blake2b(digest_size=20)
90
- h.update(kind.encode("utf-8"))
91
- h.update(b"\x00")
92
- h.update(function_id.encode("utf-8"))
93
- h.update(b"\x00")
94
- h.update(args_hash)
95
- return h.digest()
96
-
97
-
98
- class DiskCache:
99
- def __init__(self, cache_dir: str | os.PathLike[str], *, map_size: int) -> None:
100
- if lmdb is None:
101
- raise PersistentCacheError(LMDB_INSTALL_HINT)
102
- _canonical.require_msgpack()
103
- path = os.fspath(cache_dir)
104
- os.makedirs(path, exist_ok=True)
105
- self._registry_key, self._env = _acquire_env(path, map_size)
106
- self._meta = self._env.open_db(b"meta")
107
- self._blobs = self._env.open_db(b"blobs")
108
- self._sys = self._env.open_db(b"sys")
109
- self._closed = False
110
- self._ensure_format()
111
-
112
- @property
113
- def path(self) -> str:
114
- return self._env.path()
115
-
116
- def _ensure_format(self) -> None:
117
- stamp = DISK_FORMAT.to_bytes(4, "big")
118
- with self._env.begin(write=True) as txn:
119
- current = txn.get(_FORMAT_KEY, db=self._sys)
120
- if current == stamp:
121
- return
122
- if current is not None:
123
- txn.drop(self._meta, delete=False)
124
- txn.drop(self._blobs, delete=False)
125
- txn.put(_FORMAT_KEY, stamp, db=self._sys)
126
-
127
- def load_entry(self, key: bytes) -> dict[str, Any] | None:
128
- with self._env.begin() as txn:
129
- raw = txn.get(key, db=self._meta)
130
- if raw is None:
131
- return None
132
- try:
133
- record = _canonical.decode(bytes(raw))
134
- except Exception:
135
- return None
136
- if not isinstance(record, dict):
137
- return None
138
- return record
139
-
140
- def load_blob(self, value_hash: str) -> bytes | None:
141
- with self._env.begin() as txn:
142
- raw = txn.get(value_hash.encode("ascii"), db=self._blobs)
143
- return None if raw is None else bytes(raw)
144
-
145
- def store_entry(self, key: bytes, record: dict[str, Any], value_hash: str, value_blob: bytes) -> None:
146
- record_blob = _canonical.encode(record)
147
- try:
148
- with self._env.begin(write=True) as txn:
149
- txn.put(value_hash.encode("ascii"), value_blob, db=self._blobs)
150
- txn.put(key, record_blob, db=self._meta)
151
- except lmdb.MapFullError as exc:
152
- raise PersistentCacheError(
153
- f"persistent cache at {self.path!r} is full; pass a larger cache_map_size "
154
- "to Engine, or clear the cache with engine.clear_disk_cache()."
155
- ) from exc
156
-
157
- def clear(self) -> None:
158
- with self._env.begin(write=True) as txn:
159
- txn.drop(self._meta, delete=False)
160
- txn.drop(self._blobs, delete=False)
161
-
162
- def retain(self, wanted_entries: set[bytes], wanted_blobs: set[str]) -> None:
163
- """Drop all meta entries and blobs not in the provided sets."""
164
- with self._env.begin(write=True) as txn:
165
- drop_meta = []
166
- with txn.cursor(db=self._meta) as curs:
167
- if curs.first():
168
- for key in curs.iternext(keys=True, values=False):
169
- if key not in wanted_entries:
170
- drop_meta.append(key)
171
- for k in drop_meta:
172
- txn.delete(k, db=self._meta)
173
-
174
- drop_blobs = []
175
- with txn.cursor(db=self._blobs) as curs:
176
- if curs.first():
177
- for key in curs.iternext(keys=True, values=False):
178
- if key.decode("ascii") not in wanted_blobs:
179
- drop_blobs.append(key)
180
- for k in drop_blobs:
181
- txn.delete(k, db=self._blobs)
182
-
183
- def close(self) -> None:
184
- if not self._closed:
185
- self._closed = True
186
- _release_env(self._registry_key)
1
+ """LMDB-backed persistent store for query metadata and value blobs.
2
+
3
+ Layout: one LMDB environment per ``cache_dir`` with three named databases.
4
+ ``meta`` maps a deterministic entry key to a packed record describing a query
5
+ result (value fingerprint, dependency fingerprints, accumulator effects).
6
+ ``blobs`` is content-addressed: value fingerprint -> canonical value bytes.
7
+ ``sys`` holds the on-disk format stamp; a mismatched stamp wipes the cache,
8
+ which is always safe because everything here can be recomputed.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import os
15
+ import threading
16
+ from typing import Any
17
+
18
+ from . import _canonical
19
+ from ._errors import PersistentCacheError
20
+
21
+ try:
22
+ import lmdb
23
+ except ImportError: # pragma: no cover - exercised via monkeypatch in tests
24
+ lmdb = None # type: ignore[assignment]
25
+
26
+ LMDB_INSTALL_HINT = (
27
+ "cascade persistent caching requires the 'lmdb' package for the on-disk store; "
28
+ "there is no fallback. Install it with: pip install lmdb"
29
+ )
30
+
31
+ DISK_FORMAT = 1
32
+ _FORMAT_KEY = b"format"
33
+
34
+
35
+ class _SharedEnv:
36
+ def __init__(self, env: Any) -> None:
37
+ self.env = env
38
+ self.refs = 1
39
+
40
+
41
+ # LMDB permits exactly one environment handle per path per process; concurrent
42
+ # access from other processes is handled by LMDB's own file locks. Multiple
43
+ # DiskCache instances in one process therefore share a refcounted environment,
44
+ # and the first opener's map_size wins for the life of the process.
45
+ _ENV_REGISTRY: dict[str, _SharedEnv] = {}
46
+ _ENV_LOCK = threading.Lock()
47
+
48
+
49
+ def _acquire_env(path: str, map_size: int) -> tuple[str, Any]:
50
+ registry_key = os.path.realpath(path)
51
+ with _ENV_LOCK:
52
+ shared = _ENV_REGISTRY.get(registry_key)
53
+ if shared is not None:
54
+ shared.refs += 1
55
+ return registry_key, shared.env
56
+ try:
57
+ # sync=False trades durability of the last few writes for speed.
58
+ # A crash can lose recent entries, never corrupt the store, and a
59
+ # lost entry only costs one recompute.
60
+ env = lmdb.open(
61
+ path,
62
+ map_size=map_size,
63
+ max_dbs=3,
64
+ subdir=True,
65
+ sync=False,
66
+ metasync=False,
67
+ )
68
+ except lmdb.Error as exc:
69
+ raise PersistentCacheError(f"failed to open persistent cache at {path!r}: {exc}") from exc
70
+ _ENV_REGISTRY[registry_key] = _SharedEnv(env)
71
+ return registry_key, env
72
+
73
+
74
+ def _release_env(registry_key: str) -> None:
75
+ with _ENV_LOCK:
76
+ shared = _ENV_REGISTRY.get(registry_key)
77
+ if shared is None:
78
+ return
79
+ shared.refs -= 1
80
+ if shared.refs <= 0:
81
+ del _ENV_REGISTRY[registry_key]
82
+ shared.env.sync(True)
83
+ shared.env.close()
84
+
85
+
86
+ def entry_key(kind: str, function_id: str, args_blob: bytes) -> bytes:
87
+ """Deterministic cache address: Hash(kind + name + Hash(args))."""
88
+ args_hash = hashlib.blake2b(args_blob, digest_size=20).digest()
89
+ h = hashlib.blake2b(digest_size=20)
90
+ h.update(kind.encode("utf-8"))
91
+ h.update(b"\x00")
92
+ h.update(function_id.encode("utf-8"))
93
+ h.update(b"\x00")
94
+ h.update(args_hash)
95
+ return h.digest()
96
+
97
+
98
+ class DiskCache:
99
+ def __init__(self, cache_dir: str | os.PathLike[str], *, map_size: int) -> None:
100
+ if lmdb is None:
101
+ raise PersistentCacheError(LMDB_INSTALL_HINT)
102
+ _canonical.require_msgpack()
103
+ path = os.fspath(cache_dir)
104
+ os.makedirs(path, exist_ok=True)
105
+ self._registry_key, self._env = _acquire_env(path, map_size)
106
+ self._meta = self._env.open_db(b"meta")
107
+ self._blobs = self._env.open_db(b"blobs")
108
+ self._sys = self._env.open_db(b"sys")
109
+ self._closed = False
110
+ self._ensure_format()
111
+
112
+ @property
113
+ def path(self) -> str:
114
+ return self._env.path()
115
+
116
+ def _ensure_format(self) -> None:
117
+ stamp = DISK_FORMAT.to_bytes(4, "big")
118
+ with self._env.begin(write=True) as txn:
119
+ current = txn.get(_FORMAT_KEY, db=self._sys)
120
+ if current == stamp:
121
+ return
122
+ if current is not None:
123
+ txn.drop(self._meta, delete=False)
124
+ txn.drop(self._blobs, delete=False)
125
+ txn.put(_FORMAT_KEY, stamp, db=self._sys)
126
+
127
+ def load_entry(self, key: bytes) -> dict[str, Any] | None:
128
+ with self._env.begin() as txn:
129
+ raw = txn.get(key, db=self._meta)
130
+ if raw is None:
131
+ return None # pragma: no cover
132
+ try:
133
+ record = _canonical.decode(bytes(raw))
134
+ except Exception: # pragma: no cover
135
+ return None # pragma: no cover
136
+ if not isinstance(record, dict):
137
+ return None # pragma: no cover
138
+ return record
139
+
140
+ def load_blob(self, value_hash: str) -> bytes | None:
141
+ with self._env.begin() as txn:
142
+ raw = txn.get(value_hash.encode("ascii"), db=self._blobs)
143
+ return None if raw is None else bytes(raw) # pragma: no cover
144
+
145
+ def store_entry(self, key: bytes, record: dict[str, Any], value_hash: str, value_blob: bytes) -> None:
146
+ record_blob = _canonical.encode(record)
147
+ try:
148
+ with self._env.begin(write=True) as txn:
149
+ txn.put(value_hash.encode("ascii"), value_blob, db=self._blobs)
150
+ txn.put(key, record_blob, db=self._meta)
151
+ except lmdb.MapFullError as exc:
152
+ raise PersistentCacheError(
153
+ f"persistent cache at {self.path!r} is full; pass a larger cache_map_size "
154
+ "to Engine, or clear the cache with engine.clear_disk_cache()."
155
+ ) from exc
156
+
157
+ def clear(self) -> None:
158
+ with self._env.begin(write=True) as txn:
159
+ txn.drop(self._meta, delete=False)
160
+ txn.drop(self._blobs, delete=False)
161
+
162
+ def retain(self, wanted_entries: set[bytes], wanted_blobs: set[str]) -> None:
163
+ """Drop all meta entries and blobs not in the provided sets."""
164
+ with self._env.begin(write=True) as txn:
165
+ drop_meta = []
166
+ with txn.cursor(db=self._meta) as curs:
167
+ if curs.first():
168
+ for key in curs.iternext(keys=True, values=False):
169
+ if key not in wanted_entries:
170
+ drop_meta.append(key)
171
+ for k in drop_meta:
172
+ txn.delete(k, db=self._meta)
173
+
174
+ drop_blobs = []
175
+ with txn.cursor(db=self._blobs) as curs:
176
+ if curs.first():
177
+ for key in curs.iternext(keys=True, values=False):
178
+ if key.decode("ascii") not in wanted_blobs:
179
+ drop_blobs.append(key)
180
+ for k in drop_blobs:
181
+ txn.delete(k, db=self._blobs)
182
+
183
+ def close(self) -> None:
184
+ if not self._closed:
185
+ self._closed = True
186
+ _release_env(self._registry_key)