DocManager.js 8.6 KB

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