329 lines
9.4 KiB
Go
329 lines
9.4 KiB
Go
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
|
|
`
|