Compare commits

..
2 Commits
Author SHA1 Message Date
clawbot 3412e42af5 quak backup works on up to the content pool's limit of originals at once (closes #182)
check / check (push) Waiting to run
The backup awaited each original before starting the next, so it used
one of the content pool's five slots. lib.backup(), and so quak backup,
now runs as many workers as the content pool's limit (5 by default, or
the contentConcurrency of the pools the library was opened with), each
taking the next file from one shared list, so the limit covers the
--verify hashing too. Once a worker has thrown or the lock is lost, no
worker takes another file; the backup waits for the files under way and
checks its lock before the ML data fetch. runBackup rejects a
contentConcurrency that is not a positive integer before it takes the
lock. Failed files are listed in the order they failed.

Model: opus-5-5
2026-10-14 09:06:59 +02:00
clawbot 22ee2db479 TODO.md's Next Step names the open issues, not a closed one (closes #194)
check / check (push) Waiting to run
Next Step said the cache design, issue 36, waits on sneak's review. That
issue closed on 2026-10-03. Next Step now names what is open: the backup
concurrency issue, built in pull request 192 and waiting on review, and
the live-account check that only sneak can run.

Model: opus-5-5
2026-10-14 08:07:48 +02:00
4 changed files with 63 additions and 34 deletions
+11 -4
View File
@@ -17,8 +17,9 @@ pre-1.0
# Next Step
None: no implementation work is open. The cache design,
https://git.eeqj.de/sneak/quak/issues/36, waits on sneak's review.
None: no implementation work is open. The check of quak against a live Ente
account, https://git.eeqj.de/sneak/quak/issues/187, needs an account only sneak
has, so it waits on him.
Tagging and releases are decided by sneak alone, and happen only when he
declares one.
@@ -31,8 +32,14 @@ declares one.
the work for one original, its `--verify` hashing included, so no more
originals than that are open at once. `BackupLibrary` says the limit as
`contentConcurrency`. A test holds each download at the fake Ente server to
count those in flight. Each worker checks the backup's lock before it takes
the next file, so once the lock is lost no further original starts.
count those in flight. Once a worker has thrown, as one does when `onProgress`
throws, or the lock is lost, no worker takes another file; the backup fails
once the originals under way are in place, and a lost lock stops it before it
fetches ML data.
- 2026-10-14: Next Step said the cache design, issue 36, waits on sneak's
review, though that issue closed on 2026-10-03 (issue 194). It now names the
issues open on the tracker.
- 2026-10-14: A backup whose lock is lost no longer dies with a stack trace
(issue 184). `lockBackupDirectory` gives proper-lockfile an `onCompromised`
+15 -6
View File
@@ -730,16 +730,24 @@ const runLockedBackup = async (
// Up to the content pool's limit of originals are worked on at once,
// their `verify` hashing included, so however large the account is, no
// more than that many are open. Each worker takes the next file from the
// iterator they all share until none is left, and throws instead once the
// lock is lost. A worker that throws, as one also does when `onProgress`
// throws, fails the backup only once every worker has stopped, so none
// writes into the backup after it has ended.
// iterator they all share until none is left. Once a worker has thrown,
// as one does when `onProgress` throws, or the lock is lost, no worker
// takes another file. The backup fails only once every worker has
// stopped, so none writes into the backup after it has ended. It checks
// the lock then too, so it does not go on to the thumbnails or the ML
// data fetch without it.
if (includeOriginals) {
const pending = distinct.values();
let workerThrew = false;
const worker = async (): Promise<void> => {
for (const file of pending) {
if (lock.lost) throw lock.lost;
await storeOriginal(file);
if (workerThrew || lock.lost) return;
try {
await storeOriginal(file);
} catch (err) {
workerThrew = true;
throw err;
}
}
};
const workers = await Promise.allSettled(
@@ -748,6 +756,7 @@ const runLockedBackup = async (
for (const w of workers) {
if (w.status === "rejected") throw w.reason;
}
if (lock.lost) throw lock.lost;
}
if (includeThumbnails) {
+32 -19
View File
@@ -397,19 +397,33 @@ describe("lib.backup()", () => {
});
// A throw from `onProgress` fails the backup: `lib.backup()` rejects with
// it. It rejects only once it has finished with every original it was
// working on, so nothing is written into the backup after it has ended
// and removed its lock. Here, with a content pool of eight, all eight
// downloads wait at once. Photo 103's is let go first and answered 404,
// and the line reporting that failure throws while the other seven wait.
it("when onProgress throws, rejects only once the originals it was working on are in place", async () => {
// it. From then on the backup starts no further original. It finishes the
// ones it is working on, and rejects only once they are in place, so
// nothing is written into the backup after it has ended and removed its
// lock. Here the first five downloads wait. Photo 103's is let go first
// and answered 404, and the line reporting that failure throws while the
// other four wait. Only those five downloads are held: a backup that went
// on would store the other three photos.
it("when onProgress throws, starts no further original, and rejects once the originals it was working on are in place", async () => {
const dir = mkdtempSync(join(root, "backup-"));
const holding = holdingServer(103);
const lib = await openLibrary(
dir,
holding.fetch,
new RequestPools({ contentConcurrency: 8 }),
);
const downloads: number[] = [];
const held: { id: number; release: () => void }[] = [];
const fetch: typeof globalThis.fetch = async (input, init) => {
const id = downloadOf(input);
if (id !== undefined) {
downloads.push(id);
if (downloads.length <= 5) {
await new Promise<void>((release) =>
held.push({ id, release }),
);
}
if (id === 103) {
return new Response("not found", { status: 404 });
}
}
return server(input, init);
};
const lib = await openLibrary(dir, fetch);
const lines: string[] = [];
try {
const backup = lib.backup({
@@ -432,12 +446,12 @@ describe("lib.backup()", () => {
return err;
});
await vi.waitFor(() => expect(holding.held).toHaveLength(8), {
await vi.waitFor(() => expect(held).toHaveLength(5), {
interval: 5,
timeout: 5000,
});
const at103 = holding.held.findIndex((h) => h.id === 103);
holding.held.splice(at103, 1)[0]!.release();
const at103 = held.findIndex((h) => h.id === 103);
held.splice(at103, 1)[0]!.release();
await vi.waitFor(
() =>
expect(
@@ -445,14 +459,13 @@ describe("lib.backup()", () => {
).toBe(true),
{ interval: 5, timeout: 5000 },
);
for (const download of holding.held) download.release();
for (const download of held) download.release();
expect(await rejection).toEqual(
new Error("the progress output is closed"),
);
expect(storedWhenRejected).toEqual(
PHOTO_IDS.filter((id) => id !== 103),
);
expect(downloads.sort()).toEqual([101, 102, 103, 104, 105]);
expect(storedWhenRejected).toEqual([101, 102, 104, 105]);
expect(lockedWhenRejected).toBe(false);
} finally {
await lib.close();
+5 -5
View File
@@ -704,10 +704,11 @@ describe("a backup whose lock is lost", () => {
}
};
// The backup downloads up to five originals at once, so all three are
// under way when the lock is lost, and beach.jpg is stored. `quak backup`
// prints why as one line, with no stack trace, and exits 1.
it("stops quak backup before its next file when backup.lock is removed, and exits 1", async () => {
// The backup downloads up to five originals at once, so it has started
// all three when the lock is lost, and beach.jpg is stored. The backup
// then stops before it fetches ML data. `quak backup` prints why as one
// line, with no stack trace, and exits 1.
it("stops quak backup before it fetches ML data when backup.lock is removed, and exits 1", async () => {
const result = backup(dir, {}, fetch);
await heldRequested;
rmSync(lockPath, { recursive: true });
@@ -722,7 +723,6 @@ describe("a backup whose lock is lost", () => {
"Fetching original beach.jpg (100)...\n" +
"Fetching original IMG_0002.HEIC (300)...\n" +
"Fetching original toast.mp4 (200)...\n" +
"Fetching ML data...\n" +
`quak: lost the lock on ${dir}: ENOENT: no such file or directory, stat '${lockPath}'\n`,
);
expect(downloads.sort()).toEqual([100, 200, 300]);