@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/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