From 72453b76dbeb251fbbbe71a69a2a2bc71e9a6fc9 Mon Sep 17 00:00:00 2001 From: timmy Date: Mon, 10 Aug 2026 08:17:57 +0000 Subject: [PATCH] perf: adapt live polling to server freshness (#465) --- README.md | 5 + frontend/context-poller.js | 51 +++++- frontend/dashboard.js | 6 +- frontend/service-worker.js | 2 +- tests/test_comment_next.py | 2 +- tests/test_context_polling.py | 180 ++++++++++++++++++++++ tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_plan_today.py | 2 +- tests/test_service_worker.py | 20 +-- tests/test_today_readiness.py | 2 +- tests/test_today_sync.py | 2 +- 13 files changed, 255 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 1d9dc1f..b95ef8c 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,11 @@ not changed by this session boundary. Installed-app navigations are also deadline-bounded: after four seconds without a network response, Stackchain aborts the request and opens the cached dashboard shell. If the shell has not been installed yet, it returns explicit HTTP 504 reconnect guidance instead of hanging. +Live dashboard refreshes use the server's per-section freshness windows: healthy sections +refresh at their earliest deadline while a fully degraded snapshot waits for its reported +cooldown. HTTP `Retry-After` delays are honored up to five minutes, and transport failures +back off from eight seconds to a one-minute cap. Reconnect, foreground return, and explicit +refresh still run immediately; a successful response resets transport backoff. Closed-app delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries diff --git a/frontend/context-poller.js b/frontend/context-poller.js index 1ac7fff..1cd2f24 100644 --- a/frontend/context-poller.js +++ b/frontend/context-poller.js @@ -10,6 +10,13 @@ function buildLiveRevisionQuery(revisions = {}) { return params.toString(); } +function retryAfterMs(value) { + if (typeof value !== 'string' || !/^\d+$/.test(value)) return null; + const seconds = Number(value); + if (!Number.isFinite(seconds) || seconds <= 0) return null; + return Math.min(seconds * 1000, 300000); +} + function createContextPoller({ fetchContext, onSnapshot, @@ -21,25 +28,51 @@ function createContextPoller({ clearDeadlineTimer = clearTimeout, intervalMs = 8000, timeoutMs = 12000, + maxBackoffMs = 60000, }) { let activeRequest = null; let timer = null; let stopped = false; let revisions = {}; let retainedSnapshot = null; + let nextDelayMs = intervalMs; + let failureStreak = 0; + + function snapshotDelay(snapshot) { + const freshness = snapshot && snapshot.freshness; + const sections = freshness && freshness.sections; + const sectionValues = sections && Object.values(sections); + if (sectionValues && sectionValues.length && sectionValues.every(section => section.degraded)) { + const retrySeconds = Number(freshness.retry_in_seconds); + if (Number.isFinite(retrySeconds) && retrySeconds > 0) return retrySeconds * 1000; + } + const freshForSeconds = Number(freshness && freshness.fresh_for_seconds); + const healthyDeadlines = (sectionValues || []) + .filter(section => !section.degraded) + .map(section => freshForSeconds - Number(section.age_seconds)) + .filter(seconds => Number.isFinite(seconds)); + if (Number.isFinite(freshForSeconds) && freshForSeconds > 0 && healthyDeadlines.length) { + const earliestDeadline = Math.min(...healthyDeadlines); + if (earliestDeadline <= 0 && sectionValues.some(section => !section.degraded && section.revalidating)) { + return intervalMs; + } + return Math.max(0, earliestDeadline) * 1000; + } + return intervalMs; + } function cancelTimer() { if (timer !== null) clearTimer(timer); timer = null; } - function schedule() { + function schedule(delayMs = intervalMs) { cancelTimer(); if (stopped || isHidden()) return; timer = setTimer(() => { timer = null; refresh(); - }, intervalMs); + }, delayMs); } function abortError() { @@ -91,6 +124,8 @@ function createContextPoller({ requestState.promise = Promise.race([Promise.resolve(request), deadlinePromise]) .then((snapshot) => { if (activeRequest !== requestState) return retainedSnapshot; + failureStreak = 0; + nextDelayMs = snapshotDelay(snapshot); const changedSections = ['context', 'events', 'notifications'].filter( (section) => Object.prototype.hasOwnProperty.call(snapshot, section) ); @@ -100,7 +135,14 @@ function createContextPoller({ return retainedSnapshot; }) .catch((error) => { - if (activeRequest === requestState && !stopped) onError(error); + if (activeRequest === requestState && !stopped) { + const retryAfterMs = Number(error && error.retryAfterMs); + failureStreak += 1; + nextDelayMs = Number.isFinite(retryAfterMs) && retryAfterMs > 0 + ? retryAfterMs + : Math.min(intervalMs * (2 ** (failureStreak - 1)), maxBackoffMs); + onError(error); + } return null; }) .finally(() => { @@ -108,7 +150,7 @@ function createContextPoller({ clearDeadlineTimer(requestState.deadline); if (activeRequest !== requestState) return; activeRequest = null; - schedule(); + schedule(nextDelayMs); }); return requestState.promise; } @@ -136,6 +178,7 @@ function createContextPoller({ } createContextPoller.buildRevisionQuery = buildLiveRevisionQuery; +createContextPoller.retryAfterMs = retryAfterMs; if (typeof module !== 'undefined' && module.exports) { module.exports = createContextPoller; diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 9e155e1..2124e2d 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -376,7 +376,11 @@ headers: { Accept: 'application/json' }, signal, }); - if (!res.ok) throw new Error('HTTP ' + res.status); + if (!res.ok) { + const error = new Error('HTTP ' + res.status); + error.retryAfterMs = createContextPoller.retryAfterMs(res.headers.get('Retry-After')); + throw error; + } return res.json(); } diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 039eaab..d0a61d7 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v79'; +const CACHE = 'stackchain-dashboard-shell-v80'; const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index e52bb0a..c5b53e6 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -281,4 +281,4 @@ async def test_current_today_update_offers_reply_and_next_without_marking_read() assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html assert '.update-reply-actions button { min-height:44px;' in html worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v79" in worker + assert "stackchain-dashboard-shell-v80" in worker diff --git a/tests/test_context_polling.py b/tests/test_context_polling.py index c153213..72b9561 100644 --- a/tests/test_context_polling.py +++ b/tests/test_context_polling.py @@ -4,6 +4,7 @@ from pathlib import Path POLLER = Path(__file__).parents[1] / "frontend" / "context-poller.js" +DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js" def run_node(script: str) -> dict: @@ -116,6 +117,185 @@ const poller = createContextPoller({{ } +def test_context_poller_waits_for_server_cooldown_when_every_section_is_degraded(): + script = f""" +const createContextPoller = require({json.dumps(str(POLLER))}); +const delays = []; +const poller = createContextPoller({{ + fetchContext: () => Promise.resolve({{ + context: {{}}, events: [], notifications: [], + freshness: {{ + fresh_for_seconds: 8, + retry_in_seconds: 60, + sections: {{ + context: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }}, + events: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }}, + notifications: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }}, + }}, + }}, + }}), + onSnapshot: () => {{}}, + onError: error => {{ throw error; }}, + setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }}, + clearTimer: () => {{}}, + intervalMs: 8000, +}}); +(async () => {{ + await poller.start(); + process.stdout.write(JSON.stringify({{ delays }})); +}})(); +""" + + assert run_node(script) == {"delays": [60000]} + + +def test_context_poller_uses_earliest_healthy_section_freshness_deadline(): + script = f""" +const createContextPoller = require({json.dumps(str(POLLER))}); +const delays = []; +const poller = createContextPoller({{ + fetchContext: () => Promise.resolve({{ + context: {{}}, events: [], notifications: [], + freshness: {{ + fresh_for_seconds: 8, + retry_in_seconds: 60, + sections: {{ + context: {{ degraded: false, age_seconds: 2 }}, + events: {{ degraded: false, age_seconds: 5 }}, + notifications: {{ degraded: true, retry_in_seconds: 60, age_seconds: 12 }}, + }}, + }}, + }}), + onSnapshot: () => {{}}, + onError: error => {{ throw error; }}, + setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }}, + clearTimer: () => {{}}, + intervalMs: 8000, +}}); +(async () => {{ + await poller.start(); + process.stdout.write(JSON.stringify({{ delays }})); +}})(); +""" + + assert run_node(script) == {"delays": [3000]} + + +def test_context_poller_does_not_spin_while_stale_sections_are_revalidating(): + script = f""" +const createContextPoller = require({json.dumps(str(POLLER))}); +const delays = []; +const poller = createContextPoller({{ + fetchContext: () => Promise.resolve({{ + context: {{}}, events: [], notifications: [], + freshness: {{ + fresh_for_seconds: 8, + sections: {{ + context: {{ degraded: false, revalidating: true, age_seconds: 12 }}, + events: {{ degraded: false, revalidating: true, age_seconds: 12 }}, + notifications: {{ degraded: false, revalidating: true, age_seconds: 12 }}, + }}, + }}, + }}), + onSnapshot: () => {{}}, + onError: error => {{ throw error; }}, + setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }}, + clearTimer: () => {{}}, + intervalMs: 8000, +}}); +(async () => {{ + await poller.start(); + process.stdout.write(JSON.stringify({{ delays }})); +}})(); +""" + + assert run_node(script) == {"delays": [8000]} + + +def test_context_poller_honors_http_retry_after_delay(): + script = f""" +const createContextPoller = require({json.dumps(str(POLLER))}); +const delays = []; +const errors = []; +const unavailable = new Error('HTTP 503'); +unavailable.retryAfterMs = 45000; +const poller = createContextPoller({{ + fetchContext: () => Promise.reject(unavailable), + onSnapshot: () => {{}}, + onError: error => errors.push(error.message), + setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }}, + clearTimer: () => {{}}, + intervalMs: 8000, +}}); +(async () => {{ + await poller.start(); + process.stdout.write(JSON.stringify({{ delays, errors }})); +}})(); +""" + + assert run_node(script) == {"delays": [45000], "errors": ["HTTP 503"]} + + +def test_retry_after_parser_accepts_bounded_delta_seconds(): + script = f""" +const createContextPoller = require({json.dumps(str(POLLER))}); +process.stdout.write(JSON.stringify({{ + valid: createContextPoller.retryAfterMs('45'), + zero: createContextPoller.retryAfterMs('0'), + invalid: createContextPoller.retryAfterMs('later'), + excessive: createContextPoller.retryAfterMs('999999'), +}})); +""" + + assert run_node(script) == { + "valid": 45000, + "zero": None, + "invalid": None, + "excessive": 300000, + } + + +def test_context_poller_backs_off_transport_failures_and_resets_after_success(): + script = f""" +const createContextPoller = require({json.dumps(str(POLLER))}); +const delays = []; +let calls = 0; +const poller = createContextPoller({{ + fetchContext: () => {{ + calls += 1; + if (calls < 6) return Promise.reject(new TypeError('network unavailable')); + return Promise.resolve({{ context: {{}}, events: [], notifications: [] }}); + }}, + onSnapshot: () => {{}}, + onError: () => {{}}, + setTimer: (callback, delay) => {{ delays.push(delay); return delays.length; }}, + clearTimer: () => {{}}, + intervalMs: 8000, + maxBackoffMs: 60000, +}}); +(async () => {{ + await poller.refresh(); + await poller.refresh(); + await poller.refresh(); + await poller.refresh(); + await poller.refresh(); + await poller.refresh(); + process.stdout.write(JSON.stringify({{ delays }})); +}})(); +""" + + assert run_node(script) == { + "delays": [8000, 16000, 32000, 60000, 60000, 8000] + } + + +def test_live_fetch_passes_retry_after_to_the_poller_error(): + source = DASHBOARD.read_text() + + assert "createContextPoller.retryAfterMs(res.headers.get('Retry-After'))" in source + assert "error.retryAfterMs" in source + + def test_live_revision_query_preserves_bounded_opaque_tokens(): script = f""" const createContextPoller = require({json.dumps(str(POLLER))}); diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index b55f06f..3c96f10 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status(): def test_later_sync_ships_atomically_in_the_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v79" in source + assert "stackchain-dashboard-shell-v80" in source assert "BASE + 'static/later-sync.js'" in source diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index 5fc211e..8d6831e 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers(): assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css assert ".markdown-content a { min-height:44px;" in css - assert "stackchain-dashboard-shell-v79" in worker + assert "stackchain-dashboard-shell-v80" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index ad789dd..0e09614 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -35,7 +35,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset(): shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" - assert "stackchain-dashboard-shell-v79" in worker + assert "stackchain-dashboard-shell-v80" in worker def test_all_conversation_composers_offer_accessible_mobile_mentions(): diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 0de95a3..aeefb9b 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -292,6 +292,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history( def test_plan_today_controller_is_available_in_the_offline_shell(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v79" in source + assert "stackchain-dashboard-shell-v80" in source assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index cc70139..cb4c334 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -122,7 +122,7 @@ async function dispatchNotificationClick(route) {{ def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v79" in source + assert "stackchain-dashboard-shell-v80" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -131,14 +131,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v79" in source + assert "stackchain-dashboard-shell-v80" in source assert "BASE + 'static/dashboard.js'" in source def test_offline_review_next_ships_today_completion_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v79" in source + assert "stackchain-dashboard-shell-v80" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -146,7 +146,7 @@ def test_offline_review_next_ships_today_completion_atomically(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v79" in source + assert "stackchain-dashboard-shell-v80" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -154,14 +154,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v79" in source + assert "stackchain-dashboard-shell-v80" in source assert "BASE + 'static/later-picker.js'" in source def test_navigation_deadline_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v79" in source + assert "stackchain-dashboard-shell-v80" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -170,21 +170,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_today_convergence_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v79" in source + assert "stackchain-dashboard-shell-v80" in source assert "BASE + 'static/today-sync.js'" in source def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v79" in source + assert "stackchain-dashboard-shell-v80" in source assert "BASE + 'static/mobile-search-viewport.js'" in source def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v79" in source + assert "stackchain-dashboard-shell-v80" in source assert "BASE + 'static/update-ownership.js'" in source @@ -385,7 +385,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain(): def test_queue_today_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v79" in source + assert "stackchain-dashboard-shell-v80" in source assert "BASE + 'static/queue-today.js'" in source diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index ebf8d7b..82196b1 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate def test_readiness_runtime_is_available_in_offline_shell(): service_worker = SERVICE_WORKER.read_text() - assert "const CACHE = 'stackchain-dashboard-shell-v79';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v80';" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 2147c64..eef38e3 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}}); def test_inflight_today_drain_ships_in_a_new_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v79" in source + assert "stackchain-dashboard-shell-v80" in source assert "BASE + 'static/today-sync.js'" in source -- 2.43.0