@avytheone/efmesh 0.1.0-beta.1 → 0.2.0
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 +139 -89
- package/README.md +61 -9
- package/README.ru.md +57 -5
- package/SPEC.md +542 -459
- package/package.json +4 -1
- package/src/cli.ts +352 -12
- package/src/efmesh.ts +2 -0
- package/src/index.ts +32 -2
- package/src/plan/categorize.ts +4 -1
- package/src/plan/diff.ts +221 -1
- package/src/plan/executor.ts +20 -0
- package/src/plan/explain.ts +114 -0
- package/src/plan/fingerprint.ts +60 -11
- package/src/plan/planner.ts +175 -6
- package/src/plan/run.ts +50 -12
- package/src/plan/schedule.ts +212 -0
- package/src/plan/status.ts +95 -0
- package/src/state/postgres.ts +49 -0
- package/src/state/sqlite.ts +47 -0
- package/src/state/store.ts +33 -2
package/src/plan/diff.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import { Effect } from "effect"
|
|
1
|
+
import { Data, Effect } from "effect"
|
|
2
|
+
import type { GraphError } from "../core/graph.ts"
|
|
3
|
+
import { buildGraph } from "../core/graph.ts"
|
|
4
|
+
import type { AnyModel } from "../core/model.ts"
|
|
5
|
+
import { quoteIdent } from "../core/sql.ts"
|
|
6
|
+
import { EngineAdapter } from "../engine/adapter.ts"
|
|
7
|
+
import type { EngineError } from "../engine/adapter.ts"
|
|
8
|
+
import { viewRef } from "./naming.ts"
|
|
2
9
|
import { StateStore } from "../state/store.ts"
|
|
3
10
|
import type { StateError } from "../state/store.ts"
|
|
4
11
|
|
|
@@ -34,3 +41,216 @@ export const diffEnvironments = (
|
|
|
34
41
|
|
|
35
42
|
return { onlyInA, onlyInB, different, same }
|
|
36
43
|
})
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Сравнение ДАННЫХ двух окружений (#6, класс table_diff sqlmesh): счётчики
|
|
47
|
+
* строк, пересечение по ключу, помодельные доли расхождений по колонкам —
|
|
48
|
+
* между view-слоями A и B одной базы. На DuckDB-классе данных это дёшево;
|
|
49
|
+
* для больших таблиц — детерминированная выборка по md5 ключа: обе стороны
|
|
50
|
+
* фильтруются одинаковыми бакетами, поэтому выборка выровнена и пары
|
|
51
|
+
* ключей не теряются.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
export interface ColumnDrift {
|
|
55
|
+
readonly column: string
|
|
56
|
+
/** Сколько сопоставленных ключей разошлись в этой колонке. */
|
|
57
|
+
readonly mismatches: number
|
|
58
|
+
/** Доля от matched, 0..1. */
|
|
59
|
+
readonly rate: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ModelDataDiff {
|
|
63
|
+
readonly model: string
|
|
64
|
+
/** Полные счётчики строк (не задеты выборкой). */
|
|
65
|
+
readonly rowsA: number
|
|
66
|
+
readonly rowsB: number
|
|
67
|
+
/** Ключ сопоставления: grain или ключ вида (uniqueKey/scdType2). */
|
|
68
|
+
readonly key?: ReadonlyArray<string>
|
|
69
|
+
/** Дальше — только при ключе; при выборке счётчики по выбранным бакетам. */
|
|
70
|
+
readonly onlyInA?: number
|
|
71
|
+
readonly onlyInB?: number
|
|
72
|
+
readonly matched?: number
|
|
73
|
+
readonly columns?: ReadonlyArray<ColumnDrift>
|
|
74
|
+
/** Колонки только одной стороны — дрейф схемы между окружениями. */
|
|
75
|
+
readonly columnsOnlyInA?: ReadonlyArray<string>
|
|
76
|
+
readonly columnsOnlyInB?: ReadonlyArray<string>
|
|
77
|
+
/** Процент md5-бакетов ключа в сравнении; нет поля — сравнение полное. */
|
|
78
|
+
readonly sampledPercent?: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface DataDiffReport {
|
|
82
|
+
readonly envA: string
|
|
83
|
+
readonly envB: string
|
|
84
|
+
readonly models: ReadonlyArray<ModelDataDiff>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface DataDiffOptions {
|
|
88
|
+
/** Только эти модели; по умолчанию — все материализуемые из обоих окружений. */
|
|
89
|
+
readonly models?: ReadonlyArray<string>
|
|
90
|
+
/** 1–99: сравнивать долю ключей (md5-бакеты, выровнено между сторонами). */
|
|
91
|
+
readonly samplePercent?: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class DataDiffError extends Data.TaggedError("DataDiffError")<{
|
|
95
|
+
readonly model: string
|
|
96
|
+
readonly reason: string
|
|
97
|
+
}> {}
|
|
98
|
+
|
|
99
|
+
/** Виды с view-слоем в окружении — их данные есть с чем сравнивать. */
|
|
100
|
+
const COMPARABLE_KINDS: ReadonlySet<string> = new Set([
|
|
101
|
+
"full",
|
|
102
|
+
"view",
|
|
103
|
+
"incrementalByTimeRange",
|
|
104
|
+
"incrementalByUniqueKey",
|
|
105
|
+
"scdType2",
|
|
106
|
+
"seed",
|
|
107
|
+
])
|
|
108
|
+
|
|
109
|
+
const keyOf = (model: AnyModel): ReadonlyArray<string> | undefined => {
|
|
110
|
+
if (model.grain !== undefined && model.grain.length > 0) return model.grain
|
|
111
|
+
if (model.kind._tag === "incrementalByUniqueKey" || model.kind._tag === "scdType2") {
|
|
112
|
+
return model.kind.key
|
|
113
|
+
}
|
|
114
|
+
return undefined
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Детерминированный фильтр выборки: md5 от склейки ключа, первые два
|
|
119
|
+
* hex-символа = 256 бакетов. Работает и на DuckDB, и на Postgres,
|
|
120
|
+
* одинаково на обеих сторонах diff'а.
|
|
121
|
+
*/
|
|
122
|
+
const samplePredicate = (key: ReadonlyArray<string>, percent: number): string => {
|
|
123
|
+
const buckets = Math.max(1, Math.min(255, Math.floor((256 * percent) / 100)))
|
|
124
|
+
const threshold = buckets.toString(16).padStart(2, "0")
|
|
125
|
+
return `substr(md5(concat_ws('|', ${key.map(quoteIdent).join(", ")})), 1, 2) < '${threshold}'`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const asCount = (value: unknown): number => Number(value ?? 0)
|
|
129
|
+
|
|
130
|
+
export const dataDiffEnvironments = (
|
|
131
|
+
envA: string,
|
|
132
|
+
envB: string,
|
|
133
|
+
models: Iterable<AnyModel>,
|
|
134
|
+
options?: DataDiffOptions,
|
|
135
|
+
): Effect.Effect<
|
|
136
|
+
DataDiffReport,
|
|
137
|
+
GraphError | StateError | EngineError | DataDiffError,
|
|
138
|
+
StateStore | EngineAdapter
|
|
139
|
+
> =>
|
|
140
|
+
Effect.gen(function* () {
|
|
141
|
+
const store = yield* StateStore
|
|
142
|
+
const engine = yield* EngineAdapter
|
|
143
|
+
const graph = yield* buildGraph(models)
|
|
144
|
+
const inA = new Set((yield* store.getEnvironment(envA)).map((row) => row.name))
|
|
145
|
+
const inB = new Set((yield* store.getEnvironment(envB)).map((row) => row.name))
|
|
146
|
+
for (const name of options?.models ?? []) {
|
|
147
|
+
if (!graph.models.has(name)) {
|
|
148
|
+
return yield* new DataDiffError({ model: name, reason: "модели нет в проекте" })
|
|
149
|
+
}
|
|
150
|
+
if (!inA.has(name) || !inB.has(name)) {
|
|
151
|
+
return yield* new DataDiffError({
|
|
152
|
+
model: name,
|
|
153
|
+
reason: `модели нет в окружении ${inA.has(name) ? envB : envA}`,
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const wanted = options?.models === undefined ? undefined : new Set(options.models)
|
|
158
|
+
const percent = options?.samplePercent
|
|
159
|
+
|
|
160
|
+
const reports: Array<ModelDataDiff> = []
|
|
161
|
+
for (const name of graph.order) {
|
|
162
|
+
if (wanted !== undefined && !wanted.has(name)) continue
|
|
163
|
+
if (!inA.has(name) || !inB.has(name)) continue
|
|
164
|
+
const model = graph.models.get(name)!
|
|
165
|
+
if (!COMPARABLE_KINDS.has(model.kind._tag)) continue
|
|
166
|
+
const refA = viewRef(envA, model.name)
|
|
167
|
+
const refB = viewRef(envB, model.name)
|
|
168
|
+
|
|
169
|
+
const rowsA = asCount(
|
|
170
|
+
(yield* engine.query(`SELECT count(*) AS n FROM ${refA}`))[0]?.["n"],
|
|
171
|
+
)
|
|
172
|
+
const rowsB = asCount(
|
|
173
|
+
(yield* engine.query(`SELECT count(*) AS n FROM ${refB}`))[0]?.["n"],
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
// реальные колонки обеих сторон: окружения могут указывать на разные
|
|
177
|
+
// версии модели — сравниваются только общие, дрейф схемы фиксируется
|
|
178
|
+
const colsA = (yield* engine.describe(`SELECT * FROM ${refA}`)).map((c) => c.name)
|
|
179
|
+
const colsB = (yield* engine.describe(`SELECT * FROM ${refB}`)).map((c) => c.name)
|
|
180
|
+
const setB = new Set(colsB)
|
|
181
|
+
const setA = new Set(colsA)
|
|
182
|
+
const common = colsA.filter((column) => setB.has(column))
|
|
183
|
+
const columnsOnlyInA = colsA.filter((column) => !setB.has(column))
|
|
184
|
+
const columnsOnlyInB = colsB.filter((column) => !setA.has(column))
|
|
185
|
+
|
|
186
|
+
const key = keyOf(model)
|
|
187
|
+
const commonSet = new Set(common)
|
|
188
|
+
if (key === undefined || !key.every((column) => commonSet.has(column))) {
|
|
189
|
+
// сопоставлять нечем (нет grain/ключа или ключ не на обеих сторонах) —
|
|
190
|
+
// честные счётчики строк без пары
|
|
191
|
+
reports.push({
|
|
192
|
+
model: name,
|
|
193
|
+
rowsA,
|
|
194
|
+
rowsB,
|
|
195
|
+
...(columnsOnlyInA.length > 0 ? { columnsOnlyInA } : {}),
|
|
196
|
+
...(columnsOnlyInB.length > 0 ? { columnsOnlyInB } : {}),
|
|
197
|
+
})
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const keySet = new Set(key)
|
|
202
|
+
const compared = common.filter((column) => !keySet.has(column))
|
|
203
|
+
const sample = percent === undefined ? "" : ` WHERE ${samplePredicate(key, percent)}`
|
|
204
|
+
const pairs = compared
|
|
205
|
+
.map(
|
|
206
|
+
(column, index) =>
|
|
207
|
+
`, a.${quoteIdent(column)} AS a_${index}, b.${quoteIdent(column)} AS b_${index}`,
|
|
208
|
+
)
|
|
209
|
+
.join("")
|
|
210
|
+
const mismatches = compared
|
|
211
|
+
.map(
|
|
212
|
+
(_, index) =>
|
|
213
|
+
`, count(*) FILTER (WHERE in_a AND in_b AND (a_${index} IS DISTINCT FROM b_${index})) AS mm_${index}`,
|
|
214
|
+
)
|
|
215
|
+
.join("")
|
|
216
|
+
const keyList = key.map(quoteIdent).join(", ")
|
|
217
|
+
const [row] = yield* engine.query(`
|
|
218
|
+
SELECT
|
|
219
|
+
count(*) FILTER (WHERE NOT in_b) AS only_a,
|
|
220
|
+
count(*) FILTER (WHERE NOT in_a) AS only_b,
|
|
221
|
+
count(*) FILTER (WHERE in_a AND in_b) AS matched
|
|
222
|
+
${mismatches}
|
|
223
|
+
FROM (
|
|
224
|
+
SELECT coalesce(a.in_a, FALSE) AS in_a, coalesce(b.in_b, FALSE) AS in_b${pairs}
|
|
225
|
+
FROM (SELECT TRUE AS in_a, * FROM ${refA}${sample}) a
|
|
226
|
+
FULL OUTER JOIN (SELECT TRUE AS in_b, * FROM ${refB}${sample}) b
|
|
227
|
+
USING (${keyList})
|
|
228
|
+
) j
|
|
229
|
+
`)
|
|
230
|
+
const matched = asCount(row?.["matched"])
|
|
231
|
+
const columns = compared
|
|
232
|
+
.map((column, index) => {
|
|
233
|
+
const drifted = asCount(row?.[`mm_${index}`])
|
|
234
|
+
return {
|
|
235
|
+
column,
|
|
236
|
+
mismatches: drifted,
|
|
237
|
+
rate: matched === 0 ? 0 : drifted / matched,
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
.filter((drift) => drift.mismatches > 0)
|
|
241
|
+
reports.push({
|
|
242
|
+
model: name,
|
|
243
|
+
rowsA,
|
|
244
|
+
rowsB,
|
|
245
|
+
key,
|
|
246
|
+
onlyInA: asCount(row?.["only_a"]),
|
|
247
|
+
onlyInB: asCount(row?.["only_b"]),
|
|
248
|
+
matched,
|
|
249
|
+
columns,
|
|
250
|
+
...(columnsOnlyInA.length > 0 ? { columnsOnlyInA } : {}),
|
|
251
|
+
...(columnsOnlyInB.length > 0 ? { columnsOnlyInB } : {}),
|
|
252
|
+
...(percent !== undefined ? { sampledPercent: percent } : {}),
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
return { envA, envB, models: reports }
|
|
256
|
+
}).pipe(Effect.withSpan("efmesh.diff.data", { attributes: { envA, envB } }))
|
package/src/plan/executor.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
import type {
|
|
33
33
|
FingerprintVersionError,
|
|
34
34
|
ForwardOnlyError,
|
|
35
|
+
ReclassifyError,
|
|
35
36
|
InvalidEnvironmentError,
|
|
36
37
|
Plan,
|
|
37
38
|
PlanAction,
|
|
@@ -66,6 +67,7 @@ export type ApplyError =
|
|
|
66
67
|
| AuditFailure
|
|
67
68
|
| InvalidEnvironmentError
|
|
68
69
|
| ForwardOnlyError
|
|
70
|
+
| ReclassifyError
|
|
69
71
|
| FingerprintVersionError
|
|
70
72
|
| EngineFeatureError
|
|
71
73
|
|
|
@@ -546,6 +548,22 @@ export const applyPlan = (
|
|
|
546
548
|
const body = render(model.fragment, { resolveRef })
|
|
547
549
|
// контракт схемы (SPEC §3.2): дрейф типов ловится до сборки
|
|
548
550
|
yield* checkContract(engine, model, body)
|
|
551
|
+
// indirect-реюз (#5): данные идентичны по построению (родители
|
|
552
|
+
// non-breaking/forward-only, своё тело не менялось) — пересборка
|
|
553
|
+
// пропускается, аудиты гоняются по унаследованной физике
|
|
554
|
+
if (model.kind._tag === "full" && action.reusedFrom !== undefined) {
|
|
555
|
+
yield* runAudits(engine, model, physicalFor(model, action.physicalFingerprint))
|
|
556
|
+
yield* store.upsertSnapshot({
|
|
557
|
+
name: action.name,
|
|
558
|
+
fingerprint: action.fingerprint,
|
|
559
|
+
physicalFp: action.physicalFingerprint,
|
|
560
|
+
canonicalAst: action.canonicalAst ?? "",
|
|
561
|
+
renderedSql: body,
|
|
562
|
+
kind: model.kind._tag,
|
|
563
|
+
fingerprintVersion: FINGERPRINT_VERSION,
|
|
564
|
+
})
|
|
565
|
+
break
|
|
566
|
+
}
|
|
549
567
|
if (model.kind._tag === "full" && model.target === "parquet") {
|
|
550
568
|
const prefix = parquetPrefix(lakePath!, model.name, action.physicalFingerprint)
|
|
551
569
|
yield* ensureDir(prefix)
|
|
@@ -748,6 +766,8 @@ export const applyPlan = (
|
|
|
748
766
|
actions: plan.actions.map((a) => ({
|
|
749
767
|
name: a.name,
|
|
750
768
|
change: a.change,
|
|
769
|
+
// override оператора (#5) в журнале: видно, кто и что заявил
|
|
770
|
+
...(a.reclassifiedFrom !== undefined ? { reclassifiedFrom: a.reclassifiedFrom } : {}),
|
|
751
771
|
fingerprint: a.fingerprint.slice(0, 8),
|
|
752
772
|
build: a.build,
|
|
753
773
|
backfill: a.backfill.map((r) => `[${toIso(r.start)}, ${toIso(r.end)})`),
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Объяснение категоризации (#4, SPEC §5.2): `plan --explain` показывает,
|
|
3
|
+
* КАКИЕ узлы канонического AST разошлись и почему категория именно такая.
|
|
4
|
+
* Пути идут по дереву канона движка (json_serialize_sql у DuckDB,
|
|
5
|
+
* libpg_query у Postgres) — это отладочная подсказка, а не контракт:
|
|
6
|
+
* форма путей меняется вместе с каноном и semver-события не образует.
|
|
7
|
+
* Категорию считает categorize.ts; здесь — только её обоснование.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { topSelect } from "./categorize.ts"
|
|
11
|
+
|
|
12
|
+
export interface ChangeExplanation {
|
|
13
|
+
/** Пути разошедшихся узлов канонического AST (не больше MAX_DIVERGED). */
|
|
14
|
+
readonly diverged: ReadonlyArray<string>
|
|
15
|
+
/** Почему категория именно такая. */
|
|
16
|
+
readonly reason: string
|
|
17
|
+
/** Изменившиеся прямые родители — источник каскада indirect/forward-only. */
|
|
18
|
+
readonly cascadeFrom?: ReadonlyArray<string>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Дальше точечные пути перестают помогать — лучше открыть render/diff. */
|
|
22
|
+
const MAX_DIVERGED = 8
|
|
23
|
+
|
|
24
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
25
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
26
|
+
|
|
27
|
+
const at = (path: string, segment: string): string =>
|
|
28
|
+
path === "" ? segment : `${path}.${segment}`
|
|
29
|
+
|
|
30
|
+
const walk = (before: unknown, after: unknown, path: string, out: Array<string>): void => {
|
|
31
|
+
if (out.length >= MAX_DIVERGED) return
|
|
32
|
+
if (JSON.stringify(before) === JSON.stringify(after)) return
|
|
33
|
+
if (Array.isArray(before) && Array.isArray(after)) {
|
|
34
|
+
const shared = Math.min(before.length, after.length)
|
|
35
|
+
for (let i = 0; i < shared; i++) walk(before[i], after[i], `${path}[${i}]`, out)
|
|
36
|
+
for (let i = shared; i < after.length && out.length < MAX_DIVERGED; i++) {
|
|
37
|
+
out.push(`${path}[${i}] (добавлен)`)
|
|
38
|
+
}
|
|
39
|
+
for (let i = shared; i < before.length && out.length < MAX_DIVERGED; i++) {
|
|
40
|
+
out.push(`${path}[${i}] (удалён)`)
|
|
41
|
+
}
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
if (isRecord(before) && isRecord(after)) {
|
|
45
|
+
// узел заменён выражением другого типа — точечные пути внутри бессмысленны
|
|
46
|
+
if (before["type"] !== after["type"]) {
|
|
47
|
+
out.push(path === "" ? "(корень)" : path)
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort()
|
|
51
|
+
for (const key of keys) walk(before[key], after[key], at(path, key), out)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
out.push(path === "" ? "(корень)" : path)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Служебная обёртка стейтмента в пути не интересна — режем до тела запроса. */
|
|
58
|
+
const trimStatement = (path: string): string =>
|
|
59
|
+
path.replace(/^statements\[0\]\.node\.?/, "").replace(/^stmts\[0\]\.stmt\.?/, "") ||
|
|
60
|
+
"(корень)"
|
|
61
|
+
|
|
62
|
+
/** Пути расхождения двух канонических AST (JSON-строки); мусор на входе — пусто. */
|
|
63
|
+
export const divergedPaths = (oldAst: string, newAst: string): ReadonlyArray<string> => {
|
|
64
|
+
try {
|
|
65
|
+
const out: Array<string> = []
|
|
66
|
+
walk(JSON.parse(oldAst), JSON.parse(newAst), "", out)
|
|
67
|
+
return out.map(trimStatement)
|
|
68
|
+
} catch {
|
|
69
|
+
return []
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Гвардрейл override'а (#5): в новом верхнем SELECT колонок меньше — они
|
|
75
|
+
* удалены, потомки читают их по именам, «non-breaking» очевидно противоречит
|
|
76
|
+
* AST. Непарсибельный канон противоречием не считается: решение — за
|
|
77
|
+
* оператором, override явный и журналируется.
|
|
78
|
+
*/
|
|
79
|
+
export const dropsColumns = (oldAst: string, newAst: string): boolean => {
|
|
80
|
+
const before = topSelect(oldAst)
|
|
81
|
+
const after = topSelect(newAst)
|
|
82
|
+
if (before === null || after === null) return false
|
|
83
|
+
return after.list.length < before.list.length
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Обоснование вердикта categorizeAstChange теми же правилами, которыми он
|
|
88
|
+
* вынесен (SPEC §5.2): non-breaking — только суффикс верхнего SELECT.
|
|
89
|
+
*/
|
|
90
|
+
export const explainCategorized = (
|
|
91
|
+
oldAst: string,
|
|
92
|
+
newAst: string,
|
|
93
|
+
change: "breaking" | "non-breaking",
|
|
94
|
+
): ChangeExplanation => {
|
|
95
|
+
const diverged = divergedPaths(oldAst, newAst)
|
|
96
|
+
if (change === "non-breaking") {
|
|
97
|
+
return {
|
|
98
|
+
diverged,
|
|
99
|
+
reason:
|
|
100
|
+
"колонки добавлены в конец верхнего SELECT, остальное дерево нетронуто — потребители читают по именам, пересборка не нужна",
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const before = topSelect(oldAst)
|
|
104
|
+
const after = topSelect(newAst)
|
|
105
|
+
const reason =
|
|
106
|
+
before === null || after === null
|
|
107
|
+
? "канонический AST неожиданной формы — консервативно breaking"
|
|
108
|
+
: before.rest !== after.rest
|
|
109
|
+
? "дерево разошлось вне списка SELECT (FROM/WHERE/JOIN/GROUP BY/модификаторы)"
|
|
110
|
+
: after.list.length < before.list.length
|
|
111
|
+
? "колонки удалены из SELECT — потомки вставляют по позициям, физику надо пересобирать"
|
|
112
|
+
: "список SELECT изменён не только хвостом — правка или перестановка колонок ломает позиции потомков"
|
|
113
|
+
return { diverged, reason }
|
|
114
|
+
}
|
package/src/plan/fingerprint.ts
CHANGED
|
@@ -89,9 +89,57 @@ export interface ModelVersion {
|
|
|
89
89
|
readonly ast: string | null
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Кэш канонизации (#8): повторный plan почти целиком состоит из
|
|
94
|
+
* json_serialize_sql-раундтрипов по неизменённым моделям. Кэш — не
|
|
95
|
+
* данные: get/put обязаны быть безошибочными (промах/сбой = пересчёт).
|
|
96
|
+
*/
|
|
97
|
+
export interface CanonCache {
|
|
98
|
+
readonly get: (key: string) => Effect.Effect<string | undefined>
|
|
99
|
+
readonly put: (key: string, canonical: string) => Effect.Effect<void>
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Ключ кэша: версия алгоритма + диалект + исходник — апгрейд канона не маскируется. */
|
|
103
|
+
export const canonCacheKey = (dialect: string, source: string): string =>
|
|
104
|
+
sha256(`${FINGERPRINT_VERSION}:${dialect}:${source}`)
|
|
105
|
+
|
|
106
|
+
/** Модель в объёме, нужном fingerprint'у: kind, метаданные формы данных, цель. */
|
|
107
|
+
type FingerprintableModel = Parameters<typeof columnNames>[0] & {
|
|
108
|
+
readonly name: { readonly full: string }
|
|
109
|
+
readonly kind: ModelKind
|
|
110
|
+
readonly grain: ReadonlyArray<string> | undefined
|
|
111
|
+
readonly target: string | undefined
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Fingerprint одной модели из готового AST и подписей родителей
|
|
116
|
+
* (`имя=fingerprint`, отсортированы). Нужен планировщику для проверки
|
|
117
|
+
* «версию сдвинули ТОЛЬКО родители» (#5): пересчёт со старыми подписями
|
|
118
|
+
* обязан дать старый fingerprint — иначе разошлись ещё и метаданные,
|
|
119
|
+
* и физику реюзать нельзя.
|
|
120
|
+
*/
|
|
121
|
+
export const modelFingerprint = (
|
|
122
|
+
model: FingerprintableModel,
|
|
123
|
+
ast: string | null,
|
|
124
|
+
parents: ReadonlyArray<string>,
|
|
125
|
+
): Effect.Effect<string, SeedReadError> =>
|
|
126
|
+
Effect.gen(function* () {
|
|
127
|
+
const payload = JSON.stringify({
|
|
128
|
+
ast,
|
|
129
|
+
kind: yield* kindPayload(model, model.kind),
|
|
130
|
+
grain: model.grain,
|
|
131
|
+
columns: columnNames(model),
|
|
132
|
+
// смена цели материализации = новая физика, потребители перечитают её
|
|
133
|
+
target: model.target,
|
|
134
|
+
parents,
|
|
135
|
+
})
|
|
136
|
+
return sha256(payload)
|
|
137
|
+
})
|
|
138
|
+
|
|
92
139
|
/** Fingerprint всех моделей графа; транзитивность — через хэши родителей. */
|
|
93
140
|
export const fingerprintGraph = (
|
|
94
141
|
graph: ModelGraph,
|
|
142
|
+
cache?: CanonCache,
|
|
95
143
|
): Effect.Effect<
|
|
96
144
|
ReadonlyMap<string, ModelVersion>,
|
|
97
145
|
EngineError | SqlParseError | SeedReadError,
|
|
@@ -99,6 +147,16 @@ export const fingerprintGraph = (
|
|
|
99
147
|
> =>
|
|
100
148
|
Effect.gen(function* () {
|
|
101
149
|
const engine = yield* EngineAdapter
|
|
150
|
+
const canonicalize = (source: string): Effect.Effect<string, EngineError | SqlParseError> =>
|
|
151
|
+
Effect.gen(function* () {
|
|
152
|
+
if (cache === undefined) return yield* engine.canonicalize(source)
|
|
153
|
+
const key = canonCacheKey(engine.dialect, source)
|
|
154
|
+
const hit = yield* cache.get(key)
|
|
155
|
+
if (hit !== undefined) return hit
|
|
156
|
+
const canon = yield* engine.canonicalize(source)
|
|
157
|
+
yield* cache.put(key, canon)
|
|
158
|
+
return canon
|
|
159
|
+
})
|
|
102
160
|
const versions = new Map<string, ModelVersion>()
|
|
103
161
|
for (const name of graph.order) {
|
|
104
162
|
const model = graph.models.get(name)!
|
|
@@ -106,20 +164,11 @@ export const fingerprintGraph = (
|
|
|
106
164
|
const ast =
|
|
107
165
|
model.kind._tag === "external" || model.kind._tag === "seed"
|
|
108
166
|
? null
|
|
109
|
-
: yield*
|
|
167
|
+
: yield* canonicalize(canonicalSql(graph, name))
|
|
110
168
|
const parents = [...model.deps]
|
|
111
169
|
.sort()
|
|
112
170
|
.map((dep) => `${dep}=${versions.get(dep)!.fingerprint}`)
|
|
113
|
-
|
|
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 })
|
|
171
|
+
versions.set(name, { fingerprint: yield* modelFingerprint(model, ast, parents), ast })
|
|
123
172
|
}
|
|
124
173
|
return versions
|
|
125
174
|
})
|