back_fill_file_hash.mjs 38 KB

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