diff --git a/adapter/redis.go b/adapter/redis.go index aa69551ab..73e9827d5 100644 --- a/adapter/redis.go +++ b/adapter/redis.go @@ -189,6 +189,11 @@ type RedisServer struct { // reads on hot keys faster than the regular compaction interval. compactor *DeltaCompactor + // disableLegacyTTLReadFallback removes !redis|ttl| from the authoritative + // read path after the inline TTL migrator has rewritten all anchors. The + // default keeps the fallback enabled for rolling upgrades. + disableLegacyTTLReadFallback bool + // connIDSeq hands out monotonically increasing per-connection // identifiers. The zero value is never returned (atomic.AddUint64 // returns 1 on first call) so clients can treat 0 as "unset". @@ -269,6 +274,17 @@ func WithRedisCompactor(c *DeltaCompactor) RedisServerOption { } } +// WithRedisLegacyTTLReadFallback controls whether pre-inline !redis|ttl| +// entries are still consulted after the anchor-specific TTL probes miss. +// Leave this enabled during rolling upgrades; disable it only after the +// background inline TTL migrator has completed for the cluster and legacy bare +// string keys have been separately ruled out or rewritten. +func WithRedisLegacyTTLReadFallback(enabled bool) RedisServerOption { + return func(r *RedisServer) { + r.disableLegacyTTLReadFallback = !enabled + } +} + // WithRedisRequestObserver enables Prometheus-compatible request metrics. func WithRedisRequestObserver(observer monitoring.RedisRequestObserver) RedisServerOption { return func(r *RedisServer) { diff --git a/adapter/redis_collection_ttl.go b/adapter/redis_collection_ttl.go new file mode 100644 index 000000000..e2f4864ce --- /dev/null +++ b/adapter/redis_collection_ttl.go @@ -0,0 +1,440 @@ +package adapter + +import ( + "bytes" + "context" + "math" + "time" + + "github.com/bootjp/elastickv/kv" + "github.com/bootjp/elastickv/store" + "github.com/cockroachdb/errors" +) + +const ( + redisSimpleMetaLegacySizeBytes = 8 + redisSimpleMetaInlineSizeBytes = 16 + redisWideMetaLegacySizeBytes = 24 + redisWideMetaInlineSizeBytes = 32 +) + +func redisExpireAtMillis(expireAt time.Time) uint64 { + ms := expireAt.UnixMilli() + if ms < 0 { + return 0 + } + return uint64(ms) // #nosec G115 -- negative values are clamped above. +} + +func redisTimeFromMillis(ms uint64) *time.Time { + if ms == 0 { + return nil + } + clamped := min(ms, uint64(math.MaxInt64)) + t := time.UnixMilli(int64(clamped)) // #nosec G115 -- clamped to MaxInt64. + return &t +} + +func (r *RedisServer) collectionTTLAt(ctx context.Context, userKey []byte, readTS uint64) (*time.Time, bool, error) { + if ttl, found, err := r.listInlineTTLAt(ctx, userKey, readTS); err != nil || found { + return ttl, found, err + } + if ttl, found, err := r.simpleInlineTTLAt(ctx, store.HashMetaKey(userKey), readTS, hashMetaExpireAt); err != nil || found { + return ttl, found, err + } + if ttl, found, err := r.simpleInlineTTLAt(ctx, store.SetMetaKey(userKey), readTS, setMetaExpireAt); err != nil || found { + return ttl, found, err + } + if ttl, found, err := r.simpleInlineTTLAt(ctx, store.ZSetMetaKey(userKey), readTS, zsetMetaExpireAt); err != nil || found { + return ttl, found, err + } + return r.streamInlineTTLAt(ctx, userKey, readTS) +} + +func (r *RedisServer) listInlineTTLAt(ctx context.Context, userKey []byte, readTS uint64) (*time.Time, bool, error) { + raw, err := r.store.GetAt(ctx, store.ListMetaKey(userKey), readTS) + if err != nil { + if errors.Is(err, store.ErrKeyNotFound) { + return nil, false, nil + } + return nil, false, errors.WithStack(err) + } + if len(raw) != redisWideMetaInlineSizeBytes { + return nil, false, nil + } + meta, err := store.UnmarshalListMeta(raw) + if err != nil { + return nil, false, errors.WithStack(err) + } + ttl := redisTimeFromMillis(meta.ExpireAt) + return ttl, true, nil +} + +func (r *RedisServer) simpleInlineTTLAt( + ctx context.Context, + metaKey []byte, + readTS uint64, + expireAtFromMeta func([]byte) (uint64, error), +) (*time.Time, bool, error) { + raw, err := r.store.GetAt(ctx, metaKey, readTS) + if err != nil { + if errors.Is(err, store.ErrKeyNotFound) { + return nil, false, nil + } + return nil, false, errors.WithStack(err) + } + if len(raw) != redisSimpleMetaInlineSizeBytes { + return nil, false, nil + } + expireAt, err := expireAtFromMeta(raw) + if err != nil { + return nil, false, errors.WithStack(err) + } + ttl := redisTimeFromMillis(expireAt) + return ttl, true, nil +} + +func hashMetaExpireAt(raw []byte) (uint64, error) { + meta, err := store.UnmarshalHashMeta(raw) + return meta.ExpireAt, errors.WithStack(err) +} + +func setMetaExpireAt(raw []byte) (uint64, error) { + meta, err := store.UnmarshalSetMeta(raw) + return meta.ExpireAt, errors.WithStack(err) +} + +func zsetMetaExpireAt(raw []byte) (uint64, error) { + meta, err := store.UnmarshalZSetMeta(raw) + return meta.ExpireAt, errors.WithStack(err) +} + +func (r *RedisServer) streamInlineTTLAt(ctx context.Context, userKey []byte, readTS uint64) (*time.Time, bool, error) { + raw, err := r.store.GetAt(ctx, store.StreamMetaKey(userKey), readTS) + if err != nil { + if errors.Is(err, store.ErrKeyNotFound) { + return nil, false, nil + } + return nil, false, errors.WithStack(err) + } + if len(raw) != redisWideMetaInlineSizeBytes { + return nil, false, nil + } + meta, err := store.UnmarshalStreamMeta(raw) + if err != nil { + return nil, false, errors.WithStack(err) + } + ttl := redisTimeFromMillis(meta.ExpireAt) + return ttl, true, nil +} + +func (r *RedisServer) dispatchCollectionExpire( + ctx context.Context, + key []byte, + readTS uint64, + typ redisValueType, + expireAt time.Time, +) (bool, error) { + ttlMs := redisExpireAtMillis(expireAt) + elems, ok, err := r.collectionExpireElems(ctx, key, readTS, typ, ttlMs) + if err != nil || !ok { + return ok, err + } + elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Put, Key: redisTTLKey(key), Value: encodeRedisTTL(expireAt)}) + return true, r.dispatchElems(ctx, true, readTS, elems) +} + +func (r *RedisServer) collectionExpireElems( + ctx context.Context, + key []byte, + readTS uint64, + typ redisValueType, + expireAtMs uint64, +) ([]*kv.Elem[kv.OP], bool, error) { + elems, ok, err := r.collectionMetaExpireElems(ctx, key, readTS, typ, expireAtMs) + if err != nil || ok { + return elems, ok, err + } + return r.legacyCollectionExpireFallback(ctx, key, readTS, typ) +} + +func (r *RedisServer) expiredCollectionCleanupForRecreate(ctx context.Context, key []byte, readTS uint64, typ redisValueType) ([]*kv.Elem[kv.OP], bool, error) { + if typ != redisTypeNone { + return nil, false, nil + } + expired, err := r.hasExpired(ctx, key, readTS, true) + if err != nil || !expired { + return nil, false, err + } + elems, _, err := r.deleteLogicalKeyElems(ctx, key, readTS) + if err != nil { + return nil, false, err + } + return append(elems, redisTxnWideCollectionFenceElems(key)...), true, nil +} + +func (r *RedisServer) collectionMetaExpireElems( + ctx context.Context, + key []byte, + readTS uint64, + typ redisValueType, + expireAtMs uint64, +) ([]*kv.Elem[kv.OP], bool, error) { + switch typ { + case redisTypeNone, redisTypeString: + return nil, false, nil + case redisTypeList: + return r.listMetaExpireElems(ctx, key, readTS, expireAtMs) + case redisTypeHash: + return r.simpleMetaExpireElems(ctx, key, readTS, expireAtMs, "hash", store.HashMetaKey(key), store.HashMetaDeltaScanPrefix(key), + func(raw []byte) (int64, uint64, error) { + m, err := store.UnmarshalHashMeta(raw) + return m.Len, m.ExpireAt, errors.WithStack(err) + }, + func(n int64, ttl uint64) []byte { return store.MarshalHashMeta(store.HashMeta{Len: n, ExpireAt: ttl}) }, + func(raw []byte) (int64, error) { + d, err := store.UnmarshalHashMetaDelta(raw) + return d.LenDelta, errors.WithStack(err) + }, + ) + case redisTypeSet: + return r.simpleMetaExpireElems(ctx, key, readTS, expireAtMs, "set", store.SetMetaKey(key), store.SetMetaDeltaScanPrefix(key), + func(raw []byte) (int64, uint64, error) { + m, err := store.UnmarshalSetMeta(raw) + return m.Len, m.ExpireAt, errors.WithStack(err) + }, + func(n int64, ttl uint64) []byte { return store.MarshalSetMeta(store.SetMeta{Len: n, ExpireAt: ttl}) }, + func(raw []byte) (int64, error) { + d, err := store.UnmarshalSetMetaDelta(raw) + return d.LenDelta, errors.WithStack(err) + }, + ) + case redisTypeZSet: + return r.simpleMetaExpireElems(ctx, key, readTS, expireAtMs, "zset", store.ZSetMetaKey(key), store.ZSetMetaDeltaScanPrefix(key), + func(raw []byte) (int64, uint64, error) { + m, err := store.UnmarshalZSetMeta(raw) + return m.Len, m.ExpireAt, errors.WithStack(err) + }, + func(n int64, ttl uint64) []byte { return store.MarshalZSetMeta(store.ZSetMeta{Len: n, ExpireAt: ttl}) }, + func(raw []byte) (int64, error) { + d, err := store.UnmarshalZSetMetaDelta(raw) + return d.LenDelta, errors.WithStack(err) + }, + ) + case redisTypeStream: + return r.streamMetaExpireElems(ctx, key, readTS, expireAtMs) + } + return nil, false, nil +} + +func (r *RedisServer) legacyCollectionExpireFallback(ctx context.Context, key []byte, readTS uint64, typ redisValueType) ([]*kv.Elem[kv.OP], bool, error) { + legacyKey, ok := legacyCollectionBlobKey(key, typ) + if !ok { + return nil, false, nil + } + exists, err := r.store.ExistsAt(ctx, legacyKey, readTS) + if err != nil { + return nil, false, errors.WithStack(err) + } + return nil, exists, nil +} + +func legacyCollectionBlobKey(key []byte, typ redisValueType) ([]byte, bool) { + switch typ { + case redisTypeHash: + return redisHashKey(key), true + case redisTypeSet: + return redisSetKey(key), true + case redisTypeZSet: + return redisZSetKey(key), true + case redisTypeStream: + return redisStreamKey(key), true + case redisTypeNone, redisTypeString, redisTypeList: + return nil, false + } + return nil, false +} + +func (r *RedisServer) listMetaExpireElems(ctx context.Context, key []byte, readTS uint64, expireAtMs uint64) ([]*kv.Elem[kv.OP], bool, error) { + meta, exists, err := r.loadListMetaAt(ctx, key, readTS) + if err != nil { + return nil, false, err + } + prefix := store.ListMetaDeltaScanPrefix(key) + deltas, err := r.scanDeltaKVs(ctx, prefix, readTS) + if err != nil { + return r.listMetaExpireScanErr(key, meta, exists, expireAtMs, err) + } + if !exists && len(deltas) == 0 { + return nil, false, nil + } + meta, err = listMetaWithDeltas(meta, deltas) + if err != nil { + return nil, false, err + } + if meta.Len < 0 { + meta.Len = 0 + } + meta.Tail = meta.Head + meta.Len + if meta.Len == 0 { + return nil, false, nil + } + meta.ExpireAt = expireAtMs + metaBytes, err := store.MarshalListMeta(meta) + if err != nil { + return nil, false, errors.WithStack(err) + } + elems := []*kv.Elem[kv.OP]{{Op: kv.Put, Key: store.ListMetaKey(key), Value: metaBytes}} + return appendDeltaDeletes(elems, deltas), true, nil +} + +func (r *RedisServer) listMetaExpireScanErr( + key []byte, + meta store.ListMeta, + exists bool, + expireAtMs uint64, + err error, +) ([]*kv.Elem[kv.OP], bool, error) { + if !errors.Is(err, ErrDeltaScanTruncated) { + return nil, false, err + } + r.triggerUrgentCompaction("list", key) + if exists { + return listMetaTTLUpdateElem(key, meta, expireAtMs) + } + return nil, false, ErrDeltaScanTruncated +} + +func listMetaWithDeltas(meta store.ListMeta, deltas []*store.KVPair) (store.ListMeta, error) { + for _, d := range deltas { + md, err := store.UnmarshalListMetaDelta(d.Value) + if err != nil { + return store.ListMeta{}, errors.WithStack(err) + } + meta.Head += md.HeadDelta + meta.Len += md.LenDelta + } + return meta, nil +} + +func listMetaTTLUpdateElem(key []byte, meta store.ListMeta, expireAtMs uint64) ([]*kv.Elem[kv.OP], bool, error) { + meta.ExpireAt = expireAtMs + metaBytes, err := store.MarshalListMeta(meta) + if err != nil { + return nil, false, errors.WithStack(err) + } + return []*kv.Elem[kv.OP]{{Op: kv.Put, Key: store.ListMetaKey(key), Value: metaBytes}}, true, nil +} + +func (r *RedisServer) simpleMetaExpireElems( + ctx context.Context, + key []byte, + readTS uint64, + expireAtMs uint64, + typeName string, + metaKey []byte, + deltaPrefix []byte, + unmarshalBase func([]byte) (int64, uint64, error), + marshalBase func(int64, uint64) []byte, + unmarshalDelta func([]byte) (int64, error), +) ([]*kv.Elem[kv.OP], bool, error) { + var baseLen int64 + raw, err := r.store.GetAt(ctx, metaKey, readTS) + exists := true + if err != nil { + if !errors.Is(err, store.ErrKeyNotFound) { + return nil, false, errors.WithStack(err) + } + exists = false + } else { + var unmarshalErr error + baseLen, _, unmarshalErr = unmarshalBase(raw) + if unmarshalErr != nil { + return nil, false, unmarshalErr + } + } + deltas, err := r.scanDeltaKVs(ctx, deltaPrefix, readTS) + if err != nil { + return r.simpleMetaExpireScanErr(key, typeName, metaKey, baseLen, exists, expireAtMs, marshalBase, err) + } + if !exists && len(deltas) == 0 { + return nil, false, nil + } + newLen, err := simpleMetaLenWithDeltas(baseLen, deltas, unmarshalDelta) + if err != nil { + return nil, false, err + } + if newLen <= 0 { + return nil, false, nil + } + elems := []*kv.Elem[kv.OP]{{Op: kv.Put, Key: metaKey, Value: marshalBase(newLen, expireAtMs)}} + return appendDeltaDeletes(elems, deltas), true, nil +} + +func (r *RedisServer) simpleMetaExpireScanErr( + key []byte, + typeName string, + metaKey []byte, + baseLen int64, + exists bool, + expireAtMs uint64, + marshalBase func(int64, uint64) []byte, + err error, +) ([]*kv.Elem[kv.OP], bool, error) { + if !errors.Is(err, ErrDeltaScanTruncated) { + return nil, false, err + } + r.triggerUrgentCompaction(typeName, key) + if exists { + return []*kv.Elem[kv.OP]{{Op: kv.Put, Key: metaKey, Value: marshalBase(baseLen, expireAtMs)}}, true, nil + } + return nil, false, ErrDeltaScanTruncated +} + +func simpleMetaLenWithDeltas( + baseLen int64, + deltas []*store.KVPair, + unmarshalDelta func([]byte) (int64, error), +) (int64, error) { + newLen := baseLen + for _, d := range deltas { + v, err := unmarshalDelta(d.Value) + if err != nil { + return 0, err + } + newLen += v + } + return newLen, nil +} + +func (r *RedisServer) streamMetaExpireElems(ctx context.Context, key []byte, readTS uint64, expireAtMs uint64) ([]*kv.Elem[kv.OP], bool, error) { + meta, found, err := r.loadStreamMetaAt(ctx, key, readTS) + if err != nil || !found { + return nil, found, err + } + meta.ExpireAt = expireAtMs + metaBytes, err := store.MarshalStreamMeta(meta) + if err != nil { + return nil, false, errors.WithStack(err) + } + return []*kv.Elem[kv.OP]{{Op: kv.Put, Key: store.StreamMetaKey(key), Value: metaBytes}}, true, nil +} + +func (r *RedisServer) scanDeltaKVs(ctx context.Context, prefix []byte, readTS uint64) ([]*store.KVPair, error) { + end := store.PrefixScanEnd(prefix) + deltas, err := r.store.ScanAt(ctx, prefix, end, store.MaxDeltaScanLimit+1, readTS) + if err != nil { + return nil, errors.WithStack(err) + } + if len(deltas) > store.MaxDeltaScanLimit { + return nil, ErrDeltaScanTruncated + } + return deltas, nil +} + +func appendDeltaDeletes(elems []*kv.Elem[kv.OP], deltas []*store.KVPair) []*kv.Elem[kv.OP] { + for _, d := range deltas { + elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: bytes.Clone(d.Key)}) + } + return elems +} diff --git a/adapter/redis_collection_ttl_test.go b/adapter/redis_collection_ttl_test.go new file mode 100644 index 000000000..0d2064b16 --- /dev/null +++ b/adapter/redis_collection_ttl_test.go @@ -0,0 +1,1290 @@ +package adapter + +import ( + "context" + "testing" + "time" + + "github.com/bootjp/elastickv/kv" + "github.com/bootjp/elastickv/store" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" + "github.com/tidwall/redcon" +) + +func TestRedisCollectionExpireWritesInlineMetaTTL(t *testing.T) { + nodes, _, _ := createNode(t, 3) + defer shutdown(nodes) + + ctx := context.Background() + rdb := redis.NewClient(&redis.Options{Addr: nodes[0].redisAddress}) + defer func() { _ = rdb.Close() }() + + server := nodes[0].redisServer + ttl := 30 * time.Second + cases := []struct { + name string + keyBase string + create func(string) error + metaKey func([]byte) []byte + expireAt func([]byte) (uint64, error) + }{ + { + name: "hash", + keyBase: "ttl:inline:hash", + create: func(key string) error { return rdb.HSet(ctx, key, "field", "value").Err() }, + metaKey: store.HashMetaKey, + expireAt: func(raw []byte) (uint64, error) { + meta, err := store.UnmarshalHashMeta(raw) + return meta.ExpireAt, err + }, + }, + { + name: "set", + keyBase: "ttl:inline:set", + create: func(key string) error { return rdb.SAdd(ctx, key, "member").Err() }, + metaKey: store.SetMetaKey, + expireAt: func(raw []byte) (uint64, error) { + meta, err := store.UnmarshalSetMeta(raw) + return meta.ExpireAt, err + }, + }, + { + name: "zset", + keyBase: "ttl:inline:zset", + create: func(key string) error { + return rdb.ZAdd(ctx, key, redis.Z{Score: 1, Member: "member"}).Err() + }, + metaKey: store.ZSetMetaKey, + expireAt: func(raw []byte) (uint64, error) { + meta, err := store.UnmarshalZSetMeta(raw) + return meta.ExpireAt, err + }, + }, + { + name: "list", + keyBase: "ttl:inline:list", + create: func(key string) error { return rdb.RPush(ctx, key, "item").Err() }, + metaKey: store.ListMetaKey, + expireAt: func(raw []byte) (uint64, error) { + meta, err := store.UnmarshalListMeta(raw) + return meta.ExpireAt, err + }, + }, + { + name: "stream", + keyBase: "ttl:inline:stream", + create: func(key string) error { + return rdb.XAdd(ctx, &redis.XAddArgs{ + Stream: key, + Values: map[string]any{"field": "value"}, + }).Err() + }, + metaKey: store.StreamMetaKey, + expireAt: func(raw []byte) (uint64, error) { + meta, err := store.UnmarshalStreamMeta(raw) + return meta.ExpireAt, err + }, + }, + } + modes := []struct { + name string + expire func(string) error + }{ + { + name: "direct", + expire: func(key string) error { + return rdb.PExpire(ctx, key, ttl).Err() + }, + }, + { + name: "multi", + expire: func(key string) error { + pipe := rdb.TxPipeline() + pipe.PExpire(ctx, key, ttl) + _, err := pipe.Exec(ctx) + return err + }, + }, + { + name: "lua", + expire: func(key string) error { + _, err := rdb.Eval(ctx, `return redis.call("pexpire", KEYS[1], ARGV[1])`, []string{key}, int(ttl/time.Millisecond)).Result() + return err + }, + }, + } + + for _, tc := range cases { + for _, mode := range modes { + t.Run(tc.name+"/"+mode.name, func(t *testing.T) { + key := tc.keyBase + ":" + mode.name + require.NoError(t, tc.create(key)) + require.NoError(t, mode.expire(key)) + + readTS := server.readTS() + raw, err := server.store.GetAt(ctx, tc.metaKey([]byte(key)), readTS) + require.NoError(t, err) + expireAtMs, err := tc.expireAt(raw) + require.NoError(t, err) + require.Greater(t, expireAtMs, redisExpireAtMillis(time.Now().Add(ttl/2))) + + commitTS, err := server.coordinator.Clock().NextFenced() + require.NoError(t, err) + require.NoError(t, server.store.DeleteAt(ctx, redisTTLKey([]byte(key)), commitTS)) + + got, err := server.ttlAt(ctx, []byte(key), commitTS) + require.NoError(t, err) + require.NotNil(t, got, "ttlAt should read inline metadata after the scan index is deleted") + require.Equal(t, expireAtMs, redisExpireAtMillis(*got)) + }) + } + } +} + +func TestRedisCollectionExpireHandlesLegacyBlobs(t *testing.T) { + t.Parallel() + + ctx := context.Background() + expireAt := time.Now().Add(time.Hour) + cases := []struct { + name string + typ redisValueType + legacyKey func([]byte) []byte + payload func(t *testing.T) []byte + }{ + { + name: "hash", + typ: redisTypeHash, + legacyKey: redisHashKey, + payload: func(t *testing.T) []byte { + t.Helper() + raw, err := marshalHashValue(redisHashValue{"field": "value"}) + require.NoError(t, err) + return raw + }, + }, + { + name: "set", + typ: redisTypeSet, + legacyKey: redisSetKey, + payload: func(t *testing.T) []byte { + t.Helper() + raw, err := marshalSetValue(redisSetValue{Members: []string{"member"}}) + require.NoError(t, err) + return raw + }, + }, + { + name: "zset", + typ: redisTypeZSet, + legacyKey: redisZSetKey, + payload: func(t *testing.T) []byte { + t.Helper() + raw, err := marshalZSetValue(redisZSetValue{Entries: []redisZSetEntry{{Member: "member", Score: 1}}}) + require.NoError(t, err) + return raw + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + st := store.NewMVCCStore() + server := NewRedisServer(nil, "", st, newLocalAdapterCoordinator(st), nil, nil) + key := []byte("ttl:legacy:" + tc.name) + require.NoError(t, st.PutAt(ctx, tc.legacyKey(key), tc.payload(t), 1, 0)) + + typ, err := server.rawKeyTypeAt(ctx, key, 1) + require.NoError(t, err) + require.Equal(t, tc.typ, typ) + applied, err := server.dispatchExpireForType(ctx, key, 1, typ, expireAt) + require.NoError(t, err) + require.True(t, applied) + + readTS := st.LastCommitTS() + rawTTL, err := st.GetAt(ctx, redisTTLKey(key), readTS) + require.NoError(t, err) + ttl, err := decodeRedisTTL(rawTTL) + require.NoError(t, err) + require.Equal(t, redisExpireAtMillis(expireAt), redisExpireAtMillis(ttl)) + got, err := server.ttlAt(ctx, key, readTS) + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, redisExpireAtMillis(expireAt), redisExpireAtMillis(*got)) + }) + } +} + +func TestRedisTTLAtInlineNoTTLCollectionMetaSkipsLegacyFallback(t *testing.T) { + t.Parallel() + + ctx := context.Background() + expiredAt := time.Now().Add(-time.Hour) + cases := []struct { + name string + metaKey func([]byte) []byte + value func(t *testing.T) []byte + }{ + { + name: "list", + metaKey: store.ListMetaKey, + value: func(t *testing.T) []byte { + t.Helper() + raw, err := store.MarshalListMeta(store.ListMeta{Len: 1}) + require.NoError(t, err) + return raw + }, + }, + { + name: "hash", + metaKey: store.HashMetaKey, + value: func(t *testing.T) []byte { + t.Helper() + return store.MarshalHashMeta(store.HashMeta{Len: 1}) + }, + }, + { + name: "set", + metaKey: store.SetMetaKey, + value: func(t *testing.T) []byte { + t.Helper() + return store.MarshalSetMeta(store.SetMeta{Len: 1}) + }, + }, + { + name: "zset", + metaKey: store.ZSetMetaKey, + value: func(t *testing.T) []byte { + t.Helper() + return store.MarshalZSetMeta(store.ZSetMeta{Len: 1}) + }, + }, + { + name: "stream", + metaKey: store.StreamMetaKey, + value: func(t *testing.T) []byte { + t.Helper() + raw, err := store.MarshalStreamMeta(store.StreamMeta{Length: 1}) + require.NoError(t, err) + return raw + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + st := store.NewMVCCStore() + server := &RedisServer{store: st} + key := []byte("ttl:no-ttl-inline:" + tc.name) + require.NoError(t, st.PutAt(ctx, tc.metaKey(key), tc.value(t), 1, 0)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(key), encodeRedisTTL(expiredAt), 2, 0)) + + got, err := server.ttlAt(ctx, key, 2) + require.NoError(t, err) + require.Nil(t, got) + expired, err := server.hasExpired(ctx, key, 2, true) + require.NoError(t, err) + require.False(t, expired) + }) + } +} + +func TestRedisCollectionExpireAllowsDeltaHeavyCollections(t *testing.T) { + t.Parallel() + + ctx := context.Background() + expireAt := time.Now().Add(time.Hour) + cases := []struct { + name string + typ redisValueType + metaKey func([]byte) []byte + baseValue func(t *testing.T) []byte + deltaKey func([]byte, uint64) []byte + deltaValue []byte + metaExpireAt func([]byte) (uint64, error) + }{ + { + name: "list", + typ: redisTypeList, + metaKey: store.ListMetaKey, + baseValue: func(t *testing.T) []byte { + t.Helper() + raw, err := store.MarshalListMeta(store.ListMeta{Head: 0, Len: 1}) + require.NoError(t, err) + return raw + }, + deltaKey: func(key []byte, ts uint64) []byte { + return store.ListMetaDeltaKey(key, ts, 0) + }, + deltaValue: store.MarshalListMetaDelta(store.ListMetaDelta{HeadDelta: 0, LenDelta: 1}), + metaExpireAt: func(raw []byte) (uint64, error) { + meta, err := store.UnmarshalListMeta(raw) + return meta.ExpireAt, err + }, + }, + { + name: "hash", + typ: redisTypeHash, + metaKey: store.HashMetaKey, + baseValue: func(t *testing.T) []byte { + t.Helper() + return store.MarshalHashMeta(store.HashMeta{Len: 1}) + }, + deltaKey: func(key []byte, ts uint64) []byte { + return store.HashMetaDeltaKey(key, ts, 0) + }, + deltaValue: store.MarshalHashMetaDelta(store.HashMetaDelta{LenDelta: 1}), + metaExpireAt: func(raw []byte) (uint64, error) { + meta, err := store.UnmarshalHashMeta(raw) + return meta.ExpireAt, err + }, + }, + { + name: "set", + typ: redisTypeSet, + metaKey: store.SetMetaKey, + baseValue: func(t *testing.T) []byte { + t.Helper() + return store.MarshalSetMeta(store.SetMeta{Len: 1}) + }, + deltaKey: func(key []byte, ts uint64) []byte { + return store.SetMetaDeltaKey(key, ts, 0) + }, + deltaValue: store.MarshalSetMetaDelta(store.SetMetaDelta{LenDelta: 1}), + metaExpireAt: func(raw []byte) (uint64, error) { + meta, err := store.UnmarshalSetMeta(raw) + return meta.ExpireAt, err + }, + }, + { + name: "zset", + typ: redisTypeZSet, + metaKey: store.ZSetMetaKey, + baseValue: func(t *testing.T) []byte { + t.Helper() + return store.MarshalZSetMeta(store.ZSetMeta{Len: 1}) + }, + deltaKey: func(key []byte, ts uint64) []byte { + return store.ZSetMetaDeltaKey(key, ts, 0) + }, + deltaValue: store.MarshalZSetMetaDelta(store.ZSetMetaDelta{LenDelta: 1}), + metaExpireAt: func(raw []byte) (uint64, error) { + meta, err := store.UnmarshalZSetMeta(raw) + return meta.ExpireAt, err + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + st := store.NewMVCCStore() + coord := newLocalAdapterCoordinator(st) + compactor := NewDeltaCompactor(st, coord) + server := &RedisServer{store: st, coordinator: coord, compactor: compactor} + key := []byte("ttl:delta-heavy:" + tc.name) + require.NoError(t, st.PutAt(ctx, tc.metaKey(key), tc.baseValue(t), 1, 0)) + const firstDeltaTS uint64 = 2 + const lastDeltaTS uint64 = store.MaxDeltaScanLimit + firstDeltaTS + for ts := firstDeltaTS; ts <= lastDeltaTS; ts++ { + require.NoError(t, st.PutAt(ctx, tc.deltaKey(key, ts), tc.deltaValue, ts, 0)) + } + + readTS := st.LastCommitTS() + applied, err := server.dispatchCollectionExpire(ctx, key, readTS, tc.typ, expireAt) + require.NoError(t, err) + require.True(t, applied) + + readTS = st.LastCommitTS() + raw, err := st.GetAt(ctx, tc.metaKey(key), readTS) + require.NoError(t, err) + expireAtMs, err := tc.metaExpireAt(raw) + require.NoError(t, err) + require.Equal(t, redisExpireAtMillis(expireAt), expireAtMs) + rawTTL, err := st.GetAt(ctx, redisTTLKey(key), readTS) + require.NoError(t, err) + ttl, err := decodeRedisTTL(rawTTL) + require.NoError(t, err) + require.Equal(t, redisExpireAtMillis(expireAt), redisExpireAtMillis(ttl)) + select { + case req := <-compactor.urgentCh: + require.Equal(t, tc.name, req.typeName) + require.Equal(t, key, req.userKey) + default: + t.Fatalf("expected urgent compaction request for %s", tc.name) + } + }) + } +} + +func TestRedisCollectionExpireTriggersCompactionForDeltaOnlyTruncatedCollections(t *testing.T) { + t.Parallel() + + ctx := context.Background() + expireAt := time.Now().Add(time.Hour) + cases := []struct { + name string + typ redisValueType + deltaKey func([]byte, uint64) []byte + deltaValue []byte + }{ + { + name: "list", + typ: redisTypeList, + deltaKey: func(key []byte, ts uint64) []byte { return store.ListMetaDeltaKey(key, ts, 0) }, + deltaValue: store.MarshalListMetaDelta(store.ListMetaDelta{LenDelta: 1}), + }, + { + name: "hash", + typ: redisTypeHash, + deltaKey: func(key []byte, ts uint64) []byte { return store.HashMetaDeltaKey(key, ts, 0) }, + deltaValue: store.MarshalHashMetaDelta(store.HashMetaDelta{LenDelta: 1}), + }, + { + name: "set", + typ: redisTypeSet, + deltaKey: func(key []byte, ts uint64) []byte { return store.SetMetaDeltaKey(key, ts, 0) }, + deltaValue: store.MarshalSetMetaDelta(store.SetMetaDelta{LenDelta: 1}), + }, + { + name: "zset", + typ: redisTypeZSet, + deltaKey: func(key []byte, ts uint64) []byte { return store.ZSetMetaDeltaKey(key, ts, 0) }, + deltaValue: store.MarshalZSetMetaDelta(store.ZSetMetaDelta{LenDelta: 1}), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + st := store.NewMVCCStore() + coord := newLocalAdapterCoordinator(st) + compactor := NewDeltaCompactor(st, coord) + server := &RedisServer{store: st, coordinator: coord, compactor: compactor} + key := []byte("ttl:delta-only-truncated:" + tc.name) + for ts := uint64(1); ts <= store.MaxDeltaScanLimit+1; ts++ { + require.NoError(t, st.PutAt(ctx, tc.deltaKey(key, ts), tc.deltaValue, ts, 0)) + } + + applied, err := server.dispatchCollectionExpire(ctx, key, st.LastCommitTS(), tc.typ, expireAt) + require.ErrorIs(t, err, ErrDeltaScanTruncated) + require.False(t, applied) + _, err = st.GetAt(ctx, redisTTLKey(key), st.LastCommitTS()) + require.ErrorIs(t, err, store.ErrKeyNotFound) + + select { + case req := <-compactor.urgentCh: + require.Equal(t, tc.name, req.typeName) + require.Equal(t, key, req.userKey) + default: + t.Fatalf("expected urgent compaction request for %s", tc.name) + } + }) + } +} + +func TestRedisMultiExecStagedCollectionCreateWritesInlineTTL(t *testing.T) { + nodes, _, _ := createNode(t, 3) + defer shutdown(nodes) + + ctx := context.Background() + rdb := redis.NewClient(&redis.Options{Addr: nodes[0].redisAddress}) + defer func() { _ = rdb.Close() }() + + server := nodes[0].redisServer + ttl := 50 * time.Second + cases := []struct { + name string + key string + commands [][]any + metaKey func([]byte) []byte + metaExpireAt func([]byte) (uint64, error) + }{ + { + name: "list", + key: "ttl:multi-create:list", + commands: [][]any{{"RPUSH", "ttl:multi-create:list", "value"}}, + metaKey: store.ListMetaKey, + metaExpireAt: func(raw []byte) (uint64, error) { + meta, err := store.UnmarshalListMeta(raw) + return meta.ExpireAt, err + }, + }, + { + name: "hash", + key: "ttl:multi-create:hash", + commands: [][]any{{"HSET", "ttl:multi-create:hash", "field", "value"}}, + metaKey: store.HashMetaKey, + metaExpireAt: func(raw []byte) (uint64, error) { + meta, err := store.UnmarshalHashMeta(raw) + return meta.ExpireAt, err + }, + }, + { + name: "zset", + key: "ttl:multi-create:zset", + commands: [][]any{{"ZINCRBY", "ttl:multi-create:zset", "1", "member"}}, + metaKey: store.ZSetMetaKey, + metaExpireAt: func(raw []byte) (uint64, error) { + meta, err := store.UnmarshalZSetMeta(raw) + return meta.ExpireAt, err + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.NoError(t, rdb.Do(ctx, "MULTI").Err()) + for _, cmd := range tc.commands { + require.Equal(t, "QUEUED", rdb.Do(ctx, cmd...).Val()) + } + require.Equal(t, "QUEUED", rdb.Do(ctx, "PEXPIRE", tc.key, int(ttl/time.Millisecond)).Val()) + _, err := rdb.Do(ctx, "EXEC").Result() + require.NoError(t, err) + + key := []byte(tc.key) + var expireAtMs uint64 + require.Eventually(t, func() bool { + readTS := server.readTS() + raw, err := server.store.GetAt(ctx, tc.metaKey(key), readTS) + if err != nil { + return false + } + got, err := tc.metaExpireAt(raw) + if err != nil { + return false + } + expireAtMs = got + return got > redisExpireAtMillis(time.Now().Add(ttl/2)) + }, 5*time.Second, 20*time.Millisecond) + + commitTS, err := server.coordinator.Clock().NextFenced() + require.NoError(t, err) + require.NoError(t, server.store.DeleteAt(ctx, redisTTLKey(key), commitTS)) + got, err := server.ttlAt(ctx, key, commitTS) + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, expireAtMs, redisExpireAtMillis(*got)) + }) + } +} + +func TestRedisXAddPreservesStreamInlineTTL(t *testing.T) { + nodes, _, _ := createNode(t, 3) + defer shutdown(nodes) + + ctx := context.Background() + rdb := redis.NewClient(&redis.Options{Addr: nodes[0].redisAddress}) + defer func() { _ = rdb.Close() }() + + server := nodes[0].redisServer + key := "ttl:stream:xadd-preserve" + ttl := 50 * time.Second + require.NoError(t, rdb.XAdd(ctx, &redis.XAddArgs{ + Stream: key, + Values: map[string]any{"field": "one"}, + }).Err()) + require.NoError(t, rdb.PExpire(ctx, key, ttl).Err()) + + readTS := server.readTS() + raw, err := server.store.GetAt(ctx, store.StreamMetaKey([]byte(key)), readTS) + require.NoError(t, err) + before, err := store.UnmarshalStreamMeta(raw) + require.NoError(t, err) + require.Greater(t, before.ExpireAt, redisExpireAtMillis(time.Now().Add(ttl/2))) + + require.NoError(t, rdb.XAdd(ctx, &redis.XAddArgs{ + Stream: key, + Values: map[string]any{"field": "two"}, + }).Err()) + + readTS = server.readTS() + raw, err = server.store.GetAt(ctx, store.StreamMetaKey([]byte(key)), readTS) + require.NoError(t, err) + after, err := store.UnmarshalStreamMeta(raw) + require.NoError(t, err) + require.Equal(t, int64(2), after.Length) + require.Equal(t, before.ExpireAt, after.ExpireAt) + + commitTS, err := server.coordinator.Clock().NextFenced() + require.NoError(t, err) + require.NoError(t, server.store.DeleteAt(ctx, redisTTLKey([]byte(key)), commitTS)) + got, err := server.ttlAt(ctx, []byte(key), commitTS) + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, after.ExpireAt, redisExpireAtMillis(*got)) +} + +func TestRedisLegacyCollectionMigrationPreservesLegacyTTL(t *testing.T) { + t.Parallel() + + ctx := context.Background() + expireAt := time.Now().Add(time.Hour) + cases := []struct { + name string + legacy func([]byte) []byte + payload func(t *testing.T) []byte + build func(*RedisServer, context.Context, []byte, uint64) ([]*kv.Elem[kv.OP], error) + metaKey func([]byte) []byte + expireAt func([]byte) (uint64, error) + }{ + { + name: "hash", + legacy: redisHashKey, + payload: func(t *testing.T) []byte { + t.Helper() + raw, err := marshalHashValue(redisHashValue{"field": "value"}) + require.NoError(t, err) + return raw + }, + build: func(server *RedisServer, ctx context.Context, key []byte, readTS uint64) ([]*kv.Elem[kv.OP], error) { + return server.buildHashLegacyMigrationElems(ctx, key, readTS) + }, + metaKey: store.HashMetaKey, + expireAt: func(raw []byte) (uint64, error) { + meta, err := store.UnmarshalHashMeta(raw) + return meta.ExpireAt, err + }, + }, + { + name: "set", + legacy: redisSetKey, + payload: func(t *testing.T) []byte { + t.Helper() + raw, err := marshalSetValue(redisSetValue{Members: []string{"member"}}) + require.NoError(t, err) + return raw + }, + build: func(server *RedisServer, ctx context.Context, key []byte, readTS uint64) ([]*kv.Elem[kv.OP], error) { + return server.buildSetLegacyMigrationElems(ctx, key, readTS) + }, + metaKey: store.SetMetaKey, + expireAt: func(raw []byte) (uint64, error) { + meta, err := store.UnmarshalSetMeta(raw) + return meta.ExpireAt, err + }, + }, + { + name: "zset", + legacy: redisZSetKey, + payload: func(t *testing.T) []byte { + t.Helper() + raw, err := marshalZSetValue(redisZSetValue{Entries: []redisZSetEntry{{Member: "member", Score: 1}}}) + require.NoError(t, err) + return raw + }, + build: func(server *RedisServer, ctx context.Context, key []byte, readTS uint64) ([]*kv.Elem[kv.OP], error) { + return server.buildZSetLegacyMigrationElems(ctx, key, readTS) + }, + metaKey: store.ZSetMetaKey, + expireAt: func(raw []byte) (uint64, error) { + meta, err := store.UnmarshalZSetMeta(raw) + return meta.ExpireAt, err + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + st := store.NewMVCCStore() + server := NewRedisServer(nil, "", st, newLocalAdapterCoordinator(st), nil, nil) + key := []byte("ttl:legacy-migrate:" + tc.name) + require.NoError(t, st.PutAt(ctx, tc.legacy(key), tc.payload(t), 10, 0)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(key), encodeRedisTTL(expireAt), 10, 0)) + + elems, err := tc.build(server, ctx, key, 10) + require.NoError(t, err) + raw := elemValueForKey(t, elems, tc.metaKey(key)) + got, err := tc.expireAt(raw) + require.NoError(t, err) + require.Equal(t, redisExpireAtMillis(expireAt), got) + }) + } +} + +func TestRedisLegacyCollectionMigrationCleansExpiredLegacyBlob(t *testing.T) { + t.Parallel() + + ctx := context.Background() + expireAt := time.Now().Add(-time.Hour) + cases := []struct { + name string + legacy func([]byte) []byte + payload func(t *testing.T) []byte + build func(*RedisServer, context.Context, []byte, uint64) ([]*kv.Elem[kv.OP], error) + metaKey func([]byte) []byte + }{ + { + name: "hash", + legacy: redisHashKey, + payload: func(t *testing.T) []byte { + t.Helper() + raw, err := marshalHashValue(redisHashValue{"field": "value"}) + require.NoError(t, err) + return raw + }, + build: func(server *RedisServer, ctx context.Context, key []byte, readTS uint64) ([]*kv.Elem[kv.OP], error) { + return server.buildHashLegacyMigrationElems(ctx, key, readTS) + }, + metaKey: store.HashMetaKey, + }, + { + name: "set", + legacy: redisSetKey, + payload: func(t *testing.T) []byte { + t.Helper() + raw, err := marshalSetValue(redisSetValue{Members: []string{"member"}}) + require.NoError(t, err) + return raw + }, + build: func(server *RedisServer, ctx context.Context, key []byte, readTS uint64) ([]*kv.Elem[kv.OP], error) { + return server.buildSetLegacyMigrationElems(ctx, key, readTS) + }, + metaKey: store.SetMetaKey, + }, + { + name: "zset", + legacy: redisZSetKey, + payload: func(t *testing.T) []byte { + t.Helper() + raw, err := marshalZSetValue(redisZSetValue{Entries: []redisZSetEntry{{Member: "member", Score: 1}}}) + require.NoError(t, err) + return raw + }, + build: func(server *RedisServer, ctx context.Context, key []byte, readTS uint64) ([]*kv.Elem[kv.OP], error) { + return server.buildZSetLegacyMigrationElems(ctx, key, readTS) + }, + metaKey: store.ZSetMetaKey, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + st := store.NewMVCCStore() + server := NewRedisServer(nil, "", st, newLocalAdapterCoordinator(st), nil, nil) + key := []byte("ttl:legacy-expired-migrate:" + tc.name) + require.NoError(t, st.PutAt(ctx, tc.legacy(key), tc.payload(t), 10, 0)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(key), encodeRedisTTL(expireAt), 10, 0)) + + elems, err := tc.build(server, ctx, key, 10) + require.NoError(t, err) + requireNoPutElemByKey(t, elems, tc.metaKey(key)) + require.True(t, elemDelKeysContain(elems, tc.legacy(key))) + require.True(t, elemDelKeysContain(elems, redisTTLKey(key))) + }) + } +} + +func TestRedisHSetExpiredLegacyHashRecreatesWithoutStaleFields(t *testing.T) { + t.Parallel() + + ctx := context.Background() + st := store.NewMVCCStore() + server := NewRedisServer(nil, "", st, newLocalAdapterCoordinator(st), nil, nil) + key := []byte("ttl:legacy-expired-hset") + raw, err := marshalHashValue(redisHashValue{"stale": "old"}) + require.NoError(t, err) + require.NoError(t, st.PutAt(ctx, redisHashKey(key), raw, 10, 0)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(key), encodeRedisTTL(time.Now().Add(-time.Hour)), 10, 0)) + + added, err := server.applyHashFieldPairs(key, [][]byte{[]byte("fresh"), []byte("new")}) + require.NoError(t, err) + require.Equal(t, 1, added) + + readTS := st.LastCommitTS() + value, err := server.loadHashAt(ctx, key, readTS) + require.NoError(t, err) + require.Equal(t, redisHashValue{"fresh": "new"}, value) + ttl, err := server.ttlAt(ctx, key, readTS) + require.NoError(t, err) + require.Nil(t, ttl) + _, err = st.GetAt(ctx, redisHashKey(key), readTS) + require.ErrorIs(t, err, store.ErrKeyNotFound) + _, err = st.GetAt(ctx, redisTTLKey(key), readTS) + require.ErrorIs(t, err, store.ErrKeyNotFound) +} + +func TestRedisExpiredInlineCollectionRecreateCleansPhysicalRows(t *testing.T) { + t.Parallel() + + ctx := context.Background() + expiredAt := time.Now().Add(-time.Hour) + expiredMs := redisExpireAtMillis(expiredAt) + + t.Run("list", func(t *testing.T) { + t.Parallel() + + st := store.NewMVCCStore() + server := NewRedisServer(nil, "", st, newLocalAdapterCoordinator(st), nil, nil) + key := []byte("ttl:inline-expired-recreate:list") + metaRaw, err := store.MarshalListMeta(store.ListMeta{Head: 0, Tail: 1, Len: 1, ExpireAt: expiredMs}) + require.NoError(t, err) + require.NoError(t, st.PutAt(ctx, store.ListMetaKey(key), metaRaw, 10, 0)) + require.NoError(t, st.PutAt(ctx, store.ListItemKey(key, 0), []byte("old"), 10, 0)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(key), encodeRedisTTL(expiredAt), 10, 0)) + + n, err := server.listRPush(ctx, key, [][]byte{[]byte("new")}) + require.NoError(t, err) + require.Equal(t, int64(1), n) + + readTS := st.LastCommitTS() + values, err := server.listValuesAt(ctx, key, readTS) + require.NoError(t, err) + require.Equal(t, []string{"new"}, values) + requireNoTTLAt(t, server, st, key, readTS) + }) + + t.Run("hash", func(t *testing.T) { + t.Parallel() + + st := store.NewMVCCStore() + server := NewRedisServer(nil, "", st, newLocalAdapterCoordinator(st), nil, nil) + key := []byte("ttl:inline-expired-recreate:hash") + require.NoError(t, st.PutAt(ctx, store.HashMetaKey(key), store.MarshalHashMeta(store.HashMeta{Len: 1, ExpireAt: expiredMs}), 10, 0)) + require.NoError(t, st.PutAt(ctx, store.HashFieldKey(key, []byte("old")), []byte("stale"), 10, 0)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(key), encodeRedisTTL(expiredAt), 10, 0)) + + added, err := server.applyHashFieldPairs(key, [][]byte{[]byte("fresh"), []byte("new")}) + require.NoError(t, err) + require.Equal(t, 1, added) + + readTS := st.LastCommitTS() + value, err := server.loadHashAt(ctx, key, readTS) + require.NoError(t, err) + require.Equal(t, redisHashValue{"fresh": "new"}, value) + requireMissingAt(t, st, store.HashFieldKey(key, []byte("old")), readTS) + requireNoTTLAt(t, server, st, key, readTS) + }) + + t.Run("set", func(t *testing.T) { + t.Parallel() + + st := store.NewMVCCStore() + server := NewRedisServer(nil, "", st, newLocalAdapterCoordinator(st), nil, nil) + key := []byte("ttl:inline-expired-recreate:set") + require.NoError(t, st.PutAt(ctx, store.SetMetaKey(key), store.MarshalSetMeta(store.SetMeta{Len: 1, ExpireAt: expiredMs}), 10, 0)) + require.NoError(t, st.PutAt(ctx, store.SetMemberKey(key, []byte("old")), []byte{}, 10, 0)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(key), encodeRedisTTL(expiredAt), 10, 0)) + + conn := &recordingConn{} + server.sadd(conn, redcon.Command{Args: [][]byte{[]byte(cmdSAdd), key, []byte("new")}}) + require.Empty(t, conn.err) + require.Equal(t, int64(1), conn.int) + + readTS := st.LastCommitTS() + requireMissingAt(t, st, store.SetMemberKey(key, []byte("old")), readTS) + requireExistsAt(t, st, store.SetMemberKey(key, []byte("new")), readTS) + requireNoTTLAt(t, server, st, key, readTS) + }) + + t.Run("zset", func(t *testing.T) { + t.Parallel() + + st := store.NewMVCCStore() + server := NewRedisServer(nil, "", st, newLocalAdapterCoordinator(st), nil, nil) + key := []byte("ttl:inline-expired-recreate:zset") + require.NoError(t, st.PutAt(ctx, store.ZSetMetaKey(key), store.MarshalZSetMeta(store.ZSetMeta{Len: 1, ExpireAt: expiredMs}), 10, 0)) + require.NoError(t, st.PutAt(ctx, store.ZSetMemberKey(key, []byte("old")), store.MarshalZSetScore(1), 10, 0)) + require.NoError(t, st.PutAt(ctx, store.ZSetScoreKey(key, 1, []byte("old")), []byte{}, 10, 0)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(key), encodeRedisTTL(expiredAt), 10, 0)) + + conn := &recordingConn{} + server.zadd(conn, redcon.Command{Args: [][]byte{[]byte(cmdZAdd), key, []byte("2"), []byte("new")}}) + require.Empty(t, conn.err) + require.Equal(t, int64(1), conn.int) + + readTS := st.LastCommitTS() + requireMissingAt(t, st, store.ZSetMemberKey(key, []byte("old")), readTS) + requireMissingAt(t, st, store.ZSetScoreKey(key, 1, []byte("old")), readTS) + requireExistsAt(t, st, store.ZSetMemberKey(key, []byte("new")), readTS) + requireNoTTLAt(t, server, st, key, readTS) + }) +} + +func TestRedisHIncrByExpiredLegacyHashStartsFromZero(t *testing.T) { + t.Parallel() + + ctx := context.Background() + st := store.NewMVCCStore() + server := NewRedisServer(nil, "", st, newLocalAdapterCoordinator(st), nil, nil) + key := []byte("ttl:legacy-expired-hincrby") + raw, err := marshalHashValue(redisHashValue{"counter": "not-an-int"}) + require.NoError(t, err) + require.NoError(t, st.PutAt(ctx, redisHashKey(key), raw, 10, 0)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(key), encodeRedisTTL(time.Now().Add(-time.Hour)), 10, 0)) + + conn := &recordingConn{} + server.hincrby(conn, redcon.Command{Args: [][]byte{[]byte(cmdHIncrBy), key, []byte("counter"), []byte("1")}}) + require.Empty(t, conn.err) + require.Equal(t, int64(1), conn.int) + + readTS := st.LastCommitTS() + value, err := server.loadHashAt(ctx, key, readTS) + require.NoError(t, err) + require.Equal(t, redisHashValue{"counter": "1"}, value) + requireMissingAt(t, st, redisHashKey(key), readTS) + requireNoTTLAt(t, server, st, key, readTS) +} + +func TestRedisTTLInlineMigratorCleansExpiredLegacyTTLIndex(t *testing.T) { + t.Parallel() + + ctx := context.Background() + expireAt := time.Now().Add(-time.Hour) + cases := []struct { + name string + legacy func([]byte) []byte + payload func(t *testing.T) []byte + metaKey func([]byte) []byte + }{ + { + name: "hash", + legacy: redisHashKey, + payload: func(t *testing.T) []byte { + t.Helper() + raw, err := marshalHashValue(redisHashValue{"field": "value"}) + require.NoError(t, err) + return raw + }, + metaKey: store.HashMetaKey, + }, + { + name: "set", + legacy: redisSetKey, + payload: func(t *testing.T) []byte { + t.Helper() + raw, err := marshalSetValue(redisSetValue{Members: []string{"member"}}) + require.NoError(t, err) + return raw + }, + metaKey: store.SetMetaKey, + }, + { + name: "zset", + legacy: redisZSetKey, + payload: func(t *testing.T) []byte { + t.Helper() + raw, err := marshalZSetValue(redisZSetValue{Entries: []redisZSetEntry{{Member: "member", Score: 1}}}) + require.NoError(t, err) + return raw + }, + metaKey: store.ZSetMetaKey, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + st := store.NewMVCCStore() + compactor := NewDeltaCompactor(st, newLocalAdapterCoordinator(st)) + key := []byte("ttl:migrator-expired-legacy:" + tc.name) + ttlKey := redisTTLKey(key) + ttlPayload := encodeRedisTTL(expireAt) + require.NoError(t, st.PutAt(ctx, tc.legacy(key), tc.payload(t), 10, 0)) + require.NoError(t, st.PutAt(ctx, ttlKey, ttlPayload, 10, 0)) + + elems, err := compactor.migrateTTLIndexedCollectionElems(ctx, &store.KVPair{Key: ttlKey, Value: ttlPayload}, 10) + require.NoError(t, err) + requireNoPutElemByKey(t, elems, tc.metaKey(key)) + require.True(t, elemDelKeysContain(elems, tc.legacy(key))) + require.True(t, elemDelKeysContain(elems, ttlKey)) + }) + } +} + +func TestRedisLegacyStreamRecreateClearsExpiredLegacyTTL(t *testing.T) { + t.Parallel() + + ctx := context.Background() + st := store.NewMVCCStore() + server := NewRedisServer(nil, "", st, newLocalAdapterCoordinator(st), nil, nil) + key := []byte("ttl:legacy-expired-migrate:stream") + legacy := redisStreamValue{Entries: []redisStreamEntry{ + newRedisStreamEntry("1700000000000-0", []string{"field", "value"}), + }} + payload, err := marshalStreamValue(legacy) + require.NoError(t, err) + require.NoError(t, st.PutAt(ctx, redisStreamKey(key), payload, 10, 0)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(key), encodeRedisTTL(time.Now().Add(-time.Hour)), 10, 0)) + + cleanup, meta, metaFound, err := server.streamWriteBase(ctx, key, 10) + require.NoError(t, err) + require.NotEmpty(t, cleanup) + require.False(t, metaFound) + require.Zero(t, meta.ExpireAt) + require.True(t, elemDelKeysContain(cleanup, redisStreamKey(key))) + require.True(t, elemDelKeysContain(cleanup, redisTTLKey(key))) +} + +func elemValueForKey(t *testing.T, elems []*kv.Elem[kv.OP], want []byte) []byte { + t.Helper() + for _, elem := range elems { + if elem.Op == kv.Put && string(elem.Key) == string(want) { + return elem.Value + } + } + t.Fatalf("missing Put elem for key %q", want) + return nil +} + +func elemDelKeysContain(elems []*kv.Elem[kv.OP], want []byte) bool { + for _, elem := range elems { + if elem.Op == kv.Del && string(elem.Key) == string(want) { + return true + } + } + return false +} + +func requireMissingAt(t *testing.T, st store.MVCCStore, key []byte, readTS uint64) { + t.Helper() + _, err := st.GetAt(context.Background(), key, readTS) + require.ErrorIs(t, err, store.ErrKeyNotFound) +} + +func requireExistsAt(t *testing.T, st store.MVCCStore, key []byte, readTS uint64) { + t.Helper() + exists, err := st.ExistsAt(context.Background(), key, readTS) + require.NoError(t, err) + require.True(t, exists) +} + +func requireNoTTLAt(t *testing.T, server *RedisServer, st store.MVCCStore, key []byte, readTS uint64) { + t.Helper() + ttl, err := server.ttlAt(context.Background(), key, readTS) + require.NoError(t, err) + require.Nil(t, ttl) + requireMissingAt(t, st, redisTTLKey(key), readTS) +} + +func TestRedisHDelLegacyRewritePreservesLegacyTTL(t *testing.T) { + t.Parallel() + + ctx := context.Background() + st := store.NewMVCCStore() + server := &RedisServer{store: st, coordinator: newLocalAdapterCoordinator(st)} + key := []byte("ttl:hdel:legacy-rewrite") + raw, err := marshalHashValue(redisHashValue{"drop": "old", "keep": "value"}) + require.NoError(t, err) + expireAt := time.Now().Add(time.Hour) + require.NoError(t, st.PutAt(ctx, redisHashKey(key), raw, 10, 0)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(key), encodeRedisTTL(expireAt), 11, 0)) + + require.NoError(t, server.persistHashTxn(ctx, key, st.LastCommitTS(), redisHashValue{"keep": "value"})) + + readTS := st.LastCommitTS() + metaRaw, err := st.GetAt(ctx, store.HashMetaKey(key), readTS) + require.NoError(t, err) + meta, err := store.UnmarshalHashMeta(metaRaw) + require.NoError(t, err) + require.Equal(t, int64(1), meta.Len) + require.Equal(t, redisExpireAtMillis(expireAt), meta.ExpireAt) + + require.NoError(t, st.DeleteAt(ctx, redisTTLKey(key), readTS+1)) + got, err := server.ttlAt(ctx, key, readTS+1) + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, meta.ExpireAt, redisExpireAtMillis(*got)) +} + +func TestRedisLuaZSetDeltaTTLCompactionPreservesCardinality(t *testing.T) { + nodes, _, _ := createNode(t, 3) + defer shutdown(nodes) + + ctx := context.Background() + rdb := redis.NewClient(&redis.Options{Addr: nodes[0].redisAddress}) + defer func() { _ = rdb.Close() }() + + server := nodes[0].redisServer + keyString := "ttl:lua-zset-delta-compaction" + key := []byte(keyString) + require.NoError(t, server.store.PutAt(ctx, store.ZSetMetaKey(key), store.MarshalZSetMeta(store.ZSetMeta{Len: 1}), 10, 0)) + for i := uint64(0); i < store.MaxDeltaScanLimit; i++ { + require.NoError(t, server.store.PutAt(ctx, + store.ZSetMetaDeltaKey(key, 11+i, 0), + store.MarshalZSetMetaDelta(store.ZSetMetaDelta{LenDelta: 1}), + 11+i, + 0, + )) + } + + ttl := 50 * time.Second + got, err := rdb.Eval(ctx, ` +redis.call("zadd", KEYS[1], "2", "new-member") +return redis.call("pexpire", KEYS[1], ARGV[1]) +`, []string{keyString}, int(ttl/time.Millisecond)).Result() + require.NoError(t, err) + require.Equal(t, int64(1), got) + + readTS := server.readTS() + raw, err := server.store.GetAt(ctx, store.ZSetMetaKey(key), readTS) + require.NoError(t, err) + meta, err := store.UnmarshalZSetMeta(raw) + require.NoError(t, err) + require.Equal(t, int64(store.MaxDeltaScanLimit+2), meta.Len) + require.Greater(t, meta.ExpireAt, redisExpireAtMillis(time.Now().Add(ttl/2))) + + commitTS, err := server.coordinator.Clock().NextFenced() + require.NoError(t, err) + require.NoError(t, server.store.DeleteAt(ctx, redisTTLKey(key), commitTS)) + ttlValue, err := server.ttlAt(ctx, key, commitTS) + require.NoError(t, err) + require.NotNil(t, ttlValue) + require.Equal(t, meta.ExpireAt, redisExpireAtMillis(*ttlValue)) +} + +func TestRedisLuaNonStringTTLDeltaOnlyTruncatedReturnsError(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cases := []struct { + name string + typ redisValueType + deltaKey func([]byte, uint64) []byte + deltaValue []byte + }{ + { + name: "hash", + typ: redisTypeHash, + deltaKey: func(key []byte, ts uint64) []byte { return store.HashMetaDeltaKey(key, ts, 0) }, + deltaValue: store.MarshalHashMetaDelta(store.HashMetaDelta{LenDelta: 1}), + }, + { + name: "set", + typ: redisTypeSet, + deltaKey: func(key []byte, ts uint64) []byte { return store.SetMetaDeltaKey(key, ts, 0) }, + deltaValue: store.MarshalSetMetaDelta(store.SetMetaDelta{LenDelta: 1}), + }, + { + name: "zset", + typ: redisTypeZSet, + deltaKey: func(key []byte, ts uint64) []byte { return store.ZSetMetaDeltaKey(key, ts, 0) }, + deltaValue: store.MarshalZSetMetaDelta(store.ZSetMetaDelta{LenDelta: 1}), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + server, st := newRedisStorageMigrationTestServer(t) + key := []byte("ttl:lua:delta-only-truncated:" + tc.name) + for ts := uint64(1); ts <= store.MaxDeltaScanLimit+1; ts++ { + require.NoError(t, st.PutAt(ctx, tc.deltaKey(key, ts), tc.deltaValue, ts, 0)) + } + + expireAt := time.Now().Add(time.Minute) + scriptCtx := &luaScriptContext{ + server: server, + startTS: store.MaxDeltaScanLimit + 2, + ttls: map[string]*luaTTLState{ + string(key): {loaded: true, dirty: true, value: &expireAt}, + }, + } + elems, err := scriptCtx.nonStringTTLElems(ctx, string(key), tc.typ, true) + require.ErrorIs(t, err, ErrDeltaScanTruncated) + require.Empty(t, elems) + }) + } +} + +func TestRedisLuaStagedCollectionCreateWritesInlineTTL(t *testing.T) { + nodes, _, _ := createNode(t, 3) + defer shutdown(nodes) + + ctx := context.Background() + rdb := redis.NewClient(&redis.Options{Addr: nodes[0].redisAddress}) + defer func() { _ = rdb.Close() }() + + server := nodes[0].redisServer + ttl := 50 * time.Second + cases := []struct { + name string + key string + script string + metaKey func([]byte) []byte + metaExpireAt func([]byte) (uint64, error) + }{ + { + name: "list", + key: "ttl:lua-create:list", + script: `redis.call("rpush", KEYS[1], "value"); return redis.call("pexpire", KEYS[1], ARGV[1])`, + metaKey: store.ListMetaKey, + metaExpireAt: func(raw []byte) (uint64, error) { + meta, err := store.UnmarshalListMeta(raw) + return meta.ExpireAt, err + }, + }, + { + name: "hash", + key: "ttl:lua-create:hash", + script: `redis.call("hset", KEYS[1], "field", "value"); return redis.call("pexpire", KEYS[1], ARGV[1])`, + metaKey: store.HashMetaKey, + metaExpireAt: func(raw []byte) (uint64, error) { + meta, err := store.UnmarshalHashMeta(raw) + return meta.ExpireAt, err + }, + }, + { + name: "set", + key: "ttl:lua-create:set", + script: `redis.call("sadd", KEYS[1], "member"); return redis.call("pexpire", KEYS[1], ARGV[1])`, + metaKey: store.SetMetaKey, + metaExpireAt: func(raw []byte) (uint64, error) { + meta, err := store.UnmarshalSetMeta(raw) + return meta.ExpireAt, err + }, + }, + { + name: "zset", + key: "ttl:lua-create:zset", + script: `redis.call("zadd", KEYS[1], "1", "member"); return redis.call("pexpire", KEYS[1], ARGV[1])`, + metaKey: store.ZSetMetaKey, + metaExpireAt: func(raw []byte) (uint64, error) { + meta, err := store.UnmarshalZSetMeta(raw) + return meta.ExpireAt, err + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := rdb.Eval(ctx, tc.script, []string{tc.key}, int(ttl/time.Millisecond)).Result() + require.NoError(t, err) + require.Equal(t, int64(1), got) + + key := []byte(tc.key) + var expireAtMs uint64 + require.Eventually(t, func() bool { + readTS := server.readTS() + raw, err := server.store.GetAt(ctx, tc.metaKey(key), readTS) + if err != nil { + return false + } + got, err := tc.metaExpireAt(raw) + if err != nil { + return false + } + expireAtMs = got + return got > redisExpireAtMillis(time.Now().Add(ttl/2)) + }, 5*time.Second, 20*time.Millisecond) + + commitTS, err := server.coordinator.Clock().NextFenced() + require.NoError(t, err) + require.NoError(t, server.store.DeleteAt(ctx, redisTTLKey(key), commitTS)) + ttlValue, err := server.ttlAt(ctx, key, commitTS) + require.NoError(t, err) + require.NotNil(t, ttlValue) + require.Equal(t, expireAtMs, redisExpireAtMillis(*ttlValue)) + }) + } +} diff --git a/adapter/redis_compat_commands_stream_test.go b/adapter/redis_compat_commands_stream_test.go index bc78c846f..cbab8fa66 100644 --- a/adapter/redis_compat_commands_stream_test.go +++ b/adapter/redis_compat_commands_stream_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/bootjp/elastickv/store" "github.com/cockroachdb/errors" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/require" @@ -280,20 +281,8 @@ func TestRedis_StreamXReadLatencyIsConstant(t *testing.T) { defer func() { _ = rdb.Close() }() ctx := context.Background() - const ( - total = 10_000 - probes = 100 - ) - lastID := "" - for i := range total { - id, err := rdb.XAdd(ctx, &redis.XAddArgs{ - Stream: "stream-lat", - ID: "*", - Values: []string{"i", fmt.Sprint(i)}, - }).Result() - require.NoError(t, err) - lastID = id - } + const probes = 100 + lastID := seedStreamEntriesForXReadLatency(t, nodes[0].redisServer, ctx, "stream-lat") measure := func() time.Duration { start := time.Now() @@ -348,6 +337,31 @@ func TestRedis_StreamXReadLatencyIsConstant(t *testing.T) { baseline, median, p95) } +func seedStreamEntriesForXReadLatency(t *testing.T, server *RedisServer, ctx context.Context, key string) string { + t.Helper() + + const entryCount = 10_000 + commitTS, err := server.coordinator.Clock().NextFenced() + require.NoError(t, err) + + userKey := []byte(key) + for ms := uint64(1); ms <= entryCount; ms++ { + id := fmt.Sprintf("%d-0", ms) + value, err := marshalStreamEntry(newRedisStreamEntry(id, []string{"i", fmt.Sprint(ms - 1)})) + require.NoError(t, err) + require.NoError(t, server.store.PutAt(ctx, store.StreamEntryKey(userKey, ms, 0), value, commitTS, 0)) + } + + meta, err := store.MarshalStreamMeta(store.StreamMeta{ + Length: entryCount, + LastMs: entryCount, + LastSeq: 0, + }) + require.NoError(t, err) + require.NoError(t, server.store.PutAt(ctx, store.StreamMetaKey(userKey), meta, commitTS, 0)) + return fmt.Sprintf("%d-0", entryCount) +} + func TestRedis_StreamXTrimMaxLen(t *testing.T) { t.Parallel() nodes, _, _ := createNode(t, 3) @@ -700,6 +714,49 @@ func TestRedis_StreamMultiExecDelRemovesWideColumnLayout(t *testing.T) { require.Len(t, entries, 1) } +func TestRedis_StreamXAddRecreatesExpiredStreamWithoutStaleEntriesOrTTL(t *testing.T) { + t.Parallel() + nodes, _, _ := createNode(t, 3) + defer shutdown(nodes) + + rdb := redis.NewClient(&redis.Options{Addr: nodes[0].redisAddress}) + defer func() { _ = rdb.Close() }() + ctx := context.Background() + + key := "stream-expired-xadd" + _, err := rdb.XAdd(ctx, &redis.XAddArgs{ + Stream: key, + ID: "1700000000000-0", + Values: []string{"old", "value"}, + }).Result() + require.NoError(t, err) + require.NoError(t, rdb.PExpire(ctx, key, time.Millisecond).Err()) + require.Eventually(t, func() bool { + exists, err := rdb.Exists(ctx, key).Result() + return err == nil && exists == 0 + }, time.Second, 10*time.Millisecond) + + id, err := rdb.XAdd(ctx, &redis.XAddArgs{ + Stream: key, + ID: "1800000000000-0", + Values: []string{"fresh", "value"}, + }).Result() + require.NoError(t, err) + require.Equal(t, "1800000000000-0", id) + + ttl, err := rdb.TTL(ctx, key).Result() + require.NoError(t, err) + require.Equal(t, time.Duration(-1), ttl) + entries, err := rdb.XRange(ctx, key, "-", "+").Result() + require.NoError(t, err) + require.Len(t, entries, 1) + require.Equal(t, "1800000000000-0", entries[0].ID) + require.Equal(t, "value", entries[0].Values["fresh"]) + + server := nodes[0].redisServer + requireMissingAt(t, server.store, redisTTLKey([]byte(key)), server.readTS()) +} + // nowNanos returns the current UnixNano timestamp as uint64, failing the // test if the reading is non-positive. Centralising the bounds check here // keeps the int64->uint64 conversion safe and the individual test sites diff --git a/adapter/redis_compat_helpers.go b/adapter/redis_compat_helpers.go index ff10b6f92..292787f2c 100644 --- a/adapter/redis_compat_helpers.go +++ b/adapter/redis_compat_helpers.go @@ -314,21 +314,6 @@ func (r *RedisServer) keyTypeAt(ctx context.Context, key []byte, readTS uint64) return r.applyTTLFilter(ctx, key, readTS, typ) } -// requireKeyTypeOrEmpty returns nil iff the key either has the expected -// type at readTS or is absent at readTS. wrongTypeError() is returned -// when the key exists with a different type. -// -// Used by the wide-column command implementations (HSET / ZADD / -// HINCRBY / ZINCRBY etc.) to collapse the keyTypeAtExpect + -// wrong-type-rejection pair into a single branch at the call site — -// this is what keeps those functions under the cyclop ceiling after -// the HLC-4 (iii) ceiling fence added a NextFenced error branch -// (PR #867 Phase 2b). -func (r *RedisServer) requireKeyTypeOrEmpty(ctx context.Context, key []byte, readTS uint64, expected redisValueType) error { - _, err := r.keyTypeOrEmptyAt(ctx, key, readTS, expected) - return err -} - func (r *RedisServer) keyTypeOrEmptyAt(ctx context.Context, key []byte, readTS uint64, expected redisValueType) (redisValueType, error) { typ, err := r.keyTypeAtExpect(ctx, key, readTS, expected) if err != nil { @@ -557,7 +542,7 @@ func (r *RedisServer) loadHashAt(ctx context.Context, key []byte, readTS uint64) } // loadSetMembersAt scans all wide-column !st|mem| keys and returns them as a -// redisSetValue. Only used for kind=="set" (HLL stays as a legacy blob). +// redisSetValue. Only used for kind=="set". func (r *RedisServer) loadSetMembersAt(ctx context.Context, key []byte, readTS uint64) (redisSetValue, error) { prefix := store.SetMemberScanPrefix(key) end := store.PrefixScanEnd(prefix) @@ -592,7 +577,8 @@ func (r *RedisServer) loadSetAt(ctx context.Context, kind string, key []byte, re return r.loadSetMembersAt(ctx, key, readTS) } } - // Legacy blob fallback (also the only path for HLL). + // Legacy blob fallback for sets; HLL uses this anchor with a versioned TTL + // envelope while still accepting the old bare set payload during migration. storageKey := redisExactSetStorageKey(kind, key) raw, err := r.store.GetAt(ctx, storageKey, readTS) if err != nil { @@ -601,10 +587,30 @@ func (r *RedisServer) loadSetAt(ctx context.Context, kind string, key []byte, re } return redisSetValue{}, errors.WithStack(err) } + if kind == hllKind { + return r.decodeHLLSetValueAt(ctx, key, readTS, raw) + } val, err := unmarshalSetValue(raw) return val, err } +func (r *RedisServer) decodeHLLSetValueAt(ctx context.Context, key []byte, readTS uint64, raw []byte) (redisSetValue, error) { + val, ttl, embedded, err := decodeRedisHLL(raw) + if err != nil { + return redisSetValue{}, err + } + if !embedded && !r.disableLegacyTTLReadFallback { + ttl, err = r.legacyIndexTTLAt(ctx, key, readTS) + if err != nil { + return redisSetValue{}, err + } + } + if ttl != nil && !ttl.After(time.Now()) { + return redisSetValue{}, nil + } + return val, nil +} + // loadZSetMembersAt scans all wide-column !zs|mem| keys and returns them as a redisZSetValue // sorted by (score, member), matching the ordering produced by the legacy blob path. func (r *RedisServer) loadZSetMembersAt(ctx context.Context, key []byte, readTS uint64) (redisZSetValue, error) { diff --git a/adapter/redis_compat_types.go b/adapter/redis_compat_types.go index 67cef5c27..c775cefd4 100644 --- a/adapter/redis_compat_types.go +++ b/adapter/redis_compat_types.go @@ -23,6 +23,13 @@ const ( redisStrBaseHeader = 3 // magic(1) + version(1) + flags(1) ) +const ( + redisHLLMagic = byte(0xFF) + redisHLLVersion = byte(0x02) + redisHLLHasTTL = byte(0x01) + redisHLLBaseHeader = 3 // magic(1) + version(1) + flags(1) +) + // encodeRedisStr encodes a Redis string value with optional TTL. // New format: [0xFF 0x01][flags][expireAtMs(8, optional)][user value] func encodeRedisStr(value []byte, expireAt *time.Time) []byte { @@ -75,6 +82,58 @@ func isNewRedisStrFormat(raw []byte) bool { return len(raw) >= 2 && raw[0] == redisStrMagic && raw[1] == redisStrVersion } +func encodeRedisHLL(value redisSetValue, expireAt *time.Time) ([]byte, error) { + payload, err := marshalSetValue(value) + if err != nil { + return nil, err + } + flags := byte(0) + headerLen := redisHLLBaseHeader + if expireAt != nil { + flags = redisHLLHasTTL + headerLen += redisUint64Bytes + } + buf := make([]byte, headerLen+len(payload)) + buf[0] = redisHLLMagic + buf[1] = redisHLLVersion + buf[2] = flags + if expireAt != nil { + ms := max(expireAt.UnixMilli(), 0) + binary.BigEndian.PutUint64(buf[redisHLLBaseHeader:redisHLLBaseHeader+redisUint64Bytes], uint64(ms)) // #nosec G115 + copy(buf[redisHLLBaseHeader+redisUint64Bytes:], payload) + } else { + copy(buf[redisHLLBaseHeader:], payload) + } + return buf, nil +} + +func decodeRedisHLL(raw []byte) (value redisSetValue, expireAt *time.Time, embedded bool, err error) { + if !isNewRedisHLLFormat(raw) { + value, err := unmarshalSetValue(raw) + return value, nil, false, err + } + if len(raw) < redisHLLBaseHeader { + return redisSetValue{}, nil, true, errors.New("invalid encoded hll: too short") + } + flags := raw[2] + rest := raw[redisHLLBaseHeader:] + if flags&redisHLLHasTTL != 0 { + if len(rest) < redisUint64Bytes { + return redisSetValue{}, nil, true, errors.New("invalid encoded hll: missing TTL bytes") + } + ms := min(binary.BigEndian.Uint64(rest[:redisUint64Bytes]), math.MaxInt64) + t := time.UnixMilli(int64(ms)) // #nosec G115 + expireAt = &t + rest = rest[redisUint64Bytes:] + } + value, err = unmarshalSetValue(rest) + return value, expireAt, true, err +} + +func isNewRedisHLLFormat(raw []byte) bool { + return len(raw) >= 2 && raw[0] == redisHLLMagic && raw[1] == redisHLLVersion +} + const ( redisStrPrefix = "!redis|str|" redisHashPrefix = "!redis|hash|" @@ -341,9 +400,41 @@ func (r *RedisServer) ttlAt(ctx context.Context, userKey []byte, readTS uint64) return nil, errors.WithStack(err) } + if ttl, found, hllErr := r.hllTTLAt(ctx, userKey, readTS); hllErr != nil || found { + return ttl, hllErr + } + if ttl, found, collErr := r.collectionTTLAt(ctx, userKey, readTS); collErr != nil || found { + return ttl, collErr + } + if r.disableLegacyTTLReadFallback { + return nil, nil + } return r.legacyIndexTTLAt(ctx, userKey, readTS) } +func (r *RedisServer) hllTTLAt(ctx context.Context, userKey []byte, readTS uint64) (*time.Time, bool, error) { + raw, err := r.store.GetAt(ctx, redisHLLKey(userKey), readTS) + if err != nil { + if errors.Is(err, store.ErrKeyNotFound) { + return nil, false, nil + } + return nil, false, errors.WithStack(err) + } + newFormat := isNewRedisHLLFormat(raw) + _, ttl, embedded, err := decodeRedisHLL(raw) + if err != nil { + return nil, true, err + } + if embedded || newFormat { + return ttl, true, nil + } + if r.disableLegacyTTLReadFallback { + return nil, true, nil + } + ttl, err = r.legacyIndexTTLAt(ctx, userKey, readTS) + return ttl, true, err +} + // legacyIndexTTLAt reads the TTL from the !redis|ttl| secondary index only. // Use this on non-string paths where the embedded-TTL probe would always miss. func (r *RedisServer) legacyIndexTTLAt(ctx context.Context, userKey []byte, readTS uint64) (*time.Time, error) { @@ -361,16 +452,20 @@ func (r *RedisServer) legacyIndexTTLAt(ctx context.Context, userKey []byte, read return &ttl, nil } -// hasExpired checks TTL expiry. When nonStringOnly is true, the embedded-TTL -// probe is skipped and only the !redis|ttl| index is consulted, avoiding a -// wasted GetAt on !redis|str| for non-string types. +// hasExpired checks TTL expiry. When nonStringOnly is true, string and HLL +// probes are skipped; collection metadata is checked first, then the legacy +// !redis|ttl| index is used for old metadata during the migration window. func (r *RedisServer) hasExpired(ctx context.Context, userKey []byte, readTS uint64, nonStringOnly bool) (bool, error) { var ( ttl *time.Time err error ) if nonStringOnly { - ttl, err = r.legacyIndexTTLAt(ctx, userKey, readTS) + var found bool + ttl, found, err = r.collectionTTLAt(ctx, userKey, readTS) + if err == nil && !found && !r.disableLegacyTTLReadFallback { + ttl, err = r.legacyIndexTTLAt(ctx, userKey, readTS) + } } else { ttl, err = r.ttlAt(ctx, userKey, readTS) } diff --git a/adapter/redis_delta_compactor.go b/adapter/redis_delta_compactor.go index deea5b4e2..c61d04dc1 100644 --- a/adapter/redis_delta_compactor.go +++ b/adapter/redis_delta_compactor.go @@ -261,48 +261,61 @@ func (c *DeltaCompactor) compactUrgentKeyBatch(ctx context.Context, req urgentCo // SyncOnce runs one compaction pass. The IsLeader() guard avoids the // full-prefix delta scan on followers, which would proxy cross-node on // ShardStore backends. For sharded deployments where this node is the -// leader for a non-default shard group, the regular tick is skipped; those -// keys are still handled by the urgent compaction path (compactUrgentKey) -// which uses IsLeaderForKey for per-key routing. buildBatchElems adds an -// additional per-key IsLeaderForKey filter so a default-group leader never -// dispatches mutations for shards it does not own. +// leader for a non-default shard group, the regular delta-compaction tick is +// skipped; those keys are still handled by the urgent compaction path +// (compactUrgentKey) which uses IsLeaderForKey for per-key routing. +// +// The TTL-inline migrator is intentionally outside the default-group leader +// guard. It is a migration path, not just a hot-key repair path, and each +// candidate is still filtered by IsLeaderForKey before dispatch. Without this +// pass, keys whose shard is led by this node but whose default group is led +// elsewhere would never get their inline TTL anchor before legacy fallback is +// disabled. +// +// buildBatchElems adds an additional per-key IsLeaderForKey filter so a +// default-group leader never dispatches mutations for shards it does not own. // Each collection-type handler runs in its own goroutine so that a slow // handler (e.g. one with many list deltas) does not delay Hash/Set/ZSet // compaction. All goroutines share the same per-tick timeout context. func (c *DeltaCompactor) SyncOnce(ctx context.Context) error { - if c.coord == nil || !c.coord.IsLeader() { + if c.coord == nil { return nil } readTS := snapshotTS(c.coord.Clock(), c.st) tickCtx, cancel := context.WithTimeout(ctx, c.timeout) defer cancel() - handlers := c.allHandlers() - errs := make([]error, len(handlers)) - var wg sync.WaitGroup - for i, h := range handlers { - wg.Add(1) - go func(idx int, handler collectionDeltaHandler) { - defer wg.Done() - defer func() { - if rec := recover(); rec != nil { - c.logger.Error("DeltaCompactor: panic in handler", - slog.String("type", handler.typeName), - slog.Any("panic", rec)) - errs[idx] = errors.Errorf("panic in %s compactor: %v", handler.typeName, rec) - } - }() - errs[idx] = c.compactHandler(tickCtx, handler, readTS) - }(i, h) - } - wg.Wait() - var combined error - for _, err := range errs { - if err != nil && !errors.Is(err, context.Canceled) { - combined = errors.CombineErrors(combined, err) + if c.coord.IsLeader() { + handlers := c.allHandlers() + errs := make([]error, len(handlers)) + var wg sync.WaitGroup + for i, h := range handlers { + wg.Add(1) + go func(idx int, handler collectionDeltaHandler) { + defer wg.Done() + defer func() { + if rec := recover(); rec != nil { + c.logger.Error("DeltaCompactor: panic in handler", + slog.String("type", handler.typeName), + slog.Any("panic", rec)) + errs[idx] = errors.Errorf("panic in %s compactor: %v", handler.typeName, rec) + } + }() + errs[idx] = c.compactHandler(tickCtx, handler, readTS) + }(i, h) + } + wg.Wait() + + for _, err := range errs { + if err != nil && !errors.Is(err, context.Canceled) { + combined = errors.CombineErrors(combined, err) + } } } + if err := c.migrateTTLInlineOnce(tickCtx, readTS); err != nil && !errors.Is(err, context.Canceled) { + combined = errors.CombineErrors(combined, err) + } return errors.WithStack(combined) } @@ -560,8 +573,9 @@ func (c *DeltaCompactor) buildListCompactElems(ctx context.Context, userKey []by } newMeta := store.ListMeta{ - Head: baseMeta.Head + headDelta, - Len: baseMeta.Len + lenDelta, + Head: baseMeta.Head + headDelta, + Len: baseMeta.Len + lenDelta, + ExpireAt: baseMeta.ExpireAt, } if newMeta.Len < 0 { c.logger.WarnContext(ctx, "delta compactor: clamping negative list length to 0", @@ -650,9 +664,9 @@ type simpleLenHandlerParams struct { extractUserKey func(key []byte) []byte deltaKeyPrefixFn func(userKey []byte) []byte metaKeyFn func(userKey []byte) []byte - unmarshalBase func([]byte) (int64, error) + unmarshalBase func([]byte) (int64, uint64, error) unmarshalDelta func([]byte) (int64, error) - marshalBase func(int64) []byte + marshalBase func(int64, uint64) []byte } func (c *DeltaCompactor) makeSimpleLenHandler(p simpleLenHandlerParams) collectionDeltaHandler { @@ -679,15 +693,17 @@ func (c *DeltaCompactor) hashHandler() collectionDeltaHandler { extractUserKey: store.ExtractHashUserKeyFromDelta, deltaKeyPrefixFn: store.HashMetaDeltaScanPrefix, metaKeyFn: store.HashMetaKey, - unmarshalBase: func(b []byte) (int64, error) { + unmarshalBase: func(b []byte) (int64, uint64, error) { m, err := store.UnmarshalHashMeta(b) - return m.Len, errors.WithStack(err) + return m.Len, m.ExpireAt, errors.WithStack(err) }, unmarshalDelta: func(b []byte) (int64, error) { d, err := store.UnmarshalHashMetaDelta(b) return d.LenDelta, errors.WithStack(err) }, - marshalBase: func(n int64) []byte { return store.MarshalHashMeta(store.HashMeta{Len: n}) }, + marshalBase: func(n int64, expireAt uint64) []byte { + return store.MarshalHashMeta(store.HashMeta{Len: n, ExpireAt: expireAt}) + }, }) } @@ -698,15 +714,17 @@ func (c *DeltaCompactor) setHandler() collectionDeltaHandler { extractUserKey: store.ExtractSetUserKeyFromDelta, deltaKeyPrefixFn: store.SetMetaDeltaScanPrefix, metaKeyFn: store.SetMetaKey, - unmarshalBase: func(b []byte) (int64, error) { + unmarshalBase: func(b []byte) (int64, uint64, error) { m, err := store.UnmarshalSetMeta(b) - return m.Len, errors.WithStack(err) + return m.Len, m.ExpireAt, errors.WithStack(err) }, unmarshalDelta: func(b []byte) (int64, error) { d, err := store.UnmarshalSetMetaDelta(b) return d.LenDelta, errors.WithStack(err) }, - marshalBase: func(n int64) []byte { return store.MarshalSetMeta(store.SetMeta{Len: n}) }, + marshalBase: func(n int64, expireAt uint64) []byte { + return store.MarshalSetMeta(store.SetMeta{Len: n, ExpireAt: expireAt}) + }, }) } @@ -717,15 +735,17 @@ func (c *DeltaCompactor) zsetHandler() collectionDeltaHandler { extractUserKey: store.ExtractZSetUserKeyFromDelta, deltaKeyPrefixFn: store.ZSetMetaDeltaScanPrefix, metaKeyFn: store.ZSetMetaKey, - unmarshalBase: func(b []byte) (int64, error) { + unmarshalBase: func(b []byte) (int64, uint64, error) { m, err := store.UnmarshalZSetMeta(b) - return m.Len, errors.WithStack(err) + return m.Len, m.ExpireAt, errors.WithStack(err) }, unmarshalDelta: func(b []byte) (int64, error) { d, err := store.UnmarshalZSetMetaDelta(b) return d.LenDelta, errors.WithStack(err) }, - marshalBase: func(n int64) []byte { return store.MarshalZSetMeta(store.ZSetMeta{Len: n}) }, + marshalBase: func(n int64, expireAt uint64) []byte { + return store.MarshalZSetMeta(store.ZSetMeta{Len: n, ExpireAt: expireAt}) + }, }) } @@ -736,9 +756,10 @@ func foldSimpleLenDeltas( deltaKVs []*store.KVPair, additionalDelta int64, baseLen int64, + expireAt uint64, metaKey []byte, unmarshalDelta func([]byte) (int64, error), - marshalBase func(int64) []byte, + marshalBase func(int64, uint64) []byte, ) ([]*kv.Elem[kv.OP], error) { var deltaSum int64 for _, d := range deltaKVs { @@ -752,7 +773,7 @@ func foldSimpleLenDeltas( if newLen < 0 { newLen = 0 } - metaElem := simpleMetaElemForLen(metaKey, newLen, marshalBase) + metaElem := simpleMetaElemForLen(metaKey, newLen, expireAt, marshalBase) elems := make([]*kv.Elem[kv.OP], 0, 1+len(deltaKVs)) elems = append(elems, metaElem) for _, d := range deltaKVs { @@ -769,22 +790,23 @@ func (c *DeltaCompactor) buildSimpleCompactElems( deltaKVs []*store.KVPair, readTS uint64, metaKey []byte, - unmarshalBase func([]byte) (int64, error), + unmarshalBase func([]byte) (int64, uint64, error), unmarshalDelta func([]byte) (int64, error), - marshalBase func(int64) []byte, + marshalBase func(int64, uint64) []byte, ) ([]*kv.Elem[kv.OP], error) { raw, err := c.st.GetAt(ctx, metaKey, readTS) var baseLen int64 + var expireAt uint64 if err != nil && !errors.Is(err, store.ErrKeyNotFound) { return nil, errors.WithStack(err) } if err == nil { - baseLen, err = unmarshalBase(raw) + baseLen, expireAt, err = unmarshalBase(raw) if err != nil { return nil, errors.WithStack(err) } } - return foldSimpleLenDeltas(deltaKVs, 0, baseLen, metaKey, unmarshalDelta, marshalBase) + return foldSimpleLenDeltas(deltaKVs, 0, baseLen, expireAt, metaKey, unmarshalDelta, marshalBase) } // zsetInlineMetaCompactionThreshold is the number of existing ZSetMetaDeltaKey @@ -812,6 +834,7 @@ func (r *RedisServer) zsetInlineMetaCompactionElems( } raw, err := r.store.GetAt(ctx, store.ZSetMetaKey(key), readTS) var baseLen int64 + var expireAt uint64 if err != nil && !errors.Is(err, store.ErrKeyNotFound) { return nil, false, errors.WithStack(err) } @@ -821,24 +844,25 @@ func (r *RedisServer) zsetInlineMetaCompactionElems( return nil, false, errors.WithStack(unmarshalErr) } baseLen = m.Len + expireAt = m.ExpireAt } elems, err := foldSimpleLenDeltas( - deltaKVs, additionalDelta, baseLen, + deltaKVs, additionalDelta, baseLen, expireAt, store.ZSetMetaKey(key), func(b []byte) (int64, error) { d, unmarshalErr := store.UnmarshalZSetMetaDelta(b) return d.LenDelta, errors.WithStack(unmarshalErr) }, - func(n int64) []byte { return store.MarshalZSetMeta(store.ZSetMeta{Len: n}) }, + func(n int64, ttl uint64) []byte { return store.MarshalZSetMeta(store.ZSetMeta{Len: n, ExpireAt: ttl}) }, ) return elems, true, err } // simpleMetaElemForLen returns a Put or Del elem for a Hash/Set/ZSet metadata key. // When newLen==0 a Del is returned to match Redis semantics (empty collection = non-existent). -func simpleMetaElemForLen(metaKey []byte, newLen int64, marshalBase func(int64) []byte) *kv.Elem[kv.OP] { +func simpleMetaElemForLen(metaKey []byte, newLen int64, expireAt uint64, marshalBase func(int64, uint64) []byte) *kv.Elem[kv.OP] { if newLen == 0 { return &kv.Elem[kv.OP]{Op: kv.Del, Key: metaKey} } - return &kv.Elem[kv.OP]{Op: kv.Put, Key: metaKey, Value: marshalBase(newLen)} + return &kv.Elem[kv.OP]{Op: kv.Put, Key: metaKey, Value: marshalBase(newLen, expireAt)} } diff --git a/adapter/redis_delta_compactor_test.go b/adapter/redis_delta_compactor_test.go index 326bfff71..b6a17bd2c 100644 --- a/adapter/redis_delta_compactor_test.go +++ b/adapter/redis_delta_compactor_test.go @@ -2,6 +2,9 @@ package adapter import ( "context" + "encoding/binary" + "strconv" + "sync" "testing" "time" @@ -21,6 +24,835 @@ func newDeltaCompactorTestFixture(t *testing.T) (store.MVCCStore, *DeltaCompacto return st, c } +func TestDeltaCompactor_TTLInlineMigratesLegacyString(t *testing.T) { + t.Parallel() + + st, c := newDeltaCompactorTestFixture(t) + ctx := context.Background() + key := []byte("ttl:migrate:string") + expireAt := time.Now().Add(time.Hour) + require.NoError(t, st.PutAt(ctx, redisStrKey(key), []byte("value"), 1, 0)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(key), encodeRedisTTL(expireAt), 2, 0)) + + require.NoError(t, c.SyncOnce(ctx)) + + readTS := st.LastCommitTS() + raw, err := st.GetAt(ctx, redisStrKey(key), readTS) + require.NoError(t, err) + value, ttl, err := decodeRedisStr(raw) + require.NoError(t, err) + require.Equal(t, []byte("value"), value) + require.NotNil(t, ttl) + require.Equal(t, redisExpireAtMillis(expireAt), redisExpireAtMillis(*ttl)) +} + +func TestDeltaCompactor_TTLInlineMigratesLegacyHLL(t *testing.T) { + t.Parallel() + + st, c := newDeltaCompactorTestFixture(t) + ctx := context.Background() + key := []byte("ttl:migrate:hll") + expireAt := time.Now().Add(time.Hour) + legacy, err := marshalSetValue(redisSetValue{Members: []string{"b", "a"}}) + require.NoError(t, err) + require.NoError(t, st.PutAt(ctx, redisHLLKey(key), legacy, 1, 0)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(key), encodeRedisTTL(expireAt), 2, 0)) + + require.NoError(t, c.SyncOnce(ctx)) + + readTS := st.LastCommitTS() + raw, err := st.GetAt(ctx, redisHLLKey(key), readTS) + require.NoError(t, err) + value, ttl, embedded, err := decodeRedisHLL(raw) + require.NoError(t, err) + require.True(t, embedded) + require.Equal(t, redisSetValue{Members: []string{"a", "b"}}, value) + require.NotNil(t, ttl) + require.Equal(t, redisExpireAtMillis(expireAt), redisExpireAtMillis(*ttl)) +} + +func TestDeltaCompactor_TTLInlineMigratesLegacyHashMeta(t *testing.T) { + t.Parallel() + + st, c := newDeltaCompactorTestFixture(t) + ctx := context.Background() + key := []byte("ttl:migrate:hash") + expireAt := time.Now().Add(time.Hour) + legacyMeta := make([]byte, redisSimpleMetaLegacySizeBytes) + binary.BigEndian.PutUint64(legacyMeta, 3) + require.NoError(t, st.PutAt(ctx, store.HashMetaKey(key), legacyMeta, 1, 0)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(key), encodeRedisTTL(expireAt), 2, 0)) + + require.NoError(t, c.SyncOnce(ctx)) + + readTS := st.LastCommitTS() + raw, err := st.GetAt(ctx, store.HashMetaKey(key), readTS) + require.NoError(t, err) + require.Len(t, raw, redisSimpleMetaInlineSizeBytes) + meta, err := store.UnmarshalHashMeta(raw) + require.NoError(t, err) + require.Equal(t, int64(3), meta.Len) + require.Equal(t, redisExpireAtMillis(expireAt), meta.ExpireAt) +} + +func TestDeltaCompactor_TTLInlineMigratesListUserKeyStartingWithDeltaPrefix(t *testing.T) { + t.Parallel() + + st, c := newDeltaCompactorTestFixture(t) + ctx := context.Background() + key := []byte("d|ttl:migrate:list") + expireAt := time.Now().Add(time.Hour) + legacyMeta := make([]byte, redisWideMetaLegacySizeBytes) + binary.BigEndian.PutUint64(legacyMeta[0:8], 0) + binary.BigEndian.PutUint64(legacyMeta[8:16], 1) + binary.BigEndian.PutUint64(legacyMeta[16:24], 1) + require.NoError(t, st.PutAt(ctx, store.ListMetaKey(key), legacyMeta, 1, 0)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(key), encodeRedisTTL(expireAt), 2, 0)) + + deltaKey := store.ListMetaDeltaKey(key, 3, 0) + delta := store.MarshalListMetaDelta(store.ListMetaDelta{HeadDelta: 0, LenDelta: 1}) + require.NoError(t, st.PutAt(ctx, deltaKey, delta, 3, 0)) + + require.NoError(t, c.SyncOnce(ctx)) + + readTS := st.LastCommitTS() + raw, err := st.GetAt(ctx, store.ListMetaKey(key), readTS) + require.NoError(t, err) + require.Len(t, raw, redisWideMetaInlineSizeBytes) + meta, err := store.UnmarshalListMeta(raw) + require.NoError(t, err) + require.Equal(t, int64(1), meta.Len) + require.Equal(t, redisExpireAtMillis(expireAt), meta.ExpireAt) + + rawDelta, err := st.GetAt(ctx, deltaKey, readTS) + require.NoError(t, err) + require.Equal(t, delta, rawDelta) +} + +func TestDeltaCompactor_TTLInlineMigratesLegacyStreamTTL(t *testing.T) { + t.Parallel() + + st, c := newDeltaCompactorTestFixture(t) + ctx := context.Background() + key := []byte("ttl:migrate:stream") + expireAt := time.Now().Add(time.Hour) + legacy := redisStreamValue{Entries: []redisStreamEntry{ + newRedisStreamEntry("1700000000000-0", []string{"event", "a"}), + newRedisStreamEntry("1700000000000-5", []string{"event", "b"}), + }} + payload, err := marshalStreamValue(legacy) + require.NoError(t, err) + require.NoError(t, st.PutAt(ctx, redisStreamKey(key), payload, 1, 0)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(key), encodeRedisTTL(expireAt), 2, 0)) + + require.NoError(t, c.SyncOnce(ctx)) + + readTS := st.LastCommitTS() + _, err = st.GetAt(ctx, redisStreamKey(key), readTS) + require.ErrorIs(t, err, store.ErrKeyNotFound) + raw, err := st.GetAt(ctx, store.StreamMetaKey(key), readTS) + require.NoError(t, err) + require.Len(t, raw, redisWideMetaInlineSizeBytes) + meta, err := store.UnmarshalStreamMeta(raw) + require.NoError(t, err) + require.Zero(t, meta.Length) + require.Zero(t, meta.LastMs) + require.Zero(t, meta.LastSeq) + require.Equal(t, redisExpireAtMillis(expireAt), meta.ExpireAt) + _, err = st.GetAt(ctx, store.StreamEntryKey(key, 1700000000000, 5), readTS) + require.ErrorIs(t, err, store.ErrKeyNotFound) + + server := &RedisServer{store: st} + got, err := server.loadStreamAt(ctx, key, readTS) + require.NoError(t, err) + require.Empty(t, got.Entries) +} + +func TestRedisTTLAt_LegacyFallbackCanBeDisabled(t *testing.T) { + t.Parallel() + + ctx := context.Background() + st := store.NewMVCCStore() + key := []byte("ttl:fallback:disabled") + expireAt := time.Now().Add(time.Hour) + require.NoError(t, st.PutAt(ctx, redisStrKey(key), []byte("legacy"), 1, 0)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(key), encodeRedisTTL(expireAt), 2, 0)) + + withFallback := &RedisServer{store: st} + got, err := withFallback.ttlAt(ctx, key, 2) + require.NoError(t, err) + require.NotNil(t, got) + + withoutFallback := &RedisServer{store: st, disableLegacyTTLReadFallback: true} + got, err = withoutFallback.ttlAt(ctx, key, 2) + require.NoError(t, err) + require.Nil(t, got) +} + +func TestRedisHasExpiredSkipsLegacyTTLWhenFallbackDisabled(t *testing.T) { + t.Parallel() + + ctx := context.Background() + st := store.NewMVCCStore() + key := []byte("ttl:fallback:non-string") + legacyMeta := make([]byte, redisSimpleMetaLegacySizeBytes) + binary.BigEndian.PutUint64(legacyMeta, 1) + require.NoError(t, st.PutAt(ctx, store.HashMetaKey(key), legacyMeta, 1, 0)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(key), encodeRedisTTL(time.Now().Add(-time.Minute)), 2, 0)) + + withFallback := &RedisServer{store: st} + expired, err := withFallback.hasExpired(ctx, key, 2, true) + require.NoError(t, err) + require.True(t, expired) + + withoutFallback := &RedisServer{store: st, disableLegacyTTLReadFallback: true} + expired, err = withoutFallback.hasExpired(ctx, key, 2, true) + require.NoError(t, err) + require.False(t, expired) +} + +func TestDeltaCompactor_TTLInlineDoesNotOverwriteCurrentMetaFromStaleLegacyIndex(t *testing.T) { + t.Parallel() + + ctx := context.Background() + currentExpireAt := time.Now().Add(2 * time.Hour) + staleExpireAt := time.Now().Add(time.Hour) + currentMs := redisExpireAtMillis(currentExpireAt) + cases := []struct { + name string + key []byte + seed func(store.MVCCStore, []byte) error + read func(store.MVCCStore, []byte, uint64) (int64, uint64, error) + }{ + { + name: "hash", + key: []byte("ttl:migrate:current-hash"), + seed: func(st store.MVCCStore, key []byte) error { + return st.PutAt(ctx, store.HashMetaKey(key), + store.MarshalHashMeta(store.HashMeta{Len: 3, ExpireAt: currentMs}), 1, 0) + }, + read: func(st store.MVCCStore, key []byte, readTS uint64) (int64, uint64, error) { + raw, err := st.GetAt(ctx, store.HashMetaKey(key), readTS) + if err != nil { + return 0, 0, err + } + meta, err := store.UnmarshalHashMeta(raw) + return meta.Len, meta.ExpireAt, err + }, + }, + { + name: "set", + key: []byte("ttl:migrate:current-set"), + seed: func(st store.MVCCStore, key []byte) error { + return st.PutAt(ctx, store.SetMetaKey(key), + store.MarshalSetMeta(store.SetMeta{Len: 3, ExpireAt: currentMs}), 1, 0) + }, + read: func(st store.MVCCStore, key []byte, readTS uint64) (int64, uint64, error) { + raw, err := st.GetAt(ctx, store.SetMetaKey(key), readTS) + if err != nil { + return 0, 0, err + } + meta, err := store.UnmarshalSetMeta(raw) + return meta.Len, meta.ExpireAt, err + }, + }, + { + name: "zset", + key: []byte("ttl:migrate:current-zset"), + seed: func(st store.MVCCStore, key []byte) error { + return st.PutAt(ctx, store.ZSetMetaKey(key), + store.MarshalZSetMeta(store.ZSetMeta{Len: 3, ExpireAt: currentMs}), 1, 0) + }, + read: func(st store.MVCCStore, key []byte, readTS uint64) (int64, uint64, error) { + raw, err := st.GetAt(ctx, store.ZSetMetaKey(key), readTS) + if err != nil { + return 0, 0, err + } + meta, err := store.UnmarshalZSetMeta(raw) + return meta.Len, meta.ExpireAt, err + }, + }, + { + name: "list", + key: []byte("ttl:migrate:current-list"), + seed: func(st store.MVCCStore, key []byte) error { + meta, err := store.MarshalListMeta(store.ListMeta{Head: 1, Len: 3, ExpireAt: currentMs}) + if err != nil { + return err + } + return st.PutAt(ctx, store.ListMetaKey(key), meta, 1, 0) + }, + read: func(st store.MVCCStore, key []byte, readTS uint64) (int64, uint64, error) { + raw, err := st.GetAt(ctx, store.ListMetaKey(key), readTS) + if err != nil { + return 0, 0, err + } + meta, err := store.UnmarshalListMeta(raw) + return meta.Len, meta.ExpireAt, err + }, + }, + { + name: "stream", + key: []byte("ttl:migrate:current-stream"), + seed: func(st store.MVCCStore, key []byte) error { + meta, err := store.MarshalStreamMeta(store.StreamMeta{Length: 3, ExpireAt: currentMs}) + if err != nil { + return err + } + return st.PutAt(ctx, store.StreamMetaKey(key), meta, 1, 0) + }, + read: func(st store.MVCCStore, key []byte, readTS uint64) (int64, uint64, error) { + raw, err := st.GetAt(ctx, store.StreamMetaKey(key), readTS) + if err != nil { + return 0, 0, err + } + meta, err := store.UnmarshalStreamMeta(raw) + return meta.Length, meta.ExpireAt, err + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + st, c := newDeltaCompactorTestFixture(t) + require.NoError(t, tc.seed(st, tc.key)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(tc.key), encodeRedisTTL(staleExpireAt), 2, 0)) + + require.NoError(t, c.SyncOnce(ctx)) + + readTS := st.LastCommitTS() + n, ttlMs, err := tc.read(st, tc.key, readTS) + require.NoError(t, err) + require.Equal(t, int64(3), n) + require.Equal(t, currentMs, ttlMs) + rawTTL, err := st.GetAt(ctx, redisTTLKey(tc.key), readTS) + require.NoError(t, err) + indexTTL, err := decodeRedisTTL(rawTTL) + require.NoError(t, err) + require.Equal(t, currentMs, redisExpireAtMillis(indexTTL)) + }) + } +} + +func TestDeltaCompactor_TTLInlineKeepsCurrentNoTTLMetaFromStaleLegacyIndex(t *testing.T) { + t.Parallel() + + ctx := context.Background() + staleExpireAt := time.Now().Add(time.Hour) + cases := []struct { + name string + key []byte + seed func(store.MVCCStore, []byte) error + read func(store.MVCCStore, []byte, uint64) (int64, uint64, error) + }{ + { + name: "hash", + key: []byte("ttl:migrate:no-ttl-hash"), + seed: func(st store.MVCCStore, key []byte) error { + return st.PutAt(ctx, store.HashMetaKey(key), + store.MarshalHashMeta(store.HashMeta{Len: 3}), 1, 0) + }, + read: func(st store.MVCCStore, key []byte, readTS uint64) (int64, uint64, error) { + raw, err := st.GetAt(ctx, store.HashMetaKey(key), readTS) + if err != nil { + return 0, 0, err + } + meta, err := store.UnmarshalHashMeta(raw) + return meta.Len, meta.ExpireAt, err + }, + }, + { + name: "set", + key: []byte("ttl:migrate:no-ttl-set"), + seed: func(st store.MVCCStore, key []byte) error { + return st.PutAt(ctx, store.SetMetaKey(key), + store.MarshalSetMeta(store.SetMeta{Len: 3}), 1, 0) + }, + read: func(st store.MVCCStore, key []byte, readTS uint64) (int64, uint64, error) { + raw, err := st.GetAt(ctx, store.SetMetaKey(key), readTS) + if err != nil { + return 0, 0, err + } + meta, err := store.UnmarshalSetMeta(raw) + return meta.Len, meta.ExpireAt, err + }, + }, + { + name: "zset", + key: []byte("ttl:migrate:no-ttl-zset"), + seed: func(st store.MVCCStore, key []byte) error { + return st.PutAt(ctx, store.ZSetMetaKey(key), + store.MarshalZSetMeta(store.ZSetMeta{Len: 3}), 1, 0) + }, + read: func(st store.MVCCStore, key []byte, readTS uint64) (int64, uint64, error) { + raw, err := st.GetAt(ctx, store.ZSetMetaKey(key), readTS) + if err != nil { + return 0, 0, err + } + meta, err := store.UnmarshalZSetMeta(raw) + return meta.Len, meta.ExpireAt, err + }, + }, + { + name: "list", + key: []byte("ttl:migrate:no-ttl-list"), + seed: func(st store.MVCCStore, key []byte) error { + meta, err := store.MarshalListMeta(store.ListMeta{Head: 1, Len: 3}) + if err != nil { + return err + } + return st.PutAt(ctx, store.ListMetaKey(key), meta, 1, 0) + }, + read: func(st store.MVCCStore, key []byte, readTS uint64) (int64, uint64, error) { + raw, err := st.GetAt(ctx, store.ListMetaKey(key), readTS) + if err != nil { + return 0, 0, err + } + meta, err := store.UnmarshalListMeta(raw) + return meta.Len, meta.ExpireAt, err + }, + }, + { + name: "stream", + key: []byte("ttl:migrate:no-ttl-stream"), + seed: func(st store.MVCCStore, key []byte) error { + meta, err := store.MarshalStreamMeta(store.StreamMeta{Length: 3}) + if err != nil { + return err + } + return st.PutAt(ctx, store.StreamMetaKey(key), meta, 1, 0) + }, + read: func(st store.MVCCStore, key []byte, readTS uint64) (int64, uint64, error) { + raw, err := st.GetAt(ctx, store.StreamMetaKey(key), readTS) + if err != nil { + return 0, 0, err + } + meta, err := store.UnmarshalStreamMeta(raw) + return meta.Length, meta.ExpireAt, err + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + st, c := newDeltaCompactorTestFixture(t) + require.NoError(t, tc.seed(st, tc.key)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(tc.key), encodeRedisTTL(staleExpireAt), 2, 0)) + + require.NoError(t, c.SyncOnce(ctx)) + + readTS := st.LastCommitTS() + n, ttlMs, err := tc.read(st, tc.key, readTS) + require.NoError(t, err) + require.Equal(t, int64(3), n) + require.Zero(t, ttlMs) + _, err = st.GetAt(ctx, redisTTLKey(tc.key), readTS) + require.ErrorIs(t, err, store.ErrKeyNotFound) + }) + } +} + +type trackingCompactionCoordinator struct { + *localAdapterCoordinator + mu sync.Mutex + elemCount []int +} + +func (c *trackingCompactionCoordinator) Dispatch(ctx context.Context, req *kv.OperationGroup[kv.OP]) (*kv.CoordinateResponse, error) { + if req != nil && len(req.Elems) > 0 { + c.mu.Lock() + c.elemCount = append(c.elemCount, len(req.Elems)) + c.mu.Unlock() + } + return c.localAdapterCoordinator.Dispatch(ctx, req) +} + +func (c *trackingCompactionCoordinator) counts() []int { + c.mu.Lock() + defer c.mu.Unlock() + return append([]int(nil), c.elemCount...) +} + +func TestDeltaCompactor_TTLInlineMigrationDispatchesSmallBatches(t *testing.T) { + t.Parallel() + + ctx := context.Background() + st := store.NewMVCCStore() + coord := &trackingCompactionCoordinator{localAdapterCoordinator: newLocalAdapterCoordinator(st)} + c := NewDeltaCompactor(st, coord, WithDeltaCompactorMaxDeltaCount(2)) + + const totalKeys uint64 = ttlInlineMigrationBatchKeyLimit + 5 + expireAt := time.Now().Add(time.Hour) + for i := uint64(0); i < totalKeys; i++ { + key := []byte("ttl:migrate:batch:" + strconv.FormatUint(i, 10)) + require.NoError(t, st.PutAt(ctx, redisStrKey(key), []byte("value"), 1+i*2, 0)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(key), encodeRedisTTL(expireAt), 2+i*2, 0)) + } + + require.NoError(t, c.SyncOnce(ctx)) + + counts := coord.counts() + require.GreaterOrEqual(t, len(counts), 2) + for _, n := range counts { + require.LessOrEqual(t, n, ttlInlineMigrationBatchKeyLimit*redisPairWidth) + } +} + +type shardOnlyLeaderCompactionCoordinator struct { + *localAdapterCoordinator +} + +func (c *shardOnlyLeaderCompactionCoordinator) IsLeader() bool { + return false +} + +func (c *shardOnlyLeaderCompactionCoordinator) IsLeaderForKey([]byte) bool { + return true +} + +func TestDeltaCompactor_TTLInlineMigratesOnNonDefaultShardLeader(t *testing.T) { + t.Parallel() + + ctx := context.Background() + st := store.NewMVCCStore() + coord := &shardOnlyLeaderCompactionCoordinator{localAdapterCoordinator: newLocalAdapterCoordinator(st)} + c := NewDeltaCompactor(st, coord, WithDeltaCompactorMaxDeltaCount(2)) + + key := []byte("ttl:migrate:shard-only") + expireAt := time.Now().Add(time.Hour) + legacyMeta := make([]byte, redisSimpleMetaLegacySizeBytes) + binary.BigEndian.PutUint64(legacyMeta, 1) + require.NoError(t, st.PutAt(ctx, store.HashMetaKey(key), legacyMeta, 1, 0)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(key), encodeRedisTTL(expireAt), 2, 0)) + + require.NoError(t, c.SyncOnce(ctx)) + + raw, err := st.GetAt(ctx, store.HashMetaKey(key), st.LastCommitTS()) + require.NoError(t, err) + meta, err := store.UnmarshalHashMeta(raw) + require.NoError(t, err) + require.Equal(t, int64(1), meta.Len) + require.Equal(t, redisExpireAtMillis(expireAt), meta.ExpireAt) +} + +func TestDeltaCompactor_TTLInlineMigratesEmptyCollectionKeys(t *testing.T) { + t.Parallel() + + ctx := context.Background() + expireAt := time.Now().Add(time.Hour) + cases := []struct { + name string + seed func(store.MVCCStore) error + read func(store.MVCCStore, uint64) (int64, uint64, error) + }{ + { + name: "list", + seed: func(st store.MVCCStore) error { + meta := make([]byte, redisWideMetaLegacySizeBytes) + binary.BigEndian.PutUint64(meta[0:8], 0) + binary.BigEndian.PutUint64(meta[8:16], 1) + binary.BigEndian.PutUint64(meta[16:24], 1) + return st.PutAt(ctx, store.ListMetaKey(nil), meta, 1, 0) + }, + read: func(st store.MVCCStore, readTS uint64) (int64, uint64, error) { + raw, err := st.GetAt(ctx, store.ListMetaKey(nil), readTS) + if err != nil { + return 0, 0, err + } + meta, err := store.UnmarshalListMeta(raw) + return meta.Len, meta.ExpireAt, err + }, + }, + { + name: "hash", + seed: func(st store.MVCCStore) error { + meta := make([]byte, redisSimpleMetaLegacySizeBytes) + binary.BigEndian.PutUint64(meta, 1) + return st.PutAt(ctx, store.HashMetaKey(nil), meta, 1, 0) + }, + read: func(st store.MVCCStore, readTS uint64) (int64, uint64, error) { + raw, err := st.GetAt(ctx, store.HashMetaKey(nil), readTS) + if err != nil { + return 0, 0, err + } + meta, err := store.UnmarshalHashMeta(raw) + return meta.Len, meta.ExpireAt, err + }, + }, + { + name: "set", + seed: func(st store.MVCCStore) error { + meta := make([]byte, redisSimpleMetaLegacySizeBytes) + binary.BigEndian.PutUint64(meta, 1) + return st.PutAt(ctx, store.SetMetaKey(nil), meta, 1, 0) + }, + read: func(st store.MVCCStore, readTS uint64) (int64, uint64, error) { + raw, err := st.GetAt(ctx, store.SetMetaKey(nil), readTS) + if err != nil { + return 0, 0, err + } + meta, err := store.UnmarshalSetMeta(raw) + return meta.Len, meta.ExpireAt, err + }, + }, + { + name: "zset", + seed: func(st store.MVCCStore) error { + meta := make([]byte, redisSimpleMetaLegacySizeBytes) + binary.BigEndian.PutUint64(meta, 1) + return st.PutAt(ctx, store.ZSetMetaKey(nil), meta, 1, 0) + }, + read: func(st store.MVCCStore, readTS uint64) (int64, uint64, error) { + raw, err := st.GetAt(ctx, store.ZSetMetaKey(nil), readTS) + if err != nil { + return 0, 0, err + } + meta, err := store.UnmarshalZSetMeta(raw) + return meta.Len, meta.ExpireAt, err + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + st, c := newDeltaCompactorTestFixture(t) + require.NoError(t, tc.seed(st)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(nil), encodeRedisTTL(expireAt), 2, 0)) + + require.NoError(t, c.SyncOnce(ctx)) + + n, ttlMs, err := tc.read(st, st.LastCommitTS()) + require.NoError(t, err) + require.Equal(t, int64(1), n) + require.Equal(t, redisExpireAtMillis(expireAt), ttlMs) + }) + } +} + +//nolint:cyclop // The table pins four storage layouts whose fixtures must stay local to the assertion. +func TestDeltaCompactor_TTLInlineMigratesTTLIndexedDeltaOnlyCollections(t *testing.T) { + t.Parallel() + + ctx := context.Background() + expireAt := time.Now().Add(time.Hour) + cases := []struct { + name string + key []byte + seed func(store.MVCCStore, []byte) ([]byte, error) + readMeta func(store.MVCCStore, []byte, uint64) (int64, uint64, error) + }{ + { + name: "list", + key: []byte("ttl:migrate:delta-only:list"), + seed: func(st store.MVCCStore, key []byte) ([]byte, error) { + if err := st.PutAt(ctx, store.ListItemKey(key, 0), []byte("v"), 1, 0); err != nil { + return nil, err + } + deltaKey := store.ListMetaDeltaKey(key, 2, 0) + return deltaKey, st.PutAt(ctx, deltaKey, store.MarshalListMetaDelta(store.ListMetaDelta{LenDelta: 1}), 2, 0) + }, + readMeta: func(st store.MVCCStore, key []byte, readTS uint64) (int64, uint64, error) { + raw, err := st.GetAt(ctx, store.ListMetaKey(key), readTS) + if err != nil { + return 0, 0, err + } + meta, err := store.UnmarshalListMeta(raw) + return meta.Len, meta.ExpireAt, err + }, + }, + { + name: "hash", + key: []byte("ttl:migrate:delta-only:hash"), + seed: func(st store.MVCCStore, key []byte) ([]byte, error) { + if err := st.PutAt(ctx, store.HashFieldKey(key, []byte("field")), []byte("value"), 1, 0); err != nil { + return nil, err + } + deltaKey := store.HashMetaDeltaKey(key, 2, 0) + return deltaKey, st.PutAt(ctx, deltaKey, store.MarshalHashMetaDelta(store.HashMetaDelta{LenDelta: 1}), 2, 0) + }, + readMeta: func(st store.MVCCStore, key []byte, readTS uint64) (int64, uint64, error) { + raw, err := st.GetAt(ctx, store.HashMetaKey(key), readTS) + if err != nil { + return 0, 0, err + } + meta, err := store.UnmarshalHashMeta(raw) + return meta.Len, meta.ExpireAt, err + }, + }, + { + name: "set", + key: []byte("ttl:migrate:delta-only:set"), + seed: func(st store.MVCCStore, key []byte) ([]byte, error) { + if err := st.PutAt(ctx, store.SetMemberKey(key, []byte("member")), []byte{}, 1, 0); err != nil { + return nil, err + } + deltaKey := store.SetMetaDeltaKey(key, 2, 0) + return deltaKey, st.PutAt(ctx, deltaKey, store.MarshalSetMetaDelta(store.SetMetaDelta{LenDelta: 1}), 2, 0) + }, + readMeta: func(st store.MVCCStore, key []byte, readTS uint64) (int64, uint64, error) { + raw, err := st.GetAt(ctx, store.SetMetaKey(key), readTS) + if err != nil { + return 0, 0, err + } + meta, err := store.UnmarshalSetMeta(raw) + return meta.Len, meta.ExpireAt, err + }, + }, + { + name: "zset", + key: []byte("ttl:migrate:delta-only:zset"), + seed: func(st store.MVCCStore, key []byte) ([]byte, error) { + score := 1.5 + member := []byte("member") + if err := st.PutAt(ctx, store.ZSetMemberKey(key, member), store.MarshalZSetScore(score), 1, 0); err != nil { + return nil, err + } + if err := st.PutAt(ctx, store.ZSetScoreKey(key, score, member), []byte{}, 1, 0); err != nil { + return nil, err + } + deltaKey := store.ZSetMetaDeltaKey(key, 2, 0) + return deltaKey, st.PutAt(ctx, deltaKey, store.MarshalZSetMetaDelta(store.ZSetMetaDelta{LenDelta: 1}), 2, 0) + }, + readMeta: func(st store.MVCCStore, key []byte, readTS uint64) (int64, uint64, error) { + raw, err := st.GetAt(ctx, store.ZSetMetaKey(key), readTS) + if err != nil { + return 0, 0, err + } + meta, err := store.UnmarshalZSetMeta(raw) + return meta.Len, meta.ExpireAt, err + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + st, c := newDeltaCompactorTestFixture(t) + deltaKey, err := tc.seed(st, tc.key) + require.NoError(t, err) + require.NoError(t, st.PutAt(ctx, redisTTLKey(tc.key), encodeRedisTTL(expireAt), 3, 0)) + + require.NoError(t, c.SyncOnce(ctx)) + + n, ttlMs, err := tc.readMeta(st, tc.key, st.LastCommitTS()) + require.NoError(t, err) + require.Equal(t, int64(1), n) + require.Equal(t, redisExpireAtMillis(expireAt), ttlMs) + _, err = st.GetAt(ctx, deltaKey, st.LastCommitTS()) + require.ErrorIs(t, err, store.ErrKeyNotFound) + }) + } +} + +func TestDeltaCompactor_TTLInlineMigratesLegacyCollectionBlobsFromTTLIndex(t *testing.T) { + t.Parallel() + + ctx := context.Background() + expireAt := time.Now().Add(time.Hour) + cases := []struct { + name string + key []byte + legacyKey func([]byte) []byte + seed func(store.MVCCStore, []byte) error + readMeta func(store.MVCCStore, []byte, uint64) (int64, uint64, error) + requireRows func(*testing.T, store.MVCCStore, []byte, uint64) + }{ + { + name: "hash", + key: []byte("ttl:migrate:legacy:hash"), + legacyKey: redisHashKey, + seed: func(st store.MVCCStore, key []byte) error { + raw, err := marshalHashValue(redisHashValue{"field": "value"}) + if err != nil { + return err + } + return st.PutAt(ctx, redisHashKey(key), raw, 1, 0) + }, + readMeta: func(st store.MVCCStore, key []byte, readTS uint64) (int64, uint64, error) { + raw, err := st.GetAt(ctx, store.HashMetaKey(key), readTS) + if err != nil { + return 0, 0, err + } + meta, err := store.UnmarshalHashMeta(raw) + return meta.Len, meta.ExpireAt, err + }, + requireRows: func(t *testing.T, st store.MVCCStore, key []byte, readTS uint64) { + t.Helper() + raw, err := st.GetAt(ctx, store.HashFieldKey(key, []byte("field")), readTS) + require.NoError(t, err) + require.Equal(t, []byte("value"), raw) + }, + }, + { + name: "set", + key: []byte("ttl:migrate:legacy:set"), + legacyKey: redisSetKey, + seed: func(st store.MVCCStore, key []byte) error { + raw, err := marshalSetValue(redisSetValue{Members: []string{"member"}}) + if err != nil { + return err + } + return st.PutAt(ctx, redisSetKey(key), raw, 1, 0) + }, + readMeta: func(st store.MVCCStore, key []byte, readTS uint64) (int64, uint64, error) { + raw, err := st.GetAt(ctx, store.SetMetaKey(key), readTS) + if err != nil { + return 0, 0, err + } + meta, err := store.UnmarshalSetMeta(raw) + return meta.Len, meta.ExpireAt, err + }, + requireRows: func(t *testing.T, st store.MVCCStore, key []byte, readTS uint64) { + t.Helper() + raw, err := st.GetAt(ctx, store.SetMemberKey(key, []byte("member")), readTS) + require.NoError(t, err) + require.Empty(t, raw) + }, + }, + { + name: "zset", + key: []byte("ttl:migrate:legacy:zset"), + legacyKey: redisZSetKey, + seed: func(st store.MVCCStore, key []byte) error { + raw, err := marshalZSetValue(redisZSetValue{Entries: []redisZSetEntry{{Member: "member", Score: 1.5}}}) + if err != nil { + return err + } + return st.PutAt(ctx, redisZSetKey(key), raw, 1, 0) + }, + readMeta: func(st store.MVCCStore, key []byte, readTS uint64) (int64, uint64, error) { + raw, err := st.GetAt(ctx, store.ZSetMetaKey(key), readTS) + if err != nil { + return 0, 0, err + } + meta, err := store.UnmarshalZSetMeta(raw) + return meta.Len, meta.ExpireAt, err + }, + requireRows: func(t *testing.T, st store.MVCCStore, key []byte, readTS uint64) { + t.Helper() + raw, err := st.GetAt(ctx, store.ZSetMemberKey(key, []byte("member")), readTS) + require.NoError(t, err) + score, err := store.UnmarshalZSetScore(raw) + require.NoError(t, err) + require.Equal(t, 1.5, score) + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + st, c := newDeltaCompactorTestFixture(t) + require.NoError(t, tc.seed(st, tc.key)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(tc.key), encodeRedisTTL(expireAt), 2, 0)) + + require.NoError(t, c.SyncOnce(ctx)) + + readTS := st.LastCommitTS() + n, ttlMs, err := tc.readMeta(st, tc.key, readTS) + require.NoError(t, err) + require.Equal(t, int64(1), n) + require.Equal(t, redisExpireAtMillis(expireAt), ttlMs) + tc.requireRows(t, st, tc.key, readTS) + _, err = st.GetAt(ctx, tc.legacyKey(tc.key), readTS) + require.ErrorIs(t, err, store.ErrKeyNotFound) + }) + } +} + type recordingCompactorCoordinator struct { stubAdapterCoordinator labels []keyviz.Label diff --git a/adapter/redis_expire_cmds.go b/adapter/redis_expire_cmds.go index 552cc40c4..a40c8adc0 100644 --- a/adapter/redis_expire_cmds.go +++ b/adapter/redis_expire_cmds.go @@ -267,24 +267,38 @@ func (r *RedisServer) doSetExpire(ctx context.Context, key []byte, ttl int64, ex if err != nil { return 0, err } + applied, err := r.dispatchExpireForType(ctx, key, readTS, typ, expireAt) + if err != nil || !applied { + return 0, err + } + return 1, nil +} + +func (r *RedisServer) dispatchExpireForType( + ctx context.Context, + key []byte, + readTS uint64, + typ redisValueType, + expireAt time.Time, +) (bool, error) { if typ == redisTypeString { // rawKeyTypeAt also reports HLL as redisTypeString; HLL payloads live - // under !redis|hll| and don't carry an inline TTL, so fall back - // to the legacy scan-index path for them. + // under !redis|hll|, so distinguish them from plain strings + // before rewriting the TTL-bearing anchor. plain, err := r.isPlainRedisString(ctx, key, readTS) if err != nil { - return 0, err + return false, err } if plain { - applied, err := r.dispatchStringExpire(ctx, key, readTS, expireAt) - if err != nil || !applied { - return 0, err - } - return 1, nil + return r.dispatchStringExpire(ctx, key, readTS, expireAt) } + return r.dispatchHLLExpire(ctx, key, readTS, expireAt) + } + if isNonStringCollectionType(typ) { + return r.dispatchCollectionExpire(ctx, key, readTS, typ, expireAt) } elems := []*kv.Elem[kv.OP]{{Op: kv.Put, Key: redisTTLKey(key), Value: encodeRedisTTL(expireAt)}} - return 1, r.dispatchElems(ctx, true, readTS, elems) + return true, r.dispatchElems(ctx, true, readTS, elems) } // isPlainRedisString distinguishes a plain Redis string (stored under @@ -344,3 +358,26 @@ func (r *RedisServer) dispatchStringExpire(ctx context.Context, key []byte, read } return true, r.dispatchElems(ctx, true, readTS, elems) } + +func (r *RedisServer) dispatchHLLExpire(ctx context.Context, key []byte, readTS uint64, expireAt time.Time) (bool, error) { + raw, err := r.store.GetAt(ctx, redisHLLKey(key), readTS) + if err != nil { + if cockerrors.Is(err, store.ErrKeyNotFound) { + return false, nil + } + return false, cockerrors.WithStack(err) + } + value, _, _, err := decodeRedisHLL(raw) + if err != nil { + return false, err + } + encoded, err := encodeRedisHLL(value, &expireAt) + if err != nil { + return false, err + } + elems := []*kv.Elem[kv.OP]{ + {Op: kv.Put, Key: redisHLLKey(key), Value: encoded}, + {Op: kv.Put, Key: redisTTLKey(key), Value: encodeRedisTTL(expireAt)}, + } + return true, r.dispatchElems(ctx, true, readTS, elems) +} diff --git a/adapter/redis_hash_cmds.go b/adapter/redis_hash_cmds.go index 813e80ea5..039bad460 100644 --- a/adapter/redis_hash_cmds.go +++ b/adapter/redis_hash_cmds.go @@ -67,13 +67,20 @@ func (r *RedisServer) buildHashLegacyMigrationElems(ctx context.Context, key []b Value: []byte(val), }) } + ttlMs, expired, err := legacyTTLMillisForMigrationAt(ctx, r.store, key, readTS) + if err != nil { + return nil, err + } + if expired { + return legacyExpiredCollectionCleanupElems(key, redisHashKey(key)), nil + } // Delete the legacy blob. elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: redisHashKey(key)}) // Write a base meta so that resolveHashMeta starts from an accurate count. elems = append(elems, &kv.Elem[kv.OP]{ Op: kv.Put, Key: store.HashMetaKey(key), - Value: store.MarshalHashMeta(store.HashMeta{Len: int64(len(value))}), + Value: store.MarshalHashMeta(store.HashMeta{Len: int64(len(value)), ExpireAt: ttlMs}), }) return elems, nil } @@ -91,6 +98,31 @@ func addLegacyHashFieldsToMap(migrationElems []*kv.Elem[kv.OP], key []byte, exis } } +func (r *RedisServer) hashMigrationSnapshot( + ctx context.Context, + key []byte, + readTS uint64, + ignoreExisting bool, +) ([]*kv.Elem[kv.OP], map[string]struct{}, error) { + existsMap := map[string]struct{}{} + if ignoreExisting { + return nil, existsMap, nil + } + migrationElems, err := r.buildHashLegacyMigrationElems(ctx, key, readTS) + if err != nil { + return nil, nil, err + } + existsMap, err = r.scanHashFieldExistsMap(ctx, key, readTS) + if err != nil { + return nil, nil, err + } + // Fields from the legacy blob are being migrated in this same transaction, + // so they are not yet visible at readTS. Add them to existsMap so that + // buildHashFieldElems does not count already-existing fields as new. + addLegacyHashFieldsToMap(migrationElems, key, existsMap) + return migrationElems, existsMap, nil +} + // buildHashFieldElems iterates over field-value pairs in args, checks each // field against existsMap to determine if it is new, appends Put operations // to elems, and returns the updated elems and new-field count. @@ -124,7 +156,12 @@ func (r *RedisServer) applyHashFieldPairs(key []byte, args [][]byte) (int, error var added int err := r.retryRedisWrite(ctx, func() error { readTS := r.readTS() - if err := r.requireKeyTypeOrEmpty(ctx, key, readTS, redisTypeHash); err != nil { + typ, err := r.keyTypeOrEmptyAt(ctx, key, readTS, redisTypeHash) + if err != nil { + return err + } + cleanupElems, expiredRecreate, err := r.expiredCollectionCleanupForRecreate(ctx, key, readTS, typ) + if err != nil { return err } @@ -135,27 +172,14 @@ func (r *RedisServer) applyHashFieldPairs(key []byte, args [][]byte) (int, error } // Atomically migrate any legacy blob on first wide-column write. - // Fetch migration elems before allocating the main elems slice so that - // the initial capacity accounts for both migration and field Put ops, - // avoiding a reallocation when a legacy blob is present. - migrationElems, err := r.buildHashLegacyMigrationElems(ctx, key, readTS) + migrationElems, existsMap, err := r.hashMigrationSnapshot(ctx, key, readTS, expiredRecreate) if err != nil { return err } - elems := make([]*kv.Elem[kv.OP], 0, len(migrationElems)+len(args)/redisPairWidth+setWideColOverhead) + elems := make([]*kv.Elem[kv.OP], 0, len(cleanupElems)+len(migrationElems)+len(args)/redisPairWidth+setWideColOverhead) + elems = append(elems, cleanupElems...) elems = append(elems, migrationElems...) - // Bulk-scan existing fields once so buildHashFieldElems can check - // existence via a map lookup instead of per-field ExistsAt. - existsMap, err := r.scanHashFieldExistsMap(ctx, key, readTS) - if err != nil { - return err - } - // Fields from the legacy blob are being migrated in this same transaction, - // so they are not yet visible at readTS. Add them to existsMap so that - // buildHashFieldElems does not count already-existing fields as new. - addLegacyHashFieldsToMap(migrationElems, key, existsMap) - var newFields int elems, newFields = r.buildHashFieldElems(key, args, existsMap, elems) added = newFields @@ -494,6 +518,13 @@ func (r *RedisServer) persistHashTxn(ctx context.Context, key []byte, readTS uin } return r.dispatchElems(ctx, true, readTS, elems) } + ttlMs, expired, err := legacyTTLMillisForMigrationAt(ctx, r.store, key, readTS) + if err != nil { + return err + } + if expired { + return r.dispatchElems(ctx, true, readTS, legacyExpiredCollectionCleanupElems(key, redisHashKey(key))) + } // Wide-column rewrite: write per-field keys and a new base meta. // deleteLogicalKeyElems (called by the caller when needed) clears old keys. elems := make([]*kv.Elem[kv.OP], 0, len(value)+1) @@ -507,7 +538,7 @@ func (r *RedisServer) persistHashTxn(ctx context.Context, key []byte, readTS uin elems = append(elems, &kv.Elem[kv.OP]{ Op: kv.Put, Key: store.HashMetaKey(key), - Value: store.MarshalHashMeta(store.HashMeta{Len: int64(len(value))}), + Value: store.MarshalHashMeta(store.HashMeta{Len: int64(len(value)), ExpireAt: ttlMs}), }) // Also remove the legacy blob if it was present. elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: redisHashKey(key)}) @@ -654,7 +685,10 @@ func (r *RedisServer) hincrby(conn redcon.Conn, cmd redcon.Command) { // readHashFieldInt reads the current integer value of a hash field from wide-column or legacy storage. // Returns (current, isNewField, legacyHashValue, error). legacyHashValue is non-nil only when // the value came from a legacy JSON blob that needs to be migrated on the next write. -func (r *RedisServer) readHashFieldInt(ctx context.Context, key, field []byte, readTS uint64) (int64, bool, redisHashValue, error) { +func (r *RedisServer) readHashFieldInt(ctx context.Context, key, field []byte, readTS uint64, ignoreExisting bool) (int64, bool, redisHashValue, error) { + if ignoreExisting { + return 0, true, nil, nil + } fieldKey := store.HashFieldKey(key, field) raw, readErr := r.store.GetAt(ctx, fieldKey, readTS) if readErr != nil && !cockerrors.Is(readErr, store.ErrKeyNotFound) { @@ -721,6 +755,10 @@ func (r *RedisServer) hincrbyTxn(ctx context.Context, key, field []byte, increme if err != nil { return 0, err } + cleanupElems, expiredRecreate, err := r.expiredCollectionCleanupForRecreate(ctx, key, readTS, typ) + if err != nil { + return 0, err + } startTS := normalizeStartTS(readTS) commitTS, err := r.nextCommitTSAfter(ctx, startTS, "hincrbyTxn: allocate commitTS") @@ -729,7 +767,7 @@ func (r *RedisServer) hincrbyTxn(ctx context.Context, key, field []byte, increme } fieldKey := store.HashFieldKey(key, field) - current, isNewField, legacyValue, err := r.readHashFieldInt(ctx, key, field, readTS) + current, isNewField, legacyValue, err := r.readHashFieldInt(ctx, key, field, readTS, expiredRecreate) if err != nil { return 0, err } @@ -741,7 +779,8 @@ func (r *RedisServer) hincrbyTxn(ctx context.Context, key, field []byte, increme current += increment newVal := strconv.FormatInt(current, 10) - elems := make([]*kv.Elem[kv.OP], 0, setWideColOverhead) + elems := make([]*kv.Elem[kv.OP], 0, len(cleanupElems)+setWideColOverhead) + elems = append(elems, cleanupElems...) elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Put, Key: fieldKey, Value: []byte(newVal)}) if isNewField { deltaVal := store.MarshalHashMetaDelta(store.HashMetaDelta{LenDelta: 1}) diff --git a/adapter/redis_hll_ttl_test.go b/adapter/redis_hll_ttl_test.go new file mode 100644 index 000000000..c9c8c22a3 --- /dev/null +++ b/adapter/redis_hll_ttl_test.go @@ -0,0 +1,199 @@ +package adapter + +import ( + "context" + "testing" + "time" + + "github.com/bootjp/elastickv/store" + "github.com/stretchr/testify/require" +) + +func TestRedisHLLTTLAtReadsEmbeddedAfterScanIndexDeleted(t *testing.T) { + t.Parallel() + + ctx := context.Background() + st := store.NewMVCCStore() + server := &RedisServer{store: st} + key := []byte("hll:inline-ttl") + expireAt := time.Now().Add(time.Hour) + + payload, err := encodeRedisHLL(redisSetValue{Members: []string{"a"}}, &expireAt) + require.NoError(t, err) + require.NoError(t, st.PutAt(ctx, redisHLLKey(key), payload, 10, 0)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(key), encodeRedisTTL(expireAt), 11, 0)) + require.NoError(t, st.DeleteAt(ctx, redisTTLKey(key), 12)) + + got, err := server.ttlAt(ctx, key, 12) + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, redisExpireAtMillis(expireAt), redisExpireAtMillis(*got)) +} + +func TestRedisHLLTTLAtIgnoresStaleCollectionTTL(t *testing.T) { + t.Parallel() + + ctx := context.Background() + st := store.NewMVCCStore() + server := &RedisServer{store: st} + key := []byte("hll:stale-collection") + expiredAt := time.Now().Add(-time.Minute) + + require.NoError(t, st.PutAt(ctx, store.HashMetaKey(key), store.MarshalHashMeta(store.HashMeta{ + Len: 1, + ExpireAt: redisExpireAtMillis(expiredAt), + }), 10, 0)) + payload, err := encodeRedisHLL(redisSetValue{Members: []string{"fresh"}}, nil) + require.NoError(t, err) + require.NoError(t, st.PutAt(ctx, redisHLLKey(key), payload, 11, 0)) + + got, err := server.ttlAt(ctx, key, 11) + require.NoError(t, err) + require.Nil(t, got) + + expired, err := server.hasExpired(ctx, key, 11, false) + require.NoError(t, err) + require.False(t, expired) + + value, err := server.loadSetAt(ctx, hllKind, key, 11) + require.NoError(t, err) + require.Equal(t, []string{"fresh"}, value.Members) +} + +func TestRedisLegacyHLLTTLAtIgnoresStaleCollectionTTL(t *testing.T) { + t.Parallel() + + ctx := context.Background() + expiredAt := time.Now().Add(-time.Minute) + liveAt := time.Now().Add(time.Hour) + + cases := []struct { + name string + writeLegacyTTL bool + wantTTL *time.Time + }{ + {name: "no-ttl"}, + {name: "legacy-ttl", writeLegacyTTL: true, wantTTL: &liveAt}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + st := store.NewMVCCStore() + server := &RedisServer{store: st} + key := []byte("hll:legacy-stale-collection:" + tc.name) + + require.NoError(t, st.PutAt(ctx, store.HashMetaKey(key), store.MarshalHashMeta(store.HashMeta{ + Len: 1, + ExpireAt: redisExpireAtMillis(expiredAt), + }), 10, 0)) + payload, err := marshalSetValue(redisSetValue{Members: []string{"fresh"}}) + require.NoError(t, err) + require.NoError(t, st.PutAt(ctx, redisHLLKey(key), payload, 11, 0)) + if tc.writeLegacyTTL { + require.NoError(t, st.PutAt(ctx, redisTTLKey(key), encodeRedisTTL(*tc.wantTTL), 12, 0)) + } + readTS := st.LastCommitTS() + + got, err := server.ttlAt(ctx, key, readTS) + require.NoError(t, err) + if tc.wantTTL == nil { + require.Nil(t, got) + } else { + require.NotNil(t, got) + require.Equal(t, redisExpireAtMillis(*tc.wantTTL), redisExpireAtMillis(*got)) + } + + expired, err := server.hasExpired(ctx, key, readTS, false) + require.NoError(t, err) + require.False(t, expired) + }) + } +} + +func TestRedisDispatchHLLExpireWritesInlineAnchorAndScanIndex(t *testing.T) { + t.Parallel() + + ctx := context.Background() + st := store.NewMVCCStore() + coord := newLocalAdapterCoordinator(st) + server := &RedisServer{store: st, coordinator: coord} + key := []byte("hll:expire") + legacyPayload, err := marshalSetValue(redisSetValue{Members: []string{"a", "b"}}) + require.NoError(t, err) + require.NoError(t, st.PutAt(ctx, redisHLLKey(key), legacyPayload, 1, 0)) + + expireAt := time.Now().Add(time.Hour) + applied, err := server.dispatchHLLExpire(ctx, key, 10, expireAt) + require.NoError(t, err) + require.True(t, applied) + + readTS := coord.Clock().Next() + raw, err := st.GetAt(ctx, redisHLLKey(key), readTS) + require.NoError(t, err) + decoded, gotTTL, embedded, err := decodeRedisHLL(raw) + require.NoError(t, err) + require.True(t, embedded) + require.Equal(t, redisSetValue{Members: []string{"a", "b"}}, decoded) + require.NotNil(t, gotTTL) + require.Equal(t, redisExpireAtMillis(expireAt), redisExpireAtMillis(*gotTTL)) + + rawTTL, err := st.GetAt(ctx, redisTTLKey(key), readTS) + require.NoError(t, err) + indexTTL, err := decodeRedisTTL(rawTTL) + require.NoError(t, err) + require.Equal(t, redisExpireAtMillis(expireAt), redisExpireAtMillis(indexTTL)) +} + +func TestRedisLoadHLLAtTreatsExpiredInlineTTLAsEmpty(t *testing.T) { + t.Parallel() + + ctx := context.Background() + st := store.NewMVCCStore() + server := &RedisServer{store: st} + key := []byte("hll:expired") + expireAt := time.Now().Add(-time.Minute) + payload, err := encodeRedisHLL(redisSetValue{Members: []string{"stale"}}, &expireAt) + require.NoError(t, err) + require.NoError(t, st.PutAt(ctx, redisHLLKey(key), payload, 10, 0)) + + value, err := server.loadSetAt(ctx, hllKind, key, 10) + require.NoError(t, err) + require.Empty(t, value.Members) +} + +func TestRedisLoadHLLAtTreatsExpiredLegacyTTLAsEmpty(t *testing.T) { + t.Parallel() + + ctx := context.Background() + st := store.NewMVCCStore() + server := &RedisServer{store: st} + key := []byte("hll:expired-legacy") + payload, err := marshalSetValue(redisSetValue{Members: []string{"stale"}}) + require.NoError(t, err) + require.NoError(t, st.PutAt(ctx, redisHLLKey(key), payload, 10, 0)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(key), encodeRedisTTL(time.Now().Add(-time.Minute)), 11, 0)) + + value, err := server.loadSetAt(ctx, hllKind, key, 11) + require.NoError(t, err) + require.Empty(t, value.Members) + + live, err := server.hllExistsAt(key, 11) + require.NoError(t, err) + require.False(t, live) +} + +func TestRedisLoadHLLAtSkipsLegacyTTLWhenFallbackDisabled(t *testing.T) { + t.Parallel() + + ctx := context.Background() + st := store.NewMVCCStore() + server := &RedisServer{store: st, disableLegacyTTLReadFallback: true} + key := []byte("hll:expired-legacy-disabled") + payload, err := marshalSetValue(redisSetValue{Members: []string{"live"}}) + require.NoError(t, err) + require.NoError(t, st.PutAt(ctx, redisHLLKey(key), payload, 10, 0)) + require.NoError(t, st.PutAt(ctx, redisTTLKey(key), encodeRedisTTL(time.Now().Add(-time.Minute)), 11, 0)) + + value, err := server.loadSetAt(ctx, hllKind, key, 11) + require.NoError(t, err) + require.Equal(t, []string{"live"}, value.Members) +} diff --git a/adapter/redis_lists.go b/adapter/redis_lists.go index 4380073cd..454d8a717 100644 --- a/adapter/redis_lists.go +++ b/adapter/redis_lists.go @@ -100,7 +100,7 @@ func (r *RedisServer) listPushCore(ctx context.Context, key []byte, values [][]b var newLen int64 err := r.retryRedisWrite(ctx, func() error { readTS := r.readTS() - meta, metaExists, typ, err := r.listPushSnapshot(ctx, key, readTS) + meta, metaExists, typ, cleanupElems, err := r.listPushSnapshot(ctx, key, readTS) if err != nil { return err } @@ -115,6 +115,7 @@ func (r *RedisServer) listPushCore(ctx context.Context, key []byte, values [][]b if err != nil { return err } + ops = append(cleanupElems, ops...) if len(ops) == 0 { newLen = updatedMeta.Len return nil @@ -137,16 +138,23 @@ func (r *RedisServer) listPushCore(ctx context.Context, key []byte, values [][]b return newLen, err } -func (r *RedisServer) listPushSnapshot(ctx context.Context, key []byte, readTS uint64) (store.ListMeta, bool, redisValueType, error) { +func (r *RedisServer) listPushSnapshot(ctx context.Context, key []byte, readTS uint64) (store.ListMeta, bool, redisValueType, []*kv.Elem[kv.OP], error) { typ, err := r.keyTypeOrEmptyAt(ctx, key, readTS, redisTypeList) if err != nil { - return store.ListMeta{}, false, redisTypeNone, err + return store.ListMeta{}, false, redisTypeNone, nil, err + } + cleanup, expired, err := r.expiredCollectionCleanupForRecreate(ctx, key, readTS, typ) + if err != nil { + return store.ListMeta{}, false, redisTypeNone, nil, err + } + if expired { + return store.ListMeta{}, false, redisTypeNone, cleanup, nil } meta, exists, err := r.resolveListMeta(ctx, key, readTS) if err != nil { - return store.ListMeta{}, false, redisTypeNone, err + return store.ListMeta{}, false, redisTypeNone, nil, err } - return meta, exists, typ, nil + return meta, exists, typ, nil, nil } // reusableListPush captures a dispatched list-push attempt so a subsequent @@ -309,14 +317,14 @@ func (r *RedisServer) resolveLengthAfterFreshApply(ctx context.Context, key []by return currentMeta.Len } -// firstWriteKey returns the first non-empty element key from ops, or nil +// firstWriteKey returns the first non-empty Put element key from ops, or nil // when there is none. Used after a successful reuse dispatch to probe // whether our prior attempt's commit_ts actually landed: attempt 1 writes // all its elem keys atomically at the same commit_ts, so any one of them // answers the question. func firstWriteKey(ops []*kv.Elem[kv.OP]) []byte { for _, e := range ops { - if e != nil && len(e.Key) > 0 { + if e != nil && e.Op == kv.Put && len(e.Key) > 0 { return e.Key } } @@ -377,7 +385,7 @@ func (r *RedisServer) listPushCoreWithDedup(ctx context.Context, key []byte, val } readTS := r.readTS() - meta, metaExists, typ, err := r.listPushSnapshot(ctx, key, readTS) + meta, metaExists, typ, cleanupElems, err := r.listPushSnapshot(ctx, key, readTS) if err != nil { return err } @@ -393,6 +401,7 @@ func (r *RedisServer) listPushCoreWithDedup(ctx context.Context, key []byte, val if err != nil { return err } + ops = append(cleanupElems, ops...) if len(ops) == 0 { newLen = updatedMeta.Len return nil diff --git a/adapter/redis_lua_context.go b/adapter/redis_lua_context.go index c871b33c5..e4be323b1 100644 --- a/adapter/redis_lua_context.go +++ b/adapter/redis_lua_context.go @@ -172,6 +172,8 @@ const ( zsetMetaBaseElems = 1 // PUT or DEL ZSetMetaKey zsetElemsPerAdded = 3 // optional DEL stale score key + PUT member key + PUT score key zsetElemsPerRemoved = 2 // DEL member key + DEL score key + + listFullCommitExtraElems = 2 // PUT ListMetaKey + wide-list fence key ) type luaCommandHandler func(*luaScriptContext, []string) (luaReply, error) @@ -3136,16 +3138,18 @@ func parseLuaXTrimArgs(args []string) ([]byte, int, error) { } type luaCommitPlan struct { - preserveExisting bool - elems []*kv.Elem[kv.OP] + preserveExisting bool + inlineMetaRewritten bool + elems []*kv.Elem[kv.OP] } // luaKeyPlan carries the data elements and TTL metadata for a single key commit. type luaKeyPlan struct { - elems []*kv.Elem[kv.OP] - readKeys [][]byte - finalType redisValueType - preserveExisting bool + elems []*kv.Elem[kv.OP] + readKeys [][]byte + finalType redisValueType + preserveExisting bool + inlineMetaRewritten bool } func (c *luaScriptContext) commit() error { @@ -3173,10 +3177,11 @@ func (c *luaScriptContext) commit() error { } elems = append(elems, plan.elems...) readKeys = append(readKeys, plan.readKeys...) - // For non-string keys with dirty TTL: include !redis|ttl| in the same txn. - // String keys already have TTL embedded in the value via stringCommitElems. + // For collection keys with dirty TTL: update the inline metadata + // anchor and keep !redis|ttl| as the secondary scan index. if isNonStringCollectionType(plan.finalType) { - ttlElems, err := c.nonStringTTLElems(key, plan.preserveExisting) + updateExistingMeta := plan.preserveExisting && !plan.inlineMetaRewritten + ttlElems, err := c.nonStringTTLElems(ctx, key, plan.finalType, updateExistingMeta) if err != nil { return err } @@ -3207,36 +3212,49 @@ func luaCommitFloor(startTS uint64) uint64 { return startTS } -// nonStringTTLElems returns !redis|ttl| elements for a non-string key if the TTL -// state is dirty (or the key was fully rewritten, requiring TTL to be included). -func (c *luaScriptContext) nonStringTTLElems(key string, preserveExisting bool) ([]*kv.Elem[kv.OP], error) { +// nonStringTTLElems returns TTL metadata elements for a collection key if the +// TTL state is dirty, or the key was fully rewritten and needs index sync. +func (c *luaScriptContext) nonStringTTLElems(ctx context.Context, key string, typ redisValueType, preserveExisting bool) ([]*kv.Elem[kv.OP], error) { st := c.ttls[key] if preserveExisting && (st == nil || !st.dirty) { return nil, nil } - ttl, err := c.finalTTL([]byte(key)) + ttl, err := c.finalTTL(ctx, []byte(key)) if err != nil { return nil, err } + keyBytes := []byte(key) + elems := []*kv.Elem[kv.OP]{} + if preserveExisting { + metaElems, ok, err := c.server.collectionExpireElems(ctx, keyBytes, c.startTS, typ, ttlMillis(ttl)) + if err != nil { + return nil, err + } + if ok { + elems = append(elems, metaElems...) + } + } if ttl == nil { - return []*kv.Elem[kv.OP]{{Op: kv.Del, Key: redisTTLKey([]byte(key))}}, nil + elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: redisTTLKey(keyBytes)}) + return elems, nil } - return []*kv.Elem[kv.OP]{{Op: kv.Put, Key: redisTTLKey([]byte(key)), Value: encodeRedisTTL(*ttl)}}, nil + elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Put, Key: redisTTLKey(keyBytes), Value: encodeRedisTTL(*ttl)}) + return elems, nil } // commitPlanForKey builds the data Raft elements for a single key. func (c *luaScriptContext) commitPlanForKey(ctx context.Context, key string, commitTS uint64) (luaKeyPlan, error) { - finalType, err := c.finalType([]byte(key)) + finalType, err := c.finalType(ctx, []byte(key)) if err != nil { return luaKeyPlan{}, err } - valuePlan, err := c.valueCommitPlan(key, finalType, commitTS) + valuePlan, err := c.valueCommitPlan(ctx, key, finalType, commitTS) if err != nil { return luaKeyPlan{}, err } - startType, err := c.server.keyTypeAt(context.Background(), []byte(key), c.startTS) + startType, err := c.server.keyTypeAt(ctx, []byte(key), c.startTS) if err != nil { return luaKeyPlan{}, err } @@ -3254,10 +3272,11 @@ func (c *luaScriptContext) commitPlanForKey(ctx context.Context, key string, com dataElems = append(dataElems, deleteElems...) dataElems = append(dataElems, valuePlan.elems...) return luaKeyPlan{ - elems: dataElems, - readKeys: readKeys, - finalType: finalType, - preserveExisting: valuePlan.preserveExisting, + elems: dataElems, + readKeys: readKeys, + finalType: finalType, + preserveExisting: valuePlan.preserveExisting, + inlineMetaRewritten: valuePlan.inlineMetaRewritten, }, nil } @@ -3280,37 +3299,37 @@ func luaWideFenceReadKeysForPlan(key []byte, finalType, startType redisValueType return nil } -func (c *luaScriptContext) valueCommitPlan(key string, finalType redisValueType, commitTS uint64) (luaCommitPlan, error) { +func (c *luaScriptContext) valueCommitPlan(ctx context.Context, key string, finalType redisValueType, commitTS uint64) (luaCommitPlan, error) { switch finalType { case redisTypeNone: return luaCommitPlan{}, nil case redisTypeString: - elems, err := c.stringCommitElems(key) + elems, err := c.stringCommitElems(ctx, key) return luaCommitPlan{elems: elems}, err case redisTypeList: - return c.listCommitPlan(key, commitTS) + return c.listCommitPlan(ctx, key, commitTS) case redisTypeHash: - elems, err := c.hashCommitElems(key) + elems, err := c.hashCommitElems(ctx, key) return luaCommitPlan{elems: elems}, err case redisTypeSet: - elems, err := c.setCommitElems(key) + elems, err := c.setCommitElems(ctx, key) return luaCommitPlan{elems: elems}, err case redisTypeZSet: - return c.zsetCommitPlan(key, commitTS) + return c.zsetCommitPlan(ctx, key, commitTS) case redisTypeStream: - elems, err := c.streamCommitElems(key) + elems, err := c.streamCommitElems(ctx, key) return luaCommitPlan{elems: elems}, err default: return luaCommitPlan{}, errors.New("ERR unsupported final redis type") } } -func (c *luaScriptContext) stringCommitElems(key string) ([]*kv.Elem[kv.OP], error) { +func (c *luaScriptContext) stringCommitElems(ctx context.Context, key string) ([]*kv.Elem[kv.OP], error) { st, err := c.stringState([]byte(key)) if err != nil { return nil, err } - ttl, err := c.finalTTL([]byte(key)) + ttl, err := c.finalTTL(ctx, []byte(key)) if err != nil { return nil, err } @@ -3326,13 +3345,17 @@ func (c *luaScriptContext) stringCommitElems(key string) ([]*kv.Elem[kv.OP], err return elems, nil } -func (c *luaScriptContext) listCommitPlan(key string, commitTS uint64) (luaCommitPlan, error) { +func (c *luaScriptContext) listCommitPlan(ctx context.Context, key string, commitTS uint64) (luaCommitPlan, error) { st := c.lists[key] if st == nil || !st.dirty { return luaCommitPlan{preserveExisting: true}, nil } + rewriteForTTL, err := c.dirtyPositiveTTLOnLogicallyAbsentStart(ctx, key) + if err != nil { + return luaCommitPlan{}, err + } if st.materialized { - elems, err := c.listCommitElems(key, commitTS) + elems, err := c.listCommitElems(ctx, key, commitTS) return luaCommitPlan{elems: elems}, err } // If the key was deleted earlier in this script and later recreated as a @@ -3340,14 +3363,18 @@ func (c *luaScriptContext) listCommitPlan(key string, commitTS uint64) (luaCommi // deleteLogicalKeyElems is called and any orphaned storage items from the // previous incarnation of the key are cleaned up before writing the delta. if c.everDeleted[key] { - elems, err := c.listCommitElems(key, commitTS) + elems, err := c.listCommitElems(ctx, key, commitTS) + return luaCommitPlan{elems: elems}, err + } + if rewriteForTTL { + elems, err := c.listCommitElems(ctx, key, commitTS) return luaCommitPlan{elems: elems}, err } elems, err := c.listDeltaCommitElems(key, st, commitTS) return luaCommitPlan{preserveExisting: true, elems: elems}, err } -func (c *luaScriptContext) listCommitElems(key string, commitTS uint64) ([]*kv.Elem[kv.OP], error) { +func (c *luaScriptContext) listCommitElems(ctx context.Context, key string, _ uint64) ([]*kv.Elem[kv.OP], error) { st, err := c.listState([]byte(key)) if err != nil { return nil, err @@ -3355,16 +3382,31 @@ func (c *luaScriptContext) listCommitElems(key string, commitTS uint64) ([]*kv.E if err := c.materializeList([]byte(key), st); err != nil { return nil, err } - values := make([][]byte, 0, len(st.values)) - for _, value := range st.values { - values = append(values, []byte(value)) + if len(st.values) == 0 { + return nil, nil } - - listElems, _, err := c.server.buildRPushOps(store.ListMeta{}, []byte(key), values, commitTS, 0) + ttl, err := c.finalTTL(ctx, []byte(key)) if err != nil { return nil, err } - return listElems, nil + keyBytes := []byte(key) + elems := make([]*kv.Elem[kv.OP], 0, len(st.values)+listFullCommitExtraElems) + for seq, value := range st.values { + elems = append(elems, &kv.Elem[kv.OP]{ + Op: kv.Put, + Key: listItemKey(keyBytes, int64(seq)), + Value: []byte(value), + }) + } + meta, err := store.MarshalListMeta(store.ListMeta{Len: int64(len(st.values)), ExpireAt: ttlMillis(ttl)}) + if err != nil { + return nil, errors.WithStack(err) + } + elems = append(elems, + &kv.Elem[kv.OP]{Op: kv.Put, Key: store.ListMetaKey(keyBytes), Value: meta}, + redisTxnWideListFenceElem(keyBytes), + ) + return elems, nil } func (c *luaScriptContext) listDeltaCommitElems(key string, st *luaListState, commitTS uint64) ([]*kv.Elem[kv.OP], error) { @@ -3448,7 +3490,7 @@ func appendListPuts(elems []*kv.Elem[kv.OP], key []byte, values []string, startS return elems } -func (c *luaScriptContext) hashCommitElems(key string) ([]*kv.Elem[kv.OP], error) { +func (c *luaScriptContext) hashCommitElems(ctx context.Context, key string) ([]*kv.Elem[kv.OP], error) { st, err := c.hashState([]byte(key)) if err != nil { return nil, err @@ -3459,6 +3501,10 @@ func (c *luaScriptContext) hashCommitElems(key string) ([]*kv.Elem[kv.OP], error } // Wide-column: write per-field keys and a base meta key with the final count. // deleteLogicalKeyElems (called by the Lua commit flow) clears any old keys. + ttl, err := c.finalTTL(ctx, []byte(key)) + if err != nil { + return nil, err + } elems := make([]*kv.Elem[kv.OP], 0, len(st.value)+setWideColOverhead) for field, val := range st.value { elems = append(elems, &kv.Elem[kv.OP]{ @@ -3471,12 +3517,12 @@ func (c *luaScriptContext) hashCommitElems(key string) ([]*kv.Elem[kv.OP], error elems = append(elems, &kv.Elem[kv.OP]{ Op: kv.Put, Key: store.HashMetaKey([]byte(key)), - Value: store.MarshalHashMeta(store.HashMeta{Len: int64(len(st.value))}), + Value: store.MarshalHashMeta(store.HashMeta{Len: int64(len(st.value)), ExpireAt: ttlMillis(ttl)}), }) return elems, nil } -func (c *luaScriptContext) setCommitElems(key string) ([]*kv.Elem[kv.OP], error) { +func (c *luaScriptContext) setCommitElems(ctx context.Context, key string) ([]*kv.Elem[kv.OP], error) { st, err := c.setState([]byte(key)) if err != nil { return nil, err @@ -3486,6 +3532,10 @@ func (c *luaScriptContext) setCommitElems(key string) ([]*kv.Elem[kv.OP], error) return nil, nil } // Wide-column: write per-member keys and a base meta key with the final count. + ttl, err := c.finalTTL(ctx, []byte(key)) + if err != nil { + return nil, err + } elems := make([]*kv.Elem[kv.OP], 0, len(st.members)+setWideColOverhead) for member := range st.members { elems = append(elems, &kv.Elem[kv.OP]{ @@ -3498,7 +3548,7 @@ func (c *luaScriptContext) setCommitElems(key string) ([]*kv.Elem[kv.OP], error) elems = append(elems, &kv.Elem[kv.OP]{ Op: kv.Put, Key: store.SetMetaKey([]byte(key)), - Value: store.MarshalSetMeta(store.SetMeta{Len: int64(len(st.members))}), + Value: store.MarshalSetMeta(store.SetMeta{Len: int64(len(st.members)), ExpireAt: ttlMillis(ttl)}), }) return elems, nil } @@ -3508,34 +3558,55 @@ func (c *luaScriptContext) setCommitElems(key string) ([]*kv.Elem[kv.OP], error) // are fully loaded (triggered by any read command), or when the base data was in // legacy blob format (one-time migration). Delta commit is used for write-only // scripts (typically ZADD without prior reads) on an already-wide-column ZSet. -func (c *luaScriptContext) zsetCommitPlan(key string, commitTS uint64) (luaCommitPlan, error) { +func (c *luaScriptContext) zsetCommitPlan(ctx context.Context, key string, commitTS uint64) (luaCommitPlan, error) { st := c.zsets[key] if st == nil || !st.dirty { return luaCommitPlan{preserveExisting: true}, nil } + rewriteForTTL, err := c.dirtyPositiveTTLOnLogicallyAbsentStart(ctx, key) + if err != nil { + return luaCommitPlan{}, err + } // physicallyExistsAtStart is true when the key had physical ZSet data in // storage at script start but was TTL-expired (logically absent). Force a // full commit so deleteLogicalKeyElems removes stale wide-column rows // (members, score-index, meta, TTL) that were left by the expired ZSet. if st.physicallyExistsAtStart || c.everDeleted[key] || st.membersLoaded { - return c.zsetFullCommitWithMerge(key, st), nil + return c.zsetFullCommitWithMerge(ctx, key, st) + } + if rewriteForTTL { + return c.zsetFullCommitWithMerge(ctx, key, st) } if st.legacyBlobBase { // One-time migration: load legacy blob, write wide-column, let deleteLogicalKeyElems clean up. if err := c.ensureZSetLoaded(st, []byte(key)); err != nil { return luaCommitPlan{}, err } - return luaCommitPlan{elems: c.zsetFullCommitElems(key)}, nil + elems, err := c.zsetFullCommitElems(ctx, key) + return luaCommitPlan{elems: elems}, err } // Delta path: write only changed members + score index + metadata delta. - return luaCommitPlan{preserveExisting: true, elems: c.zsetDeltaCommitElems(key, st, commitTS)}, nil + elems, err := c.zsetDeltaCommitElems(ctx, key, st, commitTS) + return luaCommitPlan{preserveExisting: true, inlineMetaRewritten: luaZSetMetaRewritten(elems, []byte(key)), elems: elems}, err +} + +func (c *luaScriptContext) dirtyPositiveTTLOnLogicallyAbsentStart(ctx context.Context, key string) (bool, error) { + st := c.ttls[key] + if st == nil || !st.dirty || st.value == nil { + return false, nil + } + typ, err := c.server.keyTypeAt(ctx, []byte(key), c.startTS) + if err != nil { + return false, err + } + return typ == redisTypeNone, nil } // zsetFullCommitWithMerge returns a full wide-column commit plan for key. When // the key was deleted during the script but delta members were added afterwards // (membersLoaded=false), st.added is merged into st.members so that // zsetFullCommitElems does not silently drop the newly added entries. -func (c *luaScriptContext) zsetFullCommitWithMerge(key string, st *luaZSetState) luaCommitPlan { +func (c *luaScriptContext) zsetFullCommitWithMerge(ctx context.Context, key string, st *luaZSetState) (luaCommitPlan, error) { if !st.membersLoaded && len(st.added) > 0 { if st.members == nil { st.members = make(map[string]float64, len(st.added)) @@ -3545,16 +3616,21 @@ func (c *luaScriptContext) zsetFullCommitWithMerge(key string, st *luaZSetState) } st.added = map[string]float64{} } - return luaCommitPlan{elems: c.zsetFullCommitElems(key)} // preserveExisting=false → deleteLogicalKeyElems called + elems, err := c.zsetFullCommitElems(ctx, key) + return luaCommitPlan{elems: elems}, err // preserveExisting=false -> deleteLogicalKeyElems called } // zsetFullCommitElems writes all members in wide-column format (member keys, // score index keys, and a base meta key). deleteLogicalKeyElems is responsible // for cleaning up any previous storage before these ops are applied. -func (c *luaScriptContext) zsetFullCommitElems(key string) []*kv.Elem[kv.OP] { +func (c *luaScriptContext) zsetFullCommitElems(ctx context.Context, key string) ([]*kv.Elem[kv.OP], error) { st := c.zsets[key] if st == nil || len(st.members) == 0 { - return nil + return nil, nil + } + ttl, err := c.finalTTL(ctx, []byte(key)) + if err != nil { + return nil, err } elems := make([]*kv.Elem[kv.OP], 0, len(st.members)*zsetElemsPerMember+zsetMetaBaseElems) for member, score := range st.members { @@ -3574,78 +3650,131 @@ func (c *luaScriptContext) zsetFullCommitElems(key string) []*kv.Elem[kv.OP] { elems = append(elems, &kv.Elem[kv.OP]{ Op: kv.Put, Key: store.ZSetMetaKey([]byte(key)), - Value: store.MarshalZSetMeta(store.ZSetMeta{Len: int64(len(st.members))}), + Value: store.MarshalZSetMeta(store.ZSetMeta{Len: int64(len(st.members)), ExpireAt: ttlMillis(ttl)}), }) elems = append(elems, redisTxnWideZSetFenceElem([]byte(key))) - return elems + return elems, nil } // zsetDeltaCommitElems writes only the members that changed (added/updated/removed) // together with their score index entries and a cardinality delta key. // preserveExisting=true so deleteLogicalKeyElems is NOT called. -func (c *luaScriptContext) zsetDeltaCommitElems(key string, st *luaZSetState, commitTS uint64) []*kv.Elem[kv.OP] { +func (c *luaScriptContext) zsetDeltaCommitElems(ctx context.Context, key string, st *luaZSetState, commitTS uint64) ([]*kv.Elem[kv.OP], error) { if len(st.added) == 0 && len(st.removed) == 0 { - return nil + return nil, nil } // Each added member: optionally DEL old score key + PUT member key + PUT score key. // Each removed member: DEL member key + DEL score key. // Plus one optional ZSetMetaDeltaKey. elems := make([]*kv.Elem[kv.OP], 0, len(st.added)*zsetElemsPerAdded+len(st.removed)*zsetElemsPerRemoved+zsetMetaBaseElems) + elems = appendLuaZSetAddedElems(elems, []byte(key), st) + elems = appendLuaZSetRemovedElems(elems, []byte(key), st) + metaElems, err := c.zsetDeltaMetaElems(ctx, []byte(key), st, commitTS) + if err != nil { + return nil, err + } + elems = append(elems, metaElems...) + if len(elems) != 0 { + elems = append(elems, redisTxnWideZSetFenceElem([]byte(key))) + } + return elems, nil +} + +func appendLuaZSetAddedElems(elems []*kv.Elem[kv.OP], key []byte, st *luaZSetState) []*kv.Elem[kv.OP] { for member, score := range st.added { + memberBytes := []byte(member) if storageScore, ok := st.storageScores[member]; ok && storageScore != nil { // Remove stale score index from storage. elems = append(elems, &kv.Elem[kv.OP]{ Op: kv.Del, - Key: store.ZSetScoreKey([]byte(key), *storageScore, []byte(member)), + Key: store.ZSetScoreKey(key, *storageScore, memberBytes), }) } elems = append(elems, &kv.Elem[kv.OP]{ Op: kv.Put, - Key: store.ZSetMemberKey([]byte(key), []byte(member)), + Key: store.ZSetMemberKey(key, memberBytes), Value: store.MarshalZSetScore(score), }, &kv.Elem[kv.OP]{ Op: kv.Put, - Key: store.ZSetScoreKey([]byte(key), score, []byte(member)), + Key: store.ZSetScoreKey(key, score, memberBytes), Value: []byte{}, }, ) } + return elems +} + +func appendLuaZSetRemovedElems(elems []*kv.Elem[kv.OP], key []byte, st *luaZSetState) []*kv.Elem[kv.OP] { for member := range st.removed { storageScore, ok := st.storageScores[member] if !ok || storageScore == nil { continue } + memberBytes := []byte(member) elems = append(elems, - &kv.Elem[kv.OP]{Op: kv.Del, Key: store.ZSetMemberKey([]byte(key), []byte(member))}, - &kv.Elem[kv.OP]{Op: kv.Del, Key: store.ZSetScoreKey([]byte(key), *storageScore, []byte(member))}, + &kv.Elem[kv.OP]{Op: kv.Del, Key: store.ZSetMemberKey(key, memberBytes)}, + &kv.Elem[kv.OP]{Op: kv.Del, Key: store.ZSetScoreKey(key, *storageScore, memberBytes)}, ) } - elems = append(elems, c.zsetDeltaMetaElems([]byte(key), st, commitTS)...) - if len(elems) != 0 { - elems = append(elems, redisTxnWideZSetFenceElem([]byte(key))) - } return elems } // zsetDeltaMetaElems returns the metadata element(s) for a delta commit. // It tries to fold existing delta keys inline (compaction); on error or when // below the threshold it falls back to writing a single new ZSetMetaDeltaKey. -func (c *luaScriptContext) zsetDeltaMetaElems(key []byte, st *luaZSetState, commitTS uint64) []*kv.Elem[kv.OP] { +func (c *luaScriptContext) zsetDeltaMetaElems(ctx context.Context, key []byte, st *luaZSetState, commitTS uint64) ([]*kv.Elem[kv.OP], error) { compactElems, compacted, err := c.server.zsetInlineMetaCompactionElems( - context.Background(), key, c.startTS, st.lenDelta) + ctx, key, c.startTS, st.lenDelta) if err == nil && compacted { - return compactElems + return c.zsetMetaElemsWithFinalTTL(ctx, key, compactElems) } if st.lenDelta == 0 { - return nil + return nil, nil } return []*kv.Elem[kv.OP]{{ Op: kv.Put, Key: store.ZSetMetaDeltaKey(key, commitTS, 0), Value: store.MarshalZSetMetaDelta(store.ZSetMetaDelta{LenDelta: st.lenDelta}), - }} + }}, nil +} + +func (c *luaScriptContext) zsetMetaElemsWithFinalTTL(ctx context.Context, key []byte, elems []*kv.Elem[kv.OP]) ([]*kv.Elem[kv.OP], error) { + ttl, err := c.finalTTL(ctx, key) + if err != nil { + return nil, err + } + expireAt := ttlMillis(ttl) + metaKey := store.ZSetMetaKey(key) + out := make([]*kv.Elem[kv.OP], 0, len(elems)) + for _, elem := range elems { + if elem.Op == kv.Put && string(elem.Key) == string(metaKey) { + meta, err := store.UnmarshalZSetMeta(elem.Value) + if err != nil { + return nil, errors.WithStack(err) + } + meta.ExpireAt = expireAt + out = append(out, &kv.Elem[kv.OP]{ + Op: elem.Op, + Key: elem.Key, + Value: store.MarshalZSetMeta(meta), + }) + continue + } + out = append(out, elem) + } + return out, nil +} + +func luaZSetMetaRewritten(elems []*kv.Elem[kv.OP], key []byte) bool { + metaKey := store.ZSetMetaKey(key) + for _, elem := range elems { + if string(elem.Key) == string(metaKey) { + return true + } + } + return false } // streamCommitElems writes the script's final stream state in the @@ -3658,12 +3787,16 @@ func (c *luaScriptContext) zsetDeltaMetaElems(key []byte, st *luaZSetState, comm // deleteLogicalKeyElems which clears every prior layout for this key, so // we can safely rewrite the new-layout entries from scratch instead of // computing a delta against the original load. -func (c *luaScriptContext) streamCommitElems(key string) ([]*kv.Elem[kv.OP], error) { +func (c *luaScriptContext) streamCommitElems(ctx context.Context, key string) ([]*kv.Elem[kv.OP], error) { st, err := c.streamState([]byte(key)) if err != nil { return nil, err } keyBytes := []byte(key) + ttl, err := c.finalTTL(ctx, keyBytes) + if err != nil { + return nil, err + } elems := make([]*kv.Elem[kv.OP], 0, len(st.value.Entries)+1) var meta store.StreamMeta for _, entry := range st.value.Entries { @@ -3688,6 +3821,7 @@ func (c *luaScriptContext) streamCommitElems(key string) ([]*kv.Elem[kv.OP], err } } meta.Length = int64(len(st.value.Entries)) + meta.ExpireAt = ttlMillis(ttl) metaBytes, err := store.MarshalStreamMeta(meta) if err != nil { return nil, errors.WithStack(err) @@ -3700,19 +3834,19 @@ func (c *luaScriptContext) streamCommitElems(key string) ([]*kv.Elem[kv.OP], err return elems, nil } -func (c *luaScriptContext) finalType(key []byte) (redisValueType, error) { +func (c *luaScriptContext) finalType(ctx context.Context, key []byte) (redisValueType, error) { if typ, ok := c.cachedType(key); ok { return typ, nil } - return c.server.keyTypeAt(context.Background(), key, c.startTS) + return c.server.keyTypeAt(ctx, key, c.startTS) } -func (c *luaScriptContext) finalTTL(key []byte) (*time.Time, error) { +func (c *luaScriptContext) finalTTL(ctx context.Context, key []byte) (*time.Time, error) { st := c.ttls[string(key)] if st != nil && st.loaded { return st.value, nil } - ttl, err := c.server.ttlAt(context.Background(), key, c.startTS) + ttl, err := c.server.ttlAt(ctx, key, c.startTS) if err != nil { return nil, err } diff --git a/adapter/redis_proxy_leader.go b/adapter/redis_proxy_leader.go index d0b3d40d2..b7fa7498f 100644 --- a/adapter/redis_proxy_leader.go +++ b/adapter/redis_proxy_leader.go @@ -481,10 +481,11 @@ func (r *RedisServer) readValueAt(ctx context.Context, key []byte, readTS uint64 nonStringInternal := false if userKey := extractRedisInternalUserKey(key); userKey != nil { ttlKey = userKey - // Non-string internal keys (!redis|hash|, !redis|set|, …) can never - // carry an embedded-TTL payload, so we can skip the !redis|str| probe - // that ttlAt would otherwise make. - nonStringInternal = !bytes.HasPrefix(key, []byte(redisStrPrefix)) + // Non-string internal keys (!redis|hash|, !redis|set|, …) can skip the + // embedded-TTL probe. HLL is reported as a Redis string and now carries + // inline TTL in its own anchor, so it must use ttlAt's full probe chain. + nonStringInternal = !bytes.HasPrefix(key, []byte(redisStrPrefix)) && + !bytes.HasPrefix(key, []byte(redisHLLPrefix)) } expired, err := r.hasExpired(ctx, ttlKey, readTS, nonStringInternal) if err != nil { diff --git a/adapter/redis_retry_test.go b/adapter/redis_retry_test.go index 7c1ce8491..8ca6bf5f7 100644 --- a/adapter/redis_retry_test.go +++ b/adapter/redis_retry_test.go @@ -428,12 +428,14 @@ func TestZSetTTLExpiredRecreation(t *testing.T) { ctx := context.Background() st := store.NewMVCCStore() + expiredAt := time.UnixMilli(1) // Write a wide-column ZSet with member "old-member" at ts=1. require.NoError(t, st.PutAt(ctx, store.ZSetMemberKey([]byte("expired:zset"), []byte("old-member")), store.MarshalZSetScore(9.0), 1, 0)) require.NoError(t, st.PutAt(ctx, store.ZSetScoreKey([]byte("expired:zset"), 9.0, []byte("old-member")), []byte{}, 1, 0)) - require.NoError(t, st.PutAt(ctx, store.ZSetMetaKey([]byte("expired:zset")), store.MarshalZSetMeta(store.ZSetMeta{Len: 1}), 1, 0)) - // Set a TTL that is already in the past so the key appears expired. - require.NoError(t, st.PutAt(ctx, redisTTLKey([]byte("expired:zset")), encodeRedisTTL(time.Unix(0, 0)), 2, 0)) + require.NoError(t, st.PutAt(ctx, store.ZSetMetaKey([]byte("expired:zset")), + store.MarshalZSetMeta(store.ZSetMeta{Len: 1, ExpireAt: redisExpireAtMillis(expiredAt)}), 1, 0)) + // Keep a stale scan-index TTL too; the recreation cleanup must remove it. + require.NoError(t, st.PutAt(ctx, redisTTLKey([]byte("expired:zset")), encodeRedisTTL(expiredAt), 2, 0)) coord := newRetryOnceCoordinator(st) coord.clock.Observe(2) diff --git a/adapter/redis_set_cmds.go b/adapter/redis_set_cmds.go index d644741fe..74a6c8bd4 100644 --- a/adapter/redis_set_cmds.go +++ b/adapter/redis_set_cmds.go @@ -6,6 +6,7 @@ import ( "fmt" "slices" "sort" + "time" "github.com/bootjp/elastickv/kv" "github.com/bootjp/elastickv/store" @@ -44,7 +45,23 @@ func (r *RedisServer) validateExactSetKind(kind string, key []byte, readTS uint6 } func (r *RedisServer) hllExistsAt(key []byte, readTS uint64) (bool, error) { - exists, err := r.store.ExistsAt(context.Background(), redisHLLKey(key), readTS) + ctx := context.Background() + exists, err := r.hllAnchorExistsAt(ctx, key, readTS) + if err != nil { + return false, err + } + if !exists { + return false, nil + } + expired, err := r.hasExpired(ctx, key, readTS, false) + if err != nil { + return false, err + } + return !expired, nil +} + +func (r *RedisServer) hllAnchorExistsAt(ctx context.Context, key []byte, readTS uint64) (bool, error) { + exists, err := r.store.ExistsAt(ctx, redisHLLKey(key), readTS) if err != nil { return false, fmt.Errorf("exists hll: %w", err) } @@ -74,13 +91,20 @@ func (r *RedisServer) buildSetLegacyMigrationElems(ctx context.Context, key []by Value: []byte{}, }) } + ttlMs, expired, err := legacyTTLMillisForMigrationAt(ctx, r.store, key, readTS) + if err != nil { + return nil, err + } + if expired { + return legacyExpiredCollectionCleanupElems(key, redisSetKey(key)), nil + } // Delete the legacy blob. elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: redisSetKey(key)}) // Write a base meta so that resolveSetMeta starts from an accurate count. elems = append(elems, &kv.Elem[kv.OP]{ Op: kv.Put, Key: store.SetMetaKey(key), - Value: store.MarshalSetMeta(store.SetMeta{Len: int64(len(value.Members))}), + Value: store.MarshalSetMeta(store.SetMeta{Len: int64(len(value.Members)), ExpireAt: ttlMs}), }) elems = append(elems, redisTxnWideSetFenceElem(key)) return elems, nil @@ -112,7 +136,7 @@ func (r *RedisServer) validateExactSetType(typ redisValueType, key []byte, readT return wrongTypeError() } - hllExists, err := r.hllExistsAt(key, readTS) + hllExists, err := r.hllAnchorExistsAt(context.Background(), key, readTS) if err != nil { return err } @@ -177,21 +201,7 @@ func sortedExactSetMembers(existing map[string]struct{}) []string { func (r *RedisServer) persistExactSetMembersTxn(ctx context.Context, kind string, key []byte, readTS uint64, members map[string]struct{}) error { if kind != setKind { - // HLL and other non-set kinds keep using the legacy blob format. - if len(members) == 0 { - elems, _, err := r.deleteLogicalKeyElems(ctx, key, readTS) - if err != nil { - return err - } - return r.dispatchElems(ctx, true, readTS, elems) - } - payload, err := marshalSetValue(redisSetValue{Members: sortedExactSetMembers(members)}) - if err != nil { - return err - } - return r.dispatchElems(ctx, true, readTS, []*kv.Elem[kv.OP]{ - {Op: kv.Put, Key: redisExactSetStorageKey(kind, key), Value: payload}, - }) + return r.persistHLLMembersTxn(ctx, key, readTS, members) } // Wide-column set: full rewrite (used when the whole state is available). if len(members) == 0 { @@ -219,6 +229,38 @@ func (r *RedisServer) persistExactSetMembersTxn(ctx context.Context, kind string return r.dispatchElems(ctx, true, readTS, elems) } +func (r *RedisServer) persistHLLMembersTxn(ctx context.Context, key []byte, readTS uint64, members map[string]struct{}) error { + // HLL keeps a single anchor payload, now wrapped in an inline-TTL envelope + // while preserving legacy payload reads during migration. + if len(members) == 0 { + elems, _, err := r.deleteLogicalKeyElems(ctx, key, readTS) + if err != nil { + return err + } + return r.dispatchElems(ctx, true, readTS, elems) + } + ttl, err := r.ttlAt(ctx, key, readTS) + if err != nil { + return err + } + if ttl != nil && !ttl.After(time.Now()) { + ttl = nil + } + payload, err := encodeRedisHLL(redisSetValue{Members: sortedExactSetMembers(members)}, ttl) + if err != nil { + return err + } + elems := []*kv.Elem[kv.OP]{{Op: kv.Put, Key: redisHLLKey(key), Value: payload}} + return r.dispatchElems(ctx, true, readTS, appendHLLScanIndexElem(elems, key, ttl)) +} + +func appendHLLScanIndexElem(elems []*kv.Elem[kv.OP], key []byte, ttl *time.Time) []*kv.Elem[kv.OP] { + if ttl != nil { + return append(elems, &kv.Elem[kv.OP]{Op: kv.Put, Key: redisTTLKey(key), Value: encodeRedisTTL(*ttl)}) + } + return append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: redisTTLKey(key)}) +} + // applySetMemberMutation emits a Put or Del for one set member and returns the // change count (1) and the signed length delta (+1 or -1), or (0, 0) if no change. func applySetMemberMutation(elems []*kv.Elem[kv.OP], memberKey []byte, exists, add bool) ([]*kv.Elem[kv.OP], int, int64) { @@ -231,7 +273,8 @@ func applySetMemberMutation(elems []*kv.Elem[kv.OP], memberKey []byte, exists, a return elems, 0, 0 } -// mutateExactSetLegacy handles SADD/SREM for non-set kinds (e.g. HLL) via the legacy blob path. +// mutateExactSetLegacy handles SADD/SREM for non-set kinds (e.g. HLL) via a +// single anchor payload. func (r *RedisServer) mutateExactSetLegacy(conn redcon.Conn, ctx context.Context, kind string, key []byte, members [][]byte, add bool) { var changed int if err := r.retryRedisWrite(ctx, func() error { @@ -265,7 +308,8 @@ func (r *RedisServer) mutateExactSetWide(conn redcon.Conn, ctx context.Context, if err != nil { return err } - if err := r.rejectHLLPayloadForSetCreate(key, readTS, typ); err != nil { + cleanupElems, migrationElems, legacyMemberBase, expiredRecreate, err := r.setWideMutationBase(ctx, key, readTS, typ) + if err != nil { return err } @@ -275,25 +319,18 @@ func (r *RedisServer) mutateExactSetWide(conn redcon.Conn, ctx context.Context, return cockerrors.WithStack(err) } - migrationElems, migErr := r.buildSetLegacyMigrationElems(ctx, key, readTS) - if migErr != nil { - return migErr - } - elems := make([]*kv.Elem[kv.OP], 0, len(migrationElems)+len(members)+setWideColOverhead) + elems := make([]*kv.Elem[kv.OP], 0, len(cleanupElems)+len(migrationElems)+len(members)+setWideColOverhead) + elems = append(elems, cleanupElems...) elems = append(elems, migrationElems...) - // Extract legacy member names from migration ops so that applySetMemberMutations - // can treat them as already-existing (they are not yet visible at readTS). - legacyMemberBase := buildLegacySetMemberBase(migrationElems, key) - var lenDelta int64 var mutErr error - elems, changed, lenDelta, mutErr = r.applySetMemberMutations(ctx, key, members, add, readTS, elems, legacyMemberBase) + elems, changed, lenDelta, mutErr = r.applySetMemberMutations(ctx, key, members, add, readTS, elems, legacyMemberBase, expiredRecreate) if mutErr != nil { return mutErr } - if changed == 0 && len(migrationElems) == 0 { + if changed == 0 && len(migrationElems) == 0 && len(cleanupElems) == 0 { return nil } @@ -318,11 +355,36 @@ func (r *RedisServer) mutateExactSetWide(conn redcon.Conn, ctx context.Context, conn.WriteInt(changed) } -func (r *RedisServer) rejectHLLPayloadForSetCreate(key []byte, readTS uint64, typ redisValueType) error { +func (r *RedisServer) setWideMutationBase( + ctx context.Context, + key []byte, + readTS uint64, + typ redisValueType, +) ([]*kv.Elem[kv.OP], []*kv.Elem[kv.OP], map[string]struct{}, bool, error) { + cleanupElems, expiredRecreate, err := r.expiredCollectionCleanupForRecreate(ctx, key, readTS, typ) + if err != nil { + return nil, nil, nil, false, err + } + if err := r.rejectHLLPayloadForSetCreate(ctx, key, readTS, typ); err != nil { + return nil, nil, nil, false, err + } + if expiredRecreate { + return cleanupElems, nil, nil, true, nil + } + migrationElems, err := r.buildSetLegacyMigrationElems(ctx, key, readTS) + if err != nil { + return nil, nil, nil, false, err + } + // Extract legacy member names from migration ops so that applySetMemberMutations + // can treat them as already-existing (they are not yet visible at readTS). + return cleanupElems, migrationElems, buildLegacySetMemberBase(migrationElems, key), false, nil +} + +func (r *RedisServer) rejectHLLPayloadForSetCreate(ctx context.Context, key []byte, readTS uint64, typ redisValueType) error { if typ != redisTypeNone { return nil } - hllExists, err := r.hllExistsAt(key, readTS) + hllExists, err := r.hllAnchorExistsAt(ctx, key, readTS) if err != nil { return err } @@ -453,8 +515,11 @@ func (r *RedisServer) scanKeyExistsMap(ctx context.Context, scanPrefix []byte, r // scan; otherwise it returns an empty (non-nil) map for per-member ExistsAt // fallback. Legacy members from migration elems are merged in so that members // already in-flight in the same transaction are treated as existing. -func (r *RedisServer) initSetExistsMap(ctx context.Context, key []byte, members [][]byte, readTS uint64, legacyBase map[string]struct{}) (map[string]struct{}, error) { +func (r *RedisServer) initSetExistsMap(ctx context.Context, key []byte, members [][]byte, readTS uint64, legacyBase map[string]struct{}, ignoreExisting bool) (map[string]struct{}, error) { existsMap := make(map[string]struct{}) + if ignoreExisting { + return existsMap, nil + } if len(members) >= wideColumnBulkScanThreshold || len(legacyBase) > 0 { var err error existsMap, err = r.scanSetMemberExistsMap(ctx, key, readTS) @@ -495,12 +560,12 @@ func (r *RedisServer) lookupSetMemberExists(ctx context.Context, memberStr strin // The bulk scan threshold is wideColumnBulkScanThreshold. // legacyBase contains members from a legacy blob being migrated in the same // transaction; they are not visible at readTS and must be treated as existing. -func (r *RedisServer) applySetMemberMutations(ctx context.Context, key []byte, members [][]byte, add bool, readTS uint64, elems []*kv.Elem[kv.OP], legacyBase map[string]struct{}) ([]*kv.Elem[kv.OP], int, int64, error) { - existsMap, err := r.initSetExistsMap(ctx, key, members, readTS, legacyBase) +func (r *RedisServer) applySetMemberMutations(ctx context.Context, key []byte, members [][]byte, add bool, readTS uint64, elems []*kv.Elem[kv.OP], legacyBase map[string]struct{}, ignoreExisting bool) ([]*kv.Elem[kv.OP], int, int64, error) { + existsMap, err := r.initSetExistsMap(ctx, key, members, readTS, legacyBase, ignoreExisting) if err != nil { return nil, 0, 0, err } - isSmallClean := len(members) < wideColumnBulkScanThreshold && len(legacyBase) == 0 + isSmallClean := !ignoreExisting && len(members) < wideColumnBulkScanThreshold && len(legacyBase) == 0 changed := 0 lenDelta := int64(0) for _, member := range members { @@ -653,7 +718,7 @@ func (r *RedisServer) pfadd(conn redcon.Conn, cmd redcon.Command) { return err } - value, err := r.loadSetAt(context.Background(), hllKind, cmd.Args[1], readTS) + value, err := r.loadSetAt(ctx, hllKind, cmd.Args[1], readTS) if err != nil { return err } @@ -689,16 +754,17 @@ func (r *RedisServer) pfcount(conn redcon.Conn, cmd redcon.Command) { writeRedisError(conn, err) return } - if typ != redisTypeNone { - hllExists, err := r.store.ExistsAt(ctx, redisHLLKey(key), readTS) - if err != nil { - writeRedisError(conn, err) - return - } - if !hllExists { - conn.WriteError(wrongTypeMessage) - return - } + hllExists, err := r.hllExistsAt(key, readTS) + if err != nil { + writeRedisError(conn, err) + return + } + if typ != redisTypeNone && !hllExists { + conn.WriteError(wrongTypeMessage) + return + } + if !hllExists { + continue } value, err := r.loadSetAt(ctx, hllKind, key, readTS) if err != nil { diff --git a/adapter/redis_storage_codec_test.go b/adapter/redis_storage_codec_test.go index 31aff1d1a..4b316cb25 100644 --- a/adapter/redis_storage_codec_test.go +++ b/adapter/redis_storage_codec_test.go @@ -2,6 +2,7 @@ package adapter import ( "testing" + "time" "github.com/stretchr/testify/require" ) @@ -37,6 +38,35 @@ func TestStoredRedisSetCodec_RoundTripProto(t *testing.T) { require.Equal(t, redisSetValue{Members: []string{"a", "b"}}, decoded) } +func TestStoredRedisHLLCodec_RoundTripInlineTTL(t *testing.T) { + t.Parallel() + + ttl := time.UnixMilli(123_456) + body, err := encodeRedisHLL(redisSetValue{Members: []string{"b", "a"}}, &ttl) + require.NoError(t, err) + require.True(t, isNewRedisHLLFormat(body)) + + decoded, gotTTL, embedded, err := decodeRedisHLL(body) + require.NoError(t, err) + require.True(t, embedded) + require.Equal(t, redisSetValue{Members: []string{"a", "b"}}, decoded) + require.NotNil(t, gotTTL) + require.Equal(t, redisExpireAtMillis(ttl), redisExpireAtMillis(*gotTTL)) +} + +func TestStoredRedisHLLCodec_LegacySetPayloadFallback(t *testing.T) { + t.Parallel() + + body, err := marshalSetValue(redisSetValue{Members: []string{"legacy"}}) + require.NoError(t, err) + + decoded, ttl, embedded, err := decodeRedisHLL(body) + require.NoError(t, err) + require.False(t, embedded) + require.Nil(t, ttl) + require.Equal(t, redisSetValue{Members: []string{"legacy"}}, decoded) +} + func TestStoredRedisZSetCodec_RoundTripProto(t *testing.T) { t.Parallel() diff --git a/adapter/redis_stream_cmds.go b/adapter/redis_stream_cmds.go index c029c9cd9..f13008e41 100644 --- a/adapter/redis_stream_cmds.go +++ b/adapter/redis_stream_cmds.go @@ -227,18 +227,20 @@ func (r *RedisServer) xadd(conn redcon.Conn, cmd redcon.Command) { func (r *RedisServer) xaddTxn(ctx context.Context, key []byte, req xaddRequest) (string, error) { readTS := r.readTS() - typ, err := r.keyTypeAtExpect(ctx, key, readTS, redisTypeStream) + typ, err := r.streamTypeForXAdd(ctx, key, readTS) if err != nil { return "", err } - if typ != redisTypeNone && typ != redisTypeStream { - return "", wrongTypeError() - } legacyCleanup, meta, metaFound, err := r.streamWriteBase(ctx, key, readTS) if err != nil { return "", err } + legacyCleanup, meta, metaFound, err = r.streamCleanupForExpiredRecreate( + ctx, key, readTS, typ, legacyCleanup, meta, metaFound) + if err != nil { + return "", err + } id, parsedID, err := resolveXAddID(meta, metaFound, req.id) if err != nil { @@ -254,10 +256,10 @@ func (r *RedisServer) xaddTxn(ctx context.Context, key []byte, req xaddRequest) return "", err } - // Capacity hint covers: optional legacy-cleanup Del + one entry Put + - // one meta Put + the trim Dels. legacyCleanup is at most one element, - // and only non-empty on the very first write against a stream whose - // pre-migration blob is still on disk. + // Capacity hint covers: stream cleanup Dels + one entry Put + one meta + // Put + the trim Dels. Cleanup is usually at most one legacy Del, but can + // include existing wide-column stream rows when XADD recreates an expired + // stream before the sweeper has physically removed it. const xaddFixedElemCount = 2 elems := make([]*kv.Elem[kv.OP], 0, len(legacyCleanup)+xaddFixedElemCount+estimateXAddTrimCount(req.maxLen, meta.Length)) @@ -276,9 +278,10 @@ func (r *RedisServer) xaddTxn(ctx context.Context, key []byte, req xaddRequest) elems = appendMaxLenZeroSelfDel(elems, req.maxLen, key, parsedID) metaBytes, err := store.MarshalStreamMeta(store.StreamMeta{ - Length: nextLen, - LastMs: parsedID.ms, - LastSeq: parsedID.seq, + Length: nextLen, + LastMs: parsedID.ms, + LastSeq: parsedID.seq, + ExpireAt: meta.ExpireAt, }) if err != nil { return "", cockerrors.WithStack(err) @@ -288,6 +291,30 @@ func (r *RedisServer) xaddTxn(ctx context.Context, key []byte, req xaddRequest) return id, r.dispatchAndSignalStream(ctx, true, readTS, elems, key) } +func redisTTLMillisExpired(ttlMs uint64) bool { + return ttlMs != 0 && !redisTimeFromMillis(ttlMs).After(time.Now()) +} + +func (r *RedisServer) streamCleanupForExpiredRecreate( + ctx context.Context, + key []byte, + readTS uint64, + typ redisValueType, + cleanup []*kv.Elem[kv.OP], + meta store.StreamMeta, + metaFound bool, +) ([]*kv.Elem[kv.OP], store.StreamMeta, bool, error) { + if typ != redisTypeNone || !metaFound || !redisTTLMillisExpired(meta.ExpireAt) { + return cleanup, meta, metaFound, nil + } + cleanup, err := r.deleteStreamWideColumnElems(ctx, key, readTS) + if err != nil { + return nil, store.StreamMeta{}, false, err + } + cleanup = append(cleanup, &kv.Elem[kv.OP]{Op: kv.Del, Key: redisTTLKey(key)}) + return cleanup, store.StreamMeta{}, false, nil +} + // dispatchAndSignalStream dispatches the elems through the coordinator // and, on success, wakes any XREAD BLOCK waiter on the same node. // dispatchElems blocks until the FSM applies locally, so by the time @@ -435,7 +462,18 @@ func (r *RedisServer) streamWriteBase(ctx context.Context, key []byte, readTS ui if err != nil { return nil, store.StreamMeta{}, false, err } - return legacyCleanup, store.StreamMeta{}, false, nil + if len(legacyCleanup) == 0 { + return nil, store.StreamMeta{}, false, nil + } + ttlMs, expired, err := legacyTTLMillisForMigrationAt(ctx, r.store, key, readTS) + if err != nil { + return nil, store.StreamMeta{}, false, err + } + if expired { + legacyCleanup = append(legacyCleanup, &kv.Elem[kv.OP]{Op: kv.Del, Key: redisTTLKey(key)}) + ttlMs = 0 + } + return legacyCleanup, store.StreamMeta{ExpireAt: ttlMs}, false, nil } // legacyStreamCleanupElems returns a Del elem for the legacy single-blob @@ -579,6 +617,21 @@ func (r *RedisServer) streamTypeForWrite(ctx context.Context, key []byte, readTS } } +func (r *RedisServer) streamTypeForXAdd(ctx context.Context, key []byte, readTS uint64) (redisValueType, error) { + typ, err := r.keyTypeAtExpect(ctx, key, readTS, redisTypeStream) + if err != nil { + return redisTypeNone, err + } + switch typ { + case redisTypeNone, redisTypeStream: + return typ, nil + case redisTypeString, redisTypeList, redisTypeHash, redisTypeSet, redisTypeZSet: + return typ, wrongTypeError() + default: + return typ, wrongTypeError() + } +} + // flushLegacyCleanupOnTrimNoOp commits the legacy-blob Del + meta Put // for an XTRIM whose length is already under maxLen. Without this // flush a subsequent read would still find the stale legacy blob. diff --git a/adapter/redis_ttl_compat_test.go b/adapter/redis_ttl_compat_test.go index dc5364ad8..d654a66af 100644 --- a/adapter/redis_ttl_compat_test.go +++ b/adapter/redis_ttl_compat_test.go @@ -234,6 +234,82 @@ func TestRedis_MultiExec_ExpireNXUsesStagedSetTTL(t *testing.T) { require.LessOrEqual(t, ttl, 20*time.Second) } +func TestRedis_MultiExec_ExpireRecreatedListKeepsStagedMeta(t *testing.T) { + t.Parallel() + nodes, _, _ := createNode(t, 3) + defer shutdown(nodes) + + ctx := context.Background() + rdb := redis.NewClient(&redis.Options{Addr: nodes[0].redisAddress}) + defer func() { _ = rdb.Close() }() + + key := "multi:recreated-list-expire" + require.NoError(t, rdb.RPush(ctx, key, "old-a", "old-b").Err()) + + require.NoError(t, rdb.Do(ctx, "MULTI").Err()) + require.Equal(t, "QUEUED", rdb.Do(ctx, "DEL", key).Val()) + require.Equal(t, "QUEUED", rdb.Do(ctx, "RPUSH", key, "fresh").Val()) + require.Equal(t, "QUEUED", rdb.Do(ctx, "EXPIRE", key, "60").Val()) + execRes, err := rdb.Do(ctx, "EXEC").Result() + require.NoError(t, err) + require.Equal(t, []any{int64(1), int64(1), int64(1)}, execRes) + + got, err := rdb.LRange(ctx, key, 0, -1).Result() + require.NoError(t, err) + require.Equal(t, []string{"fresh"}, got) + + ttl, err := rdb.TTL(ctx, key).Result() + require.NoError(t, err) + require.Greater(t, ttl, time.Duration(0)) + require.LessOrEqual(t, ttl, 60*time.Second) + + readTS := nodes[0].redisServer.readTS() + raw, err := nodes[0].redisServer.store.GetAt(ctx, store.ListMetaKey([]byte(key)), readTS) + require.NoError(t, err) + meta, err := store.UnmarshalListMeta(raw) + require.NoError(t, err) + require.Equal(t, int64(1), meta.Len) + require.NotZero(t, meta.ExpireAt) +} + +func TestRedis_MultiExec_ExpireRecreatedHashKeepsStagedMeta(t *testing.T) { + t.Parallel() + nodes, _, _ := createNode(t, 3) + defer shutdown(nodes) + + ctx := context.Background() + rdb := redis.NewClient(&redis.Options{Addr: nodes[0].redisAddress}) + defer func() { _ = rdb.Close() }() + + key := "multi:recreated-hash-expire" + require.NoError(t, rdb.HSet(ctx, key, "old-a", "1", "old-b", "2").Err()) + + require.NoError(t, rdb.Do(ctx, "MULTI").Err()) + require.Equal(t, "QUEUED", rdb.Do(ctx, "DEL", key).Val()) + require.Equal(t, "QUEUED", rdb.Do(ctx, "HSET", key, "fresh", "new").Val()) + require.Equal(t, "QUEUED", rdb.Do(ctx, "EXPIRE", key, "60").Val()) + execRes, err := rdb.Do(ctx, "EXEC").Result() + require.NoError(t, err) + require.Equal(t, []any{int64(1), int64(1), int64(1)}, execRes) + + got, err := rdb.HGetAll(ctx, key).Result() + require.NoError(t, err) + require.Equal(t, map[string]string{"fresh": "new"}, got) + + ttl, err := rdb.TTL(ctx, key).Result() + require.NoError(t, err) + require.Greater(t, ttl, time.Duration(0)) + require.LessOrEqual(t, ttl, 60*time.Second) + + readTS := nodes[0].redisServer.readTS() + raw, err := nodes[0].redisServer.store.GetAt(ctx, store.HashMetaKey([]byte(key)), readTS) + require.NoError(t, err) + meta, err := store.UnmarshalHashMeta(raw) + require.NoError(t, err) + require.Equal(t, int64(1), meta.Len) + require.NotZero(t, meta.ExpireAt) +} + func TestRedis_MultiExec_HSetRecreatesExpiredHash(t *testing.T) { t.Parallel() nodes, _, _ := createNode(t, 3) @@ -313,7 +389,7 @@ func TestRedis_SAddRejectsExpiredHLLPayload(t *testing.T) { require.ErrorContains(t, err, "WRONGTYPE") count, err := rdb.Do(ctx, "PFCOUNT", "set:expired-hll").Int64() require.NoError(t, err) - require.Equal(t, int64(1), count) + require.Equal(t, int64(0), count) } func TestRedis_MultiExec_IncrRecreatesExpiredStringWithoutTTLIndex(t *testing.T) { diff --git a/adapter/redis_ttl_inline_migrator.go b/adapter/redis_ttl_inline_migrator.go new file mode 100644 index 000000000..6cedeb8d0 --- /dev/null +++ b/adapter/redis_ttl_inline_migrator.go @@ -0,0 +1,634 @@ +package adapter + +import ( + "bytes" + "context" + "time" + + "github.com/bootjp/elastickv/kv" + "github.com/bootjp/elastickv/store" + "github.com/cockroachdb/errors" +) + +const ( + ttlInlineMigrationTickScanLimit = 512 + ttlInlineMigrationBatchKeyLimit = 32 +) + +type ttlInlineMigrationHandler struct { + typeName string + prefix []byte + extractUserKey func([]byte) []byte + buildElems func(context.Context, *store.KVPair, uint64) ([]*kv.Elem[kv.OP], error) +} + +func (c *DeltaCompactor) migrateTTLInlineOnce(ctx context.Context, readTS uint64) error { + var combined error + for _, h := range c.ttlInlineMigrationHandlers() { + if err := c.migrateTTLInlineHandler(ctx, h, readTS); err != nil { + combined = errors.CombineErrors(combined, err) + } + } + return errors.WithStack(combined) +} + +func (c *DeltaCompactor) ttlInlineMigrationHandlers() []ttlInlineMigrationHandler { + return []ttlInlineMigrationHandler{ + { + typeName: "ttl-inline-string", + prefix: []byte(redisStrPrefix), + extractUserKey: trimKnownPrefix([]byte(redisStrPrefix)), + buildElems: c.migrateStringTTLInlineElems, + }, + { + typeName: "ttl-inline-hll", + prefix: []byte(redisHLLPrefix), + extractUserKey: trimKnownPrefix([]byte(redisHLLPrefix)), + buildElems: c.migrateHLLTTLInlineElems, + }, + { + typeName: "ttl-inline-indexed-collection", + prefix: []byte(redisTTLPrefix), + extractUserKey: trimKnownPrefix([]byte(redisTTLPrefix)), + buildElems: c.migrateTTLIndexedCollectionElems, + }, + { + typeName: "ttl-inline-list", + prefix: []byte(store.ListMetaPrefix), + extractUserKey: extractListMetaMigrationUserKey, + buildElems: c.migrateListTTLInlineElems, + }, + c.simpleTTLInlineMigrationHandler( + "ttl-inline-hash", + []byte(store.HashMetaPrefix), + extractHashMetaMigrationUserKey, + func(raw []byte) (int64, uint64, error) { + m, err := store.UnmarshalHashMeta(raw) + return m.Len, m.ExpireAt, errors.WithStack(err) + }, + func(n int64, ttl uint64) []byte { return store.MarshalHashMeta(store.HashMeta{Len: n, ExpireAt: ttl}) }, + ), + c.simpleTTLInlineMigrationHandler( + "ttl-inline-set", + []byte(store.SetMetaPrefix), + extractSetMetaMigrationUserKey, + func(raw []byte) (int64, uint64, error) { + m, err := store.UnmarshalSetMeta(raw) + return m.Len, m.ExpireAt, errors.WithStack(err) + }, + func(n int64, ttl uint64) []byte { return store.MarshalSetMeta(store.SetMeta{Len: n, ExpireAt: ttl}) }, + ), + c.simpleTTLInlineMigrationHandler( + "ttl-inline-zset", + []byte(store.ZSetMetaPrefix), + extractZSetMetaMigrationUserKey, + func(raw []byte) (int64, uint64, error) { + m, err := store.UnmarshalZSetMeta(raw) + return m.Len, m.ExpireAt, errors.WithStack(err) + }, + func(n int64, ttl uint64) []byte { return store.MarshalZSetMeta(store.ZSetMeta{Len: n, ExpireAt: ttl}) }, + ), + { + typeName: "ttl-inline-stream", + prefix: []byte(store.StreamMetaPrefix), + extractUserKey: store.ExtractStreamUserKeyFromMeta, + buildElems: c.migrateStreamTTLInlineElems, + }, + } +} + +func trimKnownPrefix(prefix []byte) func([]byte) []byte { + return func(key []byte) []byte { + return bytes.TrimPrefix(key, prefix) + } +} + +func extractListMetaMigrationUserKey(key []byte) []byte { + return store.ExtractListUserKey(key) +} + +func extractHashMetaMigrationUserKey(key []byte) []byte { + if store.IsHashMetaDeltaKey(key) { + return nil + } + return store.ExtractHashUserKeyFromMeta(key) +} + +func extractSetMetaMigrationUserKey(key []byte) []byte { + if store.IsSetMetaDeltaKey(key) { + return nil + } + return store.ExtractSetUserKeyFromMeta(key) +} + +func extractZSetMetaMigrationUserKey(key []byte) []byte { + if store.IsZSetMetaDeltaKey(key) { + return nil + } + return store.ExtractZSetUserKeyFromMeta(key) +} + +func (c *DeltaCompactor) simpleTTLInlineMigrationHandler( + typeName string, + prefix []byte, + extractUserKey func([]byte) []byte, + unmarshal func([]byte) (int64, uint64, error), + marshal func(int64, uint64) []byte, +) ttlInlineMigrationHandler { + return ttlInlineMigrationHandler{ + typeName: typeName, + prefix: prefix, + extractUserKey: extractUserKey, + buildElems: func(ctx context.Context, pair *store.KVPair, readTS uint64) ([]*kv.Elem[kv.OP], error) { + userKey := extractUserKey(pair.Key) + if userKey == nil { + return nil, nil + } + n, ttlMs, err := unmarshal(pair.Value) + if err != nil { + return nil, err + } + if ttlMs == 0 && isLegacySimpleMeta(pair.Value) { + ttlMs, err = legacyTTLMillisAt(ctx, c.st, userKey, readTS) + if err != nil { + return nil, err + } + } + desired := marshal(n, ttlMs) + elems := putIfChanged(pair.Key, pair.Value, desired) + return appendTTLIndexSyncElem(ctx, c.st, elems, userKey, ttlMs, readTS) + }, + } +} + +func (c *DeltaCompactor) migrateTTLInlineHandler(ctx context.Context, h ttlInlineMigrationHandler, readTS uint64) error { + kvs, truncated, err := c.scanTTLInlineMigrationRaw(ctx, h, readTS) + if err != nil { + return err + } + var ( + elems []*kv.Elem[kv.OP] + batchKeys int + flushBatch = func() error { + if err := c.dispatchCompaction(ctx, readTS, elems); err != nil { + c.logger.WarnContext(ctx, "ttl inline migrator: batch dispatch failed", + "type", h.typeName, "error", err) + return err + } + elems = nil + batchKeys = 0 + return nil + } + ) + for _, pair := range kvs { + userKey := h.extractUserKey(pair.Key) + if userKey == nil || !c.coord.IsLeaderForKey(userKey) { + continue + } + built, buildErr := h.buildElems(ctx, pair, readTS) + if buildErr != nil { + c.logger.WarnContext(ctx, "ttl inline migrator: failed to build elems", + "type", h.typeName, "key", string(userKey), "error", buildErr) + continue + } + elems = append(elems, built...) + batchKeys++ + if batchKeys >= ttlInlineMigrationBatchKeyLimit { + if err := flushBatch(); err != nil { + return err + } + } + } + if err := flushBatch(); err != nil { + return err + } + c.advanceCursor(h.typeName, lastKVKey(kvs), truncated) + return nil +} + +func (c *DeltaCompactor) scanTTLInlineMigrationRaw(ctx context.Context, h ttlInlineMigrationHandler, readTS uint64) ([]*store.KVPair, bool, error) { + c.cursorMu.Lock() + start := h.prefix + if cur := c.cursors[h.typeName]; len(cur) > 0 { + start = append(bytes.Clone(cur), cursorNextByte) + } + c.cursorMu.Unlock() + + end := store.PrefixScanEnd(h.prefix) + kvs, err := c.st.ScanAt(ctx, start, end, ttlInlineMigrationTickScanLimit, readTS) + if err != nil { + return nil, false, errors.WithStack(err) + } + return kvs, len(kvs) == ttlInlineMigrationTickScanLimit, nil +} + +func (c *DeltaCompactor) migrateStringTTLInlineElems(ctx context.Context, pair *store.KVPair, readTS uint64) ([]*kv.Elem[kv.OP], error) { + userKey := bytes.TrimPrefix(pair.Key, []byte(redisStrPrefix)) + var ( + desired []byte + ttlMs uint64 + err error + ) + if isNewRedisStrFormat(pair.Value) { + _, ttl, decErr := decodeRedisStr(pair.Value) + if decErr != nil { + return nil, decErr + } + ttlMs = ttlMillis(ttl) + desired = pair.Value + } else { + ttlMs, err = legacyTTLMillisAt(ctx, c.st, userKey, readTS) + if err != nil { + return nil, err + } + desired = encodeRedisStr(pair.Value, redisTimeFromMillis(ttlMs)) + } + elems := putIfChanged(pair.Key, pair.Value, desired) + return appendTTLIndexSyncElem(ctx, c.st, elems, userKey, ttlMs, readTS) +} + +func (c *DeltaCompactor) migrateHLLTTLInlineElems(ctx context.Context, pair *store.KVPair, readTS uint64) ([]*kv.Elem[kv.OP], error) { + userKey := bytes.TrimPrefix(pair.Key, []byte(redisHLLPrefix)) + value, ttl, embedded, err := decodeRedisHLL(pair.Value) + if err != nil { + return nil, err + } + ttlMs := ttlMillis(ttl) + desired := pair.Value + if !embedded { + ttlMs, err = legacyTTLMillisAt(ctx, c.st, userKey, readTS) + if err != nil { + return nil, err + } + desired, err = encodeRedisHLL(value, redisTimeFromMillis(ttlMs)) + if err != nil { + return nil, err + } + } + elems := putIfChanged(pair.Key, pair.Value, desired) + return appendTTLIndexSyncElem(ctx, c.st, elems, userKey, ttlMs, readTS) +} + +func (c *DeltaCompactor) migrateTTLIndexedCollectionElems(ctx context.Context, pair *store.KVPair, readTS uint64) ([]*kv.Elem[kv.OP], error) { + userKey := bytes.TrimPrefix(pair.Key, []byte(redisTTLPrefix)) + ttl, err := decodeRedisTTL(pair.Value) + if err != nil { + return nil, err + } + ttlMs := redisExpireAtMillis(ttl) + if ttlMs == 0 { + return nil, nil + } + + server := &RedisServer{store: c.st, compactor: c} + typ, err := server.rawKeyTypeAt(ctx, userKey, readTS) + if err != nil { + return nil, err + } + if !ttlIndexedCollectionMigrationType(typ) { + return nil, nil + } + if redisTTLMillisExpired(ttlMs) { + elems, _, err := server.deleteLogicalKeyElems(ctx, userKey, readTS) + return elems, err + } + baseExists, err := c.collectionBaseMetaExistsAt(ctx, userKey, typ, readTS) + if err != nil || baseExists { + return nil, err + } + elems, ok, err := server.collectionMetaExpireElems(ctx, userKey, readTS, typ, ttlMs) + if err != nil || ok { + return elems, err + } + return c.legacyCollectionTTLInlineElems(ctx, userKey, typ, ttlMs, readTS) +} + +func ttlIndexedCollectionMigrationType(typ redisValueType) bool { + switch typ { + case redisTypeList, redisTypeHash, redisTypeSet, redisTypeZSet, redisTypeStream: + return true + case redisTypeNone, redisTypeString: + return false + } + return false +} + +func (c *DeltaCompactor) collectionBaseMetaExistsAt(ctx context.Context, userKey []byte, typ redisValueType, readTS uint64) (bool, error) { + var metaKey []byte + switch typ { + case redisTypeNone, redisTypeString: + return false, nil + case redisTypeList: + metaKey = store.ListMetaKey(userKey) + case redisTypeHash: + metaKey = store.HashMetaKey(userKey) + case redisTypeSet: + metaKey = store.SetMetaKey(userKey) + case redisTypeZSet: + metaKey = store.ZSetMetaKey(userKey) + case redisTypeStream: + metaKey = store.StreamMetaKey(userKey) + } + exists, err := c.st.ExistsAt(ctx, metaKey, readTS) + return exists, errors.WithStack(err) +} + +func (c *DeltaCompactor) legacyCollectionTTLInlineElems( + ctx context.Context, + userKey []byte, + typ redisValueType, + ttlMs uint64, + readTS uint64, +) ([]*kv.Elem[kv.OP], error) { + switch typ { + case redisTypeNone, redisTypeString, redisTypeList: + return nil, nil + case redisTypeStream: + return c.legacyStreamTTLInlineElems(ctx, userKey, ttlMs, readTS) + case redisTypeHash: + return c.legacyHashTTLInlineElems(ctx, userKey, ttlMs, readTS) + case redisTypeSet: + return c.legacySetTTLInlineElems(ctx, userKey, ttlMs, readTS) + case redisTypeZSet: + return c.legacyZSetTTLInlineElems(ctx, userKey, ttlMs, readTS) + } + return nil, nil +} + +func (c *DeltaCompactor) legacyHashTTLInlineElems(ctx context.Context, userKey []byte, ttlMs uint64, readTS uint64) ([]*kv.Elem[kv.OP], error) { + raw, err := c.st.GetAt(ctx, redisHashKey(userKey), readTS) + if errors.Is(err, store.ErrKeyNotFound) { + return nil, nil + } + if err != nil { + return nil, errors.WithStack(err) + } + value, err := unmarshalHashValue(raw) + if err != nil { + return nil, err + } + elems := make([]*kv.Elem[kv.OP], 0, len(value)+setWideColOverhead) + for field, val := range value { + elems = append(elems, &kv.Elem[kv.OP]{ + Op: kv.Put, + Key: store.HashFieldKey(userKey, []byte(field)), + Value: []byte(val), + }) + } + elems = append(elems, + &kv.Elem[kv.OP]{Op: kv.Del, Key: redisHashKey(userKey)}, + &kv.Elem[kv.OP]{ + Op: kv.Put, + Key: store.HashMetaKey(userKey), + Value: store.MarshalHashMeta(store.HashMeta{Len: int64(len(value)), ExpireAt: ttlMs}), + }, + ) + return elems, nil +} + +func (c *DeltaCompactor) legacySetTTLInlineElems(ctx context.Context, userKey []byte, ttlMs uint64, readTS uint64) ([]*kv.Elem[kv.OP], error) { + raw, err := c.st.GetAt(ctx, redisSetKey(userKey), readTS) + if errors.Is(err, store.ErrKeyNotFound) { + return nil, nil + } + if err != nil { + return nil, errors.WithStack(err) + } + value, err := unmarshalSetValue(raw) + if err != nil { + return nil, err + } + elems := make([]*kv.Elem[kv.OP], 0, len(value.Members)+setWideColOverhead) + for _, member := range value.Members { + elems = append(elems, &kv.Elem[kv.OP]{ + Op: kv.Put, + Key: store.SetMemberKey(userKey, []byte(member)), + Value: []byte{}, + }) + } + elems = append(elems, + &kv.Elem[kv.OP]{Op: kv.Del, Key: redisSetKey(userKey)}, + &kv.Elem[kv.OP]{ + Op: kv.Put, + Key: store.SetMetaKey(userKey), + Value: store.MarshalSetMeta(store.SetMeta{Len: int64(len(value.Members)), ExpireAt: ttlMs}), + }, + ) + return elems, nil +} + +func (c *DeltaCompactor) legacyZSetTTLInlineElems(ctx context.Context, userKey []byte, ttlMs uint64, readTS uint64) ([]*kv.Elem[kv.OP], error) { + raw, err := c.st.GetAt(ctx, redisZSetKey(userKey), readTS) + if errors.Is(err, store.ErrKeyNotFound) { + return nil, nil + } + if err != nil { + return nil, errors.WithStack(err) + } + value, err := unmarshalZSetValue(raw) + if err != nil { + return nil, err + } + elems := make([]*kv.Elem[kv.OP], 0, len(value.Entries)*zsetOpsPerEntry+setWideColOverhead) + for _, entry := range value.Entries { + elems = append(elems, + &kv.Elem[kv.OP]{ + Op: kv.Put, + Key: store.ZSetMemberKey(userKey, []byte(entry.Member)), + Value: store.MarshalZSetScore(entry.Score), + }, + &kv.Elem[kv.OP]{ + Op: kv.Put, + Key: store.ZSetScoreKey(userKey, entry.Score, []byte(entry.Member)), + Value: []byte{}, + }, + ) + } + elems = append(elems, + &kv.Elem[kv.OP]{Op: kv.Del, Key: redisZSetKey(userKey)}, + &kv.Elem[kv.OP]{ + Op: kv.Put, + Key: store.ZSetMetaKey(userKey), + Value: store.MarshalZSetMeta(store.ZSetMeta{Len: int64(len(value.Entries)), ExpireAt: ttlMs}), + }, + ) + return elems, nil +} + +func (c *DeltaCompactor) legacyStreamTTLInlineElems(ctx context.Context, userKey []byte, ttlMs uint64, readTS uint64) ([]*kv.Elem[kv.OP], error) { + _, err := c.st.GetAt(ctx, redisStreamKey(userKey), readTS) + if errors.Is(err, store.ErrKeyNotFound) { + return nil, nil + } + if err != nil { + return nil, errors.WithStack(err) + } + metaBytes, err := store.MarshalStreamMeta(store.StreamMeta{ + ExpireAt: ttlMs, + }) + if err != nil { + return nil, errors.WithStack(err) + } + return []*kv.Elem[kv.OP]{ + {Op: kv.Del, Key: redisStreamKey(userKey)}, + {Op: kv.Put, Key: store.StreamMetaKey(userKey), Value: metaBytes}, + }, nil +} + +func (c *DeltaCompactor) migrateListTTLInlineElems(ctx context.Context, pair *store.KVPair, readTS uint64) ([]*kv.Elem[kv.OP], error) { + if isListMetaMigrationDelta(pair) { + return nil, nil + } + userKey := extractListMetaMigrationUserKey(pair.Key) + if userKey == nil { + return nil, nil + } + meta, err := store.UnmarshalListMeta(pair.Value) + if err != nil { + return nil, errors.WithStack(err) + } + if meta.ExpireAt == 0 && isLegacyWideMeta(pair.Value) { + legacyTTL, err := legacyTTLMillisAt(ctx, c.st, userKey, readTS) + if err != nil { + return nil, err + } + meta.ExpireAt = legacyTTL + } + desired, err := store.MarshalListMeta(meta) + if err != nil { + return nil, errors.WithStack(err) + } + elems := putIfChanged(pair.Key, pair.Value, desired) + return appendTTLIndexSyncElem(ctx, c.st, elems, userKey, meta.ExpireAt, readTS) +} + +func isListMetaMigrationDelta(pair *store.KVPair) bool { + if pair == nil || !store.IsListMetaDeltaKey(pair.Key) { + return false + } + return len(pair.Value) != redisWideMetaLegacySizeBytes && len(pair.Value) != redisWideMetaInlineSizeBytes +} + +func (c *DeltaCompactor) migrateStreamTTLInlineElems(ctx context.Context, pair *store.KVPair, readTS uint64) ([]*kv.Elem[kv.OP], error) { + userKey := store.ExtractStreamUserKeyFromMeta(pair.Key) + if userKey == nil { + return nil, nil + } + meta, err := store.UnmarshalStreamMeta(pair.Value) + if err != nil { + return nil, errors.WithStack(err) + } + if meta.ExpireAt == 0 && isLegacyWideMeta(pair.Value) { + legacyTTL, err := legacyTTLMillisAt(ctx, c.st, userKey, readTS) + if err != nil { + return nil, err + } + meta.ExpireAt = legacyTTL + } + desired, err := store.MarshalStreamMeta(meta) + if err != nil { + return nil, errors.WithStack(err) + } + elems := putIfChanged(pair.Key, pair.Value, desired) + return appendTTLIndexSyncElem(ctx, c.st, elems, userKey, meta.ExpireAt, readTS) +} + +func isLegacySimpleMeta(raw []byte) bool { + return len(raw) == redisSimpleMetaLegacySizeBytes +} + +func isLegacyWideMeta(raw []byte) bool { + return len(raw) == redisWideMetaLegacySizeBytes +} + +func legacyTTLMillisAt(ctx context.Context, st store.MVCCStore, userKey []byte, readTS uint64) (uint64, error) { + raw, err := st.GetAt(ctx, redisTTLKey(userKey), readTS) + if err != nil { + if errors.Is(err, store.ErrKeyNotFound) { + return 0, nil + } + return 0, errors.WithStack(err) + } + ttl, err := decodeRedisTTL(raw) + if err != nil { + return 0, err + } + return redisExpireAtMillis(ttl), nil +} + +func legacyTTLMillisForMigrationAt(ctx context.Context, st store.MVCCStore, userKey []byte, readTS uint64) (uint64, bool, error) { + ttlMs, err := legacyTTLMillisAt(ctx, st, userKey, readTS) + if err != nil { + return 0, false, err + } + return ttlMs, redisTTLMillisExpired(ttlMs), nil +} + +func legacyExpiredCollectionCleanupElems(userKey []byte, legacyKey []byte) []*kv.Elem[kv.OP] { + return []*kv.Elem[kv.OP]{ + {Op: kv.Del, Key: legacyKey}, + {Op: kv.Del, Key: redisTTLKey(userKey)}, + } +} + +func appendTTLIndexSyncElem( + ctx context.Context, + st store.MVCCStore, + elems []*kv.Elem[kv.OP], + userKey []byte, + ttlMs uint64, + readTS uint64, +) ([]*kv.Elem[kv.OP], error) { + elem, err := ttlIndexSyncElem(ctx, st, userKey, ttlMs, readTS) + if err != nil || elem == nil { + return elems, err + } + return append(elems, elem), nil +} + +func ttlIndexSyncElem(ctx context.Context, st store.MVCCStore, userKey []byte, ttlMs uint64, readTS uint64) (*kv.Elem[kv.OP], error) { + raw, err := st.GetAt(ctx, redisTTLKey(userKey), readTS) + if ttlMs == 0 { + if errors.Is(err, store.ErrKeyNotFound) { + return nil, nil + } + if err != nil { + return nil, errors.WithStack(err) + } + return &kv.Elem[kv.OP]{Op: kv.Del, Key: redisTTLKey(userKey)}, nil + } + if err == nil { + current, decErr := decodeRedisTTL(raw) + if decErr != nil { + return nil, decErr + } + if redisExpireAtMillis(current) == ttlMs { + return nil, nil + } + } else if !errors.Is(err, store.ErrKeyNotFound) { + return nil, errors.WithStack(err) + } + ttl := redisTimeFromMillis(ttlMs) + return &kv.Elem[kv.OP]{Op: kv.Put, Key: redisTTLKey(userKey), Value: encodeRedisTTL(*ttl)}, nil +} + +func putIfChanged(key, current, desired []byte) []*kv.Elem[kv.OP] { + if bytes.Equal(current, desired) { + return nil + } + return []*kv.Elem[kv.OP]{{Op: kv.Put, Key: bytes.Clone(key), Value: bytes.Clone(desired)}} +} + +func ttlMillis(ttl *time.Time) uint64 { + if ttl == nil { + return 0 + } + return redisExpireAtMillis(*ttl) +} + +func lastKVKey(kvs []*store.KVPair) []byte { + if len(kvs) == 0 { + return nil + } + return kvs[len(kvs)-1].Key +} diff --git a/adapter/redis_txn.go b/adapter/redis_txn.go index 4abaf21d5..ce6687551 100644 --- a/adapter/redis_txn.go +++ b/adapter/redis_txn.go @@ -122,6 +122,10 @@ type txnContext struct { logicalDeletes map[string][]byte hashDeletes map[string][]byte setDeletes map[string][]byte + hashCreates map[string]struct{} + // collectionExpireTypes remembers non-string keys whose TTL changed in this + // EXEC so commit can update the inline metadata anchor with the scan index. + collectionExpireTypes map[string]redisValueType // streamDeletions tracks user keys whose stream wide-column layout must // be tombstoned on commit: the !stream|meta| record plus every // !stream|entry| row. stageKeyDeletion seeds this (MULTI/EXEC @@ -142,12 +146,13 @@ type listTxnState struct { } type hashTxnState struct { - fields map[string][]byte - origFields map[string][]byte - dirtyFields map[string]struct{} - legacy bool - deleted bool - dirty bool + fields map[string][]byte + origFields map[string][]byte + dirtyFields map[string]struct{} + legacy bool + legacyExpireAt uint64 + deleted bool + dirty bool } type zsetTxnState struct { @@ -419,6 +424,7 @@ func reviveDeletedHashState(st *hashTxnState) *hashTxnState { st.origFields = map[string][]byte{} st.dirtyFields = map[string]struct{}{} st.legacy = false + st.legacyExpireAt = 0 st.deleted = false st.dirty = true } @@ -462,11 +468,19 @@ func (t *txnContext) loadExistingHashState(key []byte, keyString string) (*hashT if err != nil { return nil, errors.WithStack(err) } + var legacyExpireAt uint64 + if legacy { + legacyExpireAt, err = legacyTTLMillisAt(ctx, t.server.store, key, t.startTS) + if err != nil { + return nil, err + } + } st := &hashTxnState{ - fields: fields, - origFields: origFields, - dirtyFields: map[string]struct{}{}, - legacy: legacy, + fields: fields, + origFields: origFields, + dirtyFields: map[string]struct{}{}, + legacy: legacy, + legacyExpireAt: legacyExpireAt, } t.hashStates[keyString] = st return st, nil @@ -771,6 +785,7 @@ func (t *txnContext) stageStringReplacement(key, value []byte, ttl *time.Time) { ttl: cloneTimePtr(ttl), } delete(t.deletedKeys, k) + delete(t.collectionExpireTypes, k) } func (t *txnContext) updateStringReplacementTTL(key []byte, ttl *time.Time) bool { @@ -934,6 +949,7 @@ func (t *txnContext) applyHSet(cmd redcon.Command) (redisResult, error) { if typ != redisTypeNone && typ != redisTypeHash { return redisResult{typ: resultError, err: wrongTypeError()}, nil } + t.markHashCreateIfMissing(cmd.Args[1], typ) t.trackMissingKeyCreatorFenceReads(cmd.Args[1], typ) fields := hashCommandFields(cmd.Args[2:]) st, err := t.loadHashStateForFields(cmd.Args[1], fields) @@ -1179,6 +1195,10 @@ func (t *txnContext) applyPositiveExpire(key []byte, ttl int64, unit time.Durati expireAt := time.Now().Add(time.Duration(ttl) * unit) state.value = &expireAt state.dirty = true + if isNonStringCollectionType(typ) { + t.trackCollectionExpireType(key, typ) + return redisResult{typ: resultInt, integer: 1}, nil + } if typ == redisTypeString { if t.updateStringReplacementTTL(key, &expireAt) { return redisResult{typ: resultInt, integer: 1}, nil @@ -1190,12 +1210,33 @@ func (t *txnContext) applyPositiveExpire(key []byte, ttl int64, unit time.Durati if plain { return t.markStringDirty(key) } - // HLL is reported as redisTypeString but stores its payload under - // !redis|hll|; keep TTL in the legacy scan index via buildTTLElems. + if err := t.markHLLDirty(key, state.value); err != nil { + return redisResult{}, err + } } return redisResult{typ: resultInt, integer: 1}, nil } +func (t *txnContext) trackCollectionExpireType(key []byte, typ redisValueType) { + if t.collectionExpireTypes == nil { + t.collectionExpireTypes = map[string]redisValueType{} + } + t.collectionExpireTypes[string(key)] = typ +} + +func (t *txnContext) markHashCreateIfMissing(key []byte, typ redisValueType) { + if typ == redisTypeNone { + t.markHashCreate(key) + } +} + +func (t *txnContext) markHashCreate(key []byte) { + if t.hashCreates == nil { + t.hashCreates = map[string]struct{}{} + } + t.hashCreates[string(key)] = struct{}{} +} + // markStringDirty loads the string value into the working set so that // buildKeyElems will re-encode it with the updated embedded TTL. func (t *txnContext) markStringDirty(key []byte) (redisResult, error) { @@ -1207,6 +1248,24 @@ func (t *txnContext) markStringDirty(key []byte) (redisResult, error) { return redisResult{typ: resultInt, integer: 1}, nil } +func (t *txnContext) markHLLDirty(key []byte, ttl *time.Time) error { + hllValue, err := t.load(redisHLLKey(key)) + if err != nil { + return err + } + value, _, _, err := decodeRedisHLL(hllValue.raw) + if err != nil { + return err + } + encoded, err := encodeRedisHLL(value, ttl) + if err != nil { + return err + } + hllValue.raw = encoded + hllValue.dirty = true + return nil +} + func (t *txnContext) stageKeyDeletion(key []byte) (redisResult, error) { k := string(key) t.markLogicalDeletion(key, k) @@ -1235,6 +1294,7 @@ func (t *txnContext) markLogicalDeletion(key []byte, k string) { } t.hashDeletes[k] = bytes.Clone(key) t.setDeletes[k] = bytes.Clone(key) + delete(t.hashCreates, k) t.trackWideCollectionFenceReads(key) if st, ok := t.hashStates[k]; ok { st.deleted = true @@ -1405,21 +1465,7 @@ type preparedTxnDispatch struct { // a prepared value with empty `elems` and a no-op cancel — callers can // check len(prepared.elems)==0 and skip the dispatch. func (t *txnContext) prepareDispatch() (preparedTxnDispatch, error) { - // Derive a single redisDispatchTimeout-bounded context covering both - // the commitTS allocation, the delta-key stream-deletion scans, and - // the final Dispatch. The parent is the txnContext's own ctx (the - // caller's dispatchCtx), not the server-lifetime handlerContext, so - // an outer cancellation (client disconnect, retryRedisWrite timeout) - // interrupts the prepare+dispatch promptly instead of waiting the - // full redisDispatchTimeout. Symmetric with the reuseCtx threading in - // runTransactionWithDedup. The nil-guard falls back to handlerContext - // for callers that construct a txnContext without setting ctx (test - // fixtures). - parentCtx := t.ctx - if parentCtx == nil { - parentCtx = t.server.handlerContext() - } - ctx, cancel := context.WithTimeout(parentCtx, redisDispatchTimeout) + ctx, cancel := t.dispatchContext() replacementElems, err := t.buildReplacementElems(ctx) if err != nil { @@ -1464,9 +1510,14 @@ func (t *txnContext) prepareDispatch() (preparedTxnDispatch, error) { return preparedTxnDispatch{cancel: func() {}}, err } hashElems := t.buildHashElems(commitTS) - // TTL elements: string keys have TTL embedded in value (buildKeyElems handles that), - // non-string keys get a !redis|ttl| element written in the same transaction. - ttlElems := t.buildTTLElems() + collectionTTLElems, skipTTLIndex, err := t.buildCollectionTTLElems(ctx) + if err != nil { + cancel() + return preparedTxnDispatch{cancel: func() {}}, err + } + // TTL elements: string/HLL keys have TTL embedded in value, and collection + // keys also keep !redis|ttl| as the secondary scan index. + ttlElems := t.buildTTLElems(skipTTLIndex) // Continue using the same redisDispatchTimeout-bounded context for // the stream-deletion scans (paginated ScanAt/ExistsAt over @@ -1480,6 +1531,7 @@ func (t *txnContext) prepareDispatch() (preparedTxnDispatch, error) { elems = append(elems, listElems...) elems = append(elems, zsetElems...) elems = append(elems, hashElems...) + elems = append(elems, collectionTTLElems...) elems = append(elems, ttlElems...) elems = append(elems, streamElems...) @@ -1496,6 +1548,19 @@ func (t *txnContext) prepareDispatch() (preparedTxnDispatch, error) { }, nil } +func (t *txnContext) dispatchContext() (context.Context, context.CancelFunc) { + // Derive a single redisDispatchTimeout-bounded context covering both + // the commitTS allocation, the delta-key stream-deletion scans, and + // the final Dispatch. The parent is the txnContext's own ctx (the + // caller's dispatchCtx), not the server-lifetime handlerContext, so + // an outer cancellation interrupts the prepare+dispatch promptly. + parentCtx := t.ctx + if parentCtx == nil { + parentCtx = t.server.handlerContext() + } + return context.WithTimeout(parentCtx, redisDispatchTimeout) +} + func (t *txnContext) commit() error { prepared, err := t.prepareDispatch() if err != nil { @@ -1677,6 +1742,10 @@ func (t *txnContext) skipWorkingKeyForReplacement(storageKey []byte) bool { userKey := storageKey[len(redisStrPrefix):] return t.hasReplacement(userKey) } + if bytes.HasPrefix(storageKey, []byte(redisHLLPrefix)) { + userKey := storageKey[len(redisHLLPrefix):] + return t.hasReplacement(userKey) + } return t.hasReplacement(storageKey) } @@ -1690,6 +1759,10 @@ func (t *txnContext) appendWorkingKeyElems(elems []*kv.Elem[kv.OP], storageKey [ userKey := storageKey[len(redisStrPrefix):] elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: redisTTLKey(userKey)}) } + if bytes.HasPrefix(storageKey, []byte(redisHLLPrefix)) { + userKey := storageKey[len(redisHLLPrefix):] + elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: redisTTLKey(userKey)}) + } return elems } value := tv.raw @@ -1755,6 +1828,9 @@ func (t *txnContext) appendListStateElems(elems []*kv.Elem[kv.OP], userKey []byt elems = appendListDeletionElems(elems, userKey, st) } elems = appendListAppendElems(elems, userKey, st) + if inline, ok := t.listInlineTTLCreateElems(string(userKey), userKey, st); ok { + return append(elems, inline...), false + } return appendListDeltaElem(elems, userKey, st, commitTS, seqInTxn), true } @@ -1782,6 +1858,26 @@ func appendListAppendElems(elems []*kv.Elem[kv.OP], userKey []byte, st *listTxnS return elems } +func (t *txnContext) listInlineTTLCreateElems(key string, userKey []byte, st *listTxnState) ([]*kv.Elem[kv.OP], bool) { + ttlMs, ok := t.collectionTTLMillis(key) + if !ok || st.metaExists || (len(st.existingDeltas) != 0 && !st.purge) { + return nil, false + } + meta := store.ListMeta{Head: st.meta.Head, Len: st.meta.Len + int64(len(st.appends)), ExpireAt: ttlMs} + metaBytes, err := store.MarshalListMeta(meta) + if err != nil { + return nil, false + } + return []*kv.Elem[kv.OP]{ + redisTxnWideListFenceElem(userKey), + { + Op: kv.Put, + Key: store.ListMetaKey(userKey), + Value: metaBytes, + }, + }, true +} + func appendListDeltaElem(elems []*kv.Elem[kv.OP], userKey []byte, st *listTxnState, commitTS uint64, seqInTxn uint32) []*kv.Elem[kv.OP] { deltaVal := store.MarshalListMetaDelta(store.ListMetaDelta{HeadDelta: 0, LenDelta: int64(len(st.appends))}) return append(elems, @@ -1812,6 +1908,11 @@ func (t *txnContext) buildZSetElems(commitTS uint64) ([]*kv.Elem[kv.OP], error) continue } key := []byte(k) + inline, ok := t.zsetInlineTTLCreateElems(k, key, st) + if ok { + elems = append(elems, inline...) + continue + } if st.isWide { wideElems, lenDelta := buildZSetWideElems(key, st) elems = append(elems, wideElems...) @@ -1843,6 +1944,37 @@ func (t *txnContext) buildZSetElems(commitTS uint64) ([]*kv.Elem[kv.OP], error) return elems, nil } +func (t *txnContext) zsetInlineTTLCreateElems(key string, userKey []byte, st *zsetTxnState) ([]*kv.Elem[kv.OP], bool) { + ttlMs, ok := t.collectionTTLMillis(key) + if !ok || st.exists || st.isWide || len(st.origMembers) != 0 || len(st.members) == 0 { + return nil, false + } + elems := make([]*kv.Elem[kv.OP], 0, len(st.members)*zsetOpsPerEntry+setWideColOverhead) + for _, entry := range zsetMapToEntries(st.members) { + elems = append(elems, + &kv.Elem[kv.OP]{ + Op: kv.Put, + Key: store.ZSetMemberKey(userKey, []byte(entry.Member)), + Value: store.MarshalZSetScore(entry.Score), + }, + &kv.Elem[kv.OP]{ + Op: kv.Put, + Key: store.ZSetScoreKey(userKey, entry.Score, []byte(entry.Member)), + Value: []byte{}, + }, + ) + } + elems = append(elems, + &kv.Elem[kv.OP]{ + Op: kv.Put, + Key: store.ZSetMetaKey(userKey), + Value: store.MarshalZSetMeta(store.ZSetMeta{Len: int64(len(st.members)), ExpireAt: ttlMs}), + }, + redisTxnWideZSetFenceElem(userKey), + ) + return elems, true +} + // buildZSetWideElems computes the minimal set of ops to transition from st.origMembers to // st.members in wide-column format. Returns the ops and the net length delta. func buildZSetWideElems(key []byte, st *zsetTxnState) ([]*kv.Elem[kv.OP], int64) { @@ -1906,7 +2038,10 @@ func (t *txnContext) appendHashStateElems(elems []*kv.Elem[kv.OP], key []byte, s return elems, false } if st.legacy { - return append(elems, buildHashLegacyRewriteElems(key, st.fields)...), false + return append(elems, buildHashLegacyRewriteElems(key, st.fields, t.hashLegacyRewriteExpireAt(string(key), st))...), false + } + if inline, ok := t.hashInlineTTLCreateElems(string(key), key, st); ok { + return append(elems, inline...), false } next, newFields := appendHashChangedFieldElems(elems, key, st) if newFields == 0 { @@ -1946,9 +2081,39 @@ func appendHashDeltaElem(elems []*kv.Elem[kv.OP], key []byte, newFields int64, c const hashLegacyRewriteOverhead = 2 -func buildHashLegacyRewriteElems(key []byte, fields map[string][]byte) []*kv.Elem[kv.OP] { +func (t *txnContext) hashLegacyRewriteExpireAt(key string, st *hashTxnState) uint64 { + if ttlMs, ok := t.collectionTTLMillis(key); ok { + return ttlMs + } + ttlState := t.ttlStates[key] + if ttlState != nil && ttlState.dirty && ttlState.value == nil { + return 0 + } + return st.legacyExpireAt +} + +func (t *txnContext) hashInlineTTLCreateElems(key string, userKey []byte, st *hashTxnState) ([]*kv.Elem[kv.OP], bool) { + ttlMs, ok := t.collectionTTLMillis(key) + if !ok || !t.isHashCreate(key) || len(st.fields) == 0 { + return nil, false + } + return buildHashFullRewriteElems(userKey, st.fields, ttlMs), true +} + +func (t *txnContext) isHashCreate(key string) bool { + _, ok := t.hashCreates[key] + return ok +} + +func buildHashLegacyRewriteElems(key []byte, fields map[string][]byte, expireAt uint64) []*kv.Elem[kv.OP] { + elems := buildHashFullRewriteElems(key, fields, expireAt) + elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: redisHashKey(key)}) + return elems +} + +func buildHashFullRewriteElems(key []byte, fields map[string][]byte, expireAt uint64) []*kv.Elem[kv.OP] { fieldNames := sortedHashFieldNames(fields) - elems := make([]*kv.Elem[kv.OP], 0, len(fieldNames)+hashLegacyRewriteOverhead+1) + elems := make([]*kv.Elem[kv.OP], 0, len(fieldNames)+hashLegacyRewriteOverhead) for _, field := range fieldNames { elems = append(elems, &kv.Elem[kv.OP]{ Op: kv.Put, @@ -1957,11 +2122,10 @@ func buildHashLegacyRewriteElems(key []byte, fields map[string][]byte) []*kv.Ele }) } elems = append(elems, - &kv.Elem[kv.OP]{Op: kv.Del, Key: redisHashKey(key)}, &kv.Elem[kv.OP]{ Op: kv.Put, Key: store.HashMetaKey(key), - Value: store.MarshalHashMeta(store.HashMeta{Len: int64(len(fieldNames))}), + Value: store.MarshalHashMeta(store.HashMeta{Len: int64(len(fieldNames)), ExpireAt: expireAt}), }, redisTxnWideHashFenceElem(key), ) @@ -2021,12 +2185,15 @@ func (t *txnContext) buildStreamDeletionElems(ctx context.Context) ([]*kv.Elem[k // buildTTLElems returns !redis|ttl| Raft elements for non-string keys with dirty TTL state. // String keys have TTL embedded in the value; they are handled by buildKeyElems. -func (t *txnContext) buildTTLElems() []*kv.Elem[kv.OP] { +func (t *txnContext) buildTTLElems(skip map[string]struct{}) []*kv.Elem[kv.OP] { var elems []*kv.Elem[kv.OP] for k, st := range t.ttlStates { if !st.dirty { continue } + if _, blocked := skip[k]; blocked { + continue + } if _, ok := t.replacers[k]; ok { // A staged string replacement owns both the inline TTL and the // scan-index TTL. applyPositiveExpire updates the replacement TTL @@ -2046,6 +2213,148 @@ func (t *txnContext) buildTTLElems() []*kv.Elem[kv.OP] { return elems } +func (t *txnContext) collectionTTLMillis(key string) (uint64, bool) { + st := t.ttlStates[key] + if st == nil || !st.dirty || st.value == nil { + return 0, false + } + return redisExpireAtMillis(*st.value), true +} + +func (t *txnContext) buildCollectionTTLElems(ctx context.Context) ([]*kv.Elem[kv.OP], map[string]struct{}, error) { + if len(t.collectionExpireTypes) == 0 { + return nil, nil, nil + } + keys := make([]string, 0, len(t.collectionExpireTypes)) + for key := range t.collectionExpireTypes { + keys = append(keys, key) + } + sort.Strings(keys) + + var elems []*kv.Elem[kv.OP] + var skipTTLIndex map[string]struct{} + for _, key := range keys { + built, skipIndex, err := t.collectionTTLElemsForKey(ctx, key) + if err != nil { + return nil, nil, err + } + if !skipIndex { + elems = append(elems, built...) + continue + } + if skipTTLIndex == nil { + skipTTLIndex = map[string]struct{}{} + } + skipTTLIndex[key] = struct{}{} + } + return elems, skipTTLIndex, nil +} + +func (t *txnContext) collectionTTLElemsForKey(ctx context.Context, key string) ([]*kv.Elem[kv.OP], bool, error) { + st := t.ttlStates[key] + if st == nil || !st.dirty || st.value == nil { + return nil, false, nil + } + typ := t.collectionExpireTypes[key] + if t.collectionTTLRebuildWouldUseStaleBase(key, typ) { + return nil, false, nil + } + built, ok, err := t.server.collectionExpireElems( + ctx, + []byte(key), + t.startTS, + typ, + redisExpireAtMillis(*st.value), + ) + if err != nil || ok { + return built, false, err + } + if t.hasStagedInlineTTLCollectionCreate(key, typ) { + return nil, false, nil + } + return nil, true, nil +} + +func (t *txnContext) hasStagedInlineTTLCollectionCreate(key string, typ redisValueType) bool { + switch typ { + case redisTypeList: + return t.hasStagedInlineTTLListCreate(key) + case redisTypeHash: + return t.hasStagedInlineTTLHashCreate(key) + case redisTypeZSet: + return t.hasStagedInlineTTLZSetCreate(key) + case redisTypeNone, redisTypeString, redisTypeSet, redisTypeStream: + return false + } + return false +} + +func (t *txnContext) hasStagedInlineTTLListCreate(key string) bool { + st, ok := t.listStates[key] + return ok && !st.deleted && len(st.appends) > 0 && !st.metaExists && (len(st.existingDeltas) == 0 || st.purge) +} + +func (t *txnContext) hasStagedInlineTTLHashCreate(key string) bool { + st, ok := t.hashStates[key] + return ok && st.dirty && !st.deleted && t.isHashCreate(key) && len(st.fields) > 0 +} + +func (t *txnContext) hasStagedInlineTTLZSetCreate(key string) bool { + st, ok := t.zsetStates[key] + return ok && st.dirty && !st.exists && !st.isWide && len(st.origMembers) == 0 && len(st.members) > 0 +} + +func (t *txnContext) collectionTTLRebuildWouldUseStaleBase(key string, typ redisValueType) bool { + if !t.hasStagedLogicalDelete(key) { + return false + } + switch typ { + case redisTypeList: + return t.listTTLRebuildWouldUseStaleBase(key) + case redisTypeHash: + return t.hashTTLRebuildWouldUseStaleBase(key) + case redisTypeZSet: + return t.zsetTTLRebuildWouldUseStaleBase(key) + case redisTypeSet, redisTypeStream, redisTypeNone, redisTypeString: + return false + } + return false +} + +func (t *txnContext) hasStagedLogicalDelete(key string) bool { + if _, ok := t.deletedKeys[key]; ok { + return true + } + if _, ok := t.logicalDeletes[key]; ok { + return true + } + if _, ok := t.hashDeletes[key]; ok { + return true + } + if _, ok := t.setDeletes[key]; ok { + return true + } + if _, ok := t.streamDeletions[key]; ok { + return true + } + return false +} + +func (t *txnContext) listTTLRebuildWouldUseStaleBase(key string) bool { + st, ok := t.listStates[key] + return ok && !st.deleted && len(st.appends) > 0 +} + +func (t *txnContext) hashTTLRebuildWouldUseStaleBase(key string) bool { + st, ok := t.hashStates[key] + return ok && st.dirty && !st.deleted && len(st.fields) > 0 +} + +func (t *txnContext) zsetTTLRebuildWouldUseStaleBase(key string) bool { + st, ok := t.zsetStates[key] + return ok && st.dirty && len(st.members) > 0 +} + func (r *RedisServer) runTransaction(queue []redcon.Command) ([]redisResult, error) { if transactionHasHeavyCommand(queue) { var results []redisResult @@ -2075,21 +2384,23 @@ func (r *RedisServer) runTransactionDirect(queue []redcon.Command) ([]redisResul defer readPin.Release() txn := &txnContext{ - server: r, - ctx: dispatchCtx, - working: map[string]*txnValue{}, - replacers: map[string]*stringReplacement{}, - listStates: map[string]*listTxnState{}, - hashStates: map[string]*hashTxnState{}, - zsetStates: map[string]*zsetTxnState{}, - ttlStates: map[string]*ttlTxnState{}, - readKeys: map[string][]byte{}, - deletedKeys: map[string]struct{}{}, - logicalDeletes: map[string][]byte{}, - hashDeletes: map[string][]byte{}, - setDeletes: map[string][]byte{}, - streamDeletions: map[string][]byte{}, - startTS: startTS, + server: r, + ctx: dispatchCtx, + working: map[string]*txnValue{}, + replacers: map[string]*stringReplacement{}, + listStates: map[string]*listTxnState{}, + hashStates: map[string]*hashTxnState{}, + zsetStates: map[string]*zsetTxnState{}, + ttlStates: map[string]*ttlTxnState{}, + readKeys: map[string][]byte{}, + deletedKeys: map[string]struct{}{}, + logicalDeletes: map[string][]byte{}, + hashDeletes: map[string][]byte{}, + setDeletes: map[string][]byte{}, + hashCreates: map[string]struct{}{}, + collectionExpireTypes: map[string]redisValueType{}, + streamDeletions: map[string][]byte{}, + startTS: startTS, } nextResults := make([]redisResult, 0, len(queue)) @@ -2282,21 +2593,23 @@ func (r *RedisServer) firstExecAttempt(dispatchCtx context.Context, queue []redc defer readPin.Release() txn := &txnContext{ - server: r, - ctx: dispatchCtx, - working: map[string]*txnValue{}, - replacers: map[string]*stringReplacement{}, - listStates: map[string]*listTxnState{}, - hashStates: map[string]*hashTxnState{}, - zsetStates: map[string]*zsetTxnState{}, - ttlStates: map[string]*ttlTxnState{}, - readKeys: map[string][]byte{}, - deletedKeys: map[string]struct{}{}, - logicalDeletes: map[string][]byte{}, - hashDeletes: map[string][]byte{}, - setDeletes: map[string][]byte{}, - streamDeletions: map[string][]byte{}, - startTS: startTS, + server: r, + ctx: dispatchCtx, + working: map[string]*txnValue{}, + replacers: map[string]*stringReplacement{}, + listStates: map[string]*listTxnState{}, + hashStates: map[string]*hashTxnState{}, + zsetStates: map[string]*zsetTxnState{}, + ttlStates: map[string]*ttlTxnState{}, + readKeys: map[string][]byte{}, + deletedKeys: map[string]struct{}{}, + logicalDeletes: map[string][]byte{}, + hashDeletes: map[string][]byte{}, + setDeletes: map[string][]byte{}, + hashCreates: map[string]struct{}{}, + collectionExpireTypes: map[string]redisValueType{}, + streamDeletions: map[string][]byte{}, + startTS: startTS, } nextResults := make([]redisResult, 0, len(queue)) diff --git a/adapter/redis_txn_test.go b/adapter/redis_txn_test.go index ed96fcc04..a44bad0b0 100644 --- a/adapter/redis_txn_test.go +++ b/adapter/redis_txn_test.go @@ -36,6 +36,7 @@ func newRedisTxnTestContext(server *RedisServer) *txnContext { logicalDeletes: map[string][]byte{}, hashDeletes: map[string][]byte{}, setDeletes: map[string][]byte{}, + hashCreates: map[string]struct{}{}, streamDeletions: map[string][]byte{}, startTS: redisTxnTestStartTS, } @@ -50,6 +51,15 @@ func elemKeysContain(elems []*kv.Elem[kv.OP], want []byte) bool { return false } +func requireNoPutElemByKey(t *testing.T, elems []*kv.Elem[kv.OP], want []byte) { + t.Helper() + for _, elem := range elems { + if elem != nil && elem.Op == kv.Put && string(elem.Key) == string(want) { + t.Fatalf("unexpected put elem key %q", string(want)) + } + } +} + func requireElemByKey(t *testing.T, elems []*kv.Elem[kv.OP], want []byte) *kv.Elem[kv.OP] { t.Helper() var found *kv.Elem[kv.OP] @@ -879,6 +889,172 @@ func TestRedisTxnSetClearsOldTTLBeforeExpireNX(t *testing.T) { requireTTLNear(t, requireElemByKey(t, elems, redisTTLKey(key)).Value, wantExpire) } +func TestRedisTxnSetAfterCollectionExpireSkipsInlineTTLRebuild(t *testing.T) { + t.Parallel() + + ctx := context.Background() + server, st := newRedisStorageMigrationTestServer(t) + key := []byte("set-after-collection-expire") + require.NoError(t, st.PutAt(ctx, store.HashMetaKey(key), store.MarshalHashMeta(store.HashMeta{Len: 1}), redisTxnTestStartTS, 0)) + require.NoError(t, st.PutAt(ctx, store.HashFieldKey(key, []byte("old")), []byte("v"), redisTxnTestStartTS, 0)) + + txn := newRedisTxnTestContext(server) + expireRes, err := txn.applyExpire(redcon.Command{Args: [][]byte{[]byte(cmdExpire), key, []byte("20")}}, time.Second) + require.NoError(t, err) + require.Equal(t, int64(1), expireRes.integer) + require.Contains(t, txn.collectionExpireTypes, string(key)) + + setRes, err := txn.applySet(redcon.Command{Args: [][]byte{[]byte(cmdSet), key, []byte("replacement")}}) + require.NoError(t, err) + require.Equal(t, "OK", setRes.str) + require.NotContains(t, txn.collectionExpireTypes, string(key)) + + replacementElems, err := txn.buildReplacementElems(ctx) + require.NoError(t, err) + strElem := requireElemByKey(t, replacementElems, redisStrKey(key)) + value, _, err := decodeRedisStr(strElem.Value) + require.NoError(t, err) + require.Equal(t, []byte("replacement"), value) + + collectionTTLElems, skipTTLIndex, err := txn.buildCollectionTTLElems(ctx) + require.NoError(t, err) + require.Empty(t, collectionTTLElems) + require.Empty(t, skipTTLIndex) + requireNoPutElemByKey(t, append(replacementElems, collectionTTLElems...), store.HashMetaKey(key)) +} + +func TestRedisTxnCollectionCreateExpireKeepsTTLIndex(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cases := []struct { + name string + create func(*testing.T, *txnContext, []byte) error + }{ + { + name: "list", + create: func(t *testing.T, txn *txnContext, key []byte) error { + t.Helper() + res, err := txn.applyRPush(redcon.Command{Args: [][]byte{[]byte(cmdRPush), key, []byte("v")}}) + require.NoError(t, err) + require.Equal(t, int64(1), res.integer) + return nil + }, + }, + { + name: "hash", + create: func(t *testing.T, txn *txnContext, key []byte) error { + t.Helper() + res, err := txn.applyHSet(redcon.Command{Args: [][]byte{[]byte(cmdHSet), key, []byte("f"), []byte("v")}}) + require.NoError(t, err) + require.Equal(t, int64(1), res.integer) + return nil + }, + }, + { + name: "zset", + create: func(t *testing.T, txn *txnContext, key []byte) error { + t.Helper() + res, err := txn.applyZIncrBy(redcon.Command{Args: [][]byte{[]byte(cmdZIncrBy), key, []byte("1"), []byte("m")}}) + require.NoError(t, err) + require.Equal(t, resultBulk, res.typ) + require.Equal(t, []byte("1"), res.bulk) + return nil + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + server, _ := newRedisStorageMigrationTestServer(t) + txn := newRedisTxnTestContext(server) + key := []byte("txn:create-expire-ttl-index:" + tc.name) + + require.NoError(t, tc.create(t, txn, key)) + expireRes, err := txn.applyExpire(redcon.Command{Args: [][]byte{[]byte(cmdPExpire), key, []byte("50000")}}, time.Millisecond) + require.NoError(t, err) + require.Equal(t, int64(1), expireRes.integer) + + collectionTTLElems, skipTTLIndex, err := txn.buildCollectionTTLElems(ctx) + require.NoError(t, err) + require.Empty(t, collectionTTLElems) + require.Empty(t, skipTTLIndex) + requireElemByKey(t, txn.buildTTLElems(skipTTLIndex), redisTTLKey(key)) + }) + } +} + +func TestRedisTxnCollectionExpireDeltaOnlyTruncatedReturnsError(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cases := []struct { + name string + deltaKey func([]byte, uint64) []byte + deltaValue []byte + }{ + { + name: "hash", + deltaKey: func(key []byte, ts uint64) []byte { return store.HashMetaDeltaKey(key, ts, 0) }, + deltaValue: store.MarshalHashMetaDelta(store.HashMetaDelta{LenDelta: 1}), + }, + { + name: "set", + deltaKey: func(key []byte, ts uint64) []byte { return store.SetMetaDeltaKey(key, ts, 0) }, + deltaValue: store.MarshalSetMetaDelta(store.SetMetaDelta{LenDelta: 1}), + }, + { + name: "zset", + deltaKey: func(key []byte, ts uint64) []byte { return store.ZSetMetaDeltaKey(key, ts, 0) }, + deltaValue: store.MarshalZSetMetaDelta(store.ZSetMetaDelta{LenDelta: 1}), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + server, st := newRedisStorageMigrationTestServer(t) + key := []byte("txn:ttl:delta-only-truncated:" + tc.name) + for ts := uint64(1); ts <= store.MaxDeltaScanLimit+1; ts++ { + require.NoError(t, st.PutAt(ctx, tc.deltaKey(key, ts), tc.deltaValue, ts, 0)) + } + + txn := newRedisTxnTestContext(server) + txn.startTS = store.MaxDeltaScanLimit + 2 + expireRes, err := txn.applyExpire(redcon.Command{Args: [][]byte{[]byte(cmdPExpire), key, []byte("50000")}}, time.Millisecond) + require.NoError(t, err) + require.Equal(t, int64(1), expireRes.integer) + + elems, skipTTLIndex, err := txn.buildCollectionTTLElems(ctx) + require.ErrorIs(t, err, ErrDeltaScanTruncated) + require.Empty(t, elems) + require.Empty(t, skipTTLIndex) + }) + } +} + +func TestRedisTxnSetAfterHLLExpireSkipsDirtyHLLAnchor(t *testing.T) { + t.Parallel() + + ctx := context.Background() + server, st := newRedisStorageMigrationTestServer(t) + key := []byte("set-after-hll-expire") + payload, err := encodeRedisHLL(redisSetValue{Members: []string{"a"}}, nil) + require.NoError(t, err) + require.NoError(t, st.PutAt(ctx, redisHLLKey(key), payload, redisTxnTestStartTS, 0)) + + txn := newRedisTxnTestContext(server) + expireRes, err := txn.applyExpire(redcon.Command{Args: [][]byte{[]byte(cmdPExpire), key, []byte("1000")}}, time.Millisecond) + require.NoError(t, err) + require.Equal(t, int64(1), expireRes.integer) + require.True(t, elemKeysContain(txn.buildKeyElems(), redisHLLKey(key))) + + setRes, err := txn.applySet(redcon.Command{Args: [][]byte{[]byte(cmdSet), key, []byte("replacement")}}) + require.NoError(t, err) + require.Equal(t, "OK", setRes.str) + require.False(t, elemKeysContain(txn.buildKeyElems(), redisHLLKey(key))) +} + func TestRedisTxnStagedStringWinsOverDeletionOnlyCollectionStates(t *testing.T) { t.Parallel() @@ -1008,10 +1184,23 @@ func TestRedisTxnHashLegacyRewriteWritesFence(t *testing.T) { t.Parallel() key := []byte("legacy-rewrite:hash") - elems := buildHashLegacyRewriteElems(key, map[string][]byte{"field": []byte("value")}) + elems := buildHashLegacyRewriteElems(key, map[string][]byte{"field": []byte("value")}, 0) require.True(t, elemKeysContain(elems, redisTxnWideHashFenceKey(key))) } +func TestRedisTxnHashLegacyRewritePreservesTTL(t *testing.T) { + t.Parallel() + + key := []byte("legacy-rewrite:hash-ttl") + expireAt := redisExpireAtMillis(time.Now().Add(time.Hour)) + elems := buildHashLegacyRewriteElems(key, map[string][]byte{"field": []byte("value")}, expireAt) + metaElem := requireElemByKey(t, elems, store.HashMetaKey(key)) + meta, err := store.UnmarshalHashMeta(metaElem.Value) + require.NoError(t, err) + require.Equal(t, int64(1), meta.Len) + require.Equal(t, expireAt, meta.ExpireAt) +} + func TestRedisSetLegacyMigrationWritesFenceWithoutLenDelta(t *testing.T) { t.Parallel() diff --git a/adapter/redis_zset_cmds.go b/adapter/redis_zset_cmds.go index 9413db1fe..7418ebb2a 100644 --- a/adapter/redis_zset_cmds.go +++ b/adapter/redis_zset_cmds.go @@ -60,13 +60,20 @@ func (r *RedisServer) buildZSetLegacyMigrationElems(ctx context.Context, key []b }, ) } + ttlMs, expired, err := legacyTTLMillisForMigrationAt(ctx, r.store, key, readTS) + if err != nil { + return nil, err + } + if expired { + return legacyExpiredCollectionCleanupElems(key, redisZSetKey(key)), nil + } // Delete the legacy blob. elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: redisZSetKey(key)}) // Write a base meta so that resolveZSetMeta starts from an accurate count. elems = append(elems, &kv.Elem[kv.OP]{ Op: kv.Put, Key: store.ZSetMetaKey(key), - Value: store.MarshalZSetMeta(store.ZSetMeta{Len: int64(len(value.Entries))}), + Value: store.MarshalZSetMeta(store.ZSetMeta{Len: int64(len(value.Entries)), ExpireAt: ttlMs}), }) return elems, nil } @@ -540,10 +547,13 @@ func buildZSetMigrationView(migrationElems []*kv.Elem[kv.OP], key []byte) map[st // resolveZSetMemberScore returns the current score and existence for a ZSet // member. It checks inTxnView first (covers migration elems and earlier pairs // in the same ZADD call), then falls back to a store GetAt. -func (r *RedisServer) resolveZSetMemberScore(ctx context.Context, memberKey []byte, member string, readTS uint64, inTxnView map[string]float64) (score float64, exists bool, err error) { +func (r *RedisServer) resolveZSetMemberScore(ctx context.Context, memberKey []byte, member string, readTS uint64, inTxnView map[string]float64, ignoreExisting bool) (score float64, exists bool, err error) { if s, ok := inTxnView[member]; ok { return s, true, nil } + if ignoreExisting { + return 0, false, nil + } raw, getErr := r.store.GetAt(ctx, memberKey, readTS) if getErr == nil { s, unmarshalErr := store.UnmarshalZSetScore(raw) @@ -565,9 +575,9 @@ func (r *RedisServer) resolveZSetMemberScore(ctx context.Context, memberKey []by // inTxnView provides an in-transaction view of member→score for members written // in the same transaction (migration or earlier pairs); checked before GetAt so // migrated and duplicate members are handled correctly. -func (r *RedisServer) applyZAddPair(ctx context.Context, key []byte, p zaddPair, flags zaddFlags, readTS uint64, elems []*kv.Elem[kv.OP], inTxnView map[string]float64) ([]*kv.Elem[kv.OP], int, int64, error) { +func (r *RedisServer) applyZAddPair(ctx context.Context, key []byte, p zaddPair, flags zaddFlags, readTS uint64, elems []*kv.Elem[kv.OP], inTxnView map[string]float64, ignoreExisting bool) ([]*kv.Elem[kv.OP], int, int64, error) { memberKey := store.ZSetMemberKey(key, []byte(p.member)) - oldScore, memberExists, err := r.resolveZSetMemberScore(ctx, memberKey, p.member, readTS, inTxnView) + oldScore, memberExists, err := r.resolveZSetMemberScore(ctx, memberKey, p.member, readTS, inTxnView, ignoreExisting) if err != nil { return nil, 0, 0, err } @@ -591,7 +601,7 @@ func (r *RedisServer) applyZAddPair(ctx context.Context, key []byte, p zaddPair, func (r *RedisServer) zaddTxn(ctx context.Context, key []byte, flags zaddFlags, pairs []zaddPair) (int, error) { readTS := r.readTS() - typ, err := r.keyTypeOrEmptyAt(ctx, key, readTS, redisTypeZSet) + base, err := r.prepareZSetWriteBase(ctx, key, readTS, len(pairs)) if err != nil { return 0, err } @@ -602,33 +612,18 @@ func (r *RedisServer) zaddTxn(ctx context.Context, key []byte, flags zaddFlags, return 0, cockerrors.WithStack(err) } - migrationElems, err := r.buildZSetLegacyMigrationElems(ctx, key, readTS) - if err != nil { - return 0, err - } // Capacity: each pair may produce 3 ops (del old score + put member + put score index), // plus migration elems and a delta key. - elems := make([]*kv.Elem[kv.OP], 0, len(migrationElems)+len(pairs)*3+setWideColOverhead) //nolint:mnd // 3 ops per pair - elems = append(elems, migrationElems...) - - // Seed the in-transaction view from migration elems so that migrated - // members are not incorrectly counted as new by applyZAddPair. - inTxnView := buildZSetMigrationView(migrationElems, key) - - // For large batches, mergeZSetBulkScores performs one prefix scan that - // eliminates O(N) GetAt calls inside applyZAddPair; it is a no-op for - // batches below wideColumnBulkScanThreshold. - inTxnView, err = r.mergeZSetBulkScores(ctx, key, readTS, len(pairs), inTxnView) - if err != nil { - return 0, err - } + elems := make([]*kv.Elem[kv.OP], 0, len(base.cleanupElems)+len(base.migrationElems)+len(pairs)*3+setWideColOverhead) //nolint:mnd // 3 ops per pair + elems = append(elems, base.cleanupElems...) + elems = append(elems, base.migrationElems...) added := 0 lenDelta := int64(0) for _, p := range pairs { var c int var d int64 - elems, c, d, err = r.applyZAddPair(ctx, key, p, flags, readTS, elems, inTxnView) + elems, c, d, err = r.applyZAddPair(ctx, key, p, flags, readTS, elems, base.inTxnView, base.expiredRecreate) if err != nil { return 0, err } @@ -651,7 +646,47 @@ func (r *RedisServer) zaddTxn(ctx context.Context, key []byte, flags zaddFlags, } return added, r.dispatchAndSignalZSet(ctx, readTS, commitTS, elems, key, - redisTxnWideCreateReadKeys(key, typ, redisTxnWideZSetFenceKey)) + redisTxnWideCreateReadKeys(key, base.typ, redisTxnWideZSetFenceKey)) +} + +type zsetWriteBase struct { + typ redisValueType + cleanupElems []*kv.Elem[kv.OP] + migrationElems []*kv.Elem[kv.OP] + inTxnView map[string]float64 + expiredRecreate bool +} + +func (r *RedisServer) prepareZSetWriteBase(ctx context.Context, key []byte, readTS uint64, bulkMembers int) (zsetWriteBase, error) { + typ, err := r.keyTypeOrEmptyAt(ctx, key, readTS, redisTypeZSet) + if err != nil { + return zsetWriteBase{}, err + } + cleanupElems, expiredRecreate, err := r.expiredCollectionCleanupForRecreate(ctx, key, readTS, typ) + if err != nil { + return zsetWriteBase{}, err + } + var migrationElems []*kv.Elem[kv.OP] + if !expiredRecreate { + migrationElems, err = r.buildZSetLegacyMigrationElems(ctx, key, readTS) + if err != nil { + return zsetWriteBase{}, err + } + } + inTxnView := buildZSetMigrationView(migrationElems, key) + if !expiredRecreate && bulkMembers > 0 { + inTxnView, err = r.mergeZSetBulkScores(ctx, key, readTS, bulkMembers, inTxnView) + if err != nil { + return zsetWriteBase{}, err + } + } + return zsetWriteBase{ + typ: typ, + cleanupElems: cleanupElems, + migrationElems: migrationElems, + inTxnView: inTxnView, + expiredRecreate: expiredRecreate, + }, nil } // dispatchAndSignalZSet dispatches the elems through the coordinator @@ -687,7 +722,7 @@ func (r *RedisServer) dispatchAndSignalZSet( // Returns the new score after applying increment. func (r *RedisServer) zincrbyTxn(ctx context.Context, key []byte, member string, increment float64) (float64, error) { readTS := r.readTS() - typ, err := r.keyTypeOrEmptyAt(ctx, key, readTS, redisTypeZSet) + base, err := r.prepareZSetWriteBase(ctx, key, readTS, 0) if err != nil { return 0, err } @@ -699,15 +734,7 @@ func (r *RedisServer) zincrbyTxn(ctx context.Context, key []byte, member string, return 0, cockerrors.WithStack(err) } - migrationElems, migErr := r.buildZSetLegacyMigrationElems(ctx, key, readTS) - if migErr != nil { - return 0, migErr - } - - // Check in-txn migration view before falling back to the store - // (migrated keys are not yet visible at readTS). - inTxnView := buildZSetMigrationView(migrationElems, key) - oldScore, memberExists, err := r.resolveZSetMemberScore(ctx, memberKey, member, readTS, inTxnView) + oldScore, memberExists, err := r.resolveZSetMemberScore(ctx, memberKey, member, readTS, base.inTxnView, base.expiredRecreate) if err != nil { return 0, err } @@ -716,8 +743,9 @@ func (r *RedisServer) zincrbyTxn(ctx context.Context, key []byte, member string, if math.IsNaN(newScore) { return 0, errors.New("ERR resulting score is not a number (NaN)") } - elems := make([]*kv.Elem[kv.OP], 0, len(migrationElems)+3) //nolint:mnd // del old score + put member + put score index - elems = append(elems, migrationElems...) + elems := make([]*kv.Elem[kv.OP], 0, len(base.cleanupElems)+len(base.migrationElems)+3) //nolint:mnd // del old score + put member + put score index + elems = append(elems, base.cleanupElems...) + elems = append(elems, base.migrationElems...) if memberExists { elems = append(elems, &kv.Elem[kv.OP]{Op: kv.Del, Key: store.ZSetScoreKey(key, oldScore, []byte(member))}) } @@ -735,7 +763,7 @@ func (r *RedisServer) zincrbyTxn(ctx context.Context, key []byte, member string, }) } if err := r.dispatchAndSignalZSet(ctx, readTS, commitTS, elems, key, - redisTxnWideCreateReadKeys(key, typ, redisTxnWideZSetFenceKey)); err != nil { + redisTxnWideCreateReadKeys(key, base.typ, redisTxnWideZSetFenceKey)); err != nil { return 0, err } return newScore, nil diff --git a/docs/design/2026_04_17_proposed_ttl_inline_value.md b/docs/design/2026_04_17_implemented_ttl_inline_value.md similarity index 67% rename from docs/design/2026_04_17_proposed_ttl_inline_value.md rename to docs/design/2026_04_17_implemented_ttl_inline_value.md index 0e86143e7..b081b9052 100644 --- a/docs/design/2026_04_17_proposed_ttl_inline_value.md +++ b/docs/design/2026_04_17_implemented_ttl_inline_value.md @@ -1,5 +1,14 @@ # Design: TTL Embedded in Data Values +Status: Implemented — Redis strings, HLL, and the list/hash/set/zset/stream +metadata anchors carry inline TTL, and `EXPIRE` updates those anchors in the +same Raft transaction as the secondary scan-index row. A leader-only background +migrator rewrites legacy anchors into inline form. The legacy `!redis|ttl|` +read fallback remains enabled by default for rolling upgrades, but can be +disabled after migration with `WithRedisLegacyTTLReadFallback(false)` only after +legacy bare string keys have also been ruled out or rewritten. The TTL buffer has +been removed. + ## 1. Background and motivation ### Current architecture @@ -143,16 +152,23 @@ follows: and the caller treats the key as malformed (e.g. `isLeaderKeyExpired` treats the key as expired rather than silently alive). - For deployments that need a stronger guarantee, the migration compactor - should be run to rewrite every legacy value with the new envelope before - Phase 2 (legacy-read removal) begins. + should be run to rewrite every prefixed legacy string value with the new + envelope before Phase 3 disables the legacy `!redis|ttl|` fallback. Legacy + bare string keys are not covered by the prefix-scanning compactor and must be + ruled out or rewritten separately before disabling the fallback. + +### 3.3 Collection metadata encoding -### 3.3 Collection metadata encoding (future work) +`ListMeta`, `HashMeta`, `SetMeta`, `ZSetMeta`, and `StreamMeta` now carry an +`ExpireAt uint64` field in milliseconds since epoch. The secondary +`!redis|ttl|` row is still written as the scan index and as a migration +fallback for old-format metadata; it is no longer the only source of truth for +these collection anchors. -> **Status in this PR:** only *string* values carry inline TTL today. Collection -> anchors (`ListMeta`, `HashMeta`, `SetMeta`, `ZSetMeta`, stream, HLL) still -> store TTL in the secondary `!redis|ttl|` index. The sections below describe -> the intended Phase 4 design; they are not yet implemented and the adapter -> deliberately keeps the legacy index in use for non-string types. +HLL payloads use a small magic+version envelope around the existing serialized +set-compatible payload. The envelope carries the same optional `expireAt` +metadata as strings while legacy bare HLL payloads still decode during the +migration window and fall back to `!redis|ttl|`. `ListMeta`, `HashMeta`, `SetMeta`, `ZSetMeta` are fixed-width binary structs. Each gains an `ExpireAt uint64` field (0 = no TTL): @@ -173,13 +189,15 @@ type ListMeta struct { } ``` -The on-disk format gains 8 bytes; the existing 24-byte format is distinguished -from the new 32-byte format by length during decode (backward-compatible read). +The on-disk format gains 8 bytes; the existing 8-byte simple metadata and +24-byte list/stream metadata formats are distinguished from the new 16-byte and +32-byte formats by length during decode (backward-compatible read). -For HLL and stream types the TTL is appended to the serialised value using the -same flags+expireAt header described in §3.2. +Stream metadata follows the same 32-byte metadata shape. HLL uses its own +versioned envelope because its anchor is a serialized probabilistic payload +rather than fixed-width metadata. -### 3.4 Read path (after migration) +### 3.4 Read path (target after migration) ``` keyTypeAt(key, readTS) @@ -188,7 +206,10 @@ keyTypeAt(key, readTS) → returns (type, isExpired) in one call ``` -No secondary `!redis|ttl|` lookup on the hot read path. +Today the read path checks inline string, HLL, and collection metadata first, +then falls back to `!redis|ttl|` for legacy payloads and metadata. The +target after the migration phases is no secondary `!redis|ttl|` lookup on +the hot read path. ### 3.5 Write path @@ -201,12 +222,15 @@ Raft entry (IsTxn=true): ``` Both are written in the same transaction. The TTL buffer and its background -goroutine are no longer needed for correctness; they can be removed. +goroutine are no longer needed for correctness and have been removed. #### `EXPIRE key 30` (after-the-fact TTL on an existing key) For **string** keys: read-modify-write the anchor value to update `expireAt`. -For **collection** keys: read-modify-write the metadata key to update `ExpireAt`. +For **collection** keys: read-modify-write the metadata key to update +`ExpireAt`. When the collection has uncompacted length deltas, `EXPIRE` first +folds the deltas into the base metadata key in the same transaction so the new +metadata value is authoritative and does not double-count future reads. This is a `IsTxn=true` transaction (OCC read-modify-write on the anchor key), which is the existing pattern for all other in-place updates. @@ -228,8 +252,9 @@ Background scanner (future work): → for each entry where expireAt < now: schedule lazy deletion ``` -On reads the TTL key is **never consulted**. It is written in the same Raft -entry as the data to keep it consistent, but it is not the authoritative source. +For strings, new-format HLL payloads, and new-format collection metadata, reads +consult the anchor first. During the migration window, `!redis|ttl|` is +still consulted when the anchor is old-format. --- @@ -258,22 +283,43 @@ is absent it falls back to the legacy raw-byte format and reads This allows a rolling upgrade with no downtime. -### Phase 1 — Background migration +### Phase 1 — Collection metadata and HLL inline TTL + +The live write path now emits the expanded metadata formats for list, hash, set, +zset, and stream. `EXPIRE` on those types updates the anchor metadata and the +secondary scan index together. Delta compaction preserves existing `ExpireAt` +when it folds length deltas into base metadata. The logical backup decoder +accepts both old and new metadata lengths and exports inline metadata TTL as +`expire_at_ms`. + +HLL writes now emit an HLL-specific envelope around the existing payload. +`EXPIRE` and Redis transaction TTL updates rewrite the HLL anchor and the +secondary scan index in the same Raft transaction. Legacy HLL payloads remain +readable and are upgraded on the next HLL write. + +### Phase 2 — Background migration -A one-time background job rewrites each string key to the new encoding and each -collection metadata key to the new format. During migration the dual-read +`DeltaCompactor` now runs a bounded leader-only TTL inline migration pass on +each tick. It scans the `!redis|str|`, `!redis|hll|`, +`!lst|meta|`, `!hs|meta|`, `!st|meta|`, `!zs|meta|`, and +`!stream|meta|` anchors with per-prefix cursors and rewrites old-format values +into the inline format using OCC transactions. During migration the dual-read fallback remains active. -### Phase 2 — Remove legacy TTL read path +### Phase 3 — Remove legacy TTL read path -Once all nodes have migrated (cluster-level flag or store version check), the -`!redis|ttl|` lookup in `ttlAt()` is removed. +Once all nodes have migrated and legacy bare string keys have been ruled out or +rewritten separately, operators can start Redis with +`WithRedisLegacyTTLReadFallback(false)` to remove the `!redis|ttl|` lookup +from `ttlAt()` after the anchor-specific probes miss. The default remains +fallback-enabled so rolling upgrades and not-yet-migrated stores preserve +behavior. -### Phase 3 — Remove TTL buffer +### Phase 4 — Remove TTL buffer -The `TTLBuffer`, `runTTLFlusher`, and `flushTTLBuffer` can be removed. TTL +The `TTLBuffer`, `runTTLFlusher`, and `flushTTLBuffer` have been removed. TTL writes are back in the same `IsTxn=true` transaction as data writes, so no -buffering is needed. The write-conflict concern is resolved because OCC +buffering is needed. The write-conflict concern is resolved because OCC conflicts on the anchor key are expected and retried, and concurrent `EXPIRE` operations on distinct keys have no interaction. @@ -320,16 +366,21 @@ store/ hash_helpers.go HashMeta: add ExpireAt field set_helpers.go SetMeta: add ExpireAt field zset_helpers.go ZSetMeta: add ExpireAt field + stream_helpers.go StreamMeta: add ExpireAt field adapter/ redis_compat_types.go encodeRedisStr / decodeRedisStr (new with TTL header) - ttlAt(): read from decoded anchor, not !redis|ttl| key - hasExpiredTTLAt(): inline TTL check from anchor - redis_compat_helpers.go keyTypeAt(): single anchor read - redis_compat_commands.go setExpire(): anchor read-modify-write + encodeRedisHLL / decodeRedisHLL + ttlAt(): read from decoded anchor before fallback + hasExpired(): inline TTL check from anchor + redis_collection_ttl.go collection metadata TTL helpers + redis_expire_cmds.go setExpire(): anchor read-modify-write + redis_delta_compactor.go preserve ExpireAt during metadata compaction + run bounded TTL inline migration passes redis.go remove TTLBuffer field, runTTLFlusher, flushTTLBuffer + WithRedisLegacyTTLReadFallback gate remove WithTTLFlushInterval option - redis_ttl_buffer.go delete (Phase 3) + redis_ttl_buffer.go delete (Phase 4) redis_lua_context.go commit(): TTL back in data Raft entry; remove flushTTLForKeyToBuffer / flushTTLToBuffer ``` @@ -341,6 +392,7 @@ adapter/ | Phase | Description | Risk | |---|---|---| | 0 | Add dual-read fallback; new writes use new encoding | Low — reads always fall back | -| 1 | Background migration of existing keys | Medium — requires careful compaction | -| 2 | Remove legacy `!redis\|ttl\|` read path | Low — only after full migration | -| 3 | Remove TTL buffer | Low — no longer needed post-migration | +| 1 | Add inline TTL to list/hash/set/zset/stream metadata and HLL payloads | Medium — delta compaction must preserve TTL | +| 2 | Background migration of existing keys | Medium — shipped as bounded compactor pass | +| 3 | Remove legacy `!redis\|ttl\|` read path | Medium — safe only after prefix-scanned anchors and legacy bare strings are cleared | +| 4 | Remove TTL buffer | Low — shipped | diff --git a/internal/backup/encode_redis.go b/internal/backup/encode_redis.go index ca8958113..60234a19b 100644 --- a/internal/backup/encode_redis.go +++ b/internal/backup/encode_redis.go @@ -31,9 +31,10 @@ import ( // The live store sets NO MVCC-level expireAt for redis writes // (kv.Elem has no expiry field) and writes NO !redis|ttl| scan-index // row for strings (buildTTLElems explicitly skips string keys). -// - HLL values are raw sketch bytes with no inline header; their TTL -// lives in a !redis|ttl| scan-index row (8-byte BE ms), -// matching buildTTLElems for non-string types. +// - HLL values with TTL carry TTL INLINE in the value header +// (encodeRedisHLL: [0xFF 0x02][flags][expireMs BE if has_ttl][body]) +// and keep the !redis|ttl| scan-index row. No-TTL legacy +// HLL values remain raw sketch bytes for backward-compatible dumps. // // All redis entries therefore use MVCC value-header expireAt = 0 // (the redis adapter manages expiry itself); the builder's expireAt @@ -190,9 +191,9 @@ func (e *RedisEncoder) encodeStrings(b *snapshotBuilder) error { }) } -// encodeHLL reconstructs !redis|hll| records from hll/*.bin (raw sketch -// bytes) plus the !redis|ttl| scan-index row for any expiring HLL key, -// matching buildTTLElems for non-string types. +// encodeHLL reconstructs !redis|hll| records from hll/*.bin, folding +// hll_ttl.jsonl back into the live inline HLL header and also emitting +// the !redis|ttl| scan-index row for expiring HLL keys. func (e *RedisEncoder) encodeHLL(b *snapshotBuilder) error { ttls, err := e.loadTTLMap(redisHLLTTLFile) if err != nil { @@ -200,11 +201,15 @@ func (e *RedisEncoder) encodeHLL(b *snapshotBuilder) error { } return e.walkBlobDir("hll", func(encoded string, rawKey, body []byte) error { key := append([]byte(RedisHLLPrefix), rawKey...) - if err := b.Add(key, body, 0); err != nil { + expireMs := ttls[encoded] + value := body + if expireMs != 0 { + value = encodeRedisHLLInlineValue(body, expireMs) + } + if err := b.Add(key, value, 0); err != nil { return err } - expireMs, ok := ttls[encoded] - if !ok || expireMs == 0 { + if expireMs == 0 { return nil } ttlKey := append([]byte(RedisTTLPrefix), rawKey...) @@ -448,6 +453,19 @@ func encodeRedisStrInlineValue(body []byte, expireMs uint64) []byte { return out } +// encodeRedisHLLInlineValue reproduces adapter/redis_compat_types.go's +// encodeRedisHLL envelope for a decoded raw sketch body with an +// expiring TTL. No-TTL HLL restore keeps the legacy raw-sketch value. +func encodeRedisHLLInlineValue(body []byte, expireMs uint64) []byte { + out := make([]byte, redisHLLBaseHeader+redisUint64Bytes+len(body)) + out[0] = redisHLLMagic + out[1] = redisHLLVersion + out[2] = redisHLLHasTTL + binary.BigEndian.PutUint64(out[redisHLLBaseHeader:redisHLLBaseHeader+redisUint64Bytes], expireMs) + copy(out[redisHLLBaseHeader+redisUint64Bytes:], body) + return out +} + // encodeRedisTTLValueMs reproduces adapter/redis_compat_types.go's // encodeRedisTTL for the dump path: the 8-byte big-endian expiry // millis the !redis|ttl| scan-index row carries. diff --git a/internal/backup/encode_redis_coll.go b/internal/backup/encode_redis_coll.go index 28f35d882..7837718a9 100644 --- a/internal/backup/encode_redis_coll.go +++ b/internal/backup/encode_redis_coll.go @@ -35,9 +35,9 @@ import ( // deltas), which is the post-compaction steady state the read path // accepts — and which the decoder already drops on the way in. // -// Collection TTL is NOT inline (unlike strings): it lives in a -// !redis|ttl| scan-index row, matching buildTTLElems for -// non-string types. +// Collection TTL is stored in two places: inline in the collection +// meta value (the live read path's source of truth) and in the +// !redis|ttl| scan-index row used for expiry scans. // ErrRedisEncodeInvalidJSON is returned when a collection JSON file in // the dump cannot be parsed into its expected shape. @@ -98,7 +98,7 @@ func (e *RedisEncoder) encodeHashes(b *snapshotBuilder) error { // Base meta: element count = number of fields (consolidated, // no deltas). if err := b.Add(wideColMetaKey(RedisHashMetaPrefix, rawKey), - marshalCount8BE(uint64(len(rec.Fields))), 0); err != nil { + marshalCountMeta(uint64(len(rec.Fields)), rec.ExpireAtMs), 0); err != nil { return err } for _, f := range rec.Fields { @@ -140,7 +140,7 @@ func (e *RedisEncoder) encodeSets(b *snapshotBuilder) error { return errors.Wrapf(ErrRedisEncodeInvalidJSON, "set %q: unsupported format_version %d", rawKey, rec.FormatVersion) } if err := b.Add(wideColMetaKey(RedisSetMetaPrefix, rawKey), - marshalCount8BE(uint64(len(rec.Members))), 0); err != nil { + marshalCountMeta(uint64(len(rec.Members)), rec.ExpireAtMs), 0); err != nil { return err } for _, mRaw := range rec.Members { @@ -184,7 +184,7 @@ func (e *RedisEncoder) encodeLists(b *snapshotBuilder) error { if rec.FormatVersion != redisCollectionFormatVersion { return errors.Wrapf(ErrRedisEncodeInvalidJSON, "list %q: unsupported format_version %d", rawKey, rec.FormatVersion) } - if err := b.Add(buildListMetaKey(rawKey), marshalListMetaHead0(uint64(len(rec.Items))), 0); err != nil { + if err := b.Add(buildListMetaKey(rawKey), marshalListMetaHead0(uint64(len(rec.Items)), rec.ExpireAtMs), 0); err != nil { return err } for i, itemRaw := range rec.Items { @@ -208,17 +208,6 @@ func buildListMetaKey(userKey []byte) []byte { return append(out, userKey...) } -// marshalListMetaHead0 encodes the 24-byte [Head][Tail][Len] meta with -// Head=0, Len=n, Tail=n — the canonical restore form (mirror of -// store.marshalListMeta with Head=0). -func marshalListMetaHead0(n uint64) []byte { - buf := make([]byte, listMetaBinarySize) - binary.BigEndian.PutUint64(buf[0:8], 0) // Head - binary.BigEndian.PutUint64(buf[8:16], n) // Tail = Head + Len - binary.BigEndian.PutUint64(buf[16:24], n) // Len - return buf -} - // buildListItemKey builds !lst|itm| // (mirror of store.ListItemKey + encodeSortableInt64: the seq is // sign-flipped so a forward byte scan yields ascending int64). @@ -259,7 +248,7 @@ func (e *RedisEncoder) encodeZSets(b *snapshotBuilder) error { return errors.Wrapf(ErrRedisEncodeInvalidJSON, "zset %q: unsupported format_version %d", rawKey, rec.FormatVersion) } if err := b.Add(wideColMetaKey(RedisZSetMetaPrefix, rawKey), - marshalCount8BE(uint64(len(rec.Members))), 0); err != nil { + marshalCountMeta(uint64(len(rec.Members)), rec.ExpireAtMs), 0); err != nil { return err } for _, m := range rec.Members { @@ -386,7 +375,7 @@ func (e *RedisEncoder) encodeStreams(b *snapshotBuilder) error { return err } if err := b.Add(wideColMetaKey(RedisStreamMetaPrefix, rawKey), - marshalStreamMeta(meta.Length, meta.LastMs, meta.LastSeq), 0); err != nil { + marshalStreamMeta(meta.Length, meta.LastMs, meta.LastSeq, meta.ExpireAtMs), 0); err != nil { return err } return e.addCollectionTTL(b, rawKey, meta.ExpireAtMs) @@ -494,13 +483,20 @@ func buildStreamEntryKey(userKey []byte, ms, seq uint64) []byte { return wideColElemKey(RedisStreamEntryPrefix, userKey, id[:]) } -// marshalStreamMeta encodes the 24-byte [Length][LastMs][LastSeq] meta -// value (mirror of store.MarshalStreamMeta). -func marshalStreamMeta(length int64, lastMs, lastSeq uint64) []byte { - buf := make([]byte, redisStreamMetaSize) +// marshalStreamMeta encodes [Length][LastMs][LastSeq], optionally with +// trailing ExpireAtMs (mirror of store.MarshalStreamMeta). +func marshalStreamMeta(length int64, lastMs, lastSeq uint64, expireMs *uint64) []byte { + size := redisStreamMetaSize + if expireMs != nil && *expireMs != 0 { + size = redisStreamMetaInlineTTLSize + } + buf := make([]byte, size) binary.BigEndian.PutUint64(buf[0:8], uint64(length)) //nolint:gosec // length is a non-negative count from the dump's _meta line binary.BigEndian.PutUint64(buf[8:16], lastMs) binary.BigEndian.PutUint64(buf[16:24], lastSeq) + if size == redisStreamMetaInlineTTLSize { + binary.BigEndian.PutUint64(buf[24:32], *expireMs) + } return buf } @@ -532,7 +528,7 @@ func buildStreamEntryValue(id string, interleaved []string) ([]byte, error) { // addCollectionTTL emits the !redis|ttl| scan-index row for a // non-string collection with an expiry. A nil expiry is a no-op. func (e *RedisEncoder) addCollectionTTL(b *snapshotBuilder, rawKey []byte, expireMs *uint64) error { - if expireMs == nil { + if expireMs == nil || *expireMs == 0 { return nil } key := append([]byte(RedisTTLPrefix), rawKey...) @@ -635,11 +631,35 @@ func wideColElemKey(prefix string, userKey, suffix []byte) []byte { return append(out, suffix...) } -// marshalCount8BE encodes a collection element count as the 8-byte -// big-endian value store.MarshalHashMeta writes. -func marshalCount8BE(n uint64) []byte { - buf := make([]byte, redisUint64Bytes) +// marshalCountMeta encodes a collection count, optionally followed by +// inline ExpireAtMs. This mirrors store.MarshalHashMeta/SetMeta/ZSetMeta. +func marshalCountMeta(n uint64, expireMs *uint64) []byte { + size := redisUint64Bytes + if expireMs != nil && *expireMs != 0 { + size = redisCountMetaInlineBytes + } + buf := make([]byte, size) binary.BigEndian.PutUint64(buf, n) + if size == redisCountMetaInlineBytes { + binary.BigEndian.PutUint64(buf[redisUint64Bytes:redisCountMetaInlineBytes], *expireMs) + } + return buf +} + +// marshalListMetaHead0 encodes [Head][Tail][Len], optionally with +// trailing ExpireAtMs. Head=0, Len=n, Tail=n is the canonical restore form. +func marshalListMetaHead0(n uint64, expireMs *uint64) []byte { + size := listMetaBinarySize + if expireMs != nil && *expireMs != 0 { + size = listMetaInlineTTLSize + } + buf := make([]byte, size) + binary.BigEndian.PutUint64(buf[0:8], 0) // Head + binary.BigEndian.PutUint64(buf[8:16], n) // Tail = Head + Len + binary.BigEndian.PutUint64(buf[16:24], n) // Len + if size == listMetaInlineTTLSize { + binary.BigEndian.PutUint64(buf[24:32], *expireMs) + } return buf } diff --git a/internal/backup/encode_redis_coll_test.go b/internal/backup/encode_redis_coll_test.go index 98ae35738..905a6060a 100644 --- a/internal/backup/encode_redis_coll_test.go +++ b/internal/backup/encode_redis_coll_test.go @@ -2,6 +2,7 @@ package backup import ( "bytes" + "encoding/binary" "encoding/json" "io" "io/fs" @@ -623,6 +624,68 @@ func TestRedisEncodeStreamRoundTripViaDecode(t *testing.T) { } } +func TestRedisEncodeCollectionTTLsAreInlineInMeta(t *testing.T) { + t.Parallel() + in := t.TempDir() + const expireMs uint64 = 1_700_000_000_321 + exp := expireMs + + hashKey := []byte("hash-inline-ttl") + hashEnc := EncodeSegment(hashKey) + writeRedisFile(t, in, filepath.Join("hashes", hashEnc+".json"), + buildHashJSON(t, map[string]string{"field": "value"}, &exp)) + + setKey := []byte("set-inline-ttl") + setEnc := EncodeSegment(setKey) + writeRedisFile(t, in, filepath.Join("sets", setEnc+".json"), + buildSetJSON(t, []string{"member"}, &exp)) + + listKey := []byte("list-inline-ttl") + listEnc := EncodeSegment(listKey) + writeRedisFile(t, in, filepath.Join("lists", listEnc+".json"), + buildListJSON(t, []string{"a", "b"}, &exp)) + + zsetKey := []byte("zset-inline-ttl") + zsetEnc := EncodeSegment(zsetKey) + writeRedisFile(t, in, filepath.Join("zsets", zsetEnc+".json"), + buildZSetJSON(t, map[string]float64{"member": 1}, &exp)) + + streamKey := []byte("stream-inline-ttl") + streamEnc := EncodeSegment(streamKey) + entries := []streamTestEntry{{id: "1714400000000-0", fields: [][2]string{{"field", "value"}}}} + writeRedisFile(t, in, filepath.Join("streams", streamEnc+".jsonl"), + buildStreamJSONL(t, entries, 1714400000000, 0, &exp)) + + b := newSnapshotBuilder(redisEncTS) + if err := NewRedisEncoder(in, 0).Encode(b); err != nil { + t.Fatalf("RedisEncoder.Encode: %v", err) + } + + cases := []struct { + name string + metaKey []byte + wantLen int + ttlOffset int + }{ + {name: "hash", metaKey: wideColMetaKey(RedisHashMetaPrefix, hashKey), wantLen: redisCountMetaInlineBytes, ttlOffset: redisUint64Bytes}, + {name: "set", metaKey: wideColMetaKey(RedisSetMetaPrefix, setKey), wantLen: redisCountMetaInlineBytes, ttlOffset: redisUint64Bytes}, + {name: "list", metaKey: buildListMetaKey(listKey), wantLen: listMetaInlineTTLSize, ttlOffset: listMetaBinarySize}, + {name: "zset", metaKey: wideColMetaKey(RedisZSetMetaPrefix, zsetKey), wantLen: redisCountMetaInlineBytes, ttlOffset: redisUint64Bytes}, + {name: "stream", metaKey: wideColMetaKey(RedisStreamMetaPrefix, streamKey), wantLen: redisStreamMetaInlineTTLSize, ttlOffset: redisStreamMetaSize}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := snapshotUserValueForKey(t, b, tc.metaKey) + if len(got) != tc.wantLen { + t.Fatalf("%s meta length = %d, want %d", tc.name, len(got), tc.wantLen) + } + if gotMs := binary.BigEndian.Uint64(got[tc.ttlOffset : tc.ttlOffset+redisUint64Bytes]); gotMs != expireMs { + t.Fatalf("%s meta ttl = %d, want %d", tc.name, gotMs, expireMs) + } + }) + } +} + // assertStreamEntriesEqual checks two stream-entry slices match on ID // and interleaved field order. func assertStreamEntriesEqual(t *testing.T, got, want []streamTestEntry) { diff --git a/internal/backup/encode_redis_test.go b/internal/backup/encode_redis_test.go index 1042284fc..8f74b4b40 100644 --- a/internal/backup/encode_redis_test.go +++ b/internal/backup/encode_redis_test.go @@ -2,6 +2,7 @@ package backup import ( "bytes" + "encoding/binary" "encoding/json" "os" "path/filepath" @@ -41,6 +42,17 @@ func encodeRedisTree(t *testing.T, inRoot string) []byte { return buf.Bytes() } +func snapshotUserValueForKey(t *testing.T, b *snapshotBuilder, userKey []byte) []byte { + t.Helper() + for _, entry := range b.entries { + if len(entry.key) == len(userKey)+snapshotTSSize && bytes.HasPrefix(entry.key, userKey) { + return entry.val[snapshotValueHeaderSize:] + } + } + t.Fatalf("missing encoded entry for user key %q", userKey) + return nil +} + // decodeRedisTree decodes fsm bytes through the real decode path into a // fresh output tree and returns its root. func decodeRedisTree(t *testing.T, fsm []byte) string { @@ -105,9 +117,8 @@ func TestRedisEncodeStringTTLRoundTripViaDecode(t *testing.T) { } } -// TestRedisEncodeHLLTTLRoundTripViaDecode pins that an HLL sketch is -// emitted raw under !redis|hll| and its TTL via an !redis|ttl| row, -// both recovered on decode (hll/.bin + hll_ttl.jsonl). +// TestRedisEncodeHLLTTLRoundTripViaDecode pins that an HLL sketch and +// TTL are both recovered on decode (hll/.bin + hll_ttl.jsonl). func TestRedisEncodeHLLTTLRoundTripViaDecode(t *testing.T) { t.Parallel() in := t.TempDir() @@ -131,6 +142,35 @@ func TestRedisEncodeHLLTTLRoundTripViaDecode(t *testing.T) { } } +func TestRedisEncodeHLLTTLIsInlineInValue(t *testing.T) { + t.Parallel() + in := t.TempDir() + enc := EncodeSegment([]byte("counter")) + const expireMs uint64 = 1_700_000_000_000 + sketch := []byte{0x01, 0x02, 0x03, 0xFF, 0x00} + writeRedisFile(t, in, filepath.Join("hll", enc+".bin"), sketch) + writeTTLSidecar(t, in, "hll_ttl.jsonl", enc, expireMs) + + b := newSnapshotBuilder(redisEncTS) + if err := NewRedisEncoder(in, 0).Encode(b); err != nil { + t.Fatalf("RedisEncoder.Encode: %v", err) + } + key := append([]byte(RedisHLLPrefix), []byte("counter")...) + got := snapshotUserValueForKey(t, b, key) + if len(got) != redisHLLBaseHeader+redisUint64Bytes+len(sketch) { + t.Fatalf("encoded hll length = %d, want %d", len(got), redisHLLBaseHeader+redisUint64Bytes+len(sketch)) + } + if !bytes.Equal(got[:redisHLLBaseHeader], []byte{redisHLLMagic, redisHLLVersion, redisHLLHasTTL}) { + t.Fatalf("encoded hll header = %x", got[:redisHLLBaseHeader]) + } + if gotMs := binary.BigEndian.Uint64(got[redisHLLBaseHeader : redisHLLBaseHeader+redisUint64Bytes]); gotMs != expireMs { + t.Fatalf("encoded hll ttl = %d, want %d", gotMs, expireMs) + } + if !bytes.Equal(got[redisHLLBaseHeader+redisUint64Bytes:], sketch) { + t.Fatalf("encoded hll sketch = %x, want %x", got[redisHLLBaseHeader+redisUint64Bytes:], sketch) + } +} + // TestRedisEncodeStringInlineValueLayout pins the inline value bytes // directly (non-circular against the round-trip): no-TTL strings get a // 3-byte header, TTL strings an 11-byte header with BE millis. diff --git a/internal/backup/redis_hash.go b/internal/backup/redis_hash.go index 2e5f1e5bd..9aa123d7b 100644 --- a/internal/backup/redis_hash.go +++ b/internal/backup/redis_hash.go @@ -5,7 +5,6 @@ import ( "encoding/base64" "encoding/binary" "encoding/json" - "math" "path/filepath" "sort" "unicode/utf8" @@ -23,7 +22,7 @@ const ( ) // ErrRedisInvalidHashMeta is returned when the !hs|meta| value is not -// the expected 8-byte big-endian field count. +// the expected big-endian field count, optionally followed by inline TTL. var ErrRedisInvalidHashMeta = cockroachdberr.New("backup: invalid !hs|meta| value") // ErrRedisInvalidHashKey is returned when an !hs| key cannot be parsed @@ -36,17 +35,18 @@ var ErrRedisInvalidHashKey = cockroachdberr.New("backup: malformed !hs| key") // stream) because the design's per-hash JSON shape requires the full // field map up-front and Redis hashes are typically small. type redisHashState struct { - declaredLen int64 - metaSeen bool - fields map[string][]byte // field-name → field-value bytes - expireAtMs uint64 - hasTTL bool + declaredLen int64 + metaSeen bool + fields map[string][]byte // field-name → field-value bytes + expireAtMs uint64 + hasTTL bool + inlineTTLOwned bool } // HandleHashMeta processes one !hs|meta| record. The value is -// the 8-byte BE field count. We park the state for finalize-time flush -// and register the user key so a later !redis|ttl| record -// routes back to this hash state. +// the 8-byte BE field count, optionally followed by an inline expireAtMs. +// We park the state for finalize-time flush and register the user key so +// a later !redis|ttl| record routes back to this hash state. // // Delta keys (!hs|meta|d|...) share the !hs|meta| string prefix, so a // snapshot dispatcher that routes by "starts with RedisHashMetaPrefix" @@ -63,10 +63,6 @@ func (r *RedisDB) HandleHashMeta(key, value []byte) error { if !ok { return cockroachdberr.Wrapf(ErrRedisInvalidHashKey, "meta key: %q", key) } - if len(value) != redisUint64Bytes { - return cockroachdberr.Wrapf(ErrRedisInvalidHashMeta, - "length %d != %d", len(value), redisUint64Bytes) - } // Bounds-check the uint64 field count before narrowing to int64. // Without this, a corrupted store value with the high bit set // would wrap to a negative declaredLen and fire spurious @@ -75,14 +71,18 @@ func (r *RedisDB) HandleHashMeta(key, value []byte) error { // wide-column encoders fail closed on the same shape of // corruption. Round-2 review on PR #755 — backported from list // encoder for cross-encoder consistency. - rawLen := binary.BigEndian.Uint64(value) - if rawLen > math.MaxInt64 { - return cockroachdberr.Wrapf(ErrRedisInvalidHashMeta, - "declared len %d overflows int64", rawLen) + declaredLen, expireAtMs, hasTTL, inlineTTL, err := decodeRedisCountMeta(value, ErrRedisInvalidHashMeta) + if err != nil { + return err } st := r.hashState(userKey) - st.declaredLen = int64(rawLen) //nolint:gosec // bounds-checked above + st.declaredLen = declaredLen st.metaSeen = true + if inlineTTL { + st.expireAtMs = expireAtMs + st.hasTTL = hasTTL + st.inlineTTLOwned = true + } return nil } diff --git a/internal/backup/redis_hash_test.go b/internal/backup/redis_hash_test.go index 8344fc313..405b3cb1a 100644 --- a/internal/backup/redis_hash_test.go +++ b/internal/backup/redis_hash_test.go @@ -18,6 +18,13 @@ func encodeHashMetaValue(fieldCount int64) []byte { return v } +func encodeHashMetaInlineValue(fieldCount int64, expireAtMs uint64) []byte { + v := encodeHashMetaValue(fieldCount) + ttl := make([]byte, 8) + binary.BigEndian.PutUint64(ttl, expireAtMs) + return append(v, ttl...) +} + // hashMetaKey is the test-side key constructor for !hs|meta|. // Mirrors store.HashMetaKey. func hashMetaKey(userKey string) []byte { @@ -185,6 +192,27 @@ func TestRedisDB_HashTTLInlinedFromScanIndex(t *testing.T) { } } +func TestRedisDB_HashInlineTTLIgnoresStaleScanIndex(t *testing.T) { + t.Parallel() + db, root := newRedisDB(t) + if err := db.HandleHashMeta(hashMetaKey("k"), encodeHashMetaInlineValue(1, fixedExpireMs)); err != nil { + t.Fatal(err) + } + if err := db.HandleHashField(hashFieldKey("k", "f"), []byte("v")); err != nil { + t.Fatal(err) + } + if err := db.HandleTTL([]byte("k"), encodeTTLValue(fixedExpireMs+1)); err != nil { + t.Fatal(err) + } + if err := db.Finalize(); err != nil { + t.Fatal(err) + } + got := readHashJSON(t, filepath.Join(root, "redis", "db_0", "hashes", "k.json")) + if hashFloat(t, got, "expire_at_ms") != float64(fixedExpireMs) { + t.Fatalf("expire_at_ms = %v want inline %d", got["expire_at_ms"], fixedExpireMs) + } +} + func TestRedisDB_HashLengthMismatchWarns(t *testing.T) { t.Parallel() db, _ := newRedisDB(t) diff --git a/internal/backup/redis_list.go b/internal/backup/redis_list.go index bcc1bc40c..ba9cc6244 100644 --- a/internal/backup/redis_list.go +++ b/internal/backup/redis_list.go @@ -18,7 +18,8 @@ import ( // Three on-disk key families share the `!lst|` namespace; only two // carry restorable state: // -// - !lst|meta| -> 24-byte (Head, Tail, Len) blob +// - !lst|meta| -> 24-byte (Head, Tail, Len) blob, +// optionally followed by 8-byte expireAtMs // - !lst|itm| -> raw item bytes (Redis lists // are binary-safe) // - !lst|claim|... -> POP tombstone for OCC @@ -48,11 +49,10 @@ const ( ListMetaDeltaPrefix = "!lst|meta|d|" ListClaimPrefix = "!lst|claim|" - // listMetaBinarySize mirrors store/list_helpers.go (24 bytes: - // Head(8) + Tail(8) + Len(8)). Re-declared here rather than - // imported because the backup package is intentionally adapter- - // and store-independent. + // listMetaBinarySize is the legacy Head(8) + Tail(8) + Len(8) shape. listMetaBinarySize = 24 + // listMetaInlineTTLSize is the current shape with trailing ExpireAtMs(8). + listMetaInlineTTLSize = 32 // listSeqBytes is the fixed width of the trailing sortable-int64 // sequence number in an !lst|itm| key. @@ -60,7 +60,7 @@ const ( ) // ErrRedisInvalidListMeta is returned when an !lst|meta| value is not -// the expected 24-byte (Head, Tail, Len) layout. +// the expected (Head, Tail, Len[, ExpireAtMs]) layout. var ErrRedisInvalidListMeta = cockroachdberr.New("backup: invalid !lst|meta| value") // ErrRedisInvalidListKey is returned when an !lst| key cannot be parsed @@ -72,18 +72,19 @@ var ErrRedisInvalidListKey = cockroachdberr.New("backup: malformed !lst| key") // flush time matches the live store's left-to-right order regardless // of the order in which !lst|itm| records arrive at the dispatcher. type redisListState struct { - metaSeen bool - declaredLen int64 - items map[int64][]byte - expireAtMs uint64 - hasTTL bool + metaSeen bool + declaredLen int64 + items map[int64][]byte + expireAtMs uint64 + hasTTL bool + inlineTTLOwned bool } // HandleListMeta processes one !lst|meta| record. The value is -// the 24-byte (Head, Tail, Len) layout. We park the declared length so -// flushLists can warn on a mismatch with the observed item count and -// register the user key so a later !redis|ttl| record routes -// back to this list state. +// the 24-byte (Head, Tail, Len) layout, optionally followed by inline +// expireAtMs. We park the declared length so flushLists can warn on a +// mismatch with the observed item count and register the user key so a +// later !redis|ttl| record routes back to this list state. // // !lst|meta|d|... delta keys share the !lst|meta| string // prefix, so a snapshot dispatcher that routes by "starts with @@ -100,9 +101,9 @@ func (r *RedisDB) HandleListMeta(key, value []byte) error { if !ok { return cockroachdberr.Wrapf(ErrRedisInvalidListKey, "meta key: %q", key) } - if len(value) != listMetaBinarySize { + if len(value) != listMetaBinarySize && len(value) != listMetaInlineTTLSize { return cockroachdberr.Wrapf(ErrRedisInvalidListMeta, - "length %d != %d", len(value), listMetaBinarySize) + "length %d not in {%d,%d}", len(value), listMetaBinarySize, listMetaInlineTTLSize) } // Length is the only field needed at backup time. Head/Tail are // recomputable from the observed seqs and the live store's @@ -116,6 +117,12 @@ func (r *RedisDB) HandleListMeta(key, value []byte) error { st := r.listState(userKey) st.declaredLen = int64(rawLen) //nolint:gosec // bounds-checked above st.metaSeen = true + if len(value) == listMetaInlineTTLSize { + expireAtMs := binary.BigEndian.Uint64(value[24:32]) + st.expireAtMs = expireAtMs + st.hasTTL = expireAtMs != 0 + st.inlineTTLOwned = true + } return nil } diff --git a/internal/backup/redis_meta.go b/internal/backup/redis_meta.go new file mode 100644 index 000000000..4e02fda2e --- /dev/null +++ b/internal/backup/redis_meta.go @@ -0,0 +1,28 @@ +package backup + +import ( + "encoding/binary" + "math" + + cockroachdberr "github.com/cockroachdb/errors" +) + +const redisCountMetaInlineBytes = redisUint64Bytes * 2 + +func decodeRedisCountMeta(value []byte, invalidMeta error) (declaredLen int64, expireAtMs uint64, hasTTL bool, inlineTTL bool, err error) { + if len(value) != redisUint64Bytes && len(value) != redisCountMetaInlineBytes { + return 0, 0, false, false, cockroachdberr.Wrapf(invalidMeta, + "length %d not in {%d,%d}", len(value), redisUint64Bytes, redisCountMetaInlineBytes) + } + rawLen := binary.BigEndian.Uint64(value[0:redisUint64Bytes]) + if rawLen > math.MaxInt64 { + return 0, 0, false, false, cockroachdberr.Wrapf(invalidMeta, + "declared len %d overflows int64", rawLen) + } + if len(value) == redisCountMetaInlineBytes { + inlineTTL = true + expireAtMs = binary.BigEndian.Uint64(value[redisUint64Bytes:redisCountMetaInlineBytes]) + hasTTL = expireAtMs != 0 + } + return int64(rawLen), expireAtMs, hasTTL, inlineTTL, nil //nolint:gosec // rawLen is bounded above. +} diff --git a/internal/backup/redis_set.go b/internal/backup/redis_set.go index 392ae8ce1..16a7ee004 100644 --- a/internal/backup/redis_set.go +++ b/internal/backup/redis_set.go @@ -2,9 +2,7 @@ package backup import ( "bytes" - "encoding/binary" "encoding/json" - "math" "path/filepath" "sort" @@ -31,7 +29,7 @@ const ( ) // ErrRedisInvalidSetMeta is returned when an !st|meta| value is not -// the expected 8-byte big-endian member count. +// the expected big-endian member count, optionally followed by inline TTL. var ErrRedisInvalidSetMeta = cockroachdberr.New("backup: invalid !st|meta| value") // ErrRedisInvalidSetKey is returned when an !st| key cannot be parsed @@ -44,18 +42,19 @@ var ErrRedisInvalidSetKey = cockroachdberr.New("backup: malformed !st| key") // that re-emits a member is harmless — Redis sets are mathematical // sets, not multisets). type redisSetState struct { - metaSeen bool - declaredLen int64 - members map[string]struct{} - expireAtMs uint64 - hasTTL bool + metaSeen bool + declaredLen int64 + members map[string]struct{} + expireAtMs uint64 + hasTTL bool + inlineTTLOwned bool } // HandleSetMeta processes one !st|meta| record. The -// value is the 8-byte BE member count. We park the declared length -// so flushSets can warn on a mismatch with the observed member -// count and register the user key so a later !redis|ttl| -// record routes back to this set state. +// value is the 8-byte BE member count, optionally followed by an inline +// expireAtMs. We park the declared length so flushSets can warn on a +// mismatch with the observed member count and register the user key so a +// later !redis|ttl| record routes back to this set state. // // !st|meta|d|... delta keys share the !st|meta| string prefix, so a // snapshot dispatcher that routes by "starts with RedisSetMetaPrefix" @@ -71,23 +70,23 @@ func (r *RedisDB) HandleSetMeta(key, value []byte) error { if !ok { return cockroachdberr.Wrapf(ErrRedisInvalidSetKey, "meta key: %q", key) } - if len(value) != redisUint64Bytes { - return cockroachdberr.Wrapf(ErrRedisInvalidSetMeta, - "length %d != %d", len(value), redisUint64Bytes) - } // Bounds-check the uint64 declared count before narrowing to // int64; without this a corrupted store with the high bit set // would wrap to a negative declaredLen and fire spurious // redis_set_length_mismatch warnings on every flush. Mirrors // the hash + list encoders' symmetric guard. - rawLen := binary.BigEndian.Uint64(value) - if rawLen > math.MaxInt64 { - return cockroachdberr.Wrapf(ErrRedisInvalidSetMeta, - "declared len %d overflows int64", rawLen) + declaredLen, expireAtMs, hasTTL, inlineTTL, err := decodeRedisCountMeta(value, ErrRedisInvalidSetMeta) + if err != nil { + return err } st := r.setState(userKey) - st.declaredLen = int64(rawLen) //nolint:gosec // bounds-checked above + st.declaredLen = declaredLen st.metaSeen = true + if inlineTTL { + st.expireAtMs = expireAtMs + st.hasTTL = hasTTL + st.inlineTTLOwned = true + } return nil } diff --git a/internal/backup/redis_stream.go b/internal/backup/redis_stream.go index 16e1f691e..eb82f532f 100644 --- a/internal/backup/redis_stream.go +++ b/internal/backup/redis_stream.go @@ -22,7 +22,7 @@ import ( // Wire format mirrors store/stream_helpers.go and // adapter/redis_storage_codec.go: // - !stream|meta| -// → 24-byte BE Length(8) || LastMs(8) || LastSeq(8) +// → BE Length(8) || LastMs(8) || LastSeq(8) [|| ExpireAtMs(8)] // - !stream|entry| // → magic-prefixed pb.RedisStreamEntry protobuf with fields // {id string, fields []string} where Fields is the @@ -49,11 +49,11 @@ const ( RedisStreamMetaPrefix = "!stream|meta|" RedisStreamEntryPrefix = "!stream|entry|" - // redisStreamMetaSize is the on-disk size of one !stream|meta| - // value: Length(8) || LastMs(8) || LastSeq(8). Mirrors - // store.streamMetaBinarySize; duplicated here to keep the backup - // package free of `store` imports. + // redisStreamMetaSize is the legacy on-disk size of one !stream|meta| + // value: Length(8) || LastMs(8) || LastSeq(8). redisStreamMetaSize = 24 + // redisStreamMetaInlineTTLSize is the current shape with trailing ExpireAtMs(8). + redisStreamMetaInlineTTLSize = 32 // redisStreamIDBytes is the per-entry-key suffix size: ms(8) // || seq(8). Mirrors store.StreamIDBytes. @@ -71,7 +71,7 @@ const ( var redisStreamProtoPrefix = []byte{0x00, 'R', 'X', 'E', 0x01} // ErrRedisInvalidStreamMeta is returned when an !stream|meta| value -// is not the expected 24 bytes or carries a negative length. +// is not the expected metadata shape or carries a negative length. var ErrRedisInvalidStreamMeta = cockroachdberr.New("backup: invalid !stream|meta| value") // ErrRedisInvalidStreamEntry is returned when an !stream|entry| @@ -99,30 +99,30 @@ type redisStreamEntry struct { // state in memory; a single stream is bounded by maxWideColumnItems // on the live side, so this remains tractable. type redisStreamState struct { - metaSeen bool - length int64 - lastMs uint64 - lastSeq uint64 - entries []redisStreamEntry - expireAtMs uint64 - hasTTL bool + metaSeen bool + length int64 + lastMs uint64 + lastSeq uint64 + entries []redisStreamEntry + expireAtMs uint64 + hasTTL bool + inlineTTLOwned bool } // HandleStreamMeta processes one !stream|meta| record. -// Value layout: Length(8) || LastMs(8) || LastSeq(8). The encoder -// uses the meta's last_ms / last_seq verbatim in the JSONL _meta -// terminator so a restorer can replay them into the same XADD '*' -// monotonicity window. Length mismatches against the observed -// entry count surface as `redis_stream_length_mismatch` at flush -// time. +// Value layout: Length(8) || LastMs(8) || LastSeq(8), optionally followed +// by inline ExpireAtMs(8). The encoder uses the meta's last_ms / last_seq +// verbatim in the JSONL _meta terminator so a restorer can replay them into +// the same XADD '*' monotonicity window. Length mismatches against the +// observed entry count surface as `redis_stream_length_mismatch` at flush time. func (r *RedisDB) HandleStreamMeta(key, value []byte) error { userKey, ok := parseStreamMetaKey(key) if !ok { return cockroachdberr.Wrapf(ErrRedisInvalidStreamKey, "meta key: %q", key) } - if len(value) != redisStreamMetaSize { + if len(value) != redisStreamMetaSize && len(value) != redisStreamMetaInlineTTLSize { return cockroachdberr.Wrapf(ErrRedisInvalidStreamMeta, - "length %d != %d", len(value), redisStreamMetaSize) + "length %d not in {%d,%d}", len(value), redisStreamMetaSize, redisStreamMetaInlineTTLSize) } rawLen := binary.BigEndian.Uint64(value[0:8]) if rawLen > math.MaxInt64 { @@ -134,6 +134,12 @@ func (r *RedisDB) HandleStreamMeta(key, value []byte) error { st.lastMs = binary.BigEndian.Uint64(value[8:16]) st.lastSeq = binary.BigEndian.Uint64(value[16:24]) st.metaSeen = true + if len(value) == redisStreamMetaInlineTTLSize { + expireAtMs := binary.BigEndian.Uint64(value[24:32]) + st.expireAtMs = expireAtMs + st.hasTTL = expireAtMs != 0 + st.inlineTTLOwned = true + } return nil } diff --git a/internal/backup/redis_string.go b/internal/backup/redis_string.go index 322ef5f76..fbf41fe98 100644 --- a/internal/backup/redis_string.go +++ b/internal/backup/redis_string.go @@ -43,6 +43,15 @@ const ( redisStrBaseHeader = 3 redisUint64Bytes = 8 + // redisHLLMagic / redisHLLVersion / redisHLLHasTTL / + // redisHLLBaseHeader mirror adapter/redis_compat_types.go. HLLs use + // a distinct version byte because the payload is a stored set sketch, + // not a Redis string body. + redisHLLMagic byte = 0xFF + redisHLLVersion byte = 0x02 + redisHLLHasTTL byte = 0x01 + redisHLLBaseHeader = 3 + // wideColumnUserKeyLenSize is the fixed BE-uint32 width of the // per-key length prefix used by every wide-column key shape // (!hs|, !st|, !zs|, !stream|, and any future families that @@ -68,6 +77,11 @@ const ( // (no-magic) values are accepted as opaque raw bytes. var ErrRedisInvalidStringValue = cockroachdberr.New("backup: invalid !redis|str| value") +// ErrRedisInvalidHLLValue is returned when a !redis|hll| value uses the +// new magic-prefix format but its declared TTL section is truncated. Legacy +// (no-magic) values are accepted as opaque raw bytes. +var ErrRedisInvalidHLLValue = cockroachdberr.New("backup: invalid !redis|hll| value") + // ErrRedisInvalidTTLValue is returned when a !redis|ttl| value is not the // expected 8-byte big-endian uint64 millisecond expiry. var ErrRedisInvalidTTLValue = cockroachdberr.New("backup: invalid !redis|ttl| value") @@ -170,17 +184,12 @@ type RedisDB struct { // stat+mkdir(EEXIST) round-trips. dirsCreated map[string]struct{} - // inlineTTLEmitted tracks string keys whose TTL was already - // extracted from the inline magic-prefix header by HandleString and - // written to strings_ttl.jsonl. The live Redis encoder emits BOTH - // `!redis|str|` (with inline TTL) and `!redis|ttl|` (the - // scan-index entry the sweeper consumes) for an expiring string - // (see adapter/redis_lua_context.go stringCommitElems). Without - // this set, HandleTTL would route the redundant `!redis|ttl|` - // record back into the same sidecar, duplicating the entry and - // violating the one-record-per-key contract sidecar consumers - // rely on. Codex P1 round 5. - inlineTTLEmitted map[string]struct{} + // inlineTTLOwned tracks simple keys whose current-format magic-prefix + // header is authoritative for TTL, including explicit no-TTL anchors. + // The live Redis encoder may also emit `!redis|ttl|` as a scan index + // for the sweeper. Without this set, HandleTTL would route stale or + // redundant scan-index records back into the sidecar. + inlineTTLOwned map[string]struct{} // warn is the structured-warning sink. Non-nil in production // (fed by the decoder driver); nil in tests if the test does not @@ -324,7 +333,7 @@ func NewRedisDB(outRoot string, dbIndex int) *RedisDB { dbIndex: dbIndex, kindByKey: make(map[string]redisKeyKind), dirsCreated: make(map[string]struct{}), - inlineTTLEmitted: make(map[string]struct{}), + inlineTTLOwned: make(map[string]struct{}), hashes: make(map[string]*redisHashState), lists: make(map[string]*redisListState), sets: make(map[string]*redisSetState), @@ -368,6 +377,7 @@ func (r *RedisDB) WithWarnSink(fn func(event string, fields ...any)) *RedisDB { // the TTL — if any — to strings_ttl.jsonl. func (r *RedisDB) HandleString(userKey, value []byte) error { r.kindByKey[string(userKey)] = redisKindString + newFormat := isNewRedisStrFormat(value) userValue, expireAtMs, err := decodeRedisStringValue(value) if err != nil { return err @@ -375,23 +385,36 @@ func (r *RedisDB) HandleString(userKey, value []byte) error { if err := r.writeBlob("strings", userKey, userValue); err != nil { return err } + if newFormat { + r.inlineTTLOwned[string(userKey)] = struct{}{} + } if expireAtMs == 0 { return nil } - // Mark the key as already emitted inline so HandleTTL can drop the - // redundant !redis|ttl| scan-index record; otherwise the same - // expiring string would be written to strings_ttl.jsonl twice. - r.inlineTTLEmitted[string(userKey)] = struct{}{} return r.appendTTL(&r.stringsTTL, redisStringsTTLFile, userKey, expireAtMs) } -// HandleHLL processes one !redis|hll| record. The value is the -// raw HLL sketch bytes, written byte-for-byte to hll/.bin. TTL -// for HLL keys lives in !redis|ttl| and is consumed by -// HandleTTL. +// HandleHLL processes one !redis|hll| record. Legacy values are raw +// HLL sketch bytes. New values may carry a magic-prefix TTL envelope; the +// decoded dump remains the user-facing raw sketch in hll/.bin, with +// any embedded TTL written to hll_ttl.jsonl. func (r *RedisDB) HandleHLL(userKey, value []byte) error { r.kindByKey[string(userKey)] = redisKindHLL - return r.writeBlob("hll", userKey, value) + newFormat := isNewRedisHLLFormat(value) + sketch, expireAtMs, err := decodeRedisHLLValue(value) + if err != nil { + return err + } + if err := r.writeBlob("hll", userKey, sketch); err != nil { + return err + } + if newFormat { + r.inlineTTLOwned[string(userKey)] = struct{}{} + } + if expireAtMs == 0 { + return nil + } + return r.appendTTL(&r.hllTTL, redisHLLTTLFile, userKey, expireAtMs) } // HandleTTL processes one !redis|ttl| record. Routing @@ -427,6 +450,9 @@ func (r *RedisDB) HandleTTL(userKey, value []byte) error { } switch r.kindByKey[string(userKey)] { case redisKindHLL: + if _, ok := r.inlineTTLOwned[string(userKey)]; ok { + return nil + } return r.appendTTL(&r.hllTTL, redisHLLTTLFile, userKey, expireAtMs) case redisKindString: // New-format strings carry TTL inline in the magic-prefix @@ -435,7 +461,7 @@ func (r *RedisDB) HandleTTL(userKey, value []byte) error { // the sweeper consumes is redundant for backup output. Only // legacy strings (no inline TTL) reach the appendTTL call. // Codex P1 round 5. - if _, ok := r.inlineTTLEmitted[string(userKey)]; ok { + if _, ok := r.inlineTTLOwned[string(userKey)]; ok { return nil } return r.appendTTL(&r.stringsTTL, redisStringsTTLFile, userKey, expireAtMs) @@ -459,25 +485,15 @@ func (r *RedisDB) HandleTTL(userKey, value []byte) error { func (r *RedisDB) inlineWideColumnTTL(kind redisKeyKind, userKey []byte, expireAtMs uint64) { switch kind { case redisKindHash: - st := r.hashState(userKey) - st.expireAtMs = expireAtMs - st.hasTTL = true + r.inlineHashTTL(userKey, expireAtMs) case redisKindList: - st := r.listState(userKey) - st.expireAtMs = expireAtMs - st.hasTTL = true + r.inlineListTTL(userKey, expireAtMs) case redisKindSet: - st := r.setState(userKey) - st.expireAtMs = expireAtMs - st.hasTTL = true + r.inlineSetTTL(userKey, expireAtMs) case redisKindZSet: - st := r.zsetState(userKey) - st.expireAtMs = expireAtMs - st.hasTTL = true + r.inlineZSetTTL(userKey, expireAtMs) case redisKindStream: - st := r.streamState(userKey) - st.expireAtMs = expireAtMs - st.hasTTL = true + r.inlineStreamTTL(userKey, expireAtMs) case redisKindUnknown, redisKindString, redisKindHLL: // Unreachable: HandleTTL filters to wide-column kinds // before calling this helper. Listed explicitly to satisfy @@ -485,6 +501,51 @@ func (r *RedisDB) inlineWideColumnTTL(kind redisKeyKind, userKey []byte, expireA } } +func (r *RedisDB) inlineHashTTL(userKey []byte, expireAtMs uint64) { + st := r.hashState(userKey) + if st.inlineTTLOwned { + return + } + st.expireAtMs = expireAtMs + st.hasTTL = true +} + +func (r *RedisDB) inlineListTTL(userKey []byte, expireAtMs uint64) { + st := r.listState(userKey) + if st.inlineTTLOwned { + return + } + st.expireAtMs = expireAtMs + st.hasTTL = true +} + +func (r *RedisDB) inlineSetTTL(userKey []byte, expireAtMs uint64) { + st := r.setState(userKey) + if st.inlineTTLOwned { + return + } + st.expireAtMs = expireAtMs + st.hasTTL = true +} + +func (r *RedisDB) inlineZSetTTL(userKey []byte, expireAtMs uint64) { + st := r.zsetState(userKey) + if st.inlineTTLOwned { + return + } + st.expireAtMs = expireAtMs + st.hasTTL = true +} + +func (r *RedisDB) inlineStreamTTL(userKey []byte, expireAtMs uint64) { + st := r.streamState(userKey) + if st.inlineTTLOwned { + return + } + st.expireAtMs = expireAtMs + st.hasTTL = true +} + // parkUnknownTTL buffers a redisKindUnknown TTL into pendingTTL, or // fails closed when the buffer is at cap. Extracted from HandleTTL's // switch so the parent stays under the cyclop budget. @@ -923,6 +984,38 @@ func isNewRedisStrFormat(raw []byte) bool { raw[0] == redisStrMagic && raw[1] == redisStrVersion } +// decodeRedisHLLValue strips the redis-HLL magic-prefix TTL header (if +// present) from a !redis|hll| value and returns (sketch, expireAtMs). +// expireAtMs == 0 means "no inline TTL"; legacy values always return 0 here +// because their TTL lives in !redis|ttl|. +func decodeRedisHLLValue(value []byte) ([]byte, uint64, error) { + if !isNewRedisHLLFormat(value) { + return value, 0, nil + } + if len(value) < redisHLLBaseHeader { + return nil, 0, cockroachdberr.Wrap(ErrRedisInvalidHLLValue, "header truncated") + } + flags := value[2] + rest := value[redisHLLBaseHeader:] + if flags&redisHLLHasTTL == 0 { + return rest, 0, nil + } + if len(rest) < redisUint64Bytes { + return nil, 0, cockroachdberr.Wrap(ErrRedisInvalidHLLValue, "ttl section truncated") + } + rawMs := binary.BigEndian.Uint64(rest[:redisUint64Bytes]) + expireAtMs := rawMs + if expireAtMs > math.MaxInt64 { + expireAtMs = math.MaxInt64 + } + return rest[redisUint64Bytes:], expireAtMs, nil +} + +func isNewRedisHLLFormat(raw []byte) bool { + return len(raw) >= 2 && //nolint:mnd // 2 == magic + version length + raw[0] == redisHLLMagic && raw[1] == redisHLLVersion +} + func decodeRedisTTLValue(raw []byte) (uint64, error) { if len(raw) != redisUint64Bytes { return 0, cockroachdberr.Wrapf(ErrRedisInvalidTTLValue, diff --git a/internal/backup/redis_string_test.go b/internal/backup/redis_string_test.go index eb4564c5d..7253ff14e 100644 --- a/internal/backup/redis_string_test.go +++ b/internal/backup/redis_string_test.go @@ -40,6 +40,21 @@ func encodeNewStringValue(t *testing.T, value []byte, expireAtMs uint64) []byte return append(header, body...) } +func encodeNewHLLValue(t *testing.T, value []byte, expireAtMs uint64) []byte { + t.Helper() + flags := byte(0) + header := []byte{redisHLLMagic, redisHLLVersion, flags} + body := value + if expireAtMs > 0 { + flags = redisHLLHasTTL + header[2] = flags + var ttl [redisUint64Bytes]byte + binary.BigEndian.PutUint64(ttl[:], expireAtMs) + header = append(header, ttl[:]...) + } + return append(header, body...) +} + func encodeTTLValue(expireAtMs uint64) []byte { var b [redisUint64Bytes]byte binary.BigEndian.PutUint64(b[:], expireAtMs) @@ -160,6 +175,54 @@ func TestRedisDB_HandleHLL_WritesRawSketch(t *testing.T) { } } +func TestRedisDB_HandleHLL_NewFormatStripsEnvelopeAndDeduplicatesTTL(t *testing.T) { + t.Parallel() + db, root := newRedisDB(t) + sketch := []byte{0xde, 0xad, 0xbe, 0xef} + if err := db.HandleHLL([]byte("uniques"), encodeNewHLLValue(t, sketch, fixedExpireMs)); err != nil { + t.Fatalf("HandleHLL: %v", err) + } + if err := db.HandleTTL([]byte("uniques"), encodeTTLValue(fixedExpireMs)); err != nil { + t.Fatalf("HandleTTL: %v", err) + } + if err := db.Finalize(); err != nil { + t.Fatalf("Finalize: %v", err) + } + body := readBlob(t, filepath.Join(root, "redis", "db_0", "hll", "uniques.bin")) + if !bytes.Equal(body, sketch) { + t.Fatalf("hll blob = %x want %x", body, sketch) + } + recs := readTTLJSONL(t, filepath.Join(root, "redis", "db_0", "hll_ttl.jsonl")) + if len(recs) != 1 { + t.Fatalf("hll_ttl records = %d, want 1", len(recs)) + } + if recs[0].Key != "uniques" || recs[0].ExpireAtMs != fixedExpireMs { + t.Fatalf("hll_ttl[0] = %#v, want key uniques ms %d", recs[0], fixedExpireMs) + } +} + +func TestRedisDB_HandleHLL_NewFormatNoTTLIgnoresScanIndex(t *testing.T) { + t.Parallel() + db, root := newRedisDB(t) + sketch := []byte{0xca, 0xfe} + if err := db.HandleHLL([]byte("fresh"), encodeNewHLLValue(t, sketch, 0)); err != nil { + t.Fatalf("HandleHLL: %v", err) + } + if err := db.HandleTTL([]byte("fresh"), encodeTTLValue(fixedExpireMs)); err != nil { + t.Fatalf("HandleTTL: %v", err) + } + if err := db.Finalize(); err != nil { + t.Fatalf("Finalize: %v", err) + } + body := readBlob(t, filepath.Join(root, "redis", "db_0", "hll", "fresh.bin")) + if !bytes.Equal(body, sketch) { + t.Fatalf("hll blob = %x want %x", body, sketch) + } + if _, err := os.Stat(filepath.Join(root, "redis", "db_0", "hll_ttl.jsonl")); !os.IsNotExist(err) { + t.Fatalf("expected no hll_ttl.jsonl for no-TTL HLL envelope, stat err=%v", err) + } +} + func assertTTLSidecar(t *testing.T, path string, wantKey string, wantMs uint64) { t.Helper() recs := readTTLJSONL(t, path) diff --git a/internal/backup/redis_zset.go b/internal/backup/redis_zset.go index 8a4191594..7ef086843 100644 --- a/internal/backup/redis_zset.go +++ b/internal/backup/redis_zset.go @@ -108,19 +108,20 @@ var ErrRedisInvalidZSetKey = cockroachdberr.New("backup: malformed !zs| key") // wide-column source-of-truth, surfacing deleted or outdated // entries in the restored zset. Codex P1 round 3 (PR #790). type redisZSetState struct { - metaSeen bool - declaredLen int64 - members map[string]float64 - sawWide bool - expireAtMs uint64 - hasTTL bool + metaSeen bool + declaredLen int64 + members map[string]float64 + sawWide bool + expireAtMs uint64 + hasTTL bool + inlineTTLOwned bool } // HandleZSetMeta processes one !zs|meta| record. The -// value is the 8-byte BE member count. We park the declared length so -// flushZSets can warn on a mismatch with the observed member count -// and register the user key so a later !redis|ttl| record -// routes back to this zset state. +// value is the 8-byte BE member count, optionally followed by an inline +// expireAtMs. We park the declared length so flushZSets can warn on a +// mismatch with the observed member count and register the user key so a +// later !redis|ttl| record routes back to this zset state. // // !zs|meta|d|... delta keys share the !zs|meta| string prefix, so a // snapshot dispatcher that routes by "starts with RedisZSetMetaPrefix" @@ -136,23 +137,23 @@ func (r *RedisDB) HandleZSetMeta(key, value []byte) error { if !ok { return cockroachdberr.Wrapf(ErrRedisInvalidZSetKey, "meta key: %q", key) } - if len(value) != redisUint64Bytes { - return cockroachdberr.Wrapf(ErrRedisInvalidZSetMeta, - "length %d != %d", len(value), redisUint64Bytes) - } // Bounds-check the uint64 declared count before narrowing to // int64; without this a corrupted store with the high bit set // would wrap to a negative declaredLen and fire spurious // redis_zset_length_mismatch warnings on every flush. Mirrors // the hash/list/set encoders' symmetric guard. - rawLen := binary.BigEndian.Uint64(value) - if rawLen > math.MaxInt64 { - return cockroachdberr.Wrapf(ErrRedisInvalidZSetMeta, - "declared len %d overflows int64", rawLen) + declaredLen, expireAtMs, hasTTL, inlineTTL, err := decodeRedisCountMeta(value, ErrRedisInvalidZSetMeta) + if err != nil { + return err } st := r.zsetState(userKey) - st.declaredLen = int64(rawLen) //nolint:gosec // bounds-checked above + st.declaredLen = declaredLen st.metaSeen = true + if inlineTTL { + st.expireAtMs = expireAtMs + st.hasTTL = hasTTL + st.inlineTTLOwned = true + } // Codex P2 (PR #790 r9): do NOT call markZSetWide here. The // live read path (adapter/redis_compat_helpers.go:610-621) // switches to the wide-column layout only when the !zs|mem| diff --git a/store/hash_helpers.go b/store/hash_helpers.go index 76d5f53ec..0ee863a24 100644 --- a/store/hash_helpers.go +++ b/store/hash_helpers.go @@ -10,7 +10,7 @@ import ( // Hash wide-column key layout: // -// Base Metadata: !hs|meta| → [Len(8)] +// Base Metadata: !hs|meta| → [Len(8)][ExpireAtMs(8)] // Field Key: !hs|fld| → field value bytes // Delta Key: !hs|meta|d| → [LenDelta(8)] const ( @@ -18,13 +18,18 @@ const ( HashFieldPrefix = "!hs|fld|" HashMetaDeltaPrefix = "!hs|meta|d|" - // hashMetaSizeBytes is the fixed binary size of a HashMeta or HashMetaDelta (one int64). - hashMetaSizeBytes = 8 + // hashMetaLegacySizeBytes is the pre-inline-TTL binary size of a HashMeta. + hashMetaLegacySizeBytes = 8 + // hashMetaSizeBytes is the current binary size of a HashMeta (Len + ExpireAtMs). + hashMetaSizeBytes = 16 + // hashMetaDeltaSizeBytes is the fixed binary size of a HashMetaDelta (one int64). + hashMetaDeltaSizeBytes = 8 ) // HashMeta is the base metadata for a hash collection. type HashMeta struct { - Len int64 + Len int64 + ExpireAt uint64 } // HashMetaDelta holds a signed change in field count. @@ -32,31 +37,36 @@ type HashMetaDelta struct { LenDelta int64 } -// MarshalHashMeta encodes HashMeta into a fixed 8-byte binary format. +// MarshalHashMeta encodes HashMeta into a fixed 16-byte binary format. func MarshalHashMeta(m HashMeta) []byte { buf := make([]byte, hashMetaSizeBytes) binary.BigEndian.PutUint64(buf, uint64(m.Len)) //nolint:gosec + binary.BigEndian.PutUint64(buf[8:16], m.ExpireAt) return buf } -// UnmarshalHashMeta decodes HashMeta from the fixed 8-byte binary format. +// UnmarshalHashMeta decodes HashMeta from the legacy 8-byte or current 16-byte binary format. func UnmarshalHashMeta(b []byte) (HashMeta, error) { - if len(b) != hashMetaSizeBytes { + if len(b) != hashMetaLegacySizeBytes && len(b) != hashMetaSizeBytes { return HashMeta{}, errors.WithStack(errors.Newf("invalid hash meta length: %d", len(b))) } - return HashMeta{Len: int64(binary.BigEndian.Uint64(b))}, nil //nolint:gosec + meta := HashMeta{Len: int64(binary.BigEndian.Uint64(b[0:8]))} //nolint:gosec + if len(b) == hashMetaSizeBytes { + meta.ExpireAt = binary.BigEndian.Uint64(b[8:16]) + } + return meta, nil } // MarshalHashMetaDelta encodes HashMetaDelta into a fixed 8-byte binary format. func MarshalHashMetaDelta(d HashMetaDelta) []byte { - buf := make([]byte, hashMetaSizeBytes) + buf := make([]byte, hashMetaDeltaSizeBytes) binary.BigEndian.PutUint64(buf, uint64(d.LenDelta)) //nolint:gosec return buf } // UnmarshalHashMetaDelta decodes HashMetaDelta from the fixed 8-byte binary format. func UnmarshalHashMetaDelta(b []byte) (HashMetaDelta, error) { - if len(b) != hashMetaSizeBytes { + if len(b) != hashMetaDeltaSizeBytes { return HashMetaDelta{}, errors.WithStack(errors.Newf("invalid hash meta delta length: %d", len(b))) } return HashMetaDelta{LenDelta: int64(binary.BigEndian.Uint64(b))}, nil //nolint:gosec diff --git a/store/list_helpers.go b/store/list_helpers.go index 051233724..0f56f6878 100644 --- a/store/list_helpers.go +++ b/store/list_helpers.go @@ -174,19 +174,21 @@ func PrefixScanEnd(prefix []byte) []byte { // Wide-column style list storage using per-element keys. // Item keys: !lst|itm| -// Meta key : !lst|meta| -> [Head(8)][Tail(8)][Len(8)] +// Meta key : !lst|meta| -> [Head(8)][Tail(8)][Len(8)][ExpireAtMs(8)] const ( ListMetaPrefix = "!lst|meta|" ListItemPrefix = "!lst|itm|" - listMetaBinarySize = 24 + listMetaLegacyBinarySize = 24 + listMetaBinarySize = 32 ) type ListMeta struct { - Head int64 `json:"h"` - Tail int64 `json:"t"` - Len int64 `json:"l"` + Head int64 `json:"h"` + Tail int64 `json:"t"` + Len int64 `json:"l"` + ExpireAt uint64 `json:"e,omitempty"` } // ListMetaKey builds the metadata key for a user key. @@ -206,10 +208,10 @@ func ListItemKey(userKey []byte, seq int64) []byte { return buf } -// MarshalListMeta encodes ListMeta into a fixed 24-byte binary format. +// MarshalListMeta encodes ListMeta into a fixed 32-byte binary format. func MarshalListMeta(meta ListMeta) ([]byte, error) { return marshalListMeta(meta) } -// UnmarshalListMeta decodes ListMeta from the fixed 24-byte binary format. +// UnmarshalListMeta decodes ListMeta from the legacy 24-byte or current 32-byte binary format. func UnmarshalListMeta(b []byte) (ListMeta, error) { return unmarshalListMeta(b) } func marshalListMeta(meta ListMeta) ([]byte, error) { @@ -226,11 +228,12 @@ func marshalListMeta(meta ListMeta) ([]byte, error) { binary.BigEndian.PutUint64(buf[0:8], uint64(meta.Head)) //nolint:gosec // Head can be negative after LPUSH; uint64 cast preserves bits binary.BigEndian.PutUint64(buf[8:16], uint64(meta.Tail)) //nolint:gosec // Tail = Head + Len, may be negative binary.BigEndian.PutUint64(buf[16:24], uint64(meta.Len)) + binary.BigEndian.PutUint64(buf[24:32], meta.ExpireAt) return buf, nil } func unmarshalListMeta(b []byte) (ListMeta, error) { - if len(b) != listMetaBinarySize { + if len(b) != listMetaLegacyBinarySize && len(b) != listMetaBinarySize { return ListMeta{}, errors.Wrap(errors.Newf("invalid list meta length: %d", len(b)), "unmarshal list meta") } @@ -252,11 +255,15 @@ func unmarshalListMeta(b []byte) (ListMeta, error) { return ListMeta{}, errors.WithStack(errors.Newf("list meta invariant violated: tail (%d) != head+len (%d)", tail, expectedTail)) } - return ListMeta{ + meta := ListMeta{ Head: head, Tail: tail, Len: iLen, - }, nil + } + if len(b) == listMetaBinarySize { + meta.ExpireAt = binary.BigEndian.Uint64(b[24:32]) + } + return meta, nil } // encodeSortableInt64 writes seq with sign bit flipped (seq ^ minInt64) in big-endian order. diff --git a/store/set_helpers.go b/store/set_helpers.go index 44da629b4..318581ab7 100644 --- a/store/set_helpers.go +++ b/store/set_helpers.go @@ -10,7 +10,7 @@ import ( // Set wide-column key layout: // -// Base Metadata: !st|meta| → [Len(8)] +// Base Metadata: !st|meta| → [Len(8)][ExpireAtMs(8)] // Member Key: !st|mem| → (empty value) // Delta Key: !st|meta|d| → [LenDelta(8)] const ( @@ -18,13 +18,18 @@ const ( SetMemberPrefix = "!st|mem|" SetMetaDeltaPrefix = "!st|meta|d|" - // setMetaSizeBytes is the fixed binary size of a SetMeta or SetMetaDelta (one int64). - setMetaSizeBytes = 8 + // setMetaLegacySizeBytes is the pre-inline-TTL binary size of a SetMeta. + setMetaLegacySizeBytes = 8 + // setMetaSizeBytes is the current binary size of a SetMeta (Len + ExpireAtMs). + setMetaSizeBytes = 16 + // setMetaDeltaSizeBytes is the fixed binary size of a SetMetaDelta (one int64). + setMetaDeltaSizeBytes = 8 ) // SetMeta is the base metadata for a set collection. type SetMeta struct { - Len int64 + Len int64 + ExpireAt uint64 } // SetMetaDelta holds a signed change in member count. @@ -32,31 +37,36 @@ type SetMetaDelta struct { LenDelta int64 } -// MarshalSetMeta encodes SetMeta into a fixed 8-byte binary format. +// MarshalSetMeta encodes SetMeta into a fixed 16-byte binary format. func MarshalSetMeta(m SetMeta) []byte { buf := make([]byte, setMetaSizeBytes) binary.BigEndian.PutUint64(buf, uint64(m.Len)) //nolint:gosec + binary.BigEndian.PutUint64(buf[8:16], m.ExpireAt) return buf } -// UnmarshalSetMeta decodes SetMeta from the fixed 8-byte binary format. +// UnmarshalSetMeta decodes SetMeta from the legacy 8-byte or current 16-byte binary format. func UnmarshalSetMeta(b []byte) (SetMeta, error) { - if len(b) != setMetaSizeBytes { + if len(b) != setMetaLegacySizeBytes && len(b) != setMetaSizeBytes { return SetMeta{}, errors.WithStack(errors.Newf("invalid set meta length: %d", len(b))) } - return SetMeta{Len: int64(binary.BigEndian.Uint64(b))}, nil //nolint:gosec + meta := SetMeta{Len: int64(binary.BigEndian.Uint64(b[0:8]))} //nolint:gosec + if len(b) == setMetaSizeBytes { + meta.ExpireAt = binary.BigEndian.Uint64(b[8:16]) + } + return meta, nil } // MarshalSetMetaDelta encodes SetMetaDelta into a fixed 8-byte binary format. func MarshalSetMetaDelta(d SetMetaDelta) []byte { - buf := make([]byte, setMetaSizeBytes) + buf := make([]byte, setMetaDeltaSizeBytes) binary.BigEndian.PutUint64(buf, uint64(d.LenDelta)) //nolint:gosec return buf } // UnmarshalSetMetaDelta decodes SetMetaDelta from the fixed 8-byte binary format. func UnmarshalSetMetaDelta(b []byte) (SetMetaDelta, error) { - if len(b) != setMetaSizeBytes { + if len(b) != setMetaDeltaSizeBytes { return SetMetaDelta{}, errors.WithStack(errors.Newf("invalid set meta delta length: %d", len(b))) } return SetMetaDelta{LenDelta: int64(binary.BigEndian.Uint64(b))}, nil //nolint:gosec diff --git a/store/stream_helpers.go b/store/stream_helpers.go index a57e6bc1a..4fd2ca93c 100644 --- a/store/stream_helpers.go +++ b/store/stream_helpers.go @@ -9,13 +9,14 @@ import ( // Stream wide-column key layout: // -// Meta: !stream|meta| → [Len(8)][LastMs(8)][LastSeq(8)] +// Meta: !stream|meta| → [Len(8)][LastMs(8)][LastSeq(8)][ExpireAtMs(8)] // Entry: !stream|entry| const ( StreamMetaPrefix = "!stream|meta|" StreamEntryPrefix = "!stream|entry|" - streamMetaBinarySize = 24 + streamMetaLegacyBinarySize = 24 + streamMetaBinarySize = 32 // StreamIDBytes is the fixed size of the binary StreamID suffix on an entry key: // 8 bytes big-endian ms || 8 bytes big-endian seq. Big-endian so lex order // over the raw key bytes matches the (ms, seq) numeric order used by XADD / XRANGE. @@ -37,12 +38,13 @@ var ( // LastMs/LastSeq track the highest ID ever appended so XADD '*' stays // strictly monotonic even after XTRIM removes the current tail. type StreamMeta struct { - Length int64 - LastMs uint64 - LastSeq uint64 + Length int64 + LastMs uint64 + LastSeq uint64 + ExpireAt uint64 } -// MarshalStreamMeta encodes StreamMeta into a fixed 24-byte binary format. +// MarshalStreamMeta encodes StreamMeta into a fixed 32-byte binary format. func MarshalStreamMeta(m StreamMeta) ([]byte, error) { if m.Length < 0 { return nil, errors.WithStack(errors.Newf("stream meta negative length: %d", m.Length)) @@ -51,23 +53,28 @@ func MarshalStreamMeta(m StreamMeta) ([]byte, error) { binary.BigEndian.PutUint64(buf[0:8], uint64(m.Length)) //nolint:gosec binary.BigEndian.PutUint64(buf[8:16], m.LastMs) binary.BigEndian.PutUint64(buf[16:24], m.LastSeq) + binary.BigEndian.PutUint64(buf[24:32], m.ExpireAt) return buf, nil } -// UnmarshalStreamMeta decodes StreamMeta from the fixed 24-byte binary format. +// UnmarshalStreamMeta decodes StreamMeta from the legacy 24-byte or current 32-byte binary format. func UnmarshalStreamMeta(b []byte) (StreamMeta, error) { - if len(b) != streamMetaBinarySize { + if len(b) != streamMetaLegacyBinarySize && len(b) != streamMetaBinarySize { return StreamMeta{}, errors.WithStack(errors.Newf("invalid stream meta length: %d", len(b))) } length := binary.BigEndian.Uint64(b[0:8]) if length > (1< → [Len(8)] +// Base Metadata: !zs|meta| → [Len(8)][ExpireAtMs(8)] // Member Key: !zs|mem| → [Score(8)] IEEE 754 // Score Index: !zs|scr| → (empty) // Delta Key: !zs|meta|d| → [LenDelta(8)] @@ -20,13 +20,18 @@ const ( ZSetScorePrefix = "!zs|scr|" ZSetMetaDeltaPrefix = "!zs|meta|d|" - // zsetMetaSizeBytes is the fixed binary size of a ZSetMeta, ZSetMetaDelta, or ZSet score (one int64/float64). - zsetMetaSizeBytes = 8 + // zsetScalarSizeBytes is the fixed binary size of a ZSetMetaDelta or ZSet score. + zsetScalarSizeBytes = 8 + // zsetMetaLegacySizeBytes is the pre-inline-TTL binary size of a ZSetMeta. + zsetMetaLegacySizeBytes = 8 + // zsetMetaSizeBytes is the current binary size of a ZSetMeta (Len + ExpireAtMs). + zsetMetaSizeBytes = 16 ) // ZSetMeta is the base metadata for a sorted set collection. type ZSetMeta struct { - Len int64 + Len int64 + ExpireAt uint64 } // ZSetMetaDelta holds a signed change in member count. @@ -34,31 +39,36 @@ type ZSetMetaDelta struct { LenDelta int64 } -// MarshalZSetMeta encodes ZSetMeta into a fixed 8-byte binary format. +// MarshalZSetMeta encodes ZSetMeta into a fixed 16-byte binary format. func MarshalZSetMeta(m ZSetMeta) []byte { buf := make([]byte, zsetMetaSizeBytes) binary.BigEndian.PutUint64(buf, uint64(m.Len)) //nolint:gosec + binary.BigEndian.PutUint64(buf[8:16], m.ExpireAt) return buf } -// UnmarshalZSetMeta decodes ZSetMeta from the fixed 8-byte binary format. +// UnmarshalZSetMeta decodes ZSetMeta from the legacy 8-byte or current 16-byte binary format. func UnmarshalZSetMeta(b []byte) (ZSetMeta, error) { - if len(b) != zsetMetaSizeBytes { + if len(b) != zsetMetaLegacySizeBytes && len(b) != zsetMetaSizeBytes { return ZSetMeta{}, errors.WithStack(errors.Newf("invalid zset meta length: %d", len(b))) } - return ZSetMeta{Len: int64(binary.BigEndian.Uint64(b))}, nil //nolint:gosec + meta := ZSetMeta{Len: int64(binary.BigEndian.Uint64(b[0:8]))} //nolint:gosec + if len(b) == zsetMetaSizeBytes { + meta.ExpireAt = binary.BigEndian.Uint64(b[8:16]) + } + return meta, nil } // MarshalZSetMetaDelta encodes ZSetMetaDelta into a fixed 8-byte binary format. func MarshalZSetMetaDelta(d ZSetMetaDelta) []byte { - buf := make([]byte, zsetMetaSizeBytes) + buf := make([]byte, zsetScalarSizeBytes) binary.BigEndian.PutUint64(buf, uint64(d.LenDelta)) //nolint:gosec return buf } // UnmarshalZSetMetaDelta decodes ZSetMetaDelta from the fixed 8-byte binary format. func UnmarshalZSetMetaDelta(b []byte) (ZSetMetaDelta, error) { - if len(b) != zsetMetaSizeBytes { + if len(b) != zsetScalarSizeBytes { return ZSetMetaDelta{}, errors.WithStack(errors.Newf("invalid zset meta delta length: %d", len(b))) } return ZSetMetaDelta{LenDelta: int64(binary.BigEndian.Uint64(b))}, nil //nolint:gosec @@ -66,14 +76,14 @@ func UnmarshalZSetMetaDelta(b []byte) (ZSetMetaDelta, error) { // MarshalZSetScore encodes a float64 score in IEEE 754 big-endian format. func MarshalZSetScore(score float64) []byte { - buf := make([]byte, zsetMetaSizeBytes) + buf := make([]byte, zsetScalarSizeBytes) binary.BigEndian.PutUint64(buf, math.Float64bits(score)) return buf } // UnmarshalZSetScore decodes a float64 score from IEEE 754 big-endian format. func UnmarshalZSetScore(b []byte) (float64, error) { - if len(b) != zsetMetaSizeBytes { + if len(b) != zsetScalarSizeBytes { return 0, errors.WithStack(errors.Newf("invalid zset score length: %d", len(b))) } return math.Float64frombits(binary.BigEndian.Uint64(b)), nil @@ -162,7 +172,7 @@ func ExtractZSetMemberName(key, userKey []byte) []byte { // Layout: !zs|scr| func ZSetScoreKey(userKey []byte, score float64, member []byte) []byte { sortable := EncodeSortableFloat64(score) - buf := make([]byte, 0, len(ZSetScorePrefix)+wideColKeyLenSize+len(userKey)+zsetMetaSizeBytes+len(member)) + buf := make([]byte, 0, len(ZSetScorePrefix)+wideColKeyLenSize+len(userKey)+zsetScalarSizeBytes+len(member)) buf = append(buf, ZSetScorePrefix...) var kl [4]byte binary.BigEndian.PutUint32(kl[:], uint32(len(userKey))) //nolint:gosec // len is bounded by max slice size @@ -187,7 +197,7 @@ func ZSetScoreScanPrefix(userKey []byte) []byte { // ZSetScoreRangeScanPrefix returns the prefix for scanning scores in [minScore, maxScore]. func ZSetScoreRangeScanPrefix(userKey []byte, score float64) []byte { sortable := EncodeSortableFloat64(score) - buf := make([]byte, 0, len(ZSetScorePrefix)+wideColKeyLenSize+len(userKey)+zsetMetaSizeBytes) + buf := make([]byte, 0, len(ZSetScorePrefix)+wideColKeyLenSize+len(userKey)+zsetScalarSizeBytes) buf = append(buf, ZSetScorePrefix...) var kl [4]byte binary.BigEndian.PutUint32(kl[:], uint32(len(userKey))) //nolint:gosec // len is bounded by max slice size @@ -204,13 +214,13 @@ func ExtractZSetScoreAndMember(key, userKey []byte) (score float64, member []byt return 0, nil, false } rest := key[len(prefix):] - if len(rest) < zsetMetaSizeBytes { + if len(rest) < zsetScalarSizeBytes { return 0, nil, false } - var sortable [zsetMetaSizeBytes]byte - copy(sortable[:], rest[:zsetMetaSizeBytes]) + var sortable [zsetScalarSizeBytes]byte + copy(sortable[:], rest[:zsetScalarSizeBytes]) score = DecodeSortableFloat64(sortable) - member = rest[zsetMetaSizeBytes:] + member = rest[zsetScalarSizeBytes:] return score, member, true }