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.
- {query_cascade-0.3.3 → query_cascade-0.3.5}/PKG-INFO +61 -1
- {query_cascade-0.3.3 → query_cascade-0.3.5}/README.md +59 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/pyproject.toml +2 -1
- {query_cascade-0.3.3 → query_cascade-0.3.5}/src/cascade/_canonical.py +14 -1
- {query_cascade-0.3.3 → query_cascade-0.3.5}/src/cascade/_disk_cache.py +186 -186
- query_cascade-0.3.5/src/cascade/_evaluator.py +1300 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/src/cascade/_runtime.py +1 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/src/cascade/_serde.py +44 -2
- {query_cascade-0.3.3 → query_cascade-0.3.5}/src/cascade/_state.py +1 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/src/cascade/_store.py +37 -2
- {query_cascade-0.3.3 → query_cascade-0.3.5}/src/cascade/engine.py +73 -3
- {query_cascade-0.3.3 → query_cascade-0.3.5}/src/query_cascade.egg-info/PKG-INFO +61 -1
- {query_cascade-0.3.3 → query_cascade-0.3.5}/src/query_cascade.egg-info/SOURCES.txt +10 -1
- {query_cascade-0.3.3 → query_cascade-0.3.5}/src/query_cascade.egg-info/requires.txt +1 -0
- query_cascade-0.3.5/tests/test_async.py +103 -0
- query_cascade-0.3.5/tests/test_async_coverage.py +112 -0
- query_cascade-0.3.5/tests/test_code_versioning.py +117 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_disk_cache.py +3 -3
- query_cascade-0.3.5/tests/test_engine_sweep.py +78 -0
- query_cascade-0.3.5/tests/test_error_caching.py +125 -0
- query_cascade-0.3.5/tests/test_error_caching_persistence.py +39 -0
- query_cascade-0.3.5/tests/test_memoize_false.py +77 -0
- query_cascade-0.3.5/tests/test_store_coverage.py +44 -0
- query_cascade-0.3.5/tests/test_sweep_unaccessed.py +33 -0
- query_cascade-0.3.3/src/cascade/_evaluator.py +0 -669
- {query_cascade-0.3.3 → query_cascade-0.3.5}/setup.cfg +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/src/cascade/__init__.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/src/cascade/_errors.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/src/cascade/_graph_export.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/src/cascade/_persistence.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/src/cascade/_scheduler.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/src/cascade/_synthetic_graph.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/src/query_cascade.egg-info/dependency_links.txt +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/src/query_cascade.egg-info/top_level.txt +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_dependency_parallelism.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_engine.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_engine_compute_many.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_engine_concurrency.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_engine_persistence_and_tracing.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_engine_private.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_engine_private2.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_evaluator_and_synthetic_coverage.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_examples.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_fixed_point.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_internal_invariants.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_performance.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_repro_touch_memo_keyerror.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_scale_behavior.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_serde.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.5}/tests/test_stateful_engine_invariants.py +0 -0
- {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
|
+
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.
|
|
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,
|
|
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)
|