perf: skip unchanged live snapshot sections (#285)
All checks were successful
CI / lint (pull_request) Successful in 25s
CI / build-frontend (pull_request) Successful in 5s

This commit is contained in:
timmy 2026-08-08 09:35:48 +00:00
parent e28472b268
commit fedddb246d
10 changed files with 188 additions and 20 deletions

View File

@ -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, below the route deadlines. Streaming diff reads share the same read capacity,
while POST, PATCH, PUT, and DELETE requests are never coalesced. 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 ## Offline mobile shell
At phone widths, a persistent bottom task dock keeps **Work**, **Find**, **New**, At phone widths, a persistent bottom task dock keeps **Work**, **Find**, **New**,

View File

@ -10,6 +10,8 @@ function createContextPoller({
let inFlight = null; let inFlight = null;
let timer = null; let timer = null;
let stopped = false; let stopped = false;
let revisions = {};
let retainedSnapshot = null;
function cancelTimer() { function cancelTimer() {
if (timer !== null) clearTimer(timer); if (timer !== null) clearTimer(timer);
@ -31,14 +33,19 @@ function createContextPoller({
let request; let request;
try { try {
request = fetchContext(); request = fetchContext({ ...revisions });
} catch (error) { } catch (error) {
request = Promise.reject(error); request = Promise.reject(error);
} }
inFlight = Promise.resolve(request) inFlight = Promise.resolve(request)
.then((snapshot) => { .then((snapshot) => {
onSnapshot(snapshot); const changedSections = ['context', 'events', 'notifications'].filter(
return snapshot; (section) => Object.prototype.hasOwnProperty.call(snapshot, section)
);
retainedSnapshot = retainedSnapshot ? { ...retainedSnapshot, ...snapshot } : { ...snapshot };
revisions = { ...revisions, ...(snapshot.revisions || {}) };
onSnapshot(retainedSnapshot, changedSections);
return retainedSnapshot;
}) })
.catch((error) => { .catch((error) => {
onError(error); onError(error);

View File

@ -1004,8 +1004,15 @@ textarea { resize: vertical; min-height: 120px; }
function setClock() { qs('#clock').textContent = fmt(new Date()); } function setClock() { qs('#clock').textContent = fmt(new Date()); }
setClock(); setInterval(setClock, 1000); setClock(); setInterval(setClock, 1000);
async function fetchLiveSnapshot() { async function fetchLiveSnapshot(revisions = {}) {
const res = await fetch('api/v1/live', { headers: { Accept: 'application/json' } }); 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); if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json(); return res.json();
} }
@ -2445,15 +2452,19 @@ textarea { resize: vertical; min-height: 120px; }
qs('#gitea-events-status').textContent = message; qs('#gitea-events-status').textContent = message;
} }
function renderLiveSnapshot(snapshot) { function renderLiveSnapshot(snapshot, changedSections = ['context', 'events', 'notifications']) {
setOfflineWorkMode(false); setOfflineWorkMode(false);
offlineStatus.hidden = true; offlineStatus.hidden = true;
const contextFreshness = snapshot.freshness?.sections?.context; const contextFreshness = snapshot.freshness?.sections?.context;
const eventsFreshness = snapshot.freshness?.sections?.events; const eventsFreshness = snapshot.freshness?.sections?.events;
const notificationFreshness = snapshot.freshness?.sections?.notifications; 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 hasNotifications = Array.isArray(snapshot.notifications);
const notificationsFresh = hasNotifications && !notificationFreshness?.stale; const notificationsFresh = hasNotifications && !notificationFreshness?.stale;
if (hasNotifications) { if (notificationsChanged && hasNotifications) {
if (notificationPagination.page > 1) { if (notificationPagination.page > 1) {
const byId = new Map(lastNotifications.map(item => [item.id, item])); const byId = new Map(lastNotifications.map(item => [item.id, item]));
snapshot.notifications.forEach(item => byId.set(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); setOfflineWorkMode(false);
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale && const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
!contextFreshness?.degraded && !contextFreshness?.revalidating; !contextFreshness?.degraded && !contextFreshness?.revalidating;
@ -2494,8 +2505,8 @@ textarea { resize: vertical; min-height: 120px; }
} }
flushIssueOutbox(); flushIssueOutbox();
flushAuthoredOutbox(); flushAuthoredOutbox();
} else handleContextError(new Error('Context section unavailable')); } else if (!snapshot.context) handleContextError(new Error('Context section unavailable'));
if (Array.isArray(snapshot.events)) paintEventStream(snapshot.events); if (eventsChanged && Array.isArray(snapshot.events)) paintEventStream(snapshot.events);
if (eventsFreshness?.revalidating) { if (eventsFreshness?.revalidating) {
setEventStreamStatus('Refreshing activity · showing last activity'); setEventStreamStatus('Refreshing activity · showing last activity');
} else if (eventsFreshness?.stale || eventsFreshness?.degraded) { } else if (eventsFreshness?.stale || eventsFreshness?.degraded) {

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js'); 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 OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const SHELL = [ const SHELL = [
BASE, BASE,

View File

@ -98,6 +98,9 @@ _live_section_retry_at: dict[str, float | None] = {
section: None for section in LIVE_SNAPSHOT_SECTIONS section: None for section in LIVE_SNAPSHOT_SECTIONS
} }
_live_snapshot_refreshing_sections: set[str] = set() _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() _read_notification_ids: set[int] = set()
_authored_action_operations: dict[ _authored_action_operations: dict[
str, tuple[tuple[Any, ...], asyncio.Task, float] 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 _live_section_retry_at[section] = None
else: else:
_record_live_section_failure(section) _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) 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 _live_snapshot_value = result
successful_times = [value for value in _live_section_created_at.values() if value is not None] 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 _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 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) payload = dict(value)
now = time.monotonic() now = time.monotonic()
section_freshness = {} section_freshness = {}
@ -1138,6 +1158,13 @@ def _live_snapshot_payload(value: dict, *, stale: bool, revalidating: bool) -> d
"retry_in_seconds": min(retries, default=0), "retry_in_seconds": min(retries, default=0),
"sections": section_freshness, "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 return payload
@ -1155,6 +1182,8 @@ def _remove_notification_from_live_snapshot(thread_id: int) -> None:
for notification in retained_notifications for notification in retained_notifications
if not isinstance(notification, dict) or notification.get("id") != thread_id 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 _live_snapshot_value = updated
@ -1180,9 +1209,18 @@ def _without_read_notifications(snapshot: dict) -> dict:
@app.get("/api/v1/live") @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.""" """Return a freshness-bounded snapshot and share identical upstream loads."""
global _live_snapshot_task, _live_snapshot_value, _live_snapshot_created_at 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() now = time.monotonic()
due_sections = _due_live_sections(now) due_sections = _due_live_sections(now)
if ( if (
@ -1197,19 +1235,28 @@ async def live_snapshot() -> JSONResponse:
for state in _live_snapshot_value.get("sections", {}).values() for state in _live_snapshot_value.get("sections", {}).values()
), ),
revalidating=False, revalidating=False,
known_revisions=known_revisions,
) )
) )
task = _start_live_snapshot_refresh(due_sections) task = _start_live_snapshot_refresh(due_sections)
if _live_snapshot_value is not None: if _live_snapshot_value is not None:
return JSONResponse( return JSONResponse(
_live_snapshot_payload( _live_snapshot_payload(
_live_snapshot_value, stale=True, revalidating=True _live_snapshot_value,
stale=True,
revalidating=True,
known_revisions=known_revisions,
) )
) )
try: try:
result = await asyncio.shield(task) result = await asyncio.shield(task)
return JSONResponse( return JSONResponse(
_live_snapshot_payload(result, stale=False, revalidating=False) _live_snapshot_payload(
result,
stale=False,
revalidating=False,
known_revisions=known_revisions,
)
) )
except TimeoutError: except TimeoutError:
return JSONResponse( return JSONResponse(

View File

@ -63,3 +63,54 @@ const poller = createContextPoller({{
"callsWhileHidden": 1, "callsWhileHidden": 1,
"callsAfterResume": 2, "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": []},
],
}

View File

@ -21,6 +21,9 @@ def reset_live_snapshot_task():
section: None for section in main.LIVE_SNAPSHOT_SECTIONS section: None for section in main.LIVE_SNAPSHOT_SECTIONS
} }
main._live_snapshot_refreshing_sections = set() main._live_snapshot_refreshing_sections = set()
main._live_section_revisions = {
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
}
yield yield
main._live_snapshot_task = None main._live_snapshot_task = None
main._live_snapshot_value = None main._live_snapshot_value = None
@ -35,6 +38,9 @@ def reset_live_snapshot_task():
section: None for section in main.LIVE_SNAPSHOT_SECTIONS section: None for section in main.LIVE_SNAPSHOT_SECTIONS
} }
main._live_snapshot_refreshing_sections = set() main._live_snapshot_refreshing_sections = set()
main._live_section_revisions = {
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
}
def payload(response): def payload(response):
@ -84,6 +90,43 @@ async def test_live_snapshot_fetches_user_once_and_updates_work_and_activity(mon
assert result["sections"] == { assert result["sections"] == {
"context": "fresh", "events": "fresh", "notifications": "fresh" "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 @pytest.mark.anyio

View File

@ -112,4 +112,5 @@ async def test_initial_http_outage_hydrates_saved_work_and_recovers_on_live_snap
assert "Outage · saved " in html assert "Outage · saved " in html
assert "Server unavailable · showing private My Work saved " in html assert "Server unavailable · showing private My Work saved " in html
assert "Live details and actions will return automatically." 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

View File

@ -91,10 +91,10 @@ async function dispatchNotificationClick(route) {{
return json.loads(completed.stdout) 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() 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 assert "BASE + 'static/today-work.js'" in source

View File

@ -22,7 +22,9 @@ async def test_one_live_snapshot_updates_work_and_activity_on_one_timer():
html = await dashboard() html = await dashboard()
assert "fetch('api/v1/live'" in html 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 "renderContextSnapshot(snapshot.context)" in html
assert "paintEventStream(snapshot.events)" in html assert "paintEventStream(snapshot.events)" in html
assert "loadEventStream" not in html assert "loadEventStream" not in html