| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606 |
- const { callbackifyAll } = require('@overleaf/promise-utils')
- const RedisManager = require('./RedisManager')
- const ProjectHistoryRedisManager = require('./ProjectHistoryRedisManager')
- const PersistenceManager = require('./PersistenceManager')
- const DiffCodec = require('./DiffCodec')
- const logger = require('@overleaf/logger')
- const Metrics = require('./Metrics')
- const HistoryManager = require('./HistoryManager')
- const Errors = require('./Errors')
- const RangesManager = require('./RangesManager')
- const { extractOriginOrSource } = require('./Utils')
- const MAX_UNFLUSHED_AGE = 300 * 1000 // 5 mins, document should be flushed to mongo this time after a change
- const DocumentManager = {
- async getDoc(projectId, docId) {
- const {
- lines,
- version,
- ranges,
- resolvedCommentIds,
- pathname,
- projectHistoryId,
- unflushedTime,
- historyRangesSupport,
- } = await RedisManager.promises.getDoc(projectId, docId)
- if (lines == null || version == null) {
- logger.debug(
- { projectId, docId },
- 'doc not in redis so getting from persistence API'
- )
- const {
- lines,
- version,
- ranges,
- resolvedCommentIds,
- pathname,
- projectHistoryId,
- historyRangesSupport,
- } = await PersistenceManager.promises.getDoc(projectId, docId)
- logger.debug(
- {
- projectId,
- docId,
- lines,
- ranges,
- resolvedCommentIds,
- version,
- pathname,
- projectHistoryId,
- historyRangesSupport,
- },
- 'got doc from persistence API'
- )
- await RedisManager.promises.putDocInMemory(
- projectId,
- docId,
- lines,
- version,
- ranges,
- resolvedCommentIds,
- pathname,
- projectHistoryId,
- historyRangesSupport
- )
- return {
- lines,
- version,
- ranges: ranges || {},
- resolvedCommentIds,
- pathname,
- projectHistoryId,
- unflushedTime: null,
- alreadyLoaded: false,
- historyRangesSupport,
- }
- } else {
- return {
- lines,
- version,
- ranges,
- pathname,
- projectHistoryId,
- resolvedCommentIds,
- unflushedTime,
- alreadyLoaded: true,
- historyRangesSupport,
- }
- }
- },
- async getDocAndRecentOps(projectId, docId, fromVersion) {
- const { lines, version, ranges, pathname, projectHistoryId } =
- await DocumentManager.getDoc(projectId, docId)
- if (fromVersion === -1) {
- return { lines, version, ops: [], ranges, pathname, projectHistoryId }
- } else {
- const ops = await RedisManager.promises.getPreviousDocOps(
- docId,
- fromVersion,
- version
- )
- return {
- lines,
- version,
- ops,
- ranges,
- pathname,
- projectHistoryId,
- }
- }
- },
- async setDoc(projectId, docId, newLines, originOrSource, userId, undoing) {
- if (newLines == null) {
- throw new Error('No lines were provided to setDoc')
- }
- const UpdateManager = require('./UpdateManager')
- const {
- lines: oldLines,
- version,
- alreadyLoaded,
- } = await DocumentManager.getDoc(projectId, docId)
- if (oldLines != null && oldLines.length > 0 && oldLines[0].text != null) {
- logger.debug(
- { docId, projectId, oldLines, newLines },
- 'document is JSON so not updating'
- )
- return
- }
- logger.debug(
- { docId, projectId, oldLines, newLines },
- 'setting a document via http'
- )
- const op = DiffCodec.diffAsShareJsOp(oldLines, newLines)
- if (undoing) {
- for (const o of op || []) {
- o.u = true
- } // Turn on undo flag for each op for track changes
- }
- const { origin, source } = extractOriginOrSource(originOrSource)
- const update = {
- doc: docId,
- op,
- v: version,
- meta: {
- type: 'external',
- user_id: userId,
- },
- }
- if (origin) {
- update.meta.origin = origin
- } else if (source) {
- update.meta.source = source
- }
- // Keep track of external updates, whether they are for live documents
- // (flush) or unloaded documents (evict), and whether the update is a no-op.
- Metrics.inc('external-update', 1, {
- status: op.length > 0 ? 'diff' : 'noop',
- method: alreadyLoaded ? 'flush' : 'evict',
- path: source,
- })
- // Do not notify the frontend about a noop update.
- // We still want to execute the code below
- // to evict the doc if we loaded it into redis for
- // this update, otherwise the doc would never be
- // removed from redis.
- if (op.length > 0) {
- await UpdateManager.promises.applyUpdate(projectId, docId, update)
- }
- // If the document was loaded already, then someone has it open
- // in a project, and the usual flushing mechanism will happen.
- // Otherwise we should remove it immediately since nothing else
- // is using it.
- if (alreadyLoaded) {
- return await DocumentManager.flushDocIfLoaded(projectId, docId)
- } else {
- try {
- return await DocumentManager.flushAndDeleteDoc(projectId, docId, {})
- } finally {
- // There is no harm in flushing project history if the previous
- // call failed and sometimes it is required
- HistoryManager.flushProjectChangesAsync(projectId)
- }
- }
- },
- async flushDocIfLoaded(projectId, docId) {
- const {
- lines,
- version,
- ranges,
- unflushedTime,
- lastUpdatedAt,
- lastUpdatedBy,
- } = await RedisManager.promises.getDoc(projectId, docId)
- if (lines == null || version == null) {
- Metrics.inc('flush-doc-if-loaded', 1, { status: 'not-loaded' })
- logger.debug({ projectId, docId }, 'doc is not loaded so not flushing')
- // TODO: return a flag to bail out, as we go on to remove doc from memory?
- return
- } else if (unflushedTime == null) {
- Metrics.inc('flush-doc-if-loaded', 1, { status: 'unmodified' })
- logger.debug({ projectId, docId }, 'doc is not modified so not flushing')
- return
- }
- logger.debug({ projectId, docId, version }, 'flushing doc')
- Metrics.inc('flush-doc-if-loaded', 1, { status: 'modified' })
- const result = await PersistenceManager.promises.setDoc(
- projectId,
- docId,
- lines,
- version,
- ranges,
- lastUpdatedAt,
- lastUpdatedBy
- )
- await RedisManager.promises.clearUnflushedTime(docId)
- return result
- },
- async flushAndDeleteDoc(projectId, docId, options) {
- let result
- try {
- result = await DocumentManager.flushDocIfLoaded(projectId, docId)
- } catch (error) {
- if (options.ignoreFlushErrors) {
- logger.warn(
- { projectId, docId, err: error },
- 'ignoring flush error while deleting document'
- )
- } else {
- throw error
- }
- }
- await RedisManager.promises.removeDocFromMemory(projectId, docId)
- return result
- },
- async acceptChanges(projectId, docId, changeIds) {
- if (changeIds == null) {
- changeIds = []
- }
- const {
- lines,
- version,
- ranges,
- pathname,
- projectHistoryId,
- historyRangesSupport,
- } = await DocumentManager.getDoc(projectId, docId)
- if (lines == null || version == null) {
- throw new Errors.NotFoundError(`document not found: ${docId}`)
- }
- const newRanges = RangesManager.acceptChanges(changeIds, ranges)
- await RedisManager.promises.updateDocument(
- projectId,
- docId,
- lines,
- version,
- [],
- newRanges,
- {}
- )
- if (historyRangesSupport) {
- const historyUpdates = RangesManager.getHistoryUpdatesForAcceptedChanges({
- docId,
- acceptedChangeIds: changeIds,
- changes: ranges.changes || [],
- lines,
- pathname,
- projectHistoryId,
- })
- if (historyUpdates.length === 0) {
- return
- }
- await ProjectHistoryRedisManager.promises.queueOps(
- projectId,
- ...historyUpdates.map(op => JSON.stringify(op))
- )
- }
- },
- async updateCommentState(projectId, docId, commentId, userId, resolved) {
- const { lines, version, pathname, historyRangesSupport } =
- await DocumentManager.getDoc(projectId, docId)
- if (lines == null || version == null) {
- throw new Errors.NotFoundError(`document not found: ${docId}`)
- }
- if (historyRangesSupport) {
- await RedisManager.promises.updateCommentState(docId, commentId, resolved)
- await ProjectHistoryRedisManager.promises.queueOps(
- projectId,
- JSON.stringify({
- pathname,
- commentId,
- resolved,
- meta: {
- ts: new Date(),
- user_id: userId,
- },
- })
- )
- }
- },
- async deleteComment(projectId, docId, commentId, userId) {
- const { lines, version, ranges, pathname, historyRangesSupport } =
- await DocumentManager.getDoc(projectId, docId)
- if (lines == null || version == null) {
- throw new Errors.NotFoundError(`document not found: ${docId}`)
- }
- const newRanges = RangesManager.deleteComment(commentId, ranges)
- await RedisManager.promises.updateDocument(
- projectId,
- docId,
- lines,
- version,
- [],
- newRanges,
- {}
- )
- if (historyRangesSupport) {
- await RedisManager.promises.updateCommentState(docId, commentId, false)
- await ProjectHistoryRedisManager.promises.queueOps(
- projectId,
- JSON.stringify({
- pathname,
- deleteComment: commentId,
- meta: {
- ts: new Date(),
- user_id: userId,
- },
- })
- )
- }
- },
- async renameDoc(projectId, docId, userId, update, projectHistoryId) {
- await RedisManager.promises.renameDoc(
- projectId,
- docId,
- userId,
- update,
- projectHistoryId
- )
- },
- async getDocAndFlushIfOld(projectId, docId) {
- const { lines, version, unflushedTime, alreadyLoaded } =
- await DocumentManager.getDoc(projectId, docId)
- // if doc was already loaded see if it needs to be flushed
- if (
- alreadyLoaded &&
- unflushedTime != null &&
- Date.now() - unflushedTime > MAX_UNFLUSHED_AGE
- ) {
- await DocumentManager.flushDocIfLoaded(projectId, docId)
- }
- return { lines, version }
- },
- async resyncDocContents(projectId, docId, path) {
- logger.debug({ projectId, docId, path }, 'start resyncing doc contents')
- let {
- lines,
- ranges,
- resolvedCommentIds,
- version,
- projectHistoryId,
- historyRangesSupport,
- } = await RedisManager.promises.getDoc(projectId, docId)
- // To avoid issues where the same docId appears with different paths,
- // we use the path from the resyncProjectStructure update. If we used
- // the path from the getDoc call to web then the two occurences of the
- // docId would map to the same path, and this would be rejected by
- // project-history as an unexpected resyncDocContent update.
- if (lines == null || version == null) {
- logger.debug(
- { projectId, docId },
- 'resyncing doc contents - not found in redis - retrieving from web'
- )
- ;({
- lines,
- ranges,
- resolvedCommentIds,
- version,
- projectHistoryId,
- historyRangesSupport,
- } = await PersistenceManager.promises.getDoc(projectId, docId, {
- peek: true,
- }))
- } else {
- logger.debug(
- { projectId, docId },
- 'resyncing doc contents - doc in redis - will queue in redis'
- )
- }
- await ProjectHistoryRedisManager.promises.queueResyncDocContent(
- projectId,
- projectHistoryId,
- docId,
- lines,
- ranges,
- resolvedCommentIds,
- version,
- // use the path from the resyncProjectStructure update
- path,
- historyRangesSupport
- )
- },
- async getDocWithLock(projectId, docId) {
- const UpdateManager = require('./UpdateManager')
- return await UpdateManager.promises.lockUpdatesAndDo(
- DocumentManager.getDoc,
- projectId,
- docId
- )
- },
- async getDocAndRecentOpsWithLock(projectId, docId, fromVersion) {
- const UpdateManager = require('./UpdateManager')
- return await UpdateManager.promises.lockUpdatesAndDo(
- DocumentManager.getDocAndRecentOps,
- projectId,
- docId,
- fromVersion
- )
- },
- async getDocAndFlushIfOldWithLock(projectId, docId) {
- const UpdateManager = require('./UpdateManager')
- return await UpdateManager.promises.lockUpdatesAndDo(
- DocumentManager.getDocAndFlushIfOld,
- projectId,
- docId
- )
- },
- async setDocWithLock(projectId, docId, lines, source, userId, undoing) {
- const UpdateManager = require('./UpdateManager')
- return await UpdateManager.promises.lockUpdatesAndDo(
- DocumentManager.setDoc,
- projectId,
- docId,
- lines,
- source,
- userId,
- undoing
- )
- },
- async flushDocIfLoadedWithLock(projectId, docId) {
- const UpdateManager = require('./UpdateManager')
- return await UpdateManager.promises.lockUpdatesAndDo(
- DocumentManager.flushDocIfLoaded,
- projectId,
- docId
- )
- },
- async flushAndDeleteDocWithLock(projectId, docId, options) {
- const UpdateManager = require('./UpdateManager')
- return await UpdateManager.promises.lockUpdatesAndDo(
- DocumentManager.flushAndDeleteDoc,
- projectId,
- docId,
- options
- )
- },
- async acceptChangesWithLock(projectId, docId, changeIds) {
- const UpdateManager = require('./UpdateManager')
- await UpdateManager.promises.lockUpdatesAndDo(
- DocumentManager.acceptChanges,
- projectId,
- docId,
- changeIds
- )
- },
- async updateCommentStateWithLock(
- projectId,
- docId,
- threadId,
- userId,
- resolved
- ) {
- const UpdateManager = require('./UpdateManager')
- await UpdateManager.promises.lockUpdatesAndDo(
- DocumentManager.updateCommentState,
- projectId,
- docId,
- threadId,
- userId,
- resolved
- )
- },
- async deleteCommentWithLock(projectId, docId, threadId, userId) {
- const UpdateManager = require('./UpdateManager')
- await UpdateManager.promises.lockUpdatesAndDo(
- DocumentManager.deleteComment,
- projectId,
- docId,
- threadId,
- userId
- )
- },
- async renameDocWithLock(projectId, docId, userId, update, projectHistoryId) {
- const UpdateManager = require('./UpdateManager')
- await UpdateManager.promises.lockUpdatesAndDo(
- DocumentManager.renameDoc,
- projectId,
- docId,
- userId,
- update,
- projectHistoryId
- )
- },
- async resyncDocContentsWithLock(projectId, docId, path, callback) {
- const UpdateManager = require('./UpdateManager')
- await UpdateManager.promises.lockUpdatesAndDo(
- DocumentManager.resyncDocContents,
- projectId,
- docId,
- path,
- callback
- )
- },
- }
- module.exports = {
- ...callbackifyAll(DocumentManager, {
- multiResult: {
- getDoc: [
- 'lines',
- 'version',
- 'ranges',
- 'pathname',
- 'projectHistoryId',
- 'unflushedTime',
- 'alreadyLoaded',
- 'historyRangesSupport',
- ],
- getDocWithLock: [
- 'lines',
- 'version',
- 'ranges',
- 'pathname',
- 'projectHistoryId',
- 'unflushedTime',
- 'alreadyLoaded',
- 'historyRangesSupport',
- ],
- getDocAndFlushIfOld: ['lines', 'version'],
- getDocAndFlushIfOldWithLock: ['lines', 'version'],
- getDocAndRecentOps: [
- 'lines',
- 'version',
- 'ops',
- 'ranges',
- 'pathname',
- 'projectHistoryId',
- ],
- getDocAndRecentOpsWithLock: [
- 'lines',
- 'version',
- 'ops',
- 'ranges',
- 'pathname',
- 'projectHistoryId',
- ],
- },
- }),
- promises: DocumentManager,
- }
|