1 Commits
Author SHA1 Message Date
sneak fde6f19c86 quak get and get-thumb on an empty cache stop fetching collections once they find the file (closes #222)
check / check (push) Canceled after 0s
`quak get` and `quak get-thumb` used to wait for a refresh, which on an
empty cache fetched the files of every collection. Now, on an empty
cache, `freshFile` reads the collection list, then one collection's
files at a time, and stops at the first that holds the file. That search
stores nothing. Once the cache holds the account's collections,
`freshFile` refreshes as before and looks the file up in the cache. The
commands fetch the content through the new `Library.originalOf()` and
`Library.thumbnailOf()`, which take the file record itself, since the
store may not hold it.

On an empty cache they no longer print a refresh's progress lines. The
`-q` test that relied on `get` refreshing now runs
`helper fix-missing-thumbnails`.

Model: opus-5-5
2026-10-26 08:53:42 +00:00
7 changed files with 90 additions and 82 deletions
+21 -18
View File
@@ -687,21 +687,22 @@ 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 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 same list, then for that one `files` with a collection ID asks for the same list, then for that one
collection's files, and does not change the cache either. `get` and `get-thumb` collection's files, and does not change the cache either. On an empty cache,
ask for the same list, then for one collection's files at a time, until they `get` and `get-thumb` ask for the same list, then for one collection's files at
find the file. They ask first for the collections the cache says hold the file. a time, until they find the file. They store none of the collections or file
They store nothing they read. The original or thumbnail they fetch is kept in records they read. The original or thumbnail they fetch is kept in the cache.
the cache. The others wait for a refresh, and so does `files` without a Once the cache holds the account's collections, they wait for a refresh instead.
collection ID. With an empty cache, as on a first run, that refresh fetches the The others wait for a refresh, and so does `files` without a collection ID. With
files of every collection, one collection after another, and stores nothing an 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.
@@ -1190,7 +1191,8 @@ https://git.eeqj.de/sneak/quak/issues/210).
It answers with every live file in that collection that decrypts, in the It answers with every live file in that collection that decrypts, in the
server's order. `collection` is a record that `freshCollections()` returned. The server's order. `collection` is a record that `freshCollections()` returned. The
method fetches no other collection's files and stores nothing. `quak files` with method fetches no other collection's files and stores nothing. `quak files` with
a collection ID uses it, and so do `quak get` and `quak get-thumb`. a collection ID uses it, and so do `quak get` and `quak get-thumb` on an empty
cache.
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
@@ -1275,8 +1277,9 @@ in the cache, which does not make `isLocal` true; only `download()` and
`await lib.originalOf(file, opts?)` and `await lib.thumbnailOf(file, opts?)` `await lib.originalOf(file, opts?)` and `await lib.thumbnailOf(file, opts?)`
fetch and cache as `photo.original()` and `photo.thumbnail()` do. `file` is an fetch and cache as `photo.original()` and `photo.thumbnail()` do. `file` is an
`EnteFile` the caller holds, and the library need not hold it. `quak get` and `EnteFile` the caller holds, and the library need not hold it. On an empty
`quak get-thumb` pass one that `lib.freshFiles()` returned. cache, `quak get` and `quak get-thumb` pass one that `lib.freshFiles()`
returned.
Lower-level accessors that return decrypted model objects (which hold key Lower-level accessors that return decrypted model objects (which hold key
material) are also available: `listCollections()`, `getCollection(id)`, material) are also available: `listCollections()`, `getCollection(id)`,
+8 -7
View File
@@ -26,13 +26,14 @@ declares one.
# Completed Steps # Completed Steps
- 2026-10-26: `quak get` and `quak get-thumb` stop fetching collections' files - 2026-10-26: On an empty cache, `quak get` and `quak get-thumb` stop fetching
once they find the file (https://git.eeqj.de/sneak/quak/issues/222). They used collections' files once they find the file
to wait for a refresh, which on an empty cache fetched the files of every (https://git.eeqj.de/sneak/quak/issues/222). They used to wait for a refresh,
collection. They now read the collection list, then one collection's files at which on an empty cache fetched the files of every collection. Now they read
a time, first those the cache says hold the file. They store nothing they the collection list, then one collection's files at a time. They store none of
read, and fetch the file through the new `lib.originalOf()` and the collections or file records they read, and fetch the original or thumbnail
`lib.thumbnailOf()`. through the new `lib.originalOf()` and `lib.thumbnailOf()`. Once the cache
holds the account's collections, they still wait for a refresh.
- 2026-10-26: `quak files` with a collection ID fetches that collection's files - 2026-10-26: `quak files` with a collection ID fetches that collection's files
alone (https://git.eeqj.de/sneak/quak/issues/217). It used to wait for a alone (https://git.eeqj.de/sneak/quak/issues/217). It used to wait for a
+8 -8
View File
@@ -132,10 +132,10 @@ const readLibraryClient = (client: Client): LibraryClient => ({
// precache (issue #48) is off, because a one-shot `collections` or `get` must // precache (issue #48) is off, because a one-shot `collections` or `get` must
// not start downloading the whole account. No refresh starts as the library // not start downloading the whole account. No refresh starts as the library
// opens or on a timer. Each command asks for the refresh it needs. // opens or on a timer. Each command asks for the refresh it needs.
// `collections` needs none (issue #210). Nor do `get`, `get-thumb` and `files` // `collections` and `files` with a collection ID need none (issues #210 and
// with a collection ID (issues #217 and #222). While a refresh runs, each // #217). Nor do `get` and `get-thumb` on an empty cache (issue #222). While a
// collection whose files it has fetched is logged, so a large account shows // refresh runs, each collection whose files it has fetched is logged, so a
// progress rather than nothing. // large account shows progress rather than nothing.
const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> => const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> =>
Library.open({ Library.open({
client: readLibraryClient(client), client: readLibraryClient(client),
@@ -323,8 +323,8 @@ export const getCommand = async (
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
// Ask the server for the file as it is now (issue #36 amendment, // Force a server round-trip so the file resolves against current state
// issues #52 and #222). // (issue #36 amendment, issue #52).
const file = await freshFile(lib, fileID); const file = await freshFile(lib, fileID);
if (!file) { if (!file) {
ctx.log("fatal", `File ${fileID} not found`); ctx.log("fatal", `File ${fileID} not found`);
@@ -380,8 +380,8 @@ export const getThumbCommand = async (
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
// Ask the server for the file as it is now (issue #36 amendment, // Force a server round-trip so the file resolves against current state
// issues #52 and #222). // (issue #36 amendment, issue #52).
const file = await freshFile(lib, fileID); const file = await freshFile(lib, fileID);
if (!file) { if (!file) {
ctx.log("fatal", `File ${fileID} not found`); ctx.log("fatal", `File ${fileID} not found`);
+21 -24
View File
@@ -2,16 +2,16 @@
// //
// `collections`, `files`, `get`, and `get-thumb` must answer for // `collections`, `files`, `get`, and `get-thumb` must answer for
// 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). `get`,
// without a collection ID therefore forces a server round-trip through // `get-thumb` and `files` without a collection ID therefore force a server
// `Library.fresh()` and only then reads, so a change made elsewhere is visible. // round-trip through `Library.fresh()` and only then read, so a change made
// `collections` asks the library for the collection list alone through // elsewhere is visible. `collections` asks the library for the collection list
// `Library.freshCollections()` (issue #210): a refresh would also fetch the // alone through `Library.freshCollections()` (issue #210): a refresh would also
// files of every collection, which on a large account takes long and which // fetch the files of every collection, which on a large account takes long and
// `collections` does not print. `files` with a collection ID reads that list // which `collections` does not print. `files` with a collection ID reads that
// too, then that one collection's files through `Library.freshFiles()` (issue // list too, then that one collection's files through `Library.freshFiles()`
// #217). `get` and `get-thumb` read that list, then one collection's files at a // (issue #217). On an empty cache, `get` and `get-thumb` read that list, then
// time, until they find the file (issue #222). // one collection's files at a time, until they find the file (issue #222).
// //
// `collections` and `files` with a collection ID list in the server's order. // `collections` and `files` with a collection ID list in the server's order.
// `files` without one lists in the library's own enumeration order // `files` without one lists in the library's own enumeration order
@@ -33,7 +33,7 @@ export interface FreshReadLibrary {
freshFiles(collection: Collection): Promise<EnteFile[]>; freshFiles(collection: Collection): Promise<EnteFile[]>;
listCollections(): Collection[]; listCollections(): Collection[];
listFiles(collectionID: number): EnteFile[]; listFiles(collectionID: number): EnteFile[];
getFile(collectionID: number, fileID: number): EnteFile | undefined; getFileByID(fileID: number): EnteFile | undefined;
} }
// Every live collection, current as of one collections request, in the // Every live collection, current as of one collections request, in the
@@ -70,23 +70,20 @@ export const freshFiles = async (
}; };
// One file as the server holds it now, or `undefined` when no collection holds // One file as the server holds it now, or `undefined` when no collection holds
// it. It reads the collection list, then one collection's files at a time, and // it. When the cache holds the account's collections, it refreshes and looks
// stops at the first collection that holds the file. It tries first the // the file up in the cache. On an empty cache, it reads the collection list,
// collections the cache says hold the file, then the rest in the server's // then one collection's files at a time, and stops at the first collection
// order. It stores nothing. // that holds the file. That search stores nothing.
export const freshFile = async ( export const freshFile = async (
lib: FreshReadLibrary, lib: FreshReadLibrary,
fileID: number, fileID: number,
): Promise<EnteFile | undefined> => { ): Promise<EnteFile | undefined> => {
const collections = await lib.freshCollections(); // Only a refresh stores collections, so a cache holding any holds them all.
// Whether the cache holds the file in collection `c`. if (lib.listCollections().length > 0) {
const inCache = (c: Collection): boolean => await lib.fresh();
lib.getFile(c.id, fileID) !== undefined; return lib.getFileByID(fileID);
const ordered = [ }
...collections.filter(inCache), for (const collection of await lib.freshCollections()) {
...collections.filter((c) => !inCache(c)),
];
for (const collection of ordered) {
const files = await lib.freshFiles(collection); const files = await lib.freshFiles(collection);
const file = files.find((f) => f.id === fileID); const file = files.find((f) => f.id === fileID);
if (file) return file; if (file) return file;
+4 -4
View File
@@ -647,8 +647,8 @@ export class Library {
} }
// The original of `file`, fetched and cached as `Photo.original()` does. // The original of `file`, fetched and cached as `Photo.original()` does.
// `file` is used as given, so the store need not hold it. `quak get` // `file` is used as given, so the store need not hold it. On an empty
// passes a file that `freshFiles()` returned. // cache, `quak get` passes a file that `freshFiles()` returned.
async originalOf( async originalOf(
file: EnteFile, file: EnteFile,
opts?: ContentOptions, opts?: ContentOptions,
@@ -662,8 +662,8 @@ export class Library {
} }
// The thumbnail of `file`, fetched and cached as `Photo.thumbnail()` does. // The thumbnail of `file`, fetched and cached as `Photo.thumbnail()` does.
// `file` is used as given, so the store need not hold it. `quak get-thumb` // `file` is used as given, so the store need not hold it. On an empty
// passes a file that `freshFiles()` returned. // cache, `quak get-thumb` passes a file that `freshFiles()` returned.
async thumbnailOf( async thumbnailOf(
file: EnteFile, file: EnteFile,
opts?: ContentOptions, opts?: ContentOptions,
+11 -7
View File
@@ -1816,7 +1816,7 @@ describe("an account with many albums", () => {
// Photo 200 is in album 2, so `get` asks for no file of album 3 or later. // Photo 200 is in album 2, so `get` asks for no file of album 3 or later.
// The last request downloads the photo's original. // The last request downloads the photo's original.
it("get asks for the albums' files only until one holds the photo", async () => { it("get on an empty cache asks for the albums' files only until one holds the photo", async () => {
const out = join(root, "photo.jpg"); const out = join(root, "photo.jpg");
expect(await getCommand(accountContext(), "200", { out })).toBe(0); expect(await getCommand(accountContext(), "200", { out })).toBe(0);
@@ -1831,9 +1831,9 @@ describe("an account with many albums", () => {
expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false); expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false);
}); });
// `files` first fills the cache. The cache then says album 40, the last, // `files` first fills the cache, so `get` waits for a refresh. No album has
// holds photo 4000, so `get` asks for that album's files alone. // changed since, so the refresh asks for no album's files.
it("get asks first for the files of the album the cache says holds the photo", async () => { it("get on a cache that holds the account asks for no unchanged album's files, also for a photo in no album", async () => {
const ctx = accountContext(); const ctx = accountContext();
expect(await filesCommand(ctx, undefined, {})).toBe(0); expect(await filesCommand(ctx, undefined, {})).toBe(0);
requests.length = 0; requests.length = 0;
@@ -1841,14 +1841,18 @@ describe("an account with many albums", () => {
const out = join(root, "photo.jpg"); const out = join(root, "photo.jpg");
expect(await getCommand(ctx, "4000", { out })).toBe(0); expect(await getCommand(ctx, "4000", { out })).toBe(0);
expect(readFileSync(out, "utf-8")).toBe("photo-40.jpg"); expect(readFileSync(out, "utf-8")).toBe("photo-40.jpg");
expect(stderr.text).toBe(`12 bytes -> ${out}\n`); expect(stderr.text).toBe(`12 bytes -> ${out}\n`);
expect(requests).toEqual([ expect(requests).toEqual([
"/collections/v2?sinceTime=0", "/collections/v2?sinceTime=1",
"/collections/v2/diff?collectionID=40&sinceTime=0",
"/?fileID=4000", "/?fileID=4000",
]); ]);
requests.length = 0;
stderr.text = "";
expect(await getCommand(ctx, "999", { out })).toBe(1);
expect(stderr.text).toBe("File 999 not found\n");
expect(requests).toEqual(["/collections/v2?sinceTime=1"]);
}); });
it("files counts on stderr each album whose files its refresh fetched", async () => { it("files counts on stderr each album whose files its refresh fetched", async () => {
+17 -14
View File
@@ -3,16 +3,16 @@
* issue #36, issue #52). * issue #36, issue #52).
* *
* 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. `get`, `get-thumb` and `files`
* forces a `Library.fresh()` round-trip before it reads. The stand-in library * without a collection ID force a `Library.fresh()` round-trip before they
* below serves no cached record until `fresh()` has been awaited, so a helper * read. The stand-in library below serves no cached record until `fresh()` has
* that read without refreshing would come back empty and fail here. * been awaited, so a helper that read without refreshing would come back empty
* `collections` asks the library for the collection list alone * and fail here. `collections` asks the library for the collection list alone
* (`freshCollections()`, issue #210). `files` with a collection ID asks for * (`freshCollections()`, issue #210). `files` with a collection ID asks for
* that list, then for that one collection's files (`freshFiles()`, issue * that list, then for that one collection's files (`freshFiles()`, issue
* #217). `get` and `get-thumb` ask for that list, then for one collection's * #217). Neither may wait for a refresh, which would fetch the files of every
* files at a time, until they find the file (issue #222). None of these may * collection. On an empty cache, `get` and `get-thumb` ask for that list, then
* wait for a refresh, which would fetch the files of every collection. * for one collection's files at a time, until they find the file (issue #222).
* *
* `collections` and `files` also list in the order the library gives them, not * `collections` and `files` also list in the order the library gives them, not
* the albums/photos projection's newest-first order. The fixtures are seeded in * the albums/photos projection's newest-first order. The fixtures are seeded in
@@ -105,8 +105,9 @@ class FakeLibrary implements FreshReadLibrary {
: []; : [];
} }
getFile(collectionID: number, fileID: number): EnteFile | undefined { getFileByID(fileID: number): EnteFile | undefined {
return this.listFiles(collectionID).find((f) => f.id === fileID); if (!this.refreshed) return undefined;
return this.files.find((f) => f.id === fileID);
} }
} }
@@ -221,7 +222,7 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
], ],
); );
it("freshFile reads each collection's files in the server's order until one holds the file", async () => { it("freshFile on an empty cache reads each collection's files in the server's order until one holds the file", async () => {
const lib = threeCollections(); const lib = threeCollections();
const found = await freshFile(lib, 20); const found = await freshFile(lib, 20);
@@ -233,16 +234,18 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
}); });
// The stand-in's cache holds what the server holds once `fresh()` has run. // The stand-in's cache holds what the server holds once `fresh()` has run.
it("freshFile reads first the files of the collection the cache says holds the file", async () => { it("freshFile on a cache that holds the collections refreshes, then looks the file up in the cache", async () => {
const lib = threeCollections(); const lib = threeCollections();
await lib.fresh(); await lib.fresh();
const found = await freshFile(lib, 30); const found = await freshFile(lib, 30);
expect(lib.freshFilesCalls).toEqual([3]); expect(lib.freshCalls).toBe(2);
expect(lib.freshCollectionsCalls).toBe(0);
expect(lib.freshFilesCalls).toEqual([]);
expect(found?.collectionID).toBe(3); expect(found?.collectionID).toBe(3);
}); });
it("freshFile returns undefined for an unknown file, after reading every collection's files", async () => { it("freshFile on an empty cache returns undefined for an unknown file, after reading every collection's files", async () => {
const lib = threeCollections(); const lib = threeCollections();
const found = await freshFile(lib, 404); const found = await freshFile(lib, 404);