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,
|
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;
|
let netFailStreak = 0;
|
||||||
for (const file of uniqueFiles) {
|
let aborted = false;
|
||||||
progress.current++;
|
|
||||||
progress.file = file;
|
|
||||||
|
|
||||||
|
// 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}`;
|
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;
|
let notFound = false;
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
if (attempt > 0) await new Promise(r => setTimeout(r, 1000));
|
if (attempt > 0) await new Promise(r => setTimeout(r, 1000));
|
||||||
@@ -262,37 +265,61 @@ export class OfflineManager {
|
|||||||
} else {
|
} else {
|
||||||
data = await resp.json();
|
data = await resp.json();
|
||||||
}
|
}
|
||||||
const stored = await DataCache.set(`url:${file}`, data);
|
const stored = await DataCache.set(cacheKey, data);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
success = true;
|
return 'ok';
|
||||||
} else {
|
} else {
|
||||||
// IDB write failed (e.g. quota exceeded) — the cache is not
|
// IDB write failed (e.g. quota exceeded) — the cache is not
|
||||||
// healthy; count as failure so the circuit breaker can abort
|
// healthy; count as failure so the circuit breaker can abort
|
||||||
// instead of reporting a misleading "done".
|
// instead of reporting a misleading "done".
|
||||||
diagLog('preload-write-fail', { file });
|
diagLog('preload-write-fail', { file });
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
} catch {
|
} catch {
|
||||||
// Retry on network error
|
// Retry on network error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!success && !notFound) {
|
return notFound ? 'notFound' : 'fail';
|
||||||
progress.failed++;
|
};
|
||||||
netFailStreak++;
|
|
||||||
if (!progress.failedFiles) progress.failedFiles = [];
|
const queue = [...uniqueFiles];
|
||||||
if (progress.failedFiles.length < 10) progress.failedFiles.push(file);
|
let nextIndex = 0;
|
||||||
if (netFailStreak >= 5) {
|
|
||||||
// Circuit breaker — network appears dead; abort instead of grinding
|
const worker = async (): Promise<void> => {
|
||||||
progress.aborted = true;
|
while (true) {
|
||||||
diagLog('preload-abort', { failed: progress.failed, current: progress.current });
|
if (aborted) return;
|
||||||
break;
|
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) {
|
if ((progress.missing ?? 0) > 0) {
|
||||||
diagLog('preload-missing', { count: progress.missing });
|
diagLog('preload-missing', { count: progress.missing });
|
||||||
|
|||||||
@@ -24,22 +24,6 @@ export default defineConfig({
|
|||||||
networkTimeoutSeconds: 3,
|
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?$/,
|
urlPattern: /\/fonts\/.*\.woff2?$/,
|
||||||
handler: 'CacheFirst',
|
handler: 'CacheFirst',
|
||||||
|
|||||||
Reference in new issue
Block a user