From 72988986b794d9781960eb71d50f063cb08ac114 Mon Sep 17 00:00:00 2001 From: Martin Seidel Date: Tue, 21 Jul 2026 09:51:27 +0200 Subject: [PATCH 1/3] fix(pipeline): clear watch state after successful EXEC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real Redis always clears watching keys and dirty flag after EXEC, regardless of whether the transaction succeeded or was aborted. The abort path already did this, but the success path did not — causing the pipeline's own commands to re-dirty the client via modifiedKeyEvents, making subsequent pipelines incorrectly return null. Co-Authored-By: Claude Sonnet 4.6 --- src/pipeline.js | 4 ++++ test/integration/exec.js | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/src/pipeline.js b/src/pipeline.js index abe036cf..8c50f129 100644 --- a/src/pipeline.js +++ b/src/pipeline.js @@ -97,6 +97,10 @@ class Pipeline { const batch = this.batch this.batch = [] + // 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.redis.dirty = false return asCallback( Promise.all(batch.map(cmd => cmd())).then(replies => replies.map(reply => [null, reply]) diff --git a/test/integration/exec.js b/test/integration/exec.js index b92f6592..ccc52af0 100644 --- a/test/integration/exec.js +++ b/test/integration/exec.js @@ -83,4 +83,13 @@ 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]]) + }) }) From 1ee6189bc5bada25ada02abaa4e38d02314fed76 Mon Sep 17 00:00:00 2001 From: Martin Seidel Date: Tue, 21 Jul 2026 10:13:09 +0200 Subject: [PATCH 2/3] fix(pipeline): scope watch state handling to MULTI transactions only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regular pipelines (redis.pipeline()) should not check or clear WATCH state — only MULTI transactions (redis.multi()) interact with watch semantics in real Redis. Introduce _isMulti flag so exec() can distinguish the two cases. Co-Authored-By: Claude Sonnet 4.6 --- src/index.js | 2 ++ src/pipeline.js | 21 ++++++++++++--------- test/integration/exec.js | 10 ++++++++++ 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/index.js b/src/index.js index 6d0c64d3..7de5b915 100644 --- a/src/index.js +++ b/src/index.js @@ -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)) diff --git a/src/pipeline.js b/src/pipeline.js index 8c50f129..504ef3c4 100644 --- a/src/pipeline.js +++ b/src/pipeline.js @@ -85,22 +85,25 @@ 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 const batch = this.batch this.batch = [] - // 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.redis.dirty = false return asCallback( Promise.all(batch.map(cmd => cmd())).then(replies => replies.map(reply => [null, reply]) diff --git a/test/integration/exec.js b/test/integration/exec.js index ccc52af0..394ad480 100644 --- a/test/integration/exec.js +++ b/test/integration/exec.js @@ -92,4 +92,14 @@ describe('exec', () => { 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() + }) }) From e4651ec2e5bfe16525f3e8d510c326b50b2e7027 Mon Sep 17 00:00:00 2001 From: Martin Seidel Date: Tue, 21 Jul 2026 10:15:56 +0200 Subject: [PATCH 3/3] chore: add changeset for pipeline watch state fix Co-Authored-By: Claude Sonnet 4.6 --- .changeset/pipeline-clear-watch-state-after-exec.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/pipeline-clear-watch-state-after-exec.md diff --git a/.changeset/pipeline-clear-watch-state-after-exec.md b/.changeset/pipeline-clear-watch-state-after-exec.md new file mode 100644 index 00000000..83edf664 --- /dev/null +++ b/.changeset/pipeline-clear-watch-state-after-exec.md @@ -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.