@chkit/plugin-backfill 0.1.0-beta.2 → 0.1.0-beta.21
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/README.md +170 -0
- package/dist/args.d.ts +109 -6
- package/dist/args.d.ts.map +1 -1
- package/dist/args.js +73 -97
- package/dist/args.js.map +1 -1
- package/dist/async-backfill.d.ts +64 -0
- package/dist/async-backfill.d.ts.map +1 -0
- package/dist/async-backfill.js +251 -0
- package/dist/async-backfill.js.map +1 -0
- package/dist/check.d.ts +9 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +79 -0
- package/dist/check.js.map +1 -0
- package/dist/chunking/analyze.d.ts +8 -0
- package/dist/chunking/analyze.d.ts.map +1 -0
- package/dist/chunking/analyze.js +8 -0
- package/dist/chunking/analyze.js.map +1 -0
- package/dist/chunking/boundary-codec.d.ts +10 -0
- package/dist/chunking/boundary-codec.d.ts.map +1 -0
- package/dist/chunking/boundary-codec.js +79 -0
- package/dist/chunking/boundary-codec.js.map +1 -0
- package/dist/chunking/build.d.ts +11 -0
- package/dist/chunking/build.d.ts.map +1 -0
- package/dist/chunking/build.js +51 -0
- package/dist/chunking/build.js.map +1 -0
- package/dist/chunking/e2e/constants.d.ts +2 -0
- package/dist/chunking/e2e/constants.d.ts.map +1 -0
- package/dist/chunking/e2e/constants.js +2 -0
- package/dist/chunking/e2e/constants.js.map +1 -0
- package/dist/chunking/e2e/seed-datasets.script.d.ts +20 -0
- package/dist/chunking/e2e/seed-datasets.script.d.ts.map +1 -0
- package/dist/chunking/e2e/seed-datasets.script.js +134 -0
- package/dist/chunking/e2e/seed-datasets.script.js.map +1 -0
- package/dist/chunking/introspect.d.ts +40 -0
- package/dist/chunking/introspect.d.ts.map +1 -0
- package/dist/chunking/introspect.js +187 -0
- package/dist/chunking/introspect.js.map +1 -0
- package/dist/chunking/partition-slices.d.ts +14 -0
- package/dist/chunking/partition-slices.d.ts.map +1 -0
- package/dist/chunking/partition-slices.js +111 -0
- package/dist/chunking/partition-slices.js.map +1 -0
- package/dist/chunking/planner.d.ts +3 -0
- package/dist/chunking/planner.d.ts.map +1 -0
- package/dist/chunking/planner.js +343 -0
- package/dist/chunking/planner.js.map +1 -0
- package/dist/chunking/services/distribution-source.d.ts +11 -0
- package/dist/chunking/services/distribution-source.d.ts.map +1 -0
- package/dist/chunking/services/distribution-source.js +60 -0
- package/dist/chunking/services/distribution-source.js.map +1 -0
- package/dist/chunking/services/metadata-source.d.ts +4 -0
- package/dist/chunking/services/metadata-source.d.ts.map +1 -0
- package/dist/chunking/services/metadata-source.js +138 -0
- package/dist/chunking/services/metadata-source.js.map +1 -0
- package/dist/chunking/services/row-probe.d.ts +14 -0
- package/dist/chunking/services/row-probe.d.ts.map +1 -0
- package/dist/chunking/services/row-probe.js +62 -0
- package/dist/chunking/services/row-probe.js.map +1 -0
- package/dist/chunking/splitter.d.ts +20 -0
- package/dist/chunking/splitter.d.ts.map +1 -0
- package/dist/chunking/splitter.js +76 -0
- package/dist/chunking/splitter.js.map +1 -0
- package/dist/chunking/sql.d.ts +20 -0
- package/dist/chunking/sql.d.ts.map +1 -0
- package/dist/chunking/sql.js +304 -0
- package/dist/chunking/sql.js.map +1 -0
- package/dist/chunking/strategies/equal-width-split.d.ts +4 -0
- package/dist/chunking/strategies/equal-width-split.d.ts.map +1 -0
- package/dist/chunking/strategies/equal-width-split.js +46 -0
- package/dist/chunking/strategies/equal-width-split.js.map +1 -0
- package/dist/chunking/strategies/group-by-key-split.d.ts +3 -0
- package/dist/chunking/strategies/group-by-key-split.d.ts.map +1 -0
- package/dist/chunking/strategies/group-by-key-split.js +54 -0
- package/dist/chunking/strategies/group-by-key-split.js.map +1 -0
- package/dist/chunking/strategies/metadata-single-chunk.d.ts +3 -0
- package/dist/chunking/strategies/metadata-single-chunk.d.ts.map +1 -0
- package/dist/chunking/strategies/metadata-single-chunk.js +5 -0
- package/dist/chunking/strategies/metadata-single-chunk.js.map +1 -0
- package/dist/chunking/strategies/quantile-range-split.d.ts +5 -0
- package/dist/chunking/strategies/quantile-range-split.d.ts.map +1 -0
- package/dist/chunking/strategies/quantile-range-split.js +132 -0
- package/dist/chunking/strategies/quantile-range-split.js.map +1 -0
- package/dist/chunking/strategies/refinement.d.ts +4 -0
- package/dist/chunking/strategies/refinement.d.ts.map +1 -0
- package/dist/chunking/strategies/refinement.js +61 -0
- package/dist/chunking/strategies/refinement.js.map +1 -0
- package/dist/chunking/strategies/string-prefix-split.d.ts +4 -0
- package/dist/chunking/strategies/string-prefix-split.d.ts.map +1 -0
- package/dist/chunking/strategies/string-prefix-split.js +73 -0
- package/dist/chunking/strategies/string-prefix-split.js.map +1 -0
- package/dist/chunking/strategies/temporal-bucket-split.d.ts +4 -0
- package/dist/chunking/strategies/temporal-bucket-split.d.ts.map +1 -0
- package/dist/chunking/strategies/temporal-bucket-split.js +67 -0
- package/dist/chunking/strategies/temporal-bucket-split.js.map +1 -0
- package/dist/chunking/strategy-policy.d.ts +3 -0
- package/dist/chunking/strategy-policy.d.ts.map +1 -0
- package/dist/chunking/strategy-policy.js +4 -0
- package/dist/chunking/strategy-policy.js.map +1 -0
- package/dist/chunking/types.d.ts +139 -0
- package/dist/chunking/types.d.ts.map +1 -0
- package/dist/chunking/types.js +2 -0
- package/dist/chunking/types.js.map +1 -0
- package/dist/chunking/utils/binary-string.d.ts +8 -0
- package/dist/chunking/utils/binary-string.d.ts.map +1 -0
- package/dist/chunking/utils/binary-string.js +52 -0
- package/dist/chunking/utils/binary-string.js.map +1 -0
- package/dist/chunking/utils/ids.d.ts +4 -0
- package/dist/chunking/utils/ids.d.ts.map +1 -0
- package/dist/chunking/utils/ids.js +11 -0
- package/dist/chunking/utils/ids.js.map +1 -0
- package/dist/chunking/utils/ranges.d.ts +5 -0
- package/dist/chunking/utils/ranges.d.ts.map +1 -0
- package/dist/chunking/utils/ranges.js +19 -0
- package/dist/chunking/utils/ranges.js.map +1 -0
- package/dist/detect.d.ts +13 -0
- package/dist/detect.d.ts.map +1 -0
- package/dist/detect.js +113 -0
- package/dist/detect.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/logging.d.ts +12 -0
- package/dist/logging.d.ts.map +1 -0
- package/dist/logging.js +61 -0
- package/dist/logging.js.map +1 -0
- package/dist/options.d.ts +151 -4
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +161 -109
- package/dist/options.js.map +1 -1
- package/dist/payload.d.ts +7 -17
- package/dist/payload.d.ts.map +1 -1
- package/dist/payload.js +7 -19
- package/dist/payload.js.map +1 -1
- package/dist/planner.d.ts +10 -8
- package/dist/planner.d.ts.map +1 -1
- package/dist/planner.js +76 -97
- package/dist/planner.js.map +1 -1
- package/dist/plugin.d.ts +4 -3
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +311 -215
- package/dist/plugin.js.map +1 -1
- package/dist/queries.d.ts +21 -0
- package/dist/queries.d.ts.map +1 -0
- package/dist/queries.js +113 -0
- package/dist/queries.js.map +1 -0
- package/dist/runtime.d.ts +14 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +162 -83
- package/dist/runtime.js.map +1 -1
- package/dist/sdk.d.ts +12 -0
- package/dist/sdk.d.ts.map +1 -0
- package/dist/sdk.js +9 -0
- package/dist/sdk.js.map +1 -0
- package/dist/state.d.ts +16 -28
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +73 -127
- package/dist/state.js.map +1 -1
- package/dist/table-config.d.ts +9 -0
- package/dist/table-config.d.ts.map +1 -0
- package/dist/table-config.js +2 -0
- package/dist/table-config.js.map +1 -0
- package/dist/types.d.ts +49 -114
- package/dist/types.d.ts.map +1 -1
- package/package.json +31 -2
package/dist/plugin.js
CHANGED
|
@@ -1,12 +1,134 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createClickHouseExecutor } from '@chkit/clickhouse';
|
|
2
|
+
import { wrapPluginRun } from '@chkit/core';
|
|
3
|
+
import { executeBackfill } from './async-backfill.js';
|
|
4
|
+
import { buildChunkExecutionSql } from './chunking/sql.js';
|
|
5
|
+
import { generateIdempotencyToken } from './chunking/utils/ids.js';
|
|
2
6
|
import { BackfillConfigError } from './errors.js';
|
|
3
|
-
import {
|
|
4
|
-
import { planPayload,
|
|
7
|
+
import { PLAN_FLAGS, PLAN_ID_FLAGS, RESUME_FLAGS, RUN_FLAGS, PluginConfigSchema, resolveCheckOptions, resolvePlanOptions, resolveResumeOptions, resolveRunOptions, resolveStatusOptions, } from './options.js';
|
|
8
|
+
import { planPayload, statusPayload, cancelPayload, doctorPayload } from './payload.js';
|
|
5
9
|
import { buildBackfillPlan } from './planner.js';
|
|
6
|
-
import {
|
|
10
|
+
import { evaluateBackfillCheck } from './check.js';
|
|
11
|
+
import { cancelBackfillRun, getBackfillDoctorReport, getBackfillStatus } from './queries.js';
|
|
12
|
+
import { backfillPaths, ensureEnvironmentMatch, nowIso, readPlan, readRun, summarizeRunStatus, writeJson, } from './state.js';
|
|
13
|
+
function formatBytes(bytes) {
|
|
14
|
+
if (bytes >= 1024 ** 4)
|
|
15
|
+
return `${(bytes / 1024 ** 4).toFixed(1)} TiB`;
|
|
16
|
+
if (bytes >= 1024 ** 3)
|
|
17
|
+
return `${(bytes / 1024 ** 3).toFixed(1)} GiB`;
|
|
18
|
+
if (bytes >= 1024 ** 2)
|
|
19
|
+
return `${(bytes / 1024 ** 2).toFixed(1)} MiB`;
|
|
20
|
+
if (bytes >= 1024)
|
|
21
|
+
return `${(bytes / 1024).toFixed(1)} KiB`;
|
|
22
|
+
return `${bytes} B`;
|
|
23
|
+
}
|
|
24
|
+
async function runBackfill(input) {
|
|
25
|
+
const { plan, stateDir } = await readPlan({
|
|
26
|
+
planId: input.planId,
|
|
27
|
+
configPath: input.configPath,
|
|
28
|
+
config: input.config,
|
|
29
|
+
stateDir: input.stateDir,
|
|
30
|
+
});
|
|
31
|
+
ensureEnvironmentMatch({
|
|
32
|
+
plan,
|
|
33
|
+
clickhouse: input.clickhouse,
|
|
34
|
+
forceEnvironment: input.forceEnvironment,
|
|
35
|
+
});
|
|
36
|
+
const paths = backfillPaths(stateDir, plan.planId);
|
|
37
|
+
// Check for existing run state
|
|
38
|
+
const existingRun = await readRun(paths.runPath);
|
|
39
|
+
const resumeFrom = input.resumeFrom;
|
|
40
|
+
if (existingRun && !resumeFrom) {
|
|
41
|
+
// `run` command (no resumeFrom) must not silently continue an existing run.
|
|
42
|
+
// Users should use `backfill resume` instead.
|
|
43
|
+
const status = existingRun.status;
|
|
44
|
+
if (status === 'completed') {
|
|
45
|
+
throw new BackfillConfigError(`Run already completed for plan ${plan.planId}. Nothing to do.`);
|
|
46
|
+
}
|
|
47
|
+
if (status === 'cancelled') {
|
|
48
|
+
throw new BackfillConfigError(`Run is cancelled for plan ${plan.planId}. Create a new plan or inspect with backfill doctor.`);
|
|
49
|
+
}
|
|
50
|
+
throw new BackfillConfigError(`A run already exists for plan ${plan.planId} (status: ${status}). Use backfill resume to continue.`);
|
|
51
|
+
}
|
|
52
|
+
const db = createClickHouseExecutor(input.clickhouse);
|
|
53
|
+
try {
|
|
54
|
+
const runState = {
|
|
55
|
+
planId: plan.planId,
|
|
56
|
+
target: plan.target,
|
|
57
|
+
status: 'running',
|
|
58
|
+
startedAt: existingRun?.startedAt ?? nowIso(),
|
|
59
|
+
updatedAt: nowIso(),
|
|
60
|
+
progress: resumeFrom ?? {},
|
|
61
|
+
};
|
|
62
|
+
await writeJson(paths.runPath, runState);
|
|
63
|
+
const result = await executeBackfill({
|
|
64
|
+
executor: db,
|
|
65
|
+
planId: plan.planId,
|
|
66
|
+
chunks: plan.chunkPlan.chunks.map((chunk) => ({ id: chunk.id })),
|
|
67
|
+
buildQuery: (chunk) => {
|
|
68
|
+
const planChunk = plan.chunkPlan.chunks.find((candidate) => candidate.id === chunk.id);
|
|
69
|
+
if (!planChunk)
|
|
70
|
+
throw new Error(`Chunk ${chunk.id} not found in plan`);
|
|
71
|
+
return buildChunkExecutionSql({
|
|
72
|
+
planId: plan.planId,
|
|
73
|
+
chunk: planChunk,
|
|
74
|
+
target: plan.target,
|
|
75
|
+
sourceTarget: plan.execution.sourceTarget,
|
|
76
|
+
table: plan.chunkPlan.table,
|
|
77
|
+
mvAsQuery: plan.execution.mvAsQuery,
|
|
78
|
+
targetColumns: plan.execution.targetColumns,
|
|
79
|
+
idempotencyToken: plan.execution.requireIdempotencyToken
|
|
80
|
+
? generateIdempotencyToken(plan.planId, planChunk.id)
|
|
81
|
+
: '',
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
concurrency: input.concurrency,
|
|
85
|
+
pollIntervalMs: input.pollIntervalMs,
|
|
86
|
+
resumeFrom,
|
|
87
|
+
replayFailed: input.replayFailed,
|
|
88
|
+
onProgress: async (progress) => {
|
|
89
|
+
runState.progress = progress;
|
|
90
|
+
runState.updatedAt = nowIso();
|
|
91
|
+
await writeJson(paths.runPath, runState);
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
runState.status = result.failed > 0 ? 'failed' : 'completed';
|
|
95
|
+
runState.completedAt = nowIso();
|
|
96
|
+
runState.updatedAt = nowIso();
|
|
97
|
+
runState.progress = result.progress;
|
|
98
|
+
if (result.failed > 0) {
|
|
99
|
+
const failedEntry = Object.values(result.progress).find((c) => c.status === 'failed');
|
|
100
|
+
runState.lastError = failedEntry?.error ?? 'One or more chunks failed';
|
|
101
|
+
}
|
|
102
|
+
await writeJson(paths.runPath, runState);
|
|
103
|
+
const summary = summarizeRunStatus(runState, paths.runPath, plan);
|
|
104
|
+
if (input.jsonMode) {
|
|
105
|
+
input.print({
|
|
106
|
+
ok: result.failed === 0,
|
|
107
|
+
planId: plan.planId,
|
|
108
|
+
status: runState.status,
|
|
109
|
+
chunkCounts: summary.totals,
|
|
110
|
+
rowsWritten: summary.rowsWritten,
|
|
111
|
+
runPath: paths.runPath,
|
|
112
|
+
lastError: runState.lastError,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
let line = `Backfill ${plan.planId}: ${runState.status} (done=${summary.totals.done}/${summary.totals.total}, ${summary.rowsWritten} rows written)`;
|
|
117
|
+
if (runState.lastError)
|
|
118
|
+
line += ` \u2014 ${runState.lastError}`;
|
|
119
|
+
input.print(line);
|
|
120
|
+
if (runState.status === 'completed' && summary.rowsWritten === 0) {
|
|
121
|
+
input.print('Warning: 0 rows written across all chunks. Verify that source data exists in the time range and passes the query\'s WHERE filters.');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return result.failed > 0 ? 1 : 0;
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
await db.close();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
7
130
|
export function createBackfillPlugin(options = {}) {
|
|
8
|
-
const
|
|
9
|
-
validateBaseOptions(base);
|
|
131
|
+
const config = PluginConfigSchema.parse(options);
|
|
10
132
|
return {
|
|
11
133
|
manifest: {
|
|
12
134
|
name: 'backfill',
|
|
@@ -16,271 +138,245 @@ export function createBackfillPlugin(options = {}) {
|
|
|
16
138
|
{
|
|
17
139
|
name: 'plan',
|
|
18
140
|
description: 'Build a deterministic backfill plan and persist immutable plan state',
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
options: effectiveOptions,
|
|
31
|
-
chunkHours: parsed.chunkHours,
|
|
32
|
-
forceLargeWindow: parsed.forceLargeWindow,
|
|
33
|
-
});
|
|
34
|
-
const payload = planPayload(output);
|
|
35
|
-
if (jsonMode) {
|
|
36
|
-
print(payload);
|
|
37
|
-
}
|
|
38
|
-
else {
|
|
39
|
-
print(`Backfill plan ${payload.planId} for ${payload.target} (${payload.chunkCount} chunks at ${payload.chunkHours}h) -> ${payload.planPath}${payload.existed ? ' [existing]' : ''}`);
|
|
141
|
+
flags: PLAN_FLAGS,
|
|
142
|
+
run: async (context) => wrapPluginRun({
|
|
143
|
+
command: 'plan',
|
|
144
|
+
label: 'Backfill plan',
|
|
145
|
+
jsonMode: context.jsonMode,
|
|
146
|
+
print: context.print,
|
|
147
|
+
configErrorClass: BackfillConfigError,
|
|
148
|
+
fn: async () => {
|
|
149
|
+
const opts = resolvePlanOptions(config, context.options, context.flags);
|
|
150
|
+
if (!context.config.clickhouse) {
|
|
151
|
+
throw new BackfillConfigError('ClickHouse connection is required for backfill planning. Configure clickhouse in your clickhouse.config.ts.');
|
|
40
152
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
153
|
+
const db = createClickHouseExecutor(context.config.clickhouse);
|
|
154
|
+
try {
|
|
155
|
+
const output = await buildBackfillPlan({
|
|
156
|
+
opts,
|
|
157
|
+
configPath: context.configPath,
|
|
158
|
+
config: context.config,
|
|
159
|
+
clickhouse: context.config.clickhouse,
|
|
160
|
+
clickhouseQuery: async (sql, settings) => {
|
|
161
|
+
const result = await db.query(sql, settings);
|
|
162
|
+
return result;
|
|
163
|
+
},
|
|
164
|
+
// ObsessionDB (ClickHouse Cloud) enables parallel replicas by default,
|
|
165
|
+
// which inflates aggregate results (count, GROUP BY). Disable for planning
|
|
166
|
+
// queries until ObsessionDB handles it at the profile level.
|
|
167
|
+
querySettings: { enable_parallel_replicas: 0 },
|
|
50
168
|
});
|
|
169
|
+
const payload = planPayload(output);
|
|
170
|
+
if (context.jsonMode) {
|
|
171
|
+
context.print(payload);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
const partitionCount = output.plan.chunkPlan.partitions.length;
|
|
175
|
+
const totalBytes = formatBytes(output.plan.chunkPlan.totalBytesCompressed);
|
|
176
|
+
const primarySortKey = output.plan.chunkPlan.table.sortKeys[0];
|
|
177
|
+
const sortKeyLabel = primarySortKey
|
|
178
|
+
? `, sort key: ${primarySortKey.name} (${primarySortKey.category})`
|
|
179
|
+
: '';
|
|
180
|
+
context.print(`Backfill plan ${payload.planId} for ${payload.target} (${payload.chunkCount} chunks across ${partitionCount} partitions, ~${totalBytes}${sortKeyLabel}) -> ${payload.planPath}`);
|
|
181
|
+
}
|
|
182
|
+
return 0;
|
|
51
183
|
}
|
|
52
|
-
|
|
53
|
-
|
|
184
|
+
finally {
|
|
185
|
+
await db.close();
|
|
54
186
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return 1;
|
|
58
|
-
}
|
|
59
|
-
},
|
|
187
|
+
},
|
|
188
|
+
}),
|
|
60
189
|
},
|
|
61
190
|
{
|
|
62
191
|
name: 'run',
|
|
63
|
-
description: 'Execute a planned backfill with
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
execution
|
|
75
|
-
replayDone: parsed.replayDone,
|
|
76
|
-
replayFailed: parsed.replayFailed,
|
|
77
|
-
forceOverlap: parsed.forceOverlap,
|
|
78
|
-
forceCompatibility: parsed.forceCompatibility,
|
|
79
|
-
simulation: {
|
|
80
|
-
failChunkId: parsed.simulateFailChunk,
|
|
81
|
-
failCount: parsed.simulateFailCount,
|
|
82
|
-
},
|
|
83
|
-
},
|
|
84
|
-
});
|
|
85
|
-
const payload = {
|
|
86
|
-
...runPayload(output),
|
|
87
|
-
command: 'run',
|
|
88
|
-
};
|
|
89
|
-
if (jsonMode) {
|
|
90
|
-
print(payload);
|
|
91
|
-
}
|
|
92
|
-
else {
|
|
93
|
-
print(`Backfill run ${payload.planId}: ${payload.status} (done=${payload.chunkCounts.done}/${payload.chunkCounts.total})`);
|
|
94
|
-
}
|
|
95
|
-
return payload.ok ? 0 : 1;
|
|
96
|
-
}
|
|
97
|
-
catch (error) {
|
|
98
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
99
|
-
if (jsonMode) {
|
|
100
|
-
print({ ok: false, command: 'run', error: message });
|
|
101
|
-
}
|
|
102
|
-
else {
|
|
103
|
-
print(`Backfill run failed: ${message}`);
|
|
192
|
+
description: 'Execute a planned backfill with async query submission and polling',
|
|
193
|
+
flags: RUN_FLAGS,
|
|
194
|
+
run: async (context) => wrapPluginRun({
|
|
195
|
+
command: 'run',
|
|
196
|
+
label: 'Backfill run',
|
|
197
|
+
jsonMode: context.jsonMode,
|
|
198
|
+
print: context.print,
|
|
199
|
+
configErrorClass: BackfillConfigError,
|
|
200
|
+
fn: async () => {
|
|
201
|
+
const opts = resolveRunOptions(config, context.options, context.flags);
|
|
202
|
+
if (!context.config.clickhouse) {
|
|
203
|
+
throw new BackfillConfigError('ClickHouse connection is required for backfill execution. Configure clickhouse in your clickhouse.config.ts.');
|
|
104
204
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
205
|
+
return runBackfill({
|
|
206
|
+
planId: opts.planId,
|
|
207
|
+
forceEnvironment: opts.forceEnvironment,
|
|
208
|
+
concurrency: opts.concurrency,
|
|
209
|
+
pollIntervalMs: opts.pollIntervalMs,
|
|
210
|
+
stateDir: opts.stateDir,
|
|
211
|
+
configPath: context.configPath,
|
|
212
|
+
config: context.config,
|
|
213
|
+
clickhouse: context.config.clickhouse,
|
|
214
|
+
print: context.print,
|
|
215
|
+
jsonMode: context.jsonMode,
|
|
216
|
+
});
|
|
217
|
+
},
|
|
218
|
+
}),
|
|
110
219
|
},
|
|
111
220
|
{
|
|
112
221
|
name: 'resume',
|
|
113
222
|
description: 'Resume a backfill run from last checkpoint',
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
execution
|
|
125
|
-
replayDone: parsed.replayDone,
|
|
126
|
-
replayFailed: parsed.replayFailed,
|
|
127
|
-
forceOverlap: parsed.forceOverlap,
|
|
128
|
-
forceCompatibility: parsed.forceCompatibility,
|
|
129
|
-
},
|
|
130
|
-
});
|
|
131
|
-
const payload = {
|
|
132
|
-
...runPayload(output),
|
|
133
|
-
command: 'resume',
|
|
134
|
-
};
|
|
135
|
-
if (jsonMode) {
|
|
136
|
-
print(payload);
|
|
223
|
+
flags: RESUME_FLAGS,
|
|
224
|
+
run: async (context) => wrapPluginRun({
|
|
225
|
+
command: 'resume',
|
|
226
|
+
label: 'Backfill resume',
|
|
227
|
+
jsonMode: context.jsonMode,
|
|
228
|
+
print: context.print,
|
|
229
|
+
configErrorClass: BackfillConfigError,
|
|
230
|
+
fn: async () => {
|
|
231
|
+
const opts = resolveResumeOptions(config, context.options, context.flags);
|
|
232
|
+
if (!context.config.clickhouse) {
|
|
233
|
+
throw new BackfillConfigError('ClickHouse connection is required for backfill execution. Configure clickhouse in your clickhouse.config.ts.');
|
|
137
234
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
235
|
+
const { stateDir } = await readPlan({
|
|
236
|
+
planId: opts.planId,
|
|
237
|
+
configPath: context.configPath,
|
|
238
|
+
config: context.config,
|
|
239
|
+
stateDir: opts.stateDir,
|
|
240
|
+
});
|
|
241
|
+
const paths = backfillPaths(stateDir, opts.planId);
|
|
242
|
+
const existingRun = await readRun(paths.runPath);
|
|
243
|
+
if (!existingRun) {
|
|
244
|
+
throw new BackfillConfigError(`Run state not found for plan ${opts.planId}. Start with backfill run before resume.`);
|
|
147
245
|
}
|
|
148
|
-
|
|
149
|
-
|
|
246
|
+
if (existingRun.status === 'completed') {
|
|
247
|
+
if (context.jsonMode) {
|
|
248
|
+
context.print({ ok: true, noop: true, planId: opts.planId, status: 'completed', message: 'Run already completed. Nothing to resume.' });
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
context.print(`Backfill ${opts.planId}: already completed. Nothing to resume.`);
|
|
252
|
+
}
|
|
253
|
+
return 0;
|
|
150
254
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
255
|
+
return runBackfill({
|
|
256
|
+
planId: opts.planId,
|
|
257
|
+
forceEnvironment: opts.forceEnvironment,
|
|
258
|
+
concurrency: opts.concurrency,
|
|
259
|
+
pollIntervalMs: opts.pollIntervalMs,
|
|
260
|
+
stateDir: opts.stateDir,
|
|
261
|
+
resumeFrom: existingRun.progress,
|
|
262
|
+
replayFailed: opts.replayFailed,
|
|
263
|
+
configPath: context.configPath,
|
|
264
|
+
config: context.config,
|
|
265
|
+
clickhouse: context.config.clickhouse,
|
|
266
|
+
print: context.print,
|
|
267
|
+
jsonMode: context.jsonMode,
|
|
268
|
+
});
|
|
269
|
+
},
|
|
270
|
+
}),
|
|
156
271
|
},
|
|
157
272
|
{
|
|
158
273
|
name: 'status',
|
|
159
274
|
description: 'Show checkpoint and chunk progress for a backfill run',
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
275
|
+
flags: PLAN_ID_FLAGS,
|
|
276
|
+
run: async (context) => wrapPluginRun({
|
|
277
|
+
command: 'status',
|
|
278
|
+
label: 'Backfill status',
|
|
279
|
+
jsonMode: context.jsonMode,
|
|
280
|
+
print: context.print,
|
|
281
|
+
configErrorClass: BackfillConfigError,
|
|
282
|
+
fn: async () => {
|
|
283
|
+
const opts = resolveStatusOptions(config, context.options, context.flags);
|
|
165
284
|
const summary = await getBackfillStatus({
|
|
166
|
-
planId:
|
|
167
|
-
config,
|
|
168
|
-
configPath,
|
|
169
|
-
|
|
285
|
+
planId: opts.planId,
|
|
286
|
+
config: context.config,
|
|
287
|
+
configPath: context.configPath,
|
|
288
|
+
stateDir: opts.stateDir,
|
|
170
289
|
});
|
|
171
290
|
const payload = statusPayload(summary);
|
|
172
|
-
if (jsonMode) {
|
|
173
|
-
print(payload);
|
|
291
|
+
if (context.jsonMode) {
|
|
292
|
+
context.print(payload);
|
|
174
293
|
}
|
|
175
294
|
else {
|
|
176
|
-
|
|
295
|
+
let line = `Backfill status ${payload.planId}: ${payload.status} (done=${payload.chunkCounts.done}/${payload.chunkCounts.total}, failed=${payload.chunkCounts.failed}, ${payload.rowsWritten} rows written)`;
|
|
296
|
+
if (payload.lastError)
|
|
297
|
+
line += ` \u2014 ${payload.lastError}`;
|
|
298
|
+
context.print(line);
|
|
299
|
+
if (payload.status === 'completed' && payload.rowsWritten === 0) {
|
|
300
|
+
context.print('Warning: 0 rows written across all chunks. Verify that source data exists in the time range and passes the query\'s WHERE filters.');
|
|
301
|
+
}
|
|
177
302
|
}
|
|
178
303
|
return payload.ok ? 0 : 1;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
182
|
-
if (jsonMode) {
|
|
183
|
-
print({ ok: false, command: 'status', error: message });
|
|
184
|
-
}
|
|
185
|
-
else {
|
|
186
|
-
print(`Backfill status failed: ${message}`);
|
|
187
|
-
}
|
|
188
|
-
if (error instanceof BackfillConfigError)
|
|
189
|
-
return 2;
|
|
190
|
-
return 1;
|
|
191
|
-
}
|
|
192
|
-
},
|
|
304
|
+
},
|
|
305
|
+
}),
|
|
193
306
|
},
|
|
194
307
|
{
|
|
195
308
|
name: 'cancel',
|
|
196
309
|
description: 'Cancel an in-progress backfill run and prevent further chunk execution',
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
310
|
+
flags: PLAN_ID_FLAGS,
|
|
311
|
+
run: async (context) => wrapPluginRun({
|
|
312
|
+
command: 'cancel',
|
|
313
|
+
label: 'Backfill cancel',
|
|
314
|
+
jsonMode: context.jsonMode,
|
|
315
|
+
print: context.print,
|
|
316
|
+
configErrorClass: BackfillConfigError,
|
|
317
|
+
fn: async () => {
|
|
318
|
+
const opts = resolveStatusOptions(config, context.options, context.flags);
|
|
202
319
|
const summary = await cancelBackfillRun({
|
|
203
|
-
planId:
|
|
204
|
-
config,
|
|
205
|
-
configPath,
|
|
206
|
-
|
|
320
|
+
planId: opts.planId,
|
|
321
|
+
config: context.config,
|
|
322
|
+
configPath: context.configPath,
|
|
323
|
+
stateDir: opts.stateDir,
|
|
207
324
|
});
|
|
208
325
|
const payload = cancelPayload(summary);
|
|
209
|
-
if (jsonMode) {
|
|
210
|
-
print(payload);
|
|
326
|
+
if (context.jsonMode) {
|
|
327
|
+
context.print(payload);
|
|
211
328
|
}
|
|
212
329
|
else {
|
|
213
|
-
print(`Backfill cancel ${payload.planId}: ${payload.status} (done=${payload.chunkCounts.done}/${payload.chunkCounts.total})`);
|
|
330
|
+
context.print(`Backfill cancel ${payload.planId}: ${payload.status} (done=${payload.chunkCounts.done}/${payload.chunkCounts.total})`);
|
|
214
331
|
}
|
|
215
332
|
return payload.ok ? 0 : 1;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
219
|
-
if (jsonMode) {
|
|
220
|
-
print({ ok: false, command: 'cancel', error: message });
|
|
221
|
-
}
|
|
222
|
-
else {
|
|
223
|
-
print(`Backfill cancel failed: ${message}`);
|
|
224
|
-
}
|
|
225
|
-
if (error instanceof BackfillConfigError)
|
|
226
|
-
return 2;
|
|
227
|
-
return 1;
|
|
228
|
-
}
|
|
229
|
-
},
|
|
333
|
+
},
|
|
334
|
+
}),
|
|
230
335
|
},
|
|
231
336
|
{
|
|
232
337
|
name: 'doctor',
|
|
233
338
|
description: 'Provide actionable remediation steps for failed or pending backfill runs',
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
339
|
+
flags: PLAN_ID_FLAGS,
|
|
340
|
+
run: async (context) => wrapPluginRun({
|
|
341
|
+
command: 'doctor',
|
|
342
|
+
label: 'Backfill doctor',
|
|
343
|
+
jsonMode: context.jsonMode,
|
|
344
|
+
print: context.print,
|
|
345
|
+
configErrorClass: BackfillConfigError,
|
|
346
|
+
fn: async () => {
|
|
347
|
+
const opts = resolveStatusOptions(config, context.options, context.flags);
|
|
239
348
|
const report = await getBackfillDoctorReport({
|
|
240
|
-
planId:
|
|
241
|
-
config,
|
|
242
|
-
configPath,
|
|
243
|
-
|
|
349
|
+
planId: opts.planId,
|
|
350
|
+
config: context.config,
|
|
351
|
+
configPath: context.configPath,
|
|
352
|
+
stateDir: opts.stateDir,
|
|
244
353
|
});
|
|
245
354
|
const payload = doctorPayload(report);
|
|
246
|
-
if (jsonMode) {
|
|
247
|
-
print(payload);
|
|
355
|
+
if (context.jsonMode) {
|
|
356
|
+
context.print(payload);
|
|
248
357
|
}
|
|
249
358
|
else {
|
|
250
|
-
print(`Backfill doctor ${payload.planId}: ${payload.issueCodes.length === 0 ? 'ok' : payload.issueCodes.join(', ')}`);
|
|
359
|
+
context.print(`Backfill doctor ${payload.planId}: ${payload.issueCodes.length === 0 ? 'ok' : payload.issueCodes.join(', ')}`);
|
|
251
360
|
for (const recommendation of payload.recommendations) {
|
|
252
|
-
print(`- ${recommendation}`);
|
|
361
|
+
context.print(`- ${recommendation}`);
|
|
253
362
|
}
|
|
254
363
|
}
|
|
255
364
|
return payload.ok ? 0 : 1;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
259
|
-
if (jsonMode) {
|
|
260
|
-
print({ ok: false, command: 'doctor', error: message });
|
|
261
|
-
}
|
|
262
|
-
else {
|
|
263
|
-
print(`Backfill doctor failed: ${message}`);
|
|
264
|
-
}
|
|
265
|
-
if (error instanceof BackfillConfigError)
|
|
266
|
-
return 2;
|
|
267
|
-
return 1;
|
|
268
|
-
}
|
|
269
|
-
},
|
|
365
|
+
},
|
|
366
|
+
}),
|
|
270
367
|
},
|
|
271
368
|
],
|
|
272
369
|
hooks: {
|
|
273
370
|
onConfigLoaded({ options: runtimeOptions }) {
|
|
274
|
-
|
|
275
|
-
validateBaseOptions(merged);
|
|
371
|
+
resolveCheckOptions(config, runtimeOptions);
|
|
276
372
|
},
|
|
277
|
-
async onCheck({ config, configPath, options: runtimeOptions }) {
|
|
278
|
-
const
|
|
279
|
-
validateBaseOptions(effectiveOptions);
|
|
373
|
+
async onCheck({ config: appConfig, configPath, options: runtimeOptions }) {
|
|
374
|
+
const opts = resolveCheckOptions(config, runtimeOptions);
|
|
280
375
|
return evaluateBackfillCheck({
|
|
281
376
|
configPath,
|
|
282
|
-
config,
|
|
283
|
-
|
|
377
|
+
config: appConfig,
|
|
378
|
+
stateDir: opts.stateDir,
|
|
379
|
+
failCheckOnRequiredPendingBackfill: opts.failCheckOnRequiredPendingBackfill,
|
|
284
380
|
});
|
|
285
381
|
},
|
|
286
382
|
onCheckReport({ result, print }) {
|
|
@@ -296,7 +392,7 @@ export function createBackfillPlugin(options = {}) {
|
|
|
296
392
|
}
|
|
297
393
|
export function backfill(options = {}) {
|
|
298
394
|
return {
|
|
299
|
-
plugin: createBackfillPlugin(),
|
|
395
|
+
plugin: createBackfillPlugin(options),
|
|
300
396
|
name: 'backfill',
|
|
301
397
|
enabled: true,
|
|
302
398
|
options,
|