Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
129c648680 |
@@ -769,11 +769,8 @@ 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
|
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
|
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
|
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
|
for the file, so the next run asks for its ML data again. The JSON files are
|
||||||
fails, such as one the library's refresh starts while the backup writes the JSON
|
rewritten on every run, so ML data that arrived since the last run appears.
|
||||||
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.
|
|
||||||
|
|
||||||
A file's JSON holds, as `imageMetadata`, what `backup-metadata --exif` records
|
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`
|
from its original, or from a live photo's image: `format`, `width` and `height`
|
||||||
@@ -988,12 +985,11 @@ the precache fetches already running have all finished, so the cache directory
|
|||||||
can then be removed.
|
can then be removed.
|
||||||
|
|
||||||
`lib.status().mlDataErrors` lists, as `{ fileID, error }`, each file whose ML
|
`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
|
data the last ML data fetch to finish got but could not decrypt, gunzip or
|
||||||
parse; the `fetchMLData` event with status `"done"` carries the same list. A
|
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
|
fetch that is still running leaves the list as it is until it ends. Such ML data
|
||||||
that fails leaves it as it was. Such ML data is not cached, so the next ML data
|
is not cached, so the next ML data fetch asks for it again. The reasons are kept
|
||||||
fetch asks for it again. The reasons are kept in memory only, never in the
|
in memory only, never in the cache.
|
||||||
cache.
|
|
||||||
|
|
||||||
### Default reads vs. fresh reads
|
### Default reads vs. fresh reads
|
||||||
|
|
||||||
|
|||||||
@@ -31,14 +31,13 @@ declares one.
|
|||||||
no longer taken for no ML data (issue 198). `fetchMLDataBatch` returns why for
|
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
|
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
|
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
|
reasons from its last ML data fetch to finish in memory, not in the cache, and
|
||||||
cache, and gives them in `status().mlDataErrors` and in the `fetchMLData`
|
gives them in `status().mlDataErrors` and in the `fetchMLData` event with
|
||||||
event with status `"done"`; a fetch still running or one that fails does not
|
status `"done"`; a fetch still running does not change them. `quak backup`
|
||||||
change them. `quak backup` writes the reason as `mlDataError` in the file's
|
writes the reason as `mlDataError` in the file's JSON when no ML data is
|
||||||
JSON when no ML data is cached for it, and records the file in `failures.json`
|
cached for it, and records the file in `failures.json` with an error starting
|
||||||
with an error starting `ML data: `; `backup-metadata` writes `mlDataError`
|
`ML data: `; `backup-metadata` writes `mlDataError` too. Both exit 1. Tests
|
||||||
too. Both exit 1. Tests serve ML data encrypted under a key that is not the
|
serve ML data encrypted under a key that is not the file's.
|
||||||
file's.
|
|
||||||
|
|
||||||
- 2026-10-16: The `~/bin/quak` that `make install` builds retries a refused
|
- 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
|
connection and a name that does not resolve, and the backup records either as
|
||||||
|
|||||||
+3
-4
@@ -203,10 +203,9 @@ export interface BackupLibrary {
|
|||||||
// A file's cached ML data, as `lib.mldata.forFile()` returns it, or
|
// A file's cached ML data, as `lib.mldata.forFile()` returns it, or
|
||||||
// undefined when none is cached.
|
// undefined when none is cached.
|
||||||
mlData(fileID: number): Promise<MLData | undefined>;
|
mlData(fileID: number): Promise<MLData | undefined>;
|
||||||
// Why the ML data the last ML data fetch that succeeded got for a file did
|
// Why the ML data the last ML data fetch to finish got for a file did not
|
||||||
// not decrypt, gunzip or parse, or undefined when it did or the fetch got
|
// 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
|
// none. A fetch still running does not change it.
|
||||||
// leaves it as it was.
|
|
||||||
mlDataError(fileID: number): unknown;
|
mlDataError(fileID: number): unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-14
@@ -233,10 +233,10 @@ export interface LibraryStatus {
|
|||||||
lastMLFetchAt?: number;
|
lastMLFetchAt?: number;
|
||||||
// The most recent ML fetch pass's error, set only while it failed.
|
// The most recent ML fetch pass's error, set only while it failed.
|
||||||
lastMLError?: string;
|
lastMLError?: string;
|
||||||
// Each file whose ML data the last ML fetch pass that succeeded got but
|
// Each file whose ML data the last ML fetch pass to finish got but could
|
||||||
// could not decrypt, gunzip or parse, with the reason. A pass still
|
// not decrypt, gunzip or parse, with the reason. A pass still running
|
||||||
// running leaves it as it is. A pass that fails leaves it as it was. Such
|
// leaves it as it is. Such ML data is not cached, so the next pass asks
|
||||||
// ML data is not cached, so the next pass asks for it again.
|
// for it again.
|
||||||
mlDataErrors: { fileID: number; error: string }[];
|
mlDataErrors: { fileID: number; error: string }[];
|
||||||
// ML payloads stored on disk and CLIP embeddings in the index; undefined
|
// ML payloads stored on disk and CLIP embeddings in the index; undefined
|
||||||
// when ML fetching is disabled.
|
// when ML fetching is disabled.
|
||||||
@@ -305,11 +305,11 @@ export class Library {
|
|||||||
private lastError?: string;
|
private lastError?: string;
|
||||||
private lastMLFetchAt?: number;
|
private lastMLFetchAt?: number;
|
||||||
private lastMLError?: string;
|
private lastMLError?: string;
|
||||||
// For each file whose ML data the last ML fetch pass that succeeded got
|
// For each file whose ML data the last ML fetch pass to finish got but
|
||||||
// but could not decrypt, gunzip or parse, why not, by fileID. A pass
|
// could not decrypt, gunzip or parse, why not, by fileID. A pass gathers
|
||||||
// gathers its own and puts them here when it ends without failing, so
|
// its own and puts them here when it ends, so `status()` and `backup()`
|
||||||
// `status()` and `backup()` never see the list of a pass that is still
|
// never see the list of a pass that is still running. Kept in RAM only,
|
||||||
// running or that failed. Kept in RAM only, never in the cache.
|
// never in the cache.
|
||||||
private mlDataErrors = new Map<number, unknown>();
|
private mlDataErrors = new Map<number, unknown>();
|
||||||
// The plain-record projection as of the last refresh, and the GUI change
|
// The plain-record projection as of the last refresh, and the GUI change
|
||||||
// subscribers. A refresh that alters the projection notifies each with the
|
// subscribers. A refresh that alters the projection notifies each with the
|
||||||
@@ -860,8 +860,8 @@ export class Library {
|
|||||||
// advanced), through the metadata pool, and update the CLIP index. For a
|
// 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
|
// file whose ML data does not decrypt, gunzip or parse, it gathers the
|
||||||
// reason instead, and puts the reasons it gathered in `mlDataErrors` when
|
// reason instead, and puts the reasons it gathered in `mlDataErrors` when
|
||||||
// it ends without failing. A failure leaves `mlDataErrors` as it was and
|
// it ends. A failure is reported through `status()` and `onProgress`,
|
||||||
// is reported through `status()` and `onProgress`, then thrown.
|
// then thrown.
|
||||||
private async runMLFetch(): Promise<void> {
|
private async runMLFetch(): Promise<void> {
|
||||||
const mldata = this.mlStore;
|
const mldata = this.mlStore;
|
||||||
// Bind so the call keeps the client as its receiver when invoked
|
// Bind so the call keeps the client as its receiver when invoked
|
||||||
@@ -871,9 +871,9 @@ export class Library {
|
|||||||
|
|
||||||
const files = this.uniqueFiles();
|
const files = this.uniqueFiles();
|
||||||
const needed = mldata.neededFor(files);
|
const needed = mldata.neededFor(files);
|
||||||
// This pass's reasons, put in `this.mlDataErrors` when it ends without
|
// This pass's reasons, put in `this.mlDataErrors` when it ends. A
|
||||||
// failing. A file whose ML data did not decode in the pass before is
|
// file whose ML data did not decode in the pass before is still among
|
||||||
// still among `needed`, so this pass gives its reason anew.
|
// `needed`, so this pass gives its reason anew.
|
||||||
const mlDataErrors = new Map<number, unknown>();
|
const mlDataErrors = new Map<number, unknown>();
|
||||||
if (needed.length === 0) {
|
if (needed.length === 0) {
|
||||||
this.mlDataErrors = mlDataErrors;
|
this.mlDataErrors = mlDataErrors;
|
||||||
@@ -914,6 +914,7 @@ export class Library {
|
|||||||
mlDataErrors: this.describeMLDataErrors(),
|
mlDataErrors: this.describeMLDataErrors(),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
this.mlDataErrors = mlDataErrors;
|
||||||
const error = err instanceof Error ? err.message : String(err);
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
this.lastMLError = error;
|
this.lastMLError = error;
|
||||||
this.emit({ operation: "fetchMLData", status: "failed", error });
|
this.emit({ operation: "fetchMLData", status: "failed", error });
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* data this file encrypts with libsodium, as Ente's clients encrypt it. So
|
* data this file encrypts with libsodium, as Ente's clients encrypt it. So
|
||||||
* quak's own code fetches each original, decrypts it, checks it against the
|
* quak's own code fetches each original, decrypts it, checks it against the
|
||||||
* hash its uploader recorded and unpacks a live photo, and quak's own retries
|
* hash its uploader recorded and unpacks a live photo, and quak's own retries
|
||||||
* repeat a failed request. 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.
|
* using quak as a library does.
|
||||||
*
|
*
|
||||||
* The account, user 42, has three albums:
|
* The account, user 42, has three albums:
|
||||||
@@ -927,83 +927,6 @@ describe("quak backup", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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
|
// 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
|
// --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
|
// the hash Ente recorded, says so, and downloads it again; it put back the
|
||||||
|
|||||||
Reference in New Issue
Block a user