persist_buffer.test.mjs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. 'use strict'
  2. import fs from 'node:fs'
  3. import { expect } from 'chai'
  4. import {
  5. Change,
  6. Snapshot,
  7. File,
  8. TextOperation,
  9. AddFileOperation,
  10. EditFileOperation, // Added EditFileOperation
  11. } from 'overleaf-editor-core'
  12. import persistBuffer from '../../../../storage/lib/persist_buffer.js'
  13. import chunkStore from '../../../../storage/lib/chunk_store/index.js'
  14. import { BlobStore } from '../../../../storage/lib/blob_store/index.js'
  15. import redisBackend from '../../../../storage/lib/chunk_store/redis.js'
  16. import persistChanges from '../../../../storage/lib/persist_changes.js'
  17. import cleanup from './support/cleanup.js'
  18. import fixtures from './support/fixtures.js'
  19. import testFiles from './support/test_files.js'
  20. describe('persistBuffer', function () {
  21. let projectId
  22. const initialVersion = 0
  23. let limitsToPersistImmediately
  24. let blobStore
  25. before(function () {
  26. const farFuture = new Date()
  27. farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000)
  28. limitsToPersistImmediately = {
  29. minChangeTimestamp: farFuture,
  30. maxChangeTimestamp: farFuture,
  31. maxChunkChanges: 10,
  32. }
  33. })
  34. beforeEach(cleanup.everything)
  35. beforeEach(fixtures.create)
  36. beforeEach(async function () {
  37. projectId = fixtures.docs.uninitializedProject.id
  38. await chunkStore.initializeProject(projectId)
  39. blobStore = new BlobStore(projectId)
  40. })
  41. describe('with an empty initial chunk (new project)', function () {
  42. it('should persist changes from Redis to a new chunk', async function () {
  43. // create an initial snapshot and add the empty file `main.tex`
  44. const HELLO_TXT = fs.readFileSync(testFiles.path('hello.txt')).toString()
  45. const createFile = new Change(
  46. [new AddFileOperation('main.tex', File.fromString(HELLO_TXT))],
  47. new Date(),
  48. []
  49. )
  50. await persistChanges(
  51. projectId,
  52. [createFile],
  53. limitsToPersistImmediately,
  54. 0
  55. )
  56. // Now queue some changes in Redis
  57. const op1 = new TextOperation().insert('Hello').retain(HELLO_TXT.length)
  58. const change1 = new Change(
  59. [new EditFileOperation('main.tex', op1)],
  60. new Date()
  61. )
  62. const op2 = new TextOperation()
  63. .retain('Hello'.length)
  64. .insert(' World')
  65. .retain(HELLO_TXT.length)
  66. const change2 = new Change(
  67. [new EditFileOperation('main.tex', op2)],
  68. new Date()
  69. )
  70. const changesToQueue = [change1, change2]
  71. const finalHeadVersion = initialVersion + 1 + changesToQueue.length
  72. const now = Date.now()
  73. await redisBackend.queueChanges(
  74. projectId,
  75. new Snapshot(), // dummy snapshot
  76. 1,
  77. changesToQueue,
  78. {
  79. persistTime: now + redisBackend.MAX_PERSIST_DELAY_MS,
  80. expireTime: now + redisBackend.PROJECT_TTL_MS,
  81. }
  82. )
  83. await redisBackend.setPersistedVersion(projectId, initialVersion)
  84. // Persist the changes from Redis to the chunk store
  85. const persistResult = await persistBuffer(
  86. projectId,
  87. limitsToPersistImmediately
  88. )
  89. // Check the return value of persistBuffer
  90. expect(persistResult).to.exist
  91. expect(persistResult).to.have.property('numberOfChangesPersisted')
  92. expect(persistResult).to.have.property('originalEndVersion')
  93. expect(persistResult).to.have.property('currentChunk')
  94. expect(persistResult).to.have.property('resyncNeeded')
  95. expect(persistResult.numberOfChangesPersisted).to.equal(
  96. changesToQueue.length
  97. )
  98. expect(persistResult.originalEndVersion).to.equal(initialVersion + 1)
  99. expect(persistResult.resyncNeeded).to.be.false
  100. const latestChunk = await chunkStore.loadLatest(projectId, {
  101. persistedOnly: true,
  102. })
  103. expect(latestChunk).to.exist
  104. expect(latestChunk.getStartVersion()).to.equal(initialVersion)
  105. expect(latestChunk.getEndVersion()).to.equal(finalHeadVersion)
  106. expect(latestChunk.getChanges().length).to.equal(
  107. changesToQueue.length + 1
  108. )
  109. // Check that chunk returned by persistBuffer matches the latest chunk
  110. expect(latestChunk).to.deep.equal(persistResult.currentChunk)
  111. const chunkSnapshot = latestChunk.getSnapshot()
  112. expect(Object.keys(chunkSnapshot.getFileMap()).length).to.equal(1)
  113. const persistedVersionInRedis = (await redisBackend.getState(projectId))
  114. .persistedVersion
  115. expect(persistedVersionInRedis).to.equal(finalHeadVersion)
  116. const nonPersisted = await redisBackend.getNonPersistedChanges(
  117. projectId,
  118. finalHeadVersion
  119. )
  120. expect(nonPersisted).to.be.an('array').that.is.empty
  121. })
  122. })
  123. describe('with an existing chunk and new changes in Redis', function () {
  124. it('should persist new changes from Redis, appending to existing history', async function () {
  125. const initialContent = 'Initial document content.\n'
  126. const addInitialFileChange = new Change(
  127. [new AddFileOperation('main.tex', File.fromString(initialContent))],
  128. new Date(),
  129. []
  130. )
  131. await persistChanges(
  132. projectId,
  133. [addInitialFileChange],
  134. limitsToPersistImmediately,
  135. initialVersion
  136. )
  137. const versionAfterInitialSetup = initialVersion + 1 // Now version is 1
  138. const opForChunk1 = new TextOperation()
  139. .retain(initialContent.length)
  140. .insert(' First addition.')
  141. const changesForChunk1 = [
  142. new Change(
  143. [new EditFileOperation('main.tex', opForChunk1)],
  144. new Date(),
  145. []
  146. ),
  147. ]
  148. await persistChanges(
  149. projectId,
  150. changesForChunk1,
  151. limitsToPersistImmediately, // Original limits for this step
  152. versionAfterInitialSetup // Correct clientEndVersion
  153. )
  154. // Update persistedChunkEndVersion: 1 (from setup) + 1 (from changesForChunk1) = 2
  155. const persistedChunkEndVersion =
  156. versionAfterInitialSetup + changesForChunk1.length
  157. const contentAfterChunk1 = initialContent + ' First addition.'
  158. const opVersion2 = new TextOperation()
  159. .retain(contentAfterChunk1.length)
  160. .insert(' Second addition.')
  161. const changeVersion2 = new Change(
  162. [new EditFileOperation('main.tex', opVersion2)],
  163. new Date(),
  164. []
  165. )
  166. const contentAfterChange2 = contentAfterChunk1 + ' Second addition.'
  167. const opVersion3 = new TextOperation()
  168. .retain(contentAfterChange2.length)
  169. .insert(' Third addition.')
  170. const changeVersion3 = new Change(
  171. [new EditFileOperation('main.tex', opVersion3)],
  172. new Date(),
  173. []
  174. )
  175. const redisChangesToPush = [changeVersion2, changeVersion3]
  176. const finalHeadVersionAfterRedisPush =
  177. persistedChunkEndVersion + redisChangesToPush.length
  178. const now = Date.now()
  179. await redisBackend.queueChanges(
  180. projectId,
  181. new Snapshot(), // Use new Snapshot() like in the first test
  182. persistedChunkEndVersion,
  183. redisChangesToPush,
  184. {
  185. persistTime: now + redisBackend.MAX_PERSIST_DELAY_MS,
  186. expireTime: now + redisBackend.PROJECT_TTL_MS,
  187. }
  188. )
  189. await redisBackend.setPersistedVersion(
  190. projectId,
  191. persistedChunkEndVersion
  192. )
  193. const persistResult = await persistBuffer(
  194. projectId,
  195. limitsToPersistImmediately
  196. )
  197. // Check the return value of persistBuffer
  198. expect(persistResult).to.exist
  199. expect(persistResult).to.have.property('numberOfChangesPersisted')
  200. expect(persistResult).to.have.property('originalEndVersion')
  201. expect(persistResult).to.have.property('currentChunk')
  202. expect(persistResult).to.have.property('resyncNeeded')
  203. expect(persistResult.numberOfChangesPersisted).to.equal(
  204. redisChangesToPush.length
  205. )
  206. expect(persistResult.originalEndVersion).to.equal(
  207. persistedChunkEndVersion
  208. )
  209. expect(persistResult.resyncNeeded).to.be.false
  210. const latestChunk = await chunkStore.loadLatest(projectId, {
  211. persistedOnly: true,
  212. })
  213. expect(latestChunk).to.exist
  214. expect(latestChunk.getStartVersion()).to.equal(0)
  215. expect(latestChunk.getEndVersion()).to.equal(
  216. finalHeadVersionAfterRedisPush
  217. )
  218. expect(latestChunk.getChanges().length).to.equal(
  219. persistedChunkEndVersion + redisChangesToPush.length
  220. )
  221. const persistedVersionInRedisAfter = (
  222. await redisBackend.getState(projectId)
  223. ).persistedVersion
  224. expect(persistedVersionInRedisAfter).to.equal(
  225. finalHeadVersionAfterRedisPush
  226. )
  227. // Check that chunk returned by persistBuffer matches the latest chunk
  228. expect(persistResult.currentChunk).to.deep.equal(latestChunk)
  229. const nonPersisted = await redisBackend.getNonPersistedChanges(
  230. projectId,
  231. finalHeadVersionAfterRedisPush
  232. )
  233. expect(nonPersisted).to.be.an('array').that.is.empty
  234. })
  235. })
  236. describe('when Redis has no new changes', function () {
  237. let persistedChunkEndVersion
  238. let changesForChunk1
  239. beforeEach(async function () {
  240. const initialContent = 'Content.'
  241. const addInitialFileChange = new Change(
  242. [new AddFileOperation('main.tex', File.fromString(initialContent))],
  243. new Date(),
  244. []
  245. )
  246. // Replace chunkStore.create with persistChanges
  247. // clientEndVersion is initialVersion (0). This advances version to 1.
  248. await persistChanges(
  249. projectId,
  250. [addInitialFileChange],
  251. limitsToPersistImmediately,
  252. initialVersion
  253. )
  254. const versionAfterInitialSetup = initialVersion + 1 // Now version is 1
  255. const opForChunk1 = new TextOperation()
  256. .retain(initialContent.length)
  257. .insert(' More.')
  258. changesForChunk1 = [
  259. new Change(
  260. [new EditFileOperation('main.tex', opForChunk1)],
  261. new Date(),
  262. []
  263. ),
  264. ]
  265. // Corrected persistChanges call: clientEndVersion is versionAfterInitialSetup (1)
  266. await persistChanges(
  267. projectId,
  268. changesForChunk1,
  269. limitsToPersistImmediately, // Original limits for this step
  270. versionAfterInitialSetup // Correct clientEndVersion
  271. )
  272. // Update persistedChunkEndVersion: 1 (from setup) + 1 (from changesForChunk1) = 2
  273. persistedChunkEndVersion =
  274. versionAfterInitialSetup + changesForChunk1.length
  275. })
  276. it('should leave the persisted version and stored chunks unchanged', async function () {
  277. const now = Date.now()
  278. await redisBackend.queueChanges(
  279. projectId,
  280. new Snapshot(),
  281. persistedChunkEndVersion - 1,
  282. changesForChunk1,
  283. {
  284. persistTime: now + redisBackend.MAX_PERSIST_DELAY_MS,
  285. expireTime: now + redisBackend.PROJECT_TTL_MS,
  286. }
  287. )
  288. await redisBackend.setPersistedVersion(
  289. projectId,
  290. persistedChunkEndVersion
  291. )
  292. const chunksBefore = await chunkStore.getProjectChunks(projectId)
  293. const persistResult = await persistBuffer(
  294. projectId,
  295. limitsToPersistImmediately
  296. )
  297. const currentChunk = await chunkStore.loadLatest(projectId, {
  298. persistedOnly: true,
  299. })
  300. expect(persistResult).to.deep.equal({
  301. numberOfChangesPersisted: 0,
  302. originalEndVersion: persistedChunkEndVersion,
  303. currentChunk,
  304. resyncNeeded: false,
  305. })
  306. const chunksAfter = await chunkStore.getProjectChunks(projectId)
  307. expect(chunksAfter.length).to.equal(chunksBefore.length)
  308. expect(chunksAfter).to.deep.equal(chunksBefore)
  309. const finalPersistedVersionInRedis = (
  310. await redisBackend.getState(projectId)
  311. ).persistedVersion
  312. expect(finalPersistedVersionInRedis).to.equal(persistedChunkEndVersion)
  313. })
  314. it('should update the persisted version if it is behind the chunk store end version', async function () {
  315. const now = Date.now()
  316. await redisBackend.queueChanges(
  317. projectId,
  318. new Snapshot(),
  319. persistedChunkEndVersion - 1,
  320. changesForChunk1,
  321. {
  322. persistTime: now + redisBackend.MAX_PERSIST_DELAY_MS,
  323. expireTime: now + redisBackend.PROJECT_TTL_MS,
  324. }
  325. )
  326. // Force the persisted version in Redis to lag behind the chunk store,
  327. // simulating the situation where a worker has persisted changes to the
  328. // chunk store but failed to update the version in redis.
  329. await redisBackend.setPersistedVersion(
  330. projectId,
  331. persistedChunkEndVersion - 1
  332. )
  333. const chunksBefore = await chunkStore.getProjectChunks(projectId)
  334. // Persist buffer (which should do nothing as there are no new changes)
  335. const persistResult = await persistBuffer(
  336. projectId,
  337. limitsToPersistImmediately
  338. )
  339. // Check the return value
  340. const currentChunk = await chunkStore.loadLatest(projectId, {
  341. persistedOnly: true,
  342. })
  343. expect(persistResult).to.deep.equal({
  344. numberOfChangesPersisted: 0,
  345. originalEndVersion: persistedChunkEndVersion,
  346. currentChunk,
  347. resyncNeeded: false,
  348. })
  349. const chunksAfter = await chunkStore.getProjectChunks(projectId)
  350. expect(chunksAfter.length).to.equal(chunksBefore.length)
  351. expect(chunksAfter).to.deep.equal(chunksBefore)
  352. const finalPersistedVersionInRedis = (
  353. await redisBackend.getState(projectId)
  354. ).persistedVersion
  355. expect(finalPersistedVersionInRedis).to.equal(persistedChunkEndVersion)
  356. })
  357. })
  358. describe('when limits restrict the number of changes to persist', function () {
  359. it('should persist only a subset of changes and update persistedVersion accordingly', async function () {
  360. const now = Date.now()
  361. const oneDayAgo = now - 1000 * 60 * 60 * 24
  362. const oneHourAgo = now - 1000 * 60 * 60
  363. const twoHoursAgo = now - 1000 * 60 * 60 * 2
  364. const threeHoursAgo = now - 1000 * 60 * 60 * 3
  365. // Create an initial file with some content
  366. const initialContent = 'Initial content.'
  367. const addInitialFileChange = new Change(
  368. [new AddFileOperation('main.tex', File.fromString(initialContent))],
  369. new Date(oneDayAgo),
  370. []
  371. )
  372. await persistChanges(
  373. projectId,
  374. [addInitialFileChange],
  375. limitsToPersistImmediately,
  376. initialVersion
  377. )
  378. const versionAfterInitialSetup = initialVersion + 1 // Version is 1
  379. // Queue three additional changes in Redis
  380. const op1 = new TextOperation()
  381. .retain(initialContent.length)
  382. .insert(' Change 1.')
  383. const change1 = new Change(
  384. [new EditFileOperation('main.tex', op1)],
  385. new Date(threeHoursAgo)
  386. )
  387. const contentAfterC1 = initialContent + ' Change 1.'
  388. const op2 = new TextOperation()
  389. .retain(contentAfterC1.length)
  390. .insert(' Change 2.')
  391. const change2 = new Change(
  392. [new EditFileOperation('main.tex', op2)],
  393. new Date(twoHoursAgo)
  394. )
  395. const contentAfterC2 = contentAfterC1 + ' Change 2.'
  396. const op3 = new TextOperation()
  397. .retain(contentAfterC2.length)
  398. .insert(' Change 3.')
  399. const change3 = new Change(
  400. [new EditFileOperation('main.tex', op3)],
  401. new Date(oneHourAgo)
  402. )
  403. const changesToQueue = [change1, change2, change3]
  404. await redisBackend.queueChanges(
  405. projectId,
  406. new Snapshot(), // dummy snapshot
  407. versionAfterInitialSetup, // startVersion for queued changes
  408. changesToQueue,
  409. {
  410. persistTime: now + redisBackend.MAX_PERSIST_DELAY_MS,
  411. expireTime: now + redisBackend.PROJECT_TTL_MS,
  412. }
  413. )
  414. await redisBackend.setPersistedVersion(
  415. projectId,
  416. versionAfterInitialSetup
  417. )
  418. // Define limits to only persist 2 additional changes (on top of the initial file creation),
  419. // which should leave the final change (change3) in the redis buffer.
  420. const restrictiveLimits = {
  421. minChangeTimestamp: new Date(oneHourAgo), // only changes more than 1 hour old are considered
  422. maxChangeTimestamp: new Date(twoHoursAgo), // they will be persisted if any change is older than 2 hours
  423. }
  424. const persistResult = await persistBuffer(projectId, restrictiveLimits)
  425. // Check the return value of persistBuffer
  426. expect(persistResult).to.exist
  427. expect(persistResult).to.have.property('numberOfChangesPersisted')
  428. expect(persistResult).to.have.property('originalEndVersion')
  429. expect(persistResult).to.have.property('currentChunk')
  430. expect(persistResult).to.have.property('resyncNeeded')
  431. expect(persistResult.numberOfChangesPersisted).to.equal(2) // change1 + change2
  432. expect(persistResult.originalEndVersion).to.equal(
  433. versionAfterInitialSetup
  434. )
  435. expect(persistResult.resyncNeeded).to.be.false
  436. // Check the latest persisted chunk, it should only have the initial file and the first two changes
  437. const latestChunk = await chunkStore.loadLatest(projectId, {
  438. persistedOnly: true,
  439. })
  440. expect(latestChunk).to.exist
  441. expect(latestChunk.getChanges().length).to.equal(3) // addInitialFileChange + change1 + change2
  442. expect(latestChunk.getStartVersion()).to.equal(initialVersion)
  443. const expectedEndVersion = versionAfterInitialSetup + 2 // Persisted two changes from the queue
  444. expect(latestChunk.getEndVersion()).to.equal(expectedEndVersion)
  445. // Check that chunk returned by persistBuffer matches the latest chunk
  446. expect(persistResult.currentChunk).to.deep.equal(latestChunk)
  447. // Check persisted version in Redis
  448. const state = await redisBackend.getState(projectId)
  449. expect(state.persistedVersion).to.equal(expectedEndVersion)
  450. // Check non-persisted changes in Redis
  451. const nonPersisted = await redisBackend.getNonPersistedChanges(
  452. projectId,
  453. expectedEndVersion
  454. )
  455. expect(nonPersisted).to.be.an('array').with.lengthOf(1) // change3 should remain
  456. expect(nonPersisted).to.deep.equal([change3])
  457. })
  458. })
  459. describe('with lots of changes to persist', function () {
  460. it('should persist all changes', async function () {
  461. const changes = []
  462. // Create an initial file with some content
  463. const blob = await blobStore.putString('')
  464. changes.push(
  465. new Change(
  466. [new AddFileOperation('main.tex', File.createLazyFromBlobs(blob))],
  467. new Date(),
  468. []
  469. )
  470. )
  471. for (let i = 0; i < 500; i++) {
  472. const op = new TextOperation().retain(i).insert('x')
  473. changes.push(
  474. new Change([new EditFileOperation('main.tex', op)], new Date())
  475. )
  476. }
  477. const now = Date.now()
  478. await redisBackend.queueChanges(
  479. projectId,
  480. new Snapshot(), // dummy snapshot
  481. 0, // startVersion for queued changes
  482. changes,
  483. {
  484. persistTime: now + redisBackend.MAX_PERSIST_DELAY_MS,
  485. expireTime: now + redisBackend.PROJECT_TTL_MS,
  486. }
  487. )
  488. const expectedEndVersion = 501
  489. const persistResult = await persistBuffer(
  490. projectId,
  491. limitsToPersistImmediately
  492. )
  493. expect(persistResult.numberOfChangesPersisted).to.equal(
  494. expectedEndVersion
  495. )
  496. expect(persistResult.originalEndVersion).to.equal(0)
  497. expect(persistResult.resyncNeeded).to.be.false
  498. // Check the latest persisted chunk
  499. const latestChunk = await chunkStore.loadLatest(projectId, {
  500. persistedOnly: true,
  501. })
  502. expect(latestChunk).to.exist
  503. expect(latestChunk.getEndVersion()).to.equal(expectedEndVersion)
  504. // Check that chunk returned by persistBuffer matches the latest chunk
  505. expect(persistResult.currentChunk).to.deep.equal(latestChunk)
  506. // Check persisted version in Redis
  507. const state = await redisBackend.getState(projectId)
  508. expect(state.persistedVersion).to.equal(expectedEndVersion)
  509. // Check non-persisted changes in Redis
  510. const nonPersisted = await redisBackend.getNonPersistedChanges(
  511. projectId,
  512. expectedEndVersion
  513. )
  514. expect(nonPersisted).to.deep.equal([])
  515. })
  516. })
  517. })