perf(email): make library prewarm idle and bounded (#5925)

* perf(email): make library prewarm idle and bounded

* fix(email): preserve idle prewarm and prioritize foreground

* fix(email): retry interrupted idle prewarm safely
This commit is contained in:
RaresKeY
2026-08-10 19:14:10 +01:00
committed by GitHub
parent 8f2f483725
commit 96aca52094
2 changed files with 711 additions and 132 deletions
+297 -132
View File
@@ -1793,11 +1793,18 @@ function _rememberedEmailAccountId() {
// results and __scheduled__ are deliberately not cached.
const _libListCache = new Map();
const _LIB_CACHE_MAX = 24;
const _LIB_INITIAL_PAGE_SIZE = 100;
const _LIB_SESSION_CACHE_PREFIX = 'odysseus.email.list.';
const _LIB_SESSION_CACHE_TTL_MS = 10 * 60 * 1000;
const _LIB_LAST_ACCOUNT_KEY = 'odysseus.email.lastAccountId';
let _libPrewarmTimer = null;
const _LIB_PREWARM_COOLDOWN_MS = 5 * 60 * 1000;
let _libPrewarmDelayTimer = null;
let _libPrewarmIdleHandle = null;
let _libPrewarmPromise = null;
let _libPrewarmResolve = null;
let _libPrewarmAbortController = null;
let _libPrewarmDetachPriorityListeners = null;
let _libPrewarmGeneration = 0;
let _libLastPrewarmAt = 0;
let _libUnreadPrewarmKey = '';
let _libUnreadPrewarmAt = 0;
@@ -2108,162 +2115,319 @@ function _isChatInteractionBusy() {
}
}
function _loadEmailsWhenChatIdle({ delay = 50, retries = 180, options = {} } = {}) {
const run = () => {
if (!state._libOpen || !document.getElementById('email-lib-modal')) return;
if (_isChatInteractionBusy() && retries > 0) {
setTimeout(() => _loadEmailsWhenChatIdle({ delay: 1000, retries: retries - 1, options }), 1000);
function _canRunEmailPrewarm() {
if (state._libOpen || state._libLoading || _libSearchInFlight) return false;
if (document.visibilityState && document.visibilityState !== 'visible') return false;
return !_isChatInteractionBusy();
}
function _isEmailPrewarmTemporarilyBlocked() {
if (state._libOpen || state._libLoading || _libSearchInFlight) return false;
if (document.visibilityState && document.visibilityState !== 'visible') return false;
return _isChatInteractionBusy();
}
function _isEmailPrewarmCurrent(generation, signal) {
return generation === _libPrewarmGeneration
&& !signal?.aborted
&& _canRunEmailPrewarm();
}
function _settleEmailPrewarm(generation, value = false) {
if (generation !== _libPrewarmGeneration) return;
const resolve = _libPrewarmResolve;
const detachPriorityListeners = _libPrewarmDetachPriorityListeners;
_libPrewarmDelayTimer = null;
_libPrewarmIdleHandle = null;
_libPrewarmPromise = null;
_libPrewarmResolve = null;
_libPrewarmAbortController = null;
_libPrewarmDetachPriorityListeners = null;
detachPriorityListeners?.();
resolve?.(value);
}
function _cancelEmailPrewarm() {
const resolve = _libPrewarmResolve;
const detachPriorityListeners = _libPrewarmDetachPriorityListeners;
_libPrewarmGeneration += 1;
if (_libPrewarmDelayTimer !== null) {
clearTimeout(_libPrewarmDelayTimer);
}
if (_libPrewarmIdleHandle !== null && typeof window.cancelIdleCallback === 'function') {
try { window.cancelIdleCallback(_libPrewarmIdleHandle); } catch (_) {}
}
try { _libPrewarmAbortController?.abort(); } catch (_) {}
_libPrewarmDelayTimer = null;
_libPrewarmIdleHandle = null;
_libPrewarmPromise = null;
_libPrewarmResolve = null;
_libPrewarmAbortController = null;
_libPrewarmDetachPriorityListeners = null;
detachPriorityListeners?.();
resolve?.(false);
}
function _scheduleEmailPrewarm(task, { delay = 0 } = {}) {
if (_libPrewarmPromise) return _libPrewarmPromise;
// Do not disguise a timer as idle work. Browsers without the genuine idle
// callback simply skip this optional optimization and load on demand.
if (typeof window.requestIdleCallback !== 'function') return Promise.resolve(false);
const generation = ++_libPrewarmGeneration;
_libPrewarmPromise = new Promise(resolve => { _libPrewarmResolve = resolve; });
const promise = _libPrewarmPromise;
let attemptPending = false;
let retryRequested = false;
function clearScheduledAttempt() {
if (_libPrewarmDelayTimer !== null) clearTimeout(_libPrewarmDelayTimer);
if (_libPrewarmIdleHandle !== null && typeof window.cancelIdleCallback === 'function') {
try { window.cancelIdleCallback(_libPrewarmIdleHandle); } catch (_) {}
}
_libPrewarmDelayTimer = null;
_libPrewarmIdleHandle = null;
}
function scheduleIdleRetry(delay = 500) {
if (generation !== _libPrewarmGeneration) return;
retryRequested = true;
if (attemptPending || _libPrewarmDelayTimer !== null || _libPrewarmIdleHandle !== null) return;
if (document.visibilityState && document.visibilityState !== 'visible') return;
_libPrewarmDelayTimer = setTimeout(requestIdle, Math.max(50, Number(delay) || 500));
}
function handlePriorityChange() {
if (generation !== _libPrewarmGeneration) return;
if (_canRunEmailPrewarm()) {
scheduleIdleRetry(50);
return;
}
_loadEmails(options);
const priorityBlocked = _isChatInteractionBusy()
|| (document.visibilityState && document.visibilityState !== 'visible');
if (!priorityBlocked) return;
retryRequested = true;
clearScheduledAttempt();
const controller = _libPrewarmAbortController;
_libPrewarmAbortController = null;
try { controller?.abort(); } catch (_) {}
// A hidden page waits for visibilitychange. Chat priority also retains the
// timer fallback for busy-until windows whose final transition has no event.
if (!document.visibilityState || document.visibilityState === 'visible') {
scheduleIdleRetry();
}
}
window.addEventListener('odysseus:chat-busy-change', handlePriorityChange);
document.addEventListener('visibilitychange', handlePriorityChange);
_libPrewarmDetachPriorityListeners = () => {
window.removeEventListener('odysseus:chat-busy-change', handlePriorityChange);
document.removeEventListener('visibilitychange', handlePriorityChange);
};
setTimeout(run, Math.max(0, Number(delay) || 0));
function requestIdle() {
if (generation !== _libPrewarmGeneration) return;
_libPrewarmDelayTimer = null;
try {
_libPrewarmIdleHandle = window.requestIdleCallback((deadline) => {
if (generation !== _libPrewarmGeneration) return;
_libPrewarmIdleHandle = null;
const hasIdleBudget = Boolean(
deadline
&& !deadline.didTimeout
&& typeof deadline.timeRemaining === 'function'
&& deadline.timeRemaining() > 0
);
if (!_canRunEmailPrewarm()) {
if (_isEmailPrewarmTemporarilyBlocked()) {
scheduleIdleRetry();
} else {
_settleEmailPrewarm(generation, false);
}
return;
}
if (!hasIdleBudget) {
scheduleIdleRetry();
return;
}
if (generation !== _libPrewarmGeneration) {
_settleEmailPrewarm(generation, false);
return;
}
const controller = new AbortController();
_libPrewarmAbortController = controller;
attemptPending = true;
retryRequested = false;
Promise.resolve()
.then(() => task({ signal: controller.signal, generation }))
.then(value => {
if (controller !== _libPrewarmAbortController || controller.signal.aborted) return;
_settleEmailPrewarm(generation, Boolean(value));
})
.catch(() => {
if (controller !== _libPrewarmAbortController || controller.signal.aborted) return;
_settleEmailPrewarm(generation, false);
})
.finally(() => {
attemptPending = false;
if (generation !== _libPrewarmGeneration) return;
if (retryRequested) scheduleIdleRetry();
});
});
} catch (_) {
_settleEmailPrewarm(generation, false);
}
}
const wait = Math.max(0, Number(delay) || 0);
if (wait > 0) _libPrewarmDelayTimer = setTimeout(requestIdle, wait);
else requestIdle();
return promise;
}
export function prewarmEmailLibrary({ delay = 2500 } = {}) {
if (_libPrewarmTimer || _libPrewarmPromise) return;
if (_libPrewarmPromise) return _libPrewarmPromise;
const elapsed = Date.now() - _libLastPrewarmAt;
if (elapsed >= 0 && elapsed < 5 * 60 * 1000) return;
_libPrewarmTimer = setTimeout(() => {
_libPrewarmTimer = null;
_libPrewarmPromise = _prewarmEmailViews()
.catch(() => {})
.finally(() => { _libPrewarmPromise = null; });
}, Math.max(0, Number(delay) || 0));
if (elapsed >= 0 && elapsed < _LIB_PREWARM_COOLDOWN_MS) return Promise.resolve(false);
return _scheduleEmailPrewarm(_prewarmEmailViews, { delay });
}
async function _ensureEmailAccountsForPrewarm() {
function _chooseEmailPrewarmAccountId(accounts) {
const enabled = Array.isArray(accounts) ? accounts.filter(a => a && a.enabled !== false) : [];
const remembered = _rememberedEmailAccountId();
const current = String(state._libAccountId || '').trim();
const chosen = enabled.find(a => String(a.id || '') === remembered)
|| enabled.find(a => String(a.id || '') === current)
|| enabled.find(a => a.is_default)
|| enabled[0]
|| null;
return String(chosen?.id || '').trim();
}
async function _ensureEmailAccountsForPrewarm({ signal, generation } = {}) {
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
const accountsFresh = _libAccountsLoadedAt && (Date.now() - _libAccountsLoadedAt) < _LIB_ACCOUNTS_TTL_MS;
if (Array.isArray(state._libAccounts) && state._libAccounts.length && accountsFresh) {
if (!state._libAccountId) {
const def = state._libAccounts.find(a => a.is_default) || state._libAccounts[0];
state._libAccountId = def?.id || null;
_publishActiveAccount();
}
return;
}
try {
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
if (!accountsRes.ok) return;
const accountsData = await accountsRes.json().catch(() => ({}));
if (Array.isArray(accountsData.accounts)) {
state._libAccounts = accountsData.accounts;
_libAccountsLoadedAt = Date.now();
if (!state._libAccountId && state._libAccounts.length) {
const def = state._libAccounts.find(a => a.is_default) || state._libAccounts[0];
state._libAccountId = def?.id || null;
_publishActiveAccount();
if (!(Array.isArray(state._libAccounts) && state._libAccounts.length && accountsFresh)) {
try {
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, {
credentials: 'same-origin',
signal,
});
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
if (accountsRes.ok) {
const accountsData = await accountsRes.json().catch(() => ({}));
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
if (Array.isArray(accountsData.accounts)) {
state._libAccounts = accountsData.accounts;
_libAccountsLoadedAt = Date.now();
}
}
} catch (err) {
if (err?.name === 'AbortError') return null;
}
} catch (_) {}
}
const accountId = _chooseEmailPrewarmAccountId(state._libAccounts);
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
if (!accountId) return null;
if (accountId && state._libAccountId !== accountId) {
state._libAccountId = accountId;
_publishActiveAccount();
}
return accountId;
}
export async function prewarmUnreadEmails({ limit = 8, maxUid = 0 } = {}) {
if (state._libOpen) return;
await _ensureEmailAccountsForPrewarm();
if (state._libOpen) return;
const accountId = state._libAccountId || '';
export function prewarmUnreadEmails({ limit = 8, maxUid = 0 } = {}) {
return _scheduleEmailPrewarm(
context => _prewarmUnreadEmailsNow({ limit, maxUid }, context),
{ delay: 0 }
);
}
async function _prewarmUnreadEmailsNow({ limit = 8, maxUid = 0 } = {}, { signal, generation } = {}) {
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
const accountId = await _ensureEmailAccountsForPrewarm({ signal, generation });
if (accountId === null || !_isEmailPrewarmCurrent(generation, signal)) return false;
const n = Math.max(1, Math.min(20, Number(limit) || 8));
const key = `${accountId}|${maxUid || 0}|${n}`;
if (_libUnreadPrewarmKey === key && (Date.now() - _libUnreadPrewarmAt) < 60 * 1000) return;
_libUnreadPrewarmKey = key;
_libUnreadPrewarmAt = Date.now();
if (_libUnreadPrewarmKey === key && (Date.now() - _libUnreadPrewarmAt) < 60 * 1000) return true;
try {
const folder = 'INBOX';
const res = await fetch(emailApiUrl('/api/email/list', {
folder,
limit: n,
offset: 0,
filter: 'unread',
account_id: accountId || undefined,
}), { credentials: 'same-origin' });
if (state._libOpen) return;
if (!res.ok) return;
const res = await fetch(emailApiUrl('/api/email/list', {
folder,
limit: n,
offset: 0,
filter: 'unread',
account_id: accountId || undefined,
}), {
credentials: 'same-origin',
signal,
});
if (!_isEmailPrewarmCurrent(generation, signal) || !res.ok) return false;
const data = await res.json().catch(() => null);
if (!data || data.error || !Array.isArray(data.emails) || !data.emails.length) return;
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
if (!data || data.error || !Array.isArray(data.emails) || !data.emails.length) return false;
const sync = data.sync || {};
_libCachePut(_libCacheKeyFor(accountId, folder, 'unread', false), {
emails: data.emails,
total: data.total || data.emails.length,
sync,
});
} catch (_) {}
_libUnreadPrewarmKey = key;
_libUnreadPrewarmAt = Date.now();
return true;
} catch (_) {
return false;
}
}
function _sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function _prewarmEmailViews() {
if (state._libOpen) return;
_libLastPrewarmAt = Date.now();
async function _prewarmEmailViews({ signal, generation } = {}) {
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
_setEmailSyncStatus({ warming: true });
const folder = 'INBOX';
const filter = 'all';
// The accounts request is cheap and warms the account strip for first open.
// Then folder/list requests warm both the client cache and the backend
// IMAP/read caches. Failure stays silent: no configured mail should not nag.
try {
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
if (accountsRes.ok) {
const accountsData = await accountsRes.json().catch(() => ({}));
if (Array.isArray(accountsData.accounts)) {
state._libAccounts = accountsData.accounts;
_libAccountsLoadedAt = Date.now();
}
const accountId = await _ensureEmailAccountsForPrewarm({ signal, generation });
if (accountId === null || !_isEmailPrewarmCurrent(generation, signal)) return false;
const ck = _libCacheKeyFor(accountId, folder, filter, false);
if (_libCacheGet(ck)) {
_libLastPrewarmAt = Date.now();
return true;
}
} catch (_) {}
const accounts = Array.isArray(state._libAccounts) ? state._libAccounts.filter(a => a && a.enabled !== false) : [];
const preferred = state._libAccountId
|| (accounts.find(a => a.is_default)?.id)
|| (accounts[0]?.id)
|| '';
if (!state._libAccountId && preferred) {
state._libAccountId = preferred;
_publishActiveAccount();
}
const orderedAccountIds = [
preferred,
...accounts.map(a => a.id).filter(id => id && id !== preferred),
].filter((id, idx, arr) => arr.indexOf(id) === idx);
if (!orderedAccountIds.length) orderedAccountIds.push('');
try {
for (const accountId of orderedAccountIds.slice(0, 4)) {
if (state._libOpen) return;
const ck = _libCacheKeyFor(accountId, folder, filter, false);
if (_libCacheGet(ck)) continue;
await fetch(emailApiUrl('/api/email/folders', { account_id: accountId || undefined }), { credentials: 'same-origin' }).catch(() => null);
await fetch(emailApiUrl('/api/email/unread-state', { folder, account_id: accountId || undefined }), { credentials: 'same-origin' }).catch(() => null);
const res = await fetch(emailApiUrl('/api/email/list', {
folder,
limit: 100,
offset: 0,
filter,
account_id: accountId || undefined,
}), {
credentials: 'same-origin',
});
if (res.ok) {
const data = await res.json().catch(() => null);
if (data && !data.error) {
const sync = data.sync || {};
_libCachePut(ck, {
emails: data.emails || [],
total: data.total || 0,
sync,
});
_setEmailSyncStatus({
updatedAt: sync.updated_at || new Date().toISOString(),
source: sync.source || '',
warming: true,
});
}
}
await _sleep(900);
}
// One optional first-page request only. Folder metadata, unread state, and
// other accounts remain demand-driven so startup cannot fan out into IMAP.
const res = await fetch(emailApiUrl('/api/email/list', {
folder,
limit: _LIB_INITIAL_PAGE_SIZE,
offset: 0,
filter,
account_id: accountId || undefined,
}), {
credentials: 'same-origin',
signal,
});
if (!_isEmailPrewarmCurrent(generation, signal) || !res.ok) return false;
const data = await res.json().catch(() => null);
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
if (!data || data.error || !Array.isArray(data.emails)) return false;
const sync = data.sync || {};
_libCachePut(ck, {
emails: data.emails,
total: data.total || 0,
sync,
});
_libLastPrewarmAt = Date.now();
_setEmailSyncStatus({
updatedAt: sync.updated_at || new Date().toISOString(),
source: sync.source || '',
warming: true,
});
return true;
} catch (_) {
return false;
} finally {
_setEmailSyncStatus({ warming: false });
}
@@ -2342,10 +2506,10 @@ export function initEmailLibrary(config) {
export function isOpen() { return state._libOpen; }
export function openEmailLibrary(opts = {}) {
if (_libPrewarmTimer) {
clearTimeout(_libPrewarmTimer);
_libPrewarmTimer = null;
}
// Foreground email always wins: cancel a delayed/idle callback and abort the
// one optional request if it has already started. Generation checks make a
// non-abortable response harmless if it races this transition.
_cancelEmailPrewarm();
// Force-clean any stale state from previous attempts
const existing = document.getElementById('email-lib-modal');
if (existing) existing.remove();
@@ -2977,7 +3141,7 @@ export function openEmailLibrary(opts = {}) {
}
const fastAccountAtOpen = state._libAccountId || '';
if (fastAccountAtOpen) {
_loadEmailsWhenChatIdle({ delay: 0 });
_loadEmails({ useCache: true });
}
// If we already know the previous/default account, paint that inbox first
// from the durable index and validate accounts in parallel. Cold refreshes
@@ -2987,7 +3151,7 @@ export function openEmailLibrary(opts = {}) {
_loadFolders();
_loadEmailReminderBellVisibility();
if (!fastAccountAtOpen || fastAccountAtOpen !== (state._libAccountId || '')) {
_loadEmailsWhenChatIdle();
_loadEmails({ useCache: true });
}
})();
}
@@ -3172,6 +3336,7 @@ export async function openEmailLibrarySettings() {
}
export function closeEmailLibrary() {
_cancelEmailPrewarm();
const modal = document.getElementById('email-lib-modal');
if (modal) modal.remove();
if (_libSyncTicker) {
@@ -4605,7 +4770,7 @@ async function _loadEmails({ force = false, useCache = true } = {}) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 450);
try {
const fastRes = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folderAtStart)}${accountQS}&limit=100&offset=${offsetAtStart}&filter=${filterAtStart}${attQS}&cached_only=1`, {
const fastRes = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folderAtStart)}${accountQS}&limit=${_LIB_INITIAL_PAGE_SIZE}&offset=${offsetAtStart}&filter=${filterAtStart}${attQS}&cached_only=1`, {
signal: ctrl.signal,
});
const fastData = await fastRes.json().catch(() => null);
@@ -4632,7 +4797,7 @@ async function _loadEmails({ force = false, useCache = true } = {}) {
// opens omit it so rapid close/reopen returns instantly; the
// Refresh button passes `force: true` to add it back.
const buster = force ? `&_=${Date.now()}` : '';
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folderAtStart)}${accountQS}&limit=100&offset=${offsetAtStart}&filter=${filterAtStart}${attQS}${buster}`);
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folderAtStart)}${accountQS}&limit=${_LIB_INITIAL_PAGE_SIZE}&offset=${offsetAtStart}&filter=${filterAtStart}${attQS}${buster}`);
const data = await res.json();
if (seq !== _libLoadSeq || accountAtStart !== (state._libAccountId || '')) return;
if (data.error) throw new Error(data.error);