Merge pull request 'Skip unchanged live snapshot sections end to end' (#286) from timmy/285-live-section-revisions into main
This commit is contained in:
commit
94cbde3cf9
|
|
@ -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**,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
57
src/main.py
57
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(
|
||||
|
|
|
|||
|
|
@ -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": []},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user