query-cascade 0.2.1__tar.gz → 0.2.3__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.2.1 → query_cascade-0.2.3}/PKG-INFO +4 -3
- {query_cascade-0.2.1 → query_cascade-0.2.3}/README.md +3 -2
- {query_cascade-0.2.1 → query_cascade-0.2.3}/pyproject.toml +1 -1
- {query_cascade-0.2.1 → query_cascade-0.2.3}/src/cascade/_evaluator.py +7 -1
- {query_cascade-0.2.1 → query_cascade-0.2.3}/src/cascade/_store.py +31 -1
- {query_cascade-0.2.1 → query_cascade-0.2.3}/src/cascade/engine.py +80 -3
- {query_cascade-0.2.1 → query_cascade-0.2.3}/src/query_cascade.egg-info/PKG-INFO +4 -3
- {query_cascade-0.2.1 → query_cascade-0.2.3}/src/query_cascade.egg-info/SOURCES.txt +1 -0
- {query_cascade-0.2.1 → query_cascade-0.2.3}/tests/test_engine_compute_many.py +73 -0
- query_cascade-0.2.3/tests/test_repro_touch_memo_keyerror.py +70 -0
- {query_cascade-0.2.1 → query_cascade-0.2.3}/setup.cfg +0 -0
- {query_cascade-0.2.1 → query_cascade-0.2.3}/src/cascade/__init__.py +0 -0
- {query_cascade-0.2.1 → query_cascade-0.2.3}/src/cascade/_errors.py +0 -0
- {query_cascade-0.2.1 → query_cascade-0.2.3}/src/cascade/_graph_export.py +0 -0
- {query_cascade-0.2.1 → query_cascade-0.2.3}/src/cascade/_persistence.py +0 -0
- {query_cascade-0.2.1 → query_cascade-0.2.3}/src/cascade/_runtime.py +0 -0
- {query_cascade-0.2.1 → query_cascade-0.2.3}/src/cascade/_scheduler.py +0 -0
- {query_cascade-0.2.1 → query_cascade-0.2.3}/src/cascade/_serde.py +0 -0
- {query_cascade-0.2.1 → query_cascade-0.2.3}/src/cascade/_state.py +0 -0
- {query_cascade-0.2.1 → query_cascade-0.2.3}/src/cascade/_synthetic_graph.py +0 -0
- {query_cascade-0.2.1 → query_cascade-0.2.3}/src/query_cascade.egg-info/dependency_links.txt +0 -0
- {query_cascade-0.2.1 → query_cascade-0.2.3}/src/query_cascade.egg-info/requires.txt +0 -0
- {query_cascade-0.2.1 → query_cascade-0.2.3}/src/query_cascade.egg-info/top_level.txt +0 -0
- {query_cascade-0.2.1 → query_cascade-0.2.3}/tests/test_dependency_parallelism.py +0 -0
- {query_cascade-0.2.1 → query_cascade-0.2.3}/tests/test_engine.py +0 -0
- {query_cascade-0.2.1 → query_cascade-0.2.3}/tests/test_engine_concurrency.py +0 -0
- {query_cascade-0.2.1 → query_cascade-0.2.3}/tests/test_engine_persistence_and_tracing.py +0 -0
- {query_cascade-0.2.1 → query_cascade-0.2.3}/tests/test_evaluator_and_synthetic_coverage.py +0 -0
- {query_cascade-0.2.1 → query_cascade-0.2.3}/tests/test_examples.py +0 -0
- {query_cascade-0.2.1 → query_cascade-0.2.3}/tests/test_internal_invariants.py +0 -0
- {query_cascade-0.2.1 → query_cascade-0.2.3}/tests/test_performance.py +0 -0
- {query_cascade-0.2.1 → query_cascade-0.2.3}/tests/test_scale_behavior.py +0 -0
- {query_cascade-0.2.1 → query_cascade-0.2.3}/tests/test_serde.py +0 -0
- {query_cascade-0.2.1 → query_cascade-0.2.3}/tests/test_stateful_engine_invariants.py +0 -0
- {query_cascade-0.2.1 → query_cascade-0.2.3}/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.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Minimal demand-driven query framework for incremental computation.
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -212,7 +212,7 @@ Properties and methods:
|
|
|
212
212
|
- **`accumulator(name: str) → Accumulator`** — Return a named **`Accumulator`** (see below). Names are keys in the optional **`effects`** map passed to synchronous **`QueryHandle`** calls and **`submit`**.
|
|
213
213
|
- **`snapshot() → Snapshot`** — Capture **`Snapshot(revision=engine.revision)`** for use as **`snapshot=`** on reads. Reads through that snapshot see input and memo state as of that revision; live **`set`** calls do not disturb snapshot reads.
|
|
214
214
|
- **`submit(query, *args, *, snapshot=None, effects=None, executor=None) → concurrent.futures.Future`** — Run **`query(*args)`** on **`executor`**, or on a **lazily created** per-engine **`ThreadPoolExecutor`** when **`executor` is `None`**. The engine captures **`cancel_epoch`** at schedule time; if inputs move on before the task runs, the future may complete with **`QueryCancelled`**. **`snapshot`** defaults to a fresh **`snapshot()`** at submit time when omitted.
|
|
215
|
-
- **`compute_many(calls, *, workers=None, snapshot=None) → list[Any]`** — Run many queries in parallel with a work-stealing scheduler. **`calls`** is a sequence of **`(query_handle, args_tuple)`**. Result order matches **`calls`**. **`workers`** defaults to a sensible value from **`len(calls)`** (capped); **`workers=0`** falls back to that same default. **`snapshot`** defaults to **`snapshot()`** when omitted.
|
|
215
|
+
- **`compute_many(calls, *, workers=None, snapshot=None, effects=None) → list[Any]`** — Run many queries in parallel with a work-stealing scheduler. **`calls`** is a sequence of **`(query_handle, args_tuple)`**. Result order matches **`calls`**. **`workers`** defaults to a sensible value from **`len(calls)`** (capped); **`workers=0`** falls back to that same default. **`snapshot`** defaults to **`snapshot()`** when omitted. When **`effects`** is a dict, accumulator output is collected and appended into **`effects[name]`** deterministically in **call order** (not completion order).
|
|
216
216
|
- **`shutdown(*, wait: bool = True, cancel_futures: bool = False)`** — Shut down the **default** thread pool created by **`submit`**, if any. Call when discarding the engine if you need threads torn down promptly (for example in tests).
|
|
217
217
|
- **`traces() → list[TraceEvent]`** — Copy of recent trace events (subject to **`trace_limit`**).
|
|
218
218
|
- **`clear_traces()`** — Drop all buffered trace events.
|
|
@@ -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
|
|
@@ -452,6 +452,7 @@ Answer Yes/No:
|
|
|
452
452
|
| `dynamic_macro_expansion.py` | Query that **changes downstream dependencies** at runtime |
|
|
453
453
|
| `snapshot_isolation.py` | Snapshot reads while live inputs change |
|
|
454
454
|
| `concurrent_background_work.py` | Dedup under concurrency + cancellation after input changes |
|
|
455
|
+
| `compute_many_with_accumulators.py` | `compute_many(..., effects=...)` accumulator collection |
|
|
455
456
|
| `persistence_and_inspection.py` | Save/load and graph summaries |
|
|
456
457
|
| `gil_parallel_speedup.py` | Threaded CPU benchmark: GIL vs free-threaded |
|
|
457
458
|
|
|
@@ -199,7 +199,7 @@ Properties and methods:
|
|
|
199
199
|
- **`accumulator(name: str) → Accumulator`** — Return a named **`Accumulator`** (see below). Names are keys in the optional **`effects`** map passed to synchronous **`QueryHandle`** calls and **`submit`**.
|
|
200
200
|
- **`snapshot() → Snapshot`** — Capture **`Snapshot(revision=engine.revision)`** for use as **`snapshot=`** on reads. Reads through that snapshot see input and memo state as of that revision; live **`set`** calls do not disturb snapshot reads.
|
|
201
201
|
- **`submit(query, *args, *, snapshot=None, effects=None, executor=None) → concurrent.futures.Future`** — Run **`query(*args)`** on **`executor`**, or on a **lazily created** per-engine **`ThreadPoolExecutor`** when **`executor` is `None`**. The engine captures **`cancel_epoch`** at schedule time; if inputs move on before the task runs, the future may complete with **`QueryCancelled`**. **`snapshot`** defaults to a fresh **`snapshot()`** at submit time when omitted.
|
|
202
|
-
- **`compute_many(calls, *, workers=None, snapshot=None) → list[Any]`** — Run many queries in parallel with a work-stealing scheduler. **`calls`** is a sequence of **`(query_handle, args_tuple)`**. Result order matches **`calls`**. **`workers`** defaults to a sensible value from **`len(calls)`** (capped); **`workers=0`** falls back to that same default. **`snapshot`** defaults to **`snapshot()`** when omitted.
|
|
202
|
+
- **`compute_many(calls, *, workers=None, snapshot=None, effects=None) → list[Any]`** — Run many queries in parallel with a work-stealing scheduler. **`calls`** is a sequence of **`(query_handle, args_tuple)`**. Result order matches **`calls`**. **`workers`** defaults to a sensible value from **`len(calls)`** (capped); **`workers=0`** falls back to that same default. **`snapshot`** defaults to **`snapshot()`** when omitted. When **`effects`** is a dict, accumulator output is collected and appended into **`effects[name]`** deterministically in **call order** (not completion order).
|
|
203
203
|
- **`shutdown(*, wait: bool = True, cancel_futures: bool = False)`** — Shut down the **default** thread pool created by **`submit`**, if any. Call when discarding the engine if you need threads torn down promptly (for example in tests).
|
|
204
204
|
- **`traces() → list[TraceEvent]`** — Copy of recent trace events (subject to **`trace_limit`**).
|
|
205
205
|
- **`clear_traces()`** — Drop all buffered trace events.
|
|
@@ -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
|
|
@@ -439,6 +439,7 @@ Answer Yes/No:
|
|
|
439
439
|
| `dynamic_macro_expansion.py` | Query that **changes downstream dependencies** at runtime |
|
|
440
440
|
| `snapshot_isolation.py` | Snapshot reads while live inputs change |
|
|
441
441
|
| `concurrent_background_work.py` | Dedup under concurrency + cancellation after input changes |
|
|
442
|
+
| `compute_many_with_accumulators.py` | `compute_many(..., effects=...)` accumulator collection |
|
|
442
443
|
| `persistence_and_inspection.py` | Save/load and graph summaries |
|
|
443
444
|
| `gil_parallel_speedup.py` | Threaded CPU benchmark: GIL vs free-threaded |
|
|
444
445
|
|
|
@@ -162,7 +162,13 @@ class Evaluator:
|
|
|
162
162
|
self._store.trace_event("cache_hit", key)
|
|
163
163
|
return existing, True
|
|
164
164
|
|
|
165
|
-
|
|
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 =
|
|
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]:
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import concurrent.futures
|
|
4
4
|
import os
|
|
5
5
|
import threading
|
|
6
|
-
from typing import Any, Callable, Iterable, Literal, Mapping, Sequence
|
|
6
|
+
from typing import Any, Callable, Iterable, Iterator, Literal, Mapping, Sequence
|
|
7
7
|
|
|
8
8
|
from ._errors import CancellationError, CycleError, QueryCancelled
|
|
9
9
|
from ._evaluator import Evaluator
|
|
@@ -262,18 +262,95 @@ class Engine:
|
|
|
262
262
|
*,
|
|
263
263
|
workers: int | None = None,
|
|
264
264
|
snapshot: Snapshot | None = None,
|
|
265
|
+
effects: dict[str, list[Any]] | None = None,
|
|
265
266
|
) -> list[Any]:
|
|
266
267
|
if not calls:
|
|
267
268
|
return []
|
|
268
269
|
run_snapshot = snapshot or self.snapshot()
|
|
269
270
|
worker_count = workers or min(32, max(1, len(calls)))
|
|
270
271
|
scheduler = WorkStealingExecutor(worker_count)
|
|
272
|
+
per_call_effects: list[dict[str, list[Any]] | None]
|
|
273
|
+
if effects is None:
|
|
274
|
+
per_call_effects = [None] * len(calls)
|
|
275
|
+
else:
|
|
276
|
+
per_call_effects = [{} for _ in range(len(calls))]
|
|
271
277
|
for idx, (query, args) in enumerate(calls):
|
|
272
278
|
scheduler.submit_indexed(
|
|
273
279
|
idx,
|
|
274
|
-
lambda q=query, a=args: self._query_call(
|
|
280
|
+
lambda q=query, a=args, e=per_call_effects[idx]: self._query_call(
|
|
281
|
+
q.id,
|
|
282
|
+
q.raw,
|
|
283
|
+
a,
|
|
284
|
+
snapshot=run_snapshot,
|
|
285
|
+
effects=e,
|
|
286
|
+
),
|
|
275
287
|
)
|
|
276
|
-
|
|
288
|
+
results = scheduler.run(len(calls))
|
|
289
|
+
if effects is not None:
|
|
290
|
+
for call_effects in per_call_effects:
|
|
291
|
+
if not call_effects:
|
|
292
|
+
continue
|
|
293
|
+
for name, items in call_effects.items():
|
|
294
|
+
if not items:
|
|
295
|
+
continue
|
|
296
|
+
effects.setdefault(name, []).extend(items)
|
|
297
|
+
return results
|
|
298
|
+
|
|
299
|
+
def compute_many_stream(
|
|
300
|
+
self,
|
|
301
|
+
calls: Sequence[tuple[_QueryHandle, tuple[Any, ...]]],
|
|
302
|
+
*,
|
|
303
|
+
workers: int | None = None,
|
|
304
|
+
snapshot: Snapshot | None = None,
|
|
305
|
+
effects: dict[str, list[Any]] | None = None,
|
|
306
|
+
) -> Iterator[tuple[int, Any, dict[str, list[Any]]]]:
|
|
307
|
+
"""Yield completed results as each call finishes.
|
|
308
|
+
|
|
309
|
+
Yields ``(index, value, call_effects)`` where ``index`` matches the position in
|
|
310
|
+
``calls`` and ``call_effects`` contains accumulator output for that call when
|
|
311
|
+
``effects`` was provided (otherwise an empty dict).
|
|
312
|
+
|
|
313
|
+
Unlike :meth:`compute_many`, this API yields items in *completion order*.
|
|
314
|
+
If ``effects`` is provided, it is populated after all calls complete, merged
|
|
315
|
+
deterministically in call order (matching :meth:`compute_many`).
|
|
316
|
+
"""
|
|
317
|
+
if not calls:
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
run_snapshot = snapshot or self.snapshot()
|
|
321
|
+
worker_count = workers or min(32, max(1, len(calls)))
|
|
322
|
+
|
|
323
|
+
per_call_effects: list[dict[str, list[Any]]]
|
|
324
|
+
per_call_effects = [{} for _ in range(len(calls))]
|
|
325
|
+
|
|
326
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=worker_count) as pool:
|
|
327
|
+
future_to_index: dict[concurrent.futures.Future[Any], int] = {}
|
|
328
|
+
for idx, (query, args) in enumerate(calls):
|
|
329
|
+
fut = pool.submit(
|
|
330
|
+
self._query_call,
|
|
331
|
+
query.id,
|
|
332
|
+
query.raw,
|
|
333
|
+
args,
|
|
334
|
+
snapshot=run_snapshot,
|
|
335
|
+
effects=per_call_effects[idx] if effects is not None else None,
|
|
336
|
+
)
|
|
337
|
+
future_to_index[fut] = idx
|
|
338
|
+
|
|
339
|
+
for fut in concurrent.futures.as_completed(future_to_index):
|
|
340
|
+
idx = future_to_index[fut]
|
|
341
|
+
value = fut.result()
|
|
342
|
+
call_effects = per_call_effects[idx] if effects is not None else {}
|
|
343
|
+
item = (idx, value, call_effects)
|
|
344
|
+
yield item
|
|
345
|
+
|
|
346
|
+
if effects is not None:
|
|
347
|
+
for call_effects in per_call_effects:
|
|
348
|
+
if not call_effects:
|
|
349
|
+
continue
|
|
350
|
+
for name, items in call_effects.items():
|
|
351
|
+
if not items:
|
|
352
|
+
continue
|
|
353
|
+
effects.setdefault(name, []).extend(items)
|
|
277
354
|
|
|
278
355
|
def traces(self) -> list[TraceEvent]:
|
|
279
356
|
return self._store.traces()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: query-cascade
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Minimal demand-driven query framework for incremental computation.
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -212,7 +212,7 @@ Properties and methods:
|
|
|
212
212
|
- **`accumulator(name: str) → Accumulator`** — Return a named **`Accumulator`** (see below). Names are keys in the optional **`effects`** map passed to synchronous **`QueryHandle`** calls and **`submit`**.
|
|
213
213
|
- **`snapshot() → Snapshot`** — Capture **`Snapshot(revision=engine.revision)`** for use as **`snapshot=`** on reads. Reads through that snapshot see input and memo state as of that revision; live **`set`** calls do not disturb snapshot reads.
|
|
214
214
|
- **`submit(query, *args, *, snapshot=None, effects=None, executor=None) → concurrent.futures.Future`** — Run **`query(*args)`** on **`executor`**, or on a **lazily created** per-engine **`ThreadPoolExecutor`** when **`executor` is `None`**. The engine captures **`cancel_epoch`** at schedule time; if inputs move on before the task runs, the future may complete with **`QueryCancelled`**. **`snapshot`** defaults to a fresh **`snapshot()`** at submit time when omitted.
|
|
215
|
-
- **`compute_many(calls, *, workers=None, snapshot=None) → list[Any]`** — Run many queries in parallel with a work-stealing scheduler. **`calls`** is a sequence of **`(query_handle, args_tuple)`**. Result order matches **`calls`**. **`workers`** defaults to a sensible value from **`len(calls)`** (capped); **`workers=0`** falls back to that same default. **`snapshot`** defaults to **`snapshot()`** when omitted.
|
|
215
|
+
- **`compute_many(calls, *, workers=None, snapshot=None, effects=None) → list[Any]`** — Run many queries in parallel with a work-stealing scheduler. **`calls`** is a sequence of **`(query_handle, args_tuple)`**. Result order matches **`calls`**. **`workers`** defaults to a sensible value from **`len(calls)`** (capped); **`workers=0`** falls back to that same default. **`snapshot`** defaults to **`snapshot()`** when omitted. When **`effects`** is a dict, accumulator output is collected and appended into **`effects[name]`** deterministically in **call order** (not completion order).
|
|
216
216
|
- **`shutdown(*, wait: bool = True, cancel_futures: bool = False)`** — Shut down the **default** thread pool created by **`submit`**, if any. Call when discarding the engine if you need threads torn down promptly (for example in tests).
|
|
217
217
|
- **`traces() → list[TraceEvent]`** — Copy of recent trace events (subject to **`trace_limit`**).
|
|
218
218
|
- **`clear_traces()`** — Drop all buffered trace events.
|
|
@@ -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
|
|
@@ -452,6 +452,7 @@ Answer Yes/No:
|
|
|
452
452
|
| `dynamic_macro_expansion.py` | Query that **changes downstream dependencies** at runtime |
|
|
453
453
|
| `snapshot_isolation.py` | Snapshot reads while live inputs change |
|
|
454
454
|
| `concurrent_background_work.py` | Dedup under concurrency + cancellation after input changes |
|
|
455
|
+
| `compute_many_with_accumulators.py` | `compute_many(..., effects=...)` accumulator collection |
|
|
455
456
|
| `persistence_and_inspection.py` | Save/load and graph summaries |
|
|
456
457
|
| `gil_parallel_speedup.py` | Threaded CPU benchmark: GIL vs free-threaded |
|
|
457
458
|
|
|
@@ -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
|
|
@@ -83,6 +83,79 @@ def test_compute_many_with_zero_workers_falls_back_to_default_worker_selection()
|
|
|
83
83
|
assert result == [i + 1 for i in range(6)]
|
|
84
84
|
|
|
85
85
|
|
|
86
|
+
def test_compute_many_can_collect_accumulator_effects() -> None:
|
|
87
|
+
engine = Engine()
|
|
88
|
+
progress = engine.accumulator("progress")
|
|
89
|
+
|
|
90
|
+
@engine.query
|
|
91
|
+
def job(name: str) -> str:
|
|
92
|
+
progress.push(("start", name))
|
|
93
|
+
progress.push(("done", name))
|
|
94
|
+
return name.upper()
|
|
95
|
+
|
|
96
|
+
effects: dict[str, list[object]] = {}
|
|
97
|
+
result = engine.compute_many([(job, ("a",)), (job, ("b",)), (job, ("c",))], workers=3, effects=effects)
|
|
98
|
+
assert result == ["A", "B", "C"]
|
|
99
|
+
assert effects == {
|
|
100
|
+
"progress": [
|
|
101
|
+
("start", "a"),
|
|
102
|
+
("done", "a"),
|
|
103
|
+
("start", "b"),
|
|
104
|
+
("done", "b"),
|
|
105
|
+
("start", "c"),
|
|
106
|
+
("done", "c"),
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_compute_many_stream_yields_in_completion_order() -> None:
|
|
112
|
+
engine = Engine()
|
|
113
|
+
|
|
114
|
+
@engine.query
|
|
115
|
+
def delayed(i: int) -> int:
|
|
116
|
+
time.sleep(0.004 * (4 - i))
|
|
117
|
+
return i
|
|
118
|
+
|
|
119
|
+
calls = [(delayed, (i,)) for i in range(4)]
|
|
120
|
+
items = list(engine.compute_many_stream(calls, workers=4))
|
|
121
|
+
# i=3 sleeps least, so it should complete first.
|
|
122
|
+
assert [idx for idx, _value, _effects in items] == [3, 2, 1, 0]
|
|
123
|
+
assert sorted(value for _idx, value, _effects in items) == [0, 1, 2, 3]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_compute_many_stream_can_yield_and_collect_accumulator_effects() -> None:
|
|
127
|
+
engine = Engine()
|
|
128
|
+
progress = engine.accumulator("progress")
|
|
129
|
+
|
|
130
|
+
@engine.query
|
|
131
|
+
def job(name: str, seconds: float) -> str:
|
|
132
|
+
progress.push(("start", name))
|
|
133
|
+
time.sleep(seconds)
|
|
134
|
+
progress.push(("done", name))
|
|
135
|
+
return name.upper()
|
|
136
|
+
|
|
137
|
+
calls = [(job, ("a", 0.03)), (job, ("b", 0.01)), (job, ("c", 0.02))]
|
|
138
|
+
effects: dict[str, list[object]] = {}
|
|
139
|
+
items = list(engine.compute_many_stream(calls, workers=3, effects=effects))
|
|
140
|
+
|
|
141
|
+
# Stream yields in completion order (b, c, a).
|
|
142
|
+
assert [value for _idx, value, _call_effects in items] == ["B", "C", "A"]
|
|
143
|
+
# Each yielded item includes just that call's effects.
|
|
144
|
+
by_value = {value: call_effects["progress"] for _idx, value, call_effects in items}
|
|
145
|
+
assert by_value["A"] == [("start", "a"), ("done", "a")]
|
|
146
|
+
assert by_value["B"] == [("start", "b"), ("done", "b")]
|
|
147
|
+
assert by_value["C"] == [("start", "c"), ("done", "c")]
|
|
148
|
+
# Aggregate effects dict is merged in call order, matching compute_many.
|
|
149
|
+
assert effects["progress"] == [
|
|
150
|
+
("start", "a"),
|
|
151
|
+
("done", "a"),
|
|
152
|
+
("start", "b"),
|
|
153
|
+
("done", "b"),
|
|
154
|
+
("start", "c"),
|
|
155
|
+
("done", "c"),
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
|
|
86
159
|
def test_work_stealing_run_raises_first_recorded_task_error() -> None:
|
|
87
160
|
scheduler = WorkStealingExecutor(workers=2)
|
|
88
161
|
|
|
@@ -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
|
|
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
|