204 lines
6.2 KiB
TypeScript
204 lines
6.2 KiB
TypeScript
export const PERSONAL_REPORT_JOB_STATUSES = [
|
|
"queued",
|
|
"running",
|
|
"suspended",
|
|
"retrying",
|
|
"ready",
|
|
"failed",
|
|
"cancelled",
|
|
] as const;
|
|
|
|
export type PersonalReportJobStatus = (typeof PERSONAL_REPORT_JOB_STATUSES)[number];
|
|
|
|
export const PERSONAL_REPORT_JOB_ACTIVE_STATUSES = [
|
|
"queued",
|
|
"running",
|
|
"suspended",
|
|
"retrying",
|
|
] as const satisfies readonly PersonalReportJobStatus[];
|
|
|
|
export const PERSONAL_REPORT_JOB_TERMINAL_STATUSES = [
|
|
"ready",
|
|
"failed",
|
|
"cancelled",
|
|
] as const satisfies readonly PersonalReportJobStatus[];
|
|
|
|
export const DEFAULT_PERSONAL_REPORT_JOB_MAX_ATTEMPTS = 3;
|
|
export const MAX_PERSONAL_REPORT_JOB_MAX_ATTEMPTS = 10;
|
|
|
|
const transitionTargets = {
|
|
queued: ["running", "suspended", "cancelled"],
|
|
running: ["suspended", "retrying", "ready", "failed", "cancelled"],
|
|
suspended: ["queued", "cancelled"],
|
|
retrying: ["queued", "running", "failed", "cancelled"],
|
|
ready: [],
|
|
failed: [],
|
|
cancelled: [],
|
|
} as const satisfies Record<PersonalReportJobStatus, readonly PersonalReportJobStatus[]>;
|
|
|
|
const requestFingerprintPattern = /^[0-9a-f]{64}$/;
|
|
const progressPhasePattern = /^[a-z][a-z0-9_]{0,63}$/;
|
|
|
|
export type PersonalReportJobStateErrorReason =
|
|
| "invalid_transition"
|
|
| "invalid_retry_budget"
|
|
| "invalid_progress_phase";
|
|
|
|
export class PersonalReportJobStateError extends Error {
|
|
readonly name = "PersonalReportJobStateError";
|
|
|
|
constructor(
|
|
readonly reason: PersonalReportJobStateErrorReason,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
export function isPersonalReportJobActive(status: PersonalReportJobStatus): boolean {
|
|
return (PERSONAL_REPORT_JOB_ACTIVE_STATUSES as readonly string[]).includes(status);
|
|
}
|
|
|
|
export function isPersonalReportJobTerminal(status: PersonalReportJobStatus): boolean {
|
|
return (PERSONAL_REPORT_JOB_TERMINAL_STATUSES as readonly string[]).includes(status);
|
|
}
|
|
|
|
export function canTransitionPersonalReportJob(
|
|
from: PersonalReportJobStatus,
|
|
to: PersonalReportJobStatus,
|
|
): boolean {
|
|
return (transitionTargets[from] as readonly PersonalReportJobStatus[]).includes(to);
|
|
}
|
|
|
|
export function assertPersonalReportJobTransition(
|
|
from: PersonalReportJobStatus,
|
|
to: PersonalReportJobStatus,
|
|
): void {
|
|
if (!canTransitionPersonalReportJob(from, to)) {
|
|
throw new PersonalReportJobStateError(
|
|
"invalid_transition",
|
|
`Personal report job cannot transition from ${from} to ${to}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertRetryBudget(attemptCount: number, maxAttempts: number): void {
|
|
if (
|
|
!Number.isInteger(attemptCount)
|
|
|| attemptCount < 0
|
|
|| !Number.isInteger(maxAttempts)
|
|
|| maxAttempts < 1
|
|
|| maxAttempts > MAX_PERSONAL_REPORT_JOB_MAX_ATTEMPTS
|
|
|| attemptCount > maxAttempts
|
|
) {
|
|
throw new PersonalReportJobStateError(
|
|
"invalid_retry_budget",
|
|
`Invalid personal report retry budget: ${attemptCount}/${maxAttempts}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* attemptCount is incremented when a worker successfully acquires a running
|
|
* lease. A retry may be scheduled only while another attempt remains.
|
|
*/
|
|
export function canRetryPersonalReportJob(
|
|
attemptCount: number,
|
|
maxAttempts: number,
|
|
): boolean {
|
|
assertRetryBudget(attemptCount, maxAttempts);
|
|
return attemptCount < maxAttempts;
|
|
}
|
|
|
|
export type PersonalReportJobLease = Readonly<{
|
|
status: PersonalReportJobStatus;
|
|
leaseOwner: string | null;
|
|
leaseExpiresAt: string | null;
|
|
}>;
|
|
|
|
function parsedTime(value: string | null): number | null {
|
|
if (!value) return null;
|
|
const parsed = Date.parse(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
/**
|
|
* A running row without a valid expiry is treated as expired. This fail-closed
|
|
* rule prevents malformed leases from holding a user's active slot forever.
|
|
*/
|
|
export function isPersonalReportJobLeaseExpired(
|
|
job: PersonalReportJobLease,
|
|
now: Date,
|
|
): boolean {
|
|
if (job.status !== "running") return false;
|
|
const expiresAt = parsedTime(job.leaseExpiresAt);
|
|
return expiresAt === null || expiresAt <= now.getTime();
|
|
}
|
|
|
|
export function canHeartbeatPersonalReportJobLease(
|
|
job: PersonalReportJobLease,
|
|
workerId: string,
|
|
now: Date,
|
|
): boolean {
|
|
const expiresAt = parsedTime(job.leaseExpiresAt);
|
|
return job.status === "running"
|
|
&& workerId.length > 0
|
|
&& job.leaseOwner === workerId
|
|
&& expiresAt !== null
|
|
&& expiresAt > now.getTime();
|
|
}
|
|
|
|
export type PersonalReportExpiredLeaseRecovery =
|
|
| Readonly<{ kind: "none"; nextStatus: null }>
|
|
| Readonly<{ kind: "retry"; nextStatus: "retrying" }>
|
|
| Readonly<{ kind: "exhausted"; nextStatus: "failed" }>;
|
|
|
|
export function recoverExpiredPersonalReportJobLease(
|
|
job: PersonalReportJobLease & Readonly<{ attemptCount: number; maxAttempts: number }>,
|
|
now: Date,
|
|
): PersonalReportExpiredLeaseRecovery {
|
|
assertRetryBudget(job.attemptCount, job.maxAttempts);
|
|
if (!isPersonalReportJobLeaseExpired(job, now)) {
|
|
return { kind: "none", nextStatus: null };
|
|
}
|
|
return canRetryPersonalReportJob(job.attemptCount, job.maxAttempts)
|
|
? { kind: "retry", nextStatus: "retrying" }
|
|
: { kind: "exhausted", nextStatus: "failed" };
|
|
}
|
|
|
|
export function isPersonalReportRequestFingerprint(value: string): boolean {
|
|
return requestFingerprintPattern.test(value);
|
|
}
|
|
|
|
export type PersonalReportJobRequestIdentity = Readonly<{
|
|
requestId: string;
|
|
requestFingerprint: string;
|
|
}>;
|
|
|
|
export type PersonalReportJobRequestMatch = "new" | "replay" | "conflict";
|
|
|
|
/**
|
|
* requestId is the idempotency key. Reusing it with the same canonical request
|
|
* fingerprint is a replay; reusing it for different intent is a conflict.
|
|
*/
|
|
export function classifyPersonalReportJobRequest(
|
|
existing: PersonalReportJobRequestIdentity | null,
|
|
incoming: PersonalReportJobRequestIdentity,
|
|
): PersonalReportJobRequestMatch {
|
|
if (!existing || existing.requestId !== incoming.requestId) return "new";
|
|
return existing.requestFingerprint === incoming.requestFingerprint ? "replay" : "conflict";
|
|
}
|
|
|
|
export function isValidPersonalReportJobProgressPhase(value: string): boolean {
|
|
return progressPhasePattern.test(value);
|
|
}
|
|
|
|
export function assertPersonalReportJobProgressPhase(value: string): void {
|
|
if (!isValidPersonalReportJobProgressPhase(value)) {
|
|
throw new PersonalReportJobStateError(
|
|
"invalid_progress_phase",
|
|
`Invalid personal report progress phase: ${value}`,
|
|
);
|
|
}
|
|
}
|