2026-08-04 17:12:17 +08:00
|
|
|
// Package redis provides Redis persistence helpers.
|
|
|
|
|
package redis
|
2026-07-30 15:32:22 +08:00
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"sort"
|
|
|
|
|
"strconv"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-08-04 17:12:17 +08:00
|
|
|
redisclient "github.com/redis/go-redis/v9"
|
2026-07-30 15:32:22 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const redisChangeRestoreTimeout = 5 * time.Second
|
|
|
|
|
|
|
|
|
|
type redisHashChange struct {
|
|
|
|
|
key string
|
|
|
|
|
field string
|
|
|
|
|
oldValue string
|
|
|
|
|
newValue string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type redisZSetChange struct {
|
|
|
|
|
key string
|
2026-08-04 17:12:17 +08:00
|
|
|
oldValues []redisclient.Z
|
|
|
|
|
newValues []redisclient.Z
|
2026-07-30 15:32:22 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// RedisChangeSet keeps the Redis changes belonging to one PostgreSQL
|
|
|
|
|
// transaction. Changes are prepared first and applied together immediately
|
|
|
|
|
// before the PostgreSQL transaction is committed.
|
|
|
|
|
type RedisChangeSet struct {
|
2026-08-04 17:12:17 +08:00
|
|
|
client *redisclient.Client
|
2026-07-30 15:32:22 +08:00
|
|
|
hashChanges []redisHashChange
|
|
|
|
|
zsetChanges []redisZSetChange
|
|
|
|
|
applied bool
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 17:12:17 +08:00
|
|
|
func NewRedisChangeSet(client *redisclient.Client) *RedisChangeSet {
|
2026-07-30 15:32:22 +08:00
|
|
|
return &RedisChangeSet{client: client}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-31 13:39:18 +08:00
|
|
|
func (changes *RedisChangeSet) AddHashChange(
|
2026-07-30 15:32:22 +08:00
|
|
|
ctx context.Context,
|
2026-07-31 13:39:18 +08:00
|
|
|
canonicalKey, field string,
|
2026-07-30 15:32:22 +08:00
|
|
|
value any,
|
|
|
|
|
) error {
|
|
|
|
|
if changes == nil || changes.client == nil {
|
|
|
|
|
return fmt.Errorf("redis client is not initialized")
|
|
|
|
|
}
|
2026-07-31 13:39:18 +08:00
|
|
|
if canonicalKey == "" {
|
|
|
|
|
return fmt.Errorf("canonical redis key is empty")
|
2026-07-30 15:32:22 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
newValue, err := redisChangeString(value)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("encode redis data-object value: %w", err)
|
|
|
|
|
}
|
2026-07-31 13:39:18 +08:00
|
|
|
oldValue, err := changes.client.HGet(ctx, canonicalKey, field).Result()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("query canonical redis hash %q field %q: %w", canonicalKey, field, err)
|
2026-07-30 15:32:22 +08:00
|
|
|
}
|
2026-07-31 13:39:18 +08:00
|
|
|
changes.hashChanges = append(changes.hashChanges, redisHashChange{
|
|
|
|
|
key: canonicalKey,
|
|
|
|
|
field: field,
|
|
|
|
|
oldValue: oldValue,
|
|
|
|
|
newValue: newValue,
|
|
|
|
|
})
|
2026-07-30 15:32:22 +08:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (changes *RedisChangeSet) AddMeasurementValueChange(
|
|
|
|
|
ctx context.Context,
|
2026-08-04 17:12:17 +08:00
|
|
|
key string,
|
2026-07-30 15:32:22 +08:00
|
|
|
value float64,
|
2026-08-04 17:12:17 +08:00
|
|
|
timestamp time.Time,
|
2026-07-30 15:32:22 +08:00
|
|
|
replace bool,
|
|
|
|
|
) error {
|
|
|
|
|
if changes == nil || changes.client == nil {
|
|
|
|
|
return fmt.Errorf("redis client is not initialized")
|
|
|
|
|
}
|
2026-08-04 17:12:17 +08:00
|
|
|
if key == "" {
|
|
|
|
|
return fmt.Errorf("measurement redis key is empty")
|
2026-07-30 15:32:22 +08:00
|
|
|
}
|
|
|
|
|
keyType, err := changes.client.Type(ctx, key).Result()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("query measurement redis key type for %q: %w", key, err)
|
|
|
|
|
}
|
|
|
|
|
if keyType != "none" && keyType != "zset" {
|
|
|
|
|
return fmt.Errorf("measurement redis key %q has type %q, expected zset", key, keyType)
|
|
|
|
|
}
|
|
|
|
|
oldValues, err := changes.client.ZRangeWithScores(ctx, key, 0, -1).Result()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("query measurement redis values for %q: %w", key, err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 17:12:17 +08:00
|
|
|
newMember := strconv.FormatInt(timestamp.UnixNano(), 10)
|
|
|
|
|
newValues := []redisclient.Z{{Score: value, Member: newMember}}
|
2026-07-30 15:32:22 +08:00
|
|
|
if !replace {
|
|
|
|
|
newValues = mergeRedisZValues(oldValues, newValues...)
|
|
|
|
|
}
|
|
|
|
|
changes.zsetChanges = append(changes.zsetChanges, redisZSetChange{
|
|
|
|
|
key: key,
|
|
|
|
|
oldValues: normalizeRedisZValues(oldValues),
|
|
|
|
|
newValues: normalizeRedisZValues(newValues),
|
|
|
|
|
})
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (changes *RedisChangeSet) Apply(ctx context.Context) error {
|
|
|
|
|
if changes == nil || changes.client == nil {
|
|
|
|
|
return fmt.Errorf("redis client is not initialized")
|
|
|
|
|
}
|
|
|
|
|
if len(changes.hashChanges) == 0 && len(changes.zsetChanges) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
keys := changes.keys()
|
2026-08-04 17:12:17 +08:00
|
|
|
err := changes.client.Watch(ctx, func(tx *redisclient.Tx) error {
|
2026-07-30 15:32:22 +08:00
|
|
|
if err := changes.verify(ctx, tx, false); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-08-04 17:12:17 +08:00
|
|
|
_, err := tx.TxPipelined(ctx, func(pipe redisclient.Pipeliner) error {
|
2026-07-30 15:32:22 +08:00
|
|
|
for _, change := range changes.hashChanges {
|
|
|
|
|
pipe.HSet(ctx, change.key, change.field, change.newValue)
|
|
|
|
|
}
|
|
|
|
|
for _, change := range changes.zsetChanges {
|
|
|
|
|
pipe.Del(ctx, change.key)
|
|
|
|
|
if len(change.newValues) > 0 {
|
|
|
|
|
pipe.ZAdd(ctx, change.key, change.newValues...)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
})
|
|
|
|
|
return err
|
|
|
|
|
}, keys...)
|
|
|
|
|
if err != nil {
|
|
|
|
|
// A connection error can leave EXEC's outcome unknown. Restore only
|
|
|
|
|
// when Redis still contains either the prepared or the applied state.
|
|
|
|
|
restoreCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), redisChangeRestoreTimeout)
|
|
|
|
|
defer cancel()
|
|
|
|
|
if restoreErr := changes.restoreAfterApplyFailure(restoreCtx); restoreErr != nil {
|
|
|
|
|
return fmt.Errorf("apply redis changes: %w; restore redis changes: %v", err, restoreErr)
|
|
|
|
|
}
|
|
|
|
|
return fmt.Errorf("apply redis changes: %w", err)
|
|
|
|
|
}
|
|
|
|
|
changes.applied = true
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Revert restores Redis after a PostgreSQL commit failure. It uses WATCH and
|
|
|
|
|
// only restores values that still match this change set.
|
|
|
|
|
func (changes *RedisChangeSet) Revert(ctx context.Context) error {
|
|
|
|
|
if changes == nil || !changes.applied {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return changes.restoreOldValues(ctx, true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (changes *RedisChangeSet) restoreOldValues(ctx context.Context, compareNew bool) error {
|
|
|
|
|
keys := changes.keys()
|
2026-08-04 17:12:17 +08:00
|
|
|
return changes.client.Watch(ctx, func(tx *redisclient.Tx) error {
|
2026-07-30 15:32:22 +08:00
|
|
|
if compareNew {
|
|
|
|
|
if err := changes.verify(ctx, tx, true); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-04 17:12:17 +08:00
|
|
|
_, err := tx.TxPipelined(ctx, func(pipe redisclient.Pipeliner) error {
|
2026-07-30 15:32:22 +08:00
|
|
|
for _, change := range changes.hashChanges {
|
|
|
|
|
pipe.HSet(ctx, change.key, change.field, change.oldValue)
|
|
|
|
|
}
|
|
|
|
|
for _, change := range changes.zsetChanges {
|
|
|
|
|
pipe.Del(ctx, change.key)
|
|
|
|
|
if len(change.oldValues) > 0 {
|
|
|
|
|
pipe.ZAdd(ctx, change.key, change.oldValues...)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
})
|
|
|
|
|
return err
|
|
|
|
|
}, keys...)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (changes *RedisChangeSet) restoreAfterApplyFailure(ctx context.Context) error {
|
|
|
|
|
keys := changes.keys()
|
2026-08-04 17:12:17 +08:00
|
|
|
return changes.client.Watch(ctx, func(tx *redisclient.Tx) error {
|
2026-07-30 15:32:22 +08:00
|
|
|
for _, change := range changes.hashChanges {
|
|
|
|
|
actual, err := tx.HGet(ctx, change.key, change.field).Result()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("verify redis hash %q field %q after apply failure: %w", change.key, change.field, err)
|
|
|
|
|
}
|
|
|
|
|
if actual != change.oldValue && actual != change.newValue {
|
|
|
|
|
return fmt.Errorf("redis hash %q field %q changed concurrently", change.key, change.field)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, change := range changes.zsetChanges {
|
|
|
|
|
actual, err := tx.ZRangeWithScores(ctx, change.key, 0, -1).Result()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("verify redis zset %q after apply failure: %w", change.key, err)
|
|
|
|
|
}
|
|
|
|
|
if !equalRedisZValues(actual, change.oldValues) && !equalRedisZValues(actual, change.newValues) {
|
|
|
|
|
return fmt.Errorf("redis zset %q changed concurrently", change.key)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-04 17:12:17 +08:00
|
|
|
_, err := tx.TxPipelined(ctx, func(pipe redisclient.Pipeliner) error {
|
2026-07-30 15:32:22 +08:00
|
|
|
for _, change := range changes.hashChanges {
|
|
|
|
|
pipe.HSet(ctx, change.key, change.field, change.oldValue)
|
|
|
|
|
}
|
|
|
|
|
for _, change := range changes.zsetChanges {
|
|
|
|
|
pipe.Del(ctx, change.key)
|
|
|
|
|
if len(change.oldValues) > 0 {
|
|
|
|
|
pipe.ZAdd(ctx, change.key, change.oldValues...)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
})
|
|
|
|
|
return err
|
|
|
|
|
}, keys...)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 17:12:17 +08:00
|
|
|
func (changes *RedisChangeSet) verify(ctx context.Context, tx *redisclient.Tx, expectNew bool) error {
|
2026-07-30 15:32:22 +08:00
|
|
|
for _, change := range changes.hashChanges {
|
|
|
|
|
expected := change.oldValue
|
|
|
|
|
if expectNew {
|
|
|
|
|
expected = change.newValue
|
|
|
|
|
}
|
|
|
|
|
actual, err := tx.HGet(ctx, change.key, change.field).Result()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("verify redis hash %q field %q: %w", change.key, change.field, err)
|
|
|
|
|
}
|
|
|
|
|
if actual != expected {
|
|
|
|
|
return fmt.Errorf("redis hash %q field %q changed concurrently", change.key, change.field)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, change := range changes.zsetChanges {
|
|
|
|
|
expected := change.oldValues
|
|
|
|
|
if expectNew {
|
|
|
|
|
expected = change.newValues
|
|
|
|
|
}
|
|
|
|
|
actual, err := tx.ZRangeWithScores(ctx, change.key, 0, -1).Result()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("verify redis zset %q: %w", change.key, err)
|
|
|
|
|
}
|
|
|
|
|
if !equalRedisZValues(actual, expected) {
|
|
|
|
|
return fmt.Errorf("redis zset %q changed concurrently", change.key)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (changes *RedisChangeSet) keys() []string {
|
|
|
|
|
seen := make(map[string]struct{}, len(changes.hashChanges)+len(changes.zsetChanges))
|
|
|
|
|
keys := make([]string, 0, len(seen))
|
|
|
|
|
for _, change := range changes.hashChanges {
|
|
|
|
|
if _, ok := seen[change.key]; !ok {
|
|
|
|
|
seen[change.key] = struct{}{}
|
|
|
|
|
keys = append(keys, change.key)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, change := range changes.zsetChanges {
|
|
|
|
|
if _, ok := seen[change.key]; !ok {
|
|
|
|
|
seen[change.key] = struct{}{}
|
|
|
|
|
keys = append(keys, change.key)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
sort.Strings(keys)
|
|
|
|
|
return keys
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func redisChangeString(value any) (string, error) {
|
|
|
|
|
switch typedValue := value.(type) {
|
|
|
|
|
case string:
|
|
|
|
|
return typedValue, nil
|
|
|
|
|
case []byte:
|
|
|
|
|
return string(typedValue), nil
|
|
|
|
|
case nil:
|
|
|
|
|
return "null", nil
|
|
|
|
|
case bool:
|
|
|
|
|
return strconv.FormatBool(typedValue), nil
|
|
|
|
|
case int:
|
|
|
|
|
return strconv.Itoa(typedValue), nil
|
|
|
|
|
case int16:
|
|
|
|
|
return strconv.FormatInt(int64(typedValue), 10), nil
|
|
|
|
|
case int64:
|
|
|
|
|
return strconv.FormatInt(typedValue, 10), nil
|
|
|
|
|
case float64:
|
|
|
|
|
return strconv.FormatFloat(typedValue, 'f', -1, 64), nil
|
|
|
|
|
default:
|
|
|
|
|
encoded, err := json.Marshal(typedValue)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
return string(encoded), nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 17:12:17 +08:00
|
|
|
func cloneRedisZValues(values []redisclient.Z) []redisclient.Z {
|
|
|
|
|
cloned := make([]redisclient.Z, len(values))
|
2026-07-30 15:32:22 +08:00
|
|
|
copy(cloned, values)
|
|
|
|
|
return cloned
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 17:12:17 +08:00
|
|
|
func mergeRedisZValues(current []redisclient.Z, additions ...redisclient.Z) []redisclient.Z {
|
|
|
|
|
valuesByMember := make(map[string]redisclient.Z, len(current)+len(additions))
|
2026-07-30 15:32:22 +08:00
|
|
|
for _, value := range current {
|
|
|
|
|
valuesByMember[fmt.Sprint(value.Member)] = value
|
|
|
|
|
}
|
|
|
|
|
for _, value := range additions {
|
|
|
|
|
valuesByMember[fmt.Sprint(value.Member)] = value
|
|
|
|
|
}
|
2026-08-04 17:12:17 +08:00
|
|
|
values := make([]redisclient.Z, 0, len(valuesByMember))
|
2026-07-30 15:32:22 +08:00
|
|
|
for _, value := range valuesByMember {
|
|
|
|
|
values = append(values, value)
|
|
|
|
|
}
|
|
|
|
|
return normalizeRedisZValues(values)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 17:12:17 +08:00
|
|
|
func normalizeRedisZValues(values []redisclient.Z) []redisclient.Z {
|
2026-07-30 15:32:22 +08:00
|
|
|
normalized := cloneRedisZValues(values)
|
|
|
|
|
sort.Slice(normalized, func(i, j int) bool {
|
|
|
|
|
if normalized[i].Score != normalized[j].Score {
|
|
|
|
|
return normalized[i].Score < normalized[j].Score
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprint(normalized[i].Member) < fmt.Sprint(normalized[j].Member)
|
|
|
|
|
})
|
|
|
|
|
return normalized
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 17:12:17 +08:00
|
|
|
func equalRedisZValues(left, right []redisclient.Z) bool {
|
2026-07-30 15:32:22 +08:00
|
|
|
left = normalizeRedisZValues(left)
|
|
|
|
|
right = normalizeRedisZValues(right)
|
|
|
|
|
if len(left) != len(right) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
for index := range left {
|
|
|
|
|
if left[index].Score != right[index].Score ||
|
|
|
|
|
fmt.Sprint(left[index].Member) != fmt.Sprint(right[index].Member) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|