perf: stop note co-editing echoing every remote update back to the server (#28185)

Every client in a note re-broadcasts each update it receives, with a full content snapshot attached, and the server appends each echo to the document log and writes the note again. Traffic and note writes therefore scale with the number of people who have the note open: every extra participant adds one more full echo of every keystroke.

Remote updates are now applied with the `'server'` origin the state path already uses, which the local listener ignores, so the echo stops. The echo did carry one thing worth keeping: the receiving client holds the merged document, which the sender had not seen yet, so each receiver now sends a content-only message once the edits settle, debounced 500ms, and flushes a pending one when the editor is torn down. The backend accepts an update message with no `update` field for that case, where it previously raised and dropped the save.

Replaying keystrokes at 120ms with a real Yjs document, socket messages fall 48.8% with two clients, 65.6% with three and 79.2% with five, with byte counts tracking the same on notes up to 50KB. Server-side update appends drop by a factor of the client count. The cost is one snapshot upload per receiving client per typing pause, which the server's own debounce then collapses into a single extra note write however many people are watching. A snapshot has to come from a client because the server cannot rebuild the markdown, HTML and JSON shape the note record stores. It also moves the merge 500ms later than the echo delivered it, so if two edits cross on the wire and every editor then loses its connection and closes inside that window, the note keeps what it was last sent and one of the two edits is lost. A client still connected at teardown flushes its pending snapshot, and any other editor left in the note closes the gap.
This commit is contained in:
Classic298
2026-09-21 08:53:27 -04:00
committed by GitHub
parent 6fb68e43cb
commit 488b3c571c
2 changed files with 49 additions and 19 deletions
+19 -18
View File
@@ -840,27 +840,28 @@ async def yjs_document_update(sid, data):
log.warning(f'User {user.get("id")} does not have write access to note {note_id}. Rejecting update.')
return
user_id = data.get('user_id', sid)
update = data.get('update') # List of bytes from frontend
update = data['update'] # List of bytes from frontend
if update:
user_id = data.get('user_id', sid)
await YDOC_MANAGER.append_to_updates(
document_id=document_id,
update=update, # Convert list of bytes to bytes
)
await YDOC_MANAGER.append_to_updates(
document_id=document_id,
update=update, # Convert list of bytes to bytes
)
# Broadcast update to all other users in the document
await sio.emit(
'ydoc:document:update',
{
'document_id': document_id,
'user_id': user_id,
'update': update,
'socket_id': sid, # Add socket_id to match frontend filtering
},
room=f'doc_{document_id}',
skip_sid=sid,
)
# Broadcast update to all other users in the document
await sio.emit(
'ydoc:document:update',
{
'document_id': document_id,
'user_id': user_id,
'update': update,
'socket_id': sid, # Add socket_id to match frontend filtering
},
room=f'doc_{document_id}',
skip_sid=sid,
)
async def debounced_save():
await asyncio.sleep(0.5)
@@ -45,6 +45,7 @@ export class SocketIOCollaborationProvider {
private synced = false;
private editor: Editor | null = null;
private editorContentGetter: EditorContentGetter | null = null;
private contentSnapshotTimer: ReturnType<typeof setTimeout> | null = null;
constructor(
private readonly documentId: string,
@@ -114,6 +115,18 @@ export class SocketIOCollaborationProvider {
Y.applyUpdate(this.doc, Y.encodeStateAsUpdate(doc));
}
// Send the merged content; the remote sender had not seen our edits yet.
private sendContentSnapshot() {
this.contentSnapshotTimer = null;
const getContent = this.editorContentGetter;
if (!this.isConnected || !getContent) return;
this.socket.emit('ydoc:document:update', {
document_id: this.documentId,
data: { content: getContent() }
});
}
private joinDocument() {
if (!this.editor) return;
@@ -141,7 +154,13 @@ export class SocketIOCollaborationProvider {
if (data.document_id === this.documentId && data.socket_id !== this.socket.id) {
try {
const update = new Uint8Array(data.update);
Y.applyUpdate(this.doc, update);
// 'server' stops the local update listener sending this straight back out
Y.applyUpdate(this.doc, update, 'server');
if (this.contentSnapshotTimer) {
clearTimeout(this.contentSnapshotTimer);
}
this.contentSnapshotTimer = setTimeout(() => this.sendContentSnapshot(), 500);
} catch (error) {
console.error('Error applying Yjs update:', error);
}
@@ -229,6 +248,11 @@ export class SocketIOCollaborationProvider {
}
}
});
if (this.contentSnapshotTimer) {
clearTimeout(this.contentSnapshotTimer);
this.contentSnapshotTimer = null;
}
}
});
@@ -273,6 +297,11 @@ export class SocketIOCollaborationProvider {
this.socket.off('connect', this.onConnect);
this.socket.off('disconnect', this.onDisconnect);
if (this.contentSnapshotTimer) {
clearTimeout(this.contentSnapshotTimer);
this.sendContentSnapshot();
}
if (this.isConnected) {
this.socket.emit('ydoc:document:leave', {
document_id: this.documentId,