all the refactor
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s

This commit is contained in:
2026-06-28 17:51:52 +02:00
parent 7db0d236c7
commit aa8b2bdedc
84 changed files with 4328 additions and 2344 deletions
+69
View File
@@ -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)
}
}
}
+52
View File
@@ -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
View File
@@ -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 ""
}
+16 -9
View File
@@ -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))
})
}