queue_changes.test.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. 'use strict'
  2. const { expect } = require('chai')
  3. const sinon = require('sinon')
  4. const cleanup = require('./support/cleanup')
  5. const fixtures = require('./support/fixtures')
  6. const testFiles = require('./support/test_files.js')
  7. const storage = require('../../../../storage')
  8. const chunkStore = storage.chunkStore
  9. const queueChanges = storage.queueChanges
  10. const redisBackend = require('../../../../storage/lib/chunk_store/redis')
  11. const core = require('overleaf-editor-core')
  12. const AddFileOperation = core.AddFileOperation
  13. const EditFileOperation = core.EditFileOperation
  14. const TextOperation = core.TextOperation
  15. const Change = core.Change
  16. const Chunk = core.Chunk
  17. const File = core.File
  18. const Snapshot = core.Snapshot
  19. const BlobStore = storage.BlobStore
  20. const persistChanges = storage.persistChanges
  21. describe('queueChanges', function () {
  22. let limitsToPersistImmediately
  23. before(function () {
  24. // Used to provide a limit which forces us to persist all of the changes
  25. const farFuture = new Date()
  26. farFuture.setTime(farFuture.getTime() + 7 * 24 * 3600 * 1000)
  27. limitsToPersistImmediately = {
  28. minChangeTimestamp: farFuture,
  29. maxChangeTimestamp: farFuture,
  30. maxChanges: 10,
  31. maxChunkChanges: 10,
  32. }
  33. })
  34. beforeEach(cleanup.everything)
  35. beforeEach(fixtures.create)
  36. afterEach(function () {
  37. sinon.restore()
  38. })
  39. it('queues changes when redis has no snapshot (falls back to chunkStore with an empty chunk)', async function () {
  40. // Start with an empty chunk store for the project
  41. const projectId = fixtures.docs.uninitializedProject.id
  42. await chunkStore.initializeProject(projectId)
  43. // Ensure that the initial state in redis is empty
  44. const initialRedisState = await redisBackend.getState(projectId)
  45. expect(initialRedisState.headVersion).to.be.null
  46. expect(initialRedisState.headSnapshot).to.be.null
  47. expect(initialRedisState.changes).to.be.an('array').that.is.empty
  48. // Add a test file to the blob store
  49. const blobStore = new BlobStore(projectId)
  50. await blobStore.putFile(testFiles.path('hello.txt'))
  51. // Prepare an initial change to add a single file to an empty project
  52. const change = new Change(
  53. [
  54. new AddFileOperation(
  55. 'test.tex',
  56. File.fromHash(testFiles.HELLO_TXT_HASH)
  57. ),
  58. ],
  59. new Date(),
  60. []
  61. )
  62. const changesToQueue = [change]
  63. const endVersion = 0
  64. // Queue the changes to add the test file
  65. const status = await queueChanges(projectId, changesToQueue, endVersion)
  66. expect(status).to.equal('ok')
  67. // Verify that we now have some state in redis
  68. const redisState = await redisBackend.getState(projectId)
  69. expect(redisState).to.not.be.null
  70. // Compute the expected snapshot after applying the changes
  71. const expectedSnapshot = new Snapshot()
  72. await expectedSnapshot.loadFiles('hollow', blobStore)
  73. for (const change of changesToQueue) {
  74. const hollowChange = change.clone()
  75. await hollowChange.loadFiles('hollow', blobStore)
  76. hollowChange.applyTo(expectedSnapshot, { strict: true })
  77. }
  78. // Confirm that state in redis matches the expected snapshot and changes queue
  79. const expectedVersionInRedis = endVersion + changesToQueue.length
  80. expect(redisState.headVersion).to.equal(expectedVersionInRedis)
  81. expect(redisState.headSnapshot).to.deep.equal(expectedSnapshot.toRaw())
  82. expect(redisState.changes).to.deep.equal(changesToQueue.map(c => c.toRaw()))
  83. })
  84. it('queues changes when redis has no snapshot (falls back to chunkStore with an existing chunk)', async function () {
  85. const projectId = fixtures.docs.uninitializedProject.id
  86. // Initialise the project in the chunk store using the "Hello World" test file
  87. await chunkStore.initializeProject(projectId)
  88. const blobStore = new BlobStore(projectId)
  89. await blobStore.putFile(testFiles.path('hello.txt'))
  90. const change = new Change(
  91. [
  92. new AddFileOperation(
  93. 'hello.tex',
  94. File.fromHash(testFiles.HELLO_TXT_HASH)
  95. ),
  96. ],
  97. new Date(),
  98. []
  99. )
  100. const initialChanges = [change]
  101. const initialVersion = 0
  102. const result = await persistChanges(
  103. projectId,
  104. initialChanges,
  105. limitsToPersistImmediately,
  106. initialVersion
  107. )
  108. // Compute the state after the initial changes are persisted for later comparison
  109. const endVersion = initialVersion + initialChanges.length
  110. const { currentChunk } = result
  111. const originalSnapshot = result.currentChunk.getSnapshot()
  112. await originalSnapshot.loadFiles('hollow', blobStore)
  113. originalSnapshot.applyAll(currentChunk.getChanges())
  114. // Ensure that the initial state in redis is empty
  115. const initialRedisState = await redisBackend.getState(projectId)
  116. expect(initialRedisState.headVersion).to.be.null
  117. expect(initialRedisState.headSnapshot).to.be.null
  118. expect(initialRedisState.changes).to.be.an('array').that.is.empty
  119. // Prepare a change to edit the existing file
  120. const editFileOp = new EditFileOperation(
  121. 'hello.tex',
  122. new TextOperation()
  123. .insert('Hello')
  124. .retain(testFiles.HELLO_TXT_UTF8_LENGTH)
  125. )
  126. const editFileChange = new Change([editFileOp], new Date(), [])
  127. const changesToQueue = [editFileChange]
  128. // Queue the changes to edit the existing file
  129. const status = await queueChanges(projectId, changesToQueue, endVersion)
  130. expect(status).to.equal('ok')
  131. // Verify that we now have some state in redis
  132. const redisState = await redisBackend.getState(projectId)
  133. expect(redisState).to.not.be.null
  134. // Compute the expected snapshot after applying the changes
  135. const expectedSnapshot = originalSnapshot.clone()
  136. await expectedSnapshot.loadFiles('hollow', blobStore)
  137. expectedSnapshot.applyAll(changesToQueue)
  138. // Confirm that state in redis matches the expected snapshot and changes queue
  139. const expectedVersionInRedis = endVersion + changesToQueue.length
  140. expect(redisState.headVersion).to.equal(expectedVersionInRedis)
  141. expect(redisState.headSnapshot).to.deep.equal(expectedSnapshot.toRaw())
  142. expect(redisState.changes).to.deep.equal(changesToQueue.map(c => c.toRaw()))
  143. })
  144. it('queues changes when redis has a snapshot with existing changes', async function () {
  145. const projectId = fixtures.docs.uninitializedProject.id
  146. // Initialise the project in redis using the "Hello World" test file
  147. await chunkStore.initializeProject(projectId)
  148. const blobStore = new BlobStore(projectId)
  149. await blobStore.putFile(testFiles.path('hello.txt'))
  150. const initialChangeOp = new AddFileOperation(
  151. 'existing.tex',
  152. File.fromHash(testFiles.HELLO_TXT_HASH)
  153. )
  154. const initialChange = new Change([initialChangeOp], new Date(), [])
  155. const initialChangesToQueue = [initialChange]
  156. const versionBeforeInitialQueue = 0
  157. // Queue the initial changes
  158. const status = await queueChanges(
  159. projectId,
  160. initialChangesToQueue,
  161. versionBeforeInitialQueue
  162. )
  163. // Confirm that the initial changes were queued successfully
  164. expect(status).to.equal('ok')
  165. const versionAfterInitialQueue =
  166. versionBeforeInitialQueue + initialChangesToQueue.length
  167. // Compute the snapshot after the initial changes for later use
  168. const initialSnapshot = new Snapshot()
  169. await initialSnapshot.loadFiles('hollow', blobStore)
  170. for (const change of initialChangesToQueue) {
  171. const hollowChange = change.clone()
  172. await hollowChange.loadFiles('hollow', blobStore)
  173. hollowChange.applyTo(initialSnapshot, { strict: true })
  174. }
  175. // Now prepare some subsequent changes for the queue
  176. await blobStore.putFile(testFiles.path('graph.png'))
  177. const addFileOp = new AddFileOperation(
  178. 'graph.png',
  179. File.fromHash(testFiles.GRAPH_PNG_HASH)
  180. )
  181. const addFileChange = new Change([addFileOp], new Date(), [])
  182. const editFileOp = new EditFileOperation(
  183. 'existing.tex',
  184. new TextOperation()
  185. .insert('Hello')
  186. .retain(testFiles.HELLO_TXT_UTF8_LENGTH)
  187. )
  188. const editFileChange = new Change([editFileOp], new Date(), [])
  189. const subsequentChangesToQueue = [addFileChange, editFileChange]
  190. const versionBeforeSubsequentQueue = versionAfterInitialQueue
  191. // Queue the subsequent changes
  192. const subsequentStatus = await queueChanges(
  193. projectId,
  194. subsequentChangesToQueue,
  195. versionBeforeSubsequentQueue
  196. )
  197. expect(subsequentStatus).to.equal('ok')
  198. // Compute the expected snapshot after applying all changes
  199. const expectedSnapshot = initialSnapshot.clone()
  200. await expectedSnapshot.loadFiles('hollow', blobStore)
  201. for (const change of subsequentChangesToQueue) {
  202. const hollowChange = change.clone()
  203. await hollowChange.loadFiles('hollow', blobStore)
  204. hollowChange.applyTo(expectedSnapshot, { strict: true })
  205. }
  206. // Confirm that state in redis matches the expected snapshot and changes queue
  207. const finalRedisState = await redisBackend.getState(projectId)
  208. expect(finalRedisState).to.not.be.null
  209. const expectedFinalVersion =
  210. versionBeforeSubsequentQueue + subsequentChangesToQueue.length
  211. expect(finalRedisState.headVersion).to.equal(expectedFinalVersion)
  212. expect(finalRedisState.headSnapshot).to.deep.equal(expectedSnapshot.toRaw())
  213. const allQueuedChangesRaw = initialChangesToQueue
  214. .concat(subsequentChangesToQueue)
  215. .map(c => c.toRaw())
  216. expect(finalRedisState.changes).to.deep.equal(allQueuedChangesRaw)
  217. })
  218. it('skips queuing changes when there is no snapshot and the onlyIfExists flag is set', async function () {
  219. // Start with an empty chunk store for the project
  220. const projectId = fixtures.docs.uninitializedProject.id
  221. await chunkStore.initializeProject(projectId)
  222. // Ensure that the initial state in redis is empty
  223. const initialRedisState = await redisBackend.getState(projectId)
  224. expect(initialRedisState.headVersion).to.be.null
  225. expect(initialRedisState.headSnapshot).to.be.null
  226. expect(initialRedisState.changes).to.be.an('array').that.is.empty
  227. // Add a test file to the blob store
  228. const blobStore = new BlobStore(projectId)
  229. await blobStore.putFile(testFiles.path('hello.txt'))
  230. // Prepare an initial change to add a single file to an empty project
  231. const change = new Change(
  232. [
  233. new AddFileOperation(
  234. 'test.tex',
  235. File.fromHash(testFiles.HELLO_TXT_HASH)
  236. ),
  237. ],
  238. new Date(),
  239. []
  240. )
  241. const changesToQueue = [change]
  242. const endVersion = 0
  243. // Queue the changes to add the test file
  244. const status = await queueChanges(projectId, changesToQueue, endVersion, {
  245. onlyIfExists: true,
  246. })
  247. expect(status).to.equal('ignore')
  248. // Verify that the state in redis has not changed
  249. const redisState = await redisBackend.getState(projectId)
  250. expect(redisState).to.deep.equal(initialRedisState)
  251. })
  252. it('creates an initial hollow snapshot when redis has no snapshot (falls back to chunkStore with an empty chunk)', async function () {
  253. // Start with an empty chunk store for the project
  254. const projectId = fixtures.docs.uninitializedProject.id
  255. await chunkStore.initializeProject(projectId)
  256. const blobStore = new BlobStore(projectId)
  257. await blobStore.putFile(testFiles.path('hello.txt'))
  258. // Prepare an initial change to add a single file to an empty project
  259. const change = new Change(
  260. [
  261. new AddFileOperation(
  262. 'test.tex',
  263. File.fromHash(testFiles.HELLO_TXT_HASH)
  264. ),
  265. ],
  266. new Date(),
  267. []
  268. )
  269. const changesToQueue = [change]
  270. const endVersion = 0
  271. // Queue the changes to add the test file
  272. const status = await queueChanges(projectId, changesToQueue, endVersion)
  273. expect(status).to.equal('ok')
  274. // Verify that we now have some state in redis
  275. const redisState = await redisBackend.getState(projectId)
  276. expect(redisState).to.not.be.null
  277. expect(redisState.headSnapshot.files['test.tex']).to.deep.equal({
  278. stringLength: testFiles.HELLO_TXT_UTF8_LENGTH,
  279. })
  280. })
  281. it('throws ConflictingEndVersion if endVersion does not match current version (from chunkStore)', async function () {
  282. const projectId = fixtures.docs.uninitializedProject.id
  283. // Initialise an empty project in the chunk store
  284. await chunkStore.initializeProject(projectId)
  285. // Ensure that the initial state in redis is empty
  286. const initialRedisState = await redisBackend.getState(projectId)
  287. expect(initialRedisState.headVersion).to.be.null
  288. // Prepare a change to add a file
  289. const change = new Change(
  290. [new AddFileOperation('test.tex', File.fromString(''))],
  291. new Date(),
  292. []
  293. )
  294. const changesToQueue = [change]
  295. const incorrectEndVersion = 1
  296. // Attempt to queue the changes with an incorrect endVersion (1 instead of 0)
  297. await expect(queueChanges(projectId, changesToQueue, incorrectEndVersion))
  298. .to.be.rejectedWith(Chunk.ConflictingEndVersion)
  299. .and.eventually.satisfies(err => {
  300. expect(err.info).to.have.property(
  301. 'clientEndVersion',
  302. incorrectEndVersion
  303. )
  304. expect(err.info).to.have.property('latestEndVersion', 0)
  305. return true
  306. })
  307. // Verify that the state in redis has not changed
  308. const redisStateAfterError = await redisBackend.getState(projectId)
  309. expect(redisStateAfterError).to.deep.equal(initialRedisState)
  310. })
  311. it('throws ConflictingEndVersion if endVersion does not match current version (from redis snapshot)', async function () {
  312. const projectId = fixtures.docs.uninitializedProject.id
  313. // Initialise the project in the redis with a test file
  314. await chunkStore.initializeProject(projectId)
  315. const initialChange = new Change(
  316. [new AddFileOperation('initial.tex', File.fromString('content'))],
  317. new Date(),
  318. []
  319. )
  320. const initialChangesToQueue = [initialChange]
  321. const versionBeforeInitialQueue = 0
  322. // Queue the initial changes
  323. await queueChanges(
  324. projectId,
  325. initialChangesToQueue,
  326. versionBeforeInitialQueue
  327. )
  328. const versionInRedisAfterSetup =
  329. versionBeforeInitialQueue + initialChangesToQueue.length
  330. // Confirm that the initial changes were queued successfully
  331. const initialRedisState = await redisBackend.getState(projectId)
  332. expect(initialRedisState).to.not.be.null
  333. expect(initialRedisState.headVersion).to.equal(versionInRedisAfterSetup)
  334. // Now prepare a subsequent change for the queue
  335. const subsequentChange = new Change(
  336. [new AddFileOperation('another.tex', File.fromString(''))],
  337. new Date(),
  338. []
  339. )
  340. const subsequentChangesToQueue = [subsequentChange]
  341. const incorrectEndVersion = 0
  342. // Attempt to queue the changes with an incorrect endVersion (0 instead of 1)
  343. await expect(
  344. queueChanges(projectId, subsequentChangesToQueue, incorrectEndVersion)
  345. )
  346. .to.be.rejectedWith(Chunk.ConflictingEndVersion)
  347. .and.eventually.satisfies(err => {
  348. expect(err.info).to.have.property(
  349. 'clientEndVersion',
  350. incorrectEndVersion
  351. )
  352. expect(err.info).to.have.property(
  353. 'latestEndVersion',
  354. versionInRedisAfterSetup
  355. )
  356. return true
  357. })
  358. // Verify that the state in redis has not changed
  359. const redisStateAfterError = await redisBackend.getState(projectId)
  360. expect(redisStateAfterError).to.not.be.null
  361. expect(redisStateAfterError).to.deep.equal(initialRedisState)
  362. })
  363. })