1 Commits
Author SHA1 Message Date
sneak 6eac17b348 quak files COLLECTION fetches that collection's files alone (closes #217)
check / check (push) Canceled after 0s
`quak files COLLECTION` used to wait for a full refresh, which on an
empty cache fetched the files of every collection before it listed one.
It now reads the collection list, then calls the new
`Library.refreshCollection()`. That brings one collection up to date in
the store as a refresh would, and saves it. It runs in the same slot as
a refresh, so the two never run at the same time, and `close()` waits
for it. It never stores a collection that a refresh has already dropped.
It leaves the refresh cursor alone. `open()` therefore awaits the first
refresh on a cache whose cursor is still 0. `quak files` with no
argument still waits for a refresh.

Model: opus-5-5
2026-10-26 00:43:29 +00:00
9 changed files with 328 additions and 176 deletions
+36 -23
View File
@@ -688,17 +688,18 @@ held. `collections` asks the server for the collection list alone, in one
request, and neither fetches any collection's files nor changes the cache. request, and neither fetches any collection's files nor changes the cache.
`files` with a collection ID asks for the collection list the same way, then for `files` with a collection ID asks for the collection list the same way, then for
the files of that collection alone, and stores them in the cache. It asks for the files of that collection alone, and stores them in the cache. It asks for
none of them when the cache's copy of that collection is current. The others none of them when the cache's copy of that collection is current. `files`
wait for a refresh. With an empty cache, as on a first run, that refresh fetches without a collection ID and the other read commands wait for a refresh. With an
the files of every collection, one collection after another, and stores nothing empty cache, as on a first run, that refresh fetches the files of every
until it has them all, which on a large account takes a while. Meanwhile the collection, one collection after another, and stores nothing until it has them
command logs a line on stderr for each collection whose files it has fetched, all, which on a large account takes a while. Meanwhile the command logs a line
such as `Fetched the files of 3 of 120 collections`, with `--json` too; `backup` on stderr for each collection whose files it has fetched, such as
does the same while the refresh it starts with runs. stdout holds only the `Fetched the files of 3 of 120 collections`, with `--json` too; `backup` does
command's output. If that round-trip fails, the command prints the error on one the same while the refresh it starts with runs. stdout holds only the command's
line and exits 1, or 3 when the server no longer accepts the saved session (see output. If that round-trip fails, the command prints the error on one line and
"Session handling"). An album or file quak cannot decrypt does not fail it; the exits 1, or 3 when the server no longer accepts the saved session (see "Session
command answers from what did decrypt (see "Default reads vs. fresh reads"). handling"). An album or file quak cannot decrypt does not fail it; the command
answers from what did decrypt (see "Default reads vs. fresh reads").
`--cache-dir` overrides where the cache lives; without it each account gets its `--cache-dir` overrides where the cache lives; without it each account gets its
own directory under the per-user cache path. own directory under the per-user cache path.
@@ -1096,9 +1097,11 @@ against a fake of the Ente HTTP API, and `yarn test` verifies them.
`Library.open(options)` loads the on-disk cache, starts the background refresh `Library.open(options)` loads the on-disk cache, starts the background refresh
loop, and resolves to a `Library`. On an empty cache it awaits the first loop, and resolves to a `Library`. On an empty cache it awaits the first
refresh, so it opens onto the account's data whenever the server is reachable; refresh, so it opens onto the account's data whenever the server is reachable.
if that refresh fails, it opens with no data and records the error in It also awaits it on a cache that holds only what `lib.refreshCollection()`
`lib.status()`. On an existing cache it returns immediately and refreshes in the stored, so it never serves one collection as the whole account. If that refresh
fails, it opens onto what the cache holds and records the error in
`lib.status()`. On any other cache it returns immediately and refreshes in the
background, so an unreachable server does not block opening. With background, so an unreachable server does not block opening. With
`refreshOnOpen: false` it starts no refresh and returns at once onto what the `refreshOnOpen: false` it starts no refresh and returns at once onto what the
cache holds, empty or not; a refresh then starts only when `fresh()` or cache holds, empty or not; a refresh then starts only when `fresh()` or
@@ -1141,11 +1144,16 @@ and pass it. The three pools default to 10 / 5 / 25 (see Request pools below).
`lib.status()` returns a `LibraryStatus` (collection/file counts, last `lib.status()` returns a `LibraryStatus` (collection/file counts, last
refresh/ML times and errors, originals usage and effective limit, precache refresh/ML times and errors, originals usage and effective limit, precache
progress, and `closed`). `lib.close()` stops the background timer; it is progress, and `closed`).
idempotent, and an in-flight refresh is left to finish. The promise it returns
resolves once that refresh (including its cache write), the ML data fetch and `lib.close()` stops the background timer, and is idempotent. A refresh or
the precache fetches already running have all finished, so the cache directory `lib.refreshCollection()` in flight is left to finish. The promise `close()`
can then be removed. returns resolves once all of these have finished, so the cache directory can
then be removed:
- that refresh or `lib.refreshCollection()`, with its cache write
- the ML data fetch
- the precache fetches already running
`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 that succeeded got but could not decrypt, gunzip or
@@ -1187,10 +1195,15 @@ https://git.eeqj.de/sneak/quak/issues/210).
the cache, given its `Collection` from `freshCollections()`. It does for that the cache, given its `Collection` from `freshCollections()`. It does for that
collection what a refresh does for a changed one, and fetches no other collection what a refresh does for a changed one, and fetches no other
collection's files. It sends no request when the cache's copy has the same collection's files. It sends no request when the cache's copy has the same
`updationTime`. Otherwise it fetches the collection's files changed since that `updationTime` or a later one. It also sends none when a refresh has already
copy, or all of them when there is none, then stores and saves them. The refresh dropped the collection from the cache, because the server deleted it or it did
cursor stays where it was, so the next refresh still fetches the files of every not decrypt. Otherwise it fetches the collection's files changed since the
other collection that changed. `quak files` with a collection ID uses it. cache's copy, or all of them when there is none, then stores and saves them. The
refresh cursor stays where it was, so the next refresh still fetches the files
of every other collection that changed. It never runs at the same time as a
refresh. It waits for a running refresh to end, and a `fresh()` or
`lib.backup()` called meanwhile waits for it. `quak files` with a collection ID
uses it.
An album or file in the server's answer that quak cannot decrypt, or whose An album or file in the server's answer that quak cannot decrypt, or whose
metadata is not JSON, does not fail the refresh. The refresh leaves it out, an metadata is not JSON, does not fail the refresh. The refresh leaves it out, an
+3 -1
View File
@@ -33,7 +33,9 @@ declares one.
one collection up to date through the new `lib.refreshCollection()`. That one collection up to date through the new `lib.refreshCollection()`. That
stores and saves its files as a refresh would, and leaves the refresh cursor stores and saves its files as a refresh would, and leaves the refresh cursor
where it was. When the stored copy is current, it asks for none of the files. where it was. When the stored copy is current, it asks for none of the files.
`quak files` with no argument still waits for a refresh. It never runs at the same time as a refresh. A library opened on a cache that
only it wrote awaits its first refresh. `quak files` with no argument still
waits for a refresh.
- 2026-10-22: The JSON `quak backup` and `quak backup-metadata` write for a file - 2026-10-22: The JSON `quak backup` and `quak backup-metadata` write for a file
holds `keywords` in place of the CLIP embedding in its ML data holds `keywords` in place of the CLIP embedding in its ML data
+4 -4
View File
@@ -128,10 +128,10 @@ const readLibraryClient = (client: Client): LibraryClient => ({
contentSource: () => client.contentSource(), contentSource: () => client.contentSource(),
}); });
// Open a library for a single point command: the aggressive background precache // Open a library for a single point command. The aggressive background
// (issue #48) is off — a one-shot `collections` or `get` must not start // precache (issue #48) is off, because a one-shot `collections` or `get` must
// downloading the whole account — and no refresh starts as it opens or on a // not start downloading the whole account. No refresh starts as it opens or on
// timer: each command asks for the one it needs, and `collections` and // a timer. Each command asks for the refresh it needs, and `collections` and
// `files COLLECTION` need none (issues #210 and #217). While a refresh runs, // `files COLLECTION` need none (issues #210 and #217). While a refresh runs,
// each collection whose files it has fetched is logged, so a large account // each collection whose files it has fetched is logged, so a large account
// shows progress rather than nothing. // shows progress rather than nothing.
+12 -12
View File
@@ -4,14 +4,14 @@
// the account's state at the moment the command runs, not for whatever the // the account's state at the moment the command runs, not for whatever the
// local cache last happened to hold (owner amendment, issue #36). `files` // local cache last happened to hold (owner amendment, issue #36). `files`
// without a collection ID, `get` and `get-thumb` therefore force a server // without a collection ID, `get` and `get-thumb` therefore force a server
// round-trip through `Library.fresh()` and only then read — so a collection, // round-trip through `Library.fresh()`, and only then read. A collection, file,
// file, or metadata change made elsewhere is visible. `collections` asks the // or metadata change made elsewhere is then visible. `collections` asks the
// library for the collection list alone through `Library.freshCollections()` // library for the collection list alone through `Library.freshCollections()`
// (issue #210): a refresh would also fetch the files of every collection, which // (issue #210). A refresh would also fetch the files of every collection, which
// on a large account takes long and which `collections` does not print. For the // `collections` does not print, and on a large account that takes long.
// same reason, `files COLLECTION` reads the collection list the same way and // `files COLLECTION` reads the collection list the same way, for the same
// then brings that one collection up to date through // reason. It then brings that one collection up to date through
// `Library.refreshCollection()` (issue #217), fetching no other collection's // `Library.refreshCollection()` (issue #217), and fetches no other collection's
// files. // files.
// //
// `collections` lists in the server's order and `files` in the library's own // `collections` lists in the server's order and `files` in the library's own
@@ -27,9 +27,9 @@ import type { Collection, EnteFile } from "./model/types.js";
import type { Photo, PhotosAPI } from "./library/index.js"; import type { Photo, PhotosAPI } from "./library/index.js";
// The slice of `Library` these helpers read. `Library` satisfies it // The slice of `Library` these helpers read. `Library` satisfies it
// structurally; a test can drive them with a stand-in that records the // structurally. A test can drive them with a stand-in that records the
// `fresh()`, `freshCollections()` and `refreshCollection()` calls and serves // `fresh()`, `freshCollections()` and `refreshCollection()` calls. The
// records in a known enumeration order. // stand-in serves records in a known enumeration order.
export interface FreshReadLibrary { export interface FreshReadLibrary {
fresh(): Promise<unknown>; fresh(): Promise<unknown>;
freshCollections(): Promise<Collection[]>; freshCollections(): Promise<Collection[]>;
@@ -50,8 +50,8 @@ export const freshCollections = (
// collections request and the requests that bring that collection alone up to // collections request and the requests that bring that collection alone up to
// date. `undefined` (not an empty list) when the collection does not exist, so // date. `undefined` (not an empty list) when the collection does not exist, so
// the caller can tell "no such collection" from "an empty collection". Without // the caller can tell "no such collection" from "an empty collection". Without
// a collection ID, every file in the account, current as of a forced refresh, // a collection ID, every file in the account, current as of a forced refresh.
// each once, as held by the first collection that holds it. // Each file is listed once, as held by the first collection that holds it.
export const freshFiles = async ( export const freshFiles = async (
lib: FreshReadLibrary, lib: FreshReadLibrary,
collectionID?: number, collectionID?: number,
+115 -79
View File
@@ -1,15 +1,16 @@
// The library surface over the local cache. // The library surface over the local cache.
// //
// `Library.open()` loads the on-disk metadata store (issue #41), then starts // `Library.open()` loads the on-disk metadata store (issue #41), then starts
// the refresh loop. When the cache loaded empty it awaits the first refresh, // the refresh loop. When no refresh has stored a collection yet (the stored
// so the library never opens onto an empty store it could have filled; when an // cursor is 0), `open()` awaits the first refresh. The cache is then empty, or
// existing copy loaded, that first refresh runs in the background and `open()` // holds only what `refreshCollection()` stored, and the library must not serve
// returns as soon as the cached data is ready to serve — a slow or unreachable // it as the whole account. Otherwise that first refresh runs in the
// server no longer stalls opening. A background timer then refreshes every // background. `open()` returns as soon as the cached data is ready to serve,
// `refreshIntervalSeconds`. Every default read is answered from RAM — no // so a slow or unreachable server does not stall opening. A background timer
// default read touches the network. There is deliberately no `sync()`, no // then refreshes every `refreshIntervalSeconds`. Every default read is
// `refresh()`, no `serverReachable` flag, and no "before each read" mode // answered from RAM — no default read touches the network. There is
// (design #36). // deliberately no `sync()`, no `refresh()`, no `serverReachable` flag, and no
// "before each read" mode (design #36).
// //
// `fresh()` is the one exception (issue #75, an owner amendment to #36): it // `fresh()` is the one exception (issue #75, an owner amendment to #36): it
// forces a refresh, awaits it, and only then hands back the read namespaces, so // forces a refresh, awaits it, and only then hands back the read namespaces, so
@@ -22,10 +23,10 @@
// #210): one collections request, no collection's files, and nothing stored, // #210): one collections request, no collection's files, and nothing stored,
// for a caller such as `quak collections` that needs no more. // for a caller such as `quak collections` that needs no more.
// `refreshCollection()` brings one collection from that list up to date in the // `refreshCollection()` brings one collection from that list up to date in the
// store (issue #217): it fetches that collection's files alone, as a refresh // store (issue #217). It fetches that collection's files alone, as a refresh
// would, for a caller such as `quak files COLLECTION`. A caller that asks for // would. `quak files COLLECTION` uses it. It never runs at the same time as a
// each refresh it needs can also open the library with `refreshOnOpen: false`, // refresh. A caller that asks for each refresh it needs can also open the
// so that `open()` starts none. // library with `refreshOnOpen: false`, so that `open()` starts none.
// //
// A refresh reports through `onProgress` each collection whose files it has // A refresh reports through `onProgress` each collection whose files it has
// fetched, so a large account, whose first refresh fetches the files of every // fetched, so a large account, whose first refresh fetches the files of every
@@ -309,12 +310,16 @@ export class Library {
private readonly precache?: Precache; private readonly precache?: Precache;
private timer?: ReturnType<typeof setTimeout>; private timer?: ReturnType<typeof setTimeout>;
// The in-flight refresh cycle, or undefined when none runs. One slot serves // The in-flight refresh cycle or `refreshCollection()`, or undefined when
// both paths: the background loop skips when it is set, and a fresh read // neither runs. One slot holds both, so they never run at the same time.
// (issue #75) coalesces onto it or starts one. The promise carries the // The background loop skips while it is set. A fresh read (issue #75)
// cycle's real outcome (it rejects on failure); the background loop ignores // coalesces onto a running refresh. It waits for a running
// that, a fresh read propagates it. // `refreshCollection()` to end, and then joins or starts a refresh. The
// promise carries the real outcome (it rejects on failure); the background
// loop ignores that, a fresh read propagates it.
private cycle?: Promise<void>; private cycle?: Promise<void>;
// Whether `cycle` is a refresh cycle rather than a `refreshCollection()`.
private cycleIsRefresh = false;
// Guards the ML fetch pass so a slow backfill never runs twice at once; a // Guards the ML fetch pass so a slow backfill never runs twice at once; a
// refresh whose pass is still running kicks nothing new. Holds the running // refresh whose pass is still running kicks nothing new. Holds the running
// pass, so `close()` and `backup()` can wait for it. It rejects when the // pass, so `close()` and `backup()` can wait for it. It rejects when the
@@ -396,14 +401,16 @@ export class Library {
this.mldata = makeMLDataAPI(() => this.mlStore); this.mldata = makeMLDataAPI(() => this.mlStore);
} }
// Load the cache and start the refresh loop. With an empty cache the first // Load the cache and start the refresh loop. When the stored cursor is 0,
// refresh is awaited, so `open()` resolves onto populated data whenever the // the first refresh is awaited. Such a cache is empty, or holds only what
// server is reachable; that awaited refresh may still fail, and the library // `refreshCollection()` stored. `open()` then resolves onto the whole
// then opens empty with the failure recorded in `status()`. With an // account whenever the server is reachable. If that refresh fails, the
// existing cache the first refresh runs in the background and `open()` // library opens onto what the cache holds, with the failure recorded in
// returns as soon as the cached data is ready — an unreachable server does // `status()`. With any other cache the first refresh runs in the
// not block opening. With `refreshOnOpen: false` there is no first // background, and `open()` returns as soon as the cached data is ready.
// refresh: `open()` returns onto the cache as it is. // An unreachable server then does not block opening. With
// `refreshOnOpen: false` there is no first refresh: `open()` returns onto
// the cache as it is.
static async open(opts: LibraryOptions): Promise<Library> { static async open(opts: LibraryOptions): Promise<Library> {
const { userID } = opts.client.whoami(); const { userID } = opts.client.whoami();
const cacheDirectory = const cacheDirectory =
@@ -509,13 +516,17 @@ export class Library {
if (opts.refreshOnOpen === false) { if (opts.refreshOnOpen === false) {
// The caller asks for each refresh it needs. // The caller asks for each refresh it needs.
lib.scheduleNext(); lib.scheduleNext();
} else if (store.loadedFromDisk) { } else if (store.collectionsSinceTime > 0) {
// An existing copy already answers reads; refresh in the background // The stored cursor has moved, so a refresh has stored the
// and start the interval once that first cycle settles. // account's collections and the cache's copy answers reads.
// Refresh in the background, and start the interval once that
// first cycle settles.
void lib.runRefresh().then(() => lib.scheduleNext()); void lib.runRefresh().then(() => lib.scheduleNext());
} else { } else {
// Nothing was cached: wait for the first refresh to fill the store // The stored cursor is still 0, so no refresh has stored any
// (or fail) rather than resolve onto an empty library. // collection: the cache is empty, or holds only what
// `refreshCollection()` stored. Wait for the first refresh to fill
// the store (or fail) rather than resolve onto part of the account.
await lib.runRefresh(); await lib.runRefresh();
lib.scheduleNext(); lib.scheduleNext();
} }
@@ -630,34 +641,16 @@ export class Library {
// Bring one collection up to date in the store (issue #217), given its // Bring one collection up to date in the store (issue #217), given its
// record from `freshCollections()`. It does for that collection what a // record from `freshCollections()`. It does for that collection what a
// refresh does for a changed one, and fetches no other collection's files. // refresh does for a changed one. It fetches no other collection's files.
// It sends no request when the stored copy's `updationTime` is as new. // It runs in the same slot as a refresh, so the two never run at the same
// Otherwise it fetches the files changed since the stored copy, or all of // time. It first waits for a running refresh or `refreshCollection()` to
// them when none is stored, then commits and saves them. A refresh already // end. A fresh read or `backup()` that starts meanwhile waits for it, and
// running is waited for first. // the background loop skips its turn.
async refreshCollection(collection: Collection): Promise<void> { async refreshCollection(collection: Collection): Promise<void> {
while (this.cycle) await this.cycle.catch(() => {});
if (this.closed) throw new Error("the library is closed"); if (this.closed) throw new Error("the library is closed");
await this.cycle?.catch(() => {}); await this.startCycle(false, () =>
const known = this.store.getCollection(collection.id); this.refreshCollectionOnce(collection),
if (known && collection.updationTime <= known.updationTime) return;
const filePage = await this.client.filesSince({
collectionID: collection.id,
collectionKey: collection.key,
sinceTime: known ? known.updationTime : 0,
});
// A refresh that ran during the fetch may have stored a copy as new as
// `collection` or newer. Committing this result would replace it.
const stored = this.store.getCollection(collection.id);
if (stored && collection.updationTime <= stored.updationTime) return;
// The stored cursor stays where it is, so the next refresh still
// fetches the files of every other collection that changed.
await this.commit(
{
collections: [collection],
deleted: [],
cursor: this.store.collectionsSinceTime,
},
[{ collectionID: collection.id, page: filePage }],
); );
} }
@@ -710,11 +703,13 @@ export class Library {
); );
} }
// Stop the background timer. Idempotent. An in-flight refresh is left to // Stop the background timer. Idempotent. A refresh or `refreshCollection()`
// finish; it will not schedule another cycle once closed. The returned // in flight is left to finish, and nothing starts after it. The returned
// promise resolves once that refresh (including its cache write), the ML // promise resolves once all of these have finished:
// fetch pass and the precache fetches already running have all finished, // - that refresh or `refreshCollection()`, with its cache write;
// so a caller can then remove the cache directory. A refresh failure is // - the ML fetch pass;
// - the precache fetches already running.
// A caller can then remove the cache directory. A refresh failure is
// reported through `status()`, not thrown here. // reported through `status()`, not thrown here.
async close(): Promise<void> { async close(): Promise<void> {
this.closed = true; this.closed = true;
@@ -738,33 +733,40 @@ export class Library {
this.timer.unref?.(); this.timer.unref?.();
} }
// The background loop's refresh: run a cycle unless one is already in flight // The background loop's refresh: run a cycle unless a refresh or
// (or the library is closed), and never let a failure escape — the // `refreshCollection()` is in flight (or the library is closed), and never
// background path reports errors through `status()`/`onProgress`, it does // let a failure escape. The background path reports errors through
// not throw. Resolves once the cycle it started (or skipped past) settles. // `status()`/`onProgress`; it does not throw. Resolves once the cycle it
// started (or skipped past) settles.
private runRefresh(): Promise<void> { private runRefresh(): Promise<void> {
if (this.closed || this.cycle) return Promise.resolve(); if (this.closed || this.cycle) return Promise.resolve();
return this.startCycle().catch(() => {}); return this.startCycle(true, () => this.refreshCycle()).catch(() => {});
} }
// A fresh read's refresh (issue #75): force a cycle and await it, rejecting // A fresh read's refresh (issue #75): force a cycle and await it, rejecting
// if it fails. Concurrent fresh reads coalesce onto the one in-flight cycle // if it fails. Concurrent fresh reads coalesce onto the one in-flight cycle
// — the background loop's included — so they never fan out into redundant // — the background loop's included — so they never fan out into redundant
// server round-trips. // server round-trips. A running `refreshCollection()` is waited for first.
private refreshNow(): Promise<void> { private async refreshNow(): Promise<void> {
if (this.closed) { while (this.cycle && !this.cycleIsRefresh) {
return Promise.reject(new Error("the library is closed")); await this.cycle.catch(() => {});
} }
return this.cycle ?? this.startCycle(); if (this.closed) throw new Error("the library is closed");
return this.cycle ?? this.startCycle(true, () => this.refreshCycle());
} }
// Start one refresh cycle and record it as the in-flight cycle so every // Start `work` and record it in the in-flight slot, so every other caller
// caller coalesces onto it. The returned promise carries the cycle's real // sees it. `isRefresh` says whether it is a refresh cycle, which a fresh
// outcome; each caller attaches the handling its own path needs, and the // read can coalesce onto. The returned promise carries the real outcome.
// slot is cleared once the cycle settles. // Each caller attaches the handling its own path needs, and the slot is
private startCycle(): Promise<void> { // cleared once the work settles.
const cycle = this.refreshCycle(); private startCycle(
isRefresh: boolean,
work: () => Promise<void>,
): Promise<void> {
const cycle = work();
this.cycle = cycle; this.cycle = cycle;
this.cycleIsRefresh = isRefresh;
void cycle.then( void cycle.then(
() => { () => {
if (this.cycle === cycle) this.cycle = undefined; if (this.cycle === cycle) this.cycle = undefined;
@@ -838,9 +840,43 @@ export class Library {
await this.commit(page, filePages); await this.commit(page, filePages);
} }
// `refreshCollection()`'s work, run in the slot: fetch the files of one
// collection changed since the stored copy, then commit.
private async refreshCollectionOnce(collection: Collection): Promise<void> {
const known = this.store.getCollection(collection.id);
// The stored copy is as new or newer, so there is nothing to fetch.
if (known && collection.updationTime <= known.updationTime) return;
// No copy is stored, yet the stored cursor has reached this
// `updationTime`. A refresh has then seen this version or a later one,
// and dropped the collection: it was deleted, or did not decrypt.
// Storing it would bring it back for good, since no later refresh sees
// it again.
if (
!known &&
collection.updationTime <= this.store.collectionsSinceTime
) {
return;
}
const filePage = await this.client.filesSince({
collectionID: collection.id,
collectionKey: collection.key,
sinceTime: known ? known.updationTime : 0,
});
// The stored cursor stays where it is, so the next refresh still
// fetches the files of every other collection that changed.
await this.commit(
{
collections: [collection],
deleted: [],
cursor: this.store.collectionsSinceTime,
},
[{ collectionID: collection.id, page: filePage }],
);
}
// Commit a collections page and the file pages fetched for its collections // Commit a collections page and the file pages fetched for its collections
// to the store, then save. A collection or file that does not decrypt is // to the store, then save. A collection or file that does not decrypt is
// left out, as if deleted, with the reason kept in the store, and no later // left out, as if deleted. The reason is kept in the store. No later
// refresh sees it again until the server's copy changes. // refresh sees it again until the server's copy changes.
private async commit( private async commit(
page: CollectionsPage, page: CollectionsPage,
-7
View File
@@ -70,12 +70,6 @@ export class MetadataStore {
userID = 0; userID = 0;
collectionsSinceTime: Microseconds = 0; collectionsSinceTime: Microseconds = 0;
// True when `load` populated this store from a valid existing file; false
// on a first run or a missing/corrupt/wrong-version file that loaded empty.
// `Library.open` reads it to decide whether the first refresh may run in
// the background (an existing copy already serves reads) or must be awaited.
loadedFromDisk = false;
private readonly collections = new Map<number, Collection>(); private readonly collections = new Map<number, Collection>();
private readonly files = new Map<string, EnteFile>(); private readonly files = new Map<string, EnteFile>();
private undecryptable: Undecryptable[] = []; private undecryptable: Undecryptable[] = [];
@@ -102,7 +96,6 @@ export class MetadataStore {
if (parsed.schemaVersion !== METADATA_SCHEMA_VERSION) { if (parsed.schemaVersion !== METADATA_SCHEMA_VERSION) {
return store; return store;
} }
store.loadedFromDisk = true;
store.userID = parsed.userID ?? 0; store.userID = parsed.userID ?? 0;
store.collectionsSinceTime = parsed.collectionsSinceTime ?? 0; store.collectionsSinceTime = parsed.collectionsSinceTime ?? 0;
for (const stored of parsed.collections ?? []) { for (const stored of parsed.collections ?? []) {
+5 -4
View File
@@ -1577,12 +1577,13 @@ describe("an ID given on the command line", () => {
// An account with many albums (issue #210). On an empty cache `collections` // An account with many albums (issue #210). On an empty cache `collections`
// used to wait for a refresh that fetched and decrypted the files of every // used to wait for a refresh that fetched and decrypted the files of every
// album, one album after another, before it printed anything, so on such an // album, one album after another. It printed nothing until then, so on such an
// account it seemed to hang. `files` with an album's ID did the same before it // account it seemed to hang. `files` with an album's ID did the same before it
// listed that album (issue #217). The account here has 40 albums of one photo // listed that album (issue #217). The account here has 40 albums of one photo
// each, encrypted as Ente's clients encrypt them, on the fake Ente server of // each, encrypted as Ente's clients encrypt them. The fake Ente server of
// `test/ente-server.ts`, which also records the path and query of each request // `test/ente-server.ts` serves them. A wrapper around it records the path and
// quak sends. The session is the one logging in to that server saves. // query of each request quak sends. The session is the one logging in to that
// server saves.
describe("an account with many albums", () => { describe("an account with many albums", () => {
const ALBUMS = 40; const ALBUMS = 40;
const PASSWORD = "many-albums"; const PASSWORD = "many-albums";
+8 -8
View File
@@ -4,15 +4,15 @@
* *
* The `collections`, `files`, `get`, and `get-thumb` commands must answer for * The `collections`, `files`, `get`, and `get-thumb` commands must answer for
* current server state, not the local cache. `files` without a collection ID, * current server state, not the local cache. `files` without a collection ID,
* `get` and `get-thumb` force a `Library.fresh()` round-trip before they read; * `get` and `get-thumb` force a `Library.fresh()` round-trip before they read.
* the stand-in library below serves nothing until `fresh()` has been awaited, * The stand-in library below serves nothing until `fresh()` has been awaited.
* so a helper that read without refreshing would come back empty and fail * A helper that read without refreshing would come back empty and fail here.
* here. `collections` asks the library for the collection list alone * `collections` asks the library for the collection list alone
* (`freshCollections()`, issue #210), and must not wait for a refresh, which * (`freshCollections()`, issue #210). It must not wait for a refresh, which
* would fetch the files of every collection. `files COLLECTION` reads that list * would fetch the files of every collection. `files COLLECTION` reads that list
* too and then brings that one collection up to date * too, then brings that one collection up to date (`refreshCollection()`,
* (`refreshCollection()`, issue #217); the stand-in serves a collection's files * issue #217). The stand-in serves a collection's files once `fresh()` or
* once either has run. * `refreshCollection()` for that collection has run.
* *
* `collections` and `files` also list in the library's enumeration order * `collections` and `files` also list in the library's enumeration order
* (`listCollections`/`listFiles`) — the order the pre-library CLI printed — not * (`listCollections`/`listFiles`) — the order the pre-library CLI printed — not
+145 -38
View File
@@ -17,10 +17,12 @@
* 4. `freshCollections()` reads the collection list alone, with one request * 4. `freshCollections()` reads the collection list alone, with one request
* and no collection's files, and stores nothing, so the next refresh still * and no collection's files, and stores nothing, so the next refresh still
* fetches the files of every collection (issue #210). * fetches the files of every collection (issue #210).
* 5. `refreshCollection()` brings one collection up to date in the store and * 5. `refreshCollection()` brings one collection up to date in the store
* leaves the stored cursor where it was, so the next refresh still fetches * (issue #217). It leaves the stored cursor where it was, so the next
* the files of every other collection that changed (issue #217). A newer * refresh still fetches the files of every other collection that changed.
* copy that a refresh stores while it fetches the files is kept. * It never runs at the same time as a refresh, and never brings back a
* collection that a refresh deleted. `close()` waits for it. A library
* opened on a cache that only it wrote awaits its first refresh.
* *
* The client is the same metadata-only mock the background-refresh tests use: * The client is the same metadata-only mock the background-refresh tests use:
* no crypto, no network, scripted pages, and a record of each call. A long * no crypto, no network, scripted pages, and a record of each call. A long
@@ -34,6 +36,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { Library } from "../../src/library/index.js"; import { Library } from "../../src/library/index.js";
import { MetadataStore } from "../../src/library/store.js";
import type { CollectionsPage, FilesPage } from "../../src/client.js"; import type { CollectionsPage, FilesPage } from "../../src/client.js";
import type { Collection, EnteFile } from "../../src/model/types.js"; import type { Collection, EnteFile } from "../../src/model/types.js";
@@ -148,6 +151,26 @@ const openSeeded = async (
return { client, lib }; return { client, lib };
}; };
// Hold the client's next files request until `release()` is called. `started`
// resolves once that request has been sent.
const holdNextFilesRequest = (
client: MockClient,
): { started: Promise<void>; release: () => void } => {
let release!: () => void;
const held = new Promise<void>((r) => (release = r));
let sent!: () => void;
const started = new Promise<void>((r) => (sent = r));
const realFilesSince = client.filesSince.bind(client);
client.filesSince = async (args) => {
client.filesSince = realFilesSince;
const page = await realFilesSince(args);
sent();
await held;
return page;
};
return { started, release };
};
describe("Library.fresh", () => { describe("Library.fresh", () => {
let dir: string; let dir: string;
let cacheDirectory: string; let cacheDirectory: string;
@@ -427,48 +450,132 @@ describe("Library.refreshCollection", () => {
}); });
// Collection 1 is stored at 100. `refreshCollection()` asks for it at 110, // Collection 1 is stored at 100. `refreshCollection()` asks for it at 110,
// which adds file 1002. While that files request waits, a refresh stores // which adds file 1002. While that files request waits, a fresh read
// it at 120, where 1002 is deleted again and 1003 added. // starts a refresh. The server tells that refresh that collection 1 was
it("keeps a newer copy that a refresh stores while the files are fetched", async () => { // deleted at 130.
it("holds off a refresh that starts during the files request, which then deletes the collection", async () => {
const { client, lib } = await openSeeded(cacheDirectory); const { client, lib } = await openSeeded(cacheDirectory);
let release!: () => void; const files = holdNextFilesRequest(client);
const held = new Promise<void>((r) => (release = r));
try { try {
client.filesFor( client.filesFor(1, {
1, files: [file(1002, 1, 110)],
{ files: [file(1002, 1, 110)], deleted: [], cursor: 110 },
{ files: [file(1003, 1, 120)], deleted: [1002], cursor: 120 },
);
client.collectionsQueue.push({
collections: [collection(1, 120)],
deleted: [], deleted: [],
cursor: 120, cursor: 110,
});
client.collectionsQueue.push({
collections: [],
deleted: [1],
cursor: 130,
}); });
// Hold the next files request, refreshCollection's, until the
// test releases it. The refresh's request after it is answered
// at once.
let fetchStarted!: () => void;
const started = new Promise<void>((r) => (fetchStarted = r));
const realFilesSince = client.filesSince.bind(client);
client.filesSince = async (args) => {
client.filesSince = realFilesSince;
const page = await realFilesSince(args);
fetchStarted();
await held;
return page;
};
const refreshing = lib.refreshCollection(collection(1, 110)); const refreshing = lib.refreshCollection(collection(1, 110));
await started; await files.started;
await lib.fresh(); const reading = lib.fresh();
release(); // The refresh has not sent its collections request yet.
await refreshing; await new Promise((r) => setImmediate(r));
expect(client.collectionsSinceTimes).toEqual([0]);
expect(lib.getCollection(1)?.updationTime).toBe(120); files.release();
expect(lib.listFiles(1).map((f) => f.id)).toEqual([1001, 1003]); await refreshing;
await reading;
expect(client.collectionsSinceTimes).toEqual([0, 100]);
expect(lib.getCollection(1)).toBeUndefined();
expect(lib.listFiles(1)).toEqual([]);
} finally {
files.release();
await lib.close();
}
});
// `freshCollections()` lists collection 1 at 110. Before
// `refreshCollection()` is called, a refresh learns that it was deleted at
// 130.
it("stores nothing for a collection that a refresh deleted before the call", async () => {
const { client, lib } = await openSeeded(cacheDirectory);
try {
client.collectionsQueue.push(
{ collections: [collection(1, 110)], deleted: [], cursor: 110 },
{ collections: [], deleted: [1], cursor: 130 },
);
const [listed] = await lib.freshCollections();
await lib.fresh();
client.filesCalls.length = 0;
await lib.refreshCollection(listed);
expect(client.filesCalls).toEqual([]);
expect(lib.getCollection(1)).toBeUndefined();
} finally {
await lib.close();
}
});
// The caller may remove the cache directory once `close()` resolves, so
// the cache write must have landed by then.
it("close() waits for a refreshCollection() in flight", async () => {
const { client, lib } = await openSeeded(cacheDirectory);
const files = holdNextFilesRequest(client);
client.filesFor(1, {
files: [file(1002, 1, 110)],
deleted: [],
cursor: 110,
});
const refreshing = lib.refreshCollection(collection(1, 110));
await files.started;
let closed = false;
const closing = lib.close().then(() => {
closed = true;
});
await new Promise((r) => setImmediate(r));
expect(closed).toBe(false);
files.release();
await closing;
const saved = await MetadataStore.load(
join(cacheDirectory, "metadata.json"),
);
expect(saved.listFiles(1).map((f) => f.id)).toEqual([1001, 1002]);
await refreshing;
});
// `quak files 1` on an empty cache stores collection 1 alone. A library
// opened later on that cache must not serve it as the whole account.
it("open() awaits the first refresh on a cache that only refreshCollection() wrote", async () => {
const client = new MockClient();
client.filesFor(1, {
files: [file(1001, 1, 90)],
deleted: [],
cursor: 90,
});
const first = await Library.open({
client,
cacheDirectory,
refreshOnOpen: false,
refreshIntervalSeconds: SLOW_INTERVAL,
});
await first.refreshCollection(collection(1, 100));
await first.close();
client.collectionsQueue.push({
collections: [collection(1, 100), collection(2, 100)],
deleted: [],
cursor: 100,
});
client.filesFor(2, {
files: [file(2001, 2, 90)],
deleted: [],
cursor: 90,
});
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: SLOW_INTERVAL,
});
try {
expect(lib.status().lastRefreshAt).toBeDefined();
expect(lib.listCollections().map((c) => c.id)).toEqual([1, 2]);
expect(lib.listFiles(2).map((f) => f.id)).toEqual([2001]);
} finally { } finally {
release();
await lib.close(); await lib.close();
} }
}); });