back_fill_file_hash.mjs 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414
  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 pLimit from 'p-limit'
  12. import logger from '@overleaf/logger'
  13. import {
  14. batchedUpdate,
  15. objectIdFromInput,
  16. renderObjectId,
  17. READ_PREFERENCE_SECONDARY,
  18. } from '@overleaf/mongo-utils/batchedUpdate.js'
  19. import OError from '@overleaf/o-error'
  20. import {
  21. AlreadyWrittenError,
  22. NoKEKMatchedError,
  23. NotFoundError,
  24. } from '@overleaf/object-persistor/src/Errors.js'
  25. import { backupPersistor, projectBlobsBucket } from '../lib/backupPersistor.mjs'
  26. import {
  27. BlobStore,
  28. GLOBAL_BLOBS,
  29. loadGlobalBlobs,
  30. getProjectBlobsBatch,
  31. getStringLengthOfFile,
  32. makeBlobForFile,
  33. makeProjectKey,
  34. } from '../lib/blob_store/index.js'
  35. import { backedUpBlobs as backedUpBlobsCollection, db } from '../lib/mongodb.js'
  36. import filestorePersistor from '../lib/persistor.js'
  37. import commandLineArgs from 'command-line-args'
  38. // Silence warning.
  39. Events.setMaxListeners(20)
  40. // Enable caching for ObjectId.toString()
  41. ObjectId.cacheHexString = true
  42. /**
  43. * @typedef {import("overleaf-editor-core").Blob} Blob
  44. * @typedef {import("perf_hooks").EventLoopUtilization} EventLoopUtilization
  45. * @typedef {import("mongodb").Collection} Collection
  46. * @typedef {import("@overleaf/object-persistor/src/PerProjectEncryptedS3Persistor").CachedPerProjectEncryptedS3Persistor} CachedPerProjectEncryptedS3Persistor
  47. */
  48. /**
  49. * @typedef {Object} FileRef
  50. * @property {ObjectId} _id
  51. * @property {string} hash
  52. */
  53. /**
  54. * @typedef {Object} Folder
  55. * @property {Array<Folder>} folders
  56. * @property {Array<FileRef>} fileRefs
  57. */
  58. /**
  59. * @typedef {Object} DeletedFileRef
  60. * @property {ObjectId} _id
  61. * @property {ObjectId} projectId
  62. * @property {string} hash
  63. */
  64. /**
  65. * @typedef {Object} Project
  66. * @property {ObjectId} _id
  67. * @property {Array<Folder>} rootFolder
  68. * @property {{history: {id: (number|string)}}} overleaf
  69. */
  70. /**
  71. * @typedef {Object} QueueEntry
  72. * @property {ProjectContext} ctx
  73. * @property {string} cacheKey
  74. * @property {string} [fileId]
  75. * @property {string} path
  76. * @property {string} [hash]
  77. * @property {Blob} [blob]
  78. */
  79. /**
  80. * @return {{PROCESS_HASHED_FILES: boolean, PROCESS_DELETED_FILES: boolean, LOGGING_IDENTIFIER: string, BATCH_RANGE_START: string, PROCESS_BLOBS: boolean, BATCH_RANGE_END: string, PROCESS_NON_DELETED_PROJECTS: boolean, PROCESS_DELETED_PROJECTS: boolean, COLLECT_BACKED_UP_BLOBS: boolean}}
  81. */
  82. function parseArgs() {
  83. const PUBLIC_LAUNCH_DATE = new Date('2012-01-01T00:00:00Z')
  84. const args = commandLineArgs([
  85. { name: 'processNonDeletedProjects', type: String, defaultValue: 'false' },
  86. { name: 'processDeletedProjects', type: String, defaultValue: 'false' },
  87. { name: 'processDeletedFiles', type: String, defaultValue: 'false' },
  88. { name: 'processHashedFiles', type: String, defaultValue: 'false' },
  89. { name: 'processBlobs', type: String, defaultValue: 'true' },
  90. { name: 'collectBackedUpBlobs', type: String, defaultValue: 'true' },
  91. {
  92. name: 'BATCH_RANGE_START',
  93. type: String,
  94. defaultValue: PUBLIC_LAUNCH_DATE.toISOString(),
  95. },
  96. {
  97. name: 'BATCH_RANGE_END',
  98. type: String,
  99. defaultValue: new Date().toISOString(),
  100. },
  101. { name: 'LOGGING_IDENTIFIER', type: String, defaultValue: '' },
  102. ])
  103. /**
  104. * commandLineArgs cannot handle --foo=false, so go the long way
  105. * @param {string} name
  106. * @return {boolean}
  107. */
  108. function boolVal(name) {
  109. const v = args[name]
  110. if (['true', 'false'].includes(v)) return v === 'true'
  111. throw new Error(`expected "true" or "false" for boolean option ${name}`)
  112. }
  113. const BATCH_RANGE_START = objectIdFromInput(
  114. args['BATCH_RANGE_START']
  115. ).toString()
  116. const BATCH_RANGE_END = objectIdFromInput(args['BATCH_RANGE_END']).toString()
  117. return {
  118. PROCESS_NON_DELETED_PROJECTS: boolVal('processNonDeletedProjects'),
  119. PROCESS_DELETED_PROJECTS: boolVal('processDeletedProjects'),
  120. PROCESS_BLOBS: boolVal('processBlobs'),
  121. PROCESS_DELETED_FILES: boolVal('processDeletedFiles'),
  122. PROCESS_HASHED_FILES: boolVal('processHashedFiles'),
  123. COLLECT_BACKED_UP_BLOBS: boolVal('collectBackedUpBlobs'),
  124. BATCH_RANGE_START,
  125. BATCH_RANGE_END,
  126. LOGGING_IDENTIFIER: args['LOGGING_IDENTIFIER'] || BATCH_RANGE_START,
  127. }
  128. }
  129. const {
  130. PROCESS_NON_DELETED_PROJECTS,
  131. PROCESS_DELETED_PROJECTS,
  132. PROCESS_BLOBS,
  133. PROCESS_DELETED_FILES,
  134. PROCESS_HASHED_FILES,
  135. COLLECT_BACKED_UP_BLOBS,
  136. BATCH_RANGE_START,
  137. BATCH_RANGE_END,
  138. LOGGING_IDENTIFIER,
  139. } = parseArgs()
  140. // We need to handle the start and end differently as ids of deleted projects are created at time of deletion.
  141. if (process.env.BATCH_RANGE_START || process.env.BATCH_RANGE_END) {
  142. throw new Error('use --BATCH_RANGE_START and --BATCH_RANGE_END')
  143. }
  144. // Concurrency for downloading from GCS and updating hashes in mongo
  145. const CONCURRENCY = parseInt(process.env.CONCURRENCY || '100', 10)
  146. const CONCURRENT_BATCHES = parseInt(process.env.CONCURRENT_BATCHES || '2', 10)
  147. // Retries for processing a given file
  148. const RETRIES = parseInt(process.env.RETRIES || '10', 10)
  149. const RETRY_DELAY_MS = parseInt(process.env.RETRY_DELAY_MS || '100', 10)
  150. const USER_FILES_BUCKET_NAME = process.env.USER_FILES_BUCKET_NAME || ''
  151. if (!USER_FILES_BUCKET_NAME) {
  152. throw new Error('env var USER_FILES_BUCKET_NAME is missing')
  153. }
  154. const RETRY_FILESTORE_404 = process.env.RETRY_FILESTORE_404 === 'true'
  155. const BUFFER_DIR = fs.mkdtempSync(
  156. process.env.BUFFER_DIR_PREFIX || '/tmp/back_fill_file_hash-'
  157. )
  158. // https://nodejs.org/api/stream.html#streamgetdefaulthighwatermarkobjectmode
  159. const STREAM_HIGH_WATER_MARK = parseInt(
  160. process.env.STREAM_HIGH_WATER_MARK || (64 * 1024).toString(),
  161. 10
  162. )
  163. const LOGGING_INTERVAL = parseInt(process.env.LOGGING_INTERVAL || '60000', 10)
  164. const projectsCollection = db.collection('projects')
  165. const deletedProjectsCollection = db.collection('deletedProjects')
  166. const deletedFilesCollection = db.collection('deletedFiles')
  167. const concurrencyLimit = pLimit(CONCURRENCY)
  168. /**
  169. * @template T
  170. * @template V
  171. * @param {Array<T>} array
  172. * @param {(arg: T) => Promise<V>} fn
  173. * @return {Promise<Array<Awaited<V>>>}
  174. */
  175. async function processConcurrently(array, fn) {
  176. return await Promise.all(array.map(x => concurrencyLimit(() => fn(x))))
  177. }
  178. const STATS = {
  179. projects: 0,
  180. blobs: 0,
  181. backedUpBlobs: 0,
  182. filesWithHash: 0,
  183. filesWithoutHash: 0,
  184. filesDuplicated: 0,
  185. filesRetries: 0,
  186. filesFailed: 0,
  187. fileTreeUpdated: 0,
  188. badFileTrees: 0,
  189. globalBlobsCount: 0,
  190. globalBlobsEgress: 0,
  191. projectDeleted: 0,
  192. projectHardDeleted: 0,
  193. fileHardDeleted: 0,
  194. mongoUpdates: 0,
  195. deduplicatedWriteToAWSLocalCount: 0,
  196. deduplicatedWriteToAWSLocalEgress: 0,
  197. deduplicatedWriteToAWSRemoteCount: 0,
  198. deduplicatedWriteToAWSRemoteEgress: 0,
  199. readFromGCSCount: 0,
  200. readFromGCSIngress: 0,
  201. writeToAWSCount: 0,
  202. writeToAWSEgress: 0,
  203. writeToGCSCount: 0,
  204. writeToGCSEgress: 0,
  205. }
  206. const processStart = performance.now()
  207. let lastLogTS = processStart
  208. let lastLog = Object.assign({}, STATS)
  209. let lastEventLoopStats = performance.eventLoopUtilization()
  210. /**
  211. * @param {number} v
  212. * @param {number} ms
  213. */
  214. function toMiBPerSecond(v, ms) {
  215. const ONE_MiB = 1024 * 1024
  216. return v / ONE_MiB / (ms / 1000)
  217. }
  218. /**
  219. * @param {any} stats
  220. * @param {number} ms
  221. * @return {{writeToAWSThroughputMiBPerSecond: number, readFromGCSThroughputMiBPerSecond: number}}
  222. */
  223. function bandwidthStats(stats, ms) {
  224. return {
  225. readFromGCSThroughputMiBPerSecond: toMiBPerSecond(
  226. stats.readFromGCSIngress,
  227. ms
  228. ),
  229. writeToAWSThroughputMiBPerSecond: toMiBPerSecond(
  230. stats.writeToAWSEgress,
  231. ms
  232. ),
  233. }
  234. }
  235. /**
  236. * @param {EventLoopUtilization} nextEventLoopStats
  237. * @param {number} now
  238. * @return {Object}
  239. */
  240. function computeDiff(nextEventLoopStats, now) {
  241. const ms = now - lastLogTS
  242. lastLogTS = now
  243. const diff = {
  244. eventLoop: performance.eventLoopUtilization(
  245. nextEventLoopStats,
  246. lastEventLoopStats
  247. ),
  248. }
  249. for (const [name, v] of Object.entries(STATS)) {
  250. diff[name] = v - lastLog[name]
  251. }
  252. return Object.assign(diff, bandwidthStats(diff, ms))
  253. }
  254. /**
  255. * @param {boolean} isLast
  256. */
  257. function printStats(isLast = false) {
  258. const now = performance.now()
  259. const nextEventLoopStats = performance.eventLoopUtilization()
  260. const logLine = JSON.stringify({
  261. time: new Date(),
  262. LOGGING_IDENTIFIER,
  263. ...STATS,
  264. ...bandwidthStats(STATS, now - processStart),
  265. eventLoop: nextEventLoopStats,
  266. diff: computeDiff(nextEventLoopStats, now),
  267. deferredBatches: Array.from(deferredBatches.keys()),
  268. })
  269. if (isLast) {
  270. console.warn(logLine)
  271. } else {
  272. console.log(logLine)
  273. }
  274. lastEventLoopStats = nextEventLoopStats
  275. lastLog = Object.assign({}, STATS)
  276. }
  277. setInterval(printStats, LOGGING_INTERVAL)
  278. let gracefulShutdownInitiated = false
  279. process.on('SIGINT', handleSignal)
  280. process.on('SIGTERM', handleSignal)
  281. function handleSignal() {
  282. gracefulShutdownInitiated = true
  283. console.warn('graceful shutdown initiated, draining queue')
  284. }
  285. /**
  286. * @param {QueueEntry} entry
  287. * @return {Promise<string>}
  288. */
  289. async function processFileWithCleanup(entry) {
  290. const {
  291. ctx: { projectId },
  292. cacheKey,
  293. } = entry
  294. const filePath = Path.join(BUFFER_DIR, projectId.toString() + cacheKey)
  295. try {
  296. return await processFile(entry, filePath)
  297. } finally {
  298. await Promise.all([
  299. fs.promises.rm(filePath, { force: true }),
  300. fs.promises.rm(filePath + GZ_SUFFIX, { force: true }),
  301. ])
  302. }
  303. }
  304. /**
  305. * @param {QueueEntry} entry
  306. * @param {string} filePath
  307. * @return {Promise<string>}
  308. */
  309. async function processFile(entry, filePath) {
  310. for (let attempt = 0; attempt < RETRIES; attempt++) {
  311. try {
  312. return await processFileOnce(entry, filePath)
  313. } catch (err) {
  314. if (gracefulShutdownInitiated) throw err
  315. if (err instanceof NotFoundError) {
  316. const { bucketName } = OError.getFullInfo(err)
  317. if (bucketName === USER_FILES_BUCKET_NAME && !RETRY_FILESTORE_404) {
  318. throw err // disable retries for not found in filestore bucket case
  319. }
  320. }
  321. if (err instanceof NoKEKMatchedError) {
  322. throw err // disable retries when upload to S3 will fail again
  323. }
  324. STATS.filesRetries++
  325. const {
  326. ctx: { projectId },
  327. fileId,
  328. hash,
  329. path,
  330. } = entry
  331. logger.warn(
  332. { err, projectId, fileId, hash, path, attempt },
  333. 'failed to process file, trying again'
  334. )
  335. const jitter = Math.random() * RETRY_DELAY_MS
  336. await setTimeout(RETRY_DELAY_MS + jitter)
  337. }
  338. }
  339. return await processFileOnce(entry, filePath)
  340. }
  341. /**
  342. * @param {QueueEntry} entry
  343. * @param {string} filePath
  344. * @return {Promise<string>}
  345. */
  346. async function processFileOnce(entry, filePath) {
  347. const {
  348. ctx: { projectId, historyId },
  349. fileId,
  350. } = entry
  351. const blobStore = new BlobStore(historyId)
  352. if (entry.blob) {
  353. const { blob } = entry
  354. const hash = blob.getHash()
  355. if (entry.ctx.hasBackedUpBlob(hash)) {
  356. STATS.deduplicatedWriteToAWSLocalCount++
  357. STATS.deduplicatedWriteToAWSLocalEgress += estimateBlobSize(blob)
  358. return hash
  359. }
  360. entry.ctx.recordPendingBlob(hash)
  361. STATS.readFromGCSCount++
  362. const src = await blobStore.getStream(hash)
  363. const dst = fs.createWriteStream(filePath, {
  364. highWaterMark: STREAM_HIGH_WATER_MARK,
  365. })
  366. try {
  367. await Stream.promises.pipeline(src, dst)
  368. } finally {
  369. STATS.readFromGCSIngress += dst.bytesWritten
  370. }
  371. await uploadBlobToAWS(entry, blob, filePath)
  372. return hash
  373. }
  374. if (entry.hash && entry.ctx.hasBackedUpBlob(entry.hash)) {
  375. STATS.deduplicatedWriteToAWSLocalCount++
  376. const blob = entry.ctx.getCachedHistoryBlob(entry.hash)
  377. // blob might not exist on re-run with --PROCESS_BLOBS=false
  378. if (blob) STATS.deduplicatedWriteToAWSLocalEgress += estimateBlobSize(blob)
  379. return entry.hash
  380. }
  381. STATS.readFromGCSCount++
  382. const src = await filestorePersistor.getObjectStream(
  383. USER_FILES_BUCKET_NAME,
  384. `${projectId}/${fileId}`
  385. )
  386. const dst = fs.createWriteStream(filePath, {
  387. highWaterMark: STREAM_HIGH_WATER_MARK,
  388. })
  389. try {
  390. await Stream.promises.pipeline(src, dst)
  391. } finally {
  392. STATS.readFromGCSIngress += dst.bytesWritten
  393. }
  394. const blob = await makeBlobForFile(filePath)
  395. blob.setStringLength(
  396. await getStringLengthOfFile(blob.getByteLength(), filePath)
  397. )
  398. const hash = blob.getHash()
  399. if (entry.hash && hash !== entry.hash) {
  400. throw new OError('hash mismatch', { entry, hash })
  401. }
  402. if (GLOBAL_BLOBS.has(hash)) {
  403. STATS.globalBlobsCount++
  404. STATS.globalBlobsEgress += estimateBlobSize(blob)
  405. return hash
  406. }
  407. if (entry.ctx.hasBackedUpBlob(hash)) {
  408. STATS.deduplicatedWriteToAWSLocalCount++
  409. STATS.deduplicatedWriteToAWSLocalEgress += estimateBlobSize(blob)
  410. return hash
  411. }
  412. entry.ctx.recordPendingBlob(hash)
  413. try {
  414. await uploadBlobToGCS(blobStore, entry, blob, hash, filePath)
  415. await uploadBlobToAWS(entry, blob, filePath)
  416. } catch (err) {
  417. entry.ctx.recordFailedBlob(hash)
  418. throw err
  419. }
  420. return hash
  421. }
  422. /**
  423. * @param {BlobStore} blobStore
  424. * @param {QueueEntry} entry
  425. * @param {Blob} blob
  426. * @param {string} hash
  427. * @param {string} filePath
  428. * @return {Promise<void>}
  429. */
  430. async function uploadBlobToGCS(blobStore, entry, blob, hash, filePath) {
  431. if (entry.ctx.getCachedHistoryBlob(hash)) {
  432. return // fast-path using hint from pre-fetched blobs
  433. }
  434. if (!PROCESS_BLOBS) {
  435. // round trip to postgres/mongo when not pre-fetched
  436. const blob = await blobStore.getBlob(hash)
  437. if (blob) {
  438. entry.ctx.recordHistoryBlob(blob)
  439. return
  440. }
  441. }
  442. // blob missing in history-v1, create in GCS and persist in postgres/mongo
  443. STATS.writeToGCSCount++
  444. STATS.writeToGCSEgress += blob.getByteLength()
  445. await blobStore.putBlob(filePath, blob)
  446. entry.ctx.recordHistoryBlob(blob)
  447. }
  448. const GZ_SUFFIX = '.gz'
  449. /**
  450. * @param {QueueEntry} entry
  451. * @param {Blob} blob
  452. * @param {string} filePath
  453. * @return {Promise<void>}
  454. */
  455. async function uploadBlobToAWS(entry, blob, filePath) {
  456. const { historyId } = entry.ctx
  457. let backupSource
  458. let contentEncoding
  459. const md5 = Crypto.createHash('md5')
  460. let size
  461. if (blob.getStringLength()) {
  462. const filePathCompressed = filePath + GZ_SUFFIX
  463. backupSource = filePathCompressed
  464. contentEncoding = 'gzip'
  465. size = 0
  466. await Stream.promises.pipeline(
  467. fs.createReadStream(filePath, { highWaterMark: STREAM_HIGH_WATER_MARK }),
  468. zLib.createGzip(),
  469. async function* (source) {
  470. for await (const chunk of source) {
  471. size += chunk.byteLength
  472. md5.update(chunk)
  473. yield chunk
  474. }
  475. },
  476. fs.createWriteStream(filePathCompressed, {
  477. highWaterMark: STREAM_HIGH_WATER_MARK,
  478. })
  479. )
  480. } else {
  481. backupSource = filePath
  482. size = blob.getByteLength()
  483. await Stream.promises.pipeline(
  484. fs.createReadStream(filePath, { highWaterMark: STREAM_HIGH_WATER_MARK }),
  485. md5
  486. )
  487. }
  488. const backendKeyPath = makeProjectKey(historyId, blob.getHash())
  489. const persistor = await entry.ctx.getCachedPersistor(backendKeyPath)
  490. try {
  491. STATS.writeToAWSCount++
  492. await persistor.sendStream(
  493. projectBlobsBucket,
  494. backendKeyPath,
  495. fs.createReadStream(backupSource, {
  496. highWaterMark: STREAM_HIGH_WATER_MARK,
  497. }),
  498. {
  499. contentEncoding,
  500. contentType: 'application/octet-stream',
  501. contentLength: size,
  502. sourceMd5: md5.digest('hex'),
  503. ifNoneMatch: '*', // de-duplicate write (we pay for the request, but avoid egress)
  504. }
  505. )
  506. STATS.writeToAWSEgress += size
  507. } catch (err) {
  508. if (err instanceof AlreadyWrittenError) {
  509. STATS.deduplicatedWriteToAWSRemoteCount++
  510. STATS.deduplicatedWriteToAWSRemoteEgress += size
  511. } else {
  512. STATS.writeToAWSEgress += size
  513. throw err
  514. }
  515. }
  516. entry.ctx.recordBackedUpBlob(blob.getHash())
  517. }
  518. /**
  519. * @param {Array<QueueEntry>} files
  520. * @return {Promise<void>}
  521. */
  522. async function processFiles(files) {
  523. await processConcurrently(
  524. files,
  525. /**
  526. * @param {QueueEntry} entry
  527. * @return {Promise<void>}
  528. */
  529. async function (entry) {
  530. if (gracefulShutdownInitiated) return
  531. try {
  532. await entry.ctx.processFile(entry)
  533. } catch (err) {
  534. STATS.filesFailed++
  535. const {
  536. ctx: { projectId },
  537. fileId,
  538. hash,
  539. path,
  540. } = entry
  541. logger.error(
  542. { err, projectId, fileId, hash, path },
  543. 'failed to process file'
  544. )
  545. }
  546. }
  547. )
  548. }
  549. /** @type {Map<string, Promise>} */
  550. const deferredBatches = new Map()
  551. async function waitForDeferredQueues() {
  552. // Wait for ALL pending batches to finish, especially wait for their mongo
  553. // writes to finish to avoid extra work when resuming the batch.
  554. const all = await Promise.allSettled(deferredBatches.values())
  555. // Now that all batches finished, we can throw if needed.
  556. for (const res of all) {
  557. if (res.status === 'rejected') {
  558. throw res.reason
  559. }
  560. }
  561. }
  562. /**
  563. * @param {Array<Project>} batch
  564. * @param {string} prefix
  565. */
  566. async function queueNextBatch(batch, prefix = 'rootFolder.0') {
  567. if (gracefulShutdownInitiated) {
  568. throw new Error('graceful shutdown: aborting batch processing')
  569. }
  570. // Read ids now, the batch will get trimmed by processBatch shortly.
  571. const start = renderObjectId(batch[0]._id)
  572. const end = renderObjectId(batch[batch.length - 1]._id)
  573. const deferred = processBatch(batch, prefix)
  574. .then(() => {
  575. console.error(`Actually completed batch ending ${end}`)
  576. })
  577. .catch(err => {
  578. logger.error({ err, start, end }, 'fatal error processing batch')
  579. throw err
  580. })
  581. .finally(() => {
  582. deferredBatches.delete(end)
  583. })
  584. deferredBatches.set(end, deferred)
  585. if (deferredBatches.size >= CONCURRENT_BATCHES) {
  586. // Wait for any of the deferred batches to finish before fetching the next.
  587. // We should never have more than CONCURRENT_BATCHES batches in memory.
  588. await Promise.race(deferredBatches.values())
  589. }
  590. }
  591. /**
  592. * @param {Array<Project>} batch
  593. * @param {string} prefix
  594. * @return {Promise<void>}
  595. */
  596. async function processBatch(batch, prefix = 'rootFolder.0') {
  597. const [deletedFiles, { nBlobs, blobs }, { nBackedUpBlobs, backedUpBlobs }] =
  598. await Promise.all([
  599. collectDeletedFiles(batch),
  600. collectProjectBlobs(batch),
  601. collectBackedUpBlobs(batch),
  602. ])
  603. const files = Array.from(
  604. findFileInBatch(batch, prefix, deletedFiles, blobs, backedUpBlobs)
  605. )
  606. STATS.projects += batch.length
  607. STATS.blobs += nBlobs
  608. STATS.backedUpBlobs += nBackedUpBlobs
  609. // GC
  610. batch.length = 0
  611. deletedFiles.clear()
  612. blobs.clear()
  613. backedUpBlobs.clear()
  614. // The files are currently ordered by project-id.
  615. // Order them by file-id ASC then blobs ASC to
  616. // - process files before blobs
  617. // - avoid head-of-line blocking from many project-files waiting on the generation of the projects DEK (round trip to AWS)
  618. // - bonus: increase chance of de-duplicating write to AWS
  619. files.sort(
  620. /**
  621. * @param {QueueEntry} a
  622. * @param {QueueEntry} b
  623. * @return {number}
  624. */
  625. function (a, b) {
  626. if (a.fileId && b.fileId) return a.fileId > b.fileId ? 1 : -1
  627. if (a.hash && b.hash) return a.hash > b.hash ? 1 : -1
  628. if (a.fileId) return -1
  629. return 1
  630. }
  631. )
  632. await processFiles(files)
  633. await processConcurrently(
  634. files,
  635. /**
  636. * @param {QueueEntry} entry
  637. * @return {Promise<void>}
  638. */
  639. async function (entry) {
  640. await entry.ctx.flushMongoQueues()
  641. }
  642. )
  643. }
  644. /**
  645. * @param {Array<{project: Project}>} batch
  646. * @return {Promise<void>}
  647. */
  648. async function handleDeletedFileTreeBatch(batch) {
  649. await queueNextBatch(
  650. batch.map(d => d.project),
  651. 'project.rootFolder.0'
  652. )
  653. }
  654. /**
  655. * @param {QueueEntry} entry
  656. * @return {Promise<boolean>}
  657. */
  658. async function tryUpdateFileRefInMongo(entry) {
  659. if (entry.path === MONGO_PATH_DELETED_FILE) {
  660. return await tryUpdateDeletedFileRefInMongo(entry)
  661. } else if (entry.path.startsWith('project.')) {
  662. return await tryUpdateFileRefInMongoInDeletedProject(entry)
  663. }
  664. STATS.mongoUpdates++
  665. const result = await projectsCollection.updateOne(
  666. {
  667. _id: entry.ctx.projectId,
  668. [`${entry.path}._id`]: new ObjectId(entry.fileId),
  669. },
  670. {
  671. $set: { [`${entry.path}.hash`]: entry.hash },
  672. }
  673. )
  674. return result.matchedCount === 1
  675. }
  676. /**
  677. * @param {QueueEntry} entry
  678. * @return {Promise<boolean>}
  679. */
  680. async function tryUpdateDeletedFileRefInMongo(entry) {
  681. STATS.mongoUpdates++
  682. const result = await deletedFilesCollection.updateOne(
  683. {
  684. _id: new ObjectId(entry.fileId),
  685. projectId: entry.ctx.projectId,
  686. },
  687. { $set: { hash: entry.hash } }
  688. )
  689. return result.matchedCount === 1
  690. }
  691. /**
  692. * @param {QueueEntry} entry
  693. * @return {Promise<boolean>}
  694. */
  695. async function tryUpdateFileRefInMongoInDeletedProject(entry) {
  696. STATS.mongoUpdates++
  697. const result = await deletedProjectsCollection.updateOne(
  698. {
  699. 'deleterData.deletedProjectId': entry.ctx.projectId,
  700. [`${entry.path}._id`]: new ObjectId(entry.fileId),
  701. },
  702. {
  703. $set: { [`${entry.path}.hash`]: entry.hash },
  704. }
  705. )
  706. return result.matchedCount === 1
  707. }
  708. const RETRY_UPDATE_HASH = 100
  709. /**
  710. * @param {QueueEntry} entry
  711. * @return {Promise<void>}
  712. */
  713. async function updateFileRefInMongo(entry) {
  714. if (await tryUpdateFileRefInMongo(entry)) return
  715. const { fileId } = entry
  716. const { projectId } = entry.ctx
  717. for (let i = 0; i < RETRY_UPDATE_HASH; i++) {
  718. let prefix = 'rootFolder.0'
  719. let p = await projectsCollection.findOne(
  720. { _id: projectId },
  721. { projection: { rootFolder: 1 } }
  722. )
  723. if (!p) {
  724. STATS.projectDeleted++
  725. prefix = 'project.rootFolder.0'
  726. const deletedProject = await deletedProjectsCollection.findOne(
  727. {
  728. 'deleterData.deletedProjectId': projectId,
  729. project: { $exists: true },
  730. },
  731. { projection: { 'project.rootFolder': 1 } }
  732. )
  733. p = deletedProject?.project
  734. if (!p) {
  735. STATS.projectHardDeleted++
  736. console.warn(
  737. 'bug: project hard-deleted while processing',
  738. projectId,
  739. fileId
  740. )
  741. return
  742. }
  743. }
  744. let found = false
  745. for (const e of findFiles(entry.ctx, p.rootFolder[0], prefix)) {
  746. found = e.fileId === fileId
  747. if (!found) continue
  748. if (await tryUpdateFileRefInMongo(e)) return
  749. break
  750. }
  751. if (!found) {
  752. if (await tryUpdateDeletedFileRefInMongo(entry)) return
  753. STATS.fileHardDeleted++
  754. console.warn('bug: file hard-deleted while processing', projectId, fileId)
  755. return
  756. }
  757. STATS.fileTreeUpdated++
  758. }
  759. throw new OError(
  760. 'file-tree updated repeatedly while trying to add hash',
  761. entry
  762. )
  763. }
  764. /**
  765. * @param {ProjectContext} ctx
  766. * @param {Folder} folder
  767. * @param {string} path
  768. * @param {boolean} isInputLoop
  769. * @return Generator<QueueEntry>
  770. */
  771. function* findFiles(ctx, folder, path, isInputLoop = false) {
  772. if (!folder || typeof folder !== 'object') {
  773. ctx.fileTreeBroken = true
  774. logger.warn({ projectId: ctx.projectId, path }, 'bad file-tree, bad folder')
  775. return
  776. }
  777. if (!Array.isArray(folder.folders)) {
  778. folder.folders = []
  779. ctx.fileTreeBroken = true
  780. logger.warn(
  781. { projectId: ctx.projectId, path: `${path}.folders` },
  782. 'bad file-tree, bad folders'
  783. )
  784. }
  785. let i = 0
  786. for (const child of folder.folders) {
  787. const idx = i++
  788. yield* findFiles(ctx, child, `${path}.folders.${idx}`, isInputLoop)
  789. }
  790. if (!Array.isArray(folder.fileRefs)) {
  791. folder.fileRefs = []
  792. ctx.fileTreeBroken = true
  793. logger.warn(
  794. { projectId: ctx.projectId, path: `${path}.fileRefs` },
  795. 'bad file-tree, bad fileRefs'
  796. )
  797. }
  798. i = 0
  799. for (const fileRef of folder.fileRefs) {
  800. const idx = i++
  801. const fileRefPath = `${path}.fileRefs.${idx}`
  802. if (!fileRef._id || !(fileRef._id instanceof ObjectId)) {
  803. ctx.fileTreeBroken = true
  804. logger.warn(
  805. { projectId: ctx.projectId, path: fileRefPath },
  806. 'bad file-tree, bad fileRef id'
  807. )
  808. continue
  809. }
  810. const fileId = fileRef._id.toString()
  811. if (PROCESS_HASHED_FILES && fileRef.hash) {
  812. if (ctx.canSkipProcessingHashedFile(fileRef.hash)) continue
  813. if (isInputLoop) {
  814. ctx.remainingQueueEntries++
  815. STATS.filesWithHash++
  816. }
  817. yield {
  818. ctx,
  819. cacheKey: fileRef.hash,
  820. fileId,
  821. path: MONGO_PATH_SKIP_WRITE_HASH_TO_FILE_TREE,
  822. hash: fileRef.hash,
  823. }
  824. }
  825. if (!fileRef.hash) {
  826. if (isInputLoop) {
  827. ctx.remainingQueueEntries++
  828. STATS.filesWithoutHash++
  829. }
  830. yield {
  831. ctx,
  832. cacheKey: fileId,
  833. fileId,
  834. path: fileRefPath,
  835. }
  836. }
  837. }
  838. }
  839. /**
  840. * @param {Array<Project>} projects
  841. * @param {string} prefix
  842. * @param {Map<string,Array<DeletedFileRef>>} deletedFiles
  843. * @param {Map<string,Array<Blob>>} blobs
  844. * @param {Map<string,Array<string>>} backedUpBlobs
  845. * @return Generator<QueueEntry>
  846. */
  847. function* findFileInBatch(
  848. projects,
  849. prefix,
  850. deletedFiles,
  851. blobs,
  852. backedUpBlobs
  853. ) {
  854. for (const project of projects) {
  855. const projectIdS = project._id.toString()
  856. const historyIdS = project.overleaf.history.id.toString()
  857. const projectBlobs = blobs.get(historyIdS) || []
  858. const projectBackedUpBlobs = new Set(backedUpBlobs.get(projectIdS) || [])
  859. const projectDeletedFiles = deletedFiles.get(projectIdS) || []
  860. const ctx = new ProjectContext(
  861. project._id,
  862. historyIdS,
  863. projectBlobs,
  864. projectBackedUpBlobs
  865. )
  866. for (const fileRef of projectDeletedFiles) {
  867. const fileId = fileRef._id.toString()
  868. if (fileRef.hash) {
  869. if (ctx.canSkipProcessingHashedFile(fileRef.hash)) continue
  870. ctx.remainingQueueEntries++
  871. STATS.filesWithHash++
  872. yield {
  873. ctx,
  874. cacheKey: fileRef.hash,
  875. fileId,
  876. hash: fileRef.hash,
  877. path: MONGO_PATH_SKIP_WRITE_HASH_TO_FILE_TREE,
  878. }
  879. } else {
  880. ctx.remainingQueueEntries++
  881. STATS.filesWithoutHash++
  882. yield { ctx, cacheKey: fileId, fileId, path: MONGO_PATH_DELETED_FILE }
  883. }
  884. }
  885. for (const blob of projectBlobs) {
  886. if (projectBackedUpBlobs.has(blob.getHash())) continue
  887. ctx.remainingQueueEntries++
  888. yield {
  889. ctx,
  890. cacheKey: blob.getHash(),
  891. path: MONGO_PATH_SKIP_WRITE_HASH_TO_FILE_TREE,
  892. blob,
  893. hash: blob.getHash(),
  894. }
  895. }
  896. try {
  897. yield* findFiles(ctx, project.rootFolder?.[0], prefix, true)
  898. } catch (err) {
  899. logger.error(
  900. { err, projectId: projectIdS },
  901. 'bad file-tree, processing error'
  902. )
  903. } finally {
  904. if (ctx.fileTreeBroken) STATS.badFileTrees++
  905. }
  906. }
  907. }
  908. /**
  909. * @param {Array<Project>} batch
  910. * @return {Promise<{nBlobs: number, blobs: Map<string, Array<Blob>>}>}
  911. */
  912. async function collectProjectBlobs(batch) {
  913. if (!PROCESS_BLOBS) return { nBlobs: 0, blobs: new Map() }
  914. return await getProjectBlobsBatch(batch.map(p => p.overleaf.history.id))
  915. }
  916. /**
  917. * @param {Array<Project>} projects
  918. * @return {Promise<Map<string, Array<DeletedFileRef>>>}
  919. */
  920. async function collectDeletedFiles(projects) {
  921. const deletedFiles = new Map()
  922. if (!PROCESS_DELETED_FILES) return deletedFiles
  923. const cursor = deletedFilesCollection.find(
  924. {
  925. projectId: { $in: projects.map(p => p._id) },
  926. ...(PROCESS_HASHED_FILES
  927. ? {}
  928. : {
  929. hash: { $exists: false },
  930. }),
  931. },
  932. {
  933. projection: { _id: 1, projectId: 1, hash: 1 },
  934. readPreference: READ_PREFERENCE_SECONDARY,
  935. sort: { projectId: 1 },
  936. }
  937. )
  938. for await (const deletedFileRef of cursor) {
  939. const projectId = deletedFileRef.projectId.toString()
  940. const found = deletedFiles.get(projectId)
  941. if (found) {
  942. found.push(deletedFileRef)
  943. } else {
  944. deletedFiles.set(projectId, [deletedFileRef])
  945. }
  946. }
  947. return deletedFiles
  948. }
  949. /**
  950. * @param {Array<Project>} projects
  951. * @return {Promise<{nBackedUpBlobs:number,backedUpBlobs:Map<string,Array<string>>}>}
  952. */
  953. async function collectBackedUpBlobs(projects) {
  954. let nBackedUpBlobs = 0
  955. const backedUpBlobs = new Map()
  956. if (!COLLECT_BACKED_UP_BLOBS) return { nBackedUpBlobs, backedUpBlobs }
  957. const cursor = backedUpBlobsCollection.find(
  958. { _id: { $in: projects.map(p => p._id) } },
  959. {
  960. readPreference: READ_PREFERENCE_SECONDARY,
  961. sort: { _id: 1 },
  962. }
  963. )
  964. for await (const record of cursor) {
  965. const blobs = record.blobs.map(b => b.toString('hex'))
  966. backedUpBlobs.set(record._id.toString(), blobs)
  967. nBackedUpBlobs += blobs.length
  968. }
  969. return { nBackedUpBlobs, backedUpBlobs }
  970. }
  971. const BATCH_HASH_WRITES = 1_000
  972. const BATCH_FILE_UPDATES = 100
  973. const MONGO_PATH_DELETED_FILE = 'deleted-file'
  974. const MONGO_PATH_SKIP_WRITE_HASH_TO_FILE_TREE = 'skip-write-to-file-tree'
  975. class ProjectContext {
  976. /** @type {Promise<CachedPerProjectEncryptedS3Persistor> | null} */
  977. #cachedPersistorPromise = null
  978. /** @type {Set<string>} */
  979. #backedUpBlobs
  980. /** @type {Map<string, Blob>} */
  981. #historyBlobs
  982. /** @type {number} */
  983. remainingQueueEntries = 0
  984. /** @type {boolean} */
  985. fileTreeBroken = false
  986. /**
  987. * @param {ObjectId} projectId
  988. * @param {string} historyId
  989. * @param {Array<Blob>} blobs
  990. * @param {Set<string>} backedUpBlobs
  991. */
  992. constructor(projectId, historyId, blobs, backedUpBlobs) {
  993. this.projectId = projectId
  994. this.historyId = historyId
  995. this.#backedUpBlobs = backedUpBlobs
  996. this.#historyBlobs = new Map(blobs.map(b => [b.getHash(), b]))
  997. }
  998. /**
  999. * @param {string} hash
  1000. * @return {Blob | undefined}
  1001. */
  1002. getCachedHistoryBlob(hash) {
  1003. return this.#historyBlobs.get(hash)
  1004. }
  1005. /**
  1006. * @param {Blob} blob
  1007. */
  1008. recordHistoryBlob(blob) {
  1009. this.#historyBlobs.set(blob.getHash(), blob)
  1010. }
  1011. /**
  1012. * @param {string} hash
  1013. * @return {boolean}
  1014. */
  1015. canSkipProcessingHashedFile(hash) {
  1016. if (this.#historyBlobs.has(hash)) return true // This file will be processed as blob.
  1017. if (GLOBAL_BLOBS.has(hash)) return true // global blob
  1018. return false
  1019. }
  1020. /**
  1021. * @param {string} key
  1022. * @return {Promise<CachedPerProjectEncryptedS3Persistor>}
  1023. */
  1024. getCachedPersistor(key) {
  1025. if (!this.#cachedPersistorPromise) {
  1026. // Fetch DEK once, but only if needed -- upon the first use
  1027. this.#cachedPersistorPromise = this.#getCachedPersistorWithRetries(key)
  1028. }
  1029. return this.#cachedPersistorPromise
  1030. }
  1031. /**
  1032. * @param {string} key
  1033. * @return {Promise<CachedPerProjectEncryptedS3Persistor>}
  1034. */
  1035. async #getCachedPersistorWithRetries(key) {
  1036. // Optimization: Skip GET on DEK in case no blobs are marked as backed up yet.
  1037. let tryGenerateDEKFirst = this.#backedUpBlobs.size === 0
  1038. for (let attempt = 0; attempt < RETRIES; attempt++) {
  1039. try {
  1040. if (tryGenerateDEKFirst) {
  1041. try {
  1042. return await backupPersistor.generateDataEncryptionKey(
  1043. projectBlobsBucket,
  1044. key
  1045. )
  1046. } catch (err) {
  1047. if (err instanceof AlreadyWrittenError) {
  1048. tryGenerateDEKFirst = false
  1049. // fall back to GET below
  1050. } else {
  1051. throw err
  1052. }
  1053. }
  1054. }
  1055. return await backupPersistor.forProject(projectBlobsBucket, key)
  1056. } catch (err) {
  1057. if (gracefulShutdownInitiated) throw err
  1058. if (err instanceof NoKEKMatchedError) {
  1059. throw err
  1060. } else {
  1061. logger.warn(
  1062. { err, projectId: this.projectId, attempt },
  1063. 'failed to get DEK, trying again'
  1064. )
  1065. const jitter = Math.random() * RETRY_DELAY_MS
  1066. await setTimeout(RETRY_DELAY_MS + jitter)
  1067. }
  1068. }
  1069. }
  1070. return await backupPersistor.forProject(projectBlobsBucket, key)
  1071. }
  1072. async flushMongoQueuesIfNeeded() {
  1073. if (this.remainingQueueEntries === 0) {
  1074. await this.flushMongoQueues()
  1075. }
  1076. if (this.#completedBlobs.size > BATCH_HASH_WRITES) {
  1077. await this.#storeBackedUpBlobs()
  1078. }
  1079. if (this.#pendingFileWrites.length > BATCH_FILE_UPDATES) {
  1080. await this.#storeFileHashes()
  1081. }
  1082. }
  1083. async flushMongoQueues() {
  1084. await this.#storeBackedUpBlobs()
  1085. await this.#storeFileHashes()
  1086. }
  1087. /** @type {Set<string>} */
  1088. #pendingBlobs = new Set()
  1089. /** @type {Set<string>} */
  1090. #completedBlobs = new Set()
  1091. async #storeBackedUpBlobs() {
  1092. if (this.#completedBlobs.size === 0) return
  1093. const blobs = Array.from(this.#completedBlobs).map(
  1094. hash => new Binary(Buffer.from(hash, 'hex'))
  1095. )
  1096. this.#completedBlobs.clear()
  1097. STATS.mongoUpdates++
  1098. await backedUpBlobsCollection.updateOne(
  1099. { _id: this.projectId },
  1100. { $addToSet: { blobs: { $each: blobs } } },
  1101. { upsert: true }
  1102. )
  1103. }
  1104. /**
  1105. * @param {string} hash
  1106. */
  1107. recordPendingBlob(hash) {
  1108. this.#pendingBlobs.add(hash)
  1109. }
  1110. /**
  1111. * @param {string} hash
  1112. */
  1113. recordFailedBlob(hash) {
  1114. this.#pendingBlobs.delete(hash)
  1115. }
  1116. /**
  1117. * @param {string} hash
  1118. */
  1119. recordBackedUpBlob(hash) {
  1120. this.#backedUpBlobs.add(hash)
  1121. this.#completedBlobs.add(hash)
  1122. this.#pendingBlobs.delete(hash)
  1123. }
  1124. /**
  1125. * @param {string} hash
  1126. * @return {boolean}
  1127. */
  1128. hasBackedUpBlob(hash) {
  1129. return (
  1130. this.#pendingBlobs.has(hash) ||
  1131. this.#completedBlobs.has(hash) ||
  1132. this.#backedUpBlobs.has(hash)
  1133. )
  1134. }
  1135. /** @type {Array<QueueEntry>} */
  1136. #pendingFileWrites = []
  1137. /**
  1138. * @param {QueueEntry} entry
  1139. */
  1140. queueFileForWritingHash(entry) {
  1141. if (entry.path === MONGO_PATH_SKIP_WRITE_HASH_TO_FILE_TREE) return
  1142. this.#pendingFileWrites.push(entry)
  1143. }
  1144. /**
  1145. * @param {Collection} collection
  1146. * @param {Array<QueueEntry>} entries
  1147. * @param {Object} query
  1148. * @return {Promise<Array<QueueEntry>>}
  1149. */
  1150. async #tryBatchHashWrites(collection, entries, query) {
  1151. if (entries.length === 0) return []
  1152. const update = {}
  1153. for (const entry of entries) {
  1154. query[`${entry.path}._id`] = new ObjectId(entry.fileId)
  1155. update[`${entry.path}.hash`] = entry.hash
  1156. }
  1157. STATS.mongoUpdates++
  1158. const result = await collection.updateOne(query, { $set: update })
  1159. if (result.matchedCount === 1) {
  1160. return [] // all updated
  1161. }
  1162. return entries
  1163. }
  1164. async #storeFileHashes() {
  1165. if (this.#pendingFileWrites.length === 0) return
  1166. const individualUpdates = []
  1167. const projectEntries = []
  1168. const deletedProjectEntries = []
  1169. for (const entry of this.#pendingFileWrites) {
  1170. if (entry.path === MONGO_PATH_DELETED_FILE) {
  1171. individualUpdates.push(entry)
  1172. } else if (entry.path.startsWith('project.')) {
  1173. deletedProjectEntries.push(entry)
  1174. } else {
  1175. projectEntries.push(entry)
  1176. }
  1177. }
  1178. this.#pendingFileWrites.length = 0
  1179. // Try to process them together, otherwise fallback to individual updates and retries.
  1180. individualUpdates.push(
  1181. ...(await this.#tryBatchHashWrites(projectsCollection, projectEntries, {
  1182. _id: this.projectId,
  1183. }))
  1184. )
  1185. individualUpdates.push(
  1186. ...(await this.#tryBatchHashWrites(
  1187. deletedProjectsCollection,
  1188. deletedProjectEntries,
  1189. { 'deleterData.deletedProjectId': this.projectId }
  1190. ))
  1191. )
  1192. for (const entry of individualUpdates) {
  1193. await updateFileRefInMongo(entry)
  1194. }
  1195. }
  1196. /** @type {Map<string, Promise<string>>} */
  1197. #pendingFiles = new Map()
  1198. /**
  1199. * @param {QueueEntry} entry
  1200. */
  1201. async processFile(entry) {
  1202. if (this.#pendingFiles.has(entry.cacheKey)) {
  1203. STATS.filesDuplicated++
  1204. } else {
  1205. this.#pendingFiles.set(entry.cacheKey, processFileWithCleanup(entry))
  1206. }
  1207. try {
  1208. entry.hash = await this.#pendingFiles.get(entry.cacheKey)
  1209. } finally {
  1210. this.remainingQueueEntries--
  1211. }
  1212. this.queueFileForWritingHash(entry)
  1213. await this.flushMongoQueuesIfNeeded()
  1214. }
  1215. }
  1216. /**
  1217. * @param {Blob} blob
  1218. * @return {number}
  1219. */
  1220. function estimateBlobSize(blob) {
  1221. let size = blob.getByteLength()
  1222. if (blob.getStringLength()) {
  1223. // approximation for gzip (25 bytes gzip overhead and 20% compression ratio)
  1224. size = 25 + Math.ceil(size * 0.2)
  1225. }
  1226. return size
  1227. }
  1228. async function processNonDeletedProjects() {
  1229. try {
  1230. await batchedUpdate(
  1231. projectsCollection,
  1232. { 'overleaf.history.id': { $exists: true } },
  1233. queueNextBatch,
  1234. { rootFolder: 1, _id: 1, 'overleaf.history.id': 1 },
  1235. {},
  1236. {
  1237. BATCH_RANGE_START,
  1238. BATCH_RANGE_END,
  1239. }
  1240. )
  1241. } catch (err) {
  1242. gracefulShutdownInitiated = true
  1243. throw err
  1244. } finally {
  1245. await waitForDeferredQueues()
  1246. }
  1247. console.warn('Done updating live projects')
  1248. }
  1249. async function processDeletedProjects() {
  1250. try {
  1251. await batchedUpdate(
  1252. deletedProjectsCollection,
  1253. {
  1254. 'deleterData.deletedProjectId': {
  1255. $gt: new ObjectId(BATCH_RANGE_START),
  1256. $lte: new ObjectId(BATCH_RANGE_END),
  1257. },
  1258. 'project.overleaf.history.id': { $exists: true },
  1259. },
  1260. handleDeletedFileTreeBatch,
  1261. {
  1262. 'project.rootFolder': 1,
  1263. 'project._id': 1,
  1264. 'project.overleaf.history.id': 1,
  1265. }
  1266. )
  1267. } catch (err) {
  1268. gracefulShutdownInitiated = true
  1269. throw err
  1270. } finally {
  1271. await waitForDeferredQueues()
  1272. }
  1273. console.warn('Done updating deleted projects')
  1274. }
  1275. async function main() {
  1276. await loadGlobalBlobs()
  1277. if (PROCESS_NON_DELETED_PROJECTS) {
  1278. await processNonDeletedProjects()
  1279. }
  1280. if (PROCESS_DELETED_PROJECTS) {
  1281. await processDeletedProjects()
  1282. }
  1283. console.warn('Done.')
  1284. }
  1285. try {
  1286. try {
  1287. await main()
  1288. } finally {
  1289. printStats(true)
  1290. try {
  1291. // Perform non-recursive removal of the BUFFER_DIR. Individual files
  1292. // should get removed in parallel as part of batch processing.
  1293. await fs.promises.rmdir(BUFFER_DIR)
  1294. } catch (err) {
  1295. console.error(`cleanup of BUFFER_DIR=${BUFFER_DIR} failed`, err)
  1296. }
  1297. }
  1298. let code = 0
  1299. if (STATS.filesFailed > 0) {
  1300. console.warn('Some files could not be processed, see logs and try again')
  1301. code++
  1302. }
  1303. if (STATS.fileHardDeleted > 0) {
  1304. console.warn(
  1305. 'Some hashes could not be updated as the files were hard-deleted, this should not happen'
  1306. )
  1307. code++
  1308. }
  1309. if (STATS.projectHardDeleted > 0) {
  1310. console.warn(
  1311. 'Some hashes could not be updated as the project was hard-deleted, this should not happen'
  1312. )
  1313. code++
  1314. }
  1315. process.exit(code)
  1316. } catch (err) {
  1317. console.error(err)
  1318. process.exit(1)
  1319. }