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,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