Merge pull request 'Bound mobile Updates discovery with one atomic snapshot' (#754) from timmy/753-atomic-updates-snapshot into main
All checks were successful
CI / lint (push) Successful in 1m40s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s

This commit is contained in:
timmy 2026-08-13 16:27:32 +00:00
commit d2a85cd5ea
5 changed files with 110 additions and 3 deletions

View File

@ -1008,7 +1008,15 @@
const updateTriageLauncher = createUpdateTriageLauncher({
selectUpdates: () => selectMobileQueue('update'),
discover: () => notificationPager.loadAll(() => lastNotifications),
discover: () => api('api/v1/notifications/snapshot'),
applySnapshot: snapshot => {
lastNotifications = snapshot.items || [];
notificationPager.reset({page:1, total:snapshot.total || 0, has_more:false});
if (lastContextSnapshot) {
lastContextSnapshot.notifications = lastNotifications;
paintMyWork(lastContextSnapshot);
}
},
isUpdatesSelected: () => selectedWorkFilter === 'update',
hasMore: () => Boolean(notificationPagination.has_more),
hasCheckpoint: () => updateTriage.resumable(),

View File

@ -10,9 +10,14 @@
options.announce('Checking all unread updates…');
pending = Promise.resolve()
.then(() => options.discover())
.then(complete => {
.then(discovery => {
if (!options.isUpdatesSelected()) return 'cancelled';
if (complete === false || options.hasMore()) {
if (discovery === false || discovery?.complete === false) {
options.announce('Updates check paused. Retry to check older unread updates.');
return 'incomplete';
}
if (discovery?.complete) options.applySnapshot?.(discovery);
if (options.hasMore()) {
options.announce('Updates check paused. Retry to check older unread updates.');
return 'incomplete';
}

View File

@ -3592,6 +3592,21 @@ async def notification_page(page: int = Query(default=1, ge=1)) -> JSONResponse:
return JSONResponse(result)
@app.get("/api/v1/notifications/snapshot")
async def notification_snapshot() -> JSONResponse:
try:
result = await gitea_proxy.unread_notification_snapshot(
deadline_seconds=NOTIFICATION_PAGE_TIMEOUT_SECONDS
)
except Exception:
return JSONResponse(
{"error": "Complete unread updates are temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.get("/api/v1/notifications/{thread_id}")
async def notification_thread_detail(
thread_id: int = PathParam(gt=0),

View File

@ -113,6 +113,33 @@ launcher.open().then(result => process.stdout.write(JSON.stringify({{result, res
}
def test_update_triage_launch_applies_atomic_snapshot_before_starting():
script = f"""
const createLauncher = require({json.dumps(str(UPDATE_TRIAGE_LAUNCHER))});
const events = [];
const snapshot = {{items:[{{id:1}},{{id:51}}],total:2,complete:true}};
const launcher = createLauncher({{
selectUpdates: () => events.push('selected'),
discover: async () => snapshot,
applySnapshot: value => events.push(['applied', value.items.map(item => item.id)]),
isUpdatesSelected: () => true,
hasMore: () => false,
hasCheckpoint: () => false,
resume: () => {{ throw new Error('must start'); }},
start: () => {{ events.push('started'); return true; }},
announce: () => {{}},
}});
launcher.open().then(result => process.stdout.write(JSON.stringify({{result, events}})));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"result": "opened",
"events": ["selected", ["applied", [1, 51]], "started"],
}
def test_notification_pager_load_all_retries_from_failed_page_without_duplicates():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});

View File

@ -31,3 +31,55 @@ async def test_notification_page_endpoint_loads_only_requested_page_and_is_not_c
"has_more": True,
}
assert requested == [2]
@pytest.mark.anyio
async def test_notification_snapshot_endpoint_returns_one_complete_atomic_no_store_result(monkeypatch):
requested = []
async def snapshot_loader(*, deadline_seconds):
requested.append(deadline_seconds)
return {
"items": [
{"id": 1, "title": "Newest update"},
{"id": 51, "title": "Older update"},
],
"total": 2,
"complete": True,
}
monkeypatch.setattr(main.gitea_proxy, "unread_notification_snapshot", snapshot_loader)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/notifications/snapshot")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"items": [
{"id": 1, "title": "Newest update"},
{"id": 51, "title": "Older update"},
],
"total": 2,
"complete": True,
}
assert requested == [main.NOTIFICATION_PAGE_TIMEOUT_SECONDS]
@pytest.mark.anyio
@pytest.mark.parametrize("failure", [TimeoutError(), ValueError("pagination changed")])
async def test_notification_snapshot_endpoint_is_bounded_and_retryable(monkeypatch, failure):
async def snapshot_loader(*, deadline_seconds):
raise failure
monkeypatch.setattr(main.gitea_proxy, "unread_notification_snapshot", snapshot_loader)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/notifications/snapshot")
assert response.status_code == 503
assert response.headers["cache-control"] == "no-store"
assert response.headers["retry-after"] == "1"
assert response.json() == {
"error": "Complete unread updates are temporarily unavailable. Please retry."
}