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.
Files changed (46) hide show
  1. {query_cascade-0.3.3 → query_cascade-0.3.4}/PKG-INFO +17 -1
  2. {query_cascade-0.3.3 → query_cascade-0.3.4}/README.md +16 -0
  3. {query_cascade-0.3.3 → query_cascade-0.3.4}/pyproject.toml +1 -1
  4. {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/_canonical.py +14 -1
  5. {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/_evaluator.py +39 -6
  6. {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/_serde.py +22 -2
  7. {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/_state.py +1 -0
  8. {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/_store.py +16 -1
  9. {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/engine.py +11 -2
  10. {query_cascade-0.3.3 → query_cascade-0.3.4}/src/query_cascade.egg-info/PKG-INFO +17 -1
  11. {query_cascade-0.3.3 → query_cascade-0.3.4}/src/query_cascade.egg-info/SOURCES.txt +6 -1
  12. {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_disk_cache.py +3 -3
  13. query_cascade-0.3.4/tests/test_engine_sweep.py +78 -0
  14. query_cascade-0.3.4/tests/test_error_caching.py +125 -0
  15. query_cascade-0.3.4/tests/test_error_caching_persistence.py +39 -0
  16. query_cascade-0.3.4/tests/test_store_coverage.py +44 -0
  17. query_cascade-0.3.4/tests/test_sweep_unaccessed.py +33 -0
  18. {query_cascade-0.3.3 → query_cascade-0.3.4}/setup.cfg +0 -0
  19. {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/__init__.py +0 -0
  20. {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/_disk_cache.py +0 -0
  21. {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/_errors.py +0 -0
  22. {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/_graph_export.py +0 -0
  23. {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/_persistence.py +0 -0
  24. {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/_runtime.py +0 -0
  25. {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/_scheduler.py +0 -0
  26. {query_cascade-0.3.3 → query_cascade-0.3.4}/src/cascade/_synthetic_graph.py +0 -0
  27. {query_cascade-0.3.3 → query_cascade-0.3.4}/src/query_cascade.egg-info/dependency_links.txt +0 -0
  28. {query_cascade-0.3.3 → query_cascade-0.3.4}/src/query_cascade.egg-info/requires.txt +0 -0
  29. {query_cascade-0.3.3 → query_cascade-0.3.4}/src/query_cascade.egg-info/top_level.txt +0 -0
  30. {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_dependency_parallelism.py +0 -0
  31. {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_engine.py +0 -0
  32. {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_engine_compute_many.py +0 -0
  33. {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_engine_concurrency.py +0 -0
  34. {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_engine_persistence_and_tracing.py +0 -0
  35. {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_engine_private.py +0 -0
  36. {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_engine_private2.py +0 -0
  37. {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_evaluator_and_synthetic_coverage.py +0 -0
  38. {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_examples.py +0 -0
  39. {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_fixed_point.py +0 -0
  40. {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_internal_invariants.py +0 -0
  41. {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_performance.py +0 -0
  42. {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_repro_touch_memo_keyerror.py +0 -0
  43. {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_scale_behavior.py +0 -0
  44. {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_serde.py +0 -0
  45. {query_cascade-0.3.3 → query_cascade-0.3.4}/tests/test_stateful_engine_invariants.py +0 -0
  46. {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
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 |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "query-cascade"
7
- version = "0.3.3"
7
+ version = "0.3.4"
8
8
  description = "Minimal demand-driven query framework for incremental computation."
9
9
  requires-python = ">=3.12"
10
10
  readme = "README.md"
@@ -114,10 +114,16 @@ def _encode_node(obj: Any) -> Any:
114
114
  if isinstance(obj, dict):
115
115
  return _encode_dict(obj)
116
116
 
117
+ if isinstance(obj, BaseException):
118
+ return _ext(
119
+ 8, # _EXT_EXCEPTION
120
+ [type(obj).__module__, type(obj).__qualname__, [_encode_node(x) for x in obj.args]]
121
+ )
122
+
117
123
  raise TypeError(
118
124
  f"cascade persistent cache: cannot serialize {type(obj).__module__}.{type(obj).__qualname__!r}; "
119
125
  "supported types are primitives, bytes, list/tuple/set/frozenset/dict, "
120
- "@dataclass instances, and typing.NamedTuple instances."
126
+ "@dataclass instances, typing.NamedTuple instances, and BaseException."
121
127
  )
122
128
 
123
129
 
@@ -182,4 +188,11 @@ def _decode_ext(ext: Any) -> Any:
182
188
  if not (isinstance(cls, type) and issubclass(cls, tuple) and hasattr(cls, "_make")):
183
189
  raise TypeError(f"{module_name}.{qualname!r} is not a NamedTuple")
184
190
  return cls(*vals)
191
+ if ext.code == 8: # _EXT_EXCEPTION
192
+ module_name, qualname, values = payload
193
+ vals = [_decode_node(x) for x in values]
194
+ cls = _resolve_type(module_name, qualname)
195
+ if not (isinstance(cls, type) and issubclass(cls, BaseException)):
196
+ raise TypeError(f"{module_name}.{qualname!r} is not an Exception")
197
+ return cls(*vals)
185
198
  raise ValueError(f"cascade persistent cache: unknown extension code {ext.code}")
@@ -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
- except BaseException:
316
- runtime.stack.pop()
317
- raise
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
- value_hash = self._store.stable_hash(result)
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
- value_blob = _canonical.encode(memo.value)
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, or "
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}")
@@ -32,6 +32,7 @@ class MemoEntry:
32
32
  effects: dict[str, tuple[Any, ...]]
33
33
  last_access: int
34
34
  cycle_nodes: tuple[QueryKey, ...] = ()
35
+ error: BaseException | None = None
35
36
 
36
37
 
37
38
  @dataclass(frozen=True)
@@ -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
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/test_subgraph_stats_export.py
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 test_errors_are_not_cached_across_sessions(tmp_path: Path) -> None:
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, must re-run because errors aren't cached
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 == {"process": 1}
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