back_fill_file_hash.mjs 44 KB

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