@semiont/jobs 0.6.1 → 0.6.3

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 CHANGED
@@ -369,6 +369,23 @@ interface JobQueue {
369
369
  * starvation, 2026-09-15).
370
370
  */
371
371
  declare const JOB_QUEUE_EMITS: readonly ["job:queued"];
372
+ /**
373
+ * How long a job's record survives after it reaches a terminal state
374
+ * (complete/failed/cancelled), and how often a driver enforces that.
375
+ *
376
+ * ONE pair for every driver. The window is a CONTRACT fact — a caller that
377
+ * reads a finished job's result gets the same day whichever backing store the
378
+ * stack runs — and the cadence is what turns the window from an aspiration
379
+ * into a promise. Restated per driver the two numbers drift, and the drift is
380
+ * invisible until someone compares two deployments.
381
+ *
382
+ * The METHOD that enforces retention is not on the interface and cannot be:
383
+ * it is an unlink in one driver and a stream purge in another. The NUMBER is
384
+ * here because it is the same number.
385
+ */
386
+ declare const TERMINAL_JOB_RETENTION_MS: number;
387
+ /** How often a driver sweeps for records past `TERMINAL_JOB_RETENTION_MS`. */
388
+ declare const TERMINAL_JOB_SWEEP_INTERVAL_MS: number;
372
389
 
373
390
  /**
374
391
  * Job Queue Manager
@@ -500,9 +517,12 @@ declare class FsJobQueue implements JobQueue {
500
517
  */
501
518
  recoverStaleRunningJobs(): Promise<number>;
502
519
  /**
503
- * Clean up old completed/failed jobs (older than retention period)
520
+ * Delete terminal jobs whose `completedAt` is older than `retentionMs`.
521
+ * The window is a parameter with no default: the ONE value the queue runs
522
+ * on is `TERMINAL_JOB_RETENTION_MS`, and a default here would be a second
523
+ * copy of it that nobody notices going stale.
504
524
  */
505
- cleanupOldJobs(retentionHours?: number): Promise<number>;
525
+ cleanupOldJobs(retentionMs: number): Promise<number>;
506
526
  /**
507
527
  * Get job file path
508
528
  */
@@ -530,7 +550,10 @@ declare class FsJobQueue implements JobQueue {
530
550
  * - **KV bucket `jobs`** is the AUTHORITATIVE operational state: one entry
531
551
  * per job (`{ job, lastProgressAt }`), every transition a revision-CAS —
532
552
  * which is what makes `claimJob` atomic: simultaneous claims race on one
533
- * revision and exactly one update wins.
553
+ * revision and exactly one update wins. It holds LIVE jobs plus a day of
554
+ * concluded ones, not a history: `pruneTerminalJobs` is the retention the
555
+ * fs driver's janitor holds, and without it every scan over the bucket
556
+ * — `getStats` is one, on the metrics interval — grows forever.
534
557
  * - **Stream `JOBS`** (subjects `jobs.<category>.<type>`, work-queue
535
558
  * retention) is the DELIVERY vehicle and redelivery timer. A delivered
536
559
  * message is the lease this process holds for a job; `working()`
@@ -598,6 +621,7 @@ declare class JetStreamJobQueue implements JobQueue {
598
621
  private reconciling;
599
622
  private tick;
600
623
  private ticking;
624
+ private cleanupTimer;
601
625
  private readonly lastProgressWrite;
602
626
  private readonly staleRunningMs;
603
627
  private readonly ackWaitMs;
@@ -642,6 +666,11 @@ declare class JetStreamJobQueue implements JobQueue {
642
666
  recordProgress(jobIdArg: JobId, progress: Record<string, unknown>): Promise<void>;
643
667
  cancelPendingJobs(category: 'annotation' | 'generation'): Promise<number>;
644
668
  cancelJob(jobIdArg: JobId): Promise<boolean>;
669
+ /**
670
+ * Counts by status. The three terminal counts are the RETENTION WINDOW, not
671
+ * lifetime totals — `pruneTerminalJobs` drops a record a day after it
672
+ * concluded — so they answer "recently finished", never "finished ever".
673
+ */
645
674
  getStats(): Promise<{
646
675
  pending: number;
647
676
  running: number;
@@ -656,6 +685,33 @@ declare class JetStreamJobQueue implements JobQueue {
656
685
  * through here; that is AckWait redelivery.
657
686
  */
658
687
  recoverStaleRunningJobs(): Promise<number>;
688
+ /**
689
+ * Terminal-job retention: the fs driver's hourly janitor, in this driver's
690
+ * terms. Without it the bucket keeps every job it has ever seen, and
691
+ * `getStats` — an OTel observable gauge, so a full bucket scan every metric
692
+ * interval — pays for all of them forever. Measured on a live stack
693
+ * 2026-09-24: 139 concluded records re-read every 30 seconds by a knowledge
694
+ * base that had done very little work.
695
+ *
696
+ * A SWEEP AND NOT A BUCKET TTL, deliberately. `ttl` on `views.kv()` is a
697
+ * BUCKET limit — the backing stream's `max_age` — so it expires every key
698
+ * by age, not the terminal ones by status: a pending job nobody claimed for
699
+ * a day, a running job whose worker went quiet, both evaporate alongside
700
+ * the finished ones. That is not a slower version of this bug, it is a
701
+ * worse one. This bucket is the AUTHORITATIVE record: with the entry gone
702
+ * `read` returns null, every CAS falls through to its `onMissing`, and the
703
+ * next delivery for that job is `term()`ed as unknown — the job disappears
704
+ * with no failure and no trace. The rule retention needs to express is
705
+ * about STATUS, and status is exactly what a bucket-wide clock cannot see.
706
+ * (A per-message TTL set at the terminal write would express it, but that
707
+ * is nats-server 2.11 plus a client that exposes it; nats.js 2.x does not.)
708
+ *
709
+ * Terminal is absorbing — no transition leaves `complete`/`failed`/
710
+ * `cancelled` — so a record this sweep reads as expired cannot come back to
711
+ * life under it, and the lease was settled at the transition that made it
712
+ * terminal.
713
+ */
714
+ pruneTerminalJobs(retentionMs: number): Promise<number>;
659
715
  /** The heartbeat body — extend live leases, settle concluded ones. */
660
716
  private reconcileHeld;
661
717
  /**
@@ -1077,5 +1133,5 @@ declare const WORKER_CONSUMED_BROADCASTS: readonly ["job:queued", "job:cancel-re
1077
1133
  */
1078
1134
  declare const WORKER_CHANNELS: readonly (keyof EventMap)[];
1079
1135
 
1080
- export { AnnotationDetection, FsJobQueue, JOBS_STREAM_SUBJECTS, JOB_QUEUE_EMITS, JetStreamJobQueue, STALL_THRESHOLD_MS, WORKER_CHANNELS, WORKER_CONSUMED_BROADCASTS, generateResourceFromTopic, isCancelledJob, isCompleteJob, isFailedJob, isPendingJob, isRunningJob, processAssessmentJob, processCommentJob, processGenerationJob, processHighlightJob, processReferenceJob, processTagJob };
1136
+ export { AnnotationDetection, FsJobQueue, JOBS_STREAM_SUBJECTS, JOB_QUEUE_EMITS, JetStreamJobQueue, STALL_THRESHOLD_MS, TERMINAL_JOB_RETENTION_MS, TERMINAL_JOB_SWEEP_INTERVAL_MS, WORKER_CHANNELS, WORKER_CONSUMED_BROADCASTS, generateResourceFromTopic, isCancelledJob, isCompleteJob, isFailedJob, isPendingJob, isRunningJob, processAssessmentJob, processCommentJob, processGenerationJob, processHighlightJob, processReferenceJob, processTagJob };
1081
1137
  export type { AnyJob, AssessmentDetectionJob, AssessmentDetectionParams, AssessmentDetectionProgress, CancelledJob, CommentDetectionJob, CommentDetectionParams, CommentDetectionProgress, CompleteJob, DetectionJob, DetectionParams, DetectionProgress, FailedJob, GenerationJob, GenerationResult, HighlightDetectionJob, HighlightDetectionParams, HighlightDetectionProgress, JetStreamJobQueueOptions, JobMetadata, JobQueryFilters, JobQueue, JobStatus, JobType, OnProgress, PendingJob, ProcessorResult, RunningJob, TagDetectionJob, TagDetectionParams, TagDetectionProgress, YieldProgress };
package/dist/index.js CHANGED
@@ -14,6 +14,8 @@ import '@semiont/http-transport';
14
14
 
15
15
  // src/job-queue-interface.ts
16
16
  var JOB_QUEUE_EMITS = ["job:queued"];
17
+ var TERMINAL_JOB_RETENTION_MS = 24 * 60 * 60 * 1e3;
18
+ var TERMINAL_JOB_SWEEP_INTERVAL_MS = 60 * 60 * 1e3;
17
19
 
18
20
  // src/will-retry.ts
19
21
  function willRetryAfter(metadata, failureClass) {
@@ -39,8 +41,6 @@ function mergeUnitCursors(existing, incoming, completed) {
39
41
  var REANNOUNCE_INTERVAL_MS = 3e4;
40
42
  var STALE_RUNNING_MS = 30 * 6e4;
41
43
  var PROGRESS_WRITE_MIN_INTERVAL_MS = 5e3;
42
- var RETENTION_HOURS = 24;
43
- var CLEANUP_INTERVAL_MS = 36e5;
44
44
  var FsJobQueue = class {
45
45
  constructor(state, logger, eventBus) {
46
46
  this.eventBus = eventBus;
@@ -82,12 +82,12 @@ var FsJobQueue = class {
82
82
  }
83
83
  if (!this.cleanupTimer) {
84
84
  this.cleanupTimer = setInterval(() => {
85
- this.cleanupOldJobs(RETENTION_HOURS).catch((error) => {
85
+ this.cleanupOldJobs(TERMINAL_JOB_RETENTION_MS).catch((error) => {
86
86
  this.logger.warn("Job retention cleanup failed", {
87
87
  error: error instanceof Error ? error.message : String(error)
88
88
  });
89
89
  });
90
- }, CLEANUP_INTERVAL_MS);
90
+ }, TERMINAL_JOB_SWEEP_INTERVAL_MS);
91
91
  this.cleanupTimer.unref?.();
92
92
  }
93
93
  this.logger.info("Job queue initialized");
@@ -450,10 +450,13 @@ var FsJobQueue = class {
450
450
  return recovered;
451
451
  }
452
452
  /**
453
- * Clean up old completed/failed jobs (older than retention period)
453
+ * Delete terminal jobs whose `completedAt` is older than `retentionMs`.
454
+ * The window is a parameter with no default: the ONE value the queue runs
455
+ * on is `TERMINAL_JOB_RETENTION_MS`, and a default here would be a second
456
+ * copy of it that nobody notices going stale.
454
457
  */
455
- async cleanupOldJobs(retentionHours = 24) {
456
- const cutoffTime = Date.now() - retentionHours * 60 * 60 * 1e3;
458
+ async cleanupOldJobs(retentionMs) {
459
+ const cutoffTime = Date.now() - retentionMs;
457
460
  let deletedCount = 0;
458
461
  const cleanupStatuses = ["complete", "failed", "cancelled"];
459
462
  for (const status of cleanupStatuses) {
@@ -514,6 +517,8 @@ var FsJobQueue = class {
514
517
  var STREAM = "JOBS";
515
518
  var CONSUMER = "gateway-claims";
516
519
  var BUCKET = "jobs";
520
+ var BUCKET_STREAM = `KV_${BUCKET}`;
521
+ var bucketSubject = (key) => `$KV.${BUCKET}.${key}`;
517
522
  var PROGRESS_WRITE_MIN_INTERVAL_MS2 = 5e3;
518
523
  var MAX_CAS_ATTEMPTS = 20;
519
524
  var enc = new TextEncoder();
@@ -549,6 +554,7 @@ var JetStreamJobQueue = class {
549
554
  reconciling = false;
550
555
  tick = null;
551
556
  ticking = false;
557
+ cleanupTimer = null;
552
558
  lastProgressWrite = /* @__PURE__ */ new Map();
553
559
  staleRunningMs;
554
560
  ackWaitMs;
@@ -633,6 +639,14 @@ var JetStreamJobQueue = class {
633
639
  });
634
640
  }, this.tickMs);
635
641
  this.tick.unref?.();
642
+ this.cleanupTimer = setInterval(() => {
643
+ this.pruneTerminalJobs(TERMINAL_JOB_RETENTION_MS).catch((error) => {
644
+ this.logger.warn("Job retention cleanup failed", {
645
+ error: error instanceof Error ? error.message : String(error)
646
+ });
647
+ });
648
+ }, TERMINAL_JOB_SWEEP_INTERVAL_MS);
649
+ this.cleanupTimer.unref?.();
636
650
  }
637
651
  /** An outage is never silent: one line down, one line back, one line if the client gives up. */
638
652
  watchConnection() {
@@ -661,6 +675,8 @@ var JetStreamJobQueue = class {
661
675
  this.heartbeat = null;
662
676
  if (this.tick) clearInterval(this.tick);
663
677
  this.tick = null;
678
+ if (this.cleanupTimer) clearInterval(this.cleanupTimer);
679
+ this.cleanupTimer = null;
664
680
  this.held.clear();
665
681
  void this.nc?.close();
666
682
  }
@@ -940,6 +956,11 @@ var JetStreamJobQueue = class {
940
956
  if (done) this.settleLease(jobIdArg, "term");
941
957
  return done;
942
958
  }
959
+ /**
960
+ * Counts by status. The three terminal counts are the RETENTION WINDOW, not
961
+ * lifetime totals — `pruneTerminalJobs` drops a record a day after it
962
+ * concluded — so they answer "recently finished", never "finished ever".
963
+ */
943
964
  async getStats() {
944
965
  const stats = { pending: 0, running: 0, complete: 0, failed: 0, cancelled: 0 };
945
966
  for (const key of await this.allKeys()) {
@@ -972,6 +993,50 @@ var JetStreamJobQueue = class {
972
993
  }
973
994
  return recovered;
974
995
  }
996
+ /**
997
+ * Terminal-job retention: the fs driver's hourly janitor, in this driver's
998
+ * terms. Without it the bucket keeps every job it has ever seen, and
999
+ * `getStats` — an OTel observable gauge, so a full bucket scan every metric
1000
+ * interval — pays for all of them forever. Measured on a live stack
1001
+ * 2026-09-24: 139 concluded records re-read every 30 seconds by a knowledge
1002
+ * base that had done very little work.
1003
+ *
1004
+ * A SWEEP AND NOT A BUCKET TTL, deliberately. `ttl` on `views.kv()` is a
1005
+ * BUCKET limit — the backing stream's `max_age` — so it expires every key
1006
+ * by age, not the terminal ones by status: a pending job nobody claimed for
1007
+ * a day, a running job whose worker went quiet, both evaporate alongside
1008
+ * the finished ones. That is not a slower version of this bug, it is a
1009
+ * worse one. This bucket is the AUTHORITATIVE record: with the entry gone
1010
+ * `read` returns null, every CAS falls through to its `onMissing`, and the
1011
+ * next delivery for that job is `term()`ed as unknown — the job disappears
1012
+ * with no failure and no trace. The rule retention needs to express is
1013
+ * about STATUS, and status is exactly what a bucket-wide clock cannot see.
1014
+ * (A per-message TTL set at the terminal write would express it, but that
1015
+ * is nats-server 2.11 plus a client that exposes it; nats.js 2.x does not.)
1016
+ *
1017
+ * Terminal is absorbing — no transition leaves `complete`/`failed`/
1018
+ * `cancelled` — so a record this sweep reads as expired cannot come back to
1019
+ * life under it, and the lease was settled at the transition that made it
1020
+ * terminal.
1021
+ */
1022
+ async pruneTerminalJobs(retentionMs) {
1023
+ const cutoff = Date.now() - retentionMs;
1024
+ let pruned = 0;
1025
+ for (const key of await this.allKeys()) {
1026
+ const envelope = await this.read(jobId(key));
1027
+ if (!envelope) continue;
1028
+ const { job } = envelope;
1029
+ if (job.status !== "complete" && job.status !== "failed" && job.status !== "cancelled") continue;
1030
+ if (Date.parse(job.completedAt) >= cutoff) continue;
1031
+ await this.jsm.streams.purge(BUCKET_STREAM, { filter: bucketSubject(key) });
1032
+ this.lastProgressWrite.delete(key);
1033
+ pruned++;
1034
+ }
1035
+ if (pruned > 0) {
1036
+ this.logger.info("Jobs cleaned up", { deletedCount: pruned });
1037
+ }
1038
+ return pruned;
1039
+ }
975
1040
  /** The heartbeat body — extend live leases, settle concluded ones. */
976
1041
  async reconcileHeld() {
977
1042
  for (const [id, held] of [...this.held]) {
@@ -2975,6 +3040,6 @@ var WORKER_CHANNELS = [
2975
3040
  ...WORKER_CONSUMED_BROADCASTS
2976
3041
  ];
2977
3042
 
2978
- export { AnnotationDetection, FsJobQueue, JOBS_STREAM_SUBJECTS, JOB_QUEUE_EMITS, JetStreamJobQueue, STALL_THRESHOLD_MS, WORKER_CHANNELS, WORKER_CONSUMED_BROADCASTS, generateResourceFromTopic, isCancelledJob, isCompleteJob, isFailedJob, isPendingJob, isRunningJob, processAssessmentJob, processCommentJob, processGenerationJob, processHighlightJob, processReferenceJob, processTagJob };
3043
+ export { AnnotationDetection, FsJobQueue, JOBS_STREAM_SUBJECTS, JOB_QUEUE_EMITS, JetStreamJobQueue, STALL_THRESHOLD_MS, TERMINAL_JOB_RETENTION_MS, TERMINAL_JOB_SWEEP_INTERVAL_MS, WORKER_CHANNELS, WORKER_CONSUMED_BROADCASTS, generateResourceFromTopic, isCancelledJob, isCompleteJob, isFailedJob, isPendingJob, isRunningJob, processAssessmentJob, processCommentJob, processGenerationJob, processHighlightJob, processReferenceJob, processTagJob };
2979
3044
  //# sourceMappingURL=index.js.map
2980
3045
  //# sourceMappingURL=index.js.map