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/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
+ ]