Files
dify/api/services/workflow_draft_sync_service.py
T
twwu 488a8d9307 fix(workflow): preserve final draft saves across editor teardown
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.
2026-11-03 16:16:39 +08:00

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),
}