feat: bound bulk notification fan-out (#145)
This commit is contained in:
parent
955343a428
commit
36d7fb9ce8
38
src/main.py
38
src/main.py
|
|
@ -53,6 +53,8 @@ EVENT_STREAM_TIMEOUT_SECONDS = 5.0
|
||||||
READINESS_TIMEOUT_SECONDS = 5.0
|
READINESS_TIMEOUT_SECONDS = 5.0
|
||||||
REVIEW_DETAIL_TIMEOUT_SECONDS = 5.0
|
REVIEW_DETAIL_TIMEOUT_SECONDS = 5.0
|
||||||
NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0
|
NOTIFICATION_MUTATION_TIMEOUT_SECONDS = 5.0
|
||||||
|
BULK_NOTIFICATION_CONCURRENCY = 5
|
||||||
|
BULK_NOTIFICATION_DEADLINE_SECONDS = 6.0
|
||||||
LIVE_SNAPSHOT_FRESHNESS_SECONDS = 8.0
|
LIVE_SNAPSHOT_FRESHNESS_SECONDS = 8.0
|
||||||
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
|
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
|
||||||
_live_snapshot_task: asyncio.Task | None = None
|
_live_snapshot_task: asyncio.Task | None = None
|
||||||
|
|
@ -444,13 +446,37 @@ async def _mark_notification_read_result(thread_id: int) -> tuple[int, bool]:
|
||||||
@app.patch("/api/v1/notifications/read")
|
@app.patch("/api/v1/notifications/read")
|
||||||
async def read_notifications(batch: NotificationReadBatch) -> JSONResponse:
|
async def read_notifications(batch: NotificationReadBatch) -> JSONResponse:
|
||||||
thread_ids = list(dict.fromkeys(batch.ids))
|
thread_ids = list(dict.fromkeys(batch.ids))
|
||||||
results = await asyncio.gather(
|
|
||||||
*(_mark_notification_read_result(thread_id) for thread_id in thread_ids)
|
semaphore = asyncio.Semaphore(BULK_NOTIFICATION_CONCURRENCY)
|
||||||
|
|
||||||
|
async def mark_within_limit(thread_id: int) -> tuple[int, bool]:
|
||||||
|
async with semaphore:
|
||||||
|
return await _mark_notification_read_result(thread_id)
|
||||||
|
|
||||||
|
tasks = [asyncio.create_task(mark_within_limit(thread_id)) for thread_id in thread_ids]
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
tasks, timeout=BULK_NOTIFICATION_DEADLINE_SECONDS
|
||||||
|
)
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
if pending:
|
||||||
|
await asyncio.gather(*pending, return_exceptions=True)
|
||||||
|
|
||||||
|
succeeded_ids = {
|
||||||
|
thread_id
|
||||||
|
for task in done
|
||||||
|
if not task.cancelled() and task.exception() is None
|
||||||
|
for thread_id, succeeded in [task.result()]
|
||||||
|
if succeeded
|
||||||
|
}
|
||||||
|
failed = [thread_id for thread_id in thread_ids if thread_id not in succeeded_ids]
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"marked": [thread_id for thread_id in thread_ids if thread_id in succeeded_ids],
|
||||||
|
"failed": failed,
|
||||||
|
},
|
||||||
|
headers={"Retry-After": "1"} if failed else None,
|
||||||
)
|
)
|
||||||
return JSONResponse({
|
|
||||||
"marked": [thread_id for thread_id, succeeded in results if succeeded],
|
|
||||||
"failed": [thread_id for thread_id, succeeded in results if not succeeded],
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/api/v1/notifications/{thread_id}/read")
|
@app.patch("/api/v1/notifications/{thread_id}/read")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -77,6 +78,83 @@ async def test_bulk_mark_read_reports_partial_progress_and_retains_only_failures
|
||||||
assert main._live_snapshot_value == {"notifications": [{"id": 43}]}
|
assert main._live_snapshot_value == {"notifications": [{"id": 43}]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_bulk_mark_read_limits_upstream_concurrency(monkeypatch):
|
||||||
|
active = 0
|
||||||
|
peak_active = 0
|
||||||
|
started = asyncio.Event()
|
||||||
|
release = asyncio.Event()
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "_read_notification_ids", set())
|
||||||
|
monkeypatch.setattr(main, "_live_snapshot_value", None)
|
||||||
|
|
||||||
|
async def mark(thread_id):
|
||||||
|
nonlocal active, peak_active
|
||||||
|
calls.append(thread_id)
|
||||||
|
active += 1
|
||||||
|
peak_active = max(peak_active, active)
|
||||||
|
if active == 5:
|
||||||
|
started.set()
|
||||||
|
try:
|
||||||
|
await release.wait()
|
||||||
|
finally:
|
||||||
|
active -= 1
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "mark_notification_read", mark)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
request = asyncio.create_task(
|
||||||
|
client.patch("/api/v1/notifications/read", json={"ids": list(range(1, 51))})
|
||||||
|
)
|
||||||
|
await asyncio.wait_for(started.wait(), timeout=1)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert peak_active == 5
|
||||||
|
assert len(calls) == 5
|
||||||
|
release.set()
|
||||||
|
response = await request
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"marked": list(range(1, 51)), "failed": []}
|
||||||
|
assert sorted(calls) == list(range(1, 51))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_bulk_mark_read_cancels_unfinished_work_at_batch_deadline(monkeypatch):
|
||||||
|
active = 0
|
||||||
|
cancelled = []
|
||||||
|
|
||||||
|
async def mark(thread_id):
|
||||||
|
nonlocal active
|
||||||
|
active += 1
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(0.08)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
cancelled.append(thread_id)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
active -= 1
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "mark_notification_read", mark)
|
||||||
|
monkeypatch.setattr(main, "BULK_NOTIFICATION_DEADLINE_SECONDS", 0.01, raising=False)
|
||||||
|
monkeypatch.setattr(main, "_read_notification_ids", set())
|
||||||
|
monkeypatch.setattr(main, "_live_snapshot_value", None)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
started_at = time.monotonic()
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.patch(
|
||||||
|
"/api/v1/notifications/read", json={"ids": list(range(1, 11))}
|
||||||
|
)
|
||||||
|
elapsed = time.monotonic() - started_at
|
||||||
|
|
||||||
|
assert elapsed < 0.06
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"marked": [], "failed": list(range(1, 11))}
|
||||||
|
assert response.headers["retry-after"] == "1"
|
||||||
|
assert active == 0
|
||||||
|
assert sorted(cancelled) == list(range(1, 6))
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_bulk_mark_read_rejects_empty_invalid_and_oversized_batches(monkeypatch):
|
async def test_bulk_mark_read_rejects_empty_invalid_and_oversized_batches(monkeypatch):
|
||||||
marked = []
|
marked = []
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user