Files
twwu 7a34c7b8c4 fix(workflow): replace stale collaboration sessions on reconnect
Resume within the same workflow, account, and page so the old connection stops receiving snapshot requests. Recover from the saved draft when the replacement becomes leader.
2026-11-06 15:25:52 +08:00

162 lines
6.1 KiB
Python

import logging
from collections.abc import Callable
from typing import Annotated, cast
from flask import Request as FlaskRequest
from pydantic import UUID4, BaseModel, Field, ValidationError
from core.db.session_factory import session_factory
from extensions.ext_application_services import application_services
from extensions.ext_socketio import sio
from extensions.otel.workflow_collaboration import observe_workflow_event
from libs.passport import PassportService
from libs.token import extract_access_token
from repositories.workflow_collaboration_repository import WorkflowCollaborationRepository
from repositories.workflow_draft_sync_repository import WorkflowDraftSyncRepository
from services.errors.billing import BillingUpstreamError
from services.workflow_collaboration_service import (
WorkflowCollaborationNotEntitledError,
WorkflowCollaborationService,
WorkflowPageResume,
)
from services.workflow_draft_sync_service import WorkflowDraftSyncService
repository = WorkflowCollaborationRepository()
collaboration_service = WorkflowCollaborationService(repository, sio)
draft_sync_repository = WorkflowDraftSyncRepository()
draft_sync_service = WorkflowDraftSyncService(draft_sync_repository, repository, collaboration_service, sio)
class WorkflowPageResumePayload(BaseModel):
page_id: UUID4
sequence: Annotated[int, Field(strict=True, ge=1)]
def _sio_on(event: str) -> Callable[[Callable[..., object]], Callable[..., object]]:
return cast(Callable[[Callable[..., object]], Callable[..., object]], sio.on(event))
@_sio_on("connect")
@observe_workflow_event("connect", sio)
def socket_connect(sid, environ, auth=None):
"""
Authenticate a connect with optional Socket.IO auth data; preserve token-based admission.
"""
try:
request_environ = FlaskRequest(environ)
token = extract_access_token(request_environ)
except Exception:
logging.exception("Failed to extract token")
token = None
if not token:
logging.warning("Socket connect rejected: missing token (sid=%s)", sid)
return False
try:
decoded = PassportService().verify(token)
user_id = decoded.get("user_id")
if not user_id:
logging.warning("Socket connect rejected: missing user_id (sid=%s)", sid)
return False
with sio.app.app_context():
user = application_services().accounts.identity.load_user(user_id)
if not user:
logging.warning("Socket connect rejected: user not found (user_id=%s, sid=%s)", user_id, sid)
return False
if not user.has_edit_permission:
logging.warning("Socket connect rejected: no edit permission (user_id=%s, sid=%s)", user_id, sid)
return False
collaboration_service.save_socket_identity(sid, user)
return True
except Exception:
logging.exception("Socket authentication failed")
return False
@_sio_on("user_connect")
@observe_workflow_event("user_connect", sio)
def handle_user_connect(sid: str, data: object):
"""Admit a new page or resume one after validating the optional page credential."""
if not isinstance(data, dict) or not isinstance(workflow_id := data.get("workflow_id"), str) or not workflow_id:
return {"msg": "workflow_id is required"}, 400
resume = None
if (raw_resume := data.get("resume")) is not None:
try:
payload = WorkflowPageResumePayload.model_validate(raw_resume)
except ValidationError:
return {"msg": "invalid resume"}, 400
resume = WorkflowPageResume(payload.page_id.hex, payload.sequence)
try:
with sio.app.app_context(), session_factory.create_session() as session:
result = collaboration_service.authorize_and_join_workflow_room(
workflow_id, sid, session=session, resume=resume
)
except BillingUpstreamError:
logging.exception("Failed to check collaboration entitlement (workflow_id=%s)", workflow_id)
return {"msg": "collaboration_unavailable"}, 503
except WorkflowCollaborationNotEntitledError:
return {"msg": "not_entitled"}, 403
if not result:
return {"msg": "unauthorized"}, 401
user_id, is_leader = result
pending_draft = draft_sync_repository.get(workflow_id)
if pending_draft is not None and pending_draft.update is None:
sio.emit("server_draft_changed", {"revision": pending_draft.revision}, to=sid)
return {"msg": "connected", "user_id": user_id, "sid": sid, "isLeader": is_leader}
@observe_workflow_event("disconnect", sio)
def _handle_disconnect(sid):
"""Remove the session from online users and observe the result."""
workflow_id = collaboration_service.disconnect_session(sid)
if workflow_id and not repository.get_session_sids(workflow_id):
draft_sync_repository.clear(workflow_id)
@_sio_on("server_draft_sync")
def handle_server_draft_sync(sid, _data):
"""Return one accepted CRDT update for the latest committed server draft."""
with sio.app.app_context(), session_factory.create_session() as session:
return draft_sync_service.sync(sid, session=session)
@_sio_on("disconnect")
def handle_disconnect(sid):
"""Let Socket.IO discard its reason before starting observable cleanup.
A cleanup TypeError must propagate without Socket.IO retrying the cleanup.
"""
_handle_disconnect(sid)
@_sio_on("collaboration_event")
@observe_workflow_event("collaboration_event", sio)
def handle_collaboration_event(sid, data):
"""
Handle general collaboration events, include:
1. mouse_move
2. vars_and_features_update
3. sync_request (ask leader to update graph)
4. workflow_update
5. comments_update
6. node_panel_presence
7. graph_view_state (session reports tab visibility; drives leader election)
"""
return collaboration_service.relay_collaboration_event(sid, data)
@_sio_on("graph_event")
@observe_workflow_event("graph_event", sio)
def handle_graph_event(sid, data):
"""
Handle graph events - simple broadcast relay.
"""
return collaboration_service.relay_graph_event(sid, data)