Files
2026-07-13 12:58:18 +08:00

530 lines
26 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// <reference path="../pb_data/types.d.ts" />
//
// Fleet job-claim transactional endpoints (PB 0.22).
//
// WHY THIS EXISTS: the harness authenticates to PB as a SUPERUSER, and
// superuser writes BYPASS collection updateRules — so a rule-guarded PATCH
// (`status = "pending"`) is NOT atomically enforced. An empirical spike
// (20 concurrent claimers) confirmed a rule-guarded worker PATCH yields
// 410 winners, while these routerAdd + runInTransaction compare-and-set
// endpoints yield EXACTLY ONE winner every time. SQLite serializes write
// transactions, so the read-compare-write below is atomic across callers.
//
// IMPLEMENTATION NOTE (PB 0.22 JSVM gotcha): routerAdd handler callbacks
// are serialized and re-executed inside a POOLED goja runtime that does
// NOT have this file's module-top-level `function` declarations in scope.
// Referencing a top-level helper from inside a handler throws
// "X is not defined" at request time. Therefore every helper a handler
// needs is defined INSIDE that handler's closure. (Verified against
// PB 0.22.21 — the "leaseExpiryIso is not defined" failure mode.)
//
// Endpoints (all POST, JSON body, superuser auth ENFORCED server-side via the
// `$apis.requireAdminAuth()` middleware on each routerAdd — PB 0.22's JSVM
// superuser-auth echo middleware; a middleware-less routerAdd handler is
// PUBLIC. The harness client (job-claim.ts) authenticates as superuser and
// retries once on 401, so enforcement is compat-safe):
// /api/fleet/claim { jobId, workerId, leaseSeconds }
// → { claimed: bool, job?, alreadyHeld? }
// exactly-one-winner CAS; alreadyHeld:true marks a
// re-claim by the CURRENT holder on a live lease
// (timeout-after-commit retry idempotency)
// /api/fleet/renew { jobId, workerId, leaseSeconds }
// → { renewed: bool, job? } only the lease holder
// /api/fleet/release { jobId, workerId, status } status: done|failed|pending
// (status REQUIRED — no default)
// → { released: bool, job?, reason? } only the holder
routerAdd(
"POST",
"/api/fleet/claim",
(c) => {
const RUNNING_STATES = ["claimed", "running"];
// CLAMP leaseSeconds: the body is caller-supplied JSON, so a non-numeric /
// non-positive value (string, null, NaN) falls to the 30s default, and a
// huge value is capped at 3600s (1h) so a malformed caller can never wedge
// a row behind a multi-day lease the sweeper would wait out. NaN > 0 is
// false, so garbage routes to the default without an isFinite dependency.
// FLOOR at 1s too: n > 0 admits e.g. 0.001 — a 1ms lease that is expired
// before the response lands, making every claim instantly stealable and
// every renew a thrash loop.
const leaseExpiryIso = (leaseSeconds) => {
const n = typeof leaseSeconds === "number" ? leaseSeconds : NaN;
const secs = n > 0 ? Math.max(1, Math.min(n, 3600)) : 30;
return new Date(Date.now() + secs * 1000).toISOString();
};
// A claimed/running row is reclaimable once its lease has elapsed. Empty
// / unparseable lease is treated as expired (never let a row wedge the
// queue forever because of a malformed timestamp).
const leaseExpired = (rec) => {
const raw = rec.get("lease_expires_at");
if (!raw) return true;
// PB stores dates as "2006-01-02 15:04:05.000Z" (space separator).
// goja's Date.parse is strict and returns NaN for the space form
// (unlike V8/Node, which accept it) — so normalize the space to the
// ISO "T" separator before parsing. Without this every lease parses
// to NaN and is wrongly treated as expired, letting any caller steal
// a live claim. (Verified against PB 0.22.21 / goja.)
//
// ANCHOR the replacement to the date/time boundary
// ("YYYY-MM-DD ") so we ONLY rewrite the canonical PB shape. A bare
// String.replace(" ", "T") rewrites the FIRST space anywhere, which
// would coerce a malformed/non-standard value into something that
// parses, silently treating a LIVE claim as expired and defeating the
// exactly-one-winner CAS. An odd shape must fall through to NaN →
// expired-by-policy is intentional (never wedge the queue), but only
// because the value genuinely failed to parse, not because we mangled
// it into a parseable one.
const t = Date.parse(String(raw).replace(/^(\d{4}-\d{2}-\d{2}) /, "$1T"));
if (isNaN(t)) return true;
return t <= Date.now();
};
const jobView = (rec) => ({
id: rec.id,
probe_key: rec.get("probe_key"),
status: rec.get("status"),
claimed_by: rec.get("claimed_by"),
lease_expires_at: rec.get("lease_expires_at"),
version: rec.get("version"),
});
const data = $apis.requestInfo(c).data || {};
const jobId = data.jobId;
const workerId = data.workerId;
const leaseSeconds = data.leaseSeconds;
if (!jobId || !workerId) {
return c.json(400, { error: "jobId and workerId are required" });
}
// A non-string workerId (a JSON number) would COERCE into the text
// claimed_by column on write — but the holder renews/releases with the
// string form, so `claimed_by !== workerId` never matches again and the
// row wedges until lease expiry. Reject the type up front.
if (typeof workerId !== "string") {
return c.json(400, { error: "workerId must be a string" });
}
// Same class for jobId (consistency with the workerId guard): `!jobId`
// admits a truthy non-string (a JSON number/object) that would otherwise
// ride into findRecordById on the dao's coercion behavior. 400 up front.
if (typeof jobId !== "string") {
return c.json(400, { error: "jobId must be a string" });
}
let claimed = false;
let alreadyHeld = false;
let view = null;
// Wrap runInTransaction in try/catch so a thrown saveRecord (e.g. DB
// constraint, transient sqlite error) does NOT escape the handler as
// HTTP 500 — the worker client contract expects {claimed:false} on
// refusal, and a 500 makes the worker retry-storm without backoff or
// mis-classify the refusal class. The transaction itself rolls back on
// throw, so DB state is consistent; we reset the JS-side flags here
// because they may have been set inside the callback BEFORE the throw
// (no rollback semantics for JS-side variables).
try {
$app.dao().runInTransaction((txDao) => {
let rec;
try {
rec = txDao.findRecordById("probe_jobs", jobId);
} catch {
return; // unknown job → claimed stays false
}
const status = rec.get("status");
// Claimable if pending, OR if its prior claim's lease has expired
// (steal the lease from a dead worker). Terminal states never reclaim.
const reclaimable =
status === "pending" ||
(RUNNING_STATES.indexOf(status) !== -1 && leaseExpired(rec));
// Run-metadata (§4.2): capture WHICH branch won BEFORE mutating the
// record. An expired-lease steal (claimed/running + lease elapsed) is
// one of the two reclaim choke points and must bump reclaim_count; a
// plain pending claim must not.
const wasExpiredSteal =
RUNNING_STATES.indexOf(status) !== -1 && leaseExpired(rec);
if (!reclaimable) {
// TIMEOUT-AFTER-COMMIT IDEMPOTENCY: a claim that COMMITTED whose
// response was lost is retried by the SAME worker — the row is now
// claimed by THIS workerId with a live lease, which is not
// reclaimable, so a plain refusal would make the retry abandon a row
// it actually holds (claimed-but-orphaned until lease expiry). Answer
// the truth instead: the caller holds it. claimed:true so the client
// treats it as a win; alreadyHeld marks the re-claim (informational —
// the existing lease/expiry is RETAINED, not extended; the holder's
// heartbeat renews it on its normal cadence).
if (
RUNNING_STATES.indexOf(status) !== -1 &&
rec.get("claimed_by") === workerId &&
!leaseExpired(rec)
) {
claimed = true;
alreadyHeld = true;
view = jobView(rec);
}
return;
}
rec.set("status", "claimed");
rec.set("claimed_by", workerId);
rec.set("lease_expires_at", leaseExpiryIso(leaseSeconds));
rec.set("version", (rec.get("version") || 0) + 1);
// claimed_at is stamped on EVERY winning claim — it deliberately
// restamps on a re-claim/steal, so the derived queue latency
// (claimed_at created) measures the LAST claim (§5.2.1 corollary).
rec.set("claimed_at", new Date().toISOString());
if (wasExpiredSteal) {
rec.set("reclaim_count", (rec.get("reclaim_count") || 0) + 1);
}
txDao.saveRecord(rec);
claimed = true;
view = jobView(rec);
});
} catch {
// Transaction threw (commit failure, saveRecord exception, etc.).
// Reset JS-side flags — they may have been set inside the callback
// before the throw, but the DB rolled back, so the documented refusal
// shape is the truthful answer.
claimed = false;
alreadyHeld = false;
view = null;
}
// No object-spread (goja compat caution): build the body imperatively.
const body = claimed ? { claimed: true, job: view } : { claimed: false };
if (alreadyHeld) body.alreadyHeld = true;
return c.json(200, body);
},
$apis.requireAdminAuth(),
);
routerAdd(
"POST",
"/api/fleet/renew",
(c) => {
const RUNNING_STATES = ["claimed", "running"];
// CLAMP leaseSeconds: the body is caller-supplied JSON, so a non-numeric /
// non-positive value (string, null, NaN) falls to the 30s default, and a
// huge value is capped at 3600s (1h) so a malformed caller can never wedge
// a row behind a multi-day lease the sweeper would wait out. NaN > 0 is
// false, so garbage routes to the default without an isFinite dependency.
// FLOOR at 1s too: n > 0 admits e.g. 0.001 — a 1ms lease that is expired
// before the response lands, making every claim instantly stealable and
// every renew a thrash loop.
const leaseExpiryIso = (leaseSeconds) => {
const n = typeof leaseSeconds === "number" ? leaseSeconds : NaN;
const secs = n > 0 ? Math.max(1, Math.min(n, 3600)) : 30;
return new Date(Date.now() + secs * 1000).toISOString();
};
const leaseExpired = (rec) => {
const raw = rec.get("lease_expires_at");
if (!raw) return true;
// PB stores dates as "2006-01-02 15:04:05.000Z" (space separator).
// goja's Date.parse is strict and returns NaN for the space form
// (unlike V8/Node, which accept it) — so normalize the space to the
// ISO "T" separator before parsing. Without this every lease parses
// to NaN and is wrongly treated as expired, letting any caller steal
// a live claim. (Verified against PB 0.22.21 / goja.)
//
// ANCHOR the replacement to the date/time boundary
// ("YYYY-MM-DD ") so we ONLY rewrite the canonical PB shape. A bare
// String.replace(" ", "T") rewrites the FIRST space anywhere, which
// would coerce a malformed/non-standard value into something that
// parses, silently treating a LIVE claim as expired and defeating the
// exactly-one-winner CAS. An odd shape must fall through to NaN →
// expired-by-policy is intentional (never wedge the queue), but only
// because the value genuinely failed to parse, not because we mangled
// it into a parseable one.
const t = Date.parse(String(raw).replace(/^(\d{4}-\d{2}-\d{2}) /, "$1T"));
if (isNaN(t)) return true;
return t <= Date.now();
};
const jobView = (rec) => ({
id: rec.id,
probe_key: rec.get("probe_key"),
status: rec.get("status"),
claimed_by: rec.get("claimed_by"),
lease_expires_at: rec.get("lease_expires_at"),
version: rec.get("version"),
});
const data = $apis.requestInfo(c).data || {};
const jobId = data.jobId;
const workerId = data.workerId;
const leaseSeconds = data.leaseSeconds;
if (!jobId || !workerId) {
return c.json(400, { error: "jobId and workerId are required" });
}
// A non-string workerId (a JSON number) would COERCE into the text
// claimed_by column on write — but the holder renews/releases with the
// string form, so `claimed_by !== workerId` never matches again and the
// row wedges until lease expiry. Reject the type up front.
if (typeof workerId !== "string") {
return c.json(400, { error: "workerId must be a string" });
}
// Same class for jobId (consistency with the workerId guard): `!jobId`
// admits a truthy non-string (a JSON number/object) that would otherwise
// ride into findRecordById on the dao's coercion behavior. 400 up front.
if (typeof jobId !== "string") {
return c.json(400, { error: "jobId must be a string" });
}
let renewed = false;
let view = null;
// Wrap runInTransaction in try/catch so a thrown saveRecord does NOT
// escape as HTTP 500 — worker clients expect {renewed:false} on refusal.
// The transaction rolls back on throw, so we reset JS-side flags to
// match the rolled-back DB state.
try {
$app.dao().runInTransaction((txDao) => {
let rec;
try {
rec = txDao.findRecordById("probe_jobs", jobId);
} catch {
return;
}
const status = rec.get("status");
// Only the current lease holder may renew, and only while the row is
// still in a running state and the lease has NOT yet expired. If the
// lease already expired the holder lost it (another worker may have
// stolen it) — renew must fail so the original worker stops.
if (RUNNING_STATES.indexOf(status) === -1) return;
if (rec.get("claimed_by") !== workerId) return;
if (leaseExpired(rec)) return;
// Promote claimed → running on first renew so the lifecycle is visible.
rec.set("status", "running");
rec.set("lease_expires_at", leaseExpiryIso(leaseSeconds));
rec.set("version", (rec.get("version") || 0) + 1);
txDao.saveRecord(rec);
renewed = true;
view = jobView(rec);
});
} catch {
renewed = false;
view = null;
}
return c.json(
200,
renewed ? { renewed: true, job: view } : { renewed: false },
);
},
$apis.requireAdminAuth(),
);
routerAdd(
"POST",
"/api/fleet/release",
(c) => {
const RUNNING_STATES = ["claimed", "running"];
// A claimed/running row's lease is elapsed once its expiry timestamp has
// passed. Empty / unparseable lease is treated as expired (mirrors claim +
// renew). Defined INSIDE the handler closure — PB 0.22's pooled goja runtime
// does NOT see module-top-level declarations from a handler (the
// "X is not defined" gotcha documented at the top of this file).
const leaseExpired = (rec) => {
const raw = rec.get("lease_expires_at");
if (!raw) return true;
// PB stores dates as "2006-01-02 15:04:05.000Z" (space separator). goja's
// Date.parse is strict and returns NaN for the space form, so normalize the
// space to the ISO "T" separator before parsing — ANCHORED to the date/time
// boundary so we only rewrite the canonical PB shape (a bare replace would
// coerce a malformed value into something parseable, defeating the CAS).
const t = Date.parse(String(raw).replace(/^(\d{4}-\d{2}-\d{2}) /, "$1T"));
if (isNaN(t)) return true;
return t <= Date.now();
};
const jobView = (rec) => ({
id: rec.id,
probe_key: rec.get("probe_key"),
status: rec.get("status"),
claimed_by: rec.get("claimed_by"),
lease_expires_at: rec.get("lease_expires_at"),
version: rec.get("version"),
});
const data = $apis.requestInfo(c).data || {};
const jobId = data.jobId;
const workerId = data.workerId;
// status is REQUIRED — the old `|| "done"` fallback silently FINISHED a
// job whose caller omitted (or sent an empty) status, masking a protocol
// bug as success. Validate it exactly like jobId/workerId.
const target = data.status;
if (!jobId || !workerId) {
return c.json(400, { error: "jobId and workerId are required" });
}
// A non-string workerId (a JSON number) would COERCE into the text
// claimed_by column on write — but the holder renews/releases with the
// string form, so `claimed_by !== workerId` never matches again and the
// row wedges until lease expiry. Reject the type up front.
if (typeof workerId !== "string") {
return c.json(400, { error: "workerId must be a string" });
}
// Same class for jobId (consistency with the workerId guard): `!jobId`
// admits a truthy non-string (a JSON number/object) that would otherwise
// ride into findRecordById on the dao's coercion behavior. 400 up front.
if (typeof jobId !== "string") {
return c.json(400, { error: "jobId must be a string" });
}
if (!target) {
return c.json(400, {
error: "status is required (done|failed|pending)",
});
}
if (["done", "failed", "pending"].indexOf(target) === -1) {
return c.json(400, { error: "status must be done|failed|pending" });
}
let released = false;
let view = null;
// REFUSAL REASON (threaded to the client on released:false). The caller's
// retry truthfulness depends on one distinction: a row that is TERMINAL
// UNDER THE CALLER'S OWN workerId can only mean the caller's earlier
// release COMMITTED and the response was lost (timeout-after-commit) — a
// terminal release retains claimed_by, and no other worker can set it.
// The client (queue-client report()) uses that to proceed to its result
// write instead of falsely declaring the result discarded.
// refused_terminal_same_holder — row done|failed with claimed_by ===
// workerId (the caller's own committed release; result still writable)
// refused_lease_live — pending-target (sweeper) release on a
// still-live lease (the TOCTOU close below; holder is alive)
// refused_not_holder — everything else (unknown row, another
// holder, or a terminal-target release on an already-expired lease —
// the caller is no longer the EFFECTIVE holder)
let reason = null;
// Wrap runInTransaction in try/catch so a thrown saveRecord does NOT
// escape as HTTP 500 — worker clients expect {released:false, reason}
// on refusal. The transaction rolls back on throw; reset JS-side flags
// to match. Use refused_not_holder as the conservative reason for an
// unexpected throw (treats the caller as no-longer-effective-holder,
// which is the same class the client already handles defensively).
try {
$app.dao().runInTransaction((txDao) => {
let rec;
try {
rec = txDao.findRecordById("probe_jobs", jobId);
} catch {
reason = "refused_not_holder";
return;
}
// Only the current lease holder may release, and only while the row is in a
// running state.
const status = rec.get("status");
if (RUNNING_STATES.indexOf(status) === -1) {
reason =
(status === "done" || status === "failed") &&
rec.get("claimed_by") === workerId
? "refused_terminal_same_holder"
: "refused_not_holder";
return;
}
if (rec.get("claimed_by") !== workerId) {
reason = "refused_not_holder";
return;
}
// The lease-expiry gate applies ONLY to TERMINAL targets (done|failed). If
// the lease already expired the holder LOST it (the sweeper may have
// re-queued it, or another worker may have stolen the claim) — letting a
// stale worker clobber terminal state would violate the exactly-one-winner
// invariant. Mirror renew's holder-lost-it semantics: reject the terminal
// release so the stale worker stops. (`claimed_by` unchanged is NOT
// sufficient — a stolen-then-released race can transiently match; the lease
// check is the authoritative gate for terminal writes.)
//
// But target === "pending" is the SWEEPER's re-queue (queue-client
// sweepExpired calls releaseJob(jobId, claimed_by, "pending") on EXPIRED
// rows on behalf of a crashed worker). That path operates on expired leases
// BY DESIGN, so it must be allowed to proceed even when leaseExpired is
// true — gating it here makes REQ-B crash reclamation inert (the sweeper
// reclaims 0, no worker-reclaimed-pending comm error is ever synthesized). The
// claimed_by match above still authorizes it, and re-queue to pending is
// always safe: it just resets the row to claimable.
if (target !== "pending" && leaseExpired(rec)) {
// Expired lease on a terminal-target release: the caller is no longer
// the EFFECTIVE holder (the claim is stealable/swept), so this is the
// not-holder class, not the committed-terminal class.
reason = "refused_not_holder";
return;
}
// TOCTOU close (the sweepers' list→release race): a "pending" release is
// a SWEEPER re-queueing an EXPIRED lease on behalf of its lapsed holder —
// but the sweeper decided "expired" from a LISTED SNAPSHOT. If the holder
// RENEWED between that list and this release, `claimed_by` still matches
// and an unguarded release would yank a LIVE, just-renewed job back to
// pending (duplicate execution + a false worker-reclaimed-pending comm
// error on the dashboard). Re-check expiry HERE, at release time, inside
// the same transaction: a still-live lease means the holder is alive —
// refuse, and the sweeper's released:false path skips the row (it retries
// on a later sweep once the lease has truly lapsed). This also guards
// fleet-health's reclaim of a heartbeat-stale worker whose job loop is
// still renewing: a renewing worker is alive, so refusal is correct
// there too. leaseExpired stays byte-equivalent to the client's anchored
// parse, so both sides agree on what "expired" means.
if (target === "pending" && !leaseExpired(rec)) {
reason = "refused_lease_live";
return;
}
rec.set("status", target);
if (target === "pending") {
// Re-queue: drop ownership so it's claimable again. The (expired)
// lease_expires_at is RETAINED — claim admits pending rows regardless
// of lease, and the stale value now serves as the row's
// "last in flight" marker: the queue-client's stale-pending sweep
// skips re-queued rows whose lease is recent, so a long-running job
// that out-lived its family's expiry window gets an actual re-run
// instead of being claim-deleted off its original `created` age.
rec.set("claimed_by", "");
// Run-metadata (§4.2): the sweeper re-queue is the second reclaim
// choke point (the first is the claim CAS's expired-lease steal) —
// bump the durable per-job lifetime reclaim tally. finished_at
// deliberately stays untouched (null until a TERMINAL release): a
// re-queued job has not finished.
rec.set("reclaim_count", (rec.get("reclaim_count") || 0) + 1);
// CONSECUTIVE-ORPHAN CAP (§4.2, reclaimable-leases): bump the
// per-job CONSECUTIVE re-orphan counter — distinct from the lifetime
// `reclaim_count`. The sweeper re-queue path is the ONLY writer; the
// claim CAS's expired-lease steal does NOT bump this counter, so a
// healthy long-lived job that accrues peer steals does NOT exhaust
// the MAX_RECLAIM_ATTEMPTS budget. The queue-client reads this field
// (not `reclaim_count`) for the deletion gate.
rec.set(
"consecutive_orphan_count",
(rec.get("consecutive_orphan_count") || 0) + 1,
);
// RECLAIMABLE-LEASES re-anchor (§4.2, layer a): stamp the re-queue
// time so the queue-client's stale-age math ages this row off
// `requeued_at` (not the renewal-immune `created`). This re-anchor is
// what lets the lease-phase carve-out RE-CLAIM a stale-aged orphan
// instead of claim-deleting it: the next sweep measures the row as
// young again, so its "back in flight" signal is not falsified.
rec.set("requeued_at", new Date().toISOString());
} else {
// Run-metadata (§4.2): terminal release (done|failed) stamps the
// finish time so run duration (finished_at claimed_at) is readable
// without parsing the `result` JSON.
rec.set("finished_at", new Date().toISOString());
// CONSECUTIVE-ORPHAN CAP: reset the consecutive counter on every
// terminal release so that a LATER re-orphan of this job starts with
// a fresh budget. The lifetime `reclaim_count` is NOT reset — it
// remains as the dashboard diagnostic for "this job was ever reclaimed".
rec.set("consecutive_orphan_count", 0);
}
rec.set("version", (rec.get("version") || 0) + 1);
txDao.saveRecord(rec);
released = true;
view = jobView(rec);
});
} catch {
released = false;
view = null;
reason = "refused_not_holder";
}
return c.json(
200,
released
? { released: true, job: view }
: { released: false, reason: reason },
);
},
$apis.requireAdminAuth(),
);