backup.test.mjs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682
  1. import config from 'config'
  2. import { ObjectId } from 'mongodb'
  3. import { expect } from 'chai'
  4. import {
  5. backedUpBlobs,
  6. client,
  7. globalBlobs,
  8. } from '../../../../storage/lib/mongodb.js'
  9. import persistor from '../../../../storage/lib/persistor.js'
  10. import {
  11. loadGlobalBlobs,
  12. BlobStore,
  13. makeProjectKey,
  14. } from '../../../../storage/lib/blob_store/index.js'
  15. import { NotFoundError } from '@overleaf/object-persistor/src/Errors.js'
  16. import projectKey from '../../../../storage/lib/project_key.js'
  17. import { getBackupStatus } from '../../../../storage/lib/backup_store/index.js'
  18. import { text, buffer } from 'node:stream/consumers'
  19. import { createGunzip } from 'node:zlib'
  20. import { Change, Operation, File, TextOperation } from 'overleaf-editor-core'
  21. import ChunkStore from '../../../../storage/lib/chunk_store/index.js'
  22. import persistChanges from '../../../../storage/lib/persist_changes.js'
  23. import { historyStore } from '../../../../storage/lib/history_store.js'
  24. import { execFile } from 'node:child_process'
  25. import { promisify } from 'node:util'
  26. import testFiles from '../storage/support/test_files.js'
  27. import fs from 'node:fs'
  28. import {
  29. backupBlob,
  30. storeBlobBackup,
  31. } from '../../../../storage/lib/backupBlob.mjs'
  32. import {
  33. backupPersistor,
  34. projectBlobsBucket,
  35. chunksBucket,
  36. } from '../../../../storage/lib/backupPersistor.mjs'
  37. import { Readable } from 'node:stream'
  38. const projectsCollection = client.db().collection('projects')
  39. /**
  40. * @param {ObjectId} projectId
  41. * @param {number} version
  42. * @return {string}
  43. */
  44. function makeChunkKey(projectId, version) {
  45. return projectKey.format(projectId) + '/' + projectKey.pad(version)
  46. }
  47. describe('backup script', function () {
  48. let project
  49. let projectId, historyId
  50. let limitsToPersistImmediately
  51. before(function () {
  52. // Used to provide a limit which forces us to persist all of the changes
  53. const farFuture = new Date()
  54. farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000)
  55. limitsToPersistImmediately = {
  56. minChangeTimestamp: farFuture,
  57. maxChangeTimestamp: farFuture,
  58. maxChanges: 10,
  59. maxChunkChanges: 10,
  60. }
  61. })
  62. beforeEach(async function () {
  63. // Set up test projects with proper history metadata
  64. projectId = new ObjectId()
  65. historyId = projectId.toString()
  66. project = {
  67. _id: projectId,
  68. overleaf: {
  69. history: {
  70. id: historyId,
  71. currentEndVersion: 0, // Will be updated as changes are made
  72. currentEndTimestamp: new Date(), // Will be updated as changes are made
  73. },
  74. backup: {
  75. // Start with no backup state
  76. },
  77. },
  78. }
  79. // Pre-load the global blobs
  80. await loadGlobalBlobs()
  81. // Clean up any pre-existing test data
  82. await projectsCollection.deleteMany({
  83. _id: projectId,
  84. })
  85. await backedUpBlobs.deleteMany({}) // Clear any existing backedUpBlobs entries
  86. })
  87. describe('with simple project content', function () {
  88. const contentString = 'hello world'
  89. const newContentString = 'hello world more'
  90. const graphPngPath = testFiles.path('graph.png')
  91. const graphPngBuf = fs.readFileSync(graphPngPath)
  92. const graphPngHash = testFiles.GRAPH_PNG_HASH
  93. const nonBmpPath = testFiles.path('non_bmp.txt')
  94. const DUMMY_HASH = '1111111111111111111111111111111111111111'
  95. beforeEach(async function () {
  96. // Create initial project
  97. await projectsCollection.insertOne(project)
  98. // Initialize project in chunk store
  99. await ChunkStore.initializeProject(historyId)
  100. const blobStore = new BlobStore(historyId)
  101. // Create the blobs and then back them up using backupBlob
  102. const graphPngBlob = await blobStore.putFile(graphPngPath)
  103. await backupBlob(historyId, graphPngBlob, graphPngPath)
  104. // Add initial content using persistChanges
  105. const file = File.fromString(contentString)
  106. const addFileOp = Operation.addFile('main.tex', file)
  107. const addGraphFileOp = Operation.addFile(
  108. 'graph.png',
  109. File.fromHash(testFiles.GRAPH_PNG_HASH)
  110. )
  111. const change1 = new Change([addFileOp, addGraphFileOp], new Date(), [])
  112. await persistChanges(historyId, [change1], limitsToPersistImmediately, 0)
  113. // Add a second change with a proper TextOperation
  114. // For text operation: first number is how many chars to retain, then the text to insert
  115. const textOp = TextOperation.fromJSON({
  116. textOperation: [contentString.length, ' more'], // Keep existing content, append ' more'
  117. })
  118. const editOp = Operation.editFile('main.tex', textOp)
  119. const change2 = new Change([editOp], new Date(), [])
  120. // store an unrelated hash in the backedUpBlobs collection,
  121. // so we can test that only the backed up hashes are cleared.
  122. await storeBlobBackup(historyId, DUMMY_HASH)
  123. await persistChanges(historyId, [change2], limitsToPersistImmediately, 1)
  124. })
  125. it('should perform an initial backup', async function () {
  126. // Run backup script for initial version
  127. const { stdout } = await runBackupScript(['--projectId', projectId])
  128. expect(stdout).to.not.include(
  129. 'warning: persistor not passed to backupBlob'
  130. )
  131. // Verify backup state
  132. const result = await getBackupStatus(projectId)
  133. expect(result.backupStatus.lastBackedUpVersion).to.equal(2)
  134. expect(result.backupStatus.lastBackedUpAt).to.be.an.instanceOf(Date)
  135. expect(result.currentEndTimestamp).to.be.an.instanceOf(Date)
  136. expect(result.backupStatus.pendingChangeAt).to.be.undefined
  137. // Verify graph.png blob was backed up
  138. const graphBlobStream = await backupPersistor.getObjectStream(
  139. projectBlobsBucket,
  140. makeProjectKey(historyId, graphPngHash),
  141. { autoGunzip: true }
  142. )
  143. const graphBlobContent = await buffer(graphBlobStream)
  144. expect(graphBlobContent.equals(graphPngBuf)).to.be.true
  145. // Verify chunk was backed up
  146. const chunkStream = await backupPersistor.getObjectStream(
  147. chunksBucket,
  148. makeChunkKey(historyId, 0)
  149. )
  150. const chunkContent = await text(chunkStream.pipe(createGunzip()))
  151. const chunkMetadata = await ChunkStore.getLatestChunkMetadata(historyId)
  152. const rawHistory = await historyStore.loadRaw(historyId, chunkMetadata.id)
  153. expect(JSON.parse(chunkContent)).to.deep.equal(rawHistory)
  154. // Unrelated entries from backedUpBlobs should be not cleared
  155. const backedUpBlobsDoc = await backedUpBlobs.findOne({
  156. _id: project._id,
  157. })
  158. expect(backedUpBlobsDoc).not.to.be.null
  159. expect(backedUpBlobsDoc.blobs).to.have.length(1)
  160. expect(backedUpBlobsDoc.blobs[0].toString('hex')).to.equal(DUMMY_HASH)
  161. })
  162. it('should perform an incremental backup', async function () {
  163. // Backup first version
  164. const { stdout: stdout1 } = await runBackupScript([
  165. '--projectId',
  166. projectId,
  167. ])
  168. expect(stdout1).to.not.include(
  169. 'warning: persistor not passed to backupBlob'
  170. )
  171. // Verify first backup
  172. const result1 = await getBackupStatus(projectId)
  173. expect(result1.backupStatus.lastBackedUpVersion).to.equal(2)
  174. // Persist additional changes
  175. const additionalTextOp = TextOperation.fromJSON({
  176. textOperation: [newContentString.length, ' even more'], // Keep existing content, append ' even more'
  177. })
  178. const additionalEditOp = Operation.editFile('main.tex', additionalTextOp)
  179. const firstTimestamp = new Date()
  180. const additionalChange = new Change(
  181. [additionalEditOp],
  182. firstTimestamp,
  183. []
  184. )
  185. // add the nonbmp file
  186. const blobStore = new BlobStore(historyId)
  187. const nonBmpBlob = await blobStore.putFile(nonBmpPath)
  188. await backupBlob(historyId, nonBmpBlob, nonBmpPath)
  189. // Verify that the non-BMP file was backed up when the file was added
  190. const newBackedUpBlobs = await backedUpBlobs.findOne({
  191. _id: project._id,
  192. })
  193. expect(newBackedUpBlobs).not.to.be.null
  194. expect(newBackedUpBlobs.blobs).to.have.length(2)
  195. expect(
  196. newBackedUpBlobs.blobs.map(b => b.toString('hex'))
  197. ).to.have.members([testFiles.NON_BMP_TXT_HASH, DUMMY_HASH])
  198. const addNonBmpFileOp = Operation.addFile(
  199. 'non_bmp.txt',
  200. File.fromHash(testFiles.NON_BMP_TXT_HASH)
  201. )
  202. const secondTimestamp = new Date()
  203. const additionalChange2 = new Change(
  204. [addNonBmpFileOp],
  205. secondTimestamp,
  206. []
  207. )
  208. await persistChanges(
  209. historyId,
  210. [additionalChange, additionalChange2],
  211. limitsToPersistImmediately,
  212. 2
  213. )
  214. const afterChangeResult = await getBackupStatus(projectId)
  215. // Verify that the currentEndVersion and currentEndTimestamp are updated
  216. expect(afterChangeResult.currentEndVersion).to.equal(4)
  217. expect(afterChangeResult.currentEndTimestamp)
  218. .to.be.an.instanceOf(Date)
  219. .and.to.be.greaterThan(result1.currentEndTimestamp)
  220. // Persisting a change should not modify the backup version and timestamp
  221. expect(afterChangeResult.backupStatus.lastBackedUpVersion).to.equal(2)
  222. expect(afterChangeResult.backupStatus.lastBackedUpAt)
  223. .to.be.an.instanceOf(Date)
  224. .and.to.deep.equal(result1.backupStatus.lastBackedUpAt)
  225. // but it should update the pendingChangeAt timestamp to the timestamp of the
  226. // first change which modified the project
  227. expect(afterChangeResult.backupStatus.pendingChangeAt)
  228. .to.be.an.instanceOf(Date)
  229. .and.to.deep.equal(firstTimestamp)
  230. // Second backup
  231. const { stdout: stdout2 } = await runBackupScript([
  232. '--projectId',
  233. projectId,
  234. ])
  235. expect(stdout2).to.not.include(
  236. 'warning: persistor not passed to backupBlob'
  237. )
  238. // Verify incremental backup
  239. const result2 = await getBackupStatus(projectId)
  240. // The backup version and timestamp should be updated
  241. expect(result2.backupStatus.lastBackedUpVersion).to.equal(4)
  242. expect(result2.backupStatus.lastBackedUpAt)
  243. .to.be.an.instanceOf(Date)
  244. .and.to.be.greaterThan(result1.backupStatus.lastBackedUpAt)
  245. // The currentEndVersion and currentEndTimestamp should not be modified
  246. expect(result2.currentEndVersion).to.equal(4)
  247. expect(result2.currentEndTimestamp)
  248. .to.be.an.instanceOf(Date)
  249. .and.to.deep.equal(afterChangeResult.currentEndTimestamp)
  250. // The pendingChangeAt timestamp should be cleared when the backup is complete
  251. expect(result2.backupStatus.pendingChangeAt).to.be.undefined
  252. // Verify additional blob was backed up
  253. const newBlobStream = await backupPersistor.getObjectStream(
  254. projectBlobsBucket,
  255. makeProjectKey(historyId, testFiles.NON_BMP_TXT_HASH),
  256. { autoGunzip: true }
  257. )
  258. const newBlobContent = await buffer(newBlobStream)
  259. expect(newBlobContent).to.deep.equal(
  260. fs.readFileSync(testFiles.path('non_bmp.txt'))
  261. )
  262. // Check chunk was backed up
  263. const chunkStream = await backupPersistor.getObjectStream(
  264. chunksBucket,
  265. makeChunkKey(historyId, 0)
  266. )
  267. const chunkContent = await text(chunkStream.pipe(createGunzip()))
  268. const chunkMetadata = await ChunkStore.getLatestChunkMetadata(historyId)
  269. const rawHistory = await historyStore.loadRaw(historyId, chunkMetadata.id)
  270. expect(JSON.parse(chunkContent)).to.deep.equal(rawHistory)
  271. // Unrelated entries from backedUpBlobs should be not cleared
  272. const backedUpBlobsDoc = await backedUpBlobs.findOne({
  273. _id: project._id,
  274. })
  275. expect(backedUpBlobsDoc).not.to.be.null
  276. expect(backedUpBlobsDoc.blobs).to.have.length(1)
  277. expect(backedUpBlobsDoc.blobs[0].toString('hex')).to.equal(DUMMY_HASH)
  278. })
  279. it('should not backup global blobs', async function () {
  280. const globalBlobString = 'a'
  281. const globalBlobHash = testFiles.STRING_A_HASH
  282. await globalBlobs.insertOne({
  283. _id: globalBlobHash,
  284. byteLength: globalBlobString.length,
  285. stringLength: globalBlobString.length,
  286. })
  287. const bucket = config.get('blobStore.globalBucket')
  288. for (const { key, content } of [
  289. {
  290. key: '2e/65/efe2a145dda7ee51d1741299f848e5bf752e',
  291. content: globalBlobString,
  292. },
  293. ]) {
  294. const stream = Readable.from([content])
  295. await persistor.sendStream(bucket, key, stream)
  296. }
  297. await loadGlobalBlobs()
  298. // Create a change using the global blob
  299. const addFileOp = Operation.addFile(
  300. 'global.tex',
  301. File.fromHash(globalBlobHash)
  302. )
  303. const change = new Change([addFileOp], new Date(), [])
  304. await persistChanges(historyId, [change], limitsToPersistImmediately, 2)
  305. // Run backup
  306. await runBackupScript(['--projectId', projectId])
  307. // Verify global blob wasn't backed up
  308. try {
  309. await backupPersistor.getObjectStream(
  310. projectBlobsBucket,
  311. makeProjectKey(historyId, globalBlobHash),
  312. { autoGunzip: true }
  313. )
  314. expect.fail('Should not find global blob in project blobs')
  315. } catch (err) {
  316. expect(err).to.be.an.instanceOf(NotFoundError)
  317. }
  318. })
  319. it('should back up global blobs if they are demoted', async function () {
  320. const demotedBlobString = 'ab'
  321. const demotedBlobHash = testFiles.STRING_AB_HASH
  322. await globalBlobs.insertOne({
  323. _id: demotedBlobHash,
  324. byteLength: demotedBlobString.length,
  325. stringLength: demotedBlobString.length,
  326. demoted: true,
  327. })
  328. const bucket = config.get('blobStore.globalBucket')
  329. for (const { key, content } of [
  330. {
  331. key: '9a/e9/e86b7bd6cb1472d9373702d8249973da0832',
  332. content: demotedBlobString,
  333. },
  334. ]) {
  335. const stream = Readable.from([content])
  336. await persistor.sendStream(bucket, key, stream)
  337. }
  338. await loadGlobalBlobs()
  339. // Create a change using the global blob
  340. const addFileOp = Operation.addFile(
  341. 'demoted.tex',
  342. File.fromHash(demotedBlobHash)
  343. )
  344. const change = new Change([addFileOp], new Date(), [])
  345. await persistChanges(historyId, [change], limitsToPersistImmediately, 2)
  346. // Run backup
  347. const { stdout } = await runBackupScript(['--projectId', projectId])
  348. expect(stdout).to.not.include(
  349. 'warning: persistor not passed to backupBlob'
  350. )
  351. // Check chunk was backed up
  352. const chunkStream = await backupPersistor.getObjectStream(
  353. chunksBucket,
  354. makeChunkKey(historyId, 0)
  355. )
  356. const chunkContent = await text(chunkStream.pipe(createGunzip()))
  357. const chunkMetadata = await ChunkStore.getLatestChunkMetadata(historyId)
  358. const rawHistory = await historyStore.loadRaw(historyId, chunkMetadata.id)
  359. expect(JSON.parse(chunkContent)).to.deep.equal(rawHistory)
  360. // Verify that the demoted global blob was backed up
  361. try {
  362. const demotedBlobStream = await backupPersistor.getObjectStream(
  363. projectBlobsBucket,
  364. makeProjectKey(historyId, demotedBlobHash),
  365. {
  366. autoGunzip: true,
  367. }
  368. )
  369. const demotedBlobContent = await buffer(demotedBlobStream)
  370. expect(demotedBlobContent).to.deep.equal(Buffer.from(demotedBlobString))
  371. } catch (err) {
  372. expect.fail('Should find demoted global blob in project blobs')
  373. }
  374. })
  375. })
  376. describe('with complex project content', function () {
  377. let beforeInitializationTimestamp
  378. let afterInitializationTimestamp
  379. beforeEach(async function () {
  380. // Create initial project
  381. await projectsCollection.insertOne(project)
  382. // Initialize project in chunk store
  383. // bracket the initialisation with two timestamps to check the pendingChangeAt field
  384. beforeInitializationTimestamp = new Date()
  385. await ChunkStore.initializeProject(historyId)
  386. afterInitializationTimestamp = new Date()
  387. const blobStore = new BlobStore(historyId)
  388. // Set up test files with varying content
  389. const testFilesData = {
  390. mainTex: { name: 'main.tex', content: 'Initial content' },
  391. chapter1: { name: 'chapter1.tex', content: 'Chapter 1 content' },
  392. chapter2: { name: 'chapter2.tex', content: 'Chapter 2 content' },
  393. bibliography: {
  394. name: 'bibliography.bib',
  395. content: '@article{key1,\n title={Title1}\n}',
  396. newContent: '@article{key2,\n title={Title2}\n}',
  397. },
  398. graph: {
  399. name: 'graph.png',
  400. path: testFiles.path('graph.png'),
  401. hash: testFiles.GRAPH_PNG_HASH,
  402. },
  403. unicodeFile: {
  404. name: 'unicodeFile.tex',
  405. path: testFiles.path('non_bmp.txt'),
  406. hash: testFiles.NON_BMP_TXT_HASH,
  407. },
  408. }
  409. const textFiles = [
  410. testFilesData.mainTex,
  411. testFilesData.chapter1,
  412. testFilesData.chapter2,
  413. testFilesData.bibliography,
  414. ]
  415. const binaryFiles = [testFilesData.graph, testFilesData.unicodeFile]
  416. // Add binary files first
  417. await Promise.all(binaryFiles.map(file => blobStore.putFile(file.path)))
  418. // Back up the binary files
  419. await Promise.all(
  420. binaryFiles.map(async file => {
  421. await backupBlob(
  422. historyId,
  423. await blobStore.putFile(file.path),
  424. file.path
  425. )
  426. })
  427. )
  428. // Create operations to add all files initially
  429. const addFileOperations = Object.values(testFilesData).map(file => {
  430. if (file.path) {
  431. return Operation.addFile(file.name, File.fromHash(file.hash))
  432. }
  433. return Operation.addFile(file.name, File.fromString(file.content))
  434. })
  435. // Initial change adding all files
  436. const initialChange = new Change(addFileOperations, new Date(), [])
  437. await persistChanges(
  438. historyId,
  439. [initialChange],
  440. limitsToPersistImmediately,
  441. 0
  442. )
  443. // Generate a series of edit operations for each text file
  444. const editOperations = []
  445. for (let i = 0; i < 50; i++) {
  446. const targetFile = textFiles[i % textFiles.length]
  447. if (!targetFile.path) {
  448. // Skip binary/unicode files
  449. const appendText = `\n\nEdit ${i + 1}`
  450. targetFile.content += appendText
  451. const textOp = TextOperation.fromJSON({
  452. textOperation: [
  453. targetFile.content.length - appendText.length,
  454. appendText,
  455. ],
  456. })
  457. const editOp = Operation.editFile(targetFile.name, textOp)
  458. editOperations.push(new Change([editOp], new Date(), []))
  459. }
  460. }
  461. // Add a delete operation
  462. const deleteChange = new Change(
  463. [Operation.removeFile(testFilesData.bibliography.name)],
  464. new Date(),
  465. []
  466. )
  467. editOperations.push(deleteChange)
  468. // Add the file back with different content
  469. const addBackChange = new Change(
  470. [
  471. Operation.addFile(
  472. testFilesData.bibliography.name,
  473. File.fromString(testFilesData.bibliography.newContent)
  474. ),
  475. ],
  476. new Date(),
  477. []
  478. )
  479. editOperations.push(addBackChange)
  480. // Persist all changes
  481. await persistChanges(
  482. historyId,
  483. editOperations,
  484. limitsToPersistImmediately,
  485. 1
  486. )
  487. })
  488. it('persistChanges should set the pendingChangeAt field to the time of snapshot initialisation', async function () {
  489. const result = await getBackupStatus(projectId)
  490. expect(result.backupStatus.pendingChangeAt).to.be.an.instanceOf(Date)
  491. expect(result.backupStatus.pendingChangeAt)
  492. .to.be.greaterThan(beforeInitializationTimestamp)
  493. .and.to.be.lessThan(afterInitializationTimestamp)
  494. })
  495. it('should backup all chunks and blobs from a complex project history', async function () {
  496. // Run backup script
  497. const { stdout } = await runBackupScript(['--projectId', projectId])
  498. expect(stdout).to.not.include(
  499. 'warning: persistor not passed to backupBlob'
  500. )
  501. // Verify backup state
  502. const result = await getBackupStatus(projectId)
  503. expect(result.backupStatus.lastBackedUpVersion).to.equal(53) // 1 initial change + 50 edits + 1 delete + 1 add back
  504. expect(result.backupStatus.lastBackedUpAt).to.be.an.instanceOf(Date)
  505. expect(result.currentEndTimestamp).to.be.an.instanceOf(Date)
  506. expect(result.backupStatus.pendingChangeAt).to.be.undefined
  507. // Verify that binary files were backed up
  508. for (const hash of [
  509. testFiles.GRAPH_PNG_HASH,
  510. testFiles.NON_BMP_TXT_HASH,
  511. ]) {
  512. const blobStream = await backupPersistor.getObjectStream(
  513. projectBlobsBucket,
  514. makeProjectKey(historyId, hash),
  515. { autoGunzip: true }
  516. )
  517. expect(blobStream).to.exist
  518. }
  519. // Get all chunks and verify they were backed up
  520. const listing = await backupPersistor
  521. ._getClientForBucket(chunksBucket)
  522. .listObjectsV2({
  523. Bucket: chunksBucket,
  524. Prefix: projectKey.format(historyId) + '/',
  525. })
  526. .promise()
  527. const chunkKeys = listing.Contents.map(item => item.Key)
  528. expect(chunkKeys.length).to.equal(6) // Should have multiple chunks
  529. const localChunks = await ChunkStore.getProjectChunks(historyId)
  530. const chunksByStartVersion = new Map()
  531. for (const chunkRecord of localChunks) {
  532. chunksByStartVersion.set(chunkRecord.startVersion, chunkRecord)
  533. }
  534. // Verify the content of each chunk matches what's in the history store
  535. for (const chunkKey of chunkKeys) {
  536. const chunkStream = await backupPersistor.getObjectStream(
  537. chunksBucket,
  538. chunkKey
  539. )
  540. const chunkContent = await text(chunkStream.pipe(createGunzip()))
  541. const startVersion = parseInt(chunkKey.split('/').pop(), 10)
  542. const chunk = chunksByStartVersion.get(startVersion)
  543. const rawHistory = await historyStore.loadRaw(historyId, chunk.id)
  544. expect(JSON.parse(chunkContent)).to.deep.equal(rawHistory)
  545. }
  546. })
  547. it('should throw an error if downloading a blob fails', async function () {
  548. const blobStore = new BlobStore(historyId)
  549. const blob = await blobStore.putFile(
  550. testFiles.path('null_characters.txt')
  551. )
  552. const change = new Change(
  553. [Operation.addFile('broken-file', File.fromHash(blob.getHash()))],
  554. new Date(),
  555. []
  556. )
  557. // Persist all changes
  558. await persistChanges(historyId, [change], limitsToPersistImmediately, 53)
  559. // Delete the blob from the underlying storage to simulate a failure
  560. const bucket = config.get('blobStore.projectBucket')
  561. const key = makeProjectKey(historyId, blob.getHash())
  562. await persistor.deleteObject(bucket, key)
  563. // Run backup script - it should fail because the blob is missing
  564. let result
  565. try {
  566. result = await runBackupScript(['--projectId', projectId])
  567. expect.fail('Backup script should have failed')
  568. } catch (err) {
  569. expect(err).to.exist
  570. expect(result).to.not.exist
  571. }
  572. // Verify that backup did not complete
  573. const newBackupStatus = await getBackupStatus(projectId)
  574. expect(newBackupStatus.backupStatus.lastBackedUpVersion).to.equal(50) // backup fails on final chunk
  575. expect(newBackupStatus.currentEndVersion).to.equal(54) // backup is incomplete due to missing blob
  576. })
  577. })
  578. })
  579. /**
  580. * Run the backup script with given arguments
  581. * @param {string[]} args
  582. */
  583. async function runBackupScript(args) {
  584. const TIMEOUT = 20 * 1000
  585. let result
  586. try {
  587. result = await promisify(execFile)(
  588. 'node',
  589. ['storage/scripts/backup.mjs', ...args],
  590. {
  591. encoding: 'utf-8',
  592. timeout: TIMEOUT,
  593. env: {
  594. ...process.env,
  595. LOG_LEVEL: 'debug', // Override LOG_LEVEL of acceptance tests
  596. },
  597. }
  598. )
  599. result.status = 0
  600. } catch (err) {
  601. const { stdout, stderr, code } = err
  602. if (typeof code !== 'number') {
  603. console.log(err)
  604. }
  605. result = { stdout, stderr, status: code }
  606. }
  607. if (result.status !== 0) {
  608. throw new Error('backup failed')
  609. }
  610. return result
  611. }