back_fill_file_hash.mjs 28 KB

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