@untangle-code/jobs 0.0.0-stage → 0.1.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/LICENSE +661 -0
- package/NOTICE +90 -0
- package/README.md +138 -2
- package/THIRD_PARTY_LICENSES.md +24 -0
- package/dist/abort.d.ts +14 -0
- package/dist/abort.d.ts.map +1 -0
- package/dist/abort.js +35 -0
- package/dist/abort.js.map +1 -0
- package/dist/bounded-queue.d.ts +200 -0
- package/dist/bounded-queue.d.ts.map +1 -0
- package/dist/bounded-queue.js +338 -0
- package/dist/bounded-queue.js.map +1 -0
- package/dist/checkpoint-store.d.ts +30 -0
- package/dist/checkpoint-store.d.ts.map +1 -0
- package/dist/checkpoint-store.js +131 -0
- package/dist/checkpoint-store.js.map +1 -0
- package/dist/events.d.ts +29 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +66 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/resume.d.ts +45 -0
- package/dist/resume.d.ts.map +1 -0
- package/dist/resume.js +60 -0
- package/dist/resume.js.map +1 -0
- package/dist/runner.d.ts +148 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +686 -0
- package/dist/runner.js.map +1 -0
- package/dist/stages.d.ts +146 -0
- package/dist/stages.d.ts.map +1 -0
- package/dist/stages.js +2 -0
- package/dist/stages.js.map +1 -0
- package/dist/state-machine.d.ts +52 -0
- package/dist/state-machine.d.ts.map +1 -0
- package/dist/state-machine.js +114 -0
- package/dist/state-machine.js.map +1 -0
- package/package.json +61 -3
- package/src/abort.ts +37 -0
- package/src/bounded-queue.ts +462 -0
- package/src/checkpoint-store.ts +171 -0
- package/src/events.ts +71 -0
- package/src/index.ts +37 -0
- package/src/resume.ts +106 -0
- package/src/runner.ts +916 -0
- package/src/stages.ts +167 -0
- package/src/state-machine.ts +132 -0
package/dist/runner.js
ADDED
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
import { UntangleError, assertGenerationComplete, buildCorpusRevisionFingerprint, isUntangleError, validateGenerationCompleteness, } from "@untangle-code/domain";
|
|
2
|
+
import { configInvalidError, indexIncompleteError } from "@untangle-code/observability";
|
|
3
|
+
import { isJobCancelledError, throwIfAborted } from "./abort.js";
|
|
4
|
+
import { BoundedQueue } from "./bounded-queue.js";
|
|
5
|
+
import { AsyncEventChannel } from "./events.js";
|
|
6
|
+
import { planResume } from "./resume.js";
|
|
7
|
+
import { JobStateMachine } from "./state-machine.js";
|
|
8
|
+
const DEFAULT_MEMORY_BUDGET_BYTES = 64 * 1024 * 1024;
|
|
9
|
+
const DEFAULT_EMBED_BATCH_SIZE = 8;
|
|
10
|
+
const DEFAULT_ANALYSIS_CAPACITY = 16;
|
|
11
|
+
const DEFAULT_UPSERT_CAPACITY = 4;
|
|
12
|
+
const DEFAULT_LOCK_LEASE_MS = 30_000;
|
|
13
|
+
const MIN_LOCK_LEASE_MS = 3;
|
|
14
|
+
/**
|
|
15
|
+
* Kleinstes zulaessiges Gesamtbudget. Das Budget wird haelftig auf beide
|
|
16
|
+
* Queues verteilt (`Math.floor(budget / 2)`), also muss es mindestens 2
|
|
17
|
+
* Bytes betragen, damit beide Haelften positiv sind. Bewusst KEINE hoehere,
|
|
18
|
+
* gegriffene Schwelle: ein knappes Budget ist eine legitime Testkonfiguration
|
|
19
|
+
* — dass es die Byte-Grenze nicht mehr abschaltet, sichert seit WP-116 die
|
|
20
|
+
* `BoundedQueue` selbst, nicht eine Mindestzahl hier.
|
|
21
|
+
*/
|
|
22
|
+
const MIN_MEMORY_BUDGET_BYTES = 2;
|
|
23
|
+
function errorMessage(error) {
|
|
24
|
+
return error instanceof Error ? error.message : String(error);
|
|
25
|
+
}
|
|
26
|
+
function lockRenewalError(lock, error) {
|
|
27
|
+
return new UntangleError("LOCK_HELD", `Index lock "${lock.key}" could not be renewed; exclusive ownership is no longer proven: ${errorMessage(error)}`, {
|
|
28
|
+
retryable: true,
|
|
29
|
+
operation: "index_lock_renew",
|
|
30
|
+
context: {
|
|
31
|
+
key: lock.key,
|
|
32
|
+
processInstanceId: lock.record.processInstanceId,
|
|
33
|
+
},
|
|
34
|
+
cause: error,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Keeps one lease alive without ever issuing overlapping `renew()` calls.
|
|
39
|
+
* A failed renewal aborts the job immediately; `assertOwnership()` repeats
|
|
40
|
+
* the ownership check after the heartbeat has stopped. A runner whose lease
|
|
41
|
+
* loss was observed does not invoke the terminal-generation observer.
|
|
42
|
+
*/
|
|
43
|
+
function startIndexLockHeartbeat(lock, leaseMs, internal, onFailure) {
|
|
44
|
+
const intervalMs = Math.max(1, Math.floor(leaseMs / 3));
|
|
45
|
+
let stopped = false;
|
|
46
|
+
let timer = null;
|
|
47
|
+
let inFlight = null;
|
|
48
|
+
let failure = null;
|
|
49
|
+
const fail = (error) => {
|
|
50
|
+
if (failure === null) {
|
|
51
|
+
failure = lockRenewalError(lock, error);
|
|
52
|
+
onFailure(failure);
|
|
53
|
+
internal.abort();
|
|
54
|
+
}
|
|
55
|
+
return failure;
|
|
56
|
+
};
|
|
57
|
+
const schedule = () => {
|
|
58
|
+
if (stopped || failure !== null)
|
|
59
|
+
return;
|
|
60
|
+
timer = setTimeout(() => {
|
|
61
|
+
timer = null;
|
|
62
|
+
if (stopped || failure !== null)
|
|
63
|
+
return;
|
|
64
|
+
inFlight = lock
|
|
65
|
+
.renew()
|
|
66
|
+
.catch((error) => {
|
|
67
|
+
fail(error);
|
|
68
|
+
})
|
|
69
|
+
.finally(() => {
|
|
70
|
+
inFlight = null;
|
|
71
|
+
schedule();
|
|
72
|
+
});
|
|
73
|
+
}, intervalMs);
|
|
74
|
+
};
|
|
75
|
+
const stop = async () => {
|
|
76
|
+
stopped = true;
|
|
77
|
+
if (timer !== null) {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
timer = null;
|
|
80
|
+
}
|
|
81
|
+
await inFlight;
|
|
82
|
+
};
|
|
83
|
+
schedule();
|
|
84
|
+
return {
|
|
85
|
+
get failure() {
|
|
86
|
+
return failure;
|
|
87
|
+
},
|
|
88
|
+
stop,
|
|
89
|
+
assertOwnership: async () => {
|
|
90
|
+
await stop();
|
|
91
|
+
if (failure !== null)
|
|
92
|
+
throw failure;
|
|
93
|
+
try {
|
|
94
|
+
await lock.renew();
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
throw fail(error);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
export class IndexJobRunner {
|
|
103
|
+
options;
|
|
104
|
+
machine = new JobStateMachine("queued");
|
|
105
|
+
jobId;
|
|
106
|
+
generationId = null;
|
|
107
|
+
completedFiles = new Set();
|
|
108
|
+
storedPointIdsByFile = {};
|
|
109
|
+
manifestHashes = {};
|
|
110
|
+
corpusManifestHashes = null;
|
|
111
|
+
filesPlanned = 0;
|
|
112
|
+
queues = null;
|
|
113
|
+
lastQueueStats = null;
|
|
114
|
+
started = false;
|
|
115
|
+
constructor(options) {
|
|
116
|
+
IndexJobRunner.validateResourceOptions(options);
|
|
117
|
+
this.options = options;
|
|
118
|
+
this.jobId = options.jobId ?? options.random.uuid();
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Prueft die Ressourcen-Optionen am EINGANG des Jobs — dort, wo sie ein
|
|
122
|
+
* Aufrufer setzt, nicht erst tief in der Pipeline.
|
|
123
|
+
*
|
|
124
|
+
* WP-116: `queueCapacities` und `memoryBudgetBytes` sind frei
|
|
125
|
+
* konfigurierbar und entscheiden zusammen darueber, ob die Byte-Grenze der
|
|
126
|
+
* Queues ueberhaupt greifen kann. Die Haertung aus WP-79 sitzt im
|
|
127
|
+
* `BoundedQueue`-Konstruktor und damit erst in `runPipeline()` — ein
|
|
128
|
+
* fehlerhafter Wert faellt dort mitten im Lauf auf, nachdem der Job
|
|
129
|
+
* bereits Lock, Generation und Checkpoints angefasst hat. Hier scheitert
|
|
130
|
+
* er, bevor irgendetwas passiert ist.
|
|
131
|
+
*/
|
|
132
|
+
static validateResourceOptions(options) {
|
|
133
|
+
const capacities = [
|
|
134
|
+
["queueCapacities.analysis", options.queueCapacities?.analysis],
|
|
135
|
+
["queueCapacities.upsert", options.queueCapacities?.upsert],
|
|
136
|
+
["embedBatchSize", options.embedBatchSize],
|
|
137
|
+
["lockLeaseMs", options.lockLeaseMs],
|
|
138
|
+
];
|
|
139
|
+
for (const [field, value] of capacities) {
|
|
140
|
+
if (value === undefined) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const minimum = field === "lockLeaseMs" ? MIN_LOCK_LEASE_MS : 1;
|
|
144
|
+
if (!Number.isSafeInteger(value) || value < minimum) {
|
|
145
|
+
throw configInvalidError(`IndexJobRunner ${field} must be a safe integer >= ${String(minimum)}, got ${String(value)}`, {
|
|
146
|
+
retryable: false,
|
|
147
|
+
operation: "index_job_options",
|
|
148
|
+
context: { field, value, minimum },
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const budget = options.memoryBudgetBytes;
|
|
153
|
+
if (budget === undefined) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (!Number.isSafeInteger(budget) || budget < MIN_MEMORY_BUDGET_BYTES) {
|
|
157
|
+
throw configInvalidError(`IndexJobRunner memoryBudgetBytes must be a safe integer >= ${String(MIN_MEMORY_BUDGET_BYTES)}, got ${String(budget)} — the budget is split evenly across the two pipeline queues ` +
|
|
158
|
+
"and a non-integer/non-finite budget would disable their byte bound " +
|
|
159
|
+
"(architecture.md §13.2)", {
|
|
160
|
+
retryable: false,
|
|
161
|
+
operation: "index_job_options",
|
|
162
|
+
context: { field: "memoryBudgetBytes", value: budget, minimum: MIN_MEMORY_BUDGET_BYTES },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
status() {
|
|
167
|
+
const queues = this.queues !== null
|
|
168
|
+
? { analysis: this.queues.analysis.stats(), upsert: this.queues.upsert.stats() }
|
|
169
|
+
: this.lastQueueStats;
|
|
170
|
+
return {
|
|
171
|
+
jobId: this.jobId,
|
|
172
|
+
generationId: this.generationId,
|
|
173
|
+
stage: this.machine.stage,
|
|
174
|
+
filesPlanned: this.filesPlanned,
|
|
175
|
+
filesCompleted: this.completedFiles.size,
|
|
176
|
+
memoryBudgetBytes: this.options.memoryBudgetBytes ?? DEFAULT_MEMORY_BUDGET_BYTES,
|
|
177
|
+
queuedBytes: queues === null ? 0 : queues.analysis.bytes + queues.upsert.bytes,
|
|
178
|
+
queues,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Runs the job once. Cold: work starts when iteration starts. The stream
|
|
183
|
+
* always ends with exactly one terminal event — `job_completed`,
|
|
184
|
+
* `job_failed` or `job_cancelled` — and never throws for job-level
|
|
185
|
+
* failures (they ARE the `job_failed` event).
|
|
186
|
+
*/
|
|
187
|
+
run(options = {}) {
|
|
188
|
+
if (this.started) {
|
|
189
|
+
throw new Error("IndexJobRunner.run() may only be called once per runner");
|
|
190
|
+
}
|
|
191
|
+
this.started = true;
|
|
192
|
+
const channel = new AsyncEventChannel();
|
|
193
|
+
const internal = new AbortController();
|
|
194
|
+
const external = options.signal;
|
|
195
|
+
if (external !== undefined) {
|
|
196
|
+
if (external.aborted) {
|
|
197
|
+
internal.abort();
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
external.addEventListener("abort", () => internal.abort(), {
|
|
201
|
+
once: true,
|
|
202
|
+
signal: internal.signal,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
let driver = null;
|
|
207
|
+
const start = () => this.drive(channel, external, internal).then(() => channel.close(), (error) => channel.fail(error));
|
|
208
|
+
return {
|
|
209
|
+
[Symbol.asyncIterator]() {
|
|
210
|
+
driver ??= start();
|
|
211
|
+
const iterator = channel[Symbol.asyncIterator]();
|
|
212
|
+
const startedDriver = driver;
|
|
213
|
+
return {
|
|
214
|
+
next: () => iterator.next(),
|
|
215
|
+
async return(value) {
|
|
216
|
+
// Consumer abandoned the stream: stop the pipeline, await the
|
|
217
|
+
// driver so no orphaned worker keeps running.
|
|
218
|
+
internal.abort();
|
|
219
|
+
await startedDriver.catch(() => undefined);
|
|
220
|
+
return { done: true, value: value };
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
// -- driver ---------------------------------------------------------------
|
|
227
|
+
async drive(channel, external, internal) {
|
|
228
|
+
const { clock, stages, identity } = this.options;
|
|
229
|
+
const startedAtMs = clock.nowEpochMs();
|
|
230
|
+
const signal = internal.signal;
|
|
231
|
+
const emit = (event) => channel.emit(event);
|
|
232
|
+
const base = () => ({
|
|
233
|
+
jobId: this.jobId,
|
|
234
|
+
workspaceId: identity.workspaceId,
|
|
235
|
+
occurredAt: clock.nowIso(),
|
|
236
|
+
...(this.generationId !== null ? { generationId: this.generationId } : {}),
|
|
237
|
+
});
|
|
238
|
+
let lock = null;
|
|
239
|
+
let lockHeartbeat = null;
|
|
240
|
+
const notifyGenerationFinished = async (outcome) => {
|
|
241
|
+
if (this.generationId === null || this.options.onGenerationFinished === undefined)
|
|
242
|
+
return;
|
|
243
|
+
try {
|
|
244
|
+
const corpusRevisionFingerprint = outcome === "completed" && this.corpusManifestHashes !== null
|
|
245
|
+
? buildCorpusRevisionFingerprint(this.corpusManifestHashes)
|
|
246
|
+
: undefined;
|
|
247
|
+
await this.options.onGenerationFinished(this.generationId, outcome, corpusRevisionFingerprint);
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
// Terminal state publication is an adapter concern. The index outcome
|
|
251
|
+
// itself is already decided, and the preceding running marker remains
|
|
252
|
+
// conservative if this observer fails.
|
|
253
|
+
this.options.logger?.log({
|
|
254
|
+
timestamp: clock.nowIso(),
|
|
255
|
+
level: "warn",
|
|
256
|
+
component: "jobs.index",
|
|
257
|
+
event: "generation_terminal_observer_failed",
|
|
258
|
+
jobId: this.jobId,
|
|
259
|
+
context: {
|
|
260
|
+
generationId: this.generationId,
|
|
261
|
+
outcome,
|
|
262
|
+
message: errorMessage(error),
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
try {
|
|
268
|
+
emit({ kind: "job_queued", operation: "index", ...base() });
|
|
269
|
+
if (this.options.lockProvider !== undefined) {
|
|
270
|
+
lock = await this.options.lockProvider.acquire(`repo:${identity.repoId}:${identity.refId}:index`, { signal, leaseMs: this.options.lockLeaseMs ?? DEFAULT_LOCK_LEASE_MS });
|
|
271
|
+
lockHeartbeat = startIndexLockHeartbeat(lock, this.options.lockLeaseMs ?? DEFAULT_LOCK_LEASE_MS, internal, (error) => {
|
|
272
|
+
this.options.logger?.log({
|
|
273
|
+
timestamp: clock.nowIso(),
|
|
274
|
+
level: "error",
|
|
275
|
+
component: "jobs.index",
|
|
276
|
+
event: "index_lock_renew_failed",
|
|
277
|
+
jobId: this.jobId,
|
|
278
|
+
context: { key: lock?.key ?? "", message: error.message },
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
throwIfAborted(external, "index_job");
|
|
283
|
+
this.machine.transition("preparing_infrastructure");
|
|
284
|
+
const infrastructure = await stages.infrastructure.ensureReady({ signal });
|
|
285
|
+
emit({ kind: "infrastructure_ready", endpoints: infrastructure.endpoints, ...base() });
|
|
286
|
+
throwIfAborted(external, "index_job");
|
|
287
|
+
this.machine.transition("discovering_files");
|
|
288
|
+
const discovery = await stages.discovery.discover({ signal });
|
|
289
|
+
this.corpusManifestHashes = { ...discovery.corpusManifestHashes };
|
|
290
|
+
const checkpoint = this.options.resume === false
|
|
291
|
+
? null
|
|
292
|
+
: await this.options.checkpointStore.loadLatest(identity.repoId, identity.refId, {
|
|
293
|
+
signal,
|
|
294
|
+
});
|
|
295
|
+
const plan = planResume(discovery.planned, checkpoint, identity);
|
|
296
|
+
this.generationId =
|
|
297
|
+
plan.resumed && plan.generationId !== null
|
|
298
|
+
? plan.generationId
|
|
299
|
+
: (this.options.generationId ?? this.options.random.uuid());
|
|
300
|
+
await this.options.onGenerationStarted?.(this.generationId);
|
|
301
|
+
for (const fileId of plan.completedFiles) {
|
|
302
|
+
this.completedFiles.add(fileId);
|
|
303
|
+
}
|
|
304
|
+
this.storedPointIdsByFile = { ...plan.storedPointIdsByFile };
|
|
305
|
+
this.manifestHashes = { ...plan.manifestHashes };
|
|
306
|
+
this.filesPlanned = discovery.planned.length;
|
|
307
|
+
emit({
|
|
308
|
+
kind: "files_discovered",
|
|
309
|
+
repoId: identity.repoId,
|
|
310
|
+
refId: identity.refId,
|
|
311
|
+
totalFiles: discovery.totalFiles,
|
|
312
|
+
changedFiles: plan.remaining.length,
|
|
313
|
+
unchangedFiles: discovery.unchangedFiles + plan.skippedCompleted,
|
|
314
|
+
ignoredFiles: discovery.ignoredFiles,
|
|
315
|
+
...base(),
|
|
316
|
+
});
|
|
317
|
+
throwIfAborted(external, "index_job");
|
|
318
|
+
this.machine.transition("parsing_and_chunking");
|
|
319
|
+
await this.runPipeline(plan.remaining, emit, base, signal);
|
|
320
|
+
throwIfAborted(external, "index_job");
|
|
321
|
+
this.machine.advanceTo("building_graphs");
|
|
322
|
+
const graphResults = await stages.graphs.buildGraphs({ signal });
|
|
323
|
+
for (const result of graphResults) {
|
|
324
|
+
emit({
|
|
325
|
+
kind: "graph_updated",
|
|
326
|
+
graph: result.graph,
|
|
327
|
+
nodeCount: result.nodeCount,
|
|
328
|
+
edgeCount: result.edgeCount,
|
|
329
|
+
...base(),
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
throwIfAborted(external, "index_job");
|
|
333
|
+
this.machine.advanceTo("indexing_context");
|
|
334
|
+
if (stages.context !== undefined) {
|
|
335
|
+
await stages.context.indexContext({ signal });
|
|
336
|
+
}
|
|
337
|
+
throwIfAborted(external, "index_job");
|
|
338
|
+
this.machine.advanceTo("finalizing");
|
|
339
|
+
await this.saveCheckpoint({ signal });
|
|
340
|
+
emit({
|
|
341
|
+
kind: "checkpoint_saved",
|
|
342
|
+
stage: this.machine.stage,
|
|
343
|
+
completedFileCount: this.completedFiles.size,
|
|
344
|
+
...base(),
|
|
345
|
+
});
|
|
346
|
+
// Generation commit (architecture.md §13.4). Pre-check everything the
|
|
347
|
+
// runner can prove BEFORE asking for the metadata commit, so an
|
|
348
|
+
// incomplete generation is never even submitted for finalization.
|
|
349
|
+
const plannedFileIds = discovery.planned.map((file) => file.fileId);
|
|
350
|
+
const confirmedFileIds = [...this.completedFiles].sort();
|
|
351
|
+
const storedGraphs = graphResults.map((result) => result.graph);
|
|
352
|
+
const requiredGraphs = this.options.requiredGraphs ?? ["file", "symbol"];
|
|
353
|
+
const preCheck = validateGenerationCompleteness({
|
|
354
|
+
generationId: this.generationId,
|
|
355
|
+
plannedFileIds,
|
|
356
|
+
confirmedFileIds,
|
|
357
|
+
checkpointsSaved: true,
|
|
358
|
+
requiredGraphs,
|
|
359
|
+
storedGraphs,
|
|
360
|
+
metadataStatus: "completed",
|
|
361
|
+
});
|
|
362
|
+
if (!preCheck.complete) {
|
|
363
|
+
throw indexIncompleteError(`generation "${this.generationId}" cannot be committed: ${preCheck.reasons.join("; ")}`, {
|
|
364
|
+
retryable: false,
|
|
365
|
+
operation: "generation_commit",
|
|
366
|
+
context: {
|
|
367
|
+
generationId: this.generationId,
|
|
368
|
+
missingFileIds: preCheck.missingFileIds,
|
|
369
|
+
missingGraphs: preCheck.missingGraphs,
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
const commitResult = await stages.commit.commit({
|
|
374
|
+
jobId: this.jobId,
|
|
375
|
+
generationId: this.generationId,
|
|
376
|
+
workspaceId: identity.workspaceId,
|
|
377
|
+
repoId: identity.repoId,
|
|
378
|
+
refId: identity.refId,
|
|
379
|
+
effectiveProfileId: identity.effectiveProfileId,
|
|
380
|
+
plannedFileIds,
|
|
381
|
+
confirmedFileIds,
|
|
382
|
+
storedGraphs,
|
|
383
|
+
}, { signal });
|
|
384
|
+
// Full §13.4 gate including the FINAL metadata status the commit
|
|
385
|
+
// stage actually reached — `completed` is only reported after this.
|
|
386
|
+
assertGenerationComplete({
|
|
387
|
+
generationId: this.generationId,
|
|
388
|
+
plannedFileIds,
|
|
389
|
+
confirmedFileIds,
|
|
390
|
+
checkpointsSaved: true,
|
|
391
|
+
requiredGraphs,
|
|
392
|
+
storedGraphs,
|
|
393
|
+
metadataStatus: commitResult.metadataStatus,
|
|
394
|
+
});
|
|
395
|
+
// Stop the heartbeat and perform one last owner-checked renewal before
|
|
396
|
+
// publishing anything terminal. If another process took the lease, this
|
|
397
|
+
// throws LOCK_HELD and the stale runner never calls the observer.
|
|
398
|
+
await lockHeartbeat?.assertOwnership();
|
|
399
|
+
await notifyGenerationFinished("completed");
|
|
400
|
+
this.machine.transition("completed");
|
|
401
|
+
emit({
|
|
402
|
+
kind: "job_completed",
|
|
403
|
+
durationMs: clock.nowEpochMs() - startedAtMs,
|
|
404
|
+
...base(),
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
let effectiveError = lockHeartbeat?.failure ?? error;
|
|
409
|
+
const externallyCancelled = (lockHeartbeat === null || lockHeartbeat.failure === null) &&
|
|
410
|
+
external?.aborted === true &&
|
|
411
|
+
isJobCancelledError(error);
|
|
412
|
+
if (externallyCancelled) {
|
|
413
|
+
const checkpointSaved = await this.prepareCancelled(emit, base);
|
|
414
|
+
try {
|
|
415
|
+
await lockHeartbeat?.assertOwnership();
|
|
416
|
+
}
|
|
417
|
+
catch (ownershipError) {
|
|
418
|
+
// Cancellation cleanup can outlive a lease. Losing it changes the
|
|
419
|
+
// truthful terminal outcome from cancelled to a lock failure.
|
|
420
|
+
effectiveError = ownershipError;
|
|
421
|
+
this.finishFailed(effectiveError, emit, base);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
await notifyGenerationFinished("cancelled");
|
|
425
|
+
this.emitCancelled(checkpointSaved, emit, base);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
let ownsLock = true;
|
|
429
|
+
try {
|
|
430
|
+
await lockHeartbeat?.assertOwnership();
|
|
431
|
+
}
|
|
432
|
+
catch (ownershipError) {
|
|
433
|
+
effectiveError = ownershipError;
|
|
434
|
+
ownsLock = false;
|
|
435
|
+
}
|
|
436
|
+
if (ownsLock)
|
|
437
|
+
await notifyGenerationFinished("failed");
|
|
438
|
+
this.finishFailed(effectiveError, emit, base);
|
|
439
|
+
}
|
|
440
|
+
finally {
|
|
441
|
+
await lockHeartbeat?.stop();
|
|
442
|
+
internal.abort();
|
|
443
|
+
if (lock !== null) {
|
|
444
|
+
try {
|
|
445
|
+
await lock.release();
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
// Releasing a lock after the job already reached a terminal state
|
|
449
|
+
// must never mask the job's own outcome.
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
/** Cancellation path part one (architecture.md §21.3): save the checkpoint. */
|
|
455
|
+
async prepareCancelled(emit, base) {
|
|
456
|
+
this.machine.transition("cancel_requested");
|
|
457
|
+
let checkpointSaved = false;
|
|
458
|
+
if (this.generationId !== null) {
|
|
459
|
+
try {
|
|
460
|
+
// Only CONFIRMED work is in the runner state, so this checkpoint is
|
|
461
|
+
// valid by construction; per-batch checkpoints were already saved.
|
|
462
|
+
await this.saveCheckpoint({});
|
|
463
|
+
checkpointSaved = true;
|
|
464
|
+
emit({
|
|
465
|
+
kind: "checkpoint_saved",
|
|
466
|
+
stage: this.machine.stage,
|
|
467
|
+
completedFileCount: this.completedFiles.size,
|
|
468
|
+
...base(),
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
checkpointSaved = false;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return checkpointSaved;
|
|
476
|
+
}
|
|
477
|
+
/** Cancellation path part two: emit only after terminal lock ownership was proven. */
|
|
478
|
+
emitCancelled(checkpointSaved, emit, base) {
|
|
479
|
+
emit({ kind: "job_cancelled", checkpointSaved, ...base() });
|
|
480
|
+
this.machine.transition("cancelled");
|
|
481
|
+
}
|
|
482
|
+
finishFailed(error, emit, base) {
|
|
483
|
+
const failure = isUntangleError(error)
|
|
484
|
+
? error
|
|
485
|
+
: new UntangleError(
|
|
486
|
+
// A stage that throws something untyped still leaves the index
|
|
487
|
+
// incomplete — that is the truthful classification. Never guess a
|
|
488
|
+
// more specific infrastructure code (spec.md §25.2).
|
|
489
|
+
"INDEX_INCOMPLETE", error instanceof Error ? error.message : String(error), { retryable: false, operation: "index_job", cause: error });
|
|
490
|
+
this.machine.transition("failed");
|
|
491
|
+
emit({
|
|
492
|
+
kind: "job_failed",
|
|
493
|
+
errorCode: failure.code,
|
|
494
|
+
message: failure.message,
|
|
495
|
+
retryable: failure.retryable,
|
|
496
|
+
...base(),
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Meldet eine Budgetverletzung der Queues. Eine Ueberschreitung des
|
|
501
|
+
* Speicherbudgets darf nie stillschweigend passieren (E4) — auch dann
|
|
502
|
+
* nicht, wenn sie unvermeidbar ist, weil ein einzelnes Element groesser
|
|
503
|
+
* als das ganze Budget ist.
|
|
504
|
+
*/
|
|
505
|
+
reportOverBudget(queue, admission) {
|
|
506
|
+
this.options.logger?.log({
|
|
507
|
+
timestamp: this.options.clock.nowIso(),
|
|
508
|
+
level: "warn",
|
|
509
|
+
component: "jobs.index",
|
|
510
|
+
event: "queue_over_budget_admission",
|
|
511
|
+
jobId: this.jobId,
|
|
512
|
+
context: {
|
|
513
|
+
queue,
|
|
514
|
+
itemBytes: admission.itemBytes,
|
|
515
|
+
maxBytes: admission.maxBytes,
|
|
516
|
+
ordinal: admission.ordinal,
|
|
517
|
+
allowance: admission.allowance,
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
// -- pipeline -------------------------------------------------------------
|
|
522
|
+
async runPipeline(remaining, emit, base, signal) {
|
|
523
|
+
if (remaining.length === 0) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const { stages, identity } = this.options;
|
|
527
|
+
const memoryBudgetBytes = this.options.memoryBudgetBytes ?? DEFAULT_MEMORY_BUDGET_BYTES;
|
|
528
|
+
const embedBatchSize = this.options.embedBatchSize ?? DEFAULT_EMBED_BATCH_SIZE;
|
|
529
|
+
const analysisQueue = new BoundedQueue({
|
|
530
|
+
capacity: this.options.queueCapacities?.analysis ?? DEFAULT_ANALYSIS_CAPACITY,
|
|
531
|
+
maxBytes: Math.floor(memoryBudgetBytes / 2),
|
|
532
|
+
sizeOf: (item) => item.estimatedBytes,
|
|
533
|
+
onOverBudgetAdmission: (admission) => this.reportOverBudget("analysis", admission),
|
|
534
|
+
});
|
|
535
|
+
const upsertQueue = new BoundedQueue({
|
|
536
|
+
capacity: this.options.queueCapacities?.upsert ?? DEFAULT_UPSERT_CAPACITY,
|
|
537
|
+
maxBytes: Math.floor(memoryBudgetBytes / 2),
|
|
538
|
+
sizeOf: (item) => item.estimatedBytes,
|
|
539
|
+
onOverBudgetAdmission: (admission) => this.reportOverBudget("upsert", admission),
|
|
540
|
+
});
|
|
541
|
+
this.queues = { analysis: analysisQueue, upsert: upsertQueue };
|
|
542
|
+
const failAll = (error) => {
|
|
543
|
+
analysisQueue.fail(error);
|
|
544
|
+
upsertQueue.fail(error);
|
|
545
|
+
};
|
|
546
|
+
const analysisWorker = async () => {
|
|
547
|
+
for (const file of remaining) {
|
|
548
|
+
// Safe batch boundary: between files (architecture.md §21.3).
|
|
549
|
+
throwIfAborted(signal, "index_analysis");
|
|
550
|
+
const analyzed = await stages.analysis.analyze(file, { signal });
|
|
551
|
+
this.machine.advanceTo("computing_metrics");
|
|
552
|
+
emit({
|
|
553
|
+
kind: "file_analyzed",
|
|
554
|
+
repoId: identity.repoId,
|
|
555
|
+
refId: identity.refId,
|
|
556
|
+
fileId: file.fileId,
|
|
557
|
+
filePath: file.filePath,
|
|
558
|
+
parseQuality: analyzed.parseQuality,
|
|
559
|
+
chunkCount: analyzed.chunkCount,
|
|
560
|
+
symbolCount: analyzed.symbolCount,
|
|
561
|
+
...base(),
|
|
562
|
+
});
|
|
563
|
+
await analysisQueue.push(analyzed, { signal });
|
|
564
|
+
}
|
|
565
|
+
analysisQueue.close();
|
|
566
|
+
};
|
|
567
|
+
const embedWorker = async () => {
|
|
568
|
+
let batchIndex = 0;
|
|
569
|
+
while (true) {
|
|
570
|
+
const first = await analysisQueue.pull({ signal });
|
|
571
|
+
if (first.done) {
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
const batch = [first.value];
|
|
575
|
+
while (batch.length < embedBatchSize) {
|
|
576
|
+
const next = analysisQueue.tryPull();
|
|
577
|
+
if (next === null || next.done) {
|
|
578
|
+
break;
|
|
579
|
+
}
|
|
580
|
+
batch.push(next.value);
|
|
581
|
+
}
|
|
582
|
+
// Safe batch boundary: before embedding the next batch.
|
|
583
|
+
throwIfAborted(signal, "index_embedding");
|
|
584
|
+
this.machine.advanceTo("embedding");
|
|
585
|
+
const embedded = await stages.embedding.embedBatch(batch, { signal });
|
|
586
|
+
emit({
|
|
587
|
+
kind: "embedding_batch_completed",
|
|
588
|
+
batchIndex,
|
|
589
|
+
batchSize: batch.length,
|
|
590
|
+
cacheHits: embedded.cacheHits,
|
|
591
|
+
truncatedCount: embedded.truncatedCount,
|
|
592
|
+
...base(),
|
|
593
|
+
});
|
|
594
|
+
await upsertQueue.push(embedded, { signal });
|
|
595
|
+
batchIndex += 1;
|
|
596
|
+
}
|
|
597
|
+
upsertQueue.close();
|
|
598
|
+
};
|
|
599
|
+
const storeWorker = async () => {
|
|
600
|
+
while (true) {
|
|
601
|
+
const item = await upsertQueue.pull({ signal });
|
|
602
|
+
if (item.done) {
|
|
603
|
+
break;
|
|
604
|
+
}
|
|
605
|
+
// Safe batch boundary: before starting the next upsert.
|
|
606
|
+
throwIfAborted(signal, "index_storing");
|
|
607
|
+
this.machine.advanceTo("storing");
|
|
608
|
+
const confirmation = await stages.storage.upsertBatch(item.value, { signal });
|
|
609
|
+
emit({
|
|
610
|
+
kind: "points_stored",
|
|
611
|
+
collection: confirmation.collection,
|
|
612
|
+
pointCount: confirmation.pointCount,
|
|
613
|
+
serializedBytes: confirmation.serializedBytes,
|
|
614
|
+
...base(),
|
|
615
|
+
});
|
|
616
|
+
// Record confirmed work ONLY after the store acknowledged the
|
|
617
|
+
// upsert, and persist the checkpoint ONLY after that (spec.md §10.5).
|
|
618
|
+
for (const file of item.value.files) {
|
|
619
|
+
this.completedFiles.add(file.fileId);
|
|
620
|
+
this.manifestHashes[file.fileId] = file.contentHash;
|
|
621
|
+
}
|
|
622
|
+
for (const [fileId, pointIds] of Object.entries(confirmation.storedPointIdsByFile)) {
|
|
623
|
+
this.storedPointIdsByFile[fileId] = [...pointIds];
|
|
624
|
+
}
|
|
625
|
+
this.machine.advanceTo("checkpointing");
|
|
626
|
+
await this.saveCheckpoint({ signal });
|
|
627
|
+
emit({
|
|
628
|
+
kind: "checkpoint_saved",
|
|
629
|
+
stage: this.machine.stage,
|
|
630
|
+
completedFileCount: this.completedFiles.size,
|
|
631
|
+
...base(),
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
const guard = async (worker) => {
|
|
636
|
+
try {
|
|
637
|
+
await worker();
|
|
638
|
+
}
|
|
639
|
+
catch (error) {
|
|
640
|
+
failAll(error);
|
|
641
|
+
throw error;
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
const settled = await Promise.allSettled([
|
|
645
|
+
guard(analysisWorker),
|
|
646
|
+
guard(embedWorker),
|
|
647
|
+
guard(storeWorker),
|
|
648
|
+
]);
|
|
649
|
+
this.lastQueueStats = { analysis: analysisQueue.stats(), upsert: upsertQueue.stats() };
|
|
650
|
+
this.queues = null;
|
|
651
|
+
const rejections = settled
|
|
652
|
+
.filter((result) => result.status === "rejected")
|
|
653
|
+
.map((result) => result.reason);
|
|
654
|
+
const firstRealFailure = rejections.find((error) => !isJobCancelledError(error));
|
|
655
|
+
if (firstRealFailure !== undefined) {
|
|
656
|
+
throw firstRealFailure;
|
|
657
|
+
}
|
|
658
|
+
const firstCancellation = rejections[0];
|
|
659
|
+
if (firstCancellation !== undefined) {
|
|
660
|
+
throw firstCancellation;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
async saveCheckpoint(options) {
|
|
664
|
+
if (this.generationId === null) {
|
|
665
|
+
throw indexIncompleteError("cannot save a checkpoint before the generation is known", {
|
|
666
|
+
retryable: false,
|
|
667
|
+
operation: "checkpoint_save",
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
const checkpoint = {
|
|
671
|
+
jobId: this.jobId,
|
|
672
|
+
generationId: this.generationId,
|
|
673
|
+
workspaceId: this.options.identity.workspaceId,
|
|
674
|
+
repoId: this.options.identity.repoId,
|
|
675
|
+
refId: this.options.identity.refId,
|
|
676
|
+
effectiveProfileId: this.options.identity.effectiveProfileId,
|
|
677
|
+
completedFiles: [...this.completedFiles].sort(),
|
|
678
|
+
storedPointIdsByFile: Object.fromEntries(Object.entries(this.storedPointIdsByFile).map(([fileId, ids]) => [fileId, [...ids]])),
|
|
679
|
+
manifestHashes: { ...this.manifestHashes },
|
|
680
|
+
stage: this.machine.stage,
|
|
681
|
+
savedAt: this.options.clock.nowIso(),
|
|
682
|
+
};
|
|
683
|
+
await this.options.checkpointStore.save(checkpoint, options.signal !== undefined ? { signal: options.signal } : undefined);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
//# sourceMappingURL=runner.js.map
|