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.
- interlace/__init__.py +26 -0
- interlace/checks/__init__.py +10 -0
- interlace/checks/builtin.py +152 -0
- interlace/checks/runner.py +103 -0
- interlace/checks/spec.py +113 -0
- interlace/cli/__init__.py +7 -0
- interlace/cli/main.py +1072 -0
- interlace/config/__init__.py +7 -0
- interlace/config/config.py +172 -0
- interlace/contracts.py +35 -0
- interlace/dsl/__init__.py +16 -0
- interlace/dsl/decorators.py +222 -0
- interlace/dsl/discovery.py +75 -0
- interlace/dsl/sql_config.py +47 -0
- interlace/engines/__init__.py +16 -0
- interlace/engines/base.py +86 -0
- interlace/engines/duckdb.py +249 -0
- interlace/engines/postgres.py +157 -0
- interlace/engines/quack.py +113 -0
- interlace/engines/registry.py +125 -0
- interlace/exceptions.py +66 -0
- interlace/exports.py +145 -0
- interlace/graph/__init__.py +17 -0
- interlace/graph/column_lineage.py +69 -0
- interlace/graph/dag.py +81 -0
- interlace/graph/project.py +242 -0
- interlace/graph/selectors.py +45 -0
- interlace/ir/__init__.py +24 -0
- interlace/ir/canonicalize.py +71 -0
- interlace/ir/fingerprint.py +55 -0
- interlace/ir/relation.py +71 -0
- interlace/ir/schema.py +23 -0
- interlace/plan/__init__.py +23 -0
- interlace/plan/apply.py +527 -0
- interlace/plan/differ.py +211 -0
- interlace/plan/plan.py +177 -0
- interlace/plan/resolve.py +74 -0
- interlace/plan/run.py +79 -0
- interlace/project.py +278 -0
- interlace/py.typed +0 -0
- interlace/runtime/__init__.py +6 -0
- interlace/runtime/handles.py +48 -0
- interlace/runtime/python_model.py +175 -0
- interlace/scaffold.py +83 -0
- interlace/scheduler/__init__.py +17 -0
- interlace/scheduler/engine.py +66 -0
- interlace/scheduler/triggers.py +84 -0
- interlace/scheduler/worker.py +154 -0
- interlace/service/__init__.py +7 -0
- interlace/service/app.py +836 -0
- interlace/service/auth.py +41 -0
- interlace/state/__init__.py +18 -0
- interlace/state/interval.py +134 -0
- interlace/state/janitor.py +142 -0
- interlace/state/snapshot.py +47 -0
- interlace/state/store.py +906 -0
- interlace/strategies/__init__.py +64 -0
- interlace/strategies/base.py +71 -0
- interlace/strategies/full.py +38 -0
- interlace/strategies/full_merge.py +98 -0
- interlace/strategies/incremental_by_time.py +65 -0
- interlace/strategies/merge_by_key.py +69 -0
- interlace/strategies/scd_type_2.py +122 -0
- interlace/strategies/view.py +33 -0
- interlace/streaming/__init__.py +18 -0
- interlace/streaming/log.py +298 -0
- interlace/streaming/materializer.py +166 -0
- interlace/streaming/schema.py +218 -0
- interlaced-2.0.0a2.dist-info/METADATA +216 -0
- interlaced-2.0.0a2.dist-info/RECORD +74 -0
- interlaced-2.0.0a2.dist-info/WHEEL +5 -0
- interlaced-2.0.0a2.dist-info/entry_points.txt +2 -0
- interlaced-2.0.0a2.dist-info/licenses/LICENSE +21 -0
- interlaced-2.0.0a2.dist-info/top_level.txt +1 -0
interlace/plan/apply.py
ADDED
|
@@ -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
|