DocManager.js 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. const MongoManager = require('./MongoManager')
  2. const Errors = require('./Errors')
  3. const logger = require('@overleaf/logger')
  4. const _ = require('lodash')
  5. const DocArchive = require('./DocArchiveManager')
  6. const RangeManager = require('./RangeManager')
  7. const Settings = require('@overleaf/settings')
  8. const { setTimeout } = require('node:timers/promises')
  9. /**
  10. * @import { Document } from 'mongodb'
  11. * @import { WithId } from 'mongodb'
  12. */
  13. const DocManager = {
  14. /**
  15. * @param {string} projectId
  16. * @param {string} docId
  17. * @param {{inS3: boolean}} filter
  18. * @returns {Promise<WithId<Document>>}
  19. * @private
  20. */
  21. async _getDoc(projectId, docId, filter) {
  22. if (filter == null) {
  23. filter = {}
  24. }
  25. if (filter.inS3 !== true) {
  26. throw new Error('must include inS3 when getting doc')
  27. }
  28. const doc = await MongoManager.findDoc(projectId, docId, filter)
  29. if (doc == null) {
  30. throw new Errors.NotFoundError(
  31. `No such doc: ${docId} in project ${projectId}`
  32. )
  33. }
  34. if (doc.inS3) {
  35. await DocArchive.unarchiveDoc(projectId, docId)
  36. return await DocManager._getDoc(projectId, docId, filter)
  37. }
  38. return doc
  39. },
  40. async isDocDeleted(projectId, docId) {
  41. const doc = await MongoManager.findDoc(projectId, docId, {
  42. deleted: true,
  43. })
  44. if (!doc) {
  45. throw new Errors.NotFoundError(
  46. `No such project/doc: ${projectId}/${docId}`
  47. )
  48. }
  49. // `doc.deleted` is `undefined` for non deleted docs
  50. return Boolean(doc.deleted)
  51. },
  52. async getFullDoc(projectId, docId) {
  53. const doc = await DocManager._getDoc(projectId, docId, {
  54. lines: true,
  55. rev: true,
  56. deleted: true,
  57. version: true,
  58. ranges: true,
  59. inS3: true,
  60. })
  61. return doc
  62. },
  63. // returns the doc without any version information
  64. async _peekRawDoc(projectId, docId) {
  65. const doc = await MongoManager.findDoc(projectId, docId, {
  66. lines: true,
  67. rev: true,
  68. deleted: true,
  69. version: true,
  70. ranges: true,
  71. inS3: true,
  72. })
  73. if (doc == null) {
  74. throw new Errors.NotFoundError(
  75. `No such doc: ${docId} in project ${projectId}`
  76. )
  77. }
  78. if (doc.inS3) {
  79. // skip the unarchiving to mongo when getting a doc
  80. const archivedDoc = await DocArchive.getDoc(projectId, docId)
  81. Object.assign(doc, archivedDoc)
  82. }
  83. return doc
  84. },
  85. // get the doc from mongo if possible, or from the persistent store otherwise,
  86. // without unarchiving it (avoids unnecessary writes to mongo)
  87. async peekDoc(projectId, docId) {
  88. const doc = await DocManager._peekRawDoc(projectId, docId)
  89. await MongoManager.checkRevUnchanged(doc)
  90. return doc
  91. },
  92. async getDocLines(projectId, docId) {
  93. const doc = await DocManager._getDoc(projectId, docId, {
  94. lines: true,
  95. inS3: true,
  96. })
  97. if (!doc) throw new Errors.NotFoundError()
  98. if (!Array.isArray(doc.lines)) throw new Errors.DocWithoutLinesError()
  99. return doc.lines.join('\n')
  100. },
  101. async getAllDeletedDocs(projectId, filter) {
  102. return await MongoManager.getProjectsDeletedDocs(projectId, filter)
  103. },
  104. async getAllNonDeletedDocs(projectId, filter) {
  105. await DocArchive.unArchiveAllDocs(projectId)
  106. const docs = await MongoManager.getProjectsDocs(
  107. projectId,
  108. { include_deleted: false },
  109. filter
  110. )
  111. if (docs == null) {
  112. throw new Errors.NotFoundError(`No docs for project ${projectId}`)
  113. }
  114. return docs
  115. },
  116. async getCommentThreadIds(projectId) {
  117. const docs = await DocManager.getAllNonDeletedDocs(projectId, {
  118. _id: true,
  119. ranges: true,
  120. })
  121. const byDoc = new Map()
  122. for (const doc of docs) {
  123. const ids = new Set()
  124. for (const comment of doc.ranges?.comments || []) {
  125. ids.add(comment.op.t)
  126. }
  127. if (ids.size > 0) byDoc.set(doc._id.toString(), Array.from(ids))
  128. }
  129. return Object.fromEntries(byDoc.entries())
  130. },
  131. async getTrackedChangesUserIds(projectId) {
  132. const docs = await DocManager.getAllNonDeletedDocs(projectId, {
  133. ranges: true,
  134. })
  135. const userIds = new Set()
  136. for (const doc of docs) {
  137. for (const change of doc.ranges?.changes || []) {
  138. if (change.metadata.user_id === 'anonymous-user') continue
  139. userIds.add(change.metadata.user_id)
  140. }
  141. }
  142. return Array.from(userIds)
  143. },
  144. async projectHasRanges(projectId) {
  145. const docs = await MongoManager.getProjectsDocs(projectId, {}, { _id: 1 })
  146. const docIds = docs.map(doc => doc._id)
  147. for (const docId of docIds) {
  148. const doc = await DocManager.peekDoc(projectId, docId)
  149. if (
  150. (doc.ranges?.comments != null && doc.ranges.comments.length > 0) ||
  151. (doc.ranges?.changes != null && doc.ranges.changes.length > 0)
  152. ) {
  153. return true
  154. }
  155. }
  156. return false
  157. },
  158. async updateDoc(projectId, docId, lines, version, ranges) {
  159. const MAX_ATTEMPTS = 2
  160. for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
  161. try {
  162. const { modified, rev } = await DocManager._tryUpdateDoc(
  163. projectId,
  164. docId,
  165. lines,
  166. version,
  167. ranges
  168. )
  169. return { modified, rev }
  170. } catch (err) {
  171. if (err instanceof Errors.DocRevValueError && attempt < MAX_ATTEMPTS) {
  172. // Another updateDoc call was racing with ours.
  173. // Retry once in a bit.
  174. logger.warn(
  175. { projectId, docId, err },
  176. 'detected concurrent updateDoc call'
  177. )
  178. await setTimeout(100 + Math.random() * 100)
  179. continue
  180. } else {
  181. throw err
  182. }
  183. }
  184. }
  185. },
  186. async _tryUpdateDoc(projectId, docId, lines, version, ranges) {
  187. if (lines == null || version == null || ranges == null) {
  188. throw new Error('no lines, version or ranges provided')
  189. }
  190. let doc
  191. try {
  192. doc = await DocManager._getDoc(projectId, docId, {
  193. version: true,
  194. rev: true,
  195. lines: true,
  196. ranges: true,
  197. inS3: true,
  198. })
  199. } catch (err) {
  200. if (err instanceof Errors.NotFoundError) {
  201. doc = null
  202. } else {
  203. throw err
  204. }
  205. }
  206. ranges = RangeManager.jsonRangesToMongo(ranges)
  207. let updateLines, updateRanges, updateVersion
  208. if (doc == null) {
  209. // If the document doesn't exist, we'll make sure to create/update all parts of it.
  210. updateLines = true
  211. updateVersion = true
  212. updateRanges = true
  213. } else {
  214. if (doc.version > version) {
  215. // Reject update when the version was decremented.
  216. // Potential reasons: racing flush, broken history.
  217. throw new Errors.DocVersionDecrementedError('rejecting stale update', {
  218. updateVersion: version,
  219. flushedVersion: doc.version,
  220. })
  221. }
  222. updateLines = !_.isEqual(doc.lines, lines)
  223. updateVersion = doc.version !== version
  224. updateRanges = RangeManager.shouldUpdateRanges(doc.ranges, ranges)
  225. }
  226. let modified = false
  227. let rev = doc?.rev || 0
  228. if (updateLines || updateRanges || updateVersion) {
  229. const update = {}
  230. if (updateLines) {
  231. update.lines = lines
  232. }
  233. if (updateRanges) {
  234. update.ranges = ranges
  235. }
  236. if (updateVersion) {
  237. update.version = version
  238. }
  239. logger.debug(
  240. { projectId, docId, oldVersion: doc?.version, newVersion: version },
  241. 'updating doc'
  242. )
  243. if (updateLines || updateRanges) {
  244. rev += 1 // rev will be incremented in mongo by MongoManager.upsertIntoDocCollection
  245. }
  246. modified = true
  247. await MongoManager.upsertIntoDocCollection(
  248. projectId,
  249. docId,
  250. doc?.rev,
  251. update
  252. )
  253. } else {
  254. logger.debug({ projectId, docId }, 'doc has not changed - not updating')
  255. }
  256. return { modified, rev }
  257. },
  258. async patchDoc(projectId, docId, meta) {
  259. const projection = { _id: 1, deleted: true }
  260. const doc = await MongoManager.findDoc(projectId, docId, projection)
  261. if (!doc) {
  262. throw new Errors.NotFoundError(
  263. `No such project/doc to delete: ${projectId}/${docId}`
  264. )
  265. }
  266. if (meta.deleted && Settings.docstore.archiveOnSoftDelete) {
  267. // The user will not read this doc anytime soon. Flush it out of mongo.
  268. DocArchive.archiveDoc(projectId, docId).catch(err => {
  269. logger.warn(
  270. { projectId, docId, err },
  271. 'archiving a single doc in the background failed'
  272. )
  273. })
  274. }
  275. await MongoManager.patchDoc(projectId, docId, meta)
  276. },
  277. }
  278. module.exports = DocManager