Files
go-redis-inventory/pkg/inventory/redis_reservation_service.go
T
2025-11-26 18:35:08 +01:00

250 lines
6.6 KiB
Go

package inventory
import (
"context"
"fmt"
"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)
}
func (s *RedisCartReservationService) ReserveForCart(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
}
func (s *RedisCartReservationService) ReleaseForCart(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.GetAvailableInventory(ctx, sku, locationID)
if err != nil {
return nil, err
}
return &ReservationSummary{
ReservationCount: reservationCount,
EarliestRelease: earliestRelease,
RemainingItems: remainingItems,
}, nil
}
func (s *RedisCartReservationService) GetAvailableInventory(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
`