backup.mjs 31 KB

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