@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,91 @@
|
|
|
1
|
+
import type { ExternalSource, ModelName } from "../core/model.ts"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Раскладка объектов в движке (SPEC §2):
|
|
5
|
+
* - физика: схема `_efmesh`, таблица `<схема>__<таблица>__<fp8>`;
|
|
6
|
+
* - виртуалка: prod живёт в родных схемах моделей (`med.stays`),
|
|
7
|
+
* остальные окружения — в префиксованных (`dev__med.stays`).
|
|
8
|
+
*
|
|
9
|
+
* Схема именно `_efmesh`, не `efmesh`: DuckDB называет каталог по имени
|
|
10
|
+
* файла базы, и для `efmesh.duckdb` ссылка `efmesh.x` становится
|
|
11
|
+
* неоднозначной (каталог или схема) — Binder Error.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const PROD_ENV = "prod"
|
|
15
|
+
|
|
16
|
+
const ENV_NAME = /^[a-z][a-z0-9_]*$/
|
|
17
|
+
|
|
18
|
+
export const validateEnvName = (env: string): boolean => ENV_NAME.test(env)
|
|
19
|
+
|
|
20
|
+
export const fp8 = (fingerprint: string): string => fingerprint.slice(0, 8)
|
|
21
|
+
|
|
22
|
+
export const physicalSchema = "_efmesh"
|
|
23
|
+
|
|
24
|
+
export const physicalTable = (name: ModelName, fingerprint: string): string =>
|
|
25
|
+
`${name.schema}__${name.table}__${fp8(fingerprint)}`
|
|
26
|
+
|
|
27
|
+
export const physicalRef = (name: ModelName, fingerprint: string): string =>
|
|
28
|
+
`"${physicalSchema}"."${physicalTable(name, fingerprint)}"`
|
|
29
|
+
|
|
30
|
+
export const envSchema = (env: string, modelSchema: string): string =>
|
|
31
|
+
env === PROD_ENV ? modelSchema : `${env}__${modelSchema}`
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Раскладка parquet-озера (SPEC §3.3): `<lake>/<схема>/<таблица>/fp=<fp8>/…`,
|
|
35
|
+
* у incremental внутри — партиции `interval=<ключ>/data.parquet`.
|
|
36
|
+
* Интервал = партиция: пересчёт — перезапись файлов партиции, источник
|
|
37
|
+
* правды — учёт интервалов, поэтому неатомарность перезаписи не страшна.
|
|
38
|
+
*/
|
|
39
|
+
export const parquetPrefix = (lakePath: string, name: ModelName, fingerprint: string): string =>
|
|
40
|
+
`${lakePath.replace(/\/+$/, "")}/${name.schema}/${name.table}/fp=${fp8(fingerprint)}`
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* union_by_name: партиции одного префикса могут отличаться схемой после
|
|
44
|
+
* forward-only-эволюции (новые колонки появляются только в новых файлах —
|
|
45
|
+
* история читается с NULL).
|
|
46
|
+
*/
|
|
47
|
+
export const parquetRef = (lakePath: string, name: ModelName, fingerprint: string): string =>
|
|
48
|
+
`read_parquet('${parquetPrefix(lakePath, name, fingerprint).replaceAll(`'`, `''`)}/**/*.parquet', union_by_name=true)`
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* DuckLake-цель (SPEC §14.5): каталог подключается под фиксированным
|
|
52
|
+
* алиасом, физика — таблица-на-fingerprint в нём (то же имя, что у
|
|
53
|
+
* нативной физики, только каталог другой). Версионность остаётся нашей;
|
|
54
|
+
* снапшоты/time travel DuckLake — бонус. Потребители вне efmesh должны
|
|
55
|
+
* сами сделать ATTACH — view окружений ссылаются на каталог по алиасу.
|
|
56
|
+
*/
|
|
57
|
+
export const ducklakeAlias = "_efmesh_ducklake"
|
|
58
|
+
|
|
59
|
+
export const ducklakeRef = (name: ModelName, fingerprint: string): string =>
|
|
60
|
+
`"${ducklakeAlias}"."${physicalTable(name, fingerprint)}"`
|
|
61
|
+
|
|
62
|
+
export const ducklakeAttachSql = (config: {
|
|
63
|
+
readonly catalog: string
|
|
64
|
+
readonly dataPath?: string
|
|
65
|
+
}): string =>
|
|
66
|
+
`ATTACH IF NOT EXISTS 'ducklake:sqlite:${config.catalog.replaceAll(`'`, `''`)}' AS "${ducklakeAlias}"${
|
|
67
|
+
config.dataPath !== undefined
|
|
68
|
+
? ` (DATA_PATH '${config.dataPath.replaceAll(`'`, `''`)}')`
|
|
69
|
+
: ""
|
|
70
|
+
}`
|
|
71
|
+
|
|
72
|
+
/** Ключ партиции интервала — безопасен для файловых систем (без двоеточий). */
|
|
73
|
+
export const intervalKey = (unit: "day" | "hour", startMs: number): string => {
|
|
74
|
+
const iso = new Date(startMs).toISOString()
|
|
75
|
+
return unit === "day" ? iso.slice(0, 10) : `${iso.slice(0, 10)}T${iso.slice(11, 13)}`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const READERS = { parquet: "read_parquet", csv: "read_csv", json: "read_json" } as const
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Во что рендерится ссылка на external-модель (SPEC §9.3): имя таблицы
|
|
82
|
+
* (движка или ATTACH-базы) как есть, файлы/URL — через read_*.
|
|
83
|
+
* external не материализуется, потребители читают источник напрямую.
|
|
84
|
+
*/
|
|
85
|
+
export const externalSourceRef = (source: ExternalSource): string =>
|
|
86
|
+
source._tag === "table"
|
|
87
|
+
? source.table
|
|
88
|
+
: `${READERS[source.format]}('${source.path.replaceAll(`'`, `''`)}')`
|
|
89
|
+
|
|
90
|
+
export const viewRef = (env: string, name: ModelName): string =>
|
|
91
|
+
`"${envSchema(env, name.schema)}"."${name.table}"`
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { Clock, Data, Effect } from "effect"
|
|
2
|
+
import type { SeedReadError } from "../core/errors.ts"
|
|
3
|
+
import type { ModelGraph } from "../core/graph.ts"
|
|
4
|
+
import type { Interval } from "../core/interval.ts"
|
|
5
|
+
import { enumerateIntervals, fromIso, mergeIntervals, missingIntervals } from "../core/interval.ts"
|
|
6
|
+
import type { EngineError, SqlParseError } from "../engine/adapter.ts"
|
|
7
|
+
import { EngineAdapter } from "../engine/adapter.ts"
|
|
8
|
+
import { StateStore } from "../state/store.ts"
|
|
9
|
+
import type { StateError } from "../state/store.ts"
|
|
10
|
+
import { categorizeAstChange } from "./categorize.ts"
|
|
11
|
+
import { FINGERPRINT_VERSION, fingerprintGraph } from "./fingerprint.ts"
|
|
12
|
+
import { validateEnvName } from "./naming.ts"
|
|
13
|
+
|
|
14
|
+
export class InvalidEnvironmentError extends Data.TaggedError("InvalidEnvironmentError")<{
|
|
15
|
+
readonly env: string
|
|
16
|
+
}> {}
|
|
17
|
+
|
|
18
|
+
/** Модель нельзя применить forward-only (SPEC §5.2). */
|
|
19
|
+
export class ForwardOnlyError extends Data.TaggedError("ForwardOnlyError")<{
|
|
20
|
+
readonly model: string
|
|
21
|
+
readonly reason: string
|
|
22
|
+
}> {}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Снапшот посчитан другой версией алгоритма fingerprint (SPEC §4):
|
|
26
|
+
* отпечатки разных версий несравнимы — план честно останавливается,
|
|
27
|
+
* а не показывает «всё breaking». Лечится миграцией той версии efmesh,
|
|
28
|
+
* которая сменила алгоритм.
|
|
29
|
+
*/
|
|
30
|
+
export class FingerprintVersionError extends Data.TaggedError("FingerprintVersionError")<{
|
|
31
|
+
readonly model: string
|
|
32
|
+
readonly found: number
|
|
33
|
+
readonly wanted: number
|
|
34
|
+
}> {}
|
|
35
|
+
|
|
36
|
+
export type ChangeCategory =
|
|
37
|
+
/** Модели не было в окружении. */
|
|
38
|
+
| "added"
|
|
39
|
+
/** Смысловая правка запроса или метаданных: модель и потомки пересобираются. */
|
|
40
|
+
| "breaking"
|
|
41
|
+
/** Добавлены колонки в конец SELECT, остальное дерево нетронуто (SPEC §5.2). */
|
|
42
|
+
| "non-breaking"
|
|
43
|
+
/** Собственный AST не менялся — версия сдвинулась каскадом от родителя. */
|
|
44
|
+
| "indirect"
|
|
45
|
+
/**
|
|
46
|
+
* Физика и done-интервалы старой версии переиспользуются, история не
|
|
47
|
+
* переигрывается — новая логика действует с момента применения (SPEC §5.2).
|
|
48
|
+
* Явный флаг пользователя или каскад от forward-only-родителей.
|
|
49
|
+
*/
|
|
50
|
+
| "forward-only"
|
|
51
|
+
/** Была в окружении, из проекта исчезла — view будет снесён при промоушене. */
|
|
52
|
+
| "removed"
|
|
53
|
+
| "unchanged"
|
|
54
|
+
|
|
55
|
+
export interface PlanAction {
|
|
56
|
+
readonly name: string
|
|
57
|
+
readonly fingerprint: string
|
|
58
|
+
/**
|
|
59
|
+
* Fingerprint, чьей физикой пользуется снапшот: обычно собственный,
|
|
60
|
+
* при forward-only — унаследованный от предыдущей версии.
|
|
61
|
+
*/
|
|
62
|
+
readonly physicalFingerprint: string
|
|
63
|
+
/** Откуда наследуются физика и done-интервалы (fingerprint старой версии) при forward-only. */
|
|
64
|
+
readonly reusedFrom?: string
|
|
65
|
+
/** Канонический AST тела (null у external) — сохраняется в снапшот. */
|
|
66
|
+
readonly canonicalAst: string | null
|
|
67
|
+
readonly change: ChangeCategory
|
|
68
|
+
/**
|
|
69
|
+
* Нужна ли сборка физики. false для unchanged/removed, external и для
|
|
70
|
+
* снапшотов, уже собранных другим окружением, — тогда промоушен это
|
|
71
|
+
* только view-swap.
|
|
72
|
+
*/
|
|
73
|
+
readonly build: boolean
|
|
74
|
+
/**
|
|
75
|
+
* Диапазоны к пересчёту у incrementalByTimeRange (слитые из недостающих
|
|
76
|
+
* интервалов + lookback); у остальных видов пуст. Дыры бывают и у
|
|
77
|
+
* unchanged-модели — время идёт, появляются новые интервалы.
|
|
78
|
+
*/
|
|
79
|
+
readonly backfill: ReadonlyArray<Interval>
|
|
80
|
+
/** true у incrementalByUniqueKey: каждый apply перегоняет запрос (upsert по ключу). */
|
|
81
|
+
readonly refresh: boolean
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface Plan {
|
|
85
|
+
readonly env: string
|
|
86
|
+
/** В топологическом порядке; removed — в конце. */
|
|
87
|
+
readonly actions: ReadonlyArray<PlanAction>
|
|
88
|
+
readonly hasChanges: boolean
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface PlanOptions {
|
|
92
|
+
/** «Сейчас» для расчёта интервалов; по умолчанию — Clock. Инъекция для тестов. */
|
|
93
|
+
readonly now?: number
|
|
94
|
+
/**
|
|
95
|
+
* Модели, чьи изменения применить forward-only (SPEC §5.2): новая версия
|
|
96
|
+
* наследует физическую таблицу и done-интервалы старой, история не
|
|
97
|
+
* переигрывается. Только incrementalByTimeRange — у остальных видов
|
|
98
|
+
* «задним числом» не бывает по построению.
|
|
99
|
+
*/
|
|
100
|
+
readonly forwardOnly?: ReadonlyArray<string>
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const planChanges = (
|
|
104
|
+
env: string,
|
|
105
|
+
graph: ModelGraph,
|
|
106
|
+
options?: PlanOptions,
|
|
107
|
+
): Effect.Effect<
|
|
108
|
+
Plan,
|
|
109
|
+
| InvalidEnvironmentError
|
|
110
|
+
| ForwardOnlyError
|
|
111
|
+
| FingerprintVersionError
|
|
112
|
+
| StateError
|
|
113
|
+
| EngineError
|
|
114
|
+
| SqlParseError
|
|
115
|
+
| SeedReadError,
|
|
116
|
+
StateStore | EngineAdapter
|
|
117
|
+
> =>
|
|
118
|
+
Effect.gen(function* () {
|
|
119
|
+
if (!validateEnvName(env)) return yield* new InvalidEnvironmentError({ env })
|
|
120
|
+
const store = yield* StateStore
|
|
121
|
+
const now = options?.now ?? (yield* Clock.currentTimeMillis)
|
|
122
|
+
const forwardOnly = new Set(options?.forwardOnly ?? [])
|
|
123
|
+
for (const flagged of forwardOnly) {
|
|
124
|
+
const model = graph.models.get(flagged)
|
|
125
|
+
if (model === undefined) {
|
|
126
|
+
return yield* new ForwardOnlyError({ model: flagged, reason: "модели нет в проекте" })
|
|
127
|
+
}
|
|
128
|
+
if (model.kind._tag !== "incrementalByTimeRange") {
|
|
129
|
+
return yield* new ForwardOnlyError({
|
|
130
|
+
model: flagged,
|
|
131
|
+
reason: `forward-only переиспользует физику и учёт интервалов — применим только к incrementalByTimeRange, вид модели — ${model.kind._tag}`,
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const versions = yield* fingerprintGraph(graph)
|
|
136
|
+
const current = new Map(
|
|
137
|
+
(yield* store.getEnvironment(env)).map((row) => [row.name, row.fingerprint]),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
const actions: Array<PlanAction> = []
|
|
141
|
+
const changeOf = new Map<string, ChangeCategory>()
|
|
142
|
+
for (const name of graph.order) {
|
|
143
|
+
const model = graph.models.get(name)!
|
|
144
|
+
const { fingerprint, ast } = versions.get(name)!
|
|
145
|
+
const known = current.get(name)
|
|
146
|
+
let change: ChangeCategory
|
|
147
|
+
let reusedFrom: string | undefined
|
|
148
|
+
let physicalFingerprint = fingerprint
|
|
149
|
+
if (known === undefined) change = "added"
|
|
150
|
+
else if (known === fingerprint) change = "unchanged"
|
|
151
|
+
else {
|
|
152
|
+
// категоризация по AST против последнего известного снапшота (SPEC §5.2);
|
|
153
|
+
// старых записей без AST и external — консервативно breaking
|
|
154
|
+
const previous = yield* store.getSnapshot(name, known)
|
|
155
|
+
if (previous !== undefined && previous.fingerprintVersion !== FINGERPRINT_VERSION) {
|
|
156
|
+
return yield* new FingerprintVersionError({
|
|
157
|
+
model: name,
|
|
158
|
+
found: previous.fingerprintVersion,
|
|
159
|
+
wanted: FINGERPRINT_VERSION,
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
const oldAst = previous?.canonicalAst ?? ""
|
|
163
|
+
if (oldAst === "" || ast === null) change = "breaking"
|
|
164
|
+
else if (oldAst === ast) change = "indirect" // версия сдвинута родителем/метаданными
|
|
165
|
+
else change = categorizeAstChange(oldAst, ast)
|
|
166
|
+
// forward-only: явный флаг пользователя — либо каскад: собственный AST
|
|
167
|
+
// не менялся, а все изменившиеся родители сами forward-only (реюз
|
|
168
|
+
// физики родителя означает, что и потомку нечего переигрывать)
|
|
169
|
+
const cascaded =
|
|
170
|
+
change === "indirect" &&
|
|
171
|
+
model.kind._tag === "incrementalByTimeRange" &&
|
|
172
|
+
[...model.deps].every((dep) => {
|
|
173
|
+
const parent = changeOf.get(dep)
|
|
174
|
+
return parent === undefined || parent === "unchanged" || parent === "forward-only"
|
|
175
|
+
})
|
|
176
|
+
if (previous !== undefined && (forwardOnly.has(name) || cascaded)) {
|
|
177
|
+
change = "forward-only"
|
|
178
|
+
reusedFrom = known
|
|
179
|
+
physicalFingerprint = previous.physicalFp
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
changeOf.set(name, change)
|
|
183
|
+
// external не материализуется — версия участвует в diff, физики нет
|
|
184
|
+
const existing = yield* store.getSnapshot(name, fingerprint)
|
|
185
|
+
if (existing !== undefined) physicalFingerprint = existing.physicalFp
|
|
186
|
+
const alreadyBuilt =
|
|
187
|
+
model.kind._tag === "external" || change === "unchanged" || existing !== undefined
|
|
188
|
+
|
|
189
|
+
let backfill: ReadonlyArray<Interval> = []
|
|
190
|
+
if (model.kind._tag === "incrementalByTimeRange") {
|
|
191
|
+
const kind = model.kind
|
|
192
|
+
const wanted = enumerateIntervals(kind.interval, fromIso(kind.start), now)
|
|
193
|
+
// покрытие привязано к fingerprint: новая версия = пустой учёт = полный
|
|
194
|
+
// бэкфилл; forward-only наследует done-интервалы старой версии —
|
|
195
|
+
// пересчитывается только то, чего не было
|
|
196
|
+
const inherited =
|
|
197
|
+
reusedFrom === undefined ? [] : yield* store.listIntervals(reusedFrom)
|
|
198
|
+
const doneByStart = new Map<number, Interval>()
|
|
199
|
+
for (const record of [...(yield* store.listIntervals(fingerprint)), ...inherited]) {
|
|
200
|
+
if (record.status !== "done") continue
|
|
201
|
+
doneByStart.set(fromIso(record.startTs), {
|
|
202
|
+
start: fromIso(record.startTs),
|
|
203
|
+
end: fromIso(record.endTs),
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
const done = [...doneByStart.values()]
|
|
207
|
+
const missing = [...missingIntervals(wanted, done)]
|
|
208
|
+
if (kind.lookback > 0) {
|
|
209
|
+
// поздно приезжающие данные: последние done-интервалы перечитываются
|
|
210
|
+
const tail = done.sort((a, b) => a.start - b.start).slice(-kind.lookback)
|
|
211
|
+
missing.push(...tail)
|
|
212
|
+
}
|
|
213
|
+
backfill = mergeIntervals(missing.sort((a, b) => a.start - b.start))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
actions.push({
|
|
217
|
+
name,
|
|
218
|
+
fingerprint,
|
|
219
|
+
physicalFingerprint,
|
|
220
|
+
...(reusedFrom !== undefined ? { reusedFrom } : {}),
|
|
221
|
+
canonicalAst: ast,
|
|
222
|
+
change,
|
|
223
|
+
build: !alreadyBuilt,
|
|
224
|
+
backfill,
|
|
225
|
+
// каждый apply сверяет запрос с физикой: upsert / SCD-версионирование
|
|
226
|
+
refresh:
|
|
227
|
+
model.kind._tag === "incrementalByUniqueKey" || model.kind._tag === "scdType2",
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
for (const [name, fingerprint] of current) {
|
|
231
|
+
if (!graph.models.has(name)) {
|
|
232
|
+
actions.push({
|
|
233
|
+
name,
|
|
234
|
+
fingerprint,
|
|
235
|
+
physicalFingerprint: fingerprint,
|
|
236
|
+
canonicalAst: null,
|
|
237
|
+
change: "removed",
|
|
238
|
+
build: false,
|
|
239
|
+
backfill: [],
|
|
240
|
+
refresh: false,
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
env,
|
|
247
|
+
actions,
|
|
248
|
+
hasChanges: actions.some(
|
|
249
|
+
(a) => a.change !== "unchanged" || a.backfill.length > 0 || a.refresh,
|
|
250
|
+
),
|
|
251
|
+
}
|
|
252
|
+
}).pipe(Effect.withSpan("efmesh.plan", { attributes: { env } }))
|
package/src/plan/run.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Data, Effect, Schedule } from "effect"
|
|
2
|
+
import { buildGraph } from "../core/graph.ts"
|
|
3
|
+
import type { AnyModel } from "../core/model.ts"
|
|
4
|
+
import { EngineAdapter } from "../engine/adapter.ts"
|
|
5
|
+
import { StateStore } from "../state/store.ts"
|
|
6
|
+
import { applyPlan, type AppliedPlan, type ApplyError, type ApplyOptions } from "./executor.ts"
|
|
7
|
+
import { envLockName, withStateLock, type LockHeldError, type LockOptions } from "./lock.ts"
|
|
8
|
+
import { planChanges, type PlanOptions } from "./planner.ts"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `run` — тик планировщика (SPEC §7): догоняет интервалы и upsert'ы
|
|
12
|
+
* СУЩЕСТВУЮЩИХ версий, никогда не применяет изменения моделей — это
|
|
13
|
+
* работа plan/apply с человеком у руля. Идемпотентен и безопасен для
|
|
14
|
+
* cron/systemd: параллельный запуск отсекается блокировкой в state store —
|
|
15
|
+
* той же `env:<имя>`, что у apply (SPEC §14.6), поэтому run не вклинится
|
|
16
|
+
* в чужое применение и наоборот.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export class RunBlockedByChangesError extends Data.TaggedError("RunBlockedByChangesError")<{
|
|
20
|
+
readonly env: string
|
|
21
|
+
readonly changes: ReadonlyArray<string>
|
|
22
|
+
}> {}
|
|
23
|
+
|
|
24
|
+
export type RunError = ApplyError | LockHeldError | RunBlockedByChangesError
|
|
25
|
+
|
|
26
|
+
export interface RunOptions extends PlanOptions, ApplyOptions, LockOptions {}
|
|
27
|
+
|
|
28
|
+
export const run = (
|
|
29
|
+
env: string,
|
|
30
|
+
models: Iterable<AnyModel>,
|
|
31
|
+
options?: RunOptions,
|
|
32
|
+
): Effect.Effect<AppliedPlan, RunError, EngineAdapter | StateStore> =>
|
|
33
|
+
Effect.gen(function* () {
|
|
34
|
+
const graph = yield* buildGraph(models)
|
|
35
|
+
const plan = yield* planChanges(env, graph, options)
|
|
36
|
+
const structural = plan.actions
|
|
37
|
+
.filter((a) => a.change !== "unchanged")
|
|
38
|
+
.map((a) => `${a.name}: ${a.change}`)
|
|
39
|
+
if (structural.length > 0) {
|
|
40
|
+
return yield* new RunBlockedByChangesError({ env, changes: structural })
|
|
41
|
+
}
|
|
42
|
+
return yield* applyPlan(plan, graph, options)
|
|
43
|
+
}).pipe(withStateLock(envLockName(env), options?.lockTtlMs))
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Долгоживущий планировщик для встраивания в Effect-приложение (SPEC §7):
|
|
47
|
+
* тики по Schedule, ошибка тика логируется и не роняет демона
|
|
48
|
+
* (кроме занятого лока — это штатно, лог потише).
|
|
49
|
+
*/
|
|
50
|
+
export const daemon = (
|
|
51
|
+
env: string,
|
|
52
|
+
models: Iterable<AnyModel>,
|
|
53
|
+
schedule: Schedule.Schedule<unknown>,
|
|
54
|
+
options?: RunOptions,
|
|
55
|
+
): Effect.Effect<never, never, EngineAdapter | StateStore> =>
|
|
56
|
+
run(env, models, options).pipe(
|
|
57
|
+
Effect.tap((applied) =>
|
|
58
|
+
applied.built.length > 0
|
|
59
|
+
? Effect.logInfo(`run ${env}: обработано ${applied.built.join(", ")}`)
|
|
60
|
+
: Effect.void,
|
|
61
|
+
),
|
|
62
|
+
Effect.catchTag("LockHeldError", () =>
|
|
63
|
+
Effect.logDebug(`run ${env}: лок занят другим процессом, пропуск тика`),
|
|
64
|
+
),
|
|
65
|
+
Effect.catchCause((cause) => Effect.logError(`run ${env}: тик упал`, cause)),
|
|
66
|
+
Effect.schedule(schedule),
|
|
67
|
+
Effect.andThen(Effect.never),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
export const Runner = { run, daemon } as const
|