mongo.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  1. // @ts-check
  2. const { ObjectId, ReadPreference, MongoError } = require('mongodb')
  3. const { Chunk } = require('overleaf-editor-core')
  4. const OError = require('@overleaf/o-error')
  5. const config = require('config')
  6. const assert = require('../assert')
  7. const mongodb = require('../mongodb')
  8. const { ChunkVersionConflictError } = require('./errors')
  9. const DUPLICATE_KEY_ERROR_CODE = 11000
  10. /**
  11. * @import { ClientSession } from 'mongodb'
  12. */
  13. /**
  14. * Get the latest chunk's metadata from the database
  15. * @param {string} projectId
  16. * @param {Object} [opts]
  17. * @param {boolean} [opts.readOnly]
  18. */
  19. async function getLatestChunk(projectId, opts = {}) {
  20. assert.mongoId(projectId, 'bad projectId')
  21. const { readOnly = false } = opts
  22. const record = await mongodb.chunks.findOne(
  23. {
  24. projectId: new ObjectId(projectId),
  25. state: { $in: ['active', 'closed'] },
  26. },
  27. {
  28. sort: { startVersion: -1 },
  29. readPreference: readOnly
  30. ? ReadPreference.secondaryPreferred
  31. : ReadPreference.primary,
  32. }
  33. )
  34. if (record == null) {
  35. return null
  36. }
  37. return chunkFromRecord(record)
  38. }
  39. /**
  40. * Get the metadata for the chunk that contains the given version.
  41. *
  42. * @param {string} projectId
  43. * @param {number} version
  44. * @param {object} [opts]
  45. * @param {boolean} [opts.preferNewer] - If the version is at the boundary of
  46. * two chunks, return the newer chunk.
  47. */
  48. async function getChunkForVersion(projectId, version, opts = {}) {
  49. assert.mongoId(projectId, 'bad projectId')
  50. assert.integer(version, 'bad version')
  51. const record = await mongodb.chunks.findOne(
  52. {
  53. projectId: new ObjectId(projectId),
  54. state: { $in: ['active', 'closed'] },
  55. startVersion: { $lte: version },
  56. endVersion: { $gte: version },
  57. },
  58. { sort: { startVersion: opts.preferNewer ? -1 : 1 } }
  59. )
  60. if (record == null) {
  61. throw new Chunk.VersionNotFoundError(projectId, version)
  62. }
  63. return chunkFromRecord(record)
  64. }
  65. /**
  66. * Get the metadata for the chunk that contains the version that was current at
  67. * the given timestamp.
  68. */
  69. async function getChunkForTimestamp(projectId, timestamp) {
  70. assert.mongoId(projectId, 'bad projectId')
  71. assert.date(timestamp, 'bad timestamp')
  72. const record = await mongodb.chunks.findOne(
  73. {
  74. projectId: new ObjectId(projectId),
  75. state: { $in: ['active', 'closed'] },
  76. endTimestamp: { $gte: timestamp },
  77. },
  78. // We use the index on the startVersion for sorting records. This assumes
  79. // that timestamps go up with each version.
  80. { sort: { startVersion: 1 } }
  81. )
  82. if (record == null) {
  83. // Couldn't find a chunk that had modifications after the given timestamp.
  84. // Fetch the latest chunk instead.
  85. const chunk = await getLatestChunk(projectId)
  86. if (chunk == null) {
  87. throw new Chunk.BeforeTimestampNotFoundError(projectId, timestamp)
  88. }
  89. return chunk
  90. }
  91. return chunkFromRecord(record)
  92. }
  93. /**
  94. * Get all of a project's chunk ids
  95. */
  96. async function getProjectChunkIds(projectId) {
  97. assert.mongoId(projectId, 'bad projectId')
  98. const cursor = mongodb.chunks.find(
  99. {
  100. projectId: new ObjectId(projectId),
  101. state: { $in: ['active', 'closed'] },
  102. },
  103. { projection: { _id: 1 } }
  104. )
  105. return await cursor.map(record => record._id).toArray()
  106. }
  107. /**
  108. * Get all of a projects chunks directly
  109. */
  110. async function getProjectChunks(projectId) {
  111. assert.mongoId(projectId, 'bad projectId')
  112. const cursor = mongodb.chunks
  113. .find(
  114. {
  115. projectId: new ObjectId(projectId),
  116. state: { $in: ['active', 'closed'] },
  117. },
  118. { projection: { state: 0 } }
  119. )
  120. .sort({ startVersion: 1 })
  121. return await cursor.map(chunkFromRecord).toArray()
  122. }
  123. /**
  124. * Copy the data structures for a given project.
  125. * @param {string} sourceProjectId
  126. * @param {string} targetProjectId
  127. */
  128. async function clone(sourceProjectId, targetProjectId) {
  129. assert.mongoId(targetProjectId, 'bad target projectId')
  130. assert.mongoId(sourceProjectId, 'bad source projectId')
  131. const cursor = mongodb.chunks.find(
  132. {
  133. projectId: new ObjectId(sourceProjectId),
  134. state: { $in: ['active', 'closed'] },
  135. },
  136. { projection: { projectId: 0 } }
  137. )
  138. const chunkIds = new Map()
  139. const batch = []
  140. async function flushBatch() {
  141. await mongodb.chunks.insertMany(batch)
  142. batch.length = 0
  143. }
  144. for await (const chunk of cursor) {
  145. const newChunkId = new ObjectId()
  146. chunkIds.set(chunk._id.toString(), newChunkId.toString())
  147. batch.push({
  148. ...chunk,
  149. _id: newChunkId,
  150. projectId: new ObjectId(targetProjectId),
  151. })
  152. if (batch.length > 100) await flushBatch()
  153. }
  154. if (batch.length > 0) await flushBatch()
  155. return chunkIds
  156. }
  157. /**
  158. * Insert a pending chunk before sending it to object storage.
  159. */
  160. async function insertPendingChunk(projectId, chunk) {
  161. assert.mongoId(projectId, 'bad projectId')
  162. assert.instance(chunk, Chunk, 'bad chunk')
  163. const chunkId = new ObjectId()
  164. await mongodb.chunks.insertOne({
  165. _id: chunkId,
  166. projectId: new ObjectId(projectId),
  167. startVersion: chunk.getStartVersion(),
  168. endVersion: chunk.getEndVersion(),
  169. endTimestamp: chunk.getEndTimestamp(),
  170. state: 'pending',
  171. updatedAt: new Date(),
  172. })
  173. return chunkId.toString()
  174. }
  175. /**
  176. * Record that a new chunk was created.
  177. *
  178. * @param {string} projectId
  179. * @param {Chunk} chunk
  180. * @param {string} chunkId
  181. * @param {object} opts
  182. * @param {Date} [opts.earliestChangeTimestamp]
  183. * @param {string} [opts.oldChunkId]
  184. */
  185. async function confirmCreate(projectId, chunk, chunkId, opts = {}) {
  186. assert.mongoId(projectId, 'bad projectId')
  187. assert.instance(chunk, Chunk, 'bad newChunk')
  188. assert.mongoId(chunkId, 'bad newChunkId')
  189. await mongodb.client.withSession(async session => {
  190. await session.withTransaction(async () => {
  191. if (opts.oldChunkId != null) {
  192. await closeChunk(projectId, opts.oldChunkId, { session })
  193. }
  194. await activateChunk(projectId, chunkId, { session })
  195. await updateProjectRecord(
  196. projectId,
  197. chunk,
  198. opts.earliestChangeTimestamp,
  199. { session }
  200. )
  201. })
  202. })
  203. }
  204. /**
  205. * Write the metadata to the project record
  206. */
  207. async function updateProjectRecord(
  208. projectId,
  209. chunk,
  210. earliestChangeTimestamp,
  211. mongoOpts = {}
  212. ) {
  213. if (!config.has('backupStore')) {
  214. return
  215. }
  216. // record the end version against the project
  217. await mongodb.projects.updateOne(
  218. {
  219. 'overleaf.history.id': projectId, // string for Object ids, number for postgres ids
  220. },
  221. {
  222. // always store the latest end version and timestamp for the chunk
  223. $max: {
  224. 'overleaf.history.currentEndVersion': chunk.getEndVersion(),
  225. 'overleaf.history.currentEndTimestamp': chunk.getEndTimestamp(),
  226. 'overleaf.history.updatedAt': new Date(),
  227. },
  228. // store the first pending change timestamp for the chunk, this will
  229. // be cleared every time a backup is completed.
  230. $min: {
  231. 'overleaf.backup.pendingChangeAt':
  232. earliestChangeTimestamp || chunk.getEndTimestamp() || new Date(),
  233. },
  234. },
  235. mongoOpts
  236. )
  237. }
  238. /**
  239. * @param {number} historyId
  240. * @return {Promise<string>}
  241. */
  242. async function lookupMongoProjectIdFromHistoryId(historyId) {
  243. const project = await mongodb.projects.findOne(
  244. // string for Object ids, number for postgres ids
  245. { 'overleaf.history.id': historyId },
  246. { projection: { _id: 1 } }
  247. )
  248. if (!project) {
  249. // should not happen: We flush before allowing a project to be soft-deleted.
  250. throw new OError('mongo project not found by history id', { historyId })
  251. }
  252. return project._id.toString()
  253. }
  254. async function resolveHistoryIdToMongoProjectId(projectId) {
  255. return projectId
  256. }
  257. /**
  258. * Record that a chunk was replaced by a new one.
  259. *
  260. * @param {string} projectId
  261. * @param {string} oldChunkId
  262. * @param {Chunk} newChunk
  263. * @param {string} newChunkId
  264. * @param {object} [opts]
  265. * @param {Date} [opts.earliestChangeTimestamp]
  266. */
  267. async function confirmUpdate(
  268. projectId,
  269. oldChunkId,
  270. newChunk,
  271. newChunkId,
  272. opts = {}
  273. ) {
  274. assert.mongoId(projectId, 'bad projectId')
  275. assert.mongoId(oldChunkId, 'bad oldChunkId')
  276. assert.instance(newChunk, Chunk, 'bad newChunk')
  277. assert.mongoId(newChunkId, 'bad newChunkId')
  278. await mongodb.client.withSession(async session => {
  279. await session.withTransaction(async () => {
  280. await deleteActiveChunk(projectId, oldChunkId, { session })
  281. await activateChunk(projectId, newChunkId, { session })
  282. await updateProjectRecord(
  283. projectId,
  284. newChunk,
  285. opts.earliestChangeTimestamp,
  286. { session }
  287. )
  288. })
  289. })
  290. }
  291. /**
  292. * Activate a pending chunk
  293. *
  294. * @param {string} projectId
  295. * @param {string} chunkId
  296. * @param {object} [opts]
  297. * @param {ClientSession} [opts.session]
  298. */
  299. async function activateChunk(projectId, chunkId, opts = {}) {
  300. assert.mongoId(projectId, 'bad projectId')
  301. assert.mongoId(chunkId, 'bad chunkId')
  302. let result
  303. try {
  304. result = await mongodb.chunks.updateOne(
  305. {
  306. _id: new ObjectId(chunkId),
  307. projectId: new ObjectId(projectId),
  308. state: 'pending',
  309. },
  310. { $set: { state: 'active', updatedAt: new Date() } },
  311. opts
  312. )
  313. } catch (err) {
  314. if (err instanceof MongoError && err.code === DUPLICATE_KEY_ERROR_CODE) {
  315. throw new ChunkVersionConflictError('chunk start version is not unique', {
  316. projectId,
  317. chunkId,
  318. })
  319. } else {
  320. throw err
  321. }
  322. }
  323. if (result.matchedCount === 0) {
  324. throw new OError('pending chunk not found', { projectId, chunkId })
  325. }
  326. }
  327. /**
  328. * Close a chunk
  329. *
  330. * A closed chunk is one that can't be extended anymore.
  331. *
  332. * @param {string} projectId
  333. * @param {string} chunkId
  334. * @param {object} [opts]
  335. * @param {ClientSession} [opts.session]
  336. */
  337. async function closeChunk(projectId, chunkId, opts = {}) {
  338. const result = await mongodb.chunks.updateOne(
  339. {
  340. _id: new ObjectId(chunkId),
  341. projectId: new ObjectId(projectId),
  342. state: 'active',
  343. },
  344. { $set: { state: 'closed' } },
  345. opts
  346. )
  347. if (result.matchedCount === 0) {
  348. throw new ChunkVersionConflictError('unable to close chunk', {
  349. projectId,
  350. chunkId,
  351. })
  352. }
  353. }
  354. /**
  355. * Delete an active chunk
  356. *
  357. * This is used to delete chunks that are in the process of being extended. It
  358. * will refuse to delete chunks that are already closed and can therefore not be
  359. * extended.
  360. *
  361. * @param {string} projectId
  362. * @param {string} chunkId
  363. * @param {object} [opts]
  364. * @param {ClientSession} [opts.session]
  365. */
  366. async function deleteActiveChunk(projectId, chunkId, opts = {}) {
  367. const updateResult = await mongodb.chunks.updateOne(
  368. {
  369. _id: new ObjectId(chunkId),
  370. projectId: new ObjectId(projectId),
  371. state: 'active',
  372. },
  373. { $set: { state: 'deleted', updatedAt: new Date() } },
  374. opts
  375. )
  376. if (updateResult.matchedCount === 0) {
  377. throw new ChunkVersionConflictError('unable to delete active chunk', {
  378. projectId,
  379. chunkId,
  380. })
  381. }
  382. }
  383. /**
  384. * Delete a chunk.
  385. *
  386. * @param {string} projectId
  387. * @param {string} chunkId
  388. * @return {Promise}
  389. */
  390. async function deleteChunk(projectId, chunkId, mongoOpts = {}) {
  391. assert.mongoId(projectId, 'bad projectId')
  392. assert.mongoId(chunkId, 'bad chunkId')
  393. await mongodb.chunks.updateOne(
  394. { _id: new ObjectId(chunkId), projectId: new ObjectId(projectId) },
  395. { $set: { state: 'deleted', updatedAt: new Date() } },
  396. mongoOpts
  397. )
  398. }
  399. /**
  400. * Delete all of a project's chunks
  401. */
  402. async function deleteProjectChunks(projectId) {
  403. assert.mongoId(projectId, 'bad projectId')
  404. await mongodb.chunks.updateMany(
  405. {
  406. projectId: new ObjectId(projectId),
  407. state: { $in: ['active', 'closed'] },
  408. },
  409. { $set: { state: 'deleted', updatedAt: new Date() } }
  410. )
  411. }
  412. /**
  413. * Get a batch of old chunks for deletion
  414. */
  415. async function getOldChunksBatch(count, minAgeSecs) {
  416. const maxUpdatedAt = new Date(Date.now() - minAgeSecs * 1000)
  417. const batch = []
  418. // We need to fetch one state at a time to take advantage of the partial
  419. // indexes on the chunks collection.
  420. //
  421. // Mongo 6.0 allows partial indexes that use the $in operator. When we reach
  422. // that Mongo version, we can create a partial index on both the deleted and
  423. // pending states and simplify this logic a bit.
  424. for (const state of ['deleted', 'pending']) {
  425. if (count === 0) {
  426. // There's no more space in the batch
  427. break
  428. }
  429. const cursor = mongodb.chunks
  430. .find(
  431. { state, updatedAt: { $lt: maxUpdatedAt } },
  432. {
  433. limit: count,
  434. projection: { _id: 1, projectId: 1 },
  435. }
  436. )
  437. .map(record => ({
  438. chunkId: record._id.toString(),
  439. projectId: record.projectId.toString(),
  440. }))
  441. for await (const record of cursor) {
  442. batch.push(record)
  443. count -= 1
  444. }
  445. }
  446. return batch
  447. }
  448. /**
  449. * Delete a batch of old chunks from the database
  450. */
  451. async function deleteOldChunks(chunkIds) {
  452. await mongodb.chunks.deleteMany({
  453. _id: { $in: chunkIds.map(id => new ObjectId(id)) },
  454. state: { $in: ['deleted', 'pending'] },
  455. })
  456. }
  457. /**
  458. * Build a chunk metadata object from the database record
  459. */
  460. function chunkFromRecord(record) {
  461. return {
  462. id: record._id.toString(),
  463. startVersion: record.startVersion,
  464. endVersion: record.endVersion,
  465. endTimestamp: record.endTimestamp,
  466. }
  467. }
  468. module.exports = {
  469. clone,
  470. getLatestChunk,
  471. getChunkForVersion,
  472. getChunkForTimestamp,
  473. getProjectChunkIds,
  474. getProjectChunks,
  475. insertPendingChunk,
  476. confirmCreate,
  477. confirmUpdate,
  478. updateProjectRecord,
  479. deleteChunk,
  480. deleteProjectChunks,
  481. getOldChunksBatch,
  482. deleteOldChunks,
  483. lookupMongoProjectIdFromHistoryId,
  484. resolveHistoryIdToMongoProjectId,
  485. }