query-cascade 0.3.2__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.2 → query_cascade-0.3.4}/PKG-INFO +17 -1
- {query_cascade-0.3.2 → query_cascade-0.3.4}/README.md +16 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/pyproject.toml +1 -1
- {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/_canonical.py +14 -1
- {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/_evaluator.py +98 -12
- {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/_runtime.py +5 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/_serde.py +22 -2
- {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/_state.py +2 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/_store.py +28 -1
- {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/engine.py +18 -4
- {query_cascade-0.3.2 → query_cascade-0.3.4}/src/query_cascade.egg-info/PKG-INFO +17 -1
- {query_cascade-0.3.2 → query_cascade-0.3.4}/src/query_cascade.egg-info/SOURCES.txt +7 -1
- {query_cascade-0.3.2 → 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_fixed_point.py +38 -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.2 → query_cascade-0.3.4}/setup.cfg +0 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/__init__.py +0 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/_disk_cache.py +0 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/_errors.py +0 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/_graph_export.py +0 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/_persistence.py +0 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/_scheduler.py +0 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/_synthetic_graph.py +0 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/src/query_cascade.egg-info/dependency_links.txt +0 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/src/query_cascade.egg-info/requires.txt +0 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/src/query_cascade.egg-info/top_level.txt +0 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_dependency_parallelism.py +0 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_engine.py +0 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_engine_compute_many.py +0 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_engine_concurrency.py +0 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_engine_persistence_and_tracing.py +0 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_engine_private.py +0 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_engine_private2.py +0 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_evaluator_and_synthetic_coverage.py +0 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_examples.py +0 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_internal_invariants.py +0 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_performance.py +0 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_repro_touch_memo_keyerror.py +0 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_scale_behavior.py +0 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_serde.py +0 -0
- {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_stateful_engine_invariants.py +0 -0
- {query_cascade-0.3.2 → 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}")
|
|
@@ -7,7 +7,7 @@ from typing import Any, Callable, Mapping, Sequence
|
|
|
7
7
|
from . import _canonical, _disk_cache
|
|
8
8
|
from ._disk_cache import DiskCache
|
|
9
9
|
from ._errors import CycleError, PersistentCacheError, QueryCancelled
|
|
10
|
-
from ._runtime import RuntimeFrame, RuntimeState
|
|
10
|
+
from ._runtime import UNSET, RuntimeFrame, RuntimeState
|
|
11
11
|
from ._state import InputVersion, MemoEntry, QueryKey, Snapshot
|
|
12
12
|
from ._store import GraphStore
|
|
13
13
|
|
|
@@ -146,15 +146,31 @@ class Evaluator:
|
|
|
146
146
|
)
|
|
147
147
|
|
|
148
148
|
key: QueryKey = ("query", query_id, args)
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
149
|
+
for i, frame in enumerate(runtime.stack):
|
|
150
|
+
if frame.key == key:
|
|
151
|
+
if frame.cycle_guess is UNSET:
|
|
152
|
+
has_fp, fp_val = self._store.lookup_query_fixed_point(query_id)
|
|
153
|
+
if not has_fp:
|
|
154
|
+
cycle_start = [f.key for f in runtime.stack[i:]] + [key]
|
|
155
|
+
cycle = " -> ".join(self._store.key_to_str(k) for k in cycle_start)
|
|
156
|
+
raise CycleError(f"cycle detected: {cycle}")
|
|
157
|
+
frame.cycle_guess = fp_val
|
|
158
|
+
|
|
159
|
+
frame.is_cycle_root = True
|
|
160
|
+
for f in runtime.stack[i:]:
|
|
161
|
+
frame.cycle_nodes.add(f.key)
|
|
162
|
+
|
|
163
|
+
# Record the back-edge dependency so the calling node is properly invalidated later
|
|
164
|
+
if len(runtime.stack) > 0:
|
|
165
|
+
runtime.stack[-1].deps[key] = runtime.snapshot.revision
|
|
166
|
+
return frame.cycle_guess
|
|
153
167
|
self.check_cancelled(runtime.cancel_epoch)
|
|
154
168
|
entry, replay_needed = self.compute_or_get_memo(key, fn, runtime)
|
|
155
169
|
self.record_dependency(key, entry.changed_at)
|
|
156
170
|
if replay_needed:
|
|
157
171
|
self.replay_effects(entry.effects)
|
|
172
|
+
if entry.error is not None:
|
|
173
|
+
raise entry.error
|
|
158
174
|
return entry.value
|
|
159
175
|
|
|
160
176
|
def compute_or_get_memo(
|
|
@@ -235,6 +251,11 @@ class Evaluator:
|
|
|
235
251
|
return memo.changed_at
|
|
236
252
|
|
|
237
253
|
runtime = self._runtime_var.get()
|
|
254
|
+
if runtime is not None:
|
|
255
|
+
for frame in runtime.stack:
|
|
256
|
+
if frame.key == key:
|
|
257
|
+
return snapshot.revision
|
|
258
|
+
|
|
238
259
|
if runtime is None:
|
|
239
260
|
self._run_in_runtime(
|
|
240
261
|
RuntimeState(
|
|
@@ -280,17 +301,68 @@ class Evaluator:
|
|
|
280
301
|
runtime.stack.append(frame)
|
|
281
302
|
start = self._store.monotonic_seconds()
|
|
282
303
|
self._store.trace_event("recompute_start", key)
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
304
|
+
|
|
305
|
+
with self._store.lock:
|
|
306
|
+
old_memo = self._store.memos.get(key)
|
|
307
|
+
if old_memo is not None and old_memo.cycle_nodes:
|
|
308
|
+
for k in old_memo.cycle_nodes:
|
|
309
|
+
if k != key:
|
|
310
|
+
self._store.drop_memo_locked(k)
|
|
311
|
+
|
|
312
|
+
error: BaseException | None = None
|
|
313
|
+
while True:
|
|
314
|
+
try:
|
|
315
|
+
self.check_cancelled(runtime.cancel_epoch)
|
|
316
|
+
result = fn(*key[2])
|
|
317
|
+
self.check_cancelled(runtime.cancel_epoch)
|
|
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
|
|
335
|
+
|
|
336
|
+
if frame.is_cycle_root:
|
|
337
|
+
if error is None and self._store.stable_hash(result) != self._store.stable_hash(frame.cycle_guess):
|
|
338
|
+
frame.cycle_guess = result
|
|
339
|
+
frame.is_cycle_root = False
|
|
340
|
+
with self._store.lock:
|
|
341
|
+
self._store.revision += 1
|
|
342
|
+
runtime.snapshot = Snapshot(revision=self._store.revision)
|
|
343
|
+
for k in frame.cycle_nodes:
|
|
344
|
+
if k != key:
|
|
345
|
+
self._store.drop_memo_locked(k)
|
|
346
|
+
frame.deps.clear()
|
|
347
|
+
frame.effects.clear()
|
|
348
|
+
continue
|
|
349
|
+
|
|
288
350
|
runtime.stack.pop()
|
|
351
|
+
break
|
|
352
|
+
|
|
289
353
|
elapsed = self._store.monotonic_seconds() - start
|
|
290
354
|
duration_ms = elapsed * 1000.0
|
|
291
355
|
|
|
292
356
|
frozen_effects = {name: tuple(items) for name, items in frame.effects.items()}
|
|
293
|
-
|
|
357
|
+
if error is not None:
|
|
358
|
+
value_hash = self._store.stable_hash(error)
|
|
359
|
+
else:
|
|
360
|
+
value_hash = self._store.stable_hash(result)
|
|
361
|
+
|
|
362
|
+
if frame.cycle_nodes:
|
|
363
|
+
for k in frame.cycle_nodes:
|
|
364
|
+
frame.deps.pop(k, None)
|
|
365
|
+
|
|
294
366
|
with self._store.lock:
|
|
295
367
|
previous = self._store.memos.get(key)
|
|
296
368
|
if previous is not None and previous.value_hash == value_hash:
|
|
@@ -307,6 +379,8 @@ class Evaluator:
|
|
|
307
379
|
deps=frame.deps,
|
|
308
380
|
effects=frozen_effects,
|
|
309
381
|
last_access=self._store.next_access_id,
|
|
382
|
+
cycle_nodes=tuple(frame.cycle_nodes),
|
|
383
|
+
error=error,
|
|
310
384
|
)
|
|
311
385
|
self._store.drop_memo_locked(key)
|
|
312
386
|
self._store.memos[key] = memo
|
|
@@ -358,9 +432,16 @@ class Evaluator:
|
|
|
358
432
|
value = _canonical.decode(blob)
|
|
359
433
|
effects_raw = record.get("effects", {})
|
|
360
434
|
effects = {str(name): tuple(items) for name, items in effects_raw.items()}
|
|
435
|
+
is_error = record.get("is_error", False)
|
|
361
436
|
except Exception:
|
|
362
437
|
self._store.trace_event("disk_red", key, detail="record decode failed")
|
|
363
438
|
return None
|
|
439
|
+
|
|
440
|
+
error = None
|
|
441
|
+
if is_error:
|
|
442
|
+
error = value
|
|
443
|
+
value = None
|
|
444
|
+
|
|
364
445
|
with self._store.lock:
|
|
365
446
|
self._store.next_access_id += 1
|
|
366
447
|
memo = self._store.entry_from_runtime(
|
|
@@ -371,6 +452,7 @@ class Evaluator:
|
|
|
371
452
|
deps=observed,
|
|
372
453
|
effects=effects,
|
|
373
454
|
last_access=self._store.next_access_id,
|
|
455
|
+
error=error,
|
|
374
456
|
)
|
|
375
457
|
self._store.drop_memo_locked(key)
|
|
376
458
|
self._store.memos[key] = memo
|
|
@@ -494,7 +576,10 @@ class Evaluator:
|
|
|
494
576
|
if deps_records is None:
|
|
495
577
|
return
|
|
496
578
|
try:
|
|
497
|
-
|
|
579
|
+
if memo.error is not None:
|
|
580
|
+
value_blob = _canonical.encode(memo.error)
|
|
581
|
+
else:
|
|
582
|
+
value_blob = _canonical.encode(memo.value)
|
|
498
583
|
except TypeError as exc:
|
|
499
584
|
raise PersistentCacheError(
|
|
500
585
|
f"query {self._store.key_to_str(key)!r} returned a value the persistent cache "
|
|
@@ -513,6 +598,7 @@ class Evaluator:
|
|
|
513
598
|
"value_hash": memo.value_hash,
|
|
514
599
|
"deps": deps_records,
|
|
515
600
|
"effects": effects_node,
|
|
601
|
+
"is_error": memo.error is not None,
|
|
516
602
|
}
|
|
517
603
|
disk.store_entry(_disk_cache.entry_key(kind, fid, args_blob), record, memo.value_hash, value_blob)
|
|
518
604
|
except PersistentCacheError:
|
|
@@ -7,11 +7,16 @@ from typing import Any
|
|
|
7
7
|
from ._state import QueryKey, Snapshot
|
|
8
8
|
|
|
9
9
|
|
|
10
|
+
UNSET = object()
|
|
11
|
+
|
|
10
12
|
@dataclass
|
|
11
13
|
class RuntimeFrame:
|
|
12
14
|
key: QueryKey
|
|
13
15
|
deps: dict[QueryKey, int] = field(default_factory=dict)
|
|
14
16
|
effects: dict[str, list[Any]] = field(default_factory=lambda: defaultdict(list))
|
|
17
|
+
cycle_guess: Any = UNSET
|
|
18
|
+
is_cycle_root: bool = False
|
|
19
|
+
cycle_nodes: set[QueryKey] = field(default_factory=set)
|
|
15
20
|
|
|
16
21
|
|
|
17
22
|
@dataclass
|
|
@@ -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}")
|
|
@@ -44,6 +44,8 @@ class GraphStore:
|
|
|
44
44
|
|
|
45
45
|
self.inputs: dict[InputKey, list[InputVersion]] = {}
|
|
46
46
|
self.queries: dict[str, Callable[..., Any]] = {}
|
|
47
|
+
self.query_fixed_points: dict[str, Any] = {}
|
|
48
|
+
self.query_cache_exceptions: dict[str, bool | tuple[type[BaseException], ...]] = {}
|
|
47
49
|
self.input_fns: dict[str, Callable[..., Any]] = {}
|
|
48
50
|
self.memos: dict[QueryKey, MemoEntry] = {}
|
|
49
51
|
self.dependents: dict[QueryKey, set[QueryKey]] = defaultdict(set)
|
|
@@ -101,14 +103,28 @@ class GraphStore:
|
|
|
101
103
|
sk = self.key_to_str(key)
|
|
102
104
|
self._stats_by_key[sk] = self._stats_by_key.get(sk, 0.0) + seconds
|
|
103
105
|
|
|
104
|
-
def register_query(self, query_id: str, fn: Callable[..., Any]) -> 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:
|
|
105
107
|
with self.lock:
|
|
106
108
|
self.queries[query_id] = fn
|
|
109
|
+
self.query_cache_exceptions[query_id] = cache_exceptions
|
|
110
|
+
if has_fixed_point:
|
|
111
|
+
self.query_fixed_points[query_id] = fixed_point
|
|
107
112
|
|
|
108
113
|
def lookup_query(self, query_id: str) -> Callable[..., Any]:
|
|
109
114
|
with self.lock:
|
|
110
115
|
return self.queries[query_id]
|
|
111
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
|
+
|
|
121
|
+
def lookup_query_fixed_point(self, query_id: str) -> tuple[bool, Any]:
|
|
122
|
+
"""Returns (has_fixed_point, fixed_point_value)."""
|
|
123
|
+
with self.lock:
|
|
124
|
+
if query_id in self.query_fixed_points:
|
|
125
|
+
return True, self.query_fixed_points[query_id]
|
|
126
|
+
return False, None
|
|
127
|
+
|
|
112
128
|
def register_input(self, input_id: str, fn: Callable[..., Any]) -> None:
|
|
113
129
|
with self.lock:
|
|
114
130
|
self.input_fns[input_id] = fn
|
|
@@ -228,6 +244,13 @@ class GraphStore:
|
|
|
228
244
|
self._stats_evictions_recent.append(self.key_to_str(victim))
|
|
229
245
|
self.drop_memo_locked(victim)
|
|
230
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
|
+
|
|
231
254
|
def latest_input_version(self, input_key: InputKey) -> InputVersion | None:
|
|
232
255
|
versions = self.inputs.get(input_key)
|
|
233
256
|
if not versions:
|
|
@@ -419,6 +442,8 @@ class GraphStore:
|
|
|
419
442
|
deps: dict[QueryKey, int],
|
|
420
443
|
effects: dict[str, tuple[Any, ...]],
|
|
421
444
|
last_access: int,
|
|
445
|
+
cycle_nodes: tuple[QueryKey, ...] = (),
|
|
446
|
+
error: BaseException | None = None,
|
|
422
447
|
) -> MemoEntry:
|
|
423
448
|
return MemoEntry(
|
|
424
449
|
value=value,
|
|
@@ -428,4 +453,6 @@ class GraphStore:
|
|
|
428
453
|
deps=tuple(Dependency(dep_key, observed) for dep_key, observed in deps.items()),
|
|
429
454
|
effects=effects,
|
|
430
455
|
last_access=last_access,
|
|
456
|
+
cycle_nodes=cycle_nodes,
|
|
457
|
+
error=error,
|
|
431
458
|
)
|
|
@@ -218,10 +218,15 @@ class Engine:
|
|
|
218
218
|
self._store.register_input(handle.id, fn)
|
|
219
219
|
return handle
|
|
220
220
|
|
|
221
|
-
def query(self, fn: Callable[..., Any]) ->
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
221
|
+
def query(self, fn: Callable[..., Any] | None = None, *, fixed_point: Any = _UNSET, cache_exceptions: bool | tuple[type[BaseException], ...] = True) -> Any:
|
|
222
|
+
def decorator(f: Callable[..., Any]) -> _QueryHandle:
|
|
223
|
+
handle = _QueryHandle(self, f)
|
|
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, cache_exceptions=cache_exceptions)
|
|
226
|
+
return handle
|
|
227
|
+
if fn is not None:
|
|
228
|
+
return decorator(fn)
|
|
229
|
+
return decorator
|
|
225
230
|
|
|
226
231
|
def accumulator(self, name: str) -> Accumulator:
|
|
227
232
|
return Accumulator(self, name=name)
|
|
@@ -418,6 +423,15 @@ class Engine:
|
|
|
418
423
|
def reset_stats(self) -> None:
|
|
419
424
|
self._store.reset_stats()
|
|
420
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
|
+
|
|
421
435
|
def prune(self, roots: Iterable[tuple[str, str, tuple[Any, ...]]], *, vacuum_disk: bool = False) -> None:
|
|
422
436
|
root_list = list(roots)
|
|
423
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,12 +27,18 @@ 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
|
|
35
|
+
tests/test_fixed_point.py
|
|
32
36
|
tests/test_internal_invariants.py
|
|
33
37
|
tests/test_performance.py
|
|
34
38
|
tests/test_repro_touch_memo_keyerror.py
|
|
35
39
|
tests/test_scale_behavior.py
|
|
36
40
|
tests/test_serde.py
|
|
37
41
|
tests/test_stateful_engine_invariants.py
|
|
38
|
-
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,38 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from cascade.engine import Engine, CycleError
|
|
3
|
+
|
|
4
|
+
def test_fixed_point_basic():
|
|
5
|
+
engine = Engine()
|
|
6
|
+
|
|
7
|
+
@engine.input
|
|
8
|
+
def input_val():
|
|
9
|
+
return 1
|
|
10
|
+
|
|
11
|
+
@engine.query(fixed_point=0)
|
|
12
|
+
def cell_a():
|
|
13
|
+
return cell_b() + input_val()
|
|
14
|
+
|
|
15
|
+
@engine.query
|
|
16
|
+
def cell_b():
|
|
17
|
+
return cell_a() // 2
|
|
18
|
+
|
|
19
|
+
assert cell_a() == 1
|
|
20
|
+
assert cell_b() == 0
|
|
21
|
+
|
|
22
|
+
input_val.set(value=4)
|
|
23
|
+
assert cell_a() == 7
|
|
24
|
+
assert cell_b() == 3
|
|
25
|
+
|
|
26
|
+
def test_fixed_point_no_default_raises():
|
|
27
|
+
engine = Engine()
|
|
28
|
+
|
|
29
|
+
@engine.query
|
|
30
|
+
def cell_a():
|
|
31
|
+
return cell_b()
|
|
32
|
+
|
|
33
|
+
@engine.query
|
|
34
|
+
def cell_b():
|
|
35
|
+
return cell_a()
|
|
36
|
+
|
|
37
|
+
with pytest.raises(CycleError):
|
|
38
|
+
cell_a()
|
|
@@ -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
|