Skip to content

Commit d35caf7

Browse files
committed
feat(collab): add SQLite collaboration event backplane
Introduce CollaborationEventService with a WAL SQLite mailbox keyed by flow_id for cross-worker fanout. Register the service in deps/schema, split event types into schemas.py, and add unit tests for publish/poll, TTL, caps, and worker isolation. Fix service factory import inference for lfx flow_operations.
1 parent 88351b7 commit d35caf7

9 files changed

Lines changed: 459 additions & 2 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from langflow.services.collaboration_events.schemas import CollaborationEvent, CollaborationPollCursor
2+
from langflow.services.collaboration_events.service import CollaborationEventService
3+
from langflow.services.collaboration_events.sqlite import SQLiteCollaborationEventService
4+
5+
__all__ = [
6+
"CollaborationEvent",
7+
"CollaborationEventService",
8+
"CollaborationPollCursor",
9+
"SQLiteCollaborationEventService",
10+
]
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
from typing_extensions import override
6+
7+
from langflow.services.collaboration_events.sqlite import SQLiteCollaborationEventService
8+
from langflow.services.factory import ServiceFactory
9+
10+
if TYPE_CHECKING:
11+
from lfx.services.settings.service import SettingsService
12+
13+
from langflow.services.collaboration_events.service import CollaborationEventService
14+
15+
16+
class CollaborationEventServiceFactory(ServiceFactory):
17+
def __init__(self) -> None:
18+
super().__init__(SQLiteCollaborationEventService)
19+
20+
@override
21+
def create(self, settings_service: SettingsService) -> CollaborationEventService:
22+
return SQLiteCollaborationEventService(cache_dir=settings_service.settings.cache_dir)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass, field
4+
from typing import TYPE_CHECKING
5+
6+
if TYPE_CHECKING:
7+
from uuid import UUID
8+
9+
10+
@dataclass(frozen=True)
11+
class CollaborationEvent:
12+
"""Opaque collaboration event stored in the cross-worker backplane."""
13+
14+
id: str
15+
flow_id: UUID
16+
created_at: float
17+
type: str
18+
payload: dict = field(default_factory=dict)
19+
20+
21+
@dataclass(frozen=True)
22+
class CollaborationPollCursor:
23+
"""Per-worker poll position for a flow's collaboration event stream."""
24+
25+
created_at: float = 0.0
26+
event_id: str = ""
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from __future__ import annotations
2+
3+
from abc import ABC, abstractmethod
4+
from typing import TYPE_CHECKING
5+
6+
from langflow.services.base import Service
7+
8+
if TYPE_CHECKING:
9+
from uuid import UUID
10+
11+
from langflow.services.collaboration_events.schemas import CollaborationEvent, CollaborationPollCursor
12+
13+
14+
class CollaborationEventService(Service, ABC):
15+
"""Cross-worker event backplane for collaborative flow editing.
16+
17+
Publishes opaque events scoped by ``flow_id``. Workers poll events for flows
18+
they are actively serving and fan them out to local WebSocket rooms.
19+
"""
20+
21+
name = "collaboration_events_service"
22+
23+
@abstractmethod
24+
def publish(self, flow_id: UUID, event_type: str, payload: dict) -> CollaborationEvent:
25+
"""Persist an event for cross-worker fanout."""
26+
27+
@abstractmethod
28+
def poll(
29+
self,
30+
flow_id: UUID,
31+
*,
32+
cursor: CollaborationPollCursor | None = None,
33+
limit: int | None = None,
34+
) -> tuple[list[CollaborationEvent], CollaborationPollCursor]:
35+
"""Return events after ``cursor`` and the updated poll cursor."""
36+
37+
@abstractmethod
38+
def cleanup(self) -> None:
39+
"""Force-evict expired events. Useful for tests and ops scripts."""
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import sqlite3
5+
import tempfile
6+
import threading
7+
import time
8+
import uuid
9+
from pathlib import Path
10+
from uuid import UUID
11+
12+
from langflow.services.collaboration_events.schemas import CollaborationEvent, CollaborationPollCursor
13+
from langflow.services.collaboration_events.service import CollaborationEventService
14+
15+
_SCHEMA = """
16+
CREATE TABLE IF NOT EXISTS collaboration_events (
17+
id TEXT NOT NULL,
18+
flow_id TEXT NOT NULL,
19+
created_at REAL NOT NULL,
20+
type TEXT NOT NULL,
21+
payload TEXT NOT NULL DEFAULT '{}',
22+
expires_at REAL NOT NULL
23+
);
24+
CREATE INDEX IF NOT EXISTS idx_collab_events_flow_created_id
25+
ON collaboration_events(flow_id, created_at, id);
26+
CREATE INDEX IF NOT EXISTS idx_collab_events_expires ON collaboration_events(expires_at);
27+
"""
28+
29+
30+
class SQLiteCollaborationEventService(CollaborationEventService):
31+
"""SQLite WAL-backed collaboration event mailbox keyed by ``flow_id``.
32+
33+
Uses stdlib ``sqlite3`` so multiple uvicorn/gunicorn workers on the same host
34+
can share one cache-dir database file. This is polling-based fanout, not
35+
pub/sub.
36+
"""
37+
38+
TTL_SECONDS: float = 120.0
39+
MAX_EVENTS_PER_FLOW: int = 1000
40+
DEFAULT_POLL_LIMIT: int = 200
41+
42+
def __init__(self, cache_dir: str | Path | None = None) -> None:
43+
if cache_dir is None:
44+
cache_dir = Path(tempfile.gettempdir()) / "langflow_collaboration_events"
45+
cache_dir = Path(cache_dir)
46+
cache_dir.mkdir(parents=True, exist_ok=True)
47+
self._db_path = cache_dir / "collaboration_events.sqlite"
48+
self._conn = sqlite3.connect(
49+
str(self._db_path),
50+
isolation_level=None,
51+
check_same_thread=False,
52+
timeout=5.0,
53+
)
54+
# SQLite locks coordinate access across connections/processes. This lock
55+
# coordinates threads in this worker that share the same connection object.
56+
self._lock = threading.Lock()
57+
with self._lock:
58+
self._conn.execute("PRAGMA journal_mode=WAL")
59+
self._conn.execute("PRAGMA synchronous=NORMAL")
60+
self._conn.execute("PRAGMA busy_timeout=5000")
61+
self._conn.executescript(_SCHEMA)
62+
63+
def publish(self, flow_id: UUID, event_type: str, payload: dict) -> CollaborationEvent:
64+
flow_id_key = str(flow_id)
65+
if not event_type:
66+
msg = "event_type is required"
67+
raise ValueError(msg)
68+
if payload is None:
69+
msg = "payload must be a dict"
70+
raise TypeError(msg)
71+
72+
event_id = str(uuid.uuid4())
73+
event_payload = dict(payload)
74+
payload_json = json.dumps(event_payload)
75+
76+
with self._lock:
77+
self._conn.execute("BEGIN IMMEDIATE")
78+
try:
79+
now = time.time()
80+
event = CollaborationEvent(
81+
id=event_id,
82+
flow_id=flow_id,
83+
created_at=now,
84+
type=event_type,
85+
payload=event_payload,
86+
)
87+
expires_at = now + self.TTL_SECONDS
88+
self._purge_expired_locked(now)
89+
self._conn.execute(
90+
"""
91+
INSERT INTO collaboration_events
92+
(id, flow_id, created_at, type, payload, expires_at)
93+
VALUES (?, ?, ?, ?, ?, ?)
94+
""",
95+
(event_id, flow_id_key, now, event_type, payload_json, expires_at),
96+
)
97+
self._enforce_per_flow_cap_locked(flow_id_key)
98+
self._conn.execute("COMMIT")
99+
except Exception:
100+
self._conn.execute("ROLLBACK")
101+
raise
102+
103+
return event
104+
105+
def poll(
106+
self,
107+
flow_id: UUID,
108+
*,
109+
cursor: CollaborationPollCursor | None = None,
110+
limit: int | None = None,
111+
) -> tuple[list[CollaborationEvent], CollaborationPollCursor]:
112+
flow_id_key = str(flow_id)
113+
114+
poll_limit = self.DEFAULT_POLL_LIMIT if limit is None else limit
115+
if poll_limit < 1:
116+
msg = "limit must be at least 1"
117+
raise ValueError(msg)
118+
119+
cursor = cursor or CollaborationPollCursor()
120+
# Match SQLite's cross-worker granularity: cleanup and read are separate statements,
121+
# not one local-only critical section that blocks same-worker publishes.
122+
with self._lock:
123+
now = time.time()
124+
self._purge_expired_locked(now)
125+
126+
with self._lock:
127+
now = time.time()
128+
rows = self._conn.execute(
129+
"""
130+
SELECT id, created_at, type, payload
131+
FROM collaboration_events
132+
WHERE flow_id = ?
133+
AND expires_at >= ?
134+
AND (
135+
created_at > ?
136+
OR (created_at = ? AND id > ?)
137+
)
138+
ORDER BY created_at ASC, id ASC
139+
LIMIT ?
140+
""",
141+
(
142+
flow_id_key,
143+
now,
144+
cursor.created_at,
145+
cursor.created_at,
146+
cursor.event_id,
147+
poll_limit,
148+
),
149+
).fetchall()
150+
151+
events = [
152+
CollaborationEvent(
153+
id=row[0],
154+
flow_id=flow_id,
155+
created_at=row[1],
156+
type=row[2],
157+
payload=json.loads(row[3]),
158+
)
159+
for row in rows
160+
]
161+
162+
if not events:
163+
return [], cursor
164+
165+
last = events[-1]
166+
return events, CollaborationPollCursor(created_at=last.created_at, event_id=last.id)
167+
168+
def cleanup(self) -> None:
169+
with self._lock:
170+
now = time.time()
171+
self._purge_expired_locked(now)
172+
173+
async def teardown(self) -> None:
174+
with self._lock:
175+
self._conn.close()
176+
177+
def _purge_expired_locked(self, now: float) -> None:
178+
self._conn.execute("DELETE FROM collaboration_events WHERE expires_at < ?", (now,))
179+
180+
def _enforce_per_flow_cap_locked(self, flow_id: str) -> None:
181+
# SQLite: LIMIT -1 means no row cap; OFFSET skips the N newest rows so we delete older ones.
182+
self._conn.execute(
183+
"""
184+
DELETE FROM collaboration_events
185+
WHERE rowid IN (
186+
SELECT rowid FROM collaboration_events
187+
WHERE flow_id = ?
188+
ORDER BY created_at DESC, id DESC
189+
LIMIT -1 OFFSET ?
190+
)
191+
""",
192+
(flow_id, self.MAX_EVENTS_PER_FLOW),
193+
)

src/backend/base/langflow/services/deps.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,12 @@ def get_flow_events_service():
280280
return get_service(ServiceType.FLOW_EVENTS_SERVICE, FlowEventsServiceFactory())
281281

282282

283+
def get_collaboration_events_service():
284+
from langflow.services.collaboration_events.factory import CollaborationEventServiceFactory
285+
286+
return get_service(ServiceType.COLLABORATION_EVENTS_SERVICE, CollaborationEventServiceFactory())
287+
288+
283289
def get_flow_operation_service() -> BaseFlowOperationService:
284290
"""Retrieves the FlowOperationService instance from the service manager."""
285291
from lfx.services.flow_operations.factory import FlowOperationServiceFactory

src/backend/base/langflow/services/factory.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,8 @@ def import_all_services_into_a_dict():
7676
try:
7777
service_name = ServiceType(service_type).value.replace("_service", "")
7878

79-
# Special handling for mcp_composer which is now in lfx module
80-
if service_name == "mcp_composer":
79+
# Services implemented in the lfx package (not langflow.services.*)
80+
if service_name in {"mcp_composer", "flow_operations"}:
8181
module_name = f"lfx.services.{service_name}.service"
8282
else:
8383
module_name = f"langflow.services.{service_name}.service"

src/backend/base/langflow/services/schema.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,6 @@ class ServiceType(str, Enum):
2323
MCP_COMPOSER_SERVICE = "mcp_composer_service"
2424
JOB_SERVICE = "jobs_service"
2525
FLOW_EVENTS_SERVICE = "flow_events_service"
26+
COLLABORATION_EVENTS_SERVICE = "collaboration_events_service"
2627
FLOW_OPERATIONS_SERVICE = "flow_operations_service"
2728
MEMORY_BASE_SERVICE = "memory_base_service"

0 commit comments

Comments
 (0)