modelRT/handler/data_object_redis_change.go

430 lines
12 KiB
Go

package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
"modelRT/constants"
"modelRT/model"
"modelRT/orm"
"github.com/redis/go-redis/v9"
)
const redisChangeRestoreTimeout = 5 * time.Second
type redisHashChange struct {
key string
field string
oldValue string
newValue string
}
type redisZSetChange struct {
key string
oldValues []redis.Z
newValues []redis.Z
}
// 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 {
client *redis.Client
hashChanges []redisHashChange
zsetChanges []redisZSetChange
applied bool
}
func NewRedisChangeSet(client *redis.Client) *RedisChangeSet {
return &RedisChangeSet{client: client}
}
func (changes *RedisChangeSet) AddDataObjectHashChange(
ctx context.Context,
dataObjectType constants.DataObjectType,
token, field string,
value any,
) error {
if changes == nil || changes.client == nil {
return fmt.Errorf("redis client is not initialized")
}
metadata, err := changes.client.HMGet(ctx, token, "id", "name").Result()
if err != nil {
return fmt.Errorf("query redis data-object aliases for %q: %w", token, err)
}
if len(metadata) != 2 || metadata[0] == nil || metadata[1] == nil {
return fmt.Errorf("redis data-object hash %q does not contain id and name", token)
}
id, ok := metadata[0].(string)
if !ok {
return fmt.Errorf("redis data-object hash %q id has type %T", token, metadata[0])
}
name, ok := metadata[1].(string)
if !ok {
return fmt.Errorf("redis data-object hash %q name has type %T", token, metadata[1])
}
keys, err := dataObjectRedisAliasKeys(dataObjectType, id, name)
if err != nil {
return err
}
if !containsString(keys, token) {
return fmt.Errorf("redis data-object hash %q metadata points to different aliases", token)
}
newValue, err := redisChangeString(value)
if err != nil {
return fmt.Errorf("encode redis data-object value: %w", err)
}
for _, key := range keys {
keyType, err := changes.client.Type(ctx, key).Result()
if err != nil {
return fmt.Errorf("query redis key type for %q: %w", key, err)
}
if keyType == "none" && dataObjectType == constants.DataObjectTypeParameter && key == name && key != token {
// A parameter short alias is only initialized for a local station.
continue
}
if keyType != "hash" {
return fmt.Errorf("redis data-object key %q has type %q, expected hash", key, keyType)
}
oldValue, err := changes.client.HGet(ctx, key, field).Result()
if errors.Is(err, redis.Nil) {
return fmt.Errorf("redis data-object hash %q does not contain field %q", key, field)
}
if err != nil {
return fmt.Errorf("query redis hash %q field %q: %w", key, field, err)
}
changes.hashChanges = append(changes.hashChanges, redisHashChange{
key: key,
field: field,
oldValue: oldValue,
newValue: newValue,
})
}
return nil
}
func (changes *RedisChangeSet) AddMeasurementValueChange(
ctx context.Context,
measurement *orm.Measurement,
value float64,
replace bool,
) error {
if changes == nil || changes.client == nil {
return fmt.Errorf("redis client is not initialized")
}
if measurement == nil {
return fmt.Errorf("measurement is nil")
}
key, err := model.GenerateMeasureIdentifier(measurement.DataSource)
if err != nil {
return fmt.Errorf("generate measurement redis key: %w", err)
}
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)
}
newMember := strconv.FormatInt(time.Now().UnixNano(), 10)
newValues := []redis.Z{{Score: value, Member: newMember}}
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()
err := changes.client.Watch(ctx, func(tx *redis.Tx) error {
if err := changes.verify(ctx, tx, false); err != nil {
return err
}
_, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
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()
return changes.client.Watch(ctx, func(tx *redis.Tx) error {
if compareNew {
if err := changes.verify(ctx, tx, true); err != nil {
return err
}
}
_, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
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()
return changes.client.Watch(ctx, func(tx *redis.Tx) error {
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)
}
}
_, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
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) verify(ctx context.Context, tx *redis.Tx, expectNew bool) error {
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 dataObjectRedisAliasKeys(dataObjectType constants.DataObjectType, id, name string) ([]string, error) {
switch dataObjectType {
case constants.DataObjectTypeParameter:
if len(strings.Split(id, ".")) != 7 || len(strings.Split(name, ".")) != 4 {
return nil, fmt.Errorf("invalid parameter redis aliases id=%q name=%q", id, name)
}
return uniqueStrings(id, name), nil
case constants.DataObjectTypeMeasurement:
parts := strings.Split(id, ".")
if len(parts) != 7 || len(strings.Split(name, ".")) != 2 {
return nil, fmt.Errorf("invalid measurement redis aliases id=%q name=%q", id, name)
}
return uniqueStrings(id, strings.Join(parts[3:], "."), name), nil
default:
return nil, fmt.Errorf("unsupported data object type %q", dataObjectType)
}
}
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
}
}
func cloneRedisZValues(values []redis.Z) []redis.Z {
cloned := make([]redis.Z, len(values))
copy(cloned, values)
return cloned
}
func mergeRedisZValues(current []redis.Z, additions ...redis.Z) []redis.Z {
valuesByMember := make(map[string]redis.Z, len(current)+len(additions))
for _, value := range current {
valuesByMember[fmt.Sprint(value.Member)] = value
}
for _, value := range additions {
valuesByMember[fmt.Sprint(value.Member)] = value
}
values := make([]redis.Z, 0, len(valuesByMember))
for _, value := range valuesByMember {
values = append(values, value)
}
return normalizeRedisZValues(values)
}
func normalizeRedisZValues(values []redis.Z) []redis.Z {
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
}
func equalRedisZValues(left, right []redis.Z) bool {
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
}
func uniqueStrings(values ...string) []string {
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}