back_fill_file_hash.mjs 37 KB

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