feat: retain live snapshots while revalidating (#139)
CI / lint (pull_request) Successful in 9s
CI / build-frontend (pull_request) Successful in 5s

This commit is contained in:
timmy committed 2026-08-06 21:32:58 +00:00
1 parent 3807e7afd1
commit ea7bd15721
5 files changed
+225 -13

No files matched your search

+106 -10
View File
@@ -1,5 +1,6 @@
import asyncio
import math
import time
from contextlib import asynccontextmanager
from pathlib import Path
@@ -51,8 +52,12 @@ EVENT_STREAM_TIMEOUT_SECONDS = 5.0
READINESS_TIMEOUT_SECONDS = 5.0
REVIEW_DETAIL_TIMEOUT_SECONDS = 5.0
NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0
LIVE_SNAPSHOT_FRESHNESS_SECONDS = 8.0
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
_live_snapshot_task: asyncio.Task | None = None
_live_snapshot_value: dict | None = None
_live_snapshot_created_at: float | None = None
_read_notification_ids: set[int] = set()
class ContextPayloadError(ValueError):
@@ -268,18 +273,110 @@ async def _build_live_snapshot_before_deadline() -> dict:
return await _build_live_snapshot()
@app.get("/api/v1/live")
async def live_snapshot() -> JSONResponse:
"""Return a fresh, section-aware snapshot; join only an active identical load."""
async def _refresh_live_snapshot() -> dict:
global _live_snapshot_value, _live_snapshot_created_at
result = await _build_live_snapshot_before_deadline()
result = _without_read_notifications(result)
_live_snapshot_value = result
_live_snapshot_created_at = time.monotonic()
return result
def _consume_live_snapshot_failure(task: asyncio.Task) -> None:
if task.cancelled():
return
task.exception()
def _start_live_snapshot_refresh() -> asyncio.Task:
global _live_snapshot_task
if _live_snapshot_task is None or _live_snapshot_task.done():
_live_snapshot_task = asyncio.create_task(
_build_live_snapshot_before_deadline()
_live_snapshot_task = asyncio.create_task(_refresh_live_snapshot())
_live_snapshot_task.add_done_callback(_consume_live_snapshot_failure)
return _live_snapshot_task
def _live_snapshot_payload(value: dict, *, stale: bool, revalidating: bool) -> dict:
payload = dict(value)
age = (
max(0.0, time.monotonic() - _live_snapshot_created_at)
if _live_snapshot_created_at is not None
else 0.0
)
payload["freshness"] = {
"age_seconds": round(age, 3),
"fresh_for_seconds": LIVE_SNAPSHOT_FRESHNESS_SECONDS,
"stale": stale,
"revalidating": revalidating,
}
return payload
def _remove_notification_from_live_snapshot(thread_id: int) -> None:
global _live_snapshot_value, _read_notification_ids
_read_notification_ids = _read_notification_ids | {thread_id}
if _live_snapshot_value is None:
return
retained_notifications = _live_snapshot_value.get("notifications")
if not isinstance(retained_notifications, list):
return
updated = dict(_live_snapshot_value)
updated["notifications"] = [
notification
for notification in retained_notifications
if not isinstance(notification, dict) or notification.get("id") != thread_id
]
_live_snapshot_value = updated
def _without_read_notifications(snapshot: dict) -> dict:
global _read_notification_ids
snapshot_notifications = snapshot.get("notifications")
if not isinstance(snapshot_notifications, list):
return snapshot
returned_ids = {
notification.get("id")
for notification in snapshot_notifications
if isinstance(notification, dict)
}
updated = dict(snapshot)
updated["notifications"] = [
notification
for notification in snapshot_notifications
if not isinstance(notification, dict)
or notification.get("id") not in _read_notification_ids
]
_read_notification_ids = _read_notification_ids.intersection(returned_ids)
return updated
@app.get("/api/v1/live")
async def live_snapshot() -> JSONResponse:
"""Return a freshness-bounded snapshot and share identical upstream loads."""
global _live_snapshot_task, _live_snapshot_value, _live_snapshot_created_at
now = time.monotonic()
if (
_live_snapshot_value is not None
and _live_snapshot_created_at is not None
and now - _live_snapshot_created_at < LIVE_SNAPSHOT_FRESHNESS_SECONDS
):
return JSONResponse(
_live_snapshot_payload(
_live_snapshot_value, stale=False, revalidating=False
)
)
task = _start_live_snapshot_refresh()
if _live_snapshot_value is not None:
return JSONResponse(
_live_snapshot_payload(
_live_snapshot_value, stale=True, revalidating=True
)
)
task = _live_snapshot_task
try:
result = await asyncio.shield(task)
return JSONResponse(result)
return JSONResponse(
_live_snapshot_payload(result, stale=False, revalidating=False)
)
except TimeoutError:
return JSONResponse(
{"error": f"Gitea live snapshot timed out after {CONTEXT_TIMEOUT_SECONDS:g}s"},
@@ -291,9 +388,7 @@ async def live_snapshot() -> JSONResponse:
{"error": "Gitea live snapshot is temporarily unavailable"},
status_code=503,
)
finally:
if task.done() and _live_snapshot_task is task:
_live_snapshot_task = None
@app.get("/api/v1/events")
@@ -347,6 +442,7 @@ async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
{"error": "The update could not be marked read. Please retry."},
status_code=503,
)
_remove_notification_from_live_snapshot(thread_id)
return JSONResponse({"id": thread_id, "status": "read"})