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.
Files changed (46) hide show
  1. {query_cascade-0.3.2 → query_cascade-0.3.4}/PKG-INFO +17 -1
  2. {query_cascade-0.3.2 → query_cascade-0.3.4}/README.md +16 -0
  3. {query_cascade-0.3.2 → query_cascade-0.3.4}/pyproject.toml +1 -1
  4. {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/_canonical.py +14 -1
  5. {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/_evaluator.py +98 -12
  6. {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/_runtime.py +5 -0
  7. {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/_serde.py +22 -2
  8. {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/_state.py +2 -0
  9. {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/_store.py +28 -1
  10. {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/engine.py +18 -4
  11. {query_cascade-0.3.2 → query_cascade-0.3.4}/src/query_cascade.egg-info/PKG-INFO +17 -1
  12. {query_cascade-0.3.2 → query_cascade-0.3.4}/src/query_cascade.egg-info/SOURCES.txt +7 -1
  13. {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_disk_cache.py +3 -3
  14. query_cascade-0.3.4/tests/test_engine_sweep.py +78 -0
  15. query_cascade-0.3.4/tests/test_error_caching.py +125 -0
  16. query_cascade-0.3.4/tests/test_error_caching_persistence.py +39 -0
  17. query_cascade-0.3.4/tests/test_fixed_point.py +38 -0
  18. query_cascade-0.3.4/tests/test_store_coverage.py +44 -0
  19. query_cascade-0.3.4/tests/test_sweep_unaccessed.py +33 -0
  20. {query_cascade-0.3.2 → query_cascade-0.3.4}/setup.cfg +0 -0
  21. {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/__init__.py +0 -0
  22. {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/_disk_cache.py +0 -0
  23. {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/_errors.py +0 -0
  24. {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/_graph_export.py +0 -0
  25. {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/_persistence.py +0 -0
  26. {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/_scheduler.py +0 -0
  27. {query_cascade-0.3.2 → query_cascade-0.3.4}/src/cascade/_synthetic_graph.py +0 -0
  28. {query_cascade-0.3.2 → query_cascade-0.3.4}/src/query_cascade.egg-info/dependency_links.txt +0 -0
  29. {query_cascade-0.3.2 → query_cascade-0.3.4}/src/query_cascade.egg-info/requires.txt +0 -0
  30. {query_cascade-0.3.2 → query_cascade-0.3.4}/src/query_cascade.egg-info/top_level.txt +0 -0
  31. {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_dependency_parallelism.py +0 -0
  32. {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_engine.py +0 -0
  33. {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_engine_compute_many.py +0 -0
  34. {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_engine_concurrency.py +0 -0
  35. {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_engine_persistence_and_tracing.py +0 -0
  36. {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_engine_private.py +0 -0
  37. {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_engine_private2.py +0 -0
  38. {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_evaluator_and_synthetic_coverage.py +0 -0
  39. {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_examples.py +0 -0
  40. {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_internal_invariants.py +0 -0
  41. {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_performance.py +0 -0
  42. {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_repro_touch_memo_keyerror.py +0 -0
  43. {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_scale_behavior.py +0 -0
  44. {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_serde.py +0 -0
  45. {query_cascade-0.3.2 → query_cascade-0.3.4}/tests/test_stateful_engine_invariants.py +0 -0
  46. {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.2
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.2"
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}")
@@ -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
- if any(frame.key == key for frame in runtime.stack):
150
- cycle_start = [frame.key for frame in runtime.stack] + [key]
151
- cycle = " -> ".join(self._store.key_to_str(k) for k in cycle_start)
152
- raise CycleError(f"cycle detected: {cycle}")
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
- try:
284
- self.check_cancelled(runtime.cancel_epoch)
285
- result = fn(*key[2])
286
- self.check_cancelled(runtime.cancel_epoch)
287
- finally:
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
- 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)
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
- 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)
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, 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}")
@@ -31,6 +31,8 @@ class MemoEntry:
31
31
  deps: tuple[Dependency, ...]
32
32
  effects: dict[str, tuple[Any, ...]]
33
33
  last_access: int
34
+ cycle_nodes: tuple[QueryKey, ...] = ()
35
+ error: BaseException | None = None
34
36
 
35
37
 
36
38
  @dataclass(frozen=True)
@@ -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]) -> _QueryHandle:
222
- handle = _QueryHandle(self, fn)
223
- self._store.register_query(handle.id, fn)
224
- return handle
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.2
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/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,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