package inventory import ( "context" "fmt" "strconv" "time" "github.com/redis/go-redis/v9" ) type RedisCartReservationService struct { client *redis.Client luaReserve *redis.Script luaRelease *redis.Script luaGetAvail *redis.Script } func NewRedisCartReservationService(client *redis.Client) (*RedisCartReservationService, error) { rdb := client // Ping Redis to check connection _, err := rdb.Ping(context.Background()).Result() if err != nil { return nil, err } return &RedisCartReservationService{ client: rdb, luaReserve: redis.NewScript(reserveForCartScript), luaRelease: redis.NewScript(releaseForCartScript), luaGetAvail: redis.NewScript(getAvailableScript), }, nil } func cartQtyKey(sku SKU, locationID LocationID) string { return fmt.Sprintf("cart_qty:%s:%s", sku, locationID) } func cartExpKey(sku SKU, locationID LocationID) string { return fmt.Sprintf("cart_exp:%s:%s", sku, locationID) } func totalReservedKey(sku SKU, locationID LocationID) string { return fmt.Sprintf("total_reserved:%s:%s", sku, locationID) } // Reserve satisfies inventory.ReservationPolicy (platform/inventory). // Method renamed from ReserveForCart as part of cut-30.x: land the ReservationPolicy // interface seam — see docs/inventory-shape.md §Shallow — `ReservationPolicy` interface. func (s *RedisCartReservationService) Reserve(ctx context.Context, req CartReserveRequest) error { keys := []string{ getInventoryKey(req.SKU, req.LocationID), cartQtyKey(req.SKU, req.LocationID), cartExpKey(req.SKU, req.LocationID), totalReservedKey(req.SKU, req.LocationID), } args := []interface{}{ string(req.CartID), req.Quantity, int64(req.TTL.Seconds()), } cmd := s.luaReserve.Run(ctx, s.client, keys, args) if err := cmd.Err(); err != nil { return err } if val, err := cmd.Int(); err != nil { return err } else if val == 0 { return ErrInsufficientInventory } return nil } // Release satisfies inventory.ReservationPolicy (platform/inventory). // Method renamed from ReleaseForCart as part of cut-30.x: land the ReservationPolicy // interface seam — see docs/inventory-shape.md §Shallow — `ReservationPolicy` interface. func (s *RedisCartReservationService) Release(ctx context.Context, sku SKU, locationID LocationID, cartID CartID) error { keys := []string{ cartQtyKey(sku, locationID), cartExpKey(sku, locationID), totalReservedKey(sku, locationID), } args := []interface{}{string(cartID)} cmd := s.luaRelease.Run(ctx, s.client, keys, args) return cmd.Err() } func (s *RedisCartReservationService) GetReservationExpiry(ctx context.Context, sku SKU, locationID LocationID, cartID CartID) (time.Time, error) { expKey := cartExpKey(sku, locationID) score := s.client.ZScore(ctx, expKey, string(cartID)) if err := score.Err(); err != nil { if err == redis.Nil { return time.Time{}, fmt.Errorf("no reservation found for cart %s", cartID) } return time.Time{}, err } expiry := time.Unix(int64(score.Val()), 0) return expiry, nil } func (s *RedisCartReservationService) GetReservationStatus(ctx context.Context, sku SKU, locationID LocationID, cartID CartID) (*ReservationStatus, error) { expiry, err := s.GetReservationExpiry(ctx, sku, locationID, cartID) if err != nil { return nil, err } now := time.Now() if expiry.After(now) { return &ReservationStatus{ Active: true, RemainingTime: expiry.Sub(now), }, nil } return &ReservationStatus{ Active: false, RemainingTime: 0, }, nil } func (s *RedisCartReservationService) GetReservationSummary(ctx context.Context, sku SKU, locationID LocationID) (*ReservationSummary, error) { qtyKey := cartQtyKey(sku, locationID) expKey := cartExpKey(sku, locationID) // Get reservation count reservationCount, err := s.client.HLen(ctx, qtyKey).Result() if err != nil { return nil, err } // Get earliest release (min score in sorted set) minScore, err := s.client.ZRangeWithScores(ctx, expKey, 0, 0).Result() if err != nil { return nil, err } var earliestRelease time.Time if len(minScore) > 0 { earliestRelease = time.Unix(int64(minScore[0].Score), 0) } // Get remaining items remainingItems, err := s.Available(ctx, sku, locationID) if err != nil { return nil, err } return &ReservationSummary{ ReservationCount: reservationCount, EarliestRelease: earliestRelease, RemainingItems: remainingItems, }, nil } // GetReservationDetail returns the full per-cart reservation detail for a // SKU+location. Unlike GetReservationSummary (which returns only aggregates), // this method fetches every active (non-expired) row from Redis and returns // a ReservationDetailResponse containing the adjusted summary + per-cart lines. // Expired rows are filtered out so the UI only sees live holds. The two Redis // fetches (HGetAll + ZRangeWithScores) run sequentially — for an infrequent // admin endpoint the goroutine+channel overhead is not worth the latency win. func (s *RedisCartReservationService) GetReservationDetail(ctx context.Context, sku SKU, locationID LocationID) (*ReservationDetailResponse, error) { qtyKey := cartQtyKey(sku, locationID) expKey := cartExpKey(sku, locationID) // Fetch all cart→qty pairs. qtyMap, err := s.client.HGetAll(ctx, qtyKey).Result() if err != nil { return nil, err } // Fetch all cart→expiry pairs. expPairs, err := s.client.ZRangeWithScores(ctx, expKey, 0, -1).Result() if err != nil { return nil, err } // Index expiry by cartId. expMap := make(map[string]time.Time, len(expPairs)) for _, z := range expPairs { key, ok := z.Member.(string) if !ok { continue } expMap[key] = time.Unix(int64(z.Score), 0) } // Build the line list, skipping expired entries. now := time.Now() var lines []ReservationDetailLine var earliestRelease time.Time for cartID, qtyStr := range qtyMap { expiresAt, hasExp := expMap[cartID] if !hasExp || expiresAt.Before(now) { continue } qty, _ := strconv.ParseUint(qtyStr, 10, 32) lines = append(lines, ReservationDetailLine{ CartID: cartID, Quantity: uint32(qty), ExpiresAt: expiresAt, }) if earliestRelease.IsZero() || expiresAt.Before(earliestRelease) { earliestRelease = expiresAt } } remainingItems, err := s.Available(ctx, sku, locationID) if err != nil { return nil, err } return &ReservationDetailResponse{ Summary: &ReservationSummary{ ReservationCount: int64(len(lines)), EarliestRelease: earliestRelease, RemainingItems: remainingItems, }, Lines: lines, }, nil } // Available satisfies inventory.ReservationPolicy (platform/inventory). // Method renamed from GetAvailableInventory as part of cut-30.x: land the // ReservationPolicy interface seam — see docs/inventory-shape.md. func (s *RedisCartReservationService) Available(ctx context.Context, sku SKU, locationID LocationID) (int64, error) { keys := []string{ getInventoryKey(sku, locationID), cartQtyKey(sku, locationID), cartExpKey(sku, locationID), totalReservedKey(sku, locationID), } cmd := s.luaGetAvail.Run(ctx, s.client, keys) if err := cmd.Err(); err != nil { return 0, err } return cmd.Int64() } const reserveForCartScript = ` local inventory_key = KEYS[1] local qty_key = KEYS[2] local exp_key = KEYS[3] local total_reserved_key = KEYS[4] local cart = ARGV[1] local req_qty = tonumber(ARGV[2]) local ttl = tonumber(ARGV[3]) -- Get current time in seconds local current_time = redis.call('TIME')[1] -- Clean expired reservations local expired_carts = redis.call('ZRANGEBYSCORE', exp_key, 0, current_time) local expired_qty_sum = 0 for _, exp_cart in ipairs(expired_carts) do local qty = redis.call('HGET', qty_key, exp_cart) if qty then expired_qty_sum = expired_qty_sum + tonumber(qty) end redis.call('HDEL', qty_key, exp_cart) redis.call('ZREM', exp_key, exp_cart) end if expired_qty_sum > 0 then redis.call('DECRBY', total_reserved_key, expired_qty_sum) end -- Get total_reserved local total_reserved = tonumber(redis.call('GET', total_reserved_key) or 0) -- Get inventory local inventory = tonumber(redis.call('GET', inventory_key) or 0) -- Check if enough available if inventory - total_reserved >= req_qty then redis.call('HSET', qty_key, cart, req_qty) redis.call('ZADD', exp_key, current_time + ttl, cart) redis.call('INCRBY', total_reserved_key, req_qty) return 1 else return 0 end ` const releaseForCartScript = ` local qty_key = KEYS[1] local exp_key = KEYS[2] local total_reserved_key = KEYS[3] local cart = ARGV[1] local qty = redis.call('HGET', qty_key, cart) if qty then redis.call('DECRBY', total_reserved_key, tonumber(qty)) end redis.call('HDEL', qty_key, cart) redis.call('ZREM', exp_key, cart) return 1 ` const getAvailableScript = ` local inventory_key = KEYS[1] local qty_key = KEYS[2] local exp_key = KEYS[3] local total_reserved_key = KEYS[4] -- Get current time local current_time = redis.call('TIME')[1] -- Clean expired local expired_carts = redis.call('ZRANGEBYSCORE', exp_key, 0, current_time) local expired_qty_sum = 0 for _, exp_cart in ipairs(expired_carts) do local qty = redis.call('HGET', qty_key, exp_cart) if qty then expired_qty_sum = expired_qty_sum + tonumber(qty) end redis.call('HDEL', qty_key, exp_cart) redis.call('ZREM', exp_key, exp_cart) end if expired_qty_sum > 0 then redis.call('DECRBY', total_reserved_key, expired_qty_sum) end local total_reserved = tonumber(redis.call('GET', total_reserved_key) or 0) local inventory = tonumber(redis.call('GET', inventory_key) or 0) return inventory - total_reserved `