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.
- query_cascade-0.2.4/PKG-INFO +148 -0
- query_cascade-0.2.4/README.md +135 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/pyproject.toml +1 -1
- {query_cascade-0.2.2 → query_cascade-0.2.4}/src/cascade/engine.py +80 -3
- query_cascade-0.2.4/src/query_cascade.egg-info/PKG-INFO +148 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_engine_compute_many.py +73 -0
- query_cascade-0.2.2/PKG-INFO +0 -632
- query_cascade-0.2.2/README.md +0 -619
- query_cascade-0.2.2/src/query_cascade.egg-info/PKG-INFO +0 -632
- {query_cascade-0.2.2 → query_cascade-0.2.4}/setup.cfg +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/src/cascade/__init__.py +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/src/cascade/_errors.py +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/src/cascade/_evaluator.py +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/src/cascade/_graph_export.py +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/src/cascade/_persistence.py +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/src/cascade/_runtime.py +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/src/cascade/_scheduler.py +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/src/cascade/_serde.py +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/src/cascade/_state.py +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/src/cascade/_store.py +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/src/cascade/_synthetic_graph.py +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/src/query_cascade.egg-info/SOURCES.txt +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/src/query_cascade.egg-info/dependency_links.txt +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/src/query_cascade.egg-info/requires.txt +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/src/query_cascade.egg-info/top_level.txt +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_dependency_parallelism.py +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_engine.py +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_engine_concurrency.py +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_engine_persistence_and_tracing.py +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_evaluator_and_synthetic_coverage.py +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_examples.py +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_internal_invariants.py +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_performance.py +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_repro_touch_memo_keyerror.py +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_scale_behavior.py +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_serde.py +0 -0
- {query_cascade-0.2.2 → query_cascade-0.2.4}/tests/test_stateful_engine_invariants.py +0 -0
- {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
|
+
[](https://pypi.org/project/query-cascade/)
|
|
19
|
+
[](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
|
+
[](https://pypi.org/project/query-cascade/)
|
|
6
|
+
[](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
|
+
```
|
|
@@ -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()
|
|
@@ -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
|
+
[](https://pypi.org/project/query-cascade/)
|
|
19
|
+
[](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
|
|