Files
Jyotisha/frontend/src/lib/personal-report-job-state.ts
T

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}`,
);
}
}