query-cascade 0.2.1__tar.gz → 0.2.2__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 (35) hide show
  1. {query_cascade-0.2.1 → query_cascade-0.2.2}/PKG-INFO +2 -2
  2. {query_cascade-0.2.1 → query_cascade-0.2.2}/README.md +1 -1
  3. {query_cascade-0.2.1 → query_cascade-0.2.2}/pyproject.toml +1 -1
  4. {query_cascade-0.2.1 → query_cascade-0.2.2}/src/cascade/_evaluator.py +7 -1
  5. {query_cascade-0.2.1 → query_cascade-0.2.2}/src/cascade/_store.py +31 -1
  6. {query_cascade-0.2.1 → query_cascade-0.2.2}/src/query_cascade.egg-info/PKG-INFO +2 -2
  7. {query_cascade-0.2.1 → query_cascade-0.2.2}/src/query_cascade.egg-info/SOURCES.txt +1 -0
  8. query_cascade-0.2.2/tests/test_repro_touch_memo_keyerror.py +70 -0
  9. {query_cascade-0.2.1 → query_cascade-0.2.2}/setup.cfg +0 -0
  10. {query_cascade-0.2.1 → query_cascade-0.2.2}/src/cascade/__init__.py +0 -0
  11. {query_cascade-0.2.1 → query_cascade-0.2.2}/src/cascade/_errors.py +0 -0
  12. {query_cascade-0.2.1 → query_cascade-0.2.2}/src/cascade/_graph_export.py +0 -0
  13. {query_cascade-0.2.1 → query_cascade-0.2.2}/src/cascade/_persistence.py +0 -0
  14. {query_cascade-0.2.1 → query_cascade-0.2.2}/src/cascade/_runtime.py +0 -0
  15. {query_cascade-0.2.1 → query_cascade-0.2.2}/src/cascade/_scheduler.py +0 -0
  16. {query_cascade-0.2.1 → query_cascade-0.2.2}/src/cascade/_serde.py +0 -0
  17. {query_cascade-0.2.1 → query_cascade-0.2.2}/src/cascade/_state.py +0 -0
  18. {query_cascade-0.2.1 → query_cascade-0.2.2}/src/cascade/_synthetic_graph.py +0 -0
  19. {query_cascade-0.2.1 → query_cascade-0.2.2}/src/cascade/engine.py +0 -0
  20. {query_cascade-0.2.1 → query_cascade-0.2.2}/src/query_cascade.egg-info/dependency_links.txt +0 -0
  21. {query_cascade-0.2.1 → query_cascade-0.2.2}/src/query_cascade.egg-info/requires.txt +0 -0
  22. {query_cascade-0.2.1 → query_cascade-0.2.2}/src/query_cascade.egg-info/top_level.txt +0 -0
  23. {query_cascade-0.2.1 → query_cascade-0.2.2}/tests/test_dependency_parallelism.py +0 -0
  24. {query_cascade-0.2.1 → query_cascade-0.2.2}/tests/test_engine.py +0 -0
  25. {query_cascade-0.2.1 → query_cascade-0.2.2}/tests/test_engine_compute_many.py +0 -0
  26. {query_cascade-0.2.1 → query_cascade-0.2.2}/tests/test_engine_concurrency.py +0 -0
  27. {query_cascade-0.2.1 → query_cascade-0.2.2}/tests/test_engine_persistence_and_tracing.py +0 -0
  28. {query_cascade-0.2.1 → query_cascade-0.2.2}/tests/test_evaluator_and_synthetic_coverage.py +0 -0
  29. {query_cascade-0.2.1 → query_cascade-0.2.2}/tests/test_examples.py +0 -0
  30. {query_cascade-0.2.1 → query_cascade-0.2.2}/tests/test_internal_invariants.py +0 -0
  31. {query_cascade-0.2.1 → query_cascade-0.2.2}/tests/test_performance.py +0 -0
  32. {query_cascade-0.2.1 → query_cascade-0.2.2}/tests/test_scale_behavior.py +0 -0
  33. {query_cascade-0.2.1 → query_cascade-0.2.2}/tests/test_serde.py +0 -0
  34. {query_cascade-0.2.1 → query_cascade-0.2.2}/tests/test_stateful_engine_invariants.py +0 -0
  35. {query_cascade-0.2.1 → query_cascade-0.2.2}/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.2.1
3
+ Version: 0.2.2
4
4
  Summary: Minimal demand-driven query framework for incremental computation.
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
@@ -321,7 +321,7 @@ print(deps_only["memo_count"], deps_only["edges"])
321
321
 
322
322
  ### Query stats (timing and eviction)
323
323
 
324
- **`by_key`** totals **wall time** in seconds for **successful** recomputes only (cache hits are not timed). **`stats_clock`** is optional for deterministic tests. Counters are **in-memory only** (**`save`** / **`load`** do not persist them).
324
+ **`by_key`** totals **wall time** in seconds for **successful** recomputes only (cache hits are not timed). Each value is **inclusive** for that callable: if the body calls other queries, their execution time is included in the parent’s row as well as their own (do not sum **`by_key`** across the graph to get “total work”). **`stats_clock`** is optional for deterministic tests. Counters are **in-memory only** (**`save`** / **`load`** do not persist them).
325
325
 
326
326
  ```python
327
327
  from cascade import Engine
@@ -308,7 +308,7 @@ print(deps_only["memo_count"], deps_only["edges"])
308
308
 
309
309
  ### Query stats (timing and eviction)
310
310
 
311
- **`by_key`** totals **wall time** in seconds for **successful** recomputes only (cache hits are not timed). **`stats_clock`** is optional for deterministic tests. Counters are **in-memory only** (**`save`** / **`load`** do not persist them).
311
+ **`by_key`** totals **wall time** in seconds for **successful** recomputes only (cache hits are not timed). Each value is **inclusive** for that callable: if the body calls other queries, their execution time is included in the parent’s row as well as their own (do not sum **`by_key`** across the graph to get “total work”). **`stats_clock`** is optional for deterministic tests. Counters are **in-memory only** (**`save`** / **`load`** do not persist them).
312
312
 
313
313
  ```python
314
314
  from cascade import Engine
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "query-cascade"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "Minimal demand-driven query framework for incremental computation."
9
9
  requires-python = ">=3.12"
10
10
  readme = "README.md"
@@ -162,7 +162,13 @@ class Evaluator:
162
162
  self._store.trace_event("cache_hit", key)
163
163
  return existing, True
164
164
 
165
- if self.try_mark_green(key, existing, runtime.snapshot):
165
+ self._store.pin_memo_verification_locked(key)
166
+ try:
167
+ green = self.try_mark_green(key, existing, runtime.snapshot)
168
+ finally:
169
+ self._store.unpin_memo_verification_locked(key)
170
+
171
+ if green:
166
172
  existing.verified_at = runtime.snapshot.revision
167
173
  self._store.touch_memo_locked(key)
168
174
  self._store.trace_event("cache_green", key)
@@ -48,6 +48,9 @@ class GraphStore:
48
48
  self.in_flight: dict[tuple[QueryKey, int], concurrent.futures.Future[MemoEntry]] = {}
49
49
  # Lazy min-heap (last_access, key); stale entries removed on pop (touches push new tuples).
50
50
  self._lru_heap: list[tuple[int, QueryKey]] = []
51
+ # Refcount: memo rows participating in an in-flight cache/verify sequence must not
52
+ # be LRU-evicted (nested recompute can otherwise drop the parent before verify ends).
53
+ self._memo_verify_pin_count: dict[QueryKey, int] = {}
51
54
 
52
55
  def monotonic_seconds(self) -> float:
53
56
  return self._monotonic_seconds()
@@ -142,6 +145,16 @@ class GraphStore:
142
145
  memo.last_access = self.next_access_id
143
146
  heapq.heappush(self._lru_heap, (memo.last_access, key))
144
147
 
148
+ def pin_memo_verification_locked(self, key: QueryKey) -> None:
149
+ self._memo_verify_pin_count[key] = self._memo_verify_pin_count.get(key, 0) + 1
150
+
151
+ def unpin_memo_verification_locked(self, key: QueryKey) -> None:
152
+ n = self._memo_verify_pin_count.get(key, 0)
153
+ if n <= 1:
154
+ self._memo_verify_pin_count.pop(key, None)
155
+ else:
156
+ self._memo_verify_pin_count[key] = n - 1
157
+
145
158
  def push_memo_lru_locked(self, key: QueryKey) -> None:
146
159
  memo = self.memos.get(key)
147
160
  if memo is None:
@@ -149,6 +162,7 @@ class GraphStore:
149
162
  heapq.heappush(self._lru_heap, (memo.last_access, key))
150
163
 
151
164
  def drop_memo_locked(self, key: QueryKey) -> None:
165
+ self._memo_verify_pin_count.pop(key, None)
152
166
  memo = self.memos.pop(key, None)
153
167
  if memo is None:
154
168
  return
@@ -170,9 +184,22 @@ class GraphStore:
170
184
  memo = self.memos.get(key)
171
185
  if memo is None or memo.last_access != last_acc:
172
186
  continue
187
+ if self._memo_verify_pin_count.get(key, 0) > 0:
188
+ continue
173
189
  return key
174
190
  return None
175
191
 
192
+ def _unpinned_lru_fallback_key_locked(self) -> QueryKey | None:
193
+ best_k: QueryKey | None = None
194
+ best_acc: int | None = None
195
+ for k, memo in self.memos.items():
196
+ if self._memo_verify_pin_count.get(k, 0) > 0:
197
+ continue
198
+ if best_acc is None or memo.last_access < best_acc:
199
+ best_acc = memo.last_access
200
+ best_k = k
201
+ return best_k
202
+
176
203
  def evict_if_needed_locked(self) -> None:
177
204
  while len(self.memos) > self.max_entries:
178
205
  victim = self._pop_lru_victim_key_locked()
@@ -180,7 +207,9 @@ class GraphStore:
180
207
  self._rebuild_lru_heap_locked()
181
208
  victim = self._pop_lru_victim_key_locked()
182
209
  if victim is None:
183
- victim = min(self.memos.items(), key=lambda it: it[1].last_access)[0]
210
+ victim = self._unpinned_lru_fallback_key_locked()
211
+ if victim is None:
212
+ break
184
213
  self.trace_event("evict", victim, "lru")
185
214
  if self._stats_enabled:
186
215
  self._stats_evictions_total += 1
@@ -354,6 +383,7 @@ class GraphStore:
354
383
  # In-flight dedup futures are process-local/transient and should never
355
384
  # survive a load boundary.
356
385
  self.in_flight.clear()
386
+ self._memo_verify_pin_count.clear()
357
387
  self._rebuild_lru_heap_locked()
358
388
 
359
389
  def make_persistence_payload(self) -> dict[str, Any]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: query-cascade
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Minimal demand-driven query framework for incremental computation.
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
@@ -321,7 +321,7 @@ print(deps_only["memo_count"], deps_only["edges"])
321
321
 
322
322
  ### Query stats (timing and eviction)
323
323
 
324
- **`by_key`** totals **wall time** in seconds for **successful** recomputes only (cache hits are not timed). **`stats_clock`** is optional for deterministic tests. Counters are **in-memory only** (**`save`** / **`load`** do not persist them).
324
+ **`by_key`** totals **wall time** in seconds for **successful** recomputes only (cache hits are not timed). Each value is **inclusive** for that callable: if the body calls other queries, their execution time is included in the parent’s row as well as their own (do not sum **`by_key`** across the graph to get “total work”). **`stats_clock`** is optional for deterministic tests. Counters are **in-memory only** (**`save`** / **`load`** do not persist them).
325
325
 
326
326
  ```python
327
327
  from cascade import Engine
@@ -26,6 +26,7 @@ tests/test_evaluator_and_synthetic_coverage.py
26
26
  tests/test_examples.py
27
27
  tests/test_internal_invariants.py
28
28
  tests/test_performance.py
29
+ tests/test_repro_touch_memo_keyerror.py
29
30
  tests/test_scale_behavior.py
30
31
  tests/test_serde.py
31
32
  tests/test_stateful_engine_invariants.py
@@ -0,0 +1,70 @@
1
+ """Regression: nested ``try_mark_green`` must not LRU-evict memos mid-verify.
2
+
3
+ Previously, ``compute_or_get_memo`` could touch a parent memo, call
4
+ ``try_mark_green`` (which recursively recomputed deps), and nested
5
+ ``evict_if_needed_locked`` could evict that parent before the post-verify
6
+ ``touch_memo_locked`` → ``KeyError``. Query memos under verification are now
7
+ refcount-pinned in :class:`~cascade._store.GraphStore` so LRU skips them until
8
+ ``unpin_memo_verification_locked``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from cascade import Engine
14
+
15
+
16
+ def _run_nested_verify_under_pressure(*, max_entries: int = 6) -> None:
17
+ engine = Engine(max_entries=max_entries, trace_limit=2_000)
18
+ inp = engine.input(lambda: 0)
19
+
20
+ @engine.query
21
+ def aux(i: int) -> int:
22
+ return i + 1
23
+
24
+ @engine.query
25
+ def leaf(i: int) -> int:
26
+ return aux(i)
27
+
28
+ @engine.query
29
+ def root() -> int:
30
+ return sum(leaf(j) for j in range(3))
31
+
32
+ assert root() == 6
33
+ inp.set(1)
34
+ assert root() == 6
35
+
36
+
37
+ def test_nested_verify_with_small_memo_table_no_keyerror() -> None:
38
+ """Seven query keys, ``max_entries == 6``: first run evicts; second run verifies."""
39
+ _run_nested_verify_under_pressure(max_entries=6)
40
+
41
+
42
+ def test_same_graph_succeeds_with_smaller_cache() -> None:
43
+ _run_nested_verify_under_pressure(max_entries=5)
44
+
45
+
46
+ def test_many_fillers_after_revision_before_root_still_computes(max_entries: int = 12) -> None:
47
+ engine = Engine(max_entries=max_entries, trace_limit=10_000)
48
+ inp = engine.input(lambda: 0)
49
+
50
+ @engine.query
51
+ def filler(n: int) -> int:
52
+ return n
53
+
54
+ @engine.query
55
+ def aux(i: int) -> int:
56
+ return i + 1
57
+
58
+ @engine.query
59
+ def leaf(i: int) -> int:
60
+ return aux(i)
61
+
62
+ @engine.query
63
+ def root() -> int:
64
+ return sum(leaf(j) for j in range(3))
65
+
66
+ assert root() == 6
67
+ for n in range(400):
68
+ filler(n)
69
+ inp.set(1)
70
+ assert root() == 6
File without changes