Compare commits
66 Commits
refactor/g
...
acf2a3a8c1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acf2a3a8c1 | ||
|
|
0a86bd380a | ||
|
|
42913a079f | ||
|
|
c71b668a87 | ||
|
|
89ee3e725f | ||
|
|
aef90e2bbb | ||
|
|
834bf9f7bc | ||
|
|
de77a3b707 | ||
|
|
162a2638fa | ||
|
|
5533d241e9 | ||
|
|
b36125d664 | ||
|
|
cd0ee22ddc | ||
|
|
00fcacf1be | ||
|
|
2378635790 | ||
|
|
da28e993cd | ||
|
|
00c2ff70da | ||
|
|
eb4061f1b8 | ||
|
|
e155736313 | ||
|
|
5e7591335c | ||
|
|
c68f726a96 | ||
|
|
82d564b136 | ||
|
|
eb1f7750df | ||
|
|
ce0bac477a | ||
|
|
7c0e3e84a2 | ||
|
|
c67ebd7a5f | ||
|
|
42e38504a3 | ||
| b7ae36e53c | |||
|
|
d9fb49ec0b | ||
|
|
2202c149b8 | ||
|
|
e91433eda7 | ||
|
|
7eb000fd17 | ||
|
|
9ecd91c163 | ||
|
|
246a5ebd85 | ||
|
|
e1de5a00a0 | ||
|
|
99c9f611e7 | ||
|
|
df0cd58dcd | ||
| 86f97f2888 | |||
| af3eb0d7bf | |||
|
|
a0c82dc351 | ||
|
|
0c127e9d38 | ||
|
|
662b381a34 | ||
|
|
e127251a60 | ||
|
|
b7f0990269 | ||
|
|
d58409e3fc | ||
|
|
2ce45656d9 | ||
|
|
dc352e3b74 | ||
|
|
915d845014 | ||
|
|
4a54661f24 | ||
|
|
918aa7d265 | ||
|
|
a1833d6685 | ||
|
|
614be25ae8 | ||
|
|
71fc23bf50 | ||
| db918730d5 | |||
| 8ecad3060f | |||
|
|
c060680768 | ||
|
|
15089862d5 | ||
|
|
cdb0241c8a | ||
|
|
07a7ec5781 | ||
|
|
fa89670553 | ||
|
|
9ab0c08e79 | ||
| 8682daf481 | |||
|
|
8c2bcf5e75 | ||
|
|
47adb12112 | ||
|
|
4835041f14 | ||
| 104f9fbb4c | |||
| f5014fe906 |
@@ -47,10 +47,13 @@ jobs:
|
|||||||
docker push registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }}
|
docker push registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }}
|
||||||
- name: Apply deployment manifests
|
- name: Apply deployment manifests
|
||||||
run: kubectl apply -f deployment/deployment.yaml -n cart
|
run: kubectl apply -f deployment/deployment.yaml -n cart
|
||||||
|
- name: Rollout amd64 backoffice deployment
|
||||||
|
run: |
|
||||||
|
kubectl rollout restart deployment/cart-backoffice-x86 -n cart
|
||||||
- name: Rollout amd64 deployment (pin to version)
|
- name: Rollout amd64 deployment (pin to version)
|
||||||
run: |
|
run: |
|
||||||
kubectl set image deployment/cart-actor-x86 -n cart cart-actor-amd64=registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }}
|
kubectl set image deployment/cart-actor-x86 -n cart cart-actor-amd64=registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }}
|
||||||
kubectl rollout status deployment/cart-actor-x86 -n cart
|
# kubectl rollout status deployment/cart-actor-x86 -n cart
|
||||||
|
|
||||||
BuildAndDeployArm64:
|
BuildAndDeployArm64:
|
||||||
needs: Metadata
|
needs: Metadata
|
||||||
@@ -74,4 +77,4 @@ jobs:
|
|||||||
- name: Rollout arm64 deployment (pin to version)
|
- name: Rollout arm64 deployment (pin to version)
|
||||||
run: |
|
run: |
|
||||||
kubectl set image deployment/cart-actor-arm64 -n cart cart-actor-arm64=registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }}
|
kubectl set image deployment/cart-actor-arm64 -n cart cart-actor-arm64=registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }}
|
||||||
kubectl rollout status deployment/cart-actor-arm64 -n cart
|
# kubectl rollout status deployment/cart-actor-arm64 -n cart
|
||||||
|
|||||||
10
Dockerfile
10
Dockerfile
@@ -57,7 +57,14 @@ RUN --mount=type=cache,target=/go/build-cache \
|
|||||||
-X main.Version=${VERSION} \
|
-X main.Version=${VERSION} \
|
||||||
-X main.GitCommit=${GIT_COMMIT} \
|
-X main.GitCommit=${GIT_COMMIT} \
|
||||||
-X main.BuildDate=${BUILD_DATE}" \
|
-X main.BuildDate=${BUILD_DATE}" \
|
||||||
-o /out/go-cart-actor .
|
-o /out/go-cart-actor ./cmd/cart
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/go/build-cache \
|
||||||
|
go build -trimpath -ldflags="-s -w \
|
||||||
|
-X main.Version=${VERSION} \
|
||||||
|
-X main.GitCommit=${GIT_COMMIT} \
|
||||||
|
-X main.BuildDate=${BUILD_DATE}" \
|
||||||
|
-o /out/go-cart-backoffice ./cmd/backoffice
|
||||||
|
|
||||||
############################
|
############################
|
||||||
# Runtime Stage
|
# Runtime Stage
|
||||||
@@ -67,6 +74,7 @@ FROM gcr.io/distroless/static-debian12:nonroot AS runtime
|
|||||||
WORKDIR /
|
WORKDIR /
|
||||||
|
|
||||||
COPY --from=build /out/go-cart-actor /go-cart-actor
|
COPY --from=build /out/go-cart-actor /go-cart-actor
|
||||||
|
COPY --from=build /out/go-cart-backoffice /go-cart-backoffice
|
||||||
|
|
||||||
# Document (not expose forcibly) typical ports: 8080 (HTTP), 1337 (gRPC)
|
# Document (not expose forcibly) typical ports: 8080 (HTTP), 1337 (gRPC)
|
||||||
EXPOSE 8080 1337
|
EXPOSE 8080 1337
|
||||||
|
|||||||
6
Makefile
6
Makefile
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
MODULE_PATH := git.tornberg.me/go-cart-actor
|
MODULE_PATH := git.tornberg.me/go-cart-actor
|
||||||
PROTO_DIR := proto
|
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
|
# Allow override: make PROTOC=/path/to/protoc
|
||||||
PROTOC ?= protoc
|
PROTOC ?= protoc
|
||||||
@@ -69,8 +69,8 @@ check_tools:
|
|||||||
protogen: check_tools
|
protogen: check_tools
|
||||||
@echo "$(YELLOW)Generating protobuf code (outputs -> ./proto)...$(RESET)"
|
@echo "$(YELLOW)Generating protobuf code (outputs -> ./proto)...$(RESET)"
|
||||||
$(PROTOC) -I $(PROTO_DIR) \
|
$(PROTOC) -I $(PROTO_DIR) \
|
||||||
--go_out=./proto --go_opt=paths=source_relative \
|
--go_out=./pkg/messages --go_opt=paths=source_relative \
|
||||||
--go-grpc_out=./proto --go-grpc_opt=paths=source_relative \
|
--go-grpc_out=./pkg/messages --go-grpc_opt=paths=source_relative \
|
||||||
$(PROTOS)
|
$(PROTOS)
|
||||||
@echo "$(GREEN)Protobuf generation complete.$(RESET)"
|
@echo "$(GREEN)Protobuf generation complete.$(RESET)"
|
||||||
|
|
||||||
|
|||||||
31
README.md
31
README.md
@@ -1,36 +1,5 @@
|
|||||||
# Go Cart Actor
|
# Go Cart Actor
|
||||||
|
|
||||||
## Migration Notes (Ring-based Ownership Transition)
|
|
||||||
|
|
||||||
This release removes the legacy ConfirmOwner ownership negotiation RPC in favor of deterministic ownership via the consistent hashing ring.
|
|
||||||
|
|
||||||
Summary of changes:
|
|
||||||
- ConfirmOwner RPC removed from the ControlPlane service.
|
|
||||||
- OwnerChangeRequest message removed (was only used by ConfirmOwner).
|
|
||||||
- OwnerChangeAck retained solely as the response type for the Closing RPC.
|
|
||||||
- SyncedPool now relies exclusively on the ring for ownership (no quorum negotiation).
|
|
||||||
- Remote proxy creation includes a bounded readiness retry to reduce first-call failures.
|
|
||||||
- New Prometheus ring metrics:
|
|
||||||
- cart_ring_epoch
|
|
||||||
- cart_ring_hosts
|
|
||||||
- cart_ring_vnodes
|
|
||||||
- cart_ring_host_share{host}
|
|
||||||
- cart_ring_lookup_local_total
|
|
||||||
- cart_ring_lookup_remote_total
|
|
||||||
|
|
||||||
Action required for consumers:
|
|
||||||
1. Regenerate protobuf code after pulling (requires protoc-gen-go and protoc-gen-go-grpc installed).
|
|
||||||
2. Remove any client code or automation invoking ConfirmOwner (calls will now return UNIMPLEMENTED if using stale generated stubs).
|
|
||||||
3. Update monitoring/alerts that referenced ConfirmOwner or ownership quorum failures—use ring metrics instead.
|
|
||||||
4. If you previously interpreted “ownership flapping” via ConfirmOwner logs, now check for:
|
|
||||||
- Rapid changes in ring epoch (cart_ring_epoch)
|
|
||||||
- Host churn (cart_ring_hosts)
|
|
||||||
- Imbalance in vnode distribution (cart_ring_host_share)
|
|
||||||
|
|
||||||
No data migration is necessary; cart IDs and grain state are unaffected.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
A distributed cart management system using the actor model pattern.
|
A distributed cart management system using the actor model pattern.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AmqpOrderHandler struct {
|
|
||||||
Url string
|
|
||||||
Connection *amqp.Connection
|
|
||||||
Channel *amqp.Channel
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AmqpOrderHandler) Connect() error {
|
|
||||||
conn, err := amqp.Dial(h.Url)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to connect to RabbitMQ: %w", err)
|
|
||||||
}
|
|
||||||
h.Connection = conn
|
|
||||||
|
|
||||||
ch, err := conn.Channel()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to open a channel: %w", err)
|
|
||||||
}
|
|
||||||
h.Channel = ch
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AmqpOrderHandler) Close() error {
|
|
||||||
if h.Channel != nil {
|
|
||||||
h.Channel.Close()
|
|
||||||
}
|
|
||||||
if h.Connection != nil {
|
|
||||||
return h.Connection.Close()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AmqpOrderHandler) OrderCompleted(body []byte) error {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
err := h.Channel.PublishWithContext(ctx,
|
|
||||||
"orders", // exchange
|
|
||||||
"new", // routing key
|
|
||||||
false, // mandatory
|
|
||||||
false, // immediate
|
|
||||||
amqp.Publishing{
|
|
||||||
ContentType: "application/json",
|
|
||||||
Body: body,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to publish a message: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
294
cart-grain.go
294
cart-grain.go
@@ -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)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
327
cart_id.go
327
cart_id.go
@@ -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
|
|
||||||
}
|
|
||||||
259
cart_id_test.go
259
cart_id_test.go
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 one‑way 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 in‑memory 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
|
|
||||||
}
|
|
||||||
280
cmd/backoffice/fileserver.go
Normal file
280
cmd/backoffice/fileserver.go
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FileServer struct {
|
||||||
|
// Define fields here
|
||||||
|
dataDir string
|
||||||
|
storage actor.LogStorage[cart.CartGrain]
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFileServer(dataDir string, storage actor.LogStorage[cart.CartGrain]) *FileServer {
|
||||||
|
return &FileServer{
|
||||||
|
dataDir: dataDir,
|
||||||
|
storage: storage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidId(id string) (uint64, bool) {
|
||||||
|
if nr, err := strconv.ParseUint(id, 10, 64); err == nil {
|
||||||
|
return nr, true
|
||||||
|
}
|
||||||
|
if nr, ok := cart.ParseCartId(id); ok {
|
||||||
|
return uint64(nr), true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidFileId(name string) (uint64, bool) {
|
||||||
|
|
||||||
|
parts := strings.Split(name, ".")
|
||||||
|
if len(parts) > 1 && parts[1] == "events" {
|
||||||
|
idStr := parts[0]
|
||||||
|
|
||||||
|
return isValidId(idStr)
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// func AccessTime(info os.FileInfo) (time.Time, bool) {
|
||||||
|
// switch stat := info.Sys().(type) {
|
||||||
|
// case *syscall.Stat_t:
|
||||||
|
// // Linux: Atim; macOS/BSD: Atimespec
|
||||||
|
// // Use reflection or build tags if naming differs.
|
||||||
|
// // Linux:
|
||||||
|
// if stat.Atim.Sec != 0 || stat.Atim.Nsec != 0 {
|
||||||
|
// return time.Unix(int64(stat.Atim.Sec), int64(stat.Atim.Nsec)), true
|
||||||
|
// }
|
||||||
|
// // macOS/BSD example (uncomment if needed):
|
||||||
|
// //return time.Unix(int64(stat.Atimespec.Sec), int64(stat.Atimespec.Nsec)), true
|
||||||
|
// }
|
||||||
|
// return time.Time{}, false
|
||||||
|
// }
|
||||||
|
|
||||||
|
func appendFileInfo(info fs.FileInfo, out *CartFileInfo) *CartFileInfo {
|
||||||
|
//sys := info.Sys()
|
||||||
|
//fmt.Printf("sys type %T", sys)
|
||||||
|
out.Size = info.Size()
|
||||||
|
out.Modified = info.ModTime()
|
||||||
|
//out.Accessed, _ = AccessTime(info)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// var cartFileRe = regexp.MustCompile(`^(\d+)\.events\.log$`)
|
||||||
|
|
||||||
|
func listCartFiles(dir string) ([]*CartFileInfo, error) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return []*CartFileInfo{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]*CartFileInfo, 0)
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, valid := isValidFileId(e.Name())
|
||||||
|
if !valid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info.Sys()
|
||||||
|
out = append(out, appendFileInfo(info, &CartFileInfo{
|
||||||
|
ID: fmt.Sprintf("%d", id),
|
||||||
|
CartId: cart.CartId(id),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readRawLogLines(path string) ([]json.RawMessage, error) {
|
||||||
|
fh, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
lines := make([]json.RawMessage, 0, 64)
|
||||||
|
s := bufio.NewScanner(fh)
|
||||||
|
// increase buffer to handle larger JSON lines
|
||||||
|
buf := make([]byte, 0, 1024*1024)
|
||||||
|
s.Buffer(buf, 1024*1024)
|
||||||
|
for s.Scan() {
|
||||||
|
line := s.Bytes()
|
||||||
|
if line == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
if err := s.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return lines, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FileServer) CartsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
list, err := listCartFiles(fs.dataDir)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// sort by modified desc
|
||||||
|
sort.Slice(list, func(i, j int) bool { return list[i].Modified.After(list[j].Modified) })
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"count": len(list),
|
||||||
|
"carts": list,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FileServer) PromotionsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fileName := filepath.Join(fs.dataDir, "promotions.json")
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
file, err := os.Open(fileName)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
io.Copy(w, file)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
file, err := os.Create(fileName)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
io.Copy(file, r.Body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FileServer) VoucherHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fileName := filepath.Join(fs.dataDir, "vouchers.json")
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
file, err := os.Open(fileName)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
io.Copy(w, file)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
file, err := os.Create(fileName)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
io.Copy(file, r.Body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FileServer) PromotionPartHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
if idStr == "" {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
fmt.Fprintf(w, "missing id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, ok := isValidId(idStr)
|
||||||
|
if !ok {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
fmt.Fprintf(w, "invalid id %s", idStr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNotImplemented)
|
||||||
|
}
|
||||||
|
|
||||||
|
type JsonError struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *FileServer) CartHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
if idStr == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, JsonError{Error: "missing id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := isValidId(idStr)
|
||||||
|
if !ok {
|
||||||
|
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// reconstruct state from event log if present
|
||||||
|
grain := cart.NewCartGrain(id, time.Now())
|
||||||
|
|
||||||
|
err := fs.storage.LoadEvents(id, grain)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(fs.dataDir, fmt.Sprintf("%d.events.log", id))
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil && errors.Is(err, os.ErrNotExist) {
|
||||||
|
writeJSON(w, http.StatusNotFound, JsonError{Error: "cart not found"})
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lines, err := readRawLogLines(path)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"id": id,
|
||||||
|
"cartId": cart.CartId(id).String(),
|
||||||
|
"state": grain,
|
||||||
|
"mutations": lines,
|
||||||
|
"meta": map[string]any{
|
||||||
|
"size": info.Size(),
|
||||||
|
"modified": info.ModTime(),
|
||||||
|
"path": path,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
89
cmd/backoffice/fileserver_test.go
Normal file
89
cmd/backoffice/fileserver_test.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAppendFileInfoRandomProjectFile picks a random existing .go source file in the
|
||||||
|
// repository (from a small curated list to keep the test hermetic) and verifies
|
||||||
|
// that appendFileInfo populates Size, Modified and System without mutating the
|
||||||
|
// identity fields (ID, CartId). The randomness is only to satisfy the requirement
|
||||||
|
// of using "a random project file"; the test behavior is deterministic enough for
|
||||||
|
// CI because all chosen files are expected to exist.
|
||||||
|
func TestAppendFileInfoRandomProjectFile(t *testing.T) {
|
||||||
|
candidates := []string{
|
||||||
|
filepath.FromSlash("../../pkg/cart/cart_id.go"),
|
||||||
|
filepath.FromSlash("../../pkg/actor/grain.go"),
|
||||||
|
filepath.FromSlash("../../cmd/cart/main.go"),
|
||||||
|
}
|
||||||
|
// Pick one at random.
|
||||||
|
rand.Seed(time.Now().UnixNano())
|
||||||
|
path := candidates[rand.Intn(len(candidates))]
|
||||||
|
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat failed for %s: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-populate a CartFileInfo with identity fields.
|
||||||
|
origID := "test-id"
|
||||||
|
origCartId := cart.CartId(12345)
|
||||||
|
cf := &CartFileInfo{ID: origID, CartId: origCartId}
|
||||||
|
|
||||||
|
// Call function under test.
|
||||||
|
got := appendFileInfo(info, cf)
|
||||||
|
|
||||||
|
if got != cf {
|
||||||
|
t.Fatalf("appendFileInfo should return the same pointer instance")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cf.ID != origID {
|
||||||
|
t.Fatalf("ID mutated: expected %q got %q", origID, cf.ID)
|
||||||
|
}
|
||||||
|
if cf.CartId != origCartId {
|
||||||
|
t.Fatalf("CartId mutated: expected %v got %v", origCartId, cf.CartId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cf.Size != info.Size() {
|
||||||
|
t.Fatalf("Size mismatch: expected %d got %d", info.Size(), cf.Size)
|
||||||
|
}
|
||||||
|
|
||||||
|
mod := info.ModTime()
|
||||||
|
// Allow small clock skew / coarse timestamp truncation.
|
||||||
|
if cf.Modified.Before(mod.Add(-2*time.Second)) || cf.Modified.After(mod.Add(2*time.Second)) {
|
||||||
|
t.Fatalf("Modified not within expected range: want ~%v got %v", mod, cf.Modified)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAppendFileInfoTempFile creates a temporary file to ensure Size and Modified
|
||||||
|
// are updated for a freshly written file with known content length.
|
||||||
|
func TestAppendFileInfoTempFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "temp.events.log")
|
||||||
|
content := []byte("hello world\nanother line\n")
|
||||||
|
if err := os.WriteFile(path, content, 0644); err != nil {
|
||||||
|
t.Fatalf("write temp file failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat temp file failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cf := &CartFileInfo{ID: "temp", CartId: cart.CartId(0)}
|
||||||
|
appendFileInfo(info, cf)
|
||||||
|
|
||||||
|
if cf.Size != int64(len(content)) {
|
||||||
|
t.Fatalf("expected Size %d got %d", len(content), cf.Size)
|
||||||
|
}
|
||||||
|
if cf.Modified.IsZero() {
|
||||||
|
t.Fatalf("Modified should be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
248
cmd/backoffice/hub.go
Normal file
248
cmd/backoffice/hub.go
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hub manages websocket clients and broadcasts messages to them.
|
||||||
|
type Hub struct {
|
||||||
|
register chan *Client
|
||||||
|
unregister chan *Client
|
||||||
|
broadcast chan []byte
|
||||||
|
clients map[*Client]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client represents a single websocket client connection.
|
||||||
|
type Client struct {
|
||||||
|
hub *Hub
|
||||||
|
conn net.Conn
|
||||||
|
send chan []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHub constructs a new Hub instance.
|
||||||
|
func NewHub() *Hub {
|
||||||
|
return &Hub{
|
||||||
|
register: make(chan *Client),
|
||||||
|
unregister: make(chan *Client),
|
||||||
|
broadcast: make(chan []byte, 1024),
|
||||||
|
clients: make(map[*Client]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the hub event loop.
|
||||||
|
func (h *Hub) Run() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case c := <-h.register:
|
||||||
|
h.clients[c] = true
|
||||||
|
case c := <-h.unregister:
|
||||||
|
if _, ok := h.clients[c]; ok {
|
||||||
|
delete(h.clients, c)
|
||||||
|
close(c.send)
|
||||||
|
_ = c.conn.Close()
|
||||||
|
}
|
||||||
|
case msg := <-h.broadcast:
|
||||||
|
for c := range h.clients {
|
||||||
|
select {
|
||||||
|
case c.send <- msg:
|
||||||
|
default:
|
||||||
|
// Client is slow or dead; drop it.
|
||||||
|
delete(h.clients, c)
|
||||||
|
close(c.send)
|
||||||
|
_ = c.conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeAccept computes the Sec-WebSocket-Accept header value.
|
||||||
|
func computeAccept(key string) string {
|
||||||
|
const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||||
|
h := sha1.New()
|
||||||
|
h.Write([]byte(key + magic))
|
||||||
|
return base64.StdEncoding.EncodeToString(h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeWS upgrades the HTTP request to a WebSocket connection and registers a client.
|
||||||
|
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") || strings.ToLower(r.Header.Get("Upgrade")) != "websocket" {
|
||||||
|
http.Error(w, "upgrade required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := r.Header.Get("Sec-WebSocket-Key")
|
||||||
|
if key == "" {
|
||||||
|
http.Error(w, "missing Sec-WebSocket-Key", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accept := computeAccept(key)
|
||||||
|
|
||||||
|
hj, ok := w.(http.Hijacker)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "websocket not supported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn, buf, err := hj.Hijack()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the upgrade response
|
||||||
|
response := "HTTP/1.1 101 Switching Protocols\r\n" +
|
||||||
|
"Upgrade: websocket\r\n" +
|
||||||
|
"Connection: Upgrade\r\n" +
|
||||||
|
"Sec-WebSocket-Accept: " + accept + "\r\n" +
|
||||||
|
"\r\n"
|
||||||
|
if _, err := buf.WriteString(response); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := buf.Flush(); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &Client{
|
||||||
|
hub: h,
|
||||||
|
conn: conn,
|
||||||
|
send: make(chan []byte, 256),
|
||||||
|
}
|
||||||
|
h.register <- client
|
||||||
|
go client.writePump()
|
||||||
|
go client.readPump()
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeWSFrame writes a single WebSocket frame to the writer.
|
||||||
|
func writeWSFrame(w io.Writer, opcode byte, payload []byte) error {
|
||||||
|
// FIN set, opcode as provided
|
||||||
|
header := []byte{0x80 | (opcode & 0x0F)}
|
||||||
|
l := len(payload)
|
||||||
|
switch {
|
||||||
|
case l < 126:
|
||||||
|
header = append(header, byte(l))
|
||||||
|
case l <= 65535:
|
||||||
|
ext := make([]byte, 2)
|
||||||
|
binary.BigEndian.PutUint16(ext, uint16(l))
|
||||||
|
header = append(header, 126)
|
||||||
|
header = append(header, ext...)
|
||||||
|
default:
|
||||||
|
ext := make([]byte, 8)
|
||||||
|
binary.BigEndian.PutUint64(ext, uint64(l))
|
||||||
|
header = append(header, 127)
|
||||||
|
header = append(header, ext...)
|
||||||
|
}
|
||||||
|
if _, err := w.Write(header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if l > 0 {
|
||||||
|
if _, err := w.Write(payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPump handles control frames from the client and discards other incoming frames.
|
||||||
|
// This server is broadcast-only to clients.
|
||||||
|
func (c *Client) readPump() {
|
||||||
|
defer func() {
|
||||||
|
c.hub.unregister <- c
|
||||||
|
}()
|
||||||
|
reader := bufio.NewReader(c.conn)
|
||||||
|
for {
|
||||||
|
// Read first two bytes
|
||||||
|
b1, err := reader.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b2, err := reader.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
opcode := b1 & 0x0F
|
||||||
|
masked := (b2 & 0x80) != 0
|
||||||
|
length := int64(b2 & 0x7F)
|
||||||
|
if length == 126 {
|
||||||
|
ext := make([]byte, 2)
|
||||||
|
if _, err := io.ReadFull(reader, ext); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
length = int64(binary.BigEndian.Uint16(ext))
|
||||||
|
} else if length == 127 {
|
||||||
|
ext := make([]byte, 8)
|
||||||
|
if _, err := io.ReadFull(reader, ext); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
length = int64(binary.BigEndian.Uint64(ext))
|
||||||
|
}
|
||||||
|
var maskKey [4]byte
|
||||||
|
if masked {
|
||||||
|
if _, err := io.ReadFull(reader, maskKey[:]); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Ping -> Pong
|
||||||
|
if opcode == 0x9 && length <= 125 {
|
||||||
|
payload := make([]byte, length)
|
||||||
|
if _, err := io.ReadFull(reader, payload); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Unmask if masked
|
||||||
|
if masked {
|
||||||
|
for i := int64(0); i < length; i++ {
|
||||||
|
payload[i] ^= maskKey[i%4]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = writeWSFrame(c.conn, 0xA, payload) // best-effort pong
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close frame
|
||||||
|
if opcode == 0x8 {
|
||||||
|
// Drain payload if any, then exit
|
||||||
|
if _, err := io.CopyN(io.Discard, reader, length); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other frames, just discard payload
|
||||||
|
if _, err := io.CopyN(io.Discard, reader, length); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePump sends queued messages to the client and pings periodically to keep the connection alive.
|
||||||
|
func (c *Client) writePump() {
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer func() {
|
||||||
|
ticker.Stop()
|
||||||
|
_ = c.conn.Close()
|
||||||
|
}()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case msg, ok := <-c.send:
|
||||||
|
if !ok {
|
||||||
|
// try to send close frame
|
||||||
|
_ = writeWSFrame(c.conn, 0x8, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := writeWSFrame(c.conn, 0x1, msg); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-ticker.C:
|
||||||
|
// Send a ping to keep connections alive behind proxies
|
||||||
|
_ = writeWSFrame(c.conn, 0x9, []byte("ping"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
152
cmd/backoffice/main.go
Normal file
152
cmd/backoffice/main.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
actor "git.tornberg.me/go-cart-actor/pkg/actor"
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||||
|
"github.com/matst80/slask-finder/pkg/messaging"
|
||||||
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CartFileInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CartId cart.CartId `json:"cartId"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Modified time.Time `json:"modified"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOrDefault(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func startMutationConsumer(ctx context.Context, conn *amqp.Connection, hub *Hub) error {
|
||||||
|
ch, err := conn.Channel()
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
msgs, err := messaging.DeclareBindAndConsume(ch, "cart", "mutation")
|
||||||
|
if err != nil {
|
||||||
|
_ = ch.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer ch.Close()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case m, ok := <-msgs:
|
||||||
|
if !ok {
|
||||||
|
log.Fatalf("connection closed")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Log and broadcast to all websocket clients
|
||||||
|
log.Printf("mutation event: %s", string(m.Body))
|
||||||
|
|
||||||
|
if hub != nil {
|
||||||
|
select {
|
||||||
|
case hub.broadcast <- m.Body:
|
||||||
|
default:
|
||||||
|
// if hub queue is full, drop to avoid blocking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := m.Ack(false); err != nil {
|
||||||
|
log.Printf("error acknowledging message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dataDir := envOrDefault("DATA_DIR", "data")
|
||||||
|
addr := envOrDefault("ADDR", ":8080")
|
||||||
|
amqpURL := os.Getenv("AMQP_URL")
|
||||||
|
|
||||||
|
_ = os.MkdirAll(dataDir, 0755)
|
||||||
|
|
||||||
|
reg := cart.NewCartMultationRegistry()
|
||||||
|
diskStorage := actor.NewDiskStorage[cart.CartGrain](dataDir, reg)
|
||||||
|
|
||||||
|
fs := NewFileServer(dataDir, diskStorage)
|
||||||
|
|
||||||
|
hub := NewHub()
|
||||||
|
go hub.Run()
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /carts", fs.CartsHandler)
|
||||||
|
mux.HandleFunc("GET /cart/{id}", fs.CartHandler)
|
||||||
|
mux.HandleFunc("/promotions", fs.PromotionsHandler)
|
||||||
|
mux.HandleFunc("/vouchers", fs.VoucherHandler)
|
||||||
|
mux.HandleFunc("/promotion/{id}", fs.PromotionPartHandler)
|
||||||
|
|
||||||
|
mux.HandleFunc("/ws", hub.ServeWS)
|
||||||
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Global CORS middleware allowing all origins and handling preflight
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||||
|
w.Header().Set("Access-Control-Expose-Headers", "*")
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mux.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: handler,
|
||||||
|
ReadTimeout: 15 * time.Second,
|
||||||
|
WriteTimeout: 30 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if amqpURL != "" {
|
||||||
|
conn, err := amqp.Dial(amqpURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to connect to RabbitMQ: %v", err)
|
||||||
|
}
|
||||||
|
if err := startMutationConsumer(ctx, conn, hub); err != nil {
|
||||||
|
log.Printf("AMQP listener disabled: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("AMQP listener connected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("backoffice HTTP listening on %s (dataDir=%s)", addr, dataDir)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
log.Fatalf("http server error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// server stopped
|
||||||
|
}
|
||||||
63
cmd/cart/amqp-order-handler.go
Normal file
63
cmd/cart/amqp-order-handler.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AmqpOrderHandler struct {
|
||||||
|
conn *amqp.Connection
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAmqpOrderHandler(conn *amqp.Connection) *AmqpOrderHandler {
|
||||||
|
return &AmqpOrderHandler{
|
||||||
|
conn: conn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AmqpOrderHandler) DefineTopics() error {
|
||||||
|
ch, err := h.conn.Channel()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open a channel: %w", err)
|
||||||
|
}
|
||||||
|
defer ch.Close()
|
||||||
|
|
||||||
|
err = ch.ExchangeDeclare(
|
||||||
|
"orders", // name
|
||||||
|
"direct", // type
|
||||||
|
true, // durable
|
||||||
|
false, // auto-deleted
|
||||||
|
false, // internal
|
||||||
|
false, // no-wait
|
||||||
|
nil, // arguments
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to declare an exchange: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AmqpOrderHandler) OrderCompleted(body []byte) error {
|
||||||
|
ch, err := h.conn.Channel()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open a channel: %w", err)
|
||||||
|
}
|
||||||
|
defer ch.Close()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
return ch.PublishWithContext(ctx,
|
||||||
|
"orders", // exchange
|
||||||
|
"new", // routing key
|
||||||
|
false, // mandatory
|
||||||
|
false, // immediate
|
||||||
|
amqp.Publishing{
|
||||||
|
ContentType: "application/json",
|
||||||
|
Body: body,
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CheckoutMeta carries the external / URL metadata required to build a
|
// CheckoutMeta carries the external / URL metadata required to build a
|
||||||
@@ -33,7 +35,7 @@ type CheckoutMeta struct {
|
|||||||
//
|
//
|
||||||
// If you later need to support different tax rates per line, you can extend
|
// If you later need to support different tax rates per line, you can extend
|
||||||
// CartItem / Delivery to expose that data and propagate it here.
|
// CartItem / Delivery to expose that data and propagate it here.
|
||||||
func BuildCheckoutOrderPayload(grain *CartGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
|
func BuildCheckoutOrderPayload(grain *cart.CartGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
|
||||||
if grain == nil {
|
if grain == nil {
|
||||||
return nil, nil, fmt.Errorf("nil grain")
|
return nil, nil, fmt.Errorf("nil grain")
|
||||||
}
|
}
|
||||||
@@ -64,20 +66,20 @@ func BuildCheckoutOrderPayload(grain *CartGrain, meta *CheckoutMeta) ([]byte, *C
|
|||||||
lines = append(lines, &Line{
|
lines = append(lines, &Line{
|
||||||
Type: "physical",
|
Type: "physical",
|
||||||
Reference: it.Sku,
|
Reference: it.Sku,
|
||||||
Name: it.Name,
|
Name: it.Meta.Name,
|
||||||
Quantity: it.Quantity,
|
Quantity: it.Quantity,
|
||||||
UnitPrice: int(it.Price),
|
UnitPrice: int(it.Price.IncVat),
|
||||||
TaxRate: 2500, // TODO: derive if variable tax rates are introduced
|
TaxRate: 2500, // TODO: derive if variable tax rates are introduced
|
||||||
QuantityUnit: "st",
|
QuantityUnit: "st",
|
||||||
TotalAmount: int(it.TotalPrice),
|
TotalAmount: int(it.TotalPrice.IncVat),
|
||||||
TotalTaxAmount: int(it.TotalTax),
|
TotalTaxAmount: int(it.TotalPrice.TotalVat()),
|
||||||
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", it.Image),
|
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", it.Meta.Image),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delivery lines
|
// Delivery lines
|
||||||
for _, d := range grain.Deliveries {
|
for _, d := range grain.Deliveries {
|
||||||
if d == nil || d.Price <= 0 {
|
if d == nil || d.Price.IncVat <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
lines = append(lines, &Line{
|
lines = append(lines, &Line{
|
||||||
@@ -85,11 +87,11 @@ func BuildCheckoutOrderPayload(grain *CartGrain, meta *CheckoutMeta) ([]byte, *C
|
|||||||
Reference: d.Provider,
|
Reference: d.Provider,
|
||||||
Name: "Delivery",
|
Name: "Delivery",
|
||||||
Quantity: 1,
|
Quantity: 1,
|
||||||
UnitPrice: int(d.Price),
|
UnitPrice: int(d.Price.IncVat),
|
||||||
TaxRate: 2500,
|
TaxRate: 2500,
|
||||||
QuantityUnit: "st",
|
QuantityUnit: "st",
|
||||||
TotalAmount: int(d.Price),
|
TotalAmount: int(d.Price.IncVat),
|
||||||
TotalTaxAmount: int(GetTaxAmount(d.Price, 2500)),
|
TotalTaxAmount: int(d.Price.TotalVat()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,8 +99,8 @@ func BuildCheckoutOrderPayload(grain *CartGrain, meta *CheckoutMeta) ([]byte, *C
|
|||||||
PurchaseCountry: country,
|
PurchaseCountry: country,
|
||||||
PurchaseCurrency: currency,
|
PurchaseCurrency: currency,
|
||||||
Locale: locale,
|
Locale: locale,
|
||||||
OrderAmount: int(grain.TotalPrice),
|
OrderAmount: int(grain.TotalPrice.IncVat),
|
||||||
OrderTaxAmount: int(grain.TotalTax),
|
OrderTaxAmount: int(grain.TotalPrice.TotalVat()),
|
||||||
OrderLines: lines,
|
OrderLines: lines,
|
||||||
MerchantReference1: grain.Id.String(),
|
MerchantReference1: grain.Id.String(),
|
||||||
MerchantURLS: &CheckoutMerchantURLS{
|
MerchantURLS: &CheckoutMerchantURLS{
|
||||||
120
cmd/cart/checkout_server.go
Normal file
120
cmd/cart/checkout_server.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||||
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) HandleCheckoutRequests(amqpUrl string, mux *http.ServeMux) {
|
||||||
|
conn, err := amqp.Dial(amqpUrl)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to connect to RabbitMQ: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
amqpListener := actor.NewAmqpListener(conn, func(id uint64, msg []actor.ApplyResult) (any, error) {
|
||||||
|
return &CartChangeEvent{
|
||||||
|
CartId: cart.CartId(id),
|
||||||
|
Mutations: msg,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
amqpListener.DefineTopics()
|
||||||
|
a.pool.AddListener(amqpListener)
|
||||||
|
orderHandler := NewAmqpOrderHandler(conn)
|
||||||
|
orderHandler.DefineTopics()
|
||||||
|
|
||||||
|
mux.HandleFunc("/push", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orderId := r.URL.Query().Get("order_id")
|
||||||
|
log.Printf("Order confirmation push: %s", orderId)
|
||||||
|
|
||||||
|
order, err := a.klarnaClient.GetOrder(orderId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error creating request: %v\n", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = confirmOrder(order, orderHandler)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error confirming order: %v\n", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = triggerOrderCompleted(a.server, order)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error processing cart message: %v\n", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = a.klarnaClient.AcknowledgeOrder(orderId)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error acknowledging order: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/checkout", a.server.CheckoutHandler(func(order *CheckoutOrder, w http.ResponseWriter) error {
|
||||||
|
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)
|
||||||
|
_, err := fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
||||||
|
return err
|
||||||
|
}))
|
||||||
|
|
||||||
|
mux.HandleFunc("/confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
orderId := r.PathValue("order_id")
|
||||||
|
order, err := a.klarnaClient.GetOrder(orderId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if order.Status == "checkout_complete" {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "cartid",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
Secure: true,
|
||||||
|
HttpOnly: true,
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
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)
|
||||||
|
if r.Method != "POST" {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
order := &CheckoutOrder{}
|
||||||
|
err := json.NewDecoder(r.Body).Decode(order)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
log.Printf("Klarna order validation: %s", order.ID)
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
}
|
||||||
60
cmd/cart/k8s-host-discovery.go
Normal file
60
cmd/cart/k8s-host-discovery.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/discovery"
|
||||||
|
"k8s.io/apimachinery/pkg/watch"
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
|
"k8s.io/client-go/rest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetDiscovery() discovery.Discovery {
|
||||||
|
if podIp == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
config, kerr := rest.InClusterConfig()
|
||||||
|
|
||||||
|
if kerr != nil {
|
||||||
|
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
|
||||||
|
}
|
||||||
|
client, err := kubernetes.NewForConfig(config)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error creating client: %v\n", err)
|
||||||
|
}
|
||||||
|
return discovery.NewK8sDiscovery(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UseDiscovery(pool actor.GrainPool[*cart.CartGrain]) {
|
||||||
|
|
||||||
|
go func(hw discovery.Discovery) {
|
||||||
|
if hw == nil {
|
||||||
|
log.Print("No discovery service available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch, err := hw.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 pool.IsKnown(evt.Host) {
|
||||||
|
pool.RemoveHost(evt.Host)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if !pool.IsKnown(evt.Host) {
|
||||||
|
log.Printf("Discovered host %s", evt.Host)
|
||||||
|
pool.AddRemoteHost(evt.Host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(GetDiscovery())
|
||||||
|
}
|
||||||
279
cmd/cart/main.go
Normal file
279
cmd/cart/main.go
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/pprof"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||||
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/promotions"
|
||||||
|
"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"
|
||||||
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
grainSpawns = promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "cart_grain_spawned_total",
|
||||||
|
Help: "The total number of spawned grains",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
os.Mkdir("data", 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
pool *actor.SimpleGrainPool[cart.CartGrain]
|
||||||
|
server *PoolServer
|
||||||
|
klarnaClient *KlarnaClient
|
||||||
|
}
|
||||||
|
|
||||||
|
var podIp = os.Getenv("POD_IP")
|
||||||
|
var name = os.Getenv("POD_NAME")
|
||||||
|
var amqpUrl = os.Getenv("AMQP_URL")
|
||||||
|
|
||||||
|
var tpl = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>s10r testing - checkout</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
%s
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|
||||||
|
func getCountryFromHost(host string) string {
|
||||||
|
if strings.Contains(strings.ToLower(host), "-no") {
|
||||||
|
return "no"
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(host), "-se") {
|
||||||
|
return "se"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type MutationContext struct {
|
||||||
|
VoucherService voucher.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
type CartChangeEvent struct {
|
||||||
|
CartId cart.CartId `json:"cartId"`
|
||||||
|
Mutations []actor.ApplyResult `json:"mutations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
|
||||||
|
controlPlaneConfig := actor.DefaultServerConfig()
|
||||||
|
|
||||||
|
promotionData, err := promotions.LoadStateFile("data/promotions.json")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error loading promotions: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("loaded %d promotions", len(promotionData.State.Promotions))
|
||||||
|
|
||||||
|
promotionService := promotions.NewPromotionService(nil)
|
||||||
|
|
||||||
|
reg := cart.NewCartMultationRegistry()
|
||||||
|
reg.RegisterProcessor(
|
||||||
|
actor.NewMutationProcessor(func(g *cart.CartGrain) error {
|
||||||
|
g.UpdateTotals()
|
||||||
|
ctx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()), promotions.WithCustomerSegment("vip"))
|
||||||
|
_, actions := promotionService.EvaluateAll(promotionData.State.Promotions, ctx)
|
||||||
|
for _, action := range actions {
|
||||||
|
log.Printf("apply: %+v", action)
|
||||||
|
g.UpdateTotals()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
diskStorage := actor.NewDiskStorage[cart.CartGrain]("data", reg)
|
||||||
|
poolConfig := actor.GrainPoolConfig[cart.CartGrain]{
|
||||||
|
MutationRegistry: reg,
|
||||||
|
Storage: diskStorage,
|
||||||
|
Spawn: func(id uint64) (actor.Grain[cart.CartGrain], error) {
|
||||||
|
grainSpawns.Inc()
|
||||||
|
ret := cart.NewCartGrain(id, time.Now())
|
||||||
|
// Set baseline lastChange at spawn; replay may update it to last event timestamp.
|
||||||
|
|
||||||
|
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.Fatalf("Error creating cart pool: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
klarnaClient := NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
|
||||||
|
|
||||||
|
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient)
|
||||||
|
|
||||||
|
app := &App{
|
||||||
|
pool: pool,
|
||||||
|
server: syncedServer,
|
||||||
|
klarnaClient: klarnaClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
debugMux := http.NewServeMux()
|
||||||
|
|
||||||
|
if amqpUrl == "" {
|
||||||
|
log.Printf("no connection to amqp defined")
|
||||||
|
} else {
|
||||||
|
app.HandleCheckoutRequests(amqpUrl, mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
grpcSrv, err := actor.NewControlServer[*cart.CartGrain](controlPlaneConfig, pool)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
|
||||||
|
}
|
||||||
|
defer grpcSrv.GracefulStop()
|
||||||
|
|
||||||
|
// go diskStorage.SaveLoop(10 * time.Second)
|
||||||
|
UseDiscovery(pool)
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
otelShutdown, err := setupOTelSDK(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Unable to start otel %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
syncedServer.Serve(mux)
|
||||||
|
// only for local
|
||||||
|
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pool.AddRemote(r.PathValue("host"))
|
||||||
|
})
|
||||||
|
|
||||||
|
debugMux.HandleFunc("/debug/pprof/", pprof.Index)
|
||||||
|
debugMux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||||
|
debugMux.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
||||||
|
debugMux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
||||||
|
debugMux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||||
|
debugMux.Handle("/metrics", promhttp.Handler())
|
||||||
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Grain pool health: simple capacity check (mirrors previous GrainHandler.IsHealthy)
|
||||||
|
grainCount, capacity := app.pool.LocalUsage()
|
||||||
|
if grainCount >= capacity {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte("grain pool at capacity"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !pool.IsHealthy() {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte("control plane not healthy"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("1.0.0"))
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/openapi.json", ServeEmbeddedOpenAPI)
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: ":8080",
|
||||||
|
BaseContext: func(net.Listener) context.Context { return ctx },
|
||||||
|
ReadTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 20 * time.Second,
|
||||||
|
Handler: otelhttp.NewHandler(mux, "/"),
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
|
||||||
|
fmt.Println("Shutting down due to signal")
|
||||||
|
otelShutdown(context.Background())
|
||||||
|
diskStorage.Close()
|
||||||
|
pool.Close()
|
||||||
|
|
||||||
|
}()
|
||||||
|
|
||||||
|
srvErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
srvErr <- srv.ListenAndServe()
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Print("Server started at port 8080")
|
||||||
|
|
||||||
|
go http.ListenAndServe(":8081", debugMux)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err = <-srvErr:
|
||||||
|
// Error when starting HTTP server.
|
||||||
|
log.Fatalf("Unable to start server: %v", err)
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Wait for first CTRL+C.
|
||||||
|
// Stop receiving signal notifications as soon as possible.
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func triggerOrderCompleted(syncedServer *PoolServer, order *CheckoutOrder) error {
|
||||||
|
mutation := &messages.OrderCreated{
|
||||||
|
OrderId: order.ID,
|
||||||
|
Status: order.Status,
|
||||||
|
}
|
||||||
|
cid, ok := cart.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 {
|
||||||
|
orderToSend, err := json.Marshal(order)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = orderHandler.OrderCompleted(orderToSend)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
677
cmd/cart/openapi.json
Normal file
677
cmd/cart/openapi.json
Normal 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
69
cmd/cart/openapi_embed.go
Normal 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
|
||||||
|
}
|
||||||
117
cmd/cart/otel.go
Normal file
117
cmd/cart/otel.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
|
||||||
|
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||||
|
"go.opentelemetry.io/otel/exporters/prometheus"
|
||||||
|
"go.opentelemetry.io/otel/log/global"
|
||||||
|
"go.opentelemetry.io/otel/propagation"
|
||||||
|
"go.opentelemetry.io/otel/sdk/log"
|
||||||
|
"go.opentelemetry.io/otel/sdk/metric"
|
||||||
|
"go.opentelemetry.io/otel/sdk/trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupOTelSDK bootstraps the OpenTelemetry pipeline.
|
||||||
|
// If it does not return an error, make sure to call shutdown for proper cleanup.
|
||||||
|
func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
|
||||||
|
var shutdownFuncs []func(context.Context) error
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// shutdown calls cleanup functions registered via shutdownFuncs.
|
||||||
|
// The errors from the calls are joined.
|
||||||
|
// Each registered cleanup will be invoked once.
|
||||||
|
shutdown := func(ctx context.Context) error {
|
||||||
|
var err error
|
||||||
|
for _, fn := range shutdownFuncs {
|
||||||
|
err = errors.Join(err, fn(ctx))
|
||||||
|
}
|
||||||
|
shutdownFuncs = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleErr calls shutdown for cleanup and makes sure that all errors are returned.
|
||||||
|
handleErr := func(inErr error) {
|
||||||
|
err = errors.Join(inErr, shutdown(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up propagator.
|
||||||
|
prop := newPropagator()
|
||||||
|
otel.SetTextMapPropagator(prop)
|
||||||
|
|
||||||
|
// Set up trace provider.
|
||||||
|
tracerProvider, err := newTracerProvider()
|
||||||
|
if err != nil {
|
||||||
|
handleErr(err)
|
||||||
|
return shutdown, err
|
||||||
|
}
|
||||||
|
shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
|
||||||
|
otel.SetTracerProvider(tracerProvider)
|
||||||
|
|
||||||
|
// Set up meter provider.
|
||||||
|
meterProvider, err := newMeterProvider()
|
||||||
|
if err != nil {
|
||||||
|
handleErr(err)
|
||||||
|
return shutdown, err
|
||||||
|
}
|
||||||
|
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
|
||||||
|
otel.SetMeterProvider(meterProvider)
|
||||||
|
|
||||||
|
// Set up logger provider.
|
||||||
|
loggerProvider, err := newLoggerProvider()
|
||||||
|
if err != nil {
|
||||||
|
handleErr(err)
|
||||||
|
return shutdown, err
|
||||||
|
}
|
||||||
|
shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
|
||||||
|
global.SetLoggerProvider(loggerProvider)
|
||||||
|
|
||||||
|
return shutdown, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPropagator() propagation.TextMapPropagator {
|
||||||
|
return propagation.NewCompositeTextMapPropagator(
|
||||||
|
propagation.TraceContext{},
|
||||||
|
propagation.Baggage{},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTracerProvider() (*trace.TracerProvider, error) {
|
||||||
|
traceExporter, err := otlptracegrpc.New(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tracerProvider := trace.NewTracerProvider(
|
||||||
|
trace.WithBatcher(traceExporter,
|
||||||
|
// Default is 5s. Set to 1s for demonstrative purposes.
|
||||||
|
trace.WithBatchTimeout(time.Second)),
|
||||||
|
)
|
||||||
|
return tracerProvider, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMeterProvider() (*metric.MeterProvider, error) {
|
||||||
|
exporter, err := prometheus.New()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := metric.NewMeterProvider(metric.WithReader(exporter))
|
||||||
|
return provider, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLoggerProvider() (*log.LoggerProvider, error) {
|
||||||
|
logExporter, err := otlploggrpc.New(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
loggerProvider := log.NewLoggerProvider(
|
||||||
|
log.WithProcessor(log.NewBatchProcessor(logExporter)),
|
||||||
|
)
|
||||||
|
return loggerProvider, nil
|
||||||
|
}
|
||||||
613
cmd/cart/pool-server.go
Normal file
613
cmd/cart/pool-server.go
Normal file
@@ -0,0 +1,613 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||||
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/voucher"
|
||||||
|
"github.com/gogo/protobuf/proto"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||||
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "cart_grain_mutations_total",
|
||||||
|
Help: "The total number of mutations",
|
||||||
|
})
|
||||||
|
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "cart_grain_lookups_total",
|
||||||
|
Help: "The total number of lookups",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
type PoolServer struct {
|
||||||
|
actor.GrainPool[*cart.CartGrain]
|
||||||
|
pod_name string
|
||||||
|
klarnaClient *KlarnaClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPoolServer(pool actor.GrainPool[*cart.CartGrain], pod_name string, klarnaClient *KlarnaClient) *PoolServer {
|
||||||
|
return &PoolServer{
|
||||||
|
GrainPool: pool,
|
||||||
|
pod_name: pod_name,
|
||||||
|
klarnaClient: klarnaClient,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) ApplyLocal(id cart.CartId, mutation ...proto.Message) (*actor.MutationResult[*cart.CartGrain], error) {
|
||||||
|
return s.Apply(uint64(id), mutation...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id cart.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 cart.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
|
||||||
|
}
|
||||||
|
grainMutations.Add(float64(len(data.Mutations)))
|
||||||
|
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 cart.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 cart.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 cart.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 cart.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 cart.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 cart.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 cart.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 cart.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 cart.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 cart.CartId) (*actor.MutationResult[*cart.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 cart.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 cart.CartId
|
||||||
|
cookie, err := r.Cookie("cartid")
|
||||||
|
if err != nil || cookie.Value == "" {
|
||||||
|
id = cart.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 := cart.ParseCartId(cookie.Value)
|
||||||
|
if !ok {
|
||||||
|
id = cart.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 cart.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 cart.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 cart.CartId
|
||||||
|
raw := r.PathValue("id")
|
||||||
|
// If no id supplied, generate a new one
|
||||||
|
if raw == "" {
|
||||||
|
id := cart.MustNewCartId()
|
||||||
|
w.Header().Set("Set-Cart-Id", id.String())
|
||||||
|
} else {
|
||||||
|
// Parse base62 cart id
|
||||||
|
if parsedId, ok := cart.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 cart.CartId) error) func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
return func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
if ownerHost, ok := s.OwnerHost(uint64(cartId)); ok {
|
||||||
|
_, span := tracer.Start(r.Context(), "proxy")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
span.SetAttributes(attribute.String("other host", ownerHost.Name()))
|
||||||
|
handled, err := ownerHost.Proxy(uint64(cartId), w, r)
|
||||||
|
|
||||||
|
grainLookups.Inc()
|
||||||
|
if err == nil && handled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, span := tracer.Start(r.Context(), "own")
|
||||||
|
defer span.End()
|
||||||
|
return fn(w, r, cartId)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
tracer = otel.Tracer(name)
|
||||||
|
|
||||||
|
// meter = otel.Meter(name)
|
||||||
|
logger = otelslog.NewLogger(name)
|
||||||
|
|
||||||
|
// rollCnt metric.Int64Counter
|
||||||
|
)
|
||||||
|
|
||||||
|
type AddVoucherRequest struct {
|
||||||
|
VoucherCode string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) AddVoucherHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||||
|
data := &AddVoucherRequest{}
|
||||||
|
json.NewDecoder(r.Body).Decode(data)
|
||||||
|
v := voucher.Service{}
|
||||||
|
msg, err := v.GetVoucher(data.VoucherCode)
|
||||||
|
if err != nil {
|
||||||
|
s.ApplyLocal(cartId, &messages.PreConditionFailed{
|
||||||
|
Operation: "AddVoucher",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
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) SubscriptionDetailsHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||||
|
data := &messages.UpsertSubscriptionDetails{}
|
||||||
|
err := json.NewDecoder(r.Body).Decode(data)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reply, err := s.ApplyLocal(cartId, data)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.WriteResult(w, reply)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) CheckoutHandler(fn func(order *CheckoutOrder, w http.ResponseWriter) error) func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
return CookieCartIdHandler(s.ProxyHandler(func(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||||
|
orderId := r.URL.Query().Get("order_id")
|
||||||
|
if orderId == "" {
|
||||||
|
order, err := s.CreateOrUpdateCheckout(r.Host, cartId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.ApplyCheckoutStarted(order, cartId)
|
||||||
|
return fn(order, w)
|
||||||
|
}
|
||||||
|
order, err := s.klarnaClient.GetOrder(orderId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fn(order, w)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request, cartId cart.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(mux *http.ServeMux) {
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
handleFunc := func(pattern string, handlerFunc func(http.ResponseWriter, *http.Request)) {
|
||||||
|
// Configure the "http.route" for the HTTP instrumentation.
|
||||||
|
handler := otelhttp.WithRouteTag(pattern, http.HandlerFunc(handlerFunc))
|
||||||
|
mux.Handle(pattern, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFunc("GET /cart", CookieCartIdHandler(s.ProxyHandler(s.GetCartHandler)))
|
||||||
|
handleFunc("GET /cart/add/{sku}", CookieCartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
|
||||||
|
handleFunc("POST /cart/add", CookieCartIdHandler(s.ProxyHandler(s.AddMultipleItemHandler)))
|
||||||
|
handleFunc("POST /cart", CookieCartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
|
||||||
|
handleFunc("POST /cart/set", CookieCartIdHandler(s.ProxyHandler(s.SetCartItemsHandler)))
|
||||||
|
handleFunc("DELETE /cart/{itemId}", CookieCartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
|
||||||
|
handleFunc("PUT /cart", CookieCartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
|
||||||
|
handleFunc("DELETE /cart", CookieCartIdHandler(s.ProxyHandler(s.RemoveCartCookie)))
|
||||||
|
handleFunc("POST /cart/delivery", CookieCartIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
|
||||||
|
handleFunc("DELETE /cart/delivery/{deliveryId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
|
||||||
|
handleFunc("PUT /cart/delivery/{deliveryId}/pickupPoint", CookieCartIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
|
||||||
|
handleFunc("PUT /cart/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
|
||||||
|
handleFunc("PUT /cart/subscription-details", CookieCartIdHandler(s.ProxyHandler(s.SubscriptionDetailsHandler)))
|
||||||
|
handleFunc("DELETE /cart/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
||||||
|
|
||||||
|
//mux.HandleFunc("GET /cart/checkout", CookieCartIdHandler(s.ProxyHandler(s.HandleCheckout)))
|
||||||
|
//mux.HandleFunc("GET /cart/confirmation/{orderId}", CookieCartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
|
||||||
|
|
||||||
|
handleFunc("GET /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.GetCartHandler)))
|
||||||
|
handleFunc("GET /cart/byid/{id}/add/{sku}", CartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
|
||||||
|
handleFunc("POST /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
|
||||||
|
handleFunc("DELETE /cart/byid/{id}/{itemId}", CartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
|
||||||
|
handleFunc("PUT /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
|
||||||
|
handleFunc("POST /cart/byid/{id}/delivery", CartIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
|
||||||
|
handleFunc("DELETE /cart/byid/{id}/delivery/{deliveryId}", CartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
|
||||||
|
handleFunc("PUT /cart/byid/{id}/delivery/{deliveryId}/pickupPoint", CartIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
|
||||||
|
handleFunc("PUT /cart/byid/{id}/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
|
||||||
|
handleFunc("DELETE /cart/byid/{id}/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
||||||
|
//mux.HandleFunc("GET /cart/byid/{id}/checkout", CartIdHandler(s.ProxyHandler(s.HandleCheckout)))
|
||||||
|
//mux.HandleFunc("GET /cart/byid/{id}/confirmation", CartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
|
||||||
|
|
||||||
|
}
|
||||||
140
cmd/cart/product-fetcher.go
Normal file
140
cmd/cart/product-fetcher.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country string) (*messages.AddItem, error) {
|
||||||
|
orgPrice, _ := getInt(item.GetNumberFieldValue(5)) // getInt(item.Fields[5])
|
||||||
|
|
||||||
|
price, err := getInt(item.GetNumberFieldValue(4)) //Fields[4]
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
stock := cart.StockStatus(0)
|
||||||
|
centralStockValue, ok := item.GetStringFieldValue(3)
|
||||||
|
if storeId == nil {
|
||||||
|
if ok {
|
||||||
|
if !item.Buyable {
|
||||||
|
return nil, fmt.Errorf("item not available")
|
||||||
|
}
|
||||||
|
pureNumber := strings.Replace(centralStockValue, "+", "", -1)
|
||||||
|
if centralStock, err := strconv.ParseInt(pureNumber, 10, 64); err == nil {
|
||||||
|
stock = cart.StockStatus(centralStock)
|
||||||
|
}
|
||||||
|
if stock == cart.StockStatus(0) && item.SaleStatus == "TBD" {
|
||||||
|
return nil, fmt.Errorf("no items available")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !item.BuyableInStore {
|
||||||
|
return nil, fmt.Errorf("item not available in store")
|
||||||
|
}
|
||||||
|
storeStock, ok := item.Stock.GetStock()[*storeId]
|
||||||
|
if ok {
|
||||||
|
stock = cart.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,
|
||||||
|
SaleStatus: item.SaleStatus,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTax(articleType string) int32 {
|
||||||
|
switch articleType {
|
||||||
|
case "ZDIE":
|
||||||
|
return 600
|
||||||
|
default:
|
||||||
|
return 2500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getInt(data float64, ok bool) (int, error) {
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("invalid type")
|
||||||
|
}
|
||||||
|
return int(data), nil
|
||||||
|
}
|
||||||
61
cmd/inventory/main.go
Normal file
61
cmd/inventory/main.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/inventory"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"github.com/redis/go-redis/v9/maintnotifications"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var ctx = context.Background()
|
||||||
|
rdb := redis.NewClient(&redis.Options{
|
||||||
|
Addr: "10.10.3.18:6379",
|
||||||
|
Password: "slaskredis", // no password set
|
||||||
|
DB: 0, // use default DB
|
||||||
|
MaintNotificationsConfig: &maintnotifications.Config{
|
||||||
|
Mode: maintnotifications.ModeDisabled,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
s, err := inventory.NewRedisInventoryService(rdb, ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Unable to connect to inventory redis: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rdb.Pipelined(ctx, func(p redis.Pipeliner) error {
|
||||||
|
s.UpdateInventory(p, "1", "1", 10)
|
||||||
|
s.UpdateInventory(p, "2", "2", 20)
|
||||||
|
s.UpdateInventory(p, "3", "3", 30)
|
||||||
|
s.UpdateInventory(p, "4", "4", 40)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
err = s.ReserveInventory(
|
||||||
|
inventory.ReserveRequest{
|
||||||
|
SKU: "1",
|
||||||
|
LocationID: "1",
|
||||||
|
Quantity: 3,
|
||||||
|
},
|
||||||
|
inventory.ReserveRequest{
|
||||||
|
SKU: "2",
|
||||||
|
LocationID: "2",
|
||||||
|
Quantity: 15,
|
||||||
|
},
|
||||||
|
inventory.ReserveRequest{
|
||||||
|
SKU: "3",
|
||||||
|
LocationID: "3",
|
||||||
|
Quantity: 25,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Unable to reserve inventory: %v", err)
|
||||||
|
}
|
||||||
|
v, err := s.GetInventory("1", "1")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Unable to get inventory: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("Inventory after reservation: %v", v)
|
||||||
|
|
||||||
|
}
|
||||||
BIN
data/1.prot
BIN
data/1.prot
Binary file not shown.
BIN
data/4.prot
BIN
data/4.prot
Binary file not shown.
BIN
data/5.prot
BIN
data/5.prot
Binary file not shown.
BIN
data/state.gob
BIN
data/state.gob
Binary file not shown.
Binary file not shown.
@@ -9,13 +9,101 @@ type: Opaque
|
|||||||
---
|
---
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: cart-backoffice
|
||||||
|
arch: amd64
|
||||||
|
name: cart-backoffice-x86
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: cart-backoffice
|
||||||
|
arch: amd64
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: cart-backoffice
|
||||||
|
arch: amd64
|
||||||
|
spec:
|
||||||
|
affinity:
|
||||||
|
nodeAffinity:
|
||||||
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
nodeSelectorTerms:
|
||||||
|
- matchExpressions:
|
||||||
|
- key: kubernetes.io/arch
|
||||||
|
operator: NotIn
|
||||||
|
values:
|
||||||
|
- arm64
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
nfs:
|
||||||
|
path: /i-data/7a8af061/nfs/cart-actor
|
||||||
|
server: 10.10.1.10
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: regcred
|
||||||
|
serviceAccountName: default
|
||||||
|
containers:
|
||||||
|
- image: registry.knatofs.se/go-cart-actor-amd64:latest
|
||||||
|
name: cart-actor-amd64
|
||||||
|
imagePullPolicy: Always
|
||||||
|
command: ["/go-cart-backoffice"]
|
||||||
|
lifecycle:
|
||||||
|
preStop:
|
||||||
|
exec:
|
||||||
|
command: ["sleep", "15"]
|
||||||
|
ports:
|
||||||
|
- containerPort: 8080
|
||||||
|
name: web
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /livez
|
||||||
|
port: web
|
||||||
|
failureThreshold: 1
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /readyz
|
||||||
|
port: web
|
||||||
|
failureThreshold: 2
|
||||||
|
initialDelaySeconds: 2
|
||||||
|
periodSeconds: 10
|
||||||
|
volumeMounts:
|
||||||
|
- mountPath: "/data"
|
||||||
|
name: data
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: "768Mi"
|
||||||
|
requests:
|
||||||
|
memory: "70Mi"
|
||||||
|
cpu: "1200m"
|
||||||
|
env:
|
||||||
|
- name: TZ
|
||||||
|
value: "Europe/Stockholm"
|
||||||
|
- name: KLARNA_API_USERNAME
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: klarna-api-credentials
|
||||||
|
key: username
|
||||||
|
- name: KLARNA_API_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: klarna-api-credentials
|
||||||
|
key: password
|
||||||
|
- name: AMQP_URL
|
||||||
|
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
|
||||||
|
# - name: BASE_URL
|
||||||
|
# value: "https://s10n-no.tornberg.me"
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app: cart-actor
|
app: cart-actor
|
||||||
arch: amd64
|
arch: amd64
|
||||||
name: cart-actor-x86
|
name: cart-actor-x86
|
||||||
spec:
|
spec:
|
||||||
replicas: 0
|
replicas: 3
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: cart-actor
|
app: cart-actor
|
||||||
@@ -55,6 +143,8 @@ spec:
|
|||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
name: web
|
name: web
|
||||||
|
- containerPort: 8081
|
||||||
|
name: debug
|
||||||
- containerPort: 1337
|
- containerPort: 1337
|
||||||
name: rpc
|
name: rpc
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
@@ -87,6 +177,10 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: klarna-api-credentials
|
name: klarna-api-credentials
|
||||||
key: username
|
key: username
|
||||||
|
- name: OTEL_RESOURCE_ATTRIBUTES
|
||||||
|
value: "service.name=cart,service.version=0.1.2"
|
||||||
|
- name: OTEL_EXPORTER_OTLP_ENDPOINT
|
||||||
|
value: "http://jaeger-collector.monitoring:4317"
|
||||||
- name: KLARNA_API_PASSWORD
|
- name: KLARNA_API_PASSWORD
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
@@ -113,7 +207,7 @@ metadata:
|
|||||||
arch: arm64
|
arch: arm64
|
||||||
name: cart-actor-arm64
|
name: cart-actor-arm64
|
||||||
spec:
|
spec:
|
||||||
replicas: 3
|
replicas: 0
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: cart-actor
|
app: cart-actor
|
||||||
@@ -157,6 +251,8 @@ spec:
|
|||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
name: web
|
name: web
|
||||||
|
- containerPort: 8081
|
||||||
|
name: debug
|
||||||
- containerPort: 1337
|
- containerPort: 1337
|
||||||
name: rpc
|
name: rpc
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
@@ -184,6 +280,10 @@ spec:
|
|||||||
env:
|
env:
|
||||||
- name: TZ
|
- name: TZ
|
||||||
value: "Europe/Stockholm"
|
value: "Europe/Stockholm"
|
||||||
|
- name: OTEL_RESOURCE_ATTRIBUTES
|
||||||
|
value: "service.name=cart,service.version=0.1.2"
|
||||||
|
- name: OTEL_EXPORTER_OTLP_ENDPOINT
|
||||||
|
value: "http://jaeger-collector.monitoring:4317"
|
||||||
- name: KLARNA_API_USERNAME
|
- name: KLARNA_API_USERNAME
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
@@ -212,7 +312,7 @@ apiVersion: v1
|
|||||||
metadata:
|
metadata:
|
||||||
name: cart-actor
|
name: cart-actor
|
||||||
annotations:
|
annotations:
|
||||||
prometheus.io/port: "8080"
|
prometheus.io/port: "8081"
|
||||||
prometheus.io/scrape: "true"
|
prometheus.io/scrape: "true"
|
||||||
prometheus.io/path: "/metrics"
|
prometheus.io/path: "/metrics"
|
||||||
spec:
|
spec:
|
||||||
@@ -222,16 +322,27 @@ spec:
|
|||||||
- name: web
|
- name: web
|
||||||
port: 8080
|
port: 8080
|
||||||
---
|
---
|
||||||
|
kind: Service
|
||||||
|
apiVersion: v1
|
||||||
|
metadata:
|
||||||
|
name: cart-backoffice
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: cart-backoffice
|
||||||
|
ports:
|
||||||
|
- name: web
|
||||||
|
port: 8080
|
||||||
|
---
|
||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: Ingress
|
kind: Ingress
|
||||||
metadata:
|
metadata:
|
||||||
name: cart-ingress
|
name: cart-ingress
|
||||||
annotations:
|
annotations:
|
||||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
# nginx.ingress.kubernetes.io/affinity: "cookie"
|
nginx.ingress.kubernetes.io/affinity: "cookie"
|
||||||
# nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity"
|
nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity"
|
||||||
# nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
|
nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
|
||||||
# nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
|
nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
|
||||||
nginx.ingress.kubernetes.io/proxy-body-size: 4m
|
nginx.ingress.kubernetes.io/proxy-body-size: 4m
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: nginx
|
ingressClassName: nginx
|
||||||
@@ -250,3 +361,27 @@ spec:
|
|||||||
name: cart-actor
|
name: cart-actor
|
||||||
port:
|
port:
|
||||||
number: 8080
|
number: 8080
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: cart-backend-ingress
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- slask-cart.tornberg.me
|
||||||
|
secretName: cart-backoffice-actor-tls-secret
|
||||||
|
rules:
|
||||||
|
- host: slask-cart.tornberg.me
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: cart-backoffice
|
||||||
|
port:
|
||||||
|
number: 8080
|
||||||
|
|||||||
@@ -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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
61
go.mod
61
go.mod
@@ -1,12 +1,24 @@
|
|||||||
module git.tornberg.me/go-cart-actor
|
module git.tornberg.me/go-cart-actor
|
||||||
|
|
||||||
go 1.25.1
|
go 1.25.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/gogo/protobuf v1.3.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548
|
github.com/matst80/slask-finder v0.0.0-20251023104024-f788e5a51d68
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/rabbitmq/amqp091-go v1.10.0
|
github.com/rabbitmq/amqp091-go v1.10.0
|
||||||
|
github.com/redis/go-redis/v9 v9.16.0
|
||||||
|
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0
|
||||||
|
go.opentelemetry.io/otel v1.38.0
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0
|
||||||
|
go.opentelemetry.io/otel/exporters/prometheus v0.60.0
|
||||||
|
go.opentelemetry.io/otel/log v0.14.0
|
||||||
|
go.opentelemetry.io/otel/sdk v1.38.0
|
||||||
|
go.opentelemetry.io/otel/sdk/log v0.14.0
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.38.0
|
||||||
google.golang.org/grpc v1.76.0
|
google.golang.org/grpc v1.76.0
|
||||||
google.golang.org/protobuf v1.36.10
|
google.golang.org/protobuf v1.36.10
|
||||||
k8s.io/api v0.34.1
|
k8s.io/api v0.34.1
|
||||||
@@ -15,14 +27,20 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/RoaringBitmap/roaring/v2 v2.10.0 // indirect
|
github.com/RoaringBitmap/roaring/v2 v2.13.0 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bits-and-blooms/bitset v1.24.0 // indirect
|
github.com/bits-and-blooms/bitset v1.24.1 // indirect
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
|
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||||
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.9.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-logr/logr v1.4.3 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.22.1 // indirect
|
github.com/go-openapi/jsonpointer v0.22.1 // indirect
|
||||||
github.com/go-openapi/jsonreference v0.21.2 // indirect
|
github.com/go-openapi/jsonreference v0.21.2 // indirect
|
||||||
github.com/go-openapi/swag v0.25.1 // indirect
|
github.com/go-openapi/swag v0.25.1 // indirect
|
||||||
@@ -37,32 +55,55 @@ require (
|
|||||||
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
|
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/typeutils v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag/yamlutils 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/gnostic-models v0.7.0 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/gorilla/schema v1.4.1 // indirect
|
github.com/gorilla/schema v1.4.1 // indirect
|
||||||
|
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
|
||||||
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // 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/mschoch/smat v0.2.0 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // 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.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.1 // indirect
|
github.com/prometheus/common v0.67.1 // indirect
|
||||||
github.com/prometheus/procfs v0.17.0 // indirect
|
github.com/prometheus/otlptranslator v0.0.2 // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // indirect
|
github.com/prometheus/procfs v0.18.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.10 // indirect
|
||||||
|
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||||
|
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/mod v0.29.0 // indirect
|
||||||
golang.org/x/net v0.46.0 // indirect
|
golang.org/x/net v0.46.0 // indirect
|
||||||
golang.org/x/oauth2 v0.32.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/sys v0.37.0 // indirect
|
||||||
golang.org/x/term v0.36.0 // indirect
|
golang.org/x/term v0.36.0 // indirect
|
||||||
golang.org/x/text v0.30.0 // indirect
|
golang.org/x/text v0.30.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff // indirect
|
golang.org/x/tools v0.38.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect
|
||||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||||
gopkg.in/inf.v0 v0.9.1 // 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/klog/v2 v2.130.1 // indirect
|
||||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
|
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
|
||||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
||||||
@@ -71,3 +112,5 @@ require (
|
|||||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
|
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
|
||||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
|
||||||
|
|||||||
221
go.sum
221
go.sum
@@ -1,20 +1,41 @@
|
|||||||
github.com/RoaringBitmap/roaring/v2 v2.10.0 h1:HbJ8Cs71lfCJyvmSptxeMX2PtvOC8yonlU0GQcy2Ak0=
|
github.com/RoaringBitmap/roaring/v2 v2.13.0 h1:38BxJ6lGPcBLykIRCyYtViB/By3+a/iS9znKsiBbhNc=
|
||||||
github.com/RoaringBitmap/roaring/v2 v2.10.0/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
|
github.com/RoaringBitmap/roaring/v2 v2.13.0/go.mod h1:Mpi+oQ+3oCU7g1aF75Ib/XYCTqjTGpHI0f8djSZVY3I=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
github.com/bits-and-blooms/bitset v1.24.1 h1:hqnfFbjjk3pxGa5E9Ho3hjoU7odtUuNmJ9Ao+Bo8s1c=
|
||||||
github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM=
|
github.com/bits-and-blooms/bitset v1.24.1/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||||
github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
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/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.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.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 h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
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 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
|
||||||
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
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 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
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.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
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/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
@@ -49,90 +70,181 @@ 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/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 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
|
||||||
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
|
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 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
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 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
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 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
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 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
||||||
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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/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 h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
|
||||||
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
||||||
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
||||||
|
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248=
|
||||||
|
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
|
||||||
|
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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
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/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
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 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548 h1:lkxVz5lNPlU78El49cx1c3Rxo/bp7Gbzp2BjSRFgg1U=
|
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548/go.mod h1:k4lo5gFYb3AqrgftlraCnv95xvjB/w8udRqJQJ12mAE=
|
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
|
github.com/matst80/slask-finder v0.0.0-20251023104024-f788e5a51d68 h1:nDSim5IRFrLG3okBty3nes50ZuxKMEY5rwj2FuUnTgc=
|
||||||
|
github.com/matst80/slask-finder v0.0.0-20251023104024-f788e5a51d68/go.mod h1:ZlbURsmhMX98D8z/du5Cez8fZU/sfkA98ruczIsB9PY=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
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 h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
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 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
|
||||||
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
|
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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
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 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
|
||||||
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
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 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
|
||||||
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI=
|
github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI=
|
||||||
github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q=
|
github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q=
|
||||||
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
github.com/prometheus/otlptranslator v0.0.2 h1:+1CdeLVrRQ6Psmhnobldo0kTp96Rj80DRXRd5OSnMEQ=
|
||||||
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
github.com/prometheus/otlptranslator v0.0.2/go.mod h1:P8AwMgdD7XEr6QRUJ2QWLpiAZTgTE2UYgjlu3svompI=
|
||||||
|
github.com/prometheus/procfs v0.18.0 h1:2QTA9cKdznfYJz7EDaa7IiJobHuV7E1WzeBwcrhk0ao=
|
||||||
|
github.com/prometheus/procfs v0.18.0/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||||
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
||||||
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
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/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4=
|
||||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
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.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
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 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
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/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 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 h1:bwnLpizECbPr1RrQ27waeY2SPIPeccCx/xLuoYADZ9s=
|
||||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0/go.mod h1:3nWlOiiqA9UtUnrcNk82mYasNxD8ehOspL0gOfEo6Y4=
|
||||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
|
||||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
|
||||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 h1:OMqPldHt79PqWKOMYIAQs3CxAi7RLgPxwfFSwr4ZxtM=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0/go.mod h1:1biG4qiqTxKiUCtoWDPpL3fB3KxVwCiGw81j3nKMuHE=
|
||||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
|
||||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk=
|
||||||
|
go.opentelemetry.io/otel/exporters/prometheus v0.60.0 h1:cGtQxGvZbnrWdC2GyjZi0PDKVSLWP/Jocix3QWfXtbo=
|
||||||
|
go.opentelemetry.io/otel/exporters/prometheus v0.60.0/go.mod h1:hkd1EekxNo69PTV4OWFGZcKQiIqg0RfuWExcPKFvepk=
|
||||||
|
go.opentelemetry.io/otel/log v0.14.0 h1:2rzJ+pOAZ8qmZ3DDHg73NEKzSZkhkGIua9gXtxNGgrM=
|
||||||
|
go.opentelemetry.io/otel/log v0.14.0/go.mod h1:5jRG92fEAgx0SU/vFPxmJvhIuDU9E1SUnEQrMlJpOno=
|
||||||
|
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||||
|
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||||
|
go.opentelemetry.io/otel/sdk/log v0.14.0 h1:JU/U3O7N6fsAXj0+CXz21Czg532dW2V4gG1HE/e8Zrg=
|
||||||
|
go.opentelemetry.io/otel/sdk/log v0.14.0/go.mod h1:imQvII+0ZylXfKU7/wtOND8Hn4OpT3YUoIgqJVksUkM=
|
||||||
|
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM=
|
||||||
|
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||||
|
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||||
|
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||||
@@ -144,26 +256,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/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.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||||
|
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||||
|
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-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-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-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-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 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
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 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
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-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-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.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-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-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-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 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
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 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
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.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.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 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
@@ -171,30 +307,51 @@ 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-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-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-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.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff h1:A90eA31Wq6HOMIQlLfzFwzqGKBTuaVztYu/g8sn+8Zc=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/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 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||||
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
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 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
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 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
|
||||||
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
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 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
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-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=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM=
|
k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM=
|
||||||
|
|||||||
327
grafana_dashboard_cart.json
Normal file
327
grafana_dashboard_cart.json
Normal 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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
244
grain-pool.go
244
grain-pool.go
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
280
grpc_server.go
280
grpc_server.go
@@ -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
174
k6/README.md
Normal 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 (5–10) 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
248
k6/cart_load_test.js
Normal 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,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
410
main.go
410
main.go
@@ -1,410 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"net/http/pprof"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
messages "git.tornberg.me/go-cart-actor/proto"
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
||||||
"k8s.io/client-go/kubernetes"
|
|
||||||
"k8s.io/client-go/rest"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
grainSpawns = promauto.NewCounter(prometheus.CounterOpts{
|
|
||||||
Name: "cart_grain_spawned_total",
|
|
||||||
Help: "The total number of spawned grains",
|
|
||||||
})
|
|
||||||
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
|
|
||||||
Name: "cart_grain_mutations_total",
|
|
||||||
Help: "The total number of mutations",
|
|
||||||
})
|
|
||||||
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
|
|
||||||
Name: "cart_grain_lookups_total",
|
|
||||||
Help: "The total number of lookups",
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>s10r testing - checkout</title>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
%s
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`
|
|
||||||
|
|
||||||
func getCountryFromHost(host string) string {
|
|
||||||
if strings.Contains(strings.ToLower(host), "-no") {
|
|
||||||
return "no"
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetDiscovery() Discovery {
|
|
||||||
if podIp == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
config, kerr := rest.InClusterConfig()
|
|
||||||
|
|
||||||
if kerr != nil {
|
|
||||||
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
|
|
||||||
}
|
|
||||||
client, err := kubernetes.NewForConfig(config)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error creating client: %v\n", err)
|
|
||||||
}
|
|
||||||
return NewK8sDiscovery(client)
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
|
|
||||||
storage, err := NewDiskStorage(fmt.Sprintf("data/%s_state.gob", name))
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error loading state: %v\n", err)
|
|
||||||
}
|
|
||||||
app := &App{
|
|
||||||
pool: NewGrainLocalPool(65535, 5*time.Minute, spawn),
|
|
||||||
storage: storage,
|
|
||||||
}
|
|
||||||
|
|
||||||
syncedPool, err := NewSyncedPool(app.pool, podIp, GetDiscovery())
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
defer grpcSrv.GracefulStop()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
for range time.Tick(time.Minute * 10) {
|
|
||||||
err := app.Save()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error saving: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
orderHandler := &AmqpOrderHandler{
|
|
||||||
Url: amqpUrl,
|
|
||||||
}
|
|
||||||
|
|
||||||
syncedServer := NewPoolServer(syncedPool, fmt.Sprintf("%s, %s", name, podIp))
|
|
||||||
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 /save", app.HandleSave)
|
|
||||||
//mux.HandleFunc("/", app.RewritePath)
|
|
||||||
mux.HandleFunc("/debug/pprof/", pprof.Index)
|
|
||||||
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
|
||||||
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
|
||||||
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
|
||||||
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
|
||||||
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()
|
|
||||||
if grainCount >= capacity {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte("grain pool at capacity"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !syncedPool.IsHealthy() {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte("control plane not healthy"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
|
|
||||||
mux.HandleFunc("/checkout", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
orderId := r.URL.Query().Get("order_id")
|
|
||||||
order := &CheckoutOrder{}
|
|
||||||
|
|
||||||
if orderId == "" {
|
|
||||||
cookie, err := r.Cookie("cartid")
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if cookie.Value == "" {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte("no cart id to checkout is empty"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cartId := ToCartId(cookie.Value)
|
|
||||||
order, err = syncedServer.CreateOrUpdateCheckout(r.Host, cartId)
|
|
||||||
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)
|
|
||||||
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)))
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
|
|
||||||
orderId := r.PathValue("order_id")
|
|
||||||
order, err := KlarnaInstance.GetOrder(orderId)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
if order.Status == "checkout_complete" {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "cartid",
|
|
||||||
Value: "",
|
|
||||||
Path: "/",
|
|
||||||
Secure: true,
|
|
||||||
HttpOnly: true,
|
|
||||||
Expires: time.Unix(0, 0),
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(fmt.Sprintf(tpl, order.HTMLSnippet)))
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/validate", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
log.Printf("Klarna order validation, method: %s", r.Method)
|
|
||||||
if r.Method != "POST" {
|
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
order := &CheckoutOrder{}
|
|
||||||
err := json.NewDecoder(r.Body).Decode(order)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
log.Printf("Klarna order validation: %s", order.ID)
|
|
||||||
//err = confirmOrder(order, orderHandler)
|
|
||||||
//if err != nil {
|
|
||||||
// log.Printf("Error validating order: %v\n", err)
|
|
||||||
// w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
// return
|
|
||||||
//}
|
|
||||||
//
|
|
||||||
//err = triggerOrderCompleted(err, syncedServer, order)
|
|
||||||
//if err != nil {
|
|
||||||
// log.Printf("Error processing cart message: %v\n", err)
|
|
||||||
// w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
// return
|
|
||||||
//}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/push", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
orderId := r.URL.Query().Get("order_id")
|
|
||||||
log.Printf("Order confirmation push: %s", orderId)
|
|
||||||
|
|
||||||
order, err := KlarnaInstance.GetOrder(orderId)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error creating request: %v\n", err)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = confirmOrder(order, orderHandler)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error confirming order: %v\n", err)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = triggerOrderCompleted(err, syncedServer, order)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error processing cart message: %v\n", err)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = KlarnaInstance.AcknowledgeOrder(orderId)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error acknowledging order: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("1.0.0"))
|
|
||||||
})
|
|
||||||
|
|
||||||
sigs := make(chan os.Signal, 1)
|
|
||||||
done := make(chan bool, 1)
|
|
||||||
signal.Notify(sigs, syscall.SIGTERM)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
sig := <-sigs
|
|
||||||
fmt.Println("Shutting down due to signal:", sig)
|
|
||||||
go syncedPool.Close()
|
|
||||||
app.Save()
|
|
||||||
done <- true
|
|
||||||
}()
|
|
||||||
|
|
||||||
log.Print("Server started at port 8080")
|
|
||||||
go http.ListenAndServe(":8080", mux)
|
|
||||||
<-done
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func triggerOrderCompleted(err error, syncedServer *PoolServer, order *CheckoutOrder) error {
|
|
||||||
_, err = syncedServer.pool.Apply(ToCartId(order.MerchantReference1), &messages.OrderCreated{
|
|
||||||
OrderId: order.ID,
|
|
||||||
Status: order.Status,
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func confirmOrder(order *CheckoutOrder, orderHandler *AmqpOrderHandler) error {
|
|
||||||
orderToSend, err := json.Marshal(order)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = orderHandler.Connect()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer orderHandler.Close()
|
|
||||||
err = orderHandler.OrderCompleted(orderToSend)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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] = ®isteredMutation{
|
|
||||||
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.
|
|
||||||
|
|
||||||
*/
|
|
||||||
@@ -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(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"slices"
|
|
||||||
|
|
||||||
messages "git.tornberg.me/go-cart-actor/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
// mutation_set_delivery.go
|
|
||||||
//
|
|
||||||
// Registers the SetDelivery mutation.
|
|
||||||
//
|
|
||||||
// Semantics (mirrors legacy switch logic):
|
|
||||||
// - If the payload specifies an explicit list of item IDs (payload.Items):
|
|
||||||
// - Each referenced cart line must exist.
|
|
||||||
// - None of the referenced items may already belong to a delivery.
|
|
||||||
// - Only those items are associated with the new delivery.
|
|
||||||
// - If payload.Items is empty:
|
|
||||||
// - All items currently without any delivery are associated with the new delivery.
|
|
||||||
// - A new delivery line is created with:
|
|
||||||
// - Auto-incremented delivery ID (cart-local)
|
|
||||||
// - Provider from payload
|
|
||||||
// - Fixed price (currently hard-coded: 4900 minor units) – adjust as needed
|
|
||||||
// - Optional PickupPoint copied from payload
|
|
||||||
// - Cart totals are recalculated (WithTotals)
|
|
||||||
//
|
|
||||||
// Error cases:
|
|
||||||
// - Referenced item does not exist
|
|
||||||
// - Referenced item already has a delivery
|
|
||||||
// - No items qualify (resulting association set empty) -> returns error (prevents creating empty delivery)
|
|
||||||
//
|
|
||||||
// Concurrency:
|
|
||||||
// - Uses g.mu to protect lastDeliveryId increment and append to Deliveries slice.
|
|
||||||
// Item scans are read-only and performed outside the lock for simplicity;
|
|
||||||
// if stricter guarantees are needed, widen the lock section.
|
|
||||||
//
|
|
||||||
// Future extension points:
|
|
||||||
// - 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 {
|
|
||||||
if m == nil {
|
|
||||||
return fmt.Errorf("SetDelivery: nil payload")
|
|
||||||
}
|
|
||||||
if m.Provider == "" {
|
|
||||||
return fmt.Errorf("SetDelivery: provider is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
withDelivery := g.ItemsWithDelivery()
|
|
||||||
targetItems := make([]int, 0)
|
|
||||||
|
|
||||||
if len(m.Items) == 0 {
|
|
||||||
// Use every item currently without a delivery
|
|
||||||
targetItems = append(targetItems, g.ItemsWithoutDelivery()...)
|
|
||||||
} else {
|
|
||||||
// Validate explicit list
|
|
||||||
for _, id64 := range m.Items {
|
|
||||||
id := int(id64)
|
|
||||||
found := false
|
|
||||||
for _, it := range g.Items {
|
|
||||||
if it.Id == id {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
return fmt.Errorf("SetDelivery: item id %d not found", id)
|
|
||||||
}
|
|
||||||
if slices.Contains(withDelivery, id) {
|
|
||||||
return fmt.Errorf("SetDelivery: item id %d already has a delivery", id)
|
|
||||||
}
|
|
||||||
targetItems = append(targetItems, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(targetItems) == 0 {
|
|
||||||
return fmt.Errorf("SetDelivery: no eligible items to attach")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append new delivery
|
|
||||||
g.mu.Lock()
|
|
||||||
g.lastDeliveryId++
|
|
||||||
newId := g.lastDeliveryId
|
|
||||||
g.Deliveries = append(g.Deliveries, &CartDelivery{
|
|
||||||
Id: newId,
|
|
||||||
Provider: m.Provider,
|
|
||||||
PickupPoint: m.PickupPoint,
|
|
||||||
Price: 4900, // TODO: externalize pricing
|
|
||||||
Items: targetItems,
|
|
||||||
})
|
|
||||||
g.mu.Unlock()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
WithTotals(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
139
pkg/actor/disk_storage.go
Normal file
139
pkg/actor/disk_storage.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
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
|
||||||
|
AppendMutations(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() {
|
||||||
|
if s.queue != nil {
|
||||||
|
s.save()
|
||||||
|
}
|
||||||
|
close(s.done)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage[V]) AppendMutations(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
13
pkg/actor/grain.go
Normal 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)
|
||||||
|
}
|
||||||
42
pkg/actor/grain_pool.go
Normal file
42
pkg/actor/grain_pool.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
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)
|
||||||
|
AddRemoteHost(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)
|
||||||
|
}
|
||||||
118
pkg/actor/grpc_server.go
Normal file
118
pkg/actor/grpc_server.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
47
pkg/actor/log_listerner.go
Normal file
47
pkg/actor/log_listerner.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package actor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/matst80/slask-finder/pkg/messaging"
|
||||||
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LogListener interface {
|
||||||
|
AppendMutations(id uint64, msg ...ApplyResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AmqpListener struct {
|
||||||
|
conn *amqp.Connection
|
||||||
|
transformer func(id uint64, msg []ApplyResult) (any, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAmqpListener(conn *amqp.Connection, transformer func(id uint64, msg []ApplyResult) (any, error)) *AmqpListener {
|
||||||
|
return &AmqpListener{
|
||||||
|
conn: conn,
|
||||||
|
transformer: transformer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *AmqpListener) DefineTopics() {
|
||||||
|
ch, err := l.conn.Channel()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to open a channel: %v", err)
|
||||||
|
}
|
||||||
|
defer ch.Close()
|
||||||
|
if err := messaging.DefineTopic(ch, "cart", "mutation"); err != nil {
|
||||||
|
log.Fatalf("Failed to declare topic mutation: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *AmqpListener) AppendMutations(id uint64, msg ...ApplyResult) {
|
||||||
|
data, err := l.transformer(id, msg)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to transform mutation event: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = messaging.SendChange(l.conn, "cart", "mutation", data)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to send mutation event: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
267
pkg/actor/mutation_registry.go
Normal file
267
pkg/actor/mutation_registry.go
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
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 MutationProcessor interface {
|
||||||
|
Process(grain any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type BasicMutationProcessor[V any] struct {
|
||||||
|
processor func(any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMutationProcessor[V any](process func(V) error) MutationProcessor {
|
||||||
|
return &BasicMutationProcessor[V]{
|
||||||
|
processor: func(v any) error {
|
||||||
|
return process(v.(V))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *BasicMutationProcessor[V]) Process(grain any) error {
|
||||||
|
return p.processor(grain)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
RegisterProcessor(processor ...MutationProcessor)
|
||||||
|
//GetStorageEvent(msg proto.Message) StorageEvent
|
||||||
|
//FromStorageEvent(event StorageEvent) (proto.Message, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProtoMutationRegistry struct {
|
||||||
|
mutationRegistryMu sync.RWMutex
|
||||||
|
mutationRegistry map[reflect.Type]MutationHandler
|
||||||
|
processors []MutationProcessor
|
||||||
|
}
|
||||||
|
|
||||||
|
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{},
|
||||||
|
processors: make([]MutationProcessor, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProtoMutationRegistry) RegisterProcessor(processors ...MutationProcessor) {
|
||||||
|
r.processors = append(r.processors, processors...)
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
// Nil slice of mutations still treated as an error (call contract violation).
|
||||||
|
if msg == nil {
|
||||||
|
return results, fmt.Errorf("nil mutation message")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range msg {
|
||||||
|
// Ignore nil mutation elements (untyped or typed nil pointers) silently; they carry no data.
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Typed nil: interface holds concrete proto message type whose pointer value is nil.
|
||||||
|
rv := reflect.ValueOf(m)
|
||||||
|
if rv.Kind() == reflect.Ptr && rv.IsNil() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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 len(results) > 0 {
|
||||||
|
for _, processor := range r.processors {
|
||||||
|
err := processor.Process(grain)
|
||||||
|
if err != nil {
|
||||||
|
return results, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
134
pkg/actor/mutation_registry_test.go
Normal file
134
pkg/actor/mutation_registry_test.go
Normal 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
|
||||||
454
pkg/actor/simple_grain_pool.go
Normal file
454
pkg/actor/simple_grain_pool.go
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
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)
|
||||||
|
listeners []LogListener
|
||||||
|
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]) AddListener(listener LogListener) {
|
||||||
|
p.listeners = append(p.listeners, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SimpleGrainPool[V]) RemoveListener(listener LogListener) {
|
||||||
|
for i, l := range p.listeners {
|
||||||
|
if l == listener {
|
||||||
|
p.listeners = append(p.listeners[:i], p.listeners[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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]) AddRemoteHost(host string) {
|
||||||
|
p.AddRemote(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.AppendMutations(id, mutation...); err != nil {
|
||||||
|
log.Printf("failed to store mutation for grain %d: %v", id, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
for _, listener := range p.listeners {
|
||||||
|
go listener.AppendMutations(id, mutations...)
|
||||||
|
}
|
||||||
|
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
98
pkg/actor/state.go
Normal 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
|
||||||
|
}
|
||||||
288
pkg/cart/cart-grain.go
Normal file
288
pkg/cart/cart-grain.go
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"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"`
|
||||||
|
SaleStatus string `json:"saleStatus"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 SubscriptionDetails struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
OfferingCode string `json:"offeringCode,omitempty"`
|
||||||
|
SigningType string `json:"signingType,omitempty"`
|
||||||
|
Meta json.RawMessage `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Voucher struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Applied bool `json:"applied"`
|
||||||
|
Rules []string `json:"rules"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
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 _, expr := range v.Rules {
|
||||||
|
|
||||||
|
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 NewCartGrain(id uint64, ts time.Time) *CartGrain {
|
||||||
|
return &CartGrain{
|
||||||
|
lastItemId: 0,
|
||||||
|
lastDeliveryId: 0,
|
||||||
|
lastVoucherId: 0,
|
||||||
|
lastAccess: ts,
|
||||||
|
lastChange: ts,
|
||||||
|
TotalDiscount: NewPrice(),
|
||||||
|
Vouchers: []*Voucher{},
|
||||||
|
Deliveries: []*CartDelivery{},
|
||||||
|
Id: CartId(id),
|
||||||
|
Items: []*CartItem{},
|
||||||
|
TotalPrice: NewPrice(),
|
||||||
|
SubscriptionDetails: make(map[string]*SubscriptionDetails),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (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))
|
||||||
|
|
||||||
|
if item.OrgPrice != nil {
|
||||||
|
diff := NewPrice()
|
||||||
|
diff.Add(*item.OrgPrice)
|
||||||
|
diff.Subtract(item.Price)
|
||||||
|
diff.Multiply(int64(item.Quantity))
|
||||||
|
rowTotal.Subtract(*diff)
|
||||||
|
item.Discount = diff
|
||||||
|
if diff.IncVat > 0 {
|
||||||
|
c.TotalDiscount.Add(*diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item.TotalPrice = *rowTotal
|
||||||
|
|
||||||
|
c.TotalPrice.Add(*rowTotal)
|
||||||
|
|
||||||
|
}
|
||||||
|
for _, delivery := range c.Deliveries {
|
||||||
|
c.TotalPrice.Add(delivery.Price)
|
||||||
|
}
|
||||||
|
for _, voucher := range c.Vouchers {
|
||||||
|
_, ok := voucher.AppliesTo(c)
|
||||||
|
voucher.Applied = false
|
||||||
|
if ok {
|
||||||
|
value := NewPriceFromIncVat(voucher.Value, 25)
|
||||||
|
if c.TotalPrice.IncVat <= value.IncVat {
|
||||||
|
// don't apply discounts to more than the total price
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
voucher.Applied = true
|
||||||
|
c.TotalDiscount.Add(*value)
|
||||||
|
c.TotalPrice.Subtract(*value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
pkg/cart/cart-mutation-helper.go
Normal file
54
pkg/cart/cart-mutation-helper.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||||
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewCartMultationRegistry() actor.MutationRegistry {
|
||||||
|
|
||||||
|
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{}
|
||||||
|
}),
|
||||||
|
actor.NewMutation(UpsertSubscriptionDetails, func() *messages.UpsertSubscriptionDetails {
|
||||||
|
return &messages.UpsertSubscriptionDetails{}
|
||||||
|
}),
|
||||||
|
actor.NewMutation(PreConditionFailed, func() *messages.PreConditionFailed {
|
||||||
|
return &messages.PreConditionFailed{}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return reg
|
||||||
|
|
||||||
|
}
|
||||||
48
pkg/cart/cart_grain_totals_test.go
Normal file
48
pkg/cart/cart_grain_totals_test.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
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
pkg/cart/cart_id.go
Normal file
159
pkg/cart/cart_id.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
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
pkg/cart/cart_id_test.go
Normal file
185
pkg/cart/cart_id_test.go
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
102
pkg/cart/mutation_add_item.go
Normal file
102
pkg/cart/mutation_add_item.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge with any existing item having same SKU and matching StoreId (including both nil).
|
||||||
|
for _, existing := range g.Items {
|
||||||
|
if existing.Sku != m.Sku {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sameStore := (existing.StoreId == nil && m.StoreId == nil) ||
|
||||||
|
(existing.StoreId != nil && m.StoreId != nil && *existing.StoreId == *m.StoreId)
|
||||||
|
if !sameStore {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
existing.Quantity += int(m.Quantity)
|
||||||
|
existing.Stock = StockStatus(m.Stock)
|
||||||
|
// If existing had nil store but new has one, adopt it.
|
||||||
|
if existing.StoreId == nil && m.StoreId != nil {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
SaleStatus: m.SaleStatus,
|
||||||
|
ParentId: m.ParentId,
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
66
pkg/cart/mutation_add_voucher.go
Normal file
66
pkg/cart/mutation_add_voucher.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
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,
|
||||||
|
Applied: false,
|
||||||
|
Description: m.Description,
|
||||||
|
Code: m.Code,
|
||||||
|
Rules: m.VoucherRules,
|
||||||
|
Value: m.Value,
|
||||||
|
})
|
||||||
|
g.UpdateTotals()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package main
|
package cart
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
messages "git.tornberg.me/go-cart-actor/proto"
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mutation_change_quantity.go
|
// mutation_change_quantity.go
|
||||||
@@ -25,17 +25,15 @@ import (
|
|||||||
// the grain's implicit expectation that higher layers control access.
|
// the grain's implicit expectation that higher layers control access.
|
||||||
// (If strict locking is required around every mutation, wrap logic in
|
// (If strict locking is required around every mutation, wrap logic in
|
||||||
// an explicit g.mu.Lock()/Unlock(), but current model mirrors prior code.)
|
// an explicit g.mu.Lock()/Unlock(), but current model mirrors prior code.)
|
||||||
func init() {
|
|
||||||
RegisterMutation[messages.ChangeQuantity](
|
func ChangeQuantity(g *CartGrain, m *messages.ChangeQuantity) error {
|
||||||
"ChangeQuantity",
|
|
||||||
func(g *CartGrain, m *messages.ChangeQuantity) error {
|
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return fmt.Errorf("ChangeQuantity: nil payload")
|
return fmt.Errorf("ChangeQuantity: nil payload")
|
||||||
}
|
}
|
||||||
|
|
||||||
foundIndex := -1
|
foundIndex := -1
|
||||||
for i, it := range g.Items {
|
for i, it := range g.Items {
|
||||||
if it.Id == int(m.Id) {
|
if it.Id == uint32(m.Id) {
|
||||||
foundIndex = i
|
foundIndex = i
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -47,12 +45,11 @@ func init() {
|
|||||||
if m.Quantity <= 0 {
|
if m.Quantity <= 0 {
|
||||||
// Remove the item
|
// Remove the item
|
||||||
g.Items = append(g.Items[:foundIndex], g.Items[foundIndex+1:]...)
|
g.Items = append(g.Items[:foundIndex], g.Items[foundIndex+1:]...)
|
||||||
|
g.UpdateTotals()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
g.Items[foundIndex].Quantity = int(m.Quantity)
|
g.Items[foundIndex].Quantity = int(m.Quantity)
|
||||||
|
g.UpdateTotals()
|
||||||
return nil
|
return nil
|
||||||
},
|
|
||||||
WithTotals(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package main
|
package cart
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
messages "git.tornberg.me/go-cart-actor/proto"
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mutation_initialize_checkout.go
|
// mutation_initialize_checkout.go
|
||||||
@@ -28,10 +28,8 @@ import (
|
|||||||
// parallel checkout attempts are possible, add higher-level guards
|
// parallel checkout attempts are possible, add higher-level guards
|
||||||
// (e.g. reject if PaymentInProgress already true unless reusing
|
// (e.g. reject if PaymentInProgress already true unless reusing
|
||||||
// the same OrderReference).
|
// the same OrderReference).
|
||||||
func init() {
|
|
||||||
RegisterMutation[messages.InitializeCheckout](
|
func InitializeCheckout(g *CartGrain, m *messages.InitializeCheckout) error {
|
||||||
"InitializeCheckout",
|
|
||||||
func(g *CartGrain, m *messages.InitializeCheckout) error {
|
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return fmt.Errorf("InitializeCheckout: nil payload")
|
return fmt.Errorf("InitializeCheckout: nil payload")
|
||||||
}
|
}
|
||||||
@@ -43,7 +41,4 @@ func init() {
|
|||||||
g.PaymentStatus = m.Status
|
g.PaymentStatus = m.Status
|
||||||
g.PaymentInProgress = m.PaymentInProgress
|
g.PaymentInProgress = m.PaymentInProgress
|
||||||
return nil
|
return nil
|
||||||
},
|
|
||||||
// No WithTotals(): monetary aggregates are unaffected.
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package main
|
package cart
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
messages "git.tornberg.me/go-cart-actor/proto"
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mutation_order_created.go
|
// mutation_order_created.go
|
||||||
@@ -32,10 +32,8 @@ import (
|
|||||||
// - Relies on the higher-level guarantee that Apply() calls are serialized
|
// - Relies on the higher-level guarantee that Apply() calls are serialized
|
||||||
// per grain. If out-of-order events are possible, embed versioning or
|
// per grain. If out-of-order events are possible, embed versioning or
|
||||||
// timestamps in the mutation and compare before applying changes.
|
// timestamps in the mutation and compare before applying changes.
|
||||||
func init() {
|
|
||||||
RegisterMutation[messages.OrderCreated](
|
func OrderCreated(g *CartGrain, m *messages.OrderCreated) error {
|
||||||
"OrderCreated",
|
|
||||||
func(g *CartGrain, m *messages.OrderCreated) error {
|
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return fmt.Errorf("OrderCreated: nil payload")
|
return fmt.Errorf("OrderCreated: nil payload")
|
||||||
}
|
}
|
||||||
@@ -47,7 +45,4 @@ func init() {
|
|||||||
g.PaymentStatus = m.Status
|
g.PaymentStatus = m.Status
|
||||||
g.PaymentInProgress = false
|
g.PaymentInProgress = false
|
||||||
return nil
|
return nil
|
||||||
},
|
|
||||||
// No WithTotals(): order completion does not modify pricing or taxes.
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
7
pkg/cart/mutation_precondition.go
Normal file
7
pkg/cart/mutation_precondition.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
import messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
|
|
||||||
|
func PreConditionFailed(g *CartGrain, m *messages.PreConditionFailed) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package main
|
package cart
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
messages "git.tornberg.me/go-cart-actor/proto"
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mutation_remove_delivery.go
|
// mutation_remove_delivery.go
|
||||||
@@ -25,14 +25,12 @@ import (
|
|||||||
// Future considerations:
|
// Future considerations:
|
||||||
// - If delivery pricing logic changes (e.g., dynamic taxes per delivery),
|
// - If delivery pricing logic changes (e.g., dynamic taxes per delivery),
|
||||||
// UpdateTotals() may need enhancement to incorporate delivery tax properly.
|
// UpdateTotals() may need enhancement to incorporate delivery tax properly.
|
||||||
func init() {
|
|
||||||
RegisterMutation[messages.RemoveDelivery](
|
func RemoveDelivery(g *CartGrain, m *messages.RemoveDelivery) error {
|
||||||
"RemoveDelivery",
|
|
||||||
func(g *CartGrain, m *messages.RemoveDelivery) error {
|
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return fmt.Errorf("RemoveDelivery: nil payload")
|
return fmt.Errorf("RemoveDelivery: nil payload")
|
||||||
}
|
}
|
||||||
targetID := int(m.Id)
|
targetID := uint32(m.Id)
|
||||||
index := -1
|
index := -1
|
||||||
for i, d := range g.Deliveries {
|
for i, d := range g.Deliveries {
|
||||||
if d.Id == targetID {
|
if d.Id == targetID {
|
||||||
@@ -46,8 +44,6 @@ func init() {
|
|||||||
|
|
||||||
// Remove delivery (order not preserved beyond necessity)
|
// Remove delivery (order not preserved beyond necessity)
|
||||||
g.Deliveries = append(g.Deliveries[:index], g.Deliveries[index+1:]...)
|
g.Deliveries = append(g.Deliveries[:index], g.Deliveries[index+1:]...)
|
||||||
|
g.UpdateTotals()
|
||||||
return nil
|
return nil
|
||||||
},
|
|
||||||
WithTotals(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package main
|
package cart
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
messages "git.tornberg.me/go-cart-actor/proto"
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mutation_remove_item.go
|
// mutation_remove_item.go
|
||||||
@@ -21,14 +21,12 @@ import (
|
|||||||
// semantics require pruning delivery.item_ids you can extend this handler.
|
// semantics require pruning delivery.item_ids you can extend this handler.
|
||||||
// - If multiple lines somehow shared the same Id (should not happen), only
|
// - 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.
|
// the first match would be removed—data integrity relies on unique line Ids.
|
||||||
func init() {
|
|
||||||
RegisterMutation[messages.RemoveItem](
|
func RemoveItem(g *CartGrain, m *messages.RemoveItem) error {
|
||||||
"RemoveItem",
|
|
||||||
func(g *CartGrain, m *messages.RemoveItem) error {
|
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return fmt.Errorf("RemoveItem: nil payload")
|
return fmt.Errorf("RemoveItem: nil payload")
|
||||||
}
|
}
|
||||||
targetID := int(m.Id)
|
targetID := uint32(m.Id)
|
||||||
|
|
||||||
index := -1
|
index := -1
|
||||||
for i, it := range g.Items {
|
for i, it := range g.Items {
|
||||||
@@ -42,8 +40,6 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
g.Items = append(g.Items[:index], g.Items[index+1:]...)
|
g.Items = append(g.Items[:index], g.Items[index+1:]...)
|
||||||
|
g.UpdateTotals()
|
||||||
return nil
|
return nil
|
||||||
},
|
|
||||||
WithTotals(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
96
pkg/cart/mutation_set_delivery.go
Normal file
96
pkg/cart/mutation_set_delivery.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_set_delivery.go
|
||||||
|
//
|
||||||
|
// Registers the SetDelivery mutation.
|
||||||
|
//
|
||||||
|
// Semantics (mirrors legacy switch logic):
|
||||||
|
// - If the payload specifies an explicit list of item IDs (payload.Items):
|
||||||
|
// - Each referenced cart line must exist.
|
||||||
|
// - None of the referenced items may already belong to a delivery.
|
||||||
|
// - Only those items are associated with the new delivery.
|
||||||
|
// - If payload.Items is empty:
|
||||||
|
// - All items currently without any delivery are associated with the new delivery.
|
||||||
|
// - A new delivery line is created with:
|
||||||
|
// - Auto-incremented delivery ID (cart-local)
|
||||||
|
// - Provider from payload
|
||||||
|
// - Fixed price (currently hard-coded: 4900 minor units) – adjust as needed
|
||||||
|
// - Optional PickupPoint copied from payload
|
||||||
|
// - Cart totals are recalculated (WithTotals)
|
||||||
|
//
|
||||||
|
// Error cases:
|
||||||
|
// - Referenced item does not exist
|
||||||
|
// - Referenced item already has a delivery
|
||||||
|
// - No items qualify (resulting association set empty) -> returns error (prevents creating empty delivery)
|
||||||
|
//
|
||||||
|
// Concurrency:
|
||||||
|
// - Uses g.mu to protect lastDeliveryId increment and append to Deliveries slice.
|
||||||
|
// Item scans are read-only and performed outside the lock for simplicity;
|
||||||
|
// if stricter guarantees are needed, widen the lock section.
|
||||||
|
//
|
||||||
|
// Future extension points:
|
||||||
|
// - Variable delivery pricing (based on weight, distance, provider, etc.)
|
||||||
|
// - Validation of provider codes
|
||||||
|
// - Multi-currency delivery pricing
|
||||||
|
|
||||||
|
func SetDelivery(g *CartGrain, m *messages.SetDelivery) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("SetDelivery: nil payload")
|
||||||
|
}
|
||||||
|
if m.Provider == "" {
|
||||||
|
return fmt.Errorf("SetDelivery: provider is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
withDelivery := g.ItemsWithDelivery()
|
||||||
|
targetItems := make([]uint32, 0)
|
||||||
|
|
||||||
|
if len(m.Items) == 0 {
|
||||||
|
// Use every item currently without a delivery
|
||||||
|
targetItems = append(targetItems, g.ItemsWithoutDelivery()...)
|
||||||
|
} else {
|
||||||
|
// Validate explicit list
|
||||||
|
for _, id64 := range m.Items {
|
||||||
|
id := uint32(id64)
|
||||||
|
found := false
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if it.Id == id {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("SetDelivery: item id %d not found", id)
|
||||||
|
}
|
||||||
|
if slices.Contains(withDelivery, id) {
|
||||||
|
return fmt.Errorf("SetDelivery: item id %d already has a delivery", id)
|
||||||
|
}
|
||||||
|
targetItems = append(targetItems, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(targetItems) == 0 {
|
||||||
|
return fmt.Errorf("SetDelivery: no eligible items to attach")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append new delivery
|
||||||
|
g.mu.Lock()
|
||||||
|
g.lastDeliveryId++
|
||||||
|
newId := g.lastDeliveryId
|
||||||
|
g.Deliveries = append(g.Deliveries, &CartDelivery{
|
||||||
|
Id: newId,
|
||||||
|
Provider: m.Provider,
|
||||||
|
PickupPoint: m.PickupPoint,
|
||||||
|
Price: *NewPriceFromIncVat(4900, 25.0),
|
||||||
|
Items: targetItems,
|
||||||
|
})
|
||||||
|
g.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package main
|
package cart
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
messages "git.tornberg.me/go-cart-actor/proto"
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mutation_set_pickup_point.go
|
// mutation_set_pickup_point.go
|
||||||
@@ -28,16 +28,14 @@ import (
|
|||||||
// - Validate pickup point fields (country code, zip format, etc.)
|
// - Validate pickup point fields (country code, zip format, etc.)
|
||||||
// - Track history / audit of pickup point changes
|
// - Track history / audit of pickup point changes
|
||||||
// - Trigger delivery price adjustments (which would then require WithTotals()).
|
// - Trigger delivery price adjustments (which would then require WithTotals()).
|
||||||
func init() {
|
|
||||||
RegisterMutation[messages.SetPickupPoint](
|
func SetPickupPoint(g *CartGrain, m *messages.SetPickupPoint) error {
|
||||||
"SetPickupPoint",
|
|
||||||
func(g *CartGrain, m *messages.SetPickupPoint) error {
|
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return fmt.Errorf("SetPickupPoint: nil payload")
|
return fmt.Errorf("SetPickupPoint: nil payload")
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, d := range g.Deliveries {
|
for _, d := range g.Deliveries {
|
||||||
if d.Id == int(m.DeliveryId) {
|
if d.Id == uint32(m.DeliveryId) {
|
||||||
d.PickupPoint = &messages.PickupPoint{
|
d.PickupPoint = &messages.PickupPoint{
|
||||||
Id: m.Id,
|
Id: m.Id,
|
||||||
Name: m.Name,
|
Name: m.Name,
|
||||||
@@ -50,7 +48,15 @@ func init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fmt.Errorf("SetPickupPoint: delivery id %d not found", m.DeliveryId)
|
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
|
||||||
}
|
}
|
||||||
520
pkg/cart/mutation_test.go
Normal file
520
pkg/cart/mutation_test.go
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gogo/protobuf/proto"
|
||||||
|
anypb "google.golang.org/protobuf/types/known/anypb"
|
||||||
|
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||||
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ----------------------
|
||||||
|
// Helper constructors
|
||||||
|
// ----------------------
|
||||||
|
|
||||||
|
func newTestGrain() *CartGrain {
|
||||||
|
return NewCartGrain(123, time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRegistry() actor.MutationRegistry {
|
||||||
|
return NewCartMultationRegistry()
|
||||||
|
}
|
||||||
|
|
||||||
|
func msgAddItem(sku string, price int64, qty int32, storePtr *string) *messages.AddItem {
|
||||||
|
return &messages.AddItem{
|
||||||
|
Sku: sku,
|
||||||
|
Price: price,
|
||||||
|
Quantity: qty,
|
||||||
|
// Tax left 0 -> handler uses default 25%
|
||||||
|
StoreId: storePtr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func msgChangeQty(id uint32, qty int32) *messages.ChangeQuantity {
|
||||||
|
return &messages.ChangeQuantity{Id: id, Quantity: qty}
|
||||||
|
}
|
||||||
|
|
||||||
|
func msgRemoveItem(id uint32) *messages.RemoveItem {
|
||||||
|
return &messages.RemoveItem{Id: id}
|
||||||
|
}
|
||||||
|
|
||||||
|
func msgSetDelivery(provider string, items ...uint32) *messages.SetDelivery {
|
||||||
|
uitems := make([]uint32, len(items))
|
||||||
|
copy(uitems, items)
|
||||||
|
return &messages.SetDelivery{Provider: provider, Items: uitems}
|
||||||
|
}
|
||||||
|
|
||||||
|
func msgSetPickupPoint(deliveryId uint32, id string) *messages.SetPickupPoint {
|
||||||
|
return &messages.SetPickupPoint{
|
||||||
|
DeliveryId: deliveryId,
|
||||||
|
Id: id,
|
||||||
|
Name: ptr("Pickup"),
|
||||||
|
Address: ptr("Street 1"),
|
||||||
|
City: ptr("Town"),
|
||||||
|
Zip: ptr("12345"),
|
||||||
|
Country: ptr("SE"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func msgClearCart() *messages.ClearCartRequest {
|
||||||
|
return &messages.ClearCartRequest{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func msgAddVoucher(code string, value int64, rules ...string) *messages.AddVoucher {
|
||||||
|
return &messages.AddVoucher{Code: code, Value: value, VoucherRules: rules}
|
||||||
|
}
|
||||||
|
|
||||||
|
func msgRemoveVoucher(id uint32) *messages.RemoveVoucher {
|
||||||
|
return &messages.RemoveVoucher{Id: id}
|
||||||
|
}
|
||||||
|
|
||||||
|
func msgInitializeCheckout(orderId, status string, inProgress bool) *messages.InitializeCheckout {
|
||||||
|
return &messages.InitializeCheckout{OrderId: orderId, Status: status, PaymentInProgress: inProgress}
|
||||||
|
}
|
||||||
|
|
||||||
|
func msgOrderCreated(orderId, status string) *messages.OrderCreated {
|
||||||
|
return &messages.OrderCreated{OrderId: orderId, Status: status}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptr[T any](v T) *T { return &v }
|
||||||
|
|
||||||
|
// ----------------------
|
||||||
|
// Apply helpers
|
||||||
|
// ----------------------
|
||||||
|
|
||||||
|
func applyOne(t *testing.T, reg actor.MutationRegistry, g *CartGrain, msg proto.Message) actor.ApplyResult {
|
||||||
|
t.Helper()
|
||||||
|
results, err := reg.Apply(g, msg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected registry-level error applying %T: %v", msg, err)
|
||||||
|
}
|
||||||
|
if len(results) != 1 {
|
||||||
|
t.Fatalf("expected exactly one ApplyResult, got %d", len(results))
|
||||||
|
}
|
||||||
|
return results[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect success (nil error inside ApplyResult).
|
||||||
|
func applyOK(t *testing.T, reg actor.MutationRegistry, g *CartGrain, msg proto.Message) {
|
||||||
|
t.Helper()
|
||||||
|
res := applyOne(t, reg, g, msg)
|
||||||
|
if res.Error != nil {
|
||||||
|
t.Fatalf("expected mutation %s (%T) to succeed, got error: %v", res.Type, msg, res.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect an error matching substring.
|
||||||
|
func applyErrorContains(t *testing.T, reg actor.MutationRegistry, g *CartGrain, msg proto.Message, substr string) {
|
||||||
|
t.Helper()
|
||||||
|
res := applyOne(t, reg, g, msg)
|
||||||
|
if res.Error == nil {
|
||||||
|
t.Fatalf("expected error applying %T, got nil", msg)
|
||||||
|
}
|
||||||
|
if substr != "" && !strings.Contains(res.Error.Error(), substr) {
|
||||||
|
t.Fatalf("error mismatch, want substring %q got %q", substr, res.Error.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------
|
||||||
|
// Tests
|
||||||
|
// ----------------------
|
||||||
|
|
||||||
|
func TestMutationRegistryCoverage(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
|
||||||
|
expected := []string{
|
||||||
|
"AddItem",
|
||||||
|
"ChangeQuantity",
|
||||||
|
"RemoveItem",
|
||||||
|
"InitializeCheckout",
|
||||||
|
"OrderCreated",
|
||||||
|
"RemoveDelivery",
|
||||||
|
"SetDelivery",
|
||||||
|
"SetPickupPoint",
|
||||||
|
"ClearCartRequest",
|
||||||
|
"AddVoucher",
|
||||||
|
"RemoveVoucher",
|
||||||
|
"UpsertSubscriptionDetails",
|
||||||
|
}
|
||||||
|
|
||||||
|
names := reg.(*actor.ProtoMutationRegistry).RegisteredMutations()
|
||||||
|
for _, want := range expected {
|
||||||
|
if !slices.Contains(names, want) {
|
||||||
|
t.Fatalf("registry missing mutation %s; got %v", want, names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create() by name returns correct concrete type.
|
||||||
|
for _, name := range expected {
|
||||||
|
msg, ok := reg.Create(name)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Create failed for %s", name)
|
||||||
|
}
|
||||||
|
rt := reflect.TypeOf(msg)
|
||||||
|
if rt.Kind() == reflect.Ptr {
|
||||||
|
rt = rt.Elem()
|
||||||
|
}
|
||||||
|
if rt.Name() != name {
|
||||||
|
t.Fatalf("Create(%s) returned wrong type %s", name, rt.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregistered create
|
||||||
|
if m, ok := reg.Create("DoesNotExist"); ok || m != nil {
|
||||||
|
t.Fatalf("Create should fail for unknown; got (%T,%v)", m, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTypeName sanity
|
||||||
|
add := &messages.AddItem{}
|
||||||
|
nm, ok := reg.GetTypeName(add)
|
||||||
|
if !ok || nm != "AddItem" {
|
||||||
|
t.Fatalf("GetTypeName failed for AddItem, got (%q,%v)", nm, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply unregistered message -> result should contain ErrMutationNotRegistered, no top-level error
|
||||||
|
results, err := reg.Apply(newTestGrain(), &messages.Noop{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected top-level error applying unregistered mutation: %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 1 || results[0].Error == nil || results[0].Error != actor.ErrMutationNotRegistered {
|
||||||
|
t.Fatalf("expected ApplyResult with ErrMutationNotRegistered, got %#v", results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddItemAndMerging(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := newTestGrain()
|
||||||
|
|
||||||
|
// Merge scenario (same SKU + same store pointer)
|
||||||
|
add1 := msgAddItem("SKU-1", 1000, 2, nil)
|
||||||
|
applyOK(t, reg, g, add1)
|
||||||
|
|
||||||
|
if len(g.Items) != 1 || g.Items[0].Quantity != 2 {
|
||||||
|
t.Fatalf("expected first item added; items=%d qty=%d", len(g.Items), g.Items[0].Quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyOK(t, reg, g, msgAddItem("SKU-1", 1000, 3, nil)) // should merge
|
||||||
|
if len(g.Items) != 1 || g.Items[0].Quantity != 5 {
|
||||||
|
t.Fatalf("expected merge quantity=5 items=%d qty=%d", len(g.Items), g.Items[0].Quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different store pointer -> new line
|
||||||
|
store := "S1"
|
||||||
|
applyOK(t, reg, g, msgAddItem("SKU-1", 1000, 1, &store))
|
||||||
|
if len(g.Items) != 2 {
|
||||||
|
t.Fatalf("expected second line for different store pointer; items=%d", len(g.Items))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same store pointer & SKU -> merge with second line
|
||||||
|
applyOK(t, reg, g, msgAddItem("SKU-1", 1000, 4, &store))
|
||||||
|
if len(g.Items) != 2 || g.Items[1].Quantity != 5 {
|
||||||
|
t.Fatalf("expected merge on second line; items=%d second.qty=%d", len(g.Items), g.Items[1].Quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid quantity
|
||||||
|
applyErrorContains(t, reg, g, msgAddItem("BAD", 1000, 0, nil), "invalid quantity")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChangeQuantityBehavior(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := newTestGrain()
|
||||||
|
|
||||||
|
applyOK(t, reg, g, msgAddItem("A", 1500, 2, nil))
|
||||||
|
id := g.Items[0].Id
|
||||||
|
|
||||||
|
// Increase quantity
|
||||||
|
applyOK(t, reg, g, msgChangeQty(id, 5))
|
||||||
|
if g.Items[0].Quantity != 5 {
|
||||||
|
t.Fatalf("quantity not updated expected=5 got=%d", g.Items[0].Quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove item by setting <=0
|
||||||
|
applyOK(t, reg, g, msgChangeQty(id, 0))
|
||||||
|
if len(g.Items) != 0 {
|
||||||
|
t.Fatalf("expected item removed; items=%d", len(g.Items))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not found
|
||||||
|
applyErrorContains(t, reg, g, msgChangeQty(9999, 1), "not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveItemBehavior(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := newTestGrain()
|
||||||
|
|
||||||
|
applyOK(t, reg, g, msgAddItem("X", 1200, 1, nil))
|
||||||
|
id := g.Items[0].Id
|
||||||
|
|
||||||
|
applyOK(t, reg, g, msgRemoveItem(id))
|
||||||
|
if len(g.Items) != 0 {
|
||||||
|
t.Fatalf("expected item removed; items=%d", len(g.Items))
|
||||||
|
}
|
||||||
|
|
||||||
|
applyErrorContains(t, reg, g, msgRemoveItem(id), "not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliveryMutations(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := newTestGrain()
|
||||||
|
|
||||||
|
applyOK(t, reg, g, msgAddItem("D1", 1000, 1, nil))
|
||||||
|
applyOK(t, reg, g, msgAddItem("D2", 2000, 1, nil))
|
||||||
|
i1 := g.Items[0].Id
|
||||||
|
|
||||||
|
// Explicit items
|
||||||
|
applyOK(t, reg, g, msgSetDelivery("POSTNORD", i1))
|
||||||
|
if len(g.Deliveries) != 1 || len(g.Deliveries[0].Items) != 1 || g.Deliveries[0].Items[0] != i1 {
|
||||||
|
t.Fatalf("delivery not created as expected: %+v", g.Deliveries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to attach an already-delivered item
|
||||||
|
applyErrorContains(t, reg, g, msgSetDelivery("POSTNORD", i1), "already has a delivery")
|
||||||
|
|
||||||
|
// Attach remaining item via empty list (auto include items without delivery)
|
||||||
|
applyOK(t, reg, g, msgSetDelivery("DHL"))
|
||||||
|
if len(g.Deliveries) != 2 {
|
||||||
|
t.Fatalf("expected second delivery; deliveries=%d", len(g.Deliveries))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-existent item
|
||||||
|
applyErrorContains(t, reg, g, msgSetDelivery("UPS", 99999), "not found")
|
||||||
|
|
||||||
|
// No eligible items left
|
||||||
|
applyErrorContains(t, reg, g, msgSetDelivery("UPS"), "no eligible items")
|
||||||
|
|
||||||
|
// Set pickup point on first delivery
|
||||||
|
did := g.Deliveries[0].Id
|
||||||
|
applyOK(t, reg, g, msgSetPickupPoint(did, "PP1"))
|
||||||
|
if g.Deliveries[0].PickupPoint == nil || g.Deliveries[0].PickupPoint.Id != "PP1" {
|
||||||
|
t.Fatalf("pickup point not set correctly: %+v", g.Deliveries[0].PickupPoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bad delivery id
|
||||||
|
applyErrorContains(t, reg, g, msgSetPickupPoint(9999, "PPX"), "delivery id")
|
||||||
|
|
||||||
|
// Remove delivery
|
||||||
|
applyOK(t, reg, g, &messages.RemoveDelivery{Id: did})
|
||||||
|
if len(g.Deliveries) != 1 || g.Deliveries[0].Id == did {
|
||||||
|
t.Fatalf("expected first delivery removed, remaining: %+v", g.Deliveries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove delivery not found
|
||||||
|
applyErrorContains(t, reg, g, &messages.RemoveDelivery{Id: did}, "not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClearCart(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := newTestGrain()
|
||||||
|
|
||||||
|
applyOK(t, reg, g, msgAddItem("X", 1000, 2, nil))
|
||||||
|
applyOK(t, reg, g, msgSetDelivery("P", g.Items[0].Id))
|
||||||
|
|
||||||
|
applyOK(t, reg, g, msgClearCart())
|
||||||
|
|
||||||
|
if len(g.Items) != 0 || len(g.Deliveries) != 0 {
|
||||||
|
t.Fatalf("expected cart cleared; items=%d deliveries=%d", len(g.Items), len(g.Deliveries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVoucherMutations(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := newTestGrain()
|
||||||
|
|
||||||
|
applyOK(t, reg, g, msgAddItem("VOUCH", 10000, 1, nil))
|
||||||
|
applyOK(t, reg, g, msgAddVoucher("PROMO", 5000))
|
||||||
|
|
||||||
|
if len(g.Vouchers) != 1 {
|
||||||
|
t.Fatalf("voucher not stored")
|
||||||
|
}
|
||||||
|
if g.TotalDiscount.IncVat != 5000 {
|
||||||
|
t.Fatalf("expected discount 5000 got %d", g.TotalDiscount.IncVat)
|
||||||
|
}
|
||||||
|
if g.TotalPrice.IncVat != 5000 {
|
||||||
|
t.Fatalf("expected total price 5000 got %d", g.TotalPrice.IncVat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate voucher code
|
||||||
|
applyErrorContains(t, reg, g, msgAddVoucher("PROMO", 1000), "already applied")
|
||||||
|
|
||||||
|
// Add a large voucher (should not apply because value > total price)
|
||||||
|
applyOK(t, reg, g, msgAddVoucher("BIG", 100000))
|
||||||
|
if len(g.Vouchers) != 2 {
|
||||||
|
t.Fatalf("expected second voucher stored")
|
||||||
|
}
|
||||||
|
if g.TotalDiscount.IncVat != 5000 || g.TotalPrice.IncVat != 5000 {
|
||||||
|
t.Fatalf("large voucher incorrectly applied discount=%d total=%d",
|
||||||
|
g.TotalDiscount.IncVat, g.TotalPrice.IncVat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove existing voucher
|
||||||
|
firstId := g.Vouchers[0].Id
|
||||||
|
applyOK(t, reg, g, msgRemoveVoucher(firstId))
|
||||||
|
|
||||||
|
if slices.ContainsFunc(g.Vouchers, func(v *Voucher) bool { return v.Id == firstId }) {
|
||||||
|
t.Fatalf("voucher id %d not removed", firstId)
|
||||||
|
}
|
||||||
|
// After removing PROMO, BIG remains but is not applied (exceeds price)
|
||||||
|
if g.TotalDiscount.IncVat != 0 || g.TotalPrice.IncVat != 10000 {
|
||||||
|
t.Fatalf("totals incorrect after removal discount=%d total=%d",
|
||||||
|
g.TotalDiscount.IncVat, g.TotalPrice.IncVat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove not applied
|
||||||
|
applyErrorContains(t, reg, g, msgRemoveVoucher(firstId), "not applied")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckoutMutations(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := newTestGrain()
|
||||||
|
|
||||||
|
applyOK(t, reg, g, msgInitializeCheckout("ORD-1", "PENDING", true))
|
||||||
|
if g.OrderReference != "ORD-1" || g.PaymentStatus != "PENDING" || !g.PaymentInProgress {
|
||||||
|
t.Fatalf("initialize checkout failed: ref=%s status=%s inProgress=%v",
|
||||||
|
g.OrderReference, g.PaymentStatus, g.PaymentInProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyOK(t, reg, g, msgOrderCreated("ORD-1", "COMPLETED"))
|
||||||
|
if g.OrderReference != "ORD-1" || g.PaymentStatus != "COMPLETED" || g.PaymentInProgress {
|
||||||
|
t.Fatalf("order created mutation failed: ref=%s status=%s inProgress=%v",
|
||||||
|
g.OrderReference, g.PaymentStatus, g.PaymentInProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyErrorContains(t, reg, g, msgInitializeCheckout("", "X", true), "missing orderId")
|
||||||
|
applyErrorContains(t, reg, g, msgOrderCreated("", "X"), "missing orderId")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscriptionDetailsMutation(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := newTestGrain()
|
||||||
|
|
||||||
|
// Upsert new (Id == nil)
|
||||||
|
msgNew := &messages.UpsertSubscriptionDetails{
|
||||||
|
OfferingCode: "OFF1",
|
||||||
|
SigningType: "TYPE1",
|
||||||
|
}
|
||||||
|
applyOK(t, reg, g, msgNew)
|
||||||
|
if len(g.SubscriptionDetails) != 1 {
|
||||||
|
t.Fatalf("expected one subscription detail; got=%d", len(g.SubscriptionDetails))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture created id
|
||||||
|
var createdId string
|
||||||
|
for k := range g.SubscriptionDetails {
|
||||||
|
createdId = k
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing
|
||||||
|
msgUpdate := &messages.UpsertSubscriptionDetails{
|
||||||
|
Id: &createdId,
|
||||||
|
OfferingCode: "OFF2",
|
||||||
|
SigningType: "TYPE2",
|
||||||
|
}
|
||||||
|
applyOK(t, reg, g, msgUpdate)
|
||||||
|
if g.SubscriptionDetails[createdId].OfferingCode != "OFF2" ||
|
||||||
|
g.SubscriptionDetails[createdId].SigningType != "TYPE2" {
|
||||||
|
t.Fatalf("subscription details not updated: %+v", g.SubscriptionDetails[createdId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update non-existent
|
||||||
|
badId := "NON_EXISTENT"
|
||||||
|
applyErrorContains(t, reg, g, &messages.UpsertSubscriptionDetails{Id: &badId}, "not found")
|
||||||
|
|
||||||
|
// Nil mutation should be ignored and produce zero results.
|
||||||
|
resultsNil, errNil := reg.Apply(g, (*messages.UpsertSubscriptionDetails)(nil))
|
||||||
|
if errNil != nil {
|
||||||
|
t.Fatalf("unexpected error for nil mutation element: %v", errNil)
|
||||||
|
}
|
||||||
|
if len(resultsNil) != 0 {
|
||||||
|
t.Fatalf("expected zero results for nil mutation, got %d", len(resultsNil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure registry Apply handles nil grain and nil message defensive errors consistently.
|
||||||
|
func TestRegistryDefensiveErrors(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := newTestGrain()
|
||||||
|
|
||||||
|
// Nil grain
|
||||||
|
results, err := reg.Apply(nil, &messages.AddItem{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for nil grain")
|
||||||
|
}
|
||||||
|
if len(results) != 0 {
|
||||||
|
t.Fatalf("expected no results for nil grain")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nil message slice
|
||||||
|
results, _ = reg.Apply(g, nil)
|
||||||
|
|
||||||
|
if len(results) != 0 {
|
||||||
|
t.Fatalf("expected no results when message slice nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestSubscriptionDetailsJSONValidation(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := newTestGrain()
|
||||||
|
|
||||||
|
// Valid JSON on create
|
||||||
|
validCreate := &messages.UpsertSubscriptionDetails{
|
||||||
|
OfferingCode: "OFFJSON",
|
||||||
|
SigningType: "TYPEJSON",
|
||||||
|
Data: &anypb.Any{Value: []byte(`{"ok":true}`)},
|
||||||
|
}
|
||||||
|
applyOK(t, reg, g, validCreate)
|
||||||
|
if len(g.SubscriptionDetails) != 1 {
|
||||||
|
t.Fatalf("expected one subscription detail after valid create, got %d", len(g.SubscriptionDetails))
|
||||||
|
}
|
||||||
|
var id string
|
||||||
|
for k := range g.SubscriptionDetails {
|
||||||
|
id = k
|
||||||
|
}
|
||||||
|
if string(g.SubscriptionDetails[id].Meta) != `{"ok":true}` {
|
||||||
|
t.Fatalf("expected meta stored as valid json, got %s", string(g.SubscriptionDetails[id].Meta))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update with valid JSON replaces meta
|
||||||
|
updateValid := &messages.UpsertSubscriptionDetails{
|
||||||
|
Id: &id,
|
||||||
|
Data: &anypb.Any{Value: []byte(`{"changed":123}`)},
|
||||||
|
}
|
||||||
|
applyOK(t, reg, g, updateValid)
|
||||||
|
if string(g.SubscriptionDetails[id].Meta) != `{"changed":123}` {
|
||||||
|
t.Fatalf("expected meta updated to new json, got %s", string(g.SubscriptionDetails[id].Meta))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid JSON on create
|
||||||
|
invalidCreate := &messages.UpsertSubscriptionDetails{
|
||||||
|
OfferingCode: "BAD",
|
||||||
|
Data: &anypb.Any{Value: []byte(`{"broken":}`)},
|
||||||
|
}
|
||||||
|
res := applyOne(t, reg, g, invalidCreate)
|
||||||
|
if res.Error == nil || !strings.Contains(res.Error.Error(), "invalid json") {
|
||||||
|
t.Fatalf("expected invalid json error on create, got %v", res.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid JSON on update
|
||||||
|
badUpdate := &messages.UpsertSubscriptionDetails{
|
||||||
|
Id: &id,
|
||||||
|
Data: &anypb.Any{Value: []byte(`{oops`)},
|
||||||
|
}
|
||||||
|
res2 := applyOne(t, reg, g, badUpdate)
|
||||||
|
if res2.Error == nil || !strings.Contains(res2.Error.Error(), "invalid json") {
|
||||||
|
t.Fatalf("expected invalid json error on update, got %v", res2.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty Data.Value should not overwrite existing meta
|
||||||
|
emptyUpdate := &messages.UpsertSubscriptionDetails{
|
||||||
|
Id: &id,
|
||||||
|
Data: &anypb.Any{Value: []byte{}},
|
||||||
|
}
|
||||||
|
applyOK(t, reg, g, emptyUpdate)
|
||||||
|
if string(g.SubscriptionDetails[id].Meta) != `{"changed":123}` {
|
||||||
|
t.Fatalf("empty update should not change meta, got %s", string(g.SubscriptionDetails[id].Meta))
|
||||||
|
}
|
||||||
|
}
|
||||||
57
pkg/cart/mutation_upsert_subscriptiondetails.go
Normal file
57
pkg/cart/mutation_upsert_subscriptiondetails.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
func UpsertSubscriptionDetails(g *CartGrain, m *messages.UpsertSubscriptionDetails) error {
|
||||||
|
if m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new subscription details when Id is nil.
|
||||||
|
if m.Id == nil {
|
||||||
|
// Validate JSON if provided.
|
||||||
|
var meta json.RawMessage
|
||||||
|
if m.Data != nil && len(m.Data.Value) > 0 {
|
||||||
|
if !json.Valid(m.Data.Value) {
|
||||||
|
return fmt.Errorf("subscription details invalid json")
|
||||||
|
}
|
||||||
|
meta = json.RawMessage(m.Data.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
id := MustNewCartId().String()
|
||||||
|
g.SubscriptionDetails[id] = &SubscriptionDetails{
|
||||||
|
Id: id,
|
||||||
|
OfferingCode: m.OfferingCode,
|
||||||
|
SigningType: m.SigningType,
|
||||||
|
Meta: meta,
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing entry.
|
||||||
|
existing, ok := g.SubscriptionDetails[*m.Id]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("subscription details not found")
|
||||||
|
}
|
||||||
|
if m.OfferingCode != "" {
|
||||||
|
existing.OfferingCode = m.OfferingCode
|
||||||
|
}
|
||||||
|
if m.SigningType != "" {
|
||||||
|
existing.SigningType = m.SigningType
|
||||||
|
}
|
||||||
|
if m.Data != nil {
|
||||||
|
// Only validate & assign if there is content; empty -> leave as-is.
|
||||||
|
if len(m.Data.Value) > 0 {
|
||||||
|
if !json.Valid(m.Data.Value) {
|
||||||
|
return fmt.Errorf("subscription details invalid json")
|
||||||
|
}
|
||||||
|
existing.Meta = m.Data.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
131
pkg/cart/price.go
Normal file
131
pkg/cart/price.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
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
pkg/cart/price_test.go
Normal file
135
pkg/cart/price_test.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
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(1250, 25)
|
||||||
|
if p.IncVat != 1250 {
|
||||||
|
t.Fatalf("expected IncVat %d got %d", 1250, p.IncVat)
|
||||||
|
}
|
||||||
|
if p.VatRates[25] != 250 {
|
||||||
|
t.Fatalf("expected VAT 25 rate %d got %d", 250, p.VatRates[25])
|
||||||
|
}
|
||||||
|
if p.ValueExVat() != 1000 {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
73
pkg/discovery/discovery.go
Normal file
73
pkg/discovery/discovery.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,82 +1,12 @@
|
|||||||
package main
|
package discovery
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
v1 "k8s.io/api/core/v1"
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"k8s.io/apimachinery/pkg/watch"
|
"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.
|
// MockDiscovery is an in-memory Discovery implementation for tests.
|
||||||
// It allows deterministic injection of host additions/removals without
|
// It allows deterministic injection of host additions/removals without
|
||||||
// depending on Kubernetes API machinery.
|
// depending on Kubernetes API machinery.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package discovery
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
6
pkg/discovery/types.go
Normal file
6
pkg/discovery/types.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package discovery
|
||||||
|
|
||||||
|
type Discovery interface {
|
||||||
|
Discover() ([]string, error)
|
||||||
|
Watch() (<-chan HostChange, error)
|
||||||
|
}
|
||||||
47
pkg/inventory/memory_service.go
Normal file
47
pkg/inventory/memory_service.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package inventory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MemoryInventoryService struct {
|
||||||
|
warehouses map[LocationID]*Warehouse
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMemoryInventoryService() *MemoryInventoryService {
|
||||||
|
return &MemoryInventoryService{
|
||||||
|
warehouses: make(map[LocationID]*Warehouse),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MemoryInventoryService) AddWarehouse(warehouse *Warehouse) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.warehouses[warehouse.ID] = warehouse
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MemoryInventoryService) GetInventory(sku SKU, locationID LocationID) (*InventoryItem, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
warehouse, ok := s.warehouses[locationID]
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("warehouse not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range warehouse.Inventory {
|
||||||
|
if item.SKU == sku {
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("sku not found in warehouse")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MemoryInventoryService) ReserveInventory(req ...ReserveRequest) error {
|
||||||
|
// We'll implement the reservation logic using Lua script execution here
|
||||||
|
|
||||||
|
// For now, let's just return an error indicating it's not implemented
|
||||||
|
return errors.New("reservation not implemented yet")
|
||||||
|
}
|
||||||
251
pkg/inventory/redis_service.go
Normal file
251
pkg/inventory/redis_service.go
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
package inventory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RedisInventoryService struct {
|
||||||
|
client *redis.Client
|
||||||
|
ctx context.Context
|
||||||
|
luaScripts map[string]*redis.Script
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRedisInventoryService(client *redis.Client, ctx context.Context) (*RedisInventoryService, error) {
|
||||||
|
rdb := client
|
||||||
|
|
||||||
|
// Ping Redis to check connection
|
||||||
|
_, err := rdb.Ping(ctx).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RedisInventoryService{
|
||||||
|
client: rdb,
|
||||||
|
ctx: ctx,
|
||||||
|
luaScripts: make(map[string]*redis.Script),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RedisInventoryService) LoadLuaScript(key string) error {
|
||||||
|
// Get the script from Redis
|
||||||
|
script, err := s.client.Get(s.ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the script into the luaScripts cache
|
||||||
|
s.luaScripts[key] = redis.NewScript(script)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RedisInventoryService) AddWarehouse(warehouse *Warehouse) error {
|
||||||
|
// Convert warehouse to Redis-friendly format
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"id": string(warehouse.ID),
|
||||||
|
"name": warehouse.Name,
|
||||||
|
"inventory": warehouse.Inventory,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in Redis with a key pattern like "warehouse:<ID>"
|
||||||
|
key := "warehouse:" + string(warehouse.ID)
|
||||||
|
_, err := s.client.HMSet(s.ctx, key, data).Result()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RedisInventoryService) GetInventory(sku SKU, locationID LocationID) (int64, error) {
|
||||||
|
|
||||||
|
cmd := s.client.Get(s.ctx, getInventoryKey(sku, locationID))
|
||||||
|
if err := cmd.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
i, err := cmd.Int64()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getInventoryKey(sku SKU, locationID LocationID) string {
|
||||||
|
return fmt.Sprintf("inventory:%s:%s", sku, locationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RedisInventoryService) UpdateInventory(rdb redis.Pipeliner, sku SKU, locationID LocationID, quantity int64) error {
|
||||||
|
key := getInventoryKey(sku, locationID)
|
||||||
|
cmd := rdb.Set(s.ctx, key, quantity, 0)
|
||||||
|
return cmd.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInsufficientInventory = errors.New("insufficient inventory")
|
||||||
|
ErrInvalidQuantity = errors.New("invalid quantity")
|
||||||
|
ErrMissingReservation = errors.New("missing reservation")
|
||||||
|
)
|
||||||
|
|
||||||
|
func makeKeysAndArgs(req ...ReserveRequest) ([]string, []string) {
|
||||||
|
keys := make([]string, len(req))
|
||||||
|
args := make([]string, len(req))
|
||||||
|
for i, r := range req {
|
||||||
|
if r.Quantity <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
keys[i] = getInventoryKey(r.SKU, r.LocationID)
|
||||||
|
args[i] = strconv.Itoa(int(r.Quantity))
|
||||||
|
}
|
||||||
|
return keys, args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RedisInventoryService) ReservationCheck(req ...ReserveRequest) error {
|
||||||
|
if len(req) == 0 {
|
||||||
|
return ErrMissingReservation
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, args := makeKeysAndArgs(req...)
|
||||||
|
if keys == nil || args == nil {
|
||||||
|
return ErrInvalidQuantity
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := reserveScript.Run(s.ctx, s.client, keys, args)
|
||||||
|
if err := cmd.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if val, err := cmd.Int(); err != nil {
|
||||||
|
return err
|
||||||
|
} else if val != 1 {
|
||||||
|
return ErrInsufficientInventory
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RedisInventoryService) ReserveInventory(req ...ReserveRequest) error {
|
||||||
|
if len(req) == 0 {
|
||||||
|
return ErrMissingReservation
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, args := makeKeysAndArgs(req...)
|
||||||
|
if keys == nil || args == nil {
|
||||||
|
return ErrInvalidQuantity
|
||||||
|
}
|
||||||
|
cmd := reserveScript.Run(s.ctx, s.client, keys, args)
|
||||||
|
if err := cmd.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if val, err := cmd.Int(); err != nil {
|
||||||
|
return err
|
||||||
|
} else if val != 1 {
|
||||||
|
return ErrInsufficientInventory
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var reservationCheck = redis.NewScript(`
|
||||||
|
-- Get the number of keys passed
|
||||||
|
local num_keys = #KEYS
|
||||||
|
|
||||||
|
-- Ensure the number of keys matches the number of quantities
|
||||||
|
if num_keys ~= #ARGV then
|
||||||
|
return {err = "Script requires the same number of keys and quantities."}
|
||||||
|
end
|
||||||
|
|
||||||
|
local new_values = {}
|
||||||
|
local payload = {}
|
||||||
|
|
||||||
|
-- ---
|
||||||
|
-- 1. CHECK PHASE
|
||||||
|
-- ---
|
||||||
|
-- Loop through all keys to check their values first
|
||||||
|
for i = 1, num_keys do
|
||||||
|
local key = KEYS[i]
|
||||||
|
local quantity_to_check = tonumber(ARGV[i])
|
||||||
|
|
||||||
|
-- Fail if the quantity is not a valid number
|
||||||
|
if not quantity_to_check then
|
||||||
|
return {err = "Invalid quantity provided for key: " .. key}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get the current value stored at the key
|
||||||
|
local current_val = tonumber(redis.call('GET', key))
|
||||||
|
|
||||||
|
-- Check the condition
|
||||||
|
-- Fail if:
|
||||||
|
-- 1. The key doesn't exist (current_val is nil)
|
||||||
|
-- 2. The value is not > the required quantity
|
||||||
|
if not current_val or current_val <= quantity_to_check then
|
||||||
|
-- Return 0 to indicate the operation failed and no changes were made
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return 1
|
||||||
|
`)
|
||||||
|
|
||||||
|
var reserveScript = redis.NewScript(`
|
||||||
|
-- Get the number of keys passed
|
||||||
|
local num_keys = #KEYS
|
||||||
|
|
||||||
|
-- Ensure the number of keys matches the number of quantities
|
||||||
|
if num_keys ~= #ARGV then
|
||||||
|
return {err = "Script requires the same number of keys and quantities."}
|
||||||
|
end
|
||||||
|
|
||||||
|
local new_values = {}
|
||||||
|
local payload = {}
|
||||||
|
|
||||||
|
-- ---
|
||||||
|
-- 1. CHECK PHASE
|
||||||
|
-- ---
|
||||||
|
-- Loop through all keys to check their values first
|
||||||
|
for i = 1, num_keys do
|
||||||
|
local key = KEYS[i]
|
||||||
|
local quantity_to_check = tonumber(ARGV[i])
|
||||||
|
|
||||||
|
-- Fail if the quantity is not a valid number
|
||||||
|
if not quantity_to_check then
|
||||||
|
return {err = "Invalid quantity provided for key: " .. key}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get the current value stored at the key
|
||||||
|
local current_val = tonumber(redis.call('GET', key))
|
||||||
|
|
||||||
|
-- Check the condition
|
||||||
|
-- Fail if:
|
||||||
|
-- 1. The key doesn't exist (current_val is nil)
|
||||||
|
-- 2. The value is not > the required quantity
|
||||||
|
if not current_val or current_val <= quantity_to_check then
|
||||||
|
-- Return 0 to indicate the operation failed and no changes were made
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- If the check passes, store the new value
|
||||||
|
local new_val = current_val - quantity_to_check
|
||||||
|
table.insert(new_values, new_val)
|
||||||
|
|
||||||
|
-- Add this key and its *new* value to our payload map
|
||||||
|
payload[key] = new_val
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---
|
||||||
|
-- 2. UPDATE PHASE
|
||||||
|
-- ---
|
||||||
|
-- If the script reaches this point, all checks passed.
|
||||||
|
-- Now, loop again and apply all the updates.
|
||||||
|
for i = 1, num_keys do
|
||||||
|
local key = KEYS[i]
|
||||||
|
local new_val = new_values[i]
|
||||||
|
|
||||||
|
-- Set the key to its new calculated value
|
||||||
|
redis.call('SET', key, new_val)
|
||||||
|
end
|
||||||
|
local message_payload = cjson.encode(payload)
|
||||||
|
|
||||||
|
-- Publish the JSON-encoded message to the specified channel
|
||||||
|
redis.call('PUBLISH', "inventory_changed", message_payload)
|
||||||
|
-- Return 1 to indicate the operation was successful
|
||||||
|
return 1
|
||||||
|
`)
|
||||||
30
pkg/inventory/types.go
Normal file
30
pkg/inventory/types.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package inventory
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type SKU string
|
||||||
|
|
||||||
|
type LocationID string
|
||||||
|
|
||||||
|
type InventoryItem struct {
|
||||||
|
SKU SKU
|
||||||
|
Quantity int
|
||||||
|
LastUpdate time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Warehouse struct {
|
||||||
|
ID LocationID
|
||||||
|
Name string
|
||||||
|
Inventory []InventoryItem
|
||||||
|
}
|
||||||
|
|
||||||
|
type InventoryService interface {
|
||||||
|
GetInventory(sku SKU, locationID LocationID) (int64, error)
|
||||||
|
ReserveInventory(req ...ReserveRequest) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReserveRequest struct {
|
||||||
|
SKU SKU
|
||||||
|
LocationID LocationID
|
||||||
|
Quantity uint32
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.10
|
// protoc-gen-go v1.36.10
|
||||||
// protoc v3.21.12
|
// protoc v6.32.1
|
||||||
// source: control_plane.proto
|
// source: control_plane.proto
|
||||||
|
|
||||||
package messages
|
package messages
|
||||||
@@ -202,27 +202,27 @@ func (x *NegotiateReply) GetHosts() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CartIdsReply returns the list of cart IDs (string form) currently owned locally.
|
// CartIdsReply returns the list of cart IDs (string form) currently owned locally.
|
||||||
type CartIdsReply struct {
|
type ActorIdsReply struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
CartIds []string `protobuf:"bytes,1,rep,name=cart_ids,json=cartIds,proto3" json:"cart_ids,omitempty"`
|
Ids []uint64 `protobuf:"varint,1,rep,packed,name=ids,proto3" json:"ids,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *CartIdsReply) Reset() {
|
func (x *ActorIdsReply) Reset() {
|
||||||
*x = CartIdsReply{}
|
*x = ActorIdsReply{}
|
||||||
mi := &file_control_plane_proto_msgTypes[4]
|
mi := &file_control_plane_proto_msgTypes[4]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *CartIdsReply) String() string {
|
func (x *ActorIdsReply) String() string {
|
||||||
return protoimpl.X.MessageStringOf(x)
|
return protoimpl.X.MessageStringOf(x)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*CartIdsReply) ProtoMessage() {}
|
func (*ActorIdsReply) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *CartIdsReply) ProtoReflect() protoreflect.Message {
|
func (x *ActorIdsReply) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_control_plane_proto_msgTypes[4]
|
mi := &file_control_plane_proto_msgTypes[4]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
@@ -234,14 +234,14 @@ func (x *CartIdsReply) ProtoReflect() protoreflect.Message {
|
|||||||
return mi.MessageOf(x)
|
return mi.MessageOf(x)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deprecated: Use CartIdsReply.ProtoReflect.Descriptor instead.
|
// Deprecated: Use ActorIdsReply.ProtoReflect.Descriptor instead.
|
||||||
func (*CartIdsReply) Descriptor() ([]byte, []int) {
|
func (*ActorIdsReply) Descriptor() ([]byte, []int) {
|
||||||
return file_control_plane_proto_rawDescGZIP(), []int{4}
|
return file_control_plane_proto_rawDescGZIP(), []int{4}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *CartIdsReply) GetCartIds() []string {
|
func (x *ActorIdsReply) GetIds() []uint64 {
|
||||||
if x != nil {
|
if x != nil {
|
||||||
return x.CartIds
|
return x.Ids
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -344,6 +344,113 @@ func (x *ClosingNotice) GetHost() string {
|
|||||||
return ""
|
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 protoreflect.FileDescriptor
|
||||||
|
|
||||||
const file_control_plane_proto_rawDesc = "" +
|
const file_control_plane_proto_rawDesc = "" +
|
||||||
@@ -357,19 +464,26 @@ const file_control_plane_proto_rawDesc = "" +
|
|||||||
"\vknown_hosts\x18\x01 \x03(\tR\n" +
|
"\vknown_hosts\x18\x01 \x03(\tR\n" +
|
||||||
"knownHosts\"&\n" +
|
"knownHosts\"&\n" +
|
||||||
"\x0eNegotiateReply\x12\x14\n" +
|
"\x0eNegotiateReply\x12\x14\n" +
|
||||||
"\x05hosts\x18\x01 \x03(\tR\x05hosts\")\n" +
|
"\x05hosts\x18\x01 \x03(\tR\x05hosts\"!\n" +
|
||||||
"\fCartIdsReply\x12\x19\n" +
|
"\rActorIdsReply\x12\x10\n" +
|
||||||
"\bcart_ids\x18\x01 \x03(\tR\acartIds\"F\n" +
|
"\x03ids\x18\x01 \x03(\x04R\x03ids\"F\n" +
|
||||||
"\x0eOwnerChangeAck\x12\x1a\n" +
|
"\x0eOwnerChangeAck\x12\x1a\n" +
|
||||||
"\baccepted\x18\x01 \x01(\bR\baccepted\x12\x18\n" +
|
"\baccepted\x18\x01 \x01(\bR\baccepted\x12\x18\n" +
|
||||||
"\amessage\x18\x02 \x01(\tR\amessage\"#\n" +
|
"\amessage\x18\x02 \x01(\tR\amessage\"#\n" +
|
||||||
"\rClosingNotice\x12\x12\n" +
|
"\rClosingNotice\x12\x12\n" +
|
||||||
"\x04host\x18\x01 \x01(\tR\x04host2\xf4\x01\n" +
|
"\x04host\x18\x01 \x01(\tR\x04host\"9\n" +
|
||||||
|
"\x11OwnershipAnnounce\x12\x12\n" +
|
||||||
|
"\x04host\x18\x01 \x01(\tR\x04host\x12\x10\n" +
|
||||||
|
"\x03ids\x18\x02 \x03(\x04R\x03ids\"6\n" +
|
||||||
|
"\x0eExpiryAnnounce\x12\x12\n" +
|
||||||
|
"\x04host\x18\x01 \x01(\tR\x04host\x12\x10\n" +
|
||||||
|
"\x03ids\x18\x02 \x03(\x04R\x03ids2\x8d\x03\n" +
|
||||||
"\fControlPlane\x12,\n" +
|
"\fControlPlane\x12,\n" +
|
||||||
"\x04Ping\x12\x0f.messages.Empty\x1a\x13.messages.PingReply\x12A\n" +
|
"\x04Ping\x12\x0f.messages.Empty\x1a\x13.messages.PingReply\x12A\n" +
|
||||||
"\tNegotiate\x12\x1a.messages.NegotiateRequest\x1a\x18.messages.NegotiateReply\x125\n" +
|
"\tNegotiate\x12\x1a.messages.NegotiateRequest\x1a\x18.messages.NegotiateReply\x12<\n" +
|
||||||
"\n" +
|
"\x10GetLocalActorIds\x12\x0f.messages.Empty\x1a\x17.messages.ActorIdsReply\x12J\n" +
|
||||||
"GetCartIds\x12\x0f.messages.Empty\x1a\x16.messages.CartIdsReply\x12<\n" +
|
"\x11AnnounceOwnership\x12\x1b.messages.OwnershipAnnounce\x1a\x18.messages.OwnerChangeAck\x12D\n" +
|
||||||
|
"\x0eAnnounceExpiry\x12\x18.messages.ExpiryAnnounce\x1a\x18.messages.OwnerChangeAck\x12<\n" +
|
||||||
"\aClosing\x12\x17.messages.ClosingNotice\x1a\x18.messages.OwnerChangeAckB.Z,git.tornberg.me/go-cart-actor/proto;messagesb\x06proto3"
|
"\aClosing\x12\x17.messages.ClosingNotice\x1a\x18.messages.OwnerChangeAckB.Z,git.tornberg.me/go-cart-actor/proto;messagesb\x06proto3"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -384,27 +498,33 @@ func file_control_plane_proto_rawDescGZIP() []byte {
|
|||||||
return file_control_plane_proto_rawDescData
|
return file_control_plane_proto_rawDescData
|
||||||
}
|
}
|
||||||
|
|
||||||
var file_control_plane_proto_msgTypes = make([]protoimpl.MessageInfo, 7)
|
var file_control_plane_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
|
||||||
var file_control_plane_proto_goTypes = []any{
|
var file_control_plane_proto_goTypes = []any{
|
||||||
(*Empty)(nil), // 0: messages.Empty
|
(*Empty)(nil), // 0: messages.Empty
|
||||||
(*PingReply)(nil), // 1: messages.PingReply
|
(*PingReply)(nil), // 1: messages.PingReply
|
||||||
(*NegotiateRequest)(nil), // 2: messages.NegotiateRequest
|
(*NegotiateRequest)(nil), // 2: messages.NegotiateRequest
|
||||||
(*NegotiateReply)(nil), // 3: messages.NegotiateReply
|
(*NegotiateReply)(nil), // 3: messages.NegotiateReply
|
||||||
(*CartIdsReply)(nil), // 4: messages.CartIdsReply
|
(*ActorIdsReply)(nil), // 4: messages.ActorIdsReply
|
||||||
(*OwnerChangeAck)(nil), // 5: messages.OwnerChangeAck
|
(*OwnerChangeAck)(nil), // 5: messages.OwnerChangeAck
|
||||||
(*ClosingNotice)(nil), // 6: messages.ClosingNotice
|
(*ClosingNotice)(nil), // 6: messages.ClosingNotice
|
||||||
|
(*OwnershipAnnounce)(nil), // 7: messages.OwnershipAnnounce
|
||||||
|
(*ExpiryAnnounce)(nil), // 8: messages.ExpiryAnnounce
|
||||||
}
|
}
|
||||||
var file_control_plane_proto_depIdxs = []int32{
|
var file_control_plane_proto_depIdxs = []int32{
|
||||||
0, // 0: messages.ControlPlane.Ping:input_type -> messages.Empty
|
0, // 0: messages.ControlPlane.Ping:input_type -> messages.Empty
|
||||||
2, // 1: messages.ControlPlane.Negotiate:input_type -> messages.NegotiateRequest
|
2, // 1: messages.ControlPlane.Negotiate:input_type -> messages.NegotiateRequest
|
||||||
0, // 2: messages.ControlPlane.GetCartIds:input_type -> messages.Empty
|
0, // 2: messages.ControlPlane.GetLocalActorIds:input_type -> messages.Empty
|
||||||
6, // 3: messages.ControlPlane.Closing:input_type -> messages.ClosingNotice
|
7, // 3: messages.ControlPlane.AnnounceOwnership:input_type -> messages.OwnershipAnnounce
|
||||||
1, // 4: messages.ControlPlane.Ping:output_type -> messages.PingReply
|
8, // 4: messages.ControlPlane.AnnounceExpiry:input_type -> messages.ExpiryAnnounce
|
||||||
3, // 5: messages.ControlPlane.Negotiate:output_type -> messages.NegotiateReply
|
6, // 5: messages.ControlPlane.Closing:input_type -> messages.ClosingNotice
|
||||||
4, // 6: messages.ControlPlane.GetCartIds:output_type -> messages.CartIdsReply
|
1, // 6: messages.ControlPlane.Ping:output_type -> messages.PingReply
|
||||||
5, // 7: messages.ControlPlane.Closing:output_type -> messages.OwnerChangeAck
|
3, // 7: messages.ControlPlane.Negotiate:output_type -> messages.NegotiateReply
|
||||||
4, // [4:8] is the sub-list for method output_type
|
4, // 8: messages.ControlPlane.GetLocalActorIds:output_type -> messages.ActorIdsReply
|
||||||
0, // [0:4] is the sub-list for method input_type
|
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 type_name
|
||||||
0, // [0:0] is the sub-list for extension extendee
|
0, // [0:0] is the sub-list for extension extendee
|
||||||
0, // [0:0] is the sub-list for field type_name
|
0, // [0:0] is the sub-list for field type_name
|
||||||
@@ -421,7 +541,7 @@ func file_control_plane_proto_init() {
|
|||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_control_plane_proto_rawDesc), len(file_control_plane_proto_rawDesc)),
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_control_plane_proto_rawDesc), len(file_control_plane_proto_rawDesc)),
|
||||||
NumEnums: 0,
|
NumEnums: 0,
|
||||||
NumMessages: 7,
|
NumMessages: 9,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 1,
|
NumServices: 1,
|
||||||
},
|
},
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.5.1
|
// - protoc-gen-go-grpc v1.5.1
|
||||||
// - protoc v3.21.12
|
// - protoc v6.32.1
|
||||||
// source: control_plane.proto
|
// source: control_plane.proto
|
||||||
|
|
||||||
package messages
|
package messages
|
||||||
@@ -21,7 +21,9 @@ const _ = grpc.SupportPackageIsVersion9
|
|||||||
const (
|
const (
|
||||||
ControlPlane_Ping_FullMethodName = "/messages.ControlPlane/Ping"
|
ControlPlane_Ping_FullMethodName = "/messages.ControlPlane/Ping"
|
||||||
ControlPlane_Negotiate_FullMethodName = "/messages.ControlPlane/Negotiate"
|
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"
|
ControlPlane_Closing_FullMethodName = "/messages.ControlPlane/Closing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,7 +38,11 @@ type ControlPlaneClient interface {
|
|||||||
// Negotiate merges host views; used during discovery & convergence.
|
// Negotiate merges host views; used during discovery & convergence.
|
||||||
Negotiate(ctx context.Context, in *NegotiateRequest, opts ...grpc.CallOption) (*NegotiateReply, error)
|
Negotiate(ctx context.Context, in *NegotiateRequest, opts ...grpc.CallOption) (*NegotiateReply, error)
|
||||||
// GetCartIds lists currently owned cart IDs on this node.
|
// 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 announces graceful shutdown so peers can proactively adjust.
|
||||||
Closing(ctx context.Context, in *ClosingNotice, opts ...grpc.CallOption) (*OwnerChangeAck, error)
|
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
|
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...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(CartIdsReply)
|
out := new(ActorIdsReply)
|
||||||
err := c.cc.Invoke(ctx, ControlPlane_GetCartIds_FullMethodName, in, out, cOpts...)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -100,7 +126,11 @@ type ControlPlaneServer interface {
|
|||||||
// Negotiate merges host views; used during discovery & convergence.
|
// Negotiate merges host views; used during discovery & convergence.
|
||||||
Negotiate(context.Context, *NegotiateRequest) (*NegotiateReply, error)
|
Negotiate(context.Context, *NegotiateRequest) (*NegotiateReply, error)
|
||||||
// GetCartIds lists currently owned cart IDs on this node.
|
// 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 announces graceful shutdown so peers can proactively adjust.
|
||||||
Closing(context.Context, *ClosingNotice) (*OwnerChangeAck, error)
|
Closing(context.Context, *ClosingNotice) (*OwnerChangeAck, error)
|
||||||
mustEmbedUnimplementedControlPlaneServer()
|
mustEmbedUnimplementedControlPlaneServer()
|
||||||
@@ -119,8 +149,14 @@ func (UnimplementedControlPlaneServer) Ping(context.Context, *Empty) (*PingReply
|
|||||||
func (UnimplementedControlPlaneServer) Negotiate(context.Context, *NegotiateRequest) (*NegotiateReply, error) {
|
func (UnimplementedControlPlaneServer) Negotiate(context.Context, *NegotiateRequest) (*NegotiateReply, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method Negotiate not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method Negotiate not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedControlPlaneServer) GetCartIds(context.Context, *Empty) (*CartIdsReply, error) {
|
func (UnimplementedControlPlaneServer) GetLocalActorIds(context.Context, *Empty) (*ActorIdsReply, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method GetCartIds not implemented")
|
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) {
|
func (UnimplementedControlPlaneServer) Closing(context.Context, *ClosingNotice) (*OwnerChangeAck, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method Closing not implemented")
|
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)
|
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)
|
in := new(Empty)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if interceptor == nil {
|
if interceptor == nil {
|
||||||
return srv.(ControlPlaneServer).GetCartIds(ctx, in)
|
return srv.(ControlPlaneServer).GetLocalActorIds(ctx, in)
|
||||||
}
|
}
|
||||||
info := &grpc.UnaryServerInfo{
|
info := &grpc.UnaryServerInfo{
|
||||||
Server: srv,
|
Server: srv,
|
||||||
FullMethod: ControlPlane_GetCartIds_FullMethodName,
|
FullMethod: ControlPlane_GetLocalActorIds_FullMethodName,
|
||||||
}
|
}
|
||||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
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)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
@@ -234,8 +306,16 @@ var ControlPlane_ServiceDesc = grpc.ServiceDesc{
|
|||||||
Handler: _ControlPlane_Negotiate_Handler,
|
Handler: _ControlPlane_Negotiate_Handler,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
MethodName: "GetCartIds",
|
MethodName: "GetLocalActorIds",
|
||||||
Handler: _ControlPlane_GetCartIds_Handler,
|
Handler: _ControlPlane_GetLocalActorIds_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "AnnounceOwnership",
|
||||||
|
Handler: _ControlPlane_AnnounceOwnership_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "AnnounceExpiry",
|
||||||
|
Handler: _ControlPlane_AnnounceExpiry_Handler,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
MethodName: "Closing",
|
MethodName: "Closing",
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.10
|
// protoc-gen-go v1.36.10
|
||||||
// protoc v3.21.12
|
// protoc v6.32.1
|
||||||
// source: messages.proto
|
// source: messages.proto
|
||||||
|
|
||||||
package messages
|
package messages
|
||||||
@@ -9,6 +9,7 @@ package messages
|
|||||||
import (
|
import (
|
||||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||||
|
anypb "google.golang.org/protobuf/types/known/anypb"
|
||||||
reflect "reflect"
|
reflect "reflect"
|
||||||
sync "sync"
|
sync "sync"
|
||||||
unsafe "unsafe"
|
unsafe "unsafe"
|
||||||
@@ -21,30 +22,26 @@ const (
|
|||||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||||
)
|
)
|
||||||
|
|
||||||
type AddRequest struct {
|
type ClearCartRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *AddRequest) Reset() {
|
func (x *ClearCartRequest) Reset() {
|
||||||
*x = AddRequest{}
|
*x = ClearCartRequest{}
|
||||||
mi := &file_messages_proto_msgTypes[0]
|
mi := &file_messages_proto_msgTypes[0]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *AddRequest) String() string {
|
func (x *ClearCartRequest) String() string {
|
||||||
return protoimpl.X.MessageStringOf(x)
|
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]
|
mi := &file_messages_proto_msgTypes[0]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
@@ -56,86 +53,14 @@ func (x *AddRequest) ProtoReflect() protoreflect.Message {
|
|||||||
return mi.MessageOf(x)
|
return mi.MessageOf(x)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deprecated: Use AddRequest.ProtoReflect.Descriptor instead.
|
// Deprecated: Use ClearCartRequest.ProtoReflect.Descriptor instead.
|
||||||
func (*AddRequest) Descriptor() ([]byte, []int) {
|
func (*ClearCartRequest) Descriptor() ([]byte, []int) {
|
||||||
return file_messages_proto_rawDescGZIP(), []int{0}
|
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 {
|
type AddItem struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
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"`
|
Quantity int32 `protobuf:"varint,2,opt,name=quantity,proto3" json:"quantity,omitempty"`
|
||||||
Price int64 `protobuf:"varint,3,opt,name=price,proto3" json:"price,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"`
|
OrgPrice int64 `protobuf:"varint,9,opt,name=orgPrice,proto3" json:"orgPrice,omitempty"`
|
||||||
@@ -155,15 +80,17 @@ type AddItem struct {
|
|||||||
SellerId string `protobuf:"bytes,19,opt,name=sellerId,proto3" json:"sellerId,omitempty"`
|
SellerId string `protobuf:"bytes,19,opt,name=sellerId,proto3" json:"sellerId,omitempty"`
|
||||||
SellerName string `protobuf:"bytes,20,opt,name=sellerName,proto3" json:"sellerName,omitempty"`
|
SellerName string `protobuf:"bytes,20,opt,name=sellerName,proto3" json:"sellerName,omitempty"`
|
||||||
Country string `protobuf:"bytes,21,opt,name=country,proto3" json:"country,omitempty"`
|
Country string `protobuf:"bytes,21,opt,name=country,proto3" json:"country,omitempty"`
|
||||||
|
SaleStatus string `protobuf:"bytes,24,opt,name=saleStatus,proto3" json:"saleStatus,omitempty"`
|
||||||
Outlet *string `protobuf:"bytes,12,opt,name=outlet,proto3,oneof" json:"outlet,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"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *AddItem) Reset() {
|
func (x *AddItem) Reset() {
|
||||||
*x = AddItem{}
|
*x = AddItem{}
|
||||||
mi := &file_messages_proto_msgTypes[2]
|
mi := &file_messages_proto_msgTypes[1]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -175,7 +102,7 @@ func (x *AddItem) String() string {
|
|||||||
func (*AddItem) ProtoMessage() {}
|
func (*AddItem) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *AddItem) ProtoReflect() protoreflect.Message {
|
func (x *AddItem) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_messages_proto_msgTypes[2]
|
mi := &file_messages_proto_msgTypes[1]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -188,10 +115,10 @@ func (x *AddItem) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use AddItem.ProtoReflect.Descriptor instead.
|
// Deprecated: Use AddItem.ProtoReflect.Descriptor instead.
|
||||||
func (*AddItem) Descriptor() ([]byte, []int) {
|
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 {
|
if x != nil {
|
||||||
return x.ItemId
|
return x.ItemId
|
||||||
}
|
}
|
||||||
@@ -331,6 +258,13 @@ func (x *AddItem) GetCountry() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *AddItem) GetSaleStatus() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.SaleStatus
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (x *AddItem) GetOutlet() string {
|
func (x *AddItem) GetOutlet() string {
|
||||||
if x != nil && x.Outlet != nil {
|
if x != nil && x.Outlet != nil {
|
||||||
return *x.Outlet
|
return *x.Outlet
|
||||||
@@ -345,16 +279,23 @@ func (x *AddItem) GetStoreId() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *AddItem) GetParentId() uint32 {
|
||||||
|
if x != nil && x.ParentId != nil {
|
||||||
|
return *x.ParentId
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
type RemoveItem struct {
|
type RemoveItem struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *RemoveItem) Reset() {
|
func (x *RemoveItem) Reset() {
|
||||||
*x = RemoveItem{}
|
*x = RemoveItem{}
|
||||||
mi := &file_messages_proto_msgTypes[3]
|
mi := &file_messages_proto_msgTypes[2]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -366,7 +307,7 @@ func (x *RemoveItem) String() string {
|
|||||||
func (*RemoveItem) ProtoMessage() {}
|
func (*RemoveItem) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *RemoveItem) ProtoReflect() protoreflect.Message {
|
func (x *RemoveItem) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_messages_proto_msgTypes[3]
|
mi := &file_messages_proto_msgTypes[2]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -379,10 +320,10 @@ func (x *RemoveItem) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use RemoveItem.ProtoReflect.Descriptor instead.
|
// Deprecated: Use RemoveItem.ProtoReflect.Descriptor instead.
|
||||||
func (*RemoveItem) Descriptor() ([]byte, []int) {
|
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 {
|
if x != nil {
|
||||||
return x.Id
|
return x.Id
|
||||||
}
|
}
|
||||||
@@ -391,7 +332,7 @@ func (x *RemoveItem) GetId() int64 {
|
|||||||
|
|
||||||
type ChangeQuantity struct {
|
type ChangeQuantity struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
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"`
|
Quantity int32 `protobuf:"varint,2,opt,name=quantity,proto3" json:"quantity,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
@@ -399,7 +340,7 @@ type ChangeQuantity struct {
|
|||||||
|
|
||||||
func (x *ChangeQuantity) Reset() {
|
func (x *ChangeQuantity) Reset() {
|
||||||
*x = ChangeQuantity{}
|
*x = ChangeQuantity{}
|
||||||
mi := &file_messages_proto_msgTypes[4]
|
mi := &file_messages_proto_msgTypes[3]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -411,7 +352,7 @@ func (x *ChangeQuantity) String() string {
|
|||||||
func (*ChangeQuantity) ProtoMessage() {}
|
func (*ChangeQuantity) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *ChangeQuantity) ProtoReflect() protoreflect.Message {
|
func (x *ChangeQuantity) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_messages_proto_msgTypes[4]
|
mi := &file_messages_proto_msgTypes[3]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -424,10 +365,10 @@ func (x *ChangeQuantity) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use ChangeQuantity.ProtoReflect.Descriptor instead.
|
// Deprecated: Use ChangeQuantity.ProtoReflect.Descriptor instead.
|
||||||
func (*ChangeQuantity) Descriptor() ([]byte, []int) {
|
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 {
|
if x != nil {
|
||||||
return x.Id
|
return x.Id
|
||||||
}
|
}
|
||||||
@@ -444,7 +385,7 @@ func (x *ChangeQuantity) GetQuantity() int32 {
|
|||||||
type SetDelivery struct {
|
type SetDelivery struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"`
|
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"`
|
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"`
|
Country string `protobuf:"bytes,4,opt,name=country,proto3" json:"country,omitempty"`
|
||||||
Zip string `protobuf:"bytes,5,opt,name=zip,proto3" json:"zip,omitempty"`
|
Zip string `protobuf:"bytes,5,opt,name=zip,proto3" json:"zip,omitempty"`
|
||||||
@@ -456,7 +397,7 @@ type SetDelivery struct {
|
|||||||
|
|
||||||
func (x *SetDelivery) Reset() {
|
func (x *SetDelivery) Reset() {
|
||||||
*x = SetDelivery{}
|
*x = SetDelivery{}
|
||||||
mi := &file_messages_proto_msgTypes[5]
|
mi := &file_messages_proto_msgTypes[4]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -468,7 +409,7 @@ func (x *SetDelivery) String() string {
|
|||||||
func (*SetDelivery) ProtoMessage() {}
|
func (*SetDelivery) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *SetDelivery) ProtoReflect() protoreflect.Message {
|
func (x *SetDelivery) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_messages_proto_msgTypes[5]
|
mi := &file_messages_proto_msgTypes[4]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -481,7 +422,7 @@ func (x *SetDelivery) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use SetDelivery.ProtoReflect.Descriptor instead.
|
// Deprecated: Use SetDelivery.ProtoReflect.Descriptor instead.
|
||||||
func (*SetDelivery) Descriptor() ([]byte, []int) {
|
func (*SetDelivery) Descriptor() ([]byte, []int) {
|
||||||
return file_messages_proto_rawDescGZIP(), []int{5}
|
return file_messages_proto_rawDescGZIP(), []int{4}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *SetDelivery) GetProvider() string {
|
func (x *SetDelivery) GetProvider() string {
|
||||||
@@ -491,7 +432,7 @@ func (x *SetDelivery) GetProvider() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *SetDelivery) GetItems() []int64 {
|
func (x *SetDelivery) GetItems() []uint32 {
|
||||||
if x != nil {
|
if x != nil {
|
||||||
return x.Items
|
return x.Items
|
||||||
}
|
}
|
||||||
@@ -535,7 +476,7 @@ func (x *SetDelivery) GetCity() string {
|
|||||||
|
|
||||||
type SetPickupPoint struct {
|
type SetPickupPoint struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
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"`
|
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"`
|
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"`
|
Address *string `protobuf:"bytes,4,opt,name=address,proto3,oneof" json:"address,omitempty"`
|
||||||
@@ -548,7 +489,7 @@ type SetPickupPoint struct {
|
|||||||
|
|
||||||
func (x *SetPickupPoint) Reset() {
|
func (x *SetPickupPoint) Reset() {
|
||||||
*x = SetPickupPoint{}
|
*x = SetPickupPoint{}
|
||||||
mi := &file_messages_proto_msgTypes[6]
|
mi := &file_messages_proto_msgTypes[5]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -560,7 +501,7 @@ func (x *SetPickupPoint) String() string {
|
|||||||
func (*SetPickupPoint) ProtoMessage() {}
|
func (*SetPickupPoint) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *SetPickupPoint) ProtoReflect() protoreflect.Message {
|
func (x *SetPickupPoint) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_messages_proto_msgTypes[6]
|
mi := &file_messages_proto_msgTypes[5]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -573,10 +514,10 @@ func (x *SetPickupPoint) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use SetPickupPoint.ProtoReflect.Descriptor instead.
|
// Deprecated: Use SetPickupPoint.ProtoReflect.Descriptor instead.
|
||||||
func (*SetPickupPoint) Descriptor() ([]byte, []int) {
|
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 {
|
if x != nil {
|
||||||
return x.DeliveryId
|
return x.DeliveryId
|
||||||
}
|
}
|
||||||
@@ -639,7 +580,7 @@ type PickupPoint struct {
|
|||||||
|
|
||||||
func (x *PickupPoint) Reset() {
|
func (x *PickupPoint) Reset() {
|
||||||
*x = PickupPoint{}
|
*x = PickupPoint{}
|
||||||
mi := &file_messages_proto_msgTypes[7]
|
mi := &file_messages_proto_msgTypes[6]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -651,7 +592,7 @@ func (x *PickupPoint) String() string {
|
|||||||
func (*PickupPoint) ProtoMessage() {}
|
func (*PickupPoint) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *PickupPoint) ProtoReflect() protoreflect.Message {
|
func (x *PickupPoint) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_messages_proto_msgTypes[7]
|
mi := &file_messages_proto_msgTypes[6]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -664,7 +605,7 @@ func (x *PickupPoint) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use PickupPoint.ProtoReflect.Descriptor instead.
|
// Deprecated: Use PickupPoint.ProtoReflect.Descriptor instead.
|
||||||
func (*PickupPoint) Descriptor() ([]byte, []int) {
|
func (*PickupPoint) Descriptor() ([]byte, []int) {
|
||||||
return file_messages_proto_rawDescGZIP(), []int{7}
|
return file_messages_proto_rawDescGZIP(), []int{6}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *PickupPoint) GetId() string {
|
func (x *PickupPoint) GetId() string {
|
||||||
@@ -711,14 +652,14 @@ func (x *PickupPoint) GetCountry() string {
|
|||||||
|
|
||||||
type RemoveDelivery struct {
|
type RemoveDelivery struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *RemoveDelivery) Reset() {
|
func (x *RemoveDelivery) Reset() {
|
||||||
*x = RemoveDelivery{}
|
*x = RemoveDelivery{}
|
||||||
mi := &file_messages_proto_msgTypes[8]
|
mi := &file_messages_proto_msgTypes[7]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -730,7 +671,7 @@ func (x *RemoveDelivery) String() string {
|
|||||||
func (*RemoveDelivery) ProtoMessage() {}
|
func (*RemoveDelivery) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *RemoveDelivery) ProtoReflect() protoreflect.Message {
|
func (x *RemoveDelivery) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_messages_proto_msgTypes[8]
|
mi := &file_messages_proto_msgTypes[7]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -743,10 +684,10 @@ func (x *RemoveDelivery) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use RemoveDelivery.ProtoReflect.Descriptor instead.
|
// Deprecated: Use RemoveDelivery.ProtoReflect.Descriptor instead.
|
||||||
func (*RemoveDelivery) Descriptor() ([]byte, []int) {
|
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 {
|
if x != nil {
|
||||||
return x.Id
|
return x.Id
|
||||||
}
|
}
|
||||||
@@ -767,7 +708,7 @@ type CreateCheckoutOrder struct {
|
|||||||
|
|
||||||
func (x *CreateCheckoutOrder) Reset() {
|
func (x *CreateCheckoutOrder) Reset() {
|
||||||
*x = CreateCheckoutOrder{}
|
*x = CreateCheckoutOrder{}
|
||||||
mi := &file_messages_proto_msgTypes[9]
|
mi := &file_messages_proto_msgTypes[8]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -779,7 +720,7 @@ func (x *CreateCheckoutOrder) String() string {
|
|||||||
func (*CreateCheckoutOrder) ProtoMessage() {}
|
func (*CreateCheckoutOrder) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *CreateCheckoutOrder) ProtoReflect() protoreflect.Message {
|
func (x *CreateCheckoutOrder) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_messages_proto_msgTypes[9]
|
mi := &file_messages_proto_msgTypes[8]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -792,7 +733,7 @@ func (x *CreateCheckoutOrder) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use CreateCheckoutOrder.ProtoReflect.Descriptor instead.
|
// Deprecated: Use CreateCheckoutOrder.ProtoReflect.Descriptor instead.
|
||||||
func (*CreateCheckoutOrder) Descriptor() ([]byte, []int) {
|
func (*CreateCheckoutOrder) Descriptor() ([]byte, []int) {
|
||||||
return file_messages_proto_rawDescGZIP(), []int{9}
|
return file_messages_proto_rawDescGZIP(), []int{8}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *CreateCheckoutOrder) GetTerms() string {
|
func (x *CreateCheckoutOrder) GetTerms() string {
|
||||||
@@ -847,7 +788,7 @@ type OrderCreated struct {
|
|||||||
|
|
||||||
func (x *OrderCreated) Reset() {
|
func (x *OrderCreated) Reset() {
|
||||||
*x = OrderCreated{}
|
*x = OrderCreated{}
|
||||||
mi := &file_messages_proto_msgTypes[10]
|
mi := &file_messages_proto_msgTypes[9]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -859,7 +800,7 @@ func (x *OrderCreated) String() string {
|
|||||||
func (*OrderCreated) ProtoMessage() {}
|
func (*OrderCreated) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *OrderCreated) ProtoReflect() protoreflect.Message {
|
func (x *OrderCreated) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_messages_proto_msgTypes[10]
|
mi := &file_messages_proto_msgTypes[9]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -872,7 +813,7 @@ func (x *OrderCreated) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use OrderCreated.ProtoReflect.Descriptor instead.
|
// Deprecated: Use OrderCreated.ProtoReflect.Descriptor instead.
|
||||||
func (*OrderCreated) Descriptor() ([]byte, []int) {
|
func (*OrderCreated) Descriptor() ([]byte, []int) {
|
||||||
return file_messages_proto_rawDescGZIP(), []int{10}
|
return file_messages_proto_rawDescGZIP(), []int{9}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *OrderCreated) GetOrderId() string {
|
func (x *OrderCreated) GetOrderId() string {
|
||||||
@@ -897,7 +838,7 @@ type Noop struct {
|
|||||||
|
|
||||||
func (x *Noop) Reset() {
|
func (x *Noop) Reset() {
|
||||||
*x = Noop{}
|
*x = Noop{}
|
||||||
mi := &file_messages_proto_msgTypes[11]
|
mi := &file_messages_proto_msgTypes[10]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -909,7 +850,7 @@ func (x *Noop) String() string {
|
|||||||
func (*Noop) ProtoMessage() {}
|
func (*Noop) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *Noop) ProtoReflect() protoreflect.Message {
|
func (x *Noop) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_messages_proto_msgTypes[11]
|
mi := &file_messages_proto_msgTypes[10]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -922,7 +863,7 @@ func (x *Noop) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use Noop.ProtoReflect.Descriptor instead.
|
// Deprecated: Use Noop.ProtoReflect.Descriptor instead.
|
||||||
func (*Noop) Descriptor() ([]byte, []int) {
|
func (*Noop) Descriptor() ([]byte, []int) {
|
||||||
return file_messages_proto_rawDescGZIP(), []int{11}
|
return file_messages_proto_rawDescGZIP(), []int{10}
|
||||||
}
|
}
|
||||||
|
|
||||||
type InitializeCheckout struct {
|
type InitializeCheckout struct {
|
||||||
@@ -936,7 +877,7 @@ type InitializeCheckout struct {
|
|||||||
|
|
||||||
func (x *InitializeCheckout) Reset() {
|
func (x *InitializeCheckout) Reset() {
|
||||||
*x = InitializeCheckout{}
|
*x = InitializeCheckout{}
|
||||||
mi := &file_messages_proto_msgTypes[12]
|
mi := &file_messages_proto_msgTypes[11]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -948,7 +889,7 @@ func (x *InitializeCheckout) String() string {
|
|||||||
func (*InitializeCheckout) ProtoMessage() {}
|
func (*InitializeCheckout) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *InitializeCheckout) ProtoReflect() protoreflect.Message {
|
func (x *InitializeCheckout) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_messages_proto_msgTypes[12]
|
mi := &file_messages_proto_msgTypes[11]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -961,7 +902,7 @@ func (x *InitializeCheckout) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use InitializeCheckout.ProtoReflect.Descriptor instead.
|
// Deprecated: Use InitializeCheckout.ProtoReflect.Descriptor instead.
|
||||||
func (*InitializeCheckout) Descriptor() ([]byte, []int) {
|
func (*InitializeCheckout) Descriptor() ([]byte, []int) {
|
||||||
return file_messages_proto_rawDescGZIP(), []int{12}
|
return file_messages_proto_rawDescGZIP(), []int{11}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *InitializeCheckout) GetOrderId() string {
|
func (x *InitializeCheckout) GetOrderId() string {
|
||||||
@@ -985,23 +926,254 @@ func (x *InitializeCheckout) GetPaymentInProgress() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 []string `protobuf:"bytes,3,rep,name=voucherRules,proto3" json:"voucherRules,omitempty"`
|
||||||
|
Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AddVoucher) Reset() {
|
||||||
|
*x = AddVoucher{}
|
||||||
|
mi := &file_messages_proto_msgTypes[12]
|
||||||
|
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[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 AddVoucher.ProtoReflect.Descriptor instead.
|
||||||
|
func (*AddVoucher) Descriptor() ([]byte, []int) {
|
||||||
|
return file_messages_proto_rawDescGZIP(), []int{12}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() []string {
|
||||||
|
if x != nil {
|
||||||
|
return x.VoucherRules
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AddVoucher) GetDescription() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Description
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
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[13]
|
||||||
|
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[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 RemoveVoucher.ProtoReflect.Descriptor instead.
|
||||||
|
func (*RemoveVoucher) Descriptor() ([]byte, []int) {
|
||||||
|
return file_messages_proto_rawDescGZIP(), []int{13}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *RemoveVoucher) GetId() uint32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Id
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpsertSubscriptionDetails struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Id *string `protobuf:"bytes,1,opt,name=id,proto3,oneof" json:"id,omitempty"`
|
||||||
|
OfferingCode string `protobuf:"bytes,2,opt,name=offeringCode,proto3" json:"offeringCode,omitempty"`
|
||||||
|
SigningType string `protobuf:"bytes,3,opt,name=signingType,proto3" json:"signingType,omitempty"`
|
||||||
|
Data *anypb.Any `protobuf:"bytes,4,opt,name=data,proto3" json:"data,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *UpsertSubscriptionDetails) Reset() {
|
||||||
|
*x = UpsertSubscriptionDetails{}
|
||||||
|
mi := &file_messages_proto_msgTypes[14]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *UpsertSubscriptionDetails) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*UpsertSubscriptionDetails) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *UpsertSubscriptionDetails) 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 UpsertSubscriptionDetails.ProtoReflect.Descriptor instead.
|
||||||
|
func (*UpsertSubscriptionDetails) Descriptor() ([]byte, []int) {
|
||||||
|
return file_messages_proto_rawDescGZIP(), []int{14}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *UpsertSubscriptionDetails) GetId() string {
|
||||||
|
if x != nil && x.Id != nil {
|
||||||
|
return *x.Id
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *UpsertSubscriptionDetails) GetOfferingCode() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.OfferingCode
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *UpsertSubscriptionDetails) GetSigningType() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.SigningType
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *UpsertSubscriptionDetails) GetData() *anypb.Any {
|
||||||
|
if x != nil {
|
||||||
|
return x.Data
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type PreConditionFailed struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Operation string `protobuf:"bytes,1,opt,name=operation,proto3" json:"operation,omitempty"`
|
||||||
|
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
|
||||||
|
Input *anypb.Any `protobuf:"bytes,3,opt,name=input,proto3" json:"input,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PreConditionFailed) Reset() {
|
||||||
|
*x = PreConditionFailed{}
|
||||||
|
mi := &file_messages_proto_msgTypes[15]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PreConditionFailed) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*PreConditionFailed) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *PreConditionFailed) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_messages_proto_msgTypes[15]
|
||||||
|
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 PreConditionFailed.ProtoReflect.Descriptor instead.
|
||||||
|
func (*PreConditionFailed) Descriptor() ([]byte, []int) {
|
||||||
|
return file_messages_proto_rawDescGZIP(), []int{15}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PreConditionFailed) GetOperation() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Operation
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PreConditionFailed) GetError() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Error
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PreConditionFailed) GetInput() *anypb.Any {
|
||||||
|
if x != nil {
|
||||||
|
return x.Input
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var File_messages_proto protoreflect.FileDescriptor
|
var File_messages_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
const file_messages_proto_rawDesc = "" +
|
const file_messages_proto_rawDesc = "" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"\x0emessages.proto\x12\bmessages\"\x7f\n" +
|
"\x0emessages.proto\x12\bmessages\x1a\x19google/protobuf/any.proto\"\x12\n" +
|
||||||
"\n" +
|
"\x10ClearCartRequest\"\xb7\x05\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" +
|
"\aAddItem\x12\x17\n" +
|
||||||
"\aitem_id\x18\x01 \x01(\x03R\x06itemId\x12\x1a\n" +
|
"\aitem_id\x18\x01 \x01(\rR\x06itemId\x12\x1a\n" +
|
||||||
"\bquantity\x18\x02 \x01(\x05R\bquantity\x12\x14\n" +
|
"\bquantity\x18\x02 \x01(\x05R\bquantity\x12\x14\n" +
|
||||||
"\x05price\x18\x03 \x01(\x03R\x05price\x12\x1a\n" +
|
"\x05price\x18\x03 \x01(\x03R\x05price\x12\x1a\n" +
|
||||||
"\borgPrice\x18\t \x01(\x03R\borgPrice\x12\x10\n" +
|
"\borgPrice\x18\t \x01(\x03R\borgPrice\x12\x10\n" +
|
||||||
@@ -1025,21 +1197,26 @@ const file_messages_proto_rawDesc = "" +
|
|||||||
"\n" +
|
"\n" +
|
||||||
"sellerName\x18\x14 \x01(\tR\n" +
|
"sellerName\x18\x14 \x01(\tR\n" +
|
||||||
"sellerName\x12\x18\n" +
|
"sellerName\x12\x18\n" +
|
||||||
"\acountry\x18\x15 \x01(\tR\acountry\x12\x1b\n" +
|
"\acountry\x18\x15 \x01(\tR\acountry\x12\x1e\n" +
|
||||||
|
"\n" +
|
||||||
|
"saleStatus\x18\x18 \x01(\tR\n" +
|
||||||
|
"saleStatus\x12\x1b\n" +
|
||||||
"\x06outlet\x18\f \x01(\tH\x00R\x06outlet\x88\x01\x01\x12\x1d\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" +
|
"\astoreId\x18\x16 \x01(\tH\x01R\astoreId\x88\x01\x01\x12\x1f\n" +
|
||||||
|
"\bparentId\x18\x17 \x01(\rH\x02R\bparentId\x88\x01\x01B\t\n" +
|
||||||
"\a_outletB\n" +
|
"\a_outletB\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"\b_storeId\"\x1c\n" +
|
"\b_storeIdB\v\n" +
|
||||||
|
"\t_parentId\"\x1c\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"RemoveItem\x12\x0e\n" +
|
"RemoveItem\x12\x0e\n" +
|
||||||
"\x02Id\x18\x01 \x01(\x03R\x02Id\"<\n" +
|
"\x02Id\x18\x01 \x01(\rR\x02Id\"<\n" +
|
||||||
"\x0eChangeQuantity\x12\x0e\n" +
|
"\x0eChangeQuantity\x12\x0e\n" +
|
||||||
"\x02id\x18\x01 \x01(\x03R\x02id\x12\x1a\n" +
|
"\x02Id\x18\x01 \x01(\rR\x02Id\x12\x1a\n" +
|
||||||
"\bquantity\x18\x02 \x01(\x05R\bquantity\"\x86\x02\n" +
|
"\bquantity\x18\x02 \x01(\x05R\bquantity\"\x86\x02\n" +
|
||||||
"\vSetDelivery\x12\x1a\n" +
|
"\vSetDelivery\x12\x1a\n" +
|
||||||
"\bprovider\x18\x01 \x01(\tR\bprovider\x12\x14\n" +
|
"\bprovider\x18\x01 \x01(\tR\bprovider\x12\x14\n" +
|
||||||
"\x05items\x18\x02 \x03(\x03R\x05items\x12<\n" +
|
"\x05items\x18\x02 \x03(\rR\x05items\x12<\n" +
|
||||||
"\vpickupPoint\x18\x03 \x01(\v2\x15.messages.PickupPointH\x00R\vpickupPoint\x88\x01\x01\x12\x18\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" +
|
"\acountry\x18\x04 \x01(\tR\acountry\x12\x10\n" +
|
||||||
"\x03zip\x18\x05 \x01(\tR\x03zip\x12\x1d\n" +
|
"\x03zip\x18\x05 \x01(\tR\x03zip\x12\x1d\n" +
|
||||||
@@ -1051,7 +1228,7 @@ const file_messages_proto_rawDesc = "" +
|
|||||||
"\x05_city\"\xf9\x01\n" +
|
"\x05_city\"\xf9\x01\n" +
|
||||||
"\x0eSetPickupPoint\x12\x1e\n" +
|
"\x0eSetPickupPoint\x12\x1e\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"deliveryId\x18\x01 \x01(\x03R\n" +
|
"deliveryId\x18\x01 \x01(\rR\n" +
|
||||||
"deliveryId\x12\x0e\n" +
|
"deliveryId\x12\x0e\n" +
|
||||||
"\x02id\x18\x02 \x01(\tR\x02id\x12\x17\n" +
|
"\x02id\x18\x02 \x01(\tR\x02id\x12\x17\n" +
|
||||||
"\x04name\x18\x03 \x01(\tH\x00R\x04name\x88\x01\x01\x12\x1d\n" +
|
"\x04name\x18\x03 \x01(\tH\x00R\x04name\x88\x01\x01\x12\x1d\n" +
|
||||||
@@ -1081,7 +1258,7 @@ const file_messages_proto_rawDesc = "" +
|
|||||||
"\n" +
|
"\n" +
|
||||||
"\b_country\" \n" +
|
"\b_country\" \n" +
|
||||||
"\x0eRemoveDelivery\x12\x0e\n" +
|
"\x0eRemoveDelivery\x12\x0e\n" +
|
||||||
"\x02id\x18\x01 \x01(\x03R\x02id\"\xb9\x01\n" +
|
"\x02id\x18\x01 \x01(\rR\x02id\"\xb9\x01\n" +
|
||||||
"\x13CreateCheckoutOrder\x12\x14\n" +
|
"\x13CreateCheckoutOrder\x12\x14\n" +
|
||||||
"\x05terms\x18\x01 \x01(\tR\x05terms\x12\x1a\n" +
|
"\x05terms\x18\x01 \x01(\tR\x05terms\x12\x1a\n" +
|
||||||
"\bcheckout\x18\x02 \x01(\tR\bcheckout\x12\"\n" +
|
"\bcheckout\x18\x02 \x01(\tR\bcheckout\x12\"\n" +
|
||||||
@@ -1098,7 +1275,25 @@ const file_messages_proto_rawDesc = "" +
|
|||||||
"\x12InitializeCheckout\x12\x18\n" +
|
"\x12InitializeCheckout\x12\x18\n" +
|
||||||
"\aorderId\x18\x01 \x01(\tR\aorderId\x12\x16\n" +
|
"\aorderId\x18\x01 \x01(\tR\aorderId\x12\x16\n" +
|
||||||
"\x06status\x18\x02 \x01(\tR\x06status\x12,\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"
|
"\x11paymentInProgress\x18\x03 \x01(\bR\x11paymentInProgress\"|\n" +
|
||||||
|
"\n" +
|
||||||
|
"AddVoucher\x12\x12\n" +
|
||||||
|
"\x04code\x18\x01 \x01(\tR\x04code\x12\x14\n" +
|
||||||
|
"\x05value\x18\x02 \x01(\x03R\x05value\x12\"\n" +
|
||||||
|
"\fvoucherRules\x18\x03 \x03(\tR\fvoucherRules\x12 \n" +
|
||||||
|
"\vdescription\x18\x04 \x01(\tR\vdescription\"\x1f\n" +
|
||||||
|
"\rRemoveVoucher\x12\x0e\n" +
|
||||||
|
"\x02id\x18\x01 \x01(\rR\x02id\"\xa7\x01\n" +
|
||||||
|
"\x19UpsertSubscriptionDetails\x12\x13\n" +
|
||||||
|
"\x02id\x18\x01 \x01(\tH\x00R\x02id\x88\x01\x01\x12\"\n" +
|
||||||
|
"\fofferingCode\x18\x02 \x01(\tR\fofferingCode\x12 \n" +
|
||||||
|
"\vsigningType\x18\x03 \x01(\tR\vsigningType\x12(\n" +
|
||||||
|
"\x04data\x18\x04 \x01(\v2\x14.google.protobuf.AnyR\x04dataB\x05\n" +
|
||||||
|
"\x03_id\"t\n" +
|
||||||
|
"\x12PreConditionFailed\x12\x1c\n" +
|
||||||
|
"\toperation\x18\x01 \x01(\tR\toperation\x12\x14\n" +
|
||||||
|
"\x05error\x18\x02 \x01(\tR\x05error\x12*\n" +
|
||||||
|
"\x05input\x18\x03 \x01(\v2\x14.google.protobuf.AnyR\x05inputB.Z,git.tornberg.me/go-cart-actor/proto;messagesb\x06proto3"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_messages_proto_rawDescOnce sync.Once
|
file_messages_proto_rawDescOnce sync.Once
|
||||||
@@ -1112,30 +1307,35 @@ func file_messages_proto_rawDescGZIP() []byte {
|
|||||||
return file_messages_proto_rawDescData
|
return file_messages_proto_rawDescData
|
||||||
}
|
}
|
||||||
|
|
||||||
var file_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
|
var file_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 16)
|
||||||
var file_messages_proto_goTypes = []any{
|
var file_messages_proto_goTypes = []any{
|
||||||
(*AddRequest)(nil), // 0: messages.AddRequest
|
(*ClearCartRequest)(nil), // 0: messages.ClearCartRequest
|
||||||
(*SetCartRequest)(nil), // 1: messages.SetCartRequest
|
(*AddItem)(nil), // 1: messages.AddItem
|
||||||
(*AddItem)(nil), // 2: messages.AddItem
|
(*RemoveItem)(nil), // 2: messages.RemoveItem
|
||||||
(*RemoveItem)(nil), // 3: messages.RemoveItem
|
(*ChangeQuantity)(nil), // 3: messages.ChangeQuantity
|
||||||
(*ChangeQuantity)(nil), // 4: messages.ChangeQuantity
|
(*SetDelivery)(nil), // 4: messages.SetDelivery
|
||||||
(*SetDelivery)(nil), // 5: messages.SetDelivery
|
(*SetPickupPoint)(nil), // 5: messages.SetPickupPoint
|
||||||
(*SetPickupPoint)(nil), // 6: messages.SetPickupPoint
|
(*PickupPoint)(nil), // 6: messages.PickupPoint
|
||||||
(*PickupPoint)(nil), // 7: messages.PickupPoint
|
(*RemoveDelivery)(nil), // 7: messages.RemoveDelivery
|
||||||
(*RemoveDelivery)(nil), // 8: messages.RemoveDelivery
|
(*CreateCheckoutOrder)(nil), // 8: messages.CreateCheckoutOrder
|
||||||
(*CreateCheckoutOrder)(nil), // 9: messages.CreateCheckoutOrder
|
(*OrderCreated)(nil), // 9: messages.OrderCreated
|
||||||
(*OrderCreated)(nil), // 10: messages.OrderCreated
|
(*Noop)(nil), // 10: messages.Noop
|
||||||
(*Noop)(nil), // 11: messages.Noop
|
(*InitializeCheckout)(nil), // 11: messages.InitializeCheckout
|
||||||
(*InitializeCheckout)(nil), // 12: messages.InitializeCheckout
|
(*AddVoucher)(nil), // 12: messages.AddVoucher
|
||||||
|
(*RemoveVoucher)(nil), // 13: messages.RemoveVoucher
|
||||||
|
(*UpsertSubscriptionDetails)(nil), // 14: messages.UpsertSubscriptionDetails
|
||||||
|
(*PreConditionFailed)(nil), // 15: messages.PreConditionFailed
|
||||||
|
(*anypb.Any)(nil), // 16: google.protobuf.Any
|
||||||
}
|
}
|
||||||
var file_messages_proto_depIdxs = []int32{
|
var file_messages_proto_depIdxs = []int32{
|
||||||
0, // 0: messages.SetCartRequest.items:type_name -> messages.AddRequest
|
6, // 0: messages.SetDelivery.pickupPoint:type_name -> messages.PickupPoint
|
||||||
7, // 1: messages.SetDelivery.pickupPoint:type_name -> messages.PickupPoint
|
16, // 1: messages.UpsertSubscriptionDetails.data:type_name -> google.protobuf.Any
|
||||||
2, // [2:2] is the sub-list for method output_type
|
16, // 2: messages.PreConditionFailed.input:type_name -> google.protobuf.Any
|
||||||
2, // [2:2] is the sub-list for method input_type
|
3, // [3:3] is the sub-list for method output_type
|
||||||
2, // [2:2] is the sub-list for extension type_name
|
3, // [3:3] is the sub-list for method input_type
|
||||||
2, // [2:2] is the sub-list for extension extendee
|
3, // [3:3] is the sub-list for extension type_name
|
||||||
0, // [0:2] is the sub-list for field type_name
|
3, // [3:3] is the sub-list for extension extendee
|
||||||
|
0, // [0:3] is the sub-list for field type_name
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() { file_messages_proto_init() }
|
func init() { file_messages_proto_init() }
|
||||||
@@ -1143,18 +1343,18 @@ func file_messages_proto_init() {
|
|||||||
if File_messages_proto != nil {
|
if File_messages_proto != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
file_messages_proto_msgTypes[0].OneofWrappers = []any{}
|
file_messages_proto_msgTypes[1].OneofWrappers = []any{}
|
||||||
file_messages_proto_msgTypes[2].OneofWrappers = []any{}
|
file_messages_proto_msgTypes[4].OneofWrappers = []any{}
|
||||||
file_messages_proto_msgTypes[5].OneofWrappers = []any{}
|
file_messages_proto_msgTypes[5].OneofWrappers = []any{}
|
||||||
file_messages_proto_msgTypes[6].OneofWrappers = []any{}
|
file_messages_proto_msgTypes[6].OneofWrappers = []any{}
|
||||||
file_messages_proto_msgTypes[7].OneofWrappers = []any{}
|
file_messages_proto_msgTypes[14].OneofWrappers = []any{}
|
||||||
type x struct{}
|
type x struct{}
|
||||||
out := protoimpl.TypeBuilder{
|
out := protoimpl.TypeBuilder{
|
||||||
File: protoimpl.DescBuilder{
|
File: protoimpl.DescBuilder{
|
||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_messages_proto_rawDesc), len(file_messages_proto_rawDesc)),
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_messages_proto_rawDesc), len(file_messages_proto_rawDesc)),
|
||||||
NumEnums: 0,
|
NumEnums: 0,
|
||||||
NumMessages: 13,
|
NumMessages: 16,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 0,
|
NumServices: 0,
|
||||||
},
|
},
|
||||||
724
pkg/promotions/eval.go
Normal file
724
pkg/promotions/eval.go
Normal file
@@ -0,0 +1,724 @@
|
|||||||
|
package promotions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errInvalidTimeFormat = errors.New("invalid time format")
|
||||||
|
|
||||||
|
// Tracer allows callers to receive structured debug information during evaluation.
|
||||||
|
type Tracer interface {
|
||||||
|
Trace(event string, data map[string]any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoopTracer is used when no tracer provided.
|
||||||
|
type NoopTracer struct{}
|
||||||
|
|
||||||
|
func (NoopTracer) Trace(string, map[string]any) {}
|
||||||
|
|
||||||
|
// PromotionItem is a lightweight abstraction derived from cart.CartItem
|
||||||
|
// for the purpose of promotion condition evaluation.
|
||||||
|
type PromotionItem struct {
|
||||||
|
SKU string
|
||||||
|
Quantity int
|
||||||
|
Category string
|
||||||
|
PriceIncVat int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromotionEvalContext carries all dynamic data required to evaluate promotion
|
||||||
|
// conditions. It can be constructed from a cart.CartGrain plus optional
|
||||||
|
// customer/order metadata.
|
||||||
|
type PromotionEvalContext struct {
|
||||||
|
CartTotalIncVat int64
|
||||||
|
TotalItemQuantity int
|
||||||
|
Items []PromotionItem
|
||||||
|
CustomerSegment string
|
||||||
|
CustomerLifetimeValue float64
|
||||||
|
OrderCount int
|
||||||
|
Now time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContextOption allows customization of fields when building a PromotionEvalContext.
|
||||||
|
type ContextOption func(*PromotionEvalContext)
|
||||||
|
|
||||||
|
// WithCustomerSegment sets the customer segment.
|
||||||
|
func WithCustomerSegment(seg string) ContextOption {
|
||||||
|
return func(c *PromotionEvalContext) { c.CustomerSegment = seg }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithCustomerLifetimeValue sets lifetime value metric.
|
||||||
|
func WithCustomerLifetimeValue(v float64) ContextOption {
|
||||||
|
return func(c *PromotionEvalContext) { c.CustomerLifetimeValue = v }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithOrderCount sets historical order count.
|
||||||
|
func WithOrderCount(n int) ContextOption {
|
||||||
|
return func(c *PromotionEvalContext) { c.OrderCount = n }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithNow overrides the timestamp used for date/time related conditions.
|
||||||
|
func WithNow(t time.Time) ContextOption {
|
||||||
|
return func(c *PromotionEvalContext) { c.Now = t }
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewContextFromCart builds a PromotionEvalContext from a CartGrain and optional metadata.
|
||||||
|
func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEvalContext {
|
||||||
|
ctx := &PromotionEvalContext{
|
||||||
|
Items: make([]PromotionItem, 0, len(g.Items)),
|
||||||
|
CartTotalIncVat: 0,
|
||||||
|
TotalItemQuantity: 0,
|
||||||
|
Now: time.Now(),
|
||||||
|
}
|
||||||
|
if g.TotalPrice != nil {
|
||||||
|
ctx.CartTotalIncVat = g.TotalPrice.IncVat
|
||||||
|
}
|
||||||
|
for _, it := range g.Items {
|
||||||
|
category := ""
|
||||||
|
if it.Meta != nil {
|
||||||
|
category = it.Meta.Category
|
||||||
|
}
|
||||||
|
ctx.Items = append(ctx.Items, PromotionItem{
|
||||||
|
SKU: it.Sku,
|
||||||
|
Quantity: it.Quantity,
|
||||||
|
Category: strings.ToLower(category),
|
||||||
|
PriceIncVat: it.Price.IncVat,
|
||||||
|
})
|
||||||
|
ctx.TotalItemQuantity += it.Quantity
|
||||||
|
}
|
||||||
|
for _, o := range opts {
|
||||||
|
o(ctx)
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromotionService evaluates PromotionRules against a PromotionEvalContext.
|
||||||
|
type PromotionService struct {
|
||||||
|
tracer Tracer
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPromotionService constructs a PromotionService with an optional tracer.
|
||||||
|
func NewPromotionService(t Tracer) *PromotionService {
|
||||||
|
if t == nil {
|
||||||
|
t = NoopTracer{}
|
||||||
|
}
|
||||||
|
return &PromotionService{tracer: t}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvaluationResult holds the outcome of evaluating a single rule.
|
||||||
|
type EvaluationResult struct {
|
||||||
|
Rule PromotionRule
|
||||||
|
Applicable bool
|
||||||
|
FailedReason string
|
||||||
|
MatchedActions []Action
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvaluateRule determines if a single PromotionRule applies to the provided context.
|
||||||
|
// Returns an EvaluationResult with applicability and actions (if applicable).
|
||||||
|
func (s *PromotionService) EvaluateRule(rule PromotionRule, ctx *PromotionEvalContext) EvaluationResult {
|
||||||
|
s.tracer.Trace("rule_start", map[string]any{
|
||||||
|
"rule_id": rule.ID,
|
||||||
|
"priority": rule.Priority,
|
||||||
|
"status": rule.Status,
|
||||||
|
"startDate": rule.StartDate,
|
||||||
|
"endDate": rule.EndDate,
|
||||||
|
})
|
||||||
|
// Status gate
|
||||||
|
now := ctx.Now
|
||||||
|
switch rule.Status {
|
||||||
|
case StatusInactive, StatusExpired:
|
||||||
|
s.tracer.Trace("rule_skip_status", map[string]any{"rule_id": rule.ID, "status": rule.Status})
|
||||||
|
return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "status"}
|
||||||
|
case StatusScheduled:
|
||||||
|
// Allow scheduled only if current time >= startDate (and within endDate if present)
|
||||||
|
}
|
||||||
|
// Date window checks (if parseable)
|
||||||
|
if rule.StartDate != "" {
|
||||||
|
if tStart, err := parseDate(rule.StartDate); err == nil {
|
||||||
|
if now.Before(tStart) {
|
||||||
|
s.tracer.Trace("rule_skip_before_start", map[string]any{"rule_id": rule.ID})
|
||||||
|
return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "before_start"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rule.EndDate != nil && *rule.EndDate != "" {
|
||||||
|
if tEnd, err := parseDate(*rule.EndDate); err == nil {
|
||||||
|
if now.After(tEnd.Add(23*time.Hour + 59*time.Minute + 59*time.Second)) { // inclusive day
|
||||||
|
s.tracer.Trace("rule_skip_after_end", map[string]any{"rule_id": rule.ID})
|
||||||
|
return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "after_end"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Usage limit
|
||||||
|
if rule.UsageLimit != nil && rule.UsageCount >= *rule.UsageLimit {
|
||||||
|
s.tracer.Trace("rule_skip_usage_limit", map[string]any{"rule_id": rule.ID, "usageCount": rule.UsageCount, "limit": *rule.UsageLimit})
|
||||||
|
return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "usage_limit_exhausted"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !evaluateConditionsTrace(rule.Conditions, ctx, s.tracer, rule.ID) {
|
||||||
|
s.tracer.Trace("rule_conditions_failed", map[string]any{"rule_id": rule.ID})
|
||||||
|
return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "conditions"}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.tracer.Trace("rule_applicable", map[string]any{"rule_id": rule.ID})
|
||||||
|
return EvaluationResult{
|
||||||
|
Rule: rule,
|
||||||
|
Applicable: true,
|
||||||
|
MatchedActions: rule.Actions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvaluateAll returns all applicable promotion actions given a list of rules and context.
|
||||||
|
// Rules marked Applicable are sorted by Priority (ascending: lower number = higher precedence).
|
||||||
|
func (s *PromotionService) EvaluateAll(rules []PromotionRule, ctx *PromotionEvalContext) ([]EvaluationResult, []Action) {
|
||||||
|
results := make([]EvaluationResult, 0, len(rules))
|
||||||
|
for _, r := range rules {
|
||||||
|
res := s.EvaluateRule(r, ctx)
|
||||||
|
results = append(results, res)
|
||||||
|
}
|
||||||
|
actions := make([]Action, 0)
|
||||||
|
for _, res := range orderedByPriority(results) {
|
||||||
|
if res.Applicable {
|
||||||
|
s.tracer.Trace("actions_add", map[string]any{
|
||||||
|
"rule_id": res.Rule.ID,
|
||||||
|
"count": len(res.MatchedActions),
|
||||||
|
"priority": res.Rule.Priority,
|
||||||
|
})
|
||||||
|
actions = append(actions, res.MatchedActions...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.tracer.Trace("evaluation_complete", map[string]any{
|
||||||
|
"rules_total": len(rules),
|
||||||
|
"actions": len(actions),
|
||||||
|
})
|
||||||
|
return results, actions
|
||||||
|
}
|
||||||
|
|
||||||
|
// orderedByPriority returns results sorted by PromotionRule.Priority ascending (stable).
|
||||||
|
func orderedByPriority(in []EvaluationResult) []EvaluationResult {
|
||||||
|
out := make([]EvaluationResult, len(in))
|
||||||
|
copy(out, in)
|
||||||
|
for i := 1; i < len(out); i++ {
|
||||||
|
j := i
|
||||||
|
for j > 0 && out[j-1].Rule.Priority > out[j].Rule.Priority {
|
||||||
|
out[j-1], out[j] = out[j], out[j-1]
|
||||||
|
j--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------
|
||||||
|
// Condition evaluation (with tracing)
|
||||||
|
// ----------------------------
|
||||||
|
|
||||||
|
func evaluateConditionsTrace(conds Conditions, ctx *PromotionEvalContext, t Tracer, ruleID string) bool {
|
||||||
|
for idx, c := range conds {
|
||||||
|
if !evaluateConditionTrace(c, ctx, t, ruleID, fmt.Sprintf("root[%d]", idx)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluateConditionTrace(c Condition, ctx *PromotionEvalContext, t Tracer, ruleID, path string) bool {
|
||||||
|
if grp, ok := c.(ConditionGroup); ok {
|
||||||
|
return evaluateGroupTrace(grp, ctx, t, ruleID, path)
|
||||||
|
}
|
||||||
|
bc, ok := c.(BaseCondition)
|
||||||
|
if !ok {
|
||||||
|
t.Trace("cond_invalid_type", map[string]any{"rule_id": ruleID, "path": path})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
res := evaluateBaseCondition(bc, ctx)
|
||||||
|
t.Trace("cond_base", map[string]any{
|
||||||
|
"rule_id": ruleID,
|
||||||
|
"path": path,
|
||||||
|
"type": bc.Type,
|
||||||
|
"op": bc.Operator,
|
||||||
|
"value": bc.Value.String(),
|
||||||
|
"result": res,
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluateGroupTrace(g ConditionGroup, ctx *PromotionEvalContext, t Tracer, ruleID, path string) bool {
|
||||||
|
op := normalizeLogicOperator(string(g.Operator))
|
||||||
|
if len(g.Conditions) == 0 {
|
||||||
|
t.Trace("cond_group_empty", map[string]any{"rule_id": ruleID, "path": path})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if op == string(LogicAND) {
|
||||||
|
for i, child := range g.Conditions {
|
||||||
|
if !evaluateConditionTrace(child, ctx, t, ruleID, path+fmt.Sprintf(".AND[%d]", i)) {
|
||||||
|
t.Trace("cond_group_and_fail", map[string]any{"rule_id": ruleID, "path": path})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Trace("cond_group_and_pass", map[string]any{"rule_id": ruleID, "path": path})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for i, child := range g.Conditions {
|
||||||
|
if evaluateConditionTrace(child, ctx, t, ruleID, path+fmt.Sprintf(".OR[%d]", i)) {
|
||||||
|
t.Trace("cond_group_or_pass", map[string]any{"rule_id": ruleID, "path": path})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Trace("cond_group_or_fail", map[string]any{"rule_id": ruleID, "path": path})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback non-traced evaluation (used internally by traced path)
|
||||||
|
func evaluateConditions(conds Conditions, ctx *PromotionEvalContext) bool {
|
||||||
|
for _, c := range conds {
|
||||||
|
if !evaluateCondition(c, ctx) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluateCondition(c Condition, ctx *PromotionEvalContext) bool {
|
||||||
|
if grp, ok := c.(ConditionGroup); ok {
|
||||||
|
return evaluateGroup(grp, ctx)
|
||||||
|
}
|
||||||
|
bc, ok := c.(BaseCondition)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return evaluateBaseCondition(bc, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluateGroup(g ConditionGroup, ctx *PromotionEvalContext) bool {
|
||||||
|
op := normalizeLogicOperator(string(g.Operator))
|
||||||
|
if len(g.Conditions) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if op == string(LogicAND) {
|
||||||
|
for _, child := range g.Conditions {
|
||||||
|
if !evaluateCondition(child, ctx) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, child := range g.Conditions {
|
||||||
|
if evaluateCondition(child, ctx) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluateBaseCondition(b BaseCondition, ctx *PromotionEvalContext) bool {
|
||||||
|
switch b.Type {
|
||||||
|
case CondCartTotal:
|
||||||
|
return evalNumberCompare(float64(ctx.CartTotalIncVat), b)
|
||||||
|
case CondItemQuantity:
|
||||||
|
return evalNumberCompare(float64(ctx.TotalItemQuantity), b)
|
||||||
|
case CondCustomerSegment:
|
||||||
|
return evalStringCompare(ctx.CustomerSegment, b)
|
||||||
|
case CondProductCategory:
|
||||||
|
return evalAnyItemMatch(func(it PromotionItem) bool {
|
||||||
|
return evalValueAgainstTarget(strings.ToLower(it.Category), b)
|
||||||
|
}, b, ctx)
|
||||||
|
case CondProductID:
|
||||||
|
return evalAnyItemMatch(func(it PromotionItem) bool {
|
||||||
|
return evalValueAgainstTarget(strings.ToLower(it.SKU), b)
|
||||||
|
}, b, ctx)
|
||||||
|
case CondCustomerLifetime:
|
||||||
|
return evalNumberCompare(ctx.CustomerLifetimeValue, b)
|
||||||
|
case CondOrderCount:
|
||||||
|
return evalNumberCompare(float64(ctx.OrderCount), b)
|
||||||
|
case CondDateRange:
|
||||||
|
return evalDateRange(ctx.Now, b)
|
||||||
|
case CondDayOfWeek:
|
||||||
|
return evalDayOfWeek(ctx.Now, b)
|
||||||
|
case CondTimeOfDay:
|
||||||
|
return evalTimeOfDay(ctx.Now, b)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func evalAnyItemMatch(pred func(PromotionItem) bool, b BaseCondition, ctx *PromotionEvalContext) bool {
|
||||||
|
if slices.ContainsFunc(ctx.Items, pred) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch normalizeOperator(string(b.Operator)) {
|
||||||
|
case string(OpNotIn), string(OpNotContains):
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------
|
||||||
|
// Primitive evaluators
|
||||||
|
// ----------------------------
|
||||||
|
|
||||||
|
func evalNumberCompare(target float64, b BaseCondition) bool {
|
||||||
|
op := normalizeOperator(string(b.Operator))
|
||||||
|
cond, ok := b.Value.AsFloat64()
|
||||||
|
if !ok {
|
||||||
|
if list, okL := b.Value.AsFloat64Slice(); okL && (op == string(OpIn) || op == string(OpNotIn)) {
|
||||||
|
found := sliceFloatContains(list, target)
|
||||||
|
if op == string(OpIn) {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
return !found
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch op {
|
||||||
|
case string(OpEquals):
|
||||||
|
return target == cond
|
||||||
|
case string(OpNotEquals):
|
||||||
|
return target != cond
|
||||||
|
case string(OpGreaterThan):
|
||||||
|
return target > cond
|
||||||
|
case string(OpLessThan):
|
||||||
|
return target < cond
|
||||||
|
case string(OpGreaterOrEqual):
|
||||||
|
return target >= cond
|
||||||
|
case string(OpLessOrEqual):
|
||||||
|
return target <= cond
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func evalStringCompare(target string, b BaseCondition) bool {
|
||||||
|
op := normalizeOperator(string(b.Operator))
|
||||||
|
targetLower := strings.ToLower(target)
|
||||||
|
|
||||||
|
if s, ok := b.Value.AsString(); ok {
|
||||||
|
condLower := strings.ToLower(s)
|
||||||
|
switch op {
|
||||||
|
case string(OpEquals):
|
||||||
|
return targetLower == condLower
|
||||||
|
case string(OpNotEquals):
|
||||||
|
return targetLower != condLower
|
||||||
|
case string(OpContains):
|
||||||
|
return strings.Contains(targetLower, condLower)
|
||||||
|
case string(OpNotContains):
|
||||||
|
return !strings.Contains(targetLower, condLower)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if arr, ok := b.Value.AsStringSlice(); ok {
|
||||||
|
switch op {
|
||||||
|
case string(OpIn):
|
||||||
|
for _, v := range arr {
|
||||||
|
if targetLower == strings.ToLower(v) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
case string(OpNotIn):
|
||||||
|
for _, v := range arr {
|
||||||
|
if targetLower == strings.ToLower(v) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case string(OpContains):
|
||||||
|
for _, v := range arr {
|
||||||
|
if strings.Contains(targetLower, strings.ToLower(v)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
case string(OpNotContains):
|
||||||
|
for _, v := range arr {
|
||||||
|
if strings.Contains(targetLower, strings.ToLower(v)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func evalValueAgainstTarget(target string, b BaseCondition) bool {
|
||||||
|
op := normalizeOperator(string(b.Operator))
|
||||||
|
tLower := strings.ToLower(target)
|
||||||
|
|
||||||
|
if s, ok := b.Value.AsString(); ok {
|
||||||
|
vLower := strings.ToLower(s)
|
||||||
|
switch op {
|
||||||
|
case string(OpEquals):
|
||||||
|
return tLower == vLower
|
||||||
|
case string(OpNotEquals):
|
||||||
|
return tLower != vLower
|
||||||
|
case string(OpContains):
|
||||||
|
return strings.Contains(tLower, vLower)
|
||||||
|
case string(OpNotContains):
|
||||||
|
return !strings.Contains(tLower, vLower)
|
||||||
|
case string(OpIn):
|
||||||
|
return tLower == vLower
|
||||||
|
case string(OpNotIn):
|
||||||
|
return tLower != vLower
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if list, ok := b.Value.AsStringSlice(); ok {
|
||||||
|
found := false
|
||||||
|
for _, v := range list {
|
||||||
|
if tLower == strings.ToLower(v) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch op {
|
||||||
|
case string(OpIn):
|
||||||
|
return found
|
||||||
|
case string(OpNotIn):
|
||||||
|
return !found
|
||||||
|
case string(OpContains):
|
||||||
|
for _, v := range list {
|
||||||
|
if strings.Contains(tLower, strings.ToLower(v)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
case string(OpNotContains):
|
||||||
|
for _, v := range list {
|
||||||
|
if strings.Contains(tLower, strings.ToLower(v)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func evalDateRange(now time.Time, b BaseCondition) bool {
|
||||||
|
var start, end time.Time
|
||||||
|
if ss, ok := b.Value.AsStringSlice(); ok && len(ss) == 2 {
|
||||||
|
t0, e0 := parseDate(ss[0])
|
||||||
|
t1, e1 := parseDate(ss[1])
|
||||||
|
if e0 != nil || e1 != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
start, end = t0, t1
|
||||||
|
} else if s, ok := b.Value.AsString(); ok {
|
||||||
|
parts := strings.Split(s, "..")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
t0, e0 := parseDate(parts[0])
|
||||||
|
t1, e1 := parseDate(parts[1])
|
||||||
|
if e0 != nil || e1 != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
start, end = t0, t1
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
endInclusive := end.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
|
||||||
|
return (now.Equal(start) || now.After(start)) && (now.Equal(endInclusive) || now.Before(endInclusive))
|
||||||
|
}
|
||||||
|
|
||||||
|
func evalDayOfWeek(now time.Time, b BaseCondition) bool {
|
||||||
|
dow := int(now.Weekday())
|
||||||
|
allowed := make(map[int]struct{})
|
||||||
|
if arr, ok := b.Value.AsStringSlice(); ok {
|
||||||
|
for _, v := range arr {
|
||||||
|
if idx, ok := parseDayOfWeek(v); ok {
|
||||||
|
allowed[idx] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if s, ok := b.Value.AsString(); ok {
|
||||||
|
for _, part := range strings.Split(s, "|") {
|
||||||
|
if idx, ok := parseDayOfWeek(strings.TrimSpace(part)); ok {
|
||||||
|
allowed[idx] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allowed) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
op := normalizeOperator(string(b.Operator))
|
||||||
|
_, present := allowed[dow]
|
||||||
|
if op == string(OpIn) || op == string(OpEquals) || op == "" {
|
||||||
|
return present
|
||||||
|
}
|
||||||
|
if op == string(OpNotIn) || op == string(OpNotEquals) {
|
||||||
|
return !present
|
||||||
|
}
|
||||||
|
return present
|
||||||
|
}
|
||||||
|
|
||||||
|
func evalTimeOfDay(now time.Time, b BaseCondition) bool {
|
||||||
|
var startMin, endMin int
|
||||||
|
if arr, ok := b.Value.AsStringSlice(); ok && len(arr) == 2 {
|
||||||
|
s0, e0 := parseClock(arr[0])
|
||||||
|
s1, e1 := parseClock(arr[1])
|
||||||
|
if e0 != nil || e1 != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
startMin, endMin = s0, s1
|
||||||
|
} else if s, ok := b.Value.AsString(); ok {
|
||||||
|
parts := strings.Split(s, "-")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s0, e0 := parseClock(parts[0])
|
||||||
|
s1, e1 := parseClock(parts[1])
|
||||||
|
if e0 != nil || e1 != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
startMin, endMin = s0, s1
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
todayMin := now.Hour()*60 + now.Minute()
|
||||||
|
if startMin > endMin {
|
||||||
|
return todayMin >= startMin || todayMin <= endMin
|
||||||
|
}
|
||||||
|
return todayMin >= startMin && todayMin <= endMin
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------
|
||||||
|
// Parsing helpers
|
||||||
|
// ----------------------------
|
||||||
|
|
||||||
|
func parseDate(s string) (time.Time, error) {
|
||||||
|
layouts := []string{
|
||||||
|
"2006-01-02",
|
||||||
|
time.RFC3339,
|
||||||
|
"2006-01-02T15:04:05Z07:00",
|
||||||
|
}
|
||||||
|
for _, l := range layouts {
|
||||||
|
if t, err := time.Parse(l, strings.TrimSpace(s)); err == nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, errInvalidTimeFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDayOfWeek(s string) (int, bool) {
|
||||||
|
sl := strings.ToLower(strings.TrimSpace(s))
|
||||||
|
switch sl {
|
||||||
|
case "sun", "sunday", "0":
|
||||||
|
return 0, true
|
||||||
|
case "mon", "monday", "1":
|
||||||
|
return 1, true
|
||||||
|
case "tue", "tues", "tuesday", "2":
|
||||||
|
return 2, true
|
||||||
|
case "wed", "weds", "wednesday", "3":
|
||||||
|
return 3, true
|
||||||
|
case "thu", "thur", "thurs", "thursday", "4":
|
||||||
|
return 4, true
|
||||||
|
case "fri", "friday", "5":
|
||||||
|
return 5, true
|
||||||
|
case "sat", "saturday", "6":
|
||||||
|
return 6, true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseClock(s string) (int, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
parts := strings.Split(s, ":")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return 0, errInvalidTimeFormat
|
||||||
|
}
|
||||||
|
h, err := parsePositiveInt(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
m, err := parsePositiveInt(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if h < 0 || h > 23 || m < 0 || m > 59 {
|
||||||
|
return 0, errInvalidTimeFormat
|
||||||
|
}
|
||||||
|
return h*60 + m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePositiveInt(s string) (int, error) {
|
||||||
|
n := 0
|
||||||
|
for _, r := range s {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return 0, errInvalidTimeFormat
|
||||||
|
}
|
||||||
|
n = n*10 + int(r-'0')
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeOperator(op string) string {
|
||||||
|
o := strings.ToLower(strings.TrimSpace(op))
|
||||||
|
switch o {
|
||||||
|
case "=", "equals", "eq":
|
||||||
|
return string(OpEquals)
|
||||||
|
case "!=", "not_equals", "neq":
|
||||||
|
return string(OpNotEquals)
|
||||||
|
case ">", "greater_than", "gt":
|
||||||
|
return string(OpGreaterThan)
|
||||||
|
case "<", "less_than", "lt":
|
||||||
|
return string(OpLessThan)
|
||||||
|
case ">=", "greater_or_equal", "ge", "gte":
|
||||||
|
return string(OpGreaterOrEqual)
|
||||||
|
case "<=", "less_or_equal", "le", "lte":
|
||||||
|
return string(OpLessOrEqual)
|
||||||
|
case "contains":
|
||||||
|
return string(OpContains)
|
||||||
|
case "not_contains":
|
||||||
|
return string(OpNotContains)
|
||||||
|
case "in":
|
||||||
|
return string(OpIn)
|
||||||
|
case "not_in":
|
||||||
|
return string(OpNotIn)
|
||||||
|
default:
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLogicOperator(op string) string {
|
||||||
|
o := strings.ToLower(strings.TrimSpace(op))
|
||||||
|
switch o {
|
||||||
|
case "&&", "and":
|
||||||
|
return string(LogicAND)
|
||||||
|
case "||", "or":
|
||||||
|
return string(LogicOR)
|
||||||
|
default:
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sliceFloatContains(list []float64, v float64) bool {
|
||||||
|
return slices.Contains(list, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------
|
||||||
|
// Potential extension hooks
|
||||||
|
// ----------------------------
|
||||||
|
//
|
||||||
|
// Future ideas:
|
||||||
|
// - Conflict resolution strategies (e.g., best discount wins, stackable tags)
|
||||||
|
// - Action transformation (e.g., applying tiered logic or bundles carefully)
|
||||||
|
// - Recording evaluation traces for debugging / analytics.
|
||||||
|
// - Tracing instrumentation for condition evaluation.
|
||||||
|
//
|
||||||
|
// These can be integrated by adding strategy interfaces or injecting evaluators
|
||||||
|
// into PromotionService.
|
||||||
|
//
|
||||||
|
// ----------------------------
|
||||||
|
// End of file
|
||||||
|
// ----------------------------
|
||||||
448
pkg/promotions/eval_test.go
Normal file
448
pkg/promotions/eval_test.go
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
package promotions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/cart"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
func cvNum(n float64) ConditionValue {
|
||||||
|
b, _ := json.Marshal(n)
|
||||||
|
return ConditionValue{Raw: b}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cvString(s string) ConditionValue {
|
||||||
|
b, _ := json.Marshal(s)
|
||||||
|
return ConditionValue{Raw: b}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cvStrings(ss []string) ConditionValue {
|
||||||
|
b, _ := json.Marshal(ss)
|
||||||
|
return ConditionValue{Raw: b}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testTracer captures trace events for assertions.
|
||||||
|
type testTracer struct {
|
||||||
|
events []traceEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
type traceEvent struct {
|
||||||
|
event string
|
||||||
|
data map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testTracer) Trace(event string, data map[string]any) {
|
||||||
|
t.events = append(t.events, traceEvent{event: event, data: data})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testTracer) HasEvent(name string) bool {
|
||||||
|
return slices.ContainsFunc(t.events, func(e traceEvent) bool { return e.event == name })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testTracer) Count(name string) int {
|
||||||
|
c := 0
|
||||||
|
for _, e := range t.events {
|
||||||
|
if e.event == name {
|
||||||
|
c++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeCart creates a cart with given total and items (each item quantity & price IncVat).
|
||||||
|
func makeCart(totalOverride int64, items []struct {
|
||||||
|
sku string
|
||||||
|
category string
|
||||||
|
qty int
|
||||||
|
priceInc int64
|
||||||
|
}) *cart.CartGrain {
|
||||||
|
g := cart.NewCartGrain(1, time.Now())
|
||||||
|
for _, it := range items {
|
||||||
|
p := cart.NewPriceFromIncVat(it.priceInc, 0.25)
|
||||||
|
g.Items = append(g.Items, &cart.CartItem{
|
||||||
|
Id: uint32(len(g.Items) + 1),
|
||||||
|
Sku: it.sku,
|
||||||
|
Price: *p,
|
||||||
|
TotalPrice: cart.Price{
|
||||||
|
IncVat: p.IncVat * int64(it.qty),
|
||||||
|
VatRates: p.VatRates,
|
||||||
|
},
|
||||||
|
Quantity: it.qty,
|
||||||
|
Meta: &cart.ItemMeta{
|
||||||
|
Category: it.category,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Recalculate totals
|
||||||
|
g.UpdateTotals()
|
||||||
|
if totalOverride >= 0 {
|
||||||
|
g.TotalPrice.IncVat = totalOverride
|
||||||
|
}
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tests -----------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestEvaluateRuleBasicAND(t *testing.T) {
|
||||||
|
g := makeCart(12000, []struct {
|
||||||
|
sku string
|
||||||
|
category string
|
||||||
|
qty int
|
||||||
|
priceInc int64
|
||||||
|
}{
|
||||||
|
{"SKU-1", "summer", 2, 3000},
|
||||||
|
{"SKU-2", "winter", 1, 6000},
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx := NewContextFromCart(g,
|
||||||
|
WithCustomerSegment("vip"),
|
||||||
|
WithOrderCount(10),
|
||||||
|
WithCustomerLifetimeValue(1234.56),
|
||||||
|
WithNow(time.Date(2024, 6, 10, 12, 0, 0, 0, time.UTC)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Conditions: cart_total >= 10000 AND item_quantity >= 3 AND customer_segment = vip
|
||||||
|
conds := Conditions{
|
||||||
|
ConditionGroup{
|
||||||
|
ID: "grp",
|
||||||
|
Type: "group",
|
||||||
|
Operator: LogicAND,
|
||||||
|
Conditions: Conditions{
|
||||||
|
BaseCondition{
|
||||||
|
ID: "c_cart_total",
|
||||||
|
Type: CondCartTotal,
|
||||||
|
Operator: OpGreaterOrEqual,
|
||||||
|
Value: cvNum(10000),
|
||||||
|
},
|
||||||
|
BaseCondition{
|
||||||
|
ID: "c_item_qty",
|
||||||
|
Type: CondItemQuantity,
|
||||||
|
Operator: OpGreaterOrEqual,
|
||||||
|
Value: cvNum(3),
|
||||||
|
},
|
||||||
|
BaseCondition{
|
||||||
|
ID: "c_segment",
|
||||||
|
Type: CondCustomerSegment,
|
||||||
|
Operator: OpEquals,
|
||||||
|
Value: cvString("vip"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rule := PromotionRule{
|
||||||
|
ID: "rule-AND",
|
||||||
|
Name: "VIP Summer",
|
||||||
|
Description: "Test rule",
|
||||||
|
Status: StatusActive,
|
||||||
|
Priority: 1,
|
||||||
|
StartDate: "2024-06-01",
|
||||||
|
EndDate: ptr("2024-06-30"),
|
||||||
|
Conditions: conds,
|
||||||
|
Actions: []Action{
|
||||||
|
{ID: "a1", Type: ActionPercentageDiscount, Value: 10.0},
|
||||||
|
},
|
||||||
|
UsageLimit: ptrInt(100),
|
||||||
|
UsageCount: 5,
|
||||||
|
CreatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
CreatedBy: "tester",
|
||||||
|
}
|
||||||
|
|
||||||
|
tracer := &testTracer{}
|
||||||
|
svc := NewPromotionService(tracer)
|
||||||
|
res := svc.EvaluateRule(rule, ctx)
|
||||||
|
if !res.Applicable {
|
||||||
|
t.Fatalf("expected rule to be applicable, failedReason=%s", res.FailedReason)
|
||||||
|
}
|
||||||
|
if len(res.MatchedActions) != 1 {
|
||||||
|
t.Fatalf("expected 1 action, got %d", len(res.MatchedActions))
|
||||||
|
}
|
||||||
|
if !tracer.HasEvent("rule_applicable") {
|
||||||
|
t.Errorf("expected tracing event rule_applicable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateRuleUsageLimitExhausted(t *testing.T) {
|
||||||
|
rule := PromotionRule{
|
||||||
|
ID: "limit",
|
||||||
|
Name: "Limit",
|
||||||
|
Status: StatusActive,
|
||||||
|
Priority: 1,
|
||||||
|
StartDate: "2024-01-01",
|
||||||
|
EndDate: nil,
|
||||||
|
Conditions: Conditions{},
|
||||||
|
UsageLimit: ptrInt(5),
|
||||||
|
UsageCount: 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := &PromotionEvalContext{Now: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)}
|
||||||
|
tracer := &testTracer{}
|
||||||
|
svc := NewPromotionService(tracer)
|
||||||
|
res := svc.EvaluateRule(rule, ctx)
|
||||||
|
if res.Applicable {
|
||||||
|
t.Fatalf("expected rule NOT applicable due to usage limit")
|
||||||
|
}
|
||||||
|
if res.FailedReason != "usage_limit_exhausted" {
|
||||||
|
t.Fatalf("expected failedReason usage_limit_exhausted, got %s", res.FailedReason)
|
||||||
|
}
|
||||||
|
if !tracer.HasEvent("rule_skip_usage_limit") {
|
||||||
|
t.Errorf("tracer missing rule_skip_usage_limit event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateRuleDateWindow(t *testing.T) {
|
||||||
|
// Start in future
|
||||||
|
rule := PromotionRule{
|
||||||
|
ID: "date",
|
||||||
|
Name: "DateWindow",
|
||||||
|
Status: StatusActive,
|
||||||
|
Priority: 1,
|
||||||
|
StartDate: "2025-01-01",
|
||||||
|
EndDate: ptr("2025-01-31"),
|
||||||
|
Conditions: Conditions{},
|
||||||
|
}
|
||||||
|
ctx := &PromotionEvalContext{Now: time.Date(2024, 12, 15, 12, 0, 0, 0, time.UTC)}
|
||||||
|
tracer := &testTracer{}
|
||||||
|
svc := NewPromotionService(tracer)
|
||||||
|
res := svc.EvaluateRule(rule, ctx)
|
||||||
|
if res.Applicable {
|
||||||
|
t.Fatalf("expected rule NOT applicable (before start)")
|
||||||
|
}
|
||||||
|
if res.FailedReason != "before_start" {
|
||||||
|
t.Fatalf("expected failedReason before_start, got %s", res.FailedReason)
|
||||||
|
}
|
||||||
|
if !tracer.HasEvent("rule_skip_before_start") {
|
||||||
|
t.Errorf("missing rule_skip_before_start trace event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateProductCategoryIN(t *testing.T) {
|
||||||
|
g := makeCart(-1, []struct {
|
||||||
|
sku string
|
||||||
|
category string
|
||||||
|
qty int
|
||||||
|
priceInc int64
|
||||||
|
}{
|
||||||
|
{"A", "shoes", 1, 5000},
|
||||||
|
{"B", "bags", 1, 7000},
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx := NewContextFromCart(g)
|
||||||
|
conds := Conditions{
|
||||||
|
BaseCondition{
|
||||||
|
ID: "c_category",
|
||||||
|
Type: CondProductCategory,
|
||||||
|
Operator: OpIn,
|
||||||
|
Value: cvStrings([]string{"shoes", "hats"}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rule := PromotionRule{
|
||||||
|
ID: "cat-in",
|
||||||
|
Status: StatusActive,
|
||||||
|
Priority: 10,
|
||||||
|
StartDate: "2024-01-01",
|
||||||
|
EndDate: nil,
|
||||||
|
Conditions: conds,
|
||||||
|
Actions: []Action{
|
||||||
|
{ID: "discount", Type: ActionFixedDiscount, Value: 1000},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tracer := &testTracer{}
|
||||||
|
svc := NewPromotionService(tracer)
|
||||||
|
res := svc.EvaluateRule(rule, ctx)
|
||||||
|
if !res.Applicable {
|
||||||
|
t.Fatalf("expected category IN rule to apply")
|
||||||
|
}
|
||||||
|
if !tracer.HasEvent("rule_applicable") {
|
||||||
|
t.Errorf("tracing missing rule_applicable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateGroupOR(t *testing.T) {
|
||||||
|
g := makeCart(3000, []struct {
|
||||||
|
sku string
|
||||||
|
category string
|
||||||
|
qty int
|
||||||
|
priceInc int64
|
||||||
|
}{
|
||||||
|
{"ONE", "x", 1, 3000},
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx := NewContextFromCart(g)
|
||||||
|
|
||||||
|
// OR group: (cart_total >= 10000) OR (item_quantity >= 1)
|
||||||
|
group := ConditionGroup{
|
||||||
|
ID: "grp-or",
|
||||||
|
Type: "group",
|
||||||
|
Operator: LogicOR,
|
||||||
|
Conditions: Conditions{
|
||||||
|
BaseCondition{
|
||||||
|
ID: "c_total",
|
||||||
|
Type: CondCartTotal,
|
||||||
|
Operator: OpGreaterOrEqual,
|
||||||
|
Value: cvNum(10000),
|
||||||
|
},
|
||||||
|
BaseCondition{
|
||||||
|
ID: "c_qty",
|
||||||
|
Type: CondItemQuantity,
|
||||||
|
Operator: OpGreaterOrEqual,
|
||||||
|
Value: cvNum(1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rule := PromotionRule{
|
||||||
|
ID: "or-rule",
|
||||||
|
Status: StatusActive,
|
||||||
|
Priority: 5,
|
||||||
|
StartDate: "2024-01-01",
|
||||||
|
EndDate: nil,
|
||||||
|
Conditions: Conditions{group},
|
||||||
|
Actions: []Action{{ID: "a", Type: ActionFixedDiscount, Value: 500}},
|
||||||
|
}
|
||||||
|
tracer := &testTracer{}
|
||||||
|
svc := NewPromotionService(tracer)
|
||||||
|
res := svc.EvaluateRule(rule, ctx)
|
||||||
|
if !res.Applicable {
|
||||||
|
t.Fatalf("expected OR rule to apply (second condition true)")
|
||||||
|
}
|
||||||
|
// Ensure group pass event
|
||||||
|
if !tracer.HasEvent("cond_group_or_pass") {
|
||||||
|
t.Errorf("expected cond_group_or_pass trace event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateAllPriorityOrdering(t *testing.T) {
|
||||||
|
ctx := &PromotionEvalContext{
|
||||||
|
CartTotalIncVat: 20000,
|
||||||
|
TotalItemQuantity: 2,
|
||||||
|
Items: []PromotionItem{
|
||||||
|
{SKU: "X", Quantity: 2, Category: "general", PriceIncVat: 10000},
|
||||||
|
},
|
||||||
|
Now: time.Date(2024, 5, 10, 10, 0, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule A: priority 5
|
||||||
|
ruleA := PromotionRule{
|
||||||
|
ID: "A",
|
||||||
|
Status: StatusActive,
|
||||||
|
Priority: 5,
|
||||||
|
StartDate: "2024-01-01",
|
||||||
|
EndDate: nil,
|
||||||
|
Conditions: Conditions{},
|
||||||
|
Actions: []Action{
|
||||||
|
{ID: "actionA", Type: ActionFixedDiscount, Value: 100},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule B: priority 1
|
||||||
|
ruleB := PromotionRule{
|
||||||
|
ID: "B",
|
||||||
|
Status: StatusActive,
|
||||||
|
Priority: 1,
|
||||||
|
StartDate: "2024-01-01",
|
||||||
|
EndDate: nil,
|
||||||
|
Conditions: Conditions{},
|
||||||
|
Actions: []Action{
|
||||||
|
{ID: "actionB", Type: ActionFixedDiscount, Value: 200},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tracer := &testTracer{}
|
||||||
|
svc := NewPromotionService(tracer)
|
||||||
|
results, actions := svc.EvaluateAll([]PromotionRule{ruleA, ruleB}, ctx)
|
||||||
|
if len(results) != 2 {
|
||||||
|
t.Fatalf("expected 2 results, got %d", len(results))
|
||||||
|
}
|
||||||
|
if len(actions) != 2 {
|
||||||
|
t.Fatalf("expected 2 actions, got %d", len(actions))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions should follow priority order: ruleB (1) then ruleA (5)
|
||||||
|
if actions[0].ID != "actionB" || actions[1].ID != "actionA" {
|
||||||
|
t.Fatalf("actions order invalid: %+v", actions)
|
||||||
|
}
|
||||||
|
if tracer.Count("actions_add") != 2 {
|
||||||
|
t.Errorf("expected 2 actions_add trace events, got %d", tracer.Count("actions_add"))
|
||||||
|
}
|
||||||
|
if !tracer.HasEvent("evaluation_complete") {
|
||||||
|
t.Errorf("missing evaluation_complete trace")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDayOfWeekAndTimeOfDay(t *testing.T) {
|
||||||
|
// Wednesday 14:30 UTC
|
||||||
|
now := time.Date(2024, 6, 12, 14, 30, 0, 0, time.UTC) // 2024-06-12 is Wednesday
|
||||||
|
ctx := &PromotionEvalContext{
|
||||||
|
Now: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
condDay := BaseCondition{
|
||||||
|
ID: "dow",
|
||||||
|
Type: CondDayOfWeek,
|
||||||
|
Operator: OpIn,
|
||||||
|
Value: cvStrings([]string{"wed", "fri"}),
|
||||||
|
}
|
||||||
|
condTime := BaseCondition{
|
||||||
|
ID: "tod",
|
||||||
|
Type: CondTimeOfDay,
|
||||||
|
Operator: OpEquals, // operator is ignored for time-of-day internally
|
||||||
|
Value: cvString("13:00-15:00"),
|
||||||
|
}
|
||||||
|
|
||||||
|
rule := PromotionRule{
|
||||||
|
ID: "day-time",
|
||||||
|
Status: StatusActive,
|
||||||
|
Priority: 1,
|
||||||
|
StartDate: "2024-01-01",
|
||||||
|
EndDate: nil,
|
||||||
|
Conditions: Conditions{condDay, condTime},
|
||||||
|
Actions: []Action{{ID: "a", Type: ActionPercentageDiscount, Value: 15}},
|
||||||
|
}
|
||||||
|
|
||||||
|
tracer := &testTracer{}
|
||||||
|
svc := NewPromotionService(tracer)
|
||||||
|
res := svc.EvaluateRule(rule, ctx)
|
||||||
|
if !res.Applicable {
|
||||||
|
t.Fatalf("expected rule to apply for Wednesday 14:30 in window 13-15")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDateRangeCondition(t *testing.T) {
|
||||||
|
now := time.Date(2024, 3, 15, 9, 0, 0, 0, time.UTC)
|
||||||
|
ctx := &PromotionEvalContext{Now: now}
|
||||||
|
|
||||||
|
condRange := BaseCondition{
|
||||||
|
ID: "date_range",
|
||||||
|
Type: CondDateRange,
|
||||||
|
Operator: OpEquals, // not used
|
||||||
|
Value: cvStrings([]string{"2024-03-01", "2024-03-31"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
rule := PromotionRule{
|
||||||
|
ID: "range",
|
||||||
|
Status: StatusActive,
|
||||||
|
Priority: 1,
|
||||||
|
StartDate: "2024-01-01",
|
||||||
|
EndDate: nil,
|
||||||
|
Conditions: Conditions{condRange},
|
||||||
|
Actions: []Action{{ID: "a", Type: ActionFixedDiscount, Value: 500}},
|
||||||
|
}
|
||||||
|
|
||||||
|
tracer := &testTracer{}
|
||||||
|
svc := NewPromotionService(tracer)
|
||||||
|
res := svc.EvaluateRule(rule, ctx)
|
||||||
|
if !res.Applicable {
|
||||||
|
t.Fatalf("expected date range rule to apply for 2024-03-15")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Utilities -------------------------------------------------------------
|
||||||
|
|
||||||
|
func ptr(s string) *string { return &s }
|
||||||
|
func ptrInt(i int) *int { return &i }
|
||||||
443
pkg/promotions/type_test.go
Normal file
443
pkg/promotions/type_test.go
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
package promotions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sampleJSON mirrors the user's full example data (all three rules)
|
||||||
|
var sampleJSON = []byte(`[
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Summer Sale 2024",
|
||||||
|
"description": "20% off on all summer items",
|
||||||
|
"status": "active",
|
||||||
|
"priority": 1,
|
||||||
|
"startDate": "2024-06-01",
|
||||||
|
"endDate": "2024-08-31",
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "group1",
|
||||||
|
"type": "group",
|
||||||
|
"operator": "AND",
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c1",
|
||||||
|
"type": "product_category",
|
||||||
|
"operator": "in",
|
||||||
|
"value": ["summer", "beachwear"],
|
||||||
|
"label": "Product category is Summer or Beachwear"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "c2",
|
||||||
|
"type": "cart_total",
|
||||||
|
"operator": "greater_or_equal",
|
||||||
|
"value": 50,
|
||||||
|
"label": "Cart total is at least $50"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "a1",
|
||||||
|
"type": "percentage_discount",
|
||||||
|
"value": 20,
|
||||||
|
"label": "20% discount"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usageLimit": 1000,
|
||||||
|
"usageCount": 342,
|
||||||
|
"createdAt": "2024-05-15T10:00:00Z",
|
||||||
|
"updatedAt": "2024-05-20T14:30:00Z",
|
||||||
|
"createdBy": "admin@example.com",
|
||||||
|
"tags": ["seasonal", "summer"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"name": "VIP Customer Exclusive",
|
||||||
|
"description": "Free shipping for VIP customers",
|
||||||
|
"status": "active",
|
||||||
|
"priority": 2,
|
||||||
|
"startDate": "2024-01-01",
|
||||||
|
"endDate": null,
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c3",
|
||||||
|
"type": "customer_segment",
|
||||||
|
"operator": "equals",
|
||||||
|
"value": "vip",
|
||||||
|
"label": "Customer segment is VIP"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "a2",
|
||||||
|
"type": "free_shipping",
|
||||||
|
"value": 0,
|
||||||
|
"label": "Free shipping"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usageCount": 1523,
|
||||||
|
"createdAt": "2023-12-15T09:00:00Z",
|
||||||
|
"updatedAt": "2024-01-05T11:20:00Z",
|
||||||
|
"createdBy": "marketing@example.com",
|
||||||
|
"tags": ["vip", "loyalty"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "3",
|
||||||
|
"name": "Buy 2 Get 1 Free",
|
||||||
|
"description": "Buy 2 items, get the cheapest one free",
|
||||||
|
"status": "scheduled",
|
||||||
|
"priority": 3,
|
||||||
|
"startDate": "2024-12-01",
|
||||||
|
"endDate": "2024-12-25",
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "c4",
|
||||||
|
"type": "item_quantity",
|
||||||
|
"operator": "greater_or_equal",
|
||||||
|
"value": 3,
|
||||||
|
"label": "Cart has at least 3 items"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "a3",
|
||||||
|
"type": "buy_x_get_y",
|
||||||
|
"value": 0,
|
||||||
|
"config": { "buy": 2, "get": 1, "discount": 100 },
|
||||||
|
"label": "Buy 2 Get 1 Free"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usageCount": 0,
|
||||||
|
"createdAt": "2024-11-01T08:00:00Z",
|
||||||
|
"updatedAt": "2024-11-01T08:00:00Z",
|
||||||
|
"createdBy": "admin@example.com",
|
||||||
|
"tags": ["holiday", "christmas"]
|
||||||
|
}
|
||||||
|
]`)
|
||||||
|
|
||||||
|
func TestDecodePromotionRulesBasic(t *testing.T) {
|
||||||
|
rules, err := DecodePromotionRules(sampleJSON)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodePromotionRules failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(rules) != 3 {
|
||||||
|
t.Fatalf("expected 3 rules, got %d", len(rules))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 1 checks
|
||||||
|
r1 := rules[0]
|
||||||
|
if r1.ID != "1" {
|
||||||
|
t.Errorf("rule[0].ID = %s, want 1", r1.ID)
|
||||||
|
}
|
||||||
|
if r1.Status != StatusActive {
|
||||||
|
t.Errorf("rule[0].Status = %s, want %s", r1.Status, StatusActive)
|
||||||
|
}
|
||||||
|
if r1.EndDate == nil || *r1.EndDate != "2024-08-31" {
|
||||||
|
t.Errorf("rule[0].EndDate = %v, want 2024-08-31", r1.EndDate)
|
||||||
|
}
|
||||||
|
if r1.UsageLimit == nil || *r1.UsageLimit != 1000 {
|
||||||
|
t.Errorf("rule[0].UsageLimit = %v, want 1000", r1.UsageLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 2 checks
|
||||||
|
r2 := rules[1]
|
||||||
|
if r2.ID != "2" {
|
||||||
|
t.Errorf("rule[1].ID = %s, want 2", r2.ID)
|
||||||
|
}
|
||||||
|
if r2.EndDate != nil {
|
||||||
|
t.Errorf("rule[1].EndDate should be nil (from null), got %v", *r2.EndDate)
|
||||||
|
}
|
||||||
|
if r2.UsageLimit != nil {
|
||||||
|
t.Errorf("rule[1].UsageLimit should be nil (missing), got %v", *r2.UsageLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConditionDecoding(t *testing.T) {
|
||||||
|
rules, err := DecodePromotionRules(sampleJSON)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodePromotionRules failed: %v", err)
|
||||||
|
}
|
||||||
|
r1 := rules[0]
|
||||||
|
if len(r1.Conditions) != 1 {
|
||||||
|
t.Fatalf("expected 1 top-level condition group, got %d", len(r1.Conditions))
|
||||||
|
}
|
||||||
|
|
||||||
|
grp, ok := r1.Conditions[0].(ConditionGroup)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("top-level condition is not a group, type=%T", r1.Conditions[0])
|
||||||
|
}
|
||||||
|
if grp.Operator != LogicAND {
|
||||||
|
t.Errorf("group operator = %s, want AND", grp.Operator)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(grp.Conditions) != 2 {
|
||||||
|
t.Fatalf("expected 2 child conditions, got %d", len(grp.Conditions))
|
||||||
|
}
|
||||||
|
|
||||||
|
// First child: product_category condition with slice value
|
||||||
|
c0, ok := grp.Conditions[0].(BaseCondition)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("first child not BaseCondition, got %T", grp.Conditions[0])
|
||||||
|
}
|
||||||
|
if c0.Type != CondProductCategory {
|
||||||
|
t.Errorf("first child type = %s, want %s", c0.Type, CondProductCategory)
|
||||||
|
}
|
||||||
|
if c0.Operator != OpIn {
|
||||||
|
t.Errorf("first child operator = %s, want %s", c0.Operator, OpIn)
|
||||||
|
}
|
||||||
|
if arr, ok := c0.Value.AsStringSlice(); !ok || len(arr) != 2 || arr[0] != "summer" {
|
||||||
|
t.Errorf("expected string slice value [summer,...], got %v", arr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second child: cart_total numeric
|
||||||
|
c1, ok := grp.Conditions[1].(BaseCondition)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("second child not BaseCondition, got %T", grp.Conditions[1])
|
||||||
|
}
|
||||||
|
if c1.Type != CondCartTotal {
|
||||||
|
t.Errorf("second child type = %s, want %s", c1.Type, CondCartTotal)
|
||||||
|
}
|
||||||
|
if val, ok := c1.Value.AsFloat64(); !ok || val != 50 {
|
||||||
|
t.Errorf("expected numeric value 50, got %v (ok=%v)", val, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConditionValueHelpers(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
jsonVal string
|
||||||
|
wantStr string
|
||||||
|
wantNum *float64
|
||||||
|
wantSS []string
|
||||||
|
wantFS []float64
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "string value",
|
||||||
|
jsonVal: `"vip"`,
|
||||||
|
wantStr: "vip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "number value",
|
||||||
|
jsonVal: `42`,
|
||||||
|
wantNum: floatPtr(42),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "string slice",
|
||||||
|
jsonVal: `["a","b"]`,
|
||||||
|
wantSS: []string{"a", "b"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "number slice int",
|
||||||
|
jsonVal: `[1,2,3]`,
|
||||||
|
wantFS: []float64{1, 2, 3},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "number slice float",
|
||||||
|
jsonVal: `[1.5,2.25]`,
|
||||||
|
wantFS: []float64{1.5, 2.25},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var cv ConditionValue
|
||||||
|
if err := json.Unmarshal([]byte(tc.jsonVal), &cv); err != nil {
|
||||||
|
t.Fatalf("unmarshal value failed: %v", err)
|
||||||
|
}
|
||||||
|
if tc.wantStr != "" {
|
||||||
|
if got, ok := cv.AsString(); !ok || got != tc.wantStr {
|
||||||
|
t.Errorf("AsString got=%q ok=%v want=%q", got, ok, tc.wantStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tc.wantNum != nil {
|
||||||
|
if got, ok := cv.AsFloat64(); !ok || got != *tc.wantNum {
|
||||||
|
t.Errorf("AsFloat64 got=%v ok=%v want=%v", got, ok, *tc.wantNum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tc.wantSS != nil {
|
||||||
|
if got, ok := cv.AsStringSlice(); !ok || len(got) != len(tc.wantSS) || got[0] != tc.wantSS[0] {
|
||||||
|
t.Errorf("AsStringSlice got=%v ok=%v want=%v", got, ok, tc.wantSS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tc.wantFS != nil {
|
||||||
|
if got, ok := cv.AsFloat64Slice(); !ok || len(got) != len(tc.wantFS) {
|
||||||
|
t.Errorf("AsFloat64Slice got=%v ok=%v want=%v", got, ok, tc.wantFS)
|
||||||
|
} else {
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tc.wantFS[i] {
|
||||||
|
t.Errorf("AsFloat64Slice[%d]=%v want=%v", i, got[i], tc.wantFS[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWalkConditionsTraversal(t *testing.T) {
|
||||||
|
rules, err := DecodePromotionRules(sampleJSON)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodePromotionRules failed: %v", err)
|
||||||
|
}
|
||||||
|
visited := 0
|
||||||
|
WalkConditions(rules[0].Conditions, func(c Condition) bool {
|
||||||
|
visited++
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
// group + 2 children
|
||||||
|
if visited != 3 {
|
||||||
|
t.Errorf("expected 3 visited conditions, got %d", visited)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionBundleConfigParsing(t *testing.T) {
|
||||||
|
jsonData := []byte(`[
|
||||||
|
{
|
||||||
|
"id": "bundle-1",
|
||||||
|
"name": "Bundle Deal",
|
||||||
|
"description": "Fixed price bundle",
|
||||||
|
"status": "active",
|
||||||
|
"priority": 1,
|
||||||
|
"startDate": "2024-01-01",
|
||||||
|
"endDate": null,
|
||||||
|
"conditions": [],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "act-bundle",
|
||||||
|
"type": "bundle_discount",
|
||||||
|
"value": 0,
|
||||||
|
"bundleConfig": {
|
||||||
|
"containers": [
|
||||||
|
{
|
||||||
|
"id": "cont1",
|
||||||
|
"name": "Shoes",
|
||||||
|
"quantity": 2,
|
||||||
|
"selectionType": "any",
|
||||||
|
"qualifyingRules": {
|
||||||
|
"type": "category",
|
||||||
|
"value": "shoes"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cont2",
|
||||||
|
"name": "Socks",
|
||||||
|
"quantity": 3,
|
||||||
|
"selectionType": "specific",
|
||||||
|
"qualifyingRules": {
|
||||||
|
"type": "product_ids",
|
||||||
|
"value": ["sock-1", "sock-2"]
|
||||||
|
},
|
||||||
|
"allowedProducts": ["sock-1","sock-2"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pricing": {
|
||||||
|
"type": "fixed_price",
|
||||||
|
"value": 49.99
|
||||||
|
},
|
||||||
|
"requireAllContainers": true
|
||||||
|
},
|
||||||
|
"config": { "note": "Bundle applies to footwear + socks" }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usageCount": 0,
|
||||||
|
"createdAt": "2024-01-01T00:00:00Z",
|
||||||
|
"updatedAt": "2024-01-01T00:00:00Z",
|
||||||
|
"createdBy": "bundle@example.com",
|
||||||
|
"tags": ["bundle","footwear"]
|
||||||
|
}
|
||||||
|
]`)
|
||||||
|
rules, err := DecodePromotionRules(jsonData)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(rules) != 1 {
|
||||||
|
t.Fatalf("expected 1 rule, got %d", len(rules))
|
||||||
|
}
|
||||||
|
if len(rules[0].Actions) != 1 {
|
||||||
|
t.Fatalf("expected 1 action, got %d", len(rules[0].Actions))
|
||||||
|
}
|
||||||
|
act := rules[0].Actions[0]
|
||||||
|
if act.Type != ActionBundleDiscount {
|
||||||
|
t.Fatalf("action type = %s, want %s", act.Type, ActionBundleDiscount)
|
||||||
|
}
|
||||||
|
if act.BundleConfig == nil {
|
||||||
|
t.Fatalf("bundleConfig nil")
|
||||||
|
}
|
||||||
|
if act.BundleConfig.Pricing.Type != "fixed_price" {
|
||||||
|
t.Errorf("pricing.type = %s, want fixed_price", act.BundleConfig.Pricing.Type)
|
||||||
|
}
|
||||||
|
if act.BundleConfig.Pricing.Value != 49.99 {
|
||||||
|
t.Errorf("pricing.value = %v, want 49.99", act.BundleConfig.Pricing.Value)
|
||||||
|
}
|
||||||
|
if !act.BundleConfig.RequireAllContainers {
|
||||||
|
t.Errorf("RequireAllContainers expected true")
|
||||||
|
}
|
||||||
|
if len(act.BundleConfig.Containers) != 2 {
|
||||||
|
t.Fatalf("expected 2 containers, got %d", len(act.BundleConfig.Containers))
|
||||||
|
}
|
||||||
|
if act.Config == nil || act.Config["note"] != "Bundle applies to footwear + socks" {
|
||||||
|
t.Errorf("config.note mismatch: %v", act.Config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConditionValueInvalidTypes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
}{
|
||||||
|
{"object", `{}`},
|
||||||
|
{"booleanTrue", `true`},
|
||||||
|
{"booleanFalse", `false`},
|
||||||
|
{"null", `null`},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var cv ConditionValue
|
||||||
|
if err := json.Unmarshal([]byte(tc.raw), &cv); err != nil {
|
||||||
|
t.Fatalf("unmarshal failed: %v", err)
|
||||||
|
}
|
||||||
|
if s, ok := cv.AsString(); ok {
|
||||||
|
t.Errorf("AsString unexpectedly succeeded (%q) for %s", s, tc.name)
|
||||||
|
}
|
||||||
|
if n, ok := cv.AsFloat64(); ok {
|
||||||
|
t.Errorf("AsFloat64 unexpectedly succeeded (%v) for %s", n, tc.name)
|
||||||
|
}
|
||||||
|
if ss, ok := cv.AsStringSlice(); ok {
|
||||||
|
t.Errorf("AsStringSlice unexpectedly succeeded (%v) for %s", ss, tc.name)
|
||||||
|
}
|
||||||
|
if fs, ok := cv.AsFloat64Slice(); ok {
|
||||||
|
t.Errorf("AsFloat64Slice unexpectedly succeeded (%v) for %s", fs, tc.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromotionRuleRoundTrip(t *testing.T) {
|
||||||
|
orig, err := DecodePromotionRules(sampleJSON)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("initial decode failed: %v", err)
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(orig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
round, err := DecodePromotionRules(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("round-trip decode failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(orig) != len(round) {
|
||||||
|
t.Fatalf("rule count mismatch: orig=%d round=%d", len(orig), len(round))
|
||||||
|
}
|
||||||
|
// spot-check first rule
|
||||||
|
if orig[0].Name != round[0].Name {
|
||||||
|
t.Errorf("first rule name mismatch: %s vs %s", orig[0].Name, round[0].Name)
|
||||||
|
}
|
||||||
|
if len(orig[0].Conditions) != len(round[0].Conditions) {
|
||||||
|
t.Errorf("first rule condition count mismatch: %d vs %d", len(orig[0].Conditions), len(round[0].Conditions))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func floatPtr(f float64) *float64 { return &f }
|
||||||
413
pkg/promotions/types.go
Normal file
413
pkg/promotions/types.go
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
package promotions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// Enum-like string types
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
type ConditionOperator string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OpEquals ConditionOperator = "="
|
||||||
|
OpNotEquals ConditionOperator = "!="
|
||||||
|
OpGreaterThan ConditionOperator = ">"
|
||||||
|
OpLessThan ConditionOperator = "<"
|
||||||
|
OpGreaterOrEqual ConditionOperator = ">="
|
||||||
|
OpLessOrEqual ConditionOperator = "<="
|
||||||
|
OpContains ConditionOperator = "contains"
|
||||||
|
OpNotContains ConditionOperator = "not_contains"
|
||||||
|
OpIn ConditionOperator = "in"
|
||||||
|
OpNotIn ConditionOperator = "not_in"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ConditionType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CondCartTotal ConditionType = "cart_total"
|
||||||
|
CondItemQuantity ConditionType = "item_quantity"
|
||||||
|
CondCustomerSegment ConditionType = "customer_segment"
|
||||||
|
CondProductCategory ConditionType = "product_category"
|
||||||
|
CondProductID ConditionType = "product_id"
|
||||||
|
CondCustomerLifetime ConditionType = "customer_lifetime_value"
|
||||||
|
CondOrderCount ConditionType = "order_count"
|
||||||
|
CondDateRange ConditionType = "date_range"
|
||||||
|
CondDayOfWeek ConditionType = "day_of_week"
|
||||||
|
CondTimeOfDay ConditionType = "time_of_day"
|
||||||
|
CondGroup ConditionType = "group" // synthetic value for groups
|
||||||
|
)
|
||||||
|
|
||||||
|
type ActionType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActionPercentageDiscount ActionType = "percentage_discount"
|
||||||
|
ActionFixedDiscount ActionType = "fixed_discount"
|
||||||
|
ActionFreeShipping ActionType = "free_shipping"
|
||||||
|
ActionBuyXGetY ActionType = "buy_x_get_y"
|
||||||
|
ActionTieredDiscount ActionType = "tiered_discount"
|
||||||
|
ActionBundleDiscount ActionType = "bundle_discount"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LogicOperator string
|
||||||
|
|
||||||
|
const (
|
||||||
|
LogicAND LogicOperator = "&&"
|
||||||
|
LogicOR LogicOperator = "||"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PromotionStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusActive PromotionStatus = "active"
|
||||||
|
StatusInactive PromotionStatus = "inactive"
|
||||||
|
StatusScheduled PromotionStatus = "scheduled"
|
||||||
|
StatusExpired PromotionStatus = "expired"
|
||||||
|
)
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// Condition Value (union)
|
||||||
|
// -----------------------------
|
||||||
|
//
|
||||||
|
// Represents: string | number | []string | []number
|
||||||
|
// We store raw JSON and lazily interpret.
|
||||||
|
|
||||||
|
type ConditionValue struct {
|
||||||
|
Raw json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cv *ConditionValue) UnmarshalJSON(b []byte) error {
|
||||||
|
if len(b) == 0 {
|
||||||
|
return errors.New("empty ConditionValue")
|
||||||
|
}
|
||||||
|
// Just store raw; interpretation happens via helpers.
|
||||||
|
cv.Raw = append(cv.Raw[0:0], b...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers to interpret value:
|
||||||
|
func (cv ConditionValue) AsString() (string, bool) {
|
||||||
|
// Treat explicit JSON null as invalid
|
||||||
|
if string(cv.Raw) == "null" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(cv.Raw, &s); err == nil {
|
||||||
|
return s, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cv ConditionValue) AsFloat64() (float64, bool) {
|
||||||
|
if string(cv.Raw) == "null" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
var f float64
|
||||||
|
if err := json.Unmarshal(cv.Raw, &f); err == nil {
|
||||||
|
return f, true
|
||||||
|
}
|
||||||
|
// Attempt integer decode into float64
|
||||||
|
var i int64
|
||||||
|
if err := json.Unmarshal(cv.Raw, &i); err == nil {
|
||||||
|
return float64(i), true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cv ConditionValue) AsStringSlice() ([]string, bool) {
|
||||||
|
if string(cv.Raw) == "null" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var arr []string
|
||||||
|
if err := json.Unmarshal(cv.Raw, &arr); err == nil {
|
||||||
|
return arr, true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cv ConditionValue) AsFloat64Slice() ([]float64, bool) {
|
||||||
|
if string(cv.Raw) == "null" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var arrNum []float64
|
||||||
|
if err := json.Unmarshal(cv.Raw, &arrNum); err == nil {
|
||||||
|
return arrNum, true
|
||||||
|
}
|
||||||
|
// Try []int -> []float64
|
||||||
|
var arrInt []int64
|
||||||
|
if err := json.Unmarshal(cv.Raw, &arrInt); err == nil {
|
||||||
|
out := make([]float64, len(arrInt))
|
||||||
|
for i, v := range arrInt {
|
||||||
|
out[i] = float64(v)
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cv ConditionValue) String() string {
|
||||||
|
if s, ok := cv.AsString(); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if f, ok := cv.AsFloat64(); ok {
|
||||||
|
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||||
|
}
|
||||||
|
if ss, ok := cv.AsStringSlice(); ok {
|
||||||
|
return fmt.Sprintf("%v", ss)
|
||||||
|
}
|
||||||
|
if fs, ok := cv.AsFloat64Slice(); ok {
|
||||||
|
return fmt.Sprintf("%v", fs)
|
||||||
|
}
|
||||||
|
return string(cv.Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// BaseCondition
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
type BaseCondition struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type ConditionType `json:"type"`
|
||||||
|
Operator ConditionOperator `json:"operator"`
|
||||||
|
Value ConditionValue `json:"value"`
|
||||||
|
Label *string `json:"label,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b BaseCondition) IsGroup() bool { return false }
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// ConditionGroup
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
type ConditionGroup struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"` // always "group"
|
||||||
|
Operator LogicOperator `json:"operator"`
|
||||||
|
Conditions Conditions `json:"conditions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom unmarshaller ensures nested polymorphic conditions are decoded
|
||||||
|
// using the Conditions type (which applies the raw element discriminator).
|
||||||
|
func (g *ConditionGroup) UnmarshalJSON(b []byte) error {
|
||||||
|
type alias struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Operator LogicOperator `json:"operator"`
|
||||||
|
Conditions json.RawMessage `json:"conditions"`
|
||||||
|
}
|
||||||
|
var a alias
|
||||||
|
if err := json.Unmarshal(b, &a); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Basic validation
|
||||||
|
if a.Type != "group" {
|
||||||
|
return fmt.Errorf("ConditionGroup expected type 'group', got %q", a.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
var conds Conditions
|
||||||
|
if len(a.Conditions) > 0 {
|
||||||
|
if err := json.Unmarshal(a.Conditions, &conds); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g.ID = a.ID
|
||||||
|
g.Type = a.Type
|
||||||
|
switch strings.ToLower(string(a.Operator)) {
|
||||||
|
case "and":
|
||||||
|
g.Operator = LogicAND
|
||||||
|
case "or":
|
||||||
|
g.Operator = LogicOR
|
||||||
|
default:
|
||||||
|
g.Operator = a.Operator
|
||||||
|
}
|
||||||
|
g.Conditions = conds
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g ConditionGroup) IsGroup() bool { return true }
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// Condition interface + slice
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
type Condition interface {
|
||||||
|
IsGroup() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal wrapper to help decode each element.
|
||||||
|
type rawCond struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom unmarshaler for a Condition slice
|
||||||
|
type Conditions []Condition
|
||||||
|
|
||||||
|
func (cs *Conditions) UnmarshalJSON(b []byte) error {
|
||||||
|
var rawList []json.RawMessage
|
||||||
|
if err := json.Unmarshal(b, &rawList); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out := make([]Condition, 0, len(rawList))
|
||||||
|
for _, elem := range rawList {
|
||||||
|
var hdr rawCond
|
||||||
|
if err := json.Unmarshal(elem, &hdr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if hdr.Type == "group" {
|
||||||
|
var grp ConditionGroup
|
||||||
|
if err := json.Unmarshal(elem, &grp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Recursively decode grp.Conditions (already handled because field type is []Condition)
|
||||||
|
out = append(out, grp)
|
||||||
|
} else {
|
||||||
|
var bc BaseCondition
|
||||||
|
if err := json.Unmarshal(elem, &bc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = append(out, bc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*cs = out
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// Bundle Structures
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
type BundleQualifyingRules struct {
|
||||||
|
Type string `json:"type"` // "category" | "product_ids" | "tag" | "all"
|
||||||
|
Value interface{} `json:"value"` // string or []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type BundleContainer struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
SelectionType string `json:"selectionType"` // "any" | "specific"
|
||||||
|
QualifyingRules BundleQualifyingRules `json:"qualifyingRules"`
|
||||||
|
AllowedProducts []string `json:"allowedProducts,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BundlePricing struct {
|
||||||
|
Type string `json:"type"` // "fixed_price" | "percentage_discount" | "fixed_discount"
|
||||||
|
Value float64 `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BundleConfig struct {
|
||||||
|
Containers []BundleContainer `json:"containers"`
|
||||||
|
Pricing BundlePricing `json:"pricing"`
|
||||||
|
RequireAllContainers bool `json:"requireAllContainers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// Action
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
type Action struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type ActionType `json:"type"`
|
||||||
|
Value interface{} `json:"value"` // number or string
|
||||||
|
Config map[string]interface{} `json:"config,omitempty"`
|
||||||
|
BundleConfig *BundleConfig `json:"bundleConfig,omitempty"`
|
||||||
|
Label *string `json:"label,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// Promotion Rule
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
type PromotionRule struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Status PromotionStatus `json:"status"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
StartDate string `json:"startDate"`
|
||||||
|
EndDate *string `json:"endDate"` // null -> nil
|
||||||
|
Conditions Conditions `json:"conditions"`
|
||||||
|
Actions []Action `json:"actions"`
|
||||||
|
UsageLimit *int `json:"usageLimit,omitempty"`
|
||||||
|
UsageCount int `json:"usageCount"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
CreatedBy string `json:"createdBy"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// Promotion Stats
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
type PromotionStats struct {
|
||||||
|
TotalPromotions int `json:"totalPromotions"`
|
||||||
|
ActivePromotions int `json:"activePromotions"`
|
||||||
|
TotalRevenue float64 `json:"totalRevenue"`
|
||||||
|
TotalOrders int `json:"totalOrders"`
|
||||||
|
AverageDiscount float64 `json:"averageDiscount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// Utility: Decode array of rules
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
func DecodePromotionRules(data []byte) ([]PromotionRule, error) {
|
||||||
|
var rules []PromotionRule
|
||||||
|
if err := json.Unmarshal(data, &rules); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example helper to inspect conditions programmatically.
|
||||||
|
func WalkConditions(conds []Condition, fn func(c Condition) bool) {
|
||||||
|
for _, c := range conds {
|
||||||
|
if !fn(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if grp, ok := c.(ConditionGroup); ok {
|
||||||
|
WalkConditions(grp.Conditions, fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PromotionState struct {
|
||||||
|
Promotions []PromotionRule `json:"promotions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StateFile struct {
|
||||||
|
State PromotionState `json:"state"`
|
||||||
|
Version int `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sf *StateFile) GetPromotion(id string) (*PromotionRule, bool) {
|
||||||
|
for _, v := range sf.State.Promotions {
|
||||||
|
if v.ID == id {
|
||||||
|
return &v, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadStateFile(fileName string) (*StateFile, error) {
|
||||||
|
f, err := os.Open(fileName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
dec := json.NewDecoder(f)
|
||||||
|
sf := &StateFile{}
|
||||||
|
err = dec.Decode(sf)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sf, nil
|
||||||
|
}
|
||||||
186
pkg/proxy/remotehost.go
Normal file
186
pkg/proxy/remotehost.go
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
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")
|
||||||
|
|
||||||
|
w.WriteHeader(res.StatusCode)
|
||||||
|
_, copyErr := io.Copy(w, res.Body)
|
||||||
|
if copyErr != nil {
|
||||||
|
return true, copyErr
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RemoteHost) IsHealthy() bool {
|
||||||
|
return r.missedPings < 3
|
||||||
|
}
|
||||||
343
pkg/voucher/parser.go
Normal file
343
pkg/voucher/parser.go
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
package voucher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"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 {
|
||||||
|
return slices.ContainsFunc(items, pred)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
205
pkg/voucher/parser_test.go
Normal file
205
pkg/voucher/parser_test.go
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
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 TestRuleCartTotal(t *testing.T) {
|
||||||
|
rs, err := ParseRules("min_total>=500000")
|
||||||
|
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 != RuleMinTotal {
|
||||||
|
t.Fatalf("expected kind cart total got %s", c.Kind)
|
||||||
|
}
|
||||||
|
ctx := EvalContext{
|
||||||
|
Items: []Item{
|
||||||
|
Item{
|
||||||
|
Sku: "123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CartTotalInc: 400000,
|
||||||
|
}
|
||||||
|
applied := rs.Applies(ctx)
|
||||||
|
if applied {
|
||||||
|
t.Fatalf("expected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ~~")
|
||||||
|
}
|
||||||
84
pkg/voucher/service.go
Normal file
84
pkg/voucher/service.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package voucher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"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:"value"`
|
||||||
|
Rules string `json:"rules"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
sf, err := LoadStateFile("data/vouchers.json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
v, ok := sf.GetVoucher(code)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("no voucher found for code: %s", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &messages.AddVoucher{
|
||||||
|
Code: code,
|
||||||
|
Value: v.Value,
|
||||||
|
Description: v.Description,
|
||||||
|
VoucherRules: []string{
|
||||||
|
v.Rules,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type State struct {
|
||||||
|
Vouchers []Voucher `json:"vouchers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StateFile struct {
|
||||||
|
State State `json:"state"`
|
||||||
|
Version int `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sf *StateFile) GetVoucher(code string) (*Voucher, bool) {
|
||||||
|
for _, v := range sf.State.Vouchers {
|
||||||
|
if v.Code == code {
|
||||||
|
return &v, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadStateFile(fileName string) (*StateFile, error) {
|
||||||
|
f, err := os.Open(fileName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
dec := json.NewDecoder(f)
|
||||||
|
sf := &StateFile{}
|
||||||
|
err = dec.Decode(sf)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sf, nil
|
||||||
|
}
|
||||||
398
pool-server.go
398
pool-server.go
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user