This commit is contained in:
2026-06-28 18:47:02 +02:00
parent 63b0112cc7
commit e9e9700a09
2 changed files with 109 additions and 15 deletions
+17 -1
View File
@@ -75,6 +75,7 @@ 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")
country := getCountryFromHost(r.Host)
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
@@ -86,7 +87,21 @@ func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request,
http.Error(w, fmt.Sprintf("product service returned %d for sku %s (bus-deleted)", 404, sku), http.StatusNotFound)
return nil
}
msg, err := GetItemAddMessage(r.Context(), sku, 1, getCountryFromHost(r.Host), nil)
// Cache-only fast path: AddSkuToCartHandler is a SINGLE top-level add
// (qty=1, no children). When the projection carries full authoritative
// fields, we skip PRODUCT_BASE_URL entirely and build the AddItem
// directly from the cache. This eliminates one HTTP round-trip per
// add-to-cart under steady-state bus-fed conditions.
var msg *messages.AddItem
if s.idx != nil {
if p, ok := s.idx.Get(sku); ok && HasRequiredFields(p) {
msg = BuildAddItemFromCache(sku, 1, country, nil, p)
}
}
if msg == nil {
var err error
msg, err = GetItemAddMessage(r.Context(), sku, 1, country, nil)
if err != nil {
return err
}
@@ -95,6 +110,7 @@ func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request,
ApplyProjectionOverlay(msg, p)
}
}
}
data, err := s.ApplyLocal(r.Context(), id, msg)
if err != nil {
+82 -4
View File
@@ -34,6 +34,82 @@ func resolveTaxBp(class string) int {
return 2500
}
// HasRequiredFields asserts the catalog Projection carries enough authoritative
// fields for the cart to build a complete AddItem WITHOUT an HTTP fallback to
// PRODUCT_BASE_URL. Required:
//
// PriceIncVat > 0 (treats zero as "unbus-fed", not "free"; positive-only
// matches every other cache-authoritative field)
// TaxClass != "" (string identifier; numeric rate resolved via
// resolveTaxBp at the same scale as the HTTP path)
// ItemID != 0 (uint32 dedup key; zero means the producer didn't
// publish one, so we don't claim we know the id)
//
// Display fields (Title, Image) and fulfillment flags (InventoryTracked,
// DropShip) are optional — when present they populate the line, when absent
// the proto-zero value is acceptable.
func HasRequiredFields(p catalog.Projection) bool {
return p.PriceIncVat.Int64() > 0 &&
p.TaxClass != "" &&
p.ItemID != 0
}
// BuildAddItemFromCache constructs a complete AddItem message directly from a
// cache hit that satisfies HasRequiredFields. The caller has already confirmed
// the projection is well-formed; we map every cache-visible carrier onto the
// proto fields the existing HTTP path would have constructed.
//
// Fields mapped:
//
// Sku ← input
// ItemId ← p.ItemID (uint32 dedup key)
// Quantity ← input
// Country ← input
// StoreId ← input
// Price ← p.PriceIncVat.Int64()
// Tax ← int32(resolveTaxBp(p.TaxClass))
// Name ← p.Title (may be empty)
// Image ← p.Image (may be empty)
// InventoryTracked ← p.InventoryTracked
// DropShip ← p.DropShip
//
// Fields intentionally left blank (cache-only gap; the schema doesn't yet
// carry them and the audience for those fields is downstream of the cart line
// — they are NOT required for line dedup or pricing):
//
// SellerId / SellerName — marketplace split; not on Projection yet.
// Stock — queried synchronously from inventory at
// decision points (per docs/inventory-shape.md).
// OrgPrice / Discount — pre-discount price; a future Projection
// extension when promotion previews need it.
// ExtraJson — dynamic product data (configurator
// width/height etc.); a future schema extension
// for cache-only-skip-HTTP for groups with
// configurator children.
//
// IMPORTANT: this helper is for TOP-LEVEL lines only. Configurator children
// re-price based on parent dimensions (ToItemAddMessage's parent != nil
// path); since the Projection does not yet carry width/height, callers must
// keep the existing HTTP-fetch flow for groups with children.
//
// Tombstoned SKUs never reach this helper; callers check idx.IsDeleted(sku)
// first and short-circuit.
func BuildAddItemFromCache(sku string, qty int, country string, storeId *string, p catalog.Projection) *messages.AddItem {
return &messages.AddItem{
Sku: sku,
ItemId: p.ItemID,
Quantity: int32(qty),
Country: country,
StoreId: storeId,
Price: p.PriceIncVat.Int64(),
Tax: int32(resolveTaxBp(p.TaxClass)),
Name: p.Title,
Image: p.Image,
InventoryTracked: p.InventoryTracked,
DropShip: p.DropShip,
}
}
// ApplyProjectionOverlay overlays the cache's authoritative catalog facts onto
// an AddItem message that was just produced by a PRODUCT_BASE_URL HTTP fetch.
//
@@ -61,10 +137,12 @@ func resolveTaxBp(class string) int {
// ExtraJson product data (configurator options — width/height used by
// accessory child-pricing).
//
// Now that ItemID round-trips from the bus, a future "cache-only" build path
// (skipping PRODUCT_BASE_URL entirely for known SKUs) is unblocked — the
// only fields left HTTP-only in that future path are Sellers, Stock, OrgPrice
// and configurator extras, none of which affect line dedup.
// SKUs that satisfy HasRequiredFields AND have no configurator children
// route through BuildAddItemFromCache instead — see the cache-only fast path
// in buildItemGroups / AddSkuToCartHandler. ApplyProjectionOverlay remains
// for the cold-grace case (unbus-fed cache entry) AND for groups with
// children (the parent's HTTP fetch is required for child dimension-driven
// pricing; Overlay is applied after the fetch).
//
// Tombstoned SKUs (catalog.projection_published with deleted=true) → the
// caller checks idx.IsDeleted(sku) before this helper and short-circuits.