From fedddb246db5d4cf9775b98831cb99d841749ccc Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 8 Aug 2026 09:35:48 +0000 Subject: [PATCH] perf: skip unchanged live snapshot sections (#285) --- README.md | 6 +++ frontend/context-poller.js | 13 ++++-- frontend/index.html | 25 ++++++++---- frontend/service-worker.js | 2 +- src/main.py | 57 ++++++++++++++++++++++++--- tests/test_context_polling.py | 51 ++++++++++++++++++++++++ tests/test_live_snapshot.py | 43 ++++++++++++++++++++ tests/test_offline_work.py | 3 +- tests/test_service_worker.py | 4 +- tests/test_shared_context_snapshot.py | 4 +- 10 files changed, 188 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 24d9c7b..500b78d 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,12 @@ other writes. Requests that cannot enter within 250 ms fail as retryable HTTP below the route deadlines. Streaming diff reads share the same read capacity, while POST, PATCH, PUT, and DELETE requests are never coalesced. +Live snapshots expose independent `context`, `events`, and `notifications` +revisions. The browser sends its known revisions on later polls, so `/api/v1/live` +can omit unchanged section bodies while still returning current freshness and +retry metadata. The client retains omitted data and only rebuilds or persists the +sections that changed. + ## Offline mobile shell At phone widths, a persistent bottom task dock keeps **Work**, **Find**, **New**, diff --git a/frontend/context-poller.js b/frontend/context-poller.js index 4159a70..0fbbf87 100644 --- a/frontend/context-poller.js +++ b/frontend/context-poller.js @@ -10,6 +10,8 @@ function createContextPoller({ let inFlight = null; let timer = null; let stopped = false; + let revisions = {}; + let retainedSnapshot = null; function cancelTimer() { if (timer !== null) clearTimer(timer); @@ -31,14 +33,19 @@ function createContextPoller({ let request; try { - request = fetchContext(); + request = fetchContext({ ...revisions }); } catch (error) { request = Promise.reject(error); } inFlight = Promise.resolve(request) .then((snapshot) => { - onSnapshot(snapshot); - return snapshot; + const changedSections = ['context', 'events', 'notifications'].filter( + (section) => Object.prototype.hasOwnProperty.call(snapshot, section) + ); + retainedSnapshot = retainedSnapshot ? { ...retainedSnapshot, ...snapshot } : { ...snapshot }; + revisions = { ...revisions, ...(snapshot.revisions || {}) }; + onSnapshot(retainedSnapshot, changedSections); + return retainedSnapshot; }) .catch((error) => { onError(error); diff --git a/frontend/index.html b/frontend/index.html index c7863e7..3128909 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1004,8 +1004,15 @@ textarea { resize: vertical; min-height: 120px; } function setClock() { qs('#clock').textContent = fmt(new Date()); } setClock(); setInterval(setClock, 1000); - async function fetchLiveSnapshot() { - const res = await fetch('api/v1/live', { headers: { Accept: 'application/json' } }); + async function fetchLiveSnapshot(revisions = {}) { + const params = new URLSearchParams(); + Object.entries(revisions).forEach(([section, revision]) => { + if (Number.isInteger(revision) && revision >= 0) params.set(section + '_revision', revision); + }); + const query = params.toString(); + const res = await fetch('api/v1/live' + (query ? '?' + query : ''), { + headers: { Accept: 'application/json' }, + }); if (!res.ok) throw new Error('HTTP ' + res.status); return res.json(); } @@ -2445,15 +2452,19 @@ textarea { resize: vertical; min-height: 120px; } qs('#gitea-events-status').textContent = message; } - function renderLiveSnapshot(snapshot) { + function renderLiveSnapshot(snapshot, changedSections = ['context', 'events', 'notifications']) { setOfflineWorkMode(false); offlineStatus.hidden = true; const contextFreshness = snapshot.freshness?.sections?.context; const eventsFreshness = snapshot.freshness?.sections?.events; const notificationFreshness = snapshot.freshness?.sections?.notifications; + const contextChanged = changedSections.includes('context'); + const notificationsChanged = changedSections.includes('notifications'); + const eventsChanged = changedSections.includes('events'); + const workChanged = contextChanged || notificationsChanged; const hasNotifications = Array.isArray(snapshot.notifications); const notificationsFresh = hasNotifications && !notificationFreshness?.stale; - if (hasNotifications) { + if (notificationsChanged && hasNotifications) { if (notificationPagination.page > 1) { const byId = new Map(lastNotifications.map(item => [item.id, item])); snapshot.notifications.forEach(item => byId.set(item.id, item)); @@ -2471,7 +2482,7 @@ textarea { resize: vertical; min-height: 120px; } }); } } - if (snapshot.context) { + if (snapshot.context && workChanged) { setOfflineWorkMode(false); const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale && !contextFreshness?.degraded && !contextFreshness?.revalidating; @@ -2494,8 +2505,8 @@ textarea { resize: vertical; min-height: 120px; } } flushIssueOutbox(); flushAuthoredOutbox(); - } else handleContextError(new Error('Context section unavailable')); - if (Array.isArray(snapshot.events)) paintEventStream(snapshot.events); + } else if (!snapshot.context) handleContextError(new Error('Context section unavailable')); + if (eventsChanged && Array.isArray(snapshot.events)) paintEventStream(snapshot.events); if (eventsFreshness?.revalidating) { setEventStreamStatus('Refreshing activity · showing last activity'); } else if (eventsFreshness?.stale || eventsFreshness?.degraded) { diff --git a/frontend/service-worker.js b/frontend/service-worker.js index bea9e98..9a7e74b 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-v22'; +const CACHE = 'stackchain-dashboard-shell-v23'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const SHELL = [ BASE, diff --git a/src/main.py b/src/main.py index 500de7c..a5bc35c 100644 --- a/src/main.py +++ b/src/main.py @@ -98,6 +98,9 @@ _live_section_retry_at: dict[str, float | None] = { section: None for section in LIVE_SNAPSHOT_SECTIONS } _live_snapshot_refreshing_sections: set[str] = set() +_live_section_revisions: dict[str, int] = { + section: 0 for section in LIVE_SNAPSHOT_SECTIONS +} _read_notification_ids: set[int] = set() _authored_action_operations: dict[ str, tuple[tuple[Any, ...], asyncio.Task, float] @@ -1082,8 +1085,19 @@ async def _refresh_live_snapshot(sections: set[str]) -> dict: _live_section_retry_at[section] = None else: _record_live_section_failure(section) - result = _merge_live_snapshot(_live_snapshot_value, refreshed) + previous = _live_snapshot_value + result = _merge_live_snapshot(previous, refreshed) result = _without_read_notifications(result) + for section in sections: + if section not in refreshed_states: + continue + changed = previous is None or previous.get(section) != result.get(section) + if section == "notifications": + changed = changed or previous is None or previous.get( + "notification_pagination" + ) != result.get("notification_pagination") + if changed: + _live_section_revisions[section] += 1 _live_snapshot_value = result successful_times = [value for value in _live_section_created_at.values() if value is not None] _live_snapshot_created_at = max(successful_times) if successful_times else None @@ -1106,7 +1120,13 @@ def _start_live_snapshot_refresh(sections: set[str]) -> asyncio.Task: return _live_snapshot_task -def _live_snapshot_payload(value: dict, *, stale: bool, revalidating: bool) -> dict: +def _live_snapshot_payload( + value: dict, + *, + stale: bool, + revalidating: bool, + known_revisions: dict[str, int | None] | None = None, +) -> dict: payload = dict(value) now = time.monotonic() section_freshness = {} @@ -1138,6 +1158,13 @@ def _live_snapshot_payload(value: dict, *, stale: bool, revalidating: bool) -> d "retry_in_seconds": min(retries, default=0), "sections": section_freshness, } + payload["revisions"] = dict(_live_section_revisions) + for section, known_revision in (known_revisions or {}).items(): + if known_revision is None or known_revision != _live_section_revisions[section]: + continue + payload.pop(section, None) + if section == "notifications": + payload.pop("notification_pagination", None) return payload @@ -1155,6 +1182,8 @@ def _remove_notification_from_live_snapshot(thread_id: int) -> None: for notification in retained_notifications if not isinstance(notification, dict) or notification.get("id") != thread_id ] + if updated["notifications"] != retained_notifications: + _live_section_revisions["notifications"] += 1 _live_snapshot_value = updated @@ -1180,9 +1209,18 @@ def _without_read_notifications(snapshot: dict) -> dict: @app.get("/api/v1/live") -async def live_snapshot() -> JSONResponse: +async def live_snapshot( + context_revision: int | None = Query(default=None, ge=0), + events_revision: int | None = Query(default=None, ge=0), + notifications_revision: int | None = Query(default=None, ge=0), +) -> JSONResponse: """Return a freshness-bounded snapshot and share identical upstream loads.""" global _live_snapshot_task, _live_snapshot_value, _live_snapshot_created_at + known_revisions = { + "context": context_revision, + "events": events_revision, + "notifications": notifications_revision, + } now = time.monotonic() due_sections = _due_live_sections(now) if ( @@ -1197,19 +1235,28 @@ async def live_snapshot() -> JSONResponse: for state in _live_snapshot_value.get("sections", {}).values() ), revalidating=False, + known_revisions=known_revisions, ) ) task = _start_live_snapshot_refresh(due_sections) if _live_snapshot_value is not None: return JSONResponse( _live_snapshot_payload( - _live_snapshot_value, stale=True, revalidating=True + _live_snapshot_value, + stale=True, + revalidating=True, + known_revisions=known_revisions, ) ) try: result = await asyncio.shield(task) return JSONResponse( - _live_snapshot_payload(result, stale=False, revalidating=False) + _live_snapshot_payload( + result, + stale=False, + revalidating=False, + known_revisions=known_revisions, + ) ) except TimeoutError: return JSONResponse( diff --git a/tests/test_context_polling.py b/tests/test_context_polling.py index 52497d0..4fe4afb 100644 --- a/tests/test_context_polling.py +++ b/tests/test_context_polling.py @@ -63,3 +63,54 @@ const poller = createContextPoller({{ "callsWhileHidden": 1, "callsAfterResume": 2, } + + +def test_context_poller_sends_revisions_and_merges_changed_sections(): + script = f""" +const createContextPoller = require({json.dumps(str(POLLER))}); +const requested = []; +const rendered = []; +const responses = [ + {{ context: {{ user: {{ login: 'timmy' }} }}, events: [{{ id: 1 }}], notifications: [], + sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }}, + revisions: {{ context: 1, events: 1, notifications: 1 }}, freshness: {{ age_seconds: 0 }} }}, + {{ sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }}, + revisions: {{ context: 1, events: 2, notifications: 1 }}, events: [{{ id: 2 }}], + freshness: {{ age_seconds: 1 }} }}, + {{ sections: {{ context: 'fresh', events: 'fresh', notifications: 'fresh' }}, + revisions: {{ context: 1, events: 2, notifications: 1 }}, freshness: {{ age_seconds: 2 }} }}, +]; +const poller = createContextPoller({{ + fetchContext: revisions => {{ requested.push({{ ...revisions }}); return Promise.resolve(responses.shift()); }}, + onSnapshot: (snapshot, changed) => rendered.push({{ + context: snapshot.context.user.login, + event: snapshot.events[0].id, + age: snapshot.freshness.age_seconds, + changed, + }}), + onError: error => {{ throw error; }}, + setTimer: () => 1, + clearTimer: () => {{}}, +}}); +(async () => {{ + await poller.refresh(); + await poller.refresh(); + await poller.refresh(); + process.stdout.write(JSON.stringify({{ requested, rendered }})); +}})(); +""" + + assert run_node(script) == { + "requested": [ + {}, + {"context": 1, "events": 1, "notifications": 1}, + {"context": 1, "events": 2, "notifications": 1}, + ], + "rendered": [ + {"context": "timmy", "event": 1, "age": 0, + "changed": ["context", "events", "notifications"]}, + {"context": "timmy", "event": 2, "age": 1, + "changed": ["events"]}, + {"context": "timmy", "event": 2, "age": 2, "changed": []}, + ], + } diff --git a/tests/test_live_snapshot.py b/tests/test_live_snapshot.py index 9ce5193..8d062d7 100644 --- a/tests/test_live_snapshot.py +++ b/tests/test_live_snapshot.py @@ -21,6 +21,9 @@ def reset_live_snapshot_task(): section: None for section in main.LIVE_SNAPSHOT_SECTIONS } main._live_snapshot_refreshing_sections = set() + main._live_section_revisions = { + section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS + } yield main._live_snapshot_task = None main._live_snapshot_value = None @@ -35,6 +38,9 @@ def reset_live_snapshot_task(): section: None for section in main.LIVE_SNAPSHOT_SECTIONS } main._live_snapshot_refreshing_sections = set() + main._live_section_revisions = { + section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS + } def payload(response): @@ -84,6 +90,43 @@ async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(mon assert result["sections"] == { "context": "fresh", "events": "fresh", "notifications": "fresh" } + assert result["revisions"] == { + "context": 1, "events": 1, "notifications": 1 + } + + +@pytest.mark.anyio +async def test_live_snapshot_omits_section_bodies_at_known_revisions(monkeypatch): + async def user(): + return {"id": 1, "login": "timmy"} + + async def empty(): + return [] + + async def events(_authenticated_user): + return [{"type": "push"}] + + monkeypatch.setattr(main, "current_user", user) + monkeypatch.setattr(main, "repos", empty) + monkeypatch.setattr(main, "issues", empty) + monkeypatch.setattr(main, "pull_requests", empty) + monkeypatch.setattr(main, "activity_events", events) + monkeypatch.setattr(main, "notifications", empty) + + first = payload(await main.live_snapshot()) + second = payload(await main.live_snapshot( + context_revision=first["revisions"]["context"], + events_revision=first["revisions"]["events"], + notifications_revision=first["revisions"]["notifications"], + )) + + assert "context" not in second + assert "events" not in second + assert "notifications" not in second + assert "notification_pagination" not in second + assert second["sections"] == first["sections"] + assert second["revisions"] == first["revisions"] + assert "freshness" in second @pytest.mark.anyio diff --git a/tests/test_offline_work.py b/tests/test_offline_work.py index 976dcb1..05bbada 100644 --- a/tests/test_offline_work.py +++ b/tests/test_offline_work.py @@ -112,4 +112,5 @@ async def test_initial_http_outage_hydrates_saved_work_and_recovers_on_live_snap assert "Outage · saved " in html assert "Server unavailable · showing private My Work saved " in html assert "Live details and actions will return automatically." in html - assert "function renderLiveSnapshot(snapshot) {\n setOfflineWorkMode(false);\n offlineStatus.hidden = true;" in html + assert "function renderLiveSnapshot(snapshot, changedSections" in html + assert "setOfflineWorkMode(false);\n offlineStatus.hidden = true;" in html diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 3be7467..5eb2298 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -91,10 +91,10 @@ async function dispatchNotificationClick(route) {{ return json.loads(completed.stdout) -def test_edited_issue_retry_ships_in_a_new_shell_cache(): +def test_live_section_revisions_ship_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v22" in source + assert "stackchain-dashboard-shell-v23" in source assert "BASE + 'static/today-work.js'" in source diff --git a/tests/test_shared_context_snapshot.py b/tests/test_shared_context_snapshot.py index d093e1e..1acf70c 100644 --- a/tests/test_shared_context_snapshot.py +++ b/tests/test_shared_context_snapshot.py @@ -22,7 +22,9 @@ async def test_one_live_snapshot_updates_work_and_activity_on_one_timer(): html = await dashboard() assert "fetch('api/v1/live'" in html - assert "renderLiveSnapshot(snapshot)" in html + assert "onSnapshot: renderLiveSnapshot" in html + assert "section + '_revision'" in html + assert "workChanged = contextChanged || notificationsChanged" in html assert "renderContextSnapshot(snapshot.context)" in html assert "paintEventStream(snapshot.events)" in html assert "loadEventStream" not in html -- 2.43.0