@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.
Files changed (164) hide show
  1. package/README.md +170 -0
  2. package/dist/args.d.ts +109 -6
  3. package/dist/args.d.ts.map +1 -1
  4. package/dist/args.js +73 -97
  5. package/dist/args.js.map +1 -1
  6. package/dist/async-backfill.d.ts +64 -0
  7. package/dist/async-backfill.d.ts.map +1 -0
  8. package/dist/async-backfill.js +251 -0
  9. package/dist/async-backfill.js.map +1 -0
  10. package/dist/check.d.ts +9 -0
  11. package/dist/check.d.ts.map +1 -0
  12. package/dist/check.js +79 -0
  13. package/dist/check.js.map +1 -0
  14. package/dist/chunking/analyze.d.ts +8 -0
  15. package/dist/chunking/analyze.d.ts.map +1 -0
  16. package/dist/chunking/analyze.js +8 -0
  17. package/dist/chunking/analyze.js.map +1 -0
  18. package/dist/chunking/boundary-codec.d.ts +10 -0
  19. package/dist/chunking/boundary-codec.d.ts.map +1 -0
  20. package/dist/chunking/boundary-codec.js +79 -0
  21. package/dist/chunking/boundary-codec.js.map +1 -0
  22. package/dist/chunking/build.d.ts +11 -0
  23. package/dist/chunking/build.d.ts.map +1 -0
  24. package/dist/chunking/build.js +51 -0
  25. package/dist/chunking/build.js.map +1 -0
  26. package/dist/chunking/e2e/constants.d.ts +2 -0
  27. package/dist/chunking/e2e/constants.d.ts.map +1 -0
  28. package/dist/chunking/e2e/constants.js +2 -0
  29. package/dist/chunking/e2e/constants.js.map +1 -0
  30. package/dist/chunking/e2e/seed-datasets.script.d.ts +20 -0
  31. package/dist/chunking/e2e/seed-datasets.script.d.ts.map +1 -0
  32. package/dist/chunking/e2e/seed-datasets.script.js +134 -0
  33. package/dist/chunking/e2e/seed-datasets.script.js.map +1 -0
  34. package/dist/chunking/introspect.d.ts +40 -0
  35. package/dist/chunking/introspect.d.ts.map +1 -0
  36. package/dist/chunking/introspect.js +187 -0
  37. package/dist/chunking/introspect.js.map +1 -0
  38. package/dist/chunking/partition-slices.d.ts +14 -0
  39. package/dist/chunking/partition-slices.d.ts.map +1 -0
  40. package/dist/chunking/partition-slices.js +111 -0
  41. package/dist/chunking/partition-slices.js.map +1 -0
  42. package/dist/chunking/planner.d.ts +3 -0
  43. package/dist/chunking/planner.d.ts.map +1 -0
  44. package/dist/chunking/planner.js +343 -0
  45. package/dist/chunking/planner.js.map +1 -0
  46. package/dist/chunking/services/distribution-source.d.ts +11 -0
  47. package/dist/chunking/services/distribution-source.d.ts.map +1 -0
  48. package/dist/chunking/services/distribution-source.js +60 -0
  49. package/dist/chunking/services/distribution-source.js.map +1 -0
  50. package/dist/chunking/services/metadata-source.d.ts +4 -0
  51. package/dist/chunking/services/metadata-source.d.ts.map +1 -0
  52. package/dist/chunking/services/metadata-source.js +138 -0
  53. package/dist/chunking/services/metadata-source.js.map +1 -0
  54. package/dist/chunking/services/row-probe.d.ts +14 -0
  55. package/dist/chunking/services/row-probe.d.ts.map +1 -0
  56. package/dist/chunking/services/row-probe.js +62 -0
  57. package/dist/chunking/services/row-probe.js.map +1 -0
  58. package/dist/chunking/splitter.d.ts +20 -0
  59. package/dist/chunking/splitter.d.ts.map +1 -0
  60. package/dist/chunking/splitter.js +76 -0
  61. package/dist/chunking/splitter.js.map +1 -0
  62. package/dist/chunking/sql.d.ts +20 -0
  63. package/dist/chunking/sql.d.ts.map +1 -0
  64. package/dist/chunking/sql.js +304 -0
  65. package/dist/chunking/sql.js.map +1 -0
  66. package/dist/chunking/strategies/equal-width-split.d.ts +4 -0
  67. package/dist/chunking/strategies/equal-width-split.d.ts.map +1 -0
  68. package/dist/chunking/strategies/equal-width-split.js +46 -0
  69. package/dist/chunking/strategies/equal-width-split.js.map +1 -0
  70. package/dist/chunking/strategies/group-by-key-split.d.ts +3 -0
  71. package/dist/chunking/strategies/group-by-key-split.d.ts.map +1 -0
  72. package/dist/chunking/strategies/group-by-key-split.js +54 -0
  73. package/dist/chunking/strategies/group-by-key-split.js.map +1 -0
  74. package/dist/chunking/strategies/metadata-single-chunk.d.ts +3 -0
  75. package/dist/chunking/strategies/metadata-single-chunk.d.ts.map +1 -0
  76. package/dist/chunking/strategies/metadata-single-chunk.js +5 -0
  77. package/dist/chunking/strategies/metadata-single-chunk.js.map +1 -0
  78. package/dist/chunking/strategies/quantile-range-split.d.ts +5 -0
  79. package/dist/chunking/strategies/quantile-range-split.d.ts.map +1 -0
  80. package/dist/chunking/strategies/quantile-range-split.js +132 -0
  81. package/dist/chunking/strategies/quantile-range-split.js.map +1 -0
  82. package/dist/chunking/strategies/refinement.d.ts +4 -0
  83. package/dist/chunking/strategies/refinement.d.ts.map +1 -0
  84. package/dist/chunking/strategies/refinement.js +61 -0
  85. package/dist/chunking/strategies/refinement.js.map +1 -0
  86. package/dist/chunking/strategies/string-prefix-split.d.ts +4 -0
  87. package/dist/chunking/strategies/string-prefix-split.d.ts.map +1 -0
  88. package/dist/chunking/strategies/string-prefix-split.js +73 -0
  89. package/dist/chunking/strategies/string-prefix-split.js.map +1 -0
  90. package/dist/chunking/strategies/temporal-bucket-split.d.ts +4 -0
  91. package/dist/chunking/strategies/temporal-bucket-split.d.ts.map +1 -0
  92. package/dist/chunking/strategies/temporal-bucket-split.js +67 -0
  93. package/dist/chunking/strategies/temporal-bucket-split.js.map +1 -0
  94. package/dist/chunking/strategy-policy.d.ts +3 -0
  95. package/dist/chunking/strategy-policy.d.ts.map +1 -0
  96. package/dist/chunking/strategy-policy.js +4 -0
  97. package/dist/chunking/strategy-policy.js.map +1 -0
  98. package/dist/chunking/types.d.ts +139 -0
  99. package/dist/chunking/types.d.ts.map +1 -0
  100. package/dist/chunking/types.js +2 -0
  101. package/dist/chunking/types.js.map +1 -0
  102. package/dist/chunking/utils/binary-string.d.ts +8 -0
  103. package/dist/chunking/utils/binary-string.d.ts.map +1 -0
  104. package/dist/chunking/utils/binary-string.js +52 -0
  105. package/dist/chunking/utils/binary-string.js.map +1 -0
  106. package/dist/chunking/utils/ids.d.ts +4 -0
  107. package/dist/chunking/utils/ids.d.ts.map +1 -0
  108. package/dist/chunking/utils/ids.js +11 -0
  109. package/dist/chunking/utils/ids.js.map +1 -0
  110. package/dist/chunking/utils/ranges.d.ts +5 -0
  111. package/dist/chunking/utils/ranges.d.ts.map +1 -0
  112. package/dist/chunking/utils/ranges.js +19 -0
  113. package/dist/chunking/utils/ranges.js.map +1 -0
  114. package/dist/detect.d.ts +13 -0
  115. package/dist/detect.d.ts.map +1 -0
  116. package/dist/detect.js +113 -0
  117. package/dist/detect.js.map +1 -0
  118. package/dist/index.d.ts +3 -0
  119. package/dist/index.d.ts.map +1 -1
  120. package/dist/index.js +1 -0
  121. package/dist/index.js.map +1 -1
  122. package/dist/logging.d.ts +12 -0
  123. package/dist/logging.d.ts.map +1 -0
  124. package/dist/logging.js +61 -0
  125. package/dist/logging.js.map +1 -0
  126. package/dist/options.d.ts +151 -4
  127. package/dist/options.d.ts.map +1 -1
  128. package/dist/options.js +161 -109
  129. package/dist/options.js.map +1 -1
  130. package/dist/payload.d.ts +7 -17
  131. package/dist/payload.d.ts.map +1 -1
  132. package/dist/payload.js +7 -19
  133. package/dist/payload.js.map +1 -1
  134. package/dist/planner.d.ts +10 -8
  135. package/dist/planner.d.ts.map +1 -1
  136. package/dist/planner.js +76 -97
  137. package/dist/planner.js.map +1 -1
  138. package/dist/plugin.d.ts +4 -3
  139. package/dist/plugin.d.ts.map +1 -1
  140. package/dist/plugin.js +311 -215
  141. package/dist/plugin.js.map +1 -1
  142. package/dist/queries.d.ts +21 -0
  143. package/dist/queries.d.ts.map +1 -0
  144. package/dist/queries.js +113 -0
  145. package/dist/queries.js.map +1 -0
  146. package/dist/runtime.d.ts +14 -0
  147. package/dist/runtime.d.ts.map +1 -1
  148. package/dist/runtime.js +162 -83
  149. package/dist/runtime.js.map +1 -1
  150. package/dist/sdk.d.ts +12 -0
  151. package/dist/sdk.d.ts.map +1 -0
  152. package/dist/sdk.js +9 -0
  153. package/dist/sdk.js.map +1 -0
  154. package/dist/state.d.ts +16 -28
  155. package/dist/state.d.ts.map +1 -1
  156. package/dist/state.js +73 -127
  157. package/dist/state.js.map +1 -1
  158. package/dist/table-config.d.ts +9 -0
  159. package/dist/table-config.d.ts.map +1 -0
  160. package/dist/table-config.js +2 -0
  161. package/dist/table-config.js.map +1 -0
  162. package/dist/types.d.ts +49 -114
  163. package/dist/types.d.ts.map +1 -1
  164. package/package.json +31 -2
package/dist/plugin.js CHANGED
@@ -1,12 +1,134 @@
1
- import { parseCancelArgs, parseDoctorArgs, parsePlanArgs, parseResumeArgs, parseRunArgs, parseStatusArgs } from './args.js';
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 { normalizeBackfillOptions, mergeOptions, validateBaseOptions } from './options.js';
4
- import { planPayload, runPayload, statusPayload, cancelPayload, doctorPayload } from './payload.js';
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 { cancelBackfillRun, evaluateBackfillCheck, executeBackfillRun, getBackfillDoctorReport, getBackfillStatus, resumeBackfillRun, } from './runtime.js';
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 base = normalizeBackfillOptions(options);
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
- async run({ args, jsonMode, print, options: runtimeOptions, config, configPath }) {
20
- try {
21
- const parsed = parsePlanArgs(args);
22
- const effectiveOptions = mergeOptions(base, runtimeOptions);
23
- validateBaseOptions(effectiveOptions);
24
- const output = await buildBackfillPlan({
25
- target: parsed.target,
26
- from: parsed.from,
27
- to: parsed.to,
28
- config,
29
- configPath,
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
- return 0;
42
- }
43
- catch (error) {
44
- const message = error instanceof Error ? error.message : String(error);
45
- if (jsonMode) {
46
- print({
47
- ok: false,
48
- command: 'plan',
49
- error: message,
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
- else {
53
- print(`Backfill plan failed: ${message}`);
184
+ finally {
185
+ await db.close();
54
186
  }
55
- if (error instanceof BackfillConfigError)
56
- return 2;
57
- return 1;
58
- }
59
- },
187
+ },
188
+ }),
60
189
  },
61
190
  {
62
191
  name: 'run',
63
- description: 'Execute a planned backfill with checkpointed chunk progress',
64
- async run({ args, jsonMode, print, options: runtimeOptions, config, configPath }) {
65
- try {
66
- const parsed = parseRunArgs(args);
67
- const effectiveOptions = mergeOptions(base, runtimeOptions);
68
- validateBaseOptions(effectiveOptions);
69
- const output = await executeBackfillRun({
70
- planId: parsed.planId,
71
- config,
72
- configPath,
73
- options: effectiveOptions,
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
- if (error instanceof BackfillConfigError)
106
- return 2;
107
- return 1;
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
- async run({ args, jsonMode, print, options: runtimeOptions, config, configPath }) {
115
- try {
116
- const parsed = parseResumeArgs(args);
117
- const effectiveOptions = mergeOptions(base, runtimeOptions);
118
- validateBaseOptions(effectiveOptions);
119
- const output = await resumeBackfillRun({
120
- planId: parsed.planId,
121
- config,
122
- configPath,
123
- options: effectiveOptions,
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
- else {
139
- print(`Backfill resume ${payload.planId}: ${payload.status} (done=${payload.chunkCounts.done}/${payload.chunkCounts.total})`);
140
- }
141
- return payload.ok ? 0 : 1;
142
- }
143
- catch (error) {
144
- const message = error instanceof Error ? error.message : String(error);
145
- if (jsonMode) {
146
- print({ ok: false, command: 'resume', error: message });
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
- else {
149
- print(`Backfill resume failed: ${message}`);
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
- if (error instanceof BackfillConfigError)
152
- return 2;
153
- return 1;
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
- async run({ args, jsonMode, print, options: runtimeOptions, config, configPath }) {
161
- try {
162
- const parsed = parseStatusArgs(args);
163
- const effectiveOptions = mergeOptions(base, runtimeOptions);
164
- validateBaseOptions(effectiveOptions);
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: parsed.planId,
167
- config,
168
- configPath,
169
- options: effectiveOptions,
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
- print(`Backfill status ${payload.planId}: ${payload.status} (done=${payload.chunkCounts.done}/${payload.chunkCounts.total}, failed=${payload.chunkCounts.failed})`);
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
- catch (error) {
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
- async run({ args, jsonMode, print, options: runtimeOptions, config, configPath }) {
198
- try {
199
- const parsed = parseCancelArgs(args);
200
- const effectiveOptions = mergeOptions(base, runtimeOptions);
201
- validateBaseOptions(effectiveOptions);
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: parsed.planId,
204
- config,
205
- configPath,
206
- options: effectiveOptions,
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
- catch (error) {
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
- async run({ args, jsonMode, print, options: runtimeOptions, config, configPath }) {
235
- try {
236
- const parsed = parseDoctorArgs(args);
237
- const effectiveOptions = mergeOptions(base, runtimeOptions);
238
- validateBaseOptions(effectiveOptions);
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: parsed.planId,
241
- config,
242
- configPath,
243
- options: effectiveOptions,
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
- catch (error) {
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
- const merged = mergeOptions(base, runtimeOptions);
275
- validateBaseOptions(merged);
371
+ resolveCheckOptions(config, runtimeOptions);
276
372
  },
277
- async onCheck({ config, configPath, options: runtimeOptions }) {
278
- const effectiveOptions = mergeOptions(base, runtimeOptions);
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
- options: effectiveOptions,
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,