MongoManager.js 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. import mongodb from './mongodb.js'
  2. import Settings from '@overleaf/settings'
  3. import Errors from './Errors.js'
  4. import Metrics from '@overleaf/metrics'
  5. import logger from '@overleaf/logger'
  6. import _ from 'lodash'
  7. const { db, ObjectId, BSON } = mongodb
  8. const ARCHIVING_LOCK_DURATION_MS = Settings.archivingLockDurationMs
  9. async function findDoc(projectId, docId, projection) {
  10. const doc = await db.docs.findOne(
  11. {
  12. _id: new ObjectId(docId.toString()),
  13. project_id: new ObjectId(projectId.toString()),
  14. },
  15. { projection }
  16. )
  17. if (doc && projection.version && !doc.version) {
  18. doc.version = 0
  19. }
  20. return doc
  21. }
  22. async function getProjectsDeletedDocs(projectId, projection) {
  23. const docs = await db.docs
  24. .find(
  25. {
  26. project_id: new ObjectId(projectId.toString()),
  27. deleted: true,
  28. },
  29. {
  30. projection,
  31. sort: { deletedAt: -1 },
  32. limit: Settings.max_deleted_docs,
  33. }
  34. )
  35. .toArray()
  36. return docs
  37. }
  38. async function getProjectsDocs(projectId, options, projection) {
  39. const query = { project_id: new ObjectId(projectId.toString()) }
  40. if (!options.include_deleted) {
  41. query.deleted = { $ne: true }
  42. }
  43. const queryOptions = {
  44. projection,
  45. }
  46. if (options.limit) {
  47. queryOptions.limit = options.limit
  48. }
  49. const docs = await db.docs.find(query, queryOptions).toArray()
  50. return docs
  51. }
  52. async function getArchivedProjectDocs(projectId, maxResults) {
  53. const query = {
  54. project_id: new ObjectId(projectId.toString()),
  55. inS3: true,
  56. }
  57. const docs = await db.docs
  58. .find(query, { projection: { _id: 1 }, limit: maxResults })
  59. .toArray()
  60. return docs
  61. }
  62. async function getNonArchivedProjectDocIds(projectId) {
  63. const docs = await db.docs
  64. .find(
  65. {
  66. project_id: new ObjectId(projectId),
  67. inS3: { $ne: true },
  68. },
  69. { projection: { _id: 1 } }
  70. )
  71. .map(doc => doc._id)
  72. .toArray()
  73. return docs
  74. }
  75. async function getNonDeletedArchivedProjectDocs(projectId, maxResults) {
  76. const query = {
  77. project_id: new ObjectId(projectId.toString()),
  78. deleted: { $ne: true },
  79. inS3: true,
  80. }
  81. const docs = await db.docs
  82. .find(query, { projection: { _id: 1 }, limit: maxResults })
  83. .toArray()
  84. return docs
  85. }
  86. function convertUpdateToPipeline(update) {
  87. const pipeline = []
  88. for (const [operation, ops] of Object.entries(update)) {
  89. for (const [field, value] of Object.entries(ops)) {
  90. if (operation === '$unset') {
  91. // $unset uses a different schema in a pipeline
  92. pipeline.push({ [operation]: field })
  93. } else {
  94. pipeline.push({ [operation]: { [field]: value } })
  95. }
  96. }
  97. }
  98. return pipeline
  99. }
  100. async function upsertIntoDocCollection(projectId, docId, previousRev, updates) {
  101. if (previousRev) {
  102. const update = {
  103. $set: updates,
  104. $unset: { inS3: true },
  105. }
  106. if (updates.lines || updates.ranges) {
  107. update.$set.rev = previousRev + 1
  108. }
  109. const pipeline = convertUpdateToPipeline(update)
  110. const payloadSize = BSON.calculateObjectSize(pipeline)
  111. Metrics.count('mongo_docs_write', payloadSize, 1, { method: 'update' })
  112. const result = await db.docs.findOneAndUpdate(
  113. {
  114. _id: new ObjectId(docId),
  115. project_id: new ObjectId(projectId),
  116. rev: previousRev,
  117. },
  118. pipeline,
  119. { returnDocument: 'after' }
  120. )
  121. if (!result) {
  122. throw new Errors.DocRevValueError()
  123. }
  124. let fallbackUpdate = false
  125. if (updates.lines && !_.isEqual(updates.lines, result.lines)) {
  126. logger.warn({ projectId, docId }, 'lines are different after pipeline')
  127. fallbackUpdate = true
  128. }
  129. if (updates.ranges && !_.isEqual(updates.ranges, result.ranges)) {
  130. logger.warn({ projectId, docId }, 'ranges are different after pipeline')
  131. fallbackUpdate = true
  132. }
  133. if (fallbackUpdate) {
  134. update.$set.rev = previousRev + 2
  135. const result = await db.docs.updateOne(
  136. {
  137. _id: new ObjectId(docId),
  138. project_id: new ObjectId(projectId),
  139. rev: previousRev + 1,
  140. },
  141. update
  142. )
  143. if (result.matchedCount !== 1) {
  144. throw new Errors.DocRevValueError()
  145. }
  146. }
  147. } else {
  148. const payloadSize = BSON.calculateObjectSize(updates)
  149. Metrics.count('mongo_docs_write', payloadSize, 1, { method: 'insert' })
  150. try {
  151. await db.docs.insertOne({
  152. _id: new ObjectId(docId),
  153. project_id: new ObjectId(projectId),
  154. rev: 1,
  155. ...updates,
  156. })
  157. } catch (err) {
  158. if (err.code === 11000) {
  159. // duplicate doc _id
  160. throw new Errors.DocRevValueError()
  161. } else {
  162. throw err
  163. }
  164. }
  165. }
  166. }
  167. async function patchDoc(projectId, docId, meta) {
  168. const payloadSize = BSON.calculateObjectSize(meta)
  169. Metrics.count('mongo_docs_write', payloadSize, 1, { method: 'patch' })
  170. await db.docs.updateOne(
  171. {
  172. _id: new ObjectId(docId),
  173. project_id: new ObjectId(projectId),
  174. },
  175. { $set: meta }
  176. )
  177. }
  178. /**
  179. * Fetch a doc and lock it for archiving
  180. *
  181. * This will return null if the doc is not found, if it's already archived or
  182. * if the lock can't be acquired.
  183. */
  184. async function getDocForArchiving(projectId, docId) {
  185. const archivingUntil = new Date(Date.now() + ARCHIVING_LOCK_DURATION_MS)
  186. const result = await db.docs.findOneAndUpdate(
  187. {
  188. _id: new ObjectId(docId),
  189. project_id: new ObjectId(projectId),
  190. inS3: { $ne: true },
  191. $or: [{ archivingUntil: null }, { archivingUntil: { $lt: new Date() } }],
  192. },
  193. { $set: { archivingUntil } },
  194. {
  195. projection: { lines: 1, ranges: 1, rev: 1 },
  196. includeResultMetadata: true,
  197. }
  198. )
  199. return result.value
  200. }
  201. /**
  202. * Clear the doc contents from Mongo and release the archiving lock
  203. */
  204. async function markDocAsArchived(projectId, docId, rev) {
  205. await db.docs.updateOne(
  206. { _id: new ObjectId(docId), rev },
  207. {
  208. $set: { inS3: true },
  209. $unset: { lines: 1, ranges: 1, archivingUntil: 1 },
  210. }
  211. )
  212. }
  213. /**
  214. * Restore an archived doc
  215. *
  216. * This checks that the archived doc's rev matches.
  217. */
  218. async function restoreArchivedDoc(projectId, docId, archivedDoc) {
  219. const query = {
  220. _id: new ObjectId(docId),
  221. project_id: new ObjectId(projectId),
  222. rev: archivedDoc.rev,
  223. }
  224. const update = {
  225. $set: {
  226. lines: archivedDoc.lines,
  227. ranges: archivedDoc.ranges || {},
  228. },
  229. $unset: {
  230. inS3: true,
  231. },
  232. }
  233. const pipeline = convertUpdateToPipeline(update)
  234. const payloadSize = BSON.calculateObjectSize(pipeline)
  235. Metrics.count('mongo_docs_write', payloadSize, 1, { method: 'restore' })
  236. const result = await db.docs.findOneAndUpdate(query, pipeline, {
  237. returnDocument: 'after',
  238. })
  239. if (!result) {
  240. throw new Errors.DocRevValueError('failed to unarchive doc', {
  241. docId,
  242. rev: archivedDoc.rev,
  243. })
  244. }
  245. let fallbackUpdate = false
  246. if (!_.isEqual(update.$set.lines, result.lines)) {
  247. logger.warn(
  248. { projectId, docId },
  249. 'lines are different after pipeline when unarchiving'
  250. )
  251. fallbackUpdate = true
  252. }
  253. if (!_.isEqual(update.$set.ranges, result.ranges)) {
  254. logger.warn(
  255. { projectId, docId },
  256. 'ranges are different after pipeline when unarchiving'
  257. )
  258. fallbackUpdate = true
  259. }
  260. if (fallbackUpdate) {
  261. const result = await db.docs.updateOne(query, update)
  262. if (result.matchedCount === 0) {
  263. throw new Errors.DocRevValueError('failed to unarchive doc', {
  264. docId,
  265. rev: archivedDoc.rev,
  266. })
  267. }
  268. }
  269. }
  270. async function getDocRev(docId) {
  271. const doc = await db.docs.findOne(
  272. { _id: new ObjectId(docId.toString()) },
  273. { projection: { rev: 1 } }
  274. )
  275. return doc && doc.rev
  276. }
  277. /**
  278. * Helper method to support optimistic locking.
  279. *
  280. * Check that the rev of an existing doc is unchanged. If the rev has
  281. * changed, return a DocModifiedError.
  282. */
  283. async function checkRevUnchanged(doc) {
  284. const currentRev = await getDocRev(doc._id)
  285. if (isNaN(currentRev) || isNaN(doc.rev)) {
  286. throw new Errors.DocRevValueError('doc rev is NaN', {
  287. doc_id: doc._id,
  288. rev: doc.rev,
  289. currentRev,
  290. })
  291. }
  292. if (doc.rev !== currentRev) {
  293. throw new Errors.DocModifiedError('doc rev has changed', {
  294. doc_id: doc._id,
  295. rev: doc.rev,
  296. currentRev,
  297. })
  298. }
  299. }
  300. async function destroyProject(projectId) {
  301. await db.docs.deleteMany({ project_id: new ObjectId(projectId) })
  302. }
  303. export default {
  304. convertUpdateToPipeline,
  305. findDoc,
  306. getProjectsDeletedDocs,
  307. getProjectsDocs,
  308. getArchivedProjectDocs,
  309. getNonArchivedProjectDocIds,
  310. getNonDeletedArchivedProjectDocs,
  311. upsertIntoDocCollection,
  312. restoreArchivedDoc,
  313. patchDoc,
  314. getDocForArchiving,
  315. markDocAsArchived,
  316. checkRevUnchanged,
  317. destroyProject,
  318. }