@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.
@@ -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, EngineError, SqlParseError } from "../engine/adapter.ts"
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
- /** В проекте есть parquet-модели, но путь озера не задан в конфиге. */
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
- /** В проекте есть ducklake-модели, но каталог не задан в конфиге. */
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
- /** «Сейчас» для scdType2-версионирования; по умолчанию Clock. Инъекция для тестов. */
83
+ /** "Now" for scdType2 versioning; defaults to Clock. Injection point for tests. */
76
84
  readonly now?: number
77
- /** Корень parquet-озералокальная директория или s3://… (httpfs). */
85
+ /** Root of the parquet lake a local directory or s3://… (httpfs). */
78
86
  readonly lakePath?: string
79
- /** ATTACH-базы по алиасам (SPEC §9.3) — для export-моделей. */
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-каталог для target: "ducklake" (SPEC §14.5). DuckDB-only. */
89
+ /** DuckLake catalog for target: "ducklake" (SPEC §14.5). DuckDB-only. */
82
90
  readonly ducklake?: { readonly catalog: string; readonly dataPath?: string }
83
91
  /**
84
- * Сколько батчей бэкфилла одной модели считать одновременно (SPEC §5.3).
85
- * Осмыслен только на движке с пулом соединений (Postgres); DuckDB держит
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
- * Межмодельная DAG-конкурентность (SPEC §5.3): сколько моделей строить
91
- * одновременно. Модель стартует, как только готовы её родители из этого
92
- * плана независимые ветки DAG идут параллельно. Осмыслен на движке
93
- * с пулом (Postgres); DuckDB держит одно соединениетам модели строятся
94
- * последовательно, иначе чужие стейтменты вклинивались бы в BEGIN/COMMIT.
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
- * Ретраи упавшего батча бэкфилла (SPEC §5.3): Schedule.exponential от
99
- * baseDelayMs (по умолчанию 500 мс), не больше attempts повторов. Батч
100
- * транзакционен (DELETE+INSERT в одной транзакции, COPY перезаписывает
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
- /** Кто применяет планв журнал (SPEC §6); по умолчанию ОС-пользователь. */
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
- /** ОС-пользователь для журнала планов; в стерильных окружениях (CI) — ''. */
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
- /** Модель просит экспорт в ATTACH-алиас, которого нет в конфиге. */
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
- /** Возможность DuckDB-федерации, недоступная на текущем движке (SPEC §9.3). */
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
- /** Ретраи транзиентного сбоя записи батча; без retry в опцияхкак было. */
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
- /** Для s3://-путей mkdir не нужен (и невозможен) — httpfs пишет напрямую. */
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
- * Прогон аудитов модели (SPEC §8): `self` физика снапшота или подзапрос
156
- * только что загруженного интервала. Запрос аудита возвращает нарушения:
157
- * blocking AuditFailure, warn лог и дальше.
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
- `аудит ${auditDef.name} модели ${model.name.full}: ${violations.length} нарушений (warn)`,
220
+ `audit ${auditDef.name} of model ${model.name.full}: ${violations.length} violations (warn)`,
180
221
  )
181
222
  }
182
223
  })
183
224
 
184
225
  /**
185
- * Эволюция унаследованной таблицы при forward-only (SPEC §5.2): колонки,
186
- * появившиеся в новом запросе, добавляются через ALTER (история получает
187
- * NULL она не переигрывается), удаление колонок реюзом не выражается
188
- * это честный breaking с пересборкой.
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: колонка «${column.name}» есть в унаследованной физике, но исчезла из запросаудаление колонок требует обычного (breaking) применения`,
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
- * Бэкфилл incrementalByTimeRange в таблицу (SPEC §5.3): каждый диапазон
221
- * плана режется на батчи ≤ batchSize; батч транзакция DELETE-диапазона +
222
- * INSERT, после успеха его интервалы помечаются done. Упавший батч
223
- * помечается failed и прерывает apply; уже отмеченное не пересчитывается
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
- // вставка по именам: после forward-only-эволюции порядок колонок физики
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
- const runBatch = (batch: Interval) =>
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
- // аудит свежезагруженного интерваладо отметки done (SPEC §8)
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
- * Бэкфилл в parquet-озеро (SPEC §3.3): интервал = партиция, пересчёт —
281
- * перезапись файла партиции. Транзакция не нужна: недописанная партиция
282
- * не помечена done и будет перезаписана при повторе.
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
- for (const range of action.backfill) {
297
- for (const interval of intervalsWithin(range, kind.interval)) {
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
- // локально COPY пишет во временный файл, rename атомарен (POSIX):
308
- // kill посреди записи не оставит битую партицию, а lookback-пересчёт
309
- // не подсунет читателю view недописанный файл; s3 — прямая запись
310
- // (rename нет; недописанный ключ не помечен done и перезапишется)
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
- // аудит записанной партициидо отметки done; провал = не done → перезапись
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
- * Применяет план (SPEC §5): в топологическом порядке собирает недостающую
332
- * физику и догоняет интервалы (ссылки в SQL резолвятся в физические таблицы
333
- * ЭТОГО плана, не во view окружениясередина apply не видна снаружи),
334
- * затем промоушенпересоздание view + транзакционная запись набора
335
- * в state store.
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
- // физика моделито, что увидят её потребители и view окружения;
349
- // ключ physicalFingerprint: при forward-only это физика старой версии
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
- // его собственные ссылки резолвятся рекурсивно (DAG ацикличен)
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
- // нативная физика или таблица DuckLake-каталогапо цели модели
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
- throw new Error(`ссылка на модель вне плана: ${ref}`)
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-федерация (SPEC §9.3) на других движках не выражается
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 по файлам/URL (read_*)"
460
+ ? "external over files/URL (read_*)"
398
461
  : model.export !== undefined
399
- ? "export в ATTACH-базу"
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. Физика + бэкфилл — DAG-конкурентно (SPEC §5.3): модель стартует,
412
- // как только готовы её родители из этого плана (Deferred-гейты, без
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-каталог нужен и для сборки, и для чистого view-swap
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-сверка (SPEC §3.1): открытая строка без идентичной строки в
520
- // запросе закрывается (изменилась или исчезла); строка запроса без
521
- // идентичной открытой строки вставляется новой открытой версией.
522
- // Идентичные пары не трогаютсяих valid_from не дрожит.
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 alonetheir 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
- // контракт схемы (SPEC §3.2): дрейф типов ловится до сборки
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-реюз (#5): данные идентичны по построению (родители
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
- // у Postgres нет CREATE OR REPLACE TABLE — атомарно через транзакцию
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
- // аудиты собранного снапшотадо промоушена (SPEC §8)
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: done-интервалы старой версии наследуются до бэкфилла
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
- // пустой скелет с формой запроса; при resume уже существует
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
- // добавляются к унаследованной физике (история получает NULL);
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* buildOne(action)
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 в топологическом порядке, а forEach стартует элементы по
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. Промоушен: view-слой окружения
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
- // имя модели из state store; схему восстанавливаем из полного имени
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
- // view-слоя у external и embedded нетони не материализуются
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. Экспорт наружу (SPEC §9.3): после аудитов и промоушенанаружу
722
- // не уезжает непроверенное; готовая витрина пишется в ATTACH-базу
799
+ // 3. Export outward (SPEC §9.3): after audits and promotionnothing
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
- // экспорт освежается, когда есть что везти: сборка/бэкфилл/refresh
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. Состояние окружения + журнал; requireSnapshot защита от гонки
751
- // с janitor (F6): у материализуемых моделей снапшот обязан быть жив
752
- // в момент промоушена, иначе view указал бы на снесённую физику
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 оператора (#5) в журнале: видно, кто и что заявил
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 } }))