/** * Cleaning up after backups whose process died — the staging they left on disk, * and the records that still claim they are running. * * The two are separate obligations that share a predicate, NOT one obligation * with two effects. Coupling them is exactly the bug in #616; see * `resolveAbandonedBackups`. * * `backup-create.ts` assembles every envelope in a temp directory and removes * it in a `finally`. That is correct and it is not enough: a `finally` does not * run when the process is killed by a signal it cannot intercept — a dispatcher * timeout, an OOM, an operator's Ctrl-C, a host reboot. Those are exactly the * cases that strand the LARGEST directories, because the longer a backup has * run the more it has written. * * Measured on celilo-mgr 2026-08-05: the scheduled sweep inherited the event * bus's 60s default timeout against a forgejo backup that needs ~5.5 minutes, * so every attempt was SIGTERMed at ~1.4 GB of staging, three times an hour, * for days. 15 GB stranded in 26 hours, then 27 GB in the next 5.7. Nothing * reclaimed it, because the only cleanup was the `finally` that never ran. * * So reclamation must not be a property of how a backup ends. This pass asks a * different question — "is anyone still using this directory?" — and answers it * from state that outlives the process. * * Identity comes free: a staging directory is named `celilo-backup-`, * so the directory names its own backup record. No lockfile, no marker, no * second source of truth that could itself be stranded — and notably nothing * written BY the process whose death is the problem. * * Dependencies are injected so the decision logic tests with no filesystem and * no database. */ import { tmpdir } from 'node:os'; import { join } from 'node:path'; /** Every staging directory starts with this. The rest of the name is the record id. */ export const STAGING_PREFIX = 'celilo-backup-'; /** * Where a backup record's staging lives. Single source of truth — `backup-create.ts` * builds its temp dir from this so the reaper can never drift from the writer. */ export function stagingDirFor(recordId: string): string { return join(tmpdir(), `${STAGING_PREFIX}${recordId}`); } /** * How long an `in_progress` backup record may vouch for its staging before the * directory is reclaimed regardless of what its pid appears to be doing. * * This is not redundancy on the liveness check — it is the only check that * survives pid reuse, for the same reason `OPERATION_TTL_MS` exists in * `module-operations.ts`. A pid is a recycled number: once the pid space wraps, * a stale record's pid names an unrelated live process and the liveness probe * reports "still running" forever, stranding the directory permanently. * * Six hours is comfortably longer than any real backup (the largest measured is * ~5.5 minutes) and short enough that a wedged record costs one cycle rather * than a filesystem. */ export const STAGING_TTL_MS = 6 * 60 * 60 * 1000; /** What the backup record says about the process that owns a staging directory. */ export interface StagingOwner { status: string; /** Null for records written before backups recorded their pid. */ pid: number | null; startedAt: Date; } export interface ReapStagingDeps { /** Absolute paths of every `celilo-backup-*` entry in the temp dir. */ listStagingDirs(): string[]; /** The backup record for this id, or null when it no longer exists. */ lookupOwner(recordId: string): StagingOwner | null; isPidRunnable(pid: number): boolean; remove(path: string): void; now(): number; } export interface ReapedStaging { path: string; recordId: string; /** Why it was reclaimable — surfaced so a sweep can explain itself. */ reason: 'record-absent' | 'record-terminal' | 'process-dead' | 'expired'; } /** * Written to a backup record reclaimed while it still claimed to be running. * * A record left `in_progress` after its process died misreports the system * twice: `celilo backup list` shows work apparently underway, and the `backups` * drift check can read a module as recently backed up when every attempt in * fact died. Nine such rows were live on celilo-mgr while forgejo had no usable * backup at all. * * It names no cause because this pass genuinely cannot know one: it runs later, * in a different process, and infers the death from a pid that is no longer * there. The observer that DOES know is the dispatcher, which holds the exit * code and translates it (`describeHandlerExit` in packages/event-bus) — a * SIGKILL, say, and the OOM killer that most likely sent it. * * The two facts existing in two places is fine; the operator having no way to * learn that is not. In celilo#685 this string was the whole of what an * operator saw for twenty consecutive OOM kills, so it now points at the record * that has the answer rather than terminating the trail. */ export const ABANDONED_BACKUP_MESSAGE = 'abandoned — the backup process ended without recording an outcome. ' + 'Only the process that ran it saw how it died, so the cause is recorded ' + 'against the event delivery rather than here: run `celilo system doctor`.'; /** * Whether reclaiming this directory also means its record was lying about * being in progress. * * Only the two reasons reached from the `in_progress` branch qualify: * `record-terminal` already has an outcome and `record-absent` has no row to * correct. */ export function impliesAbandonedRecord(reason: ReapedStaging['reason']): boolean { return reason === 'process-dead' || reason === 'expired'; } export interface ReapStagingReport { reclaimed: ReapedStaging[]; /** Left alone because a live backup is using it. */ kept: string[]; /** Names that did not look like staging at all. Never touched. */ ignored: string[]; } /** An `in_progress` backup record, as seen by the record-resolution pass. */ export interface InProgressBackup { id: string; /** Null for records written before backups recorded their pid. */ pid: number | null; startedAt: Date; } export interface ResolveAbandonedDeps { listInProgress(): InProgressBackup[]; isPidRunnable(pid: number): boolean; fail(recordId: string, message: string): void; now(): number; } export interface ResolveAbandonedReport { /** Records corrected from `in_progress` to failed. */ resolved: string[]; /** Left alone — a live backup owns them. */ kept: string[]; } /** * Correct records that still claim to be running after their process died. * * Deliberately INDEPENDENT of staging. The first version of this resolved * records only as a side effect of reclaiming their staging directory, which * was efficient — one liveness lookup serving two places — and strictly * narrower than the requirement. A record whose staging is already gone was * never visited, so it stayed `in_progress` forever: 107 such rows on * celilo-mgr, the oldest from June, none of them reachable by the reaper * because their directories had been cleared by hand (#616). * * That is not an edge case. `/tmp` is declared `D` in tmpfiles.d — cleared on * boot — so ANY backup killed before a reboot loses its staging and becomes * permanently unresolvable under the coupled design. Reclaiming disk and * correcting records are two obligations that happen to share a predicate, not * one obligation with two effects. * * The predicate itself is shared rather than reimplemented: this calls the same * `reclaimReason` the reaper uses, so the TTL-versus-pid-reuse reasoning has * exactly one home. A record is only ever resolved when its owner is provably * gone; a live backup is left alone. */ export function resolveAbandonedBackups(deps: ResolveAbandonedDeps): ResolveAbandonedReport { const report: ResolveAbandonedReport = { resolved: [], kept: [] }; for (const record of deps.listInProgress()) { const reason = reclaimReason( { status: 'in_progress', pid: record.pid, startedAt: record.startedAt }, deps, ); // `record-absent` and `record-terminal` are unreachable here — every row // came from a query for in-progress records — so any reason at all means // the owner is gone. if (reason) { deps.fail(record.id, ABANDONED_BACKUP_MESSAGE); report.resolved.push(record.id); } else { report.kept.push(record.id); } } return report; } /** * Decide, for one staging directory, whether its owner is gone. * * Returns null when the directory must be left alone. Every branch that * reclaims must be able to say why; "I could not prove it is alive" is not a * reason to delete, which is why an unrecognised state keeps the directory. */ function reclaimReason( owner: StagingOwner | null, deps: Pick, ): ReapedStaging['reason'] | null { // The record was deleted, or never committed. Nothing will ever finish this. if (!owner) return 'record-absent'; // Completed or failed: the writer reached an ending and either cleaned up // already (in which case we will not see the directory) or was killed after // recording its outcome. if (owner.status !== 'in_progress') return 'record-terminal'; const age = deps.now() - owner.startedAt.getTime(); if (age > STAGING_TTL_MS) return 'expired'; // A record from before backups carried a pid. Age is the only signal // available, and it has not expired — leave it. if (owner.pid === null) return null; return deps.isPidRunnable(owner.pid) ? null : 'process-dead'; } /** * Reclaim every staging directory whose owning backup is no longer running. * * Conservative by construction: a directory is removed only when its owner is * provably gone. A running backup — `in_progress` record, live pid, inside the * TTL — is always kept, so a long-running or concurrent backup can never be * destroyed by this pass. * * A name that is not `celilo-backup-` is ignored rather than removed. This * runs against a shared temp directory as a privileged user; deleting something * it does not understand is not its job. */ export function reapOrphanedStaging(deps: ReapStagingDeps): ReapStagingReport { const report: ReapStagingReport = { reclaimed: [], kept: [], ignored: [] }; for (const path of deps.listStagingDirs()) { const name = path.slice(path.lastIndexOf('/') + 1); if (!name.startsWith(STAGING_PREFIX)) { report.ignored.push(path); continue; } const recordId = name.slice(STAGING_PREFIX.length); if (recordId.length === 0) { report.ignored.push(path); continue; } const reason = reclaimReason(deps.lookupOwner(recordId), deps); if (!reason) { report.kept.push(path); continue; } // A directory that cannot be removed is not fatal: the next pass retries, // and failing the whole sweep over one undeletable path would stop backups // entirely. Treated as kept so the report never claims space it did not free. try { deps.remove(path); report.reclaimed.push({ path, recordId, reason }); } catch { report.kept.push(path); } } return report; }