back_fill_file_hash.mjs 44 KB

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