Compare commits

...
10 Commits
Author SHA1 Message Date
mats ba60e22404 fix 2026-07-03 23:11:03 +02:00
mats 4a3c953045 docs in go files?! 2026-07-03 18:30:13 +02:00
mats bc0579a3f2 agents md 2026-07-03 18:13:11 +02:00
mats 7d94d84eb3 refactor 2026-06-29 12:34:21 +02:00
matsandClaude Opus 4.8 7b25571a23 Merge 51b30de (more fancy features) into preupdate
Resolve README install URL to the git.k6n.net module path (matches go.mod
and the import examples); keep the cart-reservation additions. go mod tidy
promotes miniredis to a direct dep (used in tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 22:01:46 +02:00
mats e80be6149b preupdate 2026-06-17 07:16:11 +02:00
mats 51b30de2d8 more fancy features 2025-11-26 18:35:08 +01:00
mats 28c9a24dac update inventory package 2025-11-26 18:22:14 +01:00
mats 7ebbc97e38 update readme 2025-11-25 19:15:30 +01:00
mats 93afa0510e update 2025-11-25 19:14:46 +01:00
11 changed files with 835 additions and 35 deletions
+28 -2
View File
@@ -6,12 +6,14 @@ A Go library for managing inventory using Redis.
- Get and update inventory quantities - Get and update inventory quantities
- Reserve inventory with atomic operations using Lua scripts - 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 - Listen to inventory changes via Redis pub/sub
## Installation ## Installation
```bash ```bash
go get git.k6n.net/go-redis-inventory go get git.k6n.net/mats/go-redis-inventory
``` ```
## Usage ## Usage
@@ -19,7 +21,7 @@ go get git.k6n.net/go-redis-inventory
Import the package: Import the package:
```go ```go
import "git.k6n.net/go-redis-inventory/pkg/inventory" import "git.k6n.net/mats/go-redis-inventory/pkg/inventory"
``` ```
Create a Redis client and initialize the service: Create a Redis client and initialize the service:
@@ -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 ## Dependencies
- Go 1.25.1 or later - Go 1.25.1 or later
+67
View File
@@ -0,0 +1,67 @@
# go-redis-inventory — agents map
Redis-backed **inventory + reservation** service. Tracks stock levels and
runs atomic reservation operations via Lua scripts; also consumes an
`inventory_changed` pub/sub channel to mirror upstream changes.
## Where to start
- `README.md` — start here, it has the high-level intent.
- `pkg/inventory/types.go` — all the data structures (`InventoryItem`,
`InventoryReference`, `CartReserveRequest`, …).
- `pkg/inventory/redis_service.go` — basic get / batch get / reserve path
+ the Lua scripts (`reserveScript`, `reservationCheck`).
- `pkg/inventory/redis_reservation_service.go` — cart-shaped reservations
with release/availability semantics + corresponding `_test.go`.
- `pkg/inventory/listener.go``InventoryChangeListener` on the
`inventory_changed` pub/sub channel.
- `pkg/inventory/adapter.go``ReservationPolicyAdapter` bridging to
`platform/inventory.ReservationPolicy`.
## Architecture
- **Two responsibilities, one process**: (1) hold and serve inventory state;
(2) mirror upstream changes via pub/sub.
- **Atomicity**: reservation idempotency and TTL semantics come from Lua
scripts embedded here. The scripts are the seam — if they change, the
test in `redis_reservation_service_test.go` is the contract.
- **Adapter**: the `ReservationPolicyAdapter` is the seam to
`platform/inventory`. Two adapters (this service and any other downstream
caller) is what makes it a real seam, not a hypothetical one.
## Important files by area
| Area | File |
| ----------------------------- | --------------------------------------------------- |
| Domain model | `pkg/inventory/types.go` |
| Core service | `pkg/inventory/redis_service.go` |
| Reservation service (cart) | `pkg/inventory/redis_reservation_service.go` |
| Pub/sub listener | `pkg/inventory/listener.go` |
| Platform adapter | `pkg/inventory/adapter.go` |
| Reservation tests | `pkg/inventory/redis_reservation_service_test.go` |
## Cross-project seams
- **`platform/inventory`**: implements `ReservationPolicy` via the adapter.
- **`go-cart-actor`**: consumes reservations through that adapter.
- **Redis pub/sub**: receives `inventory_changed` events.
## Improvement suggestions
- **`types.go` carries mixed concerns** — `InventoryItem` (storage) sits
next to `CartReserveRequest` (request). Split into `types/`
(storage-level) and `types/cart.go` (request) so callers can depend on
only the slice they need.
- **Two reservation services**: `redis_service.go` has its own reserve,
`redis_reservation_service.go` has another, plus a `Level` type
(`level.go`). The relationship between them isn't documented; an
architecture paragraph at the top of the package would help
(`doc.go` or `pkg/inventory/README.md`).
- **`listener.go` is single-channel** — it consumes only `inventory_changed`.
If more channels need the same lifecycle (reconnect, dedupe, back-off),
factor out a tiny `pubsub.Consumer` and let the listener wrap it.
- **Lua scripts live inside Go source.** Promote them to `.lua` files,
registered via `//go:embed` — that makes them version-controlled and
testable independently of the Go code.
- **No config validation.** A malformed `REDIS_URL`/`CHANNEL` boots and
fails at first call. Fail fast in `main.go` with explicit error messages.
+14 -5
View File
@@ -1,10 +1,19 @@
module git.k6n.net/go-redis-inventory module git.k6n.net/mats/go-redis-inventory
go 1.25.1 go 1.26.2
require github.com/redis/go-redis/v9 v9.16.0
require ( require (
github.com/alicebob/miniredis/v2 v2.35.0
github.com/redis/go-redis/v9 v9.20.0
)
require (
git.k6n.net/mats/platform v0.0.0
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/sys v0.46.0 // indirect
) )
+13 -4
View File
@@ -1,10 +1,19 @@
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 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+42
View File
@@ -0,0 +1,42 @@
package inventory
import (
"context"
platforminv "git.k6n.net/mats/platform/inventory"
)
// ReservationPolicyAdapter wraps a RedisCartReservationService to satisfy the
// platform/inventory.ReservationPolicy interface. It translates between the
// platform's value types (SKU, LocationID, CartID) and the Redis-level types
// used internally by go-redis-inventory.
type ReservationPolicyAdapter struct {
inner *RedisCartReservationService
}
// NewReservationPolicyAdapter creates an adapter that satisfies
// platform/inventory.ReservationPolicy by delegating to the Redis-backed
// reservation service.
func NewReservationPolicyAdapter(inner *RedisCartReservationService) *ReservationPolicyAdapter {
return &ReservationPolicyAdapter{inner: inner}
}
func (a *ReservationPolicyAdapter) Reserve(ctx context.Context, req platforminv.CartReserveRequest) error {
return a.inner.ReserveForCart(ctx, CartReserveRequest{
InventoryReference: &InventoryReference{
SKU: SKU(req.SKU),
LocationID: LocationID(req.LocationID),
},
CartID: CartID(req.CartID),
Quantity: req.Quantity,
TTL: req.TTL,
})
}
func (a *ReservationPolicyAdapter) Release(ctx context.Context, sku platforminv.SKU, locationID platforminv.LocationID, cartID platforminv.CartID) error {
return a.inner.ReleaseForCart(ctx, SKU(sku), LocationID(locationID), CartID(cartID))
}
func (a *ReservationPolicyAdapter) Available(ctx context.Context, sku platforminv.SKU, locationID platforminv.LocationID) (int64, error) {
return a.inner.GetAvailableInventory(ctx, SKU(sku), LocationID(locationID))
}
+26
View File
@@ -0,0 +1,26 @@
// Package inventory is the Redis-backed stock + reservation service.
//
// Two distinct services coexist in this package and they are NOT
// redundant; they exist for different callers:
//
// - redis_service.go — stock levels and basic get/batch/reserve.
// - redis_reservation_service.go — cart-shaped reservations (with release
// + available-quantity semantics, used by
// go-cart-actor via the adapter).
//
// `Level` (level.go) is the canonical shape returned by readers and
// accepted by writers; it is reused across both services.
//
// adapter.go bridges to platform/inventory.ReservationPolicy so other
// services depend on the platform interface, not on this package.
//
// listener.go subscribes to the inventory_changed Redis pub/sub channel
// and mirrors upstream events into the local cache.
//
// Request and reservation DTOs (CartReserveRequest, ReserveRequest,
// CartID, ReservationStatus, ReservationSummary) now live in
// request_types.go. Storage types (InventoryItem, InventoryReference,
// InventoryResult) remain in types.go. When adding a new request shape,
// put it in request_types.go; when adding a new storage shape, put it
// in types.go.
package inventory
+249
View File
@@ -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
`
@@ -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)
}
}
+33 -14
View File
@@ -42,20 +42,6 @@ func (s *RedisInventoryService) LoadLuaScript(key string) error {
return nil 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:<ID>"
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) { func (s *RedisInventoryService) GetInventory(ctx context.Context, sku SKU, locationID LocationID) (int64, error) {
cmd := s.client.Get(ctx, getInventoryKey(sku, locationID)) 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) 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 { func (s *RedisInventoryService) UpdateInventory(ctx context.Context, rdb redis.Pipeliner, sku SKU, locationID LocationID, quantity int64) error {
key := getInventoryKey(sku, locationID) key := getInventoryKey(sku, locationID)
cmd := rdb.Set(ctx, key, quantity, 0) cmd := rdb.Set(ctx, key, quantity, 0)
return cmd.Err() 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 { func (s *RedisInventoryService) IncrementInventory(ctx context.Context, rdb redis.Pipeliner, sku SKU, locationID LocationID, quantity int64) error {
key := getInventoryKey(sku, locationID) key := getInventoryKey(sku, locationID)
cmd := rdb.IncrBy(ctx, key, quantity) cmd := rdb.IncrBy(ctx, key, quantity)
+37
View File
@@ -0,0 +1,37 @@
package inventory
import "time"
// Request and reservation DTOs — separated from storage types (types.go)
// so callers that only need read-side shapes don't pull in cart-reservation
// concepts. See doc.go for the split rationale.
type CartID string
type ReserveRequest struct {
*InventoryReference
Quantity uint32
}
type CartReserveRequest struct {
*InventoryReference
CartID CartID
Quantity uint32
TTL time.Duration
}
// CartReservationService interface was removed in favor of
// platform/inventory.ReservationPolicy. Consumers should depend on the
// platform seam; the concrete *RedisCartReservationService implements its
// methods directly.
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"`
}
+15 -10
View File
@@ -10,25 +10,30 @@ type SKU string
type LocationID string type LocationID string
type InventoryItem struct { type InventoryItem struct {
SKU SKU Sku SKU
Quantity int Quantity int
LastUpdate time.Time LastUpdate time.Time
} }
type Warehouse struct { type InventoryReference struct {
ID LocationID SKU SKU
Name string LocationID LocationID
Inventory []InventoryItem
} }
// 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 { 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 ReserveInventory(ctx context.Context, req ...ReserveRequest) error
ReservationCheck(ctx context.Context, req ...ReserveRequest) (*ReserveRequest, error) ReservationCheck(ctx context.Context, req ...ReserveRequest) (*ReserveRequest, error)
} }
type ReserveRequest struct { type InventoryResult struct {
SKU SKU *InventoryReference
LocationID LocationID Quantity uint32
Quantity uint32
} }