@avytheone/efmesh 0.1.0-beta.2 → 0.2.1
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.
- package/CHANGELOG.md +171 -92
- package/README.md +83 -9
- package/README.ru.md +80 -5
- package/SPEC.md +542 -459
- package/package.json +4 -1
- package/src/bin.ts +39 -2
- package/src/cli.ts +505 -85
- package/src/config.ts +15 -15
- package/src/core/audit.ts +15 -11
- package/src/core/errors.ts +37 -7
- package/src/core/graph.ts +6 -6
- package/src/core/interval.ts +18 -18
- package/src/core/model.ts +76 -75
- package/src/core/sql.ts +21 -21
- package/src/discovery.ts +16 -8
- package/src/efmesh.ts +28 -15
- package/src/engine/adapter.ts +29 -12
- package/src/engine/duckdb.ts +7 -7
- package/src/engine/postgres.ts +17 -17
- package/src/error-text.ts +35 -0
- package/src/index.ts +44 -13
- package/src/init.ts +100 -28
- package/src/plan/audit-run.ts +21 -13
- package/src/plan/categorize.ts +10 -7
- package/src/plan/contract.ts +23 -19
- package/src/plan/diff.ts +228 -4
- package/src/plan/executor.ts +223 -121
- package/src/plan/explain.ts +114 -0
- package/src/plan/fingerprint.ts +84 -31
- package/src/plan/graph-html.ts +7 -7
- package/src/plan/janitor.ts +30 -25
- package/src/plan/lineage.ts +20 -16
- package/src/plan/lock.ts +27 -10
- package/src/plan/metrics.ts +4 -4
- package/src/plan/naming.ts +23 -23
- package/src/plan/planner.ts +238 -52
- package/src/plan/run.ts +72 -26
- package/src/plan/schedule.ts +217 -0
- package/src/plan/status.ts +96 -0
- package/src/state/postgres.ts +68 -19
- package/src/state/sqlite.ts +74 -27
- package/src/state/store.ts +88 -45
- package/src/testing/index.ts +27 -26
package/src/plan/executor.ts
CHANGED
|
@@ -8,8 +8,8 @@ import type { Interval } from "../core/interval.ts"
|
|
|
8
8
|
import { intervalsWithin, splitIntoBatches, sqlTimestamp, toIso } from "../core/interval.ts"
|
|
9
9
|
import { columnNames, type AnyModel } from "../core/model.ts"
|
|
10
10
|
import { quoteIdent, render } from "../core/sql.ts"
|
|
11
|
-
import { EngineAdapter } from "../engine/adapter.ts"
|
|
12
|
-
import type { Engine,
|
|
11
|
+
import { EngineAdapter, EngineError } from "../engine/adapter.ts"
|
|
12
|
+
import type { Engine, SqlParseError } from "../engine/adapter.ts"
|
|
13
13
|
import { StateStore } from "../state/store.ts"
|
|
14
14
|
import type { StateError, StateStoreShape } from "../state/store.ts"
|
|
15
15
|
import { Metric } from "effect"
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
import type {
|
|
33
33
|
FingerprintVersionError,
|
|
34
34
|
ForwardOnlyError,
|
|
35
|
+
ReclassifyError,
|
|
35
36
|
InvalidEnvironmentError,
|
|
36
37
|
Plan,
|
|
37
38
|
PlanAction,
|
|
@@ -39,19 +40,27 @@ import type {
|
|
|
39
40
|
|
|
40
41
|
export interface AppliedPlan {
|
|
41
42
|
readonly plan: Plan
|
|
42
|
-
/**
|
|
43
|
+
/** Names of models for which physics was built or backfill was run. */
|
|
43
44
|
readonly built: ReadonlyArray<string>
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
/**
|
|
47
|
+
/** The project has parquet models, but the lake path is not set in the config. */
|
|
47
48
|
export class LakeNotConfiguredError extends Data.TaggedError("LakeNotConfiguredError")<{
|
|
48
49
|
readonly model: string
|
|
49
|
-
}> {
|
|
50
|
+
}> {
|
|
51
|
+
override get message(): string {
|
|
52
|
+
return `model «${this.model}» targets parquet, but no lake path is configured`
|
|
53
|
+
}
|
|
54
|
+
}
|
|
50
55
|
|
|
51
|
-
/**
|
|
56
|
+
/** The project has ducklake models, but the catalog is not set in the config. */
|
|
52
57
|
export class DucklakeNotConfiguredError extends Data.TaggedError("DucklakeNotConfiguredError")<{
|
|
53
58
|
readonly model: string
|
|
54
|
-
}> {
|
|
59
|
+
}> {
|
|
60
|
+
override get message(): string {
|
|
61
|
+
return `model «${this.model}» targets ducklake, but no catalog is configured`
|
|
62
|
+
}
|
|
63
|
+
}
|
|
55
64
|
|
|
56
65
|
export type ApplyError =
|
|
57
66
|
| GraphError
|
|
@@ -66,45 +75,46 @@ export type ApplyError =
|
|
|
66
75
|
| AuditFailure
|
|
67
76
|
| InvalidEnvironmentError
|
|
68
77
|
| ForwardOnlyError
|
|
78
|
+
| ReclassifyError
|
|
69
79
|
| FingerprintVersionError
|
|
70
80
|
| EngineFeatureError
|
|
71
81
|
|
|
72
82
|
export interface ApplyOptions {
|
|
73
|
-
/**
|
|
83
|
+
/** "Now" for scdType2 versioning; defaults to Clock. Injection point for tests. */
|
|
74
84
|
readonly now?: number
|
|
75
|
-
/**
|
|
85
|
+
/** Root of the parquet lake — a local directory or s3://… (httpfs). */
|
|
76
86
|
readonly lakePath?: string
|
|
77
|
-
/** ATTACH
|
|
87
|
+
/** ATTACH databases by alias (SPEC §9.3) — for export models. */
|
|
78
88
|
readonly attach?: Readonly<Record<string, { readonly url: string; readonly options?: string }>>
|
|
79
|
-
/** DuckLake
|
|
89
|
+
/** DuckLake catalog for target: "ducklake" (SPEC §14.5). DuckDB-only. */
|
|
80
90
|
readonly ducklake?: { readonly catalog: string; readonly dataPath?: string }
|
|
81
91
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
92
|
+
* How many backfill batches of one model to compute concurrently (SPEC §5.3).
|
|
93
|
+
* Meaningful only on an engine with a connection pool (Postgres); DuckDB holds
|
|
94
|
+
* a single connection — there backfill is sequential regardless of the value.
|
|
85
95
|
*/
|
|
86
96
|
readonly concurrency?: number
|
|
87
97
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
98
|
+
* Inter-model DAG concurrency (SPEC §5.3): how many models to build at once.
|
|
99
|
+
* A model starts as soon as its parents from this plan are ready —
|
|
100
|
+
* independent DAG branches run in parallel. Meaningful on an engine with a
|
|
101
|
+
* pool (Postgres); DuckDB holds a single connection — there models build
|
|
102
|
+
* sequentially, otherwise foreign statements would wedge into a BEGIN/COMMIT.
|
|
93
103
|
*/
|
|
94
104
|
readonly modelConcurrency?: number
|
|
95
105
|
/**
|
|
96
|
-
*
|
|
97
|
-
* baseDelayMs (
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
106
|
+
* Retries of a failed backfill batch (SPEC §5.3): Schedule.exponential from
|
|
107
|
+
* baseDelayMs (500 ms by default), no more than attempts retries. A batch is
|
|
108
|
+
* transactional (DELETE+INSERT in one transaction, COPY overwrites the whole
|
|
109
|
+
* partition) — a retry is safe. Audits are not retried: an audit failure is
|
|
110
|
+
* deterministic, it is not a transient fault.
|
|
101
111
|
*/
|
|
102
112
|
readonly retry?: { readonly attempts: number; readonly baseDelayMs?: number }
|
|
103
|
-
/**
|
|
113
|
+
/** Who applies the plan — into the journal (SPEC §6); defaults to the OS user. */
|
|
104
114
|
readonly appliedBy?: string
|
|
105
115
|
}
|
|
106
116
|
|
|
107
|
-
/**
|
|
117
|
+
/** OS user for the plan journal; in sterile environments (CI) — ''. */
|
|
108
118
|
const osUser = (): string => {
|
|
109
119
|
try {
|
|
110
120
|
return userInfo().username
|
|
@@ -113,26 +123,50 @@ const osUser = (): string => {
|
|
|
113
123
|
}
|
|
114
124
|
}
|
|
115
125
|
|
|
116
|
-
/**
|
|
126
|
+
/** The model asks to export to an ATTACH alias that is not in the config. */
|
|
117
127
|
export class AttachNotConfiguredError extends Data.TaggedError("AttachNotConfiguredError")<{
|
|
118
128
|
readonly model: string
|
|
119
129
|
readonly attach: string
|
|
120
|
-
}> {
|
|
130
|
+
}> {
|
|
131
|
+
override get message(): string {
|
|
132
|
+
return `model «${this.model}» exports to ATTACH alias «${this.attach}», which is not in the config`
|
|
133
|
+
}
|
|
134
|
+
}
|
|
121
135
|
|
|
122
|
-
/**
|
|
136
|
+
/** A DuckDB federation feature unavailable on the current engine (SPEC §9.3). */
|
|
123
137
|
export class EngineFeatureError extends Data.TaggedError("EngineFeatureError")<{
|
|
124
138
|
readonly model: string
|
|
125
139
|
readonly feature: string
|
|
126
140
|
readonly dialect: string
|
|
127
|
-
}> {
|
|
141
|
+
}> {
|
|
142
|
+
override get message(): string {
|
|
143
|
+
return `model «${this.model}» uses ${this.feature}, unavailable on the ${this.dialect} engine (DuckDB only)`
|
|
144
|
+
}
|
|
145
|
+
}
|
|
128
146
|
|
|
129
|
-
/**
|
|
147
|
+
/**
|
|
148
|
+
* Names the culprit on an engine failure raised while building a model: the
|
|
149
|
+
* adapter cannot know which model a statement belongs to, so the executor
|
|
150
|
+
* attaches it here (#13). Only fills in a model that is not already set, and
|
|
151
|
+
* only for EngineError — every other error in the channel already carries the
|
|
152
|
+
* model in a typed field.
|
|
153
|
+
*/
|
|
154
|
+
const attachModel =
|
|
155
|
+
(model: string) =>
|
|
156
|
+
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
|
|
157
|
+
Effect.mapError(effect, (error) =>
|
|
158
|
+
error instanceof EngineError && error.model === undefined
|
|
159
|
+
? (new EngineError({ sql: error.sql, cause: error.cause, model }) as E & EngineError)
|
|
160
|
+
: error,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
/** Several statements in one engine transaction; rollback on any error. */
|
|
130
164
|
const transactional = (
|
|
131
165
|
engine: Engine,
|
|
132
166
|
statements: ReadonlyArray<string>,
|
|
133
167
|
): Effect.Effect<void, EngineError> => engine.transaction(statements)
|
|
134
168
|
|
|
135
|
-
/**
|
|
169
|
+
/** Retries of a transient batch-write failure; without retry in the options — as before. */
|
|
136
170
|
const withBatchRetry =
|
|
137
171
|
(retry: ApplyOptions["retry"]) =>
|
|
138
172
|
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
|
|
@@ -143,16 +177,25 @@ const withBatchRetry =
|
|
|
143
177
|
schedule: Schedule.exponential(retry.baseDelayMs ?? 500),
|
|
144
178
|
})
|
|
145
179
|
|
|
146
|
-
/**
|
|
180
|
+
/** For s3:// paths mkdir is not needed (and impossible) — httpfs writes directly. */
|
|
147
181
|
const ensureDir = (path: string): Effect.Effect<void> =>
|
|
148
182
|
path.startsWith("s3://")
|
|
149
183
|
? Effect.void
|
|
150
184
|
: Effect.sync(() => mkdirSync(path, { recursive: true }))
|
|
151
185
|
|
|
152
186
|
/**
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
187
|
+
* The rendered statement about to run — DEBUG only (#14): SQL is a firehose and
|
|
188
|
+
* would drown the info lifecycle a human watches; an operator or agent asks for
|
|
189
|
+
* it with `--log-level debug`. The model/env are inherited from the enclosing
|
|
190
|
+
* per-model log scope, so only `sql` is annotated here.
|
|
191
|
+
*/
|
|
192
|
+
const logSql = (sql: string): Effect.Effect<void> =>
|
|
193
|
+
Effect.logDebug("rendered SQL").pipe(Effect.annotateLogs("sql", sql))
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Runs a model's audits (SPEC §8): `self` is the snapshot's physics or a
|
|
197
|
+
* subquery of the just-loaded interval. The audit query returns violations:
|
|
198
|
+
* blocking → AuditFailure, warn → log and continue.
|
|
156
199
|
*/
|
|
157
200
|
const runAudits = (
|
|
158
201
|
engine: Engine,
|
|
@@ -174,16 +217,16 @@ const runAudits = (
|
|
|
174
217
|
})
|
|
175
218
|
}
|
|
176
219
|
yield* Effect.logWarning(
|
|
177
|
-
|
|
220
|
+
`audit ${auditDef.name} of model ${model.name.full}: ${violations.length} violations (warn)`,
|
|
178
221
|
)
|
|
179
222
|
}
|
|
180
223
|
})
|
|
181
224
|
|
|
182
225
|
/**
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
226
|
+
* Evolution of an inherited table under forward-only (SPEC §5.2): columns
|
|
227
|
+
* that appeared in the new query are added via ALTER (history gets NULL — it
|
|
228
|
+
* is not replayed); dropping columns cannot be expressed by reuse — that is
|
|
229
|
+
* an honest breaking change with a rebuild.
|
|
187
230
|
*/
|
|
188
231
|
const evolveTableForForwardOnly = (
|
|
189
232
|
engine: Engine,
|
|
@@ -202,7 +245,7 @@ const evolveTableForForwardOnly = (
|
|
|
202
245
|
model: model.name.full,
|
|
203
246
|
problems: vanished.map(
|
|
204
247
|
(column) =>
|
|
205
|
-
`forward-only:
|
|
248
|
+
`forward-only: column «${column.name}» exists in the inherited physics but vanished from the query — dropping columns requires a regular (breaking) apply`,
|
|
206
249
|
),
|
|
207
250
|
})
|
|
208
251
|
}
|
|
@@ -215,11 +258,11 @@ const evolveTableForForwardOnly = (
|
|
|
215
258
|
})
|
|
216
259
|
|
|
217
260
|
/**
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
* INSERT,
|
|
221
|
-
*
|
|
222
|
-
*
|
|
261
|
+
* Backfill of incrementalByTimeRange into a table (SPEC §5.3): each range of
|
|
262
|
+
* the plan is cut into batches ≤ batchSize; a batch is a transaction of a
|
|
263
|
+
* range DELETE + INSERT, and after success its intervals are marked done. A
|
|
264
|
+
* failed batch is marked failed and aborts apply; what is already marked is
|
|
265
|
+
* not recomputed on retry — backfill resumes from where it stopped.
|
|
223
266
|
*/
|
|
224
267
|
const backfillIntoTable = (
|
|
225
268
|
engine: Engine,
|
|
@@ -234,11 +277,18 @@ const backfillIntoTable = (
|
|
|
234
277
|
Effect.gen(function* () {
|
|
235
278
|
if (model.kind._tag !== "incrementalByTimeRange") return
|
|
236
279
|
const kind = model.kind
|
|
237
|
-
//
|
|
238
|
-
//
|
|
280
|
+
// insert by name: after forward-only evolution the physics' column order
|
|
281
|
+
// may differ from the query; the schema contract guarantees the names match
|
|
239
282
|
const columns = columnNames(model).map(quoteIdent).join(", ")
|
|
240
283
|
|
|
241
|
-
|
|
284
|
+
// connection pool (Postgres) → batches in parallel: intervals do not
|
|
285
|
+
// overlap, each is its own transaction; DuckDB (single connection) — sequentially
|
|
286
|
+
const batches = action.backfill.flatMap((range) =>
|
|
287
|
+
splitIntoBatches(range, kind.interval, kind.batchSize),
|
|
288
|
+
)
|
|
289
|
+
const total = batches.length
|
|
290
|
+
|
|
291
|
+
const runBatch = (batch: Interval, index: number) =>
|
|
242
292
|
Effect.gen(function* () {
|
|
243
293
|
const marks = intervalsWithin(batch, kind.interval).map((interval: Interval) => ({
|
|
244
294
|
startTs: toIso(interval.start),
|
|
@@ -247,13 +297,19 @@ const backfillIntoTable = (
|
|
|
247
297
|
const start = sqlTimestamp(batch.start)
|
|
248
298
|
const end = sqlTimestamp(batch.end)
|
|
249
299
|
const body = render(model.fragment, { resolveRef, interval: { start, end } })
|
|
300
|
+
// info lifecycle: a human watching a long backfill sees n-of-m progress;
|
|
301
|
+
// interval bounds are structured (annotation), not baked into the message
|
|
302
|
+
yield* Effect.logInfo(`backfill batch ${index + 1} of ${total}`).pipe(
|
|
303
|
+
Effect.annotateLogs("interval", `[${toIso(batch.start)}, ${toIso(batch.end)})`),
|
|
304
|
+
)
|
|
305
|
+
yield* logSql(body)
|
|
250
306
|
const markFailed = () =>
|
|
251
307
|
store.markIntervals(action.fingerprint, marks, "failed").pipe(Effect.ignore)
|
|
252
308
|
yield* transactional(engine, [
|
|
253
309
|
`DELETE FROM ${target} WHERE ${quoteIdent(kind.timeColumn)} >= ${start} AND ${quoteIdent(kind.timeColumn)} < ${end}`,
|
|
254
310
|
`INSERT INTO ${target} (${columns}) SELECT ${columns} FROM (${body}) q`,
|
|
255
311
|
]).pipe(withBatchRetry(retry), Effect.tapError(markFailed))
|
|
256
|
-
//
|
|
312
|
+
// audit of the freshly loaded interval — before marking done (SPEC §8)
|
|
257
313
|
yield* runAudits(
|
|
258
314
|
engine,
|
|
259
315
|
model,
|
|
@@ -263,11 +319,6 @@ const backfillIntoTable = (
|
|
|
263
319
|
yield* Metric.update(intervalsDone, marks.length)
|
|
264
320
|
})
|
|
265
321
|
|
|
266
|
-
// пул соединений (Postgres) → батчи параллельно: интервалы не пересекаются,
|
|
267
|
-
// каждый — своя транзакция; DuckDB (одно соединение) — последовательно
|
|
268
|
-
const batches = action.backfill.flatMap((range) =>
|
|
269
|
-
splitIntoBatches(range, kind.interval, kind.batchSize),
|
|
270
|
-
)
|
|
271
322
|
yield* Effect.forEach(batches, runBatch, {
|
|
272
323
|
concurrency: engine.dialect === "duckdb" ? 1 : Math.max(1, concurrency),
|
|
273
324
|
discard: true,
|
|
@@ -275,9 +326,9 @@ const backfillIntoTable = (
|
|
|
275
326
|
})
|
|
276
327
|
|
|
277
328
|
/**
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
329
|
+
* Backfill into the parquet lake (SPEC §3.3): interval = partition, a
|
|
330
|
+
* recompute is a rewrite of the partition file. No transaction is needed: an
|
|
331
|
+
* unfinished partition is not marked done and will be overwritten on retry.
|
|
281
332
|
*/
|
|
282
333
|
const backfillIntoParquet = (
|
|
283
334
|
engine: Engine,
|
|
@@ -291,21 +342,32 @@ const backfillIntoParquet = (
|
|
|
291
342
|
Effect.gen(function* () {
|
|
292
343
|
if (model.kind._tag !== "incrementalByTimeRange") return
|
|
293
344
|
const kind = model.kind
|
|
294
|
-
|
|
295
|
-
|
|
345
|
+
// interval = partition; flat list so progress reads "n of m" across all ranges
|
|
346
|
+
const intervals = action.backfill.flatMap((range) =>
|
|
347
|
+
intervalsWithin(range, kind.interval),
|
|
348
|
+
)
|
|
349
|
+
const total = intervals.length
|
|
350
|
+
yield* Effect.forEach(
|
|
351
|
+
intervals,
|
|
352
|
+
(interval, index) =>
|
|
353
|
+
Effect.gen(function* () {
|
|
296
354
|
const partition = `${prefix}/interval=${intervalKey(kind.interval, interval.start)}`
|
|
297
355
|
yield* ensureDir(partition)
|
|
298
356
|
const body = render(model.fragment, {
|
|
299
357
|
resolveRef,
|
|
300
358
|
interval: { start: sqlTimestamp(interval.start), end: sqlTimestamp(interval.end) },
|
|
301
359
|
})
|
|
360
|
+
yield* Effect.logInfo(`backfill partition ${index + 1} of ${total}`).pipe(
|
|
361
|
+
Effect.annotateLogs("interval", `[${toIso(interval.start)}, ${toIso(interval.end)})`),
|
|
362
|
+
)
|
|
363
|
+
yield* logSql(body)
|
|
302
364
|
const mark = [{ startTs: toIso(interval.start), endTs: toIso(interval.end) }]
|
|
303
365
|
const markFailed = () =>
|
|
304
366
|
store.markIntervals(action.fingerprint, mark, "failed").pipe(Effect.ignore)
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
// (rename
|
|
367
|
+
// locally COPY writes to a temp file, rename is atomic (POSIX): a kill
|
|
368
|
+
// mid-write leaves no broken partition, and a lookback recompute does
|
|
369
|
+
// not hand the view reader an unfinished file; s3 — direct write
|
|
370
|
+
// (no rename; the unfinished key is not marked done and gets overwritten)
|
|
309
371
|
const target = `${partition}/data.parquet`
|
|
310
372
|
const writePath = partition.startsWith("s3://") ? target : `${target}.tmp`
|
|
311
373
|
yield* engine
|
|
@@ -315,22 +377,23 @@ const backfillIntoParquet = (
|
|
|
315
377
|
yield* Effect.sync(() => renameSync(writePath, target))
|
|
316
378
|
}
|
|
317
379
|
const file = target.replaceAll(`'`, `''`)
|
|
318
|
-
//
|
|
380
|
+
// audit of the written partition — before marking done; failure = not done → rewrite
|
|
319
381
|
yield* runAudits(engine, model, `(SELECT * FROM read_parquet('${file}'))`).pipe(
|
|
320
382
|
Effect.tapError(markFailed),
|
|
321
383
|
)
|
|
322
384
|
yield* store.markIntervals(action.fingerprint, mark, "done")
|
|
323
385
|
yield* Metric.update(intervalsDone, 1)
|
|
324
|
-
|
|
325
|
-
|
|
386
|
+
}),
|
|
387
|
+
{ discard: true },
|
|
388
|
+
)
|
|
326
389
|
})
|
|
327
390
|
|
|
328
391
|
/**
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
392
|
+
* Applies the plan (SPEC §5): in topological order it builds the missing
|
|
393
|
+
* physics and catches up intervals (refs in SQL resolve to the physical
|
|
394
|
+
* tables of THIS plan, not to the environment's views — the middle of apply
|
|
395
|
+
* is not visible from outside), then promotion — recreating the views + a
|
|
396
|
+
* transactional write of the set into the state store.
|
|
334
397
|
*/
|
|
335
398
|
export const applyPlan = (
|
|
336
399
|
plan: Plan,
|
|
@@ -343,13 +406,13 @@ export const applyPlan = (
|
|
|
343
406
|
const lakePath = options?.lakePath
|
|
344
407
|
const nowMs = options?.now ?? (yield* Clock.currentTimeMillis)
|
|
345
408
|
|
|
346
|
-
//
|
|
347
|
-
//
|
|
409
|
+
// a model's physics — what its consumers and the environment views will
|
|
410
|
+
// see; the key is physicalFingerprint: under forward-only it is the old version's physics
|
|
348
411
|
const physicalFpOf = new Map(plan.actions.map((a) => [a.name, a.physicalFingerprint]))
|
|
349
412
|
const physicalFor = (model: AnyModel, fingerprint: string): string => {
|
|
350
413
|
if (model.kind._tag === "external") return externalSourceRef(model.kind.source)
|
|
351
|
-
// embedded
|
|
352
|
-
//
|
|
414
|
+
// embedded is not materialized — it is inlined into the consumer as a
|
|
415
|
+
// subquery, its own refs resolve recursively (the DAG is acyclic)
|
|
353
416
|
if (model.kind._tag === "embedded") return `(${render(model.fragment, { resolveRef })})`
|
|
354
417
|
if (model.target === "parquet") {
|
|
355
418
|
if (lakePath === undefined) throw new LakeNotConfiguredError({ model: model.name.full })
|
|
@@ -357,7 +420,7 @@ export const applyPlan = (
|
|
|
357
420
|
}
|
|
358
421
|
return tableRef(model, fingerprint)
|
|
359
422
|
}
|
|
360
|
-
//
|
|
423
|
+
// native physics or a DuckLake-catalog table — by the model's target
|
|
361
424
|
const tableRef = (model: AnyModel, fingerprint: string): string =>
|
|
362
425
|
model.target === "ducklake"
|
|
363
426
|
? ducklakeRef(model.name, fingerprint)
|
|
@@ -366,12 +429,14 @@ export const applyPlan = (
|
|
|
366
429
|
const model = graph.models.get(ref)
|
|
367
430
|
const fingerprint = physicalFpOf.get(ref)
|
|
368
431
|
if (model === undefined || fingerprint === undefined) {
|
|
369
|
-
|
|
432
|
+
// invariant: deps and the plan are built from the same graph, so every
|
|
433
|
+
// ref resolves — a miss is a defect in efmesh, not user-recoverable
|
|
434
|
+
throw new Error(`invariant violated: reference to a model «${ref}» outside the plan`)
|
|
370
435
|
}
|
|
371
436
|
return physicalFor(model, fingerprint)
|
|
372
437
|
}
|
|
373
438
|
|
|
374
|
-
// parquet
|
|
439
|
+
// parquet models without a lake — fail before any actions
|
|
375
440
|
for (const action of plan.actions) {
|
|
376
441
|
const model = graph.models.get(action.name)
|
|
377
442
|
if (model === undefined) continue
|
|
@@ -381,8 +446,8 @@ export const applyPlan = (
|
|
|
381
446
|
if (model.target === "ducklake" && options?.ducklake === undefined) {
|
|
382
447
|
return yield* new DucklakeNotConfiguredError({ model: model.name.full })
|
|
383
448
|
}
|
|
384
|
-
// DuckDB
|
|
385
|
-
//
|
|
449
|
+
// DuckDB federation (SPEC §9.3) cannot be expressed on other engines —
|
|
450
|
+
// an honest error before any actions
|
|
386
451
|
if (engine.dialect !== "duckdb") {
|
|
387
452
|
const feature =
|
|
388
453
|
model.target === "parquet"
|
|
@@ -392,9 +457,9 @@ export const applyPlan = (
|
|
|
392
457
|
: model.kind._tag === "seed"
|
|
393
458
|
? "seed (read_csv/read_json)"
|
|
394
459
|
: model.kind._tag === "external" && model.kind.source._tag === "files"
|
|
395
|
-
? "external
|
|
460
|
+
? "external over files/URL (read_*)"
|
|
396
461
|
: model.export !== undefined
|
|
397
|
-
? "export
|
|
462
|
+
? "export to an ATTACH database"
|
|
398
463
|
: undefined
|
|
399
464
|
if (feature !== undefined) {
|
|
400
465
|
return yield* new EngineFeatureError({
|
|
@@ -406,13 +471,13 @@ export const applyPlan = (
|
|
|
406
471
|
}
|
|
407
472
|
}
|
|
408
473
|
|
|
409
|
-
// 1.
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
474
|
+
// 1. Physics + backfill — DAG-concurrent (SPEC §5.3): a model starts as
|
|
475
|
+
// soon as its parents from this plan are ready (Deferred gates, no wave
|
|
476
|
+
// barriers); independent branches run in parallel on an engine with a
|
|
477
|
+
// pool; a failed parent does not open its gate — descendants are not built
|
|
413
478
|
yield* engine.execute(`CREATE SCHEMA IF NOT EXISTS "${physicalSchema}"`)
|
|
414
|
-
// ducklake
|
|
415
|
-
// view
|
|
479
|
+
// the ducklake catalog is needed both for building and for a pure
|
|
480
|
+
// view-swap — environment views reference catalog tables by alias
|
|
416
481
|
if (options?.ducklake !== undefined && engine.dialect === "duckdb") {
|
|
417
482
|
yield* engine.execute(ducklakeAttachSql(options.ducklake))
|
|
418
483
|
}
|
|
@@ -431,9 +496,10 @@ export const applyPlan = (
|
|
|
431
496
|
case "external":
|
|
432
497
|
return
|
|
433
498
|
case "embedded": {
|
|
434
|
-
//
|
|
435
|
-
//
|
|
499
|
+
// there is no physics — but the contract and the version's audits are
|
|
500
|
+
// checked here so consumers do not carry a broken subquery into themselves
|
|
436
501
|
const body = render(model.fragment, { resolveRef })
|
|
502
|
+
yield* logSql(body)
|
|
437
503
|
yield* checkContract(engine, model, body)
|
|
438
504
|
yield* runAudits(engine, model, `(${body})`)
|
|
439
505
|
yield* store.upsertSnapshot({
|
|
@@ -450,6 +516,7 @@ export const applyPlan = (
|
|
|
450
516
|
case "seed": {
|
|
451
517
|
const reader = model.kind.format === "csv" ? "read_csv" : "read_json"
|
|
452
518
|
const body = `SELECT * FROM ${reader}('${model.kind.file.replaceAll(`'`, `''`)}')`
|
|
519
|
+
yield* logSql(body)
|
|
453
520
|
yield* checkContract(engine, model, body)
|
|
454
521
|
const target = tableRef(model, action.physicalFingerprint)
|
|
455
522
|
yield* engine.execute(`CREATE OR REPLACE TABLE ${target} AS ${body}`)
|
|
@@ -467,12 +534,13 @@ export const applyPlan = (
|
|
|
467
534
|
}
|
|
468
535
|
case "incrementalByUniqueKey": {
|
|
469
536
|
const body = render(model.fragment, { resolveRef })
|
|
537
|
+
yield* logSql(body)
|
|
470
538
|
yield* checkContract(engine, model, body)
|
|
471
539
|
const target = tableRef(model, action.physicalFingerprint)
|
|
472
540
|
yield* engine.execute(
|
|
473
541
|
`CREATE TABLE IF NOT EXISTS ${target} AS SELECT * FROM (${body}) q LIMIT 0`,
|
|
474
542
|
)
|
|
475
|
-
// upsert:
|
|
543
|
+
// upsert: rows with keys from the fresh query are replaced, the rest live on
|
|
476
544
|
const keys = model.kind.key.map(quoteIdent).join(", ")
|
|
477
545
|
yield* transactional(engine, [
|
|
478
546
|
`DELETE FROM ${target} WHERE (${keys}) IN (SELECT ${keys} FROM (${body}) q)`,
|
|
@@ -493,6 +561,7 @@ export const applyPlan = (
|
|
|
493
561
|
case "scdType2": {
|
|
494
562
|
const scd = model.kind
|
|
495
563
|
const body = render(model.fragment, { resolveRef })
|
|
564
|
+
yield* logSql(body)
|
|
496
565
|
const managed = new Set([scd.validFrom, scd.validTo])
|
|
497
566
|
yield* checkContract(engine, model, body, managed)
|
|
498
567
|
const target = tableRef(model, action.physicalFingerprint)
|
|
@@ -514,10 +583,10 @@ export const applyPlan = (
|
|
|
514
583
|
const sameAsOpen = cols
|
|
515
584
|
.map((column) => `t.${column} IS NOT DISTINCT FROM q.${column}`)
|
|
516
585
|
.join(" AND ")
|
|
517
|
-
// SCD2
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
586
|
+
// SCD2 reconciliation (SPEC §3.1): an open row with no identical row
|
|
587
|
+
// in the query is closed (it changed or vanished); a query row with
|
|
588
|
+
// no identical open row is inserted as a new open version. Identical
|
|
589
|
+
// pairs are left alone — their valid_from does not jitter.
|
|
521
590
|
yield* transactional(engine, [
|
|
522
591
|
`UPDATE ${target} SET ${to} = ${ts}
|
|
523
592
|
WHERE ${to} IS NULL
|
|
@@ -544,8 +613,25 @@ export const applyPlan = (
|
|
|
544
613
|
case "view":
|
|
545
614
|
case "full": {
|
|
546
615
|
const body = render(model.fragment, { resolveRef })
|
|
547
|
-
|
|
616
|
+
yield* logSql(body)
|
|
617
|
+
// schema contract (SPEC §3.2): type drift is caught before building
|
|
548
618
|
yield* checkContract(engine, model, body)
|
|
619
|
+
// indirect reuse (#5): the data is identical by construction (parents
|
|
620
|
+
// non-breaking/forward-only, own body unchanged) — the rebuild is
|
|
621
|
+
// skipped, audits run against the inherited physics
|
|
622
|
+
if (model.kind._tag === "full" && action.reusedFrom !== undefined) {
|
|
623
|
+
yield* runAudits(engine, model, physicalFor(model, action.physicalFingerprint))
|
|
624
|
+
yield* store.upsertSnapshot({
|
|
625
|
+
name: action.name,
|
|
626
|
+
fingerprint: action.fingerprint,
|
|
627
|
+
physicalFp: action.physicalFingerprint,
|
|
628
|
+
canonicalAst: action.canonicalAst ?? "",
|
|
629
|
+
renderedSql: body,
|
|
630
|
+
kind: model.kind._tag,
|
|
631
|
+
fingerprintVersion: FINGERPRINT_VERSION,
|
|
632
|
+
})
|
|
633
|
+
break
|
|
634
|
+
}
|
|
549
635
|
if (model.kind._tag === "full" && model.target === "parquet") {
|
|
550
636
|
const prefix = parquetPrefix(lakePath!, model.name, action.physicalFingerprint)
|
|
551
637
|
yield* ensureDir(prefix)
|
|
@@ -559,14 +645,14 @@ export const applyPlan = (
|
|
|
559
645
|
} else if (engine.dialect === "duckdb") {
|
|
560
646
|
yield* engine.execute(`CREATE OR REPLACE TABLE ${target} AS ${body}`)
|
|
561
647
|
} else {
|
|
562
|
-
//
|
|
648
|
+
// Postgres has no CREATE OR REPLACE TABLE — atomically via a transaction
|
|
563
649
|
yield* transactional(engine, [
|
|
564
650
|
`DROP TABLE IF EXISTS ${target}`,
|
|
565
651
|
`CREATE TABLE ${target} AS ${body}`,
|
|
566
652
|
])
|
|
567
653
|
}
|
|
568
654
|
}
|
|
569
|
-
//
|
|
655
|
+
// audits of the built snapshot — before promotion (SPEC §8)
|
|
570
656
|
yield* runAudits(engine, model, physicalFor(model, action.physicalFingerprint))
|
|
571
657
|
yield* store.upsertSnapshot({
|
|
572
658
|
name: action.name,
|
|
@@ -586,8 +672,8 @@ export const applyPlan = (
|
|
|
586
672
|
interval: { start: zero, end: zero },
|
|
587
673
|
})
|
|
588
674
|
yield* checkContract(engine, model, emptyBody)
|
|
589
|
-
// forward-only:
|
|
590
|
-
//
|
|
675
|
+
// forward-only: the old version's done-intervals are inherited before
|
|
676
|
+
// backfill — what is done is not replayed, only the missing is recomputed
|
|
591
677
|
if (action.reusedFrom !== undefined) {
|
|
592
678
|
const inherited = (yield* store.listIntervals(action.reusedFrom))
|
|
593
679
|
.filter((record) => record.status === "done")
|
|
@@ -615,14 +701,14 @@ export const applyPlan = (
|
|
|
615
701
|
options?.retry,
|
|
616
702
|
)
|
|
617
703
|
} else {
|
|
618
|
-
//
|
|
704
|
+
// an empty skeleton with the query's shape; on resume it already exists
|
|
619
705
|
const target = tableRef(model, action.physicalFingerprint)
|
|
620
706
|
yield* engine.execute(
|
|
621
707
|
`CREATE TABLE IF NOT EXISTS ${target} AS SELECT * FROM (${emptyBody}) q LIMIT 0`,
|
|
622
708
|
)
|
|
623
|
-
// forward-only
|
|
624
|
-
//
|
|
625
|
-
//
|
|
709
|
+
// forward-only on a live table: columns that appeared in the query
|
|
710
|
+
// are added to the inherited physics (history gets NULL); ones that
|
|
711
|
+
// vanished from the query signal that reuse is impossible
|
|
626
712
|
if (action.change === "forward-only") {
|
|
627
713
|
yield* evolveTableForForwardOnly(engine, model, target, emptyBody)
|
|
628
714
|
}
|
|
@@ -654,7 +740,7 @@ export const applyPlan = (
|
|
|
654
740
|
const runOne = (action: PlanAction): Effect.Effect<void, ApplyError> =>
|
|
655
741
|
Effect.gen(function* () {
|
|
656
742
|
const model = graph.models.get(action.name)!
|
|
657
|
-
//
|
|
743
|
+
// wait only for parents that this same plan builds; the rest are ready
|
|
658
744
|
yield* Effect.forEach(
|
|
659
745
|
[...model.deps].flatMap((dep) => {
|
|
660
746
|
const gate = gates.get(dep)
|
|
@@ -663,14 +749,24 @@ export const applyPlan = (
|
|
|
663
749
|
(gate) => Deferred.await(gate),
|
|
664
750
|
{ discard: true },
|
|
665
751
|
)
|
|
666
|
-
yield*
|
|
752
|
+
const startedAt = yield* Clock.currentTimeMillis
|
|
753
|
+
yield* Effect.logInfo("build start")
|
|
754
|
+
yield* buildOne(action).pipe(attachModel(action.name))
|
|
755
|
+
yield* Effect.logInfo(
|
|
756
|
+
`build done in ${(yield* Clock.currentTimeMillis) - startedAt} ms`,
|
|
757
|
+
)
|
|
667
758
|
yield* Metric.update(snapshotsBuilt, 1)
|
|
668
759
|
yield* Deferred.succeed(gates.get(action.name)!, undefined)
|
|
669
|
-
})
|
|
760
|
+
}).pipe(
|
|
761
|
+
// one structured log scope per model: model/env/change flow into every
|
|
762
|
+
// line the build emits (SQL at debug, backfill progress at info), so a
|
|
763
|
+
// machine reader can group them without parsing the message text (#14)
|
|
764
|
+
Effect.annotateLogs({ model: action.name, env: plan.env, change: action.change }),
|
|
765
|
+
)
|
|
670
766
|
|
|
671
|
-
// working
|
|
672
|
-
//
|
|
673
|
-
//
|
|
767
|
+
// working is in topological order, and forEach starts elements in order —
|
|
768
|
+
// the earliest unfinished element always has its parents ready, so waiting
|
|
769
|
+
// on gates does not eat up slots into a deadlock
|
|
674
770
|
yield* Effect.forEach(working, runOne, {
|
|
675
771
|
concurrency:
|
|
676
772
|
engine.dialect === "duckdb" ? 1 : Math.max(1, options?.modelConcurrency ?? 4),
|
|
@@ -678,11 +774,11 @@ export const applyPlan = (
|
|
|
678
774
|
})
|
|
679
775
|
const built = working.map((action) => action.name)
|
|
680
776
|
|
|
681
|
-
// 2.
|
|
777
|
+
// 2. Promotion: the environment's view layer
|
|
682
778
|
for (const action of plan.actions) {
|
|
683
779
|
if (action.change === "unchanged") continue
|
|
684
780
|
if (action.change === "removed") {
|
|
685
|
-
//
|
|
781
|
+
// the model name comes from the state store; the schema is recovered from the full name
|
|
686
782
|
const [schema, table] = action.name.split(".") as [string, string]
|
|
687
783
|
yield* engine.execute(
|
|
688
784
|
`DROP VIEW IF EXISTS "${envSchema(plan.env, schema)}"."${table}"`,
|
|
@@ -690,7 +786,7 @@ export const applyPlan = (
|
|
|
690
786
|
continue
|
|
691
787
|
}
|
|
692
788
|
const model = graph.models.get(action.name)!
|
|
693
|
-
//
|
|
789
|
+
// external and embedded have no view layer — they are not materialized
|
|
694
790
|
if (model.kind._tag === "external" || model.kind._tag === "embedded") continue
|
|
695
791
|
yield* engine.execute(
|
|
696
792
|
`CREATE SCHEMA IF NOT EXISTS "${envSchema(plan.env, model.name.schema)}"`,
|
|
@@ -700,13 +796,13 @@ export const applyPlan = (
|
|
|
700
796
|
)
|
|
701
797
|
}
|
|
702
798
|
|
|
703
|
-
// 3.
|
|
704
|
-
//
|
|
799
|
+
// 3. Export outward (SPEC §9.3): after audits and promotion — nothing
|
|
800
|
+
// unverified leaves; the finished mart is written to the ATTACH database
|
|
705
801
|
for (const action of plan.actions) {
|
|
706
802
|
if (action.change === "removed") continue
|
|
707
803
|
const model = graph.models.get(action.name)!
|
|
708
804
|
if (model.export === undefined) continue
|
|
709
|
-
//
|
|
805
|
+
// the export is refreshed when there is something to ship: build/backfill/refresh
|
|
710
806
|
if (!action.build && action.backfill.length === 0 && !action.refresh) continue
|
|
711
807
|
const attach = options?.attach?.[model.export.attach]
|
|
712
808
|
if (attach === undefined) {
|
|
@@ -729,9 +825,9 @@ export const applyPlan = (
|
|
|
729
825
|
)
|
|
730
826
|
}
|
|
731
827
|
|
|
732
|
-
// 4.
|
|
733
|
-
//
|
|
734
|
-
//
|
|
828
|
+
// 4. Environment state + journal; requireSnapshot guards against the race
|
|
829
|
+
// with the janitor (F6): for materialized models the snapshot must be alive
|
|
830
|
+
// at the moment of promotion, otherwise the view would point at removed physics
|
|
735
831
|
yield* store.promote(
|
|
736
832
|
plan.env,
|
|
737
833
|
plan.actions
|
|
@@ -748,6 +844,8 @@ export const applyPlan = (
|
|
|
748
844
|
actions: plan.actions.map((a) => ({
|
|
749
845
|
name: a.name,
|
|
750
846
|
change: a.change,
|
|
847
|
+
// operator override (#5) in the journal: it is visible who declared what
|
|
848
|
+
...(a.reclassifiedFrom !== undefined ? { reclassifiedFrom: a.reclassifiedFrom } : {}),
|
|
751
849
|
fingerprint: a.fingerprint.slice(0, 8),
|
|
752
850
|
build: a.build,
|
|
753
851
|
backfill: a.backfill.map((r) => `[${toIso(r.start)}, ${toIso(r.end)})`),
|
|
@@ -756,5 +854,9 @@ export const applyPlan = (
|
|
|
756
854
|
options?.appliedBy ?? osUser(),
|
|
757
855
|
)
|
|
758
856
|
|
|
857
|
+
yield* Effect.logInfo(
|
|
858
|
+
built.length > 0 ? `promoted, built ${built.join(", ")}` : "promoted (view-swap only)",
|
|
859
|
+
).pipe(Effect.annotateLogs("env", plan.env))
|
|
860
|
+
|
|
759
861
|
return { plan, built }
|
|
760
862
|
}).pipe(Effect.withSpan("efmesh.apply", { attributes: { env: plan.env } }))
|