@avytheone/efmesh 0.2.0 → 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 +35 -0
- package/README.md +22 -0
- package/README.ru.md +23 -0
- package/SPEC.md +1 -1
- package/package.json +1 -1
- package/src/bin.ts +39 -2
- package/src/cli.ts +204 -124
- 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 +26 -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 +12 -11
- package/src/init.ts +100 -28
- package/src/plan/audit-run.ts +21 -13
- package/src/plan/categorize.ts +7 -7
- package/src/plan/contract.ts +23 -19
- package/src/plan/diff.ts +33 -29
- package/src/plan/executor.ts +207 -125
- package/src/plan/explain.ts +29 -29
- package/src/plan/fingerprint.ts +35 -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 +107 -90
- package/src/plan/run.ts +27 -19
- package/src/plan/schedule.ts +46 -41
- package/src/plan/status.ts +15 -14
- package/src/state/postgres.ts +19 -19
- package/src/state/sqlite.ts +27 -27
- package/src/state/store.ts +67 -55
- 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"
|
|
@@ -40,19 +40,27 @@ import type {
|
|
|
40
40
|
|
|
41
41
|
export interface AppliedPlan {
|
|
42
42
|
readonly plan: Plan
|
|
43
|
-
/**
|
|
43
|
+
/** Names of models for which physics was built or backfill was run. */
|
|
44
44
|
readonly built: ReadonlyArray<string>
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
/**
|
|
47
|
+
/** The project has parquet models, but the lake path is not set in the config. */
|
|
48
48
|
export class LakeNotConfiguredError extends Data.TaggedError("LakeNotConfiguredError")<{
|
|
49
49
|
readonly model: string
|
|
50
|
-
}> {
|
|
50
|
+
}> {
|
|
51
|
+
override get message(): string {
|
|
52
|
+
return `model «${this.model}» targets parquet, but no lake path is configured`
|
|
53
|
+
}
|
|
54
|
+
}
|
|
51
55
|
|
|
52
|
-
/**
|
|
56
|
+
/** The project has ducklake models, but the catalog is not set in the config. */
|
|
53
57
|
export class DucklakeNotConfiguredError extends Data.TaggedError("DucklakeNotConfiguredError")<{
|
|
54
58
|
readonly model: string
|
|
55
|
-
}> {
|
|
59
|
+
}> {
|
|
60
|
+
override get message(): string {
|
|
61
|
+
return `model «${this.model}» targets ducklake, but no catalog is configured`
|
|
62
|
+
}
|
|
63
|
+
}
|
|
56
64
|
|
|
57
65
|
export type ApplyError =
|
|
58
66
|
| GraphError
|
|
@@ -72,41 +80,41 @@ export type ApplyError =
|
|
|
72
80
|
| EngineFeatureError
|
|
73
81
|
|
|
74
82
|
export interface ApplyOptions {
|
|
75
|
-
/**
|
|
83
|
+
/** "Now" for scdType2 versioning; defaults to Clock. Injection point for tests. */
|
|
76
84
|
readonly now?: number
|
|
77
|
-
/**
|
|
85
|
+
/** Root of the parquet lake — a local directory or s3://… (httpfs). */
|
|
78
86
|
readonly lakePath?: string
|
|
79
|
-
/** ATTACH
|
|
87
|
+
/** ATTACH databases by alias (SPEC §9.3) — for export models. */
|
|
80
88
|
readonly attach?: Readonly<Record<string, { readonly url: string; readonly options?: string }>>
|
|
81
|
-
/** DuckLake
|
|
89
|
+
/** DuckLake catalog for target: "ducklake" (SPEC §14.5). DuckDB-only. */
|
|
82
90
|
readonly ducklake?: { readonly catalog: string; readonly dataPath?: string }
|
|
83
91
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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.
|
|
87
95
|
*/
|
|
88
96
|
readonly concurrency?: number
|
|
89
97
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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.
|
|
95
103
|
*/
|
|
96
104
|
readonly modelConcurrency?: number
|
|
97
105
|
/**
|
|
98
|
-
*
|
|
99
|
-
* baseDelayMs (
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
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.
|
|
103
111
|
*/
|
|
104
112
|
readonly retry?: { readonly attempts: number; readonly baseDelayMs?: number }
|
|
105
|
-
/**
|
|
113
|
+
/** Who applies the plan — into the journal (SPEC §6); defaults to the OS user. */
|
|
106
114
|
readonly appliedBy?: string
|
|
107
115
|
}
|
|
108
116
|
|
|
109
|
-
/**
|
|
117
|
+
/** OS user for the plan journal; in sterile environments (CI) — ''. */
|
|
110
118
|
const osUser = (): string => {
|
|
111
119
|
try {
|
|
112
120
|
return userInfo().username
|
|
@@ -115,26 +123,50 @@ const osUser = (): string => {
|
|
|
115
123
|
}
|
|
116
124
|
}
|
|
117
125
|
|
|
118
|
-
/**
|
|
126
|
+
/** The model asks to export to an ATTACH alias that is not in the config. */
|
|
119
127
|
export class AttachNotConfiguredError extends Data.TaggedError("AttachNotConfiguredError")<{
|
|
120
128
|
readonly model: string
|
|
121
129
|
readonly attach: string
|
|
122
|
-
}> {
|
|
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
|
+
}
|
|
123
135
|
|
|
124
|
-
/**
|
|
136
|
+
/** A DuckDB federation feature unavailable on the current engine (SPEC §9.3). */
|
|
125
137
|
export class EngineFeatureError extends Data.TaggedError("EngineFeatureError")<{
|
|
126
138
|
readonly model: string
|
|
127
139
|
readonly feature: string
|
|
128
140
|
readonly dialect: string
|
|
129
|
-
}> {
|
|
141
|
+
}> {
|
|
142
|
+
override get message(): string {
|
|
143
|
+
return `model «${this.model}» uses ${this.feature}, unavailable on the ${this.dialect} engine (DuckDB only)`
|
|
144
|
+
}
|
|
145
|
+
}
|
|
130
146
|
|
|
131
|
-
/**
|
|
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. */
|
|
132
164
|
const transactional = (
|
|
133
165
|
engine: Engine,
|
|
134
166
|
statements: ReadonlyArray<string>,
|
|
135
167
|
): Effect.Effect<void, EngineError> => engine.transaction(statements)
|
|
136
168
|
|
|
137
|
-
/**
|
|
169
|
+
/** Retries of a transient batch-write failure; without retry in the options — as before. */
|
|
138
170
|
const withBatchRetry =
|
|
139
171
|
(retry: ApplyOptions["retry"]) =>
|
|
140
172
|
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
|
|
@@ -145,16 +177,25 @@ const withBatchRetry =
|
|
|
145
177
|
schedule: Schedule.exponential(retry.baseDelayMs ?? 500),
|
|
146
178
|
})
|
|
147
179
|
|
|
148
|
-
/**
|
|
180
|
+
/** For s3:// paths mkdir is not needed (and impossible) — httpfs writes directly. */
|
|
149
181
|
const ensureDir = (path: string): Effect.Effect<void> =>
|
|
150
182
|
path.startsWith("s3://")
|
|
151
183
|
? Effect.void
|
|
152
184
|
: Effect.sync(() => mkdirSync(path, { recursive: true }))
|
|
153
185
|
|
|
154
186
|
/**
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
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.
|
|
158
199
|
*/
|
|
159
200
|
const runAudits = (
|
|
160
201
|
engine: Engine,
|
|
@@ -176,16 +217,16 @@ const runAudits = (
|
|
|
176
217
|
})
|
|
177
218
|
}
|
|
178
219
|
yield* Effect.logWarning(
|
|
179
|
-
|
|
220
|
+
`audit ${auditDef.name} of model ${model.name.full}: ${violations.length} violations (warn)`,
|
|
180
221
|
)
|
|
181
222
|
}
|
|
182
223
|
})
|
|
183
224
|
|
|
184
225
|
/**
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
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.
|
|
189
230
|
*/
|
|
190
231
|
const evolveTableForForwardOnly = (
|
|
191
232
|
engine: Engine,
|
|
@@ -204,7 +245,7 @@ const evolveTableForForwardOnly = (
|
|
|
204
245
|
model: model.name.full,
|
|
205
246
|
problems: vanished.map(
|
|
206
247
|
(column) =>
|
|
207
|
-
`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`,
|
|
208
249
|
),
|
|
209
250
|
})
|
|
210
251
|
}
|
|
@@ -217,11 +258,11 @@ const evolveTableForForwardOnly = (
|
|
|
217
258
|
})
|
|
218
259
|
|
|
219
260
|
/**
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
* INSERT,
|
|
223
|
-
*
|
|
224
|
-
*
|
|
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.
|
|
225
266
|
*/
|
|
226
267
|
const backfillIntoTable = (
|
|
227
268
|
engine: Engine,
|
|
@@ -236,11 +277,18 @@ const backfillIntoTable = (
|
|
|
236
277
|
Effect.gen(function* () {
|
|
237
278
|
if (model.kind._tag !== "incrementalByTimeRange") return
|
|
238
279
|
const kind = model.kind
|
|
239
|
-
//
|
|
240
|
-
//
|
|
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
|
|
241
282
|
const columns = columnNames(model).map(quoteIdent).join(", ")
|
|
242
283
|
|
|
243
|
-
|
|
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) =>
|
|
244
292
|
Effect.gen(function* () {
|
|
245
293
|
const marks = intervalsWithin(batch, kind.interval).map((interval: Interval) => ({
|
|
246
294
|
startTs: toIso(interval.start),
|
|
@@ -249,13 +297,19 @@ const backfillIntoTable = (
|
|
|
249
297
|
const start = sqlTimestamp(batch.start)
|
|
250
298
|
const end = sqlTimestamp(batch.end)
|
|
251
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)
|
|
252
306
|
const markFailed = () =>
|
|
253
307
|
store.markIntervals(action.fingerprint, marks, "failed").pipe(Effect.ignore)
|
|
254
308
|
yield* transactional(engine, [
|
|
255
309
|
`DELETE FROM ${target} WHERE ${quoteIdent(kind.timeColumn)} >= ${start} AND ${quoteIdent(kind.timeColumn)} < ${end}`,
|
|
256
310
|
`INSERT INTO ${target} (${columns}) SELECT ${columns} FROM (${body}) q`,
|
|
257
311
|
]).pipe(withBatchRetry(retry), Effect.tapError(markFailed))
|
|
258
|
-
//
|
|
312
|
+
// audit of the freshly loaded interval — before marking done (SPEC §8)
|
|
259
313
|
yield* runAudits(
|
|
260
314
|
engine,
|
|
261
315
|
model,
|
|
@@ -265,11 +319,6 @@ const backfillIntoTable = (
|
|
|
265
319
|
yield* Metric.update(intervalsDone, marks.length)
|
|
266
320
|
})
|
|
267
321
|
|
|
268
|
-
// пул соединений (Postgres) → батчи параллельно: интервалы не пересекаются,
|
|
269
|
-
// каждый — своя транзакция; DuckDB (одно соединение) — последовательно
|
|
270
|
-
const batches = action.backfill.flatMap((range) =>
|
|
271
|
-
splitIntoBatches(range, kind.interval, kind.batchSize),
|
|
272
|
-
)
|
|
273
322
|
yield* Effect.forEach(batches, runBatch, {
|
|
274
323
|
concurrency: engine.dialect === "duckdb" ? 1 : Math.max(1, concurrency),
|
|
275
324
|
discard: true,
|
|
@@ -277,9 +326,9 @@ const backfillIntoTable = (
|
|
|
277
326
|
})
|
|
278
327
|
|
|
279
328
|
/**
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
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.
|
|
283
332
|
*/
|
|
284
333
|
const backfillIntoParquet = (
|
|
285
334
|
engine: Engine,
|
|
@@ -293,21 +342,32 @@ const backfillIntoParquet = (
|
|
|
293
342
|
Effect.gen(function* () {
|
|
294
343
|
if (model.kind._tag !== "incrementalByTimeRange") return
|
|
295
344
|
const kind = model.kind
|
|
296
|
-
|
|
297
|
-
|
|
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* () {
|
|
298
354
|
const partition = `${prefix}/interval=${intervalKey(kind.interval, interval.start)}`
|
|
299
355
|
yield* ensureDir(partition)
|
|
300
356
|
const body = render(model.fragment, {
|
|
301
357
|
resolveRef,
|
|
302
358
|
interval: { start: sqlTimestamp(interval.start), end: sqlTimestamp(interval.end) },
|
|
303
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)
|
|
304
364
|
const mark = [{ startTs: toIso(interval.start), endTs: toIso(interval.end) }]
|
|
305
365
|
const markFailed = () =>
|
|
306
366
|
store.markIntervals(action.fingerprint, mark, "failed").pipe(Effect.ignore)
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
// (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)
|
|
311
371
|
const target = `${partition}/data.parquet`
|
|
312
372
|
const writePath = partition.startsWith("s3://") ? target : `${target}.tmp`
|
|
313
373
|
yield* engine
|
|
@@ -317,22 +377,23 @@ const backfillIntoParquet = (
|
|
|
317
377
|
yield* Effect.sync(() => renameSync(writePath, target))
|
|
318
378
|
}
|
|
319
379
|
const file = target.replaceAll(`'`, `''`)
|
|
320
|
-
//
|
|
380
|
+
// audit of the written partition — before marking done; failure = not done → rewrite
|
|
321
381
|
yield* runAudits(engine, model, `(SELECT * FROM read_parquet('${file}'))`).pipe(
|
|
322
382
|
Effect.tapError(markFailed),
|
|
323
383
|
)
|
|
324
384
|
yield* store.markIntervals(action.fingerprint, mark, "done")
|
|
325
385
|
yield* Metric.update(intervalsDone, 1)
|
|
326
|
-
|
|
327
|
-
|
|
386
|
+
}),
|
|
387
|
+
{ discard: true },
|
|
388
|
+
)
|
|
328
389
|
})
|
|
329
390
|
|
|
330
391
|
/**
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
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.
|
|
336
397
|
*/
|
|
337
398
|
export const applyPlan = (
|
|
338
399
|
plan: Plan,
|
|
@@ -345,13 +406,13 @@ export const applyPlan = (
|
|
|
345
406
|
const lakePath = options?.lakePath
|
|
346
407
|
const nowMs = options?.now ?? (yield* Clock.currentTimeMillis)
|
|
347
408
|
|
|
348
|
-
//
|
|
349
|
-
//
|
|
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
|
|
350
411
|
const physicalFpOf = new Map(plan.actions.map((a) => [a.name, a.physicalFingerprint]))
|
|
351
412
|
const physicalFor = (model: AnyModel, fingerprint: string): string => {
|
|
352
413
|
if (model.kind._tag === "external") return externalSourceRef(model.kind.source)
|
|
353
|
-
// embedded
|
|
354
|
-
//
|
|
414
|
+
// embedded is not materialized — it is inlined into the consumer as a
|
|
415
|
+
// subquery, its own refs resolve recursively (the DAG is acyclic)
|
|
355
416
|
if (model.kind._tag === "embedded") return `(${render(model.fragment, { resolveRef })})`
|
|
356
417
|
if (model.target === "parquet") {
|
|
357
418
|
if (lakePath === undefined) throw new LakeNotConfiguredError({ model: model.name.full })
|
|
@@ -359,7 +420,7 @@ export const applyPlan = (
|
|
|
359
420
|
}
|
|
360
421
|
return tableRef(model, fingerprint)
|
|
361
422
|
}
|
|
362
|
-
//
|
|
423
|
+
// native physics or a DuckLake-catalog table — by the model's target
|
|
363
424
|
const tableRef = (model: AnyModel, fingerprint: string): string =>
|
|
364
425
|
model.target === "ducklake"
|
|
365
426
|
? ducklakeRef(model.name, fingerprint)
|
|
@@ -368,12 +429,14 @@ export const applyPlan = (
|
|
|
368
429
|
const model = graph.models.get(ref)
|
|
369
430
|
const fingerprint = physicalFpOf.get(ref)
|
|
370
431
|
if (model === undefined || fingerprint === undefined) {
|
|
371
|
-
|
|
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`)
|
|
372
435
|
}
|
|
373
436
|
return physicalFor(model, fingerprint)
|
|
374
437
|
}
|
|
375
438
|
|
|
376
|
-
// parquet
|
|
439
|
+
// parquet models without a lake — fail before any actions
|
|
377
440
|
for (const action of plan.actions) {
|
|
378
441
|
const model = graph.models.get(action.name)
|
|
379
442
|
if (model === undefined) continue
|
|
@@ -383,8 +446,8 @@ export const applyPlan = (
|
|
|
383
446
|
if (model.target === "ducklake" && options?.ducklake === undefined) {
|
|
384
447
|
return yield* new DucklakeNotConfiguredError({ model: model.name.full })
|
|
385
448
|
}
|
|
386
|
-
// DuckDB
|
|
387
|
-
//
|
|
449
|
+
// DuckDB federation (SPEC §9.3) cannot be expressed on other engines —
|
|
450
|
+
// an honest error before any actions
|
|
388
451
|
if (engine.dialect !== "duckdb") {
|
|
389
452
|
const feature =
|
|
390
453
|
model.target === "parquet"
|
|
@@ -394,9 +457,9 @@ export const applyPlan = (
|
|
|
394
457
|
: model.kind._tag === "seed"
|
|
395
458
|
? "seed (read_csv/read_json)"
|
|
396
459
|
: model.kind._tag === "external" && model.kind.source._tag === "files"
|
|
397
|
-
? "external
|
|
460
|
+
? "external over files/URL (read_*)"
|
|
398
461
|
: model.export !== undefined
|
|
399
|
-
? "export
|
|
462
|
+
? "export to an ATTACH database"
|
|
400
463
|
: undefined
|
|
401
464
|
if (feature !== undefined) {
|
|
402
465
|
return yield* new EngineFeatureError({
|
|
@@ -408,13 +471,13 @@ export const applyPlan = (
|
|
|
408
471
|
}
|
|
409
472
|
}
|
|
410
473
|
|
|
411
|
-
// 1.
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
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
|
|
415
478
|
yield* engine.execute(`CREATE SCHEMA IF NOT EXISTS "${physicalSchema}"`)
|
|
416
|
-
// ducklake
|
|
417
|
-
// view
|
|
479
|
+
// the ducklake catalog is needed both for building and for a pure
|
|
480
|
+
// view-swap — environment views reference catalog tables by alias
|
|
418
481
|
if (options?.ducklake !== undefined && engine.dialect === "duckdb") {
|
|
419
482
|
yield* engine.execute(ducklakeAttachSql(options.ducklake))
|
|
420
483
|
}
|
|
@@ -433,9 +496,10 @@ export const applyPlan = (
|
|
|
433
496
|
case "external":
|
|
434
497
|
return
|
|
435
498
|
case "embedded": {
|
|
436
|
-
//
|
|
437
|
-
//
|
|
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
|
|
438
501
|
const body = render(model.fragment, { resolveRef })
|
|
502
|
+
yield* logSql(body)
|
|
439
503
|
yield* checkContract(engine, model, body)
|
|
440
504
|
yield* runAudits(engine, model, `(${body})`)
|
|
441
505
|
yield* store.upsertSnapshot({
|
|
@@ -452,6 +516,7 @@ export const applyPlan = (
|
|
|
452
516
|
case "seed": {
|
|
453
517
|
const reader = model.kind.format === "csv" ? "read_csv" : "read_json"
|
|
454
518
|
const body = `SELECT * FROM ${reader}('${model.kind.file.replaceAll(`'`, `''`)}')`
|
|
519
|
+
yield* logSql(body)
|
|
455
520
|
yield* checkContract(engine, model, body)
|
|
456
521
|
const target = tableRef(model, action.physicalFingerprint)
|
|
457
522
|
yield* engine.execute(`CREATE OR REPLACE TABLE ${target} AS ${body}`)
|
|
@@ -469,12 +534,13 @@ export const applyPlan = (
|
|
|
469
534
|
}
|
|
470
535
|
case "incrementalByUniqueKey": {
|
|
471
536
|
const body = render(model.fragment, { resolveRef })
|
|
537
|
+
yield* logSql(body)
|
|
472
538
|
yield* checkContract(engine, model, body)
|
|
473
539
|
const target = tableRef(model, action.physicalFingerprint)
|
|
474
540
|
yield* engine.execute(
|
|
475
541
|
`CREATE TABLE IF NOT EXISTS ${target} AS SELECT * FROM (${body}) q LIMIT 0`,
|
|
476
542
|
)
|
|
477
|
-
// upsert:
|
|
543
|
+
// upsert: rows with keys from the fresh query are replaced, the rest live on
|
|
478
544
|
const keys = model.kind.key.map(quoteIdent).join(", ")
|
|
479
545
|
yield* transactional(engine, [
|
|
480
546
|
`DELETE FROM ${target} WHERE (${keys}) IN (SELECT ${keys} FROM (${body}) q)`,
|
|
@@ -495,6 +561,7 @@ export const applyPlan = (
|
|
|
495
561
|
case "scdType2": {
|
|
496
562
|
const scd = model.kind
|
|
497
563
|
const body = render(model.fragment, { resolveRef })
|
|
564
|
+
yield* logSql(body)
|
|
498
565
|
const managed = new Set([scd.validFrom, scd.validTo])
|
|
499
566
|
yield* checkContract(engine, model, body, managed)
|
|
500
567
|
const target = tableRef(model, action.physicalFingerprint)
|
|
@@ -516,10 +583,10 @@ export const applyPlan = (
|
|
|
516
583
|
const sameAsOpen = cols
|
|
517
584
|
.map((column) => `t.${column} IS NOT DISTINCT FROM q.${column}`)
|
|
518
585
|
.join(" AND ")
|
|
519
|
-
// SCD2
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
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.
|
|
523
590
|
yield* transactional(engine, [
|
|
524
591
|
`UPDATE ${target} SET ${to} = ${ts}
|
|
525
592
|
WHERE ${to} IS NULL
|
|
@@ -546,11 +613,12 @@ export const applyPlan = (
|
|
|
546
613
|
case "view":
|
|
547
614
|
case "full": {
|
|
548
615
|
const body = render(model.fragment, { resolveRef })
|
|
549
|
-
|
|
616
|
+
yield* logSql(body)
|
|
617
|
+
// schema contract (SPEC §3.2): type drift is caught before building
|
|
550
618
|
yield* checkContract(engine, model, body)
|
|
551
|
-
// indirect
|
|
552
|
-
// non-breaking/forward-only,
|
|
553
|
-
//
|
|
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
|
|
554
622
|
if (model.kind._tag === "full" && action.reusedFrom !== undefined) {
|
|
555
623
|
yield* runAudits(engine, model, physicalFor(model, action.physicalFingerprint))
|
|
556
624
|
yield* store.upsertSnapshot({
|
|
@@ -577,14 +645,14 @@ export const applyPlan = (
|
|
|
577
645
|
} else if (engine.dialect === "duckdb") {
|
|
578
646
|
yield* engine.execute(`CREATE OR REPLACE TABLE ${target} AS ${body}`)
|
|
579
647
|
} else {
|
|
580
|
-
//
|
|
648
|
+
// Postgres has no CREATE OR REPLACE TABLE — atomically via a transaction
|
|
581
649
|
yield* transactional(engine, [
|
|
582
650
|
`DROP TABLE IF EXISTS ${target}`,
|
|
583
651
|
`CREATE TABLE ${target} AS ${body}`,
|
|
584
652
|
])
|
|
585
653
|
}
|
|
586
654
|
}
|
|
587
|
-
//
|
|
655
|
+
// audits of the built snapshot — before promotion (SPEC §8)
|
|
588
656
|
yield* runAudits(engine, model, physicalFor(model, action.physicalFingerprint))
|
|
589
657
|
yield* store.upsertSnapshot({
|
|
590
658
|
name: action.name,
|
|
@@ -604,8 +672,8 @@ export const applyPlan = (
|
|
|
604
672
|
interval: { start: zero, end: zero },
|
|
605
673
|
})
|
|
606
674
|
yield* checkContract(engine, model, emptyBody)
|
|
607
|
-
// forward-only:
|
|
608
|
-
//
|
|
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
|
|
609
677
|
if (action.reusedFrom !== undefined) {
|
|
610
678
|
const inherited = (yield* store.listIntervals(action.reusedFrom))
|
|
611
679
|
.filter((record) => record.status === "done")
|
|
@@ -633,14 +701,14 @@ export const applyPlan = (
|
|
|
633
701
|
options?.retry,
|
|
634
702
|
)
|
|
635
703
|
} else {
|
|
636
|
-
//
|
|
704
|
+
// an empty skeleton with the query's shape; on resume it already exists
|
|
637
705
|
const target = tableRef(model, action.physicalFingerprint)
|
|
638
706
|
yield* engine.execute(
|
|
639
707
|
`CREATE TABLE IF NOT EXISTS ${target} AS SELECT * FROM (${emptyBody}) q LIMIT 0`,
|
|
640
708
|
)
|
|
641
|
-
// forward-only
|
|
642
|
-
//
|
|
643
|
-
//
|
|
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
|
|
644
712
|
if (action.change === "forward-only") {
|
|
645
713
|
yield* evolveTableForForwardOnly(engine, model, target, emptyBody)
|
|
646
714
|
}
|
|
@@ -672,7 +740,7 @@ export const applyPlan = (
|
|
|
672
740
|
const runOne = (action: PlanAction): Effect.Effect<void, ApplyError> =>
|
|
673
741
|
Effect.gen(function* () {
|
|
674
742
|
const model = graph.models.get(action.name)!
|
|
675
|
-
//
|
|
743
|
+
// wait only for parents that this same plan builds; the rest are ready
|
|
676
744
|
yield* Effect.forEach(
|
|
677
745
|
[...model.deps].flatMap((dep) => {
|
|
678
746
|
const gate = gates.get(dep)
|
|
@@ -681,14 +749,24 @@ export const applyPlan = (
|
|
|
681
749
|
(gate) => Deferred.await(gate),
|
|
682
750
|
{ discard: true },
|
|
683
751
|
)
|
|
684
|
-
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
|
+
)
|
|
685
758
|
yield* Metric.update(snapshotsBuilt, 1)
|
|
686
759
|
yield* Deferred.succeed(gates.get(action.name)!, undefined)
|
|
687
|
-
})
|
|
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
|
+
)
|
|
688
766
|
|
|
689
|
-
// working
|
|
690
|
-
//
|
|
691
|
-
//
|
|
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
|
|
692
770
|
yield* Effect.forEach(working, runOne, {
|
|
693
771
|
concurrency:
|
|
694
772
|
engine.dialect === "duckdb" ? 1 : Math.max(1, options?.modelConcurrency ?? 4),
|
|
@@ -696,11 +774,11 @@ export const applyPlan = (
|
|
|
696
774
|
})
|
|
697
775
|
const built = working.map((action) => action.name)
|
|
698
776
|
|
|
699
|
-
// 2.
|
|
777
|
+
// 2. Promotion: the environment's view layer
|
|
700
778
|
for (const action of plan.actions) {
|
|
701
779
|
if (action.change === "unchanged") continue
|
|
702
780
|
if (action.change === "removed") {
|
|
703
|
-
//
|
|
781
|
+
// the model name comes from the state store; the schema is recovered from the full name
|
|
704
782
|
const [schema, table] = action.name.split(".") as [string, string]
|
|
705
783
|
yield* engine.execute(
|
|
706
784
|
`DROP VIEW IF EXISTS "${envSchema(plan.env, schema)}"."${table}"`,
|
|
@@ -708,7 +786,7 @@ export const applyPlan = (
|
|
|
708
786
|
continue
|
|
709
787
|
}
|
|
710
788
|
const model = graph.models.get(action.name)!
|
|
711
|
-
//
|
|
789
|
+
// external and embedded have no view layer — they are not materialized
|
|
712
790
|
if (model.kind._tag === "external" || model.kind._tag === "embedded") continue
|
|
713
791
|
yield* engine.execute(
|
|
714
792
|
`CREATE SCHEMA IF NOT EXISTS "${envSchema(plan.env, model.name.schema)}"`,
|
|
@@ -718,13 +796,13 @@ export const applyPlan = (
|
|
|
718
796
|
)
|
|
719
797
|
}
|
|
720
798
|
|
|
721
|
-
// 3.
|
|
722
|
-
//
|
|
799
|
+
// 3. Export outward (SPEC §9.3): after audits and promotion — nothing
|
|
800
|
+
// unverified leaves; the finished mart is written to the ATTACH database
|
|
723
801
|
for (const action of plan.actions) {
|
|
724
802
|
if (action.change === "removed") continue
|
|
725
803
|
const model = graph.models.get(action.name)!
|
|
726
804
|
if (model.export === undefined) continue
|
|
727
|
-
//
|
|
805
|
+
// the export is refreshed when there is something to ship: build/backfill/refresh
|
|
728
806
|
if (!action.build && action.backfill.length === 0 && !action.refresh) continue
|
|
729
807
|
const attach = options?.attach?.[model.export.attach]
|
|
730
808
|
if (attach === undefined) {
|
|
@@ -747,9 +825,9 @@ export const applyPlan = (
|
|
|
747
825
|
)
|
|
748
826
|
}
|
|
749
827
|
|
|
750
|
-
// 4.
|
|
751
|
-
//
|
|
752
|
-
//
|
|
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
|
|
753
831
|
yield* store.promote(
|
|
754
832
|
plan.env,
|
|
755
833
|
plan.actions
|
|
@@ -766,7 +844,7 @@ export const applyPlan = (
|
|
|
766
844
|
actions: plan.actions.map((a) => ({
|
|
767
845
|
name: a.name,
|
|
768
846
|
change: a.change,
|
|
769
|
-
// override
|
|
847
|
+
// operator override (#5) in the journal: it is visible who declared what
|
|
770
848
|
...(a.reclassifiedFrom !== undefined ? { reclassifiedFrom: a.reclassifiedFrom } : {}),
|
|
771
849
|
fingerprint: a.fingerprint.slice(0, 8),
|
|
772
850
|
build: a.build,
|
|
@@ -776,5 +854,9 @@ export const applyPlan = (
|
|
|
776
854
|
options?.appliedBy ?? osUser(),
|
|
777
855
|
)
|
|
778
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
|
+
|
|
779
861
|
return { plan, built }
|
|
780
862
|
}).pipe(Effect.withSpan("efmesh.apply", { attributes: { env: plan.env } }))
|