Fetch, store, and index per-file ML data (closes #49)
check / check (push) Successful in 14s
check / check (push) Successful in 14s
Adds the machine-learning (magic) data layer: fetches per-file ML payloads (face detections + CLIP embeddings) via the existing metadata-backup fetch through the metadata pool after each refresh, decrypts and gunzips them, and stores one mldata/<fileID>.json per file by rename (present-means-complete). A derived index (mldata/clip.f32 + clip.json) loads in one read and is rebuilt whenever it disagrees with the payloads on disk in either direction, so an interrupted backfill self-heals. Never in metadata.json; incremental on later refreshes; progress via onProgress/status. Model: opus-4-8
This commit was merged in pull request #65.
This commit is contained in:
+137
-3
@@ -23,6 +23,8 @@ import { join } from "node:path";
|
||||
import envPaths from "env-paths";
|
||||
|
||||
import { MetadataStore } from "./store.js";
|
||||
import { MLDataStore } from "./mldata.js";
|
||||
import { RequestPools } from "./pools.js";
|
||||
import {
|
||||
deriveRecords,
|
||||
snapshotFrom,
|
||||
@@ -51,6 +53,7 @@ export {
|
||||
type GroupBy,
|
||||
} from "./read.js";
|
||||
import type { CollectionsPage, FilesPage } from "../client.js";
|
||||
import { MLDATA_BATCH_SIZE, type MLData } from "../mldata-fetch.js";
|
||||
import type { Collection, EnteFile } from "../model/types.js";
|
||||
|
||||
export const DEFAULT_REFRESH_INTERVAL_SECONDS = 3;
|
||||
@@ -66,14 +69,24 @@ export interface LibraryClient {
|
||||
collectionKey: Uint8Array;
|
||||
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.
|
||||
fetchMLData?(args: {
|
||||
fileIDs: number[];
|
||||
fileKeys: Map<number, Uint8Array>;
|
||||
}): Promise<Map<number, MLData>>;
|
||||
}
|
||||
|
||||
// A single refresh cycle's progress. "started" fires before the network work,
|
||||
// then exactly one of "done" or "failed"; "failed" carries the error message.
|
||||
// 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.
|
||||
export interface RefreshEvent {
|
||||
operation: "refresh";
|
||||
operation: "refresh" | "fetchMLData";
|
||||
status: "started" | "done" | "failed";
|
||||
error?: string;
|
||||
fetched?: number;
|
||||
}
|
||||
|
||||
export type RefreshProgressCallback = (event: RefreshEvent) => void;
|
||||
@@ -88,6 +101,9 @@ export interface LibraryOptions {
|
||||
downloadDirectory?: string;
|
||||
refreshIntervalSeconds?: number;
|
||||
onProgress?: RefreshProgressCallback;
|
||||
// The bounded request pools (issue #45). ML data is fetched through the
|
||||
// metadata pool. Defaults to a fresh set at the design's caps.
|
||||
pools?: RequestPools;
|
||||
}
|
||||
|
||||
export interface LibraryStatus {
|
||||
@@ -100,6 +116,15 @@ export interface LibraryStatus {
|
||||
// The message from the most recent refresh, set only while that refresh
|
||||
// failed; cleared by the next success.
|
||||
lastError?: string;
|
||||
// Wall-clock ms of the last ML fetch pass that succeeded, or undefined if
|
||||
// none has yet (or ML fetching is disabled).
|
||||
lastMLFetchAt?: number;
|
||||
// The most recent ML fetch pass's error, set only while it failed.
|
||||
lastMLError?: string;
|
||||
// ML payloads stored on disk and CLIP embeddings in the index; undefined
|
||||
// when ML fetching is disabled.
|
||||
mlStored?: number;
|
||||
mlIndexed?: number;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
@@ -119,12 +144,20 @@ export class Library {
|
||||
private readonly userID: number;
|
||||
private readonly intervalMs: number;
|
||||
private readonly onProgress?: RefreshProgressCallback;
|
||||
private readonly pools: RequestPools;
|
||||
// The ML-data cache, present only when the client can fetch ML data.
|
||||
private readonly mldata?: MLDataStore;
|
||||
|
||||
private timer?: ReturnType<typeof setTimeout>;
|
||||
private refreshing = false;
|
||||
// Guards the ML fetch pass so a slow backfill never runs twice at once; a
|
||||
// refresh whose pass is still running kicks nothing new.
|
||||
private mlFetching = false;
|
||||
private closed = false;
|
||||
private lastRefreshAt?: number;
|
||||
private lastError?: string;
|
||||
private lastMLFetchAt?: number;
|
||||
private lastMLError?: string;
|
||||
// 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
|
||||
@@ -144,6 +177,8 @@ export class Library {
|
||||
downloadDirectory?: string;
|
||||
intervalMs: number;
|
||||
onProgress?: RefreshProgressCallback;
|
||||
pools: RequestPools;
|
||||
mldata?: MLDataStore;
|
||||
}) {
|
||||
this.client = args.client;
|
||||
this.store = args.store;
|
||||
@@ -152,6 +187,8 @@ export class Library {
|
||||
this.downloadDirectory = args.downloadDirectory;
|
||||
this.intervalMs = args.intervalMs;
|
||||
this.onProgress = args.onProgress;
|
||||
this.pools = args.pools;
|
||||
this.mldata = args.mldata;
|
||||
this.lastRecords = this.deriveNow();
|
||||
|
||||
// The read namespaces derive fresh from the store on each call, so they
|
||||
@@ -181,6 +218,12 @@ export class Library {
|
||||
(opts.refreshIntervalSeconds ?? DEFAULT_REFRESH_INTERVAL_SECONDS) *
|
||||
1000;
|
||||
|
||||
// The ML cache only earns its keep when the client can fetch ML data;
|
||||
// a client without that capability opens no `mldata/` directory.
|
||||
const mldata = opts.client.fetchMLData
|
||||
? await MLDataStore.open(join(cacheDirectory, "mldata"))
|
||||
: undefined;
|
||||
|
||||
const lib = new Library({
|
||||
client: opts.client,
|
||||
store,
|
||||
@@ -189,6 +232,8 @@ export class Library {
|
||||
downloadDirectory: opts.downloadDirectory,
|
||||
intervalMs,
|
||||
onProgress: opts.onProgress,
|
||||
pools: opts.pools ?? new RequestPools(),
|
||||
mldata,
|
||||
});
|
||||
|
||||
if (store.loadedFromDisk) {
|
||||
@@ -248,12 +293,17 @@ export class Library {
|
||||
for (const c of collections) {
|
||||
files += this.store.listFiles(c.id).length;
|
||||
}
|
||||
const ml = this.mldata?.stats();
|
||||
return {
|
||||
userID: this.store.userID,
|
||||
collections: collections.length,
|
||||
files,
|
||||
lastRefreshAt: this.lastRefreshAt,
|
||||
lastError: this.lastError,
|
||||
lastMLFetchAt: this.lastMLFetchAt,
|
||||
lastMLError: this.lastMLError,
|
||||
mlStored: ml?.stored,
|
||||
mlIndexed: ml?.indexed,
|
||||
closed: this.closed,
|
||||
};
|
||||
}
|
||||
@@ -288,6 +338,11 @@ export class Library {
|
||||
this.lastRefreshAt = Date.now();
|
||||
this.lastError = undefined;
|
||||
this.emit({ operation: "refresh", status: "done" });
|
||||
// Backfill ML data for the files this refresh knows about. It runs
|
||||
// outside the refresh's success/failure so a fetch or disk problem
|
||||
// there never marks the metadata refresh failed, and it is not
|
||||
// awaited so it never stalls the refresh interval.
|
||||
void this.runMLFetch();
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
this.lastError = error;
|
||||
@@ -388,6 +443,85 @@ 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. Guarded
|
||||
// so passes never overlap; a failure is reported, not thrown.
|
||||
private async runMLFetch(): Promise<void> {
|
||||
const mldata = this.mldata;
|
||||
// Bind so the call keeps the client as its receiver when invoked
|
||||
// through the pool below.
|
||||
const fetchMLData = this.client.fetchMLData?.bind(this.client);
|
||||
if (!mldata || !fetchMLData || this.closed || this.mlFetching) return;
|
||||
|
||||
const files = this.uniqueFiles();
|
||||
const needed = mldata.neededFor(files);
|
||||
if (needed.length === 0) return;
|
||||
|
||||
this.mlFetching = true;
|
||||
this.emit({ operation: "fetchMLData", status: "started" });
|
||||
try {
|
||||
const fileKeys = new Map<number, Uint8Array>();
|
||||
const updation = new Map<number, number>();
|
||||
for (const f of files) {
|
||||
fileKeys.set(f.id, f.key);
|
||||
updation.set(f.id, f.updationTime);
|
||||
}
|
||||
|
||||
let stored = 0;
|
||||
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(
|
||||
() => fetchMLData({ fileIDs: batch, fileKeys }),
|
||||
{ priority: "background" },
|
||||
);
|
||||
stored += (await mldata.storeFetched(payloads, updation))
|
||||
.stored;
|
||||
}
|
||||
|
||||
this.lastMLFetchAt = Date.now();
|
||||
this.lastMLError = undefined;
|
||||
this.emit({
|
||||
operation: "fetchMLData",
|
||||
status: "done",
|
||||
fetched: stored,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
this.lastMLError = error;
|
||||
this.emit({ operation: "fetchMLData", status: "failed", error });
|
||||
} finally {
|
||||
this.mlFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
private uniqueFiles(): {
|
||||
id: number;
|
||||
key: Uint8Array;
|
||||
updationTime: number;
|
||||
}[] {
|
||||
const byID = new Map<
|
||||
number,
|
||||
{ id: number; key: Uint8Array; updationTime: number }
|
||||
>();
|
||||
for (const collection of this.store.listCollections()) {
|
||||
for (const f of this.store.listFiles(collection.id)) {
|
||||
const seen = byID.get(f.id);
|
||||
if (seen === undefined || f.updationTime > seen.updationTime)
|
||||
byID.set(f.id, {
|
||||
id: f.id,
|
||||
key: f.key,
|
||||
updationTime: f.updationTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...byID.values()];
|
||||
}
|
||||
|
||||
// Gather every file membership and project the store into by-id records.
|
||||
private deriveNow(): DerivedRecords {
|
||||
const collections = this.store.listCollections();
|
||||
|
||||
Reference in New Issue
Block a user