import { forgetLocator, type LocatorKind } from "../scoped-db/locators"; import type { ScopedValue } from "../scoped-db/types"; const DAY_MS = 24 * 60 * 60 * 1000; /** Sliding idle timeout of a browser session (auth.ts SESSION_IDLE_MS). */ export const SESSION_IDLE_MS = 14 * DAY_MS; /** Rotated refresh tokens are kept this long for reuse detection. */ export const USED_REFRESH_TOKEN_MS = 30 * DAY_MS; /** Per-message delivery markers only need to outlive queue retries. */ export const DELIVERY_MARKER_TTL_MS = 7 * DAY_MS; /** Used or expired runner registration tokens are kept this long after expiry (ci/runner-registration.ts). */ const REGISTRATION_KEEP_MS = 60 * 60 * 1000; const USAGE_EVENT_RETENTION_MS = 400 * DAY_MS; /** Never wake more often than this, however soon something expires (or if a flush keeps failing). */ const MIN_INTERVAL_MS = 10 * 1000; /** * Expired rows are garbage, not a deadline (every read checks expiry itself), so they are collected at most this * often: a busy account wakes hourly for them, not every time one of many rows expires. Digests stay precise. */ const GC_INTERVAL_MS = 60 * 60 * 1000; /** A task that threw waits this long before its next attempt. */ const FAILURE_BACKOFF_MS = 5 * 60 * 1000; /** * Rows an AccountState expires by itself. Writing one of these tables arms the object's alarm; the alarm deletes * what is due (forgetting its D1 locators) and re-arms for the next expiry, so nothing walks every account on a * cron and an idle account costs nothing. */ type Rule = { table: string; /** SQL over the table, every `?` bound to now: rows to delete. */ stale: string; /** SQL expression: when a row next becomes stale. */ due: string; locator?: { kind: LocatorKind; column: string }; }; const RULES: Rule[] = [ { table: "sessions", stale: `expires_at < ? OR last_seen_at < ? - ${SESSION_IDLE_MS}`, due: `MIN(expires_at, last_seen_at + ${SESSION_IDLE_MS})`, locator: { kind: "session", column: "token_hash" }, }, { table: "auth_tokens", stale: "expires_at < ?", due: "expires_at", locator: { kind: "auth_token", column: "token_hash" }, }, { table: "oauth_codes", stale: "expires_at < ?", due: "expires_at", locator: { kind: "oauth_code", column: "code_hash" }, }, { table: "oauth_refresh_tokens", stale: `expires_at < ? OR (used_at IS NOT NULL AND used_at < ? - ${USED_REFRESH_TOKEN_MS})`, due: `CASE WHEN used_at IS NULL THEN expires_at ELSE MIN(expires_at, used_at + ${USED_REFRESH_TOKEN_MS}) END`, locator: { kind: "oauth_refresh", column: "token_hash" }, }, { table: "org_invitations", stale: "expires_at <= ?", due: "expires_at", locator: { kind: "org_invitation", column: "id" }, }, { table: "runner_registration_tokens", stale: `expires_at < ? - ${REGISTRATION_KEEP_MS}`, due: `expires_at + ${REGISTRATION_KEEP_MS}`, locator: { kind: "runner_registration", column: "token_hash" }, }, { // Usage events are kept 400 days for invoices and disputes, removed in bounded batches. table: "usage_events", stale: `rowid IN (SELECT rowid FROM usage_events WHERE created_at < ? - ${USAGE_EVENT_RETENTION_MS} LIMIT 5000)`, due: `created_at + ${USAGE_EVENT_RETENTION_MS}`, }, { table: "notification_deliveries", stale: `created_at < ? - ${DELIVERY_MARKER_TTL_MS}`, due: `created_at + ${DELIVERY_MARKER_TTL_MS}`, }, ]; type Sql = { exec>(sql: string, ...params: ScopedValue[]): { toArray(): T[] } }; /** * Deadline-driven work of an account, as opposed to garbage: digests, holds that must be released, budget alerts * and gauges. `arms` names writes that can make the task due sooner than a pending alarm; `marks` names writes * that make debounced work (recorded in `scope_dirty`, run `debounce` ms after the first such write). */ export type AccountTask = { name: string; arms?: RegExp; /** * The arming writes only ever move this task's due time later (heartbeats), so they arm only when no alarm is * pending, like expiring rows. */ lazy?: boolean; marks?: RegExp; debounce?: number; /** When the task next has work, or null. Debounced tasks default to their mark plus the debounce. */ due?: (sql: Sql, now: number) => number | null; run(accountId: string, now: number, sql: Sql): Promise; }; export type LastRuns = Record; const insertInto = (table: string) => new RegExp(`^\\s*INSERT\\s+(?:OR\\s+\\w+\\s+)?INTO\\s+${table}\\b`, "i"); const writeTo = (tables: string) => new RegExp(`^\\s*(?:INSERT\\s+(?:OR\\s+\\w+\\s+)?INTO|UPDATE|REPLACE\\s+INTO|DELETE\\s+FROM)\\s+(${tables})\\b`, "i"); function scalar(sql: Sql, query: string, ...params: ScopedValue[]): number | null { const row = sql.exec<{ due: number | null }>(query, ...params).toArray()[0]; return row?.due == null ? null : Number(row.due); } /** Registered by the AccountState (tasks import server modules this file must not depend on). */ let tasks: AccountTask[] = []; export function registerAccountTasks(list: AccountTask[]): void { tasks = list; } export const accountTaskHelpers = { insertInto, writeTo, scalar }; /** A registered task by name (tests drive tasks directly). */ export function accountTask(name: string): AccountTask | undefined { return tasks.find((task) => task.name === name); } const GC_ARMING = new RegExp( `^\\s*INSERT\\s+(?:OR\\s+\\w+\\s+)?INTO\\s+(${RULES.map((rule) => rule.table).join("|")})\\b`, "i", ); /** Whether a statement writes a row the account must later expire, or work it must later do. */ export function armsHousekeeping(sql: string): boolean { return GC_ARMING.test(sql) || tasks.some((task) => task.arms?.test(sql) || task.marks?.test(sql)); } /** Whether it may make a deadline-driven task due sooner than the pending alarm (expiring rows never are). */ export function armsPrecise(sql: string): boolean { return tasks.some((task) => (!task.lazy && task.arms?.test(sql)) || task.marks?.test(sql)); } /** Debounced tasks a statement makes due. */ export function marksOf(sql: string): string[] { return tasks.filter((task) => task.marks?.test(sql)).map((task) => task.name); } /** Records debounced work; the first mark wins, so a stream of writes can't postpone it forever. */ export function mark(sql: Sql, names: string[], now: number): void { for (const name of names) sql.exec("INSERT OR IGNORE INTO scope_dirty (name, at) VALUES (?, ?)", name, now); } function taskDue(sql: Sql, task: AccountTask, now: number, lastRuns: LastRuns): number | null { const due = task.due ? task.due(sql, now) : (() => { const at = scalar(sql, "SELECT at AS due FROM scope_dirty WHERE name = ?", task.name); return at === null ? null : at + (task.debounce ?? 0); })(); if (due === null) return null; const failed = lastRuns[`${task.name}:failed`]; return failed === undefined ? due : Math.max(due, failed + FAILURE_BACKOFF_MS); } /** Deletes every due row; returns the locators to forget in D1. */ export function sweepDue(sql: Sql, now: number): Array<{ kind: LocatorKind; key: string }> { const forget: Array<{ kind: LocatorKind; key: string }> = []; for (const rule of RULES) { const returning = rule.locator ? ` RETURNING ${rule.locator.column} AS key` : ""; const binds = Array((rule.stale.match(/\?/g) ?? []).length).fill(now); const rows = sql .exec<{ key: string }>(`DELETE FROM ${rule.table} WHERE ${rule.stale}${returning}`, ...binds) .toArray(); if (rule.locator) for (const row of rows) forget.push({ kind: rule.locator.kind, key: String(row.key) }); } return forget; } function gcDue(sql: Sql, lastSweep: number | null): number | null { const expiries: number[] = []; for (const rule of RULES) { const due = scalar(sql, `SELECT MIN(${rule.due}) AS due FROM ${rule.table}`); if (due !== null) expiries.push(due); } return expiries.length === 0 ? null : Math.max(Math.min(...expiries), (lastSweep ?? 0) + GC_INTERVAL_MS); } /** * When the alarm should next run, or null when nothing is waiting. `lastSweep` (when expired rows were last * collected) spaces collection out by GC_INTERVAL_MS; tasks are precise, failed ones back off. */ export function nextDue( sql: Sql, now: number, lastSweep: number | null = null, lastRuns: LastRuns = {}, ): number | null { const times = [gcDue(sql, lastSweep), ...tasks.map((task) => taskDue(sql, task, now, lastRuns))].filter( (due): due is number => due !== null, ); if (times.length === 0) return null; return Math.max(Math.min(...times), now + MIN_INTERVAL_MS); } /** Whether garbage collection is due now. */ export function gcDueNow(sql: Sql, now: number, lastSweep: number | null): boolean { const due = gcDue(sql, lastSweep); return due !== null && due <= now; } /** * Runs every due task, clearing its debounce mark first (writes during the run mark it again) and recording * failures for backoff; one failing never stops the others. */ export async function runAccountTasks(accountId: string, sql: Sql, now: number, lastRuns: LastRuns): Promise { for (const task of tasks) { const due = taskDue(sql, task, now, lastRuns); if (due === null || due > now) continue; if (task.marks) sql.exec("DELETE FROM scope_dirty WHERE name = ?", task.name); try { await task.run(accountId, now, sql); delete lastRuns[`${task.name}:failed`]; } catch (error) { lastRuns[`${task.name}:failed`] = now; if (task.marks) mark(sql, [task.name], now); console.error("Account housekeeping task failed", task.name, error); } } } export async function forgetSwept(forget: Array<{ kind: LocatorKind; key: string }>): Promise { await Promise.all(forget.map((entry) => forgetLocator(entry.kind, entry.key))); }