Skip to content

backup: Redis zset encoder (Phase 0a)#790

Open
bootjp wants to merge 4 commits into
mainfrom
backup/redis-zset-encoder
Open

backup: Redis zset encoder (Phase 0a)#790
bootjp wants to merge 4 commits into
mainfrom
backup/redis-zset-encoder

Conversation

@bootjp
Copy link
Copy Markdown
Owner

@bootjp bootjp commented May 19, 2026

Summary

Adds the Redis sorted-set (zset) encoder for the Phase 0 logical
snapshot decoder (docs/design/2026_04_29_proposed_snapshot_logical_decoder.md,
lines 334-335). Mirrors the hash/list/set encoders shipped in
#725/#755/#758. After this lands, Phase 0a's remaining Redis work
is the stream encoder + HLL TTL sidecar; the per-adapter encoders
themselves will be complete.

Output shape per the design:

{"format_version": 1,
 "members": [{"member": "alice", "score": 100}],
 "expire_at_ms": null}

Members sorted by raw byte order (not by score) so diff -r between
two dumps with the same logical contents stays line-stable across
score-only mutations. Scores emit as JSON numbers for finite values,
ZADD-conventional "+inf" / "-inf" strings for ±Inf (json.Marshal
rejects non-finite floats). NaN scores fail closed at intake — Redis's
ZADD rejects NaN at the wire level, so a NaN at backup time indicates
corruption.

Wire layout (mirrors store/zset_helpers.go):

  • !zs|meta|<userKeyLen(4)><userKey> → 8-byte BE Len
  • !zs|mem|<userKeyLen(4)><userKey><member> → 8-byte IEEE 754 score
  • !zs|scr|<userKeyLen(4)><userKey><sortableScore(8)><member>
    silently discarded (secondary index; !zs|mem| is the source of
    truth)
  • !zs|meta|d|<userKeyLen(4)><userKey><commitTS(8)><seqInTxn(4)>
    silently skipped (delta family; same policy as hash/set encoders)

TTL routing: !redis|ttl|<userKey> for a registered zset folds into
the JSON expire_at_ms field, matching the set/list/hash inlining,
so a restorer replays ZADD + EXPIRE in one shot.

Self-review (5 lenses)

  1. Data loss!zs|mem| is the source of truth; !zs|scr| and
    delta records are intentional silent skips with audit notes.
    Empty zsets (Len=0 but meta seen) still emit a file because
    TYPE k → zset is observable to clients.
  2. Concurrency / distributedRedisDB is sequential per scope
    (matches the existing per-DB encoder contract); no shared state.
  3. Performance — per-zset state in a map, flushed once at Finalize.
    Bounded by maxWideColumnItems on the live side; sort is O(n log n)
    on member-name bytes. Identical cost shape to hash/set encoders.
  4. Data consistency — JSON field order pinned via struct tags
    (not map). Inf score uses json.RawMessage so the same score
    key emits as either a number or a string. NaN fails closed.
  5. Test coverage — 17 table-driven tests under
    internal/backup/redis_zset_test.go:
    • round-trip basic / empty / TTL inlining
    • binary member via base64 envelope
    • delta-key skip (both HandleZSetMeta entry + parseZSetMetaKey guard)
    • HandleZSetMetaDelta explicit entry
    • HandleZSetScore silent-discard
    • malformed meta length / overflow / MaxInt64 boundary
    • malformed member-value length / NaN rejection
    • ±Inf string-form serialization
    • members-without-meta still emits file
    • duplicate-members latest-wins (ZADD semantics)

Caller audit (per /loop standing instruction)

Semantics-changing edit: new case redisKindZSet: branch in
HandleTTL (redis_string.go:310). Purely additive — the new branch
fires only when zsetState() has previously registered the key.
No existing handler maps to redisKindZSet, so no prior call site
changes behavior. Verified:

grep -n 'redisKindZSet' internal/backup/
# internal/backup/redis_string.go:88
# internal/backup/redis_string.go:310
# internal/backup/redis_zset.go:166

Three references, all new in this PR. No legacy caller assumes the
prior behavior.

Test plan

  • go test -race ./internal/backup/ → ok
  • golangci-lint run ./internal/backup/... → 0 issues
  • go build ./... → ok
  • go vet ./internal/backup/... → ok

Summary by CodeRabbit

  • New Features

    • Added Redis sorted-set (ZSET) backup and restore functionality.
    • TTL values are now inlined within sorted-set backup records.
    • Enhanced floating-point score handling, including proper serialization of infinity values.
  • Tests

    • Comprehensive test coverage added for sorted-set backup operations.

Review Change Stack

Decodes !zs|meta|/!zs|mem|/!zs|scr|/!zs|meta|d| snapshot records into
the per-zset `zsets/<key>.json` shape defined by the Phase 0 design
(docs/design/2026_04_29_proposed_snapshot_logical_decoder.md line
334-335). Mirrors the hash/list/set encoder shape:

- !zs|meta|        → 8-byte BE declared member count
- !zs|mem|<m>      → member m with 8-byte IEEE 754 score
- !zs|scr|         → secondary score index; silently discarded
                      (!zs|mem| is source of truth at backup time)
- !zs|meta|d|      → delta records; silently skipped (set/hash policy)

Output is sorted by raw member-name bytes so `diff -r` of two dumps
with the same logical contents but mutated scores stays line-stable.
Scores serialize as JSON numbers for finite values and the
ZADD-conventional `"+inf"`/`"-inf"` strings for non-finite ones
(json.Marshal rejects ±Inf so the score field uses json.RawMessage).
NaN scores fail closed at HandleZSetMember: Redis's ZADD rejects NaN
at the wire level, so a NaN in storage indicates corruption that we
refuse to silently propagate into the dump.

TTL routing: !redis|ttl|<userKey> for a registered zset key folds
into the zset JSON's expire_at_ms (matching the set/list/hash
inlining), so a restorer replays ZADD + EXPIRE in one shot without
chasing a separate sidecar.

Self-review:
1. Data loss — !zs|mem| is the source of truth; !zs|scr| and the
   delta family are intentional silent skips with caller-audit notes.
2. Concurrency — RedisDB is sequential per scope (matches the
   existing per-DB encoder contract); no shared state across DBs.
3. Performance — per-zset state buffered in a map; flushed at
   Finalize. Bounded by maxWideColumnItems on the live side. Sort
   is O(n log n) on member-name bytes; identical cost shape to
   the hash/set encoders that shipped in #725/#758.
4. Consistency — JSON field-order determinism preserved (struct
   tags, not map). Inf score uses string form via json.RawMessage
   so the same `score` key accepts both shapes.
5. Coverage — 17 table-driven tests:
   - round-trip basic / empty zset / TTL inlining
   - binary member via base64 envelope
   - delta-key skip (both HandleZSetMeta entry + parseZSetMetaKey guard)
   - HandleZSetMetaDelta explicit entry point
   - HandleZSetScore silent discard
   - malformed meta length / overflow / MaxInt64 boundary
   - malformed member-value length / NaN rejection
   - ±Inf string-form serialization
   - members-without-meta still emits file
   - duplicate-members latest-wins (ZADD semantics)

Caller audit for semantics-changing edit (new `case redisKindZSet:`
in HandleTTL, redis_string.go:310): purely additive. The new branch
fires only when zsetState() has previously registered the key; no
existing handler maps to redisKindZSet, so no prior call site
changes behavior. Verified via
`grep -n 'redisKindZSet' internal/backup/`.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 19, 2026

Warning

Rate limit exceeded

@bootjp has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 13 minutes and 53 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b18bda6e-269f-4052-a640-548153a934b8

📥 Commits

Reviewing files that changed from the base of the PR and between 5e323cc and ab88f3e.

📒 Files selected for processing (5)
  • internal/backup/redis_set.go
  • internal/backup/redis_string.go
  • internal/backup/redis_string_test.go
  • internal/backup/redis_zset.go
  • internal/backup/redis_zset_test.go
📝 Walkthrough

Walkthrough

This PR adds complete Redis sorted-set (ZSET) backup support by introducing ZSET snapshot record handlers, per-key state accumulation with TTL inlining, and deterministic JSON emission. The implementation extends the existing RedisDB infrastructure with a new redisKindZSet kind and integrates ZSET flushing into the finalize pipeline.

Changes

ZSET Backup Feature

Layer / File(s) Summary
ZSET Integration into TTL Router
internal/backup/redis_string.go
RedisDB gains a new redisKindZSet kind enum value and zsets state buffer map. HandleTTL routes ZSET TTL records into the per-key ZSET state (enabling TTL inlining into JSON output), and Finalize() calls flushZSets to emit JSON files alongside existing hash/list/set outputs.
ZSET Data Model and Parser Helpers
internal/backup/redis_zset.go (lines 1–202)
Key prefix constants (`!zs
ZSET Snapshot Intake Handlers
internal/backup/redis_zset.go (lines 74–168)
HandleZSetMeta parses declared member count with int64 overflow guards and skips delta records. HandleZSetMember decodes big-endian IEEE-754 scores, rejects NaN, and applies latest-wins semantics. HandleZSetScore and HandleZSetMetaDelta are no-ops that safely accept but discard those record types. Lazy state creation and kindByKey registration enable per-key ZSET routing in the TTL path.
ZSET JSON Emission and Marshaling
internal/backup/redis_zset.go (lines 204–329)
flushZSets iterates accumulated ZSETs and warns on member-count mismatches. writeZSetJSON encodes user key paths, marshals state to JSON, and performs atomic writes. marshalZSetJSON produces deterministic output: collects and byte-sorts member names, marshals scores via marshalRedisZSetScore (which encodes ±Inf as JSON strings), conditionally includes expire_at_ms, and wraps with format_version.
Hash Marshaler Linting Annotation
internal/backup/redis_hash.go
marshalHashJSON gains an expanded comment explaining its intentional duplication for JSON field-order determinism and a //nolint:dupl annotation to suppress linting noise.
Comprehensive Test Suite
internal/backup/redis_zset_test.go
17 test functions validate ZSET round-trip encoding, TTL inlining (with sidecar absence), member-count mismatch warnings, binary member base64 enveloping, delta meta-key skipping, error handling for malformed/overflow/NaN values, ±Inf score JSON string encoding, parser-level validation, members-without-meta emission, duplicate member latest-wins semantics, and math.MaxInt64 boundary acceptance. Test helpers encode snapshot keys and scores using big-endian formats matching intake expectations.

Sequence Diagram

sequenceDiagram
  participant Snapshot as Snapshot Data
  participant Intake as Intake Handlers
  participant State as per-ZSET State
  participant TTL as TTL Router
  participant Emit as JSON Emission
  participant Output as zsets/key.json
  
  Snapshot->>Intake: !zs|meta|... (member count)
  Intake->>State: Store declaredLen, metaSeen
  
  Snapshot->>Intake: !zs|mem|... (member name + score)
  Intake->>State: Decode IEEE-754 score, store member
  
  Snapshot->>TTL: !redis|ttl|... (for ZSET key)
  TTL->>State: Inlined into hasTTL + expireAtMs
  
  Snapshot->>Intake: !zs|score|... or !zs|meta|d|...
  Intake->>Intake: Silently discard (no-op)
  
  Snapshot->>Emit: Finalize() called
  Emit->>State: Collect all ZSET states
  State->>Emit: Check declaredLen vs observedLen
  Emit->>Emit: Warn if mismatch
  Emit->>Emit: Sort members by binary name
  Emit->>Emit: Encode scores (+Inf/-Inf as strings)
  Emit->>Emit: Include expire_at_ms if TTL set
  Emit->>Output: Atomic write with format_version
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly Related PRs

  • bootjp/elastickv#713: Adds string/HLL/TTL support to the same simple-type RedisDB router that ZSET extends in this PR, with overlapping TTL routing and Finalize() integration patterns.
  • bootjp/elastickv#755: Adds list backup support using the same RedisDB buffering architecture, HandleTTL inlining, and Finalize() flush pattern that ZSET reuses for consistent JSON output.

Poem

🐰 A hop through sorted sets so fine,
With members floating in a line,
IEEE-754 scores encoded true,
And TTL tucked in JSON's view—
One more backup type shines through!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'backup: Redis zset encoder (Phase 0a)' directly and clearly describes the main change: adding a Redis sorted-set (zset) encoder for the Phase 0 backup system.
Docstring Coverage ✅ Passed Docstring coverage is 91.18% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch backup/redis-zset-encoder

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 and usage tips.

@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 19, 2026

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented May 19, 2026

Claude finished @bootjp's task in 3m 31s —— View job


Code Review: PR #790 — Redis zset encoder (Phase 0a)

Tasks

  • Gather context
  • Read internal/backup/redis_zset.go
  • Read internal/backup/redis_zset_test.go
  • Read internal/backup/redis_string.go (modified)
  • Read internal/backup/redis_hash.go (modified)
  • Analyze correctness, edge cases, conventions
  • Post review feedback

Wire format

The encoder constants and decoding arithmetic exactly mirror store/zset_helpers.go:

  • !zs|meta| / !zs|mem| / !zs|scr| / !zs|meta|d| prefixes match the live constants.
  • redisZSetScoreSize = 8 is the correct mirror of zsetMetaSizeBytes.
  • math.Float64frombits(binary.BigEndian.Uint64(value)) correctly inverts MarshalZSetScore (IEEE 754 big-endian).
  • The uint64 > math.MaxInt64 overflow guard in HandleZSetMeta (line 104) correctly mirrors the pattern in the hash/list/set encoders.

Issues

Medium — TTL scan-order gap not tested (functional risk)

!redis|ttl| keys sort lexicographically before !zs|... keys (r=0x72 < z=0x7A). In a single sorted Pebble scan, TTL records for zset keys arrive before any !zs|meta| or !zs|mem| record registers the key in kindByKey. That means HandleTTL hits the redisKindUnknown branch and increments orphanTTLCount — the TTL is not inlined, and the JSON expire_at_ms stays null.

The same ordering issue exists for the set encoder (s=0x73 > r=0x72), so this is a pre-existing design decision rather than a new regression. But:

  1. TestRedisDB_ZSetTTLInlinedFromScanIndex only exercises the cooperative order (meta → member → TTL). There is no test for the inverse (TTL → meta → member).
  2. If the real dispatcher does a single prefix-sorted scan, TTL inlining for zsets (and sets) will silently fall through to orphanTTLCount on every expiring key.

It would be valuable to add a test that documents the current (Phase 0a) behavior under the real scan order, even if that behavior is "TTL becomes an orphan and is reported but not inlined." Otherwise the existing test creates a false sense that TTL inlining works end-to-end.

If the dispatcher is known to do per-prefix scans in a controlled order, a brief note in the encoder's top-of-file comment would be enough.

Fix/document this →

Low — marshalRedisZSetScore: no unreachable guard for NaN

redis_zset.go:254-256 documents that NaN is unreachable because HandleZSetMember rejects it at intake. However, marshalRedisZSetScore has no explicit guard. If NaN somehow enters st.members (e.g., via a future refactor that bypasses the handler), json.Marshal(nan) returns a standard-library error — which surfaces correctly. This is not a silent corruption bug, but it surfaces as an opaque json: unsupported value error rather than the domain-specific ErrRedisInvalidZSetMember. A small defensive check would make the failure mode obvious:

if math.IsNaN(score) {
    // unreachable: HandleZSetMember rejects NaN at intake
    return nil, cockroachdberr.New("backup: internal error: NaN score reached marshalling (should have been rejected at intake)")
}

Confirmed correct

  • sort.Strings byte ordersort.Strings compares by byte content in Go; the "raw byte order" claim is accurate.
  • //nolint:dupl cross-referenceredis_hash.go:244 now points to redis_zset.go:marshalZSetJSON. The rationale (can't collapse parallel wrappers without breaking JSON field-order determinism) is sound.
  • Empty zset emitLen=0 with metaSeen=true still writes a file; TYPE key → zset is observable to clients. Correct.
  • Members-without-metametaSeen=false guard in flushZSets:212 prevents a spurious length-mismatch warning. Test covers this.
  • HandleZSetScore / HandleZSetMetaDelta as named no-ops — The discards are explicit functions rather than routing to an orphan sink, so a future grep -n 'every !zs| prefix has a handler' audit finds them. The design rationale is documented.
  • Delta double-guardparseZSetMetaKey independently rejects delta-prefixed keys (line 177), so a dispatcher that bypasses HandleZSetMeta's entry guard still surfaces a parse error rather than corrupt state. Well-motivated.
  • HandleTTL case redisKindZSet — Additive-only change. r.zsetState(userKey) inside the case is a no-op re-registration of the already-registered key; correct and idempotent.
  • Test coverage — 17 cases covering round-trip, empty zset, TTL inlining, binary member base64 envelope, delta-key skip (both entry and parser levels), score-index discard, malformed meta length, overflow guard, MaxInt64 boundary, malformed member value, NaN rejection, ±Inf string form, members-without-meta, duplicate-member latest-wins.

Summary

Solid implementation that faithfully mirrors the hash/list/set encoders and handles all the wire-format corner cases well. The one functional concern is the TTL scan-order edge case (which the existing set encoder shares); whether that's a problem in practice depends on the dispatcher's scan strategy. Adding a test that documents the behavior under inverted order would close the coverage gap without requiring a design change.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5e323cc539

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +310 to +315
case redisKindZSet:
// Same per-record TTL inlining: ZADD + EXPIRE replay in
// one shot from the per-zset JSON, no separate sidecar.
st := r.zsetState(userKey)
st.expireAtMs = expireAtMs
st.hasTTL = true
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Buffer zset TTLs that arrive before zset rows

For expiring sorted sets in real snapshot order, this redisKindZSet branch is not reached: Pebble snapshots are emitted by iterator order (store/snapshot_pebble.go uses iter.First(); iter.Next()), and the TTL key prefix !redis|ttl| sorts before all zset prefixes !zs|.... That means HandleTTL sees redisKindUnknown, increments orphanTTLCount, and discards the expiry before zsetState is created, so every restored zset with a TTL becomes persistent. The zset TTL needs to be buffered/rerouted when the zset row is observed rather than relying on prior type observation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces support for Redis Sorted Sets (ZSets) in the backup system, adding a new encoder that translates raw snapshot records into versioned JSON files. The implementation handles member-score mappings, TTL inlining, and ensures deterministic output by sorting members by name. Review feedback suggests refactoring the marshaling logic for ZSets and Hashes into a shared helper to reduce code duplication, although the developer previously noted that duplication was intentional to maintain JSON field order.

// Reviewers comparing the two functions should diff
// (hashFieldRecord, "fields") against (zsetMemberRecord, "members") —
// the only intentional divergence.
func marshalZSetJSON(st *redisZSetState) ([]byte, error) { //nolint:dupl // see comment above
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

While deterministic field order is required for stable backups, repository guidelines (Rule 9) require centralizing common logic to avoid duplication. Instead of using nolint:dupl to suppress the warning, consider refactoring marshalZSetJSON and marshalHashJSON to use a shared helper function that preserves the necessary field order.

References
  1. Centralize common complex logic, such as retry/backoff mechanisms, into shared helper functions to avoid code duplication and improve maintainability.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
internal/backup/redis_zset_test.go (1)

16-19: ⚡ Quick win

Remove avoidable //nolint:gosec suppressions in test key/value builders.

Line 18 and Lines 35, 45, 58, and 70 can avoid suppression with explicit bounds-safe conversions. Keep //nolint only where truly unavoidable.

Proposed refactor
 func encodeZSetMetaValue(memberCount int64) []byte {
+	if memberCount < 0 {
+		panic("test bug: negative memberCount")
+	}
 	v := make([]byte, 8)
-	binary.BigEndian.PutUint64(v, uint64(memberCount)) //nolint:gosec
+	binary.BigEndian.PutUint64(v, uint64(memberCount))
 	return v
 }
+
+func putUserKeyLen(l *[4]byte, userKey string) {
+	if len(userKey) > math.MaxUint32 {
+		panic("test bug: userKey too large")
+	}
+	binary.BigEndian.PutUint32(l[:], uint32(len(userKey)))
+}
 func zsetMetaKey(userKey string) []byte {
 	out := []byte(RedisZSetMetaPrefix)
 	var l [4]byte
-	binary.BigEndian.PutUint32(l[:], uint32(len(userKey))) //nolint:gosec
+	putUserKeyLen(&l, userKey)
 	out = append(out, l[:]...)
 	return append(out, userKey...)
 }

(Apply the same putUserKeyLen call pattern in zsetMemberKey, zsetScoreKey, and zsetMetaDeltaKey.)

As per coding guidelines, Use gofmt formatting and pass linters configured in .golangci.yaml ... Avoid //nolint annotations — refactor instead.

Also applies to: 35-35, 45-45, 58-58, 70-70

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/backup/redis_zset_test.go` around lines 16 - 19, The test helpers
use //nolint:gosec to silence integer-to-uint64 conversions; replace those
suppressions by doing explicit, bounds-safe casts or using the existing
putUserKeyLen pattern so the conversion is obviously safe: in
encodeZSetMetaValue replace the nolint line with binary.BigEndian.PutUint64(v,
uint64(memberCount)) using an explicit checked/constrained conversion (e.g.,
ensure memberCount >= 0 then cast) or mirror putUserKeyLen’s approach; apply the
same refactor to zsetMemberKey, zsetScoreKey, and zsetMetaDeltaKey so no
//nolint is required and the conversions are clearly safe.
internal/backup/redis_zset.go (1)

87-93: ⚡ Quick win

Hoist []byte prefix conversions to package-level variables to avoid per-record allocations in snapshot processing.

Lines 87, 177, 180, and 192 repeatedly convert string constants to bytes at call sites during hot-path snapshot scanning. Moving these to package-level []byte variables eliminates four allocations per record processed.

♻️ Suggested refactor
 const (
 	RedisZSetMetaPrefix      = "!zs|meta|"
 	RedisZSetMemberPrefix    = "!zs|mem|"
 	RedisZSetScorePrefix     = "!zs|scr|"
 	RedisZSetMetaDeltaPrefix = "!zs|meta|d|"

 	redisZSetScoreSize = 8
 )
+
+var (
+	redisZSetMetaPrefixBytes      = []byte(RedisZSetMetaPrefix)
+	redisZSetMemberPrefixBytes    = []byte(RedisZSetMemberPrefix)
+	redisZSetMetaDeltaPrefixBytes = []byte(RedisZSetMetaDeltaPrefix)
+)

Then replace:

  • Line 87: []byte(RedisZSetMetaDeltaPrefix)redisZSetMetaDeltaPrefixBytes
  • Line 177: []byte(RedisZSetMetaDeltaPrefix)redisZSetMetaDeltaPrefixBytes
  • Line 180: []byte(RedisZSetMetaPrefix)redisZSetMetaPrefixBytes
  • Line 192: []byte(RedisZSetMemberPrefix)redisZSetMemberPrefixBytes
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/backup/redis_zset.go` around lines 87 - 93, Hot-path code repeatedly
converts string prefix constants to []byte per record; define package-level
byte-slice variables (e.g., redisZSetMetaDeltaPrefixBytes,
redisZSetMetaPrefixBytes, redisZSetMemberPrefixBytes) initialized from the
existing string constants (RedisZSetMetaDeltaPrefix, RedisZSetMetaPrefix,
RedisZSetMemberPrefix) and replace all call-site conversions such as
bytes.HasPrefix(key, []byte(RedisZSetMetaDeltaPrefix)) and other occurrences
(`[]byte(RedisZSetMetaPrefix)`, `[]byte(RedisZSetMemberPrefix)`) with the new
package-level variables; keep the semantic checks (e.g., parseZSetMetaKey usage)
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/backup/redis_zset_test.go`:
- Around line 310-316: The test currently only checks envelope["base64"] is
non-empty; instead decode the base64 string from envelope["base64"] (the
variable envelope in redis_zset_test.go) back to bytes and assert the decoded
bytes equal the original payload expected in the test (compare against the known
original member payload used earlier in the test), failing the test if decoding
errors or the bytes differ to guarantee true round-trip behavior.

---

Nitpick comments:
In `@internal/backup/redis_zset_test.go`:
- Around line 16-19: The test helpers use //nolint:gosec to silence
integer-to-uint64 conversions; replace those suppressions by doing explicit,
bounds-safe casts or using the existing putUserKeyLen pattern so the conversion
is obviously safe: in encodeZSetMetaValue replace the nolint line with
binary.BigEndian.PutUint64(v, uint64(memberCount)) using an explicit
checked/constrained conversion (e.g., ensure memberCount >= 0 then cast) or
mirror putUserKeyLen’s approach; apply the same refactor to zsetMemberKey,
zsetScoreKey, and zsetMetaDeltaKey so no //nolint is required and the
conversions are clearly safe.

In `@internal/backup/redis_zset.go`:
- Around line 87-93: Hot-path code repeatedly converts string prefix constants
to []byte per record; define package-level byte-slice variables (e.g.,
redisZSetMetaDeltaPrefixBytes, redisZSetMetaPrefixBytes,
redisZSetMemberPrefixBytes) initialized from the existing string constants
(RedisZSetMetaDeltaPrefix, RedisZSetMetaPrefix, RedisZSetMemberPrefix) and
replace all call-site conversions such as bytes.HasPrefix(key,
[]byte(RedisZSetMetaDeltaPrefix)) and other occurrences
(`[]byte(RedisZSetMetaPrefix)`, `[]byte(RedisZSetMemberPrefix)`) with the new
package-level variables; keep the semantic checks (e.g., parseZSetMetaKey usage)
unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a09d53a0-f475-408c-b3ba-4d2181e38e17

📥 Commits

Reviewing files that changed from the base of the PR and between 8c0d07f and 5e323cc.

📒 Files selected for processing (4)
  • internal/backup/redis_hash.go
  • internal/backup/redis_string.go
  • internal/backup/redis_zset.go
  • internal/backup/redis_zset_test.go

Comment on lines +310 to +316
envelope, ok := rec["member"].(map[string]any)
if !ok {
t.Fatalf("expected base64 envelope on member, got %T(%v)", rec["member"], rec["member"])
}
if envelope["base64"] == "" {
t.Fatalf("base64 envelope missing payload: %v", envelope)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Assert base64 payload correctness, not only presence.

Line 314-Line 316 only verifies non-empty payload, so a wrong payload could still pass. Decode and compare bytes to pin true round-trip behavior.

Proposed test hardening
 import (
+	"bytes"
+	"encoding/base64"
 	"encoding/binary"
 	"encoding/json"
 	"math"
@@
 	envelope, ok := rec["member"].(map[string]any)
 	if !ok {
 		t.Fatalf("expected base64 envelope on member, got %T(%v)", rec["member"], rec["member"])
 	}
-	if envelope["base64"] == "" {
-		t.Fatalf("base64 envelope missing payload: %v", envelope)
-	}
+	payload, ok := envelope["base64"].(string)
+	if !ok || payload == "" {
+		t.Fatalf("base64 envelope missing payload: %v", envelope)
+	}
+	decoded, err := base64.StdEncoding.DecodeString(payload)
+	if err != nil {
+		t.Fatalf("invalid base64 payload %q: %v", payload, err)
+	}
+	if !bytes.Equal(decoded, []byte{0x80, 0xff, 0x01}) {
+		t.Fatalf("decoded member = %v, want %v", decoded, []byte{0x80, 0xff, 0x01})
+	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
envelope, ok := rec["member"].(map[string]any)
if !ok {
t.Fatalf("expected base64 envelope on member, got %T(%v)", rec["member"], rec["member"])
}
if envelope["base64"] == "" {
t.Fatalf("base64 envelope missing payload: %v", envelope)
}
envelope, ok := rec["member"].(map[string]any)
if !ok {
t.Fatalf("expected base64 envelope on member, got %T(%v)", rec["member"], rec["member"])
}
payload, ok := envelope["base64"].(string)
if !ok || payload == "" {
t.Fatalf("base64 envelope missing payload: %v", envelope)
}
decoded, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
t.Fatalf("invalid base64 payload %q: %v", payload, err)
}
if !bytes.Equal(decoded, []byte{0x80, 0xff, 0x01}) {
t.Fatalf("decoded member = %v, want %v", decoded, []byte{0x80, 0xff, 0x01})
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/backup/redis_zset_test.go` around lines 310 - 316, The test
currently only checks envelope["base64"] is non-empty; instead decode the base64
string from envelope["base64"] (the variable envelope in redis_zset_test.go)
back to bytes and assert the decoded bytes equal the original payload expected
in the test (compare against the known original member payload used earlier in
the test), failing the test if decoding errors or the bytes differ to guarantee
true round-trip behavior.

…d records

Pebble snapshots are emitted in encoded-key order
(store/snapshot_pebble.go::iter.First()+Next()), and `!redis|ttl|`
lex-sorts BEFORE every wide-column prefix where the type letter is
>= 's' (`!st|` set, `!stream|`, `!zs|` zset — because `r` < `s`/`z`).
The original HandleTTL routed any unknown-kind expiry straight into
orphanTTLCount, so for sets, streams, and zsets the TTL was DROPPED
before zsetState/setState/streamState could claim it. Restored
sets/zsets/streams with TTL became permanent.

Codex P1 finding on PR #790. The same bug exists in the already-
merged set encoder (PR #758); this commit fixes both retroactively.
Stream encoder (PR #791) inherits the fix once rebased.

Fix: HandleTTL parks unknown-kind expiries in a new pendingTTL
map. Each wide-column state-init that may face the bad ordering
(setState, zsetState — and streamState once PR #791 lands) drains
the entry on first user-key registration via claimPendingTTL().
Finalize counts whatever remains in pendingTTL as truly-unmatched
orphans (a TTL whose user key never appeared in any typed record —
indicates store corruption or an unknown type prefix).

Semantic-change caller audit (per /loop standing instruction):
- HandleTTL's redisKindUnknown branch: previously incremented
  orphanTTLCount immediately; now buffers and lets Finalize count.
  All callers: only the per-record dispatcher in
  cmd/elastickv-snapshot-decode (not yet built — Phase 0a follow-
  up). No external caller mutates orphanTTLCount today.
- TestRedisDB_OrphanTTLCountedNotBuffered: updated to assert
  intake-time orphanTTLCount==0 + pendingTTL grows, then post-
  Finalize orphanTTLCount==n.
- New caller claimPendingTTL: called only by zsetState and setState
  in this PR. hashState/listState don't call it because their
  type prefixes (`!hs|`/`!lst|`) lex-sort BEFORE `!redis|ttl|`
  so the typed record arrives first; pendingTTL is always empty
  for them. Verified via `grep -n 'claimPendingTTL' internal/backup/`.

New tests:
- TestRedisDB_ZSetTTLArrivesBeforeRows — pins the fix for zsets.
- TestRedisDB_SetTTLArrivesBeforeRows — retroactive coverage for
  PR #758's set encoder.
- TestRedisDB_OrphanTTLCountsTrulyUnmatchedKeys — pins the new
  Finalize-time orphan semantics.

Self-review:
1. Data loss — the original code DROPPED real TTL'd
   sets/zsets/streams on every backup. This fix recovers them.
   No new data-loss surface introduced.
2. Concurrency — pendingTTL is added to RedisDB which is already
   sequential-per-scope; no new locking required.
3. Performance — pendingTTL holds (string-userKey, uint64-expireAt)
   pairs. The string allocation cost matches kindByKey's, which
   we already pay for every typed record. The original P2 round
   6 concern (don't buffer arbitrarily-large value payloads) is
   preserved: we still only buffer 8-byte expiry, not value bytes.
4. Consistency — drain happens at FIRST state registration so a
   later HandleTTL re-arrival (which would route through the
   redisKindSet/ZSet case in HandleTTL) is a no-op (st.expireAtMs
   gets overwritten with the same value).
5. Coverage — 3 new tests + 1 updated test. All 64 redis tests pass.
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 19, 2026

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented May 19, 2026

Claude finished @bootjp's task in 2m 38s —— View job


Code Review: PR #790 — Redis zset encoder (Phase 0a)

Tasks

  • Gather context from PR description and prior reviews
  • Read internal/backup/redis_zset.go
  • Read internal/backup/redis_zset_test.go
  • Read internal/backup/redis_string.go (modified)
  • Read internal/backup/redis_set.go (modified)
  • Cross-check against store/zset_helpers.go
  • Analyze and post review

The PR landed in two commits. The second commit (63e54d9) directly addresses the Codex P1 TTL scan-order finding that the previous Claude review and Codex both flagged, so I'm reviewing the whole PR as-shipped (including both commits).


P1 Fix — pendingTTL buffering (commit 2)

The fix is architecturally correct and well-tested. The essential flow:

  • HandleTTL parks unknown-kind expiries into pendingTTL (a new map[string]uint64 on RedisDB)
  • zsetState() and setState() call claimPendingTTL() on first registration of a user key, draining the parked expiry into the zset/set's expireAtMs
  • Finalize() counts len(r.pendingTTL) as orphans after all flush steps, so only keys that truly never appeared as typed records are warned

Key correctness properties verified:

  1. No double-set: when TTL arrives after the key is registered, HandleTTL hits case redisKindZSet:r.zsetState(userKey) fast-paths via if st, ok := r.zsets[uk]; ok { return st }claimPendingTTL is never re-called. Safe.
  2. No double-drain: claimPendingTTL does delete(r.pendingTTL, uk) atomically. A second call for the same key returns (0, false).
  3. Finalize ordering: flushSets and flushZSets both run before orphanTTLCount is incremented, so no typed record can claim a TTL after the count fires.
  4. hashState/listState correctness: Those state-init functions don't call claimPendingTTL because !hs|/!lst| lex-sort before !redis|ttl| — the typed record always arrives first. pendingTTL is always empty for those keys when their state-init fires. ✓

New tests TestRedisDB_ZSetTTLArrivesBeforeRows, TestRedisDB_SetTTLArrivesBeforeRows, and TestRedisDB_OrphanTTLCountsTrulyUnmatchedKeys all pin this behavior correctly.


Wire format and codec

All field sizes and decoding arithmetic match store/zset_helpers.go:

  • MarshalZSetScoremath.Float64bits(score) / binary.BigEndian.PutUint64 matches math.Float64frombits(binary.BigEndian.Uint64(value)) in HandleZSetMember:133. ✓
  • ZSetMetaKey layout matches parseZSetMetaKey. ✓
  • Delta key !zs|meta|d| double-guard (entry check + parseZSetMetaKey rejection) is the correct defense-in-depth pattern from the hash encoder. ✓
  • uint64 > math.MaxInt64 overflow guard mirrors hash/list/set encoders exactly. ✓

Confirmed correct — design decisions

  • Sort by member-name bytes, not scoresort.Strings in Go sorts by byte value of the string content; the claim in the docstring is accurate.
  • //nolint:dupl — The marshalZSetJSON / marshalHashJSON structural parallel cannot be collapsed without either losing JSON field-order determinism (map) or needing per-type generics (distinct JSON tags). The //nolint is the right call here.
  • NaN fail-closed at intakeHandleZSetMember:134 returns ErrRedisInvalidZSetMember for NaN; this prevents silent re-corruption and surfaces at the source record. ✓
  • Empty-zset emitmetaSeen=true with Len=0 still writes zsets/<key>.json; TYPE k → zset is observable. ✓
  • Members-without-metaflushZSets:223 checks st.metaSeen before firing the length-mismatch warning, so a members-only run doesn't emit a spurious warning. ✓
  • HandleZSetScore / HandleZSetMetaDelta — Named no-op functions rather than routing to the orphan sink. Future audits via grep 'every !zs| prefix has a handler' will find them. ✓

Issues

Low — Base64 test assertion is shallow (redis_zset_test.go:314-316)

TestRedisDB_ZSetBinaryMemberUsesBase64Envelope only verifies the envelope is non-empty:

if envelope["base64"] == "" {
    t.Fatalf("base64 envelope missing payload: %v", envelope)
}

This passes even if the encoded bytes are wrong. The CodeRabbit suggestion is sound — decode and compare against the expected bytes {0x80, 0xff, 0x01}:

payload, _ := envelope["base64"].(string)
decoded, err := base64.StdEncoding.DecodeString(payload)
if err != nil { t.Fatalf(...) }
if !bytes.Equal(decoded, []byte{0x80, 0xff, 0x01}) {
    t.Fatalf("decoded member = %v, want [0x80 0xff 0x01]", decoded)
}

Fix this →

Nit — //nolint:gosec in test key-builder helpers

Per CLAUDE.md convention (Avoid //nolint — refactor instead), the five //nolint:gosec suppressions in encodeZSetMetaValue, zsetMetaKey, zsetMemberKey, zsetScoreKey, and zsetMetaDeltaKey could be removed by adding an explicit bounds panic (e.g., if memberCount < 0 { panic("test bug") } before the conversion). Given these are test helpers with fully-controlled inputs this is very low impact, but worth tracking to stay consistent with the project convention.

Nit — Per-record []byte(prefix) allocations (CodeRabbit)

bytes.HasPrefix(key, []byte(RedisZSetMetaDeltaPrefix)) at lines 87, 177 and similar at 180, 192 create a []byte from a string constant on every call. During a real snapshot scan these fire per record. Hoisting to package-level var redisZSetMetaDeltaPrefixBytes = []byte(RedisZSetMetaDeltaPrefix) (and analogues) eliminates 4 allocations per dispatched record. In the same vein the set and hash encoders have the same pattern — fixing zset here would be consistent.


Future work (not blocking)

  • Stream encoderpendingTTL's doc comment already notes that streamState must call claimPendingTTL once PR backup: Redis stream encoder (Phase 0a) #791 lands. The infrastructure is in place.
  • store.UnmarshalZSetMeta missing overflow guard — The live decoder does int64(binary.BigEndian.Uint64(b)) without the > math.MaxInt64 check. The backup encoder is more defensive here. Worth a separate follow-up to align (or document that the live side trusts the write path to never produce > MaxInt64).

Summary

Solid implementation. The Codex P1 scan-order bug was correctly identified and fixed in commit 2 with proper pendingTTL infrastructure and three new tests. The zset encoder itself faithfully mirrors the hash/list/set encoders across wire format, delta handling, NaN rejection, ±Inf serialization, binary member safety, and empty-key emit rules. The only functional gap remaining is the weak base64 assertion in the binary-member test; the rest are style nits.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 63e54d91a7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +33 to +37
const (
RedisZSetMetaPrefix = "!zs|meta|"
RedisZSetMemberPrefix = "!zs|mem|"
RedisZSetScorePrefix = "!zs|scr|"
RedisZSetMetaDeltaPrefix = "!zs|meta|d|"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Include legacy zset blobs in dumps

For zsets stored only as the legacy single-blob key, this new encoder never claims the record because it only recognizes the wide-column !zs|... prefixes. That format is still part of the live data model: adapter/redis_compat_types.go:82 defines !redis|zset|, adapter/redis_compat_commands.go:3495-3508 still writes that key for non-empty persisted zset updates, and adapter/redis_compat_helpers.go:610-631 reads it as the fallback when no wide-column members exist. A snapshot containing such a zset will omit it from zsets/ entirely (and any TTL remains orphaned), so backups can lose user-visible sorted sets; add a handler for !redis|zset|<key> or migrate those blobs during encoding.

Useful? React with 👍 / 👎.

bootjp added a commit that referenced this pull request May 19, 2026
…ield preservation

Two P1 findings from chatgpt-codex on PR #791:

P1a: Buffer stream TTLs that arrive before stream rows

Pebble snapshots emit records in encoded-key order
(store/snapshot_pebble.go::iter.First()+Next()), and
`!redis|ttl|` lex-sorts BEFORE `!stream|...` because `r` < `s`.
In real snapshot order the TTL arrives FIRST, kindByKey is still
redisKindUnknown when HandleTTL fires, and the original code
counted the TTL as an orphan and dropped it — every TTL'd stream
restored as permanent.

Same root cause as the set encoder's latent bug in PR #758. This
commit adds a pendingTTL infrastructure (matching the parallel fix
on PR #790) so the expiry parks during the redisKindUnknown
window and drains when streamState first registers the user key.
The set encoder gets the same retroactive drain.

P1b: Preserve duplicate stream fields instead of map-collapsing

XADD permits duplicate field names within one entry (e.g.
`XADD s * f v1 f v2`). The protobuf entry stores the interleaved
slice verbatim, but my marshalStreamJSONL collapsed pairs into
`map[string]string`, silently dropping every duplicate. A restore
of such an entry would lose the second (and later) pair.

Fix: emit `fields` as a JSON ARRAY of `{name, value}` records
(streamFieldJSON). Order is the protobuf's interleaved order so
a restore can replay the original XADD argv exactly.

The design example at
docs/design/2026_04_29_proposed_snapshot_logical_decoder.md:338
showed object form. That representation was unsafe for streams
(though fine for hashes where the wire-level encoder normalises
field names earlier). The format is owned by Phase 0 — adjusted
in this PR before the format ships any consumers.

Caller audit (per /loop standing instruction):
- HandleTTL's redisKindUnknown branch: same semantic change as
  PR #790's r1 — previously incremented orphanTTLCount on intake;
  now buffers in pendingTTL and lets Finalize count at end.
  Same audit conclusion: no external callers of orphanTTLCount;
  TestRedisDB_OrphanTTLCountedNotBuffered updated to assert the
  new intake/Finalize split.
- streamEntryJSON.Fields type change `map → slice`: only
  marshalled by encoding/json; the only reader is the test suite,
  which is updated in this commit. No on-disk format compatibility
  concerns because Phase 0 has not shipped a consumer yet.
- New caller claimPendingTTL: called by setState (retroactive) and
  streamState (new) in this PR. hashState/listState don't call it
  because their type prefixes lex-sort BEFORE `!redis|ttl|`.
  Verified via `grep -n 'claimPendingTTL' internal/backup/`.

New tests:
- TestRedisDB_StreamDuplicateFieldsPreserved — pins P1b fix.
- TestRedisDB_StreamTTLArrivesBeforeRows — pins P1a fix for streams.
- TestRedisDB_SetTTLArrivesBeforeRows — retroactive coverage for
  PR #758's set encoder (same root cause as the stream bug).
- TestRedisDB_StreamFieldsDecodedToArray (renamed from
  ToObject) — updated to match the array shape.

Self-review:
1. Data loss — the original code DROPPED real TTL'd streams on
   every backup AND dropped duplicate-field entries' later pairs.
   This fix recovers both. No new data-loss surface introduced.
2. Concurrency — pendingTTL added to RedisDB which is already
   sequential-per-scope; no new locking required.
3. Performance — pendingTTL holds (string-userKey, uint64-expireAt)
   pairs; same allocation shape as kindByKey. Fields slice
   replaces a map of the same logical size — slightly cheaper
   actually (no hash overhead).
4. Consistency — drain happens at FIRST state registration. The
   array form preserves insertion order from the protobuf so the
   restored XADD argv matches.
5. Coverage — 4 new tests + 2 updated. All 78 redis tests pass.
…lob layout

Codex flagged that the wide-column zset encoder skips the legacy
consolidated single-key blob layout the live store still writes.
A zset stored only as `!redis|zset|<userKey>` (with the magic-
prefixed pb.RedisZSetValue body) is silently dropped from backup
output and any inline TTL becomes an orphan — user-visible
sorted-set data loss.

Live-side references (adapter, not changed by this commit):
- adapter/redis_compat_types.go:82 — redisZSetPrefix
- adapter/redis_compat_commands.go:3495-3508 — writes the blob for
  non-empty persisted zset updates
- adapter/redis_compat_helpers.go:610-631 — reads it as the
  fallback when no wide-column members exist

Fix: new public RedisDB.HandleZSetLegacyBlob method that decodes
the magic-prefixed pb.RedisZSetValue and registers the same per-
member state HandleZSetMember would. The wide-column merge case
(mid-migration snapshot containing BOTH layouts for the same user
key) works because `!redis|zset|` sorts BEFORE `!zs|...` so the
blob arrives first and wide-column rows then update / add members
via the latest-wins map.

Inline TTL: `!redis|zset|<k>` sorts BEFORE `!redis|ttl|<k>`, so
HandleTTL after this handler sees redisKindZSet already and
folds via the case-redisKindZSet branch. No pendingTTL detour
needed for this ordering.

Fail-closed contract (matches existing wide-column path):
- Missing magic prefix → ErrRedisInvalidZSetLegacyBlob
- Unmarshal error    → ErrRedisInvalidZSetLegacyBlob
- NaN score          → ErrRedisInvalidZSetLegacyBlob (Redis ZADD
                       rejects NaN at wire level)

Caller audit (per /loop standing instruction): new public method
HandleZSetLegacyBlob has no external callers. Verified via
'grep -rn HandleZSetLegacyBlob --include=*.go' — all matches inside
the test file in this PR. The cmd/elastickv-snapshot-decode
dispatcher (Phase 0a follow-up, not yet built) will route the
`!redis|zset|` prefix to this handler.

Parallel bug class: the SAME issue exists for `!redis|hash|`,
`!redis|set|`, and `!redis|stream|` legacy blob prefixes. Those
encoders shipped in earlier PRs (#725, #758, #791). Each needs
its own legacy-blob handler in a follow-up PR; this commit fixes
only the zset case codex flagged on PR #790.

New tests:
- TestRedisDB_ZSetLegacyBlobRoundTrip — basic round-trip
- TestRedisDB_ZSetLegacyBlobThenWideColumnMerges — mid-migration
- TestRedisDB_ZSetLegacyBlobWithInlineTTL — TTL ordering
- TestRedisDB_ZSetLegacyBlobRejectsMissingMagic — fail-closed
- TestRedisDB_ZSetLegacyBlobRejectsNaNScore — fail-closed
- TestRedisDB_ZSetLegacyBlobRejectsMalformedKey — fail-closed

Self-review:
1. Data loss — exact opposite: this commit RECOVERS zsets that
   were silently dropped. New fail-closed guards prevent silently
   importing a corrupt blob.
2. Concurrency — no new shared state; per-DB sequential as before.
3. Performance — one protobuf Unmarshal per legacy zset key
   (same as the live read path). Member map shares the same
   latest-wins behavior as wide-column intake.
4. Consistency — merge order (blob first, wide-column second) is
   determined by snapshot lex order; tested explicitly.
5. Coverage — 6 new tests. All 84 redis tests pass.
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 19, 2026

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented May 19, 2026

Claude finished @bootjp's task in 4m 37s —— View job


Code Review: PR #790 — Redis zset encoder (Phase 0a) — Round 3

Tasks

  • Gather context from PR description and prior reviews
  • Read internal/backup/redis_zset.go (all 3 commits)
  • Read internal/backup/redis_zset_test.go
  • Read internal/backup/redis_string.go (HandleTTL routing)
  • Cross-check legacy !redis|zset| handler against live encoder
  • Check outstanding issues from prior reviews
  • Post comprehensive review

The third commit (5446190) addresses the Codex P1 finding on round 2: legacy !redis|zset|<userKey> blobs were silently dropped from backup output. This review covers the full three-commit PR as shipped.


Commit 3: HandleZSetLegacyBlob — correctness

Magic prefix match: redisZSetLegacyProtoPrefix = []byte{0x00, 'R', 'Z', 0x01} exactly matches adapter/redis_storage_codec.go:15 storedRedisZSetProtoPrefix. A live-side rename would surface as ErrRedisInvalidZSetLegacyBlob on any real cluster dump — the "canary" pattern documented in the constant's docstring is a good operational safeguard.

!redis|zset| < !zs|... ordering (mid-migration merge): comparing at the first diverging byte — r (0x72) vs z (0x7A) — !redis|... sorts before !zs|.... TestRedisDB_ZSetLegacyBlobThenWideColumnMerges tests the correct order. ✓

decodeZSetLegacyBlobValue: rejects payloads shorter than 4 bytes, rejects mismatched magic, rejects NaN scores — all fail-closed at intake. ✓

zsetState() + claimPendingTTL(): HandleZSetLegacyBlob calls r.zsetState(userKey) which calls claimPendingTTL on first registration. This means the pendingTTL drain works for legacy blobs as well, regardless of scan order. ✓


Medium — Incorrect scan-order claim; wrong test order for legacy-blob TTL

HandleZSetLegacyBlob's docstring (lines 202–210 in the current file) states:

!redis|zset| ALSO sorts BEFORE !redis|ttl|, so an inline TTL on the same user key will reach HandleTTL after this handler has already registered redisKindZSet.

This is incorrect. Comparing at the first diverging byte (|t... vs |z...): t = 0x74 < z = 0x7A, so !redis|ttl|k sorts before !redis|zset|k in Pebble's lexicographic order. The real snapshot sequence for a TTL'd legacy zset is:

!redis|ttl|k    → HandleTTL → kindByKey["k"] == redisKindUnknown → pendingTTL["k"] = ms
!redis|zset|k   → HandleZSetLegacyBlob → zsetState() → claimPendingTTL() → st.expireAtMs = ms

The pendingTTL mechanism handles this correctly (since zsetState() always calls claimPendingTTL()), so the behavior is correct — but the comment documents the wrong ordering and the wrong code path.

TestRedisDB_ZSetLegacyBlobWithInlineTTL tests blob-first-then-TTL, which is the non-real-world order (taking the case redisKindZSet: shortcut, not the pendingTTL path). The real-world TTL-first ordering for legacy blobs is not tested.

Two fixes needed:

  1. Correct the docstring: !redis|ttl| sorts before !redis|zset| so the TTL arrives first; the pendingTTL drain in zsetState() handles it.
  2. Add TestRedisDB_ZSetLegacyBlobTTLArrivesBeforeBlob that calls HandleTTL first then HandleZSetLegacyBlob, and asserts expire_at_ms is inlined. This pins the real-world path through pendingTTL.

Fix these →


Low — TestRedisDB_ZSetLegacyBlobRejectsMalformedKey has contradictory comments and tests the wrong boundary

The test docstring says "key with no trailing user-key bytes — parser must still accept it (empty user key is technically valid Redis)" and then the body comment says "Use a key that doesn't have the prefix to trigger the parse failure." The test uses []byte("not-the-right-prefix|k") — a key that doesn't have the !redis|zset| prefix at all.

Inspecting parseZSetLegacyBlobKey: it returns (rest, true) whenever TrimPrefix removes anything, including when rest is empty (prefix-only key). So HandleZSetLegacyBlob([]byte("!redis|zset|"), value) succeeds with an empty user key. The test never touches this boundary.

This isn't a functional bug — the test does exercise the prefix-missing error path — but the test name (RejectsMalformedKey) and first comment are misleading. Better: rename to TestRedisDB_ZSetLegacyBlobRejectsMissingPrefix and remove the contradictory "no trailing user-key bytes" comment.


Outstanding from prior reviews (still unaddressed)

Both prior Claude reviews flagged these. They remain open:

LowTestRedisDB_ZSetBinaryMemberUsesBase64Envelope (redis_zset_test.go:346): only checks envelope["base64"] == "" (non-empty presence). A wrong base64 payload still passes. Fully decode and compare against []byte{0x80, 0xff, 0x01}:

payload, ok := envelope["base64"].(string)
if !ok || payload == "" { t.Fatalf(...) }
decoded, err := base64.StdEncoding.DecodeString(payload)
if err != nil { t.Fatalf("invalid base64: %v", err) }
if !bytes.Equal(decoded, []byte{0x80, 0xff, 0x01}) {
    t.Fatalf("decoded member = %v, want [0x80 0xff 0x01]", decoded)
}

Nit — Five //nolint:gosec suppressions remain in test helpers (encodeZSetMetaValue, zsetMetaKey, zsetMemberKey, zsetScoreKey, zsetMetaDeltaKey). Per CLAUDE.md: "Avoid //nolint — refactor instead." Adding a bounds panic before the cast makes the conversion obviously safe and removes the suppressions.

Nit[]byte(RedisZSetMetaDeltaPrefix), []byte(RedisZSetMetaPrefix), []byte(RedisZSetMemberPrefix) are allocated per dispatched record in parseZSetMetaKey, parseZSetMemberKey, and HandleZSetMeta. Hoisting these to package-level var (as CodeRabbit suggested) eliminates the per-record overhead.


Confirmed correct in commit 3

  • Magic prefix {0x00, 'R', 'Z', 0x01} matches the live storedRedisZSetProtoPrefix exactly ✓
  • decodeZSetLegacyBlobValue rejects payloads shorter than 4 bytes (empty protobuf body is 0 bytes; 0 < 4 → error) ✓
  • NaN fail-closed in decodeZSetLegacyBlobValue matches HandleZSetMember's contract ✓
  • Mid-migration merge (!redis|zset| arrives before !zs|...): ordering claim and test are correct ✓
  • zsetState()claimPendingTTL() chain means pendingTTL works for legacy blobs even though the comment misstates the reason ✓
  • encodeZSetLegacyBlobValue test helper mirrors adapter/redis_storage_codec.go::marshalZSetValue exactly ✓
  • zsetLegacyBlobKey mirrors adapter/redis_compat_types.go:177 ZSetKey exactly ✓
  • 6 new legacy-blob tests cover round-trip, mid-migration merge, NaN rejection, missing-magic rejection ✓

Summary

The Codex P1 data-loss fix is architecturally sound: HandleZSetLegacyBlob decodes the protobuf blob, registers the same per-key state HandleZSetMeta + HandleZSetMember would, and the zsetState()claimPendingTTL() chain means TTL inlining works via pendingTTL for the real snapshot order. The one material issue is that the code comments state the wrong scan order for !redis|ttl| vs !redis|zset|, and the TTL test exercises the non-real-world path (blob-first), leaving the actual pendingTTL path for legacy blobs untested. The base64 shallow assertion and //nolint:gosec suppressions carry over from prior reviews.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5446190f78

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +221 to +224
st := r.zsetState(userKey)
for _, e := range entries {
st.members[e.member] = e.score
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Ignore stale legacy zset blobs when wide rows exist

When a snapshot contains both !redis|zset|<key> and !zs|mem|... rows for the same key, this merge makes backup output include members from the legacy blob that are not user-visible anymore. I checked the live read path in adapter/redis_compat_helpers.go:610-620: as soon as any wide-column member exists, loadZSetAt returns only loadZSetMembersAt and uses the legacy blob only as a fallback when no wide rows exist. A dump taken after a partially migrated/stale legacy blob remains alongside current wide rows would therefore restore deleted or outdated members; the encoder should skip or replace legacy state once wide-column rows are observed for that key.

Useful? React with 👍 / 👎.

Codex flagged that the r2 fix (legacy-blob handler in #790 #2)
mis-handles the mid-migration / stale-leftover case: when a
snapshot contains BOTH `!redis|zset|<k>` and `!zs|mem|<k>...` for
the same user key, my code merged both, surfacing members that
are no longer visible to readers.

Live read path
(adapter/redis_compat_helpers.go:610-620 RedisServer.loadZSetAt):
when ANY wide-column row exists, loadZSetMembersAt is used and
the legacy blob is IGNORED. A stale `!redis|zset|` blob left over
after a partial migration would be invisible to clients but my
encoder was surfacing its entries in the dump — silent backup
corruption.

Fix: add a sawWide flag to redisZSetState that flips on every
wide-column observation (HandleZSetMeta + HandleZSetMember).
markZSetWide() clears any legacy entries previously deposited by
HandleZSetLegacyBlob, and HandleZSetLegacyBlob short-circuits if
sawWide is already set. The result matches the live read path's
source-of-truth selection:

  - No wide rows         → legacy blob is the source of truth.
  - Any wide row exists  → wide rows are authoritative; legacy
                            entries are ignored (whether they
                            arrived before or after the wide row).

Caller audit (per /loop standing instruction): the new
markZSetWide method is called only from HandleZSetMeta line 160
and HandleZSetMember line 194. HandleZSetLegacyBlob (line 268)
short-circuits on the same flag. No other call sites — verified
via 'grep -rn markZSetWide|sawWide internal/backup/'. The
semantic change (legacy entries cleared on first wide observation)
is contained to redisZSetState; no public API or external caller
visible.

Test changes:
- TestRedisDB_ZSetLegacyBlobThenWideColumnMerges renamed to
  TestRedisDB_ZSetWideColumnSupersedesLegacyBlob, expectations
  updated (bob-stale no longer survives).
- New TestRedisDB_ZSetMetaAloneEvictsLegacyBlob — meta-only
  wide-column row evicts legacy blob.
- New TestRedisDB_ZSetLegacyBlobAfterWideRowsIsDropped — reverse-
  order case (wide first, blob later).

Self-review:
1. Data loss — opposite: this fix PREVENTS surfacing stale members
   that the live read path hides. Legacy blob is still applied
   when no wide rows exist (preserves the original P1 round 2 fix
   for legacy-only zsets).
2. Concurrency — no new shared state.
3. Performance — sawWide is a single bool per state; markZSetWide
   short-circuits on subsequent calls so the clear-loop runs at
   most once per state.
4. Consistency — encoder output now matches `loadZSetAt`'s read-
   side source-of-truth selection. Tested with both orderings
   (legacy-first-wide-later, wide-first-legacy-later, meta-alone).
5. Coverage — 1 renamed test + 2 new tests. All 90 redis tests pass.
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 19, 2026

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented May 19, 2026

Claude finished @bootjp's task in 4m 12s —— View job


Code Review: PR #790 — Redis zset encoder (Phase 0a) — Round 4

Tasks

  • Gather context from PR description and prior reviews
  • Read internal/backup/redis_zset.go (all 4 commits)
  • Read internal/backup/redis_zset_test.go
  • Read internal/backup/redis_string.go and redis_set.go
  • Cross-check scan ordering and pendingTTL logic
  • Check all outstanding issues from Round 3
  • Analyze commit 4 (ab88f3e): wide-column rows supersede legacy blob
  • Post comprehensive review

Commit 4 (ab88f3e) — Wide-column supersedes legacy blob

This addresses the Codex P1 round-3 finding correctly. The sawWide flag + markZSetWide() mechanism mirrors the live read path (adapter/redis_compat_helpers.go:610-620) precisely:

markZSetWide() logic traced across all orderings:

Order Trace Result
legacy blob → wide member HandleZSetLegacyBlob adds {alice:1, bob-stale:2}; HandleZSetMember("alice", 99)markZSetWide clears map, adds alice:99 {alice:99} ✓
wide member → legacy blob HandleZSetMember → sawWide=true; HandleZSetLegacyBlobst.sawWide check → early return wide-only ✓
legacy blob → meta(0) HandleZSetLegacyBlob adds {stale:1}; HandleZSetMeta(0)markZSetWide clears map {} ✓
legacy blob alone sawWide never set; entries stay in map through Finalize legacy entries ✓

Key invariants confirmed:

  • markZSetWide() is called from both HandleZSetMeta (line 160) and HandleZSetMember (line 194), covering every wide-column entry point ✓
  • HandleZSetScore and HandleZSetMetaDelta are no-ops; neither creates state nor sets sawWide — correct, these are secondary-index / delta records ✓
  • Three new tests (TestRedisDB_ZSetWideColumnSupersedesLegacyBlob, TestRedisDB_ZSetMetaAloneEvictsLegacyBlob, TestRedisDB_ZSetLegacyBlobAfterWideRowsIsDropped) cover all key orderings ✓
  • The markZSetWide in-place map-clear (for k := range st.members { delete(st.members, k) }) avoids changing the pointer — consistent with the nil-check-free pattern used throughout ✓

No new issues found in commit 4.


Issues — Remaining from prior rounds (none addressed in commit 4)

Medium — HandleZSetLegacyBlob docstring states the wrong scan order for TTL (lines 252–257)

The comment claims:

!redis|zset| ALSO sorts BEFORE !redis|ttl|, so an inline TTL … will reach HandleTTL after this handler has already registered redisKindZSet.

This is incorrect. Comparing at the first diverging byte after the shared !redis| prefix: t = 0x74 < z = 0x7A, so !redis|ttl| sorts BEFORE !redis|zset|. The real sequence for a TTL'd legacy zset is:

!redis|ttl|k   → HandleTTL → redisKindUnknown → pendingTTL["k"] = ms
!redis|zset|k  → HandleZSetLegacyBlob → zsetState() → claimPendingTTL() → st.expireAtMs = ms

The code is correct — the zsetState()claimPendingTTL() chain handles this. Only the comment documents the wrong path. The first paragraph at line 246–250 also incorrectly says !redis|zset| sorts before !redis|ttl|.

Fix this →

Low — TestRedisDB_ZSetLegacyBlobWithInlineTTL tests the non-real-world scan order

TestRedisDB_ZSetLegacyBlobWithInlineTTL (line 783) calls HandleZSetLegacyBlob first, then HandleTTL. In a real Pebble scan the opposite is true (!redis|ttl| < !redis|zset| as shown above), so the test exercises the HandleTTL → redisKindZSet → zsetState(k) shortcut path — not the actual pendingTTL → claimPendingTTL() path that fires in production.

A companion test is needed:

func TestRedisDB_ZSetLegacyBlobTTLArrivesBeforeBlob(t *testing.T) {
    db, root := newRedisDB(t)
    // Real-world order: TTL arrives first (!redis|ttl| < !redis|zset|).
    if err := db.HandleTTL([]byte("k"), encodeTTLValue(fixedExpireMs)); err != nil {
        t.Fatal(err)
    }
    value := encodeZSetLegacyBlobValue(t, []zsetLegacyEntry{{member: "m", score: 1}})
    if err := db.HandleZSetLegacyBlob(zsetLegacyBlobKey("k"), value); err != nil {
        t.Fatal(err)
    }
    if err := db.Finalize(); err != nil { t.Fatal(err) }
    got := readZSetJSON(t, filepath.Join(root, "redis", "db_0", "zsets", "k.json"))
    if zsetFloat(t, got, "expire_at_ms") != float64(fixedExpireMs) {
        t.Fatalf("expire_at_ms = %v want %d — pendingTTL path for legacy blob failed",
            got["expire_at_ms"], fixedExpireMs)
    }
}

Fix this →

Low — Base64 assertion is non-functional (redis_zset_test.go:346-348, carried from rounds 2–3)

if envelope["base64"] == "" {
    t.Fatalf("base64 envelope missing payload: %v", envelope)
}

envelope["base64"] is a any typed map value; comparing it to "" compares the interface value to a string. This check passes even if the payload is corrupt. Fully decode and compare:

payload, ok := envelope["base64"].(string)
if !ok || payload == "" {
    t.Fatalf("base64 envelope missing payload: %v", envelope)
}
decoded, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
    t.Fatalf("invalid base64 payload %q: %v", payload, err)
}
if !bytes.Equal(decoded, []byte{0x80, 0xff, 0x01}) {
    t.Fatalf("decoded member = %v, want [0x80 0xff 0x01]", decoded)
}

Fix this →

Nit — //nolint:gosec suppressions in test helpers (lines 50, 67, 77, 90, 102; carried from rounds 2–3)

Per CLAUDE.md: Avoid //nolint — refactor instead. The five integer-to-uint conversions in encodeZSetMetaValue, zsetMetaKey, zsetMemberKey, zsetScoreKey, and zsetMetaDeltaKey can be made obviously safe by adding an explicit bounds panic before the cast. CodeRabbit's proposed putUserKeyLen helper applies here.

Nit — TestRedisDB_ZSetLegacyBlobRejectsMalformedKey has contradictory comments (line 838–841; carried from round 3)

The test comment block says "Key has the prefix but no trailing user-key bytes — parser must still accept it (empty user key is technically valid Redis)" but the actual test passes a key that lacks the !redis|zset| prefix entirely ("not-the-right-prefix|k"). The test exercises the missing-prefix path, not the empty-user-key path. Rename to TestRedisDB_ZSetLegacyBlobRejectsMissingPrefix and remove the contradictory comment.


Confirmed correct across all 4 commits

  • sawWide mechanism mirrors adapter/redis_compat_helpers.go:610-620 exactly: wide-column rows are authoritative, legacy blob is a fallback consulted only when no wide rows exist ✓
  • markZSetWide is guarded by if st.sawWide { return } making it idempotent across multiple wide member records ✓
  • redisZSetLegacyProtoPrefix = []byte{0x00, 'R', 'Z', 0x01} matches adapter/redis_storage_codec.go:15 storedRedisZSetProtoPrefix
  • !redis|zset| < !zs|... ordering is correct for the mid-migration merge case (at the first diverging byte: r=0x72 < z=0x7A) ✓
  • NaN fail-closed in decodeZSetLegacyBlobValue matches HandleZSetMember contract ✓
  • pendingTTL drain in zsetState() correctly handles the legacy-blob+TTL scenario at the code level (comment is wrong, code is right) ✓
  • encodeZSetLegacyBlobValue and zsetLegacyBlobKey test helpers match the live encoder/key builder exactly ✓
  • All six legacy-blob tests in commit 3 + three new wide-supersedes-legacy tests in commit 4 are well-constructed ✓

Summary

Commit 4 is architecturally correct: sawWide + markZSetWide() faithfully replicates the live read path's source-of-truth preference, and the three new tests pin all ordering variants. The one functional gap is that the real-world scan order for TTL'd legacy blobs (TTL arrives first, blob second) is not exercised by a test — the existing TestRedisDB_ZSetLegacyBlobWithInlineTTL tests the synthetic reverse order. The docstring at lines 252–257 documents the wrong path for the same reason. Both can be fixed together with a comment correction and a single new test.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ab88f3e096

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// this state is stale; drop it. The live read path
// (adapter/redis_compat_helpers.go:610-620) makes the same
// choice on read.
r.markZSetWide(st)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve legacy zsets when only meta exists

When a snapshot has a legacy !redis|zset|<k> blob plus a !zs|meta|<k> row but no !zs|mem| rows, this call clears the legacy members and the dump restores an empty zset. The live read path in adapter/redis_compat_helpers.go:610-623 only switches away from the legacy blob when the member-prefix scan returns rows; meta alone is not used as the value source. This can happen with stale/partial wide metadata alongside a still-visible legacy blob, so the backup should only evict legacy entries once a member row is observed, not on meta alone.

Useful? React with 👍 / 👎.

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