Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a92e4929f8 |
@@ -31,12 +31,6 @@ RUN script/bootstrap
|
||||
|
||||
COPY . .
|
||||
|
||||
# Unlike the template, the suite runs as the image's non-root `node` user:
|
||||
# root ignores directory permissions, so the tests of a destination that is
|
||||
# not writable would otherwise be skipped. vitest writes into /app.
|
||||
RUN chown -R node:node /app
|
||||
USER node
|
||||
|
||||
RUN timeout 90 yarn run vitest run --reporter=dot || \
|
||||
{ echo "--- Rerunning with verbose for details ---"; \
|
||||
timeout 90 yarn run vitest run --reporter=verbose; exit 1; }
|
||||
|
||||
@@ -449,11 +449,6 @@ accepted for backward compatibility but ignored. `backup-metadata --exif` (alias
|
||||
metadata. The listing and backup commands support `--json` for machine-readable
|
||||
output.
|
||||
|
||||
`backup-metadata` fetches ML data in requests of up to 200 files. When a request
|
||||
still fails after its retries, 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. The exit code is non-zero if any ML data request failed.
|
||||
|
||||
`helper fix-missing-thumbnails` regenerates thumbnails for baseline JPEG images
|
||||
only, because the bundled decoder (`jpeg-js`) decodes only JPEG. A non-JPEG
|
||||
image (PNG, HEIC) or a video is reported as `skipped` (unsupported format), kept
|
||||
@@ -659,11 +654,6 @@ Under `cacheDirectory`:
|
||||
fetched.json per-file fetch bookkeeping
|
||||
```
|
||||
|
||||
When `metadata.json` belongs to a different account than the client's,
|
||||
`Library.open` deletes it and `mldata/` and starts from an empty cache. Cached
|
||||
originals and thumbnails are kept; they are reached only through the files the
|
||||
current account's records name.
|
||||
|
||||
A stored file appears only via an atomic temp-then-rename, so its presence means
|
||||
it is complete. The design also calls for a content-hash comparison against
|
||||
`FileMetadata.hash` on each fetched original; that check is deferred (issue
|
||||
|
||||
@@ -24,17 +24,6 @@ Tag v1.0.0.
|
||||
stage compiles and depends on both, so `script/cibuild` is one build.
|
||||
`Dockerfile.lint`, `CHECK_EPOCH`, `LINT_EPOCH` and the tests that checked them
|
||||
are gone; `REPO_POLICIES.md` is re-copied.
|
||||
- 2026-09-23: Kept one account's cache from mixing with another's (issue 104).
|
||||
When `metadata.json` in the cache directory was written for a different,
|
||||
non-zero user ID than the client's, `Library.open` deletes it and `mldata/`
|
||||
and starts empty, so the first refresh enumerates from 0. This only happens
|
||||
with `--cache-dir` or an explicit `cacheDirectory`; the default path already
|
||||
includes the user ID. A test opens one account's cache as another account.
|
||||
- 2026-09-23: `backup-metadata` no longer stops on one failed ML data request
|
||||
(issue 101). Each request of up to 200 files is tried on its own; a failed one
|
||||
is logged, its files are written with the reason in `mlDataError`, and the
|
||||
command exits 1 once the dump is complete. `fetchMLData`, which only this
|
||||
command used, is gone; the command calls `fetchMLDataBatch` per batch.
|
||||
- 2026-09-23: Single-sourced the version string (issue 5). `package.json` is the
|
||||
only place it is written: `src/index.ts` imports it for `VERSION` and
|
||||
`bin/quak.ts` passes `VERSION` to commander. tsc copies `package.json` to
|
||||
|
||||
+2
-2
@@ -323,11 +323,11 @@ export const backupMetadataCommand = async (
|
||||
if (!client) return 1;
|
||||
const lib = await openReadLibrary(ctx, client);
|
||||
try {
|
||||
const { failedMLBatches } = await runMetadataBackup(lib, client, dir, {
|
||||
await runMetadataBackup(lib, client, dir, {
|
||||
exif: opts.exif || opts.all,
|
||||
onProgress: (msg) => ctx.stderr.write(msg + "\n"),
|
||||
});
|
||||
return failedMLBatches > 0 ? 1 : 0;
|
||||
return 0;
|
||||
} finally {
|
||||
await lib.close();
|
||||
}
|
||||
|
||||
+3
-15
@@ -26,7 +26,6 @@
|
||||
// store marked unsaved until a later save actually lands, so a stuck disk is
|
||||
// never masked by a subsequent empty refresh.
|
||||
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import envPaths from "env-paths";
|
||||
|
||||
@@ -346,20 +345,9 @@ export class Library {
|
||||
const cacheDirectory =
|
||||
opts.cacheDirectory ??
|
||||
join(envPaths("quak", { suffix: "" }).cache, String(userID));
|
||||
const metadataPath = join(cacheDirectory, "metadata.json");
|
||||
let store = await MetadataStore.load(metadataPath);
|
||||
// A cache directory given explicitly can hold another account's cache.
|
||||
// Its records and cursor are not this account's, so delete it and the
|
||||
// ML data beside it and start empty. A user ID of 0 means the cache
|
||||
// was never refreshed and so holds nothing to discard.
|
||||
if (store.userID !== 0 && store.userID !== userID) {
|
||||
await rm(metadataPath, { force: true });
|
||||
await rm(join(cacheDirectory, "mldata"), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
store = await MetadataStore.load(metadataPath);
|
||||
}
|
||||
const store = await MetadataStore.load(
|
||||
join(cacheDirectory, "metadata.json"),
|
||||
);
|
||||
const intervalMs =
|
||||
(opts.refreshIntervalSeconds ?? DEFAULT_REFRESH_INTERVAL_SECONDS) *
|
||||
1000;
|
||||
|
||||
+8
-35
@@ -5,11 +5,7 @@ import exifReader from "exif-reader";
|
||||
import type { Client } from "./client.js";
|
||||
import type { Library, Photo } from "./library/index.js";
|
||||
import { sanitizeFileName } from "./filename.js";
|
||||
import {
|
||||
fetchMLDataBatch,
|
||||
MLDATA_BATCH_SIZE,
|
||||
type MLData,
|
||||
} from "./mldata-fetch.js";
|
||||
import { fetchMLData } from "./mldata-fetch.js";
|
||||
import type { EnteFile } from "./model/types.js";
|
||||
|
||||
export type ProgressCallback = (message: string) => void;
|
||||
@@ -141,14 +137,13 @@ 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 rather than a fresh server
|
||||
// scan. Returns how many ML data requests failed; their files are still
|
||||
// written, with `mlDataError` in place of `mlData`.
|
||||
// scan; the ML fetch and EXIF extraction are unchanged.
|
||||
export const runMetadataBackup = async (
|
||||
lib: Library,
|
||||
client: Client,
|
||||
outDir: string,
|
||||
opts?: MetadataBackupOptions,
|
||||
): Promise<{ failedMLBatches: number }> => {
|
||||
): Promise<void> => {
|
||||
const log = opts?.onProgress ?? (() => {});
|
||||
const wantExif = opts?.exif ?? false;
|
||||
|
||||
@@ -213,31 +208,12 @@ 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.
|
||||
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(
|
||||
client.getApiClient(),
|
||||
batch,
|
||||
fileKeys,
|
||||
);
|
||||
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}`,
|
||||
);
|
||||
for (const id of batch) mlDataErrors.set(id, reason);
|
||||
}
|
||||
}
|
||||
const mlDataMap = await fetchMLData(
|
||||
client.getApiClient(),
|
||||
[...fileKeys.keys()],
|
||||
fileKeys,
|
||||
);
|
||||
log(`Got ML data for ${mlDataMap.size} file(s)`);
|
||||
|
||||
const writtenFileIDs = new Set<number>();
|
||||
@@ -257,8 +233,6 @@ export const runMetadataBackup = async (
|
||||
|
||||
const ml = mlDataMap.get(file.id);
|
||||
if (ml) fileMeta.mlData = ml;
|
||||
const mlError = mlDataErrors.get(file.id);
|
||||
if (mlError) fileMeta.mlDataError = mlError;
|
||||
|
||||
if (wantExif && !writtenFileIDs.has(file.id)) {
|
||||
log(`[${file.metadata.title}] Extracting EXIF...`);
|
||||
@@ -279,5 +253,4 @@ export const runMetadataBackup = async (
|
||||
}
|
||||
|
||||
log("Metadata backup complete.");
|
||||
return { failedMLBatches };
|
||||
};
|
||||
|
||||
+25
-2
@@ -5,8 +5,9 @@
|
||||
// comes back encrypted under the file's own key and gzipped; decrypting and
|
||||
// gunzipping yields the JSON payload
|
||||
// `{ face: { faces: [...] }, clip: { embedding } }`. Ente caps a request at 200
|
||||
// ids, so callers that want many at once split them into batches of
|
||||
// `MLDATA_BATCH_SIZE` and call `fetchMLDataBatch` once per batch.
|
||||
// ids, so `fetchMLData` batches for callers that want many at once while
|
||||
// `fetchMLDataBatch` is the single-request unit the library submits to its
|
||||
// request pool.
|
||||
|
||||
import { gunzipSync } from "node:zlib";
|
||||
|
||||
@@ -68,3 +69,25 @@ export const fetchMLDataBatch = async (
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Fetch ML data for arbitrarily many ids, batching at `MLDATA_BATCH_SIZE`. Used
|
||||
// by the one-shot metadata backup; the library fetches through its request pool
|
||||
// with `fetchMLDataBatch` instead.
|
||||
export const fetchMLData = async (
|
||||
api: ApiClient,
|
||||
fileIDs: number[],
|
||||
fileKeys: Map<number, Uint8Array>,
|
||||
): Promise<Map<number, MLData>> => {
|
||||
const result = new Map<number, MLData>();
|
||||
for (let i = 0; i < fileIDs.length; i += MLDATA_BATCH_SIZE) {
|
||||
const batch = fileIDs.slice(i, i + MLDATA_BATCH_SIZE);
|
||||
for (const [id, payload] of await fetchMLDataBatch(
|
||||
api,
|
||||
batch,
|
||||
fileKeys,
|
||||
)) {
|
||||
result.set(id, payload);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import sodium from "libsodium-wrappers-sumo";
|
||||
import { SRP, SrpServer } from "fast-srp-hap";
|
||||
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, afterAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
init,
|
||||
toBase64,
|
||||
@@ -53,16 +53,8 @@ import {
|
||||
runMetadataBackup,
|
||||
type MetadataBackupOptions,
|
||||
} from "../../src/metadata-backup.js";
|
||||
import { backupMetadataCommand } from "../../src/cli-commands.js";
|
||||
import type { KeyAttributes } from "../../src/auth/types.js";
|
||||
|
||||
// One file per ML data request, so the two files of the mock account are
|
||||
// fetched in two requests and one of them can fail on its own.
|
||||
vi.mock("../../src/mldata-fetch.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../src/mldata-fetch.js")>()),
|
||||
MLDATA_BATCH_SIZE: 1,
|
||||
}));
|
||||
|
||||
const TEST_EMAIL = "metabackup@example.com";
|
||||
const TEST_PASSWORD = "metapass";
|
||||
const TEST_OPS = 2;
|
||||
@@ -355,8 +347,7 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
|
||||
};
|
||||
};
|
||||
|
||||
// `failMLDataFor`: answer 500 to every ML data request that asks for this file.
|
||||
const buildMetaFetch = (m: MetaMockState, failMLDataFor?: number) => {
|
||||
const buildMetaFetch = (m: MetaMockState) => {
|
||||
let srpServer: SrpServer;
|
||||
return (async (
|
||||
input: RequestInfo | URL,
|
||||
@@ -412,8 +403,6 @@ const buildMetaFetch = (m: MetaMockState, failMLDataFor?: number) => {
|
||||
}
|
||||
if (path === "/files/data/fetch") {
|
||||
const body = JSON.parse(init?.body as string);
|
||||
if ((body.fileIDs as number[]).includes(failMLDataFor!))
|
||||
return new Response("server error", { status: 500 });
|
||||
const data = (body.fileIDs as number[])
|
||||
.filter((id: number) => m.encryptedMLData[id])
|
||||
.map((id: number) => ({
|
||||
@@ -647,63 +636,3 @@ describe("quak backup-metadata", () => {
|
||||
expect(failedMeta.imageMetadataError).toEqual(expect.any(String));
|
||||
});
|
||||
});
|
||||
|
||||
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: buildMetaFetch(mock, failMLDataFor),
|
||||
retry: { sleep: async () => {} },
|
||||
},
|
||||
});
|
||||
const outDir = mkdtempSync(join(testDir, "ml-fail-"));
|
||||
let stderr = "";
|
||||
const code = await backupMetadataCommand(
|
||||
{
|
||||
stdout: { write: () => true },
|
||||
stderr: { write: (text: string) => (stderr += text) },
|
||||
sessionDir: testDir,
|
||||
cacheDir: mkdtempSync(join(testDir, "cache-")),
|
||||
loadSession: () => client,
|
||||
},
|
||||
outDir,
|
||||
{},
|
||||
);
|
||||
return { code, stderr, outDir };
|
||||
};
|
||||
|
||||
it("writes every file, marks the failed batch's files, and exits 1", async () => {
|
||||
const { code, stderr, outDir } = await runCommand(200);
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain("ML data request for 1 file(s) failed");
|
||||
|
||||
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]);
|
||||
expect(ok.mlDataError).toBeUndefined();
|
||||
|
||||
const failed = JSON.parse(
|
||||
readFileSync(
|
||||
join(outDir, "collections", "20-__Work", "200.json"),
|
||||
"utf-8",
|
||||
),
|
||||
);
|
||||
expect(failed.metadata.title).toBe("diagram.png");
|
||||
expect(failed.mlData).toBeUndefined();
|
||||
expect(failed.mlDataError).toContain("500");
|
||||
});
|
||||
|
||||
it("exits 0 when every ML data request succeeds", async () => {
|
||||
const { code } = await runCommand();
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -223,8 +223,7 @@ afterAll(() => {
|
||||
* `sodium.randombytes_buf` goes through the wasm wrapper a byte at a time and
|
||||
* costs roughly 20 seconds for the 4 MiB chunk below — about two hundred
|
||||
* times what it costs to encrypt the same buffer, and on its own enough to
|
||||
* push `make test` past the 90-second `timeout` in the `test` phase of the
|
||||
* `Dockerfile`. This loop fills
|
||||
* push `make test` past the 30-second cap in `script/test`. This loop fills
|
||||
* 4 MiB in a few milliseconds.
|
||||
*/
|
||||
const patternBytes = (length: number, seed: number): Uint8Array => {
|
||||
@@ -1010,9 +1009,8 @@ describe.each(entryPoints)(
|
||||
expect(readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
// Root ignores directory permissions, so this cannot fail as root.
|
||||
// The `test` phase of the `Dockerfile` runs as the `node` user so
|
||||
// that `make check` runs it.
|
||||
// Root ignores directory permissions, so this cannot fail as root
|
||||
// (the Docker test image runs as root).
|
||||
it.skipIf(process.getuid?.() === 0)(
|
||||
"fails without creating anything when the destination directory is not writable",
|
||||
async () => {
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
* 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.
|
||||
* 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.
|
||||
@@ -500,63 +498,4 @@ describe("Library ML-data fetch on refresh", () => {
|
||||
await lib.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("starts empty when the cache directory holds another account's cache", async () => {
|
||||
// Account A fills the cache directory: metadata and ML data.
|
||||
const clientA = new MLMockClient();
|
||||
clientA.collectionsQueue.push({
|
||||
collections: [collection(1, 100)],
|
||||
deleted: [],
|
||||
cursor: 100,
|
||||
});
|
||||
clientA.filesFor(1, {
|
||||
files: [file(1001, 1, 90)],
|
||||
deleted: [],
|
||||
cursor: 90,
|
||||
});
|
||||
clientA.mlByFile.set(1001, payload([0.5, 0.25, 0.75]));
|
||||
const libA = await Library.open({
|
||||
client: clientA,
|
||||
cacheDirectory,
|
||||
refreshIntervalSeconds: 3600,
|
||||
});
|
||||
try {
|
||||
await vi.waitFor(
|
||||
() => expect(libA.status().lastMLFetchAt).toBeGreaterThan(0),
|
||||
{ timeout: 2000, interval: 5 },
|
||||
);
|
||||
} finally {
|
||||
await libA.close();
|
||||
}
|
||||
|
||||
// Account B opens the same directory.
|
||||
const clientB = new MLMockClient();
|
||||
clientB.userID = USER_ID + 1;
|
||||
const sinceTimes: number[] = [];
|
||||
const realCollectionsSince = clientB.collectionsSince.bind(clientB);
|
||||
clientB.collectionsSince = async (args) => {
|
||||
sinceTimes.push(args.sinceTime);
|
||||
return realCollectionsSince(args);
|
||||
};
|
||||
const libB = await Library.open({
|
||||
client: clientB,
|
||||
cacheDirectory,
|
||||
refreshIntervalSeconds: 3600,
|
||||
});
|
||||
try {
|
||||
expect(sinceTimes[0]).toBe(0);
|
||||
expect(libB.status().userID).toBe(USER_ID + 1);
|
||||
expect(libB.listCollections()).toEqual([]);
|
||||
expect(libB.getFile(1, 1001)).toBeUndefined();
|
||||
expect(await libB.mldata.forFile({ fileID: 1001 })).toBeUndefined();
|
||||
expect(
|
||||
libB.mldata.searchByEmbedding({ embedding: [0.5, 0.25, 0.75] }),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
existsSync(join(cacheDirectory, "mldata", "1001.json")),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
await libB.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user