Files
dify/api/services/workflow_collaboration_service.py
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

607 lines
26 KiB
Python

from __future__ import annotations
import logging
import os
import socket
import time
import uuid
from collections.abc import Mapping
from typing import Any, NamedTuple, override
from socketio.exceptions import TimeoutError as SocketIOTimeoutError # type: ignore[reportMissingTypeStubs]
from sqlalchemy import select
from sqlalchemy.orm import Session
from configs import dify_config
from core.rbac import RBACPermission, RBACResourceScope
from models.account import Account
from models.model import App
from repositories.workflow_collaboration_repository import WorkflowCollaborationRepository, WorkflowSessionInfo
from services.enterprise.rbac_service import RBACService
from services.feature_service import FeatureService
logger = logging.getLogger(__name__)
SERVER_HEARTBEAT_INTERVAL_SECONDS = 30
SYNC_REQUEST_TIMEOUT_SECONDS = 15
_PROCESS_SERVER_ID: str | None = None
_PROCESS_SERVER_ID_PID: int | None = None
class WorkflowCollaborationNotEntitledError(Exception):
"""The workspace cannot use workflow collaboration."""
class WorkflowPageResume(NamedTuple):
"""Validated page credential and join sequence supplied by the browser."""
page_id: str
sequence: int
def _get_process_server_id() -> str:
global _PROCESS_SERVER_ID, _PROCESS_SERVER_ID_PID # pylint: disable=global-statement
pid = os.getpid()
if _PROCESS_SERVER_ID is None or pid != _PROCESS_SERVER_ID_PID:
_PROCESS_SERVER_ID_PID = pid
_PROCESS_SERVER_ID = f"{socket.gethostname()}:{pid}:{uuid.uuid4().hex}"
return _PROCESS_SERVER_ID
class WorkflowCollaborationService:
"""
Coordinate workflow collaboration state across Socket.IO workers.
Socket.IO rooms are process-local unless backed by a message queue, while online users and leader election live in
Redis. Each websocket worker writes a small heartbeat keyed by `server_id`; session rows store their owner so a
worker can distinguish a live remote sid from a stale sid left behind by a dead worker. Visibility events are
ordered per socket, and sync requests wait for the selected saver to acknowledge its draft write.
"""
_heartbeat_started: bool
_repository: WorkflowCollaborationRepository
_server_id_override: str | None
_socketio: Any
def __init__(
self, repository: WorkflowCollaborationRepository, socketio: Any, server_id: str | None = None
) -> None:
self._repository = repository
self._socketio = socketio
self._server_id_override = server_id
self._heartbeat_started = False
@override
def __repr__(self) -> str:
return f"{self.__class__.__name__}(repository={self._repository})"
@property
def server_id(self) -> str:
return self._server_id_override or _get_process_server_id()
def _refresh_server_heartbeat(self) -> None:
self._repository.refresh_server_heartbeat(self.server_id)
def _server_heartbeat_loop(self) -> None:
while True:
try:
self._refresh_server_heartbeat()
self._repository.refresh_server_sessions(self.server_id)
except Exception:
logger.exception("Failed to refresh workflow collaboration server heartbeat")
self._socketio.sleep(SERVER_HEARTBEAT_INTERVAL_SECONDS)
def _ensure_server_heartbeat_started(self) -> None:
if self._heartbeat_started:
return
self._heartbeat_started = True
self._refresh_server_heartbeat()
self._socketio.start_background_task(self._server_heartbeat_loop)
def save_socket_identity(self, sid: str, user: Account) -> None:
"""Persist the authenticated console user on the raw socket session."""
self._socketio.save_session(
sid,
{
"user_id": user.id,
"username": user.name,
"avatar": user.avatar,
"tenant_id": user.current_tenant_id,
},
)
def authorize_and_join_workflow_room(
self, workflow_id: str, sid: str, *, session: Session, resume: WorkflowPageResume | None = None
) -> tuple[str, bool] | None:
"""
Join a collaboration room only after validating the socket session, app access, and workspace entitlement.
The Socket.IO payload still calls the room key `workflow_id`, but the identifier is the workflow app's
`App.id`. Missing access or an outdated join returns `None`; an ineligible workspace raises
`WorkflowCollaborationNotEntitledError`. An admitted resume supersedes only the prior SID for
this page in the authenticated workflow and account.
"""
socket_session = self._socketio.get_session(sid)
user_id = socket_session.get("user_id")
tenant_id = socket_session.get("tenant_id")
if not user_id or not tenant_id:
return None
if not self._can_access_workflow(workflow_id, str(tenant_id), str(user_id), session=session):
logger.warning(
"Workflow collaboration join rejected: workflow_id=%s tenant_id=%s user_id=%s sid=%s",
workflow_id,
tenant_id,
user_id,
sid,
)
return None
if not FeatureService.can_use_workflow_collaboration(str(tenant_id)):
raise WorkflowCollaborationNotEntitledError
self._ensure_server_heartbeat_started()
session_info: WorkflowSessionInfo = {
"user_id": str(user_id),
"username": str(socket_session.get("username", "Unknown")),
"avatar": socket_session.get("avatar"),
"sid": sid,
"connected_at": int(time.time()),
"server_id": self.server_id,
# Joins are assumed visible; hidden tabs re-report via a graph_view_state
# event right after receiving the post-join "status" emit.
"graph_active": True,
}
previous_sid = None
replaced_sid = None
with self._repository.room_state_lock(workflow_id):
if resume is not None:
previous = self._repository.get_page_session(workflow_id, session_info["user_id"], resume.page_id)
if previous is not None:
if resume.sequence < previous["sequence"]:
return None
if previous["sid"] != sid:
if resume.sequence == previous["sequence"]:
return None
previous_sid = previous["sid"]
mapping = self._repository.get_sid_mapping(previous_sid)
if mapping is not None and (
mapping["workflow_id"],
mapping["user_id"],
mapping.get("page_id"),
) == (workflow_id, session_info["user_id"], resume.page_id):
replaced_sid = previous_sid
self._socketio.enter_room(sid, workflow_id)
self._repository.set_session_info(workflow_id, session_info, page_id=resume.page_id if resume else None)
if resume is not None:
self._repository.set_page_session(
workflow_id, session_info["user_id"], resume.page_id, {"sid": sid, "sequence": resume.sequence}
)
if replaced_sid is not None:
self._repository.delete_session(workflow_id, replaced_sid)
if previous_sid is not None:
self.handle_leader_disconnect(workflow_id, previous_sid, joining_sid=sid)
self.get_or_set_leader(workflow_id, sid)
is_leader = self.broadcast_online_users(workflow_id, joining_sid=sid) == sid
status = {"isLeader": is_leader}
if is_leader and replaced_sid is not None:
status["requiresGraphReload"] = True
self._socketio.emit("status", status, room=sid)
# Socket.IO can run the disconnect handler synchronously. Room state already fences this SID.
if replaced_sid is not None:
try:
self._socketio.disconnect(replaced_sid)
except Exception:
logger.exception("Failed to close superseded workflow collaboration session: sid=%s", replaced_sid)
return str(user_id), is_leader
def _can_access_workflow(self, workflow_id: str, tenant_id: str, user_id: str, *, session: Session) -> bool:
"""Check tenant and app permission without relying on Flask's scoped session."""
with session.begin():
app = session.execute(
select(App.id, App.maintainer).where(
App.id == workflow_id, App.tenant_id == tenant_id, App.status == "normal"
)
).one_or_none()
if app is None:
return False
app_id, maintainer = app
if not dify_config.RBAC_ENABLED or maintainer == user_id:
return True
return RBACService.CheckAccess.check(
tenant_id,
user_id,
scene=RBACPermission.APP_EDIT,
resource_type=RBACResourceScope.APP,
resource_id=app_id,
)
def disconnect_session(self, sid: str) -> str | None:
"""Remove a known session and return its workflow ID for final draft cleanup.
Unknown and superseded sessions return None. Cleanup is serialized with joins,
so a late disconnect cannot act on the replacement session's room state.
"""
mapping = self._repository.get_sid_mapping(sid)
if not mapping:
return None
workflow_id = mapping["workflow_id"]
with self._repository.room_state_lock(workflow_id):
if self._repository.get_sid_mapping(sid) != mapping:
return None
self._repository.delete_session(workflow_id, sid)
self.handle_leader_disconnect(workflow_id, sid)
self.broadcast_online_users(workflow_id)
return workflow_id
def relay_collaboration_event(self, sid: str, data: Mapping[str, object]) -> tuple[dict[str, object], int]:
"""Route collaboration control events, directing save and graph-resync requests to the active leader."""
mapping = self._repository.get_sid_mapping(sid)
if not mapping:
return {"msg": "unauthorized"}, 401
workflow_id = mapping["workflow_id"]
user_id = mapping["user_id"]
self.refresh_session_state(workflow_id, sid)
event_type = data.get("type")
event_data = data.get("data")
timestamp = data.get("timestamp", int(time.time()))
if not event_type:
return {"msg": "invalid event type"}, 400
if event_type == "graph_view_state":
if not isinstance(event_data, Mapping):
return {"msg": "invalid graph_view_state"}, 400
graph_active = event_data.get("graphActive")
sequence = event_data.get("sequence")
if not isinstance(graph_active, bool):
return {"msg": "invalid graph_view_state"}, 400
if not isinstance(sequence, int) or isinstance(sequence, bool) or sequence < 0:
return {"msg": "invalid graph_view_state"}, 400
# Sequence the visibility write together with its leader side effect. Otherwise a newer
# visible event can land after the Lua write but before an older hidden handler demotes.
with self._repository.room_state_lock(workflow_id):
applied = self._repository.update_session_graph_active(workflow_id, sid, graph_active, sequence)
if not applied:
return {"msg": "graph_view_state_ignored"}, 200
# Write the flag before re-electing so tier-1 selection already excludes the
# session that just went hidden (including one _ensure_leader just promoted).
if not graph_active:
self._demote_leader_if_hidden(workflow_id, sid)
return {"msg": "graph_view_state_updated"}, 200
if event_type == "sync_request":
if not isinstance(event_data, Mapping):
return {"msg": "invalid sync_request"}, 400
request_id = event_data.get("requestId")
if not isinstance(request_id, str) or not request_id.strip():
return {"msg": "invalid sync_request"}, 400
with self._repository.room_state_lock(workflow_id):
if self._repository.get_sid_mapping(sid) != mapping:
return {"msg": "unauthorized"}, 401
target_sid = self._select_sync_target(workflow_id, sid, require_visible_leader=True)
if not target_sid:
return {"msg": "no_active_leader", "requestId": request_id}, 503
target_data = dict(event_data)
target_data["requestId"] = request_id
try:
result = self._socketio.call(
"collaboration_update",
{"type": event_type, "userId": user_id, "data": target_data, "timestamp": timestamp},
to=target_sid,
timeout=SYNC_REQUEST_TIMEOUT_SECONDS,
)
except SocketIOTimeoutError:
logger.warning(
"Workflow collaboration sync request timed out: workflow_id=%s requester_sid=%s target_sid=%s",
workflow_id,
sid,
target_sid,
)
return {"msg": "sync_request_timeout", "requestId": request_id}, 504
if not isinstance(result, Mapping) or result.get("success") is not True:
response: dict[str, object] = {
"msg": "workflow_sync_failed",
"requestId": request_id,
"success": False,
}
if isinstance(result, Mapping) and isinstance(result.get("error"), str):
response["error"] = result["error"]
return response, 502
workflow_hash = result.get("hash")
updated_at = result.get("updatedAt")
if not isinstance(workflow_hash, str) or not workflow_hash:
return {"msg": "invalid_sync_response", "requestId": request_id}, 502
if not isinstance(updated_at, int) or isinstance(updated_at, bool):
return {"msg": "invalid_sync_response", "requestId": request_id}, 502
return {
"msg": "workflow_synced",
"requestId": request_id,
"success": True,
"hash": workflow_hash,
"updatedAt": updated_at,
}, 200
if event_type == "graph_resync_request":
with self._repository.room_state_lock(workflow_id):
if self._repository.get_sid_mapping(sid) != mapping:
return {"msg": "unauthorized"}, 401
resync_target_sid = self._select_sync_target(workflow_id, sid, require_visible_leader=False)
if not resync_target_sid:
return {"msg": "no_active_leader"}, 503
self._socketio.emit(
"collaboration_update",
{"type": event_type, "userId": user_id, "data": event_data, "timestamp": timestamp},
to=resync_target_sid,
)
return {"msg": "graph_resync_request_forwarded"}, 200
self._socketio.emit(
"collaboration_update",
{"type": event_type, "userId": user_id, "data": event_data, "timestamp": timestamp},
room=workflow_id,
skip_sid=sid,
)
return {"msg": "event_broadcasted"}, 200
def _select_sync_target(self, workflow_id: str, sid: str, *, require_visible_leader: bool) -> str | None:
"""Route an admitted request under the room lock; publish any necessary leader change."""
leader_sid = self._repository.get_current_leader(workflow_id)
if leader_sid and self.is_session_active(workflow_id, leader_sid):
if not require_visible_leader or self._is_session_graph_active(workflow_id, leader_sid):
return leader_sid
# A hidden tab's canvas may be stale even when its CRDT can serve snapshots.
# Saves prefer a visible editor, or the requester if all tabs are hidden.
target_sid = self._select_graph_leader(workflow_id, preferred_sid=sid) or leader_sid
else:
target_sid = self._select_graph_leader(workflow_id, preferred_sid=sid)
if target_sid != leader_sid:
if target_sid is None:
self._repository.delete_leader(workflow_id)
else:
self._repository.set_leader(workflow_id, target_sid)
self.broadcast_leader_change(workflow_id, target_sid)
return target_sid
def relay_graph_event(self, sid: str, data: object) -> tuple[dict[str, str], int]:
mapping = self._repository.get_sid_mapping(sid)
if not mapping:
return {"msg": "unauthorized"}, 401
workflow_id = mapping["workflow_id"]
self.refresh_session_state(workflow_id, sid)
self._socketio.emit("graph_update", data, room=workflow_id, skip_sid=sid)
return {"msg": "graph_update_broadcasted"}, 200
def get_or_set_leader(self, workflow_id: str, sid: str) -> str:
"""Keep the current leader or elect the joining SID while the room is locked."""
current_leader = self._repository.get_current_leader(workflow_id)
if current_leader:
if self.is_session_active(workflow_id, current_leader):
return current_leader
self._repository.delete_session(workflow_id, current_leader)
self._repository.delete_leader(workflow_id)
was_set = self._repository.set_leader_if_absent(workflow_id, sid)
if was_set:
if current_leader:
self.broadcast_leader_change(workflow_id, sid, skip_sid=sid)
return sid
current_leader = self._repository.get_current_leader(workflow_id)
if current_leader:
return current_leader
return sid
def handle_leader_disconnect(
self, workflow_id: str, disconnected_sid: str, *, joining_sid: str | None = None
) -> None:
"""Transfer leadership to a remaining editor, leaving a new join to recover separately."""
current_leader = self._repository.get_current_leader(workflow_id)
if not current_leader:
return
if current_leader != disconnected_sid:
return
new_leader_sid = self._select_graph_leader(workflow_id, excluded_sid=joining_sid)
if new_leader_sid:
self._repository.set_leader(workflow_id, new_leader_sid)
self.broadcast_leader_change(workflow_id, new_leader_sid, skip_sid=joining_sid)
else:
self._repository.delete_leader(workflow_id)
def broadcast_leader_change(
self, workflow_id: str, new_leader_sid: str | None, *, skip_sid: str | None = None
) -> None:
"""Notify existing editors. A joining SID receives its own status with recovery instructions."""
for sid in self._repository.get_session_sids(workflow_id):
if sid != skip_sid:
try:
is_leader = new_leader_sid is not None and sid == new_leader_sid
self._socketio.emit("status", {"isLeader": is_leader}, room=sid)
except Exception:
logging.exception("Failed to emit leader status to session %s", sid)
def get_current_leader(self, workflow_id: str) -> str | None:
return self._repository.get_current_leader(workflow_id)
def _prune_inactive_sessions(self, workflow_id: str) -> list[WorkflowSessionInfo]:
"""Remove inactive sessions from storage and return active sessions only."""
sessions = self._repository.list_sessions(workflow_id)
if not sessions:
return []
active_sessions: list[WorkflowSessionInfo] = []
stale_sids: list[str] = []
for session in sessions:
sid = session["sid"]
if self.is_session_active(workflow_id, sid):
active_sessions.append(session)
else:
stale_sids.append(sid)
for sid in stale_sids:
self._repository.delete_session(workflow_id, sid)
return active_sessions
def broadcast_online_users(self, workflow_id: str, *, joining_sid: str | None = None) -> str | None:
"""Prune and publish the online list under the room lock, returning its final leader."""
users = self._prune_inactive_sessions(workflow_id)
users.sort(key=lambda x: x.get("connected_at") or 0)
leader_sid = self.get_current_leader(workflow_id)
previous_leader = leader_sid
active_sids = {user["sid"] for user in users}
if leader_sid and leader_sid not in active_sids:
self._repository.delete_leader(workflow_id)
leader_sid = None
if not leader_sid and users:
leader_sid = self._select_graph_leader(workflow_id)
if leader_sid:
self._repository.set_leader(workflow_id, leader_sid)
if leader_sid != previous_leader:
self.broadcast_leader_change(workflow_id, leader_sid, skip_sid=joining_sid)
self._socketio.emit(
"online_users",
{"workflow_id": workflow_id, "users": users, "leader": leader_sid},
room=workflow_id,
)
return leader_sid
def refresh_session_state(self, workflow_id: str, sid: str) -> None:
self._refresh_server_heartbeat()
self._repository.refresh_session_state(workflow_id, sid)
self._ensure_leader(workflow_id, sid)
def _ensure_leader(self, workflow_id: str, sid: str) -> None:
"""Keep a live leader; elect a requester only while its SID is still admitted."""
current_leader = self._repository.get_current_leader(workflow_id)
if current_leader and self.is_session_active(workflow_id, current_leader):
self._repository.expire_leader(workflow_id)
return
with self._repository.room_state_lock(workflow_id):
mapping = self._repository.get_sid_mapping(sid)
if mapping is None or mapping["workflow_id"] != workflow_id:
return
current_leader = self._repository.get_current_leader(workflow_id)
if current_leader and self.is_session_active(workflow_id, current_leader):
self._repository.expire_leader(workflow_id)
return
self._repository.set_leader(workflow_id, sid)
self.broadcast_leader_change(workflow_id, sid)
def _select_graph_leader(
self,
workflow_id: str,
preferred_sid: str | None = None,
*,
require_graph_active: bool = False,
excluded_sid: str | None = None,
) -> str | None:
"""Pick a leader, preferring sessions whose canvas tab is visible.
Hidden tabs freeze rAF-driven CRDT->canvas application, so a visible session is
always the freshest saver. When every tab is hidden the room must still keep a
leader (followers drop sync_requests unless they believe they are the leader),
so tier 2 falls back to any active session unless require_graph_active is set.
"""
active_sessions = [
session
for session in self._repository.list_sessions(workflow_id)
if session["sid"] != excluded_sid and self.is_session_active(workflow_id, session["sid"])
]
visible_sids = [session["sid"] for session in active_sessions if session.get("graph_active", True)]
candidate_sids = visible_sids
if not candidate_sids and not require_graph_active:
candidate_sids = [session["sid"] for session in active_sessions]
if not candidate_sids:
return None
if preferred_sid and preferred_sid in candidate_sids:
return preferred_sid
return candidate_sids[0]
def _is_session_graph_active(self, workflow_id: str, sid: str) -> bool:
"""Default to True on missing/unreadable session data so read failures never churn leadership."""
session_info = self._repository.get_session_info(workflow_id, sid)
if session_info is None:
return True
return bool(session_info.get("graph_active", True))
def _demote_leader_if_hidden(self, workflow_id: str, hidden_sid: str) -> None:
current_leader = self._repository.get_current_leader(workflow_id)
if current_leader != hidden_sid:
return
new_leader = self._select_graph_leader(workflow_id, require_graph_active=True)
# No visible session: keep the hidden leader rather than leaving the room leaderless.
if not new_leader or new_leader == hidden_sid:
return
self._repository.set_leader(workflow_id, new_leader)
self.broadcast_leader_change(workflow_id, new_leader)
def is_session_active(self, workflow_id: str, sid: str) -> bool:
if not sid:
return False
mapping = self._repository.get_sid_mapping(sid)
if not mapping or mapping["workflow_id"] != workflow_id:
return False
if not self._repository.session_exists(workflow_id, sid):
return False
server_id = mapping.get("server_id")
if not server_id:
return self._is_socket_connected_locally(sid)
if server_id == self.server_id:
return self._is_socket_connected_locally(sid)
return self._repository.server_heartbeat_exists(server_id)
def _is_socket_connected_locally(self, sid: str) -> bool:
try:
return bool(self._socketio.manager.is_connected(sid, "/"))
except AttributeError:
return False