matview-cache 0.1.0__py3-none-any.whl
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.
- matview/__init__.py +78 -0
- matview/cache.py +750 -0
- matview/canon.py +116 -0
- matview/cli.py +900 -0
- matview/codecs/__init__.py +159 -0
- matview/codecs/_arrow.py +104 -0
- matview/codecs/_arrow_ipc.py +89 -0
- matview/codecs/_arrow_pandas.py +76 -0
- matview/codecs/_arrow_parts.py +99 -0
- matview/codecs/_json.py +36 -0
- matview/codecs/_npy.py +53 -0
- matview/context.py +87 -0
- matview/errors.py +96 -0
- matview/fingerprint.py +99 -0
- matview/foreign.py +436 -0
- matview/graph.py +876 -0
- matview/policy.py +67 -0
- matview/py.typed +0 -0
- matview/registry.py +293 -0
- matview/store/__init__.py +6 -0
- matview/store/base.py +99 -0
- matview/store/sqlite.py +1188 -0
- matview/types.py +341 -0
- matview_cache-0.1.0.dist-info/METADATA +262 -0
- matview_cache-0.1.0.dist-info/RECORD +28 -0
- matview_cache-0.1.0.dist-info/WHEEL +4 -0
- matview_cache-0.1.0.dist-info/entry_points.txt +2 -0
- matview_cache-0.1.0.dist-info/licenses/LICENSE +21 -0
matview/cache.py
ADDED
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
"""The scheduler: a suspending scheduler with constructive traces.
|
|
2
|
+
|
|
3
|
+
Plain recursion. The walk carries an explicit stack of in-flight
|
|
4
|
+
`(recipe, key_hash, part)` triples; re-entry is a cycle and raises with the full path.
|
|
5
|
+
Graph depth in practice is single digits, so coroutines or trampolining would be
|
|
6
|
+
unnecessary complexity.
|
|
7
|
+
|
|
8
|
+
An artifact is in exactly one of three states, and conflating the first two is the
|
|
9
|
+
single most common way to get this wrong:
|
|
10
|
+
|
|
11
|
+
absent no row must compute
|
|
12
|
+
stale row exists, valid = 0 must verify; may or may not recompute
|
|
13
|
+
valid row exists, valid = 1 return it
|
|
14
|
+
|
|
15
|
+
Stale is not absent. Reading a stale artifact resolves its dependencies (usually
|
|
16
|
+
already valid, so this costs nothing), recomputes the fingerprint, and compares it to
|
|
17
|
+
the stored one. If they match, the flag flips back and the existing bytes are returned
|
|
18
|
+
without recomputing. That is early cutoff, and it is the entire payoff of digest-based
|
|
19
|
+
fingerprints over version-based ones.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import contextlib
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import time
|
|
28
|
+
from collections.abc import Mapping, Sequence
|
|
29
|
+
from dataclasses import dataclass, field, replace
|
|
30
|
+
from types import MappingProxyType
|
|
31
|
+
from typing import Any, Literal
|
|
32
|
+
|
|
33
|
+
from matview.canon import Scalar, utf16_sort_key
|
|
34
|
+
from matview.codecs import Codec, get_codec
|
|
35
|
+
from matview.context import Context
|
|
36
|
+
from matview.errors import (
|
|
37
|
+
CodecError,
|
|
38
|
+
CycleError,
|
|
39
|
+
MatviewError,
|
|
40
|
+
MissingArtifacts,
|
|
41
|
+
RecipeNotFound,
|
|
42
|
+
)
|
|
43
|
+
from matview.fingerprint import canonical_key_hash, digest_bytes, fingerprint
|
|
44
|
+
from matview.policy import Policy
|
|
45
|
+
from matview.registry import REGISTRY, Recipe, Registry
|
|
46
|
+
from matview.store.base import Store
|
|
47
|
+
from matview.types import (
|
|
48
|
+
Artifact,
|
|
49
|
+
ArtifactRef,
|
|
50
|
+
BatchResult,
|
|
51
|
+
CacheStats,
|
|
52
|
+
DepEdge,
|
|
53
|
+
MissingNode,
|
|
54
|
+
OnMiss,
|
|
55
|
+
PlanReport,
|
|
56
|
+
Request,
|
|
57
|
+
stricter,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
__all__ = ["Cache"]
|
|
61
|
+
|
|
62
|
+
#: One event per node resolved, at DEBUG. No handler is installed: a library that
|
|
63
|
+
#: configures logging is a library that fights its host.
|
|
64
|
+
log = logging.getLogger("matview.scheduler")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _Blocked(Exception):
|
|
68
|
+
"""Internal. A node could not be produced under the effective policy.
|
|
69
|
+
|
|
70
|
+
Never escapes a top-level call: it is converted into a batched `MissingArtifacts`
|
|
71
|
+
once the walk has collected everything it can reach.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class _Walk:
|
|
77
|
+
"""State for one top-level call."""
|
|
78
|
+
|
|
79
|
+
mode: Literal["compute", "plan"]
|
|
80
|
+
policy: Policy
|
|
81
|
+
path: list[str] = field(default_factory=list)
|
|
82
|
+
in_flight: set[tuple[str, str, str | None]] = field(default_factory=set)
|
|
83
|
+
memo: dict[tuple[str, str, str | None], list[Artifact]] = field(default_factory=dict)
|
|
84
|
+
missing: list[MissingNode] = field(default_factory=list)
|
|
85
|
+
missing_seen: set[tuple[str, str, str | None]] = field(default_factory=set)
|
|
86
|
+
expired_seen: set[tuple[str, str, str | None]] = field(default_factory=set)
|
|
87
|
+
stale: list[ArtifactRef] = field(default_factory=list)
|
|
88
|
+
current: list[ArtifactRef] = field(default_factory=list)
|
|
89
|
+
nondeterministic: set[str] = field(default_factory=set)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _label(recipe: Recipe, key: Mapping[str, Scalar], part: str | None) -> str:
|
|
93
|
+
params = ", ".join(f"{k}={v!r}" for k, v in sorted(key.items()))
|
|
94
|
+
text = f"{recipe.name}(v{recipe.version})[{params}]"
|
|
95
|
+
return text if part is None else f"{text} part={part!r}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Cache:
|
|
99
|
+
"""Ask for a value; get the stored one if it is current, and compute it if not.
|
|
100
|
+
|
|
101
|
+
Not global, deliberately. The decorator registry being global will tempt an
|
|
102
|
+
implementer into making this global too; it must not be.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, store: Store, *, registry: Registry | None = None) -> None:
|
|
106
|
+
self.store = store
|
|
107
|
+
self.registry = REGISTRY if registry is None else registry
|
|
108
|
+
#: Cumulative counters across every call on this cache. See `CacheStats`.
|
|
109
|
+
self.stats = CacheStats()
|
|
110
|
+
|
|
111
|
+
# -- public API ---------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def get(
|
|
114
|
+
self,
|
|
115
|
+
recipe: str,
|
|
116
|
+
*,
|
|
117
|
+
part: str | None = None,
|
|
118
|
+
on_miss: OnMiss = "compute",
|
|
119
|
+
force: bool = False,
|
|
120
|
+
force_deps: bool = False,
|
|
121
|
+
deadline: float | None = None,
|
|
122
|
+
**params: Any,
|
|
123
|
+
) -> Any:
|
|
124
|
+
"""Return the decoded value.
|
|
125
|
+
|
|
126
|
+
The codec is a property of the recipe, so decoding is a lookup and never an
|
|
127
|
+
inference.
|
|
128
|
+
"""
|
|
129
|
+
data, codec = self._top_level(recipe, params, part, on_miss, force, force_deps, deadline)
|
|
130
|
+
return codec.decode(data) if isinstance(data, bytes) else codec.decode_parts(data)
|
|
131
|
+
|
|
132
|
+
def get_bytes(
|
|
133
|
+
self,
|
|
134
|
+
recipe: str,
|
|
135
|
+
*,
|
|
136
|
+
part: str | None = None,
|
|
137
|
+
on_miss: OnMiss = "compute",
|
|
138
|
+
force: bool = False,
|
|
139
|
+
force_deps: bool = False,
|
|
140
|
+
deadline: float | None = None,
|
|
141
|
+
**params: Any,
|
|
142
|
+
) -> bytes | dict[str, bytes]:
|
|
143
|
+
"""The stored bytes.
|
|
144
|
+
|
|
145
|
+
The escape hatch, and the one to use for replication and any future HTTP
|
|
146
|
+
serving: decode-then-reencode both wastes work and risks failing to round-trip.
|
|
147
|
+
"""
|
|
148
|
+
return self._top_level(recipe, params, part, on_miss, force, force_deps, deadline)[0]
|
|
149
|
+
|
|
150
|
+
def get_many(
|
|
151
|
+
self,
|
|
152
|
+
requests: Sequence[Request | tuple[Any, ...]],
|
|
153
|
+
*,
|
|
154
|
+
on_miss: OnMiss = "compute",
|
|
155
|
+
force: bool = False,
|
|
156
|
+
force_deps: bool = False,
|
|
157
|
+
deadline: float | None = None,
|
|
158
|
+
) -> BatchResult:
|
|
159
|
+
"""Resolve many roots in one walk.
|
|
160
|
+
|
|
161
|
+
This is what makes the spec's story about cold runs true. A recipe blocked on
|
|
162
|
+
its first `ctx.get` cannot continue, so a single root reports one protected
|
|
163
|
+
miss and stops — but five hundred *independent* roots in one walk report
|
|
164
|
+
everything they need between them, which is the difference between one run and
|
|
165
|
+
five hundred.
|
|
166
|
+
|
|
167
|
+
Roots resolve in order, sharing one memo, so a dependency common to several of
|
|
168
|
+
them is computed once. They are not run in parallel: the scheduler is plain
|
|
169
|
+
recursion by design, and making the memo, the in-flight set and the miss
|
|
170
|
+
accumulator concurrent would change its character. See DECISIONS.md D33.
|
|
171
|
+
|
|
172
|
+
A protected miss is a policy outcome and is collected. An exception raised by a
|
|
173
|
+
recipe is not, and propagates immediately.
|
|
174
|
+
"""
|
|
175
|
+
normalized = tuple(Request.coerce(request) for request in requests)
|
|
176
|
+
policy = Policy.from_call(
|
|
177
|
+
on_miss=on_miss, force=force, force_deps=force_deps, deadline=deadline
|
|
178
|
+
)
|
|
179
|
+
walk = _Walk(mode="compute", policy=policy)
|
|
180
|
+
|
|
181
|
+
values: dict[int, Any] = {}
|
|
182
|
+
for index, request in enumerate(normalized):
|
|
183
|
+
rec = self.registry.get(request.recipe)
|
|
184
|
+
key = rec.bind(request.key)
|
|
185
|
+
try:
|
|
186
|
+
rows = self._resolve(
|
|
187
|
+
walk, rec, key, request.part, force=force, policy=policy, is_root=True
|
|
188
|
+
)
|
|
189
|
+
except _Blocked:
|
|
190
|
+
continue
|
|
191
|
+
payload = self._payload(rows, request.part)
|
|
192
|
+
codec = get_codec(rec.codec)
|
|
193
|
+
values[index] = (
|
|
194
|
+
codec.decode(payload) if isinstance(payload, bytes) else codec.decode_parts(payload)
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
return BatchResult(
|
|
198
|
+
requests=normalized,
|
|
199
|
+
values=MappingProxyType(values),
|
|
200
|
+
missing=tuple(walk.missing),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def plan_many(
|
|
204
|
+
self,
|
|
205
|
+
requests: Sequence[Request | tuple[Any, ...]],
|
|
206
|
+
*,
|
|
207
|
+
on_miss: OnMiss = "compute",
|
|
208
|
+
force_deps: bool = False,
|
|
209
|
+
) -> PlanReport:
|
|
210
|
+
"""Walk many roots without computing, and report on all of them together."""
|
|
211
|
+
policy = Policy.from_call(on_miss=on_miss, force_deps=force_deps)
|
|
212
|
+
walk = _Walk(mode="plan", policy=policy)
|
|
213
|
+
for request in (Request.coerce(r) for r in requests):
|
|
214
|
+
rec = self.registry.get(request.recipe)
|
|
215
|
+
with contextlib.suppress(_Blocked):
|
|
216
|
+
self._resolve(
|
|
217
|
+
walk,
|
|
218
|
+
rec,
|
|
219
|
+
rec.bind(request.key),
|
|
220
|
+
request.part,
|
|
221
|
+
force=False,
|
|
222
|
+
policy=policy,
|
|
223
|
+
is_root=True,
|
|
224
|
+
)
|
|
225
|
+
return PlanReport(
|
|
226
|
+
absent=tuple(walk.missing),
|
|
227
|
+
stale=tuple(walk.stale),
|
|
228
|
+
valid=tuple(walk.current),
|
|
229
|
+
nondeterministic=tuple(sorted(walk.nondeterministic)),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def plan(
|
|
233
|
+
self,
|
|
234
|
+
recipe: str,
|
|
235
|
+
*,
|
|
236
|
+
part: str | None = None,
|
|
237
|
+
on_miss: OnMiss = "compute",
|
|
238
|
+
force_deps: bool = False,
|
|
239
|
+
**params: Any,
|
|
240
|
+
) -> PlanReport:
|
|
241
|
+
"""Walk the graph without computing anything.
|
|
242
|
+
|
|
243
|
+
Absent and stale are reported separately, because stale work is often near-free
|
|
244
|
+
while absent work at a protected root means you cannot proceed at all.
|
|
245
|
+
|
|
246
|
+
The walk stops at an absent Python node, necessarily: its dependencies are only
|
|
247
|
+
knowable by running it. A foreign recipe declares its inputs, so the walk
|
|
248
|
+
continues through it — which is why the spec calls the declared graph the
|
|
249
|
+
privileged case.
|
|
250
|
+
"""
|
|
251
|
+
return self.plan_many(
|
|
252
|
+
[Request(recipe, params, part)], on_miss=on_miss, force_deps=force_deps
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def query(self, recipe: str | None = None, **indexed_fields: Any) -> list[ArtifactRef]:
|
|
256
|
+
return self.store.query(recipe, **indexed_fields)
|
|
257
|
+
|
|
258
|
+
def drop(self, recipe: str, version: int | None = None) -> int:
|
|
259
|
+
"""Remove one recipe version's artifacts. See DECISIONS.md D19."""
|
|
260
|
+
return self.store.drop(recipe, version)
|
|
261
|
+
|
|
262
|
+
def gc(self) -> int:
|
|
263
|
+
"""Drop superseded versions with no dependents."""
|
|
264
|
+
return self.store.gc()
|
|
265
|
+
|
|
266
|
+
# -- walk ---------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
def _top_level(
|
|
269
|
+
self,
|
|
270
|
+
recipe: str,
|
|
271
|
+
params: dict[str, Any],
|
|
272
|
+
part: str | None,
|
|
273
|
+
on_miss: OnMiss,
|
|
274
|
+
force: bool,
|
|
275
|
+
force_deps: bool,
|
|
276
|
+
deadline: float | None,
|
|
277
|
+
) -> tuple[bytes | dict[str, bytes], Codec]:
|
|
278
|
+
rec = self.registry.get(recipe)
|
|
279
|
+
policy = Policy.from_call(
|
|
280
|
+
on_miss=on_miss, force=force, force_deps=force_deps, deadline=deadline
|
|
281
|
+
)
|
|
282
|
+
walk = _Walk(mode="compute", policy=policy)
|
|
283
|
+
key = rec.bind(params)
|
|
284
|
+
try:
|
|
285
|
+
rows = self._resolve(walk, rec, key, part, force=force, policy=policy, is_root=True)
|
|
286
|
+
except _Blocked:
|
|
287
|
+
raise MissingArtifacts(walk.missing) from None
|
|
288
|
+
if walk.missing:
|
|
289
|
+
# Reached when a protected node was skipped but the value was still
|
|
290
|
+
# obtainable. Report it anyway: the caller asked for something that could
|
|
291
|
+
# not be fully honoured.
|
|
292
|
+
raise MissingArtifacts(walk.missing)
|
|
293
|
+
return self._payload(rows, part), get_codec(rec.codec)
|
|
294
|
+
|
|
295
|
+
def _resolve_dependency(
|
|
296
|
+
self,
|
|
297
|
+
walk: _Walk,
|
|
298
|
+
policy: Policy,
|
|
299
|
+
recipe: str,
|
|
300
|
+
params: dict[str, Any],
|
|
301
|
+
part: str | None,
|
|
302
|
+
on_miss: OnMiss | None,
|
|
303
|
+
) -> tuple[list[Artifact], Codec]:
|
|
304
|
+
"""What `ctx.get` calls.
|
|
305
|
+
|
|
306
|
+
The policy handed down is the caller's, narrowed by any `on_miss` on this
|
|
307
|
+
particular `ctx.get`. It is *not* the parent recipe's own declaration: a recipe
|
|
308
|
+
declaring `on_miss="raise"` protects itself, not everything beneath it.
|
|
309
|
+
"""
|
|
310
|
+
rec = self.registry.get(recipe)
|
|
311
|
+
policy = policy.narrow(on_miss)
|
|
312
|
+
key = rec.bind(params)
|
|
313
|
+
rows = self._resolve(walk, rec, key, part, force=False, policy=policy)
|
|
314
|
+
return rows, get_codec(rec.codec)
|
|
315
|
+
|
|
316
|
+
def _resolve(
|
|
317
|
+
self,
|
|
318
|
+
walk: _Walk,
|
|
319
|
+
rec: Recipe,
|
|
320
|
+
key: Mapping[str, Scalar],
|
|
321
|
+
part: str | None,
|
|
322
|
+
*,
|
|
323
|
+
force: bool,
|
|
324
|
+
policy: Policy,
|
|
325
|
+
is_root: bool = False,
|
|
326
|
+
) -> list[Artifact]:
|
|
327
|
+
key_json, key_hash = canonical_key_hash(key)
|
|
328
|
+
node = (rec.name, key_hash, part)
|
|
329
|
+
label = _label(rec, key, part)
|
|
330
|
+
|
|
331
|
+
if node in walk.in_flight:
|
|
332
|
+
raise CycleError([*walk.path, label])
|
|
333
|
+
# Each descent checks the remaining budget before going any deeper.
|
|
334
|
+
policy.check(label)
|
|
335
|
+
|
|
336
|
+
# The path is pushed here rather than in _compute, so that it is accurate
|
|
337
|
+
# during a stale verification as well, where dependencies are resolved without
|
|
338
|
+
# anything being computed.
|
|
339
|
+
walk.path.append(label)
|
|
340
|
+
try:
|
|
341
|
+
return self._resolve_node(
|
|
342
|
+
walk,
|
|
343
|
+
rec,
|
|
344
|
+
key,
|
|
345
|
+
key_json,
|
|
346
|
+
key_hash,
|
|
347
|
+
part,
|
|
348
|
+
node,
|
|
349
|
+
force=force,
|
|
350
|
+
policy=policy,
|
|
351
|
+
is_root=is_root,
|
|
352
|
+
)
|
|
353
|
+
finally:
|
|
354
|
+
walk.path.pop()
|
|
355
|
+
|
|
356
|
+
def _resolve_node(
|
|
357
|
+
self,
|
|
358
|
+
walk: _Walk,
|
|
359
|
+
rec: Recipe,
|
|
360
|
+
key: Mapping[str, Scalar],
|
|
361
|
+
key_json: str,
|
|
362
|
+
key_hash: str,
|
|
363
|
+
part: str | None,
|
|
364
|
+
node: tuple[str, str, str | None],
|
|
365
|
+
*,
|
|
366
|
+
force: bool,
|
|
367
|
+
policy: Policy,
|
|
368
|
+
is_root: bool,
|
|
369
|
+
) -> list[Artifact]:
|
|
370
|
+
"""The body of `_resolve`, with the cycle, deadline and path bookkeeping done."""
|
|
371
|
+
if node in walk.memo:
|
|
372
|
+
return walk.memo[node]
|
|
373
|
+
|
|
374
|
+
if not rec.deterministic:
|
|
375
|
+
walk.nondeterministic.add(rec.name)
|
|
376
|
+
|
|
377
|
+
force_this = force or policy.force_deps
|
|
378
|
+
rows = self._fetch(rec, key_hash, part)
|
|
379
|
+
stale_and_wrong = False
|
|
380
|
+
expired = bool(
|
|
381
|
+
rows
|
|
382
|
+
and rec.max_age is not None
|
|
383
|
+
and int(time.time()) >= min(row.refreshed_at for row in rows) + rec.max_age
|
|
384
|
+
)
|
|
385
|
+
if expired and node not in walk.expired_seen:
|
|
386
|
+
# Counted as a reason rather than an outcome: what follows is also counted
|
|
387
|
+
# under recomputed or blocked. See DECISIONS.md D35.
|
|
388
|
+
#
|
|
389
|
+
# Deduplicated per walk, like the missing set, because one node can be
|
|
390
|
+
# reached twice in a single walk: a blocked node is never memoized, so a
|
|
391
|
+
# stale dependent resolves it once while verifying and again from its own
|
|
392
|
+
# body. Counting that twice would overstate how much a clock actually drove.
|
|
393
|
+
walk.expired_seen.add(node)
|
|
394
|
+
self.stats.expired += 1
|
|
395
|
+
log.debug(
|
|
396
|
+
"expired %s (max_age %ds elapsed since its last refresh)",
|
|
397
|
+
walk.path[-1],
|
|
398
|
+
rec.max_age,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
if rows and not force_this and not expired:
|
|
402
|
+
if all(row.valid for row in rows):
|
|
403
|
+
self.stats.hits += 1
|
|
404
|
+
log.debug("hit %s", walk.path[-1])
|
|
405
|
+
walk.current.extend(_as_refs(rows))
|
|
406
|
+
if walk.mode == "plan":
|
|
407
|
+
self._walk_stored_deps(walk, rows, policy)
|
|
408
|
+
return self._memoize(walk, node, rows)
|
|
409
|
+
|
|
410
|
+
walk.stale.extend(_as_refs(row for row in rows if not row.valid))
|
|
411
|
+
if self._verify(walk, rec, rows, policy):
|
|
412
|
+
for row in rows:
|
|
413
|
+
if not row.valid:
|
|
414
|
+
self.store.revalidate(row.id or 0)
|
|
415
|
+
rows = [replace(row, valid=True) for row in rows]
|
|
416
|
+
self.stats.revalidated += 1
|
|
417
|
+
# Early cutoff doing its job: stale, verified, and not rebuilt.
|
|
418
|
+
log.debug("revalidated %s (fingerprint unchanged, not recomputed)", walk.path[-1])
|
|
419
|
+
return self._memoize(walk, node, rows)
|
|
420
|
+
stale_and_wrong = True
|
|
421
|
+
|
|
422
|
+
if expired:
|
|
423
|
+
walk.stale.extend(replace(ref, valid=False) for ref in _as_refs(rows))
|
|
424
|
+
|
|
425
|
+
# Everything below here needs the value produced.
|
|
426
|
+
#
|
|
427
|
+
# The ceiling protects a recipe "everywhere it appears as a transitive
|
|
428
|
+
# dependency", which is exactly where the danger is: a backtester asking for
|
|
429
|
+
# sharpe with on_miss="compute" must not fire a paid vendor request six levels
|
|
430
|
+
# down that it never mentioned. A caller naming the recipe *directly* is the
|
|
431
|
+
# opposite case, and the spec's own ingest example depends on it working:
|
|
432
|
+
# `cache.get("ohlcv", symbol=..., on_miss="compute")` reads as deliberate
|
|
433
|
+
# intent. So the recipe's declaration is a ceiling below the root, and the
|
|
434
|
+
# caller's ask governs at it. See DECISIONS.md D27.
|
|
435
|
+
effective = policy.on_miss if is_root else stricter(policy.on_miss, rec.on_miss)
|
|
436
|
+
|
|
437
|
+
if walk.mode == "plan":
|
|
438
|
+
if not rows:
|
|
439
|
+
# A Python recipe's dependencies are only knowable by running it, so an
|
|
440
|
+
# absent one is where the walk stops. A foreign recipe declares its
|
|
441
|
+
# inputs, so the walk goes through it — which is the concrete payoff of
|
|
442
|
+
# the spec calling the declared graph the privileged case.
|
|
443
|
+
if rec.foreign is not None:
|
|
444
|
+
self._walk_declared_inputs(walk, rec, key, policy)
|
|
445
|
+
self._record_missing(walk, rec, key, part, effective)
|
|
446
|
+
raise _Blocked
|
|
447
|
+
return self._memoize(walk, node, rows)
|
|
448
|
+
|
|
449
|
+
if effective != "compute":
|
|
450
|
+
if rows and not stale_and_wrong and not expired:
|
|
451
|
+
# A forced recompute of a protected node is skipped, not an error: the
|
|
452
|
+
# stored value is still there and still current. The ceiling applies
|
|
453
|
+
# under force_deps too, which is exactly what "protected roots are
|
|
454
|
+
# skipped even then" means.
|
|
455
|
+
return self._memoize(walk, node, rows)
|
|
456
|
+
if effective == "enqueue":
|
|
457
|
+
self.store.enqueue(rec.name, rec.version, key_json, part, int(time.time()))
|
|
458
|
+
self.stats.enqueued += 1
|
|
459
|
+
self._record_missing(walk, rec, key, part, effective)
|
|
460
|
+
raise _Blocked
|
|
461
|
+
|
|
462
|
+
log.debug("recomputing %s", walk.path[-1])
|
|
463
|
+
rows = self._compute(walk, rec, key, key_json, key_hash, part, policy)
|
|
464
|
+
self.stats.recomputed += 1
|
|
465
|
+
if part is not None:
|
|
466
|
+
rows = [row for row in rows if row.part == part]
|
|
467
|
+
if not rows:
|
|
468
|
+
raise CodecError(
|
|
469
|
+
f"{rec.name} was asked for part {part!r} but its codec produced no such part"
|
|
470
|
+
)
|
|
471
|
+
return self._memoize(walk, node, rows)
|
|
472
|
+
|
|
473
|
+
def _fetch(self, rec: Recipe, key_hash: str, part: str | None) -> list[Artifact]:
|
|
474
|
+
rows = self.store.fetch_all_parts(rec.name, rec.version, key_hash)
|
|
475
|
+
if part is None:
|
|
476
|
+
return rows
|
|
477
|
+
return [row for row in rows if row.part == part]
|
|
478
|
+
|
|
479
|
+
def _compute(
|
|
480
|
+
self,
|
|
481
|
+
walk: _Walk,
|
|
482
|
+
rec: Recipe,
|
|
483
|
+
key: Mapping[str, Scalar],
|
|
484
|
+
key_json: str,
|
|
485
|
+
key_hash: str,
|
|
486
|
+
part: str | None,
|
|
487
|
+
policy: Policy,
|
|
488
|
+
) -> list[Artifact]:
|
|
489
|
+
node = (rec.name, key_hash, part)
|
|
490
|
+
collected: list[DepEdge] = []
|
|
491
|
+
ctx = Context(self, walk, policy, collected)
|
|
492
|
+
|
|
493
|
+
walk.in_flight.add(node)
|
|
494
|
+
started = time.monotonic()
|
|
495
|
+
try:
|
|
496
|
+
if rec.foreign is not None:
|
|
497
|
+
# A foreign recipe's inputs are declared, not discovered, but they are
|
|
498
|
+
# resolved through the same ctx, so the ceiling, the deadline and the
|
|
499
|
+
# dep edges all behave identically.
|
|
500
|
+
from matview.foreign import run_foreign
|
|
501
|
+
|
|
502
|
+
value = run_foreign(self, walk, rec, key, ctx, collected, policy)
|
|
503
|
+
elif rec.fn is not None:
|
|
504
|
+
value = rec.fn(ctx, **key)
|
|
505
|
+
else: # pragma: no cover - registration accepts neither
|
|
506
|
+
raise MatviewError(f"{rec.name} has neither a function nor a manifest")
|
|
507
|
+
finally:
|
|
508
|
+
walk.in_flight.discard(node)
|
|
509
|
+
# The recipe body's own cost, with dependency resolution attributed to the
|
|
510
|
+
# dependency rather than to this node. See DECISIONS.md D6.
|
|
511
|
+
elapsed = time.monotonic() - started - ctx.dep_seconds
|
|
512
|
+
|
|
513
|
+
# A failed compute raised above and is never cached, in any form.
|
|
514
|
+
return self._persist(rec, key, key_json, key_hash, value, collected, elapsed)
|
|
515
|
+
|
|
516
|
+
def _persist(
|
|
517
|
+
self,
|
|
518
|
+
rec: Recipe,
|
|
519
|
+
key: Mapping[str, Scalar],
|
|
520
|
+
key_json: str,
|
|
521
|
+
key_hash: str,
|
|
522
|
+
value: Any,
|
|
523
|
+
deps: Sequence[DepEdge],
|
|
524
|
+
elapsed: float,
|
|
525
|
+
) -> list[Artifact]:
|
|
526
|
+
codec = get_codec(rec.codec)
|
|
527
|
+
encoded = codec.encode(value)
|
|
528
|
+
|
|
529
|
+
if isinstance(encoded, dict):
|
|
530
|
+
if not encoded:
|
|
531
|
+
raise CodecError(
|
|
532
|
+
f"codec {rec.codec!r} split {rec.name}'s value into zero parts, which "
|
|
533
|
+
f"would store nothing at all"
|
|
534
|
+
)
|
|
535
|
+
payloads: list[tuple[str | None, bytes]] = sorted(
|
|
536
|
+
((str(k), v) for k, v in encoded.items()), key=lambda kv: utf16_sort_key(kv[0])
|
|
537
|
+
)
|
|
538
|
+
else:
|
|
539
|
+
payloads = [(None, encoded)]
|
|
540
|
+
|
|
541
|
+
now = int(time.time())
|
|
542
|
+
compute_ms = max(0, round(elapsed * 1000))
|
|
543
|
+
index_fields = rec.index_fields(key)
|
|
544
|
+
|
|
545
|
+
artifacts = [
|
|
546
|
+
Artifact(
|
|
547
|
+
recipe=rec.name,
|
|
548
|
+
version=rec.version,
|
|
549
|
+
codec=rec.codec,
|
|
550
|
+
key_hash=key_hash,
|
|
551
|
+
key_json=key_json,
|
|
552
|
+
part=label,
|
|
553
|
+
digest=digest_bytes(data),
|
|
554
|
+
fingerprint=fingerprint(
|
|
555
|
+
recipe=rec.name,
|
|
556
|
+
version=rec.version,
|
|
557
|
+
key_hash=key_hash,
|
|
558
|
+
part=label,
|
|
559
|
+
deps=deps,
|
|
560
|
+
),
|
|
561
|
+
n_bytes=len(data),
|
|
562
|
+
data=data,
|
|
563
|
+
computed_at=now,
|
|
564
|
+
refreshed_at=now,
|
|
565
|
+
# Every part of one value shares the cost of producing that value;
|
|
566
|
+
# the compute happened once.
|
|
567
|
+
compute_ms=compute_ms,
|
|
568
|
+
source_hash=rec.source_hash,
|
|
569
|
+
index_fields=index_fields,
|
|
570
|
+
)
|
|
571
|
+
for label, data in payloads
|
|
572
|
+
]
|
|
573
|
+
|
|
574
|
+
self.stats.artifacts_written += len(artifacts)
|
|
575
|
+
self.stats.bytes_written += sum(a.n_bytes for a in artifacts)
|
|
576
|
+
self.stats.compute_ms += compute_ms
|
|
577
|
+
log.debug(
|
|
578
|
+
"computed %s in %d ms -> %d artifact(s), %d bytes",
|
|
579
|
+
rec.name,
|
|
580
|
+
compute_ms,
|
|
581
|
+
len(artifacts),
|
|
582
|
+
sum(a.n_bytes for a in artifacts),
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
results = self.store.store_parts(artifacts, deps)
|
|
586
|
+
return [replace(a, id=r.artifact_id) for a, r in zip(artifacts, results, strict=True)]
|
|
587
|
+
|
|
588
|
+
# -- stale verification -------------------------------------------------
|
|
589
|
+
|
|
590
|
+
def _verify(self, walk: _Walk, rec: Recipe, rows: Sequence[Artifact], policy: Policy) -> bool:
|
|
591
|
+
"""Recompute the fingerprint from the stored edges and compare.
|
|
592
|
+
|
|
593
|
+
Dependencies are re-resolved at their *current* registered version, which may be
|
|
594
|
+
a different artifact row than the one the edge points at. That is what makes
|
|
595
|
+
early cutoff work across a dependency version bump: if the new version produces
|
|
596
|
+
identical bytes, the digest is unchanged, so this fingerprint matches and
|
|
597
|
+
nothing is recomputed.
|
|
598
|
+
|
|
599
|
+
Every edge is resolved even after one of them is blocked, so a cold run collects
|
|
600
|
+
all of its protected misses in one pass rather than one per attempt.
|
|
601
|
+
"""
|
|
602
|
+
current = True
|
|
603
|
+
# `deps_of`, not `deps_of_many`: `rows` is the parts of ONE node, so there is
|
|
604
|
+
# nothing here to batch. See the note in `_walk_stored_deps`.
|
|
605
|
+
for row in rows:
|
|
606
|
+
edges: list[DepEdge] = []
|
|
607
|
+
blocked = False
|
|
608
|
+
for edge in self.store.deps_of(row.id or 0):
|
|
609
|
+
try:
|
|
610
|
+
dep = self.registry.get(edge.recipe)
|
|
611
|
+
key = json.loads(edge.key_json) if edge.key_json else {}
|
|
612
|
+
resolved = self._resolve(walk, dep, key, edge.part, force=False, policy=policy)
|
|
613
|
+
except _Blocked:
|
|
614
|
+
blocked = True
|
|
615
|
+
continue
|
|
616
|
+
edges.extend(self._edges_for(resolved))
|
|
617
|
+
if blocked:
|
|
618
|
+
current = False
|
|
619
|
+
continue
|
|
620
|
+
recomputed = fingerprint(
|
|
621
|
+
recipe=rec.name,
|
|
622
|
+
version=rec.version,
|
|
623
|
+
key_hash=row.key_hash,
|
|
624
|
+
part=row.part,
|
|
625
|
+
deps=edges,
|
|
626
|
+
)
|
|
627
|
+
if recomputed != row.fingerprint:
|
|
628
|
+
current = False
|
|
629
|
+
return current
|
|
630
|
+
|
|
631
|
+
def _walk_declared_inputs(
|
|
632
|
+
self, walk: _Walk, rec: Recipe, key: Mapping[str, Scalar], policy: Policy
|
|
633
|
+
) -> None:
|
|
634
|
+
"""Plan mode: follow a foreign recipe's manifest without invoking anything."""
|
|
635
|
+
from matview.foreign import substitute_key
|
|
636
|
+
|
|
637
|
+
assert rec.foreign is not None
|
|
638
|
+
for declared in rec.foreign.inputs:
|
|
639
|
+
with contextlib.suppress(_Blocked, RecipeNotFound):
|
|
640
|
+
dep = self.registry.get(declared.recipe)
|
|
641
|
+
self._resolve(
|
|
642
|
+
walk,
|
|
643
|
+
dep,
|
|
644
|
+
dep.bind(substitute_key(declared.key, key)),
|
|
645
|
+
declared.part,
|
|
646
|
+
force=False,
|
|
647
|
+
policy=policy,
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
def _walk_stored_deps(self, walk: _Walk, rows: Sequence[Artifact], policy: Policy) -> None:
|
|
651
|
+
"""Plan mode: follow what a node read last time, to report the whole subgraph."""
|
|
652
|
+
# One `deps_of` per node, and measurably the right call despite appearances.
|
|
653
|
+
# `rows` is the parts of a single node, so batching across it saves nothing: the
|
|
654
|
+
# loop is one iteration for every unpartitioned value. `plan_many` *is* slow
|
|
655
|
+
# against `get_many`, and its N+1 is across the depth-first descent, not across
|
|
656
|
+
# this loop — each node's edges must be read before its children can be
|
|
657
|
+
# resolved, so removing it means restructuring the walk level by level rather
|
|
658
|
+
# than swapping a store call. Measured both ways; see DECISIONS.md D52.
|
|
659
|
+
for row in rows:
|
|
660
|
+
for edge in self.store.deps_of(row.id or 0):
|
|
661
|
+
try:
|
|
662
|
+
dep = self.registry.get(edge.recipe)
|
|
663
|
+
key = json.loads(edge.key_json) if edge.key_json else {}
|
|
664
|
+
self._resolve(walk, dep, key, edge.part, force=False, policy=policy)
|
|
665
|
+
except _Blocked:
|
|
666
|
+
continue
|
|
667
|
+
|
|
668
|
+
# -- helpers ------------------------------------------------------------
|
|
669
|
+
|
|
670
|
+
def _edges_for(self, rows: Sequence[Artifact]) -> list[DepEdge]:
|
|
671
|
+
return [
|
|
672
|
+
DepEdge(
|
|
673
|
+
recipe=row.recipe,
|
|
674
|
+
version=row.version,
|
|
675
|
+
key_hash=row.key_hash,
|
|
676
|
+
part=row.part,
|
|
677
|
+
digest=row.digest,
|
|
678
|
+
artifact_id=row.id or 0,
|
|
679
|
+
key_json=row.key_json,
|
|
680
|
+
)
|
|
681
|
+
for row in rows
|
|
682
|
+
]
|
|
683
|
+
|
|
684
|
+
def _payload(self, rows: Sequence[Artifact], part: str | None) -> bytes | dict[str, bytes]:
|
|
685
|
+
if part is not None or (len(rows) == 1 and rows[0].part is None):
|
|
686
|
+
return rows[0].data
|
|
687
|
+
return {str(row.part): row.data for row in rows}
|
|
688
|
+
|
|
689
|
+
def _memoize(
|
|
690
|
+
self, walk: _Walk, node: tuple[str, str, str | None], rows: list[Artifact]
|
|
691
|
+
) -> list[Artifact]:
|
|
692
|
+
"""One node resolves once per top-level call.
|
|
693
|
+
|
|
694
|
+
A diamond in the graph would otherwise recompute the shared node twice, and the
|
|
695
|
+
two results could differ for a non-deterministic recipe. See DECISIONS.md D25.
|
|
696
|
+
"""
|
|
697
|
+
walk.memo[node] = rows
|
|
698
|
+
return rows
|
|
699
|
+
|
|
700
|
+
def _record_missing(
|
|
701
|
+
self,
|
|
702
|
+
walk: _Walk,
|
|
703
|
+
rec: Recipe,
|
|
704
|
+
key: Mapping[str, Scalar],
|
|
705
|
+
part: str | None,
|
|
706
|
+
action: OnMiss,
|
|
707
|
+
) -> None:
|
|
708
|
+
"""Collect rather than raise.
|
|
709
|
+
|
|
710
|
+
Walking as much of the graph as possible and reporting every protected node at
|
|
711
|
+
once turns the protection from an obstacle into a planning tool: a cold run
|
|
712
|
+
tells you in one pass exactly what needs ingesting.
|
|
713
|
+
"""
|
|
714
|
+
node = (rec.name, canonical_key_hash(key)[1], part)
|
|
715
|
+
if node in walk.missing_seen:
|
|
716
|
+
return
|
|
717
|
+
walk.missing_seen.add(node)
|
|
718
|
+
self.stats.blocked += 1
|
|
719
|
+
log.debug("blocked %s (%s)", walk.path[-1] if walk.path else rec.name, action)
|
|
720
|
+
walk.missing.append(
|
|
721
|
+
MissingNode(
|
|
722
|
+
recipe=rec.name,
|
|
723
|
+
version=rec.version,
|
|
724
|
+
key=dict(key),
|
|
725
|
+
part=part,
|
|
726
|
+
action=action,
|
|
727
|
+
path=tuple(walk.path),
|
|
728
|
+
)
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _as_refs(rows: Any) -> list[ArtifactRef]:
|
|
733
|
+
return [
|
|
734
|
+
ArtifactRef(
|
|
735
|
+
id=row.id or 0,
|
|
736
|
+
recipe=row.recipe,
|
|
737
|
+
version=row.version,
|
|
738
|
+
key_hash=row.key_hash,
|
|
739
|
+
key_json=row.key_json,
|
|
740
|
+
part=row.part,
|
|
741
|
+
digest=row.digest,
|
|
742
|
+
fingerprint=row.fingerprint,
|
|
743
|
+
valid=row.valid,
|
|
744
|
+
n_bytes=row.n_bytes,
|
|
745
|
+
computed_at=row.computed_at,
|
|
746
|
+
refreshed_at=row.refreshed_at,
|
|
747
|
+
compute_ms=row.compute_ms,
|
|
748
|
+
)
|
|
749
|
+
for row in rows
|
|
750
|
+
]
|