Archilyzer · Source

archilyzer

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

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:
Mcommon/controller/storageLocations.test.ts | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mcommon/controller/storageLocations.ts | 93++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
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`);