feat(tx): skip redundant UNWATCH in Tx.Close when no WATCH is active#3854
Merged
ndyakov merged 1 commit intoJun 17, 2026
Merged
Conversation
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Reviewed by Cursor Bugbot for commit fa25e29. Configure here.
fcostaoliveira
force-pushed
the
feat/tx-skip-redundant-unwatch
branch
5 times, most recently
from
June 16, 2026 20:25
64ed55a to
f66be46
Compare
Tx.Close issued UNWATCH unconditionally before returning the connection to the pool, adding a round trip to every Watch transaction even when nothing was watched or when EXEC had already discarded the watched keys. Track whether a WATCH is still active on the transaction (watchArmed) and only issue UNWATCH from Close when it is. The flag is set on a successful WATCH and cleared only when EXEC actually ran: a nil error (committed), TxFailedErr (a watched key changed, EXEC returned nil), or an EXECABORT error (a queued command was rejected, EXEC discarded the transaction) — all of which release the watched keys server-side. Any other error may be reported before EXEC executes (for example -LOADING on the MULTI reply) or on a broken connection, so the flag is left set and Close still sends UNWATCH rather than risk leaving a watch on a pooled connection. This removes the extra UNWATCH on the common WATCH/.../EXEC path and on the no-key Watch path, and never returns a connection to the pool with an active WATCH. No exported API changes; the only wire difference is the elided no-op UNWATCH. Tests assert, on the wire (recording hook) and server-side (INFO commandstats), that UNWATCH is elided after a committed EXEC, an aborted EXEC, an EXECABORT, and a no-key Watch, and is still sent on every path where no EXEC cleared the watch: an error before EXEC, a read-only decision, a non-transactional pipeline, an empty TxPipelined, multi-key bail, a WATCH re-armed after EXEC, a panic in fn, a failed WATCH, and a server error reported before EXEC runs. A manual Unwatch case asserts no second UNWATCH from Close, and a PoolSize=1 reuse case asserts no watch leaks onto a pooled connection. Co-Authored-By: Claude Opus 4.8 <[email protected]>
fcostaoliveira
force-pushed
the
feat/tx-skip-redundant-unwatch
branch
from
June 16, 2026 20:35
f66be46 to
98d22c6
Compare
ndyakov
approved these changes
Jun 17, 2026
ndyakov
left a comment
Member
There was a problem hiding this comment.
Looks good @fcostaoliveira, thank you.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
Tx.Closealways sendsUNWATCHbefore releasing the connection:Because
Client.Watchrunsdefer tx.Close(ctx), every transaction pays an extraUNWATCHround trip — even afterEXEC, which already discards the watched keys server-side, and even forWatch(ctx, fn)calls with no keys where nothing was ever watched.Change
Add an unexported
watchArmedflag toTx, set on a successfulWATCHand cleared whenever the server discards the watched keys: onUNWATCH, or onEXEC(commit,TxFailedErr, orEXECABORT).CloseissuesUNWATCHonly while a watch is still armed.EXECclearsWATCHwhether the transaction commits or aborts, so the flag is cleared on any server reply to the MULTI/EXEC block (includingTxFailedErr). A transport error leaves the flag set, but that connection is removed from the pool rather than reused, so the skippedUNWATCHcannot leak a watch onto a pooled connection.Why "armed" rather than "cleared"
Tracking "is a watch armed" keeps the zero value (
false) on the safe, cheap path: aTxthat never watched anything never sendsUNWATCH, and a re-WATCHafter anEXECre-arms naturally, soClosestill releases it. A "was it cleared" flag would default to sendingUNWATCH, miss the no-key case, and need an extra reset to avoid leaking on re-WATCH.Compatibility
No exported API or struct field changes. The only wire-visible difference is that the redundant no-op
UNWATCHis no longer sent on the paths above;UNWATCHis still sent whenever a watch may still be active.Tests
tx_unwatch_test.goasserts, both on the wire (via a recording hook) and server-side (viaINFO commandstats):UNWATCH/ nocmdstat_unwatchafter a committedEXEC;UNWATCHfor a no-keyWatch;UNWATCH/cmdstat_unwatchpresent whenfnreturns beforeEXEC;WATCH(WATCH, bail, mutate the key, then run an unrelated transaction and assert it is not spuriously aborted).Note
Medium Risk
Changes connection lifecycle for Redis transactions; incorrect
watchArmedhandling could leakWATCHonto pooled connections, though behavior is heavily tested and the public API is unchanged.Overview
Tx.Closeno longer always sendsUNWATCH. It tracks whether a successfulTx.Watchleft keys watched via an internalwatchArmedflag and only issuesUNWATCHwhen a watch may still be active, avoiding an extra round trip afterEXEC(commit,TxFailedErr, orEXECABORT), afterTx.Unwatch, and forClient.Watchwith no keys.The flag is set on successful
WATCH, cleared on successfulUNWATCH, and cleared in theTxPipelineexec path only whenEXECactually ran (nil,TxFailedErr, orIsExecAbortError); other pipeline errors leave it set soClosestill releases the watch. Documentation onwatchArmednotes that rawProcess/DoforWATCHbypasses tracking (unsupported).Tests: New
tx_unwatch_test.gocovers wire-level command recording,INFO commandstats, optimistic-lock abort, pre-EXECfailures, panic unwinding, re-arm afterEXEC, and pool reuse (PoolSize=1) to ensure watches are not leaked.Reviewed by Cursor Bugbot for commit 98d22c6. Bugbot is set up for automated code reviews on this repo. Configure here.
Behavior note (unsupported usage)
Issuing
WATCHdirectly viaTx.Process/Tx.Do(instead ofTx.Watch) is not tracked by the flag, soClosewill not send a coveringUNWATCHfor it — previously the unconditionalCloseUNWATCH masked this. Use the typedTx.Watch/Tx.Unwatch. This is documented on thewatchArmedfield.Related: #435 (the no-key
Watchround-trip this also removes).