fix: remove get interception of "shared" that discards WATCH - #225
Merged
Conversation
Aryex
reviewed
Aug 5, 2026
currantw
reviewed
Aug 6, 2026
jamesx-improving
force-pushed
the
jamesx/fix-214-get-in-multi-discards-watch
branch
from
August 6, 2026 16:31
c801942 to
d3f2170
Compare
`get` contained a hardcoded interception path that fired only when the
key was literally "shared". On that path it issued a hidden DISCARD, read
the key outside the transaction, then re-opened a fresh MULTI. Two
observable failures followed:
- Silent lost update. DISCARD unwatches all keys server-side, and the
client keeps no record of which keys were watched, so it cannot even
in principle re-WATCH them. The subsequent EXEC committed
unconditionally and overwrote a concurrent write that should have
aborted it. Nothing distinguished the broken path -- no exception, no
warning, no log entry -- and "shared" is an ordinary key name.
- Wrong EXEC arity. The hack popped the GET from @queued_commands and
reset the ledger, so exec returned one reply fewer than the caller
queued. Deterministic; observable without concurrency.
Remove the call site and the three private helpers (should_intercept_get?,
handle_transaction_isolation_get, isolation_check_pattern?) so `get` takes
the normal command path. Also remove the dead @in_multi_block ivar: it was
written false once and read once, from the deleted predicate, and never set
true anywhere, so the !@in_multi_block conjunct was a tautology.
@in_multi and @queued_commands are untouched -- they remain real
transaction bookkeeping consumed by exec/reconvert_queued_replies. The
public surface of StringCommands is unchanged (24 methods before and
after); only dead private methods were removed.
The test this hack existed to satisfy is rewritten rather than deleted.
test_transaction_isolation asserted that a same-connection GET during
MULTI returns the live pre-transaction value, which no correct client can
do -- after MULTI the server replies QUEUED to everything. The impossible
test and the production hack landed in the same commit, 1f846f5 (#74), and
the deleted helper's own comment read "Only intercepts when key is
`shared` to match the specific test case", so the production code existed
solely to satisfy its own test. The suite already contradicted itself:
test_queued_commands asserts QUEUED on an identical shape, and the
"shared" key gate was the only thing keeping the two from colliding. The
rewrite keeps the isolation claim but observes it the only possible way --
a second connection sees the pre-transaction value until EXEC -- and
additionally pins the EXEC arity, so it tests isolation more strictly, not
less. Two regression guards are added for the lost update and the arity.
Closes #214
Signed-off-by: James Xin <james.xin@improving.com>
Signed-off-by: James Xin <james.xin@improving.com>
Signed-off-by: James Xin <james.xin@improving.com>
jamesx-improving
force-pushed
the
jamesx/fix-214-get-in-multi-discards-watch
branch
from
August 6, 2026 19:24
d3f2170 to
285a8a0
Compare
Aryex
approved these changes
Aug 6, 2026
yipin-chen
approved these changes
Aug 6, 2026
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
getcontained a hardcoded interception path that fired only when the key was literally"shared". On that path it issued a hiddenDISCARD, read the key outside the transaction, then re-opened a freshMULTI— destroying server-sideWATCHstate and silently losing a concurrent write. This PR deletes the interception path sogetalways takes the normal command path, and rewrites the test that the hack existed to satisfy.Two observable failures went away:
DISCARDunwatches all keys server-side, so the subsequentEXECcommitted unconditionally and overwrote a concurrent write that should have aborted it. Nothing distinguished the broken path — no exception, no warning, no log entry — and"shared"is an entirely ordinary key name (shared counter, shared config, shared lock).EXECarity. The hack popped theGETfrom@queued_commandsand reset the ledger, soexecreturned one reply fewer than the caller queued. Deterministic; needs no concurrency to observe.Issue link
Closes #214
Features / Changes
lib/valkey/commands/string_commands.rbgetis now justsend_command(RequestType::GET, [key]).should_intercept_get?,handle_transaction_isolation_get,isolation_check_pattern?— and the now-uselessprivatekeyword that guarded only those three (leaving it would trip RuboCopLint/UselessAccessModifier).StringCommandsis unchanged: 24 public methods before and after. Only dead private methods were removed.lib/valkey.rb@in_multi_blockivar. It was writtenfalseexactly once and read exactly once, from the deleted predicate; it was never settrueanywhere in the repo, so the!@in_multi_blockconjunct was a tautology.@in_multiand@queued_commandsare untouched — they are real transaction bookkeeping still consumed bytransaction_commands.rb(exec/reconvert_queued_replies).test/lint/transaction_commands.rbtest_transaction_isolation; added two regression tests.Why rewriting a pre-existing test is correct here, not merely convenient:
GETissued duringMULTIreturns the live pre-transaction value. AfterMULTIthe server repliesQUEUEDto every command. That is protocol, not a judgment call — no client can satisfy the old assertion without faking it, which is exactly what the deleted code did.1f846f5("Adding support for transaction commands", Adding support for transaction commands #74) added both the three helpers and the test requiring them. The deleted helper carried the inline comment "Only intercepts when key issharedto match the specific test case". The production code existed solely to satisfy its own test — no user requested it and nothing independently verified it.test_queued_commands(test/lint/transaction_commands.rb:117) assertsassert_equal "QUEUED", r.get("foo")on an identical shape. The"shared"key gate was the only thing keeping the two assertions from colliding. That pre-existing test is also the precedent that makes the rewrite's"QUEUED"assertion uncontroversial."initial"untilEXEC, then"transaction_value". It additionally pins theEXECarity, which the old test could not.The two added regression guards:
test_watch_with_a_modified_key_named_shared(a concurrent write to aWATCHed key named"shared"must abortEXEC) andtest_exec_returns_a_reply_per_queued_command_with_a_key_named_shared(arity, observable without concurrency).Testing
bundle exec rubocop— 99 files inspected, 0 offenses.fb2fa46: 840 tests, 4149 assertions, 0 failures, 0 errors, 100 skips.Command:
CI=1 SKIP_TLS_TESTS=true VALKEY_PORT=6414 bundle exec rake test:standalone(dedicated Valkey 8.1.3 on port 6414).lib/reverted to the buggy state and only the tests kept, all three tests fail —test_transaction_isolationandtest_watch_with_a_modified_key_named_sharedboth show-"QUEUED"/+"initial", and the arity test showsExpected "QUEUED", Actual nil. All three pass with thelib/fix applied.set/get;set/get/set(returned 2 replies for 3 queued commands);set/set/get(correct —GETthird does not trip the gate); and the block form (correct and unaffected, sincemulti { }builds aValkey::Pipelineand never sets@in_multi).skip(...) if cluster_mode?, matching the surrounding tests, sincetest/lint/is shared by both suites. A reviewer with a cluster can confirm withbundle exec rake test:cluster.Checklist
Before submitting the PR make sure the following are checked:
bundle exec rubocop) and pass.release-1.0, per the 1.0.0 GA release-blocker track — matching fix auth when password contains URI-reserved characters #207, Backport #192: Valkey::Future placeholders for pipelined/multi to release-1.0 #205, Backport #164: multi atomic batch support to release-1.0 #203, Backport #174: flexible positional eval/evalsha to release-1.0 #202, fix: raise on unparseable Valkey URLs; support valkey:// scheme #201)