fixes
Build and Publish / BuildAndDeployArm64 (push) Failing after 49s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled

This commit is contained in:
2026-06-16 13:14:35 +02:00
parent faec360789
commit 60722e3414
29 changed files with 1946 additions and 455 deletions
+50
View File
@@ -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()
}
}
+28 -2
View File
@@ -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
View File
@@ -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 {
+1
View File
@@ -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),
)
+77
View File
@@ -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
}
+94
View File
@@ -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")
}
}
+33 -3
View File
@@ -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) {
+40
View File
@@ -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))
}
}
+65
View File
@@ -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)
}
}
+36 -14
View File
@@ -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
}
+70
View File
@@ -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)
}
}
+31
View File
@@ -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
View File
@@ -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)
}