perf(preload): bounded concurrency + drop redundant SW data cache
Fetch preload files with 6 workers in flight instead of one-at-a-time, preserving retry/404/circuit-breaker/IDB-write-failure semantics. Browsers already cap ~6 connections per origin, so this is safe and cuts a full 3431-file preload to roughly 1/5 the wall-clock time. Remove the StaleWhileRevalidate routes for /data/*.json and /data/*.text. IndexedDB is the canonical offline store (every data read falls back to it), so the SW data caches only thrashed (200 entries over 3431 files) and added a caches.match/cache.put to every request. Keep NetworkFirst for the manifest and CacheFirst for fonts.
This commit is contained in:
1 parent
baf2baf65d
commit
f3810fa8ef
2 files changed
+51
-40
No files matched your search
+51
-24
@@ -231,16 +231,19 @@ export class OfflineManager {
|
||||
manifest: m !== null,
|
||||
});
|
||||
|
||||
// Fetch files sequentially to avoid overwhelming the network
|
||||
// Fetch files with bounded concurrency (6 in flight). Browsers already cap
|
||||
// ~6 connections per origin, so this parallelism is safe and cuts the
|
||||
// wall-clock time of a full preload dramatically versus one-at-a-time.
|
||||
const CONCURRENCY = 6;
|
||||
let netFailStreak = 0;
|
||||
for (const file of uniqueFiles) {
|
||||
progress.current++;
|
||||
progress.file = file;
|
||||
let aborted = false;
|
||||
|
||||
// Fetch (and cache) one file, returning its verdict. Already-cached files
|
||||
// resolve immediately without touching the network.
|
||||
const fetchOne = async (file: string): Promise<'ok' | 'notFound' | 'fail'> => {
|
||||
const cacheKey = `url:${file}`;
|
||||
if (await DataCache.get(cacheKey) !== null) continue;
|
||||
if (await DataCache.get(cacheKey) !== null) return 'ok';
|
||||
|
||||
let success = false;
|
||||
let notFound = false;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if (attempt > 0) await new Promise(r => setTimeout(r, 1000));
|
||||
@@ -262,37 +265,61 @@ export class OfflineManager {
|
||||
} else {
|
||||
data = await resp.json();
|
||||
}
|
||||
const stored = await DataCache.set(`url:${file}`, data);
|
||||
const stored = await DataCache.set(cacheKey, data);
|
||||
if (stored) {
|
||||
success = true;
|
||||
return 'ok';
|
||||
} else {
|
||||
// IDB write failed (e.g. quota exceeded) — the cache is not
|
||||
// healthy; count as failure so the circuit breaker can abort
|
||||
// instead of reporting a misleading "done".
|
||||
diagLog('preload-write-fail', { file });
|
||||
break;
|
||||
}
|
||||
break;
|
||||
} catch {
|
||||
// Retry on network error
|
||||
}
|
||||
}
|
||||
if (!success && !notFound) {
|
||||
progress.failed++;
|
||||
netFailStreak++;
|
||||
if (!progress.failedFiles) progress.failedFiles = [];
|
||||
if (progress.failedFiles.length < 10) progress.failedFiles.push(file);
|
||||
if (netFailStreak >= 5) {
|
||||
// Circuit breaker — network appears dead; abort instead of grinding
|
||||
progress.aborted = true;
|
||||
diagLog('preload-abort', { failed: progress.failed, current: progress.current });
|
||||
break;
|
||||
return notFound ? 'notFound' : 'fail';
|
||||
};
|
||||
|
||||
const queue = [...uniqueFiles];
|
||||
let nextIndex = 0;
|
||||
|
||||
const worker = async (): Promise<void> => {
|
||||
while (true) {
|
||||
if (aborted) return;
|
||||
const idx = nextIndex++;
|
||||
if (idx >= queue.length) return;
|
||||
const file = queue[idx];
|
||||
progress.file = file;
|
||||
|
||||
const result = await fetchOne(file);
|
||||
if (aborted) return; // a sibling tripped the breaker; stop counting
|
||||
progress.current++;
|
||||
|
||||
if (result === 'notFound') {
|
||||
progress.missing = (progress.missing ?? 0) + 1;
|
||||
netFailStreak = 0;
|
||||
} else if (result === 'ok') {
|
||||
netFailStreak = 0;
|
||||
} else {
|
||||
progress.failed++;
|
||||
netFailStreak++;
|
||||
if (!progress.failedFiles) progress.failedFiles = [];
|
||||
if (progress.failedFiles.length < 10) progress.failedFiles.push(file);
|
||||
if (netFailStreak >= 5) {
|
||||
// Circuit breaker — network appears dead; abort instead of grinding
|
||||
aborted = true;
|
||||
progress.aborted = true;
|
||||
diagLog('preload-abort', { failed: progress.failed, current: progress.current });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (notFound) progress.missing = (progress.missing ?? 0) + 1;
|
||||
// Success or 404 both prove connectivity
|
||||
netFailStreak = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(CONCURRENCY, queue.length) }, () => worker())
|
||||
);
|
||||
|
||||
if ((progress.missing ?? 0) > 0) {
|
||||
diagLog('preload-missing', { count: progress.missing });
|
||||
|
||||
@@ -24,22 +24,6 @@ export default defineConfig({
|
||||
networkTimeoutSeconds: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\/data\/.*\.json$/,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'ponomar-data',
|
||||
expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 },
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\/data\/.*\.text$/,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'ponomar-bible',
|
||||
expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 * 30 },
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\/fonts\/.*\.woff2?$/,
|
||||
handler: 'CacheFirst',
|
||||
|
||||
Reference in new issue
Block a user