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
|
- 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
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
|
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)
|
||||||
|
|||||||
@@ -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 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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user