[document-updater] wrap doc locks in project lock (#35416)

* [document-updater] wrap doc locks in project lock

* [document-updater] use BlockingProject as redis key prefix

* [redis-wrapper] update comment

GitOrigin-RevId: 0ce50e76386871e6c29481494febc8d6b37e7df5
This commit is contained in:
Jakob Ackermann
2026-09-17 11:24:34 +00:00
committed by Copybot
parent ff72ccf5ba
commit 89f12e6b1f
6 changed files with 197 additions and 21 deletions
@@ -0,0 +1,21 @@
const Settings = require('@overleaf/settings')
const redis = require('@overleaf/redis-wrapper')
const rclient = redis.createClient(Settings.redis.lock)
const keys = Settings.redis.lock.key_schema
const RedisLocker = require('@overleaf/redis-wrapper/RedisLocker')
// Project level lock used to serialise processing of a project's pending
// updates (the `BlockingProject:{project_id}` key). Acquired before the per-doc
// `Blocking:{doc_id}` lock - never the other way around - to avoid deadlocks.
module.exports = new RedisLocker({
rclient,
getKey(projectId) {
return keys.projectBlockingKey({ project_id: projectId })
},
wrapTimeoutError(err, projectId) {
err.project_id = projectId
return err
},
metricsPrefix: 'project',
lockTTLSeconds: Settings.redisLockTTLSeconds,
})
@@ -2,6 +2,7 @@
const { callbackifyAll } = require('@overleaf/promise-utils')
const LockManager = require('./LockManager')
const ProjectLockManager = require('./ProjectLockManager')
const RedisManager = require('./RedisManager')
const ProjectHistoryRedisManager = require('./ProjectHistoryRedisManager')
const RealTimeRedisManager = require('./RealTimeRedisManager')
@@ -40,18 +41,30 @@ const UpdateManager = {
doc_id: docId,
})
const lockValue = await LockManager.promises.tryLock(docId)
if (lockValue == null) {
return
}
profile.log('tryLock')
// Always take the project lock before the per-doc lock to avoid deadlocks.
// Wait for the project lock rather than bailing out: another action
// holding it (e.g. for a different doc) will not process this doc's queue.
const projectLockValue =
await ProjectLockManager.promises.getLock(projectId)
profile.log('getProjectLock')
try {
await UpdateManager.processOutstandingUpdates(projectId, docId)
profile.log('processOutstandingUpdates')
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')
}
} finally {
await LockManager.promises.releaseLock(docId, lockValue)
profile.log('releaseLock').end()
await ProjectLockManager.promises.releaseLock(projectId, projectLockValue)
profile.log('releaseProjectLock').end()
}
await UpdateManager.continueProcessingUpdatesWithLock(projectId, docId)
@@ -279,19 +292,29 @@ const UpdateManager = {
doc_id: docId,
})
const lockValue = await LockManager.promises.getLock(docId)
profile.log('getLock')
// Always take the project lock before the per-doc lock to avoid deadlocks.
const projectLockValue =
await ProjectLockManager.promises.getLock(projectId)
profile.log('getProjectLock')
let result
try {
await UpdateManager.processOutstandingUpdates(projectId, docId)
profile.log('processOutstandingUpdates')
const lockValue = await LockManager.promises.getLock(docId)
profile.log('getLock')
result = await method(projectId, docId, ...args)
profile.log('method')
try {
await UpdateManager.processOutstandingUpdates(projectId, docId)
profile.log('processOutstandingUpdates')
result = await method(projectId, docId, ...args)
profile.log('method')
} finally {
await LockManager.promises.releaseLock(docId, lockValue)
profile.log('releaseLock')
}
} finally {
await LockManager.promises.releaseLock(docId, lockValue)
profile.log('releaseLock').end()
await ProjectLockManager.promises.releaseLock(projectId, projectLockValue)
profile.log('releaseProjectLock').end()
}
// We held the lock for a while so updates might have queued up
@@ -80,6 +80,9 @@ module.exports = {
blockingKey({ doc_id: docId }) {
return `Blocking:{${docId}}`
},
projectBlockingKey({ project_id: projectId }) {
return `BlockingProject:{${projectId}}`
},
},
},
@@ -11,6 +11,7 @@ describe('UpdateManager', function () {
this.projectHistoryId = 'history-id-123'
this.doc_id = 'document-id-123'
this.lockValue = 'mock-lock-value'
this.projectLockValue = 'mock-project-lock-value'
this.pathname = '/a/b/c.tex'
this.Metrics = {
@@ -31,6 +32,14 @@ describe('UpdateManager', function () {
},
}
this.ProjectLockManager = {
promises: {
tryLock: sinon.stub().resolves(this.projectLockValue),
getLock: sinon.stub().resolves(this.projectLockValue),
releaseLock: sinon.stub().resolves(),
},
}
this.RedisManager = {
promises: {
setDocument: sinon.stub().resolves(),
@@ -92,6 +101,7 @@ describe('UpdateManager', function () {
this.UpdateManager = SandboxedModule.require(MODULE_PATH, {
requires: {
'./LockManager': this.LockManager,
'./ProjectLockManager': this.ProjectLockManager,
'./RedisManager': this.RedisManager,
'./RealTimeRedisManager': this.RealTimeRedisManager,
'./ShareJsUpdateManager': this.ShareJsUpdateManager,
@@ -147,6 +157,15 @@ describe('UpdateManager', function () {
)
})
it('should acquire the project lock before the doc 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)
@@ -159,6 +178,12 @@ describe('UpdateManager', function () {
.should.equal(true)
})
it('should free the project lock', function () {
this.ProjectLockManager.promises.releaseLock
.calledWith(this.project_id, this.projectLockValue)
.should.equal(true)
})
it('should process the outstanding updates', function () {
this.UpdateManager.promises.processOutstandingUpdates
.calledWith(this.project_id, this.doc_id)
@@ -200,6 +225,12 @@ describe('UpdateManager', function () {
.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)
})
})
})
@@ -220,6 +251,12 @@ describe('UpdateManager', function () {
false
)
})
it('should free the project lock', function () {
this.ProjectLockManager.promises.releaseLock
.calledWith(this.project_id, this.projectLockValue)
.should.equal(true)
})
})
})
@@ -876,10 +913,16 @@ describe('UpdateManager', function () {
)
})
it('should lock the doc', function () {
it('should lock the project then the doc', function () {
this.ProjectLockManager.promises.getLock
.calledWith(this.project_id)
.should.equal(true)
this.LockManager.promises.getLock
.calledWith(this.doc_id)
.should.equal(true)
this.LockManager.promises.getLock
.calledAfter(this.ProjectLockManager.promises.getLock)
.should.equal(true)
})
it('should process any outstanding updates', function () {
@@ -899,10 +942,13 @@ describe('UpdateManager', function () {
expect(this.response).to.equal(this.methodResult)
})
it('should release the lock', function () {
it('should release both locks', function () {
this.LockManager.promises.releaseLock
.calledWith(this.doc_id, this.lockValue)
.should.equal(true)
this.ProjectLockManager.promises.releaseLock
.calledWith(this.project_id, this.projectLockValue)
.should.equal(true)
})
it('should continue processing updates', function () {
@@ -928,10 +974,13 @@ describe('UpdateManager', function () {
).to.be.rejectedWith(this.error)
})
it('should free the lock', function () {
it('should free both locks', function () {
this.LockManager.promises.releaseLock
.calledWith(this.doc_id, this.lockValue)
.should.equal(true)
this.ProjectLockManager.promises.releaseLock
.calledWith(this.project_id, this.projectLockValue)
.should.equal(true)
})
})
@@ -952,10 +1001,13 @@ describe('UpdateManager', function () {
).to.be.rejectedWith(this.error)
})
it('should free the lock', function () {
it('should free both locks', function () {
this.LockManager.promises.releaseLock
.calledWith(this.doc_id, this.lockValue)
.should.equal(true)
this.ProjectLockManager.promises.releaseLock
.calledWith(this.project_id, this.projectLockValue)
.should.equal(true)
})
})
})