Merge pull request 'Keep dashboard live when IndexedDB stalls' (#530) from timmy/529-indexeddb-startup-deadline into main
This commit is contained in:
commit
29ceedfc01
|
|
@ -417,7 +417,7 @@
|
|||
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
|
||||
outboxCoordinator.subscribe(() => refreshMyWorkView());
|
||||
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB });
|
||||
await offlineWorkStore.ready();
|
||||
let offlineStorageReady = await offlineWorkStore.ready();
|
||||
function renderOfflineTodayStatus(status) {
|
||||
const container = qs('#offline-today-readiness');
|
||||
const label = qs('#offline-today-status');
|
||||
|
|
@ -4735,12 +4735,16 @@
|
|||
const offlineStatus = qs('#offline-status');
|
||||
const keepWorkOffline = qs('#keep-work-offline');
|
||||
const offlineWorkStatus = qs('#offline-work-status');
|
||||
const retryOfflineStorage = qs('#retry-offline-storage');
|
||||
const deliveryReceipts = qs('#delivery-receipts');
|
||||
const deliveryReceiptStatus = qs('#delivery-receipt-status');
|
||||
async function updateOfflineWorkControls(message) {
|
||||
keepWorkOffline.checked = offlineWorkStore.enabled();
|
||||
keepWorkOffline.disabled = !offlineStorageReady;
|
||||
retryOfflineStorage.hidden = offlineStorageReady;
|
||||
const saved = await offlineWorkStore.load();
|
||||
offlineWorkStatus.textContent = message || (saved ? 'Saved ' + fmt(saved.saved_at) + ' · expires after 7 days.' :
|
||||
offlineWorkStatus.textContent = message || (!offlineStorageReady ?
|
||||
'Offline saving unavailable · online work remains live.' : saved ? 'Saved ' + fmt(saved.saved_at) + ' · expires after 7 days.' :
|
||||
(keepWorkOffline.checked ? 'Waiting for a healthy live refresh.' : 'Off · no work data is stored.'));
|
||||
}
|
||||
async function updateDeliveryReceiptControls(message) {
|
||||
|
|
@ -4848,6 +4852,18 @@
|
|||
renderOfflineTodayStatus({ total:0, ready:0, failed:0, pending:0 });
|
||||
await updateOfflineWorkControls('Offline work data cleared.');
|
||||
});
|
||||
retryOfflineStorage.addEventListener('click', async () => {
|
||||
retryOfflineStorage.disabled = true;
|
||||
offlineWorkStatus.textContent = 'Retrying offline saving…';
|
||||
offlineStorageReady = await offlineWorkStore.retry();
|
||||
retryOfflineStorage.disabled = false;
|
||||
if (offlineStorageReady && offlineWorkStore.enabled() && liveMode && lastContextSnapshot) {
|
||||
await offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications,
|
||||
notification_pagination:notificationPagination });
|
||||
}
|
||||
await updateOfflineWorkControls(offlineStorageReady ? 'Offline saving restored.' :
|
||||
'Offline saving unavailable · online work remains live.');
|
||||
});
|
||||
qs('#retry-offline-today').addEventListener('click', () =>
|
||||
offlineToday.retry(confirmedOwnerLogin, todayMyWork)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@
|
|||
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
|
||||
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
||||
<button id="clear-offline-work" type="button">Clear offline work data</button>
|
||||
<button id="retry-offline-storage" type="button" hidden>Retry offline saving</button>
|
||||
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
|
||||
<span class="offline-today-readiness" id="offline-today-readiness" hidden>
|
||||
<span class="small" id="offline-today-status" role="status" aria-live="polite"></span>
|
||||
|
|
|
|||
|
|
@ -85,12 +85,32 @@
|
|||
|
||||
function createOfflineWorkStore({
|
||||
storage, indexedDB, transaction, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS,
|
||||
maxDetails = 10,
|
||||
maxDetails = 10, initializationTimeoutMs = 1500,
|
||||
}) {
|
||||
const transact = transaction || createIndexedDbTransaction(indexedDB);
|
||||
let migrationPromise;
|
||||
let readinessPromise;
|
||||
let readinessGeneration = 0;
|
||||
let readinessState = transact ? { state:'initializing', reason:'' } : { state:'ready', reason:'' };
|
||||
let recordCache = null;
|
||||
function migrateLegacy() {
|
||||
let pendingDurableClear = false;
|
||||
|
||||
function hydrateLegacyCache() {
|
||||
const cache = new Map();
|
||||
try {
|
||||
const snapshot = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null');
|
||||
if (snapshot) cache.set('snapshot', { ...snapshot, id:'snapshot' });
|
||||
const details = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
|
||||
if (Array.isArray(details)) details.forEach(record => {
|
||||
if (!record?.key || !record?.user_login || !record?.data) return;
|
||||
const id = 'detail:' + record.user_login + ':' + record.key;
|
||||
cache.set(id, { ...record, id });
|
||||
});
|
||||
} catch (_) { cache.clear(); }
|
||||
recordCache = cache;
|
||||
}
|
||||
|
||||
function migrateLegacy(generation) {
|
||||
if (!transact) return Promise.resolve(true);
|
||||
if (!migrationPromise) migrationPromise = (async () => {
|
||||
let snapshot = null;
|
||||
|
|
@ -113,46 +133,82 @@
|
|||
if (!(await records.get(id))) await records.put({ ...record, id });
|
||||
}
|
||||
});
|
||||
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
|
||||
storage.removeItem(SNAPSHOT_KEY);
|
||||
storage.removeItem(DETAILS_KEY);
|
||||
return true;
|
||||
})().catch(() => false);
|
||||
return migrationPromise;
|
||||
}
|
||||
function ready() {
|
||||
if (!transact) return Promise.resolve(true);
|
||||
if (recordCache) return Promise.resolve(true);
|
||||
return migrateLegacy().then(migrated => {
|
||||
if (!migrated) {
|
||||
recordCache = new Map();
|
||||
try {
|
||||
const snapshot = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null');
|
||||
if (snapshot) recordCache.set('snapshot', { ...snapshot, id:'snapshot' });
|
||||
const details = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
|
||||
if (Array.isArray(details)) details.forEach(record => {
|
||||
if (!record?.key || !record?.user_login || !record?.data) return;
|
||||
const id = 'detail:' + record.user_login + ':' + record.key;
|
||||
recordCache.set(id, { ...record, id });
|
||||
});
|
||||
} catch (_) { recordCache.clear(); }
|
||||
return false;
|
||||
|
||||
async function initialize(generation) {
|
||||
const migrated = await migrateLegacy(generation);
|
||||
if (!migrated || generation !== readinessGeneration || readinessState.state === 'degraded') return false;
|
||||
if (pendingDurableClear) {
|
||||
await transact(async records => { await records.clear(); });
|
||||
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
|
||||
pendingDurableClear = false;
|
||||
recordCache = new Map();
|
||||
return true;
|
||||
}
|
||||
return transact(async records => {
|
||||
const currentTime = now().getTime();
|
||||
const valid = [];
|
||||
for (const record of await records.getAll()) {
|
||||
const savedAt = Date.parse(record?.saved_at || '');
|
||||
const validShape = record?.id === 'snapshot' ?
|
||||
record.version === VERSION && record.user_login && record.data :
|
||||
record?.id?.startsWith('detail:') && record.user_login && record.key && record.data;
|
||||
if (!validShape || !Number.isFinite(savedAt) || currentTime - savedAt > maxAgeMs) {
|
||||
if (record?.id) await records.delete(record.id);
|
||||
} else valid.push(record);
|
||||
}
|
||||
return transact(async records => {
|
||||
const currentTime = now().getTime();
|
||||
const valid = [];
|
||||
for (const record of await records.getAll()) {
|
||||
const savedAt = Date.parse(record?.saved_at || '');
|
||||
const validShape = record?.id === 'snapshot' ?
|
||||
record.version === VERSION && record.user_login && record.data :
|
||||
record?.id?.startsWith('detail:') && record.user_login && record.key && record.data;
|
||||
if (!validShape || !Number.isFinite(savedAt) || currentTime - savedAt > maxAgeMs) {
|
||||
if (record?.id) await records.delete(record.id);
|
||||
} else valid.push(record);
|
||||
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
|
||||
recordCache = new Map(valid.map(record => [record.id, record]));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function startReady() {
|
||||
const generation = ++readinessGeneration;
|
||||
readinessState = { state:'initializing', reason:'' };
|
||||
const operation = initialize(generation).catch(() => false);
|
||||
readinessPromise = new Promise(resolve => {
|
||||
const timer = setTimeout(() => {
|
||||
if (generation !== readinessGeneration || readinessState.state !== 'initializing') return;
|
||||
hydrateLegacyCache();
|
||||
readinessState = { state:'degraded', reason:'deadline' };
|
||||
resolve(false);
|
||||
}, Math.max(1, Number(initializationTimeoutMs) || 1500));
|
||||
operation.then(available => {
|
||||
if (generation !== readinessGeneration || readinessState.state === 'degraded') return;
|
||||
clearTimeout(timer);
|
||||
if (available) readinessState = { state:'ready', reason:'' };
|
||||
else {
|
||||
hydrateLegacyCache();
|
||||
readinessState = { state:'degraded', reason:'unavailable' };
|
||||
}
|
||||
recordCache = new Map(valid.map(record => [record.id, record]));
|
||||
return true;
|
||||
resolve(Boolean(available));
|
||||
});
|
||||
}).catch(() => false);
|
||||
});
|
||||
return readinessPromise;
|
||||
}
|
||||
|
||||
function ready() {
|
||||
if (!transact || readinessState.state === 'ready') return Promise.resolve(true);
|
||||
if (readinessState.state === 'degraded') return Promise.resolve(false);
|
||||
return readinessPromise || startReady();
|
||||
}
|
||||
|
||||
function status() {
|
||||
return { ...readinessState };
|
||||
}
|
||||
|
||||
function retry() {
|
||||
if (!transact) return Promise.resolve(true);
|
||||
migrationPromise = undefined;
|
||||
readinessPromise = undefined;
|
||||
return startReady();
|
||||
}
|
||||
function enabled() {
|
||||
try { return storage.getItem(ENABLED_KEY) === 'true'; }
|
||||
|
|
@ -165,6 +221,11 @@
|
|||
storage.removeItem(SNAPSHOT_KEY);
|
||||
storage.removeItem(DETAILS_KEY);
|
||||
} catch (_) { /* IndexedDB remains the source of truth. */ }
|
||||
if (readinessState.state === 'degraded') {
|
||||
pendingDurableClear = true;
|
||||
recordCache = new Map();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
migrationPromise = Promise.resolve(true);
|
||||
return transact(async records => {
|
||||
await records.clear();
|
||||
|
|
@ -234,7 +295,7 @@
|
|||
};
|
||||
if (transact) {
|
||||
record.id = 'detail:' + login + ':' + key;
|
||||
return ready().then(() => transact(async records => {
|
||||
return ready().then(available => available ? transact(async records => {
|
||||
const existing = (await records.getAll()).filter(candidate =>
|
||||
candidate?.id?.startsWith('detail:') && candidate.id !== record.id
|
||||
);
|
||||
|
|
@ -246,7 +307,7 @@
|
|||
overflow.forEach(candidate => recordCache.delete(candidate.id));
|
||||
recordCache.set(record.id, record);
|
||||
return true;
|
||||
})).catch(() => false);
|
||||
}) : false).catch(() => false);
|
||||
}
|
||||
const records = readDetails().filter(candidate =>
|
||||
!(candidate?.key === key && candidate?.user_login === login)
|
||||
|
|
@ -313,11 +374,11 @@
|
|||
},
|
||||
};
|
||||
if (transact) {
|
||||
return ready().then(() => transact(async records => {
|
||||
return ready().then(available => available ? transact(async records => {
|
||||
await records.put(record);
|
||||
recordCache.set(record.id, record);
|
||||
return true;
|
||||
})).catch(() => false);
|
||||
}) : false).catch(() => false);
|
||||
}
|
||||
try { storage.setItem(SNAPSHOT_KEY, JSON.stringify(record)); }
|
||||
catch (_) { return false; }
|
||||
|
|
@ -357,7 +418,7 @@
|
|||
return { ...record.data, saved_at: record.saved_at };
|
||||
}
|
||||
|
||||
return { enabled, setEnabled, ready, save, load, saveDetail, loadDetail, clear };
|
||||
return { enabled, setEnabled, ready, retry, status, save, load, saveDetail, loadDetail, clear };
|
||||
}
|
||||
|
||||
return createOfflineWorkStore;
|
||||
|
|
|
|||
|
|
@ -157,6 +157,91 @@ process.stdout.write(JSON.stringify({recordIds:[...records.keys()]}));
|
|||
assert result["recordIds"] == []
|
||||
|
||||
|
||||
def test_ready_degrades_within_deadline_when_indexeddb_never_settles():
|
||||
result = run_async_scenario("""
|
||||
values.set('stackchain.offline-work.enabled.v1', 'true');
|
||||
values.set('stackchain.offline-work.snapshot.v1', JSON.stringify({
|
||||
version:1, user_login:'timmy', saved_at:'2026-08-07T12:00:00.000Z',
|
||||
data:{user:{login:'timmy'}, issues:[{number:7, title:'Still available'}], pull_requests:[], notifications:[]},
|
||||
}));
|
||||
const neverTransaction = async () => new Promise(() => {});
|
||||
const store = createOfflineWorkStore({
|
||||
storage, transaction:neverTransaction, initializationTimeoutMs:20,
|
||||
now:() => new Date('2026-08-07T12:01:00Z'),
|
||||
});
|
||||
const started = Date.now();
|
||||
const outcome = await Promise.race([
|
||||
store.ready().then(value => ({value, status:store.status(), saved:store.load('timmy')})),
|
||||
new Promise(resolve => setTimeout(() => resolve({timedOut:true}), 100)),
|
||||
]);
|
||||
process.stdout.write(JSON.stringify({...outcome, elapsed:Date.now() - started,
|
||||
legacySnapshot:values.has('stackchain.offline-work.snapshot.v1')}));
|
||||
""")
|
||||
|
||||
assert result.get("timedOut", False) is False
|
||||
assert result["value"] is False
|
||||
assert result["status"] == {"state": "degraded", "reason": "deadline"}
|
||||
assert result["saved"]["issues"][0]["title"] == "Still available"
|
||||
assert result["legacySnapshot"] is True
|
||||
assert result["elapsed"] < 100
|
||||
|
||||
|
||||
def test_degraded_store_rejects_saves_without_waiting_for_stalled_indexeddb():
|
||||
result = run_async_scenario("""
|
||||
values.set('stackchain.offline-work.enabled.v1', 'true');
|
||||
const neverTransaction = async () => new Promise(() => {});
|
||||
const store = createOfflineWorkStore({storage, transaction:neverTransaction, initializationTimeoutMs:10});
|
||||
await store.ready();
|
||||
const outcome = await Promise.race([
|
||||
store.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]})
|
||||
.then(value => ({value})),
|
||||
new Promise(resolve => setTimeout(() => resolve({timedOut:true}), 80)),
|
||||
]);
|
||||
process.stdout.write(JSON.stringify(outcome));
|
||||
""")
|
||||
|
||||
assert result == {"value": False}
|
||||
|
||||
|
||||
def test_degraded_store_clears_legacy_data_without_waiting_for_indexeddb():
|
||||
result = run_async_scenario("""
|
||||
values.set('stackchain.offline-work.snapshot.v1', '{"private":"snapshot"}');
|
||||
values.set('stackchain.offline-work.details.v1', '[{"private":"detail"}]');
|
||||
const neverTransaction = async () => new Promise(() => {});
|
||||
const store = createOfflineWorkStore({storage, transaction:neverTransaction, initializationTimeoutMs:10});
|
||||
await store.ready();
|
||||
const outcome = await Promise.race([
|
||||
store.clear().then(value => ({value, keys:[...values.keys()]})),
|
||||
new Promise(resolve => setTimeout(() => resolve({timedOut:true}), 80)),
|
||||
]);
|
||||
process.stdout.write(JSON.stringify(outcome));
|
||||
""")
|
||||
|
||||
assert result == {"value": True, "keys": []}
|
||||
|
||||
|
||||
def test_retry_recovers_durable_saving_after_initialization_deadline():
|
||||
result = run_async_scenario("""
|
||||
let stalled = true;
|
||||
const recoveringTransaction = async work => stalled ? new Promise(() => {}) : transaction(work);
|
||||
const store = createOfflineWorkStore({storage, transaction:recoveringTransaction, initializationTimeoutMs:10});
|
||||
const initial = await store.ready();
|
||||
stalled = false;
|
||||
const recovered = await store.retry();
|
||||
store.setEnabled(true);
|
||||
const saved = await store.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]});
|
||||
process.stdout.write(JSON.stringify({initial, recovered, saved, status:store.status(), recordIds:[...records.keys()]}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"initial": False,
|
||||
"recovered": True,
|
||||
"saved": True,
|
||||
"status": {"state": "ready", "reason": ""},
|
||||
"recordIds": ["snapshot"],
|
||||
}
|
||||
|
||||
|
||||
def test_opted_in_snapshot_survives_restart_with_only_queue_card_fields():
|
||||
result = run_scenario("""
|
||||
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')});
|
||||
|
|
@ -348,6 +433,10 @@ async def test_dashboard_uses_async_indexeddb_offline_store_end_to_end():
|
|||
html = await dashboard()
|
||||
|
||||
assert "createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB })" in html
|
||||
assert "let offlineStorageReady = await offlineWorkStore.ready();" in html
|
||||
assert 'id="retry-offline-storage"' in html
|
||||
assert "Offline saving unavailable · online work remains live." in html
|
||||
assert "await offlineWorkStore.retry()" in html
|
||||
assert "async function hydrateOfflineWork" in html
|
||||
assert "const saved = await offlineWorkStore.load();" in html
|
||||
assert "await offlineWorkStore.save({" in html
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user