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/differ.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Compute a plan by diffing a compiled project against an environment.
|
|
2
|
+
|
|
3
|
+
For each model, compares the desired (compiled) fingerprint against the
|
|
4
|
+
fingerprint currently promoted in the target environment, classifying it as
|
|
5
|
+
ADDED / MODIFIED / REMOVED / UNCHANGED. A MODIFIED change is further classified
|
|
6
|
+
BREAKING vs NON_BREAKING, and each changed model gets an *impact* that drives
|
|
7
|
+
what actually gets rebuilt:
|
|
8
|
+
|
|
9
|
+
- ``semantic`` — the data of pre-existing columns may differ (changed
|
|
10
|
+
expressions, filters, strategy config, Python source, or any semantic
|
|
11
|
+
upstream). Rebuilt; downstream inherits BREAKING.
|
|
12
|
+
- ``additive`` — existing columns are provably identical, new columns appeared
|
|
13
|
+
(strictly additive projections). Rebuilt; downstream inherits NON_BREAKING.
|
|
14
|
+
- ``clean`` — output is provably identical (only upstream fingerprints moved,
|
|
15
|
+
and no additive upstream leaks in). **Not rebuilt**: the new snapshot reuses
|
|
16
|
+
the previous physical table and the environment view repoints to it.
|
|
17
|
+
|
|
18
|
+
The clean case is the improvement over sqlmesh's model-granular invalidation.
|
|
19
|
+
It needs no column lineage: an indirectly-changed model's SQL is unchanged and
|
|
20
|
+
was previously valid, so it cannot reference newly-added upstream columns —
|
|
21
|
+
the only leak is a projection ``*`` (which inherits new columns), and Python
|
|
22
|
+
models, which see whole upstream tables and are treated as ``*``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import replace
|
|
28
|
+
|
|
29
|
+
from sqlglot import exp
|
|
30
|
+
|
|
31
|
+
from interlace.graph.project import CompiledModel, CompiledProject
|
|
32
|
+
from interlace.ir.canonicalize import parse
|
|
33
|
+
from interlace.ir.fingerprint import canonical_sql
|
|
34
|
+
from interlace.plan.plan import ChangeType, ModelChange, Plan, ViewSwap, collect_transfers, env_view, schedule_build
|
|
35
|
+
from interlace.state.snapshot import ChangeCategory, Snapshot
|
|
36
|
+
from interlace.state.store import StateStore
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def snapshot_of(model: CompiledModel, category: ChangeCategory) -> Snapshot:
|
|
40
|
+
"""Build the persistable Snapshot for a compiled model's new version."""
|
|
41
|
+
return Snapshot(
|
|
42
|
+
name=model.name,
|
|
43
|
+
fingerprint=model.fingerprint,
|
|
44
|
+
metadata_hash=model.metadata_hash,
|
|
45
|
+
physical_table=model.physical_table,
|
|
46
|
+
change_category=category,
|
|
47
|
+
local_fingerprint=model.local_fingerprint,
|
|
48
|
+
definition_sql=model.definition_sql,
|
|
49
|
+
engine=model.engine,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _projection_map(ast: exp.Expression | None) -> dict[str, str] | None:
|
|
54
|
+
"""Map output column name -> expression SQL for a simple SELECT, or None if undeterminable."""
|
|
55
|
+
if not isinstance(ast, exp.Select):
|
|
56
|
+
return None
|
|
57
|
+
columns: dict[str, str] = {}
|
|
58
|
+
for projection in ast.selects:
|
|
59
|
+
if projection.find(exp.Star) is not None:
|
|
60
|
+
return None # star -> output columns unknown without a schema
|
|
61
|
+
expr = projection.this if isinstance(projection, exp.Alias) else projection
|
|
62
|
+
columns[projection.alias_or_name] = expr.sql()
|
|
63
|
+
return columns
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _added_columns(previous_sql: str | None, ast: exp.Expression | None) -> tuple[str, ...] | None:
|
|
67
|
+
"""The strictly-added output columns of a direct change, or None if the change
|
|
68
|
+
is not provably additive (i.e. pre-existing column data may differ)."""
|
|
69
|
+
if previous_sql is None or not isinstance(ast, exp.Select):
|
|
70
|
+
return None
|
|
71
|
+
previous = parse(previous_sql)
|
|
72
|
+
previous_map = _projection_map(previous)
|
|
73
|
+
current_map = _projection_map(ast)
|
|
74
|
+
if previous_map is None or current_map is None:
|
|
75
|
+
return None
|
|
76
|
+
added = [name for name in current_map if name not in previous_map]
|
|
77
|
+
if not added or any(current_map.get(name) != expr for name, expr in previous_map.items()):
|
|
78
|
+
return None # nothing added, or an existing projection changed/was removed
|
|
79
|
+
# strict: with the added projections removed, everything else (FROM, WHERE,
|
|
80
|
+
# GROUP BY, ...) must be identical — a filter change is never additive
|
|
81
|
+
stripped = ast.copy()
|
|
82
|
+
stripped.set("expressions", [p for p in stripped.selects if p.alias_or_name not in set(added)])
|
|
83
|
+
if canonical_sql(stripped) != canonical_sql(previous):
|
|
84
|
+
return None
|
|
85
|
+
return tuple(added)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _selects_star(ast: exp.Expression | None) -> bool:
|
|
89
|
+
"""Whether the model's output can inherit new upstream columns."""
|
|
90
|
+
if ast is None:
|
|
91
|
+
return True # Python models read whole upstream tables
|
|
92
|
+
for select in ast.find_all(exp.Select):
|
|
93
|
+
for projection in select.selects:
|
|
94
|
+
if isinstance(projection, exp.Star):
|
|
95
|
+
return True
|
|
96
|
+
if isinstance(projection, exp.Column) and isinstance(projection.this, exp.Star):
|
|
97
|
+
return True # qualified: t.*
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _schedule_reuse(plan: Plan, model: CompiledModel, previous: Snapshot, environment: str) -> None:
|
|
102
|
+
"""Record the new fingerprint over the previous physical table; build nothing."""
|
|
103
|
+
if model.materialise == "ephemeral":
|
|
104
|
+
return # never physical; promotion alone carries it
|
|
105
|
+
snapshot = replace(
|
|
106
|
+
snapshot_of(model, ChangeCategory.NON_BREAKING),
|
|
107
|
+
physical_table=previous.physical_table,
|
|
108
|
+
intervals=previous.intervals,
|
|
109
|
+
)
|
|
110
|
+
plan.reuses.append(snapshot)
|
|
111
|
+
if model.export is None and model.materialise in ("table", "view"):
|
|
112
|
+
plan.virtual_updates.append(
|
|
113
|
+
ViewSwap(env_view(environment, model.name), previous.physical_table, engine=model.engine)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
_HISTORY_STRATEGIES = frozenset({"merge_by_key", "full_merge", "scd_type_2", "scd2", "incremental_by_time"})
|
|
118
|
+
"""Strategies whose targets accumulate state a rebuild would destroy."""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def diff(
|
|
122
|
+
compiled: CompiledProject,
|
|
123
|
+
environment: str,
|
|
124
|
+
state: StateStore,
|
|
125
|
+
*,
|
|
126
|
+
select: set[str] | None = None,
|
|
127
|
+
forward_only: bool = False,
|
|
128
|
+
) -> Plan:
|
|
129
|
+
"""Diff the compiled project against ``environment`` and return the plan.
|
|
130
|
+
|
|
131
|
+
``select`` limits which models are scheduled and promoted (None = all). Impact
|
|
132
|
+
classification still runs over the whole graph so downstream categories are correct.
|
|
133
|
+
|
|
134
|
+
``forward_only``: modified models whose strategy accumulates history
|
|
135
|
+
(merge_by_key / full_merge / scd_type_2 / incremental_by_time) inherit their
|
|
136
|
+
previous physical table and interval ledger instead of starting fresh — the
|
|
137
|
+
new logic applies going forward, history survives. Requires the new query to
|
|
138
|
+
stay shape-compatible with the existing table.
|
|
139
|
+
"""
|
|
140
|
+
selected = set(compiled.models) if select is None else select
|
|
141
|
+
current = await state.get_environment(environment)
|
|
142
|
+
plan = Plan(environment=environment)
|
|
143
|
+
impact: dict[str, str] = {} # changed models only: "semantic" | "additive" | "clean"
|
|
144
|
+
|
|
145
|
+
for model in compiled.ordered(): # topo order: upstream impact known before downstream
|
|
146
|
+
previous_fingerprint = current.get(model.name)
|
|
147
|
+
|
|
148
|
+
if previous_fingerprint is None:
|
|
149
|
+
impact[model.name] = "semantic"
|
|
150
|
+
if model.name in selected:
|
|
151
|
+
plan.changes.append(ModelChange(model.name, ChangeType.ADDED, None, None, model.fingerprint))
|
|
152
|
+
schedule_build(plan, model, snapshot_of(model, ChangeCategory.BREAKING), environment)
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
if previous_fingerprint == model.fingerprint:
|
|
156
|
+
continue # unchanged
|
|
157
|
+
|
|
158
|
+
previous = await state.get_snapshot(model.name, previous_fingerprint)
|
|
159
|
+
added: tuple[str, ...] = ()
|
|
160
|
+
if previous is None or previous.local_fingerprint != model.local_fingerprint:
|
|
161
|
+
# direct change: always rebuilds itself; additive-only narrows downstream impact
|
|
162
|
+
columns = _added_columns(previous.definition_sql if previous else None, model.ast)
|
|
163
|
+
semantic = columns is None
|
|
164
|
+
added = columns or ()
|
|
165
|
+
category = ChangeCategory.BREAKING if semantic else ChangeCategory.NON_BREAKING
|
|
166
|
+
impact[model.name] = "semantic" if semantic else "additive"
|
|
167
|
+
rebuild = True
|
|
168
|
+
else: # indirect: only upstream fingerprints moved
|
|
169
|
+
upstream = [impact.get(dep, "clean") for dep in model.dependencies]
|
|
170
|
+
if "semantic" in upstream:
|
|
171
|
+
category, rebuild = ChangeCategory.BREAKING, True
|
|
172
|
+
impact[model.name] = "semantic"
|
|
173
|
+
elif "additive" in upstream and _selects_star(model.ast):
|
|
174
|
+
category, rebuild = ChangeCategory.NON_BREAKING, True
|
|
175
|
+
impact[model.name] = "additive" # a * projection inherits the new columns
|
|
176
|
+
else:
|
|
177
|
+
category, rebuild = ChangeCategory.NON_BREAKING, False
|
|
178
|
+
impact[model.name] = "clean" # provably identical output
|
|
179
|
+
|
|
180
|
+
if model.name not in selected:
|
|
181
|
+
continue
|
|
182
|
+
inherit = (
|
|
183
|
+
forward_only
|
|
184
|
+
and rebuild
|
|
185
|
+
and model.strategy in _HISTORY_STRATEGIES
|
|
186
|
+
and previous is not None
|
|
187
|
+
and previous.engine == model.engine # history can't be copied across engines
|
|
188
|
+
)
|
|
189
|
+
if inherit:
|
|
190
|
+
category = ChangeCategory.FORWARD_ONLY
|
|
191
|
+
plan.changes.append(
|
|
192
|
+
ModelChange(model.name, ChangeType.MODIFIED, category, previous_fingerprint, model.fingerprint, added)
|
|
193
|
+
)
|
|
194
|
+
if inherit: # copy-on-write: history seeds the NEW table; checks gate before views move
|
|
195
|
+
snapshot = replace(
|
|
196
|
+
snapshot_of(model, ChangeCategory.FORWARD_ONLY),
|
|
197
|
+
intervals=previous.intervals, # type: ignore[union-attr]
|
|
198
|
+
)
|
|
199
|
+
schedule_build(plan, model, snapshot, environment, seed_from=previous.physical_table) # type: ignore[union-attr]
|
|
200
|
+
elif rebuild:
|
|
201
|
+
schedule_build(plan, model, snapshot_of(model, category), environment)
|
|
202
|
+
else:
|
|
203
|
+
_schedule_reuse(plan, model, previous, environment) # type: ignore[arg-type] # previous is not None here
|
|
204
|
+
|
|
205
|
+
if select is None:
|
|
206
|
+
for removed in sorted(set(current) - set(compiled.models)):
|
|
207
|
+
plan.changes.append(ModelChange(removed, ChangeType.REMOVED, None, current[removed], None))
|
|
208
|
+
|
|
209
|
+
plan.promote = sorted(selected)
|
|
210
|
+
plan.transfers = collect_transfers(compiled, [task.snapshot.name for task in plan.backfills])
|
|
211
|
+
return plan
|
interlace/plan/plan.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""The plan/apply data model.
|
|
2
|
+
|
|
3
|
+
``interlace plan`` produces a :class:`Plan`: the classified set of model changes,
|
|
4
|
+
the exact ``(snapshot, interval)`` work needed to back them, any explicit
|
|
5
|
+
cross-engine transfers, and the virtual-view repointing that promotes the result.
|
|
6
|
+
Apply executes it; nothing here runs SQL.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from interlace.ir.relation import EngineRef, TableRef
|
|
17
|
+
from interlace.state.interval import Interval
|
|
18
|
+
from interlace.state.snapshot import ChangeCategory, Snapshot
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from interlace.graph.project import CompiledModel, CompiledProject
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ChangeType(Enum):
|
|
25
|
+
"""A model's status relative to the target environment."""
|
|
26
|
+
|
|
27
|
+
ADDED = "added"
|
|
28
|
+
MODIFIED = "modified"
|
|
29
|
+
REMOVED = "removed"
|
|
30
|
+
UNCHANGED = "unchanged"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class ModelChange:
|
|
35
|
+
"""A model added, modified, or removed relative to the target environment."""
|
|
36
|
+
|
|
37
|
+
name: str
|
|
38
|
+
change_type: ChangeType
|
|
39
|
+
category: ChangeCategory | None # severity of a MODIFIED change; None for added/removed
|
|
40
|
+
previous_fingerprint: str | None
|
|
41
|
+
new_fingerprint: str | None
|
|
42
|
+
impacted_columns: tuple[str, ...] = () # columns whose lineage changed (drives narrowing)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class BackfillTask:
|
|
47
|
+
"""One unit of physical work: build ``snapshot`` (optionally for one ``interval``)."""
|
|
48
|
+
|
|
49
|
+
snapshot: Snapshot
|
|
50
|
+
interval: Interval | None = None # None = full refresh (non-incremental models)
|
|
51
|
+
# Forward-only: copy this table into the snapshot's (new) physical table before
|
|
52
|
+
# the strategy runs — history moves to the new fingerprint, the old table stays
|
|
53
|
+
# as the rollback until gc.
|
|
54
|
+
seed_from: TableRef | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class TransferEdge:
|
|
59
|
+
"""Explicit cross-engine data movement, surfaced in plan output (never silent)."""
|
|
60
|
+
|
|
61
|
+
source: EngineRef
|
|
62
|
+
target: EngineRef
|
|
63
|
+
table: TableRef # the staging table on the target engine
|
|
64
|
+
via: str # "arrow" (generic fetch->load) | "attach" (future optimisation)
|
|
65
|
+
model: str = "" # the upstream model being moved
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
XFER_SCHEMA = "interlace__xfer"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def staging_table(upstream: str) -> TableRef:
|
|
72
|
+
"""Where a transferred upstream lands on the consumer's engine (replaced on every transfer)."""
|
|
73
|
+
return TableRef(schema=XFER_SCHEMA, name=upstream.replace(".", "__"))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def collect_transfers(compiled: CompiledProject, build_names: Iterable[str]) -> list[TransferEdge]:
|
|
77
|
+
"""One edge per (upstream, target engine) needed by the scheduled builds."""
|
|
78
|
+
edges: dict[tuple[str, str], TransferEdge] = {}
|
|
79
|
+
for name in build_names:
|
|
80
|
+
model = compiled.models[name]
|
|
81
|
+
for dep in model.dependencies:
|
|
82
|
+
upstream = compiled.models[dep]
|
|
83
|
+
if upstream.engine == model.engine or upstream.materialise == "ephemeral":
|
|
84
|
+
continue
|
|
85
|
+
key = (dep, model.engine)
|
|
86
|
+
if key not in edges:
|
|
87
|
+
edges[key] = TransferEdge(
|
|
88
|
+
source=EngineRef(name=upstream.engine, dialect=upstream.dialect),
|
|
89
|
+
target=EngineRef(name=model.engine, dialect=model.dialect),
|
|
90
|
+
table=staging_table(dep),
|
|
91
|
+
via="arrow",
|
|
92
|
+
model=dep,
|
|
93
|
+
)
|
|
94
|
+
return list(edges.values())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class ViewSwap:
|
|
99
|
+
"""Repoint an environment view at a (new) physical snapshot table."""
|
|
100
|
+
|
|
101
|
+
view: TableRef
|
|
102
|
+
target: TableRef
|
|
103
|
+
engine: str = "default" # named engine that hosts the view
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
PRODUCTION_ENV = "prod"
|
|
107
|
+
"""The production environment lives at the *unprefixed* schema (``main.orders``):
|
|
108
|
+
that's what BI tools and consumers connect to. Every other environment is a
|
|
109
|
+
prefixed sandbox (``dev__main.orders``) over the same physical snapshots."""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def env_view(environment: str, model_name: str) -> TableRef:
|
|
113
|
+
"""The virtual-environment view for a model: ``<schema>.<model>`` in
|
|
114
|
+
production, ``<env>__<schema>.<model>`` everywhere else."""
|
|
115
|
+
schema, _, base = model_name.rpartition(".")
|
|
116
|
+
prefix = "" if environment == PRODUCTION_ENV else f"{environment}__"
|
|
117
|
+
return TableRef(schema=f"{prefix}{schema or 'main'}", name=base)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def schedule_build(
|
|
121
|
+
plan: Plan, model: CompiledModel, snapshot: Snapshot, environment: str, *, seed_from: TableRef | None = None
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Add the right tasks for a model: ephemeral builds nothing; a sink builds but
|
|
124
|
+
gets no view; a table/view builds and is repointed by an environment view.
|
|
125
|
+
|
|
126
|
+
An incremental_by_time model cannot build without a window, so an apply fills
|
|
127
|
+
the latest grain interval — the same default as ``interlace run`` — leaving
|
|
128
|
+
history to ``run --start/--end``.
|
|
129
|
+
"""
|
|
130
|
+
if model.materialise == "ephemeral": # inlined into consumers, never built
|
|
131
|
+
return
|
|
132
|
+
if model.strategy == "incremental_by_time" and model.export is None:
|
|
133
|
+
from datetime import datetime
|
|
134
|
+
|
|
135
|
+
from interlace.state.interval import parse_grain
|
|
136
|
+
|
|
137
|
+
grain = parse_grain(model.interval or "1d")
|
|
138
|
+
now = datetime.now()
|
|
139
|
+
window = Interval(now - grain, now)
|
|
140
|
+
# scheduled even when an inherited ledger covers the window: the task is what
|
|
141
|
+
# seeds forward-only history, creates the table, and records the snapshot —
|
|
142
|
+
# and forward-only means the new logic owns the latest window anyway
|
|
143
|
+
plan.backfills.append(BackfillTask(snapshot=snapshot, interval=window, seed_from=seed_from))
|
|
144
|
+
plan.virtual_updates.append(
|
|
145
|
+
ViewSwap(env_view(environment, model.name), snapshot.physical_table, engine=model.engine)
|
|
146
|
+
)
|
|
147
|
+
return
|
|
148
|
+
plan.backfills.append(BackfillTask(snapshot=snapshot, seed_from=seed_from))
|
|
149
|
+
if model.export is None and model.materialise in ("table", "view"): # sinks have no view
|
|
150
|
+
# the snapshot's table, not the fingerprint-derived one: a forward-only
|
|
151
|
+
# snapshot builds into (and the view must point at) its inherited table
|
|
152
|
+
plan.virtual_updates.append(
|
|
153
|
+
ViewSwap(env_view(environment, model.name), snapshot.physical_table, engine=model.engine)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class Plan:
|
|
159
|
+
"""The full preview of an apply against one environment."""
|
|
160
|
+
|
|
161
|
+
environment: str
|
|
162
|
+
changes: list[ModelChange] = field(default_factory=list)
|
|
163
|
+
backfills: list[BackfillTask] = field(default_factory=list)
|
|
164
|
+
transfers: list[TransferEdge] = field(default_factory=list)
|
|
165
|
+
virtual_updates: list[ViewSwap] = field(default_factory=list)
|
|
166
|
+
promote: list[str] = field(default_factory=list) # model names whose fingerprints to promote
|
|
167
|
+
# Indirectly-changed models whose output is provably identical: their new
|
|
168
|
+
# snapshot points at the previous physical table — recorded, never rebuilt.
|
|
169
|
+
reuses: list[Snapshot] = field(default_factory=list)
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def is_empty(self) -> bool:
|
|
173
|
+
return not (self.changes or self.backfills or self.virtual_updates)
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def has_breaking_changes(self) -> bool:
|
|
177
|
+
return any(c.category is ChangeCategory.BREAKING for c in self.changes)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Resolve a model's query for execution.
|
|
2
|
+
|
|
3
|
+
Direct upstream references are rewritten to their physical tables; references to
|
|
4
|
+
*ephemeral* upstreams are instead inlined as CTEs (the ephemeral model produces
|
|
5
|
+
no table). Ephemeral chains inline recursively, in dependency order, so a model
|
|
6
|
+
that reads an ephemeral staging model gets its logic spliced in at compile time.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from typing import cast
|
|
13
|
+
|
|
14
|
+
from sqlglot import exp
|
|
15
|
+
|
|
16
|
+
from interlace.exceptions import PlanError
|
|
17
|
+
from interlace.graph.project import CompiledModel, CompiledProject
|
|
18
|
+
from interlace.ir.canonicalize import resolve_references
|
|
19
|
+
from interlace.ir.relation import TableRef
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _cte_name(model_name: str) -> str:
|
|
23
|
+
return "_eph_" + model_name.replace(".", "_")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _ref_mapping(
|
|
27
|
+
model: CompiledModel, project: CompiledProject, physical: Mapping[str, TableRef] | None
|
|
28
|
+
) -> dict[str, TableRef]:
|
|
29
|
+
"""Map each dependency to a physical table, or to a (schema-less) CTE name if ephemeral.
|
|
30
|
+
|
|
31
|
+
``physical`` overrides the fingerprint-derived table names — a reused snapshot
|
|
32
|
+
lives at its *previous* physical table, and only the caller (apply) knows that.
|
|
33
|
+
"""
|
|
34
|
+
mapping: dict[str, TableRef] = {}
|
|
35
|
+
for dep in model.dependencies:
|
|
36
|
+
upstream = project.models[dep]
|
|
37
|
+
if upstream.materialise == "ephemeral":
|
|
38
|
+
mapping[dep] = TableRef(schema="", name=_cte_name(dep))
|
|
39
|
+
else:
|
|
40
|
+
mapping[dep] = (physical or {}).get(dep, upstream.physical_table)
|
|
41
|
+
return mapping
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ephemeral_ancestors(model: CompiledModel, project: CompiledProject) -> list[str]:
|
|
45
|
+
"""Ephemeral models reachable through ephemeral chains, in dependency order."""
|
|
46
|
+
order: list[str] = []
|
|
47
|
+
seen: set[str] = set()
|
|
48
|
+
|
|
49
|
+
def visit(name: str) -> None:
|
|
50
|
+
for dep in project.models[name].dependencies:
|
|
51
|
+
if project.models[dep].materialise == "ephemeral" and dep not in seen:
|
|
52
|
+
seen.add(dep)
|
|
53
|
+
visit(dep) # inline an ephemeral's own ephemeral deps first
|
|
54
|
+
order.append(dep)
|
|
55
|
+
|
|
56
|
+
visit(model.name)
|
|
57
|
+
return order
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_model_query(
|
|
61
|
+
model: CompiledModel, project: CompiledProject, physical: Mapping[str, TableRef] | None = None
|
|
62
|
+
) -> exp.Expression:
|
|
63
|
+
"""Rewrite a model's query for execution, inlining ephemeral upstreams as CTEs."""
|
|
64
|
+
if model.ast is None:
|
|
65
|
+
raise PlanError(f"cannot resolve a Python model query: {model.name!r}")
|
|
66
|
+
|
|
67
|
+
body: exp.Expression = resolve_references(model.ast, _ref_mapping(model, project, physical))
|
|
68
|
+
for ancestor_name in _ephemeral_ancestors(model, project):
|
|
69
|
+
ancestor = project.models[ancestor_name]
|
|
70
|
+
if ancestor.ast is None:
|
|
71
|
+
raise PlanError(f"ephemeral model {ancestor_name!r} must be SQL (cannot inline a Python model)")
|
|
72
|
+
cte_body = resolve_references(ancestor.ast, _ref_mapping(ancestor, project, physical))
|
|
73
|
+
body = cast("exp.Query", body).with_(_cte_name(ancestor_name), as_=cte_body)
|
|
74
|
+
return body
|
interlace/plan/run.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Forced / windowed execution — the plan behind ``interlace run``.
|
|
2
|
+
|
|
3
|
+
Unlike ``diff`` (which schedules only models whose fingerprint changed), a run
|
|
4
|
+
rebuilds every model regardless of change detection. Incremental_by_time models
|
|
5
|
+
are expanded over a time window into one task per grain interval, skipping
|
|
6
|
+
intervals already recorded in the ledger (catchup); non-incremental models get a
|
|
7
|
+
single full task. Because unchanged SQL keeps the same fingerprint (and physical
|
|
8
|
+
table), a full model is replaced and a merge model upserts — both pick up new
|
|
9
|
+
source data.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from datetime import datetime, timedelta
|
|
15
|
+
|
|
16
|
+
from interlace.graph.project import CompiledProject
|
|
17
|
+
from interlace.plan.differ import snapshot_of
|
|
18
|
+
from interlace.plan.plan import (
|
|
19
|
+
BackfillTask,
|
|
20
|
+
ChangeType,
|
|
21
|
+
ModelChange,
|
|
22
|
+
Plan,
|
|
23
|
+
ViewSwap,
|
|
24
|
+
collect_transfers,
|
|
25
|
+
env_view,
|
|
26
|
+
schedule_build,
|
|
27
|
+
)
|
|
28
|
+
from interlace.state.interval import Interval, parse_grain, slice_interval
|
|
29
|
+
from interlace.state.snapshot import ChangeCategory
|
|
30
|
+
from interlace.state.store import StateStore
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _window(start: datetime | None, end: datetime | None, grain: timedelta) -> Interval:
|
|
34
|
+
finish = end or datetime.now()
|
|
35
|
+
begin = start or (finish - grain)
|
|
36
|
+
return Interval(begin, finish)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def run_plan(
|
|
40
|
+
compiled: CompiledProject,
|
|
41
|
+
environment: str,
|
|
42
|
+
state: StateStore,
|
|
43
|
+
*,
|
|
44
|
+
start: datetime | None = None,
|
|
45
|
+
end: datetime | None = None,
|
|
46
|
+
select: set[str] | None = None,
|
|
47
|
+
restate: bool = False,
|
|
48
|
+
) -> Plan:
|
|
49
|
+
"""Build a forced plan; incremental models are expanded over ``[start, end)``.
|
|
50
|
+
|
|
51
|
+
``select`` limits which models run (None = all). ``restate`` reprocesses every
|
|
52
|
+
interval in the window instead of skipping the ones already filled (catchup).
|
|
53
|
+
"""
|
|
54
|
+
selected = set(compiled.models) if select is None else select
|
|
55
|
+
plan = Plan(environment=environment)
|
|
56
|
+
for model in compiled.ordered():
|
|
57
|
+
if model.name not in selected:
|
|
58
|
+
continue
|
|
59
|
+
plan.changes.append(ModelChange(model.name, ChangeType.MODIFIED, None, None, model.fingerprint))
|
|
60
|
+
|
|
61
|
+
is_incremental = (
|
|
62
|
+
model.strategy == "incremental_by_time" and model.export is None and model.materialise != "ephemeral"
|
|
63
|
+
)
|
|
64
|
+
if is_incremental:
|
|
65
|
+
grain = parse_grain(model.interval or "1d")
|
|
66
|
+
filled = await state.get_intervals(model.name, model.fingerprint)
|
|
67
|
+
snapshot = snapshot_of(model, ChangeCategory.BREAKING)
|
|
68
|
+
for window in slice_interval(_window(start, end, grain), grain):
|
|
69
|
+
if restate or not filled.covers(window): # restate reprocesses; otherwise catch up
|
|
70
|
+
plan.backfills.append(BackfillTask(snapshot=snapshot, interval=window))
|
|
71
|
+
plan.virtual_updates.append(
|
|
72
|
+
ViewSwap(env_view(environment, model.name), model.physical_table, engine=model.engine)
|
|
73
|
+
)
|
|
74
|
+
else:
|
|
75
|
+
schedule_build(plan, model, snapshot_of(model, ChangeCategory.BREAKING), environment)
|
|
76
|
+
|
|
77
|
+
plan.promote = sorted(selected)
|
|
78
|
+
plan.transfers = collect_transfers(compiled, [task.snapshot.name for task in plan.backfills])
|
|
79
|
+
return plan
|