Skip to content

feat(sorted_set): implement ZRANGEBYLEX, ZREVRANGEBYLEX, ZREMRANGEBY{RANK,SCORE,LEX}, ZDIFFSTORE and ZADD INCR (moon#959) [stacked on #991] - #1003

Draft
TinDang97 wants to merge 9 commits into
mainfrom
feat/959-zset-missing-commands
Draft

TinDang97 wants to merge 9 commits into
mainfrom
feat/959-zset-missing-commands

Conversation

@TinDang97

@TinDang97 TinDang97 commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Closes #959.

STACKED on #991 (fix/969-zset-option-semantics). Base is that branch,
not main. #991 must merge first, and this PR must then be re-based
onto main before it is merged — squash-merging a stacked child has landed
its parent silently in this repo before. Do NOT merge this while #991 is
open.

Six sorted-set commands answered ERR unknown command and ZADD ... INCR
answered an arity error. docs/commands.md:52 advertised ZRANGEBYLEX all
along, and neither parity harness named any of them. Every reply below was
read off a live redis-server 8.6.1 on the wire before the code was
written — never from COMMAND INFO.

What changed

command where notes
ZRANGEBYLEX, ZREVRANGEBYLEX new sorted_set_lex.rs one impl behind both spellings, via the shared-borrow view (a listpack survives the read, moon#928); wired into dispatch, dispatch_read and the read prefilter
ZREMRANGEBYRANK/SCORE/LEX sorted_set_write.rs zrem's two-arm shape: listpack trimmed in place and never converted, B+tree credited exactly, drained key deleted; new rank_window helper follows zremrangeGenericCommand
ZDIFFSTORE new sorted_set_store.rs (the store family moved out of the write half when it crossed 1500 lines) third SetOp arm of zstore_impl, so it inherits #991's numkeys classes; WEIGHTS/AGGREGATE are syntax error as on redis
ZADD ... INCR sorted_set_write.rs zincrby refactored into zincr_member(flags); zsetAdd's decision order for NX/XX/GT/LT; bulk-string score or nil

Registered in the phf table as @sortedset with redis's arities.

Two family-wide corrections that rode along (one function, now shared
by three commands): the store family reads its sources before parsing
options, so ZUNIONSTORE d 1 <string> BOGUS is WRONGTYPE as on redis
(#991 had made it syntax error); and it reads them through
get_sorted_set_ref_if_alive, so a listpack source is no longer flattened
to skiplist by being read.

The one shared.rs commit — please read

0d05221f is the only commit touching src/server/conn/shared.rs. It is
confined to touches_a_key_it_did_not_route_on: its doc block (the
ZDIFFSTORE — not implemented bullet) and one match arm directly below it
((10, b'z') => ZDIFFSTORE). The arm goes one step past "doc block only",
and it is not optional: with the arm removed and everything else in place,
tests/two_key_write_cross_shard.rs::t2k1 at --shards 4 reports
12/180 placements acking ZDIFFSTORE while the destination lands nowhere
(the moon#592 defect) — that is the attack result below, verbatim. The doc
block itself names the t2k4 tripwire as the thing that forces this
migration. is_inline_intercepted / INTERCEPTED_NOT_DECLARED are untouched.
If the arm must ship separately, say so and I will split it out.

Evidence

Red — the 119 harness rows (30 in test-commands.sh, 89 in
test-consistency.sh) extracted verbatim from the committed scripts and run
against the pre-fix control a8eb2efc next to redis 8.6.1:
112 red / 7 green. 65 reds are unknown command, 22 are the arity error
ZADD INCR used to give, the rest are wrong answers downstream of those.
The 7 greens are fence rows that hold either way (EXISTS of a key nothing
created, GET of the WRONGTYPE string).

Green — the same 119 rows against this branch's binary: 119/119.
A 301-probe oracle sweep (every happy path and error form I could think of,
plus encodings and DEBUG DIGEST) leaves 16 differences, every one
pre-existing and listed under "out of scope" below; the sweep also caught
one wrong guess of mine (WITHSCORES/bounds precedence), fixed in
10bd1ff0.

Unit tests — 22 new in sorted_set/mod.rs::missing_commands_959_tests,
22/22; the broader sorted_set:: + command::tests + metadata:: set
225/225; zset_read_cold_tier_928 (now with the two lex reads) 5/5;
two_key_write_cross_shard 5/5 with ZDIFFSTORE in PROBES.

Attack (apply, build, read, restore, git diff HEAD --stat = 0 files,
rebuilt green):

  • rank_window made to clamp a still-negative stop → rank_window_follows_the_redis_rule and zremrangebyrank_normalises_ranks_like_redis red.
  • dispatch_read's (14, b'z') arm emptied → dispatch_read_serves_the_lex_reads red (ZREVRANGEBYLEX came back unknown command on the read path).
  • WITHSCORES check moved back after the bounds → zrangebylex_error_surface_matches_the_oracle red.
  • ZDIFFSTORE dropped from the cross-shard write guard → t2k1 red: 12/180 placements broke the acknowledgement contract ... ZDIFFSTORE ACKED ":0" but the destination does not hold the write.

All four attacked trees compiled; the tests, not the compiler, caught them.

Dispatch-path coverage, per command (tested vs reasoned):

  • command::dispatch — every new command has tests through the real dispatch() (call(...) in the module) and wire rows on a live server.
  • command::dispatch_readZRANGEBYLEX/ZREVRANGEBYLEX tested through dispatch_read() and the prefilter (dispatch_read_serves_the_lex_reads). The five write commands are W-flagged and never reach this path (reasoning: is_read/prefilter route on the registry flag; a wire row on a bare connection exercises the real routing).
  • server::conn::try_inline_dispatchreasoning only: it inlines exactly GET and a plain SET (blocking.rs, the [G,E,T]/[S,E,T] match); every other command falls through to generic dispatch. No test, because there is no arm for a zset command to be missing from.

Gates (exit codes): cargo fmt --check 0 · cargo clippy --all-targets -- -D warnings 0 · cargo check --all-targets --no-default-features --features runtime-tokio,jemalloc 0 · release-fast build 0. Not run: scripts/ci-local.sh, the hosted matrix, any Linux/VM leg, the full scripts/test-*.sh scripts end to end (their moon#959 blocks were run verbatim against both binaries instead).

Found while reproducing, NOT changed here

…rs as syntax errors

Two inputs moon ACCEPTED that Redis rejects, plus a sweep of wrong error
classes. Every expectation here was taken from a live redis-server 8.6.1 on a
second port, command by command, before anything was changed.

Accepted where Redis errors:

* `ZADD k GT LT 1 m` returned `(integer) 1`. The guard only rejected
  `nx && (gt || lt)`; Redis rejects all three pairings. Worse, the pairing then
  reached TWO mutation sites that answered it with a SILENT no-op — the
  listpack arm and the B+tree arm each carried a `gt && lt => false`
  fallthrough, so a client asking for an impossible combination got `0` and an
  unchanged score instead of an error. Both arms are removed rather than left
  unreachable, so relaxing the guard cannot quietly resurrect the no-op. The
  message also said "GT, LT, and NX"; Redis says "and/or".

* `ZUNIONSTORE d 1 k WEIGHTS nan` returned `(integer) 1`, and the read twin
  `ZUNION 1 k WEIGHTS nan` returned the member. Rust's `str::parse::<f64>`
  accepts "nan" where C's `strtod` + `isnan` check in
  `getDoubleFromObjectOrReply` rejects it, and only a `None` mapped to the
  error. A NaN weight poisons every aggregated score. Infinities stay legal, as
  they are on Redis.

Wrong error CLASS. This matters beyond wording: redis-py raises a distinct
exception type per class, so a client branching on the exception takes the
wrong branch — and retries a request that can never succeed.

    command                        was                       now
    ZPOPMIN k notanint / k -1      not an integer            value is out of range, must be positive
    ZINTERCARD 0 k                 numkeys can't be ...      at least 1 input key is needed for 'zintercard' command
    ZUNIONSTORE d 0 k              wrong number of args      at least 1 input key is needed for 'zunionstore' command
    ZUNIONSTORE d -1 k             not an integer            (same)
    ZUNIONSTORE d 1 k WEIGHTS      wrong number of args      syntax error
    ZUNIONSTORE d 1 k AGGREGATE    wrong number of args      syntax error
    ZUNIONSTORE d 2 k              wrong number of args      syntax error
    ZADD k 1 a 2                   wrong number of args      syntax error
    ZMPOP 0 k MIN                  numkeys can't be ...      numkeys should be greater than 0

The set-operation family splits into TWO classes and the split is the point:
`getLongFromObjectOrReply(..., NULL)` answers the generic integer error for
bytes that are not a number, and only then does `if (setnum < 1)` name the
command. ZMPOP does NOT split — `getRangeLongFromObject` carries one message
for every failure. Arity is checked FIRST, so `ZUNION 0`, which names no key at
all, stays an arity error and never reaches the numkeys rules.

Two divergences found while reproducing the above, in neither issue:

* `ZINTERCARD k LIMIT -1` — `limit: usize` failed a negative into the generic
  message; Redis says `LIMIT can't be negative`.
* `ZMPOP 1 k MIN COUNT 0` / `COUNT -1` — Redis says
  `count should be greater than 0`.

And one moon#967 leftover: that sweep rewrote every zset option loop to reject
an unrecognised token except `zstore_impl`, so `ZUNIONSTORE d 1 k BOGUS`
stepped over `BOGUS` and answered a different, successful command.

Deliberately NOT changed. moon#969 also cites four ZRANGE-family sites as
wrong. They are not. `mod.rs:317/:329/:639/:649` are rank-index parses, and the
`LIMIT offset count` parses next to them take the same shape; Redis reads all
of them with `getLongFromObjectOrReply(..., NULL)`, which produces exactly the
`ERR value is not an integer or out of range` moon already answers — confirmed
against the oracle, which disagrees with the issue. Those sites also accept
negatives, which the new helpers do not. `rank_and_limit_parses_keep_the_
generic_integer_error` pins all eight so a later reading of moon#969 cannot
"fix" them into a divergence.

Two shared helpers carry the two shapes so eleven call sites do not each
transcribe them, and the boundary against the correct sites is explicit:
`parse_numkeys` (two classes, names the command) and `parse_bounded_count`
(one bespoke message, minimum bound). The ZADD option loop itself changes by
one condition and one error class only.

BEHAVIOUR CHANGE: `ZADD ... GT LT` and `WEIGHTS nan` now error where they
previously succeeded.

Closes #969

author: Tin Dang
…ilon window

Both ZADD mutation loops decided `changed` with an ABSOLUTE `f64::EPSILON`
window — `(old - new).abs() > f64::EPSILON` — where Redis's `zsetAdd` compares
the two scores EXACTLY (`if (score != curscore)`).

`f64::EPSILON` is the gap between 1.0 and the next double, a RELATIVE
quantity. Used as an absolute tolerance it swallows real differences at every
magnitude below 1, and the error grows as the scores shrink:

    ZADD k 0.0000000001 m
    ZADD k CH 0.00000000010000001 m   -> moon: 0   redis: 1
    ZSCORE k m                        -> 0.00000000010000001

The reply said nothing changed; the very next read said otherwise. That is not
a rounding nicety — a change of six significant figures was reported as no
change, because the ABSOLUTE difference (1e-17) sits under a window sized for
magnitude 1. Any client using `CH` as a did-anything-happen signal — cache
invalidation, a change feed, a dirty flag — silently skipped the update.

The window also disagreed with the code that does the writing.
`zset_update_existing` decides whether to move the member on `to_bits()`
inequality, so a sub-epsilon rescore really was written to both the hash and
the B+tree, and only the tally pretended otherwise.

Both arms are fixed, because they are separate code: the listpack loop and the
B+tree loop each carried their own copy. `ch_counts_a_sub_epsilon_rescore`
drives both — a short member for the listpack encoding, a 73-byte member to
force the B+tree — and asserts the reply AND the stored score, so the two can
no longer disagree. It uses `nextafter(1.0)`, whose distance from 1.0 is
exactly `f64::EPSILON` and so failed the old strict `>`.

Neither side can be NaN: `parse_zadd_pair` rejects a NaN score before either
loop runs, so `!=` is total here. `-0.0 != 0.0` is false, matching Redis.

The range-bound half of the same epsilon defect was fixed separately by
moon#966; this is the remaining `CH` counter.

Closes #792

author: Tin Dang
…counter

Neither harness had a single row for any of these forms, which is exactly how
they were free to drift: `scripts/test-consistency.sh` and
`scripts/test-commands.sh` both compare moon against a live redis, so a row
here is a standing oracle assertion.

69 `assert_both` rows in the consistency suite and 26 `assert_match` rows in
the command suite, covering: the three GT/LT/NX pairings and the score being
left alone at BOTH encodings; the odd-tail-vs-no-pairs split; a NaN weight on
all four set-operation commands plus the infinite weight that stays legal; the
ZPOPMIN/ZPOPMAX count class; the two-class numkeys split and the arity floor
above it; ZMPOP's single-message numkeys and COUNT; ZINTERCARD's LIMIT; and
the six syntax-vs-arity forms.

Proven red before the fix, against the pre-fix binary and the same redis
8.6.1 oracle: 48 of the 69 rows FAILED, and the 21 that passed are precisely
the anti-regression rows plus the forms that were already correct. All 69 pass
after. The rows were extracted verbatim from the committed script to run that
comparison, so the evidence cannot have drifted from what landed here.

The anti-regression rows are the point of the last block. moon#969 names four
ZRANGE-family sites as wrong; the oracle says they are not, because Redis
reads a rank index and a `LIMIT offset count` with
`getLongFromObjectOrReply(..., NULL)` and that IS the generic integer error
moon already answers. Those ten rows passed identically before and after this
branch, on both servers, and exist so a later reading of the issue cannot
"fix" them into a divergence.

CHANGELOG records the behaviour change: `ZADD GT LT` and `WEIGHTS nan` now
error where they previously succeeded.

Refs #969
Refs #792

author: Tin Dang
ZRANGEBYLEX, ZREVRANGEBYLEX, ZREMRANGEBYRANK, ZREMRANGEBYSCORE,
ZREMRANGEBYLEX and ZDIFFSTORE answered `ERR unknown command` on moon, and
`ZADD ... INCR` — what redis-py's `zadd(..., incr=True)` sends — answered an
arity error (moon#959). docs/commands.md advertised ZRANGEBYLEX all along.

Every reply, error surface included, was read off a live redis-server 8.6.1
BEFORE the code was written (206 probes, 165 of them diverging on the
pre-fix binary a8eb2ef), and the implementation reuses the bound parsers
and range helpers ZRANGE already owns rather than growing a second grammar:

* ZRANGEBYLEX / ZREVRANGEBYLEX (`sorted_set_lex.rs`): the same shape as
  `zrangebyscore_readonly` — one implementation behind both spellings,
  through the shared-borrow view so a listpack survives the read
  (moon#928), wired into `dispatch`, `dispatch_read` and the read
  prefilter. Redis's precedence is kept: option loop, then the range
  grammar (checked BEFORE the key, so a bad bound on a missing key is an
  error), then the legacy spelling's own `WITHSCORES not supported in
  combination with BYLEX`.
* ZREMRANGEBY{RANK,SCORE,LEX} (`sorted_set_write.rs`): `zrem`'s two-arm
  shape — a listpack is trimmed in place and never converted, the B+tree
  arm credits each member and the table shrink, and a drained key is
  deleted on both arms (which is also what reclaims the container the
  accessor fabricates for a missing key). Rank normalisation follows
  `zremrangeGenericCommand` through a new `rank_window` helper: a stop
  still negative after `len + stop` is NOT clamped, so `-10 -6` removes
  nothing. (`zrange_by_rank` clamps it and answers `[a]` where redis
  answers `[]` — a pre-existing ZRANGE divergence reported separately, not
  changed here.)
* ZDIFFSTORE (`sorted_set_store.rs`, the store family moved out of the
  write half when it crossed the 1500-line rule): a third `SetOp` arm of
  `zstore_impl`, so it inherits the two-class numkeys split, the overrun
  rule and the unknown-token rule of moon#969; WEIGHTS/AGGREGATE are
  refused as `syntax error`, which is what redis does for `SET_OP_DIFF`.
  Two family-wide corrections rode along: the sources are now looked up
  BEFORE the options are parsed, so `ZUNIONSTORE d 1 <string> BOGUS` is
  WRONGTYPE as on redis; and they are read through
  `get_sorted_set_ref_if_alive`, so reading a listpack source no longer
  flattens it to a skiplist.
* ZADD INCR: `zincrby` is refactored into `zincr_member` taking the
  NX/XX/GT/LT flags, in `zsetAdd`'s decision order — NX refuses a present
  member before the sum is formed, a NaN sum errors with nothing written,
  GT/LT refuse a sum that does not move the right way (zero included),
  only XX refuses an absent member, and a refusal on a key the call had to
  fabricate leaves nothing behind. Reply is the new score as a bulk
  string, or nil. Pair count is checked after the parity and flag-pairing
  rules, as redis does.

Registered in the phf table as @sortedset with redis's arities; the
`used_memory` ledger is asserted exact against a full recount on both
encodings for every new write; the cold-tier read test gains the two lex
reads. `try_inline_dispatch` inlines only GET and a plain SET, so there is
no third arm for these to be missing from.

Refs moon#959. Stacked on #991 (moon#969), whose error conventions the new
commands follow.

author: Tin Dang
…n#959

Neither scripts/test-commands.sh (504 rows) nor scripts/test-consistency.sh
(132 rows) named any of the six commands or ZADD INCR, which is how a
command docs/commands.md advertised went missing without a harness
noticing. 29 rows join the first script and 87 the second, every one a
reply read off redis 8.6.1: the happy paths, the bounds grammar (checked
before the key), LIMIT, the numkeys classes and the refused option tokens
of ZDIFFSTORE, WRONGTYPE from every command without clobbering the value,
drained keys deleted, and the NX/XX/GT/LT decision order of ZADD INCR.
Run verbatim against the pre-fix binary a8eb2ef: 109 of 116 rows red (62
of them `unknown command`, the rest the arity error ZADD INCR used to
give); the 7 green are the fence rows that hold either way.

Exclusive/inclusive bound tokens are quoted (`'(d'`): a bare `(` is a bash
syntax error that kills the sourced script at that line, which the
runner caught on the first attempt.

docs/commands.md: the sorted-set line now lists exactly what dispatch
accepts on a live server — the six new commands plus nine that were
implemented but unlisted (ZRANGESTORE, ZDIFF, ZUNION, ZINTER, ZINTERCARD,
ZMSCORE, ZRANDMEMBER, ZMPOP, BZMPOP), each sent to the server to confirm.

Refs moon#959.

author: Tin Dang
… isolated)

ISOLATED COMMIT — the only change to src/server/conn/shared.rs in this
branch, confined to `touches_a_key_it_did_not_route_on`'s doc block and
its match. `is_inline_intercepted` and the `INTERCEPTED_NOT_DECLARED`
waiver (moon#937 / moon#946, owned by a parallel change) are untouched.

That doc block listed ZDIFFSTORE as "not implemented, so nothing to
misplace" and named `tests/two_key_write_cross_shard.rs::t2k4` as the
tripwire that fires the moment it starts working — which moon#959 did.
Without the guard, `ZDIFFSTORE dst 1 src` at --shards >= 2 would ack +OK
and write `dst` into the wrong shard's slice (the moon#592 defect). The
arm is the same shape as ZUNIONSTORE/ZINTERSTORE's: routed on `dst`,
reads every source.

t2k4 migrates ZDIFFSTORE into PROBES, exactly as GEORADIUS/
GEORADIUSBYMEMBER migrated under moon#645, and keeps the two `_RO` rows.

Refs moon#959, moon#592.

author: Tin Dang
…bounds

The first draft validated the lex bounds before the WITHSCORES refusal;
the oracle sweep of the built binary (295 probes vs redis 8.6.1) caught
the one probe that disagreed: `ZRANGEBYLEX k a b WITHSCORES` is
`ERR syntax error, WITHSCORES not supported in combination with BYLEX` on
redis, not the bound error. The unit test had encoded the wrong guess and
is corrected; a row pinning the precedence joins both harnesses, plus one
for the option loop still outranking WITHSCORES (`... WITHSCORES LIMIT 1`
is a plain `syntax error`).

Also: the registry comment no longer cites `COMMAND INFO` as a source
(every arity was compared on the wire), and the docs tip states what was
actually done to re-verify the sorted-set table.

Refs moon#959.

author: Tin Dang
@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@TinDang97

Copy link
Copy Markdown
Collaborator Author

Ruling on the shared.rs scope question: keep the match arm — it is part of the command, not a scope overrun

You asked whether to split out the (10, b'z') => ZDIFFSTORE arm in
touches_a_key_it_did_not_route_on, since the brief said "doc block only". Keep it.

Your attack settles it. With the arm removed, t2k1 at --shards 4 reports
12/180 placements ... ZDIFFSTORE ACKED ":0" but the destination does not hold the write
— that is the moon#592 defect, shipped. A two-key write command without its routing guard
is not a working command; it is a silent data-loss bug wearing a command's name.

So the arm is not an extra edit that happened to land nearby — it is a required part of
implementing ZDIFFSTORE at all. Shipping the command without it would have been the
scope error. The instruction I gave you was drawn to prevent you colliding with the
moon#937/#946 agent inside is_inline_intercepted and INTERCEPTED_NOT_DECLARED, and you
left both untouched, which is what actually mattered.

Stopping to ask was still right. The rule I would apply generally: a file-ownership boundary
protects the other agent's work, not the correctness of yours. When the two conflict,
surface it — exactly as you did — rather than shipping something broken to stay inside a
line I drew.

One consequence you should know about

PR #1005 (moon#937/#946) landed and it rewrites the command registry, adding one-token flag
edits to 98 commands in metadata.rs. Its author flagged that your ZDIFFSTORE row
there will likely need a trivial rebase. That is now concrete rather than hypothetical:
whichever of #1003 / #1005 merges second takes the rebase. Worth deciding that order
deliberately.

Also relevant to your stack: #1005 turned out to fix a user-visible transaction bug —
WATCH was inline-intercepted while a pipelined cross-shard write was still deferred, so
EXEC aborted 17/24 times at --shards 4 (redis: 0/24). Filed as moon#1006. It does not
touch your work, but it is in the same file and worth knowing about if you rebase.

I am verifying your commands against a live oracle now and will report separately.

@TinDang97

Copy link
Copy Markdown
Collaborator Author

Independent verification — my own build, live redis 8.6.1 oracle

All nine probe rows match redis byte-for-byte, and every one is unknown command or an
arity error on the pre-fix control:

probe pre-fix a8eb2efc redis 8.6.1 this PR
ZRANGEBYLEX z - + ERR unknown command a b c d a b c d
ZRANGEBYLEX z [b (d ERR unknown command b c b c
ZREVRANGEBYLEX z + - ERR unknown command d c b a d c b a
ZREMRANGEBYLEX z [a [b ERR unknown command 2, left c d 2, left c d
ZREMRANGEBYSCORE z 1 2 ERR unknown command 2, left c 2, left c
ZREMRANGEBYRANK z 0 1 ERR unknown command 2, left c 2, left c
ZDIFFSTORE d 2 s1 s2 ERR unknown command 1, dst b 1, dst b
ZADD z INCR 3 m ERR wrong number of arguments 8 8
ZADD z NX INCR 3 m ERR wrong number of arguments (nil) (nil)

The NX INCR row is the one I expected to be wrong and it is not — nil on both.

The shared.rs match arm: measured, and it confirms the ruling

I tested ZDIFFSTORE with untagged destination and sources, 30 trials:

this PR, --shards 1 : correct=30  CROSSSLOT=0   ACKED-BUT-LOST=0
this PR, --shards 4 : correct=2   CROSSSLOT=28  ACKED-BUT-LOST=0

Zero acked-but-lost. The 2 correct rows at 4 shards are trials where the keys happened to
co-locate — the same hash-placement luck that made
sort_store_invalidates_the_destination_it_writes pass in PR #990's follow-up, and a good
reminder that a 30-trial loop is the right shape for this probe and a single placement is
not.

So the arm is doing exactly what your attack said it does, and keeping it in this PR is
correct: without it the command ships as a silent moon#592 data-loss bug. Ruled on
separately above — no change needed.

On the two instrument bugs you caught

Both are worth recording because they are the failure mode this repo keeps hitting:

  • bare (d tokens were a bash syntax error that killed the sourced rows at line 9 — a
    harness that silently stops running is indistinguishable from one that passes;
  • the first ZRANGEBYLEX unit test encoded your own wrong precedence guess, so it
    would have locked in the bug it was written to prevent.

The oracle sweep caught the second one. That is the argument for sweeping against a live
server rather than asserting from the spec, in one line.

Out of scope, acknowledged

You filed moon#1001 for ZRANGE z -10 -6[a] where redis says [], plus
bounds-parsed-after-lookup. Correctly separated — that is a pre-existing wrong answer in a
command you did not add, and folding it in here would have made this PR unreviewable.

Merge-order note

PR #1005 (moon#937/#946) is open and rewrites the command registry with one-token flag
edits across 98 commands in metadata.rs. Your ZDIFFSTORE row there will need a trivial
rebase whichever way the order falls; worth choosing it deliberately.

scripts/ci-local.sh has not been run and the hosted matrix has not been dispatched.

@TinDang97
TinDang97 changed the base branch from fix/969-zset-option-semantics to main September 17, 2026 04:31
…d guard

#1003 was stacked on #991, which has since merged to main along with #990,
#987, #993, #996 and #985. Retargeting to `main` left four conflicted files.
Resolutions, each argued from behaviour rather than from which side is longer:

* `src/command/sorted_set/sorted_set_write.rs` (3 hunks) — OURS, all three.
  The conflict looks like "main added `zunionstore`/`zinterstore`/`zstore_impl`
  and this branch did not", but `git diff c3c5e33 0cfab03 -- src/command/
  sorted_set` is EMPTY: main's sorted-set content is exactly #991's, which is
  already in this branch's history, and #1003 then MOVED that family into the
  new `sorted_set_store.rs` when the file crossed 1500 lines. Taking main's
  side would have duplicated three functions. The `use` list is likewise ours:
  main's extra imports (`AggregateOp`, `parse_numkeys`, `zadd_member`,
  `zrange_by_*`, `clamp_nan_to_zero`) serve only the code that moved out, and
  `clippy --all-targets -D warnings` confirms nothing is unused or missing.

* `scripts/test-commands.sh`, `scripts/test-consistency.sh` — both sides append
  independent sections and main's half of each conflict hunk is EMPTY, so the
  markers were removed in place rather than resolved by file. (`--ours` would
  have silently dropped the 616 lines main added elsewhere in
  `test-consistency.sh`; the merged files are pure additions against main —
  zero deleted lines — and `bash -n` is clean on both.) One row is ADDED by
  this merge: `test-consistency.sh`'s moon#592 `XW_CASES` sweep enumerates the
  cross-shard write family, and `ZDIFFSTORE` is now a member, so it gains a
  `zdiffstore` case beside `zunionstore`/`zinterstore` — the same row moon#645
  added for `georadiusstore` when that command joined.

* `src/server/conn/shared.rs` — the dangerous one, and not just a doc comment.

## ZDIFFSTORE: the routing invariant the two sides disagree about

Main's doc block says `ZDIFFSTORE` is deliberately EXCLUDED from
`touches_a_key_it_did_not_route_on` because it is "not implemented in moon
(unknown command), so there is no write to misplace". moon#959 implements it.
That sentence is now false, and shipping it as written would leave a
cross-shard `ZDIFFSTORE` acking a destination that lands nowhere — the
moon#592 misdirected destructive write that main's moon#962 work just closed
for twelve other commands.

The mechanical trap is that `ZDIFFSTORE` and `ZINTERCARD` are both ten bytes
beginning with `z`, so the two sides edited the SAME match arm:

    HEAD: (10, b'z') => cmd.eq_ignore_ascii_case(b"ZDIFFSTORE")
    main: (10, b'z') => cmd.eq_ignore_ascii_case(b"ZINTERCARD")

Keeping either arm alone drops the other command out of the guard, and keeping
both as separate arms is an unreachable pattern. Resolved as one arm naming
both spellings, with the collision documented at the arm so a future narrowing
is not mistaken for a simplification.

Two sites in main's own test code merged CLEANLY and were left asserting the
pre-#959 world. Both are corrected here, because a conflict-free merge is not
a correct one:

* `out_of_family_and_malformed_argvs_keep_their_own_answers` asserted that
  `ZDIFFSTORE` must NOT be refused ("ZDIFFSTORE is unimplemented; t2k4 owns
  the moment that changes"). That assertion is now the bug. Removed, with a
  note saying where it went.
* `ZDIFFSTORE` added to `FAMILY`, so
  `every_family_member_is_refused_across_a_boundary_and_only_there` now covers
  it positively in all four positions the table checks: refused across a
  boundary, NOT refused co-located, NOT refused under a `{hash}` tag, and NOT
  refused at `--shards 1`.

The family doc block keeps main's moon#962 text verbatim (it is the accurate,
more detailed version) minus the now-false `ZDIFFSTORE` bullet, and gains a
short section recording the migration and the arm collision.

`tests/two_key_write_cross_shard.rs` needed no change: #1003 had already moved
`ZDIFFSTORE` from the `t2k4` unimplemented-store tripwire into `t2k1`'s
`PROBES`, which is precisely the migration `t2k4` exists to force.

CHANGELOG: the moon#959 entry now states that `ZDIFFSTORE` joins the moon#592
cross-shard write guard and shares moon#962's `(10, b'z')` arm.

Verified on macOS (aarch64): `cargo clippy --all-targets -- -D warnings`
clean; `cargo check --no-default-features --features runtime-tokio,jemalloc
--all-targets` clean. Tests, release-fast: lib 5608 passed, plus
`two_key_write_cross_shard` 5/5 (t2k1's 180 live placements at `--shards 4`
now include `ZDIFFSTORE`), `multikey_read_cross_shard` 3/3,
`zset_read_cold_tier_928` 5/5, `workspace_key_walker_668` 6/6,
`tracking_movablekeys` 2/2. No behaviour from either side was dropped.

One lib test is red and it is PRE-EXISTING, not merge-induced:
`scripting::bridge::tests::gate_is_skipped_with_spill_sender_when_no_limit_is_
configured` fails 3/3 in the full suite and passes alone — its own doc comment
says `MAXMEMORY_GLOBAL`/`DB_MAXMEMORY_ANY_SET` are process-global and other
tests in the binary publish them. A/B settles it rather than argument:
`0cfab03c` (origin/main, untouched) fails the SAME test, 5586 passed / 1
failed; this merge is 5608 passed / 1 failed, the +22 being moon#959's own
unit tests, and skipping all 22 still leaves it red. This merge does not touch
`src/scripting/` or `src/storage/eviction` at all (`git diff origin/main --
src/scripting/` is empty).

author: Tin Dang
main advanced to 43ad387 while the previous merge was being verified. #1000
touches `src/shard/spsc_handler.rs`, `src/shard/event_loop.rs` and the two
connection handlers, plus a new `tests/cross_shard_command_telemetry_982.rs`;
none of that overlaps moon#959's sorted-set work or the cross-shard write
guard, so the merge is conflict-free. Conflict-free is not correct, so it was
re-verified rather than assumed:

* `clippy --all-targets -- -D warnings` clean, and the crate really was
  re-checked (325 rmeta artefacts rewritten, not a cached "Finished").
* `cargo check --no-default-features --features runtime-tokio,jemalloc
  --all-targets` clean.
* `scripts/test-consistency.sh` is +121/-0 and `scripts/test-commands.sh`
  +39/-0 against main — pure additions, both `bash -n` clean, so #1000's 61
  new rows and moon#959's block coexist.
* `two_key_write_cross_shard` 5/5, `cross_shard_command_telemetry_982` 5/5,
  `multikey_read_cross_shard` 5/5, `zset_read_cold_tier_928` 2/2, lib 5609
  passed.

## The ZDIFFSTORE guard, proved on the MERGED arm rather than the PR's

The previous commit's argument was that `ZDIFFSTORE` must stay in
`touches_a_key_it_did_not_route_on`'s `(10, b'z')` arm, which it now shares
with moon#962's `ZINTERCARD`. That argument was re-run as an experiment
against this tree, because a guard nobody has seen fail is not a guard:

    -        (10, b'z') => {
    -            cmd.eq_ignore_ascii_case(b"ZINTERCARD") || cmd.eq_ignore_ascii_case(b"ZDIFFSTORE")
    -        }
    +        (10, b'z') => cmd.eq_ignore_ascii_case(b"ZINTERCARD"),

    t2k1 ... FAILED
    12/180 placements broke the acknowledgement contract (moon#592):
      ZDIFFSTORE [t2k:ZDIFFSTORE:0:s -> t2k:ZDIFFSTORE:0:d2]: ACKED ":0\r\n"
      but the destination does not hold the write: ZCARD answered ":0\r\n",
      expected ":2\r\n" — this is acknowledged data loss

That is a live `--shards 4` server acknowledging a destructive write that
landed nowhere — the exact defect main's doc comment asserted could not
happen because the command was unimplemented. The one-line edit was then
restored from a copy (not `git checkout`) and the suite is green again;
`git diff -- src/server/conn/shared.rs` is empty.

## Correction to the previous commit message

It mis-attributed four per-suite counts: cargo's `Running`/`test result`
lines were read from two separate greps and paired wrongly. The authoritative
pairing is `multikey_read_cross_shard` 5, `tracking_movablekeys` 3,
`two_key_write_cross_shard` 5, `workspace_key_walker_668` 6,
`zset_read_cold_tier_928` 2. No conclusion changes — every suite was green
either way — but the numbers as written were not measured.

## Known-red, pre-existing, not from this branch

* `scripting::bridge::tests::gate_is_skipped_with_spill_sender_when_no_limit_
  is_configured` — fails in the full lib suite, passes alone. A/B: `0cfab03c`
  (main, untouched) fails it too, 5586 passed / 1 failed. This branch does not
  touch `src/scripting/`.
* `admin::footprint::footprint_tests::footprint_is_phys_footprint_not_resident
  _size` — failed once, under two concurrent cargo builds, and passed on the
  runs before and after. It samples macOS `phys_footprint`; it is load
  sensitive, not a merge effect.

author: Tin Dang
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant