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 }