backup.mjs 30 KB

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