DocManager.js 9.9 KB

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