From 98f674c3a3586ccdf900ec5aa54145d22e53ecb2 Mon Sep 17 00:00:00 2001 From: sneak Date: Sun, 25 Oct 2026 23:24:16 +0000 Subject: [PATCH] quak files COLLECTION fetches that collection's files alone (closes #217) `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 --- README.md | 161 +++++++++------ TODO.md | 11 + src/cli-commands.ts | 13 +- src/cli-read.ts | 45 ++-- src/library/index.ts | 406 +++++++++++++++++++++++-------------- src/library/store.ts | 7 - test/cli/commands.test.ts | 81 +++++--- test/cli/read.test.ts | 53 +++-- test/library/fresh.test.ts | 240 ++++++++++++++++++++++ 9 files changed, 719 insertions(+), 298 deletions(-) diff --git a/README.md b/README.md index 8e34535..5f1fd37 100644 --- a/README.md +++ b/README.md @@ -685,17 +685,21 @@ library. The read commands — `collections`, `files`, `get`, `get-thumb`, `helper fix-missing-thumbnails` — force a fresh server round-trip before they answer, so they report current account state rather than whatever the cache last held. `collections` asks the server for the collection list alone, in one -request, and neither fetches any collection's files nor changes the cache. The -others wait for a refresh. With an empty cache, as on a first run, that refresh -fetches the files of every collection, one collection after another, and stores -nothing until it has them all, which on a large account takes a while. Meanwhile -the command logs a line on stderr for each collection whose files it has -fetched, such as `Fetched the files of 3 of 120 collections`, with `--json` too; -`backup` does the same while the refresh it starts with runs. stdout holds only -the command's output. If that round-trip fails, the command prints the error on -one line and exits 1, or 3 when the server no longer accepts the saved session -(see "Session 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"). +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 +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. `files` +without a collection ID and the other read commands wait for a refresh. With an +empty cache, as on a first run, that refresh fetches the files of every +collection, one collection after another, and stores nothing until it has them +all, which on a large account takes a while. Meanwhile the command logs a line +on stderr for each collection whose files it has fetched, such as +`Fetched the files of 3 of 120 collections`, with `--json` too; `backup` does +the same while the refresh it starts with runs. stdout holds only the command's +output. If that round-trip fails, the command prints the error on one line and +exits 1, or 3 when the server no longer accepts the saved session (see "Session +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 own directory under the per-user cache path. @@ -1093,12 +1097,13 @@ 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 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; -if that refresh fails, it opens with no data and records the error in -`lib.status()`. On an existing cache it returns immediately and refreshes in the -background, so an unreachable server does not block opening. With -`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 +refresh, so it opens onto the account's data whenever the server is reachable. +It also awaits it on a cache that holds only what `lib.refreshCollection()` +stored. 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 `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 `lib.backup()` asks, or the background timer fires. The CLI's read commands open it so (see "CLI surface"). @@ -1127,8 +1132,8 @@ when the library opens; `lib.downloadDirectory` holds the result. `onProgress` receives a `RefreshEvent` with status `"started"` as a refresh, an ML data fetch or a precache sweep starts, and `"done"` or `"failed"` as it ends. In between, a refresh sends `"progress"` after each collection whose files it -fetched, with `fetched`, how many collections it has fetched the files of so -far, and `total`, how many it will. A refresh fetches the files of each +fetched. That event's `fetched` is how many collections it has fetched the files +of so far, and its `total` how many it will. A refresh fetches the files of each collection that is new or changed since the cache's copy, so on an empty cache it fetches those of every collection. @@ -1138,11 +1143,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 refresh/ML times and errors, originals usage and effective limit, precache -progress, and `closed`). `lib.close()` stops the background timer; it is -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 -the precache fetches already running have all finished, so the cache directory -can then be removed. +progress, and `closed`). + +`lib.close()` stops the background timer, and is idempotent. A refresh or +`lib.refreshCollection()` in flight is left to finish. The promise `close()` +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 data the last ML data fetch that succeeded got but could not decrypt, gunzip or @@ -1156,39 +1166,58 @@ cache. Default reads — `lib.albums`, `lib.photos`, `lib.timeline` — answer synchronously from the copy held in RAM and never touch the network. A refresh -changes that copy only once all its server requests have succeeded, and the -background timer starts the next refresh `refreshIntervalSeconds` after the -previous one ends. So a default read is immediate, but only as current as the -last refresh whose requests all succeeded; right after opening an existing -cache, it is the copy on disk. +changes that copy only once all its server requests have succeeded. +`lib.refreshCollection()` changes it the same way for one collection. So a +default read is immediate, but only as current as the last refresh or +`lib.refreshCollection()` whose requests all succeeded. Right after opening an +existing cache, it is the copy on disk. + +The background timer takes a turn `refreshIntervalSeconds` after its last turn +ends. Each turn starts a refresh, unless a refresh or `lib.refreshCollection()` +is already running. `await lib.fresh()` waits for a refresh to complete and persist, and returns the same `{ albums, photos, timeline }` namespaces, which then reflect a completed -server round-trip. When a refresh is already running, background or not, -`fresh()` waits for that one, so its answer can come from requests made before -the call; only when none is running does it start one. A refresh that fails -rejects the caller (default reads stay silent and keep serving the last good -copy). The CLI's read commands use fresh reads (issue -https://git.eeqj.de/sneak/quak/issues/75). +server round-trip. It first waits for a running `lib.refreshCollection()` to +end. When a refresh is already running, background or not, `fresh()` waits for +that one. Its answer can then come from requests made before the call. Only when +none is running does it start one. A refresh that fails rejects the caller +(default reads stay silent and keep serving the last good copy). The CLI's read +commands use fresh reads (issue https://git.eeqj.de/sneak/quak/issues/75). `await lib.freshCollections()` is the fresh read of the collection list alone: one collections request, answered with every live collection that decrypts, as the `Collection` objects `listCollections()` returns, in the server's order. It -fetches no collection's files and stores nothing, not even the list: a refresh +fetches no collection's files and stores nothing, not even the list. A refresh fetches a collection's files only when the collection is new or has changed -since the stored copy, so a collection stored without its files would never get +since the stored copy. A collection stored without its files would never get them. `quak collections` uses it (issue https://git.eeqj.de/sneak/quak/issues/210). +`await lib.refreshCollection(collection)` brings one collection up to date in +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's files. It sends no request when the cache's copy has the same +`updationTime` or a later one. It also sends none when a refresh has already +dropped the collection from the cache, because the server deleted it or it did +not decrypt. Otherwise it fetches the collection's files changed since the +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 first waits for a running refresh to end. A `fresh()` or +`lib.backup()` called after that 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 metadata is not JSON, does not fail the refresh. The refresh leaves it out, an album with all its files, and drops any older copy the cache held, so reads answer from what did decrypt. The library keeps a list of what it left out, with -the reason, in `metadata.json`: the next refresh asks the server only for what +the reason, in `metadata.json`. The next refresh asks the server only for what changed, so it would not see the record again. A record stays on that list until -a later refresh gets a copy that decrypts or learns that the server deleted it. -`lib.backup()` reports each such album, and each such file in its scope that has -no copy that decrypted (see "Backup layout"). +a later refresh or `lib.refreshCollection()` stores a copy that decrypts, or +learns that the server deleted it. `lib.backup()` reports each such album, and +each such file in its scope that has no copy that decrypted (see "Backup +layout"). ### Read surface @@ -1282,10 +1311,11 @@ The GUI-facing records hold no key material and no binary, so they survive - `LibrarySnapshot`: `{ albums, photos, takenAt }`. `lib.snapshot()` returns a `LibrarySnapshot` (albums newest-updated first, -photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange` -(`albumsChanged`, `photosChanged`, `fileIDsRemoved`, `albumIDsRemoved`, -`refreshedAt`) whenever a refresh alters the projection, and returns -`{ unsubscribe }`; a refresh that changes nothing delivers nothing. +photos newest first). `lib.subscribe({ onChange })` returns `{ unsubscribe }`. +It delivers a `LibraryChange` (`albumsChanged`, `photosChanged`, +`fileIDsRemoved`, `albumIDsRemoved`, `refreshedAt`) whenever a refresh or +`lib.refreshCollection()` alters the projection. One that changes nothing +delivers nothing. ### Thumbnails, ML search, and backup @@ -1304,26 +1334,27 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange` - `await lib.backup(opts?)` → `BackupResult`. It takes the lock in the download directory, and fails at once with an error whose `code` is `ELOCKED` while another backup of it runs. A caller can instead take the lock itself, before - it opens its library, as `quak backup` does: `await lockBackupDirectory(dir)` - takes it, failing the same way, and returns a `BackupLock`, whose `release()` - removes the lock unless it was lost and whose `lost` holds why it was lost. - The caller passes it to the backup as `lock`, and releases it once the backup - ends. It waits for a refresh as `fresh()` does, puts every in-scope original - not already at its save path there as `photo.download()` does (and, with - `includeThumbnails`, fetches thumbnails) through the content cache, waits for - an ML data fetch, and rebuilds the on-disk backup tree with a durable failure - ledger. Each file's JSON holds its ML data and keywords. An image's JSON also - holds the EXIF and XMP of its original, and its dimensions if it is a JPEG. A - live photo's JSON holds these for its image. A video's JSON holds no EXIF, XMP - or dimensions. A fetched original is written straight to its save path and not - into the cache, which then counts it as present; one the cache already held is - copied from there. `BackupOptions`: `downloadDirectory` (falls back to the - library's), `includeOriginals` (default `true`), `includeThumbnails` (default - `false`), `onlyAlbumNames`, `verify` (default `false`), `onProgress`, and - `lock`. See Backup layout above for the tree it writes, and for the albums - `BackupResult` lists in `undecryptableAlbums` and `failedAlbums`. Whether the - backup took the lock or its caller did, once the lock is lost the backup - rejects with `lost the lock on : ` before it starts another file. + it opens its library, as `quak backup` does. `await lockBackupDirectory(dir)` + takes it, failing the same way, and returns a `BackupLock`. Its `release()` + removes the lock unless it was lost, and its `lost` holds why it was lost. The + caller passes it to the backup as `lock`, and releases it once the backup + ends. The backup waits for a refresh as `fresh()` does. It then puts every + in-scope original not already at its save path there, as `photo.download()` + does, through the content cache. With `includeThumbnails` it also fetches + thumbnails. It waits for an ML data fetch, and rebuilds the on-disk backup + tree with a durable failure ledger. Each file's JSON holds its ML data and + keywords. An image's JSON also holds the EXIF and XMP of its original, and its + dimensions if it is a JPEG. A live photo's JSON holds these for its image. A + video's JSON holds no EXIF, XMP or dimensions. A fetched original is written + straight to its save path and not into the cache, which then counts it as + present; one the cache already held is copied from there. `BackupOptions`: + `downloadDirectory` (falls back to the library's), `includeOriginals` (default + `true`), `includeThumbnails` (default `false`), `onlyAlbumNames`, `verify` + (default `false`), `onProgress`, and `lock`. See Backup layout above for the + tree it writes, and for the albums `BackupResult` lists in + `undecryptableAlbums` and `failedAlbums`. Whether the backup took the lock or + its caller did, once the lock is lost the backup rejects with + `lost the lock on : ` before it starts another file. ### Request pools diff --git a/TODO.md b/TODO.md index 99ae803..ddb5341 100644 --- a/TODO.md +++ b/TODO.md @@ -26,6 +26,17 @@ declares one. # Completed Steps +- 2026-10-25: `quak files COLLECTION` fetches the files of that collection alone + (https://git.eeqj.de/sneak/quak/issues/217). It used to wait for a refresh, + which on an empty cache fetched the files of every collection first. It now + reads the collection list through `lib.freshCollections()`, then brings 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 + where it was. When the stored copy is current, it asks for none of the files. + 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 holds `keywords` in place of the CLIP embedding in its ML data (https://git.eeqj.de/sneak/quak/issues/218). The keywords are the titles of diff --git a/src/cli-commands.ts b/src/cli-commands.ts index 52f1b05..ee60bc7 100644 --- a/src/cli-commands.ts +++ b/src/cli-commands.ts @@ -128,12 +128,13 @@ const readLibraryClient = (client: Client): LibraryClient => ({ contentSource: () => client.contentSource(), }); -// Open a library for a single point command: the aggressive background precache -// (issue #48) is off — a one-shot `collections` or `get` must not start -// downloading the whole account — and no refresh starts as it opens or on a -// timer: each command asks for the one it needs, and `collections` needs none -// (issue #210). While a refresh runs, each collection whose files it has -// fetched is logged, so a large account shows progress rather than nothing. +// Open a library for a single point command. The aggressive background +// precache (issue #48) is off, because a one-shot `collections` or `get` must +// not start downloading the whole account. No refresh starts as it opens or on +// 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, +// each collection whose files it has fetched is logged, so a large account +// shows progress rather than nothing. const openReadLibrary = (ctx: CliContext, client: Client): Promise => Library.open({ client: readLibraryClient(client), diff --git a/src/cli-read.ts b/src/cli-read.ts index 70b1f21..7fed0bb 100644 --- a/src/cli-read.ts +++ b/src/cli-read.ts @@ -2,13 +2,17 @@ // // `collections`, `files`, `get`, and `get-thumb` must answer for // 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`, -// `get` and `get-thumb` therefore force a server round-trip through -// `Library.fresh()` and only then read — so a collection, file, or metadata -// change made elsewhere is visible. `collections` asks the library for the -// collection list alone through `Library.freshCollections()` (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. +// local cache last happened to hold (owner amendment, issue #36). `files` +// without a collection ID, `get` and `get-thumb` therefore force a server +// round-trip through `Library.fresh()`, and only then read. A collection, file, +// or metadata change made elsewhere is then visible. `collections` asks the +// library for the collection list alone through `Library.freshCollections()` +// (issue #210). A refresh would also fetch the files of every collection, which +// `collections` does not print, and on a large account that takes long. +// `files COLLECTION` reads the collection list the same way, for the same +// reason. It then brings that one collection up to date through +// `Library.refreshCollection()` (issue #217), and fetches no other collection's +// files. // // `collections` lists in the server's order and `files` in the library's own // enumeration order (`listFiles()`), the orders the pre-library CLI printed, @@ -23,13 +27,13 @@ import type { Collection, EnteFile } from "./model/types.js"; import type { Photo, PhotosAPI } from "./library/index.js"; // The slice of `Library` these helpers read. `Library` satisfies it -// structurally; a test can drive them with a stand-in that records the -// `fresh()` and `freshCollections()` calls and serves records in a known -// enumeration order. +// structurally. A test can drive them with a stand-in that records the +// `fresh()`, `freshCollections()` and `refreshCollection()` calls. The +// stand-in serves records in a known enumeration order. export interface FreshReadLibrary { fresh(): Promise; freshCollections(): Promise; - getCollection(id: number): Collection | undefined; + refreshCollection(collection: Collection): Promise; listCollections(): Collection[]; listFiles(collectionID: number): EnteFile[]; getFileByID(fileID: number): EnteFile | undefined; @@ -42,21 +46,24 @@ export const freshCollections = ( lib: FreshReadLibrary, ): Promise => lib.freshCollections(); -// The files of one collection, current as of a forced refresh, in enumeration -// order. `undefined` (not an empty list) when the collection does not exist, so +// The files of one collection, in enumeration order, current as of one +// 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 // the caller can tell "no such collection" from "an empty collection". Without -// a collection ID, every file in the account, each once, as held by the first -// collection that holds it. +// a collection ID, every file in the account, current as of a forced refresh. +// Each file is listed once, as held by the first collection that holds it. export const freshFiles = async ( lib: FreshReadLibrary, collectionID?: number, ): Promise => { - await lib.fresh(); if (collectionID !== undefined) { - return lib.getCollection(collectionID) - ? lib.listFiles(collectionID) - : undefined; + const collections = await lib.freshCollections(); + const collection = collections.find((c) => c.id === collectionID); + if (!collection) return undefined; + await lib.refreshCollection(collection); + return lib.listFiles(collectionID); } + await lib.fresh(); const listed = new Set(); const files: EnteFile[] = []; for (const collection of lib.listCollections()) { diff --git a/src/library/index.ts b/src/library/index.ts index a3bed63..f2a524e 100644 --- a/src/library/index.ts +++ b/src/library/index.ts @@ -1,28 +1,33 @@ // The library surface over the local cache. // // `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, -// so the library never opens onto an empty store it could have filled; when an -// existing copy loaded, that first refresh runs in the background and `open()` -// returns as soon as the cached data is ready to serve — a slow or unreachable -// server no longer stalls opening. A background timer then refreshes every -// `refreshIntervalSeconds`. Every default read is answered from RAM — no -// default read touches the network. There is deliberately no `sync()`, no -// `refresh()`, no `serverReachable` flag, and no "before each read" mode -// (design #36). +// the refresh loop. When the stored cursor is 0, `open()` awaits the first +// refresh. No refresh has then stored any collection: the cache is empty, or +// holds only what `refreshCollection()` stored. The library must not serve +// that as the whole account. Otherwise that first refresh runs in the +// background. `open()` returns as soon as the cached data is ready to serve, +// so a slow or unreachable server does not stall opening. A background timer +// then takes a turn `refreshIntervalSeconds` after its last turn ends. Each +// turn starts a refresh, unless a refresh or `refreshCollection()` is already +// running. Every default read is answered from RAM — no default read touches +// the network. There is 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 -// forces a refresh, awaits it, and only then hands back the read namespaces, so -// a caller that needs server-current data can ask for it. Concurrent `fresh()` -// calls coalesce onto one in-flight refresh, and a refresh that fails rejects -// the caller (the default reads stay silent and serve the last good copy). The -// default methods and the background loop are unchanged. +// `fresh()` is an exception (issue #75, an owner amendment to #36). It awaits +// a refresh, and only then hands back the read namespaces. A caller that needs +// server-current data uses it. Concurrent `fresh()` calls coalesce onto one +// in-flight refresh. A refresh that fails rejects the caller. The default +// reads stay silent and serve the last good copy. The default methods and the +// background loop are unchanged. // // `freshCollections()` is the fresh read of the collection list alone (issue -// #210): one collections request, no collection's files, and nothing stored, -// for a caller such as `quak collections` that needs no more. A caller that -// asks for each refresh it needs can also open the library with -// `refreshOnOpen: false`, so that `open()` starts none. +// #210). It sends one collections request and stores nothing. +// `quak collections` uses it. `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 would. `quak files COLLECTION` uses +// it. It never runs at the same time as a refresh. A caller that asks for each +// refresh it needs can also open the library with `refreshOnOpen: false`, so +// that `open()` starts none. // // A refresh reports through `onProgress` each collection whose files it has // fetched, so a large account, whose first refresh fetches the files of every @@ -31,10 +36,12 @@ // A refresh stages all of its network work first and only mutates the store // once every fetch has succeeded. A refresh that fails partway therefore never // becomes visible to reads: the last good snapshot stays in place, and the -// failure surfaces through `onProgress` and `status()` instead. A commit that -// mutates RAM but then fails to persist keeps `status().lastError` set and the -// store marked unsaved until a later save actually lands, so a stuck disk is -// never masked by a subsequent empty refresh. +// failure surfaces through `onProgress` and `status()` instead. A save can +// still fail after the store has changed in RAM. The store then stays marked +// unsaved until a later save lands. Until then, every `commit()` retries the +// save. A refresh whose save fails sets `status().lastError`, which stays set +// until a later refresh succeeds. `refreshCollection()` never sets or clears +// it. So a stuck disk is never hidden by a later refresh that changes nothing. import { rm } from "node:fs/promises"; import { join, resolve } from "node:path"; @@ -159,21 +166,23 @@ export interface LibraryClient { payloads: Map; errors: Map; }>; - // 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 - // disabled and `Photo.original`/`thumbnail` and `thumbnails.ensure` throw. + // The byte source for the on-disk content cache. Optional, so a mock + // client that only serves metadata still satisfies the interface. Without + // it, and with no `contentSource` passed to `open`, the content cache is + // disabled. `Photo.original`/`thumbnail` and `thumbnails.ensure` then + // throw. contentSource?(): ContentSource; } -// 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 and, in `mlDataErrors`, -// each file whose ML data did not decrypt, gunzip or parse, with the reason. -// In between, a refresh fires "progress" after each collection whose files it -// fetched: `fetched` is how many collections it has fetched the files of so -// far, `total` how many it will. The precache fills +// A progress event for one unit of background work. A metadata "refresh" and +// an ML "fetchMLData" pass each fire "started" before their network work. Each +// then fires exactly one of "done" or "failed". A "failed" event carries the +// error message. An ML "done" reports in `fetched` how many payloads it +// stored. Its `mlDataErrors` lists each file whose ML data did not decrypt, +// gunzip or parse, with the reason. In between, a refresh fires "progress" +// after each collection whose files it fetched. Its `fetched` is how many +// collections it has fetched the files of so far, and `total` how many it +// will. The precache fills // ("precacheThumbnails"/"precacheOriginals", #48) fire "started"/"done" around // each sweep that has work, "done" reporting the count newly cached. export interface RefreshEvent { @@ -201,9 +210,10 @@ export interface LibraryOptions { // directory at open. The content cache treats an original already stored // at its save path as present. downloadDirectory?: string; - // How long after one refresh ends the background timer starts the next. - // `Infinity` starts none: the library then refreshes only as it opens and - // when `fresh()` or `backup()` asks. + // How long after its last turn ends the background timer takes the next. + // A turn starts a refresh unless a refresh or `refreshCollection()` is + // running. With `Infinity` the timer takes no turn: the library then + // refreshes only as it opens and when `fresh()` or `backup()` asks. refreshIntervalSeconds?: number; // Whether `open()` starts a refresh. Defaults to true. With false, `open()` // returns at once onto what the cache holds, empty or not, and a refresh @@ -242,8 +252,9 @@ export interface LibraryStatus { // Wall-clock ms of the last refresh that succeeded, or undefined if none // has yet. lastRefreshAt?: number; - // The message from the most recent refresh, set only while that refresh - // failed; cleared by the next success. + // The error of the most recent refresh, set only when it failed. The next + // refresh that succeeds clears it. `refreshCollection()` never sets or + // clears it. lastError?: string; // Wall-clock ms of the last ML fetch pass that succeeded, or undefined if // none has yet (or ML fetching is disabled). @@ -306,12 +317,17 @@ export class Library { private readonly precache?: Precache; private timer?: ReturnType; - // The in-flight refresh cycle, or undefined when none runs. One slot serves - // both paths: the background loop skips when it is set, and a fresh read - // (issue #75) coalesces onto it or starts one. The promise carries the - // cycle's real outcome (it rejects on failure); the background loop ignores - // that, a fresh read propagates it. + // The in-flight refresh cycle or `refreshCollection()`, or undefined when + // neither runs. One slot holds both, so they never run at the same time. + // The background loop skips while it is set. A fresh read (issue #75) + // coalesces onto a running refresh. It waits for a running + // `refreshCollection()` to end, and then joins or starts a refresh. The + // promise carries the real outcome: it rejects on failure. The background + // loop ignores a failure. A fresh read passes on the failure of the + // refresh it joins or starts, and `refreshCollection()` passes on its own. private cycle?: Promise; + // 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 // 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 @@ -324,19 +340,20 @@ export class Library { 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. + // gathers its own and puts them here only when it ends without failing. + // So `status()` and `backup()` never see the list of a pass still running + // or one that failed. Kept in RAM only, never in the cache. private mlDataErrors = new Map(); - // 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 - // joins later diffs against the state its own `snapshot()` already returned. + // The plain-record projection as of the last store change, and the GUI + // change subscribers. A refresh or `refreshCollection()` that alters the + // projection notifies each subscriber with the delta. `lastRecords` + // advances on each `commit()` that changes the store. A subscriber that + // joins later then diffs against the state its own `snapshot()` returned. private readonly subscribers = new Set<(change: LibraryChange) => void>(); private lastRecords: DerivedRecords; // RAM holds changes disk has not yet accepted (an earlier save failed). - // Cleared only when a save actually succeeds; keeps the store trying to - // persist and the failure visible in `status()` until then. + // Cleared only when a save succeeds. Until then, every `commit()` retries + // the save. private unsaved = false; private constructor(args: { @@ -366,7 +383,7 @@ export class Library { this.lastRecords = this.deriveNow(); // The read namespaces derive fresh from the store on each call, so they - // always reflect the latest refresh. + // always reflect what it holds now. const derive = (): DerivedRecords => this.deriveNow(); const root = this.downloadDirectory; const saves: SavePathLookup = { @@ -393,14 +410,16 @@ export class Library { this.mldata = makeMLDataAPI(() => this.mlStore); } - // Load the cache and start the refresh loop. With an empty cache the first - // refresh is awaited, so `open()` resolves onto populated data whenever the - // server is reachable; that awaited refresh may still fail, and the library - // then opens empty with the failure recorded in `status()`. With an - // existing cache the first refresh runs in the background and `open()` - // returns as soon as the cached data is ready — an unreachable server does - // not block opening. With `refreshOnOpen: false` there is no first - // refresh: `open()` returns onto the cache as it is. + // Load the cache and start the refresh loop. When the stored cursor is 0, + // the first refresh is awaited. Such a cache is empty, or holds only what + // `refreshCollection()` stored. `open()` then resolves onto the whole + // account whenever the server is reachable. If that refresh fails, the + // library opens onto what the cache holds, with the failure recorded in + // `status()`. With any other cache the first refresh runs in the + // background, and `open()` returns as soon as the cached data is ready. + // 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 { const { userID } = opts.client.whoami(); const cacheDirectory = @@ -416,8 +435,8 @@ export class Library { 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. + // ML data beside it and start empty. A user ID of 0 means nothing was + // ever stored there, so there is nothing to discard. if (store.userID !== 0 && store.userID !== userID) { await rm(metadataPath, { force: true }); await rm(join(cacheDirectory, "mldata"), { @@ -440,10 +459,11 @@ export class Library { ? await MLDataStore.open(join(cacheDirectory, "mldata")) : undefined; - // Build the content cache from an explicit source or the client's own, - // and take its record of what is already cached (and reap orphan temp - // files) before the first projection, so cached paths are present from - // the start and the first refresh raises no spurious path-change diff. + // Build the content cache from an explicit source or the client's own. + // Take its record of what is already cached, and reap orphan temp + // files, before the first projection. Cached paths are then present + // from the start, and the first `commit()` raises no spurious + // path-change diff. const source = opts.contentSource ?? opts.client.contentSource?.(); let cache: ContentCache | undefined; let precache: Precache | undefined; @@ -497,22 +517,26 @@ export class Library { precache, }); - // Start filling from whatever the loaded store already holds; each - // refresh below re-kicks with the new files (and retries any that - // failed). An empty store starts empty here and fills after its first - // refresh. + // Start filling from whatever the loaded store already holds. Each + // later `commit()` re-kicks the fills with its new files, and retries + // any that failed. An empty store starts empty here, and fills once a + // `commit()` stores files. precache?.start(); if (opts.refreshOnOpen === false) { // The caller asks for each refresh it needs. lib.scheduleNext(); - } else if (store.loadedFromDisk) { - // An existing copy already answers reads; refresh in the background - // and start the interval once that first cycle settles. + } else if (store.collectionsSinceTime > 0) { + // The stored cursor has moved, so a refresh has stored the + // 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()); } else { - // Nothing was cached: wait for the first refresh to fill the store - // (or fail) rather than resolve onto an empty library. + // The stored cursor is still 0, so no refresh has stored any + // 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(); lib.scheduleNext(); } @@ -550,9 +574,9 @@ export class Library { return snapshotFrom(this.deriveNow(), Date.now()); } - // Deliver a `LibraryChange` whenever a refresh alters the projection. A - // refresh that changes nothing delivers nothing. The returned handle's - // `unsubscribe` stops delivery. + // Deliver a `LibraryChange` whenever a refresh or `refreshCollection()` + // alters the projection. One that changes nothing delivers nothing. The + // returned handle's `unsubscribe` stops delivery. subscribe(args: { onChange: (change: LibraryChange) => void }): { unsubscribe: () => void; } { @@ -595,12 +619,13 @@ export class Library { }; } - // Fresh reads (issue #75, owner amendment to design #36). Force a refresh, - // wait for it to complete and persist, then hand back the same - // `albums`/`photos`/`timeline` namespaces — now guaranteed to reflect a - // completed server round-trip. Concurrent calls coalesce onto one refresh; - // a refresh that fails rejects here, where the default namespaces would - // instead stay silent and serve the last good copy. + // Fresh reads (issue #75, owner amendment to design #36). Wait for a + // refresh to complete and persist, then hand back the same + // `albums`/`photos`/`timeline` namespaces. They then reflect a completed + // server round-trip. A running `refreshCollection()` is waited for first. + // Concurrent calls coalesce onto one refresh. A refresh that fails rejects + // here, where the default namespaces would stay silent and serve the last + // good copy. async fresh(): Promise { await this.refreshNow(); return { @@ -613,10 +638,10 @@ export class Library { // The fresh read of the collection list alone (issue #210): every live // collection that decrypts, in the server's order, from one collections // request. Unlike a refresh, it fetches no collection's files, which on a - // large account take long. Nothing is stored: a refresh fetches a + // large account take long. It stores nothing. A refresh fetches a // collection's files only when the collection is new or has changed since - // the stored copy, so a collection stored here without its files would - // never have them fetched. + // the stored copy. A collection stored here without its files would never + // have them fetched. async freshCollections(): Promise { if (this.closed) throw new Error("the library is closed"); const { collections } = await this.client.collectionsSince({ @@ -625,19 +650,35 @@ export class Library { return collections; } + // Bring one collection up to date in the store (issue #217), given its + // record from `freshCollections()`. It does for that collection what a + // refresh does for a changed one. It fetches no other collection's files. + // It runs in the same slot as a refresh, so the two never run at the same + // time. It first waits for a running refresh or `refreshCollection()` to + // end. While its own work runs, a fresh read or `backup()` waits for it, + // and the background loop skips its turn. + async refreshCollection(collection: Collection): Promise { + while (this.cycle) await this.cycle.catch(() => {}); + if (this.closed) throw new Error("the library is closed"); + await this.startCycle(false, () => + this.refreshCollectionOnce(collection), + ); + } + // Back up every in-scope file to `opts.downloadDirectory`, or else the // library's, each original at its save path, with a durable failure - // ledger (issue #51). Takes the lock in that directory first, unless the - // caller passes the lock it took as `opts.lock`, failing at once while - // another backup of it runs, and before it starts another file once the - // lock is lost (see `lockBackupDirectory`). Waits for a - // completed refresh, as `fresh()` does, joining one already running, and - // rejects before touching any file but the lock when it fails. Then puts - // pending originals at their save paths as `Photo.download()` does (and - // optional thumbnails) through the content cache and pools, waits for an - // ML data fetch, and rebuilds the derived symlink/JSON views from the - // model, each file's JSON with its ML data, beside an `account.json` with - // the account's email and user ID. + // ledger (issue #51). It first takes the lock in that directory, unless + // the caller passes the lock it took as `opts.lock`. Taking it fails at + // once while another backup of that directory runs. Once the lock is lost, + // the backup fails before it starts another file (see + // `lockBackupDirectory`). It waits for a completed refresh as `fresh()` + // does, joining one already running. When that refresh fails, it rejects + // before touching any file but the lock. It then puts pending originals + // at their save paths as `Photo.download()` does, through the content + // cache and pools. With `includeThumbnails` it also fetches thumbnails. It + // waits for an ML data fetch. It rebuilds the derived symlink/JSON views + // from the model, each file's JSON with its ML data. An `account.json` + // beside them holds the account's email and user ID. // Throws before any network work when no content cache backs the // originals it must fetch. backup(opts?: BackupOptions): Promise { @@ -674,12 +715,15 @@ export class Library { ); } - // Stop the background timer. Idempotent. An in-flight refresh is left to - // finish; it will not schedule another cycle once closed. The returned - // promise resolves once that refresh (including its cache write), the ML - // fetch pass and the precache fetches already running have all finished, - // so a caller can then remove the cache directory. A refresh failure is - // reported through `status()`, not thrown here. + // Stop the background timer. Idempotent. A refresh or `refreshCollection()` + // in flight is left to finish, and nothing starts after it. The returned + // promise resolves once all of these have finished: + // - that refresh or `refreshCollection()`, with its cache write; + // - the ML fetch pass; + // - the precache fetches already running. + // A caller can then remove the cache directory. A refresh failure is + // reported through `status()`, and a `refreshCollection()` failure to its + // own caller. Neither is thrown here. async close(): Promise { this.closed = true; const precacheClosed = this.precache?.close(); @@ -702,33 +746,41 @@ export class Library { this.timer.unref?.(); } - // The background loop's refresh: run a cycle unless one is already in flight - // (or the library is closed), and never let a failure escape — the - // background path reports errors through `status()`/`onProgress`, it does - // not throw. Resolves once the cycle it started (or skipped past) settles. + // The background loop's refresh: run a cycle unless a refresh or + // `refreshCollection()` is in flight (or the library is closed), and never + // let a failure escape. The background path reports errors through + // `status()`/`onProgress`; it does not throw. It resolves once the cycle + // it started settles, or at once when it starts none. private runRefresh(): Promise { 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 - // 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 - // server round-trips. - private refreshNow(): Promise { - if (this.closed) { - return Promise.reject(new Error("the library is closed")); + // A fresh read's refresh (issue #75): join the running refresh or start + // one, and await it, rejecting 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 server round-trips. A running + // `refreshCollection()` is waited for first. + private async refreshNow(): Promise { + while (this.cycle && !this.cycleIsRefresh) { + 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 - // caller coalesces onto it. The returned promise carries the cycle's real - // outcome; each caller attaches the handling its own path needs, and the - // slot is cleared once the cycle settles. - private startCycle(): Promise { - const cycle = this.refreshCycle(); + // Start `work` and record it in the in-flight slot, so every other caller + // sees it. `isRefresh` says whether it is a refresh cycle, which a fresh + // read can coalesce onto. The returned promise carries the real outcome. + // Each caller attaches the handling its own path needs, and the slot is + // cleared once the work settles. + private startCycle( + isRefresh: boolean, + work: () => Promise, + ): Promise { + const cycle = work(); this.cycle = cycle; + this.cycleIsRefresh = isRefresh; void cycle.then( () => { if (this.cycle === cycle) this.cycle = undefined; @@ -751,9 +803,9 @@ export class Library { 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. Its failure is + // outside the refresh's success or failure, so a fetch or disk + // problem there never marks the metadata refresh failed. It is not + // awaited, so it never stalls the refresh interval. Its failure is // reported through `status()` and `onProgress`. void this.fetchMLDataNow().catch(() => {}); } catch (err) { @@ -766,10 +818,7 @@ export class Library { // Fetch every change since the stored cursor, then commit. All network // reads happen before any store mutation, so a fetch that throws leaves the - // store untouched and the previous snapshot intact. A collection or file - // that does not decrypt is left out instead, as if deleted, with the reason - // kept in the store: the cursor moves past it, so no later refresh sees it - // again until the server's copy changes. + // store untouched and the previous snapshot intact. private async refreshOnce(): Promise { const page = await this.client.collectionsSince({ sinceTime: this.store.collectionsSinceTime, @@ -802,8 +851,54 @@ export class Library { }); } - // Network work done; commit to the store and persist only if something - // actually changed. + 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 { + 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, and no later refresh would see it + // again until the server's copy changes. + 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 + // to the store. A collection or file that does not decrypt is left out, as + // if deleted. The reason is kept in the store. No later refresh sees it + // again until the server's copy changes. Last, save the store when it + // holds changes not yet saved. + private async commit( + page: CollectionsPage, + filePages: { collectionID: number; page: FilesPage }[], + ): Promise { + // The network work is done, so change the store now. `changed` says + // whether this commit changed anything. let changed = false; if (this.store.userID !== this.userID) { @@ -864,10 +959,10 @@ export class Library { if (changed) this.unsaved = true; // Reproject and notify subscribers of the delta. This tracks RAM (what - // reads see), so it fires whether or not the save below succeeds; a - // save failure surfaces separately through `status().lastError`. - // `lastRecords` advances every changed refresh so the next diff is - // against current state. + // reads see), so it fires whether or not the save below succeeds. A + // failed save rejects the caller, and a refresh also records it in + // `status().lastError`. `lastRecords` advances on every commit that + // changes something, so the next diff is against current state. if (changed) { const next = this.deriveNow(); if (this.subscribers.size > 0) { @@ -878,16 +973,16 @@ export class Library { // Recompute the fill orders and pinned set against the new library. this.precache?.update(next); } - // Re-kick the fills every cycle: a finished sweep starts afresh to pick - // up new files and retry any that failed, and a running one is left be. + // Re-kick the fills on every commit. A finished sweep starts afresh to + // pick up new files and retry any that failed. A running one is left + // be. this.precache?.start(); // Persist whenever RAM holds changes disk has not accepted — including // changes an earlier cycle staged whose save failed. `unsaved` clears - // only once a save lands, so a save failure both stays visible through - // `status().lastError` (the throw below records it) and keeps being - // retried, instead of a later empty refresh silently clearing it while - // the on-disk cache is still behind RAM. + // only once a save lands, so a later commit retries a failed save even + // when it changes nothing. A failed save rejects the caller, and a + // refresh also records it in `status().lastError`. if (this.unsaved) { await this.store.save(); this.unsaved = false; @@ -903,13 +998,14 @@ export class Library { return this.mlFetch; } - // 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. 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. + // One ML fetch pass. It fetches, decrypts and stores the ML data of every + // file the store knows about that is not cached, or whose `updationTime` + // has advanced. It fetches through the metadata pool, and updates the CLIP + // index. For a file whose ML data does not decrypt, gunzip or parse, it + // gathers the reason instead. It 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 { const mldata = this.mlStore; // Bind so the call keeps the client as its receiver when invoked @@ -977,9 +1073,9 @@ export class Library { })); } - // 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. + // The distinct files the store holds, one entry per fileID, since a file + // in several collections shares its ML data. Each entry carries the key + // and the newest `updationTime` seen across the file's memberships. private uniqueFiles(): { id: number; key: Uint8Array; diff --git a/src/library/store.ts b/src/library/store.ts index fc59b78..51d9169 100644 --- a/src/library/store.ts +++ b/src/library/store.ts @@ -70,12 +70,6 @@ export class MetadataStore { userID = 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(); private readonly files = new Map(); private undecryptable: Undecryptable[] = []; @@ -102,7 +96,6 @@ export class MetadataStore { if (parsed.schemaVersion !== METADATA_SCHEMA_VERSION) { return store; } - store.loadedFromDisk = true; store.userID = parsed.userID ?? 0; store.collectionsSinceTime = parsed.collectionsSinceTime ?? 0; for (const stored of parsed.collections ?? []) { diff --git a/test/cli/commands.test.ts b/test/cli/commands.test.ts index 3269fdb..58261fc 100644 --- a/test/cli/commands.test.ts +++ b/test/cli/commands.test.ts @@ -535,9 +535,7 @@ describe("files", () => { it("exits 1 for an unknown collection", async () => { expect(await filesCommand(context(), "9", {})).toBe(1); - expect(stderr.text).toBe( - FETCHED_BOTH_ALBUMS + "Collection 9 not found\n", - ); + expect(stderr.text).toBe("Collection 9 not found\n"); }); // Without a collection ID it lists every file in the account once (issue @@ -1389,9 +1387,9 @@ describe("backup-metadata --exif", () => { }); }); -// Each test first runs `files` so the cache holds the account as it was, then -// changes the server under it. (`collections` would not do: it reads the -// collection list alone and stores nothing.) +// Each test first runs `files 1` so the cache holds Vacation as it was, then +// adds a file to Vacation on the server. (`collections` would not do: it reads +// the collection list alone and stores nothing.) describe("backup-metadata and the thumbnail helpers refresh first", () => { beforeEach(async () => { expect(await filesCommand(context(), "1", {})).toBe(0); @@ -1579,11 +1577,13 @@ describe("an ID given on the command line", () => { // 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 -// album, one album after another, before it printed anything, so on such an -// account it seemed to hang. The account here has 40 albums of one photo each, -// encrypted as Ente's clients encrypt them, on the fake Ente server of -// `test/ente-server.ts`, which also records the path of each request quak -// sends. The session is the one logging in to that server saves. +// 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 +// listed that album (issue #217). The account here has 40 albums of one photo +// each, encrypted as Ente's clients encrypt them. The fake Ente server of +// `test/ente-server.ts` serves them. A wrapper around it records the path and +// query of each request quak sends. The session is the one logging in to that +// server saves. describe("an account with many albums", () => { const ALBUMS = 40; const PASSWORD = "many-albums"; @@ -1664,7 +1664,8 @@ describe("an account with many albums", () => { : input instanceof URL ? input.href : input.url; - paths.push(new URL(url).pathname); + const { pathname, search } = new URL(url); + paths.push(pathname + search); return ente(input, init); }) as typeof globalThis.fetch; const client = await Client.login({ @@ -1698,7 +1699,7 @@ describe("an account with many albums", () => { albums += `${id}\talbum\tAlbum ${id}\n`; expect(stdout.text).toBe(albums); expect(stderr.text).toBe(""); - expect(paths).toEqual(["/collections/v2"]); + expect(paths).toEqual(["/collections/v2?sinceTime=0"]); // It stored nothing that would make a refresh skip an album's files: // `files` after it still finds the album's photo. @@ -1707,33 +1708,59 @@ describe("an account with many albums", () => { expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n"); }); - it("files counts on stderr each album whose files its refresh fetched", async () => { + it("files fetches the files of the album it lists and of no other", async () => { expect(await filesCommand(accountContext(), "7", {})).toBe(0); expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n"); + expect(stderr.text).toBe(""); + expect(paths).toEqual([ + "/collections/v2?sinceTime=0", + "/collections/v2/diff?collectionID=7&sinceTime=0", + ]); + }); + + // The first listing stored the album, so the second finds its stored copy + // current and asks for no album's files. + it("files run again for the same album sends no request for its files", async () => { + const ctx = accountContext(); + expect(await filesCommand(ctx, "7", {})).toBe(0); + stdout.text = ""; + paths.length = 0; + + expect(await filesCommand(ctx, "7", {})).toBe(0); + expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n"); + expect(paths).toEqual(["/collections/v2?sinceTime=0"]); + }); + + it("files with no album's ID counts on stderr each album whose files its refresh fetched", async () => { + expect(await filesCommand(accountContext(), undefined, {})).toBe(0); + + let photos = ""; let progress = ""; for (let n = 1; n <= ALBUMS; n++) { + photos += `${n * 100}\timage\tphoto-${n}.jpg\n`; progress += `Fetched the files of ${n} of ${ALBUMS} collections\n`; } + expect(stdout.text).toBe(photos); expect(stderr.text).toBe(progress); }); // `--json` shapes stdout only, so the refresh's progress is still counted // on stderr. - it("files --json counts on stderr each album whose files its refresh fetched", async () => { - expect(await filesCommand(accountContext(), "7", { json: true })).toBe( - 0, - ); + it("files --json with no album's ID counts on stderr each album whose files its refresh fetched", async () => { + expect( + await filesCommand(accountContext(), undefined, { json: true }), + ).toBe(0); - expect(JSON.parse(stdout.text)).toEqual([ - { - id: 700, - title: "photo-7.jpg", - fileType: "image", - creationTime: 1000, - collectionID: 7, - }, - ]); + const rows = JSON.parse(stdout.text); + expect(rows).toHaveLength(ALBUMS); + expect(rows[6]).toEqual({ + id: 700, + title: "photo-7.jpg", + fileType: "image", + creationTime: 1000, + collectionID: 7, + }); let progress = ""; for (let n = 1; n <= ALBUMS; n++) { progress += `Fetched the files of ${n} of ${ALBUMS} collections\n`; diff --git a/test/cli/read.test.ts b/test/cli/read.test.ts index 66ff369..d58e72c 100644 --- a/test/cli/read.test.ts +++ b/test/cli/read.test.ts @@ -3,13 +3,16 @@ * issue #36, issue #52). * * The `collections`, `files`, `get`, and `get-thumb` commands must answer for - * current server state, not the local cache. `files`, `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, so a helper that read - * without refreshing would come back empty and fail here. `collections` asks - * the library for the collection list alone (`freshCollections()`, issue - * #210), and must not wait for a refresh, which would fetch the files of every - * collection. + * 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. + * The stand-in library below serves nothing until `fresh()` has been awaited. + * A helper that read without refreshing would come back empty and fail here. + * `collections` asks the library for the collection list alone + * (`freshCollections()`, issue #210). It must not wait for a refresh, which + * would fetch the files of every collection. `files COLLECTION` reads that list + * too, then brings that one collection up to date (`refreshCollection()`, + * issue #217). The stand-in serves a collection's files once `fresh()` or + * `refreshCollection()` for that collection has run. * * `collections` and `files` also list in the library's enumeration order * (`listCollections`/`listFiles`) — the order the pre-library CLI printed — not @@ -64,12 +67,14 @@ const file = ( // A library that reveals its records only after `fresh()` has been awaited, and // serves them in the enumeration order it was given. `freshCollections()` -// answers the collection list in that order without a refresh. `photos.byID` -// returns a stand-in `Photo` carrying just the fileID the helper passes -// through. +// answers the collection list in that order without a refresh. +// `refreshCollection()` reveals the files of that one collection. +// `photos.byID` returns a stand-in `Photo` carrying just the fileID the helper +// passes through. class FakeLibrary implements FreshReadLibrary { freshCalls = 0; freshCollectionsCalls = 0; + refreshCollectionIDs: number[] = []; private refreshed = false; constructor( @@ -88,9 +93,8 @@ class FakeLibrary implements FreshReadLibrary { return this.collections; } - getCollection(id: number): Collection | undefined { - if (!this.refreshed) return undefined; - return this.collections.find((c) => c.id === id); + async refreshCollection(collection: Collection): Promise { + this.refreshCollectionIDs.push(collection.id); } listCollections(): Collection[] { @@ -98,7 +102,9 @@ class FakeLibrary implements FreshReadLibrary { } listFiles(collectionID: number): EnteFile[] { - return this.refreshed + const current = + this.refreshed || this.refreshCollectionIDs.includes(collectionID); + return current ? this.files.filter((f) => f.collectionID === collectionID) : []; } @@ -138,18 +144,25 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => { expect(rows.map((c) => c.id)).not.toEqual(newestFirst); }); - it("freshFiles refreshes first, lists in enumeration order, keeps raw fields", async () => { + it("freshFiles brings the collection alone up to date, lists in enumeration order, keeps raw fields", async () => { // Enumeration order by id 10, 11, 12; creationTimes ascending, so a - // newest-first sort would reverse them. + // newest-first sort would reverse them. Collection 2's file is not + // listed. const files = [ file(10, 1, 1_700_000_000_000_000), file(11, 1, 1_700_000_000_000_001), file(12, 1, 1_700_000_000_000_002), + file(20, 2, 1_700_000_000_000_003), ]; - const lib = new FakeLibrary([collection(1, 100)], files); + const lib = new FakeLibrary( + [collection(1, 100), collection(2, 100)], + files, + ); const rows = await freshFiles(lib, 1); - expect(lib.freshCalls).toBe(1); + expect(lib.freshCollectionsCalls).toBe(1); + expect(lib.refreshCollectionIDs).toEqual([1]); + expect(lib.freshCalls).toBe(0); expect(rows?.map((f) => f.id)).toEqual([10, 11, 12]); // Field values come from raw metadata: microsecond creationTime and the @@ -206,7 +219,9 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => { it("freshFiles returns undefined for an unknown collection", async () => { const lib = new FakeLibrary([collection(1, 100)], []); const rows = await freshFiles(lib, 999); - expect(lib.freshCalls).toBe(1); + expect(lib.freshCollectionsCalls).toBe(1); + expect(lib.refreshCollectionIDs).toEqual([]); + expect(lib.freshCalls).toBe(0); expect(rows).toBeUndefined(); }); diff --git a/test/library/fresh.test.ts b/test/library/fresh.test.ts index 859a8da..2d0b07e 100644 --- a/test/library/fresh.test.ts +++ b/test/library/fresh.test.ts @@ -17,6 +17,12 @@ * 4. `freshCollections()` reads the collection list alone, with one request * and no collection's files, and stores nothing, so the next refresh still * fetches the files of every collection (issue #210). + * 5. `refreshCollection()` brings one collection up to date in the store + * (issue #217). It leaves the stored cursor 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, 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: * no crypto, no network, scripted pages, and a record of each call. A long @@ -30,6 +36,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { Library } from "../../src/library/index.js"; +import { MetadataStore } from "../../src/library/store.js"; import type { CollectionsPage, FilesPage } from "../../src/client.js"; import type { Collection, EnteFile } from "../../src/model/types.js"; @@ -144,6 +151,26 @@ const openSeeded = async ( 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; release: () => void } => { + let release!: () => void; + const held = new Promise((r) => (release = r)); + let sent!: () => void; + const started = new Promise((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", () => { let dir: string; let cacheDirectory: string; @@ -340,3 +367,216 @@ describe("Library.freshCollections", () => { } }); }); + +describe("Library.refreshCollection", () => { + let dir: string; + let cacheDirectory: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "quak-refresh-collection-")); + cacheDirectory = join(dir, "cache"); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + // Collections 1 and 2 are stored at updationTime 100, with a stored cursor + // of 100, and then both change on the server. + it("fetches the files of one changed collection and leaves the others to the next refresh", async () => { + const client = new MockClient(); + client.collectionsQueue.push({ + collections: [collection(1, 100), collection(2, 100)], + deleted: [], + cursor: 100, + }); + client.filesFor(1, { + files: [file(1001, 1, 90)], + deleted: [], + cursor: 90, + }); + client.filesFor(2, { + files: [file(2001, 2, 90)], + deleted: [], + cursor: 90, + }); + const lib = await Library.open({ + client, + cacheDirectory, + refreshIntervalSeconds: SLOW_INTERVAL, + }); + try { + client.filesCalls.length = 0; + client.filesFor(1, { + files: [file(1002, 1, 110)], + deleted: [], + cursor: 110, + }); + client.filesFor(2, { + files: [file(2002, 2, 120)], + deleted: [], + cursor: 120, + }); + + // Collection 1's files alone, since the stored copy. + await lib.refreshCollection(collection(1, 110)); + expect(client.filesCalls).toEqual([ + { collectionID: 1, sinceTime: 100 }, + ]); + expect(lib.listFiles(1).map((f) => f.id)).toEqual([1001, 1002]); + expect(lib.listFiles(2).map((f) => f.id)).toEqual([2001]); + + // The stored copy is now current, so no request. + await lib.refreshCollection(collection(1, 110)); + expect(client.filesCalls).toHaveLength(1); + + // The next refresh asks from the stored cursor, 100, so it still + // sees collection 2 changed and fetches its files. + client.collectionsQueue.push({ + collections: [collection(1, 110), collection(2, 120)], + deleted: [], + cursor: 120, + }); + await lib.fresh(); + expect(client.collectionsSinceTimes).toEqual([0, 100]); + expect(client.filesCalls).toEqual([ + { collectionID: 1, sinceTime: 100 }, + { collectionID: 2, sinceTime: 100 }, + ]); + expect(lib.listFiles(2).map((f) => f.id)).toEqual([2001, 2002]); + } finally { + await lib.close(); + } + }); + + // Collection 1 is stored at 100. `refreshCollection()` asks for it at 110, + // which adds file 1002. While that files request waits, a fresh read + // starts a refresh. The server tells that refresh that collection 1 was + // 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 files = holdNextFilesRequest(client); + try { + client.filesFor(1, { + files: [file(1002, 1, 110)], + deleted: [], + cursor: 110, + }); + client.collectionsQueue.push({ + collections: [], + deleted: [1], + cursor: 130, + }); + + const refreshing = lib.refreshCollection(collection(1, 110)); + await files.started; + const reading = lib.fresh(); + // The refresh has not sent its collections request yet. + await new Promise((r) => setImmediate(r)); + expect(client.collectionsSinceTimes).toEqual([0]); + + files.release(); + 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 { + await lib.close(); + } + }); +});