Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 4458168e0e07afa34db8fa9e4bd4d722e6978cc2
parent 30224b9528b004afed82103f56581d66824dccd3
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 21:10:17 -0400

common: the tier places bytes by hard link or timed copy before one rename; derefs any id; strays healed; no slot for an in-place media/ (review H1, L2, L3, L5, N8)

The name always resolves across a crash; an EXDEV copy carries the file's
times so stat and lstat agree; the marker is asked again before the name is
swapped; removeMediaFile derefs a link into any id under the channel's own
media/ and drops the dir when it empties; tierVideoDir removes a dead
process's stray temp link; a real media/ takes no watchdog slot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mcommon/lib/mediaTier-server.test.ts | 48+++++++++++++++++++++++++++++++++++++++++++++---
Mcommon/lib/mediaTier-server.ts | 109++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
2 files changed, 122 insertions(+), 35 deletions(-)

diff --git a/common/lib/mediaTier-server.test.ts b/common/lib/mediaTier-server.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { lstat, mkdir, + stat, mkdtemp, readFile, readlink, @@ -104,7 +105,7 @@ test("tierVideoDir tiers the audio and the raw live chat, nothing else", async ( await rm(f.root, { recursive: true, force: true }); }); -test("relocated: the media link points at another root; EXDEV copies, then links", async () => { +test("relocated: the media link points at another root; EXDEV copies (with the file's times), then links", async () => { const f = await fixture(); const platter = path.join(f.root, "platter"); const target = relocatedMediaDir(platter, f.slug); @@ -113,7 +114,7 @@ test("relocated: the media link points at another root; EXDEV copies, then links let copies = 0; const result = await tierMediaFile(f.videoDir, "audio.mp3", { fs: { - rename: async () => { + link: async () => { const err = new Error("cross-device link not permitted") as NodeJS.ErrnoException; err.code = "EXDEV"; throw err; @@ -130,6 +131,8 @@ test("relocated: the media link points at another root; EXDEV copies, then links assert.ok((await lstat(link)).isSymbolicLink()); assert.equal(await readFile(link, "utf8"), "AUDIO"); assert.equal(await readFile(path.join(target, "vid1", "audio.mp3"), "utf8"), "AUDIO"); + // stat (the copy) and lstat (the link) agree on the mtime. + assert.equal((await stat(link)).mtimeMs, (await lstat(link)).mtimeMs); await rm(f.root, { recursive: true, force: true }); }); @@ -140,7 +143,7 @@ test("a failed move is undone: the name is a real file again, no link", async () const result = await tierMediaFile(f.videoDir, "audio.mp3", { onLog: (s) => logs.push(s), fs: { - rename: async () => { + link: async () => { const err = new Error("no space left on device") as NodeJS.ErrnoException; err.code = "ENOSPC"; throw err; @@ -283,3 +286,42 @@ test("a move marker on the channel: the hook writes nothing into media/", async assert.ok((await lstat(path.join(f.videoDir, "audio.mp3"))).isFile()); await rm(f.root, { recursive: true, force: true }); }); + +test("the name always resolves: a crash after the bytes land leaves the real file, and the next sweep removes the stray link", async () => { + const f = await fixture(); + await mkdir(channelMediaLink(f.paths, f.slug)); + // A process killed between placing the bytes and the rename: its temp link + // is left beside the still-real file (pid 999999 is not alive). + await symlink("../../media/vid1/audio.mp3", path.join(f.videoDir, ".audio.mp3.tierlink-999999")); + assert.ok((await lstat(path.join(f.videoDir, "audio.mp3"))).isFile()); + const c = await tierVideoDir(f.videoDir); + assert.equal(c.tiered, 2); + assert.deepEqual( + (await readdir(f.videoDir)).filter((n) => n.includes("tierlink")), + [], + ); + await rm(f.root, { recursive: true, force: true }); +}); + +test("same filesystem: the bytes are hard-linked into the tier (one inode), then the name is replaced", async () => { + const f = await fixture(); + await mkdir(channelMediaLink(f.paths, f.slug)); + const ino = (await stat(path.join(f.videoDir, "audio.mp3"))).ino; + assert.equal(await tierMediaFile(f.videoDir, "audio.mp3"), "tiered"); + const bytes = path.join(f.channelsDir, f.slug, "media", "vid1", "audio.mp3"); + assert.equal((await stat(bytes)).ino, ino); + assert.equal((await stat(bytes)).nlink, 1, "the original name no longer holds the inode"); + await rm(f.root, { recursive: true, force: true }); +}); + +test("removeMediaFile derefs a link into ANOTHER id's media dir (a renamed video dir), and drops it when empty", async () => { + const f = await fixture(); + const media = channelMediaLink(f.paths, f.slug); + await mkdir(path.join(media, "oldid"), { recursive: true }); + await writeFile(path.join(media, "oldid", "audio.m4a"), "OLD"); + await symlink("../../media/oldid/audio.m4a", path.join(f.videoDir, "audio.m4a")); + await removeMediaFile(f.videoDir, "audio.m4a"); + assert.equal(existsSync(path.join(media, "oldid")), false); + await assert.rejects(lstat(path.join(f.videoDir, "audio.m4a"))); + await rm(f.root, { recursive: true, force: true }); +}); diff --git a/common/lib/mediaTier-server.ts b/common/lib/mediaTier-server.ts @@ -29,6 +29,7 @@ import path from "node:path"; import { + link, lstat, lutimes, mkdir, @@ -36,8 +37,10 @@ import { readlink, rename, rm, + rmdir, stat, symlink, + utimes, } from "node:fs/promises"; import type { Paths } from "./paths"; import { copyFileAtomic } from "./jsonFile-server"; @@ -87,20 +90,33 @@ function errCode(err: unknown): string | undefined { } // Test seam: the filesystem calls a test needs to fail on cue (an injected -// `rename` throwing EXDEV, the copy the fallback makes). +// `link` throwing EXDEV, the copy the fallback makes). export type TierFs = { - rename: typeof rename; + link: (existing: string, linkPath: string) => Promise<void>; copyFileAtomic: (src: string, dest: string) => Promise<void>; // Called with ONE argument — never `{ recursive: true }` (below). mkdir: (dir: string) => Promise<unknown>; }; const DEFAULT_FS: TierFs = { - rename, + link: (a, b) => link(a, b), copyFileAtomic: (s, d) => copyFileAtomic(s, d), mkdir: (d) => mkdir(d), }; +// A hard link cannot be made here: another filesystem (a relocated channel), +// or one that has none. +const NO_HARD_LINK = new Set(["EXDEV", "EPERM", "ENOTSUP", "EOPNOTSUPP", "EMLINK"]); + +async function markerStands(mediaRoot: string): Promise<boolean> { + try { + await lstat(path.join(path.dirname(mediaRoot), TIER_RELOCATION_MARKER)); + return true; + } catch { + return false; + } +} + export type TierOptions = { onLog?: (line: string) => void; fs?: Partial<TierFs>; @@ -116,20 +132,16 @@ async function mediaTierReady(mediaRoot: string): Promise<boolean> { // does — the file stays real and the next sweep tiers it. (The writers that // call the hook are held by the media guard during a move; this is the // backstop for one that started before the marker.) - try { - await lstat(path.join(path.dirname(mediaRoot), TIER_RELOCATION_MARKER)); - return false; - } catch { - /* no marker: the normal case */ - } + if (await markerStands(mediaRoot)) return false; let target = mediaRoot; try { const l = await lstat(mediaRoot); if (l.isSymbolicLink()) { target = path.resolve(path.dirname(mediaRoot), await readlink(mediaRoot)); if (stalledLocationForPath(target)) return false; - } else if (!l.isDirectory()) { - return false; + } else { + // A real `media/` is on the corpus disk: no drive, no watchdog slot. + return l.isDirectory(); } } catch { return false; @@ -156,12 +168,17 @@ export type TierResult = "tiered" | "left" | "already"; // directory, and a recursive mkdir aimed at a mountpoint that went away in // between would build the path on the root filesystem and fill it. // -// The link REPLACES the file atomically (a temp link renamed over the name), -// so a reader opening the name between the two steps finds the file or the -// link, never nothing. Same filesystem: the file is renamed into the tier -// first (instant), the link replaces the now-missing name. Across filesystems -// (EXDEV — a relocated channel): the bytes are copied atomically into the tier, -// then the link replaces the original, which is then gone. +// THE NAME ALWAYS RESOLVES, crash or not. The bytes are put into the tier +// WITHOUT touching the name — same filesystem: a hard link (instant, the same +// inode), renamed into place; across filesystems (a relocated channel) or with +// no hard links: an atomic copy, given the file's times so `stat` and `lstat` +// agree — and only then does the temp link replace the name, in one rename. +// A process killed anywhere leaves the real file under its name (and at worst +// a stray temp link, which the next sweep removes). +// +// A MOVE THAT BEGAN MEANWHILE: the marker is asked again after the bytes land +// and before the name changes; if one stands, the tier's copy is removed and +// the file stays real. export async function tierMediaFile( videoDir: string, name: string, @@ -194,38 +211,51 @@ export async function tierMediaFile( } const tmpLink = path.join(videoDir, `.${name}.tierlink-${process.pid}`); - let moved = false; + const destTmp = path.join(destDir, `.${name}.tiering-${process.pid}`); + let placed = false; try { - // The link first (dangling until the bytes land), so the move and the - // replace are two consecutive renames. await rm(tmpLink, { force: true }); await symlink(tierLinkTarget(id, name), tmpLink); // The FILE's times on the LINK, so an `lstat` of the name answers what a // `stat` of the file did before it was tiered — the live-chat freshness - // check (normalizeLiveChat.ts) reads it there, on the corpus disk, instead - // of reaching the media drive. + // check and the index's sub-track key read it there, on the corpus disk, + // instead of reaching the media drive. await lutimes(tmpLink, st.atime, st.mtime).catch(() => {}); + await rm(destTmp, { force: true }); try { - await fsx.rename(file, dest); - moved = true; + await fsx.link(file, destTmp); + await rename(destTmp, dest); } catch (err) { - if (errCode(err) !== "EXDEV") throw err; + await rm(destTmp, { force: true }).catch(() => {}); + if (!NO_HARD_LINK.has(errCode(err) ?? "")) throw err; await fsx.copyFileAtomic(file, dest); + await utimes(dest, st.atime, st.mtime).catch(() => {}); + } + placed = true; + if (await markerStands(mediaRoot)) { + throw new Error("a move of this channel's media began"); } await rename(tmpLink, file); return "tiered"; } catch (err) { await rm(tmpLink, { force: true }).catch(() => {}); - // Undo a same-filesystem move so the name is never left missing. A failed - // cross-filesystem copy left the original where it was. - if (moved) { - await rename(dest, file).catch(() => {}); - } + // The name still holds the real file; the tier's copy goes. + if (placed) await rm(dest, { force: true }).catch(() => {}); opts.onLog?.(`[media tier] ${id}/${name} left in place: ${(err as Error).message}\n`); return "left"; } } +function processAlive(pid: number): boolean { + if (pid === process.pid) return true; + try { + process.kill(pid, 0); + return true; + } catch (err) { + return errCode(err) === "EPERM"; + } +} + export type TierCounts = { tiered: number; left: number; already: number }; function emptyCounts(): TierCounts { @@ -245,6 +275,16 @@ export async function tierVideoDir( return counts; } for (const name of names) { + // A stray temp link from a process that is gone (killed between placing + // the bytes and the rename): the name still holds the file, so it is only + // removed. + const stray = /^\..+\.tierlink-(\d+)$/.exec(name); + if (stray) { + if (!processAlive(Number(stray[1]))) { + await rm(path.join(videoDir, name), { force: true }).catch(() => {}); + } + continue; + } if (!isTierable(name)) continue; counts[await tierMediaFile(videoDir, name, opts)] += 1; } @@ -324,15 +364,20 @@ export async function removeMediaFile(videoDir: string, name: string): Promise<v return; } if (st.isSymbolicLink()) { - const tierDir = mediaDirOfVideoDir(videoDir); + // Any id's dir under the channel's own `media/`: a video dir renamed by + // reconcileVideoDirs keeps links into `media/<itsOldId>/`. Never followed + // out of `media/`. + const mediaRoot = path.dirname(mediaDirOfVideoDir(videoDir)); let target = ""; try { target = path.resolve(videoDir, await readlink(file)); } catch { /* unreadable link: removed alone below */ } - if (target && path.dirname(target) === tierDir) { + if (target && path.dirname(path.dirname(target)) === mediaRoot) { await rm(target, { force: true }); + // Its dir goes when it empties (a non-recursive rmdir refuses otherwise). + await rmdir(path.dirname(target)).catch(() => {}); } } await rm(file, { force: true });