Commit 9b82d8a
authored
backup: Redis zset encoder (Phase 0a) (#790)
## 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:
```json
{"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 / distributed** — `RedisDB` 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
- [x] `go test -race ./internal/backup/` → ok
- [x] `golangci-lint run ./internal/backup/...` → 0 issues
- [x] `go build ./...` → ok
- [x] `go vet ./internal/backup/...` → ok
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Added Redis sorted-set (ZSET) backup/restore with deterministic JSON
output, proper ±Inf score serialization, and legacy-blob reconciliation.
* TTLs for ZSETs and sets can be inlined so restored items retain
expiry; pending TTLs are drained when a key is first typed.
* Orphan-TTL buffering with configurable byte-cap and a fail-closed
overflow behavior; option to disable buffering.
* **Tests**
* Extensive tests covering ZSET semantics, TTL buffering/drain,
legacy-blob handling, byte-cap edge cases, and overflow behavior.
<!-- review_stack_entry_start -->
[](https://app.coderabbit.ai/change-stack/bootjp/elastickv/pull/790?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)
<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->6 files changed
Lines changed: 2082 additions & 30 deletions
File tree
- internal/backup
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
237 | 237 | | |
238 | 238 | | |
239 | 239 | | |
240 | | - | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
241 | 245 | | |
242 | 246 | | |
243 | 247 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
114 | 114 | | |
115 | 115 | | |
116 | 116 | | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
117 | 125 | | |
118 | 126 | | |
119 | 127 | | |
120 | 128 | | |
121 | 129 | | |
122 | 130 | | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
123 | 135 | | |
124 | 136 | | |
125 | 137 | | |
| |||
0 commit comments