@semiont/jobs 0.5.36 → 0.5.37
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/dist/index.d.ts +169 -4
- package/dist/index.js +510 -10
- package/dist/index.js.map +1 -1
- package/dist/worker-main.js +9 -7
- package/dist/worker-main.js.map +1 -1
- package/package.json +9 -8
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { promises, mkdtempSync, writeFileSync, readFileSync, rmSync } from 'fs';
|
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { replyChannelsFor, jobId, deriveViews, GENERATABLE_MEDIA_TYPES, estimateTokens, isObject, isString, reconcileSelector, getLocaleEnglishName, cutChunk, chunkText } from '@semiont/core';
|
|
5
|
+
import { connect, RetentionPolicy, DeliverPolicy, AckPolicy, nanos } from 'nats';
|
|
5
6
|
import { withSpan, recordAnchorOutcome, recordDetectionCall } from '@semiont/observability';
|
|
6
7
|
import { StructuredReadError } from '@semiont/inference';
|
|
7
8
|
import { execFileSync } from 'child_process';
|
|
@@ -18,10 +19,7 @@ function willRetryAfter(metadata, failureClass) {
|
|
|
18
19
|
return failureClass !== "deterministic" && metadata.retryCount < metadata.maxRetries;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
// src/
|
|
22
|
-
var REANNOUNCE_INTERVAL_MS = 3e4;
|
|
23
|
-
var STALE_RUNNING_MS = 30 * 6e4;
|
|
24
|
-
var PROGRESS_WRITE_MIN_INTERVAL_MS = 5e3;
|
|
22
|
+
// src/checkpoint-merge.ts
|
|
25
23
|
function mergeUnitCursors(existing, incoming, completed) {
|
|
26
24
|
const done = new Set(completed);
|
|
27
25
|
const merged = {};
|
|
@@ -35,6 +33,11 @@ function mergeUnitCursors(existing, incoming, completed) {
|
|
|
35
33
|
}
|
|
36
34
|
return merged;
|
|
37
35
|
}
|
|
36
|
+
|
|
37
|
+
// src/fs-job-queue.ts
|
|
38
|
+
var REANNOUNCE_INTERVAL_MS = 3e4;
|
|
39
|
+
var STALE_RUNNING_MS = 30 * 6e4;
|
|
40
|
+
var PROGRESS_WRITE_MIN_INTERVAL_MS = 5e3;
|
|
38
41
|
var RETENTION_HOURS = 24;
|
|
39
42
|
var CLEANUP_INTERVAL_MS = 36e5;
|
|
40
43
|
var FsJobQueue = class {
|
|
@@ -165,10 +168,43 @@ var FsJobQueue = class {
|
|
|
165
168
|
}
|
|
166
169
|
return null;
|
|
167
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* Serializes claims. This driver's atomicity is single-process by design,
|
|
173
|
+
* but `claimJob`'s contract must hold even against interleaved awaits in
|
|
174
|
+
* that one process — a read-check-write with await points in it can admit
|
|
175
|
+
* two winners without this chain (the TOCTOU the operation exists to kill).
|
|
176
|
+
*/
|
|
177
|
+
claimChain = Promise.resolve();
|
|
178
|
+
async claimNextJob(types) {
|
|
179
|
+
const claim = this.claimChain.then(() => this.doClaimNext(types));
|
|
180
|
+
this.claimChain = claim.catch(() => void 0);
|
|
181
|
+
return claim;
|
|
182
|
+
}
|
|
183
|
+
async doClaimNext(types) {
|
|
184
|
+
const pending = await this.listJobs({ status: "pending", limit: Number.MAX_SAFE_INTEGER });
|
|
185
|
+
const match = pending.find((j) => types.length === 0 || types.includes(j.metadata.type));
|
|
186
|
+
if (!match) return { declined: "none-available" };
|
|
187
|
+
const running = {
|
|
188
|
+
status: "running",
|
|
189
|
+
metadata: match.metadata,
|
|
190
|
+
params: match.params,
|
|
191
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
192
|
+
progress: {}
|
|
193
|
+
};
|
|
194
|
+
await this.transition(running, "pending");
|
|
195
|
+
return { job: running };
|
|
196
|
+
}
|
|
168
197
|
/**
|
|
169
198
|
* Update a job (atomic: delete old, write new)
|
|
170
199
|
*/
|
|
171
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Cross-status move (or in-place rewrite): delete old slot, write new.
|
|
202
|
+
* Private — the interface exposes named transitions only (JOB-QUEUE-DRIVER
|
|
203
|
+
* P0); a generic public patch is trivial on files and impossible on an
|
|
204
|
+
* in-flight message, so no driver may offer one. Re-entering `pending`
|
|
205
|
+
* announces, which is how failJob's retry reaches a listening worker.
|
|
206
|
+
*/
|
|
207
|
+
async transition(job, oldStatus) {
|
|
172
208
|
if (oldStatus && oldStatus !== job.status) {
|
|
173
209
|
const oldPath = this.getJobPath(job.metadata.id, oldStatus);
|
|
174
210
|
try {
|
|
@@ -206,7 +242,7 @@ var FsJobQueue = class {
|
|
|
206
242
|
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
207
243
|
result
|
|
208
244
|
};
|
|
209
|
-
await this.
|
|
245
|
+
await this.transition(completed, "running");
|
|
210
246
|
return true;
|
|
211
247
|
}
|
|
212
248
|
/**
|
|
@@ -235,7 +271,7 @@ var FsJobQueue = class {
|
|
|
235
271
|
metadata: { ...metadata, retryCount: job.metadata.retryCount + 1 },
|
|
236
272
|
params: job.params
|
|
237
273
|
};
|
|
238
|
-
await this.
|
|
274
|
+
await this.transition(retried, "running");
|
|
239
275
|
return "retried";
|
|
240
276
|
}
|
|
241
277
|
if (failureClass === "deterministic" && job.metadata.retryCount < job.metadata.maxRetries) {
|
|
@@ -249,7 +285,7 @@ var FsJobQueue = class {
|
|
|
249
285
|
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
250
286
|
error
|
|
251
287
|
};
|
|
252
|
-
await this.
|
|
288
|
+
await this.transition(failed, "running");
|
|
253
289
|
return "failed";
|
|
254
290
|
}
|
|
255
291
|
/**
|
|
@@ -352,7 +388,7 @@ var FsJobQueue = class {
|
|
|
352
388
|
startedAt: job.status === "running" ? job.startedAt : void 0,
|
|
353
389
|
completedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
354
390
|
};
|
|
355
|
-
await this.
|
|
391
|
+
await this.transition(cancelledJob, oldStatus);
|
|
356
392
|
return true;
|
|
357
393
|
}
|
|
358
394
|
/**
|
|
@@ -474,6 +510,470 @@ var FsJobQueue = class {
|
|
|
474
510
|
return stats;
|
|
475
511
|
}
|
|
476
512
|
};
|
|
513
|
+
var STREAM = "JOBS";
|
|
514
|
+
var CONSUMER = "gateway-claims";
|
|
515
|
+
var BUCKET = "jobs";
|
|
516
|
+
var PROGRESS_WRITE_MIN_INTERVAL_MS2 = 5e3;
|
|
517
|
+
var MAX_CAS_ATTEMPTS = 20;
|
|
518
|
+
var enc = new TextEncoder();
|
|
519
|
+
var dec = new TextDecoder();
|
|
520
|
+
function categoryOf(type) {
|
|
521
|
+
return type === "generation" ? "generation" : "annotation";
|
|
522
|
+
}
|
|
523
|
+
function subjectFor(job) {
|
|
524
|
+
return `jobs.${categoryOf(job.metadata.type)}.${job.metadata.type}`;
|
|
525
|
+
}
|
|
526
|
+
var JetStreamJobQueue = class {
|
|
527
|
+
constructor(options, logger, eventBus) {
|
|
528
|
+
this.options = options;
|
|
529
|
+
this.logger = logger;
|
|
530
|
+
this.eventBus = eventBus;
|
|
531
|
+
this.staleRunningMs = options.staleRunningMs ?? 30 * 6e4;
|
|
532
|
+
this.ackWaitMs = options.ackWaitMs ?? 3e4;
|
|
533
|
+
this.tickMs = options.tickMs ?? 3e4;
|
|
534
|
+
}
|
|
535
|
+
options;
|
|
536
|
+
logger;
|
|
537
|
+
eventBus;
|
|
538
|
+
nc;
|
|
539
|
+
js;
|
|
540
|
+
jsm;
|
|
541
|
+
kv;
|
|
542
|
+
iter = null;
|
|
543
|
+
/** Delivered messages this process holds — the lease, keyed by jobId,
|
|
544
|
+
* with the job's type so a by-type claim can walk them without a read. */
|
|
545
|
+
held = /* @__PURE__ */ new Map();
|
|
546
|
+
heartbeat = null;
|
|
547
|
+
reconciling = false;
|
|
548
|
+
tick = null;
|
|
549
|
+
ticking = false;
|
|
550
|
+
lastProgressWrite = /* @__PURE__ */ new Map();
|
|
551
|
+
staleRunningMs;
|
|
552
|
+
ackWaitMs;
|
|
553
|
+
tickMs;
|
|
554
|
+
async initialize() {
|
|
555
|
+
this.nc = await connect({
|
|
556
|
+
servers: this.options.servers,
|
|
557
|
+
reconnect: this.options.reconnect ?? true,
|
|
558
|
+
timeout: 1e4
|
|
559
|
+
});
|
|
560
|
+
this.js = this.nc.jetstream();
|
|
561
|
+
this.jsm = await this.nc.jetstreamManager();
|
|
562
|
+
this.kv = await this.js.views.kv(BUCKET);
|
|
563
|
+
try {
|
|
564
|
+
await this.jsm.streams.info(STREAM);
|
|
565
|
+
} catch {
|
|
566
|
+
await this.jsm.streams.add({
|
|
567
|
+
name: STREAM,
|
|
568
|
+
subjects: ["jobs.>"],
|
|
569
|
+
retention: RetentionPolicy.Workqueue
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
try {
|
|
573
|
+
await this.jsm.consumers.info(STREAM, CONSUMER);
|
|
574
|
+
} catch {
|
|
575
|
+
await this.jsm.consumers.add(STREAM, {
|
|
576
|
+
durable_name: CONSUMER,
|
|
577
|
+
ack_policy: AckPolicy.Explicit,
|
|
578
|
+
deliver_policy: DeliverPolicy.All,
|
|
579
|
+
ack_wait: nanos(this.ackWaitMs),
|
|
580
|
+
// ONE retry authority: will-retry.ts decides; the stream never
|
|
581
|
+
// gives up a message on its own count.
|
|
582
|
+
max_deliver: -1
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
const consumer = await this.js.consumers.get(STREAM, CONSUMER);
|
|
586
|
+
this.iter = await consumer.consume({
|
|
587
|
+
callback: (m) => {
|
|
588
|
+
void this.onDelivery(m).catch((error) => {
|
|
589
|
+
this.logger.warn("Job delivery handling failed", {
|
|
590
|
+
error: error instanceof Error ? error.message : String(error)
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
this.heartbeat = setInterval(() => {
|
|
596
|
+
if (this.reconciling) return;
|
|
597
|
+
this.reconciling = true;
|
|
598
|
+
void this.reconcileHeld().catch((error) => {
|
|
599
|
+
this.logger.warn("Held-lease reconciliation failed", {
|
|
600
|
+
error: error instanceof Error ? error.message : String(error)
|
|
601
|
+
});
|
|
602
|
+
}).finally(() => {
|
|
603
|
+
this.reconciling = false;
|
|
604
|
+
});
|
|
605
|
+
}, Math.max(250, Math.floor(this.ackWaitMs / 4)));
|
|
606
|
+
this.heartbeat.unref?.();
|
|
607
|
+
this.tick = setInterval(() => {
|
|
608
|
+
if (this.ticking) return;
|
|
609
|
+
this.ticking = true;
|
|
610
|
+
void (async () => {
|
|
611
|
+
for (const [id, held] of [...this.held]) {
|
|
612
|
+
const envelope = await this.read(jobId(id));
|
|
613
|
+
if (envelope?.job.status === "pending") this.announce(envelope.job);
|
|
614
|
+
}
|
|
615
|
+
await this.recoverStaleRunningJobs();
|
|
616
|
+
})().catch((error) => {
|
|
617
|
+
this.logger.warn("Job-queue tick failed", {
|
|
618
|
+
error: error instanceof Error ? error.message : String(error)
|
|
619
|
+
});
|
|
620
|
+
}).finally(() => {
|
|
621
|
+
this.ticking = false;
|
|
622
|
+
});
|
|
623
|
+
}, this.tickMs);
|
|
624
|
+
this.tick.unref?.();
|
|
625
|
+
}
|
|
626
|
+
destroy() {
|
|
627
|
+
this.iter?.stop();
|
|
628
|
+
this.iter = null;
|
|
629
|
+
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
630
|
+
this.heartbeat = null;
|
|
631
|
+
if (this.tick) clearInterval(this.tick);
|
|
632
|
+
this.tick = null;
|
|
633
|
+
this.held.clear();
|
|
634
|
+
void this.nc?.close();
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* A delivery is a job arriving at this gateway. What it means depends on
|
|
638
|
+
* the job's authoritative (KV) state:
|
|
639
|
+
* - pending → hold the lease, announce for a worker to claim;
|
|
640
|
+
* - running → hold silently (a claim raced ahead of the delivery, or a
|
|
641
|
+
* redelivery reached a fresh instance after a gateway death);
|
|
642
|
+
* - terminal → the work already concluded elsewhere; consume the message;
|
|
643
|
+
* - unknown → not ours to run; terminate it.
|
|
644
|
+
*/
|
|
645
|
+
async onDelivery(m) {
|
|
646
|
+
let id;
|
|
647
|
+
try {
|
|
648
|
+
id = JSON.parse(dec.decode(m.data)).jobId;
|
|
649
|
+
} catch {
|
|
650
|
+
m.term();
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
const envelope = await this.read(jobId(id));
|
|
654
|
+
if (!envelope) {
|
|
655
|
+
m.term();
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
switch (envelope.job.status) {
|
|
659
|
+
case "pending":
|
|
660
|
+
this.held.set(id, { m, type: envelope.job.metadata.type });
|
|
661
|
+
this.announce(envelope.job);
|
|
662
|
+
break;
|
|
663
|
+
case "running":
|
|
664
|
+
this.held.set(id, { m, type: envelope.job.metadata.type });
|
|
665
|
+
break;
|
|
666
|
+
default:
|
|
667
|
+
m.ack();
|
|
668
|
+
this.held.delete(id);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
/** Same wire shape as the fs driver: only jobs with a resourceId announce. */
|
|
672
|
+
announce(job) {
|
|
673
|
+
if (this.eventBus && "params" in job && "resourceId" in job.params) {
|
|
674
|
+
this.eventBus.get("job:queued").next({
|
|
675
|
+
jobId: job.metadata.id,
|
|
676
|
+
jobType: job.metadata.type,
|
|
677
|
+
resourceId: job.params.resourceId,
|
|
678
|
+
userId: job.metadata.userId
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
async read(jobIdArg) {
|
|
683
|
+
const entry = await this.kv.get(jobIdArg);
|
|
684
|
+
if (!entry || entry.operation !== "PUT") return null;
|
|
685
|
+
const envelope = JSON.parse(dec.decode(entry.value));
|
|
686
|
+
return { ...envelope, revision: entry.revision };
|
|
687
|
+
}
|
|
688
|
+
async write(jobIdArg, envelope, revision) {
|
|
689
|
+
const payload = enc.encode(JSON.stringify(envelope));
|
|
690
|
+
if (revision === void 0) return this.kv.put(jobIdArg, payload);
|
|
691
|
+
return this.kv.update(jobIdArg, payload, revision);
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Read-transform-CAS with bounded retries: the transform sees the current
|
|
695
|
+
* job and returns the replacement (or null to stop). Every state
|
|
696
|
+
* transition goes through here, which is what makes each one atomic.
|
|
697
|
+
*/
|
|
698
|
+
async cas(jobIdArg, transform, onMissing) {
|
|
699
|
+
for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt++) {
|
|
700
|
+
const current = await this.read(jobIdArg);
|
|
701
|
+
if (!current) return onMissing;
|
|
702
|
+
const out = transform(current.job, current.lastProgressAt);
|
|
703
|
+
if (out === null) return onMissing;
|
|
704
|
+
if (!("envelope" in out)) return out.result;
|
|
705
|
+
try {
|
|
706
|
+
await this.write(jobIdArg, out.envelope, current.revision);
|
|
707
|
+
return out.result;
|
|
708
|
+
} catch {
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
throw new Error(`Job ${jobIdArg} transition failed after ${MAX_CAS_ATTEMPTS} CAS attempts`);
|
|
712
|
+
}
|
|
713
|
+
async createJob(job) {
|
|
714
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
715
|
+
await this.kv.create(job.metadata.id, enc.encode(JSON.stringify({ job, lastProgressAt: now })));
|
|
716
|
+
if (job.status === "pending") {
|
|
717
|
+
await this.js.publish(subjectFor(job), enc.encode(JSON.stringify({ jobId: job.metadata.id })));
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
async getJob(jobIdArg) {
|
|
721
|
+
const envelope = await this.read(jobIdArg);
|
|
722
|
+
return envelope?.job ?? null;
|
|
723
|
+
}
|
|
724
|
+
async claimNextJob(types) {
|
|
725
|
+
const matches = (t) => types.length === 0 || types.includes(t);
|
|
726
|
+
for (const [id, held] of [...this.held]) {
|
|
727
|
+
if (!matches(held.type)) continue;
|
|
728
|
+
const won = await this.tryClaim(jobId(id));
|
|
729
|
+
if (won) return won;
|
|
730
|
+
}
|
|
731
|
+
for (const key of await this.allKeys()) {
|
|
732
|
+
const envelope = await this.read(jobId(key));
|
|
733
|
+
if (!envelope || envelope.job.status !== "pending" || !matches(envelope.job.metadata.type)) continue;
|
|
734
|
+
const won = await this.tryClaim(jobId(key));
|
|
735
|
+
if (won) return won;
|
|
736
|
+
}
|
|
737
|
+
return { declined: "none-available" };
|
|
738
|
+
}
|
|
739
|
+
/** CAS one pending job to running; null when someone else won it. */
|
|
740
|
+
async tryClaim(jobIdArg) {
|
|
741
|
+
return this.cas(
|
|
742
|
+
jobIdArg,
|
|
743
|
+
(job) => {
|
|
744
|
+
if (job.status !== "pending") return { result: null };
|
|
745
|
+
const running = {
|
|
746
|
+
status: "running",
|
|
747
|
+
metadata: job.metadata,
|
|
748
|
+
params: job.params,
|
|
749
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
750
|
+
progress: {}
|
|
751
|
+
};
|
|
752
|
+
return {
|
|
753
|
+
envelope: { job: running, lastProgressAt: (/* @__PURE__ */ new Date()).toISOString() },
|
|
754
|
+
result: { job: running }
|
|
755
|
+
};
|
|
756
|
+
},
|
|
757
|
+
null
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
async completeJob(jobIdArg, result) {
|
|
761
|
+
const moved = await this.cas(
|
|
762
|
+
jobIdArg,
|
|
763
|
+
(job) => {
|
|
764
|
+
if (job.status !== "running") return { result: false };
|
|
765
|
+
const completed = {
|
|
766
|
+
status: "complete",
|
|
767
|
+
metadata: job.metadata,
|
|
768
|
+
params: job.params,
|
|
769
|
+
startedAt: job.startedAt,
|
|
770
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
771
|
+
result
|
|
772
|
+
};
|
|
773
|
+
return { envelope: { job: completed, lastProgressAt: (/* @__PURE__ */ new Date()).toISOString() }, result: true };
|
|
774
|
+
},
|
|
775
|
+
false
|
|
776
|
+
);
|
|
777
|
+
if (moved) this.settleLease(jobIdArg, "ack");
|
|
778
|
+
return moved;
|
|
779
|
+
}
|
|
780
|
+
async failJob(jobIdArg, error, completedUnits, failureClass, unitCursors) {
|
|
781
|
+
const outcome = await this.cas(
|
|
782
|
+
jobIdArg,
|
|
783
|
+
(job) => {
|
|
784
|
+
if (job.status !== "running") return { result: null };
|
|
785
|
+
const units = [.../* @__PURE__ */ new Set([...job.metadata.completedUnits ?? [], ...completedUnits ?? []])];
|
|
786
|
+
const cursors = mergeUnitCursors(job.metadata.unitCursors, unitCursors, units);
|
|
787
|
+
const metadata = {
|
|
788
|
+
...job.metadata,
|
|
789
|
+
...units.length > 0 ? { completedUnits: units } : {},
|
|
790
|
+
...Object.keys(cursors).length > 0 ? { unitCursors: cursors } : {}
|
|
791
|
+
};
|
|
792
|
+
if ("unitCursors" in metadata && Object.keys(cursors).length === 0) delete metadata.unitCursors;
|
|
793
|
+
if (willRetryAfter(job.metadata, failureClass)) {
|
|
794
|
+
const retried = {
|
|
795
|
+
status: "pending",
|
|
796
|
+
metadata: { ...metadata, retryCount: job.metadata.retryCount + 1 },
|
|
797
|
+
params: job.params
|
|
798
|
+
};
|
|
799
|
+
return { envelope: { job: retried, lastProgressAt: (/* @__PURE__ */ new Date()).toISOString() }, result: "retried" };
|
|
800
|
+
}
|
|
801
|
+
const failed = {
|
|
802
|
+
status: "failed",
|
|
803
|
+
metadata,
|
|
804
|
+
params: job.params,
|
|
805
|
+
startedAt: job.startedAt,
|
|
806
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
807
|
+
error
|
|
808
|
+
};
|
|
809
|
+
return { envelope: { job: failed, lastProgressAt: (/* @__PURE__ */ new Date()).toISOString() }, result: "failed" };
|
|
810
|
+
},
|
|
811
|
+
null
|
|
812
|
+
);
|
|
813
|
+
if (outcome === "retried") {
|
|
814
|
+
const held = this.held.get(jobIdArg);
|
|
815
|
+
if (held) {
|
|
816
|
+
this.held.delete(jobIdArg);
|
|
817
|
+
held.m.nak();
|
|
818
|
+
} else {
|
|
819
|
+
const envelope = await this.read(jobIdArg);
|
|
820
|
+
if (envelope) await this.js.publish(subjectFor(envelope.job), enc.encode(JSON.stringify({ jobId: jobIdArg })));
|
|
821
|
+
}
|
|
822
|
+
} else if (outcome === "failed") {
|
|
823
|
+
this.settleLease(jobIdArg, "term");
|
|
824
|
+
}
|
|
825
|
+
return outcome;
|
|
826
|
+
}
|
|
827
|
+
async checkpointUnits(jobIdArg, completedUnits, unitCursors) {
|
|
828
|
+
await this.cas(
|
|
829
|
+
jobIdArg,
|
|
830
|
+
(job) => {
|
|
831
|
+
if (job.status !== "running") return { result: void 0 };
|
|
832
|
+
const units = [.../* @__PURE__ */ new Set([...job.metadata.completedUnits ?? [], ...completedUnits])];
|
|
833
|
+
const cursors = mergeUnitCursors(job.metadata.unitCursors, unitCursors, units);
|
|
834
|
+
const metadata = { ...job.metadata, completedUnits: units };
|
|
835
|
+
if (Object.keys(cursors).length > 0) metadata.unitCursors = cursors;
|
|
836
|
+
else delete metadata.unitCursors;
|
|
837
|
+
const updated = { ...job, metadata };
|
|
838
|
+
return { envelope: { job: updated, lastProgressAt: (/* @__PURE__ */ new Date()).toISOString() }, result: void 0 };
|
|
839
|
+
},
|
|
840
|
+
void 0
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
async recordProgress(jobIdArg, progress) {
|
|
844
|
+
const last = this.lastProgressWrite.get(jobIdArg) ?? 0;
|
|
845
|
+
if (Date.now() - last < PROGRESS_WRITE_MIN_INTERVAL_MS2) return;
|
|
846
|
+
this.lastProgressWrite.set(jobIdArg, Date.now());
|
|
847
|
+
this.held.get(jobIdArg)?.m.working();
|
|
848
|
+
await this.cas(
|
|
849
|
+
jobIdArg,
|
|
850
|
+
(job) => {
|
|
851
|
+
if (job.status !== "running") return { result: void 0 };
|
|
852
|
+
const updated = {
|
|
853
|
+
status: "running",
|
|
854
|
+
metadata: job.metadata,
|
|
855
|
+
params: job.params,
|
|
856
|
+
startedAt: job.startedAt,
|
|
857
|
+
progress
|
|
858
|
+
};
|
|
859
|
+
return { envelope: { job: updated, lastProgressAt: (/* @__PURE__ */ new Date()).toISOString() }, result: void 0 };
|
|
860
|
+
},
|
|
861
|
+
void 0
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
async cancelPendingJobs(category) {
|
|
865
|
+
const matches = category === "generation" ? (type) => type === "generation" : (type) => type.endsWith("-annotation");
|
|
866
|
+
let cancelled = 0;
|
|
867
|
+
for (const key of await this.allKeys()) {
|
|
868
|
+
const envelope = await this.read(jobId(key));
|
|
869
|
+
if (!envelope || envelope.job.status !== "pending" || !matches(envelope.job.metadata.type)) continue;
|
|
870
|
+
const done = await this.cas(
|
|
871
|
+
jobId(key),
|
|
872
|
+
(job) => {
|
|
873
|
+
if (job.status !== "pending" || !matches(job.metadata.type)) return { result: false };
|
|
874
|
+
const record = {
|
|
875
|
+
status: "cancelled",
|
|
876
|
+
metadata: job.metadata,
|
|
877
|
+
params: job.params,
|
|
878
|
+
startedAt: void 0,
|
|
879
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
880
|
+
};
|
|
881
|
+
return { envelope: { job: record, lastProgressAt: (/* @__PURE__ */ new Date()).toISOString() }, result: true };
|
|
882
|
+
},
|
|
883
|
+
false
|
|
884
|
+
);
|
|
885
|
+
if (done) {
|
|
886
|
+
cancelled++;
|
|
887
|
+
this.settleLease(jobId(key), "term");
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
await this.jsm.streams.purge(STREAM, { filter: `jobs.${category}.>` });
|
|
891
|
+
return cancelled;
|
|
892
|
+
}
|
|
893
|
+
async cancelJob(jobIdArg) {
|
|
894
|
+
const done = await this.cas(
|
|
895
|
+
jobIdArg,
|
|
896
|
+
(job) => {
|
|
897
|
+
if (job.status !== "pending" && job.status !== "running") return { result: false };
|
|
898
|
+
const record = {
|
|
899
|
+
status: "cancelled",
|
|
900
|
+
metadata: job.metadata,
|
|
901
|
+
params: job.params,
|
|
902
|
+
startedAt: job.status === "running" ? job.startedAt : void 0,
|
|
903
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
904
|
+
};
|
|
905
|
+
return { envelope: { job: record, lastProgressAt: (/* @__PURE__ */ new Date()).toISOString() }, result: true };
|
|
906
|
+
},
|
|
907
|
+
false
|
|
908
|
+
);
|
|
909
|
+
if (done) this.settleLease(jobIdArg, "term");
|
|
910
|
+
return done;
|
|
911
|
+
}
|
|
912
|
+
async getStats() {
|
|
913
|
+
const stats = { pending: 0, running: 0, complete: 0, failed: 0, cancelled: 0 };
|
|
914
|
+
for (const key of await this.allKeys()) {
|
|
915
|
+
const envelope = await this.read(jobId(key));
|
|
916
|
+
if (envelope) stats[envelope.job.status]++;
|
|
917
|
+
}
|
|
918
|
+
return stats;
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Worker-death recovery: the same contract the fs driver's mtime janitor
|
|
922
|
+
* implemented — a running job with no progress inside the stale window is
|
|
923
|
+
* retried or failed, checkpoint intact. Gateway-death recovery never comes
|
|
924
|
+
* through here; that is AckWait redelivery.
|
|
925
|
+
*/
|
|
926
|
+
async recoverStaleRunningJobs() {
|
|
927
|
+
const now = Date.now();
|
|
928
|
+
let recovered = 0;
|
|
929
|
+
for (const key of await this.allKeys()) {
|
|
930
|
+
const envelope = await this.read(jobId(key));
|
|
931
|
+
if (!envelope || envelope.job.status !== "running") continue;
|
|
932
|
+
if (now - Date.parse(envelope.lastProgressAt) < this.staleRunningMs) continue;
|
|
933
|
+
const outcome = await this.failJob(
|
|
934
|
+
jobId(key),
|
|
935
|
+
`worker presumed dead \u2014 no progress within ${this.staleRunningMs / 6e4} minutes`
|
|
936
|
+
);
|
|
937
|
+
if (outcome) {
|
|
938
|
+
this.logger.warn("Recovered stale running job", { jobId: key, outcome });
|
|
939
|
+
recovered++;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
return recovered;
|
|
943
|
+
}
|
|
944
|
+
/** The heartbeat body — extend live leases, settle concluded ones. */
|
|
945
|
+
async reconcileHeld() {
|
|
946
|
+
for (const [id, held] of [...this.held]) {
|
|
947
|
+
const envelope = await this.read(jobId(id));
|
|
948
|
+
const status = envelope?.job.status;
|
|
949
|
+
if (!envelope || status === "complete" || status === "failed" || status === "cancelled") {
|
|
950
|
+
this.held.delete(id);
|
|
951
|
+
held.m.ack();
|
|
952
|
+
} else {
|
|
953
|
+
held.m.working();
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Materialize the KV key list BEFORE touching any entry: interleaving
|
|
959
|
+
* `get`/`update` with an open `keys()` iterator on the same connection
|
|
960
|
+
* makes the iterator drop entries (measured: the second pending job
|
|
961
|
+
* vanished from every sweep until this was split into two passes).
|
|
962
|
+
*/
|
|
963
|
+
async allKeys() {
|
|
964
|
+
const keys = [];
|
|
965
|
+
for await (const key of await this.kv.keys()) keys.push(key);
|
|
966
|
+
return keys;
|
|
967
|
+
}
|
|
968
|
+
/** Conclude this process's lease on a job, if it holds one. */
|
|
969
|
+
settleLease(jobIdArg, how) {
|
|
970
|
+
const held = this.held.get(jobIdArg);
|
|
971
|
+
if (!held) return;
|
|
972
|
+
this.held.delete(jobIdArg);
|
|
973
|
+
if (how === "ack") held.m.ack();
|
|
974
|
+
else held.m.term();
|
|
975
|
+
}
|
|
976
|
+
};
|
|
477
977
|
|
|
478
978
|
// src/types.ts
|
|
479
979
|
function isPendingJob(job) {
|
|
@@ -2435,6 +2935,6 @@ var WORKER_AWAITED_OPERATIONS = [
|
|
|
2435
2935
|
];
|
|
2436
2936
|
replyChannelsFor(WORKER_AWAITED_OPERATIONS);
|
|
2437
2937
|
|
|
2438
|
-
export { AnnotationDetection, FsJobQueue, STALL_THRESHOLD_MS, generateResourceFromTopic, isCancelledJob, isCompleteJob, isFailedJob, isPendingJob, isRunningJob, processAssessmentJob, processCommentJob, processGenerationJob, processHighlightJob, processReferenceJob, processTagJob };
|
|
2938
|
+
export { AnnotationDetection, FsJobQueue, JetStreamJobQueue, STALL_THRESHOLD_MS, generateResourceFromTopic, isCancelledJob, isCompleteJob, isFailedJob, isPendingJob, isRunningJob, processAssessmentJob, processCommentJob, processGenerationJob, processHighlightJob, processReferenceJob, processTagJob };
|
|
2439
2939
|
//# sourceMappingURL=index.js.map
|
|
2440
2940
|
//# sourceMappingURL=index.js.map
|