[monorepo] prepare for per-project pub/sub for applied ops (#35663)

GitOrigin-RevId: 11d45addf366c3449cad432272ed03d4c8dae49d
This commit is contained in:
Jakob Ackermann
2026-09-17 11:33:01 +00:00
committed by Copybot
parent 1b2da240d5
commit 3565f51a84
13 changed files with 723 additions and 71 deletions
@@ -151,7 +151,7 @@ async function applyUpdate(projectId, docId, update) {
RealTimeRedisManager.sendData({
project_id: projectId,
doc_id: docId,
error: error instanceof Error ? error.message : error,
error: error instanceof Error ? error.message : String(error),
})
profiler.log('sendData')
throw error
@@ -140,22 +140,46 @@ const RealTimeRedisManager = {
},
/**
* Publish an applied-ops message, stamped with a unique message id.
* Publish an applied op or an error for a doc back to real-time.
*
* @param {Object} data
* @param {string} data.project_id - routes the message to the project's
* editor-events channel (when publishAppliedOpsOnEditorEvents is
* enabled) and to the project room in real-time
* @param {string} data.doc_id - routes the message to the doc's applied-ops
* channel (legacy path) and identifies the doc for clients
* @param {Object} [data.op] - the applied op
* @param {string} [data.error] - the error message when applying failed
*/
sendData(data) {
// create a unique message id using a counter
const messageId = `doc:${HOST}:${RND}-${COUNT++}`
if (data != null) {
data._id = messageId
data._id = messageId
// Send applied ops on the per-project editor-events channel, which
// real-time subscribes to for the lifetime of every connected project,
// instead of the legacy per-doc applied-ops channel.
const path = Settings.publishAppliedOpsOnEditorEvents
? 'editor-events'
: 'applied-ops'
if (path === 'editor-events') {
// the message name routes the message to the applied-ops handling in
// real-time's editor-events processing
data.message = data.op ? 'otUpdateApplied' : 'otUpdateError'
}
const blob = JSON.stringify(data)
metrics.summary('redis.publish.applied-ops', blob.length)
metrics.summary('redis.publish.applied-ops', blob.length, { path })
// publish on separate channels for individual projects and docs when
// configured (needs realtime to be configured for this too).
if (path === 'editor-events') {
if (Settings.publishOnIndividualChannels) {
return pubsubClient.publish(`editor-events:${data.project_id}`, blob)
} else {
return pubsubClient.publish('editor-events', blob)
}
}
if (Settings.publishOnIndividualChannels) {
return pubsubClient.publish(`applied-ops:${data.doc_id}`, blob)
} else {
@@ -266,7 +266,7 @@ const UpdateManager = {
RealTimeRedisManager.sendData({
project_id: projectId,
doc_id: docId,
error: error instanceof Error ? error.message : error,
error: error instanceof Error ? error.message : String(error),
})
profile.log('sendData')
throw error
@@ -185,6 +185,16 @@ module.exports = {
publishOnIndividualChannels:
process.env.PUBLISH_ON_INDIVIDUAL_CHANNELS === 'true',
// Migration of the applied-ops pub/sub (document-updater -> real-time) from
// the per-doc applied-ops channel to the per-project editor-events channel.
// See docs/per-project-applied-ops-pubsub-migration.md. Requires all
// real-time instances to handle applied-ops on editor-events before being
// enabled. Composes with publishOnIndividualChannels, which picks between
// the base channel and the per-doc/per-project channel of the family
// selected here.
publishAppliedOpsOnEditorEvents:
process.env.PUBLISH_APPLIED_OPS_ON_EDITOR_EVENTS === 'true',
continuousBackgroundFlush: process.env.CONTINUOUS_BACKGROUND_FLUSH === 'true',
smoothingOffset: process.env.SMOOTHING_OFFSET || 1000, // milliseconds
@@ -658,6 +658,85 @@ describe('Applying updates to a doc', function () {
})
})
describe('with publishAppliedOpsOnEditorEvents enabled', function () {
beforeEach(function () {
Settings.publishAppliedOpsOnEditorEvents = true
})
afterEach(function () {
Settings.publishAppliedOpsOnEditorEvents = false
})
describe('with an applied update', function () {
beforeEach(async function () {
MockWebApi.insertDoc(this.project_id, this.doc_id, {
lines: this.lines,
version: this.version,
})
DocUpdaterClient.subscribeToAppliedOps(
(this.messageCallback = sinon.stub())
)
await sendUpdateAndWait(this.project_id, this.doc_id, this.update)
})
it('should update the doc', async function () {
const doc = await DocUpdaterClient.getDoc(this.project_id, this.doc_id)
doc.lines.should.deep.equal(this.result)
})
it('should publish the applied op on the editor-events channel', function () {
this.messageCallback.called.should.equal(true)
const [channel, message] = this.messageCallback.args[0]
channel.should.equal('editor-events')
const parsedMessage = JSON.parse(message)
parsedMessage.should.deep.include({
project_id: this.project_id,
doc_id: this.doc_id,
message: 'otUpdateApplied',
})
parsedMessage.op.v.should.equal(this.version)
})
})
describe('with a broken update', function () {
beforeEach(async function () {
this.broken_update = {
doc: this.doc_id,
v: this.version,
op: [{ d: 'not the correct content', p: 0 }],
}
MockWebApi.insertDoc(this.project_id, this.doc_id, {
lines: this.lines,
version: this.version,
})
DocUpdaterClient.subscribeToAppliedOps(
(this.messageCallback = sinon.stub())
)
await sendUpdateAndWait(
this.project_id,
this.doc_id,
this.broken_update
)
})
it('should publish the error on the editor-events channel', function () {
this.messageCallback.called.should.equal(true)
const [channel, message] = this.messageCallback.args[0]
channel.should.equal('editor-events')
JSON.parse(message).should.deep.include({
project_id: this.project_id,
doc_id: this.doc_id,
message: 'otUpdateError',
error: 'Delete component does not match',
})
})
})
})
describe('with a broken update (history-ot)', function () {
beforeEach(async function () {
this.broken_update = {
@@ -12,6 +12,9 @@ const rclientSub = require('@overleaf/redis-wrapper').createClient(
Settings.redis.pubsub
)
rclientSub.subscribe('applied-ops')
// applied ops are published on the editor-events channel instead when
// publishAppliedOpsOnEditorEvents is enabled
rclientSub.subscribe('editor-events')
rclientSub.setMaxListeners(0)
function getPendingUpdateListKey() {
@@ -33,7 +36,12 @@ module.exports = DocUpdaterClient = {
},
subscribeToAppliedOps(messageHandler) {
rclientSub.on('message', messageHandler)
rclientSub.on('message', (channel, message) => {
// every applied op also publishes a bandwidth-estimation canary on the
// editor-events channel; drop those so tests only see the applied ops
if (message.includes('"canary-applied-op"')) return
messageHandler(channel, message)
})
},
// Enqueue an update for the doc-updater: push the payload (tagged with its
@@ -28,7 +28,7 @@ describe('RealTimeRedisManager', function () {
createClient: config =>
config.name === 'pubsub' ? this.pubsubClient : this.rclient,
},
'@overleaf/settings': {
'@overleaf/settings': (this.Settings = {
redis: {
documentupdater: (this.settings = {
key_schema: {
@@ -41,7 +41,7 @@ describe('RealTimeRedisManager', function () {
name: 'pubsub',
},
},
},
}),
crypto: (this.crypto = {
randomBytes: sinon
.stub()
@@ -143,25 +143,113 @@ describe('RealTimeRedisManager', function () {
return describe('sendData', function () {
beforeEach(function () {
this.message_id = 'doc:somehost:01020304-0'
return this.RealTimeRedisManager.sendData({ op: 'thisop' })
this.data = {
project_id: this.project_id,
doc_id: this.doc_id,
op: 'thisop',
}
this.blob = JSON.stringify({ ...this.data, _id: this.message_id })
})
it('should send the op with a message id', function () {
return this.pubsubClient.publish
.calledWith(
'applied-ops',
JSON.stringify({ op: 'thisop', _id: this.message_id })
)
.should.equal(true)
describe('on the base applied-ops channel', function () {
beforeEach(function () {
this.RealTimeRedisManager.sendData(this.data)
})
it('should send the op with a message id', function () {
return this.pubsubClient.publish
.calledWith('applied-ops', this.blob)
.should.equal(true)
})
it('should track the payload size', function () {
return this.metrics.summary
.calledWith('redis.publish.applied-ops', this.blob.length, {
path: 'applied-ops',
})
.should.equal(true)
})
})
return it('should track the payload size', function () {
return this.metrics.summary
.calledWith(
'redis.publish.applied-ops',
JSON.stringify({ op: 'thisop', _id: this.message_id }).length
)
.should.equal(true)
describe('on the per-doc applied-ops channel', function () {
beforeEach(function () {
this.Settings.publishOnIndividualChannels = true
this.RealTimeRedisManager.sendData(this.data)
})
it('should send the op on the per-doc channel', function () {
return this.pubsubClient.publish
.calledWith(`applied-ops:${this.doc_id}`, this.blob)
.should.equal(true)
})
})
describe('on the base editor-events channel', function () {
beforeEach(function () {
this.Settings.publishAppliedOpsOnEditorEvents = true
this.blob = JSON.stringify({
...this.data,
_id: this.message_id,
message: 'otUpdateApplied',
})
this.RealTimeRedisManager.sendData(this.data)
})
it('should send the op with a message id and message name', function () {
return this.pubsubClient.publish
.calledWith('editor-events', this.blob)
.should.equal(true)
})
it('should track the payload size', function () {
return this.metrics.summary
.calledWith('redis.publish.applied-ops', this.blob.length, {
path: 'editor-events',
})
.should.equal(true)
})
})
describe('on the per-project editor-events channel', function () {
beforeEach(function () {
this.Settings.publishAppliedOpsOnEditorEvents = true
this.Settings.publishOnIndividualChannels = true
this.blob = JSON.stringify({
...this.data,
_id: this.message_id,
message: 'otUpdateApplied',
})
this.RealTimeRedisManager.sendData(this.data)
})
it('should send the op on the per-project channel', function () {
return this.pubsubClient.publish
.calledWith(`editor-events:${this.project_id}`, this.blob)
.should.equal(true)
})
})
describe('with an error on the editor-events channel', function () {
beforeEach(function () {
this.Settings.publishAppliedOpsOnEditorEvents = true
this.data = {
project_id: this.project_id,
doc_id: this.doc_id,
error: 'something went wrong',
}
this.blob = JSON.stringify({
...this.data,
_id: this.message_id,
message: 'otUpdateError',
})
this.RealTimeRedisManager.sendData(this.data)
})
it('should send the error with the otUpdateError message name', function () {
return this.pubsubClient.publish
.calledWith('editor-events', this.blob)
.should.equal(true)
})
})
})
})
@@ -66,42 +66,61 @@ export default DocumentUpdaterController = {
logger.error({ err: error, channel }, 'error parsing JSON')
return
}
if (message.op) {
if (message._id && settings.checkEventOrder) {
const status = EventLogger.checkEventOrder(
'applied-ops',
message._id,
message
)
if (status === 'duplicate') {
return // skip duplicate events
}
}
DocumentUpdaterController._applyUpdateFromDocumentUpdater(
io,
message.doc_id,
message.op
)
} else if (message.error) {
DocumentUpdaterController._processErrorFromDocumentUpdater(
io,
message.doc_id,
message.error,
message
)
} else if (message.health_check) {
if (message.health_check) {
logger.debug(
{ message },
'got health check message in applied ops channel'
)
HealthCheckManager.check(channel, message.key)
return
}
// Messages on the legacy applied-ops channel are broadcast to the doc
// room, matching the per-doc channel granularity.
DocumentUpdaterController.handleAppliedOpMessage(
io,
message,
message.doc_id
)
})
},
_applyUpdateFromDocumentUpdater(io, docId, update) {
// Handle an already-parsed message from document-updater (an applied op
// or an error) and broadcast it to the given room: the doc room for
// messages from the legacy per-doc applied-ops channel, the project room
// for messages forwarded from the editor-events channel by the
// WebsocketLoadBalancer.
handleAppliedOpMessage(io, message, roomId) {
if (message.op) {
if (message._id && settings.checkEventOrder) {
const status = EventLogger.checkEventOrder(
'applied-ops',
message._id,
message
)
if (status === 'duplicate') {
return // skip duplicate events
}
}
DocumentUpdaterController._applyUpdateFromDocumentUpdater(
io,
roomId,
message.doc_id,
message.op
)
} else if (message.error) {
DocumentUpdaterController._processErrorFromDocumentUpdater(
io,
roomId,
message.doc_id,
message.error,
message
)
}
},
_applyUpdateFromDocumentUpdater(io, roomId, docId, update) {
let client
const clientList = io.sockets.clients(docId)
const clientList = io.sockets.clients(roomId)
// avoid unnecessary work if no clients are connected
if (clientList.length === 0) {
return
@@ -172,8 +191,14 @@ export default DocumentUpdaterController = {
}
},
_processErrorFromDocumentUpdater(io, docId, error, message) {
for (const client of io.sockets.clients(docId)) {
_processErrorFromDocumentUpdater(io, roomId, docId, error, message) {
for (const client of io.sockets.clients(roomId)) {
// When broadcasting via the project room, only clients that have
// joined the doc are affected by the error. (Always true for clients
// in the doc room.)
if (client.ol_context?.[`doc:${docId}`] !== 'allowed') {
continue
}
logger.warn(
{ err: error, docId, clientId: client.id },
'error from document updater, disconnecting client'
@@ -8,6 +8,7 @@ import HealthCheckManager from './HealthCheckManager.js'
import RoomManager from './RoomManager.js'
import ChannelManager from './ChannelManager.js'
import ConnectedUsersManager from './ConnectedUsersManager.js'
import DocumentUpdaterController from './DocumentUpdaterController.js'
const RESTRICTED_USER_MESSAGE_TYPE_PASS_LIST = [
'otUpdateApplied',
@@ -171,6 +172,28 @@ export default WebsocketLoadBalancer = {
estimateBandwidth(projectId, 'per-project')
estimateBandwidth(docId, 'per-doc')
} else if (
message.message === 'otUpdateApplied' ||
message.message === 'otUpdateError'
) {
// An applied op (or error) from document-updater, published on the
// per-project editor-events channel instead of the legacy per-doc
// applied-ops channel. Forward it for broadcasting to the project
// room.
const status = message.message === 'otUpdateApplied' ? 'op' : 'error'
Metrics.inc('applied-ops-via-editor-events', 1, { status })
if (!message.project_id) {
logger.error(
{ channel, message },
'applied-ops message without project_id'
)
} else {
DocumentUpdaterController.handleAppliedOpMessage(
io,
message,
message.project_id
)
}
} else if (message.room_id) {
if (message._id && Settings.checkEventOrder) {
const status = EventLogger.checkEventOrder(
@@ -389,6 +389,34 @@ describe('MatrixTests', function () {
})
})
describe('receive updates via editor-events', function () {
const receivedMessages = []
beforeEach(function publishAnUpdateInRedis(done) {
const update = {
project_id: privateProjectId,
doc_id: privateDocId,
message: 'otUpdateApplied',
op: {
meta: { source: privateClient.publicId },
v: 42,
doc: privateDocId,
op: [{ i: 'foo', p: 50 }],
},
}
client.on('otUpdateApplied', update => {
receivedMessages.push(update)
})
privateClient.once('otUpdateApplied', () => {
setTimeout(done, 10)
})
rclient.publish('editor-events', JSON.stringify(update))
})
it('should send nothing to client', function () {
expect(receivedMessages).to.have.length(0)
})
})
describe('receive messages from web', function () {
const receivedMessages = []
beforeEach(function publishAMessageInRedis(done) {
@@ -69,6 +69,11 @@ describe('receiveUpdate', function () {
return this.clientB.emit('joinDoc', this.doc_id, cb)
},
cb => {
// clientD is in the first project, but has not joined the doc
this.clientD = RealTimeClient.connect(this.project_id, cb)
},
cb => {
return FixturesManager.setUpProject(
{
@@ -116,6 +121,10 @@ describe('receiveUpdate', function () {
this.clientC.on('otUpdateApplied', update =>
this.clientCUpdates.push(update)
)
this.clientDUpdates = []
this.clientD.on('otUpdateApplied', update =>
this.clientDUpdates.push(update)
)
this.clientAErrors = []
this.clientA.on('otUpdateError', error =>
@@ -129,6 +138,10 @@ describe('receiveUpdate', function () {
this.clientC.on('otUpdateError', error =>
this.clientCErrors.push(error)
)
this.clientDErrors = []
this.clientD.on('otUpdateError', error =>
this.clientDErrors.push(error)
)
return cb()
},
],
@@ -143,7 +156,10 @@ describe('receiveUpdate', function () {
if (this.clientB != null) {
this.clientB.disconnect()
}
return this.clientC != null ? this.clientC.disconnect() : undefined
if (this.clientC != null) {
this.clientC.disconnect()
}
return this.clientD != null ? this.clientD.disconnect() : undefined
})
describe('with an update from clientA', function () {
@@ -246,6 +262,117 @@ describe('receiveUpdate', function () {
})
})
describe('with an update from clientA published on the editor-events channel', function () {
beforeEach(function (done) {
this.update = {
project_id: this.project_id,
doc_id: this.doc_id,
message: 'otUpdateApplied',
op: {
meta: {
source: this.clientA.publicId,
},
v: this.version,
doc: this.doc_id,
op: [{ i: 'foo', p: 50 }],
},
}
rclient.publish(
`editor-events:${this.project_id}`,
JSON.stringify(this.update)
)
setTimeout(done, 200)
}) // Give clients time to get message
it('should send the full op to clientB', function () {
this.clientBUpdates.should.deep.equal([this.update.op])
})
it('should send an ack to clientA', function () {
this.clientAUpdates.should.deep.equal([
{
v: this.version,
doc: this.doc_id,
},
])
})
it('should send the full op to clientD, who has not joined the doc', function () {
this.clientDUpdates.should.deep.equal([this.update.op])
})
it('should send nothing to clientC', function () {
this.clientCUpdates.should.deep.equal([])
})
})
describe('with an update from a remote client published on the base editor-events channel', function () {
// producers with PUBLISH_ON_INDIVIDUAL_CHANNELS disabled publish on the
// base channel instead of the per-project channel
beforeEach(function (done) {
this.update = {
project_id: this.project_id,
doc_id: this.doc_id,
message: 'otUpdateApplied',
op: {
meta: {
source: 'this-is-a-remote-client-id',
},
v: this.version,
doc: this.doc_id,
op: [{ i: 'foo', p: 50 }],
},
}
rclient.publish('editor-events', JSON.stringify(this.update))
setTimeout(done, 200)
}) // Give clients time to get message
it('should send the full op to the clients in the first project', function () {
this.clientAUpdates.should.deep.equal([this.update.op])
this.clientBUpdates.should.deep.equal([this.update.op])
this.clientDUpdates.should.deep.equal([this.update.op])
})
it('should send nothing to clientC', function () {
this.clientCUpdates.should.deep.equal([])
})
})
describe('with an error for the first project published on the editor-events channel', function () {
beforeEach(function (done) {
rclient.publish(
`editor-events:${this.project_id}`,
JSON.stringify({
project_id: this.project_id,
doc_id: this.doc_id,
message: 'otUpdateError',
error: (this.error = 'something went wrong'),
})
)
setTimeout(done, 200)
}) // Give clients time to get message
it('should send the error to the clients that joined the doc', function () {
this.clientAErrors.should.deep.equal([this.error])
this.clientBErrors.should.deep.equal([this.error])
})
it('should disconnect the clients that joined the doc', function () {
this.clientA.socket.connected.should.equal(false)
this.clientB.socket.connected.should.equal(false)
})
it('should not send the error to clientD, who has not joined the doc', function () {
this.clientDErrors.should.deep.equal([])
this.clientD.socket.connected.should.equal(true)
})
it('should not send any errors to the client in the second project', function () {
this.clientCErrors.should.deep.equal([])
this.clientC.socket.connected.should.equal(true)
})
})
describe('with an error for the first project', function () {
beforeEach(function (done) {
rclient.publish(
@@ -138,9 +138,9 @@ describe('DocumentUpdaterController', function () {
)
})
it('should apply the update', function (ctx) {
it('should apply the update to the doc room', function (ctx) {
ctx.EditorUpdatesController._applyUpdateFromDocumentUpdater
.calledWith(ctx.io, ctx.doc_id, ctx.message.op)
.calledWith(ctx.io, ctx.doc_id, ctx.doc_id, ctx.message.op)
.should.equal(true)
})
})
@@ -160,9 +160,89 @@ describe('DocumentUpdaterController', function () {
)
})
it('should process the error', function (ctx) {
it('should process the error in the doc room', function (ctx) {
ctx.EditorUpdatesController._processErrorFromDocumentUpdater
.calledWith(ctx.io, ctx.doc_id, ctx.message.error)
.calledWith(ctx.io, ctx.doc_id, ctx.doc_id, ctx.message.error)
.should.equal(true)
})
})
})
describe('handleAppliedOpMessage', function () {
describe('with an update forwarded from the editor-events channel', function () {
beforeEach(function (ctx) {
ctx.message = {
project_id: ctx.project_id,
doc_id: ctx.doc_id,
op: { t: 'foo', p: 12 },
}
ctx.EditorUpdatesController._applyUpdateFromDocumentUpdater =
sinon.stub()
ctx.EditorUpdatesController.handleAppliedOpMessage(
ctx.io,
ctx.message,
ctx.project_id
)
})
it('should apply the update to the project room', function (ctx) {
ctx.EditorUpdatesController._applyUpdateFromDocumentUpdater
.calledWith(ctx.io, ctx.project_id, ctx.doc_id, ctx.message.op)
.should.equal(true)
})
})
describe('with a duplicate update', function () {
beforeEach(function (ctx) {
ctx.settings.checkEventOrder = true
ctx.message = {
project_id: ctx.project_id,
doc_id: ctx.doc_id,
op: { t: 'foo', p: 12 },
_id: 'doc:host:rnd-1',
}
ctx.EventLogger.checkEventOrder.returns('duplicate')
ctx.EditorUpdatesController._applyUpdateFromDocumentUpdater =
sinon.stub()
ctx.EditorUpdatesController.handleAppliedOpMessage(
ctx.io,
ctx.message,
ctx.project_id
)
})
it('should check the event order on the applied-ops channel', function (ctx) {
ctx.EventLogger.checkEventOrder
.calledWith('applied-ops', ctx.message._id, ctx.message)
.should.equal(true)
})
it('should skip the update', function (ctx) {
ctx.EditorUpdatesController._applyUpdateFromDocumentUpdater.called.should.equal(
false
)
})
})
describe('with an error forwarded from the editor-events channel', function () {
beforeEach(function (ctx) {
ctx.message = {
project_id: ctx.project_id,
doc_id: ctx.doc_id,
error: 'Something went wrong',
}
ctx.EditorUpdatesController._processErrorFromDocumentUpdater =
sinon.stub()
ctx.EditorUpdatesController.handleAppliedOpMessage(
ctx.io,
ctx.message,
ctx.project_id
)
})
it('should process the error in the project room', function (ctx) {
ctx.EditorUpdatesController._processErrorFromDocumentUpdater
.calledWith(ctx.io, ctx.project_id, ctx.doc_id, ctx.message.error)
.should.equal(true)
})
})
@@ -194,6 +274,7 @@ describe('DocumentUpdaterController', function () {
ctx.EditorUpdatesController._applyUpdateFromDocumentUpdater(
ctx.io,
ctx.doc_id,
ctx.doc_id,
ctx.update
)
})
@@ -224,6 +305,7 @@ describe('DocumentUpdaterController', function () {
ctx.EditorUpdatesController._applyUpdateFromDocumentUpdater(
ctx.io,
ctx.doc_id,
ctx.doc_id,
ctx.update
)
})
@@ -240,28 +322,95 @@ describe('DocumentUpdaterController', function () {
)
})
})
describe('when broadcasting to the project room', function () {
beforeEach(function (ctx) {
ctx.EditorUpdatesController._applyUpdateFromDocumentUpdater(
ctx.io,
ctx.project_id,
ctx.doc_id,
ctx.update
)
})
it('should get the clients connected to the project', function (ctx) {
ctx.io.sockets.clients.calledWith(ctx.project_id).should.equal(true)
})
it('should send a version bump to the source client', function (ctx) {
ctx.sourceClient.emit
.calledWith('otUpdateApplied', { v: ctx.version, doc: ctx.doc_id })
.should.equal(true)
ctx.sourceClient.emit.calledOnce.should.equal(true)
})
it('should send the full update to the other clients', function (ctx) {
Array.from(ctx.otherClients).map(client =>
client.emit
.calledWith('otUpdateApplied', ctx.update)
.should.equal(true)
)
})
})
})
describe('_processErrorFromDocumentUpdater', function () {
beforeEach(function (ctx) {
ctx.clients = [new MockClient(), new MockClient()]
ctx.io.sockets = { clients: sinon.stub().returns(ctx.clients) }
ctx.EditorUpdatesController._processErrorFromDocumentUpdater(
ctx.io,
ctx.doc_id,
'Something went wrong'
)
for (const client of ctx.clients) {
client.ol_context[`doc:${ctx.doc_id}`] = 'allowed'
}
})
it('should log a warning', function (ctx) {
ctx.logger.warn.called.should.equal(true)
describe('in the doc room', function () {
beforeEach(function (ctx) {
ctx.io.sockets = { clients: sinon.stub().returns(ctx.clients) }
ctx.EditorUpdatesController._processErrorFromDocumentUpdater(
ctx.io,
ctx.doc_id,
ctx.doc_id,
'Something went wrong'
)
})
it('should log a warning', function (ctx) {
ctx.logger.warn.called.should.equal(true)
})
it('should disconnect all clients in that document', function (ctx) {
ctx.io.sockets.clients.calledWith(ctx.doc_id).should.equal(true)
Array.from(ctx.clients).map(client =>
client.disconnect.called.should.equal(true)
)
})
})
it('should disconnect all clients in that document', function (ctx) {
ctx.io.sockets.clients.calledWith(ctx.doc_id).should.equal(true)
Array.from(ctx.clients).map(client =>
client.disconnect.called.should.equal(true)
)
describe('in the project room', function () {
beforeEach(function (ctx) {
ctx.otherDocClient = new MockClient()
ctx.otherDocClient.ol_context['doc:other-doc-id'] = 'allowed'
ctx.io.sockets = {
clients: sinon.stub().returns([...ctx.clients, ctx.otherDocClient]),
}
ctx.EditorUpdatesController._processErrorFromDocumentUpdater(
ctx.io,
ctx.project_id,
ctx.doc_id,
'Something went wrong'
)
})
it('should disconnect the clients that joined the doc', function (ctx) {
ctx.io.sockets.clients.calledWith(ctx.project_id).should.equal(true)
Array.from(ctx.clients).map(client =>
client.disconnect.called.should.equal(true)
)
})
it('should not disconnect clients that did not join the doc', function (ctx) {
ctx.otherDocClient.emit.called.should.equal(false)
ctx.otherDocClient.disconnect.called.should.equal(false)
})
})
})
})
@@ -53,6 +53,12 @@ describe('WebsocketLoadBalancer', function () {
}),
}))
vi.doMock('../../../app/js/DocumentUpdaterController', () => ({
default: (ctx.DocumentUpdaterController = {
handleAppliedOpMessage: sinon.stub(),
}),
}))
ctx.WebsocketLoadBalancer = (await import(modulePath)).default
ctx.io = {}
ctx.WebsocketLoadBalancer.rclientPubList = [{ publish: sinon.stub() }]
@@ -64,7 +70,9 @@ describe('WebsocketLoadBalancer', function () {
]
ctx.room_id = 'room-id'
ctx.message = 'otUpdateApplied'
// any non-restricted message name (must be on the restricted-user message
// pass list so the restricted-client cases receive it)
ctx.message = 'projectNameUpdated'
ctx.payload = ['argument one', 42]
})
@@ -337,8 +345,91 @@ describe('WebsocketLoadBalancer', function () {
.should.equal(true)
ctx.emit3.called.should.equal(false)
})
it('should not forward the message to the DocumentUpdaterController', function (ctx) {
ctx.DocumentUpdaterController.handleAppliedOpMessage.called.should.equal(
false
)
})
}) // duplicate client should be ignored
describe('with an applied op from document-updater', function () {
beforeEach(function (ctx) {
ctx.appliedOpMessage = {
project_id: 'project-id-123',
doc_id: 'doc-id-123',
op: { v: 42, doc: 'doc-id-123' },
_id: 'doc:host:rnd-1',
message: 'otUpdateApplied',
}
ctx.io.sockets = { clients: sinon.stub().returns([]) }
ctx.WebsocketLoadBalancer._processEditorEvent(
ctx.io,
'editor-events:project-id-123',
JSON.stringify(ctx.appliedOpMessage)
)
})
it('should forward the message for broadcasting to the project room', function (ctx) {
ctx.DocumentUpdaterController.handleAppliedOpMessage
.calledWith(ctx.io, ctx.appliedOpMessage, 'project-id-123')
.should.equal(true)
})
it('should not emit the message as an editor event', function (ctx) {
ctx.io.sockets.clients.called.should.equal(false)
})
})
describe('with an applied-op error from document-updater', function () {
beforeEach(function (ctx) {
ctx.appliedOpMessage = {
project_id: 'project-id-123',
doc_id: 'doc-id-123',
error: 'Something went wrong',
message: 'otUpdateError',
}
ctx.io.sockets = { clients: sinon.stub().returns([]) }
ctx.WebsocketLoadBalancer._processEditorEvent(
ctx.io,
'editor-events:project-id-123',
JSON.stringify(ctx.appliedOpMessage)
)
})
it('should forward the message for broadcasting to the project room', function (ctx) {
ctx.DocumentUpdaterController.handleAppliedOpMessage
.calledWith(ctx.io, ctx.appliedOpMessage, 'project-id-123')
.should.equal(true)
})
})
describe('with an applied op without a project_id', function () {
beforeEach(function (ctx) {
ctx.appliedOpMessage = {
doc_id: 'doc-id-123',
op: { v: 42, doc: 'doc-id-123' },
message: 'otUpdateApplied',
}
ctx.io.sockets = { clients: sinon.stub().returns([]) }
ctx.WebsocketLoadBalancer._processEditorEvent(
ctx.io,
'editor-events',
JSON.stringify(ctx.appliedOpMessage)
)
})
it('should log an error', function (ctx) {
ctx.logger.error.called.should.equal(true)
})
it('should not forward the message', function (ctx) {
ctx.DocumentUpdaterController.handleAppliedOpMessage.called.should.equal(
false
)
})
})
describe('with a designated room, and restricted clients, not restricted message', function () {
beforeEach(function (ctx) {
ctx.io.sockets = {