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:". 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 "" }