backup.mjs 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535
  1. // @ts-check
  2. import logger from '@overleaf/logger'
  3. import commandLineArgs from 'command-line-args'
  4. import { Chunk, History, Snapshot } from 'overleaf-editor-core'
  5. import {
  6. getProjectChunks,
  7. getLatestChunkMetadata,
  8. create,
  9. getBackend,
  10. } from '../lib/chunk_store/index.js'
  11. import { client } from '../lib/mongodb.js'
  12. import redis from '../lib/redis.js'
  13. import knex from '../lib/knex.js'
  14. import { historyStore } from '../lib/history_store.js'
  15. import pLimit from 'p-limit'
  16. import {
  17. GLOBAL_BLOBS,
  18. loadGlobalBlobs,
  19. makeProjectKey,
  20. BlobStore,
  21. } from '../lib/blob_store/index.js'
  22. import {
  23. listPendingBackups,
  24. getBackupStatus,
  25. setBackupVersion,
  26. updateCurrentMetadataIfNotSet,
  27. updatePendingChangeTimestamp,
  28. getBackedUpBlobHashes,
  29. unsetBackedUpBlobHashes,
  30. getHashesFromFileTree,
  31. } from '../lib/backup_store/index.js'
  32. import { backupBlob, downloadBlobToDir } from '../lib/backupBlob.mjs'
  33. import {
  34. backupPersistor,
  35. chunksBucket,
  36. projectBlobsBucket,
  37. } from '../lib/backupPersistor.mjs'
  38. import { backupGenerator } from '../lib/backupGenerator.mjs'
  39. import { promises as fs, createWriteStream } from 'node:fs'
  40. import os from 'node:os'
  41. import path from 'node:path'
  42. import projectKey from '@overleaf/object-persistor/src/ProjectKey.js'
  43. import Crypto from 'node:crypto'
  44. import Stream from 'node:stream'
  45. import { EventEmitter } from 'node:events'
  46. import {
  47. objectIdFromInput,
  48. batchedUpdate,
  49. READ_PREFERENCE_SECONDARY,
  50. } from '@overleaf/mongo-utils/batchedUpdate.js'
  51. import { createGunzip } from 'node:zlib'
  52. import { text } from 'node:stream/consumers'
  53. import { fromStream as blobHashFromStream } from '../lib/blob_hash.js'
  54. import { NotFoundError } from '@overleaf/object-persistor/src/Errors.js'
  55. // Create a singleton promise that loads global blobs once
  56. let globalBlobsPromise = null
  57. function ensureGlobalBlobsLoaded() {
  58. if (!globalBlobsPromise) {
  59. globalBlobsPromise = loadGlobalBlobs()
  60. }
  61. return globalBlobsPromise
  62. }
  63. EventEmitter.defaultMaxListeners = 20
  64. logger.initialize('history-v1-backup')
  65. // Settings shared between command-line and module usage
  66. let DRY_RUN = false
  67. let RETRY_LIMIT = 3
  68. const RETRY_DELAY = 1000
  69. let CONCURRENCY = 4
  70. let BATCH_CONCURRENCY = 1
  71. let BLOB_LIMITER = pLimit(CONCURRENCY)
  72. let USE_SECONDARY = false
  73. /**
  74. * Configure backup settings
  75. * @param {Object} options Backup configuration options
  76. */
  77. export function configureBackup(options = {}) {
  78. DRY_RUN = options.dryRun || false
  79. RETRY_LIMIT = options.retries || 3
  80. CONCURRENCY = options.concurrency || 1
  81. BATCH_CONCURRENCY = options.batchConcurrency || 1
  82. BLOB_LIMITER = pLimit(CONCURRENCY)
  83. USE_SECONDARY = options.useSecondary || false
  84. }
  85. let gracefulShutdownInitiated = false
  86. process.on('SIGINT', handleSignal)
  87. process.on('SIGTERM', handleSignal)
  88. function handleSignal() {
  89. if (!gracefulShutdownInitiated) {
  90. gracefulShutdownInitiated = true
  91. logger.info({}, 'graceful shutdown: waiting for backups to complete')
  92. }
  93. }
  94. async function retry(fn, times, delayMs) {
  95. let attempts = times
  96. while (attempts > 0) {
  97. try {
  98. const result = await fn()
  99. return result
  100. } catch (err) {
  101. attempts--
  102. if (attempts === 0) throw err
  103. await new Promise(resolve => setTimeout(resolve, delayMs))
  104. }
  105. }
  106. }
  107. function wrapWithRetry(fn, retries, delayMs) {
  108. return async (...args) => {
  109. const result = await retry(() => fn(...args), retries, delayMs)
  110. return result
  111. }
  112. }
  113. const downloadWithRetry = wrapWithRetry(
  114. downloadBlobToDir,
  115. RETRY_LIMIT,
  116. RETRY_DELAY
  117. )
  118. // FIXME: this creates a new backupPersistor for each blob
  119. // so there is no caching of the DEK
  120. const backupWithRetry = wrapWithRetry(backupBlob, RETRY_LIMIT, RETRY_DELAY)
  121. async function findNewBlobs(projectId, blobs) {
  122. const newBlobs = []
  123. const existingBackedUpBlobHashes = await getBackedUpBlobHashes(projectId)
  124. for (const blob of blobs) {
  125. const hash = blob.getHash()
  126. if (existingBackedUpBlobHashes.has(blob.getHash())) {
  127. logger.debug({ projectId, hash }, 'Blob is already backed up, skipping')
  128. continue
  129. }
  130. const globalBlob = GLOBAL_BLOBS.get(hash)
  131. if (globalBlob && !globalBlob.demoted) {
  132. logger.debug(
  133. { projectId, hash },
  134. 'Blob is a global blob and not demoted, skipping'
  135. )
  136. continue
  137. }
  138. newBlobs.push(blob)
  139. }
  140. return newBlobs
  141. }
  142. async function cleanBackedUpBlobs(projectId, blobs) {
  143. const hashes = blobs.map(blob => blob.getHash())
  144. if (DRY_RUN) {
  145. console.log(
  146. 'Would remove blobs',
  147. hashes.join(' '),
  148. 'from project',
  149. projectId
  150. )
  151. return
  152. }
  153. await unsetBackedUpBlobHashes(projectId, hashes)
  154. }
  155. async function backupSingleBlob(projectId, historyId, blob, tmpDir, persistor) {
  156. if (DRY_RUN) {
  157. console.log(
  158. 'Would back up blob',
  159. JSON.stringify(blob),
  160. 'in history',
  161. historyId,
  162. 'for project',
  163. projectId
  164. )
  165. return
  166. }
  167. logger.debug({ blob, historyId }, 'backing up blob')
  168. const blobPath = await downloadWithRetry(historyId, blob, tmpDir)
  169. await backupWithRetry(historyId, blob, blobPath, persistor)
  170. }
  171. async function backupBlobs(projectId, historyId, blobs, limiter, persistor) {
  172. let tmpDir
  173. try {
  174. tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'blob-backup-'))
  175. const blobBackupOperations = blobs.map(blob =>
  176. limiter(backupSingleBlob, projectId, historyId, blob, tmpDir, persistor)
  177. )
  178. // Reject if any blob backup fails
  179. await Promise.all(blobBackupOperations)
  180. } finally {
  181. if (tmpDir) {
  182. await fs.rm(tmpDir, { recursive: true, force: true })
  183. }
  184. }
  185. }
  186. async function backupChunk(
  187. projectId,
  188. historyId,
  189. chunkBackupPersistorForProject,
  190. chunkToBackup,
  191. chunkRecord,
  192. chunkBuffer
  193. ) {
  194. if (DRY_RUN) {
  195. console.log(
  196. 'Would back up chunk',
  197. JSON.stringify(chunkRecord),
  198. 'in history',
  199. historyId,
  200. 'for project',
  201. projectId,
  202. 'key',
  203. makeChunkKey(historyId, chunkToBackup.startVersion)
  204. )
  205. return
  206. }
  207. const key = makeChunkKey(historyId, chunkToBackup.startVersion)
  208. logger.debug({ chunkRecord, historyId, projectId, key }, 'backing up chunk')
  209. const timer = setTimeout(function () {
  210. logger.warn(
  211. { historyId, chunkRecord, size: chunkBuffer.byteLength },
  212. 'chunk upload still active after 1 minute'
  213. )
  214. }, 60 * 1000)
  215. try {
  216. await chunkBackupPersistorForProject.sendStream(
  217. chunksBucket,
  218. makeChunkKey(historyId, chunkToBackup.startVersion),
  219. Stream.Readable.from([chunkBuffer]),
  220. {
  221. contentType: 'application/json',
  222. contentEncoding: 'gzip',
  223. contentLength: chunkBuffer.byteLength,
  224. }
  225. )
  226. } finally {
  227. clearTimeout(timer)
  228. }
  229. }
  230. async function updateBackupStatus(
  231. projectId,
  232. lastBackedUpVersion,
  233. chunkRecord,
  234. startOfBackupTime
  235. ) {
  236. if (DRY_RUN) {
  237. console.log(
  238. 'Would set backup version to',
  239. chunkRecord.endVersion,
  240. 'with lastBackedUpTimestamp',
  241. startOfBackupTime
  242. )
  243. return
  244. }
  245. logger.debug(
  246. { projectId, chunkRecord, startOfBackupTime },
  247. 'setting backupVersion and lastBackedUpTimestamp'
  248. )
  249. await setBackupVersion(
  250. projectId,
  251. lastBackedUpVersion,
  252. chunkRecord.endVersion,
  253. startOfBackupTime
  254. )
  255. }
  256. // Define command-line options
  257. const optionDefinitions = [
  258. {
  259. name: 'projectId',
  260. alias: 'p',
  261. type: String,
  262. description: 'The ID of the project to backup',
  263. defaultOption: true,
  264. },
  265. {
  266. name: 'help',
  267. alias: 'h',
  268. type: Boolean,
  269. description: 'Display this usage guide.',
  270. },
  271. {
  272. name: 'status',
  273. alias: 's',
  274. type: Boolean,
  275. description: 'Display project status.',
  276. },
  277. {
  278. name: 'list',
  279. alias: 'l',
  280. type: Boolean,
  281. description: 'List projects that need to be backed up',
  282. },
  283. {
  284. name: 'dry-run',
  285. alias: 'n',
  286. type: Boolean,
  287. description: 'Perform a dry run without making any changes.',
  288. },
  289. {
  290. name: 'retries',
  291. alias: 'r',
  292. type: Number,
  293. description: 'Number of retries, default is 3.',
  294. },
  295. {
  296. name: 'concurrency',
  297. alias: 'c',
  298. type: Number,
  299. description: 'Number of concurrent blob downloads (default: 1)',
  300. },
  301. {
  302. name: 'batch-concurrency',
  303. alias: 'b',
  304. type: Number,
  305. description: 'Number of concurrent project operations (default: 1)',
  306. },
  307. {
  308. name: 'pending',
  309. alias: 'P',
  310. type: Boolean,
  311. description: 'Backup all pending projects.',
  312. },
  313. {
  314. name: 'interval',
  315. alias: 'i',
  316. type: Number,
  317. description: 'Time interval in seconds for pending backups (default: 3600)',
  318. defaultValue: 3600,
  319. },
  320. {
  321. name: 'fix',
  322. type: Number,
  323. description: 'Fix projects without chunks',
  324. },
  325. {
  326. name: 'init',
  327. alias: 'I',
  328. type: Boolean,
  329. description: 'Initialize backups for all projects.',
  330. },
  331. { name: 'output', alias: 'o', type: String, description: 'Output file' },
  332. {
  333. name: 'start-date',
  334. type: String,
  335. description: 'Start date for initialization (ISO format)',
  336. },
  337. {
  338. name: 'end-date',
  339. type: String,
  340. description: 'End date for initialization (ISO format)',
  341. },
  342. {
  343. name: 'use-secondary',
  344. type: Boolean,
  345. description: 'Use secondary read preference for backup status',
  346. },
  347. {
  348. name: 'compare',
  349. alias: 'C',
  350. type: Boolean,
  351. description:
  352. 'Compare backup with original chunks. With --start-date and --end-date compares all projects in range.',
  353. },
  354. {
  355. name: 'fast',
  356. type: Boolean,
  357. description:
  358. 'Performs a fast comparison of blobs by only checking for presence and size. Only works with --compare.',
  359. },
  360. {
  361. name: 'input',
  362. type: String,
  363. description:
  364. 'Input file containing project IDs (one per line) for batch comparison. Only works with --compare.',
  365. },
  366. {
  367. name: 'verbose',
  368. alias: 'v',
  369. type: Boolean,
  370. description:
  371. 'Enable verbose output during batch comparison. Only works with --compare and --input.',
  372. },
  373. ]
  374. function handleOptions() {
  375. const options = commandLineArgs(optionDefinitions)
  376. if (options.help) {
  377. console.log('Usage:')
  378. optionDefinitions.forEach(option => {
  379. console.log(` --${option.name}, -${option.alias}: ${option.description}`)
  380. })
  381. process.exit(0)
  382. }
  383. const projectIdRequired =
  384. !options.list &&
  385. !options.pending &&
  386. !options.init &&
  387. !(options.fix >= 0) &&
  388. !(options.compare && options['start-date'] && options['end-date']) &&
  389. !(options.compare && options.input)
  390. if (projectIdRequired && !options.projectId) {
  391. console.error('Error: projectId is required')
  392. process.exit(1)
  393. }
  394. if (options.pending && options.projectId) {
  395. console.error('Error: --pending cannot be specified with projectId')
  396. process.exit(1)
  397. }
  398. if (options.pending && (options.list || options.status)) {
  399. console.error('Error: --pending is exclusive with --list and --status')
  400. process.exit(1)
  401. }
  402. if (options.init && options.pending) {
  403. console.error('Error: --init cannot be specified with --pending')
  404. process.exit(1)
  405. }
  406. if (
  407. (options['start-date'] || options['end-date']) &&
  408. !options.init &&
  409. !options.compare
  410. ) {
  411. console.error(
  412. 'Error: date options can only be used with --init or --compare'
  413. )
  414. process.exit(1)
  415. }
  416. if (options['use-secondary']) {
  417. USE_SECONDARY = true
  418. }
  419. if (
  420. options.compare &&
  421. !options.projectId &&
  422. !(options['start-date'] && options['end-date']) &&
  423. !options.input
  424. ) {
  425. console.error(
  426. 'Error: --compare requires either projectId, --input file, or both --start-date and --end-date'
  427. )
  428. process.exit(1)
  429. }
  430. if (options.fast && !options.compare) {
  431. console.error('Error: --fast can only be used with --compare')
  432. process.exit(1)
  433. }
  434. if (options.input && !options.compare) {
  435. console.error('Error: --input can only be used with --compare')
  436. process.exit(1)
  437. }
  438. if (options.input && options.projectId) {
  439. console.error('Error: --input cannot be specified with projectId')
  440. process.exit(1)
  441. }
  442. if (options.input && (options['start-date'] || options['end-date'])) {
  443. console.error(
  444. 'Error: --input cannot be specified with --start-date or --end-date'
  445. )
  446. process.exit(1)
  447. }
  448. if (options.verbose && !options.input) {
  449. console.error('Error: --verbose can only be used with --input')
  450. process.exit(1)
  451. }
  452. DRY_RUN = options['dry-run'] || false
  453. RETRY_LIMIT = options.retries || 3
  454. CONCURRENCY = options.concurrency || 1
  455. BATCH_CONCURRENCY = options['batch-concurrency'] || 1
  456. BLOB_LIMITER = pLimit(CONCURRENCY)
  457. return options
  458. }
  459. async function displayBackupStatus(projectId) {
  460. const result = await analyseBackupStatus(projectId)
  461. console.log('Backup status:', JSON.stringify(result))
  462. }
  463. async function analyseBackupStatus(projectId) {
  464. const { backupStatus, historyId, currentEndVersion, currentEndTimestamp } =
  465. await getBackupStatus(projectId)
  466. // TODO: when we have confidence that the latestChunkMetadata always matches
  467. // the values from the backupStatus we can skip loading it here
  468. const latestChunkMetadata = await getLatestChunkMetadata(historyId, {
  469. readOnly: Boolean(USE_SECONDARY),
  470. })
  471. if (
  472. currentEndVersion &&
  473. currentEndVersion !== latestChunkMetadata.endVersion
  474. ) {
  475. // compare the current end version with the latest chunk metadata to check that
  476. // the updates to the project collection are reliable
  477. // expect some failures due to the time window between getBackupStatus and
  478. // getLatestChunkMetadata where the project is being actively edited.
  479. logger.warn(
  480. {
  481. projectId,
  482. historyId,
  483. currentEndVersion,
  484. currentEndTimestamp,
  485. latestChunkMetadata,
  486. },
  487. 'currentEndVersion does not match latest chunk metadata'
  488. )
  489. }
  490. if (DRY_RUN) {
  491. console.log('Project:', projectId)
  492. console.log('History ID:', historyId)
  493. console.log('Latest Chunk Metadata:', JSON.stringify(latestChunkMetadata))
  494. console.log('Current end version:', currentEndVersion)
  495. console.log('Current end timestamp:', currentEndTimestamp)
  496. console.log('Backup status:', backupStatus ?? 'none')
  497. }
  498. if (!backupStatus) {
  499. if (DRY_RUN) {
  500. console.log('No backup status found - doing full backup')
  501. }
  502. }
  503. const lastBackedUpVersion = backupStatus?.lastBackedUpVersion
  504. const endVersion = latestChunkMetadata.endVersion
  505. if (endVersion >= 0 && endVersion === lastBackedUpVersion) {
  506. if (DRY_RUN) {
  507. console.log(
  508. 'Project is up to date, last backed up at version',
  509. lastBackedUpVersion
  510. )
  511. }
  512. } else if (endVersion < lastBackedUpVersion) {
  513. throw new Error('backup is ahead of project')
  514. } else {
  515. if (DRY_RUN) {
  516. console.log(
  517. 'Project needs to be backed up from',
  518. lastBackedUpVersion,
  519. 'to',
  520. endVersion
  521. )
  522. }
  523. }
  524. return {
  525. historyId,
  526. lastBackedUpVersion,
  527. currentVersion: latestChunkMetadata.endVersion || 0,
  528. upToDate: endVersion >= 0 && lastBackedUpVersion === endVersion,
  529. pendingChangeAt: backupStatus?.pendingChangeAt,
  530. currentEndVersion,
  531. currentEndTimestamp,
  532. latestChunkMetadata,
  533. }
  534. }
  535. async function displayPendingBackups(options) {
  536. const intervalMs = options.interval * 1000
  537. for await (const project of listPendingBackups(intervalMs)) {
  538. console.log(
  539. 'Project:',
  540. project._id.toHexString(),
  541. 'backup status:',
  542. JSON.stringify(project.overleaf.backup),
  543. 'history status:',
  544. JSON.stringify(project.overleaf.history, [
  545. 'currentEndVersion',
  546. 'currentEndTimestamp',
  547. ])
  548. )
  549. }
  550. }
  551. function makeChunkKey(projectId, startVersion) {
  552. return path.join(projectKey.format(projectId), projectKey.pad(startVersion))
  553. }
  554. export async function backupProject(projectId, options) {
  555. if (gracefulShutdownInitiated) {
  556. return
  557. }
  558. await ensureGlobalBlobsLoaded()
  559. // FIXME: flush the project first!
  560. // Let's assume the the flush happens externally and triggers this backup
  561. const backupStartTime = new Date()
  562. // find the last backed up version
  563. const {
  564. historyId,
  565. lastBackedUpVersion,
  566. currentVersion,
  567. upToDate,
  568. pendingChangeAt,
  569. currentEndVersion,
  570. latestChunkMetadata,
  571. } = await analyseBackupStatus(projectId)
  572. if (upToDate) {
  573. logger.debug(
  574. {
  575. projectId,
  576. historyId,
  577. lastBackedUpVersion,
  578. currentVersion,
  579. pendingChangeAt,
  580. },
  581. 'backup is up to date'
  582. )
  583. if (
  584. currentEndVersion === undefined &&
  585. latestChunkMetadata.endVersion >= 0
  586. ) {
  587. if (DRY_RUN) {
  588. console.log('Would update current metadata to', latestChunkMetadata)
  589. } else {
  590. await updateCurrentMetadataIfNotSet(projectId, latestChunkMetadata)
  591. }
  592. }
  593. // clear the pending changes timestamp if the backup is complete
  594. if (pendingChangeAt) {
  595. if (DRY_RUN) {
  596. console.log(
  597. 'Would update or clear pending changes timestamp',
  598. backupStartTime
  599. )
  600. } else {
  601. await updatePendingChangeTimestamp(projectId, backupStartTime)
  602. }
  603. }
  604. return
  605. }
  606. logger.debug(
  607. {
  608. projectId,
  609. historyId,
  610. lastBackedUpVersion,
  611. currentVersion,
  612. pendingChangeAt,
  613. },
  614. 'backing up project'
  615. )
  616. // this persistor works for both the chunks and blobs buckets,
  617. // because they use the same DEK
  618. const backupPersistorForProject = await backupPersistor.forProject(
  619. chunksBucket,
  620. makeProjectKey(historyId, '')
  621. )
  622. let previousBackedUpVersion = lastBackedUpVersion
  623. const backupVersions = [previousBackedUpVersion]
  624. for await (const {
  625. blobsToBackup,
  626. chunkToBackup,
  627. chunkRecord,
  628. chunkBuffer,
  629. } of backupGenerator(historyId, lastBackedUpVersion)) {
  630. // backup the blobs first
  631. // this can be done in parallel but must fail if any blob cannot be backed up
  632. // if the blob already exists in the backup then that is allowed
  633. const newBlobs = await findNewBlobs(projectId, blobsToBackup)
  634. await backupBlobs(
  635. projectId,
  636. historyId,
  637. newBlobs,
  638. BLOB_LIMITER,
  639. backupPersistorForProject
  640. )
  641. // then backup the original compressed chunk using the startVersion as the key
  642. await backupChunk(
  643. projectId,
  644. historyId,
  645. backupPersistorForProject,
  646. chunkToBackup,
  647. chunkRecord,
  648. chunkBuffer
  649. )
  650. // persist the backup status in mongo for the current chunk
  651. try {
  652. await updateBackupStatus(
  653. projectId,
  654. previousBackedUpVersion,
  655. chunkRecord,
  656. backupStartTime
  657. )
  658. } catch (err) {
  659. logger.error(
  660. { projectId, chunkRecord, err, backupVersions },
  661. 'error updating backup status'
  662. )
  663. throw err
  664. }
  665. previousBackedUpVersion = chunkRecord.endVersion
  666. backupVersions.push(previousBackedUpVersion)
  667. await cleanBackedUpBlobs(projectId, blobsToBackup)
  668. }
  669. // update the current end version and timestamp if they are not set
  670. if (currentEndVersion === undefined && latestChunkMetadata.endVersion >= 0) {
  671. if (DRY_RUN) {
  672. console.log('Would update current metadata to', latestChunkMetadata)
  673. } else {
  674. await updateCurrentMetadataIfNotSet(projectId, latestChunkMetadata)
  675. }
  676. }
  677. // clear the pending changes timestamp if the backup is complete, otherwise set it to the time
  678. // when the backup started (to pick up the new changes on the next backup)
  679. if (DRY_RUN) {
  680. console.log(
  681. 'Would update or clear pending changes timestamp',
  682. backupStartTime
  683. )
  684. } else {
  685. await updatePendingChangeTimestamp(projectId, backupStartTime)
  686. }
  687. }
  688. function convertToISODate(dateStr) {
  689. // Expecting YYYY-MM-DD format
  690. if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
  691. throw new Error('Date must be in YYYY-MM-DD format')
  692. }
  693. return new Date(dateStr + 'T00:00:00.000Z').toISOString()
  694. }
  695. export async function fixProjectsWithoutChunks(options) {
  696. const limit = options.fix || 1
  697. const query = {
  698. 'overleaf.history.id': { $exists: true },
  699. 'overleaf.backup.lastBackedUpVersion': { $in: [null] },
  700. }
  701. const cursor = client
  702. .db()
  703. .collection('projects')
  704. .find(query, {
  705. projection: { _id: 1, 'overleaf.history.id': 1 },
  706. readPreference: READ_PREFERENCE_SECONDARY,
  707. })
  708. .limit(limit)
  709. for await (const project of cursor) {
  710. const historyId = project.overleaf.history.id.toString()
  711. const chunks = await getProjectChunks(historyId)
  712. if (chunks.length > 0) {
  713. continue
  714. }
  715. if (DRY_RUN) {
  716. console.log(
  717. 'Would create new chunk for Project ID:',
  718. project._id.toHexString(),
  719. 'History ID:',
  720. historyId,
  721. 'Chunks:',
  722. chunks
  723. )
  724. } else {
  725. console.log(
  726. 'Creating new chunk for Project ID:',
  727. project._id.toHexString(),
  728. 'History ID:',
  729. historyId,
  730. 'Chunks:',
  731. chunks
  732. )
  733. const snapshot = new Snapshot()
  734. const history = new History(snapshot, [])
  735. const chunk = new Chunk(history, 0)
  736. await create(historyId, chunk)
  737. const newChunks = await getProjectChunks(historyId)
  738. console.log('New chunk:', newChunks)
  739. }
  740. }
  741. }
  742. export async function initializeProjects(options) {
  743. await ensureGlobalBlobsLoaded()
  744. let totalErrors = 0
  745. let totalProjects = 0
  746. const query = {
  747. 'overleaf.backup.lastBackedUpVersion': { $in: [null] },
  748. }
  749. if (options['start-date'] && options['end-date']) {
  750. query._id = {
  751. $gte: objectIdFromInput(convertToISODate(options['start-date'])),
  752. $lt: objectIdFromInput(convertToISODate(options['end-date'])),
  753. }
  754. }
  755. const cursor = client
  756. .db()
  757. .collection('projects')
  758. .find(query, {
  759. projection: { _id: 1 },
  760. readPreference: READ_PREFERENCE_SECONDARY,
  761. })
  762. if (options.output) {
  763. console.log("Writing project IDs to file: '" + options.output + "'")
  764. const output = createWriteStream(options.output)
  765. for await (const project of cursor) {
  766. output.write(project._id.toHexString() + '\n')
  767. totalProjects++
  768. }
  769. output.end()
  770. console.log('Wrote ' + totalProjects + ' project IDs to file')
  771. return
  772. }
  773. for await (const project of cursor) {
  774. if (gracefulShutdownInitiated) {
  775. console.warn('graceful shutdown: stopping project initialization')
  776. break
  777. }
  778. totalProjects++
  779. const projectId = project._id.toHexString()
  780. try {
  781. await backupProject(projectId, options)
  782. } catch (err) {
  783. totalErrors++
  784. logger.error({ projectId, err }, 'error backing up project')
  785. }
  786. }
  787. return { errors: totalErrors, projects: totalProjects }
  788. }
  789. async function backupPendingProjects(options) {
  790. const intervalMs = options.interval * 1000
  791. for await (const project of listPendingBackups(intervalMs)) {
  792. if (gracefulShutdownInitiated) {
  793. console.warn('graceful shutdown: stopping pending project backups')
  794. break
  795. }
  796. const projectId = project._id.toHexString()
  797. console.log(`Backing up pending project with ID: ${projectId}`)
  798. await backupProject(projectId, options)
  799. }
  800. }
  801. class BlobComparator {
  802. constructor(backupPersistorForProject) {
  803. this.cache = new Map()
  804. this.backupPersistorForProject = backupPersistorForProject
  805. }
  806. async compareBlob(historyId, blob) {
  807. let computedHash = this.cache.get(blob.hash)
  808. const fromCache = !!computedHash
  809. if (!computedHash) {
  810. const blobKey = makeProjectKey(historyId, blob.hash)
  811. const backupBlobStream =
  812. await this.backupPersistorForProject.getObjectStream(
  813. projectBlobsBucket,
  814. blobKey,
  815. { autoGunzip: true }
  816. )
  817. computedHash = await blobHashFromStream(blob.byteLength, backupBlobStream)
  818. this.cache.set(blob.hash, computedHash)
  819. }
  820. const matches = computedHash === blob.hash
  821. return {
  822. matches,
  823. computedHash,
  824. fromCache,
  825. }
  826. }
  827. }
  828. const SHA1_HEX_REGEX = /^[a-f0-9]{40}$/
  829. /**
  830. * Get a listing of all blobs for a project
  831. * @param {string} historyId - The history ID
  832. * @returns {Promise<Map<string, {key: string, size: number}>>} Map of blob hash to blob metadata
  833. */
  834. async function getBlobListing(historyId) {
  835. const backupPersistorForProject = await backupPersistor.forProject(
  836. projectBlobsBucket,
  837. makeProjectKey(historyId, '')
  838. )
  839. // get the blob listing
  840. const projectBlobsPath = projectKey.format(historyId)
  841. const blobList = await backupPersistorForProject.listDirectoryStats(
  842. projectBlobsBucket,
  843. projectBlobsPath
  844. )
  845. if (blobList.length === 0) {
  846. return new Map()
  847. }
  848. /** @type {Map<string, {key: string, size: number}>} */
  849. const remoteBlobs = new Map()
  850. for (const blobRecord of blobList) {
  851. if (!blobRecord.key || typeof blobRecord.size !== 'number') {
  852. logger.debug({ blobRecord }, 'invalid blob record')
  853. continue
  854. }
  855. const parts = blobRecord.key.split('/')
  856. const hash = parts[3] + parts[4]
  857. if (!SHA1_HEX_REGEX.test(hash)) {
  858. console.warn(`Invalid SHA1 hash for project ${historyId}: ${hash}`)
  859. continue
  860. }
  861. remoteBlobs.set(hash, { key: blobRecord.key, size: blobRecord.size })
  862. }
  863. return remoteBlobs
  864. }
  865. /**
  866. * @typedef {Object} ComparisonError
  867. * @property {string} type - Error type code (e.g., 'chunk-not-found', 'blob-hash-mismatch')
  868. * @property {string} [chunkId]
  869. * @property {string} historyId
  870. * @property {string} [blobHash]
  871. * @property {string|Error} error
  872. */
  873. /**
  874. * @typedef {Error & {historyId: string, errors: ComparisonError[], counters: Object}} ComparisonFailureError
  875. */
  876. async function compareBackups(projectId, options, log = console.log) {
  877. // Convert any postgres history ids to mongo project ids
  878. const backend = getBackend(projectId)
  879. projectId = await backend.resolveHistoryIdToMongoProjectId(projectId)
  880. const { historyId, rootFolder } = await getBackupStatus(projectId, {
  881. includeRootFolder: true,
  882. })
  883. log(`Comparing backups for project ${projectId} historyId ${historyId}`)
  884. const hashesFromFileTree = rootFolder
  885. ? getHashesFromFileTree(rootFolder)
  886. : new Set()
  887. const hashesFromHistory = new Set()
  888. const chunks = await getProjectChunks(historyId)
  889. const blobStore = new BlobStore(historyId)
  890. const backupPersistorForProject = await backupPersistor.forProject(
  891. chunksBucket,
  892. makeProjectKey(historyId, '')
  893. )
  894. let totalChunkMatches = 0
  895. let totalChunkMismatches = 0
  896. let totalChunksNotFound = 0
  897. let totalBlobMatches = 0
  898. let totalBlobMismatches = 0
  899. let totalBlobsNotFound = 0
  900. /** @type {ComparisonError[]} */
  901. const errors = []
  902. const blobComparator = new BlobComparator(backupPersistorForProject)
  903. const blobsFromListing = await getBlobListing(historyId)
  904. for (const chunk of chunks) {
  905. if (gracefulShutdownInitiated) {
  906. throw new Error('interrupted')
  907. }
  908. try {
  909. // Compare chunk content
  910. const originalChunk = await historyStore.loadRaw(historyId, chunk.id)
  911. const key = makeChunkKey(historyId, chunk.startVersion)
  912. try {
  913. const backupChunkStream =
  914. await backupPersistorForProject.getObjectStream(chunksBucket, key)
  915. const backupStr = await text(backupChunkStream.pipe(createGunzip()))
  916. const originalStr = JSON.stringify(originalChunk)
  917. const backupChunk = JSON.parse(backupStr)
  918. const backupStartVersion = chunk.startVersion
  919. const backupEndVersion = chunk.startVersion + backupChunk.changes.length
  920. if (originalStr === backupStr) {
  921. log(
  922. `✓ Chunk ${chunk.id} (v${chunk.startVersion}-v${chunk.endVersion}) matches`
  923. )
  924. totalChunkMatches++
  925. } else if (originalStr === JSON.stringify(JSON.parse(backupStr))) {
  926. log(
  927. `✓ Chunk ${chunk.id} (v${chunk.startVersion}-v${chunk.endVersion}) matches (after normalisation)`
  928. )
  929. totalChunkMatches++
  930. } else if (backupEndVersion < chunk.endVersion) {
  931. log(
  932. `✗ Chunk ${chunk.id} is ahead of backup (v${chunk.startVersion}-v${chunk.endVersion} vs v${backupStartVersion}-v${backupEndVersion})`
  933. )
  934. totalChunkMismatches++
  935. errors.push({
  936. type: 'chunk-ahead',
  937. chunkId: chunk.id,
  938. historyId,
  939. error: 'Chunk ahead of backup',
  940. })
  941. } else {
  942. log(
  943. `✗ Chunk ${chunk.id} (v${chunk.startVersion}-v${chunk.endVersion}) MISMATCH`
  944. )
  945. totalChunkMismatches++
  946. errors.push({
  947. type: 'chunk-mismatch',
  948. chunkId: chunk.id,
  949. historyId,
  950. error: 'Chunk mismatch',
  951. })
  952. }
  953. } catch (err) {
  954. if (err instanceof NotFoundError) {
  955. log(`✗ Chunk ${chunk.id} not found in backup`, err.cause)
  956. totalChunksNotFound++
  957. errors.push({
  958. type: 'chunk-not-found',
  959. chunkId: chunk.id,
  960. historyId,
  961. error: `Chunk not found`,
  962. })
  963. } else {
  964. throw err
  965. }
  966. }
  967. const history = History.fromRaw(originalChunk)
  968. // Compare blobs in chunk
  969. const blobHashes = new Set()
  970. history.findBlobHashes(blobHashes)
  971. const blobs = await blobStore.getBlobs(Array.from(blobHashes))
  972. for (const blob of blobs) {
  973. if (gracefulShutdownInitiated) {
  974. throw new Error('interrupted')
  975. }
  976. // Track all the hashes in the history
  977. hashesFromHistory.add(blob.hash)
  978. if (GLOBAL_BLOBS.has(blob.hash)) {
  979. const globalBlob = GLOBAL_BLOBS.get(blob.hash)
  980. log(
  981. ` ✓ Blob ${blob.hash} is a global blob`,
  982. globalBlob?.demoted ? '(demoted)' : ''
  983. )
  984. continue
  985. }
  986. try {
  987. const blobListEntry = blobsFromListing.get(blob.hash)
  988. if (options.fast) {
  989. if (blobListEntry) {
  990. if (blob.byteLength === blobListEntry.size) {
  991. // Size matches exactly
  992. log(
  993. ` ✓ Blob ${blob.hash} exists on remote with expected size (${blob.byteLength} bytes)`
  994. )
  995. totalBlobMatches++
  996. continue
  997. } else if (blob.stringLength > 0 && blobListEntry.size > 0) {
  998. // Text file present with compressed size, assume valid as we are in --fast comparison mode
  999. const compressionRatio = (
  1000. blobListEntry.size / blob.byteLength
  1001. ).toFixed(2)
  1002. log(
  1003. ` ✓ Blob ${blob.hash} consistent with compressed data on remote (${blob.byteLength} bytes => ${blobListEntry.size} bytes, ratio=${compressionRatio})`
  1004. )
  1005. totalBlobMatches++
  1006. continue
  1007. } else {
  1008. log(
  1009. ` ✗ Blob ${blob.hash} size mismatch (original: ${blob.byteLength} bytes, stringLength: ${blob.stringLength}, backup: ${blobListEntry.size} bytes)`
  1010. )
  1011. totalBlobMismatches++
  1012. errors.push({
  1013. type: 'blob-size-mismatch',
  1014. chunkId: chunk.id,
  1015. historyId,
  1016. blobHash: blob.hash,
  1017. error: `Blob ${blob.hash} size mismatch`,
  1018. })
  1019. continue
  1020. }
  1021. } else {
  1022. log(
  1023. ` ✗ Blob ${blob.hash} not found on remote listing (${blob.byteLength} bytes, ${blob.stringLength} string length)`
  1024. )
  1025. totalBlobMismatches++
  1026. errors.push({
  1027. type: 'blob-not-found',
  1028. chunkId: chunk.id,
  1029. historyId,
  1030. blobHash: blob.hash,
  1031. error: `Blob ${blob.hash} not found`,
  1032. })
  1033. continue
  1034. }
  1035. } else {
  1036. const { matches, computedHash, fromCache } =
  1037. await blobComparator.compareBlob(historyId, blob)
  1038. if (matches) {
  1039. log(
  1040. ` ✓ Blob ${blob.hash} hash matches (${blob.byteLength} bytes)` +
  1041. (fromCache ? ' (from cache)' : '')
  1042. )
  1043. totalBlobMatches++
  1044. continue
  1045. } else {
  1046. log(
  1047. ` ✗ Blob ${blob.hash} hash mismatch (original: ${blob.hash}, backup: ${computedHash}) (${blob.byteLength} bytes, ${blob.stringLength} string length)` +
  1048. (fromCache ? ' (from cache)' : '')
  1049. )
  1050. totalBlobMismatches++
  1051. errors.push({
  1052. type: 'blob-hash-mismatch',
  1053. chunkId: chunk.id,
  1054. historyId,
  1055. blobHash: blob.hash,
  1056. error: `Blob ${blob.hash} hash mismatch`,
  1057. })
  1058. continue
  1059. }
  1060. }
  1061. } catch (err) {
  1062. if (err instanceof NotFoundError) {
  1063. log(` ✗ Blob ${blob.hash} not found in backup`, err.cause)
  1064. totalBlobsNotFound++
  1065. errors.push({
  1066. type: 'blob-not-found',
  1067. chunkId: chunk.id,
  1068. historyId,
  1069. blobHash: blob.hash,
  1070. error: `Blob ${blob.hash} not found`,
  1071. })
  1072. } else {
  1073. throw err
  1074. }
  1075. }
  1076. }
  1077. } catch (err) {
  1078. log(`Error comparing chunk ${chunk.id}:`, err)
  1079. errors.push({
  1080. type: 'error',
  1081. chunkId: chunk.id,
  1082. historyId,
  1083. error: err instanceof Error ? err : String(err),
  1084. })
  1085. }
  1086. }
  1087. if (gracefulShutdownInitiated) {
  1088. throw new Error('interrupted')
  1089. }
  1090. // Reconcile hashes in file tree with history
  1091. log(`Comparing file hashes from file tree with history`)
  1092. if (hashesFromFileTree.size > 0) {
  1093. for (const hash of hashesFromFileTree) {
  1094. const presentInHistory = hashesFromHistory.has(hash)
  1095. if (presentInHistory) {
  1096. log(` ✓ File tree hash ${hash} present in history`)
  1097. } else {
  1098. log(` ✗ File tree hash ${hash} not found in history`)
  1099. totalBlobsNotFound++
  1100. errors.push({
  1101. type: 'file-not-found',
  1102. historyId,
  1103. blobHash: hash,
  1104. error: `File tree hash ${hash} not found in history`,
  1105. })
  1106. }
  1107. }
  1108. } else {
  1109. log(` ✓ File tree does not contain any binary files`)
  1110. }
  1111. // Print summary
  1112. log('\nComparison Summary:')
  1113. log('==================')
  1114. log(`Total chunks: ${chunks.length}`)
  1115. log(`Chunk matches: ${totalChunkMatches}`)
  1116. log(`Chunk mismatches: ${totalChunkMismatches}`)
  1117. log(`Chunk not found: ${totalChunksNotFound}`)
  1118. log(`Blob matches: ${totalBlobMatches}`)
  1119. log(`Blob mismatches: ${totalBlobMismatches}`)
  1120. log(`Blob not found: ${totalBlobsNotFound}`)
  1121. log(`Errors: ${errors.length}`)
  1122. if (errors.length > 0) {
  1123. log('\nErrors:')
  1124. errors.forEach(({ chunkId, error }) => {
  1125. log(` Chunk ${chunkId}: ${error}`)
  1126. })
  1127. const err = /** @type {ComparisonFailureError} */ (
  1128. new Error('Backup comparison FAILED')
  1129. )
  1130. err.historyId = historyId
  1131. err.errors = errors
  1132. err.counters = {
  1133. totalChunks: chunks.length,
  1134. chunkMatches: totalChunkMatches,
  1135. chunkMismatches: totalChunkMismatches,
  1136. chunksNotFound: totalChunksNotFound,
  1137. blobMatches: totalBlobMatches,
  1138. blobMismatches: totalBlobMismatches,
  1139. blobsNotFound: totalBlobsNotFound,
  1140. }
  1141. throw err
  1142. } else {
  1143. log('Backup comparison successful')
  1144. }
  1145. }
  1146. /**
  1147. * Compare a single project and emit structured output
  1148. * @param {string} projectId - The project ID to compare
  1149. * @param {Object} options - Comparison options
  1150. * @param {number} projectNumber - Current project number for progress reporting
  1151. * @param {number} totalCount - Total number of projects
  1152. * @returns {Promise<boolean>} - Returns true if comparison had errors
  1153. */
  1154. async function compareProjectAndEmitResult(
  1155. projectId,
  1156. options,
  1157. projectNumber,
  1158. totalCount
  1159. ) {
  1160. if (gracefulShutdownInitiated) {
  1161. return false
  1162. }
  1163. console.error(
  1164. `Processing project ${projectNumber}/${totalCount}: ${projectId}`
  1165. )
  1166. // Custom logger: silent by default, buffered if verbose
  1167. const logBuffer = []
  1168. const customLog = options.verbose
  1169. ? (...args) => logBuffer.push(args.join(' '))
  1170. : () => {}
  1171. try {
  1172. await compareBackups(projectId, options, customLog)
  1173. console.log(`OK: ${projectId}`)
  1174. // Output buffered logs after success
  1175. if (options.verbose && logBuffer.length > 0) {
  1176. console.error(`\n--- Verbose output for ${projectId} ---`)
  1177. logBuffer.forEach(line => console.error(line))
  1178. console.error(`--- End of output for ${projectId} ---\n`)
  1179. }
  1180. return false
  1181. } catch (err) {
  1182. if (gracefulShutdownInitiated) {
  1183. throw err
  1184. }
  1185. console.log(`FAIL: ${projectId}`)
  1186. // Output buffered logs on error when verbose
  1187. if (options.verbose && logBuffer.length > 0) {
  1188. console.error(`\n--- Verbose output for ${projectId} (FAILED) ---`)
  1189. logBuffer.forEach(line => console.error(line))
  1190. console.error(`--- End of output for ${projectId} ---\n`)
  1191. }
  1192. // Check if this is a comparison error with attached details
  1193. const error = /** @type {ComparisonFailureError} */ (err)
  1194. if (error.errors && error.historyId) {
  1195. // Emit structured error lines
  1196. for (const errorRecord of error.errors) {
  1197. const {
  1198. type,
  1199. historyId,
  1200. blobHash,
  1201. chunkId,
  1202. error: errorDetail,
  1203. } = errorRecord
  1204. const errorMsg =
  1205. typeof errorDetail === 'string'
  1206. ? errorDetail
  1207. : errorDetail?.message || String(errorDetail)
  1208. // Use error type for structured output
  1209. switch (type) {
  1210. case 'blob-not-found':
  1211. console.log(`missing: ${projectId},${historyId},${blobHash}`)
  1212. break
  1213. case 'chunk-not-found':
  1214. console.log(`chunk-missing: ${projectId},${historyId},${chunkId}`)
  1215. break
  1216. case 'blob-hash-mismatch':
  1217. console.log(`hash-mismatch: ${projectId},${historyId},${blobHash}`)
  1218. break
  1219. case 'blob-size-mismatch':
  1220. console.log(`size-mismatch: ${projectId},${historyId},${blobHash}`)
  1221. break
  1222. case 'file-not-found':
  1223. console.log(`file-not-found: ${projectId},${historyId},${blobHash}`)
  1224. break
  1225. case 'chunk-mismatch':
  1226. console.log(`chunk-mismatch: ${projectId},${historyId},${chunkId}`)
  1227. break
  1228. case 'chunk-ahead':
  1229. console.log(`chunk-ahead: ${projectId},${historyId},${chunkId}`)
  1230. break
  1231. default:
  1232. console.log(
  1233. `error: ${projectId},${historyId},${errorMsg.replace(/[,\n]/g, ' ')}`
  1234. )
  1235. break
  1236. }
  1237. }
  1238. } else {
  1239. // Generic error without details
  1240. const errorMsg = error?.message || String(error)
  1241. console.log(
  1242. `error: ${projectId},unknown,${errorMsg.replace(/[,\n]/g, ' ')}`
  1243. )
  1244. }
  1245. return true
  1246. }
  1247. }
  1248. async function compareProjectsFromFile(options) {
  1249. await ensureGlobalBlobsLoaded()
  1250. const limiter = pLimit(CONCURRENCY)
  1251. let totalErrors = 0
  1252. let totalProjects = 0
  1253. // Read project IDs from file
  1254. const fileContent = await fs.readFile(options.input, 'utf-8')
  1255. const projectIds = fileContent
  1256. .split('\n')
  1257. .map(line => line.trim())
  1258. .filter(line => line.length > 0)
  1259. console.error(`Loaded ${projectIds.length} project IDs from ${options.input}`)
  1260. const operations = projectIds.map(projectId =>
  1261. limiter(async () => {
  1262. totalProjects++
  1263. const hadError = await compareProjectAndEmitResult(
  1264. projectId,
  1265. options,
  1266. totalProjects,
  1267. projectIds.length
  1268. )
  1269. if (hadError) {
  1270. totalErrors++
  1271. }
  1272. })
  1273. )
  1274. await Promise.allSettled(operations)
  1275. console.error('\nComparison Summary:')
  1276. console.error('==================')
  1277. console.error(`Total projects processed: ${totalProjects}`)
  1278. console.error(`Projects with errors: ${totalErrors}`)
  1279. if (totalErrors > 0) {
  1280. throw new Error('Some project comparisons failed')
  1281. }
  1282. }
  1283. async function compareAllProjects(options) {
  1284. const limiter = pLimit(BATCH_CONCURRENCY)
  1285. let totalErrors = 0
  1286. let totalProjects = 0
  1287. async function processBatch(batch) {
  1288. if (gracefulShutdownInitiated) {
  1289. throw new Error('graceful shutdown')
  1290. }
  1291. const batchOperations = batch.map(project =>
  1292. limiter(async () => {
  1293. const projectId = project._id.toHexString()
  1294. totalProjects++
  1295. try {
  1296. console.log(`\nComparing project ${projectId} (${totalProjects})`)
  1297. await compareBackups(projectId, options)
  1298. } catch (err) {
  1299. totalErrors++
  1300. console.error(`Failed to compare project ${projectId}:`, err)
  1301. }
  1302. })
  1303. )
  1304. await Promise.allSettled(batchOperations)
  1305. }
  1306. const query = {
  1307. 'overleaf.history.id': { $exists: true },
  1308. 'overleaf.backup.lastBackedUpVersion': { $exists: true },
  1309. }
  1310. await batchedUpdate(
  1311. client.db().collection('projects'),
  1312. query,
  1313. processBatch,
  1314. {
  1315. _id: 1,
  1316. 'overleaf.history': 1,
  1317. 'overleaf.backup': 1,
  1318. },
  1319. { readPreference: 'secondary' },
  1320. {
  1321. BATCH_RANGE_START: convertToISODate(options['start-date']),
  1322. BATCH_RANGE_END: convertToISODate(options['end-date']),
  1323. }
  1324. )
  1325. console.log('\nComparison Summary:')
  1326. console.log('==================')
  1327. console.log(`Total projects processed: ${totalProjects}`)
  1328. console.log(`Projects with errors: ${totalErrors}`)
  1329. if (totalErrors > 0) {
  1330. throw new Error('Some project comparisons failed')
  1331. }
  1332. }
  1333. async function main() {
  1334. const options = handleOptions()
  1335. await ensureGlobalBlobsLoaded()
  1336. const projectId = options.projectId
  1337. if (options.status) {
  1338. await displayBackupStatus(projectId)
  1339. } else if (options.list) {
  1340. await displayPendingBackups(options)
  1341. } else if (options.fix !== undefined) {
  1342. await fixProjectsWithoutChunks(options)
  1343. } else if (options.pending) {
  1344. await backupPendingProjects(options)
  1345. } else if (options.init) {
  1346. await initializeProjects(options)
  1347. } else if (options.compare) {
  1348. if (options.input) {
  1349. await compareProjectsFromFile(options)
  1350. } else if (options['start-date'] && options['end-date']) {
  1351. await compareAllProjects(options)
  1352. } else {
  1353. await compareBackups(projectId, options)
  1354. }
  1355. } else {
  1356. await backupProject(projectId, options)
  1357. }
  1358. }
  1359. /**
  1360. * Close all database connections gracefully
  1361. * @returns {Promise<void>}
  1362. */
  1363. export async function closeConnections() {
  1364. /** @type {Error[]} */
  1365. const errors = []
  1366. try {
  1367. await knex.destroy()
  1368. console.log('Postgres connection closed')
  1369. } catch (err) {
  1370. console.error('Error closing Postgres connection:', err)
  1371. errors.push(/** @type {Error} */ (err))
  1372. }
  1373. try {
  1374. await client.close()
  1375. console.log('MongoDB connection closed')
  1376. } catch (err) {
  1377. console.error('Error closing MongoDB connection:', err)
  1378. errors.push(/** @type {Error} */ (err))
  1379. }
  1380. try {
  1381. await redis.disconnect()
  1382. console.log('Redis connection closed')
  1383. } catch (err) {
  1384. console.error('Error closing Redis connection:', err)
  1385. errors.push(/** @type {Error} */ (err))
  1386. }
  1387. if (errors.length > 0) {
  1388. throw new Error(
  1389. `Failed to close ${errors.length} connection(s): ${errors.map(e => e.message).join(', ')}`
  1390. )
  1391. }
  1392. }
  1393. // Only run command-line interface when script is run directly
  1394. if (import.meta.url === `file://${process.argv[1]}`) {
  1395. main()
  1396. .then(() => {
  1397. console.log(
  1398. gracefulShutdownInitiated ? 'Exited - graceful shutdown' : 'Completed'
  1399. )
  1400. })
  1401. .catch(err => {
  1402. console.error('Error backing up project:', err)
  1403. process.exit(1)
  1404. })
  1405. .finally(async () => {
  1406. await closeConnections()
  1407. })
  1408. }