Compare commits

..
6 Commits
Author SHA1 Message Date
sneak 558ce6052c runBackup rejects a contentConcurrency that is not a positive integer
check / check (push) Canceled after 0s
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
2026-10-14 05:18:34 +00:00
sneak 115357ce97 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
2026-10-14 05:18:34 +00:00
sneak 28770938ef 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
2026-10-14 05:12:37 +00:00
sneak 7339199be8 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
2026-10-14 05:12:37 +00:00
sneak c85ac33cb6 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.

Model: opus-5-5
2026-10-14 05:12:37 +00:00
sneak bdcaee68fa 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
2026-10-14 05:11:48 +00:00
8 changed files with 76 additions and 440 deletions
+19 -32
View File
@@ -761,9 +761,9 @@ drops it.
`backup.lock` keeps two backups of the same directory from running at once, such `backup.lock` keeps two backups of the same directory from running at once, such
as a cron run that starts while the previous one is still going. A backup as a cron run that starts while the previous one is still going. A backup
creates `<dir>` if it is missing and takes the lock before its refresh, and creates `<dir>` if it is missing and takes the lock before its refresh, and
removes the lock when it ends, whether it succeeds or fails, unless it lost the removes the lock when it ends, whether it succeeds or fails; `quak backup` takes
lock (see below); `quak backup` takes it before it opens its library, so a it before it opens its library, so a refused run sends no request. The lock is a
refused run sends no request. The lock is a directory that directory that
[proper-lockfile](https://github.com/moxystudio/node-proper-lockfile) creates [proper-lockfile](https://github.com/moxystudio/node-proper-lockfile) creates
and keeps touching while the backup runs. A second backup of the directory, from and keeps touching while the backup runs. A second backup of the directory, from
another process or the same one, fails at once: `quak backup` prints another process or the same one, fails at once: `quak backup` prints
@@ -775,16 +775,6 @@ over, so nobody has to remove it. The lock is outside the date folders and
`collections/`, so it is never taken for an original, and the removal of old `collections/`, so it is never taken for an original, and the removal of old
album directories never touches it. album directories never touches it.
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 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 <dir>: <reason>` as one line and exits 1, and
`lib.backup()` rejects with `lost the lock on <dir>: <reason>`.
A collection's directory and JSON are named after the collection, and a symlink A collection's directory and JSON are named after the collection, and a symlink
after the file's title, both with unsafe characters replaced. When two after the file's title, both with unsafe characters replaced. When two
collections would get the same name, or two symlinks in one collection the same collections would get the same name, or two symlinks in one collection the same
@@ -1070,24 +1060,21 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
directory, and fails at once with an error whose `code` is `ELOCKED` while directory, and fails at once with an error whose `code` is `ELOCKED` while
another backup of it runs. A caller can instead take the lock itself, before another backup of it runs. A caller can instead take the lock itself, before
it opens its library, as `quak backup` does: `await lockBackupDirectory(dir)` it opens its library, as `quak backup` does: `await lockBackupDirectory(dir)`
takes it, failing the same way, and returns a `BackupLock`, whose `release()` takes it, failing the same way, and returns the function that releases it, and
removes the lock unless it was lost and whose `lost` holds why it was lost. the caller passes `lockHeld: true` to the backup. It waits for a refresh as
The caller passes it to the backup as `lock`, and releases it once the backup `fresh()` does, puts every in-scope original not already at its save path
ends. It waits for a refresh as `fresh()` does, puts every in-scope original there as `photo.download()` does (and, with `includeThumbnails`, fetches
not already at its save path there as `photo.download()` does (and, with thumbnails) through the content cache, waits for an ML data fetch, and
`includeThumbnails`, fetches thumbnails) through the content cache, waits for rebuilds the on-disk backup tree with a durable failure ledger. Each file's
an ML data fetch, and rebuilds the on-disk backup tree with a durable failure JSON holds its ML data and, for an image (for a live photo, its image), its
ledger. Each file's JSON holds its ML data and, for an image (for a live original's EXIF and XMP, with its dimensions for a JPEG only; a video's JSON
photo, its image), its original's EXIF and XMP, with its dimensions for a JPEG holds none of these three. A fetched original is written straight to its save
only; a video's JSON holds none of these three. A fetched original is written path and not into the cache, which then counts it as present; one the cache
straight to its save path and not into the cache, which then counts it as already held is copied from there. `BackupOptions`: `downloadDirectory` (falls
present; one the cache already held is copied from there. `BackupOptions`: back to the library's), `includeOriginals` (default `true`),
`downloadDirectory` (falls back to the library's), `includeOriginals` (default `includeThumbnails` (default `false`), `onlyAlbumNames`, `verify` (default
`true`), `includeThumbnails` (default `false`), `onlyAlbumNames`, `verify` `false`), `onProgress`, and `lockHeld` (default `false`). See Backup layout
(default `false`), `onProgress`, and `lock`. See Backup layout above for the above for the tree it writes.
tree it writes. Whether the backup took the lock or its caller did, once the
lock is lost the backup rejects with `lost the lock on <dir>: <reason>` before
it starts another file.
### Request pools ### Request pools
@@ -1152,7 +1139,7 @@ from a very old client, is stored unchecked.
- `src/library/mlsearch.ts`: `MLDataAPI`, `SimilarResult` - `src/library/mlsearch.ts`: `MLDataAPI`, `SimilarResult`
- `src/exif.ts`: `ExifTags`, `PhotoExif` - `src/exif.ts`: `ExifTags`, `PhotoExif`
- `src/library/pools.ts`: `RequestPools`, `RequestPoolsOptions`, `BoundedPool` - `src/library/pools.ts`: `RequestPools`, `RequestPoolsOptions`, `BoundedPool`
- `src/backup.ts`: `BackupLock`, `BackupOptions`, `BackupResult`, `BackupError` - `src/backup.ts`: `BackupOptions`, `BackupResult`, `BackupError`
- `src/client.ts`: `Client`, `LoginOptions`, `ClientSnapshot` - `src/client.ts`: `Client`, `LoginOptions`, `ClientSnapshot`
- `src/api/client.ts`: `ApiClient`, `ApiClientOptions`, `StreamOptions` - `src/api/client.ts`: `ApiClient`, `ApiClientOptions`, `StreamOptions`
- `src/errors.ts`: `ApiError`, `TruncatedStreamError` - `src/errors.ts`: `ApiError`, `TruncatedStreamError`
+3 -21
View File
@@ -17,9 +17,8 @@ pre-1.0
# Next Step # Next Step
None: no implementation work is open. The check of quak against a live Ente None: no implementation work is open. The cache design,
account, https://git.eeqj.de/sneak/quak/issues/187, needs an account only sneak https://git.eeqj.de/sneak/quak/issues/36, waits on sneak's review.
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.
@@ -32,24 +31,7 @@ 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. Once a worker has thrown, as one does when `onProgress` count those in flight.
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`
that records why the lock was lost instead of throwing from its timer, and
returns the lock, which `quak backup` passes to `lib.backup()` as `lock`. The
backup then stops before it starts another file with
`lost the lock on DIR: REASON`, which `quak backup` prints as one line before
it exits 1. Releasing a lost lock leaves `backup.lock` alone. Tests in
`test/cli/backup-end-to-end.test.ts` remove the lock, or put another backup's
in its place, while a download is held open.
- 2026-10-14: An album or file quak cannot decrypt, or whose metadata is not - 2026-10-14: An album or file quak cannot decrypt, or whose metadata is not
JSON, no longer fails the refresh, so one bad record no longer stops JSON, no longer fails the refresh, so one bad record no longer stops
+25 -77
View File
@@ -1,9 +1,8 @@
// The backup command, rebuilt on the library API (issue #51). // The backup command, rebuilt on the library API (issue #51).
// //
// `lib.backup()` takes the lock in `downloadDirectory` (unless its caller // `lib.backup()` takes the lock in `downloadDirectory` (unless its caller
// passes one in), and fails at once when another backup of it holds the // already holds it), and fails at once when another backup of it holds the
// lock; when the lock is lost while it runs, it fails before it starts another // lock. It waits for a completed refresh of the library (a failed one fails the
// 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 // backup before any file is touched), then, for every file in scope, puts its
// original at its save path under `downloadDirectory`, as `Photo.download()` // original at its save path under `downloadDirectory`, as `Photo.download()`
// does, up to the content pool's limit of them at once, waits for an ML data // does, up to the content pool's limit of them at once, waits for an ML data
@@ -97,14 +96,6 @@ import type { Collection, EnteFile, FileMetadata } from "./model/types.js";
export type ProgressCallback = (message: string) => void; export type ProgressCallback = (message: string) => void;
// The lock in a backup directory, as `lockBackupDirectory` returns it.
export interface BackupLock {
// Removes `backup.lock`, unless the lock was lost.
release(): Promise<void>;
// Why the lock was lost, once it is.
lost?: Error;
}
export interface BackupOptions { export interface BackupOptions {
// Where the backup tree lives. `lib.backup()` defaults it to the library's // Where the backup tree lives. `lib.backup()` defaults it to the library's
// download directory; `runBackup` with none throws before any network // download directory; `runBackup` with none throws before any network
@@ -121,11 +112,10 @@ export interface BackupOptions {
// bytes do not match the content hash its metadata records. Default false. // bytes do not match the content hash its metadata records. Default false.
verify?: boolean; verify?: boolean;
onProgress?: ProgressCallback; onProgress?: ProgressCallback;
// The lock in `downloadDirectory`, when the caller took it with // The caller already holds the lock in `downloadDirectory`, taken with
// `lockBackupDirectory` and releases it itself. The backup then does not // `lockBackupDirectory`, and releases it itself, so the backup does not
// take the lock, and fails once this one is lost. `quak backup` takes it // take it. `quak backup` takes it before it opens its library.
// before it opens its library. lockHeld?: boolean;
lock?: BackupLock;
} }
export interface BackupError { export interface BackupError {
@@ -523,14 +513,11 @@ const writeAlbumJSON = (
writeFileSync(path, JSON.stringify(album, null, 2)); writeFileSync(path, JSON.stringify(album, null, 2));
}; };
// The backup itself, which `runBackup` below runs while `lock` is held. Once // The backup itself, which `runBackup` below runs while the lock is held.
// the lock is lost, another backup may be writing to `downloadDirectory`, so
// this one fails before it starts another file.
const runLockedBackup = async ( const runLockedBackup = async (
lib: BackupLibrary, lib: BackupLibrary,
opts: BackupOptions, opts: BackupOptions,
downloadDirectory: string, downloadDirectory: string,
lock: BackupLock,
): Promise<BackupResult> => { ): Promise<BackupResult> => {
const includeOriginals = opts.includeOriginals ?? true; const includeOriginals = opts.includeOriginals ?? true;
const includeThumbnails = opts.includeThumbnails ?? false; const includeThumbnails = opts.includeThumbnails ?? false;
@@ -540,7 +527,6 @@ const runLockedBackup = async (
log("Refreshing library..."); log("Refreshing library...");
await lib.refresh(); await lib.refresh();
if (lock.lost) throw lock.lost;
const collectionsDir = join(downloadDirectory, "collections"); const collectionsDir = join(downloadDirectory, "collections");
const thumbnailsDir = join(downloadDirectory, "thumbnails"); const thumbnailsDir = join(downloadDirectory, "thumbnails");
@@ -730,25 +716,13 @@ 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. Once a worker has thrown, // iterator they all share until none is left. A worker that throws, as
// as one does when `onProgress` throws, or the lock is lost, no worker // one does when `onProgress` throws, fails the backup only once every
// takes another file. The backup fails only once every worker has // worker has stopped, so none 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) await storeOriginal(file);
if (workerThrew || lock.lost) return;
try {
await storeOriginal(file);
} catch (err) {
workerThrew = true;
throw err;
}
}
}; };
const workers = await Promise.allSettled( const workers = await Promise.allSettled(
Array.from({ length: lib.contentConcurrency }, worker), Array.from({ length: lib.contentConcurrency }, worker),
@@ -756,12 +730,10 @@ 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) {
for (const [fileID, file] of distinct) { for (const [fileID, file] of distinct) {
if (lock.lost) throw lock.lost;
const dest = join(thumbnailsDir, `${fileID}.jpg`); const dest = join(thumbnailsDir, `${fileID}.jpg`);
if (isPresent(dest)) continue; if (isPresent(dest)) continue;
try { try {
@@ -794,7 +766,6 @@ const runLockedBackup = async (
log(`FAILED ML data: ${mlDataError}`); log(`FAILED ML data: ${mlDataError}`);
} }
for (const file of distinct.values()) { for (const file of distinct.values()) {
if (lock.lost) throw lock.lost;
const stored = storedAtSavePath(downloadDirectory, file); const stored = storedAtSavePath(downloadDirectory, file);
if (stored === undefined) continue; if (stored === undefined) continue;
const path = withExtension( const path = withExtension(
@@ -838,7 +809,6 @@ const runLockedBackup = async (
const albumDirNames = new Map( const albumDirNames = new Map(
allCollections.map((c, i) => [c.id, dirNames[i]!]), allCollections.map((c, i) => [c.id, dirNames[i]!]),
); );
if (lock.lost) throw lock.lost;
try { try {
removeStaleAlbumDirs( removeStaleAlbumDirs(
collectionsDir, collectionsDir,
@@ -850,7 +820,6 @@ const runLockedBackup = async (
} }
for (const c of collections) { for (const c of collections) {
if (lock.lost) throw lock.lost;
const colDirName = albumDirNames.get(c.id)!; const colDirName = albumDirNames.get(c.id)!;
const colDir = join(collectionsDir, colDirName); const colDir = join(collectionsDir, colDirName);
mkdirSync(colDir, { recursive: true }); mkdirSync(colDir, { recursive: true });
@@ -908,9 +877,7 @@ const runLockedBackup = async (
// its failure resolved; a file gone from the library (deleted) or outside // its failure resolved; a file gone from the library (deleted) or outside
// this run's scope is not something this run can resolve, so keeping its // this run's scope is not something this run can resolve, so keeping its
// stale entry would keep the exit code non-zero forever — a single // stale entry would keep the exit code non-zero forever — a single
// since-deleted photo would fail every future scheduled backup. A backup // since-deleted photo would fail every future scheduled backup.
// whose lock was lost leaves the ledger as it is.
if (lock.lost) throw lock.lost;
for (const fileID of [...ledger.keys()]) { for (const fileID of [...ledger.keys()]) {
if (!failedThisRun.has(fileID)) ledger.delete(fileID); if (!failedThisRun.has(fileID)) ledger.delete(fileID);
} }
@@ -931,36 +898,18 @@ const runLockedBackup = async (
// Only one backup of a directory runs at a time, in this process or another. // Only one backup of a directory runs at a time, in this process or another.
// Creates `downloadDirectory` if it is missing, takes the lock in it and // Creates `downloadDirectory` if it is missing, takes the lock in it and
// returns it; while another backup holds the lock, fails at once with an error // returns the function that releases it; while another backup holds the lock,
// whose `code` is `ELOCKED`. The lock is the directory `backup.lock`, whose // fails at once with an error whose `code` is `ELOCKED`. The lock is the
// modification time proper-lockfile keeps current while it is held. One it has // directory `backup.lock`, whose modification time proper-lockfile keeps
// not touched for 10 seconds was left by a run that could not remove it, and // current while it is held. One it has not touched for 10 seconds was left by
// is taken over. // a run that could not remove it, and is taken over.
//
// proper-lockfile finds the lock lost when `backup.lock` is gone or is another
// backup's, and calls `onCompromised` from a timer, where throwing would end
// the process. The `onCompromised` given here sets the lock's `lost` instead,
// and the backup holding the lock stops before it starts another file (see
// `runLockedBackup`). Releasing a lost lock leaves `backup.lock` alone, since
// it may be another backup's.
export const lockBackupDirectory = async ( export const lockBackupDirectory = async (
downloadDirectory: string, downloadDirectory: string,
): Promise<BackupLock> => { ): Promise<() => Promise<void>> => {
mkdirSync(downloadDirectory, { recursive: true }); mkdirSync(downloadDirectory, { recursive: true });
let release: () => Promise<void>;
const lock: BackupLock = {
release: async () => {
if (lock.lost === undefined) await release();
},
};
try { try {
release = await lockfile.lock(downloadDirectory, { return await lockfile.lock(downloadDirectory, {
lockfilePath: join(downloadDirectory, "backup.lock"), lockfilePath: join(downloadDirectory, "backup.lock"),
onCompromised: (err) => {
lock.lost = new Error(
`lost the lock on ${downloadDirectory}: ${err.message}`,
);
},
}); });
} catch (err) { } catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ELOCKED") throw err; if ((err as NodeJS.ErrnoException).code !== "ELOCKED") throw err;
@@ -969,11 +918,10 @@ export const lockBackupDirectory = async (
{ code: "ELOCKED" }, { code: "ELOCKED" },
); );
} }
return lock;
}; };
// Runs the backup holding the lock, which it takes and releases itself unless // Runs the backup holding the lock, which it takes and releases itself unless
// the caller passes the lock it took (`opts.lock`). // the caller already holds it (`opts.lockHeld`).
export const runBackup = async ( export const runBackup = async (
lib: BackupLibrary, lib: BackupLibrary,
opts: BackupOptions, opts: BackupOptions,
@@ -993,13 +941,13 @@ export const runBackup = async (
`contentConcurrency must be a positive integer, got ${contentConcurrency}`, `contentConcurrency must be a positive integer, got ${contentConcurrency}`,
); );
} }
if (opts.lock) { if (opts.lockHeld) {
return runLockedBackup(lib, opts, downloadDirectory, opts.lock); return runLockedBackup(lib, opts, downloadDirectory);
} }
const lock = await lockBackupDirectory(downloadDirectory); const release = await lockBackupDirectory(downloadDirectory);
try { try {
return await runLockedBackup(lib, opts, downloadDirectory, lock); return await runLockedBackup(lib, opts, downloadDirectory);
} finally { } finally {
await lock.release(); await release();
} }
}; };
+5 -5
View File
@@ -20,7 +20,7 @@ import {
type ClientSnapshot, type ClientSnapshot,
type LoginOptions, type LoginOptions,
} from "./client.js"; } from "./client.js";
import { lockBackupDirectory, type BackupLock } from "./backup.js"; import { lockBackupDirectory } from "./backup.js";
import { init } from "./crypto/index.js"; import { init } from "./crypto/index.js";
import { import {
defaultCacheDirectory, defaultCacheDirectory,
@@ -416,9 +416,9 @@ export const backupCommand = async (
ctx.stderr.write("Starting backup...\n"); ctx.stderr.write("Starting backup...\n");
// The lock is taken before the library opens and starts its refresh, so a // The lock is taken before the library opens and starts its refresh, so a
// run refused while another backup of `dir` runs sends no request. // run refused while another backup of `dir` runs sends no request.
let lock: BackupLock; let release: () => Promise<void>;
try { try {
lock = await lockBackupDirectory(dir); release = await lockBackupDirectory(dir);
} catch (err) { } catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ELOCKED") throw err; if ((err as NodeJS.ErrnoException).code !== "ELOCKED") throw err;
ctx.stderr.write(`quak: ${(err as Error).message}\n`); ctx.stderr.write(`quak: ${(err as Error).message}\n`);
@@ -440,7 +440,7 @@ export const backupCommand = async (
try { try {
const result = await lib.backup({ const result = await lib.backup({
downloadDirectory: dir, downloadDirectory: dir,
lock, lockHeld: true,
verify: opts.verify, verify: opts.verify,
onProgress: (msg) => { onProgress: (msg) => {
if (!opts.json) ctx.stderr.write(msg + "\n"); if (!opts.json) ctx.stderr.write(msg + "\n");
@@ -477,7 +477,7 @@ export const backupCommand = async (
await lib.close(); await lib.close();
} }
} finally { } finally {
await lock.release(); await release();
} }
}; };
-1
View File
@@ -69,7 +69,6 @@ export {
type EnsureEvent, type EnsureEvent,
lockBackupDirectory, lockBackupDirectory,
runBackup, runBackup,
type BackupLock,
type BackupOptions, type BackupOptions,
type BackupResult, type BackupResult,
type BackupError, type BackupError,
+3 -5
View File
@@ -96,7 +96,6 @@ import { runBackup, type BackupOptions, type BackupResult } from "../backup.js";
export { export {
lockBackupDirectory, lockBackupDirectory,
runBackup, runBackup,
type BackupLock,
type BackupOptions, type BackupOptions,
type BackupResult, type BackupResult,
type BackupError, type BackupError,
@@ -571,10 +570,9 @@ export class Library {
// Back up every in-scope file to `opts.downloadDirectory`, or else the // Back up every in-scope file to `opts.downloadDirectory`, or else the
// library's, each original at its save path, with a durable failure // library's, each original at its save path, with a durable failure
// ledger (issue #51). Takes the lock in that directory first, unless the // ledger (issue #51). Takes the lock in that directory first, unless
// caller passes the lock it took as `opts.lock`, failing at once while // `opts.lockHeld` says the caller holds it, failing at once while another
// another backup of it runs, and before it starts another file once the // backup of it runs (see `runBackup`). Waits for a
// lock is lost (see `lockBackupDirectory`). Waits for a
// completed refresh, as `fresh()` does, joining one already running, and // completed refresh, as `fresh()` does, joining one already running, and
// rejects before touching any file but the lock when it fails. Then puts // rejects before touching any file but the lock when it fails. Then puts
// pending originals at their save paths as `Photo.download()` does (and // pending originals at their save paths as `Photo.download()` does (and
+19 -97
View File
@@ -397,33 +397,19 @@ 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. From then on the backup starts no further original. It finishes the // it. It rejects only once it has finished with every original it was
// ones it is working on, and rejects only once they are in place, so // working on, so nothing is written into the backup after it has ended
// nothing is written into the backup after it has ended and removed its // and removed its lock. Here, with a content pool of eight, all eight
// lock. Here the first five downloads wait. Photo 103's is let go first // downloads wait at once. Photo 103's is let go first and answered 404,
// and answered 404, and the line reporting that failure throws while the // and the line reporting that failure throws while the other seven wait.
// other four wait. Only those five downloads are held: a backup that went it("when onProgress throws, rejects only once the originals it was working on are in place", async () => {
// 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 downloads: number[] = []; const holding = holdingServer(103);
const held: { id: number; release: () => void }[] = []; const lib = await openLibrary(
const fetch: typeof globalThis.fetch = async (input, init) => { dir,
const id = downloadOf(input); holding.fetch,
if (id !== undefined) { new RequestPools({ contentConcurrency: 8 }),
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({
@@ -446,12 +432,12 @@ describe("lib.backup()", () => {
return err; return err;
}); });
await vi.waitFor(() => expect(held).toHaveLength(5), { await vi.waitFor(() => expect(holding.held).toHaveLength(8), {
interval: 5, interval: 5,
timeout: 5000, timeout: 5000,
}); });
const at103 = held.findIndex((h) => h.id === 103); const at103 = holding.held.findIndex((h) => h.id === 103);
held.splice(at103, 1)[0]!.release(); holding.held.splice(at103, 1)[0]!.release();
await vi.waitFor( await vi.waitFor(
() => () =>
expect( expect(
@@ -459,84 +445,20 @@ describe("lib.backup()", () => {
).toBe(true), ).toBe(true),
{ interval: 5, timeout: 5000 }, { interval: 5, timeout: 5000 },
); );
for (const download of held) download.release(); for (const download of holding.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(downloads.sort()).toEqual([101, 102, 103, 104, 105]); expect(storedWhenRejected).toEqual(
expect(storedWhenRejected).toEqual([101, 102, 104, 105]); PHOTO_IDS.filter((id) => id !== 103),
);
expect(lockedWhenRejected).toBe(false); expect(lockedWhenRejected).toBe(false);
} finally { } finally {
await lib.close(); await lib.close();
} }
}); });
// 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<void>((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 // 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 // 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: // one original at a time, and with the default pools, end the same way:
+2 -202
View File
@@ -9,8 +9,7 @@
* data this file encrypts with libsodium, as Ente's clients encrypt it. So * data this file encrypts with libsodium, as Ente's clients encrypt it. So
* quak's own code fetches each original, decrypts it, checks it against the * quak's own code fetches each original, decrypts it, checks it against the
* hash its uploader recorded and unpacks a live photo, and quak's own retries * hash its uploader recorded and unpacks a live photo, and quak's own retries
* repeat a failed request. Two tests run `lib.backup()` instead, as a program * repeat a failed request.
* using quak as a library does.
* *
* The account, user 42, has three albums: * The account, user 42, has three albums:
* *
@@ -25,7 +24,6 @@
import { import {
existsSync, existsSync,
lstatSync, lstatSync,
mkdirSync,
mkdtempSync, mkdtempSync,
readdirSync, readdirSync,
readFileSync, readFileSync,
@@ -38,16 +36,7 @@ import { join } from "node:path";
import { Writable } from "node:stream"; import { Writable } from "node:stream";
import { gzipSync } from "node:zlib"; import { gzipSync } from "node:zlib";
import sodium from "libsodium-wrappers-sumo"; import sodium from "libsodium-wrappers-sumo";
import { import { afterAll, beforeAll, describe, expect, it } from "vitest";
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { import {
backupCommand, backupCommand,
@@ -58,7 +47,6 @@ import { run } from "../../src/cli-run.js";
import { loadSession } from "../../src/cli-session.js"; import { loadSession } from "../../src/cli-session.js";
import { Client } from "../../src/client.js"; import { Client } from "../../src/client.js";
import { init, toBase64 } from "../../src/crypto/index.js"; import { init, toBase64 } from "../../src/crypto/index.js";
import { Library, lockBackupDirectory } from "../../src/library/index.js";
import { UNATTENDED_RETRY_OPTIONS } from "../../src/retry.js"; import { UNATTENDED_RETRY_OPTIONS } from "../../src/retry.js";
import { import {
buildAccount, buildAccount,
@@ -645,191 +633,3 @@ describe("quak backup", () => {
expect(stderr).toContain(" Verified: 2\n Mismatched: 1\n"); expect(stderr).toContain(" Verified: 2\n Mismatched: 1\n");
}); });
}); });
// `backup.lock` is a directory that proper-lockfile touches every 5 seconds,
// from a timer, while the backup runs. Each time, it first looks at the lock
// on disk. The lock is lost when it is gone, or when another backup has made
// its own in its place, as one does with a lock nobody touched for 10
// seconds. Another backup may then be writing to the directory, so the backup
// stops before it starts another file.
//
// The fake server holds one file's download open until the test lets it go,
// so the lock can be lost while the backup runs: beach.jpg's, the first file
// backed up, unless the test names another. Only `setTimeout` and
// `clearTimeout` are faked, so a test can fire proper-lockfile's timer without
// waiting 5 seconds.
describe("a backup whose lock is lost", () => {
let dir: string;
let lockPath: string;
let downloads: number[];
let heldFile: number;
let heldRequested: Promise<void>;
let releaseHeld: () => void;
let fetch: typeof globalThis.fetch;
beforeEach(() => {
dir = mkdtempSync(join(root, "backup-"));
lockPath = join(dir, "backup.lock");
downloads = [];
heldFile = BEACH.id;
let requested!: () => void;
heldRequested = new Promise((resolve) => {
requested = resolve;
});
const released = new Promise<void>((resolve) => {
releaseHeld = resolve;
});
const recording = recordingDownloads(downloads);
fetch = async (input, init) => {
if (downloadOf(input) === heldFile) {
requested();
await released;
}
return recording(input, init);
};
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
});
afterEach(() => {
vi.useRealTimers();
});
// 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`.
const fireLockTimer = async (): Promise<void> => {
vi.advanceTimersByTime(5_000);
while (process.getActiveResourcesInfo().includes("FSReqCallback")) {
await new Promise((resolve) => setImmediate(resolve));
}
};
// 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 });
await fireLockTimer();
releaseHeld();
const { code, stderr } = await result;
expect(code).toBe(1);
expect(stderr).toBe(
"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" +
`quak: lost the lock on ${dir}: ENOENT: no such file or directory, stat '${lockPath}'\n`,
);
expect(downloads.sort()).toEqual([100, 200, 300]);
expect(readFileSync(join(dir, DAY, "2024-03-01.100.jpg"))).toEqual(
Buffer.from(JPEG_WITH_EXIF),
);
});
// Here the lock is lost while toast.mp4, the last original, downloads.
// All three originals are stored, beside what the backup wrote before it
// started them, but no file's JSON and no album is written.
it("stops quak backup before any file's JSON when the lock is lost during the last original", async () => {
heldFile = TOAST_VIDEO.id;
const result = backup(dir, {}, fetch);
await heldRequested;
rmSync(lockPath, { recursive: true });
await fireLockTimer();
releaseHeld();
const { code, stderr } = await result;
expect(code).toBe(1);
expect(stderr).toContain(`quak: lost the lock on ${dir}: `);
expect(listTree(dir)).toEqual([
"2024",
"2024/2024-03",
"2024/2024-03/2024-03-01",
"2024/2024-03/2024-03-01/2024-03-01.100.jpg",
"2024/2024-03/2024-03-01/2024-03-01.200.mp4",
"2024/2024-03/2024-03-01/2024-03-01.300.heic",
"2024/2024-03/2024-03-01/2024-03-01.300.livephoto.json",
"2024/2024-03/2024-03-01/2024-03-01.300.mov",
"account.json",
"collections",
]);
});
// Nothing touches the lock but quak, which finds it as it left it each
// time it looks, so the backup runs to the end and removes its lock.
it("lets quak backup run to the end while its lock stays", async () => {
const result = backup(dir, {}, fetch);
await heldRequested;
await fireLockTimer();
await fireLockTimer();
releaseHeld();
const { code } = await result;
expect(code).toBe(0);
expect(downloads.sort()).toEqual([100, 200, 300]);
expect(existsSync(lockPath)).toBe(false);
});
// The library a program using quak opens to back `dir` up.
const openLibrary = () =>
Library.open({
client: loadSession(join(sessionDir, "session.json"), { fetch })!,
cacheDirectory: `${dir}-cache`,
downloadDirectory: dir,
refreshIntervalSeconds: Infinity,
precacheThumbnails: false,
precacheOriginals: false,
});
// A program using quak as a library sees the same: `lib.backup()` rejects
// with the reason. Here another backup has made its own lock in place of
// quak's, and that lock is left where it is.
it("rejects lib.backup() when another backup has taken the lock over", async () => {
const lib = await openLibrary();
try {
const result = lib.backup();
await heldRequested;
rmSync(lockPath, { recursive: true });
mkdirSync(lockPath);
await fireLockTimer();
releaseHeld();
await expect(result).rejects.toThrow(
`lost the lock on ${dir}: Unable to update lock within the stale threshold`,
);
expect(downloads.sort()).toEqual([100, 200, 300]);
expect(existsSync(lockPath)).toBe(true);
} finally {
await lib.close();
}
});
// A program can take the lock before it opens its library, as `quak
// backup` does: `lockBackupDirectory` returns the lock, which the program
// passes to `lib.backup()` and releases once the backup ends. The backup
// checks it as it checks a lock it took, and the lock's `lost` says why it
// was lost.
it("rejects lib.backup() given its caller's lock once that lock is lost", async () => {
const lock = await lockBackupDirectory(dir);
const lib = await openLibrary();
try {
const result = lib.backup({ lock });
await heldRequested;
rmSync(lockPath, { recursive: true });
await fireLockTimer();
releaseHeld();
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.sort()).toEqual([100, 200, 300]);
} finally {
await lib.close();
await lock.release();
}
});
});