269 lines
9.0 KiB
Go
269 lines
9.0 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
|
|
|
|
// rdb + conn back the batch write path (pipelined UpdateInventory + level
|
|
// crossing). conn is nil when RABBIT_HOST is unset; emit is then skipped.
|
|
rdb *redis.Client
|
|
conn *rabbit.Conn
|
|
}
|
|
|
|
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, rdb: rdb}
|
|
|
|
// 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)
|
|
http.HandleFunc("POST /reservations", server.reserveInventoryHandler)
|
|
http.HandleFunc("POST /reservations/release", server.releaseReservationHandler)
|
|
http.HandleFunc("POST /inventory/batch", server.batchInventoryHandler)
|
|
|
|
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
|
|
// Share the bus connection with the batch handler so its writes emit
|
|
// inventory.level_changed crossings too.
|
|
server.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 ""
|
|
}
|