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