Files
nekonest-cloud/db/retention-runner.ts

104 lines
2.9 KiB
TypeScript

import { ensureDatabase } from "./bootstrap";
import { DomainError, runRetentionMaintenance, type RetentionMaintenanceResult } from "./repository";
import {
ACQUIRE_RETENTION_JOB_SQL,
COMPLETE_RETENTION_JOB_SQL,
FAIL_RETENTION_JOB_SQL,
RETENTION_JOB_KEY,
RETENTION_RUNNING_STALE_MS,
} from "./retention";
export type ScheduledRetentionOutcome =
| { status: "skipped"; reason: "already_running" }
| { status: "succeeded"; result: RetentionMaintenanceResult };
function scheduledTimestamp(value: number): string {
if (!Number.isFinite(value)) {
throw new TypeError("scheduled retention requires a finite timestamp");
}
const timestamp = new Date(value);
if (!Number.isFinite(timestamp.getTime())) {
throw new TypeError("scheduled retention requires a valid timestamp");
}
return timestamp.toISOString();
}
function safeErrorCode(error: unknown): string {
if (error instanceof DomainError && /^[a-z0-9_]{3,64}$/.test(error.code)) {
return error.code;
}
return "retention_maintenance_failed";
}
export async function runScheduledRetentionMaintenance(
db: D1Database,
scheduledTime: number,
): Promise<ScheduledRetentionOutcome> {
await ensureDatabase();
const scheduledAt = scheduledTimestamp(scheduledTime);
const startedAt = new Date().toISOString();
const staleBefore = new Date(
new Date(startedAt).getTime() - RETENTION_RUNNING_STALE_MS,
).toISOString();
const runId = `maintenance_${crypto.randomUUID().replaceAll("-", "")}`;
const acquired = await db
.prepare(ACQUIRE_RETENTION_JOB_SQL)
.bind(
RETENTION_JOB_KEY,
runId,
"scheduled",
scheduledAt,
startedAt,
startedAt,
startedAt,
staleBefore,
)
.run();
if (Number(acquired.meta.changes ?? 0) !== 1) {
return { status: "skipped", reason: "already_running" };
}
try {
const result = await runRetentionMaintenance({
actorId: "system:scheduled-retention",
confirmed: true,
reason: "每日自动清理已经到期的技术记录",
now: startedAt,
});
const completedAt = new Date().toISOString();
const completion = await db
.prepare(COMPLETE_RETENTION_JOB_SQL)
.bind(
completedAt,
completedAt,
JSON.stringify(result),
completedAt,
RETENTION_JOB_KEY,
runId,
)
.run();
if (Number(completion.meta.changes ?? 0) !== 1) {
throw new DomainError(
"retention_run_fenced",
"到期数据清理完成,但运行状态已经被更新的任务接管",
409,
);
}
return { status: "succeeded", result };
} catch (error) {
const failedAt = new Date().toISOString();
await db
.prepare(FAIL_RETENTION_JOB_SQL)
.bind(
failedAt,
safeErrorCode(error),
failedAt,
RETENTION_JOB_KEY,
runId,
)
.run();
throw error;
}
}