query-cascade 0.3.3__tar.gz → 0.3.4__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.4}/PKG-INFO +17 -1
- {query_cascade-0.3.3 → query_cascade-0.3.4}/README.md +16 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/pyproject.toml +1 -1
- {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/_canonical.py +14 -1
- {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/_evaluator.py +39 -6
- {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/_serde.py +22 -2
- {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/_state.py +1 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/_store.py +16 -1
- {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/engine.py +11 -2
- {query_cascade-0.3.3 → query_cascade-0.3.4}/src/query_cascade.egg-info/PKG-INFO +17 -1
- {query_cascade-0.3.3 → query_cascade-0.3.4}/src/query_cascade.egg-info/SOURCES.txt +6 -1
- {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_disk_cache.py +3 -3
- query_cascade-0.3.4/tests/test_engine_sweep.py +78 -0
- query_cascade-0.3.4/tests/test_error_caching.py +125 -0
- query_cascade-0.3.4/tests/test_error_caching_persistence.py +39 -0
- query_cascade-0.3.4/tests/test_store_coverage.py +44 -0
- query_cascade-0.3.4/tests/test_sweep_unaccessed.py +33 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/setup.cfg +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/__init__.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/_disk_cache.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/_errors.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/_graph_export.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/_persistence.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/_runtime.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/_scheduler.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/_synthetic_graph.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/src/query_cascade.egg-info/dependency_links.txt +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/src/query_cascade.egg-info/requires.txt +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/src/query_cascade.egg-info/top_level.txt +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_dependency_parallelism.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_engine.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_engine_compute_many.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_engine_concurrency.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_engine_persistence_and_tracing.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_engine_private.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_engine_private2.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_evaluator_and_synthetic_coverage.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_examples.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_fixed_point.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_internal_invariants.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_performance.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_repro_touch_memo_keyerror.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_scale_behavior.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_serde.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_stateful_engine_invariants.py +0 -0
- {query_cascade-0.3.3 → query_cascade-0.3.4}/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.4
|
|
4
4
|
Summary: Minimal demand-driven query framework for incremental computation.
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -92,6 +92,8 @@ print(get_result())
|
|
|
92
92
|
* **`engine.inspect_graph()`**: Returns a dictionary of all nodes and edges in the dependency graph.
|
|
93
93
|
* **`engine.subgraph(roots, direction="deps")`**: Filters the graph to the dependency chain of the specified root nodes.
|
|
94
94
|
* **`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.
|
|
95
|
+
* **`engine.access_id`**: Property returning a monotonically increasing sequence number for memo accesses.
|
|
96
|
+
* **`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
97
|
|
|
96
98
|
---
|
|
97
99
|
|
|
@@ -155,6 +157,18 @@ validate_data(effects=effects)
|
|
|
155
157
|
print(effects["warnings"])
|
|
156
158
|
```
|
|
157
159
|
|
|
160
|
+
### Error Caching
|
|
161
|
+
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.
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
@engine.query(cache_exceptions=(ValueError, TypeError))
|
|
165
|
+
def parse(source: str):
|
|
166
|
+
if not source:
|
|
167
|
+
raise ValueError("Empty source")
|
|
168
|
+
return {"ast": source}
|
|
169
|
+
```
|
|
170
|
+
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
|
+
|
|
158
172
|
### Performance Metrics
|
|
159
173
|
Set `stats=True` in the `Engine` constructor to track execution timing.
|
|
160
174
|
* **`engine.stats_summary()`**: Returns wall-clock time spent in function bodies and cache eviction counts.
|
|
@@ -197,6 +211,8 @@ pip install query-cascade
|
|
|
197
211
|
| Script | What it shows |
|
|
198
212
|
|--------|----------------|
|
|
199
213
|
| `compiler_pipeline.py` | `source → parse → symbols → typecheck`, warnings accumulator, cache-hit narration |
|
|
214
|
+
| `error_caching.py` | Basic exception caching to prevent repeated re-evaluation on failure |
|
|
215
|
+
| `error_caching_persistence.py` | Disk cache hydration of exceptions across process runs |
|
|
200
216
|
| `dynamic_macro_expansion.py` | Query that **changes downstream dependencies** at runtime |
|
|
201
217
|
| `snapshot_isolation.py` | Snapshot reads while live inputs change |
|
|
202
218
|
| `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
|
|
|
@@ -137,6 +139,18 @@ validate_data(effects=effects)
|
|
|
137
139
|
print(effects["warnings"])
|
|
138
140
|
```
|
|
139
141
|
|
|
142
|
+
### Error Caching
|
|
143
|
+
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.
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
@engine.query(cache_exceptions=(ValueError, TypeError))
|
|
147
|
+
def parse(source: str):
|
|
148
|
+
if not source:
|
|
149
|
+
raise ValueError("Empty source")
|
|
150
|
+
return {"ast": source}
|
|
151
|
+
```
|
|
152
|
+
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
|
+
|
|
140
154
|
### Performance Metrics
|
|
141
155
|
Set `stats=True` in the `Engine` constructor to track execution timing.
|
|
142
156
|
* **`engine.stats_summary()`**: Returns wall-clock time spent in function bodies and cache eviction counts.
|
|
@@ -179,6 +193,8 @@ pip install query-cascade
|
|
|
179
193
|
| Script | What it shows |
|
|
180
194
|
|--------|----------------|
|
|
181
195
|
| `compiler_pipeline.py` | `source → parse → symbols → typecheck`, warnings accumulator, cache-hit narration |
|
|
196
|
+
| `error_caching.py` | Basic exception caching to prevent repeated re-evaluation on failure |
|
|
197
|
+
| `error_caching_persistence.py` | Disk cache hydration of exceptions across process runs |
|
|
182
198
|
| `dynamic_macro_expansion.py` | Query that **changes downstream dependencies** at runtime |
|
|
183
199
|
| `snapshot_isolation.py` | Snapshot reads while live inputs change |
|
|
184
200
|
| `concurrent_background_work.py` | Dedup under concurrency + cancellation after input changes |
|
|
@@ -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}")
|
|
@@ -169,6 +169,8 @@ class Evaluator:
|
|
|
169
169
|
self.record_dependency(key, entry.changed_at)
|
|
170
170
|
if replay_needed:
|
|
171
171
|
self.replay_effects(entry.effects)
|
|
172
|
+
if entry.error is not None:
|
|
173
|
+
raise entry.error
|
|
172
174
|
return entry.value
|
|
173
175
|
|
|
174
176
|
def compute_or_get_memo(
|
|
@@ -307,17 +309,32 @@ class Evaluator:
|
|
|
307
309
|
if k != key:
|
|
308
310
|
self._store.drop_memo_locked(k)
|
|
309
311
|
|
|
312
|
+
error: BaseException | None = None
|
|
310
313
|
while True:
|
|
311
314
|
try:
|
|
312
315
|
self.check_cancelled(runtime.cancel_epoch)
|
|
313
316
|
result = fn(*key[2])
|
|
314
317
|
self.check_cancelled(runtime.cancel_epoch)
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
+
error = None
|
|
319
|
+
except BaseException as exc:
|
|
320
|
+
cache_ex = self._store.lookup_query_cache_exceptions(key[1])
|
|
321
|
+
should_cache = False
|
|
322
|
+
if isinstance(exc, (QueryCancelled, CycleError, PersistentCacheError)):
|
|
323
|
+
pass
|
|
324
|
+
elif cache_ex is True:
|
|
325
|
+
should_cache = isinstance(exc, Exception)
|
|
326
|
+
elif isinstance(cache_ex, tuple):
|
|
327
|
+
should_cache = isinstance(exc, cache_ex)
|
|
328
|
+
|
|
329
|
+
if should_cache:
|
|
330
|
+
error = exc
|
|
331
|
+
result = None
|
|
332
|
+
else:
|
|
333
|
+
runtime.stack.pop()
|
|
334
|
+
raise
|
|
318
335
|
|
|
319
336
|
if frame.is_cycle_root:
|
|
320
|
-
if self._store.stable_hash(result) != self._store.stable_hash(frame.cycle_guess):
|
|
337
|
+
if error is None and self._store.stable_hash(result) != self._store.stable_hash(frame.cycle_guess):
|
|
321
338
|
frame.cycle_guess = result
|
|
322
339
|
frame.is_cycle_root = False
|
|
323
340
|
with self._store.lock:
|
|
@@ -337,7 +354,10 @@ class Evaluator:
|
|
|
337
354
|
duration_ms = elapsed * 1000.0
|
|
338
355
|
|
|
339
356
|
frozen_effects = {name: tuple(items) for name, items in frame.effects.items()}
|
|
340
|
-
|
|
357
|
+
if error is not None:
|
|
358
|
+
value_hash = self._store.stable_hash(error)
|
|
359
|
+
else:
|
|
360
|
+
value_hash = self._store.stable_hash(result)
|
|
341
361
|
|
|
342
362
|
if frame.cycle_nodes:
|
|
343
363
|
for k in frame.cycle_nodes:
|
|
@@ -360,6 +380,7 @@ class Evaluator:
|
|
|
360
380
|
effects=frozen_effects,
|
|
361
381
|
last_access=self._store.next_access_id,
|
|
362
382
|
cycle_nodes=tuple(frame.cycle_nodes),
|
|
383
|
+
error=error,
|
|
363
384
|
)
|
|
364
385
|
self._store.drop_memo_locked(key)
|
|
365
386
|
self._store.memos[key] = memo
|
|
@@ -411,9 +432,16 @@ class Evaluator:
|
|
|
411
432
|
value = _canonical.decode(blob)
|
|
412
433
|
effects_raw = record.get("effects", {})
|
|
413
434
|
effects = {str(name): tuple(items) for name, items in effects_raw.items()}
|
|
435
|
+
is_error = record.get("is_error", False)
|
|
414
436
|
except Exception:
|
|
415
437
|
self._store.trace_event("disk_red", key, detail="record decode failed")
|
|
416
438
|
return None
|
|
439
|
+
|
|
440
|
+
error = None
|
|
441
|
+
if is_error:
|
|
442
|
+
error = value
|
|
443
|
+
value = None
|
|
444
|
+
|
|
417
445
|
with self._store.lock:
|
|
418
446
|
self._store.next_access_id += 1
|
|
419
447
|
memo = self._store.entry_from_runtime(
|
|
@@ -424,6 +452,7 @@ class Evaluator:
|
|
|
424
452
|
deps=observed,
|
|
425
453
|
effects=effects,
|
|
426
454
|
last_access=self._store.next_access_id,
|
|
455
|
+
error=error,
|
|
427
456
|
)
|
|
428
457
|
self._store.drop_memo_locked(key)
|
|
429
458
|
self._store.memos[key] = memo
|
|
@@ -547,7 +576,10 @@ class Evaluator:
|
|
|
547
576
|
if deps_records is None:
|
|
548
577
|
return
|
|
549
578
|
try:
|
|
550
|
-
|
|
579
|
+
if memo.error is not None:
|
|
580
|
+
value_blob = _canonical.encode(memo.error)
|
|
581
|
+
else:
|
|
582
|
+
value_blob = _canonical.encode(memo.value)
|
|
551
583
|
except TypeError as exc:
|
|
552
584
|
raise PersistentCacheError(
|
|
553
585
|
f"query {self._store.key_to_str(key)!r} returned a value the persistent cache "
|
|
@@ -566,6 +598,7 @@ class Evaluator:
|
|
|
566
598
|
"value_hash": memo.value_hash,
|
|
567
599
|
"deps": deps_records,
|
|
568
600
|
"effects": effects_node,
|
|
601
|
+
"is_error": memo.error is not None,
|
|
569
602
|
}
|
|
570
603
|
disk.store_entry(_disk_cache.entry_key(kind, fid, args_blob), record, memo.value_hash, value_blob)
|
|
571
604
|
except PersistentCacheError:
|
|
@@ -183,10 +183,19 @@ def _to_jsonable(obj: Any) -> Any:
|
|
|
183
183
|
pairs.append([_to_jsonable(k), _to_jsonable(obj[k])])
|
|
184
184
|
return {"__map__": pairs}
|
|
185
185
|
|
|
186
|
+
if isinstance(obj, BaseException):
|
|
187
|
+
return {
|
|
188
|
+
"__exception__": {
|
|
189
|
+
"m": type(obj).__module__,
|
|
190
|
+
"q": type(obj).__qualname__,
|
|
191
|
+
"a": [_to_jsonable(x) for x in obj.args],
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
186
195
|
raise TypeError(
|
|
187
196
|
f"cascade serde: unsupported type {type(obj).__module__}.{type(obj).__qualname__!r}; "
|
|
188
|
-
"use primitives, bytes, collections, @dataclass / typing.NamedTuple instances,
|
|
189
|
-
"cascade state types (MemoEntry, InputVersion, …)."
|
|
197
|
+
"use primitives, bytes, collections, @dataclass / typing.NamedTuple instances, "
|
|
198
|
+
"cascade state types (MemoEntry, InputVersion, …), or BaseException."
|
|
190
199
|
)
|
|
191
200
|
|
|
192
201
|
|
|
@@ -281,4 +290,15 @@ def _from_jsonable(obj: Any) -> Any:
|
|
|
281
290
|
pass
|
|
282
291
|
return tuple(vals)
|
|
283
292
|
|
|
293
|
+
if sole_key == "__exception__":
|
|
294
|
+
d = sole_val
|
|
295
|
+
vals = [_from_jsonable(x) for x in d["a"]]
|
|
296
|
+
try:
|
|
297
|
+
cls = _resolve_type(d["m"], d["q"])
|
|
298
|
+
if isinstance(cls, type) and issubclass(cls, BaseException):
|
|
299
|
+
return cls(*vals)
|
|
300
|
+
except Exception:
|
|
301
|
+
pass
|
|
302
|
+
return Exception(f"Unresolved exception {d['m']}.{d['q']}: {vals}")
|
|
303
|
+
|
|
284
304
|
raise TypeError(f"cascade serde: unrecognized object shape: {obj!r}")
|
|
@@ -45,6 +45,7 @@ class GraphStore:
|
|
|
45
45
|
self.inputs: dict[InputKey, list[InputVersion]] = {}
|
|
46
46
|
self.queries: dict[str, Callable[..., Any]] = {}
|
|
47
47
|
self.query_fixed_points: dict[str, Any] = {}
|
|
48
|
+
self.query_cache_exceptions: dict[str, bool | tuple[type[BaseException], ...]] = {}
|
|
48
49
|
self.input_fns: dict[str, Callable[..., Any]] = {}
|
|
49
50
|
self.memos: dict[QueryKey, MemoEntry] = {}
|
|
50
51
|
self.dependents: dict[QueryKey, set[QueryKey]] = defaultdict(set)
|
|
@@ -102,9 +103,10 @@ class GraphStore:
|
|
|
102
103
|
sk = self.key_to_str(key)
|
|
103
104
|
self._stats_by_key[sk] = self._stats_by_key.get(sk, 0.0) + seconds
|
|
104
105
|
|
|
105
|
-
def register_query(self, query_id: str, fn: Callable[..., Any], *, fixed_point: Any = None, has_fixed_point: bool = False) -> None:
|
|
106
|
+
def register_query(self, query_id: str, fn: Callable[..., Any], *, fixed_point: Any = None, has_fixed_point: bool = False, cache_exceptions: bool | tuple[type[BaseException], ...] = False) -> None:
|
|
106
107
|
with self.lock:
|
|
107
108
|
self.queries[query_id] = fn
|
|
109
|
+
self.query_cache_exceptions[query_id] = cache_exceptions
|
|
108
110
|
if has_fixed_point:
|
|
109
111
|
self.query_fixed_points[query_id] = fixed_point
|
|
110
112
|
|
|
@@ -112,6 +114,10 @@ class GraphStore:
|
|
|
112
114
|
with self.lock:
|
|
113
115
|
return self.queries[query_id]
|
|
114
116
|
|
|
117
|
+
def lookup_query_cache_exceptions(self, query_id: str) -> bool | tuple[type[BaseException], ...]:
|
|
118
|
+
with self.lock:
|
|
119
|
+
return self.query_cache_exceptions.get(query_id, False)
|
|
120
|
+
|
|
115
121
|
def lookup_query_fixed_point(self, query_id: str) -> tuple[bool, Any]:
|
|
116
122
|
"""Returns (has_fixed_point, fixed_point_value)."""
|
|
117
123
|
with self.lock:
|
|
@@ -238,6 +244,13 @@ class GraphStore:
|
|
|
238
244
|
self._stats_evictions_recent.append(self.key_to_str(victim))
|
|
239
245
|
self.drop_memo_locked(victim)
|
|
240
246
|
|
|
247
|
+
def sweep_unaccessed(self, since_access_id: int) -> None:
|
|
248
|
+
with self.lock:
|
|
249
|
+
remove = [k for k, memo in self.memos.items() if memo.last_access <= since_access_id]
|
|
250
|
+
for key in remove:
|
|
251
|
+
self.drop_memo_locked(key)
|
|
252
|
+
self._rebuild_lru_heap_locked()
|
|
253
|
+
|
|
241
254
|
def latest_input_version(self, input_key: InputKey) -> InputVersion | None:
|
|
242
255
|
versions = self.inputs.get(input_key)
|
|
243
256
|
if not versions:
|
|
@@ -430,6 +443,7 @@ class GraphStore:
|
|
|
430
443
|
effects: dict[str, tuple[Any, ...]],
|
|
431
444
|
last_access: int,
|
|
432
445
|
cycle_nodes: tuple[QueryKey, ...] = (),
|
|
446
|
+
error: BaseException | None = None,
|
|
433
447
|
) -> MemoEntry:
|
|
434
448
|
return MemoEntry(
|
|
435
449
|
value=value,
|
|
@@ -440,4 +454,5 @@ class GraphStore:
|
|
|
440
454
|
effects=effects,
|
|
441
455
|
last_access=last_access,
|
|
442
456
|
cycle_nodes=cycle_nodes,
|
|
457
|
+
error=error,
|
|
443
458
|
)
|
|
@@ -218,11 +218,11 @@ class Engine:
|
|
|
218
218
|
self._store.register_input(handle.id, fn)
|
|
219
219
|
return handle
|
|
220
220
|
|
|
221
|
-
def query(self, fn: Callable[..., Any] | None = None, *, fixed_point: Any = _UNSET) -> Any:
|
|
221
|
+
def query(self, fn: Callable[..., Any] | None = None, *, fixed_point: Any = _UNSET, cache_exceptions: bool | tuple[type[BaseException], ...] = True) -> Any:
|
|
222
222
|
def decorator(f: Callable[..., Any]) -> _QueryHandle:
|
|
223
223
|
handle = _QueryHandle(self, f)
|
|
224
224
|
has_fixed_point = fixed_point is not _UNSET
|
|
225
|
-
self._store.register_query(handle.id, f, fixed_point=fixed_point, has_fixed_point=has_fixed_point)
|
|
225
|
+
self._store.register_query(handle.id, f, fixed_point=fixed_point, has_fixed_point=has_fixed_point, cache_exceptions=cache_exceptions)
|
|
226
226
|
return handle
|
|
227
227
|
if fn is not None:
|
|
228
228
|
return decorator(fn)
|
|
@@ -423,6 +423,15 @@ class Engine:
|
|
|
423
423
|
def reset_stats(self) -> None:
|
|
424
424
|
self._store.reset_stats()
|
|
425
425
|
|
|
426
|
+
@property
|
|
427
|
+
def access_id(self) -> int:
|
|
428
|
+
"""The monotonically increasing sequence number for memo accesses."""
|
|
429
|
+
return self._store.next_access_id
|
|
430
|
+
|
|
431
|
+
def sweep_unaccessed(self, since_access_id: int) -> None:
|
|
432
|
+
"""Evict all memos that haven't been accessed since the given access ID."""
|
|
433
|
+
self._store.sweep_unaccessed(since_access_id)
|
|
434
|
+
|
|
426
435
|
def prune(self, roots: Iterable[tuple[str, str, tuple[Any, ...]]], *, vacuum_disk: bool = False) -> None:
|
|
427
436
|
root_list = list(roots)
|
|
428
437
|
self._store.prune(root_list)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: query-cascade
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Minimal demand-driven query framework for incremental computation.
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -92,6 +92,8 @@ print(get_result())
|
|
|
92
92
|
* **`engine.inspect_graph()`**: Returns a dictionary of all nodes and edges in the dependency graph.
|
|
93
93
|
* **`engine.subgraph(roots, direction="deps")`**: Filters the graph to the dependency chain of the specified root nodes.
|
|
94
94
|
* **`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.
|
|
95
|
+
* **`engine.access_id`**: Property returning a monotonically increasing sequence number for memo accesses.
|
|
96
|
+
* **`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
97
|
|
|
96
98
|
---
|
|
97
99
|
|
|
@@ -155,6 +157,18 @@ validate_data(effects=effects)
|
|
|
155
157
|
print(effects["warnings"])
|
|
156
158
|
```
|
|
157
159
|
|
|
160
|
+
### Error Caching
|
|
161
|
+
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.
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
@engine.query(cache_exceptions=(ValueError, TypeError))
|
|
165
|
+
def parse(source: str):
|
|
166
|
+
if not source:
|
|
167
|
+
raise ValueError("Empty source")
|
|
168
|
+
return {"ast": source}
|
|
169
|
+
```
|
|
170
|
+
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
|
+
|
|
158
172
|
### Performance Metrics
|
|
159
173
|
Set `stats=True` in the `Engine` constructor to track execution timing.
|
|
160
174
|
* **`engine.stats_summary()`**: Returns wall-clock time spent in function bodies and cache eviction counts.
|
|
@@ -197,6 +211,8 @@ pip install query-cascade
|
|
|
197
211
|
| Script | What it shows |
|
|
198
212
|
|--------|----------------|
|
|
199
213
|
| `compiler_pipeline.py` | `source → parse → symbols → typecheck`, warnings accumulator, cache-hit narration |
|
|
214
|
+
| `error_caching.py` | Basic exception caching to prevent repeated re-evaluation on failure |
|
|
215
|
+
| `error_caching_persistence.py` | Disk cache hydration of exceptions across process runs |
|
|
200
216
|
| `dynamic_macro_expansion.py` | Query that **changes downstream dependencies** at runtime |
|
|
201
217
|
| `snapshot_isolation.py` | Snapshot reads while live inputs change |
|
|
202
218
|
| `concurrent_background_work.py` | Dedup under concurrency + cancellation after input changes |
|
|
@@ -27,6 +27,9 @@ tests/test_engine_concurrency.py
|
|
|
27
27
|
tests/test_engine_persistence_and_tracing.py
|
|
28
28
|
tests/test_engine_private.py
|
|
29
29
|
tests/test_engine_private2.py
|
|
30
|
+
tests/test_engine_sweep.py
|
|
31
|
+
tests/test_error_caching.py
|
|
32
|
+
tests/test_error_caching_persistence.py
|
|
30
33
|
tests/test_evaluator_and_synthetic_coverage.py
|
|
31
34
|
tests/test_examples.py
|
|
32
35
|
tests/test_fixed_point.py
|
|
@@ -36,4 +39,6 @@ tests/test_repro_touch_memo_keyerror.py
|
|
|
36
39
|
tests/test_scale_behavior.py
|
|
37
40
|
tests/test_serde.py
|
|
38
41
|
tests/test_stateful_engine_invariants.py
|
|
39
|
-
tests/
|
|
42
|
+
tests/test_store_coverage.py
|
|
43
|
+
tests/test_subgraph_stats_export.py
|
|
44
|
+
tests/test_sweep_unaccessed.py
|
|
@@ -747,7 +747,7 @@ def test_dynamic_graph_topology_cross_session(tmp_path: Path) -> None:
|
|
|
747
747
|
engine_5.shutdown()
|
|
748
748
|
|
|
749
749
|
|
|
750
|
-
def
|
|
750
|
+
def test_errors_are_cached_across_sessions(tmp_path: Path) -> None:
|
|
751
751
|
data = tmp_path / "state.txt"
|
|
752
752
|
data.write_text("fail")
|
|
753
753
|
cache = tmp_path / "cache"
|
|
@@ -777,12 +777,12 @@ def test_errors_are_not_cached_across_sessions(tmp_path: Path) -> None:
|
|
|
777
777
|
assert runs == {"process": 1}
|
|
778
778
|
engine_1.shutdown()
|
|
779
779
|
|
|
780
|
-
# Session 2: still failing,
|
|
780
|
+
# Session 2: still failing, hydrated from disk
|
|
781
781
|
runs.clear()
|
|
782
782
|
engine_2, process_2 = build()
|
|
783
783
|
with pytest.raises(ValueError, match="Oops"):
|
|
784
784
|
process_2()
|
|
785
|
-
assert runs == {
|
|
785
|
+
assert runs == {}
|
|
786
786
|
engine_2.shutdown()
|
|
787
787
|
|
|
788
788
|
# Session 3: state fixed, runs and caches
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from cascade.engine import Engine
|
|
2
|
+
|
|
3
|
+
def test_sweep_unaccessed() -> None:
|
|
4
|
+
engine = Engine()
|
|
5
|
+
|
|
6
|
+
@engine.input
|
|
7
|
+
def file_content(path: str) -> str:
|
|
8
|
+
return "content"
|
|
9
|
+
|
|
10
|
+
@engine.query
|
|
11
|
+
def compile_file(path: str) -> str:
|
|
12
|
+
return file_content(path).upper()
|
|
13
|
+
|
|
14
|
+
@engine.query
|
|
15
|
+
def compile_project(paths: tuple[str, ...]) -> str:
|
|
16
|
+
return " + ".join(compile_file(p) for p in paths)
|
|
17
|
+
|
|
18
|
+
# 1. Populate cache with A, B, C
|
|
19
|
+
res = compile_project(("A", "B", "C"))
|
|
20
|
+
assert res == "CONTENT + CONTENT + CONTENT"
|
|
21
|
+
|
|
22
|
+
# Store access ID after the first pass
|
|
23
|
+
start_id = engine.access_id
|
|
24
|
+
|
|
25
|
+
# 2. Simulate second pass: file "C" is deleted, only "A" and "B" remain.
|
|
26
|
+
res2 = compile_project(("A", "B"))
|
|
27
|
+
assert res2 == "CONTENT + CONTENT"
|
|
28
|
+
|
|
29
|
+
# Verify that file_content("C") and compile_file("C") are still in cache
|
|
30
|
+
graph_before = engine.inspect_graph()
|
|
31
|
+
nodes_before = set(graph_before["nodes"])
|
|
32
|
+
assert "query:tests.test_engine_sweep:test_sweep_unaccessed.<locals>.compile_file('C',)" in nodes_before
|
|
33
|
+
|
|
34
|
+
# 3. Sweep
|
|
35
|
+
engine.sweep_unaccessed(start_id)
|
|
36
|
+
|
|
37
|
+
# 4. Verify unaccessed nodes are dropped, accessed are kept
|
|
38
|
+
graph_after = engine.inspect_graph()
|
|
39
|
+
nodes_after = set(graph_after["nodes"])
|
|
40
|
+
|
|
41
|
+
assert "query:tests.test_engine_sweep:test_sweep_unaccessed.<locals>.compile_file('C',)" not in nodes_after
|
|
42
|
+
assert "query:tests.test_engine_sweep:test_sweep_unaccessed.<locals>.compile_file('A',)" in nodes_after
|
|
43
|
+
assert "query:tests.test_engine_sweep:test_sweep_unaccessed.<locals>.compile_file('B',)" in nodes_after
|
|
44
|
+
|
|
45
|
+
# Note: sweep_unaccessed operates on query memos (Memos), not input versions.
|
|
46
|
+
# The input nodes reported by inspect_graph might still show up since they
|
|
47
|
+
# are tracked separately in inputs, but the memo table eviction is what frees up memory.
|
|
48
|
+
|
|
49
|
+
def test_sweep_empty_cache() -> None:
|
|
50
|
+
engine = Engine()
|
|
51
|
+
start_id = engine.access_id
|
|
52
|
+
|
|
53
|
+
# sweeping an empty cache should not crash
|
|
54
|
+
engine.sweep_unaccessed(start_id)
|
|
55
|
+
|
|
56
|
+
graph_after = engine.inspect_graph()
|
|
57
|
+
assert len(graph_after["nodes"]) == 0
|
|
58
|
+
|
|
59
|
+
def test_sweep_resets_lru() -> None:
|
|
60
|
+
engine = Engine(max_entries=2)
|
|
61
|
+
|
|
62
|
+
@engine.query
|
|
63
|
+
def q(x: int) -> int:
|
|
64
|
+
return x
|
|
65
|
+
|
|
66
|
+
q(1)
|
|
67
|
+
q(2)
|
|
68
|
+
start_id = engine.access_id
|
|
69
|
+
q(3)
|
|
70
|
+
|
|
71
|
+
# 3 pushes out 1 because max_entries=2 (2 and 3 remain)
|
|
72
|
+
graph = engine.inspect_graph()
|
|
73
|
+
assert len(graph["nodes"]) == 2
|
|
74
|
+
|
|
75
|
+
engine.sweep_unaccessed(start_id)
|
|
76
|
+
# Only 3 remains since it was accessed after start_id
|
|
77
|
+
graph_after = engine.inspect_graph()
|
|
78
|
+
assert len(graph_after["nodes"]) == 1
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from cascade.engine import Engine
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_error_is_cached():
|
|
7
|
+
engine = Engine()
|
|
8
|
+
calls = 0
|
|
9
|
+
|
|
10
|
+
@engine.query(cache_exceptions=True)
|
|
11
|
+
def throws_error():
|
|
12
|
+
nonlocal calls
|
|
13
|
+
calls += 1
|
|
14
|
+
raise ValueError("Something went wrong")
|
|
15
|
+
|
|
16
|
+
with pytest.raises(ValueError, match="Something went wrong"):
|
|
17
|
+
throws_error()
|
|
18
|
+
assert calls == 1
|
|
19
|
+
|
|
20
|
+
# Should hit cache and raise again without incrementing calls
|
|
21
|
+
with pytest.raises(ValueError, match="Something went wrong"):
|
|
22
|
+
throws_error()
|
|
23
|
+
assert calls == 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_error_cache_invalidation():
|
|
27
|
+
engine = Engine()
|
|
28
|
+
|
|
29
|
+
@engine.input
|
|
30
|
+
def get_input():
|
|
31
|
+
return "bad"
|
|
32
|
+
|
|
33
|
+
calls = 0
|
|
34
|
+
|
|
35
|
+
@engine.query(cache_exceptions=True)
|
|
36
|
+
def process():
|
|
37
|
+
nonlocal calls
|
|
38
|
+
calls += 1
|
|
39
|
+
val = get_input()
|
|
40
|
+
if val == "bad":
|
|
41
|
+
raise ValueError("bad input")
|
|
42
|
+
return val
|
|
43
|
+
|
|
44
|
+
with pytest.raises(ValueError, match="bad input"):
|
|
45
|
+
process()
|
|
46
|
+
assert calls == 1
|
|
47
|
+
|
|
48
|
+
with pytest.raises(ValueError, match="bad input"):
|
|
49
|
+
process()
|
|
50
|
+
assert calls == 1
|
|
51
|
+
|
|
52
|
+
# Invalidate by changing input
|
|
53
|
+
get_input.set(value="good")
|
|
54
|
+
assert process() == "good"
|
|
55
|
+
assert calls == 2
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_dependent_query_rethrows():
|
|
59
|
+
engine = Engine()
|
|
60
|
+
b_calls = 0
|
|
61
|
+
|
|
62
|
+
@engine.query(cache_exceptions=True)
|
|
63
|
+
def b():
|
|
64
|
+
nonlocal b_calls
|
|
65
|
+
b_calls += 1
|
|
66
|
+
raise RuntimeError("b failed")
|
|
67
|
+
|
|
68
|
+
a_calls = 0
|
|
69
|
+
|
|
70
|
+
@engine.query(cache_exceptions=True)
|
|
71
|
+
def a():
|
|
72
|
+
nonlocal a_calls
|
|
73
|
+
a_calls += 1
|
|
74
|
+
return b()
|
|
75
|
+
|
|
76
|
+
with pytest.raises(RuntimeError, match="b failed"):
|
|
77
|
+
a()
|
|
78
|
+
assert a_calls == 1
|
|
79
|
+
assert b_calls == 1
|
|
80
|
+
|
|
81
|
+
with pytest.raises(RuntimeError, match="b failed"):
|
|
82
|
+
a()
|
|
83
|
+
assert a_calls == 1
|
|
84
|
+
assert b_calls == 1
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class MyError(Exception):
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_selective_exception_caching():
|
|
92
|
+
engine = Engine()
|
|
93
|
+
calls = 0
|
|
94
|
+
|
|
95
|
+
@engine.input
|
|
96
|
+
def error_type():
|
|
97
|
+
return "my"
|
|
98
|
+
|
|
99
|
+
@engine.query(cache_exceptions=(MyError,))
|
|
100
|
+
def throws_specific():
|
|
101
|
+
nonlocal calls
|
|
102
|
+
calls += 1
|
|
103
|
+
err = error_type()
|
|
104
|
+
if err == "my":
|
|
105
|
+
raise MyError("my error")
|
|
106
|
+
else:
|
|
107
|
+
raise ValueError("value error")
|
|
108
|
+
|
|
109
|
+
with pytest.raises(MyError):
|
|
110
|
+
throws_specific()
|
|
111
|
+
assert calls == 1
|
|
112
|
+
|
|
113
|
+
with pytest.raises(MyError):
|
|
114
|
+
throws_specific()
|
|
115
|
+
assert calls == 1 # cached
|
|
116
|
+
|
|
117
|
+
error_type.set(value="value")
|
|
118
|
+
with pytest.raises(ValueError):
|
|
119
|
+
throws_specific()
|
|
120
|
+
assert calls == 2
|
|
121
|
+
|
|
122
|
+
# Should NOT be cached
|
|
123
|
+
with pytest.raises(ValueError):
|
|
124
|
+
throws_specific()
|
|
125
|
+
assert calls == 3
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import tempfile
|
|
3
|
+
from cascade.engine import Engine
|
|
4
|
+
|
|
5
|
+
def test_persistent_error_caching():
|
|
6
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
7
|
+
engine1 = Engine(cache_dir=tmpdir)
|
|
8
|
+
|
|
9
|
+
calls1 = 0
|
|
10
|
+
|
|
11
|
+
@engine1.query
|
|
12
|
+
def throws():
|
|
13
|
+
nonlocal calls1
|
|
14
|
+
calls1 += 1
|
|
15
|
+
raise ValueError("Disk cached error")
|
|
16
|
+
|
|
17
|
+
with pytest.raises(ValueError, match="Disk cached error"):
|
|
18
|
+
throws()
|
|
19
|
+
assert calls1 == 1
|
|
20
|
+
|
|
21
|
+
engine1.shutdown()
|
|
22
|
+
|
|
23
|
+
# Hydrate in new engine
|
|
24
|
+
engine2 = Engine(cache_dir=tmpdir)
|
|
25
|
+
calls2 = 0
|
|
26
|
+
|
|
27
|
+
@engine2.query
|
|
28
|
+
def throws():
|
|
29
|
+
nonlocal calls2
|
|
30
|
+
calls2 += 1
|
|
31
|
+
raise ValueError("Disk cached error")
|
|
32
|
+
|
|
33
|
+
with pytest.raises(ValueError, match="Disk cached error"):
|
|
34
|
+
throws()
|
|
35
|
+
|
|
36
|
+
# Should be hydrated from disk, so calls2 remains 0
|
|
37
|
+
assert calls2 == 0
|
|
38
|
+
|
|
39
|
+
engine2.shutdown()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from cascade.engine import Engine
|
|
2
|
+
|
|
3
|
+
def test_store_stats_eviction_recent_cap() -> None:
|
|
4
|
+
engine = Engine(stats=True, max_entries=2)
|
|
5
|
+
# populate cache
|
|
6
|
+
@engine.query
|
|
7
|
+
def q(x: int) -> int:
|
|
8
|
+
return x
|
|
9
|
+
|
|
10
|
+
q(1)
|
|
11
|
+
q(2)
|
|
12
|
+
q(3) # evicts 1
|
|
13
|
+
|
|
14
|
+
assert engine.stats_summary()["evictions_total"] == 1
|
|
15
|
+
assert len(engine.stats_summary()["evictions_recent"]) == 1
|
|
16
|
+
|
|
17
|
+
# modify cap to 0
|
|
18
|
+
engine._store.set_stats_eviction_recent_cap(0)
|
|
19
|
+
assert len(engine.stats_summary()["evictions_recent"]) == 0
|
|
20
|
+
|
|
21
|
+
# modify cap to 5
|
|
22
|
+
engine._store.set_stats_eviction_recent_cap(5)
|
|
23
|
+
|
|
24
|
+
q(4) # evicts 2
|
|
25
|
+
assert engine.stats_summary()["evictions_total"] == 2
|
|
26
|
+
assert len(engine.stats_summary()["evictions_recent"]) == 1
|
|
27
|
+
|
|
28
|
+
def test_engine_negative_clock() -> None:
|
|
29
|
+
clock_val = 100.0
|
|
30
|
+
def clock():
|
|
31
|
+
nonlocal clock_val
|
|
32
|
+
val = clock_val
|
|
33
|
+
clock_val -= 10.0 # goes backwards!
|
|
34
|
+
return val
|
|
35
|
+
|
|
36
|
+
engine = Engine(stats=True, stats_clock=clock)
|
|
37
|
+
@engine.query
|
|
38
|
+
def q(x: int) -> int:
|
|
39
|
+
return x
|
|
40
|
+
|
|
41
|
+
q(1)
|
|
42
|
+
stats = engine.stats_summary()
|
|
43
|
+
key = list(stats["by_key"].keys())[0]
|
|
44
|
+
assert stats["by_key"][key] == 0.0 # clamped from negative to 0.0
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from cascade import Engine
|
|
2
|
+
|
|
3
|
+
def test_sweep_unaccessed() -> None:
|
|
4
|
+
engine = Engine()
|
|
5
|
+
|
|
6
|
+
@engine.input
|
|
7
|
+
def root() -> int:
|
|
8
|
+
return 1
|
|
9
|
+
|
|
10
|
+
@engine.query
|
|
11
|
+
def query_a() -> int:
|
|
12
|
+
return root() + 1
|
|
13
|
+
|
|
14
|
+
@engine.query
|
|
15
|
+
def query_b() -> int:
|
|
16
|
+
return query_a() + 1
|
|
17
|
+
|
|
18
|
+
# Phase 1: Compute both. Both 'query_a' and 'query_b' are accessed.
|
|
19
|
+
assert query_b() == 3
|
|
20
|
+
stats = engine.stats_summary()
|
|
21
|
+
assert stats["memo_count"] == 2
|
|
22
|
+
|
|
23
|
+
# Record access_id before the next "epoch"
|
|
24
|
+
epoch = engine.access_id
|
|
25
|
+
|
|
26
|
+
# Phase 2: Compute only a (query_b is unaccessed this pass)
|
|
27
|
+
assert query_a() == 2
|
|
28
|
+
|
|
29
|
+
engine.sweep_unaccessed(epoch)
|
|
30
|
+
|
|
31
|
+
# query_b should be evicted, query_a should remain
|
|
32
|
+
stats_after = engine.stats_summary()
|
|
33
|
+
assert stats_after["memo_count"] == 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|