109 lines
3.6 KiB
Go
109 lines
3.6 KiB
Go
package redisstate
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
// releaseRouteLeaseScript releases the route lease only when the supplied
|
|
// token still owns it. The Lua script gates the DEL on the SET value match
|
|
// so a publisher that lost the lease (TTL expiry, replica swap) cannot
|
|
// clear another worker's claim.
|
|
var releaseRouteLeaseScript = redis.NewScript(`
|
|
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
|
return redis.call("DEL", KEYS[1])
|
|
end
|
|
return 0
|
|
`)
|
|
|
|
// LeaseStore owns the short-lived route lease keys that coordinate exclusive
|
|
// route publication across replicas. The lease lives on Redis as a per-route
|
|
// SETNX-with-TTL token; releasing it requires the same token via a Lua
|
|
// script that compares the stored value before deleting it.
|
|
//
|
|
// LeaseStore is intentionally separate from the durable route-state storage
|
|
// so the publishers can compose one storage-layer adapter (PostgreSQL since
|
|
// Stage 5) with the runtime-coordination layer that stays on Redis per
|
|
// `ARCHITECTURE.md §Persistence Backends`.
|
|
type LeaseStore struct {
|
|
client *redis.Client
|
|
keys Keyspace
|
|
}
|
|
|
|
// NewLeaseStore constructs one Redis-backed lease store.
|
|
func NewLeaseStore(client *redis.Client) (*LeaseStore, error) {
|
|
if client == nil {
|
|
return nil, errors.New("new notification lease store: nil redis client")
|
|
}
|
|
|
|
return &LeaseStore{client: client, keys: Keyspace{}}, nil
|
|
}
|
|
|
|
// TryAcquireRouteLease attempts to acquire one temporary route lease owned
|
|
// by token for ttl. The lease is stored at the route-lease keyspace key and
|
|
// auto-expires; a publisher whose work outlives the TTL must accept that
|
|
// another replica may pick the route up.
|
|
func (store *LeaseStore) TryAcquireRouteLease(ctx context.Context, notificationID string, routeID string, token string, ttl time.Duration) (bool, error) {
|
|
if store == nil || store.client == nil {
|
|
return false, errors.New("try acquire route lease: nil store")
|
|
}
|
|
if ctx == nil {
|
|
return false, errors.New("try acquire route lease: nil context")
|
|
}
|
|
if notificationID == "" {
|
|
return false, errors.New("try acquire route lease: notification id must not be empty")
|
|
}
|
|
if routeID == "" {
|
|
return false, errors.New("try acquire route lease: route id must not be empty")
|
|
}
|
|
if token == "" {
|
|
return false, errors.New("try acquire route lease: token must not be empty")
|
|
}
|
|
if ttl <= 0 {
|
|
return false, errors.New("try acquire route lease: ttl must be positive")
|
|
}
|
|
|
|
acquired, err := store.client.SetNX(ctx, store.keys.RouteLease(notificationID, routeID), token, ttl).Result()
|
|
if err != nil {
|
|
return false, fmt.Errorf("try acquire route lease: %w", err)
|
|
}
|
|
|
|
return acquired, nil
|
|
}
|
|
|
|
// ReleaseRouteLease releases one temporary route lease only when token still
|
|
// matches the stored owner value. Releasing a lease the caller no longer
|
|
// owns is a silent no-op.
|
|
func (store *LeaseStore) ReleaseRouteLease(ctx context.Context, notificationID string, routeID string, token string) error {
|
|
if store == nil || store.client == nil {
|
|
return errors.New("release route lease: nil store")
|
|
}
|
|
if ctx == nil {
|
|
return errors.New("release route lease: nil context")
|
|
}
|
|
if notificationID == "" {
|
|
return errors.New("release route lease: notification id must not be empty")
|
|
}
|
|
if routeID == "" {
|
|
return errors.New("release route lease: route id must not be empty")
|
|
}
|
|
if token == "" {
|
|
return errors.New("release route lease: token must not be empty")
|
|
}
|
|
|
|
if err := releaseRouteLeaseScript.Run(
|
|
ctx,
|
|
store.client,
|
|
[]string{store.keys.RouteLease(notificationID, routeID)},
|
|
token,
|
|
).Err(); err != nil {
|
|
return fmt.Errorf("release route lease: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|