mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 10:37:35 +00:00
* chore: remove verified unused Orca code * chore: remove translations orphaned by dead code cleanup
208 lines
7.2 KiB
TypeScript
208 lines
7.2 KiB
TypeScript
// Why: rename() is atomic for readers but not durable. Without fsync on the file and its directory,
|
|
// a power loss after a successful rename can leave the old contents, or an empty inode — the same
|
|
// empty-file symptom as issue #1158, from a different cause. The .bak ring recovers it at up to an
|
|
// hour's loss; fsync stops it from happening.
|
|
|
|
import { closeSync, fsyncSync, openSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { open, readdir, rm, stat } from 'node:fs/promises'
|
|
import { basename, dirname, join } from 'node:path'
|
|
import {
|
|
publishFileWithoutOverwrite,
|
|
renameFileWithWindowsRetry,
|
|
renameFileWithWindowsRetryAsync
|
|
} from './codex-accounts/fs-utils'
|
|
|
|
/**
|
|
* fsync a directory so a rename within it is durable. Best-effort by design: Windows cannot open a
|
|
* directory for fsync, and some filesystems reject it. The file fsync above it is the load-bearing
|
|
* part; this closes the "rename recorded but not persisted" window where the platform allows it.
|
|
*/
|
|
async function syncDirectory(directory: string): Promise<void> {
|
|
let handle: Awaited<ReturnType<typeof open>> | null = null
|
|
try {
|
|
handle = await open(directory, 'r')
|
|
await handle.sync()
|
|
} catch {
|
|
// Expected on Windows and on filesystems without directory fsync.
|
|
} finally {
|
|
await handle?.close().catch(() => {})
|
|
}
|
|
}
|
|
|
|
/** Sync variant of the best-effort directory fsync, for callers that publish by rename or link. */
|
|
export function syncDirectoryDurablySync(directory: string): void {
|
|
let fd: number | null = null
|
|
try {
|
|
fd = openSync(directory, 'r')
|
|
fsyncSync(fd)
|
|
} catch {
|
|
// Same platform caveats as syncDirectory.
|
|
} finally {
|
|
if (fd !== null) {
|
|
try {
|
|
closeSync(fd)
|
|
} catch {
|
|
// Nothing actionable; the fsync already happened or the open failed.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Rename an already-fsynced file and make the containing directory durable. */
|
|
export function renameDurableSync(tmpPath: string, finalPath: string): void {
|
|
renameFileWithWindowsRetry(tmpPath, finalPath)
|
|
syncDirectoryDurablySync(dirname(finalPath))
|
|
}
|
|
|
|
/** Publish an already-fsynced file without replacing a concurrently created destination. */
|
|
export function publishFileDurableSync(tmpPath: string, finalPath: string): boolean {
|
|
if (!publishFileWithoutOverwrite(tmpPath, finalPath)) {
|
|
return false
|
|
}
|
|
syncDirectoryDurablySync(dirname(finalPath))
|
|
rmSync(tmpPath)
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Rename and then fsync the containing directory. For callers that already fsynced the temp file
|
|
* themselves and need the rename made durable.
|
|
*/
|
|
export async function renameDurable(tmpPath: string, finalPath: string): Promise<void> {
|
|
await renameFileWithWindowsRetryAsync(tmpPath, finalPath)
|
|
await syncDirectory(dirname(finalPath))
|
|
}
|
|
|
|
/**
|
|
* Write `payload` to `tmpPath` and fsync it, WITHOUT publishing it. For callers that must order
|
|
* other work between "the new content is durable" and "the new content is visible" — a backup
|
|
* rotation that has to happen while the old file is still in place, for instance.
|
|
*/
|
|
export async function writeTempFileDurable(
|
|
tmpPath: string,
|
|
payload: string | Uint8Array,
|
|
mode?: number
|
|
): Promise<void> {
|
|
const handle = await open(tmpPath, 'w', mode)
|
|
try {
|
|
await handle.writeFile(payload, 'utf-8')
|
|
await handle.sync()
|
|
} finally {
|
|
await handle.close()
|
|
}
|
|
}
|
|
|
|
/** Write `payload` to `tmpPath`, fsync it, then rename onto `finalPath` and fsync the directory. */
|
|
export async function writeFileDurable(
|
|
tmpPath: string,
|
|
finalPath: string,
|
|
payload: string | Uint8Array
|
|
): Promise<void> {
|
|
await writeFileDurableIfCurrent(tmpPath, finalPath, payload, () => true)
|
|
}
|
|
|
|
/**
|
|
* `writeFileDurable` with a commit veto: `isCurrent` is consulted after the fsync and before the
|
|
* rename so a writer that was superseded mid-write doesn't publish a stale snapshot. Returns whether
|
|
* the rename happened; the temp file is removed on every path that doesn't commit, so a multi-MB
|
|
* payload can't orphan itself.
|
|
*/
|
|
export async function writeFileDurableIfCurrent(
|
|
tmpPath: string,
|
|
finalPath: string,
|
|
payload: string | Uint8Array,
|
|
isCurrent: () => boolean
|
|
): Promise<boolean> {
|
|
let renamed = false
|
|
try {
|
|
// Why: fsync BEFORE rename. A rename that lands first can expose a zero-length file.
|
|
await writeTempFileDurable(tmpPath, payload)
|
|
if (!(await renameFileWithWindowsRetryAsync(tmpPath, finalPath, isCurrent))) {
|
|
return false
|
|
}
|
|
renamed = true
|
|
await syncDirectory(dirname(finalPath))
|
|
return true
|
|
} finally {
|
|
if (!renamed) {
|
|
await rm(tmpPath, { force: true }).catch(() => {})
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Temp path for a durable write. Shared shape so `removeStaleDurableWriteTempFiles` can reclaim orphans. */
|
|
export function durableWriteTempPath(finalPath: string, owner?: string): string {
|
|
const ownerSuffix = owner === undefined ? '' : `.owner-${owner}`
|
|
return `${finalPath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}${ownerSuffix}.tmp`
|
|
}
|
|
|
|
/**
|
|
* Sweep temp files orphaned by a death between write and rename — for multi-MB payloads they would
|
|
* otherwise accumulate forever. Callers can require a minimum age to spare another live instance's
|
|
* write. This process's own temps are skipped unless their owner is known to have exited.
|
|
*/
|
|
export async function removeStaleDurableWriteTempFiles(
|
|
finalPath: string,
|
|
options: { minimumAgeMs?: number; retiredOwner?: string } = {}
|
|
): Promise<void> {
|
|
const directory = dirname(finalPath)
|
|
const prefix = `${basename(finalPath)}.`
|
|
const ownPrefix = `${prefix}${process.pid}.`
|
|
const retiredOwnerSuffix =
|
|
options.retiredOwner === undefined ? null : `.owner-${options.retiredOwner}.tmp`
|
|
try {
|
|
const names = await readdir(directory)
|
|
await Promise.all(
|
|
names
|
|
.filter((name) => {
|
|
if (!name.startsWith(prefix) || !name.endsWith('.tmp')) {
|
|
return false
|
|
}
|
|
// The caller must await owner exit before reclaiming its current-process files.
|
|
return retiredOwnerSuffix === null
|
|
? !name.startsWith(ownPrefix)
|
|
: name.startsWith(ownPrefix) && name.endsWith(retiredOwnerSuffix)
|
|
})
|
|
.map(async (name) => {
|
|
const path = join(directory, name)
|
|
if (options.minimumAgeMs) {
|
|
const info = await stat(path).catch(() => null)
|
|
if (!info || Date.now() - info.mtimeMs < options.minimumAgeMs) {
|
|
return
|
|
}
|
|
}
|
|
await rm(path, { force: true }).catch(() => {})
|
|
})
|
|
)
|
|
} catch {
|
|
// Directory missing or unreadable — nothing to sweep.
|
|
}
|
|
}
|
|
|
|
/** Synchronous counterpart for quit and crash paths that cannot await. */
|
|
export function writeFileDurableSync(
|
|
tmpPath: string,
|
|
finalPath: string,
|
|
payload: string | Uint8Array,
|
|
/** Creation mode for a new file, e.g. 0o600 for state other users must not read. */
|
|
mode?: number
|
|
): void {
|
|
let renamed = false
|
|
try {
|
|
// A Uint8Array payload is written verbatim; a string still defaults to UTF-8.
|
|
writeFileSync(tmpPath, payload, mode === undefined ? undefined : { mode })
|
|
const fd = openSync(tmpPath, 'r+')
|
|
try {
|
|
fsyncSync(fd)
|
|
} finally {
|
|
closeSync(fd)
|
|
}
|
|
renameDurableSync(tmpPath, finalPath)
|
|
renamed = true
|
|
} finally {
|
|
if (!renamed) {
|
|
rmSync(tmpPath, { force: true })
|
|
}
|
|
}
|
|
}
|