fixes
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
package actor
|
||||
|
||||
import "sync"
|
||||
|
||||
// keyedMutex provides one logical mutex per key (grain id). It is the
|
||||
// mechanism that enforces the core actor guarantee: a single grain only ever
|
||||
// processes one message (spawn / mutation / read) at a time, while distinct
|
||||
// grains run fully in parallel.
|
||||
//
|
||||
// Locks are reference counted and removed once no caller holds or waits on
|
||||
// them, so memory stays proportional to in-flight grains rather than the total
|
||||
// number of grains ever touched.
|
||||
type keyedMutex struct {
|
||||
mu sync.Mutex
|
||||
locks map[uint64]*keyedMutexEntry
|
||||
}
|
||||
|
||||
type keyedMutexEntry struct {
|
||||
mu sync.Mutex
|
||||
refs int
|
||||
}
|
||||
|
||||
func newKeyedMutex() *keyedMutex {
|
||||
return &keyedMutex{locks: make(map[uint64]*keyedMutexEntry)}
|
||||
}
|
||||
|
||||
// lock acquires the mutex for id and returns an unlock function. The returned
|
||||
// function must be called exactly once.
|
||||
func (k *keyedMutex) lock(id uint64) func() {
|
||||
k.mu.Lock()
|
||||
entry, ok := k.locks[id]
|
||||
if !ok {
|
||||
entry = &keyedMutexEntry{}
|
||||
k.locks[id] = entry
|
||||
}
|
||||
entry.refs++
|
||||
k.mu.Unlock()
|
||||
|
||||
entry.mu.Lock()
|
||||
|
||||
return func() {
|
||||
entry.mu.Unlock()
|
||||
k.mu.Lock()
|
||||
entry.refs--
|
||||
if entry.refs == 0 {
|
||||
delete(k.locks, id)
|
||||
}
|
||||
k.mu.Unlock()
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,10 @@ type SimpleGrainPool[V any] struct {
|
||||
ttl time.Duration
|
||||
poolSize int
|
||||
|
||||
// grainLocks serializes spawn + mutation + read per grain id so that each
|
||||
// grain processes a single message at a time (the actor guarantee).
|
||||
grainLocks *keyedMutex
|
||||
|
||||
// Cluster coordination --------------------------------------------------
|
||||
hostname string
|
||||
remoteMu sync.RWMutex
|
||||
@@ -59,6 +63,7 @@ func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V],
|
||||
hostname: config.Hostname,
|
||||
remoteOwners: make(map[uint64]Host[V]),
|
||||
remoteHosts: make(map[string]Host[V]),
|
||||
grainLocks: newKeyedMutex(),
|
||||
}
|
||||
|
||||
p.purgeTicker = time.NewTicker(time.Minute)
|
||||
@@ -187,7 +192,17 @@ func (p *SimpleGrainPool[V]) AddRemote(host string) (Host[V], error) {
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Re-check under the write lock: spawnHost opened a real connection above
|
||||
// without holding the lock, so a concurrent AddRemote for the same host may
|
||||
// have already registered one. Keep the existing entry and close ours to
|
||||
// avoid leaking the gRPC connection / HTTP transport (and its file handles).
|
||||
p.remoteMu.Lock()
|
||||
if existing, found := p.remoteHosts[host]; found {
|
||||
p.remoteMu.Unlock()
|
||||
go remote.Close()
|
||||
return existing, nil
|
||||
}
|
||||
p.remoteHosts[host] = remote
|
||||
p.remoteMu.Unlock()
|
||||
// connectedRemotes.Set(float64(p.RemoteCount()))
|
||||
@@ -221,7 +236,6 @@ func (p *SimpleGrainPool[V]) RemoveHost(host string) {
|
||||
remote, exists := p.remoteHosts[host]
|
||||
|
||||
if exists {
|
||||
go remote.Close()
|
||||
delete(p.remoteHosts, host)
|
||||
}
|
||||
count := 0
|
||||
@@ -234,6 +248,7 @@ func (p *SimpleGrainPool[V]) RemoveHost(host string) {
|
||||
log.Printf("Removing host %s, grains: %d", host, count)
|
||||
p.remoteMu.Unlock()
|
||||
|
||||
// Close once, outside the lock.
|
||||
if exists {
|
||||
remote.Close()
|
||||
}
|
||||
@@ -275,7 +290,9 @@ func (p *SimpleGrainPool[V]) pingLoop(remote Host[V]) {
|
||||
if !remote.Ping() {
|
||||
if !remote.IsHealthy() {
|
||||
log.Printf("Remote %s unhealthy, removing", remote.Name())
|
||||
p.Close()
|
||||
// Remove only this host. Previously this called p.Close(),
|
||||
// which tore down every remote connection and stopped the
|
||||
// purge ticker for the whole pool.
|
||||
p.RemoveHost(remote.Name())
|
||||
return
|
||||
}
|
||||
@@ -397,6 +414,12 @@ func (p *SimpleGrainPool[V]) getOrClaimGrain(ctx context.Context, id uint64) (Gr
|
||||
|
||||
// Apply applies a mutation to a grain.
|
||||
func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[V], error) {
|
||||
// Serialize all access to this grain: spawn, mutation handlers and the
|
||||
// final state read happen atomically with respect to other callers of the
|
||||
// same id. Different ids never contend.
|
||||
unlock := p.grainLocks.lock(id)
|
||||
defer unlock()
|
||||
|
||||
grain, err := p.getOrClaimGrain(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -429,6 +452,9 @@ func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...p
|
||||
|
||||
// Get returns the current state of a grain.
|
||||
func (p *SimpleGrainPool[V]) Get(ctx context.Context, id uint64) (*V, error) {
|
||||
unlock := p.grainLocks.lock(id)
|
||||
defer unlock()
|
||||
|
||||
grain, err := p.getOrClaimGrain(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
+32
-23
@@ -28,29 +28,38 @@ type ItemMeta struct {
|
||||
}
|
||||
|
||||
type CartItem struct {
|
||||
Id uint32 `json:"id"`
|
||||
ItemId uint32 `json:"itemId,omitempty"`
|
||||
ParentId *uint32 `json:"parentId,omitempty"`
|
||||
Sku string `json:"sku"`
|
||||
Price Price `json:"price"`
|
||||
TotalPrice Price `json:"totalPrice"`
|
||||
SellerId string `json:"sellerId,omitempty"`
|
||||
OrgPrice *Price `json:"orgPrice,omitempty"`
|
||||
Cgm string `json:"cgm,omitempty"`
|
||||
Tax int
|
||||
Stock uint16 `json:"stock"`
|
||||
Quantity uint16 `json:"qty"`
|
||||
Discount *Price `json:"discount,omitempty"`
|
||||
Disclaimer string `json:"disclaimer,omitempty"`
|
||||
ArticleType string `json:"type,omitempty"`
|
||||
StoreId *string `json:"storeId,omitempty"`
|
||||
Meta *ItemMeta `json:"meta,omitempty"`
|
||||
SaleStatus string `json:"saleStatus"`
|
||||
Marking *Marking `json:"marking,omitempty"`
|
||||
SubscriptionDetailsId string `json:"subscriptionDetailsId,omitempty"`
|
||||
OrderReference string `json:"orderReference,omitempty"`
|
||||
IsSubscribed bool `json:"isSubscribed,omitempty"`
|
||||
ReservationEndTime *time.Time `json:"reservationEndTime,omitempty"`
|
||||
Id uint32 `json:"id"`
|
||||
ItemId uint32 `json:"itemId,omitempty"`
|
||||
ParentId *uint32 `json:"parentId,omitempty"`
|
||||
Sku string `json:"sku"`
|
||||
Price Price `json:"price"`
|
||||
TotalPrice Price `json:"totalPrice"`
|
||||
SellerId string `json:"sellerId,omitempty"`
|
||||
OrgPrice *Price `json:"orgPrice,omitempty"`
|
||||
Cgm string `json:"cgm,omitempty"`
|
||||
Tax int
|
||||
Stock uint16 `json:"stock"`
|
||||
Quantity uint16 `json:"qty"`
|
||||
Discount *Price `json:"discount,omitempty"`
|
||||
Disclaimer string `json:"disclaimer,omitempty"`
|
||||
ArticleType string `json:"type,omitempty"`
|
||||
StoreId *string `json:"storeId,omitempty"`
|
||||
Meta *ItemMeta `json:"meta,omitempty"`
|
||||
SaleStatus string `json:"saleStatus"`
|
||||
Marking *Marking `json:"marking,omitempty"`
|
||||
// CustomFields holds optional user-supplied input fields for this line
|
||||
// (engraving text, configurator notes, ...), keyed by field name.
|
||||
CustomFields map[string]string `json:"customFields,omitempty"`
|
||||
SubscriptionDetailsId string `json:"subscriptionDetailsId,omitempty"`
|
||||
OrderReference string `json:"orderReference,omitempty"`
|
||||
IsSubscribed bool `json:"isSubscribed,omitempty"`
|
||||
ReservationEndTime *time.Time `json:"reservationEndTime,omitempty"`
|
||||
|
||||
// Extra holds arbitrary dynamic product data. Its keys are flattened onto
|
||||
// the item object in JSON (see cart_item_json.go), so they are returned
|
||||
// over the API alongside the typed fields. Typed fields win on key
|
||||
// collisions.
|
||||
Extra map[string]json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
type CartNotification struct {
|
||||
|
||||
@@ -79,6 +79,7 @@ func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegist
|
||||
actor.NewMutation(SetUserId),
|
||||
actor.NewMutation(LineItemMarking),
|
||||
actor.NewMutation(RemoveLineItemMarking),
|
||||
actor.NewMutation(SetLineItemCustomFields),
|
||||
actor.NewMutation(SubscriptionAdded),
|
||||
// actor.NewMutation(SubscriptionRemoved),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// cartItemAlias mirrors CartItem but has no custom (Un)MarshalJSON, so it is
|
||||
// used to (de)serialize the typed fields without recursing.
|
||||
type cartItemAlias CartItem
|
||||
|
||||
// knownItemKeys is the set of top-level JSON object keys owned by typed
|
||||
// CartItem fields. Any other key in an item object is dynamic and kept in
|
||||
// CartItem.Extra.
|
||||
var knownItemKeys = buildKnownItemKeys()
|
||||
|
||||
func buildKnownItemKeys() map[string]struct{} {
|
||||
keys := make(map[string]struct{})
|
||||
t := reflect.TypeOf(CartItem{})
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
name, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ",")
|
||||
if name == "" {
|
||||
name = t.Field(i).Name // untagged fields serialize under their Go name
|
||||
}
|
||||
if name == "-" {
|
||||
continue // e.g. Extra itself
|
||||
}
|
||||
keys[name] = struct{}{}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// MarshalJSON emits the typed fields and flattens Extra onto the same object.
|
||||
// Typed fields take precedence on key collisions.
|
||||
func (c CartItem) MarshalJSON() ([]byte, error) {
|
||||
core, err := json.Marshal(cartItemAlias(c))
|
||||
if err != nil || len(c.Extra) == 0 {
|
||||
return core, err
|
||||
}
|
||||
merged := make(map[string]json.RawMessage, len(c.Extra)+8)
|
||||
if err := json.Unmarshal(core, &merged); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range c.Extra {
|
||||
if _, taken := merged[k]; taken {
|
||||
continue
|
||||
}
|
||||
merged[k] = v
|
||||
}
|
||||
return json.Marshal(merged)
|
||||
}
|
||||
|
||||
// UnmarshalJSON reads the typed fields and collects every other key into Extra.
|
||||
func (c *CartItem) UnmarshalJSON(data []byte) error {
|
||||
var alias cartItemAlias
|
||||
if err := json.Unmarshal(data, &alias); err != nil {
|
||||
return err
|
||||
}
|
||||
*c = CartItem(alias)
|
||||
|
||||
all := make(map[string]json.RawMessage)
|
||||
if err := json.Unmarshal(data, &all); err != nil {
|
||||
return err
|
||||
}
|
||||
for k := range all {
|
||||
if _, known := knownItemKeys[k]; known {
|
||||
delete(all, k)
|
||||
}
|
||||
}
|
||||
if len(all) > 0 {
|
||||
c.Extra = all
|
||||
} else {
|
||||
c.Extra = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCartItem_FlattensDynamicKeys(t *testing.T) {
|
||||
item := CartItem{
|
||||
Id: 7,
|
||||
Sku: "YD10050921H11406",
|
||||
Quantity: 2,
|
||||
Extra: map[string]json.RawMessage{
|
||||
"glas": json.RawMessage(`"V"`),
|
||||
"hangning": json.RawMessage(`"H"`),
|
||||
"deliveryWeek": json.RawMessage(`10`),
|
||||
},
|
||||
}
|
||||
|
||||
b, err := json.Marshal(&item)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
var flat map[string]json.RawMessage
|
||||
if err := json.Unmarshal(b, &flat); err != nil {
|
||||
t.Fatalf("unmarshal to map: %v", err)
|
||||
}
|
||||
|
||||
// Typed field present at top level.
|
||||
if _, ok := flat["sku"]; !ok {
|
||||
t.Error("expected typed key \"sku\" at top level")
|
||||
}
|
||||
// Dynamic keys flattened to top level (not nested under \"extra\").
|
||||
for _, k := range []string{"glas", "hangning", "deliveryWeek"} {
|
||||
if _, ok := flat[k]; !ok {
|
||||
t.Errorf("expected dynamic key %q flattened to top level", k)
|
||||
}
|
||||
}
|
||||
if string(flat["glas"]) != `"V"` {
|
||||
t.Errorf("glas = %s, want \"V\"", flat["glas"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCartItem_RoundTrip(t *testing.T) {
|
||||
original := CartItem{
|
||||
Id: 3,
|
||||
Sku: "ABC",
|
||||
Quantity: 1,
|
||||
Extra: map[string]json.RawMessage{
|
||||
"color": json.RawMessage(`"red"`),
|
||||
"weight": json.RawMessage(`12.5`),
|
||||
},
|
||||
}
|
||||
|
||||
b, err := json.Marshal(&original)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
var got CartItem
|
||||
if err := json.Unmarshal(b, &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if got.Sku != original.Sku || got.Id != original.Id || got.Quantity != original.Quantity {
|
||||
t.Errorf("typed fields not preserved: got %+v", got)
|
||||
}
|
||||
if len(got.Extra) != 2 {
|
||||
t.Fatalf("Extra len = %d, want 2 (%v)", len(got.Extra), got.Extra)
|
||||
}
|
||||
if string(got.Extra["color"]) != `"red"` {
|
||||
t.Errorf("color = %s, want \"red\"", got.Extra["color"])
|
||||
}
|
||||
// A typed key must never leak into Extra.
|
||||
if _, leaked := got.Extra["sku"]; leaked {
|
||||
t.Error("typed key \"sku\" leaked into Extra")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCartItem_NoExtraIsCompact(t *testing.T) {
|
||||
item := CartItem{Id: 1, Sku: "X", Quantity: 1}
|
||||
b, err := json.Marshal(&item)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var flat map[string]json.RawMessage
|
||||
if err := json.Unmarshal(b, &flat); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if _, ok := flat["sku"]; !ok {
|
||||
t.Error("expected \"sku\" in output")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package cart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -11,6 +12,20 @@ import (
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
// decodeExtra unpacks the dynamic product data carried as raw JSON. Invalid
|
||||
// payloads are logged and dropped rather than failing the mutation.
|
||||
func decodeExtra(b []byte) map[string]json.RawMessage {
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
m := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
log.Printf("AddItem: invalid extra_json: %v", err)
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// mutation_add_item.go
|
||||
//
|
||||
// Registers the AddItem cart mutation in the generic mutation registry.
|
||||
@@ -18,10 +33,13 @@ import (
|
||||
//
|
||||
// Behavior:
|
||||
// - Validates quantity > 0
|
||||
// - If an item with same SKU exists -> increases quantity
|
||||
// - If an item with the same item id (ItemId) exists -> increases quantity
|
||||
// - Else creates a new CartItem with computed tax amounts
|
||||
// - Totals recalculated automatically via WithTotals()
|
||||
//
|
||||
// Item identity is the catalog item id (ItemId), not the SKU: the product
|
||||
// service is looked up by id and the returned SKU is reference-only.
|
||||
//
|
||||
// NOTE: Any future field additions in messages.AddItem that affect pricing / tax
|
||||
// must keep this handler in sync.
|
||||
var ErrPaymentInProgress = errors.New("payment in progress")
|
||||
@@ -35,9 +53,10 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
|
||||
return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity)
|
||||
}
|
||||
|
||||
// Merge with any existing item having same SKU and matching StoreId (including both nil).
|
||||
// Merge with any existing item having the same item id and matching StoreId
|
||||
// (including both nil). Identity is the id; SKU is reference-only.
|
||||
for _, existing := range g.Items {
|
||||
if existing.Sku != m.Sku {
|
||||
if existing.ItemId != m.ItemId {
|
||||
continue
|
||||
}
|
||||
sameStore := (existing.StoreId == nil && m.StoreId == nil) ||
|
||||
@@ -61,6 +80,14 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
|
||||
if existing.StoreId == nil && m.StoreId != nil {
|
||||
existing.StoreId = m.StoreId
|
||||
}
|
||||
// Refresh dynamic product data with the latest payload.
|
||||
if extra := decodeExtra(m.ExtraJson); extra != nil {
|
||||
existing.Extra = extra
|
||||
}
|
||||
// Replace custom fields when provided on the re-add.
|
||||
if len(m.CustomFields) > 0 {
|
||||
existing.CustomFields = m.CustomFields
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -114,6 +141,9 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
|
||||
ArticleType: m.ArticleType,
|
||||
|
||||
StoreId: m.StoreId,
|
||||
|
||||
Extra: decodeExtra(m.ExtraJson),
|
||||
CustomFields: m.CustomFields,
|
||||
}
|
||||
|
||||
if needsReservation && c.UseReservations(cartItem) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cart_messages "git.k6n.net/go-cart-actor/proto/cart"
|
||||
)
|
||||
|
||||
// Item identity is the catalog item id, not the (reference-only) SKU: two adds
|
||||
// with the same ItemId but different SKU strings must merge into one line.
|
||||
func TestAddItem_MergesByItemIdNotSku(t *testing.T) {
|
||||
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||
g := NewCartGrain(1, time.Now())
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 144047, Sku: "A0162590", Quantity: 1, Price: 1000}); err != nil {
|
||||
t.Fatalf("first add: %v", err)
|
||||
}
|
||||
// Same id, different (reference) sku string -> should merge, not create a line.
|
||||
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 144047, Sku: "STALE-REF", Quantity: 2, Price: 1000}); err != nil {
|
||||
t.Fatalf("second add: %v", err)
|
||||
}
|
||||
|
||||
if len(g.Items) != 1 {
|
||||
t.Fatalf("items = %d, want 1 (merged by id)", len(g.Items))
|
||||
}
|
||||
if g.Items[0].Quantity != 3 {
|
||||
t.Errorf("quantity = %d, want 3", g.Items[0].Quantity)
|
||||
}
|
||||
|
||||
// A different id is a distinct line.
|
||||
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 170852, Sku: "A0190103", Quantity: 1, Price: 500}); err != nil {
|
||||
t.Fatalf("third add: %v", err)
|
||||
}
|
||||
if len(g.Items) != 2 {
|
||||
t.Fatalf("items = %d, want 2", len(g.Items))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cart_messages "git.k6n.net/go-cart-actor/proto/cart"
|
||||
)
|
||||
|
||||
func TestAddItem_StoresCustomFields(t *testing.T) {
|
||||
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||
g := NewCartGrain(1, time.Now())
|
||||
|
||||
mustApply(t, reg, g, &cart_messages.AddItem{
|
||||
ItemId: 100, Sku: "P", Quantity: 1, Price: 1000,
|
||||
CustomFields: map[string]string{"engraving": "Happy Birthday", "color": "blue"},
|
||||
})
|
||||
|
||||
if len(g.Items) != 1 {
|
||||
t.Fatalf("items = %d, want 1", len(g.Items))
|
||||
}
|
||||
cf := g.Items[0].CustomFields
|
||||
if cf["engraving"] != "Happy Birthday" || cf["color"] != "blue" {
|
||||
t.Fatalf("custom fields = %v, want engraving+color", cf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetLineItemCustomFields_Merges(t *testing.T) {
|
||||
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||
g := NewCartGrain(1, time.Now())
|
||||
|
||||
mustApply(t, reg, g, &cart_messages.AddItem{
|
||||
ItemId: 100, Sku: "P", Quantity: 1, Price: 1000,
|
||||
CustomFields: map[string]string{"engraving": "v1"},
|
||||
})
|
||||
line := g.Items[0].Id
|
||||
|
||||
// Upsert: overwrite "engraving", add "note", leave others alone.
|
||||
mustApply(t, reg, g, &cart_messages.SetLineItemCustomFields{
|
||||
Id: line,
|
||||
CustomFields: map[string]string{"engraving": "v2", "note": "gift wrap"},
|
||||
})
|
||||
|
||||
cf := g.Items[0].CustomFields
|
||||
if cf["engraving"] != "v2" {
|
||||
t.Errorf("engraving = %q, want v2", cf["engraving"])
|
||||
}
|
||||
if cf["note"] != "gift wrap" {
|
||||
t.Errorf("note = %q, want 'gift wrap'", cf["note"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetLineItemCustomFields_UnknownItem(t *testing.T) {
|
||||
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||
g := NewCartGrain(1, time.Now())
|
||||
// The handler error is reported per-mutation in the ApplyResult (the
|
||||
// registry only returns a top-level error for unregistered mutations).
|
||||
results, _ := reg.Apply(context.Background(), g, &cart_messages.SetLineItemCustomFields{
|
||||
Id: 999, CustomFields: map[string]string{"x": "y"},
|
||||
})
|
||||
if len(results) != 1 || results[0].Error == nil {
|
||||
t.Errorf("expected per-mutation error for unknown item id, got %+v", results)
|
||||
}
|
||||
}
|
||||
@@ -15,15 +15,17 @@ import (
|
||||
//
|
||||
// Behavior:
|
||||
// - Removes the cart line whose local cart line Id == payload.Id
|
||||
// - Cascades: also removes any line whose ParentId points at a removed line
|
||||
// (transitively), so removing a parent removes its child sub-articles
|
||||
// - If no such line exists returns an error
|
||||
// - Recalculates cart totals (WithTotals)
|
||||
// - Releases reservations for every removed line and recalculates totals
|
||||
//
|
||||
// Notes:
|
||||
// - This removes only the line item; any deliveries referencing the removed
|
||||
// - This removes only the line items; any deliveries referencing a removed
|
||||
// item are NOT automatically adjusted (mirrors prior logic). If future
|
||||
// semantics require pruning delivery.item_ids you can extend this handler.
|
||||
// - If multiple lines somehow shared the same Id (should not happen), only
|
||||
// the first match would be removed—data integrity relies on unique line Ids.
|
||||
// - If multiple lines somehow shared the same Id (should not happen), all
|
||||
// matches are removed—data integrity relies on unique line Ids.
|
||||
|
||||
func (c *CartMutationContext) RemoveItem(g *CartGrain, m *messages.RemoveItem) error {
|
||||
if m == nil {
|
||||
@@ -32,26 +34,46 @@ func (c *CartMutationContext) RemoveItem(g *CartGrain, m *messages.RemoveItem) e
|
||||
|
||||
targetID := uint32(m.Id)
|
||||
|
||||
index := -1
|
||||
for i, it := range g.Items {
|
||||
found := false
|
||||
for _, it := range g.Items {
|
||||
if it.Id == targetID {
|
||||
index = i
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if index == -1 {
|
||||
if !found {
|
||||
return fmt.Errorf("RemoveItem: item id %d not found", m.Id)
|
||||
}
|
||||
|
||||
item := g.Items[index]
|
||||
if item.ReservationEndTime != nil && item.ReservationEndTime.After(time.Now()) {
|
||||
err := c.ReleaseItem(context.Background(), g.Id, item.Sku, item.StoreId)
|
||||
if err != nil {
|
||||
log.Printf("unable to release item reservation")
|
||||
// Collect the target and, transitively, any children pointing at a removed
|
||||
// line. Loops until no further descendants are found (handles nesting).
|
||||
remove := map[uint32]bool{targetID: true}
|
||||
for {
|
||||
grew := false
|
||||
for _, it := range g.Items {
|
||||
if it.ParentId != nil && remove[*it.ParentId] && !remove[it.Id] {
|
||||
remove[it.Id] = true
|
||||
grew = true
|
||||
}
|
||||
}
|
||||
if !grew {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
g.Items = append(g.Items[:index], g.Items[index+1:]...)
|
||||
kept := g.Items[:0]
|
||||
for _, it := range g.Items {
|
||||
if remove[it.Id] {
|
||||
if it.ReservationEndTime != nil && it.ReservationEndTime.After(time.Now()) {
|
||||
if err := c.ReleaseItem(context.Background(), g.Id, it.Sku, it.StoreId); err != nil {
|
||||
log.Printf("unable to release reservation for item %d: %v", it.Id, err)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
kept = append(kept, it)
|
||||
}
|
||||
g.Items = kept
|
||||
g.UpdateTotals()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/go-cart-actor/pkg/actor"
|
||||
cart_messages "git.k6n.net/go-cart-actor/proto/cart"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// Removing a parent line cascades to its child sub-articles, while unrelated
|
||||
// lines are left untouched.
|
||||
func TestRemoveItem_CascadesToChildren(t *testing.T) {
|
||||
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||
g := NewCartGrain(1, time.Now())
|
||||
ctx := context.Background()
|
||||
|
||||
// Parent (line 1) + two children (lines 2,3) + an unrelated item (line 4).
|
||||
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000})
|
||||
parentLine := g.Items[0].Id
|
||||
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine})
|
||||
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 102, Sku: "C2", Quantity: 1, Price: 200, ParentId: &parentLine})
|
||||
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 200, Sku: "OTHER", Quantity: 1, Price: 300})
|
||||
|
||||
if len(g.Items) != 4 {
|
||||
t.Fatalf("setup: items = %d, want 4", len(g.Items))
|
||||
}
|
||||
|
||||
if _, err := reg.Apply(ctx, g, &cart_messages.RemoveItem{Id: parentLine}); err != nil {
|
||||
t.Fatalf("remove parent: %v", err)
|
||||
}
|
||||
|
||||
if len(g.Items) != 1 {
|
||||
t.Fatalf("after remove: items = %d, want 1 (only the unrelated line)", len(g.Items))
|
||||
}
|
||||
if g.Items[0].Sku != "OTHER" {
|
||||
t.Errorf("remaining item sku = %q, want OTHER", g.Items[0].Sku)
|
||||
}
|
||||
}
|
||||
|
||||
// Removing a child leaves the parent and siblings intact.
|
||||
func TestRemoveItem_ChildDoesNotRemoveParent(t *testing.T) {
|
||||
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||
g := NewCartGrain(1, time.Now())
|
||||
ctx := context.Background()
|
||||
|
||||
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000})
|
||||
parentLine := g.Items[0].Id
|
||||
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine})
|
||||
childLine := g.Items[1].Id
|
||||
|
||||
if _, err := reg.Apply(ctx, g, &cart_messages.RemoveItem{Id: childLine}); err != nil {
|
||||
t.Fatalf("remove child: %v", err)
|
||||
}
|
||||
if len(g.Items) != 1 {
|
||||
t.Fatalf("items = %d, want 1 (parent remains)", len(g.Items))
|
||||
}
|
||||
if g.Items[0].Id != parentLine {
|
||||
t.Errorf("remaining line = %d, want parent %d", g.Items[0].Id, parentLine)
|
||||
}
|
||||
}
|
||||
|
||||
func mustApply(t *testing.T, reg actor.MutationRegistry, g *CartGrain, m proto.Message) {
|
||||
t.Helper()
|
||||
if _, err := reg.Apply(context.Background(), g, m); err != nil {
|
||||
t.Fatalf("apply %T: %v", m, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.k6n.net/go-cart-actor/proto/cart"
|
||||
)
|
||||
|
||||
// SetLineItemCustomFields sets/merges user-supplied custom input fields on an
|
||||
// existing cart line. It is the dict equivalent of LineItemMarking: keys in the
|
||||
// request are upserted; existing keys not present in the request are left
|
||||
// untouched. (Send an empty value to clear a single field at the API layer if
|
||||
// desired.)
|
||||
func SetLineItemCustomFields(grain *CartGrain, req *messages.SetLineItemCustomFields) error {
|
||||
for _, item := range grain.Items {
|
||||
if item.Id != req.Id {
|
||||
continue
|
||||
}
|
||||
if len(req.CustomFields) == 0 {
|
||||
return nil
|
||||
}
|
||||
if item.CustomFields == nil {
|
||||
item.CustomFields = make(map[string]string, len(req.CustomFields))
|
||||
}
|
||||
for k, v := range req.CustomFields {
|
||||
item.CustomFields[k] = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("item with ID %d not found", req.Id)
|
||||
}
|
||||
+44
-4
@@ -69,7 +69,15 @@ func (m *MockResponseWriter) WriteHeader(statusCode int) {
|
||||
m.StatusCode = statusCode
|
||||
}
|
||||
|
||||
func NewRemoteHost[V any](host string) (*RemoteHost[V], error) {
|
||||
// NewRemoteHost connects to a peer node. httpPort overrides the peer's HTTP
|
||||
// port used for request proxying (defaults to "8080"); the gRPC control port
|
||||
// is always 1337.
|
||||
func NewRemoteHost[V any](host string, httpPort ...string) (*RemoteHost[V], error) {
|
||||
|
||||
port := "8080"
|
||||
if len(httpPort) > 0 && httpPort[0] != "" {
|
||||
port = httpPort[0]
|
||||
}
|
||||
|
||||
target := fmt.Sprintf("%s:1337", host)
|
||||
|
||||
@@ -85,14 +93,18 @@ func NewRemoteHost[V any](host string) (*RemoteHost[V], error) {
|
||||
transport := &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 100,
|
||||
DisableKeepAlives: false,
|
||||
IdleConnTimeout: 120 * time.Second,
|
||||
// Bound concurrently-open connections to this peer so a load spike
|
||||
// can't exhaust file handles. Excess requests wait for a free conn
|
||||
// rather than opening unbounded sockets.
|
||||
MaxConnsPerHost: 256,
|
||||
DisableKeepAlives: false,
|
||||
IdleConnTimeout: 120 * time.Second,
|
||||
}
|
||||
client := &http.Client{Transport: transport, Timeout: 10 * time.Second}
|
||||
|
||||
return &RemoteHost[V]{
|
||||
host: host,
|
||||
httpBase: fmt.Sprintf("http://%s:8080", host),
|
||||
httpBase: fmt.Sprintf("http://%s:%s", host, port),
|
||||
conn: conn,
|
||||
transport: transport,
|
||||
client: client,
|
||||
@@ -101,6 +113,25 @@ func NewRemoteHost[V any](host string) (*RemoteHost[V], error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// hopByHopHeaders are connection-specific headers that must not be forwarded
|
||||
// by a proxy (RFC 7230 §6.1).
|
||||
var hopByHopHeaders = map[string]struct{}{
|
||||
"Connection": {},
|
||||
"Proxy-Connection": {},
|
||||
"Keep-Alive": {},
|
||||
"Proxy-Authenticate": {},
|
||||
"Proxy-Authorization": {},
|
||||
"Te": {},
|
||||
"Trailer": {},
|
||||
"Transfer-Encoding": {},
|
||||
"Upgrade": {},
|
||||
}
|
||||
|
||||
func isHopByHopHeader(key string) bool {
|
||||
_, ok := hopByHopHeaders[http.CanonicalHeaderKey(key)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (h *RemoteHost[V]) Name() string {
|
||||
return h.host
|
||||
}
|
||||
@@ -277,6 +308,12 @@ func (h *RemoteHost[V]) Proxy(id uint64, w http.ResponseWriter, r *http.Request,
|
||||
req.Header.Set("X-Forwarded-Host", r.Host)
|
||||
|
||||
for k, v := range r.Header {
|
||||
if isHopByHopHeader(k) {
|
||||
// Don't forward connection-management headers (notably an inbound
|
||||
// "Connection: close" would disable keep-alive on the proxied leg
|
||||
// and churn outbound connections / file handles).
|
||||
continue
|
||||
}
|
||||
for _, vv := range v {
|
||||
req.Header.Add(k, vv)
|
||||
}
|
||||
@@ -290,6 +327,9 @@ func (h *RemoteHost[V]) Proxy(id uint64, w http.ResponseWriter, r *http.Request,
|
||||
defer res.Body.Close()
|
||||
span.SetAttributes(attribute.Int("status_code", res.StatusCode))
|
||||
for k, v := range res.Header {
|
||||
if isHopByHopHeader(k) {
|
||||
continue
|
||||
}
|
||||
for _, vv := range v {
|
||||
w.Header().Add(k, vv)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user