From 5cd4ecd34a6a55f116c0e8d5983feb0684955fc1 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 1 May 2022 08:30:07 -0700 Subject: [PATCH] fix: CRDT creation touches document updated timestamp (#3482) fix: Race condition in collaboration document persistence --- server/collaboration/PersistenceExtension.ts | 1 + .../commands/documentCollaborativeUpdater.ts | 101 ++++++++++-------- 2 files changed, 56 insertions(+), 46 deletions(-) diff --git a/server/collaboration/PersistenceExtension.ts b/server/collaboration/PersistenceExtension.ts index 34974f9b0..5cdc3f416 100644 --- a/server/collaboration/PersistenceExtension.ts +++ b/server/collaboration/PersistenceExtension.ts @@ -54,6 +54,7 @@ export default class PersistenceExtension implements Extension { state: Buffer.from(state), }, { + silent: true, hooks: false, transaction, } diff --git a/server/commands/documentCollaborativeUpdater.ts b/server/commands/documentCollaborativeUpdater.ts index 66a889990..2a9a06a37 100644 --- a/server/commands/documentCollaborativeUpdater.ts +++ b/server/commands/documentCollaborativeUpdater.ts @@ -3,6 +3,7 @@ import invariant from "invariant"; import { uniq } from "lodash"; import { Node } from "prosemirror-model"; import * as Y from "yjs"; +import { sequelize } from "@server/database/sequelize"; import { schema, serializer } from "@server/editor"; import { Document, Event } from "@server/models"; @@ -15,56 +16,64 @@ export default async function documentCollaborativeUpdater({ ydoc: Y.Doc; userId?: string; }) { - const document = await Document.scope("withState").findByPk(documentId); - invariant(document, "document not found"); + return sequelize.transaction(async (transaction) => { + const document = await Document.unscoped() + .scope("withState") + .findByPk(documentId, { + transaction, + lock: { + of: Document, + level: transaction.LOCK.UPDATE, + }, + paranoid: false, + }); + invariant(document, "document not found"); - const state = Y.encodeStateAsUpdate(ydoc); - const node = Node.fromJSON(schema, yDocToProsemirrorJSON(ydoc, "default")); - const text = serializer.serialize(node, undefined); - const isUnchanged = document.text === text; - const hasMultiplayerState = !!document.state; + const state = Y.encodeStateAsUpdate(ydoc); + const node = Node.fromJSON(schema, yDocToProsemirrorJSON(ydoc, "default")); + const text = serializer.serialize(node, undefined); + const isUnchanged = document.text === text; + const hasMultiplayerState = !!document.state; - if (isUnchanged && hasMultiplayerState) { - return; - } - - // extract collaborators from doc user data - const pud = new Y.PermanentUserData(ydoc); - const pudIds = Array.from(pud.clients.values()); - const existingIds = document.collaboratorIds; - const collaboratorIds = uniq([...pudIds, ...existingIds]); - - await Document.scope(["withDrafts", "withState"]).update( - { - text, - state: Buffer.from(state), - updatedAt: isUnchanged ? document.updatedAt : new Date(), - lastModifiedById: - isUnchanged || !userId ? document.lastModifiedById : userId, - collaboratorIds, - }, - { - silent: true, - hooks: false, - where: { - id: documentId, - }, + if (isUnchanged && hasMultiplayerState) { + return; } - ); - if (isUnchanged) { - return; - } + // extract collaborators from doc user data + const pud = new Y.PermanentUserData(ydoc); + const pudIds = Array.from(pud.clients.values()); + const existingIds = document.collaboratorIds; + const collaboratorIds = uniq([...pudIds, ...existingIds]); - await Event.schedule({ - name: "documents.update", - documentId: document.id, - collectionId: document.collectionId, - teamId: document.teamId, - actorId: userId, - data: { - multiplayer: true, - title: document.title, - }, + await document.update( + { + text, + state: Buffer.from(state), + lastModifiedById: + isUnchanged || !userId ? document.lastModifiedById : userId, + collaboratorIds, + }, + { + transaction, + silent: isUnchanged, + hooks: false, + } + ); + + if (isUnchanged) { + return; + } + + await Event.schedule({ + name: "documents.update", + documentId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: userId, + data: { + multiplayer: true, + title: document.title, + }, + }); }); }