back_fill_file_hash.mjs 41 KB

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