Compare commits
97 Commits
refactor/g
...
5223fef2fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5223fef2fa | ||
|
|
7161c2a8b6 | ||
|
|
cc6d48c879 | ||
|
|
756a43b342 | ||
|
|
81246fe497 | ||
|
|
3fa0009b95 | ||
|
|
19fc5a9553 | ||
|
|
ab5d9cb2b7 | ||
|
|
5d4d917f6a | ||
|
|
af7ce20557 | ||
|
|
ed9a02227e | ||
|
|
34e0445857 | ||
| 61457bce6b | |||
| 8bf29020dd | |||
| 44d7c1faad | |||
|
|
caab742461 | ||
|
|
b272282b1f | ||
|
|
43fcf69139 | ||
|
|
7d9fd0ebb4 | ||
|
|
e662c7dafa | ||
|
|
d969da428f | ||
|
|
d0325e302e | ||
|
|
1aa12ff8d9 | ||
|
|
68eca49cd0 | ||
|
|
bb80c9ab13 | ||
|
|
dce00fb5e3 | ||
|
|
81e2fb5faa | ||
|
|
01d8d86c7c | ||
|
|
0c4d9e2245 | ||
|
|
8aab59a5f4 | ||
|
|
c1599af40b | ||
|
|
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 |
@@ -3,75 +3,78 @@ run-name: ${{ gitea.actor }} build 🚀
|
|||||||
on: [push]
|
on: [push]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
Metadata:
|
Metadata:
|
||||||
runs-on: arm64
|
runs-on: arm64
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.meta.outputs.version }}
|
version: ${{ steps.meta.outputs.version }}
|
||||||
git_commit: ${{ steps.meta.outputs.git_commit }}
|
git_commit: ${{ steps.meta.outputs.git_commit }}
|
||||||
build_date: ${{ steps.meta.outputs.build_date }}
|
build_date: ${{ steps.meta.outputs.build_date }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- id: meta
|
- id: meta
|
||||||
name: Derive build metadata
|
name: Derive build metadata
|
||||||
run: |
|
run: |
|
||||||
GIT_COMMIT=$(git rev-parse HEAD)
|
GIT_COMMIT=$(git rev-parse HEAD)
|
||||||
if git describe --tags --exact-match >/dev/null 2>&1; then
|
if git describe --tags --exact-match >/dev/null 2>&1; then
|
||||||
VERSION=$(git describe --tags --exact-match)
|
VERSION=$(git describe --tags --exact-match)
|
||||||
else
|
else
|
||||||
VERSION=$(git rev-parse --short=12 HEAD)
|
VERSION=$(git rev-parse --short=12 HEAD)
|
||||||
fi
|
fi
|
||||||
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
echo "git_commit=$GIT_COMMIT" >> $GITHUB_OUTPUT
|
echo "git_commit=$GIT_COMMIT" >> $GITHUB_OUTPUT
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "build_date=$BUILD_DATE" >> $GITHUB_OUTPUT
|
echo "build_date=$BUILD_DATE" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
BuildAndDeployAmd64:
|
BuildAndDeployAmd64:
|
||||||
needs: Metadata
|
needs: Metadata
|
||||||
runs-on: amd64
|
runs-on: amd64
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Build amd64 image
|
- name: Build amd64 image
|
||||||
run: |
|
run: |
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg VERSION=${{ needs.Metadata.outputs.version }} \
|
--build-arg VERSION=${{ needs.Metadata.outputs.version }} \
|
||||||
--build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \
|
--build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \
|
||||||
--build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \
|
--build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \
|
||||||
--progress=plain \
|
--progress=plain \
|
||||||
-t registry.knatofs.se/go-cart-actor-amd64:latest \
|
-t registry.knatofs.se/go-cart-actor-amd64:latest \
|
||||||
-t registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }} \
|
-t registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }} \
|
||||||
.
|
.
|
||||||
- name: Push amd64 images
|
- name: Push amd64 images
|
||||||
run: |
|
run: |
|
||||||
docker push registry.knatofs.se/go-cart-actor-amd64:latest
|
docker push registry.knatofs.se/go-cart-actor-amd64:latest
|
||||||
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 deployment (pin to version)
|
- name: Rollout amd64 backoffice deployment
|
||||||
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 rollout restart deployment/cart-backoffice-x86 -n cart
|
||||||
kubectl rollout status deployment/cart-actor-x86 -n cart
|
- name: Rollout amd64 deployment (pin to version)
|
||||||
|
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 rollout status deployment/cart-actor-x86 -n cart
|
||||||
|
|
||||||
BuildAndDeployArm64:
|
BuildAndDeployArm64:
|
||||||
needs: Metadata
|
needs: Metadata
|
||||||
runs-on: arm64
|
runs-on: arm64
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Build arm64 image
|
- name: Build arm64 image
|
||||||
run: |
|
run: |
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg VERSION=${{ needs.Metadata.outputs.version }} \
|
--build-arg VERSION=${{ needs.Metadata.outputs.version }} \
|
||||||
--build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \
|
--build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \
|
||||||
--build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \
|
--build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \
|
||||||
--progress=plain \
|
--progress=plain \
|
||||||
-t registry.knatofs.se/go-cart-actor:latest \
|
-t registry.knatofs.se/go-cart-actor:latest \
|
||||||
-t registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }} \
|
-t registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }} \
|
||||||
.
|
.
|
||||||
- name: Push arm64 images
|
- name: Push arm64 images
|
||||||
run: |
|
run: |
|
||||||
docker push registry.knatofs.se/go-cart-actor:latest
|
docker push registry.knatofs.se/go-cart-actor:latest
|
||||||
docker push registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }}
|
docker push registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }}
|
||||||
- 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
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@ __debug*
|
|||||||
go-cart-actor
|
go-cart-actor
|
||||||
data/*.prot
|
data/*.prot
|
||||||
data/*.go*
|
data/*.go*
|
||||||
|
data/se/*
|
||||||
24
Dockerfile
24
Dockerfile
@@ -54,10 +54,24 @@ COPY . .
|
|||||||
# Build with minimal binary size and embedded metadata
|
# Build with minimal binary size and embedded metadata
|
||||||
RUN --mount=type=cache,target=/go/build-cache \
|
RUN --mount=type=cache,target=/go/build-cache \
|
||||||
go build -trimpath -ldflags="-s -w \
|
go build -trimpath -ldflags="-s -w \
|
||||||
-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
|
||||||
|
|
||||||
|
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-inventory ./cmd/inventory
|
||||||
|
|
||||||
############################
|
############################
|
||||||
# Runtime Stage
|
# Runtime Stage
|
||||||
@@ -67,6 +81,8 @@ 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
|
||||||
|
COPY --from=build /out/go-cart-inventory /go-cart-inventory
|
||||||
|
|
||||||
# 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
|
||||||
|
}
|
||||||
64
cmd/cart/amqp-order-handler.go
Normal file
64
cmd/cart/amqp-order-handler.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AmqpOrderHandler struct {
|
||||||
|
conn *amqp.Connection
|
||||||
|
queue *amqp.Queue
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
queue, err := ch.QueueDeclare(
|
||||||
|
"orders", // name
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to declare an exchange: %w", err)
|
||||||
|
}
|
||||||
|
h.queue = &queue
|
||||||
|
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,
|
||||||
|
"", // exchange
|
||||||
|
"orders", // routing key
|
||||||
|
false, // mandatory
|
||||||
|
false, // immediate
|
||||||
|
amqp.Publishing{
|
||||||
|
DeliveryMode: amqp.Persistent,
|
||||||
|
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{
|
||||||
155
cmd/cart/checkout_server.go
Normal file
155
cmd/cart/checkout_server.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
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"
|
||||||
|
"git.tornberg.me/mats/go-redis-inventory/pkg/inventory"
|
||||||
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 (a *App) HandleCheckoutRequests(amqpUrl string, mux *http.ServeMux, inventoryService inventory.InventoryService) {
|
||||||
|
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("POST /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(r.Context(), orderId)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error acknowledging order: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /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("GET /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("POST /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)
|
||||||
|
cartId, ok := cart.ParseCartId(order.MerchantReference1)
|
||||||
|
if !ok {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
grain, err := a.pool.Get(uint64(cartId))
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if inventoryService != nil {
|
||||||
|
inventoryRequests := getInventoryRequests(grain.Items)
|
||||||
|
err = inventoryService.ReserveInventory(inventoryRequests...)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnContext(r.Context(), "placeorder inventory reservation failed")
|
||||||
|
w.WriteHeader(http.StatusNotAcceptable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -64,13 +65,13 @@ func (k *KlarnaClient) getOrderResponse(res *http.Response) (*CheckoutOrder, err
|
|||||||
return nil, fmt.Errorf("%s", res.Status)
|
return nil, fmt.Errorf("%s", res.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *KlarnaClient) CreateOrder(reader io.Reader) (*CheckoutOrder, error) {
|
func (k *KlarnaClient) CreateOrder(ctx context.Context, reader io.Reader) (*CheckoutOrder, error) {
|
||||||
//bytes.NewReader(reply.Payload)
|
//bytes.NewReader(reply.Payload)
|
||||||
req, err := http.NewRequest("POST", k.Url+"/checkout/v3/orders", reader)
|
req, err := http.NewRequest("POST", k.Url+"/checkout/v3/orders", reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
req.WithContext(ctx)
|
||||||
req.Header.Add("Content-Type", "application/json")
|
req.Header.Add("Content-Type", "application/json")
|
||||||
req.SetBasicAuth(k.UserName, k.Password)
|
req.SetBasicAuth(k.UserName, k.Password)
|
||||||
|
|
||||||
@@ -82,13 +83,14 @@ func (k *KlarnaClient) CreateOrder(reader io.Reader) (*CheckoutOrder, error) {
|
|||||||
return k.getOrderResponse(res)
|
return k.getOrderResponse(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *KlarnaClient) UpdateOrder(orderId string, reader io.Reader) (*CheckoutOrder, error) {
|
func (k *KlarnaClient) UpdateOrder(ctx context.Context, orderId string, reader io.Reader) (*CheckoutOrder, error) {
|
||||||
//bytes.NewReader(reply.Payload)
|
//bytes.NewReader(reply.Payload)
|
||||||
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s", k.Url, orderId), reader)
|
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s", k.Url, orderId), reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
req.WithContext(ctx)
|
||||||
req.Header.Add("Content-Type", "application/json")
|
req.Header.Add("Content-Type", "application/json")
|
||||||
req.SetBasicAuth(k.UserName, k.Password)
|
req.SetBasicAuth(k.UserName, k.Password)
|
||||||
|
|
||||||
@@ -100,12 +102,12 @@ func (k *KlarnaClient) UpdateOrder(orderId string, reader io.Reader) (*CheckoutO
|
|||||||
return k.getOrderResponse(res)
|
return k.getOrderResponse(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *KlarnaClient) AbortOrder(orderId string) error {
|
func (k *KlarnaClient) AbortOrder(ctx context.Context, orderId string) error {
|
||||||
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s/abort", k.Url, orderId), nil)
|
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s/abort", k.Url, orderId), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
req.WithContext(ctx)
|
||||||
req.SetBasicAuth(k.UserName, k.Password)
|
req.SetBasicAuth(k.UserName, k.Password)
|
||||||
|
|
||||||
_, err = http.DefaultClient.Do(req)
|
_, err = http.DefaultClient.Do(req)
|
||||||
@@ -113,11 +115,12 @@ func (k *KlarnaClient) AbortOrder(orderId string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ordermanagement/v1/orders/{order_id}/acknowledge
|
// ordermanagement/v1/orders/{order_id}/acknowledge
|
||||||
func (k *KlarnaClient) AcknowledgeOrder(orderId string) error {
|
func (k *KlarnaClient) AcknowledgeOrder(ctx context.Context, orderId string) error {
|
||||||
req, err := http.NewRequest("POST", fmt.Sprintf("%s/ordermanagement/v1/orders/%s/acknowledge", k.Url, orderId), nil)
|
req, err := http.NewRequest("POST", fmt.Sprintf("%s/ordermanagement/v1/orders/%s/acknowledge", k.Url, orderId), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
req.WithContext(ctx)
|
||||||
id := uuid.New()
|
id := uuid.New()
|
||||||
|
|
||||||
req.SetBasicAuth(k.UserName, k.Password)
|
req.SetBasicAuth(k.UserName, k.Password)
|
||||||
278
cmd/cart/main.go
Normal file
278
cmd/cart/main.go
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
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"
|
||||||
|
"git.tornberg.me/mats/go-redis-inventory/pkg/inventory"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"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 redisAddress = os.Getenv("REDIS_ADDRESS")
|
||||||
|
var redisPassword = os.Getenv("REDIS_PASSWORD")
|
||||||
|
|
||||||
|
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"))
|
||||||
|
rdb := redis.NewClient(&redis.Options{
|
||||||
|
Addr: redisAddress,
|
||||||
|
Password: redisPassword,
|
||||||
|
DB: 0,
|
||||||
|
})
|
||||||
|
inventoryService, err := inventory.NewRedisInventoryService(rdb, context.Background())
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error creating inventory service: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, inventoryService)
|
||||||
|
|
||||||
|
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, inventoryService)
|
||||||
|
}
|
||||||
|
|
||||||
|
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/otlpmetric/otlpmetricgrpc"
|
||||||
|
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||||
|
"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 := otlpmetricgrpc.New(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(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
|
||||||
|
}
|
||||||
674
cmd/cart/pool-server.go
Normal file
674
cmd/cart/pool-server.go
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"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"
|
||||||
|
"git.tornberg.me/mats/go-redis-inventory/pkg/inventory"
|
||||||
|
"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"
|
||||||
|
"go.opentelemetry.io/otel/metric"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
inventoryService inventory.InventoryService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPoolServer(pool actor.GrainPool[*cart.CartGrain], pod_name string, klarnaClient *KlarnaClient, inventoryService inventory.InventoryService) *PoolServer {
|
||||||
|
return &PoolServer{
|
||||||
|
GrainPool: pool,
|
||||||
|
pod_name: pod_name,
|
||||||
|
klarnaClient: klarnaClient,
|
||||||
|
inventoryService: inventoryService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(r.Context(), 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(ctx context.Context, 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(ctx, 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(r.Context(), 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(r.Context(), 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(r.Context(), 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 getLocationId(item *cart.CartItem) inventory.LocationID {
|
||||||
|
if item.StoreId == nil || *item.StoreId == "" {
|
||||||
|
return "se"
|
||||||
|
}
|
||||||
|
return inventory.LocationID(*item.StoreId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
|
||||||
|
var requests []inventory.ReserveRequest
|
||||||
|
for _, item := range items {
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
requests = append(requests, inventory.ReserveRequest{
|
||||||
|
SKU: inventory.SKU(item.Sku),
|
||||||
|
LocationID: getLocationId(item),
|
||||||
|
Quantity: uint32(item.Quantity),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return requests
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) CreateOrUpdateCheckout(ctx context.Context, 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: "https://cart.tornberg.me/validate",
|
||||||
|
Push: "https://cart.tornberg.me/push?order_id={checkout.order.id}",
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if s.inventoryService != nil {
|
||||||
|
inventoryRequests := getInventoryRequests(grain.Items)
|
||||||
|
failingRequest, err := s.inventoryService.ReservationCheck(inventoryRequests...)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnContext(ctx, "inventory check failed", string(failingRequest.SKU), string(failingRequest.LocationID))
|
||||||
|
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(ctx, grain.OrderReference, bytes.NewReader(payload))
|
||||||
|
} else {
|
||||||
|
return s.klarnaClient.CreateOrder(ctx, 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 {
|
||||||
|
ctx, span := tracer.Start(r.Context(), "proxy")
|
||||||
|
defer span.End()
|
||||||
|
span.SetAttributes(attribute.String("cartid", cartId.String()))
|
||||||
|
hostAttr := attribute.String("other host", ownerHost.Name())
|
||||||
|
span.SetAttributes(hostAttr)
|
||||||
|
logger.InfoContext(ctx, "cart proxyed", "result", ownerHost.Name())
|
||||||
|
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
|
||||||
|
handled, err := ownerHost.Proxy(uint64(cartId), w, r)
|
||||||
|
|
||||||
|
grainLookups.Inc()
|
||||||
|
if err == nil && handled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, span := tracer.Start(r.Context(), "own")
|
||||||
|
span.SetAttributes(attribute.String("cartid", cartId.String()))
|
||||||
|
defer span.End()
|
||||||
|
return fn(w, r, cartId)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
tracer = otel.Tracer(name)
|
||||||
|
|
||||||
|
meter = otel.Meter(name)
|
||||||
|
logger = otelslog.NewLogger(name)
|
||||||
|
proxyCalls metric.Int64Counter
|
||||||
|
|
||||||
|
// rollCnt metric.Int64Counter
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
var err error
|
||||||
|
proxyCalls, err = meter.Int64Counter("proxy.calls",
|
||||||
|
metric.WithDescription("Number of proxy calls"),
|
||||||
|
metric.WithUnit("{calls}"))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Context(), r.Host, cartId)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("unable to create klarna session: %v", err)
|
||||||
|
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 /cart", 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)) {
|
||||||
|
attr := attribute.String("http.route", pattern)
|
||||||
|
mux.HandleFunc(pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
span := trace.SpanFromContext(r.Context())
|
||||||
|
span.SetAttributes(attr)
|
||||||
|
|
||||||
|
labeler, _ := otelhttp.LabelerFromContext(r.Context())
|
||||||
|
labeler.Add(attr)
|
||||||
|
|
||||||
|
handlerFunc(w, r)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
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)))
|
||||||
|
|
||||||
|
}
|
||||||
146
cmd/cart/product-fetcher.go
Normal file
146
cmd/cart/product-fetcher.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"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(ctx context.Context, sku string, country string) (*index.DataItem, error) {
|
||||||
|
baseUrl := getBaseUrl(country)
|
||||||
|
req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/by-sku/%s", baseUrl, sku), nil)
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res, err := http.DefaultClient.Do(req)
|
||||||
|
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(ctx context.Context, sku string, qty int, country string, storeId *string) (*messages.AddItem, error) {
|
||||||
|
item, err := FetchItem(ctx, 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
|
||||||
|
}
|
||||||
143
cmd/inventory/main.go
Normal file
143
cmd/inventory/main.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.tornberg.me/mats/go-redis-inventory/pkg/inventory"
|
||||||
|
"github.com/matst80/slask-finder/pkg/index"
|
||||||
|
"github.com/matst80/slask-finder/pkg/messaging"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"github.com/redis/go-redis/v9/maintnotifications"
|
||||||
|
|
||||||
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
service *inventory.RedisInventoryService
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *Server) livezHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("OK"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *Server) readyzHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("OK"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *Server) getInventoryHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Parse path: /inventory/{sku}/{location}
|
||||||
|
path := r.URL.Path
|
||||||
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||||
|
if len(parts) != 3 || parts[0] != "inventory" {
|
||||||
|
http.Error(w, "Invalid path", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sku := inventory.SKU(parts[1])
|
||||||
|
locationID := inventory.LocationID(parts[2])
|
||||||
|
|
||||||
|
quantity, err := srv.service.GetInventory(sku, locationID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := map[string]int64{"quantity": quantity}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
var country = "se"
|
||||||
|
var redisAddress = "10.10.3.18:6379"
|
||||||
|
var redisPassword = "slaskredis"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Override redis config from environment variables if set
|
||||||
|
if addr, ok := os.LookupEnv("REDIS_ADDRESS"); ok {
|
||||||
|
redisAddress = addr
|
||||||
|
}
|
||||||
|
if password, ok := os.LookupEnv("REDIS_PASSWORD"); ok {
|
||||||
|
redisPassword = password
|
||||||
|
}
|
||||||
|
if ctry, ok := os.LookupEnv("COUNTRY"); ok {
|
||||||
|
country = ctry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var ctx = context.Background()
|
||||||
|
rdb := redis.NewClient(&redis.Options{
|
||||||
|
Addr: redisAddress,
|
||||||
|
Password: redisPassword, // 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
|
||||||
|
}
|
||||||
|
|
||||||
|
server := &Server{service: s}
|
||||||
|
|
||||||
|
// Set up HTTP routes
|
||||||
|
http.HandleFunc("/livez", server.livezHandler)
|
||||||
|
http.HandleFunc("/readyz", server.readyzHandler)
|
||||||
|
http.HandleFunc("/inventory/", server.getInventoryHandler)
|
||||||
|
|
||||||
|
stockhandler := &StockHandler{
|
||||||
|
MainStockLocationID: inventory.LocationID(country),
|
||||||
|
rdb: rdb,
|
||||||
|
ctx: ctx,
|
||||||
|
svc: *s,
|
||||||
|
}
|
||||||
|
|
||||||
|
amqpUrl, ok := os.LookupEnv("RABBIT_HOST")
|
||||||
|
if ok {
|
||||||
|
log.Printf("Connecting to rabbitmq")
|
||||||
|
conn, err := amqp.DialConfig(amqpUrl, amqp.Config{
|
||||||
|
Properties: amqp.NewConnectionProperties(),
|
||||||
|
})
|
||||||
|
//a.conn = conn
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to connect to RabbitMQ: %v", err)
|
||||||
|
}
|
||||||
|
ch, err := conn.Channel()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to open a channel: %v", err)
|
||||||
|
}
|
||||||
|
// items listener
|
||||||
|
err = messaging.ListenToTopic(ch, country, "item_added", func(d amqp.Delivery) error {
|
||||||
|
var items []*index.DataItem
|
||||||
|
err := json.Unmarshal(d.Body, &items)
|
||||||
|
if err == nil {
|
||||||
|
log.Printf("Got upserts %d, message count %d", len(items), d.MessageCount)
|
||||||
|
wg := &sync.WaitGroup{}
|
||||||
|
for _, item := range items {
|
||||||
|
stockhandler.HandleItem(item, wg)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
log.Print("Batch done...")
|
||||||
|
} else {
|
||||||
|
log.Printf("Failed to unmarshal upsert message %v", err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to listen to item_added topic: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start HTTP server
|
||||||
|
log.Println("Starting HTTP server on :8080")
|
||||||
|
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||||
|
}
|
||||||
48
cmd/inventory/stockhandler.go
Normal file
48
cmd/inventory/stockhandler.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.tornberg.me/mats/go-redis-inventory/pkg/inventory"
|
||||||
|
"github.com/matst80/slask-finder/pkg/types"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StockHandler struct {
|
||||||
|
rdb *redis.Client
|
||||||
|
ctx context.Context
|
||||||
|
svc inventory.RedisInventoryService
|
||||||
|
MainStockLocationID inventory.LocationID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StockHandler) HandleItem(item types.Item, wg *sync.WaitGroup) {
|
||||||
|
wg.Go(func() {
|
||||||
|
pipe := s.rdb.Pipeline()
|
||||||
|
centralStockString, ok := item.GetStringFieldValue(3)
|
||||||
|
if !ok {
|
||||||
|
centralStockString = "0"
|
||||||
|
}
|
||||||
|
centralStockString = strings.Replace(centralStockString, "+", "", -1)
|
||||||
|
centralStockString = strings.Replace(centralStockString, "<", "", -1)
|
||||||
|
centralStockString = strings.Replace(centralStockString, ">", "", -1)
|
||||||
|
centralStock, err := strconv.ParseInt(centralStockString, 10, 64)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("unable to parse central stock for item %s: %v", item.GetSku(), err)
|
||||||
|
centralStock = 0
|
||||||
|
} else {
|
||||||
|
s.svc.UpdateInventory(pipe, inventory.SKU(item.GetSku()), s.MainStockLocationID, int64(centralStock))
|
||||||
|
}
|
||||||
|
for id, value := range item.GetStock() {
|
||||||
|
s.svc.UpdateInventory(pipe, inventory.SKU(item.GetSku()), inventory.LocationID(id), int64(value))
|
||||||
|
}
|
||||||
|
_, err = pipe.Exec(s.ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("unable to update stock: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
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.
@@ -1,252 +1,468 @@
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
name: klarna-api-credentials
|
name: klarna-api-credentials
|
||||||
data:
|
data:
|
||||||
username: ZjQzZDY3YjEtNzA2Yy00NTk2LTliNTgtYjg1YjU2NDEwZTUw
|
username: ZjQzZDY3YjEtNzA2Yy00NTk2LTliNTgtYjg1YjU2NDEwZTUw
|
||||||
password: a2xhcm5hX3Rlc3RfYXBpX0trUWhWVE5yYVZsV2FsTnhTRVp3Y1ZSSFF5UkVNRmxyY25Kd1AxSndQMGdzWmpRelpEWTNZakV0TnpBMll5MDBOVGsyTFRsaU5UZ3RZamcxWWpVMk5ERXdaVFV3TERFc2JUUkNjRFpWU1RsTllsSk1aMlEyVEc4MmRVODNZMkozUlRaaFdEZDViV3AwYkhGV1JqTjVNQzlaYXow
|
password: a2xhcm5hX3Rlc3RfYXBpX0trUWhWVE5yYVZsV2FsTnhTRVp3Y1ZSSFF5UkVNRmxyY25Kd1AxSndQMGdzWmpRelpEWTNZakV0TnpBMll5MDBOVGsyTFRsaU5UZ3RZamcxWWpVMk5ERXdaVFV3TERFc2JUUkNjRFpWU1RsTllsSk1aMlEyVEc4MmRVODNZMkozUlRaaFdEZDViV3AwYkhGV1JqTjVNQzlaYXow
|
||||||
type: Opaque
|
type: Opaque
|
||||||
---
|
---
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app: cart-actor
|
app: cart-backoffice
|
||||||
arch: amd64
|
arch: amd64
|
||||||
name: cart-actor-x86
|
name: cart-backoffice-x86
|
||||||
spec:
|
spec:
|
||||||
replicas: 0
|
replicas: 1
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: cart-actor
|
app: cart-backoffice
|
||||||
arch: amd64
|
arch: amd64
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app: cart-actor
|
app: cart-backoffice
|
||||||
actor-pool: cart
|
arch: amd64
|
||||||
arch: amd64
|
spec:
|
||||||
spec:
|
affinity:
|
||||||
affinity:
|
nodeAffinity:
|
||||||
nodeAffinity:
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
requiredDuringSchedulingIgnoredDuringExecution:
|
nodeSelectorTerms:
|
||||||
nodeSelectorTerms:
|
- matchExpressions:
|
||||||
- matchExpressions:
|
- key: kubernetes.io/arch
|
||||||
- key: kubernetes.io/arch
|
operator: NotIn
|
||||||
operator: NotIn
|
values:
|
||||||
values:
|
- arm64
|
||||||
- arm64
|
volumes:
|
||||||
volumes:
|
- name: data
|
||||||
- name: data
|
nfs:
|
||||||
nfs:
|
path: /i-data/7a8af061/nfs/cart-actor
|
||||||
path: /i-data/7a8af061/nfs/cart-actor
|
server: 10.10.1.10
|
||||||
server: 10.10.1.10
|
imagePullSecrets:
|
||||||
imagePullSecrets:
|
- name: regcred
|
||||||
- name: regcred
|
serviceAccountName: default
|
||||||
serviceAccountName: default
|
containers:
|
||||||
containers:
|
- image: registry.knatofs.se/go-cart-actor-amd64:latest
|
||||||
- image: registry.knatofs.se/go-cart-actor-amd64:latest
|
name: cart-actor-amd64
|
||||||
name: cart-actor-amd64
|
imagePullPolicy: Always
|
||||||
imagePullPolicy: Always
|
command: ["/go-cart-backoffice"]
|
||||||
lifecycle:
|
lifecycle:
|
||||||
preStop:
|
preStop:
|
||||||
exec:
|
exec:
|
||||||
command: ["sleep", "15"]
|
command: ["sleep", "15"]
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
name: web
|
name: web
|
||||||
- containerPort: 1337
|
livenessProbe:
|
||||||
name: rpc
|
httpGet:
|
||||||
livenessProbe:
|
path: /livez
|
||||||
httpGet:
|
port: web
|
||||||
path: /livez
|
failureThreshold: 1
|
||||||
port: web
|
periodSeconds: 30
|
||||||
failureThreshold: 1
|
readinessProbe:
|
||||||
periodSeconds: 10
|
httpGet:
|
||||||
readinessProbe:
|
path: /readyz
|
||||||
httpGet:
|
port: web
|
||||||
path: /readyz
|
failureThreshold: 2
|
||||||
port: web
|
initialDelaySeconds: 2
|
||||||
failureThreshold: 2
|
periodSeconds: 30
|
||||||
initialDelaySeconds: 2
|
volumeMounts:
|
||||||
periodSeconds: 10
|
- mountPath: "/data"
|
||||||
volumeMounts:
|
name: data
|
||||||
- mountPath: "/data"
|
resources:
|
||||||
name: data
|
limits:
|
||||||
resources:
|
memory: "768Mi"
|
||||||
limits:
|
requests:
|
||||||
memory: "768Mi"
|
memory: "70Mi"
|
||||||
requests:
|
cpu: "1200m"
|
||||||
memory: "70Mi"
|
env:
|
||||||
cpu: "1200m"
|
- name: TZ
|
||||||
env:
|
value: "Europe/Stockholm"
|
||||||
- name: TZ
|
- name: KLARNA_API_USERNAME
|
||||||
value: "Europe/Stockholm"
|
valueFrom:
|
||||||
- name: KLARNA_API_USERNAME
|
secretKeyRef:
|
||||||
valueFrom:
|
name: klarna-api-credentials
|
||||||
secretKeyRef:
|
key: username
|
||||||
name: klarna-api-credentials
|
- name: KLARNA_API_PASSWORD
|
||||||
key: username
|
valueFrom:
|
||||||
- name: KLARNA_API_PASSWORD
|
secretKeyRef:
|
||||||
valueFrom:
|
name: klarna-api-credentials
|
||||||
secretKeyRef:
|
key: password
|
||||||
name: klarna-api-credentials
|
- name: AMQP_URL
|
||||||
key: password
|
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
|
||||||
- name: POD_IP
|
# - name: BASE_URL
|
||||||
valueFrom:
|
# value: "https://s10n-no.tornberg.me"
|
||||||
fieldRef:
|
|
||||||
fieldPath: status.podIP
|
|
||||||
- name: AMQP_URL
|
|
||||||
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
|
|
||||||
# - name: BASE_URL
|
|
||||||
# value: "https://s10n-no.tornberg.me"
|
|
||||||
- name: POD_NAME
|
|
||||||
valueFrom:
|
|
||||||
fieldRef:
|
|
||||||
fieldPath: metadata.name
|
|
||||||
---
|
---
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app: cart-actor
|
app: cart-actor
|
||||||
arch: arm64
|
arch: amd64
|
||||||
name: cart-actor-arm64
|
name: cart-actor-x86
|
||||||
spec:
|
spec:
|
||||||
replicas: 3
|
replicas: 3
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: cart-actor
|
app: cart-actor
|
||||||
arch: arm64
|
arch: amd64
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app: cart-actor
|
app: cart-actor
|
||||||
actor-pool: cart
|
actor-pool: cart
|
||||||
arch: arm64
|
arch: amd64
|
||||||
spec:
|
spec:
|
||||||
affinity:
|
affinity:
|
||||||
nodeAffinity:
|
nodeAffinity:
|
||||||
requiredDuringSchedulingIgnoredDuringExecution:
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
nodeSelectorTerms:
|
nodeSelectorTerms:
|
||||||
- matchExpressions:
|
- matchExpressions:
|
||||||
- key: kubernetes.io/hostname
|
- key: kubernetes.io/arch
|
||||||
operator: NotIn
|
operator: NotIn
|
||||||
values:
|
values:
|
||||||
- masterpi
|
- arm64
|
||||||
- key: kubernetes.io/arch
|
volumes:
|
||||||
operator: In
|
- name: data
|
||||||
values:
|
nfs:
|
||||||
- arm64
|
path: /i-data/7a8af061/nfs/cart-actor
|
||||||
volumes:
|
server: 10.10.1.10
|
||||||
- name: data
|
imagePullSecrets:
|
||||||
nfs:
|
- name: regcred
|
||||||
path: /i-data/7a8af061/nfs/cart-actor
|
serviceAccountName: default
|
||||||
server: 10.10.1.10
|
containers:
|
||||||
imagePullSecrets:
|
- image: registry.knatofs.se/go-cart-actor-amd64:latest
|
||||||
- name: regcred
|
name: cart-actor-amd64
|
||||||
serviceAccountName: default
|
imagePullPolicy: Always
|
||||||
containers:
|
lifecycle:
|
||||||
- image: registry.knatofs.se/go-cart-actor:latest
|
preStop:
|
||||||
name: cart-actor-arm64
|
exec:
|
||||||
imagePullPolicy: Always
|
command: ["sleep", "15"]
|
||||||
lifecycle:
|
ports:
|
||||||
preStop:
|
- containerPort: 8080
|
||||||
exec:
|
name: web
|
||||||
command: ["sleep", "15"]
|
- containerPort: 8081
|
||||||
ports:
|
name: debug
|
||||||
- containerPort: 8080
|
- containerPort: 1337
|
||||||
name: web
|
name: rpc
|
||||||
- containerPort: 1337
|
livenessProbe:
|
||||||
name: rpc
|
httpGet:
|
||||||
livenessProbe:
|
path: /livez
|
||||||
httpGet:
|
port: web
|
||||||
path: /livez
|
failureThreshold: 1
|
||||||
port: web
|
periodSeconds: 30
|
||||||
failureThreshold: 1
|
readinessProbe:
|
||||||
periodSeconds: 10
|
httpGet:
|
||||||
readinessProbe:
|
path: /readyz
|
||||||
httpGet:
|
port: web
|
||||||
path: /readyz
|
failureThreshold: 2
|
||||||
port: web
|
initialDelaySeconds: 2
|
||||||
failureThreshold: 2
|
periodSeconds: 50
|
||||||
initialDelaySeconds: 2
|
volumeMounts:
|
||||||
periodSeconds: 10
|
- mountPath: "/data"
|
||||||
volumeMounts:
|
name: data
|
||||||
- mountPath: "/data"
|
resources:
|
||||||
name: data
|
limits:
|
||||||
resources:
|
memory: "768Mi"
|
||||||
limits:
|
requests:
|
||||||
memory: "768Mi"
|
memory: "70Mi"
|
||||||
requests:
|
cpu: "1200m"
|
||||||
memory: "70Mi"
|
env:
|
||||||
cpu: "1200m"
|
- name: TZ
|
||||||
env:
|
value: "Europe/Stockholm"
|
||||||
- name: TZ
|
- name: KLARNA_API_USERNAME
|
||||||
value: "Europe/Stockholm"
|
valueFrom:
|
||||||
- name: KLARNA_API_USERNAME
|
secretKeyRef:
|
||||||
valueFrom:
|
name: klarna-api-credentials
|
||||||
secretKeyRef:
|
key: username
|
||||||
name: klarna-api-credentials
|
- name: REDIS_ADDRESS
|
||||||
key: username
|
value: "redis.home:6379"
|
||||||
- name: KLARNA_API_PASSWORD
|
- name: REDIS_PASSWORD
|
||||||
valueFrom:
|
value: "slaskredis"
|
||||||
secretKeyRef:
|
- name: OTEL_RESOURCE_ATTRIBUTES
|
||||||
name: klarna-api-credentials
|
value: "service.name=cart,service.version=0.1.2"
|
||||||
key: password
|
- name: OTEL_EXPORTER_OTLP_ENDPOINT
|
||||||
- name: POD_IP
|
value: "http://otel-debug-service.monitoring:4317"
|
||||||
valueFrom:
|
- name: KLARNA_API_PASSWORD
|
||||||
fieldRef:
|
valueFrom:
|
||||||
fieldPath: status.podIP
|
secretKeyRef:
|
||||||
- name: AMQP_URL
|
name: klarna-api-credentials
|
||||||
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
|
key: password
|
||||||
# - name: BASE_URL
|
- name: POD_IP
|
||||||
# value: "https://s10n-no.tornberg.me"
|
valueFrom:
|
||||||
- name: POD_NAME
|
fieldRef:
|
||||||
valueFrom:
|
fieldPath: status.podIP
|
||||||
fieldRef:
|
- name: AMQP_URL
|
||||||
fieldPath: metadata.name
|
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
|
||||||
|
# - name: BASE_URL
|
||||||
|
# value: "https://s10n-no.tornberg.me"
|
||||||
|
- name: POD_NAME
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.name
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: cart-actor
|
||||||
|
arch: arm64
|
||||||
|
name: cart-actor-arm64
|
||||||
|
spec:
|
||||||
|
replicas: 0
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: cart-actor
|
||||||
|
arch: arm64
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: cart-actor
|
||||||
|
actor-pool: cart
|
||||||
|
arch: arm64
|
||||||
|
spec:
|
||||||
|
affinity:
|
||||||
|
nodeAffinity:
|
||||||
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
nodeSelectorTerms:
|
||||||
|
- matchExpressions:
|
||||||
|
- key: kubernetes.io/hostname
|
||||||
|
operator: NotIn
|
||||||
|
values:
|
||||||
|
- masterpi
|
||||||
|
- key: kubernetes.io/arch
|
||||||
|
operator: In
|
||||||
|
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:latest
|
||||||
|
name: cart-actor-arm64
|
||||||
|
imagePullPolicy: Always
|
||||||
|
lifecycle:
|
||||||
|
preStop:
|
||||||
|
exec:
|
||||||
|
command: ["sleep", "15"]
|
||||||
|
ports:
|
||||||
|
- containerPort: 8080
|
||||||
|
name: web
|
||||||
|
- containerPort: 8081
|
||||||
|
name: debug
|
||||||
|
- containerPort: 1337
|
||||||
|
name: rpc
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /livez
|
||||||
|
port: web
|
||||||
|
failureThreshold: 1
|
||||||
|
periodSeconds: 30
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /readyz
|
||||||
|
port: web
|
||||||
|
failureThreshold: 2
|
||||||
|
initialDelaySeconds: 2
|
||||||
|
periodSeconds: 30
|
||||||
|
volumeMounts:
|
||||||
|
- mountPath: "/data"
|
||||||
|
name: data
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: "768Mi"
|
||||||
|
requests:
|
||||||
|
memory: "70Mi"
|
||||||
|
cpu: "1200m"
|
||||||
|
env:
|
||||||
|
- name: TZ
|
||||||
|
value: "Europe/Stockholm"
|
||||||
|
- name: REDIS_ADDRESS
|
||||||
|
value: "redis.home:6379"
|
||||||
|
- name: REDIS_PASSWORD
|
||||||
|
value: "slaskredis"
|
||||||
|
- name: OTEL_RESOURCE_ATTRIBUTES
|
||||||
|
value: "service.name=cart,service.version=0.1.2"
|
||||||
|
- name: OTEL_EXPORTER_OTLP_ENDPOINT
|
||||||
|
value: "http://otel-debug-service.monitoring:4317"
|
||||||
|
- 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: POD_IP
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: status.podIP
|
||||||
|
- name: AMQP_URL
|
||||||
|
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
|
||||||
|
# - name: BASE_URL
|
||||||
|
# value: "https://s10n-no.tornberg.me"
|
||||||
|
- name: POD_NAME
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.name
|
||||||
---
|
---
|
||||||
kind: Service
|
kind: Service
|
||||||
apiVersion: v1
|
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:
|
||||||
selector:
|
selector:
|
||||||
app: cart-actor
|
app: cart-actor
|
||||||
ports:
|
ports:
|
||||||
- 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
|
||||||
tls:
|
tls:
|
||||||
- hosts:
|
- hosts:
|
||||||
- cart.tornberg.me
|
- cart.tornberg.me
|
||||||
secretName: cart-actor-tls-secret
|
secretName: cart-actor-tls-secret
|
||||||
rules:
|
rules:
|
||||||
- host: cart.tornberg.me
|
- host: cart.tornberg.me
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
- path: /
|
- path: /
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
backend:
|
backend:
|
||||||
service:
|
service:
|
||||||
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
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: cart-inventory
|
||||||
|
arch: amd64
|
||||||
|
name: cart-inventory-x86
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: cart-inventory
|
||||||
|
arch: amd64
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: cart-inventory
|
||||||
|
arch: amd64
|
||||||
|
spec:
|
||||||
|
affinity:
|
||||||
|
nodeAffinity:
|
||||||
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
nodeSelectorTerms:
|
||||||
|
- matchExpressions:
|
||||||
|
- key: kubernetes.io/arch
|
||||||
|
operator: NotIn
|
||||||
|
values:
|
||||||
|
- arm64
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: regcred
|
||||||
|
serviceAccountName: default
|
||||||
|
containers:
|
||||||
|
- image: registry.knatofs.se/go-cart-actor-amd64:latest
|
||||||
|
name: cart-inventory-amd64
|
||||||
|
imagePullPolicy: Always
|
||||||
|
command: ["/go-cart-inventory"]
|
||||||
|
lifecycle:
|
||||||
|
preStop:
|
||||||
|
exec:
|
||||||
|
command: ["sleep", "15"]
|
||||||
|
ports:
|
||||||
|
- containerPort: 8080
|
||||||
|
name: web
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /livez
|
||||||
|
port: web
|
||||||
|
failureThreshold: 1
|
||||||
|
periodSeconds: 30
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /readyz
|
||||||
|
port: web
|
||||||
|
failureThreshold: 2
|
||||||
|
initialDelaySeconds: 2
|
||||||
|
periodSeconds: 30
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: "256Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
requests:
|
||||||
|
memory: "50Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
env:
|
||||||
|
- name: TZ
|
||||||
|
value: "Europe/Stockholm"
|
||||||
|
- name: RABBIT_HOST
|
||||||
|
value: amqp://admin:12bananer@rabbitmq.s10n:5672/
|
||||||
|
- name: REDIS_ADDRESS
|
||||||
|
value: "redis.home:6379"
|
||||||
|
- name: REDIS_PASSWORD
|
||||||
|
value: "slaskredis"
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
60
go.mod
60
go.mod
@@ -1,12 +1,26 @@
|
|||||||
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/otlpmetric/otlpmetricgrpc v1.38.0
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0
|
||||||
|
go.opentelemetry.io/otel/log v0.14.0
|
||||||
|
go.opentelemetry.io/otel/metric v1.38.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
|
||||||
|
go.opentelemetry.io/otel/trace 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 +29,21 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/RoaringBitmap/roaring/v2 v2.10.0 // indirect
|
git.tornberg.me/mats/go-redis-inventory v0.0.0-20251110193851-19d7ad0de6e5 // 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 +58,51 @@ 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/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/procfs v0.18.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // 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/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 +111,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
|
||||||
|
|||||||
219
go.sum
219
go.sum
@@ -1,20 +1,43 @@
|
|||||||
github.com/RoaringBitmap/roaring/v2 v2.10.0 h1:HbJ8Cs71lfCJyvmSptxeMX2PtvOC8yonlU0GQcy2Ak0=
|
git.tornberg.me/mats/go-redis-inventory v0.0.0-20251110193851-19d7ad0de6e5 h1:54ZKuqppO6reMmnWOYJaFMlPJK947xnPrv3zDbSuknQ=
|
||||||
github.com/RoaringBitmap/roaring/v2 v2.10.0/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
|
git.tornberg.me/mats/go-redis-inventory v0.0.0-20251110193851-19d7ad0de6e5/go.mod h1:jrDU55O7sdN2RJr99upmig/FAla/mW1Cdju7834TXug=
|
||||||
|
github.com/RoaringBitmap/roaring/v2 v2.13.0 h1:38BxJ6lGPcBLykIRCyYtViB/By3+a/iS9znKsiBbhNc=
|
||||||
|
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 +72,177 @@ 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/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/procfs v0.18.0 h1:2QTA9cKdznfYJz7EDaa7IiJobHuV7E1WzeBwcrhk0ao=
|
||||||
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
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/otlpmetric/otlpmetricgrpc v1.38.0 h1:vl9obrcoWVKp/lwl8tRE33853I8Xru9HFbw/skNeLs8=
|
||||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0/go.mod h1:GAXRxmLJcVM3u22IjTg74zWBrRCKq8BnOqUVLodpcpw=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
|
||||||
|
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/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 +254,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 +305,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)
|
||||||
|
}
|
||||||
233
pkg/actor/grpc_server.go
Normal file
233
pkg/actor/grpc_server.go
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
package actor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
|
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
"go.opentelemetry.io/otel/metric"
|
||||||
|
"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]
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = "grpc_server"
|
||||||
|
|
||||||
|
var (
|
||||||
|
tracer = otel.Tracer(name)
|
||||||
|
meter = otel.Meter(name)
|
||||||
|
logger = otelslog.NewLogger(name)
|
||||||
|
pingCalls metric.Int64Counter
|
||||||
|
negotiateCalls metric.Int64Counter
|
||||||
|
getLocalActorIdsCalls metric.Int64Counter
|
||||||
|
announceOwnershipCalls metric.Int64Counter
|
||||||
|
announceExpiryCalls metric.Int64Counter
|
||||||
|
closingCalls metric.Int64Counter
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
var err error
|
||||||
|
pingCalls, err = meter.Int64Counter("grpc.ping_calls",
|
||||||
|
metric.WithDescription("Number of ping calls"),
|
||||||
|
metric.WithUnit("{calls}"))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
negotiateCalls, err = meter.Int64Counter("grpc.negotiate_calls",
|
||||||
|
metric.WithDescription("Number of negotiate calls"),
|
||||||
|
metric.WithUnit("{calls}"))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
getLocalActorIdsCalls, err = meter.Int64Counter("grpc.get_local_actor_ids_calls",
|
||||||
|
metric.WithDescription("Number of get local actor ids calls"),
|
||||||
|
metric.WithUnit("{calls}"))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
announceOwnershipCalls, err = meter.Int64Counter("grpc.announce_ownership_calls",
|
||||||
|
metric.WithDescription("Number of announce ownership calls"),
|
||||||
|
metric.WithUnit("{calls}"))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
announceExpiryCalls, err = meter.Int64Counter("grpc.announce_expiry_calls",
|
||||||
|
metric.WithDescription("Number of announce expiry calls"),
|
||||||
|
metric.WithUnit("{calls}"))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
closingCalls, err = meter.Int64Counter("grpc.closing_calls",
|
||||||
|
metric.WithDescription("Number of closing calls"),
|
||||||
|
metric.WithUnit("{calls}"))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ControlServer[V]) AnnounceOwnership(ctx context.Context, req *messages.OwnershipAnnounce) (*messages.OwnerChangeAck, error) {
|
||||||
|
ctx, span := tracer.Start(ctx, "grpc_announce_ownership")
|
||||||
|
defer span.End()
|
||||||
|
span.SetAttributes(
|
||||||
|
attribute.String("component", "controlplane"),
|
||||||
|
attribute.String("host", req.Host),
|
||||||
|
attribute.Int("id_count", len(req.Ids)),
|
||||||
|
)
|
||||||
|
logger.InfoContext(ctx, "announce ownership", "host", req.Host, "id_count", len(req.Ids))
|
||||||
|
announceOwnershipCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
|
||||||
|
|
||||||
|
err := s.pool.HandleOwnershipChange(req.Host, req.Ids)
|
||||||
|
if err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
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) {
|
||||||
|
ctx, span := tracer.Start(ctx, "grpc_announce_expiry")
|
||||||
|
defer span.End()
|
||||||
|
span.SetAttributes(
|
||||||
|
attribute.String("component", "controlplane"),
|
||||||
|
attribute.String("host", req.Host),
|
||||||
|
attribute.Int("id_count", len(req.Ids)),
|
||||||
|
)
|
||||||
|
logger.InfoContext(ctx, "announce expiry", "host", req.Host, "id_count", len(req.Ids))
|
||||||
|
announceExpiryCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
|
||||||
|
|
||||||
|
err := s.pool.HandleRemoteExpiry(req.Host, req.Ids)
|
||||||
|
if err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
|
||||||
|
host := s.pool.Hostname()
|
||||||
|
|
||||||
|
pingCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", host)))
|
||||||
|
|
||||||
|
// log.Printf("got ping")
|
||||||
|
return &messages.PingReply{
|
||||||
|
Host: host,
|
||||||
|
UnixTime: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ControlPlane: Negotiate (merge host views)
|
||||||
|
func (s *ControlServer[V]) Negotiate(ctx context.Context, req *messages.NegotiateRequest) (*messages.NegotiateReply, error) {
|
||||||
|
ctx, span := tracer.Start(ctx, "grpc_negotiate")
|
||||||
|
defer span.End()
|
||||||
|
span.SetAttributes(
|
||||||
|
attribute.String("component", "controlplane"),
|
||||||
|
attribute.Int("known_hosts_count", len(req.KnownHosts)),
|
||||||
|
)
|
||||||
|
logger.InfoContext(ctx, "negotiate", "known_hosts_count", len(req.KnownHosts))
|
||||||
|
negotiateCalls.Add(ctx, 1)
|
||||||
|
|
||||||
|
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) {
|
||||||
|
ctx, span := tracer.Start(ctx, "grpc_get_local_actor_ids")
|
||||||
|
defer span.End()
|
||||||
|
ids := s.pool.GetLocalIds()
|
||||||
|
span.SetAttributes(
|
||||||
|
attribute.String("component", "controlplane"),
|
||||||
|
attribute.Int("id_count", len(ids)),
|
||||||
|
)
|
||||||
|
logger.InfoContext(ctx, "get local actor ids", "id_count", len(ids))
|
||||||
|
getLocalActorIdsCalls.Add(ctx, 1)
|
||||||
|
|
||||||
|
return &messages.ActorIdsReply{Ids: ids}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ControlPlane: Closing (peer shutdown notification)
|
||||||
|
func (s *ControlServer[V]) Closing(ctx context.Context, req *messages.ClosingNotice) (*messages.OwnerChangeAck, error) {
|
||||||
|
ctx, span := tracer.Start(ctx, "grpc_closing")
|
||||||
|
defer span.End()
|
||||||
|
host := req.GetHost()
|
||||||
|
span.SetAttributes(
|
||||||
|
attribute.String("component", "controlplane"),
|
||||||
|
attribute.String("host", host),
|
||||||
|
)
|
||||||
|
logger.InfoContext(ctx, "closing notice", "host", host)
|
||||||
|
closingCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", host)))
|
||||||
|
|
||||||
|
if host != "" {
|
||||||
|
s.pool.RemoveHost(host)
|
||||||
|
}
|
||||||
|
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 != 500 {
|
||||||
|
t.Fatalf("TotalDiscount expected 500 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,34 +25,31 @@ 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](
|
|
||||||
"ChangeQuantity",
|
|
||||||
func(g *CartGrain, m *messages.ChangeQuantity) error {
|
|
||||||
if m == nil {
|
|
||||||
return fmt.Errorf("ChangeQuantity: nil payload")
|
|
||||||
}
|
|
||||||
|
|
||||||
foundIndex := -1
|
func ChangeQuantity(g *CartGrain, m *messages.ChangeQuantity) error {
|
||||||
for i, it := range g.Items {
|
if m == nil {
|
||||||
if it.Id == int(m.Id) {
|
return fmt.Errorf("ChangeQuantity: nil payload")
|
||||||
foundIndex = i
|
}
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if foundIndex == -1 {
|
|
||||||
return fmt.Errorf("ChangeQuantity: item id %d not found", m.Id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.Quantity <= 0 {
|
foundIndex := -1
|
||||||
// Remove the item
|
for i, it := range g.Items {
|
||||||
g.Items = append(g.Items[:foundIndex], g.Items[foundIndex+1:]...)
|
if it.Id == uint32(m.Id) {
|
||||||
return nil
|
foundIndex = i
|
||||||
}
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if foundIndex == -1 {
|
||||||
|
return fmt.Errorf("ChangeQuantity: item id %d not found", m.Id)
|
||||||
|
}
|
||||||
|
|
||||||
g.Items[foundIndex].Quantity = int(m.Quantity)
|
if m.Quantity <= 0 {
|
||||||
return nil
|
// Remove the item
|
||||||
},
|
g.Items = append(g.Items[:foundIndex], g.Items[foundIndex+1:]...)
|
||||||
WithTotals(),
|
g.UpdateTotals()
|
||||||
)
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Items[foundIndex].Quantity = int(m.Quantity)
|
||||||
|
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_initialize_checkout.go
|
// mutation_initialize_checkout.go
|
||||||
@@ -28,22 +28,17 @@ 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](
|
|
||||||
"InitializeCheckout",
|
|
||||||
func(g *CartGrain, m *messages.InitializeCheckout) error {
|
|
||||||
if m == nil {
|
|
||||||
return fmt.Errorf("InitializeCheckout: nil payload")
|
|
||||||
}
|
|
||||||
if m.OrderId == "" {
|
|
||||||
return fmt.Errorf("InitializeCheckout: missing orderId")
|
|
||||||
}
|
|
||||||
|
|
||||||
g.OrderReference = m.OrderId
|
func InitializeCheckout(g *CartGrain, m *messages.InitializeCheckout) error {
|
||||||
g.PaymentStatus = m.Status
|
if m == nil {
|
||||||
g.PaymentInProgress = m.PaymentInProgress
|
return fmt.Errorf("InitializeCheckout: nil payload")
|
||||||
return nil
|
}
|
||||||
},
|
if m.OrderId == "" {
|
||||||
// No WithTotals(): monetary aggregates are unaffected.
|
return fmt.Errorf("InitializeCheckout: missing orderId")
|
||||||
)
|
}
|
||||||
|
|
||||||
|
g.OrderReference = m.OrderId
|
||||||
|
g.PaymentStatus = m.Status
|
||||||
|
g.PaymentInProgress = m.PaymentInProgress
|
||||||
|
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_order_created.go
|
// mutation_order_created.go
|
||||||
@@ -32,22 +32,17 @@ 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](
|
|
||||||
"OrderCreated",
|
|
||||||
func(g *CartGrain, m *messages.OrderCreated) error {
|
|
||||||
if m == nil {
|
|
||||||
return fmt.Errorf("OrderCreated: nil payload")
|
|
||||||
}
|
|
||||||
if m.OrderId == "" {
|
|
||||||
return fmt.Errorf("OrderCreated: missing orderId")
|
|
||||||
}
|
|
||||||
|
|
||||||
g.OrderReference = m.OrderId
|
func OrderCreated(g *CartGrain, m *messages.OrderCreated) error {
|
||||||
g.PaymentStatus = m.Status
|
if m == nil {
|
||||||
g.PaymentInProgress = false
|
return fmt.Errorf("OrderCreated: nil payload")
|
||||||
return nil
|
}
|
||||||
},
|
if m.OrderId == "" {
|
||||||
// No WithTotals(): order completion does not modify pricing or taxes.
|
return fmt.Errorf("OrderCreated: missing orderId")
|
||||||
)
|
}
|
||||||
|
|
||||||
|
g.OrderReference = m.OrderId
|
||||||
|
g.PaymentStatus = m.Status
|
||||||
|
g.PaymentInProgress = false
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
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,29 +25,25 @@ 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](
|
|
||||||
"RemoveDelivery",
|
|
||||||
func(g *CartGrain, m *messages.RemoveDelivery) error {
|
|
||||||
if m == nil {
|
|
||||||
return fmt.Errorf("RemoveDelivery: nil payload")
|
|
||||||
}
|
|
||||||
targetID := int(m.Id)
|
|
||||||
index := -1
|
|
||||||
for i, d := range g.Deliveries {
|
|
||||||
if d.Id == targetID {
|
|
||||||
index = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if index == -1 {
|
|
||||||
return fmt.Errorf("RemoveDelivery: delivery id %d not found", m.Id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove delivery (order not preserved beyond necessity)
|
func RemoveDelivery(g *CartGrain, m *messages.RemoveDelivery) error {
|
||||||
g.Deliveries = append(g.Deliveries[:index], g.Deliveries[index+1:]...)
|
if m == nil {
|
||||||
return nil
|
return fmt.Errorf("RemoveDelivery: nil payload")
|
||||||
},
|
}
|
||||||
WithTotals(),
|
targetID := uint32(m.Id)
|
||||||
)
|
index := -1
|
||||||
|
for i, d := range g.Deliveries {
|
||||||
|
if d.Id == targetID {
|
||||||
|
index = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if index == -1 {
|
||||||
|
return fmt.Errorf("RemoveDelivery: delivery id %d not found", m.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove delivery (order not preserved beyond necessity)
|
||||||
|
g.Deliveries = append(g.Deliveries[:index], g.Deliveries[index+1:]...)
|
||||||
|
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_remove_item.go
|
// mutation_remove_item.go
|
||||||
@@ -21,29 +21,25 @@ 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](
|
|
||||||
"RemoveItem",
|
|
||||||
func(g *CartGrain, m *messages.RemoveItem) error {
|
|
||||||
if m == nil {
|
|
||||||
return fmt.Errorf("RemoveItem: nil payload")
|
|
||||||
}
|
|
||||||
targetID := int(m.Id)
|
|
||||||
|
|
||||||
index := -1
|
func RemoveItem(g *CartGrain, m *messages.RemoveItem) error {
|
||||||
for i, it := range g.Items {
|
if m == nil {
|
||||||
if it.Id == targetID {
|
return fmt.Errorf("RemoveItem: nil payload")
|
||||||
index = i
|
}
|
||||||
break
|
targetID := uint32(m.Id)
|
||||||
}
|
|
||||||
}
|
|
||||||
if index == -1 {
|
|
||||||
return fmt.Errorf("RemoveItem: item id %d not found", m.Id)
|
|
||||||
}
|
|
||||||
|
|
||||||
g.Items = append(g.Items[:index], g.Items[index+1:]...)
|
index := -1
|
||||||
return nil
|
for i, it := range g.Items {
|
||||||
},
|
if it.Id == targetID {
|
||||||
WithTotals(),
|
index = i
|
||||||
)
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if index == -1 {
|
||||||
|
return fmt.Errorf("RemoveItem: item id %d not found", m.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Items = append(g.Items[:index], g.Items[index+1:]...)
|
||||||
|
g.UpdateTotals()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
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,29 +28,35 @@ 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](
|
|
||||||
"SetPickupPoint",
|
|
||||||
func(g *CartGrain, m *messages.SetPickupPoint) error {
|
|
||||||
if m == nil {
|
|
||||||
return fmt.Errorf("SetPickupPoint: nil payload")
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, d := range g.Deliveries {
|
func SetPickupPoint(g *CartGrain, m *messages.SetPickupPoint) error {
|
||||||
if d.Id == int(m.DeliveryId) {
|
if m == nil {
|
||||||
d.PickupPoint = &messages.PickupPoint{
|
return fmt.Errorf("SetPickupPoint: nil payload")
|
||||||
Id: m.Id,
|
}
|
||||||
Name: m.Name,
|
|
||||||
Address: m.Address,
|
for _, d := range g.Deliveries {
|
||||||
City: m.City,
|
if d.Id == uint32(m.DeliveryId) {
|
||||||
Zip: m.Zip,
|
d.PickupPoint = &messages.PickupPoint{
|
||||||
Country: m.Country,
|
Id: m.Id,
|
||||||
}
|
Name: m.Name,
|
||||||
return nil
|
Address: m.Address,
|
||||||
}
|
City: m.City,
|
||||||
|
Zip: m.Zip,
|
||||||
|
Country: m.Country,
|
||||||
}
|
}
|
||||||
return fmt.Errorf("SetPickupPoint: delivery id %d not found", m.DeliveryId)
|
return nil
|
||||||
},
|
}
|
||||||
// No WithTotals(): pickup point does not change pricing / tax.
|
}
|
||||||
)
|
return fmt.Errorf("SetPickupPoint: delivery id %d not found", m.DeliveryId)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -19,10 +19,12 @@ import (
|
|||||||
const _ = grpc.SupportPackageIsVersion9
|
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_Closing_FullMethodName = "/messages.ControlPlane/Closing"
|
ControlPlane_AnnounceOwnership_FullMethodName = "/messages.ControlPlane/AnnounceOwnership"
|
||||||
|
ControlPlane_AnnounceExpiry_FullMethodName = "/messages.ControlPlane/AnnounceExpiry"
|
||||||
|
ControlPlane_Closing_FullMethodName = "/messages.ControlPlane/Closing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ControlPlaneClient is the client API for ControlPlane service.
|
// ControlPlaneClient is the client API for ControlPlane service.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
214
pkg/proxy/remotehost.go
Normal file
214
pkg/proxy/remotehost.go
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
|
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = "proxy"
|
||||||
|
|
||||||
|
var (
|
||||||
|
tracer = otel.Tracer(name)
|
||||||
|
meter = otel.Meter(name)
|
||||||
|
logger = otelslog.NewLogger(name)
|
||||||
|
)
|
||||||
|
|
||||||
|
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", 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())
|
||||||
|
|
||||||
|
log.Printf("proxy target: %s, method: %s", target, r.Method)
|
||||||
|
|
||||||
|
ctx, span := tracer.Start(r.Context(), "remote_proxy")
|
||||||
|
defer span.End()
|
||||||
|
span.SetAttributes(
|
||||||
|
attribute.String("component", "proxy"),
|
||||||
|
attribute.String("cartid", fmt.Sprintf("%d", id)),
|
||||||
|
attribute.String("host", h.host),
|
||||||
|
attribute.String("method", r.Method),
|
||||||
|
attribute.String("target", target),
|
||||||
|
)
|
||||||
|
logger.InfoContext(ctx, "proxying request", "cartid", id, "host", h.host, "method", r.Method)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, r.Method, target, r.Body)
|
||||||
|
if err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
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 {
|
||||||
|
span.RecordError(err)
|
||||||
|
http.Error(w, "proxy request error", http.StatusBadGateway)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
span.SetAttributes(attribute.Int("status_code", res.StatusCode))
|
||||||
|
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 {
|
||||||
|
span.RecordError(copyErr)
|
||||||
|
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
@@ -1,187 +0,0 @@
|
|||||||
syntax = "proto3";
|
|
||||||
|
|
||||||
package messages;
|
|
||||||
|
|
||||||
option go_package = "git.tornberg.me/go-cart-actor/proto;messages";
|
|
||||||
|
|
||||||
import "messages.proto";
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Cart Actor gRPC API (Breaking v2 - Per-Mutation RPCs)
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// This version removes the previous MutationEnvelope + Mutate RPC.
|
|
||||||
// Each mutation now has its own request wrapper and dedicated RPC method
|
|
||||||
// providing simpler, type-focused client stubs and enabling per-mutation
|
|
||||||
// metrics, auth and rate limiting.
|
|
||||||
//
|
|
||||||
// Regenerate Go code after editing:
|
|
||||||
// protoc --go_out=. --go_opt=paths=source_relative \
|
|
||||||
// --go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
|
||||||
// proto/cart_actor.proto proto/messages.proto
|
|
||||||
//
|
|
||||||
// Backward compatibility: This is a breaking change (old clients must update).
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Shared reply for all mutation RPCs.
|
|
||||||
message CartMutationReply {
|
|
||||||
int32 status_code = 1; // HTTP-like status (200 success, 4xx client, 5xx server)
|
|
||||||
oneof result {
|
|
||||||
CartState state = 2; // Updated cart state on success
|
|
||||||
string error = 3; // Error message on failure
|
|
||||||
}
|
|
||||||
int64 server_timestamp = 4; // Server-assigned Unix timestamp (optional auditing)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch current cart state without mutation.
|
|
||||||
message StateRequest {
|
|
||||||
string cart_id = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
message StateReply {
|
|
||||||
int32 status_code = 1;
|
|
||||||
oneof result {
|
|
||||||
CartState state = 2;
|
|
||||||
string error = 3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-mutation request wrappers. We wrap the existing inner mutation
|
|
||||||
// messages (defined in messages.proto) to add cart_id + optional metadata
|
|
||||||
// without altering the inner message definitions.
|
|
||||||
|
|
||||||
message AddRequestRequest {
|
|
||||||
string cart_id = 1;
|
|
||||||
int64 client_timestamp = 2;
|
|
||||||
AddRequest payload = 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
message AddItemRequest {
|
|
||||||
string cart_id = 1;
|
|
||||||
int64 client_timestamp = 2;
|
|
||||||
AddItem payload = 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
message RemoveItemRequest {
|
|
||||||
string cart_id = 1;
|
|
||||||
int64 client_timestamp = 2;
|
|
||||||
RemoveItem payload = 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
message RemoveDeliveryRequest {
|
|
||||||
string cart_id = 1;
|
|
||||||
int64 client_timestamp = 2;
|
|
||||||
RemoveDelivery payload = 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
message ChangeQuantityRequest {
|
|
||||||
string cart_id = 1;
|
|
||||||
int64 client_timestamp = 2;
|
|
||||||
ChangeQuantity payload = 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
message SetDeliveryRequest {
|
|
||||||
string cart_id = 1;
|
|
||||||
int64 client_timestamp = 2;
|
|
||||||
SetDelivery payload = 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
message SetPickupPointRequest {
|
|
||||||
string cart_id = 1;
|
|
||||||
int64 client_timestamp = 2;
|
|
||||||
SetPickupPoint payload = 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
message CreateCheckoutOrderRequest {
|
|
||||||
string cart_id = 1;
|
|
||||||
int64 client_timestamp = 2;
|
|
||||||
CreateCheckoutOrder payload = 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
message SetCartItemsRequest {
|
|
||||||
string cart_id = 1;
|
|
||||||
int64 client_timestamp = 2;
|
|
||||||
SetCartRequest payload = 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
message OrderCompletedRequest {
|
|
||||||
string cart_id = 1;
|
|
||||||
int64 client_timestamp = 2;
|
|
||||||
OrderCreated payload = 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Excerpt: updated messages for camelCase JSON output
|
|
||||||
message CartState {
|
|
||||||
string id = 1; // was cart_id
|
|
||||||
repeated CartItemState items = 2;
|
|
||||||
int64 totalPrice = 3; // was total_price
|
|
||||||
int64 totalTax = 4; // was total_tax
|
|
||||||
int64 totalDiscount = 5; // was total_discount
|
|
||||||
repeated DeliveryState deliveries = 6;
|
|
||||||
bool paymentInProgress = 7; // was payment_in_progress
|
|
||||||
string orderReference = 8; // was order_reference
|
|
||||||
string paymentStatus = 9; // was payment_status
|
|
||||||
bool processing = 10; // NEW (mirrors legacy CartGrain.processing)
|
|
||||||
}
|
|
||||||
|
|
||||||
message CartItemState {
|
|
||||||
int64 id = 1;
|
|
||||||
int64 itemId = 2; // was source_item_id
|
|
||||||
string sku = 3;
|
|
||||||
string name = 4;
|
|
||||||
int64 price = 5; // was unit_price
|
|
||||||
int32 qty = 6; // was quantity
|
|
||||||
int64 totalPrice = 7; // was total_price
|
|
||||||
int64 totalTax = 8; // was total_tax
|
|
||||||
int64 orgPrice = 9; // was org_price
|
|
||||||
int32 taxRate = 10; // was tax_rate
|
|
||||||
int64 totalDiscount = 11;
|
|
||||||
string brand = 12;
|
|
||||||
string category = 13;
|
|
||||||
string category2 = 14;
|
|
||||||
string category3 = 15;
|
|
||||||
string category4 = 16;
|
|
||||||
string category5 = 17;
|
|
||||||
string image = 18;
|
|
||||||
string type = 19; // was article_type
|
|
||||||
string sellerId = 20; // was seller_id
|
|
||||||
string sellerName = 21; // was seller_name
|
|
||||||
string disclaimer = 22;
|
|
||||||
string outlet = 23;
|
|
||||||
string storeId = 24; // was store_id
|
|
||||||
int32 stock = 25;
|
|
||||||
}
|
|
||||||
|
|
||||||
message DeliveryState {
|
|
||||||
int64 id = 1;
|
|
||||||
string provider = 2;
|
|
||||||
int64 price = 3;
|
|
||||||
repeated int64 items = 4; // was item_ids
|
|
||||||
PickupPoint pickupPoint = 5; // was pickup_point
|
|
||||||
}
|
|
||||||
|
|
||||||
// (CheckoutRequest / CheckoutReply removed - checkout handled at HTTP layer)
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Service definition (per-mutation RPCs + checkout)
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
service CartActor {
|
|
||||||
rpc AddRequest(AddRequestRequest) returns (CartMutationReply);
|
|
||||||
rpc AddItem(AddItemRequest) returns (CartMutationReply);
|
|
||||||
rpc RemoveItem(RemoveItemRequest) returns (CartMutationReply);
|
|
||||||
rpc RemoveDelivery(RemoveDeliveryRequest) returns (CartMutationReply);
|
|
||||||
rpc ChangeQuantity(ChangeQuantityRequest) returns (CartMutationReply);
|
|
||||||
rpc SetDelivery(SetDeliveryRequest) returns (CartMutationReply);
|
|
||||||
rpc SetPickupPoint(SetPickupPointRequest) returns (CartMutationReply);
|
|
||||||
// (Checkout RPC removed - handled externally)
|
|
||||||
rpc SetCartItems(SetCartItemsRequest) returns (CartMutationReply);
|
|
||||||
rpc OrderCompleted(OrderCompletedRequest) returns (CartMutationReply);
|
|
||||||
|
|
||||||
rpc GetState(StateRequest) returns (StateReply);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Future enhancements:
|
|
||||||
// * BatchMutate RPC (repeated heterogeneous mutations)
|
|
||||||
// * Streaming state updates (WatchState)
|
|
||||||
// * Versioning / optimistic concurrency control
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user