[monorepo] prepare per-project queues (#35386)

GitOrigin-RevId: ab020603ec04c705d08be466563adac0ce30bc04
This commit is contained in:
Jakob Ackermann
2026-09-17 11:26:50 +00:00
committed by Copybot
parent 3700cf5968
commit cdba372dfc
23 changed files with 1583 additions and 154 deletions
@@ -30,59 +30,116 @@ let COUNT = 0
const MAX_OPS_PER_ITERATION = 8 // process a limited number of ops for safety
/**
* Shared implementation for draining a batch of updates from a
* pending-updates list. Used for both the per-doc and per-project queues;
* `path` ('doc' | 'project') differentiates the two in metrics.
*
* The MULTI only operates on the single passed key (which has an id in curly
* braces), so all of its operations run on the same node in a cluster
* environment.
*
* @param {string} key - the redis key of the queue
* @param {'doc' | 'project'} path - metrics label
* @param {function(Error, Array<Object>=): void} callback
*/
function getPendingUpdatesFromQueue(key, path, callback) {
const multi = rclient.multi()
multi.llen(key)
multi.lrange(key, 0, MAX_OPS_PER_ITERATION - 1)
multi.ltrim(key, MAX_OPS_PER_ITERATION, -1)
multi.exec(function (error, replys) {
if (error != null) {
return callback(error)
}
const [llen, jsonUpdates, _trimResult] = replys
metrics.histogram(
'redis.pendingUpdates.llen',
llen,
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 20, 25, 50, 75, 100],
{ path }
)
for (const jsonUpdate of jsonUpdates) {
// record metric for each update removed from queue
metrics.summary('redis.pendingUpdates', jsonUpdate.length, {
status: 'pop',
path,
})
}
const updates = []
for (const jsonUpdate of jsonUpdates) {
let update
try {
update = JSON.parse(jsonUpdate)
} catch (e) {
return callback(e)
}
updates.push(update)
}
return callback(error, updates)
})
}
const RealTimeRedisManager = {
/**
* Drain a batch of updates from a doc's legacy per-doc queue.
*
* @param {string} docId
* @param {function(Error, Array<Object>=): void} callback
*/
getPendingUpdatesForDoc(docId, callback) {
// Make sure that this MULTI operation only operates on doc
// specific keys, i.e. keys that have the doc id in curly braces.
// The curly braces identify a hash key for Redis and ensures that
// the MULTI's operations are all done on the same node in a
// cluster environment.
const multi = rclient.multi()
multi.llen(Keys.pendingUpdates({ doc_id: docId }))
multi.lrange(
getPendingUpdatesFromQueue(
Keys.pendingUpdates({ doc_id: docId }),
0,
MAX_OPS_PER_ITERATION - 1
'doc',
callback
)
multi.ltrim(
Keys.pendingUpdates({ doc_id: docId }),
MAX_OPS_PER_ITERATION,
-1
)
multi.exec(function (error, replys) {
if (error != null) {
return callback(error)
}
const [llen, jsonUpdates, _trimResult] = replys
metrics.histogram(
'redis.pendingUpdates.llen',
llen,
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 20, 25, 50, 75, 100]
)
for (const jsonUpdate of jsonUpdates) {
// record metric for each update removed from queue
metrics.summary('redis.pendingUpdates', jsonUpdate.length, {
status: 'pop',
})
}
const updates = []
for (const jsonUpdate of jsonUpdates) {
let update
try {
update = JSON.parse(jsonUpdate)
} catch (e) {
return callback(e)
}
updates.push(update)
}
return callback(error, updates)
})
},
/**
* Get the length of a doc's legacy per-doc queue.
*
* @param {string} docId
* @param {function(Error, number=): void} callback
*/
getUpdatesLength(docId, callback) {
rclient.llen(Keys.pendingUpdates({ doc_id: docId }), callback)
},
/**
* Drain a batch of updates from a project's per-project queue. Each update
* on the per-project queue carries its own `doc` id so the caller knows
* which doc to apply it to.
*
* @param {string} projectId
* @param {function(Error, Array<Object>=): void} callback
*/
getPendingProjectUpdates(projectId, callback) {
getPendingUpdatesFromQueue(
Keys.pendingProjectUpdates({ project_id: projectId }),
'project',
callback
)
},
/**
* Get the length of a project's per-project queue.
*
* @param {string} projectId
* @param {function(Error, number=): void} callback
*/
getProjectUpdatesLength(projectId, callback) {
rclient.llen(
Keys.pendingProjectUpdates({ project_id: projectId }),
callback
)
},
/**
* Publish a canary message on the project's editor-events channel with the
* sizes of the applied op's ack and broadcast payloads.
*
* @param {{projectId: string, docId: string, op: Object}} args
*/
sendCanaryAppliedOp({ projectId, docId, op }) {
const ack = JSON.stringify({ v: op.v, doc: docId }).length
// Updates with op.dup===true will not get sent to other clients, they only get acked.
@@ -110,6 +167,11 @@ const RealTimeRedisManager = {
}
},
/**
* Publish an applied-ops message, stamped with a unique message id.
*
* @param {Object} data
*/
sendData(data) {
// create a unique message id using a counter
const messageId = `doc:${HOST}:${RND}-${COUNT++}`
+19 -1
View File
@@ -1,10 +1,28 @@
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
module.exports = {
/**
* Build a legacy `project_id:doc_id` dispatch marker.
*
* @param {string} projectId
* @param {string} docId
* @return {string}
*/
combineProjectIdAndDocId(projectId, docId) {
return `${projectId}:${docId}`
},
/**
* Split a dispatch marker into project id and doc id. A marker is either
* the legacy `project_id:doc_id` or, after the queue migration, a bare
* `project_id`. Project and doc ids never contain a colon, so splitting is
* unambiguous; for the bare form docId is undefined.
*
* @param {string} projectAndDocId
* @return {[string, string | undefined]}
*/
splitProjectIdAndDocId(projectAndDocId) {
return projectAndDocId.split(':')
const [projectId, docId] = projectAndDocId.split(':')
return [projectId, docId]
},
}
+276 -25
View File
@@ -1,5 +1,6 @@
// @ts-check
const Settings = require('@overleaf/settings')
const { callbackifyAll } = require('@overleaf/promise-utils')
const LockManager = require('./LockManager')
const ProjectLockManager = require('./ProjectLockManager')
@@ -24,6 +25,13 @@ const HistoryOTUpdateManager = require('./HistoryOTUpdateManager')
*/
const UpdateManager = {
/**
* Process the pending updates on a single doc's legacy per-doc queue. The
* caller must hold the doc lock.
*
* @param {string} projectId
* @param {string} docId
*/
async processOutstandingUpdates(projectId, docId) {
const timer = new Metrics.Timer('updateManager.processOutstandingUpdates')
try {
@@ -35,10 +43,17 @@ const UpdateManager = {
}
},
async processOutstandingUpdatesWithLock(projectId, docId) {
/**
* Process the pending updates for a project under the project lock, then
* keep going for as long as more updates are queued up.
*
* @param {string} projectId
* @param {string} [docIdHint] - doc id from a legacy dispatch marker, if any
*/
async processOutstandingUpdatesWithLock(projectId, docIdHint) {
const profile = new Profiler('processOutstandingUpdatesWithLock', {
project_id: projectId,
doc_id: docId,
doc_id: docIdHint,
})
// Always take the project lock before the per-doc lock to avoid deadlocks.
@@ -49,34 +64,247 @@ const UpdateManager = {
profile.log('getProjectLock')
try {
const lockValue = await LockManager.promises.tryLock(docId)
if (lockValue == null) {
return
}
profile.log('tryLock')
try {
await UpdateManager.processOutstandingUpdates(projectId, docId)
profile.log('processOutstandingUpdates')
} finally {
await LockManager.promises.releaseLock(docId, lockValue)
profile.log('releaseLock')
}
await UpdateManager.processOutstandingProjectUpdates(
projectId,
docIdHint,
projectLockValue,
profile
)
profile.log('processOutstandingProjectUpdates')
} finally {
await ProjectLockManager.promises.releaseLock(projectId, projectLockValue)
profile.log('releaseProjectLock').end()
}
await UpdateManager.continueProcessingUpdatesWithLock(projectId, docId)
await UpdateManager.continueProcessingUpdatesWithLock(projectId, docIdHint)
},
async continueProcessingUpdatesWithLock(projectId, docId) {
const length = await RealTimeRedisManager.promises.getUpdatesLength(docId)
if (length > 0) {
await UpdateManager.processOutstandingUpdatesWithLock(projectId, docId)
/**
* Process the pending updates for a project while holding the project lock.
*
* In phases 1 & 2 this drains the legacy per-doc queues (taking the per-doc
* lock for each doc) as well as the shared per-project queue; in phase 3
* only the per-project queue is processed.
*
* @param {string} projectId
* @param {string | undefined} docIdHint - doc id from a legacy dispatch
* marker, if any
* @param {string} projectLockValue - lock value of the held project lock
* @param {Profiler} profile - profile started by the caller
*/
async processOutstandingProjectUpdates(
projectId,
docIdHint,
projectLockValue,
profile
) {
switch (Settings.pendingUpdatesMigrationPhase) {
case 1:
case 2:
await UpdateManager.processOutstandingDocUpdates(
projectId,
docIdHint,
projectLockValue,
profile
)
// Phases 1 & 2 drain the per-project queue as well, as a safety net in
// case a producer writes it ahead of the phase switch.
// falls through
case 3:
await UpdateManager.fetchAndApplyProjectUpdates(projectId, profile)
break
default:
throw new Error(
`invalid pendingUpdatesMigrationPhase: ${Settings.pendingUpdatesMigrationPhase}`
)
}
},
/**
* Drain the legacy per-doc queues of all the docs in the project, plus the
* hinted doc, while holding the project lock.
*
* @param {string} projectId
* @param {string | undefined} docIdHint - doc id from a legacy dispatch
* marker, if any
* @param {string} projectLockValue - lock value of the held project lock
* @param {Profiler} profile - profile started by the caller
*/
async processOutstandingDocUpdates(
projectId,
docIdHint,
projectLockValue,
profile
) {
const docIds = new Set(
await RedisManager.promises.getDocIdsInProject(projectId)
)
profile.log('getDocIdsInProject')
if (docIdHint) {
docIds.add(docIdHint)
}
// Listing the docs above may have taken a while, so bump before we start
// processing and again after each doc. The final bump also covers draining
// the per-project queue afterwards.
await ProjectLockManager.promises.extendLock(projectId, projectLockValue)
profile.log('extendProjectLock')
for (const docId of docIds) {
await UpdateManager.fetchAndApplyDocUpdatesUnderDocLock(
projectId,
docId,
profile
)
await ProjectLockManager.promises.extendLock(projectId, projectLockValue)
profile.log('extendProjectLock')
}
},
/**
* Drain (one batch of) a single doc's legacy per-doc queue, taking the
* per-doc lock so that an old document-updater instance - which only takes
* the per-doc lock - cannot process the same doc concurrently during a
* rolling deploy. tryLock and skip on contention; the doc is revisited by
* the continuation check or a later dispatch.
*
* @param {string} projectId
* @param {string} docId
* @param {Profiler} profile - profile started by the caller
*/
async fetchAndApplyDocUpdatesUnderDocLock(projectId, docId, profile) {
const length = await RealTimeRedisManager.promises.getUpdatesLength(docId)
profile.log('getUpdatesLength')
if (length === 0) {
return
}
const lockValue = await LockManager.promises.tryLock(docId)
if (lockValue == null) {
return
}
profile.log('tryLock')
try {
await UpdateManager.processOutstandingUpdates(projectId, docId)
profile.log('processOutstandingUpdates')
} finally {
await LockManager.promises.releaseLock(docId, lockValue)
profile.log('releaseLock')
}
},
/**
* Apply one batch of updates from the shared per-project queue. We already
* hold the project lock, so no per-doc lock is required: the project lock
* serialises all processing for the project. Each update carries its `doc`
* id - real-time forces `update.doc = docId` before queueing it.
*
* @param {string} projectId
* @param {Profiler} profile - profile started by the caller
*/
async fetchAndApplyProjectUpdates(projectId, profile) {
const updates =
await RealTimeRedisManager.promises.getPendingProjectUpdates(projectId)
profile.log('getPendingProjectUpdates')
if (updates.length === 0) {
return
}
for (const update of updates) {
const docId = update.doc
if (HistoryOTUpdateManager.isHistoryOTEditOperationUpdate(update)) {
await HistoryOTUpdateManager.applyUpdate(projectId, docId, update)
} else {
await UpdateManager.applyUpdate(projectId, docId, update)
}
profile.log('applyProjectUpdate')
}
},
/**
* Total pending updates for a project: the per-project queue plus (in phases
* 1 & 2) the loaded docs' legacy per-doc queues. Used to decide whether to
* keep processing after releasing the lock - other workers' markers may have
* been consumed (and dropped on a failed tryLock) while we held it.
*
* @param {string} projectId
* @param {string} [docIdHint] - doc id from a legacy dispatch marker, if any
* @return {Promise<number>}
*/
async getProjectPendingUpdatesLength(projectId, docIdHint) {
switch (Settings.pendingUpdatesMigrationPhase) {
case 1:
case 2: {
const length =
await RealTimeRedisManager.promises.getProjectUpdatesLength(projectId)
if (length > 0) {
return length
}
return await UpdateManager.getDocPendingUpdatesLength(
projectId,
docIdHint
)
}
case 3:
return await RealTimeRedisManager.promises.getProjectUpdatesLength(
projectId
)
default:
throw new Error(
`invalid pendingUpdatesMigrationPhase: ${Settings.pendingUpdatesMigrationPhase}`
)
}
},
/**
* Pending updates across the legacy per-doc queues of the docs in the
* project, plus the hinted doc. Returns early with the first non-empty
* queue's length.
*
* @param {string} projectId
* @param {string} [docIdHint] - doc id from a legacy dispatch marker, if any
* @return {Promise<number>}
*/
async getDocPendingUpdatesLength(projectId, docIdHint) {
const docIds = new Set(
await RedisManager.promises.getDocIdsInProject(projectId)
)
if (docIdHint) {
docIds.add(docIdHint)
}
for (const docId of docIds) {
const length = await RealTimeRedisManager.promises.getUpdatesLength(docId)
if (length > 0) {
return length
}
}
return 0
},
/**
* Process the project's pending updates under the project lock if any are
* queued up.
*
* @param {string} projectId
* @param {string} [docIdHint] - doc id from a legacy dispatch marker, if any
*/
async continueProcessingUpdatesWithLock(projectId, docIdHint) {
const length = await UpdateManager.getProjectPendingUpdatesLength(
projectId,
docIdHint
)
if (length > 0) {
await UpdateManager.processOutstandingUpdatesWithLock(
projectId,
docIdHint
)
}
},
/**
* Apply one batch of updates from a doc's legacy per-doc queue. The caller
* must hold the doc lock.
*
* @param {string} projectId
* @param {string} docId
*/
async fetchAndApplyUpdates(projectId, docId) {
const profile = new Profiler('fetchAndApplyUpdates', {
project_id: projectId,
@@ -286,26 +514,43 @@ const UpdateManager = {
}
},
/**
* Process the project's pending updates, then run the given method on the
* doc, all under the project and doc locks.
*
* @param {Function} method - called with (projectId, docId, ...args)
* @param {string} projectId
* @param {string} docId
* @param {...any} args
* @return {Promise<any>} the return value of `method`
*/
async lockUpdatesAndDo(method, projectId, docId, ...args) {
const profile = new Profiler('lockUpdatesAndDo', {
project_id: projectId,
doc_id: docId,
})
// Always take the project lock before the per-doc lock to avoid deadlocks.
// Project lock first (always before the per-doc lock, to avoid deadlocks)
// so we can safely drain the shared per-project queue, then the per-doc
// lock for the operation itself.
const projectLockValue =
await ProjectLockManager.promises.getLock(projectId)
profile.log('getProjectLock')
let result
try {
await UpdateManager.processOutstandingProjectUpdates(
projectId,
docId,
projectLockValue,
profile
)
profile.log('processOutstandingProjectUpdates')
const lockValue = await LockManager.promises.getLock(docId)
profile.log('getLock')
try {
await UpdateManager.processOutstandingUpdates(projectId, docId)
profile.log('processOutstandingUpdates')
result = await method(projectId, docId, ...args)
profile.log('method')
} finally {
@@ -334,6 +579,12 @@ const UpdateManager = {
return result
},
/**
* Replace unpaired surrogate characters in the update's inserts.
*
* @param {Update} update
* @return {Update}
*/
_sanitizeUpdate(update) {
// In Javascript, characters are 16-bits wide. It does not understand surrogates as characters.
//
@@ -347,7 +598,7 @@ const UpdateManager = {
// Something must be going on client side that is screwing up the encoding and splitting the
// two 16-bit characters so that \uD835 is standalone.
for (const op of update.op || []) {
if (op.i != null) {
if (isInsert(op)) {
// Replace high and low surrogate characters with 'replacement character' (\uFFFD)
op.i = op.i.replace(/[\uD800-\uDFFF]/g, '\uFFFD')
}
@@ -143,6 +143,9 @@ module.exports = {
pendingUpdates({ doc_id: docId }) {
return `PendingUpdates:{${docId}}`
},
pendingProjectUpdates({ project_id: projectId }) {
return `PendingProjectUpdates:{${projectId}}`
},
lastUpdatedBy({ doc_id: docId }) {
return `lastUpdatedBy:{${docId}}`
},
@@ -171,6 +174,17 @@ module.exports = {
dispatcherCount: parseInt(process.env.DISPATCHER_COUNT || 10, 10),
// Migration of the real-time -> document-updater update queue from a per-doc
// queue to a per-project queue. See
// services/document-updater/scripts/check_migration_phase_switch.js
// phase 1: legacy per-doc queue only
// phase 2: producer writes the per-project queue; consumer drains both
// phase 3: per-project queue only
pendingUpdatesMigrationPhase: parseInt(
process.env.PENDING_UPDATES_MIGRATION_PHASE || '1',
10
),
redisLockTTLSeconds: 30,
mongo: {
@@ -0,0 +1,214 @@
/**
* Pre-flight check for switching the real-time -> document-updater update queue
* migration phase (PENDING_UPDATES_MIGRATION_PHASE).
*
* phase 1: legacy per-doc queue only
* phase 2: producer writes the per-project queue; consumer drains both
* phase 3: per-project queue only
*
* Going to phase 3 is only safe once no data can be stranded in the legacy
* per-doc queues (because the phase-3 consumer stops reading them). This script
* verifies that and exits non-zero when the switch is unsafe.
*
* It is READ-ONLY (SCAN / LLEN / LRANGE only - never KEYS) and safe to run at
* any time. In a redis cluster it SCANs every master node, since the per-doc
* keys are spread across slots.
*
* Usage:
* node scripts/check_migration_phase_switch.js --current 2 --target 3
*/
const { setTimeout } = require('node:timers/promises')
const minimist = require('minimist')
const Settings = require('@overleaf/settings')
const { rclient } = require('./../app/js/RedisManager')
// Window size when walking a dispatch list tail -> head.
const LIST_CHUNK_SIZE = 1000
/**
* List all cluster master nodes, or the plain client outside a cluster.
*
* @return {Array<Object>}
*/
function getNodes() {
return (
(typeof rclient.nodes === 'function'
? rclient.nodes('master')
: undefined) || [rclient]
)
}
/**
* Non-blocking SCAN across all cluster master nodes.
*
* @param {string} pattern - a redis MATCH pattern
* @yields {string} matching keys
*/
async function* scanKeys(pattern) {
for (const node of getNodes()) {
let cursor = '0'
do {
const [next, keys] = await node.scan(
cursor,
'MATCH',
pattern,
'COUNT',
1000
)
cursor = next
for (const key of keys) {
yield key
}
if (cursor !== '0') {
await setTimeout(10) // be gentle on redis
}
} while (cursor !== '0')
}
}
/**
* Are there any non-empty legacy per-doc queues left?
*
* @return {Promise<{nonEmptyDocs: number, totalOps: number}>}
*/
async function checkPerDocQueues() {
let nonEmptyDocs = 0
let totalOps = 0
for await (const key of scanKeys('PendingUpdates:{*}')) {
const length = await rclient.llen(key)
if (length > 0) {
nonEmptyDocs++
totalOps += length
}
}
return { nonEmptyDocs, totalOps }
}
/**
* Are there any legacy "<proj>:<doc>" markers still sitting on the dispatch
* lists? A colon means a phase-1 producer is still writing. We snapshot the
* length and walk each list tail -> head in bounded windows, rather than
* materialising the whole list in one LRANGE. The lists are only popped from
* the head (BLPOP) and appended at the tail (RPUSH), so an entry's index only
* ever decreases; sweeping tail -> head with contiguous windows therefore
* never skips an entry that is still queued (though a fast-draining entry can
* ride the window boundary down and be read several times - harmless for a
* colon scan). The only entries we don't observe are ones drained mid-walk
* (already processed) or pushed after we started. The exhaustive per-doc SCAN
* above is in any case the authoritative gate; a colon found here is just a
* strong extra signal.
*
* @return {Promise<{legacyMarkers: number, inspectedEntries: number}>}
*/
async function checkDispatchListsForLegacyMarkers() {
const shardCount = Settings.dispatcherCount
let legacyMarkers = 0
let inspectedEntries = 0
for (let shard = 0; shard < shardCount; shard++) {
const listKey =
shard === 0 ? 'pending-updates-list' : `pending-updates-list-${shard}`
const length = await rclient.llen(listKey)
for (let end = length; end > 0; end -= LIST_CHUNK_SIZE) {
const start = Math.max(0, end - LIST_CHUNK_SIZE)
const entries = await rclient.lrange(listKey, start, end - 1)
for (const marker of entries) {
inspectedEntries++
if (marker.includes(':')) {
legacyMarkers++
}
}
}
}
return { legacyMarkers, inspectedEntries }
}
/**
* Check whether switching the migration phase is safe.
*
* @param {number} current - the phase currently running
* @param {number} target - the phase to switch to
* @return {Promise<boolean>} whether the switch is safe
*/
async function checkSwitch(current, target) {
console.log(`Checking switch from phase ${current} -> ${target}`)
if (Math.abs(target - current) > 1) {
console.warn(
`WARNING: ${current} -> ${target} is not an adjacent transition. ` +
'Roll the phase one step at a time.'
)
}
// The only gated transition is enabling phase 3 (consumer stops reading the
// per-doc queues). Everything else is safe with the dual-read consumer.
if (target < 3) {
console.log(
'Target phase < 3: the dual-read consumer drains both queues, so this ' +
'switch (incl. roll-backs) is safe.'
)
return true
}
const { nonEmptyDocs, totalOps } = await checkPerDocQueues()
const { legacyMarkers, inspectedEntries } =
await checkDispatchListsForLegacyMarkers()
console.log(
`Legacy per-doc queues with pending updates: ${nonEmptyDocs} (${totalOps} ops)`
)
console.log(
`Legacy "<proj>:<doc>" markers found across ${inspectedEntries} dispatch ` +
`list entries: ${legacyMarkers}`
)
const safe = nonEmptyDocs === 0 && legacyMarkers === 0
if (safe) {
console.log(
'SAFE: no legacy per-doc data remains; phase 3 will not strand updates.'
)
} else {
console.error(
'UNSAFE: legacy per-doc data still present. Keep the producer at phase ' +
'>= 2 and wait for the per-doc queues to drain before switching to ' +
'phase 3.'
)
}
return safe
}
/**
* Parse the command line arguments and run the check.
*
* @return {Promise<boolean>} whether the switch is safe
*/
async function main() {
const argv = minimist(process.argv.slice(2), {
boolean: ['help'],
alias: { h: 'help' },
})
if (argv.help || argv.current == null || argv.target == null) {
console.log(`
Usage: node scripts/check_migration_phase_switch.js --current N --target M
Options:
--current The phase currently running (1, 2 or 3)
--target The phase you want to switch to (1, 2 or 3)
--help, -h Show this help message
`)
process.exit(argv.help ? 0 : 1)
}
const current = parseInt(argv.current, 10)
const target = parseInt(argv.target, 10)
return await checkSwitch(current, target)
}
main()
.then(safe => {
process.exit(safe ? 0 : 1)
})
.catch(error => {
console.error('Error running phase-switch check', error)
process.exit(2)
})
@@ -0,0 +1,181 @@
/*
* Exercises the migration of the real-time -> document-updater update queue
* from the per-doc queue to the per-project queue.
*
* The consumer's behaviour is gated by Settings.pendingUpdatesMigrationPhase,
* which the app reads live. The acceptance app runs in-process, so each block
* sets the phase it needs in a `before` hook and the top-level `after` restores
* the original value.
*
* The dual-read consumer (phases 1 & 2) must drain BOTH the legacy per-doc queue
* and the new per-project queue, so updates queued either side of a producer
* roll-forward/roll-backward are still applied. Ordering is relaxed (ShareJS
* rebases by version); these tests keep versions monotonic across the switch.
*/
const { expect } = require('chai')
const { setTimeout } = require('node:timers/promises')
const Settings = require('@overleaf/settings')
const rclientDU = require('@overleaf/redis-wrapper').createClient(
Settings.redis.documentupdater
)
const Keys = Settings.redis.documentupdater.key_schema
const MockWebApi = require('./helpers/MockWebApi')
const DocUpdaterClient = require('./helpers/DocUpdaterClient')
const DocUpdaterApp = require('./helpers/DocUpdaterApp')
function insertOp(text, position, version, docId) {
return { doc: docId, op: [{ i: text, p: position }], v: version }
}
describe('Queue migration', function () {
before(function () {
this.originalPhase = Settings.pendingUpdatesMigrationPhase
})
after(function () {
Settings.pendingUpdatesMigrationPhase = this.originalPhase
})
beforeEach(async function () {
this.lines = ['one', 'two', 'three']
this.version = 0
this.project_id = DocUpdaterClient.randomId()
this.doc_id = DocUpdaterClient.randomId()
MockWebApi.insertDoc(this.project_id, this.doc_id, {
lines: this.lines,
version: this.version,
})
await DocUpdaterApp.ensureRunning()
await DocUpdaterClient.preloadDoc(this.project_id, this.doc_id)
})
describe('dual-read consumer (phases 1 & 2)', function () {
before(function () {
Settings.pendingUpdatesMigrationPhase = 2
})
it('roll-forward: legacy updates then per-project updates all apply in order', async function () {
await DocUpdaterClient.sendUpdate(
this.project_id,
this.doc_id,
insertOp('AAA\n', 0, 0, this.doc_id),
{ toProjectQueue: false }
)
await DocUpdaterClient.waitForPendingUpdates(this.project_id, this.doc_id)
await DocUpdaterClient.sendUpdate(
this.project_id,
this.doc_id,
insertOp('BBB\n', 0, 1, this.doc_id),
{ toProjectQueue: true }
)
await DocUpdaterClient.waitForPendingUpdates(this.project_id, this.doc_id)
const doc = await DocUpdaterClient.getDoc(this.project_id, this.doc_id)
doc.lines.should.deep.equal(['BBB', 'AAA', 'one', 'two', 'three'])
doc.version.should.equal(2)
})
it('roll-backward: per-project updates then legacy updates all apply in order', async function () {
await DocUpdaterClient.sendUpdate(
this.project_id,
this.doc_id,
insertOp('AAA\n', 0, 0, this.doc_id),
{ toProjectQueue: true }
)
await DocUpdaterClient.waitForPendingUpdates(this.project_id, this.doc_id)
await DocUpdaterClient.sendUpdate(
this.project_id,
this.doc_id,
insertOp('BBB\n', 0, 1, this.doc_id),
{ toProjectQueue: false }
)
await DocUpdaterClient.waitForPendingUpdates(this.project_id, this.doc_id)
const doc = await DocUpdaterClient.getDoc(this.project_id, this.doc_id)
doc.lines.should.deep.equal(['BBB', 'AAA', 'one', 'two', 'three'])
doc.version.should.equal(2)
})
it('straddle: updates present in BOTH queues drain together (per-doc first), in order', async function () {
// Populate both queues before any dispatch, then trigger a single
// whole-project drain with a bare project-id marker.
await rclientDU.rpush(
Keys.pendingUpdates({ doc_id: this.doc_id }),
JSON.stringify(insertOp('AAA\n', 0, 0, this.doc_id))
)
await rclientDU.rpush(
Keys.pendingProjectUpdates({ project_id: this.project_id }),
JSON.stringify(insertOp('BBB\n', 0, 1, this.doc_id))
)
await rclientDU.rpush('pending-updates-list', this.project_id)
await DocUpdaterClient.waitForPendingUpdates(this.project_id, this.doc_id)
await setTimeout(200)
const doc = await DocUpdaterClient.getDoc(this.project_id, this.doc_id)
doc.lines.should.deep.equal(['BBB', 'AAA', 'one', 'two', 'three'])
doc.version.should.equal(2)
})
it('flush applies per-project queue updates first (no stale flush)', async function () {
// Queue an update on the per-project queue WITHOUT a dispatch marker, so
// it is only picked up by the flush path draining outstanding updates.
await rclientDU.rpush(
Keys.pendingProjectUpdates({ project_id: this.project_id }),
JSON.stringify(insertOp('AAA\n', 0, 0, this.doc_id))
)
await DocUpdaterClient.flushDoc(this.project_id, this.doc_id)
const doc = await DocUpdaterClient.getDoc(this.project_id, this.doc_id)
doc.lines.should.deep.equal(['AAA', 'one', 'two', 'three'])
doc.version.should.equal(1)
const remaining = await rclientDU.llen(
Keys.pendingProjectUpdates({ project_id: this.project_id })
)
expect(remaining).to.equal(0)
})
})
describe('project-queue-only consumer (phase 3)', function () {
before(function () {
Settings.pendingUpdatesMigrationPhase = 3
})
it('drains the per-project queue', async function () {
await DocUpdaterClient.sendUpdate(
this.project_id,
this.doc_id,
insertOp('AAA\n', 0, 0, this.doc_id),
{ toProjectQueue: true }
)
await DocUpdaterClient.waitForPendingUpdates(this.project_id, this.doc_id)
const doc = await DocUpdaterClient.getDoc(this.project_id, this.doc_id)
doc.lines.should.deep.equal(['AAA', 'one', 'two', 'three'])
doc.version.should.equal(1)
})
it('ignores the legacy per-doc queue', async function () {
// Push to the legacy per-doc queue with its marker; the phase-3 consumer
// must NOT process it (it no longer reads the per-doc queue).
await DocUpdaterClient.sendUpdate(
this.project_id,
this.doc_id,
insertOp('AAA\n', 0, 0, this.doc_id),
{ toProjectQueue: false }
)
await setTimeout(500)
const doc = await DocUpdaterClient.getDoc(this.project_id, this.doc_id)
doc.lines.should.deep.equal(this.lines)
doc.version.should.equal(0)
const remaining = await rclientDU.llen(
Keys.pendingUpdates({ doc_id: this.doc_id })
)
expect(remaining).to.equal(1)
})
})
})
@@ -546,7 +546,7 @@ describe('Setting a document', function () {
this.doc_id,
this.historyOTUpdate
)
await DocUpdaterClient.waitForPendingUpdates(this.doc_id)
await DocUpdaterClient.waitForPendingUpdates(this.project_id, this.doc_id)
})
afterEach(function () {
@@ -36,32 +36,52 @@ module.exports = DocUpdaterClient = {
rclientSub.on('message', messageHandler)
},
async sendUpdate(projectId, docId, update) {
const docKey = `${projectId}:${docId}`
await rclient.rpush(
keys.pendingUpdates({ doc_id: docId }),
JSON.stringify(update)
)
await rclient.sadd('DocsWithPendingUpdates', docKey)
await rclient.rpush(getPendingUpdateListKey(), docKey)
// Enqueue an update for the doc-updater. By default the queue format follows
// the configured migration phase, but tests can force a format with
// options.toProjectQueue to exercise the migration explicitly:
// phase 1 / toProjectQueue=false: legacy per-doc queue + "<proj>:<doc>" marker
// phase >= 2 / toProjectQueue=true: per-project queue + bare "<proj>" marker
async sendUpdate(projectId, docId, update, options = {}) {
const toProjectQueue =
options.toProjectQueue ?? Settings.pendingUpdatesMigrationPhase >= 2
if (toProjectQueue) {
await rclient.rpush(
keys.pendingProjectUpdates({ project_id: projectId }),
JSON.stringify({ ...update, doc: docId })
)
await rclient.rpush(getPendingUpdateListKey(), projectId)
} else {
await rclient.rpush(
keys.pendingUpdates({ doc_id: docId }),
JSON.stringify(update)
)
await rclient.rpush(getPendingUpdateListKey(), `${projectId}:${docId}`)
}
},
async sendUpdates(projectId, docId, updates) {
async sendUpdates(projectId, docId, updates, options = {}) {
await DocUpdaterClient.preloadDoc(projectId, docId)
for (const update of updates) {
await DocUpdaterClient.sendUpdate(projectId, docId, update)
await DocUpdaterClient.sendUpdate(projectId, docId, update, options)
}
await DocUpdaterClient.waitForPendingUpdates(docId)
await DocUpdaterClient.waitForPendingUpdates(projectId, docId)
},
async waitForPendingUpdates(docId) {
// Wait until both the legacy per-doc queue and the per-project queue are
// drained, so the helper works regardless of the migration phase in use.
async waitForPendingUpdates(projectId, docId) {
const maxRetries = 30
const retryInterval = 100
for (let attempt = 0; attempt < maxRetries; attempt++) {
const length = await rclient.llen(keys.pendingUpdates({ doc_id: docId }))
const docLength = await rclient.llen(
keys.pendingUpdates({ doc_id: docId })
)
const projectLength = await rclient.llen(
keys.pendingProjectUpdates({ project_id: projectId })
)
if (length === 0) {
if (docLength === 0 && projectLength === 0) {
return // Success - no pending updates
}
@@ -139,6 +139,28 @@ describe('DispatchManager', function () {
})
})
describe('with a bare project-id marker (per-project queue)', function () {
beforeEach(function () {
this.client.blpop = sinon
.stub()
.callsArgWith(2, null, ['pending-updates-list', this.project_id])
this.UpdateManager.processOutstandingUpdatesWithLock = sinon
.stub()
.callsArg(2)
return this.worker._waitForUpdateThenDispatchWorker(this.callback)
})
it('should call processOutstandingUpdatesWithLock with no doc hint', function () {
this.UpdateManager.processOutstandingUpdatesWithLock
.calledWith(this.project_id, undefined)
.should.equal(true)
})
it('should call the callback', function () {
return this.callback.called.should.equal(true)
})
})
describe('pending updates list with shard key', function () {
beforeEach(function (done) {
this.client = {
@@ -35,6 +35,9 @@ describe('RealTimeRedisManager', function () {
pendingUpdates({ doc_id: docId }) {
return `PendingUpdates:${docId}`
},
pendingProjectUpdates({ project_id: projectId }) {
return `PendingProjectUpdates:${projectId}`
},
},
}),
pubsub: {
@@ -140,6 +143,85 @@ describe('RealTimeRedisManager', function () {
})
})
describe('getPendingProjectUpdates', function () {
beforeEach(function () {
this.rclient.llen = sinon.stub()
this.rclient.lrange = sinon.stub()
this.rclient.ltrim = sinon.stub()
})
describe('successfully', function () {
beforeEach(function () {
this.updates = [
{ doc: 'doc-1', op: [{ i: 'foo', p: 4 }] },
{ doc: 'doc-2', op: [{ i: 'bar', p: 6 }] },
]
this.jsonUpdates = this.updates.map(update => JSON.stringify(update))
this.rclient.exec = sinon.stub().yields(null, [2, this.jsonUpdates])
return this.RealTimeRedisManager.getPendingProjectUpdates(
this.project_id,
this.callback
)
})
it('should get the pending updates from the per-project queue', function () {
return this.rclient.lrange
.calledWith(`PendingProjectUpdates:${this.project_id}`, 0, 7)
.should.equal(true)
})
it('should delete the pending updates', function () {
return this.rclient.ltrim
.calledWith(`PendingProjectUpdates:${this.project_id}`, 8, -1)
.should.equal(true)
})
return it('should call the callback with the updates', function () {
return this.callback.calledWith(null, this.updates).should.equal(true)
})
})
return describe("when the JSON doesn't parse", function () {
beforeEach(function () {
this.jsonUpdates = [
JSON.stringify({ doc: 'doc-1', op: [{ i: 'foo', p: 4 }] }),
'broken json',
]
this.rclient.exec = sinon.stub().yields(null, [2, this.jsonUpdates])
return this.RealTimeRedisManager.getPendingProjectUpdates(
this.project_id,
this.callback
)
})
return it('should return an error to the callback', function () {
return this.callback
.calledWith(sinon.match.has('name', 'SyntaxError'))
.should.equal(true)
})
})
})
describe('getProjectUpdatesLength', function () {
beforeEach(function () {
this.rclient.llen = sinon.stub().yields(null, (this.length = 4))
return this.RealTimeRedisManager.getProjectUpdatesLength(
this.project_id,
this.callback
)
})
it('should look up the length of the per-project queue', function () {
return this.rclient.llen
.calledWith(`PendingProjectUpdates:${this.project_id}`)
.should.equal(true)
})
return it('should return the length', function () {
return this.callback.calledWith(null, this.length).should.equal(true)
})
})
return describe('sendData', function () {
beforeEach(function () {
this.message_id = 'doc:somehost:01020304-0'
@@ -36,6 +36,7 @@ describe('UpdateManager', function () {
promises: {
tryLock: sinon.stub().resolves(this.projectLockValue),
getLock: sinon.stub().resolves(this.projectLockValue),
extendLock: sinon.stub().resolves(),
releaseLock: sinon.stub().resolves(),
},
}
@@ -45,17 +46,25 @@ describe('UpdateManager', function () {
setDocument: sinon.stub().resolves(),
updateDocument: sinon.stub(),
recordProjectNotificationTimestamp: sinon.stub().resolves(),
getDocIdsInProject: sinon.stub().resolves([]),
},
}
this.RealTimeRedisManager = {
sendData: sinon.stub(),
promises: {
getUpdatesLength: sinon.stub(),
getUpdatesLength: sinon.stub().resolves(0),
getPendingUpdatesForDoc: sinon.stub(),
getProjectUpdatesLength: sinon.stub().resolves(0),
getPendingProjectUpdates: sinon.stub().resolves([]),
},
}
this.HistoryOTUpdateManager = {
isHistoryOTEditOperationUpdate: sinon.stub().returns(false),
applyUpdate: sinon.stub().resolves(),
}
this.ShareJsUpdateManager = {
promises: {
applyUpdate: sinon.stub(),
@@ -66,7 +75,7 @@ describe('UpdateManager', function () {
recordAndFlushHistoryOps: sinon.stub(),
}
this.Settings = {}
this.Settings = { pendingUpdatesMigrationPhase: 1 }
this.DocumentManager = {
promises: {
@@ -106,6 +115,7 @@ describe('UpdateManager', function () {
'./RealTimeRedisManager': this.RealTimeRedisManager,
'./ShareJsUpdateManager': this.ShareJsUpdateManager,
'./HistoryManager': this.HistoryManager,
'./HistoryOTUpdateManager': this.HistoryOTUpdateManager,
'./Metrics': this.Metrics,
'@overleaf/settings': this.Settings,
'./DocumentManager': this.DocumentManager,
@@ -139,12 +149,12 @@ describe('UpdateManager', function () {
})
describe('processOutstandingUpdatesWithLock', function () {
describe('when the lock is free', function () {
describe('when the project lock is free', function () {
beforeEach(function () {
this.UpdateManager.promises.continueProcessingUpdatesWithLock = sinon
.stub()
.resolves()
this.UpdateManager.promises.processOutstandingUpdates = sinon
this.UpdateManager.promises.processOutstandingProjectUpdates = sinon
.stub()
.resolves()
})
@@ -157,25 +167,10 @@ describe('UpdateManager', function () {
)
})
it('should acquire the project lock before the doc lock', function () {
it('should acquire the project lock', function () {
this.ProjectLockManager.promises.getLock
.calledWith(this.project_id)
.should.equal(true)
this.LockManager.promises.tryLock
.calledAfter(this.ProjectLockManager.promises.getLock)
.should.equal(true)
})
it('should acquire the lock', function () {
this.LockManager.promises.tryLock
.calledWith(this.doc_id)
.should.equal(true)
})
it('should free the lock', function () {
this.LockManager.promises.releaseLock
.calledWith(this.doc_id, this.lockValue)
.should.equal(true)
})
it('should free the project lock', function () {
@@ -184,18 +179,18 @@ describe('UpdateManager', function () {
.should.equal(true)
})
it('should process the outstanding updates', function () {
this.UpdateManager.promises.processOutstandingUpdates
.calledWith(this.project_id, this.doc_id)
it('should process the outstanding project updates', function () {
this.UpdateManager.promises.processOutstandingProjectUpdates
.calledWith(this.project_id, this.doc_id, this.projectLockValue)
.should.equal(true)
})
it('should do everything with the lock acquired', function () {
this.UpdateManager.promises.processOutstandingUpdates
.calledAfter(this.LockManager.promises.tryLock)
this.UpdateManager.promises.processOutstandingProjectUpdates
.calledAfter(this.ProjectLockManager.promises.getLock)
.should.equal(true)
this.UpdateManager.promises.processOutstandingUpdates
.calledBefore(this.LockManager.promises.releaseLock)
this.UpdateManager.promises.processOutstandingProjectUpdates
.calledBefore(this.ProjectLockManager.promises.releaseLock)
.should.equal(true)
})
@@ -206,10 +201,10 @@ describe('UpdateManager', function () {
})
})
describe('when processOutstandingUpdates returns an error', function () {
describe('when processOutstandingProjectUpdates returns an error', function () {
beforeEach(async function () {
this.error = new Error('Something went wrong')
this.UpdateManager.promises.processOutstandingUpdates = sinon
this.UpdateManager.promises.processOutstandingProjectUpdates = sinon
.stub()
.rejects(this.error)
await expect(
@@ -220,12 +215,6 @@ describe('UpdateManager', function () {
).to.be.rejectedWith(this.error)
})
it('should free the lock', function () {
this.LockManager.promises.releaseLock
.calledWith(this.doc_id, this.lockValue)
.should.equal(true)
})
it('should free the project lock', function () {
this.ProjectLockManager.promises.releaseLock
.calledWith(this.project_id, this.projectLockValue)
@@ -233,37 +222,247 @@ describe('UpdateManager', function () {
})
})
})
})
describe('when the lock is taken', function () {
describe('processOutstandingProjectUpdates', function () {
beforeEach(function () {
this.UpdateManager.promises.fetchAndApplyDocUpdatesUnderDocLock = sinon
.stub()
.resolves()
this.UpdateManager.promises.fetchAndApplyProjectUpdates = sinon
.stub()
.resolves()
})
describe('in phases 1 & 2', function () {
beforeEach(async function () {
this.LockManager.promises.tryLock.resolves(null)
this.UpdateManager.promises.processOutstandingUpdates = sinon
.stub()
.resolves()
await this.UpdateManager.promises.processOutstandingUpdatesWithLock(
this.Settings.pendingUpdatesMigrationPhase = 2
this.RedisManager.promises.getDocIdsInProject.resolves([
'doc-1',
'doc-2',
])
await this.UpdateManager.promises.processOutstandingProjectUpdates(
this.project_id,
this.doc_id,
this.projectLockValue,
new this.Profiler()
)
})
it('should drain the per-doc queue for every doc in the project plus the hint', function () {
this.UpdateManager.promises.fetchAndApplyDocUpdatesUnderDocLock.should.have.been.calledWith(
this.project_id,
'doc-1'
)
this.UpdateManager.promises.fetchAndApplyDocUpdatesUnderDocLock.should.have.been.calledWith(
this.project_id,
'doc-2'
)
this.UpdateManager.promises.fetchAndApplyDocUpdatesUnderDocLock.should.have.been.calledWith(
this.project_id,
this.doc_id
)
})
it('should not process the updates', function () {
this.UpdateManager.promises.processOutstandingUpdates.called.should.equal(
it('should extend the project lock between docs', function () {
this.ProjectLockManager.promises.extendLock.should.have.been.calledWith(
this.project_id,
this.projectLockValue
)
})
it('should drain the per-project queue', function () {
this.UpdateManager.promises.fetchAndApplyProjectUpdates.should.have.been.calledWith(
this.project_id
)
})
})
describe('in phase 3', function () {
beforeEach(async function () {
this.Settings.pendingUpdatesMigrationPhase = 3
await this.UpdateManager.promises.processOutstandingProjectUpdates(
this.project_id,
this.doc_id,
this.projectLockValue,
new this.Profiler()
)
})
it('should not look up the docs in the project', function () {
this.RedisManager.promises.getDocIdsInProject.called.should.equal(false)
})
it('should not drain any per-doc queue', function () {
this.UpdateManager.promises.fetchAndApplyDocUpdatesUnderDocLock.called.should.equal(
false
)
})
it('should free the project lock', function () {
this.ProjectLockManager.promises.releaseLock
.calledWith(this.project_id, this.projectLockValue)
it('should not bump the project lock (freshly acquired, no per-doc loop)', function () {
this.ProjectLockManager.promises.extendLock.called.should.equal(false)
})
it('should drain the per-project queue', function () {
this.UpdateManager.promises.fetchAndApplyProjectUpdates.should.have.been.calledWith(
this.project_id
)
})
})
})
describe('fetchAndApplyDocUpdatesUnderDocLock', function () {
beforeEach(function () {
this.UpdateManager.promises.processOutstandingUpdates = sinon
.stub()
.resolves()
})
describe('when the doc has pending updates', function () {
beforeEach(async function () {
this.RealTimeRedisManager.promises.getUpdatesLength.resolves(2)
await this.UpdateManager.promises.fetchAndApplyDocUpdatesUnderDocLock(
this.project_id,
this.doc_id,
new this.Profiler()
)
})
it('should take the per-doc lock', function () {
this.LockManager.promises.tryLock
.calledWith(this.doc_id)
.should.equal(true)
})
it('should process the doc updates and release the per-doc lock', function () {
this.UpdateManager.promises.processOutstandingUpdates.should.have.been.calledWith(
this.project_id,
this.doc_id
)
this.LockManager.promises.releaseLock
.calledWith(this.doc_id, this.lockValue)
.should.equal(true)
})
})
describe('when the doc has no pending updates', function () {
beforeEach(async function () {
this.RealTimeRedisManager.promises.getUpdatesLength.resolves(0)
await this.UpdateManager.promises.fetchAndApplyDocUpdatesUnderDocLock(
this.project_id,
this.doc_id,
new this.Profiler()
)
})
it('should not take the per-doc lock', function () {
this.LockManager.promises.tryLock.called.should.equal(false)
})
})
describe('when the per-doc lock is taken (e.g. by an old instance)', function () {
beforeEach(async function () {
this.RealTimeRedisManager.promises.getUpdatesLength.resolves(2)
this.LockManager.promises.tryLock.resolves(null)
await this.UpdateManager.promises.fetchAndApplyDocUpdatesUnderDocLock(
this.project_id,
this.doc_id,
new this.Profiler()
)
})
it('should skip processing the doc', function () {
this.UpdateManager.promises.processOutstandingUpdates.called.should.equal(
false
)
})
})
})
describe('fetchAndApplyProjectUpdates', function () {
beforeEach(function () {
this.UpdateManager.promises.applyUpdate = sinon.stub().resolves()
})
describe('with updates carrying their doc id', function () {
beforeEach(async function () {
this.updates = [
{ doc: 'doc-1', op: 'a' },
{ doc: 'doc-2', op: 'b' },
]
this.RealTimeRedisManager.promises.getPendingProjectUpdates.resolves(
this.updates
)
await this.UpdateManager.promises.fetchAndApplyProjectUpdates(
this.project_id,
new this.Profiler()
)
})
it('should read the per-project queue', function () {
this.RealTimeRedisManager.promises.getPendingProjectUpdates
.calledWith(this.project_id)
.should.equal(true)
})
it('should apply each update to the doc it carries', function () {
this.UpdateManager.promises.applyUpdate.should.have.been.calledWith(
this.project_id,
'doc-1',
this.updates[0]
)
this.UpdateManager.promises.applyUpdate.should.have.been.calledWith(
this.project_id,
'doc-2',
this.updates[1]
)
})
})
describe('with a history-OT update', function () {
beforeEach(async function () {
this.update = { doc: 'doc-1', op: 'a' }
this.RealTimeRedisManager.promises.getPendingProjectUpdates.resolves([
this.update,
])
this.HistoryOTUpdateManager.isHistoryOTEditOperationUpdate.returns(true)
await this.UpdateManager.promises.fetchAndApplyProjectUpdates(
this.project_id,
new this.Profiler()
)
})
it('should route it to the history-OT update manager', function () {
this.HistoryOTUpdateManager.applyUpdate.should.have.been.calledWith(
this.project_id,
'doc-1',
this.update
)
this.UpdateManager.promises.applyUpdate.called.should.equal(false)
})
})
describe('with no updates', function () {
beforeEach(async function () {
this.RealTimeRedisManager.promises.getPendingProjectUpdates.resolves([])
await this.UpdateManager.promises.fetchAndApplyProjectUpdates(
this.project_id,
new this.Profiler()
)
})
it('should not apply any update', function () {
this.UpdateManager.promises.applyUpdate.called.should.equal(false)
})
})
})
describe('continueProcessingUpdatesWithLock', function () {
describe('when there are outstanding updates', function () {
beforeEach(async function () {
this.RealTimeRedisManager.promises.getUpdatesLength.resolves(3)
this.UpdateManager.promises.getProjectPendingUpdatesLength = sinon
.stub()
.resolves(3)
this.UpdateManager.promises.processOutstandingUpdatesWithLock = sinon
.stub()
.resolves()
@@ -282,7 +481,9 @@ describe('UpdateManager', function () {
describe('when there are no outstanding updates', function () {
beforeEach(async function () {
this.RealTimeRedisManager.promises.getUpdatesLength.resolves(0)
this.UpdateManager.promises.getProjectPendingUpdatesLength = sinon
.stub()
.resolves(0)
this.UpdateManager.promises.processOutstandingUpdatesWithLock = sinon
.stub()
.resolves()
@@ -300,6 +501,61 @@ describe('UpdateManager', function () {
})
})
describe('getProjectPendingUpdatesLength', function () {
describe('when the per-project queue is non-empty', function () {
beforeEach(async function () {
this.RealTimeRedisManager.promises.getProjectUpdatesLength.resolves(4)
this.length =
await this.UpdateManager.promises.getProjectPendingUpdatesLength(
this.project_id,
this.doc_id
)
})
it('should short-circuit without scanning per-doc queues', function () {
this.length.should.equal(4)
this.RedisManager.promises.getDocIdsInProject.called.should.equal(false)
})
})
describe('when the per-project queue is empty (phases 1 & 2)', function () {
beforeEach(async function () {
this.Settings.pendingUpdatesMigrationPhase = 2
this.RealTimeRedisManager.promises.getProjectUpdatesLength.resolves(0)
this.RedisManager.promises.getDocIdsInProject.resolves(['doc-1'])
this.RealTimeRedisManager.promises.getUpdatesLength
.withArgs('doc-1')
.resolves(2)
this.length =
await this.UpdateManager.promises.getProjectPendingUpdatesLength(
this.project_id,
this.doc_id
)
})
it('should fall back to the per-doc queues', function () {
this.length.should.equal(2)
})
})
describe('in phase 3', function () {
beforeEach(async function () {
this.Settings.pendingUpdatesMigrationPhase = 3
this.RealTimeRedisManager.promises.getProjectUpdatesLength.resolves(0)
this.length =
await this.UpdateManager.promises.getProjectPendingUpdatesLength(
this.project_id,
this.doc_id
)
})
it('should only consider the per-project queue', function () {
this.length.should.equal(0)
this.RedisManager.promises.getDocIdsInProject.called.should.equal(false)
})
})
})
describe('fetchAndApplyUpdates', function () {
describe('with updates', function () {
beforeEach(async function () {
@@ -902,7 +1158,7 @@ describe('UpdateManager', function () {
this.UpdateManager.promises.continueProcessingUpdatesWithLock = sinon
.stub()
.resolves()
this.UpdateManager.promises.processOutstandingUpdates = sinon
this.UpdateManager.promises.processOutstandingProjectUpdates = sinon
.stub()
.resolves()
this.response = await this.UpdateManager.promises.lockUpdatesAndDo(
@@ -925,10 +1181,11 @@ describe('UpdateManager', function () {
.should.equal(true)
})
it('should process any outstanding updates', function () {
this.UpdateManager.promises.processOutstandingUpdates.should.have.been.calledWith(
it('should process any outstanding updates for the project', function () {
this.UpdateManager.promises.processOutstandingProjectUpdates.should.have.been.calledWith(
this.project_id,
this.doc_id
this.doc_id,
this.projectLockValue
)
})
@@ -958,10 +1215,10 @@ describe('UpdateManager', function () {
})
})
describe('when processOutstandingUpdates returns an error', function () {
describe('when processOutstandingProjectUpdates returns an error', function () {
beforeEach(async function () {
this.error = new Error('Something went wrong')
this.UpdateManager.promises.processOutstandingUpdates = sinon
this.UpdateManager.promises.processOutstandingProjectUpdates = sinon
.stub()
.rejects(this.error)
await expect(
@@ -974,10 +1231,7 @@ describe('UpdateManager', function () {
).to.be.rejectedWith(this.error)
})
it('should free both locks', function () {
this.LockManager.promises.releaseLock
.calledWith(this.doc_id, this.lockValue)
.should.equal(true)
it('should free the project lock', function () {
this.ProjectLockManager.promises.releaseLock
.calledWith(this.project_id, this.projectLockValue)
.should.equal(true)
@@ -987,7 +1241,7 @@ describe('UpdateManager', function () {
describe('when the method returns an error', function () {
beforeEach(async function () {
this.error = new Error('something went wrong')
this.UpdateManager.promises.processOutstandingUpdates = sinon
this.UpdateManager.promises.processOutstandingProjectUpdates = sinon
.stub()
.resolves()
this.method = sinon.stub().rejects(this.error)
@@ -22,6 +22,15 @@ const {
const rclient = RedisWrapper.createClient(settings.redis.documentupdater)
const Keys = settings.redis.documentupdater.key_schema
/**
* Fetch a doc's lines, version, ranges and recent ops from the
* document-updater.
*
* @param {string} projectId
* @param {string} docId
* @param {number} fromVersion - return ops from this version onwards (-1 for
* no ops)
*/
async function getDocument(projectId, docId, fromVersion) {
const timer = new metrics.Timer('get-document')
const url = `${settings.apis.documentupdater.url}/project/${projectId}/doc/${docId}?fromVersion=${fromVersion}&historyOTSupport=true`
@@ -67,11 +76,23 @@ async function getDocument(projectId, docId, fromVersion) {
}
}
/**
* Check that a doc is loadable from the document-updater.
*
* @param {string} projectId
* @param {string} docId
*/
async function checkDocument(projectId, docId) {
// in this call fromVersion = -1 means get document without docOps
return await getDocument(projectId, docId, -1)
}
/**
* Ask the document-updater to flush a project to mongo and remove it from
* redis.
*
* @param {string} projectId
*/
async function flushProjectToMongoAndDelete(projectId) {
// this method is called when the last connected user leaves the project
logger.debug({ projectId }, 'deleting project from document updater')
@@ -98,6 +119,11 @@ async function flushProjectToMongoAndDelete(projectId) {
}
}
/**
* Pick a random shard of the document-updater dispatch list.
*
* @return {string}
*/
function _getPendingUpdateListKey() {
const shard = _.random(0, settings.pendingUpdateListShardCount - 1)
if (shard === 0) {
@@ -107,7 +133,22 @@ function _getPendingUpdateListKey() {
}
}
async function queueChange(projectId, docId, change) {
/**
* Queue an update for processing by the document-updater.
*
* @param {string} projectId
* @param {string} docId
* @param {Object} change
* @param {boolean} usePerProjectPendingUpdates - feature flag assigned on
* joinProject; in migration phase 2 it selects the per-project queue
* over the legacy per-doc queue
*/
async function queueChange(
projectId,
docId,
change,
usePerProjectPendingUpdates
) {
const allowedKeys = ['doc', 'op', 'v', 'dupIfSource', 'meta', 'lastV', 'hash']
change = _.pick(change, allowedKeys)
const jsonChange = JSON.stringify(change)
@@ -121,8 +162,44 @@ async function queueChange(projectId, docId, change) {
throw new UpdateTooLargeError(updateSize)
}
switch (settings.pendingUpdatesMigrationPhase) {
case 1:
await _queueChangeOnDocQueue(projectId, docId, jsonChange)
break
case 2:
// the consumer drains both queues in phase 2, so the feature flag can
// safely pick the queue per client
if (usePerProjectPendingUpdates) {
await _queueChangeOnProjectQueue(projectId, jsonChange)
} else {
await _queueChangeOnDocQueue(projectId, docId, jsonChange)
}
break
case 3:
await _queueChangeOnProjectQueue(projectId, jsonChange)
break
default:
throw new OError('invalid pendingUpdatesMigrationPhase', {
pendingUpdatesMigrationPhase: settings.pendingUpdatesMigrationPhase,
})
}
}
/**
* Queue an update on the legacy per-doc queue (phase 1): push the payload
* onto the doc queue, then put a `project_id:doc_id` marker onto
* pending-updates-list.
*
* @param {string} projectId
* @param {string} docId
* @param {string} jsonChange - the serialized change
*/
async function _queueChangeOnDocQueue(projectId, docId, jsonChange) {
// record metric for each update added to queue
metrics.summary('redis.pendingUpdates', updateSize, { status: 'push' })
metrics.summary('redis.pendingUpdates', jsonChange.length, {
status: 'push',
path: 'doc',
})
const docKey = `${projectId}:${docId}`
// Push onto pendingUpdates for doc_id first, because once the doc updater
@@ -143,6 +220,40 @@ async function queueChange(projectId, docId, change) {
}
}
/**
* Queue an update on the per-project queue (phases 2 & 3): push the payload
* (tagged with its doc id) onto the project queue, then put a bare
* `project_id` marker onto pending-updates-list.
*
* @param {string} projectId
* @param {string} jsonChange
*/
async function _queueChangeOnProjectQueue(projectId, jsonChange) {
// record metric for each update added to queue
metrics.summary('redis.pendingUpdates', jsonChange.length, {
status: 'push',
path: 'project',
})
try {
await rclient.rpush(
Keys.pendingProjectUpdates({ project_id: projectId }),
jsonChange
)
} catch (error) {
throw new OError('error pushing project update into redis').withCause(error)
}
const queueKey = _getPendingUpdateListKey()
try {
await rclient.rpush(queueKey, projectId)
} catch (error) {
throw new OError('error pushing project_id into redis')
.withInfo({ queueKey })
.withCause(error)
}
}
export default {
getDocument: callbackify(getDocument),
checkDocument: callbackify(checkDocument),
@@ -54,6 +54,7 @@ async function joinProject(projectId, user) {
isRestrictedUser: data.isRestrictedUser,
isTokenMember: data.isTokenMember,
isInvitedMember: data.isInvitedMember,
usePerProjectPendingUpdates: data.usePerProjectPendingUpdates,
}
return {
project: data.project,
@@ -100,6 +100,8 @@ export default WebsocketController = {
client.ol_context.is_restricted_user = !!userMetadata.isRestrictedUser
client.ol_context.is_token_member = !!userMetadata.isTokenMember
client.ol_context.is_invited_member = !!userMetadata.isInvitedMember
client.ol_context.use_per_project_pending_updates =
!!userMetadata.usePerProjectPendingUpdates
RoomManager.joinProject(client, projectId, function (err) {
if (err) {
@@ -557,7 +559,11 @@ export default WebsocketController = {
applyOtUpdate(client, docId, update, callback) {
// client may have disconnected, but we can submit their update to doc-updater anyways.
const { user_id: userId, project_id: projectId } = client.ol_context
const {
user_id: userId,
project_id: projectId,
use_per_project_pending_updates: usePerProjectPendingUpdates,
} = client.ol_context
if (!projectId) {
return callback(new NotJoinedError())
}
@@ -607,6 +613,7 @@ export default WebsocketController = {
projectId,
docId,
update,
usePerProjectPendingUpdates,
function (error) {
if ((error && error.message) === 'update is too large') {
metrics.inc('update_too_large')
@@ -64,6 +64,9 @@ const settings = {
pendingUpdates({ doc_id }) {
return `PendingUpdates:{${doc_id}}`
},
pendingProjectUpdates({ project_id }) {
return `PendingProjectUpdates:{${project_id}}`
},
},
maxRetriesPerRequest: parseInt(
process.env.DOC_UPDATER_REDIS_MAX_RETRIES_PER_REQUEST ||
@@ -131,6 +134,20 @@ const settings = {
10
),
// Migration of the document-updater update queue from a per-doc queue to a
// per-project queue. Must be kept in step with document-updater.
// phase 1: write the legacy per-doc queue (`PendingUpdates:{doc_id}` +
// `<project_id>:<doc_id>` marker)
// phase 2: write the per-project queue (`PendingProjectUpdates:{project_id}`
// + bare `<project_id>` marker) for clients with the
// per-project-pending-updates feature flag (assigned by web on
// joinProject), the legacy per-doc queue otherwise
// phase 3: write the per-project queue for all clients
pendingUpdatesMigrationPhase: parseInt(
process.env.PENDING_UPDATES_MIGRATION_PHASE || '1',
10
),
// combine
// max_doc_length (2mb see above) * 2 (delete + insert)
// max_ranges_size (3mb see MAX_RANGES_SIZE in document-updater)
@@ -74,6 +74,8 @@ export default MockWebServer = {
isRestrictedUser: !!userMetadata?.isRestrictedUser,
isTokenMember: !!userMetadata?.isTokenMember,
isInvitedMember: !!userMetadata?.isInvitedMember,
usePerProjectPendingUpdates:
!!userMetadata?.usePerProjectPendingUpdates,
})
}
)
@@ -24,11 +24,15 @@ describe('DocumentUpdaterManager', function () {
pendingUpdates({ doc_id: docId }) {
return `PendingUpdates:${docId}`
},
pendingProjectUpdates({ project_id: projectId }) {
return `PendingProjectUpdates:${projectId}`
},
},
},
},
maxUpdateSize: 7 * 1024 * 1024,
pendingUpdateListShardCount: 10,
pendingUpdatesMigrationPhase: 1,
}
ctx.rclient = { auth() {} }
ctx.fetchJson = sinon.stub()
@@ -239,7 +243,7 @@ describe('DocumentUpdaterManager', function () {
describe('queueChange', function () {
beforeEach(function (ctx) {
ctx.change = {
doc: '1234567890',
doc: ctx.doc_id,
op: [{ d: 'test', p: 345 }],
v: 789,
}
@@ -274,6 +278,103 @@ describe('DocumentUpdaterManager', function () {
})
})
describe('in migration phase 2 with the per-project queue flag', function () {
beforeEach(async function (ctx) {
ctx.settings.pendingUpdatesMigrationPhase = 2
await ctx.DocumentUpdaterManager.promises.queueChange(
ctx.project_id,
ctx.doc_id,
ctx.change,
true
)
})
it('should push the change onto the per-project queue with its doc id', function (ctx) {
ctx.rclient.rpush
.calledWith(
`PendingProjectUpdates:${ctx.project_id}`,
JSON.stringify(ctx.change)
)
.should.equal(true)
})
it('should not push onto the legacy per-doc queue', function (ctx) {
ctx.rclient.rpush
.calledWith(`PendingUpdates:${ctx.doc_id}`)
.should.equal(false)
})
it('should notify the doc updater with a bare project id marker', function (ctx) {
const secondCall = ctx.rclient.rpush.secondCall
secondCall.should.exist
const queueKey = secondCall.args[0]
queueKey.should.match(/^pending-updates-list(-\d+)?$/)
secondCall.args[1].should.equal(ctx.project_id)
})
})
describe('in migration phase 2 without the per-project queue flag', function () {
beforeEach(async function (ctx) {
ctx.settings.pendingUpdatesMigrationPhase = 2
await ctx.DocumentUpdaterManager.promises.queueChange(
ctx.project_id,
ctx.doc_id,
ctx.change,
false
)
})
it('should push the change onto the legacy per-doc queue', function (ctx) {
ctx.rclient.rpush
.calledWith(
`PendingUpdates:${ctx.doc_id}`,
JSON.stringify(ctx.change)
)
.should.equal(true)
})
it('should not push onto the per-project queue', function (ctx) {
ctx.rclient.rpush
.calledWith(`PendingProjectUpdates:${ctx.project_id}`)
.should.equal(false)
})
it('should notify the doc updater with a project_id:doc_id marker', function (ctx) {
const secondCall = ctx.rclient.rpush.secondCall
secondCall.should.exist
const queueKey = secondCall.args[0]
queueKey.should.match(/^pending-updates-list(-\d+)?$/)
secondCall.args[1].should.equal(`${ctx.project_id}:${ctx.doc_id}`)
})
})
describe('in migration phase 3 (per-project queue only)', function () {
beforeEach(async function (ctx) {
ctx.settings.pendingUpdatesMigrationPhase = 3
await ctx.DocumentUpdaterManager.promises.queueChange(
ctx.project_id,
ctx.doc_id,
ctx.change,
false
)
})
it('should push the change onto the per-project queue even without the flag', function (ctx) {
ctx.rclient.rpush
.calledWith(
`PendingProjectUpdates:${ctx.project_id}`,
JSON.stringify(ctx.change)
)
.should.equal(true)
})
it('should not push onto the legacy per-doc queue', function (ctx) {
ctx.rclient.rpush
.calledWith(`PendingUpdates:${ctx.doc_id}`)
.should.equal(false)
})
})
describe('with error talking to redis during rpush', function () {
beforeEach(function (ctx) {
ctx.rclient.rpush = sinon
@@ -42,6 +42,7 @@ describe('WebApiManager', function () {
isRestrictedUser: true,
isTokenMember: true,
isInvitedMember: true,
usePerProjectPendingUpdates: true,
}
ctx.fetchUtils.fetchJson.resolves(ctx.response)
ctx.WebApiManager.joinProject(ctx.project_id, ctx.user, ctx.callback)
@@ -70,6 +71,8 @@ describe('WebApiManager', function () {
isRestrictedUser: ctx.response.isRestrictedUser,
isTokenMember: ctx.response.isTokenMember,
isInvitedMember: ctx.response.isInvitedMember,
usePerProjectPendingUpdates:
ctx.response.usePerProjectPendingUpdates,
})
.should.equal(true)
})
@@ -89,6 +92,7 @@ describe('WebApiManager', function () {
isRestrictedUser: true,
isTokenMember: false,
isInvitedMember: false,
usePerProjectPendingUpdates: false,
}
ctx.fetchUtils.fetchJson.resolves(ctx.response)
ctx.WebApiManager.joinProject(ctx.project_id, ctx.user, ctx.callback)
@@ -120,6 +124,8 @@ describe('WebApiManager', function () {
isRestrictedUser: ctx.response.isRestrictedUser,
isTokenMember: ctx.response.isTokenMember,
isInvitedMember: ctx.response.isInvitedMember,
usePerProjectPendingUpdates:
ctx.response.usePerProjectPendingUpdates,
}
)
})
@@ -97,12 +97,14 @@ describe('WebsocketController', function () {
ctx.isRestrictedUser = true
ctx.isTokenMember = true
ctx.isInvitedMember = true
ctx.usePerProjectPendingUpdates = true
ctx.WebApiManager.joinProject = sinon
.stub()
.callsArgWith(2, null, ctx.project, ctx.privilegeLevel, {
isRestrictedUser: ctx.isRestrictedUser,
isTokenMember: ctx.isTokenMember,
isInvitedMember: ctx.isInvitedMember,
usePerProjectPendingUpdates: ctx.usePerProjectPendingUpdates,
})
ctx.RoomManager.joinProject = sinon.stub().callsArg(2)
return ctx.WebsocketController.joinProject(
@@ -176,6 +178,11 @@ describe('WebsocketController', function () {
ctx.isInvitedMember
)
})
it('should set the use_per_project_pending_updates flag on the client', function (ctx) {
ctx.client.ol_context.use_per_project_pending_updates.should.equal(
ctx.usePerProjectPendingUpdates
)
})
it('should call the callback with the project, privilegeLevel and protocolVersion', function (ctx) {
return ctx.callback
.calledWith(
@@ -240,12 +247,14 @@ describe('WebsocketController', function () {
ctx.isRestrictedUser = true
ctx.isTokenMember = true
ctx.isInvitedMember = true
ctx.usePerProjectPendingUpdates = true
ctx.WebApiManager.joinProject = sinon
.stub()
.callsArgWith(2, null, ctx.project, ctx.privilegeLevel, {
isRestrictedUser: ctx.isRestrictedUser,
isTokenMember: ctx.isTokenMember,
isInvitedMember: ctx.isInvitedMember,
usePerProjectPendingUpdates: ctx.usePerProjectPendingUpdates,
})
ctx.RoomManager.joinProject = sinon
.stub()
@@ -1466,10 +1475,11 @@ describe('WebsocketController', function () {
ctx.update = { op: { p: 12, t: 'foo' } }
ctx.client.ol_context.user_id = ctx.user_id
ctx.client.ol_context.project_id = ctx.project_id
ctx.client.ol_context.use_per_project_pending_updates = true
ctx.WebsocketController._assertClientCanApplyUpdate = sinon
.stub()
.yields()
ctx.DocumentUpdaterManager.queueChange = sinon.stub().callsArg(3)
ctx.DocumentUpdaterManager.queueChange = sinon.stub().callsArg(4)
})
describe('succesfully', function () {
@@ -1490,9 +1500,9 @@ describe('WebsocketController', function () {
ctx.update.meta.user_id.should.equal(ctx.user_id)
})
it('should queue the update', function (ctx) {
it('should queue the update with the per-project queue flag', function (ctx) {
ctx.DocumentUpdaterManager.queueChange
.calledWith(ctx.project_id, ctx.doc_id, ctx.update)
.calledWith(ctx.project_id, ctx.doc_id, ctx.update, true)
.should.equal(true)
})
@@ -1510,7 +1520,7 @@ describe('WebsocketController', function () {
ctx.client.disconnect = sinon.stub()
ctx.DocumentUpdaterManager.queueChange = sinon
.stub()
.callsArgWith(3, (ctx.error = new Error('Something went wrong')))
.callsArgWith(4, (ctx.error = new Error('Something went wrong')))
ctx.WebsocketController.applyOtUpdate(
ctx.client,
ctx.doc_id,
@@ -1570,7 +1580,7 @@ describe('WebsocketController', function () {
const error = new UpdateTooLargeError(7372835)
ctx.DocumentUpdaterManager.queueChange = sinon
.stub()
.callsArgWith(3, error)
.callsArgWith(4, error)
ctx.WebsocketController.applyOtUpdate(
ctx.client,
ctx.doc_id,
@@ -7,6 +7,7 @@ import Metrics from '@overleaf/metrics'
import CollaboratorsInviteGetter from '../Collaborators/CollaboratorsInviteGetter.mjs'
import PrivilegeLevels from '../Authorization/PrivilegeLevels.mjs'
import SessionManager from '../Authentication/SessionManager.mjs'
import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
import Errors from '../Errors/Errors.js'
import { expressify } from '@overleaf/promise-utils'
import Settings from '@overleaf/settings'
@@ -67,12 +68,22 @@ async function joinProject(req, res, next) {
)
}
let usePerProjectPendingUpdates = false
if (userId) {
usePerProjectPendingUpdates =
await SplitTestHandler.promises.featureFlagEnabledForUser(
userId,
'per-project-pending-updates'
)
}
res.json({
project,
privilegeLevel,
isRestrictedUser,
isTokenMember,
isInvitedMember,
usePerProjectPendingUpdates,
})
}
+6 -1
View File
@@ -152,7 +152,12 @@ async function deleteDocFromRedis(projectId, docId) {
`lastUpdatedAt:{${docId}}`,
`lastUpdatedBy:{${docId}}`
)
await redis.srem(`DocsIn:{${projectId}}`, projectId)
// NOTE: once the update-queue migration reaches phase >= 2, a doc's pending
// updates live in the shared `PendingProjectUpdates:{projectId}` list (on the
// document-updater redis) rather than `PendingUpdates:{docId}`. That list is
// per-project, so it cannot be cleared here without dropping other docs'
// updates - flush/drain the project before running this script.
await redis.srem(`DocsIn:{${projectId}}`, docId)
}
try {
@@ -170,7 +170,12 @@ async function deleteDocFromRedis(projectId, docId) {
`lastUpdatedAt:{${docId}}`,
`lastUpdatedBy:{${docId}}`
)
await redis.srem(`DocsIn:{${projectId}}`, projectId)
// NOTE: once the update-queue migration reaches phase >= 2, a doc's pending
// updates live in the shared `PendingProjectUpdates:{projectId}` list (on the
// document-updater redis) rather than `PendingUpdates:{docId}`. That list is
// per-project, so it cannot be cleared here without dropping other docs'
// updates - flush/drain the project before running this script.
await redis.srem(`DocsIn:{${projectId}}`, docId)
}
try {
@@ -160,6 +160,7 @@ describe('EditorHttpController', function () {
ctx.SplitTestHandler = {
promises: {
getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }),
featureFlagEnabledForUser: sinon.stub().resolves(false),
},
}
ctx.UserGetter = { promises: { getUser: sinon.stub().resolves(null, {}) } }
@@ -304,6 +305,7 @@ describe('EditorHttpController', function () {
isRestrictedUser: false,
isTokenMember: false,
isInvitedMember: true,
usePerProjectPendingUpdates: false,
})
})
@@ -361,6 +363,7 @@ describe('EditorHttpController', function () {
isRestrictedUser: true,
isTokenMember: false,
isInvitedMember: false,
usePerProjectPendingUpdates: false,
})
})
})
@@ -417,8 +420,14 @@ describe('EditorHttpController', function () {
isRestrictedUser: true,
isTokenMember: false,
isInvitedMember: false,
usePerProjectPendingUpdates: false,
})
})
it('should not fetch a split test assignment', function (ctx) {
expect(ctx.SplitTestHandler.promises.featureFlagEnabledForUser).not.to
.have.been.called
})
})
describe('with a token access user', function () {
@@ -451,10 +460,36 @@ describe('EditorHttpController', function () {
isRestrictedUser: false,
isTokenMember: true,
isInvitedMember: false,
usePerProjectPendingUpdates: false,
})
})
})
describe('with the per-project-pending-updates flag enabled', function () {
beforeEach(async function (ctx) {
ctx.SplitTestHandler.promises.featureFlagEnabledForUser.resolves(true)
await new Promise(resolve => {
ctx.res.callback = resolve
ctx.EditorHttpController.joinProject(ctx.req, ctx.res)
})
})
it('should fetch the assignment for the joining user', function (ctx) {
expect(
ctx.SplitTestHandler.promises.featureFlagEnabledForUser
).to.have.been.calledWith(
ctx.user._id.toString(),
'per-project-pending-updates'
)
})
it('should return usePerProjectPendingUpdates=true', function (ctx) {
expect(ctx.res.json).toHaveBeenCalledWith(
expect.objectContaining({ usePerProjectPendingUpdates: true })
)
})
})
describe('when project is not found', function () {
beforeEach(async function (ctx) {
ctx.ProjectGetter.promises.getProject.resolves(null)