Files
go-cart-actor/cmd/inventory/main.go
T
mats aa8b2bdedc
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
all the refactor
2026-06-28 17:51:52 +02:00

258 lines
8.5 KiB
Go

package main
import (
"context"
"encoding/json"
"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"
"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"
)
type Server struct {
inventoryService *inventory.RedisInventoryService
reservationService *inventory.RedisCartReservationService
}
func (srv *Server) livezHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
func (srv *Server) readyzHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
func (srv *Server) getInventoryHandler(w http.ResponseWriter, r *http.Request) {
sku := inventory.SKU(r.PathValue("sku"))
locationID := inventory.LocationID(r.PathValue("locationId"))
quantity, err := srv.inventoryService.GetInventory(r.Context(), sku, locationID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
response := map[string]int64{"quantity": quantity}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func (srv *Server) getReservationHandler(w http.ResponseWriter, r *http.Request) {
sku := inventory.SKU(r.PathValue("sku"))
locationID := inventory.LocationID(r.PathValue("locationId"))
summary, err := srv.reservationService.GetReservationSummary(r.Context(), sku, locationID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(summary)
}
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 {
redisAddress = addr
}
if password, ok := os.LookupEnv("REDIS_PASSWORD"); ok {
redisPassword = password
}
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() {
var ctx = context.Background()
rdb := redis.NewClient(&redis.Options{
Addr: redisAddress,
Password: redisPassword, // no password set
DB: 0, // use default DB
MaintNotificationsConfig: &maintnotifications.Config{
Mode: maintnotifications.ModeDisabled,
},
})
s, err := inventory.NewRedisInventoryService(rdb)
if err != nil {
log.Fatalf("Unable to connect to inventory redis: %v", err)
return
}
r, err := inventory.NewRedisCartReservationService(rdb)
if err != nil {
log.Fatalf("Unable to connect to reservation redis: %v", err)
return
}
server := &Server{inventoryService: s, reservationService: r}
// Set up HTTP routes
http.HandleFunc("/livez", server.livezHandler)
http.HandleFunc("/readyz", server.readyzHandler)
http.HandleFunc("/inventory/{sku}/{locationId}", server.getInventoryHandler)
http.HandleFunc("/reservations/{sku}/{locationId}", server.getReservationHandler)
stockhandler := &StockHandler{
MainStockLocationID: inventory.LocationID(country),
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 := rabbit.Dial(amqpUrl, "cart-inventory")
if err != nil {
log.Fatalf("Failed to connect to RabbitMQ: %v", err)
}
// 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 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)
}
})
}
// 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 ""
}