Skip to content

feat(tx): skip redundant UNWATCH in Tx.Close when no WATCH is active#3854

Merged
ndyakov merged 1 commit into
redis:masterfrom
fcostaoliveira:feat/tx-skip-redundant-unwatch
Jun 17, 2026
Merged

feat(tx): skip redundant UNWATCH in Tx.Close when no WATCH is active#3854
ndyakov merged 1 commit into
redis:masterfrom
fcostaoliveira:feat/tx-skip-redundant-unwatch

Conversation

@fcostaoliveira

@fcostaoliveira fcostaoliveira commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Tx.Close always sends UNWATCH before releasing the connection:

func (c *Tx) Close(ctx context.Context) error {
	_ = c.Unwatch(ctx).Err()
	return c.baseClient.Close()
}

Because Client.Watch runs defer tx.Close(ctx), every transaction pays an extra UNWATCH round trip — even after EXEC, which already discards the watched keys server-side, and even for Watch(ctx, fn) calls with no keys where nothing was ever watched.

Change

Add an unexported watchArmed flag to Tx, set on a successful WATCH and cleared whenever the server discards the watched keys: on UNWATCH, or on EXEC (commit, TxFailedErr, or EXECABORT). Close issues UNWATCH only while a watch is still armed.

EXEC clears WATCH whether the transaction commits or aborts, so the flag is cleared on any server reply to the MULTI/EXEC block (including TxFailedErr). A transport error leaves the flag set, but that connection is removed from the pool rather than reused, so the skipped UNWATCH cannot 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: a Tx that never watched anything never sends UNWATCH, and a re-WATCH after an EXEC re-arms naturally, so Close still releases it. A "was it cleared" flag would default to sending UNWATCH, 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 UNWATCH is no longer sent on the paths above; UNWATCH is still sent whenever a watch may still be active.

Tests

tx_unwatch_test.go asserts, both on the wire (via a recording hook) and server-side (via INFO commandstats):

  • no UNWATCH / no cmdstat_unwatch after a committed EXEC;
  • no UNWATCH for a no-key Watch;
  • UNWATCH / cmdstat_unwatch present when fn returns before EXEC;
  • a pooled connection (PoolSize=1) is never reused with a leaked 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 watchArmed handling could leak WATCH onto pooled connections, though behavior is heavily tested and the public API is unchanged.

Overview
Tx.Close no longer always sends UNWATCH. It tracks whether a successful Tx.Watch left keys watched via an internal watchArmed flag and only issues UNWATCH when a watch may still be active, avoiding an extra round trip after EXEC (commit, TxFailedErr, or EXECABORT), after Tx.Unwatch, and for Client.Watch with no keys.

The flag is set on successful WATCH, cleared on successful UNWATCH, and cleared in the TxPipeline exec path only when EXEC actually ran (nil, TxFailedErr, or IsExecAbortError); other pipeline errors leave it set so Close still releases the watch. Documentation on watchArmed notes that raw Process/Do for WATCH bypasses tracking (unsupported).

Tests: New tx_unwatch_test.go covers wire-level command recording, INFO commandstats, optimistic-lock abort, pre-EXEC failures, panic unwinding, re-arm after EXEC, 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 WATCH directly via Tx.Process/Tx.Do (instead of Tx.Watch) is not tracked by the flag, so Close will not send a covering UNWATCH for it — previously the unconditional Close UNWATCH masked this. Use the typed Tx.Watch/Tx.Unwatch. This is documented on the watchArmed field.

Related: #435 (the no-key Watch round-trip this also removes).

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit fa25e29. Configure here.

Comment thread tx.go
@fcostaoliveira
fcostaoliveira force-pushed the feat/tx-skip-redundant-unwatch branch 5 times, most recently from 64ed55a to f66be46 Compare June 16, 2026 20:25
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
fcostaoliveira force-pushed the feat/tx-skip-redundant-unwatch branch from f66be46 to 98d22c6 Compare June 16, 2026 20:35

@ndyakov ndyakov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good @fcostaoliveira, thank you.

@ndyakov ndyakov added the bug label Jun 17, 2026
@ndyakov
ndyakov merged commit 5484b0b into redis:master Jun 17, 2026
40 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants