query-cascade 0.3.3__tar.gz → 0.3.5__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.3 → query_cascade-0.3.5}/PKG-INFO +61 -1
  2. {query_cascade-0.3.3 → query_cascade-0.3.5}/README.md +59 -0
  3. {query_cascade-0.3.3 → query_cascade-0.3.5}/pyproject.toml +2 -1
  4. {query_cascade-0.3.3 → query_cascade-0.3.5}/src/cascade/_canonical.py +14 -1
  5. {query_cascade-0.3.3 → query_cascade-0.3.5}/src/cascade/_disk_cache.py +186 -186
  6. query_cascade-0.3.5/src/cascade/_evaluator.py +1300 -0
  7. {query_cascade-0.3.3 → query_cascade-0.3.5}/src/cascade/_runtime.py +1 -0
  8. {query_cascade-0.3.3 → query_cascade-0.3.5}/src/cascade/_serde.py +44 -2
  9. {query_cascade-0.3.3 → query_cascade-0.3.5}/src/cascade/_state.py +1 -0
  10. {query_cascade-0.3.3 → query_cascade-0.3.5}/src/cascade/_store.py +37 -2
  11. {query_cascade-0.3.3 → query_cascade-0.3.5}/src/cascade/engine.py +73 -3
  12. {query_cascade-0.3.3 → query_cascade-0.3.5}/src/query_cascade.egg-info/PKG-INFO +61 -1
  13. {query_cascade-0.3.3 → query_cascade-0.3.5}/src/query_cascade.egg-info/SOURCES.txt +10 -1
  14. {query_cascade-0.3.3 → query_cascade-0.3.5}/src/query_cascade.egg-info/requires.txt +1 -0
  15. query_cascade-0.3.5/tests/test_async.py +103 -0
  16. query_cascade-0.3.5/tests/test_async_coverage.py +112 -0
  17. query_cascade-0.3.5/tests/test_code_versioning.py +117 -0
  18. {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_disk_cache.py +3 -3
  19. query_cascade-0.3.5/tests/test_engine_sweep.py +78 -0
  20. query_cascade-0.3.5/tests/test_error_caching.py +125 -0
  21. query_cascade-0.3.5/tests/test_error_caching_persistence.py +39 -0
  22. query_cascade-0.3.5/tests/test_memoize_false.py +77 -0
  23. query_cascade-0.3.5/tests/test_store_coverage.py +44 -0
  24. query_cascade-0.3.5/tests/test_sweep_unaccessed.py +33 -0
  25. query_cascade-0.3.3/src/cascade/_evaluator.py +0 -669
  26. {query_cascade-0.3.3 → query_cascade-0.3.5}/setup.cfg +0 -0
  27. {query_cascade-0.3.3 → query_cascade-0.3.5}/src/cascade/__init__.py +0 -0
  28. {query_cascade-0.3.3 → query_cascade-0.3.5}/src/cascade/_errors.py +0 -0
  29. {query_cascade-0.3.3 → query_cascade-0.3.5}/src/cascade/_graph_export.py +0 -0
  30. {query_cascade-0.3.3 → query_cascade-0.3.5}/src/cascade/_persistence.py +0 -0
  31. {query_cascade-0.3.3 → query_cascade-0.3.5}/src/cascade/_scheduler.py +0 -0
  32. {query_cascade-0.3.3 → query_cascade-0.3.5}/src/cascade/_synthetic_graph.py +0 -0
  33. {query_cascade-0.3.3 → query_cascade-0.3.5}/src/query_cascade.egg-info/dependency_links.txt +0 -0
  34. {query_cascade-0.3.3 → query_cascade-0.3.5}/src/query_cascade.egg-info/top_level.txt +0 -0
  35. {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_dependency_parallelism.py +0 -0
  36. {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_engine.py +0 -0
  37. {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_engine_compute_many.py +0 -0
  38. {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_engine_concurrency.py +0 -0
  39. {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_engine_persistence_and_tracing.py +0 -0
  40. {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_engine_private.py +0 -0
  41. {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_engine_private2.py +0 -0
  42. {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_evaluator_and_synthetic_coverage.py +0 -0
  43. {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_examples.py +0 -0
  44. {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_fixed_point.py +0 -0
  45. {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_internal_invariants.py +0 -0
  46. {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_performance.py +0 -0
  47. {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_repro_touch_memo_keyerror.py +0 -0
  48. {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_scale_behavior.py +0 -0
  49. {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_serde.py +0 -0
  50. {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_stateful_engine_invariants.py +0 -0
  51. {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_subgraph_stats_export.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: query-cascade
3
- Version: 0.3.3
3
+ Version: 0.3.5
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
 
@@ -92,6 +93,8 @@ print(get_result())
92
93
  * **`engine.inspect_graph()`**: Returns a dictionary of all nodes and edges in the dependency graph.
93
94
  * **`engine.subgraph(roots, direction="deps")`**: Filters the graph to the dependency chain of the specified root nodes.
94
95
  * **`engine.prune(roots, vacuum_disk=False)`**: Removes cached query results from the in-memory LRU cache that are not reachable from the specified roots. Set `vacuum_disk=True` to also do a deep vacuum of the persistent LMDB disk cache, deleting all orphaned blobs and metadata.
96
+ * **`engine.access_id`**: Property returning a monotonically increasing sequence number for memo accesses.
97
+ * **`engine.sweep_unaccessed(since_access_id)`**: Evicts all memos that haven't been accessed since `since_access_id`. Useful for generational garbage collection (e.g. at the end of a compilation pass).
95
98
 
96
99
  ---
97
100
 
@@ -136,6 +139,43 @@ A few things to know:
136
139
 
137
140
  ## Advanced Features
138
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
+
139
179
  ### Side-Effect Accumulators
140
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.
141
181
 
@@ -155,6 +195,21 @@ validate_data(effects=effects)
155
195
  print(effects["warnings"])
156
196
  ```
157
197
 
198
+ ### Error Caching
199
+ By default, queries will intercept and cache exceptions (`Exception` subclasses, excluding control flow exceptions like `QueryCancelled`). This is critical for interactive systems like language servers where inputs are frequently invalid.
200
+
201
+ ```python
202
+ @engine.query(cache_exceptions=(ValueError, TypeError))
203
+ def parse(source: str):
204
+ if not source:
205
+ raise ValueError("Empty source")
206
+ return {"ast": source}
207
+ ```
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.
209
+
210
+ ### Code-Aware Caching (Automatic Invalidation)
211
+ 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.
212
+
158
213
  ### Performance Metrics
159
214
  Set `stats=True` in the `Engine` constructor to track execution timing.
160
215
  * **`engine.stats_summary()`**: Returns wall-clock time spent in function bodies and cache eviction counts.
@@ -197,6 +252,11 @@ pip install query-cascade
197
252
  | Script | What it shows |
198
253
  |--------|----------------|
199
254
  | `compiler_pipeline.py` | `source → parse → symbols → typecheck`, warnings accumulator, cache-hit narration |
255
+ | `async_execution.py` | Asynchronous query evaluation and asyncio event loop integration for IO-bound work |
256
+ | `error_caching.py` | Basic exception caching to prevent repeated re-evaluation on failure |
257
+ | `error_caching_persistence.py` | Disk cache hydration of exceptions across process runs |
258
+ | `code_versioning.py` | Automatic cache invalidation when a function's bytecode logic changes |
259
+ | `pass_through_queries.py` | `memoize=False` tracking inputs without keeping large outputs in the LRU cache |
200
260
  | `dynamic_macro_expansion.py` | Query that **changes downstream dependencies** at runtime |
201
261
  | `snapshot_isolation.py` | Snapshot reads while live inputs change |
202
262
  | `concurrent_background_work.py` | Dedup under concurrency + cancellation after input changes |
@@ -74,6 +74,8 @@ print(get_result())
74
74
  * **`engine.inspect_graph()`**: Returns a dictionary of all nodes and edges in the dependency graph.
75
75
  * **`engine.subgraph(roots, direction="deps")`**: Filters the graph to the dependency chain of the specified root nodes.
76
76
  * **`engine.prune(roots, vacuum_disk=False)`**: Removes cached query results from the in-memory LRU cache that are not reachable from the specified roots. Set `vacuum_disk=True` to also do a deep vacuum of the persistent LMDB disk cache, deleting all orphaned blobs and metadata.
77
+ * **`engine.access_id`**: Property returning a monotonically increasing sequence number for memo accesses.
78
+ * **`engine.sweep_unaccessed(since_access_id)`**: Evicts all memos that haven't been accessed since `since_access_id`. Useful for generational garbage collection (e.g. at the end of a compilation pass).
77
79
 
78
80
  ---
79
81
 
@@ -118,6 +120,43 @@ A few things to know:
118
120
 
119
121
  ## Advanced Features
120
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
+
121
160
  ### Side-Effect Accumulators
122
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.
123
162
 
@@ -137,6 +176,21 @@ validate_data(effects=effects)
137
176
  print(effects["warnings"])
138
177
  ```
139
178
 
179
+ ### Error Caching
180
+ By default, queries will intercept and cache exceptions (`Exception` subclasses, excluding control flow exceptions like `QueryCancelled`). This is critical for interactive systems like language servers where inputs are frequently invalid.
181
+
182
+ ```python
183
+ @engine.query(cache_exceptions=(ValueError, TypeError))
184
+ def parse(source: str):
185
+ if not source:
186
+ raise ValueError("Empty source")
187
+ return {"ast": source}
188
+ ```
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.
190
+
191
+ ### Code-Aware Caching (Automatic Invalidation)
192
+ 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.
193
+
140
194
  ### Performance Metrics
141
195
  Set `stats=True` in the `Engine` constructor to track execution timing.
142
196
  * **`engine.stats_summary()`**: Returns wall-clock time spent in function bodies and cache eviction counts.
@@ -179,6 +233,11 @@ pip install query-cascade
179
233
  | Script | What it shows |
180
234
  |--------|----------------|
181
235
  | `compiler_pipeline.py` | `source → parse → symbols → typecheck`, warnings accumulator, cache-hit narration |
236
+ | `async_execution.py` | Asynchronous query evaluation and asyncio event loop integration for IO-bound work |
237
+ | `error_caching.py` | Basic exception caching to prevent repeated re-evaluation on failure |
238
+ | `error_caching_persistence.py` | Disk cache hydration of exceptions across process runs |
239
+ | `code_versioning.py` | Automatic cache invalidation when a function's bytecode logic changes |
240
+ | `pass_through_queries.py` | `memoize=False` tracking inputs without keeping large outputs in the LRU cache |
182
241
  | `dynamic_macro_expansion.py` | Query that **changes downstream dependencies** at runtime |
183
242
  | `snapshot_isolation.py` | Snapshot reads while live inputs change |
184
243
  | `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.3"
7
+ version = "0.3.5"
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
  ]
@@ -114,10 +114,16 @@ def _encode_node(obj: Any) -> Any:
114
114
  if isinstance(obj, dict):
115
115
  return _encode_dict(obj)
116
116
 
117
+ if isinstance(obj, BaseException):
118
+ return _ext(
119
+ 8, # _EXT_EXCEPTION
120
+ [type(obj).__module__, type(obj).__qualname__, [_encode_node(x) for x in obj.args]]
121
+ )
122
+
117
123
  raise TypeError(
118
124
  f"cascade persistent cache: cannot serialize {type(obj).__module__}.{type(obj).__qualname__!r}; "
119
125
  "supported types are primitives, bytes, list/tuple/set/frozenset/dict, "
120
- "@dataclass instances, and typing.NamedTuple instances."
126
+ "@dataclass instances, typing.NamedTuple instances, and BaseException."
121
127
  )
122
128
 
123
129
 
@@ -182,4 +188,11 @@ def _decode_ext(ext: Any) -> Any:
182
188
  if not (isinstance(cls, type) and issubclass(cls, tuple) and hasattr(cls, "_make")):
183
189
  raise TypeError(f"{module_name}.{qualname!r} is not a NamedTuple")
184
190
  return cls(*vals)
191
+ if ext.code == 8: # _EXT_EXCEPTION
192
+ module_name, qualname, values = payload
193
+ vals = [_decode_node(x) for x in values]
194
+ cls = _resolve_type(module_name, qualname)
195
+ if not (isinstance(cls, type) and issubclass(cls, BaseException)):
196
+ raise TypeError(f"{module_name}.{qualname!r} is not an Exception")
197
+ return cls(*vals)
185
198
  raise ValueError(f"cascade persistent cache: unknown extension code {ext.code}")
@@ -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)