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