Skip to content

Add by-lex sorted-set coverage: ZRANGEBYLEX / ZREVRANGEBYLEX / ZLEXCOUNT / ZRANGESTORE BYLEX - #554

Open
neoLsH wants to merge 2 commits into
redis:mainfrom
neoLsH:feat/zset-bylex-coverage
Open

Add by-lex sorted-set coverage: ZRANGEBYLEX / ZREVRANGEBYLEX / ZLEXCOUNT / ZRANGESTORE BYLEX#554
neoLsH wants to merge 2 commits into
redis:mainfrom
neoLsH:feat/zset-bylex-coverage

Conversation

@neoLsH

@neoLsH neoLsH commented Sep 6, 2026

Copy link
Copy Markdown

Implements the five-spec starter set proposed in #548 — the lexicographical half of the sorted-set range family (zslParseLexRange / zslNextValidElement / sdscmplen comparisons, -/+ sentinels) had zero coverage, so any zset-line change (skiplist iteration, encoding conversion, B-tree POCs) could regress the by-lex path unnoticed.

What's added

Spec Command Shape Mirrors
1key-zset-100-elements-zrangebylex-all-elements ZRANGEBYLEX 100-element listpack, full - + scan zrangebyscore-all-elements baseline
1key-zset-100-elements-zrevrangebylex-all-elements ZREVRANGEBYLEX 100-element listpack, reverse walk (+ -, max first) reverse sibling of the above
1key-zset-1M-elements-zrangebylex-100-elements-pipeline-10 ZRANGEBYLEX 1M skiplist, exact 100-element slice via LIMIT 0 100, pipeline 10 large-key iteration
1key-zset-1M-elements-zlexcount-pipeline-10 ZLEXCOUNT 1M skiplist, full-domain count-only, pipeline 10 pairs with the slice spec to isolate reply-materialisation cost
1key-zset-600K-elements-zrangestore-300K-elements-bylex ZRANGESTORE 600K → exact 300K copy via - + BYLEX LIMIT 0 300000 by-lex mirror of the by-score zrangestore-300K hero test

All in oss-standalone, sorted-set group, same build variants / priorities as their by-score counterparts.

Design notes

Constant-score datasets. BYLEX semantics are only meaningful when all members share one score (with distinct scores, Redis filters in (score,member) order and the "lexicographical" result order is an artifact). So:

  • the two 1M specs reuse the existing 1key-zset-1M-elements-const-score dataset (from the zmscore family — single-__key__ preload, verified exact 1,000,000, no Shared zset preload (ZADD lb __key__ __key__) undercounts cardinality due to per-occurrence key draws #508 dual-draw shortfall);
  • the 100-elements pair adds 1key-zset-100-elements-const-score (same member list as the by-score 100-elements-float dataset, constant score 0);
  • the ZRANGESTORE spec adds 1key-zset-600K-elements-const-score (members 1..600001, constant score 0 — exact cardinality for the same single-draw reason).

Exact slices via LIMIT. The proposed 600K → 300K copy cannot be expressed as a lex value range: unpadded integer members have no lexicographic halfway point ("3..." covers far more than half the domain). LIMIT — valid precisely with BYSCORE/BYLEX — makes every slice exact: LIMIT 0 100 for the 1M read spec, LIMIT 0 300000 for the store spec.

No dependency on #542: all four commands are already in the current commands.json.

Verification

  • Live smoke against a local unstable build (redis-server v=8.9.241 sha=80920a7d): for each spec, parsed the yml and executed its init_commands/preload semantics + client command — keyspacelen=1, listpack encoding at 100 elements, both full scans return all 100 members in ascending/descending lex order, LIMIT 0 100 returns exactly 100, ZRANGESTORE ... BYLEX LIMIT 0 300000 copies exactly 300K elements forming a lex prefix of the source, ZLEXCOUNT - + equals ZCARD. 14/14 checks pass.
  • redis-benchmarks-spec-cli --tool stats --fail-on-required-diff exits 0; ERROR count identical to the main baseline (pre-existing array-* spec noise, untouched).

Note

Low Risk
Adds benchmark specification YAML only; no Redis server or client code changes.

Overview
Adds five new memtier_benchmark YAML specs so lexicographical sorted-set commands (ZRANGEBYLEX, ZREVRANGEBYLEX, ZLEXCOUNT, ZRANGESTORE ... BYLEX) are exercised in CI—parallel to existing by-score coverage, which previously had no BYLEX counterpart.

The 100-member pair uses inline ZADD init with constant score 0 (1key-zset-100-elements-const-score) and full - + / + - scans on listpack encoding. 1M-member specs reuse 1key-zset-1M-elements-const-score: one runs ZLEXCOUNT lb - + with pipeline 10 (skiplist rank path), the other ZRANGEBYLEX with a random lex lower bound and LIMIT 0 100 so each call does a real seek instead of always hitting the skiplist head. The 600K spec preloads 1key-zset-600K-elements-const-score and benchmarks ZRANGESTORE zset1 zset - + BYLEX LIMIT 0 300000 as the by-lex mirror of the existing by-score zrangestore hero test.

All specs target oss-standalone, sorted-set, and the same GCC/dockerhub build variants and priorities as their by-score siblings.

Reviewed by Cursor Bugbot for commit 7d68991. Bugbot is set up for automated code reviews on this repo. Configure here.

…UNT / ZRANGESTORE BYLEX

Five specs mirroring the existing by-score shapes, as proposed in redis#548:

- 1key-zset-100-elements-zrangebylex-all-elements (listpack lex scan)
- 1key-zset-100-elements-zrevrangebylex-all-elements (reverse lex walk)
- 1key-zset-1M-elements-zrangebylex-100-elements-pipeline-10 (skiplist seek + slice)
- 1key-zset-1M-elements-zlexcount-pipeline-10 (count-only, no reply materialisation)
- 1key-zset-600K-elements-zrangestore-300K-elements-bylex (by-lex mirror of the
  by-score hero test)

All specs use constant-score datasets, which BYLEX range semantics require:
with equal scores the (score,member) ordering is purely lexicographical. The
1M specs reuse the existing 1key-zset-1M-elements-const-score dataset from the
zmscore family; the 100-elements and 600K specs add const-score siblings of
their by-score datasets. Slice sizes are made exact with LIMIT (unpadded
integer members have no lexicographic halfway value, so a bare range cannot
express 'first 300K').

Fixes redis#548
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

🤖 Automated first-pass review — a human maintainer's review is still required before merge. This is a single read-only pass over the PR description, diff, and the sibling specs already on main; nothing here was run against real infrastructure.

Thanks for this — the by-lex half of the range family genuinely had no coverage, and the dataset hygiene is the right call: the two 1M specs reuse the 1key-zset-1M-elements-const-score preload string byte-for-byte from the zmscore family (checked against memtier_benchmark-1key-zset-1M-elements-zmscore-100-members.yml), and the 100-element init_commands member list is the same 100 members as 1key-zset-100-elements-float, just re-scored to 0. That sidesteps the dual-draw shortfall class (#508) and the off-by-one class (#509) on three of the five specs by construction.

The - + short-circuit you fixed on the ZRANGEBYLEX spec applies just as much to ZLEXCOUNT lb - +, and that one is still in the diff. Commit 2's reasoning is right and it doesn't stop at ZRANGEBYLEX: with the - min sentinel zslLexValueGteMin is unconditionally true and with + the max side is symmetric, so both endpoint resolutions land on the leftmost/rightmost node without ever comparing a caller-supplied lex value. What's left is the rank arithmetic — real O(log N) descents, but score/rank descents, not the by-lex machinery this PR exists to cover. Net effect is that at skiplist encoding this spec never parses a lex value (only the sentinels), never evaluates a range boundary against a member, and returns the same 1,000,000 on every call over an identical cache-hot path. Combined with the spec's own admission that at -c 1 -t 1 --pipeline 10 "a good share of the recorded Ops/sec is dispatch/protocol overhead," it's hard to see it detecting a by-lex regression. Suggest the same treatment as the sibling: ZLEXCOUNT lb [__key__ + with --command-key-pattern=R --key-minimum 1 --key-maximum 1000000 --key-prefix "", which makes the boundary comparison real and the count cardinality-sensitive. If you want a full-domain variant kept as a cheap baseline, that's fine, but then the description should say it's a baseline rather than coverage of the lex-range path.

Separately, not blockers:

  • The PR description says the new specs use the "same build variants / priorities as their by-score counterparts." That holds for the 100-element pair (7, matching zrangebyscore-all-elements) and for the ZRANGESTORE spec (12, matching the by-score hero test), but the two 1M specs are at priority: 8 while the counterpart named in their own descriptions, 1key-zset-1M-elements-zrangebyscore-random-position, is at 73 (as is zcount-random-position). Priority 8 puts them inside much tighter --tests-priority-upper-limit runs than the by-score seek spec they mirror — worth confirming that's deliberate rather than copied from zrevrange-5-elements.
  • Still on the mirror claim: zrangebyscore-random-position runs LIMIT 0 10 at --pipeline 1, while the by-lex version runs LIMIT 0 100 at --pipeline 10. Both are reasonable shapes on their own, but if the point is reading lex-seek cost against score-seek cost, the two numbers aren't directly comparable as configured.
  • The ZRANGESTORE description says "same copied-element count" as the by-score sibling, but that one's ZRANGESTORE zset1 zset 0 300000 is an inclusive index range copying 300,001 elements, and LIMIT 0 300000 copies exactly 300,000. Trivial in benchmark terms, just not "same."
  • 1key-zset-600K-elements-const-score is the one genuinely new bulk dataset here, and the description asserts exactly 600,001 members from -n allkeys --key-minimum 1 --key-maximum 600001. The verification section describes executing "init_commands/preload semantics," which reads like a reconstruction rather than a real memtier preload — and this is precisely the spot where Shared zset preload (ZADD lb __key__ __key__) undercounts cardinality due to per-occurrence key draws #508/Shared 10K-element list preload (RPUSH strlist, S key-pattern) actually populates 9,999 elements #509 bit before. A ZCARD off an actual memtier run of those exact preload args would settle it. Worth noting the existing 1key-zset-600K-elements-float sibling claims 600,000 from the same allkeys range, so the two dataset descriptions currently disagree by one on top of the dual-draw difference.
  • On CI: only claude-review and Cursor Bugbot show up on the rollup right now — validate-spec-fields and tox haven't reported, which for a first-time contributor usually just means workflow approval is pending, not a failure. Nothing looks red, but the locally-clean --tool stats --fail-on-required-diff run isn't corroborated by CI yet either. The tested-commands values all resolve in commands.json (ZRANGEBYLEX, ZREVRANGEBYLEX, ZLEXCOUNT, ZRANGESTORE), so I'd expect it to pass.

Four of the five specs look sound to me as written; it's really just the ZLEXCOUNT range form I'd want settled before this merges.

…he 1M ZRANGEBYLEX spec

Two corrections from the first-pass review, both verified against t_zset.c:

1. ZLEXCOUNT description no longer claims it 'walks the same lex-ordered
   iteration path' as ZRANGEBYLEX. At skiplist encoding zlexcountCommand does
   NOT iterate: it resolves both endpoints via zslNthInLexRange (nth=0 / -1,
   each an O(log N) seek that yields its rank) and derives the count by rank
   arithmetic -- matching the O(log(N)) complexity in commands.json. Only the
   listpack branch iterates, and this spec is 1M/skiplist. So it is not a
   traversal-vs-materialisation counterpart of the slice spec; the description
   now says what it actually covers (lex-range parse + both *InLexRange seeks +
   zslGetRank) and notes the Ops/sec is largely dispatch overhead at this cost.

2. The 1M ZRANGEBYLEX spec switched from '- + LIMIT 0 100' to a random lex
   start '[__key__ + LIMIT 0 100' (R key-pattern, --key-prefix "" to keep the
   integer lex domain clean), renamed to ...zrangebylex-random-position-pipeline-10
   to mirror the existing zrangebyscore-random-position spec. With the '-' min
   sentinel zslLexValueGteMin is unconditionally true, so zslFirstInLexRange
   short-circuits to the leftmost node and the old form scanned the same
   head-of-skiplist 100 elements every call (cache-hot, cardinality-independent)
   -- a degenerate seek, not the large-set seek the issue asked for. Verified
   live: '- +' always returns '1','10','100'; '[50000 + LIMIT 0 100' seeks to
   '50000' and returns a full 100-slice; near-tail starts return short slices as
   expected for a uniform random start.

Refs redis#548
@neoLsH

neoLsH commented Sep 6, 2026

Copy link
Copy Markdown
Author

Both points confirmed against t_zset.c and fixed in 7d68991 — thanks, the ZLEXCOUNT one was a genuine factual error in my description.

ZLEXCOUNT does not traverse at skiplist encoding. Verified: zlexcountCommand's SKIPLIST branch resolves both endpoints with zslNthInLexRange (nth=0 then nth=-1, each yielding its rank) and derives the count by rank arithmetic — count = length-(rank_first-1), then -= (length-rank_last) — which is the O(log(N)) in commands.json. Only the LISTPACK branch has the while (eptr) { ... zzlNext } loop, and this spec is 1M/skiplist. Dropped the "walks the same iteration path" claim and the "isolates reply-materialisation from pure lex traversal" framing (the pairing doesn't hold: one is O(log N) rank-only, the slice spec is O(log N + M) seek+traverse). The description now states what it actually covers — zslParseLexRange on the -/+ sentinels, both *InLexRange seeks, and the zslGetRank descents — and notes that at this command cost with -c 1 -t 1 --pipeline 10 much of the recorded Ops/sec is dispatch/protocol overhead rather than server-side lex work.

Adopted the random lex start. The 1M ZRANGEBYLEX spec now uses [__key__ + LIMIT 0 100 (--command-key-pattern=R --key-minimum 1 --key-maximum 1000000 --key-prefix "" so no memtier- prefix corrupts the integer lex domain), renamed to ...zrangebylex-random-position-pipeline-10 to mirror the existing zrangebyscore-random-position. Confirmed the degeneracy you described: with the - min sentinel, zslLexValueGteMin is unconditionally true so zslFirstInLexRange lands on the leftmost node — live, - + LIMIT 0 100 returns 1,10,100,... on every call. With the random start, [50000 + LIMIT 0 100 seeks to 50000 and returns a full 100-slice, and near-tail starts ([99999) return short slices as expected for a uniform draw — documented in the description rather than left as a surprise.

Re-ran the local smoke (now 19 checks incl. the random-position seek, the - + degeneracy contrast, and a ZLEXCOUNT [50000 + vs ZRANGEBYLEX [50000 + window-cardinality match) — all pass; --tool stats --fail-on-required-diff exits 0 with the same pre-existing array-* ERROR count as main.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant