feat: precache resilience — circuit breaker, forensic diag log, safe version check
- Circuit breaker: abort preload after 5 consecutive network failures (success or 404 resets streak) instead of grinding doomed retries - localStorage diag ring buffer (survives cache wipes): preload-start/ done/abort, manifest-unavailable, version events; shown in Settings - checkDataVersion: no longer wipes IDB on transient read errors (DataCache.has tri-state probe); logs old value on real migrations - loadManifest: null result no longer cached forever (per-session fallback lock-in)
This commit is contained in:
1 parent
ee5fb17c6b
commit
8c4f60d635
6 files changed
+140
-7
No files matched your search
@@ -93,6 +93,24 @@ export class DataCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tri-state presence probe: true/false when known, null on read error.
|
||||||
|
* Unlike get(), lets callers distinguish "absent" from "unreadable".
|
||||||
|
*/
|
||||||
|
static async has(key: string): Promise<boolean | null> {
|
||||||
|
try {
|
||||||
|
const db = await getDB();
|
||||||
|
return await new Promise<boolean | null>((resolve) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||||
|
const request = tx.objectStore(STORE_NAME).count(IDBKeyRange.only(key));
|
||||||
|
request.onsuccess = () => resolve(request.result > 0);
|
||||||
|
request.onerror = () => resolve(null);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store a value in the cache.
|
* Store a value in the cache.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Forensic event log — append-only ring buffer in localStorage.
|
||||||
|
*
|
||||||
|
* Deliberately stored OUTSIDE IndexedDB / CacheStorage so it survives
|
||||||
|
* DataCache.clear(), service-worker cache purges and version wipes.
|
||||||
|
* Used to diagnose transient offline/precache issues after the fact.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DIAG_KEY = 'ponomar-diag-log';
|
||||||
|
const MAX_EVENTS = 30;
|
||||||
|
|
||||||
|
export interface DiagEvent {
|
||||||
|
t: string;
|
||||||
|
event: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function diagLog(event: string, detail?: Record<string, unknown>) {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(DIAG_KEY);
|
||||||
|
const arr: DiagEvent[] = raw ? JSON.parse(raw) : [];
|
||||||
|
arr.push({ t: new Date().toISOString(), event, ...(detail ?? {}) });
|
||||||
|
while (arr.length > MAX_EVENTS) arr.shift();
|
||||||
|
localStorage.setItem(DIAG_KEY, JSON.stringify(arr));
|
||||||
|
} catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDiagLog(): DiagEvent[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(DIAG_KEY);
|
||||||
|
const arr = raw ? JSON.parse(raw) : [];
|
||||||
|
return Array.isArray(arr) ? arr : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEvent(e: DiagEvent): string {
|
||||||
|
const time = (e.t || '').slice(11, 19);
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const [k, v] of Object.entries(e)) {
|
||||||
|
if (k === 't' || k === 'event') continue;
|
||||||
|
parts.push(`${k}=${String(v)}`);
|
||||||
|
}
|
||||||
|
return `${time} ${e.event}${parts.length ? ' ' + parts.join(' ') : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function diagTail(): string {
|
||||||
|
return getDiagLog().map(formatEvent).join('\n');
|
||||||
|
}
|
||||||
@@ -515,6 +515,7 @@ export interface Translations {
|
|||||||
offlineMenaion: string;
|
offlineMenaion: string;
|
||||||
offlineBible: string;
|
offlineBible: string;
|
||||||
offlinePreload: string;
|
offlinePreload: string;
|
||||||
|
offlineAborted: string;
|
||||||
offlineClearCache: string;
|
offlineClearCache: string;
|
||||||
offlineCalculating: string;
|
offlineCalculating: string;
|
||||||
offlineDone: string;
|
offlineDone: string;
|
||||||
@@ -1084,6 +1085,7 @@ const translations: Record<LanguageCode, Translations> = {
|
|||||||
offlineMenaion: 'Menaion',
|
offlineMenaion: 'Menaion',
|
||||||
offlineBible: 'Bible',
|
offlineBible: 'Bible',
|
||||||
offlinePreload: 'Preload',
|
offlinePreload: 'Preload',
|
||||||
|
offlineAborted: 'Preloading stopped: network unavailable. Check your connection and try again.',
|
||||||
offlineClearCache: 'Clear cache',
|
offlineClearCache: 'Clear cache',
|
||||||
offlineCalculating: 'Calculating storage...',
|
offlineCalculating: 'Calculating storage...',
|
||||||
offlineDone: 'Done! Data cached for offline use.',
|
offlineDone: 'Done! Data cached for offline use.',
|
||||||
@@ -1651,6 +1653,7 @@ const translations: Record<LanguageCode, Translations> = {
|
|||||||
offlineMenaion: 'Минея',
|
offlineMenaion: 'Минея',
|
||||||
offlineBible: 'Библия',
|
offlineBible: 'Библия',
|
||||||
offlinePreload: 'Загрузить',
|
offlinePreload: 'Загрузить',
|
||||||
|
offlineAborted: 'Загрузка прервана: сеть недоступна. Проверьте соединение и повторите попытку.',
|
||||||
offlineClearCache: 'Очистить кэш',
|
offlineClearCache: 'Очистить кэш',
|
||||||
offlineCalculating: 'Расчёт размера...',
|
offlineCalculating: 'Расчёт размера...',
|
||||||
offlineDone: 'Готово! Данные закэшированы для офлайн-использования.',
|
offlineDone: 'Готово! Данные закэшированы для офлайн-использования.',
|
||||||
@@ -2218,6 +2221,7 @@ const translations: Record<LanguageCode, Translations> = {
|
|||||||
offlineMenaion: 'Минїа',
|
offlineMenaion: 'Минїа',
|
||||||
offlineBible: 'Би́блїа',
|
offlineBible: 'Би́блїа',
|
||||||
offlinePreload: 'Загрузити',
|
offlinePreload: 'Загрузити',
|
||||||
|
offlineAborted: 'Загрузка прервана: сеть недостꙋпна. Про̀верьте соедине́нiе и҆ повтори́те попы́ткꙋ.',
|
||||||
offlineClearCache: 'Очистити кешъ',
|
offlineClearCache: 'Очистити кешъ',
|
||||||
offlineCalculating: 'Расчётъ размѣра...',
|
offlineCalculating: 'Расчётъ размѣра...',
|
||||||
offlineDone: 'Готово! Данныя закешированы для офлайн-потребленїя.',
|
offlineDone: 'Готово! Данныя закешированы для офлайн-потребленїя.',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { DataCache } from './data-cache';
|
import { DataCache } from './data-cache';
|
||||||
|
import { diagLog } from './diag-log';
|
||||||
|
|
||||||
export interface PreloadOptions {
|
export interface PreloadOptions {
|
||||||
languages: string[];
|
languages: string[];
|
||||||
@@ -29,6 +30,7 @@ export interface PreloadProgress {
|
|||||||
done: boolean;
|
done: boolean;
|
||||||
failed: number;
|
failed: number;
|
||||||
failedFiles?: string[];
|
failedFiles?: string[];
|
||||||
|
aborted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CacheStats {
|
export interface CacheStats {
|
||||||
@@ -60,7 +62,14 @@ function loadManifest(): Promise<Record<string, Record<string, string[]>> | null
|
|||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
})();
|
})().then((m) => {
|
||||||
|
// Don't cache a failure forever — allow retry on next call
|
||||||
|
if (m === null) {
|
||||||
|
diagLog('manifest-unavailable');
|
||||||
|
manifestPromise = null;
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return manifestPromise;
|
return manifestPromise;
|
||||||
}
|
}
|
||||||
@@ -112,6 +121,7 @@ export class OfflineManager {
|
|||||||
* Returns a PreloadProgress object that can be polled for progress.
|
* Returns a PreloadProgress object that can be polled for progress.
|
||||||
*/
|
*/
|
||||||
static async preload(options: PreloadOptions): Promise<PreloadProgress> {
|
static async preload(options: PreloadOptions): Promise<PreloadProgress> {
|
||||||
|
const startedAt = Date.now();
|
||||||
const filesToFetch: string[] = [];
|
const filesToFetch: string[] = [];
|
||||||
const m = await loadManifest();
|
const m = await loadManifest();
|
||||||
|
|
||||||
@@ -149,7 +159,16 @@ export class OfflineManager {
|
|||||||
};
|
};
|
||||||
OfflineManager._progress = progress;
|
OfflineManager._progress = progress;
|
||||||
|
|
||||||
|
diagLog('preload-start', {
|
||||||
|
langs: options.languages.join(','),
|
||||||
|
types: options.types.join(','),
|
||||||
|
bibles: (options.bibleTranslations || []).length,
|
||||||
|
total: progress.total,
|
||||||
|
manifest: m !== null,
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch files sequentially to avoid overwhelming the network
|
// Fetch files sequentially to avoid overwhelming the network
|
||||||
|
let netFailStreak = 0;
|
||||||
for (const file of uniqueFiles) {
|
for (const file of uniqueFiles) {
|
||||||
progress.current++;
|
progress.current++;
|
||||||
progress.file = file;
|
progress.file = file;
|
||||||
@@ -186,12 +205,28 @@ export class OfflineManager {
|
|||||||
}
|
}
|
||||||
if (!success && !notFound) {
|
if (!success && !notFound) {
|
||||||
progress.failed++;
|
progress.failed++;
|
||||||
|
netFailStreak++;
|
||||||
if (!progress.failedFiles) progress.failedFiles = [];
|
if (!progress.failedFiles) progress.failedFiles = [];
|
||||||
if (progress.failedFiles.length < 10) progress.failedFiles.push(file);
|
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;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Success or 404 both prove connectivity
|
||||||
|
netFailStreak = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
progress.done = true;
|
progress.done = true;
|
||||||
|
diagLog('preload-done', {
|
||||||
|
total: progress.total,
|
||||||
|
failed: progress.failed,
|
||||||
|
ms: Date.now() - startedAt,
|
||||||
|
aborted: progress.aborted === true,
|
||||||
|
});
|
||||||
OfflineManager._progress = null;
|
OfflineManager._progress = null;
|
||||||
return progress;
|
return progress;
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-2
@@ -11,6 +11,7 @@ import { JDate } from '../core/jdate';
|
|||||||
import { loadSettings, type Settings } from './settings-view';
|
import { loadSettings, type Settings } from './settings-view';
|
||||||
import { getTranslations, type LanguageCode } from '../core/i18n';
|
import { getTranslations, type LanguageCode } from '../core/i18n';
|
||||||
import { DataCache } from '../core/data-cache';
|
import { DataCache } from '../core/data-cache';
|
||||||
|
import { diagLog } from '../core/diag-log';
|
||||||
|
|
||||||
type View = 'calendar' | 'bible' | 'prayer' | 'akathists' | 'parimii' | 'horologion' | 'sbornik' | 'paraclete' | 'irmologion' | 'menaion' | 'triodion' | 'lives' | 'settings';
|
type View = 'calendar' | 'bible' | 'prayer' | 'akathists' | 'parimii' | 'horologion' | 'sbornik' | 'paraclete' | 'irmologion' | 'menaion' | 'triodion' | 'lives' | 'settings';
|
||||||
|
|
||||||
@@ -75,13 +76,25 @@ export class App {
|
|||||||
private async checkDataVersion() {
|
private async checkDataVersion() {
|
||||||
const DATA_VERSION = '2026-08-04'; // bump when data structure changes
|
const DATA_VERSION = '2026-08-04'; // bump when data structure changes
|
||||||
try {
|
try {
|
||||||
|
const present = await DataCache.has('__data_version__');
|
||||||
|
if (present === null) {
|
||||||
|
// Unreadable IDB — never wipe the cache on a flaky read
|
||||||
|
diagLog('version-check', { result: 'read-error', action: 'skip' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!present) {
|
||||||
|
await DataCache.set('__data_version__', DATA_VERSION);
|
||||||
|
diagLog('version-init');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const cachedVer = await DataCache.get('__data_version__') as string | null;
|
const cachedVer = await DataCache.get('__data_version__') as string | null;
|
||||||
if (cachedVer !== DATA_VERSION) {
|
if (cachedVer !== DATA_VERSION) {
|
||||||
await DataCache.clear();
|
await DataCache.clear();
|
||||||
await DataCache.set('__data_version__', DATA_VERSION);
|
await DataCache.set('__data_version__', DATA_VERSION);
|
||||||
|
diagLog('version-clear', { old: cachedVer });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Silently fail — cache is best-effort
|
diagLog('idb-error', { where: 'checkDataVersion', msg: String(err) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-4
@@ -5,6 +5,7 @@
|
|||||||
import { getTranslations, type LanguageCode } from '../core/i18n';
|
import { getTranslations, type LanguageCode } from '../core/i18n';
|
||||||
import { OfflineManager } from '../core/offline-manager';
|
import { OfflineManager } from '../core/offline-manager';
|
||||||
import { DataCache } from '../core/data-cache';
|
import { DataCache } from '../core/data-cache';
|
||||||
|
import { diagTail } from '../core/diag-log';
|
||||||
|
|
||||||
const LANGUAGES = [
|
const LANGUAGES = [
|
||||||
{ code: 'en', name: 'English', local: 'English' },
|
{ code: 'en', name: 'English', local: 'English' },
|
||||||
@@ -303,6 +304,7 @@ export class SettingsView {
|
|||||||
</div>
|
</div>
|
||||||
<p id="offline-progress-text" class="text-xs text-navy-light">0 / 0 ${t.settings.offlineDataTypes}</p>
|
<p id="offline-progress-text" class="text-xs text-navy-light">0 / 0 ${t.settings.offlineDataTypes}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<pre id="diag-log" class="text-[10px] font-mono text-navy-light whitespace-pre-wrap max-h-32 overflow-auto mt-1"></pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- About -->
|
<!-- About -->
|
||||||
@@ -552,13 +554,17 @@ export class SettingsView {
|
|||||||
}).then((prog) => {
|
}).then((prog) => {
|
||||||
if (progressBar) progressBar.style.width = '100%';
|
if (progressBar) progressBar.style.width = '100%';
|
||||||
if (progressText) {
|
if (progressText) {
|
||||||
if (prog.failed > 0) {
|
if (prog.aborted) {
|
||||||
|
progressText.textContent = t.settings.offlineAborted;
|
||||||
|
} else if (prog.failed > 0) {
|
||||||
const failList = prog.failedFiles?.length ? `: ${prog.failedFiles.join(', ')}` : '';
|
const failList = prog.failedFiles?.length ? `: ${prog.failedFiles.join(', ')}` : '';
|
||||||
progressText.textContent = `${t.settings.offlineDone} ${prog.failed} ${t.settings.offlineFailed}${failList}`;
|
progressText.textContent = `${t.settings.offlineDone} ${prog.failed} ${t.settings.offlineFailed}${failList}`;
|
||||||
} else {
|
} else {
|
||||||
progressText.textContent = t.settings.offlineDone;
|
progressText.textContent = t.settings.offlineDone;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const diagEl = document.getElementById('diag-log');
|
||||||
|
if (diagEl) diagEl.textContent = diagTail();
|
||||||
OfflineManager.getStats().then(stats => {
|
OfflineManager.getStats().then(stats => {
|
||||||
const statsEl = document.getElementById('offline-stats');
|
const statsEl = document.getElementById('offline-stats');
|
||||||
if (statsEl) {
|
if (statsEl) {
|
||||||
@@ -601,6 +607,9 @@ export class SettingsView {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const diagEl = document.getElementById('diag-log');
|
||||||
|
if (diagEl) diagEl.textContent = diagTail();
|
||||||
|
|
||||||
// Check cache status for each data type and set checkbox states
|
// Check cache status for each data type and set checkbox states
|
||||||
(async () => {
|
(async () => {
|
||||||
const checkCache = async (key: string): Promise<boolean> => {
|
const checkCache = async (key: string): Promise<boolean> => {
|
||||||
@@ -678,10 +687,14 @@ export class SettingsView {
|
|||||||
if (progressBar) progressBar.style.width = '100%';
|
if (progressBar) progressBar.style.width = '100%';
|
||||||
if (progressText) {
|
if (progressText) {
|
||||||
const failList = prog.failedFiles?.length ? `: ${prog.failedFiles.join(', ')}` : '';
|
const failList = prog.failedFiles?.length ? `: ${prog.failedFiles.join(', ')}` : '';
|
||||||
progressText.textContent = prog.failed > 0
|
progressText.textContent = prog.aborted
|
||||||
? `${t.settings.offlineDone} ${prog.failed} ${t.settings.offlineFailed}${failList}`
|
? t.settings.offlineAborted
|
||||||
: t.settings.offlineDone;
|
: prog.failed > 0
|
||||||
|
? `${t.settings.offlineDone} ${prog.failed} ${t.settings.offlineFailed}${failList}`
|
||||||
|
: t.settings.offlineDone;
|
||||||
}
|
}
|
||||||
|
const diagEl = document.getElementById('diag-log');
|
||||||
|
if (diagEl) diagEl.textContent = diagTail();
|
||||||
OfflineManager.getStats().then(stats => {
|
OfflineManager.getStats().then(stats => {
|
||||||
const statsEl = document.getElementById('offline-stats');
|
const statsEl = document.getElementById('offline-stats');
|
||||||
if (statsEl) {
|
if (statsEl) {
|
||||||
|
|||||||
Reference in new issue
Block a user