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.
- {query_cascade-0.3.4 → query_cascade-0.3.6}/PKG-INFO +56 -1
- {query_cascade-0.3.4 → query_cascade-0.3.6}/README.md +54 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/pyproject.toml +2 -1
- {query_cascade-0.3.4 → query_cascade-0.3.6}/src/cascade/_disk_cache.py +186 -186
- query_cascade-0.3.6/src/cascade/_evaluator.py +1300 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/src/cascade/_runtime.py +1 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/src/cascade/_serde.py +22 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/src/cascade/_store.py +55 -2
- {query_cascade-0.3.4 → query_cascade-0.3.6}/src/cascade/engine.py +107 -4
- {query_cascade-0.3.4 → query_cascade-0.3.6}/src/query_cascade.egg-info/PKG-INFO +56 -1
- {query_cascade-0.3.4 → query_cascade-0.3.6}/src/query_cascade.egg-info/SOURCES.txt +4 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/src/query_cascade.egg-info/requires.txt +1 -0
- query_cascade-0.3.6/tests/test_async.py +103 -0
- query_cascade-0.3.6/tests/test_async_coverage.py +112 -0
- query_cascade-0.3.6/tests/test_code_versioning.py +117 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_engine.py +46 -0
- query_cascade-0.3.6/tests/test_memoize_false.py +77 -0
- query_cascade-0.3.4/src/cascade/_evaluator.py +0 -702
- {query_cascade-0.3.4 → query_cascade-0.3.6}/setup.cfg +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/src/cascade/__init__.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/src/cascade/_canonical.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/src/cascade/_errors.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/src/cascade/_graph_export.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/src/cascade/_persistence.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/src/cascade/_scheduler.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/src/cascade/_state.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/src/cascade/_synthetic_graph.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/src/query_cascade.egg-info/dependency_links.txt +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/src/query_cascade.egg-info/top_level.txt +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_dependency_parallelism.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_disk_cache.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_engine_compute_many.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_engine_concurrency.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_engine_persistence_and_tracing.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_engine_private.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_engine_private2.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_engine_sweep.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_error_caching.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_error_caching_persistence.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_evaluator_and_synthetic_coverage.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_examples.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_fixed_point.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_internal_invariants.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_performance.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_repro_touch_memo_keyerror.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_scale_behavior.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_serde.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_stateful_engine_invariants.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_store_coverage.py +0 -0
- {query_cascade-0.3.4 → query_cascade-0.3.6}/tests/test_subgraph_stats_export.py +0 -0
- {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.
|
|
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.
|
|
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)
|