commit d2bf82cf88f698f5d190a8741919137006049e04
parent 31b4bb77d3425258099e821ade09f6b97c6ab60c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 2 Oct 2026 01:28:04 -0400
common: review H1 — a re-point carries a legacy channel the retired way
A location holding migrated and not-yet-migrated channels side by side still
follows its disk: a legacy channel (its data/ a link, config.dataDir) is
re-pointed in the same ledger and rollback loop by rewriting its data link and
dataDir to <newRoot>/<slug>/data, after checking that tree exists there; the
media channels keep the media link and mediaDir. The tier migration so only
ever sees dataDir = <root>/<slug>/data. A recorded dataDir over a real data/
is refused. Tests: one legacy and one media channel re-pointed together; a
legacy tree missing under the new root is named and nothing moves.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
2 files changed, 151 insertions(+), 40 deletions(-)
diff --git a/common/controller/storageLocations.test.ts b/common/controller/storageLocations.test.ts
@@ -187,27 +187,95 @@ test("channelsOnLocation buckets ok / unreachable / moving and ignores channels
});
});
-test("re-point refuses a legacy channel on the location, naming migrate-tier", async () => {
+// A LOCATION SHARED BY A MIGRATED AND A NOT-YET-MIGRATED CHANNEL still follows
+// its disk (release 17 review H1): the migration stops before the biggest
+// channels, so both layouts sit on the platter for a while. The legacy one is
+// re-pointed the retired way — its `data` link and `dataDir` to
+// `<newRoot>/<slug>/data`, the one shape the tier migration reads.
+async function seedLegacy(h: Harness, slug: string, root: string): Promise<string> {
+ const target = path.join(root, slug, "data");
+ await mkdir(path.join(target, "v1"), { recursive: true });
+ const channelDir = path.join(h.paths.channelsDir, slug);
+ await mkdir(channelDir, { recursive: true });
+ await writeFile(
+ path.join(channelDir, "config.json"),
+ JSON.stringify({ handling: "transcribe", url: `https://x/${slug}`, dataDir: target }) + "\n",
+ );
+ await symlink(target, path.join(channelDir, "data"));
+ return target;
+}
+
+test("re-point moves a legacy channel the retired way beside a migrated one", async () => {
await withTmp(async (h) => {
- const oldTarget = path.join(h.rootA, "old", "data");
- await mkdir(oldTarget, { recursive: true });
- await mkdir(path.join(h.rootB, "old", "data"), { recursive: true });
- const oldDir = path.join(h.paths.channelsDir, "old");
- await mkdir(oldDir, { recursive: true });
- await writeFile(
- path.join(oldDir, "config.json"),
- JSON.stringify({ handling: "transcribe", url: "https://x/o", dataDir: oldTarget }) + "\n",
- );
- await symlink(oldTarget, path.join(oldDir, "data"));
- await setLocations(h, [loc("a", h.rootA)]);
+ await seedRelocated(h, "alpha", h.rootA);
+ await seedLegacy(h, "old", h.rootA);
+ // The disk moved: both trees are now under B.
+ await mkdir(path.join(h.rootB, "alpha", "media"), { recursive: true });
+ await mkdir(path.join(h.rootB, "old", "data", "v1"), { recursive: true });
+ await setLocations(h, [loc("cold", h.rootA)]);
+
const pre = await preflightRepoint({
paths: h.paths,
- locationId: "a",
+ locationId: "cold",
newRoot: h.rootB,
io: h.io,
});
- assert.equal(pre.ok, false);
- assert.match(pre.problems.join("\n"), /old: .*archilyzer storage migrate-tier old/);
+ assert.deepEqual(pre.problems, []);
+ assert.deepEqual(pre.channels, ["alpha", "old"]);
+ assert.deepEqual(pre.legacy, ["old"]);
+
+ const result = await repointStorageLocation({
+ paths: h.paths,
+ locationId: "cold",
+ newRoot: h.rootB,
+ io: h.io,
+ });
+ assert.deepEqual(result.channels, ["alpha", "old"]);
+ assert.equal(
+ await readlink(path.join(h.paths.channelsDir, "alpha", "media")),
+ path.join(h.rootB, "alpha", "media"),
+ );
+ assert.equal(
+ (await readChannelConfig(h.paths, "alpha"))?.mediaDir,
+ path.join(h.rootB, "alpha", "media"),
+ );
+ const oldCfg = await readChannelConfig(h.paths, "old");
+ assert.equal(oldCfg?.dataDir, path.join(h.rootB, "old", "data"));
+ assert.equal(oldCfg?.mediaDir, undefined);
+ assert.equal(
+ await readlink(path.join(h.paths.channelsDir, "old", "data")),
+ path.join(h.rootB, "old", "data"),
+ );
+ // No `media` link was invented for the legacy channel.
+ await assert.rejects(() => readlink(path.join(h.paths.channelsDir, "old", "media")));
+ assert.equal(h.settings().storage.locations[0].root, h.rootB);
+ });
+});
+
+test("a legacy channel whose retired tree is missing under the new root is named, and nothing moves", async () => {
+ await withTmp(async (h) => {
+ await seedRelocated(h, "alpha", h.rootA);
+ const oldTarget = await seedLegacy(h, "old", h.rootA);
+ await mkdir(path.join(h.rootB, "alpha", "media"), { recursive: true });
+ await setLocations(h, [loc("cold", h.rootA)]);
+ await assert.rejects(
+ () =>
+ repointStorageLocation({
+ paths: h.paths,
+ locationId: "cold",
+ newRoot: h.rootB,
+ io: h.io,
+ }),
+ /old\. Expected .*not yet migrated/,
+ );
+ assert.equal(
+ await readlink(path.join(h.paths.channelsDir, "old", "data")),
+ oldTarget,
+ );
+ assert.equal(
+ await readlink(path.join(h.paths.channelsDir, "alpha", "media")),
+ path.join(h.rootA, "alpha", "media"),
+ );
});
});
diff --git a/common/controller/storageLocations.ts b/common/controller/storageLocations.ts
@@ -1,5 +1,5 @@
import path from "node:path";
-import { readlink, stat, symlink, unlink } from "node:fs/promises";
+import { lstat, readlink, stat, symlink, unlink } from "node:fs/promises";
import { getPaths, type Paths } from "../lib/paths";
import { getFreeBytes } from "../lib/diskSpace";
import {
@@ -27,6 +27,7 @@ import {
import {
forgetChannelMedia,
inspectChannelMedia,
+ relocatedDataDir,
} from "../lib/channelMedia";
import { MEDIA_LINK_NAME, relocatedMediaDir } from "../lib/mediaTier-server";
import {
@@ -465,6 +466,13 @@ export type RepointPreflight = {
// wholesale, naming a state with no remedy. For these the job writes the
// config and does NOT touch the link: the link is already right.
resumable: string[];
+ // A SUBSET of `channels`: the ones still on the RETIRED whole-directory
+ // layout (`legacy`, release 17). They are re-pointed the retired way — their
+ // `data` link and `config.dataDir` to `<newRoot>/<slug>/data` — so a
+ // location holding migrated and not-yet-migrated channels side by side
+ // still follows its disk, and the tier migration only ever sees
+ // `dataDir = <root>/<slug>/data`.
+ legacy: string[];
// The identity the new root actually has, when the preflight was able to
// probe it (bins passed AND the location has a recorded uuid to compare
// against). The job writes THIS rather than deriving a mountpoint from the
@@ -475,17 +483,38 @@ export type RepointPreflight = {
newRoot: string;
};
-// Does `channels/<slug>/media` already point exactly where a re-point would
-// put it? lstat/readlink, never stat: the target may not exist yet either, and
-// a stat would call a perfectly good link missing.
+// What a re-point rewrites for one channel: the `media` link and `mediaDir`,
+// or — a legacy channel — the retired `data` link and `dataDir`.
+type RepointShape = {
+ linkName: string;
+ key: "mediaDir" | "dataDir";
+ targetFor: (root: string, slug: string) => string;
+};
+
+const MEDIA_SHAPE: RepointShape = {
+ linkName: MEDIA_LINK_NAME,
+ key: "mediaDir",
+ targetFor: relocatedMediaDir,
+};
+const LEGACY_SHAPE: RepointShape = {
+ linkName: "data",
+ key: "dataDir",
+ targetFor: relocatedDataDir,
+};
+
+// Does the channel's link (`media`, or a legacy channel's `data`) already point
+// exactly where a re-point would put it? lstat/readlink, never stat: the target
+// may not exist yet either, and a stat would call a perfectly good link
+// missing.
async function linkAlreadyAt(
paths: Paths,
slug: string,
newTarget: string,
+ linkName: string = MEDIA_LINK_NAME,
): Promise<boolean> {
try {
const linkTarget = await readlink(
- path.join(paths.channelsDir, slug, MEDIA_LINK_NAME),
+ path.join(paths.channelsDir, slug, linkName),
);
return path.resolve(linkTarget) === path.resolve(newTarget);
} catch {
@@ -523,6 +552,7 @@ export async function preflightRepoint(opts: {
problems: [],
channels: [],
resumable: [],
+ legacy: [],
locationId: opts.locationId,
oldRoot: loc?.root ?? "",
newRoot,
@@ -608,22 +638,29 @@ export async function preflightRepoint(opts: {
// is the job being refused. When the link already points exactly where this
// run would point it, the channel is not broken: it is half done, and the
// remaining half is the config write this job performs anyway.
+ // THE RETIRED LAYOUT (release 17) is re-pointed the retired way: its
+ // `data` link and `dataDir`. Only a channel whose `data/` IS a link — a
+ // recorded `dataDir` over a real `data/` is a disagreement, refused below.
+ const legacy = media.status === "legacy";
+ const shape = legacy ? LEGACY_SHAPE : MEDIA_SHAPE;
const resumable = await linkAlreadyAt(
opts.paths,
slug,
- relocatedMediaDir(newRoot, slug),
+ shape.targetFor(newRoot, slug),
+ shape.linkName,
);
- // THE RETIRED LAYOUT (release 17) is migrated, never re-pointed: its
- // `data` link is not the media link a re-point rewrites.
- if (media.status === "legacy") {
- base.problems.push(
- `${slug}: ${media.detail ?? "its media layout is the retired whole-directory one"}. ` +
- `A re-point rewrites the media link, which this channel does not ` +
- `have until it is migrated.`,
- );
- continue;
- }
- if (!resumable && media.status !== "ok" && media.status !== "unreachable") {
+ if (legacy) {
+ const dataIsLink = await lstat(path.join(opts.paths.channelsDir, slug, "data"))
+ .then((l) => l.isSymbolicLink())
+ .catch(() => false);
+ if (!dataIsLink) {
+ base.problems.push(
+ `${slug}: config.json records the retired dataDir but its data/ is ` +
+ `not a link — ${media.detail ?? "run archilyzer storage migrate-tier"}.`,
+ );
+ continue;
+ }
+ } else if (!resumable && media.status !== "ok" && media.status !== "unreachable") {
base.problems.push(
`${slug}: its media reads as ${media.status} — ` +
`${media.detail ?? "disk and config do not agree"}. A re-point ` +
@@ -636,7 +673,7 @@ export async function preflightRepoint(opts: {
base.problems.push(`${slug}: ${busy}`);
continue;
}
- const target = relocatedMediaDir(newRoot, slug);
+ const target = shape.targetFor(newRoot, slug);
if (!(await isDirectory(target))) {
missing.push(slug);
continue;
@@ -652,6 +689,7 @@ export async function preflightRepoint(opts: {
}
base.channels.push(slug);
if (resumable) base.resumable.push(slug);
+ if (legacy) base.legacy.push(slug);
}
// THE MISSING TARGETS ARE ONE REFUSAL, NOT n. A root that holds none of the
@@ -662,7 +700,8 @@ export async function preflightRepoint(opts: {
base.problems.push(
`${missing.length} channel(s) have no media under ${newRoot}: ` +
`${missing.join(", ")}. Expected ` +
- `${relocatedMediaDir(newRoot, missing[0])} and friends.`,
+ `${relocatedMediaDir(newRoot, missing[0])} (or, for a channel not yet ` +
+ `migrated, ${relocatedDataDir(newRoot, missing[0])}) and friends.`,
);
}
@@ -676,6 +715,8 @@ export async function preflightRepoint(opts: {
type ChannelLedgerEntry = {
slug: string;
+ // Which link and config key this channel's re-point rewrites.
+ shape: RepointShape;
oldTarget: string;
newTarget: string;
unlinked: boolean;
@@ -707,11 +748,11 @@ async function rollbackChannel(
// Only the field this job changed goes back; anything else edited since
// stays. (It used to rewrite the whole config it found at the start.)
await patchChannelConfig(paths, entry.slug, {
- mediaDir: entry.oldTarget,
+ [entry.shape.key]: entry.oldTarget,
}).catch(() => {});
}
if (entry.relinked || entry.unlinked) {
- const link = path.join(paths.channelsDir, entry.slug, MEDIA_LINK_NAME);
+ const link = path.join(paths.channelsDir, entry.slug, entry.shape.linkName);
await unlink(link).catch(() => {});
await symlink(entry.oldTarget, link).catch(() => {});
}
@@ -773,10 +814,12 @@ export async function repointStorageLocation(opts: {
for (const slug of pre.channels) {
opts.signal?.throwIfAborted();
const fresh = await readChannelConfig(opts.paths, slug);
- const oldTarget = fresh?.mediaDir?.trim() ?? "";
- const newTarget = relocatedMediaDir(pre.newRoot, slug);
+ const shape = pre.legacy.includes(slug) ? LEGACY_SHAPE : MEDIA_SHAPE;
+ const oldTarget = fresh?.[shape.key]?.trim() ?? "";
+ const newTarget = shape.targetFor(pre.newRoot, slug);
const entry: ChannelLedgerEntry = {
slug,
+ shape,
oldTarget,
newTarget,
unlinked: false,
@@ -785,7 +828,7 @@ export async function repointStorageLocation(opts: {
oldConfig: fresh,
};
ledger.push(entry);
- const link = path.join(opts.paths.channelsDir, slug, MEDIA_LINK_NAME);
+ const link = path.join(opts.paths.channelsDir, slug, shape.linkName);
// RESUMING SKIPS THE LINK, and must: it already points at newTarget, so
// unlinking and recreating it would be two syscalls to reach the state it
// is in — and a crash between them would turn a half-done channel into a
@@ -804,7 +847,7 @@ export async function repointStorageLocation(opts: {
// preflight listed the channel: fail this step, so the ledger rolls
// the link back, rather than leave a link no mediaDir records.
const written = await patchChannelConfig(opts.paths, slug, {
- mediaDir: newTarget,
+ [shape.key]: newTarget,
});
if (!written) {
throw new Error(`channels/${slug}/config.json is missing or unreadable`);