ML data that does not decrypt is recorded with its reason (closes #198)
check / check (push) Canceled after 0s

`fetchMLDataBatch` dropped an entry that did not decrypt, gunzip or parse,
so the file looked like one with no ML data and the run exited 0. It now
returns the reason beside the payloads, also for ML data that is not a
JSON object. The library gives the reasons from its last ML data fetch
that succeeded in `status().mlDataErrors` and the `fetchMLData` "done"
event; a fetch that fails leaves them as they were. `quak backup` writes
the reason as `mlDataError` and records the file in `failures.json`;
`backup-metadata` writes `mlDataError` and exits 1.

Judgement call: a file whose ML data is cached keeps it in its JSON when a
newer copy does not decode, as when the fetch fails.

Model: opus-5-5
This commit was merged in pull request #208.
This commit is contained in:
2026-10-16 07:23:16 +02:00
parent 8583863ec0
commit 7ec71bf7ec
13 changed files with 558 additions and 87 deletions
+19 -9
View File
@@ -208,6 +208,11 @@ 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";
@@ -791,19 +796,18 @@ 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 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.
// 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.
if (includeOriginals) {
let mlDataFailure: unknown;
let mlDataError: string | undefined;
try {
log("Fetching ML data...");
await lib.fetchMLData();
} catch (err) {
mlDataFailure = err;
mlDataError = describeError(err);
log(`FAILED ML data: ${mlDataError}`);
log(`FAILED ML data: ${describeError(err)}`);
}
for (const file of distinct.values()) {
if (lock.lost) throw lock.lost;
@@ -820,13 +824,19 @@ const runLockedBackup = async (
storedThisRun.has(file.id),
);
const mlData = await lib.mlData(file.id);
if (mlData === undefined && mlDataError !== undefined) {
writeSidecar(path, file, { mlDataError }, image);
const failure = lib.mlDataError(file.id) ?? mlDataFailure;
if (mlData === undefined && failure !== undefined) {
writeSidecar(
path,
file,
{ mlDataError: describeError(failure) },
image,
);
recordFailure(
file.id,
file.metadata.title,
collectionName.get(file.collectionID) ?? "",
new Error("ML data", { cause: mlDataFailure }),
new Error("ML data", { cause: failure }),
);
} else {
writeSidecar(path, file, { mlData }, image);
+2 -2
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 { failedMLBatches } = await runMetadataBackup(lib, client, dir, {
const result = await runMetadataBackup(lib, client, dir, {
exif: opts.exif || opts.all,
onProgress: (msg) => ctx.stderr.write(msg + "\n"),
});
return failedMLBatches > 0 ? 1 : 0;
return result.filesWithMLDataError > 0 ? 1 : 0;
} finally {
await lib.close();
}
+8 -4
View File
@@ -373,13 +373,17 @@ export class Client {
}
// Fetch machine-learning data (face detections + CLIP embeddings) for up
// 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.
// 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.
async fetchMLData(args: {
fileIDs: number[];
fileKeys: Map<number, Uint8Array>;
}): Promise<Map<number, MLData>> {
}): Promise<{
payloads: Map<number, MLData>;
errors: Map<number, unknown>;
}> {
this.assertLoggedIn();
return fetchMLDataBatch(this.api, args.fileIDs, args.fileKeys);
}
+54 -10
View File
@@ -89,6 +89,7 @@ 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";
@@ -138,12 +139,16 @@ export interface LibraryClient {
sinceTime: number;
}): Promise<FilesPage>;
// Fetch ML data (face detections + CLIP embeddings) for up to a batch of
// files. Optional: a client without it simply disables ML fetching, leaving
// the metadata refresh untouched.
// 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.
fetchMLData?(args: {
fileIDs: number[];
fileKeys: Map<number, Uint8Array>;
}): Promise<Map<number, MLData>>;
}): Promise<{
payloads: Map<number, MLData>;
errors: Map<number, unknown>;
}>;
// 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
@@ -154,9 +159,11 @@ 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. 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 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.
export interface RefreshEvent {
operation:
| "refresh"
@@ -166,6 +173,7 @@ export interface RefreshEvent {
status: "started" | "done" | "failed";
error?: string;
fetched?: number;
mlDataErrors?: { fileID: number; error: string }[];
}
export type RefreshProgressCallback = (event: RefreshEvent) => void;
@@ -225,6 +233,11 @@ 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;
@@ -292,6 +305,12 @@ 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
@@ -542,6 +561,7 @@ export class Library {
lastError: this.lastError,
lastMLFetchAt: this.lastMLFetchAt,
lastMLError: this.lastMLError,
mlDataErrors: this.describeMLDataErrors(),
mlStored: ml?.stored,
mlIndexed: ml?.indexed,
originalsUsedBytes: originals?.usedBytes,
@@ -612,6 +632,7 @@ export class Library {
contentConcurrency: this.pools.content.concurrency,
fetchMLData: () => this.fetchMLDataNow(),
mlData: (fileID) => this.mldata.forFile({ fileID }),
mlDataError: (fileID) => this.mlDataErrors.get(fileID),
},
{ ...opts, downloadDirectory },
);
@@ -838,8 +859,11 @@ 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. A
// failure is reported through `status()` and `onProgress`, then thrown.
// 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.
private async runMLFetch(): Promise<void> {
const mldata = this.mlStore;
// Bind so the call keeps the client as its receiver when invoked
@@ -849,7 +873,14 @@ export class Library {
const files = this.uniqueFiles();
const needed = mldata.neededFor(files);
if (needed.length === 0) return;
// 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;
}
this.emit({ operation: "fetchMLData", status: "started" });
try {
@@ -864,20 +895,25 @@ 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 = await this.pools.metadata.run(
const { payloads, errors } = 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);
@@ -887,6 +923,14 @@ 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.
+14 -8
View File
@@ -2,6 +2,7 @@ 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";
@@ -91,14 +92,15 @@ 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 ML data requests failed; their files are still
// 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
// written, with `mlDataError` in place of `mlData`.
export const runMetadataBackup = async (
lib: Library,
client: Client,
outDir: string,
opts?: MetadataBackupOptions,
): Promise<{ failedMLBatches: number }> => {
): Promise<{ filesWithMLDataError: number }> => {
const log = opts?.onProgress ?? (() => {});
const wantExif = opts?.exif ?? false;
@@ -164,24 +166,28 @@ 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.
// get the reason in `mlDataError` and the other batches go on. So does a
// file whose ML data does not decrypt, gunzip or parse.
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 result = await fetchMLDataBatch(
const { payloads, errors } = await fetchMLDataBatch(
client.getApiClient(),
batch,
fileKeys,
);
for (const [id, payload] of result) mlDataMap.set(id, payload);
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);
}
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
failedMLBatches++;
log(
`ML data request for ${batch.length} file(s) failed: ${reason}`,
);
@@ -229,5 +235,5 @@ export const runMetadataBackup = async (
}
log("Metadata backup complete.");
return { failedMLBatches };
return { filesWithMLDataError: mlDataErrors.size };
};
+33 -24
View File
@@ -28,43 +28,52 @@ interface RawRemoteFileData {
updatedAt?: number;
}
// 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;
// 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");
}
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.
// 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.
export const fetchMLDataBatch = async (
api: ApiClient,
fileIDs: number[],
fileKeys: Map<number, Uint8Array>,
): Promise<Map<number, MLData>> => {
): Promise<{ payloads: Map<number, MLData>; errors: Map<number, unknown> }> => {
const { data } = await api.postJSON<{ data: RawRemoteFileData[] }>(
"/files/data/fetch",
{ type: "mldata", fileIDs },
);
const result = new Map<number, MLData>();
const payloads = new Map<number, MLData>();
const errors = new Map<number, unknown>();
for (const entry of data ?? []) {
const payload = decodeEntry(entry, fileKeys.get(entry.fileID));
if (payload) result.set(entry.fileID, payload);
const key = fileKeys.get(entry.fileID);
if (!key) continue;
try {
payloads.set(entry.fileID, decodeEntry(entry, key));
} catch (err) {
errors.set(entry.fileID, err);
}
}
return result;
return { payloads, errors };
};