@avytheone/efmesh 0.1.0-beta.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 +96 -0
- package/LICENSE +21 -0
- package/README.md +196 -0
- package/README.ru.md +203 -0
- package/SPEC.md +641 -0
- package/package.json +57 -0
- package/src/bin.ts +12 -0
- package/src/cli.ts +518 -0
- package/src/config.ts +44 -0
- package/src/core/audit.ts +88 -0
- package/src/core/errors.ts +27 -0
- package/src/core/graph.ts +76 -0
- package/src/core/interval.ts +119 -0
- package/src/core/model.ts +475 -0
- package/src/core/sql.ts +200 -0
- package/src/discovery.ts +77 -0
- package/src/efmesh.ts +94 -0
- package/src/engine/adapter.ts +52 -0
- package/src/engine/duckdb.ts +117 -0
- package/src/engine/postgres.ts +119 -0
- package/src/index.ts +114 -0
- package/src/init.ts +88 -0
- package/src/plan/audit-run.ts +101 -0
- package/src/plan/categorize.ts +41 -0
- package/src/plan/contract.ts +114 -0
- package/src/plan/diff.ts +36 -0
- package/src/plan/executor.ts +760 -0
- package/src/plan/fingerprint.ts +125 -0
- package/src/plan/graph-html.ts +157 -0
- package/src/plan/janitor.ts +130 -0
- package/src/plan/lineage.ts +143 -0
- package/src/plan/lock.ts +37 -0
- package/src/plan/metrics.ts +14 -0
- package/src/plan/naming.ts +91 -0
- package/src/plan/planner.ts +252 -0
- package/src/plan/run.ts +70 -0
- package/src/state/postgres.ts +396 -0
- package/src/state/sqlite.ts +407 -0
- package/src/state/store.ts +161 -0
- package/src/testing/index.ts +193 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { SeedReadError } from "../core/errors.ts"
|
|
4
|
+
import type { ModelGraph } from "../core/graph.ts"
|
|
5
|
+
import type { ModelKind } from "../core/model.ts"
|
|
6
|
+
import { columnNames } from "../core/model.ts"
|
|
7
|
+
import { render } from "../core/sql.ts"
|
|
8
|
+
import type { EngineError, SqlParseError } from "../engine/adapter.ts"
|
|
9
|
+
import { EngineAdapter } from "../engine/adapter.ts"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Fingerprint снапшота (SPEC §4): хэш канонизированного AST (родной парсер
|
|
13
|
+
* движка, переформатирование запроса fingerprint не меняет), метаданных,
|
|
14
|
+
* влияющих на данные, и fingerprint'ов прямых зависимостей (транзитивность).
|
|
15
|
+
*
|
|
16
|
+
* `batchSize`, `lookback`, `start` и `description` в fingerprint не входят:
|
|
17
|
+
* они меняют исполнение или объём истории, но не форму данных — недостающие
|
|
18
|
+
* интервалы учёт увидит сам.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Версия алгоритма fingerprint — КОНТРАКТ (SPEC §4). Fingerprint зависит от
|
|
23
|
+
* канонизации движка (json_serialize_sql DuckDB / libpg_query) и состава
|
|
24
|
+
* payload ниже: любое их изменение молча пере-фингерпринтит все модели
|
|
25
|
+
* пользователя и вынуждает полный ребилд склада. Поэтому: (1) канонизация
|
|
26
|
+
* заморожена golden-тестами (test/fingerprint-golden.test.ts) — красный
|
|
27
|
+
* тест при апгрейде DuckDB/libpg_query означает дрейф канона; (2) осознанная
|
|
28
|
+
* смена алгоритма = инкремент этой константы + история миграции, снапшоты
|
|
29
|
+
* другой версии план не сравнивает, а честно останавливается.
|
|
30
|
+
*/
|
|
31
|
+
export const FINGERPRINT_VERSION = 1
|
|
32
|
+
|
|
33
|
+
const sha256 = (input: string): string => {
|
|
34
|
+
const hasher = new Bun.CryptoHasher("sha256")
|
|
35
|
+
hasher.update(input)
|
|
36
|
+
return hasher.digest("hex")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Canonical-рендер: ссылки — логические имена, границы — плейсхолдеры $start/$end. */
|
|
40
|
+
export const canonicalSql = (graph: ModelGraph, name: string): string => {
|
|
41
|
+
const model = graph.models.get(name)
|
|
42
|
+
if (model === undefined) throw new Error(`модели ${name} нет в графе`)
|
|
43
|
+
return render(model.fragment, { resolveRef: (ref) => ref })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Часть kind, влияющая на данные. Для seed — хэш содержимого файла: правка данных = новая версия. */
|
|
47
|
+
const kindPayload = (
|
|
48
|
+
model: { readonly name: { readonly full: string } },
|
|
49
|
+
kind: ModelKind,
|
|
50
|
+
): Effect.Effect<unknown, SeedReadError> => {
|
|
51
|
+
switch (kind._tag) {
|
|
52
|
+
case "full":
|
|
53
|
+
case "view":
|
|
54
|
+
case "embedded":
|
|
55
|
+
return Effect.succeed({ _tag: kind._tag })
|
|
56
|
+
case "incrementalByTimeRange":
|
|
57
|
+
return Effect.succeed({
|
|
58
|
+
_tag: kind._tag,
|
|
59
|
+
timeColumn: kind.timeColumn,
|
|
60
|
+
interval: kind.interval,
|
|
61
|
+
})
|
|
62
|
+
case "incrementalByUniqueKey":
|
|
63
|
+
return Effect.succeed({ _tag: kind._tag, key: kind.key })
|
|
64
|
+
case "scdType2":
|
|
65
|
+
return Effect.succeed({
|
|
66
|
+
_tag: kind._tag,
|
|
67
|
+
key: kind.key,
|
|
68
|
+
validFrom: kind.validFrom,
|
|
69
|
+
validTo: kind.validTo,
|
|
70
|
+
})
|
|
71
|
+
case "external":
|
|
72
|
+
return Effect.succeed({ _tag: kind._tag, source: kind.source })
|
|
73
|
+
case "seed":
|
|
74
|
+
return Effect.try({
|
|
75
|
+
try: () => ({
|
|
76
|
+
_tag: kind._tag,
|
|
77
|
+
file: kind.file,
|
|
78
|
+
contentHash: sha256(readFileSync(kind.file, "utf8")),
|
|
79
|
+
}),
|
|
80
|
+
catch: (cause) =>
|
|
81
|
+
new SeedReadError({ model: model.name.full, file: kind.file, cause }),
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ModelVersion {
|
|
87
|
+
readonly fingerprint: string
|
|
88
|
+
/** Канонический AST тела; null у external. Хранится в снапшоте для категоризации (§5.2). */
|
|
89
|
+
readonly ast: string | null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Fingerprint всех моделей графа; транзитивность — через хэши родителей. */
|
|
93
|
+
export const fingerprintGraph = (
|
|
94
|
+
graph: ModelGraph,
|
|
95
|
+
): Effect.Effect<
|
|
96
|
+
ReadonlyMap<string, ModelVersion>,
|
|
97
|
+
EngineError | SqlParseError | SeedReadError,
|
|
98
|
+
EngineAdapter
|
|
99
|
+
> =>
|
|
100
|
+
Effect.gen(function* () {
|
|
101
|
+
const engine = yield* EngineAdapter
|
|
102
|
+
const versions = new Map<string, ModelVersion>()
|
|
103
|
+
for (const name of graph.order) {
|
|
104
|
+
const model = graph.models.get(name)!
|
|
105
|
+
// у external и seed нет SQL — версия определяется источником/файлом и схемой
|
|
106
|
+
const ast =
|
|
107
|
+
model.kind._tag === "external" || model.kind._tag === "seed"
|
|
108
|
+
? null
|
|
109
|
+
: yield* engine.canonicalize(canonicalSql(graph, name))
|
|
110
|
+
const parents = [...model.deps]
|
|
111
|
+
.sort()
|
|
112
|
+
.map((dep) => `${dep}=${versions.get(dep)!.fingerprint}`)
|
|
113
|
+
const payload = JSON.stringify({
|
|
114
|
+
ast,
|
|
115
|
+
kind: yield* kindPayload(model, model.kind),
|
|
116
|
+
grain: model.grain,
|
|
117
|
+
columns: columnNames(model),
|
|
118
|
+
// смена цели материализации = новая физика, потребители перечитают её
|
|
119
|
+
target: model.target,
|
|
120
|
+
parents,
|
|
121
|
+
})
|
|
122
|
+
versions.set(name, { fingerprint: sha256(payload), ast })
|
|
123
|
+
}
|
|
124
|
+
return versions
|
|
125
|
+
})
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { ModelGraph } from "../core/graph.ts"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `efmesh graph --html` (SPEC §11): самодостаточная страница с DAG моделей —
|
|
5
|
+
* SVG без внешних зависимостей, слои по длиннейшему пути от корней,
|
|
6
|
+
* рёбра — кривые Безье, подсветка соседей по наведению.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const KIND_COLOR: Record<string, string> = {
|
|
10
|
+
external: "#8899aa",
|
|
11
|
+
seed: "#b08968",
|
|
12
|
+
view: "#5fa8d3",
|
|
13
|
+
embedded: "#9d8cd6",
|
|
14
|
+
full: "#4f772d",
|
|
15
|
+
incrementalByTimeRange: "#e07a5f",
|
|
16
|
+
incrementalByUniqueKey: "#d4a373",
|
|
17
|
+
scdType2: "#c05299",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const escapeHtml = (text: string): string =>
|
|
21
|
+
text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll(`"`, """)
|
|
22
|
+
|
|
23
|
+
const NODE_W = 210
|
|
24
|
+
const NODE_H = 46
|
|
25
|
+
const COL_GAP = 90
|
|
26
|
+
const ROW_GAP = 26
|
|
27
|
+
const PAD = 32
|
|
28
|
+
|
|
29
|
+
export const renderGraphHtml = (graph: ModelGraph): string => {
|
|
30
|
+
// слой = длиннейший путь от корней: родители всегда левее потомков
|
|
31
|
+
const depth = new Map<string, number>()
|
|
32
|
+
for (const name of graph.order) {
|
|
33
|
+
const model = graph.models.get(name)!
|
|
34
|
+
let level = 0
|
|
35
|
+
for (const dep of model.deps) level = Math.max(level, (depth.get(dep) ?? 0) + 1)
|
|
36
|
+
depth.set(name, level)
|
|
37
|
+
}
|
|
38
|
+
const columns = new Map<number, Array<string>>()
|
|
39
|
+
for (const name of graph.order) {
|
|
40
|
+
const level = depth.get(name)!
|
|
41
|
+
const column = columns.get(level) ?? []
|
|
42
|
+
column.push(name)
|
|
43
|
+
columns.set(level, column)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const position = new Map<string, { x: number; y: number }>()
|
|
47
|
+
for (const [level, names] of columns) {
|
|
48
|
+
names.forEach((name, index) => {
|
|
49
|
+
position.set(name, {
|
|
50
|
+
x: PAD + level * (NODE_W + COL_GAP),
|
|
51
|
+
y: PAD + index * (NODE_H + ROW_GAP),
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
const width = PAD * 2 + columns.size * (NODE_W + COL_GAP) - COL_GAP
|
|
56
|
+
const height =
|
|
57
|
+
PAD * 2 +
|
|
58
|
+
Math.max(...[...columns.values()].map((names) => names.length)) * (NODE_H + ROW_GAP) -
|
|
59
|
+
ROW_GAP
|
|
60
|
+
|
|
61
|
+
const edges: Array<string> = []
|
|
62
|
+
for (const name of graph.order) {
|
|
63
|
+
const model = graph.models.get(name)!
|
|
64
|
+
const to = position.get(name)!
|
|
65
|
+
for (const dep of model.deps) {
|
|
66
|
+
const from = position.get(dep)!
|
|
67
|
+
const x1 = from.x + NODE_W
|
|
68
|
+
const y1 = from.y + NODE_H / 2
|
|
69
|
+
const x2 = to.x
|
|
70
|
+
const y2 = to.y + NODE_H / 2
|
|
71
|
+
const bend = (x2 - x1) / 2
|
|
72
|
+
edges.push(
|
|
73
|
+
`<path class="edge" data-from="${escapeHtml(dep)}" data-to="${escapeHtml(name)}" d="M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}"/>`,
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const nodes = graph.order.map((name) => {
|
|
79
|
+
const model = graph.models.get(name)!
|
|
80
|
+
const { x, y } = position.get(name)!
|
|
81
|
+
const color = KIND_COLOR[model.kind._tag] ?? "#666"
|
|
82
|
+
const title =
|
|
83
|
+
model.description !== undefined ? `${name} — ${model.description}` : name
|
|
84
|
+
return `<g class="node" data-name="${escapeHtml(name)}" transform="translate(${x}, ${y})">
|
|
85
|
+
<title>${escapeHtml(title)}</title>
|
|
86
|
+
<rect width="${NODE_W}" height="${NODE_H}" rx="8"/>
|
|
87
|
+
<rect width="4" height="${NODE_H}" rx="2" fill="${color}"/>
|
|
88
|
+
<text x="14" y="19" class="name">${escapeHtml(name)}</text>
|
|
89
|
+
<text x="14" y="36" class="kind" fill="${color}">${escapeHtml(model.kind._tag)}</text>
|
|
90
|
+
</g>`
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const legend = Object.entries(KIND_COLOR)
|
|
94
|
+
.filter(([tag]) => [...graph.models.values()].some((model) => model.kind._tag === tag))
|
|
95
|
+
.map(
|
|
96
|
+
([tag, color]) =>
|
|
97
|
+
`<span class="badge"><i style="background:${color}"></i>${escapeHtml(tag)}</span>`,
|
|
98
|
+
)
|
|
99
|
+
.join("")
|
|
100
|
+
|
|
101
|
+
return `<!doctype html>
|
|
102
|
+
<html lang="ru">
|
|
103
|
+
<head>
|
|
104
|
+
<meta charset="utf-8">
|
|
105
|
+
<title>efmesh — DAG моделей</title>
|
|
106
|
+
<style>
|
|
107
|
+
:root { color-scheme: light dark; }
|
|
108
|
+
body { font: 14px/1.4 system-ui, sans-serif; margin: 0; padding: 16px;
|
|
109
|
+
background: light-dark(#fafafa, #16181d); color: light-dark(#222, #ddd); }
|
|
110
|
+
h1 { font-size: 16px; margin: 0 0 4px; }
|
|
111
|
+
.legend { margin-bottom: 12px; }
|
|
112
|
+
.badge { display: inline-flex; align-items: center; gap: 5px; margin-right: 14px;
|
|
113
|
+
font-size: 12px; opacity: .85; }
|
|
114
|
+
.badge i { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
|
|
115
|
+
svg { max-width: 100%; height: auto; }
|
|
116
|
+
.node rect:first-of-type { fill: light-dark(#fff, #23262e);
|
|
117
|
+
stroke: light-dark(#d5d5d5, #3a3f4a); }
|
|
118
|
+
.node .name { font-weight: 600; font-size: 13px; fill: light-dark(#222, #eee); }
|
|
119
|
+
.node .kind { font-size: 11px; }
|
|
120
|
+
.edge { fill: none; stroke: light-dark(#b9c0c9, #4a5160); stroke-width: 1.5; }
|
|
121
|
+
.node, .edge { transition: opacity .12s ease; }
|
|
122
|
+
svg.focused .node:not(.lit), svg.focused .edge:not(.lit) { opacity: .18; }
|
|
123
|
+
.edge.lit { stroke: light-dark(#e07a5f, #e6a08c); stroke-width: 2; }
|
|
124
|
+
</style>
|
|
125
|
+
</head>
|
|
126
|
+
<body>
|
|
127
|
+
<h1>efmesh — DAG моделей</h1>
|
|
128
|
+
<div class="legend">${legend}</div>
|
|
129
|
+
<svg id="dag" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
|
|
130
|
+
${edges.join("\n")}
|
|
131
|
+
${nodes.join("\n")}
|
|
132
|
+
</svg>
|
|
133
|
+
<script>
|
|
134
|
+
const svg = document.getElementById("dag")
|
|
135
|
+
const edges = [...svg.querySelectorAll(".edge")]
|
|
136
|
+
for (const node of svg.querySelectorAll(".node")) {
|
|
137
|
+
node.addEventListener("mouseenter", () => {
|
|
138
|
+
const name = node.dataset.name
|
|
139
|
+
svg.classList.add("focused")
|
|
140
|
+
node.classList.add("lit")
|
|
141
|
+
for (const edge of edges) {
|
|
142
|
+
if (edge.dataset.from !== name && edge.dataset.to !== name) continue
|
|
143
|
+
edge.classList.add("lit")
|
|
144
|
+
const other = edge.dataset.from === name ? edge.dataset.to : edge.dataset.from
|
|
145
|
+
svg.querySelector('.node[data-name="' + CSS.escape(other) + '"]')?.classList.add("lit")
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
node.addEventListener("mouseleave", () => {
|
|
149
|
+
svg.classList.remove("focused")
|
|
150
|
+
for (const lit of svg.querySelectorAll(".lit")) lit.classList.remove("lit")
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
</script>
|
|
154
|
+
</body>
|
|
155
|
+
</html>
|
|
156
|
+
`
|
|
157
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { rmSync } from "node:fs"
|
|
2
|
+
import { Clock, Effect } from "effect"
|
|
3
|
+
import { parseModelName } from "../core/model.ts"
|
|
4
|
+
import { EngineAdapter } from "../engine/adapter.ts"
|
|
5
|
+
import type { EngineError } from "../engine/adapter.ts"
|
|
6
|
+
import { StateStore } from "../state/store.ts"
|
|
7
|
+
import type { StateError } from "../state/store.ts"
|
|
8
|
+
import { janitorLockName, withStateLock, type LockHeldError, type LockOptions } from "./lock.ts"
|
|
9
|
+
import { ducklakeAttachSql, ducklakeRef, parquetPrefix, physicalRef } from "./naming.ts"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Уборка осиротевшей физики (SPEC §5.4): снапшоты, на которые не ссылается
|
|
13
|
+
* ни одно окружение и которые осиротели раньше, чем ttl назад, сносятся —
|
|
14
|
+
* таблица/view движка, parquet-префикс озера, запись снапшота и учёт
|
|
15
|
+
* интервалов.
|
|
16
|
+
*
|
|
17
|
+
* ttl отсчитывается от orphaned_at — отметки, которую промоушен ставит
|
|
18
|
+
* при потере последней ссылки и снимает при возврате (откат на старую
|
|
19
|
+
* версию обнуляет счётчик). Для записей без отметки (ни разу не
|
|
20
|
+
* промоутились — например, apply упал до промоушена) — от created_at.
|
|
21
|
+
* ttl по умолчанию 7 дней — достаточно, чтобы мгновенно откатиться
|
|
22
|
+
* переключением view.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface JanitorOptions extends LockOptions {
|
|
26
|
+
readonly ttlDays?: number
|
|
27
|
+
readonly lakePath?: string
|
|
28
|
+
/** DuckLake-каталог (SPEC §14.5) — чтобы снести и таблицы-на-fingerprint в нём. */
|
|
29
|
+
readonly ducklake?: { readonly catalog: string; readonly dataPath?: string }
|
|
30
|
+
/** «Сейчас» — инъекция для тестов. */
|
|
31
|
+
readonly now?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface JanitorReport {
|
|
35
|
+
/** Снесённые снапшоты в виде `имя@fp8`. */
|
|
36
|
+
readonly removed: ReadonlyArray<string>
|
|
37
|
+
/** Осиротевшие, но моложе ttl — остаются до следующего раза. */
|
|
38
|
+
readonly kept: ReadonlyArray<string>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const DAY_MS = 86_400_000
|
|
42
|
+
|
|
43
|
+
export const janitor = (
|
|
44
|
+
options?: JanitorOptions,
|
|
45
|
+
): Effect.Effect<
|
|
46
|
+
JanitorReport,
|
|
47
|
+
EngineError | StateError | LockHeldError,
|
|
48
|
+
EngineAdapter | StateStore
|
|
49
|
+
> =>
|
|
50
|
+
Effect.gen(function* () {
|
|
51
|
+
const engine = yield* EngineAdapter
|
|
52
|
+
const store = yield* StateStore
|
|
53
|
+
const now = options?.now ?? (yield* Clock.currentTimeMillis)
|
|
54
|
+
const ttlMs = (options?.ttlDays ?? 7) * DAY_MS
|
|
55
|
+
|
|
56
|
+
// снапшот не хранит цель материализации — при настроенном каталоге
|
|
57
|
+
// таблица сносится и там, и в _efmesh (DROP IF EXISTS терпим к пустоте)
|
|
58
|
+
const ducklake = options?.ducklake
|
|
59
|
+
if (ducklake !== undefined && engine.dialect === "duckdb") {
|
|
60
|
+
yield* engine.execute(ducklakeAttachSql(ducklake))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const referenced = yield* store.listReferencedFingerprints()
|
|
64
|
+
const removed: Array<string> = []
|
|
65
|
+
const kept: Array<string> = []
|
|
66
|
+
|
|
67
|
+
const snapshots = yield* store.listSnapshots()
|
|
68
|
+
const deadline = new Date(now - ttlMs).toISOString()
|
|
69
|
+
const isDoomed = (snapshot: (typeof snapshots)[number]): boolean =>
|
|
70
|
+
!referenced.has(snapshot.fingerprint) &&
|
|
71
|
+
(snapshot.orphanedAt ?? snapshot.createdAt) <= deadline
|
|
72
|
+
|
|
73
|
+
// фаза 1 — транзакционный claim записей: снос состоится, только если
|
|
74
|
+
// снапшот ВСЁ ЕЩЁ не referenced и сирота (проверки атомарны с удалением);
|
|
75
|
+
// параллельный apply, воскресивший версию (upsert снимает orphaned_at),
|
|
76
|
+
// claim проиграет — и её физика не тронется (гонка F6)
|
|
77
|
+
const claimed: Array<(typeof snapshots)[number]> = []
|
|
78
|
+
for (const snapshot of snapshots) {
|
|
79
|
+
if (referenced.has(snapshot.fingerprint)) continue
|
|
80
|
+
const label = `${snapshot.name}@${snapshot.fingerprint.slice(0, 8)}`
|
|
81
|
+
if (!isDoomed(snapshot)) {
|
|
82
|
+
kept.push(label)
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
const won = yield* store.deleteSnapshotIfDoomed(
|
|
86
|
+
snapshot.name,
|
|
87
|
+
snapshot.fingerprint,
|
|
88
|
+
deadline,
|
|
89
|
+
)
|
|
90
|
+
if (!won) {
|
|
91
|
+
kept.push(label)
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
claimed.push(snapshot)
|
|
95
|
+
removed.push(label)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// фаза 2 — снос физики по СВЕЖЕМУ состоянию стора: физика делится между
|
|
99
|
+
// версиями (forward-only) и сносится, только если после claim'ов её не
|
|
100
|
+
// использует ни один выживший снапшот
|
|
101
|
+
const survivors = yield* store.listSnapshots()
|
|
102
|
+
const physicalInUse = new Set(survivors.map((snapshot) => snapshot.physicalFp))
|
|
103
|
+
const dropped = new Set<string>()
|
|
104
|
+
for (const snapshot of claimed) {
|
|
105
|
+
if (physicalInUse.has(snapshot.physicalFp) || dropped.has(snapshot.physicalFp)) continue
|
|
106
|
+
dropped.add(snapshot.physicalFp)
|
|
107
|
+
const name = parseModelName(snapshot.name)
|
|
108
|
+
const target = physicalRef(name, snapshot.physicalFp)
|
|
109
|
+
yield* engine.execute(
|
|
110
|
+
snapshot.kind === "view"
|
|
111
|
+
? `DROP VIEW IF EXISTS ${target}`
|
|
112
|
+
: `DROP TABLE IF EXISTS ${target}`,
|
|
113
|
+
)
|
|
114
|
+
if (ducklake !== undefined && engine.dialect === "duckdb" && snapshot.kind !== "view") {
|
|
115
|
+
yield* engine.execute(
|
|
116
|
+
`DROP TABLE IF EXISTS ${ducklakeRef(name, snapshot.physicalFp)}`,
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
if (options?.lakePath !== undefined && !options.lakePath.startsWith("s3://")) {
|
|
120
|
+
const prefix = parquetPrefix(options.lakePath, name, snapshot.physicalFp)
|
|
121
|
+
yield* Effect.sync(() => rmSync(prefix, { recursive: true, force: true }))
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { removed, kept }
|
|
126
|
+
}).pipe(
|
|
127
|
+
// два janitor'а из разных процессов не должны наперегонки сносить одно и
|
|
128
|
+
// то же; от гонки janitor↔apply защищает ttl (окно на мгновенный откат)
|
|
129
|
+
withStateLock(janitorLockName, options?.lockTtlMs),
|
|
130
|
+
)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Data, Effect } from "effect"
|
|
2
|
+
import type { ModelGraph } from "../core/graph.ts"
|
|
3
|
+
import type { AnyModel } from "../core/model.ts"
|
|
4
|
+
import type { EngineError, SqlParseError } from "../engine/adapter.ts"
|
|
5
|
+
import { EngineAdapter } from "../engine/adapter.ts"
|
|
6
|
+
import { canonicalSql } from "./fingerprint.ts"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Колоночный lineage (SPEC §9.4): цепочка от колонки модели до сырьевых
|
|
10
|
+
* колонок external/seed-моделей. Точность best-effort — выражение колонки
|
|
11
|
+
* берётся из канонического AST (родной парсер движка), ссылки на колонки
|
|
12
|
+
* сопоставляются схемам родителей по имени, квалификаторы и алиасы CTE
|
|
13
|
+
* не разворачиваются. Граф моделей при этом точен всегда: зависимости
|
|
14
|
+
* известны из `ctx.ref`, не из парсинга текста.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export class LineageError extends Data.TaggedError("LineageError")<{
|
|
18
|
+
readonly model: string
|
|
19
|
+
readonly reason: string
|
|
20
|
+
}> {}
|
|
21
|
+
|
|
22
|
+
export interface LineageNode {
|
|
23
|
+
readonly model: string
|
|
24
|
+
readonly column: string
|
|
25
|
+
/** Вид модели — external/seed являются листьями (сырьё). */
|
|
26
|
+
readonly kind: string
|
|
27
|
+
readonly sources: ReadonlyArray<LineageNode>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Все COLUMN_REF в поддереве выражения — имена колонок без квалификаторов. */
|
|
31
|
+
const collectColumnRefs = (node: unknown, out: Set<string>): void => {
|
|
32
|
+
if (Array.isArray(node)) {
|
|
33
|
+
for (const item of node) collectColumnRefs(item, out)
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
if (node === null || typeof node !== "object") return
|
|
37
|
+
const record = node as Record<string, unknown>
|
|
38
|
+
if (record.class === "COLUMN_REF" && Array.isArray(record.column_names)) {
|
|
39
|
+
const names = record.column_names as ReadonlyArray<string>
|
|
40
|
+
if (names.length > 0) out.add(names[names.length - 1]!)
|
|
41
|
+
}
|
|
42
|
+
for (const value of Object.values(record)) collectColumnRefs(value, out)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const selectItems = (ast: unknown): ReadonlyArray<Record<string, unknown>> => {
|
|
46
|
+
const statements = (ast as { statements?: ReadonlyArray<{ node?: unknown }> }).statements
|
|
47
|
+
const node = statements?.[0]?.node as { select_list?: ReadonlyArray<unknown> } | undefined
|
|
48
|
+
return (node?.select_list ?? []) as ReadonlyArray<Record<string, unknown>>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Колонки, от которых зависит `column` в select_list: выражение с алиасом
|
|
53
|
+
* или одноимённый COLUMN_REF; `SELECT *` — сквозной проброс имени.
|
|
54
|
+
* undefined — выражение колонки не найдено (движковый сахар) — best-effort.
|
|
55
|
+
*/
|
|
56
|
+
const sourceColumnsOf = (ast: unknown, column: string): ReadonlySet<string> | undefined => {
|
|
57
|
+
const items = selectItems(ast)
|
|
58
|
+
const named =
|
|
59
|
+
items.find((item) => item.alias === column) ??
|
|
60
|
+
items.find((item) => {
|
|
61
|
+
if (item.alias !== "" && item.alias !== undefined) return false
|
|
62
|
+
if (item.class !== "COLUMN_REF" || !Array.isArray(item.column_names)) return false
|
|
63
|
+
const names = item.column_names as ReadonlyArray<string>
|
|
64
|
+
return names[names.length - 1] === column
|
|
65
|
+
})
|
|
66
|
+
if (named !== undefined) {
|
|
67
|
+
const out = new Set<string>()
|
|
68
|
+
collectColumnRefs(named, out)
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
if (items.some((item) => item.class === "STAR" || item.type === "STAR")) {
|
|
72
|
+
return new Set([column])
|
|
73
|
+
}
|
|
74
|
+
return undefined
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const lineage = (
|
|
78
|
+
graph: ModelGraph,
|
|
79
|
+
modelName: string,
|
|
80
|
+
column: string,
|
|
81
|
+
): Effect.Effect<LineageNode, LineageError | EngineError | SqlParseError, EngineAdapter> =>
|
|
82
|
+
Effect.gen(function* () {
|
|
83
|
+
const engine = yield* EngineAdapter
|
|
84
|
+
const root = graph.models.get(modelName)
|
|
85
|
+
if (root === undefined) {
|
|
86
|
+
return yield* new LineageError({ model: modelName, reason: "модели нет в проекте" })
|
|
87
|
+
}
|
|
88
|
+
if (!(column in root.schema.fields)) {
|
|
89
|
+
return yield* new LineageError({
|
|
90
|
+
model: modelName,
|
|
91
|
+
reason: `колонки «${column}» нет в схеме`,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const asts = new Map<string, unknown>()
|
|
96
|
+
const astOf = (model: AnyModel): Effect.Effect<unknown, EngineError | SqlParseError> =>
|
|
97
|
+
Effect.gen(function* () {
|
|
98
|
+
const cached = asts.get(model.name.full)
|
|
99
|
+
if (cached !== undefined) return cached
|
|
100
|
+
const ast = JSON.parse(
|
|
101
|
+
yield* engine.canonicalize(canonicalSql(graph, model.name.full)),
|
|
102
|
+
) as unknown
|
|
103
|
+
asts.set(model.name.full, ast)
|
|
104
|
+
return ast
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const trace = (
|
|
108
|
+
model: AnyModel,
|
|
109
|
+
wanted: string,
|
|
110
|
+
): Effect.Effect<LineageNode, EngineError | SqlParseError> =>
|
|
111
|
+
Effect.gen(function* () {
|
|
112
|
+
const leaf: LineageNode = {
|
|
113
|
+
model: model.name.full,
|
|
114
|
+
column: wanted,
|
|
115
|
+
kind: model.kind._tag,
|
|
116
|
+
sources: [],
|
|
117
|
+
}
|
|
118
|
+
// сырьё: дальше цепочка упирается во внешний мир
|
|
119
|
+
if (model.kind._tag === "external" || model.kind._tag === "seed" || model.deps.size === 0) {
|
|
120
|
+
return leaf
|
|
121
|
+
}
|
|
122
|
+
const columns = sourceColumnsOf(yield* astOf(model), wanted)
|
|
123
|
+
if (columns === undefined) return leaf
|
|
124
|
+
const sources: Array<LineageNode> = []
|
|
125
|
+
for (const source of columns) {
|
|
126
|
+
for (const parent of model.refs.values()) {
|
|
127
|
+
if (!(source in parent.schema.fields)) continue
|
|
128
|
+
sources.push(yield* trace(parent, source))
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { ...leaf, sources }
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
return yield* trace(root, column)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
/** Плоская печать lineage-дерева для CLI. */
|
|
138
|
+
export const formatLineage = (node: LineageNode, indent = ""): ReadonlyArray<string> => {
|
|
139
|
+
const marker =
|
|
140
|
+
node.kind === "external" || node.kind === "seed" ? ` [${node.kind}]` : ""
|
|
141
|
+
const line = `${indent}${node.model}.${node.column}${marker}`
|
|
142
|
+
return [line, ...node.sources.flatMap((source) => formatLineage(source, `${indent} `))]
|
|
143
|
+
}
|
package/src/plan/lock.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Data, Effect } from "effect"
|
|
2
|
+
import { StateStore, type StateError } from "../state/store.ts"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Межпроцессная блокировка через state store (SPEC §7, §14.6): мутации
|
|
6
|
+
* окружения — apply и run — идут под ОДНИМ локом `env:<имя>`, поэтому
|
|
7
|
+
* параллельные apply+apply и apply+run из разных процессов отсекаются.
|
|
8
|
+
* Протухший лок упавшего процесса перехватывается по ttl (учтена гонка
|
|
9
|
+
* в ту же миллисекунду: expires_at <= now).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export class LockHeldError extends Data.TaggedError("LockHeldError")<{
|
|
13
|
+
readonly name: string
|
|
14
|
+
}> {}
|
|
15
|
+
|
|
16
|
+
export interface LockOptions {
|
|
17
|
+
/** Сколько лок живёт без освобождения (упавший процесс); по умолчанию 1 час. */
|
|
18
|
+
readonly lockTtlMs?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Имя лока, под которым мутируется окружение (общий для apply и run). */
|
|
22
|
+
export const envLockName = (env: string): string => `env:${env}`
|
|
23
|
+
|
|
24
|
+
/** Лок janitor — глобальный: уборка физики не привязана к окружению. */
|
|
25
|
+
export const janitorLockName = "janitor"
|
|
26
|
+
|
|
27
|
+
export const withStateLock =
|
|
28
|
+
(name: string, ttlMs?: number) =>
|
|
29
|
+
<A, E, R>(
|
|
30
|
+
effect: Effect.Effect<A, E, R>,
|
|
31
|
+
): Effect.Effect<A, E | LockHeldError | StateError, R | StateStore> =>
|
|
32
|
+
Effect.gen(function* () {
|
|
33
|
+
const store = yield* StateStore
|
|
34
|
+
const acquired = yield* store.acquireLock(name, ttlMs ?? 3_600_000)
|
|
35
|
+
if (!acquired) return yield* new LockHeldError({ name })
|
|
36
|
+
return yield* effect.pipe(Effect.ensuring(store.releaseLock(name).pipe(Effect.ignore)))
|
|
37
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Metric } from "effect"
|
|
2
|
+
|
|
3
|
+
/** Наблюдаемость из коробки (SPEC §10): счётчики конвейера. */
|
|
4
|
+
export const intervalsDone = Metric.counter("efmesh_intervals_done_total", {
|
|
5
|
+
description: "Сколько интервалов досчитано и помечено done",
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
export const auditFailuresTotal = Metric.counter("efmesh_audit_failures_total", {
|
|
9
|
+
description: "Сколько blocking-аудитов провалилось",
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
export const snapshotsBuilt = Metric.counter("efmesh_snapshots_built_total", {
|
|
13
|
+
description: "Сколько снапшотов собрано (физика или бэкфилл)",
|
|
14
|
+
})
|