Compare commits
4
Commits
7b25571a23
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba60e22404 | ||
|
|
4a3c953045 | ||
|
|
bc0579a3f2 | ||
|
|
7d94d84eb3 |
@@ -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.
|
||||||
@@ -4,11 +4,16 @@ go 1.26.2
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alicebob/miniredis/v2 v2.35.0
|
github.com/alicebob/miniredis/v2 v2.35.0
|
||||||
github.com/redis/go-redis/v9 v9.16.0
|
github.com/redis/go-redis/v9 v9.20.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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
|
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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,9 +6,14 @@ 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 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
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,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"`
|
||||||
|
}
|
||||||
@@ -33,41 +33,7 @@ type InventoryService interface {
|
|||||||
ReservationCheck(ctx context.Context, req ...ReserveRequest) (*ReserveRequest, error)
|
ReservationCheck(ctx context.Context, req ...ReserveRequest) (*ReserveRequest, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReserveRequest struct {
|
|
||||||
*InventoryReference
|
|
||||||
Quantity uint32
|
|
||||||
}
|
|
||||||
|
|
||||||
type InventoryResult struct {
|
type InventoryResult struct {
|
||||||
*InventoryReference
|
*InventoryReference
|
||||||
Quantity uint32
|
Quantity uint32
|
||||||
}
|
}
|
||||||
|
|
||||||
type CartID string
|
|
||||||
|
|
||||||
type CartReserveRequest struct {
|
|
||||||
*InventoryReference
|
|
||||||
CartID CartID
|
|
||||||
Quantity uint32
|
|
||||||
TTL time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
type CartReservationService interface {
|
|
||||||
ReserveForCart(ctx context.Context, req CartReserveRequest) error
|
|
||||||
ReleaseForCart(ctx context.Context, sku SKU, locationID LocationID, cartID CartID) error
|
|
||||||
GetAvailableInventory(ctx context.Context, sku SKU, locationID LocationID) (int64, error)
|
|
||||||
GetReservationExpiry(ctx context.Context, sku SKU, locationID LocationID, cartID CartID) (time.Time, error)
|
|
||||||
GetReservationStatus(ctx context.Context, sku SKU, locationID LocationID, cartID CartID) (*ReservationStatus, error)
|
|
||||||
GetReservationSummary(ctx context.Context, sku SKU, locationID LocationID) (*ReservationSummary, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReservationStatus struct {
|
|
||||||
Active bool
|
|
||||||
RemainingTime time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReservationSummary struct {
|
|
||||||
ReservationCount int64 `json:"reservation_count"`
|
|
||||||
EarliestRelease time.Time `json:"earliest_release"`
|
|
||||||
RemainingItems int64 `json:"remaining_items"`
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user