This commit is contained in:
levineam 2026-09-13 12:08:45 +00:00 committed by GitHub
commit cb726a0021
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 1031 additions and 13 deletions

View File

@ -5,6 +5,10 @@ import { gunzipSync } from "node:zlib";
import { afterEach, describe, expect, it } from "vitest";
import postgres from "postgres";
import { createBufferedTextFileWriter, runDatabaseBackup, runDatabaseRestore } from "./backup-lib.js";
import {
STORAGE_TRANSACTION_LOCK_DIR_NAME,
STORAGE_TRANSACTION_PARTICIPATION_NAME,
} from "./backup-transaction-lock.js";
import { ensurePostgresDatabase } from "./client.js";
import {
getEmbeddedPostgresTestSupport,
@ -74,6 +78,40 @@ describe("createBufferedTextFileWriter", () => {
});
});
describe("runDatabaseBackup transaction teardown", () => {
it("does not publish an owner when database client setup throws synchronously", async () => {
const backupDir = createTempDir("paperclip-db-backup-setup-failure-");
await expect(runDatabaseBackup({
connectionString: "not-a-url",
backupDir,
retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 },
backupEngine: "javascript",
})).rejects.toThrow();
expect(fs.existsSync(path.join(backupDir, STORAGE_TRANSACTION_LOCK_DIR_NAME))).toBe(false);
expect(fs.existsSync(path.join(backupDir, STORAGE_TRANSACTION_PARTICIPATION_NAME))).toBe(false);
});
it("releases an acquired owner when the backup connection fails", async () => {
const backupDir = createTempDir("paperclip-db-backup-connection-failure-");
await expect(runDatabaseBackup({
connectionString: "postgres://paperclip:paperclip@127.0.0.1:1/paperclip",
backupDir,
retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 },
backupEngine: "javascript",
connectTimeoutSeconds: 1,
})).rejects.toThrow();
expect(fs.existsSync(path.join(backupDir, STORAGE_TRANSACTION_LOCK_DIR_NAME))).toBe(false);
expect(JSON.parse(fs.readFileSync(
path.join(backupDir, STORAGE_TRANSACTION_PARTICIPATION_NAME),
"utf8",
))).toMatchObject({ state: "ready" });
});
});
describeEmbeddedPostgres("runDatabaseBackup", () => {
it(
"keeps the newest backup for each retained calendar month",

View File

@ -1,5 +1,13 @@
import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
import { basename, resolve } from "node:path";
import {
acquireStorageTransactionLock,
assertStorageTransactionLockHeld,
isStorageTransactionReservedName,
releaseStorageTransactionLock,
type StorageTransactionLockHandle,
StorageTransactionLockError,
} from "./backup-transaction-lock.js";
import { createInterface } from "node:readline";
import { spawn } from "node:child_process";
import { open as openFile } from "node:fs/promises";
@ -34,6 +42,8 @@ export type RunDatabaseBackupResult = {
backupFile: string;
sizeBytes: number;
prunedCount: number;
/** Present when the backup transaction lock was acquired for this run. */
lockTokenPrefix?: string;
};
export type RunDatabaseRestoreOptions = {
@ -120,7 +130,12 @@ function monthlyRetentionCutoff(nowMs: number, monthlyMonths: number): number {
* - Monthly tier: keep the NEWEST backup per calendar month for `monthlyMonths` months
* - Everything else is deleted
*/
function pruneOldBackups(backupDir: string, retention: BackupRetentionPolicy, filenamePrefix: string): number {
function pruneOldBackups(
backupDir: string,
retention: BackupRetentionPolicy,
filenamePrefix: string,
lockHandle?: StorageTransactionLockHandle,
): number {
if (!existsSync(backupDir)) return 0;
const now = Date.now();
@ -132,10 +147,13 @@ function pruneOldBackups(backupDir: string, retention: BackupRetentionPolicy, fi
const entries: BackupEntry[] = [];
for (const name of readdirSync(backupDir)) {
// Never treat protocol artifacts as backup candidates.
if (isStorageTransactionReservedName(name)) continue;
if (!name.startsWith(`${filenamePrefix}-`)) continue;
if (!name.endsWith(".sql") && !name.endsWith(".sql.gz")) continue;
const fullPath = resolve(backupDir, name);
const stat = statSync(fullPath);
if (!stat.isFile()) continue;
entries.push({ name, fullPath, mtimeMs: stat.mtimeMs });
}
@ -179,6 +197,10 @@ function pruneOldBackups(backupDir: string, retention: BackupRetentionPolicy, fi
}
for (const filePath of toDelete) {
// Reverify producer lock ownership before every irreversible retention unlink.
if (lockHandle) {
assertStorageTransactionLockHeld(lockHandle);
}
unlinkSync(filePath);
}
@ -533,6 +555,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
const canUsePgDump = !hasBackupTransforms(opts);
const excludedTableNames = normalizeTableNameSet(opts.excludeTables);
const nullifiedColumnsByTable = normalizeNullifyColumnMap(opts.nullifyColumns);
let sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
let sqlClosed = false;
const closeSql = async () => {
@ -540,12 +563,32 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
sqlClosed = true;
await sql.end();
};
// Acquire the source-root transaction lock before any backup/prune filesystem activity.
// Client construction stays outside the hold so a synchronous setup error cannot strand it.
mkdirSync(opts.backupDir, { recursive: true });
let lockHandle: StorageTransactionLockHandle;
try {
lockHandle = acquireStorageTransactionLock({
sourceRoot: opts.backupDir,
operationKind: "backup",
});
} catch (error) {
await closeSql();
if (error instanceof StorageTransactionLockError) {
throw error;
}
throw error;
}
const sqlFile = resolve(opts.backupDir, `${filenamePrefix}-${timestamp()}.sql`);
const backupFile = `${sqlFile}.gz`;
const writer = createBufferedTextFileWriter(sqlFile);
let writer: ReturnType<typeof createBufferedTextFileWriter> | null = null;
let operationError: unknown = null;
try {
writer = createBufferedTextFileWriter(sqlFile);
const activeWriter = writer;
if (backupEngine === "pg_dump" || (backupEngine === "auto" && canUsePgDump)) {
await sql`SELECT 1`;
try {
@ -555,13 +598,15 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
backupFile,
connectTimeout,
});
await writer.abort();
await activeWriter.abort();
const sizeBytes = statSync(backupFile).size;
const prunedCount = pruneOldBackups(opts.backupDir, retention, filenamePrefix);
assertStorageTransactionLockHeld(lockHandle);
const prunedCount = pruneOldBackups(opts.backupDir, retention, filenamePrefix, lockHandle);
return {
backupFile,
sizeBytes,
prunedCount,
lockTokenPrefix: lockHandle.token.slice(0, 8),
};
} catch (error) {
if (existsSync(backupFile)) {
@ -578,7 +623,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
await sql`SELECT 1`;
const emit = (line: string) => writer.emit(line);
const emit = (line: string) => activeWriter.emit(line);
const emitStatement = (statement: string) => {
emit(statement);
emit(STATEMENT_BREAKPOINT);
@ -936,19 +981,19 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
const nullifiedColumns = nullifiedColumnsByTable.get(currentTableKey) ?? new Set<string>();
if (effectiveBackupEngine !== "javascript" && nullifiedColumns.size === 0) {
emit(`COPY ${qualifiedTableName} (${colNames}) FROM stdin;`);
await writer.writeRaw("\n");
await activeWriter.writeRaw("\n");
const copySql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
try {
const copyStream = await copySql
.unsafe(`COPY ${qualifiedTableName} (${colNames}) TO STDOUT`)
.readable();
for await (const chunk of copyStream) {
await writer.writeRaw(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
await activeWriter.writeRaw(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
}
} finally {
await copySql.end();
}
await writer.writeRaw("\\.\n");
await activeWriter.writeRaw("\\.\n");
emitStatementBoundary();
emit("");
continue;
@ -965,7 +1010,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
);
emitStatement(`INSERT INTO ${qualifiedTableName} (${colNames}) VALUES (${values.join(", ")});`);
}
await writer.drain();
await activeWriter.drain();
}
emit("");
}
@ -1018,7 +1063,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
emitStatement("COMMIT;");
emit("");
await writer.close();
await activeWriter.close();
// Compress the SQL file with gzip
const sqlReadStream = createReadStream(sqlFile);
@ -1026,16 +1071,19 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
await pipeline(sqlReadStream, createGzip(), gzWriteStream);
unlinkSync(sqlFile);
assertStorageTransactionLockHeld(lockHandle);
const sizeBytes = statSync(backupFile).size;
const prunedCount = pruneOldBackups(opts.backupDir, retention, filenamePrefix);
const prunedCount = pruneOldBackups(opts.backupDir, retention, filenamePrefix, lockHandle);
return {
backupFile,
sizeBytes,
prunedCount,
lockTokenPrefix: lockHandle.token.slice(0, 8),
};
} catch (error) {
await writer.abort();
operationError = error;
await writer?.abort();
if (existsSync(backupFile)) {
try { unlinkSync(backupFile); } catch { /* ignore */ }
}
@ -1044,7 +1092,25 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
}
throw error;
} finally {
await closeSql();
let closeError: unknown = null;
try {
await closeSql();
} catch (error) {
closeError = error;
}
let releaseError: unknown = null;
try {
releaseStorageTransactionLock(lockHandle, { participationState: "ready" });
} catch (error) {
releaseError = error;
}
const teardownErrors = [operationError, closeError, releaseError].filter(
(error): error is NonNullable<typeof error> => error !== null,
);
if (closeError || releaseError) {
if (teardownErrors.length === 1) throw teardownErrors[0];
throw new AggregateError(teardownErrors, "Database backup and storage transaction teardown failed");
}
}
}

View File

@ -0,0 +1,198 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
STORAGE_TRANSACTION_LOCK_DIR_NAME,
STORAGE_TRANSACTION_LOCK_SCHEMA,
STORAGE_TRANSACTION_PARTICIPATION_NAME,
STORAGE_TRANSACTION_RESERVED_PREFIX,
StorageTransactionLockError,
acquireStorageTransactionLock,
assertStorageTransactionLockHeld,
isStorageTransactionReservedName,
parseStorageTransactionOwnerRecord,
readLocalBootId,
readLocalMachineId,
readProcessStartId,
readStorageTransactionParticipation,
releaseStorageTransactionLock,
resolveSourceRootRealPath,
} from "./backup-transaction-lock.js";
const cleanupRoots: string[] = [];
afterEach(() => {
while (cleanupRoots.length > 0) {
fs.rmSync(cleanupRoots.pop()!, { recursive: true, force: true });
}
});
function tempDir(prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
cleanupRoots.push(dir);
return dir;
}
function writeOwner(root: string, overrides: Record<string, unknown> = {}) {
const realRoot = resolveSourceRootRealPath(root);
const ownerPath = path.join(realRoot, STORAGE_TRANSACTION_LOCK_DIR_NAME);
const owner = {
schema: STORAGE_TRANSACTION_LOCK_SCHEMA,
token: "FAKESECRET_g1h2i3j4k5l6m7n8o9p0",
machineId: readLocalMachineId(),
bootId: readLocalBootId(),
pid: 2_147_483_646,
processStartId: "dead-process-start-id",
operationKind: "backup",
acquiredAt: new Date(Date.now() - 60_000).toISOString(),
...overrides,
};
fs.writeFileSync(ownerPath, `${JSON.stringify(owner)}\n`, { mode: 0o600 });
return owner;
}
describe("paperclip.storage-transaction-lock/v1", () => {
it("acquires, holds, and token-safely releases a source-root lock", () => {
const root = tempDir("paperclip-tx-lock-basic-");
const handle = acquireStorageTransactionLock({
sourceRoot: root,
operationKind: "backup",
});
expect(handle.token.length).toBeGreaterThanOrEqual(16);
expect(fs.existsSync(handle.ownerPath)).toBe(true);
expect(path.basename(handle.ownerPath)).toBe(".paperclip-stx-owner");
expect(path.basename(handle.participationPath)).toBe(".paperclip-stx-marker");
assertStorageTransactionLockHeld(handle);
const participation = readStorageTransactionParticipation(root);
expect(participation?.state).toBe("active");
expect(participation?.schema).toBe(STORAGE_TRANSACTION_LOCK_SCHEMA);
releaseStorageTransactionLock(handle);
expect(fs.existsSync(handle.ownerPath)).toBe(false);
expect(readStorageTransactionParticipation(root)?.state).toBe("ready");
});
it("returns busy when a live local owner already holds the lock", () => {
const root = tempDir("paperclip-tx-lock-busy-");
const first = acquireStorageTransactionLock({
sourceRoot: root,
operationKind: "backup",
});
try {
expect(() =>
acquireStorageTransactionLock({
sourceRoot: root,
operationKind: "raid-archive",
}),
).toThrow(StorageTransactionLockError);
try {
acquireStorageTransactionLock({
sourceRoot: root,
operationKind: "raid-archive",
});
expect.unreachable("second acquire should fail");
} catch (error) {
expect(error).toBeInstanceOf(StorageTransactionLockError);
expect((error as StorageTransactionLockError).code).toBe("busy");
}
} finally {
releaseStorageTransactionLock(first);
}
});
it("emits a clawd-compatible owner and participation fixture shape", () => {
const root = tempDir("paperclip-tx-lock-fixture-");
const handle = acquireStorageTransactionLock({
sourceRoot: root,
operationKind: "backup",
});
const raw = JSON.parse(fs.readFileSync(handle.ownerPath, "utf8"));
const parsed = parseStorageTransactionOwnerRecord(raw);
expect(parsed).not.toBeNull();
expect(parsed!.schema).toBe(STORAGE_TRANSACTION_LOCK_SCHEMA);
expect(parsed!.token).toBe(handle.token);
expect(Object.keys(parsed!).sort()).toEqual([
"acquiredAt",
"bootId",
"machineId",
"operationKind",
"pid",
"processStartId",
"schema",
"token",
]);
expect(Object.keys(readStorageTransactionParticipation(root)!).sort()).toEqual([
"schema",
"state",
"token",
"updatedAt",
]);
releaseStorageTransactionLock(handle);
});
it("recovers a confirmed dead local owner exactly once under the recovery mutex", () => {
const root = tempDir("paperclip-tx-lock-dead-");
writeOwner(root, {
machineId: readLocalMachineId(),
bootId: readLocalBootId(),
pid: 2_147_483_646,
processStartId: "dead-process-start-id",
});
const recovered = acquireStorageTransactionLock({
sourceRoot: root,
operationKind: "backup",
allowDeadOwnerRecovery: true,
});
expect(recovered.token).not.toBe("deadtoken0123456789abcdef012345");
expect(recovered.owner.bootId).toBe(readLocalBootId());
expect(recovered.owner.processStartId).toBe(readProcessStartId());
assertStorageTransactionLockHeld(recovered);
releaseStorageTransactionLock(recovered);
});
it("refuses a foreign-machine owner without recovery", () => {
const root = tempDir("paperclip-tx-lock-foreign-");
writeOwner(root, { machineId: "foreign-machine-id-not-local" });
try {
acquireStorageTransactionLock({ sourceRoot: root, operationKind: "backup" });
expect.unreachable("foreign owner should block");
} catch (error) {
expect(error).toBeInstanceOf(StorageTransactionLockError);
expect((error as StorageTransactionLockError).code).toBe("foreign_owner");
}
});
it("refuses to release when the caller token no longer matches", () => {
const root = tempDir("paperclip-tx-lock-token-");
const first = acquireStorageTransactionLock({
sourceRoot: root,
operationKind: "backup",
});
const secondRoot = tempDir("paperclip-tx-lock-token-2-");
// Replace owner under recovery by releasing and reacquiring, then forge handle.
const handle = acquireStorageTransactionLock({
sourceRoot: secondRoot,
operationKind: "backup",
});
const forged = {
...handle,
token: "FAKESECRET_g2h3i4j5k6l7m8n9o0p1",
};
expect(() => releaseStorageTransactionLock(forged)).toThrow(StorageTransactionLockError);
releaseStorageTransactionLock(handle);
releaseStorageTransactionLock(first);
});
it("excludes reserved protocol artifact names", () => {
expect(isStorageTransactionReservedName(STORAGE_TRANSACTION_LOCK_DIR_NAME)).toBe(true);
expect(isStorageTransactionReservedName(STORAGE_TRANSACTION_PARTICIPATION_NAME)).toBe(true);
expect(isStorageTransactionReservedName(`${STORAGE_TRANSACTION_RESERVED_PREFIX}owner.tmp.x`)).toBe(true);
expect(isStorageTransactionReservedName("paperclip-20260911-120000.sql.gz")).toBe(false);
});
});

View File

@ -0,0 +1,679 @@
import {
closeSync,
existsSync,
fsyncSync,
linkSync,
lstatSync,
mkdirSync,
openSync,
readFileSync,
realpathSync,
renameSync,
unlinkSync,
writeFileSync,
writeSync,
} from "node:fs";
import { randomUUID } from "node:crypto";
import { join, resolve, sep } from "node:path";
import { spawnSync } from "node:child_process";
/** Wire schema shared with clawd (byte-compatible; no cross-repo imports). */
export const STORAGE_TRANSACTION_LOCK_SCHEMA = "paperclip.storage-transaction-lock/v1";
/** Reserved artifact prefix under a locked source root. All enumerators must exclude it. */
export const STORAGE_TRANSACTION_RESERVED_PREFIX = ".paperclip-stx-";
export const STORAGE_TRANSACTION_LOCK_DIR_NAME = `${STORAGE_TRANSACTION_RESERVED_PREFIX}owner`;
export const STORAGE_TRANSACTION_RECOVERY_MUTEX_NAME = `${STORAGE_TRANSACTION_RESERVED_PREFIX}recovery`;
export const STORAGE_TRANSACTION_PARTICIPATION_NAME = `${STORAGE_TRANSACTION_RESERVED_PREFIX}marker`;
export const STORAGE_TRANSACTION_TMP_PREFIX = STORAGE_TRANSACTION_RESERVED_PREFIX;
export const STORAGE_TRANSACTION_QUARANTINE_PREFIX = `${STORAGE_TRANSACTION_RESERVED_PREFIX}quarantine.`;
const MAX_RECORD_BYTES = 64 * 1024;
export type StorageTransactionOperationKind =
| "backup"
| "backup-prune"
| "raid-archive"
| "storage-transaction-recovery"
| "recovery";
export type StorageTransactionParticipationState = "active" | "ready";
export type StorageTransactionOwnerRecord = {
schema: typeof STORAGE_TRANSACTION_LOCK_SCHEMA;
token: string;
machineId: string;
bootId: string;
pid: number;
processStartId: string;
operationKind: string;
acquiredAt: string;
};
export type StorageTransactionParticipationRecord = {
schema: typeof STORAGE_TRANSACTION_LOCK_SCHEMA;
state: StorageTransactionParticipationState;
token: string;
updatedAt: string;
};
export type StorageTransactionLockHandle = {
sourceRootRealPath: string;
ownerPath: string;
recoveryMutexPath: string;
participationPath: string;
token: string;
owner: StorageTransactionOwnerRecord;
recoveries?: Array<Record<string, unknown>>;
};
export type AcquireStorageTransactionLockOptions = {
sourceRoot: string;
operationKind: StorageTransactionOperationKind | string;
allowDeadOwnerRecovery?: boolean;
nowMs?: number;
};
export class StorageTransactionLockError extends Error {
readonly code: string;
readonly details?: Record<string, unknown>;
constructor(code: string, message: string, details?: Record<string, unknown>) {
super(message);
this.name = "StorageTransactionLockError";
this.code = code;
this.details = details;
}
}
function isErrno(error: unknown, code: string): boolean {
return Boolean(error && typeof error === "object" && (error as NodeJS.ErrnoException).code === code);
}
function safeUnlink(filePath: string): void {
try {
unlinkSync(filePath);
} catch (error) {
if (!isErrno(error, "ENOENT")) throw error;
}
}
function shellText(command: string, args: string[]): string | null {
const result = spawnSync(command, args, {
encoding: "utf8",
timeout: 2_000,
stdio: ["ignore", "pipe", "ignore"],
env: process.env,
});
if (result.status !== 0) return null;
const out = String(result.stdout ?? "").trim();
return out.length > 0 ? out : null;
}
export function readLocalMachineId(): string | null {
for (const candidate of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
try {
const value = readFileSync(candidate, "utf8").trim();
if (value) return value;
} catch {
// try next
}
}
if (process.platform === "darwin") {
const output = shellText("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"]);
const matched = output && output.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/);
if (matched) return matched[1]!;
}
return null;
}
export function readLocalBootId(): string | null {
if (process.platform === "linux") {
try {
return readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim() || null;
} catch {
return null;
}
}
if (process.platform === "darwin") return shellText("sysctl", ["-n", "kern.boottime"]);
return null;
}
export function readProcessStartId(pid: number = process.pid): string | null {
if (process.platform === "linux") {
try {
const raw = readFileSync(`/proc/${pid}/stat`, "utf8");
const closeParen = raw.lastIndexOf(")");
if (closeParen < 0) return null;
return raw.slice(closeParen + 2).trim().split(/\s+/)[19] || null;
} catch {
return null;
}
}
if (process.platform === "darwin") return shellText("ps", ["-p", String(pid), "-o", "lstart="]);
return null;
}
export function resolveStorageTransactionIdentity(): {
machineId: string;
bootId: string;
pid: number;
processStartId: string;
} {
const machineId = readLocalMachineId();
const bootId = readLocalBootId();
const processStartId = readProcessStartId();
if (!machineId || !bootId || !processStartId) {
throw new StorageTransactionLockError(
"unreadable",
"Cannot establish stable machine, boot, and process-start identity",
);
}
return { machineId, bootId, pid: process.pid, processStartId };
}
export function resolveSourceRootRealPath(
sourceRoot: string,
options: { createIfMissing?: boolean } = {},
): string {
const createIfMissing = options.createIfMissing !== false;
const resolved = resolve(sourceRoot);
if (createIfMissing) mkdirSync(resolved, { recursive: true });
try {
return realpathSync(resolved);
} catch (error) {
if (!createIfMissing && isErrno(error, "ENOENT")) return resolved;
throw error;
}
}
function ownerPaths(sourceRootRealPath: string) {
return {
sourceRootRealPath,
ownerPath: join(sourceRootRealPath, STORAGE_TRANSACTION_LOCK_DIR_NAME),
recoveryMutexPath: join(sourceRootRealPath, STORAGE_TRANSACTION_RECOVERY_MUTEX_NAME),
participationPath: join(sourceRootRealPath, STORAGE_TRANSACTION_PARTICIPATION_NAME),
};
}
function readRegularFile(filePath: string): string | null {
let stat;
try {
stat = lstatSync(filePath);
} catch (error) {
if (isErrno(error, "ENOENT")) return null;
if (isErrno(error, "EPERM") || isErrno(error, "EACCES")) {
throw new StorageTransactionLockError("eperm", "Cannot inspect storage transaction artifact", { filePath });
}
throw new StorageTransactionLockError("unreadable", "Cannot inspect storage transaction artifact", { filePath });
}
if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > MAX_RECORD_BYTES) {
throw new StorageTransactionLockError(
"malformed",
"Storage transaction artifact is not a bounded regular file",
{ filePath },
);
}
try {
return readFileSync(filePath, "utf8");
} catch (error) {
if (isErrno(error, "EPERM") || isErrno(error, "EACCES")) {
throw new StorageTransactionLockError("eperm", "Cannot read storage transaction artifact", { filePath });
}
throw new StorageTransactionLockError("unreadable", "Cannot read storage transaction artifact", { filePath });
}
}
export function parseStorageTransactionOwnerRecord(
value: unknown,
): StorageTransactionOwnerRecord | null {
if (!value || typeof value !== "object") return null;
const v = value as Record<string, unknown>;
const valid = v.schema === STORAGE_TRANSACTION_LOCK_SCHEMA
&& typeof v.machineId === "string" && v.machineId.length > 0
&& typeof v.bootId === "string" && v.bootId.length > 0
&& Number.isSafeInteger(v.pid) && (v.pid as number) > 0
&& typeof v.processStartId === "string" && v.processStartId.length > 0
&& typeof v.token === "string" && v.token.length > 0
&& typeof v.operationKind === "string" && v.operationKind.length > 0
&& typeof v.acquiredAt === "string" && Number.isFinite(Date.parse(v.acquiredAt));
return valid ? (value as StorageTransactionOwnerRecord) : null;
}
export function parseStorageTransactionParticipationRecord(
value: unknown,
): StorageTransactionParticipationRecord | null {
if (!value || typeof value !== "object") return null;
const v = value as Record<string, unknown>;
const valid = v.schema === STORAGE_TRANSACTION_LOCK_SCHEMA
&& (v.state === "active" || v.state === "ready")
&& typeof v.token === "string" && v.token.length > 0
&& typeof v.updatedAt === "string" && Number.isFinite(Date.parse(v.updatedAt));
return valid ? (value as StorageTransactionParticipationRecord) : null;
}
function parseJson(raw: string, filePath: string): unknown {
try {
return JSON.parse(raw);
} catch {
throw new StorageTransactionLockError("malformed", "Storage transaction artifact is invalid JSON", { filePath });
}
}
function readOwnerRecord(filePath: string): StorageTransactionOwnerRecord | null {
const raw = readRegularFile(filePath);
if (raw == null) return null;
const parsed = parseStorageTransactionOwnerRecord(parseJson(raw, filePath));
if (!parsed) {
throw new StorageTransactionLockError("malformed", "Storage transaction owner record is malformed", { filePath });
}
return parsed;
}
function readParticipationRecord(filePath: string): StorageTransactionParticipationRecord | null {
const raw = readRegularFile(filePath);
if (raw == null) return null;
const parsed = parseStorageTransactionParticipationRecord(parseJson(raw, filePath));
if (!parsed) {
throw new StorageTransactionLockError(
"malformed",
"Storage transaction participation marker is malformed",
{ filePath },
);
}
return parsed;
}
export function checkStorageTransactionLiveness(
record: StorageTransactionOwnerRecord,
current: { machineId: string; bootId: string; pid: number; processStartId: string },
): "alive" | "dead" | "foreign_machine" | "eperm" | "unreadable" {
if (record.machineId !== current.machineId) return "foreign_machine";
if (record.bootId !== current.bootId) return "dead";
if (record.pid === current.pid) {
return record.processStartId === current.processStartId ? "alive" : "dead";
}
try {
process.kill(record.pid, 0);
} catch (error) {
if (isErrno(error, "ESRCH")) return "dead";
if (isErrno(error, "EPERM")) return "eperm";
return "unreadable";
}
const observedStart = readProcessStartId(record.pid);
if (!observedStart) return "unreadable";
return observedStart === record.processStartId ? "alive" : "dead";
}
function classifyExisting(
record: StorageTransactionOwnerRecord,
identity: { machineId: string; bootId: string; pid: number; processStartId: string },
): "dead" {
const status = checkStorageTransactionLiveness(record, identity);
if (status === "dead") return "dead";
if (status === "alive") {
throw new StorageTransactionLockError("busy", "Storage transaction lock is held by a live process", {
holderPid: record.pid,
});
}
if (status === "foreign_machine") {
throw new StorageTransactionLockError("foreign_owner", "Storage transaction lock belongs to another machine");
}
if (status === "eperm") {
throw new StorageTransactionLockError("eperm", "Storage transaction owner liveness is permission-denied");
}
throw new StorageTransactionLockError("unreadable", "Storage transaction owner liveness is unknown");
}
function newRecord(
identity: { machineId: string; bootId: string; pid: number; processStartId: string },
token: string,
operationKind: string,
nowMs: number,
): StorageTransactionOwnerRecord {
return {
schema: STORAGE_TRANSACTION_LOCK_SCHEMA,
...identity,
token,
operationKind,
acquiredAt: new Date(nowMs).toISOString(),
};
}
function writeTempRecord(root: string, role: string, record: StorageTransactionOwnerRecord): string {
const filePath = join(root, `${STORAGE_TRANSACTION_RESERVED_PREFIX}${role}.tmp.${record.token}.${randomUUID()}`);
const fd = openSync(filePath, "wx", 0o600);
try {
writeSync(fd, `${JSON.stringify(record)}\n`, undefined, "utf8");
fsyncSync(fd);
} finally {
closeSync(fd);
}
return filePath;
}
function publishHardLink(tempPath: string, canonicalPath: string): void {
try {
linkSync(tempPath, canonicalPath);
} catch (error) {
if (isErrno(error, "EEXIST")) throw error;
if (["EXDEV", "EPERM", "ENOTSUP", "EOPNOTSUPP"].some((code) => isErrno(error, code))) {
throw new StorageTransactionLockError(
"unsupported_filesystem",
"Filesystem cannot publish the storage transaction lock atomically",
);
}
throw error;
}
const source = lstatSync(tempPath);
const canonical = lstatSync(canonicalPath);
if (!source.isFile() || !canonical.isFile() || source.dev !== canonical.dev || source.ino !== canonical.ino) {
throw new StorageTransactionLockError("protocol_fault", "Published lock is not the expected hard-linked record");
}
}
function retireRecord(
filePath: string,
root: string,
role: string,
expectedToken: string,
): string {
const retired = join(root, `${STORAGE_TRANSACTION_RESERVED_PREFIX}${role}.${expectedToken}.${randomUUID()}`);
try {
renameSync(filePath, retired);
} catch (error) {
if (isErrno(error, "ENOENT")) {
throw new StorageTransactionLockError("busy", "Storage transaction ownership changed");
}
throw error;
}
const moved = readOwnerRecord(retired);
if (!moved || moved.token !== expectedToken) {
throw new StorageTransactionLockError(
"protocol_fault",
`Retired ${role} record token does not match the observed owner`,
);
}
return retired;
}
function acquireRecoveryMutex(
paths: ReturnType<typeof ownerPaths>,
identity: { machineId: string; bootId: string; pid: number; processStartId: string },
token: string,
nowMs: number,
): { release: () => void } {
const record = newRecord(identity, token, "storage-transaction-recovery", nowMs);
const temp = writeTempRecord(paths.sourceRootRealPath, "recovery", record);
try {
try {
publishHardLink(temp, paths.recoveryMutexPath);
} catch (error) {
if (!isErrno(error, "EEXIST")) throw error;
const existing = readOwnerRecord(paths.recoveryMutexPath);
if (!existing) throw new StorageTransactionLockError("recovery_conflict", "Storage transaction recovery ownership changed");
classifyExisting(existing, identity);
const reread = readOwnerRecord(paths.recoveryMutexPath);
if (!reread) throw new StorageTransactionLockError("recovery_conflict", "Storage transaction recovery ownership changed");
classifyExisting(reread, identity);
const quarantined = retireRecord(paths.recoveryMutexPath, paths.sourceRootRealPath, "quarantine", reread.token);
safeUnlink(quarantined);
try {
publishHardLink(temp, paths.recoveryMutexPath);
} catch (publishError) {
if (isErrno(publishError, "EEXIST")) {
throw new StorageTransactionLockError("recovery_conflict", "Storage transaction recovery mutex is busy");
}
throw publishError;
}
}
} finally {
safeUnlink(temp);
}
return {
release() {
const existing = readOwnerRecord(paths.recoveryMutexPath);
if (!existing || existing.token !== token) return;
const retired = retireRecord(paths.recoveryMutexPath, paths.sourceRootRealPath, "release", token);
safeUnlink(retired);
},
};
}
function writeParticipation(
paths: ReturnType<typeof ownerPaths>,
marker: StorageTransactionParticipationRecord,
): void {
const temp = join(
paths.sourceRootRealPath,
`${STORAGE_TRANSACTION_RESERVED_PREFIX}marker.tmp.${marker.token}.${randomUUID()}`,
);
const fd = openSync(temp, "wx", 0o600);
try {
writeSync(fd, `${JSON.stringify(marker)}\n`, undefined, "utf8");
fsyncSync(fd);
} finally {
closeSync(fd);
}
try {
renameSync(temp, paths.participationPath);
} catch (error) {
safeUnlink(temp);
throw error;
}
}
function markerFor(
state: StorageTransactionParticipationState,
token: string,
nowMs: number,
): StorageTransactionParticipationRecord {
return {
schema: STORAGE_TRANSACTION_LOCK_SCHEMA,
state,
token,
updatedAt: new Date(nowMs).toISOString(),
};
}
export function isStorageTransactionReservedName(name: string): boolean {
return typeof name === "string" && name.startsWith(STORAGE_TRANSACTION_RESERVED_PREFIX);
}
export function isStorageTransactionReservedPath(filePath: string, sourceRoot: string): boolean {
const root = resolveSourceRootRealPath(sourceRoot, { createIfMissing: false });
const resolved = resolve(filePath);
const rel = resolved.startsWith(root + sep)
? resolved.slice(root.length + 1)
: resolved === root
? ""
: null;
if (rel == null) return false;
const top = rel.split(sep)[0] ?? "";
return isStorageTransactionReservedName(top);
}
/**
* Acquire the source-root storage transaction lock.
* Publishes a fully populated owner record via same-directory hardlink and holds
* it for the entire producer/archive transaction.
*/
export function acquireStorageTransactionLock(
options: AcquireStorageTransactionLockOptions,
): StorageTransactionLockHandle {
const sourceRootRealPath = resolveSourceRootRealPath(options.sourceRoot);
const paths = ownerPaths(sourceRootRealPath);
const identity = resolveStorageTransactionIdentity();
const nowMs = options.nowMs ?? Date.now();
const token = randomUUID();
const owner = newRecord(identity, token, options.operationKind || "backup", nowMs);
const recoveries: Array<Record<string, unknown>> = [];
const publishOwner = (): StorageTransactionLockHandle => {
const temp = writeTempRecord(sourceRootRealPath, "owner", owner);
try {
publishHardLink(temp, paths.ownerPath);
} finally {
safeUnlink(temp);
}
try {
writeParticipation(paths, markerFor("active", token, nowMs));
} catch (error) {
const recovery = acquireRecoveryMutex(paths, identity, randomUUID(), Date.now());
try {
const current = readOwnerRecord(paths.ownerPath);
if (current && current.token === token) {
const retired = retireRecord(paths.ownerPath, sourceRootRealPath, "release", token);
safeUnlink(retired);
}
} finally {
recovery.release();
}
throw error;
}
return {
sourceRootRealPath,
ownerPath: paths.ownerPath,
recoveryMutexPath: paths.recoveryMutexPath,
participationPath: paths.participationPath,
token,
owner,
recoveries,
};
};
try {
return publishOwner();
} catch (error) {
if (!isErrno(error, "EEXIST")) throw error;
}
const existing = readOwnerRecord(paths.ownerPath);
if (!existing) {
throw new StorageTransactionLockError("busy", "Storage transaction ownership changed; retry from fresh state");
}
classifyExisting(existing, identity);
if (options.allowDeadOwnerRecovery === false) {
throw new StorageTransactionLockError("busy", "Dead-owner recovery is disabled");
}
const recovery = acquireRecoveryMutex(paths, identity, token, nowMs);
try {
const reread = readOwnerRecord(paths.ownerPath);
if (reread) {
classifyExisting(reread, identity);
const quarantined = retireRecord(paths.ownerPath, sourceRootRealPath, "quarantine", reread.token);
recoveries.push({
sourceRootRealPath,
classification: "dead_local",
quarantined: true,
quarantinePath: quarantined,
recoveredToken: reread.token,
});
safeUnlink(quarantined);
}
try {
return publishOwner();
} catch (error) {
if (isErrno(error, "EEXIST")) {
throw new StorageTransactionLockError("busy", "Storage transaction lock was acquired by another owner");
}
throw error;
}
} finally {
recovery.release();
}
}
export function assertStorageTransactionLockHeld(handle: StorageTransactionLockHandle): StorageTransactionOwnerRecord {
const current = readOwnerRecord(handle.ownerPath);
if (!current || current.token !== handle.token) {
throw new StorageTransactionLockError(
"token_mismatch",
"Storage transaction ownership no longer matches this handle",
{ sourceRootRealPath: handle.sourceRootRealPath },
);
}
if (current.pid !== process.pid || current.processStartId !== readProcessStartId()) {
throw new StorageTransactionLockError(
"not_holder",
"Storage transaction owner identity no longer matches this process",
{ sourceRootRealPath: handle.sourceRootRealPath },
);
}
return current;
}
export function markStorageTransactionParticipationReady(
handle: StorageTransactionLockHandle,
nowMs: number = Date.now(),
): void {
assertStorageTransactionLockHeld(handle);
writeParticipation(ownerPaths(handle.sourceRootRealPath), markerFor("ready", handle.token, nowMs));
}
/**
* Token-safe release: removes only this holder's owner record and leaves a
* ready participation marker for enrolled archive consumers.
*/
export function releaseStorageTransactionLock(
handle: StorageTransactionLockHandle,
options: { participationState?: StorageTransactionParticipationState } = {},
): void {
const identity = resolveStorageTransactionIdentity();
const paths = ownerPaths(handle.sourceRootRealPath);
const recovery = acquireRecoveryMutex(paths, identity, randomUUID(), Date.now());
try {
const current = readOwnerRecord(paths.ownerPath);
if (!current) return;
if (current.token !== handle.token) {
throw new StorageTransactionLockError(
"token_mismatch",
"Refusing to release a replacement storage transaction owner",
{ sourceRootRealPath: handle.sourceRootRealPath },
);
}
if ((options.participationState || "ready") === "ready") {
writeParticipation(paths, markerFor("ready", handle.token, Date.now()));
}
const retired = retireRecord(paths.ownerPath, paths.sourceRootRealPath, "release", handle.token);
safeUnlink(retired);
} finally {
recovery.release();
}
}
export function readStorageTransactionParticipation(
sourceRoot: string,
options: { createIfMissing?: boolean } = {},
): StorageTransactionParticipationRecord | null {
const sourceRootRealPath = resolveSourceRootRealPath(sourceRoot, {
createIfMissing: options.createIfMissing === true,
});
try {
return readParticipationRecord(ownerPaths(sourceRootRealPath).participationPath);
} catch {
return null;
}
}
export function classifyPaperclipParticipation(sourceRoot: string): {
ok: boolean;
reason: string | null;
sourceRootRealPath: string;
record?: StorageTransactionParticipationRecord | null;
newestSourceMtimeMs?: number | null;
} {
const sourceRootRealPath = resolveSourceRootRealPath(sourceRoot, { createIfMissing: false });
let record: StorageTransactionParticipationRecord | null = null;
try {
record = readParticipationRecord(ownerPaths(sourceRootRealPath).participationPath);
} catch {
return { ok: false, reason: "participation_incompatible", sourceRootRealPath };
}
if (!record) return { ok: false, reason: "participation_missing", sourceRootRealPath };
if (record.state === "active") {
return { ok: false, reason: "participation_active", sourceRootRealPath, record };
}
return { ok: true, reason: null, sourceRootRealPath, record };
}

View File

@ -29,6 +29,35 @@ export {
type RunDatabaseBackupResult,
type RunDatabaseRestoreOptions,
} from "./backup-lib.js";
export {
STORAGE_TRANSACTION_LOCK_SCHEMA,
STORAGE_TRANSACTION_RESERVED_PREFIX,
STORAGE_TRANSACTION_LOCK_DIR_NAME,
STORAGE_TRANSACTION_RECOVERY_MUTEX_NAME,
STORAGE_TRANSACTION_PARTICIPATION_NAME,
STORAGE_TRANSACTION_TMP_PREFIX,
STORAGE_TRANSACTION_QUARANTINE_PREFIX,
StorageTransactionLockError,
acquireStorageTransactionLock,
assertStorageTransactionLockHeld,
releaseStorageTransactionLock,
markStorageTransactionParticipationReady,
readStorageTransactionParticipation,
parseStorageTransactionOwnerRecord,
parseStorageTransactionParticipationRecord,
isStorageTransactionReservedName,
isStorageTransactionReservedPath,
resolveSourceRootRealPath,
readLocalMachineId,
readLocalBootId,
readProcessStartId,
resolveStorageTransactionIdentity,
classifyPaperclipParticipation,
type StorageTransactionLockHandle,
type StorageTransactionOwnerRecord,
type StorageTransactionParticipationRecord,
type StorageTransactionOperationKind,
} from "./backup-transaction-lock.js";
export {
createEmbeddedPostgresLogBuffer,
formatEmbeddedPostgresError,

View File

@ -34,6 +34,7 @@ import {
reconcilePendingMigrationHistory,
formatDatabaseBackupResult,
runDatabaseBackup,
StorageTransactionLockError,
authUsers,
companies,
companyMemberships,
@ -863,6 +864,13 @@ async function startServerWithDatabaseTeardown(
);
return response;
} catch (err) {
if (trigger === "scheduled" && err instanceof StorageTransactionLockError && err.code === "busy") {
logger.warn(
{ backupDir: config.databaseBackupDir, trigger, reason: err.code },
"Skipping scheduled database backup because the storage transaction lock is busy",
);
return null;
}
logger.error({ err, backupDir: config.databaseBackupDir, trigger }, `${label} database backup failed`);
throw err;
} finally {