commit 425cd7102957063690c6b06e37fc07c4abda5634
parent 7207e26c33055c82c0a7edce5d7ace7af6da790e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 21 Sep 2026 03:03:40 -0400
storage/ops: four things the first e2e run found
- **The memo made the page lie.** `measureTreeCached` was TTL-only, and a spec
that seeded a container into the saved-video store then read "0 B in 0
file(s)" off a walk taken before the seed. It is keyed by the directory's own
mtime now, with the sixty seconds kept as a ceiling — the entry has to match
on both. Adding or removing an entry bumps that directory's mtime, so a
container landing, a prune, a move and an e2e reset all invalidate on the next
render; a write deeper in the tree is what the ceiling is still there for. One
extra `stat` per render against a recursive walk.
- **`refresh-report --all` had the `--wait` bug too.** It is a fan-out that
starts one job per channel and its response carried no ids, so an HTTP caller
could not tell it from an action that started nothing. `RefreshAllResult`
gains `jobIds`, the same shape and for the same reason as `QueueOutcome`'s.
- **The move-refusal spec typed a root the panel does not offer.** With a
location configured the Storage panel shows the select and hides the free-text
box, so the test now picks the location BY NAME — which is the shape an
operator meets and the one that carries the location id into the refusal.
- **The full-source spec needed transcribe handling.** `finalizeAppExtraction`
— the step that moves `source-media.*` into the store and writes the pointer —
runs only in app-extraction mode. The fixture channel is subtitles-first
`youtube`, so the container stayed in the video dir and there was no pointer
to read. Not a bug in the fetch: the persistence plan doing what it says.
And one strict-mode violation: `getByLabel("clip windows")` also matched "clip
windows bytes", which reads as a missing element rather than as two.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
5 files changed, 57 insertions(+), 11 deletions(-)
diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts
@@ -375,6 +375,15 @@ export async function refreshChannelSnapshotAction(
export type RefreshAllResult = {
queued: string[];
skipped: { slug: string; reason: string }[];
+ // The ids of the jobs started, parallel to `queued`. Same reason
+ // `QueueOutcome` grew one: without it an HTTP caller could not tell a
+ // fan-out that started work from an action that started none, so
+ // `pnpm ops refresh-report --json '{"all":true}' --wait` returned the moment
+ // the response arrived. (This action also AWAITS its streams, so by the time
+ // it answers the work is done — but the ids are what make the response
+ // honest about what it started, and identical in shape to every other
+ // fan-out's.)
+ jobIds: string[];
};
export async function refreshAllChannelSnapshotsAction(): Promise<RefreshAllResult> {
@@ -392,6 +401,7 @@ export async function refreshAllChannelSnapshotsAction(): Promise<RefreshAllResu
.map((j) => j.channelSlug as string),
);
const queued: string[] = [];
+ const jobIds: string[] = [];
const skipped: { slug: string; reason: string }[] = [];
const streams: ReadableStream<string>[] = [];
for (const c of channels) {
@@ -433,6 +443,7 @@ export async function refreshAllChannelSnapshotsAction(): Promise<RefreshAllResu
continue;
}
queued.push(c.slug);
+ jobIds.push(result.jobId);
streams.push(result.stream);
}
// Wait for all snapshots to finish writing before revalidating so the
@@ -444,7 +455,7 @@ export async function refreshAllChannelSnapshotsAction(): Promise<RefreshAllResu
revalidatePath("/operations/[id]", "page");
revalidatePath("/cleanup");
revalidatePath("/");
- return { queued, skipped };
+ return { queued, jobIds, skipped };
}
export async function toggleChannelBuildInclusionAction(
diff --git a/editor/app/storage/lib/measureStore.ts b/editor/app/storage/lib/measureStore.ts
@@ -1,3 +1,4 @@
+import { stat } from "node:fs/promises";
import { measureTree } from "yt-dlp-transcript-common/controller/relocateDir";
// THE SAVED-VIDEO STORE'S SIZE, MEASURED AT MOST ONCE EVERY 60 SECONDS.
@@ -15,11 +16,22 @@ import { measureTree } from "yt-dlp-transcript-common/controller/relocateDir";
// it. A page that walks a growing directory on every refresh gets slower in
// exactly the situation the operator opened it to understand.
//
-// SIXTY SECONDS, keyed by directory. Short enough that a move or a prune shows
-// up on the next reload the operator was going to do anyway; long enough that
-// an auto-refresh cycle costs one walk rather than one per tick. The store's
-// SIZE is not a safety number — the location, the status and the marker are,
-// and all three are read fresh every render.
+// KEYED BY THE DIRECTORY'S OWN MTIME, and capped at sixty seconds. The TTL
+// alone was wrong and an e2e caught it within the hour: a spec seeded a
+// container into the store and the page went on reporting "0 B in 0 file(s)"
+// from a walk taken before the seed. A cache that makes a page lie about a
+// number the operator just changed is worse than the walk it saves.
+//
+// Adding or removing an entry in a directory bumps THAT directory's mtime, so
+// the common cases — the first container landing, a prune, a move that replaces
+// the store with a symlink, an e2e reset — all invalidate on the next render.
+// A write deeper in the tree (a container added inside an existing
+// `<slug>/<video>/`) does not, which is what the sixty-second ceiling is still
+// there for: the entry has to match on BOTH.
+//
+// One extra `stat` per render against a recursive walk is the trade. The
+// store's SIZE is the only thing cached; its location, status and marker are
+// read fresh every render, because those are the safety facts.
//
// `maxEntries` caps the map because the key is a path and a re-point changes
// it: without a cap a long-lived process that re-pointed repeatedly would keep
@@ -36,7 +48,7 @@ export type MeasuredTree = { bytes: number; files: number };
const TTL_MS = 60_000;
const MAX_ENTRIES = 32;
-type Entry = { at: number; value: MeasuredTree };
+type Entry = { at: number; mtimeMs: number; value: MeasuredTree };
const cache = new Map<string, Entry>();
// Test seam, and the escape hatch for a process that has just moved the store.
@@ -49,16 +61,25 @@ export async function measureTreeCached(
opts: { now?: number; refresh?: boolean } = {},
): Promise<MeasuredTree> {
const now = opts.now ?? Date.now();
+ // -1 when the directory is gone or unreadable: a value that can never equal a
+ // real mtime, so a vanished store is always re-walked rather than answered
+ // from a stale entry.
+ const mtimeMs = await stat(dir).then(
+ (st) => st.mtimeMs,
+ () => -1,
+ );
if (!opts.refresh) {
const hit = cache.get(dir);
- if (hit && now - hit.at < TTL_MS) return hit.value;
+ if (hit && hit.mtimeMs === mtimeMs && now - hit.at < TTL_MS) {
+ return hit.value;
+ }
}
const value = await measureTree(dir);
// Delete before set so a refreshed key moves to the END of the insertion
// order; otherwise the entry the page uses every minute would be the first
// one evicted.
cache.delete(dir);
- cache.set(dir, { at: now, value });
+ cache.set(dir, { at: now, mtimeMs, value });
while (cache.size > MAX_ENTRIES) {
const oldest = cache.keys().next();
if (oldest.done) break;
diff --git a/editor/e2e/channel-storage.spec.ts b/editor/e2e/channel-storage.spec.ts
@@ -651,7 +651,11 @@ test("a move to an unmounted root refuses before it creates anything", async ({
});
await page.goto(channelStage(SLUG, "storage"));
- await page.getByLabel("destination root").fill(root);
+ // BY NAME, not as a typed root: with a location configured the panel offers
+ // the select and hides the free-text box (`custom` is false), which is the
+ // shape an operator actually meets — and it is the shape that carries the
+ // location id into the refusal.
+ await page.getByLabel("destination location").selectOption("cold");
await page.getByRole("button", { name: "Preview" }).click();
const alert = page.getByRole("alert");
await expect(alert).toContainText(/does not exist or is not a directory/, {
diff --git a/editor/e2e/fetch-window.spec.ts b/editor/e2e/fetch-window.spec.ts
@@ -336,6 +336,14 @@ test("a full-source fetch lands in the saved-video store and is cached on a seco
request,
}) => {
test.setTimeout(90_000);
+ // TRANSCRIBE HANDLING, because that is what makes the container persist.
+ // `finalizeAppExtraction` — the step that moves `source-media.*` into the
+ // saved-video store and writes the pointer — runs only in app-extraction
+ // mode, which is `handling: "transcribe"`. The fixture channel is a
+ // subtitles-first `youtube` channel, so a full-source fetch there leaves the
+ // container in the video dir and never reaches the store. That is not a bug
+ // in the fetch; it is the persistence plan doing what it says.
+ await writeChannelConfig(SLUG, { handling: "transcribe" });
const ask = () =>
request.post(`${baseUrl}/api/media/fetch-window`, {
headers: AUTH,
diff --git a/editor/e2e/storage-locations.spec.ts b/editor/e2e/storage-locations.spec.ts
@@ -594,7 +594,9 @@ test("Evict fetched windows removes an old one and leaves a recent one", async (
await writeSettings({ adminTitle: "Test Admin", minFreeDiskGB: 0 });
await page.goto("/storage");
- const card = page.getByLabel("clip windows");
+ // EXACT: "clip windows bytes" is a substring match away, and a strict-mode
+ // violation reads as a missing element rather than as two.
+ const card = page.getByLabel("clip windows", { exact: true });
await expect(card).toBeVisible();
// THE LIMITATION IS ON THE PAGE, not only in a comment. An operator about to
// delete a cache they cannot reference-count is entitled to read why.