query-cascade 0.3.7__tar.gz → 0.3.8__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 (51) hide show
  1. {query_cascade-0.3.7 → query_cascade-0.3.8}/PKG-INFO +12 -1
  2. {query_cascade-0.3.7 → query_cascade-0.3.8}/README.md +11 -0
  3. {query_cascade-0.3.7 → query_cascade-0.3.8}/pyproject.toml +1 -1
  4. {query_cascade-0.3.7 → query_cascade-0.3.8}/src/cascade/_evaluator.py +30 -2
  5. {query_cascade-0.3.7 → query_cascade-0.3.8}/src/cascade/_serde.py +3 -1
  6. {query_cascade-0.3.7 → query_cascade-0.3.8}/src/cascade/_state.py +1 -0
  7. {query_cascade-0.3.7 → query_cascade-0.3.8}/src/cascade/_store.py +9 -1
  8. {query_cascade-0.3.7 → query_cascade-0.3.8}/src/cascade/engine.py +2 -2
  9. {query_cascade-0.3.7 → query_cascade-0.3.8}/src/query_cascade.egg-info/PKG-INFO +12 -1
  10. {query_cascade-0.3.7 → query_cascade-0.3.8}/src/query_cascade.egg-info/SOURCES.txt +2 -1
  11. query_cascade-0.3.8/tests/test_ttl.py +63 -0
  12. {query_cascade-0.3.7 → query_cascade-0.3.8}/setup.cfg +0 -0
  13. {query_cascade-0.3.7 → query_cascade-0.3.8}/src/cascade/__init__.py +0 -0
  14. {query_cascade-0.3.7 → query_cascade-0.3.8}/src/cascade/_canonical.py +0 -0
  15. {query_cascade-0.3.7 → query_cascade-0.3.8}/src/cascade/_disk_cache.py +0 -0
  16. {query_cascade-0.3.7 → query_cascade-0.3.8}/src/cascade/_errors.py +0 -0
  17. {query_cascade-0.3.7 → query_cascade-0.3.8}/src/cascade/_graph_export.py +0 -0
  18. {query_cascade-0.3.7 → query_cascade-0.3.8}/src/cascade/_persistence.py +0 -0
  19. {query_cascade-0.3.7 → query_cascade-0.3.8}/src/cascade/_runtime.py +0 -0
  20. {query_cascade-0.3.7 → query_cascade-0.3.8}/src/cascade/_scheduler.py +0 -0
  21. {query_cascade-0.3.7 → query_cascade-0.3.8}/src/cascade/_synthetic_graph.py +0 -0
  22. {query_cascade-0.3.7 → query_cascade-0.3.8}/src/query_cascade.egg-info/dependency_links.txt +0 -0
  23. {query_cascade-0.3.7 → query_cascade-0.3.8}/src/query_cascade.egg-info/requires.txt +0 -0
  24. {query_cascade-0.3.7 → query_cascade-0.3.8}/src/query_cascade.egg-info/top_level.txt +0 -0
  25. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_async.py +0 -0
  26. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_async_coverage.py +0 -0
  27. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_code_versioning.py +0 -0
  28. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_dependency_parallelism.py +0 -0
  29. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_disk_cache.py +0 -0
  30. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_engine.py +0 -0
  31. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_engine_compute_many.py +0 -0
  32. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_engine_concurrency.py +0 -0
  33. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_engine_persistence_and_tracing.py +0 -0
  34. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_engine_private.py +0 -0
  35. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_engine_private2.py +0 -0
  36. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_engine_sweep.py +0 -0
  37. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_error_caching.py +0 -0
  38. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_error_caching_persistence.py +0 -0
  39. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_evaluator_and_synthetic_coverage.py +0 -0
  40. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_examples.py +0 -0
  41. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_fixed_point.py +0 -0
  42. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_internal_invariants.py +0 -0
  43. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_memoize_false.py +0 -0
  44. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_performance.py +0 -0
  45. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_repro_touch_memo_keyerror.py +0 -0
  46. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_scale_behavior.py +0 -0
  47. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_serde.py +0 -0
  48. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_stateful_engine_invariants.py +0 -0
  49. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_store_coverage.py +0 -0
  50. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_subgraph_stats_export.py +0 -0
  51. {query_cascade-0.3.7 → query_cascade-0.3.8}/tests/test_sweep_unaccessed.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: query-cascade
3
- Version: 0.3.7
3
+ Version: 0.3.8
4
4
  Summary: Minimal demand-driven query framework for incremental computation.
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
@@ -139,6 +139,16 @@ A few things to know:
139
139
 
140
140
  ## Advanced Features
141
141
 
142
+ ### Time-To-Live Cache Invalidation
143
+ Cascade supports expiring cache entries automatically based on wall-clock time. By passing the `ttl` flag to `@engine.query`, you can guarantee that a node will recompute its value if it is accessed after the specified number of seconds has elapsed since its last evaluation.
144
+
145
+ ```python
146
+ @engine.query(ttl=5.0)
147
+ def fetch_external_data():
148
+ return request.get("https://api.example.com/data")
149
+ ```
150
+ When a TTL expires, Cascade evaluates the function again. If the newly returned value is identical to the previous one, downstream queries are **not** re-evaluated—early bail-out works exactly as it does for regular dependency changes.
151
+
142
152
  ### Asynchronous Execution
143
153
  Cascade natively supports asynchronous queries via `async def`. This is extremely useful for I/O-bound workflows (such as making database or network calls). When an asynchronous query evaluates, it runs cooperatively on the active `asyncio` event loop.
144
154
 
@@ -264,6 +274,7 @@ pip install query-cascade
264
274
  |--------|----------------|
265
275
  | `compiler_pipeline.py` | `source → parse → symbols → typecheck`, warnings accumulator, cache-hit narration |
266
276
  | `async_execution.py` | Asynchronous query evaluation and asyncio event loop integration for IO-bound work |
277
+ | `ttl_invalidation.py` | Expiring stale query caches automatically based on wall-clock time |
267
278
  | `error_caching.py` | Basic exception caching to prevent repeated re-evaluation on failure |
268
279
  | `error_caching_persistence.py` | Disk cache hydration of exceptions across process runs |
269
280
  | `code_versioning.py` | Automatic cache invalidation when a function's bytecode logic changes |
@@ -120,6 +120,16 @@ A few things to know:
120
120
 
121
121
  ## Advanced Features
122
122
 
123
+ ### Time-To-Live Cache Invalidation
124
+ Cascade supports expiring cache entries automatically based on wall-clock time. By passing the `ttl` flag to `@engine.query`, you can guarantee that a node will recompute its value if it is accessed after the specified number of seconds has elapsed since its last evaluation.
125
+
126
+ ```python
127
+ @engine.query(ttl=5.0)
128
+ def fetch_external_data():
129
+ return request.get("https://api.example.com/data")
130
+ ```
131
+ When a TTL expires, Cascade evaluates the function again. If the newly returned value is identical to the previous one, downstream queries are **not** re-evaluated—early bail-out works exactly as it does for regular dependency changes.
132
+
123
133
  ### Asynchronous Execution
124
134
  Cascade natively supports asynchronous queries via `async def`. This is extremely useful for I/O-bound workflows (such as making database or network calls). When an asynchronous query evaluates, it runs cooperatively on the active `asyncio` event loop.
125
135
 
@@ -245,6 +255,7 @@ pip install query-cascade
245
255
  |--------|----------------|
246
256
  | `compiler_pipeline.py` | `source → parse → symbols → typecheck`, warnings accumulator, cache-hit narration |
247
257
  | `async_execution.py` | Asynchronous query evaluation and asyncio event loop integration for IO-bound work |
258
+ | `ttl_invalidation.py` | Expiring stale query caches automatically based on wall-clock time |
248
259
  | `error_caching.py` | Basic exception caching to prevent repeated re-evaluation on failure |
249
260
  | `error_caching_persistence.py` | Disk cache hydration of exceptions across process runs |
250
261
  | `code_versioning.py` | Automatic cache invalidation when a function's bytecode logic changes |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "query-cascade"
7
- version = "0.3.7"
7
+ version = "0.3.8"
8
8
  description = "Minimal demand-driven query framework for incremental computation."
9
9
  requires-python = ">=3.12"
10
10
  readme = "README.md"
@@ -308,7 +308,13 @@ class Evaluator:
308
308
  is_memoized = self._store.lookup_query_memoize(key[1])
309
309
  if existing is not None:
310
310
  self._store.touch_memo_locked(key)
311
- if existing.verified_at == runtime.snapshot.revision:
311
+
312
+ ttl = self._store.lookup_query_ttl(key[1])
313
+ is_expired = False
314
+ if ttl is not None and self._store.monotonic_seconds() - existing.computed_at_time > ttl:
315
+ is_expired = True
316
+
317
+ if not is_expired and existing.verified_at == runtime.snapshot.revision:
312
318
  self._store.trace_event("cache_hit", key)
313
319
  if is_memoized or not need_value:
314
320
  return existing, True
@@ -370,7 +376,13 @@ class Evaluator:
370
376
  is_memoized = self._store.lookup_query_memoize(key[1])
371
377
  if existing is not None:
372
378
  self._store.touch_memo_locked(key)
373
- if existing.verified_at == runtime.snapshot.revision:
379
+
380
+ ttl = self._store.lookup_query_ttl(key[1])
381
+ is_expired = False
382
+ if ttl is not None and self._store.monotonic_seconds() - existing.computed_at_time > ttl:
383
+ is_expired = True
384
+
385
+ if not is_expired and existing.verified_at == runtime.snapshot.revision:
374
386
  self._store.trace_event("cache_hit", key)
375
387
  if is_memoized or not need_value:
376
388
  return existing, True
@@ -423,6 +435,11 @@ class Evaluator:
423
435
  self._store.in_flight.pop((key, runtime.snapshot.revision), None)
424
436
 
425
437
  def try_mark_green(self, key: QueryKey, entry: MemoEntry, snapshot: Snapshot) -> bool:
438
+ ttl = self._store.lookup_query_ttl(key[1])
439
+ if ttl is not None and self._store.monotonic_seconds() - entry.computed_at_time > ttl:
440
+ self._store.trace_event("cache_red_ttl", key)
441
+ return False
442
+
426
443
  for dep in entry.deps:
427
444
  dep_changed_at = self.dependency_changed_at(dep.key, snapshot)
428
445
  if dep_changed_at != dep.observed_changed_at:
@@ -431,6 +448,11 @@ class Evaluator:
431
448
  return True
432
449
 
433
450
  async def try_mark_green_async(self, key: QueryKey, entry: MemoEntry, snapshot: Snapshot) -> bool: # pragma: no cover
451
+ ttl = self._store.lookup_query_ttl(key[1])
452
+ if ttl is not None and self._store.monotonic_seconds() - entry.computed_at_time > ttl:
453
+ self._store.trace_event("cache_red_ttl", key)
454
+ return False
455
+
434
456
  for dep in entry.deps:
435
457
  dep_changed_at = await self.dependency_changed_at_async(dep.key, snapshot)
436
458
  if dep_changed_at != dep.observed_changed_at:
@@ -636,6 +658,7 @@ class Evaluator:
636
658
  deps=frame.deps,
637
659
  effects=frozen_effects,
638
660
  last_access=self._store.next_access_id,
661
+ computed_at_time=start,
639
662
  cycle_nodes=tuple(frame.cycle_nodes),
640
663
  error=error,
641
664
  )
@@ -650,6 +673,7 @@ class Evaluator:
650
673
  deps=frame.deps,
651
674
  effects=frozen_effects,
652
675
  last_access=self._store.next_access_id,
676
+ computed_at_time=start,
653
677
  cycle_nodes=tuple(frame.cycle_nodes),
654
678
  error=error,
655
679
  )
@@ -768,6 +792,7 @@ class Evaluator:
768
792
  deps=frame.deps,
769
793
  effects=frozen_effects,
770
794
  last_access=self._store.next_access_id,
795
+ computed_at_time=start,
771
796
  cycle_nodes=tuple(frame.cycle_nodes),
772
797
  error=error,
773
798
  )
@@ -782,6 +807,7 @@ class Evaluator:
782
807
  deps=frame.deps,
783
808
  effects=frozen_effects,
784
809
  last_access=self._store.next_access_id,
810
+ computed_at_time=start,
785
811
  cycle_nodes=tuple(frame.cycle_nodes),
786
812
  error=error,
787
813
  )
@@ -866,6 +892,7 @@ class Evaluator:
866
892
  deps=observed,
867
893
  effects=effects,
868
894
  last_access=self._store.next_access_id,
895
+ computed_at_time=record.get("computed_at_time", 0.0),
869
896
  error=error,
870
897
  )
871
898
  self._store.drop_memo_locked(key)
@@ -1197,6 +1224,7 @@ class Evaluator:
1197
1224
  "deps": deps_records,
1198
1225
  "effects": effects_node,
1199
1226
  "is_error": memo.error is not None,
1227
+ "computed_at_time": memo.computed_at_time,
1200
1228
  }
1201
1229
  disk.store_entry(_disk_cache.entry_key(kind, fid, args_blob), record, memo.value_hash, value_blob)
1202
1230
  except PersistentCacheError:
@@ -16,7 +16,7 @@ from typing import Any
16
16
 
17
17
  from ._state import Dependency, InputVersion, MemoEntry, TraceEvent
18
18
 
19
- PERSISTENCE_FORMAT = 1
19
+ PERSISTENCE_FORMAT = 2
20
20
 
21
21
  # Used for canonical key ordering; kept separate so tests can monkeypatch `json.dumps`
22
22
  # on this module without breaking recursive sorting inside `_to_jsonable`.
@@ -168,6 +168,7 @@ def _to_jsonable(obj: Any) -> Any:
168
168
  "deps": _to_jsonable(list(obj.deps)),
169
169
  "effects": _to_jsonable(obj.effects),
170
170
  "last_access": obj.last_access,
171
+ "computed_at_time": _to_jsonable(obj.computed_at_time),
171
172
  }
172
173
  }
173
174
 
@@ -290,6 +291,7 @@ def _from_jsonable(obj: Any) -> Any:
290
291
  deps=tuple(deps_raw),
291
292
  effects=_from_jsonable(d["effects"]),
292
293
  last_access=d["last_access"],
294
+ computed_at_time=_from_jsonable(d.get("computed_at_time", 0.0)),
293
295
  )
294
296
  if sole_key == "__dataclass__":
295
297
  d = sole_val
@@ -31,6 +31,7 @@ class MemoEntry:
31
31
  deps: tuple[Dependency, ...]
32
32
  effects: dict[str, tuple[Any, ...]]
33
33
  last_access: int
34
+ computed_at_time: float = 0.0
34
35
  cycle_nodes: tuple[QueryKey, ...] = ()
35
36
  error: BaseException | None = None
36
37
 
@@ -49,6 +49,7 @@ class GraphStore:
49
49
  self.query_memoize: dict[str, bool] = {}
50
50
  self.query_fixed_points: dict[str, Any] = {}
51
51
  self.query_cache_exceptions: dict[str, bool | tuple[type[BaseException], ...]] = {}
52
+ self.query_ttl: dict[str, float | None] = {}
52
53
  self.input_fns: dict[str, Callable[..., Any]] = {}
53
54
  self.memos: dict[QueryKey, MemoEntry] = {}
54
55
  self.dependents: dict[QueryKey, set[QueryKey]] = defaultdict(set)
@@ -106,7 +107,7 @@ class GraphStore:
106
107
  sk = self.key_to_str(key)
107
108
  self._stats_by_key[sk] = self._stats_by_key.get(sk, 0.0) + seconds
108
109
 
109
- def register_query(self, query_id: str, fn: Callable[..., Any], *, memoize: bool = True, fixed_point: Any = None, has_fixed_point: bool = False, cache_exceptions: bool | tuple[type[BaseException], ...] = False) -> None:
110
+ def register_query(self, query_id: str, fn: Callable[..., Any], *, memoize: bool = True, fixed_point: Any = None, has_fixed_point: bool = False, cache_exceptions: bool | tuple[type[BaseException], ...] = False, ttl: float | None = None) -> None:
110
111
  fn_hash = hash_bytecode(fn)
111
112
  with self.lock:
112
113
  if query_id in self.query_hashes and self.query_hashes[query_id] != fn_hash:
@@ -117,9 +118,14 @@ class GraphStore:
117
118
  self.query_hashes[query_id] = fn_hash
118
119
  self.query_memoize[query_id] = memoize
119
120
  self.query_cache_exceptions[query_id] = cache_exceptions
121
+ self.query_ttl[query_id] = ttl
120
122
  if has_fixed_point:
121
123
  self.query_fixed_points[query_id] = fixed_point
122
124
 
125
+ def lookup_query_ttl(self, query_id: str) -> float | None:
126
+ with self.lock:
127
+ return self.query_ttl.get(query_id)
128
+
123
129
  def lookup_query_memoize(self, query_id: str) -> bool:
124
130
  with self.lock:
125
131
  return self.query_memoize.get(query_id, True)
@@ -495,6 +501,7 @@ class GraphStore:
495
501
  deps: dict[QueryKey, int],
496
502
  effects: dict[str, tuple[Any, ...]],
497
503
  last_access: int,
504
+ computed_at_time: float,
498
505
  cycle_nodes: tuple[QueryKey, ...] = (),
499
506
  error: BaseException | None = None,
500
507
  ) -> MemoEntry:
@@ -506,6 +513,7 @@ class GraphStore:
506
513
  deps=tuple(Dependency(dep_key, observed) for dep_key, observed in deps.items()),
507
514
  effects=effects,
508
515
  last_access=last_access,
516
+ computed_at_time=computed_at_time,
509
517
  cycle_nodes=cycle_nodes,
510
518
  error=error,
511
519
  )
@@ -284,11 +284,11 @@ class Engine:
284
284
  self._store.register_input(handle.id, fn)
285
285
  return handle
286
286
 
287
- def query(self, fn: Callable[..., Any] | None = None, *, memoize: bool = True, fixed_point: Any = _UNSET, cache_exceptions: bool | tuple[type[BaseException], ...] = True) -> Any:
287
+ def query(self, fn: Callable[..., Any] | None = None, *, memoize: bool = True, fixed_point: Any = _UNSET, cache_exceptions: bool | tuple[type[BaseException], ...] = True, ttl: float | None = None) -> Any:
288
288
  def decorator(f: Callable[..., Any]) -> _QueryHandle:
289
289
  handle = _QueryHandle(self, f)
290
290
  has_fixed_point = fixed_point is not _UNSET
291
- self._store.register_query(handle.id, f, memoize=memoize, fixed_point=fixed_point, has_fixed_point=has_fixed_point, cache_exceptions=cache_exceptions)
291
+ self._store.register_query(handle.id, f, memoize=memoize, fixed_point=fixed_point, has_fixed_point=has_fixed_point, cache_exceptions=cache_exceptions, ttl=ttl)
292
292
  return handle
293
293
  if fn is not None:
294
294
  return decorator(fn)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: query-cascade
3
- Version: 0.3.7
3
+ Version: 0.3.8
4
4
  Summary: Minimal demand-driven query framework for incremental computation.
5
5
  Requires-Python: >=3.12
6
6
  Description-Content-Type: text/markdown
@@ -139,6 +139,16 @@ A few things to know:
139
139
 
140
140
  ## Advanced Features
141
141
 
142
+ ### Time-To-Live Cache Invalidation
143
+ Cascade supports expiring cache entries automatically based on wall-clock time. By passing the `ttl` flag to `@engine.query`, you can guarantee that a node will recompute its value if it is accessed after the specified number of seconds has elapsed since its last evaluation.
144
+
145
+ ```python
146
+ @engine.query(ttl=5.0)
147
+ def fetch_external_data():
148
+ return request.get("https://api.example.com/data")
149
+ ```
150
+ When a TTL expires, Cascade evaluates the function again. If the newly returned value is identical to the previous one, downstream queries are **not** re-evaluated—early bail-out works exactly as it does for regular dependency changes.
151
+
142
152
  ### Asynchronous Execution
143
153
  Cascade natively supports asynchronous queries via `async def`. This is extremely useful for I/O-bound workflows (such as making database or network calls). When an asynchronous query evaluates, it runs cooperatively on the active `asyncio` event loop.
144
154
 
@@ -264,6 +274,7 @@ pip install query-cascade
264
274
  |--------|----------------|
265
275
  | `compiler_pipeline.py` | `source → parse → symbols → typecheck`, warnings accumulator, cache-hit narration |
266
276
  | `async_execution.py` | Asynchronous query evaluation and asyncio event loop integration for IO-bound work |
277
+ | `ttl_invalidation.py` | Expiring stale query caches automatically based on wall-clock time |
267
278
  | `error_caching.py` | Basic exception caching to prevent repeated re-evaluation on failure |
268
279
  | `error_caching_persistence.py` | Disk cache hydration of exceptions across process runs |
269
280
  | `code_versioning.py` | Automatic cache invalidation when a function's bytecode logic changes |
@@ -45,4 +45,5 @@ tests/test_serde.py
45
45
  tests/test_stateful_engine_invariants.py
46
46
  tests/test_store_coverage.py
47
47
  tests/test_subgraph_stats_export.py
48
- tests/test_sweep_unaccessed.py
48
+ tests/test_sweep_unaccessed.py
49
+ tests/test_ttl.py
@@ -0,0 +1,63 @@
1
+ from cascade import Engine
2
+
3
+ def test_ttl_invalidation_basic():
4
+ engine = Engine()
5
+
6
+ clock = 0.0
7
+ engine._store.monotonic_seconds = lambda: clock
8
+
9
+ compute_count = 0
10
+
11
+ @engine.query(ttl=2.0)
12
+ def my_query():
13
+ nonlocal compute_count
14
+ compute_count += 1
15
+ return "value"
16
+
17
+ assert my_query() == "value"
18
+ assert compute_count == 1
19
+
20
+ # Still within TTL
21
+ clock = 1.0
22
+ assert my_query() == "value"
23
+ assert compute_count == 1
24
+
25
+ # Exceed TTL
26
+ clock = 3.0
27
+ assert my_query() == "value"
28
+ assert compute_count == 2
29
+
30
+ # Still within new TTL
31
+ clock = 4.0
32
+ assert my_query() == "value"
33
+ assert compute_count == 2
34
+
35
+ def test_ttl_invalidation_async():
36
+ import asyncio
37
+ engine = Engine()
38
+
39
+ clock = 0.0
40
+ engine._store.monotonic_seconds = lambda: clock
41
+
42
+ compute_count = 0
43
+
44
+ @engine.query(ttl=1.5)
45
+ async def my_async_query():
46
+ nonlocal compute_count
47
+ compute_count += 1
48
+ return "async_value"
49
+
50
+ async def run():
51
+ nonlocal clock
52
+ assert await my_async_query() == "async_value"
53
+ assert compute_count == 1
54
+
55
+ clock = 1.0
56
+ assert await my_async_query() == "async_value"
57
+ assert compute_count == 1
58
+
59
+ clock = 2.0
60
+ assert await my_async_query() == "async_value"
61
+ assert compute_count == 2
62
+
63
+ asyncio.run(run())
File without changes