Skip to content

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

Merged
bootjp merged 15 commits into
mainfrom
backup/redis-zset-encoder
May 21, 2026
Merged

backup: Redis zset encoder (Phase 0a)#790
bootjp merged 15 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/restore with deterministic JSON files, binary-safe member encoding, raw-byte member sorting, duplicate "latest-wins" semantics, and ±Inf score serialization as strings.
    • TTL handling: per-key TTLs can be inlined into ZSET/Set outputs; TTLs arriving before typed rows are buffered and drained when the key is first seen.
    • Orphan-TTL buffering with configurable byte-cap, fail-closed overflow behavior, and an option to disable buffering.
  • Tests

    • Extensive test suite covering ZSET semantics, TTL buffering/drain, legacy-blob reconciliation, byte-cap edge cases, and overflow warnings.

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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: efb0060b-dd20-4848-b16e-b47292e1f70f

📥 Commits

Reviewing files that changed from the base of the PR and between 1303913 and cbeae91.

📒 Files selected for processing (3)
  • internal/backup/redis_string.go
  • internal/backup/redis_string_test.go
  • internal/backup/redis_zset_test.go

📝 Walkthrough

Walkthrough

Adds ZSET intake handlers (wide-column + legacy), per-key zset state with TTL inlining and pending-unknown-kind TTL buffering (byte-cap, fail-closed overflow), deterministic per-zset JSON emission on Finalize, set TTL claim integration, a hash marshaler lint annotation, and comprehensive tests.

Changes

ZSET Backup Feature

Layer / File(s) Summary
TTL routing, RedisDB fields & config
internal/backup/redis_string.go
Adds redisKindZSet, zsets and pendingTTL buffers, ErrPendingTTLBufferFull, WithPendingTTLByteCap, claimPendingTTL and integrates flushZSets into Finalize(); updates HandleTTL to park or inline TTLs for ZSETs and to buffer unknown-kind TTL records under a byte cap.
ZSET constants & errors
internal/backup/redis_zset.go
Defines ZSET snapshot key prefixes, score/legacy-format constants, and exported error variables for invalid meta/member/legacy/key cases.
ZSET intake handlers (wide-column)
internal/backup/redis_zset.go
Implements HandleZSetMeta, HandleZSetMember, no-op HandleZSetScore/HandleZSetMetaDelta, lazy per-key redisZSetState creation, wide-column detection that evicts legacy blobs, and latest-wins member score semantics with NaN rejection.
Legacy ZSET blob decoding and precedence
internal/backup/redis_zset.go
Parses and decodes legacy `!redis
ZSET flush & JSON marshaling
internal/backup/redis_zset.go
Implements flushZSets and writeZSetJSON, emits deterministic JSON per zset with format_version, binary-safe member encoding, byte-sorted member ordering, score serialization (numbers or "+inf"/"-inf" strings), optional expire_at_ms, and length-mismatch warnings.
Set state TTL claim integration
internal/backup/redis_set.go
setState now claims any pending TTL for the set's user key when the state is first created, initializing expireAtMs/hasTTL as appropriate.
Hash marshaler lint annotation
internal/backup/redis_hash.go
Adds expanded comment and //nolint:dupl to marshalHashJSON documenting intentional duplication for deterministic JSON field ordering.
Comprehensive ZSET & TTL tests
internal/backup/redis_zset_test.go, internal/backup/redis_string_test.go
Adds tests covering ZSET round-trips, legacy blob correctness, member ordering and binary envelopes, special-float score serialization, TTL inlining and pending-TTL buffering semantics (including byte-cap overflow/fail-closed, reclamation on claim, duplicate-key accounting, zero/negative cap behavior, and finalize orphan counting), and intake validation edge cases.

Sequence Diagram

sequenceDiagram
  participant Snapshot as Snapshot Record
  participant Intake as Intake Handlers
  participant RedisDB as RedisDB State
  participant Pending as PendingTTL Buffer
  participant ZSet as per-ZSET State
  participant Finalize as Finalize/flushZSets
  participant Output as zsets/*.json

  Snapshot->>Intake: !zs|meta|... (declared members)
  Intake->>ZSet: Store declaredLen / metaSeen

  Snapshot->>Intake: !zs|mem|... (member + score)
  Intake->>ZSet: Decode IEEE-754 score (reject NaN), store member (latest wins)

  Snapshot->>RedisDB: !redis|ttl|<userKey>
  RedisDB->>ZSet: Inline expire_at_ms / set hasTTL (if typed as ZSET)
  RedisDB->>Pending: Park TTL (latest-wins) when kind unknown (byte-cap accounted)

  Snapshot->>Intake: !zs|score|... or !zs|meta|d|...
  Intake->>Intake: Silently discard (no-op)

  Snapshot->>Finalize: Finalize()
  Finalize->>ZSet: flushZSets -> marshalZSetJSON
  ZSet->>Output: atomic write zsets/<encoded>.json
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly Related PRs

  • bootjp/elastickv#755: Modifies internal/backup/redis_string.go TTL ingestion routing; related surface area for pending/typed TTL handling.
  • bootjp/elastickv#713: Prior TTL routing/state work in internal/backup/redis_string.go that ZSET buffering and routing builds upon.

Poem

🐰 I buffered TTLs before they met their set,
Sorted members by bytes, not by fret,
Scores finite or ±inf tucked tight,
Legacy blobs bowed to wide-column might—
Bunny backed up ZSETs with a hop and a duet.

🚥 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)' is clear, specific, and accurately describes the primary change: adding a Redis sorted-set encoder to the backup package.
Docstring Coverage ✅ Passed Docstring coverage is 86.36% 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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 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 thread internal/backup/redis_zset_test.go
…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".

Comment thread internal/backup/redis_zset.go Outdated
// 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 👍 / 👎.

Codex flagged the same P1 on PR #791 round 2 — pendingTTL grows
unboundedly when no zset handler is present to drain the parked
TTLs. The same bug exists on PR #790's branch (the two PRs share
the pendingTTL infrastructure; the codex finding's text applies
verbatim).

Fix: cap pendingTTL at pendingTTLCap entries (default 1 MiB). Once
the cap is reached, subsequent unknown-kind TTLs fall back to
immediate-orphan counting via the new parkUnknownTTL helper.

New API:
- RedisDB.WithPendingTTLCap(int) chainable setter.
- pendingTTLOverflow counter surfaced in the orphan-TTL warning.

Caller audit (per /loop standing instruction):
- HandleTTL's redisKindUnknown branch behavior changes: previously
  ALWAYS buffered, now buffers up to cap then orphan-counts. Other
  branches unchanged.
- Callers of HandleTTL: tests only. The cmd/elastickv-snapshot-decode
  driver (Phase 0a follow-up) will be the first production caller.
- New helper parkUnknownTTL is package-private with one call site
  (HandleTTL). No prior call sites to audit.
- Verified via `grep -rn HandleTTL|pendingTTLCap|WithPendingTTLCap
  --include=*.go internal/backup/`.

New tests:
- TestRedisDB_PendingTTLBoundedByCap.
- TestRedisDB_WithPendingTTLCapZeroDisablesBuffering.
- TestRedisDB_WithPendingTTLCapNegativeCoercedToZero.

Self-review:
1. Data loss - cap can mis-classify TTLs in the overflow window
   that COULD have been drained by a later state-init. Mitigation:
   default cap (1 MiB) is well above the count of legitimately-
   buffered wide-column TTL'd keys on real clusters; operators
   can tune via WithPendingTTLCap.
2. Concurrency - no new shared state.
3. Performance - one extra comparison per unknown-kind TTL.
4. Consistency - mirrors the fix landing on PR #791 r3; when
   either PR merges first the other can rebase cleanly.
5. Coverage - 3 new tests + the existing
   TestRedisDB_OrphanTTLCountedNotBuffered (still pins the
   default-cap buffered + drained-at-Finalize semantics).
@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 5s —— View job


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

Tasks

  • Gather context from PR description and prior reviews (rounds 1–4)
  • Read internal/backup/redis_zset.go (all 5 commits)
  • Read internal/backup/redis_zset_test.go
  • Read internal/backup/redis_string.go
  • Review commit 5 (174456a): bound pendingTTL to prevent OOM
  • Check all outstanding issues from Round 4
  • Post comprehensive review

Commit 5 (174456a) — pendingTTL OOM bound

The fix is architecturally correct and complete:

  • defaultPendingTTLCap = 1 << 20 (~64 MiB at ~64 B/entry) with clear documentation in the constant's comment ✓
  • WithPendingTTLCap(0) disables buffering entirely (reverts to pre-pendingTTL immediate-orphan behavior); negative coerced to 0 ✓
  • parkUnknownTTL caps at pendingTTLCap entries; overflow increments both pendingTTLOverflow and orphanTTLCount immediately ✓
  • Finalize surfaces pending_ttl_buffer_overflow and pending_ttl_buffer_cap in the warning so operators can distinguish "exceeded cap" from "truly unmatched TTLs" ✓
  • Three tests (TestRedisDB_PendingTTLBoundedByCap, TestRedisDB_WithPendingTTLCapZeroDisablesBuffering, TestRedisDB_WithPendingTTLCapNegativeCoercedToZero) correctly pin all behavioral variants ✓

TestRedisDB_PendingTTLBoundedByCap arithmetic verified:
cap=8, 16 writes → intake: len(pendingTTL)=8, orphanTTLCount=8, pendingTTLOverflow=8 → after Finalize: orphanTTLCount=16. Correct.

Low — parkUnknownTTL: duplicate-key at-cap edge case

When the buffer is exactly at pendingTTLCap and a TTL record arrives for a key already in pendingTTL, the code takes the overflow branch (incrementing both pendingTTLOverflow and orphanTTLCount) rather than updating the existing entry. If the key is later claimed by a typed record, orphanTTLCount will have been incremented by 1 for a key that was actually buffered — a phantom orphan.

In practice Pebble snapshots don't emit duplicate !redis|ttl| records for the same key, so this is effectively unreachable today. But a one-line pre-check makes the edge case explicit and prevents overcounting in a corrupt snapshot:

func (r *RedisDB) parkUnknownTTL(userKey []byte, expireAtMs uint64) {
    uk := string(userKey)
    if _, alreadyBuffered := r.pendingTTL[uk]; alreadyBuffered {
        r.pendingTTL[uk] = expireAtMs // latest-wins update; no size change
        return
    }
    if len(r.pendingTTL) >= r.pendingTTLCap {
        r.pendingTTLOverflow++
        r.orphanTTLCount++
        return
    }
    r.pendingTTL[uk] = expireAtMs
}

Fix this →


Issues — Carried from prior rounds (still unaddressed in commit 5)

Medium — HandleZSetLegacyBlob docstring states wrong scan order for TTL

redis_zset.go:246–257 says:

!redis|zset| ALSO sorts BEFORE !redis|ttl|

This is incorrect. Comparing the first diverging byte after the shared !redis| prefix: t = 0x74 < z = 0x7A, so !redis|ttl| sorts before !redis|zset|. The real-world scan 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 properly. But the docstring at lines 246–250 also gets the mid-migration order claim half-wrong (it says !redis|zset| sorts before !redis|ttl|; the !redis|zset| < !zs|... part at line 246 is correct).

Fix this →

Low — TestRedisDB_ZSetLegacyBlobWithInlineTTL tests non-real-world scan order

The test docstring (line 779) repeats the wrong claim (!redis|zset| sorts before !redis|ttl|) and the test exercises the blob-first → TTL-second path (i.e., the HandleTTL redisKindZSet fast-path). The real-world ordering is TTL-first → blob-second (the pendingTTL path) — which is currently untested for legacy blobs.

A companion test is still needed:

func TestRedisDB_ZSetLegacyBlobTTLArrivesBeforeBlob(t *testing.T) {
    t.Parallel()
    db, root := newRedisDB(t)
    // Real-world order: !redis|ttl|k sorts before !redis|zset|k (t < z).
    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)

envelope["base64"] == "" compares an any-typed map value to the string "". A wrong-but-non-empty base64 payload still passes. Need a full round-trip decode (unchanged from Rounds 2–4):

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 in test helpers (lines 50, 67, 77, 90, 102)

Per CLAUDE.md: "Avoid //nolint — refactor instead." The five integer conversions in encodeZSetMetaValue, zsetMetaKey, zsetMemberKey, zsetScoreKey, and zsetMetaDeltaKey can be made obviously safe with a bounds panic (or putUserKeyLen helper as CodeRabbit suggested), eliminating the suppressions.

Nit — TestRedisDB_ZSetLegacyBlobRejectsMalformedKey has contradictory comments

The outer docstring (line 831) says "key with no trailing user-key bytes fails parse" but the inner comment (lines 837–840) says "empty user key is technically valid Redis … use a key that doesn't have the prefix." The test actually exercises the missing-prefix case, not an empty-user-key case. Rename to TestRedisDB_ZSetLegacyBlobRejectsMissingPrefix and remove the contradictory comment.


Confirmed correct across all 5 commits

  • pendingTTLCap default (1M entries, ~64 MiB) is documented and correctly applied ✓
  • WithPendingTTLCap(0) disables buffering; negative coercion to 0 is clean ✓
  • Finalize double-counts nothing: overflow path increments orphanTTLCount at intake; orphanTTLCount += len(pendingTTL) at Finalize adds only the buffered-but-unclaimed entries ✓
  • sawWide + markZSetWide() faithfully mirrors adapter/redis_compat_helpers.go:610-620
  • redisZSetLegacyProtoPrefix matches adapter/redis_storage_codec.go:15
  • NaN fail-closed in both HandleZSetMember and decodeZSetLegacyBlobValue
  • !redis|zset| < !zs|... ordering correct (mid-migration merge case) ✓
  • zsetState()claimPendingTTL() chain correctly handles TTL-before-zset ordering at the code level ✓

Summary

Commit 5's pendingTTL OOM bound is correct: the 1 << 20 cap, the WithPendingTTLCap override, and the three new tests are all well-constructed. The one behavioral gap is the duplicate-key-at-cap edge case in parkUnknownTTL (phantom orphan count on corrupt input, not reachable from a healthy Pebble snapshot).

From prior rounds, the docstring scan-order error (!redis|zset| sorts after, not before, !redis|ttl|) and the missing TestRedisDB_ZSetLegacyBlobTTLArrivesBeforeBlob test remain open — the code handles the real-world TTL-first ordering correctly via pendingTTL, but the comment documents the wrong path and the test exercises the non-production order.

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: 174456aed1

ℹ️ 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 thread internal/backup/redis_string.go Outdated
Comment on lines +416 to +422
if len(r.pendingTTL) >= r.pendingTTLCap {
// Cap reached: fall back to the immediate-orphan path so
// memory stays bounded. Already-buffered entries can still
// be drained by later state-inits.
r.pendingTTLOverflow++
r.orphanTTLCount++
return
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 Fail closed when pending TTL buffer reaches cap

When pendingTTL hits pendingTTLCap, this branch drops subsequent unknown-kind TTLs by counting them as orphans instead of buffering them. In real snapshot order (!redis|ttl| before !st|/!zs|/!stream|), those dropped entries can belong to valid set/zset/stream keys that are parsed later, so their expire_at_ms is permanently lost and restored data becomes non-expiring. This can happen on legitimate large datasets (e.g., more than 1,048,576 expiring wide-column keys with default cap), and the code only warns instead of preventing partial-corruption output.

Useful? React with 👍 / 👎.

Codex P1 on round 4: the r4 fix capped pendingTTL but silently
counted overflow entries as orphans. That's a data-loss path on
legitimate large datasets — in real Pebble snapshot order
(`!redis|ttl|` < `!st|`/`!stream|`/`!zs|`), an overflowed TTL
likely belongs to a wide-column key arriving later in the scan,
and dropping it permanently loses `expire_at_ms`. Restored data
ends up non-expiring without the operator noticing — only a
warning gets logged.

Fix: replace the silent-orphan fallback with fail-closed semantics.
Three modes determined by pendingTTLCap:

- cap > 0 and buffer NOT full: buffer the entry as before.
- cap == 0: counter-only mode (operator explicit opt-out;
  orphan-count without buffering).
- cap > 0 and buffer FULL: fail closed with new sentinel
  ErrPendingTTLBufferFull. Operator must raise the cap via
  WithPendingTTLCap or investigate the snapshot for corruption.

Caller audit (per /loop standing instruction):
- HandleTTL's redisKindUnknown branch now CAN return non-nil error
  in a new condition. Existing callers (all in test files) already
  check the error. Verified via
  `grep -rn '\.HandleTTL(' --include=*.go`: every call site has
  `if err := db.HandleTTL(...); err != nil { ... }`. No silent
  swallow.
- The cmd/elastickv-snapshot-decode driver (Phase 0a follow-up)
  will see the new error class and can either bail or call
  WithPendingTTLCap before retrying.
- Tests TestRedisDB_PendingTTLFailsClosedAtCap (renamed from
  TestRedisDB_PendingTTLBoundedByCap) and
  TestRedisDB_WithPendingTTLCapZeroOpts (renamed from
  TestRedisDB_WithPendingTTLCapZeroDisablesBuffering) pin the new
  semantics. The existing TestRedisDB_OrphanTTLCountedNotBuffered
  drives 10,000 TTLs with default cap=1M, well under the cap so
  no behavior change.
- parkUnknownTTL signature changed from `func(...)` to
  `func(...) error`. Single caller (HandleTTL) updated.

Self-review:
1. Data loss - opposite of the original concern: fail-closed
   prevents silent TTL drops. Operators who explicitly accept the
   lossy path opt in via WithPendingTTLCap(0).
2. Concurrency - no new shared state; orphanTTLCount unchanged
   for the cap=0 counter mode.
3. Performance - one comparison per unknown-kind TTL. Failed-
   closed path is a clean Go error return; no allocation
   amplification.
4. Consistency - matches the "fail closed" pattern called out in
   CLAUDE.md (replication/HLC/snapshot reads). Error type makes
   the failure mode legible to the caller.
5. Coverage - 2 renamed tests pin the new semantics. The error
   path is exercised at the cap+1 record; the counter-only mode
   stays lossy by explicit opt-in.
@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 11s —— View job


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

Tasks

  • Gather context from PR description and prior reviews (rounds 1–5)
  • Read commit 6 (800cc8c): fail closed on pendingTTL overflow
  • Read internal/backup/redis_string.go (parkUnknownTTL changes)
  • Read internal/backup/redis_zset.go (docstring / scan-order)
  • Read internal/backup/redis_zset_test.go (new tests, base64 assertion)
  • Check all outstanding issues from Round 5
  • Post comprehensive review

Commit 6 (800cc8c) — fail-closed on pendingTTL overflow

The Codex P1 finding from Round 5 is correctly addressed. The three-mode design is sound:

pendingTTLCap buffer state result
0 orphanTTLCount++, no error (counter-only opt-in)
> 0 not full buffer entry, return nil
> 0 full pendingTTLOverflow++, return ErrPendingTTLBufferFull

Key correctness properties verified:

  • ErrPendingTTLBufferFull is well-documented (lines 75–91) with recovery guidance pointing to WithPendingTTLCap.
  • pendingTTLOverflow is still tracked so Finalize's redis_orphan_ttl warning can surface both the count and the cap context (pending_ttl_buffer_overflow / pending_ttl_buffer_cap fields), letting operators distinguish corruption from an undersized cap.
  • HandleTTL's redisKindUnknown case propagates parkUnknownTTL's error to the caller. All test call sites already check HandleTTL errors.
  • TestRedisDB_PendingTTLFailsClosedAtCap: correctly asserts orphanTTLCount == 0 at intake (caller must handle the error, not Finalize).
  • TestRedisDB_WithPendingTTLCapZeroOpts: counter-only mode, no error, immediate increment.
  • TestRedisDB_WithPendingTTLCapNegativeCoercedToZero: input-sanitisation guard.

Issues

Low — parkUnknownTTL: false error on duplicate-key-at-cap (carried from Round 5, new shape)

The Round-5 concern changes shape under the fail-closed semantics. When the buffer is exactly at cap and a TTL record arrives for a key already in pendingTTL:

if len(r.pendingTTL) >= r.pendingTTLCap {  // true — key exists, len == cap
    r.pendingTTLOverflow++
    return ErrPendingTTLBufferFull          // ← wrong: key IS already buffered
}

The key is in the buffer (with the previous expiry) and will be claimed by claimPendingTTL when the typed record arrives. But the caller sees ErrPendingTTLBufferFull — a false error that may cause the snapshot decoder to abort the decode for a key that was actually handled correctly.

A healthy Pebble snapshot never emits duplicate !redis|ttl| records for the same key, so this only triggers on corrupt snapshots. Still, returning an error for something that succeeded is a correctness violation, and the fix is a one-liner:

func (r *RedisDB) parkUnknownTTL(userKey []byte, expireAtMs uint64) error {
    if r.pendingTTLCap == 0 {
        r.orphanTTLCount++
        return nil
    }
    uk := string(userKey)
    if _, alreadyBuffered := r.pendingTTL[uk]; alreadyBuffered {
        r.pendingTTL[uk] = expireAtMs // latest-wins; no size change
        return nil
    }
    if len(r.pendingTTL) >= r.pendingTTLCap {
        r.pendingTTLOverflow++
        return cockroachdberr.Wrapf(ErrPendingTTLBufferFull, ...)
    }
    r.pendingTTL[uk] = expireAtMs
    return nil
}

Fix this →

Medium — HandleZSetLegacyBlob docstring states wrong scan order for !redis|ttl| (carried from Round 3)

Lines 246–257 state (twice) that !redis|zset| sorts before !redis|ttl|. This is incorrect. Comparing the first diverging byte after the shared !redis| prefix: t = 0x74 < z = 0x7A, so !redis|ttl|k sorts before !redis|zset|k. The real snapshot 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 — zsetState()claimPendingTTL() handles this. But the comment documents the wrong path and creates false confidence that the redisKindZSet HandleTTL shortcut is what fires in production (it isn't — that only fires if a wide-column row arrived before the TTL, which would also be non-standard Pebble order).

Line 248 also gets the !redis|zset| < !zs|... ordering correct — only the !redis|ttl| ordering claim is wrong.

The TestRedisDB_ZSetLegacyBlobWithInlineTTL docstring (line 779–782) makes the same wrong claim, saying !redis|zset|<k> "sorts before !redis|ttl|."

Fix this →

Low — Missing test for real-world TTL-first ordering on legacy blobs (carried from Round 3)

TestRedisDB_ZSetLegacyBlobWithInlineTTL exercises blob-first-then-TTL (the redisKindZSet HandleTTL fast path). The actual Pebble ordering — !redis|ttl|k arrives before !redis|zset|k — goes through pendingTTLclaimPendingTTL(), which is untested for legacy blobs. A companion test would pin the real path:

func TestRedisDB_ZSetLegacyBlobTTLArrivesBeforeBlob(t *testing.T) {
    t.Parallel()
    db, root := newRedisDB(t)
    // Real-world Pebble order: !redis|ttl|k < !redis|zset|k (t < z).
    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 Round 2)

if envelope["base64"] == "" {

envelope["base64"] is typed as any. Comparing an any to a string literal "" with == compares the interface value to the string — this evaluates to false whenever the map value is non-nil (even if the string payload is "garbage"), so a wrong base64 payload still passes. The assertion should be:

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)
}

Add "bytes" and "encoding/base64" to the import block.

Fix this →


Nits (carried from prior rounds, still unaddressed)

//nolint:gosec suppressions in test helpers (lines 50, 67, 77, 90, 102) — five integer-to-uint casts in encodeZSetMetaValue, zsetMetaKey, zsetMemberKey, zsetScoreKey, zsetMetaDeltaKey. Per CLAUDE.md: "Avoid //nolint — refactor instead." A putUserKeyLen helper (as CodeRabbit proposed) or a bounds-panic before each cast removes all five suppressions cleanly.

TestRedisDB_ZSetLegacyBlobRejectsMalformedKey contradictory comment (lines 837–840) — the comment block says "key with no trailing user-key bytes — parser must still accept it (empty user key is technically valid Redis)" then immediately says "Use a key that doesn't have the prefix to trigger the parse failure." These contradict each other. Rename to TestRedisDB_ZSetLegacyBlobRejectsMissingPrefix and remove the contradictory first sentence.

Per-record []byte() allocations[]byte(RedisZSetMetaDeltaPrefix) / []byte(RedisZSetMetaPrefix) / []byte(RedisZSetMemberPrefix) at lines 132, 361, 364, 376 create a new allocation on every dispatched record. Hoisting to package-level var eliminates ~4 allocations per record in the snapshot hot path.


Confirmed correct in commit 6

  • Three-mode parkUnknownTTL is correct: cap==0 (counter-only), not-full (buffer), full (fail-closed) ✓
  • pendingTTLOverflow incremented before the error return so Finalize warning carries the count even if the caller swallows ErrPendingTTLBufferFull
  • TestRedisDB_PendingTTLFailsClosedAtCap correctly asserts orphanTTLCount == 0 at intake ✓
  • TestRedisDB_WithPendingTTLCapZeroOpts correctly asserts no error, immediate orphan count ✓
  • Rename from *_BoundedByCap / *_DisablesBuffering*_FailsClosedAtCap / *_CapZeroOpts is accurate ✓
  • All HandleTTL call sites already check the returned error ✓

Summary

Commit 6's fail-closed semantics are architecturally correct. The remaining open items — all carried since earlier rounds — are: a false error in parkUnknownTTL when a duplicate TTL arrives at cap (the entry IS buffered; returning an error is wrong), the wrong scan-order claim in the HandleZSetLegacyBlob docstring and test comment, the missing real-world TTL-first ordering test for legacy blobs, and the non-functional base64 assertion. The //nolint:gosec suppressions and contradictory test comment are style nits.

bootjp added a commit that referenced this pull request May 19, 2026
Mirror of PR #790 round 5 codex P1 fix: the cap-with-silent-orphan
fallback introduced in r3 is itself a data-loss path. In real
Pebble snapshot order (`!redis|ttl|` < `!st|`/`!stream|`/`!zs|`),
an overflowed TTL likely belongs to a wide-column key arriving
later in the scan; dropping it permanently loses `expire_at_ms`.

Fix: replace the silent-orphan fallback with fail-closed semantics.
Three modes:

- cap > 0, buffer NOT full: buffer the entry as before.
- cap == 0: counter-only mode (operator explicit opt-out).
- cap > 0, buffer FULL: return ErrPendingTTLBufferFull. Operator
  must raise WithPendingTTLCap or accept counter-only via cap=0.

Caller audit (per /loop standing instruction): same as the parallel
PR #790 r5 fix. HandleTTL's redisKindUnknown branch CAN return a
non-nil error in a new condition. All call sites check the error
(grep -rn '\.HandleTTL(' --include=*.go). parkUnknownTTL changes
signature from `func(...)` to `func(...) error`; single caller
(HandleTTL) updated.

Test renames:
- TestRedisDB_PendingTTLBoundedByCap → TestRedisDB_PendingTTLFailsClosedAtCap
- TestRedisDB_WithPendingTTLCapZeroDisablesBuffering → TestRedisDB_WithPendingTTLCapZeroOpts

Self-review: same shape as PR #790 r5 commit's review. Data loss
avoidance via fail-closed; semantics match CLAUDE.md's pattern
for replication/HLC/snapshot invariants.
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: 800cc8c16d

ℹ️ 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 thread internal/backup/redis_string.go Outdated
Comment on lines +444 to +456
if len(r.pendingTTL) >= r.pendingTTLCap {
// Fail closed: refuse to silently drop a TTL that may
// belong to a wide-column key arriving later in the
// snapshot scan. The operator should raise the cap
// (WithPendingTTLCap) or investigate the snapshot for
// corruption. We still increment pendingTTLOverflow so
// the Finalize warning surfaces the count even if the
// caller swallows the error.
r.pendingTTLOverflow++
return cockroachdberr.Wrapf(ErrPendingTTLBufferFull,
"buffer at cap=%d (user_key_len=%d)", r.pendingTTLCap, len(userKey))
}
r.pendingTTL[string(userKey)] = expireAtMs
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 Bound pending TTL buffer by bytes, not entry count

parkUnknownTTL copies each unknown-key TTL into pendingTTL as string(userKey), so memory usage scales with key length, but the guard only checks len(r.pendingTTL) against pendingTTLCap. With snapshot keys allowed up to 1 MiB, a relatively small number of large unknown TTL keys can consume hundreds of MiB or more before hitting the entry cap, so the intended OOM protection is ineffective in this path. This is especially risky on corrupted/adversarial snapshots where many unmatched TTL records are possible.

Useful? React with 👍 / 👎.

codex P1 on round 7: the 64 MiB defaultPendingTTLBytesCap turns
large-but-valid datasets into hard failures. Because
!redis|ttl| lex-sorts BEFORE !st|/!stream|/!zs|, ALL
expiring wide-column TTLs must be parked in pendingTTL before
any wide-column row arrives to drain them — the peak buffer
size equals sum((userKeyLen + 8) for all expiring wide-column
keys in the database). A real Redis with 20M expiring wide-
column keys × 50-byte user keys peaks at ~1.16 GiB.

The previous 64 MiB default would hard-fail with
ErrPendingTTLBufferFull on healthy snapshots. There is no
in-repo caller raising the ceiling (WithPendingTTLByteCap is
only exercised in tests), so the default IS the policy in
practice — and the cmd/elastickv-snapshot-decode binary (task
23) is still WIP, so operators have no override path yet.

Fix: raise default to 1 GiB. Sizing rationale:
- 1 GiB tolerates a 20M-expiring-wide-column-key workload at
  the average 50-byte user-key size.
- Still a hard OOM ceiling: adversarial snapshots with TB of
  1 MiB keys still fail closed (the byte-budget accounting is
  unchanged).
- Typical dump-host budget headroom is 16+ GiB, so 1 GiB is a
  small fraction of available memory.

Caller audit (per /loop standing instruction): no semantics
change on parkUnknownTTL — the constant value changed only.
All callers (HandleTTL redisKindUnknown branch, single site)
see the same fail-closed-at-cap semantic; only the cap value
shifts. Tests that exercise WithPendingTTLByteCap with
explicit (smaller) caps still work because they override the
default.

Test added:
- TestRedisDB_DefaultPendingTTLByteCapSurvives1GiBLoad pins
  the new constant invariant at 1 GiB and verifies NewRedisDB
  applies it.

Self-review:
1. Data loss — strictly safer than r6: previous default
   refused healthy snapshots; now accepts them while the
   byte budget protects against OOM.
2. Concurrency — no new shared state; constant change only.
3. Performance — no measurable impact.
4. Consistency — byte-budget accounting and fail-closed
   semantics unchanged; only the threshold shifts.
5. Coverage — new sentinel test pins the constant; all other
   pendingTTL tests still pass with -race.
bootjp added a commit that referenced this pull request May 20, 2026
… GiB

Mirror of PR #790 r8 codex P1 fix. The 64 MiB
defaultPendingTTLBytesCap (r5) turns large-but-valid datasets
into hard failures. Because !redis|ttl| lex-sorts BEFORE
!st|/!stream|/!zs|, ALL expiring wide-column TTLs must be
parked in pendingTTL before any wide-column row arrives to
drain them — peak buffer size = sum((userKeyLen + 8) for all
expiring wide-column keys). A real Redis with 20M expiring
wide-column keys × 50-byte user keys peaks at ~1.16 GiB.

The previous 64 MiB default would hard-fail with
ErrPendingTTLBufferFull on healthy snapshots. There is no
in-repo caller raising the ceiling (WithPendingTTLByteCap is
test-only), so the default IS the policy.

Fix: raise default to 1 GiB. The byte-budget accounting and
fail-closed semantics are unchanged; only the threshold
shifts.

Caller audit (per /loop standing instruction): constant value
change only. parkUnknownTTL signature, semantics, and single
caller (HandleTTL redisKindUnknown branch) all unchanged.

Test added (same as PR #790 r8):
- TestRedisDB_DefaultPendingTTLByteCapSurvives1GiBLoad pins
  the new constant at 1 GiB and verifies NewRedisDB applies
  it.

Self-review: same shape as PR #790 r8 commit's review (safer
than r6, no concurrency change, no perf impact, accounting
unchanged, sentinel test pins the constant).
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 20, 2026

@claude review

Round-8 (commit f8ce906e): codex P1 — raise default pendingTTL cap from 64 MiB to 1 GiB.

codex P1 — large-but-valid snapshots hard-fail at default cap

Because !redis|ttl| lex-sorts BEFORE !st|/!stream|/!zs|, ALL expiring wide-column TTLs are parked in pendingTTL before any wide-column row arrives to drain them. Peak buffer size = sum((userKeyLen + 8) for all expiring wide-column keys). A real Redis with 20M expiring wide-column keys × 50-byte user keys peaks at ~1.16 GiB. The old 64 MiB default would hard-fail with ErrPendingTTLBufferFull on healthy snapshots — and there's no in-repo caller raising the ceiling (WithPendingTTLByteCap is test-only), so the default IS the policy.

Fix

Raise defaultPendingTTLBytesCap to 1 GiB (1 << 30). Sizing rationale documented inline:

  • Tolerates a 20M-expiring-wide-column-key workload at 50-byte average key size.
  • Still a hard OOM ceiling — adversarial snapshots with TB of 1 MiB keys still fail closed.
  • Typical dump-host budget headroom is 16+ GiB, so 1 GiB is a small fraction.

Caller audit

Constant value change only — no semantics shift on parkUnknownTTL or any caller. Same fail-closed-at-cap behavior; just the threshold moved.

Test added

TestRedisDB_DefaultPendingTTLByteCapSurvives1GiBLoad pins the constant invariant and verifies NewRedisDB applies it. Sentinel guard against accidental regression.

go test -race ./internal/backup/ passes; golangci-lint clean.

@claude
Copy link
Copy Markdown

claude Bot commented May 20, 2026

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


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

Tasks

  • Gather context from PR description and prior reviews (rounds 1–8)
  • Read internal/backup/redis_string.go (commit 9: raise default cap to 1 GiB)
  • Read internal/backup/redis_string_test.go (new sentinel test)
  • Read internal/backup/redis_zset.go (docstring, outstanding items)
  • Read internal/backup/redis_zset_test.go (base64 assertion, legacy TTL test)
  • Check all outstanding issues from Round 8
  • Post comprehensive review

Commit 9 (f8ce906e) — raise default pendingTTL cap from 64 MiB to 1 GiB

The intent is correct: the old 64 MiB cap would hard-fail on healthy large snapshots because all expiring set/zset/stream TTLs are buffered in pendingTTL before any wide-column row drains them. The constant, constructor wiring, and the sentinel test are all correct.


Issues

Medium — Sizing claim is internally inconsistent: the documented workload exceeds the cap

Both the constant comment (redis_string.go:283–286) and the ErrPendingTTLBufferFull comment (redis_string.go:92–95) state that the 1 GiB cap is "sized to accommodate" / "tolerates" a "20M expiring wide-column keys × 50-byte user keys" workload that "peaks at ~1.16 GiB".

But 1.16 GiB > 1 GiB — the cap would fail-close at entry ~18.5M (1,073,741,824 ÷ 58 = 18,513,652). The PR trigger itself makes the same claim ("tolerates a 20M-expiring-wide-column-key workload at 50-byte average key size") while acknowledging the peak is ~1.16 GiB. An operator who trusts this sizing guidance and does NOT raise WithPendingTTLByteCap on a 20M-expiring-zset database will still encounter ErrPendingTTLBufferFull.

The correct statement for either the constant or the error:

// The 1 GiB cap accommodates up to ~18.5M expiring wide-column
// keys at 50-byte average user key length (18.5M × 58 bytes ≈ 1 GiB).
// For larger deployments or longer average keys, raise via
// WithPendingTTLByteCap. The old 64 MiB default hard-failed at
// ~1.1M 50-byte keys.

Fix this →

Nit — Test name implies load simulation, but only checks the constant

TestRedisDB_DefaultPendingTTLByteCapSurvives1GiBLoad (line 826) only pins the constant value (defaultPendingTTLBytesCap == 1 << 30) and NewRedisDB's wiring — it does not actually buffer or drain 1 GiB of data. The name is fine as a sentinel-only test but "Survives1GiBLoad" implies a load test. A more accurate name: TestRedisDB_DefaultPendingTTLByteCapIs1GiB or TestRedisDB_DefaultPendingTTLByteCap_NewRedisDBWiresDefault.


Issues — Carried from prior rounds (none addressed in commit 9)

Medium — HandleZSetLegacyBlob docstring states wrong scan order for TTL (carried from R3–R8)

redis_zset.go:246–257 states twice that !redis|zset| sorts before !redis|ttl|:

The legacy prefix !redis|zset| lex-sorts BEFORE !zs|... and BEFORE !redis|ttl|

!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. Both share the !redis| prefix; the first diverging byte is t (0x74, in ttl) vs z (0x7A, in zset) — and 0x74 < 0x7A, so !redis|ttl| sorts before !redis|zset| in Pebble's lexicographic order. The real snapshot 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 — zsetState()claimPendingTTL() handles this. Only the comment documents the wrong path and creates false confidence that the redisKindZSet HandleTTL shortcut fires in production.

The same wrong claim appears in TestRedisDB_ZSetLegacyBlobWithInlineTTL's docstring (lines 779–782).

Fix this →

Low — Missing test for real-world TTL-first ordering on legacy blobs (carried from R3–R8)

TestRedisDB_ZSetLegacyBlobWithInlineTTL exercises blob-first → TTL-second (the redisKindZSet HandleTTL shortcut). In a real Pebble scan TTL arrives before the blob (!redis|ttl| < !redis|zset|), which takes the pendingTTLclaimPendingTTL() path. That path is untested for legacy blobs. Proposed companion test:

func TestRedisDB_ZSetLegacyBlobTTLArrivesBeforeBlob(t *testing.T) {
    t.Parallel()
    db, root := newRedisDB(t)
    // Real Pebble order: !redis|ttl|k < !redis|zset|k (t=0x74 < z=0x7A).
    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 R2–R8)

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

envelope["base64"] is typed as any. Comparing any to the string "" with == checks interface equality — when the map value is any non-nil string (including a corrupt payload like "garbage"), this evaluates to false and the test passes. A correct round-trip assertion:

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)
}

Add "bytes" and "encoding/base64" to the import block.

Fix this →


Nits (carried from prior rounds, still open)

//nolint:gosec in test helpers (lines 50, 67, 77, 90, 102 in redis_zset_test.go) — per CLAUDE.md: "Avoid //nolint — refactor instead." Five uint32(len(userKey)) / uint64(memberCount) casts in encodeZSetMetaValue, zsetMetaKey, zsetMemberKey, zsetScoreKey, zsetMetaDeltaKey can be made obviously safe with a putUserKeyLen helper or explicit bounds panics.

TestRedisDB_ZSetLegacyBlobRejectsMalformedKey contradictory comments (lines 837–840) — the comment says "empty user key is technically valid Redis" then immediately says "Use a key that doesn't have the prefix to trigger the parse failure." The test uses "not-the-right-prefix|k" (prefix mismatch, not an empty user key). Rename to TestRedisDB_ZSetLegacyBlobRejectsMissingPrefix and remove the contradictory sentence.


Confirmed correct in commit 9

  • defaultPendingTTLBytesCap = 1 << 30 (1 GiB) correctly addresses the 64 MiB round-7 regression ✓
  • NewRedisDB wires defaultPendingTTLBytesCap at line 318 ✓
  • Sentinel test pins both the constant value and the constructor wiring ✓
  • ErrPendingTTLBufferFull comment updated to reference 1 GiB (line 92) ✓
  • All parkUnknownTTL logic from commit 8 (duplicate-key short-circuit) is intact ✓
  • sawWide + markZSetWide() faithfully mirror adapter/redis_compat_helpers.go:610-620
  • NaN fail-closed in both HandleZSetMember and decodeZSetLegacyBlobValue

Summary

Commit 9's intent is correct — the 64 MiB cap was too small for production workloads. The implementation is mechanically correct: constant raised, constructor wired, sentinel test added. The one new issue is that the sizing claim in both the constant comment and ErrPendingTTLBufferFull says the 1 GiB cap "accommodates" a 20M × 50-byte workload that actually requires ~1.08 GiB — exceeding the cap. The documented workload fails at ~18.5M keys. Three issues carry from prior rounds (wrong legacy-blob TTL scan-order comment, missing real-world-order test, non-functional base64 assertion); the //nolint:gosec suppressions and contradictory MalformedKey test comment remain as 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: f8ce906ecf

ℹ️ 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 thread internal/backup/redis_zset.go Outdated
// 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 zset members until member rows are seen

Calling markZSetWide from HandleZSetMeta drops legacy !redis|zset| members as soon as a !zs|meta| row appears, even if there are no !zs|mem| rows. In that snapshot shape, the live read path still serves the legacy blob, so the backup emits an empty/stale zset and loses user-visible data on restore; fresh evidence is that loadZSetAt in adapter/redis_compat_helpers.go (lines 610-623) switches to wide-column data only when a member scan finds at least one !zs|mem| key.

Useful? React with 👍 / 👎.

…a-only

codex P2 on round 7: data-loss bug. HandleZSetMeta calling
markZSetWide(st) dropped any legacy `!redis|zset|<k>` members as
soon as a `!zs|meta|<k>` row appeared, even when no `!zs|mem|`
rows ever arrived. But the live read path
(adapter/redis_compat_helpers.go:610-621) switches to the wide-
column layout ONLY when the `!zs|mem|` prefix scan finds at
least one row — a snapshot containing only `!zs|meta|<k>`
(no member rows) continues to serve the legacy blob to live
clients. The backup encoder dropping the legacy members on
meta-alone produced empty/stale zsets on restore — actual
user-visible data loss.

Although codex labelled this P2, the substance is data-loss on
restore for a legitimate snapshot shape (meta-without-members
can arise transiently during migration, after partial scans, or
on shards where members were pruned but meta lingered).

Fix: remove `r.markZSetWide(st)` from HandleZSetMeta. Only
HandleZSetMember triggers the supersedence rule, matching the
live read path's "switch to wide-column only when a member row
exists" semantic. Meta-alone is now treated as bookkeeping
(declaredLen + metaSeen for length-mismatch warnings); it does
NOT invalidate the legacy blob.

Caller audit (per /loop standing instruction):
  grep -rn 'markZSetWide\|HandleZSetMeta\b' --include='*.go'
  → markZSetWide has exactly one caller now: HandleZSetMember
    (redis_zset.go:198). The supersedence rule still fires on
    the first wide-column MEMBER row, matching the live read
    path. HandleZSetMeta callers in tests are unaffected:
    those that follow up with HandleZSetMember still observe
    markZSetWide via the member row; the new test
    TestRedisDB_ZSetMetaAlonePreservesLegacyBlob pins the
    meta-only case explicitly.

Test changes:
- TestRedisDB_ZSetMetaAloneEvictsLegacyBlob (asserted the OLD
  buggy behavior) → renamed and inverted to
  TestRedisDB_ZSetMetaAlonePreservesLegacyBlob (asserts the
  correct behavior). Test now seeds a 2-member legacy blob,
  emits meta-only with declaredLen=2, finalizes, and asserts
  both members survive in the output JSON.
- Other tests that combine HandleZSetMeta + HandleZSetMember
  still pass because HandleZSetMember triggers markZSetWide
  on the first member row.

Self-review:
1. Data loss — STRICTLY safer: previously the encoder dropped
   members that the live read still served; now meta-only
   preserves the legacy blob. Wide-column member rows still
   correctly supersede legacy on first observation
   (TestRedisDB_ZSetWideColumnSupersedesLegacyBlob still passes).
2. Concurrency — no new shared state; behavior change only.
3. Performance — one less map-clear call on meta-only snapshots.
4. Consistency — encoder semantics now MATCH the live read
   path's loadZSetAt at adapter/redis_compat_helpers.go:610-621.
   This is the alignment the rest of the legacy/wide-column
   handling is designed around.
5. Coverage — TestRedisDB_ZSetMetaAlonePreservesLegacyBlob
   pins the new behavior. TestRedisDB_ZSetWideColumnSupersedesLegacyBlob
   (member row triggers supersedence) and
   TestRedisDB_ZSetLegacyBlobAfterWideRowsIsDropped (reverse
   order) still pass.
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 20, 2026

@claude review

Round-9 (commit 060ce8a9): codex P2 — preserve legacy zset members on meta-only.

codex P2 — data-loss on meta-only snapshots

HandleZSetMeta was calling markZSetWide(st) which dropped any legacy !redis|zset|<k> members as soon as a !zs|meta|<k> row appeared, even without any !zs|mem| rows. But the live read (adapter/redis_compat_helpers.go:610-621) switches to wide-column ONLY when the !zs|mem| scan finds at least one row — a meta-only snapshot continues to serve the legacy blob to live clients. The encoder dropping legacy members on meta-alone produced empty/stale zsets on restore (actual user-visible data loss).

Although codex labelled this P2, the substance is data-loss for legitimate meta-without-members snapshot shapes.

Fix

Remove r.markZSetWide(st) from HandleZSetMeta. Only HandleZSetMember triggers supersedence — matching the live read's "switch only on member-row presence" semantic.

Caller audit

grep -rn 'markZSetWide\|HandleZSetMeta\b' --include='*.go' confirms markZSetWide has exactly one remaining caller (HandleZSetMember, redis_zset.go:198). Tests combining meta + member still trigger supersedence via the member row.

Test changes

  • TestRedisDB_ZSetMetaAloneEvictsLegacyBlob (asserted the OLD buggy behavior) → renamed and inverted to TestRedisDB_ZSetMetaAlonePreservesLegacyBlob. Seeds 2-member legacy blob, emits meta-only, asserts both members survive.
  • TestRedisDB_ZSetWideColumnSupersedesLegacyBlob (member row triggers supersedence) and TestRedisDB_ZSetLegacyBlobAfterWideRowsIsDropped still pass.

go test -race ./internal/backup/ passes; golangci-lint clean.

@claude
Copy link
Copy Markdown

claude Bot commented May 20, 2026

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


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

Tasks

  • Load tools
  • Read internal/backup/redis_zset.go (commit 10: meta-only fix)
  • Read internal/backup/redis_zset_test.go (test rename + invert)
  • Check adapter/redis_compat_helpers.go for live-read semantics
  • Cross-check all outstanding issues from Round 9
  • Post comprehensive review

Commit 10 (060ce8a) — preserve legacy zset members on meta-only

The fix is correct. Removing r.markZSetWide(st) from HandleZSetMeta aligns the encoder with the live read path at adapter/redis_compat_helpers.go:610-621:

func (r *RedisServer) loadZSetAt(ctx context.Context, key []byte, readTS uint64) (redisZSetValue, bool, error) {
    prefix := store.ZSetMemberScanPrefix(key)
    end := store.PrefixScanEnd(prefix)
    kvs, err := r.store.ScanAt(ctx, prefix, end, 1, readTS)
    ...
    if len(kvs) > 0 {           // ← wide-column only when member rows exist
        val, loadErr := r.loadZSetMembersAt(ctx, key, readTS)
        return val, true, loadErr
    }
    // Legacy blob fallback.
    raw, err := r.store.GetAt(ctx, redisZSetKey(key), readTS)
    ...
}

The live side reads member rows (!zs|mem|) to decide the code path — !zs|meta| alone is never consulted as the switch. The encoder now correctly mirrors this: meta-alone leaves legacy members intact; only a !zs|mem| row (via HandleZSetMembermarkZSetWide) evicts them.

Scan-order consistency verified: !redis|zset|k (r=0x72) sorts before !zs|meta|<len>k (z=0x7A), so in a real Pebble scan the legacy blob always arrives before the wide-column meta row — exactly the order the test exercises (blob → meta → Finalize). ✓

Test rename/inversion is correct:

  • Old TestRedisDB_ZSetMetaAloneEvictsLegacyBlob: seeded 1 stale member, declared Len=0, asserted 0 members survived — this was the OLD buggy behavior
  • New TestRedisDB_ZSetMetaAlonePreservesLegacyBlob: seeds 2 {kept:1, also-kept:2.5} members, declares Len=2, asserts both survive with correct scores ✓

TestRedisDB_ZSetWideColumnSupersedesLegacyBlob still correct: supersedence is still triggered by HandleZSetMembermarkZSetWide. The meta row alone no longer triggers it, but the member row does. ✓


Nit — Test uses declaredLen=2 to suppress a length-mismatch warning

TestRedisDB_ZSetMetaAlonePreservesLegacyBlob calls encodeZSetMetaValue(2) which numerically matches the 2 legacy members. In a real meta-without-members snapshot, the declaredLen from !zs|meta| is the intended wide-column member count — there's no reason it would equal the number of legacy blob members. Using encodeZSetMetaValue(99) would make the test more realistic (and implicitly tests that Finalize still emits the correct members despite a mismatch warning). A comment explaining the 2 choice would at minimum make the intent explicit:

// Use declaredLen=2 to match the legacy member count and avoid
// a spurious redis_zset_length_mismatch warning in this test;
// the warning behavior on mismatch is separately tested.
if err := db.HandleZSetMeta(zsetMetaKey("k"), encodeZSetMetaValue(2)); err != nil {

This is genuinely a nit — the test pins the correct behavior regardless. But a future reader might wonder why the meta count was chosen to match the legacy count.


Issues — Carried from prior rounds (all still open after commit 10)

Medium — HandleZSetLegacyBlob docstring states wrong scan order for !redis|ttl| (carried from R3–R9)

redis_zset.go:251 and redis_zset.go:256 say (twice) that !redis|zset| sorts BEFORE !redis|ttl|:

// The legacy prefix `!redis|zset|` lex-sorts BEFORE `!zs|...` and
// BEFORE `!redis|ttl|`, …
//
// `!redis|zset|` ALSO sorts BEFORE `!redis|ttl|`, so an inline TTL …

This is incorrect. Both share the !redis| prefix; the first diverging byte is t (0x74, in ttl) vs z (0x7A, in zset). Since 0x74 < 0x7A, !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 → redisKindUnknown → pendingTTL["k"] = ms
!redis|zset|k  → HandleZSetLegacyBlob → zsetState() → claimPendingTTL() → st.expireAtMs = ms

The code is correct — zsetState()claimPendingTTL() handles this properly. But the comment documents the wrong path and creates false confidence that the redisKindZSet HandleTTL shortcut fires in production. The same wrong claim appears in TestRedisDB_ZSetLegacyBlobWithInlineTTL's docstring at line 800.

Fix this →

Medium — Sizing claim says 1 GiB cap accommodates a workload that exceeds it (carried from R9)

redis_string.go:283–286 and redis_string.go:93–94 both state that the 1 GiB cap accommodates a "20M expiring wide-column keys × 50-byte user keys" workload "peaking at ~1.16 GiB". But 1.16 GiB > 1.0 GiB — the cap fails-close at ~18.5M keys (1,073,741,824 ÷ 58 ≈ 18.5M). Operators who trust this guidance and don't raise WithPendingTTLByteCap on a 20M-key database will still hit ErrPendingTTLBufferFull.

Correct claim: the 1 GiB cap accommodates up to ~18.5M expiring wide-column keys at 50-byte average user key length. The 20M reference should either be replaced with ~18.5M or paired with a note that it exceeds the default cap and requires WithPendingTTLByteCap.

Fix this →

Low — Missing test for real-world TTL-first ordering on legacy blobs (carried from R3–R9)

TestRedisDB_ZSetLegacyBlobWithInlineTTL exercises blob-first → TTL-second (the redisKindZSet HandleTTL fast path). In a real Pebble scan !redis|ttl|k arrives BEFORE !redis|zset|k (since t < z), taking the pendingTTLclaimPendingTTL() path. That path is tested for wide-column records (TestRedisDB_ZSetTTLArrivesBeforeRows) but not for legacy blobs:

func TestRedisDB_ZSetLegacyBlobTTLArrivesBeforeBlob(t *testing.T) {
    t.Parallel()
    db, root := newRedisDB(t)
    // Real Pebble order: !redis|ttl|k < !redis|zset|k (t=0x74 < z=0x7A).
    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, carried from R2–R9)

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

envelope["base64"] is typed as any. Comparing any to the string "" with == performs interface comparison — this evaluates to false (not equal) whenever the value is a non-nil string, including a corrupt payload like "garbage". A correct round-trip assertion:

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)
}

Add "bytes" and "encoding/base64" to the import block.

Fix this →


Nits (carried from prior rounds, still open)

//nolint:gosec in test helpers (lines 50, 67, 77, 90, 102) — per CLAUDE.md: "Avoid //nolint — refactor instead." The five uint32(len(userKey)) / uint64(memberCount) casts in encodeZSetMetaValue, zsetMetaKey, zsetMemberKey, zsetScoreKey, zsetMetaDeltaKey can be made obviously safe with a bounds panic before each cast (or a putUserKeyLen helper as CodeRabbit proposed), eliminating all five suppressions.

TestRedisDB_ZSetLegacyBlobRejectsMalformedKey contradictory comment (lines 858–861) — the comment says "key with no trailing user-key bytes — parser must still accept it (empty user key is technically valid Redis)" then contradicts itself: "Use a key that doesn't have the prefix to trigger the parse failure." The test uses "not-the-right-prefix|k" (prefix mismatch, not an empty user key). Rename to TestRedisDB_ZSetLegacyBlobRejectsMissingPrefix and remove the contradictory sentence.


Confirmed correct in commit 10

  • HandleZSetMeta no longer calls markZSetWide(st) — meta-alone is now correctly treated as bookkeeping only ✓
  • HandleZSetMember still calls markZSetWide(st) on the first wide-column member row, matching the live read path ✓
  • TestRedisDB_ZSetWideColumnSupersedesLegacyBlob and TestRedisDB_ZSetLegacyBlobAfterWideRowsIsDropped still pass (supersedence still fires on first member row) ✓
  • TestRedisDB_ZSetMetaAlonePreservesLegacyBlob correctly pins the new behavior: 2 legacy members, meta-only, both survive Finalize ✓
  • Scan ordering in the test (blob before meta) matches real Pebble order (!redis|zset| < !zs|meta| since r < z) ✓
  • adapter/redis_compat_helpers.go:610-621 confirms the live read path only uses wide-column when len(kvs) > 0 from a !zs|mem| scan ✓

Summary

Commit 10 is a correct data-loss fix. Removing markZSetWide from HandleZSetMeta precisely matches the live read path's "switch to wide-column only on first member row" contract, and the test rename/inversion correctly pins the new behavior. The fix is strictly safer than before.

The three open issues all predate commit 10: the wrong scan-order claim in the HandleZSetLegacyBlob docstring and TestRedisDB_ZSetLegacyBlobWithInlineTTL docstring (code is correct, comment is wrong), the inflated 20M sizing claim (the cap covers ~18.5M at 50 bytes), and the missing real-world TTL-first-then-blob test. The base64 shallow assertion and the //nolint:gosec suppressions remain as well.

…+ missing test

Three carryover items from claude reviews r3-r10, none blocking
merge by themselves but the user has asked for them to be cleaned
up. All three are doc/coverage; no code semantics change.

(1) HandleZSetLegacyBlob docstring claimed `!redis|zset|` sorts
BEFORE `!redis|ttl|`. Wrong: 'z'=0x7A > 't'=0x74 on the byte
after the shared `!redis|` prefix, so `!redis|ttl|` sorts BEFORE
`!redis|zset|` in natural Pebble order. The code was always
correct (uses claimPendingTTL to drain the pre-arrived TTL); only
the doc misled readers. Same wrong claim in
TestRedisDB_ZSetLegacyBlobWithInlineTTL's docstring.

(2) defaultPendingTTLBytesCap doc + ErrPendingTTLBufferFull doc
both said the 1 GiB cap "accommodates" / "tolerates" a 20M-
expiring-wide-column-key workload "peaking at ~1.16 GiB". But
1.16 GiB > 1 GiB — the cap fails-closed at ~18.5M keys
(1,073,741,824 / 58 ≈ 18,513,652). Operators trusting the
guidance would hit ErrPendingTTLBufferFull. Corrected both
docstrings to state the actual ~18.5M ceiling and note that
larger deployments must raise the cap.

(3) TestRedisDB_ZSetLegacyBlobWithInlineTTL exercised only
blob-first → TTL-second (the redisKindZSet HandleTTL fast-path),
which is the REVERSE of natural Pebble order. Added
TestRedisDB_ZSetLegacyBlobTTLArrivesBeforeBlob to cover the
production code path: TTL first → parked in pendingTTL as
redisKindUnknown → HandleZSetLegacyBlob → zsetState() drains via
claimPendingTTL. Pins both the expireAtMs round-trip and the
member survival.

Caller audit (per /loop standing instruction): documentation
change only on (1) and (2); no semantics change. Test addition
in (3) is purely additive. No caller-side audit needed.

Self-review:
1. Data loss — none. Docs and a new test only.
2. Concurrency — none.
3. Performance — none.
4. Consistency — docs now accurately describe runtime behavior.
   The sizing claim and scan-order claim are factually correct.
5. Coverage — new test pins the natural Pebble order for legacy
   blobs with TTL; previously only the reverse order was
   covered. Existing tests still pass with -race.
bootjp added a commit that referenced this pull request May 20, 2026
…ction

Mirror of PR #790 r11 sizing-claim correction. The 1 GiB default
cap docstring (defaultPendingTTLBytesCap + ErrPendingTTLBufferFull)
said the cap "accommodates" a "20M expiring wide-column keys x
50-byte user keys" workload "peaking at ~1.16 GiB". But
1.16 GiB > 1 GiB — the cap fails closed at ~18.5M keys
(1,073,741,824 / 58 ≈ 18,513,652). Operators trusting the
guidance would hit ErrPendingTTLBufferFull on legitimately-
healthy 20M+ snapshots.

Fix: corrected both docstrings to state the actual ~18.5M
ceiling and note that larger deployments must raise the cap
via WithPendingTTLByteCap. The 1 GiB constant value itself is
unchanged.

Caller audit (per /loop standing instruction): documentation
change only. No semantics change in parkUnknownTTL,
claimPendingTTL, or any caller. The constant value is
unchanged.

Self-review:
1. Data loss — none. Docs only.
2. Concurrency — none.
3. Performance — none.
4. Consistency — sizing claim now accurate. Operators can
   reason about the threshold without doing the arithmetic.
5. Coverage — no behavior change; existing tests cover the
   constant.
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 20, 2026

@claude review

Round-11 (commit 6f1f0716): claude r9/r10 carryover items addressed.

3 carryover items

Medium — HandleZSetLegacyBlob docstring scan-order claim (carried R3-R10)

Wrong claim: "!redis|zset| sorts BEFORE !redis|ttl|". Actual: 'z'=0x7A > 't'=0x74 on the diverging byte after the shared !redis| prefix, so !redis|ttl| sorts BEFORE !redis|zset| in natural Pebble order. The code was always correct (uses claimPendingTTL to drain the pre-arrived TTL); only the doc misled. Same wrong claim in TestRedisDB_ZSetLegacyBlobWithInlineTTL docstring — both corrected.

Medium — sizing claim says 20M but cap fits only ~18.5M (carried R9)

Both defaultPendingTTLBytesCap and ErrPendingTTLBufferFull docstrings said 1 GiB cap accommodates a 20M-key workload "peaking at ~1.16 GiB". But 1.16 GiB > 1 GiB → operators trusting the guidance would hit ErrPendingTTLBufferFull. Corrected: cap holds ~18.5M expiring wide-column keys at 50-byte avg (1,073,741,824 / 58 ≈ 18,513,652). Constant value unchanged.

Low — missing test for natural Pebble order on legacy blobs (carried R3-R10)

Added TestRedisDB_ZSetLegacyBlobTTLArrivesBeforeBlob: TTL first → parked in pendingTTL → HandleZSetLegacyBlobzsetState() drains via claimPendingTTL. Pins both the expire_at_ms round-trip and member survival. TestRedisDB_ZSetLegacyBlobWithInlineTTL remains for the reverse-order fast-path (now correctly documented as non-natural).

Caller audit

Doc-only changes for (1) and (2). Test addition for (3) is purely additive. No semantics change anywhere; no caller-side audit needed.

go test -race ./internal/backup/ passes; golangci-lint clean.

@claude
Copy link
Copy Markdown

claude Bot commented May 20, 2026

Claude finished @bootjp's task in 1m 56s —— View job


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

Tasks

  • Load tools
  • Read internal/backup/redis_zset.go (docstring corrections)
  • Read internal/backup/redis_zset_test.go (new test + docstring fix)
  • Read internal/backup/redis_string.go (sizing claim corrections)
  • Check outstanding items from Round 10
  • Post review

Commit 11 (6f1f071) — docstring corrections + missing legacy-blob TTL test

All three carryover items are addressed. Properties verified:

Item 1 — HandleZSetLegacyBlob docstring scan-order claim (lines 250–269)

The wrong "sorts BEFORE !redis|ttl|" claim is replaced with the correct explanation: t=0x74 < z=0x7A in the byte after the shared !redis| prefix, so !redis|ttl|k sorts before !redis|zset|k. The production sequence (TTL → pendingTTL, then blob → claimPendingTTL) is now documented accurately. ✓

The phrasing at lines 257–259 is slightly awkward ("0x72='r' < 0x74='t' for the byte after the shared !redis| prefix would be the wrong direction; the actual comparison is byte 'z'=0x7A > 't'=0x74") — a negative-reasoning-then-correction style that requires two passes to understand. A direct statement ("both share !redis|; the next byte is 'z'=0x7A vs 't'=0x74; 0x74 < 0x7A so !redis|ttl| sorts first") would be clearer, but the factual content is now correct. Nit only.

Item 2 — TestRedisDB_ZSetLegacyBlobWithInlineTTL docstring (lines 798–807)

Now correctly documents this as the non-natural scan order and explicitly cross-references TestRedisDB_ZSetLegacyBlobTTLArrivesBeforeBlob for the production path. ✓

Item 3 — Sizing claim corrected (redis_string.go)

Both ErrPendingTTLBufferFull (lines 92–100) and defaultPendingTTLBytesCap (lines 277–295) now correctly state the cap holds ~18.5M expiring wide-column keys at 50-byte average (1,073,741,824 / 58 ≈ 18,513,652). The old incorrect "20M / ~1.16 GiB" claim is gone from both sites. ✓

New test TestRedisDB_ZSetLegacyBlobTTLArrivesBeforeBlob (lines 827–862)

Correctly exercises the production code path:

  1. HandleTTL([]byte("k"), ...)redisKindUnknownpendingTTL["k"] = ms
  2. HandleZSetLegacyBlob(...)zsetState("k")claimPendingTTL("k")st.expireAtMs = ms
  3. Asserts both expire_at_ms round-trip and member survival ✓

The companion TestRedisDB_ZSetLegacyBlobWithInlineTTL is kept and now correctly labelled as the non-natural reverse-order path. Good split.


Issues — Carried from prior rounds (none addressed in commit 11)

Low — Base64 assertion is non-functional (redis_zset_test.go:346, carried R2–R10)

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

envelope["base64"] is typed as any. The == comparison against the string "" performs interface comparison — this evaluates to false (not equal) whenever the value is a non-nil string, including a corrupt payload. A correct round-trip assertion:

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)
}

Add "bytes" and "encoding/base64" to the import block.

Fix this →


Nits (carried from prior rounds, still open)

//nolint:gosec in test helpers (lines 50, 67, 77, 90, 102) — per CLAUDE.md: "Avoid //nolint — refactor instead." The five uint32(len(userKey)) / uint64(memberCount) casts in encodeZSetMetaValue, zsetMetaKey, zsetMemberKey, zsetScoreKey, zsetMetaDeltaKey can be made obviously safe by adding a bounds panic before each cast (or a putUserKeyLen helper as CodeRabbit proposed), eliminating all five suppressions.

TestRedisDB_ZSetLegacyBlobRejectsMalformedKey contradictory comment (lines 899–902) — the outer comment says "key with no trailing user-key bytes — parser must still accept it (empty user key is technically valid Redis)" but then immediately says "Use a key that doesn't have the prefix to trigger the parse failure." The test uses "not-the-right-prefix|k" (prefix mismatch, not an empty user key). Rename to TestRedisDB_ZSetLegacyBlobRejectsMissingPrefix and remove the contradictory first sentence.


Confirmed correct in commit 11

  • HandleZSetLegacyBlob docstring: scan-order claim corrected; production sequence documented accurately ✓
  • TestRedisDB_ZSetLegacyBlobWithInlineTTL docstring: labelled as non-natural order, cross-references companion test ✓
  • ErrPendingTTLBufferFull comment: ~18.5M not 20M
  • defaultPendingTTLBytesCap comment: ~18.5M not 20M, same arithmetic shown ✓
  • TestRedisDB_ZSetLegacyBlobTTLArrivesBeforeBlob: exercises real production path (TTL-first → pendingTTL → claimPendingTTL); asserts both expire_at_ms and member survival ✓
  • All prior invariants (sawWide/markZSetWide, legacy blob precedence, NaN fail-closed, pendingTTL byte-budget accounting, duplicate-key short-circuit) unchanged ✓

Summary

Commit 11 cleanly closes all three R9/R10 carryover items. The two medium documentation issues are now resolved — the HandleZSetLegacyBlob scan-order claim and the sizing claim both state correct values. The new TestRedisDB_ZSetLegacyBlobTTLArrivesBeforeBlob test pins the real production path for legacy-blob TTL inlining that had been untested since R3.

The remaining open item is the non-functional base64 assertion (carried since R2). The //nolint:gosec suppressions and the contradictory RejectsMalformedKey comment are style nits per CLAUDE.md convention.

…nvelope assertion

claude review carryover from r2-r10 (Low priority but a real bug):
TestRedisDB_ZSetBinaryMemberUsesBase64Envelope's payload check was
non-functional:

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

envelope["base64"] is typed as `any`. Comparing `any` to the
string literal "" with `==` performs interface comparison, which
is never equal whenever the value is a non-nil string —
including a corrupt payload like "garbage". The test passed for
any base64 field that was a string, regardless of its content.

Fix: round-trip the payload. Type-assert to string, decode via
base64.StdEncoding.DecodeString, and bytes.Equal against the
original input bytes [0x80, 0xff, 0x01]. Added "bytes" and
"encoding/base64" imports.

Extracted assertBase64EnvelopeDecodesTo helper (called via
t.Helper) so the test function stays under the cyclop=10
ceiling.

Caller audit (per /loop standing instruction): test-only change;
no production code touched. No caller-side audit needed.

Self-review:
1. Data loss — none. Test fix only.
2. Concurrency — none.
3. Performance — none.
4. Consistency — the test now actually verifies what its name
   claims: non-UTF-8 member bytes round-trip via the typed
   {"base64":"..."} envelope. Previously a regression that
   corrupted the payload would have silently passed.
5. Coverage — the new helper is also reusable for any future
   base64-envelope assertions on hashes/lists/streams.
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 21, 2026

@claude review

Round-12 (commit 13039134): claude r2-r10 base64-envelope assertion now actually checks the payload.

Low — non-functional base64 assertion (carried R2-R10)

Previous check at TestRedisDB_ZSetBinaryMemberUsesBase64Envelope:

if envelope["base64"] == "" { ... }

envelope["base64"] is any. Comparing any to "" with == is never equal for any non-nil string — so any payload (including a corrupt one) passed. The test name claimed it verifies the round-trip; it didn't.

Fix

Type-assert to string, base64-decode, bytes.Equal against the original input [0x80, 0xff, 0x01]. Extracted assertBase64EnvelopeDecodesTo helper to keep the test under cyclop=10.

go test -race ./internal/backup/ passes; golangci-lint clean.

@claude
Copy link
Copy Markdown

claude Bot commented May 21, 2026

Claude finished @bootjp's task in 1m 14s —— View job


Claude finished @bootjp's task


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

Tasks

  • Load tools
  • Read internal/backup/redis_zset_test.go (base64 fix + new helper)
  • Check outstanding items from Round 11
  • Post comprehensive review

Commit 12 (1303913) — round-trip base64 envelope assertion

The fix is correct and complete. The three key properties verified:

Type-assertion correctness: envelope["base64"].(string) correctly type-asserts the any-typed map value to string before comparison — the previous envelope["base64"] == "" was comparing an interface value to a string literal, which is only equal when the interface holds exactly the string "" (never the case for any non-empty base64 payload). ✓

Round-trip correctness: base64.StdEncoding.DecodeString(payload) followed by bytes.Equal(decoded, []byte{0x80, 0xff, 0x01}) validates that the encoder produced the exact bytes that were fed in, not just that a base64-looking string is present. A regression that corrupted 0x800x90 would now fail the test. ✓

t.Helper() placement: assertBase64EnvelopeDecodesTo calls t.Helper() at the top, so test failures report the call site (TestRedisDB_ZSetBinaryMemberUsesBase64Envelope) rather than the helper function. ✓

Cyclop budget: Extracting the helper keeps TestRedisDB_ZSetBinaryMemberUsesBase64Envelope itself under cyclop=10 as intended. ✓

Helper reusability: The assertBase64EnvelopeDecodesTo(t, envelope, want []byte) signature is generic — it can be called with any want payload, making it directly reusable for future hash/list/stream binary-member tests. ✓

The inline comment explaining the prior bug ("interface{} to string with ==, which is never equal — any non-nil value (including a corrupt payload) passed") is accurate and good for future readers who might wonder why the type-assert path is used instead of a direct comparison.


Nits — Carried from prior rounds (all that remain)

These two style items have been open since early rounds. They are not blocking:

//nolint:gosec in test helpers (redis_zset_test.go lines 52, 69, 79, 92, 104) — per CLAUDE.md: "Avoid //nolint — refactor instead." The five uint32(len(userKey)) / uint64(memberCount) casts in encodeZSetMetaValue, zsetMetaKey, zsetMemberKey, zsetScoreKey, zsetMetaDeltaKey can be made obviously safe with a bounds panic (e.g., if len(userKey) > math.MaxUint32 { panic("test bug") }) or the putUserKeyLen helper CodeRabbit proposed (r1). Neither a data-correctness risk nor a lint-affecting issue in production code, but inconsistent with the repo convention.

TestRedisDB_ZSetLegacyBlobRejectsMalformedKey contradictory comment (lines 917–920) — the comment says "Key has the prefix but no trailing user-key bytes — parser must still accept it (empty user key is technically valid Redis)" then immediately says "Use a key that doesn't have the prefix to trigger the parse failure." The test actually exercises the missing-prefix case, not an empty-user-key case. The test name RejectsMalformedKey and the first sentence contradict the real behavior. Rename to TestRedisDB_ZSetLegacyBlobRejectsMissingPrefix and remove the first contradictory comment sentence.


Overall PR Status

With commit 12, all medium and low findings raised across rounds 1–12 have been addressed:

Round Finding Status
R1 TTL scan-order gap (medium) ✅ Fixed R2 (pendingTTL buffering)
R1 NaN defensive guard ✅ Confirmed correct at intake
R2 Base64 assertion non-functional (low) ✅ Fixed R12
R2 //nolint:gosec (nit) 🟡 Still open
R2 Per-record []byte() alloc (nit) ✅ CodeRabbit suggestion remains open but minor
R3 Legacy blob data loss (medium/P1) ✅ Fixed R3
R3 Wrong scan-order docstring (medium) ✅ Fixed R11
R3 Missing TTL-first legacy-blob test (low) ✅ Fixed R11
R3 Contradictory test comment (nit) 🟡 Still open
R4 Wide-column supersedes legacy blob (P1) ✅ Fixed R4
R5 pendingTTL OOM (P1) ✅ Fixed R5
R5 duplicate-key false error (low) ✅ Fixed R8
R6 fail-closed at cap (P1) ✅ Fixed R6
R7 byte-budget cap (P1) ✅ Fixed R7
R9 incorrect 20M sizing claim (medium) ✅ Fixed R11
R10 meta-only data loss (P2/data loss) ✅ Fixed R10

The two remaining items are both style nits with no correctness impact. The implementation is functionally complete.

…ame contradictory test

Two style-nit cleanups per CLAUDE.md "Avoid //nolint — refactor
instead" convention; both were carried by claude reviews
across rounds 1-10.

(1) //nolint:gosec suppressions in test helpers (lines 52, 69,
77, 90, 102 of the prior file). Five separate sites all doing
the same uint32(len(userKey)) cast to pack the wideColKeyLen
prefix per store/wideColKeyLenSize. Extracted
appendUserKeyLenBE32 helper: it panics on the (theoretical)
overflow and carries the SOLE nolint annotation in the file.
Net: 5 nolints -> 1 nolint, plus an explicit bounds check that
documents the invariant for future readers. Also added a
non-negative bounds check to encodeZSetMetaValue (memberCount
int64 -> uint64) which had a similar nolint annotation.

(2) TestRedisDB_ZSetLegacyBlobRejectsMalformedKey had a
contradictory docstring: it claimed to test "empty user key is
technically valid Redis" then said "Use a key that doesn't have
the prefix to trigger the parse failure" — which is the
opposite. The test actually exercises the missing-prefix case.
Renamed to TestRedisDB_ZSetLegacyBlobRejectsMissingPrefix and
rewrote the docstring to accurately describe what it pins.

Caller audit (per /loop standing instruction): test-only
changes; no production code touched. appendUserKeyLenBE32 has
no production callers (only test fixtures use it). Rename of
the test function has no callers (test functions are entry
points).

Self-review:
1. Data loss — none. Refactor + rename only.
2. Concurrency — none.
3. Performance — same call overhead (append + putUint32);
   helper does not add an allocation.
4. Consistency — wire-format layout unchanged; helper just
   wraps the existing cast with bounds documentation.
5. Coverage — all existing tests still pass with -race;
   golangci-lint clean.
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 21, 2026

@claude review

Round-13 (commit ab7ee2ce): claude nits — consolidated gosec nolints + renamed contradictory test.

Nits per CLAUDE.md "Avoid //nolint — refactor instead"

(1) Consolidated 5 //nolint:gosec -> 1. All five sites did the same uint32(len(userKey)) cast for the wideColKeyLen prefix. Extracted appendUserKeyLenBE32(out []byte, userKey string) []byte helper with an explicit bounds check (panics on theoretical overflow) and a single nolint annotation. Same fix for encodeZSetMetaValue (added non-negative bounds check on memberCount int64 -> uint64).

(2) Renamed TestRedisDB_ZSetLegacyBlobRejectsMalformedKey -> ...RejectsMissingPrefix. Previous docstring contradicted itself ("empty user key is valid" then "use a key without prefix to trigger failure"). Rewrote to accurately describe what the test pins: missing-prefix routing through ErrRedisInvalidZSetLegacyBlob.

go test -race ./internal/backup/ passes; golangci-lint clean.

@claude
Copy link
Copy Markdown

claude Bot commented May 21, 2026

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


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

Tasks

  • Load tools
  • Read internal/backup/redis_zset_test.go (helper extraction + test rename)
  • Verify appendUserKeyLenBE32 helper correctness
  • Check all outstanding items from Round 12
  • Post comprehensive review

Commit 13 (ab7ee2ce) — consolidated gosec nolints + renamed test

Both remaining nits from prior rounds are addressed. Properties verified:

appendUserKeyLenBE32 helper (lines 55–63)

Consolidates the four call-site nolints (zsetMetaKey, zsetMemberKey, zsetScoreKey, zsetMetaDeltaKey) into a single annotated helper. The bounds check if n > 0xFFFFFFFF { panic(...) } before uint32(n) makes the conversion demonstrably safe — the gosec G115 linter can't infer this statically, so the nolint is warranted and minimal. All four call sites now use appendUserKeyLenBE32(out, userKey) with no annotations at the call site. ✓

encodeZSetMetaValue (lines 67–74)

Adds if memberCount < 0 { panic(...) } before uint64(memberCount), making the int64→uint64 conversion explicitly safe. Retains its own nolint (annotated // memberCount bounds-checked above) since the linter can't trace the negative guard. ✓

Net nolint reduction: 5 individual call-site suppressions → 2 documented helper-level suppressions (both with explicit bounds checks). The commit message says "5 → 1" which is slightly off by one — encodeZSetMetaValue retains a second nolint. The code change is correct; the count in the message is a minor inaccuracy in the PR description.

TestRedisDB_ZSetLegacyBlobRejectsMissingPrefix (lines 923–937)

The rename is correct and the new docstring is accurate: it explains what the test pins (missing-prefix routing via ErrRedisInvalidZSetLegacyBlob) and explicitly handles the "empty user key" edge case by noting it is technically valid and covered by parseZSetLegacyBlobKey's own unit path. The contradictory two-sentence block from prior rounds is gone. ✓


Nit — 0xFFFFFFFF vs math.MaxUint32

Line 57: if n > 0xFFFFFFFF { is functionally identical to if n > math.MaxUint32 { but the latter is more idiomatic Go and self-documenting. Trivially minor.


Overall PR status after Round 13

All findings across 13 rounds are resolved:

Finding Round raised Status
TTL scan-order gap / pendingTTL buffering R1 ✅ Fixed R2
Legacy blob data loss (`!redis zset `)
Wide-column supersedes legacy blob R4/Codex ✅ Fixed R4
pendingTTL OOM (entry-count cap) R5/Codex ✅ Fixed R5
fail-closed at cap R6/Codex ✅ Fixed R6
Byte-budget cap (large-key OOM) R7/Codex ✅ Fixed R7
Duplicate-key false error / double-count R8/Codex ✅ Fixed R8
Default cap too small (20M sizing claim) R9 ✅ Fixed R11
Meta-only data loss (legacy member eviction) R10/Codex ✅ Fixed R10
Wrong scan-order docstring R3 ✅ Fixed R11
Missing TTL-first legacy-blob test R3 ✅ Fixed R11
Base64 assertion non-functional R2 ✅ Fixed R12
//nolint:gosec (5 call-site suppressions) R2 ✅ Fixed R13 (5→2 in helpers)
Contradictory RejectsMalformedKey docstring R3 ✅ Fixed R13

The implementation is functionally complete and clean. Ready to merge.

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.

🧹 Nitpick comments (1)
internal/backup/redis_zset.go (1)

257-269: 💤 Low value

Simplify the convoluted scan-order rationale.

The parenthetical "(0x72='r' < 0x74='t' for the byte after the shared !redis| prefix would be the wrong direction; the actual comparison is byte 'z'=0x7A > 't'=0x74)" walks the reader through an aborted line of reasoning before reaching the conclusion. Also, the bullet header leads with !redis|zset|<k> but the bullet is about TTL sorting before zset, so the framing is backwards. A direct statement reads cleaner.

📝 Proposed doc rewrite
-//   - `!redis|zset|<k>` (0x72='r' < 0x74='t' for the byte after the
-//     shared `!redis|` prefix would be the wrong direction; the
-//     actual comparison is byte 'z'=0x7A > 't'=0x74) — so
-//     `!redis|ttl|<k>` sorts BEFORE `!redis|zset|<k>`. The real
-//     production order for a TTL'd legacy zset is therefore:
+//   - Within the shared `!redis|` family, byte 't' (0x74) < 'z'
+//     (0x7A), so `!redis|ttl|<k>` sorts BEFORE `!redis|zset|<k>`.
+//     The real production order for a TTL'd legacy zset is therefore:
🤖 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 257 - 269, The comment block
explaining scan-order should be rewritten to a concise, direct description:
remove the parenthetical byte-by-byte comparison and reframe the bullet heading
so it states that the TTL entry sorts before the zset entry (i.e.,
`!redis|ttl|<k>` sorts BEFORE `!redis|zset|<k>`), then briefly explain the
resulting production flow: HandleTTL records pendingTTL["k"]=ms
(redisKindUnknown) and HandleZSetLegacyBlob → zsetState() calls
claimPendingTTL() which moves pendingTTL into st.expireAtMs; note that the
HandleTTL redisKindZSet fast-path only fires when the order is reversed. Use the
existing symbols HandleTTL, HandleZSetLegacyBlob, zsetState(),
claimPendingTTL(), pendingTTL, and st.expireAtMs to keep the explanation
grounded.
🤖 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.

Nitpick comments:
In `@internal/backup/redis_zset.go`:
- Around line 257-269: The comment block explaining scan-order should be
rewritten to a concise, direct description: remove the parenthetical
byte-by-byte comparison and reframe the bullet heading so it states that the TTL
entry sorts before the zset entry (i.e., `!redis|ttl|<k>` sorts BEFORE
`!redis|zset|<k>`), then briefly explain the resulting production flow:
HandleTTL records pendingTTL["k"]=ms (redisKindUnknown) and HandleZSetLegacyBlob
→ zsetState() calls claimPendingTTL() which moves pendingTTL into st.expireAtMs;
note that the HandleTTL redisKindZSet fast-path only fires when the order is
reversed. Use the existing symbols HandleTTL, HandleZSetLegacyBlob, zsetState(),
claimPendingTTL(), pendingTTL, and st.expireAtMs to keep the explanation
grounded.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ba9bd4dc-0a25-4ff8-87cb-a8f6d38f5b58

📥 Commits

Reviewing files that changed from the base of the PR and between 060ce8a and 1303913.

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

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: ab7ee2ce7a

ℹ️ 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 thread internal/backup/redis_string.go Outdated
// key whose type prefix we don't recognise (a future Redis
// type added on the live side without a backup-encoder update).
r.orphanTTLCount += len(r.pendingTTL)
if r.warn != nil && r.orphanTTLCount > 0 {
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 Emit pending TTL overflow warnings unconditionally

Finalize only calls the warn sink when orphanTTLCount > 0, but entries rejected in parkUnknownTTL are counted in pendingTTLOverflow and never contribute to orphanTTLCount. In runs where the pending TTL buffer overflows but no unmatched TTLs remain (for example, overflowed inserts plus later successful drains), no warning is emitted even though expirations were dropped, so operators can miss partial-loss signals if the caller continues after HandleTTL errors.

Useful? React with 👍 / 👎.

codex P2 on round 13: Finalize's warn sink fired only when
orphanTTLCount > 0. But entries rejected in parkUnknownTTL are
counted in pendingTTLOverflow and never contribute to
orphanTTLCount (the overflow path returns
ErrPendingTTLBufferFull before pendingTTL is touched). In runs
where overflow happens but all other TTLs drain cleanly
(orphanTTLCount == 0), no warning was emitted despite dropped
expirations. A caller that swallows the HandleTTL error and
continues would lose the visibility signal entirely.

Fix: also emit warn when pendingTTLOverflow > 0. The condition
becomes `orphanTTLCount > 0 || pendingTTLOverflow > 0`. The
warning's existing field set already carries
pending_ttl_buffer_overflow and pending_ttl_buffer_bytes_cap
when overflow is non-zero — operators get the same diagnostics
in the overflow-only path as in the orphan-plus-overflow path.

Caller audit (per /loop standing instruction):
  grep -rn 'orphanTTLCount\b|pendingTTLOverflow\b' --include='*.go'
  - orphanTTLCount writers: parkUnknownTTL (cap==0 mode),
    Finalize (adds len(pendingTTL))
  - pendingTTLOverflow writers: parkUnknownTTL (overflow branch)
  - Readers: Finalize (warn emit), tests
  The change broadens the warn-trigger predicate to include
  pendingTTLOverflow; no caller depends on the absence of the
  warning. The warn fields are unchanged for the orphan-only path
  (still fields existing tests assert).

Test added:
- TestRedisDB_FinalizeWarnsOnOverflowOnlyEvenWhenOrphanCountZero
  pins the new behavior: with byte_cap=16, key "k0" (cost=10)
  fits but key "k1" (would push to 20) fails closed. After
  draining k0 via HandleSetMember, orphanTTLCount stays at 0
  while pendingTTLOverflow stays at 1. Asserts that the
  redis_orphan_ttl warning still fires AND carries
  pending_ttl_buffer_overflow in its fields.
- Extracted warnFieldsContain helper to keep the test under
  cyclop=10.

Self-review:
1. Data loss visibility — STRICTLY safer: operators now see the
   overflow signal even when orphanTTLCount==0. Previous
   behavior silently swallowed dropped expirations if the
   caller's HandleTTL error handling let them through.
2. Concurrency — none. Same single-threaded Finalize contract.
3. Performance — one extra integer comparison in Finalize
   (O(1)); negligible.
4. Consistency — predicate now matches the field set that was
   already added on overflow. The warning's reported "count"
   field remains orphanTTLCount; pending_ttl_buffer_overflow is
   a separate field already in the schema. No JSON schema
   change.
5. Coverage — new test pins both the warn emission and the
   field carrying. TestRedisDB_HandleTTL_OrphanWarnsOnFinalize
   still passes (orphan-only path unchanged). All other
   pendingTTL tests pass with -race.
bootjp added a commit that referenced this pull request May 21, 2026
Mirror of PR #790 r14 codex P2 fix. Finalize's warn sink fired
only when orphanTTLCount > 0, but entries rejected in
parkUnknownTTL are counted in pendingTTLOverflow and never
contribute to orphanTTLCount. A caller that swallows
ErrPendingTTLBufferFull would lose the visibility signal for
dropped expirations.

Fix: also emit warn when pendingTTLOverflow > 0. Predicate
becomes `orphanTTLCount > 0 || pendingTTLOverflow > 0`.

Caller audit: same as PR #790 r14. Single Finalize predicate
change; warn field set unchanged for the orphan-only path.

Test added (same as PR #790 r14):
- TestRedisDB_FinalizeWarnsOnOverflowOnlyEvenWhenOrphanCountZero
  pins the overflow-only warn emission.
- warnFieldsContain helper extracted.

Self-review: same shape as PR #790 r14 commit's review
(strictly safer visibility, no concurrency change, negligible
perf, schema unchanged, coverage solid).
@bootjp
Copy link
Copy Markdown
Owner Author

bootjp commented May 21, 2026

@claude review

Round-14 (commit c9d49e28): codex P2 — Finalize warns on overflow-only.

codex P2 — overflow signal suppressed when orphanTTLCount==0

Finalize's warn sink fired only when orphanTTLCount > 0. But pendingTTLOverflow entries fail-closed at parkUnknownTTL and never reach pendingTTL, so they don't contribute to orphanTTLCount. A caller that swallowed ErrPendingTTLBufferFull would lose the visibility signal for dropped expirations.

Fix

Predicate widened: orphanTTLCount > 0 || pendingTTLOverflow > 0. The warning's existing field set already carries pending_ttl_buffer_overflow and pending_ttl_buffer_bytes_cap when overflow > 0, so operators get the same diagnostics in the overflow-only path.

Caller audit

grep -rn 'orphanTTLCount\b|pendingTTLOverflow\b' confirms single writer for each counter (parkUnknownTTL, Finalize) and a single reader (Finalize warn). Predicate-only change broadens emission; no caller depends on the absence of the warning.

Test added

TestRedisDB_FinalizeWarnsOnOverflowOnlyEvenWhenOrphanCountZero pins the overflow-only path: byte_cap=16, two TTL inserts (second fails closed), drain via HandleSetMember, then Finalize. Asserts redis_orphan_ttl fires AND pending_ttl_buffer_overflow field present. Extracted warnFieldsContain helper to stay under cyclop=10.

go test -race ./internal/backup/ passes; golangci-lint clean.

@claude
Copy link
Copy Markdown

claude Bot commented May 21, 2026

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


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

Tasks

  • Load tools
  • Read internal/backup/redis_string.go (Finalize warn predicate change)
  • Read internal/backup/redis_string_test.go (new overflow-only warn test)
  • Read internal/backup/redis_zset.go and redis_zset_test.go (check remaining nits)
  • Check all outstanding items from Round 13
  • Post comprehensive review

Commit 14 (c9d49e28) — Finalize warns on overflow-only

The fix is correct and addresses the last open finding from Codex's Round 13 comment. Properties verified:

Predicate change (redis_string.go:595)

// Before
if r.warn != nil && r.orphanTTLCount > 0 {

// After
if r.warn != nil && (r.orphanTTLCount > 0 || r.pendingTTLOverflow > 0) {

Overflow entries fail-closed at parkUnknownTTL and are never inserted into pendingTTL, so they don't increment orphanTTLCount. The old predicate silently suppressed the visibility signal for dropped expirations when all remaining buffered TTLs were later claimed. ✓

Field semantics are consistent: The count field still reflects orphanTTLCount (truly unmatched, buffered-but-unclaimed TTLs). The pending_ttl_buffer_overflow field carries the overflow count as a separate signal. An operator seeing count=0, pending_ttl_buffer_overflow=1 in the redis_orphan_ttl event gets the correct picture: no buffered orphans, but one expiry was dropped during intake. ✓

warnFieldsContain helper (redis_string_test.go:421)

Iterates keys at even-indexed positions in the variadic key, value, key, value field slice. This matches the layout used throughout the codebase. t.Helper() is correctly omitted here (the function returns bool; it doesn't call t.Fatalf). ✓

Test arithmetic verified — TestRedisDB_FinalizeWarnsOnOverflowOnlyEvenWhenOrphanCountZero:

  • byteCap = 16
  • "k0": cost = len("k0") + 8 = 2 + 8 = 10; 10 ≤ 16 → buffered. pendingTTLBytes = 10
  • "k1": cost = 2 + 8 = 10; 10 + 10 = 20 > 16ErrPendingTTLBufferFull. pendingTTLOverflow = 1
  • HandleSetMember("k0", ...)setState("k0")claimPendingTTL("k0")pendingTTLBytes = 0, mapLen = 0
  • Finalize: orphanTTLCount += len(pendingTTL) = 0; predicate 0 > 0 || 1 > 0 → true → warn fires ✓
  • assertPendingTTLState post-finalize: {bytes:0, mapLen:0, overflow:1} ✓ (pendingTTLOverflow is not reset by Finalize)
  • orphanTTLCount == 0
  • events == ["redis_orphan_ttl"]
  • warnFieldsContain(lastFields, "pending_ttl_buffer_overflow")

Existing TestRedisDB_HandleTTL_OrphanWarnsOnFinalize (orphan-only path) is unchanged and still passes. ✓


No remaining open issues

All findings raised across Rounds 1–14 have been resolved:

Finding Round raised Status
TTL scan-order gap / pendingTTL buffering R1 ✅ Fixed R2
Legacy blob data loss (`!redis zset `)
Wide-column supersedes legacy blob R4/Codex ✅ Fixed R4
pendingTTL OOM (entry-count cap) R5/Codex ✅ Fixed R5
Fail-closed at cap R6/Codex ✅ Fixed R6
Byte-budget cap (large-key OOM) R7/Codex ✅ Fixed R7
Duplicate-key false error / byte double-count R8/Codex ✅ Fixed R8
Default cap too small for production workloads R9/Codex ✅ Fixed R9
Meta-only data loss (legacy member eviction) R10/Codex ✅ Fixed R10
Wrong scan-order docstring R3 ✅ Fixed R11
Missing TTL-first legacy-blob test R3 ✅ Fixed R11
Incorrect 20M sizing claim R9 ✅ Fixed R11
Base64 assertion non-functional R2 ✅ Fixed R12
//nolint:gosec suppressions (5 → 2 in helpers) R2 ✅ Fixed R13
Contradictory RejectsMalformedKey docstring R3 ✅ Fixed R13
Finalize suppresses warn on overflow-only Codex R13 ✅ Fixed R14

The implementation is functionally complete. Ready to merge.

@bootjp bootjp enabled auto-merge May 21, 2026 14:29
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: cbeae91a45

ℹ️ 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 +289 to +291
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.

P2 Badge Overwrite legacy zset state on repeated blob records

When HandleZSetLegacyBlob is invoked more than once for the same key (for example, replay/overlapping snapshot windows), the second blob is merged into st.members without clearing prior entries. That means members removed in the newer blob remain in output, so restored data can include stale members that are no longer present in the latest record. The handler should replace the legacy member set for that key (while sawWide is still false), not union it.

Useful? React with 👍 / 👎.

@bootjp bootjp merged commit 9b82d8a into main May 21, 2026
11 checks passed
@bootjp bootjp deleted the backup/redis-zset-encoder branch May 21, 2026 14:39
bootjp added a commit that referenced this pull request May 21, 2026
Resolve conflicts after PR #790 (zset encoder) merged to main:
- redis_string.go: take main's pendingTTL infrastructure (which
  includes PR #790's r14 Finalize warn predicate plus all prior
  fixes); add stream-specific bits — redisKindStream constant,
  streams map field, NewRedisDB init, HandleTTL redisKindStream
  branch, Finalize flushStreams call. Extracted
  inlineWideColumnTTL helper so HandleTTL stays under cyclop=10
  with the 5th wide-column branch.
- redis_string_test.go: take main's version (already has the
  warnFieldsContain helper and FinalizeWarnsOnOverflowOnly test
  landed via PR #790 r14 mirror).
- redis_stream_test.go: remove TestRedisDB_SetTTLArrivesBeforeRows
  duplicate (now in main via PR #790).
- redis_set.go: take main's PR #790 attribution.

No semantic change beyond what was already in PR #790. Stream
encoder bits remain unchanged from this PR's pre-merge state.
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