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:
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 });