272 lines
9.2 KiB
TypeScript
272 lines
9.2 KiB
TypeScript
export const CLAIM_RATE_RETENTION_MS = 24 * 60 * 60_000;
|
|
export const CLAIM_ATTEMPT_RETENTION_MS = 30 * 24 * 60 * 60_000;
|
|
export const HANDOFF_TICKET_RETENTION_MS = 24 * 60 * 60_000;
|
|
export const RETENTION_JOB_KEY = "retention_cleanup";
|
|
export const RETENTION_CRON = "17 18 * * *";
|
|
export const RETENTION_RUNNING_STALE_MS = 30 * 60_000;
|
|
export const RETENTION_SUCCESS_OVERDUE_MS = 36 * 60 * 60_000;
|
|
const RETENTION_FUTURE_SKEW_MS = 60_000;
|
|
|
|
export type RetentionCutoffs = {
|
|
now: string;
|
|
claimRateBefore: string;
|
|
claimAttemptBefore: string;
|
|
handoffTicketBefore: string;
|
|
};
|
|
|
|
export type RetentionJobRecord = {
|
|
key: string;
|
|
state: string;
|
|
run_id: string | null;
|
|
trigger: string | null;
|
|
scheduled_at: string | null;
|
|
started_at: string | null;
|
|
completed_at: string | null;
|
|
last_success_at: string | null;
|
|
result_json: string | null;
|
|
error_code: string | null;
|
|
consecutive_failures: number;
|
|
run_count: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
};
|
|
|
|
export type RetentionJobHealth = {
|
|
state: "never_run" | "running" | "healthy" | "overdue" | "failed" | "stalled" | "invalid";
|
|
trigger: "scheduled" | "manual" | null;
|
|
startedAt: string | null;
|
|
completedAt: string | null;
|
|
lastSuccessAt: string | null;
|
|
errorCode: string | null;
|
|
consecutiveFailures: number;
|
|
runCount: number;
|
|
ageSeconds: number | null;
|
|
};
|
|
|
|
function parseTimestamp(value: string | null): number | null {
|
|
if (!value) return null;
|
|
const timestamp = new Date(value).getTime();
|
|
return Number.isFinite(timestamp) ? timestamp : null;
|
|
}
|
|
|
|
export function deriveRetentionJobHealth(
|
|
record: RetentionJobRecord | null,
|
|
now = new Date().toISOString(),
|
|
): RetentionJobHealth {
|
|
if (!record) {
|
|
return {
|
|
state: "never_run",
|
|
trigger: null,
|
|
startedAt: null,
|
|
completedAt: null,
|
|
lastSuccessAt: null,
|
|
errorCode: null,
|
|
consecutiveFailures: 0,
|
|
runCount: 0,
|
|
ageSeconds: null,
|
|
};
|
|
}
|
|
|
|
const nowMilliseconds = new Date(now).getTime();
|
|
const scheduledMilliseconds = parseTimestamp(record.scheduled_at);
|
|
const startedMilliseconds = parseTimestamp(record.started_at);
|
|
const completedMilliseconds = parseTimestamp(record.completed_at);
|
|
const successMilliseconds = parseTimestamp(record.last_success_at);
|
|
const countsValid = Number.isSafeInteger(record.run_count) && record.run_count >= 1
|
|
&& Number.isSafeInteger(record.consecutive_failures) && record.consecutive_failures >= 0;
|
|
const errorCodeValid = record.error_code === null
|
|
|| /^[a-z0-9_]{3,64}$/.test(record.error_code);
|
|
const trigger: RetentionJobHealth["trigger"] = record.trigger === "scheduled" || record.trigger === "manual"
|
|
? record.trigger
|
|
: null;
|
|
const base = {
|
|
trigger,
|
|
startedAt: record.started_at,
|
|
completedAt: record.completed_at,
|
|
lastSuccessAt: record.last_success_at,
|
|
errorCode: record.error_code,
|
|
consecutiveFailures: countsValid ? record.consecutive_failures : 0,
|
|
runCount: countsValid ? record.run_count : 0,
|
|
};
|
|
const invalid = !Number.isFinite(nowMilliseconds)
|
|
|| record.key !== RETENTION_JOB_KEY
|
|
|| !countsValid
|
|
|| !errorCodeValid
|
|
|| !trigger
|
|
|| !record.run_id
|
|
|| scheduledMilliseconds === null
|
|
|| !startedMilliseconds
|
|
|| scheduledMilliseconds > nowMilliseconds + RETENTION_FUTURE_SKEW_MS
|
|
|| startedMilliseconds > nowMilliseconds + RETENTION_FUTURE_SKEW_MS
|
|
|| (record.completed_at !== null && completedMilliseconds === null)
|
|
|| (record.last_success_at !== null && successMilliseconds === null)
|
|
|| (completedMilliseconds !== null && completedMilliseconds > nowMilliseconds + RETENTION_FUTURE_SKEW_MS)
|
|
|| (successMilliseconds !== null && successMilliseconds > nowMilliseconds + RETENTION_FUTURE_SKEW_MS)
|
|
|| (completedMilliseconds !== null && completedMilliseconds < startedMilliseconds)
|
|
|| (successMilliseconds !== null && completedMilliseconds !== null && successMilliseconds > completedMilliseconds);
|
|
if (invalid) {
|
|
return { state: "invalid", ...base, ageSeconds: null };
|
|
}
|
|
|
|
if (record.state === "running") {
|
|
if (record.error_code !== null) {
|
|
return { state: "invalid", ...base, ageSeconds: null };
|
|
}
|
|
const ageMilliseconds = Math.max(0, nowMilliseconds - startedMilliseconds);
|
|
return {
|
|
state: ageMilliseconds > RETENTION_RUNNING_STALE_MS ? "stalled" : "running",
|
|
...base,
|
|
ageSeconds: Math.floor(ageMilliseconds / 1_000),
|
|
};
|
|
}
|
|
|
|
if (record.state === "failed") {
|
|
if (record.error_code === null) {
|
|
return { state: "invalid", ...base, ageSeconds: null };
|
|
}
|
|
const failureAgeMilliseconds = Math.max(
|
|
0,
|
|
nowMilliseconds - (completedMilliseconds ?? startedMilliseconds),
|
|
);
|
|
return {
|
|
state: "failed",
|
|
...base,
|
|
ageSeconds: Math.floor(failureAgeMilliseconds / 1_000),
|
|
};
|
|
}
|
|
if (record.state !== "succeeded"
|
|
|| record.error_code !== null
|
|
|| successMilliseconds === null
|
|
|| completedMilliseconds === null) {
|
|
return { state: "invalid", ...base, ageSeconds: null };
|
|
}
|
|
const ageMilliseconds = Math.max(0, nowMilliseconds - successMilliseconds);
|
|
return {
|
|
state: ageMilliseconds > RETENTION_SUCCESS_OVERDUE_MS ? "overdue" : "healthy",
|
|
...base,
|
|
ageSeconds: Math.floor(ageMilliseconds / 1_000),
|
|
};
|
|
}
|
|
|
|
export function getRetentionCutoffs(now: string): RetentionCutoffs {
|
|
const nowMilliseconds = new Date(now).getTime();
|
|
if (!Number.isFinite(nowMilliseconds)) {
|
|
throw new TypeError("retention maintenance requires an ISO timestamp");
|
|
}
|
|
return {
|
|
now: new Date(nowMilliseconds).toISOString(),
|
|
claimRateBefore: new Date(nowMilliseconds - CLAIM_RATE_RETENTION_MS).toISOString(),
|
|
claimAttemptBefore: new Date(nowMilliseconds - CLAIM_ATTEMPT_RETENTION_MS).toISOString(),
|
|
handoffTicketBefore: new Date(nowMilliseconds - HANDOFF_TICKET_RETENTION_MS).toISOString(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Pairing history remains available, but its credential-derived hash does not.
|
|
* The deterministic tombstone is unique because pairing request IDs are unique.
|
|
*/
|
|
export const RETIRE_EXPIRED_PAIRING_CODES_SQL = `
|
|
UPDATE pairing_requests
|
|
SET code_hash = 'expired:' || id
|
|
WHERE expires_at <= ?
|
|
AND code_hash != ('expired:' || id)`;
|
|
|
|
export const DELETE_EXPIRED_CLAIM_RATE_WINDOWS_SQL = `
|
|
DELETE FROM pairing_claim_rate_limits
|
|
WHERE window_start < ?`;
|
|
|
|
export const DELETE_EXPIRED_CLAIM_ATTEMPTS_SQL = `
|
|
DELETE FROM pairing_claim_attempts
|
|
WHERE created_at < ?`;
|
|
|
|
export const DELETE_EXPIRED_IDEMPOTENCY_SQL = `
|
|
DELETE FROM idempotency_records
|
|
WHERE expires_at <= ?`;
|
|
|
|
/**
|
|
* Handoff tickets contain only a one-way ticket digest and public phone identity,
|
|
* never the raw ticket or phone token. Retain consumed/expired rows briefly for
|
|
* replay diagnostics, then remove them on a bounded schedule.
|
|
*/
|
|
export const DELETE_RETIRED_PHONE_HANDOFF_TICKETS_SQL = `
|
|
DELETE FROM phone_handoff_tickets
|
|
WHERE expires_at <= ?
|
|
OR (consumed_at IS NOT NULL AND consumed_at <= ?)`;
|
|
|
|
export const DELETE_RETIRED_PENDING_PHONE_ROUTES_SQL = `
|
|
DELETE FROM phone_route_handles
|
|
WHERE status = 'pending'
|
|
AND EXISTS (
|
|
SELECT 1 FROM phone_handoff_tickets AS tickets
|
|
WHERE tickets.tenant_id = phone_route_handles.tenant_id
|
|
AND tickets.completed_phone_id = phone_route_handles.phone_id
|
|
AND tickets.completed_route_handle_hash = phone_route_handles.handle_hash
|
|
AND (
|
|
tickets.expires_at <= ?
|
|
OR (tickets.consumed_at IS NOT NULL AND tickets.consumed_at <= ?)
|
|
)
|
|
)`;
|
|
|
|
export const DELETE_RETIRED_PENDING_PHONE_PRINCIPALS_SQL = `
|
|
DELETE FROM relay_phone_principals
|
|
WHERE status = 'pending'
|
|
AND EXISTS (
|
|
SELECT 1 FROM phone_handoff_tickets AS tickets
|
|
WHERE tickets.tenant_id = relay_phone_principals.tenant_id
|
|
AND tickets.completed_phone_id = relay_phone_principals.phone_id
|
|
AND tickets.completed_phone_token_hash = relay_phone_principals.token_hash
|
|
AND (
|
|
tickets.expires_at <= ?
|
|
OR (tickets.consumed_at IS NOT NULL AND tickets.consumed_at <= ?)
|
|
)
|
|
)`;
|
|
|
|
export const DELETE_EXPIRED_DEVICE_REGISTRATION_REPLAYS_SQL = `
|
|
DELETE FROM device_registration_replays
|
|
WHERE expires_at <= ?`;
|
|
|
|
export const ACQUIRE_RETENTION_JOB_SQL = `
|
|
INSERT INTO maintenance_jobs
|
|
(key, state, run_id, trigger, scheduled_at, started_at, completed_at,
|
|
last_success_at, result_json, error_code, consecutive_failures, run_count,
|
|
created_at, updated_at)
|
|
VALUES (?, 'running', ?, ?, ?, ?, NULL, NULL, NULL, NULL, 0, 1, ?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET
|
|
state = 'running',
|
|
run_id = excluded.run_id,
|
|
trigger = excluded.trigger,
|
|
scheduled_at = excluded.scheduled_at,
|
|
started_at = excluded.started_at,
|
|
completed_at = NULL,
|
|
result_json = NULL,
|
|
error_code = NULL,
|
|
run_count = maintenance_jobs.run_count + 1,
|
|
updated_at = excluded.updated_at
|
|
WHERE maintenance_jobs.state != 'running'
|
|
OR maintenance_jobs.started_at < ?`;
|
|
|
|
export const COMPLETE_RETENTION_JOB_SQL = `
|
|
UPDATE maintenance_jobs
|
|
SET state = 'succeeded',
|
|
completed_at = ?,
|
|
last_success_at = ?,
|
|
result_json = ?,
|
|
error_code = NULL,
|
|
consecutive_failures = 0,
|
|
updated_at = ?
|
|
WHERE key = ?
|
|
AND run_id = ?
|
|
AND state = 'running'`;
|
|
|
|
export const FAIL_RETENTION_JOB_SQL = `
|
|
UPDATE maintenance_jobs
|
|
SET state = 'failed',
|
|
completed_at = ?,
|
|
result_json = NULL,
|
|
error_code = ?,
|
|
consecutive_failures = consecutive_failures + 1,
|
|
updated_at = ?
|
|
WHERE key = ?
|
|
AND run_id = ?
|
|
AND state = 'running'`;
|