backup.mjs 30 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102
  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. } from '../lib/chunk_store/index.js'
  10. import { client } from '../lib/mongodb.js'
  11. import redis from '../lib/redis.js'
  12. import knex from '../lib/knex.js'
  13. import { historyStore } from '../lib/history_store.js'
  14. import pLimit from 'p-limit'
  15. import {
  16. GLOBAL_BLOBS,
  17. loadGlobalBlobs,
  18. makeProjectKey,
  19. BlobStore,
  20. } from '../lib/blob_store/index.js'
  21. import {
  22. listPendingBackups,
  23. getBackupStatus,
  24. setBackupVersion,
  25. updateCurrentMetadataIfNotSet,
  26. updatePendingChangeTimestamp,
  27. getBackedUpBlobHashes,
  28. unsetBackedUpBlobHashes,
  29. } from '../lib/backup_store/index.js'
  30. import { backupBlob, downloadBlobToDir } from '../lib/backupBlob.mjs'
  31. import {
  32. backupPersistor,
  33. chunksBucket,
  34. projectBlobsBucket,
  35. } from '../lib/backupPersistor.mjs'
  36. import { backupGenerator } from '../lib/backupGenerator.mjs'
  37. import { promises as fs, createWriteStream } from 'node:fs'
  38. import os from 'node:os'
  39. import path from 'node:path'
  40. import projectKey from '../lib/project_key.js'
  41. import Crypto from 'node:crypto'
  42. import Stream from 'node:stream'
  43. import { EventEmitter } from 'node:events'
  44. import {
  45. objectIdFromInput,
  46. batchedUpdate,
  47. READ_PREFERENCE_SECONDARY,
  48. } from '@overleaf/mongo-utils/batchedUpdate.js'
  49. import { createGunzip } from 'node:zlib'
  50. import { text } from 'node:stream/consumers'
  51. import { fromStream as blobHashFromStream } from '../lib/blob_hash.js'
  52. import { NotFoundError } from '@overleaf/object-persistor/src/Errors.js'
  53. // Create a singleton promise that loads global blobs once
  54. let globalBlobsPromise = null
  55. function ensureGlobalBlobsLoaded() {
  56. if (!globalBlobsPromise) {
  57. globalBlobsPromise = loadGlobalBlobs()
  58. }
  59. return globalBlobsPromise
  60. }
  61. EventEmitter.defaultMaxListeners = 20
  62. logger.initialize('history-v1-backup')
  63. // Settings shared between command-line and module usage
  64. let DRY_RUN = false
  65. let RETRY_LIMIT = 3
  66. const RETRY_DELAY = 1000
  67. let CONCURRENCY = 4
  68. let BATCH_CONCURRENCY = 1
  69. let BLOB_LIMITER = pLimit(CONCURRENCY)
  70. let USE_SECONDARY = false
  71. /**
  72. * Configure backup settings
  73. * @param {Object} options Backup configuration options
  74. */
  75. export function configureBackup(options = {}) {
  76. DRY_RUN = options.dryRun || false
  77. RETRY_LIMIT = options.retries || 3
  78. CONCURRENCY = options.concurrency || 1
  79. BATCH_CONCURRENCY = options.batchConcurrency || 1
  80. BLOB_LIMITER = pLimit(CONCURRENCY)
  81. USE_SECONDARY = options.useSecondary || false
  82. }
  83. let gracefulShutdownInitiated = false
  84. process.on('SIGINT', handleSignal)
  85. process.on('SIGTERM', handleSignal)
  86. function handleSignal() {
  87. gracefulShutdownInitiated = true
  88. logger.info({}, 'graceful shutdown initiated, draining queue')
  89. }
  90. async function retry(fn, times, delayMs) {
  91. let attempts = times
  92. while (attempts > 0) {
  93. try {
  94. const result = await fn()
  95. return result
  96. } catch (err) {
  97. attempts--
  98. if (attempts === 0) throw err
  99. await new Promise(resolve => setTimeout(resolve, delayMs))
  100. }
  101. }
  102. }
  103. function wrapWithRetry(fn, retries, delayMs) {
  104. return async (...args) => {
  105. const result = await retry(() => fn(...args), retries, delayMs)
  106. return result
  107. }
  108. }
  109. const downloadWithRetry = wrapWithRetry(
  110. downloadBlobToDir,
  111. RETRY_LIMIT,
  112. RETRY_DELAY
  113. )
  114. // FIXME: this creates a new backupPersistor for each blob
  115. // so there is no caching of the DEK
  116. const backupWithRetry = wrapWithRetry(backupBlob, RETRY_LIMIT, RETRY_DELAY)
  117. async function findNewBlobs(projectId, blobs) {
  118. const newBlobs = []
  119. const existingBackedUpBlobHashes = await getBackedUpBlobHashes(projectId)
  120. for (const blob of blobs) {
  121. const hash = blob.getHash()
  122. if (existingBackedUpBlobHashes.has(blob.getHash())) {
  123. logger.debug({ projectId, hash }, 'Blob is already backed up, skipping')
  124. continue
  125. }
  126. const globalBlob = GLOBAL_BLOBS.get(hash)
  127. if (globalBlob && !globalBlob.demoted) {
  128. logger.debug(
  129. { projectId, hash },
  130. 'Blob is a global blob and not demoted, skipping'
  131. )
  132. continue
  133. }
  134. newBlobs.push(blob)
  135. }
  136. return newBlobs
  137. }
  138. async function cleanBackedUpBlobs(projectId, blobs) {
  139. const hashes = blobs.map(blob => blob.getHash())
  140. if (DRY_RUN) {
  141. console.log(
  142. 'Would remove blobs',
  143. hashes.join(' '),
  144. 'from project',
  145. projectId
  146. )
  147. return
  148. }
  149. await unsetBackedUpBlobHashes(projectId, hashes)
  150. }
  151. async function backupSingleBlob(projectId, historyId, blob, tmpDir, persistor) {
  152. if (DRY_RUN) {
  153. console.log(
  154. 'Would back up blob',
  155. JSON.stringify(blob),
  156. 'in history',
  157. historyId,
  158. 'for project',
  159. projectId
  160. )
  161. return
  162. }
  163. logger.debug({ blob, historyId }, 'backing up blob')
  164. const blobPath = await downloadWithRetry(historyId, blob, tmpDir)
  165. await backupWithRetry(historyId, blob, blobPath, persistor)
  166. }
  167. async function backupBlobs(projectId, historyId, blobs, limiter, persistor) {
  168. let tmpDir
  169. try {
  170. tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'blob-backup-'))
  171. const blobBackupOperations = blobs.map(blob =>
  172. limiter(backupSingleBlob, projectId, historyId, blob, tmpDir, persistor)
  173. )
  174. // Reject if any blob backup fails
  175. await Promise.all(blobBackupOperations)
  176. } finally {
  177. if (tmpDir) {
  178. await fs.rm(tmpDir, { recursive: true, force: true })
  179. }
  180. }
  181. }
  182. async function backupChunk(
  183. projectId,
  184. historyId,
  185. chunkBackupPersistorForProject,
  186. chunkToBackup,
  187. chunkRecord,
  188. chunkBuffer
  189. ) {
  190. if (DRY_RUN) {
  191. console.log(
  192. 'Would back up chunk',
  193. JSON.stringify(chunkRecord),
  194. 'in history',
  195. historyId,
  196. 'for project',
  197. projectId,
  198. 'key',
  199. makeChunkKey(historyId, chunkToBackup.startVersion)
  200. )
  201. return
  202. }
  203. const key = makeChunkKey(historyId, chunkToBackup.startVersion)
  204. logger.debug({ chunkRecord, historyId, projectId, key }, 'backing up chunk')
  205. await chunkBackupPersistorForProject.sendStream(
  206. chunksBucket,
  207. makeChunkKey(historyId, chunkToBackup.startVersion),
  208. Stream.Readable.from([chunkBuffer]),
  209. {
  210. contentType: 'application/json',
  211. contentEncoding: 'gzip',
  212. contentLength: chunkBuffer.byteLength,
  213. }
  214. )
  215. }
  216. async function updateBackupStatus(
  217. projectId,
  218. lastBackedUpVersion,
  219. chunkRecord,
  220. startOfBackupTime
  221. ) {
  222. if (DRY_RUN) {
  223. console.log(
  224. 'Would set backup version to',
  225. chunkRecord.endVersion,
  226. 'with lastBackedUpTimestamp',
  227. startOfBackupTime
  228. )
  229. return
  230. }
  231. logger.debug(
  232. { projectId, chunkRecord, startOfBackupTime },
  233. 'setting backupVersion and lastBackedUpTimestamp'
  234. )
  235. await setBackupVersion(
  236. projectId,
  237. lastBackedUpVersion,
  238. chunkRecord.endVersion,
  239. startOfBackupTime
  240. )
  241. }
  242. // Define command-line options
  243. const optionDefinitions = [
  244. {
  245. name: 'projectId',
  246. alias: 'p',
  247. type: String,
  248. description: 'The ID of the project to backup',
  249. defaultOption: true,
  250. },
  251. {
  252. name: 'help',
  253. alias: 'h',
  254. type: Boolean,
  255. description: 'Display this usage guide.',
  256. },
  257. {
  258. name: 'status',
  259. alias: 's',
  260. type: Boolean,
  261. description: 'Display project status.',
  262. },
  263. {
  264. name: 'list',
  265. alias: 'l',
  266. type: Boolean,
  267. description: 'List projects that need to be backed up',
  268. },
  269. {
  270. name: 'dry-run',
  271. alias: 'n',
  272. type: Boolean,
  273. description: 'Perform a dry run without making any changes.',
  274. },
  275. {
  276. name: 'retries',
  277. alias: 'r',
  278. type: Number,
  279. description: 'Number of retries, default is 3.',
  280. },
  281. {
  282. name: 'concurrency',
  283. alias: 'c',
  284. type: Number,
  285. description: 'Number of concurrent blob downloads (default: 1)',
  286. },
  287. {
  288. name: 'batch-concurrency',
  289. alias: 'b',
  290. type: Number,
  291. description: 'Number of concurrent project operations (default: 1)',
  292. },
  293. {
  294. name: 'pending',
  295. alias: 'P',
  296. type: Boolean,
  297. description: 'Backup all pending projects.',
  298. },
  299. {
  300. name: 'interval',
  301. alias: 'i',
  302. type: Number,
  303. description: 'Time interval in seconds for pending backups (default: 3600)',
  304. defaultValue: 3600,
  305. },
  306. {
  307. name: 'fix',
  308. type: Number,
  309. description: 'Fix projects without chunks',
  310. },
  311. {
  312. name: 'init',
  313. alias: 'I',
  314. type: Boolean,
  315. description: 'Initialize backups for all projects.',
  316. },
  317. { name: 'output', alias: 'o', type: String, description: 'Output file' },
  318. {
  319. name: 'start-date',
  320. type: String,
  321. description: 'Start date for initialization (ISO format)',
  322. },
  323. {
  324. name: 'end-date',
  325. type: String,
  326. description: 'End date for initialization (ISO format)',
  327. },
  328. {
  329. name: 'use-secondary',
  330. type: Boolean,
  331. description: 'Use secondary read preference for backup status',
  332. },
  333. {
  334. name: 'compare',
  335. alias: 'C',
  336. type: Boolean,
  337. description:
  338. 'Compare backup with original chunks. With --start-date and --end-date compares all projects in range.',
  339. },
  340. ]
  341. function handleOptions() {
  342. const options = commandLineArgs(optionDefinitions)
  343. if (options.help) {
  344. console.log('Usage:')
  345. optionDefinitions.forEach(option => {
  346. console.log(` --${option.name}, -${option.alias}: ${option.description}`)
  347. })
  348. process.exit(0)
  349. }
  350. const projectIdRequired =
  351. !options.list &&
  352. !options.pending &&
  353. !options.init &&
  354. !(options.fix >= 0) &&
  355. !(options.compare && options['start-date'] && options['end-date'])
  356. if (projectIdRequired && !options.projectId) {
  357. console.error('Error: projectId is required')
  358. process.exit(1)
  359. }
  360. if (options.pending && options.projectId) {
  361. console.error('Error: --pending cannot be specified with projectId')
  362. process.exit(1)
  363. }
  364. if (options.pending && (options.list || options.status)) {
  365. console.error('Error: --pending is exclusive with --list and --status')
  366. process.exit(1)
  367. }
  368. if (options.init && options.pending) {
  369. console.error('Error: --init cannot be specified with --pending')
  370. process.exit(1)
  371. }
  372. if (
  373. (options['start-date'] || options['end-date']) &&
  374. !options.init &&
  375. !options.compare
  376. ) {
  377. console.error(
  378. 'Error: date options can only be used with --init or --compare'
  379. )
  380. process.exit(1)
  381. }
  382. if (options['use-secondary']) {
  383. USE_SECONDARY = true
  384. }
  385. if (
  386. options.compare &&
  387. !options.projectId &&
  388. !(options['start-date'] && options['end-date'])
  389. ) {
  390. console.error(
  391. 'Error: --compare requires either projectId or both --start-date and --end-date'
  392. )
  393. process.exit(1)
  394. }
  395. DRY_RUN = options['dry-run'] || false
  396. RETRY_LIMIT = options.retries || 3
  397. CONCURRENCY = options.concurrency || 1
  398. BATCH_CONCURRENCY = options['batch-concurrency'] || 1
  399. BLOB_LIMITER = pLimit(CONCURRENCY)
  400. return options
  401. }
  402. async function displayBackupStatus(projectId) {
  403. const result = await analyseBackupStatus(projectId)
  404. console.log('Backup status:', JSON.stringify(result))
  405. }
  406. async function analyseBackupStatus(projectId) {
  407. const { backupStatus, historyId, currentEndVersion, currentEndTimestamp } =
  408. await getBackupStatus(projectId)
  409. // TODO: when we have confidence that the latestChunkMetadata always matches
  410. // the values from the backupStatus we can skip loading it here
  411. const latestChunkMetadata = await getLatestChunkMetadata(historyId, {
  412. readOnly: Boolean(USE_SECONDARY),
  413. })
  414. if (
  415. currentEndVersion &&
  416. currentEndVersion !== latestChunkMetadata.endVersion
  417. ) {
  418. // compare the current end version with the latest chunk metadata to check that
  419. // the updates to the project collection are reliable
  420. // expect some failures due to the time window between getBackupStatus and
  421. // getLatestChunkMetadata where the project is being actively edited.
  422. logger.warn(
  423. {
  424. projectId,
  425. historyId,
  426. currentEndVersion,
  427. currentEndTimestamp,
  428. latestChunkMetadata,
  429. },
  430. 'currentEndVersion does not match latest chunk metadata'
  431. )
  432. }
  433. if (DRY_RUN) {
  434. console.log('Project:', projectId)
  435. console.log('History ID:', historyId)
  436. console.log('Latest Chunk Metadata:', JSON.stringify(latestChunkMetadata))
  437. console.log('Current end version:', currentEndVersion)
  438. console.log('Current end timestamp:', currentEndTimestamp)
  439. console.log('Backup status:', backupStatus ?? 'none')
  440. }
  441. if (!backupStatus) {
  442. if (DRY_RUN) {
  443. console.log('No backup status found - doing full backup')
  444. }
  445. }
  446. const lastBackedUpVersion = backupStatus?.lastBackedUpVersion
  447. const endVersion = latestChunkMetadata.endVersion
  448. if (endVersion >= 0 && endVersion === lastBackedUpVersion) {
  449. if (DRY_RUN) {
  450. console.log(
  451. 'Project is up to date, last backed up at version',
  452. lastBackedUpVersion
  453. )
  454. }
  455. } else if (endVersion < lastBackedUpVersion) {
  456. throw new Error('backup is ahead of project')
  457. } else {
  458. if (DRY_RUN) {
  459. console.log(
  460. 'Project needs to be backed up from',
  461. lastBackedUpVersion,
  462. 'to',
  463. endVersion
  464. )
  465. }
  466. }
  467. return {
  468. historyId,
  469. lastBackedUpVersion,
  470. currentVersion: latestChunkMetadata.endVersion || 0,
  471. upToDate: endVersion >= 0 && lastBackedUpVersion === endVersion,
  472. pendingChangeAt: backupStatus?.pendingChangeAt,
  473. currentEndVersion,
  474. currentEndTimestamp,
  475. latestChunkMetadata,
  476. }
  477. }
  478. async function displayPendingBackups(options) {
  479. const intervalMs = options.interval * 1000
  480. for await (const project of listPendingBackups(intervalMs)) {
  481. console.log(
  482. 'Project:',
  483. project._id.toHexString(),
  484. 'backup status:',
  485. JSON.stringify(project.overleaf.backup),
  486. 'history status:',
  487. JSON.stringify(project.overleaf.history, [
  488. 'currentEndVersion',
  489. 'currentEndTimestamp',
  490. ])
  491. )
  492. }
  493. }
  494. function makeChunkKey(projectId, startVersion) {
  495. return path.join(projectKey.format(projectId), projectKey.pad(startVersion))
  496. }
  497. export async function backupProject(projectId, options) {
  498. if (gracefulShutdownInitiated) {
  499. return
  500. }
  501. await ensureGlobalBlobsLoaded()
  502. // FIXME: flush the project first!
  503. // Let's assume the the flush happens externally and triggers this backup
  504. const backupStartTime = new Date()
  505. // find the last backed up version
  506. const {
  507. historyId,
  508. lastBackedUpVersion,
  509. currentVersion,
  510. upToDate,
  511. pendingChangeAt,
  512. currentEndVersion,
  513. latestChunkMetadata,
  514. } = await analyseBackupStatus(projectId)
  515. if (upToDate) {
  516. logger.debug(
  517. {
  518. projectId,
  519. historyId,
  520. lastBackedUpVersion,
  521. currentVersion,
  522. pendingChangeAt,
  523. },
  524. 'backup is up to date'
  525. )
  526. if (
  527. currentEndVersion === undefined &&
  528. latestChunkMetadata.endVersion >= 0
  529. ) {
  530. if (DRY_RUN) {
  531. console.log('Would update current metadata to', latestChunkMetadata)
  532. } else {
  533. await updateCurrentMetadataIfNotSet(projectId, latestChunkMetadata)
  534. }
  535. }
  536. // clear the pending changes timestamp if the backup is complete
  537. if (pendingChangeAt) {
  538. if (DRY_RUN) {
  539. console.log(
  540. 'Would update or clear pending changes timestamp',
  541. backupStartTime
  542. )
  543. } else {
  544. await updatePendingChangeTimestamp(projectId, backupStartTime)
  545. }
  546. }
  547. return
  548. }
  549. logger.debug(
  550. {
  551. projectId,
  552. historyId,
  553. lastBackedUpVersion,
  554. currentVersion,
  555. pendingChangeAt,
  556. },
  557. 'backing up project'
  558. )
  559. // this persistor works for both the chunks and blobs buckets,
  560. // because they use the same DEK
  561. const backupPersistorForProject = await backupPersistor.forProject(
  562. chunksBucket,
  563. makeProjectKey(historyId, '')
  564. )
  565. let previousBackedUpVersion = lastBackedUpVersion
  566. const backupVersions = [previousBackedUpVersion]
  567. for await (const {
  568. blobsToBackup,
  569. chunkToBackup,
  570. chunkRecord,
  571. chunkBuffer,
  572. } of backupGenerator(historyId, lastBackedUpVersion)) {
  573. // backup the blobs first
  574. // this can be done in parallel but must fail if any blob cannot be backed up
  575. // if the blob already exists in the backup then that is allowed
  576. const newBlobs = await findNewBlobs(projectId, blobsToBackup)
  577. await backupBlobs(
  578. projectId,
  579. historyId,
  580. newBlobs,
  581. BLOB_LIMITER,
  582. backupPersistorForProject
  583. )
  584. // then backup the original compressed chunk using the startVersion as the key
  585. await backupChunk(
  586. projectId,
  587. historyId,
  588. backupPersistorForProject,
  589. chunkToBackup,
  590. chunkRecord,
  591. chunkBuffer
  592. )
  593. // persist the backup status in mongo for the current chunk
  594. try {
  595. await updateBackupStatus(
  596. projectId,
  597. previousBackedUpVersion,
  598. chunkRecord,
  599. backupStartTime
  600. )
  601. } catch (err) {
  602. logger.error(
  603. { projectId, chunkRecord, err, backupVersions },
  604. 'error updating backup status'
  605. )
  606. throw err
  607. }
  608. previousBackedUpVersion = chunkRecord.endVersion
  609. backupVersions.push(previousBackedUpVersion)
  610. await cleanBackedUpBlobs(projectId, blobsToBackup)
  611. }
  612. // update the current end version and timestamp if they are not set
  613. if (currentEndVersion === undefined && latestChunkMetadata.endVersion >= 0) {
  614. if (DRY_RUN) {
  615. console.log('Would update current metadata to', latestChunkMetadata)
  616. } else {
  617. await updateCurrentMetadataIfNotSet(projectId, latestChunkMetadata)
  618. }
  619. }
  620. // clear the pending changes timestamp if the backup is complete, otherwise set it to the time
  621. // when the backup started (to pick up the new changes on the next backup)
  622. if (DRY_RUN) {
  623. console.log(
  624. 'Would update or clear pending changes timestamp',
  625. backupStartTime
  626. )
  627. } else {
  628. await updatePendingChangeTimestamp(projectId, backupStartTime)
  629. }
  630. }
  631. function convertToISODate(dateStr) {
  632. // Expecting YYYY-MM-DD format
  633. if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
  634. throw new Error('Date must be in YYYY-MM-DD format')
  635. }
  636. return new Date(dateStr + 'T00:00:00.000Z').toISOString()
  637. }
  638. export async function fixProjectsWithoutChunks(options) {
  639. const limit = options.fix || 1
  640. const query = {
  641. 'overleaf.history.id': { $exists: true },
  642. 'overleaf.backup.lastBackedUpVersion': { $in: [null] },
  643. }
  644. const cursor = client
  645. .db()
  646. .collection('projects')
  647. .find(query, {
  648. projection: { _id: 1, 'overleaf.history.id': 1 },
  649. readPreference: READ_PREFERENCE_SECONDARY,
  650. })
  651. .limit(limit)
  652. for await (const project of cursor) {
  653. const historyId = project.overleaf.history.id.toString()
  654. const chunks = await getProjectChunks(historyId)
  655. if (chunks.length > 0) {
  656. continue
  657. }
  658. if (DRY_RUN) {
  659. console.log(
  660. 'Would create new chunk for Project ID:',
  661. project._id.toHexString(),
  662. 'History ID:',
  663. historyId,
  664. 'Chunks:',
  665. chunks
  666. )
  667. } else {
  668. console.log(
  669. 'Creating new chunk for Project ID:',
  670. project._id.toHexString(),
  671. 'History ID:',
  672. historyId,
  673. 'Chunks:',
  674. chunks
  675. )
  676. const snapshot = new Snapshot()
  677. const history = new History(snapshot, [])
  678. const chunk = new Chunk(history, 0)
  679. await create(historyId, chunk)
  680. const newChunks = await getProjectChunks(historyId)
  681. console.log('New chunk:', newChunks)
  682. }
  683. }
  684. }
  685. export async function initializeProjects(options) {
  686. await ensureGlobalBlobsLoaded()
  687. let totalErrors = 0
  688. let totalProjects = 0
  689. const query = {
  690. 'overleaf.backup.lastBackedUpVersion': { $in: [null] },
  691. }
  692. if (options['start-date'] && options['end-date']) {
  693. query._id = {
  694. $gte: objectIdFromInput(convertToISODate(options['start-date'])),
  695. $lt: objectIdFromInput(convertToISODate(options['end-date'])),
  696. }
  697. }
  698. const cursor = client
  699. .db()
  700. .collection('projects')
  701. .find(query, {
  702. projection: { _id: 1 },
  703. readPreference: READ_PREFERENCE_SECONDARY,
  704. })
  705. if (options.output) {
  706. console.log("Writing project IDs to file: '" + options.output + "'")
  707. const output = createWriteStream(options.output)
  708. for await (const project of cursor) {
  709. output.write(project._id.toHexString() + '\n')
  710. totalProjects++
  711. }
  712. output.end()
  713. console.log('Wrote ' + totalProjects + ' project IDs to file')
  714. return
  715. }
  716. for await (const project of cursor) {
  717. if (gracefulShutdownInitiated) {
  718. console.warn('graceful shutdown: stopping project initialization')
  719. break
  720. }
  721. totalProjects++
  722. const projectId = project._id.toHexString()
  723. try {
  724. await backupProject(projectId, options)
  725. } catch (err) {
  726. totalErrors++
  727. logger.error({ projectId, err }, 'error backing up project')
  728. }
  729. }
  730. return { errors: totalErrors, projects: totalProjects }
  731. }
  732. async function backupPendingProjects(options) {
  733. const intervalMs = options.interval * 1000
  734. for await (const project of listPendingBackups(intervalMs)) {
  735. if (gracefulShutdownInitiated) {
  736. console.warn('graceful shutdown: stopping pending project backups')
  737. break
  738. }
  739. const projectId = project._id.toHexString()
  740. console.log(`Backing up pending project with ID: ${projectId}`)
  741. await backupProject(projectId, options)
  742. }
  743. }
  744. class BlobComparator {
  745. constructor(backupPersistorForProject) {
  746. this.cache = new Map()
  747. this.backupPersistorForProject = backupPersistorForProject
  748. }
  749. async compareBlob(historyId, blob) {
  750. let computedHash = this.cache.get(blob.hash)
  751. const fromCache = !!computedHash
  752. if (!computedHash) {
  753. const blobKey = makeProjectKey(historyId, blob.hash)
  754. const backupBlobStream =
  755. await this.backupPersistorForProject.getObjectStream(
  756. projectBlobsBucket,
  757. blobKey,
  758. { autoGunzip: true }
  759. )
  760. computedHash = await blobHashFromStream(blob.byteLength, backupBlobStream)
  761. this.cache.set(blob.hash, computedHash)
  762. }
  763. const matches = computedHash === blob.hash
  764. return {
  765. matches,
  766. computedHash,
  767. fromCache,
  768. }
  769. }
  770. }
  771. async function compareBackups(projectId, options) {
  772. console.log(`Comparing backups for project ${projectId}`)
  773. const { historyId } = await getBackupStatus(projectId)
  774. const chunks = await getProjectChunks(historyId)
  775. const blobStore = new BlobStore(historyId)
  776. const backupPersistorForProject = await backupPersistor.forProject(
  777. chunksBucket,
  778. makeProjectKey(historyId, '')
  779. )
  780. let totalChunkMatches = 0
  781. let totalChunkMismatches = 0
  782. let totalChunksNotFound = 0
  783. let totalBlobMatches = 0
  784. let totalBlobMismatches = 0
  785. let totalBlobsNotFound = 0
  786. const errors = []
  787. const blobComparator = new BlobComparator(backupPersistorForProject)
  788. for (const chunk of chunks) {
  789. try {
  790. // Compare chunk content
  791. const originalChunk = await historyStore.loadRaw(historyId, chunk.id)
  792. const key = makeChunkKey(historyId, chunk.startVersion)
  793. try {
  794. const backupChunkStream =
  795. await backupPersistorForProject.getObjectStream(chunksBucket, key)
  796. const backupStr = await text(backupChunkStream.pipe(createGunzip()))
  797. const originalStr = JSON.stringify(originalChunk)
  798. const backupChunk = JSON.parse(backupStr)
  799. const backupStartVersion = chunk.startVersion
  800. const backupEndVersion = chunk.startVersion + backupChunk.changes.length
  801. if (originalStr === backupStr) {
  802. console.log(
  803. `✓ Chunk ${chunk.id} (v${chunk.startVersion}-v${chunk.endVersion}) matches`
  804. )
  805. totalChunkMatches++
  806. } else if (originalStr === JSON.stringify(JSON.parse(backupStr))) {
  807. console.log(
  808. `✓ Chunk ${chunk.id} (v${chunk.startVersion}-v${chunk.endVersion}) matches (after normalisation)`
  809. )
  810. totalChunkMatches++
  811. } else if (backupEndVersion < chunk.endVersion) {
  812. console.log(
  813. `✗ Chunk ${chunk.id} is ahead of backup (v${chunk.startVersion}-v${chunk.endVersion} vs v${backupStartVersion}-v${backupEndVersion})`
  814. )
  815. totalChunkMismatches++
  816. errors.push({ chunkId: chunk.id, error: 'Chunk ahead of backup' })
  817. } else {
  818. console.log(
  819. `✗ Chunk ${chunk.id} (v${chunk.startVersion}-v${chunk.endVersion}) MISMATCH`
  820. )
  821. totalChunkMismatches++
  822. errors.push({ chunkId: chunk.id, error: 'Chunk mismatch' })
  823. }
  824. } catch (err) {
  825. if (err instanceof NotFoundError) {
  826. console.log(`✗ Chunk ${chunk.id} not found in backup`, err.cause)
  827. totalChunksNotFound++
  828. errors.push({ chunkId: chunk.id, error: `Chunk not found` })
  829. } else {
  830. throw err
  831. }
  832. }
  833. const history = History.fromRaw(originalChunk)
  834. // Compare blobs in chunk
  835. const blobHashes = new Set()
  836. history.findBlobHashes(blobHashes)
  837. const blobs = await blobStore.getBlobs(Array.from(blobHashes))
  838. for (const blob of blobs) {
  839. if (GLOBAL_BLOBS.has(blob.hash)) {
  840. const globalBlob = GLOBAL_BLOBS.get(blob.hash)
  841. console.log(
  842. ` ✓ Blob ${blob.hash} is a global blob`,
  843. globalBlob?.demoted ? '(demoted)' : ''
  844. )
  845. continue
  846. }
  847. try {
  848. const { matches, computedHash, fromCache } =
  849. await blobComparator.compareBlob(historyId, blob)
  850. if (matches) {
  851. console.log(
  852. ` ✓ Blob ${blob.hash} hash matches (${blob.byteLength} bytes)` +
  853. (fromCache ? ' (from cache)' : '')
  854. )
  855. totalBlobMatches++
  856. } else {
  857. console.log(
  858. ` ✗ Blob ${blob.hash} hash mismatch (original: ${blob.hash}, backup: ${computedHash}) (${blob.byteLength} bytes, ${blob.stringLength} string length)` +
  859. (fromCache ? ' (from cache)' : '')
  860. )
  861. totalBlobMismatches++
  862. errors.push({
  863. chunkId: chunk.id,
  864. error: `Blob ${blob.hash} hash mismatch`,
  865. })
  866. }
  867. } catch (err) {
  868. if (err instanceof NotFoundError) {
  869. console.log(` ✗ Blob ${blob.hash} not found in backup`, err.cause)
  870. totalBlobsNotFound++
  871. errors.push({
  872. chunkId: chunk.id,
  873. error: `Blob ${blob.hash} not found`,
  874. })
  875. } else {
  876. throw err
  877. }
  878. }
  879. }
  880. } catch (err) {
  881. console.error(`Error comparing chunk ${chunk.id}:`, err)
  882. errors.push({ chunkId: chunk.id, error: err })
  883. }
  884. }
  885. // Print summary
  886. console.log('\nComparison Summary:')
  887. console.log('==================')
  888. console.log(`Total chunks: ${chunks.length}`)
  889. console.log(`Chunk matches: ${totalChunkMatches}`)
  890. console.log(`Chunk mismatches: ${totalChunkMismatches}`)
  891. console.log(`Chunk not found: ${totalChunksNotFound}`)
  892. console.log(`Blob matches: ${totalBlobMatches}`)
  893. console.log(`Blob mismatches: ${totalBlobMismatches}`)
  894. console.log(`Blob not found: ${totalBlobsNotFound}`)
  895. console.log(`Errors: ${errors.length}`)
  896. if (errors.length > 0) {
  897. console.log('\nErrors:')
  898. errors.forEach(({ chunkId, error }) => {
  899. console.log(` Chunk ${chunkId}: ${error}`)
  900. })
  901. throw new Error('Backup comparison FAILED')
  902. } else {
  903. console.log('Backup comparison successful')
  904. }
  905. }
  906. async function compareAllProjects(options) {
  907. const limiter = pLimit(BATCH_CONCURRENCY)
  908. let totalErrors = 0
  909. let totalProjects = 0
  910. async function processBatch(batch) {
  911. if (gracefulShutdownInitiated) {
  912. throw new Error('graceful shutdown')
  913. }
  914. const batchOperations = batch.map(project =>
  915. limiter(async () => {
  916. const projectId = project._id.toHexString()
  917. totalProjects++
  918. try {
  919. console.log(`\nComparing project ${projectId} (${totalProjects})`)
  920. await compareBackups(projectId, options)
  921. } catch (err) {
  922. totalErrors++
  923. console.error(`Failed to compare project ${projectId}:`, err)
  924. }
  925. })
  926. )
  927. await Promise.allSettled(batchOperations)
  928. }
  929. const query = {
  930. 'overleaf.history.id': { $exists: true },
  931. 'overleaf.backup.lastBackedUpVersion': { $exists: true },
  932. }
  933. await batchedUpdate(
  934. client.db().collection('projects'),
  935. query,
  936. processBatch,
  937. {
  938. _id: 1,
  939. 'overleaf.history': 1,
  940. 'overleaf.backup': 1,
  941. },
  942. { readPreference: 'secondary' },
  943. {
  944. BATCH_RANGE_START: convertToISODate(options['start-date']),
  945. BATCH_RANGE_END: convertToISODate(options['end-date']),
  946. }
  947. )
  948. console.log('\nComparison Summary:')
  949. console.log('==================')
  950. console.log(`Total projects processed: ${totalProjects}`)
  951. console.log(`Projects with errors: ${totalErrors}`)
  952. if (totalErrors > 0) {
  953. throw new Error('Some project comparisons failed')
  954. }
  955. }
  956. async function main() {
  957. const options = handleOptions()
  958. await ensureGlobalBlobsLoaded()
  959. const projectId = options.projectId
  960. if (options.status) {
  961. await displayBackupStatus(projectId)
  962. } else if (options.list) {
  963. await displayPendingBackups(options)
  964. } else if (options.fix !== undefined) {
  965. await fixProjectsWithoutChunks(options)
  966. } else if (options.pending) {
  967. await backupPendingProjects(options)
  968. } else if (options.init) {
  969. await initializeProjects(options)
  970. } else if (options.compare) {
  971. if (options['start-date'] && options['end-date']) {
  972. await compareAllProjects(options)
  973. } else {
  974. await compareBackups(projectId, options)
  975. }
  976. } else {
  977. await backupProject(projectId, options)
  978. }
  979. }
  980. // Only run command-line interface when script is run directly
  981. if (import.meta.url === `file://${process.argv[1]}`) {
  982. main()
  983. .then(() => {
  984. console.log(
  985. gracefulShutdownInitiated ? 'Exited - graceful shutdown' : 'Completed'
  986. )
  987. })
  988. .catch(err => {
  989. console.error('Error backing up project:', err)
  990. process.exit(1)
  991. })
  992. .finally(() => {
  993. knex
  994. .destroy()
  995. .then(() => {
  996. console.log('Postgres connection closed')
  997. })
  998. .catch(err => {
  999. console.error('Error closing Postgres connection:', err)
  1000. })
  1001. client
  1002. .close()
  1003. .then(() => {
  1004. console.log('MongoDB connection closed')
  1005. })
  1006. .catch(err => {
  1007. console.error('Error closing MongoDB connection:', err)
  1008. })
  1009. redis
  1010. .disconnect()
  1011. .then(() => {
  1012. console.log('Redis connection closed')
  1013. })
  1014. .catch(err => {
  1015. console.error('Error closing Redis connection:', err)
  1016. })
  1017. })
  1018. }