fix: Collaboration debounce shared between docs (#3401)

* fix: Collaboration debounce shared between docs

* Rename, Tracing -> Metrics

* Add tracing

* tsc

* fix: Lock document row when loading document in collaboration service incase state needs writing

* fix: Incorrect service name regression
This commit is contained in:
Tom Moor
2022-04-16 14:58:17 -07:00
committed by GitHub
parent 1a8f2c3bb0
commit 4c4b80ba9b
9 changed files with 192 additions and 156 deletions

View File

@@ -1,10 +1,14 @@
import { onAuthenticatePayload } from "@hocuspocus/server";
import { onAuthenticatePayload, Extension } from "@hocuspocus/server";
import { APM } from "@server/logging/tracing";
import Document from "@server/models/Document";
import { can } from "@server/policies";
import { getUserForJWT } from "@server/utils/jwt";
import { AuthenticationError } from "../errors";
export default class Authentication {
@APM.trace({
spanName: "authentication",
})
export default class AuthenticationExtension implements Extension {
async onAuthenticate({
connection,
token,

View File

@@ -2,13 +2,14 @@ import {
onConnectPayload,
onDisconnectPayload,
onLoadDocumentPayload,
Extension,
} from "@hocuspocus/server";
import Logger from "@server/logging/logger";
export default class CollaborationLogger {
export default class LoggerExtension implements Extension {
async onLoadDocument(data: onLoadDocumentPayload) {
Logger.info("hocuspocus", `Loaded document "${data.documentName}"`, {
userId: data.context.user.id,
userId: data.context.user?.id,
});
}
@@ -17,6 +18,8 @@ export default class CollaborationLogger {
}
async onDisconnect(data: onDisconnectPayload) {
Logger.info("hocuspocus", `Connection to "${data.documentName}" closed `);
Logger.info("hocuspocus", `Closed connection to "${data.documentName}"`, {
userId: data.context.user?.id,
});
}
}

View File

@@ -3,11 +3,12 @@ import {
onConnectPayload,
onDisconnectPayload,
onLoadDocumentPayload,
Extension,
} from "@hocuspocus/server";
import Metrics from "@server/logging/metrics";
export default class Tracing {
onLoadDocument({ documentName, instance }: onLoadDocumentPayload) {
export default class MetricsExtension implements Extension {
async onLoadDocument({ documentName, instance }: onLoadDocumentPayload) {
Metrics.increment("collaboration.load_document", {
documentName,
});
@@ -23,7 +24,7 @@ export default class Tracing {
});
}
onConnect({ documentName, instance }: onConnectPayload) {
async onConnect({ documentName, instance }: onConnectPayload) {
Metrics.increment("collaboration.connect", {
documentName,
});
@@ -33,7 +34,7 @@ export default class Tracing {
);
}
onDisconnect({ documentName, instance }: onDisconnectPayload) {
async onDisconnect({ documentName, instance }: onDisconnectPayload) {
Metrics.increment("collaboration.disconnect", {
documentName,
});
@@ -47,13 +48,13 @@ export default class Tracing {
);
}
onChange({ documentName }: onChangePayload) {
async onStoreDocument({ documentName }: onChangePayload) {
Metrics.increment("collaboration.change", {
documentName,
});
}
onDestroy() {
async onDestroy() {
Metrics.gaugePerInstance("collaboration.connections_count", 0);
Metrics.gaugePerInstance("collaboration.documents_count", 0);
}

View File

@@ -0,0 +1,86 @@
import {
onStoreDocumentPayload,
onLoadDocumentPayload,
Extension,
} from "@hocuspocus/server";
import invariant from "invariant";
import * as Y from "yjs";
import { sequelize } from "@server/database/sequelize";
import Logger from "@server/logging/logger";
import { APM } from "@server/logging/tracing";
import Document from "@server/models/Document";
import documentUpdater from "../commands/documentUpdater";
import markdownToYDoc from "./utils/markdownToYDoc";
@APM.trace({
spanName: "persistence",
})
export default class PersistenceExtension implements Extension {
async onLoadDocument({ documentName, ...data }: onLoadDocumentPayload) {
const [, documentId] = documentName.split(".");
const fieldName = "default";
// Check if the given field already exists in the given y-doc. This is import
// so we don't import a document fresh if it exists already.
if (!data.document.isEmpty(fieldName)) {
return;
}
return await sequelize.transaction(async (transaction) => {
const document = await Document.scope("withState").findOne({
transaction,
lock: transaction.LOCK.UPDATE,
where: {
id: documentId,
},
});
invariant(document, "Document not found");
if (document.state) {
const ydoc = new Y.Doc();
Logger.info("database", `Document ${documentId} is in database state`);
Y.applyUpdate(ydoc, document.state);
return ydoc;
}
Logger.info(
"database",
`Document ${documentId} is not in state, creating from markdown`
);
const ydoc = markdownToYDoc(document.text, fieldName);
const state = Y.encodeStateAsUpdate(ydoc);
await document.update(
{
state: Buffer.from(state),
},
{
hooks: false,
transaction,
}
);
return ydoc;
});
}
async onStoreDocument({
document,
context,
documentName,
}: onStoreDocumentPayload) {
const [, documentId] = documentName.split(".");
Logger.info("database", `Persisting ${documentId}`);
try {
await documentUpdater({
documentId,
ydoc: document,
userId: context.user?.id,
});
} catch (err) {
Logger.error("Unable to persist document", err, {
documentId,
userId: context.user?.id,
});
}
}
}

View File

@@ -1,77 +0,0 @@
import { onChangePayload, onLoadDocumentPayload } from "@hocuspocus/server";
import invariant from "invariant";
import { debounce } from "lodash";
import * as Y from "yjs";
import Logger from "@server/logging/logger";
import Document from "@server/models/Document";
import documentUpdater from "../commands/documentUpdater";
import markdownToYDoc from "./utils/markdownToYDoc";
const DELAY = 3000;
export default class Persistence {
async onLoadDocument({ documentName, ...data }: onLoadDocumentPayload) {
const [, documentId] = documentName.split(".");
const fieldName = "default";
// Check if the given field already exists in the given y-doc. This is import
// so we don't import a document fresh if it exists already.
if (!data.document.isEmpty(fieldName)) {
return;
}
const document = await Document.scope("withState").findOne({
where: {
id: documentId,
},
});
invariant(document, "Document not found");
if (document.state) {
const ydoc = new Y.Doc();
Logger.info("database", `Document ${documentId} is in database state`);
Y.applyUpdate(ydoc, document.state);
return ydoc;
}
Logger.info(
"database",
`Document ${documentId} is not in state, creating from markdown`
);
const ydoc = markdownToYDoc(document.text, fieldName);
const state = Y.encodeStateAsUpdate(ydoc);
await document.update(
{
state: Buffer.from(state),
},
{
hooks: false,
}
);
return ydoc;
}
onChange = debounce(
async ({ document, context, documentName }: onChangePayload) => {
const [, documentId] = documentName.split(".");
Logger.info("database", `Persisting ${documentId}`);
try {
await documentUpdater({
documentId,
ydoc: document,
userId: context.user?.id,
});
} catch (err) {
Logger.error("Unable to persist document", err, {
documentId,
userId: context.user?.id,
});
}
},
DELAY,
{
maxWait: DELAY * 3,
}
);
}

View File

@@ -10,7 +10,7 @@ if (process.env.DD_API_KEY) {
// SOURCE_COMMIT is used by Docker Hub
// SOURCE_VERSION is used by Heroku
version: process.env.SOURCE_COMMIT || process.env.SOURCE_VERSION,
service: "outline",
service: process.env.DD_SERVICE || "outline",
},
{
useMock: process.env.NODE_ENV === "test",

View File

@@ -4,26 +4,28 @@ import { Server } from "@hocuspocus/server";
import invariant from "invariant";
import Koa from "koa";
import WebSocket from "ws";
import AuthenticationExtension from "../collaboration/authentication";
import LoggerExtension from "../collaboration/logger";
import PersistenceExtension from "../collaboration/persistence";
import TracingExtension from "../collaboration/tracing";
import AuthenticationExtension from "../collaboration/AuthenticationExtension";
import LoggerExtension from "../collaboration/LoggerExtension";
import MetricsExtension from "../collaboration/MetricsExtension";
import PersistenceExtension from "../collaboration/PersistenceExtension";
export default function init(app: Koa, server: http.Server) {
const path = "/collaboration";
const wss = new WebSocket.Server({
noServer: true,
});
const hocuspocus = Server.configure({
debounce: 3000,
maxDebounce: 10000,
extensions: [
new AuthenticationExtension(),
// @ts-expect-error ts-migrate(2322) FIXME: Type 'Persistence' is not assignable to type 'Exte... Remove this comment to see the full error message
new PersistenceExtension(),
new LoggerExtension(),
// @ts-expect-error ts-migrate(2322) FIXME: Type 'Persistence' is not assignable to type 'Exte... Remove this comment to see the full error message
new TracingExtension(),
new MetricsExtension(),
],
});
server.on("upgrade", function (req, socket, head) {
if (req.url && req.url.indexOf(path) > -1) {
const documentName = url.parse(req.url).pathname?.split("/").pop();
@@ -34,7 +36,8 @@ export default function init(app: Koa, server: http.Server) {
});
}
});
server.on("shutdown", () => {
hocuspocus.destroy();
return hocuspocus.destroy();
});
}