sealkeep 0.8.1 → 0.11.0
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/ARCHITECTURE.md +163 -14
- package/CHANGELOG.md +244 -1
- package/CONTROL_PLANE.md +2 -2
- package/LICENSE +1 -1
- package/README.md +108 -22
- package/THIRD_PARTY.md +2 -2
- package/THREAT_MODEL.md +23 -4
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
- package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +4 -4
- package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
- package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
- package/dist/site/index.html +1808 -1904
- package/dist/site/llms.txt +67 -0
- package/dist/site/trust/architecture-data-flow.html +53 -0
- package/dist/site/trust/audit-roadmap.html +37 -0
- package/dist/site/trust/deployment-responsibility.html +11 -0
- package/dist/site/trust/dpa-sample.html +30 -0
- package/dist/site/trust/release-provenance.html +21 -0
- package/dist/site/trust/subprocessors.html +15 -0
- package/dist/site/trust/threat-model.html +34 -0
- package/dist/site/trust/toms.html +41 -0
- package/dist/site/trust-document.css +32 -0
- package/dist/site/trust.html +73 -0
- package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
- package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
- package/dist/site/visual/index.html +18 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +9 -0
- package/dist/src/activity.js +90 -1
- package/dist/src/adapters.d.ts +175 -5
- package/dist/src/adapters.js +961 -73
- package/dist/src/agent-context.d.ts +135 -0
- package/dist/src/agent-context.js +1059 -0
- package/dist/src/archive-copies.d.ts +47 -0
- package/dist/src/archive-copies.js +179 -0
- package/dist/src/audit.d.ts +1 -1
- package/dist/src/audit.js +29 -4
- package/dist/src/autopilot.d.ts +52 -7
- package/dist/src/autopilot.js +143 -25
- package/dist/src/background-bandwidth.d.ts +46 -0
- package/dist/src/background-bandwidth.js +301 -0
- package/dist/src/background-cpu.d.ts +82 -0
- package/dist/src/background-cpu.js +212 -0
- package/dist/src/background-worker-error.d.ts +12 -0
- package/dist/src/background-worker-error.js +18 -0
- package/dist/src/branding.d.ts +24 -1
- package/dist/src/branding.js +31 -1
- package/dist/src/bridge.d.ts +233 -0
- package/dist/src/bridge.js +604 -0
- package/dist/src/byte-stream.d.ts +91 -0
- package/dist/src/byte-stream.js +385 -0
- package/dist/src/chunk-store.d.ts +41 -8
- package/dist/src/chunk-store.js +161 -65
- package/dist/src/cli.js +1746 -166
- package/dist/src/cloud.d.ts +841 -31
- package/dist/src/cloud.js +3196 -277
- package/dist/src/context-background.d.ts +37 -0
- package/dist/src/context-background.js +309 -0
- package/dist/src/context-drain-child.d.ts +1 -0
- package/dist/src/context-drain-child.js +98 -0
- package/dist/src/context-reader.d.ts +118 -0
- package/dist/src/context-reader.js +447 -0
- package/dist/src/control-plane/auth.d.ts +32 -4
- package/dist/src/control-plane/auth.js +85 -24
- package/dist/src/control-plane/server.js +19 -6
- package/dist/src/control-plane.d.ts +17 -1
- package/dist/src/control-plane.js +32 -6
- package/dist/src/crypto.d.ts +1 -1
- package/dist/src/crypto.js +5 -5
- package/dist/src/daemon-lease.d.ts +70 -0
- package/dist/src/daemon-lease.js +420 -0
- package/dist/src/daemon.d.ts +94 -1
- package/dist/src/daemon.js +1082 -105
- package/dist/src/darwin-service-policy.d.ts +41 -0
- package/dist/src/darwin-service-policy.js +60 -0
- package/dist/src/dashboard-cli.js +15 -15
- package/dist/src/device-authorization.d.ts +37 -0
- package/dist/src/device-authorization.js +199 -0
- package/dist/src/device-enrollment.d.ts +91 -0
- package/dist/src/device-enrollment.js +349 -0
- package/dist/src/disk.d.ts +17 -12
- package/dist/src/disk.js +43 -17
- package/dist/src/doctor.d.ts +35 -1
- package/dist/src/doctor.js +316 -41
- package/dist/src/durable-ticket-lock.d.ts +24 -0
- package/dist/src/durable-ticket-lock.js +232 -0
- package/dist/src/enroll.d.ts +1 -1
- package/dist/src/enroll.js +13 -7
- package/dist/src/env.d.ts +10 -1
- package/dist/src/env.js +11 -3
- package/dist/src/errors.d.ts +8 -8
- package/dist/src/errors.js +6 -6
- package/dist/src/flush.d.ts +12 -0
- package/dist/src/flush.js +37 -0
- package/dist/src/heartbeat.d.ts +86 -12
- package/dist/src/heartbeat.js +415 -29
- package/dist/src/index-background-watchdog.d.ts +1 -0
- package/dist/src/index-background-watchdog.js +94 -0
- package/dist/src/index-background-work.d.ts +21 -0
- package/dist/src/index-background-work.js +25 -0
- package/dist/src/index-background.d.ts +64 -0
- package/dist/src/index-background.js +394 -0
- package/dist/src/index-build-child.d.ts +1 -0
- package/dist/src/index-build-child.js +109 -0
- package/dist/src/index-manifest.d.ts +52 -0
- package/dist/src/index-manifest.js +444 -0
- package/dist/src/index-publication-proof.d.ts +84 -0
- package/dist/src/index-publication-proof.js +380 -0
- package/dist/src/index-publication-state.d.ts +149 -0
- package/dist/src/index-publication-state.js +696 -0
- package/dist/src/index-publication-verifier.d.ts +89 -0
- package/dist/src/index-publication-verifier.js +341 -0
- package/dist/src/index-publish.d.ts +62 -0
- package/dist/src/index-publish.js +540 -0
- package/dist/src/index-scratch-cleanup.d.ts +19 -0
- package/dist/src/index-scratch-cleanup.js +166 -0
- package/dist/src/index-segment-types.d.ts +132 -0
- package/dist/src/index-segment-types.js +21 -0
- package/dist/src/index-segments.d.ts +9 -0
- package/dist/src/index-segments.js +516 -0
- package/dist/src/index-store.d.ts +123 -0
- package/dist/src/index-store.js +495 -0
- package/dist/src/index-sync.d.ts +91 -15
- package/dist/src/index-sync.js +286 -53
- package/dist/src/index-upgrade-publication.d.ts +30 -0
- package/dist/src/index-upgrade-publication.js +179 -0
- package/dist/src/integration-manager.d.ts +32 -0
- package/dist/src/integration-manager.js +394 -0
- package/dist/src/leakscan.js +1 -1
- package/dist/src/local-api.d.ts +56 -15
- package/dist/src/local-api.js +4979 -541
- package/dist/src/machine-settings.d.ts +51 -0
- package/dist/src/machine-settings.js +166 -0
- package/dist/src/managed-chunks.d.ts +5 -2
- package/dist/src/managed-chunks.js +14 -14
- package/dist/src/mcp-install.d.ts +11 -9
- package/dist/src/mcp-install.js +73 -28
- package/dist/src/mcp-workspace.d.ts +18 -0
- package/dist/src/mcp-workspace.js +50 -0
- package/dist/src/mcp.js +294 -25
- package/dist/src/migrate.d.ts +1 -0
- package/dist/src/migrate.js +33 -5
- package/dist/src/notify.d.ts +1 -1
- package/dist/src/notify.js +5 -5
- package/dist/src/offload.d.ts +201 -14
- package/dist/src/offload.js +1848 -140
- package/dist/src/onboarding.d.ts +8 -1
- package/dist/src/onboarding.js +4 -4
- package/dist/src/packages.d.ts +2 -2
- package/dist/src/packages.js +10 -2
- package/dist/src/passkey.d.ts +0 -1
- package/dist/src/passkey.js +2 -7
- package/dist/src/password-lock.d.ts +2 -2
- package/dist/src/password-lock.js +6 -6
- package/dist/src/paths.d.ts +2 -0
- package/dist/src/paths.js +2 -0
- package/dist/src/presence.d.ts +86 -0
- package/dist/src/presence.js +240 -0
- package/dist/src/progress-deadline.d.ts +21 -0
- package/dist/src/progress-deadline.js +91 -0
- package/dist/src/project-repair.d.ts +55 -0
- package/dist/src/project-repair.js +131 -0
- package/dist/src/providers/gcs.d.ts +28 -7
- package/dist/src/providers/gcs.js +35 -24
- package/dist/src/providers/gdrive.d.ts +71 -8
- package/dist/src/providers/gdrive.js +223 -50
- package/dist/src/providers/index.d.ts +11 -3
- package/dist/src/providers/index.js +30 -10
- package/dist/src/providers/s3.d.ts +30 -8
- package/dist/src/providers/s3.js +41 -30
- package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
- package/dist/src/providers/safe-storage-fetch.js +72 -0
- package/dist/src/queue.d.ts +136 -19
- package/dist/src/queue.js +862 -96
- package/dist/src/reclaim-transaction.d.ts +156 -0
- package/dist/src/reclaim-transaction.js +1027 -0
- package/dist/src/recovery-codes.d.ts +32 -0
- package/dist/src/recovery-codes.js +338 -0
- package/dist/src/recovery.js +12 -9
- package/dist/src/rehydrate.d.ts +25 -22
- package/dist/src/rehydrate.js +319 -23
- package/dist/src/restore.d.ts +57 -4
- package/dist/src/restore.js +272 -36
- package/dist/src/resume-stub.d.ts +92 -0
- package/dist/src/resume-stub.js +417 -0
- package/dist/src/retention.d.ts +98 -7
- package/dist/src/retention.js +1066 -62
- package/dist/src/rotate.js +3 -3
- package/dist/src/search.d.ts +566 -8
- package/dist/src/search.js +5897 -290
- package/dist/src/secrets.d.ts +51 -7
- package/dist/src/secrets.js +316 -24
- package/dist/src/service.d.ts +49 -11
- package/dist/src/service.js +776 -35
- package/dist/src/share.js +3 -3
- package/dist/src/shared-spaces.d.ts +98 -0
- package/dist/src/shared-spaces.js +214 -0
- package/dist/src/source-reader.d.ts +73 -0
- package/dist/src/source-reader.js +715 -0
- package/dist/src/spool.d.ts +1 -1
- package/dist/src/spool.js +1 -1
- package/dist/src/start-tui.js +2 -1
- package/dist/src/start.js +2 -2
- package/dist/src/storage-endpoint.d.ts +21 -0
- package/dist/src/storage-endpoint.js +122 -0
- package/dist/src/storage-setup.js +12 -12
- package/dist/src/storage-targets.d.ts +109 -6
- package/dist/src/storage-targets.js +975 -67
- package/dist/src/stream-to-cloud.d.ts +5 -1
- package/dist/src/stream-to-cloud.js +34 -14
- package/dist/src/sync-rules.d.ts +31 -6
- package/dist/src/sync-rules.js +153 -14
- package/dist/src/team-backfill-scheduling.d.ts +8 -0
- package/dist/src/team-backfill-scheduling.js +33 -0
- package/dist/src/team-backfill.d.ts +116 -0
- package/dist/src/team-backfill.js +1429 -0
- package/dist/src/team-index-cache.d.ts +16 -0
- package/dist/src/team-index-cache.js +152 -0
- package/dist/src/team-offboarding.d.ts +38 -0
- package/dist/src/team-offboarding.js +1043 -0
- package/dist/src/team-presence.d.ts +127 -0
- package/dist/src/team-presence.js +904 -0
- package/dist/src/team-publication-policy.d.ts +20 -0
- package/dist/src/team-publication-policy.js +140 -0
- package/dist/src/team-realtime.d.ts +68 -0
- package/dist/src/team-realtime.js +816 -0
- package/dist/src/team-source-facts-cache.d.ts +23 -0
- package/dist/src/team-source-facts-cache.js +255 -0
- package/dist/src/trash.d.ts +1 -1
- package/dist/src/trash.js +2 -2
- package/dist/src/tui.js +11 -12
- package/dist/src/types.d.ts +173 -7
- package/dist/src/types.js +20 -0
- package/dist/src/ui-server.d.ts +163 -35
- package/dist/src/ui-server.js +712 -72
- package/dist/src/ui.d.ts +1 -2
- package/dist/src/ui.js +1 -2
- package/dist/src/upload.d.ts +27 -0
- package/dist/src/upload.js +383 -43
- package/dist/src/vault.d.ts +235 -29
- package/dist/src/vault.js +1783 -190
- package/dist/src/watcher.d.ts +7 -1
- package/dist/src/watcher.js +198 -55
- package/dist/src/worker.d.ts +27 -3
- package/dist/src/worker.js +274 -55
- package/package.json +33 -12
- package/scripts/native-reboot-rehearsal.mjs +90 -0
- package/web/app.js +6032 -343
- package/web/bootstrap.js +17 -0
- package/web/index.html +255 -57
- package/web/rail.js +317 -40
- package/web/retention.html +2 -2
- package/web/rules-view.js +188 -16
- package/web/sessions-view.js +485 -62
- package/web/sessions.html +2 -2
- package/web/setup-api.js +152 -29
- package/web/setup-logic.js +68 -9
- package/web/setup.html +113 -44
- package/web/setup.js +604 -71
- package/web/style.css +513 -98
- package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
package/dist/src/queue.js
CHANGED
|
@@ -1,8 +1,162 @@
|
|
|
1
|
-
import { link, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
1
|
+
import { link, mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
|
+
import { classifyDaemonProcessGeneration, daemonProcessIdentity } from "./daemon-lease.js";
|
|
4
5
|
import { fail } from "./errors.js";
|
|
5
6
|
const JOB_VERSION = 1;
|
|
7
|
+
/**
|
|
8
|
+
* The daemon worker and local API intentionally own separate `ArchiveQueue`
|
|
9
|
+
* instances. Keep their read snapshot at module scope so a status/dashboard
|
|
10
|
+
* burst still pays for one mature-queue walk, not one walk per object.
|
|
11
|
+
*
|
|
12
|
+
* This is a bounded cache because tests and embedded callers can construct
|
|
13
|
+
* queues for many short-lived roots in one process. Production ordinarily has
|
|
14
|
+
* one. An active read is never evicted.
|
|
15
|
+
*/
|
|
16
|
+
const sharedQueueReads = new Map();
|
|
17
|
+
const MAX_SHARED_QUEUE_ROOTS = 32;
|
|
18
|
+
// A mature startup scan can revisit thousands of already-settled rows. Even
|
|
19
|
+
// when every stat/read is hot in the filesystem cache, a chain of immediately
|
|
20
|
+
// fulfilled promises remains one microtask turn and can keep the HTTP poll
|
|
21
|
+
// phase from accepting a local dashboard request. Yield on a fixed work bound,
|
|
22
|
+
// not a time measurement: responsiveness must not depend on disk speed.
|
|
23
|
+
const QUEUE_EVENT_LOOP_YIELD_EVERY = 32;
|
|
24
|
+
const CANONICAL_JOB_FILE = /^([a-f0-9]{64})\.json$/;
|
|
25
|
+
const LEGACY_LOCK_STALE_MS = 30_000;
|
|
26
|
+
const DEFAULT_LOCK_ACQUIRE_MS = 30_000;
|
|
27
|
+
let currentQueueProcessIdentity;
|
|
28
|
+
function queueWorkYield(position) {
|
|
29
|
+
if ((position + 1) % QUEUE_EVENT_LOOP_YIELD_EVERY !== 0)
|
|
30
|
+
return undefined;
|
|
31
|
+
return new Promise((ready) => setImmediate(ready));
|
|
32
|
+
}
|
|
33
|
+
function trimSharedQueueReads(protectedKey) {
|
|
34
|
+
if (sharedQueueReads.size <= MAX_SHARED_QUEUE_ROOTS)
|
|
35
|
+
return;
|
|
36
|
+
for (const [candidate, value] of sharedQueueReads) {
|
|
37
|
+
if (candidate === protectedKey || value.inFlight || value.activeReaders > 0)
|
|
38
|
+
continue;
|
|
39
|
+
sharedQueueReads.delete(candidate);
|
|
40
|
+
if (sharedQueueReads.size <= MAX_SHARED_QUEUE_ROOTS)
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function sharedQueueReadState(root) {
|
|
45
|
+
const key = resolve(root);
|
|
46
|
+
let state = sharedQueueReads.get(key);
|
|
47
|
+
if (!state) {
|
|
48
|
+
state = { localRevision: 0, activeReaders: 0 };
|
|
49
|
+
sharedQueueReads.set(key, state);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
// Map insertion order doubles as a tiny LRU without another timer/handle.
|
|
53
|
+
sharedQueueReads.delete(key);
|
|
54
|
+
sharedQueueReads.set(key, state);
|
|
55
|
+
}
|
|
56
|
+
trimSharedQueueReads(key);
|
|
57
|
+
return state;
|
|
58
|
+
}
|
|
59
|
+
function invalidateSharedQueueRead(root) {
|
|
60
|
+
const state = sharedQueueReads.get(resolve(root));
|
|
61
|
+
if (!state)
|
|
62
|
+
return;
|
|
63
|
+
state.localRevision += 1;
|
|
64
|
+
state.cached = undefined;
|
|
65
|
+
}
|
|
66
|
+
function validTimestamp(value) {
|
|
67
|
+
return typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
68
|
+
}
|
|
69
|
+
function validJob(value, expectedId) {
|
|
70
|
+
if (!value || typeof value !== "object")
|
|
71
|
+
return false;
|
|
72
|
+
const job = value;
|
|
73
|
+
if (job.version !== JOB_VERSION || job.id !== expectedId
|
|
74
|
+
|| !CANONICAL_JOB_FILE.test(`${job.id}.json`)
|
|
75
|
+
|| !["ready", "leased", "done", "failed"].includes(job.status)
|
|
76
|
+
|| typeof job.agent !== "string" || !job.agent
|
|
77
|
+
|| typeof job.sourcePath !== "string" || !job.sourcePath
|
|
78
|
+
|| typeof job.event !== "string" || !job.event
|
|
79
|
+
|| !Number.isSafeInteger(job.sourceBytes) || (job.sourceBytes ?? -1) < 0
|
|
80
|
+
|| !validTimestamp(job.sourceModifiedAt)
|
|
81
|
+
|| !validTimestamp(job.enqueuedAt)
|
|
82
|
+
|| !validTimestamp(job.updatedAt)
|
|
83
|
+
|| !Number.isSafeInteger(job.attempts) || (job.attempts ?? -1) < 0
|
|
84
|
+
|| !validTimestamp(job.nextAttemptAt)
|
|
85
|
+
|| (job.sessionId !== undefined && typeof job.sessionId !== "string")
|
|
86
|
+
|| (job.supersededBy !== undefined && typeof job.supersededBy !== "string"))
|
|
87
|
+
return false;
|
|
88
|
+
if (job.lease !== undefined && (typeof job.lease.id !== "string" || !job.lease.id
|
|
89
|
+
|| typeof job.lease.owner !== "string" || !job.lease.owner
|
|
90
|
+
|| !validTimestamp(job.lease.acquiredAt) || !validTimestamp(job.lease.expiresAt)))
|
|
91
|
+
return false;
|
|
92
|
+
if (job.progress !== undefined && (!Number.isFinite(job.progress.bytes) || job.progress.bytes < 0
|
|
93
|
+
|| !Number.isFinite(job.progress.of) || job.progress.of < 0))
|
|
94
|
+
return false;
|
|
95
|
+
if (job.lastError !== undefined && (typeof job.lastError.code !== "string" || typeof job.lastError.message !== "string"))
|
|
96
|
+
return false;
|
|
97
|
+
if (job.result !== undefined && (typeof job.result.archiveId !== "string" || !job.result.archiveId
|
|
98
|
+
|| typeof job.result.ciphertextSha256 !== "string" || !job.result.ciphertextSha256
|
|
99
|
+
|| !Number.isFinite(job.result.bytes) || job.result.bytes < 0
|
|
100
|
+
|| !validTimestamp(job.result.completedAt)
|
|
101
|
+
|| (job.result.deduplicated !== undefined && typeof job.result.deduplicated !== "boolean")))
|
|
102
|
+
return false;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
function parseJobLockOwner(raw) {
|
|
106
|
+
if (!raw)
|
|
107
|
+
return null;
|
|
108
|
+
try {
|
|
109
|
+
const value = JSON.parse(raw);
|
|
110
|
+
if (value.version !== 1 || !Number.isSafeInteger(value.pid) || (value.pid ?? 0) <= 0
|
|
111
|
+
|| typeof value.processIdentity !== "string" || !value.processIdentity || value.processIdentity.length > 512
|
|
112
|
+
|| typeof value.instanceId !== "string" || !/^[a-f0-9-]{36}$/.test(value.instanceId)
|
|
113
|
+
|| !validTimestamp(value.createdAt))
|
|
114
|
+
return null;
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
async function ownProcessIdentity() {
|
|
122
|
+
if (!currentQueueProcessIdentity) {
|
|
123
|
+
const lookup = daemonProcessIdentity(process.pid).then((current) => {
|
|
124
|
+
if (current.state !== "identified")
|
|
125
|
+
throw new Error("Sealkeep could not verify this process generation for the queue lock");
|
|
126
|
+
return current.identity;
|
|
127
|
+
});
|
|
128
|
+
currentQueueProcessIdentity = lookup;
|
|
129
|
+
void lookup.catch(() => {
|
|
130
|
+
// A transient procfs/ps failure must not poison every future queue
|
|
131
|
+
// transition in this long-lived process.
|
|
132
|
+
if (currentQueueProcessIdentity === lookup)
|
|
133
|
+
currentQueueProcessIdentity = undefined;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return currentQueueProcessIdentity;
|
|
137
|
+
}
|
|
138
|
+
async function processIdentityBefore(pid, deadline) {
|
|
139
|
+
const remaining = deadline - performance.now();
|
|
140
|
+
if (remaining <= 0)
|
|
141
|
+
return { state: "timeout" };
|
|
142
|
+
const lookup = (pid === process.pid
|
|
143
|
+
? ownProcessIdentity().then((identity) => ({ state: "identified", identity }))
|
|
144
|
+
: daemonProcessIdentity(pid))
|
|
145
|
+
.catch(() => ({ state: "unknown" }));
|
|
146
|
+
let timer;
|
|
147
|
+
try {
|
|
148
|
+
return await Promise.race([
|
|
149
|
+
lookup,
|
|
150
|
+
new Promise((resolve) => {
|
|
151
|
+
timer = setTimeout(() => resolve({ state: "timeout" }), remaining);
|
|
152
|
+
}),
|
|
153
|
+
]);
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
if (timer)
|
|
157
|
+
clearTimeout(timer);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
6
160
|
export function queueDir(dataDir) { return join(dataDir, "queue"); }
|
|
7
161
|
/**
|
|
8
162
|
* Two lifecycle events describing the same unchanged transcript produce the same
|
|
@@ -23,6 +177,17 @@ export class ArchiveQueue {
|
|
|
23
177
|
maxAttempts;
|
|
24
178
|
backoffMs;
|
|
25
179
|
maxBackoffMs;
|
|
180
|
+
lockAcquireMs;
|
|
181
|
+
/**
|
|
182
|
+
* `hasClaimable` already opened and parsed this record. Keep that answer for
|
|
183
|
+
* the immediately following claim so a mature queue does not enumerate and
|
|
184
|
+
* parse every completed job a second time merely to lease the work it just
|
|
185
|
+
* found. The hint is deliberately in-memory: the job file remains the
|
|
186
|
+
* durable source of truth and is revalidated before a lease is written.
|
|
187
|
+
*/
|
|
188
|
+
claimableHint;
|
|
189
|
+
/** One startup reconciliation absorbs queue files written by older builds. */
|
|
190
|
+
reconciledBacklog = false;
|
|
26
191
|
constructor(dataDir, options = {}) {
|
|
27
192
|
this.root = queueDir(dataDir);
|
|
28
193
|
this.now = options.now ?? Date.now;
|
|
@@ -30,14 +195,164 @@ export class ArchiveQueue {
|
|
|
30
195
|
this.maxAttempts = options.maxAttempts ?? 5;
|
|
31
196
|
this.backoffMs = options.backoffMs ?? 15_000;
|
|
32
197
|
this.maxBackoffMs = options.maxBackoffMs ?? 30 * 60_000;
|
|
198
|
+
this.lockAcquireMs = Math.max(10, options.lockAcquireMs ?? DEFAULT_LOCK_ACQUIRE_MS);
|
|
33
199
|
}
|
|
34
200
|
path(id) { return join(this.root, `${id}.json`); }
|
|
201
|
+
lockPath(id) { return join(this.root, ".locks", `${id}.lock`); }
|
|
35
202
|
stamp() { return new Date(this.now()).toISOString(); }
|
|
203
|
+
/**
|
|
204
|
+
* Serialises one job's read-check-write transition across every Sealkeep
|
|
205
|
+
* process on this machine. Atomic rename protects readers from partial JSON,
|
|
206
|
+
* but it is not compare-and-swap: without this lock, a delayed renewal can
|
|
207
|
+
* overwrite a newer worker's reclaimed lease, and a delayed progress write
|
|
208
|
+
* can resurrect a job after completion.
|
|
209
|
+
*/
|
|
210
|
+
async withJobLock(id, work) {
|
|
211
|
+
// Queue-record transitions are one read-check-rename. A verified owner is
|
|
212
|
+
// never stolen, but callers must not disappear into an infinite retry loop
|
|
213
|
+
// if that owner is wedged or its process identity cannot be inspected.
|
|
214
|
+
const deadline = performance.now() + this.lockAcquireMs;
|
|
215
|
+
const lockDir = join(this.root, ".locks");
|
|
216
|
+
const lock = this.lockPath(id);
|
|
217
|
+
const ownerFile = join(lock, "owner");
|
|
218
|
+
const self = await processIdentityBefore(process.pid, deadline);
|
|
219
|
+
if (self.state !== "identified") {
|
|
220
|
+
return fail("internal", "Sealkeep could not verify this process generation before acquiring the queue lock", {
|
|
221
|
+
jobId: id,
|
|
222
|
+
reason: self.state === "timeout" ? "queue_lock_timeout" : "queue_lock_identity_unavailable",
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
const owner = {
|
|
226
|
+
version: 1,
|
|
227
|
+
pid: process.pid,
|
|
228
|
+
processIdentity: self.identity,
|
|
229
|
+
instanceId: randomUUID(),
|
|
230
|
+
createdAt: new Date().toISOString(),
|
|
231
|
+
};
|
|
232
|
+
const token = `${JSON.stringify(owner)}\n`;
|
|
233
|
+
await mkdir(lockDir, { recursive: true, mode: 0o700 });
|
|
234
|
+
for (;;) {
|
|
235
|
+
if (performance.now() >= deadline) {
|
|
236
|
+
return fail("internal", `Timed out waiting for queue job ${id} to become available`, {
|
|
237
|
+
jobId: id,
|
|
238
|
+
reason: "queue_lock_timeout",
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
const staging = join(lockDir, `.${id}.${randomUUID()}.pending`);
|
|
242
|
+
try {
|
|
243
|
+
// Build a complete, non-empty lock directory out of sight, then publish
|
|
244
|
+
// it with one rename. An observer can therefore never mistake the
|
|
245
|
+
// normal mkdir -> owner-file window for an abandoned malformed lock.
|
|
246
|
+
await mkdir(staging, { mode: 0o700 });
|
|
247
|
+
await writeFile(join(staging, "owner"), token, { mode: 0o600, flag: "wx" });
|
|
248
|
+
await rename(staging, lock);
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
await rm(join(staging, "owner"), { force: true }).catch(() => undefined);
|
|
253
|
+
await rmdir(staging).catch(() => undefined);
|
|
254
|
+
const code = error.code;
|
|
255
|
+
// macOS and Linux use different errors when rename meets an existing,
|
|
256
|
+
// non-empty directory.
|
|
257
|
+
if (code !== "EEXIST" && code !== "ENOTEMPTY")
|
|
258
|
+
throw error;
|
|
259
|
+
const [held, owner] = await Promise.all([
|
|
260
|
+
stat(lock).catch(() => null),
|
|
261
|
+
readFile(ownerFile, "utf8").catch(() => null),
|
|
262
|
+
]);
|
|
263
|
+
const parsedOwner = parseJobLockOwner(owner);
|
|
264
|
+
// Old queue builds wrote "pid uuid timestamp". Those locks are kept for
|
|
265
|
+
// one generous transition window when the PID is alive, then treated as
|
|
266
|
+
// stale: without a process generation, a recycled PID can never prove
|
|
267
|
+
// that it is the process which published this directory.
|
|
268
|
+
const legacyPid = parsedOwner ? NaN : Number.parseInt(owner?.split(/\s+/)[0] ?? "", 10);
|
|
269
|
+
const lookupPid = parsedOwner?.pid ?? legacyPid;
|
|
270
|
+
const current = Number.isSafeInteger(lookupPid) && lookupPid > 0
|
|
271
|
+
? await processIdentityBefore(lookupPid, deadline)
|
|
272
|
+
: { state: "unknown" };
|
|
273
|
+
if (current.state === "timeout") {
|
|
274
|
+
return fail("internal", `Timed out waiting for queue job ${id} to become available`, {
|
|
275
|
+
jobId: id,
|
|
276
|
+
reason: "queue_lock_timeout",
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
const staleAge = !!held && Date.now() - held.mtimeMs > LEGACY_LOCK_STALE_MS;
|
|
280
|
+
const abandoned = current.state === "dead"
|
|
281
|
+
|| (parsedOwner && classifyDaemonProcessGeneration(current, parsedOwner.processIdentity) === "stale")
|
|
282
|
+
|| (!parsedOwner && staleAge);
|
|
283
|
+
if (abandoned) {
|
|
284
|
+
// Move the exact abandoned directory aside atomically. The
|
|
285
|
+
// quarantine name is derived from the directory identity, not from
|
|
286
|
+
// a rescuer, so every process racing on this same stale lock chooses
|
|
287
|
+
// the same destination. We intentionally leave the non-empty
|
|
288
|
+
// quarantine in place: it fences a delayed observer from ever
|
|
289
|
+
// renaming a successor lock after the first rescuer has moved on.
|
|
290
|
+
const identity = held
|
|
291
|
+
? `${held.dev}:${held.ino}:${held.birthtimeMs}`
|
|
292
|
+
: "missing";
|
|
293
|
+
const fingerprint = createHash("sha256").update(identity).digest("hex").slice(0, 20);
|
|
294
|
+
const quarantine = `${lock}.reclaimed-${fingerprint}`;
|
|
295
|
+
try {
|
|
296
|
+
if (!owner) {
|
|
297
|
+
// A historical/corrupt empty lock needs content before it is
|
|
298
|
+
// quarantined; otherwise POSIX rename could replace that empty
|
|
299
|
+
// destination with a later live lock.
|
|
300
|
+
await writeFile(join(lock, "malformed"), token, { mode: 0o600, flag: "wx" }).catch((markerError) => {
|
|
301
|
+
if (markerError.code !== "EEXIST")
|
|
302
|
+
throw markerError;
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
const [currentHeld, currentOwner] = await Promise.all([
|
|
306
|
+
stat(lock).catch(() => null),
|
|
307
|
+
readFile(ownerFile, "utf8").catch(() => null),
|
|
308
|
+
]);
|
|
309
|
+
const stillSameDirectory = !!currentHeld
|
|
310
|
+
&& currentHeld.dev === held?.dev
|
|
311
|
+
&& currentHeld.ino === held?.ino
|
|
312
|
+
&& currentHeld.birthtimeMs === held?.birthtimeMs;
|
|
313
|
+
if (stillSameDirectory && currentOwner === owner) {
|
|
314
|
+
await rename(lock, quarantine);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
catch (reclaimError) {
|
|
319
|
+
const reclaimCode = reclaimError.code;
|
|
320
|
+
if (reclaimCode !== "EEXIST" && reclaimCode !== "ENOTEMPTY" && reclaimCode !== "ENOENT")
|
|
321
|
+
throw reclaimError;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const remaining = deadline - performance.now();
|
|
325
|
+
if (remaining <= 0) {
|
|
326
|
+
return fail("internal", `Timed out waiting for queue job ${id} to become available`, {
|
|
327
|
+
jobId: id,
|
|
328
|
+
reason: "queue_lock_timeout",
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(10, remaining)));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
return await work();
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
// Only the holder that wrote this token may release this directory.
|
|
339
|
+
// rmdir is atomic and refuses a non-empty/replaced directory.
|
|
340
|
+
const current = await readFile(ownerFile, "utf8").catch(() => null);
|
|
341
|
+
if (current === token) {
|
|
342
|
+
await rm(ownerFile, { force: true }).catch(() => undefined);
|
|
343
|
+
await rmdir(lock).catch(() => undefined);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
36
347
|
async writeAtomic(job) {
|
|
37
348
|
const target = this.path(job.id);
|
|
38
349
|
const temp = `${target}.${randomUUID()}.tmp`;
|
|
39
350
|
await writeFile(temp, JSON.stringify(job, null, 2) + "\n", { mode: 0o600 });
|
|
40
351
|
await rename(temp, target);
|
|
352
|
+
// Invalidate every queue object in this process before the mutation's
|
|
353
|
+
// promise resolves. Other processes observe the atomic rename through the
|
|
354
|
+
// directory generation checked by `readJobsSnapshot`.
|
|
355
|
+
invalidateSharedQueueRead(this.root);
|
|
41
356
|
}
|
|
42
357
|
/** Creates the file only if the id is unseen; the content is complete before it becomes visible. */
|
|
43
358
|
async createExclusive(job) {
|
|
@@ -46,6 +361,7 @@ export class ArchiveQueue {
|
|
|
46
361
|
await writeFile(temp, JSON.stringify(job, null, 2) + "\n", { mode: 0o600 });
|
|
47
362
|
try {
|
|
48
363
|
await link(temp, target);
|
|
364
|
+
invalidateSharedQueueRead(this.root);
|
|
49
365
|
return true;
|
|
50
366
|
}
|
|
51
367
|
catch (error) {
|
|
@@ -69,47 +385,217 @@ export class ArchiveQueue {
|
|
|
69
385
|
const job = await this.get(id);
|
|
70
386
|
return job ?? fail("queue_job_not_found", `No queued job with id ${id}`, { jobId: id });
|
|
71
387
|
}
|
|
72
|
-
async
|
|
388
|
+
async readQueueDirectory() {
|
|
389
|
+
return readdir(this.root);
|
|
390
|
+
}
|
|
391
|
+
async readQueueFile(name) {
|
|
392
|
+
return readFile(join(this.root, name), "utf8");
|
|
393
|
+
}
|
|
394
|
+
async scanJobs() {
|
|
73
395
|
let names;
|
|
74
396
|
try {
|
|
75
|
-
names = await
|
|
397
|
+
names = await this.readQueueDirectory();
|
|
76
398
|
}
|
|
77
|
-
catch {
|
|
78
|
-
|
|
399
|
+
catch (error) {
|
|
400
|
+
// A genuinely absent root is a complete empty queue. Permission, file
|
|
401
|
+
// descriptor and I/O failures are not: caching those as empty can strand
|
|
402
|
+
// already-durable work forever when no later write changes the directory.
|
|
403
|
+
return { jobs: [], complete: error.code === "ENOENT" };
|
|
79
404
|
}
|
|
80
405
|
const jobs = [];
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
406
|
+
let complete = true;
|
|
407
|
+
for (const [position, name] of names.entries()) {
|
|
408
|
+
const canonical = CANONICAL_JOB_FILE.exec(name);
|
|
409
|
+
if (canonical) {
|
|
410
|
+
try {
|
|
411
|
+
const parsed = JSON.parse(await this.readQueueFile(name));
|
|
412
|
+
if (validJob(parsed, canonical[1]))
|
|
413
|
+
jobs.push(parsed);
|
|
414
|
+
else
|
|
415
|
+
complete = false;
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
// Return healthy rows for availability, but never let this partial
|
|
419
|
+
// census become cached authority or evidence for the done fast path.
|
|
420
|
+
complete = false;
|
|
421
|
+
}
|
|
86
422
|
}
|
|
87
|
-
|
|
88
|
-
|
|
423
|
+
// Count every directory entry, including a damaged/stale one. A queue
|
|
424
|
+
// full of files we skip must remain just as cooperative as a healthy one.
|
|
425
|
+
await queueWorkYield(position);
|
|
426
|
+
}
|
|
427
|
+
return { jobs, complete };
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Atomic job replacement changes the queue directory's mtime/ctime. Reading
|
|
431
|
+
* those at nanosecond precision gives all Sealkeep processes a cheap durable
|
|
432
|
+
* generation check: sequential dashboard endpoints can reuse parsed jobs,
|
|
433
|
+
* while a hook/worker in another process invalidates that snapshot merely by
|
|
434
|
+
* committing its ordinary rename.
|
|
435
|
+
*/
|
|
436
|
+
async directoryGeneration() {
|
|
437
|
+
try {
|
|
438
|
+
const value = await stat(this.root, { bigint: true });
|
|
439
|
+
return [value.dev, value.ino, value.size, value.mtimeNs, value.ctimeNs].join(":");
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
if (error.code === "ENOENT")
|
|
443
|
+
return "missing";
|
|
444
|
+
// Do not keep a completed snapshot when this process cannot validate the
|
|
445
|
+
// directory. The changing nonce deliberately forces the next read back
|
|
446
|
+
// to disk while preserving the queue's historical damaged-file tolerance.
|
|
447
|
+
return `unverifiable:${randomUUID()}`;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
startSharedScan(state) {
|
|
451
|
+
if (state.inFlight)
|
|
452
|
+
return state.inFlight;
|
|
453
|
+
const scan = (async () => {
|
|
454
|
+
const localRevision = state.localRevision;
|
|
455
|
+
const before = await this.directoryGeneration();
|
|
456
|
+
const result = await this.scanJobs();
|
|
457
|
+
const after = await this.directoryGeneration();
|
|
458
|
+
const stable = result.complete && before === after && localRevision === state.localRevision && !after.startsWith("unverifiable:");
|
|
459
|
+
const snapshot = { directoryGeneration: after, localRevision, jobs: result.jobs, stable };
|
|
460
|
+
if (stable)
|
|
461
|
+
state.cached = snapshot;
|
|
462
|
+
return snapshot;
|
|
463
|
+
})();
|
|
464
|
+
state.inFlight = scan;
|
|
465
|
+
void scan.finally(() => {
|
|
466
|
+
if (state.inFlight === scan)
|
|
467
|
+
state.inFlight = undefined;
|
|
468
|
+
}).catch(() => undefined);
|
|
469
|
+
return scan;
|
|
470
|
+
}
|
|
471
|
+
async readJobsSnapshot() {
|
|
472
|
+
const state = sharedQueueReadState(this.root);
|
|
473
|
+
state.activeReaders += 1;
|
|
474
|
+
try {
|
|
475
|
+
// A caller that arrived behind a write overlapping the previous scan gets
|
|
476
|
+
// one retry against the new generation. Under a continuously mutating
|
|
477
|
+
// worker we then return the atomically parsed (but deliberately uncached)
|
|
478
|
+
// view instead of making the local API wait forever for perfect quiescence.
|
|
479
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
480
|
+
const observed = await this.directoryGeneration();
|
|
481
|
+
const cached = state.cached;
|
|
482
|
+
if (cached?.stable
|
|
483
|
+
&& cached.directoryGeneration === observed
|
|
484
|
+
&& cached.localRevision === state.localRevision) {
|
|
485
|
+
return cached.jobs;
|
|
486
|
+
}
|
|
487
|
+
const snapshot = await this.startSharedScan(state);
|
|
488
|
+
const current = await this.directoryGeneration();
|
|
489
|
+
if (snapshot.stable
|
|
490
|
+
&& snapshot.directoryGeneration === current
|
|
491
|
+
&& snapshot.localRevision === state.localRevision) {
|
|
492
|
+
return snapshot.jobs;
|
|
493
|
+
}
|
|
494
|
+
if (attempt === 1)
|
|
495
|
+
return snapshot.jobs;
|
|
89
496
|
}
|
|
497
|
+
return [];
|
|
498
|
+
}
|
|
499
|
+
finally {
|
|
500
|
+
state.activeReaders -= 1;
|
|
501
|
+
trimSharedQueueReads();
|
|
90
502
|
}
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Returns rows only when one durable directory generation covered the whole
|
|
506
|
+
* scan. `enqueueMany` uses this as a read-only fast path for settled ids; an
|
|
507
|
+
* unstable snapshot is never evidence that a job already exists.
|
|
508
|
+
*/
|
|
509
|
+
async stableJobsSnapshot() {
|
|
510
|
+
const state = sharedQueueReadState(this.root);
|
|
511
|
+
state.activeReaders += 1;
|
|
512
|
+
try {
|
|
513
|
+
const observed = await this.directoryGeneration();
|
|
514
|
+
const cached = state.cached;
|
|
515
|
+
if (cached?.stable
|
|
516
|
+
&& cached.directoryGeneration === observed
|
|
517
|
+
&& cached.localRevision === state.localRevision) {
|
|
518
|
+
return cached.jobs;
|
|
519
|
+
}
|
|
520
|
+
const snapshot = await this.startSharedScan(state);
|
|
521
|
+
const current = await this.directoryGeneration();
|
|
522
|
+
if (snapshot.stable
|
|
523
|
+
&& snapshot.directoryGeneration === current
|
|
524
|
+
&& snapshot.localRevision === state.localRevision) {
|
|
525
|
+
return snapshot.jobs;
|
|
526
|
+
}
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
finally {
|
|
530
|
+
state.activeReaders -= 1;
|
|
531
|
+
trimSharedQueueReads();
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
async list(filter) {
|
|
535
|
+
const jobs = await this.readJobsSnapshot();
|
|
91
536
|
return jobs.filter((job) => !filter?.status || job.status === filter.status).sort((a, b) => a.enqueuedAt.localeCompare(b.enqueuedAt) || a.id.localeCompare(b.id));
|
|
92
537
|
}
|
|
93
538
|
async stats() {
|
|
94
539
|
const jobs = await this.list();
|
|
95
540
|
const count = (status) => jobs.filter((job) => job.status === status).length;
|
|
96
|
-
|
|
541
|
+
const completed = jobs.filter((job) => job.status === "done" && !!job.result).length;
|
|
542
|
+
const superseded = jobs.filter((job) => job.status === "done" && !!job.supersededBy && !job.result).length;
|
|
543
|
+
return {
|
|
544
|
+
total: jobs.length,
|
|
545
|
+
ready: count("ready"),
|
|
546
|
+
leased: count("leased"),
|
|
547
|
+
// Kept for wire compatibility: `done` is every settled queue row. It is
|
|
548
|
+
// not an archive count because older snapshots settle by supersession.
|
|
549
|
+
done: count("done"),
|
|
550
|
+
completed,
|
|
551
|
+
superseded,
|
|
552
|
+
failed: count("failed"),
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Answers the worker's startup question from the same generation-validated
|
|
557
|
+
* snapshot used by the local API. A mature vault can have thousands of
|
|
558
|
+
* completed job files; independently reading every one merely to learn that
|
|
559
|
+
* one ready job exists made preservation compete with the status UI.
|
|
560
|
+
*
|
|
561
|
+
* Expired leases count too: after a crash they are work the next worker can
|
|
562
|
+
* reclaim, even though a plain `stats().ready` check would call the queue
|
|
563
|
+
* empty and wait for discovery first.
|
|
564
|
+
*/
|
|
565
|
+
async hasClaimable(eligible) {
|
|
566
|
+
const jobs = await this.readJobsSnapshot();
|
|
567
|
+
let retired = new Set();
|
|
568
|
+
if (!this.reconciledBacklog) {
|
|
569
|
+
const outcome = await this.supersedeMany(jobs, jobs);
|
|
570
|
+
retired = new Set([...outcome.values()].flat());
|
|
571
|
+
this.reconciledBacklog = true;
|
|
572
|
+
}
|
|
573
|
+
const at = this.now();
|
|
574
|
+
for (const job of jobs) {
|
|
575
|
+
if (!retired.has(job.id) && this.claimable(job, at) && (!eligible || eligible(job))) {
|
|
576
|
+
this.claimableHint = job.id;
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return false;
|
|
97
581
|
}
|
|
98
582
|
/**
|
|
99
583
|
* Whether `candidate` describes a later state of the transcript than `over`.
|
|
100
584
|
*
|
|
101
585
|
* A total order over snapshots of one file, so two hook processes enqueueing
|
|
102
586
|
* at once reach the same verdict and the most advanced snapshot is never the
|
|
103
|
-
* one superseded.
|
|
104
|
-
*
|
|
105
|
-
* the same content address, which is the same job.
|
|
587
|
+
* one superseded. Modification time leads because compaction can make a later
|
|
588
|
+
* transcript smaller; byte length settles the rare same-time observation.
|
|
589
|
+
* Equal on both means the same content address, which is the same job.
|
|
106
590
|
*/
|
|
107
591
|
advances(candidate, over) {
|
|
108
|
-
if (candidate.sourceBytes !== over.sourceBytes)
|
|
109
|
-
return candidate.sourceBytes > over.sourceBytes;
|
|
110
592
|
const [later, earlier] = [Date.parse(candidate.sourceModifiedAt), Date.parse(over.sourceModifiedAt)];
|
|
111
593
|
if (later !== earlier)
|
|
112
594
|
return later > earlier;
|
|
595
|
+
// Compaction and truncation legitimately make a later transcript smaller.
|
|
596
|
+
// Size therefore breaks a same-mtime tie; it cannot define chronology.
|
|
597
|
+
if (candidate.sourceBytes !== over.sourceBytes)
|
|
598
|
+
return candidate.sourceBytes > over.sourceBytes;
|
|
113
599
|
return candidate.id > over.id;
|
|
114
600
|
}
|
|
115
601
|
/**
|
|
@@ -125,26 +611,111 @@ export class ArchiveQueue {
|
|
|
125
611
|
*
|
|
126
612
|
* Nothing is lost by dropping the earlier job: the bytes it would have
|
|
127
613
|
* archived are the bytes still on disk, and the surviving job archives those.
|
|
128
|
-
*
|
|
129
|
-
* a done or failed job
|
|
614
|
+
* Ready jobs and abandoned expired leases may be retired; an active lease may
|
|
615
|
+
* already be mid-archive, and a done or failed job records something that
|
|
616
|
+
* actually happened.
|
|
130
617
|
*/
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
618
|
+
sourceKey(job) {
|
|
619
|
+
return `${job.agent}\0${job.sourcePath}`;
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Retires older ready snapshots for a whole discovery pass with one queue read.
|
|
623
|
+
*
|
|
624
|
+
* `enqueue()` used to call `list()` once per discovered transcript. A startup
|
|
625
|
+
* scan of N transcripts against Q queue records therefore parsed N*Q JSON
|
|
626
|
+
* files before it could seal anything. All prospective jobs are made visible
|
|
627
|
+
* first, then this method indexes the queue once and applies the same
|
|
628
|
+
* source-local ordering rule.
|
|
629
|
+
*
|
|
630
|
+
* A hook in another process remains safe: job creation is still exclusive. A
|
|
631
|
+
* hook that lands before this snapshot is included here; one that lands after
|
|
632
|
+
* it sees every batch job in its own reconciliation. Re-read before writing so
|
|
633
|
+
* a job claimed by a worker since the snapshot is never knowingly retired.
|
|
634
|
+
*/
|
|
635
|
+
async supersedeMany(winners, snapshots) {
|
|
636
|
+
const retiredByWinner = new Map();
|
|
637
|
+
const strongest = new Map();
|
|
638
|
+
for (const winner of winners) {
|
|
639
|
+
// A dead-lettered job archives nothing, so it may not retire work either.
|
|
640
|
+
if (winner.status === "failed")
|
|
138
641
|
continue;
|
|
139
|
-
|
|
642
|
+
const key = this.sourceKey(winner);
|
|
643
|
+
const previous = strongest.get(key);
|
|
644
|
+
if (!previous || this.advances(winner, previous))
|
|
645
|
+
strongest.set(key, winner);
|
|
646
|
+
}
|
|
647
|
+
if (strongest.size === 0)
|
|
648
|
+
return retiredByWinner;
|
|
649
|
+
const retireable = (job, at = this.now()) => job.status === "ready"
|
|
650
|
+
|| (job.status === "leased" && (!job.lease || Date.parse(job.lease.expiresAt) <= at));
|
|
651
|
+
// An active lease may already be sealing the earlier snapshot and stays
|
|
652
|
+
// untouched. An EXPIRED lease is different: it is already claimable by a
|
|
653
|
+
// replacement worker, and leaving it behind after discovery has published
|
|
654
|
+
// a newer snapshot makes the old row look permanently in-flight. Retiring
|
|
655
|
+
// it under the same per-job lock is the enqueue equivalent of reclaiming
|
|
656
|
+
// it, without doing the obsolete multi-gigabyte read again.
|
|
657
|
+
for (const snapshot of snapshots ?? await this.list()) {
|
|
658
|
+
if (!retireable(snapshot))
|
|
140
659
|
continue;
|
|
141
|
-
|
|
142
|
-
|
|
660
|
+
const winner = strongest.get(this.sourceKey(snapshot));
|
|
661
|
+
if (!winner || snapshot.id === winner.id || !this.advances(winner, snapshot))
|
|
662
|
+
continue;
|
|
663
|
+
const retired = await this.withJobLock(snapshot.id, async () => {
|
|
664
|
+
const current = await this.get(snapshot.id);
|
|
665
|
+
if (!current || !retireable(current) || !this.advances(winner, current))
|
|
666
|
+
return false;
|
|
667
|
+
await this.writeAtomic({ ...current, status: "done", updatedAt: this.stamp(), lease: undefined, supersededBy: winner.id });
|
|
668
|
+
return true;
|
|
669
|
+
});
|
|
670
|
+
if (retired) {
|
|
671
|
+
const ids = retiredByWinner.get(winner.id) ?? [];
|
|
672
|
+
ids.push(snapshot.id);
|
|
673
|
+
retiredByWinner.set(winner.id, ids);
|
|
674
|
+
}
|
|
143
675
|
}
|
|
144
|
-
return
|
|
676
|
+
return retiredByWinner;
|
|
145
677
|
}
|
|
146
|
-
/**
|
|
147
|
-
|
|
678
|
+
/**
|
|
679
|
+
* Settles obsolete observations after a worker archives the live path.
|
|
680
|
+
*
|
|
681
|
+
* Queue rows contain metadata and a path, not a private copy of those old
|
|
682
|
+
* bytes. If an older row was already leased when discovery observed a newer
|
|
683
|
+
* state, that worker reads the newer live state and its completion covers
|
|
684
|
+
* the newer ready row too. Active *other* leases remain untouched; they may
|
|
685
|
+
* already have captured their own bounded snapshot.
|
|
686
|
+
*/
|
|
687
|
+
async settleCoveredSourceSnapshots(winner, observed) {
|
|
688
|
+
const observedAt = Date.parse(observed.modifiedAt);
|
|
689
|
+
if (!Number.isFinite(observedAt))
|
|
690
|
+
return;
|
|
691
|
+
const retireable = (job, at = this.now()) => job.status === "ready"
|
|
692
|
+
|| (job.status === "leased" && (!job.lease || Date.parse(job.lease.expiresAt) <= at));
|
|
693
|
+
const covered = (job) => {
|
|
694
|
+
if (job.id === winner.id || this.sourceKey(job) !== this.sourceKey(winner))
|
|
695
|
+
return false;
|
|
696
|
+
const jobAt = Date.parse(job.sourceModifiedAt);
|
|
697
|
+
return jobAt < observedAt || (jobAt === observedAt && job.sourceBytes === observed.bytes);
|
|
698
|
+
};
|
|
699
|
+
for (const snapshot of await this.list()) {
|
|
700
|
+
if (!retireable(snapshot) || !covered(snapshot))
|
|
701
|
+
continue;
|
|
702
|
+
await this.withJobLock(snapshot.id, async () => {
|
|
703
|
+
const current = await this.get(snapshot.id);
|
|
704
|
+
if (!current || !retireable(current) || !covered(current))
|
|
705
|
+
return;
|
|
706
|
+
await this.writeAtomic({
|
|
707
|
+
...current,
|
|
708
|
+
status: "done",
|
|
709
|
+
updatedAt: this.stamp(),
|
|
710
|
+
lease: undefined,
|
|
711
|
+
progress: undefined,
|
|
712
|
+
lastError: undefined,
|
|
713
|
+
supersededBy: winner.id,
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
async describe(request) {
|
|
148
719
|
const absolute = resolve(request.sourcePath);
|
|
149
720
|
let source;
|
|
150
721
|
try {
|
|
@@ -155,18 +726,113 @@ export class ArchiveQueue {
|
|
|
155
726
|
}
|
|
156
727
|
if (!source.isFile())
|
|
157
728
|
fail("source_unreadable", `Transcript is not a file: ${absolute}`, { sourcePath: absolute });
|
|
158
|
-
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
|
159
729
|
const id = jobId({ agent: request.agent, sourcePath: absolute, bytes: source.size, modifiedAtMs: source.mtimeMs });
|
|
160
730
|
const timestamp = this.stamp();
|
|
161
|
-
|
|
731
|
+
return {
|
|
162
732
|
version: JOB_VERSION, id, status: "ready", agent: request.agent, sourcePath: absolute, event: request.event, sessionId: request.sessionId,
|
|
163
733
|
sourceBytes: source.size, sourceModifiedAt: new Date(source.mtimeMs).toISOString(),
|
|
164
734
|
enqueuedAt: timestamp, updatedAt: timestamp, attempts: 0, nextAttemptAt: timestamp
|
|
165
735
|
};
|
|
736
|
+
}
|
|
737
|
+
async persist(job) {
|
|
166
738
|
if (await this.createExclusive(job))
|
|
167
|
-
return { job, deduped: false
|
|
168
|
-
|
|
169
|
-
|
|
739
|
+
return { job, deduped: false };
|
|
740
|
+
return { job: await this.require(job.id), deduped: true };
|
|
741
|
+
}
|
|
742
|
+
/** Records intent to archive a transcript. It never reads transcript contents. */
|
|
743
|
+
async enqueue(request) {
|
|
744
|
+
const described = await this.describe(request);
|
|
745
|
+
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
|
746
|
+
let persisted = await this.persist(described);
|
|
747
|
+
// A click in Sessions is a durable local override, not merely another
|
|
748
|
+
// lifecycle label. If an automatic hook already created this exact
|
|
749
|
+
// content-addressed row, preserve the one row but promote its intent so an
|
|
750
|
+
// exclusion cannot make the person's explicit request unclaimable.
|
|
751
|
+
if (request.event === "manual" && persisted.job.event !== "manual"
|
|
752
|
+
&& (persisted.job.status === "ready" || persisted.job.status === "failed")) {
|
|
753
|
+
const promoted = await this.withJobLock(persisted.job.id, async () => {
|
|
754
|
+
const current = await this.require(persisted.job.id);
|
|
755
|
+
if (current.status !== "ready" && current.status !== "failed")
|
|
756
|
+
return current;
|
|
757
|
+
if (current.event === "manual")
|
|
758
|
+
return current;
|
|
759
|
+
const next = { ...current, event: "manual", updatedAt: this.stamp() };
|
|
760
|
+
await this.writeAtomic(next);
|
|
761
|
+
return next;
|
|
762
|
+
});
|
|
763
|
+
persisted = { ...persisted, job: promoted };
|
|
764
|
+
}
|
|
765
|
+
const retired = await this.supersedeMany([persisted.job]);
|
|
766
|
+
return { ...persisted, superseded: retired.get(persisted.job.id) ?? [] };
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Batch form used by startup/rescan discovery. Individual unreadable sources
|
|
770
|
+
* are reported without aborting the rest of the pass, matching the watcher's
|
|
771
|
+
* previous per-file failure isolation.
|
|
772
|
+
*/
|
|
773
|
+
async enqueueMany(requests) {
|
|
774
|
+
if (requests.length === 0)
|
|
775
|
+
return [];
|
|
776
|
+
const outcomes = new Array(requests.length);
|
|
777
|
+
const described = [];
|
|
778
|
+
for (const [index, request] of requests.entries()) {
|
|
779
|
+
try {
|
|
780
|
+
described.push({ index, request, job: await this.describe(request) });
|
|
781
|
+
}
|
|
782
|
+
catch (error) {
|
|
783
|
+
outcomes[index] = { ok: false, request, error };
|
|
784
|
+
}
|
|
785
|
+
await queueWorkYield(index);
|
|
786
|
+
}
|
|
787
|
+
if (described.length === 0)
|
|
788
|
+
return outcomes;
|
|
789
|
+
try {
|
|
790
|
+
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
|
791
|
+
}
|
|
792
|
+
catch (error) {
|
|
793
|
+
for (const item of described)
|
|
794
|
+
outcomes[item.index] = { ok: false, request: item.request, error };
|
|
795
|
+
return outcomes;
|
|
796
|
+
}
|
|
797
|
+
// Most rows in a mature vault are immutable `done` records. A prior
|
|
798
|
+
// implementation nevertheless wrote a complete temp file, attempted an
|
|
799
|
+
// exclusive hard-link, removed the temp and reread the winner for every
|
|
800
|
+
// one on every startup. On a 1,300-row real queue beside an index rebuild,
|
|
801
|
+
// that kept even /health out of the event loop for minutes.
|
|
802
|
+
//
|
|
803
|
+
// Only a generation-validated snapshot enables this shortcut, and only a
|
|
804
|
+
// done row is reused: ready/failed rows can be removed by the UI and leased
|
|
805
|
+
// rows can change ownership, so they retain the exclusive-create/re-read
|
|
806
|
+
// path. An id absent from the snapshot also retains that path, preserving
|
|
807
|
+
// convergence with a hook process that creates it after the snapshot.
|
|
808
|
+
const stable = await this.stableJobsSnapshot();
|
|
809
|
+
const settled = new Map((stable ?? [])
|
|
810
|
+
.filter((job) => job.status === "done")
|
|
811
|
+
.map((job) => [job.id, job]));
|
|
812
|
+
const persisted = [];
|
|
813
|
+
for (const [position, item] of described.entries()) {
|
|
814
|
+
try {
|
|
815
|
+
const known = settled.get(item.job.id);
|
|
816
|
+
persisted.push(known
|
|
817
|
+
? { ...item, job: known, deduped: true }
|
|
818
|
+
: { ...item, ...await this.persist(item.job) });
|
|
819
|
+
}
|
|
820
|
+
catch (error) {
|
|
821
|
+
outcomes[item.index] = { ok: false, request: item.request, error };
|
|
822
|
+
}
|
|
823
|
+
await queueWorkYield(position);
|
|
824
|
+
}
|
|
825
|
+
const retired = await this.supersedeMany(persisted.map((item) => item.job));
|
|
826
|
+
const reported = new Set();
|
|
827
|
+
for (const item of persisted) {
|
|
828
|
+
// Duplicate requests in one batch describe one content-addressed job. As
|
|
829
|
+
// with sequential enqueue calls, report its retirements once, not once per
|
|
830
|
+
// duplicate lifecycle event.
|
|
831
|
+
const superseded = reported.has(item.job.id) ? [] : retired.get(item.job.id) ?? [];
|
|
832
|
+
reported.add(item.job.id);
|
|
833
|
+
outcomes[item.index] = { ok: true, request: item.request, job: item.job, deduped: item.deduped, superseded };
|
|
834
|
+
}
|
|
835
|
+
return outcomes;
|
|
170
836
|
}
|
|
171
837
|
claimable(job, at) {
|
|
172
838
|
if (job.status === "ready")
|
|
@@ -187,40 +853,120 @@ export class ArchiveQueue {
|
|
|
187
853
|
* several workers against the same instance, and two claims listing the
|
|
188
854
|
* directory at the same moment would pick the same candidate and both write
|
|
189
855
|
* a lease on it — the loser then failing its own job with a lease error.
|
|
190
|
-
* Serialising costs nothing next to the seal that follows
|
|
191
|
-
*
|
|
192
|
-
* and
|
|
856
|
+
* Serialising costs nothing next to the seal that follows. The per-job
|
|
857
|
+
* filesystem lock inside `claimNext` extends that same exclusivity across
|
|
858
|
+
* hook, daemon and manual-command processes; atomic rename alone cannot act
|
|
859
|
+
* as compare-and-swap and previously let both callers believe they won.
|
|
193
860
|
*/
|
|
194
|
-
async claim(owner, excluding) {
|
|
195
|
-
const turn = this.claimTurn.then(() => this.claimNext(owner, excluding));
|
|
861
|
+
async claim(owner, excluding, eligible) {
|
|
862
|
+
const turn = this.claimTurn.then(() => this.claimNext(owner, excluding, eligible));
|
|
196
863
|
this.claimTurn = turn.then(() => undefined, () => undefined);
|
|
197
864
|
return turn;
|
|
198
865
|
}
|
|
199
|
-
async claimNext(owner, excluding) {
|
|
200
|
-
const at = this.now();
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
866
|
+
async claimNext(owner, excluding, eligible) {
|
|
867
|
+
const acceptable = (job, at = this.now()) => this.claimable(job, at) && !excluding?.has(job.id) && (!eligible || eligible(job));
|
|
868
|
+
let candidates;
|
|
869
|
+
let retired = new Set();
|
|
870
|
+
if (!this.reconciledBacklog) {
|
|
871
|
+
candidates = await this.list();
|
|
872
|
+
const outcome = await this.supersedeMany(candidates, candidates);
|
|
873
|
+
retired = new Set([...outcome.values()].flat());
|
|
874
|
+
this.reconciledBacklog = true;
|
|
875
|
+
}
|
|
876
|
+
const hintedId = this.claimableHint;
|
|
877
|
+
this.claimableHint = undefined;
|
|
878
|
+
const tryClaim = async (candidate) => {
|
|
879
|
+
const leased = await this.withJobLock(candidate.id, async () => {
|
|
880
|
+
const current = await this.get(candidate.id);
|
|
881
|
+
const at = this.now();
|
|
882
|
+
if (!current || !acceptable(current, at))
|
|
883
|
+
return null;
|
|
884
|
+
const next = {
|
|
885
|
+
...current, status: "leased", attempts: current.attempts + 1, updatedAt: new Date(at).toISOString(),
|
|
886
|
+
lease: { id: randomUUID(), owner, acquiredAt: new Date(at).toISOString(), expiresAt: new Date(at + this.leaseMs).toISOString() }
|
|
887
|
+
};
|
|
888
|
+
await this.writeAtomic(next);
|
|
889
|
+
return next;
|
|
890
|
+
});
|
|
891
|
+
return leased;
|
|
207
892
|
};
|
|
208
|
-
|
|
209
|
-
|
|
893
|
+
// `hasClaimable` has already parsed this exact job. Try it before listing
|
|
894
|
+
// thousands of completed records; the lock re-reads it under exclusion so
|
|
895
|
+
// the hint is an optimization, never a trust boundary.
|
|
896
|
+
if (hintedId) {
|
|
897
|
+
const hinted = await this.get(hintedId);
|
|
898
|
+
if (hinted && acceptable(hinted)) {
|
|
899
|
+
const leased = await tryClaim(hinted);
|
|
900
|
+
if (leased)
|
|
901
|
+
return leased;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
for (const candidate of candidates ?? await this.list()) {
|
|
905
|
+
if (retired.has(candidate.id) || candidate.id === hintedId || !acceptable(candidate))
|
|
906
|
+
continue;
|
|
907
|
+
const leased = await tryClaim(candidate);
|
|
908
|
+
if (leased)
|
|
909
|
+
return leased;
|
|
910
|
+
}
|
|
911
|
+
return null;
|
|
210
912
|
}
|
|
211
|
-
|
|
913
|
+
assertLeaseOwner(job, leaseId) {
|
|
212
914
|
if (job.status !== "leased" || !job.lease)
|
|
213
915
|
fail("queue_lease_invalid", `Job ${job.id} is not leased`, { jobId: job.id, status: job.status });
|
|
214
916
|
if (job.lease.id !== leaseId)
|
|
215
917
|
fail("queue_lease_invalid", `Lease does not match job ${job.id}`, { jobId: job.id });
|
|
216
|
-
if (Date.parse(job.lease.expiresAt) <= this.now())
|
|
217
|
-
fail("queue_lease_expired", `Lease for job ${job.id} expired at ${job.lease.expiresAt}`, { jobId: job.id, expiresAt: job.lease.expiresAt });
|
|
218
918
|
return job;
|
|
219
919
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
920
|
+
assertLease(job, leaseId) {
|
|
921
|
+
// Expiry makes this record claimable; it does not silently change its id.
|
|
922
|
+
// Every mutation runs under the per-job lock. If a replacement claimed
|
|
923
|
+
// first, the id differs and this call is refused. If the id still matches,
|
|
924
|
+
// nobody owns a competing lease and the original worker may commit safely.
|
|
925
|
+
return this.assertLeaseOwner(job, leaseId);
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* How often a worker should prove it is still alive while holding a lease.
|
|
929
|
+
* Kept below one third of the lease, with a one-minute ceiling for the
|
|
930
|
+
* ordinary five-minute lease. Tests and small embedded queues can shorten
|
|
931
|
+
* the lease without having to guess a separate heartbeat setting.
|
|
932
|
+
*/
|
|
933
|
+
leaseHeartbeatMs() {
|
|
934
|
+
return Math.max(10, Math.min(60_000, Math.floor(this.leaseMs / 3)));
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Extends an unchanged lease without changing its owner, attempt count or
|
|
938
|
+
* original acquisition time. A delayed event loop may reach this write just
|
|
939
|
+
* after the deadline. The per-job lock makes that safe: if another worker
|
|
940
|
+
* reclaimed first, its new lease id wins and this renewal is refused; if the
|
|
941
|
+
* id is still ours, nobody took the work and renewal prevents a false orphan.
|
|
942
|
+
*/
|
|
943
|
+
async renew(id, leaseId) {
|
|
944
|
+
return this.withJobLock(id, async () => {
|
|
945
|
+
const at = this.now();
|
|
946
|
+
const job = this.assertLeaseOwner(await this.require(id), leaseId);
|
|
947
|
+
const renewed = {
|
|
948
|
+
...job,
|
|
949
|
+
updatedAt: new Date(at).toISOString(),
|
|
950
|
+
lease: { ...job.lease, expiresAt: new Date(at + this.leaseMs).toISOString() }
|
|
951
|
+
};
|
|
952
|
+
await this.writeAtomic(renewed);
|
|
953
|
+
return renewed;
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
async complete(id, leaseId, result, sourceSnapshot) {
|
|
957
|
+
const done = await this.withJobLock(id, async () => {
|
|
958
|
+
const job = this.assertLease(await this.require(id), leaseId);
|
|
959
|
+
const done = { ...job, status: "done", updatedAt: this.stamp(), lease: undefined, lastError: undefined, result: { ...result, completedAt: this.stamp() } };
|
|
960
|
+
await this.writeAtomic(done);
|
|
961
|
+
return done;
|
|
962
|
+
});
|
|
963
|
+
if (sourceSnapshot && Number.isFinite(sourceSnapshot.bytes) && Number.isFinite(Date.parse(sourceSnapshot.modifiedAt))) {
|
|
964
|
+
// A worker may claim yesterday's row while startup discovery publishes
|
|
965
|
+
// today's row for the same live path. The archive reads the current
|
|
966
|
+
// bounded snapshot, so settle every older/equal ready observation that
|
|
967
|
+
// this exact completion covered. A genuinely newer append survives.
|
|
968
|
+
await this.settleCoveredSourceSnapshots(done, sourceSnapshot);
|
|
969
|
+
}
|
|
224
970
|
return done;
|
|
225
971
|
}
|
|
226
972
|
/**
|
|
@@ -234,53 +980,73 @@ export class ArchiveQueue {
|
|
|
234
980
|
*/
|
|
235
981
|
/** Progress on the job being worked — visible, not merely true. */
|
|
236
982
|
async progress(id, leaseId, bytes, of) {
|
|
237
|
-
|
|
238
|
-
|
|
983
|
+
await this.withJobLock(id, async () => {
|
|
984
|
+
const at = this.now();
|
|
985
|
+
const job = this.assertLease(await this.require(id), leaseId);
|
|
986
|
+
// Progress is also proof of life. Extend in this same atomic document so
|
|
987
|
+
// the visible percentage can never land while leaving an older expiry
|
|
988
|
+
// behind for a competing claimant.
|
|
989
|
+
await this.writeAtomic({
|
|
990
|
+
...job, progress: { bytes, of }, updatedAt: new Date(at).toISOString(),
|
|
991
|
+
lease: { ...job.lease, expiresAt: new Date(at + this.leaseMs).toISOString() }
|
|
992
|
+
});
|
|
993
|
+
});
|
|
239
994
|
}
|
|
240
995
|
/**
|
|
241
|
-
* Takes a waiting job back out of the queue.
|
|
242
|
-
* can go
|
|
243
|
-
* on its own
|
|
244
|
-
* and removing that is a different, bigger decision made elsewhere.
|
|
996
|
+
* Takes a waiting job back out of the queue. Ready, failed, and expired-lease
|
|
997
|
+
* jobs can go. An unexpired lease is still owned by a worker and must finish
|
|
998
|
+
* or fail on its own; a done row is an archive and belongs to the vault.
|
|
245
999
|
*/
|
|
246
1000
|
async remove(id) {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
1001
|
+
return this.withJobLock(id, async () => {
|
|
1002
|
+
const job = await this.require(id);
|
|
1003
|
+
if (job.status === "leased" && job.lease && Date.parse(job.lease.expiresAt) > this.now()) {
|
|
1004
|
+
fail("invalid_argument", "This one is being sealed right now — it finishes (or fails) on its own in a moment.", { jobId: id });
|
|
1005
|
+
}
|
|
1006
|
+
if (job.status === "done")
|
|
1007
|
+
fail("invalid_argument", "Already sealed. What exists now is an archive; delete that from the vault, not from the queue.", { jobId: id });
|
|
1008
|
+
await rm(this.path(id), { force: true });
|
|
1009
|
+
invalidateSharedQueueRead(this.root);
|
|
1010
|
+
return { ...job, status: "failed", updatedAt: this.stamp(), lastError: { code: "removed", message: "Taken out of the queue by hand" } };
|
|
1011
|
+
});
|
|
254
1012
|
}
|
|
255
1013
|
async release(id, leaseId, reason) {
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
1014
|
+
return this.withJobLock(id, async () => {
|
|
1015
|
+
const job = this.assertLease(await this.require(id), leaseId);
|
|
1016
|
+
const back = {
|
|
1017
|
+
...job, status: "ready", updatedAt: this.stamp(), lease: undefined,
|
|
1018
|
+
attempts: Math.max(0, job.attempts - 1),
|
|
1019
|
+
...(reason ? { lastError: reason } : {})
|
|
1020
|
+
};
|
|
1021
|
+
await this.writeAtomic(back);
|
|
1022
|
+
return back;
|
|
1023
|
+
});
|
|
264
1024
|
}
|
|
265
1025
|
/** Reschedules with exponential backoff, or dead-letters once attempts are exhausted. */
|
|
266
1026
|
async fail(id, leaseId, error) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
1027
|
+
return this.withJobLock(id, async () => {
|
|
1028
|
+
const job = this.assertLease(await this.require(id), leaseId);
|
|
1029
|
+
const exhausted = job.attempts >= this.maxAttempts;
|
|
1030
|
+
const delay = Math.min(this.backoffMs * 2 ** Math.max(0, job.attempts - 1), this.maxBackoffMs);
|
|
1031
|
+
const next = {
|
|
1032
|
+
...job, status: exhausted ? "failed" : "ready", updatedAt: this.stamp(), lease: undefined, lastError: error,
|
|
1033
|
+
nextAttemptAt: exhausted ? job.nextAttemptAt : new Date(this.now() + delay).toISOString()
|
|
1034
|
+
};
|
|
1035
|
+
await this.writeAtomic(next);
|
|
1036
|
+
return next;
|
|
1037
|
+
});
|
|
276
1038
|
}
|
|
277
1039
|
/** Explicit operator action: a dead-lettered job never revives itself. */
|
|
278
1040
|
async retry(id) {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
1041
|
+
return this.withJobLock(id, async () => {
|
|
1042
|
+
const job = await this.require(id);
|
|
1043
|
+
if (job.status === "done")
|
|
1044
|
+
fail("invalid_argument", `Job ${id} already completed`, { jobId: id });
|
|
1045
|
+
if (job.status === "leased")
|
|
1046
|
+
fail("invalid_argument", `Job ${id} is being sealed and cannot be retried`, { jobId: id });
|
|
1047
|
+
const revived = { ...job, status: "ready", attempts: 0, updatedAt: this.stamp(), nextAttemptAt: this.stamp(), lease: undefined };
|
|
1048
|
+
await this.writeAtomic(revived);
|
|
1049
|
+
return revived;
|
|
1050
|
+
});
|
|
285
1051
|
}
|
|
286
1052
|
}
|