Sync says what it is doing, file by file
The counters said how MUCH and never which file, so a run that was working normally and one that had wedged produced the same screen: `done` stops moving either way. A photograph is around thirty encodes and a video is three plus a poster ladder, and none of that was visible from outside. So the run keeps a transcript and the button opens it. Every rung reports itself by CONSTRUCTION. Ten `variants.push` call sites became one `add`, which is the point — a rung added later is described without anybody remembering to describe it. The slow stages that produce nothing until they finish say so BEFORE they start instead: the HEIC decode, each video rung, the poster frame. Those are the pauses that read as a hang. `?since=<n>` is what keeps it cheap. The transcript is thousands of lines and polls once a second, so sending all of it every tick would be the same megabyte over and over; the client keeps what it has and asks for the rest. No `since` means everything, which is what a modal opened mid-run wants. The transcript lives in the shared slot, not the modal, so closing it does not throw away the run — somebody who looks, closes, and looks again is watching one run rather than two. `startedAt` is what identifies a run, because `n` restarts at 1 and appending across a boundary would interleave two. Polling, not a socket. Each line transfers once and the events are seconds apart, so a second transport to open, authenticate and reconnect would buy nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+116
-5
@@ -76,8 +76,45 @@ export type SyncState = {
|
||||
*/
|
||||
scope: string;
|
||||
trigger: RunTrigger;
|
||||
/**
|
||||
* A running transcript — what happened, to which file, when.
|
||||
*
|
||||
* The counters say how MUCH; this says what. They answer different questions and a run that stalls
|
||||
* on one photograph is invisible in the counters: `done` simply stops moving, and there is nothing
|
||||
* to tell "av-club is chewing on a 40-megapixel HEIC" apart from "the process is wedged".
|
||||
*
|
||||
* Kept on the state rather than written to a file, because the run already lives in memory and a log
|
||||
* file would be a second thing to find, rotate and clean up for output whose whole value is that
|
||||
* somebody is watching it right now. The ledger is what survives; this is what is happening.
|
||||
*/
|
||||
log: LogLine[];
|
||||
};
|
||||
|
||||
/** One line of the transcript. */
|
||||
export type LogLine = {
|
||||
/**
|
||||
* Monotonic, and the reason the client can poll cheaply.
|
||||
*
|
||||
* A watcher asks for everything after the last number it holds, so a modal open for a long run
|
||||
* transfers each line once instead of re-sending the whole transcript every second.
|
||||
*/
|
||||
n: number;
|
||||
at: string;
|
||||
level: 'info' | 'step' | 'done' | 'warn' | 'error';
|
||||
/** The file this is about, when it is about one. Blank for the run's own lines. */
|
||||
file: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* How many lines to keep.
|
||||
*
|
||||
* A photograph produces about thirty of them, so this is a few hundred files of detail. Past it the
|
||||
* OLDEST go: the interesting end of a long run is the end, and a transcript that stopped recording at
|
||||
* the top would drop the failure somebody opened it to read.
|
||||
*/
|
||||
const LOG_LIMIT = 4000;
|
||||
|
||||
/** One. See the note above — this is a memory limit, not a politeness. */
|
||||
const CONCURRENCY = 1;
|
||||
|
||||
@@ -96,6 +133,7 @@ const idle = (): SyncState => ({
|
||||
fatal: null,
|
||||
scope: '',
|
||||
trigger: 'manual',
|
||||
log: [],
|
||||
});
|
||||
|
||||
const states = new Map<string, SyncState>();
|
||||
@@ -131,14 +169,39 @@ export function startSync(
|
||||
return { started: true, state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a line, and keep the buffer bounded.
|
||||
*
|
||||
* `seq` is per-run and starts where the last one left off only in the sense that a new run resets it —
|
||||
* a watcher that reconnects across runs sees the numbers go backwards, which is why the client drops
|
||||
* what it holds when `startedAt` changes rather than trusting the numbers alone.
|
||||
*/
|
||||
const say = (state: SyncState, level: LogLine['level'], file: string, text: string): void => {
|
||||
const n = (state.log.at(-1)?.n ?? 0) + 1;
|
||||
state.log.push({ n, at: new Date().toISOString(), level, file, text });
|
||||
// Oldest first. See LOG_LIMIT — the end of a long run is the part worth keeping.
|
||||
if (state.log.length > LOG_LIMIT) state.log.splice(0, state.log.length - LOG_LIMIT);
|
||||
};
|
||||
|
||||
/** `1.4s`, `320ms`. Short enough to sit at the end of a line without becoming the line. */
|
||||
const since = (from: number): string => {
|
||||
const ms = Date.now() - from;
|
||||
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
|
||||
};
|
||||
|
||||
async function run(email: string, state: SyncState): Promise<void> {
|
||||
const began = Date.now();
|
||||
try {
|
||||
const from = await originalsDir(email);
|
||||
const to = await derivedDir(email);
|
||||
|
||||
say(state, 'info', '', `Reading ${state.scope || 'originals/'}`);
|
||||
|
||||
const files = await listOriginals(from, state.scope);
|
||||
state.total = files.length;
|
||||
|
||||
say(state, 'info', '', `${files.length} file${files.length === 1 ? '' : 's'} to check`);
|
||||
|
||||
/*
|
||||
What is already built, indexed by what it was built FROM.
|
||||
|
||||
@@ -149,6 +212,10 @@ async function run(email: string, state: SyncState): Promise<void> {
|
||||
*/
|
||||
const derivedIndex = await indexDerived(to);
|
||||
|
||||
// Worth a line: on a big library this is the pause before anything appears to happen, and without
|
||||
// it the transcript opens with a gap somebody reads as a hang.
|
||||
say(state, 'info', '', `Indexed ${derivedIndex.size} existing ladder${derivedIndex.size === 1 ? '' : 's'}`);
|
||||
|
||||
let next = 0;
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
@@ -157,6 +224,8 @@ async function run(email: string, state: SyncState): Promise<void> {
|
||||
if (file === undefined) return;
|
||||
|
||||
state.current.push(file);
|
||||
const startedFile = Date.now();
|
||||
say(state, 'step', file, 'checking');
|
||||
try {
|
||||
const target = targetFor(to, file);
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
@@ -175,10 +244,28 @@ async function run(email: string, state: SyncState): Promise<void> {
|
||||
? await relocateDerived(derivedIndex, to, file, { bytes: info.size, mtimeMs: Math.round(info.mtimeMs) })
|
||||
: false;
|
||||
|
||||
const { built } = await deriveOne(`${from}/${file}`, target, email);
|
||||
if (relocated && !built) state.moved++;
|
||||
else if (built) state.built++;
|
||||
else state.skipped++;
|
||||
if (relocated) say(state, 'info', file, 'moved — renaming its ladder instead of rebuilding');
|
||||
|
||||
/*
|
||||
Every rung, as it lands. This is the "what is it doing right now" the counters cannot say:
|
||||
one photograph is thirty encodes and a video is three of them plus a poster ladder, and a
|
||||
run that looks stopped is usually most of the way through one of those.
|
||||
*/
|
||||
const { built } = await deriveOne(`${from}/${file}`, target, email, {
|
||||
onStep: (message) => say(state, 'step', file, message),
|
||||
});
|
||||
|
||||
if (relocated && !built) {
|
||||
state.moved++;
|
||||
say(state, 'done', file, `moved · ${since(startedFile)}`);
|
||||
} else if (built) {
|
||||
state.built++;
|
||||
say(state, 'done', file, `built · ${since(startedFile)}`);
|
||||
} else {
|
||||
state.skipped++;
|
||||
// `current` and not `skipped`: nothing was skipped over, the ladder was already right.
|
||||
say(state, 'done', file, `already current · ${since(startedFile)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
/*
|
||||
@@ -188,9 +275,11 @@ async function run(email: string, state: SyncState): Promise<void> {
|
||||
*/
|
||||
if (message.includes('av-club is not running')) {
|
||||
state.fatal = message;
|
||||
say(state, 'error', '', message);
|
||||
return;
|
||||
}
|
||||
state.errors.push({ file, message });
|
||||
say(state, 'error', file, message);
|
||||
} finally {
|
||||
state.current = state.current.filter((name) => name !== file);
|
||||
state.done++;
|
||||
@@ -211,15 +300,37 @@ async function run(email: string, state: SyncState): Promise<void> {
|
||||
what the name has meant since these two folders stopped being called `user` and `treated`.
|
||||
*/
|
||||
if (!state.scope && !state.fatal) {
|
||||
state.pruned = (await pruneDerived(from, to)).length;
|
||||
const orphans = await pruneDerived(from, to);
|
||||
state.pruned = orphans.length;
|
||||
// Named, not counted. A removal nobody can see the target of is the one thing here somebody
|
||||
// might want to argue with afterwards.
|
||||
for (const orphan of orphans) say(state, 'warn', orphan, 'removed — its original is gone');
|
||||
}
|
||||
} catch (error) {
|
||||
state.fatal = (error as Error).message;
|
||||
say(state, 'error', '', (error as Error).message);
|
||||
} finally {
|
||||
state.running = false;
|
||||
state.current = [];
|
||||
state.finishedAt = new Date().toISOString();
|
||||
|
||||
// The last line, and the one still on screen when the modal stops moving.
|
||||
say(
|
||||
state,
|
||||
state.fatal || state.errors.length ? 'warn' : 'done',
|
||||
'',
|
||||
[
|
||||
`${state.built} built`,
|
||||
state.skipped ? `${state.skipped} already current` : '',
|
||||
state.moved ? `${state.moved} moved` : '',
|
||||
state.pruned ? `${state.pruned} removed` : '',
|
||||
state.errors.length ? `${state.errors.length} failed` : '',
|
||||
`in ${since(began)}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
);
|
||||
|
||||
/*
|
||||
The one durable record this leaves.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user