Реал-тайм через SSE (push-to-invalidate): живые приглашения/партии, авто-обновление без перезагрузки

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 22:52:41 +03:00
co-authored by Claude Opus 4.8
parent 16452f7bbe
commit 2500ca575b
15 changed files with 357 additions and 9 deletions
+56
View File
@@ -0,0 +1,56 @@
"""In-memory SSE-шина (pub/sub) для пушей «данные изменились».
Рассчитана на один процесс (uvicorn `--workers 1`). При переходе на несколько воркеров
шину нужно вынести во внешний брокер (например, Redis pub/sub).
Публикация вызывается из СИНХРОННЫХ роутеров (FastAPI выполняет их в threadpool), а очереди
подписчиков живут в event-loop'е — поэтому публикация перекидывается в loop через
`call_soon_threadsafe`, а весь доступ к подпискам происходит на потоке loop'а.
"""
from __future__ import annotations
import asyncio
from collections import defaultdict
from typing import Any, Iterable
class EventHub:
def __init__(self) -> None:
self._subs: dict[int, set[asyncio.Queue]] = defaultdict(set)
self._loop: asyncio.AbstractEventLoop | None = None
def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
self._loop = loop
def subscribe(self, user_id: int) -> asyncio.Queue:
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
self._subs[user_id].add(queue)
return queue
def unsubscribe(self, user_id: int, queue: asyncio.Queue) -> None:
subs = self._subs.get(user_id)
if subs is not None:
subs.discard(queue)
if not subs:
self._subs.pop(user_id, None)
def publish(self, user_ids: Iterable[int], event: dict[str, Any]) -> None:
"""Доставить событие подписчикам с указанными user_id (потокобезопасно)."""
loop = self._loop
if loop is None:
return
ids = [uid for uid in user_ids if uid is not None]
if ids:
loop.call_soon_threadsafe(self._fanout, ids, event)
def _fanout(self, ids: list[int], event: dict[str, Any]) -> None:
# Выполняется на потоке loop'а — доступ к _subs безопасен.
for uid in ids:
for queue in list(self._subs.get(uid, ())):
try:
queue.put_nowait(event)
except asyncio.QueueFull:
pass # медленный клиент — пропускаем (догонит при reconnect/focus)
hub = EventHub()
+9 -1
View File
@@ -19,6 +19,7 @@ from app.routers import (
achievements,
admin,
auth,
events,
groups,
invitations,
matches,
@@ -63,6 +64,13 @@ class CSRFMiddleware(BaseHTTPMiddleware):
@asynccontextmanager
async def _lifespan(_app: FastAPI):
# SSE-шина публикует из sync-роутеров в этот event-loop — сохраняем ссылку (все окружения).
import asyncio
from app.core.events import hub
hub.bind_loop(asyncio.get_running_loop())
# В DEV приложение само подтягивает справочники и админа из .env при старте
# (в test/prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0":
@@ -118,7 +126,7 @@ def create_app() -> FastAPI:
# API-роутеры под /api.
api_routers = [auth.router, users.router, groups.router, invitations.router,
matches.router, reference.router, stats.router, achievements.router,
admin.router]
events.router, admin.router]
for r in api_routers:
app.include_router(r, prefix="/api")
+6
View File
@@ -20,6 +20,7 @@ from app.services import (
audit_service,
faction_service,
match_service,
notify,
user_service,
)
@@ -240,6 +241,7 @@ def update_match(
ip=request.client.host if request.client else None,
)
session.commit()
notify.match_changed(session, match)
return build_match_read(session, match, can_modify=True)
@@ -289,12 +291,14 @@ def delete_match(
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.OkResponse:
group_id = match_service.get_match(session, match_id).group_id # для уведомления
admin_service.delete_match(session, match_id)
audit_service.record(
session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id,
ip=request.client.host if request.client else None,
)
session.commit()
notify.match_removed(session, match_id, group_id)
return s.OkResponse()
@@ -330,6 +334,7 @@ def admin_add_attachment(
att = attachment_service.add_photo(
session, match, admin, content, ext, user_service.avatar_media_type(ext)
)
notify.match_changed(session, match)
return attachment_read(att, f"/api/admin/matches/{match_id}")
@@ -342,6 +347,7 @@ def admin_delete_attachment(
) -> s.OkResponse:
match = match_service.get_match(session, match_id)
attachment_service.delete(session, match, attachment_id)
notify.match_changed(session, match)
return s.OkResponse()
+45
View File
@@ -0,0 +1,45 @@
"""SSE: поток событий «данные изменились» для текущего пользователя.
Клиент (EventSource) держит одно соединение и по событию точечно перезапрашивает данные
(push-to-invalidate). Мутации остаются обычным REST.
"""
from __future__ import annotations
import asyncio
import json
from fastapi import APIRouter, Depends, Request
from fastapi.responses import StreamingResponse
from app.auth.deps import get_current_user
from app.core.events import hub
from app.models import User
router = APIRouter(tags=["events"])
_HEARTBEAT_SECONDS = 25
@router.get("/events")
async def events(request: Request, user: User = Depends(get_current_user)) -> StreamingResponse:
queue = hub.subscribe(user.id) # type: ignore[arg-type]
async def stream():
try:
yield ": connected\n\n"
while True:
if await request.is_disconnected():
break
try:
event = await asyncio.wait_for(queue.get(), timeout=_HEARTBEAT_SECONDS)
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
except asyncio.TimeoutError:
yield ": ping\n\n" # heartbeat против idle-таймаутов прокси/туннеля
finally:
hub.unsubscribe(user.id, queue) # type: ignore[arg-type]
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
+3
View File
@@ -14,6 +14,7 @@ from app.services import (
group_service,
invitation_service,
membership_service,
notify,
stats_service,
)
@@ -153,6 +154,7 @@ def invite_member(
user_agent=request.headers.get("user-agent"),
)
session.commit()
notify.invitations_changed(invited.id) # type: ignore[arg-type] # живое появление у получателя
return s.InvitationRead(
id=inv.id, # type: ignore[arg-type]
group_id=group_id,
@@ -174,6 +176,7 @@ def remove_member(
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
group = group_service.get_group(session, group_id)
membership_service.remove_member(session, group, user_id)
notify.group_changed(session, group_id, extra_user_ids=[user_id]) # + удалённому
return s.OkResponse()
+5 -2
View File
@@ -8,7 +8,7 @@ from app.auth.deps import get_current_user
from app.db.session import get_session
from app.models import User
from app.schemas import api as s
from app.services import invitation_service
from app.services import invitation_service, notify
router = APIRouter(prefix="/invitations", tags=["invitations"])
@@ -27,7 +27,9 @@ def accept_invitation(
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.OkResponse:
invitation_service.accept(session, user, invitation_id)
group = invitation_service.accept(session, user, invitation_id)
notify.invitations_changed(user.id) # type: ignore[arg-type]
notify.group_changed(session, group.id) # type: ignore[arg-type] # новый участник
return s.OkResponse()
@@ -38,4 +40,5 @@ def decline_invitation(
user: User = Depends(get_current_user),
) -> s.OkResponse:
invitation_service.decline(session, user, invitation_id)
notify.invitations_changed(user.id) # type: ignore[arg-type]
return s.OkResponse()
+7
View File
@@ -16,6 +16,7 @@ from app.services import (
audit_service,
group_service,
match_service,
notify,
user_service,
)
from app.services.match_service import FinishInput, ParticipantInput, RosterInput
@@ -119,6 +120,7 @@ def start_match(
user_agent=request.headers.get("user-agent"),
)
session.commit()
notify.match_changed(session, match)
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
@@ -157,6 +159,7 @@ def finish_match(
user_agent=request.headers.get("user-agent"),
)
session.commit()
notify.match_changed(session, match)
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
@@ -214,6 +217,7 @@ def update_match(
user_agent=request.headers.get("user-agent"),
)
session.commit()
notify.match_changed(session, match)
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
@@ -243,6 +247,7 @@ def add_attachment(
att = attachment_service.add_photo(
session, match, user, content, ext, user_service.avatar_media_type(ext)
)
notify.match_changed(session, match)
return attachment_read(att, f"/api/matches/{match_id}")
@@ -256,6 +261,7 @@ def delete_attachment(
match = match_service.get_match(session, match_id)
_assert_can_attach(session, match, user)
attachment_service.delete(session, match, attachment_id)
notify.match_changed(session, match)
return s.OkResponse()
@@ -301,4 +307,5 @@ def delete_match(
user_agent=request.headers.get("user-agent"),
)
session.commit()
notify.match_removed(session, match_id_val, group_id_val) # type: ignore[arg-type]
return s.OkResponse()
+46
View File
@@ -0,0 +1,46 @@
"""Публикация SSE-событий «данные изменились» нужным пользователям.
Тонкая прослойка над core.events.hub — держит роутеры чистыми. Событие несёт лишь тип и id,
клиент по нему точечно перезапрашивает данные.
"""
from __future__ import annotations
from sqlmodel import Session, select
from app.core.events import hub
from app.models import GroupMember, Match
def _group_member_ids(session: Session, group_id: int) -> list[int]:
return list(
session.exec(select(GroupMember.user_id).where(GroupMember.group_id == group_id)).all()
)
def match_changed(session: Session, match: Match) -> None:
"""Партия изменилась — уведомить всех участников её группы."""
hub.publish(
_group_member_ids(session, match.group_id),
{"type": "match", "match_id": match.id, "group_id": match.group_id},
)
def match_removed(session: Session, match_id: int, group_id: int) -> None:
"""Партия удалена — уведомить участников группы (обновить списки)."""
hub.publish(
_group_member_ids(session, group_id),
{"type": "match", "match_id": match_id, "group_id": group_id},
)
def group_changed(session: Session, group_id: int, extra_user_ids: list[int] | None = None) -> None:
"""Состав/данные группы изменились — участникам (+ доп. адресатам, напр. удалённому)."""
ids = _group_member_ids(session, group_id)
if extra_user_ids:
ids = ids + extra_user_ids
hub.publish(ids, {"type": "group", "group_id": group_id})
def invitations_changed(user_id: int) -> None:
"""У пользователя изменился список приглашений."""
hub.publish([user_id], {"type": "invitations"})
+43
View File
@@ -0,0 +1,43 @@
"""SSE-шина: доставка событий подписчику; эндпойнт /api/events требует вход."""
from __future__ import annotations
import asyncio
from fastapi.testclient import TestClient
def test_hub_delivers_to_subscriber():
from app.core.events import EventHub
async def run():
hub = EventHub()
hub.bind_loop(asyncio.get_running_loop())
q = hub.subscribe(1)
hub.publish([1, 2], {"type": "match", "match_id": 5})
event = await asyncio.wait_for(q.get(), timeout=1)
assert event == {"type": "match", "match_id": 5}
hub.unsubscribe(1, q)
asyncio.run(run())
def test_hub_isolates_users():
from app.core.events import EventHub
async def run():
hub = EventHub()
hub.bind_loop(asyncio.get_running_loop())
q1 = hub.subscribe(1)
q2 = hub.subscribe(2)
hub.publish([2], {"type": "invitations"}) # только пользователю 2
got2 = await asyncio.wait_for(q2.get(), timeout=1)
assert got2 == {"type": "invitations"}
assert q1.empty() # пользователю 1 ничего не пришло
asyncio.run(run())
def test_events_requires_auth(client: TestClient):
# Без сессии SSE-эндпойнт не отдаёт поток (401 на зависимости get_current_user).
r = client.get("/api/events")
assert r.status_code == 401