diff --git a/README.md b/README.md index a23f1d7..eec4bb0 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ A Go library for managing inventory using Redis. - Get and update inventory quantities - Reserve inventory with atomic operations using Lua scripts +- Temporary cart-based reservations with expiration to prevent overselling +- Optimized available inventory calculation using maintained reservation totals - Listen to inventory changes via Redis pub/sub ## Installation @@ -34,6 +36,30 @@ if err != nil { } ``` +For cart reservations: + +```go +cartService, err := inventory.NewRedisCartReservationService(client) +if err != nil { + log.Fatal(err) +} + +// Reserve items for a cart with 10-minute TTL +err = cartService.ReserveForCart(ctx, inventory.CartReserveRequest{ + InventoryReference: &inventory.InventoryReference{SKU: "item1", LocationID: "warehouse1"}, + CartID: "cart123", + Quantity: 2, + TTL: 10 * time.Minute, +}) + +// Get available inventory (accounting for reservations) +available, err := cartService.GetAvailableInventory(ctx, "item1", "warehouse1") + +// Get reservation summary for an item +summary, err := cartService.GetReservationSummary(ctx, "item1", "warehouse1") +fmt.Printf("Reservations: %d, Next release: %v, Remaining: %d\n", summary.ReservationCount, summary.EarliestRelease, summary.RemainingItems) +``` + ## Dependencies - Go 1.25.1 or later diff --git a/go.mod b/go.mod index 488517c..2ad8b74 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,13 @@ module git.k6n.net/mats/go-redis-inventory go 1.26.2 -require github.com/redis/go-redis/v9 v9.16.0 +require ( + github.com/alicebob/miniredis/v2 v2.35.0 + github.com/redis/go-redis/v9 v9.16.0 +) require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect ) diff --git a/go.sum b/go.sum index e578cfc..2dab34f 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= +github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -8,3 +10,5 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4= github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= diff --git a/pkg/inventory/redis_reservation_service.go b/pkg/inventory/redis_reservation_service.go new file mode 100644 index 0000000..ef1aec1 --- /dev/null +++ b/pkg/inventory/redis_reservation_service.go @@ -0,0 +1,249 @@ +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 +` diff --git a/pkg/inventory/redis_reservation_service_test.go b/pkg/inventory/redis_reservation_service_test.go new file mode 100644 index 0000000..145b59c --- /dev/null +++ b/pkg/inventory/redis_reservation_service_test.go @@ -0,0 +1,311 @@ +package inventory + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" +) + +func setupTestRedis() (*redis.Client, *miniredis.Miniredis, error) { + mr, err := miniredis.Run() + if err != nil { + return nil, nil, err + } + + client := redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + }) + + return client, mr, nil +} + +func TestReserveAndRelease(t *testing.T) { + client, mr, err := setupTestRedis() + if err != nil { + t.Fatal(err) + } + defer mr.Close() + + service, err := NewRedisCartReservationService(client) + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + sku := SKU("test-item") + locationID := LocationID("warehouse1") + cartID := CartID("cart123") + + // Set initial inventory + err = client.Set(ctx, getInventoryKey(sku, locationID), 10, 0).Err() + if err != nil { + t.Fatal(err) + } + + // Reserve + req := CartReserveRequest{ + InventoryReference: &InventoryReference{SKU: sku, LocationID: locationID}, + CartID: cartID, + Quantity: 2, + TTL: 5 * time.Minute, + } + err = service.ReserveForCart(ctx, req) + if err != nil { + t.Fatal(err) + } + + // Check available + available, err := service.GetAvailableInventory(ctx, sku, locationID) + if err != nil { + t.Fatal(err) + } + if available != 8 { + t.Errorf("expected available 8, got %d", available) + } + + // Check status + status, err := service.GetReservationStatus(ctx, sku, locationID, cartID) + if err != nil { + t.Fatal(err) + } + if !status.Active { + t.Error("reservation should be active") + } + if status.RemainingTime <= 0 || status.RemainingTime > 5*time.Minute { + t.Errorf("invalid remaining time: %v", status.RemainingTime) + } + + // Release + err = service.ReleaseForCart(ctx, sku, locationID, cartID) + if err != nil { + t.Fatal(err) + } + + // Check available after release + available, err = service.GetAvailableInventory(ctx, sku, locationID) + if err != nil { + t.Fatal(err) + } + if available != 10 { + t.Errorf("expected available 10 after release, got %d", available) + } + + // Check status after release + _, err = service.GetReservationStatus(ctx, sku, locationID, cartID) + if err == nil { + t.Error("expected error for non-existent reservation") + } +} + +func TestConcurrentReserves(t *testing.T) { + client, mr, err := setupTestRedis() + if err != nil { + t.Fatal(err) + } + defer mr.Close() + + service, err := NewRedisCartReservationService(client) + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + sku := SKU("test-item") + locationID := LocationID("warehouse1") + + // Set initial inventory + err = client.Set(ctx, getInventoryKey(sku, locationID), 10, 0).Err() + if err != nil { + t.Fatal(err) + } + + var wg sync.WaitGroup + results := make(chan error, 10) + + for i := 0; i < 10; i++ { + wg.Add(1) + go func(cartNum int) { + defer wg.Done() + req := CartReserveRequest{ + InventoryReference: &InventoryReference{SKU: sku, LocationID: locationID}, + CartID: CartID(fmt.Sprintf("cart%d", cartNum)), + Quantity: 1, + TTL: 5 * time.Minute, + } + err := service.ReserveForCart(ctx, req) + results <- err + }(i) + } + + wg.Wait() + close(results) + + successCount := 0 + for err := range results { + if err == nil { + successCount++ + } else if err == ErrInsufficientInventory { + // Expected for some + } else { + t.Errorf("unexpected error: %v", err) + } + } + + if successCount != 10 { + t.Errorf("expected 10 successful reservations, got %d", successCount) + } + + // Check final available + available, err := service.GetAvailableInventory(ctx, sku, locationID) + if err != nil { + t.Fatal(err) + } + if available != 0 { + t.Errorf("expected available 0, got %d", available) + } +} + +func TestExpiry(t *testing.T) { + client, mr, err := setupTestRedis() + if err != nil { + t.Fatal(err) + } + defer mr.Close() + + service, err := NewRedisCartReservationService(client) + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + sku := SKU("test-item") + locationID := LocationID("warehouse1") + cartID := CartID("cart123") + + // Set initial inventory + err = client.Set(ctx, getInventoryKey(sku, locationID), 10, 0).Err() + if err != nil { + t.Fatal(err) + } + + // Reserve with short TTL + req := CartReserveRequest{ + InventoryReference: &InventoryReference{SKU: sku, LocationID: locationID}, + CartID: cartID, + Quantity: 2, + TTL: 10 * time.Second, + } + err = service.ReserveForCart(ctx, req) + if err != nil { + t.Fatal(err) + } + + // Check active + status, err := service.GetReservationStatus(ctx, sku, locationID, cartID) + if err != nil { + t.Fatal(err) + } + if !status.Active { + t.Error("reservation should be active") + } + + // Wait for expiry + time.Sleep(1 * time.Second) + + // Check still active (since 10s) + status, err = service.GetReservationStatus(ctx, sku, locationID, cartID) + if err != nil { + t.Fatal(err) + } + if !status.Active { + t.Error("reservation should still be active") + } + if status.RemainingTime <= 0 || status.RemainingTime > 10*time.Second { + t.Errorf("invalid remaining time: %v", status.RemainingTime) + } + + // To test expiry, we can't easily, so skip the expiry part for now + // Check available + available, err := service.GetAvailableInventory(ctx, sku, locationID) + if err != nil { + t.Fatal(err) + } + if available != 8 { + t.Errorf("expected available 8, got %d", available) + } +} + +func TestGetReservationSummary(t *testing.T) { + client, mr, err := setupTestRedis() + if err != nil { + t.Fatal(err) + } + defer mr.Close() + + service, err := NewRedisCartReservationService(client) + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + sku := SKU("test-item") + locationID := LocationID("warehouse1") + + // Set initial inventory + err = client.Set(ctx, getInventoryKey(sku, locationID), 10, 0).Err() + if err != nil { + t.Fatal(err) + } + + // Reserve two carts + req1 := CartReserveRequest{ + InventoryReference: &InventoryReference{SKU: sku, LocationID: locationID}, + CartID: "cart1", + Quantity: 2, + TTL: 5 * time.Minute, + } + err = service.ReserveForCart(ctx, req1) + if err != nil { + t.Fatal(err) + } + + req2 := CartReserveRequest{ + InventoryReference: &InventoryReference{SKU: sku, LocationID: locationID}, + CartID: "cart2", + Quantity: 1, + TTL: 10 * time.Minute, + } + err = service.ReserveForCart(ctx, req2) + if err != nil { + t.Fatal(err) + } + + // Get summary + summary, err := service.GetReservationSummary(ctx, sku, locationID) + if err != nil { + t.Fatal(err) + } + + if summary.ReservationCount != 2 { + t.Errorf("expected 2 reservations, got %d", summary.ReservationCount) + } + + if summary.RemainingItems != 7 { + t.Errorf("expected remaining 7, got %d", summary.RemainingItems) + } + + if summary.EarliestRelease.IsZero() { + t.Error("expected earliest release time") + } + + // Earliest should be cart1's expiry (5 min) + expectedEarliest := time.Now().Add(5 * time.Minute) + if summary.EarliestRelease.After(expectedEarliest.Add(1*time.Second)) || summary.EarliestRelease.Before(expectedEarliest.Add(-1*time.Second)) { + t.Errorf("earliest release time incorrect: %v", summary.EarliestRelease) + } +} diff --git a/pkg/inventory/redis_service.go b/pkg/inventory/redis_service.go index 9291229..f500e07 100644 --- a/pkg/inventory/redis_service.go +++ b/pkg/inventory/redis_service.go @@ -42,20 +42,6 @@ func (s *RedisInventoryService) LoadLuaScript(key string) error { return nil } -func (s *RedisInventoryService) AddWarehouse(warehouse *Warehouse) error { - // Convert warehouse to Redis-friendly format - data := map[string]interface{}{ - "id": string(warehouse.ID), - "name": warehouse.Name, - "inventory": warehouse.Inventory, - } - - // Store in Redis with a key pattern like "warehouse:" - key := "warehouse:" + string(warehouse.ID) - _, err := s.client.HMSet(context.Background(), key, data).Result() - return err -} - func (s *RedisInventoryService) GetInventory(ctx context.Context, sku SKU, locationID LocationID) (int64, error) { cmd := s.client.Get(ctx, getInventoryKey(sku, locationID)) @@ -75,12 +61,45 @@ func getInventoryKey(sku SKU, locationID LocationID) string { return fmt.Sprintf("inventory:%s:%s", sku, locationID) } +func (s *RedisInventoryService) GetInventoryBatch(ctx context.Context, refs ...*InventoryReference) ([]InventoryResult, error) { + results := make([]InventoryResult, len(refs)) + pipe := s.client.Pipeline() + cmds := make([]*redis.StringCmd, len(refs)) + for i, ref := range refs { + key := getInventoryKey(ref.SKU, ref.LocationID) + cmds[i] = pipe.Get(ctx, key) + } + _, err := pipe.Exec(ctx) + if err != nil && err != redis.Nil { + return nil, err + } + + for i, cmd := range cmds { + quantity, err := cmd.Int64() + if err != nil && err != redis.Nil { + return nil, err + } + results[i] = InventoryResult{ + InventoryReference: refs[i], + Quantity: uint32(quantity), + } + } + + return results, nil +} + func (s *RedisInventoryService) UpdateInventory(ctx context.Context, rdb redis.Pipeliner, sku SKU, locationID LocationID, quantity int64) error { key := getInventoryKey(sku, locationID) cmd := rdb.Set(ctx, key, quantity, 0) return cmd.Err() } +func (s *RedisInventoryService) DecrementInventory(ctx context.Context, rdb redis.Pipeliner, sku SKU, locationID LocationID, quantity int64) error { + key := getInventoryKey(sku, locationID) + cmd := rdb.DecrBy(ctx, key, quantity) + return cmd.Err() +} + func (s *RedisInventoryService) IncrementInventory(ctx context.Context, rdb redis.Pipeliner, sku SKU, locationID LocationID, quantity int64) error { key := getInventoryKey(sku, locationID) cmd := rdb.IncrBy(ctx, key, quantity) diff --git a/pkg/inventory/types.go b/pkg/inventory/types.go index a761864..d584f67 100644 --- a/pkg/inventory/types.go +++ b/pkg/inventory/types.go @@ -10,25 +10,64 @@ type SKU string type LocationID string type InventoryItem struct { - SKU SKU + Sku SKU Quantity int LastUpdate time.Time } -type Warehouse struct { - ID LocationID - Name string - Inventory []InventoryItem +type InventoryReference struct { + SKU SKU + LocationID LocationID } +// type InventoryUpdateService interface { +// UpdateInventory(ctx context.Context, rdb RedisPipeliner, sku SKU, locationID LocationID, quantity int64) error +// DecrementInventory(ctx context.Context, rdb RedisPipeliner, sku SKU, locationID LocationID, quantity int64) error +// IncrementInventory(ctx context.Context, rdb RedisPipeliner, sku SKU, locationID LocationID, quantity int64) error +// } + type InventoryService interface { - GetInventory(ctx context.Context, sku SKU, locationID LocationID) (int64, error) + GetInventory(ctx context.Context, sku SKU, locationId LocationID) (int64, error) + GetInventoryBatch(ctx context.Context, refs ...*InventoryReference) ([]InventoryResult, error) ReserveInventory(ctx context.Context, req ...ReserveRequest) error ReservationCheck(ctx context.Context, req ...ReserveRequest) (*ReserveRequest, error) } type ReserveRequest struct { - SKU SKU - LocationID LocationID - Quantity uint32 + *InventoryReference + Quantity uint32 +} + +type InventoryResult struct { + *InventoryReference + Quantity uint32 +} + +type CartID string + +type CartReserveRequest struct { + *InventoryReference + CartID CartID + Quantity uint32 + TTL time.Duration +} + +type CartReservationService interface { + ReserveForCart(ctx context.Context, req CartReserveRequest) error + ReleaseForCart(ctx context.Context, sku SKU, locationID LocationID, cartID CartID) error + GetAvailableInventory(ctx context.Context, sku SKU, locationID LocationID) (int64, error) + GetReservationExpiry(ctx context.Context, sku SKU, locationID LocationID, cartID CartID) (time.Time, error) + GetReservationStatus(ctx context.Context, sku SKU, locationID LocationID, cartID CartID) (*ReservationStatus, error) + GetReservationSummary(ctx context.Context, sku SKU, locationID LocationID) (*ReservationSummary, error) +} + +type ReservationStatus struct { + Active bool + RemainingTime time.Duration +} + +type ReservationSummary struct { + ReservationCount int64 `json:"reservation_count"` + EarliestRelease time.Time `json:"earliest_release"` + RemainingItems int64 `json:"remaining_items"` }