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.
|
||||
`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. 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").
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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, 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
|
||||
`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
|
||||
@@ -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
|
||||
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
|
||||
@@ -1187,10 +1195,15 @@ https://git.eeqj.de/sneak/quak/issues/210).
|
||||
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`. Otherwise it fetches the collection's files changed since that
|
||||
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. `quak files` with a collection ID uses it.
|
||||
`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 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
|
||||
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
|
||||
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.
|
||||
`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
|
||||
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(),
|
||||
});
|
||||
|
||||
// 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` and
|
||||
// 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.
|
||||
|
||||
+12
-12
@@ -4,14 +4,14 @@
|
||||
// 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`
|
||||
// without a collection ID, `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
|
||||
// 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
|
||||
// on a large account takes long and which `collections` does not print. For the
|
||||
// same reason, `files COLLECTION` reads the collection list the same way and
|
||||
// then brings that one collection up to date through
|
||||
// `Library.refreshCollection()` (issue #217), fetching no other collection's
|
||||
// (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
|
||||
@@ -27,9 +27,9 @@ 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()`, `freshCollections()` and `refreshCollection()` 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<unknown>;
|
||||
freshCollections(): Promise<Collection[]>;
|
||||
@@ -50,8 +50,8 @@ export const freshCollections = (
|
||||
// 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, current as of a forced refresh,
|
||||
// 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,
|
||||
|
||||
+115
-79
@@ -1,15 +1,16 @@
|
||||
// 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 no refresh has stored a collection yet (the stored
|
||||
// cursor is 0), `open()` awaits the first refresh. The cache is then empty, or
|
||||
// holds only what `refreshCollection()` stored, and the library must not serve
|
||||
// it 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 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).
|
||||
//
|
||||
// `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
|
||||
@@ -22,10 +23,10 @@
|
||||
// #210): one collections request, no collection's files, and nothing stored,
|
||||
// for a caller such as `quak collections` that needs no more.
|
||||
// `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, for a caller such as `quak files COLLECTION`. A caller that asks for
|
||||
// each refresh it needs can also open the library with `refreshOnOpen: false`,
|
||||
// so that `open()` starts none.
|
||||
// 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
|
||||
@@ -309,12 +310,16 @@ export class Library {
|
||||
private readonly precache?: Precache;
|
||||
|
||||
private timer?: ReturnType<typeof setTimeout>;
|
||||
// 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 that, a fresh read propagates it.
|
||||
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
|
||||
// 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
|
||||
@@ -396,14 +401,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<Library> {
|
||||
const { userID } = opts.client.whoami();
|
||||
const cacheDirectory =
|
||||
@@ -509,13 +516,17 @@ export class Library {
|
||||
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();
|
||||
}
|
||||
@@ -630,34 +641,16 @@ export class Library {
|
||||
|
||||
// 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, and fetches no other collection's files.
|
||||
// It sends no request when the stored copy's `updationTime` is as new.
|
||||
// Otherwise it fetches the files changed since the stored copy, or all of
|
||||
// them when none is stored, then commits and saves them. A refresh already
|
||||
// running is waited for first.
|
||||
// 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. A fresh read or `backup()` that starts meanwhile waits for it, and
|
||||
// the background loop skips its turn.
|
||||
async refreshCollection(collection: Collection): Promise<void> {
|
||||
while (this.cycle) await this.cycle.catch(() => {});
|
||||
if (this.closed) throw new Error("the library is closed");
|
||||
await this.cycle?.catch(() => {});
|
||||
const known = this.store.getCollection(collection.id);
|
||||
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 }],
|
||||
await this.startCycle(false, () =>
|
||||
this.refreshCollectionOnce(collection),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -710,11 +703,13 @@ 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
|
||||
// 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()`, not thrown here.
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
@@ -738,33 +733,40 @@ 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. Resolves once the cycle it
|
||||
// started (or skipped past) settles.
|
||||
private runRefresh(): Promise<void> {
|
||||
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<void> {
|
||||
if (this.closed) {
|
||||
return Promise.reject(new Error("the library is closed"));
|
||||
// server round-trips. A running `refreshCollection()` is waited for first.
|
||||
private async refreshNow(): Promise<void> {
|
||||
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<void> {
|
||||
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<void>,
|
||||
): Promise<void> {
|
||||
const cycle = work();
|
||||
this.cycle = cycle;
|
||||
this.cycleIsRefresh = isRefresh;
|
||||
void cycle.then(
|
||||
() => {
|
||||
if (this.cycle === cycle) this.cycle = undefined;
|
||||
@@ -838,9 +840,43 @@ export class Library {
|
||||
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
|
||||
// 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.
|
||||
private async commit(
|
||||
page: CollectionsPage,
|
||||
|
||||
@@ -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<number, Collection>();
|
||||
private readonly files = new Map<string, EnteFile>();
|
||||
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 ?? []) {
|
||||
|
||||
@@ -1577,12 +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
|
||||
// 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, on the fake Ente server of
|
||||
// `test/ente-server.ts`, which also records the path and query of each request
|
||||
// quak sends. The session is the one logging in to that server saves.
|
||||
// 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";
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
*
|
||||
* The `collections`, `files`, `get`, and `get-thumb` commands must answer for
|
||||
* 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,
|
||||
* 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
|
||||
* `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 and then brings that one collection up to date
|
||||
* (`refreshCollection()`, issue #217); the stand-in serves a collection's files
|
||||
* once either has run.
|
||||
* 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
|
||||
|
||||
+145
-38
@@ -17,10 +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 and
|
||||
* leaves the stored cursor where it was, so the next refresh still fetches
|
||||
* the files of every other collection that changed (issue #217). A newer
|
||||
* copy that a refresh stores while it fetches the files is kept.
|
||||
* 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
|
||||
@@ -34,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";
|
||||
|
||||
@@ -148,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<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", () => {
|
||||
let dir: string;
|
||||
let cacheDirectory: string;
|
||||
@@ -427,48 +450,132 @@ describe("Library.refreshCollection", () => {
|
||||
});
|
||||
|
||||
// Collection 1 is stored at 100. `refreshCollection()` asks for it at 110,
|
||||
// which adds file 1002. While that files request waits, a refresh stores
|
||||
// it at 120, where 1002 is deleted again and 1003 added.
|
||||
it("keeps a newer copy that a refresh stores while the files are fetched", async () => {
|
||||
// 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);
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((r) => (release = r));
|
||||
const files = holdNextFilesRequest(client);
|
||||
try {
|
||||
client.filesFor(
|
||||
1,
|
||||
{ files: [file(1002, 1, 110)], deleted: [], cursor: 110 },
|
||||
{ files: [file(1003, 1, 120)], deleted: [1002], cursor: 120 },
|
||||
);
|
||||
client.collectionsQueue.push({
|
||||
collections: [collection(1, 120)],
|
||||
client.filesFor(1, {
|
||||
files: [file(1002, 1, 110)],
|
||||
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));
|
||||
await started;
|
||||
await lib.fresh();
|
||||
release();
|
||||
await refreshing;
|
||||
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]);
|
||||
|
||||
expect(lib.getCollection(1)?.updationTime).toBe(120);
|
||||
expect(lib.listFiles(1).map((f) => f.id)).toEqual([1001, 1003]);
|
||||
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 {
|
||||
release();
|
||||
await lib.close();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user