all the refactor
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||
contract "git.k6n.net/mats/platform/order"
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// commitOrder applies an order.created event to inventory. The sale already
|
||||
// happened, so this is an unconditional permanent decrement (it may drive stock
|
||||
// negative — visible oversell, reconciled downstream as backorder), NOT a
|
||||
// check-and-reserve that could refuse. It then releases the cart's TTL hold and
|
||||
// emits the resulting level crossing. Idempotent per order via a Redis marker,
|
||||
// since the bus is at-least-once.
|
||||
func commitOrder(ctx context.Context, rdb *redis.Client, svc *inventory.RedisInventoryService, resv *inventory.RedisCartReservationService, conn *rabbit.Conn, country string, low int64, c contract.Created) {
|
||||
if c.OrderID == "" {
|
||||
return
|
||||
}
|
||||
// Idempotency: first writer wins; redelivery is a no-op.
|
||||
fresh, err := rdb.SetNX(ctx, "invcommit:"+c.OrderID, "1", 30*24*time.Hour).Result()
|
||||
if err != nil {
|
||||
log.Printf("inventory: commit idempotency order=%s: %v", c.OrderID, err)
|
||||
return
|
||||
}
|
||||
if !fresh {
|
||||
return // already committed
|
||||
}
|
||||
// Default location for lines that don't carry their own: the order country
|
||||
// (central stock), falling back to the service's configured country.
|
||||
defaultLoc := c.Country
|
||||
if defaultLoc == "" {
|
||||
defaultLoc = country
|
||||
}
|
||||
for _, line := range c.Lines {
|
||||
if line.SKU == "" || line.Quantity <= 0 {
|
||||
continue
|
||||
}
|
||||
loc := inventory.LocationID(defaultLoc)
|
||||
if line.Location != "" {
|
||||
loc = inventory.LocationID(line.Location)
|
||||
}
|
||||
sku := inventory.SKU(line.SKU)
|
||||
pipe := rdb.Pipeline()
|
||||
if err := svc.DecrementInventory(ctx, pipe, sku, loc, int64(line.Quantity)); err != nil {
|
||||
log.Printf("inventory: commit decrement sku=%s order=%s: %v", sku, c.OrderID, err)
|
||||
continue
|
||||
}
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
log.Printf("inventory: commit exec sku=%s order=%s: %v", sku, c.OrderID, err)
|
||||
continue
|
||||
}
|
||||
// Release the cart's TTL hold now the sale is committed (best-effort —
|
||||
// it would expire on its own anyway).
|
||||
if c.CartID != "" && resv != nil {
|
||||
if err := resv.ReleaseForCart(ctx, sku, loc, inventory.CartID(c.CartID)); err != nil {
|
||||
log.Printf("inventory: commit release sku=%s cart=%s: %v", sku, c.CartID, err)
|
||||
}
|
||||
}
|
||||
// Emit the level crossing from the new on-hand.
|
||||
if newQty, err := svc.GetInventory(ctx, sku, loc); err == nil {
|
||||
emitLevelIfChanged(ctx, rdb, conn, country, low, string(sku), string(loc), newQty)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/platform/event"
|
||||
invlevel "git.k6n.net/mats/platform/inventory"
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// emitLevelIfChanged classifies qty into a Level and emits an
|
||||
// inventory.level_changed bus event only when it differs from the level last
|
||||
// emitted for this SKU+location (the levelKey marker). Exact quantity never
|
||||
// leaves the service; only the coarse crossing does. Shared by every write path
|
||||
// (catalog feed, order.created commit). A nil conn (no bus) makes it a no-op.
|
||||
func emitLevelIfChanged(ctx context.Context, rdb *redis.Client, conn *rabbit.Conn, country string, low int64, sku, loc string, qty int64) {
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
lvl := invlevel.LevelFor(qty, low)
|
||||
// Atomic read-old + write-new of the level marker; redis.Nil means the
|
||||
// marker didn't exist yet (first observation), which we treat as a crossing.
|
||||
prev, err := rdb.SetArgs(ctx, levelKey(sku, loc), string(lvl), redis.SetArgs{Get: true}).Result()
|
||||
if err != nil && err != redis.Nil {
|
||||
log.Printf("inventory: level marker sku=%s: %v", sku, err)
|
||||
return
|
||||
}
|
||||
if prev == string(lvl) {
|
||||
return // no threshold crossing — stay off the bus
|
||||
}
|
||||
payload, err := invlevel.LevelChanged{SKU: sku, Location: loc, Level: lvl}.Encode()
|
||||
if err != nil {
|
||||
log.Printf("inventory: encode level payload sku=%s: %v", sku, err)
|
||||
return
|
||||
}
|
||||
ev := event.Event{
|
||||
ID: "invlvl-" + sku + "-" + loc + "-" + strconv.FormatInt(time.Now().UnixNano(), 36),
|
||||
Type: event.InventoryLevelChanged,
|
||||
Source: "inventory",
|
||||
Subject: sku,
|
||||
Payload: payload,
|
||||
Time: time.Now(),
|
||||
Meta: map[string]string{"country": country},
|
||||
}
|
||||
if err := rabbit.PublishEvent(conn.Connection(), "inventory", ev); err != nil {
|
||||
log.Printf("inventory: publish level event sku=%s: %v", sku, err)
|
||||
}
|
||||
}
|
||||
+125
-20
@@ -6,14 +6,18 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||
"git.k6n.net/mats/platform/event"
|
||||
orderevent "git.k6n.net/mats/platform/order"
|
||||
"git.k6n.net/mats/slask-finder/pkg/index"
|
||||
"git.k6n.net/mats/slask-finder/pkg/messaging"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/redis/go-redis/v9/maintnotifications"
|
||||
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
@@ -66,6 +70,12 @@ var country = "se"
|
||||
var redisAddress = "10.10.3.18:6379"
|
||||
var redisPassword = "slaskredis"
|
||||
|
||||
// lowWatermark is the cutoff between "low" and "high" stock for the level
|
||||
// crossings emitted on the bus (0 < qty <= lowWatermark ⇒ low). Single global
|
||||
// number for now; per-SKU / per-tenant thresholds can come later behind the
|
||||
// same inventory.LevelChanged payload.
|
||||
var lowWatermark int64 = 5
|
||||
|
||||
func init() {
|
||||
// Override redis config from environment variables if set
|
||||
if addr, ok := os.LookupEnv("REDIS_ADDRESS"); ok {
|
||||
@@ -77,6 +87,19 @@ func init() {
|
||||
if ctry, ok := os.LookupEnv("COUNTRY"); ok {
|
||||
country = ctry
|
||||
}
|
||||
if v, ok := os.LookupEnv("INVENTORY_LOW_WATERMARK"); ok {
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
lowWatermark = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// levelKey is the Redis key holding the last-emitted Level for a SKU+location.
|
||||
// It is the crossing-detection marker: the level listener only emits when the
|
||||
// freshly computed level differs from this stored value, so the bus carries
|
||||
// crossings, not every quantity tick.
|
||||
func levelKey(sku, loc string) string {
|
||||
return "invlevel:" + sku + ":" + loc
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -114,39 +137,121 @@ func main() {
|
||||
rdb: rdb,
|
||||
ctx: ctx,
|
||||
svc: *s,
|
||||
country: country,
|
||||
low: lowWatermark,
|
||||
}
|
||||
|
||||
amqpUrl, ok := os.LookupEnv("RABBIT_HOST")
|
||||
if ok {
|
||||
log.Printf("Connecting to rabbitmq")
|
||||
conn, err := amqp.DialConfig(amqpUrl, amqp.Config{
|
||||
Properties: amqp.NewConnectionProperties(),
|
||||
})
|
||||
//a.conn = conn
|
||||
conn, err := rabbit.Dial(amqpUrl, "cart-inventory")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to RabbitMQ: %v", err)
|
||||
}
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open a channel: %v", err)
|
||||
}
|
||||
// items listener
|
||||
err = messaging.ListenToTopic(ch, country, "item_added", func(d amqp.Delivery) error {
|
||||
wg := &sync.WaitGroup{}
|
||||
err = index.ForEachRawDataItemInJSONArray(d.Body, func(item *index.RawDataItem) error {
|
||||
stockhandler.HandleItem(item, wg)
|
||||
// The catalog-feed handler emits inventory.level_changed crossings
|
||||
// directly on this connection as it writes stock.
|
||||
stockhandler.conn = conn
|
||||
// Reconnecting consumer: re-listen on reconnect.
|
||||
conn.NotifyOnReconnect(func() {
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
log.Printf("inventory: channel on reconnect: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Producer side: declare the "inventory" topic exchange we publish
|
||||
// inventory.level_changed crossings to. Durable + idempotent, so
|
||||
// re-declaring on each reconnect is safe.
|
||||
if err := ch.ExchangeDeclare("inventory", "topic", true, false, false, false, nil); err != nil {
|
||||
log.Printf("inventory: declare inventory exchange: %v", err)
|
||||
}
|
||||
|
||||
// 1) LEGACY raw `catalog.item_changed` wire — payload is a raw item
|
||||
// JSON array. Consumed by the historical pricewatcher +
|
||||
// writeradmin item-changed paths. Carries RawDataItem bodies
|
||||
// with full finder-style metadata (including the stock
|
||||
// field that the new projection wire does not).
|
||||
//
|
||||
// This listener is the one that mutates Redis stock state via
|
||||
// StockHandler.HandleItem.
|
||||
if err := rabbit.ListenToPattern(ch, "catalog", string(event.CatalogItemChanged), func(d amqp.Delivery) error {
|
||||
var ev event.Event
|
||||
if err := json.Unmarshal(d.Body, &ev); err != nil {
|
||||
log.Printf("inventory: decode catalog event: %v", err)
|
||||
return nil
|
||||
}
|
||||
if ev.Meta["country"] != "" && ev.Meta["country"] != country {
|
||||
return nil // not for our country
|
||||
}
|
||||
wg := &sync.WaitGroup{}
|
||||
if err := index.ForEachRawDataItemInJSONArray(ev.Payload, func(item *index.RawDataItem) error {
|
||||
stockhandler.HandleItem(item, wg)
|
||||
return nil
|
||||
}); err != nil {
|
||||
log.Printf("inventory: apply items: %v", err)
|
||||
}
|
||||
wg.Wait()
|
||||
log.Print("Batch done...")
|
||||
return err
|
||||
})
|
||||
return nil
|
||||
return nil
|
||||
}); err != nil {
|
||||
log.Printf("inventory: listen on reconnect: %v", err)
|
||||
}
|
||||
|
||||
// 2) (REMOVED) `catalog.projection_published` listener — moved to
|
||||
// the cart service. Per docs/inventory-shape.md, inventory's
|
||||
// bus role is emit-only (inventory.level_changed crossings back
|
||||
// to finder on the inventory exchange). The catalog projection
|
||||
// is read by the cart at checkout-time lookups via its own
|
||||
// in-memory CatalogCache, not by inventory. Stock state stays
|
||||
// sourced from the legacy raw wire (block 1 below) until
|
||||
// inventory itself emits level_changed on a stock mutation.
|
||||
|
||||
// 3) order.created → commit stock (permanent decrement), release the
|
||||
// cart hold, emit the level crossing. This is the async commit
|
||||
// path: checkout no longer decrements synchronously; it announces
|
||||
// a completed sale and inventory reacts. Idempotent per order.
|
||||
if err := rabbit.ListenToPattern(ch, "order", string(event.OrderCreated), func(d amqp.Delivery) error {
|
||||
var ev event.Event
|
||||
if err := json.Unmarshal(d.Body, &ev); err != nil {
|
||||
log.Printf("inventory: decode order event: %v", err)
|
||||
return nil
|
||||
}
|
||||
if target := countryForEvent(&ev); target != "" && target != country {
|
||||
return nil // not for our country/tenant
|
||||
}
|
||||
created, err := orderevent.Decode(ev.Payload)
|
||||
if err != nil {
|
||||
log.Printf("inventory: decode order.created: %v", err)
|
||||
return nil
|
||||
}
|
||||
commitOrder(ctx, rdb, s, r, conn, country, lowWatermark, created)
|
||||
return nil
|
||||
}); err != nil {
|
||||
log.Printf("inventory: listen order.created on reconnect: %v", err)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to listen to item_added topic: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Start HTTP server
|
||||
log.Println("Starting HTTP server on :8080")
|
||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||
}
|
||||
|
||||
// countryForEvent extracts the best-available country/tenant identifier
|
||||
// from an envelope. Lookup order: Meta["country"], Meta["tenant"], then
|
||||
// the Source prefix "catalog:<x>". An empty string means "no decoration" —
|
||||
// callers treat that as "for everyone" (same as the legacy listener).
|
||||
func countryForEvent(ev *event.Event) string {
|
||||
if t := strings.TrimSpace(ev.Meta["country"]); t != "" {
|
||||
return t
|
||||
}
|
||||
if t := strings.TrimSpace(ev.Meta["tenant"]); t != "" {
|
||||
return t
|
||||
}
|
||||
if strings.HasPrefix(ev.Source, "catalog:") {
|
||||
if t := strings.TrimSpace(strings.TrimPrefix(ev.Source, "catalog:")); t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
"git.k6n.net/mats/slask-finder/pkg/types"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
@@ -15,12 +16,17 @@ type StockHandler struct {
|
||||
ctx context.Context
|
||||
svc inventory.RedisInventoryService
|
||||
MainStockLocationID inventory.LocationID
|
||||
|
||||
// Bus producer config. conn is nil when RABBIT_HOST is unset (no bus); the
|
||||
// handler still updates Redis and just skips emitting.
|
||||
conn *rabbit.Conn
|
||||
country string
|
||||
low int64
|
||||
}
|
||||
|
||||
func (s *StockHandler) HandleItem(item types.Item, wg *sync.WaitGroup) {
|
||||
wg.Go(func() {
|
||||
ctx := s.ctx
|
||||
pipe := s.rdb.Pipeline()
|
||||
centralStock, ok := item.GetNumberFieldValue("inStock")
|
||||
if !ok {
|
||||
centralStock = 0
|
||||
@@ -28,16 +34,17 @@ func (s *StockHandler) HandleItem(item types.Item, wg *sync.WaitGroup) {
|
||||
sku, ok := item.GetStringFieldValue("sku")
|
||||
if !ok {
|
||||
log.Printf("unable to parse central stock for item: sku missing")
|
||||
centralStock = 0
|
||||
} else {
|
||||
s.svc.UpdateInventory(ctx, pipe, inventory.SKU(sku), s.MainStockLocationID, int64(centralStock))
|
||||
return
|
||||
}
|
||||
// for id, value := range item.GetStock() {
|
||||
// s.svc.UpdateInventory(ctx, pipe, inventory.SKU(sku), inventory.LocationID(id), int64(value))
|
||||
// }
|
||||
_, err := pipe.Exec(ctx)
|
||||
if err != nil {
|
||||
// One linear pass: write the authoritative quantity, then derive and
|
||||
// emit the level crossing from the same value. No Redis pub/sub
|
||||
// round-trip — Redis stays the internal store, the bus carries levels.
|
||||
pipe := s.rdb.Pipeline()
|
||||
s.svc.UpdateInventory(ctx, pipe, inventory.SKU(sku), s.MainStockLocationID, int64(centralStock))
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
log.Printf("unable to update stock: %v", err)
|
||||
return
|
||||
}
|
||||
emitLevelIfChanged(ctx, s.rdb, s.conn, s.country, s.low, sku, string(s.MainStockLocationID), int64(centralStock))
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user