Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pipeline-clear-watch-state-after-exec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ioredis-mock": patch
---

Fix `Pipeline.exec()` not clearing watch state after a successful `MULTI/EXEC`. In real Redis, `EXEC` always clears `WATCH` state regardless of outcome. Previously, the pipeline's own commands would re-dirty the connection during execution, causing all subsequent `MULTI/EXEC` calls to incorrectly return `null`. Watch state handling is now scoped to `MULTI` transactions only — regular pipelines (`redis.pipeline()`) no longer check or clear watch state.
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ class RedisMock extends EventEmitter {
this.batch = new Pipeline(this)
// eslint-disable-next-line no-underscore-dangle
this.batch._transactions += 1
// eslint-disable-next-line no-underscore-dangle
this.batch._isMulti = true

batch.forEach?.(([command, ...options]) => this.batch[command](...options))

Expand Down
17 changes: 12 additions & 5 deletions src/pipeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,19 @@ class Pipeline {
}

exec(callback) {
// return null if WATCHed key was modified or expired
if (this._isDirty()) {
this.redis.dirty = false
// Only MULTI transactions respect WATCH state; regular pipelines run unconditionally.
if (this._isMulti) {
if (this._isDirty()) {
this.redis.dirty = false
this.redis.watching.clear()
this.batch = undefined
return asCallback(Promise.resolve(null), callback)
}

// Real Redis always clears watch state after EXEC, whether it succeeded or aborted.
// Clear before running batch so commands within the pipeline don't re-dirty the state.
this.redis.watching.clear()
this.batch = undefined
return asCallback(Promise.resolve(null), callback)
this.redis.dirty = false
}

// eslint-disable-next-line prefer-destructuring
Expand Down
19 changes: 19 additions & 0 deletions test/integration/exec.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,23 @@ describe('exec', () => {
])
redis2.disconnect()
})

it('should clear watch state after a successful exec so subsequent pipelines are not aborted', async () => {
await redis.watch('user_next')
const firstResult = await redis.multi([['incr', 'user_next']]).exec()
expect(firstResult).toEqual([[null, 2]])

const secondResult = await redis.multi([['incr', 'user_next']]).exec()
expect(secondResult).toEqual([[null, 3]])
})

it('should not clear watch state after a regular pipeline exec', async () => {
const redis2 = redis.duplicate()
await redis.watch('user_next')
await redis.pipeline([['incr', 'user_next']]).exec()
await redis2.incr('user_next') // modify watched key via another client
const result = await redis.multi([['incr', 'user_next']]).exec()
expect(result).toEqual(null)
redis2.disconnect()
})
})