interlaced 2.0.0a2__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.
Files changed (74) hide show
  1. interlace/__init__.py +26 -0
  2. interlace/checks/__init__.py +10 -0
  3. interlace/checks/builtin.py +152 -0
  4. interlace/checks/runner.py +103 -0
  5. interlace/checks/spec.py +113 -0
  6. interlace/cli/__init__.py +7 -0
  7. interlace/cli/main.py +1072 -0
  8. interlace/config/__init__.py +7 -0
  9. interlace/config/config.py +172 -0
  10. interlace/contracts.py +35 -0
  11. interlace/dsl/__init__.py +16 -0
  12. interlace/dsl/decorators.py +222 -0
  13. interlace/dsl/discovery.py +75 -0
  14. interlace/dsl/sql_config.py +47 -0
  15. interlace/engines/__init__.py +16 -0
  16. interlace/engines/base.py +86 -0
  17. interlace/engines/duckdb.py +249 -0
  18. interlace/engines/postgres.py +157 -0
  19. interlace/engines/quack.py +113 -0
  20. interlace/engines/registry.py +125 -0
  21. interlace/exceptions.py +66 -0
  22. interlace/exports.py +145 -0
  23. interlace/graph/__init__.py +17 -0
  24. interlace/graph/column_lineage.py +69 -0
  25. interlace/graph/dag.py +81 -0
  26. interlace/graph/project.py +242 -0
  27. interlace/graph/selectors.py +45 -0
  28. interlace/ir/__init__.py +24 -0
  29. interlace/ir/canonicalize.py +71 -0
  30. interlace/ir/fingerprint.py +55 -0
  31. interlace/ir/relation.py +71 -0
  32. interlace/ir/schema.py +23 -0
  33. interlace/plan/__init__.py +23 -0
  34. interlace/plan/apply.py +527 -0
  35. interlace/plan/differ.py +211 -0
  36. interlace/plan/plan.py +177 -0
  37. interlace/plan/resolve.py +74 -0
  38. interlace/plan/run.py +79 -0
  39. interlace/project.py +278 -0
  40. interlace/py.typed +0 -0
  41. interlace/runtime/__init__.py +6 -0
  42. interlace/runtime/handles.py +48 -0
  43. interlace/runtime/python_model.py +175 -0
  44. interlace/scaffold.py +83 -0
  45. interlace/scheduler/__init__.py +17 -0
  46. interlace/scheduler/engine.py +66 -0
  47. interlace/scheduler/triggers.py +84 -0
  48. interlace/scheduler/worker.py +154 -0
  49. interlace/service/__init__.py +7 -0
  50. interlace/service/app.py +836 -0
  51. interlace/service/auth.py +41 -0
  52. interlace/state/__init__.py +18 -0
  53. interlace/state/interval.py +134 -0
  54. interlace/state/janitor.py +142 -0
  55. interlace/state/snapshot.py +47 -0
  56. interlace/state/store.py +906 -0
  57. interlace/strategies/__init__.py +64 -0
  58. interlace/strategies/base.py +71 -0
  59. interlace/strategies/full.py +38 -0
  60. interlace/strategies/full_merge.py +98 -0
  61. interlace/strategies/incremental_by_time.py +65 -0
  62. interlace/strategies/merge_by_key.py +69 -0
  63. interlace/strategies/scd_type_2.py +122 -0
  64. interlace/strategies/view.py +33 -0
  65. interlace/streaming/__init__.py +18 -0
  66. interlace/streaming/log.py +298 -0
  67. interlace/streaming/materializer.py +166 -0
  68. interlace/streaming/schema.py +218 -0
  69. interlaced-2.0.0a2.dist-info/METADATA +216 -0
  70. interlaced-2.0.0a2.dist-info/RECORD +74 -0
  71. interlaced-2.0.0a2.dist-info/WHEEL +5 -0
  72. interlaced-2.0.0a2.dist-info/entry_points.txt +2 -0
  73. interlaced-2.0.0a2.dist-info/licenses/LICENSE +21 -0
  74. interlaced-2.0.0a2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,527 @@
1
+ """Apply a plan: build changed snapshots, repoint environment views, promote.
2
+
3
+ For each backfill the model's query has its upstream references rewritten to the
4
+ upstreams' physical tables, the strategy emits the build statements, and the
5
+ engine runs them; the new snapshot is persisted. Then the environment's virtual
6
+ views are repointed at the new physical tables, and the environment is promoted
7
+ to the full desired fingerprint set. Runs in the compiled topological order, so
8
+ upstream physical tables exist before downstream models build against them.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import contextlib
15
+ import time
16
+ from collections.abc import Callable, Mapping
17
+ from dataclasses import dataclass, field, replace
18
+ from pathlib import Path
19
+
20
+ import pyarrow as pa
21
+ import sqlglot
22
+ from sqlglot import exp
23
+
24
+ from interlace.checks.runner import CheckOutcome, run_checks
25
+ from interlace.contracts import validate_contract
26
+ from interlace.engines.base import EngineAdapter
27
+ from interlace.engines.registry import EngineRegistry, as_registry
28
+ from interlace.exceptions import CheckError, PlanError
29
+ from interlace.exports import export_row_counts, export_statements, export_target_ref, table_export_statements
30
+ from interlace.graph.project import CompiledModel, CompiledProject
31
+ from interlace.ir.relation import EngineRef, SqlRelation, TableRef
32
+ from interlace.ir.schema import empty_schema
33
+ from interlace.plan.plan import XFER_SCHEMA, BackfillTask, Plan, staging_table
34
+ from interlace.plan.resolve import resolve_model_query
35
+ from interlace.runtime.python_model import build_python_model, run_python_model
36
+ from interlace.state.store import StateStore
37
+ from interlace.strategies import resolve_strategy
38
+ from interlace.strategies.base import RowCounts
39
+
40
+
41
+ @dataclass
42
+ class ApplyResult:
43
+ built: list[str] = field(default_factory=list)
44
+ reused: list[str] = field(default_factory=list) # recorded over their previous physical table
45
+ transfers: list[str] = field(default_factory=list) # executed cross-engine transfers
46
+ promoted: int = 0
47
+ checks: list[CheckOutcome] = field(default_factory=list)
48
+ # Wall-clock build seconds per built model (extraction + strategy + checks).
49
+ timings: dict[str, float] = field(default_factory=dict)
50
+ # What each build did to its target's rows, as its strategy interprets the
51
+ # engine's affected-row counts. Interval windows accumulate.
52
+ rows: dict[str, RowCounts] = field(default_factory=dict)
53
+
54
+ def record_rows(self, name: str, counts: RowCounts) -> None:
55
+ self.rows[name] = self.rows.get(name, RowCounts()) + counts
56
+
57
+
58
+ # The widening promotions DuckLake's ALTER COLUMN supports, in order.
59
+ _NUMERIC_WIDTH = {"TINYINT": 0, "SMALLINT": 1, "INTEGER": 2, "BIGINT": 3, "FLOAT": 4, "DOUBLE": 5}
60
+
61
+
62
+ def _widens(current: str, incoming: str) -> bool:
63
+ """True when ``incoming`` is a strictly wider numeric type than ``current``."""
64
+ return (
65
+ current in _NUMERIC_WIDTH and incoming in _NUMERIC_WIDTH and _NUMERIC_WIDTH[incoming] > _NUMERIC_WIDTH[current]
66
+ )
67
+
68
+
69
+ def _resolve_export_path(base_path: Path | None, path: str) -> str:
70
+ target = Path(path)
71
+ if target.is_absolute():
72
+ return str(target)
73
+ return str((base_path or Path.cwd()) / target)
74
+
75
+
76
+ async def _merge_python_output(
77
+ model: CompiledModel,
78
+ engine: EngineAdapter,
79
+ target: TableRef,
80
+ reader: pa.RecordBatchReader,
81
+ *,
82
+ exists: bool,
83
+ ) -> RowCounts:
84
+ """Stage a Python model's Arrow output and apply its keyed strategy in SQL.
85
+
86
+ The output lands in a stage table (CREATE OR REPLACE, so a crashed run's
87
+ leftover is harmless), the target is evolved additively when the output grew
88
+ new columns, and the strategy's statements run atomically against a source
89
+ select aligned to the target's column set. The stage is dropped in the same
90
+ batch.
91
+ """
92
+ stage = replace(target, name=f"{target.name}__stage")
93
+ await engine.load(stage, reader, "create")
94
+ stage_table = stage.to_expr()
95
+
96
+ strategy = resolve_strategy(model.materialise, model.strategy, model.key, model.time_column)
97
+ source: exp.Query = exp.select("*").from_(stage_table.copy())
98
+ pre_statements: list[exp.Expression] = []
99
+ if exists:
100
+ pre_statements, source, _ = await _align_stage_to_target(
101
+ engine, stage, target, exclude=strategy.managed_columns
102
+ )
103
+
104
+ relation = SqlRelation(
105
+ ast=source, engine=EngineRef(name=model.engine, dialect=model.dialect), schema=empty_schema()
106
+ )
107
+ statements = strategy.plan_statements(relation, target, engine.caps, None)
108
+ drop_stage = exp.Drop(this=stage_table.copy(), kind="TABLE", exists=True)
109
+ counts = await engine.execute_all([*pre_statements, *statements, drop_stage])
110
+ return strategy.row_counts(counts[len(pre_statements) : len(pre_statements) + len(statements)])
111
+
112
+
113
+ async def _align_stage_to_target(
114
+ engine: EngineAdapter, stage: TableRef, target: TableRef, exclude: tuple[str, ...] = ()
115
+ ) -> tuple[list[exp.Expression], exp.Query, list[str]]:
116
+ """Align a staged source to an EXISTING target: additive ALTERs for new columns,
117
+ widening type promotions in place, and a projection over the stage matching the
118
+ target's final column set (NULL-fill vanished columns, cast type drift). Returns
119
+ (pre-statements, aligned source select, target column order).
120
+
121
+ ``exclude`` names strategy-managed bookkeeping columns (e.g. scd2's validity
122
+ pair): they live on the target but never in the model's output, so they must
123
+ not be NULL-filled into the aligned source — the strategy owns them."""
124
+ target_columns = await engine.describe(target)
125
+ for column in exclude:
126
+ target_columns.pop(column, None)
127
+ stage_columns = await engine.describe(stage)
128
+ target_expr = target.to_expr()
129
+ pre_statements: list[exp.Expression] = []
130
+ for column, dtype in stage_columns.items():
131
+ if column not in target_columns:
132
+ pre_statements.append(
133
+ exp.Alter(
134
+ this=target_expr.copy(),
135
+ kind="TABLE",
136
+ actions=[
137
+ exp.ColumnDef(this=exp.to_identifier(column), kind=exp.DataType.build(dtype)),
138
+ ],
139
+ )
140
+ )
141
+ target_columns[column] = dtype
142
+ elif dtype != target_columns[column] and _widens(target_columns[column], dtype):
143
+ # Source type drifted wider (int -> bigint -> double): promote the target
144
+ # in place — DuckLake supports exactly these widening promotions.
145
+ pre_statements.append(
146
+ exp.Alter(
147
+ this=target_expr.copy(),
148
+ kind="TABLE",
149
+ actions=[exp.AlterColumn(this=exp.column(column), dtype=exp.DataType.build(dtype))],
150
+ )
151
+ )
152
+ target_columns[column] = dtype
153
+ # Any remaining type mismatch (e.g. a numeric field arriving as VARCHAR) is cast
154
+ # to the target's type — deterministic, and loudly fails the run on values that
155
+ # genuinely don't convert rather than silently corrupting the column.
156
+ projection = []
157
+ for column, dtype in target_columns.items():
158
+ if column not in stage_columns:
159
+ projection.append(exp.alias_(exp.Cast(this=exp.Null(), to=exp.DataType.build(dtype)), column))
160
+ elif stage_columns[column] != dtype:
161
+ projection.append(exp.alias_(exp.Cast(this=exp.column(column), to=exp.DataType.build(dtype)), column))
162
+ else:
163
+ projection.append(exp.column(column))
164
+ source = exp.select(*projection).from_(stage.to_expr())
165
+ return pre_statements, source, list(target_columns)
166
+
167
+
168
+ async def _deliver_table_export(model: CompiledModel, engine: EngineAdapter, resolved: exp.Expression) -> RowCounts:
169
+ """Reverse ETL with schema evolution. The external target is never dropped (grants
170
+ and readers survive), so when it already exists the source is staged in the
171
+ warehouse, aligned to the target (additive ALTERs, NULL-fill, casts), and delivered
172
+ with an explicit column list — a sink model growing or reordering columns must
173
+ evolve the destination, never break it or positionally corrupt it."""
174
+ assert model.export is not None
175
+ target = export_target_ref(model.export.target)
176
+ if not await engine.table_exists(target): # first delivery: the ensure-create matches the source
177
+ counts = await engine.execute_all(table_export_statements(model.export, resolved, model.dialect, model.engine))
178
+ return export_row_counts(model.export, counts)
179
+ stage = TableRef(schema=XFER_SCHEMA, name=f"{model.name}__sink_stage")
180
+ await engine.create_schema(stage.schema)
181
+ await engine.execute(exp.Create(this=stage.to_expr(), kind="TABLE", replace=True, expression=resolved.copy()))
182
+ pre_statements, aligned, columns = await _align_stage_to_target(engine, stage, target)
183
+ statements = table_export_statements(model.export, aligned, model.dialect, model.engine, columns=columns)
184
+ # One transaction may write only ONE attached database: the delivery batch writes the
185
+ # external target; the stage lives in the warehouse and is dropped separately (a
186
+ # leftover is harmless — the next delivery CREATE OR REPLACEs it).
187
+ counts = await engine.execute_all([*pre_statements, *statements])
188
+ await engine.execute(exp.Drop(this=stage.to_expr(), kind="TABLE", exists=True))
189
+ return export_row_counts(model.export, counts[len(pre_statements) :])
190
+
191
+
192
+ async def _stage_cross_engine_inputs(
193
+ model: CompiledModel,
194
+ compiled: CompiledProject,
195
+ registry: EngineRegistry,
196
+ physical: Mapping[str, TableRef],
197
+ staged: set[tuple[str, str]],
198
+ stage_lock: asyncio.Lock,
199
+ result: ApplyResult,
200
+ ) -> dict[str, TableRef]:
201
+ """Move cross-engine upstreams into staging tables on the model's engine.
202
+
203
+ Returns the model's *local* resolution map: cross-engine deps point at their
204
+ staged copies; everything else keeps the global physical map. Each
205
+ (upstream, target-engine) pair transfers once per apply — always replaced,
206
+ so a re-run upstream (merge/incremental) is never read stale. The lock is
207
+ held across the transfer so a concurrent consumer of the same upstream
208
+ never reads a half-populated stage table.
209
+ """
210
+ local = dict(physical)
211
+ for dep in model.dependencies:
212
+ upstream = compiled.models[dep]
213
+ if upstream.engine == model.engine or upstream.materialise == "ephemeral":
214
+ continue
215
+ stage = staging_table(dep)
216
+ local[dep] = stage
217
+ key = (dep, model.engine)
218
+ async with stage_lock:
219
+ if key in staged:
220
+ continue
221
+ target = registry.require(model.engine, model=model.name)
222
+ origin = physical.get(dep, upstream.physical_table)
223
+ await target.create_schema(stage.schema)
224
+ via = "arrow"
225
+ if await _attach_transfer(
226
+ target, registry.attach_uris.get(upstream.engine), upstream.engine, origin, stage
227
+ ):
228
+ via = "attach" # federated CTAS: no Python hop at all
229
+ else:
230
+ source_engine = registry.require(upstream.engine, model=dep)
231
+ reader = await source_engine.fetch(exp.select("*").from_(origin.to_expr()))
232
+ await target.load(stage, reader, "create")
233
+ staged.add(key)
234
+ result.transfers.append(f"{dep}: {upstream.engine} -> {model.engine} ({stage.schema}.{stage.name}, {via})")
235
+ return local
236
+
237
+
238
+ async def _attach_transfer(
239
+ target: EngineAdapter, uri: str | None, source_name: str, origin: TableRef, stage: TableRef
240
+ ) -> bool:
241
+ """Fast lane: when the target is DuckDB-family and the source is attachable,
242
+ stage with one federated CTAS. Opportunistic — any failure falls back to Arrow."""
243
+ from interlace.engines.duckdb import DuckDBAdapter
244
+ from interlace.engines.quack import QuackAdapter
245
+
246
+ if uri is None or not isinstance(target, DuckDBAdapter) or isinstance(target, QuackAdapter):
247
+ return False
248
+ alias = f"__xfer_{source_name}"
249
+ src = exp.table_(origin.name, db=origin.schema, catalog=alias).sql(dialect="duckdb")
250
+ dst = exp.table_(stage.name, db=stage.schema).sql(dialect="duckdb")
251
+ try:
252
+ target.attach(alias, uri)
253
+ await target.execute_sql(f"CREATE OR REPLACE TABLE {dst} AS SELECT * FROM {src}")
254
+ except Exception:
255
+ return False # e.g. the source file is held open by its own adapter -> Arrow path
256
+ finally:
257
+ # release the handle either way: the source engine must stay openable
258
+ # by its own adapter later in this (long-lived daemon) process
259
+ with contextlib.suppress(Exception):
260
+ await target.execute_sql(f"DETACH {exp.to_identifier(alias).sql('duckdb')}")
261
+ return True
262
+
263
+
264
+ async def _seed_history(engine: EngineAdapter, source: TableRef, target: TableRef) -> None:
265
+ """Forward-only copy-on-write: seed the new fingerprint's table from the previous
266
+ one. Idempotent (IF NOT EXISTS), so a crashed apply re-seeds harmlessly; the old
267
+ table is untouched and stays the rollback until gc reclaims it."""
268
+ await engine.create_schema(target.schema)
269
+ source_expr = source.to_expr()
270
+ await engine.execute(
271
+ exp.Create(
272
+ this=target.to_expr(),
273
+ kind="TABLE",
274
+ exists=True,
275
+ expression=exp.select("*").from_(source_expr),
276
+ )
277
+ )
278
+
279
+
280
+ async def _gate_checks(
281
+ model: CompiledModel,
282
+ compiled: CompiledProject,
283
+ engine: EngineAdapter,
284
+ state: StateStore,
285
+ environment: str,
286
+ result: ApplyResult,
287
+ physical: Mapping[str, TableRef] | None = None,
288
+ ) -> None:
289
+ """Run the model's checks against its built snapshot table; an error-severity
290
+ failure raises before the environment is promoted."""
291
+ outcomes = await run_checks(
292
+ model, compiled, engine, model.physical_table, compiled.python_checks.get(model.name, ()), physical
293
+ )
294
+ if not outcomes:
295
+ return
296
+ result.checks.extend(outcomes)
297
+ await state.record_check_results(environment, model.fingerprint, outcomes)
298
+ blocking = [o for o in outcomes if o.blocking]
299
+ if blocking:
300
+ details = "; ".join(
301
+ f"{o.model}.{o.name}: {o.message}" if o.status == "error" else f"{o.model}.{o.name} ({o.failures} failing)"
302
+ for o in blocking
303
+ )
304
+ raise CheckError(f"checks failed — promotion blocked: {details}")
305
+
306
+
307
+ def _check_references(model: CompiledModel, compiled: CompiledProject) -> set[str]:
308
+ """Other models a check reads (``relationships`` targets, tables in ``sql``
309
+ checks): they must be built before this model's checks can run."""
310
+ refs: set[str] = set()
311
+ for spec in model.checks:
312
+ if spec.type == "relationships":
313
+ to = str(spec.params.get("to", ""))
314
+ if to in compiled.models:
315
+ refs.add(to)
316
+ elif spec.type == "sql":
317
+ with contextlib.suppress(Exception):
318
+ parsed = sqlglot.parse_one(str(spec.params.get("query", "")))
319
+ refs.update(t.name for t in parsed.find_all(exp.Table) if t.name in compiled.models)
320
+ return refs
321
+
322
+
323
+ async def _run_backfill(
324
+ task: BackfillTask,
325
+ plan: Plan,
326
+ compiled: CompiledProject,
327
+ registry: EngineRegistry,
328
+ physical: Mapping[str, TableRef],
329
+ staged: set[tuple[str, str]],
330
+ stage_lock: asyncio.Lock,
331
+ state: StateStore,
332
+ base_path: Path | None,
333
+ result: ApplyResult,
334
+ ) -> None:
335
+ """Build one backfill task end-to-end: stage inputs, execute, contract, record, gate."""
336
+ task_started = time.perf_counter()
337
+ snapshot = task.snapshot
338
+ model = compiled.models[snapshot.name]
339
+ target_engine = registry.require(model.engine, model=model.name)
340
+ resolution = await _stage_cross_engine_inputs(model, compiled, registry, physical, staged, stage_lock, result)
341
+
342
+ if model.ast is None: # Python model: run the function, load Arrow into the snapshot table
343
+ if model.export is not None:
344
+ raise PlanError(f"Python model {snapshot.name!r} cannot be a sink yet; write SQL over its output")
345
+ if model.materialise != "table":
346
+ raise PlanError(f"Python model {snapshot.name!r} must materialise as a table")
347
+ if model.strategy == "incremental_by_time":
348
+ raise PlanError(
349
+ f"Python model {snapshot.name!r} cannot use incremental_by_time; "
350
+ f"use cursor= with merge_by_key instead"
351
+ )
352
+ recorded_self = await state.get_snapshot(snapshot.name, snapshot.fingerprint)
353
+ previous = recorded_self.physical_table if recorded_self is not None else None
354
+ await target_engine.create_schema(snapshot.physical_table.schema)
355
+ if task.seed_from is not None: # forward-only: the seeded copy IS the history
356
+ await _seed_history(target_engine, task.seed_from, snapshot.physical_table)
357
+ previous = previous or snapshot.physical_table
358
+ if model.strategy == "full":
359
+ loaded = await build_python_model(
360
+ model, compiled, target_engine, snapshot.physical_table, physical=resolution, previous=previous
361
+ )
362
+ result.record_rows(snapshot.name, RowCounts(inserted=loaded))
363
+ else: # keyed strategy: stage the Arrow output, then merge it in SQL
364
+ reader = await run_python_model(model, compiled, target_engine, resolution, previous)
365
+ merged = await _merge_python_output(
366
+ model, target_engine, snapshot.physical_table, reader, exists=previous is not None
367
+ )
368
+ result.record_rows(snapshot.name, merged)
369
+ if model.columns:
370
+ validate_contract(model.name, await target_engine.describe(snapshot.physical_table), model.columns)
371
+ await state.add_snapshot(snapshot)
372
+ result.built.append(snapshot.name)
373
+ await _gate_checks(model, compiled, target_engine, state, plan.environment, result, resolution)
374
+ result.timings[snapshot.name] = time.perf_counter() - task_started
375
+ return
376
+
377
+ resolved = resolve_model_query(model, compiled, resolution)
378
+
379
+ if model.export is not None: # sink: push the result to a destination, no table/view
380
+ if model.export.to == "table": # reverse ETL into an attached database
381
+ result.record_rows(snapshot.name, await _deliver_table_export(model, target_engine, resolved))
382
+ else:
383
+ export_path = _resolve_export_path(base_path, model.export.path)
384
+ Path(export_path).parent.mkdir(parents=True, exist_ok=True)
385
+ copied = await target_engine.execute_all(
386
+ export_statements(model.export, resolved, export_path, model.dialect)
387
+ )
388
+ result.record_rows(snapshot.name, RowCounts(inserted=copied[0] if copied else 0))
389
+ await state.add_snapshot(snapshot)
390
+ result.built.append(snapshot.name)
391
+ result.timings[snapshot.name] = time.perf_counter() - task_started
392
+ return
393
+
394
+ relation = SqlRelation(
395
+ ast=resolved, engine=EngineRef(name=model.engine, dialect=model.dialect), schema=empty_schema()
396
+ )
397
+ strategy = resolve_strategy(model.materialise, model.strategy, model.key, model.time_column)
398
+
399
+ await target_engine.create_schema(snapshot.physical_table.schema)
400
+ if task.seed_from is not None: # forward-only: history moves onto the new table first
401
+ await _seed_history(target_engine, task.seed_from, snapshot.physical_table)
402
+ statements = strategy.plan_statements(relation, snapshot.physical_table, target_engine.caps, task.interval)
403
+ counts = await target_engine.execute_all(statements)
404
+ result.record_rows(snapshot.name, strategy.row_counts(counts))
405
+ if model.columns: # validate the built schema against the contract before recording it
406
+ validate_contract(model.name, await target_engine.describe(snapshot.physical_table), model.columns)
407
+
408
+ if task.interval is not None: # incremental: accumulate the filled window in the ledger
409
+ filled = await state.get_intervals(snapshot.name, snapshot.fingerprint)
410
+ for carried in snapshot.intervals: # forward-only: the INHERITED ledger must persist too
411
+ filled = filled.add(carried)
412
+ snapshot = replace(snapshot, intervals=filled.add(task.interval))
413
+ await state.add_snapshot(snapshot)
414
+ result.built.append(snapshot.name)
415
+ await _gate_checks(model, compiled, target_engine, state, plan.environment, result, resolution)
416
+ result.timings[snapshot.name] = time.perf_counter() - task_started
417
+
418
+
419
+ async def apply(
420
+ plan: Plan,
421
+ *,
422
+ compiled: CompiledProject,
423
+ engine: EngineAdapter | None = None,
424
+ engines: Mapping[str, EngineAdapter] | EngineRegistry | None = None,
425
+ state: StateStore,
426
+ base_path: Path | None = None,
427
+ parallelism: int = 4,
428
+ on_progress: Callable[[str, str], None] | None = None,
429
+ ) -> ApplyResult:
430
+ """Execute a plan and record the result in ``state``.
431
+
432
+ Pass either a single ``engine`` (single-engine projects / tests) or an
433
+ ``engines`` registry / mapping. Each model builds on ``model.engine``.
434
+ ``base_path`` is the project root used to resolve relative export paths.
435
+ ``on_progress`` (model, event) fires on the event loop as each model's
436
+ build starts / finishes: events are ``"start"``, ``"done"``, ``"failed"``.
437
+ """
438
+ registry = as_registry(engine, engines)
439
+ result = ApplyResult()
440
+
441
+ # Where each model's data actually lives: recorded snapshots win over the
442
+ # fingerprint-derived name (a reused snapshot sits on an older table), and
443
+ # models building in this apply resolve to where they are being built now.
444
+ recorded_snapshots = await state.get_snapshots(
445
+ (name, compiled_model.fingerprint) for name, compiled_model in compiled.models.items()
446
+ )
447
+ physical: dict[str, TableRef] = {
448
+ name: snapshot.physical_table for (name, _), snapshot in recorded_snapshots.items()
449
+ }
450
+ for task in plan.backfills:
451
+ physical[task.snapshot.name] = task.snapshot.physical_table
452
+ for reuse in plan.reuses:
453
+ physical[reuse.name] = reuse.physical_table
454
+
455
+ # Independent DAG branches build concurrently: tasks group per model (a model's
456
+ # interval windows stay ordered), models group into dependency levels, and each
457
+ # level runs as a TaskGroup — a failure cancels its level before promote.
458
+ staged: set[tuple[str, str]] = set() # (upstream, target engine) pairs moved this apply
459
+ stage_lock = asyncio.Lock()
460
+ per_model: dict[str, list[BackfillTask]] = {}
461
+ for task in plan.backfills: # differ/run emit tasks in topological order
462
+ per_model.setdefault(task.snapshot.name, []).append(task)
463
+ # Depth counts in-plan ancestors and propagates through models that aren't
464
+ # building (ephemeral, reused): a Python model over an ephemeral view of a
465
+ # building table must still wait for that table. Checks that read *other*
466
+ # models (relationships, sql) add scheduling edges the DAG doesn't have, and
467
+ # may point "backwards" — so relax to a fixed point instead of one topo pass.
468
+ scheduling_deps = {
469
+ model.name: (set(model.dependencies) | _check_references(model, compiled)) - {model.name}
470
+ for model in compiled.models.values()
471
+ }
472
+ depth: dict[str, int] = dict.fromkeys(compiled.models, 0)
473
+ for _ in range(len(depth)):
474
+ settled = True
475
+ for name, deps in scheduling_deps.items():
476
+ required = max((depth[dep] + (1 if dep in per_model else 0) for dep in deps), default=0)
477
+ if required > depth[name]:
478
+ depth[name] = required
479
+ settled = False
480
+ if settled:
481
+ break
482
+ level = {name: depth[name] for name in per_model}
483
+ build_slots = asyncio.Semaphore(max(1, parallelism))
484
+
485
+ async def run_model(name: str) -> None:
486
+ async with build_slots:
487
+ if on_progress is not None:
488
+ on_progress(name, "start")
489
+ try:
490
+ for model_task in per_model[name]:
491
+ await _run_backfill(
492
+ model_task, plan, compiled, registry, physical, staged, stage_lock, state, base_path, result
493
+ )
494
+ except asyncio.CancelledError: # a SIBLING failed; this model is collateral
495
+ if on_progress is not None:
496
+ on_progress(name, "cancelled")
497
+ raise
498
+ except BaseException:
499
+ if on_progress is not None:
500
+ on_progress(name, "failed")
501
+ raise
502
+ if on_progress is not None:
503
+ on_progress(name, "done")
504
+
505
+ try:
506
+ for tier in sorted(set(level.values())):
507
+ async with asyncio.TaskGroup() as group:
508
+ for name in (n for n in per_model if level[n] == tier):
509
+ group.create_task(run_model(name))
510
+ except ExceptionGroup as failures: # single failure keeps apply()'s plain-exception contract
511
+ if len(failures.exceptions) == 1:
512
+ raise failures.exceptions[0] from None
513
+ raise
514
+
515
+ for reuse in plan.reuses: # output provably identical: record the fingerprint, build nothing
516
+ await state.add_snapshot(reuse)
517
+ result.reused.append(reuse.name)
518
+
519
+ for swap in plan.virtual_updates:
520
+ view_engine = registry.require(swap.engine)
521
+ await view_engine.create_schema(swap.view.schema)
522
+ await view_engine.create_view(swap.view, swap.target)
523
+
524
+ mapping = {name: compiled.models[name].fingerprint for name in plan.promote}
525
+ await state.promote(plan.environment, mapping)
526
+ result.promoted = len(mapping)
527
+ return result