back_fill_file_hash.mjs 24 KB

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