mirror of
https://github.com/langgenius/dify.git
synced 2026-11-12 06:11:12 +08:00
Dispatch keepalive snapshots before teardown and persist save receipts in the draft transaction. Validate base receipts and named predecessors to reject stale saves even when the graph returns to its original hash. Retain unacknowledged saves and variable patches for recovery, propagate receipts through refresh, import, and collaboration, and support UUID generation on HTTP deployments. Include the migration and API contracts.
158 lines
6.7 KiB
Python
158 lines
6.7 KiB
Python
"""Select one browser to translate a committed draft change into CRDT operations."""
|
|
|
|
import base64
|
|
import logging
|
|
import time
|
|
import uuid
|
|
from datetime import UTC
|
|
|
|
from pydantic import BaseModel, ValidationError
|
|
from socketio import Server # type: ignore[reportMissingTypeStubs]
|
|
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 extensions.ext_socketio import sio
|
|
from models.workflow import Workflow
|
|
from repositories.workflow_collaboration_repository import WorkflowCollaborationRepository
|
|
from repositories.workflow_draft_sync_repository import ServerDraftChange, WorkflowDraftSyncRepository
|
|
from services.workflow_collaboration_service import WorkflowCollaborationService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
DRAFT_PROPOSAL_TIMEOUT_SECONDS = 5
|
|
|
|
|
|
class DraftGraphProposal(BaseModel):
|
|
revision: str
|
|
update: bytes
|
|
|
|
|
|
def _change_from_workflow(workflow: Workflow, previous_graph: dict[str, object] | None = None) -> ServerDraftChange:
|
|
updated_at = workflow.updated_at.replace(tzinfo=UTC)
|
|
return ServerDraftChange(
|
|
revision=f"{updated_at.isoformat(timespec='microseconds')}:{workflow.unique_hash}",
|
|
hash=workflow.unique_hash,
|
|
client_save_id=workflow.draft_client_save_id,
|
|
updated_at=int(updated_at.timestamp()),
|
|
graph=dict(workflow.graph_dict),
|
|
previous_graph=previous_graph,
|
|
)
|
|
|
|
|
|
def notify_workflow_draft_changed(workflow: Workflow, *, previous_graph: dict[str, object] | None = None) -> None:
|
|
"""Call after commit; a notification failure must not undo a successful write.
|
|
|
|
An explicit canvas refresh also requests the latest committed draft, allowing
|
|
the browser to recover when this notification or its delivery fails.
|
|
"""
|
|
if not dify_config.ENABLE_COLLABORATION_MODE:
|
|
return
|
|
try:
|
|
if not WorkflowCollaborationRepository().get_session_sids(workflow.app_id):
|
|
return
|
|
change = _change_from_workflow(workflow, previous_graph)
|
|
if WorkflowDraftSyncRepository().announce(workflow.app_id, change):
|
|
sio.emit(
|
|
"server_draft_changed",
|
|
{"revision": change.revision},
|
|
room=workflow.app_id,
|
|
)
|
|
except Exception:
|
|
logger.exception("Failed to notify collaborators about draft change: app_id=%s", workflow.app_id)
|
|
|
|
|
|
class WorkflowDraftSyncService:
|
|
def __init__(
|
|
self,
|
|
repository: WorkflowDraftSyncRepository,
|
|
collaboration_repository: WorkflowCollaborationRepository,
|
|
collaboration: WorkflowCollaborationService,
|
|
socketio: Server,
|
|
) -> None:
|
|
self._repository = repository
|
|
self._collaboration_repository = collaboration_repository
|
|
self._collaboration = collaboration
|
|
self._socketio = socketio
|
|
|
|
def sync(self, sid: str, *, session: Session) -> tuple[dict[str, object], int]:
|
|
mapping = self._collaboration_repository.get_sid_mapping(sid)
|
|
if not mapping:
|
|
return {"msg": "unauthorized"}, 401
|
|
identity = self._socketio.get_session(sid)
|
|
if not identity.get("tenant_id"):
|
|
return {"msg": "unauthorized"}, 401
|
|
app_id = mapping["workflow_id"]
|
|
# End the database transaction before waiting for a browser response.
|
|
with session.begin():
|
|
workflow = session.scalar(
|
|
select(Workflow).where(
|
|
Workflow.app_id == app_id,
|
|
Workflow.tenant_id == identity["tenant_id"],
|
|
Workflow.version == Workflow.VERSION_DRAFT,
|
|
)
|
|
)
|
|
if workflow is None:
|
|
return {"msg": "draft_not_found"}, 404
|
|
latest = _change_from_workflow(workflow)
|
|
|
|
if self._repository.announce(app_id, latest):
|
|
self._socketio.emit("server_draft_changed", {"revision": latest.revision}, room=app_id)
|
|
change = self._repository.get(app_id)
|
|
if change is None:
|
|
return {"msg": "draft_sync_expired"}, 409
|
|
if change.update is not None:
|
|
return self._result(change), 200
|
|
|
|
token = uuid.uuid4().hex
|
|
if not self._repository.claim(app_id, token):
|
|
return {"msg": "draft_sync_pending"}, 202
|
|
try:
|
|
leader = self._collaboration_repository.get_current_leader(app_id)
|
|
candidates = self._collaboration_repository.get_session_sids(app_id)
|
|
if leader in candidates:
|
|
candidates.remove(leader)
|
|
candidates.insert(0, leader)
|
|
active = [candidate for candidate in candidates if self._collaboration.is_session_active(app_id, candidate)]
|
|
# Keep retries inside the lease, but skip unready browsers without
|
|
# preventing a later candidate from responding. A timed-out browser
|
|
# has only edited a fork, so its late response is harmless.
|
|
deadline = time.monotonic() + 2 * DRAFT_PROPOSAL_TIMEOUT_SECONDS
|
|
for target in active:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
break
|
|
try:
|
|
response = self._socketio.call(
|
|
"server_draft_request",
|
|
change.model_dump(exclude={"update"}),
|
|
to=target,
|
|
timeout=min(DRAFT_PROPOSAL_TIMEOUT_SECONDS, remaining),
|
|
)
|
|
proposal = DraftGraphProposal.model_validate(response, strict=True)
|
|
except (SocketIOTimeoutError, ValidationError):
|
|
continue
|
|
if proposal.revision != change.revision:
|
|
continue
|
|
encoded_update = base64.b64encode(proposal.update).decode("ascii")
|
|
if not self._repository.accept(app_id, token, change.revision, encoded_update):
|
|
return {"msg": "draft_sync_superseded"}, 409
|
|
change.update = encoded_update
|
|
result = self._result(change)
|
|
self._socketio.emit("server_draft_update", result, room=app_id)
|
|
return result, 200
|
|
return {"msg": "no_ready_draft_writer"}, 503
|
|
finally:
|
|
self._repository.release(app_id, token)
|
|
|
|
@staticmethod
|
|
def _result(change: ServerDraftChange) -> dict[str, object]:
|
|
assert change.update is not None
|
|
return {
|
|
"revision": change.revision,
|
|
"hash": change.hash,
|
|
"client_save_id": change.client_save_id,
|
|
"updated_at": change.updated_at,
|
|
"update": base64.b64decode(change.update),
|
|
}
|