perf: adapt live polling to server freshness (#465)
This commit is contained in:
1 parent
7639a42d7b
commit
72453b76db
13 files changed
+255
-23
No files matched your search
@@ -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
|
||||
@@ -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))});
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user