2024-01-30 10:35:54 -05:00
|
|
|
|
// @ts-check
|
|
|
|
|
|
|
|
|
|
const { callbackifyAll } = require('@overleaf/promise-utils')
|
2020-05-06 06:09:33 -04:00
|
|
|
|
const LockManager = require('./LockManager')
|
|
|
|
|
const RedisManager = require('./RedisManager')
|
2024-02-13 08:15:43 -05:00
|
|
|
|
const ProjectHistoryRedisManager = require('./ProjectHistoryRedisManager')
|
2020-05-06 06:09:33 -04:00
|
|
|
|
const RealTimeRedisManager = require('./RealTimeRedisManager')
|
|
|
|
|
const ShareJsUpdateManager = require('./ShareJsUpdateManager')
|
|
|
|
|
const HistoryManager = require('./HistoryManager')
|
2021-10-06 05:10:28 -04:00
|
|
|
|
const logger = require('@overleaf/logger')
|
2020-05-06 06:09:33 -04:00
|
|
|
|
const Metrics = require('./Metrics')
|
|
|
|
|
const Errors = require('./Errors')
|
|
|
|
|
const DocumentManager = require('./DocumentManager')
|
|
|
|
|
const RangesManager = require('./RangesManager')
|
|
|
|
|
const SnapshotManager = require('./SnapshotManager')
|
|
|
|
|
const Profiler = require('./Profiler')
|
2024-04-16 04:55:56 -04:00
|
|
|
|
const { isInsert, isDelete, getDocLength } = require('./Utils')
|
2024-02-13 08:15:43 -05:00
|
|
|
|
|
|
|
|
|
/**
|
2024-09-20 09:52:23 -04:00
|
|
|
|
* @import { DeleteOp, InsertOp, Op, Ranges, Update, HistoryUpdate } from "./types"
|
2024-02-13 08:15:43 -05:00
|
|
|
|
*/
|
2014-02-12 05:40:42 -05:00
|
|
|
|
|
2024-01-30 10:35:54 -05:00
|
|
|
|
const UpdateManager = {
|
|
|
|
|
async processOutstandingUpdates(projectId, docId) {
|
2020-05-06 06:09:33 -04:00
|
|
|
|
const timer = new Metrics.Timer('updateManager.processOutstandingUpdates')
|
2024-01-30 10:35:54 -05:00
|
|
|
|
try {
|
|
|
|
|
await UpdateManager.fetchAndApplyUpdates(projectId, docId)
|
|
|
|
|
timer.done({ status: 'success' })
|
|
|
|
|
} catch (err) {
|
|
|
|
|
timer.done({ status: 'error' })
|
|
|
|
|
throw err
|
|
|
|
|
}
|
2020-05-06 06:09:33 -04:00
|
|
|
|
},
|
2014-02-12 05:40:42 -05:00
|
|
|
|
|
2024-01-30 10:35:54 -05:00
|
|
|
|
async processOutstandingUpdatesWithLock(projectId, docId) {
|
2020-05-06 06:09:33 -04:00
|
|
|
|
const profile = new Profiler('processOutstandingUpdatesWithLock', {
|
2023-03-21 08:06:13 -04:00
|
|
|
|
project_id: projectId,
|
|
|
|
|
doc_id: docId,
|
2020-05-06 06:09:33 -04:00
|
|
|
|
})
|
2014-02-12 05:40:42 -05:00
|
|
|
|
|
2024-01-30 10:35:54 -05:00
|
|
|
|
const lockValue = await LockManager.promises.tryLock(docId)
|
|
|
|
|
if (lockValue == null) {
|
|
|
|
|
return
|
2020-05-06 06:09:33 -04:00
|
|
|
|
}
|
2024-01-30 10:35:54 -05:00
|
|
|
|
profile.log('tryLock')
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await UpdateManager.processOutstandingUpdates(projectId, docId)
|
|
|
|
|
profile.log('processOutstandingUpdates')
|
|
|
|
|
} finally {
|
|
|
|
|
await LockManager.promises.releaseLock(docId, lockValue)
|
|
|
|
|
profile.log('releaseLock').end()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await UpdateManager.continueProcessingUpdatesWithLock(projectId, docId)
|
2020-05-06 06:09:33 -04:00
|
|
|
|
},
|
2014-02-12 05:40:42 -05:00
|
|
|
|
|
2024-01-30 10:35:54 -05:00
|
|
|
|
async continueProcessingUpdatesWithLock(projectId, docId) {
|
|
|
|
|
const length = await RealTimeRedisManager.promises.getUpdatesLength(docId)
|
|
|
|
|
if (length > 0) {
|
|
|
|
|
await UpdateManager.processOutstandingUpdatesWithLock(projectId, docId)
|
2020-05-06 06:09:33 -04:00
|
|
|
|
}
|
2024-01-30 10:35:54 -05:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async fetchAndApplyUpdates(projectId, docId) {
|
2023-03-21 08:06:13 -04:00
|
|
|
|
const profile = new Profiler('fetchAndApplyUpdates', {
|
|
|
|
|
project_id: projectId,
|
|
|
|
|
doc_id: docId,
|
|
|
|
|
})
|
2014-02-12 05:40:42 -05:00
|
|
|
|
|
2024-03-25 06:51:40 -04:00
|
|
|
|
const updates =
|
|
|
|
|
await RealTimeRedisManager.promises.getPendingUpdatesForDoc(docId)
|
2024-01-30 10:35:54 -05:00
|
|
|
|
logger.debug(
|
|
|
|
|
{ projectId, docId, count: updates.length },
|
|
|
|
|
'processing updates'
|
|
|
|
|
)
|
|
|
|
|
if (updates.length === 0) {
|
|
|
|
|
return
|
2020-05-06 06:09:33 -04:00
|
|
|
|
}
|
2024-01-30 10:35:54 -05:00
|
|
|
|
profile.log('getPendingUpdatesForDoc')
|
|
|
|
|
|
|
|
|
|
for (const update of updates) {
|
|
|
|
|
await UpdateManager.applyUpdate(projectId, docId, update)
|
|
|
|
|
profile.log('applyUpdate')
|
2020-05-06 06:09:33 -04:00
|
|
|
|
}
|
2024-01-30 10:35:54 -05:00
|
|
|
|
profile.log('async done').end()
|
|
|
|
|
},
|
2017-05-19 11:00:16 -04:00
|
|
|
|
|
2024-02-13 08:15:43 -05:00
|
|
|
|
/**
|
|
|
|
|
* Apply an update to the given document
|
|
|
|
|
*
|
|
|
|
|
* @param {string} projectId
|
|
|
|
|
* @param {string} docId
|
|
|
|
|
* @param {Update} update
|
|
|
|
|
*/
|
2024-01-30 10:35:54 -05:00
|
|
|
|
async applyUpdate(projectId, docId, update) {
|
2023-03-21 08:06:13 -04:00
|
|
|
|
const profile = new Profiler('applyUpdate', {
|
|
|
|
|
project_id: projectId,
|
|
|
|
|
doc_id: docId,
|
|
|
|
|
})
|
2024-01-30 10:35:54 -05:00
|
|
|
|
|
2020-05-06 06:09:33 -04:00
|
|
|
|
UpdateManager._sanitizeUpdate(update)
|
2021-10-06 05:09:44 -04:00
|
|
|
|
profile.log('sanitizeUpdate', { sync: true })
|
2024-01-30 10:35:54 -05:00
|
|
|
|
|
|
|
|
|
try {
|
2024-02-13 08:15:43 -05:00
|
|
|
|
let {
|
|
|
|
|
lines,
|
|
|
|
|
version,
|
|
|
|
|
ranges,
|
|
|
|
|
pathname,
|
|
|
|
|
projectHistoryId,
|
|
|
|
|
historyRangesSupport,
|
|
|
|
|
} = await DocumentManager.promises.getDoc(projectId, docId)
|
2024-01-30 10:35:54 -05:00
|
|
|
|
profile.log('getDoc')
|
|
|
|
|
|
|
|
|
|
if (lines == null || version == null) {
|
|
|
|
|
throw new Errors.NotFoundError(`document not found: ${docId}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const previousVersion = version
|
|
|
|
|
const incomingUpdateVersion = update.v
|
|
|
|
|
let updatedDocLines, appliedOps
|
|
|
|
|
;({ updatedDocLines, version, appliedOps } =
|
|
|
|
|
await ShareJsUpdateManager.promises.applyUpdate(
|
2023-03-21 08:06:13 -04:00
|
|
|
|
projectId,
|
|
|
|
|
docId,
|
2021-07-13 07:04:42 -04:00
|
|
|
|
update,
|
|
|
|
|
lines,
|
2024-01-30 10:35:54 -05:00
|
|
|
|
version
|
|
|
|
|
))
|
|
|
|
|
profile.log('sharejs.applyUpdate', {
|
|
|
|
|
// only synchronous when the update applies directly to the
|
|
|
|
|
// doc version, otherwise getPreviousDocOps is called.
|
|
|
|
|
sync: incomingUpdateVersion === previousVersion,
|
|
|
|
|
})
|
|
|
|
|
|
2024-02-16 07:40:13 -05:00
|
|
|
|
const { newRanges, rangesWereCollapsed, historyUpdates } =
|
2024-02-13 08:15:43 -05:00
|
|
|
|
RangesManager.applyUpdate(
|
|
|
|
|
projectId,
|
|
|
|
|
docId,
|
|
|
|
|
ranges,
|
|
|
|
|
appliedOps,
|
2024-02-16 07:40:13 -05:00
|
|
|
|
updatedDocLines,
|
|
|
|
|
{ historyRangesSupport }
|
2024-02-13 08:15:43 -05:00
|
|
|
|
)
|
2024-01-30 10:35:54 -05:00
|
|
|
|
profile.log('RangesManager.applyUpdate', { sync: true })
|
|
|
|
|
|
2024-02-13 08:15:43 -05:00
|
|
|
|
await RedisManager.promises.updateDocument(
|
2024-01-30 10:35:54 -05:00
|
|
|
|
projectId,
|
|
|
|
|
docId,
|
|
|
|
|
updatedDocLines,
|
|
|
|
|
version,
|
|
|
|
|
appliedOps,
|
|
|
|
|
newRanges,
|
|
|
|
|
update.meta
|
|
|
|
|
)
|
|
|
|
|
profile.log('RedisManager.updateDocument')
|
|
|
|
|
|
2024-03-26 07:28:14 -04:00
|
|
|
|
UpdateManager._adjustHistoryUpdatesMetadata(
|
2024-02-13 11:36:05 -05:00
|
|
|
|
historyUpdates,
|
|
|
|
|
pathname,
|
|
|
|
|
projectHistoryId,
|
|
|
|
|
lines,
|
|
|
|
|
ranges,
|
|
|
|
|
historyRangesSupport
|
|
|
|
|
)
|
|
|
|
|
|
2024-02-13 08:15:43 -05:00
|
|
|
|
if (historyUpdates.length > 0) {
|
|
|
|
|
Metrics.inc('history-queue', 1, { status: 'project-history' })
|
|
|
|
|
try {
|
|
|
|
|
const projectOpsLength =
|
|
|
|
|
await ProjectHistoryRedisManager.promises.queueOps(
|
|
|
|
|
projectId,
|
|
|
|
|
...historyUpdates.map(op => JSON.stringify(op))
|
|
|
|
|
)
|
|
|
|
|
HistoryManager.recordAndFlushHistoryOps(
|
|
|
|
|
projectId,
|
|
|
|
|
historyUpdates,
|
|
|
|
|
projectOpsLength
|
|
|
|
|
)
|
|
|
|
|
profile.log('recordAndFlushHistoryOps')
|
|
|
|
|
} catch (err) {
|
|
|
|
|
// The full project history can re-sync a project in case
|
|
|
|
|
// updates went missing.
|
|
|
|
|
// Just record the error here and acknowledge the write-op.
|
|
|
|
|
Metrics.inc('history-queue-error')
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-01-30 10:35:54 -05:00
|
|
|
|
|
|
|
|
|
if (rangesWereCollapsed) {
|
|
|
|
|
Metrics.inc('doc-snapshot')
|
|
|
|
|
logger.debug(
|
|
|
|
|
{
|
|
|
|
|
projectId,
|
|
|
|
|
docId,
|
|
|
|
|
previousVersion,
|
|
|
|
|
lines,
|
|
|
|
|
ranges,
|
|
|
|
|
update,
|
|
|
|
|
},
|
|
|
|
|
'update collapsed some ranges, snapshotting previous content'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Do this last, since it's a mongo call, and so potentially longest running
|
|
|
|
|
// If it overruns the lock, it's ok, since all of our redis work is done
|
|
|
|
|
await SnapshotManager.promises.recordSnapshot(
|
|
|
|
|
projectId,
|
|
|
|
|
docId,
|
|
|
|
|
previousVersion,
|
|
|
|
|
pathname,
|
|
|
|
|
lines,
|
|
|
|
|
ranges
|
2021-07-13 07:04:42 -04:00
|
|
|
|
)
|
|
|
|
|
}
|
2024-01-30 10:35:54 -05:00
|
|
|
|
} catch (error) {
|
|
|
|
|
RealTimeRedisManager.sendData({
|
|
|
|
|
project_id: projectId,
|
|
|
|
|
doc_id: docId,
|
|
|
|
|
error: error instanceof Error ? error.message : error,
|
|
|
|
|
})
|
|
|
|
|
profile.log('sendData')
|
|
|
|
|
throw error
|
|
|
|
|
} finally {
|
|
|
|
|
profile.end()
|
|
|
|
|
}
|
2020-05-06 06:09:33 -04:00
|
|
|
|
},
|
2014-02-12 05:40:42 -05:00
|
|
|
|
|
2024-01-30 10:35:54 -05:00
|
|
|
|
async lockUpdatesAndDo(method, projectId, docId, ...args) {
|
2023-03-21 08:06:13 -04:00
|
|
|
|
const profile = new Profiler('lockUpdatesAndDo', {
|
|
|
|
|
project_id: projectId,
|
|
|
|
|
doc_id: docId,
|
|
|
|
|
})
|
2024-01-30 10:35:54 -05:00
|
|
|
|
|
|
|
|
|
const lockValue = await LockManager.promises.getLock(docId)
|
|
|
|
|
profile.log('getLock')
|
|
|
|
|
|
2024-04-02 09:49:42 -04:00
|
|
|
|
let result
|
2024-01-30 10:35:54 -05:00
|
|
|
|
try {
|
|
|
|
|
await UpdateManager.processOutstandingUpdates(projectId, docId)
|
|
|
|
|
profile.log('processOutstandingUpdates')
|
|
|
|
|
|
2024-04-02 09:49:42 -04:00
|
|
|
|
result = await method(projectId, docId, ...args)
|
2024-01-30 10:35:54 -05:00
|
|
|
|
profile.log('method')
|
|
|
|
|
} finally {
|
|
|
|
|
await LockManager.promises.releaseLock(docId, lockValue)
|
|
|
|
|
profile.log('releaseLock').end()
|
2020-05-06 06:09:33 -04:00
|
|
|
|
}
|
2024-01-30 10:35:54 -05:00
|
|
|
|
|
|
|
|
|
// We held the lock for a while so updates might have queued up
|
|
|
|
|
UpdateManager.continueProcessingUpdatesWithLock(projectId, docId).catch(
|
|
|
|
|
err => {
|
|
|
|
|
// The processing may fail for invalid user updates.
|
|
|
|
|
// This can be very noisy, put them on level DEBUG
|
|
|
|
|
// and record a metric.
|
|
|
|
|
Metrics.inc('background-processing-updates-error')
|
|
|
|
|
logger.debug(
|
|
|
|
|
{ err, projectId, docId },
|
|
|
|
|
'error processing updates in background'
|
|
|
|
|
)
|
|
|
|
|
}
|
2020-05-06 06:09:33 -04:00
|
|
|
|
)
|
2024-01-30 10:35:54 -05:00
|
|
|
|
|
2024-04-02 09:49:42 -04:00
|
|
|
|
return result
|
2020-05-06 06:09:33 -04:00
|
|
|
|
},
|
2017-09-29 07:57:27 -04:00
|
|
|
|
|
2020-05-06 06:09:33 -04:00
|
|
|
|
_sanitizeUpdate(update) {
|
|
|
|
|
// In Javascript, characters are 16-bits wide. It does not understand surrogates as characters.
|
|
|
|
|
//
|
|
|
|
|
// From Wikipedia (http://en.wikipedia.org/wiki/Plane_(Unicode)#Basic_Multilingual_Plane):
|
|
|
|
|
// "The High Surrogates (U+D800–U+DBFF) and Low Surrogate (U+DC00–U+DFFF) codes are reserved
|
|
|
|
|
// for encoding non-BMP characters in UTF-16 by using a pair of 16-bit codes: one High Surrogate
|
|
|
|
|
// and one Low Surrogate. A single surrogate code point will never be assigned a character.""
|
|
|
|
|
//
|
|
|
|
|
// The main offender seems to be \uD835 as a stand alone character, which would be the first
|
|
|
|
|
// 16-bit character of a blackboard bold character (http://www.fileformat.info/info/unicode/char/1d400/index.htm).
|
|
|
|
|
// 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.
|
2024-01-30 10:35:54 -05:00
|
|
|
|
for (const op of update.op || []) {
|
2020-05-06 06:09:33 -04:00
|
|
|
|
if (op.i != null) {
|
|
|
|
|
// Replace high and low surrogate characters with 'replacement character' (\uFFFD)
|
|
|
|
|
op.i = op.i.replace(/[\uD800-\uDFFF]/g, '\uFFFD')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return update
|
|
|
|
|
},
|
2014-02-12 05:40:42 -05:00
|
|
|
|
|
2024-02-13 08:15:43 -05:00
|
|
|
|
/**
|
2024-02-13 11:36:05 -05:00
|
|
|
|
* Add metadata that will be useful to project history
|
2024-02-13 08:15:43 -05:00
|
|
|
|
*
|
2024-02-13 11:36:05 -05:00
|
|
|
|
* @param {HistoryUpdate[]} updates
|
2024-02-13 08:15:43 -05:00
|
|
|
|
* @param {string} pathname
|
|
|
|
|
* @param {string} projectHistoryId
|
|
|
|
|
* @param {string[]} lines
|
2024-02-13 11:36:05 -05:00
|
|
|
|
* @param {Ranges} ranges
|
|
|
|
|
* @param {boolean} historyRangesSupport
|
2024-02-13 08:15:43 -05:00
|
|
|
|
*/
|
2024-03-26 07:28:14 -04:00
|
|
|
|
_adjustHistoryUpdatesMetadata(
|
2024-02-13 11:36:05 -05:00
|
|
|
|
updates,
|
|
|
|
|
pathname,
|
|
|
|
|
projectHistoryId,
|
|
|
|
|
lines,
|
|
|
|
|
ranges,
|
|
|
|
|
historyRangesSupport
|
|
|
|
|
) {
|
2024-04-16 04:55:56 -04:00
|
|
|
|
let docLength = getDocLength(lines)
|
2024-02-13 11:36:05 -05:00
|
|
|
|
let historyDocLength = docLength
|
|
|
|
|
for (const change of ranges.changes ?? []) {
|
|
|
|
|
if ('d' in change.op) {
|
|
|
|
|
historyDocLength += change.op.d.length
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-02-13 08:15:43 -05:00
|
|
|
|
|
|
|
|
|
for (const update of updates) {
|
2020-05-06 06:09:33 -04:00
|
|
|
|
update.projectHistoryId = projectHistoryId
|
|
|
|
|
if (!update.meta) {
|
|
|
|
|
update.meta = {}
|
|
|
|
|
}
|
|
|
|
|
update.meta.pathname = pathname
|
2023-03-21 08:06:13 -04:00
|
|
|
|
update.meta.doc_length = docLength
|
2024-02-13 11:36:05 -05:00
|
|
|
|
if (historyRangesSupport && historyDocLength !== docLength) {
|
|
|
|
|
update.meta.history_doc_length = historyDocLength
|
|
|
|
|
}
|
|
|
|
|
|
2020-05-06 06:09:33 -04:00
|
|
|
|
// Each update may contain multiple ops, i.e.
|
|
|
|
|
// [{
|
|
|
|
|
// ops: [{i: "foo", p: 4}, {d: "bar", p:8}]
|
|
|
|
|
// }, {
|
|
|
|
|
// ops: [{d: "baz", p: 40}, {i: "qux", p:8}]
|
|
|
|
|
// }]
|
|
|
|
|
// We want to include the doc_length at the start of each update,
|
|
|
|
|
// before it's ops are applied. However, we need to track any
|
|
|
|
|
// changes to it for the next update.
|
2024-01-30 10:35:54 -05:00
|
|
|
|
for (const op of update.op) {
|
2024-02-13 08:15:43 -05:00
|
|
|
|
if (isInsert(op)) {
|
2024-01-30 10:35:54 -05:00
|
|
|
|
docLength += op.i.length
|
2024-02-13 11:36:05 -05:00
|
|
|
|
if (!op.trackedDeleteRejection) {
|
|
|
|
|
// Tracked delete rejections end up retaining characters rather
|
|
|
|
|
// than inserting
|
|
|
|
|
historyDocLength += op.i.length
|
|
|
|
|
}
|
2020-05-06 06:09:33 -04:00
|
|
|
|
}
|
2024-02-13 08:15:43 -05:00
|
|
|
|
if (isDelete(op)) {
|
2024-01-30 10:35:54 -05:00
|
|
|
|
docLength -= op.d.length
|
2024-06-05 10:25:17 -04:00
|
|
|
|
if (update.meta.tc) {
|
|
|
|
|
// This is a tracked delete. It will be translated into a retain in
|
|
|
|
|
// history, except any enclosed tracked inserts, which will be
|
|
|
|
|
// translated into regular deletes.
|
|
|
|
|
for (const change of op.trackedChanges ?? []) {
|
|
|
|
|
if (change.type === 'insert') {
|
|
|
|
|
historyDocLength -= change.length
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// This is a regular delete. It will be translated to a delete in
|
|
|
|
|
// history.
|
2024-02-13 11:36:05 -05:00
|
|
|
|
historyDocLength -= op.d.length
|
|
|
|
|
}
|
2024-01-30 10:35:54 -05:00
|
|
|
|
}
|
|
|
|
|
}
|
2024-03-26 07:28:14 -04:00
|
|
|
|
|
|
|
|
|
if (!historyRangesSupport) {
|
|
|
|
|
// Prevent project-history from processing tracked changes
|
|
|
|
|
delete update.meta.tc
|
|
|
|
|
}
|
2024-02-13 08:15:43 -05:00
|
|
|
|
}
|
2021-07-13 07:04:42 -04:00
|
|
|
|
},
|
2020-05-06 06:09:33 -04:00
|
|
|
|
}
|
2024-01-30 10:35:54 -05:00
|
|
|
|
|
2024-04-02 09:49:42 -04:00
|
|
|
|
module.exports = { ...callbackifyAll(UpdateManager), promises: UpdateManager }
|