Compare commits

1 Commits
Author SHA1 Message Date
sneak 0be9ec7b32 A network failure is printed and recorded with its reason (closes #196)
check / check (push) Waiting to run
Node's `fetch` rejects every failure below HTTP as `fetch failed` and puts
the reason on `cause`. `describeError` in `src/errors.ts` gives the message
followed by the messages on the cause chain, and for an `AggregateError`,
which Node gives when every address of a host fails, the message of each
address. `run` prints it, and the backup logs and records it. `classify`
reads that text and calls a failure transient when `hasTransportCode`, now
exported by `src/retry.ts`, finds a transport errno on the chain. A failed
ML data fetch keeps its error as the cause. The bounded walk along the
chain moved from `src/retry.ts` to `src/errors.ts`.

A truncated download's message leaves the authentication failure to its
cause.

Model: opus-5-5
2026-10-16 00:31:35 +00:00
19 changed files with 293 additions and 1550 deletions
+47 -106
View File
@@ -134,12 +134,12 @@ matters.
### Exit codes
| Code | Meaning |
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `0` | The backup is complete: every original is at its save path, and no file failed. |
| `1` | The run finished with files in `failures.json` or albums it could not decrypt or write (see "Backup layout"), or it stopped on another error, printed as one line `quak: <message>`. The next run tries again. |
| `2` | Another backup of the same directory is running. This run sent no request and changed nothing. |
| `3` | There is no usable session: none is saved, the saved one is corrupt, or the server no longer accepts it. Run `quak login` as the user the cron job runs as. |
| Code | Meaning |
| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `0` | The backup is complete: every original is at its save path, and no file failed. |
| `1` | The run finished with files in `failures.json` or an album it could not decrypt, or it stopped on another error, printed as one line `quak: <message>`. The next run tries again. |
| `2` | Another backup of the same directory is running. This run sent no request and changed nothing. |
| `3` | There is no usable session: none is saved, the saved one is corrupt, or the server no longer accepts it. Run `quak login` as the user the cron job runs as. |
`<message>` is the error's message, followed by the message of each error that
caused it. Under Node, as `yarn quak` runs quak, `fetch` reports every failure
@@ -151,19 +151,16 @@ for each, separated by `, `. A file whose download failed that way is in
`failures.json` with that error, as a transient failure. So is each file with no
ML data cached when the ML data fetch failed that way, its error starting
`ML data: `. The `~/bin/quak` that `make install` builds runs under bun, and
prints bun's own wording for such a failure, with no reason after it, such as
`quak: Unable to connect. Is the computer able to access the url?` for a refused
connection. It records such a failure in `failures.json` as transient too.
prints bun's own wording for such a failure.
After a `1`, the next run fetches each missing original again, fetches the ML
data that is not cached, and rebuilds the album links. An album quak could not
decrypt, or a file in `failures.json` because quak could not decrypt it, keeps
the exit code at `1` until the server sends a copy that decrypts, or deletes it
(see "Backup layout"). An original that a `--verify` run could not read, or
could not replace with a download that matches, stays at its save path as it
was. Only a later `--verify` run checks it again: a run without `--verify`
leaves it as it is and takes the file out of `failures.json`, so that run can
exit 0.
(see "Backup layout"). An original that a `--verify` run could not read, or put
back with bytes that still do not match, stays at its save path. Only a later
`--verify` run checks it again: a run without `--verify` leaves it as it is and
takes the file out of `failures.json`, so that run can exit 0.
## Examples
@@ -497,17 +494,6 @@ request is repeated only when repeating it could produce a different answer:
declining to retry a transient one costs a single file that the next run picks
up.
The `~/bin/quak` that `make install` builds runs under bun, whose `fetch`
rejects with a plain `Error` that has no `cause` and carries a code of bun's
own. quak retries a refused connection, a name that does not resolve, a reset
connection and a deadline under bun as it does under Node. Bun reports a refused
connection, and a TLS handshake that failed, as `ConnectionRefused`. For a name
that does not resolve it gives `ConnectionRefused` on some requests and
`FailedToOpenSocket` on others. A connection reset or closed before the response
ended is `ECONNRESET`, as under Node, and a deadline is the same `TimeoutError`.
A certificate bun rejects, reported with a code such as
`DEPTH_ZERO_SELF_SIGNED_CERT`, is not retried.
Backoff is exponential with full jitter: the delay before retry _n_ is
`random() * min(maxDelayMs, baseDelayMs * 2 ** (n - 1))`. The exponential term
is the ceiling and the wait is drawn below it, so a client that lost many
@@ -563,13 +549,9 @@ routing errnos `EHOSTUNREACH`, `ENETUNREACH` and `ENETDOWN` are excluded for the
same reason, despite looking like connect-time failures: on Linux an ICMP
unreachable arriving mid-flight, or a local interface going down after the
request was written, delivers them on an already-established socket. They stay
retryable for the idempotent calls. So do bun's `ConnectionRefused` and
`FailedToOpenSocket`, on which a `POST` or `PUT` is not replayed either. When a
connection bun kept open from an earlier request closes before any answer, bun
sends the request again on a new connection, and if that one fails it gives one
of these codes although the server may already have the request. `putFile` is
exempt: a presigned PUT stores one whole object at one key in one request, so
replaying it has no partial state to damage.
retryable for the idempotent calls. `putFile` is exempt: a presigned PUT stores
one whole object at one key in one request, so replaying it has no partial state
to damage.
A download is retried as a whole — request, stream consumption, and decryption —
because a socket reset after the response headers have arrived surfaces in the
@@ -693,15 +675,13 @@ reads no tag from is recorded, base64, as `exifRaw`, with the reason in
and `helper fix-missing-thumbnails` take `--json` for machine-readable output.
`backup --verify` also hashes the originals already in the backup and replaces
any that do not match the content hash Ente records; one it cannot replace stays
as it was and goes into `failures.json` (see "Backup layout").
any that do not match the content hash Ente records; one it cannot replace goes
into `failures.json` (see "Backup layout").
`backup-metadata` fetches ML data in requests of up to 200 files. When a request
fails, the error is logged, each of its files is written with the reason in an
`mlDataError` field instead of `mlData`, and the dump goes on. A file whose ML
data does not decrypt, gunzip or parse is logged and written the same way, with
why in `mlDataError`. The exit code is non-zero if any file was written with
`mlDataError`.
`mlDataError` field instead of `mlData`, and the dump goes on. The exit code is
non-zero if any ML data request failed.
`helper fix-missing-thumbnails` regenerates thumbnails for JPEG images only,
because the bundled decoder (`jpeg-js`) decodes only JPEG. A non-JPEG image
@@ -766,15 +746,8 @@ A file's JSON holds Ente's ML data for it (its faces and its CLIP embedding) as
for has no `mlData`. The backup waits for the library's ML data fetch to finish
before it writes the JSON files. If that fetch fails, each file whose ML data is
not in the cache gets the reason in `mlDataError` instead and counts as failed,
and the next run fetches it again. The same goes for a file with no ML data in
the cache whose ML data the server sent but quak could not decrypt, gunzip or
parse. Its `mlDataError` says why, and `failures.json` records it with an error
starting `ML data: `. quak keeps that reason in memory only and caches nothing
for the file, so the next run asks for its ML data again. An ML data fetch that
fails, such as one the library's refresh starts while the backup writes the JSON
files, leaves the reasons as the last fetch that succeeded left them. The JSON
files are rewritten on every run, so ML data that arrived since the last run
appears.
and the next run fetches it again. The JSON files are rewritten on every run, so
ML data that arrived since the last run appears.
A file's JSON holds, as `imageMetadata`, what `backup-metadata --exif` records
from its original, or from a live photo's image: `format`, `width` and `height`
@@ -837,23 +810,8 @@ 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
name (ignoring case in both), each of them gets its ID added: two albums named
`Trip` become `Trip (10)/` and `Trip (11)/`, and two files titled `IMG_0001.JPG`
become `IMG_0001 (12345).JPG` and `IMG_0001 (12346).JPG`. A collection whose
directory would have the name of another collection's JSON, again ignoring case,
gets its ID added as well: beside an album `x`, whose JSON is `x.json`, an album
named `x.json` gets the directory `x.json (12)/`. A name too long for a file
name, which most file systems limit to 255 bytes, gets its ID added too. A name
with its ID added is cut short between two characters in the part before the ID,
just far enough that it fits. When a symlink's extension alone leaves no room
for the ID, the extension is cut short and the part before the ID keeps only the
bytes that cut leaves over. A collection's directory name also leaves room for
the `.json` its JSON adds. IDs never change, so a name stays the same from run
to run until such a clash appears or goes away.
A collection whose directory or JSON cannot be written, such as one whose
directory name is taken by a file you put in `collections/`, does not stop the
run. A run without `--json` prints `FAILED album <name>: <reason>` for it,
`--json` and the `BackupResult` list its ID in `failedAlbums`, and the exit code
is 1. The other collections and `failures.json` are still written.
become `IMG_0001 (12345).JPG` and `IMG_0001 (12346).JPG`. IDs never change, so a
name stays the same from run to run until such a clash appears or goes away.
A live photo, which Ente stores as one ZIP of its image and its video, is stored
as those two files, which a photo viewer can open: each is
@@ -888,41 +846,33 @@ original already at its save path the way a download is checked (see "On-disk
cache layout" below): its bytes, read in chunks, or a live photo's image and
video, joined as `<imageHash>:<videoHash>`. An original that matches the content
hash its metadata records is left as it is. One that does not is logged on one
line naming the file, and replaced once a matching copy is downloaded, in the
same run. The copy is downloaded even when the cache holds the original, because
a copy from the cache is not checked as a download is. It is written to a
temporary file beside the original, a live photo's image and video each to its
own, hashed the same way, and renamed over the original only when it matches; a
live photo's image and video are both hashed before either is renamed. Until
then the original stays as it was: when the download fails, or what it brings
does not match either, the temporary files are deleted, the original keeps its
bytes and its album links, and the file goes into `failures.json`. A run that
stops between renaming a live photo's new image and its new video leaves the new
image beside the old video, which a later `--verify` run replaces if the video
was the part that did not match. A file whose metadata records no hash is left
as it is and counted as unchecked. A stored original that cannot be read is left
as it is and counts as failed. The summary and `--json` add the counts
`verified`, `mismatched` and `unchecked`, all of originals that were already
stored; one first downloaded in this run is in none of them. A mismatch that was
replaced with matching bytes does not make the exit code non-zero. Without
`--verify` nothing is hashed, the summary is unchanged, and the three counts are
0 in `--json`.
line naming the file, deleted (a live photo's image and video both), and put
back in the same run like a missing one: downloaded, or copied from the cache if
the cache holds it. What is put back is hashed too, because a copy from the
cache is not checked as a download is. If it still does not match, it stays at
its save path and the file goes into `failures.json`, as it does when the
download fails. A file whose metadata records no hash is left as it is and
counted as unchecked. A stored original that cannot be read is left as it is and
counts as failed. The summary and `--json` add the counts `verified`,
`mismatched` and `unchecked`, all of originals that were already stored; one
first downloaded in this run is in none of them. A mismatch that was put back
with matching bytes does not make the exit code non-zero. Without `--verify`
nothing is hashed, the summary is unchanged, and the three counts are 0 in
`--json`.
Each original is written to a temporary file in the same directory, synced to
disk, and renamed into place, so an original is either complete or absent, even
after a power cut. A downloaded original's temporary file is named
`.quak-<pid>-<random>.tmp`, one copied from the cache
`.quak-backup-YYYY-MM-DD.<fileID>.<ext>-<pid>-<random>.tmp`. A copy that
`--verify` downloads to replace an original is written to such a
`.quak-<pid>-<random>.tmp` too, a live photo's image and video to
`.quak-<pid>-<random>.<ext>`, each with its own extension. A run that is killed
can leave one of these temporary files behind; the next backup deletes those
whose process is no longer running. The content cache uses the same scheme, and
opening a library deletes the temporary files in the cache whose process is no
longer running, so a download another process has in progress in the same cache
is left alone. The rename replaces whatever was at the destination rather than
writing through it: a symlink there is replaced, not followed, and the new file
has the temporary file's permissions, not those of the file it replaced.
`.quak-backup-YYYY-MM-DD.<fileID>.<ext>-<pid>-<random>.tmp`. A run that is
killed can leave one of these temporary files behind; the next backup deletes
those whose process is no longer running. The content cache uses the same
scheme, and opening a library deletes the temporary files in the cache whose
process is no longer running, so a download another process has in progress in
the same cache is left alone. The rename replaces whatever was at the
destination rather than writing through it: a symlink there is replaced, not
followed, and the new file has the temporary file's permissions, not those of
the file it replaced.
## TODO
@@ -996,14 +946,6 @@ resolves once that refresh (including its cache write), the ML data fetch and
the precache fetches already running have all finished, so the cache directory
can then be removed.
`lib.status().mlDataErrors` lists, as `{ fileID, error }`, each file whose ML
data the last ML data fetch that succeeded got but could not decrypt, gunzip or
parse; the `fetchMLData` event with status `"done"` carries the same list. A
fetch that is still running leaves the list as it is until it ends, and a fetch
that fails leaves it as it was. Such ML data is not cached, so the next ML data
fetch asks for it again. The reasons are kept in memory only, never in the
cache.
### Default reads vs. fresh reads
Default reads — `lib.albums`, `lib.photos`, `lib.timeline` — answer
@@ -1163,10 +1105,9 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
`downloadDirectory` (falls back to the library's), `includeOriginals` (default
`true`), `includeThumbnails` (default `false`), `onlyAlbumNames`, `verify`
(default `false`), `onProgress`, and `lock`. See Backup layout above for the
tree it writes, and for the albums `BackupResult` lists in
`undecryptableAlbums` and `failedAlbums`. 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.
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
+5 -48
View File
@@ -17,60 +17,17 @@ pre-1.0
# Next Step
One bug is open: https://git.eeqj.de/sneak/quak/issues/205. 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.
Three bugs are open: https://git.eeqj.de/sneak/quak/issues/197,
https://git.eeqj.de/sneak/quak/issues/198 and
https://git.eeqj.de/sneak/quak/issues/199. 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-16: ML data the server sends that does not decrypt, gunzip or parse is
no longer taken for no ML data (issue 198). `fetchMLDataBatch` returns why for
each such file beside the payloads; ML data that parses to something other
than a JSON object, such as `null`, is one of them. The library keeps the
reasons from its last ML data fetch that succeeded in memory, not in the
cache, and gives them in `status().mlDataErrors` and in the `fetchMLData`
event with status `"done"`; a fetch still running or one that fails does not
change them. `quak backup` writes the reason as `mlDataError` in the file's
JSON when no ML data is cached for it, and records the file in `failures.json`
with an error starting `ML data: `; `backup-metadata` writes `mlDataError`
too. Both exit 1. Tests serve ML data encrypted under a key that is not the
file's.
- 2026-10-16: With `--verify`, an original whose hash does not match is no
longer removed before its replacement is downloaded (issue 199).
`replaceOriginal` in `src/library/content.ts` downloads the replacement to a
temporary file beside it, a live photo's image and video each to its own,
hashes it and renames it over the original only when it matches. When the
download fails or does not match either, the original keeps its bytes and
links, and the file goes into `failures.json`. The replacement is always
downloaded, through the new `fetchOriginal` of the content cache, never copied
from the cache.
- 2026-10-16: The `~/bin/quak` that `make install` builds retries a refused
connection and a name that does not resolve, and the backup records either as
transient (issue 206). Bun's `fetch` reports them with codes of its own,
`ConnectionRefused` and `FailedToOpenSocket`, which `src/retry.ts` now counts
as transport failures. A `POST` or `PUT` is not replayed on them, because bun
also gives them when it resends a request on a new connection after a
connection it kept open closed, and the server may already have the request.
Bun already gave `ECONNRESET` for a reset and a `TimeoutError` for a deadline.
`classify` in `src/backup.ts` now records any `TimeoutError` as transient, so
a stalled download, whose error says only that it stalled, is no longer
`unknown`. The shapes were found by running the binary against local
listeners.
- 2026-10-15: An album whose folder or JSON `quak backup` cannot write no longer
ends the run before the other albums and `failures.json` are written (issue
197). The run prints `FAILED album <name>: <reason>`, lists the album's ID in
`failedAlbums` in the result and in `--json`, goes on, and exits 1. An album
whose folder would have the name of another album's JSON, as `x.json` beside
`x` would, gets its ID added. A folder or link name over 255 bytes is cut
short between two characters, by `shortenToBytes` in `src/filename.ts`, and
gets the ID.
- 2026-10-15: A network failure is printed and recorded with its reason, not as
just `fetch failed` (issue 196). `describeError` in `src/errors.ts` gives an
error's message followed by the message of each error on its `cause` chain,
+124 -151
View File
@@ -41,9 +41,8 @@
//
// With `verify`, each original already at its save path is hashed as the
// download check hashes it, and one that does not match the content hash its
// metadata records is replaced once a matching copy is downloaded, in the same
// run. Until then it stays as it is: a download that fails, or that does not
// match either, leaves it in place and is recorded as failed.
// metadata records is removed and fetched again in the same run. What is put
// back is hashed too, and recorded as failed if it still does not match.
//
// Resilience (issue #8): no per-file condition aborts the run. A failed
// download, a failed symlink, or ML data missing because the ML data fetch
@@ -51,9 +50,7 @@
// running attempt count, and the last-tried time, and the run continues.
// A file the library could not decrypt is recorded there too, unless a copy of
// it in another album decrypted; an album it could not decrypt is logged by ID
// and listed in `result.undecryptableAlbums`, as its name is not known. An
// album whose folder or JSON cannot be written is logged by name and
// listed in `result.failedAlbums`, and the run goes on to the next album.
// and listed in `result.undecryptableAlbums`, as its name is not known.
// `result.failed` — and thus the CLI's exit code — stays non-zero while any
// failure remains unresolved and clears once every one succeeds. Each run
// reconciles the ledger against the files it attempted, so an entry for a file
@@ -62,6 +59,7 @@
// code.
import {
createReadStream,
lstatSync,
mkdirSync,
readdirSync,
@@ -77,21 +75,20 @@ import { readFile } from "node:fs/promises";
import { dirname, extname, join, relative, resolve } from "node:path";
import lockfile from "proper-lockfile";
import {
chunkHashFinal,
chunkHashInit,
chunkHashUpdate,
init,
} from "./crypto/index.js";
import { removeLeftoverTempFiles } from "./download/index.js";
import { describeError } from "./errors.js";
import {
MAX_NAME_BYTES,
sanitizeFileName,
shortenToBytes,
withExtension,
} from "./filename.js";
import { sanitizeFileName, withExtension } from "./filename.js";
import {
copyAtomic,
placeOriginal,
replaceOriginal,
savePath,
storedAtSavePath,
storedHash,
} from "./library/content.js";
import { representative } from "./library/records.js";
import type { Undecryptable } from "./library/store.js";
@@ -148,9 +145,9 @@ export interface BackupResult {
// Originals already at their save path and left untouched.
skipped: number;
// With `verify`, the originals already at their save path whose hash
// matched, those whose hash did not (each replaced once a matching copy is
// downloaded), and those whose metadata records no hash (left as they
// are). All three are zero without `verify`.
// matched, those whose hash did not (each removed and fetched again), and
// those whose metadata records no hash (left as they are). All three are
// zero without `verify`.
verified: number;
mismatched: number;
unchecked: number;
@@ -163,9 +160,6 @@ export interface BackupResult {
// The IDs of the albums the library could not decrypt, whose files it
// could not list; the CLI exits non-zero while there are any.
undecryptableAlbums: number[];
// The IDs of the albums whose folder or JSON this run could not write;
// the CLI exits non-zero while there are any.
failedAlbums: number[];
}
// The slice of the library that backup drives. `Library` implements it; a test
@@ -188,15 +182,6 @@ export interface BackupLibrary {
fileID: number,
destination: string,
): Promise<{ path: string; videoPath?: string }>;
// Fetch an original's bytes to `destination` through the content pool,
// a live photo as its image and its video beside it, even when the cache
// or the library's save path already holds it, and return where they
// landed. With `verify`, an original whose hash does not match is replaced
// with what this fetches.
fetchOriginal(
fileID: number,
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.
@@ -208,11 +193,6 @@ export interface BackupLibrary {
// A file's cached ML data, as `lib.mldata.forFile()` returns it, or
// undefined when none is cached.
mlData(fileID: number): Promise<MLData | undefined>;
// Why the ML data the last ML data fetch that succeeded got for a file did
// not decrypt, gunzip or parse, or undefined when it did or the fetch got
// none. A fetch still running does not change it. A fetch that fails
// leaves it as it was.
mlDataError(fileID: number): unknown;
}
type FailureClass = "transient" | "permanent" | "unknown";
@@ -243,13 +223,10 @@ const isPresent = (path: string): boolean => {
// are transient; refusals and local filesystem/decrypt errors are permanent;
// anything else is unknown. The error code and the error as `describeError`
// gives it are inspected, and so is each errno on the error's cause chain,
// where Node's `fetch` puts it. An error named `TimeoutError` is transient
// whatever its message says, as `isRetryable` retries it: each of quak's
// deadlines aborts with one, and the download's says only that it stalled.
// where Node's `fetch` puts it.
const classify = (err: unknown): FailureClass => {
if (hasTransportCode(err)) return "transient";
const e = err as NodeJS.ErrnoException;
if (e?.name === "TimeoutError") return "transient";
const text = `${e?.code ?? ""} ${describeError(err)}`.toLowerCase();
if (
/timeout|timed out|econnreset|econnrefused|econnaborted|network|socket|eai_again|throttl|temporarily|429|500|502|503|504/.test(
@@ -287,47 +264,31 @@ const rebuildSymlink = (linkPath: string, target: string): void => {
// The on-disk names for the entries of one directory, in entry order. Each
// name is used as is unless another entry would get the same name, ignoring
// case (two names that differ only in case are one entry on a case-insensitive
// file system), or it is too long for a file name; then it gets ` (<id>)`,
// right before the extension when `beforeExtension` is set. To fit, the part
// before the ID is cut short as far as it must be. When the extension alone
// leaves no room for the ID, the extension is cut short and the part before
// the ID keeps only the bytes that cut leaves over. With `withJSON`, each
// entry also has the JSON file `<name>.json` beside it, as an album does: a
// name then leaves room for `.json`, and one that is another entry's JSON name
// gets its ID added too.
// Two names with different IDs added never match, but one can match another
// entry's own name (`IMG (6).JPG`), so this repeats until no name is shared.
// IDs are stable, so the names are too.
// file system); then every entry sharing it gets ` (<id>)`, before the
// extension when `beforeExtension` is set. A name with an ID added can match
// another entry's own name (`IMG (6).JPG`), so this repeats until no name is
// shared. IDs are stable, so the names are too.
const uniqueNames = (
entries: { id: number; name: string }[],
opts: { beforeExtension?: boolean; withJSON?: boolean },
beforeExtension: boolean,
): string[] => {
const maxBytes = MAX_NAME_BYTES - (opts.withJSON ? ".json".length : 0);
const withID = (id: number, name: string): string => {
const tag = ` (${id})`;
const fullExt = opts.beforeExtension ? extname(name) : "";
const stem = name.slice(0, name.length - fullExt.length);
const ext = shortenToBytes(fullExt, maxBytes - Buffer.byteLength(tag));
const room = maxBytes - Buffer.byteLength(tag + ext);
return shortenToBytes(stem, room) + tag + ext;
const ext = beforeExtension ? extname(name) : "";
const stem = name.slice(0, name.length - ext.length);
return `${stem} (${id})${ext}`;
};
const names = entries.map((e) => e.name);
const suffixed = new Set<number>();
for (;;) {
const counts = new Map<string, number>();
const count = (name: string): void => {
for (const name of names) {
const key = name.toLowerCase();
counts.set(key, (counts.get(key) ?? 0) + 1);
};
for (const name of names) {
count(name);
if (opts.withJSON) count(`${name}.json`);
}
let changed = false;
for (const [i, { id, name }] of entries.entries()) {
if (suffixed.has(i)) continue;
const fits = Buffer.byteLength(name) <= maxBytes;
if (fits && counts.get(name.toLowerCase()) === 1) continue;
if (counts.get(name.toLowerCase()) === 1) continue;
names[i] = withID(id, name);
suffixed.add(i);
changed = true;
@@ -454,6 +415,26 @@ const saveLedger = (path: string, ledger: Map<number, FailureEntry>): void => {
);
};
// The content hash of the original stored at `stored`, computed as the download
// check computes it: over each file's bytes, read in chunks, and for a live
// photo `<imageHash>:<videoHash>`.
const storedHash = async (stored: {
path: string;
videoPath?: string;
}): Promise<string> => {
await init();
const hashFile = async (path: string): Promise<string> => {
const state = chunkHashInit();
for await (const chunk of createReadStream(path)) {
chunkHashUpdate(state, chunk as Buffer);
}
return chunkHashFinal(state);
};
const hash = await hashFile(stored.path);
if (stored.videoPath === undefined) return hash;
return `${hash}:${await hashFile(stored.videoPath)}`;
};
// A file's EXIF, XMP and dimensions as its JSON holds them: what
// `extractImageMetadata` found in its original, or why the original could not
// be read.
@@ -675,13 +656,11 @@ const runLockedBackup = async (
// through the content cache/pools, as `Photo.download()` does, and fetch
// the optional thumbnails; a present file is left as is. With `verify`, a
// present original is hashed first, and one that does not match the hash
// its metadata records is downloaded again and replaced by
// `replaceOriginal`, which leaves it as it was when that download fails
// or does not match either. One that cannot be read is recorded as failed
// and left where it is. `storeOriginal` does this for one original, and
// records its failure.
// 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.
const storeOriginal = async (file: EnteFile): Promise<void> => {
const stored = storedAtSavePath(downloadDirectory, file);
let stored = storedAtSavePath(downloadDirectory, file);
let mismatch = false;
if (stored !== undefined && verify) {
try {
@@ -695,6 +674,11 @@ const runLockedBackup = async (
);
mismatched++;
mismatch = true;
rmSync(stored.path);
if (stored.videoPath !== undefined) {
rmSync(stored.videoPath);
}
stored = undefined;
}
} catch (err) {
log(
@@ -709,25 +693,29 @@ const runLockedBackup = async (
return;
}
}
if (stored !== undefined && !mismatch) {
if (stored !== undefined) {
skipped++;
return;
}
try {
log(`Fetching original ${file.metadata.title} (${file.id})...`);
if (mismatch) {
await replaceOriginal(downloadDirectory, file, (dest) =>
lib.fetchOriginal(file.id, dest),
);
} else {
// A fetched original is written straight to its save path (a
// live photo beside it); only one that was already cached
// elsewhere is copied.
await placeOriginal(downloadDirectory, file, (dest) =>
lib.original(file.id, dest),
// 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",
);
}
storedThisRun.add(file.id);
downloaded++;
} catch (err) {
log(
@@ -796,18 +784,19 @@ const runLockedBackup = async (
// Phase 2: rebuild the derived views from the model. Sidecars first, for
// every present original (this repairs stale ones), each with the file's
// ML data once an ML data fetch has completed, and its image metadata.
// When the fetch got a file's ML data but could not decrypt, gunzip or
// parse it, or when the fetch failed, a file with no cached ML data gets
// the reason instead and is recorded as failed. The next run fetches its
// ML data again because none is cached.
// When the fetch fails, a file with no cached ML data gets the reason
// instead and is recorded as failed. The next run fetches its ML data
// again because none is cached.
if (includeOriginals) {
let mlDataFailure: unknown;
let mlDataError: string | undefined;
try {
log("Fetching ML data...");
await lib.fetchMLData();
} catch (err) {
mlDataFailure = err;
log(`FAILED ML data: ${describeError(err)}`);
mlDataError = describeError(err);
log(`FAILED ML data: ${mlDataError}`);
}
for (const file of distinct.values()) {
if (lock.lost) throw lock.lost;
@@ -824,19 +813,13 @@ const runLockedBackup = async (
storedThisRun.has(file.id),
);
const mlData = await lib.mlData(file.id);
const failure = lib.mlDataError(file.id) ?? mlDataFailure;
if (mlData === undefined && failure !== undefined) {
writeSidecar(
path,
file,
{ mlDataError: describeError(failure) },
image,
);
if (mlData === undefined && mlDataError !== undefined) {
writeSidecar(path, file, { mlDataError }, image);
recordFailure(
file.id,
file.metadata.title,
collectionName.get(file.collectionID) ?? "",
new Error("ML data", { cause: failure }),
new Error("ML data", { cause: mlDataFailure }),
);
} else {
writeSidecar(path, file, { mlData }, image);
@@ -855,7 +838,7 @@ const runLockedBackup = async (
id: c.id,
name: sanitizeFileName(c.name, `collection-${c.id}`),
})),
{ withJSON: true },
false,
);
const albumDirNames = new Map(
allCollections.map((c, i) => [c.id, dirNames[i]!]),
@@ -871,69 +854,60 @@ const runLockedBackup = async (
log(`FAILED removing old album directories: ${describeError(err)}`);
}
// An album whose folder or JSON cannot be written, such as one whose
// folder name is taken by a file the user put there, is logged and listed
// in the result, and the run goes on to the next album.
const failedAlbums: number[] = [];
for (const c of collections) {
if (lock.lost) throw lock.lost;
try {
const colDirName = albumDirNames.get(c.id)!;
const colDir = join(collectionsDir, colDirName);
mkdirSync(colDir, { recursive: true });
const colDirName = albumDirNames.get(c.id)!;
const colDir = join(collectionsDir, colDirName);
mkdirSync(colDir, { recursive: true });
// Every album links the one original, saved from the file's entry
// in `distinct`.
const files = filesByCollection.get(c.id) ?? [];
const links = files.flatMap((f) =>
linksFor(
f,
storedAtSavePath(downloadDirectory, distinct.get(f.id)!),
),
// Every album links the one original, saved from the file's entry in
// `distinct`.
const files = filesByCollection.get(c.id) ?? [];
const links = files.flatMap((f) =>
linksFor(
f,
storedAtSavePath(downloadDirectory, distinct.get(f.id)!),
),
);
const linkNames = uniqueNames(links, true);
try {
removeStaleLinks(colDir, new Set(linkNames), downloadDirectory);
} catch (err) {
log(
`FAILED removing old links in ${c.name}: ${describeError(err)}`,
);
const linkNames = uniqueNames(links, { beforeExtension: true });
}
const metaFiles = files.map((f) => ({
id: f.id,
metadata: f.metadata,
}));
for (const [i, link] of links.entries()) {
if (!includeOriginals || link.target === undefined) continue;
const linkName = linkNames[i]!;
try {
removeStaleLinks(colDir, new Set(linkNames), downloadDirectory);
rebuildSymlink(
join(colDir, linkName),
relative(colDir, link.target),
);
} catch (err) {
log(
`FAILED removing old links in ${c.name}: ${describeError(err)}`,
`FAILED symlink ${c.name}/${linkName}: ${describeError(err)}`,
);
recordFailure(
link.file.id,
link.file.metadata.title,
c.name,
err,
);
}
const metaFiles = files.map((f) => ({
id: f.id,
metadata: f.metadata,
}));
for (const [i, link] of links.entries()) {
if (!includeOriginals || link.target === undefined) continue;
const linkName = linkNames[i]!;
try {
rebuildSymlink(
join(colDir, linkName),
relative(colDir, link.target),
);
} catch (err) {
log(
`FAILED symlink ${c.name}/${linkName}: ${describeError(err)}`,
);
recordFailure(
link.file.id,
link.file.metadata.title,
c.name,
err,
);
}
}
writeAlbumJSON(
join(collectionsDir, `${colDirName}.json`),
c,
metaFiles,
);
} catch (err) {
log(`FAILED album ${c.name}: ${describeError(err)}`);
failedAlbums.push(c.id);
}
writeAlbumJSON(
join(collectionsDir, `${colDirName}.json`),
c,
metaFiles,
);
}
// Reconcile the ledger against what this run actually attempted: an entry
@@ -959,7 +933,6 @@ const runLockedBackup = async (
failed: ledger.size,
errors,
undecryptableAlbums,
failedAlbums,
};
};
+3 -5
View File
@@ -394,11 +394,11 @@ export const backupMetadataCommand = async (
// Refresh first so the dump holds current account state, not what the
// cache last held; a failed refresh throws.
await lib.fresh();
const result = await runMetadataBackup(lib, client, dir, {
const { failedMLBatches } = await runMetadataBackup(lib, client, dir, {
exif: opts.exif || opts.all,
onProgress: (msg) => ctx.stderr.write(msg + "\n"),
});
return result.filesWithMLDataError > 0 ? 1 : 0;
return failedMLBatches > 0 ? 1 : 0;
} finally {
await lib.close();
}
@@ -470,9 +470,7 @@ export const backupCommand = async (
}
}
return result.failed > 0 ||
result.undecryptableAlbums.length > 0 ||
result.failedAlbums.length > 0
return result.failed > 0 || result.undecryptableAlbums.length > 0
? 1
: 0;
} finally {
+4 -8
View File
@@ -373,17 +373,13 @@ export class Client {
}
// Fetch machine-learning data (face detections + CLIP embeddings) for up
// to a batch of files, each decrypted with its own key, and for each file
// whose ML data did not decrypt, gunzip or parse, why not. One request;
// the library batches at `MLDATA_BATCH_SIZE` and schedules each batch
// through its metadata request pool.
// to a batch of files, each decrypted with its own key. One request; the
// library batches at `MLDATA_BATCH_SIZE` and schedules each batch through
// its metadata request pool.
async fetchMLData(args: {
fileIDs: number[];
fileKeys: Map<number, Uint8Array>;
}): Promise<{
payloads: Map<number, MLData>;
errors: Map<number, unknown>;
}> {
}): Promise<Map<number, MLData>> {
this.assertLoggedIn();
return fetchMLDataBatch(this.api, args.fileIDs, args.fileKeys);
}
+6 -10
View File
@@ -199,13 +199,11 @@ const isRunning = (pid: number): boolean => {
};
// Delete the temp files a killed process left in `dir`: the writer's
// `.quak-<pid>-<random>.tmp`, the backup copy's
// `.quak-backup-<name>-<pid>-<random>.tmp`, and the image and video of a live
// photo downloaded to replace a stored one, `.quak-<pid>-<random>.<ext>` (see
// `replaceOriginal`). Only files whose process is no longer running are
// removed, so another process writing into the same directory keeps its own. A
// reused process ID can only keep a leftover a while longer, never remove a
// live one.
// `.quak-<pid>-<random>.tmp` and the backup copy's
// `.quak-backup-<name>-<pid>-<random>.tmp`. Only files whose process is no
// longer running are removed, so another process writing into the same
// directory keeps its own. A reused process ID can only keep a leftover a while
// longer, never remove a live one.
export const removeLeftoverTempFiles = (dir: string): void => {
let names: string[];
try {
@@ -214,9 +212,7 @@ export const removeLeftoverTempFiles = (dir: string): void => {
return;
}
for (const name of names) {
const match = /^\.quak-(?:.*-)?(\d+)-[0-9a-z]*\.[0-9A-Za-z]+$/.exec(
name,
);
const match = /^\.quak-(?:.*-)?(\d+)-[0-9a-z]*\.tmp$/.exec(name);
if (match && !isRunning(Number(match[1]))) {
rmSync(join(dir, name), { force: true });
}
+1 -19
View File
@@ -4,8 +4,7 @@
// hands us, and quak does not trust the server. Any name taken from them and
// used on disk goes through here, so it can only ever name one file inside the
// directory the caller chose: never a path, never `..`, never hidden, never a
// Windows device name. Such a name can still be too long for a file name, and
// `shortenToBytes` cuts one short.
// Windows device name.
//
// A path the user typed (`--out`, `outPath`) is not passed through here: the
// caller is trusted, the server is not.
@@ -40,20 +39,3 @@ export const safeExtension = (title: string): string => {
// `name` with its extension, if it has one, replaced by `ext` (".mov").
export const withExtension = (name: string, ext: string): string =>
name.slice(0, name.length - extname(name).length) + ext;
// The longest file name, in bytes, that ext4 and most other file systems allow.
export const MAX_NAME_BYTES = 255;
// `name` cut short to at most `maxBytes` bytes of UTF-8, the encoding a file
// name is written to disk in, between two characters, never inside one. A name
// that fits comes back unchanged.
export const shortenToBytes = (name: string, maxBytes: number): string => {
let bytes = 0;
let end = 0;
for (const character of name) {
bytes += Buffer.byteLength(character);
if (bytes > maxBytes) break;
end += character.length;
}
return name.slice(0, end);
};
+2 -116
View File
@@ -13,8 +13,6 @@
// originals, the thumbnail pool for thumbnails. The pool limits concurrency,
// orders on-demand work ahead of background, and dedups by key so a fileID
// requested twice while the first is still in flight downloads once.
// `fetchOriginal` passes no key, so it is not shared with a fetch of the same
// file already in flight.
//
// Integrity. The reused streaming decrypt is the enforced guarantee: every
// chunk is authenticated and the writer renames the file into place only once
@@ -29,10 +27,9 @@
// It also names each original's save path under the download directory
// (`savePath`), where `Photo.download()` and `lib.backup()` put it. A copy in
// the cache does not count as saved there, but is copied there rather than
// fetched again. An original stored there whose bytes do not match its content
// hash is replaced only by a download that does (`replaceOriginal`).
// fetched again.
import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
import { existsSync, readFileSync, statSync } from "node:fs";
import {
chmod,
copyFile,
@@ -47,12 +44,6 @@ import {
import { basename, dirname, extname, join } from "node:path";
import type { ApiClient } from "../api/client.js";
import {
chunkHashFinal,
chunkHashInit,
chunkHashUpdate,
init,
} from "../crypto/index.js";
import {
downloadFile,
downloadThumbnail,
@@ -358,26 +349,6 @@ export const storedAtSavePath = (
return storedOriginal(dirname(path), basename(path, extname(path)), file);
};
// The content hash of the original stored at `stored`, computed as the download
// check computes it: over each file's bytes, read in chunks, and for a live
// photo `<imageHash>:<videoHash>`.
export const storedHash = async (stored: {
path: string;
videoPath?: string;
}): Promise<string> => {
await init();
const hashFile = async (path: string): Promise<string> => {
const state = chunkHashInit();
for await (const chunk of createReadStream(path)) {
chunkHashUpdate(state, chunk as Buffer);
}
return chunkHashFinal(state);
};
const hash = await hashFile(stored.path);
if (stored.videoPath === undefined) return hash;
return `${hash}:${await hashFile(stored.videoPath)}`;
};
// Copy bytes into `dest` via a temp file in the same directory plus rename, so
// `dest` appears only once it is whole ("present means complete"). As in the
// download writer, the temp file is fsynced before the rename and the directory
@@ -434,62 +405,6 @@ export const placeOriginal = async (
return { path, videoPath };
};
// Replace the original of `file` stored at its save path under `root` with a
// new copy, keeping the stored one until the copy is complete and its hash,
// computed as `storedHash` computes it, is the content hash the file's metadata
// records. `get` is given a temporary file beside the save path and writes the
// copy there, a live photo as its image and its video beside that file, each
// with its own extension. Only a copy that matches is renamed over the stored
// one, and a live photo's image and video are both hashed before either is
// renamed. The image is renamed first: should the run stop, or the video's
// rename fail, before the video is renamed, the new image is left beside the
// old video, and a later backup with `verify` replaces the video if the video
// was the part that did not match. When `get` fails or the copy does not match,
// the temporary files are removed and the stored original stays as it was.
export const replaceOriginal = async (
root: string,
file: EnteFile,
get: (dest: string) => Promise<{ path: string; videoPath?: string }>,
): Promise<{ path: string; videoPath?: string }> => {
const dest = savePath(root, file);
const dir = dirname(dest);
// Named as the download writer names its temporary files; a live photo's
// image and video get this name with their own extensions. The next
// backup removes any that a killed run leaves (`removeLeftoverTempFiles`).
const tmp = join(
dir,
`.quak-${process.pid}-${Math.random().toString(36).slice(2)}.tmp`,
);
let got: { path: string; videoPath?: string } | undefined;
try {
got = await get(tmp);
if ((await storedHash(got)) !== file.metadata.hash) {
throw new Error(
"the downloaded original does not match its content hash either",
);
}
if (got.videoPath === undefined) {
await rename(got.path, dest);
await fsyncPath(dir);
return { path: dest };
}
const path = withExtension(dest, extname(got.path));
const videoPath = withExtension(dest, extname(got.videoPath));
await rename(got.path, path);
await rename(got.videoPath, videoPath);
const name = basename(dest, extname(dest));
await writeLivePhotoJSON(dir, name, { path, videoPath });
return { path, videoPath };
} finally {
// Whatever was not renamed into place. A failure to remove it must
// not replace the error that explains what went wrong.
for (const path of [tmp, got?.path, got?.videoPath]) {
if (path === undefined) continue;
await rm(path, { force: true }).catch(() => undefined);
}
}
};
export class ContentCache implements PhotoContent, ThumbnailsAPI {
private readonly pools: RequestPools;
private readonly source: ContentSource;
@@ -628,35 +543,6 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
};
}
// Fetch an original through the content pool to `destination`, a live
// photo as its image and its video beside it, and return where it landed.
// Unlike `backupOriginal`, it fetches even when the cache or the save path
// already holds the original, and it records nothing: what it writes is
// the caller's. It is not shared with a fetch of the same file already
// under way, which writes somewhere else.
async fetchOriginal(
fileID: number,
destination: string,
): Promise<{ path: string; videoPath?: string }> {
const file = this.getFile(fileID);
if (!file) throw new Error(`content cache: unknown file ${fileID}`);
return this.pools.content.run(
async () => {
const fetched = await this.fetchInto(
file,
destination,
"original",
undefined,
);
for (const path of [fetched.path, fetched.videoPath]) {
if (path !== undefined) await chmod(path, FILE_MODE);
}
return fetched;
},
{ priority: "on-demand" },
);
}
async ensure(args: EnsureOptions): Promise<EnsureResult[]> {
return this.ensureThumbnails(args);
}
+10 -56
View File
@@ -89,7 +89,6 @@ export {
} from "./content.js";
export { type MLDataAPI, type SimilarResult } from "./mlsearch.js";
import type { CollectionsPage, FilesPage } from "../client.js";
import { describeError } from "../errors.js";
import { MLDATA_BATCH_SIZE, type MLData } from "../mldata-fetch.js";
import type { Collection, EnteFile } from "../model/types.js";
import { runBackup, type BackupOptions, type BackupResult } from "../backup.js";
@@ -139,16 +138,12 @@ export interface LibraryClient {
sinceTime: number;
}): Promise<FilesPage>;
// Fetch ML data (face detections + CLIP embeddings) for up to a batch of
// files, and for each file whose ML data did not decrypt, gunzip or parse,
// why not. Optional: a client without it simply disables ML fetching,
// leaving the metadata refresh untouched.
// files. Optional: a client without it simply disables ML fetching, leaving
// the metadata refresh untouched.
fetchMLData?(args: {
fileIDs: number[];
fileKeys: Map<number, Uint8Array>;
}): Promise<{
payloads: Map<number, MLData>;
errors: Map<number, unknown>;
}>;
}): Promise<Map<number, MLData>>;
// The byte source for the on-disk content cache. Optional so a mock client
// that only serves metadata still satisfies the interface; when absent (and
// no explicit `contentSource` is passed to `open`) the content cache is
@@ -159,11 +154,9 @@ export interface LibraryClient {
// A progress event for one unit of background work. A metadata "refresh" or an
// ML "fetchMLData" pass each fire "started" before their network work and then
// exactly one of "done" or "failed"; "failed" carries the error message and
// an ML "done" reports how many payloads it stored and, in `mlDataErrors`,
// each file whose ML data did not decrypt, gunzip or parse, with the reason.
// The precache fills ("precacheThumbnails"/"precacheOriginals", #48) fire
// "started"/"done" around each sweep that has work, "done" reporting the count
// newly cached.
// an ML "done" reports how many payloads it stored. The precache fills
// ("precacheThumbnails"/"precacheOriginals", #48) fire "started"/"done" around
// each sweep that has work, "done" reporting the count newly cached.
export interface RefreshEvent {
operation:
| "refresh"
@@ -173,7 +166,6 @@ export interface RefreshEvent {
status: "started" | "done" | "failed";
error?: string;
fetched?: number;
mlDataErrors?: { fileID: number; error: string }[];
}
export type RefreshProgressCallback = (event: RefreshEvent) => void;
@@ -233,11 +225,6 @@ export interface LibraryStatus {
lastMLFetchAt?: number;
// The most recent ML fetch pass's error, set only while it failed.
lastMLError?: string;
// Each file whose ML data the last ML fetch pass that succeeded got but
// could not decrypt, gunzip or parse, with the reason. A pass still
// running leaves it as it is. A pass that fails leaves it as it was. Such
// ML data is not cached, so the next pass asks for it again.
mlDataErrors: { fileID: number; error: string }[];
// ML payloads stored on disk and CLIP embeddings in the index; undefined
// when ML fetching is disabled.
mlStored?: number;
@@ -305,12 +292,6 @@ export class Library {
private lastError?: string;
private lastMLFetchAt?: number;
private lastMLError?: string;
// For each file whose ML data the last ML fetch pass that succeeded got
// but could not decrypt, gunzip or parse, why not, by fileID. A pass
// gathers its own and puts them here when it ends without failing, so
// `status()` and `backup()` never see the list of a pass that is still
// running or that failed. Kept in RAM only, never in the cache.
private mlDataErrors = new Map<number, unknown>();
// The plain-record projection as of the last refresh, and the GUI change
// subscribers. A refresh that alters the projection notifies each with the
// delta; `lastRecords` is kept current every refresh so a subscriber that
@@ -561,7 +542,6 @@ export class Library {
lastError: this.lastError,
lastMLFetchAt: this.lastMLFetchAt,
lastMLError: this.lastMLError,
mlDataErrors: this.describeMLDataErrors(),
mlStored: ml?.stored,
mlIndexed: ml?.indexed,
originalsUsedBytes: originals?.usedBytes,
@@ -626,13 +606,10 @@ export class Library {
undecryptable: () => this.store.listUndecryptable(),
original: (fileID, destination) =>
cache!.backupOriginal(fileID, destination),
fetchOriginal: (fileID, destination) =>
cache!.fetchOriginal(fileID, destination),
thumbnail: (fileID) => cache!.thumbnail(fileID),
contentConcurrency: this.pools.content.concurrency,
fetchMLData: () => this.fetchMLDataNow(),
mlData: (fileID) => this.mldata.forFile({ fileID }),
mlDataError: (fileID) => this.mlDataErrors.get(fileID),
},
{ ...opts, downloadDirectory },
);
@@ -859,11 +836,8 @@ export class Library {
// One ML fetch pass: fetch, decrypt and store the ML data for every file
// the store knows about that is not cached (or whose `updationTime` has
// advanced), through the metadata pool, and update the CLIP index. For a
// file whose ML data does not decrypt, gunzip or parse, it gathers the
// reason instead, and puts the reasons it gathered in `mlDataErrors` when
// it ends without failing. A failure leaves `mlDataErrors` as it was and
// is reported through `status()` and `onProgress`, then thrown.
// advanced), through the metadata pool, and update the CLIP index. A
// failure is reported through `status()` and `onProgress`, then thrown.
private async runMLFetch(): Promise<void> {
const mldata = this.mlStore;
// Bind so the call keeps the client as its receiver when invoked
@@ -873,14 +847,7 @@ export class Library {
const files = this.uniqueFiles();
const needed = mldata.neededFor(files);
// This pass's reasons, put in `this.mlDataErrors` when it ends without
// failing. A file whose ML data did not decode in the pass before is
// still among `needed`, so this pass gives its reason anew.
const mlDataErrors = new Map<number, unknown>();
if (needed.length === 0) {
this.mlDataErrors = mlDataErrors;
return;
}
if (needed.length === 0) return;
this.emit({ operation: "fetchMLData", status: "started" });
try {
@@ -895,25 +862,20 @@ export class Library {
for (let i = 0; i < needed.length; i += MLDATA_BATCH_SIZE) {
if (this.closed) break;
const batch = needed.slice(i, i + MLDATA_BATCH_SIZE);
const { payloads, errors } = await this.pools.metadata.run(
const payloads = await this.pools.metadata.run(
() => fetchMLData({ fileIDs: batch, fileKeys }),
{ priority: "background" },
);
for (const [id, error] of errors) {
mlDataErrors.set(id, error);
}
stored += (await mldata.storeFetched(payloads, updation))
.stored;
}
this.mlDataErrors = mlDataErrors;
this.lastMLFetchAt = Date.now();
this.lastMLError = undefined;
this.emit({
operation: "fetchMLData",
status: "done",
fetched: stored,
mlDataErrors: this.describeMLDataErrors(),
});
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
@@ -923,14 +885,6 @@ export class Library {
}
}
// `mlDataErrors` as `status()` and the ML "done" event give it.
private describeMLDataErrors(): { fileID: number; error: string }[] {
return [...this.mlDataErrors].map(([fileID, err]) => ({
fileID,
error: describeError(err),
}));
}
// The distinct files the store holds, one entry per fileID (a file in
// several collections shares its ML data), each carrying the key and the
// newest `updationTime` seen across its memberships.
+8 -14
View File
@@ -2,7 +2,6 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import * as jpeg from "jpeg-js";
import type { Client } from "./client.js";
import { describeError } from "./errors.js";
import { readExifTags } from "./exif.js";
import type { Library, Photo } from "./library/index.js";
import { sanitizeFileName } from "./filename.js";
@@ -92,15 +91,14 @@ const extractExif = async (
// of plain JSON: account, per-collection, and per-file records including the
// private and public magic metadata and (by default) the ML data. Collections
// and files are enumerated from the library's cache, which the caller refreshes
// first. Returns how many files have no ML data because their ML data request
// failed or their ML data did not decrypt, gunzip or parse; they are still
// first. Returns how many ML data requests failed; their files are still
// written, with `mlDataError` in place of `mlData`.
export const runMetadataBackup = async (
lib: Library,
client: Client,
outDir: string,
opts?: MetadataBackupOptions,
): Promise<{ filesWithMLDataError: number }> => {
): Promise<{ failedMLBatches: number }> => {
const log = opts?.onProgress ?? (() => {});
const wantExif = opts?.exif ?? false;
@@ -166,28 +164,24 @@ export const runMetadataBackup = async (
}
// One failed request (retries exhausted) must not end the dump: its files
// get the reason in `mlDataError` and the other batches go on. So does a
// file whose ML data does not decrypt, gunzip or parse.
// get the reason in `mlDataError` and the other batches go on.
log("Fetching ML data (face detections, CLIP embeddings)...");
const mlDataMap = new Map<number, MLData>();
const mlDataErrors = new Map<number, string>();
let failedMLBatches = 0;
const fileIDs = [...fileKeys.keys()];
for (let i = 0; i < fileIDs.length; i += MLDATA_BATCH_SIZE) {
const batch = fileIDs.slice(i, i + MLDATA_BATCH_SIZE);
try {
const { payloads, errors } = await fetchMLDataBatch(
const result = await fetchMLDataBatch(
client.getApiClient(),
batch,
fileKeys,
);
for (const [id, payload] of payloads) mlDataMap.set(id, payload);
for (const [id, err] of errors) {
const reason = describeError(err);
log(`ML data of file ${id} could not be read: ${reason}`);
mlDataErrors.set(id, reason);
}
for (const [id, payload] of result) mlDataMap.set(id, payload);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
failedMLBatches++;
log(
`ML data request for ${batch.length} file(s) failed: ${reason}`,
);
@@ -235,5 +229,5 @@ export const runMetadataBackup = async (
}
log("Metadata backup complete.");
return { filesWithMLDataError: mlDataErrors.size };
return { failedMLBatches };
};
+24 -33
View File
@@ -28,52 +28,43 @@ interface RawRemoteFileData {
updatedAt?: number;
}
// Decrypt one entry with its file key and gunzip the JSON payload. Throws why
// when the entry does not decrypt, gunzip or parse, or parses to something
// other than a JSON object, such as `null`, a number or an array.
const decodeEntry = (entry: RawRemoteFileData, key: Uint8Array): MLData => {
const decrypted = decryptBlob(
fromBase64(entry.encryptedData),
fromBase64(entry.decryptionHeader),
key,
);
const json = gunzipSync(Buffer.from(decrypted)).toString("utf-8");
const payload: unknown = JSON.parse(json);
if (
typeof payload !== "object" ||
payload === null ||
Array.isArray(payload)
) {
throw new Error("not a JSON object");
// Decrypt one entry with its file key and gunzip the JSON payload. Returns
// undefined when the key is unknown or the entry does not decrypt/parse, so one
// corrupt file never fails a whole batch.
const decodeEntry = (
entry: RawRemoteFileData,
key: Uint8Array | undefined,
): MLData | undefined => {
if (!key) return undefined;
try {
const decrypted = decryptBlob(
fromBase64(entry.encryptedData),
fromBase64(entry.decryptionHeader),
key,
);
const json = gunzipSync(Buffer.from(decrypted)).toString("utf-8");
return JSON.parse(json) as MLData;
} catch {
return undefined;
}
return payload as MLData;
};
// Fetch ML data for up to `MLDATA_BATCH_SIZE` ids in a single request. This is
// the unit the request pools schedule; callers with more ids split them into
// batches and submit each batch to the pool. Returns the payload of each file
// whose entry decodes and, in `errors`, why not for each file whose entry does
// not, so one corrupt file never fails a whole batch. An entry for a file
// whose key is not known, which quak did not ask for, is skipped.
// batches and submit each batch to the pool.
export const fetchMLDataBatch = async (
api: ApiClient,
fileIDs: number[],
fileKeys: Map<number, Uint8Array>,
): Promise<{ payloads: Map<number, MLData>; errors: Map<number, unknown> }> => {
): Promise<Map<number, MLData>> => {
const { data } = await api.postJSON<{ data: RawRemoteFileData[] }>(
"/files/data/fetch",
{ type: "mldata", fileIDs },
);
const payloads = new Map<number, MLData>();
const errors = new Map<number, unknown>();
const result = new Map<number, MLData>();
for (const entry of data ?? []) {
const key = fileKeys.get(entry.fileID);
if (!key) continue;
try {
payloads.set(entry.fileID, decodeEntry(entry, key));
} catch (err) {
errors.set(entry.fileID, err);
}
const payload = decodeEntry(entry, fileKeys.get(entry.fileID));
if (payload) result.set(entry.fileID, payload);
}
return { payloads, errors };
return result;
};
+7 -27
View File
@@ -58,14 +58,6 @@ export const resolveRetryOptions = (
});
// Transport failures: the request did not complete, for reasons below HTTP.
//
// The errnos are Node's. The binary `make install` builds runs under bun,
// whose `fetch` rejects with a plain `Error` that has no `cause` and a `code`
// of bun's own. Bun gives `ConnectionRefused` for a refused connection and for
// a TLS handshake that failed, and `ConnectionRefused` or `FailedToOpenSocket`
// for a name that does not resolve. For a connection reset or closed before
// the response ended, it gives Node's `ECONNRESET`. These were found by
// running that binary, built with bun 1.3.13, against listeners on 127.0.0.1.
const TRANSPORT_CODES = new Set([
"ECONNRESET",
"ECONNABORTED",
@@ -78,9 +70,6 @@ const TRANSPORT_CODES = new Set([
"ENETUNREACH",
"ENETRESET",
"ENETDOWN",
// Bun's.
"ConnectionRefused",
"FailedToOpenSocket",
]);
// The subset of the above that can only be reported before a TCP connection
@@ -98,12 +87,6 @@ const TRANSPORT_CODES = new Set([
// ambiguity this set exists to exclude. They stay in `TRANSPORT_CODES`, so
// they remain retryable for idempotent calls; only replay eligibility is
// narrowed. See `isSafeToReplay`.
//
// Bun's `ConnectionRefused` and `FailedToOpenSocket` are absent too, so a
// `POST` or `PUT` is not replayed on them. When a connection bun kept open from
// an earlier request closes before any answer, bun sends the request again on
// a new connection, and if that one fails it gives one of these codes although
// the server may already have the request.
const CONNECT_CODES = new Set(["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED"]);
// Collect every `code` in an error's cause chain. undici does not put the
@@ -122,7 +105,7 @@ const causeCodes = (err: unknown): { codes: string[]; complete: boolean } => {
return { codes, complete };
};
// Does the error's cause chain hold the code of a transport failure, one of
// Does the error's cause chain hold the errno of a transport failure, one of
// `TRANSPORT_CODES`? `isRetryable` retries such an error, and `classify` in
// `src/backup.ts` calls it transient.
export const hasTransportCode = (err: unknown): boolean =>
@@ -169,8 +152,7 @@ export const isRetryable = (err: unknown): boolean => {
// deliberately literal. Demanding a recognised `cause` instead would
// classify real network failures as permanent and fail backups that should
// have succeeded; the cost of the imprecision is bounded by the attempt
// count. Bun's fetch rejects with a plain `Error` instead, which is
// recognised by its code (see `TRANSPORT_CODES`).
// count.
if (err instanceof TypeError) return true;
return hasTransportCode(err);
};
@@ -188,13 +170,11 @@ export const isRetryable = (err: unknown): boolean => {
//
// Everything else is ambiguous. A 5xx proves the server did process the
// request. A reset or a broken pipe can arrive after it was fully sent and
// acted on. A routing errno can be delivered on an established socket. Bun's
// `ConnectionRefused` and `FailedToOpenSocket` can come from a new connection
// bun opened to resend a request that already went out on a connection it kept
// open. A deadline says nothing at all about the server's state. So every code in the
// cause chain must be one of `CONNECT_CODES`: one other code anywhere in the
// chain is doubt, and doubt is not replayed. A chain longer than the walk is
// doubt too: the links below the limit were never read.
// acted on. A routing errno can be delivered on an established socket. A
// deadline says nothing at all about the server's state. So every errno in the
// cause chain must be a connect errno: one other errno anywhere in the chain
// is doubt, and doubt is not replayed. A chain longer than the walk is doubt
// too: the links below the limit were never read.
export const isSafeToReplay = (err: unknown): boolean => {
const { codes, complete } = causeCodes(err);
return (
+9 -250
View File
@@ -9,7 +9,7 @@
* 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
* hash its uploader recorded and unpacks a live photo, and quak's own retries
* repeat a failed request. Three tests run `lib.backup()` instead, as a program
* repeat a failed request. Two tests run `lib.backup()` instead, as a program
* using quak as a library does.
*
* The account, user 42, has three albums:
@@ -274,17 +274,15 @@ afterAll(() => {
// Run `quak backup <dir>` as `bin/quak.ts` runs it, sending its requests
// through `fetch`, and return its exit code and everything it printed on
// stderr and on stdout. Its requests are retried as those of `quak backup`
// are, except that a retry does not wait. A download is given up after
// `downloadTimeoutMs` with no bytes received, 60 seconds unless given. Its
// cache is beside `dir`, so each run of one directory finds the cache the run
// before it left, as cron's runs find the user's.
// stderr. Its requests are retried as those of `quak backup` are, except that
// a retry does not wait. Its cache is beside `dir`, so each run of one
// directory finds the cache the run before it left, as cron's runs find the
// user's.
const backup = async (
dir: string,
opts: { verify?: boolean; json?: boolean } = {},
opts: { verify?: boolean } = {},
fetch = server,
downloadTimeoutMs?: number,
): Promise<{ code: number; stderr: string; stdout: string }> => {
): Promise<{ code: number; stderr: string }> => {
let printed = "";
const stderr = new Writable({
write(chunk: Buffer, _encoding, done) {
@@ -292,10 +290,8 @@ const backup = async (
done();
},
});
let output = "";
const stdout = new Writable({
write(chunk: Buffer, _encoding, done) {
output += chunk.toString();
write(_chunk, _encoding, done) {
done();
},
});
@@ -312,7 +308,6 @@ const backup = async (
loadSession(path, {
fetch,
retry: { ...UNATTENDED_RETRY_OPTIONS, sleep: async () => {} },
downloadTimeoutMs,
}),
login: prompted,
prompt: prompted,
@@ -321,7 +316,7 @@ const backup = async (
const code = await new Promise<number>((resolve) => {
void run(backupCommand(ctx, dir, opts), stdout, stderr, resolve);
});
return { code, stderr: printed, stdout: output };
return { code, stderr: printed };
};
// The ID of the file whose original a request downloads, or undefined for any
@@ -632,40 +627,6 @@ describe("quak backup", () => {
).toEqual([]);
});
// The user has put a file in collections/ with the name of the Trips
// folder, so quak cannot make that folder. It says so on one line, writes
// the two albums after it, and exits 1, and --json lists the album's ID
// in `failedAlbums`. No file failed, so there is no failures.json.
it("exits 1 when an album's folder cannot be made, writes the other albums, and lists it in --json", async () => {
const dir = mkdtempSync(join(root, "backup-"));
mkdirSync(join(dir, "collections"));
writeFileSync(join(dir, "collections", "Trips"), "mine");
const first = await backup(dir);
const { code, stdout } = await backup(dir, { json: true });
expect(first.code).toBe(1);
expect(first.stderr).toMatch(/^FAILED album Trips: EEXIST: /m);
expect(code).toBe(1);
expect(JSON.parse(stdout)).toMatchObject({
failed: 0,
undecryptableAlbums: [],
failedAlbums: [10],
});
const collections = join(dir, "collections");
expect(readJSON(join(collections, "Family.json")).id).toBe(20);
expect(readdirSync(join(collections, "Family")).sort()).toEqual([
"IMG_0002.heic",
"IMG_0002.mov",
"beach.jpg",
]);
expect(readJSON(join(collections, "Friend's Wedding.json")).id).toBe(
30,
);
expect(existsSync(join(collections, "Trips.json"))).toBe(false);
expect(existsSync(join(dir, "failures.json"))).toBe(false);
});
// The server no longer accepts the saved session's token, as after
// `quak logout` elsewhere, and answers every request with 401. The run
// stops at the refresh it starts with, says to log in again, and exits 3.
@@ -763,81 +724,6 @@ describe("quak backup", () => {
},
);
// The download of toast.mp4 fails as bun's `fetch` reports a failure, in
// the binary `make install` builds: a plain `Error` with bun's own code
// and message, and no cause. These are what bun gave for a refused
// connection, a name that did not resolve and a reset connection. The
// file is tried as often as under Node, and goes into `failures.json`
// with that message, as a transient failure.
it.each([
[
"ConnectionRefused",
"Unable to connect. Is the computer able to access the url?",
],
["FailedToOpenSocket", "Was there a typo in the url or port?"],
[
"ECONNRESET",
"The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()",
],
])(
"records a download that failed with bun's %s in failures.json, as transient",
async (bunCode, message) => {
let attempts = 0;
const fetch: typeof globalThis.fetch = async (input, init) => {
if (downloadOf(input) === 200) {
attempts++;
throw Object.assign(new Error(message), {
code: bunCode,
errno: 0,
});
}
return server(input, init);
};
const dir = mkdtempSync(join(root, "backup-"));
const { code } = await backup(dir, {}, fetch);
expect(code).toBe(1);
expect(attempts).toBe(UNATTENDED_RETRY_OPTIONS.attempts);
const failures = readJSON(join(dir, "failures.json"));
expect(failures.files["200"]).toMatchObject({
classification: "transient",
error: message,
});
},
);
// The download of toast.mp4 stalls: the server sends the headers and then
// never a byte of the body. Each attempt is given up after 50 ms, quak's
// own deadline for this run, with a `TimeoutError` whose message says
// only that the download stalled. The file goes into `failures.json` as a
// transient failure, as a request that ran out of time is retried.
it("records a stalled download in failures.json, as transient", async () => {
let attempts = 0;
const fetch: typeof globalThis.fetch = async (input, init) => {
if (downloadOf(input) === 200) {
attempts++;
return new Response(
new ReadableStream<Uint8Array>({
pull: () => new Promise<void>(() => {}),
}),
);
}
return server(input, init);
};
const dir = mkdtempSync(join(root, "backup-"));
const { code } = await backup(dir, {}, fetch, 50);
expect(code).toBe(1);
expect(attempts).toBe(UNATTENDED_RETRY_OPTIONS.attempts);
const failures = readJSON(join(dir, "failures.json"));
expect(failures.files["200"]).toMatchObject({
classification: "transient",
error: "download stalled: no bytes received for 50 ms",
});
});
// The name of Ente's API server does not resolve when quak fetches the ML
// data, after every original has been backed up. No file has ML data
// cached yet, so each goes into `failures.json` with the reason, as a
@@ -877,133 +763,6 @@ describe("quak backup", () => {
]);
});
// The server sends beach.jpg's ML data encrypted under a key that is not
// the file's, so quak cannot decrypt it. The file's JSON has the reason
// in place of the ML data, and the file goes into `failures.json` with
// it, so the exit code is 1. The other two files have no ML data, which
// is no failure.
it("records a file whose ML data does not decrypt in its JSON and in failures.json, and exits 1", async () => {
const otherKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const mlData = encryptStreamBlob(
gzipSync(JSON.stringify(ML_DATA)),
otherKey,
);
const fetch: typeof globalThis.fetch = async (input, init) => {
if (new URL(String(input)).pathname !== "/files/data/fetch") {
return server(input, init);
}
const data = [
{
fileID: BEACH.id,
encryptedData: toBase64(mlData.ciphertext),
decryptionHeader: toBase64(mlData.header),
},
];
return new Response(JSON.stringify({ data }), {
headers: { "content-type": "application/json" },
});
};
const dir = mkdtempSync(join(root, "backup-"));
const { code, stderr } = await backup(dir, {}, fetch);
expect(code).toBe(1);
const reason = "secretstream chunk authentication failed";
const beachJSON = readJSON(join(dir, DAY, "2024-03-01.100.json"));
expect(beachJSON).not.toHaveProperty("mlData");
expect(beachJSON.mlDataError).toBe(reason);
const failures = readJSON(join(dir, "failures.json"));
expect(Object.keys(failures.files)).toEqual(["100"]);
expect(failures.files["100"]).toMatchObject({
fileID: 100,
title: "beach.jpg",
error: `ML data: ${reason}`,
});
expect(stderr).toContain(`beach.jpg (id 100): ML data: ${reason}\n`);
for (const fileID of [200, 300]) {
const json = readJSON(join(dir, DAY, `2024-03-01.${fileID}.json`));
expect(json).not.toHaveProperty("mlData");
expect(json).not.toHaveProperty("mlDataError");
}
});
// A program using quak as a library backs the account up, and another ML
// data fetch, as one the library's refresh starts, fails while the backup
// writes the JSON files. The backup's own fetch got beach.jpg's ML data
// under a key that is not the file's; the fetch that fails leaves that
// reason in place, so beach.jpg's JSON and `failures.json` still give it.
it("keeps the reason in beach.jpg's JSON and in failures.json when an ML data fetch fails while lib.backup() writes the JSON files", async () => {
const otherKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const mlData = encryptStreamBlob(
gzipSync(JSON.stringify(ML_DATA)),
otherKey,
);
let mlDataFetchFails = false;
const fetch: typeof globalThis.fetch = async (input, init) => {
if (new URL(String(input)).pathname !== "/files/data/fetch") {
return server(input, init);
}
if (mlDataFetchFails) throw new Error("the connection was reset");
const data = [
{
fileID: BEACH.id,
encryptedData: toBase64(mlData.ciphertext),
decryptionHeader: toBase64(mlData.header),
},
];
return new Response(JSON.stringify({ data }), {
headers: { "content-type": "application/json" },
});
};
const dir = mkdtempSync(join(root, "backup-"));
const lib = await Library.open({
client: loadSession(join(sessionDir, "session.json"), {
fetch,
retry: { ...UNATTENDED_RETRY_OPTIONS, sleep: async () => {} },
})!,
cacheDirectory: `${dir}-cache`,
downloadDirectory: dir,
refreshIntervalSeconds: Infinity,
precacheThumbnails: false,
precacheOriginals: false,
});
try {
// The backup reads a file's cached ML data just before it writes
// that file's JSON. The first time, a refresh starts an ML data
// fetch whose request fails, and the backup goes on once that
// fetch has failed.
const forFile = lib.mldata.forFile.bind(lib.mldata);
vi.spyOn(lib.mldata, "forFile").mockImplementationOnce(
async (args) => {
mlDataFetchFails = true;
await lib.fresh();
await vi.waitFor(
() => expect(lib.status().lastMLError).toBeDefined(),
{ timeout: 2000, interval: 5 },
);
return forFile(args);
},
);
const result = await lib.backup();
expect(result.failed).toBe(1);
const reason = "secretstream chunk authentication failed";
const beachJSON = readJSON(join(dir, DAY, "2024-03-01.100.json"));
expect(beachJSON).not.toHaveProperty("mlData");
expect(beachJSON.mlDataError).toBe(reason);
const failures = readJSON(join(dir, "failures.json"));
expect(Object.keys(failures.files)).toEqual(["100"]);
expect(failures.files["100"]).toMatchObject({
fileID: 100,
title: "beach.jpg",
error: `ML data: ${reason}`,
});
} finally {
await lib.close();
}
});
// Something changed beach.jpg on disk after it was backed up. A run with
// --verify hashes every stored original, finds that one no longer matches
// the hash Ente recorded, says so, and downloads it again; it put back the
+27 -340
View File
@@ -191,12 +191,9 @@ class HostileClient extends MockClient {
}
// A content source that writes byte buffers of the expected length and can be
// told to fail one fileID's original, as a server error or as a 404, or to
// write other bytes for it, to exercise per-file resilience.
// told to fail one fileID's original, to exercise per-file resilience.
interface StubSource extends ContentSource {
failID?: number;
notFoundID?: number;
otherBytesID?: number;
failThumbID?: number;
originalCalls: number;
}
@@ -207,10 +204,8 @@ const stubSource = (): StubSource => {
original: async ({ file: f, destination }) => {
s.originalCalls++;
if (s.failID === f.id) throw new Error("HTTP 500 from server");
if (s.notFoundID === f.id) throw new Error("HTTP 404 from server");
const size = SIZE_BY_ID[f.id] ?? 10;
const fill = s.otherBytesID === f.id ? 2 : 0;
writeFileSync(destination, Buffer.alloc(size, fill));
writeFileSync(destination, Buffer.alloc(size));
return { bytesWritten: size };
},
thumbnail: async ({ file: f, destination }) => {
@@ -701,26 +696,17 @@ describe("lib.backup", () => {
// A child that has already exited: its process ID is not running.
const exitedPID = spawnSync(process.execPath, ["-e", ""]).pid;
const leftover = `.quak-backup-2026-03-01.100.jpg-${exitedPID}-abc123.tmp`;
// A live photo's image and video downloaded to replace a stored one,
// before they were renamed into place.
const replacement = [
`.quak-${exitedPID}-abc789.heic`,
`.quak-${exitedPID}-abc789.mov`,
];
// This test's own process stands in for a backup running at the same
// time.
const inProgress = `.quak-backup-2026-03-01.101.jpg-${process.pid}-def456.tmp`;
for (const name of [leftover, ...replacement, inProgress]) {
writeFileSync(join(day, name), "partial");
}
writeFileSync(join(day, leftover), "partial");
writeFileSync(join(day, inProgress), "partial");
const lib = await openLibrary(stubSource());
await lib.backup({ downloadDirectory: outDir });
const names = readdirSync(day);
expect(names).not.toContain(leftover);
expect(names).not.toContain(replacement[0]);
expect(names).not.toContain(replacement[1]);
expect(names).toContain(inProgress);
lib.close();
});
@@ -952,14 +938,13 @@ const ML_PAYLOAD = { face: { faces: [] }, clip: { embedding: [0.5, 0.25] } };
class MLClient extends MockClient {
mlError?: string;
async fetchMLData(args: { fileIDs: number[] }): Promise<{
payloads: Map<number, MLData>;
errors: Map<number, unknown>;
}> {
async fetchMLData(args: {
fileIDs: number[];
}): Promise<Map<number, MLData>> {
if (this.mlError) throw new Error(this.mlError);
const payloads = new Map<number, MLData>();
if (args.fileIDs.includes(100)) payloads.set(100, ML_PAYLOAD);
return { payloads, errors: new Map() };
const result = new Map<number, MLData>();
if (args.fileIDs.includes(100)) result.set(100, ML_PAYLOAD);
return result;
}
}
@@ -1309,8 +1294,7 @@ describe("image metadata in each file's JSON", () => {
});
// With `verify`, each original already stored is hashed, and one that does not
// match the content hash its metadata records is downloaded again, and replaced
// only by a download that matches.
// match the content hash its metadata records is downloaded again.
describe("backup with verify", () => {
// MockClient's files, each recording the hash of what `stubSource` writes
// for it, except diagram.png (200), which records none.
@@ -1353,12 +1337,6 @@ describe("backup with verify", () => {
const good = Buffer.alloc(SIZE_BY_ID[100]!);
const corrupt = Buffer.alloc(SIZE_BY_ID[100]!, 1);
// The temporary files left in the date folder of the backup in `outDir`.
const tempFiles = (outDir: string): string[] =>
readdirSync(join(outDir, DAY)).filter((name) =>
name.startsWith(".quak-"),
);
it("leaves an original that matches its hash, and one with no hash, as they are", async () => {
const { lib, source, outDir } = await backedUp();
const calls = source.originalCalls;
@@ -1403,16 +1381,13 @@ describe("backup with verify", () => {
expect(log.filter((msg) => msg.startsWith("MISMATCH"))).toEqual([
"MISMATCH original beach.jpg (100): its bytes do not match its content hash",
]);
expect(tempFiles(outDir)).toEqual([]);
await lib.close();
});
// A copy whose hash does not match is still mostly the photo, so the
// backup keeps it until it holds a better one.
it("keeps an original that does not match its hash when its download answers 404", async () => {
it("records a failed download in failures.json, with the original removed", async () => {
const { lib, source, outDir } = await backedUp();
writeFileSync(saved(outDir, "100.jpg"), corrupt);
source.notFoundID = 100;
source.failID = 100;
const result = await lib.backup({
downloadDirectory: outDir,
@@ -1425,18 +1400,18 @@ describe("backup with verify", () => {
failed: 1,
});
expect(Object.keys(readLedger(outDir).files)).toEqual(["100"]);
expect(readFileSync(saved(outDir, "100.jpg"))).toEqual(corrupt);
expect(
readlinkSync(join(outDir, "collections", "Vacation", "beach.jpg")),
).toBe(linkTo("100.jpg"));
expect(tempFiles(outDir)).toEqual([]);
expect(existsSync(saved(outDir, "100.jpg"))).toBe(false);
await lib.close();
});
it("keeps an original that does not match its hash when its download does not match either", async () => {
const { lib, source, outDir } = await backedUp();
writeFileSync(saved(outDir, "100.jpg"), corrupt);
source.otherBytesID = 100;
it("records in failures.json an original the cache puts back with the same bad bytes", async () => {
const lib = await openLibrary(stubSource(), new HashedClient());
const outDir = join(root, "backup");
// The cache holds a bad copy, and a backup copies an original the
// cache holds to its save path.
const cached = await lib.photos.byID({ fileID: 100 })!.original();
writeFileSync(cached.path, corrupt);
await lib.backup({ downloadDirectory: outDir });
const result = await lib.backup({
downloadDirectory: outDir,
@@ -1449,39 +1424,10 @@ describe("backup with verify", () => {
failed: 1,
});
expect(result.errors.map((e) => e.error)).toEqual([
"the downloaded original does not match its content hash either",
"the original put back does not match its content hash either",
]);
expect(Object.keys(readLedger(outDir).files)).toEqual(["100"]);
expect(readFileSync(saved(outDir, "100.jpg"))).toEqual(corrupt);
expect(tempFiles(outDir)).toEqual([]);
await lib.close();
});
// The cache can hold the same bad bytes, as a copy from it is not checked
// as a download is, so the replacement is always downloaded.
it("downloads the replacement even when the cache holds the original", async () => {
const source = stubSource();
const lib = await openLibrary(source, new HashedClient());
const outDir = join(root, "backup");
// The cache holds a bad copy, and a backup copies an original the
// cache holds to its save path.
const cached = await lib.photos.byID({ fileID: 100 })!.original();
writeFileSync(cached.path, corrupt);
await lib.backup({ downloadDirectory: outDir });
const calls = source.originalCalls;
const result = await lib.backup({
downloadDirectory: outDir,
verify: true,
});
expect(result).toMatchObject({
downloaded: 1,
mismatched: 1,
failed: 0,
});
expect(source.originalCalls).toBe(calls + 1);
expect(readFileSync(saved(outDir, "100.jpg"))).toEqual(good);
await lib.close();
});
@@ -1552,29 +1498,23 @@ describe("backup album folders", () => {
files: EnteFile[];
}
// The original of the file `notFound`, if given, always fails to download.
const libraryOf = (albums: Album[], notFound?: number): BackupLibrary => ({
const libraryOf = (albums: Album[]): BackupLibrary => ({
refresh: async () => {},
listCollections: () => albums.map((a) => a.collection),
listFiles: (id) =>
albums.find((a) => a.collection.id === id)?.files ?? [],
undecryptable: () => [],
original: async (fileID) => {
if (fileID === notFound) throw new Error("HTTP 404");
const path = join(root, `source-${fileID}`);
writeFileSync(path, `original ${fileID}`);
return { path };
},
fetchOriginal: async () => {
throw new Error("no verify in this stand-in");
},
thumbnail: async () => {
throw new Error("no thumbnails in this stand-in");
},
contentConcurrency: 5,
fetchMLData: async () => {},
mlData: async () => undefined,
mlDataError: () => undefined,
whoami: () => ({ email: "backup@example.com", userID: USER_ID }),
});
@@ -1660,252 +1600,6 @@ describe("backup album folders", () => {
expect(albumID(outDir, "Trip (11) (12).json")).toBe(12);
});
// The next three tests end with the album "ok" (14), whose one file
// always fails to download, so each run has a failure to record in
// failures.json after the album folders are written.
const ok: Album = {
collection: collection(14, "ok"),
files: [file(5, 14, "e.jpg")],
};
// The folder of an album named "x.json" would be the JSON file of the
// album "x", and once ended every run there. Two runs, as on two nights,
// write the same tree, and the failed file's attempt count goes up.
it("adds the ID to an album whose folder would have another album's JSON name", async () => {
const outDir = join(root, "backup");
const lib = libraryOf(
[
{
collection: collection(10, "x"),
files: [file(1, 10, "a.jpg")],
},
{
collection: collection(11, "x.json"),
files: [file(2, 11, "b.jpg")],
},
{
collection: collection(12, "Trip"),
files: [file(3, 12, "c.jpg")],
},
{
collection: collection(13, "TRIP.json"),
files: [file(4, 13, "d.jpg")],
},
ok,
],
5,
);
await runBackup(lib, { downloadDirectory: outDir });
const result = await runBackup(lib, { downloadDirectory: outDir });
expect(result.failedAlbums).toEqual([]);
expect(tree(outDir)).toEqual([
"TRIP.json (13)/",
`TRIP.json (13)/d.jpg -> ${linkTo("4.jpg")}`,
"TRIP.json (13).json",
"Trip/",
`Trip/c.jpg -> ${linkTo("3.jpg")}`,
"Trip.json",
"ok/",
"ok.json",
"x/",
`x/a.jpg -> ${linkTo("1.jpg")}`,
"x.json",
"x.json (11)/",
`x.json (11)/b.jpg -> ${linkTo("2.jpg")}`,
"x.json (11).json",
]);
expect(albumID(outDir, "x.json")).toBe(10);
expect(albumID(outDir, "x.json (11).json")).toBe(11);
expect(albumID(outDir, "TRIP.json (13).json")).toBe(13);
expect(albumID(outDir, "ok.json")).toBe(14);
expect(result.failed).toBe(1);
expect(readLedger(outDir).files["5"]).toMatchObject({ attempts: 2 });
});
// A name over 255 bytes is refused as a file name, and an album folder's
// name leaves room for `.json` in its JSON's. "é" takes two bytes, and a
// name is cut between two characters.
it("cuts an album name and a title too long for a file name short and adds the ID", async () => {
const outDir = join(root, "backup");
const lib = libraryOf(
[
{
collection: collection(10, "L".repeat(300)),
files: [file(1, 10, `${"é".repeat(150)}.jpg`)],
},
ok,
],
5,
);
await runBackup(lib, { downloadDirectory: outDir });
const result = await runBackup(lib, { downloadDirectory: outDir });
const folder = `${"L".repeat(245)} (10)`;
const link = `${"é".repeat(123)} (1).jpg`;
expect(Buffer.byteLength(`${folder}.json`)).toBe(255);
expect(Buffer.byteLength(link)).toBe(254);
expect(result.failedAlbums).toEqual([]);
expect(tree(outDir)).toEqual([
`${folder}/`,
`${folder}/${link} -> ${linkTo("1.jpg")}`,
`${folder}.json`,
"ok/",
"ok.json",
]);
expect(albumID(outDir, `${folder}.json`)).toBe(10);
expect(albumID(outDir, "ok.json")).toBe(14);
expect(result.failed).toBe(1);
expect(readLedger(outDir).files["5"]).toMatchObject({ attempts: 2 });
});
// The user has put a file in collections/ with the name of the album
// Trip's folder, so the folder cannot be made.
it("goes on to the next album when an album's folder cannot be made, and lists it as failed", async () => {
const outDir = join(root, "backup");
const lib = libraryOf(
[
{
collection: collection(10, "Trip"),
files: [file(1, 10, "a.jpg")],
},
ok,
],
5,
);
mkdirSync(join(outDir, "collections"), { recursive: true });
writeFileSync(join(outDir, "collections", "Trip"), "mine");
const logged: string[] = [];
const result = await runBackup(lib, {
downloadDirectory: outDir,
onProgress: (message) => logged.push(message),
});
expect(result.failedAlbums).toEqual([10]);
expect(logged).toContainEqual(
expect.stringMatching(/^FAILED album Trip: EEXIST: /),
);
expect(tree(outDir)).toEqual(["Trip", "ok/", "ok.json"]);
expect(albumID(outDir, "ok.json")).toBe(14);
expect(existsSync(saved(outDir, "1.jpg"))).toBe(true);
expect(result.failed).toBe(1);
expect(readLedger(outDir).files["5"]).toMatchObject({ attempts: 1 });
});
// The extensions of these titles leave no room for the ID, so each is cut
// short and the name before the ID keeps only the bytes that cut leaves
// over. File 1's extension is 303 bytes and is cut to 251, which leaves
// none. File 2's is 271 bytes and is cut to 250, since "€" takes three
// bytes, which leaves one, for the "a". Neither extension is only letters
// and digits, so both originals are saved with `.bin`, a name that fits.
it("cuts the extension of a title short when it leaves no room for the ID", async () => {
const outDir = join(root, "backup");
const lib = libraryOf([
{
collection: collection(10, "Trip"),
files: [
file(1, 10, `a.${"L".repeat(300)}-x`),
file(2, 10, `a.${"€".repeat(90)}`),
],
},
]);
const result = await runBackup(lib, { downloadDirectory: outDir });
const link = ` (1).${"L".repeat(250)}`;
const link2 = `a (2).${"€".repeat(83)}`;
expect(Buffer.byteLength(link)).toBe(255);
expect(Buffer.byteLength(link2)).toBe(255);
expect(result.failed).toBe(0);
expect(tree(outDir)).toEqual([
"Trip/",
`Trip/${link} -> ${linkTo("1.bin")}`,
`Trip/${link2} -> ${linkTo("2.bin")}`,
"Trip.json",
]);
});
// Files 1 and 3 share a title, so each gets its ID before the
// extension. File 2's title is too long, and its extension leaves no
// room for the ID. Were the ID put at the end of file 2's title cut
// short, its link would have file 1's name, and the link made second
// would replace the first.
it("keeps names unique when a title's extension leaves no room for the ID", async () => {
const outDir = join(root, "backup");
const shared = `x.${"Q".repeat(245)} (2)`;
const lib = libraryOf([
{
collection: collection(10, "Trip"),
files: [
file(1, 10, shared),
file(2, 10, `x (1).${"Q".repeat(250)}-`),
file(3, 10, shared),
],
},
]);
const result = await runBackup(lib, { downloadDirectory: outDir });
expect(result.failed).toBe(0);
expect(tree(outDir)).toEqual([
"Trip/",
`Trip/ (2).${"Q".repeat(250)} -> ${linkTo("2.bin")}`,
`Trip/x (1).${"Q".repeat(245)} (2) -> ${linkTo("1.bin")}`,
`Trip/x (3).${"Q".repeat(245)} (2) -> ${linkTo("3.bin")}`,
"Trip.json",
]);
});
// A title of 255 bytes is the longest file name allowed, and an album
// name of 250 bytes the longest whose JSON name fits; both are kept as
// they are. A name one byte longer gets the ID and is cut short. "é"
// takes two bytes, and in both names the room left ends inside one, so
// the cut falls before it, one byte short of the room.
it("keeps a name at the byte limit and adds the ID to one a byte over it", async () => {
const outDir = join(root, "backup");
const album = "A".repeat(250);
const title = `${"t".repeat(251)}.jpg`;
const longAlbum = `${"é".repeat(125)}B`;
const longTitle = `${"é".repeat(126)}.jpg`;
const lib = libraryOf([
{
collection: collection(10, album),
files: [file(1, 10, title), file(2, 10, longTitle)],
},
{
collection: collection(11, longAlbum),
files: [file(3, 11, "c.jpg")],
},
]);
const result = await runBackup(lib, { downloadDirectory: outDir });
const folder = `${"é".repeat(122)} (11)`;
const link = `${"é".repeat(123)} (2).jpg`;
expect(Buffer.byteLength(`${album}.json`)).toBe(255);
expect(Buffer.byteLength(title)).toBe(255);
expect(Buffer.byteLength(longAlbum)).toBe(251);
expect(Buffer.byteLength(longTitle)).toBe(256);
expect(Buffer.byteLength(`${folder}.json`)).toBe(254);
expect(Buffer.byteLength(link)).toBe(254);
expect(result.failed).toBe(0);
expect(result.failedAlbums).toEqual([]);
expect(tree(outDir)).toEqual([
`${album}/`,
`${album}/${title} -> ${linkTo("1.jpg")}`,
`${album}/${link} -> ${linkTo("2.jpg")}`,
`${album}.json`,
`${folder}/`,
`${folder}/c.jpg -> ${linkTo("3.jpg")}`,
`${folder}.json`,
]);
expect(albumID(outDir, `${album}.json`)).toBe(10);
expect(albumID(outDir, `${folder}.json`)).toBe(11);
});
it("changes nothing on a second run over an unchanged account", async () => {
const outDir = join(root, "backup");
const lib = libraryOf([
@@ -2302,12 +1996,11 @@ describe("backup of live photos", () => {
Buffer.from(IMAGE),
);
expect(readFileSync(video)).toEqual(Buffer.from(VIDEO));
expect(readdirSync(join(outDir, DAY)).sort()).toEqual(stored);
expect(tree(outDir)).toEqual(linked);
await lib.close();
});
it("keeps both files of a live photo that does not match its hash when downloading it again fails", async () => {
it("removes both files of a live photo that does not match its hash when downloading it again fails", async () => {
const { file: live, body } = await asLivePhoto(
file(500, 10, "IMG_0500.HEIC"),
);
@@ -2325,14 +2018,8 @@ describe("backup of live photos", () => {
expect(result).toMatchObject({ mismatched: 1, failed: 1 });
expect(Object.keys(readLedger(outDir).files)).toEqual(["500"]);
expect(readFileSync(saved(outDir, "500.heic"))).toEqual(
Buffer.from(IMAGE),
);
expect(readFileSync(saved(outDir, "500.mov"), "utf-8")).toBe(
"another few seconds of video",
);
expect(readdirSync(join(outDir, DAY)).sort()).toEqual(stored);
expect(tree(outDir)).toEqual(linked);
expect(existsSync(saved(outDir, "500.heic"))).toBe(false);
expect(existsSync(saved(outDir, "500.mov"))).toBe(false);
await lib.close();
});
+6 -51
View File
@@ -489,15 +489,15 @@ describe("quak backup-metadata", () => {
});
});
describe("quak backup-metadata when a file's ML data cannot be read", () => {
// Run the CLI command against `fetch`, the mock unless given, and return
// its exit code, stderr and output directory.
const runCommand = async (fetch = buildMetaFetch(mock)) => {
describe("quak backup-metadata when an ML data request fails", () => {
// Run the CLI command against the mock and return its exit code, stderr
// and output directory.
const runCommand = async (failMLDataFor?: number) => {
const client = await Client.login({
email: TEST_EMAIL,
password: TEST_PASSWORD,
apiOptions: {
fetch,
fetch: buildMetaFetch(mock, failMLDataFor),
retry: { sleep: async () => {} },
},
});
@@ -518,9 +518,7 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
};
it("writes every file, marks the failed batch's files, and exits 1", async () => {
const { code, stderr, outDir } = await runCommand(
buildMetaFetch(mock, 200),
);
const { code, stderr, outDir } = await runCommand(200);
expect(code).toBe(1);
expect(stderr).toContain("ML data request for 1 file(s) failed");
@@ -545,49 +543,6 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
expect(failed.mlDataError).toContain("500");
});
// The server sends ML data for diagram.png encrypted under a key that is
// not the file's, so quak cannot decrypt it.
it("writes the reason for ML data that does not decrypt, and exits 1", async () => {
const otherKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { header, ciphertext } = encryptBlob(
new Uint8Array(gzipSync(Buffer.from("{}"))),
otherKey,
);
const { code, stderr, outDir } = await runCommand(
buildMetaFetch({
...mock,
encryptedMLData: {
...mock.encryptedMLData,
200: {
encryptedData: toBase64(ciphertext),
decryptionHeader: toBase64(header),
},
},
}),
);
expect(code).toBe(1);
const reason = "secretstream chunk authentication failed";
expect(stderr).toContain(
`ML data of file 200 could not be read: ${reason}\n`,
);
const failed = JSON.parse(
readFileSync(
join(outDir, "collections", "20-__Work", "200.json"),
"utf-8",
),
);
expect(failed.mlData).toBeUndefined();
expect(failed.mlDataError).toBe(reason);
const ok = JSON.parse(
readFileSync(
join(outDir, "collections", "10-Vacation", "100.json"),
"utf-8",
),
);
expect(ok.mlData.clip.embedding).toEqual([0.5, 0.6, 0.7]);
});
it("exits 0 when every ML data request succeeds", async () => {
const { code } = await runCommand();
expect(code).toBe(0);
+1 -28
View File
@@ -14,11 +14,7 @@
import { describe, expect, it } from "vitest";
import {
safeExtension,
sanitizeFileName,
shortenToBytes,
} from "../../src/filename.js";
import { safeExtension, sanitizeFileName } from "../../src/filename.js";
const FALLBACK = "file-42";
@@ -95,26 +91,3 @@ describe("safeExtension", () => {
expect(safeExtension("x.jpg\0")).toBe(".bin");
});
});
describe("shortenToBytes", () => {
it("returns a name that fits unchanged", () => {
expect(shortenToBytes("beach", 5)).toBe("beach");
expect(shortenToBytes("café", 5)).toBe("café");
expect(shortenToBytes("", 0)).toBe("");
});
it("cuts a name to the number of bytes given", () => {
expect(shortenToBytes("L".repeat(300), 255)).toBe("L".repeat(255));
});
// In UTF-8 "é" takes two bytes and an emoji four, which a JavaScript
// string holds as two halves.
it("cuts between two characters, never inside one", () => {
expect(shortenToBytes("ééé", 5)).toBe("éé");
expect(shortenToBytes("a\u{1F600}b", 4)).toBe("a");
expect(shortenToBytes("a\u{1F600}b", 5)).toBe("a\u{1F600}");
expect(Buffer.byteLength(shortenToBytes("é".repeat(200), 255))).toBe(
254,
);
});
});
+9 -150
View File
@@ -12,11 +12,9 @@
*
* 2. `Library` wiring: after each refresh the library fetches ML data through
* the metadata pool for every known file not yet cached, is incremental on
* later refreshes, and refetches a file whose `updationTime` advanced. A
* file whose ML data did not decode is reported with the reason, which
* stays listed until the next pass ends, and asked for again. Opening a
* cache directory written by another account starts empty, its ML data
* included (issue #104).
* later refreshes, and refetches a file whose `updationTime` advanced.
* Opening a cache directory written by another account starts empty,
* its ML data included (issue #104).
*
* Embedding values are chosen to be exactly representable as float32 so the
* round-trip through `clip.f32` compares equal.
@@ -28,7 +26,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { MLDataStore } from "../../src/library/mldata.js";
import { Library, type RefreshEvent } from "../../src/library/index.js";
import { Library } from "../../src/library/index.js";
import type { CollectionsPage, FilesPage } from "../../src/client.js";
import type { MLData } from "../../src/mldata-fetch.js";
import type { Collection, EnteFile } from "../../src/model/types.js";
@@ -258,14 +256,12 @@ const file = (
// A mock client that serves scripted collection/file pages and per-file ML
// payloads, recording every ML fetch request so incremental behaviour is
// provable. A file in `mlErrorByFile` has ML data that did not decode, for
// the reason given there.
// provable.
class MLMockClient {
userID = USER_ID;
collectionsQueue: CollectionsPage[] = [];
filesByCollection = new Map<number, FilesPage[]>();
mlByFile = new Map<number, MLData>();
mlErrorByFile = new Map<number, Error>();
mlFetchCalls: number[][] = [];
whoami(): { email: string; userID: number } {
@@ -302,20 +298,14 @@ class MLMockClient {
async fetchMLData(args: {
fileIDs: number[];
fileKeys: Map<number, Uint8Array>;
}): Promise<{
payloads: Map<number, MLData>;
errors: Map<number, unknown>;
}> {
}): Promise<Map<number, MLData>> {
this.mlFetchCalls.push([...args.fileIDs]);
const payloads = new Map<number, MLData>();
const errors = new Map<number, unknown>();
const result = new Map<number, MLData>();
for (const id of args.fileIDs) {
const p = this.mlByFile.get(id);
if (p) payloads.set(id, p);
const error = this.mlErrorByFile.get(id);
if (error) errors.set(id, error);
if (p) result.set(id, p);
}
return { payloads, errors };
return result;
}
filesFor(collectionID: number, ...pages: FilesPage[]): void {
@@ -459,137 +449,6 @@ describe("Library ML-data fetch on refresh", () => {
}
});
it("reports a file whose ML data did not decode through status() and onProgress, and asks for it again", async () => {
const client = new MLMockClient();
client.collectionsQueue.push({
collections: [collection(1, 100)],
deleted: [],
cursor: 100,
});
client.filesFor(1, {
files: [file(1001, 1, 90), file(1002, 1, 95)],
deleted: [],
cursor: 95,
});
client.mlByFile.set(1001, payload([0.5, 0.25, 0.75]));
client.mlErrorByFile.set(
1002,
new Error("secretstream chunk authentication failed"),
);
const events: RefreshEvent[] = [];
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: Infinity,
onProgress: (event) => events.push(event),
});
try {
await vi.waitFor(
() => expect(lib.status().lastMLFetchAt).toBeGreaterThan(0),
{ timeout: 2000, interval: 5 },
);
const reasons = [
{
fileID: 1002,
error: "secretstream chunk authentication failed",
},
];
expect(lib.status().mlDataErrors).toEqual(reasons);
expect(lib.status().mlStored).toBe(1);
expect(events.filter((e) => e.operation === "fetchMLData")).toEqual(
[
{ operation: "fetchMLData", status: "started" },
{
operation: "fetchMLData",
status: "done",
fetched: 1,
mlDataErrors: reasons,
},
],
);
// Nothing was cached for 1002, so the pass a refresh starts asks
// for it again, and once its ML data decodes the reason is gone.
client.mlErrorByFile.clear();
client.mlByFile.set(1002, payload([1, -2, 0.5]));
await lib.fresh();
await vi.waitFor(
() => {
expect(lib.status().mlStored).toBe(2);
expect(lib.status().mlDataErrors).toEqual([]);
},
{ timeout: 2000, interval: 5 },
);
expect(client.mlFetchCalls.at(-1)).toEqual([1002]);
} finally {
await lib.close();
}
});
it("still lists the last pass's reasons while the next pass runs", async () => {
const client = new MLMockClient();
client.collectionsQueue.push({
collections: [collection(1, 100)],
deleted: [],
cursor: 100,
});
client.filesFor(1, {
files: [file(1002, 1, 95)],
deleted: [],
cursor: 95,
});
client.mlErrorByFile.set(
1002,
new Error("secretstream chunk authentication failed"),
);
const reasons = [
{ fileID: 1002, error: "secretstream chunk authentication failed" },
];
let release!: () => void;
const held = new Promise<void>((r) => (release = r));
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: Infinity,
});
try {
await vi.waitFor(
() => expect(lib.status().lastMLFetchAt).toBeGreaterThan(0),
{ timeout: 2000, interval: 5 },
);
expect(lib.status().mlDataErrors).toEqual(reasons);
// Hold the next ML data fetch open until the test releases it.
let fetchStarted!: () => void;
const started = new Promise<void>((r) => (fetchStarted = r));
const realFetch = client.fetchMLData.bind(client);
client.fetchMLData = async (args) => {
fetchStarted();
await held;
return realFetch(args);
};
client.mlErrorByFile.clear();
client.mlByFile.set(1002, payload([1, -2, 0.5]));
// The pass a refresh starts asks for 1002 again. While it is
// held, the reason from the pass before is still listed; once it
// ends, its own empty list takes the place of that one.
await lib.fresh();
await started;
expect(lib.status().mlDataErrors).toEqual(reasons);
release();
await vi.waitFor(
() => expect(lib.status().mlDataErrors).toEqual([]),
{ timeout: 2000, interval: 5 },
);
} finally {
release();
await lib.close();
}
});
it("close() resolves only after a running ML data fetch has stored its payloads", async () => {
const client = new MLMockClient();
client.collectionsQueue.push({
-54
View File
@@ -1,54 +0,0 @@
// `fetchMLDataBatch`, against a server that sends one file's ML data.
//
// ML data that decrypts and gunzips but is not a JSON object is reported with
// the reason, as ML data that does not parse is, and is never returned as a
// payload: the library would fail to store it.
import { gzipSync } from "node:zlib";
import sodium from "libsodium-wrappers-sumo";
import { beforeAll, describe, expect, it } from "vitest";
import { ApiClient } from "../../src/api/client.js";
import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js";
import { fetchMLDataBatch } from "../../src/mldata-fetch.js";
beforeAll(async () => {
await init();
await sodium.ready;
});
// Fetch the ML data of file 1 from a server that sends `json`, gzipped and
// encrypted with the file's key.
const fetchServing = async (json: string) => {
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { header, ciphertext } = encryptBlob(
new Uint8Array(gzipSync(json)),
key,
);
const data = [
{
fileID: 1,
encryptedData: toBase64(ciphertext),
decryptionHeader: toBase64(header),
},
];
const api = new ApiClient({
fetch: async () =>
new Response(JSON.stringify({ data }), {
headers: { "content-type": "application/json" },
}),
});
return fetchMLDataBatch(api, [1], new Map([[1, key]]));
};
describe("fetchMLDataBatch", () => {
it.each(["null", "5", "[]"])(
"gives a reason for ML data that is %s",
async (json) => {
const { payloads, errors } = await fetchServing(json);
expect(payloads.size).toBe(0);
expect(errors.get(1)).toEqual(new Error("not a JSON object"));
},
);
});
-84
View File
@@ -77,14 +77,6 @@ const recordingSleep = (): {
const errnoError = (code: string, message = code): Error =>
Object.assign(new Error(message), { code });
/**
* An error shaped like what bun's `fetch` rejects with for a failure below
* HTTP: a plain `Error` with bun's own message, a `code`, an `errno` of 0, and
* no `cause`.
*/
const bunError = (code: string, message: string): Error =>
Object.assign(new Error(message), { code, errno: 0 });
// ---------------------------------------------------------------------------
// Classification
// ---------------------------------------------------------------------------
@@ -252,82 +244,6 @@ describe("isRetryable: transport failures", () => {
});
});
describe("isRetryable and isSafeToReplay: bun's fetch errors", () => {
// The binary `make install` builds runs quak under bun, whose `fetch`
// does not reject as Node's does. Each shape here was found by running
// that binary, built with bun 1.3.13, against listeners on 127.0.0.1 and
// a name under `.invalid`.
it("retries a refused connection, and does not replay it", () => {
// Bun gives the same error when the TLS handshake fails, and when a
// new connection it opened to resend a request is refused. That
// request may already have reached the server on a connection bun
// kept open from an earlier request.
const refused = bunError(
"ConnectionRefused",
"Unable to connect. Is the computer able to access the url?",
);
expect(isRetryable(refused)).toBe(true);
expect(isSafeToReplay(refused)).toBe(false);
});
it("retries a name that does not resolve, with either code bun gives, and does not replay it", () => {
// For the same name bun gave `ConnectionRefused` on some requests and
// `FailedToOpenSocket` on others. Either can also come from a new
// connection bun opened to resend a request, as above.
for (const unresolved of [
bunError(
"ConnectionRefused",
"Unable to connect. Is the computer able to access the url?",
),
bunError(
"FailedToOpenSocket",
"Was there a typo in the url or port?",
),
]) {
expect(isRetryable(unresolved)).toBe(true);
expect(isSafeToReplay(unresolved)).toBe(false);
}
});
it("retries a connection closed before the response ended, and does not replay it", () => {
// Bun gives Node's errno, `ECONNRESET`, for a reset and for a close,
// before the response or part-way through the body. The request may
// already have reached the server.
const reset = bunError(
"ECONNRESET",
"The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()",
);
expect(isRetryable(reset)).toBe(true);
expect(isSafeToReplay(reset)).toBe(false);
});
it("retries a request that ran out of time, and does not replay it", () => {
// What bun's fetch rejects with when `AbortSignal.timeout()`, which
// bounds each JSON request, fires.
const timedOut = new DOMException(
"The operation timed out.",
"TimeoutError",
);
expect(isRetryable(timedOut)).toBe(true);
expect(isSafeToReplay(timedOut)).toBe(false);
});
it("does not retry a certificate bun rejects", () => {
// A certificate that fails verification fails the same way however
// often it is asked. Under Node it is retried only because every
// `TypeError` is.
expect(
isRetryable(
bunError(
"DEPTH_ZERO_SELF_SIGNED_CERT",
"self signed certificate",
),
),
).toBe(false);
});
});
describe("isRetryable: stream truncation versus corruption", () => {
it("retries a truncated stream", () => {
// Truncation is a transfer that stopped early. The bytes that did