Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6eac17b348 |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 ?? []) {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user