@untangle-code/jobs 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +90 -0
  3. package/README.md +138 -2
  4. package/THIRD_PARTY_LICENSES.md +24 -0
  5. package/dist/abort.d.ts +14 -0
  6. package/dist/abort.d.ts.map +1 -0
  7. package/dist/abort.js +35 -0
  8. package/dist/abort.js.map +1 -0
  9. package/dist/bounded-queue.d.ts +200 -0
  10. package/dist/bounded-queue.d.ts.map +1 -0
  11. package/dist/bounded-queue.js +338 -0
  12. package/dist/bounded-queue.js.map +1 -0
  13. package/dist/checkpoint-store.d.ts +30 -0
  14. package/dist/checkpoint-store.d.ts.map +1 -0
  15. package/dist/checkpoint-store.js +131 -0
  16. package/dist/checkpoint-store.js.map +1 -0
  17. package/dist/events.d.ts +29 -0
  18. package/dist/events.d.ts.map +1 -0
  19. package/dist/events.js +66 -0
  20. package/dist/events.js.map +1 -0
  21. package/dist/index.d.ts +22 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +30 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/resume.d.ts +45 -0
  26. package/dist/resume.d.ts.map +1 -0
  27. package/dist/resume.js +60 -0
  28. package/dist/resume.js.map +1 -0
  29. package/dist/runner.d.ts +148 -0
  30. package/dist/runner.d.ts.map +1 -0
  31. package/dist/runner.js +686 -0
  32. package/dist/runner.js.map +1 -0
  33. package/dist/stages.d.ts +146 -0
  34. package/dist/stages.d.ts.map +1 -0
  35. package/dist/stages.js +2 -0
  36. package/dist/stages.js.map +1 -0
  37. package/dist/state-machine.d.ts +52 -0
  38. package/dist/state-machine.d.ts.map +1 -0
  39. package/dist/state-machine.js +114 -0
  40. package/dist/state-machine.js.map +1 -0
  41. package/package.json +61 -3
  42. package/src/abort.ts +37 -0
  43. package/src/bounded-queue.ts +462 -0
  44. package/src/checkpoint-store.ts +171 -0
  45. package/src/events.ts +71 -0
  46. package/src/index.ts +37 -0
  47. package/src/resume.ts +106 -0
  48. package/src/runner.ts +916 -0
  49. package/src/stages.ts +167 -0
  50. package/src/state-machine.ts +132 -0
package/src/runner.ts ADDED
@@ -0,0 +1,916 @@
1
+ /**
2
+ * Index job runner — the pipeline MECHANICS of spec.md §11 and
3
+ * architecture.md §13:
4
+ *
5
+ * - drives the exact state machine of spec.md §11.1 (`JobStateMachine`);
6
+ * - emits the typed job events of architecture.md §2.2 as an
7
+ * `AsyncIterable<JobEvent>` (spec.md §22);
8
+ * - connects the injected stages with bounded queues so a full queue pauses
9
+ * the previous stage (backpressure, §13.2) under a configurable, visible
10
+ * memory budget;
11
+ * - persists an `IndexCheckpointV1` ONLY after a confirmed upsert
12
+ * (spec.md §10.5, §13.3), one per file batch (spec.md §11.2);
13
+ * - resumes from the last confirmed checkpoint without repeating confirmed
14
+ * work (spec.md §11.2);
15
+ * - cancels via `AbortSignal` at safe batch boundaries only: checkpoint
16
+ * saved, then `cancelled` (architecture.md §21.3);
17
+ * - commits the generation per §13.4 — `completed` is only reported when
18
+ * all planned file operations are confirmed, checkpoints are saved, the
19
+ * required graphs are stored and the metadata reached its final status
20
+ * (enforced through domain's `assertGenerationComplete`).
21
+ */
22
+ import type {
23
+ CheckpointStorePort,
24
+ Clock,
25
+ IndexCheckpointV1,
26
+ IndexJobStage,
27
+ JobEvent,
28
+ LockHandle,
29
+ LockProvider,
30
+ LoggerPort,
31
+ Random,
32
+ } from "@untangle-code/domain";
33
+ import {
34
+ UntangleError,
35
+ assertGenerationComplete,
36
+ buildCorpusRevisionFingerprint,
37
+ isUntangleError,
38
+ validateGenerationCompleteness,
39
+ } from "@untangle-code/domain";
40
+ import { configInvalidError, indexIncompleteError } from "@untangle-code/observability";
41
+
42
+ import { isJobCancelledError, throwIfAborted } from "./abort.js";
43
+ import { BoundedQueue, type OverBudgetAdmissionV1, type QueueStatsV1 } from "./bounded-queue.js";
44
+ import { AsyncEventChannel } from "./events.js";
45
+ import type { ResumeIdentityV1 } from "./resume.js";
46
+ import { planResume } from "./resume.js";
47
+ import type { AnalyzedFileV1, EmbeddedBatchV1, IndexJobStages, PlannedFileV1 } from "./stages.js";
48
+ import { JobStateMachine } from "./state-machine.js";
49
+
50
+ /** Identity of the ref this job indexes. */
51
+ export type IndexJobIdentityV1 = ResumeIdentityV1;
52
+
53
+ export interface IndexJobQueueCapacities {
54
+ /** Analyzed-file queue capacity (items). Default 16. */
55
+ analysis?: number;
56
+ /** Embedded-batch queue capacity (items). Default 4. */
57
+ upsert?: number;
58
+ }
59
+
60
+ export interface IndexJobRunnerOptions {
61
+ identity: IndexJobIdentityV1;
62
+ stages: IndexJobStages;
63
+ checkpointStore: CheckpointStorePort;
64
+ clock: Clock;
65
+ random: Random;
66
+ logger?: LoggerPort;
67
+ /** When given, the job holds `repo:<repoId>:<refId>:index` while running. */
68
+ lockProvider?: LockProvider;
69
+ /**
70
+ * Duration of the index-lock lease. While the job is active, the runner
71
+ * renews it with a non-overlapping heartbeat at most one third this long.
72
+ * Default 30 seconds; must be a safe integer >= 3 milliseconds.
73
+ */
74
+ lockLeaseMs?: number;
75
+ /** Job id; defaults to `random.uuid()`. */
76
+ jobId?: string;
77
+ /** Generation id for a FRESH run; a resumed run keeps the checkpoint's. */
78
+ generationId?: string;
79
+ /**
80
+ * Awaited immediately after the generation is chosen and before any
81
+ * analysis, graph, checkpoint, or index mutation starts. Adapters use this
82
+ * boundary to persist a durable `running` marker; rejection fails the job
83
+ * before the pipeline can mutate the index.
84
+ */
85
+ onGenerationStarted?: (generationId: string) => Promise<void>;
86
+ /**
87
+ * Runs for a terminal outcome only after a final owner-checked lease renewal
88
+ * and before release. A detected ownership loss suppresses it. Adapters should serialize and
89
+ * generation-CAS any repo-wide singleton state they replace. Completed
90
+ * outcomes include an immutable fingerprint of the full discovered corpus;
91
+ * terminal failures deliberately do not.
92
+ */
93
+ onGenerationFinished?: (
94
+ generationId: string,
95
+ outcome: "completed" | "cancelled" | "failed",
96
+ corpusRevisionFingerprint?: string,
97
+ ) => Promise<void>;
98
+ /** Graphs the generation must store to complete. Default ["file","symbol"]. */
99
+ requiredGraphs?: readonly ("file" | "symbol")[];
100
+ /** Files per embedding batch (also the checkpoint batch). Default 8. */
101
+ embedBatchSize?: number;
102
+ queueCapacities?: IndexJobQueueCapacities;
103
+ /**
104
+ * Byte budget shared by the pipeline queues (architecture.md §13.2),
105
+ * split evenly between them; visible in `status()`. Default 64 MiB.
106
+ */
107
+ memoryBudgetBytes?: number;
108
+ /** Set false to ignore existing checkpoints and start fresh. Default true. */
109
+ resume?: boolean;
110
+ }
111
+
112
+ /** Live job status (architecture.md §13.2: budget visible in status output). */
113
+ export interface JobStatusV1 {
114
+ jobId: string;
115
+ generationId: string | null;
116
+ stage: IndexJobStage;
117
+ filesPlanned: number;
118
+ filesCompleted: number;
119
+ memoryBudgetBytes: number;
120
+ queuedBytes: number;
121
+ queues: { analysis: QueueStatsV1; upsert: QueueStatsV1 } | null;
122
+ }
123
+
124
+ const DEFAULT_MEMORY_BUDGET_BYTES = 64 * 1024 * 1024;
125
+ const DEFAULT_EMBED_BATCH_SIZE = 8;
126
+ const DEFAULT_ANALYSIS_CAPACITY = 16;
127
+ const DEFAULT_UPSERT_CAPACITY = 4;
128
+ const DEFAULT_LOCK_LEASE_MS = 30_000;
129
+ const MIN_LOCK_LEASE_MS = 3;
130
+ /**
131
+ * Kleinstes zulaessiges Gesamtbudget. Das Budget wird haelftig auf beide
132
+ * Queues verteilt (`Math.floor(budget / 2)`), also muss es mindestens 2
133
+ * Bytes betragen, damit beide Haelften positiv sind. Bewusst KEINE hoehere,
134
+ * gegriffene Schwelle: ein knappes Budget ist eine legitime Testkonfiguration
135
+ * — dass es die Byte-Grenze nicht mehr abschaltet, sichert seit WP-116 die
136
+ * `BoundedQueue` selbst, nicht eine Mindestzahl hier.
137
+ */
138
+ const MIN_MEMORY_BUDGET_BYTES = 2;
139
+
140
+ interface PipelineQueues {
141
+ analysis: BoundedQueue<AnalyzedFileV1>;
142
+ upsert: BoundedQueue<EmbeddedBatchV1>;
143
+ }
144
+
145
+ interface IndexLockHeartbeat {
146
+ readonly failure: UntangleError | null;
147
+ /** Stops periodic renewal and waits for an already-running renewal. */
148
+ stop(): Promise<void>;
149
+ /** Stops the heartbeat, then proves ownership once more for terminal publication. */
150
+ assertOwnership(): Promise<void>;
151
+ }
152
+
153
+ function errorMessage(error: unknown): string {
154
+ return error instanceof Error ? error.message : String(error);
155
+ }
156
+
157
+ function lockRenewalError(lock: LockHandle, error: unknown): UntangleError {
158
+ return new UntangleError(
159
+ "LOCK_HELD",
160
+ `Index lock "${lock.key}" could not be renewed; exclusive ownership is no longer proven: ${errorMessage(error)}`,
161
+ {
162
+ retryable: true,
163
+ operation: "index_lock_renew",
164
+ context: {
165
+ key: lock.key,
166
+ processInstanceId: lock.record.processInstanceId,
167
+ },
168
+ cause: error,
169
+ },
170
+ );
171
+ }
172
+
173
+ /**
174
+ * Keeps one lease alive without ever issuing overlapping `renew()` calls.
175
+ * A failed renewal aborts the job immediately; `assertOwnership()` repeats
176
+ * the ownership check after the heartbeat has stopped. A runner whose lease
177
+ * loss was observed does not invoke the terminal-generation observer.
178
+ */
179
+ function startIndexLockHeartbeat(
180
+ lock: LockHandle,
181
+ leaseMs: number,
182
+ internal: AbortController,
183
+ onFailure: (error: UntangleError) => void,
184
+ ): IndexLockHeartbeat {
185
+ const intervalMs = Math.max(1, Math.floor(leaseMs / 3));
186
+ let stopped = false;
187
+ let timer: ReturnType<typeof setTimeout> | null = null;
188
+ let inFlight: Promise<void> | null = null;
189
+ let failure: UntangleError | null = null;
190
+
191
+ const fail = (error: unknown): UntangleError => {
192
+ if (failure === null) {
193
+ failure = lockRenewalError(lock, error);
194
+ onFailure(failure);
195
+ internal.abort();
196
+ }
197
+ return failure;
198
+ };
199
+
200
+ const schedule = (): void => {
201
+ if (stopped || failure !== null) return;
202
+ timer = setTimeout(() => {
203
+ timer = null;
204
+ if (stopped || failure !== null) return;
205
+ inFlight = lock
206
+ .renew()
207
+ .catch((error: unknown) => {
208
+ fail(error);
209
+ })
210
+ .finally(() => {
211
+ inFlight = null;
212
+ schedule();
213
+ });
214
+ }, intervalMs);
215
+ };
216
+
217
+ const stop = async (): Promise<void> => {
218
+ stopped = true;
219
+ if (timer !== null) {
220
+ clearTimeout(timer);
221
+ timer = null;
222
+ }
223
+ await inFlight;
224
+ };
225
+
226
+ schedule();
227
+ return {
228
+ get failure() {
229
+ return failure;
230
+ },
231
+ stop,
232
+ assertOwnership: async (): Promise<void> => {
233
+ await stop();
234
+ if (failure !== null) throw failure;
235
+ try {
236
+ await lock.renew();
237
+ } catch (error) {
238
+ throw fail(error);
239
+ }
240
+ },
241
+ };
242
+ }
243
+
244
+ export class IndexJobRunner {
245
+ private readonly options: IndexJobRunnerOptions;
246
+ private readonly machine = new JobStateMachine("queued");
247
+ private readonly jobId: string;
248
+ private generationId: string | null = null;
249
+ private readonly completedFiles = new Set<string>();
250
+ private storedPointIdsByFile: Record<string, string[]> = {};
251
+ private manifestHashes: Record<string, string> = {};
252
+ private corpusManifestHashes: Record<string, string> | null = null;
253
+ private filesPlanned = 0;
254
+ private queues: PipelineQueues | null = null;
255
+ private lastQueueStats: { analysis: QueueStatsV1; upsert: QueueStatsV1 } | null = null;
256
+ private started = false;
257
+
258
+ public constructor(options: IndexJobRunnerOptions) {
259
+ IndexJobRunner.validateResourceOptions(options);
260
+ this.options = options;
261
+ this.jobId = options.jobId ?? options.random.uuid();
262
+ }
263
+
264
+ /**
265
+ * Prueft die Ressourcen-Optionen am EINGANG des Jobs — dort, wo sie ein
266
+ * Aufrufer setzt, nicht erst tief in der Pipeline.
267
+ *
268
+ * WP-116: `queueCapacities` und `memoryBudgetBytes` sind frei
269
+ * konfigurierbar und entscheiden zusammen darueber, ob die Byte-Grenze der
270
+ * Queues ueberhaupt greifen kann. Die Haertung aus WP-79 sitzt im
271
+ * `BoundedQueue`-Konstruktor und damit erst in `runPipeline()` — ein
272
+ * fehlerhafter Wert faellt dort mitten im Lauf auf, nachdem der Job
273
+ * bereits Lock, Generation und Checkpoints angefasst hat. Hier scheitert
274
+ * er, bevor irgendetwas passiert ist.
275
+ */
276
+ private static validateResourceOptions(options: IndexJobRunnerOptions): void {
277
+ const capacities: readonly (readonly [string, number | undefined])[] = [
278
+ ["queueCapacities.analysis", options.queueCapacities?.analysis],
279
+ ["queueCapacities.upsert", options.queueCapacities?.upsert],
280
+ ["embedBatchSize", options.embedBatchSize],
281
+ ["lockLeaseMs", options.lockLeaseMs],
282
+ ];
283
+ for (const [field, value] of capacities) {
284
+ if (value === undefined) {
285
+ continue;
286
+ }
287
+ const minimum = field === "lockLeaseMs" ? MIN_LOCK_LEASE_MS : 1;
288
+ if (!Number.isSafeInteger(value) || value < minimum) {
289
+ throw configInvalidError(
290
+ `IndexJobRunner ${field} must be a safe integer >= ${String(minimum)}, got ${String(value)}`,
291
+ {
292
+ retryable: false,
293
+ operation: "index_job_options",
294
+ context: { field, value, minimum },
295
+ },
296
+ );
297
+ }
298
+ }
299
+ const budget = options.memoryBudgetBytes;
300
+ if (budget === undefined) {
301
+ return;
302
+ }
303
+ if (!Number.isSafeInteger(budget) || budget < MIN_MEMORY_BUDGET_BYTES) {
304
+ throw configInvalidError(
305
+ `IndexJobRunner memoryBudgetBytes must be a safe integer >= ${String(
306
+ MIN_MEMORY_BUDGET_BYTES,
307
+ )}, got ${String(budget)} — the budget is split evenly across the two pipeline queues ` +
308
+ "and a non-integer/non-finite budget would disable their byte bound " +
309
+ "(architecture.md §13.2)",
310
+ {
311
+ retryable: false,
312
+ operation: "index_job_options",
313
+ context: { field: "memoryBudgetBytes", value: budget, minimum: MIN_MEMORY_BUDGET_BYTES },
314
+ },
315
+ );
316
+ }
317
+ }
318
+
319
+ public status(): JobStatusV1 {
320
+ const queues =
321
+ this.queues !== null
322
+ ? { analysis: this.queues.analysis.stats(), upsert: this.queues.upsert.stats() }
323
+ : this.lastQueueStats;
324
+ return {
325
+ jobId: this.jobId,
326
+ generationId: this.generationId,
327
+ stage: this.machine.stage,
328
+ filesPlanned: this.filesPlanned,
329
+ filesCompleted: this.completedFiles.size,
330
+ memoryBudgetBytes: this.options.memoryBudgetBytes ?? DEFAULT_MEMORY_BUDGET_BYTES,
331
+ queuedBytes: queues === null ? 0 : queues.analysis.bytes + queues.upsert.bytes,
332
+ queues,
333
+ };
334
+ }
335
+
336
+ /**
337
+ * Runs the job once. Cold: work starts when iteration starts. The stream
338
+ * always ends with exactly one terminal event — `job_completed`,
339
+ * `job_failed` or `job_cancelled` — and never throws for job-level
340
+ * failures (they ARE the `job_failed` event).
341
+ */
342
+ public run(options: { signal?: AbortSignal } = {}): AsyncIterable<JobEvent> {
343
+ if (this.started) {
344
+ throw new Error("IndexJobRunner.run() may only be called once per runner");
345
+ }
346
+ this.started = true;
347
+ const channel = new AsyncEventChannel<JobEvent>();
348
+ const internal = new AbortController();
349
+ const external = options.signal;
350
+ if (external !== undefined) {
351
+ if (external.aborted) {
352
+ internal.abort();
353
+ } else {
354
+ external.addEventListener("abort", () => internal.abort(), {
355
+ once: true,
356
+ signal: internal.signal,
357
+ });
358
+ }
359
+ }
360
+
361
+ let driver: Promise<void> | null = null;
362
+ const start = (): Promise<void> =>
363
+ this.drive(channel, external, internal).then(
364
+ () => channel.close(),
365
+ (error) => channel.fail(error),
366
+ );
367
+
368
+ return {
369
+ [Symbol.asyncIterator]() {
370
+ driver ??= start();
371
+ const iterator = channel[Symbol.asyncIterator]();
372
+ const startedDriver = driver;
373
+ return {
374
+ next: () => iterator.next(),
375
+ async return(value?: unknown): Promise<IteratorResult<JobEvent>> {
376
+ // Consumer abandoned the stream: stop the pipeline, await the
377
+ // driver so no orphaned worker keeps running.
378
+ internal.abort();
379
+ await startedDriver.catch(() => undefined);
380
+ return { done: true, value: value as JobEvent };
381
+ },
382
+ };
383
+ },
384
+ };
385
+ }
386
+
387
+ // -- driver ---------------------------------------------------------------
388
+
389
+ private async drive(
390
+ channel: AsyncEventChannel<JobEvent>,
391
+ external: AbortSignal | undefined,
392
+ internal: AbortController,
393
+ ): Promise<void> {
394
+ const { clock, stages, identity } = this.options;
395
+ const startedAtMs = clock.nowEpochMs();
396
+ const signal = internal.signal;
397
+ const emit = (event: JobEvent): void => channel.emit(event);
398
+ const base = (): { jobId: string; workspaceId: string; occurredAt: string } & {
399
+ generationId?: string;
400
+ } => ({
401
+ jobId: this.jobId,
402
+ workspaceId: identity.workspaceId,
403
+ occurredAt: clock.nowIso(),
404
+ ...(this.generationId !== null ? { generationId: this.generationId } : {}),
405
+ });
406
+
407
+ let lock: LockHandle | null = null;
408
+ let lockHeartbeat: IndexLockHeartbeat | null = null;
409
+ const notifyGenerationFinished = async (
410
+ outcome: "completed" | "cancelled" | "failed",
411
+ ): Promise<void> => {
412
+ if (this.generationId === null || this.options.onGenerationFinished === undefined) return;
413
+ try {
414
+ const corpusRevisionFingerprint =
415
+ outcome === "completed" && this.corpusManifestHashes !== null
416
+ ? buildCorpusRevisionFingerprint(this.corpusManifestHashes)
417
+ : undefined;
418
+ await this.options.onGenerationFinished(
419
+ this.generationId,
420
+ outcome,
421
+ corpusRevisionFingerprint,
422
+ );
423
+ } catch (error) {
424
+ // Terminal state publication is an adapter concern. The index outcome
425
+ // itself is already decided, and the preceding running marker remains
426
+ // conservative if this observer fails.
427
+ this.options.logger?.log({
428
+ timestamp: clock.nowIso(),
429
+ level: "warn",
430
+ component: "jobs.index",
431
+ event: "generation_terminal_observer_failed",
432
+ jobId: this.jobId,
433
+ context: {
434
+ generationId: this.generationId,
435
+ outcome,
436
+ message: errorMessage(error),
437
+ },
438
+ });
439
+ }
440
+ };
441
+ try {
442
+ emit({ kind: "job_queued", operation: "index", ...base() });
443
+
444
+ if (this.options.lockProvider !== undefined) {
445
+ lock = await this.options.lockProvider.acquire(
446
+ `repo:${identity.repoId}:${identity.refId}:index`,
447
+ { signal, leaseMs: this.options.lockLeaseMs ?? DEFAULT_LOCK_LEASE_MS },
448
+ );
449
+ lockHeartbeat = startIndexLockHeartbeat(
450
+ lock,
451
+ this.options.lockLeaseMs ?? DEFAULT_LOCK_LEASE_MS,
452
+ internal,
453
+ (error) => {
454
+ this.options.logger?.log({
455
+ timestamp: clock.nowIso(),
456
+ level: "error",
457
+ component: "jobs.index",
458
+ event: "index_lock_renew_failed",
459
+ jobId: this.jobId,
460
+ context: { key: lock?.key ?? "", message: error.message },
461
+ });
462
+ },
463
+ );
464
+ }
465
+
466
+ throwIfAborted(external, "index_job");
467
+ this.machine.transition("preparing_infrastructure");
468
+ const infrastructure = await stages.infrastructure.ensureReady({ signal });
469
+ emit({ kind: "infrastructure_ready", endpoints: infrastructure.endpoints, ...base() });
470
+
471
+ throwIfAborted(external, "index_job");
472
+ this.machine.transition("discovering_files");
473
+ const discovery = await stages.discovery.discover({ signal });
474
+ this.corpusManifestHashes = { ...discovery.corpusManifestHashes };
475
+ const checkpoint =
476
+ this.options.resume === false
477
+ ? null
478
+ : await this.options.checkpointStore.loadLatest(identity.repoId, identity.refId, {
479
+ signal,
480
+ });
481
+ const plan = planResume(discovery.planned, checkpoint, identity);
482
+ this.generationId =
483
+ plan.resumed && plan.generationId !== null
484
+ ? plan.generationId
485
+ : (this.options.generationId ?? this.options.random.uuid());
486
+ await this.options.onGenerationStarted?.(this.generationId);
487
+ for (const fileId of plan.completedFiles) {
488
+ this.completedFiles.add(fileId);
489
+ }
490
+ this.storedPointIdsByFile = { ...plan.storedPointIdsByFile };
491
+ this.manifestHashes = { ...plan.manifestHashes };
492
+ this.filesPlanned = discovery.planned.length;
493
+ emit({
494
+ kind: "files_discovered",
495
+ repoId: identity.repoId,
496
+ refId: identity.refId,
497
+ totalFiles: discovery.totalFiles,
498
+ changedFiles: plan.remaining.length,
499
+ unchangedFiles: discovery.unchangedFiles + plan.skippedCompleted,
500
+ ignoredFiles: discovery.ignoredFiles,
501
+ ...base(),
502
+ });
503
+
504
+ throwIfAborted(external, "index_job");
505
+ this.machine.transition("parsing_and_chunking");
506
+ await this.runPipeline(plan.remaining, emit, base, signal);
507
+
508
+ throwIfAborted(external, "index_job");
509
+ this.machine.advanceTo("building_graphs");
510
+ const graphResults = await stages.graphs.buildGraphs({ signal });
511
+ for (const result of graphResults) {
512
+ emit({
513
+ kind: "graph_updated",
514
+ graph: result.graph,
515
+ nodeCount: result.nodeCount,
516
+ edgeCount: result.edgeCount,
517
+ ...base(),
518
+ });
519
+ }
520
+
521
+ throwIfAborted(external, "index_job");
522
+ this.machine.advanceTo("indexing_context");
523
+ if (stages.context !== undefined) {
524
+ await stages.context.indexContext({ signal });
525
+ }
526
+
527
+ throwIfAborted(external, "index_job");
528
+ this.machine.advanceTo("finalizing");
529
+ await this.saveCheckpoint({ signal });
530
+ emit({
531
+ kind: "checkpoint_saved",
532
+ stage: this.machine.stage,
533
+ completedFileCount: this.completedFiles.size,
534
+ ...base(),
535
+ });
536
+
537
+ // Generation commit (architecture.md §13.4). Pre-check everything the
538
+ // runner can prove BEFORE asking for the metadata commit, so an
539
+ // incomplete generation is never even submitted for finalization.
540
+ const plannedFileIds = discovery.planned.map((file) => file.fileId);
541
+ const confirmedFileIds = [...this.completedFiles].sort();
542
+ const storedGraphs = graphResults.map((result) => result.graph);
543
+ const requiredGraphs = this.options.requiredGraphs ?? ["file", "symbol"];
544
+ const preCheck = validateGenerationCompleteness({
545
+ generationId: this.generationId,
546
+ plannedFileIds,
547
+ confirmedFileIds,
548
+ checkpointsSaved: true,
549
+ requiredGraphs,
550
+ storedGraphs,
551
+ metadataStatus: "completed",
552
+ });
553
+ if (!preCheck.complete) {
554
+ throw indexIncompleteError(
555
+ `generation "${this.generationId}" cannot be committed: ${preCheck.reasons.join("; ")}`,
556
+ {
557
+ retryable: false,
558
+ operation: "generation_commit",
559
+ context: {
560
+ generationId: this.generationId,
561
+ missingFileIds: preCheck.missingFileIds,
562
+ missingGraphs: preCheck.missingGraphs,
563
+ },
564
+ },
565
+ );
566
+ }
567
+ const commitResult = await stages.commit.commit(
568
+ {
569
+ jobId: this.jobId,
570
+ generationId: this.generationId,
571
+ workspaceId: identity.workspaceId,
572
+ repoId: identity.repoId,
573
+ refId: identity.refId,
574
+ effectiveProfileId: identity.effectiveProfileId,
575
+ plannedFileIds,
576
+ confirmedFileIds,
577
+ storedGraphs,
578
+ },
579
+ { signal },
580
+ );
581
+ // Full §13.4 gate including the FINAL metadata status the commit
582
+ // stage actually reached — `completed` is only reported after this.
583
+ assertGenerationComplete({
584
+ generationId: this.generationId,
585
+ plannedFileIds,
586
+ confirmedFileIds,
587
+ checkpointsSaved: true,
588
+ requiredGraphs,
589
+ storedGraphs,
590
+ metadataStatus: commitResult.metadataStatus,
591
+ });
592
+
593
+ // Stop the heartbeat and perform one last owner-checked renewal before
594
+ // publishing anything terminal. If another process took the lease, this
595
+ // throws LOCK_HELD and the stale runner never calls the observer.
596
+ await lockHeartbeat?.assertOwnership();
597
+ await notifyGenerationFinished("completed");
598
+ this.machine.transition("completed");
599
+ emit({
600
+ kind: "job_completed",
601
+ durationMs: clock.nowEpochMs() - startedAtMs,
602
+ ...base(),
603
+ });
604
+ } catch (error) {
605
+ let effectiveError: unknown = lockHeartbeat?.failure ?? error;
606
+ const externallyCancelled =
607
+ (lockHeartbeat === null || lockHeartbeat.failure === null) &&
608
+ external?.aborted === true &&
609
+ isJobCancelledError(error);
610
+
611
+ if (externallyCancelled) {
612
+ const checkpointSaved = await this.prepareCancelled(emit, base);
613
+ try {
614
+ await lockHeartbeat?.assertOwnership();
615
+ } catch (ownershipError) {
616
+ // Cancellation cleanup can outlive a lease. Losing it changes the
617
+ // truthful terminal outcome from cancelled to a lock failure.
618
+ effectiveError = ownershipError;
619
+ this.finishFailed(effectiveError, emit, base);
620
+ return;
621
+ }
622
+ await notifyGenerationFinished("cancelled");
623
+ this.emitCancelled(checkpointSaved, emit, base);
624
+ return;
625
+ }
626
+
627
+ let ownsLock = true;
628
+ try {
629
+ await lockHeartbeat?.assertOwnership();
630
+ } catch (ownershipError) {
631
+ effectiveError = ownershipError;
632
+ ownsLock = false;
633
+ }
634
+ if (ownsLock) await notifyGenerationFinished("failed");
635
+ this.finishFailed(effectiveError, emit, base);
636
+ } finally {
637
+ await lockHeartbeat?.stop();
638
+ internal.abort();
639
+ if (lock !== null) {
640
+ try {
641
+ await lock.release();
642
+ } catch {
643
+ // Releasing a lock after the job already reached a terminal state
644
+ // must never mask the job's own outcome.
645
+ }
646
+ }
647
+ }
648
+ }
649
+
650
+ /** Cancellation path part one (architecture.md §21.3): save the checkpoint. */
651
+ private async prepareCancelled(
652
+ emit: (event: JobEvent) => void,
653
+ base: () => { jobId: string; workspaceId: string; occurredAt: string },
654
+ ): Promise<boolean> {
655
+ this.machine.transition("cancel_requested");
656
+ let checkpointSaved = false;
657
+ if (this.generationId !== null) {
658
+ try {
659
+ // Only CONFIRMED work is in the runner state, so this checkpoint is
660
+ // valid by construction; per-batch checkpoints were already saved.
661
+ await this.saveCheckpoint({});
662
+ checkpointSaved = true;
663
+ emit({
664
+ kind: "checkpoint_saved",
665
+ stage: this.machine.stage,
666
+ completedFileCount: this.completedFiles.size,
667
+ ...base(),
668
+ });
669
+ } catch {
670
+ checkpointSaved = false;
671
+ }
672
+ }
673
+ return checkpointSaved;
674
+ }
675
+
676
+ /** Cancellation path part two: emit only after terminal lock ownership was proven. */
677
+ private emitCancelled(
678
+ checkpointSaved: boolean,
679
+ emit: (event: JobEvent) => void,
680
+ base: () => { jobId: string; workspaceId: string; occurredAt: string },
681
+ ): void {
682
+ emit({ kind: "job_cancelled", checkpointSaved, ...base() });
683
+ this.machine.transition("cancelled");
684
+ }
685
+
686
+ private finishFailed(
687
+ error: unknown,
688
+ emit: (event: JobEvent) => void,
689
+ base: () => { jobId: string; workspaceId: string; occurredAt: string },
690
+ ): void {
691
+ const failure = isUntangleError(error)
692
+ ? error
693
+ : new UntangleError(
694
+ // A stage that throws something untyped still leaves the index
695
+ // incomplete — that is the truthful classification. Never guess a
696
+ // more specific infrastructure code (spec.md §25.2).
697
+ "INDEX_INCOMPLETE",
698
+ error instanceof Error ? error.message : String(error),
699
+ { retryable: false, operation: "index_job", cause: error },
700
+ );
701
+ this.machine.transition("failed");
702
+ emit({
703
+ kind: "job_failed",
704
+ errorCode: failure.code,
705
+ message: failure.message,
706
+ retryable: failure.retryable,
707
+ ...base(),
708
+ });
709
+ }
710
+
711
+ /**
712
+ * Meldet eine Budgetverletzung der Queues. Eine Ueberschreitung des
713
+ * Speicherbudgets darf nie stillschweigend passieren (E4) — auch dann
714
+ * nicht, wenn sie unvermeidbar ist, weil ein einzelnes Element groesser
715
+ * als das ganze Budget ist.
716
+ */
717
+ private reportOverBudget(queue: "analysis" | "upsert", admission: OverBudgetAdmissionV1): void {
718
+ this.options.logger?.log({
719
+ timestamp: this.options.clock.nowIso(),
720
+ level: "warn",
721
+ component: "jobs.index",
722
+ event: "queue_over_budget_admission",
723
+ jobId: this.jobId,
724
+ context: {
725
+ queue,
726
+ itemBytes: admission.itemBytes,
727
+ maxBytes: admission.maxBytes,
728
+ ordinal: admission.ordinal,
729
+ allowance: admission.allowance,
730
+ },
731
+ });
732
+ }
733
+
734
+ // -- pipeline -------------------------------------------------------------
735
+
736
+ private async runPipeline(
737
+ remaining: readonly PlannedFileV1[],
738
+ emit: (event: JobEvent) => void,
739
+ base: () => { jobId: string; workspaceId: string; occurredAt: string },
740
+ signal: AbortSignal,
741
+ ): Promise<void> {
742
+ if (remaining.length === 0) {
743
+ return;
744
+ }
745
+ const { stages, identity } = this.options;
746
+ const memoryBudgetBytes = this.options.memoryBudgetBytes ?? DEFAULT_MEMORY_BUDGET_BYTES;
747
+ const embedBatchSize = this.options.embedBatchSize ?? DEFAULT_EMBED_BATCH_SIZE;
748
+ const analysisQueue = new BoundedQueue<AnalyzedFileV1>({
749
+ capacity: this.options.queueCapacities?.analysis ?? DEFAULT_ANALYSIS_CAPACITY,
750
+ maxBytes: Math.floor(memoryBudgetBytes / 2),
751
+ sizeOf: (item) => item.estimatedBytes,
752
+ onOverBudgetAdmission: (admission) => this.reportOverBudget("analysis", admission),
753
+ });
754
+ const upsertQueue = new BoundedQueue<EmbeddedBatchV1>({
755
+ capacity: this.options.queueCapacities?.upsert ?? DEFAULT_UPSERT_CAPACITY,
756
+ maxBytes: Math.floor(memoryBudgetBytes / 2),
757
+ sizeOf: (item) => item.estimatedBytes,
758
+ onOverBudgetAdmission: (admission) => this.reportOverBudget("upsert", admission),
759
+ });
760
+ this.queues = { analysis: analysisQueue, upsert: upsertQueue };
761
+
762
+ const failAll = (error: unknown): void => {
763
+ analysisQueue.fail(error);
764
+ upsertQueue.fail(error);
765
+ };
766
+
767
+ const analysisWorker = async (): Promise<void> => {
768
+ for (const file of remaining) {
769
+ // Safe batch boundary: between files (architecture.md §21.3).
770
+ throwIfAborted(signal, "index_analysis");
771
+ const analyzed = await stages.analysis.analyze(file, { signal });
772
+ this.machine.advanceTo("computing_metrics");
773
+ emit({
774
+ kind: "file_analyzed",
775
+ repoId: identity.repoId,
776
+ refId: identity.refId,
777
+ fileId: file.fileId,
778
+ filePath: file.filePath,
779
+ parseQuality: analyzed.parseQuality,
780
+ chunkCount: analyzed.chunkCount,
781
+ symbolCount: analyzed.symbolCount,
782
+ ...base(),
783
+ });
784
+ await analysisQueue.push(analyzed, { signal });
785
+ }
786
+ analysisQueue.close();
787
+ };
788
+
789
+ const embedWorker = async (): Promise<void> => {
790
+ let batchIndex = 0;
791
+ while (true) {
792
+ const first = await analysisQueue.pull({ signal });
793
+ if (first.done) {
794
+ break;
795
+ }
796
+ const batch: AnalyzedFileV1[] = [first.value];
797
+ while (batch.length < embedBatchSize) {
798
+ const next = analysisQueue.tryPull();
799
+ if (next === null || next.done) {
800
+ break;
801
+ }
802
+ batch.push(next.value);
803
+ }
804
+ // Safe batch boundary: before embedding the next batch.
805
+ throwIfAborted(signal, "index_embedding");
806
+ this.machine.advanceTo("embedding");
807
+ const embedded = await stages.embedding.embedBatch(batch, { signal });
808
+ emit({
809
+ kind: "embedding_batch_completed",
810
+ batchIndex,
811
+ batchSize: batch.length,
812
+ cacheHits: embedded.cacheHits,
813
+ truncatedCount: embedded.truncatedCount,
814
+ ...base(),
815
+ });
816
+ await upsertQueue.push(embedded, { signal });
817
+ batchIndex += 1;
818
+ }
819
+ upsertQueue.close();
820
+ };
821
+
822
+ const storeWorker = async (): Promise<void> => {
823
+ while (true) {
824
+ const item = await upsertQueue.pull({ signal });
825
+ if (item.done) {
826
+ break;
827
+ }
828
+ // Safe batch boundary: before starting the next upsert.
829
+ throwIfAborted(signal, "index_storing");
830
+ this.machine.advanceTo("storing");
831
+ const confirmation = await stages.storage.upsertBatch(item.value, { signal });
832
+ emit({
833
+ kind: "points_stored",
834
+ collection: confirmation.collection,
835
+ pointCount: confirmation.pointCount,
836
+ serializedBytes: confirmation.serializedBytes,
837
+ ...base(),
838
+ });
839
+ // Record confirmed work ONLY after the store acknowledged the
840
+ // upsert, and persist the checkpoint ONLY after that (spec.md §10.5).
841
+ for (const file of item.value.files) {
842
+ this.completedFiles.add(file.fileId);
843
+ this.manifestHashes[file.fileId] = file.contentHash;
844
+ }
845
+ for (const [fileId, pointIds] of Object.entries(confirmation.storedPointIdsByFile)) {
846
+ this.storedPointIdsByFile[fileId] = [...pointIds];
847
+ }
848
+ this.machine.advanceTo("checkpointing");
849
+ await this.saveCheckpoint({ signal });
850
+ emit({
851
+ kind: "checkpoint_saved",
852
+ stage: this.machine.stage,
853
+ completedFileCount: this.completedFiles.size,
854
+ ...base(),
855
+ });
856
+ }
857
+ };
858
+
859
+ const guard = async (worker: () => Promise<void>): Promise<void> => {
860
+ try {
861
+ await worker();
862
+ } catch (error) {
863
+ failAll(error);
864
+ throw error;
865
+ }
866
+ };
867
+
868
+ const settled = await Promise.allSettled([
869
+ guard(analysisWorker),
870
+ guard(embedWorker),
871
+ guard(storeWorker),
872
+ ]);
873
+ this.lastQueueStats = { analysis: analysisQueue.stats(), upsert: upsertQueue.stats() };
874
+ this.queues = null;
875
+
876
+ const rejections = settled
877
+ .filter((result): result is PromiseRejectedResult => result.status === "rejected")
878
+ .map((result) => result.reason as unknown);
879
+ const firstRealFailure = rejections.find((error) => !isJobCancelledError(error));
880
+ if (firstRealFailure !== undefined) {
881
+ throw firstRealFailure;
882
+ }
883
+ const firstCancellation = rejections[0];
884
+ if (firstCancellation !== undefined) {
885
+ throw firstCancellation;
886
+ }
887
+ }
888
+
889
+ private async saveCheckpoint(options: { signal?: AbortSignal }): Promise<void> {
890
+ if (this.generationId === null) {
891
+ throw indexIncompleteError("cannot save a checkpoint before the generation is known", {
892
+ retryable: false,
893
+ operation: "checkpoint_save",
894
+ });
895
+ }
896
+ const checkpoint: IndexCheckpointV1 = {
897
+ jobId: this.jobId,
898
+ generationId: this.generationId,
899
+ workspaceId: this.options.identity.workspaceId,
900
+ repoId: this.options.identity.repoId,
901
+ refId: this.options.identity.refId,
902
+ effectiveProfileId: this.options.identity.effectiveProfileId,
903
+ completedFiles: [...this.completedFiles].sort(),
904
+ storedPointIdsByFile: Object.fromEntries(
905
+ Object.entries(this.storedPointIdsByFile).map(([fileId, ids]) => [fileId, [...ids]]),
906
+ ),
907
+ manifestHashes: { ...this.manifestHashes },
908
+ stage: this.machine.stage,
909
+ savedAt: this.options.clock.nowIso(),
910
+ };
911
+ await this.options.checkpointStore.save(
912
+ checkpoint,
913
+ options.signal !== undefined ? { signal: options.signal } : undefined,
914
+ );
915
+ }
916
+ }