back_fill_file_hash.mjs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824
  1. // @ts-check
  2. import Crypto from 'node:crypto'
  3. import Events from 'node:events'
  4. import fs from 'node:fs'
  5. import Path from 'node:path'
  6. import Stream from 'node:stream'
  7. import zLib from 'node:zlib'
  8. import { setTimeout } from 'node:timers/promises'
  9. import { Binary, ObjectId } from 'mongodb'
  10. import logger from '@overleaf/logger'
  11. import {
  12. batchedUpdate,
  13. READ_PREFERENCE_SECONDARY,
  14. } from '@overleaf/mongo-utils/batchedUpdate.js'
  15. import OError from '@overleaf/o-error'
  16. import {
  17. AlreadyWrittenError,
  18. NoKEKMatchedError,
  19. NotFoundError,
  20. } from '@overleaf/object-persistor/src/Errors.js'
  21. import { promiseMapWithLimit } from '@overleaf/promise-utils'
  22. import { backupPersistor, projectBlobsBucket } from '../lib/backupPersistor.mjs'
  23. import {
  24. BlobStore,
  25. GLOBAL_BLOBS,
  26. loadGlobalBlobs,
  27. makeProjectKey,
  28. } from '../lib/blob_store/index.js'
  29. import { backedUpBlobs, db } from '../lib/mongodb.js'
  30. import filestorePersistor from '../lib/persistor.js'
  31. // Silence warning.
  32. Events.setMaxListeners(20)
  33. // Enable caching for ObjectId.toString()
  34. ObjectId.cacheHexString = true
  35. /**
  36. * @typedef {import("overleaf-editor-core").Blob} Blob
  37. * @typedef {import("mongodb").Collection} Collection
  38. * @typedef {import("@overleaf/object-persistor/src/PerProjectEncryptedS3Persistor").CachedPerProjectEncryptedS3Persistor} CachedPerProjectEncryptedS3Persistor
  39. */
  40. /**
  41. * @typedef {Object} FileRef
  42. * @property {ObjectId} _id
  43. * @property {string} hash
  44. */
  45. /**
  46. * @typedef {Object} Folder
  47. * @property {Array<Folder>} folders
  48. * @property {Array<FileRef>} fileRefs
  49. */
  50. /**
  51. * @typedef {Object} DeletedFileRef
  52. * @property {ObjectId} _id
  53. * @property {ObjectId} projectId
  54. * @property {string} hash
  55. */
  56. /**
  57. * @typedef {Object} Project
  58. * @property {ObjectId} _id
  59. * @property {Array<Folder>} rootFolder
  60. * @property {Array<string>} deletedFileIds
  61. * @property {{history: {id: string}}} overleaf
  62. */
  63. /**
  64. * @typedef {Object} QueueEntry
  65. * @property {ProjectContext} ctx
  66. * @property {string} fileId
  67. * @property {string} path
  68. * @property {string} [hash]
  69. */
  70. // Time of closing the ticket for adding hashes: https://github.com/overleaf/internal/issues/464#issuecomment-492668129
  71. const ALL_PROJECTS_HAVE_FILE_HASHES_AFTER = new Date('2019-05-15T14:02:00Z')
  72. const PUBLIC_LAUNCH_DATE = new Date('2012-01-01T00:00:00Z')
  73. const BATCH_RANGE_START =
  74. process.env.BATCH_RANGE_START ||
  75. ObjectId.createFromTime(PUBLIC_LAUNCH_DATE.getTime() / 1000).toString()
  76. const BATCH_RANGE_END =
  77. process.env.BATCH_RANGE_END ||
  78. ObjectId.createFromTime(
  79. ALL_PROJECTS_HAVE_FILE_HASHES_AFTER.getTime() / 1000
  80. ).toString()
  81. // We need to control the start and end as ids of deleted projects are created at time of deletion.
  82. delete process.env.BATCH_RANGE_START
  83. delete process.env.BATCH_RANGE_END
  84. // Concurrency for downloading from GCS and updating hashes in mongo
  85. const CONCURRENCY = parseInt(process.env.CONCURRENCY || '100', 10)
  86. // Retries for processing a given file
  87. const RETRIES = parseInt(process.env.RETRIES || '10', 10)
  88. const RETRY_DELAY_MS = parseInt(process.env.RETRY_DELAY_MS || '100', 10)
  89. const USER_FILES_BUCKET_NAME = process.env.USER_FILES_BUCKET_NAME || ''
  90. if (!USER_FILES_BUCKET_NAME) {
  91. throw new Error('env var USER_FILES_BUCKET_NAME is missing')
  92. }
  93. const RETRY_FILESTORE_404 = process.env.RETRY_FILESTORE_404 === 'true'
  94. const BUFFER_DIR = fs.mkdtempSync(
  95. process.env.BUFFER_DIR_PREFIX || '/tmp/back_fill_file_hash-'
  96. )
  97. const projectsCollection = db.collection('projects')
  98. const deletedProjectsCollection = db.collection('deletedProjects')
  99. const deletedFilesCollection = db.collection('deletedFiles')
  100. const STATS = {
  101. projects: 0,
  102. filesWithoutHash: 0,
  103. filesDuplicated: 0,
  104. filesRetries: 0,
  105. filesFailed: 0,
  106. fileTreeUpdated: 0,
  107. globalBlobsCount: 0,
  108. globalBlobsEgress: 0,
  109. projectDeleted: 0,
  110. projectHardDeleted: 0,
  111. fileHardDeleted: 0,
  112. mongoUpdates: 0,
  113. deduplicatedWriteToAWSLocalCount: 0,
  114. deduplicatedWriteToAWSLocalEgress: 0,
  115. deduplicatedWriteToAWSRemoteCount: 0,
  116. deduplicatedWriteToAWSRemoteEgress: 0,
  117. writeToAWSCount: 0,
  118. writeToAWSEgress: 0,
  119. }
  120. function printStats() {
  121. console.log(
  122. JSON.stringify({
  123. time: new Date(),
  124. ...STATS,
  125. })
  126. )
  127. }
  128. setInterval(printStats, 60_000)
  129. /**
  130. * @param {QueueEntry} entry
  131. * @return {Promise<string>}
  132. */
  133. async function processFile(entry) {
  134. for (let attempt = 0; attempt < RETRIES; attempt++) {
  135. try {
  136. return await processFileOnce(entry)
  137. } catch (err) {
  138. if (err instanceof NotFoundError) {
  139. const { bucketName } = OError.getFullInfo(err)
  140. if (bucketName === USER_FILES_BUCKET_NAME && !RETRY_FILESTORE_404) {
  141. throw err // disable retries for not found in filestore bucket case
  142. }
  143. }
  144. if (err instanceof NoKEKMatchedError) {
  145. throw err // disable retries when upload to S3 will fail again
  146. }
  147. STATS.filesRetries++
  148. const {
  149. ctx: { projectId },
  150. fileId,
  151. path,
  152. } = entry
  153. logger.warn(
  154. { err, projectId, fileId, path, attempt },
  155. 'failed to process file, trying again'
  156. )
  157. await setTimeout(RETRY_DELAY_MS)
  158. }
  159. }
  160. return await processFileOnce(entry)
  161. }
  162. /**
  163. * @param {QueueEntry} entry
  164. * @return {Promise<string>}
  165. */
  166. async function processFileOnce(entry) {
  167. const { fileId } = entry
  168. const { projectId, historyId } = entry.ctx
  169. const filePath = Path.join(
  170. BUFFER_DIR,
  171. projectId.toString() + fileId.toString()
  172. )
  173. const dst = fs.createWriteStream(filePath)
  174. const src = await filestorePersistor.getObjectStream(
  175. USER_FILES_BUCKET_NAME,
  176. `${projectId}/${fileId}`
  177. )
  178. await Stream.promises.pipeline(src, dst)
  179. const blobStore = new BlobStore(historyId)
  180. const blob = await blobStore.putFile(filePath)
  181. const hash = blob.getHash()
  182. if (GLOBAL_BLOBS.has(hash)) {
  183. STATS.globalBlobsCount++
  184. STATS.globalBlobsEgress += estimateBlobSize(blob)
  185. return hash
  186. }
  187. if (entry.ctx.hasBackedUpBlob(hash)) {
  188. STATS.deduplicatedWriteToAWSLocalCount++
  189. STATS.deduplicatedWriteToAWSLocalEgress += estimateBlobSize(blob)
  190. return hash
  191. }
  192. entry.ctx.recordPendingBlob(hash)
  193. let backupSource
  194. let contentEncoding
  195. const md5 = Crypto.createHash('md5')
  196. let size
  197. if (blob.getStringLength()) {
  198. const filePathCompressed = filePath + '.gz'
  199. backupSource = filePathCompressed
  200. contentEncoding = 'gzip'
  201. size = 0
  202. await Stream.promises.pipeline(
  203. fs.createReadStream(filePath),
  204. zLib.createGzip(),
  205. async function* (source) {
  206. for await (const chunk of source) {
  207. size += chunk.byteLength
  208. md5.update(chunk)
  209. yield chunk
  210. }
  211. },
  212. fs.createWriteStream(filePathCompressed)
  213. )
  214. } else {
  215. backupSource = filePath
  216. size = blob.getByteLength()
  217. await Stream.promises.pipeline(fs.createReadStream(filePath), md5)
  218. }
  219. const backendKeyPath = makeProjectKey(historyId, blob.getHash())
  220. const persistor = await entry.ctx.getCachedPersistor(backendKeyPath)
  221. try {
  222. STATS.writeToAWSCount++
  223. await persistor.sendStream(
  224. projectBlobsBucket,
  225. backendKeyPath,
  226. fs.createReadStream(backupSource),
  227. {
  228. contentEncoding,
  229. contentType: 'application/octet-stream',
  230. contentLength: size,
  231. sourceMd5: md5.digest('hex'),
  232. ifNoneMatch: '*', // de-duplicate write (we pay for the request, but avoid egress)
  233. }
  234. )
  235. STATS.writeToAWSEgress += size
  236. } catch (err) {
  237. if (err instanceof AlreadyWrittenError) {
  238. STATS.deduplicatedWriteToAWSRemoteCount++
  239. STATS.deduplicatedWriteToAWSRemoteEgress += size
  240. } else {
  241. STATS.writeToAWSEgress += size
  242. entry.ctx.recordFailedBlob(hash)
  243. throw err
  244. }
  245. }
  246. entry.ctx.recordBackedUpBlob(hash)
  247. return hash
  248. }
  249. /**
  250. * @param {Array<QueueEntry>} files
  251. * @return {Promise<void>}
  252. */
  253. async function processFiles(files) {
  254. if (files.length === 0) return // all processed
  255. await fs.promises.mkdir(BUFFER_DIR, { recursive: true })
  256. try {
  257. await promiseMapWithLimit(
  258. CONCURRENCY,
  259. files,
  260. /**
  261. * @param {QueueEntry} entry
  262. * @return {Promise<void>}
  263. */
  264. async function (entry) {
  265. try {
  266. await entry.ctx.processFile(entry)
  267. } catch (err) {
  268. STATS.filesFailed++
  269. const {
  270. ctx: { projectId },
  271. fileId,
  272. path,
  273. } = entry
  274. logger.error(
  275. { err, projectId, fileId, path },
  276. 'failed to process file'
  277. )
  278. }
  279. }
  280. )
  281. } finally {
  282. await fs.promises.rm(BUFFER_DIR, { recursive: true, force: true })
  283. }
  284. }
  285. /**
  286. * @param {Array<Project>} batch
  287. * @param {string} prefix
  288. * @return {Promise<void>}
  289. */
  290. async function handleLiveTreeBatch(batch, prefix = 'rootFolder.0') {
  291. if (process.argv.includes('deletedFiles')) {
  292. await collectDeletedFiles(batch)
  293. }
  294. const files = Array.from(findFileInBatch(batch, prefix))
  295. STATS.projects += batch.length
  296. STATS.filesWithoutHash += files.length
  297. batch.length = 0 // GC
  298. // The files are currently ordered by project-id.
  299. // Order them by file-id to
  300. // - avoid head-of-line blocking from many project-files waiting on the generation of the projects DEK (round trip to AWS)
  301. // - bonus: increase chance of de-duplicating write to AWS
  302. files.sort((a, b) => (a.fileId > b.fileId ? 1 : -1))
  303. await processFiles(files)
  304. await promiseMapWithLimit(
  305. CONCURRENCY,
  306. files,
  307. /**
  308. * @param {QueueEntry} entry
  309. * @return {Promise<void>}
  310. */
  311. async function (entry) {
  312. await entry.ctx.flushMongoQueues()
  313. }
  314. )
  315. }
  316. /**
  317. * @param {Array<{project: Project}>} batch
  318. * @return {Promise<void>}
  319. */
  320. async function handleDeletedFileTreeBatch(batch) {
  321. await handleLiveTreeBatch(
  322. batch.map(d => d.project),
  323. 'project.rootFolder.0'
  324. )
  325. }
  326. /**
  327. * @param {QueueEntry} entry
  328. * @return {Promise<boolean>}
  329. */
  330. async function tryUpdateFileRefInMongo(entry) {
  331. if (entry.path === '') {
  332. return await tryUpdateDeletedFileRefInMongo(entry)
  333. } else if (entry.path.startsWith('project.')) {
  334. return await tryUpdateFileRefInMongoInDeletedProject(entry)
  335. }
  336. STATS.mongoUpdates++
  337. const result = await projectsCollection.updateOne(
  338. {
  339. _id: entry.ctx.projectId,
  340. [`${entry.path}._id`]: new ObjectId(entry.fileId),
  341. },
  342. {
  343. $set: { [`${entry.path}.hash`]: entry.hash },
  344. }
  345. )
  346. return result.matchedCount === 1
  347. }
  348. /**
  349. * @param {QueueEntry} entry
  350. * @return {Promise<boolean>}
  351. */
  352. async function tryUpdateDeletedFileRefInMongo(entry) {
  353. STATS.mongoUpdates++
  354. const result = await deletedFilesCollection.updateOne(
  355. {
  356. _id: new ObjectId(entry.fileId),
  357. projectId: entry.ctx.projectId,
  358. },
  359. { $set: { hash: entry.hash } }
  360. )
  361. return result.matchedCount === 1
  362. }
  363. /**
  364. * @param {QueueEntry} entry
  365. * @return {Promise<boolean>}
  366. */
  367. async function tryUpdateFileRefInMongoInDeletedProject(entry) {
  368. STATS.mongoUpdates++
  369. const result = await deletedProjectsCollection.updateOne(
  370. {
  371. 'deleterData.deletedProjectId': entry.ctx.projectId,
  372. [`${entry.path}._id`]: new ObjectId(entry.fileId),
  373. },
  374. {
  375. $set: { [`${entry.path}.hash`]: entry.hash },
  376. }
  377. )
  378. return result.matchedCount === 1
  379. }
  380. const RETRY_UPDATE_HASH = 100
  381. /**
  382. * @param {QueueEntry} entry
  383. * @return {Promise<void>}
  384. */
  385. async function updateFileRefInMongo(entry) {
  386. if (await tryUpdateFileRefInMongo(entry)) return
  387. const { fileId } = entry
  388. const { projectId } = entry.ctx
  389. for (let i = 0; i < RETRY_UPDATE_HASH; i++) {
  390. let prefix = 'rootFolder.0'
  391. let p = await projectsCollection.findOne(
  392. { _id: projectId },
  393. { projection: { rootFolder: 1 } }
  394. )
  395. if (!p) {
  396. STATS.projectDeleted++
  397. prefix = 'project.rootFolder.0'
  398. const deletedProject = await deletedProjectsCollection.findOne(
  399. {
  400. 'deleterData.deletedProjectId': projectId,
  401. project: { $exists: true },
  402. },
  403. { projection: { 'project.rootFolder': 1 } }
  404. )
  405. p = deletedProject?.project
  406. if (!p) {
  407. STATS.projectHardDeleted++
  408. console.warn(
  409. 'bug: project hard-deleted while processing',
  410. projectId,
  411. fileId
  412. )
  413. return
  414. }
  415. }
  416. let found = false
  417. for (const e of findFiles(entry.ctx, p.rootFolder[0], prefix)) {
  418. found = e.fileId === fileId
  419. if (!found) continue
  420. if (await tryUpdateFileRefInMongo(e)) return
  421. break
  422. }
  423. if (!found) {
  424. if (await tryUpdateDeletedFileRefInMongo(entry)) return
  425. STATS.fileHardDeleted++
  426. console.warn('bug: file hard-deleted while processing', projectId, fileId)
  427. return
  428. }
  429. STATS.fileTreeUpdated++
  430. }
  431. throw new OError(
  432. 'file-tree updated repeatedly while trying to add hash',
  433. entry
  434. )
  435. }
  436. /**
  437. * @param {ProjectContext} ctx
  438. * @param {Folder} folder
  439. * @param {string} path
  440. * @return Generator<QueueEntry>
  441. */
  442. function* findFiles(ctx, folder, path) {
  443. let i = 0
  444. for (const child of folder.folders) {
  445. yield* findFiles(ctx, child, `${path}.folders.${i}`)
  446. i++
  447. }
  448. i = 0
  449. for (const fileRef of folder.fileRefs) {
  450. if (!fileRef.hash) {
  451. yield {
  452. ctx,
  453. fileId: fileRef._id.toString(),
  454. path: `${path}.fileRefs.${i}`,
  455. }
  456. }
  457. i++
  458. }
  459. }
  460. /**
  461. * @param {Array<Project>} projects
  462. * @param {string} prefix
  463. */
  464. function* findFileInBatch(projects, prefix) {
  465. for (const project of projects) {
  466. const ctx = new ProjectContext(project)
  467. yield* findFiles(ctx, project.rootFolder[0], prefix)
  468. for (const fileId of project.deletedFileIds || []) {
  469. yield { ctx, fileId, path: '' }
  470. }
  471. }
  472. }
  473. /**
  474. * @param {Array<Project>} projects
  475. * @return {Promise<void>}
  476. */
  477. async function collectDeletedFiles(projects) {
  478. const cursor = deletedFilesCollection.find(
  479. {
  480. projectId: { $in: projects.map(p => p._id) },
  481. hash: { $exists: false },
  482. },
  483. {
  484. projection: { _id: 1, projectId: 1 },
  485. readPreference: READ_PREFERENCE_SECONDARY,
  486. sort: { projectId: 1 },
  487. }
  488. )
  489. const processed = projects.slice()
  490. for await (const deletedFileRef of cursor) {
  491. const idx = processed.findIndex(
  492. p => p._id.toString() === deletedFileRef.projectId.toString()
  493. )
  494. if (idx === -1) {
  495. throw new Error(
  496. `bug: order of deletedFiles mongo records does not match batch of projects (${deletedFileRef.projectId} out of order)`
  497. )
  498. }
  499. processed.splice(0, idx)
  500. const project = processed[0]
  501. project.deletedFileIds = project.deletedFileIds || []
  502. project.deletedFileIds.push(deletedFileRef._id.toString())
  503. }
  504. }
  505. const BATCH_HASH_WRITES = 1_000
  506. const BATCH_FILE_UPDATES = 100
  507. class ProjectContext {
  508. /** @type {Promise<CachedPerProjectEncryptedS3Persistor> | null} */
  509. #cachedPersistorPromise = null
  510. /**
  511. * @param {Project} project
  512. */
  513. constructor(project) {
  514. this.projectId = project._id
  515. this.historyId = project.overleaf.history.id.toString()
  516. }
  517. /**
  518. * @param {string} key
  519. * @return {Promise<CachedPerProjectEncryptedS3Persistor>}
  520. */
  521. getCachedPersistor(key) {
  522. if (!this.#cachedPersistorPromise) {
  523. // Fetch DEK once, but only if needed -- upon the first use
  524. this.#cachedPersistorPromise = this.#getCachedPersistorWithRetries(key)
  525. }
  526. return this.#cachedPersistorPromise
  527. }
  528. /**
  529. * @param {string} key
  530. * @return {Promise<CachedPerProjectEncryptedS3Persistor>}
  531. */
  532. async #getCachedPersistorWithRetries(key) {
  533. for (let attempt = 0; attempt < RETRIES; attempt++) {
  534. try {
  535. return await backupPersistor.forProject(projectBlobsBucket, key)
  536. } catch (err) {
  537. if (err instanceof NoKEKMatchedError) {
  538. throw err
  539. } else {
  540. logger.warn(
  541. { err, projectId: this.projectId, attempt },
  542. 'failed to get DEK, trying again'
  543. )
  544. await setTimeout(RETRY_DELAY_MS)
  545. }
  546. }
  547. }
  548. return await backupPersistor.forProject(projectBlobsBucket, key)
  549. }
  550. async flushMongoQueuesIfNeeded() {
  551. if (this.#completedBlobs.size > BATCH_HASH_WRITES) {
  552. await this.#storeBackedUpBlobs()
  553. }
  554. if (this.#pendingFileWrites.length > BATCH_FILE_UPDATES) {
  555. await this.#storeFileHashes()
  556. }
  557. }
  558. async flushMongoQueues() {
  559. await this.#storeBackedUpBlobs()
  560. await this.#storeFileHashes()
  561. }
  562. /** @type {Set<string>} */
  563. #pendingBlobs = new Set()
  564. /** @type {Set<string>} */
  565. #completedBlobs = new Set()
  566. async #storeBackedUpBlobs() {
  567. if (this.#completedBlobs.size === 0) return
  568. const blobs = Array.from(this.#completedBlobs).map(
  569. hash => new Binary(Buffer.from(hash, 'hex'))
  570. )
  571. this.#completedBlobs.clear()
  572. STATS.mongoUpdates++
  573. await backedUpBlobs.updateOne(
  574. { _id: this.projectId },
  575. { $addToSet: { blobs: { $each: blobs } } },
  576. { upsert: true }
  577. )
  578. }
  579. /**
  580. * @param {string} hash
  581. */
  582. recordPendingBlob(hash) {
  583. this.#pendingBlobs.add(hash)
  584. }
  585. /**
  586. * @param {string} hash
  587. */
  588. recordFailedBlob(hash) {
  589. this.#pendingBlobs.delete(hash)
  590. }
  591. /**
  592. * @param {string} hash
  593. */
  594. recordBackedUpBlob(hash) {
  595. this.#completedBlobs.add(hash)
  596. this.#pendingBlobs.delete(hash)
  597. }
  598. /**
  599. * @param {string} hash
  600. * @return {boolean}
  601. */
  602. hasBackedUpBlob(hash) {
  603. return this.#pendingBlobs.has(hash) || this.#completedBlobs.has(hash)
  604. }
  605. /** @type {Array<QueueEntry>} */
  606. #pendingFileWrites = []
  607. /**
  608. * @param {QueueEntry} entry
  609. */
  610. queueFileForWritingHash(entry) {
  611. this.#pendingFileWrites.push(entry)
  612. }
  613. /**
  614. * @param {Collection} collection
  615. * @param {Array<QueueEntry>} entries
  616. * @param {Object} query
  617. * @return {Promise<Array<QueueEntry>>}
  618. */
  619. async #tryBatchHashWrites(collection, entries, query) {
  620. if (entries.length === 0) return []
  621. const update = {}
  622. for (const entry of entries) {
  623. query[`${entry.path}._id`] = new ObjectId(entry.fileId)
  624. update[`${entry.path}.hash`] = entry.hash
  625. }
  626. STATS.mongoUpdates++
  627. const result = await collection.updateOne(query, { $set: update })
  628. if (result.matchedCount === 1) {
  629. return [] // all updated
  630. }
  631. return entries
  632. }
  633. async #storeFileHashes() {
  634. if (this.#pendingFileWrites.length === 0) return
  635. const individualUpdates = []
  636. const projectEntries = []
  637. const deletedProjectEntries = []
  638. for (const entry of this.#pendingFileWrites) {
  639. if (entry.path === '') {
  640. individualUpdates.push(entry)
  641. } else if (entry.path.startsWith('project.')) {
  642. deletedProjectEntries.push(entry)
  643. } else {
  644. projectEntries.push(entry)
  645. }
  646. }
  647. this.#pendingFileWrites.length = 0
  648. // Try to process them together, otherwise fallback to individual updates and retries.
  649. individualUpdates.push(
  650. ...(await this.#tryBatchHashWrites(projectsCollection, projectEntries, {
  651. _id: this.projectId,
  652. }))
  653. )
  654. individualUpdates.push(
  655. ...(await this.#tryBatchHashWrites(
  656. deletedProjectsCollection,
  657. deletedProjectEntries,
  658. { 'deleterData.deletedProjectId': this.projectId }
  659. ))
  660. )
  661. for (const entry of individualUpdates) {
  662. await updateFileRefInMongo(entry)
  663. }
  664. }
  665. /** @type {Map<string, Promise<string>>} */
  666. #pendingFiles = new Map()
  667. /**
  668. * @param {QueueEntry} entry
  669. */
  670. async processFile(entry) {
  671. if (this.#pendingFiles.has(entry.fileId)) {
  672. STATS.filesDuplicated++
  673. } else {
  674. this.#pendingFiles.set(entry.fileId, processFile(entry))
  675. }
  676. entry.hash = await this.#pendingFiles.get(entry.fileId)
  677. this.queueFileForWritingHash(entry)
  678. await this.flushMongoQueuesIfNeeded()
  679. }
  680. }
  681. /**
  682. * @param {Blob} blob
  683. * @return {number}
  684. */
  685. function estimateBlobSize(blob) {
  686. let size = blob.getByteLength()
  687. if (blob.getStringLength()) {
  688. // approximation for gzip (25 bytes gzip overhead and 20% compression ratio)
  689. size = 25 + Math.ceil(size * 0.2)
  690. }
  691. return size
  692. }
  693. async function updateLiveFileTrees() {
  694. await batchedUpdate(
  695. projectsCollection,
  696. { 'overleaf.history.id': { $exists: true } },
  697. handleLiveTreeBatch,
  698. { rootFolder: 1, _id: 1, 'overleaf.history.id': 1 },
  699. {},
  700. {
  701. BATCH_RANGE_START,
  702. BATCH_RANGE_END,
  703. }
  704. )
  705. console.warn('Done updating live projects')
  706. }
  707. async function updateDeletedFileTrees() {
  708. await batchedUpdate(
  709. deletedProjectsCollection,
  710. {
  711. 'deleterData.deletedProjectId': {
  712. $gt: new ObjectId(BATCH_RANGE_START),
  713. $lte: new ObjectId(BATCH_RANGE_END),
  714. },
  715. 'project.overleaf.history.id': { $exists: true },
  716. },
  717. handleDeletedFileTreeBatch,
  718. {
  719. 'project.rootFolder': 1,
  720. 'project._id': 1,
  721. 'project.overleaf.history.id': 1,
  722. }
  723. )
  724. console.warn('Done updating deleted projects')
  725. }
  726. async function main() {
  727. await loadGlobalBlobs()
  728. if (process.argv.includes('live')) {
  729. await updateLiveFileTrees()
  730. }
  731. if (process.argv.includes('deleted')) {
  732. await updateDeletedFileTrees()
  733. }
  734. console.warn('Done.')
  735. }
  736. try {
  737. try {
  738. await main()
  739. } finally {
  740. printStats()
  741. }
  742. let code = 0
  743. if (STATS.filesFailed > 0) {
  744. console.warn('Some files could not be processed, see logs and try again')
  745. code++
  746. }
  747. if (STATS.fileHardDeleted > 0) {
  748. console.warn(
  749. 'Some hashes could not be updated as the files were hard-deleted, this should not happen'
  750. )
  751. code++
  752. }
  753. if (STATS.projectHardDeleted > 0) {
  754. console.warn(
  755. 'Some hashes could not be updated as the project was hard-deleted, this should not happen'
  756. )
  757. code++
  758. }
  759. process.exit(code)
  760. } catch (err) {
  761. console.error(err)
  762. process.exit(1)
  763. }