Precache all thumbnails and pinned originals inside open() (closes #48)
check / check (push) Successful in 26s
check / check (push) Successful in 26s
Precaches aggressively inside open(): every thumbnail (newest first, through the shared thumbnail pool, never evicted; visible/ahead requests preempt the background fill) and the pinned originals — the favorites album plus every file within precacheOriginalsDays (default 7) of the newest takenAt — through the content pool. The pinned set integrates with the #47 eviction hook; when the window moves or a favorite is removed, files become ordinary evictable originals. open() options precacheThumbnails/precacheOriginals/precacheOriginalsDays; progress via onProgress/status(). Model: opus-4-8
This commit was merged in pull request #73.
This commit is contained in:
+38
-9
@@ -328,12 +328,45 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
||||
}
|
||||
|
||||
async ensureThumbnails(args: EnsureOptions): Promise<EnsureResult[]> {
|
||||
const priority = poolPriorityOf(args.priority);
|
||||
return this.ensureMany(
|
||||
"thumbnail",
|
||||
poolPriorityOf(args.priority),
|
||||
args.fileIDs,
|
||||
args.signal,
|
||||
args.onProgress,
|
||||
);
|
||||
}
|
||||
|
||||
// Fill originals through the content pool for the precache (#48), always at
|
||||
// background priority so an on-demand `original()` preempts the fill. A
|
||||
// present original is a map lookup and no fetch; a per-file failure is
|
||||
// returned, not thrown, so one bad file never halts a background sweep.
|
||||
async ensureOriginals(args: {
|
||||
fileIDs: number[];
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (event: EnsureEvent) => void;
|
||||
}): Promise<EnsureResult[]> {
|
||||
return this.ensureMany(
|
||||
"original",
|
||||
"background",
|
||||
args.fileIDs,
|
||||
args.signal,
|
||||
args.onProgress,
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureMany(
|
||||
kind: Kind,
|
||||
priority: Priority,
|
||||
fileIDs: number[],
|
||||
signal: AbortSignal | undefined,
|
||||
onProgress: ((event: EnsureEvent) => void) | undefined,
|
||||
): Promise<EnsureResult[]> {
|
||||
// Dedup the request list so a repeated fileID is fetched once and
|
||||
// reported once, in first-requested order.
|
||||
const seen = new Set<number>();
|
||||
const unique: number[] = [];
|
||||
for (const id of args.fileIDs) {
|
||||
for (const id of fileIDs) {
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
unique.push(id);
|
||||
@@ -341,24 +374,20 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
||||
}
|
||||
return Promise.all(
|
||||
unique.map((fileID) =>
|
||||
this.ensureOne(fileID, priority, args.signal, args.onProgress),
|
||||
this.ensureOne(fileID, kind, priority, signal, onProgress),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureOne(
|
||||
fileID: number,
|
||||
kind: Kind,
|
||||
priority: Priority,
|
||||
signal: AbortSignal | undefined,
|
||||
onProgress: ((event: EnsureEvent) => void) | undefined,
|
||||
): Promise<EnsureResult> {
|
||||
try {
|
||||
const result = await this.acquire(
|
||||
fileID,
|
||||
"thumbnail",
|
||||
priority,
|
||||
signal,
|
||||
);
|
||||
const result = await this.acquire(fileID, kind, priority, signal);
|
||||
const status = result.cached ? "skipped" : "done";
|
||||
onProgress?.({ fileID, status, path: result.path });
|
||||
return { fileID, path: result.path };
|
||||
|
||||
+84
-14
@@ -49,6 +49,7 @@ import {
|
||||
type EnsureResult,
|
||||
} from "./content.js";
|
||||
import { makeMLDataAPI, type MLDataAPI } from "./mlsearch.js";
|
||||
import { Precache } from "./precache.js";
|
||||
|
||||
export {
|
||||
Album,
|
||||
@@ -87,6 +88,23 @@ export {
|
||||
|
||||
export const DEFAULT_REFRESH_INTERVAL_SECONDS = 3;
|
||||
|
||||
// Project a metadata store into by-id records, filling each record's cache
|
||||
// paths from the content cache when one is given. Shared by the live read
|
||||
// projection and the precache's initial seeding at open().
|
||||
const deriveRecordsFromStore = (
|
||||
store: MetadataStore,
|
||||
cache?: ContentCache,
|
||||
): DerivedRecords => {
|
||||
const collections = store.listCollections();
|
||||
const files: EnteFile[] = [];
|
||||
for (const c of collections) files.push(...store.listFiles(c.id));
|
||||
return deriveRecords(
|
||||
collections,
|
||||
files,
|
||||
cache ? (fileID) => cache.pathsFor(fileID) : undefined,
|
||||
);
|
||||
};
|
||||
|
||||
// The slice of `Client` the library depends on. Narrowing to an interface lets
|
||||
// tests drive a mock with no crypto or network; the real `Client` satisfies it
|
||||
// structurally.
|
||||
@@ -115,9 +133,15 @@ export interface LibraryClient {
|
||||
// A progress event for one unit of background work. A metadata "refresh" or an
|
||||
// ML "fetchMLData" pass each fire "started" before their network work and then
|
||||
// exactly one of "done" or "failed"; "failed" carries the error message and
|
||||
// an ML "done" reports how many payloads it stored.
|
||||
// an ML "done" reports how many payloads it stored. The precache fills
|
||||
// ("precacheThumbnails"/"precacheOriginals", #48) fire "started"/"done" around
|
||||
// each sweep that has work, "done" reporting the count newly cached.
|
||||
export interface RefreshEvent {
|
||||
operation: "refresh" | "fetchMLData";
|
||||
operation:
|
||||
| "refresh"
|
||||
| "fetchMLData"
|
||||
| "precacheThumbnails"
|
||||
| "precacheOriginals";
|
||||
status: "started" | "done" | "failed";
|
||||
error?: string;
|
||||
fetched?: number;
|
||||
@@ -147,9 +171,17 @@ export interface LibraryOptions {
|
||||
// down as the disk fills; `status().originalsLimitBytes` reports it.
|
||||
cacheOriginalsMaxBytes?: number;
|
||||
freeBelowBytes?: number;
|
||||
// Whether an original is pinned and so never evicted (favorites + latest
|
||||
// week; the precache unit #48 supplies the set).
|
||||
// An extra pinned predicate OR-ed with the precache's own pinned set
|
||||
// (favorites + latest week, #48). Pinned originals are never evicted.
|
||||
isOriginalPinned?: (fileID: number) => boolean;
|
||||
// The aggressive local precache (#48), all starting inside `open()` with no
|
||||
// caller input. Thumbnails: every file, newest first, until all are on
|
||||
// disk. Originals: the favorites album then the latest `precacheOriginalsDays`
|
||||
// window (the days ending at the newest file). Both default on; the days
|
||||
// default to 7.
|
||||
precacheThumbnails?: boolean;
|
||||
precacheOriginals?: boolean;
|
||||
precacheOriginalsDays?: number;
|
||||
}
|
||||
|
||||
export interface LibraryStatus {
|
||||
@@ -175,6 +207,13 @@ export interface LibraryStatus {
|
||||
// last write or open; undefined when no content cache is open.
|
||||
originalsUsedBytes?: number;
|
||||
originalsLimitBytes?: number;
|
||||
// Precache progress (#48); undefined when no content cache is open. Totals
|
||||
// are the files targeted (0 when a fill is disabled); "cached" is how many
|
||||
// of them are on disk.
|
||||
thumbnailsCached?: number;
|
||||
thumbnailsTotal?: number;
|
||||
originalsCached?: number;
|
||||
originalsPinned?: number;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
@@ -207,6 +246,8 @@ export class Library {
|
||||
private readonly pools: RequestPools;
|
||||
// The ML-data cache, present only when the client can fetch ML data.
|
||||
private readonly mlStore?: MLDataStore;
|
||||
// The local precache (#48), present only when the content cache is.
|
||||
private readonly precache?: Precache;
|
||||
|
||||
private timer?: ReturnType<typeof setTimeout>;
|
||||
private refreshing = false;
|
||||
@@ -240,6 +281,7 @@ export class Library {
|
||||
pools: RequestPools;
|
||||
mldata?: MLDataStore;
|
||||
cache?: ContentCache;
|
||||
precache?: Precache;
|
||||
}) {
|
||||
this.client = args.client;
|
||||
this.store = args.store;
|
||||
@@ -251,6 +293,7 @@ export class Library {
|
||||
this.pools = args.pools;
|
||||
this.mlStore = args.mldata;
|
||||
this.cache = args.cache;
|
||||
this.precache = args.precache;
|
||||
this.lastRecords = this.deriveNow();
|
||||
|
||||
// The read namespaces derive fresh from the store on each call, so they
|
||||
@@ -310,7 +353,21 @@ export class Library {
|
||||
// the start and the first refresh raises no spurious path-change diff.
|
||||
const source = opts.contentSource ?? opts.client.contentSource?.();
|
||||
let cache: ContentCache | undefined;
|
||||
let precache: Precache | undefined;
|
||||
if (source) {
|
||||
// The precache owns the pinned set (favorites + latest week), which
|
||||
// is the cache's eviction predicate. It is built and seeded from
|
||||
// the loaded store first so the cache can wire `isPinned` to it, and
|
||||
// then bound to the cache it fills. A caller-supplied predicate is
|
||||
// OR-ed in so both survive.
|
||||
precache = new Precache({
|
||||
thumbnails: opts.precacheThumbnails,
|
||||
originals: opts.precacheOriginals,
|
||||
originalsDays: opts.precacheOriginalsDays,
|
||||
onEvent: opts.onProgress,
|
||||
});
|
||||
precache.update(deriveRecordsFromStore(store));
|
||||
const extraPinned = opts.isOriginalPinned;
|
||||
cache = new ContentCache({
|
||||
pools,
|
||||
source,
|
||||
@@ -319,9 +376,12 @@ export class Library {
|
||||
getFile: (fileID) => store.getFileByID(fileID),
|
||||
cacheOriginalsMaxBytes: opts.cacheOriginalsMaxBytes,
|
||||
freeBelowBytes: opts.freeBelowBytes,
|
||||
isPinned: opts.isOriginalPinned,
|
||||
isPinned: (fileID) =>
|
||||
precache!.isPinned(fileID) ||
|
||||
(extraPinned?.(fileID) ?? false),
|
||||
});
|
||||
await cache.open();
|
||||
precache.bind(cache);
|
||||
}
|
||||
|
||||
const lib = new Library({
|
||||
@@ -335,8 +395,15 @@ export class Library {
|
||||
pools,
|
||||
mldata,
|
||||
cache,
|
||||
precache,
|
||||
});
|
||||
|
||||
// Start filling from whatever the loaded store already holds; each
|
||||
// refresh below re-kicks with the new files (and retries any that
|
||||
// failed). An empty store starts empty here and fills after its first
|
||||
// refresh.
|
||||
precache?.start();
|
||||
|
||||
if (store.loadedFromDisk) {
|
||||
// An existing copy already answers reads; refresh in the background
|
||||
// and start the interval once that first cycle settles.
|
||||
@@ -396,6 +463,7 @@ export class Library {
|
||||
}
|
||||
const ml = this.mlStore?.stats();
|
||||
const originals = this.cache?.originalsStatus();
|
||||
const pre = this.precache?.status();
|
||||
return {
|
||||
userID: this.store.userID,
|
||||
collections: collections.length,
|
||||
@@ -408,6 +476,10 @@ export class Library {
|
||||
mlIndexed: ml?.indexed,
|
||||
originalsUsedBytes: originals?.usedBytes,
|
||||
originalsLimitBytes: originals?.limitBytes,
|
||||
thumbnailsCached: pre?.thumbnailsCached,
|
||||
thumbnailsTotal: pre?.thumbnailsTotal,
|
||||
originalsCached: pre?.originalsCached,
|
||||
originalsPinned: pre?.originalsPinned,
|
||||
closed: this.closed,
|
||||
};
|
||||
}
|
||||
@@ -455,6 +527,7 @@ export class Library {
|
||||
// finish; it will not schedule another cycle once closed.
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
this.precache?.close();
|
||||
if (this.timer !== undefined) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
@@ -572,7 +645,12 @@ export class Library {
|
||||
if (change) this.notify(change);
|
||||
}
|
||||
this.lastRecords = next;
|
||||
// Recompute the fill orders and pinned set against the new library.
|
||||
this.precache?.update(next);
|
||||
}
|
||||
// Re-kick the fills every cycle: a finished sweep starts afresh to pick
|
||||
// up new files and retry any that failed, and a running one is left be.
|
||||
this.precache?.start();
|
||||
|
||||
// Persist whenever RAM holds changes disk has not accepted — including
|
||||
// changes an earlier cycle staged whose save failed. `unsaved` clears
|
||||
@@ -668,15 +746,7 @@ export class Library {
|
||||
// Gather every file membership and project the store into by-id records,
|
||||
// filling each record's cache paths from the content cache when present.
|
||||
private deriveNow(): DerivedRecords {
|
||||
const collections = this.store.listCollections();
|
||||
const files: EnteFile[] = [];
|
||||
for (const c of collections) files.push(...this.store.listFiles(c.id));
|
||||
const cache = this.cache;
|
||||
return deriveRecords(
|
||||
collections,
|
||||
files,
|
||||
cache ? (fileID) => cache.pathsFor(fileID) : undefined,
|
||||
);
|
||||
return deriveRecordsFromStore(this.store, this.cache);
|
||||
}
|
||||
|
||||
private notify(change: LibraryChange): void {
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
// The aggressive local precache (issue #48), started from `Library.open` with
|
||||
// no caller input.
|
||||
//
|
||||
// Two background fills run concurrently through the shared request pools (#45):
|
||||
//
|
||||
// - Thumbnails: every file in the account, newest first, through the
|
||||
// thumbnail pool until all are on disk. Never evicted. The pool is the same
|
||||
// one `thumbnails.ensure` uses, so a visible or ahead request always jumps
|
||||
// ahead of this background fill and a fileID both want is fetched once.
|
||||
//
|
||||
// - Originals (the pinned set): through the content pool, the favorites album
|
||||
// first, then every file whose `takenAt` falls in the latest
|
||||
// `originalsDays` window — the days ending at the newest file in the
|
||||
// account. The pinned set is the eviction predicate (#47): a pinned
|
||||
// original is never evicted, and a file that leaves the set (a favorite
|
||||
// removed, or the window moving past it on a later refresh) becomes an
|
||||
// ordinary, evictable original with its bytes left in place.
|
||||
//
|
||||
// Both fills yield to on-demand work: every fetch goes to its pool at
|
||||
// background priority, which the pool serves only after on-demand requests. A
|
||||
// file already cached costs one map lookup (`pathsFor`) and no fetch. Each
|
||||
// sweep is driven in bounded chunks so the pool's waiting queue never grows to
|
||||
// the whole account, keeping on-demand preemption and per-admit cost cheap on a
|
||||
// large library. Failures are not fatal: an uncached file is retried on the
|
||||
// next sweep, which `Library` re-kicks after every refresh.
|
||||
|
||||
import type { EnsureResult } from "./content.js";
|
||||
import type { DerivedRecords } from "./records.js";
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export const DEFAULT_PRECACHE_ORIGINALS_DAYS = 7;
|
||||
|
||||
// How many files a sweep submits to a pool before awaiting them. Bounds the
|
||||
// pool's waiting queue so on-demand work is never stuck behind the whole
|
||||
// account; the values track each pool's concurrency (#45).
|
||||
const THUMBNAIL_CHUNK = 25;
|
||||
const ORIGINAL_CHUNK = 5;
|
||||
|
||||
// The precache metrics `Library.status()` surfaces.
|
||||
export interface PrecacheStatus {
|
||||
thumbnailsCached: number;
|
||||
thumbnailsTotal: number;
|
||||
originalsCached: number;
|
||||
originalsPinned: number;
|
||||
}
|
||||
|
||||
// A background-fill progress event. Each active sweep fires "started" before
|
||||
// its fetches and "done" (with the count newly on disk) after; a sweep with
|
||||
// nothing left to fetch is silent.
|
||||
export interface PrecacheEvent {
|
||||
operation: "precacheThumbnails" | "precacheOriginals";
|
||||
status: "started" | "done";
|
||||
fetched?: number;
|
||||
}
|
||||
|
||||
// The slice of the content cache the precache drives. The real `ContentCache`
|
||||
// satisfies it; tests inject a fake.
|
||||
export interface PrecacheCache {
|
||||
pathsFor(fileID: number): { originalPath?: string; thumbnailPath?: string };
|
||||
ensureThumbnails(args: {
|
||||
fileIDs: number[];
|
||||
priority: "background";
|
||||
signal?: AbortSignal;
|
||||
}): Promise<EnsureResult[]>;
|
||||
ensureOriginals(args: {
|
||||
fileIDs: number[];
|
||||
signal?: AbortSignal;
|
||||
}): Promise<EnsureResult[]>;
|
||||
}
|
||||
|
||||
export interface PrecacheOptions {
|
||||
// Default true; false disables the fill and, for originals, the pinning.
|
||||
thumbnails?: boolean;
|
||||
originals?: boolean;
|
||||
// The latest-week window length in days; default 7.
|
||||
originalsDays?: number;
|
||||
onEvent?: (event: PrecacheEvent) => void;
|
||||
}
|
||||
|
||||
export class Precache {
|
||||
private readonly doThumbnails: boolean;
|
||||
private readonly doOriginals: boolean;
|
||||
private readonly originalsDays: number;
|
||||
private readonly onEvent?: (event: PrecacheEvent) => void;
|
||||
|
||||
private cache?: PrecacheCache;
|
||||
// Every file in the account, newest first (the thumbnail fill order).
|
||||
private thumbOrder: number[] = [];
|
||||
// The pinned originals in fetch order: favorites first, then the window.
|
||||
private originalsOrder: number[] = [];
|
||||
private pinned = new Set<number>();
|
||||
|
||||
// A sweep runs at most once per fill at a time; a re-kick while one runs is
|
||||
// a no-op, and the next refresh re-kicks after it finishes.
|
||||
private thumbRunning = false;
|
||||
private originalsRunning = false;
|
||||
private readonly aborter = new AbortController();
|
||||
private closed = false;
|
||||
|
||||
constructor(opts: PrecacheOptions = {}) {
|
||||
this.doThumbnails = opts.thumbnails ?? true;
|
||||
this.doOriginals = opts.originals ?? true;
|
||||
this.originalsDays =
|
||||
opts.originalsDays ?? DEFAULT_PRECACHE_ORIGINALS_DAYS;
|
||||
this.onEvent = opts.onEvent;
|
||||
}
|
||||
|
||||
// Attach the cache the fills fetch through. `isPinned` works before this is
|
||||
// called, so the cache can be constructed with `isPinned` wired in and then
|
||||
// bound here.
|
||||
bind(cache: PrecacheCache): void {
|
||||
this.cache = cache;
|
||||
}
|
||||
|
||||
// Whether an original is pinned (favorites + the latest-week window), the
|
||||
// eviction predicate (#47). False for every file when originals precaching
|
||||
// is disabled.
|
||||
isPinned(fileID: number): boolean {
|
||||
return this.pinned.has(fileID);
|
||||
}
|
||||
|
||||
// Recompute the fill orders and the pinned set from the current projection.
|
||||
// Called at open and after every refresh that changes the library.
|
||||
update(records: DerivedRecords): void {
|
||||
const photos = [...records.photos.values()].sort(
|
||||
(a, b) => b.takenAt - a.takenAt || b.fileID - a.fileID,
|
||||
);
|
||||
|
||||
this.thumbOrder = this.doThumbnails ? photos.map((p) => p.fileID) : [];
|
||||
|
||||
const pinned = new Set<number>();
|
||||
const order: number[] = [];
|
||||
if (this.doOriginals) {
|
||||
// Favorites first, in the album's own newest-first order.
|
||||
for (const album of records.albums.values()) {
|
||||
if (album.type !== "favorites") continue;
|
||||
for (const id of album.fileIDs) {
|
||||
if (!pinned.has(id)) {
|
||||
pinned.add(id);
|
||||
order.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Then the latest-week window, ending at the newest file. Photos
|
||||
// are newest first, so stop once one falls before the window start.
|
||||
const newest = photos[0];
|
||||
if (newest !== undefined) {
|
||||
const windowStart =
|
||||
newest.takenAt - this.originalsDays * ONE_DAY_MS;
|
||||
for (const p of photos) {
|
||||
if (p.takenAt < windowStart) break;
|
||||
if (!pinned.has(p.fileID)) {
|
||||
pinned.add(p.fileID);
|
||||
order.push(p.fileID);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.pinned = pinned;
|
||||
this.originalsOrder = order;
|
||||
}
|
||||
|
||||
status(): PrecacheStatus {
|
||||
const cache = this.cache;
|
||||
let thumbnailsCached = 0;
|
||||
let originalsCached = 0;
|
||||
if (cache) {
|
||||
for (const id of this.thumbOrder)
|
||||
if (cache.pathsFor(id).thumbnailPath !== undefined)
|
||||
thumbnailsCached++;
|
||||
for (const id of this.originalsOrder)
|
||||
if (cache.pathsFor(id).originalPath !== undefined)
|
||||
originalsCached++;
|
||||
}
|
||||
return {
|
||||
thumbnailsCached,
|
||||
thumbnailsTotal: this.thumbOrder.length,
|
||||
originalsCached,
|
||||
originalsPinned: this.originalsOrder.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Kick both fills. Idempotent: a fill already sweeping is left alone. Safe
|
||||
// to call after every refresh; a finished fill starts a fresh sweep that
|
||||
// picks up new files and retries any that failed before.
|
||||
start(): void {
|
||||
if (this.closed || !this.cache) return;
|
||||
if (this.doThumbnails) this.kickThumbnails();
|
||||
if (this.doOriginals) this.kickOriginals();
|
||||
}
|
||||
|
||||
// Stop the fills. In-flight fetches are left to settle; queued ones drop.
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
this.aborter.abort();
|
||||
}
|
||||
|
||||
private kickThumbnails(): void {
|
||||
if (this.thumbRunning) return;
|
||||
this.thumbRunning = true;
|
||||
void this.sweep(
|
||||
"precacheThumbnails",
|
||||
() => this.thumbOrder,
|
||||
(id) => this.cache!.pathsFor(id).thumbnailPath !== undefined,
|
||||
THUMBNAIL_CHUNK,
|
||||
(chunk) =>
|
||||
this.cache!.ensureThumbnails({
|
||||
fileIDs: chunk,
|
||||
priority: "background",
|
||||
signal: this.aborter.signal,
|
||||
}),
|
||||
).finally(() => {
|
||||
this.thumbRunning = false;
|
||||
});
|
||||
}
|
||||
|
||||
private kickOriginals(): void {
|
||||
if (this.originalsRunning) return;
|
||||
this.originalsRunning = true;
|
||||
void this.sweep(
|
||||
"precacheOriginals",
|
||||
() => this.originalsOrder,
|
||||
(id) => this.cache!.pathsFor(id).originalPath !== undefined,
|
||||
ORIGINAL_CHUNK,
|
||||
(chunk) =>
|
||||
this.cache!.ensureOriginals({
|
||||
fileIDs: chunk,
|
||||
signal: this.aborter.signal,
|
||||
}),
|
||||
).finally(() => {
|
||||
this.originalsRunning = false;
|
||||
});
|
||||
}
|
||||
|
||||
// One fill sweep: skip files already on disk (one lookup each), fetch the
|
||||
// rest in bounded chunks, and report progress only when there was work.
|
||||
private async sweep(
|
||||
operation: PrecacheEvent["operation"],
|
||||
order: () => number[],
|
||||
present: (fileID: number) => boolean,
|
||||
chunkSize: number,
|
||||
fetch: (chunk: number[]) => Promise<EnsureResult[]>,
|
||||
): Promise<void> {
|
||||
const todo = order().filter((id) => !present(id));
|
||||
if (todo.length === 0) return;
|
||||
this.emit({ operation, status: "started" });
|
||||
let fetched = 0;
|
||||
for (let i = 0; i < todo.length && !this.closed; i += chunkSize) {
|
||||
const results = await fetch(todo.slice(i, i + chunkSize));
|
||||
for (const r of results) if (r.path !== undefined) fetched++;
|
||||
}
|
||||
this.emit({ operation, status: "done", fetched });
|
||||
}
|
||||
|
||||
private emit(event: PrecacheEvent): void {
|
||||
if (!this.onEvent) return;
|
||||
// A misbehaving callback must not break the fill loop.
|
||||
try {
|
||||
this.onEvent(event);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user