all the refactor
This commit is contained in:
+180
-31
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/internal/ucp"
|
||||
@@ -21,8 +23,10 @@ import (
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||
"git.k6n.net/mats/platform/catalog"
|
||||
"git.k6n.net/mats/platform/config"
|
||||
"git.k6n.net/mats/slask-finder/pkg/messaging"
|
||||
"git.k6n.net/mats/platform/event"
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
@@ -116,6 +120,123 @@ func matchesSkuAndLocation(update inventory.InventoryResult, item cart.CartItem)
|
||||
return false
|
||||
}
|
||||
|
||||
// catalogProjectionCache is the cart's in-process map of SKU → catalog.Projection,
|
||||
// updated by reading catalog.projection_published events off the bus. It is the
|
||||
// cart-side source of truth for catalog facts (price, tax_class, inventory_tracked
|
||||
// flag, currency, image, brand) used at add-to-cart / checkout-reserve decisions.
|
||||
// Stock state is NOT cached here — exact qty is still queried synchronously from
|
||||
// the inventory service at decision points per docs/inventory-shape.md.
|
||||
//
|
||||
// Concurrency: read-mostly workload justifies sync.RWMutex.
|
||||
//
|
||||
// Single-tier storage. The bus is the only writer — the cart does not separately
|
||||
// seed from a snapshot file at boot, because the access pattern is point lookup
|
||||
// of cart-active SKUs only, and coupling two sources (mmap snapshot + bus-fed
|
||||
// map) created duplicated state with subtle drift risk and an mmap-SIGBUS hazard.
|
||||
// Cold-grace: the cache may be incomplete for an SKU the cart sees before the
|
||||
// first projection_published arrives for it; the cart falls back to the existing
|
||||
// PRODUCT_BASE_URL HTTP fetch and overlays the cache fields on top of it.
|
||||
//
|
||||
// Deletes carry a tombstone in the map (not a plain delete) so a bus delete of
|
||||
// an SKU that already fits the cache rules correctly shadows it for Get /
|
||||
// IsDeleted lookups — preventing an oversight from re-fetching a deleted SKU
|
||||
// before the cache rebuilds. Tombstones have a per-pod lifetime; bus-driven,
|
||||
// so the next sweep on the same SKU flips the entry back to live.
|
||||
type catalogProjectionCache struct {
|
||||
mu sync.RWMutex
|
||||
items map[string]projectionEntry
|
||||
}
|
||||
|
||||
// projectionEntry is a cache slot: a live projection, or a tombstone (deleted)
|
||||
// that shadows the live fallback.
|
||||
type projectionEntry struct {
|
||||
proj catalog.Projection
|
||||
deleted bool
|
||||
}
|
||||
|
||||
func newCatalogProjectionCache() *catalogProjectionCache {
|
||||
return &catalogProjectionCache{items: make(map[string]projectionEntry, 8192)}
|
||||
}
|
||||
|
||||
func (c *catalogProjectionCache) Apply(updates []catalog.ProjectionUpdate) (upserts, deletes int) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, u := range updates {
|
||||
if u.Deleted {
|
||||
c.items[u.SKU] = projectionEntry{deleted: true}
|
||||
deletes++
|
||||
continue
|
||||
}
|
||||
if u.SKU == "" {
|
||||
continue
|
||||
}
|
||||
c.items[u.SKU] = projectionEntry{proj: u.Projection}
|
||||
upserts++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c *catalogProjectionCache) Get(sku string) (catalog.Projection, bool) {
|
||||
c.mu.RLock()
|
||||
e, ok := c.items[sku]
|
||||
c.mu.RUnlock()
|
||||
if !ok {
|
||||
return catalog.Projection{}, false
|
||||
}
|
||||
if e.deleted {
|
||||
return catalog.Projection{}, false
|
||||
}
|
||||
return e.proj, true
|
||||
}
|
||||
|
||||
// IsDeleted reports whether a tombstone exists for sku — used by HTTP-fetch
|
||||
// call sites to skip the fallback for an SKU the bus has explicitly deleted,
|
||||
// avoiding a wasteful round-trip on items the writer has just removed.
|
||||
func (c *catalogProjectionCache) IsDeleted(sku string) bool {
|
||||
c.mu.RLock()
|
||||
e, ok := c.items[sku]
|
||||
c.mu.RUnlock()
|
||||
return ok && e.deleted
|
||||
}
|
||||
|
||||
// Len reports the number of map entries (bus-fed upserts + tombstones). Used
|
||||
// for log gauges only.
|
||||
func (c *catalogProjectionCache) Len() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return len(c.items)
|
||||
}
|
||||
|
||||
// projectionDeliveryHandler is the AMQP delivery handler for
|
||||
// `catalog.projection_published` on the `catalog` topic exchange. Decodes the
|
||||
// []ProjectionUpdate batch and merges it into cache. Returns nil on a batch
|
||||
// that doesn't apply locally so the AMQP delivery is acked; returns an error
|
||||
// on a decode failure so the broker knows to redeliver / quarantine.
|
||||
//
|
||||
// Recovery: any panic in a single event is caught so the goroutine keeps
|
||||
// draining the queue; the bus stays live across malformed events.
|
||||
func projectionDeliveryHandler(cache *catalogProjectionCache) func(amqp.Delivery) error {
|
||||
return func(d amqp.Delivery) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("projection handler panic recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
var ev event.Event
|
||||
if err := json.Unmarshal(d.Body, &ev); err != nil {
|
||||
return fmt.Errorf("decode event envelope: %w", err)
|
||||
}
|
||||
updates, err := catalog.DecodeUpdates(ev.Payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode projection updates: %w", err)
|
||||
}
|
||||
upserts, deletes := cache.Apply(updates)
|
||||
log.Printf("cart: catalog projection batch applied: upserts=%d deletes=%d total=%d eventID=%s source=%q",
|
||||
upserts, deletes, cache.Len(), ev.ID, ev.Source)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
// cartPort is the bare HTTP port. It drives both the local listener and the
|
||||
@@ -282,29 +403,48 @@ func main() {
|
||||
|
||||
cartMCP := cartmcp.New(pool)
|
||||
|
||||
// Publish each applied mutation to the "cart"/"mutation" RabbitMQ topic so the
|
||||
// backoffice /commerce live feed (and other consumers) see cart activity.
|
||||
// Best-effort: if AMQP is unreachable the cart still serves; the feed stays empty.
|
||||
// Stream each applied mutation to the shared "mutations" exchange (routing key
|
||||
// mutation.cart) so the backoffice /commerce live feed (and other consumers)
|
||||
// see cart activity. Best-effort: if AMQP is unreachable the cart still serves.
|
||||
if amqpUrl != "" {
|
||||
if conn, derr := amqp.Dial(amqpUrl); derr != nil {
|
||||
if conn, derr := rabbit.Dial(amqpUrl, "cart"); derr != nil {
|
||||
log.Printf("cart: AMQP connect failed, mutation feed disabled: %v", derr)
|
||||
} else {
|
||||
if ch, cerr := conn.Channel(); cerr == nil {
|
||||
_ = messaging.DefineTopic(ch, "cart", "mutation") // idempotent; non-fatal
|
||||
_ = ch.Close()
|
||||
}
|
||||
pool.AddListener(actor.NewAmqpListener(conn, func(id uint64, results []actor.ApplyResult) (any, error) {
|
||||
types := make([]string, 0, len(results))
|
||||
for _, r := range results {
|
||||
types = append(types, r.Type)
|
||||
}
|
||||
return map[string]any{"cartId": id, "mutations": types}, nil
|
||||
}))
|
||||
log.Printf("cart: mutation feed enabled (cart/mutation)")
|
||||
listener := actor.NewAmqpListener(conn.Connection(), "cart", actor.MutationSummary)
|
||||
listener.DefineTopics()
|
||||
pool.AddListener(listener)
|
||||
log.Printf("cart: mutation feed enabled (mutation.cart)")
|
||||
}
|
||||
}
|
||||
|
||||
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp)) //inventoryService, inventoryReservationService)
|
||||
// catalog.projection_published consumer (the cart is the canonical
|
||||
// consumer of the projection wire per docs/inventory-shape.md; inventory
|
||||
// emits level crossings back to finder only, never the reverse). Own
|
||||
// connection keeps the cart's mutation-feed lifecycle decoupled from a
|
||||
// read-write-consume connection that could grow event handlers later
|
||||
// (e.g. promotions reacting to a price drop on a watched SKU).
|
||||
projectionCache := newCatalogProjectionCache()
|
||||
if amqpUrl != "" {
|
||||
if pConn, perr := rabbit.Dial(amqpUrl, "cart-catalog-projection"); perr != nil {
|
||||
log.Printf("cart: catalog projection consumer DISABLED (dial failed): %v", perr)
|
||||
} else {
|
||||
pConn.NotifyOnReconnect(func() {
|
||||
ch, err := pConn.Channel()
|
||||
if err != nil {
|
||||
log.Printf("cart: channel on projection reconnect: %v", err)
|
||||
return
|
||||
}
|
||||
if err := rabbit.ListenToPattern(ch, "catalog", string(event.CatalogProjectionPublished), projectionDeliveryHandler(projectionCache)); err != nil {
|
||||
log.Printf("cart: bind catalog.projection_published on reconnect: %v", err)
|
||||
_ = ch.Close()
|
||||
}
|
||||
})
|
||||
defer func() { _ = pConn.Close() }()
|
||||
log.Printf("cart: catalog projection consumer ENABLED (catalog.projection_published on 'catalog' exchange)")
|
||||
}
|
||||
}
|
||||
|
||||
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), projectionCache) //inventoryService, inventoryReservationService)
|
||||
|
||||
app := &App{
|
||||
pool: pool,
|
||||
@@ -374,6 +514,23 @@ func main() {
|
||||
debugMux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
||||
debugMux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||
debugMux.Handle("/metrics", promhttp.Handler())
|
||||
|
||||
// Projection cache probe (dev/verification). GET /debug/projection/{sku} →
|
||||
// the resolved projection + the bus-fed map size. map_entries counts both
|
||||
// live projections and tombstones; a found=true response confirms the bus
|
||||
// consumer delivered the SKU to this pod.
|
||||
debugMux.HandleFunc("/debug/projection/", func(w http.ResponseWriter, r *http.Request) {
|
||||
sku := strings.TrimPrefix(r.URL.Path, "/debug/projection/")
|
||||
p, found := projectionCache.Get(sku)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"sku": sku,
|
||||
"found": found,
|
||||
"projection": p,
|
||||
"map_entries": projectionCache.Len(),
|
||||
"deleted": projectionCache.IsDeleted(sku),
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Grain pool health: simple capacity check (mirrors previous GrainHandler.IsHealthy)
|
||||
grainCount, capacity := app.pool.LocalUsage()
|
||||
@@ -434,19 +591,11 @@ func main() {
|
||||
srvErr <- srv.ListenAndServe()
|
||||
}()
|
||||
|
||||
// listener := inventory.NewInventoryChangeListener(rdb, context.Background(), func(changes []inventory.InventoryChange) {
|
||||
// for _, change := range changes {
|
||||
// log.Printf("inventory change: %v", change)
|
||||
// inventoryPubSub.Publish(change)
|
||||
// }
|
||||
// })
|
||||
|
||||
// go func() {
|
||||
// err := listener.Start()
|
||||
// if err != nil {
|
||||
// log.Fatalf("Unable to start inventory listener: %v", err)
|
||||
// }
|
||||
// }()
|
||||
// Inventory change consumption used to live here over the bare Redis
|
||||
// `inventory_changed` channel; it is now owned by the cart-inventory
|
||||
// service, which translates quantity changes into inventory.level_changed
|
||||
// bus crossings. The cart reads exact stock synchronously when it needs it.
|
||||
// See docs/inventory-shape.md.
|
||||
|
||||
log.Printf("Server started at %s (debug %s)", httpAddr, debugAddr)
|
||||
|
||||
|
||||
+54
-5
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -41,12 +42,19 @@ var (
|
||||
type PoolServer struct {
|
||||
actor.GrainPool[cart.CartGrain]
|
||||
pod_name string
|
||||
// idx is the bus-fed catalog projection cache, consulted at HTTP-fetch
|
||||
// sites (AddSkuToCartHandler, buildItemGroups) to overlay authoritative
|
||||
// price / tax_class / display fields onto AddItem before mutation. nil is
|
||||
// tolerated so handlers still serve if the bus consumer is unavailable
|
||||
// (orphan / quarantine / CI), with a plain cache-miss fall-through.
|
||||
idx *catalogProjectionCache
|
||||
}
|
||||
|
||||
func NewPoolServer(pool actor.GrainPool[cart.CartGrain], pod_name string) *PoolServer {
|
||||
func NewPoolServer(pool actor.GrainPool[cart.CartGrain], pod_name string, idx *catalogProjectionCache) *PoolServer {
|
||||
srv := &PoolServer{
|
||||
GrainPool: pool,
|
||||
pod_name: pod_name,
|
||||
idx: idx,
|
||||
}
|
||||
|
||||
return srv
|
||||
@@ -67,10 +75,26 @@ func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id c
|
||||
|
||||
func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
sku := r.PathValue("sku")
|
||||
if s.idx != nil && s.idx.IsDeleted(sku) {
|
||||
// Bus-deleted SKU — skip the HTTP fetch and reject the add. Mirrors the
|
||||
// product-fetcher's "product service returned %d for sku %s" shape so
|
||||
// upstream code that pattern-matches that string still works, AND we
|
||||
// publish StatusNotFound so the HTTP response carries the right code
|
||||
// (the handler's error-return path otherwise renders as 500).
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(fmt.Sprintf(`{"error":"product service returned %d for sku %s (bus-deleted)","sku":%q}`, 404, sku, sku)))
|
||||
return nil
|
||||
}
|
||||
msg, err := GetItemAddMessage(r.Context(), sku, 1, getCountryFromHost(r.Host), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.idx != nil {
|
||||
if p, ok := s.idx.Get(sku); ok {
|
||||
ApplyProjectionOverlay(msg, p)
|
||||
}
|
||||
}
|
||||
|
||||
data, err := s.ApplyLocal(r.Context(), id, msg)
|
||||
if err != nil {
|
||||
@@ -154,16 +178,32 @@ type itemGroup struct {
|
||||
// drive child pricing, then children are fetched concurrently. Input order is
|
||||
// preserved. Items that fail to fetch are skipped (logged), matching the prior
|
||||
// best-effort behavior.
|
||||
func buildItemGroups(ctx context.Context, items []Item, country string) []itemGroup {
|
||||
//
|
||||
// idx is the bus-fed projection cache; when non-nil, every AddItem built here
|
||||
// has its cache-covered fields (price/tax_class/display/inventory_tracked/
|
||||
// drop_ship) overlaid onto the HTTP-fetched answer. Cache-hit calls naturally
|
||||
// become authoritative for what the projection carries; HTTP stays for
|
||||
// dimensions (parent width/height for child pricing), seller/orgPrice and the
|
||||
// dynamic ExtraJson product data. Skipped cleanly when idx is nil.
|
||||
func buildItemGroups(ctx context.Context, items []Item, country string, idx *catalogProjectionCache) []itemGroup {
|
||||
groups := make([]itemGroup, len(items))
|
||||
wg := sync.WaitGroup{}
|
||||
for i, itm := range items {
|
||||
wg.Go(func() {
|
||||
if idx != nil && idx.IsDeleted(itm.Sku) {
|
||||
log.Printf("error adding item %s: bus-deleted (skipping)", itm.Sku)
|
||||
return
|
||||
}
|
||||
parentMsg, parentProduct, err := BuildItemMessage(ctx, itm.Sku, itm.Quantity, country, itm.StoreId, nil)
|
||||
if err != nil {
|
||||
log.Printf("error adding item %s: %v", itm.Sku, err)
|
||||
return
|
||||
}
|
||||
if idx != nil {
|
||||
if p, ok := idx.Get(itm.Sku); ok {
|
||||
ApplyProjectionOverlay(parentMsg, p)
|
||||
}
|
||||
}
|
||||
parentMsg.CustomFields = itm.CustomFields
|
||||
groups[i].parent = parentMsg
|
||||
|
||||
@@ -174,11 +214,20 @@ func buildItemGroups(ctx context.Context, items []Item, country string) []itemGr
|
||||
cwg := sync.WaitGroup{}
|
||||
for j, child := range itm.Children {
|
||||
cwg.Go(func() {
|
||||
if idx != nil && idx.IsDeleted(child.Sku) {
|
||||
log.Printf("error adding child %s of %s: bus-deleted (skipping)", child.Sku, itm.Sku)
|
||||
return
|
||||
}
|
||||
childMsg, _, err := BuildItemMessage(ctx, child.Sku, child.Quantity, country, child.StoreId, parentProduct)
|
||||
if err != nil {
|
||||
log.Printf("error adding child %s of %s: %v", child.Sku, itm.Sku, err)
|
||||
return
|
||||
}
|
||||
if idx != nil {
|
||||
if p, ok := idx.Get(child.Sku); ok {
|
||||
ApplyProjectionOverlay(childMsg, p)
|
||||
}
|
||||
}
|
||||
childMsg.CustomFields = child.CustomFields
|
||||
children[j] = childMsg
|
||||
})
|
||||
@@ -263,7 +312,7 @@ func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request,
|
||||
if _, err := s.ApplyLocal(r.Context(), id, &messages.ClearCartRequest{}); err != nil {
|
||||
return err
|
||||
}
|
||||
groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country)
|
||||
groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country, s.idx)
|
||||
reply, err := s.applyItemGroups(r.Context(), id, groups)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -286,7 +335,7 @@ func (s *PoolServer) AddMultipleItemHandler(w http.ResponseWriter, r *http.Reque
|
||||
return err
|
||||
}
|
||||
|
||||
groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country)
|
||||
groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country, s.idx)
|
||||
reply, err := s.applyItemGroups(r.Context(), id, groups)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -326,7 +375,7 @@ func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request
|
||||
Children: addRequest.Children,
|
||||
CustomFields: addRequest.CustomFields,
|
||||
}
|
||||
groups := buildItemGroups(r.Context(), []Item{item}, addRequest.Country)
|
||||
groups := buildItemGroups(r.Context(), []Item{item}, addRequest.Country, s.idx)
|
||||
reply, err := s.applyItemGroups(r.Context(), id, groups)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -201,6 +201,9 @@ func ToItemAddMessage(item *ProductItem, parent *ProductItem, storeId *string, q
|
||||
Name: item.Title,
|
||||
Image: item.Img,
|
||||
Stock: stock,
|
||||
// item.Vat is the product's VAT as a raw integer percent (e.g. 25);
|
||||
// ×100 converts to the platform basis-point scale (2500). This is the
|
||||
// single conversion boundary — everything downstream is basis points.
|
||||
Tax: int32(item.Vat * 100),
|
||||
SellerId: strconv.Itoa(item.SupplierId),
|
||||
SellerName: item.SupplierName,
|
||||
|
||||
@@ -56,7 +56,7 @@ func newTestPoolServer(t *testing.T) *PoolServer {
|
||||
if err != nil {
|
||||
t.Fatalf("new pool: %v", err)
|
||||
}
|
||||
return NewPoolServer(pool, "test")
|
||||
return NewPoolServer(pool, "test", nil)
|
||||
}
|
||||
|
||||
// exampleId is the catalog item the fetcher rework was validated against.
|
||||
@@ -338,7 +338,7 @@ func TestChildren_ParentLinkage(t *testing.T) {
|
||||
Children: []Item{{Sku: exampleId, Quantity: 1, StoreId: &childStore}},
|
||||
}}
|
||||
|
||||
groups := buildItemGroups(ctx, items, "se")
|
||||
groups := buildItemGroups(ctx, items, "se", nil)
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("groups = %d, want 1", len(groups))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.k6n.net/mats/platform/catalog"
|
||||
"git.k6n.net/mats/platform/money"
|
||||
)
|
||||
|
||||
// TestProjectionCache_ApplyUpserts covers the happy path: a batch of N
|
||||
// projections reaches the cache via Apply, all are visible via Get.
|
||||
func TestProjectionCache_ApplyUpserts(t *testing.T) {
|
||||
c := newCatalogProjectionCache()
|
||||
updates := []catalog.ProjectionUpdate{
|
||||
{Projection: catalog.Projection{ID: "id-A", SKU: "A", PriceIncVat: money.Cents(100_00), TaxClass: "standard"}},
|
||||
{Projection: catalog.Projection{ID: "id-B", SKU: "B", PriceIncVat: money.Cents(150_00), TaxClass: "reduced"}},
|
||||
{Projection: catalog.Projection{ID: "id-C", SKU: "C", PriceIncVat: money.Cents(0), TaxClass: ""}},
|
||||
}
|
||||
upserts, deletes := c.Apply(updates)
|
||||
if upserts != 3 || deletes != 0 {
|
||||
t.Fatalf("counts: upserts=%d deletes=%d, want 3/0", upserts, deletes)
|
||||
}
|
||||
for _, want := range []struct {
|
||||
sku string
|
||||
price money.Cents
|
||||
taxCls string
|
||||
}{
|
||||
{"A", money.Cents(100_00), "standard"},
|
||||
{"B", money.Cents(150_00), "reduced"},
|
||||
{"C", money.Cents(0), ""},
|
||||
} {
|
||||
got, ok := c.Get(want.sku)
|
||||
if !ok {
|
||||
t.Fatalf("Get(%q): missing", want.sku)
|
||||
}
|
||||
if got.PriceIncVat != want.price || got.TaxClass != want.taxCls {
|
||||
t.Fatalf("Get(%q) = %+v, want price=%d taxCls=%q", want.sku, got, want.price, want.taxCls)
|
||||
}
|
||||
}
|
||||
if c.Len() != 3 {
|
||||
t.Fatalf("Len = %d, want 3", c.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectionCache_DeleteTombstone verifies that a delete update sets a
|
||||
// tombstone (not removes the entry), so:
|
||||
// - Get returns (_, false) on a deleted SKU
|
||||
// - IsDeleted returns true
|
||||
// - the same entry continues to count toward Len (size accounting)
|
||||
func TestProjectionCache_DeleteTombstone(t *testing.T) {
|
||||
c := newCatalogProjectionCache()
|
||||
c.Apply([]catalog.ProjectionUpdate{
|
||||
{Projection: catalog.Projection{ID: "id-A", SKU: "A", PriceIncVat: money.Cents(100_00)}},
|
||||
})
|
||||
if _, ok := c.Get("A"); !ok {
|
||||
t.Fatalf("A: expected hit before delete")
|
||||
}
|
||||
|
||||
upserts, deletes := c.Apply([]catalog.ProjectionUpdate{
|
||||
{Projection: catalog.Projection{SKU: "A"}, Deleted: true},
|
||||
})
|
||||
if upserts != 0 || deletes != 1 {
|
||||
t.Fatalf("delete counts: upserts=%d deletes=%d, want 0/1", upserts, deletes)
|
||||
}
|
||||
|
||||
if got, ok := c.Get("A"); ok {
|
||||
t.Fatalf("A after delete: got %+v ok=true, want miss", got)
|
||||
}
|
||||
if !c.IsDeleted("A") {
|
||||
t.Fatalf("A after delete: IsDeleted false")
|
||||
}
|
||||
if c.IsDeleted("UNKNOWN") {
|
||||
t.Fatalf("UNKNOWN: IsDeleted should be false (entry doesn't exist)")
|
||||
}
|
||||
if c.Len() != 1 {
|
||||
t.Fatalf("Len after tombstone: %d, want 1 (tombstone still in map)", c.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectionCache_BusResurrect verifies that a fresh upsert after a delete
|
||||
// clears the tombstone (live entry replaces it): Get returns the projection,
|
||||
// IsDeleted flips to false. Mirrors how the live catalog keeps flipping state.
|
||||
func TestProjectionCache_BusResurrect(t *testing.T) {
|
||||
c := newCatalogProjectionCache()
|
||||
c.Apply([]catalog.ProjectionUpdate{
|
||||
{Projection: catalog.Projection{SKU: "A", PriceIncVat: money.Cents(50_00)}},
|
||||
{Projection: catalog.Projection{SKU: "A"}, Deleted: true},
|
||||
})
|
||||
if !c.IsDeleted("A") {
|
||||
t.Fatalf("A: not tombstoned after delete")
|
||||
}
|
||||
|
||||
c.Apply([]catalog.ProjectionUpdate{
|
||||
{Projection: catalog.Projection{SKU: "A", PriceIncVat: money.Cents(75_00)}},
|
||||
})
|
||||
|
||||
got, ok := c.Get("A")
|
||||
if !ok {
|
||||
t.Fatalf("A: expected live after re-upsert")
|
||||
}
|
||||
if got.PriceIncVat != money.Cents(75_00) {
|
||||
t.Fatalf("A re-upsert price = %d, want 7500", got.PriceIncVat)
|
||||
}
|
||||
if c.IsDeleted("A") {
|
||||
t.Fatalf("A: still tombstoned after re-upsert")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectionCache_EmptySKUIgnored validates that an upsert with an empty
|
||||
// SKU is a no-op (defensive — bus should never publish "" but cheaper to log
|
||||
// + drop than to populate an ambiguous cache slot).
|
||||
func TestProjectionCache_EmptySKUIgnored(t *testing.T) {
|
||||
c := newCatalogProjectionCache()
|
||||
upserts, _ := c.Apply([]catalog.ProjectionUpdate{
|
||||
{Projection: catalog.Projection{SKU: "", PriceIncVat: money.Cents(100_00)}},
|
||||
})
|
||||
if upserts != 0 {
|
||||
t.Fatalf("empty-SKU upsert: %d, want 0", upserts)
|
||||
}
|
||||
if c.Len() != 0 {
|
||||
t.Fatalf("Len after empty-SKU: %d, want 0", c.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectionCache_BusRaceSafe runs Apply + Get concurrently under -race
|
||||
// to lock down the lock order. Run via `go test -race ./cmd/cart/...`.
|
||||
func TestProjectionCache_BusRaceSafe(t *testing.T) {
|
||||
c := newCatalogProjectionCache()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for i := 0; i < 500; i++ {
|
||||
c.Apply([]catalog.ProjectionUpdate{
|
||||
{Projection: catalog.Projection{SKU: "X", PriceIncVat: money.Cents(int64(i * 100))}},
|
||||
})
|
||||
}
|
||||
}()
|
||||
for i := 0; i < 2000; i++ {
|
||||
_, _ = c.Get("X")
|
||||
_ = c.IsDeleted("X")
|
||||
_ = c.Len()
|
||||
}
|
||||
<-done
|
||||
if _, ok := c.Get("X"); !ok {
|
||||
t.Fatalf("final Get(X): miss after concurrent writes")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaxResolveBp covers the static TaxClass→bp mapping used by
|
||||
// ApplyProjectionOverlay. Adds a regression net for the common Nordic rates.
|
||||
func TestTaxResolveBp(t *testing.T) {
|
||||
cases := []struct {
|
||||
class string
|
||||
want int
|
||||
}{
|
||||
{"standard", 2500},
|
||||
{"reduced", 1200},
|
||||
{"lowered", 600},
|
||||
{"zero", 0},
|
||||
{"exempt", 0},
|
||||
{"", 2500}, // missing class defaults to standard (matches mutation registry)
|
||||
{"unknown-class", 2500},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := resolveTaxBp(tc.class)
|
||||
if got != tc.want {
|
||||
t.Errorf("resolveTaxBp(%q) = %d, want %d", tc.class, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
"git.k6n.net/mats/platform/catalog"
|
||||
)
|
||||
|
||||
// taxClassToBp maps the canonical TaxClass string identifiers published on
|
||||
// catalog.Projection to basis-points-of-a-percent (rate × 100) — the single
|
||||
// platform-wide rate scale used by AddItem.Tax (int32 basis points), the
|
||||
// mutation registry, and the tax.Provider Compute formula.
|
||||
//
|
||||
// Values reflect the standard tariff names used by the Nordic configuration.
|
||||
// Per-country / per-tenant numeric resolution (e.g. Finland 25.5%, Ireland
|
||||
// 13.5%) is delegated to platform/tax and is a follow-up: this static map
|
||||
// gets the common case right (2500 / 1200 / 600 / 0) and lets the cache path
|
||||
// ship while the platform/tax lookup is wired into the cart pool server.
|
||||
var taxClassToBp = map[string]int{
|
||||
"standard": 2500,
|
||||
"reduced": 1200, // common reduced rate (e.g. SE/NO food)
|
||||
"lowered": 600, // super-reduced
|
||||
"zero": 0, // zero-rated (export, healthcare, ...)
|
||||
"exempt": 0, // similarly untaxed, separate nominal class
|
||||
}
|
||||
|
||||
// resolveTaxBp returns the basis-point rate for a TaxClass string, falling
|
||||
// back to platform "standard" (25%) when the class is unrecognised. The
|
||||
// default matches the grain's existing behaviour (AddItem falls back to 2500
|
||||
// when m.Tax == 0 — see pkg/cart/mutation_add_item.go).
|
||||
func resolveTaxBp(class string) int {
|
||||
if bp, ok := taxClassToBp[class]; ok {
|
||||
return bp
|
||||
}
|
||||
return 2500
|
||||
}
|
||||
|
||||
// ApplyProjectionOverlay overlays the cache's authoritative catalog facts onto
|
||||
// an AddItem message that was just produced by a PRODUCT_BASE_URL HTTP fetch.
|
||||
//
|
||||
// Authoritative-from-cache fields (these WIN over the HTTP-fetched value when
|
||||
// both are present, because the bus is the live stream):
|
||||
//
|
||||
// Price (bus has post-class-overrides; product service has static)
|
||||
// Tax (TaxClass → bp via resolveTaxBp; only when TaxClass is set —
|
||||
// an empty TaxClass means the producer didn't classify,
|
||||
// so we leave the HTTP-fetched rate intact rather than
|
||||
// quietly swapping in a default 2500 and miscategorising)
|
||||
// Title (canonical, post-trim)
|
||||
// Image (canonical)
|
||||
// InventoryTracked (only flips to true; false is the zero value in proto
|
||||
// and may be unset on the wire, so we don't second-guess)
|
||||
// DropShip (only flips to true; same reasoning)
|
||||
//
|
||||
// The HTTP-fetched values remain authoritative for fields the cache does not
|
||||
// carry: SellerId / SellerName (marketplace split), Stock (per docs/inventory-
|
||||
// shape.md stock is queried synchronously from inventory, not cached),
|
||||
// OrgPrice / Discount (not yet on the projection schema), the dynamic
|
||||
// ExtraJson product data (configurator options — width/height used by
|
||||
// accessory child-pricing), and the catalog uint32 ItemId (the product
|
||||
// service's integer id which messages.AddItem.ItemId dedupes on — Projection
|
||||
// only carries the opaque string ID, so a cache-only build path is blocked
|
||||
// and the HTTP fetch is preserved).
|
||||
//
|
||||
// Tombstoned SKUs (catalog.projection_published with deleted=true) → the
|
||||
// caller checks idx.IsDeleted(sku) before this helper and short-circuits.
|
||||
func ApplyProjectionOverlay(msg *messages.AddItem, p catalog.Projection) {
|
||||
if msg == nil {
|
||||
return
|
||||
}
|
||||
// Price: positive bus values override HTTP. A zero PriceIncVat is treated
|
||||
// as "no value" rather than "free".
|
||||
if p.PriceIncVat.Int64() > 0 {
|
||||
msg.Price = p.PriceIncVat.Int64()
|
||||
}
|
||||
// Tax: only override when the bus has classified the SKU. An empty TaxClass
|
||||
// means the producer had nothing to say about VAT; the HTTP rate is more
|
||||
// trustworthy in that case than a guessed 2500.
|
||||
if p.TaxClass != "" {
|
||||
msg.Tax = int32(resolveTaxBp(p.TaxClass))
|
||||
}
|
||||
// Display fields: only override if non-empty so the HTTP-fanned value
|
||||
// remains in place when the cache hasn't populated them yet.
|
||||
if p.Title != "" {
|
||||
msg.Name = p.Title
|
||||
}
|
||||
if p.Image != "" {
|
||||
msg.Image = p.Image
|
||||
}
|
||||
// Flags: only flip to true (positive boolean signals). A false is the
|
||||
// proto-zero value and not worth overriding.
|
||||
if p.InventoryTracked {
|
||||
msg.InventoryTracked = true
|
||||
}
|
||||
if p.DropShip {
|
||||
msg.DropShip = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
"git.k6n.net/mats/platform/catalog"
|
||||
"git.k6n.net/mats/platform/money"
|
||||
)
|
||||
|
||||
// TestApplyProjectionOverlay_PricePositiveOnly covers the contract that a cache
|
||||
// hit with a positive PriceIncVat overrides HTTP-fetched price, but a zero
|
||||
// (unbroadcast) PriceIncVat must NOT overwrite a valid HTTP price — it would
|
||||
// be a silent corruption.
|
||||
func TestApplyProjectionOverlay_PricePositiveOnly(t *testing.T) {
|
||||
startPrice := int64(95_00)
|
||||
base := func() *messages.AddItem {
|
||||
return &messages.AddItem{Sku: "X", Price: startPrice, Name: "http-name", Image: "http.jpg", Tax: 2500}
|
||||
}
|
||||
|
||||
// Positive cache value wins.
|
||||
m := base()
|
||||
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", Title: "cache-name", Image: "cache.jpg", PriceIncVat: money.Cents(120_00), TaxClass: "reduced"})
|
||||
if m.Price != 120_00 {
|
||||
t.Errorf("positive cache price: got %d, want 12000", m.Price)
|
||||
}
|
||||
|
||||
// Zero cache value must NOT overwrite (treat as "no value").
|
||||
m = base()
|
||||
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", PriceIncVat: money.Cents(0)})
|
||||
if m.Price != startPrice {
|
||||
t.Errorf("zero cache price overwrote HTTP price: got %d, want %d (HTTP)", m.Price, startPrice)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyProjectionOverlay_TaxOnlyWhenClassSet verifies the TaxClass-skip on
|
||||
// an unclassified cache entry: HTTP rate is more trustworthy than a guessed
|
||||
// 2500 default, so msg.Tax stays untouched.
|
||||
func TestApplyProjectionOverlay_TaxOnlyWhenClassSet(t *testing.T) {
|
||||
base := func() *messages.AddItem {
|
||||
return &messages.AddItem{Sku: "X", Tax: 600} // HTTP-fetched lowered 6%
|
||||
}
|
||||
|
||||
// Empty TaxClass: HTTP rate preserved.
|
||||
m := base()
|
||||
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", TaxClass: ""})
|
||||
if m.Tax != 600 {
|
||||
t.Errorf("empty TaxClass overwrote HTTP tax: got %d, want 600", m.Tax)
|
||||
}
|
||||
|
||||
// Non-empty: cache rate wins.
|
||||
m = base()
|
||||
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", TaxClass: "reduced"})
|
||||
if m.Tax != 1200 {
|
||||
t.Errorf("TaxClass=reduced: got %d, want 1200", m.Tax)
|
||||
}
|
||||
|
||||
m = base()
|
||||
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", TaxClass: "zero"})
|
||||
if m.Tax != 0 {
|
||||
t.Errorf("TaxClass=zero: got %d, want 0", m.Tax)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyProjectionOverlay_DisplayFields verifies Title/Image override only
|
||||
// when the cache actually carries a value (an unbus-fed "" isn't propagated
|
||||
// over a valid HTTP value).
|
||||
func TestApplyProjectionOverlay_DisplayFields(t *testing.T) {
|
||||
base := func() *messages.AddItem {
|
||||
return &messages.AddItem{Sku: "X", Name: "http-name", Image: "http.jpg"}
|
||||
}
|
||||
|
||||
m := base()
|
||||
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", Title: "cache-name", Image: "cache.jpg"})
|
||||
if m.Name != "cache-name" {
|
||||
t.Errorf("Title overlay: got %q, want %q", m.Name, "cache-name")
|
||||
}
|
||||
if m.Image != "cache.jpg" {
|
||||
t.Errorf("Image overlay: got %q, want %q", m.Image, "cache.jpg")
|
||||
}
|
||||
|
||||
// Empty cache Title/Image must NOT overwrite.
|
||||
m = base()
|
||||
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", Title: "", Image: ""})
|
||||
if m.Name != "http-name" {
|
||||
t.Errorf("empty cache Title wiped HTTP name: got %q", m.Name)
|
||||
}
|
||||
if m.Image != "http.jpg" {
|
||||
t.Errorf("empty cache Image wiped HTTP image: got %q", m.Image)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyProjectionOverlay_FlagFlipToTrueOnly locks down the
|
||||
// InventoryTracked / DropShip positive-flip semantics. False in the cache
|
||||
// is the proto zero value and may be unset on the wire; we don't second-guess.
|
||||
func TestApplyProjectionOverlay_FlagFlipToTrueOnly(t *testing.T) {
|
||||
// HTTP set both true (e.g. live SKU). Cache also has them true. Should
|
||||
// stay true.
|
||||
m := &messages.AddItem{InventoryTracked: true, DropShip: true}
|
||||
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", InventoryTracked: true, DropShip: true})
|
||||
if !m.InventoryTracked || !m.DropShip {
|
||||
t.Fatalf("true-from-cache over a true-from-HTTP: %+v", m)
|
||||
}
|
||||
|
||||
// HTTP set false (default), cache says true → upgrade to true.
|
||||
m = &messages.AddItem{InventoryTracked: false, DropShip: false}
|
||||
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", InventoryTracked: true, DropShip: true})
|
||||
if !m.InventoryTracked || !m.DropShip {
|
||||
t.Errorf("true-from-cache should flip false-from-HTTP: %+v", m)
|
||||
}
|
||||
|
||||
// HTTP already true, cache says false → must NOT downgrade (false would
|
||||
// be the proto zero value; trust the live HTTP signal here).
|
||||
m = &messages.AddItem{InventoryTracked: true, DropShip: true}
|
||||
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", InventoryTracked: false, DropShip: false})
|
||||
if !m.InventoryTracked || !m.DropShip {
|
||||
t.Errorf("false-from-cache must NOT overwrite true-from-HTTP: %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyProjectionOverlay_NilMsgSafe confirms the helper tolerates a nil
|
||||
// pointer (defensive — caller already validates, but a future caller may not).
|
||||
func TestApplyProjectionOverlay_NilMsgSafe(t *testing.T) {
|
||||
// Must not panic.
|
||||
ApplyProjectionOverlay(nil, catalog.Projection{SKU: "X"})
|
||||
}
|
||||
|
||||
// TestIsDeletedVsGet asserts the two methods don't conflict: a tombstoned SKU
|
||||
// is reported by both IsDeleted (true) and Get (miss).
|
||||
func TestIsDeletedVsGet(t *testing.T) {
|
||||
c := newCatalogProjectionCache()
|
||||
c.Apply([]catalog.ProjectionUpdate{
|
||||
{Projection: catalog.Projection{SKU: "DL", PriceIncVat: money.Cents(10_00)}},
|
||||
{Projection: catalog.Projection{SKU: "DL"}, Deleted: true},
|
||||
})
|
||||
if _, ok := c.Get("DL"); ok {
|
||||
t.Fatalf("Get on tombstone: ok=true")
|
||||
}
|
||||
if !c.IsDeleted("DL") {
|
||||
t.Fatalf("IsDeleted on tombstone: false")
|
||||
}
|
||||
if c.IsDeleted("DL-fresh") {
|
||||
t.Fatalf("IsDeleted on absent entry: true")
|
||||
}
|
||||
if _, ok := c.Get("DL-fresh"); ok {
|
||||
t.Fatalf("Get on absent entry: ok=true")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user