query-cascade 0.2.2__tar.gz → 0.2.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 (38) hide show
  1. query_cascade-0.2.4/PKG-INFO +148 -0
  2. query_cascade-0.2.4/README.md +135 -0
  3. {query_cascade-0.2.2 → query_cascade-0.2.4}/pyproject.toml +1 -1
  4. {query_cascade-0.2.2 → query_cascade-0.2.4}/src/cascade/engine.py +80 -3
  5. query_cascade-0.2.4/src/query_cascade.egg-info/PKG-INFO +148 -0
  6. {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_engine_compute_many.py +73 -0
  7. query_cascade-0.2.2/PKG-INFO +0 -632
  8. query_cascade-0.2.2/README.md +0 -619
  9. query_cascade-0.2.2/src/query_cascade.egg-info/PKG-INFO +0 -632
  10. {query_cascade-0.2.2 → query_cascade-0.2.4}/setup.cfg +0 -0
  11. {query_cascade-0.2.2 → query_cascade-0.2.4}/src/cascade/__init__.py +0 -0
  12. {query_cascade-0.2.2 → query_cascade-0.2.4}/src/cascade/_errors.py +0 -0
  13. {query_cascade-0.2.2 → query_cascade-0.2.4}/src/cascade/_evaluator.py +0 -0
  14. {query_cascade-0.2.2 → query_cascade-0.2.4}/src/cascade/_graph_export.py +0 -0
  15. {query_cascade-0.2.2 → query_cascade-0.2.4}/src/cascade/_persistence.py +0 -0
  16. {query_cascade-0.2.2 → query_cascade-0.2.4}/src/cascade/_runtime.py +0 -0
  17. {query_cascade-0.2.2 → query_cascade-0.2.4}/src/cascade/_scheduler.py +0 -0
  18. {query_cascade-0.2.2 → query_cascade-0.2.4}/src/cascade/_serde.py +0 -0
  19. {query_cascade-0.2.2 → query_cascade-0.2.4}/src/cascade/_state.py +0 -0
  20. {query_cascade-0.2.2 → query_cascade-0.2.4}/src/cascade/_store.py +0 -0
  21. {query_cascade-0.2.2 → query_cascade-0.2.4}/src/cascade/_synthetic_graph.py +0 -0
  22. {query_cascade-0.2.2 → query_cascade-0.2.4}/src/query_cascade.egg-info/SOURCES.txt +0 -0
  23. {query_cascade-0.2.2 → query_cascade-0.2.4}/src/query_cascade.egg-info/dependency_links.txt +0 -0
  24. {query_cascade-0.2.2 → query_cascade-0.2.4}/src/query_cascade.egg-info/requires.txt +0 -0
  25. {query_cascade-0.2.2 → query_cascade-0.2.4}/src/query_cascade.egg-info/top_level.txt +0 -0
  26. {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_dependency_parallelism.py +0 -0
  27. {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_engine.py +0 -0
  28. {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_engine_concurrency.py +0 -0
  29. {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_engine_persistence_and_tracing.py +0 -0
  30. {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_evaluator_and_synthetic_coverage.py +0 -0
  31. {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_examples.py +0 -0
  32. {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_internal_invariants.py +0 -0
  33. {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_performance.py +0 -0
  34. {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_repro_touch_memo_keyerror.py +0 -0
  35. {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_scale_behavior.py +0 -0
  36. {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_serde.py +0 -0
  37. {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_stateful_engine_invariants.py +0 -0
  38. {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_subgraph_stats_export.py +0 -0
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.4
2
+ Name: query-cascade
3
+ Version: 0.2.4
4
+ Summary: Minimal demand-driven query framework for incremental computation.
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+ Provides-Extra: dev
8
+ Requires-Dist: hypothesis; extra == "dev"
9
+ Requires-Dist: mutmut; extra == "dev"
10
+ Requires-Dist: pytest; extra == "dev"
11
+ Requires-Dist: pytest-cov; extra == "dev"
12
+ Requires-Dist: ruff; extra == "dev"
13
+
14
+ # Cascade Query
15
+
16
+ Cascade Query is a Python library for incremental dependency tracking. It caches function results and re-executes them only when their specific inputs or upstream dependencies change.
17
+
18
+ [![PyPI version](https://img.shields.io/pypi/v/query-cascade?color=3775A9&label=PyPI)](https://pypi.org/project/query-cascade/)
19
+ [![Python versions](https://img.shields.io/pypi/pyversions/query-cascade?color=5A9)](https://pypi.org/project/query-cascade/)
20
+
21
+ ---
22
+
23
+ ## Core Principles
24
+
25
+ 1. **Automatic Caching:** Results are stored. If dependencies are unchanged, the function body does not execute.
26
+ 2. **Dependency Tracking:** Cascade records every `@engine.input` or `@engine.query` accessed during execution.
27
+ 3. **Targeted Updates:** When an input changes, Cascade identifies and invalidates only the affected downstream functions.
28
+ 4. **Early Bail-out:** If a function's output remains identical after its dependencies change, re-computation stops for that branch.
29
+
30
+ ---
31
+
32
+ ## Quickstart
33
+
34
+ ```python
35
+ import time
36
+ from cascade import Engine
37
+
38
+ engine = Engine()
39
+
40
+ @engine.input
41
+ def user_id():
42
+ return "user_1"
43
+
44
+ @engine.query
45
+ def fetch_data():
46
+ time.sleep(2)
47
+ return {"id": user_id(), "data": "value"}
48
+
49
+ @engine.query
50
+ def get_result():
51
+ data = fetch_data()
52
+ return f"Result for {data['id']}"
53
+
54
+ # First run: Executes for 2 seconds.
55
+ print(get_result())
56
+
57
+ # Second run: Returns immediately from cache.
58
+ print(get_result())
59
+
60
+ # Update input:
61
+ user_id.set("user_2")
62
+
63
+ # Third run: Executes for 2 seconds to refresh.
64
+ print(get_result())
65
+ ```
66
+
67
+ ---
68
+
69
+ ## Engine API
70
+
71
+ ### Core Methods
72
+ * **`Engine(max_entries=10000, stats=False)`**: Initializes the engine. `max_entries` sets the limit for the Least Recently Used (LRU) cache.
73
+ * **`@engine.input`**: Decorator for mutable data roots.
74
+ * `input.set(value)`: Updates the value and increments the global revision.
75
+ * `input.set(*args, value=value)`: Updates a keyed input.
76
+ * **`@engine.query`**: Decorator for cached computations.
77
+ * **`engine.snapshot()`**: Returns a `Snapshot` object pinning the current global revision. Use `query(snapshot=s)` to read data as it existed at that revision.
78
+ * **`engine.save(path)` / `engine.load(path)`**: Persists all inputs and cached results to a SQLite database.
79
+
80
+ ### Parallel & Background Execution
81
+ * **`engine.compute_many(calls, workers=None)`**: Executes a list of queries in parallel using a thread pool.
82
+ * **`engine.submit(query, *args, executor=None)`**: Schedules a query for background execution. Returns a `concurrent.futures.Future`.
83
+ * **`QueryCancelled`**: Exception raised if a background query's dependencies change before it completes.
84
+
85
+ ### Graph Utilities
86
+ * **`engine.inspect_graph()`**: Returns a dictionary of all nodes and edges in the dependency graph.
87
+ * **`engine.subgraph(roots, direction="deps")`**: Filters the graph to the dependency chain of the specified root nodes.
88
+ * **`engine.prune(roots)`**: Removes cached query results that are not reachable from the specified roots.
89
+
90
+ ---
91
+
92
+ ## Advanced Features
93
+
94
+ ### Side-Effect Accumulators
95
+ Queries must be pure functions. Use `Accumulator` to record side-effects (like logs or warnings) that must be replayed when a result is served from the cache.
96
+
97
+ ```python
98
+ warnings = engine.accumulator("warnings")
99
+
100
+ @engine.query
101
+ def validate_data():
102
+ data = fetch_data()
103
+ if not data:
104
+ warnings.push("No data found")
105
+ return data
106
+
107
+ # On cache hit, 'warnings' are re-populated into the effects dictionary.
108
+ effects = {}
109
+ validate_data(effects=effects)
110
+ print(effects["warnings"])
111
+ ```
112
+
113
+ ### Performance Metrics
114
+ Set `stats=True` in the `Engine` constructor to track execution timing.
115
+ * **`engine.stats_summary()`**: Returns wall-clock time spent in function bodies and cache eviction counts.
116
+ * **`engine.reset_stats()`**: Clears accumulated timing data.
117
+
118
+ ---
119
+
120
+ ## Visualization
121
+ Cascade provides renderers for the dependency graph.
122
+
123
+ ```python
124
+ from cascade import export_dot, export_mermaid
125
+
126
+ graph = engine.inspect_graph()
127
+ # Generate Graphviz DOT format
128
+ dot_text = export_dot(graph)
129
+ # Generate Mermaid flowchart format
130
+ mermaid_text = export_mermaid(graph)
131
+ ```
132
+
133
+ ---
134
+
135
+ ## Limitations
136
+
137
+ 1. **Cycle Detection:** Cascade detects and rejects recursive function calls (cycles) with a `CycleError`.
138
+ 2. **Thread Safety:** While Cascade supports parallel query execution, the `Engine` object itself should be modified (`.set()`, `@engine.query`) from a single thread or with external synchronization.
139
+ 3. **Persistence Security:** `engine.load()` uses JSON to resolve types via `importlib`. Only load databases from trusted sources.
140
+ 4. **Python Version:** Optimization for parallel CPU-bound work requires **CPython 3.14+ free-threaded** builds with `PYTHON_GIL=0`.
141
+
142
+ ---
143
+
144
+ ## Installation
145
+
146
+ ```bash
147
+ pip install query-cascade
148
+ ```
@@ -0,0 +1,135 @@
1
+ # Cascade Query
2
+
3
+ Cascade Query is a Python library for incremental dependency tracking. It caches function results and re-executes them only when their specific inputs or upstream dependencies change.
4
+
5
+ [![PyPI version](https://img.shields.io/pypi/v/query-cascade?color=3775A9&label=PyPI)](https://pypi.org/project/query-cascade/)
6
+ [![Python versions](https://img.shields.io/pypi/pyversions/query-cascade?color=5A9)](https://pypi.org/project/query-cascade/)
7
+
8
+ ---
9
+
10
+ ## Core Principles
11
+
12
+ 1. **Automatic Caching:** Results are stored. If dependencies are unchanged, the function body does not execute.
13
+ 2. **Dependency Tracking:** Cascade records every `@engine.input` or `@engine.query` accessed during execution.
14
+ 3. **Targeted Updates:** When an input changes, Cascade identifies and invalidates only the affected downstream functions.
15
+ 4. **Early Bail-out:** If a function's output remains identical after its dependencies change, re-computation stops for that branch.
16
+
17
+ ---
18
+
19
+ ## Quickstart
20
+
21
+ ```python
22
+ import time
23
+ from cascade import Engine
24
+
25
+ engine = Engine()
26
+
27
+ @engine.input
28
+ def user_id():
29
+ return "user_1"
30
+
31
+ @engine.query
32
+ def fetch_data():
33
+ time.sleep(2)
34
+ return {"id": user_id(), "data": "value"}
35
+
36
+ @engine.query
37
+ def get_result():
38
+ data = fetch_data()
39
+ return f"Result for {data['id']}"
40
+
41
+ # First run: Executes for 2 seconds.
42
+ print(get_result())
43
+
44
+ # Second run: Returns immediately from cache.
45
+ print(get_result())
46
+
47
+ # Update input:
48
+ user_id.set("user_2")
49
+
50
+ # Third run: Executes for 2 seconds to refresh.
51
+ print(get_result())
52
+ ```
53
+
54
+ ---
55
+
56
+ ## Engine API
57
+
58
+ ### Core Methods
59
+ * **`Engine(max_entries=10000, stats=False)`**: Initializes the engine. `max_entries` sets the limit for the Least Recently Used (LRU) cache.
60
+ * **`@engine.input`**: Decorator for mutable data roots.
61
+ * `input.set(value)`: Updates the value and increments the global revision.
62
+ * `input.set(*args, value=value)`: Updates a keyed input.
63
+ * **`@engine.query`**: Decorator for cached computations.
64
+ * **`engine.snapshot()`**: Returns a `Snapshot` object pinning the current global revision. Use `query(snapshot=s)` to read data as it existed at that revision.
65
+ * **`engine.save(path)` / `engine.load(path)`**: Persists all inputs and cached results to a SQLite database.
66
+
67
+ ### Parallel & Background Execution
68
+ * **`engine.compute_many(calls, workers=None)`**: Executes a list of queries in parallel using a thread pool.
69
+ * **`engine.submit(query, *args, executor=None)`**: Schedules a query for background execution. Returns a `concurrent.futures.Future`.
70
+ * **`QueryCancelled`**: Exception raised if a background query's dependencies change before it completes.
71
+
72
+ ### Graph Utilities
73
+ * **`engine.inspect_graph()`**: Returns a dictionary of all nodes and edges in the dependency graph.
74
+ * **`engine.subgraph(roots, direction="deps")`**: Filters the graph to the dependency chain of the specified root nodes.
75
+ * **`engine.prune(roots)`**: Removes cached query results that are not reachable from the specified roots.
76
+
77
+ ---
78
+
79
+ ## Advanced Features
80
+
81
+ ### Side-Effect Accumulators
82
+ Queries must be pure functions. Use `Accumulator` to record side-effects (like logs or warnings) that must be replayed when a result is served from the cache.
83
+
84
+ ```python
85
+ warnings = engine.accumulator("warnings")
86
+
87
+ @engine.query
88
+ def validate_data():
89
+ data = fetch_data()
90
+ if not data:
91
+ warnings.push("No data found")
92
+ return data
93
+
94
+ # On cache hit, 'warnings' are re-populated into the effects dictionary.
95
+ effects = {}
96
+ validate_data(effects=effects)
97
+ print(effects["warnings"])
98
+ ```
99
+
100
+ ### Performance Metrics
101
+ Set `stats=True` in the `Engine` constructor to track execution timing.
102
+ * **`engine.stats_summary()`**: Returns wall-clock time spent in function bodies and cache eviction counts.
103
+ * **`engine.reset_stats()`**: Clears accumulated timing data.
104
+
105
+ ---
106
+
107
+ ## Visualization
108
+ Cascade provides renderers for the dependency graph.
109
+
110
+ ```python
111
+ from cascade import export_dot, export_mermaid
112
+
113
+ graph = engine.inspect_graph()
114
+ # Generate Graphviz DOT format
115
+ dot_text = export_dot(graph)
116
+ # Generate Mermaid flowchart format
117
+ mermaid_text = export_mermaid(graph)
118
+ ```
119
+
120
+ ---
121
+
122
+ ## Limitations
123
+
124
+ 1. **Cycle Detection:** Cascade detects and rejects recursive function calls (cycles) with a `CycleError`.
125
+ 2. **Thread Safety:** While Cascade supports parallel query execution, the `Engine` object itself should be modified (`.set()`, `@engine.query`) from a single thread or with external synchronization.
126
+ 3. **Persistence Security:** `engine.load()` uses JSON to resolve types via `importlib`. Only load databases from trusted sources.
127
+ 4. **Python Version:** Optimization for parallel CPU-bound work requires **CPython 3.14+ free-threaded** builds with `PYTHON_GIL=0`.
128
+
129
+ ---
130
+
131
+ ## Installation
132
+
133
+ ```bash
134
+ pip install query-cascade
135
+ ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "query-cascade"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "Minimal demand-driven query framework for incremental computation."
9
9
  requires-python = ">=3.12"
10
10
  readme = "README.md"
@@ -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(q.id, q.raw, a, snapshot=run_snapshot),
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
- return scheduler.run(len(calls))
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()
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.4
2
+ Name: query-cascade
3
+ Version: 0.2.4
4
+ Summary: Minimal demand-driven query framework for incremental computation.
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+ Provides-Extra: dev
8
+ Requires-Dist: hypothesis; extra == "dev"
9
+ Requires-Dist: mutmut; extra == "dev"
10
+ Requires-Dist: pytest; extra == "dev"
11
+ Requires-Dist: pytest-cov; extra == "dev"
12
+ Requires-Dist: ruff; extra == "dev"
13
+
14
+ # Cascade Query
15
+
16
+ Cascade Query is a Python library for incremental dependency tracking. It caches function results and re-executes them only when their specific inputs or upstream dependencies change.
17
+
18
+ [![PyPI version](https://img.shields.io/pypi/v/query-cascade?color=3775A9&label=PyPI)](https://pypi.org/project/query-cascade/)
19
+ [![Python versions](https://img.shields.io/pypi/pyversions/query-cascade?color=5A9)](https://pypi.org/project/query-cascade/)
20
+
21
+ ---
22
+
23
+ ## Core Principles
24
+
25
+ 1. **Automatic Caching:** Results are stored. If dependencies are unchanged, the function body does not execute.
26
+ 2. **Dependency Tracking:** Cascade records every `@engine.input` or `@engine.query` accessed during execution.
27
+ 3. **Targeted Updates:** When an input changes, Cascade identifies and invalidates only the affected downstream functions.
28
+ 4. **Early Bail-out:** If a function's output remains identical after its dependencies change, re-computation stops for that branch.
29
+
30
+ ---
31
+
32
+ ## Quickstart
33
+
34
+ ```python
35
+ import time
36
+ from cascade import Engine
37
+
38
+ engine = Engine()
39
+
40
+ @engine.input
41
+ def user_id():
42
+ return "user_1"
43
+
44
+ @engine.query
45
+ def fetch_data():
46
+ time.sleep(2)
47
+ return {"id": user_id(), "data": "value"}
48
+
49
+ @engine.query
50
+ def get_result():
51
+ data = fetch_data()
52
+ return f"Result for {data['id']}"
53
+
54
+ # First run: Executes for 2 seconds.
55
+ print(get_result())
56
+
57
+ # Second run: Returns immediately from cache.
58
+ print(get_result())
59
+
60
+ # Update input:
61
+ user_id.set("user_2")
62
+
63
+ # Third run: Executes for 2 seconds to refresh.
64
+ print(get_result())
65
+ ```
66
+
67
+ ---
68
+
69
+ ## Engine API
70
+
71
+ ### Core Methods
72
+ * **`Engine(max_entries=10000, stats=False)`**: Initializes the engine. `max_entries` sets the limit for the Least Recently Used (LRU) cache.
73
+ * **`@engine.input`**: Decorator for mutable data roots.
74
+ * `input.set(value)`: Updates the value and increments the global revision.
75
+ * `input.set(*args, value=value)`: Updates a keyed input.
76
+ * **`@engine.query`**: Decorator for cached computations.
77
+ * **`engine.snapshot()`**: Returns a `Snapshot` object pinning the current global revision. Use `query(snapshot=s)` to read data as it existed at that revision.
78
+ * **`engine.save(path)` / `engine.load(path)`**: Persists all inputs and cached results to a SQLite database.
79
+
80
+ ### Parallel & Background Execution
81
+ * **`engine.compute_many(calls, workers=None)`**: Executes a list of queries in parallel using a thread pool.
82
+ * **`engine.submit(query, *args, executor=None)`**: Schedules a query for background execution. Returns a `concurrent.futures.Future`.
83
+ * **`QueryCancelled`**: Exception raised if a background query's dependencies change before it completes.
84
+
85
+ ### Graph Utilities
86
+ * **`engine.inspect_graph()`**: Returns a dictionary of all nodes and edges in the dependency graph.
87
+ * **`engine.subgraph(roots, direction="deps")`**: Filters the graph to the dependency chain of the specified root nodes.
88
+ * **`engine.prune(roots)`**: Removes cached query results that are not reachable from the specified roots.
89
+
90
+ ---
91
+
92
+ ## Advanced Features
93
+
94
+ ### Side-Effect Accumulators
95
+ Queries must be pure functions. Use `Accumulator` to record side-effects (like logs or warnings) that must be replayed when a result is served from the cache.
96
+
97
+ ```python
98
+ warnings = engine.accumulator("warnings")
99
+
100
+ @engine.query
101
+ def validate_data():
102
+ data = fetch_data()
103
+ if not data:
104
+ warnings.push("No data found")
105
+ return data
106
+
107
+ # On cache hit, 'warnings' are re-populated into the effects dictionary.
108
+ effects = {}
109
+ validate_data(effects=effects)
110
+ print(effects["warnings"])
111
+ ```
112
+
113
+ ### Performance Metrics
114
+ Set `stats=True` in the `Engine` constructor to track execution timing.
115
+ * **`engine.stats_summary()`**: Returns wall-clock time spent in function bodies and cache eviction counts.
116
+ * **`engine.reset_stats()`**: Clears accumulated timing data.
117
+
118
+ ---
119
+
120
+ ## Visualization
121
+ Cascade provides renderers for the dependency graph.
122
+
123
+ ```python
124
+ from cascade import export_dot, export_mermaid
125
+
126
+ graph = engine.inspect_graph()
127
+ # Generate Graphviz DOT format
128
+ dot_text = export_dot(graph)
129
+ # Generate Mermaid flowchart format
130
+ mermaid_text = export_mermaid(graph)
131
+ ```
132
+
133
+ ---
134
+
135
+ ## Limitations
136
+
137
+ 1. **Cycle Detection:** Cascade detects and rejects recursive function calls (cycles) with a `CycleError`.
138
+ 2. **Thread Safety:** While Cascade supports parallel query execution, the `Engine` object itself should be modified (`.set()`, `@engine.query`) from a single thread or with external synchronization.
139
+ 3. **Persistence Security:** `engine.load()` uses JSON to resolve types via `importlib`. Only load databases from trusted sources.
140
+ 4. **Python Version:** Optimization for parallel CPU-bound work requires **CPython 3.14+ free-threaded** builds with `PYTHON_GIL=0`.
141
+
142
+ ---
143
+
144
+ ## Installation
145
+
146
+ ```bash
147
+ pip install query-cascade
148
+ ```
@@ -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