65 Commits

Author SHA1 Message Date
matst80
e8fd0a21b1 Merge branch 'main' of git-ssh.tornberg.me:mats/go-cart-actor into refactor/http-proxy
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m23s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m35s
2025-10-14 23:11:20 +02:00
matst80
137958b83b update if same, else add new
Some checks failed
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m24s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-14 23:08:45 +02:00
f5014fe906 Complete refactor to new grpc control plane and only http proxy for carts (#4)
All checks were successful
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m14s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m54s
Co-authored-by: matst80 <mats.tornberg@gmail.com>
Reviewed-on: https://git.tornberg.me/mats/go-cart-actor/pulls/4
Co-authored-by: Mats Törnberg <mats@tornberg.me>
Co-committed-by: Mats Törnberg <mats@tornberg.me>
2025-10-14 22:31:12 +02:00
matst80
ee5d714220 m
All checks were successful
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m15s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m44s
2025-10-14 22:30:19 +02:00
matst80
5ca21e214f subtract from totalprice
Some checks failed
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m14s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-14 22:26:59 +02:00
matst80
2df64e715e fix mutation
Some checks are pending
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m30s
Build and Publish / BuildAndDeployArm64 (push) Has started running
2025-10-14 22:23:15 +02:00
matst80
e0ea03f6cf remove voucher
All checks were successful
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m17s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m0s
2025-10-14 22:18:49 +02:00
matst80
a7cbdcd0da try new vouchers
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m14s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m57s
2025-10-14 22:14:22 +02:00
matst80
f0b6a733f1 update
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m9s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m53s
2025-10-14 21:41:50 +02:00
matst80
c86a189795 parsestock
Some checks failed
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 39s
Build and Publish / BuildAndDeployArm64 (push) Failing after 3m26s
2025-10-14 21:36:52 +02:00
matst80
1565cfdaba change alot of id values
All checks were successful
Build and Publish / Metadata (push) Successful in 15s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m11s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m7s
2025-10-14 21:24:50 +02:00
matst80
4bec3f8cc0 check storestock
All checks were successful
Build and Publish / Metadata (push) Successful in 12s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m22s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m58s
2025-10-14 20:19:29 +02:00
matst80
5e2025e30f test other tax
All checks were successful
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m20s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m15s
2025-10-14 20:00:32 +02:00
e908a4130b more tests
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m20s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m4s
2025-10-14 19:38:38 +02:00
606df6218a update cartitem layout
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m19s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m51s
2025-10-14 19:23:01 +02:00
matst80
9f83717ea9 try new errors
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m27s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m0s
2025-10-14 15:52:59 +02:00
matst80
b029a9d05a errors as string for now
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m30s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m6s
2025-10-14 15:36:50 +02:00
matst80
cbbae3dfd3 dont allow duplicate
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m16s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m52s
2025-10-14 14:52:42 +02:00
matst80
b842efae6a better logs
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m25s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m6s
2025-10-14 13:49:47 +02:00
matst80
bb9a595d25 log ownership change
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m20s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m3s
2025-10-14 13:43:10 +02:00
matst80
de62b0664d fix ownership
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m11s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m20s
2025-10-14 13:33:08 +02:00
matst80
0b9c14c231 more
All checks were successful
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m12s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m9s
2025-10-14 12:30:12 +02:00
matst80
7a79efbb9f tax calculations
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m15s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m37s
2025-10-14 09:13:38 +02:00
matst80
6c44a03dd1 test add vouchers and shared service
Some checks failed
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m22s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-14 09:10:01 +02:00
matst80
11e82de114 add manual openapi docs
All checks were successful
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m35s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m26s
2025-10-14 08:42:04 +02:00
matst80
67b9a739fe Update simple_grain_pool.go
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m25s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m8s
2025-10-13 22:50:09 +02:00
matst80
5c67579464 less logs
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m23s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m58s
2025-10-13 22:25:46 +02:00
matst80
22ac64c14d lock
All checks were successful
Build and Publish / Metadata (push) Successful in 11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m24s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m57s
2025-10-13 22:19:46 +02:00
27c866ce58 allow multiple
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m23s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m0s
2025-10-13 20:40:36 +02:00
c9701803d1 less logs
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m9s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m54s
2025-10-13 20:22:51 +02:00
dc12e8c3d5 take the correct type
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m25s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m45s
2025-10-13 20:10:49 +02:00
30b98f93ce get correct type
All checks were successful
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m8s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m54s
2025-10-13 20:01:41 +02:00
8e60cc2239 support multiple mutations
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m13s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m54s
2025-10-13 19:51:27 +02:00
matst80
91e398dcc3 more files
All checks were successful
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m26s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m2s
2025-10-13 19:05:24 +02:00
matst80
f3e92c7d65 queued disk stuff
Some checks are pending
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Has started running
Build and Publish / BuildAndDeployArm64 (push) Has started running
2025-10-13 19:05:12 +02:00
matst80
6fbd62936f cleanup
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m28s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m1s
2025-10-13 17:39:07 +02:00
matst80
2bf0475335 append async
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m28s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m3s
2025-10-13 17:23:17 +02:00
matst80
10e1affad0 update
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m21s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m0s
2025-10-13 16:25:33 +02:00
matst80
e26ad676b3 dont read forever
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m29s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m55s
2025-10-13 15:46:34 +02:00
matst80
9fc3871e84 better 2025-10-13 15:39:41 +02:00
matst80
6094da99f3 Update Dockerfile
All checks were successful
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m39s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m54s
2025-10-13 10:30:08 +02:00
matst80
1575b3a829 move actor to pkg
Some checks failed
Build and Publish / Metadata (push) Has been cancelled
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-13 10:29:55 +02:00
matst80
c6671ceef0 dont log pings
All checks were successful
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m13s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m46s
2025-10-12 23:29:25 +02:00
matst80
6cb46b4e16 async append to log
Some checks failed
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Has started running
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-12 23:28:05 +02:00
matst80
4e4d5371ec store reference to cart
Some checks failed
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m18s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-12 23:25:28 +02:00
matst80
c4f0c67580 Update grpc_server.go
Some checks failed
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m4s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-12 23:23:02 +02:00
matst80
6a9ebbf453 såh 2025-10-12 23:19:35 +02:00
matst80
ea35871676 test
Some checks failed
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m40s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2025-10-12 23:18:55 +02:00
matst80
7ad28966fb longer time
All checks were successful
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m10s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m47s
2025-10-12 23:06:11 +02:00
matst80
a7a778caaf mer
All checks were successful
Build and Publish / Metadata (push) Successful in 6s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m8s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m11s
2025-10-12 23:00:17 +02:00
matst80
873fb6c97b test
Some checks failed
Build and Publish / Metadata (push) Successful in 3s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m24s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-12 22:57:27 +02:00
matst80
33ef868295 faster deployment
All checks were successful
Build and Publish / Metadata (push) Successful in 6s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m4s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m20s
2025-10-12 22:49:12 +02:00
matst80
8d73f856bf test
Some checks failed
Build and Publish / Metadata (push) Successful in 5s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 48s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-12 22:47:13 +02:00
matst80
b591e3d3f5 update
All checks were successful
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 47s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m18s
2025-10-12 22:26:05 +02:00
matst80
b8266d80f9 more stuff
All checks were successful
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m50s
2025-10-12 21:36:00 +02:00
0ba7410162 even more refactoring
All checks were successful
Build and Publish / Metadata (push) Successful in 6s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 46s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m8s
2025-10-11 18:17:31 +02:00
9df2f3362a some cleanup and annonce expiry
All checks were successful
Build and Publish / Metadata (push) Successful in 6s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 46s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m8s
2025-10-11 17:42:37 +02:00
6345d91ef7 more cleanup 2025-10-11 10:34:48 +02:00
4cacc0ee2d connection
All checks were successful
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 50s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m10s
2025-10-11 10:29:44 +02:00
24cd0b6ad7 major refactor
All checks were successful
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m49s
2025-10-11 10:22:47 +02:00
matst80
e48a2590bd change ids
All checks were successful
Build and Publish / Metadata (push) Successful in 3s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 50s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m25s
2025-10-10 21:50:18 +00:00
matst80
b0e6c8eca8 add tests and grafana dashboard
All checks were successful
Build and Publish / Metadata (push) Successful in 7s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 47s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m4s
2025-10-10 20:45:42 +00:00
matst80
7814f33a06 some strange storage stuff
Some checks failed
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 48s
Build and Publish / BuildAndDeployArm64 (push) Failing after 26m40s
2025-10-10 19:31:06 +00:00
matst80
fb111ebf97 cleanup and http proxy
All checks were successful
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 47s
Build and Publish / BuildAndDeployArm64 (push) Successful in 8m9s
2025-10-10 18:44:31 +00:00
matst80
5525e91ecc refactor once again
All checks were successful
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 52s
Build and Publish / BuildAndDeployArm64 (push) Successful in 8m23s
2025-10-10 18:34:46 +00:00
81 changed files with 7241 additions and 8333 deletions

View File

@@ -57,7 +57,7 @@ RUN --mount=type=cache,target=/go/build-cache \
-X main.Version=${VERSION} \
-X main.GitCommit=${GIT_COMMIT} \
-X main.BuildDate=${BUILD_DATE}" \
-o /out/go-cart-actor .
-o /out/go-cart-actor ./cmd/cart
############################
# Runtime Stage

View File

@@ -19,7 +19,7 @@
MODULE_PATH := git.tornberg.me/go-cart-actor
PROTO_DIR := proto
PROTOS := $(PROTO_DIR)/messages.proto $(PROTO_DIR)/cart_actor.proto $(PROTO_DIR)/control_plane.proto
PROTOS := $(PROTO_DIR)/messages.proto $(PROTO_DIR)/control_plane.proto
# Allow override: make PROTOC=/path/to/protoc
PROTOC ?= protoc
@@ -69,8 +69,8 @@ check_tools:
protogen: check_tools
@echo "$(YELLOW)Generating protobuf code (outputs -> ./proto)...$(RESET)"
$(PROTOC) -I $(PROTO_DIR) \
--go_out=./proto --go_opt=paths=source_relative \
--go-grpc_out=./proto --go-grpc_opt=paths=source_relative \
--go_out=./pkg/messages --go_opt=paths=source_relative \
--go-grpc_out=./pkg/messages --go-grpc_opt=paths=source_relative \
$(PROTOS)
@echo "$(GREEN)Protobuf generation complete.$(RESET)"

BIN
cart Executable file

Binary file not shown.

View File

@@ -1,294 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"sync"
messages "git.tornberg.me/go-cart-actor/proto"
)
type CartId [16]byte
// String returns the cart id as a trimmed UTF-8 string (trailing zero bytes removed).
func (id CartId) String() string {
n := 0
for n < len(id) && id[n] != 0 {
n++
}
return string(id[:n])
}
// ToCartId converts an arbitrary string to a fixed-size CartId (truncating or padding with zeros).
func ToCartId(s string) CartId {
var id CartId
copy(id[:], []byte(s))
return id
}
func (id CartId) MarshalJSON() ([]byte, error) {
return json.Marshal(id.String())
}
func (id *CartId) UnmarshalJSON(data []byte) error {
var str string
err := json.Unmarshal(data, &str)
if err != nil {
return err
}
copy(id[:], []byte(str))
return nil
}
type StockStatus int
const (
OutOfStock StockStatus = 0
LowStock StockStatus = 1
InStock StockStatus = 2
)
type CartItem struct {
Id int `json:"id"`
ItemId int `json:"itemId,omitempty"`
ParentId int `json:"parentId,omitempty"`
Sku string `json:"sku"`
Name string `json:"name"`
Price int64 `json:"price"`
TotalPrice int64 `json:"totalPrice"`
TotalTax int64 `json:"totalTax"`
OrgPrice int64 `json:"orgPrice"`
Stock StockStatus `json:"stock"`
Quantity int `json:"qty"`
Tax int `json:"tax"`
TaxRate int `json:"taxRate"`
Brand string `json:"brand,omitempty"`
Category string `json:"category,omitempty"`
Category2 string `json:"category2,omitempty"`
Category3 string `json:"category3,omitempty"`
Category4 string `json:"category4,omitempty"`
Category5 string `json:"category5,omitempty"`
Disclaimer string `json:"disclaimer,omitempty"`
SellerId string `json:"sellerId,omitempty"`
SellerName string `json:"sellerName,omitempty"`
ArticleType string `json:"type,omitempty"`
Image string `json:"image,omitempty"`
Outlet *string `json:"outlet,omitempty"`
StoreId *string `json:"storeId,omitempty"`
}
type CartDelivery struct {
Id int `json:"id"`
Provider string `json:"provider"`
Price int64 `json:"price"`
Items []int `json:"items"`
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
}
type CartGrain struct {
mu sync.RWMutex
lastItemId int
lastDeliveryId int
Id CartId `json:"id"`
Items []*CartItem `json:"items"`
TotalPrice int64 `json:"totalPrice"`
TotalTax int64 `json:"totalTax"`
TotalDiscount int64 `json:"totalDiscount"`
Deliveries []*CartDelivery `json:"deliveries,omitempty"`
Processing bool `json:"processing"`
PaymentInProgress bool `json:"paymentInProgress"`
OrderReference string `json:"orderReference,omitempty"`
PaymentStatus string `json:"paymentStatus,omitempty"`
}
type Grain interface {
GetId() CartId
Apply(content interface{}, isReplay bool) (*CartGrain, error)
GetCurrentState() (*CartGrain, error)
}
func (c *CartGrain) GetId() CartId {
return c.Id
}
func (c *CartGrain) GetLastChange() int64 {
// Legacy event log removed; return 0 to indicate no persisted mutation history.
return 0
}
func (c *CartGrain) GetCurrentState() (*CartGrain, error) {
return c, nil
}
func getInt(data float64, ok bool) (int, error) {
if !ok {
return 0, fmt.Errorf("invalid type")
}
return int(data), nil
}
func getItemData(sku string, qty int, country string) (*messages.AddItem, error) {
item, err := FetchItem(sku, country)
if err != nil {
return nil, err
}
orgPrice, _ := getInt(item.GetNumberFieldValue(5)) // getInt(item.Fields[5])
price, priceErr := getInt(item.GetNumberFieldValue(4)) //Fields[4]
if priceErr != nil {
return nil, fmt.Errorf("invalid price")
}
stock := InStock
/*item.t
if item.StockLevel == "0" || item.StockLevel == "" {
stock = OutOfStock
} else if item.StockLevel == "5+" {
stock = LowStock
}*/
articleType, _ := item.GetStringFieldValue(1) //.Fields[1].(string)
outletGrade, ok := item.GetStringFieldValue(20) //.Fields[20].(string)
var outlet *string
if ok {
outlet = &outletGrade
}
sellerId, _ := item.GetStringFieldValue(24) // .Fields[24].(string)
sellerName, _ := item.GetStringFieldValue(9) // .Fields[9].(string)
brand, _ := item.GetStringFieldValue(2) //.Fields[2].(string)
category, _ := item.GetStringFieldValue(10) //.Fields[10].(string)
category2, _ := item.GetStringFieldValue(11) //.Fields[11].(string)
category3, _ := item.GetStringFieldValue(12) //.Fields[12].(string)
category4, _ := item.GetStringFieldValue(13) //Fields[13].(string)
category5, _ := item.GetStringFieldValue(14) //.Fields[14].(string)
return &messages.AddItem{
ItemId: int64(item.Id),
Quantity: int32(qty),
Price: int64(price),
OrgPrice: int64(orgPrice),
Sku: sku,
Name: item.Title,
Image: item.Img,
Stock: int32(stock),
Brand: brand,
Category: category,
Category2: category2,
Category3: category3,
Category4: category4,
Category5: category5,
Tax: 2500,
SellerId: sellerId,
SellerName: sellerName,
ArticleType: articleType,
Disclaimer: item.Disclaimer,
Country: country,
Outlet: outlet,
}, nil
}
func (c *CartGrain) AddItem(sku string, qty int, country string, storeId *string) (*CartGrain, error) {
cartItem, err := getItemData(sku, qty, country)
if err != nil {
return nil, err
}
cartItem.StoreId = storeId
return c.Apply(cartItem, false)
}
/*
Legacy storage (event sourcing) removed in oneof refactor.
Kept stub (commented) for potential future reintroduction using proto envelopes.
func (c *CartGrain) GetStorageMessage(since int64) []interface{} {
return nil
}
*/
func (c *CartGrain) GetState() ([]byte, error) {
return json.Marshal(c)
}
func (c *CartGrain) ItemsWithDelivery() []int {
ret := make([]int, 0, len(c.Items))
for _, item := range c.Items {
for _, delivery := range c.Deliveries {
for _, id := range delivery.Items {
if item.Id == id {
ret = append(ret, id)
}
}
}
}
return ret
}
func (c *CartGrain) ItemsWithoutDelivery() []int {
ret := make([]int, 0, len(c.Items))
hasDelivery := c.ItemsWithDelivery()
for _, item := range c.Items {
found := false
for _, id := range hasDelivery {
if item.Id == id {
found = true
break
}
}
if !found {
ret = append(ret, item.Id)
}
}
return ret
}
func (c *CartGrain) FindItemWithSku(sku string) (*CartItem, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
for _, item := range c.Items {
if item.Sku == sku {
return item, true
}
}
return nil, false
}
func GetTaxAmount(total int64, tax int) int64 {
taxD := 10000 / float64(tax)
return int64(float64(total) / float64((1 + taxD)))
}
func (c *CartGrain) Apply(content interface{}, isReplay bool) (*CartGrain, error) {
grainMutations.Inc()
updated, err := ApplyRegistered(c, content)
if err != nil {
if err == ErrMutationNotRegistered {
return nil, fmt.Errorf("unsupported mutation type %T (not registered)", content)
}
return nil, err
}
return updated, nil
}
func (c *CartGrain) UpdateTotals() {
c.TotalPrice = 0
c.TotalTax = 0
c.TotalDiscount = 0
for _, item := range c.Items {
rowTotal := item.Price * int64(item.Quantity)
rowTax := int64(item.Tax) * int64(item.Quantity)
item.TotalPrice = rowTotal
item.TotalTax = rowTax
c.TotalPrice += rowTotal
c.TotalTax += rowTax
itemDiff := max(0, item.OrgPrice-item.Price)
c.TotalDiscount += itemDiff * int64(item.Quantity)
}
for _, delivery := range c.Deliveries {
c.TotalPrice += delivery.Price
c.TotalTax += GetTaxAmount(delivery.Price, 2500)
}
}

View File

@@ -1,327 +0,0 @@
package main
import (
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"strings"
)
// cart_id.go
//
// Compact CartID implementation using 64 bits of cryptographic randomness,
// base62 encoded (0-9 A-Z a-z). Typical length is 11 characters (since 62^11 > 2^64).
//
// Motivation:
// * Shorter identifiers for cookies / URLs than legacy padded 16-byte CartId
// * O(1) hashing (raw uint64) for consistent hashing ring integration
// * Extremely low collision probability (birthday bound negligible at scale)
//
// Backward Compatibility Strategy (Phased):
// Phase 1: Introduce CartID helpers while continuing to accept legacy CartId.
// Phase 2: Internally migrate maps to key by uint64 (CartID.Raw()).
// Phase 3: Canonicalize all inbound IDs to short base62; reissue Set-Cart-Id header.
//
// NOTE:
// The legacy type `CartId [16]byte` is still present elsewhere; helper
// UpgradeLegacyCartId bridges that representation to the new form without
// breaking deterministic mapping for existing carts.
//
// Security / Predictability:
// Uses crypto/rand for generation. If ever required, you can layer an
// HMAC-based derivation for additional secrecy. Current approach already
// provides 64 bits of entropy (brute force infeasible for practical risk).
//
// Future Extensions:
// * Time-sortable IDs: prepend a 48-bit timestamp field and encode 80 bits.
// * Add metrics counters for: generated_new, parsed_existing, legacy_fallback.
// * Add a pool of pre-generated IDs for ultra-low-latency hot paths (rarely needed).
//
// Public Surface Summary:
// NewCartID() (CartID, error)
// ParseCartID(string) (CartID, bool)
// FallbackFromString(string) CartID
// UpgradeLegacyCartId(CartId) CartID
// CanonicalizeIncoming(string) (CartID, bool /*wasGenerated*/, error)
//
// Encoding Details:
// encodeBase62 / decodeBase62 maintain a stable alphabet. DO NOT change
// alphabet order once IDs are in circulation, or previously issued IDs
// will change meaning.
//
// Zero Values:
// The zero value CartID{} has raw=0, txt="0". Treat it as valid but
// usually you will call NewCartID instead.
//
// ---------------------------------------------------------------------------
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
// Precomputed reverse lookup table for decode (255 = invalid).
var base62Rev [256]byte
func init() {
for i := range base62Rev {
base62Rev[i] = 0xFF
}
for i := 0; i < len(base62Alphabet); i++ {
base62Rev[base62Alphabet[i]] = byte(i)
}
}
// CartID is the compact representation of a cart identifier.
// raw: 64-bit entropy (also used directly for consistent hashing).
// txt: cached base62 textual form.
type CartID struct {
raw uint64
txt string
}
// String returns the canonical base62 encoded ID.
func (c CartID) String() string {
if c.txt == "" { // lazily encode if constructed manually
c.txt = encodeBase62(c.raw)
}
return c.txt
}
// Raw returns the 64-bit numeric value (useful for hashing / ring lookup).
func (c CartID) Raw() uint64 {
return c.raw
}
// IsZero reports whether this CartID is the zero value.
func (c CartID) IsZero() bool {
return c.raw == 0
}
// NewCartID generates a new cryptographically random 64-bit ID.
func NewCartID() (CartID, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return CartID{}, fmt.Errorf("NewCartID: %w", err)
}
u := binary.BigEndian.Uint64(b[:])
// Reject zero if you want to avoid ever producing "0" (optional).
if u == 0 {
// Extremely unlikely; recurse once.
return NewCartID()
}
return CartID{raw: u, txt: encodeBase62(u)}, nil
}
// MustNewCartID panics on failure (suitable for tests / initialization).
func MustNewCartID() CartID {
id, err := NewCartID()
if err != nil {
panic(err)
}
return id
}
// ParseCartID attempts to parse a base62 canonical ID.
// Returns (id, true) if fully valid; (zero, false) otherwise.
func ParseCartID(s string) (CartID, bool) {
if len(s) == 0 {
return CartID{}, false
}
// Basic length sanity; allow a bit of headroom for future timestamp variant.
if len(s) > 16 {
return CartID{}, false
}
u, ok := decodeBase62(s)
if !ok {
return CartID{}, false
}
return CartID{raw: u, txt: s}, true
}
// FallbackFromString produces a deterministic CartID from arbitrary input
// using a 64-bit FNV-1a hash. This allows legacy or malformed IDs to map
// consistently into the new scheme (collision probability still low).
func FallbackFromString(s string) CartID {
const (
offset64 = 1469598103934665603
prime64 = 1099511628211
)
h := uint64(offset64)
for i := 0; i < len(s); i++ {
h ^= uint64(s[i])
h *= prime64
}
return CartID{raw: h, txt: encodeBase62(h)}
}
// UpgradeLegacyCartId converts the old 16-byte CartId (padded) to CartID
// by hashing its trimmed string form. Keeps stable mapping across restarts.
func UpgradeLegacyCartId(old CartId) CartID {
return FallbackFromString(old.String())
}
// CanonicalizeIncoming normalizes user-provided ID strings.
// Behavior:
//
// Empty string -> generate new ID (wasGenerated = true)
// Valid base62 -> parse and return (wasGenerated = false)
// Anything else -> fallback deterministic hash (wasGenerated = false)
//
// Errors only occur if crypto/rand fails during generation.
func CanonicalizeIncoming(s string) (CartID, bool, error) {
if s == "" {
id, err := NewCartID()
return id, true, err
}
if cid, ok := ParseCartID(s); ok {
return cid, false, nil
}
// Legacy heuristic: if length == 16 and contains non-base62 chars, treat as legacy padded ID.
if len(s) == 16 && !isAllBase62(s) {
return FallbackFromString(strings.TrimRight(s, "\x00")), false, nil
}
return FallbackFromString(s), false, nil
}
// isAllBase62 returns true if every byte is in the base62 alphabet.
func isAllBase62(s string) bool {
for i := 0; i < len(s); i++ {
if base62Rev[s[i]] == 0xFF {
return false
}
}
return true
}
// encodeBase62 turns a uint64 into base62 text.
// Complexity: O(log_62 n) ~ at most 11 iterations for 64 bits.
func encodeBase62(u uint64) string {
if u == 0 {
return "0"
}
// 62^11 = 743008370688 > 2^39; 62^11 > 2^64? Actually 62^11 ~= 5.18e19 < 2^64 (1.84e19)? 2^64 ≈ 1.84e19.
// 62^11 ≈ 5.18e19 > 2^64? Correction: 2^64 ≈ 1.844e19, so 62^11 > 2^64. Thus 11 chars suffice.
var buf [11]byte
i := len(buf)
for u > 0 {
i--
buf[i] = base62Alphabet[u%62]
u /= 62
}
return string(buf[i:])
}
// decodeBase62 converts a base62 string to uint64.
// Returns (value, false) if any invalid character appears.
func decodeBase62(s string) (uint64, bool) {
var v uint64
for i := 0; i < len(s); i++ {
c := s[i]
d := base62Rev[c]
if d == 0xFF {
return 0, false
}
v = v*62 + uint64(d)
}
return v, true
}
// ErrInvalidCartID can be returned by higher-level validation layers if you decide
// to reject fallback-derived IDs (currently unused here).
var ErrInvalidCartID = errors.New("invalid cart id")
// ---------------------------------------------------------------------------
// Legacy / Compatibility Conversion Helpers
// ---------------------------------------------------------------------------
// CartIDToLegacy converts a CartID (base62) into the legacy fixed-size CartId
// ([16]byte) by copying the textual form (truncated or zero-padded).
// NOTE: If the base62 string is longer than 16 (should not happen with current
// 64-bit space), it will be truncated.
func CartIDToLegacy(c CartID) CartId {
var id CartId
txt := c.String()
copy(id[:], []byte(txt))
return id
}
// LegacyToCartID upgrades a legacy CartId (padded) to a CartID by hashing its
// trimmed string form (deterministic). This preserves stable mapping without
// depending on original randomness.
func LegacyToCartID(old CartId) CartID {
return UpgradeLegacyCartId(old)
}
// CartIDToKey returns the numeric key representation (uint64) for map indexing.
func CartIDToKey(c CartID) uint64 {
return c.Raw()
}
// LegacyToCartKey converts a legacy CartId to the numeric key via deterministic
// fallback hashing. (Uses the same logic as LegacyToCartID then returns raw.)
func LegacyToCartKey(old CartId) uint64 {
return LegacyToCartID(old).Raw()
}
// ---------------------- Optional Helper Utilities ----------------------------
// CartIDOrNew tries to parse s; if empty OR invalid returns a fresh ID.
func CartIDOrNew(s string) (CartID, bool /*wasParsed*/, error) {
if cid, ok := ParseCartID(s); ok {
return cid, true, nil
}
id, err := NewCartID()
return id, false, err
}
// MustParseCartID panics if s is not a valid base62 ID (useful in tests).
func MustParseCartID(s string) CartID {
if cid, ok := ParseCartID(s); ok {
return cid
}
panic(fmt.Sprintf("invalid CartID: %s", s))
}
// DebugString returns a verbose description (for logging / diagnostics).
func (c CartID) DebugString() string {
return fmt.Sprintf("CartID(raw=%d txt=%s)", c.raw, c.String())
}
// Equal compares two CartIDs by raw value.
func (c CartID) Equal(other CartID) bool {
return c.raw == other.raw
}
// CanonicalizeOrLegacy preserves legacy (non-base62) IDs without altering their
// textual form, avoiding the previous behavior where fallback hashing replaced
// the original string with a base62-encoded hash (which broke deterministic
// key derivation across mixed call paths).
//
// Behavior:
// - s == "" -> generate new CartID (generatedNew = true, wasBase62 = true)
// - base62 ok -> return parsed CartID (generatedNew = false, wasBase62 = true)
// - otherwise -> treat as legacy: raw = hash(s), txt = original s
//
// Returns:
//
// cid - CartID (txt preserved for legacy inputs)
// generatedNew - true only when a brand new ID was created due to empty input
// wasBase62 - true if the input was already canonical base62 (or generated)
// err - only set if crypto/rand fails when generating a new ID
func CanonicalizeOrLegacy(s string) (cid CartID, generatedNew bool, wasBase62 bool, err error) {
if s == "" {
id, e := NewCartID()
if e != nil {
return CartID{}, false, false, e
}
return id, true, true, nil
}
if parsed, ok := ParseCartID(s); ok {
return parsed, false, true, nil
}
// Legacy path: keep original text so downstream legacy-to-key hashing
// (which uses the visible string) yields consistent keys across code paths.
hashCID := FallbackFromString(s)
// Preserve original textual form
hashCID.txt = s
return hashCID, false, false, nil
}

View File

@@ -1,259 +0,0 @@
package main
import (
"crypto/rand"
"encoding/binary"
"fmt"
mrand "math/rand"
"testing"
)
// TestEncodeDecodeBase62RoundTrip verifies encodeBase62/decodeBase62 are inverse.
func TestEncodeDecodeBase62RoundTrip(t *testing.T) {
mrand.Seed(42)
for i := 0; i < 1000; i++ {
// Random 64-bit value
v := mrand.Uint64()
s := encodeBase62(v)
dec, ok := decodeBase62(s)
if !ok {
t.Fatalf("decodeBase62 failed for %d encoded=%s", v, s)
}
if dec != v {
t.Fatalf("round trip mismatch: have %d got %d (encoded=%s)", v, dec, s)
}
}
// Explicit zero test
if s := encodeBase62(0); s != "0" {
t.Fatalf("expected encodeBase62(0) == \"0\", got %q", s)
}
if v, ok := decodeBase62("0"); !ok || v != 0 {
t.Fatalf("decodeBase62(0) unexpected result v=%d ok=%v", v, ok)
}
}
// TestNewCartIDUniqueness generates a number of IDs and checks for duplicates.
func TestNewCartIDUniqueness(t *testing.T) {
const n = 10000
seen := make(map[string]struct{}, n)
for i := 0; i < n; i++ {
id, err := NewCartID()
if err != nil {
t.Fatalf("NewCartID error: %v", err)
}
s := id.String()
if _, exists := seen[s]; exists {
t.Fatalf("duplicate CartID generated: %s", s)
}
seen[s] = struct{}{}
if id.IsZero() {
t.Fatalf("NewCartID returned zero value")
}
}
}
// TestParseCartIDValidation tests parsing of valid and invalid base62 strings.
func TestParseCartIDValidation(t *testing.T) {
id, err := NewCartID()
if err != nil {
t.Fatalf("NewCartID error: %v", err)
}
parsed, ok := ParseCartID(id.String())
if !ok {
t.Fatalf("ParseCartID failed for valid id %s", id)
}
if parsed.raw != id.raw {
t.Fatalf("parsed raw mismatch: %d vs %d", parsed.raw, id.raw)
}
if _, ok := ParseCartID(""); ok {
t.Fatalf("expected empty string to be invalid")
}
// Invalid char ('-')
if _, ok := ParseCartID("abc-123"); ok {
t.Fatalf("expected invalid chars to fail parse")
}
// Overly long ( >16 )
if _, ok := ParseCartID("1234567890abcdefg"); ok {
t.Fatalf("expected overly long string to fail parse")
}
}
// TestFallbackDeterminism ensures fallback hashing is deterministic.
func TestFallbackDeterminism(t *testing.T) {
inputs := []string{
"legacy-cart-1",
"legacy-cart-2",
"UPPER_lower_123",
"🚀unicode", // unicode bytes (will hash byte sequence)
}
for _, in := range inputs {
a := FallbackFromString(in)
b := FallbackFromString(in)
if a.raw != b.raw || a.String() != b.String() {
t.Fatalf("fallback mismatch for %q: %+v vs %+v", in, a, b)
}
}
// Distinct inputs should almost always differ; sample check
a := FallbackFromString("distinct-A")
b := FallbackFromString("distinct-B")
if a.raw == b.raw {
t.Fatalf("unexpected identical fallback hashes for distinct inputs")
}
}
// TestCanonicalizeIncomingBehavior covers main control flow branches.
func TestCanonicalizeIncomingBehavior(t *testing.T) {
// Empty => new id
id1, generated, err := CanonicalizeIncoming("")
if err != nil || !generated || id1.IsZero() {
t.Fatalf("CanonicalizeIncoming empty failed: id=%v gen=%v err=%v", id1, generated, err)
}
// Valid base62 => parse; no generation
id2, gen2, err := CanonicalizeIncoming(id1.String())
if err != nil || gen2 || id2.raw != id1.raw {
t.Fatalf("CanonicalizeIncoming parse mismatch: id2=%v gen2=%v err=%v", id2, gen2, err)
}
// Legacy-like random containing invalid chars -> fallback
fallbackInput := "legacy\x00\x00padding"
id3, gen3, err := CanonicalizeIncoming(fallbackInput)
if err != nil || gen3 {
t.Fatalf("CanonicalizeIncoming fallback unexpected: id3=%v gen3=%v err=%v", id3, gen3, err)
}
// Deterministic fallback
id4, _, _ := CanonicalizeIncoming(fallbackInput)
if id3.raw != id4.raw {
t.Fatalf("fallback canonicalization not deterministic")
}
}
// TestUpgradeLegacyCartId ensures mapping of old CartId is stable.
func TestUpgradeLegacyCartId(t *testing.T) {
var legacy CartId
copy(legacy[:], []byte("legacy-123456789")) // 15 bytes + padding
up1 := UpgradeLegacyCartId(legacy)
up2 := UpgradeLegacyCartId(legacy)
if up1.raw != up2.raw {
t.Fatalf("UpgradeLegacyCartId not deterministic: %v vs %v", up1, up2)
}
if up1.String() != up2.String() {
t.Fatalf("UpgradeLegacyCartId string mismatch: %s vs %s", up1, up2)
}
}
// BenchmarkNewCartID gives a rough idea of generation cost.
func BenchmarkNewCartID(b *testing.B) {
for i := 0; i < b.N; i++ {
if _, err := NewCartID(); err != nil {
b.Fatalf("error: %v", err)
}
}
}
// BenchmarkEncodeBase62 measures encode speed in isolation.
func BenchmarkEncodeBase62(b *testing.B) {
// Random sample of values
samples := make([]uint64, 1024)
for i := range samples {
var buf [8]byte
if _, err := rand.Read(buf[:]); err != nil {
b.Fatalf("rand: %v", err)
}
samples[i] = binary.BigEndian.Uint64(buf[:])
}
b.ResetTimer()
var sink string
for i := 0; i < b.N; i++ {
sink = encodeBase62(samples[i%len(samples)])
}
_ = sink
}
// BenchmarkDecodeBase62 measures decode speed.
func BenchmarkDecodeBase62(b *testing.B) {
// Pre-encode
encoded := make([]string, 1024)
for i := range encoded {
encoded[i] = encodeBase62(uint64(i)<<32 | uint64(i))
}
b.ResetTimer()
var sum uint64
for i := 0; i < b.N; i++ {
v, ok := decodeBase62(encoded[i%len(encoded)])
if !ok {
b.Fatalf("decode failed")
}
sum ^= v
}
_ = sum
}
// TestLookupNDeterminism (ring integration smoke test) ensures LookupN
// returns distinct hosts and stable ordering for a fixed ring.
func TestLookupNDeterminism(t *testing.T) {
rb := NewRingBuilder().WithEpoch(1).WithVnodesPerHost(8).WithHosts([]string{"a", "b", "c"})
ring := rb.Build()
if ring.Empty() {
t.Fatalf("expected non-empty ring")
}
id := MustNewCartID()
owners1 := ring.LookupN(id.Raw(), 3)
owners2 := ring.LookupN(id.Raw(), 3)
if len(owners1) != len(owners2) {
t.Fatalf("LookupN length mismatch")
}
for i := range owners1 {
if owners1[i].Host != owners2[i].Host {
t.Fatalf("LookupN ordering instability at %d: %v vs %v", i, owners1[i], owners2[i])
}
}
// Distinct host constraint
seen := map[string]struct{}{}
for _, v := range owners1 {
if _, ok := seen[v.Host]; ok {
t.Fatalf("duplicate host in LookupN result: %v", owners1)
}
seen[v.Host] = struct{}{}
}
}
// TestRingFingerprintChanges ensures fingerprint updates with membership changes.
func TestRingFingerprintChanges(t *testing.T) {
b1 := NewRingBuilder().WithEpoch(1).WithHosts([]string{"node1", "node2"})
r1 := b1.Build()
b2 := NewRingBuilder().WithEpoch(2).WithHosts([]string{"node1", "node2", "node3"})
r2 := b2.Build()
if r1.Fingerprint() == r2.Fingerprint() {
t.Fatalf("expected differing fingerprints after host set change")
}
}
// TestRingDiffHosts verifies added/removed host detection.
func TestRingDiffHosts(t *testing.T) {
r1 := NewRingBuilder().WithEpoch(1).WithHosts([]string{"a", "b"}).Build()
r2 := NewRingBuilder().WithEpoch(2).WithHosts([]string{"b", "c"}).Build()
added, removed := r1.DiffHosts(r2)
if fmt.Sprintf("%v", added) != "[c]" {
t.Fatalf("expected added [c], got %v", added)
}
if fmt.Sprintf("%v", removed) != "[a]" {
t.Fatalf("expected removed [a], got %v", removed)
}
}
// TestRingLookupConsistency ensures direct Lookup and LookupID are aligned.
func TestRingLookupConsistency(t *testing.T) {
ring := NewRingBuilder().WithEpoch(1).WithHosts([]string{"alpha", "beta"}).WithVnodesPerHost(4).Build()
id, _ := ParseCartID("1")
if id.IsZero() {
t.Fatalf("expected parsed id non-zero")
}
v1 := ring.Lookup(id.Raw())
v2 := ring.LookupID(id)
if v1.Host != v2.Host || v1.Hash != v2.Hash {
t.Fatalf("Lookup vs LookupID mismatch: %+v vs %+v", v1, v2)
}
}

View File

@@ -1,212 +0,0 @@
package main
import (
messages "git.tornberg.me/go-cart-actor/proto"
)
// cart_state_mapper.go
//
// Utilities to translate between internal CartGrain state and the gRPC
// (typed) protobuf representation CartState. This replaces the previous
// JSON blob framing and enables type-safe replies over gRPC, as well as
// internal reuse for HTTP handlers without an extra marshal / unmarshal
// hop (you can marshal CartState directly for JSON responses if desired).
//
// Only the oneway mapping (CartGrain -> CartState) is strictly required
// for mutation / state replies. A reverse helper is included in case
// future features (e.g. snapshot import, replay, or migration) need it.
// ToCartState converts the inmemory CartGrain into a protobuf CartState.
func ToCartState(c *CartGrain) *messages.CartState {
if c == nil {
return nil
}
items := make([]*messages.CartItemState, 0, len(c.Items))
for _, it := range c.Items {
if it == nil {
continue
}
itemDiscountPerUnit := max(0, it.OrgPrice-it.Price)
itemTotalDiscount := itemDiscountPerUnit * int64(it.Quantity)
items = append(items, &messages.CartItemState{
Id: int64(it.Id),
ItemId: int64(it.ItemId),
Sku: it.Sku,
Name: it.Name,
Price: it.Price,
Qty: int32(it.Quantity),
TotalPrice: it.TotalPrice,
TotalTax: it.TotalTax,
OrgPrice: it.OrgPrice,
TaxRate: int32(it.TaxRate),
TotalDiscount: itemTotalDiscount,
Brand: it.Brand,
Category: it.Category,
Category2: it.Category2,
Category3: it.Category3,
Category4: it.Category4,
Category5: it.Category5,
Image: it.Image,
Type: it.ArticleType,
SellerId: it.SellerId,
SellerName: it.SellerName,
Disclaimer: it.Disclaimer,
Outlet: deref(it.Outlet),
StoreId: deref(it.StoreId),
Stock: int32(it.Stock),
})
}
deliveries := make([]*messages.DeliveryState, 0, len(c.Deliveries))
for _, d := range c.Deliveries {
if d == nil {
continue
}
itemIds := make([]int64, 0, len(d.Items))
for _, id := range d.Items {
itemIds = append(itemIds, int64(id))
}
var pp *messages.PickupPoint
if d.PickupPoint != nil {
// Copy to avoid accidental shared mutation (proto points are fine but explicit).
pp = &messages.PickupPoint{
Id: d.PickupPoint.Id,
Name: d.PickupPoint.Name,
Address: d.PickupPoint.Address,
City: d.PickupPoint.City,
Zip: d.PickupPoint.Zip,
Country: d.PickupPoint.Country,
}
}
deliveries = append(deliveries, &messages.DeliveryState{
Id: int64(d.Id),
Provider: d.Provider,
Price: d.Price,
Items: itemIds,
PickupPoint: pp,
})
}
return &messages.CartState{
Id: c.Id.String(),
Items: items,
TotalPrice: c.TotalPrice,
TotalTax: c.TotalTax,
TotalDiscount: c.TotalDiscount,
Deliveries: deliveries,
PaymentInProgress: c.PaymentInProgress,
OrderReference: c.OrderReference,
PaymentStatus: c.PaymentStatus,
}
}
// FromCartState merges a protobuf CartState into an existing CartGrain.
// This is optional and primarily useful for snapshot import or testing.
func FromCartState(cs *messages.CartState, g *CartGrain) *CartGrain {
if cs == nil {
return g
}
if g == nil {
g = &CartGrain{}
}
g.Id = ToCartId(cs.Id)
g.TotalPrice = cs.TotalPrice
g.TotalTax = cs.TotalTax
g.TotalDiscount = cs.TotalDiscount
g.PaymentInProgress = cs.PaymentInProgress
g.OrderReference = cs.OrderReference
g.PaymentStatus = cs.PaymentStatus
// Items
g.Items = g.Items[:0]
for _, it := range cs.Items {
if it == nil {
continue
}
outlet := toPtr(it.Outlet)
storeId := toPtr(it.StoreId)
g.Items = append(g.Items, &CartItem{
Id: int(it.Id),
ItemId: int(it.ItemId),
Sku: it.Sku,
Name: it.Name,
Price: it.Price,
Quantity: int(it.Qty),
TotalPrice: it.TotalPrice,
TotalTax: it.TotalTax,
OrgPrice: it.OrgPrice,
TaxRate: int(it.TaxRate),
Brand: it.Brand,
Category: it.Category,
Category2: it.Category2,
Category3: it.Category3,
Category4: it.Category4,
Category5: it.Category5,
Image: it.Image,
ArticleType: it.Type,
SellerId: it.SellerId,
SellerName: it.SellerName,
Disclaimer: it.Disclaimer,
Outlet: outlet,
StoreId: storeId,
Stock: StockStatus(it.Stock),
// Tax, TaxRate already set via Price / Totals if needed
})
if it.Id > int64(g.lastItemId) {
g.lastItemId = int(it.Id)
}
}
// Deliveries
g.Deliveries = g.Deliveries[:0]
for _, d := range cs.Deliveries {
if d == nil {
continue
}
intIds := make([]int, 0, len(d.Items))
for _, id := range d.Items {
intIds = append(intIds, int(id))
}
var pp *messages.PickupPoint
if d.PickupPoint != nil {
pp = &messages.PickupPoint{
Id: d.PickupPoint.Id,
Name: d.PickupPoint.Name,
Address: d.PickupPoint.Address,
City: d.PickupPoint.City,
Zip: d.PickupPoint.Zip,
Country: d.PickupPoint.Country,
}
}
g.Deliveries = append(g.Deliveries, &CartDelivery{
Id: int(d.Id),
Provider: d.Provider,
Price: d.Price,
Items: intIds,
PickupPoint: pp,
})
if d.Id > int64(g.lastDeliveryId) {
g.lastDeliveryId = int(d.Id)
}
}
return g
}
// Helper to safely de-reference optional string pointers to value or "".
func deref(p *string) string {
if p == nil {
return ""
}
return *p
}
func toPtr(s string) *string {
if s == "" {
return nil
}
return &s
}

5
cmd/backoffice/main.go Normal file
View File

@@ -0,0 +1,5 @@
package main
func main() {
// Your code here
}

268
cmd/cart/cart-grain.go Normal file
View File

@@ -0,0 +1,268 @@
package main
import (
"encoding/json"
"fmt"
"slices"
"sync"
"time"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"git.tornberg.me/go-cart-actor/pkg/voucher"
)
// Legacy padded [16]byte CartId and its helper methods removed.
// Unified CartId (uint64 with base62 string form) now defined in cart_id.go.
type StockStatus int
type ItemMeta struct {
Name string `json:"name"`
Brand string `json:"brand,omitempty"`
Category string `json:"category,omitempty"`
Category2 string `json:"category2,omitempty"`
Category3 string `json:"category3,omitempty"`
Category4 string `json:"category4,omitempty"`
Category5 string `json:"category5,omitempty"`
SellerId string `json:"sellerId,omitempty"`
SellerName string `json:"sellerName,omitempty"`
Image string `json:"image,omitempty"`
Outlet *string `json:"outlet,omitempty"`
}
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"`
OrgPrice *Price `json:"orgPrice,omitempty"`
Stock StockStatus `json:"stock"`
Quantity int `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"`
}
type CartDelivery struct {
Id uint32 `json:"id"`
Provider string `json:"provider"`
Price Price `json:"price"`
Items []uint32 `json:"items"`
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
}
type CartNotification struct {
LinkedId int `json:"id"`
Provider string `json:"provider"`
Title string `json:"title"`
Content string `json:"content"`
}
type CartGrain struct {
mu sync.RWMutex
lastItemId uint32
lastDeliveryId uint32
lastVoucherId uint32
lastAccess time.Time
lastChange time.Time // unix seconds of last successful mutation (replay sets from event ts)
userId string
Id CartId `json:"id"`
Items []*CartItem `json:"items"`
TotalPrice *Price `json:"totalPrice"`
TotalDiscount *Price `json:"totalDiscount"`
Deliveries []*CartDelivery `json:"deliveries,omitempty"`
Processing bool `json:"processing"`
PaymentInProgress bool `json:"paymentInProgress"`
OrderReference string `json:"orderReference,omitempty"`
PaymentStatus string `json:"paymentStatus,omitempty"`
Vouchers []*Voucher `json:"vouchers,omitempty"`
Notifications []CartNotification `json:"cartNotification,omitempty"`
}
type Voucher struct {
Code string `json:"code"`
Rules []*messages.VoucherRule `json:"rules"`
Id uint32 `json:"id"`
Value int64 `json:"value"`
}
func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
// No rules -> applies to entire cart
if len(v.Rules) == 0 {
return cart.Items, true
}
// Build evaluation context once
ctx := voucher.EvalContext{
Items: make([]voucher.Item, 0, len(cart.Items)),
CartTotalInc: 0,
}
if cart.TotalPrice != nil {
ctx.CartTotalInc = cart.TotalPrice.IncVat
}
for _, it := range cart.Items {
category := ""
if it.Meta != nil {
category = it.Meta.Category
}
ctx.Items = append(ctx.Items, voucher.Item{
Sku: it.Sku,
Category: category,
UnitPrice: it.Price.IncVat,
})
}
// All voucher rules must pass (logical AND)
for _, rule := range v.Rules {
expr := rule.GetCondition()
if expr == "" {
// Empty condition treated as pass (acts like a comment / placeholder)
continue
}
rs, err := voucher.ParseRules(expr)
if err != nil {
// Fail closed on parse error
return nil, false
}
if !rs.Applies(ctx) {
return nil, false
}
}
return cart.Items, true
}
func (c *CartGrain) GetId() uint64 {
return uint64(c.Id)
}
func (c *CartGrain) GetLastChange() time.Time {
return c.lastChange
}
func (c *CartGrain) GetLastAccess() time.Time {
return c.lastAccess
}
func (c *CartGrain) GetCurrentState() (*CartGrain, error) {
c.lastAccess = time.Now()
return c, nil
}
func getInt(data float64, ok bool) (int, error) {
if !ok {
return 0, fmt.Errorf("invalid type")
}
return int(data), nil
}
// func (c *CartGrain) AddItem(sku string, qty int, country string, storeId *string) (*CartGrain, error) {
// cartItem, err := getItemData(sku, qty, country)
// if err != nil {
// return nil, err
// }
// cartItem.StoreId = storeId
// return c.Apply(cartItem, false)
// }
func (c *CartGrain) GetState() ([]byte, error) {
return json.Marshal(c)
}
func (c *CartGrain) ItemsWithDelivery() []uint32 {
ret := make([]uint32, 0, len(c.Items))
for _, item := range c.Items {
for _, delivery := range c.Deliveries {
for _, id := range delivery.Items {
if item.Id == id {
ret = append(ret, id)
}
}
}
}
return ret
}
func (c *CartGrain) ItemsWithoutDelivery() []uint32 {
ret := make([]uint32, 0, len(c.Items))
hasDelivery := c.ItemsWithDelivery()
for _, item := range c.Items {
found := slices.Contains(hasDelivery, item.Id)
if !found {
ret = append(ret, item.Id)
}
}
return ret
}
func (c *CartGrain) FindItemWithSku(sku string) (*CartItem, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
for _, item := range c.Items {
if item.Sku == sku {
return item, true
}
}
return nil, false
}
// func (c *CartGrain) Apply(content proto.Message, isReplay bool) (*CartGrain, error) {
// updated, err := ApplyRegistered(c, content)
// if err != nil {
// if err == ErrMutationNotRegistered {
// return nil, fmt.Errorf("unsupported mutation type %T (not registered)", content)
// }
// return nil, err
// }
// // Sliding TTL: update lastChange only for non-replay successful mutations.
// if updated != nil && !isReplay {
// c.lastChange = time.Now()
// c.lastAccess = time.Now()
// go AppendCartEvent(c.Id, content)
// }
// return updated, nil
// }
func (c *CartGrain) UpdateTotals() {
c.TotalPrice = NewPrice()
c.TotalDiscount = NewPrice()
for _, item := range c.Items {
rowTotal := MultiplyPrice(item.Price, int64(item.Quantity))
item.TotalPrice = *rowTotal
c.TotalPrice.Add(*rowTotal)
if item.OrgPrice != nil {
diff := NewPrice()
diff.Add(*item.OrgPrice)
diff.Subtract(item.Price)
if diff.IncVat > 0 {
c.TotalDiscount.Add(*diff)
}
}
}
for _, delivery := range c.Deliveries {
c.TotalPrice.Add(delivery.Price)
}
for _, voucher := range c.Vouchers {
if _, ok := voucher.AppliesTo(c); ok {
value := NewPriceFromIncVat(voucher.Value, 25)
c.TotalDiscount.Add(*value)
c.TotalPrice.Subtract(*value)
}
}
}

View File

@@ -0,0 +1,48 @@
package main
import (
"testing"
)
// helper to create a cart grain with items and deliveries
func newTestCart() *CartGrain {
return &CartGrain{Items: []*CartItem{}, Deliveries: []*CartDelivery{}, Vouchers: []*Voucher{}, Notifications: []CartNotification{}}
}
func TestCartGrainUpdateTotalsBasic(t *testing.T) {
c := newTestCart()
// Item1 price 1250 (ex 1000 vat 250) org price higher -> discount 200 per unit
item1Price := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}}
item1Org := &Price{IncVat: 1500, VatRates: map[float32]int64{25: 300}}
item2Price := Price{IncVat: 2000, VatRates: map[float32]int64{25: 400}}
c.Items = []*CartItem{
{Id: 1, Price: item1Price, OrgPrice: item1Org, Quantity: 2},
{Id: 2, Price: item2Price, OrgPrice: &item2Price, Quantity: 1},
}
deliveryPrice := Price{IncVat: 4900, VatRates: map[float32]int64{25: 980}}
c.Deliveries = []*CartDelivery{{Id: 1, Price: deliveryPrice, Items: []uint32{1, 2}}}
c.UpdateTotals()
// Expected totals: sum inc vat of items * qty plus delivery
// item1 total inc = 1250*2 = 2500
// item2 total inc = 2000*1 = 2000
// delivery inc = 4900
expectedInc := int64(2500 + 2000 + 4900)
if c.TotalPrice.IncVat != expectedInc {
t.Fatalf("TotalPrice IncVat expected %d got %d", expectedInc, c.TotalPrice.IncVat)
}
// Discount: current implementation computes (OrgPrice - Price) ignoring quantity -> 1500-1250=250
if c.TotalDiscount.IncVat != 250 {
t.Fatalf("TotalDiscount expected 250 got %d", c.TotalDiscount.IncVat)
}
}
func TestCartGrainUpdateTotalsNoItems(t *testing.T) {
c := newTestCart()
c.UpdateTotals()
if c.TotalPrice.IncVat != 0 || c.TotalDiscount.IncVat != 0 {
t.Fatalf("expected zero totals got %+v", c)
}
}

159
cmd/cart/cart_id.go Normal file
View File

@@ -0,0 +1,159 @@
package main
import (
"crypto/rand"
"encoding/json"
"fmt"
)
// cart_id.go
//
// Breaking change:
// Unified cart identifier as a raw 64-bit unsigned integer (type CartId uint64).
// External textual representation: base62 (0-9 A-Z a-z), shortest possible
// encoding for 64 bits (max 11 characters, since 62^11 > 2^64).
//
// Rationale:
// - Replaces legacy fixed [16]byte padded string and transitional CartID wrapper.
// - Provides compact, URL/cookie-friendly identifiers.
// - O(1) hashing and minimal memory footprint.
// - 64 bits of crypto randomness => negligible collision probability at realistic scale.
//
// Public API:
// type CartId uint64
// func NewCartId() (CartId, error)
// func MustNewCartId() CartId
// func ParseCartId(string) (CartId, bool)
// func MustParseCartId(string) CartId
// (CartId).String() string
// (CartId).MarshalJSON() / UnmarshalJSON()
//
// NOTE:
// All legacy helpers (UpgradeLegacyCartId, Fallback hashing, Canonicalize variants,
// CartIDToLegacy, LegacyToCartID) have been removed as part of the breaking change.
//
// ---------------------------------------------------------------------------
type CartId uint64
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
// Reverse lookup (0xFF marks invalid)
var base62Rev [256]byte
func init() {
for i := range base62Rev {
base62Rev[i] = 0xFF
}
for i := 0; i < len(base62Alphabet); i++ {
base62Rev[base62Alphabet[i]] = byte(i)
}
}
// String returns the canonical base62 encoding of the 64-bit id.
func (id CartId) String() string {
return encodeBase62(uint64(id))
}
// MarshalJSON encodes the cart id as a JSON string.
func (id CartId) MarshalJSON() ([]byte, error) {
return json.Marshal(id.String())
}
// UnmarshalJSON decodes a cart id from a JSON string containing base62 text.
func (id *CartId) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
parsed, ok := ParseCartId(s)
if !ok {
return fmt.Errorf("invalid cart id: %q", s)
}
*id = parsed
return nil
}
// NewCartId generates a new cryptographically random non-zero 64-bit id.
func NewCartId() (CartId, error) {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return 0, fmt.Errorf("NewCartId: %w", err)
}
u := (uint64(b[0]) << 56) |
(uint64(b[1]) << 48) |
(uint64(b[2]) << 40) |
(uint64(b[3]) << 32) |
(uint64(b[4]) << 24) |
(uint64(b[5]) << 16) |
(uint64(b[6]) << 8) |
uint64(b[7])
if u == 0 {
// Extremely unlikely; regenerate once to avoid "0" identifier if desired.
return NewCartId()
}
return CartId(u), nil
}
// MustNewCartId panics if generation fails.
func MustNewCartId() CartId {
id, err := NewCartId()
if err != nil {
panic(err)
}
return id
}
// ParseCartId parses a base62 string into a CartId.
// Returns (0,false) for invalid input.
func ParseCartId(s string) (CartId, bool) {
// Accept length 1..11 (11 sufficient for 64 bits). Reject >11 immediately.
// Provide a slightly looser upper bound (<=16) only if you anticipate future
// extensions; here we stay strict.
if len(s) == 0 || len(s) > 11 {
return 0, false
}
u, ok := decodeBase62(s)
if !ok {
return 0, false
}
return CartId(u), true
}
// MustParseCartId panics on invalid base62 input.
func MustParseCartId(s string) CartId {
id, ok := ParseCartId(s)
if !ok {
panic(fmt.Sprintf("invalid cart id: %q", s))
}
return id
}
// encodeBase62 converts a uint64 to base62 (shortest form).
func encodeBase62(u uint64) string {
if u == 0 {
return "0"
}
var buf [11]byte
i := len(buf)
for u > 0 {
i--
buf[i] = base62Alphabet[u%62]
u /= 62
}
return string(buf[i:])
}
// decodeBase62 converts base62 text to uint64.
func decodeBase62(s string) (uint64, bool) {
var v uint64
for i := 0; i < len(s); i++ {
c := s[i]
d := base62Rev[c]
if d == 0xFF {
return 0, false
}
v = v*62 + uint64(d)
}
return v, true
}

185
cmd/cart/cart_id_test.go Normal file
View File

@@ -0,0 +1,185 @@
package main
import (
"encoding/json"
"fmt"
"testing"
)
// TestNewCartIdUniqueness generates many ids and checks for collisions.
func TestNewCartIdUniqueness(t *testing.T) {
const n = 20000
seen := make(map[string]struct{}, n)
for i := 0; i < n; i++ {
id, err := NewCartId()
if err != nil {
t.Fatalf("NewCartId error: %v", err)
}
s := id.String()
if _, exists := seen[s]; exists {
t.Fatalf("duplicate id encountered: %s", s)
}
seen[s] = struct{}{}
if s == "" {
t.Fatalf("empty string representation for id %d", id)
}
if len(s) > 11 {
t.Fatalf("encoded id length exceeds 11 chars: %s (%d)", s, len(s))
}
if id == 0 {
// We force regeneration on zero, extremely unlikely but test guards intent.
t.Fatalf("zero id generated (should be regenerated)")
}
}
}
// TestParseCartIdRoundTrip ensures parse -> string -> parse is stable.
func TestParseCartIdRoundTrip(t *testing.T) {
id := MustNewCartId()
txt := id.String()
parsed, ok := ParseCartId(txt)
if !ok {
t.Fatalf("ParseCartId failed for valid text %q", txt)
}
if parsed != id {
t.Fatalf("round trip mismatch: original=%d parsed=%d txt=%s", id, parsed, txt)
}
}
// TestParseCartIdInvalid covers invalid inputs.
func TestParseCartIdInvalid(t *testing.T) {
invalid := []string{
"", // empty
" ", // space
"01234567890abc", // >11 chars
"!!!!", // invalid chars
"-underscore-", // invalid chars
"abc_def", // underscore invalid for base62
"0123456789ABCD", // 14 chars
}
for _, s := range invalid {
if _, ok := ParseCartId(s); ok {
t.Fatalf("expected parse failure for %q", s)
}
}
}
// TestMustParseCartIdPanics verifies panic behavior for invalid input.
func TestMustParseCartIdPanics(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatalf("expected panic for invalid MustParseCartId input")
}
}()
_ = MustParseCartId("not*base62")
}
// TestJSONMarshalUnmarshalCartId verifies JSON round trip.
func TestJSONMarshalUnmarshalCartId(t *testing.T) {
id := MustNewCartId()
data, err := json.Marshal(struct {
Cart CartId `json:"cart"`
}{Cart: id})
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var out struct {
Cart CartId `json:"cart"`
}
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if out.Cart != id {
t.Fatalf("JSON round trip mismatch: have %d got %d", id, out.Cart)
}
}
// TestBase62LengthBound checks worst-case length (near max uint64).
func TestBase62LengthBound(t *testing.T) {
// Largest uint64
const maxU64 = ^uint64(0)
s := encodeBase62(maxU64)
if len(s) > 11 {
t.Fatalf("max uint64 encoded length > 11: %d (%s)", len(s), s)
}
dec, ok := decodeBase62(s)
if !ok || dec != maxU64 {
t.Fatalf("decode failed for max uint64: ok=%v dec=%d want=%d", ok, dec, maxU64)
}
}
// TestZeroEncoding ensures zero value encodes to "0" and parses back.
func TestZeroEncoding(t *testing.T) {
if s := encodeBase62(0); s != "0" {
t.Fatalf("encodeBase62(0) expected '0', got %q", s)
}
v, ok := decodeBase62("0")
if !ok || v != 0 {
t.Fatalf("decodeBase62('0') failed: ok=%v v=%d", ok, v)
}
if _, ok := ParseCartId("0"); !ok {
t.Fatalf("ParseCartId(\"0\") should succeed")
}
}
// TestSequentialParse ensures sequentially generated ids parse correctly.
func TestSequentialParse(t *testing.T) {
for i := 0; i < 1000; i++ {
id := MustNewCartId()
txt := id.String()
parsed, ok := ParseCartId(txt)
if !ok || parsed != id {
t.Fatalf("sequential parse mismatch: idx=%d orig=%d parsed=%d txt=%s", i, id, parsed, txt)
}
}
}
// BenchmarkNewCartId measures generation performance.
func BenchmarkNewCartId(b *testing.B) {
for i := 0; i < b.N; i++ {
if _, err := NewCartId(); err != nil {
b.Fatalf("NewCartId error: %v", err)
}
}
}
// BenchmarkEncodeBase62 measures encoding performance.
func BenchmarkEncodeBase62(b *testing.B) {
// Precompute sample values
samples := make([]uint64, 1024)
for i := range samples {
// Spread bits without crypto randomness overhead
samples[i] = (uint64(i) << 53) ^ (uint64(i) * 0x9E3779B185EBCA87)
}
b.ResetTimer()
var sink string
for i := 0; i < b.N; i++ {
sink = encodeBase62(samples[i%len(samples)])
}
_ = sink
}
// BenchmarkDecodeBase62 measures decoding performance.
func BenchmarkDecodeBase62(b *testing.B) {
encoded := make([]string, 1024)
for i := range encoded {
encoded[i] = encodeBase62((uint64(i) << 32) | uint64(i))
}
b.ResetTimer()
var sum uint64
for i := 0; i < b.N; i++ {
v, ok := decodeBase62(encoded[i%len(encoded)])
if !ok {
b.Fatalf("decode failure for %s", encoded[i%len(encoded)])
}
sum ^= v
}
_ = sum
}
// ExampleCartIdString documents usage of CartId string form.
func ExampleCartId_string() {
id := MustNewCartId()
fmt.Println(len(id.String()) <= 11) // outputs true
// Output: true
}

View File

@@ -64,20 +64,20 @@ func BuildCheckoutOrderPayload(grain *CartGrain, meta *CheckoutMeta) ([]byte, *C
lines = append(lines, &Line{
Type: "physical",
Reference: it.Sku,
Name: it.Name,
Name: it.Meta.Name,
Quantity: it.Quantity,
UnitPrice: int(it.Price),
UnitPrice: int(it.Price.IncVat),
TaxRate: 2500, // TODO: derive if variable tax rates are introduced
QuantityUnit: "st",
TotalAmount: int(it.TotalPrice),
TotalTaxAmount: int(it.TotalTax),
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", it.Image),
TotalAmount: int(it.TotalPrice.IncVat),
TotalTaxAmount: int(it.TotalPrice.TotalVat()),
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", it.Meta.Image),
})
}
// Delivery lines
for _, d := range grain.Deliveries {
if d == nil || d.Price <= 0 {
if d == nil || d.Price.IncVat <= 0 {
continue
}
lines = append(lines, &Line{
@@ -85,11 +85,11 @@ func BuildCheckoutOrderPayload(grain *CartGrain, meta *CheckoutMeta) ([]byte, *C
Reference: d.Provider,
Name: "Delivery",
Quantity: 1,
UnitPrice: int(d.Price),
UnitPrice: int(d.Price.IncVat),
TaxRate: 2500,
QuantityUnit: "st",
TotalAmount: int(d.Price),
TotalTaxAmount: int(GetTaxAmount(d.Price, 2500)),
TotalAmount: int(d.Price.IncVat),
TotalTaxAmount: int(d.Price.TotalVat()),
})
}
@@ -97,8 +97,8 @@ func BuildCheckoutOrderPayload(grain *CartGrain, meta *CheckoutMeta) ([]byte, *C
PurchaseCountry: country,
PurchaseCurrency: currency,
Locale: locale,
OrderAmount: int(grain.TotalPrice),
OrderTaxAmount: int(grain.TotalTax),
OrderAmount: int(grain.TotalPrice.IncVat),
OrderTaxAmount: int(grain.TotalPrice.TotalVat()),
OrderLines: lines,
MerchantReference1: grain.Id.String(),
MerchantURLS: &CheckoutMerchantURLS{

View File

@@ -12,10 +12,15 @@ import (
"syscall"
"time"
messages "git.tornberg.me/go-cart-actor/proto"
"git.tornberg.me/go-cart-actor/pkg/actor"
"git.tornberg.me/go-cart-actor/pkg/discovery"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"git.tornberg.me/go-cart-actor/pkg/proxy"
"git.tornberg.me/go-cart-actor/pkg/voucher"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
@@ -35,67 +40,17 @@ var (
})
)
func spawn(id CartId) (*CartGrain, error) {
grainSpawns.Inc()
ret := &CartGrain{
lastItemId: 0,
lastDeliveryId: 0,
Deliveries: []*CartDelivery{},
Id: id,
Items: []*CartItem{},
// storageMessages removed (legacy event log deprecated)
TotalPrice: 0,
}
err := loadMessages(ret, id)
return ret, err
}
func init() {
os.Mkdir("data", 0755)
}
type App struct {
pool *GrainLocalPool
storage *DiskStorage
}
func (a *App) Save() error {
hasChanges := false
a.pool.mu.RLock()
defer a.pool.mu.RUnlock()
for id, grain := range a.pool.GetGrains() {
if grain == nil {
continue
}
if grain.GetLastChange() > a.storage.LastSaves[id] {
hasChanges = true
err := a.storage.Store(id, grain)
if err != nil {
log.Printf("Error saving grain %s: %v\n", id, err)
}
}
}
if !hasChanges {
return nil
}
return a.storage.saveState()
}
func (a *App) HandleSave(w http.ResponseWriter, r *http.Request) {
err := a.Save()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
} else {
w.WriteHeader(http.StatusCreated)
}
pool *actor.SimpleGrainPool[CartGrain]
}
var podIp = os.Getenv("POD_IP")
var name = os.Getenv("POD_NAME")
var amqpUrl = os.Getenv("AMQP_URL")
var KlarnaInstance = NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
var tpl = `<!DOCTYPE html>
<html lang="en">
@@ -115,28 +70,13 @@ func getCountryFromHost(host string) string {
if strings.Contains(strings.ToLower(host), "-no") {
return "no"
}
if strings.Contains(strings.ToLower(host), "-se") {
return "se"
}
func getCheckoutOrder(host string, cartId CartId) *messages.CreateCheckoutOrder {
baseUrl := fmt.Sprintf("https://%s", host)
cartBaseUrl := os.Getenv("CART_BASE_URL")
if cartBaseUrl == "" {
cartBaseUrl = "https://cart.tornberg.me"
}
country := getCountryFromHost(host)
return &messages.CreateCheckoutOrder{
Terms: fmt.Sprintf("%s/terms", baseUrl),
Checkout: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}", baseUrl),
Confirmation: fmt.Sprintf("%s/confirmation/{checkout.order.id}", baseUrl),
Validation: fmt.Sprintf("%s/validation", cartBaseUrl),
Push: fmt.Sprintf("%s/push?order_id={checkout.order.id}", cartBaseUrl),
Country: country,
}
return ""
}
func GetDiscovery() Discovery {
func GetDiscovery() discovery.Discovery {
if podIp == "" {
return nil
}
@@ -150,52 +90,139 @@ func GetDiscovery() Discovery {
if err != nil {
log.Fatalf("Error creating client: %v\n", err)
}
return NewK8sDiscovery(client)
return discovery.NewK8sDiscovery(client)
}
type MutationContext struct {
VoucherService voucher.Service
}
func main() {
storage, err := NewDiskStorage(fmt.Sprintf("data/%s_state.gob", name))
controlPlaneConfig := actor.DefaultServerConfig()
reg := actor.NewMutationRegistry()
reg.RegisterMutations(
actor.NewMutation(AddItem, func() *messages.AddItem {
return &messages.AddItem{}
}),
actor.NewMutation(ChangeQuantity, func() *messages.ChangeQuantity {
return &messages.ChangeQuantity{}
}),
actor.NewMutation(RemoveItem, func() *messages.RemoveItem {
return &messages.RemoveItem{}
}),
actor.NewMutation(InitializeCheckout, func() *messages.InitializeCheckout {
return &messages.InitializeCheckout{}
}),
actor.NewMutation(OrderCreated, func() *messages.OrderCreated {
return &messages.OrderCreated{}
}),
actor.NewMutation(RemoveDelivery, func() *messages.RemoveDelivery {
return &messages.RemoveDelivery{}
}),
actor.NewMutation(SetDelivery, func() *messages.SetDelivery {
return &messages.SetDelivery{}
}),
actor.NewMutation(SetPickupPoint, func() *messages.SetPickupPoint {
return &messages.SetPickupPoint{}
}),
actor.NewMutation(ClearCart, func() *messages.ClearCartRequest {
return &messages.ClearCartRequest{}
}),
actor.NewMutation(AddVoucher, func() *messages.AddVoucher {
return &messages.AddVoucher{}
}),
actor.NewMutation(RemoveVoucher, func() *messages.RemoveVoucher {
return &messages.RemoveVoucher{}
}),
)
diskStorage := actor.NewDiskStorage[CartGrain]("data", reg)
poolConfig := actor.GrainPoolConfig[CartGrain]{
MutationRegistry: reg,
Storage: diskStorage,
Spawn: func(id uint64) (actor.Grain[CartGrain], error) {
grainSpawns.Inc()
ret := &CartGrain{
lastItemId: 0,
lastDeliveryId: 0,
Deliveries: []*CartDelivery{},
Id: CartId(id),
Items: []*CartItem{},
TotalPrice: NewPrice(),
}
// Set baseline lastChange at spawn; replay may update it to last event timestamp.
ret.lastChange = time.Now()
ret.lastAccess = time.Now()
err := diskStorage.LoadEvents(id, ret)
return ret, err
},
SpawnHost: func(host string) (actor.Host, error) {
return proxy.NewRemoteHost(host)
},
TTL: 15 * time.Minute,
PoolSize: 2 * 65535,
Hostname: podIp,
}
pool, err := actor.NewSimpleGrainPool(poolConfig)
if err != nil {
log.Printf("Error loading state: %v\n", err)
log.Fatalf("Error creating cart pool: %v\n", err)
}
app := &App{
pool: NewGrainLocalPool(65535, 5*time.Minute, spawn),
storage: storage,
pool: pool,
}
syncedPool, err := NewSyncedPool(app.pool, podIp, GetDiscovery())
grpcSrv, err := actor.NewControlServer[*CartGrain](controlPlaneConfig, pool)
if err != nil {
log.Fatalf("Error creating synced pool: %v\n", err)
}
// Start unified gRPC server (CartActor + ControlPlane) replacing legacy RPC server on :1337
// TODO: Remove any remaining legacy RPC server references and deprecated frame-based code after full gRPC migration is validated.
grpcSrv, err := StartGRPCServer(":1337", app.pool, syncedPool)
if err != nil {
log.Fatalf("Error starting gRPC server: %v\n", err)
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
}
defer grpcSrv.GracefulStop()
go func() {
for range time.Tick(time.Minute * 10) {
err := app.Save()
go diskStorage.SaveLoop(10 * time.Second)
go func(hw discovery.Discovery) {
if hw == nil {
log.Print("No discovery service available")
return
}
ch, err := hw.Watch()
if err != nil {
log.Printf("Error saving: %v\n", err)
log.Printf("Discovery error: %v", err)
return
}
for evt := range ch {
if evt.Host == "" {
continue
}
switch evt.Type {
case watch.Deleted:
if pool.IsKnown(evt.Host) {
pool.RemoveHost(evt.Host)
}
default:
if !pool.IsKnown(evt.Host) {
log.Printf("Discovered host %s", evt.Host)
pool.AddRemote(evt.Host)
}
}
}()
}
}(GetDiscovery())
orderHandler := &AmqpOrderHandler{
Url: amqpUrl,
}
klarnaClient := NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
syncedServer := NewPoolServer(syncedPool, fmt.Sprintf("%s, %s", name, podIp))
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient)
mux := http.NewServeMux()
mux.Handle("/cart/", http.StripPrefix("/cart", syncedServer.Serve()))
// only for local
// mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
// syncedPool.AddRemote(r.PathValue("host"))
// })
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
pool.AddRemote(r.PathValue("host"))
})
// mux.HandleFunc("GET /save", app.HandleSave)
//mux.HandleFunc("/", app.RewritePath)
mux.HandleFunc("/debug/pprof/", pprof.Index)
@@ -206,16 +233,13 @@ func main() {
mux.Handle("/metrics", promhttp.Handler())
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
// Grain pool health: simple capacity check (mirrors previous GrainHandler.IsHealthy)
app.pool.mu.RLock()
grainCount := len(app.pool.grains)
capacity := app.pool.PoolSize
app.pool.mu.RUnlock()
grainCount, capacity := app.pool.LocalUsage()
if grainCount >= capacity {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("grain pool at capacity"))
return
}
if !syncedPool.IsHealthy() {
if !pool.IsHealthy() {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("control plane not healthy"))
return
@@ -248,31 +272,49 @@ func main() {
w.Write([]byte("no cart id to checkout is empty"))
return
}
cartId := ToCartId(cookie.Value)
parsed, ok := ParseCartId(cookie.Value)
if !ok {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("invalid cart id format"))
return
}
cartId := parsed
syncedServer.ProxyHandler(func(w http.ResponseWriter, r *http.Request, cartId CartId) error {
order, err = syncedServer.CreateOrUpdateCheckout(r.Host, cartId)
if err != nil {
return err
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, tpl, order.HTMLSnippet)
return nil
})(cartId, w, r)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
// v2: Apply now returns *CartGrain; order creation handled inside grain (no payload to unmarshal)
} else {
order, err = KlarnaInstance.GetOrder(orderId)
order, err = klarnaClient.GetOrder(orderId)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
w.WriteHeader(http.StatusOK)
w.Write([]byte(fmt.Sprintf(tpl, order.HTMLSnippet)))
fmt.Fprintf(w, tpl, order.HTMLSnippet)
}
})
mux.HandleFunc("/confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) {
orderId := r.PathValue("order_id")
order, err := KlarnaInstance.GetOrder(orderId)
order, err := klarnaClient.GetOrder(orderId)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
@@ -294,7 +336,7 @@ func main() {
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(fmt.Sprintf(tpl, order.HTMLSnippet)))
fmt.Fprintf(w, tpl, order.HTMLSnippet)
})
mux.HandleFunc("/validate", func(w http.ResponseWriter, r *http.Request) {
log.Printf("Klarna order validation, method: %s", r.Method)
@@ -332,7 +374,7 @@ func main() {
orderId := r.URL.Query().Get("order_id")
log.Printf("Order confirmation push: %s", orderId)
order, err := KlarnaInstance.GetOrder(orderId)
order, err := klarnaClient.GetOrder(orderId)
if err != nil {
log.Printf("Error creating request: %v\n", err)
@@ -347,13 +389,13 @@ func main() {
return
}
err = triggerOrderCompleted(err, syncedServer, order)
err = triggerOrderCompleted(syncedServer, order)
if err != nil {
log.Printf("Error processing cart message: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = KlarnaInstance.AcknowledgeOrder(orderId)
err = klarnaClient.AcknowledgeOrder(orderId)
if err != nil {
log.Printf("Error acknowledging order: %v\n", err)
}
@@ -365,6 +407,8 @@ func main() {
w.Write([]byte("1.0.0"))
})
mux.HandleFunc("/openapi.json", ServeEmbeddedOpenAPI)
sigs := make(chan os.Signal, 1)
done := make(chan bool, 1)
signal.Notify(sigs, syscall.SIGTERM)
@@ -372,8 +416,9 @@ func main() {
go func() {
sig := <-sigs
fmt.Println("Shutting down due to signal:", sig)
go syncedPool.Close()
app.Save()
diskStorage.Close()
pool.Close()
done <- true
}()
@@ -383,12 +428,18 @@ func main() {
}
func triggerOrderCompleted(err error, syncedServer *PoolServer, order *CheckoutOrder) error {
_, err = syncedServer.pool.Apply(ToCartId(order.MerchantReference1), &messages.OrderCreated{
func triggerOrderCompleted(syncedServer *PoolServer, order *CheckoutOrder) error {
mutation := &messages.OrderCreated{
OrderId: order.ID,
Status: order.Status,
})
return err
}
cid, ok := ParseCartId(order.MerchantReference1)
if !ok {
return fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
}
_, applyErr := syncedServer.Apply(uint64(cid), mutation)
return applyErr
}
func confirmOrder(order *CheckoutOrder, orderHandler *AmqpOrderHandler) error {

View File

@@ -0,0 +1,91 @@
package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_add_item.go
//
// Registers the AddItem cart mutation in the generic mutation registry.
// This replaces the legacy switch-based logic previously found in CartGrain.Apply.
//
// Behavior:
// * Validates quantity > 0
// * If an item with same SKU exists -> increases quantity
// * Else creates a new CartItem with computed tax amounts
// * Totals recalculated automatically via WithTotals()
//
// NOTE: Any future field additions in messages.AddItem that affect pricing / tax
// must keep this handler in sync.
func AddItem(g *CartGrain, m *messages.AddItem) error {
if m == nil {
return fmt.Errorf("AddItem: nil payload")
}
if m.Quantity < 1 {
return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity)
}
// Fast path: merge with existing item having same SKU
if existing, found := g.FindItemWithSku(m.Sku); found {
if existing.StoreId == m.StoreId {
existing.Quantity += int(m.Quantity)
existing.Stock = StockStatus(m.Stock)
existing.StoreId = m.StoreId
return nil
}
}
g.mu.Lock()
defer g.mu.Unlock()
g.lastItemId++
taxRate := float32(25.0)
if m.Tax > 0 {
taxRate = float32(int(m.Tax) / 100)
}
pricePerItem := NewPriceFromIncVat(m.Price, taxRate)
g.Items = append(g.Items, &CartItem{
Id: g.lastItemId,
ItemId: uint32(m.ItemId),
Quantity: int(m.Quantity),
Sku: m.Sku,
Meta: &ItemMeta{
Name: m.Name,
Image: m.Image,
Brand: m.Brand,
Category: m.Category,
Category2: m.Category2,
Category3: m.Category3,
Category4: m.Category4,
Category5: m.Category5,
Outlet: m.Outlet,
SellerId: m.SellerId,
SellerName: m.SellerName,
},
Price: *pricePerItem,
TotalPrice: *MultiplyPrice(*pricePerItem, int64(m.Quantity)),
Stock: StockStatus(m.Stock),
Disclaimer: m.Disclaimer,
OrgPrice: getOrgPrice(m.OrgPrice, taxRate),
ArticleType: m.ArticleType,
StoreId: m.StoreId,
})
g.UpdateTotals()
return nil
}
func getOrgPrice(orgPrice int64, taxRate float32) *Price {
if orgPrice <= 0 {
return nil
}
return NewPriceFromIncVat(orgPrice, taxRate)
}

View File

@@ -0,0 +1,64 @@
package main
import (
"slices"
"git.tornberg.me/go-cart-actor/pkg/actor"
"git.tornberg.me/go-cart-actor/pkg/messages"
)
func RemoveVoucher(g *CartGrain, m *messages.RemoveVoucher) error {
if m == nil {
return &actor.MutationError{
Message: "RemoveVoucher: nil payload",
Code: 1003,
StatusCode: 400,
}
}
if !slices.ContainsFunc(g.Vouchers, func(v *Voucher) bool {
return v.Id == m.Id
}) {
return &actor.MutationError{
Message: "voucher not applied",
Code: 1004,
StatusCode: 400,
}
}
g.Vouchers = slices.DeleteFunc(g.Vouchers, func(v *Voucher) bool {
return v.Id == m.Id
})
g.UpdateTotals()
return nil
}
func AddVoucher(g *CartGrain, m *messages.AddVoucher) error {
if m == nil {
return &actor.MutationError{
Message: "AddVoucher: nil payload",
Code: 1001,
StatusCode: 400,
}
}
if slices.ContainsFunc(g.Vouchers, func(v *Voucher) bool {
return v.Code == m.Code
}) {
return &actor.MutationError{
Message: "voucher already applied",
Code: 1002,
StatusCode: 400,
}
}
g.lastVoucherId++
g.Vouchers = append(g.Vouchers, &Voucher{
Id: g.lastVoucherId,
Code: m.Code,
Rules: m.VoucherRules,
Value: m.Value,
})
g.UpdateTotals()
return nil
}

View File

@@ -3,7 +3,7 @@ package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/proto"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_change_quantity.go
@@ -25,17 +25,15 @@ import (
// the grain's implicit expectation that higher layers control access.
// (If strict locking is required around every mutation, wrap logic in
// an explicit g.mu.Lock()/Unlock(), but current model mirrors prior code.)
func init() {
RegisterMutation[messages.ChangeQuantity](
"ChangeQuantity",
func(g *CartGrain, m *messages.ChangeQuantity) error {
func ChangeQuantity(g *CartGrain, m *messages.ChangeQuantity) error {
if m == nil {
return fmt.Errorf("ChangeQuantity: nil payload")
}
foundIndex := -1
for i, it := range g.Items {
if it.Id == int(m.Id) {
if it.Id == uint32(m.Id) {
foundIndex = i
break
}
@@ -51,8 +49,6 @@ func init() {
}
g.Items[foundIndex].Quantity = int(m.Quantity)
g.UpdateTotals()
return nil
},
WithTotals(),
)
}

View File

@@ -3,7 +3,7 @@ package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/proto"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_initialize_checkout.go
@@ -28,10 +28,8 @@ import (
// parallel checkout attempts are possible, add higher-level guards
// (e.g. reject if PaymentInProgress already true unless reusing
// the same OrderReference).
func init() {
RegisterMutation[messages.InitializeCheckout](
"InitializeCheckout",
func(g *CartGrain, m *messages.InitializeCheckout) error {
func InitializeCheckout(g *CartGrain, m *messages.InitializeCheckout) error {
if m == nil {
return fmt.Errorf("InitializeCheckout: nil payload")
}
@@ -43,7 +41,4 @@ func init() {
g.PaymentStatus = m.Status
g.PaymentInProgress = m.PaymentInProgress
return nil
},
// No WithTotals(): monetary aggregates are unaffected.
)
}

View File

@@ -3,7 +3,7 @@ package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/proto"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_order_created.go
@@ -32,10 +32,8 @@ import (
// - Relies on the higher-level guarantee that Apply() calls are serialized
// per grain. If out-of-order events are possible, embed versioning or
// timestamps in the mutation and compare before applying changes.
func init() {
RegisterMutation[messages.OrderCreated](
"OrderCreated",
func(g *CartGrain, m *messages.OrderCreated) error {
func OrderCreated(g *CartGrain, m *messages.OrderCreated) error {
if m == nil {
return fmt.Errorf("OrderCreated: nil payload")
}
@@ -47,7 +45,4 @@ func init() {
g.PaymentStatus = m.Status
g.PaymentInProgress = false
return nil
},
// No WithTotals(): order completion does not modify pricing or taxes.
)
}

View File

@@ -3,7 +3,7 @@ package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/proto"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_remove_delivery.go
@@ -25,14 +25,12 @@ import (
// Future considerations:
// - If delivery pricing logic changes (e.g., dynamic taxes per delivery),
// UpdateTotals() may need enhancement to incorporate delivery tax properly.
func init() {
RegisterMutation[messages.RemoveDelivery](
"RemoveDelivery",
func(g *CartGrain, m *messages.RemoveDelivery) error {
func RemoveDelivery(g *CartGrain, m *messages.RemoveDelivery) error {
if m == nil {
return fmt.Errorf("RemoveDelivery: nil payload")
}
targetID := int(m.Id)
targetID := uint32(m.Id)
index := -1
for i, d := range g.Deliveries {
if d.Id == targetID {
@@ -46,8 +44,6 @@ func init() {
// Remove delivery (order not preserved beyond necessity)
g.Deliveries = append(g.Deliveries[:index], g.Deliveries[index+1:]...)
g.UpdateTotals()
return nil
},
WithTotals(),
)
}

View File

@@ -3,7 +3,7 @@ package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/proto"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_remove_item.go
@@ -21,14 +21,12 @@ import (
// 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.
func init() {
RegisterMutation[messages.RemoveItem](
"RemoveItem",
func(g *CartGrain, m *messages.RemoveItem) error {
func RemoveItem(g *CartGrain, m *messages.RemoveItem) error {
if m == nil {
return fmt.Errorf("RemoveItem: nil payload")
}
targetID := int(m.Id)
targetID := uint32(m.Id)
index := -1
for i, it := range g.Items {
@@ -42,8 +40,6 @@ func init() {
}
g.Items = append(g.Items[:index], g.Items[index+1:]...)
g.UpdateTotals()
return nil
},
WithTotals(),
)
}

View File

@@ -4,7 +4,7 @@ import (
"fmt"
"slices"
messages "git.tornberg.me/go-cart-actor/proto"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_set_delivery.go
@@ -39,10 +39,8 @@ import (
// - Variable delivery pricing (based on weight, distance, provider, etc.)
// - Validation of provider codes
// - Multi-currency delivery pricing
func init() {
RegisterMutation[messages.SetDelivery](
"SetDelivery",
func(g *CartGrain, m *messages.SetDelivery) error {
func SetDelivery(g *CartGrain, m *messages.SetDelivery) error {
if m == nil {
return fmt.Errorf("SetDelivery: nil payload")
}
@@ -51,7 +49,7 @@ func init() {
}
withDelivery := g.ItemsWithDelivery()
targetItems := make([]int, 0)
targetItems := make([]uint32, 0)
if len(m.Items) == 0 {
// Use every item currently without a delivery
@@ -59,7 +57,7 @@ func init() {
} else {
// Validate explicit list
for _, id64 := range m.Items {
id := int(id64)
id := uint32(id64)
found := false
for _, it := range g.Items {
if it.Id == id {
@@ -89,13 +87,10 @@ func init() {
Id: newId,
Provider: m.Provider,
PickupPoint: m.PickupPoint,
Price: 4900, // TODO: externalize pricing
Price: *NewPriceFromIncVat(4900, 25.0),
Items: targetItems,
})
g.mu.Unlock()
return nil
},
WithTotals(),
)
}

View File

@@ -3,7 +3,7 @@ package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/proto"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_set_pickup_point.go
@@ -28,16 +28,14 @@ import (
// - Validate pickup point fields (country code, zip format, etc.)
// - Track history / audit of pickup point changes
// - Trigger delivery price adjustments (which would then require WithTotals()).
func init() {
RegisterMutation[messages.SetPickupPoint](
"SetPickupPoint",
func(g *CartGrain, m *messages.SetPickupPoint) error {
func SetPickupPoint(g *CartGrain, m *messages.SetPickupPoint) error {
if m == nil {
return fmt.Errorf("SetPickupPoint: nil payload")
}
for _, d := range g.Deliveries {
if d.Id == int(m.DeliveryId) {
if d.Id == uint32(m.DeliveryId) {
d.PickupPoint = &messages.PickupPoint{
Id: m.Id,
Name: m.Name,
@@ -50,7 +48,15 @@ func init() {
}
}
return fmt.Errorf("SetPickupPoint: delivery id %d not found", m.DeliveryId)
},
// No WithTotals(): pickup point does not change pricing / tax.
)
}
func ClearCart(g *CartGrain, m *messages.ClearCartRequest) error {
if m == nil {
return fmt.Errorf("ClearCart: nil payload")
}
// maybe check if payment is done?
g.Deliveries = g.Deliveries[:0]
g.Items = g.Items[:0]
g.UpdateTotals()
return nil
}

677
cmd/cart/openapi.json Normal file
View File

@@ -0,0 +1,677 @@
{
"openapi": "3.0.3",
"info": {
"title": "Cart Service API",
"description": "HTTP API for shopping cart operations (cookie-based or explicit id): retrieve cart, add/replace items, update quantity, manage deliveries.",
"version": "1.0.0"
},
"servers": [
{
"url": "https://cart.tornberg.me",
"description": "Production server"
},
{
"url": "http://localhost:8080",
"description": "Local development (cart API mounted under /cart)"
}
],
"paths": {
"/cart/": {
"get": {
"summary": "Get (or create) current cart (cookie based)",
"description": "Returns the current cart. If no cartid cookie is present a new cart is created and Set-Cart-Id response header plus a Set-Cookie header are sent.",
"responses": {
"200": {
"description": "Cart retrieved",
"headers": {
"Set-Cart-Id": {
"description": "Returned when a new cart was created this request",
"schema": { "type": "string" }
},
"X-Pod-Name": {
"description": "Pod identifier serving the request",
"schema": { "type": "string" }
}
},
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"500": { "description": "Server error" }
}
},
"post": {
"summary": "Add single SKU (body)",
"description": "Adds (or increases quantity of) a single SKU using request body.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/AddRequest" }
}
}
},
"responses": {
"200": {
"description": "Item added",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid request body" },
"500": { "description": "Server error" }
}
},
"put": {
"summary": "Change quantity of an item",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ChangeQuantity" }
}
}
},
"responses": {
"200": {
"description": "Quantity updated",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid request body" },
"500": { "description": "Server error" }
}
},
"delete": {
"summary": "Clear cart cookie (logical cart reused only if referenced later)",
"description": "Removes the cartid cookie by expiring it. Does not mutate server-side cart state.",
"responses": {
"200": { "description": "Cookie cleared (empty body)" }
}
}
},
"/cart/add/{sku}": {
"get": {
"summary": "Add a SKU (path)",
"description": "Adds a single SKU with implicit quantity 1. Country inferred from Host header (-se / -no).",
"parameters": [{ "$ref": "#/components/parameters/SkuParam" }],
"responses": {
"200": {
"description": "Item added",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"500": { "description": "Server error" }
}
}
},
"/cart/add": {
"post": {
"summary": "Add multiple items (append)",
"description": "Adds multiple items to the cart without clearing existing contents.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/SetCartItems" }
}
}
},
"responses": {
"200": {
"description": "Items added",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body" },
"500": { "description": "Server error" }
}
}
},
"/cart/set": {
"post": {
"summary": "Replace cart contents",
"description": "Clears the cart first, then adds the provided items (idempotent with respect to target set).",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/SetCartItems" }
}
}
},
"responses": {
"200": {
"description": "Cart replaced",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body" },
"500": { "description": "Server error" }
}
}
},
"/cart/{itemId}": {
"delete": {
"summary": "Remove item by line id",
"parameters": [
{
"name": "itemId",
"in": "path",
"required": true,
"schema": { "type": "integer", "format": "int64", "minimum": 0 },
"description": "Internal cart line item identifier (not SKU)."
}
],
"responses": {
"200": {
"description": "Item removed",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Bad id" },
"500": { "description": "Server error" }
}
}
},
"/cart/delivery": {
"post": {
"summary": "Set (add) delivery",
"description": "Adds a delivery option referencing one or more line item ids.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/SetDeliveryRequest" }
}
}
},
"responses": {
"200": {
"description": "Delivery added/updated",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body" },
"500": { "description": "Server error" }
}
}
},
"/cart/delivery/{deliveryId}": {
"delete": {
"summary": "Remove delivery",
"parameters": [{ "$ref": "#/components/parameters/DeliveryIdParam" }],
"responses": {
"200": {
"description": "Delivery removed",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Bad id" },
"500": { "description": "Server error" }
}
}
},
"/cart/delivery/{deliveryId}/pickupPoint": {
"put": {
"summary": "Set pickup point for delivery",
"parameters": [{ "$ref": "#/components/parameters/DeliveryIdParam" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/PickupPoint" }
}
}
},
"responses": {
"200": {
"description": "Pickup point set",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body" },
"500": { "description": "Server error" }
}
}
},
"/cart/byid/{id}": {
"get": {
"summary": "Get cart by explicit id",
"parameters": [{ "$ref": "#/components/parameters/CartIdParam" }],
"responses": {
"200": {
"description": "Cart retrieved",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid id" },
"500": { "description": "Server error" }
}
},
"post": {
"summary": "Add single SKU (body) by cart id",
"parameters": [{ "$ref": "#/components/parameters/CartIdParam" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/AddRequest" }
}
}
},
"responses": {
"200": {
"description": "Item added",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body" },
"500": { "description": "Server error" }
}
},
"put": {
"summary": "Change quantity (by id variant)",
"parameters": [{ "$ref": "#/components/parameters/CartIdParam" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ChangeQuantity" }
}
}
},
"responses": {
"200": {
"description": "Quantity updated",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body" },
"500": { "description": "Server error" }
}
}
},
"/cart/byid/{id}/add/{sku}": {
"get": {
"summary": "Add SKU (path) by explicit cart id",
"parameters": [
{ "$ref": "#/components/parameters/CartIdParam" },
{ "$ref": "#/components/parameters/SkuParam" }
],
"responses": {
"200": {
"description": "Item added",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid id/sku" },
"500": { "description": "Server error" }
}
}
},
"/cart/byid/{id}/{itemId}": {
"delete": {
"summary": "Remove item (by id variant)",
"parameters": [
{ "$ref": "#/components/parameters/CartIdParam" },
{
"name": "itemId",
"in": "path",
"required": true,
"schema": { "type": "integer", "format": "int64", "minimum": 0 }
}
],
"responses": {
"200": {
"description": "Item removed",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid id" },
"500": { "description": "Server error" }
}
}
},
"/cart/byid/{id}/delivery": {
"post": {
"summary": "Set delivery (by id variant)",
"parameters": [{ "$ref": "#/components/parameters/CartIdParam" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/SetDeliveryRequest" }
}
}
},
"responses": {
"200": {
"description": "Delivery added/updated",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body" },
"500": { "description": "Server error" }
}
}
},
"/cart/byid/{id}/delivery/{deliveryId}": {
"delete": {
"summary": "Remove delivery (by id variant)",
"parameters": [
{ "$ref": "#/components/parameters/CartIdParam" },
{ "$ref": "#/components/parameters/DeliveryIdParam" }
],
"responses": {
"200": {
"description": "Delivery removed",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid ids" },
"500": { "description": "Server error" }
}
}
},
"/cart/byid/{id}/delivery/{deliveryId}/pickupPoint": {
"put": {
"summary": "Set pickup point (by id variant)",
"parameters": [
{ "$ref": "#/components/parameters/CartIdParam" },
{ "$ref": "#/components/parameters/DeliveryIdParam" }
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/PickupPoint" }
}
}
},
"responses": {
"200": {
"description": "Pickup point updated",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body" },
"500": { "description": "Server error" }
}
}
},
"/healthz": {
"get": {
"summary": "Liveness & capacity probe",
"responses": {
"200": { "description": "Healthy" },
"500": { "description": "Unhealthy" }
}
}
},
"/readyz": {
"get": {
"summary": "Readiness probe",
"responses": {
"200": { "description": "Ready" }
}
}
},
"/livez": {
"get": {
"summary": "Liveness probe",
"responses": {
"200": { "description": "Alive" }
}
}
},
"/version": {
"get": {
"summary": "Service version",
"responses": {
"200": {
"description": "Version string",
"content": {
"text/plain": {
"schema": { "type": "string", "example": "1.0.0" }
}
}
}
}
}
}
},
"components": {
"parameters": {
"SkuParam": {
"name": "sku",
"in": "path",
"required": true,
"schema": { "type": "string" }
},
"CartIdParam": {
"name": "id",
"in": "path",
"required": true,
"description": "Base62 encoded cart id",
"schema": {
"type": "string",
"pattern": "^[0-9A-Za-z]+$",
"minLength": 1
}
},
"DeliveryIdParam": {
"name": "deliveryId",
"in": "path",
"required": true,
"schema": { "type": "integer", "format": "int64", "minimum": 0 }
}
},
"schemas": {
"CartGrain": {
"type": "object",
"description": "Cart aggregate (actor state)",
"properties": {
"id": {
"type": "string",
"description": "Cart id (base62 encoded uint64)"
},
"items": {
"type": "array",
"items": { "$ref": "#/components/schemas/CartItem" }
},
"totalPrice": { "type": "integer", "format": "int64" },
"totalTax": { "type": "integer", "format": "int64" },
"totalDiscount": { "type": "integer", "format": "int64" },
"deliveries": {
"type": "array",
"items": { "$ref": "#/components/schemas/CartDelivery" }
},
"processing": { "type": "boolean" },
"paymentInProgress": { "type": "boolean" },
"orderReference": { "type": "string" },
"paymentStatus": { "type": "string" }
},
"required": ["id", "items", "totalPrice", "totalTax", "totalDiscount"]
},
"CartItem": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"itemId": { "type": "integer" },
"parentId": { "type": "integer" },
"sku": { "type": "string" },
"name": { "type": "string" },
"price": { "type": "integer", "format": "int64" },
"totalPrice": { "type": "integer", "format": "int64" },
"totalTax": { "type": "integer", "format": "int64" },
"orgPrice": { "type": "integer", "format": "int64" },
"stock": {
"type": "integer",
"description": "0=OutOfStock,1=LowStock,2=InStock"
},
"qty": { "type": "integer" },
"tax": { "type": "integer" },
"taxRate": { "type": "integer" },
"brand": { "type": "string" },
"category": { "type": "string" },
"category2": { "type": "string" },
"category3": { "type": "string" },
"category4": { "type": "string" },
"category5": { "type": "string" },
"disclaimer": { "type": "string" },
"sellerId": { "type": "string" },
"sellerName": { "type": "string" },
"type": { "type": "string", "description": "Article type" },
"image": { "type": "string" },
"outlet": { "type": "string", "nullable": true },
"storeId": { "type": "string", "nullable": true }
},
"required": ["id", "sku", "name", "price", "qty", "tax"]
},
"CartDelivery": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"provider": { "type": "string" },
"price": { "type": "integer", "format": "int64" },
"items": {
"type": "array",
"items": { "type": "integer" }
},
"pickupPoint": { "$ref": "#/components/schemas/PickupPoint" }
},
"required": ["id", "provider", "price", "items"]
},
"PickupPoint": {
"type": "object",
"properties": {
"id": { "type": "string" },
"name": { "type": "string", "nullable": true },
"address": { "type": "string", "nullable": true },
"city": { "type": "string", "nullable": true },
"zip": { "type": "string", "nullable": true },
"country": { "type": "string", "nullable": true }
},
"required": ["id"]
},
"AddRequest": {
"type": "object",
"properties": {
"quantity": {
"type": "integer",
"format": "int32",
"minimum": 1,
"default": 1
},
"sku": { "type": "string" },
"country": {
"type": "string",
"description": "Two-letter country code (inferred if omitted)"
},
"storeId": { "type": "string", "nullable": true }
},
"required": ["sku"]
},
"ChangeQuantity": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int64",
"description": "Cart line item id"
},
"quantity": { "type": "integer", "format": "int32", "minimum": 0 }
},
"required": ["id", "quantity"]
},
"Item": {
"type": "object",
"properties": {
"sku": { "type": "string" },
"quantity": { "type": "integer", "minimum": 1 },
"storeId": { "type": "string", "nullable": true }
},
"required": ["sku", "quantity"]
},
"SetCartItems": {
"type": "object",
"properties": {
"country": { "type": "string" },
"items": {
"type": "array",
"items": { "$ref": "#/components/schemas/Item" },
"minItems": 0
}
},
"required": ["items"]
},
"SetDeliveryRequest": {
"type": "object",
"properties": {
"provider": { "type": "string" },
"items": {
"type": "array",
"items": { "type": "integer", "format": "int64" },
"description": "Line item ids served by this delivery"
},
"pickupPoint": { "$ref": "#/components/schemas/PickupPoint" }
},
"required": ["provider", "items"]
}
}
},
"tags": [{ "name": "Cart" }, { "name": "Delivery" }, { "name": "System" }]
}

69
cmd/cart/openapi_embed.go Normal file
View File

@@ -0,0 +1,69 @@
package main
import (
"bytes"
"crypto/sha256"
_ "embed"
"encoding/hex"
"net/http"
"sync"
)
// openapi_embed.go: Provides embedded OpenAPI spec and helper to mount handler.
//go:embed openapi.json
var openapiJSON []byte
var (
openapiOnce sync.Once
openapiETag string
)
// initOpenAPIMetadata computes immutable metadata for the embedded spec.
func initOpenAPIMetadata() {
sum := sha256.Sum256(openapiJSON)
openapiETag = `W/"` + hex.EncodeToString(sum[:8]) + `"` // weak ETag with first 8 bytes
}
// ServeEmbeddedOpenAPI serves the embedded OpenAPI JSON spec at /openapi.json.
// It supports GET and HEAD and implements basic ETag caching.
func ServeEmbeddedOpenAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
openapiOnce.Do(initOpenAPIMetadata)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("ETag", openapiETag)
if match := r.Header.Get("If-None-Match"); match != "" {
if bytes.Contains([]byte(match), []byte(openapiETag)) {
w.WriteHeader(http.StatusNotModified)
return
}
}
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(openapiJSON)
}
// Optional: function to access raw spec bytes programmatically.
func OpenAPISpecBytes() []byte {
return openapiJSON
}
// Optional: function to access current ETag.
func OpenAPIETag() string {
openapiOnce.Do(initOpenAPIMetadata)
return openapiETag
}

532
cmd/cart/pool-server.go Normal file
View File

@@ -0,0 +1,532 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"sync"
"time"
"git.tornberg.me/go-cart-actor/pkg/actor"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"git.tornberg.me/go-cart-actor/pkg/voucher"
"github.com/gogo/protobuf/proto"
)
type PoolServer struct {
actor.GrainPool[*CartGrain]
pod_name string
klarnaClient *KlarnaClient
}
func NewPoolServer(pool actor.GrainPool[*CartGrain], pod_name string, klarnaClient *KlarnaClient) *PoolServer {
return &PoolServer{
GrainPool: pool,
pod_name: pod_name,
klarnaClient: klarnaClient,
}
}
func (s *PoolServer) ApplyLocal(id CartId, mutation ...proto.Message) (*actor.MutationResult[*CartGrain], error) {
return s.Apply(uint64(id), mutation...)
}
func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
grain, err := s.Get(uint64(id))
if err != nil {
return err
}
return s.WriteResult(w, grain)
}
func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
sku := r.PathValue("sku")
msg, err := GetItemAddMessage(sku, 1, getCountryFromHost(r.Host), nil)
if err != nil {
return err
}
data, err := s.ApplyLocal(id, msg)
if err != nil {
return err
}
return s.WriteResult(w, data)
}
func (s *PoolServer) WriteResult(w http.ResponseWriter, result any) error {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("X-Pod-Name", s.pod_name)
if result == nil {
w.WriteHeader(http.StatusInternalServerError)
return nil
}
w.WriteHeader(http.StatusOK)
enc := json.NewEncoder(w)
err := enc.Encode(result)
return err
}
func (s *PoolServer) DeleteItemHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
itemIdString := r.PathValue("itemId")
itemId, err := strconv.ParseInt(itemIdString, 10, 64)
if err != nil {
return err
}
data, err := s.ApplyLocal(id, &messages.RemoveItem{Id: uint32(itemId)})
if err != nil {
return err
}
return s.WriteResult(w, data)
}
type SetDeliveryRequest struct {
Provider string `json:"provider"`
Items []uint32 `json:"items"`
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
}
func (s *PoolServer) SetDeliveryHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
delivery := SetDeliveryRequest{}
err := json.NewDecoder(r.Body).Decode(&delivery)
if err != nil {
return err
}
data, err := s.ApplyLocal(id, &messages.SetDelivery{
Provider: delivery.Provider,
Items: delivery.Items,
PickupPoint: delivery.PickupPoint,
})
if err != nil {
return err
}
return s.WriteResult(w, data)
}
func (s *PoolServer) SetPickupPointHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
deliveryIdString := r.PathValue("deliveryId")
deliveryId, err := strconv.ParseInt(deliveryIdString, 10, 64)
if err != nil {
return err
}
pickupPoint := messages.PickupPoint{}
err = json.NewDecoder(r.Body).Decode(&pickupPoint)
if err != nil {
return err
}
reply, err := s.ApplyLocal(id, &messages.SetPickupPoint{
DeliveryId: uint32(deliveryId),
Id: pickupPoint.Id,
Name: pickupPoint.Name,
Address: pickupPoint.Address,
City: pickupPoint.City,
Zip: pickupPoint.Zip,
Country: pickupPoint.Country,
})
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) RemoveDeliveryHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
deliveryIdString := r.PathValue("deliveryId")
deliveryId, err := strconv.Atoi(deliveryIdString)
if err != nil {
return err
}
reply, err := s.ApplyLocal(id, &messages.RemoveDelivery{Id: uint32(deliveryId)})
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) QuantityChangeHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
changeQuantity := messages.ChangeQuantity{}
err := json.NewDecoder(r.Body).Decode(&changeQuantity)
if err != nil {
return err
}
reply, err := s.ApplyLocal(id, &changeQuantity)
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
type Item struct {
Sku string `json:"sku"`
Quantity int `json:"quantity"`
StoreId *string `json:"storeId,omitempty"`
}
type SetCartItems struct {
Country string `json:"country"`
Items []Item `json:"items"`
}
func getMultipleAddMessages(items []Item, country string) []proto.Message {
wg := sync.WaitGroup{}
mu := sync.Mutex{}
msgs := make([]proto.Message, 0, len(items))
for _, itm := range items {
wg.Go(
func() {
msg, err := GetItemAddMessage(itm.Sku, itm.Quantity, country, itm.StoreId)
if err != nil {
log.Printf("error adding item %s: %v", itm.Sku, err)
return
}
mu.Lock()
msgs = append(msgs, msg)
mu.Unlock()
})
}
wg.Wait()
return msgs
}
func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
setCartItems := SetCartItems{}
err := json.NewDecoder(r.Body).Decode(&setCartItems)
if err != nil {
return err
}
msgs := make([]proto.Message, 0, len(setCartItems.Items)+1)
msgs = append(msgs, &messages.ClearCartRequest{})
msgs = append(msgs, getMultipleAddMessages(setCartItems.Items, setCartItems.Country)...)
reply, err := s.ApplyLocal(id, msgs...)
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) AddMultipleItemHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
setCartItems := SetCartItems{}
err := json.NewDecoder(r.Body).Decode(&setCartItems)
if err != nil {
return err
}
reply, err := s.ApplyLocal(id, getMultipleAddMessages(setCartItems.Items, setCartItems.Country)...)
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
type AddRequest struct {
Sku string `json:"sku"`
Quantity int32 `json:"quantity"`
Country string `json:"country"`
StoreId *string `json:"storeId"`
}
func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
addRequest := AddRequest{Quantity: 1}
err := json.NewDecoder(r.Body).Decode(&addRequest)
if err != nil {
return err
}
msg, err := GetItemAddMessage(addRequest.Sku, int(addRequest.Quantity), addRequest.Country, addRequest.StoreId)
if err != nil {
return err
}
reply, err := s.ApplyLocal(id, msg)
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
// func (s *PoolServer) HandleConfirmation(w http.ResponseWriter, r *http.Request, id CartId) error {
// orderId := r.PathValue("orderId")
// if orderId == "" {
// return fmt.Errorf("orderId is empty")
// }
// order, err := KlarnaInstance.GetOrder(orderId)
// if err != nil {
// return err
// }
// w.Header().Set("Content-Type", "application/json")
// w.Header().Set("X-Pod-Name", s.pod_name)
// w.Header().Set("Cache-Control", "no-cache")
// w.Header().Set("Access-Control-Allow-Origin", "*")
// w.WriteHeader(http.StatusOK)
// return json.NewEncoder(w).Encode(order)
// }
func getCurrency(country string) string {
if country == "no" {
return "NOK"
}
return "SEK"
}
func getLocale(country string) string {
if country == "no" {
return "nb-no"
}
return "sv-se"
}
func (s *PoolServer) CreateOrUpdateCheckout(host string, id CartId) (*CheckoutOrder, error) {
country := getCountryFromHost(host)
meta := &CheckoutMeta{
Terms: fmt.Sprintf("https://%s/terms", host),
Checkout: fmt.Sprintf("https://%s/checkout?order_id={checkout.order.id}", host),
Confirmation: fmt.Sprintf("https://%s/confirmation/{checkout.order.id}", host),
Validation: fmt.Sprintf("https://%s/validate", host),
Push: fmt.Sprintf("https://%s/push?order_id={checkout.order.id}", host),
Country: country,
Currency: getCurrency(country),
Locale: getLocale(country),
}
// Get current grain state (may be local or remote)
grain, err := s.Get(uint64(id))
if err != nil {
return nil, err
}
// Build pure checkout payload
payload, _, err := BuildCheckoutOrderPayload(grain, meta)
if err != nil {
return nil, err
}
if grain.OrderReference != "" {
return s.klarnaClient.UpdateOrder(grain.OrderReference, bytes.NewReader(payload))
} else {
return s.klarnaClient.CreateOrder(bytes.NewReader(payload))
}
}
func (s *PoolServer) ApplyCheckoutStarted(klarnaOrder *CheckoutOrder, id CartId) (*actor.MutationResult[*CartGrain], error) {
// Persist initialization state via mutation (best-effort)
return s.ApplyLocal(id, &messages.InitializeCheckout{
OrderId: klarnaOrder.ID,
Status: klarnaOrder.Status,
PaymentInProgress: true,
})
}
// func (s *PoolServer) HandleCheckout(w http.ResponseWriter, r *http.Request, id CartId) error {
// klarnaOrder, err := s.CreateOrUpdateCheckout(r.Host, id)
// if err != nil {
// return err
// }
// s.ApplyCheckoutStarted(klarnaOrder, id)
// w.Header().Set("Content-Type", "application/json")
// return json.NewEncoder(w).Encode(klarnaOrder)
// }
//
func CookieCartIdHandler(fn func(cartId CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var id CartId
cookie, err := r.Cookie("cartid")
if err != nil || cookie.Value == "" {
id = MustNewCartId()
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: id.String(),
Secure: r.TLS != nil,
HttpOnly: true,
Path: "/",
Expires: time.Now().AddDate(0, 0, 14),
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Set-Cart-Id", id.String())
} else {
parsed, ok := ParseCartId(cookie.Value)
if !ok {
id = MustNewCartId()
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: id.String(),
Secure: r.TLS != nil,
HttpOnly: true,
Path: "/",
Expires: time.Now().AddDate(0, 0, 14),
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Set-Cart-Id", id.String())
} else {
id = parsed
}
}
err = fn(id, w, r)
if err != nil {
log.Printf("Server error, not remote error: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
}
}
// Removed leftover legacy block after CookieCartIdHandler (obsolete code referencing cid/legacy)
func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId CartId) error {
// Clear cart cookie (breaking change: do not issue a new legacy id here)
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: "",
Path: "/",
Secure: r.TLS != nil,
HttpOnly: true,
Expires: time.Unix(0, 0),
SameSite: http.SameSiteLaxMode,
})
w.WriteHeader(http.StatusOK)
return nil
}
func CartIdHandler(fn func(cartId CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var id CartId
raw := r.PathValue("id")
// If no id supplied, generate a new one
if raw == "" {
id := MustNewCartId()
w.Header().Set("Set-Cart-Id", id.String())
} else {
// Parse base62 cart id
if parsedId, ok := ParseCartId(raw); !ok {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("cart id is invalid"))
return
} else {
id = parsedId
}
}
err := fn(id, w, r)
if err != nil {
log.Printf("Server error, not remote error: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
}
}
func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request, cartId CartId) error) func(cartId CartId, w http.ResponseWriter, r *http.Request) error {
return func(cartId CartId, w http.ResponseWriter, r *http.Request) error {
if ownerHost, ok := s.OwnerHost(uint64(cartId)); ok {
handled, err := ownerHost.Proxy(uint64(cartId), w, r)
if err == nil && handled {
return nil
}
}
return fn(w, r, cartId)
}
}
type AddVoucherRequest struct {
VoucherCode string `json:"code"`
}
func (s *PoolServer) AddVoucherHandler(w http.ResponseWriter, r *http.Request, cartId CartId) error {
data := &AddVoucherRequest{}
json.NewDecoder(r.Body).Decode(data)
v := voucher.Service{}
msg, err := v.GetVoucher(data.VoucherCode)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return err
}
reply, err := s.ApplyLocal(cartId, msg)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return err
}
s.WriteResult(w, reply)
return nil
}
func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request, cartId CartId) error {
idStr := r.PathValue("voucherId")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return err
}
reply, err := s.ApplyLocal(cartId, &messages.RemoveVoucher{Id: uint32(id)})
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return err
}
s.WriteResult(w, reply)
return nil
}
func (s *PoolServer) Serve() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("OPTIONS /", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("GET /", CookieCartIdHandler(s.ProxyHandler(s.GetCartHandler)))
mux.HandleFunc("GET /add/{sku}", CookieCartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
mux.HandleFunc("POST /add", CookieCartIdHandler(s.ProxyHandler(s.AddMultipleItemHandler)))
mux.HandleFunc("POST /", CookieCartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
mux.HandleFunc("POST /set", CookieCartIdHandler(s.ProxyHandler(s.SetCartItemsHandler)))
mux.HandleFunc("DELETE /{itemId}", CookieCartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
mux.HandleFunc("PUT /", CookieCartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
mux.HandleFunc("DELETE /", CookieCartIdHandler(s.ProxyHandler(s.RemoveCartCookie)))
mux.HandleFunc("POST /delivery", CookieCartIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
mux.HandleFunc("DELETE /delivery/{deliveryId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
mux.HandleFunc("PUT /delivery/{deliveryId}/pickupPoint", CookieCartIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
mux.HandleFunc("PUT /voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
mux.HandleFunc("DELETE /voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
//mux.HandleFunc("GET /checkout", CookieCartIdHandler(s.ProxyHandler(s.HandleCheckout)))
//mux.HandleFunc("GET /confirmation/{orderId}", CookieCartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
mux.HandleFunc("GET /byid/{id}", CartIdHandler(s.ProxyHandler(s.GetCartHandler)))
mux.HandleFunc("GET /byid/{id}/add/{sku}", CartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
mux.HandleFunc("POST /byid/{id}", CartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
mux.HandleFunc("DELETE /byid/{id}/{itemId}", CartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
mux.HandleFunc("PUT /byid/{id}", CartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
mux.HandleFunc("POST /byid/{id}/delivery", CartIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
mux.HandleFunc("DELETE /byid/{id}/delivery/{deliveryId}", CartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
mux.HandleFunc("PUT /byid/{id}/delivery/{deliveryId}/pickupPoint", CartIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
mux.HandleFunc("PUT /byid/{id}/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
mux.HandleFunc("DELETE /byid/{id}/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
//mux.HandleFunc("GET /byid/{id}/checkout", CartIdHandler(s.ProxyHandler(s.HandleCheckout)))
//mux.HandleFunc("GET /byid/{id}/confirmation", CartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
return mux
}

131
cmd/cart/price.go Normal file
View File

@@ -0,0 +1,131 @@
package main
import (
"encoding/json"
"strconv"
)
func GetTaxAmount(total int64, tax int) int64 {
taxD := 10000 / float64(tax)
return int64(float64(total) / float64((1 + taxD)))
}
type Price struct {
IncVat int64 `json:"incVat"`
VatRates map[float32]int64 `json:"vat,omitempty"`
}
func NewPrice() *Price {
return &Price{
IncVat: 0,
VatRates: make(map[float32]int64),
}
}
func NewPriceFromIncVat(incVat int64, taxRate float32) *Price {
tax := GetTaxAmount(incVat, int(taxRate*100))
return &Price{
IncVat: incVat,
VatRates: map[float32]int64{
taxRate: tax,
},
}
}
func (p *Price) ValueExVat() int64 {
exVat := p.IncVat
for _, amount := range p.VatRates {
exVat -= amount
}
return exVat
}
func (p *Price) TotalVat() int64 {
total := int64(0)
for _, amount := range p.VatRates {
total += amount
}
return total
}
func MultiplyPrice(p Price, qty int64) *Price {
ret := &Price{
IncVat: p.IncVat * qty,
VatRates: make(map[float32]int64),
}
for rate, amount := range p.VatRates {
ret.VatRates[rate] = amount * qty
}
return ret
}
func (p *Price) Multiply(qty int64) {
p.IncVat *= qty
for rate, amount := range p.VatRates {
p.VatRates[rate] = amount * qty
}
}
func (p Price) MarshalJSON() ([]byte, error) {
// Build a stable wire format without calling Price.MarshalJSON recursively
exVat := p.ValueExVat()
var vat map[string]int64
if len(p.VatRates) > 0 {
vat = make(map[string]int64, len(p.VatRates))
for rate, amount := range p.VatRates {
// Rely on default formatting that trims trailing zeros for whole numbers
// Using %g could output scientific notation for large numbers; float32 rates here are small.
key := trimFloat(rate)
vat[key] = amount
}
}
type wire struct {
ExVat int64 `json:"exVat"`
IncVat int64 `json:"incVat"`
Vat map[string]int64 `json:"vat,omitempty"`
}
return json.Marshal(wire{ExVat: exVat, IncVat: p.IncVat, Vat: vat})
}
// trimFloat converts a float32 tax rate like 25 or 12.5 into a compact string without
// unnecessary decimals ("25", "12.5").
func trimFloat(f float32) string {
// Convert via FormatFloat then trim trailing zeros and dot.
s := strconv.FormatFloat(float64(f), 'f', -1, 32)
return s
}
func (p *Price) Add(price Price) {
p.IncVat += price.IncVat
for rate, amount := range price.VatRates {
p.VatRates[rate] += amount
}
}
func (p *Price) Subtract(price Price) {
p.IncVat -= price.IncVat
for rate, amount := range price.VatRates {
p.VatRates[rate] -= amount
}
}
func SumPrices(prices ...Price) *Price {
if len(prices) == 0 {
return NewPrice()
}
aggregated := NewPrice()
for _, price := range prices {
aggregated.IncVat += price.IncVat
for rate, amount := range price.VatRates {
aggregated.VatRates[rate] += amount
}
}
if len(aggregated.VatRates) == 0 {
aggregated.VatRates = nil
}
return aggregated
}

135
cmd/cart/price_test.go Normal file
View File

@@ -0,0 +1,135 @@
package main
import (
"encoding/json"
"testing"
)
func TestPriceMarshalJSON(t *testing.T) {
p := Price{IncVat: 13700, VatRates: map[float32]int64{25: 2500, 12: 1200}}
// ExVat = 13700 - (2500+1200) = 10000
data, err := json.Marshal(p)
if err != nil {
t.Fatalf("marshal error: %v", err)
}
// Unmarshal into a generic struct to validate fields
var out struct {
ExVat int64 `json:"exVat"`
IncVat int64 `json:"incVat"`
Vat map[string]int64 `json:"vat"`
}
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if out.ExVat != 10000 {
t.Fatalf("expected exVat 10000 got %d", out.ExVat)
}
if out.IncVat != 13700 {
t.Fatalf("expected incVat 13700 got %d", out.IncVat)
}
if out.Vat["25"] != 2500 || out.Vat["12"] != 1200 {
t.Fatalf("unexpected vat map: %#v", out.Vat)
}
}
func TestNewPriceFromIncVat(t *testing.T) {
p := NewPriceFromIncVat(1000, 0.25)
if p.IncVat != 1000 {
t.Fatalf("expected IncVat %d got %d", 1000, p.IncVat)
}
if p.VatRates[25] != 250 {
t.Fatalf("expected VAT 25 rate %d got %d", 250, p.VatRates[25])
}
if p.ValueExVat() != 750 {
t.Fatalf("expected exVat %d got %d", 750, p.ValueExVat())
}
}
func TestSumPrices(t *testing.T) {
// We'll construct prices via raw struct since constructor expects tax math.
// IncVat already includes vat portions.
a := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}} // ex=1000
b := Price{IncVat: 2740, VatRates: map[float32]int64{25: 500, 12: 240}} // ex=2000
c := Price{IncVat: 0, VatRates: nil}
sum := SumPrices(a, b, c)
if sum.IncVat != 3990 { // 1250+2740
t.Fatalf("expected incVat 3990 got %d", sum.IncVat)
}
if len(sum.VatRates) != 2 {
t.Fatalf("expected 2 vat rates got %d", len(sum.VatRates))
}
if sum.VatRates[25] != 750 {
t.Fatalf("expected 25%% vat 750 got %d", sum.VatRates[25])
}
if sum.VatRates[12] != 240 {
t.Fatalf("expected 12%% vat 240 got %d", sum.VatRates[12])
}
if sum.ValueExVat() != 3000 { // 3990 - (750+240)
t.Fatalf("expected exVat 3000 got %d", sum.ValueExVat())
}
}
func TestSumPricesEmpty(t *testing.T) {
sum := SumPrices()
if sum.IncVat != 0 || sum.VatRates == nil { // constructor sets empty map
t.Fatalf("expected zero price got %#v", sum)
}
}
func TestMultiplyPriceFunction(t *testing.T) {
base := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}}
multiplied := MultiplyPrice(base, 3)
if multiplied.IncVat != 1250*3 {
t.Fatalf("expected IncVat %d got %d", 1250*3, multiplied.IncVat)
}
if multiplied.VatRates[25] != 250*3 {
t.Fatalf("expected VAT 25 rate %d got %d", 250*3, multiplied.VatRates[25])
}
if multiplied.ValueExVat() != (1250-250)*3 {
t.Fatalf("expected exVat %d got %d", (1250-250)*3, multiplied.ValueExVat())
}
}
func TestPriceAddSubtract(t *testing.T) {
a := Price{IncVat: 1000, VatRates: map[float32]int64{25: 200}}
b := Price{IncVat: 500, VatRates: map[float32]int64{25: 100, 12: 54}}
acc := NewPrice()
acc.Add(a)
acc.Add(b)
if acc.IncVat != 1500 {
t.Fatalf("expected IncVat 1500 got %d", acc.IncVat)
}
if acc.VatRates[25] != 300 || acc.VatRates[12] != 54 {
t.Fatalf("unexpected VAT map: %#v", acc.VatRates)
}
// Subtract b then a returns to zero
acc.Subtract(b)
acc.Subtract(a)
if acc.IncVat != 0 {
t.Fatalf("expected IncVat 0 got %d", acc.IncVat)
}
if len(acc.VatRates) != 2 || acc.VatRates[25] != 0 || acc.VatRates[12] != 0 {
t.Fatalf("expected zeroed vat rates got %#v", acc.VatRates)
}
}
func TestPriceMultiplyMethod(t *testing.T) {
p := Price{IncVat: 2000, VatRates: map[float32]int64{25: 400}}
// Value before multiply
exBefore := p.ValueExVat()
p.Multiply(2)
if p.IncVat != 4000 {
t.Fatalf("expected IncVat 4000 got %d", p.IncVat)
}
if p.VatRates[25] != 800 {
t.Fatalf("expected VAT 800 got %d", p.VatRates[25])
}
if p.ValueExVat() != exBefore*2 {
t.Fatalf("expected exVat %d got %d", exBefore*2, p.ValueExVat())
}
}

121
cmd/cart/product-fetcher.go Normal file
View File

@@ -0,0 +1,121 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"github.com/matst80/slask-finder/pkg/index"
)
// TODO make this configurable
func getBaseUrl(country string) string {
// if country == "se" {
// return "http://s10n-se:8080"
// }
if country == "no" {
return "http://s10n-no.s10n:8080"
}
if country == "se" {
return "http://s10n-se.s10n:8080"
}
return "http://localhost:8082"
}
func FetchItem(sku string, country string) (*index.DataItem, error) {
baseUrl := getBaseUrl(country)
res, err := http.Get(fmt.Sprintf("%s/api/by-sku/%s", baseUrl, sku))
if err != nil {
return nil, err
}
defer res.Body.Close()
var item index.DataItem
err = json.NewDecoder(res.Body).Decode(&item)
return &item, err
}
func GetItemAddMessage(sku string, qty int, country string, storeId *string) (*messages.AddItem, error) {
item, err := FetchItem(sku, country)
if err != nil {
return nil, err
}
return ToItemAddMessage(item, storeId, qty, country), nil
}
func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country string) *messages.AddItem {
orgPrice, _ := getInt(item.GetNumberFieldValue(5)) // getInt(item.Fields[5])
price, err := getInt(item.GetNumberFieldValue(4)) //Fields[4]
if err != nil {
return nil
}
stock := StockStatus(0)
centralStockValue, ok := item.GetStringFieldValue(3)
if storeId == nil {
if ok {
pureNumber := strings.Replace(centralStockValue, "+", "", -1)
if centralStock, err := strconv.ParseInt(pureNumber, 10, 64); err == nil {
stock = StockStatus(centralStock)
}
}
} else {
storeStock, ok := item.Stock.GetStock()[*storeId]
if ok {
stock = StockStatus(storeStock)
}
}
articleType, _ := item.GetStringFieldValue(1) //.Fields[1].(string)
outletGrade, ok := item.GetStringFieldValue(20) //.Fields[20].(string)
var outlet *string
if ok {
outlet = &outletGrade
}
sellerId, _ := item.GetStringFieldValue(24) // .Fields[24].(string)
sellerName, _ := item.GetStringFieldValue(9) // .Fields[9].(string)
brand, _ := item.GetStringFieldValue(2) //.Fields[2].(string)
category, _ := item.GetStringFieldValue(10) //.Fields[10].(string)
category2, _ := item.GetStringFieldValue(11) //.Fields[11].(string)
category3, _ := item.GetStringFieldValue(12) //.Fields[12].(string)
category4, _ := item.GetStringFieldValue(13) //Fields[13].(string)
category5, _ := item.GetStringFieldValue(14) //.Fields[14].(string)
return &messages.AddItem{
ItemId: uint32(item.Id),
Quantity: int32(qty),
Price: int64(price),
OrgPrice: int64(orgPrice),
Sku: item.GetSku(),
Name: item.Title,
Image: item.Img,
Stock: int32(stock),
Brand: brand,
Category: category,
Category2: category2,
Category3: category3,
Category4: category4,
Category5: category5,
Tax: getTax(articleType),
SellerId: sellerId,
SellerName: sellerName,
ArticleType: articleType,
Disclaimer: item.Disclaimer,
Country: country,
Outlet: outlet,
StoreId: storeId,
}
}
func getTax(articleType string) int32 {
switch articleType {
case "ZDIE":
return 600
default:
return 2500
}
}

View File

@@ -15,7 +15,7 @@ metadata:
arch: amd64
name: cart-actor-x86
spec:
replicas: 0
replicas: 3
selector:
matchLabels:
app: cart-actor
@@ -113,7 +113,7 @@ metadata:
arch: arm64
name: cart-actor-arm64
spec:
replicas: 3
replicas: 0
selector:
matchLabels:
app: cart-actor
@@ -228,10 +228,10 @@ metadata:
name: cart-ingress
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
# nginx.ingress.kubernetes.io/affinity: "cookie"
# nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity"
# nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
# nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
nginx.ingress.kubernetes.io/affinity: "cookie"
nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity"
nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
nginx.ingress.kubernetes.io/proxy-body-size: 4m
spec:
ingressClassName: nginx

View File

@@ -1,84 +0,0 @@
package main
import (
"fmt"
"log"
"net"
"sync"
"time"
)
type DiscardedHost struct {
Host string
Tries int
}
type DiscardedHostHandler struct {
mu sync.RWMutex
port int
hosts []*DiscardedHost
onConnection *func(string)
}
func (d *DiscardedHostHandler) run() {
for range time.Tick(time.Second) {
d.mu.RLock()
lst := make([]*DiscardedHost, 0, len(d.hosts))
for _, host := range d.hosts {
if host.Tries >= 0 && host.Tries < 5 {
go d.testConnection(host)
lst = append(lst, host)
} else {
if host.Tries > 0 {
log.Printf("Host %s discarded after %d tries", host.Host, host.Tries)
}
}
}
d.mu.RUnlock()
d.mu.Lock()
d.hosts = lst
d.mu.Unlock()
}
}
func (d *DiscardedHostHandler) testConnection(host *DiscardedHost) {
addr := fmt.Sprintf("%s:%d", host.Host, d.port)
conn, err := net.Dial("tcp", addr)
if err != nil {
host.Tries++
if host.Tries >= 5 {
// Exceeded retry threshold; will be dropped by run loop.
}
} else {
conn.Close()
if d.onConnection != nil {
fn := *d.onConnection
fn(host.Host)
}
}
}
func NewDiscardedHostHandler(port int) *DiscardedHostHandler {
ret := &DiscardedHostHandler{
hosts: make([]*DiscardedHost, 0),
port: port,
}
go ret.run()
return ret
}
func (d *DiscardedHostHandler) SetReconnectHandler(fn func(string)) {
d.onConnection = &fn
}
func (d *DiscardedHostHandler) AppendHost(host string) {
d.mu.Lock()
defer d.mu.Unlock()
log.Printf("Adding host %s to retry list", host)
d.hosts = append(d.hosts, &DiscardedHost{
Host: host,
Tries: 0,
})
}

View File

@@ -1,70 +0,0 @@
package main
import (
"encoding/gob"
"fmt"
"os"
"time"
)
type DiskStorage struct {
stateFile string
lastSave int64
LastSaves map[CartId]int64
}
func NewDiskStorage(stateFile string) (*DiskStorage, error) {
ret := &DiskStorage{
stateFile: stateFile,
LastSaves: make(map[CartId]int64),
}
err := ret.loadState()
return ret, err
}
func saveMessages(_ interface{}, _ CartId) error {
// No-op: legacy event log persistence removed in oneof refactor.
return nil
}
func getCartPath(id string) string {
return fmt.Sprintf("data/%s.prot", id)
}
func loadMessages(_ Grain, _ CartId) error {
// No-op: legacy replay removed in oneof refactor.
return nil
}
func (s *DiskStorage) saveState() error {
tmpFile := s.stateFile + "_tmp"
file, err := os.Create(tmpFile)
if err != nil {
return err
}
defer file.Close()
err = gob.NewEncoder(file).Encode(s.LastSaves)
if err != nil {
return err
}
os.Remove(s.stateFile + ".bak")
os.Rename(s.stateFile, s.stateFile+".bak")
return os.Rename(tmpFile, s.stateFile)
}
func (s *DiskStorage) loadState() error {
file, err := os.Open(s.stateFile)
if err != nil {
return err
}
defer file.Close()
return gob.NewDecoder(file).Decode(&s.LastSaves)
}
func (s *DiskStorage) Store(id CartId, _ *CartGrain) error {
// With the removal of the legacy message log, we only update the timestamp.
ts := time.Now().Unix()
s.LastSaves[id] = ts
s.lastSave = ts
return nil
}

21
go.mod
View File

@@ -3,6 +3,7 @@ module git.tornberg.me/go-cart-actor
go 1.25.1
require (
github.com/gogo/protobuf v1.3.2
github.com/google/uuid v1.6.0
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548
github.com/prometheus/client_golang v1.23.2
@@ -20,8 +21,10 @@ require (
github.com/bits-and-blooms/bitset v1.24.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/getkin/kin-openapi v0.132.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-openapi/jsonpointer v0.22.1 // indirect
github.com/go-openapi/jsonreference v0.21.2 // indirect
@@ -37,32 +40,46 @@ require (
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/gorilla/schema v1.4.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 // indirect
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.1 // indirect
github.com/prometheus/procfs v0.17.0 // indirect
github.com/speakeasy-api/jsonpath v0.6.0 // indirect
github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.28.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/oauth2 v0.32.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.37.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
@@ -71,3 +88,5 @@ require (
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen

118
go.sum
View File

@@ -7,14 +7,25 @@ github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJiz
github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -49,35 +60,62 @@ github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548 h1:lkxVz5lNPlU78El49cx1c3Rxo/bp7Gbzp2BjSRFgg1U=
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548/go.mod h1:k4lo5gFYb3AqrgftlraCnv95xvjB/w8udRqJQJ12mAE=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -86,14 +124,38 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 h1:iJvF8SdB/3/+eGOXEpsWkD8FQAHj6mqkb6Fnsoc8MFU=
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0/go.mod h1:fwlMxUEMuQK5ih9aymrxKPQqNm2n8bdLk1ppjH+lr9w=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
@@ -108,15 +170,27 @@ github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzuk
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8=
github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw=
github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU=
github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -144,26 +218,50 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
@@ -171,6 +269,7 @@ golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
@@ -184,15 +283,34 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff h1:
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

327
grafana_dashboard_cart.json Normal file
View File

@@ -0,0 +1,327 @@
{
"uid": "cart-actors",
"title": "Cart Actor Cluster",
"timezone": "browser",
"refresh": "30s",
"schemaVersion": 38,
"version": 1,
"editable": true,
"graphTooltip": 0,
"panels": [
{
"type": "row",
"title": "Overview",
"gridPos": { "x": 0, "y": 0, "w": 24, "h": 1 },
"id": 1,
"collapsed": false
},
{
"type": "stat",
"title": "Active Grains",
"id": 2,
"gridPos": { "x": 0, "y": 1, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "cart_active_grains" }
],
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"type": "stat",
"title": "Grains In Pool",
"id": 3,
"gridPos": { "x": 6, "y": 1, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "cart_grains_in_pool" }
],
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
}
},
{
"type": "stat",
"title": "Pool Usage %",
"id": 4,
"gridPos": { "x": 12, "y": 1, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "cart_grain_pool_usage * 100" }
],
"units": "percent",
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"] }
}
},
{
"type": "stat",
"title": "Connected Remotes",
"id": 5,
"gridPos": { "x": 18, "y": 1, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "connected_remotes" }
],
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"] }
}
},
{
"type": "row",
"title": "Mutations",
"gridPos": { "x": 0, "y": 5, "w": 24, "h": 1 },
"id": 6,
"collapsed": false
},
{
"type": "timeseries",
"title": "Mutation Rate (1m)",
"id": 7,
"gridPos": { "x": 0, "y": 6, "w": 12, "h": 8 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "rate(cart_mutations_total[1m])", "legendFormat": "mutations/s" },
{ "refId": "B", "expr": "rate(cart_mutation_failures_total[1m])", "legendFormat": "failures/s" }
],
"fieldConfig": { "defaults": { "unit": "ops" } }
},
{
"type": "stat",
"title": "Failure % (5m)",
"id": 8,
"gridPos": { "x": 12, "y": 6, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{
"refId": "A",
"expr": "100 * (increase(cart_mutation_failures_total[5m]) / clamp_max(increase(cart_mutations_total[5m]), 1))"
}
],
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "center",
"reduceOptions": { "calcs": ["lastNotNull"] }
}
},
{
"type": "timeseries",
"title": "Mutation Latency Quantiles",
"id": 9,
"gridPos": { "x": 18, "y": 6, "w": 6, "h": 8 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.50, sum(rate(cart_mutation_latency_seconds_bucket[5m])) by (le))",
"legendFormat": "p50"
},
{
"refId": "B",
"expr": "histogram_quantile(0.90, sum(rate(cart_mutation_latency_seconds_bucket[5m])) by (le))",
"legendFormat": "p90"
},
{
"refId": "C",
"expr": "histogram_quantile(0.99, sum(rate(cart_mutation_latency_seconds_bucket[5m])) by (le))",
"legendFormat": "p99"
}
],
"fieldConfig": { "defaults": { "unit": "s" } }
},
{
"type": "row",
"title": "Event Log",
"gridPos": { "x": 0, "y": 14, "w": 24, "h": 1 },
"id": 10,
"collapsed": false
},
{
"type": "timeseries",
"title": "Event Append Rate (5m)",
"id": 11,
"gridPos": { "x": 0, "y": 15, "w": 8, "h": 6 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "rate(cart_event_log_appends_total[5m])", "legendFormat": "appends/s" }
]
},
{
"type": "timeseries",
"title": "Event Bytes Written Rate (5m)",
"id": 12,
"gridPos": { "x": 8, "y": 15, "w": 8, "h": 6 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "rate(cart_event_log_bytes_written_total[5m])", "legendFormat": "bytes/s" }
],
"fieldConfig": { "defaults": { "unit": "Bps" } }
},
{
"type": "stat",
"title": "Existing Log Files",
"id": 13,
"gridPos": { "x": 16, "y": 15, "w": 4, "h": 3 },
"datasource": "${DS_PROMETHEUS}",
"targets": [{ "refId": "A", "expr": "cart_event_log_files_existing" }],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat",
"title": "Last Append Age (s)",
"id": 14,
"gridPos": { "x": 20, "y": 15, "w": 4, "h": 3 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "(time() - cart_event_log_last_append_unix)" }
],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat",
"title": "Replay Failures Total",
"id": 15,
"gridPos": { "x": 16, "y": 18, "w": 4, "h": 3 },
"datasource": "${DS_PROMETHEUS}",
"targets": [{ "refId": "A", "expr": "cart_event_log_replay_failures_total" }],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat",
"title": "Replay Duration p95 (5m)",
"id": 16,
"gridPos": { "x": 20, "y": 18, "w": 4, "h": 3 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.95, sum(rate(cart_event_log_replay_duration_seconds_bucket[5m])) by (le))"
}
],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "fieldConfig": { "defaults": { "unit": "s" } } }
},
{
"type": "row",
"title": "Grain Lifecycle",
"gridPos": { "x": 0, "y": 21, "w": 24, "h": 1 },
"id": 17,
"collapsed": false
},
{
"type": "timeseries",
"title": "Spawn & Lookup Rates (1m)",
"id": 18,
"gridPos": { "x": 0, "y": 22, "w": 12, "h": 8 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "rate(cart_grain_spawned_total[1m])", "legendFormat": "spawns/s" },
{ "refId": "B", "expr": "rate(cart_grain_lookups_total[1m])", "legendFormat": "lookups/s" }
]
},
{
"type": "stat",
"title": "Negotiations Rate (5m)",
"id": 19,
"gridPos": { "x": 12, "y": 22, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{ "refId": "A", "expr": "rate(cart_remote_negotiation_total[5m])" }
],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "orientation": "horizontal" }
},
{
"type": "stat",
"title": "Mutations Total",
"id": 20,
"gridPos": { "x": 18, "y": 22, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [{ "refId": "A", "expr": "cart_mutations_total" }],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "row",
"title": "Event Log Errors",
"gridPos": { "x": 0, "y": 30, "w": 24, "h": 1 },
"id": 21,
"collapsed": false
},
{
"type": "stat",
"title": "Unknown Event Types",
"id": 22,
"gridPos": { "x": 0, "y": 31, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [{ "refId": "A", "expr": "cart_event_log_unknown_types_total" }],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat",
"title": "Event Mutation Errors",
"id": 23,
"gridPos": { "x": 6, "y": 31, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [{ "refId": "A", "expr": "cart_event_log_mutation_errors_total" }],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat",
"title": "Replay Success Total",
"id": 24,
"gridPos": { "x": 12, "y": 31, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [{ "refId": "A", "expr": "cart_event_log_replay_total" }],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
},
{
"type": "stat",
"title": "Replay Duration p50 (5m)",
"id": 25,
"gridPos": { "x": 18, "y": 31, "w": 6, "h": 4 },
"datasource": "${DS_PROMETHEUS}",
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.50, sum(rate(cart_event_log_replay_duration_seconds_bucket[5m])) by (le))"
}
],
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "fieldConfig": { "defaults": { "unit": "s" } } }
}
],
"templating": {
"list": [
{
"name": "DS_PROMETHEUS",
"label": "Prometheus",
"type": "datasource",
"query": "prometheus",
"current": { "text": "Prometheus", "value": "Prometheus" }
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {
"refresh_intervals": ["5s","10s","30s","1m","5m","15m","30m","1h"],
"time_options": ["5m","15m","30m","1h","6h","12h","24h","2d","7d"]
}
}

View File

@@ -1,244 +0,0 @@
package main
import (
"fmt"
"log"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
// grain-pool.go
//
// Migration Note:
// This file has been migrated to use uint64 cart keys internally (derived
// from the new CartID base62 representation). For backward compatibility,
// a deprecated legacy map keyed by CartId is maintained so existing code
// that directly indexes pool.grains with a CartId continues to compile
// until the full refactor across SyncedPool / remoteIndex is completed.
//
// Authoritative storage: grains (map[uint64]*CartGrain)
// Legacy compatibility: grainsLegacy (map[CartId]*CartGrain) - kept in sync.
//
// Once all external usages are updated to rely on helper accessors,
// grainsLegacy can be removed.
//
// ---------------------------------------------------------------------------
var (
poolGrains = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_grains_in_pool",
Help: "The total number of grains in the pool",
})
poolSize = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_pool_size",
Help: "The total number of mutations",
})
poolUsage = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_grain_pool_usage",
Help: "The current usage of the grain pool",
})
)
// GrainPool interface remains legacy-compatible.
type GrainPool interface {
Apply(id CartId, mutation interface{}) (*CartGrain, error)
Get(id CartId) (*CartGrain, error)
}
// Ttl keeps expiry info
type Ttl struct {
Expires time.Time
Grain *CartGrain
}
// GrainLocalPool now stores grains keyed by uint64 (CartKey).
type GrainLocalPool struct {
mu sync.RWMutex
grains map[uint64]*CartGrain // authoritative only
expiry []Ttl
spawn func(id CartId) (*CartGrain, error)
Ttl time.Duration
PoolSize int
}
// NewGrainLocalPool constructs a new pool.
func NewGrainLocalPool(size int, ttl time.Duration, spawn func(id CartId) (*CartGrain, error)) *GrainLocalPool {
ret := &GrainLocalPool{
spawn: spawn,
grains: make(map[uint64]*CartGrain),
expiry: make([]Ttl, 0),
Ttl: ttl,
PoolSize: size,
}
cartPurge := time.NewTicker(time.Minute)
go func() {
for range cartPurge.C {
ret.Purge()
}
}()
return ret
}
// keyFromCartId derives the uint64 key from a legacy CartId deterministically.
func keyFromCartId(id CartId) uint64 {
return LegacyToCartKey(id)
}
// storeGrain indexes a grain in both maps.
func (p *GrainLocalPool) storeGrain(id CartId, g *CartGrain) {
k := keyFromCartId(id)
p.grains[k] = g
}
// deleteGrain removes a grain from both maps.
func (p *GrainLocalPool) deleteGrain(id CartId) {
k := keyFromCartId(id)
delete(p.grains, k)
}
// SetAvailable pre-populates placeholder entries (legacy signature).
func (p *GrainLocalPool) SetAvailable(availableWithLastChangeUnix map[CartId]int64) {
p.mu.Lock()
defer p.mu.Unlock()
for id := range availableWithLastChangeUnix {
k := keyFromCartId(id)
if _, ok := p.grains[k]; !ok {
p.grains[k] = nil
p.expiry = append(p.expiry, Ttl{
Expires: time.Now().Add(p.Ttl),
Grain: nil,
})
}
}
}
// Purge removes expired grains.
func (p *GrainLocalPool) Purge() {
lastChangeTime := time.Now().Add(-p.Ttl)
keepChanged := lastChangeTime.Unix()
p.mu.Lock()
defer p.mu.Unlock()
for i := 0; i < len(p.expiry); i++ {
item := p.expiry[i]
if item.Grain == nil {
continue
}
if item.Expires.Before(time.Now()) {
if item.Grain.GetLastChange() > keepChanged {
log.Printf("Expired item %s changed, keeping", item.Grain.GetId())
if i < len(p.expiry)-1 {
p.expiry = append(p.expiry[:i], p.expiry[i+1:]...)
p.expiry = append(p.expiry, item)
} else {
// move last to end (noop)
p.expiry = append(p.expiry[:i], item)
}
} else {
log.Printf("Item %s expired", item.Grain.GetId())
p.deleteGrain(item.Grain.GetId())
if i < len(p.expiry)-1 {
p.expiry = append(p.expiry[:i], p.expiry[i+1:]...)
} else {
p.expiry = p.expiry[:i]
}
}
} else {
break
}
}
}
// GetGrains returns a legacy view of grains (copy) for compatibility.
func (p *GrainLocalPool) GetGrains() map[CartId]*CartGrain {
p.mu.RLock()
defer p.mu.RUnlock()
out := make(map[CartId]*CartGrain, len(p.grains))
for _, g := range p.grains {
if g != nil {
out[g.GetId()] = g
}
}
return out
}
// statsUpdate updates Prometheus gauges asynchronously.
func (p *GrainLocalPool) statsUpdate() {
go func(size int) {
l := float64(size)
ps := float64(p.PoolSize)
poolUsage.Set(l / ps)
poolGrains.Set(l)
poolSize.Set(ps)
}(len(p.grains))
}
// GetGrain retrieves or spawns a grain (legacy id signature).
func (p *GrainLocalPool) GetGrain(id CartId) (*CartGrain, error) {
grainLookups.Inc()
k := keyFromCartId(id)
p.mu.RLock()
grain, ok := p.grains[k]
p.mu.RUnlock()
var err error
if grain == nil || !ok {
p.mu.Lock()
// Re-check under write lock
grain, ok = p.grains[k]
if grain == nil || !ok {
// Capacity check
if len(p.grains) >= p.PoolSize && len(p.expiry) > 0 {
if p.expiry[0].Expires.Before(time.Now()) && p.expiry[0].Grain != nil {
oldId := p.expiry[0].Grain.GetId()
p.deleteGrain(oldId)
p.expiry = p.expiry[1:]
} else {
p.mu.Unlock()
return nil, fmt.Errorf("pool is full")
}
}
grain, err = p.spawn(id)
if err == nil {
p.storeGrain(id, grain)
}
}
p.mu.Unlock()
p.statsUpdate()
}
return grain, err
}
// Apply applies a mutation (legacy compatibility).
func (p *GrainLocalPool) Apply(id CartId, mutation interface{}) (*CartGrain, error) {
grain, err := p.GetGrain(id)
if err != nil || grain == nil {
return nil, err
}
return grain.Apply(mutation, false)
}
// Get returns current state (legacy wrapper).
func (p *GrainLocalPool) Get(id CartId) (*CartGrain, error) {
return p.GetGrain(id)
}
// DebugGrainCount returns counts for debugging.
func (p *GrainLocalPool) DebugGrainCount() (authoritative int) {
p.mu.RLock()
defer p.mu.RUnlock()
return len(p.grains)
}
// UnsafePointerToLegacyMap exposes the legacy map pointer (for transitional
// tests that still poke the field directly). DO NOT rely on this long-term.
func (p *GrainLocalPool) UnsafePointerToLegacyMap() uintptr {
// Legacy map removed; retained only to satisfy any transitional callers.
return 0
}

View File

@@ -1,115 +0,0 @@
package main
import (
"context"
"fmt"
"testing"
"time"
messages "git.tornberg.me/go-cart-actor/proto"
"google.golang.org/grpc"
)
// TestCartActorMutationAndState validates end-to-end gRPC mutation + state retrieval
// against a locally started gRPC server (single-node scenario).
// This test uses the new per-mutation AddItem RPC (breaking v2 API) to avoid external product fetch logic
// fetching logic (FetchItem) which would require network I/O.
func TestCartActorMutationAndState(t *testing.T) {
// Setup local grain pool + synced pool (no discovery, single host)
pool := NewGrainLocalPool(1024, time.Minute, spawn)
synced, err := NewSyncedPool(pool, "127.0.0.1", nil)
if err != nil {
t.Fatalf("NewSyncedPool error: %v", err)
}
// Start gRPC server (CartActor + ControlPlane) on :1337
grpcSrv, err := StartGRPCServer(":1337", pool, synced)
if err != nil {
t.Fatalf("StartGRPCServer error: %v", err)
}
defer grpcSrv.GracefulStop()
// Dial the local server
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
conn, err := grpc.DialContext(ctx, "127.0.0.1:1337",
grpc.WithInsecure(),
grpc.WithBlock(),
)
if err != nil {
t.Fatalf("grpc.Dial error: %v", err)
}
defer conn.Close()
cartClient := messages.NewCartActorClient(conn)
// Create a short cart id (<=16 chars so it fits into the fixed CartId 16-byte array cleanly)
cartID := fmt.Sprintf("cart-%d", time.Now().UnixNano())
// Build an AddItem payload (bypasses FetchItem to keep test deterministic)
addItem := &messages.AddItem{
ItemId: 1,
Quantity: 1,
Price: 1000,
OrgPrice: 1000,
Sku: "test-sku",
Name: "Test SKU",
Image: "/img.png",
Stock: 2, // InStock
Tax: 2500,
Country: "se",
}
// Issue AddItem RPC directly (breaking v2 API)
addResp, err := cartClient.AddItem(context.Background(), &messages.AddItemRequest{
CartId: cartID,
ClientTimestamp: time.Now().Unix(),
Payload: addItem,
})
if err != nil {
t.Fatalf("AddItem RPC error: %v", err)
}
if addResp.StatusCode != 200 {
t.Fatalf("AddItem returned non-200 status: %d, error: %s", addResp.StatusCode, addResp.GetError())
}
// Validate the response state (from AddItem)
state := addResp.GetState()
if state == nil {
t.Fatalf("AddItem response state is nil")
}
// (Removed obsolete Mutate response handling)
if len(state.Items) != 1 {
t.Fatalf("Expected 1 item after AddItem, got %d", len(state.Items))
}
if state.Items[0].Sku != "test-sku" {
t.Fatalf("Unexpected item SKU: %s", state.Items[0].Sku)
}
// Issue GetState RPC
getResp, err := cartClient.GetState(context.Background(), &messages.StateRequest{
CartId: cartID,
})
if err != nil {
t.Fatalf("GetState RPC error: %v", err)
}
if getResp.StatusCode != 200 {
t.Fatalf("GetState returned non-200 status: %d, error: %s", getResp.StatusCode, getResp.GetError())
}
state2 := getResp.GetState()
if state2 == nil {
t.Fatalf("GetState response state is nil")
}
if len(state2.Items) != 1 {
t.Fatalf("Expected 1 item in GetState, got %d", len(state2.Items))
}
if state2.Items[0].Sku != "test-sku" {
t.Fatalf("Unexpected SKU in GetState: %s", state2.Items[0].Sku)
}
}
// Legacy serialization helper removed (oneof envelope used directly)

View File

@@ -1,280 +0,0 @@
package main
import (
"context"
"fmt"
"log"
"net"
"time"
messages "git.tornberg.me/go-cart-actor/proto"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
)
// cartActorGRPCServer implements the CartActor and ControlPlane gRPC services.
// It delegates cart operations to a grain pool and cluster operations to a synced pool.
type cartActorGRPCServer struct {
messages.UnimplementedCartActorServer
messages.UnimplementedControlPlaneServer
pool GrainPool // For cart state mutations and queries
syncedPool *SyncedPool // For cluster membership and control
}
// NewCartActorGRPCServer creates and initializes the server.
func NewCartActorGRPCServer(pool GrainPool, syncedPool *SyncedPool) *cartActorGRPCServer {
return &cartActorGRPCServer{
pool: pool,
syncedPool: syncedPool,
}
}
// applyMutation routes a single cart mutation to the target grain (used by per-mutation RPC handlers).
func (s *cartActorGRPCServer) applyMutation(cartID string, mutation interface{}) *messages.CartMutationReply {
// Canonicalize or preserve legacy id (do NOT hash-rewrite legacy textual ids)
cid, _, wasBase62, cerr := CanonicalizeOrLegacy(cartID)
if cerr != nil {
return &messages.CartMutationReply{
StatusCode: 500,
Result: &messages.CartMutationReply_Error{Error: fmt.Sprintf("cart_id canonicalization failed: %v", cerr)},
ServerTimestamp: time.Now().Unix(),
}
}
_ = wasBase62 // placeholder; future: propagate canonical id in reply metadata
legacy := CartIDToLegacy(cid)
grain, err := s.pool.Apply(legacy, mutation)
if err != nil {
return &messages.CartMutationReply{
StatusCode: 500,
Result: &messages.CartMutationReply_Error{Error: err.Error()},
ServerTimestamp: time.Now().Unix(),
}
}
cartState := ToCartState(grain)
return &messages.CartMutationReply{
StatusCode: 200,
Result: &messages.CartMutationReply_State{State: cartState},
ServerTimestamp: time.Now().Unix(),
}
}
func (s *cartActorGRPCServer) AddRequest(ctx context.Context, req *messages.AddRequestRequest) (*messages.CartMutationReply, error) {
if req.GetCartId() == "" {
return &messages.CartMutationReply{
StatusCode: 400,
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
ServerTimestamp: time.Now().Unix(),
}, nil
}
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
}
func (s *cartActorGRPCServer) AddItem(ctx context.Context, req *messages.AddItemRequest) (*messages.CartMutationReply, error) {
if req.GetCartId() == "" {
return &messages.CartMutationReply{
StatusCode: 400,
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
ServerTimestamp: time.Now().Unix(),
}, nil
}
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
}
func (s *cartActorGRPCServer) RemoveItem(ctx context.Context, req *messages.RemoveItemRequest) (*messages.CartMutationReply, error) {
if req.GetCartId() == "" {
return &messages.CartMutationReply{
StatusCode: 400,
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
ServerTimestamp: time.Now().Unix(),
}, nil
}
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
}
func (s *cartActorGRPCServer) RemoveDelivery(ctx context.Context, req *messages.RemoveDeliveryRequest) (*messages.CartMutationReply, error) {
if req.GetCartId() == "" {
return &messages.CartMutationReply{
StatusCode: 400,
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
ServerTimestamp: time.Now().Unix(),
}, nil
}
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
}
func (s *cartActorGRPCServer) ChangeQuantity(ctx context.Context, req *messages.ChangeQuantityRequest) (*messages.CartMutationReply, error) {
if req.GetCartId() == "" {
return &messages.CartMutationReply{
StatusCode: 400,
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
ServerTimestamp: time.Now().Unix(),
}, nil
}
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
}
func (s *cartActorGRPCServer) SetDelivery(ctx context.Context, req *messages.SetDeliveryRequest) (*messages.CartMutationReply, error) {
if req.GetCartId() == "" {
return &messages.CartMutationReply{
StatusCode: 400,
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
ServerTimestamp: time.Now().Unix(),
}, nil
}
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
}
func (s *cartActorGRPCServer) SetPickupPoint(ctx context.Context, req *messages.SetPickupPointRequest) (*messages.CartMutationReply, error) {
if req.GetCartId() == "" {
return &messages.CartMutationReply{
StatusCode: 400,
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
ServerTimestamp: time.Now().Unix(),
}, nil
}
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
}
/*
Checkout RPC removed. Checkout is handled at the HTTP layer (PoolServer.HandleCheckout).
*/
func (s *cartActorGRPCServer) SetCartItems(ctx context.Context, req *messages.SetCartItemsRequest) (*messages.CartMutationReply, error) {
if req.GetCartId() == "" {
return &messages.CartMutationReply{
StatusCode: 400,
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
ServerTimestamp: time.Now().Unix(),
}, nil
}
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
}
func (s *cartActorGRPCServer) OrderCompleted(ctx context.Context, req *messages.OrderCompletedRequest) (*messages.CartMutationReply, error) {
if req.GetCartId() == "" {
return &messages.CartMutationReply{
StatusCode: 400,
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
ServerTimestamp: time.Now().Unix(),
}, nil
}
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
}
// GetState retrieves the current state of a cart grain.
func (s *cartActorGRPCServer) GetState(ctx context.Context, req *messages.StateRequest) (*messages.StateReply, error) {
if req.GetCartId() == "" {
return &messages.StateReply{
StatusCode: 400,
Result: &messages.StateReply_Error{Error: "cart_id is required"},
}, nil
}
// Canonicalize / upgrade incoming cart id (preserve legacy strings)
cid, _, _, cerr := CanonicalizeOrLegacy(req.GetCartId())
if cerr != nil {
return &messages.StateReply{
StatusCode: 500,
Result: &messages.StateReply_Error{Error: fmt.Sprintf("cart_id canonicalization failed: %v", cerr)},
}, nil
}
legacy := CartIDToLegacy(cid)
grain, err := s.pool.Get(legacy)
if err != nil {
return &messages.StateReply{
StatusCode: 500,
Result: &messages.StateReply_Error{Error: err.Error()},
}, nil
}
cartState := ToCartState(grain)
return &messages.StateReply{
StatusCode: 200,
Result: &messages.StateReply_State{State: cartState},
}, nil
}
// ControlPlane: Ping
func (s *cartActorGRPCServer) Ping(ctx context.Context, _ *messages.Empty) (*messages.PingReply, error) {
return &messages.PingReply{
Host: s.syncedPool.Hostname,
UnixTime: time.Now().Unix(),
}, nil
}
// ControlPlane: Negotiate (merge host views)
func (s *cartActorGRPCServer) Negotiate(ctx context.Context, req *messages.NegotiateRequest) (*messages.NegotiateReply, error) {
hostSet := make(map[string]struct{})
// Caller view
for _, h := range req.GetKnownHosts() {
if h != "" {
hostSet[h] = struct{}{}
}
}
// This host
hostSet[s.syncedPool.Hostname] = struct{}{}
// Known remotes
s.syncedPool.mu.RLock()
for h := range s.syncedPool.remoteHosts {
hostSet[h] = struct{}{}
}
s.syncedPool.mu.RUnlock()
out := make([]string, 0, len(hostSet))
for h := range hostSet {
out = append(out, h)
}
return &messages.NegotiateReply{Hosts: out}, nil
}
// ControlPlane: GetCartIds (locally owned carts only)
func (s *cartActorGRPCServer) GetCartIds(ctx context.Context, _ *messages.Empty) (*messages.CartIdsReply, error) {
s.syncedPool.local.mu.RLock()
ids := make([]string, 0, len(s.syncedPool.local.grains))
for _, g := range s.syncedPool.local.grains {
if g == nil {
continue
}
ids = append(ids, g.GetId().String())
}
s.syncedPool.local.mu.RUnlock()
return &messages.CartIdsReply{CartIds: ids}, nil
}
// ControlPlane: Closing (peer shutdown notification)
func (s *cartActorGRPCServer) Closing(ctx context.Context, req *messages.ClosingNotice) (*messages.OwnerChangeAck, error) {
if req.GetHost() != "" {
s.syncedPool.RemoveHost(req.GetHost())
}
return &messages.OwnerChangeAck{
Accepted: true,
Message: "removed host",
}, nil
}
// StartGRPCServer configures and starts the unified gRPC server on the given address.
// It registers both the CartActor and ControlPlane services.
func StartGRPCServer(addr string, pool GrainPool, syncedPool *SyncedPool) (*grpc.Server, error) {
lis, err := net.Listen("tcp", addr)
if err != nil {
return nil, fmt.Errorf("failed to listen: %w", err)
}
grpcServer := grpc.NewServer()
server := NewCartActorGRPCServer(pool, syncedPool)
messages.RegisterCartActorServer(grpcServer, server)
messages.RegisterControlPlaneServer(grpcServer, server)
reflection.Register(grpcServer)
log.Printf("gRPC server listening on %s", addr)
go func() {
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("failed to serve gRPC: %v", err)
}
}()
return grpcServer, nil
}

174
k6/README.md Normal file
View File

@@ -0,0 +1,174 @@
# k6 Load Tests for Cart API
This directory contains a k6 script (`cart_load_test.js`) to stress and observe the cart actor HTTP API.
## Contents
- `cart_load_test.js` primary k6 scenario script
- `README.md` this file
## Prerequisites
- Node not required (k6 runs standalone)
- k6 installed (>= v0.43 recommended)
- Prometheus + Grafana (optional) if you want to correlate with the dashboard you generated
- A running cart service exposing HTTP endpoints at (default) `http://localhost:8080/cart`
## Endpoints Exercised
The script exercises (per iteration):
1. `GET /cart/` ensure / fetch cart state (creates cart if missing; sets `cartid` & `cartowner` cookies)
2. `POST /cart/` add item mutation (random SKU & quantity)
3. `GET /cart/` fetch after mutations
4. `GET /cart/checkout` occasionally (~2% of iterations) to simulate checkout start
You can extend it easily to hit deliveries, quantity changes, or removal endpoints.
## Environment Variables
| Variable | Purpose | Default |
|-----------------|----------------------------------------------|-------------------------|
| `BASE_URL` | Base URL root (either host or host/cart) | `http://localhost:8080/cart` |
| `VUS` | VUs for steady_mutations scenario | `20` |
| `DURATION` | Duration for steady_mutations scenario | `5m` |
| `RAMP_TARGET` | Peak VUs for ramp_up scenario | `50` |
You can also disable one scenario by editing `options.scenarios` inside the script.
Example run:
```bash
k6 run \
-e BASE_URL=https://cart.prod.example.com/cart \
-e VUS=40 \
-e DURATION=10m \
-e RAMP_TARGET=120 \
k6/cart_load_test.js
```
## Metrics (Custom)
The script defines additional k6 metrics:
- `cart_add_item_duration` (Trend) latency of POST add item
- `cart_fetch_duration` (Trend) latency of GET cart state
- `cart_checkout_duration` (Trend) latency of checkout
- `cart_items_added` (Counter) successful add item operations
- `cart_checkout_calls` (Counter) successful checkout calls
Thresholds (in `options.thresholds`) enforce basic SLO:
- Mutation failure rate < 2%
- p90 mutation latency < 800 ms
- p99 overall HTTP latency < 1500 ms
Adjust thresholds to your environment if they trigger prematurely.
## Cookies & Stickiness
The script preserves:
- `cartid` cart identity (server sets expiry separately)
- `cartowner` owning host for sticky routing
If your load balancer or ingress enforces affinity based on these cookies, traffic will naturally concentrate on the originally claimed host for each cart under test.
## SKU Set
SKUs used (randomly selected each mutation):
```
778290 778345 778317 778277 778267 778376 778244 778384
778365 778377 778255 778286 778246 778270 778266 778285
778329 778425 778407 778418 778430 778469 778358 778351
778319 778307 778278 778251 778253 778261 778263 778273
778281 778294 778297 778302
```
To add/remove SKUs, edit the `SKUS` array. Keeping it non-empty and moderately sized helps randomization.
## Extending the Script
### Add Quantity Change
```js
function changeQuantity(itemId, newQty) {
const payload = JSON.stringify({ Id: itemId, Qty: newQty });
http.put(baseUrl() + '/', payload, { headers: headers() });
}
```
### Remove Item
```js
function removeItem(itemId) {
http.del(baseUrl() + '/' + itemId, null, { headers: headers() });
}
```
### Add Delivery
```js
function addDelivery(itemIds) {
const payload = JSON.stringify({ provider: "POSTNORD", items: itemIds });
http.post(baseUrl() + '/delivery', payload, { headers: headers() });
}
```
You can integrate these into the iteration loop with probabilities.
## Output Summary
`handleSummary` outputs a JSON summary to stdout:
- Average & p95 mutation latencies (if present)
- Fetch p95
- Checkout count
- Check statuses
Redirect or parse that output for CI pipelines.
## Running in CI
Use shorter durations (e.g. `DURATION=2m VUS=10`) to keep builds fast. Fail build on threshold breaches:
```bash
k6 run -e BASE_URL=$TARGET -e VUS=10 -e DURATION=2m k6/cart_load_test.js || exit 1
```
## Correlating with Prometheus / Grafana
During load, observe:
- `cart_mutations_total` growth and latency histograms
- Event log write rate (`cart_event_log_appends_total`)
- Pool usage (`cart_grain_pool_usage`) and spawn rate (`cart_grain_spawned_total`)
- Failure counters (`cart_mutation_failures_total`) ensure they remain low
If mutation latency spikes without high error rate, inspect external dependencies (e.g., product fetcher or Klarna endpoints).
## Common Tuning Tips
| Symptom | Potential Adjustment |
|------------------------------------|---------------------------------------------------|
| High latency p99 | Increase CPU/memory, optimize mutation handlers |
| Pool at capacity | Raise pool size argument or TTL |
| Frequent cart eviction mid-test | Confirm TTL is sliding (now 2h on mutation) |
| High replay duration | Consider snapshot + truncate event logs |
| Uneven host load | Verify `cartowner` cookie is respected upstream |
## Safety / Load Guardrails
- Start with low VUs (510) and short duration.
- Scale incrementally to find saturation points.
- If using production endpoints, coordinate off-peak runs.
## License / Attribution
This test script is tailored for your internal cart actor system; adapt freely. k6 is open-source (AGPL v3). Ensure compliance if redistributing.
---
Feel free to request:
- A variant script for spike tests
- WebSocket / long poll integration (if added later)
- Synthetic error injection harness
Happy load testing!

248
k6/cart_load_test.js Normal file
View File

@@ -0,0 +1,248 @@
import http from "k6/http";
import { check, sleep, group } from "k6";
import { Counter, Trend } from "k6/metrics";
// ---------------- Configuration ----------------
export const options = {
// Adjust vus/duration for your environment
scenarios: {
steady_mutations: {
executor: "constant-vus",
vus: __ENV.VUS ? parseInt(__ENV.VUS, 10) : 20,
duration: __ENV.DURATION || "5m",
gracefulStop: "30s",
},
ramp_up: {
executor: "ramping-vus",
startVUs: 0,
stages: [
{
duration: "1m",
target: __ENV.RAMP_TARGET
? parseInt(__ENV.RAMP_TARGET, 10)
: 50,
},
{
duration: "1m",
target: __ENV.RAMP_TARGET
? parseInt(__ENV.RAMP_TARGET, 10)
: 50,
},
{ duration: "1m", target: 0 },
],
gracefulStop: "30s",
startTime: "30s",
},
},
thresholds: {
http_req_failed: ["rate<0.02"], // < 2% failures
http_req_duration: ["p(90)<800", "p(99)<1500"], // latency SLO
"cart_add_item_duration{op:add}": ["p(90)<800"],
"cart_fetch_duration{op:get}": ["p(90)<600"],
},
summaryTrendStats: ["avg", "min", "med", "max", "p(90)", "p(95)", "p(99)"],
};
// ---------------- Metrics ----------------
const addItemTrend = new Trend("cart_add_item_duration", true);
const fetchTrend = new Trend("cart_fetch_duration", true);
const checkoutTrend = new Trend("cart_checkout_duration", true);
const addedItemsCounter = new Counter("cart_items_added");
const checkoutCounter = new Counter("cart_checkout_calls");
// ---------------- SKUs ----------------
const SKUS = [
"778290",
"778345",
"778317",
"778277",
"778267",
"778376",
"778244",
"778384",
"778365",
"778377",
"778255",
"778286",
"778246",
"778270",
"778266",
"778285",
"778329",
"778425",
"778407",
"778418",
"778430",
"778469",
"778358",
"778351",
"778319",
"778307",
"778278",
"778251",
"778253",
"778261",
"778263",
"778273",
"778281",
"778294",
"778297",
"778302",
];
// ---------------- Helpers ----------------
function randomSku() {
return SKUS[Math.floor(Math.random() * SKUS.length)];
}
function randomQty() {
return 1 + Math.floor(Math.random() * 3); // 1..3
}
function baseUrl() {
const u = __ENV.BASE_URL || "http://localhost:8080/cart";
// Allow user to pass either root host or full /cart path
return u.endsWith("/cart") ? u : u.replace(/\/+$/, "") + "/cart";
}
function extractCookie(res, name) {
const cookies = res.cookies[name];
if (!cookies || cookies.length === 0) return null;
return cookies[0].value;
}
function withCookies(headers, cookieJar) {
if (!cookieJar || Object.keys(cookieJar).length === 0) return headers;
const cookieStr = Object.entries(cookieJar)
.map(([k, v]) => `${k}=${v}`)
.join("; ");
return { ...headers, Cookie: cookieStr };
}
// Maintain cart + owner cookies per VU
let cartState = {
cartid: null,
cartowner: null,
};
// Refresh cookies from response
function updateCookies(res) {
const cid = extractCookie(res, "cartid");
if (cid) cartState.cartid = cid;
const owner = extractCookie(res, "cartowner");
if (owner) cartState.cartowner = owner;
}
// Build headers
function headers() {
const h = { "Content-Type": "application/json" };
const jar = {};
if (cartState.cartid) jar["cartid"] = cartState.cartid;
if (cartState.cartowner) jar["cartowner"] = cartState.cartowner;
return withCookies(h, jar);
}
// Ensure cart exists (GET /)
function ensureCart() {
if (cartState.cartid) return;
const res = http.get(baseUrl() + "/", { headers: headers() });
updateCookies(res);
check(res, {
"ensure cart status 200": (r) => r.status === 200,
"ensure cart has id": () => !!cartState.cartid,
});
}
// Add random item
function addRandomItem() {
const payload = JSON.stringify({
sku: randomSku(),
quantity: randomQty(),
country: "no",
});
const start = Date.now();
const res = http.post(baseUrl(), payload, { headers: headers() });
const dur = Date.now() - start;
addItemTrend.add(dur, { op: "add" });
if (res.status === 200) {
addedItemsCounter.add(1);
}
updateCookies(res);
check(res, {
"add item status ok": (r) => r.status === 200,
});
}
// Fetch cart state
function fetchCart() {
const start = Date.now();
const res = http.get(baseUrl() + "/", { headers: headers() });
const dur = Date.now() - start;
fetchTrend.add(dur, { op: "get" });
updateCookies(res);
check(res, { "fetch status ok": (r) => r.status === 200 });
}
// Occasional checkout trigger
function maybeCheckout() {
if (!cartState.cartid) return;
// // Small probability
// if (Math.random() < 0.02) {
// const start = Date.now();
// const res = http.get(baseUrl() + "/checkout", { headers: headers() });
// const dur = Date.now() - start;
// checkoutTrend.add(dur, { op: "checkout" });
// updateCookies(res);
// if (res.status === 200) checkoutCounter.add(1);
// check(res, { "checkout status ok": (r) => r.status === 200 });
// }
}
// ---------------- k6 lifecycle ----------------
export function setup() {
// Provide SKU list length for summary
return { skuCount: SKUS.length };
}
export default function (data) {
group("cart flow", () => {
// Create or reuse cart
ensureCart();
// Random number of item mutations per iteration (1..5)
const ops = 1 + Math.floor(Math.random() * 5);
for (let i = 0; i < ops; i++) {
addRandomItem();
}
// Fetch state
fetchCart();
// Optional checkout attempt
maybeCheckout();
});
// Small think time
sleep(Math.random() * 0.5);
}
export function teardown(data) {
// Optionally we could GET confirmation or clear cart cookie
// Not implemented for load purpose.
console.log(`Test complete. SKU count: ${data.skuCount}`);
}
// ---------------- Summary ----------------
export function handleSummary(data) {
return {
stdout: JSON.stringify(
{
metrics: {
mutations_avg: data.metrics.cart_add_item_duration?.avg,
mutations_p95: data.metrics.cart_add_item_duration?.p(95),
fetch_p95: data.metrics.cart_fetch_duration?.p(95),
checkout_count: data.metrics.cart_checkout_calls?.count,
},
checks: data.root_checks,
},
null,
2,
),
};
}

View File

@@ -1,182 +0,0 @@
package main
import (
"context"
"fmt"
"testing"
"time"
messages "git.tornberg.me/go-cart-actor/proto"
"google.golang.org/grpc"
)
// TestMultiNodeOwnershipNegotiation spins up two gRPC servers (nodeA, nodeB),
// manually links their SyncedPools (bypassing AddRemote's fixed port assumption),
// and verifies that only one node becomes the owner of a new cart while the
// other can still apply a mutation via the remote proxy path.
//
// NOTE:
// - We manually inject RemoteHostGRPC entries because AddRemote() hard-codes
// port 1337; to run two distinct servers concurrently we need distinct ports.
// - This test asserts single ownership consistency rather than the complete
// quorum semantics (which depend on real discovery + AddRemote).
func TestMultiNodeOwnershipNegotiation(t *testing.T) {
// Allocate distinct ports for the two nodes.
const (
addrA = "127.0.0.1:18081"
addrB = "127.0.0.1:18082"
hostA = "nodeA"
hostB = "nodeB"
)
// Create local grain pools.
poolA := NewGrainLocalPool(1024, time.Minute, spawn)
poolB := NewGrainLocalPool(1024, time.Minute, spawn)
// Create synced pools (no discovery).
syncedA, err := NewSyncedPool(poolA, hostA, nil)
if err != nil {
t.Fatalf("nodeA NewSyncedPool error: %v", err)
}
syncedB, err := NewSyncedPool(poolB, hostB, nil)
if err != nil {
t.Fatalf("nodeB NewSyncedPool error: %v", err)
}
// Start gRPC servers (CartActor + ControlPlane) on different ports.
grpcSrvA, err := StartGRPCServer(addrA, poolA, syncedA)
if err != nil {
t.Fatalf("StartGRPCServer A error: %v", err)
}
defer grpcSrvA.GracefulStop()
grpcSrvB, err := StartGRPCServer(addrB, poolB, syncedB)
if err != nil {
t.Fatalf("StartGRPCServer B error: %v", err)
}
defer grpcSrvB.GracefulStop()
// Helper to connect one pool to the other's server (manual AddRemote equivalent).
link := func(src *SyncedPool, remoteHost, remoteAddr string) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
conn, dialErr := grpc.DialContext(ctx, remoteAddr, grpc.WithInsecure(), grpc.WithBlock())
if dialErr != nil {
t.Fatalf("dial %s (%s) failed: %v", remoteHost, remoteAddr, dialErr)
}
cartClient := messages.NewCartActorClient(conn)
controlClient := messages.NewControlPlaneClient(conn)
src.mu.Lock()
src.remoteHosts[remoteHost] = &RemoteHostGRPC{
Host: remoteHost,
Conn: conn,
CartClient: cartClient,
ControlClient: controlClient,
}
src.mu.Unlock()
}
// Cross-link the two pools.
link(syncedA, hostB, addrB)
link(syncedB, hostA, addrA)
// Rebuild rings after manual cross-link so deterministic ownership works immediately.
syncedA.ForceRingRefresh()
syncedB.ForceRingRefresh()
// Allow brief stabilization (control plane pings / no real negotiation needed here).
time.Sleep(200 * time.Millisecond)
// Create a deterministic cart id for test readability.
cartID := ToCartId(fmt.Sprintf("cart-%d", time.Now().UnixNano()))
// Mutation payload (ring-determined ownership; no assumption about which node owns).
addItem := &messages.AddItem{
ItemId: 1,
Quantity: 1,
Price: 1500,
OrgPrice: 1500,
Sku: "sku-test-multi",
Name: "Multi Node Test",
Image: "/test.png",
Stock: 2,
Tax: 2500,
Country: "se",
}
// Determine ring owner and set primary / secondary references.
ownerHost := syncedA.DebugOwnerHost(cartID)
var ownerSynced, otherSynced *SyncedPool
var ownerPool, otherPool *GrainLocalPool
switch ownerHost {
case hostA:
ownerSynced, ownerPool = syncedA, poolA
otherSynced, otherPool = syncedB, poolB
case hostB:
ownerSynced, ownerPool = syncedB, poolB
otherSynced, otherPool = syncedA, poolA
default:
t.Fatalf("unexpected ring owner %s (expected %s or %s)", ownerHost, hostA, hostB)
}
// Apply mutation on the ring-designated owner.
if _, err := ownerSynced.Apply(cartID, addItem); err != nil {
t.Fatalf("owner %s Apply addItem error: %v", ownerHost, err)
}
// Validate owner pool has the grain and the other does not.
if _, ok := ownerPool.GetGrains()[cartID]; !ok {
t.Fatalf("expected owner %s to have local grain", ownerHost)
}
if _, ok := otherPool.GetGrains()[cartID]; ok {
t.Fatalf("non-owner unexpectedly holds local grain")
}
// Prepare change mutation to be applied from the non-owner (should route remotely).
change := &messages.ChangeQuantity{
Id: 1, // line id after first AddItem
Quantity: 2,
}
// Apply remotely via the non-owner.
if _, err := otherSynced.Apply(cartID, change); err != nil {
t.Fatalf("non-owner remote Apply changeQuantity error: %v", err)
}
// Remote re-mutation already performed via otherSynced; removed duplicate block.
// NodeB local grain assertion:
// Only assert absence if nodeB is NOT the ring-designated owner. If nodeB is the owner,
// it is expected to have a local grain (previous generic ownership assertions already ran).
if ownerHost != hostB {
if _, local := poolB.GetGrains()[cartID]; local {
t.Fatalf("nodeB unexpectedly created local grain (ownership duplication)")
}
}
// Fetch state from nodeB to ensure we see updated quantity (2).
grainStateB, err := syncedB.Get(cartID)
if err != nil {
t.Fatalf("nodeB Get error: %v", err)
}
if len(grainStateB.Items) != 1 || grainStateB.Items[0].Quantity != 2 {
t.Fatalf("nodeB observed inconsistent state: items=%d qty=%d (expected 1 / 2)",
len(grainStateB.Items),
func() int {
if len(grainStateB.Items) == 0 {
return -1
}
return grainStateB.Items[0].Quantity
}(),
)
}
// Cross-check from nodeA (authoritative) to ensure state matches.
grainStateA, err := syncedA.Get(cartID)
if err != nil {
t.Fatalf("nodeA Get error: %v", err)
}
if grainStateA.Items[0].Quantity != 2 {
t.Fatalf("nodeA authoritative state mismatch: expected qty=2 got %d", grainStateA.Items[0].Quantity)
}
}

View File

@@ -1,304 +0,0 @@
package main
import (
"context"
"fmt"
"testing"
"time"
messages "git.tornberg.me/go-cart-actor/proto"
"google.golang.org/grpc"
)
// TestThreeNodeMajorityOwnership validates ring-determined ownership and routing
// in a 3-node cluster (A,B,C) using the consistent hashing ring (no quorum RPC).
// The previous ConfirmOwner / quorum semantics have been removed; ownership is
// deterministic and derived from the ring.
//
// It validates:
// 1. The ring selects exactly one primary owner for a new cart.
// 2. Other nodes (B,C) do NOT create local grains for the cart.
// 3. Remote proxies are installed lazily so remote mutations can route.
// 4. A remote mutation from one non-owner updates state visible on another.
// 5. Authoritative state on the owner matches remote observations.
// 6. (Future) This scaffolds replication tests when RF>1 is enabled.
//
// (Legacy comments about ConfirmOwner acceptance thresholds have been removed.)
// (Function name retained for historical continuity.)
func TestThreeNodeMajorityOwnership(t *testing.T) {
const (
addrA = "127.0.0.1:18181"
addrB = "127.0.0.1:18182"
addrC = "127.0.0.1:18183"
hostA = "nodeA3"
hostB = "nodeB3"
hostC = "nodeC3"
)
// Local grain pools
poolA := NewGrainLocalPool(1024, time.Minute, spawn)
poolB := NewGrainLocalPool(1024, time.Minute, spawn)
poolC := NewGrainLocalPool(1024, time.Minute, spawn)
// Synced pools (no discovery)
syncedA, err := NewSyncedPool(poolA, hostA, nil)
if err != nil {
t.Fatalf("nodeA NewSyncedPool error: %v", err)
}
syncedB, err := NewSyncedPool(poolB, hostB, nil)
if err != nil {
t.Fatalf("nodeB NewSyncedPool error: %v", err)
}
syncedC, err := NewSyncedPool(poolC, hostC, nil)
if err != nil {
t.Fatalf("nodeC NewSyncedPool error: %v", err)
}
// Start gRPC servers
grpcSrvA, err := StartGRPCServer(addrA, poolA, syncedA)
if err != nil {
t.Fatalf("StartGRPCServer A error: %v", err)
}
defer grpcSrvA.GracefulStop()
grpcSrvB, err := StartGRPCServer(addrB, poolB, syncedB)
if err != nil {
t.Fatalf("StartGRPCServer B error: %v", err)
}
defer grpcSrvB.GracefulStop()
grpcSrvC, err := StartGRPCServer(addrC, poolC, syncedC)
if err != nil {
t.Fatalf("StartGRPCServer C error: %v", err)
}
defer grpcSrvC.GracefulStop()
// Helper for manual cross-link (since AddRemote assumes fixed port)
link := func(src *SyncedPool, remoteHost, remoteAddr string) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
conn, dialErr := grpc.DialContext(ctx, remoteAddr, grpc.WithInsecure(), grpc.WithBlock())
if dialErr != nil {
t.Fatalf("dial %s (%s) failed: %v", remoteHost, remoteAddr, dialErr)
}
cartClient := messages.NewCartActorClient(conn)
controlClient := messages.NewControlPlaneClient(conn)
src.mu.Lock()
src.remoteHosts[remoteHost] = &RemoteHostGRPC{
Host: remoteHost,
Conn: conn,
CartClient: cartClient,
ControlClient: controlClient,
}
src.mu.Unlock()
}
// Full mesh (each node knows all others)
link(syncedA, hostB, addrB)
link(syncedA, hostC, addrC)
link(syncedB, hostA, addrA)
link(syncedB, hostC, addrC)
link(syncedC, hostA, addrA)
link(syncedC, hostB, addrB)
// Rebuild rings after manual linking so ownership resolution is immediate.
syncedA.ForceRingRefresh()
syncedB.ForceRingRefresh()
syncedC.ForceRingRefresh()
// Allow brief stabilization
time.Sleep(200 * time.Millisecond)
// Deterministic-ish cart id
cartID := ToCartId(fmt.Sprintf("cart3-%d", time.Now().UnixNano()))
addItem := &messages.AddItem{
ItemId: 10,
Quantity: 1,
Price: 5000,
OrgPrice: 5000,
Sku: "sku-3node",
Name: "Three Node Test",
Image: "/t.png",
Stock: 10,
Tax: 2500,
Country: "se",
}
// Determine ring-designated owner (may be any of the three hosts)
ownerPre := syncedA.DebugOwnerHost(cartID)
if ownerPre != hostA && ownerPre != hostB && ownerPre != hostC {
t.Fatalf("ring returned unexpected owner %s (not in set {%s,%s,%s})", ownerPre, hostA, hostB, hostC)
}
var ownerSynced *SyncedPool
var ownerPool *GrainLocalPool
switch ownerPre {
case hostA:
ownerSynced, ownerPool = syncedA, poolA
case hostB:
ownerSynced, ownerPool = syncedB, poolB
case hostC:
ownerSynced, ownerPool = syncedC, poolC
}
// Pick two distinct non-owner nodes for remote mutation assertions
var remote1Synced, remote2Synced *SyncedPool
switch ownerPre {
case hostA:
remote1Synced, remote2Synced = syncedB, syncedC
case hostB:
remote1Synced, remote2Synced = syncedA, syncedC
case hostC:
remote1Synced, remote2Synced = syncedA, syncedB
}
// Apply on the ring-designated owner
if _, err := ownerSynced.Apply(cartID, addItem); err != nil {
t.Fatalf("owner %s Apply addItem error: %v", ownerPre, err)
}
// Small wait for remote proxy spawn (ring ownership already deterministic)
time.Sleep(150 * time.Millisecond)
// Assert only nodeA has local grain
localCount := 0
if _, ok := poolA.GetGrains()[cartID]; ok {
localCount++
}
if _, ok := poolB.GetGrains()[cartID]; ok {
localCount++
}
if _, ok := poolC.GetGrains()[cartID]; ok {
localCount++
}
if localCount != 1 {
t.Fatalf("expected exactly 1 local grain, got %d", localCount)
}
if _, ok := ownerPool.GetGrains()[cartID]; !ok {
t.Fatalf("expected owner %s to hold local grain", ownerPre)
}
// Remote proxies may not pre-exist; first remote mutation will trigger SpawnRemoteGrain lazily.
// Issue remote mutation from one non-owner -> ChangeQuantity (increase)
change := &messages.ChangeQuantity{
Id: 1,
Quantity: 3,
}
if _, err := remote1Synced.Apply(cartID, change); err != nil {
t.Fatalf("remote mutation (remote1) changeQuantity error: %v", err)
}
// Validate updated state visible via nodeC
stateC, err := remote2Synced.Get(cartID)
if err != nil {
t.Fatalf("nodeC Get error: %v", err)
}
if len(stateC.Items) != 1 || stateC.Items[0].Quantity != 3 {
t.Fatalf("nodeC observed state mismatch: items=%d qty=%d (expected 1 / 3)",
len(stateC.Items),
func() int {
if len(stateC.Items) == 0 {
return -1
}
return stateC.Items[0].Quantity
}(),
)
}
// Cross-check authoritative nodeA
stateA, err := syncedA.Get(cartID)
if err != nil {
t.Fatalf("nodeA Get error: %v", err)
}
if stateA.Items[0].Quantity != 3 {
t.Fatalf("nodeA authoritative state mismatch: expected qty=3 got %d", stateA.Items[0].Quantity)
}
}
// TestThreeNodeDiscoveryMajorityOwnership (placeholder)
// This test is a scaffold demonstrating how a MockDiscovery would be wired
// once AddRemote supports host:port (currently hard-coded to :1337).
// It is skipped to avoid flakiness / false negatives until the production
// AddRemote logic is enhanced to parse dynamic ports or the test harness
// provides consistent port mapping.
func TestThreeNodeDiscoveryMajorityOwnership(t *testing.T) {
t.Skip("Pending enhancement: AddRemote needs host:port support to fully exercise discovery-based multi-node linking")
// Example skeleton (non-functional with current AddRemote implementation):
//
// md := NewMockDiscovery([]string{"nodeB3", "nodeC3"})
// poolA := NewGrainLocalPool(1024, time.Minute, spawn)
// syncedA, err := NewSyncedPool(poolA, "nodeA3", md)
// if err != nil {
// t.Fatalf("NewSyncedPool with mock discovery error: %v", err)
// }
// // Start server for nodeA (would also need servers for nodeB3/nodeC3 on expected ports)
// // grpcSrvA, _ := StartGRPCServer(":1337", poolA, syncedA)
// // defer grpcSrvA.GracefulStop()
//
// // Dynamically add a host via discovery
// // md.AddHost("nodeB3")
// // time.Sleep(100 * time.Millisecond) // allow AddRemote attempt
//
// // Assertions would verify syncedA.remoteHosts contains "nodeB3"
}
// TestHostRemovalAndErrorWithMockDiscovery validates behavior when:
// 1. Discovery reports a host that cannot be dialed (AddRemote error path)
// 2. That host is then removed (Deleted event) without leaving residual state
// 3. A second failing host is added afterward (ensuring watcher still processes events)
//
// NOTE: Because AddRemote currently hard-codes :1337 and we are NOT starting a
// real server for the bogus hosts, the dial will fail and the remote host should
// never appear in remoteHosts. This intentionally exercises the error logging
// path: "AddRemote: dial ... failed".
func TestHostRemovalAndErrorWithMockDiscovery(t *testing.T) {
// Start a real node A (acts as the observing node)
const addrA = "127.0.0.1:18281"
hostA := "nodeA-md"
poolA := NewGrainLocalPool(128, time.Minute, spawn)
// Mock discovery starts with one bogus host that will fail to connect.
md := NewMockDiscovery([]string{"bogus-host-1"})
syncedA, err := NewSyncedPool(poolA, hostA, md)
if err != nil {
t.Fatalf("NewSyncedPool error: %v", err)
}
grpcSrvA, err := StartGRPCServer(addrA, poolA, syncedA)
if err != nil {
t.Fatalf("StartGRPCServer A error: %v", err)
}
defer grpcSrvA.GracefulStop()
// Kick off watch processing by starting Watch() (NewSyncedPool does this internally
// when discovery is non-nil, but we ensure events channel is active).
// The initial bogus host should trigger AddRemote -> dial failure.
time.Sleep(300 * time.Millisecond)
syncedA.mu.RLock()
if len(syncedA.remoteHosts) != 0 {
syncedA.mu.RUnlock()
t.Fatalf("expected 0 remoteHosts after failing dial, got %d", len(syncedA.remoteHosts))
}
syncedA.mu.RUnlock()
// Remove the bogus host (should not panic; no entry to clean up).
md.RemoveHost("bogus-host-1")
time.Sleep(100 * time.Millisecond)
// Add another bogus host to ensure watcher still alive.
md.AddHost("bogus-host-2")
time.Sleep(300 * time.Millisecond)
syncedA.mu.RLock()
if len(syncedA.remoteHosts) != 0 {
syncedA.mu.RUnlock()
t.Fatalf("expected 0 remoteHosts after second failing dial, got %d", len(syncedA.remoteHosts))
}
syncedA.mu.RUnlock()
// Clean up discovery
md.Close()
}

View File

@@ -1,82 +0,0 @@
package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/proto"
)
// mutation_add_item.go
//
// Registers the AddItem cart mutation in the generic mutation registry.
// This replaces the legacy switch-based logic previously found in CartGrain.Apply.
//
// Behavior:
// * Validates quantity > 0
// * If an item with same SKU exists -> increases quantity
// * Else creates a new CartItem with computed tax amounts
// * Totals recalculated automatically via WithTotals()
//
// NOTE: Any future field additions in messages.AddItem that affect pricing / tax
// must keep this handler in sync.
func init() {
RegisterMutation[messages.AddItem](
"AddItem",
func(g *CartGrain, m *messages.AddItem) error {
if m == nil {
return fmt.Errorf("AddItem: nil payload")
}
if m.Quantity < 1 {
return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity)
}
// Fast path: merge with existing item having same SKU
if existing, found := g.FindItemWithSku(m.Sku); found {
existing.Quantity += int(m.Quantity)
return nil
}
g.mu.Lock()
defer g.mu.Unlock()
g.lastItemId++
taxRate := 2500
if m.Tax > 0 {
taxRate = int(m.Tax)
}
taxAmountPerUnit := GetTaxAmount(m.Price, taxRate)
g.Items = append(g.Items, &CartItem{
Id: g.lastItemId,
ItemId: int(m.ItemId),
Quantity: int(m.Quantity),
Sku: m.Sku,
Name: m.Name,
Price: m.Price,
TotalPrice: m.Price * int64(m.Quantity),
TotalTax: int64(taxAmountPerUnit * int64(m.Quantity)),
Image: m.Image,
Stock: StockStatus(m.Stock),
Disclaimer: m.Disclaimer,
Brand: m.Brand,
Category: m.Category,
Category2: m.Category2,
Category3: m.Category3,
Category4: m.Category4,
Category5: m.Category5,
OrgPrice: m.OrgPrice,
ArticleType: m.ArticleType,
Outlet: m.Outlet,
SellerId: m.SellerId,
SellerName: m.SellerName,
Tax: int(taxAmountPerUnit),
TaxRate: taxRate,
StoreId: m.StoreId,
})
return nil
},
WithTotals(), // Recalculate totals after successful mutation
)
}

View File

@@ -1,61 +0,0 @@
package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/proto"
)
// mutation_add_request.go
//
// Registers the AddRequest mutation. This mutation is a higher-level intent
// (add by SKU + quantity) which may translate into either:
// - Increasing quantity of an existing line (same SKU), OR
// - Creating a new item by performing a product lookup (via getItemData inside CartGrain.AddItem)
//
// Behavior:
// - Validates non-empty SKU and quantity > 0
// - If an item with the SKU already exists: increments its quantity
// - Else delegates to CartGrain.AddItem (which itself produces an AddItem mutation)
// - Totals recalculated automatically (WithTotals)
//
// NOTE:
// - This handler purposely avoids duplicating the detailed AddItem logic;
// it reuses CartGrain.AddItem which then flows through the AddItem mutation
// registry handler.
// - Double total recalculation can occur (AddItem has WithTotals too), but
// is acceptable for clarity. Optimize later if needed.
//
// Potential future improvements:
// - Stock validation before increasing quantity
// - Reservation logic or concurrency guards around stock updates
// - Coupon / pricing rules applied conditionally during add-by-sku
func init() {
RegisterMutation[messages.AddRequest](
"AddRequest",
func(g *CartGrain, m *messages.AddRequest) error {
if m == nil {
return fmt.Errorf("AddRequest: nil payload")
}
if m.Sku == "" {
return fmt.Errorf("AddRequest: sku is empty")
}
if m.Quantity < 1 {
return fmt.Errorf("AddRequest: invalid quantity %d", m.Quantity)
}
// Existing line: accumulate quantity only.
if existing, found := g.FindItemWithSku(m.Sku); found {
existing.Quantity += int(m.Quantity)
return nil
}
// New line: delegate to higher-level AddItem flow (product lookup).
// We intentionally ignore the returned *CartGrain; registry will
// do totals again after this handler returns (harmless).
_, err := g.AddItem(m.Sku, int(m.Quantity), m.Country, m.StoreId)
return err
},
WithTotals(),
)
}

View File

@@ -1,301 +0,0 @@
package main
import (
"fmt"
"reflect"
"sync"
)
// mutation_registry.go
//
// Mutation Registry Infrastructure
// --------------------------------
// This file introduces a generic registry for cart mutations that:
//
// 1. Decouples mutation logic from the large type-switch inside CartGrain.Apply.
// 2. Enforces (at registration time) that every mutation handler has the correct
// signature: func(*CartGrain, *T) error
// 3. Optionally auto-updates cart totals after a mutation if flagged.
// 4. Provides a single authoritative list of registered mutations for
// introspection / coverage testing.
// 5. Allows incremental migration: you can first register new mutations here,
// and later prune the legacy switch cases.
//
// Usage Pattern
// -------------
// // Define your mutation proto message (e.g. messages.ApplyCoupon in messages.proto)
// // Regenerate protobufs.
//
// // In an init() (ideally in a small file like mutations_apply_coupon.go)
// func init() {
// RegisterMutation[*messages.ApplyCoupon](
// "ApplyCoupon",
// func(g *CartGrain, m *messages.ApplyCoupon) error {
// // domain logic ...
// discount := int64(5000)
// if g.TotalPrice < discount {
// discount = g.TotalPrice
// }
// g.TotalDiscount += discount
// g.TotalPrice -= discount
// return nil
// },
// WithTotals(), // we changed price-related fields; recalc totals
// )
// }
//
// // To invoke dynamically (alternative to the current switch):
// if updated, err := ApplyRegistered(grain, incomingMessage); err == nil {
// grain = updated
// } else if errors.Is(err, ErrMutationNotRegistered) {
// // fallback to legacy switch logic
// }
//
// Migration Strategy
// ------------------
// 1. For each existing mutation handled in CartGrain.Apply, add a registry
// registration with equivalent logic.
// 2. Add a test that enumerates all *expected* mutation proto types and asserts
// they are present in RegisteredMutationTypes().
// 3. Once coverage is 100%, replace the switch in CartGrain.Apply with a call
// to ApplyRegistered (and optionally keep a minimal default to produce an
// "unsupported mutation" error).
//
// Thread Safety
// -------------
// Registration is typically done at init() time; a RWMutex provides safety
// should late dynamic registration ever be introduced.
//
// Auto Totals
// -----------
// Many mutations require recomputing totals. To avoid forgetting this, pass
// WithTotals() when registering. This will invoke grain.UpdateTotals() after
// the handler returns successfully.
//
// Error Semantics
// ---------------
// - If a handler returns an error, totals are NOT recalculated (even if
// WithTotals() was specified).
// - ApplyRegistered returns (nil, ErrMutationNotRegistered) if the message type
// is absent.
//
// Extensibility
// -------------
// It is straightforward to add options like audit hooks, metrics wrappers,
// or optimistic concurrency guards by extending MutationOption.
//
// NOTE: Generics require Go 1.18+. If constrained to earlier Go versions,
// replace the generic registration with a non-generic RegisterMutationType
// that accepts reflect.Type and an adapter function.
//
// ---------------------------------------------------------------------------
var (
mutationRegistryMu sync.RWMutex
mutationRegistry = make(map[reflect.Type]*registeredMutation)
// ErrMutationNotRegistered is returned when no handler exists for a given mutation type.
ErrMutationNotRegistered = fmt.Errorf("mutation not registered")
)
// MutationOption configures additional behavior for a registered mutation.
type MutationOption func(*mutationOptions)
// mutationOptions holds flags adjustable per registration.
type mutationOptions struct {
updateTotals bool
}
// WithTotals ensures CartGrain.UpdateTotals() is called after a successful handler.
func WithTotals() MutationOption {
return func(o *mutationOptions) {
o.updateTotals = true
}
}
// registeredMutation stores metadata + the execution closure.
type registeredMutation struct {
name string
handler func(*CartGrain, interface{}) error
updateTotals bool
msgType reflect.Type
}
// RegisterMutation registers a mutation handler for a specific message type T.
//
// Parameters:
//
// name - a human-readable identifier (used for diagnostics / coverage tests).
// handler - business logic operating on the cart grain & strongly typed message.
// options - optional behavior flags (e.g., WithTotals()).
//
// Panics if:
// - name is empty
// - handler is nil
// - duplicate registration for the same message type T
//
// Typical call is placed in an init() function.
func RegisterMutation[T any](name string, handler func(*CartGrain, *T) error, options ...MutationOption) {
if name == "" {
panic("RegisterMutation: name is required")
}
if handler == nil {
panic("RegisterMutation: handler is nil")
}
// Derive the reflect.Type for *T then its Elem (T) for mapping.
var zero *T
rtPtr := reflect.TypeOf(zero)
if rtPtr.Kind() != reflect.Ptr {
panic("RegisterMutation: expected pointer type for generic parameter")
}
rt := rtPtr.Elem()
opts := mutationOptions{}
for _, opt := range options {
opt(&opts)
}
wrapped := func(g *CartGrain, m interface{}) error {
typed, ok := m.(*T)
if !ok {
return fmt.Errorf("mutation type mismatch: have %T want *%s", m, rt.Name())
}
return handler(g, typed)
}
mutationRegistryMu.Lock()
defer mutationRegistryMu.Unlock()
if _, exists := mutationRegistry[rt]; exists {
panic(fmt.Sprintf("RegisterMutation: duplicate registration for type %s", rt.String()))
}
mutationRegistry[rt] = &registeredMutation{
name: name,
handler: wrapped,
updateTotals: opts.updateTotals,
msgType: rt,
}
}
// ApplyRegistered attempts to apply a registered mutation.
// Returns updated grain if successful.
//
// If the mutation is not registered, returns (nil, ErrMutationNotRegistered).
func ApplyRegistered(grain *CartGrain, msg interface{}) (*CartGrain, error) {
if grain == nil {
return nil, fmt.Errorf("nil grain")
}
if msg == nil {
return nil, fmt.Errorf("nil mutation message")
}
rt := indirectType(reflect.TypeOf(msg))
mutationRegistryMu.RLock()
entry, ok := mutationRegistry[rt]
mutationRegistryMu.RUnlock()
if !ok {
return nil, ErrMutationNotRegistered
}
if err := entry.handler(grain, msg); err != nil {
return nil, err
}
if entry.updateTotals {
grain.UpdateTotals()
}
return grain, nil
}
// RegisteredMutations returns metadata for all registered mutations (snapshot).
func RegisteredMutations() []string {
mutationRegistryMu.RLock()
defer mutationRegistryMu.RUnlock()
out := make([]string, 0, len(mutationRegistry))
for _, entry := range mutationRegistry {
out = append(out, entry.name)
}
return out
}
// RegisteredMutationTypes returns the reflect.Type list of all registered messages.
// Useful for coverage tests ensuring expected set matches actual set.
func RegisteredMutationTypes() []reflect.Type {
mutationRegistryMu.RLock()
defer mutationRegistryMu.RUnlock()
out := make([]reflect.Type, 0, len(mutationRegistry))
for t := range mutationRegistry {
out = append(out, t)
}
return out
}
// MustAssertMutationCoverage can be called at startup to ensure every expected
// mutation type has been registered. It panics with a descriptive message if any
// are missing. Provide a slice of prototype pointers (e.g. []*messages.AddItem{nil} ...)
func MustAssertMutationCoverage(expected []interface{}) {
mutationRegistryMu.RLock()
defer mutationRegistryMu.RUnlock()
missing := make([]string, 0)
for _, ex := range expected {
if ex == nil {
continue
}
t := indirectType(reflect.TypeOf(ex))
if _, ok := mutationRegistry[t]; !ok {
missing = append(missing, t.String())
}
}
if len(missing) > 0 {
panic(fmt.Sprintf("mutation registry missing handlers for: %v", missing))
}
}
// indirectType returns the element type if given a pointer; otherwise the type itself.
func indirectType(t reflect.Type) reflect.Type {
for t.Kind() == reflect.Ptr {
t = t.Elem()
}
return t
}
/*
Integration Guide
-----------------
1. Register all existing mutations:
func init() {
RegisterMutation[*messages.AddItem]("AddItem",
func(g *CartGrain, m *messages.AddItem) error {
// (port logic from existing switch branch)
// ...
return nil
},
WithTotals(),
)
// ... repeat for others
}
2. In CartGrain.Apply (early in the method) add:
if updated, err := ApplyRegistered(c, content); err == nil {
return updated, nil
} else if err != ErrMutationNotRegistered {
return nil, err
}
// existing switch fallback below
3. Once all mutations are registered, remove the legacy switch cases
and leave a single ErrMutationNotRegistered path for unknown types.
4. Add a coverage test (see docs for example; removed from source for clarity).
5. (Optional) Add metrics / tracing wrappers for handlers.
*/

View File

@@ -1,57 +0,0 @@
package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/proto"
)
// mutation_set_cart_items.go
//
// Registers the SetCartRequest mutation. This mutation replaces the entire list
// of cart items with the provided list (each entry is an AddRequest).
//
// Behavior:
// - Clears existing items (but leaves deliveries intact).
// - Iterates over each AddRequest and delegates to CartGrain.AddItem
// (which performs product lookup, creates AddItem mutation).
// - If any single addition fails, the mutation aborts with an error;
// items added prior to the failure remain (consistent with previous behavior).
// - Totals recalculated after completion via WithTotals().
//
// Notes:
// - Potential optimization: batch product lookups; currently sequential.
// - Consider adding rollback semantics if atomic replacement is desired.
// - Deliveries might reference item IDs that are now invalid—original logic
// also left deliveries untouched. If that becomes an issue, add a cleanup
// pass to remove deliveries whose item IDs no longer exist.
func init() {
RegisterMutation[messages.SetCartRequest](
"SetCartRequest",
func(g *CartGrain, m *messages.SetCartRequest) error {
if m == nil {
return fmt.Errorf("SetCartRequest: nil payload")
}
// Clear current items (keep deliveries)
g.mu.Lock()
g.Items = make([]*CartItem, 0, len(m.Items))
g.mu.Unlock()
for _, it := range m.Items {
if it == nil {
continue
}
if it.Sku == "" || it.Quantity < 1 {
return fmt.Errorf("SetCartRequest: invalid item (sku='%s' qty=%d)", it.Sku, it.Quantity)
}
_, err := g.AddItem(it.Sku, int(it.Quantity), it.Country, it.StoreId)
if err != nil {
return fmt.Errorf("SetCartRequest: add sku '%s' failed: %w", it.Sku, err)
}
}
return nil
},
WithTotals(),
)
}

137
pkg/actor/disk_storage.go Normal file
View File

@@ -0,0 +1,137 @@
package actor
import (
"errors"
"fmt"
"log"
"os"
"path/filepath"
"sync"
"time"
"github.com/gogo/protobuf/proto"
)
type QueueEvent struct {
TimeStamp time.Time
Message proto.Message
}
type DiskStorage[V any] struct {
*StateStorage
path string
done chan struct{}
queue *sync.Map // map[uint64][]QueueEvent
}
type LogStorage[V any] interface {
LoadEvents(id uint64, grain Grain[V]) error
AppendEvent(id uint64, msg ...proto.Message) error
}
func NewDiskStorage[V any](path string, registry MutationRegistry) *DiskStorage[V] {
return &DiskStorage[V]{
StateStorage: NewState(registry),
path: path,
done: make(chan struct{}),
}
}
func (s *DiskStorage[V]) SaveLoop(duration time.Duration) {
s.queue = &sync.Map{}
ticker := time.NewTicker(duration)
defer ticker.Stop()
for {
select {
case <-s.done:
s.save()
return
case <-ticker.C:
s.save()
}
}
}
func (s *DiskStorage[V]) save() {
carts := 0
lines := 0
s.queue.Range(func(key, value any) bool {
id := key.(uint64)
path := s.logPath(id)
fh, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Printf("failed to open event log file: %v", err)
return true
}
defer fh.Close()
if qe, ok := value.([]QueueEvent); ok {
for _, msg := range qe {
if err := s.Append(fh, msg.Message, msg.TimeStamp); err != nil {
log.Printf("failed to append event to log file: %v", err)
}
lines++
}
}
carts++
s.queue.Delete(id)
return true
})
if lines > 0 {
log.Printf("Appended %d carts and %d lines to disk", carts, lines)
}
}
func (s *DiskStorage[V]) logPath(id uint64) string {
return filepath.Join(s.path, fmt.Sprintf("%d.events.log", id))
}
func (s *DiskStorage[V]) LoadEvents(id uint64, grain Grain[V]) error {
path := s.logPath(id)
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
// No log -> nothing to replay
return nil
}
fh, err := os.Open(path)
if err != nil {
return fmt.Errorf("open replay file: %w", err)
}
defer fh.Close()
return s.Load(fh, func(msg proto.Message) {
s.registry.Apply(grain, msg)
})
}
func (s *DiskStorage[V]) Close() {
s.save()
close(s.done)
}
func (s *DiskStorage[V]) AppendEvent(id uint64, msg ...proto.Message) error {
if s.queue != nil {
queue := make([]QueueEvent, 0)
data, found := s.queue.Load(id)
if found {
queue = data.([]QueueEvent)
}
for _, m := range msg {
queue = append(queue, QueueEvent{Message: m, TimeStamp: time.Now()})
}
s.queue.Store(id, queue)
return nil
} else {
path := s.logPath(id)
fh, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Printf("failed to open event log file: %v", err)
return err
}
defer fh.Close()
for _, m := range msg {
err = s.Append(fh, m, time.Now())
}
return err
}
}

13
pkg/actor/grain.go Normal file
View File

@@ -0,0 +1,13 @@
package actor
import (
"time"
)
type Grain[V any] interface {
GetId() uint64
GetLastAccess() time.Time
GetLastChange() time.Time
GetCurrentState() (*V, error)
}

41
pkg/actor/grain_pool.go Normal file
View File

@@ -0,0 +1,41 @@
package actor
import (
"net/http"
"github.com/gogo/protobuf/proto"
)
type MutationResult[V any] struct {
Result V `json:"result"`
Mutations []ApplyResult `json:"mutations,omitempty"`
}
type GrainPool[V any] interface {
Apply(id uint64, mutation ...proto.Message) (*MutationResult[V], error)
Get(id uint64) (V, error)
OwnerHost(id uint64) (Host, bool)
Hostname() string
TakeOwnership(id uint64)
HandleOwnershipChange(host string, ids []uint64) error
HandleRemoteExpiry(host string, ids []uint64) error
Negotiate(otherHosts []string)
GetLocalIds() []uint64
RemoveHost(host string)
IsHealthy() bool
IsKnown(string) bool
Close()
}
// Host abstracts a remote node capable of proxying cart requests.
type Host interface {
AnnounceExpiry(ids []uint64)
Negotiate(otherHosts []string) ([]string, error)
Name() string
Proxy(id uint64, w http.ResponseWriter, r *http.Request) (bool, error)
GetActorIds() []uint64
Close() error
Ping() bool
IsHealthy() bool
AnnounceOwnership(ownerHost string, ids []uint64)
}

119
pkg/actor/grpc_server.go Normal file
View File

@@ -0,0 +1,119 @@
package actor
import (
"context"
"fmt"
"log"
"net"
"time"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
)
// ControlServer implements the ControlPlane gRPC services.
// It delegates to a grain pool and cluster operations to a synced pool.
type ControlServer[V any] struct {
messages.UnimplementedControlPlaneServer
pool GrainPool[V]
}
func (s *ControlServer[V]) AnnounceOwnership(ctx context.Context, req *messages.OwnershipAnnounce) (*messages.OwnerChangeAck, error) {
err := s.pool.HandleOwnershipChange(req.Host, req.Ids)
if err != nil {
return &messages.OwnerChangeAck{
Accepted: false,
Message: "owner change failed",
}, err
}
log.Printf("Ack count: %d", len(req.Ids))
return &messages.OwnerChangeAck{
Accepted: true,
Message: "ownership announced",
}, nil
}
func (s *ControlServer[V]) AnnounceExpiry(ctx context.Context, req *messages.ExpiryAnnounce) (*messages.OwnerChangeAck, error) {
err := s.pool.HandleRemoteExpiry(req.Host, req.Ids)
return &messages.OwnerChangeAck{
Accepted: err == nil,
Message: "expiry acknowledged",
}, nil
}
// ControlPlane: Ping
func (s *ControlServer[V]) Ping(ctx context.Context, _ *messages.Empty) (*messages.PingReply, error) {
// log.Printf("got ping")
return &messages.PingReply{
Host: s.pool.Hostname(),
UnixTime: time.Now().Unix(),
}, nil
}
// ControlPlane: Negotiate (merge host views)
func (s *ControlServer[V]) Negotiate(ctx context.Context, req *messages.NegotiateRequest) (*messages.NegotiateReply, error) {
s.pool.Negotiate(req.KnownHosts)
return &messages.NegotiateReply{Hosts: req.GetKnownHosts()}, nil
}
// ControlPlane: GetCartIds (locally owned carts only)
func (s *ControlServer[V]) GetLocalActorIds(ctx context.Context, _ *messages.Empty) (*messages.ActorIdsReply, error) {
return &messages.ActorIdsReply{Ids: s.pool.GetLocalIds()}, nil
}
// ControlPlane: Closing (peer shutdown notification)
func (s *ControlServer[V]) Closing(ctx context.Context, req *messages.ClosingNotice) (*messages.OwnerChangeAck, error) {
if req.GetHost() != "" {
s.pool.RemoveHost(req.GetHost())
}
return &messages.OwnerChangeAck{
Accepted: true,
Message: "removed host",
}, nil
}
type ServerConfig struct {
Addr string
Options []grpc.ServerOption
}
func NewServerConfig(addr string, options ...grpc.ServerOption) ServerConfig {
return ServerConfig{
Addr: addr,
Options: options,
}
}
func DefaultServerConfig() ServerConfig {
return NewServerConfig(":1337")
}
// StartGRPCServer configures and starts the unified gRPC server on the given address.
// It registers both the CartActor and ControlPlane services.
func NewControlServer[V any](config ServerConfig, pool GrainPool[V]) (*grpc.Server, error) {
lis, err := net.Listen("tcp", config.Addr)
if err != nil {
return nil, fmt.Errorf("failed to listen: %w", err)
}
grpcServer := grpc.NewServer(config.Options...)
server := &ControlServer[V]{
pool: pool,
}
messages.RegisterControlPlaneServer(grpcServer, server)
reflection.Register(grpcServer)
log.Printf("gRPC server listening as %s on %s", pool.Hostname(), config.Addr)
go func() {
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("failed to serve gRPC: %v", err)
}
}()
return grpcServer, nil
}

View File

@@ -0,0 +1,226 @@
package actor
import (
"fmt"
"log"
"reflect"
"sync"
"github.com/gogo/protobuf/proto"
)
type ApplyResult struct {
Type string `json:"type"`
Mutation proto.Message `json:"mutation"`
Error error `json:"error,omitempty"`
}
type MutationRegistry interface {
Apply(grain any, msg ...proto.Message) ([]ApplyResult, error)
RegisterMutations(handlers ...MutationHandler)
Create(typeName string) (proto.Message, bool)
GetTypeName(msg proto.Message) (string, bool)
//GetStorageEvent(msg proto.Message) StorageEvent
//FromStorageEvent(event StorageEvent) (proto.Message, error)
}
type ProtoMutationRegistry struct {
mutationRegistryMu sync.RWMutex
mutationRegistry map[reflect.Type]MutationHandler
}
var (
ErrMutationNotRegistered = &MutationError{
Message: "mutation not registered",
Code: 255,
StatusCode: 500,
}
)
type MutationError struct {
Message string `json:"message"`
Code uint32 `json:"code"`
StatusCode uint32 `json:"status_code"`
}
func (m MutationError) Error() string {
return m.Message
}
// MutationOption configures additional behavior for a registered mutation.
type MutationOption func(*mutationOptions)
// mutationOptions holds flags adjustable per registration.
type mutationOptions struct {
updateTotals bool
}
// WithTotals ensures CartGrain.UpdateTotals() is called after a successful handler.
func WithTotals() MutationOption {
return func(o *mutationOptions) {
o.updateTotals = true
}
}
type MutationHandler interface {
Handle(state any, msg proto.Message) error
Name() string
Type() reflect.Type
Create() proto.Message
}
// RegisteredMutation stores metadata + the execution closure.
type RegisteredMutation[V any, T proto.Message] struct {
name string
handler func(*V, T) error
create func() T
msgType reflect.Type
}
func NewMutation[V any, T proto.Message](handler func(*V, T) error, create func() T) *RegisteredMutation[V, T] {
// Derive the name and message type from a concrete instance produced by create().
// This avoids relying on reflect.TypeFor (which can yield unexpected results in some toolchains)
// and ensures we always peel off the pointer layer for proto messages.
instance := create()
rt := reflect.TypeOf(instance)
if rt.Kind() == reflect.Ptr {
rt = rt.Elem()
}
return &RegisteredMutation[V, T]{
name: rt.Name(),
handler: handler,
create: create,
msgType: rt,
}
}
func (m *RegisteredMutation[V, T]) Handle(state any, msg proto.Message) error {
return m.handler(state.(*V), msg.(T))
}
func (m *RegisteredMutation[V, T]) Name() string {
return m.name
}
func (m *RegisteredMutation[V, T]) Create() proto.Message {
return m.create()
}
func (m *RegisteredMutation[V, T]) Type() reflect.Type {
return m.msgType
}
func NewMutationRegistry() MutationRegistry {
return &ProtoMutationRegistry{
mutationRegistry: make(map[reflect.Type]MutationHandler),
mutationRegistryMu: sync.RWMutex{},
}
}
func (r *ProtoMutationRegistry) RegisterMutations(handlers ...MutationHandler) {
r.mutationRegistryMu.Lock()
defer r.mutationRegistryMu.Unlock()
for _, handler := range handlers {
r.mutationRegistry[handler.Type()] = handler
}
}
func (r *ProtoMutationRegistry) GetTypeName(msg proto.Message) (string, bool) {
r.mutationRegistryMu.RLock()
defer r.mutationRegistryMu.RUnlock()
rt := indirectType(reflect.TypeOf(msg))
if handler, ok := r.mutationRegistry[rt]; ok {
return handler.Name(), true
}
return "", false
}
func (r *ProtoMutationRegistry) getHandler(typeName string) MutationHandler {
r.mutationRegistryMu.Lock()
defer r.mutationRegistryMu.Unlock()
for _, handler := range r.mutationRegistry {
if handler.Name() == typeName {
return handler
}
}
return nil
}
func (r *ProtoMutationRegistry) Create(typeName string) (proto.Message, bool) {
handler := r.getHandler(typeName)
if handler == nil {
log.Printf("missing handler for %s", typeName)
return nil, false
}
return handler.Create(), true
}
// ApplyRegistered attempts to apply a registered mutation.
// Returns updated grain if successful.
//
// If the mutation is not registered, returns (nil, ErrMutationNotRegistered).
func (r *ProtoMutationRegistry) Apply(grain any, msg ...proto.Message) ([]ApplyResult, error) {
results := make([]ApplyResult, 0, len(msg))
if grain == nil {
return results, fmt.Errorf("nil grain")
}
if msg == nil {
return results, fmt.Errorf("nil mutation message")
}
for _, m := range msg {
rt := indirectType(reflect.TypeOf(m))
r.mutationRegistryMu.RLock()
entry, ok := r.mutationRegistry[rt]
r.mutationRegistryMu.RUnlock()
if !ok {
results = append(results, ApplyResult{Error: ErrMutationNotRegistered, Type: rt.Name(), Mutation: m})
continue
}
err := entry.Handle(grain, m)
results = append(results, ApplyResult{Error: err, Type: rt.Name(), Mutation: m})
}
// if entry.updateTotals {
// grain.UpdateTotals()
// }
return results, nil
}
// RegisteredMutations returns metadata for all registered mutations (snapshot).
func (r *ProtoMutationRegistry) RegisteredMutations() []string {
r.mutationRegistryMu.RLock()
defer r.mutationRegistryMu.RUnlock()
out := make([]string, 0, len(r.mutationRegistry))
for _, entry := range r.mutationRegistry {
out = append(out, entry.Name())
}
return out
}
// RegisteredMutationTypes returns the reflect.Type list of all registered messages.
// Useful for coverage tests ensuring expected set matches actual set.
func (r *ProtoMutationRegistry) RegisteredMutationTypes() []reflect.Type {
r.mutationRegistryMu.RLock()
defer r.mutationRegistryMu.RUnlock()
out := make([]reflect.Type, 0, len(r.mutationRegistry))
for _, entry := range r.mutationRegistry {
out = append(out, entry.Type())
}
return out
}
func indirectType(t reflect.Type) reflect.Type {
for t.Kind() == reflect.Ptr {
t = t.Elem()
}
return t
}

View File

@@ -0,0 +1,134 @@
package actor
import (
"errors"
"reflect"
"slices"
"testing"
"git.tornberg.me/go-cart-actor/pkg/messages"
)
type cartState struct {
calls int
lastAdded *messages.AddItem
}
func TestRegisteredMutationBasics(t *testing.T) {
reg := NewMutationRegistry().(*ProtoMutationRegistry)
addItemMutation := NewMutation(
func(state *cartState, msg *messages.AddItem) error {
state.calls++
// copy to avoid external mutation side-effects (not strictly necessary for the test)
cp := *msg
state.lastAdded = &cp
return nil
},
func() *messages.AddItem { return &messages.AddItem{} },
)
// Sanity check on mutation metadata
if addItemMutation.Name() != "AddItem" {
t.Fatalf("expected mutation Name() == AddItem, got %s", addItemMutation.Name())
}
if got, want := addItemMutation.Type(), reflect.TypeOf(messages.AddItem{}); got != want {
t.Fatalf("expected Type() == %v, got %v", want, got)
}
reg.RegisterMutations(addItemMutation)
// RegisteredMutations: membership (order not guaranteed)
names := reg.RegisteredMutations()
if !slices.Contains(names, "AddItem") {
t.Fatalf("RegisteredMutations missing AddItem, got %v", names)
}
// RegisteredMutationTypes: membership (order not guaranteed)
types := reg.RegisteredMutationTypes()
if !slices.Contains(types, reflect.TypeOf(messages.AddItem{})) {
t.Fatalf("RegisteredMutationTypes missing AddItem type, got %v", types)
}
// GetTypeName should resolve for a pointer instance
name, ok := reg.GetTypeName(&messages.AddItem{})
if !ok || name != "AddItem" {
t.Fatalf("GetTypeName returned (%q,%v), expected (AddItem,true)", name, ok)
}
// GetTypeName should fail for unregistered type
if name, ok := reg.GetTypeName(&messages.Noop{}); ok || name != "" {
t.Fatalf("expected GetTypeName to fail for unregistered message, got (%q,%v)", name, ok)
}
// Create by name
msg, ok := reg.Create("AddItem")
if !ok {
t.Fatalf("Create failed for registered mutation")
}
if _, isAddItem := msg.(*messages.AddItem); !isAddItem {
t.Fatalf("Create returned wrong concrete type: %T", msg)
}
// Create unknown
if m2, ok := reg.Create("Unknown"); ok || m2 != nil {
t.Fatalf("Create should fail for unknown mutation, got (%T,%v)", m2, ok)
}
// Apply happy path
state := &cartState{}
add := &messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
if _, err := reg.Apply(state, add); err != nil {
t.Fatalf("Apply returned error: %v", err)
}
if state.calls != 1 {
t.Fatalf("handler not invoked expected calls=1 got=%d", state.calls)
}
if state.lastAdded == nil || state.lastAdded.ItemId != 42 || state.lastAdded.Quantity != 3 {
t.Fatalf("state not updated correctly: %+v", state.lastAdded)
}
// Apply nil grain
if _, err := reg.Apply(nil, add); err == nil {
t.Fatalf("expected error for nil grain")
}
// Apply nil message
if _, err := reg.Apply(state, nil); err == nil {
t.Fatalf("expected error for nil mutation message")
}
// Apply unregistered message
if _, err := reg.Apply(state, &messages.Noop{}); !errors.Is(err, ErrMutationNotRegistered) {
t.Fatalf("expected ErrMutationNotRegistered, got %v", err)
}
}
// func TestConcurrentSafeRegistrationLookup(t *testing.T) {
// // This test is light-weight; it ensures locks don't deadlock under simple concurrent access.
// reg := NewMutationRegistry().(*ProtoMutationRegistry)
// mut := NewMutation[cartState, *messages.Noop](
// func(state *cartState, msg *messages.Noop) error { state.calls++; return nil },
// func() *messages.Noop { return &messages.Noop{} },
// )
// reg.RegisterMutations(mut)
// done := make(chan struct{})
// const workers = 25
// for i := 0; i < workers; i++ {
// go func() {
// for j := 0; j < 100; j++ {
// _, _ = reg.Create("Noop")
// _, _ = reg.GetTypeName(&messages.Noop{})
// _ = reg.Apply(&cartState{}, &messages.Noop{})
// }
// done <- struct{}{}
// }()
// }
// for i := 0; i < workers; i++ {
// <-done
// }
// }
// Helpers

View File

@@ -0,0 +1,433 @@
package actor
import (
"fmt"
"log"
"maps"
"sync"
"time"
"github.com/gogo/protobuf/proto"
)
type SimpleGrainPool[V any] struct {
// fields and methods
localMu sync.RWMutex
grains map[uint64]Grain[V]
mutationRegistry MutationRegistry
spawn func(id uint64) (Grain[V], error)
spawnHost func(host string) (Host, error)
storage LogStorage[V]
ttl time.Duration
poolSize int
// Cluster coordination --------------------------------------------------
hostname string
remoteMu sync.RWMutex
remoteOwners map[uint64]Host
remoteHosts map[string]Host
//discardedHostHandler *DiscardedHostHandler
// House-keeping ---------------------------------------------------------
purgeTicker *time.Ticker
}
type GrainPoolConfig[V any] struct {
Hostname string
Spawn func(id uint64) (Grain[V], error)
SpawnHost func(host string) (Host, error)
TTL time.Duration
PoolSize int
MutationRegistry MutationRegistry
Storage LogStorage[V]
}
func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V], error) {
p := &SimpleGrainPool[V]{
grains: make(map[uint64]Grain[V]),
mutationRegistry: config.MutationRegistry,
storage: config.Storage,
spawn: config.Spawn,
spawnHost: config.SpawnHost,
ttl: config.TTL,
poolSize: config.PoolSize,
hostname: config.Hostname,
remoteOwners: make(map[uint64]Host),
remoteHosts: make(map[string]Host),
}
p.purgeTicker = time.NewTicker(time.Minute)
go func() {
for range p.purgeTicker.C {
p.purge()
}
}()
return p, nil
}
func (p *SimpleGrainPool[V]) purge() {
purgeLimit := time.Now().Add(-p.ttl)
purgedIds := make([]uint64, 0, len(p.grains))
p.localMu.Lock()
for id, grain := range p.grains {
if grain.GetLastAccess().Before(purgeLimit) {
purgedIds = append(purgedIds, id)
delete(p.grains, id)
}
}
p.localMu.Unlock()
p.forAllHosts(func(remote Host) {
remote.AnnounceExpiry(purgedIds)
})
}
// LocalUsage returns the number of resident grains and configured capacity.
func (p *SimpleGrainPool[V]) LocalUsage() (int, int) {
p.localMu.RLock()
defer p.localMu.RUnlock()
return len(p.grains), p.poolSize
}
// LocalCartIDs returns the currently owned cart ids (for control-plane RPCs).
func (p *SimpleGrainPool[V]) GetLocalIds() []uint64 {
p.localMu.RLock()
defer p.localMu.RUnlock()
ids := make([]uint64, 0, len(p.grains))
for _, g := range p.grains {
if g == nil {
continue
}
ids = append(ids, uint64(g.GetId()))
}
return ids
}
func (p *SimpleGrainPool[V]) HandleRemoteExpiry(host string, ids []uint64) error {
p.remoteMu.Lock()
defer p.remoteMu.Unlock()
for _, id := range ids {
delete(p.remoteOwners, id)
}
return nil
}
func (p *SimpleGrainPool[V]) HandleOwnershipChange(host string, ids []uint64) error {
log.Printf("host %s now owns %d cart ids", host, len(ids))
p.remoteMu.RLock()
remoteHost, exists := p.remoteHosts[host]
p.remoteMu.RUnlock()
if !exists {
createdHost, err := p.AddRemote(host)
if err != nil {
return err
}
remoteHost = createdHost
}
p.remoteMu.Lock()
defer p.remoteMu.Unlock()
p.localMu.Lock()
defer p.localMu.Unlock()
for _, id := range ids {
log.Printf("Handling ownership change for cart %d to host %s", id, host)
delete(p.grains, id)
p.remoteOwners[id] = remoteHost
}
return nil
}
// TakeOwnership takes ownership of a grain.
func (p *SimpleGrainPool[V]) TakeOwnership(id uint64) {
p.broadcastOwnership([]uint64{id})
}
func (p *SimpleGrainPool[V]) AddRemote(host string) (Host, error) {
if host == "" {
return nil, fmt.Errorf("host is empty")
}
if host == p.hostname {
return nil, fmt.Errorf("same host, this should not happen")
}
p.remoteMu.RLock()
existing, found := p.remoteHosts[host]
p.remoteMu.RUnlock()
if found {
return existing, nil
}
remote, err := p.spawnHost(host)
if err != nil {
log.Printf("AddRemote %s failed: %v", host, err)
return nil, err
}
p.remoteMu.Lock()
p.remoteHosts[host] = remote
p.remoteMu.Unlock()
// connectedRemotes.Set(float64(p.RemoteCount()))
log.Printf("Connected to remote host %s", host)
go p.pingLoop(remote)
go p.initializeRemote(remote)
go p.SendNegotiation()
return remote, nil
}
func (p *SimpleGrainPool[V]) initializeRemote(remote Host) {
remotesIds := remote.GetActorIds()
p.remoteMu.Lock()
for _, id := range remotesIds {
p.localMu.Lock()
delete(p.grains, id)
p.localMu.Unlock()
if _, exists := p.remoteOwners[id]; !exists {
p.remoteOwners[id] = remote
}
}
p.remoteMu.Unlock()
}
func (p *SimpleGrainPool[V]) RemoveHost(host string) {
p.remoteMu.Lock()
remote, exists := p.remoteHosts[host]
if exists {
go remote.Close()
delete(p.remoteHosts, host)
}
count := 0
for id, owner := range p.remoteOwners {
if owner.Name() == host {
count++
delete(p.remoteOwners, id)
}
}
log.Printf("Removing host %s, grains: %d", host, count)
p.remoteMu.Unlock()
if exists {
remote.Close()
}
// connectedRemotes.Set(float64(p.RemoteCount()))
}
func (p *SimpleGrainPool[V]) RemoteCount() int {
p.remoteMu.RLock()
defer p.remoteMu.RUnlock()
return len(p.remoteHosts)
}
// RemoteHostNames returns a snapshot of connected remote host identifiers.
func (p *SimpleGrainPool[V]) RemoteHostNames() []string {
p.remoteMu.RLock()
defer p.remoteMu.RUnlock()
hosts := make([]string, 0, len(p.remoteHosts))
for host := range p.remoteHosts {
hosts = append(hosts, host)
}
return hosts
}
func (p *SimpleGrainPool[V]) IsKnown(host string) bool {
if host == p.hostname {
return true
}
p.remoteMu.RLock()
defer p.remoteMu.RUnlock()
_, ok := p.remoteHosts[host]
return ok
}
func (p *SimpleGrainPool[V]) pingLoop(remote Host) {
remote.Ping()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
if !remote.Ping() {
if !remote.IsHealthy() {
log.Printf("Remote %s unhealthy, removing", remote.Name())
p.Close()
p.RemoveHost(remote.Name())
return
}
continue
}
}
}
func (p *SimpleGrainPool[V]) IsHealthy() bool {
p.remoteMu.RLock()
defer p.remoteMu.RUnlock()
for _, r := range p.remoteHosts {
if !r.IsHealthy() {
return false
}
}
return true
}
func (p *SimpleGrainPool[V]) Negotiate(otherHosts []string) {
for _, host := range otherHosts {
if host != p.hostname {
p.remoteMu.RLock()
_, ok := p.remoteHosts[host]
p.remoteMu.RUnlock()
if !ok {
go p.AddRemote(host)
}
}
}
}
func (p *SimpleGrainPool[V]) SendNegotiation() {
//negotiationCount.Inc()
p.remoteMu.RLock()
hosts := make([]string, 0, len(p.remoteHosts)+1)
hosts = append(hosts, p.hostname)
remotes := make([]Host, 0, len(p.remoteHosts))
for h, r := range p.remoteHosts {
hosts = append(hosts, h)
remotes = append(remotes, r)
}
p.remoteMu.RUnlock()
p.forAllHosts(func(remote Host) {
knownByRemote, err := remote.Negotiate(hosts)
if err != nil {
log.Printf("Negotiate with %s failed: %v", remote.Name(), err)
return
}
for _, h := range knownByRemote {
if !p.IsKnown(h) {
go p.AddRemote(h)
}
}
})
}
func (p *SimpleGrainPool[V]) forAllHosts(fn func(Host)) {
p.remoteMu.RLock()
rh := maps.Clone(p.remoteHosts)
p.remoteMu.RUnlock()
wg := sync.WaitGroup{}
for _, host := range rh {
wg.Go(func() { fn(host) })
}
for name, host := range rh {
if !host.IsHealthy() {
host.Close()
p.remoteMu.Lock()
delete(p.remoteHosts, name)
p.remoteMu.Unlock()
}
}
}
func (p *SimpleGrainPool[V]) broadcastOwnership(ids []uint64) {
if len(ids) == 0 {
return
}
p.forAllHosts(func(rh Host) {
rh.AnnounceOwnership(p.hostname, ids)
})
log.Printf("%s taking ownership of %d ids", p.hostname, len(ids))
// go p.statsUpdate()
}
func (p *SimpleGrainPool[V]) getOrClaimGrain(id uint64) (Grain[V], error) {
p.localMu.RLock()
grain, exists := p.grains[id]
p.localMu.RUnlock()
if exists && grain != nil {
return grain, nil
}
grain, err := p.spawn(id)
if err != nil {
return nil, err
}
p.localMu.Lock()
p.grains[id] = grain
p.localMu.Unlock()
go p.broadcastOwnership([]uint64{id})
return grain, nil
}
// // ErrNotOwner is returned when a cart belongs to another host.
// var ErrNotOwner = fmt.Errorf("not owner")
// Apply applies a mutation to a grain.
func (p *SimpleGrainPool[V]) Apply(id uint64, mutation ...proto.Message) (*MutationResult[*V], error) {
grain, err := p.getOrClaimGrain(id)
if err != nil {
return nil, err
}
mutations, err := p.mutationRegistry.Apply(grain, mutation...)
if err != nil {
return nil, err
}
if p.storage != nil {
go func() {
if err := p.storage.AppendEvent(id, mutation...); err != nil {
log.Printf("failed to store mutation for grain %d: %v", id, err)
}
}()
}
result, err := grain.GetCurrentState()
if err != nil {
return nil, err
}
return &MutationResult[*V]{
Result: result,
Mutations: mutations,
}, nil
}
// Get returns the current state of a grain.
func (p *SimpleGrainPool[V]) Get(id uint64) (*V, error) {
grain, err := p.getOrClaimGrain(id)
if err != nil {
return nil, err
}
return grain.GetCurrentState()
}
// OwnerHost reports the remote owner (if any) for the supplied cart id.
func (p *SimpleGrainPool[V]) OwnerHost(id uint64) (Host, bool) {
p.remoteMu.RLock()
defer p.remoteMu.RUnlock()
owner, ok := p.remoteOwners[id]
return owner, ok
}
// Hostname returns the local hostname (pod IP).
func (p *SimpleGrainPool[V]) Hostname() string {
return p.hostname
}
// Close notifies remotes that this host is shutting down.
func (p *SimpleGrainPool[V]) Close() {
p.forAllHosts(func(rh Host) {
rh.Close()
})
if p.purgeTicker != nil {
p.purgeTicker.Stop()
}
}

98
pkg/actor/state.go Normal file
View File

@@ -0,0 +1,98 @@
package actor
import (
"bufio"
"encoding/json"
"errors"
"io"
"time"
"github.com/gogo/protobuf/proto"
)
type StateStorage struct {
registry MutationRegistry
}
type StorageEvent struct {
Type string `json:"type"`
TimeStamp time.Time `json:"timestamp"`
Mutation proto.Message `json:"mutation"`
}
type rawEvent struct {
Type string `json:"type"`
TimeStamp time.Time `json:"timestamp"`
Mutation json.RawMessage `json:"mutation"`
}
func NewState(registry MutationRegistry) *StateStorage {
return &StateStorage{
registry: registry,
}
}
var ErrUnknownType = errors.New("unknown type")
func (s *StateStorage) Load(r io.Reader, onMessage func(msg proto.Message)) error {
var err error
var evt *StorageEvent
scanner := bufio.NewScanner(r)
for err == nil {
evt, err = s.Read(scanner)
if err == nil {
onMessage(evt.Mutation)
}
}
if err == io.EOF {
return nil
}
return err
}
func (s *StateStorage) Append(io io.Writer, mutation proto.Message, timeStamp time.Time) error {
typeName, ok := s.registry.GetTypeName(mutation)
if !ok {
return ErrUnknownType
}
event := &StorageEvent{
Type: typeName,
TimeStamp: timeStamp,
Mutation: mutation,
}
jsonBytes, err := json.Marshal(event)
if err != nil {
return err
}
if _, err := io.Write(jsonBytes); err != nil {
return err
}
io.Write([]byte("\n"))
return nil
}
func (s *StateStorage) Read(r *bufio.Scanner) (*StorageEvent, error) {
var event rawEvent
if r.Scan() {
b := r.Bytes()
err := json.Unmarshal(b, &event)
if err != nil {
return nil, err
}
typeName := event.Type
mutation, ok := s.registry.Create(typeName)
if !ok {
return nil, ErrUnknownType
}
if err := json.Unmarshal(event.Mutation, mutation); err != nil {
return nil, err
}
return &StorageEvent{
Type: typeName,
TimeStamp: event.TimeStamp,
Mutation: mutation,
}, r.Err()
}
return nil, io.EOF
}

View File

@@ -0,0 +1,73 @@
package discovery
import (
"context"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/cache"
toolsWatch "k8s.io/client-go/tools/watch"
)
type K8sDiscovery struct {
ctx context.Context
client *kubernetes.Clientset
}
func (k *K8sDiscovery) Discover() ([]string, error) {
return k.DiscoverInNamespace("")
}
func (k *K8sDiscovery) DiscoverInNamespace(namespace string) ([]string, error) {
pods, err := k.client.CoreV1().Pods(namespace).List(k.ctx, metav1.ListOptions{
LabelSelector: "actor-pool=cart",
})
if err != nil {
return nil, err
}
hosts := make([]string, 0, len(pods.Items))
for _, pod := range pods.Items {
hosts = append(hosts, pod.Status.PodIP)
}
return hosts, nil
}
type HostChange struct {
Host string
Type watch.EventType
}
func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
timeout := int64(30)
watcherFn := func(options metav1.ListOptions) (watch.Interface, error) {
return k.client.CoreV1().Pods("").Watch(k.ctx, metav1.ListOptions{
LabelSelector: "actor-pool=cart",
TimeoutSeconds: &timeout,
})
}
watcher, err := toolsWatch.NewRetryWatcherWithContext(k.ctx, "1", &cache.ListWatch{WatchFunc: watcherFn})
if err != nil {
return nil, err
}
ch := make(chan HostChange)
go func() {
for event := range watcher.ResultChan() {
pod := event.Object.(*v1.Pod)
// log.Printf("pod change %+v", pod.Status.Phase == v1.PodRunning)
ch <- HostChange{
Host: pod.Status.PodIP,
Type: event.Type,
}
}
}()
return ch, nil
}
func NewK8sDiscovery(client *kubernetes.Clientset) *K8sDiscovery {
return &K8sDiscovery{
ctx: context.Background(),
client: client,
}
}

View File

@@ -1,82 +1,12 @@
package main
package discovery
import (
"context"
"sync"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/cache"
toolsWatch "k8s.io/client-go/tools/watch"
)
type Discovery interface {
Discover() ([]string, error)
Watch() (<-chan HostChange, error)
}
type K8sDiscovery struct {
ctx context.Context
client *kubernetes.Clientset
}
func (k *K8sDiscovery) Discover() ([]string, error) {
return k.DiscoverInNamespace("")
}
func (k *K8sDiscovery) DiscoverInNamespace(namespace string) ([]string, error) {
pods, err := k.client.CoreV1().Pods(namespace).List(k.ctx, metav1.ListOptions{
LabelSelector: "actor-pool=cart",
})
if err != nil {
return nil, err
}
hosts := make([]string, 0, len(pods.Items))
for _, pod := range pods.Items {
hosts = append(hosts, pod.Status.PodIP)
}
return hosts, nil
}
type HostChange struct {
Host string
Type watch.EventType
}
func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
timeout := int64(30)
watcherFn := func(options metav1.ListOptions) (watch.Interface, error) {
return k.client.CoreV1().Pods("").Watch(k.ctx, metav1.ListOptions{
LabelSelector: "actor-pool=cart",
TimeoutSeconds: &timeout,
})
}
watcher, err := toolsWatch.NewRetryWatcher("1", &cache.ListWatch{WatchFunc: watcherFn})
if err != nil {
return nil, err
}
ch := make(chan HostChange)
go func() {
for event := range watcher.ResultChan() {
pod := event.Object.(*v1.Pod)
ch <- HostChange{
Host: pod.Status.PodIP,
Type: event.Type,
}
}
}()
return ch, nil
}
func NewK8sDiscovery(client *kubernetes.Clientset) *K8sDiscovery {
return &K8sDiscovery{
ctx: context.Background(),
client: client,
}
}
// MockDiscovery is an in-memory Discovery implementation for tests.
// It allows deterministic injection of host additions/removals without
// depending on Kubernetes API machinery.

View File

@@ -1,4 +1,4 @@
package main
package discovery
import (
"testing"

6
pkg/discovery/types.go Normal file
View File

@@ -0,0 +1,6 @@
package discovery
type Discovery interface {
Discover() ([]string, error)
Watch() (<-chan HostChange, error)
}

View File

@@ -0,0 +1,582 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.5
// protoc v5.29.3
// source: control_plane.proto
package messages
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// Empty request placeholder (common pattern).
type Empty struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Empty) Reset() {
*x = Empty{}
mi := &file_control_plane_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Empty) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Empty) ProtoMessage() {}
func (x *Empty) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Empty.ProtoReflect.Descriptor instead.
func (*Empty) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{0}
}
// Ping reply includes responding host and its current unix time (seconds).
type PingReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
UnixTime int64 `protobuf:"varint,2,opt,name=unix_time,json=unixTime,proto3" json:"unix_time,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PingReply) Reset() {
*x = PingReply{}
mi := &file_control_plane_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PingReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PingReply) ProtoMessage() {}
func (x *PingReply) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PingReply.ProtoReflect.Descriptor instead.
func (*PingReply) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{1}
}
func (x *PingReply) GetHost() string {
if x != nil {
return x.Host
}
return ""
}
func (x *PingReply) GetUnixTime() int64 {
if x != nil {
return x.UnixTime
}
return 0
}
// NegotiateRequest carries the caller's full view of known hosts (including self).
type NegotiateRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
KnownHosts []string `protobuf:"bytes,1,rep,name=known_hosts,json=knownHosts,proto3" json:"known_hosts,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *NegotiateRequest) Reset() {
*x = NegotiateRequest{}
mi := &file_control_plane_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *NegotiateRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*NegotiateRequest) ProtoMessage() {}
func (x *NegotiateRequest) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use NegotiateRequest.ProtoReflect.Descriptor instead.
func (*NegotiateRequest) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{2}
}
func (x *NegotiateRequest) GetKnownHosts() []string {
if x != nil {
return x.KnownHosts
}
return nil
}
// NegotiateReply returns the callee's healthy hosts (including itself).
type NegotiateReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Hosts []string `protobuf:"bytes,1,rep,name=hosts,proto3" json:"hosts,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *NegotiateReply) Reset() {
*x = NegotiateReply{}
mi := &file_control_plane_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *NegotiateReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*NegotiateReply) ProtoMessage() {}
func (x *NegotiateReply) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use NegotiateReply.ProtoReflect.Descriptor instead.
func (*NegotiateReply) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{3}
}
func (x *NegotiateReply) GetHosts() []string {
if x != nil {
return x.Hosts
}
return nil
}
// CartIdsReply returns the list of cart IDs (string form) currently owned locally.
type ActorIdsReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Ids []uint64 `protobuf:"varint,1,rep,packed,name=ids,proto3" json:"ids,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ActorIdsReply) Reset() {
*x = ActorIdsReply{}
mi := &file_control_plane_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ActorIdsReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ActorIdsReply) ProtoMessage() {}
func (x *ActorIdsReply) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ActorIdsReply.ProtoReflect.Descriptor instead.
func (*ActorIdsReply) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{4}
}
func (x *ActorIdsReply) GetIds() []uint64 {
if x != nil {
return x.Ids
}
return nil
}
// OwnerChangeAck retained as response type for Closing RPC (ConfirmOwner removed).
type OwnerChangeAck struct {
state protoimpl.MessageState `protogen:"open.v1"`
Accepted bool `protobuf:"varint,1,opt,name=accepted,proto3" json:"accepted,omitempty"`
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *OwnerChangeAck) Reset() {
*x = OwnerChangeAck{}
mi := &file_control_plane_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *OwnerChangeAck) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*OwnerChangeAck) ProtoMessage() {}
func (x *OwnerChangeAck) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use OwnerChangeAck.ProtoReflect.Descriptor instead.
func (*OwnerChangeAck) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{5}
}
func (x *OwnerChangeAck) GetAccepted() bool {
if x != nil {
return x.Accepted
}
return false
}
func (x *OwnerChangeAck) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
// ClosingNotice notifies peers this host is terminating (so they can drop / re-resolve).
type ClosingNotice struct {
state protoimpl.MessageState `protogen:"open.v1"`
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ClosingNotice) Reset() {
*x = ClosingNotice{}
mi := &file_control_plane_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ClosingNotice) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ClosingNotice) ProtoMessage() {}
func (x *ClosingNotice) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ClosingNotice.ProtoReflect.Descriptor instead.
func (*ClosingNotice) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{6}
}
func (x *ClosingNotice) GetHost() string {
if x != nil {
return x.Host
}
return ""
}
// OwnershipAnnounce broadcasts first-touch ownership claims for cart IDs.
// First claim wins; receivers SHOULD NOT overwrite an existing different owner.
type OwnershipAnnounce struct {
state protoimpl.MessageState `protogen:"open.v1"`
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` // announcing host
Ids []uint64 `protobuf:"varint,2,rep,packed,name=ids,proto3" json:"ids,omitempty"` // newly claimed cart ids
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *OwnershipAnnounce) Reset() {
*x = OwnershipAnnounce{}
mi := &file_control_plane_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *OwnershipAnnounce) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*OwnershipAnnounce) ProtoMessage() {}
func (x *OwnershipAnnounce) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use OwnershipAnnounce.ProtoReflect.Descriptor instead.
func (*OwnershipAnnounce) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{7}
}
func (x *OwnershipAnnounce) GetHost() string {
if x != nil {
return x.Host
}
return ""
}
func (x *OwnershipAnnounce) GetIds() []uint64 {
if x != nil {
return x.Ids
}
return nil
}
// ExpiryAnnounce broadcasts that a host evicted the provided cart IDs.
type ExpiryAnnounce struct {
state protoimpl.MessageState `protogen:"open.v1"`
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
Ids []uint64 `protobuf:"varint,2,rep,packed,name=ids,proto3" json:"ids,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExpiryAnnounce) Reset() {
*x = ExpiryAnnounce{}
mi := &file_control_plane_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExpiryAnnounce) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExpiryAnnounce) ProtoMessage() {}
func (x *ExpiryAnnounce) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExpiryAnnounce.ProtoReflect.Descriptor instead.
func (*ExpiryAnnounce) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{8}
}
func (x *ExpiryAnnounce) GetHost() string {
if x != nil {
return x.Host
}
return ""
}
func (x *ExpiryAnnounce) GetIds() []uint64 {
if x != nil {
return x.Ids
}
return nil
}
var File_control_plane_proto protoreflect.FileDescriptor
var file_control_plane_proto_rawDesc = string([]byte{
0x0a, 0x13, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22,
0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x3c, 0x0a, 0x09, 0x50, 0x69, 0x6e, 0x67,
0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20,
0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x75, 0x6e, 0x69,
0x78, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x75, 0x6e,
0x69, 0x78, 0x54, 0x69, 0x6d, 0x65, 0x22, 0x33, 0x0a, 0x10, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69,
0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x6b, 0x6e,
0x6f, 0x77, 0x6e, 0x5f, 0x68, 0x6f, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52,
0x0a, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x48, 0x6f, 0x73, 0x74, 0x73, 0x22, 0x26, 0x0a, 0x0e, 0x4e,
0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x14, 0x0a,
0x05, 0x68, 0x6f, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x68, 0x6f,
0x73, 0x74, 0x73, 0x22, 0x21, 0x0a, 0x0d, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x73, 0x52,
0x65, 0x70, 0x6c, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28,
0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22, 0x46, 0x0a, 0x0e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43,
0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65,
0x70, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65,
0x70, 0x74, 0x65, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18,
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x23,
0x0a, 0x0d, 0x43, 0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67, 0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x12,
0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68,
0x6f, 0x73, 0x74, 0x22, 0x39, 0x0a, 0x11, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70,
0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03,
0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22, 0x36,
0x0a, 0x0e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x79, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65,
0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
0x68, 0x6f, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28,
0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x32, 0x8d, 0x03, 0x0a, 0x0c, 0x43, 0x6f, 0x6e, 0x74, 0x72,
0x6f, 0x6c, 0x50, 0x6c, 0x61, 0x6e, 0x65, 0x12, 0x2c, 0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, 0x12,
0x0f, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79,
0x1a, 0x13, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x50, 0x69, 0x6e, 0x67,
0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x41, 0x0a, 0x09, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69, 0x61,
0x74, 0x65, 0x12, 0x1a, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4e, 0x65,
0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18,
0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69,
0x61, 0x74, 0x65, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x3c, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4c,
0x6f, 0x63, 0x61, 0x6c, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x73, 0x12, 0x0f, 0x2e, 0x6d,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x17, 0x2e,
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64,
0x73, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x4a, 0x0a, 0x11, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e,
0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x12, 0x1b, 0x2e, 0x6d, 0x65,
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70,
0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x1a, 0x18, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61,
0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x41,
0x63, 0x6b, 0x12, 0x44, 0x0a, 0x0e, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x45, 0x78,
0x70, 0x69, 0x72, 0x79, 0x12, 0x18, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
0x45, 0x78, 0x70, 0x69, 0x72, 0x79, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x1a, 0x18,
0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43,
0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x12, 0x3c, 0x0a, 0x07, 0x43, 0x6c, 0x6f, 0x73,
0x69, 0x6e, 0x67, 0x12, 0x17, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43,
0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67, 0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x1a, 0x18, 0x2e, 0x6d,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61,
0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x42, 0x2e, 0x5a, 0x2c, 0x67, 0x69, 0x74, 0x2e, 0x74, 0x6f,
0x72, 0x6e, 0x62, 0x65, 0x72, 0x67, 0x2e, 0x6d, 0x65, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72,
0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x3b, 0x6d, 0x65,
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
})
var (
file_control_plane_proto_rawDescOnce sync.Once
file_control_plane_proto_rawDescData []byte
)
func file_control_plane_proto_rawDescGZIP() []byte {
file_control_plane_proto_rawDescOnce.Do(func() {
file_control_plane_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_control_plane_proto_rawDesc), len(file_control_plane_proto_rawDesc)))
})
return file_control_plane_proto_rawDescData
}
var file_control_plane_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
var file_control_plane_proto_goTypes = []any{
(*Empty)(nil), // 0: messages.Empty
(*PingReply)(nil), // 1: messages.PingReply
(*NegotiateRequest)(nil), // 2: messages.NegotiateRequest
(*NegotiateReply)(nil), // 3: messages.NegotiateReply
(*ActorIdsReply)(nil), // 4: messages.ActorIdsReply
(*OwnerChangeAck)(nil), // 5: messages.OwnerChangeAck
(*ClosingNotice)(nil), // 6: messages.ClosingNotice
(*OwnershipAnnounce)(nil), // 7: messages.OwnershipAnnounce
(*ExpiryAnnounce)(nil), // 8: messages.ExpiryAnnounce
}
var file_control_plane_proto_depIdxs = []int32{
0, // 0: messages.ControlPlane.Ping:input_type -> messages.Empty
2, // 1: messages.ControlPlane.Negotiate:input_type -> messages.NegotiateRequest
0, // 2: messages.ControlPlane.GetLocalActorIds:input_type -> messages.Empty
7, // 3: messages.ControlPlane.AnnounceOwnership:input_type -> messages.OwnershipAnnounce
8, // 4: messages.ControlPlane.AnnounceExpiry:input_type -> messages.ExpiryAnnounce
6, // 5: messages.ControlPlane.Closing:input_type -> messages.ClosingNotice
1, // 6: messages.ControlPlane.Ping:output_type -> messages.PingReply
3, // 7: messages.ControlPlane.Negotiate:output_type -> messages.NegotiateReply
4, // 8: messages.ControlPlane.GetLocalActorIds:output_type -> messages.ActorIdsReply
5, // 9: messages.ControlPlane.AnnounceOwnership:output_type -> messages.OwnerChangeAck
5, // 10: messages.ControlPlane.AnnounceExpiry:output_type -> messages.OwnerChangeAck
5, // 11: messages.ControlPlane.Closing:output_type -> messages.OwnerChangeAck
6, // [6:12] is the sub-list for method output_type
0, // [0:6] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_control_plane_proto_init() }
func file_control_plane_proto_init() {
if File_control_plane_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_control_plane_proto_rawDesc), len(file_control_plane_proto_rawDesc)),
NumEnums: 0,
NumMessages: 9,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_control_plane_proto_goTypes,
DependencyIndexes: file_control_plane_proto_depIdxs,
MessageInfos: file_control_plane_proto_msgTypes,
}.Build()
File_control_plane_proto = out.File
file_control_plane_proto_goTypes = nil
file_control_plane_proto_depIdxs = nil
}

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v3.21.12
// - protoc v5.29.3
// source: control_plane.proto
package messages
@@ -21,7 +21,9 @@ const _ = grpc.SupportPackageIsVersion9
const (
ControlPlane_Ping_FullMethodName = "/messages.ControlPlane/Ping"
ControlPlane_Negotiate_FullMethodName = "/messages.ControlPlane/Negotiate"
ControlPlane_GetCartIds_FullMethodName = "/messages.ControlPlane/GetCartIds"
ControlPlane_GetLocalActorIds_FullMethodName = "/messages.ControlPlane/GetLocalActorIds"
ControlPlane_AnnounceOwnership_FullMethodName = "/messages.ControlPlane/AnnounceOwnership"
ControlPlane_AnnounceExpiry_FullMethodName = "/messages.ControlPlane/AnnounceExpiry"
ControlPlane_Closing_FullMethodName = "/messages.ControlPlane/Closing"
)
@@ -36,7 +38,11 @@ type ControlPlaneClient interface {
// Negotiate merges host views; used during discovery & convergence.
Negotiate(ctx context.Context, in *NegotiateRequest, opts ...grpc.CallOption) (*NegotiateReply, error)
// GetCartIds lists currently owned cart IDs on this node.
GetCartIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*CartIdsReply, error)
GetLocalActorIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*ActorIdsReply, error)
// Ownership announcement: first-touch claim broadcast (idempotent; best-effort).
AnnounceOwnership(ctx context.Context, in *OwnershipAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error)
// Expiry announcement: drop remote ownership hints when local TTL expires.
AnnounceExpiry(ctx context.Context, in *ExpiryAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error)
// Closing announces graceful shutdown so peers can proactively adjust.
Closing(ctx context.Context, in *ClosingNotice, opts ...grpc.CallOption) (*OwnerChangeAck, error)
}
@@ -69,10 +75,30 @@ func (c *controlPlaneClient) Negotiate(ctx context.Context, in *NegotiateRequest
return out, nil
}
func (c *controlPlaneClient) GetCartIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*CartIdsReply, error) {
func (c *controlPlaneClient) GetLocalActorIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*ActorIdsReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CartIdsReply)
err := c.cc.Invoke(ctx, ControlPlane_GetCartIds_FullMethodName, in, out, cOpts...)
out := new(ActorIdsReply)
err := c.cc.Invoke(ctx, ControlPlane_GetLocalActorIds_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *controlPlaneClient) AnnounceOwnership(ctx context.Context, in *OwnershipAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(OwnerChangeAck)
err := c.cc.Invoke(ctx, ControlPlane_AnnounceOwnership_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *controlPlaneClient) AnnounceExpiry(ctx context.Context, in *ExpiryAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(OwnerChangeAck)
err := c.cc.Invoke(ctx, ControlPlane_AnnounceExpiry_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
@@ -100,7 +126,11 @@ type ControlPlaneServer interface {
// Negotiate merges host views; used during discovery & convergence.
Negotiate(context.Context, *NegotiateRequest) (*NegotiateReply, error)
// GetCartIds lists currently owned cart IDs on this node.
GetCartIds(context.Context, *Empty) (*CartIdsReply, error)
GetLocalActorIds(context.Context, *Empty) (*ActorIdsReply, error)
// Ownership announcement: first-touch claim broadcast (idempotent; best-effort).
AnnounceOwnership(context.Context, *OwnershipAnnounce) (*OwnerChangeAck, error)
// Expiry announcement: drop remote ownership hints when local TTL expires.
AnnounceExpiry(context.Context, *ExpiryAnnounce) (*OwnerChangeAck, error)
// Closing announces graceful shutdown so peers can proactively adjust.
Closing(context.Context, *ClosingNotice) (*OwnerChangeAck, error)
mustEmbedUnimplementedControlPlaneServer()
@@ -119,8 +149,14 @@ func (UnimplementedControlPlaneServer) Ping(context.Context, *Empty) (*PingReply
func (UnimplementedControlPlaneServer) Negotiate(context.Context, *NegotiateRequest) (*NegotiateReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method Negotiate not implemented")
}
func (UnimplementedControlPlaneServer) GetCartIds(context.Context, *Empty) (*CartIdsReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetCartIds not implemented")
func (UnimplementedControlPlaneServer) GetLocalActorIds(context.Context, *Empty) (*ActorIdsReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetLocalActorIds not implemented")
}
func (UnimplementedControlPlaneServer) AnnounceOwnership(context.Context, *OwnershipAnnounce) (*OwnerChangeAck, error) {
return nil, status.Errorf(codes.Unimplemented, "method AnnounceOwnership not implemented")
}
func (UnimplementedControlPlaneServer) AnnounceExpiry(context.Context, *ExpiryAnnounce) (*OwnerChangeAck, error) {
return nil, status.Errorf(codes.Unimplemented, "method AnnounceExpiry not implemented")
}
func (UnimplementedControlPlaneServer) Closing(context.Context, *ClosingNotice) (*OwnerChangeAck, error) {
return nil, status.Errorf(codes.Unimplemented, "method Closing not implemented")
@@ -182,20 +218,56 @@ func _ControlPlane_Negotiate_Handler(srv interface{}, ctx context.Context, dec f
return interceptor(ctx, in, info, handler)
}
func _ControlPlane_GetCartIds_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
func _ControlPlane_GetLocalActorIds_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(Empty)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ControlPlaneServer).GetCartIds(ctx, in)
return srv.(ControlPlaneServer).GetLocalActorIds(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ControlPlane_GetCartIds_FullMethodName,
FullMethod: ControlPlane_GetLocalActorIds_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ControlPlaneServer).GetCartIds(ctx, req.(*Empty))
return srv.(ControlPlaneServer).GetLocalActorIds(ctx, req.(*Empty))
}
return interceptor(ctx, in, info, handler)
}
func _ControlPlane_AnnounceOwnership_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(OwnershipAnnounce)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ControlPlaneServer).AnnounceOwnership(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ControlPlane_AnnounceOwnership_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ControlPlaneServer).AnnounceOwnership(ctx, req.(*OwnershipAnnounce))
}
return interceptor(ctx, in, info, handler)
}
func _ControlPlane_AnnounceExpiry_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ExpiryAnnounce)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ControlPlaneServer).AnnounceExpiry(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ControlPlane_AnnounceExpiry_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ControlPlaneServer).AnnounceExpiry(ctx, req.(*ExpiryAnnounce))
}
return interceptor(ctx, in, info, handler)
}
@@ -234,8 +306,16 @@ var ControlPlane_ServiceDesc = grpc.ServiceDesc{
Handler: _ControlPlane_Negotiate_Handler,
},
{
MethodName: "GetCartIds",
Handler: _ControlPlane_GetCartIds_Handler,
MethodName: "GetLocalActorIds",
Handler: _ControlPlane_GetLocalActorIds_Handler,
},
{
MethodName: "AnnounceOwnership",
Handler: _ControlPlane_AnnounceOwnership_Handler,
},
{
MethodName: "AnnounceExpiry",
Handler: _ControlPlane_AnnounceExpiry_Handler,
},
{
MethodName: "Closing",

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.10
// protoc v3.21.12
// protoc-gen-go v1.36.5
// protoc v5.29.3
// source: messages.proto
package messages
@@ -21,30 +21,26 @@ const (
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type AddRequest struct {
type ClearCartRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Quantity int32 `protobuf:"varint,1,opt,name=quantity,proto3" json:"quantity,omitempty"`
Sku string `protobuf:"bytes,2,opt,name=sku,proto3" json:"sku,omitempty"`
Country string `protobuf:"bytes,3,opt,name=country,proto3" json:"country,omitempty"`
StoreId *string `protobuf:"bytes,4,opt,name=storeId,proto3,oneof" json:"storeId,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AddRequest) Reset() {
*x = AddRequest{}
func (x *ClearCartRequest) Reset() {
*x = ClearCartRequest{}
mi := &file_messages_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AddRequest) String() string {
func (x *ClearCartRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AddRequest) ProtoMessage() {}
func (*ClearCartRequest) ProtoMessage() {}
func (x *AddRequest) ProtoReflect() protoreflect.Message {
func (x *ClearCartRequest) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
@@ -56,86 +52,14 @@ func (x *AddRequest) ProtoReflect() protoreflect.Message {
return mi.MessageOf(x)
}
// Deprecated: Use AddRequest.ProtoReflect.Descriptor instead.
func (*AddRequest) Descriptor() ([]byte, []int) {
// Deprecated: Use ClearCartRequest.ProtoReflect.Descriptor instead.
func (*ClearCartRequest) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{0}
}
func (x *AddRequest) GetQuantity() int32 {
if x != nil {
return x.Quantity
}
return 0
}
func (x *AddRequest) GetSku() string {
if x != nil {
return x.Sku
}
return ""
}
func (x *AddRequest) GetCountry() string {
if x != nil {
return x.Country
}
return ""
}
func (x *AddRequest) GetStoreId() string {
if x != nil && x.StoreId != nil {
return *x.StoreId
}
return ""
}
type SetCartRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Items []*AddRequest `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SetCartRequest) Reset() {
*x = SetCartRequest{}
mi := &file_messages_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SetCartRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SetCartRequest) ProtoMessage() {}
func (x *SetCartRequest) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SetCartRequest.ProtoReflect.Descriptor instead.
func (*SetCartRequest) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{1}
}
func (x *SetCartRequest) GetItems() []*AddRequest {
if x != nil {
return x.Items
}
return nil
}
type AddItem struct {
state protoimpl.MessageState `protogen:"open.v1"`
ItemId int64 `protobuf:"varint,1,opt,name=item_id,json=itemId,proto3" json:"item_id,omitempty"`
ItemId uint32 `protobuf:"varint,1,opt,name=item_id,json=itemId,proto3" json:"item_id,omitempty"`
Quantity int32 `protobuf:"varint,2,opt,name=quantity,proto3" json:"quantity,omitempty"`
Price int64 `protobuf:"varint,3,opt,name=price,proto3" json:"price,omitempty"`
OrgPrice int64 `protobuf:"varint,9,opt,name=orgPrice,proto3" json:"orgPrice,omitempty"`
@@ -157,13 +81,14 @@ type AddItem struct {
Country string `protobuf:"bytes,21,opt,name=country,proto3" json:"country,omitempty"`
Outlet *string `protobuf:"bytes,12,opt,name=outlet,proto3,oneof" json:"outlet,omitempty"`
StoreId *string `protobuf:"bytes,22,opt,name=storeId,proto3,oneof" json:"storeId,omitempty"`
ParentId *uint32 `protobuf:"varint,23,opt,name=parentId,proto3,oneof" json:"parentId,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AddItem) Reset() {
*x = AddItem{}
mi := &file_messages_proto_msgTypes[2]
mi := &file_messages_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -175,7 +100,7 @@ func (x *AddItem) String() string {
func (*AddItem) ProtoMessage() {}
func (x *AddItem) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[2]
mi := &file_messages_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -188,10 +113,10 @@ func (x *AddItem) ProtoReflect() protoreflect.Message {
// Deprecated: Use AddItem.ProtoReflect.Descriptor instead.
func (*AddItem) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{2}
return file_messages_proto_rawDescGZIP(), []int{1}
}
func (x *AddItem) GetItemId() int64 {
func (x *AddItem) GetItemId() uint32 {
if x != nil {
return x.ItemId
}
@@ -345,16 +270,23 @@ func (x *AddItem) GetStoreId() string {
return ""
}
func (x *AddItem) GetParentId() uint32 {
if x != nil && x.ParentId != nil {
return *x.ParentId
}
return 0
}
type RemoveItem struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id int64 `protobuf:"varint,1,opt,name=Id,proto3" json:"Id,omitempty"`
Id uint32 `protobuf:"varint,1,opt,name=Id,proto3" json:"Id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RemoveItem) Reset() {
*x = RemoveItem{}
mi := &file_messages_proto_msgTypes[3]
mi := &file_messages_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -366,7 +298,7 @@ func (x *RemoveItem) String() string {
func (*RemoveItem) ProtoMessage() {}
func (x *RemoveItem) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[3]
mi := &file_messages_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -379,10 +311,10 @@ func (x *RemoveItem) ProtoReflect() protoreflect.Message {
// Deprecated: Use RemoveItem.ProtoReflect.Descriptor instead.
func (*RemoveItem) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{3}
return file_messages_proto_rawDescGZIP(), []int{2}
}
func (x *RemoveItem) GetId() int64 {
func (x *RemoveItem) GetId() uint32 {
if x != nil {
return x.Id
}
@@ -391,7 +323,7 @@ func (x *RemoveItem) GetId() int64 {
type ChangeQuantity struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
Id uint32 `protobuf:"varint,1,opt,name=Id,proto3" json:"Id,omitempty"`
Quantity int32 `protobuf:"varint,2,opt,name=quantity,proto3" json:"quantity,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
@@ -399,7 +331,7 @@ type ChangeQuantity struct {
func (x *ChangeQuantity) Reset() {
*x = ChangeQuantity{}
mi := &file_messages_proto_msgTypes[4]
mi := &file_messages_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -411,7 +343,7 @@ func (x *ChangeQuantity) String() string {
func (*ChangeQuantity) ProtoMessage() {}
func (x *ChangeQuantity) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[4]
mi := &file_messages_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -424,10 +356,10 @@ func (x *ChangeQuantity) ProtoReflect() protoreflect.Message {
// Deprecated: Use ChangeQuantity.ProtoReflect.Descriptor instead.
func (*ChangeQuantity) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{4}
return file_messages_proto_rawDescGZIP(), []int{3}
}
func (x *ChangeQuantity) GetId() int64 {
func (x *ChangeQuantity) GetId() uint32 {
if x != nil {
return x.Id
}
@@ -444,7 +376,7 @@ func (x *ChangeQuantity) GetQuantity() int32 {
type SetDelivery struct {
state protoimpl.MessageState `protogen:"open.v1"`
Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"`
Items []int64 `protobuf:"varint,2,rep,packed,name=items,proto3" json:"items,omitempty"`
Items []uint32 `protobuf:"varint,2,rep,packed,name=items,proto3" json:"items,omitempty"`
PickupPoint *PickupPoint `protobuf:"bytes,3,opt,name=pickupPoint,proto3,oneof" json:"pickupPoint,omitempty"`
Country string `protobuf:"bytes,4,opt,name=country,proto3" json:"country,omitempty"`
Zip string `protobuf:"bytes,5,opt,name=zip,proto3" json:"zip,omitempty"`
@@ -456,7 +388,7 @@ type SetDelivery struct {
func (x *SetDelivery) Reset() {
*x = SetDelivery{}
mi := &file_messages_proto_msgTypes[5]
mi := &file_messages_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -468,7 +400,7 @@ func (x *SetDelivery) String() string {
func (*SetDelivery) ProtoMessage() {}
func (x *SetDelivery) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[5]
mi := &file_messages_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -481,7 +413,7 @@ func (x *SetDelivery) ProtoReflect() protoreflect.Message {
// Deprecated: Use SetDelivery.ProtoReflect.Descriptor instead.
func (*SetDelivery) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{5}
return file_messages_proto_rawDescGZIP(), []int{4}
}
func (x *SetDelivery) GetProvider() string {
@@ -491,7 +423,7 @@ func (x *SetDelivery) GetProvider() string {
return ""
}
func (x *SetDelivery) GetItems() []int64 {
func (x *SetDelivery) GetItems() []uint32 {
if x != nil {
return x.Items
}
@@ -535,7 +467,7 @@ func (x *SetDelivery) GetCity() string {
type SetPickupPoint struct {
state protoimpl.MessageState `protogen:"open.v1"`
DeliveryId int64 `protobuf:"varint,1,opt,name=deliveryId,proto3" json:"deliveryId,omitempty"`
DeliveryId uint32 `protobuf:"varint,1,opt,name=deliveryId,proto3" json:"deliveryId,omitempty"`
Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"`
Name *string `protobuf:"bytes,3,opt,name=name,proto3,oneof" json:"name,omitempty"`
Address *string `protobuf:"bytes,4,opt,name=address,proto3,oneof" json:"address,omitempty"`
@@ -548,7 +480,7 @@ type SetPickupPoint struct {
func (x *SetPickupPoint) Reset() {
*x = SetPickupPoint{}
mi := &file_messages_proto_msgTypes[6]
mi := &file_messages_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -560,7 +492,7 @@ func (x *SetPickupPoint) String() string {
func (*SetPickupPoint) ProtoMessage() {}
func (x *SetPickupPoint) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[6]
mi := &file_messages_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -573,10 +505,10 @@ func (x *SetPickupPoint) ProtoReflect() protoreflect.Message {
// Deprecated: Use SetPickupPoint.ProtoReflect.Descriptor instead.
func (*SetPickupPoint) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{6}
return file_messages_proto_rawDescGZIP(), []int{5}
}
func (x *SetPickupPoint) GetDeliveryId() int64 {
func (x *SetPickupPoint) GetDeliveryId() uint32 {
if x != nil {
return x.DeliveryId
}
@@ -639,7 +571,7 @@ type PickupPoint struct {
func (x *PickupPoint) Reset() {
*x = PickupPoint{}
mi := &file_messages_proto_msgTypes[7]
mi := &file_messages_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -651,7 +583,7 @@ func (x *PickupPoint) String() string {
func (*PickupPoint) ProtoMessage() {}
func (x *PickupPoint) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[7]
mi := &file_messages_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -664,7 +596,7 @@ func (x *PickupPoint) ProtoReflect() protoreflect.Message {
// Deprecated: Use PickupPoint.ProtoReflect.Descriptor instead.
func (*PickupPoint) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{7}
return file_messages_proto_rawDescGZIP(), []int{6}
}
func (x *PickupPoint) GetId() string {
@@ -711,14 +643,14 @@ func (x *PickupPoint) GetCountry() string {
type RemoveDelivery struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RemoveDelivery) Reset() {
*x = RemoveDelivery{}
mi := &file_messages_proto_msgTypes[8]
mi := &file_messages_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -730,7 +662,7 @@ func (x *RemoveDelivery) String() string {
func (*RemoveDelivery) ProtoMessage() {}
func (x *RemoveDelivery) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[8]
mi := &file_messages_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -743,10 +675,10 @@ func (x *RemoveDelivery) ProtoReflect() protoreflect.Message {
// Deprecated: Use RemoveDelivery.ProtoReflect.Descriptor instead.
func (*RemoveDelivery) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{8}
return file_messages_proto_rawDescGZIP(), []int{7}
}
func (x *RemoveDelivery) GetId() int64 {
func (x *RemoveDelivery) GetId() uint32 {
if x != nil {
return x.Id
}
@@ -767,7 +699,7 @@ type CreateCheckoutOrder struct {
func (x *CreateCheckoutOrder) Reset() {
*x = CreateCheckoutOrder{}
mi := &file_messages_proto_msgTypes[9]
mi := &file_messages_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -779,7 +711,7 @@ func (x *CreateCheckoutOrder) String() string {
func (*CreateCheckoutOrder) ProtoMessage() {}
func (x *CreateCheckoutOrder) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[9]
mi := &file_messages_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -792,7 +724,7 @@ func (x *CreateCheckoutOrder) ProtoReflect() protoreflect.Message {
// Deprecated: Use CreateCheckoutOrder.ProtoReflect.Descriptor instead.
func (*CreateCheckoutOrder) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{9}
return file_messages_proto_rawDescGZIP(), []int{8}
}
func (x *CreateCheckoutOrder) GetTerms() string {
@@ -847,7 +779,7 @@ type OrderCreated struct {
func (x *OrderCreated) Reset() {
*x = OrderCreated{}
mi := &file_messages_proto_msgTypes[10]
mi := &file_messages_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -859,7 +791,7 @@ func (x *OrderCreated) String() string {
func (*OrderCreated) ProtoMessage() {}
func (x *OrderCreated) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[10]
mi := &file_messages_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -872,7 +804,7 @@ func (x *OrderCreated) ProtoReflect() protoreflect.Message {
// Deprecated: Use OrderCreated.ProtoReflect.Descriptor instead.
func (*OrderCreated) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{10}
return file_messages_proto_rawDescGZIP(), []int{9}
}
func (x *OrderCreated) GetOrderId() string {
@@ -897,7 +829,7 @@ type Noop struct {
func (x *Noop) Reset() {
*x = Noop{}
mi := &file_messages_proto_msgTypes[11]
mi := &file_messages_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -909,7 +841,7 @@ func (x *Noop) String() string {
func (*Noop) ProtoMessage() {}
func (x *Noop) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[11]
mi := &file_messages_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -922,7 +854,7 @@ func (x *Noop) ProtoReflect() protoreflect.Message {
// Deprecated: Use Noop.ProtoReflect.Descriptor instead.
func (*Noop) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{11}
return file_messages_proto_rawDescGZIP(), []int{10}
}
type InitializeCheckout struct {
@@ -936,7 +868,7 @@ type InitializeCheckout struct {
func (x *InitializeCheckout) Reset() {
*x = InitializeCheckout{}
mi := &file_messages_proto_msgTypes[12]
mi := &file_messages_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -948,7 +880,7 @@ func (x *InitializeCheckout) String() string {
func (*InitializeCheckout) ProtoMessage() {}
func (x *InitializeCheckout) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[12]
mi := &file_messages_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -961,7 +893,7 @@ func (x *InitializeCheckout) ProtoReflect() protoreflect.Message {
// Deprecated: Use InitializeCheckout.ProtoReflect.Descriptor instead.
func (*InitializeCheckout) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{12}
return file_messages_proto_rawDescGZIP(), []int{11}
}
func (x *InitializeCheckout) GetOrderId() string {
@@ -985,120 +917,325 @@ func (x *InitializeCheckout) GetPaymentInProgress() bool {
return false
}
type VoucherRule struct {
state protoimpl.MessageState `protogen:"open.v1"`
Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"`
Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"`
Condition string `protobuf:"bytes,4,opt,name=condition,proto3" json:"condition,omitempty"`
Action string `protobuf:"bytes,5,opt,name=action,proto3" json:"action,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *VoucherRule) Reset() {
*x = VoucherRule{}
mi := &file_messages_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *VoucherRule) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*VoucherRule) ProtoMessage() {}
func (x *VoucherRule) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use VoucherRule.ProtoReflect.Descriptor instead.
func (*VoucherRule) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{12}
}
func (x *VoucherRule) GetType() string {
if x != nil {
return x.Type
}
return ""
}
func (x *VoucherRule) GetDescription() string {
if x != nil {
return x.Description
}
return ""
}
func (x *VoucherRule) GetCondition() string {
if x != nil {
return x.Condition
}
return ""
}
func (x *VoucherRule) GetAction() string {
if x != nil {
return x.Action
}
return ""
}
type AddVoucher struct {
state protoimpl.MessageState `protogen:"open.v1"`
Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"`
Value int64 `protobuf:"varint,2,opt,name=value,proto3" json:"value,omitempty"`
VoucherRules []*VoucherRule `protobuf:"bytes,3,rep,name=voucherRules,proto3" json:"voucherRules,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *AddVoucher) Reset() {
*x = AddVoucher{}
mi := &file_messages_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *AddVoucher) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*AddVoucher) ProtoMessage() {}
func (x *AddVoucher) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use AddVoucher.ProtoReflect.Descriptor instead.
func (*AddVoucher) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{13}
}
func (x *AddVoucher) GetCode() string {
if x != nil {
return x.Code
}
return ""
}
func (x *AddVoucher) GetValue() int64 {
if x != nil {
return x.Value
}
return 0
}
func (x *AddVoucher) GetVoucherRules() []*VoucherRule {
if x != nil {
return x.VoucherRules
}
return nil
}
type RemoveVoucher struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RemoveVoucher) Reset() {
*x = RemoveVoucher{}
mi := &file_messages_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RemoveVoucher) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RemoveVoucher) ProtoMessage() {}
func (x *RemoveVoucher) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[14]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RemoveVoucher.ProtoReflect.Descriptor instead.
func (*RemoveVoucher) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{14}
}
func (x *RemoveVoucher) GetId() uint32 {
if x != nil {
return x.Id
}
return 0
}
var File_messages_proto protoreflect.FileDescriptor
const file_messages_proto_rawDesc = "" +
"\n" +
"\x0emessages.proto\x12\bmessages\"\x7f\n" +
"\n" +
"AddRequest\x12\x1a\n" +
"\bquantity\x18\x01 \x01(\x05R\bquantity\x12\x10\n" +
"\x03sku\x18\x02 \x01(\tR\x03sku\x12\x18\n" +
"\acountry\x18\x03 \x01(\tR\acountry\x12\x1d\n" +
"\astoreId\x18\x04 \x01(\tH\x00R\astoreId\x88\x01\x01B\n" +
"\n" +
"\b_storeId\"<\n" +
"\x0eSetCartRequest\x12*\n" +
"\x05items\x18\x01 \x03(\v2\x14.messages.AddRequestR\x05items\"\xe9\x04\n" +
"\aAddItem\x12\x17\n" +
"\aitem_id\x18\x01 \x01(\x03R\x06itemId\x12\x1a\n" +
"\bquantity\x18\x02 \x01(\x05R\bquantity\x12\x14\n" +
"\x05price\x18\x03 \x01(\x03R\x05price\x12\x1a\n" +
"\borgPrice\x18\t \x01(\x03R\borgPrice\x12\x10\n" +
"\x03sku\x18\x04 \x01(\tR\x03sku\x12\x12\n" +
"\x04name\x18\x05 \x01(\tR\x04name\x12\x14\n" +
"\x05image\x18\x06 \x01(\tR\x05image\x12\x14\n" +
"\x05stock\x18\a \x01(\x05R\x05stock\x12\x10\n" +
"\x03tax\x18\b \x01(\x05R\x03tax\x12\x14\n" +
"\x05brand\x18\r \x01(\tR\x05brand\x12\x1a\n" +
"\bcategory\x18\x0e \x01(\tR\bcategory\x12\x1c\n" +
"\tcategory2\x18\x0f \x01(\tR\tcategory2\x12\x1c\n" +
"\tcategory3\x18\x10 \x01(\tR\tcategory3\x12\x1c\n" +
"\tcategory4\x18\x11 \x01(\tR\tcategory4\x12\x1c\n" +
"\tcategory5\x18\x12 \x01(\tR\tcategory5\x12\x1e\n" +
"\n" +
"disclaimer\x18\n" +
" \x01(\tR\n" +
"disclaimer\x12 \n" +
"\varticleType\x18\v \x01(\tR\varticleType\x12\x1a\n" +
"\bsellerId\x18\x13 \x01(\tR\bsellerId\x12\x1e\n" +
"\n" +
"sellerName\x18\x14 \x01(\tR\n" +
"sellerName\x12\x18\n" +
"\acountry\x18\x15 \x01(\tR\acountry\x12\x1b\n" +
"\x06outlet\x18\f \x01(\tH\x00R\x06outlet\x88\x01\x01\x12\x1d\n" +
"\astoreId\x18\x16 \x01(\tH\x01R\astoreId\x88\x01\x01B\t\n" +
"\a_outletB\n" +
"\n" +
"\b_storeId\"\x1c\n" +
"\n" +
"RemoveItem\x12\x0e\n" +
"\x02Id\x18\x01 \x01(\x03R\x02Id\"<\n" +
"\x0eChangeQuantity\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x03R\x02id\x12\x1a\n" +
"\bquantity\x18\x02 \x01(\x05R\bquantity\"\x86\x02\n" +
"\vSetDelivery\x12\x1a\n" +
"\bprovider\x18\x01 \x01(\tR\bprovider\x12\x14\n" +
"\x05items\x18\x02 \x03(\x03R\x05items\x12<\n" +
"\vpickupPoint\x18\x03 \x01(\v2\x15.messages.PickupPointH\x00R\vpickupPoint\x88\x01\x01\x12\x18\n" +
"\acountry\x18\x04 \x01(\tR\acountry\x12\x10\n" +
"\x03zip\x18\x05 \x01(\tR\x03zip\x12\x1d\n" +
"\aaddress\x18\x06 \x01(\tH\x01R\aaddress\x88\x01\x01\x12\x17\n" +
"\x04city\x18\a \x01(\tH\x02R\x04city\x88\x01\x01B\x0e\n" +
"\f_pickupPointB\n" +
"\n" +
"\b_addressB\a\n" +
"\x05_city\"\xf9\x01\n" +
"\x0eSetPickupPoint\x12\x1e\n" +
"\n" +
"deliveryId\x18\x01 \x01(\x03R\n" +
"deliveryId\x12\x0e\n" +
"\x02id\x18\x02 \x01(\tR\x02id\x12\x17\n" +
"\x04name\x18\x03 \x01(\tH\x00R\x04name\x88\x01\x01\x12\x1d\n" +
"\aaddress\x18\x04 \x01(\tH\x01R\aaddress\x88\x01\x01\x12\x17\n" +
"\x04city\x18\x05 \x01(\tH\x02R\x04city\x88\x01\x01\x12\x15\n" +
"\x03zip\x18\x06 \x01(\tH\x03R\x03zip\x88\x01\x01\x12\x1d\n" +
"\acountry\x18\a \x01(\tH\x04R\acountry\x88\x01\x01B\a\n" +
"\x05_nameB\n" +
"\n" +
"\b_addressB\a\n" +
"\x05_cityB\x06\n" +
"\x04_zipB\n" +
"\n" +
"\b_country\"\xd6\x01\n" +
"\vPickupPoint\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x17\n" +
"\x04name\x18\x02 \x01(\tH\x00R\x04name\x88\x01\x01\x12\x1d\n" +
"\aaddress\x18\x03 \x01(\tH\x01R\aaddress\x88\x01\x01\x12\x17\n" +
"\x04city\x18\x04 \x01(\tH\x02R\x04city\x88\x01\x01\x12\x15\n" +
"\x03zip\x18\x05 \x01(\tH\x03R\x03zip\x88\x01\x01\x12\x1d\n" +
"\acountry\x18\x06 \x01(\tH\x04R\acountry\x88\x01\x01B\a\n" +
"\x05_nameB\n" +
"\n" +
"\b_addressB\a\n" +
"\x05_cityB\x06\n" +
"\x04_zipB\n" +
"\n" +
"\b_country\" \n" +
"\x0eRemoveDelivery\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x03R\x02id\"\xb9\x01\n" +
"\x13CreateCheckoutOrder\x12\x14\n" +
"\x05terms\x18\x01 \x01(\tR\x05terms\x12\x1a\n" +
"\bcheckout\x18\x02 \x01(\tR\bcheckout\x12\"\n" +
"\fconfirmation\x18\x03 \x01(\tR\fconfirmation\x12\x12\n" +
"\x04push\x18\x04 \x01(\tR\x04push\x12\x1e\n" +
"\n" +
"validation\x18\x05 \x01(\tR\n" +
"validation\x12\x18\n" +
"\acountry\x18\x06 \x01(\tR\acountry\"@\n" +
"\fOrderCreated\x12\x18\n" +
"\aorderId\x18\x01 \x01(\tR\aorderId\x12\x16\n" +
"\x06status\x18\x02 \x01(\tR\x06status\"\x06\n" +
"\x04Noop\"t\n" +
"\x12InitializeCheckout\x12\x18\n" +
"\aorderId\x18\x01 \x01(\tR\aorderId\x12\x16\n" +
"\x06status\x18\x02 \x01(\tR\x06status\x12,\n" +
"\x11paymentInProgress\x18\x03 \x01(\bR\x11paymentInProgressB.Z,git.tornberg.me/go-cart-actor/proto;messagesb\x06proto3"
var file_messages_proto_rawDesc = string([]byte{
0x0a, 0x0e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
0x12, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0x12, 0x0a, 0x10, 0x43, 0x6c,
0x65, 0x61, 0x72, 0x43, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x97,
0x05, 0x0a, 0x07, 0x41, 0x64, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x69, 0x74,
0x65, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x69, 0x74, 0x65,
0x6d, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18,
0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12,
0x14, 0x0a, 0x05, 0x70, 0x72, 0x69, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05,
0x70, 0x72, 0x69, 0x63, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x6f, 0x72, 0x67, 0x50, 0x72, 0x69, 0x63,
0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x6f, 0x72, 0x67, 0x50, 0x72, 0x69, 0x63,
0x65, 0x12, 0x10, 0x0a, 0x03, 0x73, 0x6b, 0x75, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
0x73, 0x6b, 0x75, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28,
0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65,
0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x14, 0x0a,
0x05, 0x73, 0x74, 0x6f, 0x63, 0x6b, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x73, 0x74,
0x6f, 0x63, 0x6b, 0x12, 0x10, 0x0a, 0x03, 0x74, 0x61, 0x78, 0x18, 0x08, 0x20, 0x01, 0x28, 0x05,
0x52, 0x03, 0x74, 0x61, 0x78, 0x12, 0x14, 0x0a, 0x05, 0x62, 0x72, 0x61, 0x6e, 0x64, 0x18, 0x0d,
0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x62, 0x72, 0x61, 0x6e, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63,
0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63,
0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67,
0x6f, 0x72, 0x79, 0x32, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65,
0x67, 0x6f, 0x72, 0x79, 0x32, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72,
0x79, 0x33, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f,
0x72, 0x79, 0x33, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x34,
0x18, 0x11, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79,
0x34, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x35, 0x18, 0x12,
0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x35, 0x12,
0x1e, 0x0a, 0x0a, 0x64, 0x69, 0x73, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x65, 0x72, 0x18, 0x0a, 0x20,
0x01, 0x28, 0x09, 0x52, 0x0a, 0x64, 0x69, 0x73, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x65, 0x72, 0x12,
0x20, 0x0a, 0x0b, 0x61, 0x72, 0x74, 0x69, 0x63, 0x6c, 0x65, 0x54, 0x79, 0x70, 0x65, 0x18, 0x0b,
0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x72, 0x74, 0x69, 0x63, 0x6c, 0x65, 0x54, 0x79, 0x70,
0x65, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x49, 0x64, 0x18, 0x13, 0x20,
0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1e, 0x0a,
0x0a, 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x14, 0x20, 0x01, 0x28,
0x09, 0x52, 0x0a, 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a,
0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x15, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x1b, 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x6c, 0x65,
0x74, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x06, 0x6f, 0x75, 0x74, 0x6c, 0x65,
0x74, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x49, 0x64, 0x18,
0x16, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x49, 0x64,
0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x18,
0x17, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x02, 0x52, 0x08, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49,
0x64, 0x88, 0x01, 0x01, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x6f, 0x75, 0x74, 0x6c, 0x65, 0x74, 0x42,
0x0a, 0x0a, 0x08, 0x5f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x49, 0x64, 0x42, 0x0b, 0x0a, 0x09, 0x5f,
0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x22, 0x1c, 0x0a, 0x0a, 0x52, 0x65, 0x6d, 0x6f,
0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01,
0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x22, 0x3c, 0x0a, 0x0e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65,
0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x64, 0x18, 0x01,
0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e,
0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e,
0x74, 0x69, 0x74, 0x79, 0x22, 0x86, 0x02, 0x0a, 0x0b, 0x53, 0x65, 0x74, 0x44, 0x65, 0x6c, 0x69,
0x76, 0x65, 0x72, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72,
0x12, 0x14, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0d, 0x52,
0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x12, 0x3c, 0x0a, 0x0b, 0x70, 0x69, 0x63, 0x6b, 0x75, 0x70,
0x50, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x65,
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x50, 0x69, 0x63, 0x6b, 0x75, 0x70, 0x50, 0x6f, 0x69,
0x6e, 0x74, 0x48, 0x00, 0x52, 0x0b, 0x70, 0x69, 0x63, 0x6b, 0x75, 0x70, 0x50, 0x6f, 0x69, 0x6e,
0x74, 0x88, 0x01, 0x01, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18,
0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10,
0x0a, 0x03, 0x7a, 0x69, 0x70, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x7a, 0x69, 0x70,
0x12, 0x1d, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28,
0x09, 0x48, 0x01, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x88, 0x01, 0x01, 0x12,
0x17, 0x0a, 0x04, 0x63, 0x69, 0x74, 0x79, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52,
0x04, 0x63, 0x69, 0x74, 0x79, 0x88, 0x01, 0x01, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x70, 0x69, 0x63,
0x6b, 0x75, 0x70, 0x50, 0x6f, 0x69, 0x6e, 0x74, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x61, 0x64, 0x64,
0x72, 0x65, 0x73, 0x73, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x63, 0x69, 0x74, 0x79, 0x22, 0xf9, 0x01,
0x0a, 0x0e, 0x53, 0x65, 0x74, 0x50, 0x69, 0x63, 0x6b, 0x75, 0x70, 0x50, 0x6f, 0x69, 0x6e, 0x74,
0x12, 0x1e, 0x0a, 0x0a, 0x64, 0x65, 0x6c, 0x69, 0x76, 0x65, 0x72, 0x79, 0x49, 0x64, 0x18, 0x01,
0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x64, 0x65, 0x6c, 0x69, 0x76, 0x65, 0x72, 0x79, 0x49, 0x64,
0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64,
0x12, 0x17, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00,
0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x61, 0x64, 0x64,
0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x61, 0x64,
0x64, 0x72, 0x65, 0x73, 0x73, 0x88, 0x01, 0x01, 0x12, 0x17, 0x0a, 0x04, 0x63, 0x69, 0x74, 0x79,
0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, 0x04, 0x63, 0x69, 0x74, 0x79, 0x88, 0x01,
0x01, 0x12, 0x15, 0x0a, 0x03, 0x7a, 0x69, 0x70, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x48, 0x03,
0x52, 0x03, 0x7a, 0x69, 0x70, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e,
0x74, 0x72, 0x79, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x04, 0x52, 0x07, 0x63, 0x6f, 0x75,
0x6e, 0x74, 0x72, 0x79, 0x88, 0x01, 0x01, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x6e, 0x61, 0x6d, 0x65,
0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x42, 0x07, 0x0a, 0x05,
0x5f, 0x63, 0x69, 0x74, 0x79, 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x7a, 0x69, 0x70, 0x42, 0x0a, 0x0a,
0x08, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x22, 0xd6, 0x01, 0x0a, 0x0b, 0x50, 0x69,
0x63, 0x6b, 0x75, 0x70, 0x50, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18,
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x04, 0x6e, 0x61, 0x6d,
0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x88,
0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20,
0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x88, 0x01,
0x01, 0x12, 0x17, 0x0a, 0x04, 0x63, 0x69, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48,
0x02, 0x52, 0x04, 0x63, 0x69, 0x74, 0x79, 0x88, 0x01, 0x01, 0x12, 0x15, 0x0a, 0x03, 0x7a, 0x69,
0x70, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x03, 0x52, 0x03, 0x7a, 0x69, 0x70, 0x88, 0x01,
0x01, 0x12, 0x1d, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x06, 0x20, 0x01,
0x28, 0x09, 0x48, 0x04, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x88, 0x01, 0x01,
0x42, 0x07, 0x0a, 0x05, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x61, 0x64,
0x64, 0x72, 0x65, 0x73, 0x73, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x63, 0x69, 0x74, 0x79, 0x42, 0x06,
0x0a, 0x04, 0x5f, 0x7a, 0x69, 0x70, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74,
0x72, 0x79, 0x22, 0x20, 0x0a, 0x0e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x44, 0x65, 0x6c, 0x69,
0x76, 0x65, 0x72, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d,
0x52, 0x02, 0x69, 0x64, 0x22, 0xb9, 0x01, 0x0a, 0x13, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43,
0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x14, 0x0a, 0x05,
0x74, 0x65, 0x72, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x65, 0x72,
0x6d, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x18, 0x02,
0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x12, 0x22,
0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x72, 0x6d, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03,
0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x72, 0x6d, 0x61, 0x74, 0x69,
0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x75, 0x73, 0x68, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09,
0x52, 0x04, 0x70, 0x75, 0x73, 0x68, 0x12, 0x1e, 0x0a, 0x0a, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61,
0x74, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x76, 0x61, 0x6c, 0x69,
0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72,
0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79,
0x22, 0x40, 0x0a, 0x0c, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64,
0x12, 0x18, 0x0a, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74,
0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74,
0x75, 0x73, 0x22, 0x06, 0x0a, 0x04, 0x4e, 0x6f, 0x6f, 0x70, 0x22, 0x74, 0x0a, 0x12, 0x49, 0x6e,
0x69, 0x74, 0x69, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74,
0x12, 0x18, 0x0a, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74,
0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74,
0x75, 0x73, 0x12, 0x2c, 0x0a, 0x11, 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x6e, 0x50,
0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x70,
0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x6e, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73,
0x22, 0x79, 0x0a, 0x0b, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x12,
0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74,
0x79, 0x70, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69,
0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69,
0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69,
0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74,
0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20,
0x01, 0x28, 0x09, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x71, 0x0a, 0x0a, 0x41,
0x64, 0x64, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64,
0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x14, 0x0a,
0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x76, 0x61,
0x6c, 0x75, 0x65, 0x12, 0x39, 0x0a, 0x0c, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75,
0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x65, 0x73, 0x73,
0x61, 0x67, 0x65, 0x73, 0x2e, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65,
0x52, 0x0c, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x22, 0x1f,
0x0a, 0x0d, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x12,
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x42,
0x2e, 0x5a, 0x2c, 0x67, 0x69, 0x74, 0x2e, 0x74, 0x6f, 0x72, 0x6e, 0x62, 0x65, 0x72, 0x67, 0x2e,
0x6d, 0x65, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72, 0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72,
0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x3b, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62,
0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
})
var (
file_messages_proto_rawDescOnce sync.Once
@@ -1112,25 +1249,27 @@ func file_messages_proto_rawDescGZIP() []byte {
return file_messages_proto_rawDescData
}
var file_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
var file_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 15)
var file_messages_proto_goTypes = []any{
(*AddRequest)(nil), // 0: messages.AddRequest
(*SetCartRequest)(nil), // 1: messages.SetCartRequest
(*AddItem)(nil), // 2: messages.AddItem
(*RemoveItem)(nil), // 3: messages.RemoveItem
(*ChangeQuantity)(nil), // 4: messages.ChangeQuantity
(*SetDelivery)(nil), // 5: messages.SetDelivery
(*SetPickupPoint)(nil), // 6: messages.SetPickupPoint
(*PickupPoint)(nil), // 7: messages.PickupPoint
(*RemoveDelivery)(nil), // 8: messages.RemoveDelivery
(*CreateCheckoutOrder)(nil), // 9: messages.CreateCheckoutOrder
(*OrderCreated)(nil), // 10: messages.OrderCreated
(*Noop)(nil), // 11: messages.Noop
(*InitializeCheckout)(nil), // 12: messages.InitializeCheckout
(*ClearCartRequest)(nil), // 0: messages.ClearCartRequest
(*AddItem)(nil), // 1: messages.AddItem
(*RemoveItem)(nil), // 2: messages.RemoveItem
(*ChangeQuantity)(nil), // 3: messages.ChangeQuantity
(*SetDelivery)(nil), // 4: messages.SetDelivery
(*SetPickupPoint)(nil), // 5: messages.SetPickupPoint
(*PickupPoint)(nil), // 6: messages.PickupPoint
(*RemoveDelivery)(nil), // 7: messages.RemoveDelivery
(*CreateCheckoutOrder)(nil), // 8: messages.CreateCheckoutOrder
(*OrderCreated)(nil), // 9: messages.OrderCreated
(*Noop)(nil), // 10: messages.Noop
(*InitializeCheckout)(nil), // 11: messages.InitializeCheckout
(*VoucherRule)(nil), // 12: messages.VoucherRule
(*AddVoucher)(nil), // 13: messages.AddVoucher
(*RemoveVoucher)(nil), // 14: messages.RemoveVoucher
}
var file_messages_proto_depIdxs = []int32{
0, // 0: messages.SetCartRequest.items:type_name -> messages.AddRequest
7, // 1: messages.SetDelivery.pickupPoint:type_name -> messages.PickupPoint
6, // 0: messages.SetDelivery.pickupPoint:type_name -> messages.PickupPoint
12, // 1: messages.AddVoucher.voucherRules:type_name -> messages.VoucherRule
2, // [2:2] is the sub-list for method output_type
2, // [2:2] is the sub-list for method input_type
2, // [2:2] is the sub-list for extension type_name
@@ -1143,18 +1282,17 @@ func file_messages_proto_init() {
if File_messages_proto != nil {
return
}
file_messages_proto_msgTypes[0].OneofWrappers = []any{}
file_messages_proto_msgTypes[2].OneofWrappers = []any{}
file_messages_proto_msgTypes[1].OneofWrappers = []any{}
file_messages_proto_msgTypes[4].OneofWrappers = []any{}
file_messages_proto_msgTypes[5].OneofWrappers = []any{}
file_messages_proto_msgTypes[6].OneofWrappers = []any{}
file_messages_proto_msgTypes[7].OneofWrappers = []any{}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_messages_proto_rawDesc), len(file_messages_proto_rawDesc)),
NumEnums: 0,
NumMessages: 13,
NumMessages: 15,
NumExtensions: 0,
NumServices: 0,
},

187
pkg/proxy/remotehost.go Normal file
View File

@@ -0,0 +1,187 @@
package proxy
import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"time"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// RemoteHost mirrors the lightweight controller used for remote node
// interaction.
type RemoteHost struct {
host string
httpBase string
conn *grpc.ClientConn
transport *http.Transport
client *http.Client
controlClient messages.ControlPlaneClient
missedPings int
}
func NewRemoteHost(host string) (*RemoteHost, error) {
target := fmt.Sprintf("%s:1337", host)
conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Printf("AddRemote: dial %s failed: %v", target, err)
return nil, err
}
controlClient := messages.NewControlPlaneClient(conn)
transport := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
DisableKeepAlives: false,
IdleConnTimeout: 120 * time.Second,
}
client := &http.Client{Transport: transport, Timeout: 10 * time.Second}
return &RemoteHost{
host: host,
httpBase: fmt.Sprintf("http://%s:8080/cart", host),
conn: conn,
transport: transport,
client: client,
controlClient: controlClient,
missedPings: 0,
}, nil
}
func (h *RemoteHost) Name() string {
return h.host
}
func (h *RemoteHost) Close() error {
if h.conn != nil {
h.conn.Close()
}
return nil
}
func (h *RemoteHost) Ping() bool {
var err error = errors.ErrUnsupported
for err != nil {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
_, err = h.controlClient.Ping(ctx, &messages.Empty{})
cancel()
if err != nil {
h.missedPings++
log.Printf("Ping %s failed (%d) %v", h.host, h.missedPings, err)
}
if !h.IsHealthy() {
return false
}
time.Sleep(time.Millisecond * 200)
}
h.missedPings = 0
return true
}
func (h *RemoteHost) Negotiate(knownHosts []string) ([]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := h.controlClient.Negotiate(ctx, &messages.NegotiateRequest{
KnownHosts: knownHosts,
})
if err != nil {
h.missedPings++
log.Printf("Negotiate %s failed: %v", h.host, err)
return nil, err
}
h.missedPings = 0
return resp.Hosts, nil
}
func (h *RemoteHost) GetActorIds() []uint64 {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
reply, err := h.controlClient.GetLocalActorIds(ctx, &messages.Empty{})
if err != nil {
log.Printf("Init remote %s: GetCartIds error: %v", h.host, err)
h.missedPings++
return []uint64{}
}
return reply.GetIds()
}
func (h *RemoteHost) AnnounceOwnership(ownerHost string, uids []uint64) {
_, err := h.controlClient.AnnounceOwnership(context.Background(), &messages.OwnershipAnnounce{
Host: ownerHost,
Ids: uids,
})
if err != nil {
log.Printf("ownership announce to %s failed: %v", h.host, err)
h.missedPings++
return
}
h.missedPings = 0
}
func (h *RemoteHost) AnnounceExpiry(uids []uint64) {
_, err := h.controlClient.AnnounceExpiry(context.Background(), &messages.ExpiryAnnounce{
Host: h.host,
Ids: uids,
})
if err != nil {
log.Printf("expiry announce to %s failed: %v", h.host, err)
h.missedPings++
return
}
h.missedPings = 0
}
func (h *RemoteHost) Proxy(id uint64, w http.ResponseWriter, r *http.Request) (bool, error) {
target := fmt.Sprintf("%s%s", h.httpBase, r.URL.RequestURI())
req, err := http.NewRequestWithContext(r.Context(), r.Method, target, r.Body)
if err != nil {
http.Error(w, "proxy build error", http.StatusBadGateway)
return false, err
}
//r.Body = io.NopCloser(bytes.NewReader(bodyCopy))
req.Header.Set("X-Forwarded-Host", r.Host)
for k, v := range r.Header {
for _, vv := range v {
req.Header.Add(k, vv)
}
}
res, err := h.client.Do(req)
if err != nil {
http.Error(w, "proxy request error", http.StatusBadGateway)
return false, err
}
defer res.Body.Close()
for k, v := range res.Header {
for _, vv := range v {
w.Header().Add(k, vv)
}
}
w.Header().Set("X-Cart-Owner-Routed", "true")
if res.StatusCode >= 200 && res.StatusCode <= 299 {
w.WriteHeader(res.StatusCode)
_, copyErr := io.Copy(w, res.Body)
if copyErr != nil {
return true, copyErr
}
return true, nil
}
return false, fmt.Errorf("proxy response status %d", res.StatusCode)
}
func (r *RemoteHost) IsHealthy() bool {
return r.missedPings < 3
}

347
pkg/voucher/parser.go Normal file
View File

@@ -0,0 +1,347 @@
package voucher
import (
"errors"
"fmt"
"strconv"
"strings"
"unicode"
)
/*
Package voucher - rule parser
A lightweight parser for voucher rule expressions.
Supported rule kinds (case-insensitive keywords):
sku=SKU1|SKU2|SKU3
- At least one of the listed SKUs must be present in the cart.
category=CatA|CatB|CatC
- At least one of the listed categories must be present.
min_total>=12345
- Cart total (Inc VAT) must be at least this value (int64).
min_item_price>=5000
- At least one individual item (Inc VAT single unit price) must be at least this value (int64).
Rule list grammar (simplified):
rules := rule (sep rule)*
rule := (sku|category) '=' valueList
| (min_total|min_item_price) comparator number
valueList := value ('|' value)*
comparator := '>=' (only comparator currently supported for numeric rules)
sep := ';' | ',' | newline
Whitespace is ignored around tokens.
Example:
sku=ABC123|XYZ999; category=Shoes|Bags
min_total>=10000
min_item_price>=2500, category=Accessories
Parsing returns a RuleSet which can later be evaluated against a generic context.
The evaluation context uses simple Item abstractions to avoid tight coupling with
the cart implementation (which currently lives under cmd/cart and cannot be
imported due to being in package main).
This is intentionally conservative and extensible:
* Adding new rule kinds: extend RuleKind constants, add parse + evaluate logic.
* Supporting new operators: extend numeric rule parsing & evaluation.
*/
var (
// ErrEmptyExpression is returned when the input string has only whitespace.
ErrEmptyExpression = errors.New("voucher: empty rule expression")
// ErrInvalidRule indicates a syntactic or semantic issue with a single rule fragment.
ErrInvalidRule = errors.New("voucher: invalid rule")
)
// RuleKind enumerates supported rule kinds.
type RuleKind string
const (
RuleSku RuleKind = "sku"
RuleCategory RuleKind = "category"
RuleMinTotal RuleKind = "min_total"
RuleMinItemPrice RuleKind = "min_item_price"
)
// ruleCondition represents a single, parsed rule.
type ruleCondition struct {
Kind RuleKind
StringVals []string // For sku / category multi-value list
MinValue *int64 // For numeric threshold rules
// Operator reserved for future (e.g., >, >=, ==). Currently always ">=" for numeric kinds.
Operator string
}
// RuleSet groups multiple rule conditions (logical AND).
// All conditions must pass for Applies() to return true.
type RuleSet struct {
Conditions []ruleCondition
Source string // original, trimmed source string
}
// Item is a minimal abstraction for evaluation (decoupled from cart domain structs).
type Item struct {
Sku string
Category string
UnitPrice int64 // Inc VAT (single unit)
}
// EvalContext bundles cart-like data necessary for evaluation.
type EvalContext struct {
Items []Item
CartTotalInc int64
}
// Applies returns true if all rule conditions pass for the context.
func (rs *RuleSet) Applies(ctx EvalContext) bool {
for _, c := range rs.Conditions {
switch c.Kind {
case RuleSku:
if !anyItem(ctx.Items, func(it Item) bool {
return containsFold(c.StringVals, it.Sku)
}) {
return false
}
case RuleCategory:
if !anyItem(ctx.Items, func(it Item) bool {
return containsFold(c.StringVals, it.Category)
}) {
return false
}
case RuleMinTotal:
if c.MinValue == nil || ctx.CartTotalInc < *c.MinValue {
return false
}
case RuleMinItemPrice:
if c.MinValue == nil {
return false
}
if !anyItem(ctx.Items, func(it Item) bool {
return it.UnitPrice >= *c.MinValue
}) {
return false
}
default:
// Unknown kinds fail closed to avoid granting unintended discounts.
return false
}
}
return true
}
// anyItem returns true if predicate matches any item.
func anyItem(items []Item, pred func(Item) bool) bool {
for _, it := range items {
if pred(it) {
return true
}
}
return false
}
// ParseRules parses a rule expression into a RuleSet.
func ParseRules(input string) (*RuleSet, error) {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return nil, ErrEmptyExpression
}
fragments := splitRuleFragments(trimmed)
if len(fragments) == 0 {
return nil, ErrInvalidRule
}
var conditions []ruleCondition
for _, frag := range fragments {
if frag == "" {
continue
}
c, err := parseFragment(frag)
if err != nil {
return nil, fmt.Errorf("%w: %s (%v)", ErrInvalidRule, frag, err)
}
conditions = append(conditions, c)
}
if len(conditions) == 0 {
return nil, ErrInvalidRule
}
return &RuleSet{
Conditions: conditions,
Source: trimmed,
}, nil
}
// splitRuleFragments splits on ; , or newline, while respecting basic structure.
func splitRuleFragments(s string) []string {
// Normalize line endings
s = strings.ReplaceAll(s, "\r\n", "\n")
// We allow separators: newline, semicolon, comma.
seps := func(r rune) bool {
return r == ';' || r == '\n' || r == ','
}
raw := strings.FieldsFunc(s, seps)
out := make([]string, 0, len(raw))
for _, f := range raw {
t := strings.TrimSpace(f)
if t != "" {
out = append(out, t)
}
}
return out
}
// parseFragment parses an individual rule fragment.
func parseFragment(frag string) (ruleCondition, error) {
lower := strings.ToLower(frag)
// Numeric rules have form: <kind> >= number
if strings.HasPrefix(lower, string(RuleMinTotal)) ||
strings.HasPrefix(lower, string(RuleMinItemPrice)) {
return parseNumericRule(frag)
}
// Key=Value list rules (sku / category).
if i := strings.Index(frag, "="); i > 0 {
key := strings.TrimSpace(frag[:i])
valPart := strings.TrimSpace(frag[i+1:])
if key == "" || valPart == "" {
return ruleCondition{}, errors.New("empty key/value")
}
kind := RuleKind(strings.ToLower(key))
switch kind {
case RuleSku, RuleCategory:
values := splitAndClean(valPart, "|")
if len(values) == 0 {
return ruleCondition{}, errors.New("empty value list")
}
return ruleCondition{
Kind: kind,
StringVals: values,
}, nil
default:
return ruleCondition{}, fmt.Errorf("unsupported key '%s'", key)
}
}
return ruleCondition{}, fmt.Errorf("unrecognized fragment '%s'", frag)
}
func parseNumericRule(frag string) (ruleCondition, error) {
// Support only '>=' for now.
var kind RuleKind
var rest string
fragTrim := strings.TrimSpace(frag)
switch {
case strings.HasPrefix(strings.ToLower(fragTrim), string(RuleMinTotal)):
kind = RuleMinTotal
rest = strings.TrimSpace(fragTrim[len(RuleMinTotal):])
case strings.HasPrefix(strings.ToLower(fragTrim), string(RuleMinItemPrice)):
kind = RuleMinItemPrice
rest = strings.TrimSpace(fragTrim[len(RuleMinItemPrice):])
default:
return ruleCondition{}, fmt.Errorf("unknown numeric rule '%s'", frag)
}
// Expect operator and number (>= <number>)
rest = stripLeadingSpace(rest)
if !strings.HasPrefix(rest, ">=") {
return ruleCondition{}, fmt.Errorf("expected '>=' in '%s'", frag)
}
numStr := strings.TrimSpace(rest[2:])
if numStr == "" {
return ruleCondition{}, fmt.Errorf("missing numeric value in '%s'", frag)
}
value, err := strconv.ParseInt(numStr, 10, 64)
if err != nil {
return ruleCondition{}, fmt.Errorf("invalid number '%s': %v", numStr, err)
}
if value < 0 {
return ruleCondition{}, fmt.Errorf("negative threshold %d", value)
}
return ruleCondition{
Kind: kind,
MinValue: &value,
Operator: ">=",
}, nil
}
func stripLeadingSpace(s string) string {
for len(s) > 0 && unicode.IsSpace(rune(s[0])) {
s = s[1:]
}
return s
}
func splitAndClean(s string, sep string) []string {
raw := strings.Split(s, sep)
out := make([]string, 0, len(raw))
for _, r := range raw {
t := strings.TrimSpace(r)
if t != "" {
out = append(out, t)
}
}
return out
}
func containsFold(list []string, candidate string) bool {
for _, v := range list {
if strings.EqualFold(v, candidate) {
return true
}
}
return false
}
// Describe returns a human-friendly summary of the parsed rule set.
func (rs *RuleSet) Describe() string {
if rs == nil {
return "<nil>"
}
var parts []string
for _, c := range rs.Conditions {
switch c.Kind {
case RuleSku, RuleCategory:
parts = append(parts, fmt.Sprintf("%s in (%s)", c.Kind, strings.Join(c.StringVals, "|")))
case RuleMinTotal, RuleMinItemPrice:
if c.MinValue != nil {
parts = append(parts, fmt.Sprintf("%s %s %d", c.Kind, c.OperatorOr(">="), *c.MinValue))
}
default:
parts = append(parts, fmt.Sprintf("unknown(%s)", c.Kind))
}
}
return strings.Join(parts, " AND ")
}
func (c ruleCondition) OperatorOr(def string) string {
if c.Operator == "" {
return def
}
return c.Operator
}
// --- Convenience helpers for incremental adoption ---
// MustParseRules panics on parse error (useful in tests or static initialization).
func MustParseRules(expr string) *RuleSet {
rs, err := ParseRules(expr)
if err != nil {
panic(err)
}
return rs
}

179
pkg/voucher/parser_test.go Normal file
View File

@@ -0,0 +1,179 @@
package voucher
import (
"errors"
"testing"
)
func TestParseRules_SimpleSku(t *testing.T) {
rs, err := ParseRules("sku=ABC123|XYZ999|def456")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rs.Conditions) != 1 {
t.Fatalf("expected 1 condition got %d", len(rs.Conditions))
}
c := rs.Conditions[0]
if c.Kind != RuleSku {
t.Fatalf("expected kind sku got %s", c.Kind)
}
if len(c.StringVals) != 3 {
t.Fatalf("expected 3 sku values got %d", len(c.StringVals))
}
want := []string{"ABC123", "XYZ999", "def456"}
for i, v := range want {
if c.StringVals[i] != v {
t.Fatalf("expected sku[%d]=%s got %s", i, v, c.StringVals[i])
}
}
}
func TestParseRules_CategoryAndSkuMixedSeparators(t *testing.T) {
rs, err := ParseRules(" category=Shoes|Bags ; sku= A | B , min_total>=1000\nmin_item_price>=500")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rs.Conditions) != 4 {
t.Fatalf("expected 4 conditions got %d", len(rs.Conditions))
}
kinds := []RuleKind{RuleCategory, RuleSku, RuleMinTotal, RuleMinItemPrice}
for i, k := range kinds {
if rs.Conditions[i].Kind != k {
t.Fatalf("expected condition[%d] kind %s got %s", i, k, rs.Conditions[i].Kind)
}
}
// Validate numeric thresholds
if rs.Conditions[2].MinValue == nil || *rs.Conditions[2].MinValue != 1000 {
t.Fatalf("expected min_total>=1000 got %+v", rs.Conditions[2])
}
if rs.Conditions[3].MinValue == nil || *rs.Conditions[3].MinValue != 500 {
t.Fatalf("expected min_item_price>=500 got %+v", rs.Conditions[3])
}
}
func TestParseRules_Empty(t *testing.T) {
_, err := ParseRules(" \n ")
if !errors.Is(err, ErrEmptyExpression) {
t.Fatalf("expected ErrEmptyExpression got %v", err)
}
}
func TestParseRules_Invalid(t *testing.T) {
_, err := ParseRules("unknown=foo")
if err == nil {
t.Fatal("expected error for unknown key")
}
_, err = ParseRules("min_total>100") // wrong operator
if err == nil {
t.Fatal("expected error for wrong operator")
}
_, err = ParseRules("min_total>=") // missing value
if err == nil {
t.Fatal("expected error for missing numeric value")
}
}
func TestRuleSet_Applies(t *testing.T) {
rs := MustParseRules("sku=ABC123|XYZ999; category=Shoes|min_total>=10000; min_item_price>=3000")
ctx := EvalContext{
Items: []Item{
{Sku: "ABC123", Category: "Shoes", UnitPrice: 2500},
{Sku: "FFF000", Category: "Accessories", UnitPrice: 3200},
},
CartTotalInc: 12000,
}
if !rs.Applies(ctx) {
t.Fatalf("expected rules to apply")
}
// Fail due to missing sku/category
ctx2 := EvalContext{
Items: []Item{
{Sku: "NOPE", Category: "Different", UnitPrice: 4000},
},
CartTotalInc: 20000,
}
if rs.Applies(ctx2) {
t.Fatalf("expected rules NOT to apply (sku/category mismatch)")
}
// Fail due to min_total
ctx3 := EvalContext{
Items: []Item{
{Sku: "ABC123", Category: "Shoes", UnitPrice: 2500},
{Sku: "FFF000", Category: "Accessories", UnitPrice: 3200},
},
CartTotalInc: 9000,
}
if rs.Applies(ctx3) {
t.Fatalf("expected rules NOT to apply (min_total not reached)")
}
// Fail due to min_item_price (no item >=3000)
ctx4 := EvalContext{
Items: []Item{
{Sku: "ABC123", Category: "Shoes", UnitPrice: 2500},
{Sku: "FFF000", Category: "Accessories", UnitPrice: 2800},
},
CartTotalInc: 15000,
}
if rs.Applies(ctx4) {
t.Fatalf("expected rules NOT to apply (min_item_price not satisfied)")
}
}
func TestRuleSet_Applies_CaseInsensitive(t *testing.T) {
rs := MustParseRules("SKU=abc123|xyz999; CATEGORY=Shoes")
ctx := EvalContext{
Items: []Item{
{Sku: "AbC123", Category: "shoes", UnitPrice: 1000},
},
CartTotalInc: 1000,
}
if !rs.Applies(ctx) {
t.Fatalf("expected rules to apply (case-insensitive match)")
}
}
func TestDescribe(t *testing.T) {
rs := MustParseRules("sku=A|B|min_total>=500")
desc := rs.Describe()
// Loose assertions to avoid over-specification
if desc == "" {
t.Fatalf("expected non-empty description")
}
if !(contains(desc, "sku") && contains(desc, "min_total")) {
t.Fatalf("description missing expected parts: %s", desc)
}
}
func contains(haystack, needle string) bool {
return len(haystack) >= len(needle) && indexOf(haystack, needle) >= 0
}
// Simple substring search (avoid importing strings to show intent explicitly here)
func indexOf(s, sub string) int {
outer:
for i := 0; i+len(sub) <= len(s); i++ {
for j := 0; j < len(sub); j++ {
if s[i+j] != sub[j] {
continue outer
}
}
return i
}
return -1
}
func TestMustParseRules_Panics(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatalf("expected panic for invalid expression")
}
}()
MustParseRules("~~ totally invalid ~~")
}

39
pkg/voucher/service.go Normal file
View File

@@ -0,0 +1,39 @@
package voucher
import (
"errors"
"git.tornberg.me/go-cart-actor/pkg/messages"
)
type Rule struct {
Type string `json:"type"`
Value int64 `json:"value"`
}
type Voucher struct {
Code string `json:"code"`
Value int64 `json:"discount"`
TaxValue int64 `json:"taxValue"`
TaxRate int `json:"taxRate"`
rules []Rule `json:"rules"`
}
type Service struct {
// Add fields here
}
var ErrInvalidCode = errors.New("invalid vouchercode")
func (s *Service) GetVoucher(code string) (*messages.AddVoucher, error) {
if code == "" {
return nil, ErrInvalidCode
}
value := int64(250_00)
return &messages.AddVoucher{
Code: code,
Value: value,
VoucherRules: make([]*messages.VoucherRule, 0),
}, nil
}

View File

@@ -1,398 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"math/rand"
"net/http"
"strconv"
"time"
messages "git.tornberg.me/go-cart-actor/proto"
)
type PoolServer struct {
pod_name string
pool GrainPool
}
func NewPoolServer(pool GrainPool, pod_name string) *PoolServer {
return &PoolServer{
pod_name: pod_name,
pool: pool,
}
}
func (s *PoolServer) process(id CartId, mutation interface{}) (*messages.CartState, error) {
grain, err := s.pool.Apply(id, mutation)
if err != nil {
return nil, err
}
return ToCartState(grain), nil
}
func (s *PoolServer) HandleGet(w http.ResponseWriter, r *http.Request, id CartId) error {
grain, err := s.pool.Get(id)
if err != nil {
return err
}
return s.WriteResult(w, ToCartState(grain))
}
func (s *PoolServer) HandleAddSku(w http.ResponseWriter, r *http.Request, id CartId) error {
sku := r.PathValue("sku")
data, err := s.process(id, &messages.AddRequest{Sku: sku, Quantity: 1})
if err != nil {
return err
}
return s.WriteResult(w, data)
}
func ErrorHandler(fn func(w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
err := fn(w, r)
if err != nil {
log.Printf("Server error, not remote error: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
}
}
func (s *PoolServer) WriteResult(w http.ResponseWriter, result *messages.CartState) error {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("X-Pod-Name", s.pod_name)
if result == nil {
w.WriteHeader(http.StatusInternalServerError)
return nil
}
w.WriteHeader(http.StatusOK)
enc := json.NewEncoder(w)
err := enc.Encode(result)
return err
}
func (s *PoolServer) HandleDeleteItem(w http.ResponseWriter, r *http.Request, id CartId) error {
itemIdString := r.PathValue("itemId")
itemId, err := strconv.Atoi(itemIdString)
if err != nil {
return err
}
data, err := s.process(id, &messages.RemoveItem{Id: int64(itemId)})
if err != nil {
return err
}
return s.WriteResult(w, data)
}
type SetDelivery struct {
Provider string `json:"provider"`
Items []int64 `json:"items"`
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
}
func (s *PoolServer) HandleSetDelivery(w http.ResponseWriter, r *http.Request, id CartId) error {
delivery := SetDelivery{}
err := json.NewDecoder(r.Body).Decode(&delivery)
if err != nil {
return err
}
data, err := s.process(id, &messages.SetDelivery{
Provider: delivery.Provider,
Items: delivery.Items,
PickupPoint: delivery.PickupPoint,
})
if err != nil {
return err
}
return s.WriteResult(w, data)
}
func (s *PoolServer) HandleSetPickupPoint(w http.ResponseWriter, r *http.Request, id CartId) error {
deliveryIdString := r.PathValue("deliveryId")
deliveryId, err := strconv.Atoi(deliveryIdString)
if err != nil {
return err
}
pickupPoint := messages.PickupPoint{}
err = json.NewDecoder(r.Body).Decode(&pickupPoint)
if err != nil {
return err
}
reply, err := s.process(id, &messages.SetPickupPoint{
DeliveryId: int64(deliveryId),
Id: pickupPoint.Id,
Name: pickupPoint.Name,
Address: pickupPoint.Address,
City: pickupPoint.City,
Zip: pickupPoint.Zip,
Country: pickupPoint.Country,
})
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) HandleRemoveDelivery(w http.ResponseWriter, r *http.Request, id CartId) error {
deliveryIdString := r.PathValue("deliveryId")
deliveryId, err := strconv.Atoi(deliveryIdString)
if err != nil {
return err
}
reply, err := s.process(id, &messages.RemoveDelivery{Id: int64(deliveryId)})
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) HandleQuantityChange(w http.ResponseWriter, r *http.Request, id CartId) error {
changeQuantity := messages.ChangeQuantity{}
err := json.NewDecoder(r.Body).Decode(&changeQuantity)
if err != nil {
return err
}
reply, err := s.process(id, &changeQuantity)
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) HandleSetCartItems(w http.ResponseWriter, r *http.Request, id CartId) error {
setCartItems := messages.SetCartRequest{}
err := json.NewDecoder(r.Body).Decode(&setCartItems)
if err != nil {
return err
}
reply, err := s.process(id, &setCartItems)
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) HandleAddRequest(w http.ResponseWriter, r *http.Request, id CartId) error {
addRequest := messages.AddRequest{}
err := json.NewDecoder(r.Body).Decode(&addRequest)
if err != nil {
return err
}
reply, err := s.process(id, &addRequest)
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) HandleConfirmation(w http.ResponseWriter, r *http.Request, id CartId) error {
orderId := r.PathValue("orderId")
if orderId == "" {
return fmt.Errorf("orderId is empty")
}
order, err := KlarnaInstance.GetOrder(orderId)
if err != nil {
return err
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Pod-Name", s.pod_name)
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(http.StatusOK)
return json.NewEncoder(w).Encode(order)
}
func (s *PoolServer) CreateOrUpdateCheckout(host string, id CartId) (*CheckoutOrder, error) {
meta := &CheckoutMeta{
Terms: fmt.Sprintf("https://%s/terms", host),
Checkout: fmt.Sprintf("https://%s/checkout?order_id={checkout.order.id}", host),
Confirmation: fmt.Sprintf("https://%s/confirmation/{checkout.order.id}", host),
Validation: fmt.Sprintf("https://%s/validate", host),
Push: fmt.Sprintf("https://%s/push?order_id={checkout.order.id}", host),
Country: getCountryFromHost(host),
}
// Get current grain state (may be local or remote)
grain, err := s.pool.Get(id)
if err != nil {
return nil, err
}
// Build pure checkout payload
payload, _, err := BuildCheckoutOrderPayload(grain, meta)
if err != nil {
return nil, err
}
if grain.OrderReference != "" {
return KlarnaInstance.UpdateOrder(grain.OrderReference, bytes.NewReader(payload))
} else {
return KlarnaInstance.CreateOrder(bytes.NewReader(payload))
}
}
func (s *PoolServer) ApplyCheckoutStarted(klarnaOrder *CheckoutOrder, id CartId) (*CartGrain, error) {
// Persist initialization state via mutation (best-effort)
return s.pool.Apply(id, &messages.InitializeCheckout{
OrderId: klarnaOrder.ID,
Status: klarnaOrder.Status,
PaymentInProgress: true,
})
}
func (s *PoolServer) HandleCheckout(w http.ResponseWriter, r *http.Request, id CartId) error {
klarnaOrder, err := s.CreateOrUpdateCheckout(r.Host, id)
if err != nil {
return err
}
s.ApplyCheckoutStarted(klarnaOrder, id)
w.Header().Set("Content-Type", "application/json")
return json.NewEncoder(w).Encode(klarnaOrder)
}
func NewCartId() CartId {
// Deprecated: legacy random/time based cart id generator.
// Retained for compatibility; new code should prefer canonical CartID path.
cid, err := NewCartID()
if err != nil {
// Fallback to legacy method only if crypto/rand fails
id := time.Now().UnixNano() + rand.Int63()
return ToCartId(fmt.Sprintf("%d", id))
}
return CartIDToLegacy(cid)
}
func CookieCartIdHandler(fn func(w http.ResponseWriter, r *http.Request, cartId CartId) error) func(w http.ResponseWriter, r *http.Request) error {
return func(w http.ResponseWriter, r *http.Request) error {
// Extract / normalize cookie (preserve legacy textual IDs without rewriting).
var legacy CartId
cookies := r.CookiesNamed("cartid")
if len(cookies) == 0 {
// No cookie -> generate new canonical base62 id.
cid, generated, _, err := CanonicalizeOrLegacy("")
if err != nil {
return fmt.Errorf("failed to generate cart id: %w", err)
}
legacy = CartIDToLegacy(cid)
if generated {
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: cid.String(),
Secure: r.TLS != nil,
HttpOnly: true,
Path: "/",
Expires: time.Now().AddDate(0, 0, 14),
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Set-Cart-Id", cid.String())
}
} else {
raw := cookies[0].Value
cid, generated, wasBase62, err := CanonicalizeOrLegacy(raw)
if err != nil {
return fmt.Errorf("failed to canonicalize cart id: %w", err)
}
legacy = CartIDToLegacy(cid)
// Only set a new cookie if we actually generated a brand-new ID (empty input).
// For legacy (non-base62) ids we preserve the original text and do not overwrite.
if generated && wasBase62 {
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: cid.String(),
Secure: r.TLS != nil,
HttpOnly: true,
Path: "/",
Expires: time.Now().AddDate(0, 0, 14),
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Set-Cart-Id", cid.String())
}
}
return fn(w, r, legacy)
}
}
func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId CartId) error {
cartId = NewCartId()
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: cartId.String(),
Path: "/",
Secure: r.TLS != nil,
HttpOnly: true,
Expires: time.Unix(0, 0),
SameSite: http.SameSiteLaxMode,
})
w.WriteHeader(http.StatusOK)
return nil
}
func CartIdHandler(fn func(w http.ResponseWriter, r *http.Request, cartId CartId) error) func(w http.ResponseWriter, r *http.Request) error {
return func(w http.ResponseWriter, r *http.Request) error {
raw := r.PathValue("id")
cid, generated, wasBase62, err := CanonicalizeOrLegacy(raw)
if err != nil {
return fmt.Errorf("invalid cart id: %w", err)
}
legacy := CartIDToLegacy(cid)
// Only emit Set-Cart-Id header if we produced a brand-new canonical id
// AND it is base62 (avoid rewriting legacy textual identifiers).
if generated && wasBase62 {
w.Header().Set("Set-Cart-Id", cid.String())
}
return fn(w, r, legacy)
}
}
func (s *PoolServer) Serve() *http.ServeMux {
mux := http.NewServeMux()
//mux.HandleFunc("/", s.RewritePath)
mux.HandleFunc("OPTIONS /", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("GET /", ErrorHandler(CookieCartIdHandler(s.HandleGet)))
mux.HandleFunc("GET /add/{sku}", ErrorHandler(CookieCartIdHandler(s.HandleAddSku)))
mux.HandleFunc("POST /", ErrorHandler(CookieCartIdHandler(s.HandleAddRequest)))
mux.HandleFunc("POST /set", ErrorHandler(CookieCartIdHandler(s.HandleSetCartItems)))
mux.HandleFunc("DELETE /{itemId}", ErrorHandler(CookieCartIdHandler(s.HandleDeleteItem)))
mux.HandleFunc("PUT /", ErrorHandler(CookieCartIdHandler(s.HandleQuantityChange)))
mux.HandleFunc("DELETE /", ErrorHandler(CookieCartIdHandler(s.RemoveCartCookie)))
mux.HandleFunc("POST /delivery", ErrorHandler(CookieCartIdHandler(s.HandleSetDelivery)))
mux.HandleFunc("DELETE /delivery/{deliveryId}", ErrorHandler(CookieCartIdHandler(s.HandleRemoveDelivery)))
mux.HandleFunc("PUT /delivery/{deliveryId}/pickupPoint", ErrorHandler(CookieCartIdHandler(s.HandleSetPickupPoint)))
mux.HandleFunc("GET /checkout", ErrorHandler(CookieCartIdHandler(s.HandleCheckout)))
mux.HandleFunc("GET /confirmation/{orderId}", ErrorHandler(CookieCartIdHandler(s.HandleConfirmation)))
mux.HandleFunc("GET /byid/{id}", ErrorHandler(CartIdHandler(s.HandleGet)))
mux.HandleFunc("GET /byid/{id}/add/{sku}", ErrorHandler(CartIdHandler(s.HandleAddSku)))
mux.HandleFunc("POST /byid/{id}", ErrorHandler(CartIdHandler(s.HandleAddRequest)))
mux.HandleFunc("DELETE /byid/{id}/{itemId}", ErrorHandler(CartIdHandler(s.HandleDeleteItem)))
mux.HandleFunc("PUT /byid/{id}", ErrorHandler(CartIdHandler(s.HandleQuantityChange)))
mux.HandleFunc("POST /byid/{id}/delivery", ErrorHandler(CartIdHandler(s.HandleSetDelivery)))
mux.HandleFunc("DELETE /byid/{id}/delivery/{deliveryId}", ErrorHandler(CartIdHandler(s.HandleRemoveDelivery)))
mux.HandleFunc("PUT /byid/{id}/delivery/{deliveryId}/pickupPoint", ErrorHandler(CartIdHandler(s.HandleSetPickupPoint)))
mux.HandleFunc("GET /byid/{id}/checkout", ErrorHandler(CartIdHandler(s.HandleCheckout)))
mux.HandleFunc("GET /byid/{id}/confirmation", ErrorHandler(CartIdHandler(s.HandleConfirmation)))
return mux
}

View File

@@ -1,35 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"github.com/matst80/slask-finder/pkg/index"
)
// TODO make this configurable
func getBaseUrl(country string) string {
// if country == "se" {
// return "http://s10n-se:8080"
// }
if country == "no" {
return "http://s10n-no.s10n:8080"
}
if country == "se" {
return "http://s10n-se.s10n:8080"
}
return "http://localhost:8082"
}
func FetchItem(sku string, country string) (*index.DataItem, error) {
baseUrl := getBaseUrl(country)
res, err := http.Get(fmt.Sprintf("%s/api/by-sku/%s", baseUrl, sku))
if err != nil {
return nil, err
}
defer res.Body.Close()
var item index.DataItem
err = json.NewDecoder(res.Body).Decode(&item)
return &item, err
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,187 +0,0 @@
syntax = "proto3";
package messages;
option go_package = "git.tornberg.me/go-cart-actor/proto;messages";
import "messages.proto";
// -----------------------------------------------------------------------------
// Cart Actor gRPC API (Breaking v2 - Per-Mutation RPCs)
// -----------------------------------------------------------------------------
// This version removes the previous MutationEnvelope + Mutate RPC.
// Each mutation now has its own request wrapper and dedicated RPC method
// providing simpler, type-focused client stubs and enabling per-mutation
// metrics, auth and rate limiting.
//
// Regenerate Go code after editing:
// protoc --go_out=. --go_opt=paths=source_relative \
// --go-grpc_out=. --go-grpc_opt=paths=source_relative \
// proto/cart_actor.proto proto/messages.proto
//
// Backward compatibility: This is a breaking change (old clients must update).
// -----------------------------------------------------------------------------
// Shared reply for all mutation RPCs.
message CartMutationReply {
int32 status_code = 1; // HTTP-like status (200 success, 4xx client, 5xx server)
oneof result {
CartState state = 2; // Updated cart state on success
string error = 3; // Error message on failure
}
int64 server_timestamp = 4; // Server-assigned Unix timestamp (optional auditing)
}
// Fetch current cart state without mutation.
message StateRequest {
string cart_id = 1;
}
message StateReply {
int32 status_code = 1;
oneof result {
CartState state = 2;
string error = 3;
}
}
// Per-mutation request wrappers. We wrap the existing inner mutation
// messages (defined in messages.proto) to add cart_id + optional metadata
// without altering the inner message definitions.
message AddRequestRequest {
string cart_id = 1;
int64 client_timestamp = 2;
AddRequest payload = 10;
}
message AddItemRequest {
string cart_id = 1;
int64 client_timestamp = 2;
AddItem payload = 10;
}
message RemoveItemRequest {
string cart_id = 1;
int64 client_timestamp = 2;
RemoveItem payload = 10;
}
message RemoveDeliveryRequest {
string cart_id = 1;
int64 client_timestamp = 2;
RemoveDelivery payload = 10;
}
message ChangeQuantityRequest {
string cart_id = 1;
int64 client_timestamp = 2;
ChangeQuantity payload = 10;
}
message SetDeliveryRequest {
string cart_id = 1;
int64 client_timestamp = 2;
SetDelivery payload = 10;
}
message SetPickupPointRequest {
string cart_id = 1;
int64 client_timestamp = 2;
SetPickupPoint payload = 10;
}
message CreateCheckoutOrderRequest {
string cart_id = 1;
int64 client_timestamp = 2;
CreateCheckoutOrder payload = 10;
}
message SetCartItemsRequest {
string cart_id = 1;
int64 client_timestamp = 2;
SetCartRequest payload = 10;
}
message OrderCompletedRequest {
string cart_id = 1;
int64 client_timestamp = 2;
OrderCreated payload = 10;
}
// Excerpt: updated messages for camelCase JSON output
message CartState {
string id = 1; // was cart_id
repeated CartItemState items = 2;
int64 totalPrice = 3; // was total_price
int64 totalTax = 4; // was total_tax
int64 totalDiscount = 5; // was total_discount
repeated DeliveryState deliveries = 6;
bool paymentInProgress = 7; // was payment_in_progress
string orderReference = 8; // was order_reference
string paymentStatus = 9; // was payment_status
bool processing = 10; // NEW (mirrors legacy CartGrain.processing)
}
message CartItemState {
int64 id = 1;
int64 itemId = 2; // was source_item_id
string sku = 3;
string name = 4;
int64 price = 5; // was unit_price
int32 qty = 6; // was quantity
int64 totalPrice = 7; // was total_price
int64 totalTax = 8; // was total_tax
int64 orgPrice = 9; // was org_price
int32 taxRate = 10; // was tax_rate
int64 totalDiscount = 11;
string brand = 12;
string category = 13;
string category2 = 14;
string category3 = 15;
string category4 = 16;
string category5 = 17;
string image = 18;
string type = 19; // was article_type
string sellerId = 20; // was seller_id
string sellerName = 21; // was seller_name
string disclaimer = 22;
string outlet = 23;
string storeId = 24; // was store_id
int32 stock = 25;
}
message DeliveryState {
int64 id = 1;
string provider = 2;
int64 price = 3;
repeated int64 items = 4; // was item_ids
PickupPoint pickupPoint = 5; // was pickup_point
}
// (CheckoutRequest / CheckoutReply removed - checkout handled at HTTP layer)
// -----------------------------------------------------------------------------
// Service definition (per-mutation RPCs + checkout)
// -----------------------------------------------------------------------------
service CartActor {
rpc AddRequest(AddRequestRequest) returns (CartMutationReply);
rpc AddItem(AddItemRequest) returns (CartMutationReply);
rpc RemoveItem(RemoveItemRequest) returns (CartMutationReply);
rpc RemoveDelivery(RemoveDeliveryRequest) returns (CartMutationReply);
rpc ChangeQuantity(ChangeQuantityRequest) returns (CartMutationReply);
rpc SetDelivery(SetDeliveryRequest) returns (CartMutationReply);
rpc SetPickupPoint(SetPickupPointRequest) returns (CartMutationReply);
// (Checkout RPC removed - handled externally)
rpc SetCartItems(SetCartItemsRequest) returns (CartMutationReply);
rpc OrderCompleted(OrderCompletedRequest) returns (CartMutationReply);
rpc GetState(StateRequest) returns (StateReply);
}
// -----------------------------------------------------------------------------
// Future enhancements:
// * BatchMutate RPC (repeated heterogeneous mutations)
// * Streaming state updates (WatchState)
// * Versioning / optimistic concurrency control
// -----------------------------------------------------------------------------

View File

@@ -1,473 +0,0 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v3.21.12
// source: cart_actor.proto
package messages
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
CartActor_AddRequest_FullMethodName = "/messages.CartActor/AddRequest"
CartActor_AddItem_FullMethodName = "/messages.CartActor/AddItem"
CartActor_RemoveItem_FullMethodName = "/messages.CartActor/RemoveItem"
CartActor_RemoveDelivery_FullMethodName = "/messages.CartActor/RemoveDelivery"
CartActor_ChangeQuantity_FullMethodName = "/messages.CartActor/ChangeQuantity"
CartActor_SetDelivery_FullMethodName = "/messages.CartActor/SetDelivery"
CartActor_SetPickupPoint_FullMethodName = "/messages.CartActor/SetPickupPoint"
CartActor_SetCartItems_FullMethodName = "/messages.CartActor/SetCartItems"
CartActor_OrderCompleted_FullMethodName = "/messages.CartActor/OrderCompleted"
CartActor_GetState_FullMethodName = "/messages.CartActor/GetState"
)
// CartActorClient is the client API for CartActor service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// -----------------------------------------------------------------------------
// Service definition (per-mutation RPCs + checkout)
// -----------------------------------------------------------------------------
type CartActorClient interface {
AddRequest(ctx context.Context, in *AddRequestRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
AddItem(ctx context.Context, in *AddItemRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
RemoveItem(ctx context.Context, in *RemoveItemRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
RemoveDelivery(ctx context.Context, in *RemoveDeliveryRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
ChangeQuantity(ctx context.Context, in *ChangeQuantityRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
SetDelivery(ctx context.Context, in *SetDeliveryRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
SetPickupPoint(ctx context.Context, in *SetPickupPointRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
// (Checkout RPC removed - handled externally)
SetCartItems(ctx context.Context, in *SetCartItemsRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
OrderCompleted(ctx context.Context, in *OrderCompletedRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
GetState(ctx context.Context, in *StateRequest, opts ...grpc.CallOption) (*StateReply, error)
}
type cartActorClient struct {
cc grpc.ClientConnInterface
}
func NewCartActorClient(cc grpc.ClientConnInterface) CartActorClient {
return &cartActorClient{cc}
}
func (c *cartActorClient) AddRequest(ctx context.Context, in *AddRequestRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CartMutationReply)
err := c.cc.Invoke(ctx, CartActor_AddRequest_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *cartActorClient) AddItem(ctx context.Context, in *AddItemRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CartMutationReply)
err := c.cc.Invoke(ctx, CartActor_AddItem_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *cartActorClient) RemoveItem(ctx context.Context, in *RemoveItemRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CartMutationReply)
err := c.cc.Invoke(ctx, CartActor_RemoveItem_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *cartActorClient) RemoveDelivery(ctx context.Context, in *RemoveDeliveryRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CartMutationReply)
err := c.cc.Invoke(ctx, CartActor_RemoveDelivery_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *cartActorClient) ChangeQuantity(ctx context.Context, in *ChangeQuantityRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CartMutationReply)
err := c.cc.Invoke(ctx, CartActor_ChangeQuantity_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *cartActorClient) SetDelivery(ctx context.Context, in *SetDeliveryRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CartMutationReply)
err := c.cc.Invoke(ctx, CartActor_SetDelivery_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *cartActorClient) SetPickupPoint(ctx context.Context, in *SetPickupPointRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CartMutationReply)
err := c.cc.Invoke(ctx, CartActor_SetPickupPoint_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *cartActorClient) SetCartItems(ctx context.Context, in *SetCartItemsRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CartMutationReply)
err := c.cc.Invoke(ctx, CartActor_SetCartItems_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *cartActorClient) OrderCompleted(ctx context.Context, in *OrderCompletedRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CartMutationReply)
err := c.cc.Invoke(ctx, CartActor_OrderCompleted_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *cartActorClient) GetState(ctx context.Context, in *StateRequest, opts ...grpc.CallOption) (*StateReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(StateReply)
err := c.cc.Invoke(ctx, CartActor_GetState_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// CartActorServer is the server API for CartActor service.
// All implementations must embed UnimplementedCartActorServer
// for forward compatibility.
//
// -----------------------------------------------------------------------------
// Service definition (per-mutation RPCs + checkout)
// -----------------------------------------------------------------------------
type CartActorServer interface {
AddRequest(context.Context, *AddRequestRequest) (*CartMutationReply, error)
AddItem(context.Context, *AddItemRequest) (*CartMutationReply, error)
RemoveItem(context.Context, *RemoveItemRequest) (*CartMutationReply, error)
RemoveDelivery(context.Context, *RemoveDeliveryRequest) (*CartMutationReply, error)
ChangeQuantity(context.Context, *ChangeQuantityRequest) (*CartMutationReply, error)
SetDelivery(context.Context, *SetDeliveryRequest) (*CartMutationReply, error)
SetPickupPoint(context.Context, *SetPickupPointRequest) (*CartMutationReply, error)
// (Checkout RPC removed - handled externally)
SetCartItems(context.Context, *SetCartItemsRequest) (*CartMutationReply, error)
OrderCompleted(context.Context, *OrderCompletedRequest) (*CartMutationReply, error)
GetState(context.Context, *StateRequest) (*StateReply, error)
mustEmbedUnimplementedCartActorServer()
}
// UnimplementedCartActorServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedCartActorServer struct{}
func (UnimplementedCartActorServer) AddRequest(context.Context, *AddRequestRequest) (*CartMutationReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method AddRequest not implemented")
}
func (UnimplementedCartActorServer) AddItem(context.Context, *AddItemRequest) (*CartMutationReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method AddItem not implemented")
}
func (UnimplementedCartActorServer) RemoveItem(context.Context, *RemoveItemRequest) (*CartMutationReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method RemoveItem not implemented")
}
func (UnimplementedCartActorServer) RemoveDelivery(context.Context, *RemoveDeliveryRequest) (*CartMutationReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method RemoveDelivery not implemented")
}
func (UnimplementedCartActorServer) ChangeQuantity(context.Context, *ChangeQuantityRequest) (*CartMutationReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method ChangeQuantity not implemented")
}
func (UnimplementedCartActorServer) SetDelivery(context.Context, *SetDeliveryRequest) (*CartMutationReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetDelivery not implemented")
}
func (UnimplementedCartActorServer) SetPickupPoint(context.Context, *SetPickupPointRequest) (*CartMutationReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetPickupPoint not implemented")
}
func (UnimplementedCartActorServer) SetCartItems(context.Context, *SetCartItemsRequest) (*CartMutationReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetCartItems not implemented")
}
func (UnimplementedCartActorServer) OrderCompleted(context.Context, *OrderCompletedRequest) (*CartMutationReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method OrderCompleted not implemented")
}
func (UnimplementedCartActorServer) GetState(context.Context, *StateRequest) (*StateReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetState not implemented")
}
func (UnimplementedCartActorServer) mustEmbedUnimplementedCartActorServer() {}
func (UnimplementedCartActorServer) testEmbeddedByValue() {}
// UnsafeCartActorServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to CartActorServer will
// result in compilation errors.
type UnsafeCartActorServer interface {
mustEmbedUnimplementedCartActorServer()
}
func RegisterCartActorServer(s grpc.ServiceRegistrar, srv CartActorServer) {
// If the following call pancis, it indicates UnimplementedCartActorServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&CartActor_ServiceDesc, srv)
}
func _CartActor_AddRequest_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AddRequestRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CartActorServer).AddRequest(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CartActor_AddRequest_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CartActorServer).AddRequest(ctx, req.(*AddRequestRequest))
}
return interceptor(ctx, in, info, handler)
}
func _CartActor_AddItem_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AddItemRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CartActorServer).AddItem(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CartActor_AddItem_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CartActorServer).AddItem(ctx, req.(*AddItemRequest))
}
return interceptor(ctx, in, info, handler)
}
func _CartActor_RemoveItem_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RemoveItemRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CartActorServer).RemoveItem(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CartActor_RemoveItem_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CartActorServer).RemoveItem(ctx, req.(*RemoveItemRequest))
}
return interceptor(ctx, in, info, handler)
}
func _CartActor_RemoveDelivery_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RemoveDeliveryRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CartActorServer).RemoveDelivery(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CartActor_RemoveDelivery_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CartActorServer).RemoveDelivery(ctx, req.(*RemoveDeliveryRequest))
}
return interceptor(ctx, in, info, handler)
}
func _CartActor_ChangeQuantity_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ChangeQuantityRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CartActorServer).ChangeQuantity(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CartActor_ChangeQuantity_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CartActorServer).ChangeQuantity(ctx, req.(*ChangeQuantityRequest))
}
return interceptor(ctx, in, info, handler)
}
func _CartActor_SetDelivery_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetDeliveryRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CartActorServer).SetDelivery(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CartActor_SetDelivery_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CartActorServer).SetDelivery(ctx, req.(*SetDeliveryRequest))
}
return interceptor(ctx, in, info, handler)
}
func _CartActor_SetPickupPoint_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetPickupPointRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CartActorServer).SetPickupPoint(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CartActor_SetPickupPoint_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CartActorServer).SetPickupPoint(ctx, req.(*SetPickupPointRequest))
}
return interceptor(ctx, in, info, handler)
}
func _CartActor_SetCartItems_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetCartItemsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CartActorServer).SetCartItems(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CartActor_SetCartItems_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CartActorServer).SetCartItems(ctx, req.(*SetCartItemsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _CartActor_OrderCompleted_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(OrderCompletedRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CartActorServer).OrderCompleted(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CartActor_OrderCompleted_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CartActorServer).OrderCompleted(ctx, req.(*OrderCompletedRequest))
}
return interceptor(ctx, in, info, handler)
}
func _CartActor_GetState_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StateRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CartActorServer).GetState(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CartActor_GetState_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CartActorServer).GetState(ctx, req.(*StateRequest))
}
return interceptor(ctx, in, info, handler)
}
// CartActor_ServiceDesc is the grpc.ServiceDesc for CartActor service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var CartActor_ServiceDesc = grpc.ServiceDesc{
ServiceName: "messages.CartActor",
HandlerType: (*CartActorServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "AddRequest",
Handler: _CartActor_AddRequest_Handler,
},
{
MethodName: "AddItem",
Handler: _CartActor_AddItem_Handler,
},
{
MethodName: "RemoveItem",
Handler: _CartActor_RemoveItem_Handler,
},
{
MethodName: "RemoveDelivery",
Handler: _CartActor_RemoveDelivery_Handler,
},
{
MethodName: "ChangeQuantity",
Handler: _CartActor_ChangeQuantity_Handler,
},
{
MethodName: "SetDelivery",
Handler: _CartActor_SetDelivery_Handler,
},
{
MethodName: "SetPickupPoint",
Handler: _CartActor_SetPickupPoint_Handler,
},
{
MethodName: "SetCartItems",
Handler: _CartActor_SetCartItems_Handler,
},
{
MethodName: "OrderCompleted",
Handler: _CartActor_OrderCompleted_Handler,
},
{
MethodName: "GetState",
Handler: _CartActor_GetState_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "cart_actor.proto",
}

View File

@@ -1,435 +0,0 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.10
// protoc v3.21.12
// source: control_plane.proto
package messages
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// Empty request placeholder (common pattern).
type Empty struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Empty) Reset() {
*x = Empty{}
mi := &file_control_plane_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Empty) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Empty) ProtoMessage() {}
func (x *Empty) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Empty.ProtoReflect.Descriptor instead.
func (*Empty) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{0}
}
// Ping reply includes responding host and its current unix time (seconds).
type PingReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
UnixTime int64 `protobuf:"varint,2,opt,name=unix_time,json=unixTime,proto3" json:"unix_time,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PingReply) Reset() {
*x = PingReply{}
mi := &file_control_plane_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PingReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PingReply) ProtoMessage() {}
func (x *PingReply) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PingReply.ProtoReflect.Descriptor instead.
func (*PingReply) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{1}
}
func (x *PingReply) GetHost() string {
if x != nil {
return x.Host
}
return ""
}
func (x *PingReply) GetUnixTime() int64 {
if x != nil {
return x.UnixTime
}
return 0
}
// NegotiateRequest carries the caller's full view of known hosts (including self).
type NegotiateRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
KnownHosts []string `protobuf:"bytes,1,rep,name=known_hosts,json=knownHosts,proto3" json:"known_hosts,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *NegotiateRequest) Reset() {
*x = NegotiateRequest{}
mi := &file_control_plane_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *NegotiateRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*NegotiateRequest) ProtoMessage() {}
func (x *NegotiateRequest) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use NegotiateRequest.ProtoReflect.Descriptor instead.
func (*NegotiateRequest) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{2}
}
func (x *NegotiateRequest) GetKnownHosts() []string {
if x != nil {
return x.KnownHosts
}
return nil
}
// NegotiateReply returns the callee's healthy hosts (including itself).
type NegotiateReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Hosts []string `protobuf:"bytes,1,rep,name=hosts,proto3" json:"hosts,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *NegotiateReply) Reset() {
*x = NegotiateReply{}
mi := &file_control_plane_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *NegotiateReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*NegotiateReply) ProtoMessage() {}
func (x *NegotiateReply) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use NegotiateReply.ProtoReflect.Descriptor instead.
func (*NegotiateReply) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{3}
}
func (x *NegotiateReply) GetHosts() []string {
if x != nil {
return x.Hosts
}
return nil
}
// CartIdsReply returns the list of cart IDs (string form) currently owned locally.
type CartIdsReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
CartIds []string `protobuf:"bytes,1,rep,name=cart_ids,json=cartIds,proto3" json:"cart_ids,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CartIdsReply) Reset() {
*x = CartIdsReply{}
mi := &file_control_plane_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CartIdsReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CartIdsReply) ProtoMessage() {}
func (x *CartIdsReply) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CartIdsReply.ProtoReflect.Descriptor instead.
func (*CartIdsReply) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{4}
}
func (x *CartIdsReply) GetCartIds() []string {
if x != nil {
return x.CartIds
}
return nil
}
// OwnerChangeAck retained as response type for Closing RPC (ConfirmOwner removed).
type OwnerChangeAck struct {
state protoimpl.MessageState `protogen:"open.v1"`
Accepted bool `protobuf:"varint,1,opt,name=accepted,proto3" json:"accepted,omitempty"`
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *OwnerChangeAck) Reset() {
*x = OwnerChangeAck{}
mi := &file_control_plane_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *OwnerChangeAck) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*OwnerChangeAck) ProtoMessage() {}
func (x *OwnerChangeAck) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use OwnerChangeAck.ProtoReflect.Descriptor instead.
func (*OwnerChangeAck) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{5}
}
func (x *OwnerChangeAck) GetAccepted() bool {
if x != nil {
return x.Accepted
}
return false
}
func (x *OwnerChangeAck) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
// ClosingNotice notifies peers this host is terminating (so they can drop / re-resolve).
type ClosingNotice struct {
state protoimpl.MessageState `protogen:"open.v1"`
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ClosingNotice) Reset() {
*x = ClosingNotice{}
mi := &file_control_plane_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ClosingNotice) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ClosingNotice) ProtoMessage() {}
func (x *ClosingNotice) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ClosingNotice.ProtoReflect.Descriptor instead.
func (*ClosingNotice) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{6}
}
func (x *ClosingNotice) GetHost() string {
if x != nil {
return x.Host
}
return ""
}
var File_control_plane_proto protoreflect.FileDescriptor
const file_control_plane_proto_rawDesc = "" +
"\n" +
"\x13control_plane.proto\x12\bmessages\"\a\n" +
"\x05Empty\"<\n" +
"\tPingReply\x12\x12\n" +
"\x04host\x18\x01 \x01(\tR\x04host\x12\x1b\n" +
"\tunix_time\x18\x02 \x01(\x03R\bunixTime\"3\n" +
"\x10NegotiateRequest\x12\x1f\n" +
"\vknown_hosts\x18\x01 \x03(\tR\n" +
"knownHosts\"&\n" +
"\x0eNegotiateReply\x12\x14\n" +
"\x05hosts\x18\x01 \x03(\tR\x05hosts\")\n" +
"\fCartIdsReply\x12\x19\n" +
"\bcart_ids\x18\x01 \x03(\tR\acartIds\"F\n" +
"\x0eOwnerChangeAck\x12\x1a\n" +
"\baccepted\x18\x01 \x01(\bR\baccepted\x12\x18\n" +
"\amessage\x18\x02 \x01(\tR\amessage\"#\n" +
"\rClosingNotice\x12\x12\n" +
"\x04host\x18\x01 \x01(\tR\x04host2\xf4\x01\n" +
"\fControlPlane\x12,\n" +
"\x04Ping\x12\x0f.messages.Empty\x1a\x13.messages.PingReply\x12A\n" +
"\tNegotiate\x12\x1a.messages.NegotiateRequest\x1a\x18.messages.NegotiateReply\x125\n" +
"\n" +
"GetCartIds\x12\x0f.messages.Empty\x1a\x16.messages.CartIdsReply\x12<\n" +
"\aClosing\x12\x17.messages.ClosingNotice\x1a\x18.messages.OwnerChangeAckB.Z,git.tornberg.me/go-cart-actor/proto;messagesb\x06proto3"
var (
file_control_plane_proto_rawDescOnce sync.Once
file_control_plane_proto_rawDescData []byte
)
func file_control_plane_proto_rawDescGZIP() []byte {
file_control_plane_proto_rawDescOnce.Do(func() {
file_control_plane_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_control_plane_proto_rawDesc), len(file_control_plane_proto_rawDesc)))
})
return file_control_plane_proto_rawDescData
}
var file_control_plane_proto_msgTypes = make([]protoimpl.MessageInfo, 7)
var file_control_plane_proto_goTypes = []any{
(*Empty)(nil), // 0: messages.Empty
(*PingReply)(nil), // 1: messages.PingReply
(*NegotiateRequest)(nil), // 2: messages.NegotiateRequest
(*NegotiateReply)(nil), // 3: messages.NegotiateReply
(*CartIdsReply)(nil), // 4: messages.CartIdsReply
(*OwnerChangeAck)(nil), // 5: messages.OwnerChangeAck
(*ClosingNotice)(nil), // 6: messages.ClosingNotice
}
var file_control_plane_proto_depIdxs = []int32{
0, // 0: messages.ControlPlane.Ping:input_type -> messages.Empty
2, // 1: messages.ControlPlane.Negotiate:input_type -> messages.NegotiateRequest
0, // 2: messages.ControlPlane.GetCartIds:input_type -> messages.Empty
6, // 3: messages.ControlPlane.Closing:input_type -> messages.ClosingNotice
1, // 4: messages.ControlPlane.Ping:output_type -> messages.PingReply
3, // 5: messages.ControlPlane.Negotiate:output_type -> messages.NegotiateReply
4, // 6: messages.ControlPlane.GetCartIds:output_type -> messages.CartIdsReply
5, // 7: messages.ControlPlane.Closing:output_type -> messages.OwnerChangeAck
4, // [4:8] is the sub-list for method output_type
0, // [0:4] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_control_plane_proto_init() }
func file_control_plane_proto_init() {
if File_control_plane_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_control_plane_proto_rawDesc), len(file_control_plane_proto_rawDesc)),
NumEnums: 0,
NumMessages: 7,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_control_plane_proto_goTypes,
DependencyIndexes: file_control_plane_proto_depIdxs,
MessageInfos: file_control_plane_proto_msgTypes,
}.Build()
File_control_plane_proto = out.File
file_control_plane_proto_goTypes = nil
file_control_plane_proto_depIdxs = nil
}

View File

@@ -12,7 +12,7 @@ option go_package = "git.tornberg.me/go-cart-actor/proto;messages";
// - Liveness (Ping)
// - Membership negotiation (Negotiate)
// - Deterministic ring-based ownership (ConfirmOwner RPC removed)
// - Cart ID listing for remote grain spawning (GetCartIds)
// - Actor ID listing for remote grain spawning (GetActorIds)
// - Graceful shutdown notifications (Closing)
// No authentication / TLS is defined initially (can be added later).
// -----------------------------------------------------------------------------
@@ -37,8 +37,8 @@ message NegotiateReply {
}
// CartIdsReply returns the list of cart IDs (string form) currently owned locally.
message CartIdsReply {
repeated string cart_ids = 1;
message ActorIdsReply {
repeated uint64 ids = 1;
}
// OwnerChangeAck retained as response type for Closing RPC (ConfirmOwner removed).
@@ -52,6 +52,19 @@ message ClosingNotice {
string host = 1;
}
// OwnershipAnnounce broadcasts first-touch ownership claims for cart IDs.
// First claim wins; receivers SHOULD NOT overwrite an existing different owner.
message OwnershipAnnounce {
string host = 1; // announcing host
repeated uint64 ids = 2; // newly claimed cart ids
}
// ExpiryAnnounce broadcasts that a host evicted the provided cart IDs.
message ExpiryAnnounce {
string host = 1;
repeated uint64 ids = 2;
}
// ControlPlane defines cluster coordination and ownership operations.
service ControlPlane {
// Ping for liveness; lightweight health signal.
@@ -61,10 +74,16 @@ service ControlPlane {
rpc Negotiate(NegotiateRequest) returns (NegotiateReply);
// GetCartIds lists currently owned cart IDs on this node.
rpc GetCartIds(Empty) returns (CartIdsReply);
rpc GetLocalActorIds(Empty) returns (ActorIdsReply);
// ConfirmOwner RPC removed (was legacy ownership acknowledgement; ring-based ownership now authoritative)
// Ownership announcement: first-touch claim broadcast (idempotent; best-effort).
rpc AnnounceOwnership(OwnershipAnnounce) returns (OwnerChangeAck);
// Expiry announcement: drop remote ownership hints when local TTL expires.
rpc AnnounceExpiry(ExpiryAnnounce) returns (OwnerChangeAck);
// Closing announces graceful shutdown so peers can proactively adjust.
rpc Closing(ClosingNotice) returns (OwnerChangeAck);
}

View File

@@ -2,19 +2,12 @@ syntax = "proto3";
package messages;
option go_package = "git.tornberg.me/go-cart-actor/proto;messages";
message AddRequest {
int32 quantity = 1;
string sku = 2;
string country = 3;
optional string storeId = 4;
}
message ClearCartRequest {
message SetCartRequest {
repeated AddRequest items = 1;
}
message AddItem {
int64 item_id = 1;
uint32 item_id = 1;
int32 quantity = 2;
int64 price = 3;
int64 orgPrice = 9;
@@ -36,20 +29,21 @@ message AddItem {
string country = 21;
optional string outlet = 12;
optional string storeId = 22;
optional uint32 parentId = 23;
}
message RemoveItem {
int64 Id = 1;
uint32 Id = 1;
}
message ChangeQuantity {
int64 id = 1;
uint32 Id = 1;
int32 quantity = 2;
}
message SetDelivery {
string provider = 1;
repeated int64 items = 2;
repeated uint32 items = 2;
optional PickupPoint pickupPoint = 3;
string country = 4;
string zip = 5;
@@ -58,7 +52,7 @@ message SetDelivery {
}
message SetPickupPoint {
int64 deliveryId = 1;
uint32 deliveryId = 1;
string id = 2;
optional string name = 3;
optional string address = 4;
@@ -77,7 +71,7 @@ message PickupPoint {
}
message RemoveDelivery {
int64 id = 1;
uint32 id = 1;
}
message CreateCheckoutOrder {
@@ -103,3 +97,20 @@ message InitializeCheckout {
string status = 2;
bool paymentInProgress = 3;
}
message VoucherRule {
string type = 2;
string description = 3;
string condition = 4;
string action = 5;
}
message AddVoucher {
string code = 1;
int64 value = 2;
repeated VoucherRule voucherRules = 3;
}
message RemoveVoucher {
uint32 id = 1;
}

View File

@@ -1,341 +0,0 @@
package main
import (
"context"
"fmt"
"log"
"time"
proto "git.tornberg.me/go-cart-actor/proto" // generated package name is 'messages'; aliased as proto for consistency
"google.golang.org/grpc"
)
// RemoteGrainGRPC is the gRPC-backed implementation of a remote grain.
// It mirrors the previous RemoteGrain (TCP/frame based) while using the
// new CartActor gRPC service. It implements the Grain interface so that
// SyncedPool can remain largely unchanged when swapping transport layers.
type RemoteGrainGRPC struct {
Id CartId
Host string
client proto.CartActorClient
// Optional: keep the underlying conn so higher-level code can close if needed
conn *grpc.ClientConn
// Per-call timeout settings (tunable)
mutateTimeout time.Duration
stateTimeout time.Duration
}
// NewRemoteGrainGRPC constructs a remote grain adapter from an existing gRPC client.
func NewRemoteGrainGRPC(id CartId, host string, client proto.CartActorClient) *RemoteGrainGRPC {
return &RemoteGrainGRPC{
Id: id,
Host: host,
client: client,
mutateTimeout: 800 * time.Millisecond,
stateTimeout: 400 * time.Millisecond,
}
}
// NewRemoteGrainGRPCWithConn dials the target and creates the gRPC client.
// target should be host:port (where the CartActor service is exposed).
func NewRemoteGrainGRPCWithConn(id CartId, host string, target string, dialOpts ...grpc.DialOption) (*RemoteGrainGRPC, error) {
// NOTE: insecure for initial migration; should be replaced with TLS later.
baseOpts := []grpc.DialOption{grpc.WithInsecure(), grpc.WithBlock()}
baseOpts = append(baseOpts, dialOpts...)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, err := grpc.DialContext(ctx, target, baseOpts...)
if err != nil {
return nil, err
}
client := proto.NewCartActorClient(conn)
return &RemoteGrainGRPC{
Id: id,
Host: host,
client: client,
conn: conn,
mutateTimeout: 800 * time.Millisecond,
stateTimeout: 400 * time.Millisecond,
}, nil
}
func (g *RemoteGrainGRPC) GetId() CartId {
return g.Id
}
// Apply executes a cart mutation via per-mutation RPCs (breaking v2 API)
// and returns a *CartGrain reconstructed from the CartMutationReply state.
func (g *RemoteGrainGRPC) Apply(content interface{}, isReplay bool) (*CartGrain, error) {
if isReplay {
return nil, fmt.Errorf("replay not supported for remote grains")
}
if content == nil {
return nil, fmt.Errorf("nil mutation content")
}
ts := time.Now().Unix()
var invoke func(ctx context.Context) (*proto.CartMutationReply, error)
switch m := content.(type) {
case *proto.AddRequest:
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
return g.client.AddRequest(ctx, &proto.AddRequestRequest{
CartId: g.Id.String(),
ClientTimestamp: ts,
Payload: m,
})
}
case *proto.AddItem:
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
return g.client.AddItem(ctx, &proto.AddItemRequest{
CartId: g.Id.String(),
ClientTimestamp: ts,
Payload: m,
})
}
case *proto.RemoveItem:
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
return g.client.RemoveItem(ctx, &proto.RemoveItemRequest{
CartId: g.Id.String(),
ClientTimestamp: ts,
Payload: m,
})
}
case *proto.RemoveDelivery:
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
return g.client.RemoveDelivery(ctx, &proto.RemoveDeliveryRequest{
CartId: g.Id.String(),
ClientTimestamp: ts,
Payload: m,
})
}
case *proto.ChangeQuantity:
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
return g.client.ChangeQuantity(ctx, &proto.ChangeQuantityRequest{
CartId: g.Id.String(),
ClientTimestamp: ts,
Payload: m,
})
}
case *proto.SetDelivery:
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
return g.client.SetDelivery(ctx, &proto.SetDeliveryRequest{
CartId: g.Id.String(),
ClientTimestamp: ts,
Payload: m,
})
}
case *proto.SetPickupPoint:
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
return g.client.SetPickupPoint(ctx, &proto.SetPickupPointRequest{
CartId: g.Id.String(),
ClientTimestamp: ts,
Payload: m,
})
}
case *proto.CreateCheckoutOrder:
return nil, fmt.Errorf("CreateCheckoutOrder deprecated: checkout is handled via HTTP endpoint (HandleCheckout)")
case *proto.SetCartRequest:
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
return g.client.SetCartItems(ctx, &proto.SetCartItemsRequest{
CartId: g.Id.String(),
ClientTimestamp: ts,
Payload: m,
})
}
case *proto.OrderCreated:
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
return g.client.OrderCompleted(ctx, &proto.OrderCompletedRequest{
CartId: g.Id.String(),
ClientTimestamp: ts,
Payload: m,
})
}
default:
return nil, fmt.Errorf("unsupported mutation type %T", content)
}
if invoke == nil {
return nil, fmt.Errorf("no invocation mapped for mutation %T", content)
}
ctx, cancel := context.WithTimeout(context.Background(), g.mutateTimeout)
defer cancel()
resp, err := invoke(ctx)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if e := resp.GetError(); e != "" {
return nil, fmt.Errorf("remote mutation failed %d: %s", resp.StatusCode, e)
}
return nil, fmt.Errorf("remote mutation failed %d", resp.StatusCode)
}
state := resp.GetState()
if state == nil {
return nil, fmt.Errorf("mutation reply missing state on success")
}
// Reconstruct a lightweight CartGrain (only fields we expose internally)
grain := &CartGrain{
Id: ToCartId(state.Id),
TotalPrice: state.TotalPrice,
TotalTax: state.TotalTax,
TotalDiscount: state.TotalDiscount,
PaymentInProgress: state.PaymentInProgress,
OrderReference: state.OrderReference,
PaymentStatus: state.PaymentStatus,
}
// Items
for _, it := range state.Items {
if it == nil {
continue
}
outlet := toPtr(it.Outlet)
storeId := toPtr(it.StoreId)
grain.Items = append(grain.Items, &CartItem{
Id: int(it.Id),
ItemId: int(it.ItemId),
Sku: it.Sku,
Name: it.Name,
Price: it.Price,
Quantity: int(it.Qty),
TotalPrice: it.TotalPrice,
TotalTax: it.TotalTax,
OrgPrice: it.OrgPrice,
TaxRate: int(it.TaxRate),
Brand: it.Brand,
Category: it.Category,
Category2: it.Category2,
Category3: it.Category3,
Category4: it.Category4,
Category5: it.Category5,
Image: it.Image,
ArticleType: it.Type,
SellerId: it.SellerId,
SellerName: it.SellerName,
Disclaimer: it.Disclaimer,
Outlet: outlet,
StoreId: storeId,
Stock: StockStatus(it.Stock),
})
}
// Deliveries
for _, d := range state.Deliveries {
if d == nil {
continue
}
intIds := make([]int, 0, len(d.Items))
for _, id := range d.Items {
intIds = append(intIds, int(id))
}
grain.Deliveries = append(grain.Deliveries, &CartDelivery{
Id: int(d.Id),
Provider: d.Provider,
Price: d.Price,
Items: intIds,
PickupPoint: d.PickupPoint,
})
}
return grain, nil
}
// GetCurrentState retrieves the current cart state using the typed StateReply oneof.
func (g *RemoteGrainGRPC) GetCurrentState() (*CartGrain, error) {
ctx, cancel := context.WithTimeout(context.Background(), g.stateTimeout)
defer cancel()
resp, err := g.client.GetState(ctx, &proto.StateRequest{CartId: g.Id.String()})
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if e := resp.GetError(); e != "" {
return nil, fmt.Errorf("remote get state failed %d: %s", resp.StatusCode, e)
}
return nil, fmt.Errorf("remote get state failed %d", resp.StatusCode)
}
state := resp.GetState()
if state == nil {
return nil, fmt.Errorf("state reply missing state on success")
}
grain := &CartGrain{
Id: ToCartId(state.Id),
TotalPrice: state.TotalPrice,
TotalTax: state.TotalTax,
TotalDiscount: state.TotalDiscount,
PaymentInProgress: state.PaymentInProgress,
OrderReference: state.OrderReference,
PaymentStatus: state.PaymentStatus,
}
for _, it := range state.Items {
if it == nil {
continue
}
outlet := toPtr(it.Outlet)
storeId := toPtr(it.StoreId)
grain.Items = append(grain.Items, &CartItem{
Id: int(it.Id),
ItemId: int(it.ItemId),
Sku: it.Sku,
Name: it.Name,
Price: it.Price,
Quantity: int(it.Qty),
TotalPrice: it.TotalPrice,
TotalTax: it.TotalTax,
OrgPrice: it.OrgPrice,
TaxRate: int(it.TaxRate),
Brand: it.Brand,
Category: it.Category,
Category2: it.Category2,
Category3: it.Category3,
Category4: it.Category4,
Category5: it.Category5,
Image: it.Image,
ArticleType: it.Type,
SellerId: it.SellerId,
SellerName: it.SellerName,
Disclaimer: it.Disclaimer,
Outlet: outlet,
StoreId: storeId,
Stock: StockStatus(it.Stock),
})
}
for _, d := range state.Deliveries {
if d == nil {
continue
}
intIds := make([]int, 0, len(d.Items))
for _, id := range d.Items {
intIds = append(intIds, int(id))
}
grain.Deliveries = append(grain.Deliveries, &CartDelivery{
Id: int(d.Id),
Provider: d.Provider,
Price: d.Price,
Items: intIds,
PickupPoint: d.PickupPoint,
})
}
return grain, nil
}
// Close closes the underlying gRPC connection if this adapter created it.
func (g *RemoteGrainGRPC) Close() error {
if g.conn != nil {
return g.conn.Close()
}
return nil
}
// Debug helper to log operations (optional).
func (g *RemoteGrainGRPC) logf(format string, args ...interface{}) {
log.Printf("[remote-grain-grpc host=%s id=%s] %s", g.Host, g.Id.String(), fmt.Sprintf(format, args...))
}

344
ring.go
View File

@@ -1,344 +0,0 @@
package main
import (
"encoding/binary"
"fmt"
"hash/fnv"
"sort"
"strings"
"sync"
)
// ring.go
//
// Consistent hashing ring skeleton for future integration.
// --------------------------------------------------------
// This file introduces a minimal, allocationlight consistent hashing structure
// intended to replace per-cart ownership negotiation. It focuses on:
// * Deterministic lookup: O(log V) via binary search
// * Even(ish) distribution using virtual nodes (vnodes)
// * Epoch / fingerprint tracking to detect membership drift
//
// NOT YET WIRED:
// * SyncedPool integration (ownerForCart, lazy migration)
// * Replication factor > 1
// * Persistent state migration
//
// Safe to import now; unused until explicit integration code is added.
//
// Design Notes
// ------------
// - Hosts contribute `vnodesPerHost` virtual nodes. Higher counts smooth
// distribution at cost of memory (V = hosts * vnodesPerHost).
// - Hash of vnode = FNV1a64(host + "#" + index). For improved quality you
// can swap in xxhash or siphash later without changing API (but doing so
// will reshuffle ownership).
// - Cart ownership lookup uses either cartID.Raw() when provided (uniform
// 64-bit space) or falls back to hashing string forms (legacy).
// - Epoch is monotonically increasing; consumers can fence stale results.
//
// Future Extensions
// -----------------
// - Weighted hosts (proportionally more vnodes).
// - Replication: LookupN(h, n) to return primary + replicas.
// - Streaming / diff-based ring updates (gossip).
// - Hash function injection for deterministic test scenarios.
//
// ---------------------------------------------------------------------------
// Vnode represents a single virtual node position on the ring.
type Vnode struct {
Hash uint64 // position on the ring
Host string // physical host owning this vnode
Index int // per-host vnode index (0..vnodesPerHost-1)
}
// Ring is an immutable consistent hash ring snapshot.
type Ring struct {
Epoch uint64
Vnodes []Vnode // sorted by Hash
hosts []string
fingerprint uint64 // membership fingerprint (order-independent)
}
// RingBuilder accumulates parameters to construct a Ring.
type RingBuilder struct {
epoch uint64
vnodesPerHost int
hosts []string
}
// NewRingBuilder creates a builder with defaults.
func NewRingBuilder() *RingBuilder {
return &RingBuilder{
vnodesPerHost: 64, // a reasonable default for small clusters
}
}
func (b *RingBuilder) WithEpoch(e uint64) *RingBuilder {
b.epoch = e
return b
}
func (b *RingBuilder) WithVnodesPerHost(n int) *RingBuilder {
if n > 0 {
b.vnodesPerHost = n
}
return b
}
func (b *RingBuilder) WithHosts(hosts []string) *RingBuilder {
uniq := make(map[string]struct{}, len(hosts))
out := make([]string, 0, len(hosts))
for _, h := range hosts {
h = strings.TrimSpace(h)
if h == "" {
continue
}
if _, ok := uniq[h]; ok {
continue
}
uniq[h] = struct{}{}
out = append(out, h)
}
sort.Strings(out)
b.hosts = out
return b
}
func (b *RingBuilder) Build() *Ring {
if len(b.hosts) == 0 {
return &Ring{
Epoch: b.epoch,
Vnodes: nil,
hosts: nil,
fingerprint: 0,
}
}
totalVnodes := len(b.hosts) * b.vnodesPerHost
vnodes := make([]Vnode, 0, totalVnodes)
for _, host := range b.hosts {
for i := 0; i < b.vnodesPerHost; i++ {
h := hashVnode(host, i)
vnodes = append(vnodes, Vnode{
Hash: h,
Host: host,
Index: i,
})
}
}
sort.Slice(vnodes, func(i, j int) bool {
if vnodes[i].Hash == vnodes[j].Hash {
// Tie-break deterministically by host then index to avoid instability
if vnodes[i].Host == vnodes[j].Host {
return vnodes[i].Index < vnodes[j].Index
}
return vnodes[i].Host < vnodes[j].Host
}
return vnodes[i].Hash < vnodes[j].Hash
})
fp := fingerprintHosts(b.hosts)
return &Ring{
Epoch: b.epoch,
Vnodes: vnodes,
hosts: append([]string(nil), b.hosts...),
fingerprint: fp,
}
}
// Hosts returns a copy of the host list (sorted).
func (r *Ring) Hosts() []string {
if len(r.hosts) == 0 {
return nil
}
cp := make([]string, len(r.hosts))
copy(cp, r.hosts)
return cp
}
// Fingerprint returns a hash representing the unordered membership set.
func (r *Ring) Fingerprint() uint64 {
return r.fingerprint
}
// Empty indicates ring has no vnodes.
func (r *Ring) Empty() bool {
return len(r.Vnodes) == 0
}
// Lookup returns the vnode owning a given hash value.
func (r *Ring) Lookup(h uint64) Vnode {
if len(r.Vnodes) == 0 {
return Vnode{}
}
// Binary search: first position with Hash >= h
i := sort.Search(len(r.Vnodes), func(i int) bool {
return r.Vnodes[i].Hash >= h
})
if i == len(r.Vnodes) {
return r.Vnodes[0]
}
return r.Vnodes[i]
}
// LookupID selects owner vnode for a CartID (fast path).
func (r *Ring) LookupID(id CartID) Vnode {
return r.Lookup(id.Raw())
}
// LookupString hashes an arbitrary string and looks up owner.
func (r *Ring) LookupString(s string) Vnode {
return r.Lookup(hashKeyString(s))
}
// LookupN returns up to n distinct host vnodes in ring order
// starting from the primary owner of hash h (for replication).
func (r *Ring) LookupN(h uint64, n int) []Vnode {
if n <= 0 || len(r.Vnodes) == 0 {
return nil
}
if n > len(r.hosts) {
n = len(r.hosts)
}
owners := make([]Vnode, 0, n)
seen := make(map[string]struct{}, n)
start := r.Lookup(h)
// Find index of start (can binary search again or linear scan; since we
// already have start.Hash we do another search for clarity)
i := sort.Search(len(r.Vnodes), func(i int) bool {
return r.Vnodes[i].Hash >= start.Hash
})
if i == len(r.Vnodes) {
i = 0
}
for idx := 0; len(owners) < n && idx < len(r.Vnodes); idx++ {
v := r.Vnodes[(i+idx)%len(r.Vnodes)]
if _, ok := seen[v.Host]; ok {
continue
}
seen[v.Host] = struct{}{}
owners = append(owners, v)
}
return owners
}
// DiffHosts compares this ring's membership to another.
func (r *Ring) DiffHosts(other *Ring) (added []string, removed []string) {
if other == nil {
return r.Hosts(), nil
}
cur := make(map[string]struct{}, len(r.hosts))
for _, h := range r.hosts {
cur[h] = struct{}{}
}
oth := make(map[string]struct{}, len(other.hosts))
for _, h := range other.hosts {
oth[h] = struct{}{}
}
for h := range cur {
if _, ok := oth[h]; !ok {
removed = append(removed, h)
}
}
for h := range oth {
if _, ok := cur[h]; !ok {
added = append(added, h)
}
}
sort.Strings(added)
sort.Strings(removed)
return
}
// ---------------------------- Hash Functions ---------------------------------
func hashVnode(host string, idx int) uint64 {
h := fnv.New64a()
_, _ = h.Write([]byte(host))
_, _ = h.Write([]byte{'#'})
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], uint64(idx))
_, _ = h.Write(buf[:])
return h.Sum64()
}
// hashKeyString provides a stable hash for arbitrary string keys (legacy IDs).
func hashKeyString(s string) uint64 {
h := fnv.New64a()
_, _ = h.Write([]byte(s))
return h.Sum64()
}
// fingerprintHosts produces an order-insensitive hash over the host set.
func fingerprintHosts(hosts []string) uint64 {
if len(hosts) == 0 {
return 0
}
h := fnv.New64a()
for _, host := range hosts {
_, _ = h.Write([]byte(host))
_, _ = h.Write([]byte{0})
}
return h.Sum64()
}
// --------------------------- Thread-Safe Wrapper -----------------------------
//
// RingRef offers atomic swap + read semantics. SyncedPool can embed or hold
// one of these to manage live ring updates safely.
type RingRef struct {
mu sync.RWMutex
ring *Ring
}
func NewRingRef(r *Ring) *RingRef {
return &RingRef{ring: r}
}
func (rr *RingRef) Get() *Ring {
rr.mu.RLock()
r := rr.ring
rr.mu.RUnlock()
return r
}
func (rr *RingRef) Set(r *Ring) {
rr.mu.Lock()
rr.ring = r
rr.mu.Unlock()
}
func (rr *RingRef) LookupID(id CartID) Vnode {
r := rr.Get()
if r == nil {
return Vnode{}
}
return r.LookupID(id)
}
// ----------------------------- Debug Utilities -------------------------------
func (r *Ring) String() string {
var b strings.Builder
fmt.Fprintf(&b, "Ring{epoch=%d vnodes=%d hosts=%d}\n", r.Epoch, len(r.Vnodes), len(r.hosts))
limit := len(r.Vnodes)
if limit > 16 {
limit = 16
}
for i := 0; i < limit; i++ {
v := r.Vnodes[i]
fmt.Fprintf(&b, " %02d hash=%016x host=%s idx=%d\n", i, v.Hash, v.Host, v.Index)
}
if len(r.Vnodes) > limit {
fmt.Fprintf(&b, " ... (%d more)\n", len(r.Vnodes)-limit)
}
return b.String()
}

View File

@@ -1,647 +0,0 @@
package main
import (
"context"
"fmt"
"log"
"reflect"
"sync"
"time"
proto "git.tornberg.me/go-cart-actor/proto"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"google.golang.org/grpc"
"k8s.io/apimachinery/pkg/watch"
)
// SyncedPool coordinates cart grain ownership across nodes using gRPC control plane
// and cart actor services.
//
// Responsibilities:
// - Local grain access (delegates to GrainLocalPool)
// - Remote grain proxy management (RemoteGrainGRPC)
// - Cluster membership (AddRemote via discovery + negotiation)
// - Health/ping monitoring & remote removal
// - Ring based deterministic ownership (no runtime negotiation)
// - (Scaffolding) replication factor awareness via ring.LookupN
//
// Thread-safety: public methods that mutate internal maps lock p.mu (RWMutex).
type SyncedPool struct {
Hostname string
local *GrainLocalPool
mu sync.RWMutex
// Remote host state (gRPC only)
remoteHosts map[string]*RemoteHostGRPC // host -> remote host
// Remote grain proxies (by cart id)
remoteIndex map[CartId]Grain
// Discovery handler for re-adding hosts after failures
discardedHostHandler *DiscardedHostHandler
// Consistent hashing ring (immutable snapshot reference)
ringRef *RingRef
// Configuration
vnodesPerHost int
replicationFactor int // RF (>=1). Currently only primary is active; replicas are scaffolding.
}
// RemoteHostGRPC tracks a remote host's clients & health.
type RemoteHostGRPC struct {
Host string
Conn *grpc.ClientConn
CartClient proto.CartActorClient
ControlClient proto.ControlPlaneClient
MissedPings int
}
func (r *RemoteHostGRPC) IsHealthy() bool {
return r.MissedPings < 3
}
var (
negotiationCount = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_remote_negotiation_total",
Help: "The total number of remote negotiations",
})
grainSyncCount = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_grain_sync_total",
Help: "The total number of grain owner changes",
})
connectedRemotes = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_connected_remotes",
Help: "The number of connected remotes",
})
remoteLookupCount = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_remote_lookup_total",
Help: "The total number of remote lookups (legacy counter)",
})
// Ring / ownership metrics
ringEpoch = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_ring_epoch",
Help: "Current consistent hashing ring epoch (fingerprint-based pseudo-epoch)",
})
ringHosts = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_ring_hosts",
Help: "Number of hosts currently in the ring",
})
ringVnodes = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_ring_vnodes",
Help: "Number of virtual nodes in the ring",
})
ringLookupLocal = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_ring_lookup_local_total",
Help: "Ring ownership lookups resolved to the local host",
})
ringLookupRemote = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_ring_lookup_remote_total",
Help: "Ring ownership lookups resolved to a remote host",
})
ringHostShare = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "cart_ring_host_share",
Help: "Fractional share of ring vnodes per host",
}, []string{"host"})
cartMutationsTotal = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_mutations_total",
Help: "Total number of cart state mutations applied (local + remote routed).",
})
cartMutationFailuresTotal = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_mutation_failures_total",
Help: "Total number of failed cart state mutations (local apply errors or remote routing failures).",
})
cartMutationLatencySeconds = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "cart_mutation_latency_seconds",
Help: "Latency of cart mutations (successful or failed) in seconds.",
Buckets: prometheus.DefBuckets,
}, []string{"mutation"})
cartActiveGrains = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_active_grains",
Help: "Number of active (resident) local grains.",
})
)
func NewSyncedPool(local *GrainLocalPool, hostname string, discovery Discovery) (*SyncedPool, error) {
p := &SyncedPool{
Hostname: hostname,
local: local,
remoteHosts: make(map[string]*RemoteHostGRPC),
remoteIndex: make(map[CartId]Grain),
discardedHostHandler: NewDiscardedHostHandler(1338),
vnodesPerHost: 64, // default smoothing factor; adjust if needed
replicationFactor: 1, // RF scaffold; >1 not yet activating replicas
}
p.discardedHostHandler.SetReconnectHandler(p.AddRemote)
// Initialize empty ring (will be rebuilt after first AddRemote or discovery event)
p.rebuildRing()
if discovery != nil {
go func() {
time.Sleep(3 * time.Second) // allow gRPC server startup
log.Printf("Starting discovery watcher")
ch, err := discovery.Watch()
if err != nil {
log.Printf("Discovery error: %v", err)
return
}
for evt := range ch {
if evt.Host == "" {
continue
}
switch evt.Type {
case watch.Deleted:
if p.IsKnown(evt.Host) {
p.RemoveHost(evt.Host)
}
default:
if !p.IsKnown(evt.Host) {
log.Printf("Discovered host %s", evt.Host)
p.AddRemote(evt.Host)
}
}
}
}()
} else {
log.Printf("No discovery configured; expecting manual AddRemote or static host injection")
}
return p, nil
}
// ------------------------- Remote Host Management -----------------------------
// AddRemote dials a remote host and initializes grain proxies.
func (p *SyncedPool) AddRemote(host string) {
if host == "" || host == p.Hostname {
return
}
p.mu.Lock()
if _, exists := p.remoteHosts[host]; exists {
p.mu.Unlock()
return
}
p.mu.Unlock()
target := fmt.Sprintf("%s:1337", host)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, err := grpc.DialContext(ctx, target, grpc.WithInsecure(), grpc.WithBlock())
if err != nil {
log.Printf("AddRemote: dial %s failed: %v", target, err)
return
}
cartClient := proto.NewCartActorClient(conn)
controlClient := proto.NewControlPlaneClient(conn)
// Health check (Ping) with limited retries
pings := 3
for pings > 0 {
ctxPing, cancelPing := context.WithTimeout(context.Background(), 1*time.Second)
_, pingErr := controlClient.Ping(ctxPing, &proto.Empty{})
cancelPing()
if pingErr == nil {
break
}
pings--
time.Sleep(200 * time.Millisecond)
if pings == 0 {
log.Printf("AddRemote: ping %s failed after retries: %v", host, pingErr)
conn.Close()
return
}
}
remote := &RemoteHostGRPC{
Host: host,
Conn: conn,
CartClient: cartClient,
ControlClient: controlClient,
MissedPings: 0,
}
p.mu.Lock()
p.remoteHosts[host] = remote
p.mu.Unlock()
connectedRemotes.Set(float64(p.RemoteCount()))
// Rebuild consistent hashing ring including this new host
p.rebuildRing()
log.Printf("Connected to remote host %s", host)
go p.pingLoop(remote)
go p.initializeRemote(remote)
go p.Negotiate()
}
// initializeRemote fetches remote cart ids and sets up remote grain proxies.
func (p *SyncedPool) initializeRemote(remote *RemoteHostGRPC) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
reply, err := remote.ControlClient.GetCartIds(ctx, &proto.Empty{})
if err != nil {
log.Printf("Init remote %s: GetCartIds error: %v", remote.Host, err)
return
}
count := 0
for _, idStr := range reply.CartIds {
if idStr == "" {
continue
}
p.SpawnRemoteGrain(ToCartId(idStr), remote.Host)
count++
}
log.Printf("Remote %s reported %d grains", remote.Host, count)
}
// RemoveHost removes remote host and its grains.
func (p *SyncedPool) RemoveHost(host string) {
p.mu.Lock()
remote, exists := p.remoteHosts[host]
if exists {
delete(p.remoteHosts, host)
}
// remove grains pointing to host
for id, g := range p.remoteIndex {
if rg, ok := g.(*RemoteGrainGRPC); ok && rg.Host == host {
delete(p.remoteIndex, id)
}
}
p.mu.Unlock()
if exists {
remote.Conn.Close()
}
connectedRemotes.Set(float64(p.RemoteCount()))
// Rebuild ring after host removal
p.rebuildRing()
}
// RemoteCount returns number of tracked remote hosts.
func (p *SyncedPool) RemoteCount() int {
p.mu.RLock()
defer p.mu.RUnlock()
return len(p.remoteHosts)
}
func (p *SyncedPool) IsKnown(host string) bool {
if host == p.Hostname {
return true
}
p.mu.RLock()
defer p.mu.RUnlock()
_, ok := p.remoteHosts[host]
return ok
}
func (p *SyncedPool) ExcludeKnown(hosts []string) []string {
ret := make([]string, 0, len(hosts))
for _, h := range hosts {
if !p.IsKnown(h) {
ret = append(ret, h)
}
}
return ret
}
// ------------------------- Health / Ping -------------------------------------
func (p *SyncedPool) pingLoop(remote *RemoteHostGRPC) {
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for range ticker.C {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
_, err := remote.ControlClient.Ping(ctx, &proto.Empty{})
cancel()
if err != nil {
remote.MissedPings++
log.Printf("Ping %s failed (%d)", remote.Host, remote.MissedPings)
if !remote.IsHealthy() {
log.Printf("Remote %s unhealthy, removing", remote.Host)
p.RemoveHost(remote.Host)
return
}
continue
}
remote.MissedPings = 0
}
}
func (p *SyncedPool) IsHealthy() bool {
p.mu.RLock()
defer p.mu.RUnlock()
for _, r := range p.remoteHosts {
if !r.IsHealthy() {
return false
}
}
return true
}
// ------------------------- Negotiation ---------------------------------------
func (p *SyncedPool) Negotiate() {
negotiationCount.Inc()
p.mu.RLock()
hosts := make([]string, 0, len(p.remoteHosts)+1)
hosts = append(hosts, p.Hostname)
for h := range p.remoteHosts {
hosts = append(hosts, h)
}
remotes := make([]*RemoteHostGRPC, 0, len(p.remoteHosts))
for _, r := range p.remoteHosts {
remotes = append(remotes, r)
}
p.mu.RUnlock()
changed := false
for _, r := range remotes {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
reply, err := r.ControlClient.Negotiate(ctx, &proto.NegotiateRequest{KnownHosts: hosts})
cancel()
if err != nil {
log.Printf("Negotiate with %s failed: %v", r.Host, err)
continue
}
for _, h := range reply.Hosts {
if !p.IsKnown(h) {
p.AddRemote(h)
changed = true
}
}
}
// If new hosts were discovered during negotiation, rebuild the ring once at the end.
if changed {
p.rebuildRing()
}
}
// ------------------------- Grain / Ring Ownership ----------------------------
// RemoveRemoteGrain removes a remote grain mapping.
func (p *SyncedPool) RemoveRemoteGrain(id CartId) {
p.mu.Lock()
delete(p.remoteIndex, id)
p.mu.Unlock()
}
// SpawnRemoteGrain creates/updates a remote grain proxy for a given host.
func (p *SyncedPool) SpawnRemoteGrain(id CartId, host string) {
if id.String() == "" {
return
}
p.mu.Lock()
// If local grain exists (legacy key), remove from local map (ownership moved).
if g, ok := p.local.grains[LegacyToCartKey(id)]; ok && g != nil {
delete(p.local.grains, LegacyToCartKey(id))
}
remoteHost, ok := p.remoteHosts[host]
if !ok {
p.mu.Unlock()
log.Printf("SpawnRemoteGrain: host %s unknown (id=%s), attempting AddRemote", host, id)
go p.AddRemote(host)
return
}
rg := NewRemoteGrainGRPC(id, host, remoteHost.CartClient)
p.remoteIndex[id] = rg
p.mu.Unlock()
}
// GetHealthyRemotes returns a copy slice of healthy remote hosts.
func (p *SyncedPool) GetHealthyRemotes() []*RemoteHostGRPC {
p.mu.RLock()
defer p.mu.RUnlock()
ret := make([]*RemoteHostGRPC, 0, len(p.remoteHosts))
for _, r := range p.remoteHosts {
if r.IsHealthy() {
ret = append(ret, r)
}
}
return ret
}
// rebuildRing reconstructs the consistent hashing ring from current host set
// and updates ring-related metrics.
func (p *SyncedPool) rebuildRing() {
p.mu.RLock()
hosts := make([]string, 0, len(p.remoteHosts)+1)
hosts = append(hosts, p.Hostname)
for h := range p.remoteHosts {
hosts = append(hosts, h)
}
p.mu.RUnlock()
epochSeed := fingerprintHosts(hosts)
builder := NewRingBuilder().
WithHosts(hosts).
WithEpoch(epochSeed).
WithVnodesPerHost(p.vnodesPerHost)
r := builder.Build()
if p.ringRef == nil {
p.ringRef = NewRingRef(r)
} else {
p.ringRef.Set(r)
}
// Metrics
ringEpoch.Set(float64(r.Epoch))
ringHosts.Set(float64(len(r.Hosts())))
ringVnodes.Set(float64(len(r.Vnodes)))
ringHostShare.Reset()
if len(r.Vnodes) > 0 {
perHost := make(map[string]int)
for _, v := range r.Vnodes {
perHost[v.Host]++
}
total := float64(len(r.Vnodes))
for h, c := range perHost {
ringHostShare.WithLabelValues(h).Set(float64(c) / total)
}
}
}
// ForceRingRefresh exposes a manual ring rebuild hook (primarily for tests).
func (p *SyncedPool) ForceRingRefresh() {
p.rebuildRing()
}
// ownersFor returns the ordered list of primary + replica owners for a cart id
// (length min(replicationFactor, #hosts)). Currently only the first (primary)
// is used. This scaffolds future replication work.
func (p *SyncedPool) ownersFor(id CartId) []string {
if p.ringRef == nil || p.replicationFactor <= 0 {
return []string{p.Hostname}
}
r := p.ringRef.Get()
if r == nil || r.Empty() {
return []string{p.Hostname}
}
vnodes := r.LookupN(hashKeyString(id.String()), p.replicationFactor)
out := make([]string, 0, len(vnodes))
seen := make(map[string]struct{}, len(vnodes))
for _, v := range vnodes {
if _, ok := seen[v.Host]; ok {
continue
}
seen[v.Host] = struct{}{}
out = append(out, v.Host)
}
if len(out) == 0 {
out = append(out, p.Hostname)
}
return out
}
// ownerHostFor returns the primary owner host for a given id.
func (p *SyncedPool) ownerHostFor(id CartId) string {
return p.ownersFor(id)[0]
}
// DebugOwnerHost exposes (for tests) the currently computed primary owner host.
func (p *SyncedPool) DebugOwnerHost(id CartId) string {
return p.ownerHostFor(id)
}
func (p *SyncedPool) removeLocalGrain(id CartId) {
p.mu.Lock()
delete(p.local.grains, LegacyToCartKey(id))
p.mu.Unlock()
}
// getGrain returns a local or remote grain. For remote ownership it performs a
// bounded readiness wait (small retries) to reduce first-call failures while
// the remote connection & proxy are initializing.
func (p *SyncedPool) getGrain(id CartId) (Grain, error) {
owner := p.ownerHostFor(id)
if owner == p.Hostname {
ringLookupLocal.Inc()
grain, err := p.local.GetGrain(id)
if err != nil {
return nil, err
}
return grain, nil
}
ringLookupRemote.Inc()
// Kick off remote dial if we don't yet know the owner.
if !p.IsKnown(owner) {
go p.AddRemote(owner)
}
// Fast path existing proxy
p.mu.RLock()
if rg, ok := p.remoteIndex[id]; ok {
p.mu.RUnlock()
remoteLookupCount.Inc()
return rg, nil
}
p.mu.RUnlock()
const (
attempts = 5
sleepPerTry = 40 * time.Millisecond
)
for attempt := 0; attempt < attempts; attempt++ {
// Try to spawn (idempotent if host already known)
if p.IsKnown(owner) {
p.SpawnRemoteGrain(id, owner)
}
// Check again
p.mu.RLock()
if rg, ok := p.remoteIndex[id]; ok {
p.mu.RUnlock()
remoteLookupCount.Inc()
return rg, nil
}
p.mu.RUnlock()
// Last attempt? break to return error.
if attempt == attempts-1 {
break
}
time.Sleep(sleepPerTry)
}
return nil, fmt.Errorf("remote owner %s not yet available for cart %s (after %d attempts)", owner, id.String(), attempts)
}
// Apply applies a single mutation to a grain (local or remote).
// Replication (RF>1) scaffolding: future enhancement will fan-out mutations
// to replica owners (best-effort) and reconcile quorum on read.
func (p *SyncedPool) Apply(id CartId, mutation interface{}) (*CartGrain, error) {
grain, err := p.getGrain(id)
if err != nil {
return nil, err
}
start := time.Now()
result, applyErr := grain.Apply(mutation, false)
// Derive mutation type label (strip pointer)
mutationType := "unknown"
if mutation != nil {
if t := reflect.TypeOf(mutation); t != nil {
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Name() != "" {
mutationType = t.Name()
}
}
}
cartMutationLatencySeconds.WithLabelValues(mutationType).Observe(time.Since(start).Seconds())
if applyErr == nil && result != nil {
cartMutationsTotal.Inc()
if p.ownerHostFor(id) == p.Hostname {
// Update active grains gauge only for local ownership
cartActiveGrains.Set(float64(p.local.DebugGrainCount()))
}
} else if applyErr != nil {
cartMutationFailuresTotal.Inc()
}
return result, applyErr
}
// Get returns current state of a grain (local or remote).
// Future replication hook: Read-repair or quorum read can be added here.
func (p *SyncedPool) Get(id CartId) (*CartGrain, error) {
grain, err := p.getGrain(id)
if err != nil {
return nil, err
}
return grain.GetCurrentState()
}
// Close notifies remotes this host is terminating.
func (p *SyncedPool) Close() {
p.mu.RLock()
remotes := make([]*RemoteHostGRPC, 0, len(p.remoteHosts))
for _, r := range p.remoteHosts {
remotes = append(remotes, r)
}
p.mu.RUnlock()
for _, r := range remotes {
go func(rh *RemoteHostGRPC) {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
_, err := rh.ControlClient.Closing(ctx, &proto.ClosingNotice{Host: p.Hostname})
cancel()
if err != nil {
log.Printf("Close notify to %s failed: %v", rh.Host, err)
}
}(r)
}
}

View File

@@ -1,8 +0,0 @@
/*
Legacy TCP networking (GenericListener / Frame protocol) has been removed
as part of the gRPC migration. This file intentionally contains no tests.
Keeping an empty Go file (with a package declaration) ensures the old
tcp-connection test target no longer runs without causing build issues.
*/
package main