back_fill_file_hash.mjs 40 KB

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