feat: isolate live section refresh backoff (#167)
This commit is contained in:
1 parent
5fe0b28747
commit
4870b58c5b
5 files changed
+262
-100
No files matched your search
+90
-10
@@ -11,14 +11,30 @@ def reset_live_snapshot_task():
|
||||
main._live_snapshot_task = None
|
||||
main._live_snapshot_value = None
|
||||
main._live_snapshot_created_at = None
|
||||
main._live_snapshot_failure_count = 0
|
||||
main._live_snapshot_retry_at = None
|
||||
main._live_section_created_at = {
|
||||
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
main._live_section_failure_count = {
|
||||
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
main._live_section_retry_at = {
|
||||
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
main._live_snapshot_refreshing_sections = set()
|
||||
yield
|
||||
main._live_snapshot_task = None
|
||||
main._live_snapshot_value = None
|
||||
main._live_snapshot_created_at = None
|
||||
main._live_snapshot_failure_count = 0
|
||||
main._live_snapshot_retry_at = None
|
||||
main._live_section_created_at = {
|
||||
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
main._live_section_failure_count = {
|
||||
section: 0 for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
main._live_section_retry_at = {
|
||||
section: None for section in main.LIVE_SNAPSHOT_SECTIONS
|
||||
}
|
||||
main._live_snapshot_refreshing_sections = set()
|
||||
|
||||
|
||||
def payload(response):
|
||||
@@ -209,7 +225,7 @@ async def test_stale_snapshot_returns_immediately_while_one_refresh_revalidates(
|
||||
release_refresh = asyncio.Event()
|
||||
builds = 0
|
||||
|
||||
async def snapshot():
|
||||
async def snapshot(_sections=None):
|
||||
nonlocal builds
|
||||
builds += 1
|
||||
if builds == 2:
|
||||
@@ -247,7 +263,7 @@ async def test_failed_revalidation_enters_cooldown_and_keeps_last_snapshot(monke
|
||||
now = 100.0
|
||||
builds = 0
|
||||
|
||||
async def snapshot():
|
||||
async def snapshot(_sections=None):
|
||||
nonlocal builds
|
||||
builds += 1
|
||||
if builds > 1:
|
||||
@@ -288,7 +304,7 @@ async def test_partial_refresh_updates_fresh_sections_and_retains_failed_section
|
||||
now = 100.0
|
||||
builds = 0
|
||||
|
||||
async def snapshot():
|
||||
async def snapshot(_sections=None):
|
||||
nonlocal builds
|
||||
builds += 1
|
||||
if builds == 1:
|
||||
@@ -332,6 +348,70 @@ async def test_partial_refresh_updates_fresh_sections_and_retains_failed_section
|
||||
assert result["freshness"]["revalidating"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_cooldown_does_not_stop_due_work_and_activity_refreshes(monkeypatch):
|
||||
now = 100.0
|
||||
calls = {"context": 0, "events": 0, "notifications": 0}
|
||||
event_refresh_times = []
|
||||
notification_outage = False
|
||||
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def work():
|
||||
calls["context"] += 1
|
||||
return []
|
||||
|
||||
async def empty_work():
|
||||
return []
|
||||
|
||||
async def events(_authenticated_user):
|
||||
calls["events"] += 1
|
||||
event_refresh_times.append(now)
|
||||
return [{"generation": calls["events"]}]
|
||||
|
||||
async def updates():
|
||||
calls["notifications"] += 1
|
||||
if notification_outage:
|
||||
raise ConnectionError("notifications unavailable")
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(main.time, "monotonic", lambda: now)
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", work)
|
||||
monkeypatch.setattr(main, "issues", empty_work)
|
||||
monkeypatch.setattr(main, "pull_requests", empty_work)
|
||||
monkeypatch.setattr(main, "activity_events", events)
|
||||
monkeypatch.setattr(main, "notifications", updates)
|
||||
|
||||
await main.live_snapshot()
|
||||
notification_outage = True
|
||||
|
||||
for elapsed in (
|
||||
main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1,
|
||||
main.LIVE_SNAPSHOT_RETRY_BASE_SECONDS,
|
||||
):
|
||||
now += elapsed
|
||||
await main.live_snapshot()
|
||||
assert main._live_snapshot_task is not None
|
||||
await main._live_snapshot_task
|
||||
|
||||
# Notification backoff is now 10 seconds. The healthy sections become due
|
||||
# one second before that cooldown expires and must refresh independently.
|
||||
now += main.LIVE_SNAPSHOT_FRESHNESS_SECONDS + 1
|
||||
response = payload(await main.live_snapshot())
|
||||
assert main._live_snapshot_task is not None
|
||||
await main._live_snapshot_task
|
||||
refreshed = payload(await main.live_snapshot())
|
||||
|
||||
assert calls == {"context": 3, "events": 3, "notifications": 3}
|
||||
assert event_refresh_times == [100.0, 109.0, 123.0]
|
||||
assert response["freshness"]["sections"]["context"]["revalidating"] is True
|
||||
assert refreshed["events"] == [{"generation": 3}]
|
||||
assert refreshed["sections"]["notifications"] == "stale"
|
||||
assert refreshed["freshness"]["sections"]["notifications"]["retry_in_seconds"] == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_retry_window_starts_one_refresh_and_success_clears_degraded_state(monkeypatch):
|
||||
now = 100.0
|
||||
@@ -339,7 +419,7 @@ async def test_retry_window_starts_one_refresh_and_success_clears_degraded_state
|
||||
retry_started = asyncio.Event()
|
||||
release_retry = asyncio.Event()
|
||||
|
||||
async def snapshot():
|
||||
async def snapshot(_sections=None):
|
||||
nonlocal builds
|
||||
builds += 1
|
||||
if builds == 1:
|
||||
@@ -399,7 +479,7 @@ async def test_cancelling_one_waiter_does_not_cancel_the_shared_snapshot(monkeyp
|
||||
release = asyncio.Event()
|
||||
snapshot_calls = 0
|
||||
|
||||
async def blocked_snapshot():
|
||||
async def blocked_snapshot(_sections=None):
|
||||
nonlocal snapshot_calls
|
||||
snapshot_calls += 1
|
||||
started.set()
|
||||
@@ -437,7 +517,7 @@ async def test_shared_snapshot_deadline_cancels_upstream_work_for_all_waiters(mo
|
||||
started = asyncio.Event()
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def snapshot_that_exceeds_deadline():
|
||||
async def snapshot_that_exceeds_deadline(_sections=None):
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
|
||||
@@ -214,7 +214,7 @@ async def test_inflight_refresh_cannot_restore_a_notification_marked_read(monkey
|
||||
async def mark(_thread_id):
|
||||
return None
|
||||
|
||||
async def stale_upstream_snapshot():
|
||||
async def stale_upstream_snapshot(_sections):
|
||||
refresh_started.set()
|
||||
await release_refresh.wait()
|
||||
return {
|
||||
@@ -231,7 +231,7 @@ async def test_inflight_refresh_cannot_restore_a_notification_marked_read(monkey
|
||||
monkeypatch.setattr(main, "_live_snapshot_task", None)
|
||||
monkeypatch.setattr(main, "_live_snapshot_value", {"notifications": [{"id": 42}]})
|
||||
|
||||
refresh = main._start_live_snapshot_refresh()
|
||||
refresh = main._start_live_snapshot_refresh(set(main.LIVE_SNAPSHOT_SECTIONS))
|
||||
await refresh_started.wait()
|
||||
await main.read_notification(42)
|
||||
release_refresh.set()
|
||||
|
||||
@@ -44,3 +44,14 @@ async def test_dashboard_announces_failed_refresh_and_retry_without_blanking_pan
|
||||
assert "snapshot.freshness?.degraded" in html
|
||||
assert "Refresh failed · showing last known data" in html
|
||||
assert "snapshot.freshness.retry_in_seconds" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_reports_each_live_section_from_its_own_freshness():
|
||||
html = await dashboard()
|
||||
|
||||
assert "snapshot.freshness?.sections?.notifications" in html
|
||||
assert "snapshot.freshness?.sections?.events" in html
|
||||
assert "const notificationsFresh = Array.isArray(snapshot.notifications)" not in html
|
||||
assert "Unread updates unavailable · showing last known updates" in html
|
||||
assert "Activity refresh failed · showing last activity" in html
|
||||
Reference in new issue
Block a user