mongo.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  1. // @ts-check
  2. /**
  3. * Mongo backend for the blob store.
  4. *
  5. * Blobs are stored in the projectHistoryBlobs collection. Each project has a
  6. * document in that collection. That document has a "blobs" subdocument whose
  7. * fields are buckets of blobs. The key of a bucket is the first three hex
  8. * digits of the blob hash. The value of the bucket is an array of blobs that
  9. * match the key.
  10. *
  11. * Buckets have a maximum capacity of 8 blobs. When that capacity is exceeded,
  12. * blobs are stored in a secondary collection: the projectHistoryShardedBlobs
  13. * collection. This collection shards blobs between 16 documents per project.
  14. * The shard key is the first hex digit of the hash. The documents are also
  15. * organized in buckets, but the bucket key is made of hex digits 2, 3 and 4.
  16. */
  17. const { Blob } = require('overleaf-editor-core')
  18. const { ObjectId, Binary, MongoError, ReadPreference } = require('mongodb')
  19. const assert = require('../assert')
  20. const mongodb = require('../mongodb')
  21. const MAX_BLOBS_IN_BUCKET = 8
  22. const DUPLICATE_KEY_ERROR_CODE = 11000
  23. /**
  24. * @typedef {import('mongodb').ReadPreferenceLike} ReadPreferenceLike
  25. */
  26. /**
  27. * Set up the data structures for a given project.
  28. * @param {string} projectId
  29. */
  30. async function initialize(projectId) {
  31. assert.mongoId(projectId, 'bad projectId')
  32. try {
  33. await mongodb.blobs.insertOne({
  34. _id: new ObjectId(projectId),
  35. blobs: {},
  36. })
  37. } catch (err) {
  38. if (err instanceof MongoError && err.code === DUPLICATE_KEY_ERROR_CODE) {
  39. return // ignore already initialized case
  40. }
  41. throw err
  42. }
  43. }
  44. /**
  45. * Copy the data structures for a given project.
  46. * @param {string} sourceProjectId
  47. * @param {string} targetProjectId
  48. */
  49. async function clone(sourceProjectId, targetProjectId) {
  50. assert.mongoId(targetProjectId, 'bad target projectId')
  51. assert.mongoId(sourceProjectId, 'bad source projectId')
  52. const result = await mongodb.blobs.findOne({
  53. _id: new ObjectId(sourceProjectId),
  54. })
  55. if (!result || !('blobs' in result)) {
  56. throw new Error('missing blobs for source project')
  57. }
  58. const blobHashes = []
  59. for (const bucket of Object.values(result.blobs)) {
  60. for (const record of bucket) {
  61. blobHashes.push(record.h.toString('hex'))
  62. }
  63. }
  64. await mongodb.blobs.updateOne(
  65. { _id: new ObjectId(targetProjectId) },
  66. { $set: { blobs: result.blobs } }
  67. )
  68. const minShardedId = makeShardedId(sourceProjectId, '0')
  69. const maxShardedId = makeShardedId(sourceProjectId, 'f')
  70. // @ts-ignore We are using a custom _id here.
  71. const sharded = mongodb.shardedBlobs.find({
  72. _id: { $gte: minShardedId, $lte: maxShardedId },
  73. })
  74. const newShards = [] // gather up-to 16 shards
  75. for await (const shardedRecord of sharded) {
  76. if (shardedRecord.blobs == null) {
  77. continue
  78. }
  79. // Schema of shard id: <projectId>0<shard id: hex>
  80. const shard = shardedRecord._id.toString('hex').slice(25)
  81. const newId = makeShardedId(targetProjectId, shard)
  82. newShards.push({
  83. ...shardedRecord,
  84. _id: newId,
  85. })
  86. for (const bucket of Object.values(shardedRecord.blobs)) {
  87. for (const record of bucket) {
  88. blobHashes.push(record.h.toString('hex'))
  89. }
  90. }
  91. }
  92. if (newShards.length > 0) {
  93. // @ts-ignore We are using a custom _id here.
  94. await mongodb.shardedBlobs.insertMany(newShards)
  95. }
  96. return blobHashes
  97. }
  98. /**
  99. * Return blob metadata for the given project and hash.
  100. * @param {string} projectId
  101. * @param {string} hash
  102. * @return {Promise<Blob | null>}
  103. */
  104. async function findBlob(projectId, hash) {
  105. assert.mongoId(projectId, 'bad projectId')
  106. assert.blobHash(hash, 'bad hash')
  107. const bucket = getBucket(hash)
  108. const result = await mongodb.blobs.findOne(
  109. { _id: new ObjectId(projectId) },
  110. { projection: { _id: 0, bucket: `$${bucket}` } }
  111. )
  112. if (result?.bucket == null) {
  113. return null
  114. }
  115. const record = result.bucket.find(blob => blob.h.toString('hex') === hash)
  116. if (record == null) {
  117. if (result.bucket.length >= MAX_BLOBS_IN_BUCKET) {
  118. return await findBlobSharded(projectId, hash)
  119. } else {
  120. return null
  121. }
  122. }
  123. return recordToBlob(record)
  124. }
  125. /**
  126. * Search in the sharded collection for blob metadata
  127. * @param {string} projectId
  128. * @param {string} hash
  129. * @return {Promise<Blob | null>}
  130. */
  131. async function findBlobSharded(projectId, hash) {
  132. const [shard, bucket] = getShardedBucket(hash)
  133. const id = makeShardedId(projectId, shard)
  134. const result = await mongodb.shardedBlobs.findOne(
  135. { _id: id },
  136. { projection: { _id: 0, blobs: `$${bucket}` } }
  137. )
  138. if (result?.blobs == null) {
  139. return null
  140. }
  141. const record = result.blobs.find(blob => blob.h.toString('hex') === hash)
  142. if (!record) return null
  143. return recordToBlob(record)
  144. }
  145. /**
  146. * Read multiple blob metadata records by hexadecimal hashes.
  147. * @param {string} projectId
  148. * @param {Array<string>} hashes
  149. * @return {Promise<Array<Blob>>}
  150. */
  151. async function findBlobs(projectId, hashes) {
  152. assert.mongoId(projectId, 'bad projectId')
  153. assert.array(hashes, 'bad hashes: not array')
  154. hashes.forEach(function (hash) {
  155. assert.blobHash(hash, 'bad hash')
  156. })
  157. // Build a set of unique buckets
  158. const buckets = new Set(hashes.map(getBucket))
  159. // Get buckets from Mongo
  160. const projection = { _id: 0 }
  161. for (const bucket of buckets) {
  162. projection[bucket] = 1
  163. }
  164. const result = await mongodb.blobs.findOne(
  165. { _id: new ObjectId(projectId) },
  166. { projection }
  167. )
  168. if (result?.blobs == null) {
  169. return []
  170. }
  171. // Build blobs from the query results
  172. const hashSet = new Set(hashes)
  173. const blobs = []
  174. for (const bucket of Object.values(result.blobs)) {
  175. for (const record of bucket) {
  176. const hash = record.h.toString('hex')
  177. if (hashSet.has(hash)) {
  178. blobs.push(recordToBlob(record))
  179. hashSet.delete(hash)
  180. }
  181. }
  182. }
  183. // If we haven't found all the blobs, look in the sharded collection
  184. if (hashSet.size > 0) {
  185. const shardedBlobs = await findBlobsSharded(projectId, hashSet)
  186. blobs.push(...shardedBlobs)
  187. }
  188. return blobs
  189. }
  190. /**
  191. * Search in the sharded collection for blob metadata.
  192. * @param {string} projectId
  193. * @param {Set<string>} hashSet
  194. * @return {Promise<Array<Blob>>}
  195. */
  196. async function findBlobsSharded(projectId, hashSet) {
  197. // Build a map of buckets by shard key
  198. const bucketsByShard = new Map()
  199. for (const hash of hashSet) {
  200. const [shard, bucket] = getShardedBucket(hash)
  201. let buckets = bucketsByShard.get(shard)
  202. if (buckets == null) {
  203. buckets = new Set()
  204. bucketsByShard.set(shard, buckets)
  205. }
  206. buckets.add(bucket)
  207. }
  208. // Make parallel requests to the shards that might contain the hashes we want
  209. const requests = []
  210. for (const [shard, buckets] of bucketsByShard.entries()) {
  211. const id = makeShardedId(projectId, shard)
  212. const projection = { _id: 0 }
  213. for (const bucket of buckets) {
  214. projection[bucket] = 1
  215. }
  216. const request = mongodb.shardedBlobs.findOne({ _id: id }, { projection })
  217. requests.push(request)
  218. }
  219. const results = await Promise.all(requests)
  220. // Build blobs from the query results
  221. const blobs = []
  222. for (const result of results) {
  223. if (result?.blobs == null) {
  224. continue
  225. }
  226. for (const bucket of Object.values(result.blobs)) {
  227. for (const record of bucket) {
  228. const hash = record.h.toString('hex')
  229. if (hashSet.has(hash)) {
  230. blobs.push(recordToBlob(record))
  231. }
  232. }
  233. }
  234. }
  235. return blobs
  236. }
  237. /**
  238. * Return metadata for all blobs in the given project
  239. */
  240. async function getProjectBlobs(projectId) {
  241. assert.mongoId(projectId, 'bad projectId')
  242. const result = await mongodb.blobs.findOne(
  243. { _id: new ObjectId(projectId) },
  244. { projection: { _id: 0 } }
  245. )
  246. if (!result) {
  247. return []
  248. }
  249. // Build blobs from the query results
  250. const blobs = []
  251. for (const bucket of Object.values(result.blobs)) {
  252. for (const record of bucket) {
  253. blobs.push(recordToBlob(record))
  254. }
  255. }
  256. // Look for all possible sharded blobs
  257. const minShardedId = makeShardedId(projectId, '0')
  258. const maxShardedId = makeShardedId(projectId, 'f')
  259. // @ts-ignore We are using a custom _id here.
  260. const shardedRecords = mongodb.shardedBlobs.find(
  261. {
  262. _id: { $gte: minShardedId, $lte: maxShardedId },
  263. },
  264. { projection: { _id: 0 } }
  265. )
  266. for await (const shardedRecord of shardedRecords) {
  267. if (shardedRecord.blobs == null) {
  268. continue
  269. }
  270. for (const bucket of Object.values(shardedRecord.blobs)) {
  271. for (const record of bucket) {
  272. blobs.push(recordToBlob(record))
  273. }
  274. }
  275. }
  276. return blobs
  277. }
  278. /**
  279. * Return metadata for all blobs in the given project
  280. * @param {Array<string>} projectIds
  281. * @return {Promise<{ nBlobs: number, blobs: Map<string, Array<Blob>> }>}
  282. */
  283. async function getProjectBlobsBatch(projectIds) {
  284. for (const project of projectIds) {
  285. assert.mongoId(project, 'bad projectId')
  286. }
  287. let nBlobs = 0
  288. const blobs = new Map()
  289. if (projectIds.length === 0) return { nBlobs, blobs }
  290. // blobs
  291. {
  292. const cursor = await mongodb.blobs.find(
  293. { _id: { $in: projectIds.map(projectId => new ObjectId(projectId)) } },
  294. { readPreference: ReadPreference.secondaryPreferred }
  295. )
  296. for await (const record of cursor) {
  297. const projectBlobs = Object.values(record.blobs).flat().map(recordToBlob)
  298. blobs.set(record._id.toString(), projectBlobs)
  299. nBlobs += projectBlobs.length
  300. }
  301. }
  302. // sharded blobs
  303. {
  304. // @ts-ignore We are using a custom _id here.
  305. const cursor = await mongodb.shardedBlobs.find(
  306. {
  307. _id: {
  308. $gte: makeShardedId(projectIds[0], '0'),
  309. $lte: makeShardedId(projectIds[projectIds.length - 1], 'f'),
  310. },
  311. },
  312. { readPreference: ReadPreference.secondaryPreferred }
  313. )
  314. for await (const record of cursor) {
  315. const recordIdHex = record._id.toString('hex')
  316. const recordProjectId = recordIdHex.slice(0, 24)
  317. const projectBlobs = Object.values(record.blobs).flat().map(recordToBlob)
  318. const found = blobs.get(recordProjectId)
  319. if (found) {
  320. found.push(...projectBlobs)
  321. } else {
  322. blobs.set(recordProjectId, projectBlobs)
  323. }
  324. nBlobs += projectBlobs.length
  325. }
  326. }
  327. return { nBlobs, blobs }
  328. }
  329. /**
  330. * Add a blob's metadata to the blobs collection after it has been uploaded.
  331. * @param {string} projectId
  332. * @param {Blob} blob
  333. */
  334. async function insertBlob(projectId, blob) {
  335. assert.mongoId(projectId, 'bad projectId')
  336. const hash = blob.getHash()
  337. const bucket = getBucket(hash)
  338. const record = blobToRecord(blob)
  339. const result = await mongodb.blobs.updateOne(
  340. {
  341. _id: new ObjectId(projectId),
  342. $expr: {
  343. $lt: [{ $size: { $ifNull: [`$${bucket}`, []] } }, MAX_BLOBS_IN_BUCKET],
  344. },
  345. },
  346. {
  347. $addToSet: { [bucket]: record },
  348. }
  349. )
  350. if (result.matchedCount === 0) {
  351. await insertRecordSharded(projectId, hash, record)
  352. }
  353. }
  354. /**
  355. * Add a blob's metadata to the sharded blobs collection.
  356. * @param {string} projectId
  357. * @param {string} hash
  358. * @param {Record} record
  359. * @return {Promise<void>}
  360. */
  361. async function insertRecordSharded(projectId, hash, record) {
  362. const [shard, bucket] = getShardedBucket(hash)
  363. const id = makeShardedId(projectId, shard)
  364. await mongodb.shardedBlobs.updateOne(
  365. { _id: id },
  366. { $addToSet: { [bucket]: record } },
  367. { upsert: true }
  368. )
  369. }
  370. /**
  371. * Delete all blobs for a given project.
  372. * @param {string} projectId
  373. */
  374. async function deleteBlobs(projectId) {
  375. assert.mongoId(projectId, 'bad projectId')
  376. await mongodb.blobs.deleteOne({ _id: new ObjectId(projectId) })
  377. const minShardedId = makeShardedId(projectId, '0')
  378. const maxShardedId = makeShardedId(projectId, 'f')
  379. await mongodb.shardedBlobs.deleteMany({
  380. // @ts-ignore We are using a custom _id here.
  381. _id: { $gte: minShardedId, $lte: maxShardedId },
  382. })
  383. }
  384. /**
  385. * Return the Mongo path to the bucket for the given hash.
  386. * @param {string} hash
  387. * @return {string}
  388. */
  389. function getBucket(hash) {
  390. return `blobs.${hash.slice(0, 3)}`
  391. }
  392. /**
  393. * Return the shard key and Mongo path to the bucket for the given hash in the
  394. * sharded collection.
  395. * @param {string} hash
  396. * @return {[string, string]}
  397. */
  398. function getShardedBucket(hash) {
  399. const shard = hash.slice(0, 1)
  400. const bucket = `blobs.${hash.slice(1, 4)}`
  401. return [shard, bucket]
  402. }
  403. /**
  404. * Create an _id key for the sharded collection.
  405. * @param {string} projectId
  406. * @param {string} shard
  407. * @return {Binary}
  408. */
  409. function makeShardedId(projectId, shard) {
  410. return new Binary(Buffer.from(`${projectId}0${shard}`, 'hex'))
  411. }
  412. /**
  413. * @typedef {Object} Record
  414. * @property {Binary} h
  415. * @property {number} b
  416. * @property {number} [s]
  417. */
  418. /**
  419. * Return the Mongo record for the given blob.
  420. * @param {Blob} blob
  421. * @return {Record}
  422. */
  423. function blobToRecord(blob) {
  424. const hash = blob.getHash()
  425. const byteLength = blob.getByteLength()
  426. const stringLength = blob.getStringLength()
  427. return {
  428. h: new Binary(Buffer.from(hash, 'hex')),
  429. b: byteLength,
  430. s: stringLength,
  431. }
  432. }
  433. /**
  434. * Create a blob from the given Mongo record.
  435. * @param {Record} record
  436. * @return {Blob}
  437. */
  438. function recordToBlob(record) {
  439. return new Blob(record.h.toString('hex'), record.b, record.s)
  440. }
  441. module.exports = {
  442. initialize,
  443. clone,
  444. findBlob,
  445. findBlobs,
  446. getProjectBlobs,
  447. getProjectBlobsBatch,
  448. insertBlob,
  449. deleteBlobs,
  450. }