Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion internal/backup/redis_hash.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,11 @@ type hashFieldRecord struct {
Value json.RawMessage `json:"value"`
}

func marshalHashJSON(st *redisHashState) ([]byte, error) {
// nolint comment lives at the function head: dupl pairs this with
// marshalZSetJSON, which carries the rationale (parallel design-spec
// wrappers that can't collapse into a shared helper without breaking
// JSON field-order determinism). See redis_zset.go:marshalZSetJSON.
func marshalHashJSON(st *redisHashState) ([]byte, error) { //nolint:dupl // see comment above + redis_zset.go
// Sort by raw byte order for deterministic output across runs.
names := make([]string, 0, len(st.fields))
for name := range st.fields {
Expand Down
22 changes: 20 additions & 2 deletions internal/backup/redis_string.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ const (
redisKindHash
redisKindList
redisKindSet
redisKindZSet
)

// RedisDB encodes one logical Redis database (`redis/db_<n>/`). All
Expand Down Expand Up @@ -185,6 +186,14 @@ type RedisDB struct {
// Finalize into sets/<key>.json with members sorted by raw byte
// order for deterministic dump output.
sets map[string]*redisSetState

// zsets buffers per-userKey sorted-set state. Score lives in the
// !zs|mem| value (8-byte IEEE 754 big-endian); member name is the
// trailing key bytes (binary-safe). Flushed at Finalize into
// zsets/<key>.json sorted by member-name bytes (not by score) so
// `diff -r` between dumps stays line-stable across score-only
// mutations.
zsets map[string]*redisZSetState
}

// NewRedisDB constructs a RedisDB rooted at <outRoot>/redis/db_<n>/.
Expand All @@ -204,6 +213,7 @@ func NewRedisDB(outRoot string, dbIndex int) *RedisDB {
hashes: make(map[string]*redisHashState),
lists: make(map[string]*redisListState),
sets: make(map[string]*redisSetState),
zsets: make(map[string]*redisZSetState),
}
}

Expand Down Expand Up @@ -297,9 +307,16 @@ func (r *RedisDB) HandleTTL(userKey, value []byte) error {
st.expireAtMs = expireAtMs
st.hasTTL = true
return nil
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
Comment on lines +348 to +353
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 👍 / 👎.

return nil
case redisKindUnknown:
// Track orphan TTL counts only — keys are unused before the
// remaining wide-column encoders (set/zset/stream) land, and
// remaining wide-column encoder (stream) lands, and
// buffering them allocates proportional to user-key size
// (up to 1 MiB per key) for no benefit. Codex P2 round 6.
r.orphanTTLCount++
Expand All @@ -318,6 +335,7 @@ func (r *RedisDB) Finalize() error {
r.flushHashes,
r.flushLists,
r.flushSets,
r.flushZSets,
func() error { return closeJSONL(r.stringsTTL) },
func() error { return closeJSONL(r.hllTTL) },
r.closeKeymap,
Expand All @@ -329,7 +347,7 @@ func (r *RedisDB) Finalize() error {
if r.warn != nil && r.orphanTTLCount > 0 {
r.warn("redis_orphan_ttl",
"count", r.orphanTTLCount,
"hint", "remaining wide-column encoders (zset/stream) have not landed yet")
"hint", "remaining wide-column encoder (stream) has not landed yet")
}
return firstErr
}
Expand Down
Loading
Loading