From 36d7cdd5db0b56a1b81e3d404295f4808e66bc45 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 14 Oct 2026 03:06:43 +0000 Subject: [PATCH 01/10] Test: lib.backup() works on up to the content pool's limit of originals at once Against the fake Ente server, with each download held until the test lets it go: a library with the default pools has five downloads in flight and a pool of two has two; with verify, only the originals whose downloads are in flight have been hashed; a 404 for one photo does not stop the rest; and two runs end the same with a pool of one as with the default pools. Today the backup downloads one original at a time, so the first four fail. Model: opus-5-5 --- test/cli/backup-concurrency.test.ts | 465 ++++++++++++++++++++++++++++ 1 file changed, 465 insertions(+) create mode 100644 test/cli/backup-concurrency.test.ts diff --git a/test/cli/backup-concurrency.test.ts b/test/cli/backup-concurrency.test.ts new file mode 100644 index 0000000..fe8caa4 --- /dev/null +++ b/test/cli/backup-concurrency.test.ts @@ -0,0 +1,465 @@ +/** + * How many originals `lib.backup()` works on at once. + * + * The backup puts up to the content pool's limit of originals at their save + * paths at the same time: 5 for a library opened with the default + * `RequestPools`, as `quak backup` opens it, or the `contentConcurrency` of the + * pools a library was opened with. Everything the backup does for one original + * counts against that limit, hashing it for `verify` included, so however large + * the account is, the backup has no more than that many originals open at once. + * + * As in `backup-end-to-end.test.ts`, the real `Client` and `Library` run + * against `buildMetaFetch` from `test/ente-server.ts`, a fake of the Ente HTTP + * API serving data this file encrypts with libsodium. Here a test can also hold + * each original's download until it lets it go, to see how many the backup has + * in flight at once. + * + * The account, user 42, has one album of its own, Holiday, of eight photos, + * all taken at noon on 1 March 2024, each with an original of its own. + */ + +import { + existsSync, + lstatSync, + mkdtempSync, + readdirSync, + readFileSync, + readlinkSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import sodium from "libsodium-wrappers-sumo"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import type { BackupOptions } from "../../src/backup.js"; +import { Client, type ClientSnapshot } from "../../src/client.js"; +import { init, toBase64 } from "../../src/crypto/index.js"; +import { Library } from "../../src/library/index.js"; +import { RequestPools } from "../../src/library/pools.js"; +import { + buildAccount, + buildMetaFetch, + encryptSecretbox, + encryptStreamBlob, +} from "../ente-server.js"; +import { blake2b } from "../live-photo.js"; + +const EMAIL = "backup@example.com"; +const PASSWORD = "backup-password"; +// The fake server logs every account in as user 42. +const USER_ID = 42; + +// Noon on 1 March 2024 in the time zone of the machine running the test, in +// microseconds, as Ente records times. +const TAKEN = new Date(2024, 2, 1, 12).getTime() * 1000; + +// The eight photos' IDs. Photo `id` is titled `photo-.jpg`, and its +// original is a few bytes naming it. +const PHOTO_IDS = [101, 102, 103, 104, 105, 106, 107, 108]; +const originalOf = (id: number): Uint8Array => + new TextEncoder().encode(`the photo ${id}`); + +// Where a backup to `dir` saves photo `id`'s original. +const savedAt = (dir: string, id: number): string => + join(dir, "2024/2024-03/2024-03-01", `2024-03-01.${id}.jpg`); + +// The fake server, the session it logs the account in with, and the +// directory every backup below is made in. +let server: typeof globalThis.fetch; +let session: ClientSnapshot; +let root: string; + +// The fake server, holding the account encrypted as Ente's clients upload it. +const buildServer = async () => { + const account = await buildAccount(PASSWORD); + + // The album as `/collections/v2` lists it: its key encrypted with the + // account's master key, its name with its own key. + const albumKey = sodium.crypto_secretbox_keygen(); + const encryptedAlbumKey = encryptSecretbox(albumKey, account.masterKey); + const encryptedName = encryptSecretbox( + new TextEncoder().encode("Holiday"), + albumKey, + ); + const album = { + id: 10, + owner: { id: USER_ID }, + encryptedKey: toBase64(encryptedAlbumKey.ciphertext), + keyDecryptionNonce: toBase64(encryptedAlbumKey.nonce), + encryptedName: toBase64(encryptedName.ciphertext), + nameDecryptionNonce: toBase64(encryptedName.nonce), + type: "album", + updationTime: TAKEN, + }; + + // Each photo as the album's `/collections/v2/diff` lists it: its key + // encrypted with the album's key, and its metadata, holding the hash of + // its original, encrypted with its own key. Its original, encrypted with + // its own key too, is what the files CDN serves for it. + const ciphertexts: Record = {}; + const photos = PHOTO_IDS.map((id) => { + const key = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const original = encryptStreamBlob(originalOf(id), key); + ciphertexts[id] = original.ciphertext; + const encryptedKey = encryptSecretbox(key, albumKey); + const metadata = encryptStreamBlob( + new TextEncoder().encode( + JSON.stringify({ + title: `photo-${id}.jpg`, + fileType: 0, + hash: blake2b(originalOf(id)), + creationTime: TAKEN, + modificationTime: TAKEN, + }), + ), + key, + ); + return { + id, + collectionID: 10, + ownerID: USER_ID, + encryptedKey: toBase64(encryptedKey.ciphertext), + keyDecryptionNonce: toBase64(encryptedKey.nonce), + metadata: { + encryptedData: toBase64(metadata.ciphertext), + decryptionHeader: toBase64(metadata.header), + }, + file: { decryptionHeader: toBase64(original.header) }, + thumbnail: { + decryptionHeader: toBase64(sodium.randombytes_buf(24)), + }, + updationTime: TAKEN, + }; + }); + + return buildMetaFetch({ + verifier: account.verifier, + srpAttributes: account.srpAttributes, + keyAttributes: account.keyAttributes, + encryptedToken: account.encryptedToken, + collections: [album], + filesByCollection: { 10: photos }, + encryptedMLData: {}, + fileCiphertexts: ciphertexts, + }); +}; + +beforeAll(async () => { + await init(); + await sodium.ready; + root = mkdtempSync(join(tmpdir(), "quak-backup-concurrency-")); + server = await buildServer(); + const client = await Client.login({ + email: EMAIL, + password: PASSWORD, + apiOptions: { fetch: server }, + }); + session = client.toJSON(); +}); + +afterAll(() => { + rmSync(root, { recursive: true, force: true }); +}); + +// The ID of the photo whose original a request downloads, or undefined for any +// other request. quak downloads an original from +// `https://files.ente.io/?fileID=`. +const downloadOf = (input: RequestInfo | URL): number | undefined => { + const url = new URL(String(input)); + return url.hostname === "files.ente.io" + ? Number(url.searchParams.get("fileID")) + : undefined; +}; + +// The fake server, except that it answers 404 to every download of photo +// `failing`'s original. +const failingFor = + (failing: number): typeof globalThis.fetch => + async (input, init) => + downloadOf(input) === failing + ? new Response("not found", { status: 404 }) + : server(input, init); + +// The fake server, holding each download of an original until the test lets +// it go. `held` lists the downloads waiting, the first to arrive first; +// `most()` is the largest number that ever waited at once. A held download of +// photo `failing`'s original is answered 404 once it is let go. +const holdingServer = (failing?: number) => { + const held: { id: number; release: () => void }[] = []; + let most = 0; + const fetch: typeof globalThis.fetch = async (input, init) => { + const id = downloadOf(input); + if (id !== undefined) { + await new Promise((release) => { + held.push({ id, release }); + most = Math.max(most, held.length); + }); + if (id === failing) { + return new Response("not found", { status: 404 }); + } + } + return server(input, init); + }; + return { fetch, held, most: () => most }; +}; + +// Let the held downloads go one at a time, the first to arrive first, each +// once `limit` of them wait, or all the downloads still to come when there +// are fewer. Each of the eight photos is downloaded once. With a backup that +// downloads fewer than `limit` at once, this fails waiting for them. +const releaseOneAtATime = async ( + held: { release: () => void }[], + limit: number, +): Promise => { + for (let left = PHOTO_IDS.length; left > 0; left--) { + await vi.waitFor( + () => expect(held).toHaveLength(Math.min(limit, left)), + { interval: 5 }, + ); + held.shift()!.release(); + } +}; + +// A library of the account opened as `quak backup` opens one, with no +// precache and no background refresh, sending its requests through `fetch`. +// It backs up to `dir` and keeps its cache beside it. Its request pools are +// `pools`, or by default the library's own. +const openLibrary = ( + dir: string, + fetch: typeof globalThis.fetch, + pools?: RequestPools, +): Promise => + Library.open({ + client: Client.fromJSON(session, { fetch }), + downloadDirectory: dir, + cacheDirectory: `${dir}-cache`, + refreshIntervalSeconds: Infinity, + precacheThumbnails: false, + precacheOriginals: false, + pools, + }); + +// Every entry under `dir`, relative to it, sorted, a symlink as +// `name -> target`. +const listTree = (dir: string): string[] => + readdirSync(dir, { recursive: true }) + .sort() + .map((name) => { + const path = join(dir, name); + return lstatSync(path).isSymbolicLink() + ? `${name} -> ${readlinkSync(path)}` + : name; + }); + +const expectStored = (dir: string, ids: number[]): void => { + for (const id of ids) { + expect(readFileSync(savedAt(dir, id))).toEqual( + Buffer.from(originalOf(id)), + ); + } +}; + +describe("lib.backup()", () => { + // A library opened with the default pools, as `quak backup` opens one, + // downloads up to five originals at once. As soon as one finishes, the + // backup starts on the next photo, so five stay in flight until fewer + // than five are left. + it("downloads five originals at once, and never more, with the default pools", async () => { + const dir = mkdtempSync(join(root, "backup-")); + const holding = holdingServer(); + const lib = await openLibrary(dir, holding.fetch); + try { + const backup = lib.backup(); + await releaseOneAtATime(holding.held, 5); + const result = await backup; + + expect(holding.most()).toBe(5); + expect(result).toMatchObject({ + totalFiles: 8, + downloaded: 8, + failed: 0, + }); + expectStored(dir, PHOTO_IDS); + } finally { + await lib.close(); + } + }); + + // The limit is the content pool's, so a library opened with pools of + // another `contentConcurrency` backs up that many at once. + it("downloads as many originals at once as the content pool of the pools it was opened with allows", async () => { + const dir = mkdtempSync(join(root, "backup-")); + const holding = holdingServer(); + const lib = await openLibrary( + dir, + holding.fetch, + new RequestPools({ contentConcurrency: 2 }), + ); + try { + const backup = lib.backup(); + await releaseOneAtATime(holding.held, 2); + const result = await backup; + + expect(holding.most()).toBe(2); + expect(result).toMatchObject({ downloaded: 8, failed: 0 }); + expectStored(dir, PHOTO_IDS); + } finally { + await lib.close(); + } + }); + + // With `verify`, hashing an original already stored is part of the work + // on it, and counts against the limit too: a large backup never has more + // than five originals open for hashing. Here every stored original has + // changed on disk, so each is hashed, found not to match, and downloaded + // again. While five downloads wait, the backup has hashed only those five + // photos' originals; it opens the next one once a download finishes. + it("with verify, hashes no more originals ahead of their downloads than the limit", async () => { + const dir = mkdtempSync(join(root, "backup-")); + const first = await openLibrary(dir, server); + try { + await first.backup(); + } finally { + await first.close(); + } + for (const id of PHOTO_IDS) { + writeFileSync(savedAt(dir, id), "not the photo any more"); + } + + const holding = holdingServer(); + const lib = await openLibrary(dir, holding.fetch); + const lines: string[] = []; + const mismatches = () => + lines.filter((line) => line.startsWith("MISMATCH")); + try { + const backup = lib.backup({ + verify: true, + onProgress: (line) => lines.push(line), + }); + await vi.waitFor(() => expect(holding.held).toHaveLength(5), { + interval: 5, + }); + expect(mismatches()).toHaveLength(5); + + await releaseOneAtATime(holding.held, 5); + const result = await backup; + + expect(holding.most()).toBe(5); + expect(mismatches()).toHaveLength(8); + expect(result).toMatchObject({ + downloaded: 8, + verified: 0, + mismatched: 8, + failed: 0, + }); + expectStored(dir, PHOTO_IDS); + } finally { + await lib.close(); + } + }); + + // The server answers 404 for photo 103's original. A 404 is not retried, + // so that download fails at once, while other downloads are still in + // flight. The backup records the failure and goes on: the downloads + // beside it finish, the photos after it are downloaded, and only photo + // 103 is missing from the backup and in failures.json. + it("goes on with the other originals when one fails", async () => { + const dir = mkdtempSync(join(root, "backup-")); + const holding = holdingServer(103); + const lib = await openLibrary(dir, holding.fetch); + try { + const backup = lib.backup(); + await releaseOneAtATime(holding.held, 5); + const result = await backup; + + expect(result).toMatchObject({ + totalFiles: 8, + downloaded: 7, + failed: 1, + }); + expect(result.errors.map((e) => e.fileID)).toEqual([103]); + expectStored( + dir, + PHOTO_IDS.filter((id) => id !== 103), + ); + expect(existsSync(savedAt(dir, 103))).toBe(false); + const failures = JSON.parse( + readFileSync(join(dir, "failures.json"), "utf-8"), + ); + expect(Object.keys(failures.files)).toEqual(["103"]); + } finally { + await lib.close(); + } + }); + + // Working on several originals at once changes only how long a backup + // takes. The same two runs, with a content pool of one, which backs up + // one original at a time, and with the default pools, end the same way: + // the same result, the same progress lines (in another order), the same + // failures.json, and the same tree. The first run stores every photo but + // 103, whose download is answered 404. Then two stored originals change + // on disk, and a run with `verify` puts them back and tries 103 again. + it("ends as a backup of one original at a time does", async () => { + const twoRuns = async (pools: RequestPools) => { + const dir = mkdtempSync(join(root, "backup-")); + const lines: string[] = []; + const backup = async (opts: BackupOptions) => { + const lib = await openLibrary(dir, failingFor(103), pools); + try { + return await lib.backup({ + ...opts, + onProgress: (line) => lines.push(line), + }); + } finally { + await lib.close(); + } + }; + const first = await backup({}); + writeFileSync(savedAt(dir, 101), "not the photo any more"); + writeFileSync(savedAt(dir, 105), "not the photo any more"); + const second = await backup({ verify: true }); + + // Each entry of failures.json without the time it was last + // tried, which differs from run to run. + const { files } = JSON.parse( + readFileSync(join(dir, "failures.json"), "utf-8"), + ) as { files: Record }; + for (const entry of Object.values(files)) delete entry.lastTriedAt; + + return { + first, + second, + lines: lines.sort(), + failures: files, + tree: listTree(dir), + }; + }; + + const oneAtATime = await twoRuns( + new RequestPools({ contentConcurrency: 1 }), + ); + const fiveAtATime = await twoRuns(new RequestPools()); + + expect(oneAtATime.first).toMatchObject({ + totalFiles: 8, + downloaded: 7, + skipped: 0, + failed: 1, + }); + expect(oneAtATime.second).toMatchObject({ + totalFiles: 8, + downloaded: 2, + skipped: 5, + verified: 5, + mismatched: 2, + unchecked: 0, + failed: 1, + }); + expect(oneAtATime.failures).toEqual({ + "103": expect.objectContaining({ fileID: 103, attempts: 2 }), + }); + expect(fiveAtATime).toEqual(oneAtATime); + }); +}); -- 2.54.0 From 8904477f759563f2c40a58b994d9d7806e56305c Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 14 Oct 2026 05:42:47 +0000 Subject: [PATCH 02/10] quak backup works on up to the content pool's limit of originals at once (closes #182) lib.backup() ran its originals one at a time, so the content pool's five slots, or the contentConcurrency of the pools a library was opened with, went unused. The work for one original, its verify hashing included, is now storeOriginal, and that many workers take files from one iterator over the backup's files, so no more originals than the limit are open at once. BackupLibrary gains contentConcurrency, which the library sets from its content pool. The README's "Request pools" says so. The tests of a backup whose lock is lost now expect all three originals downloaded, since all three are under way at once. Model: opus-5-5 --- README.md | 6 ++ TODO.md | 17 ++-- src/backup.ts | 142 +++++++++++++++------------- src/library/index.ts | 1 + test/cli/backup-concurrency.test.ts | 6 +- test/cli/backup-end-to-end.test.ts | 15 +-- test/cli/backup.test.ts | 1 + 7 files changed, 107 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index bc74537..422e5d5 100644 --- a/README.md +++ b/README.md @@ -1096,6 +1096,12 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange` Each pool orders on-demand work ahead of background/precache work and dedups in-flight fetches by key, and an idle pool never lends its slots to a busy one. +`lib.backup()`, and so `quak backup`, works on up to the content pool's limit of +originals at once, 5 unless the library was opened with other `pools`. The limit +covers everything it does for one original, hashing it for `verify` included, so +however large the account is, no more originals than that are open at once. With +`includeThumbnails`, it fetches the thumbnails one at a time. + ### On-disk cache layout Under `cacheDirectory`: diff --git a/TODO.md b/TODO.md index c231724..c4a3e17 100644 --- a/TODO.md +++ b/TODO.md @@ -17,18 +17,23 @@ pre-1.0 # Next Step -`quak backup` downloading one original at a time, -https://git.eeqj.de/sneak/quak/issues/182, is fixed in -https://git.eeqj.de/sneak/quak/pulls/192, which waits on review. No other -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. +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. # Completed Steps +- 2026-10-14: `lib.backup()`, and so `quak backup`, works on up to the content + pool's limit of originals at once, 5 by default, instead of one at a time + (issue 182). That many workers take files from one shared list, each doing all + 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. + - 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. diff --git a/src/backup.ts b/src/backup.ts index bd76d2f..bbb7d7c 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -6,9 +6,9 @@ // file. It waits for a completed refresh of the library (a failed one fails the // backup before any file is touched), then, for every file in scope, puts its // original at its save path under `downloadDirectory`, as `Photo.download()` -// does, waits for an ML data fetch, and rebuilds the derived views (per-file -// sidecars, per-collection symlink trees, per-collection JSON) from the model. -// The on-disk layout: +// does, up to the content pool's limit of them at once, waits for an ML data +// fetch, and rebuilds the derived views (per-file sidecars, per-collection +// symlink trees, per-collection JSON) from the model. The on-disk layout: // // / // YYYY/YYYY-MM/YYYY-MM-DD/ @@ -181,6 +181,9 @@ export interface BackupLibrary { destination: string, ): Promise<{ path: string; videoPath?: string }>; thumbnail(fileID: number): Promise<{ path: string }>; + // How many originals the backup works on at once: the limit of the + // library's content pool. + contentConcurrency: number; // Wait for an ML data fetch to complete, joining one already running or // starting one. Rejects with the reason when it fails; resolves at once // when the library cannot fetch ML data. @@ -654,75 +657,32 @@ const runLockedBackup = async ( // present original is hashed first, and one that does not match the hash // its metadata records is removed and fetched like a missing one. One // that cannot be read is recorded as failed and left where it is. - if (includeOriginals) { - for (const [fileID, file] of distinct) { - if (lock.lost) throw lock.lost; - let stored = storedAtSavePath(downloadDirectory, file); - let mismatch = false; - if (stored !== undefined && verify) { - try { - if (file.metadata.hash === undefined) { - unchecked++; - } else if ( - (await storedHash(stored)) === file.metadata.hash - ) { - verified++; - } else { - log( - `MISMATCH original ${file.metadata.title} (${fileID}): its bytes do not match its content hash`, - ); - mismatched++; - mismatch = true; - rmSync(stored.path); - if (stored.videoPath !== undefined) { - rmSync(stored.videoPath); - } - stored = undefined; - } - } catch (err) { - log( - `FAILED verifying original ${file.metadata.title}: ${errorMessage(err)}`, - ); - recordFailure( - file.id, - file.metadata.title, - collectionName.get(file.collectionID) ?? "", - err, - ); - continue; - } - } - if (stored !== undefined) { - skipped++; - continue; - } + // `storeOriginal` does this for one original, and records its failure + // rather than throwing. + const storeOriginal = async (file: EnteFile): Promise => { + let stored = storedAtSavePath(downloadDirectory, file); + let mismatch = false; + if (stored !== undefined && verify) { try { - log(`Fetching original ${file.metadata.title} (${fileID})...`); - // A fetched original is written straight to its save path (a - // live photo beside it); only one that was already cached - // elsewhere is copied. - const placed = await placeOriginal( - downloadDirectory, - file, - (dest) => lib.original(fileID, dest), - ); - storedThisRun.add(fileID); - // A copy from the cache is not checked as a download is and can - // hold the same bad bytes, so what is put back after a - // mismatch is hashed too. A bad copy stays where it is and the - // file fails. - if ( - mismatch && - (await storedHash(placed)) !== file.metadata.hash - ) { - throw new Error( - "the original put back does not match its content hash either", + if (file.metadata.hash === undefined) { + unchecked++; + } else if ((await storedHash(stored)) === file.metadata.hash) { + verified++; + } else { + log( + `MISMATCH original ${file.metadata.title} (${file.id}): its bytes do not match its content hash`, ); + mismatched++; + mismatch = true; + rmSync(stored.path); + if (stored.videoPath !== undefined) { + rmSync(stored.videoPath); + } + stored = undefined; } - downloaded++; } catch (err) { log( - `FAILED original ${file.metadata.title}: ${errorMessage(err)}`, + `FAILED verifying original ${file.metadata.title}: ${errorMessage(err)}`, ); recordFailure( file.id, @@ -730,8 +690,56 @@ const runLockedBackup = async ( collectionName.get(file.collectionID) ?? "", err, ); + return; } } + if (stored !== undefined) { + skipped++; + return; + } + try { + log(`Fetching original ${file.metadata.title} (${file.id})...`); + // A fetched original is written straight to its save path (a + // live photo beside it); only one that was already cached + // elsewhere is copied. + const placed = await placeOriginal( + downloadDirectory, + file, + (dest) => lib.original(file.id, dest), + ); + storedThisRun.add(file.id); + // A copy from the cache is not checked as a download is and can + // hold the same bad bytes, so what is put back after a mismatch + // is hashed too. A bad copy stays where it is and the file fails. + if (mismatch && (await storedHash(placed)) !== file.metadata.hash) { + throw new Error( + "the original put back does not match its content hash either", + ); + } + downloaded++; + } catch (err) { + log(`FAILED original ${file.metadata.title}: ${errorMessage(err)}`); + recordFailure( + file.id, + file.metadata.title, + collectionName.get(file.collectionID) ?? "", + err, + ); + } + }; + + // 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. + if (includeOriginals) { + const pending = distinct.values(); + const worker = async (): Promise => { + for (const file of pending) await storeOriginal(file); + }; + await Promise.all( + Array.from({ length: lib.contentConcurrency }, worker), + ); } if (includeThumbnails) { diff --git a/src/library/index.ts b/src/library/index.ts index eb36410..95d8e1d 100644 --- a/src/library/index.ts +++ b/src/library/index.ts @@ -607,6 +607,7 @@ export class Library { original: (fileID, destination) => cache!.backupOriginal(fileID, destination), thumbnail: (fileID) => cache!.thumbnail(fileID), + contentConcurrency: this.pools.content.concurrency, fetchMLData: () => this.fetchMLDataNow(), mlData: (fileID) => this.mldata.forFile({ fileID }), }, diff --git a/test/cli/backup-concurrency.test.ts b/test/cli/backup-concurrency.test.ts index fe8caa4..964bff5 100644 --- a/test/cli/backup-concurrency.test.ts +++ b/test/cli/backup-concurrency.test.ts @@ -208,7 +208,8 @@ const holdingServer = (failing?: number) => { // Let the held downloads go one at a time, the first to arrive first, each // once `limit` of them wait, or all the downloads still to come when there // are fewer. Each of the eight photos is downloaded once. With a backup that -// downloads fewer than `limit` at once, this fails waiting for them. +// downloads fewer than `limit` at once, this fails waiting for them. Each wait +// allows five seconds, for a loaded machine's disk to sync the files written. const releaseOneAtATime = async ( held: { release: () => void }[], limit: number, @@ -216,7 +217,7 @@ const releaseOneAtATime = async ( for (let left = PHOTO_IDS.length; left > 0; left--) { await vi.waitFor( () => expect(held).toHaveLength(Math.min(limit, left)), - { interval: 5 }, + { interval: 5, timeout: 5000 }, ); held.shift()!.release(); } @@ -340,6 +341,7 @@ describe("lib.backup()", () => { }); await vi.waitFor(() => expect(holding.held).toHaveLength(5), { interval: 5, + timeout: 5000, }); expect(mismatches()).toHaveLength(5); diff --git a/test/cli/backup-end-to-end.test.ts b/test/cli/backup-end-to-end.test.ts index 886a1fc..0ec1fd7 100644 --- a/test/cli/backup-end-to-end.test.ts +++ b/test/cli/backup-end-to-end.test.ts @@ -704,9 +704,9 @@ describe("a backup whose lock is lost", () => { } }; - // beach.jpg, whose download was under way, is stored; no other file is - // started. `quak backup` prints why as one line, with no stack trace, and - // exits 1. + // 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 () => { const result = backup(dir, {}, fetch); await heldRequested; @@ -720,9 +720,12 @@ describe("a backup whose lock is lost", () => { "Starting backup...\n" + "Refreshing library...\n" + "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).toEqual([BEACH.id]); + expect(downloads.sort()).toEqual([100, 200, 300]); expect(readFileSync(join(dir, DAY, "2024-03-01.100.jpg"))).toEqual( Buffer.from(JPEG_WITH_EXIF), ); @@ -798,7 +801,7 @@ describe("a backup whose lock is lost", () => { await expect(result).rejects.toThrow( `lost the lock on ${dir}: Unable to update lock within the stale threshold`, ); - expect(downloads).toEqual([BEACH.id]); + expect(downloads.sort()).toEqual([100, 200, 300]); expect(existsSync(lockPath)).toBe(true); } finally { await lib.close(); @@ -823,7 +826,7 @@ describe("a backup whose lock is lost", () => { const lost = `lost the lock on ${dir}: ENOENT: no such file or directory, stat '${lockPath}'`; await expect(result).rejects.toThrow(lost); expect(lock.lost?.message).toBe(lost); - expect(downloads).toEqual([BEACH.id]); + expect(downloads.sort()).toEqual([100, 200, 300]); } finally { await lib.close(); await lock.release(); diff --git a/test/cli/backup.test.ts b/test/cli/backup.test.ts index 065a2c5..7330b72 100644 --- a/test/cli/backup.test.ts +++ b/test/cli/backup.test.ts @@ -1491,6 +1491,7 @@ describe("backup album folders", () => { thumbnail: async () => { throw new Error("no thumbnails in this stand-in"); }, + contentConcurrency: 5, fetchMLData: async () => {}, mlData: async () => undefined, whoami: () => ({ email: "backup@example.com", userID: USER_ID }), -- 2.54.0 From 9f6fa1990f06e3863b7eb23acd76fe7d42fb1c8c Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 14 Oct 2026 04:21:03 +0000 Subject: [PATCH 03/10] Test: lib.backup() rejects only once its workers have stopped when onProgress throws With a content pool of eight, all eight downloads wait at once. Photo 103's is answered 404 and the line reporting it throws while the other seven still wait. When lib.backup() rejects, the other seven originals must be in place and the lock gone, so nothing is written after the backup has ended. Today the backup rejects as soon as that one worker throws, before the others have written anything. Model: opus-5-5 --- test/cli/backup-concurrency.test.ts | 63 +++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/test/cli/backup-concurrency.test.ts b/test/cli/backup-concurrency.test.ts index 964bff5..cd411d8 100644 --- a/test/cli/backup-concurrency.test.ts +++ b/test/cli/backup-concurrency.test.ts @@ -396,6 +396,69 @@ 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 () => { + const dir = mkdtempSync(join(root, "backup-")); + const holding = holdingServer(103); + const lib = await openLibrary( + dir, + holding.fetch, + new RequestPools({ contentConcurrency: 8 }), + ); + const lines: string[] = []; + try { + const backup = lib.backup({ + onProgress: (line) => { + lines.push(line); + if (line.startsWith("FAILED")) { + throw new Error("the progress output is closed"); + } + }, + }); + // The photos whose originals are in place, and whether the lock + // is there, at the moment `lib.backup()` rejects. + let storedWhenRejected: number[] = []; + let lockedWhenRejected = true; + const rejection = backup.catch((err: unknown) => { + storedWhenRejected = PHOTO_IDS.filter((id) => + existsSync(savedAt(dir, id)), + ); + lockedWhenRejected = existsSync(join(dir, "backup.lock")); + return err; + }); + + await vi.waitFor(() => expect(holding.held).toHaveLength(8), { + interval: 5, + timeout: 5000, + }); + const at103 = holding.held.findIndex((h) => h.id === 103); + holding.held.splice(at103, 1)[0]!.release(); + await vi.waitFor( + () => + expect( + lines.some((line) => line.startsWith("FAILED")), + ).toBe(true), + { interval: 5, timeout: 5000 }, + ); + for (const download of holding.held) download.release(); + + expect(await rejection).toEqual( + new Error("the progress output is closed"), + ); + expect(storedWhenRejected).toEqual( + PHOTO_IDS.filter((id) => id !== 103), + ); + expect(lockedWhenRejected).toBe(false); + } finally { + await lib.close(); + } + }); + // Working on several originals at once changes only how long a backup // takes. The same two runs, with a content pool of one, which backs up // one original at a time, and with the default pools, end the same way: -- 2.54.0 From b5fb5b17384f13e77d0c313dd2229356d08c2b3c Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 14 Oct 2026 04:26:56 +0000 Subject: [PATCH 04/10] lib.backup() fails only once every worker has stopped A worker that threw, as one does when onProgress throws on a FAILED line, made lib.backup() reject at once, so the lock was removed while the other workers still wrote originals into the backup. The backup now waits for every worker to settle and then throws the first error found. The README no longer says thumbnails are fetched one at a time, which no test checks. Model: opus-5-5 --- README.md | 3 +-- src/backup.ts | 12 ++++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 422e5d5..f8fb8a9 100644 --- a/README.md +++ b/README.md @@ -1099,8 +1099,7 @@ in-flight fetches by key, and an idle pool never lends its slots to a busy one. `lib.backup()`, and so `quak backup`, works on up to the content pool's limit of originals at once, 5 unless the library was opened with other `pools`. The limit covers everything it does for one original, hashing it for `verify` included, so -however large the account is, no more originals than that are open at once. With -`includeThumbnails`, it fetches the thumbnails one at a time. +however large the account is, no more originals than that are open at once. ### On-disk cache layout diff --git a/src/backup.ts b/src/backup.ts index bbb7d7c..db39266 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -657,8 +657,7 @@ const runLockedBackup = async ( // present original is hashed first, and one that does not match the hash // its metadata records is removed and fetched like a missing one. One // that cannot be read is recorded as failed and left where it is. - // `storeOriginal` does this for one original, and records its failure - // rather than throwing. + // `storeOriginal` does this for one original, and records its failure. const storeOriginal = async (file: EnteFile): Promise => { let stored = storedAtSavePath(downloadDirectory, file); let mismatch = false; @@ -731,15 +730,20 @@ 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. + // iterator they all share until none is left. A worker that throws, as + // one does when `onProgress` throws, fails the backup only once every + // worker has stopped, so none writes into the backup after it has ended. if (includeOriginals) { const pending = distinct.values(); const worker = async (): Promise => { for (const file of pending) await storeOriginal(file); }; - await Promise.all( + const workers = await Promise.allSettled( Array.from({ length: lib.contentConcurrency }, worker), ); + for (const w of workers) { + if (w.status === "rejected") throw w.reason; + } } if (includeThumbnails) { -- 2.54.0 From 4dc9e9e23cc73f1f5257e99146c9dcc2aec337b9 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 14 Oct 2026 05:18:34 +0000 Subject: [PATCH 05/10] Test: runBackup rejects a contentConcurrency that is not a positive integer runBackup takes a library object from its caller. One whose contentConcurrency is missing, 0 or negative starts no worker, so the backup stores no original and reports no error. The test expects runBackup to reject any limit that is not a positive integer, as BoundedPool does, before it takes the lock, which would create the download directory. Today it takes the lock and fails on the first method the object lacks. Model: opus-5-5 --- test/cli/backup.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/cli/backup.test.ts b/test/cli/backup.test.ts index 7330b72..1938a4b 100644 --- a/test/cli/backup.test.ts +++ b/test/cli/backup.test.ts @@ -909,6 +909,27 @@ describe("the backup lock", () => { expect(existsSync(lockPath(outDir))).toBe(false); await lib.close(); }); + + // `runBackup` takes a library object from its caller. With a + // `contentConcurrency` below 1, or one that is not a number, it would + // back up no original and report no error. It rejects any that is not a + // positive integer, as `BoundedPool` does, before it takes the lock, + // which would create the download directory, and before it asks the + // library for anything: the object here has nothing else. + it("rejects a library whose contentConcurrency is not a positive integer, before taking the lock", async () => { + const outDir = join(root, "backup"); + for (const contentConcurrency of [undefined, 0, -1, 1.5, NaN]) { + const lib = { contentConcurrency } as unknown as BackupLibrary; + + await expect( + runBackup(lib, { downloadDirectory: outDir }), + ).rejects.toThrow( + `contentConcurrency must be a positive integer, got ${contentConcurrency}`, + ); + + expect(existsSync(outDir)).toBe(false); + } + }); }); // Also serves ML data: file 100 has `ML_PAYLOAD`, the other two have none. -- 2.54.0 From 65b0de469462d7051f685c2ca746e3608dd6887c Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 14 Oct 2026 05:18:34 +0000 Subject: [PATCH 06/10] runBackup rejects a contentConcurrency that is not a positive integer A library object whose contentConcurrency was missing, 0 or negative started no worker, so the backup stored no original, dropped the earlier entries of failures.json and reported no error. runBackup now throws a RangeError for any limit that is not a positive integer, as BoundedPool does, before it takes the lock. Model: opus-5-5 --- src/backup.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/backup.ts b/src/backup.ts index db39266..b3eb8d8 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -972,6 +972,14 @@ export const runBackup = async ( "open the library with one)", ); } + // As `BoundedPool` does. A limit below 1, or one that is not a number, + // would back up no original and end with no error. + const { contentConcurrency } = lib; + if (!Number.isInteger(contentConcurrency) || contentConcurrency < 1) { + throw new RangeError( + `contentConcurrency must be a positive integer, got ${contentConcurrency}`, + ); + } if (opts.lock) { return runLockedBackup(lib, opts, downloadDirectory, opts.lock); } -- 2.54.0 From 7f9bf5eb90be63ee22dc0926bd6e6b852dcd8032 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 14 Oct 2026 05:44:43 +0000 Subject: [PATCH 07/10] Test: lib.backup() starts no further original once its lock is lost Issue 184 made the backup check its lock before each original. The workers that now back up several originals at once take the next file without checking it. With five downloads waiting, backup.lock is removed and proper-lockfile's timer finds it gone. The backup must download no other photo, and reject with "lost the lock on DIR" once those five are in place. Today its workers go on to the other three photos. Model: opus-5-5 --- test/cli/backup-concurrency.test.ts | 65 +++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/test/cli/backup-concurrency.test.ts b/test/cli/backup-concurrency.test.ts index cd411d8..be17b94 100644 --- a/test/cli/backup-concurrency.test.ts +++ b/test/cli/backup-concurrency.test.ts @@ -459,6 +459,71 @@ describe("lib.backup()", () => { } }); + // Once the backup's lock is lost, another backup may be writing to the + // directory, so the backup starts no further original. It finishes the + // ones it is working on, and `lib.backup()` rejects once they are all in + // place. Here `backup.lock` is removed while the first five downloads + // wait, and proper-lockfile's timer finds it gone. As in + // `backup-end-to-end.test.ts`, only `setTimeout` and `clearTimeout` are + // faked, so the test can fire that timer without waiting 5 seconds. Only + // the first five downloads are held: a backup that went on would store + // the other three photos. + it("when its lock is lost, starts no further original, and rejects once the originals it was working on are in place", async () => { + const dir = mkdtempSync(join(root, "backup-")); + const lockPath = join(dir, "backup.lock"); + const downloads: number[] = []; + const held: (() => 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((release) => held.push(release)); + } + } + return server(input, init); + }; + const lib = await openLibrary(dir, fetch); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + const backup = lib.backup(); + // The photos whose originals are in place at the moment + // `lib.backup()` rejects. + let storedWhenRejected: number[] = []; + const rejection = backup.catch((err: unknown) => { + storedWhenRejected = PHOTO_IDS.filter((id) => + existsSync(savedAt(dir, id)), + ); + return err; + }); + + await vi.waitFor(() => expect(held).toHaveLength(5), { + interval: 5, + timeout: 5000, + }); + rmSync(lockPath, { recursive: true }); + // Fire proper-lockfile's timer, and wait for the look at the lock + // on disk that it starts to finish: Node lists a file system call + // in progress as an `FSReqCallback`. + vi.advanceTimersByTime(5_000); + while (process.getActiveResourcesInfo().includes("FSReqCallback")) { + await new Promise((resolve) => setImmediate(resolve)); + } + for (const release of held) release(); + + expect(await rejection).toEqual( + new Error( + `lost the lock on ${dir}: ENOENT: no such file or directory, stat '${lockPath}'`, + ), + ); + expect(downloads.sort()).toEqual([101, 102, 103, 104, 105]); + expect(storedWhenRejected).toEqual([101, 102, 103, 104, 105]); + } finally { + vi.useRealTimers(); + await lib.close(); + } + }); + // Working on several originals at once changes only how long a backup // takes. The same two runs, with a content pool of one, which backs up // one original at a time, and with the default pools, end the same way: -- 2.54.0 From d95c49e5481fc106e3d13368202c07deee21ef1b Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 14 Oct 2026 05:46:39 +0000 Subject: [PATCH 08/10] lib.backup() starts no further original once its lock is lost Issue 184 checked the backup's lock before each original, in the loop this branch replaces with several workers. Each worker now checks the lock before it takes the next file, so once the lock is lost no further original starts; the originals under way are finished, and the backup rejects once every worker has stopped. The README says the originals under way, not the one, are still stored. Model: opus-5-5 --- README.md | 6 +++--- TODO.md | 3 ++- src/backup.ts | 12 ++++++++---- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f8fb8a9..12fd670 100644 --- a/README.md +++ b/README.md @@ -779,9 +779,9 @@ A running backup loses its lock when someone removes `backup.lock`, or when another backup takes it over because the running one left it untouched for 10 seconds, as a stopped or stalled process does. The backup finds this out the next time it goes to touch the lock, which it does every 5 seconds, and then -stops before it starts another file. The original it is downloading at that -moment is still stored. It leaves `failures.json` and `backup.lock` as they are, -since another backup may be using them. `quak backup` prints +stops before it starts another file. The originals it is downloading at that +moment are still stored. It leaves `failures.json` and `backup.lock` as they +are, since another backup may be using them. `quak backup` prints `quak: lost the lock on : ` as one line and exits 1, and `lib.backup()` rejects with `lost the lock on : `. diff --git a/TODO.md b/TODO.md index c4a3e17..67edd09 100644 --- a/TODO.md +++ b/TODO.md @@ -32,7 +32,8 @@ 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. + 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. - 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 diff --git a/src/backup.ts b/src/backup.ts index b3eb8d8..8a21adf 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -730,13 +730,17 @@ 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. A worker that throws, as - // one 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, 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. if (includeOriginals) { const pending = distinct.values(); const worker = async (): Promise => { - for (const file of pending) await storeOriginal(file); + for (const file of pending) { + if (lock.lost) throw lock.lost; + await storeOriginal(file); + } }; const workers = await Promise.allSettled( Array.from({ length: lib.contentConcurrency }, worker), -- 2.54.0 From 767545f4abd6c3148a7e7745479209b6f3b7d677 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 14 Oct 2026 06:37:26 +0000 Subject: [PATCH 09/10] Test: lib.backup() takes no new original once onProgress has thrown or its lock is lost The onProgress test now runs eight photos with a pool of five and holds only the first five downloads, so a backup that went on after the throw would download and store the other three. The end-to-end test of a removed backup.lock expects the backup to stop after its three originals, before it fetches ML data. Model: opus-5-5 --- test/cli/backup-concurrency.test.ts | 51 ++++++++++++++++++----------- test/cli/backup-end-to-end.test.ts | 8 ++--- 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/test/cli/backup-concurrency.test.ts b/test/cli/backup-concurrency.test.ts index be17b94..f3c476c 100644 --- a/test/cli/backup-concurrency.test.ts +++ b/test/cli/backup-concurrency.test.ts @@ -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((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(); diff --git a/test/cli/backup-end-to-end.test.ts b/test/cli/backup-end-to-end.test.ts index 0ec1fd7..45865ba 100644 --- a/test/cli/backup-end-to-end.test.ts +++ b/test/cli/backup-end-to-end.test.ts @@ -705,9 +705,10 @@ 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 () => { + // under way 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]); -- 2.54.0 From 8d85364a01b58f99278b425b8fb2113e28422870 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 14 Oct 2026 06:42:06 +0000 Subject: [PATCH 10/10] lib.backup() takes no new original once a worker has thrown, and checks its lock before going on Once a worker has thrown, as one does when onProgress throws, or the lock is lost, no worker takes another file, and the backup fails once the originals under way are in place. Before, the other workers went on through the rest of the account after a throw. Once the workers have stopped, the backup checks its lock, so a lost lock no longer lets it start the ML data fetch. The end-to-end test's comment now says the backup has started all three originals when the lock is lost, not that all three are still downloading. Model: opus-5-5 --- TODO.md | 6 ++++-- src/backup.ts | 21 +++++++++++++++------ test/cli/backup-end-to-end.test.ts | 4 ++-- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/TODO.md b/TODO.md index 67edd09..1aa7f9b 100644 --- a/TODO.md +++ b/TODO.md @@ -32,8 +32,10 @@ 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 diff --git a/src/backup.ts b/src/backup.ts index 8a21adf..488ff30 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -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 => { 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) { diff --git a/test/cli/backup-end-to-end.test.ts b/test/cli/backup-end-to-end.test.ts index 45865ba..65bf5b6 100644 --- a/test/cli/backup-end-to-end.test.ts +++ b/test/cli/backup-end-to-end.test.ts @@ -704,8 +704,8 @@ 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. The backup + // 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 () => { -- 2.54.0