729 lines
25 KiB
TypeScript
729 lines
25 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
import {
|
|
PersonalReportJobServiceError,
|
|
createSupabasePersonalReportJobService,
|
|
type PersonalReportJobQueryResult,
|
|
} from "../src/lib/personal-report-job-service-core.ts";
|
|
import type { PersonalReportRecord } from "../src/lib/personal-report-service-core.ts";
|
|
|
|
const USER_A = "11111111-1111-4111-8111-111111111111";
|
|
const USER_B = "22222222-2222-4222-8222-222222222222";
|
|
const REQUEST_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
|
const REQUEST_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
|
const REQUEST_C = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
|
|
const FINGERPRINT_A = "a".repeat(64);
|
|
const FINGERPRINT_B = "b".repeat(64);
|
|
const WRONG_TOKEN = "ffffffff-ffff-4fff-8fff-ffffffffffff";
|
|
const READY_DOCUMENT = JSON.parse(readFileSync(
|
|
new URL("../../tests/fixtures/personal_report_document.v2.json", import.meta.url),
|
|
"utf8",
|
|
)) as Record<string, unknown>;
|
|
const REPORT_ID = String(READY_DOCUMENT.reportId);
|
|
const PROVENANCE = READY_DOCUMENT.provenance as Record<string, unknown>;
|
|
|
|
type Row = Record<string, unknown>;
|
|
type Filter =
|
|
| Readonly<{ kind: "eq"; column: string; value: unknown }>
|
|
| Readonly<{ kind: "gt"; column: string; value: unknown }>
|
|
| Readonly<{ kind: "in"; column: string; values: readonly unknown[] }>;
|
|
|
|
type Mutation =
|
|
| Readonly<{ kind: "insert"; row: Readonly<Record<string, unknown>> }>
|
|
| Readonly<{ kind: "update"; values: Readonly<Record<string, unknown>> }>
|
|
| Readonly<{ kind: "delete"; exactCount: boolean }>;
|
|
|
|
function uuidFor(value: number): string {
|
|
return `00000000-0000-4000-8000-${String(value).padStart(12, "0")}`;
|
|
}
|
|
|
|
function result(data: unknown, count?: number): PersonalReportJobQueryResult {
|
|
return { data, error: null, ...(count === undefined ? {} : { count }) };
|
|
}
|
|
|
|
class MemoryQueryBuilder implements PromiseLike<PersonalReportJobQueryResult> {
|
|
private mutation: Mutation | null = null;
|
|
private readonly filters: Filter[] = [];
|
|
private rowLimit: number | null = null;
|
|
private cardinality: "many" | "single" | "maybeSingle" = "many";
|
|
|
|
constructor(private readonly backend: MemoryBackend) {}
|
|
|
|
select() {
|
|
return this;
|
|
}
|
|
|
|
insert(row: Readonly<Record<string, unknown>>) {
|
|
this.mutation = { kind: "insert", row };
|
|
return this;
|
|
}
|
|
|
|
update(values: Readonly<Record<string, unknown>>) {
|
|
this.mutation = { kind: "update", values };
|
|
return this;
|
|
}
|
|
|
|
delete(options?: Readonly<{ count?: string }>) {
|
|
this.mutation = { kind: "delete", exactCount: options?.count === "exact" };
|
|
return this;
|
|
}
|
|
|
|
eq(column: string, value: unknown) {
|
|
this.filters.push({ kind: "eq", column, value });
|
|
return this;
|
|
}
|
|
|
|
gt(column: string, value: unknown) {
|
|
this.filters.push({ kind: "gt", column, value });
|
|
return this;
|
|
}
|
|
|
|
in(column: string, values: readonly unknown[]) {
|
|
this.filters.push({ kind: "in", column, values });
|
|
return this;
|
|
}
|
|
|
|
limit(value: number) {
|
|
this.rowLimit = value;
|
|
return this;
|
|
}
|
|
|
|
single() {
|
|
this.cardinality = "single";
|
|
return this.execute();
|
|
}
|
|
|
|
maybeSingle() {
|
|
this.cardinality = "maybeSingle";
|
|
return this.execute();
|
|
}
|
|
|
|
then<TResult1 = PersonalReportJobQueryResult, TResult2 = never>(
|
|
onfulfilled?: ((value: PersonalReportJobQueryResult) => TResult1 | PromiseLike<TResult1>) | null,
|
|
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
|
): PromiseLike<TResult1 | TResult2> {
|
|
return this.execute().then(onfulfilled, onrejected);
|
|
}
|
|
|
|
private matches(row: Row): boolean {
|
|
return this.filters.every((filter) => {
|
|
if (filter.kind === "eq") return row[filter.column] === filter.value;
|
|
if (filter.kind === "in") return filter.values.includes(row[filter.column]);
|
|
return String(row[filter.column]) > String(filter.value);
|
|
});
|
|
}
|
|
|
|
private cardinal(data: Row[]): PersonalReportJobQueryResult {
|
|
if (this.cardinality === "many") return result(data);
|
|
if (data.length > 1 || (this.cardinality === "single" && data.length !== 1)) {
|
|
return { data: null, error: { code: "PGRST116", message: "unexpected row count" } };
|
|
}
|
|
return result(data[0] ?? null);
|
|
}
|
|
|
|
private async execute(): Promise<PersonalReportJobQueryResult> {
|
|
if (!this.mutation) {
|
|
const rows = this.backend.rows.filter((row) => this.matches(row));
|
|
return this.cardinal(this.rowLimit === null ? rows : rows.slice(0, this.rowLimit));
|
|
}
|
|
|
|
if (this.mutation.kind === "insert") {
|
|
const incoming = this.mutation.row;
|
|
const sameRequest = this.backend.rows.some((row) =>
|
|
row.user_id === incoming.user_id && row.request_id === incoming.request_id);
|
|
const active = this.backend.rows.some((row) =>
|
|
row.user_id === incoming.user_id
|
|
&& ["queued", "running", "suspended", "retrying"].includes(String(row.status)));
|
|
if (sameRequest || active) {
|
|
return {
|
|
data: null,
|
|
error: { code: "23505", message: "personal_report_jobs unique constraint" },
|
|
};
|
|
}
|
|
const at = this.backend.now.toISOString();
|
|
const row: Row = {
|
|
id: uuidFor(++this.backend.sequence),
|
|
user_id: incoming.user_id,
|
|
request_id: incoming.request_id,
|
|
request_fingerprint: incoming.request_fingerprint,
|
|
status: incoming.status ?? "queued",
|
|
attempt_count: incoming.attempt_count ?? 0,
|
|
max_attempts: incoming.max_attempts ?? 3,
|
|
lease_token: null,
|
|
lease_owner: null,
|
|
lease_acquired_at: null,
|
|
lease_expires_at: null,
|
|
heartbeat_at: null,
|
|
next_attempt_at: null,
|
|
progress_phase: incoming.progress_phase ?? "queued",
|
|
progress_percent: incoming.progress_percent ?? 0,
|
|
last_error_code: null,
|
|
last_error_at: null,
|
|
started_at: null,
|
|
finished_at: null,
|
|
created_at: at,
|
|
updated_at: at,
|
|
};
|
|
this.backend.rows.push(row);
|
|
return this.cardinal([row]);
|
|
}
|
|
|
|
const matches = this.backend.rows.filter((row) => this.matches(row));
|
|
if (this.mutation.kind === "delete") {
|
|
this.backend.rows = this.backend.rows.filter((row) => !this.matches(row));
|
|
return result(null, this.mutation.exactCount ? matches.length : undefined);
|
|
}
|
|
|
|
for (const row of matches) {
|
|
if (["ready", "failed", "cancelled"].includes(String(row.status))) {
|
|
return {
|
|
data: null,
|
|
error: { code: "55000", message: "personal_report_job_terminal_immutable" },
|
|
};
|
|
}
|
|
Object.assign(row, this.mutation.values, { updated_at: this.backend.now.toISOString() });
|
|
}
|
|
return this.cardinal(matches);
|
|
}
|
|
}
|
|
|
|
class MemoryBackend {
|
|
rows: Row[] = [];
|
|
sequence = 0;
|
|
tokenSequence = 100;
|
|
lastCompletionArgs: Readonly<Record<string, unknown>> | null = null;
|
|
lastFailureArgs: Readonly<Record<string, unknown>> | null = null;
|
|
|
|
constructor(public now = new Date("2026-08-14T03:00:00.000Z")) {}
|
|
|
|
from(table: string) {
|
|
assert.equal(table, "personal_report_jobs");
|
|
return new MemoryQueryBuilder(this);
|
|
}
|
|
|
|
async rpc(
|
|
functionName: string,
|
|
args: Readonly<Record<string, unknown>> = {},
|
|
): Promise<PersonalReportJobQueryResult> {
|
|
if (functionName === "claim_personal_report_job") {
|
|
const requestedId = args.p_job_id;
|
|
const runnable = this.rows.find((row) =>
|
|
(requestedId === null || requestedId === undefined || row.id === requestedId)
|
|
&& ["queued", "retrying"].includes(String(row.status))
|
|
&& (row.next_attempt_at === null || Date.parse(String(row.next_attempt_at)) <= this.now.getTime())
|
|
&& Number(row.attempt_count) < Number(row.max_attempts));
|
|
if (!runnable) return result([]);
|
|
const at = this.now.toISOString();
|
|
runnable.status = "running";
|
|
runnable.attempt_count = Number(runnable.attempt_count) + 1;
|
|
runnable.lease_token = uuidFor(++this.tokenSequence);
|
|
runnable.lease_owner = String(args.p_worker_id);
|
|
runnable.lease_acquired_at = at;
|
|
runnable.heartbeat_at = at;
|
|
runnable.lease_expires_at = new Date(
|
|
this.now.getTime() + Number(args.p_lease_seconds) * 1000,
|
|
).toISOString();
|
|
runnable.next_attempt_at = null;
|
|
runnable.progress_phase = Number(runnable.attempt_count) === 1 ? "starting" : "retry_starting";
|
|
runnable.started_at ??= at;
|
|
runnable.updated_at = at;
|
|
return result([runnable]);
|
|
}
|
|
|
|
if (functionName === "heartbeat_personal_report_job") {
|
|
const row = this.rows.find((candidate) =>
|
|
candidate.id === args.p_job_id
|
|
&& candidate.status === "running"
|
|
&& candidate.lease_token === args.p_lease_token
|
|
&& Date.parse(String(candidate.lease_expires_at)) > this.now.getTime());
|
|
if (!row) return result([]);
|
|
row.heartbeat_at = this.now.toISOString();
|
|
row.lease_expires_at = new Date(
|
|
this.now.getTime() + Number(args.p_lease_seconds) * 1000,
|
|
).toISOString();
|
|
row.updated_at = this.now.toISOString();
|
|
return result([row]);
|
|
}
|
|
|
|
if (functionName === "complete_personal_report_job") {
|
|
this.lastCompletionArgs = args;
|
|
const row = this.rows.find((candidate) =>
|
|
candidate.id === args.p_job_id
|
|
&& candidate.status === "running"
|
|
&& candidate.lease_token === args.p_lease_token
|
|
&& candidate.user_id === args.p_user_id
|
|
&& candidate.request_id === args.p_request_id
|
|
&& candidate.request_fingerprint === args.p_request_fingerprint
|
|
&& Date.parse(String(candidate.lease_expires_at)) > this.now.getTime());
|
|
if (!row) {
|
|
return { data: null, error: { message: "personal_report_job_lease_lost" } };
|
|
}
|
|
const at = this.now.toISOString();
|
|
row.status = "ready";
|
|
row.lease_token = null;
|
|
row.lease_owner = null;
|
|
row.lease_acquired_at = null;
|
|
row.lease_expires_at = null;
|
|
row.heartbeat_at = at;
|
|
row.next_attempt_at = null;
|
|
row.progress_phase = "ready";
|
|
row.progress_percent = 100;
|
|
row.last_error_code = null;
|
|
row.last_error_at = null;
|
|
row.finished_at = at;
|
|
row.updated_at = at;
|
|
return result([row]);
|
|
}
|
|
|
|
if (functionName === "fail_personal_report_job") {
|
|
this.lastFailureArgs = args;
|
|
const row = this.rows.find((candidate) =>
|
|
candidate.id === args.p_job_id
|
|
&& candidate.status === "running"
|
|
&& candidate.lease_token === args.p_lease_token
|
|
&& candidate.user_id === args.p_user_id
|
|
&& candidate.request_id === args.p_request_id
|
|
&& candidate.request_fingerprint === args.p_request_fingerprint
|
|
&& Date.parse(String(candidate.lease_expires_at)) > this.now.getTime());
|
|
if (!row) {
|
|
return { data: null, error: { message: "personal_report_job_lease_lost" } };
|
|
}
|
|
const at = this.now.toISOString();
|
|
row.status = "failed";
|
|
row.lease_token = null;
|
|
row.lease_owner = null;
|
|
row.lease_acquired_at = null;
|
|
row.lease_expires_at = null;
|
|
row.heartbeat_at = at;
|
|
row.next_attempt_at = null;
|
|
row.progress_phase = "failed";
|
|
row.last_error_code = args.p_error_code;
|
|
row.last_error_at = at;
|
|
row.finished_at = at;
|
|
row.updated_at = at;
|
|
return result([row]);
|
|
}
|
|
|
|
if (functionName === "recover_expired_personal_report_jobs") {
|
|
let retryingCount = 0;
|
|
let failedCount = 0;
|
|
const limit = Number(args.p_limit);
|
|
const expired = this.rows
|
|
.filter((row) => row.status === "running"
|
|
&& Date.parse(String(row.lease_expires_at)) <= this.now.getTime())
|
|
.slice(0, limit);
|
|
for (const row of expired) {
|
|
const at = this.now.toISOString();
|
|
const retry = Number(row.attempt_count) < Number(row.max_attempts);
|
|
row.status = retry ? "retrying" : "failed";
|
|
row.lease_token = null;
|
|
row.lease_owner = null;
|
|
row.lease_acquired_at = null;
|
|
row.lease_expires_at = null;
|
|
row.heartbeat_at = null;
|
|
row.next_attempt_at = retry ? at : null;
|
|
row.progress_phase = retry ? "retry_wait" : "failed";
|
|
row.last_error_code = retry ? "lease_expired" : "lease_expired_attempts_exhausted";
|
|
row.last_error_at = at;
|
|
row.finished_at = retry ? null : at;
|
|
row.updated_at = at;
|
|
if (retry) retryingCount += 1;
|
|
else failedCount += 1;
|
|
}
|
|
return result([{ retrying_count: retryingCount, failed_count: failedCount }]);
|
|
}
|
|
|
|
return { data: null, error: { message: "unknown rpc" } };
|
|
}
|
|
}
|
|
|
|
function fixture() {
|
|
const backend = new MemoryBackend();
|
|
const service = createSupabasePersonalReportJobService(backend, {
|
|
now: () => new Date(backend.now),
|
|
});
|
|
return { backend, service };
|
|
}
|
|
|
|
function reportRecord(): PersonalReportRecord {
|
|
return {
|
|
id: REPORT_ID,
|
|
userId: USER_A,
|
|
sessionId: null,
|
|
chartProfileId: null,
|
|
requestId: REQUEST_A,
|
|
requestFingerprint: FINGERPRINT_A,
|
|
reportType: "personal_full",
|
|
status: "generating",
|
|
schemaVersion: "report_document.v2",
|
|
presentationMode: "default",
|
|
depth: "standard",
|
|
requestedThemes: ["career"],
|
|
reportDocument: null,
|
|
calculationHash: null,
|
|
evidenceHash: null,
|
|
skillName: String(PROVENANCE.skillName),
|
|
skillVersion: String(PROVENANCE.skillVersion),
|
|
skillSourceCommit: String(PROVENANCE.skillSourceCommit),
|
|
skillSnapshotSha256: String(PROVENANCE.skillSnapshotSha256),
|
|
failureCode: null,
|
|
createdAt: "2026-08-14T03:00:00.000Z",
|
|
updatedAt: "2026-08-14T03:00:00.000Z",
|
|
completedAt: null,
|
|
};
|
|
}
|
|
|
|
async function expectCode(
|
|
promise: Promise<unknown>,
|
|
code: PersonalReportJobServiceError["code"],
|
|
): Promise<void> {
|
|
await assert.rejects(
|
|
promise,
|
|
(error) => error instanceof PersonalReportJobServiceError && error.code === code,
|
|
);
|
|
}
|
|
|
|
test("enqueue replays the same request fingerprint and reports a changed fingerprint conflict", async () => {
|
|
const { backend, service } = fixture();
|
|
const input = {
|
|
userId: USER_A,
|
|
requestId: REQUEST_A,
|
|
requestFingerprint: FINGERPRINT_A,
|
|
};
|
|
|
|
const created = await service.enqueue(input);
|
|
assert.equal(created.kind, "created");
|
|
const replayed = await service.enqueue(input);
|
|
assert.equal(replayed.kind, "replayed");
|
|
assert.equal(replayed.job.id, created.job.id);
|
|
|
|
const conflict = await service.enqueue({ ...input, requestFingerprint: FINGERPRINT_B });
|
|
assert.equal(conflict.kind, "request_conflict");
|
|
assert.equal(conflict.job.id, created.job.id);
|
|
assert.equal(backend.rows.length, 1);
|
|
assert.deepEqual(
|
|
Object.keys(backend.rows[0]).filter((key) => /payload|prompt|birth|report_document|model/i.test(key)),
|
|
[],
|
|
);
|
|
});
|
|
|
|
test("enqueue preserves one active job per user while allowing another owner", async () => {
|
|
const { service } = fixture();
|
|
const first = await service.enqueue({
|
|
userId: USER_A,
|
|
requestId: REQUEST_A,
|
|
requestFingerprint: FINGERPRINT_A,
|
|
});
|
|
const blocked = await service.enqueue({
|
|
userId: USER_A,
|
|
requestId: REQUEST_B,
|
|
requestFingerprint: FINGERPRINT_B,
|
|
});
|
|
const otherOwner = await service.enqueue({
|
|
userId: USER_B,
|
|
requestId: REQUEST_B,
|
|
requestFingerprint: FINGERPRINT_B,
|
|
});
|
|
|
|
assert.equal(first.kind, "created");
|
|
assert.equal(blocked.kind, "active_job_exists");
|
|
assert.equal(blocked.job.id, first.job.id);
|
|
assert.equal(otherOwner.kind, "created");
|
|
});
|
|
|
|
test("claim and heartbeat require the exact live lease token", async () => {
|
|
const { backend, service } = fixture();
|
|
const enqueued = await service.enqueue({
|
|
userId: USER_A,
|
|
requestId: REQUEST_A,
|
|
requestFingerprint: FINGERPRINT_A,
|
|
});
|
|
assert.equal(enqueued.kind, "created");
|
|
|
|
const claimed = await service.claimLease({
|
|
workerId: "worker-a",
|
|
leaseSeconds: 30,
|
|
jobId: enqueued.job.id,
|
|
});
|
|
assert.ok(claimed?.leaseToken);
|
|
assert.equal(claimed.status, "running");
|
|
assert.equal(claimed.attemptCount, 1);
|
|
|
|
await expectCode(service.heartbeatLease({
|
|
jobId: claimed.id,
|
|
leaseToken: WRONG_TOKEN,
|
|
leaseSeconds: 60,
|
|
}), "lease_lost");
|
|
|
|
backend.now = new Date("2026-08-14T03:00:10.000Z");
|
|
const heartbeat = await service.heartbeatLease({
|
|
jobId: claimed.id,
|
|
leaseToken: claimed.leaseToken,
|
|
leaseSeconds: 60,
|
|
});
|
|
assert.equal(heartbeat.heartbeatAt, backend.now.toISOString());
|
|
assert.equal(heartbeat.leaseExpiresAt, "2026-08-14T03:01:10.000Z");
|
|
|
|
await expectCode(service.updateProgress({
|
|
jobId: claimed.id,
|
|
leaseToken: WRONG_TOKEN,
|
|
phase: "building_evidence",
|
|
percent: 40,
|
|
}), "lease_lost");
|
|
const progressed = await service.updateProgress({
|
|
jobId: claimed.id,
|
|
leaseToken: claimed.leaseToken,
|
|
phase: "building_evidence",
|
|
percent: 40,
|
|
});
|
|
assert.equal(progressed.progressPhase, "building_evidence");
|
|
assert.equal(progressed.progressPercent, 40);
|
|
});
|
|
|
|
test("atomic completion binds the canonical v2 document to the exact live lease", async () => {
|
|
const { backend, service } = fixture();
|
|
const enqueued = await service.enqueue({
|
|
userId: USER_A,
|
|
requestId: REQUEST_A,
|
|
requestFingerprint: FINGERPRINT_A,
|
|
});
|
|
assert.equal(enqueued.kind, "created");
|
|
const claimed = await service.claimLease({ workerId: "worker-a", jobId: enqueued.job.id });
|
|
assert.ok(claimed?.leaseToken);
|
|
|
|
const completed = await service.completeReportReady({
|
|
jobId: claimed.id,
|
|
leaseToken: claimed.leaseToken,
|
|
report: reportRecord(),
|
|
document: READY_DOCUMENT,
|
|
});
|
|
|
|
assert.equal(completed.status, "ready");
|
|
assert.equal(completed.progressPercent, 100);
|
|
assert.equal(backend.lastCompletionArgs?.p_schema_version, "report_document.v2");
|
|
assert.equal(backend.lastCompletionArgs?.p_report_id, REPORT_ID);
|
|
assert.deepEqual(backend.lastCompletionArgs?.p_report_document, READY_DOCUMENT);
|
|
assert.equal(backend.lastCompletionArgs?.p_evidence_hash, PROVENANCE.evidenceHash);
|
|
});
|
|
|
|
test("atomic failure binds the report failure to the exact live lease", async () => {
|
|
const { backend, service } = fixture();
|
|
const enqueued = await service.enqueue({
|
|
userId: USER_A,
|
|
requestId: REQUEST_A,
|
|
requestFingerprint: FINGERPRINT_A,
|
|
});
|
|
assert.equal(enqueued.kind, "created");
|
|
const claimed = await service.claimLease({ workerId: "worker-a", jobId: enqueued.job.id });
|
|
assert.ok(claimed?.leaseToken);
|
|
|
|
const failed = await service.completeReportFailed({
|
|
jobId: claimed.id,
|
|
leaseToken: claimed.leaseToken,
|
|
report: reportRecord(),
|
|
errorCode: "report_schema_invalid",
|
|
failureCode: "report_schema_invalid",
|
|
});
|
|
|
|
assert.equal(failed.status, "failed");
|
|
assert.equal(failed.lastErrorCode, "report_schema_invalid");
|
|
assert.equal(backend.lastFailureArgs?.p_report_id, REPORT_ID);
|
|
assert.equal(backend.lastFailureArgs?.p_failure_code, "report_schema_invalid");
|
|
});
|
|
|
|
test("atomic failure rejects an expired lease without changing the job", async () => {
|
|
const { backend, service } = fixture();
|
|
const enqueued = await service.enqueue({
|
|
userId: USER_A,
|
|
requestId: REQUEST_A,
|
|
requestFingerprint: FINGERPRINT_A,
|
|
});
|
|
assert.equal(enqueued.kind, "created");
|
|
const claimed = await service.claimLease({
|
|
workerId: "worker-a",
|
|
leaseSeconds: 5,
|
|
jobId: enqueued.job.id,
|
|
});
|
|
assert.ok(claimed?.leaseToken);
|
|
backend.now = new Date("2026-08-14T03:00:06.000Z");
|
|
|
|
await expectCode(service.completeReportFailed({
|
|
jobId: claimed.id,
|
|
leaseToken: claimed.leaseToken,
|
|
report: reportRecord(),
|
|
errorCode: "report_schema_invalid",
|
|
failureCode: "report_schema_invalid",
|
|
}), "lease_lost");
|
|
|
|
assert.equal((await service.getOwnedById(USER_A, claimed.id))?.status, "running");
|
|
});
|
|
|
|
test("atomic completion rejects an expired lease without changing the job", async () => {
|
|
const { backend, service } = fixture();
|
|
const enqueued = await service.enqueue({
|
|
userId: USER_A,
|
|
requestId: REQUEST_A,
|
|
requestFingerprint: FINGERPRINT_A,
|
|
});
|
|
assert.equal(enqueued.kind, "created");
|
|
const claimed = await service.claimLease({
|
|
workerId: "worker-a",
|
|
leaseSeconds: 5,
|
|
jobId: enqueued.job.id,
|
|
});
|
|
assert.ok(claimed?.leaseToken);
|
|
backend.now = new Date("2026-08-14T03:00:06.000Z");
|
|
|
|
await expectCode(service.completeReportReady({
|
|
jobId: claimed.id,
|
|
leaseToken: claimed.leaseToken,
|
|
report: reportRecord(),
|
|
document: READY_DOCUMENT,
|
|
}), "lease_lost");
|
|
|
|
assert.equal((await service.getOwnedById(USER_A, claimed.id))?.status, "running");
|
|
});
|
|
|
|
test("ready is terminal and owner reads/deletes remain owner-scoped", async () => {
|
|
const { service } = fixture();
|
|
const enqueued = await service.enqueue({
|
|
userId: USER_A,
|
|
requestId: REQUEST_A,
|
|
requestFingerprint: FINGERPRINT_A,
|
|
});
|
|
assert.equal(enqueued.kind, "created");
|
|
const claimed = await service.claimLease({ workerId: "worker-a", jobId: enqueued.job.id });
|
|
assert.ok(claimed?.leaseToken);
|
|
|
|
const ready = await service.markReady({ jobId: claimed.id, leaseToken: claimed.leaseToken });
|
|
assert.equal(ready.status, "ready");
|
|
assert.equal(ready.progressPercent, 100);
|
|
await expectCode(service.markFailed({
|
|
jobId: ready.id,
|
|
leaseToken: claimed.leaseToken,
|
|
errorCode: "late_failure",
|
|
}), "terminal_immutable");
|
|
await expectCode(service.cancel({ jobId: ready.id }), "terminal_immutable");
|
|
|
|
assert.equal(await service.getOwnedById(USER_B, ready.id), null);
|
|
assert.equal((await service.getOwnedById(USER_A, ready.id))?.status, "ready");
|
|
assert.equal(await service.deleteOwned(USER_B, ready.id), false);
|
|
assert.equal(await service.deleteOwned(USER_A, ready.id), true);
|
|
assert.equal(await service.getOwnedById(USER_A, ready.id), null);
|
|
});
|
|
|
|
test("retry is bounded and exhaustion preserves the live lease for atomic report failure", async () => {
|
|
const { service } = fixture();
|
|
const enqueued = await service.enqueue({
|
|
userId: USER_A,
|
|
requestId: REQUEST_A,
|
|
requestFingerprint: FINGERPRINT_A,
|
|
maxAttempts: 2,
|
|
});
|
|
assert.equal(enqueued.kind, "created");
|
|
const first = await service.claimLease({ workerId: "worker-a", jobId: enqueued.job.id });
|
|
assert.ok(first?.leaseToken);
|
|
const scheduled = await service.scheduleRetry({
|
|
jobId: first.id,
|
|
leaseToken: first.leaseToken,
|
|
errorCode: "model_unavailable",
|
|
});
|
|
assert.equal(scheduled.kind, "scheduled");
|
|
assert.equal(scheduled.job.status, "retrying");
|
|
|
|
const second = await service.claimLease({ workerId: "worker-b", jobId: first.id });
|
|
assert.ok(second?.leaseToken);
|
|
assert.equal(second.attemptCount, 2);
|
|
const exhausted = await service.scheduleRetry({
|
|
jobId: second.id,
|
|
leaseToken: second.leaseToken,
|
|
errorCode: "model_unavailable",
|
|
});
|
|
assert.equal(exhausted.kind, "exhausted");
|
|
assert.equal(exhausted.job.status, "running");
|
|
assert.equal(exhausted.job.leaseToken, second.leaseToken);
|
|
assert.equal(await service.claimLease({ workerId: "worker-c", jobId: second.id }), null);
|
|
});
|
|
|
|
test("expired lease recovery retries within budget and fails at the bound", async () => {
|
|
const { backend, service } = fixture();
|
|
const enqueued = await service.enqueue({
|
|
userId: USER_A,
|
|
requestId: REQUEST_C,
|
|
requestFingerprint: FINGERPRINT_A,
|
|
maxAttempts: 2,
|
|
});
|
|
assert.equal(enqueued.kind, "created");
|
|
const first = await service.claimLease({
|
|
workerId: "worker-a",
|
|
leaseSeconds: 5,
|
|
jobId: enqueued.job.id,
|
|
});
|
|
assert.ok(first);
|
|
|
|
backend.now = new Date("2026-08-14T03:00:06.000Z");
|
|
assert.deepEqual(
|
|
await service.recoverExpiredLeases(1),
|
|
{ retryingCount: 1, failedCount: 0 },
|
|
);
|
|
const retrying = await service.getOwnedById(USER_A, first.id);
|
|
assert.equal(retrying?.status, "retrying");
|
|
assert.equal(retrying?.lastErrorCode, "lease_expired");
|
|
|
|
const second = await service.claimLease({
|
|
workerId: "worker-b",
|
|
leaseSeconds: 5,
|
|
jobId: first.id,
|
|
});
|
|
assert.ok(second);
|
|
assert.equal(second.attemptCount, 2);
|
|
backend.now = new Date("2026-08-14T03:00:12.000Z");
|
|
assert.deepEqual(
|
|
await service.recoverExpiredLeases(1),
|
|
{ retryingCount: 0, failedCount: 1 },
|
|
);
|
|
const failed = await service.getOwnedById(USER_A, first.id);
|
|
assert.equal(failed?.status, "failed");
|
|
assert.equal(failed?.lastErrorCode, "lease_expired_attempts_exhausted");
|
|
});
|
|
|
|
test("suspend and cancel enforce valid state and identifier shapes", async () => {
|
|
const { service } = fixture();
|
|
const enqueued = await service.enqueue({
|
|
userId: USER_A,
|
|
requestId: REQUEST_A,
|
|
requestFingerprint: FINGERPRINT_A,
|
|
});
|
|
assert.equal(enqueued.kind, "created");
|
|
const suspended = await service.suspend({ jobId: enqueued.job.id });
|
|
assert.equal(suspended.status, "suspended");
|
|
const cancelled = await service.cancel({ jobId: suspended.id });
|
|
assert.equal(cancelled.status, "cancelled");
|
|
|
|
await expectCode(service.enqueue({
|
|
userId: "not-a-uuid",
|
|
requestId: REQUEST_B,
|
|
requestFingerprint: FINGERPRINT_B,
|
|
}), "invalid_request");
|
|
await expectCode(service.heartbeatLease({
|
|
jobId: enqueued.job.id,
|
|
leaseToken: "not-a-token",
|
|
}), "invalid_request");
|
|
});
|
|
|
|
test("service source exposes only operational job columns", () => {
|
|
const source = readFileSync(
|
|
new URL("../src/lib/personal-report-job-service-core.ts", import.meta.url),
|
|
"utf8",
|
|
);
|
|
assert.doesNotMatch(source, /console\.(?:log|info|warn|error)/);
|
|
const columns = source.match(/const JOB_COLUMNS = \[([\s\S]*?)\]\.join/)?.[1] ?? "";
|
|
assert.doesNotMatch(columns, /birth|prompt|report_document|payload|model/i);
|
|
assert.match(source, /claim_personal_report_job/);
|
|
assert.match(source, /heartbeat_personal_report_job/);
|
|
assert.match(source, /complete_personal_report_job/);
|
|
assert.match(source, /fail_personal_report_job/);
|
|
assert.match(source, /recover_expired_personal_report_jobs/);
|
|
});
|