Compare commits
10
Commits
8d2f5db47d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba60e22404 | ||
|
|
4a3c953045 | ||
|
|
bc0579a3f2 | ||
|
|
7d94d84eb3 | ||
|
|
7b25571a23 | ||
|
|
e80be6149b | ||
|
|
51b30de2d8 | ||
|
|
28c9a24dac | ||
|
|
7ebbc97e38 | ||
|
|
93afa0510e |
@@ -6,12 +6,14 @@ 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
|
||||
|
||||
```bash
|
||||
go get git.k6n.net/go-redis-inventory
|
||||
go get git.k6n.net/mats/go-redis-inventory
|
||||
```
|
||||
|
||||
## Usage
|
||||
@@ -19,7 +21,7 @@ go get git.k6n.net/go-redis-inventory
|
||||
Import the package:
|
||||
|
||||
```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:
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -1,10 +1,19 @@
|
||||
module git.k6n.net/go-redis-inventory
|
||||
module git.k6n.net/mats/go-redis-inventory
|
||||
|
||||
go 1.25.1
|
||||
|
||||
require github.com/redis/go-redis/v9 v9.16.0
|
||||
go 1.26.2
|
||||
|
||||
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/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
|
||||
)
|
||||
|
||||
@@ -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/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
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/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
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=
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:<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) {
|
||||
|
||||
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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
+14
-9
@@ -10,25 +10,30 @@ 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
|
||||
type InventoryResult struct {
|
||||
*InventoryReference
|
||||
Quantity uint32
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user