| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556 |
- import { expect } from 'chai'
- import sinon from 'sinon'
- import { strict as esmock } from 'esmock'
- const MODULE_PATH = '../../../../app/js/RedisManager.js'
- describe('RedisManager', function () {
- beforeEach(async function () {
- this.rclient = new FakeRedis()
- this.RedisWrapper = {
- createClient: sinon.stub().returns(this.rclient),
- }
- this.Settings = {
- redis: {
- project_history: {
- key_schema: {
- projectHistoryOps({ project_id: projectId }) {
- return `Project:HistoryOps:{${projectId}}`
- },
- projectHistoryFirstOpTimestamp({ project_id: projectId }) {
- return `ProjectHistory:FirstOpTimestamp:{${projectId}}`
- },
- },
- },
- },
- }
- this.Metrics = {
- timing: sinon.stub(),
- summary: sinon.stub(),
- globalGauge: sinon.stub(),
- }
- this.RedisManager = await esmock(MODULE_PATH, {
- '@overleaf/redis-wrapper': this.RedisWrapper,
- '@overleaf/settings': this.Settings,
- '@overleaf/metrics': this.Metrics,
- })
- this.projectId = 'project-id-123'
- this.batchSize = 100
- this.historyOpsKey = `Project:HistoryOps:{${this.projectId}}`
- this.firstOpTimestampKey = `ProjectHistory:FirstOpTimestamp:{${this.projectId}}`
- this.updates = [
- { v: 42, op: ['a', 'b', 'c', 'd'] },
- { v: 45, op: ['e', 'f', 'g', 'h'] },
- ]
- this.extraUpdates = [{ v: 100, op: ['i', 'j', 'k'] }]
- this.rawUpdates = this.updates.map(update => JSON.stringify(update))
- this.extraRawUpdates = this.extraUpdates.map(update =>
- JSON.stringify(update)
- )
- })
- describe('getRawUpdatesBatch', function () {
- it('gets a small number of updates in one batch', async function () {
- const updates = makeUpdates(2)
- const rawUpdates = makeRawUpdates(updates)
- this.rclient.setList(this.historyOpsKey, rawUpdates)
- const result = await this.RedisManager.promises.getRawUpdatesBatch(
- this.projectId,
- 100
- )
- expect(result).to.deep.equal({ rawUpdates, hasMore: false })
- })
- it('gets a larger number of updates in several batches', async function () {
- const updates = makeUpdates(
- this.RedisManager.RAW_UPDATES_BATCH_SIZE * 2 + 12
- )
- const rawUpdates = makeRawUpdates(updates)
- this.rclient.setList(this.historyOpsKey, rawUpdates)
- const result = await this.RedisManager.promises.getRawUpdatesBatch(
- this.projectId,
- 5000
- )
- expect(result).to.deep.equal({ rawUpdates, hasMore: false })
- })
- it("doesn't return more than the number of updates requested", async function () {
- const updates = makeUpdates(100)
- const rawUpdates = makeRawUpdates(updates)
- this.rclient.setList(this.historyOpsKey, rawUpdates)
- const result = await this.RedisManager.promises.getRawUpdatesBatch(
- this.projectId,
- 75
- )
- expect(result).to.deep.equal({
- rawUpdates: rawUpdates.slice(0, 75),
- hasMore: true,
- })
- })
- })
- describe('parseDocUpdates', function () {
- it('should return the parsed ops', function () {
- const updates = makeUpdates(12)
- const rawUpdates = makeRawUpdates(updates)
- this.RedisManager.parseDocUpdates(rawUpdates).should.deep.equal(updates)
- })
- })
- describe('getUpdatesInBatches', function () {
- beforeEach(function () {
- this.runner = sinon.stub().resolves()
- })
- describe('single batch smaller than batch size', function () {
- beforeEach(async function () {
- this.updates = makeUpdates(2)
- this.rawUpdates = makeRawUpdates(this.updates)
- this.rclient.setList(this.historyOpsKey, this.rawUpdates)
- await this.RedisManager.promises.getUpdatesInBatches(
- this.projectId,
- 3,
- this.runner
- )
- })
- it('calls the runner once', function () {
- this.runner.callCount.should.equal(1)
- })
- it('calls the runner with the updates', function () {
- this.runner.should.have.been.calledWith(this.updates)
- })
- it('deletes the applied updates', function () {
- expect(this.rclient.getList(this.historyOpsKey)).to.deep.equal([])
- })
- it('deletes the first op timestamp', function () {
- expect(this.rclient.del).to.have.been.calledWith(
- this.firstOpTimestampKey
- )
- })
- })
- describe('single batch at batch size', function () {
- beforeEach(async function () {
- this.updates = makeUpdates(123)
- this.rawUpdates = makeRawUpdates(this.updates)
- this.rclient.setList(this.historyOpsKey, this.rawUpdates)
- await this.RedisManager.promises.getUpdatesInBatches(
- this.projectId,
- 123,
- this.runner
- )
- })
- it('calls the runner once', function () {
- this.runner.callCount.should.equal(1)
- })
- it('calls the runner with the updates', function () {
- this.runner.should.have.been.calledWith(this.updates)
- })
- it('deletes the applied updates', function () {
- expect(this.rclient.getList(this.historyOpsKey)).to.deep.equal([])
- })
- it('deletes the first op timestamp', function () {
- expect(this.rclient.del).to.have.been.calledWith(
- this.firstOpTimestampKey
- )
- })
- })
- describe('single batch exceeding size limit on updates', function () {
- beforeEach(async function () {
- this.updates = makeUpdates(2, [
- 'x'.repeat(this.RedisManager.RAW_UPDATE_SIZE_THRESHOLD),
- ])
- this.rawUpdates = makeRawUpdates(this.updates)
- this.rclient.setList(this.historyOpsKey, this.rawUpdates)
- await this.RedisManager.promises.getUpdatesInBatches(
- this.projectId,
- 123,
- this.runner
- )
- })
- it('calls the runner twice', function () {
- this.runner.callCount.should.equal(2)
- })
- it('calls the runner with the first update', function () {
- this.runner
- .getCall(0)
- .should.have.been.calledWith(this.updates.slice(0, 1))
- })
- it('calls the runner with the second update', function () {
- this.runner
- .getCall(1)
- .should.have.been.calledWith(this.updates.slice(1))
- })
- it('deletes the applied updates', function () {
- expect(this.rclient.getList(this.historyOpsKey)).to.deep.equal([])
- })
- })
- describe('two batches with first update below and second update above the size limit on updates', function () {
- beforeEach(async function () {
- this.updates = makeUpdates(2, [
- 'x'.repeat(this.RedisManager.RAW_UPDATE_SIZE_THRESHOLD / 2),
- ])
- this.rawUpdates = makeRawUpdates(this.updates)
- this.rclient.setList(this.historyOpsKey, this.rawUpdates)
- await this.RedisManager.promises.getUpdatesInBatches(
- this.projectId,
- 123,
- this.runner
- )
- })
- it('calls the runner twice', function () {
- this.runner.callCount.should.equal(2)
- })
- it('calls the runner with the first update', function () {
- this.runner
- .getCall(0)
- .should.have.been.calledWith(this.updates.slice(0, 1))
- })
- it('calls the runner with the second update', function () {
- this.runner
- .getCall(1)
- .should.have.been.calledWith(this.updates.slice(1))
- })
- it('deletes the applied updates', function () {
- expect(this.rclient.getList(this.historyOpsKey)).to.deep.equal([])
- })
- })
- describe('single batch exceeding op count limit on updates', function () {
- beforeEach(async function () {
- const ops = Array(this.RedisManager.MAX_UPDATE_OP_LENGTH + 1).fill('op')
- this.updates = makeUpdates(2, { op: ops })
- this.rawUpdates = makeRawUpdates(this.updates)
- this.rclient.setList(this.historyOpsKey, this.rawUpdates)
- await this.RedisManager.promises.getUpdatesInBatches(
- this.projectId,
- 123,
- this.runner
- )
- })
- it('calls the runner twice', function () {
- this.runner.callCount.should.equal(2)
- })
- it('calls the runner with the first update', function () {
- this.runner
- .getCall(0)
- .should.have.been.calledWith(this.updates.slice(0, 1))
- })
- it('calls the runner with the second update', function () {
- this.runner
- .getCall(1)
- .should.have.been.calledWith(this.updates.slice(1))
- })
- it('deletes the applied updates', function () {
- expect(this.rclient.getList(this.historyOpsKey)).to.deep.equal([])
- })
- })
- describe('single batch exceeding doc content count', function () {
- beforeEach(async function () {
- this.updates = makeUpdates(
- this.RedisManager.MAX_NEW_DOC_CONTENT_COUNT + 3,
- { resyncDocContent: 123 }
- )
- this.rawUpdates = makeRawUpdates(this.updates)
- this.rclient.setList(this.historyOpsKey, this.rawUpdates)
- await this.RedisManager.promises.getUpdatesInBatches(
- this.projectId,
- 123,
- this.runner
- )
- })
- it('calls the runner twice', function () {
- this.runner.callCount.should.equal(2)
- })
- it('calls the runner with the first batch of updates', function () {
- this.runner.should.have.been.calledWith(
- this.updates.slice(0, this.RedisManager.MAX_NEW_DOC_CONTENT_COUNT)
- )
- })
- it('calls the runner with the second batch of updates', function () {
- this.runner.should.have.been.calledWith(
- this.updates.slice(this.RedisManager.MAX_NEW_DOC_CONTENT_COUNT)
- )
- })
- it('deletes the applied updates', function () {
- expect(this.rclient.getList(this.historyOpsKey)).to.deep.equal([])
- })
- })
- describe('two batches with first update below and second update above the ops length limit on updates', function () {
- beforeEach(async function () {
- // set the threshold below the size of the first update
- this.updates = makeUpdates(2, { op: ['op1', 'op2'] })
- this.updates[1].op = Array(
- this.RedisManager.MAX_UPDATE_OP_LENGTH + 2
- ).fill('op')
- this.rawUpdates = makeRawUpdates(this.updates)
- this.rclient.setList(this.historyOpsKey, this.rawUpdates)
- await this.RedisManager.promises.getUpdatesInBatches(
- this.projectId,
- 123,
- this.runner
- )
- })
- it('calls the runner twice', function () {
- this.runner.callCount.should.equal(2)
- })
- it('calls the runner with the first update', function () {
- this.runner.should.have.been.calledWith(this.updates.slice(0, 1))
- })
- it('calls the runner with the second update', function () {
- this.runner.should.have.been.calledWith(this.updates.slice(1))
- })
- it('deletes the applied updates', function () {
- expect(this.rclient.getList(this.historyOpsKey)).to.deep.equal([])
- })
- })
- describe('two batches, one partial', function () {
- beforeEach(async function () {
- this.updates = makeUpdates(15)
- this.rawUpdates = makeRawUpdates(this.updates)
- this.rclient.setList(this.historyOpsKey, this.rawUpdates)
- await this.RedisManager.promises.getUpdatesInBatches(
- this.projectId,
- 10,
- this.runner
- )
- })
- it('calls the runner twice', function () {
- this.runner.callCount.should.equal(2)
- })
- it('calls the runner with the updates', function () {
- this.runner
- .getCall(0)
- .should.have.been.calledWith(this.updates.slice(0, 10))
- this.runner
- .getCall(1)
- .should.have.been.calledWith(this.updates.slice(10))
- })
- it('deletes the applied updates', function () {
- expect(this.rclient.getList(this.historyOpsKey)).to.deep.equal([])
- })
- })
- describe('two full batches', function () {
- beforeEach(async function () {
- this.updates = makeUpdates(20)
- this.rawUpdates = makeRawUpdates(this.updates)
- this.rclient.setList(this.historyOpsKey, this.rawUpdates)
- await this.RedisManager.promises.getUpdatesInBatches(
- this.projectId,
- 10,
- this.runner
- )
- })
- it('calls the runner twice', function () {
- this.runner.callCount.should.equal(2)
- })
- it('calls the runner with the updates', function () {
- this.runner
- .getCall(0)
- .should.have.been.calledWith(this.updates.slice(0, 10))
- this.runner
- .getCall(1)
- .should.have.been.calledWith(this.updates.slice(10))
- })
- it('deletes the applied updates', function () {
- expect(this.rclient.getList(this.historyOpsKey)).to.deep.equal([])
- })
- })
- describe('three full bathches, bigger than the Redis read batch size', function () {
- beforeEach(async function () {
- this.batchSize = this.RedisManager.RAW_UPDATES_BATCH_SIZE * 2
- this.updates = makeUpdates(this.batchSize * 3)
- this.rawUpdates = makeRawUpdates(this.updates)
- this.rclient.setList(this.historyOpsKey, this.rawUpdates)
- await this.RedisManager.promises.getUpdatesInBatches(
- this.projectId,
- this.batchSize,
- this.runner
- )
- })
- it('calls the runner twice', function () {
- this.runner.callCount.should.equal(3)
- })
- it('calls the runner with the updates', function () {
- this.runner
- .getCall(0)
- .should.have.been.calledWith(this.updates.slice(0, this.batchSize))
- this.runner
- .getCall(1)
- .should.have.been.calledWith(
- this.updates.slice(this.batchSize, this.batchSize * 2)
- )
- this.runner
- .getCall(2)
- .should.have.been.calledWith(this.updates.slice(this.batchSize * 2))
- })
- it('deletes the applied updates', function () {
- expect(this.rclient.getList(this.historyOpsKey)).to.deep.equal([])
- })
- })
- describe('error when first reading updates', function () {
- beforeEach(async function () {
- this.updates = makeUpdates(10)
- this.rawUpdates = makeRawUpdates(this.updates)
- this.rclient.setList(this.historyOpsKey, this.rawUpdates)
- this.rclient.throwErrorOnLrangeCall(0)
- await expect(
- this.RedisManager.promises.getUpdatesInBatches(
- this.projectId,
- 2,
- this.runner
- )
- ).to.be.rejected
- })
- it('does not delete any updates', function () {
- expect(this.rclient.getList(this.historyOpsKey)).to.deep.equal(
- this.rawUpdates
- )
- })
- })
- describe('error when reading updates for a second batch', function () {
- beforeEach(async function () {
- this.batchSize = this.RedisManager.RAW_UPDATES_BATCH_SIZE - 1
- this.updates = makeUpdates(this.RedisManager.RAW_UPDATES_BATCH_SIZE * 2)
- this.rawUpdates = makeRawUpdates(this.updates)
- this.rclient.setList(this.historyOpsKey, this.rawUpdates)
- this.rclient.throwErrorOnLrangeCall(1)
- await expect(
- this.RedisManager.promises.getUpdatesInBatches(
- this.projectId,
- this.batchSize,
- this.runner
- )
- ).to.be.rejected
- })
- it('calls the runner with the first batch of updates', function () {
- this.runner.should.have.been.calledOnce
- this.runner
- .getCall(0)
- .should.have.been.calledWith(this.updates.slice(0, this.batchSize))
- })
- it('deletes only the first batch of applied updates', function () {
- expect(this.rclient.getList(this.historyOpsKey)).to.deep.equal(
- this.rawUpdates.slice(this.batchSize)
- )
- })
- })
- })
- })
- class FakeRedis {
- constructor() {
- this.data = new Map()
- this.del = sinon.stub()
- this.lrangeCallCount = -1
- }
- setList(key, list) {
- this.data.set(key, list)
- }
- getList(key) {
- return this.data.get(key)
- }
- throwErrorOnLrangeCall(callNum) {
- this.lrangeCallThrowingError = callNum
- }
- async lrange(key, start, stop) {
- this.lrangeCallCount += 1
- if (
- this.lrangeCallThrowingError != null &&
- this.lrangeCallThrowingError === this.lrangeCallCount
- ) {
- throw new Error('LRANGE failed!')
- }
- const list = this.data.get(key) ?? []
- return list.slice(start, stop + 1)
- }
- async lrem(key, count, elementToRemove) {
- expect(count).to.be.greaterThan(0)
- const original = this.data.get(key) ?? []
- const filtered = original.filter(element => {
- if (count > 0 && element === elementToRemove) {
- count--
- return false
- }
- return true
- })
- this.data.set(key, filtered)
- }
- async exec() {
- // Nothing to do
- }
- multi() {
- return this
- }
- }
- function makeUpdates(updateCount, extraFields = {}) {
- const updates = []
- for (let i = 0; i < updateCount; i++) {
- updates.push({ v: i, ...extraFields })
- }
- return updates
- }
- function makeRawUpdates(updates) {
- return updates.map(JSON.stringify)
- }
|