31 Commits

Author SHA1 Message Date
matst80
ea35871676 test
Some checks failed
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m40s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2025-10-12 23:18:55 +02:00
matst80
7ad28966fb longer time
All checks were successful
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m10s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m47s
2025-10-12 23:06:11 +02:00
matst80
a7a778caaf mer
All checks were successful
Build and Publish / Metadata (push) Successful in 6s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m8s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m11s
2025-10-12 23:00:17 +02:00
matst80
873fb6c97b test
Some checks failed
Build and Publish / Metadata (push) Successful in 3s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m24s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-12 22:57:27 +02:00
matst80
33ef868295 faster deployment
All checks were successful
Build and Publish / Metadata (push) Successful in 6s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m4s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m20s
2025-10-12 22:49:12 +02:00
matst80
8d73f856bf test
Some checks failed
Build and Publish / Metadata (push) Successful in 5s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 48s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-12 22:47:13 +02:00
matst80
b591e3d3f5 update
All checks were successful
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 47s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m18s
2025-10-12 22:26:05 +02:00
matst80
b8266d80f9 more stuff
All checks were successful
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m50s
2025-10-12 21:36:00 +02:00
0ba7410162 even more refactoring
All checks were successful
Build and Publish / Metadata (push) Successful in 6s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 46s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m8s
2025-10-11 18:17:31 +02:00
9df2f3362a some cleanup and annonce expiry
All checks were successful
Build and Publish / Metadata (push) Successful in 6s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 46s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m8s
2025-10-11 17:42:37 +02:00
6345d91ef7 more cleanup 2025-10-11 10:34:48 +02:00
4cacc0ee2d connection
All checks were successful
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 50s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m10s
2025-10-11 10:29:44 +02:00
24cd0b6ad7 major refactor
All checks were successful
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m49s
2025-10-11 10:22:47 +02:00
matst80
e48a2590bd change ids
All checks were successful
Build and Publish / Metadata (push) Successful in 3s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 50s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m25s
2025-10-10 21:50:18 +00:00
matst80
b0e6c8eca8 add tests and grafana dashboard
All checks were successful
Build and Publish / Metadata (push) Successful in 7s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 47s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m4s
2025-10-10 20:45:42 +00:00
matst80
7814f33a06 some strange storage stuff
Some checks failed
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 48s
Build and Publish / BuildAndDeployArm64 (push) Failing after 26m40s
2025-10-10 19:31:06 +00:00
matst80
fb111ebf97 cleanup and http proxy
All checks were successful
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 47s
Build and Publish / BuildAndDeployArm64 (push) Successful in 8m9s
2025-10-10 18:44:31 +00:00
matst80
5525e91ecc refactor once again
All checks were successful
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 52s
Build and Publish / BuildAndDeployArm64 (push) Successful in 8m23s
2025-10-10 18:34:46 +00:00
matst80
f8c8ad56c7 fix checkout again
All checks were successful
Build and Publish / Metadata (push) Successful in 5s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 47s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m0s
2025-10-10 16:00:20 +00:00
matst80
09a68db8d5 update propertynames
All checks were successful
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 49s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m56s
2025-10-10 14:43:51 +00:00
matst80
30c89a0394 metadata on arm
All checks were successful
Build and Publish / Metadata (push) Successful in 4s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m11s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 44s
2025-10-10 13:59:27 +00:00
matst80
d6563d0b3a fix docker build
Some checks failed
Build and Publish / Metadata (push) Has been cancelled
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-10 13:56:48 +00:00
matst80
2a2ce247d5 more stuff
Some checks failed
Build and Publish / BuildAndDeploy (push) Successful in 4m45s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2025-10-10 13:47:42 +00:00
matst80
159253b8b0 more refactoring
Some checks failed
Build and Publish / BuildAndDeploy (push) Successful in 3m6s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2025-10-10 13:22:36 +00:00
matst80
c30be581cd revert port
Some checks failed
Build and Publish / BuildAndDeploy (push) Successful in 3m2s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2025-10-10 12:10:37 +00:00
matst80
716f1121aa even more refactoring
Some checks failed
Build and Publish / BuildAndDeploy (push) Successful in 3m7s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2025-10-10 11:46:19 +00:00
matst80
12d87036f6 more 2025-10-10 09:35:47 +00:00
matst80
e7c67fbb9b more changes 2025-10-10 09:34:40 +00:00
matst80
b97eb8f285 upgrade cartgrain
All checks were successful
Build and Publish / BuildAndDeploy (push) Successful in 2m52s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 41s
2025-10-10 07:35:49 +00:00
matst80
2697832d98 upgrade deps
Some checks failed
Build and Publish / BuildAndDeploy (push) Failing after 3m25s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 31s
2025-10-10 07:21:50 +00:00
matst80
4c973b239f complete rewrite to grpc 2025-10-10 06:45:23 +00:00
70 changed files with 6194 additions and 4000 deletions

68
.dockerignore Normal file
View File

@@ -0,0 +1,68 @@
# .dockerignore for go-cart-actor
#
# Goal: Keep Docker build context lean & reproducible.
# Adjust as project structure evolves.
# Version control & CI metadata
.git
.git/
.gitignore
.github
# Local tooling / editors
.vscode
.idea
*.iml
# Build artifacts / outputs
bin/
build/
dist/
out/
coverage/
*.coverprofile
# Temporary files
*.tmp
*.log
tmp/
.tmp/
# Dependency/vendor caches (not used; rely on go modules download)
vendor/
# Examples / scripts (adjust if you actually need them in build context)
examples/
scripts/
# Docs (retain README.md explicitly)
docs/
CHANGELOG*
**/*.md
!README.md
# Tests (not needed for production build)
**/*_test.go
# Node / frontend artifacts (if any future addition)
node_modules/
# Docker / container metadata not needed inside image
Dockerfile
# Editor swap/backup files
*~
*.swp
# Go race / profiling outputs
*.pprof
# Security / secret placeholders (ensure real secrets never copied)
*.secret
*.key
*.pem
# Keep proto and generated code (do NOT ignore proto/)
!proto/
# End of file

View File

@@ -1,30 +1,77 @@
name: Build and Publish name: Build and Publish
run-name: ${{ gitea.actor }} is building 🚀 run-name: ${{ gitea.actor }} build 🚀
on: [push] on: [push]
jobs: jobs:
Metadata:
runs-on: arm64
outputs:
version: ${{ steps.meta.outputs.version }}
git_commit: ${{ steps.meta.outputs.git_commit }}
build_date: ${{ steps.meta.outputs.build_date }}
steps:
- name: Checkout
uses: actions/checkout@v4
- id: meta
name: Derive build metadata
run: |
GIT_COMMIT=$(git rev-parse HEAD)
if git describe --tags --exact-match >/dev/null 2>&1; then
VERSION=$(git describe --tags --exact-match)
else
VERSION=$(git rev-parse --short=12 HEAD)
fi
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
echo "git_commit=$GIT_COMMIT" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "build_date=$BUILD_DATE" >> $GITHUB_OUTPUT
BuildAndDeployAmd64: BuildAndDeployAmd64:
needs: Metadata
runs-on: amd64 runs-on: amd64
steps: steps:
- name: Check out repository code - uses: actions/checkout@v4
uses: actions/checkout@v4 - name: Build amd64 image
- name: Build docker image run: |
run: docker build --progress=plain -t registry.knatofs.se/go-cart-actor-amd64:latest . docker build \
- name: Push to registry --build-arg VERSION=${{ needs.Metadata.outputs.version }} \
run: docker push registry.knatofs.se/go-cart-actor-amd64:latest --build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \
- name: Deploy to Kubernetes --build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \
--progress=plain \
-t registry.knatofs.se/go-cart-actor-amd64:latest \
-t registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }} \
.
- name: Push amd64 images
run: |
docker push registry.knatofs.se/go-cart-actor-amd64:latest
docker push registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }}
- 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 - name: Rollout amd64 deployment (pin to version)
run: kubectl rollout restart deployment/cart-actor-x86 -n cart 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
BuildAndDeploy: BuildAndDeployArm64:
needs: Metadata
runs-on: arm64 runs-on: arm64
steps: steps:
- name: Check out repository code - uses: actions/checkout@v4
uses: actions/checkout@v4 - name: Build arm64 image
- name: Build docker image run: |
run: docker build --progress=plain -t registry.knatofs.se/go-cart-actor . docker build \
- name: Push to registry --build-arg VERSION=${{ needs.Metadata.outputs.version }} \
run: docker push registry.knatofs.se/go-cart-actor --build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \
- name: Rollout arm64 deployment --build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \
run: kubectl rollout restart deployment/cart-actor-arm64 -n cart --progress=plain \
-t registry.knatofs.se/go-cart-actor:latest \
-t registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }} \
.
- name: Push arm64 images
run: |
docker push registry.knatofs.se/go-cart-actor:latest
docker push registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }}
- name: Rollout arm64 deployment (pin to version)
run: |
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

View File

@@ -1,17 +1,75 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1.7
#
# Multi-stage build:
# 1. Build static binary with pinned Go version (matching go.mod).
# 2. Copy into distroless static nonroot runtime image.
#
# Build args (optional):
# VERSION - semantic/app version (default: dev)
# GIT_COMMIT - git SHA (default: unknown)
# BUILD_DATE - RFC3339 build timestamp
#
# Example build:
# docker build \
# --build-arg VERSION=$(git describe --tags --always) \
# --build-arg GIT_COMMIT=$(git rev-parse HEAD) \
# --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
# -t go-cart-actor:dev .
#
# If you add subpackages or directories, no Dockerfile change needed (COPY . .).
# Ensure a .dockerignore exists to keep context lean.
FROM golang:alpine AS build-stage ############################
WORKDIR /app # Build Stage
############################
FROM golang:1.25-alpine AS build
WORKDIR /src
# Build metadata (can be overridden at build time)
ARG VERSION=dev
ARG GIT_COMMIT=unknown
ARG BUILD_DATE=unknown
# Ensure reproducible static build
# Multi-arch build args (TARGETOS/TARGETARCH provided automatically by buildx)
ARG TARGETOS
ARG TARGETARCH
ENV CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH}
# Dependency caching
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY proto ./proto # Copy full source (relay on .dockerignore to prune)
COPY *.go ./ COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /go-cart-actor # (Optional) If you do NOT check in generated protobuf code, uncomment generation:
# RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest && \
# go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest && \
# protoc --go_out=. --go_opt=paths=source_relative \
# --go-grpc_out=. --go-grpc_opt=paths=source_relative \
# proto/*.proto
FROM gcr.io/distroless/base-debian11 # Build with minimal binary size and embedded metadata
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-actor .
############################
# Runtime Stage
############################
# Using distroless static (nonroot) for minimal surface area.
FROM gcr.io/distroless/static-debian12:nonroot AS runtime
WORKDIR / WORKDIR /
COPY --from=build-stage /go-cart-actor /go-cart-actor COPY --from=build /out/go-cart-actor /go-cart-actor
# Document (not expose forcibly) typical ports: 8080 (HTTP), 1337 (gRPC)
EXPOSE 8080 1337
USER nonroot:nonroot
ENTRYPOINT ["/go-cart-actor"] ENTRYPOINT ["/go-cart-actor"]

132
Makefile Normal file
View File

@@ -0,0 +1,132 @@
# ------------------------------------------------------------------------------
# Makefile for go-cart-actor
#
# Key targets:
# make protogen - Generate protobuf + gRPC code into proto/
# make clean_proto - Remove generated proto *.pb.go files
# make verify_proto - Ensure no stray root-level *.pb.go files exist
# make build - Build the project
# make test - Run tests (verbose)
# make tidy - Run go mod tidy
# make regen - Clean proto, regenerate, tidy, verify, build
# make help - Show this help
#
# Conventions:
# - All .proto files live in $(PROTO_DIR)
# - Generated Go code is emitted under $(PROTO_DIR) via go_package mapping
# - go_package is set to: git.tornberg.me/go-cart-actor/proto;messages
# ------------------------------------------------------------------------------
MODULE_PATH := git.tornberg.me/go-cart-actor
PROTO_DIR := proto
PROTOS := $(PROTO_DIR)/messages.proto $(PROTO_DIR)/control_plane.proto
# Allow override: make PROTOC=/path/to/protoc
PROTOC ?= protoc
# Tools (auto-detect; can override)
PROTOC_GEN_GO ?= $(shell command -v protoc-gen-go 2>/dev/null)
PROTOC_GEN_GO_GRPC ?= $(shell command -v protoc-gen-go-grpc 2>/dev/null)
GO ?= go
# Colors (optional)
GREEN := \033[32m
RED := \033[31m
YELLOW := \033[33m
RESET := \033[0m
# ------------------------------------------------------------------------------
.PHONY: protogen clean_proto verify_proto tidy build test regen help check_tools
help:
@echo "Available targets:"
@echo " protogen Generate protobuf & gRPC code"
@echo " clean_proto Remove generated *.pb.go files in $(PROTO_DIR)"
@echo " verify_proto Ensure no root-level *.pb.go files (old layout)"
@echo " tidy Run go mod tidy"
@echo " build Build the module"
@echo " test Run tests (verbose)"
@echo " regen Clean proto, regenerate, tidy, verify, and build"
@echo " check_tools Verify protoc + plugins are installed"
check_tools:
@if [ -z "$(PROTOC_GEN_GO)" ] || [ -z "$(PROTOC_GEN_GO_GRPC)" ]; then \
echo "$(RED)Missing protoc-gen-go or protoc-gen-go-grpc in PATH.$(RESET)"; \
echo "Install with:"; \
echo " go install google.golang.org/protobuf/cmd/protoc-gen-go@latest"; \
echo " go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest"; \
exit 1; \
fi
@if ! command -v "$(PROTOC)" >/dev/null 2>&1; then \
echo "$(RED)protoc not found. Install protoc (e.g. via package manager)$(RESET)"; \
exit 1; \
fi
@echo "$(GREEN)All required tools detected.$(RESET)"
protogen: check_tools
@echo "$(YELLOW)Generating protobuf code (outputs -> ./proto)...$(RESET)"
$(PROTOC) -I $(PROTO_DIR) \
--go_out=./pkg/messages --go_opt=paths=source_relative \
--go-grpc_out=./pkg/messages --go-grpc_opt=paths=source_relative \
$(PROTOS)
@echo "$(GREEN)Protobuf generation complete.$(RESET)"
clean_proto:
@echo "$(YELLOW)Removing generated protobuf files...$(RESET)"
@rm -f $(PROTO_DIR)/*_grpc.pb.go $(PROTO_DIR)/*.pb.go
@rm -f *.pb.go
@rm -rf git.tornberg.me
@echo "$(GREEN)Clean complete.$(RESET)"
verify_proto:
@echo "$(YELLOW)Verifying proto layout...$(RESET)"
@if ls *.pb.go >/dev/null 2>&1; then \
echo "$(RED)ERROR: Found root-level generated *.pb.go files (should be only under $(PROTO_DIR)/).$(RESET)"; \
ls -1 *.pb.go; \
exit 1; \
fi
@echo "$(GREEN)Proto layout OK (no root-level *.pb.go files).$(RESET)"
tidy:
@echo "$(YELLOW)Running go mod tidy...$(RESET)"
$(GO) mod tidy
@echo "$(GREEN)tidy complete.$(RESET)"
build:
@echo "$(YELLOW)Building...$(RESET)"
$(GO) build ./...
@echo "$(GREEN)Build success.$(RESET)"
test:
@echo "$(YELLOW)Running tests...$(RESET)"
$(GO) test -v ./...
@echo "$(GREEN)Tests completed.$(RESET)"
regen: clean_proto protogen tidy verify_proto build
@echo "$(GREEN)Full regenerate cycle complete.$(RESET)"
# Utility: show proto sources and generated outputs
print_proto:
@echo "Proto sources:"
@ls -1 $(PROTOS)
@echo ""
@echo "Generated files (if any):"
@ls -1 $(PROTO_DIR)/*pb.go 2>/dev/null || echo "(none)"
# Prevent make from treating these as file targets if similarly named files appear.
.SILENT: help check_tools protogen clean_proto verify_proto tidy build test regen print_proto

372
README.md
View File

@@ -1,12 +1,43 @@
# 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
- Go 1.24.2+ - Go 1.24.2+
- Protocol Buffers compiler (`protoc`) - Protocol Buffers compiler (`protoc`)
- protoc-gen-go plugin - protoc-gen-go and protoc-gen-go-grpc plugins
### Installing Protocol Buffers ### Installing Protocol Buffers
@@ -32,17 +63,20 @@ sudo apt install protobuf-compiler
```bash ```bash
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
``` ```
## Working with Protocol Buffers ## Working with Protocol Buffers
### Generating Go code from proto files ### Generating Go code from proto files
After modifying `proto/messages.proto`, regenerate the Go code: After modifying any proto (`proto/messages.proto`, `proto/cart_actor.proto`, `proto/control_plane.proto`), regenerate the Go code (all three share the unified `messages` package):
```bash ```bash
cd proto cd proto
protoc --go_out=. --go_opt=paths=source_relative messages.proto protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
messages.proto cart_actor.proto control_plane.proto
``` ```
### Protocol Buffer Messages ### Protocol Buffer Messages
@@ -73,8 +107,338 @@ go build .
go test ./... go test ./...
``` ```
## HTTP API Quick Start (curl Examples)
Assuming the service is reachable at http://localhost:8080 and the cart API is mounted at /cart.
Most endpoints use an HTTP cookie named `cartid` to track the cart. The first request will set it.
### 1. Get (or create) a cart
```bash
curl -i http://localhost:8080/cart/
```
Response sets a `cartid` cookie and returns the current (possibly empty) cart JSON.
### 2. Add an item by SKU (implicit quantity = 1)
```bash
curl -i --cookie-jar cookies.txt http://localhost:8080/cart/add/TEST-SKU-123
```
Stores cookie in `cookies.txt` for subsequent calls.
### 3. Add an item with explicit payload (country, quantity)
```bash
curl -i --cookie cookies.txt \
-H "Content-Type: application/json" \
-d '{"sku":"TEST-SKU-456","quantity":2,"country":"se"}' \
http://localhost:8080/cart/
```
### 4. Change quantity of an existing line
(First list the cart to find `id` of the line; here we use id=1 as an example)
```bash
curl -i --cookie cookies.txt \
-X PUT -H "Content-Type: application/json" \
-d '{"id":1,"quantity":3}' \
http://localhost:8080/cart/
```
### 5. Remove an item
```bash
curl -i --cookie cookies.txt -X DELETE http://localhost:8080/cart/1
```
### 6. Set entire cart contents (overwrites items)
```bash
curl -i --cookie cookies.txt \
-X POST -H "Content-Type: application/json" \
-d '{"items":[{"sku":"TEST-SKU-AAA","quantity":1,"country":"se"},{"sku":"TEST-SKU-BBB","quantity":2,"country":"se"}]}' \
http://localhost:8080/cart/set
```
### 7. Add a delivery (provider + optional items)
If `items` is empty or omitted, all items without a delivery get this one.
```bash
curl -i --cookie cookies.txt \
-X POST -H "Content-Type: application/json" \
-d '{"provider":"standard","items":[1,2]}' \
http://localhost:8080/cart/delivery
```
### 8. Remove a delivery by deliveryId
```bash
curl -i --cookie cookies.txt -X DELETE http://localhost:8080/cart/delivery/1
```
### 9. Set a pickup point for a delivery
```bash
curl -i --cookie cookies.txt \
-X PUT -H "Content-Type: application/json" \
-d '{"id":"PUP123","name":"Locker 5","address":"Main St 1","city":"Stockholm","zip":"11122","country":"SE"}' \
http://localhost:8080/cart/delivery/1/pickupPoint
```
### 10. Checkout (returns HTML snippet from Klarna)
```bash
curl -i --cookie cookies.txt http://localhost:8080/cart/checkout
```
### 11. Using a known cart id directly (bypassing cookie)
If you already have a cart id (e.g. 1720000000000000):
```bash
CART_ID=1720000000000000
curl -i http://localhost:8080/cart/byid/$CART_ID
curl -i -X POST -H "Content-Type: application/json" \
-d '{"sku":"TEST-SKU-XYZ","quantity":1,"country":"se"}' \
http://localhost:8080/cart/byid/$CART_ID
```
### 12. Clear cart cookie (forces a new cart on next request)
```bash
curl -i --cookie cookies.txt -X DELETE http://localhost:8080/cart/
```
Tip: Use `--cookie-jar` and `--cookie` to persist the session across multiple commands:
```bash
curl --cookie-jar cookies.txt http://localhost:8080/cart/
curl --cookie cookies.txt http://localhost:8080/cart/add/TEST-SKU-123
```
## Important Notes ## Important Notes
- Always regenerate protobuf Go code after modifying `.proto` files - Always regenerate protobuf Go code after modifying any `.proto` files (messages/cart_actor/control_plane)
- The generated `messages.pb.go` file should not be edited manually - The generated `messages.pb.go` file should not be edited manually
- Make sure your PATH includes the protoc-gen-go binary location (usually `$GOPATH/bin`) - Make sure your PATH includes the protoc-gen-go binary location (usually `$GOPATH/bin`)
---
## Architecture Overview
The system is a distributed, sharded (by cart id) actor model implementation:
- Each cart is a grain (an inmemory struct `*CartGrain`) that owns and mutates its own state.
- A **local grain pool** holds grains owned by the node.
- A **synced (cluster) pool** (`SyncedPool`) coordinates multiple nodes and exposes local or remote grains through a uniform interface (`GrainPool`).
- All internode communication is gRPC:
- Cart mutation & state RPCs (CartActor service).
- Control plane RPCs (ControlPlane service) for membership, ownership negotiation, liveness, and graceful shutdown.
### Key Processes
1. Client HTTP request (or gRPC client) arrives with a cart identifier (cookie or path).
2. The pool resolves ownership:
- If local grain exists → use it.
- If a remote host is known owner → a remote grain proxy (`RemoteGrainGRPC`) is used; it performs gRPC calls to the owning node.
- If ownership is unknown → node attempts to claim ownership (quorum negotiation) and spawns a local grain.
3. Mutation is executed via the **mutation registry** (registry wraps domain logic + optional totals recomputation).
4. Updated state returned to caller; ownership preserved unless relinquished later (not yet implemented to shed load).
---
## Grain & Mutation Model
- `CartGrain` holds items, deliveries, pricing aggregates, and checkout/order metadata.
- All mutations are registered via `RegisterMutation[T]` with signature:
```
func(*CartGrain, *T) error
```
- `WithTotals()` flag triggers automatic recalculation of totals after successful handlers.
- The old giant `switch` in `CartGrain.Apply` has been replaced by registry dispatch; unregistered mutations fail fast.
- Adding a mutation:
1. Define proto message.
2. Generate code.
3. Register handler (optionally WithTotals).
4. Add gRPC RPC + request wrapper if the mutation must be remotely invokable.
5. (Optional) Add HTTP endpoint mapping to the mutation.
---
## Local Grain Pool
- Manages an inmemory map `map[CartId]*CartGrain`.
- Lazy spawn: first mutation or explicit access triggers `spawn(id)`.
- TTL / purge loop periodically removes expired grains unless they changed recently (basic memory pressure management).
- Capacity limit (`PoolSize`); oldest expired grain evicted first when full.
---
## Synced (Cluster) Pool
`SyncedPool` wraps a local pool and tracks:
- `remoteHosts`: known peer nodes (gRPC connections).
- `remoteIndex`: mapping of cart id → remote grain proxy (`RemoteGrainGRPC`) for carts owned elsewhere.
Responsibilities:
1. Discovery integration (via a `Discovery` interface) adds/removes hosts.
2. Periodic ping health checks (ControlPlane.Ping).
3. Ring-based deterministic ownership:
- Ownership is derived directly from the consistent hashing ring (no quorum RPC or `ConfirmOwner`).
4. Remote spawning:
- When a remote host reports its cart ids (`GetCartIds`), the pool creates remote proxies for fast routing.
---
## Remote Grain Proxies
A `RemoteGrainGRPC` implements the `Grain` interface but delegates:
- `Apply` → Specific CartActor permutation RPC (e.g., `AddItem`, `RemoveItem`) constructed from the mutation type. (Legacy envelope removed.)
- `GetCurrentState` → `CartActor.GetState`.
Return path:
1. gRPC reply (CartMutationReply / StateReply) → proto `CartState`.
2. `ToCartState` / mapping reconstructs a local `CartGrain` snapshot for callers expecting grain semantics.
---
## Control Plane (InterNode Coordination)
Defined in `proto/control_plane.proto`:
| RPC | Purpose |
|-----|---------|
| `Ping` | Liveness; increments missed ping counter if failing. |
| `Negotiate` | Merges membership views; used after discovery events. |
| `GetCartIds` | Enumerate locally owned carts for remote index seeding. |
| `Closing` | Graceful shutdown notice; peers remove host & associated remote grains. |
### Ownership / Quorum Rules
- If total participating hosts < 3 → all must accept.
- Otherwise majority acceptance (`ok >= total/2`).
- On failure → local tentative grain is removed (rollback to avoid splitbrain).
---
## Request / Mutation Flow Examples
### Local Mutation
1. HTTP handler parses request → determines cart id.
2. `SyncedPool.Apply`:
- Finds local grain (or spawns new after quorum).
- Executes registry mutation.
3. Totals updated if flagged.
4. HTTP response returns updated JSON (via `ToCartState`).
### Remote Mutation
1. `SyncedPool.Apply` sees cart mapped to a remote host.
2. Routes to `RemoteGrainGRPC.Apply`.
3. Remote node executes mutation locally and returns updated state over gRPC.
4. Proxy materializes snapshot locally (not authoritative, readonly view).
### Checkout (SideEffecting, Non-Pure)
- HTTP `/checkout` uses current grain snapshot to build payload (pure function).
- Calls Klarna externally (not a mutation).
- Applies `InitializeCheckout` mutation to persist reference + status.
- Returns Klarna order JSON to client.
---
## Scaling & Deployment
- **Horizontal scaling**: Add more nodes; discovery layer (Kubernetes / service registry) feeds hosts to `SyncedPool`.
- **Sharding**: Implicit by cart id hash. Ownership is first-claim with quorum acceptance.
- **Hot spots**: A single popular cart remains on one node; for heavy multi-client concurrency, future work could add read replicas or partitioning (not implemented).
- **Capacity tuning**: Increase `PoolSize` & memory limits; adjust TTL for stale cart eviction.
### Adding Nodes
1. Node starts gRPC server (CartActor + ControlPlane).
2. After brief delay, begins discovery watch; on event:
- New host → dial + negotiate → seed remote cart ids.
3. Pings maintain health; failed hosts removed (proxies invalidated).
---
## Failure Handling
| Scenario | Behavior |
|----------|----------|
| Remote host unreachable | Pings increment `MissedPings`; after threshold host removed. |
| Ownership negotiation fails | Tentative local grain discarded. |
| gRPC call error on remote mutation | Error bubbled to caller; no local fallback. |
| Missing mutation registration | Fast failure with explicit error message. |
| Partial checkout (Klarna fails) | No local state mutation for checkout; client sees error; cart remains unchanged. |
---
## Mutation Registry Summary
- Central, type-safe registry prevents silent omission.
- Each handler:
- Validates input.
- Mutates `*CartGrain`.
- Returns error for rejection.
- Automatic totals recomputation reduces boilerplate and consistency risk.
- Coverage test (add separately) can enforce all proto mutations are registered.
---
## gRPC Interfaces
- **CartActor**: Per-mutation unary RPCs + `GetState`. (Checkout logic intentionally excluded; handled at HTTP layer.)
- **ControlPlane**: Cluster coordination (Ping, Negotiate, GetCartIds, Closing) — ownership now ring-determined (no ConfirmOwner).
**Ports** (default / implied):
- CartActor & ControlPlane share the same gRPC server/listener (single port, e.g. `:1337`).
- Legacy frame/TCP code has been removed.
---
## Security & Future Enhancements
| Area | Potential Improvement |
|------|------------------------|
| Transport Security | Add TLS / mTLS to gRPC servers & clients. |
| Auth / RBAC | Intercept CartActor RPCs with auth metadata. |
| Backpressure | Rate-limit remote mutation calls per host. |
| Observability | Add per-mutation Prometheus metrics & tracing spans. |
| Ownership | Add lease timeouts / fencing tokens for stricter guarantees. |
| Batch Ops | Introduce batch mutation RPC or streaming updates (WatchState). |
| Persistence | Reintroduce event log or snapshot persistence layer if durability required. |
---
## Adding a New Node (Operational Checklist)
1. Deploy binary/container with same proto + registry.
2. Expose gRPC port.
3. Ensure discovery lists the new host.
4. Node dials peers, negotiates membership.
5. Remote cart proxies seeded.
6. Traffic routed automatically based on ownership.
---
## Adding a New Mutation (Checklist Recap)
1. Define proto message (+ request wrapper & RPC if remote invocation needed).
2. Regenerate protobuf code.
3. Implement & register handler (`RegisterMutation`).
4. Add client (HTTP/gRPC) endpoint.
5. Write unit + integration tests.
6. (Optional) Add to coverage test list and docs.
---
## High-Level Data Flow Diagram (Text)
```
Client -> HTTP Handler -> SyncedPool -> (local?) -> Registry -> Grain State
\-> (remote?) -> RemoteGrainGRPC -> gRPC -> Remote CartActor -> Registry -> Grain
ControlPlane: Discovery Events <-> Negotiation/Ping <-> SyncedPool state (ring determines ownership)
```
---
## Troubleshooting
| Symptom | Likely Cause | Action |
|---------|--------------|--------|
| New cart every request | Secure cookie over plain HTTP or not sending cookie jar | Disable Secure locally or use HTTPS & proper curl `-b` |
| Unsupported mutation error | Missing registry handler | Add `RegisterMutation` for that proto |
| Ownership imbalance | Ring host distribution skew or rapid host churn | Examine `cart_ring_host_share`, `cart_ring_hosts`, and logs for host add/remove; rebalance or investigate instability |
| Remote mutation latency | Network / serialization overhead | Consider batching or colocating hot carts |
| Checkout returns 500 | Klarna call failed | Inspect logs; no grain state mutated |
---

245
TODO.md Normal file
View File

@@ -0,0 +1,245 @@
# TODO / Roadmap
A living roadmap for improving the cart actor system. Focus areas:
1. Reliability & correctness
2. Simplicity of mutation & ownership flows
3. Developer experience (DX)
4. Operability (observability, tracing, metrics)
5. Performance & scalability
6. Security & multi-tenant readiness
---
## 1. Immediate Next Steps (High-Leverage)
| Priority | Task | Goal | Effort | Owner | Notes |
|----------|------|------|--------|-------|-------|
| P0 | Add mutation registry coverage test | Ensure no unregistered mutations silently fail | S | | Failing fast in CI |
| P0 | Add decodeJSON helper + 400 mapping for EOF | Reduce noisy 500 logs | S | | Improves client API clarity |
| P0 | Regenerate protos & prune unused messages (CreateCheckoutOrder, Checkout RPC remnants) | Eliminate dead types | S | | Avoid confusion |
| P0 | Add integration test: multi-node ownership negotiation | Validate quorum logic | M | | Spin up 23 nodes ephemeral |
| P1 | Export Prometheus metrics for per-mutation counts & latency | Operability | M | | Wrap registry handlers |
| P1 | Add graceful shutdown ordering (Closing → wait for acks → stop gRPC) | Reduce in-flight mutation failures | S | | Add context cancellation |
| P1 | Add coverage for InitializeCheckout / OrderCreated flows | Checkout reliability | S | | Simulate Klarna stub |
| P2 | Add optional batching client (apply multiple mutations locally then persist) | Performance | M | | Only if needed |
---
## 2. Simplification Opportunities
### A. RemoteGrain Proxy Mapping
Current: manual switch building each RPC call.
Simplify by:
- Generating a thin client adapter from proto RPC descriptors (codegen).
- Or using a registry similar to mutation registry but for “outbound call constructors”.
Benefit: adding a new mutation = add proto + register server handler + register outbound invoker (no switch edits).
### B. Ownership Negotiation
Current: ad hoc quorum rule in `SyncedPool`.
Simplify:
- Introduce explicit `OwnershipLease{holder, expiresAt, version}`.
- Use monotonic version increment—reject stale ConfirmOwner replies.
- Optional: add randomized backoff to reduce thundering herd on contested cart ids.
### C. CartId Handling
Current: ephemeral 16-byte array with trimmed string semantics.
Simplify:
- Use ULID / UUIDv7 (time-ordered, collision-resistant) for easier external correlation.
- Provide helper `NewCartIdString()` and keep internal fixed-size if still desired.
### D. Mutation Signatures
Current: registry assumes `func(*CartGrain, *T) error`.
Extension option: allow pure transforms returning a delta struct (for audit/logging):
```
type MutationResult struct {
Changed bool
Events []interface{}
}
```
Only implement if auditing/event-sourcing reintroduced.
---
## 3. Developer Experience Improvements
| Task | Rationale | Approach |
|------|-----------|----------|
| Makefile targets: `make run-single`, `make run-multi N=3` | Faster local cluster spin-up | Docker compose or background “mini cluster” scripts |
| Template for new mutation (generator) | Reduce boilerplate | `go:generate` scanning proto for new RPCs |
| Lint config (golangci-lint) | Catch subtle issues early | Add `.golangci.yml` |
| Pre-commit hook for proto regeneration check | Avoid stale generated code | Script compares git diff after `make protogen` |
| Example client (Go + curl snippets auto-generated) | Onboarding | Codegen a markdown from proto comments |
---
## 4. Observability / Metrics / Tracing
| Area | Metric / Trace | Notes |
|------|----------------|-------|
| Mutation registry | `cart_mutations_total{type,success}`; duration histogram | Wrap handler |
| Ownership negotiation | `cart_ownership_attempts_total{result}` | result=accepted,rejected,timeout |
| Remote latency | `cart_remote_mutation_seconds{method}` | Use client interceptors |
| Pings | `cart_remote_missed_pings_total{host}` | Already count, expose |
| Checkout flow | `checkout_attempts_total`, `checkout_failures_total` | Differentiate Klarna vs internal errors |
| Tracing | Span: HTTP handler → SyncedPool.Apply → (Remote?) gRPC → mutation handler | Add OpenTelemetry instrumentation |
---
## 5. Performance & Scalability
| Concern | Idea | Trade-Off |
|---------|------|-----------|
| High mutation rate on single cart | Introduce optional mutation queue (serialize explicitly) | Slight latency increase per op |
| Remote call overhead | Add client-side gRPC pooling & per-host circuit breaker | Complexity vs resilience |
| TTL purge efficiency | Use min-heap or timing wheel instead of slice scan | More code, better big-N performance |
| Batch network latency | Add `BatchMutate` RPC (list of mutations applied atomically) | Lost single-op simplicity |
---
## 6. Reliability Features
| Feature | Description | Priority |
|---------|-------------|----------|
| Lease fencing token | Include `ownership_version` in all remote mutate requests | M |
| Retry policy | Limited retry for transient network errors (idempotent mutations only) | L |
| Dead host reconciliation | On host removal, proactively attempt re-acquire of its carts | M |
| Drain mode | Node marks itself “draining” → refuses new ownership claims | M |
---
## 7. Security & Hardening
| Area | Next Step | Detail |
|------|-----------|--------|
| Transport | mTLS on gRPC | Use SPIFFE IDs or simple CA |
| AuthN/AuthZ | Interceptor enforcing service token | Inject metadata header |
| Input validation | Strengthen JSON decode responses | Disallow unknown fields globally |
| Rate limiting | Per-IP / per-cart throttling | Guard hotspot abuse |
| Multi-tenancy | Tenant id dimension in cart id or metadata | Partition metrics & ownership |
---
## 8. Testing Strategy Enhancements
| Gap | Improvement |
|-----|------------|
| No multi-node integration test in CI | Spin ephemeral in-process servers on randomized ports |
| Mutation regression | Table-driven tests auto-discover handlers via registry |
| Ownership race | Stress test: concurrent Apply on same new cart id from N goroutines |
| Checkout external dependency | Klarna mock server (HTTptest) + deterministic responses |
| Fuzzing | Fuzz `BuildCheckoutOrderPayload` & mutation handlers for panics |
---
## 9. Cleanup / Tech Debt
| Item | Action |
|------|--------|
| Remove deprecated proto remnants (CreateCheckoutOrder, Checkout RPC) | Delete & regenerate |
| Consolidate duplicate tax computations | Single helper with tax config |
| Delivery price hard-coded (4900) | Config or pricing strategy interface |
| Mixed naming (camel vs snake JSON historically) | Provide stable external API doc; accept old forms if needed |
| Manual remote mutation switch (if still present) | Replace with generated outbound registry |
| Mixed error responses (string bodies) | Standardize JSON: `{ "error": "...", "code": 400 }` |
---
## 10. Potential Future Features
| Feature | Value | Complexity |
|---------|-------|------------|
| Streaming `WatchState` RPC | Real-time cart updates for clients | Medium |
| Event sourcing / audit log | Replay, analytics, debugging | High |
| Promotion / coupon engine plugin | Business extensibility | Medium |
| Partial cart reservation / inventory lock | Stock accuracy under concurrency | High |
| Multi-currency pricing | Globalization | Medium |
| GraphQL facade | Client flexibility | Medium |
---
## 11. Suggested Prioritized Backlog (Condensed)
1. Coverage test + decode error mapping (P0)
2. Proto regeneration & cleanup (P0)
3. Metrics wrapper for registry (P1)
4. Multi-node ownership integration test (P1)
5. Delivery pricing abstraction (P2)
6. Lease version in remote RPCs (P2)
7. BatchMutate evaluation (P3)
8. TLS / auth hardening (P3) if going multi-tenant/public
9. Event sourcing (Evaluate after stability) (P4)
---
## 12. Simplifying the Developer Workflow
| Pain | Simplifier |
|------|------------|
| Manual mutation boilerplate | Code generator for registry stubs |
| Forgetting totals | Enforce WithTotals lint: fail if mutation touches items/deliveries without flag |
| Hard to inspect remote ownership | `/internal/ownership` debug endpoint (JSON of local + remoteIndex) |
| Hard to see mutation timings | Add `?debug=latency` header to return per-mutation durations |
| Cookie dev confusion (Secure flag) | Env var: `DEV_INSECURE_COOKIES=1` |
---
## 13. Example: Mutation Codegen Sketch (Future)
Input: cart_actor.proto
Output: `mutation_auto.go`
- Detect messages used in RPC wrappers (e.g., `AddItemRequest` → payload field).
- Generate `RegisterMutation` template if handler not found.
- Mark with `// TODO implement logic`.
---
## 14. Risk / Impact Matrix (Abbreviated)
| Change | Risk | Mitigation |
|--------|------|-----------|
| Replace remote switch with registry | Possible missing registration → runtime error | Coverage test gating CI |
| Lease introduction | Split-brain if version mishandled | Increment + assert monotonic; test race |
| BatchMutate | Large atomic operations starving others | Size limits & fair scheduling |
| Event sourcing | Storage + replay complexity | Start with append-only log + compaction job |
---
## 15. Contributing Workflow (Proposed)
1. Add / modify proto → run `make protogen`
2. Implement mutation logic → add `RegisterMutation` invocation
3. Add/Update tests (unit + integration)
4. Run `make verify` (lint, test, coverage, proto diff)
5. Open PR (template auto-checklist referencing this TODO)
6. Merge requires green CI + coverage threshold
---
## 16. Open Questions
| Question | Notes |
|----------|-------|
| Do we need sticky sessions for HTTP layer scaling? | Currently cart id routing suffices |
| Should deliveries prune invalid line references on SetCartRequest? | Inconsistency risk; add optional cleanup |
| Is checkout idempotency strict enough? | Multiple create vs update semantics |
| Add version field to CartState for optimistic concurrency? | Could enable external CAS writes |
---
## 17. Tracking
Mark any completed tasks with `[x]`:
- [ ] Coverage test
- [ ] Decode helper + 400 mapping
- [ ] Proto cleanup
- [ ] Registry metrics instrumentation
- [ ] Ownership multi-node test
- [ ] Lease versioning
- [ ] Delivery pricing abstraction
- [ ] TLS/mTLS internal
- [ ] BatchMutate design doc
---
_Last updated: roadmap draft refine after first metrics & scaling test run._

View File

@@ -1,83 +1,61 @@
package main package main
import ( import (
"log" "context"
"fmt"
"time"
amqp "github.com/rabbitmq/amqp091-go" amqp "github.com/rabbitmq/amqp091-go"
) )
type AmqpOrderHandler struct { type AmqpOrderHandler struct {
Url string Url string
connection *amqp.Connection Connection *amqp.Connection
//channel *amqp.Channel Channel *amqp.Channel
} }
const ( func (h *AmqpOrderHandler) Connect() error {
topic = "order-placed" conn, err := amqp.Dial(h.Url)
)
func (t *AmqpOrderHandler) Connect() error {
conn, err := amqp.DialConfig(t.Url, amqp.Config{
//Vhost: "/",
Properties: amqp.NewConnectionProperties(),
})
if err != nil { if err != nil {
return err return fmt.Errorf("failed to connect to RabbitMQ: %w", err)
} }
t.connection = conn h.Connection = conn
ch, err := conn.Channel() ch, err := conn.Channel()
if err != nil { if err != nil {
return err return fmt.Errorf("failed to open a channel: %w", err)
}
defer ch.Close()
if err := ch.ExchangeDeclare(
topic, // name
"topic", // type
true, // durable
false, // auto-delete
false, // internal
false, // noWait
nil, // arguments
); err != nil {
return err
}
if _, err = ch.QueueDeclare(
topic, // name of the queue
true, // durable
false, // delete when unused
false, // exclusive
false, // noWait
nil, // arguments
); err != nil {
return err
} }
h.Channel = ch
return nil return nil
} }
func (t *AmqpOrderHandler) Close() error { func (h *AmqpOrderHandler) Close() error {
log.Println("Closing master channel") if h.Channel != nil {
return t.connection.Close() h.Channel.Close()
//return t.channel.Close() }
if h.Connection != nil {
return h.Connection.Close()
}
return nil
} }
func (t *AmqpOrderHandler) OrderCompleted(data []byte) error { func (h *AmqpOrderHandler) OrderCompleted(body []byte) error {
ch, err := t.connection.Channel() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if err != nil { defer cancel()
return err
} err := h.Channel.PublishWithContext(ctx,
defer ch.Close() "orders", // exchange
return ch.Publish( "new", // routing key
topic, false, // mandatory
topic, false, // immediate
true,
false,
amqp.Publishing{ amqp.Publishing{
ContentType: "application/json", ContentType: "application/json",
Body: data, Body: body,
}, })
) if err != nil {
return fmt.Errorf("failed to publish a message: %w", err)
}
return nil
} }

540
cart-grain-pool.go Normal file
View File

@@ -0,0 +1,540 @@
package main
import (
"fmt"
"log"
"maps"
"reflect"
"sync"
"time"
"git.tornberg.me/go-cart-actor/pkg/actor"
"git.tornberg.me/go-cart-actor/pkg/discovery"
"git.tornberg.me/go-cart-actor/pkg/proxy"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"k8s.io/apimachinery/pkg/watch"
)
// ---------------------------------------------------------------------------
// Metrics shared by the cart pool implementation.
// ---------------------------------------------------------------------------
var (
poolGrains = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_grains_in_pool",
Help: "The total number of grains in the local pool",
})
poolSize = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_pool_size",
Help: "Configured capacity of the cart pool",
})
poolUsage = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_grain_pool_usage",
Help: "Current utilisation of the cart pool",
})
negotiationCount = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_remote_negotiation_total",
Help: "The total number of remote host negotiations",
})
connectedRemotes = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_connected_remotes",
Help: "Number of connected remote hosts",
})
cartMutationsTotal = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_mutations_total",
Help: "Total number of cart state mutations applied",
})
cartMutationFailuresTotal = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_mutation_failures_total",
Help: "Total number of failed cart state mutations",
})
cartMutationLatencySeconds = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "cart_mutation_latency_seconds",
Help: "Latency of cart mutations in seconds",
Buckets: prometheus.DefBuckets,
}, []string{"mutation"})
)
// GrainPool is the interface exposed to HTTP handlers and other subsystems.
// CartPool merges the responsibilities that previously belonged to
// GrainLocalPool and SyncedPool. It provides local grain storage together
// with cluster coordination, ownership negotiation and expiry signalling.
type CartPool struct {
// Local grain state -----------------------------------------------------
localMu sync.RWMutex
grains map[uint64]*CartGrain
spawn func(id CartId) (*CartGrain, error)
ttl time.Duration
poolSize int
// Cluster coordination --------------------------------------------------
hostname string
remoteMu sync.RWMutex
remoteOwners map[uint64]*proxy.RemoteHost
remoteHosts map[string]*proxy.RemoteHost
//discardedHostHandler *DiscardedHostHandler
// House-keeping ---------------------------------------------------------
purgeTicker *time.Ticker
}
// NewCartPool constructs a unified pool. Discovery may be nil for standalone
// deployments.
func NewCartPool(size int, ttl time.Duration, hostname string, spawn func(id CartId) (*CartGrain, error), hostWatch discovery.Discovery) (*CartPool, error) {
p := &CartPool{
grains: make(map[uint64]*CartGrain),
spawn: spawn,
ttl: ttl,
poolSize: size,
hostname: hostname,
remoteOwners: make(map[uint64]*proxy.RemoteHost),
remoteHosts: make(map[string]*proxy.RemoteHost),
}
p.purgeTicker = time.NewTicker(time.Minute)
go func() {
for range p.purgeTicker.C {
p.purge()
}
}()
if hostWatch != nil {
go p.startDiscovery(hostWatch)
} else {
log.Printf("No discovery configured; expecting manual AddRemote or static host injection")
}
return p, nil
}
func (p *CartPool) 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 *proxy.RemoteHost) {
remote.AnnounceExpiry(purgedIds)
})
}
// startDiscovery subscribes to cluster events and adds/removes hosts.
func (p *CartPool) startDiscovery(discovery discovery.Discovery) {
time.Sleep(3 * time.Second) // allow gRPC server startup
log.Printf("Starting discovery watcher")
ch, err := discovery.Watch()
if err != nil {
log.Printf("Discovery error: %v", err)
return
}
for evt := range ch {
if evt.Host == "" {
continue
}
switch evt.Type {
case watch.Deleted:
if p.IsKnown(evt.Host) {
p.RemoveHost(evt.Host)
}
default:
if !p.IsKnown(evt.Host) {
log.Printf("Discovered host %s", evt.Host)
p.AddRemote(evt.Host)
}
}
}
}
// ---------------------------------------------------------------------------
// Local grain management
// ---------------------------------------------------------------------------
func (p *CartPool) statsUpdate() {
p.localMu.RLock()
size := len(p.grains)
cap := p.poolSize
p.localMu.RUnlock()
poolGrains.Set(float64(size))
poolSize.Set(float64(cap))
if cap > 0 {
poolUsage.Set(float64(size) / float64(cap))
}
}
// LocalUsage returns the number of resident grains and configured capacity.
func (p *CartPool) 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 *CartPool) 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 *CartPool) 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 *CartPool) HandleOwnershipChange(host string, ids []uint64) error {
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
}
// SnapshotGrains returns a copy of the currently resident grains keyed by id.
func (p *CartPool) SnapshotGrains() map[uint64]*CartGrain {
p.localMu.RLock()
defer p.localMu.RUnlock()
out := maps.Clone(p.grains)
return out
}
// func (p *CartPool) getLocalGrain(key uint64) (*CartGrain, error) {
// grainLookups.Inc()
// p.localMu.RLock()
// grain, ok := p.grains[key]
// p.localMu.RUnlock()
// if grain != nil && ok {
// return grain, nil
// }
// go p.statsUpdate()
// return grain, nil
// }
// ---------------------------------------------------------------------------
// Cluster ownership and coordination
// ---------------------------------------------------------------------------
func (p *CartPool) TakeOwnership(id uint64) {
if p.grains[id] != nil {
return
}
log.Printf("taking ownership of: %d", id)
p.broadcastOwnership([]uint64{id})
}
func (p *CartPool) AddRemote(host string) (*proxy.RemoteHost, error) {
if host == "" || host == p.hostname || p.IsKnown(host) {
return nil, fmt.Errorf("invalid host")
}
remote, err := proxy.NewRemoteHost(host)
if err != nil {
log.Printf("AddRemote: NewRemoteHostGRPC %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 *CartPool) initializeRemote(remote *proxy.RemoteHost) {
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 *CartPool) RemoveHost(host string) {
p.remoteMu.Lock()
remote, exists := p.remoteHosts[host]
if exists {
go remote.Close()
delete(p.remoteHosts, host)
}
for id, owner := range p.remoteOwners {
if owner.Host == host {
delete(p.remoteOwners, id)
}
}
p.remoteMu.Unlock()
if exists {
remote.Close()
}
connectedRemotes.Set(float64(p.RemoteCount()))
}
func (p *CartPool) RemoteCount() int {
p.remoteMu.RLock()
defer p.remoteMu.RUnlock()
return len(p.remoteHosts)
}
// RemoteHostNames returns a snapshot of connected remote host identifiers.
func (p *CartPool) 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 *CartPool) 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 *CartPool) pingLoop(remote *proxy.RemoteHost) {
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.Host)
p.Close()
p.RemoveHost(remote.Host)
return
}
continue
}
}
}
func (p *CartPool) IsHealthy() bool {
p.remoteMu.RLock()
defer p.remoteMu.RUnlock()
for _, r := range p.remoteHosts {
if !r.IsHealthy() {
return false
}
}
return true
}
func (p *CartPool) 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 *CartPool) SendNegotiation() {
negotiationCount.Inc()
p.remoteMu.RLock()
hosts := make([]string, 0, len(p.remoteHosts)+1)
hosts = append(hosts, p.hostname)
remotes := make([]*proxy.RemoteHost, 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 *proxy.RemoteHost) {
knownByRemote, err := remote.Negotiate(hosts)
if err != nil {
log.Printf("Negotiate with %s failed: %v", remote.Host, err)
return
}
for _, h := range knownByRemote {
if !p.IsKnown(h) {
go p.AddRemote(h)
}
}
})
}
func (p *CartPool) forAllHosts(fn func(*proxy.RemoteHost)) {
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 *CartPool) broadcastOwnership(ids []uint64) {
if len(ids) == 0 {
return
}
p.forAllHosts(func(rh *proxy.RemoteHost) {
rh.AnnounceOwnership(ids)
})
}
func (p *CartPool) getOrClaimGrain(id uint64) (*CartGrain, error) {
p.localMu.RLock()
grain, exists := p.grains[id]
p.localMu.RUnlock()
if exists && grain != nil {
return grain, nil
}
grain, err := p.spawn(CartId(id))
if err != nil {
return nil, err
}
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 *CartPool) Apply(id uint64, mutation any) (*CartGrain, error) {
grain, err := p.getOrClaimGrain(id)
if err != nil {
return nil, err
}
start := time.Now()
result, applyErr := grain.Apply(mutation, false)
mutationType := "unknown"
if mutation != nil {
if t := reflect.TypeOf(mutation); t != nil {
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t.Name() != "" {
mutationType = t.Name()
}
}
}
cartMutationLatencySeconds.WithLabelValues(mutationType).Observe(time.Since(start).Seconds())
if applyErr == nil && result != nil {
cartMutationsTotal.Inc()
} else if applyErr != nil {
cartMutationFailuresTotal.Inc()
}
return result, applyErr
}
// Get returns the current state of a grain.
func (p *CartPool) Get(id uint64) (*CartGrain, 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 *CartPool) OwnerHost(id uint64) (actor.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 *CartPool) Hostname() string {
return p.hostname
}
// Close notifies remotes that this host is shutting down.
func (p *CartPool) Close() {
p.remoteMu.Lock()
defer p.remoteMu.Unlock()
for _, r := range p.remoteHosts {
go func(rh *proxy.RemoteHost) {
rh.Close()
}(r)
}
if p.purgeTicker != nil {
p.purgeTicker.Stop()
}
}

View File

@@ -1,32 +1,17 @@
package main package main
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"slices" "slices"
"sync" "sync"
"time" "time"
messages "git.tornberg.me/go-cart-actor/proto" messages "git.tornberg.me/go-cart-actor/pkg/messages"
) )
type CartId [16]byte // Legacy padded [16]byte CartId and its helper methods removed.
// Unified CartId (uint64 with base62 string form) now defined in cart_id.go.
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 type StockStatus int
@@ -77,7 +62,8 @@ type CartGrain struct {
mu sync.RWMutex mu sync.RWMutex
lastItemId int lastItemId int
lastDeliveryId int lastDeliveryId int
storageMessages []Message lastAccess time.Time
lastChange time.Time // unix seconds of last successful mutation (replay sets from event ts)
Id CartId `json:"id"` Id CartId `json:"id"`
Items []*CartItem `json:"items"` Items []*CartItem `json:"items"`
TotalPrice int64 `json:"totalPrice"` TotalPrice int64 `json:"totalPrice"`
@@ -90,42 +76,28 @@ type CartGrain struct {
PaymentStatus string `json:"paymentStatus,omitempty"` PaymentStatus string `json:"paymentStatus,omitempty"`
} }
type Grain interface {
GetId() CartId
HandleMessage(message *Message, isReplay bool) (*FrameWithPayload, error)
GetCurrentState() (*FrameWithPayload, error)
}
func (c *CartGrain) GetId() CartId { func (c *CartGrain) GetId() CartId {
return c.Id return c.Id
} }
func (c *CartGrain) GetLastChange() int64 { func (c *CartGrain) GetLastChange() time.Time {
if len(c.storageMessages) == 0 { return c.lastChange
return 0
}
return *c.storageMessages[len(c.storageMessages)-1].TimeStamp
} }
func (c *CartGrain) GetCurrentState() (*FrameWithPayload, error) { func (c *CartGrain) GetLastAccess() time.Time {
result, err := json.Marshal(c) return c.lastAccess
if err != nil {
ret := MakeFrameWithPayload(0, 400, []byte(err.Error()))
return &ret, nil
}
ret := MakeFrameWithPayload(0, 200, result)
return &ret, nil
} }
func getInt(data interface{}) (int, error) { func (c *CartGrain) GetCurrentState() (*CartGrain, error) {
switch v := data.(type) { c.lastAccess = time.Now()
case float64: return c, nil
return int(v), nil }
case int:
return v, nil func getInt(data float64, ok bool) (int, error) {
default: if !ok {
return 0, fmt.Errorf("invalid type") return 0, fmt.Errorf("invalid type")
} }
return int(data), nil
} }
func getItemData(sku string, qty int, country string) (*messages.AddItem, error) { func getItemData(sku string, qty int, country string) (*messages.AddItem, error) {
@@ -133,35 +105,40 @@ func getItemData(sku string, qty int, country string) (*messages.AddItem, error)
if err != nil { if err != nil {
return nil, err return nil, err
} }
orgPrice, _ := getInt(item.Fields[5]) orgPrice, _ := getInt(item.GetNumberFieldValue(5)) // getInt(item.Fields[5])
price, priceErr := getInt(item.Fields[4]) price, priceErr := getInt(item.GetNumberFieldValue(4)) //Fields[4]
if priceErr != nil { if priceErr != nil {
return nil, fmt.Errorf("invalid price") return nil, fmt.Errorf("invalid price")
} }
stock := InStock stock := InStock
if item.StockLevel == "0" || item.StockLevel == "" { item.HasStock()
stockValue, ok := item.GetNumberFieldValue(3)
if !ok || stockValue == 0 {
stock = OutOfStock stock = OutOfStock
} else if item.StockLevel == "5+" { } else {
if stockValue < 5 {
stock = LowStock stock = LowStock
} }
articleType, _ := item.Fields[1].(string) }
outletGrade, ok := item.Fields[20].(string)
articleType, _ := item.GetStringFieldValue(1) //.Fields[1].(string)
outletGrade, ok := item.GetStringFieldValue(20) //.Fields[20].(string)
var outlet *string var outlet *string
if ok { if ok {
outlet = &outletGrade outlet = &outletGrade
} }
sellerId, _ := item.Fields[24].(string) sellerId, _ := item.GetStringFieldValue(24) // .Fields[24].(string)
sellerName, _ := item.Fields[9].(string) sellerName, _ := item.GetStringFieldValue(9) // .Fields[9].(string)
brand, _ := item.Fields[2].(string) brand, _ := item.GetStringFieldValue(2) //.Fields[2].(string)
category, _ := item.Fields[10].(string) category, _ := item.GetStringFieldValue(10) //.Fields[10].(string)
category2, _ := item.Fields[11].(string) category2, _ := item.GetStringFieldValue(11) //.Fields[11].(string)
category3, _ := item.Fields[12].(string) category3, _ := item.GetStringFieldValue(12) //.Fields[12].(string)
category4, _ := item.Fields[13].(string) category4, _ := item.GetStringFieldValue(13) //Fields[13].(string)
category5, _ := item.Fields[14].(string) category5, _ := item.GetStringFieldValue(14) //.Fields[14].(string)
return &messages.AddItem{ return &messages.AddItem{
ItemId: int64(item.Id), ItemId: int64(item.Id),
@@ -188,29 +165,13 @@ func getItemData(sku string, qty int, country string) (*messages.AddItem, error)
}, nil }, nil
} }
func (c *CartGrain) AddItem(sku string, qty int, country string, storeId *string) (*FrameWithPayload, error) { func (c *CartGrain) AddItem(sku string, qty int, country string, storeId *string) (*CartGrain, error) {
cartItem, err := getItemData(sku, qty, country) cartItem, err := getItemData(sku, qty, country)
if err != nil { if err != nil {
return nil, err return nil, err
} }
cartItem.StoreId = storeId cartItem.StoreId = storeId
return c.HandleMessage(&Message{ return c.Apply(cartItem, false)
Type: 2,
Content: cartItem,
}, false)
}
func (c *CartGrain) GetStorageMessage(since int64) []StorableMessage {
c.mu.RLock()
defer c.mu.RUnlock()
ret := make([]StorableMessage, 0)
for _, message := range c.storageMessages {
if *message.TimeStamp > since {
ret = append(ret, message)
}
}
return ret
} }
func (c *CartGrain) GetState() ([]byte, error) { func (c *CartGrain) GetState() ([]byte, error) {
@@ -235,13 +196,7 @@ func (c *CartGrain) ItemsWithoutDelivery() []int {
ret := make([]int, 0, len(c.Items)) ret := make([]int, 0, len(c.Items))
hasDelivery := c.ItemsWithDelivery() hasDelivery := c.ItemsWithDelivery()
for _, item := range c.Items { for _, item := range c.Items {
found := false found := slices.Contains(hasDelivery, item.Id)
for _, id := range hasDelivery {
if item.Id == id {
found = true
break
}
}
if !found { if !found {
ret = append(ret, item.Id) ret = append(ret, item.Id)
@@ -266,324 +221,25 @@ func GetTaxAmount(total int64, tax int) int64 {
return int64(float64(total) / float64((1 + taxD))) return int64(float64(total) / float64((1 + taxD)))
} }
func (c *CartGrain) HandleMessage(message *Message, isReplay bool) (*FrameWithPayload, error) { func (c *CartGrain) Apply(content interface{}, isReplay bool) (*CartGrain, error) {
if message.TimeStamp == nil {
now := time.Now().Unix()
message.TimeStamp = &now
}
grainMutations.Inc() grainMutations.Inc()
var err error
switch message.Type {
case SetCartItemsType:
msg, ok := message.Content.(*messages.SetCartRequest)
if !ok {
err = fmt.Errorf("expected SetCartItems")
} else {
c.mu.Lock() updated, err := ApplyRegistered(c, content)
c.Items = make([]*CartItem, 0, len(msg.Items))
c.mu.Unlock()
for _, item := range msg.Items {
c.AddItem(item.Sku, int(item.Quantity), item.Country, item.StoreId)
}
}
case AddRequestType:
msg, ok := message.Content.(*messages.AddRequest)
if !ok {
err = fmt.Errorf("expected AddRequest")
} else {
existingItem, found := c.FindItemWithSku(msg.Sku)
if found {
existingItem.Quantity += int(msg.Quantity)
c.UpdateTotals()
} else {
return c.AddItem(msg.Sku, int(msg.Quantity), msg.Country, msg.StoreId)
}
}
case AddItemType:
msg, ok := message.Content.(*messages.AddItem)
if !ok {
err = fmt.Errorf("expected AddItem")
} else {
if msg.Quantity < 1 {
return nil, fmt.Errorf("invalid quantity")
}
existingItem, found := c.FindItemWithSku(msg.Sku)
if found {
existingItem.Quantity += int(msg.Quantity)
c.UpdateTotals()
} else {
c.mu.Lock()
c.lastItemId++
tax := 2500
if msg.Tax > 0 {
tax = int(msg.Tax)
}
taxAmount := GetTaxAmount(msg.Price, tax)
c.Items = append(c.Items, &CartItem{
Id: c.lastItemId,
ItemId: int(msg.ItemId),
Quantity: int(msg.Quantity),
Sku: msg.Sku,
Name: msg.Name,
Price: msg.Price,
TotalPrice: msg.Price * int64(msg.Quantity),
TotalTax: int64(taxAmount * int64(msg.Quantity)),
Image: msg.Image,
Stock: StockStatus(msg.Stock),
Disclaimer: msg.Disclaimer,
Brand: msg.Brand,
Category: msg.Category,
Category2: msg.Category2,
Category3: msg.Category3,
Category4: msg.Category4,
Category5: msg.Category5,
OrgPrice: msg.OrgPrice,
ArticleType: msg.ArticleType,
Outlet: msg.Outlet,
SellerId: msg.SellerId,
SellerName: msg.SellerName,
Tax: int(taxAmount),
TaxRate: tax,
StoreId: msg.StoreId,
})
c.UpdateTotals()
c.mu.Unlock()
}
}
case ChangeQuantityType:
msg, ok := message.Content.(*messages.ChangeQuantity)
if !ok {
err = fmt.Errorf("expected ChangeQuantity")
} else {
for i, item := range c.Items {
if item.Id == int(msg.Id) {
if msg.Quantity <= 0 {
//c.TotalPrice -= item.Price * int64(item.Quantity)
c.Items = append(c.Items[:i], c.Items[i+1:]...)
} else {
//diff := int(msg.Quantity) - item.Quantity
item.Quantity = int(msg.Quantity)
//c.TotalPrice += item.Price * int64(diff)
}
break
}
}
c.UpdateTotals()
}
case RemoveItemType:
msg, ok := message.Content.(*messages.RemoveItem)
if !ok {
err = fmt.Errorf("expected RemoveItem")
} else {
items := make([]*CartItem, 0, len(c.Items))
for _, item := range c.Items {
if item.Id == int(msg.Id) {
//c.TotalPrice -= item.Price * int64(item.Quantity)
} else {
items = append(items, item)
}
}
c.Items = items
c.UpdateTotals()
}
case SetDeliveryType:
msg, ok := message.Content.(*messages.SetDelivery)
if !ok {
err = fmt.Errorf("expected SetDelivery")
} else {
c.lastDeliveryId++
items := make([]int, 0)
withDelivery := c.ItemsWithDelivery()
if len(msg.Items) == 0 {
items = append(items, c.ItemsWithoutDelivery()...)
} else {
for _, id := range msg.Items {
for _, item := range c.Items {
if item.Id == int(id) {
if slices.Contains(withDelivery, item.Id) {
return nil, fmt.Errorf("item already has delivery")
}
items = append(items, int(item.Id))
break
}
}
}
}
if len(items) > 0 {
c.Deliveries = append(c.Deliveries, &CartDelivery{
Id: c.lastDeliveryId,
Provider: msg.Provider,
PickupPoint: msg.PickupPoint,
Price: 4900,
Items: items,
})
c.UpdateTotals()
}
}
case RemoveDeliveryType:
msg, ok := message.Content.(*messages.RemoveDelivery)
if !ok {
err = fmt.Errorf("expected RemoveDelivery")
} else {
deliveries := make([]*CartDelivery, 0, len(c.Deliveries))
for _, delivery := range c.Deliveries {
if delivery.Id == int(msg.Id) {
c.TotalPrice -= delivery.Price
} else {
deliveries = append(deliveries, delivery)
}
}
c.Deliveries = deliveries
c.UpdateTotals()
}
case SetPickupPointType:
msg, ok := message.Content.(*messages.SetPickupPoint)
if !ok {
err = fmt.Errorf("expected SetPickupPoint")
} else {
for _, delivery := range c.Deliveries {
if delivery.Id == int(msg.DeliveryId) {
delivery.PickupPoint = &messages.PickupPoint{
Id: msg.Id,
Address: msg.Address,
City: msg.City,
Zip: msg.Zip,
Country: msg.Country,
Name: msg.Name,
}
break
}
}
}
case CreateCheckoutOrderType:
msg, ok := message.Content.(*messages.CreateCheckoutOrder)
if !ok {
err = fmt.Errorf("expected CreateCheckoutOrder")
} else {
orderLines := make([]*Line, 0, len(c.Items))
c.PaymentInProgress = true
c.Processing = true
for _, item := range c.Items {
orderLines = append(orderLines, &Line{
Type: "physical",
Reference: item.Sku,
Name: item.Name,
Quantity: item.Quantity,
UnitPrice: int(item.Price),
TaxRate: 2500, // item.TaxRate,
QuantityUnit: "st",
TotalAmount: int(item.TotalPrice),
TotalTaxAmount: int(item.TotalTax),
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", item.Image),
})
}
for _, line := range c.Deliveries {
if line.Price > 0 {
orderLines = append(orderLines, &Line{
Type: "shipping_fee",
Reference: line.Provider,
Name: "Delivery",
Quantity: 1,
UnitPrice: int(line.Price),
TaxRate: 2500, // item.TaxRate,
QuantityUnit: "st",
TotalAmount: int(line.Price),
TotalTaxAmount: int(GetTaxAmount(line.Price, 2500)),
})
}
}
order := CheckoutOrder{
PurchaseCountry: "SE",
PurchaseCurrency: "SEK",
Locale: "sv-se",
OrderAmount: int(c.TotalPrice),
OrderTaxAmount: int(c.TotalTax),
OrderLines: orderLines,
MerchantReference1: c.Id.String(),
MerchantURLS: &CheckoutMerchantURLS{
Terms: msg.Terms,
Checkout: msg.Checkout,
Confirmation: msg.Confirmation,
Validation: msg.Validation,
Push: msg.Push,
},
}
orderPayload, err := json.Marshal(order)
if err != nil { if err != nil {
return nil, err if err == ErrMutationNotRegistered {
return nil, fmt.Errorf("unsupported mutation type %T (not registered)", content)
} }
var klarnaOrder *CheckoutOrder
if c.OrderReference != "" {
log.Printf("Updating order id %s", c.OrderReference)
klarnaOrder, err = KlarnaInstance.UpdateOrder(c.OrderReference, bytes.NewReader(orderPayload))
} else {
klarnaOrder, err = KlarnaInstance.CreateOrder(bytes.NewReader(orderPayload))
}
// log.Printf("Order result: %+v", klarnaOrder)
if nil != err {
log.Printf("error from klarna: %v", err)
return nil, err
}
if c.OrderReference == "" {
c.OrderReference = klarnaOrder.ID
c.PaymentStatus = klarnaOrder.Status
}
orderData, err := json.Marshal(klarnaOrder)
if nil != err {
return nil, err return nil, err
} }
result := MakeFrameWithPayload(RemoteCreateOrderReply, 200, orderData) // Sliding TTL: update lastChange only for non-replay successful mutations.
return &result, nil if updated != nil && !isReplay {
} c.lastChange = time.Now()
case OrderCompletedType: c.lastAccess = time.Now()
msg, ok := message.Content.(*messages.OrderCreated) _ = AppendCartEvent(c.Id, content)
if !ok {
log.Printf("expected OrderCompleted, got %T", message.Content)
err = fmt.Errorf("expected OrderCompleted")
} else {
c.OrderReference = msg.OrderId
c.PaymentStatus = msg.Status
c.PaymentInProgress = false
}
default:
err = fmt.Errorf("unknown message type %d", message.Type)
}
if err != nil {
return nil, err
} }
if !isReplay { return updated, nil
c.mu.Lock()
c.storageMessages = append(c.storageMessages, *message)
c.mu.Unlock()
}
result, err := json.Marshal(c)
msg := MakeFrameWithPayload(RemoteHandleMutationReply, 200, result)
return &msg, err
} }
func (c *CartGrain) UpdateTotals() { func (c *CartGrain) UpdateTotals() {

View File

@@ -1,248 +0,0 @@
package main
import (
"testing"
"time"
messages "git.tornberg.me/go-cart-actor/proto"
)
func GetMessage(t uint16, data interface{}) *Message {
ts := time.Now().Unix()
return &Message{
TimeStamp: &ts,
Type: t,
Content: data,
}
}
func TestTaxAmount(t *testing.T) {
taxAmount := GetTaxAmount(12500, 2500)
if taxAmount != 2500 {
t.Errorf("Expected 2500, got %d\n", taxAmount)
}
}
func TestAddToCartShortCut(t *testing.T) {
grain, err := spawn(ToCartId("kalle"))
if err != nil {
t.Errorf("Error spawning: %v\n", err)
}
if len(grain.Items) != 0 {
t.Errorf("Expected 0 items, got %d\n", len(grain.Items))
}
msg := GetMessage(AddItemType, &messages.AddItem{
Quantity: 2,
Price: 100,
Sku: "123",
Name: "Test item",
Image: "test.jpg",
})
_, err = grain.HandleMessage(msg, false)
if err != nil {
t.Errorf("Error handling message: %v\n", err)
}
if len(grain.Items) != 1 {
t.Errorf("Expected 1 item, got %d\n", len(grain.Items))
}
if grain.Items[0].Quantity != 2 {
t.Errorf("Expected quantity 2, got %d\n", grain.Items[0].Quantity)
}
if len(grain.storageMessages) != 1 {
t.Errorf("Expected 1 storage message, got %d\n", len(grain.storageMessages))
}
shortCutMessage := GetMessage(AddRequestType, &messages.AddRequest{
Quantity: 2,
Sku: "123",
})
_, err = grain.HandleMessage(shortCutMessage, false)
if err != nil {
t.Errorf("Error handling message: %v\n", err)
}
if len(grain.Items) != 1 {
t.Errorf("Expected 1 item, got %d\n", len(grain.Items))
}
if len(grain.storageMessages) != 2 {
t.Errorf("Expected 2 storage message, got %d\n", len(grain.storageMessages))
}
if grain.storageMessages[0].Type != AddItemType {
t.Errorf("Expected AddItemType, got %d\n", grain.storageMessages[0].Type)
}
if grain.storageMessages[1].Type != AddRequestType {
t.Errorf("Expected AddRequestType, got %d\n", grain.storageMessages[1].Type)
}
}
func TestAddRequestToGrain(t *testing.T) {
grain, err := spawn(ToCartId("kalle"))
if err != nil {
t.Errorf("Error spawning: %v\n", err)
}
msg := GetMessage(AddRequestType, &messages.AddRequest{
Quantity: 2,
Sku: "763281",
})
result, err := grain.HandleMessage(msg, false)
if err != nil {
t.Errorf("Error handling message: %v\n", err)
}
if result.StatusCode != 200 {
t.Errorf("Call failed\n")
}
t.Log(result)
}
func TestAddToCart(t *testing.T) {
grain, err := spawn(ToCartId("kalle"))
if err != nil {
t.Errorf("Error spawning: %v\n", err)
}
if len(grain.Items) != 0 {
t.Errorf("Expected 0 items, got %d\n", len(grain.Items))
}
msg := GetMessage(AddItemType, &messages.AddItem{
Quantity: 2,
Price: 100,
Sku: "123",
Name: "Test item",
Image: "test.jpg",
})
result, err := grain.HandleMessage(msg, false)
if err != nil {
t.Errorf("Error handling message: %v\n", err)
}
if result.StatusCode != 200 {
t.Errorf("Call failed\n")
}
if grain.TotalPrice != 200 {
t.Errorf("Expected total price 200, got %d\n", grain.TotalPrice)
}
if len(grain.Items) != 1 {
t.Errorf("Expected 1 item, got %d\n", len(grain.Items))
}
if grain.Items[0].Quantity != 2 {
t.Errorf("Expected quantity 2, got %d\n", grain.Items[0].Quantity)
}
result, err = grain.HandleMessage(msg, false)
if err != nil {
t.Errorf("Error handling message: %v\n", err)
}
if result.StatusCode != 200 {
t.Errorf("Call failed\n")
}
if grain.Items[0].Quantity != 4 {
t.Errorf("Expected quantity 4, got %d\n", grain.Items[0].Quantity)
}
if grain.TotalPrice != 400 {
t.Errorf("Expected total price 400, got %d\n", grain.TotalPrice)
}
}
func TestSetDelivery(t *testing.T) {
grain, err := spawn(ToCartId("kalle"))
if err != nil {
t.Errorf("Error spawning: %v\n", err)
}
if len(grain.Items) != 0 {
t.Errorf("Expected 0 items, got %d\n", len(grain.Items))
}
msg := GetMessage(AddItemType, &messages.AddItem{
Quantity: 2,
Price: 100,
Sku: "123",
Name: "Test item",
Image: "test.jpg",
})
grain.HandleMessage(msg, false)
msg = GetMessage(AddItemType, &messages.AddItem{
Quantity: 2,
Price: 100,
Sku: "123",
Name: "Test item",
Image: "test.jpg",
})
result, err := grain.HandleMessage(msg, false)
if err != nil {
t.Errorf("Error handling message: %v\n", err)
}
if result.StatusCode != 200 {
t.Errorf("Call failed\n")
}
setDelivery := GetMessage(SetDeliveryType, &messages.SetDelivery{
Provider: "test",
Items: []int64{1},
})
_, err = grain.HandleMessage(setDelivery, false)
if err != nil {
t.Errorf("Error handling message: %v\n", err)
}
if len(grain.Deliveries) != 1 {
t.Errorf("Expected 1 delivery, got %d\n", len(grain.Deliveries))
}
if len(grain.Deliveries[0].Items) != 1 {
t.Errorf("Expected 1 items in delivery, got %d\n", len(grain.Deliveries[0].Items))
}
}
func TestSetDeliveryOnAll(t *testing.T) {
grain, err := spawn(ToCartId("kalle"))
if err != nil {
t.Errorf("Error spawning: %v\n", err)
}
if len(grain.Items) != 0 {
t.Errorf("Expected 0 items, got %d\n", len(grain.Items))
}
msg := GetMessage(AddItemType, &messages.AddItem{
Quantity: 2,
Price: 100,
Sku: "123",
Name: "Test item",
Image: "test.jpg",
})
grain.HandleMessage(msg, false)
msg = GetMessage(AddItemType, &messages.AddItem{
Quantity: 2,
Price: 100,
Sku: "1233",
Name: "Test item2",
Image: "test.jpg",
})
result, err := grain.HandleMessage(msg, false)
if err != nil {
t.Errorf("Error handling message: %v\n", err)
}
if result.StatusCode != 200 {
t.Errorf("Call failed\n")
}
setDelivery := GetMessage(SetDeliveryType, &messages.SetDelivery{
Provider: "test",
Items: []int64{},
})
_, err = grain.HandleMessage(setDelivery, false)
if err != nil {
t.Errorf("Error handling message: %v\n", err)
}
if len(grain.Deliveries) != 1 {
t.Errorf("Expected 1 delivery, got %d\n", len(grain.Deliveries))
}
if len(grain.Deliveries[0].Items) != 2 {
t.Errorf("Expected 2 items in delivery, got %d\n", len(grain.Deliveries[0].Items))
}
}

159
cart_id.go Normal file
View File

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

185
cart_id_test.go Normal file
View File

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

119
checkout_builder.go Normal file
View File

@@ -0,0 +1,119 @@
package main
import (
"encoding/json"
"fmt"
)
// CheckoutMeta carries the external / URL metadata required to build a
// Klarna CheckoutOrder from a CartGrain snapshot. It deliberately excludes
// any Klarna-specific response fields (HTML snippet, client token, etc.).
type CheckoutMeta struct {
Terms string
Checkout string
Confirmation string
Validation string
Push string
Country string
Currency string // optional override (defaults to "SEK" if empty)
Locale string // optional override (defaults to "sv-se" if empty)
}
// BuildCheckoutOrderPayload converts the current cart grain + meta information
// into a CheckoutOrder domain struct and returns its JSON-serialized payload
// (to send to Klarna) alongside the structured CheckoutOrder object.
//
// This function is PURE: it does not perform any network I/O or mutate the
// grain. The caller is responsible for:
//
// 1. Choosing whether to create or update the Klarna order.
// 2. Invoking KlarnaClient.CreateOrder / UpdateOrder with the returned payload.
// 3. Applying an InitializeCheckout mutation (or equivalent) with the
// resulting Klarna order id + status.
//
// If you later need to support different tax rates per line, you can extend
// CartItem / Delivery to expose that data and propagate it here.
func BuildCheckoutOrderPayload(grain *CartGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
if grain == nil {
return nil, nil, fmt.Errorf("nil grain")
}
if meta == nil {
return nil, nil, fmt.Errorf("nil checkout meta")
}
currency := meta.Currency
if currency == "" {
currency = "SEK"
}
locale := meta.Locale
if locale == "" {
locale = "sv-se"
}
country := meta.Country
if country == "" {
country = "SE" // sensible default; adjust if multi-country support changes
}
lines := make([]*Line, 0, len(grain.Items)+len(grain.Deliveries))
// Item lines
for _, it := range grain.Items {
if it == nil {
continue
}
lines = append(lines, &Line{
Type: "physical",
Reference: it.Sku,
Name: it.Name,
Quantity: it.Quantity,
UnitPrice: int(it.Price),
TaxRate: 2500, // TODO: derive if variable tax rates are introduced
QuantityUnit: "st",
TotalAmount: int(it.TotalPrice),
TotalTaxAmount: int(it.TotalTax),
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", it.Image),
})
}
// Delivery lines
for _, d := range grain.Deliveries {
if d == nil || d.Price <= 0 {
continue
}
lines = append(lines, &Line{
Type: "shipping_fee",
Reference: d.Provider,
Name: "Delivery",
Quantity: 1,
UnitPrice: int(d.Price),
TaxRate: 2500,
QuantityUnit: "st",
TotalAmount: int(d.Price),
TotalTaxAmount: int(GetTaxAmount(d.Price, 2500)),
})
}
order := &CheckoutOrder{
PurchaseCountry: country,
PurchaseCurrency: currency,
Locale: locale,
OrderAmount: int(grain.TotalPrice),
OrderTaxAmount: int(grain.TotalTax),
OrderLines: lines,
MerchantReference1: grain.Id.String(),
MerchantURLS: &CheckoutMerchantURLS{
Terms: meta.Terms,
Checkout: meta.Checkout,
Confirmation: meta.Confirmation,
Validation: meta.Validation,
Push: meta.Push,
},
}
payload, err := json.Marshal(order)
if err != nil {
return nil, nil, fmt.Errorf("marshal checkout order: %w", err)
}
return payload, order, nil
}

5
cookies.txt Normal file
View File

@@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1761304670 cartid 4393545184291837

View File

@@ -1,276 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: klarna-api-credentials
data:
username: ZjQzZDY3YjEtNzA2Yy00NTk2LTliNTgtYjg1YjU2NDEwZTUw
password: a2xhcm5hX3Rlc3RfYXBpX0trUWhWVE5yYVZsV2FsTnhTRVp3Y1ZSSFF5UkVNRmxyY25Kd1AxSndQMGdzWmpRelpEWTNZakV0TnpBMll5MDBOVGsyTFRsaU5UZ3RZamcxWWpVMk5ERXdaVFV3TERFc2JUUkNjRFpWU1RsTllsSk1aMlEyVEc4MmRVODNZMkozUlRaaFdEZDViV3AwYkhGV1JqTjVNQzlaYXow
type: Opaque
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: cart-actor
arch: amd64
name: cart-actor-x86
spec:
replicas: 0
selector:
matchLabels:
app: cart-actor
arch: amd64
template:
metadata:
labels:
app: cart-actor
actor-pool: cart
arch: amd64
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/arch
operator: NotIn
values:
- arm64
volumes:
- name: data
nfs:
path: /i-data/7a8af061/nfs/cart-actor-no
server: 10.10.1.10
imagePullSecrets:
- name: regcred
serviceAccountName: default
containers:
- image: registry.knatofs.se/go-cart-actor-amd64:latest
name: cart-actor-amd64
imagePullPolicy: Always
lifecycle:
preStop:
exec:
command: ["sleep", "15"]
ports:
- containerPort: 8080
name: web
- containerPort: 1234
name: echo
- containerPort: 1337
name: rpc
- containerPort: 1338
name: quorum
livenessProbe:
httpGet:
path: /livez
port: web
failureThreshold: 1
periodSeconds: 10
readinessProbe:
httpGet:
path: /readyz
port: web
failureThreshold: 2
initialDelaySeconds: 2
periodSeconds: 10
volumeMounts:
- mountPath: "/data"
name: data
resources:
limits:
memory: "768Mi"
requests:
memory: "70Mi"
cpu: "1200m"
env:
- name: TZ
value: "Europe/Stockholm"
- name: KLARNA_API_USERNAME
valueFrom:
secretKeyRef:
name: klarna-api-credentials
key: username
- name: KLARNA_API_PASSWORD
valueFrom:
secretKeyRef:
name: klarna-api-credentials
key: password
- name: 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: CART_BASE_URL
value: "https://cart-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: 3
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-no
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: 1234
name: echo
- containerPort: 1337
name: rpc
- containerPort: 1338
name: quorum
livenessProbe:
httpGet:
path: /livez
port: web
failureThreshold: 1
periodSeconds: 10
readinessProbe:
httpGet:
path: /readyz
port: web
failureThreshold: 2
initialDelaySeconds: 2
periodSeconds: 10
volumeMounts:
- mountPath: "/data"
name: data
resources:
limits:
memory: "768Mi"
requests:
memory: "70Mi"
cpu: "1200m"
env:
- name: TZ
value: "Europe/Stockholm"
- name: KLARNA_API_USERNAME
valueFrom:
secretKeyRef:
name: klarna-api-credentials
key: username
- name: KLARNA_API_PASSWORD
valueFrom:
secretKeyRef:
name: klarna-api-credentials
key: password
- name: 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: CART_BASE_URL
value: "https://cart-no.tornberg.me"
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
---
kind: Service
apiVersion: v1
metadata:
name: cart-echo
spec:
selector:
app: cart-actor
type: LoadBalancer
ports:
- name: echo
port: 1234
---
kind: Service
apiVersion: v1
metadata:
name: cart-actor
annotations:
prometheus.io/port: "8080"
prometheus.io/scrape: "true"
prometheus.io/path: "/metrics"
spec:
selector:
app: cart-actor
ports:
- name: web
port: 8080
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: cart-ingress
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
# nginx.ingress.kubernetes.io/affinity: "cookie"
# nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity"
# nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
# nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
nginx.ingress.kubernetes.io/proxy-body-size: 4m
spec:
ingressClassName: nginx
tls:
- hosts:
- cart-no.tornberg.me
secretName: cart-actor-no-tls-secret
rules:
- host: cart-no.tornberg.me
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: cart-actor
port:
number: 8080

View File

@@ -15,7 +15,7 @@ metadata:
arch: amd64 arch: amd64
name: cart-actor-x86 name: cart-actor-x86
spec: spec:
replicas: 0 replicas: 3
selector: selector:
matchLabels: matchLabels:
app: cart-actor app: cart-actor
@@ -55,12 +55,8 @@ spec:
ports: ports:
- containerPort: 8080 - containerPort: 8080
name: web name: web
- containerPort: 1234
name: echo
- containerPort: 1337 - containerPort: 1337
name: rpc name: rpc
- containerPort: 1338
name: quorum
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /livez path: /livez
@@ -161,12 +157,8 @@ spec:
ports: ports:
- containerPort: 8080 - containerPort: 8080
name: web name: web
- containerPort: 1234
name: echo
- containerPort: 1337 - containerPort: 1337
name: rpc name: rpc
- containerPort: 1338
name: quorum
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /livez path: /livez
@@ -217,18 +209,6 @@ spec:
--- ---
kind: Service kind: Service
apiVersion: v1 apiVersion: v1
metadata:
name: cart-echo
spec:
selector:
app: cart-actor
type: LoadBalancer
ports:
- name: echo
port: 1234
---
kind: Service
apiVersion: v1
metadata: metadata:
name: cart-actor name: cart-actor
annotations: annotations:
@@ -248,10 +228,10 @@ 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

View File

@@ -1,4 +1,4 @@
apiVersion: autoscaling/v1 apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler kind: HorizontalPodAutoscaler
metadata: metadata:
name: cart-scaler-amd name: cart-scaler-amd
@@ -9,9 +9,47 @@ spec:
name: cart-actor-x86 name: cart-actor-x86
minReplicas: 3 minReplicas: 3
maxReplicas: 9 maxReplicas: 9
targetCPUUtilizationPercentage: 30 behavior:
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Percent
value: 100
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 180
policies:
- type: Percent
value: 50
periodSeconds: 60
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 50
# Future custom metric (example):
# - type: Pods
# pods:
# metric:
# name: cart_mutations_per_second
# target:
# type: AverageValue
# averageValue: "15"
# - type: Object
# object:
# describedObject:
# apiVersion: networking.k8s.io/v1
# kind: Ingress
# name: cart-ingress
# metric:
# name: http_requests_per_second
# target:
# type: Value
# value: "100"
--- ---
apiVersion: autoscaling/v1 apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler kind: HorizontalPodAutoscaler
metadata: metadata:
name: cart-scaler-arm name: cart-scaler-arm
@@ -22,4 +60,42 @@ spec:
name: cart-actor-arm64 name: cart-actor-arm64
minReplicas: 3 minReplicas: 3
maxReplicas: 9 maxReplicas: 9
targetCPUUtilizationPercentage: 30 behavior:
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Percent
value: 100
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 180
policies:
- type: Percent
value: 50
periodSeconds: 60
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 50
# Future custom metric (example):
# - type: Pods
# pods:
# metric:
# name: cart_mutations_per_second
# target:
# type: AverageValue
# averageValue: "15"
# - type: Object
# object:
# describedObject:
# apiVersion: networking.k8s.io/v1
# kind: Ingress
# name: cart-ingress
# metric:
# name: http_requests_per_second
# target:
# type: Value
# value: "100"

View File

@@ -1,83 +0,0 @@
package main
import (
"fmt"
"log"
"net"
"sync"
"time"
)
type DiscardedHost struct {
*Connection
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++
host.Tries = -1
} else {
conn.Close()
if d.onConnection != nil {
fn := *d.onConnection
fn(host.Host)
}
}
}
func NewDiscardedHostHandler(port int) *DiscardedHostHandler {
ret := &DiscardedHostHandler{
hosts: make([]*DiscardedHost, 0),
port: port,
}
go ret.run()
return ret
}
func (d *DiscardedHostHandler) SetReconnectHandler(fn func(string)) {
d.onConnection = &fn
}
func (d *DiscardedHostHandler) AppendHost(host string) {
d.mu.Lock()
defer d.mu.Unlock()
log.Printf("Adding host %s to retry list", host)
d.hosts = append(d.hosts, &DiscardedHost{
Host: host,
Tries: 0,
})
}

View File

@@ -1,18 +0,0 @@
package main
import (
"testing"
"time"
)
func TestDiscardedHost(t *testing.T) {
dh := NewDiscardedHostHandler(8080)
dh.SetReconnectHandler(func(host string) {
t.Log(host)
})
dh.AppendHost("localhost")
time.Sleep(2 * time.Second)
if dh.hosts[0].Tries == 0 {
t.Error("Host not tested")
}
}

View File

@@ -2,118 +2,72 @@ package main
import ( import (
"encoding/gob" "encoding/gob"
"fmt"
"log"
"os"
"time" "time"
) )
func init() {
gob.Register(map[uint64]int64{})
}
type DiskStorage struct { type DiskStorage struct {
stateFile string stateFile string
lastSave int64 lastSave time.Time
LastSaves map[CartId]int64 LastSaves map[uint64]time.Time
} }
func NewDiskStorage(stateFile string) (*DiskStorage, error) { func NewDiskStorage(stateFile string) (*DiskStorage, error) {
ret := &DiskStorage{ ret := &DiskStorage{
stateFile: stateFile, stateFile: stateFile,
LastSaves: make(map[CartId]int64), LastSaves: make(map[uint64]time.Time),
} }
err := ret.loadState() //err := ret.loadState()
return ret, err return ret, nil
} }
func saveMessages(messages []StorableMessage, id CartId) error { // func saveMessages(_ interface{}, _ CartId) error {
// // No-op: legacy event log persistence removed in oneof refactor.
// return nil
// }
if len(messages) == 0 { // func getCartPath(id string) string {
return nil // return fmt.Sprintf("data/%s.prot", id)
} // }
log.Printf("%d messages to save for grain id %s", len(messages), id)
var file *os.File
var err error
path := getCartPath(id.String())
file, err = os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer file.Close()
for _, m := range messages { // func loadMessages(_ Grain, _ CartId) error {
err := m.Write(file) // // No-op: legacy replay removed in oneof refactor.
if err != nil { // return nil
return err // }
}
}
return err
}
func getCartPath(id string) string { // func (s *DiskStorage) saveState() error {
return fmt.Sprintf("data/%s.prot", id) // 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 loadMessages(grain Grain, id CartId) error { // func (s *DiskStorage) loadState() error {
var err error // file, err := os.Open(s.stateFile)
path := getCartPath(id.String()) // if err != nil {
// return err
// }
// defer file.Close()
// return gob.NewDecoder(file).Decode(&s.LastSaves)
// }
file, err := os.Open(path) func (s *DiskStorage) Store(id CartId, _ *CartGrain) error {
if err != nil { // With the removal of the legacy message log, we only update the timestamp.
if os.IsNotExist(err) { ts := time.Now()
return nil s.LastSaves[uint64(id)] = ts
}
return err
}
defer file.Close()
for err == nil {
var msg Message
err = ReadMessage(file, &msg)
if err == nil {
grain.HandleMessage(&msg, true)
}
}
if err.Error() == "EOF" {
return nil
}
return err
}
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, grain *CartGrain) error {
lastSavedMessage, ok := s.LastSaves[id]
if ok && lastSavedMessage > grain.GetLastChange() {
return nil
}
err := saveMessages(grain.GetStorageMessage(lastSavedMessage), id)
if err != nil {
return err
}
ts := time.Now().Unix()
s.LastSaves[id] = ts
s.lastSave = ts s.lastSave = ts
return nil return nil
} }

288
event_log.go Normal file
View File

@@ -0,0 +1,288 @@
package main
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"sync"
"time"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
/*
event_log.go
Append-only cart event log (per cart id) with replay + metrics.
Rationale:
- Enables recovery of in-memory cart state after process restarts or TTL eviction.
- Provides a chronological mutation trail for auditing / debugging.
- Avoids write amplification of full snapshots on every mutation.
Format:
One JSON object per line:
{
"ts": 1700000000,
"type": "AddRequest",
"payload": { ... mutation fields ... }
}
Concurrency:
- Appends: synchronized per-cart via an in-process mutex map to avoid partial writes.
- Replay: sequential read of entire file; mutations applied in order.
Usage Integration (to be wired by caller):
1. After successful mutation application (non-replay), invoke:
AppendCartEvent(grain.GetId(), mutation)
2. During grain spawn, call:
ReplayCartEvents(grain, grain.GetId())
BEFORE serving requests, so state is reconstructed.
Metrics:
- cart_event_log_appends_total
- cart_event_log_replay_total
- cart_event_log_replay_failures_total
- cart_event_log_bytes_written_total
- cart_event_log_files_existing (gauge)
- cart_event_log_last_append_unix (gauge)
- cart_event_log_replay_duration_seconds (histogram)
Rotation / Compaction:
- Not implemented. If needed, implement size checks and snapshot+truncate later.
Caveats:
- Mutation schema changes may break replay unless backward-compatible.
- Missing / unknown event types are skipped (metric incremented).
- If a mutation fails during replay, replay continues (logged + metric).
*/
var (
eventLogDir = "data"
eventAppendsTotal = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_event_log_appends_total",
Help: "Total number of cart mutation events appended to event logs.",
})
eventReplayTotal = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_event_log_replay_total",
Help: "Total number of successful event log replays (per cart).",
})
eventReplayFailuresTotal = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_event_log_replay_failures_total",
Help: "Total number of failed event log replay operations.",
})
eventBytesWrittenTotal = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_event_log_bytes_written_total",
Help: "Cumulative number of bytes written to all cart event logs.",
})
eventFilesExisting = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_event_log_files_existing",
Help: "Number of cart event log files currently existing on disk.",
})
eventLastAppendUnix = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_event_log_last_append_unix",
Help: "Unix timestamp of the last append to any cart event log.",
})
eventReplayDuration = promauto.NewHistogram(prometheus.HistogramOpts{
Name: "cart_event_log_replay_duration_seconds",
Help: "Duration of replay operations per cart in seconds.",
Buckets: prometheus.DefBuckets,
})
eventUnknownTypesTotal = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_event_log_unknown_types_total",
Help: "Total number of unknown event types encountered during replay (skipped).",
})
eventMutationErrorsTotal = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_event_log_mutation_errors_total",
Help: "Total number of errors applying mutation events during replay.",
})
)
type cartEventRecord struct {
Timestamp int64 `json:"ts"`
Type string `json:"type"`
Payload interface{} `json:"payload"`
}
// registry of supported mutation payload type constructors
var eventTypeFactories = map[string]func() interface{}{
"AddRequest": func() interface{} { return &messages.AddRequest{} },
"AddItem": func() interface{} { return &messages.AddItem{} },
"RemoveItem": func() interface{} { return &messages.RemoveItem{} },
"RemoveDelivery": func() interface{} { return &messages.RemoveDelivery{} },
"ChangeQuantity": func() interface{} { return &messages.ChangeQuantity{} },
"SetDelivery": func() interface{} { return &messages.SetDelivery{} },
"SetPickupPoint": func() interface{} { return &messages.SetPickupPoint{} },
"SetCartRequest": func() interface{} { return &messages.SetCartRequest{} },
"OrderCreated": func() interface{} { return &messages.OrderCreated{} },
"InitializeCheckout": func() interface{} { return &messages.InitializeCheckout{} },
}
// Per-cart mutexes to serialize append operations (avoid partial overlapping writes)
var (
eventLogMu sync.Map // map[string]*sync.Mutex
)
// getCartEventMutex returns a mutex for a specific cart id string.
func getCartEventMutex(id string) *sync.Mutex {
if v, ok := eventLogMu.Load(id); ok {
return v.(*sync.Mutex)
}
m := &sync.Mutex{}
actual, _ := eventLogMu.LoadOrStore(id, m)
return actual.(*sync.Mutex)
}
// EventLogPath returns the path to the cart's event log file.
func EventLogPath(id CartId) string {
return filepath.Join(eventLogDir, fmt.Sprintf("%s.events.log", id.String()))
}
// EnsureEventLogDirectory ensures base directory exists and updates gauge.
func EnsureEventLogDirectory() error {
if _, err := os.Stat(eventLogDir); errors.Is(err, os.ErrNotExist) {
if err2 := os.MkdirAll(eventLogDir, 0755); err2 != nil {
return err2
}
}
// Update files existing gauge (approximate; counts matching *.events.log)
pattern := filepath.Join(eventLogDir, "*.events.log")
matches, _ := filepath.Glob(pattern)
eventFilesExisting.Set(float64(len(matches)))
return nil
}
// AppendCartEvent appends a mutation event to the cart's log (JSON line).
func AppendCartEvent(id CartId, mutation interface{}) error {
if mutation == nil {
return errors.New("nil mutation cannot be logged")
}
if err := EnsureEventLogDirectory(); err != nil {
return err
}
typ := mutationTypeName(mutation)
rec := cartEventRecord{
Timestamp: time.Now().Unix(),
Type: typ,
Payload: mutation,
}
lineBytes, err := json.Marshal(rec)
if err != nil {
return fmt.Errorf("marshal event: %w", err)
}
lineBytes = append(lineBytes, '\n')
path := EventLogPath(id)
mtx := getCartEventMutex(id.String())
mtx.Lock()
defer mtx.Unlock()
fh, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("open event log: %w", err)
}
defer fh.Close()
n, werr := fh.Write(lineBytes)
if werr != nil {
return fmt.Errorf("write event log: %w", werr)
}
eventAppendsTotal.Inc()
eventBytesWrittenTotal.Add(float64(n))
eventLastAppendUnix.Set(float64(rec.Timestamp))
return nil
}
// ReplayCartEvents replays an existing cart's event log into the provided grain.
// It applies mutation payloads in order, skipping unknown types.
func ReplayCartEvents(grain *CartGrain, id CartId) error {
start := time.Now()
path := EventLogPath(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 {
eventReplayFailuresTotal.Inc()
return fmt.Errorf("open replay file: %w", err)
}
defer fh.Close()
scanner := bufio.NewScanner(fh)
// Increase buffer in case of large payloads
const maxLine = 256 * 1024
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, maxLine)
for scanner.Scan() {
line := scanner.Bytes()
var raw struct {
Timestamp time.Time `json:"ts"`
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
if err := json.Unmarshal(line, &raw); err != nil {
eventReplayFailuresTotal.Inc()
continue // skip malformed line
}
factory, ok := eventTypeFactories[raw.Type]
if !ok {
eventUnknownTypesTotal.Inc()
continue // skip unknown mutation type
}
instance := factory()
if err := json.Unmarshal(raw.Payload, instance); err != nil {
eventMutationErrorsTotal.Inc()
continue
}
// Apply mutation directly using internal registration (bypass AppendCartEvent recursion).
if _, applyErr := ApplyRegistered(grain, instance); applyErr != nil {
eventMutationErrorsTotal.Inc()
continue
} else {
// Update lastChange to the timestamp of this event (sliding inactivity window support).
grain.lastChange = raw.Timestamp
}
}
if serr := scanner.Err(); serr != nil {
eventReplayFailuresTotal.Inc()
return fmt.Errorf("scanner error: %w", serr)
}
eventReplayTotal.Inc()
eventReplayDuration.Observe(time.Since(start).Seconds())
return nil
}
// mutationTypeName returns the short struct name for a mutation (pointer aware).
func mutationTypeName(v interface{}) string {
if v == nil {
return "nil"
}
t := reflect.TypeOf(v)
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
return t.Name()
}
/*
Future enhancements:
- Compression: gzip large (> N events) logs to reduce disk usage.
- Compaction: periodic snapshot + truncate old events to bound replay latency.
- Checkpoint events: inject cart state snapshots every M mutations.
- Integrity: add checksum per line for corruption detection.
- Multi-writer safety across processes (currently only safe within one process).
*/

90
go.mod
View File

@@ -1,63 +1,73 @@
module git.tornberg.me/go-cart-actor module git.tornberg.me/go-cart-actor
go 1.24.2 go 1.25.1
require ( require (
github.com/matst80/slask-finder v0.0.0-20250418094723-2eb7d6615761 github.com/google/uuid v1.6.0
github.com/prometheus/client_golang v1.22.0 github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548
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/yudhasubki/netpool v0.0.0-20230717065341-3c1353ca328e google.golang.org/grpc v1.76.0
google.golang.org/protobuf v1.36.6 google.golang.org/protobuf v1.36.10
k8s.io/api v0.32.3 k8s.io/api v0.34.1
k8s.io/apimachinery v0.32.3 k8s.io/apimachinery v0.34.1
k8s.io/client-go v0.32.3 k8s.io/client-go v0.34.1
) )
require ( require (
github.com/RoaringBitmap/roaring/v2 v2.10.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/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/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.8.0 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/logr v1.4.2 // indirect github.com/go-openapi/jsonpointer v0.22.1 // indirect
github.com/go-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/jsonreference v0.21.2 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.25.1 // indirect
github.com/go-openapi/swag v0.23.1 // indirect github.com/go-openapi/swag/cmdutils v0.25.1 // indirect
github.com/go-openapi/swag/conv v0.25.1 // indirect
github.com/go-openapi/swag/fileutils v0.25.1 // indirect
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
github.com/go-openapi/swag/loading v0.25.1 // indirect
github.com/go-openapi/swag/mangling v0.25.1 // indirect
github.com/go-openapi/swag/netutils 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/yamlutils v0.25.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/gnostic-models v0.6.9 // indirect
github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect github.com/gorilla/schema v1.4.1 // indirect
github.com/google/uuid v1.6.0 // 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.9.0 // 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.2 // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // 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/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.63.0 // indirect github.com/prometheus/common v0.67.1 // indirect
github.com/prometheus/procfs v0.16.0 // indirect github.com/prometheus/procfs v0.17.0 // indirect
github.com/redis/go-redis/v9 v9.7.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/pflag v1.0.6 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
golang.org/x/net v0.39.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/oauth2 v0.29.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.32.0 // indirect golang.org/x/net v0.46.0 // indirect
golang.org/x/term v0.31.0 // indirect golang.org/x/oauth2 v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect golang.org/x/sys v0.37.0 // indirect
golang.org/x/time v0.11.0 // indirect golang.org/x/term v0.36.0 // indirect
golang.org/x/tools v0.32.0 // indirect golang.org/x/text v0.30.0 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/inf.v0 v0.9.1 // 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-20250318190949-c8a335a9a2ff // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e // indirect k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect
) )

210
go.sum
View File

@@ -1,51 +1,71 @@
github.com/Flaconi/go-klarna v0.0.0-20230216165926-e2f708c721d9 h1:U5gu3M9/khqtvgg6iRKo0+nxGEfPHWFHRlKrbZvFxIY= github.com/RoaringBitmap/roaring/v2 v2.10.0 h1:HbJ8Cs71lfCJyvmSptxeMX2PtvOC8yonlU0GQcy2Ak0=
github.com/Flaconi/go-klarna v0.0.0-20230216165926-e2f708c721d9/go.mod h1:+LVFV9FXH5cwN1VcU30WcNYRs5FhkEtL7/IqqTD42cU= github.com/RoaringBitmap/roaring/v2 v2.10.0/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
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/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
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/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/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8=
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= github.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo=
github.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY=
github.com/go-openapi/swag/cmdutils v0.25.1/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
github.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU=
github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M=
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
github.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync=
github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ=
github.com/go-openapi/swag/netutils v0.25.1 h1:2wFLYahe40tDUHfKT1GRC4rfa5T1B4GWZ+msEFA4Fl4=
github.com/go-openapi/swag/netutils v0.25.1/go.mod h1:CAkkvqnUJX8NV96tNhEQvKz8SQo2KF0f7LleiJwIeRE=
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
github.com/go-task/slim-sprig/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/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.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.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
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/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
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=
@@ -58,54 +78,67 @@ 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/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548 h1:lkxVz5lNPlU78El49cx1c3Rxo/bp7Gbzp2BjSRFgg1U=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548/go.mod h1:k4lo5gFYb3AqrgftlraCnv95xvjB/w8udRqJQJ12mAE=
github.com/matst80/slask-finder v0.0.0-20250418094723-2eb7d6615761 h1:kEvcfY+vCg+sCeFxHj5AzKbLMSmxsWU53OEPeCi28RU=
github.com/matst80/slask-finder v0.0.0-20250418094723-2eb7d6615761/go.mod h1:+DqYJ8l2i/6haKggOnecs2mU7T8CC3v5XW3R4UGCgo4=
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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
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/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 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/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.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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
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.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI=
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q=
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
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/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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/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/yudhasubki/netpool v0.0.0-20230717065341-3c1353ca328e h1:fAzVSmKQkWflN25ED65CH/C1T3iVWq2BQfN7eQsg4E4=
github.com/yudhasubki/netpool v0.0.0-20230717065341-3c1353ca328e/go.mod h1:gQsFrHrY6nviQu+VX7zKWDyhtLPNzngtYZ+C+7cywdk=
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.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
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/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
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=
@@ -115,65 +148,72 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
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-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.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
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/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-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 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.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 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-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
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=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 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/rpc v0.0.0-20251007200510-49b9836ed3ff/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
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/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.32.3 h1:Hw7KqxRusq+6QSplE3NYG4MBxZw1BZnq4aP4cJVINls= k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM=
k8s.io/api v0.32.3/go.mod h1:2wEDTXADtm/HA7CCMD8D8bK4yuBUptzaRhYcYEEYA3k= k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk=
k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U= k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4=
k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
k8s.io/client-go v0.32.3 h1:RKPVltzopkSgHS7aS98QdscAgtgah/+zmpAogooIqVU= k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY=
k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY= k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e h1:KqK5c/ghOm8xkHYhlodbp6i6+r+ChV2vuAuVRdFbLro= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxgMd7wez1XkI= sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=

327
grafana_dashboard_cart.json Normal file
View File

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

View File

@@ -1,168 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"log"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
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",
})
)
type GrainPool interface {
Process(id CartId, messages ...Message) (*FrameWithPayload, error)
Get(id CartId) (*FrameWithPayload, error)
}
type Ttl struct {
Expires time.Time
Grain *CartGrain
}
type GrainLocalPool struct {
mu sync.RWMutex
grains map[CartId]*CartGrain
expiry []Ttl
spawn func(id CartId) (*CartGrain, error)
Ttl time.Duration
PoolSize int
}
func NewGrainLocalPool(size int, ttl time.Duration, spawn func(id CartId) (*CartGrain, error)) *GrainLocalPool {
ret := &GrainLocalPool{
spawn: spawn,
grains: make(map[CartId]*CartGrain),
expiry: make([]Ttl, 0),
Ttl: ttl,
PoolSize: size,
}
cartPurge := time.NewTicker(time.Minute)
go func() {
<-cartPurge.C
ret.Purge()
}()
return ret
}
func (p *GrainLocalPool) SetAvailable(availableWithLastChangeUnix map[CartId]int64) {
p.mu.Lock()
defer p.mu.Unlock()
for id := range availableWithLastChangeUnix {
if _, ok := p.grains[id]; !ok {
p.grains[id] = nil
p.expiry = append(p.expiry, Ttl{
Expires: time.Now().Add(p.Ttl),
Grain: nil,
})
}
}
}
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.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 {
p.expiry = append(p.expiry[:i], item)
}
} else {
log.Printf("Item %s expired", item.Grain.GetId())
delete(p.grains, 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
}
}
}
func (p *GrainLocalPool) GetGrains() map[CartId]*CartGrain {
return p.grains
}
func (p *GrainLocalPool) GetGrain(id CartId) (*CartGrain, error) {
var err error
// p.mu.RLock()
// defer p.mu.RUnlock()
grain, ok := p.grains[id]
grainLookups.Inc()
if grain == nil || !ok {
if len(p.grains) >= p.PoolSize {
if p.expiry[0].Expires.Before(time.Now()) {
delete(p.grains, p.expiry[0].Grain.GetId())
p.expiry = p.expiry[1:]
} else {
return nil, fmt.Errorf("pool is full")
}
}
grain, err = p.spawn(id)
p.mu.Lock()
p.grains[id] = grain
p.mu.Unlock()
}
go func() {
l := float64(len(p.grains))
ps := float64(p.PoolSize)
poolUsage.Set(l / ps)
poolGrains.Set(l)
poolSize.Set(ps)
}()
return grain, err
}
func (p *GrainLocalPool) Process(id CartId, messages ...Message) (*FrameWithPayload, error) {
grain, err := p.GetGrain(id)
var result *FrameWithPayload
if err == nil && grain != nil {
for _, message := range messages {
result, err = grain.HandleMessage(&message, false)
}
}
return result, err
}
func (p *GrainLocalPool) Get(id CartId) (*FrameWithPayload, error) {
grain, err := p.GetGrain(id)
if err != nil {
return nil, err
}
data, err := json.Marshal(grain)
if err != nil {
return nil, err
}
ret := MakeFrameWithPayload(0, 200, data)
return &ret, nil
}

View File

@@ -1,19 +0,0 @@
package main
import (
"testing"
)
func TestIdGeneration(t *testing.T) {
// Generate a random ID
id := NewCartId()
// Generate a random ID
id2 := NewCartId()
// Compare the two IDs
if id == id2 {
t.Errorf("IDs are the same: %v == %v", id, id2)
} else {
t.Log("ID generation test passed", id, id2)
}
}

174
k6/README.md Normal file
View File

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

248
k6/cart_load_test.js Normal file
View File

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

206
main.go
View File

@@ -12,7 +12,9 @@ import (
"syscall" "syscall"
"time" "time"
messages "git.tornberg.me/go-cart-actor/proto" "git.tornberg.me/go-cart-actor/pkg/actor"
"git.tornberg.me/go-cart-actor/pkg/discovery"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
@@ -43,10 +45,16 @@ func spawn(id CartId) (*CartGrain, error) {
Deliveries: []*CartDelivery{}, Deliveries: []*CartDelivery{},
Id: id, Id: id,
Items: []*CartItem{}, Items: []*CartItem{},
storageMessages: []Message{},
TotalPrice: 0, TotalPrice: 0,
} }
err := loadMessages(ret, id) // Set baseline lastChange at spawn; replay may update it to last event timestamp.
ret.lastChange = time.Now()
ret.lastAccess = time.Now()
// Legacy loadMessages (no-op) retained; then replay append-only event log
//_ = loadMessages(ret, id)
err := ReplayCartEvents(ret, id)
return ret, err return ret, err
} }
@@ -55,65 +63,31 @@ func init() {
} }
type App struct { type App struct {
pool *GrainLocalPool pool *CartPool
storage *DiskStorage storage *DiskStorage
} }
func (a *App) Save() error { // func (a *App) Save() error {
hasChanges := false // for id, grain := range a.pool.SnapshotGrains() {
a.pool.mu.RLock() // if grain == nil {
defer a.pool.mu.RUnlock() // continue
for id, grain := range a.pool.GetGrains() { // }
if grain == nil { // if grain.GetLastChange().After(a.storage.LastSaves[uint64(id)]) {
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 { // err := a.storage.Store(id, grain)
return nil // if err != nil {
} // log.Printf("Error saving grain %s: %v\n", id, err)
return a.storage.saveState() // }
} // }
// }
func (a *App) HandleSave(w http.ResponseWriter, r *http.Request) { // return nil
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 podIp = os.Getenv("POD_IP")
var name = os.Getenv("POD_NAME") var name = os.Getenv("POD_NAME")
var amqpUrl = os.Getenv("AMQP_URL") var amqpUrl = os.Getenv("AMQP_URL")
var KlarnaInstance = NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD")) var KlarnaInstance = NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
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)
}
var tpl = `<!DOCTYPE html> var tpl = `<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -135,58 +109,56 @@ func getCountryFromHost(host string) string {
return "se" return "se"
} }
func getCheckoutOrder(host string, cartId CartId) *messages.CreateCheckoutOrder { func GetDiscovery() discovery.Discovery {
baseUrl := fmt.Sprintf("https://%s", host) if podIp == "" {
cartBaseUrl := os.Getenv("CART_BASE_URL") return nil
if cartBaseUrl == "" {
cartBaseUrl = "https://cart.tornberg.me"
} }
country := getCountryFromHost(host)
return &messages.CreateCheckoutOrder{ config, kerr := rest.InClusterConfig()
Terms: fmt.Sprintf("%s/terms", baseUrl),
Checkout: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}", baseUrl), if kerr != nil {
Confirmation: fmt.Sprintf("%s/confirmation/{checkout.order.id}", baseUrl), log.Fatalf("Error creating kubernetes client: %v\n", kerr)
Validation: fmt.Sprintf("%s/validation", cartBaseUrl),
Push: fmt.Sprintf("%s/push?order_id={checkout.order.id}", cartBaseUrl),
Country: country,
} }
client, err := kubernetes.NewForConfig(config)
if err != nil {
log.Fatalf("Error creating client: %v\n", err)
}
return discovery.NewK8sDiscovery(client)
} }
func main() { func main() {
storage, err := NewDiskStorage(fmt.Sprintf("data/s_%s.gob", name))
storage, err := NewDiskStorage(fmt.Sprintf("data/%s_state.gob", name))
if err != nil { if err != nil {
log.Printf("Error loading state: %v\n", err) log.Printf("Error loading state: %v\n", err)
} }
pool, err := NewCartPool(2*65535, 15*time.Minute, podIp, spawn, GetDiscovery())
if err != nil {
log.Fatalf("Error creating cart pool: %v\n", err)
}
app := &App{ app := &App{
pool: NewGrainLocalPool(65535, 5*time.Minute, spawn), pool: pool,
storage: storage, storage: storage,
} }
syncedPool, err := NewSyncedPool(app.pool, podIp, GetDiscovery()) grpcSrv, err := actor.NewControlServer[*CartGrain](":1337", pool)
if err != nil { if err != nil {
log.Fatalf("Error creating synced pool: %v\n", err) log.Fatalf("Error starting control plane gRPC server: %v\n", err)
} }
defer grpcSrv.GracefulStop()
hg, err := NewGrainHandler(app.pool, ":1337") // go func() {
if err != nil { // for range time.Tick(time.Minute * 5) {
log.Fatalf("Error creating handler: %v\n", err) // err := app.Save()
} // if err != nil {
// log.Printf("Error saving: %v\n", err)
go func() { // }
for range time.Tick(time.Minute * 10) { // }
err := app.Save() // }()
if err != nil {
log.Printf("Error saving: %v\n", err)
}
}
}()
orderHandler := &AmqpOrderHandler{ orderHandler := &AmqpOrderHandler{
Url: amqpUrl, Url: amqpUrl,
} }
syncedServer := NewPoolServer(syncedPool, fmt.Sprintf("%s, %s", name, podIp)) syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp))
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/cart/", http.StripPrefix("/cart", syncedServer.Serve())) mux.Handle("/cart/", http.StripPrefix("/cart", syncedServer.Serve()))
// only for local // only for local
@@ -202,17 +174,18 @@ func main() {
mux.HandleFunc("/debug/pprof/trace", pprof.Trace) mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
mux.Handle("/metrics", promhttp.Handler()) mux.Handle("/metrics", promhttp.Handler())
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
if !hg.IsHealthy() { // Grain pool health: simple capacity check (mirrors previous GrainHandler.IsHealthy)
grainCount, capacity := app.pool.LocalUsage()
if grainCount >= capacity {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("handler not healthy")) w.Write([]byte("grain pool at capacity"))
return return
} }
if !syncedPool.IsHealthy() { if !pool.IsHealthy() {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("pool not healthy")) w.Write([]byte("control plane not healthy"))
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte("ok")) w.Write([]byte("ok"))
}) })
@@ -241,34 +214,32 @@ func main() {
w.Write([]byte("no cart id to checkout is empty")) w.Write([]byte("no cart id to checkout is empty"))
return return
} }
cartId := ToCartId(cookie.Value) parsed, ok := ParseCartId(cookie.Value)
reply, err := syncedServer.pool.Process(cartId, Message{ if !ok {
Type: CreateCheckoutOrderType, w.WriteHeader(http.StatusBadRequest)
Content: getCheckoutOrder(r.Host, cartId), w.Write([]byte("invalid cart id format"))
})
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
err = json.Unmarshal(reply.Payload, &order)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return return
} }
cartId := parsed
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 { } else {
prevOrder, err := KlarnaInstance.GetOrder(orderId) order, err = KlarnaInstance.GetOrder(orderId)
if err != nil { if err != nil {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error())) w.Write([]byte(err.Error()))
return return
} }
order = prevOrder
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") 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.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte(fmt.Sprintf(tpl, order.HTMLSnippet))) fmt.Fprintf(w, tpl, order.HTMLSnippet)
}) })
mux.HandleFunc("/confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) {
@@ -295,7 +266,7 @@ func main() {
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte(fmt.Sprintf(tpl, order.HTMLSnippet))) fmt.Fprintf(w, tpl, order.HTMLSnippet)
}) })
mux.HandleFunc("/validate", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/validate", func(w http.ResponseWriter, r *http.Request) {
log.Printf("Klarna order validation, method: %s", r.Method) log.Printf("Klarna order validation, method: %s", r.Method)
@@ -373,25 +344,32 @@ func main() {
go func() { go func() {
sig := <-sigs sig := <-sigs
fmt.Println("Shutting down due to signal:", sig) fmt.Println("Shutting down due to signal:", sig)
go syncedPool.Close() //app.Save()
app.Save() pool.Close()
done <- true done <- true
}() }()
log.Print("Server started at port 8080")
go http.ListenAndServe(":8080", mux) go http.ListenAndServe(":8080", mux)
<-done <-done
} }
func triggerOrderCompleted(err error, syncedServer *PoolServer, order *CheckoutOrder) error { func triggerOrderCompleted(err error, syncedServer *PoolServer, order *CheckoutOrder) error {
_, err = syncedServer.pool.Process(ToCartId(order.MerchantReference1), Message{ mutation := &messages.OrderCreated{
Type: OrderCompletedType,
Content: &messages.OrderCreated{
OrderId: order.ID, OrderId: order.ID,
Status: order.Status, Status: order.Status,
}, }
}) cid, ok := ParseCartId(order.MerchantReference1)
return err if !ok {
return fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
}
_, applyErr := syncedServer.pool.Apply(uint64(cid), mutation)
if applyErr == nil {
_ = AppendCartEvent(cid, mutation)
}
return applyErr
} }
func confirmOrder(order *CheckoutOrder, orderHandler *AmqpOrderHandler) error { func confirmOrder(order *CheckoutOrder, orderHandler *AmqpOrderHandler) error {

View File

@@ -1,286 +0,0 @@
package main
import (
"os"
"testing"
)
func TestGetCountryFromHost(t *testing.T) {
tests := []struct {
name string
host string
expected string
}{
{
name: "Norwegian host",
host: "s10n-no.tornberg.me",
expected: "no",
},
{
name: "Swedish host",
host: "s10n-se.tornberg.me",
expected: "se",
},
{
name: "Host with -no in the middle",
host: "api-no-staging.tornberg.me",
expected: "no",
},
{
name: "Host without country suffix",
host: "s10n.tornberg.me",
expected: "se",
},
{
name: "Host with different domain",
host: "example-no.com",
expected: "no",
},
{
name: "Empty host",
host: "",
expected: "se",
},
{
name: "Host with uppercase",
host: "S10N-NO.TORNBERG.ME",
expected: "no",
},
{
name: "Host with mixed case",
host: "S10n-No.Tornberg.Me",
expected: "no",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getCountryFromHost(tt.host)
if result != tt.expected {
t.Errorf("getCountryFromHost(%q) = %q, want %q", tt.host, result, tt.expected)
}
})
}
}
func TestGetCheckoutOrder(t *testing.T) {
// Save original environment variable and restore after test
originalCartBaseUrl := os.Getenv("CART_BASE_URL")
defer func() {
if originalCartBaseUrl == "" {
os.Unsetenv("CART_BASE_URL")
} else {
os.Setenv("CART_BASE_URL", originalCartBaseUrl)
}
}()
tests := []struct {
name string
host string
cartId CartId
cartBaseUrl string
expectedUrls struct {
terms string
checkout string
confirmation string
validation string
push string
country string
}
}{
{
name: "Norwegian host with default cart base URL",
host: "s10n-no.tornberg.me",
cartId: ToCartId("test-cart-123"),
cartBaseUrl: "", // Use default
expectedUrls: struct {
terms string
checkout string
confirmation string
validation string
push string
country string
}{
terms: "https://s10n-no.tornberg.me/terms",
checkout: "https://s10n-no.tornberg.me/checkout?order_id={checkout.order.id}",
confirmation: "https://s10n-no.tornberg.me/confirmation/{checkout.order.id}",
validation: "https://cart.tornberg.me/validation",
push: "https://cart.tornberg.me/push?order_id={checkout.order.id}",
country: "no",
},
},
{
name: "Swedish host with default cart base URL",
host: "s10n-se.tornberg.me",
cartId: ToCartId("test-cart-456"),
cartBaseUrl: "", // Use default
expectedUrls: struct {
terms string
checkout string
confirmation string
validation string
push string
country string
}{
terms: "https://s10n-se.tornberg.me/terms",
checkout: "https://s10n-se.tornberg.me/checkout?order_id={checkout.order.id}",
confirmation: "https://s10n-se.tornberg.me/confirmation/{checkout.order.id}",
validation: "https://cart.tornberg.me/validation",
push: "https://cart.tornberg.me/push?order_id={checkout.order.id}",
country: "se",
},
},
{
name: "Norwegian host with custom cart base URL",
host: "s10n-no.tornberg.me",
cartId: ToCartId("test-cart-789"),
cartBaseUrl: "https://custom-cart.example.com",
expectedUrls: struct {
terms string
checkout string
confirmation string
validation string
push string
country string
}{
terms: "https://s10n-no.tornberg.me/terms",
checkout: "https://s10n-no.tornberg.me/checkout?order_id={checkout.order.id}",
confirmation: "https://s10n-no.tornberg.me/confirmation/{checkout.order.id}",
validation: "https://custom-cart.example.com/validation",
push: "https://custom-cart.example.com/push?order_id={checkout.order.id}",
country: "no",
},
},
{
name: "Host without country code defaults to Swedish",
host: "s10n.tornberg.me",
cartId: ToCartId("test-cart-default"),
cartBaseUrl: "",
expectedUrls: struct {
terms string
checkout string
confirmation string
validation string
push string
country string
}{
terms: "https://s10n.tornberg.me/terms",
checkout: "https://s10n.tornberg.me/checkout?order_id={checkout.order.id}",
confirmation: "https://s10n.tornberg.me/confirmation/{checkout.order.id}",
validation: "https://cart.tornberg.me/validation",
push: "https://cart.tornberg.me/push?order_id={checkout.order.id}",
country: "se",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Set up environment variable for this test
if tt.cartBaseUrl == "" {
os.Unsetenv("CART_BASE_URL")
} else {
os.Setenv("CART_BASE_URL", tt.cartBaseUrl)
}
result := getCheckoutOrder(tt.host, tt.cartId)
// Verify the result is not nil
if result == nil {
t.Fatal("getCheckoutOrder returned nil")
}
// Check each URL field
if result.Terms != tt.expectedUrls.terms {
t.Errorf("Terms URL: got %q, want %q", result.Terms, tt.expectedUrls.terms)
}
if result.Checkout != tt.expectedUrls.checkout {
t.Errorf("Checkout URL: got %q, want %q", result.Checkout, tt.expectedUrls.checkout)
}
if result.Confirmation != tt.expectedUrls.confirmation {
t.Errorf("Confirmation URL: got %q, want %q", result.Confirmation, tt.expectedUrls.confirmation)
}
if result.Validation != tt.expectedUrls.validation {
t.Errorf("Validation URL: got %q, want %q", result.Validation, tt.expectedUrls.validation)
}
if result.Push != tt.expectedUrls.push {
t.Errorf("Push URL: got %q, want %q", result.Push, tt.expectedUrls.push)
}
if result.Country != tt.expectedUrls.country {
t.Errorf("Country: got %q, want %q", result.Country, tt.expectedUrls.country)
}
})
}
}
func TestGetCheckoutOrderIntegration(t *testing.T) {
// Test that both functions work together correctly
hosts := []string{"s10n-no.tornberg.me", "s10n-se.tornberg.me"}
cartId := ToCartId("integration-test-cart")
for _, host := range hosts {
t.Run(host, func(t *testing.T) {
// Get country from host
country := getCountryFromHost(host)
// Get checkout order
order := getCheckoutOrder(host, cartId)
// Verify that the country in the order matches what getCountryFromHost returns
if order.Country != country {
t.Errorf("Country mismatch: getCountryFromHost(%q) = %q, but order.Country = %q",
host, country, order.Country)
}
// Verify that all URLs contain the correct host
expectedBaseUrl := "https://" + host
if !containsPrefix(order.Terms, expectedBaseUrl) {
t.Errorf("Terms URL should start with %q, got %q", expectedBaseUrl, order.Terms)
}
if !containsPrefix(order.Checkout, expectedBaseUrl) {
t.Errorf("Checkout URL should start with %q, got %q", expectedBaseUrl, order.Checkout)
}
if !containsPrefix(order.Confirmation, expectedBaseUrl) {
t.Errorf("Confirmation URL should start with %q, got %q", expectedBaseUrl, order.Confirmation)
}
})
}
}
// Helper function to check if a string starts with a prefix
func containsPrefix(s, prefix string) bool {
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
}
// Benchmark tests to measure performance
func BenchmarkGetCountryFromHost(b *testing.B) {
hosts := []string{
"s10n-no.tornberg.me",
"s10n-se.tornberg.me",
"api-no-staging.tornberg.me",
"s10n.tornberg.me",
}
for i := 0; i < b.N; i++ {
for _, host := range hosts {
getCountryFromHost(host)
}
}
}
func BenchmarkGetCheckoutOrder(b *testing.B) {
host := "s10n-no.tornberg.me"
cartId := ToCartId("benchmark-cart")
for i := 0; i < b.N; i++ {
getCheckoutOrder(host, cartId)
}
}

View File

@@ -1,315 +0,0 @@
package main
import (
"fmt"
"io"
messages "git.tornberg.me/go-cart-actor/proto"
"google.golang.org/protobuf/proto"
)
var Handlers = map[uint16]MessageHandler{
AddRequestType: &AddRequestHandler{},
AddItemType: &AddItemHandler{},
ChangeQuantityType: &ChangeQuantityHandler{},
SetDeliveryType: &SetDeliveryHandler{},
RemoveItemType: &RemoveItemHandler{},
RemoveDeliveryType: &RemoveDeliveryHandler{},
CreateCheckoutOrderType: &CheckoutHandler{},
SetCartItemsType: &SetCartItemsHandler{},
OrderCompletedType: &OrderCompletedHandler{},
}
func GetMessageHandler(t uint16) (MessageHandler, error) {
h, ok := Handlers[t]
if !ok {
return nil, fmt.Errorf("no handler for message type %d", t)
}
return h, nil
}
type MessageHandler interface {
Write(*Message, io.Writer) error
Read(data []byte) (interface{}, error)
Is(*Message) bool
}
type TypedMessageHandler struct {
Type uint16
}
type SetCartItemsHandler struct {
TypedMessageHandler
}
func (h *SetCartItemsHandler) Write(m *Message, w io.Writer) error {
messageBytes, err := proto.Marshal(m.Content.(*messages.SetCartRequest))
if err != nil {
return err
}
w.Write(messageBytes)
return nil
}
func (h *SetCartItemsHandler) Read(data []byte) (interface{}, error) {
msg := &messages.SetCartRequest{}
err := proto.Unmarshal(data, msg)
if err != nil {
return nil, err
}
return msg, nil
}
func (h *SetCartItemsHandler) Is(m *Message) bool {
if m.Type != AddRequestType {
return false
}
_, ok := m.Content.(*messages.SetCartRequest)
return ok
}
type AddRequestHandler struct {
TypedMessageHandler
}
func (h *AddRequestHandler) Write(m *Message, w io.Writer) error {
messageBytes, err := proto.Marshal(m.Content.(*messages.AddRequest))
if err != nil {
return err
}
w.Write(messageBytes)
return nil
}
func (h *AddRequestHandler) Read(data []byte) (interface{}, error) {
msg := &messages.AddRequest{}
err := proto.Unmarshal(data, msg)
if err != nil {
return nil, err
}
return msg, nil
}
func (h *AddRequestHandler) Is(m *Message) bool {
if m.Type != AddRequestType {
return false
}
_, ok := m.Content.(*messages.AddRequest)
return ok
}
type AddItemHandler struct {
TypedMessageHandler
}
func (h *AddItemHandler) Write(m *Message, w io.Writer) error {
messageBytes, err := proto.Marshal(m.Content.(*messages.AddItem))
if err != nil {
return err
}
w.Write(messageBytes)
return nil
}
func (h *AddItemHandler) Read(data []byte) (interface{}, error) {
msg := &messages.AddItem{}
err := proto.Unmarshal(data, msg)
if err != nil {
return nil, err
}
return msg, nil
}
func (h *AddItemHandler) Is(m *Message) bool {
if m.Type != AddItemType {
return false
}
_, ok := m.Content.(*messages.AddItem)
return ok
}
type ChangeQuantityHandler struct {
TypedMessageHandler
}
func (h *ChangeQuantityHandler) Write(m *Message, w io.Writer) error {
messageBytes, err := proto.Marshal(m.Content.(*messages.ChangeQuantity))
if err != nil {
return err
}
w.Write(messageBytes)
return nil
}
func (h *ChangeQuantityHandler) Read(data []byte) (interface{}, error) {
msg := &messages.ChangeQuantity{}
err := proto.Unmarshal(data, msg)
if err != nil {
return nil, err
}
return msg, nil
}
func (h *ChangeQuantityHandler) Is(m *Message) bool {
if m.Type != ChangeQuantityType {
return false
}
_, ok := m.Content.(*messages.ChangeQuantity)
return ok
}
type SetDeliveryHandler struct {
TypedMessageHandler
}
func (h *SetDeliveryHandler) Write(m *Message, w io.Writer) error {
messageBytes, err := proto.Marshal(m.Content.(*messages.SetDelivery))
if err != nil {
return err
}
w.Write(messageBytes)
return nil
}
func (h *SetDeliveryHandler) Read(data []byte) (interface{}, error) {
msg := &messages.SetDelivery{}
err := proto.Unmarshal(data, msg)
if err != nil {
return nil, err
}
return msg, nil
}
func (h *SetDeliveryHandler) Is(m *Message) bool {
if m.Type != ChangeQuantityType {
return false
}
_, ok := m.Content.(*messages.SetDelivery)
return ok
}
type RemoveItemHandler struct {
TypedMessageHandler
}
func (h *RemoveItemHandler) Write(m *Message, w io.Writer) error {
messageBytes, err := proto.Marshal(m.Content.(*messages.RemoveItem))
if err != nil {
return err
}
w.Write(messageBytes)
return nil
}
func (h *RemoveItemHandler) Read(data []byte) (interface{}, error) {
msg := &messages.RemoveItem{}
err := proto.Unmarshal(data, msg)
if err != nil {
return nil, err
}
return msg, nil
}
func (h *RemoveItemHandler) Is(m *Message) bool {
if m.Type != AddItemType {
return false
}
_, ok := m.Content.(*messages.RemoveItem)
return ok
}
type RemoveDeliveryHandler struct {
TypedMessageHandler
}
func (h *RemoveDeliveryHandler) Write(m *Message, w io.Writer) error {
messageBytes, err := proto.Marshal(m.Content.(*messages.RemoveDelivery))
if err != nil {
return err
}
w.Write(messageBytes)
return nil
}
func (h *RemoveDeliveryHandler) Read(data []byte) (interface{}, error) {
msg := &messages.RemoveDelivery{}
err := proto.Unmarshal(data, msg)
if err != nil {
return nil, err
}
return msg, nil
}
func (h *RemoveDeliveryHandler) Is(m *Message) bool {
if m.Type != AddItemType {
return false
}
_, ok := m.Content.(*messages.RemoveDelivery)
return ok
}
type CheckoutHandler struct {
TypedMessageHandler
}
func (h *CheckoutHandler) Write(m *Message, w io.Writer) error {
messageBytes, err := proto.Marshal(m.Content.(*messages.CreateCheckoutOrder))
if err != nil {
return err
}
w.Write(messageBytes)
return nil
}
func (h *CheckoutHandler) Read(data []byte) (interface{}, error) {
msg := &messages.CreateCheckoutOrder{}
err := proto.Unmarshal(data, msg)
if err != nil {
return nil, err
}
return msg, nil
}
func (h *CheckoutHandler) Is(m *Message) bool {
if m.Type != CreateCheckoutOrderType {
return false
}
_, ok := m.Content.(*messages.CreateCheckoutOrder)
return ok
}
type OrderCompletedHandler struct {
TypedMessageHandler
}
func (h *OrderCompletedHandler) Write(m *Message, w io.Writer) error {
messageBytes, err := proto.Marshal(m.Content.(*messages.OrderCreated))
if err != nil {
return err
}
w.Write(messageBytes)
return nil
}
func (h *OrderCompletedHandler) Read(data []byte) (interface{}, error) {
msg := &messages.OrderCreated{}
err := proto.Unmarshal(data, msg)
if err != nil {
return nil, err
}
return msg, nil
}
func (h *OrderCompletedHandler) Is(m *Message) bool {
if m.Type != OrderCompletedType {
return false
}
_, ok := m.Content.(*messages.OrderCreated)
return ok
}

View File

@@ -1,129 +0,0 @@
package main
import (
"bytes"
"testing"
messages "git.tornberg.me/go-cart-actor/proto"
)
func TestAddRequest(t *testing.T) {
h, err := GetMessageHandler(AddRequestType)
if err != nil {
t.Errorf("Error getting message handler: %v\n", err)
}
if h == nil {
t.Errorf("Expected message handler, got nil\n")
}
message := Message{
Type: AddRequestType,
Content: &messages.AddRequest{
Quantity: 2,
Sku: "123",
},
}
var b bytes.Buffer
err = h.Write(&message, &b)
if err != nil {
t.Errorf("Error writing message: %v\n", err)
}
result, err := h.Read(b.Bytes())
if err != nil {
t.Errorf("Error reading message: %v\n", err)
}
if result == nil {
t.Errorf("Expected result, got nil\n")
}
r, ok := result.(*messages.AddRequest)
if !ok {
t.Errorf("Expected AddRequest, got %T\n", result)
}
if r.Quantity != 2 {
t.Errorf("Expected quantity 2, got %d\n", r.Quantity)
}
if r.Sku != "123" {
t.Errorf("Expected sku '123', got %s\n", r.Sku)
}
}
func TestItemRequest(t *testing.T) {
h, err := GetMessageHandler(AddItemType)
if err != nil {
t.Errorf("Error getting message handler: %v\n", err)
}
if h == nil {
t.Errorf("Expected message handler, got nil\n")
}
message := Message{
Type: AddItemType,
Content: &messages.AddItem{
Quantity: 2,
Sku: "123",
Price: 100,
Name: "Test item",
Image: "test.jpg",
},
}
var b bytes.Buffer
err = h.Write(&message, &b)
if err != nil {
t.Errorf("Error writing message: %v\n", err)
}
result, err := h.Read(b.Bytes())
if err != nil {
t.Errorf("Error reading message: %v\n", err)
}
if result == nil {
t.Errorf("Expected result, got nil\n")
}
var r *messages.AddItem
ok := h.Is(&message)
if !ok {
t.Errorf("Expected AddRequest, got %T\n", result)
}
if r.Quantity != 2 {
t.Errorf("Expected quantity 2, got %d\n", r.Quantity)
}
if r.Sku != "123" {
t.Errorf("Expected sku '123', got %s\n", r.Sku)
}
}
func TestSetDeliveryMssage(t *testing.T) {
h, err := GetMessageHandler(SetDeliveryType)
if err != nil {
t.Errorf("Error getting message handler: %v\n", err)
}
if h == nil {
t.Errorf("Expected message handler, got nil\n")
}
message := Message{
Type: SetDeliveryType,
Content: &messages.SetDelivery{
Provider: "test",
Items: []int64{1, 2},
},
}
var b bytes.Buffer
err = h.Write(&message, &b)
if err != nil {
t.Errorf("Error writing message: %v\n", err)
}
result, err := h.Read(b.Bytes())
if err != nil {
t.Errorf("Error reading message: %v\n", err)
}
if result == nil {
t.Errorf("Expected result, got nil\n")
}
r, ok := result.(*messages.SetDelivery)
if !ok {
t.Errorf("Expected AddRequest, got %T\n", result)
}
if len(r.Items) != 2 {
t.Errorf("Expected 2 items, got %d\n", len(r.Items))
}
if r.Provider != "test" {
t.Errorf("Expected provider 'test', got %s\n", r.Provider)
}
}

View File

@@ -1,15 +0,0 @@
package main
const (
AddRequestType = 1
AddItemType = 2
RemoveItemType = 4
RemoveDeliveryType = 5
ChangeQuantityType = 6
SetDeliveryType = 7
SetPickupPointType = 8
CreateCheckoutOrderType = 9
SetCartItemsType = 10
OrderCompletedType = 11
)

View File

@@ -1,94 +0,0 @@
package main
import (
"bytes"
"encoding/binary"
"io"
"time"
)
type StorableMessage interface {
Write(w io.Writer) error
}
type Message struct {
Type uint16
TimeStamp *int64
Content interface{}
}
type MessageWriter struct {
io.Writer
}
type StorableMessageHeader struct {
Version uint16
Type uint16
TimeStamp int64
DataLength uint64
}
func GetData(fn func(w io.Writer) error) ([]byte, error) {
var buf bytes.Buffer
err := fn(&buf)
if err != nil {
return nil, err
}
b := buf.Bytes()
return b, nil
}
func (m Message) Write(w io.Writer) error {
h, err := GetMessageHandler(m.Type)
if err != nil {
return err
}
data, err := GetData(func(w io.Writer) error {
return h.Write(&m, w)
})
if err != nil {
return err
}
ts := time.Now().Unix()
if m.TimeStamp != nil {
ts = *m.TimeStamp
}
err = binary.Write(w, binary.LittleEndian, StorableMessageHeader{
Version: 1,
Type: m.Type,
TimeStamp: ts,
DataLength: uint64(len(data)),
})
w.Write(data)
return err
}
func ReadMessage(reader io.Reader, m *Message) error {
header := StorableMessageHeader{}
err := binary.Read(reader, binary.LittleEndian, &header)
if err != nil {
return err
}
messageBytes := make([]byte, header.DataLength)
_, err = reader.Read(messageBytes)
if err != nil {
return err
}
h, err := GetMessageHandler(header.Type)
if err != nil {
return err
}
content, err := h.Read(messageBytes)
if err != nil {
return err
}
m.Content = content
m.Type = header.Type
m.TimeStamp = &header.TimeStamp
return nil
}

82
mutation_add_item.go Normal file
View File

@@ -0,0 +1,82 @@
package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_add_item.go
//
// Registers the AddItem cart mutation in the generic mutation registry.
// This replaces the legacy switch-based logic previously found in CartGrain.Apply.
//
// Behavior:
// * Validates quantity > 0
// * If an item with same SKU exists -> increases quantity
// * Else creates a new CartItem with computed tax amounts
// * Totals recalculated automatically via WithTotals()
//
// NOTE: Any future field additions in messages.AddItem that affect pricing / tax
// must keep this handler in sync.
func 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
)
}

61
mutation_add_request.go Normal file
View File

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

View File

@@ -0,0 +1,58 @@
package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_change_quantity.go
//
// Registers the ChangeQuantity mutation.
//
// Behavior:
// - Locates an item by its cart-local line item Id (not source item_id).
// - If requested quantity <= 0 the line is removed.
// - Otherwise the line's Quantity field is updated.
// - Totals are recalculated (WithTotals).
//
// Error handling:
// - Returns an error if the item Id is not found.
// - Returns an error if payload is nil (defensive).
//
// Concurrency:
// - Uses the grain's RW-safe mutation pattern: we mutate in place under
// the grain's implicit expectation that higher layers control access.
// (If strict locking is required around every mutation, wrap logic in
// an explicit g.mu.Lock()/Unlock(), but current model mirrors prior code.)
func init() {
RegisterMutation[messages.ChangeQuantity](
"ChangeQuantity",
func(g *CartGrain, m *messages.ChangeQuantity) error {
if m == nil {
return fmt.Errorf("ChangeQuantity: nil payload")
}
foundIndex := -1
for i, it := range g.Items {
if it.Id == int(m.Id) {
foundIndex = i
break
}
}
if foundIndex == -1 {
return fmt.Errorf("ChangeQuantity: item id %d not found", m.Id)
}
if m.Quantity <= 0 {
// Remove the item
g.Items = append(g.Items[:foundIndex], g.Items[foundIndex+1:]...)
return nil
}
g.Items[foundIndex].Quantity = int(m.Quantity)
return nil
},
WithTotals(),
)
}

View File

@@ -0,0 +1,49 @@
package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_initialize_checkout.go
//
// Registers the InitializeCheckout mutation.
// This mutation is invoked AFTER an external Klarna checkout session
// has been successfully created or updated. It persists the Klarna
// order reference / status and marks the cart as having a payment in progress.
//
// Behavior:
// - Sets OrderReference to the Klarna order ID (overwriting if already set).
// - Sets PaymentStatus to the current Klarna status.
// - Sets / updates PaymentInProgress flag.
// - Does NOT alter pricing or line items (so no totals recalculation).
//
// Validation:
// - Returns an error if payload is nil.
// - Returns an error if orderId is empty (integrity guard).
//
// Concurrency:
// - Relies on upstream mutation serialization for a single grain. If
// parallel checkout attempts are possible, add higher-level guards
// (e.g. reject if PaymentInProgress already true unless reusing
// the same OrderReference).
func init() {
RegisterMutation[messages.InitializeCheckout](
"InitializeCheckout",
func(g *CartGrain, m *messages.InitializeCheckout) error {
if m == nil {
return fmt.Errorf("InitializeCheckout: nil payload")
}
if m.OrderId == "" {
return fmt.Errorf("InitializeCheckout: missing orderId")
}
g.OrderReference = m.OrderId
g.PaymentStatus = m.Status
g.PaymentInProgress = m.PaymentInProgress
return nil
},
// No WithTotals(): monetary aggregates are unaffected.
)
}

53
mutation_order_created.go Normal file
View File

@@ -0,0 +1,53 @@
package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_order_created.go
//
// Registers the OrderCreated mutation.
//
// This mutation represents the completion (or state transition) of an order
// initiated earlier via InitializeCheckout / external Klarna processing.
// It finalizes (or updates) the cart's order metadata.
//
// Behavior:
// - Validates payload non-nil and OrderId not empty.
// - Sets (or overwrites) OrderReference with the provided OrderId.
// - Sets PaymentStatus from payload.Status.
// - Marks PaymentInProgress = false (checkout flow finished / acknowledged).
// - Does NOT adjust monetary totals (no WithTotals()).
//
// Notes / Future Extensions:
// - If multiple order completion events can arrive (e.g., retries / webhook
// replays), this handler is idempotent: it simply overwrites fields.
// - If you need to guard against conflicting order IDs, add a check:
// if g.OrderReference != "" && g.OrderReference != m.OrderId { ... }
// - Add audit logging or metrics here if required.
//
// Concurrency:
// - Relies on the higher-level guarantee that Apply() calls are serialized
// per grain. If out-of-order events are possible, embed versioning or
// timestamps in the mutation and compare before applying changes.
func init() {
RegisterMutation[messages.OrderCreated](
"OrderCreated",
func(g *CartGrain, m *messages.OrderCreated) error {
if m == nil {
return fmt.Errorf("OrderCreated: nil payload")
}
if m.OrderId == "" {
return fmt.Errorf("OrderCreated: missing orderId")
}
g.OrderReference = m.OrderId
g.PaymentStatus = m.Status
g.PaymentInProgress = false
return nil
},
// No WithTotals(): order completion does not modify pricing or taxes.
)
}

301
mutation_registry.go Normal file
View File

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

View File

@@ -0,0 +1,53 @@
package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_remove_delivery.go
//
// Registers the RemoveDelivery mutation.
//
// Behavior:
// - Removes the delivery entry whose Id == payload.Id.
// - If not found, returns an error.
// - Cart totals are recalculated (WithTotals) after removal.
// - Items previously associated with that delivery simply become "without delivery";
// subsequent delivery mutations can reassign them.
//
// Differences vs legacy:
// - Legacy logic decremented TotalPrice explicitly before recalculating.
// Here we rely solely on UpdateTotals() to recompute from remaining
// deliveries and items (simpler / single source of truth).
//
// Future considerations:
// - If delivery pricing logic changes (e.g., dynamic taxes per delivery),
// UpdateTotals() may need enhancement to incorporate delivery tax properly.
func init() {
RegisterMutation[messages.RemoveDelivery](
"RemoveDelivery",
func(g *CartGrain, m *messages.RemoveDelivery) error {
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)
g.Deliveries = append(g.Deliveries[:index], g.Deliveries[index+1:]...)
return nil
},
WithTotals(),
)
}

49
mutation_remove_item.go Normal file
View File

@@ -0,0 +1,49 @@
package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_remove_item.go
//
// Registers the RemoveItem mutation.
//
// Behavior:
// - Removes the cart line whose local cart line Id == payload.Id
// - If no such line exists returns an error
// - Recalculates cart totals (WithTotals)
//
// Notes:
// - This removes only the line item; any deliveries referencing the removed
// item are NOT automatically adjusted (mirrors prior logic). If future
// semantics require pruning delivery.item_ids you can extend this handler.
// - If multiple lines somehow shared the same Id (should not happen), only
// the first match would be removed—data integrity relies on unique line Ids.
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
for i, it := range g.Items {
if it.Id == targetID {
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:]...)
return nil
},
WithTotals(),
)
}

View File

@@ -0,0 +1,57 @@
package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// 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(),
)
}

101
mutation_set_delivery.go Normal file
View File

@@ -0,0 +1,101 @@
package main
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 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(),
)
}

View File

@@ -0,0 +1,56 @@
package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_set_pickup_point.go
//
// Registers the SetPickupPoint mutation using the generic mutation registry.
//
// Semantics (mirrors original switch-based implementation):
// - Locate the delivery with Id == payload.DeliveryId
// - Set (or overwrite) its PickupPoint with the provided data
// - Does NOT alter pricing or taxes (so no totals recalculation required)
//
// Validation / Error Handling:
// - If payload is nil -> error
// - If DeliveryId not found -> error
//
// Concurrency:
// - Relies on the existing expectation that higher-level mutation routing
// serializes Apply() calls per grain; if stricter guarantees are needed,
// a delivery-level lock could be introduced later.
//
// Future Extensions:
// - Validate pickup point fields (country code, zip format, etc.)
// - Track history / audit of pickup point changes
// - Trigger delivery price adjustments (which would then require WithTotals()).
func init() {
RegisterMutation[messages.SetPickupPoint](
"SetPickupPoint",
func(g *CartGrain, m *messages.SetPickupPoint) error {
if m == nil {
return fmt.Errorf("SetPickupPoint: nil payload")
}
for _, d := range g.Deliveries {
if d.Id == int(m.DeliveryId) {
d.PickupPoint = &messages.PickupPoint{
Id: m.Id,
Name: m.Name,
Address: m.Address,
City: m.City,
Zip: m.Zip,
Country: m.Country,
}
return nil
}
}
return fmt.Errorf("SetPickupPoint: delivery id %d not found", m.DeliveryId)
},
// No WithTotals(): pickup point does not change pricing / tax.
)
}

View File

@@ -1,89 +0,0 @@
package main
const (
RemoteGetState = FrameType(0x01)
RemoteHandleMutation = FrameType(0x02)
ResponseBody = FrameType(0x03)
RemoteGetStateReply = FrameType(0x04)
RemoteHandleMutationReply = FrameType(0x05)
RemoteCreateOrderReply = FrameType(0x06)
)
// type CartPacket struct {
// Version PackageVersion
// MessageType CartMessage
// DataLength uint32
// StatusCode uint32
// Id CartId
// }
// type Packet struct {
// Version PackageVersion
// MessageType PoolMessage
// DataLength uint32
// StatusCode uint32
// }
// var headerData = make([]byte, 4)
// func matchHeader(conn io.Reader) error {
// pos := 0
// for pos < 4 {
// l, err := conn.Read(headerData)
// if err != nil {
// return err
// }
// for i := 0; i < l; i++ {
// if headerData[i] == header[pos] {
// pos++
// if pos == 4 {
// return nil
// }
// } else {
// pos = 0
// }
// }
// }
// return nil
// }
// func ReadPacket(conn io.Reader, packet *Packet) error {
// err := matchHeader(conn)
// if err != nil {
// return err
// }
// return binary.Read(conn, binary.LittleEndian, packet)
// }
// func ReadCartPacket(conn io.Reader, packet *CartPacket) error {
// err := matchHeader(conn)
// if err != nil {
// return err
// }
// return binary.Read(conn, binary.LittleEndian, packet)
// }
// func GetPacketData(conn io.Reader, len uint32) ([]byte, error) {
// if len == 0 {
// return []byte{}, nil
// }
// data := make([]byte, len)
// _, err := conn.Read(data)
// return data, err
// }
// func ReceivePacket(conn io.Reader) (uint32, []byte, error) {
// var packet Packet
// err := ReadPacket(conn, &packet)
// if err != nil {
// return 0, nil, err
// }
// data, err := GetPacketData(conn, packet.DataLength)
// if err != nil {
// return 0, nil, err
// }
// return packet.MessageType, data, nil
// }

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

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

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

@@ -0,0 +1,25 @@
package actor
import "net/http"
type GrainPool[V any] interface {
Apply(id uint64, mutation any) (V, error)
Get(id uint64) (V, error)
OwnerHost(id uint64) (Host, bool)
Hostname() string
TakeOwnership(id uint64)
HandleOwnershipChange(host string, ids []uint64) error
HandleRemoteExpiry(host string, ids []uint64) error
Negotiate(otherHosts []string)
GetLocalIds() []uint64
RemoveHost(host string)
IsHealthy() bool
Close()
}
// Host abstracts a remote node capable of proxying cart requests.
type Host interface {
Name() string
Proxy(id uint64, w http.ResponseWriter, r *http.Request) (bool, error)
GetActorIds() []uint64
}

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

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

View File

@@ -1,4 +1,4 @@
package main package discovery
import ( import (
"context" "context"
@@ -11,11 +11,6 @@ import (
toolsWatch "k8s.io/client-go/tools/watch" toolsWatch "k8s.io/client-go/tools/watch"
) )
type Discovery interface {
Discover() ([]string, error)
Watch() (<-chan HostChange, error)
}
type K8sDiscovery struct { type K8sDiscovery struct {
ctx context.Context ctx context.Context
client *kubernetes.Clientset client *kubernetes.Clientset

View File

@@ -0,0 +1,102 @@
package discovery
import (
"context"
"sync"
"k8s.io/apimachinery/pkg/watch"
)
// MockDiscovery is an in-memory Discovery implementation for tests.
// It allows deterministic injection of host additions/removals without
// depending on Kubernetes API machinery.
type MockDiscovery struct {
mu sync.RWMutex
hosts []string
events chan HostChange
closed bool
started bool
}
// NewMockDiscovery creates a mock discovery with an initial host list.
func NewMockDiscovery(initial []string) *MockDiscovery {
cp := make([]string, len(initial))
copy(cp, initial)
return &MockDiscovery{
hosts: cp,
events: make(chan HostChange, 32),
}
}
// Discover returns the current host snapshot.
func (m *MockDiscovery) Discover() ([]string, error) {
m.mu.RLock()
defer m.mu.RUnlock()
cp := make([]string, len(m.hosts))
copy(cp, m.hosts)
return cp, nil
}
// Watch returns a channel that will receive HostChange events.
// The channel is buffered; AddHost/RemoveHost push events non-blockingly.
func (m *MockDiscovery) Watch() (<-chan HostChange, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.closed {
return nil, context.Canceled
}
m.started = true
return m.events, nil
}
// AddHost inserts a new host (if absent) and emits an Added event.
func (m *MockDiscovery) AddHost(host string) {
m.mu.Lock()
defer m.mu.Unlock()
if m.closed {
return
}
for _, h := range m.hosts {
if h == host {
return
}
}
m.hosts = append(m.hosts, host)
if m.started {
m.events <- HostChange{Host: host, Type: watch.Added}
}
}
// RemoveHost removes a host (if present) and emits a Deleted event.
func (m *MockDiscovery) RemoveHost(host string) {
m.mu.Lock()
defer m.mu.Unlock()
if m.closed {
return
}
idx := -1
for i, h := range m.hosts {
if h == host {
idx = i
break
}
}
if idx == -1 {
return
}
m.hosts = append(m.hosts[:idx], m.hosts[idx+1:]...)
if m.started {
m.events <- HostChange{Host: host, Type: watch.Deleted}
}
}
// Close closes the event channel (idempotent).
func (m *MockDiscovery) Close() {
m.mu.Lock()
defer m.mu.Unlock()
if m.closed {
return
}
m.closed = true
close(m.events)
}

View File

@@ -1,4 +1,4 @@
package main package discovery
import ( import (
"testing" "testing"
@@ -9,7 +9,7 @@ import (
) )
func TestDiscovery(t *testing.T) { func TestDiscovery(t *testing.T) {
config, err := clientcmd.BuildConfigFromFlags("", "/Users/mats/.kube/config") config, err := clientcmd.BuildConfigFromFlags("", "/home/mats/.kube/config")
if err != nil { if err != nil {
t.Errorf("Error building config: %v", err) t.Errorf("Error building config: %v", err)
} }
@@ -28,7 +28,7 @@ func TestDiscovery(t *testing.T) {
} }
func TestWatch(t *testing.T) { func TestWatch(t *testing.T) {
config, err := clientcmd.BuildConfigFromFlags("", "/Users/mats/.kube/config") config, err := clientcmd.BuildConfigFromFlags("", "/home/mats/.kube/config")
if err != nil { if err != nil {
t.Errorf("Error building config: %v", err) t.Errorf("Error building config: %v", err)
} }

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

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

View File

@@ -0,0 +1,555 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.10
// protoc v6.32.1
// source: control_plane.proto
package messages
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// Empty request placeholder (common pattern).
type Empty struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Empty) Reset() {
*x = Empty{}
mi := &file_control_plane_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Empty) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Empty) ProtoMessage() {}
func (x *Empty) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Empty.ProtoReflect.Descriptor instead.
func (*Empty) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{0}
}
// Ping reply includes responding host and its current unix time (seconds).
type PingReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
UnixTime int64 `protobuf:"varint,2,opt,name=unix_time,json=unixTime,proto3" json:"unix_time,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PingReply) Reset() {
*x = PingReply{}
mi := &file_control_plane_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PingReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PingReply) ProtoMessage() {}
func (x *PingReply) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PingReply.ProtoReflect.Descriptor instead.
func (*PingReply) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{1}
}
func (x *PingReply) GetHost() string {
if x != nil {
return x.Host
}
return ""
}
func (x *PingReply) GetUnixTime() int64 {
if x != nil {
return x.UnixTime
}
return 0
}
// NegotiateRequest carries the caller's full view of known hosts (including self).
type NegotiateRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
KnownHosts []string `protobuf:"bytes,1,rep,name=known_hosts,json=knownHosts,proto3" json:"known_hosts,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *NegotiateRequest) Reset() {
*x = NegotiateRequest{}
mi := &file_control_plane_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *NegotiateRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*NegotiateRequest) ProtoMessage() {}
func (x *NegotiateRequest) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use NegotiateRequest.ProtoReflect.Descriptor instead.
func (*NegotiateRequest) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{2}
}
func (x *NegotiateRequest) GetKnownHosts() []string {
if x != nil {
return x.KnownHosts
}
return nil
}
// NegotiateReply returns the callee's healthy hosts (including itself).
type NegotiateReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Hosts []string `protobuf:"bytes,1,rep,name=hosts,proto3" json:"hosts,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *NegotiateReply) Reset() {
*x = NegotiateReply{}
mi := &file_control_plane_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *NegotiateReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*NegotiateReply) ProtoMessage() {}
func (x *NegotiateReply) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use NegotiateReply.ProtoReflect.Descriptor instead.
func (*NegotiateReply) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{3}
}
func (x *NegotiateReply) GetHosts() []string {
if x != nil {
return x.Hosts
}
return nil
}
// CartIdsReply returns the list of cart IDs (string form) currently owned locally.
type ActorIdsReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Ids []uint64 `protobuf:"varint,1,rep,packed,name=ids,proto3" json:"ids,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ActorIdsReply) Reset() {
*x = ActorIdsReply{}
mi := &file_control_plane_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ActorIdsReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ActorIdsReply) ProtoMessage() {}
func (x *ActorIdsReply) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ActorIdsReply.ProtoReflect.Descriptor instead.
func (*ActorIdsReply) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{4}
}
func (x *ActorIdsReply) GetIds() []uint64 {
if x != nil {
return x.Ids
}
return nil
}
// OwnerChangeAck retained as response type for Closing RPC (ConfirmOwner removed).
type OwnerChangeAck struct {
state protoimpl.MessageState `protogen:"open.v1"`
Accepted bool `protobuf:"varint,1,opt,name=accepted,proto3" json:"accepted,omitempty"`
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *OwnerChangeAck) Reset() {
*x = OwnerChangeAck{}
mi := &file_control_plane_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *OwnerChangeAck) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*OwnerChangeAck) ProtoMessage() {}
func (x *OwnerChangeAck) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use OwnerChangeAck.ProtoReflect.Descriptor instead.
func (*OwnerChangeAck) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{5}
}
func (x *OwnerChangeAck) GetAccepted() bool {
if x != nil {
return x.Accepted
}
return false
}
func (x *OwnerChangeAck) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
// ClosingNotice notifies peers this host is terminating (so they can drop / re-resolve).
type ClosingNotice struct {
state protoimpl.MessageState `protogen:"open.v1"`
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ClosingNotice) Reset() {
*x = ClosingNotice{}
mi := &file_control_plane_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ClosingNotice) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ClosingNotice) ProtoMessage() {}
func (x *ClosingNotice) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ClosingNotice.ProtoReflect.Descriptor instead.
func (*ClosingNotice) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{6}
}
func (x *ClosingNotice) GetHost() string {
if x != nil {
return x.Host
}
return ""
}
// OwnershipAnnounce broadcasts first-touch ownership claims for cart IDs.
// First claim wins; receivers SHOULD NOT overwrite an existing different owner.
type OwnershipAnnounce struct {
state protoimpl.MessageState `protogen:"open.v1"`
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` // announcing host
Ids []uint64 `protobuf:"varint,2,rep,packed,name=ids,proto3" json:"ids,omitempty"` // newly claimed cart ids
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *OwnershipAnnounce) Reset() {
*x = OwnershipAnnounce{}
mi := &file_control_plane_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *OwnershipAnnounce) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*OwnershipAnnounce) ProtoMessage() {}
func (x *OwnershipAnnounce) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use OwnershipAnnounce.ProtoReflect.Descriptor instead.
func (*OwnershipAnnounce) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{7}
}
func (x *OwnershipAnnounce) GetHost() string {
if x != nil {
return x.Host
}
return ""
}
func (x *OwnershipAnnounce) GetIds() []uint64 {
if x != nil {
return x.Ids
}
return nil
}
// ExpiryAnnounce broadcasts that a host evicted the provided cart IDs.
type ExpiryAnnounce struct {
state protoimpl.MessageState `protogen:"open.v1"`
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
Ids []uint64 `protobuf:"varint,2,rep,packed,name=ids,proto3" json:"ids,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExpiryAnnounce) Reset() {
*x = ExpiryAnnounce{}
mi := &file_control_plane_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExpiryAnnounce) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExpiryAnnounce) ProtoMessage() {}
func (x *ExpiryAnnounce) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExpiryAnnounce.ProtoReflect.Descriptor instead.
func (*ExpiryAnnounce) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{8}
}
func (x *ExpiryAnnounce) GetHost() string {
if x != nil {
return x.Host
}
return ""
}
func (x *ExpiryAnnounce) GetIds() []uint64 {
if x != nil {
return x.Ids
}
return nil
}
var File_control_plane_proto protoreflect.FileDescriptor
const file_control_plane_proto_rawDesc = "" +
"\n" +
"\x13control_plane.proto\x12\bmessages\"\a\n" +
"\x05Empty\"<\n" +
"\tPingReply\x12\x12\n" +
"\x04host\x18\x01 \x01(\tR\x04host\x12\x1b\n" +
"\tunix_time\x18\x02 \x01(\x03R\bunixTime\"3\n" +
"\x10NegotiateRequest\x12\x1f\n" +
"\vknown_hosts\x18\x01 \x03(\tR\n" +
"knownHosts\"&\n" +
"\x0eNegotiateReply\x12\x14\n" +
"\x05hosts\x18\x01 \x03(\tR\x05hosts\"!\n" +
"\rActorIdsReply\x12\x10\n" +
"\x03ids\x18\x01 \x03(\x04R\x03ids\"F\n" +
"\x0eOwnerChangeAck\x12\x1a\n" +
"\baccepted\x18\x01 \x01(\bR\baccepted\x12\x18\n" +
"\amessage\x18\x02 \x01(\tR\amessage\"#\n" +
"\rClosingNotice\x12\x12\n" +
"\x04host\x18\x01 \x01(\tR\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" +
"\x04Ping\x12\x0f.messages.Empty\x1a\x13.messages.PingReply\x12A\n" +
"\tNegotiate\x12\x1a.messages.NegotiateRequest\x1a\x18.messages.NegotiateReply\x12<\n" +
"\x10GetLocalActorIds\x12\x0f.messages.Empty\x1a\x17.messages.ActorIdsReply\x12J\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"
var (
file_control_plane_proto_rawDescOnce sync.Once
file_control_plane_proto_rawDescData []byte
)
func file_control_plane_proto_rawDescGZIP() []byte {
file_control_plane_proto_rawDescOnce.Do(func() {
file_control_plane_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_control_plane_proto_rawDesc), len(file_control_plane_proto_rawDesc)))
})
return file_control_plane_proto_rawDescData
}
var file_control_plane_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
var file_control_plane_proto_goTypes = []any{
(*Empty)(nil), // 0: messages.Empty
(*PingReply)(nil), // 1: messages.PingReply
(*NegotiateRequest)(nil), // 2: messages.NegotiateRequest
(*NegotiateReply)(nil), // 3: messages.NegotiateReply
(*ActorIdsReply)(nil), // 4: messages.ActorIdsReply
(*OwnerChangeAck)(nil), // 5: messages.OwnerChangeAck
(*ClosingNotice)(nil), // 6: messages.ClosingNotice
(*OwnershipAnnounce)(nil), // 7: messages.OwnershipAnnounce
(*ExpiryAnnounce)(nil), // 8: messages.ExpiryAnnounce
}
var file_control_plane_proto_depIdxs = []int32{
0, // 0: messages.ControlPlane.Ping:input_type -> messages.Empty
2, // 1: messages.ControlPlane.Negotiate:input_type -> messages.NegotiateRequest
0, // 2: messages.ControlPlane.GetLocalActorIds:input_type -> messages.Empty
7, // 3: messages.ControlPlane.AnnounceOwnership:input_type -> messages.OwnershipAnnounce
8, // 4: messages.ControlPlane.AnnounceExpiry:input_type -> messages.ExpiryAnnounce
6, // 5: messages.ControlPlane.Closing:input_type -> messages.ClosingNotice
1, // 6: messages.ControlPlane.Ping:output_type -> messages.PingReply
3, // 7: messages.ControlPlane.Negotiate:output_type -> messages.NegotiateReply
4, // 8: messages.ControlPlane.GetLocalActorIds:output_type -> messages.ActorIdsReply
5, // 9: messages.ControlPlane.AnnounceOwnership:output_type -> messages.OwnerChangeAck
5, // 10: messages.ControlPlane.AnnounceExpiry:output_type -> messages.OwnerChangeAck
5, // 11: messages.ControlPlane.Closing:output_type -> messages.OwnerChangeAck
6, // [6:12] is the sub-list for method output_type
0, // [0:6] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_control_plane_proto_init() }
func file_control_plane_proto_init() {
if File_control_plane_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_control_plane_proto_rawDesc), len(file_control_plane_proto_rawDesc)),
NumEnums: 0,
NumMessages: 9,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_control_plane_proto_goTypes,
DependencyIndexes: file_control_plane_proto_depIdxs,
MessageInfos: file_control_plane_proto_msgTypes,
}.Build()
File_control_plane_proto = out.File
file_control_plane_proto_goTypes = nil
file_control_plane_proto_depIdxs = nil
}

View File

@@ -0,0 +1,327 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v6.32.1
// source: control_plane.proto
package messages
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
ControlPlane_Ping_FullMethodName = "/messages.ControlPlane/Ping"
ControlPlane_Negotiate_FullMethodName = "/messages.ControlPlane/Negotiate"
ControlPlane_GetLocalActorIds_FullMethodName = "/messages.ControlPlane/GetLocalActorIds"
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.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// ControlPlane defines cluster coordination and ownership operations.
type ControlPlaneClient interface {
// Ping for liveness; lightweight health signal.
Ping(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*PingReply, error)
// Negotiate merges host views; used during discovery & convergence.
Negotiate(ctx context.Context, in *NegotiateRequest, opts ...grpc.CallOption) (*NegotiateReply, error)
// GetCartIds lists currently owned cart IDs on this node.
GetLocalActorIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*ActorIdsReply, error)
// Ownership announcement: first-touch claim broadcast (idempotent; best-effort).
AnnounceOwnership(ctx context.Context, in *OwnershipAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error)
// Expiry announcement: drop remote ownership hints when local TTL expires.
AnnounceExpiry(ctx context.Context, in *ExpiryAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error)
// Closing announces graceful shutdown so peers can proactively adjust.
Closing(ctx context.Context, in *ClosingNotice, opts ...grpc.CallOption) (*OwnerChangeAck, error)
}
type controlPlaneClient struct {
cc grpc.ClientConnInterface
}
func NewControlPlaneClient(cc grpc.ClientConnInterface) ControlPlaneClient {
return &controlPlaneClient{cc}
}
func (c *controlPlaneClient) Ping(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*PingReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PingReply)
err := c.cc.Invoke(ctx, ControlPlane_Ping_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *controlPlaneClient) Negotiate(ctx context.Context, in *NegotiateRequest, opts ...grpc.CallOption) (*NegotiateReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(NegotiateReply)
err := c.cc.Invoke(ctx, ControlPlane_Negotiate_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *controlPlaneClient) GetLocalActorIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*ActorIdsReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ActorIdsReply)
err := c.cc.Invoke(ctx, ControlPlane_GetLocalActorIds_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *controlPlaneClient) AnnounceOwnership(ctx context.Context, in *OwnershipAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(OwnerChangeAck)
err := c.cc.Invoke(ctx, ControlPlane_AnnounceOwnership_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *controlPlaneClient) AnnounceExpiry(ctx context.Context, in *ExpiryAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(OwnerChangeAck)
err := c.cc.Invoke(ctx, ControlPlane_AnnounceExpiry_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *controlPlaneClient) Closing(ctx context.Context, in *ClosingNotice, opts ...grpc.CallOption) (*OwnerChangeAck, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(OwnerChangeAck)
err := c.cc.Invoke(ctx, ControlPlane_Closing_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// ControlPlaneServer is the server API for ControlPlane service.
// All implementations must embed UnimplementedControlPlaneServer
// for forward compatibility.
//
// ControlPlane defines cluster coordination and ownership operations.
type ControlPlaneServer interface {
// Ping for liveness; lightweight health signal.
Ping(context.Context, *Empty) (*PingReply, error)
// Negotiate merges host views; used during discovery & convergence.
Negotiate(context.Context, *NegotiateRequest) (*NegotiateReply, error)
// GetCartIds lists currently owned cart IDs on this node.
GetLocalActorIds(context.Context, *Empty) (*ActorIdsReply, error)
// Ownership announcement: first-touch claim broadcast (idempotent; best-effort).
AnnounceOwnership(context.Context, *OwnershipAnnounce) (*OwnerChangeAck, error)
// Expiry announcement: drop remote ownership hints when local TTL expires.
AnnounceExpiry(context.Context, *ExpiryAnnounce) (*OwnerChangeAck, error)
// Closing announces graceful shutdown so peers can proactively adjust.
Closing(context.Context, *ClosingNotice) (*OwnerChangeAck, error)
mustEmbedUnimplementedControlPlaneServer()
}
// UnimplementedControlPlaneServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedControlPlaneServer struct{}
func (UnimplementedControlPlaneServer) Ping(context.Context, *Empty) (*PingReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method Ping not implemented")
}
func (UnimplementedControlPlaneServer) Negotiate(context.Context, *NegotiateRequest) (*NegotiateReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method Negotiate not implemented")
}
func (UnimplementedControlPlaneServer) GetLocalActorIds(context.Context, *Empty) (*ActorIdsReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetLocalActorIds not implemented")
}
func (UnimplementedControlPlaneServer) AnnounceOwnership(context.Context, *OwnershipAnnounce) (*OwnerChangeAck, error) {
return nil, status.Errorf(codes.Unimplemented, "method AnnounceOwnership not implemented")
}
func (UnimplementedControlPlaneServer) AnnounceExpiry(context.Context, *ExpiryAnnounce) (*OwnerChangeAck, error) {
return nil, status.Errorf(codes.Unimplemented, "method AnnounceExpiry not implemented")
}
func (UnimplementedControlPlaneServer) Closing(context.Context, *ClosingNotice) (*OwnerChangeAck, error) {
return nil, status.Errorf(codes.Unimplemented, "method Closing not implemented")
}
func (UnimplementedControlPlaneServer) mustEmbedUnimplementedControlPlaneServer() {}
func (UnimplementedControlPlaneServer) testEmbeddedByValue() {}
// UnsafeControlPlaneServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to ControlPlaneServer will
// result in compilation errors.
type UnsafeControlPlaneServer interface {
mustEmbedUnimplementedControlPlaneServer()
}
func RegisterControlPlaneServer(s grpc.ServiceRegistrar, srv ControlPlaneServer) {
// If the following call pancis, it indicates UnimplementedControlPlaneServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&ControlPlane_ServiceDesc, srv)
}
func _ControlPlane_Ping_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(Empty)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ControlPlaneServer).Ping(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ControlPlane_Ping_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ControlPlaneServer).Ping(ctx, req.(*Empty))
}
return interceptor(ctx, in, info, handler)
}
func _ControlPlane_Negotiate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(NegotiateRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ControlPlaneServer).Negotiate(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ControlPlane_Negotiate_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ControlPlaneServer).Negotiate(ctx, req.(*NegotiateRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ControlPlane_GetLocalActorIds_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(Empty)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ControlPlaneServer).GetLocalActorIds(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ControlPlane_GetLocalActorIds_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
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)
}
func _ControlPlane_Closing_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ClosingNotice)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ControlPlaneServer).Closing(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ControlPlane_Closing_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ControlPlaneServer).Closing(ctx, req.(*ClosingNotice))
}
return interceptor(ctx, in, info, handler)
}
// ControlPlane_ServiceDesc is the grpc.ServiceDesc for ControlPlane service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var ControlPlane_ServiceDesc = grpc.ServiceDesc{
ServiceName: "messages.ControlPlane",
HandlerType: (*ControlPlaneServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "Ping",
Handler: _ControlPlane_Ping_Handler,
},
{
MethodName: "Negotiate",
Handler: _ControlPlane_Negotiate_Handler,
},
{
MethodName: "GetLocalActorIds",
Handler: _ControlPlane_GetLocalActorIds_Handler,
},
{
MethodName: "AnnounceOwnership",
Handler: _ControlPlane_AnnounceOwnership_Handler,
},
{
MethodName: "AnnounceExpiry",
Handler: _ControlPlane_AnnounceExpiry_Handler,
},
{
MethodName: "Closing",
Handler: _ControlPlane_Closing_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "control_plane.proto",
}

View File

@@ -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
@@ -889,6 +889,102 @@ func (x *OrderCreated) GetStatus() string {
return "" return ""
} }
type Noop struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Noop) Reset() {
*x = Noop{}
mi := &file_messages_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Noop) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Noop) ProtoMessage() {}
func (x *Noop) ProtoReflect() protoreflect.Message {
mi := &file_messages_proto_msgTypes[11]
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 Noop.ProtoReflect.Descriptor instead.
func (*Noop) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{11}
}
type InitializeCheckout struct {
state protoimpl.MessageState `protogen:"open.v1"`
OrderId string `protobuf:"bytes,1,opt,name=orderId,proto3" json:"orderId,omitempty"`
Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"`
PaymentInProgress bool `protobuf:"varint,3,opt,name=paymentInProgress,proto3" json:"paymentInProgress,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *InitializeCheckout) Reset() {
*x = InitializeCheckout{}
mi := &file_messages_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *InitializeCheckout) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*InitializeCheckout) ProtoMessage() {}
func (x *InitializeCheckout) 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 InitializeCheckout.ProtoReflect.Descriptor instead.
func (*InitializeCheckout) Descriptor() ([]byte, []int) {
return file_messages_proto_rawDescGZIP(), []int{12}
}
func (x *InitializeCheckout) GetOrderId() string {
if x != nil {
return x.OrderId
}
return ""
}
func (x *InitializeCheckout) GetStatus() string {
if x != nil {
return x.Status
}
return ""
}
func (x *InitializeCheckout) GetPaymentInProgress() bool {
if x != nil {
return x.PaymentInProgress
}
return false
}
var File_messages_proto protoreflect.FileDescriptor var File_messages_proto protoreflect.FileDescriptor
const file_messages_proto_rawDesc = "" + const file_messages_proto_rawDesc = "" +
@@ -997,8 +1093,12 @@ const file_messages_proto_rawDesc = "" +
"\acountry\x18\x06 \x01(\tR\acountry\"@\n" + "\acountry\x18\x06 \x01(\tR\acountry\"@\n" +
"\fOrderCreated\x12\x18\n" + "\fOrderCreated\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\x06statusB\fZ\n" + "\x06status\x18\x02 \x01(\tR\x06status\"\x06\n" +
".;messagesb\x06proto3" "\x04Noop\"t\n" +
"\x12InitializeCheckout\x12\x18\n" +
"\aorderId\x18\x01 \x01(\tR\aorderId\x12\x16\n" +
"\x06status\x18\x02 \x01(\tR\x06status\x12,\n" +
"\x11paymentInProgress\x18\x03 \x01(\bR\x11paymentInProgressB.Z,git.tornberg.me/go-cart-actor/proto;messagesb\x06proto3"
var ( var (
file_messages_proto_rawDescOnce sync.Once file_messages_proto_rawDescOnce sync.Once
@@ -1012,7 +1112,7 @@ func file_messages_proto_rawDescGZIP() []byte {
return file_messages_proto_rawDescData return file_messages_proto_rawDescData
} }
var file_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 11) var file_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
var file_messages_proto_goTypes = []any{ var file_messages_proto_goTypes = []any{
(*AddRequest)(nil), // 0: messages.AddRequest (*AddRequest)(nil), // 0: messages.AddRequest
(*SetCartRequest)(nil), // 1: messages.SetCartRequest (*SetCartRequest)(nil), // 1: messages.SetCartRequest
@@ -1025,6 +1125,8 @@ var file_messages_proto_goTypes = []any{
(*RemoveDelivery)(nil), // 8: messages.RemoveDelivery (*RemoveDelivery)(nil), // 8: messages.RemoveDelivery
(*CreateCheckoutOrder)(nil), // 9: messages.CreateCheckoutOrder (*CreateCheckoutOrder)(nil), // 9: messages.CreateCheckoutOrder
(*OrderCreated)(nil), // 10: messages.OrderCreated (*OrderCreated)(nil), // 10: messages.OrderCreated
(*Noop)(nil), // 11: messages.Noop
(*InitializeCheckout)(nil), // 12: messages.InitializeCheckout
} }
var file_messages_proto_depIdxs = []int32{ var file_messages_proto_depIdxs = []int32{
0, // 0: messages.SetCartRequest.items:type_name -> messages.AddRequest 0, // 0: messages.SetCartRequest.items:type_name -> messages.AddRequest
@@ -1052,7 +1154,7 @@ func file_messages_proto_init() {
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: 11, NumMessages: 13,
NumExtensions: 0, NumExtensions: 0,
NumServices: 0, NumServices: 0,
}, },

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

@@ -0,0 +1,209 @@
package proxy
import (
"context"
"fmt"
"io"
"log"
"net/http"
"time"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// RemoteHost mirrors the lightweight controller used for remote node
// interaction.
type RemoteHost struct {
Host string
httpBase string
conn *grpc.ClientConn
transport *http.Transport
client *http.Client
controlClient messages.ControlPlaneClient
MissedPings int
}
func NewRemoteHost(host string) (*RemoteHost, error) {
target := fmt.Sprintf("%s:1337", host)
conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Printf("AddRemote: dial %s failed: %v", target, err)
return nil, err
}
controlClient := messages.NewControlPlaneClient(conn)
for retries := range 3 {
ctx, pingCancel := context.WithTimeout(context.Background(), time.Second)
_, pingErr := controlClient.Ping(ctx, &messages.Empty{})
pingCancel()
if pingErr == nil {
break
}
if retries == 2 {
log.Printf("AddRemote: ping %s failed after retries: %v", host, pingErr)
conn.Close()
return nil, pingErr
}
time.Sleep(500 * time.Millisecond)
}
transport := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 120 * time.Second,
}
client := &http.Client{Transport: transport, Timeout: 10 * time.Second}
return &RemoteHost{
Host: host,
httpBase: fmt.Sprintf("http://%s:8080/cart", host),
conn: conn,
transport: transport,
client: client,
controlClient: controlClient,
MissedPings: 0,
}, nil
}
func (h *RemoteHost) Name() string {
return h.Host
}
func (h *RemoteHost) Close() error {
if h.conn != nil {
h.conn.Close()
}
return nil
}
func (h *RemoteHost) Ping() bool {
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)
return false
}
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(uids []uint64) {
_, err := h.controlClient.AnnounceOwnership(context.Background(), &messages.OwnershipAnnounce{
Host: h.Host,
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())
// var bodyCopy []byte
// if r.Body != nil && r.Body != http.NoBody {
// var err error
// bodyCopy, err = io.ReadAll(r.Body)
// if err != nil {
// http.Error(w, "proxy read error", http.StatusBadGateway)
// return false, err
// }
// }
// if r.Body != nil {
// r.Body.Close()
// }
// var reqBody io.Reader
// if len(bodyCopy) > 0 {
// reqBody = bytes.NewReader(bodyCopy)
// }
req, err := http.NewRequestWithContext(r.Context(), r.Method, target, r.Body)
if err != nil {
http.Error(w, "proxy build error", http.StatusBadGateway)
return false, err
}
//r.Body = io.NopCloser(bytes.NewReader(bodyCopy))
req.Header.Set("X-Forwarded-Host", r.Host)
for k, v := range r.Header {
for _, vv := range v {
req.Header.Add(k, vv)
}
}
res, err := h.client.Do(req)
if err != nil {
http.Error(w, "proxy request error", http.StatusBadGateway)
return false, err
}
defer res.Body.Close()
for k, v := range res.Header {
for _, vv := range v {
w.Header().Add(k, vv)
}
}
w.Header().Set("X-Cart-Owner-Routed", "true")
if res.StatusCode >= 200 && res.StatusCode <= 299 {
w.WriteHeader(res.StatusCode)
_, copyErr := io.Copy(w, res.Body)
if copyErr != nil {
return true, copyErr
}
return true, nil
}
return false, fmt.Errorf("proxy response status %d", res.StatusCode)
}
func (r *RemoteHost) IsHealthy() bool {
return r.MissedPings < 3
}

View File

@@ -1,78 +1,65 @@
package main package main
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"math/rand"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
messages "git.tornberg.me/go-cart-actor/proto" messages "git.tornberg.me/go-cart-actor/pkg/messages"
) )
type PoolServer struct { type PoolServer struct {
pod_name string pod_name string
pool GrainPool pool *CartPool
} }
func NewPoolServer(pool GrainPool, pod_name string) *PoolServer { func NewPoolServer(pool *CartPool, pod_name string) *PoolServer {
return &PoolServer{ return &PoolServer{
pod_name: pod_name, pod_name: pod_name,
pool: pool, pool: pool,
} }
} }
func (s *PoolServer) ApplyLocal(id CartId, mutation interface{}) (*CartGrain, error) {
return s.pool.Apply(uint64(id), mutation)
}
func (s *PoolServer) HandleGet(w http.ResponseWriter, r *http.Request, id CartId) error { func (s *PoolServer) HandleGet(w http.ResponseWriter, r *http.Request, id CartId) error {
data, err := s.pool.Get(id) grain, err := s.pool.Get(uint64(id))
if err != nil { if err != nil {
return err return err
} }
return s.WriteResult(w, data) return s.WriteResult(w, grain)
} }
func (s *PoolServer) HandleAddSku(w http.ResponseWriter, r *http.Request, id CartId) error { func (s *PoolServer) HandleAddSku(w http.ResponseWriter, r *http.Request, id CartId) error {
sku := r.PathValue("sku") sku := r.PathValue("sku")
data, err := s.pool.Process(id, Message{ data, err := s.ApplyLocal(id, &messages.AddRequest{Sku: sku, Quantity: 1})
Type: AddRequestType,
Content: &messages.AddRequest{Sku: sku, Quantity: 1},
})
if err != nil { if err != nil {
return err return err
} }
return s.WriteResult(w, data) return s.WriteResult(w, data)
} }
func ErrorHandler(fn func(w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) { func (s *PoolServer) WriteResult(w http.ResponseWriter, result *CartGrain) error {
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 *FrameWithPayload) error {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("X-Pod-Name", s.pod_name) w.Header().Set("X-Pod-Name", s.pod_name)
if result.StatusCode != 200 { if result == nil {
log.Printf("Call error: %d\n", result.StatusCode)
if result.StatusCode >= 200 && result.StatusCode < 600 {
w.WriteHeader(int(result.StatusCode))
} else {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
}
w.Write([]byte(result.Payload))
return nil return nil
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, err := w.Write(result.Payload) enc := json.NewEncoder(w)
err := enc.Encode(result)
return err return err
} }
@@ -83,10 +70,7 @@ func (s *PoolServer) HandleDeleteItem(w http.ResponseWriter, r *http.Request, id
if err != nil { if err != nil {
return err return err
} }
data, err := s.pool.Process(id, Message{ data, err := s.ApplyLocal(id, &messages.RemoveItem{Id: int64(itemId)})
Type: RemoveItemType,
Content: &messages.RemoveItem{Id: int64(itemId)},
})
if err != nil { if err != nil {
return err return err
} }
@@ -106,13 +90,10 @@ func (s *PoolServer) HandleSetDelivery(w http.ResponseWriter, r *http.Request, i
if err != nil { if err != nil {
return err return err
} }
data, err := s.pool.Process(id, Message{ data, err := s.ApplyLocal(id, &messages.SetDelivery{
Type: SetDeliveryType,
Content: &messages.SetDelivery{
Provider: delivery.Provider, Provider: delivery.Provider,
Items: delivery.Items, Items: delivery.Items,
PickupPoint: delivery.PickupPoint, PickupPoint: delivery.PickupPoint,
},
}) })
if err != nil { if err != nil {
return err return err
@@ -132,9 +113,7 @@ func (s *PoolServer) HandleSetPickupPoint(w http.ResponseWriter, r *http.Request
if err != nil { if err != nil {
return err return err
} }
reply, err := s.pool.Process(id, Message{ reply, err := s.ApplyLocal(id, &messages.SetPickupPoint{
Type: SetPickupPointType,
Content: &messages.SetPickupPoint{
DeliveryId: int64(deliveryId), DeliveryId: int64(deliveryId),
Id: pickupPoint.Id, Id: pickupPoint.Id,
Name: pickupPoint.Name, Name: pickupPoint.Name,
@@ -142,7 +121,6 @@ func (s *PoolServer) HandleSetPickupPoint(w http.ResponseWriter, r *http.Request
City: pickupPoint.City, City: pickupPoint.City,
Zip: pickupPoint.Zip, Zip: pickupPoint.Zip,
Country: pickupPoint.Country, Country: pickupPoint.Country,
},
}) })
if err != nil { if err != nil {
return err return err
@@ -157,10 +135,7 @@ func (s *PoolServer) HandleRemoveDelivery(w http.ResponseWriter, r *http.Request
if err != nil { if err != nil {
return err return err
} }
reply, err := s.pool.Process(id, Message{ reply, err := s.ApplyLocal(id, &messages.RemoveDelivery{Id: int64(deliveryId)})
Type: RemoveDeliveryType,
Content: &messages.RemoveDelivery{Id: int64(deliveryId)},
})
if err != nil { if err != nil {
return err return err
} }
@@ -173,10 +148,7 @@ func (s *PoolServer) HandleQuantityChange(w http.ResponseWriter, r *http.Request
if err != nil { if err != nil {
return err return err
} }
reply, err := s.pool.Process(id, Message{ reply, err := s.ApplyLocal(id, &changeQuantity)
Type: ChangeQuantityType,
Content: &changeQuantity,
})
if err != nil { if err != nil {
return err return err
} }
@@ -189,10 +161,7 @@ func (s *PoolServer) HandleSetCartItems(w http.ResponseWriter, r *http.Request,
if err != nil { if err != nil {
return err return err
} }
reply, err := s.pool.Process(id, Message{ reply, err := s.ApplyLocal(id, &setCartItems)
Type: SetCartItemsType,
Content: &setCartItems,
})
if err != nil { if err != nil {
return err return err
} }
@@ -205,10 +174,7 @@ func (s *PoolServer) HandleAddRequest(w http.ResponseWriter, r *http.Request, id
if err != nil { if err != nil {
return err return err
} }
reply, err := s.pool.Process(id, Message{ reply, err := s.ApplyLocal(id, &addRequest)
Type: AddRequestType,
Content: &addRequest,
})
if err != nil { if err != nil {
return err return err
} }
@@ -234,68 +200,127 @@ func (s *PoolServer) HandleConfirmation(w http.ResponseWriter, r *http.Request,
return json.NewEncoder(w).Encode(order) return json.NewEncoder(w).Encode(order)
} }
func (s *PoolServer) HandleCheckout(w http.ResponseWriter, r *http.Request, id CartId) error { func getCurrency(country string) string {
if country == "no" {
return "NOK"
}
return "SEK"
}
reply, err := s.pool.Process(id, Message{ func getLocale(country string) string {
Type: CreateCheckoutOrderType, if country == "no" {
Content: &messages.CreateCheckoutOrder{ return "nb-no"
Terms: "https://slask-finder.tornberg.me/terms", }
Checkout: "https://slask-finder.tornberg.me/checkout?order_id={checkout.order.id}", return "sv-se"
Confirmation: "https://slask-finder.tornberg.me/confirmation/{checkout.order.id}", }
Push: "https://cart.tornberg.me/push?order_id={checkout.order.id}",
}, func (s *PoolServer) CreateOrUpdateCheckout(host string, id CartId) (*CheckoutOrder, error) {
country := getCountryFromHost(host)
meta := &CheckoutMeta{
Terms: fmt.Sprintf("https://%s/terms", host),
Checkout: fmt.Sprintf("https://%s/checkout?order_id={checkout.order.id}", host),
Confirmation: fmt.Sprintf("https://%s/confirmation/{checkout.order.id}", host),
Validation: fmt.Sprintf("https://%s/validate", host),
Push: fmt.Sprintf("https://%s/push?order_id={checkout.order.id}", host),
Country: country,
Currency: getCurrency(country),
Locale: getLocale(country),
}
// Get current grain state (may be local or remote)
grain, err := s.pool.Get(uint64(id))
if err != nil {
return nil, err
}
// Build pure checkout payload
payload, _, err := BuildCheckoutOrderPayload(grain, meta)
if err != nil {
return nil, err
}
if grain.OrderReference != "" {
return 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(uint64(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 { if err != nil {
return err return err
} }
if reply.StatusCode != 200 {
return s.WriteResult(w, reply)
}
// w.Header().Set("Content-Type", "application/json") s.ApplyCheckoutStarted(klarnaOrder, id)
// 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 s.WriteResult(w, reply) w.Header().Set("Content-Type", "application/json")
return json.NewEncoder(w).Encode(klarnaOrder)
} }
func NewCartId() CartId { func CookieCartIdHandler(fn func(cartId CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
id := time.Now().UnixNano() + rand.Int63() return func(w http.ResponseWriter, r *http.Request) {
var id CartId
return ToCartId(fmt.Sprintf("%d", id)) cookie, err := r.Cookie("cartid")
} if err != nil || cookie.Value == "" {
id = MustNewCartId()
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 {
var cartId CartId
cartIdCookie := r.CookiesNamed("cartid")
if cartIdCookie == nil || len(cartIdCookie) == 0 {
cartId = NewCartId()
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "cartid", Name: "cartid",
Value: cartId.String(), Value: id.String(),
Secure: true, Secure: r.TLS != nil,
HttpOnly: true, HttpOnly: true,
Path: "/", Path: "/",
Expires: time.Now().AddDate(0, 0, 14), Expires: time.Now().AddDate(0, 0, 14),
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
}) })
w.Header().Set("Set-Cart-Id", id.String())
} else { } else {
cartId = ToCartId(cartIdCookie[0].Value) parsed, ok := ParseCartId(cookie.Value)
if !ok {
id = MustNewCartId()
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: id.String(),
Secure: r.TLS != nil,
HttpOnly: true,
Path: "/",
Expires: time.Now().AddDate(0, 0, 14),
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Set-Cart-Id", id.String())
} else {
id = parsed
} }
return fn(w, r, cartId) }
err = fn(id, w, r)
if err != nil {
log.Printf("Server error, not remote error: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
} }
} }
// Removed leftover legacy block after CookieCartIdHandler (obsolete code referencing cid/legacy)
func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId CartId) error { func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId CartId) error {
cartId = NewCartId() // Clear cart cookie (breaking change: do not issue a new legacy id here)
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "cartid", Name: "cartid",
Value: cartId.String(), Value: "",
Path: "/", Path: "/",
Secure: true, Secure: r.TLS != nil,
HttpOnly: true, HttpOnly: true,
Expires: time.Unix(0, 0), Expires: time.Unix(0, 0),
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
@@ -304,16 +329,51 @@ func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, ca
return nil return nil
} }
func CartIdHandler(fn func(w http.ResponseWriter, r *http.Request, cartId CartId) error) func(w http.ResponseWriter, r *http.Request) error { func CartIdHandler(fn func(cartId CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) error { return func(w http.ResponseWriter, r *http.Request) {
cartId := ToCartId(r.PathValue("id")) var id CartId
raw := r.PathValue("id")
// If no id supplied, generate a new one
if raw == "" {
id := MustNewCartId()
w.Header().Set("Set-Cart-Id", id.String())
} else {
// Parse base62 cart id
if parsedId, ok := ParseCartId(raw); !ok {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("cart id is invalid"))
return
} else {
id = parsedId
}
}
err := fn(id, w, r)
if err != nil {
log.Printf("Server error, not remote error: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
}
}
func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request, cartId CartId) error) func(cartId CartId, w http.ResponseWriter, r *http.Request) error {
return func(cartId CartId, w http.ResponseWriter, r *http.Request) error {
if ownerHost, ok := s.pool.OwnerHost(uint64(cartId)); ok {
handled, err := ownerHost.Proxy(uint64(cartId), w, r)
if err == nil && handled {
return nil
}
}
return fn(w, r, cartId) return fn(w, r, cartId)
} }
} }
func (s *PoolServer) Serve() *http.ServeMux { func (s *PoolServer) Serve() *http.ServeMux {
mux := http.NewServeMux() mux := http.NewServeMux()
//mux.HandleFunc("/", s.RewritePath)
mux.HandleFunc("OPTIONS /", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("OPTIONS /", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE") w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE")
@@ -321,29 +381,29 @@ func (s *PoolServer) Serve() *http.ServeMux {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}) })
mux.HandleFunc("GET /", ErrorHandler(CookieCartIdHandler(s.HandleGet))) mux.HandleFunc("GET /", CookieCartIdHandler(s.ProxyHandler(s.HandleGet)))
mux.HandleFunc("GET /add/{sku}", ErrorHandler(CookieCartIdHandler(s.HandleAddSku))) mux.HandleFunc("GET /add/{sku}", CookieCartIdHandler(s.ProxyHandler(s.HandleAddSku)))
mux.HandleFunc("POST /", ErrorHandler(CookieCartIdHandler(s.HandleAddRequest))) mux.HandleFunc("POST /", CookieCartIdHandler(s.ProxyHandler(s.HandleAddRequest)))
mux.HandleFunc("POST /set", ErrorHandler(CookieCartIdHandler(s.HandleSetCartItems))) mux.HandleFunc("POST /set", CookieCartIdHandler(s.ProxyHandler(s.HandleSetCartItems)))
mux.HandleFunc("DELETE /{itemId}", ErrorHandler(CookieCartIdHandler(s.HandleDeleteItem))) mux.HandleFunc("DELETE /{itemId}", CookieCartIdHandler(s.ProxyHandler(s.HandleDeleteItem)))
mux.HandleFunc("PUT /", ErrorHandler(CookieCartIdHandler(s.HandleQuantityChange))) mux.HandleFunc("PUT /", CookieCartIdHandler(s.ProxyHandler(s.HandleQuantityChange)))
mux.HandleFunc("DELETE /", ErrorHandler(CookieCartIdHandler(s.RemoveCartCookie))) mux.HandleFunc("DELETE /", CookieCartIdHandler(s.ProxyHandler(s.RemoveCartCookie)))
mux.HandleFunc("POST /delivery", ErrorHandler(CookieCartIdHandler(s.HandleSetDelivery))) mux.HandleFunc("POST /delivery", CookieCartIdHandler(s.ProxyHandler(s.HandleSetDelivery)))
mux.HandleFunc("DELETE /delivery/{deliveryId}", ErrorHandler(CookieCartIdHandler(s.HandleRemoveDelivery))) mux.HandleFunc("DELETE /delivery/{deliveryId}", CookieCartIdHandler(s.ProxyHandler(s.HandleRemoveDelivery)))
mux.HandleFunc("PUT /delivery/{deliveryId}/pickupPoint", ErrorHandler(CookieCartIdHandler(s.HandleSetPickupPoint))) mux.HandleFunc("PUT /delivery/{deliveryId}/pickupPoint", CookieCartIdHandler(s.ProxyHandler(s.HandleSetPickupPoint)))
mux.HandleFunc("GET /checkout", ErrorHandler(CookieCartIdHandler(s.HandleCheckout))) mux.HandleFunc("GET /checkout", CookieCartIdHandler(s.ProxyHandler(s.HandleCheckout)))
mux.HandleFunc("GET /confirmation/{orderId}", ErrorHandler(CookieCartIdHandler(s.HandleConfirmation))) mux.HandleFunc("GET /confirmation/{orderId}", CookieCartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
mux.HandleFunc("GET /byid/{id}", ErrorHandler(CartIdHandler(s.HandleGet))) mux.HandleFunc("GET /byid/{id}", CartIdHandler(s.ProxyHandler(s.HandleGet)))
mux.HandleFunc("GET /byid/{id}/add/{sku}", ErrorHandler(CartIdHandler(s.HandleAddSku))) mux.HandleFunc("GET /byid/{id}/add/{sku}", CartIdHandler(s.ProxyHandler(s.HandleAddSku)))
mux.HandleFunc("POST /byid/{id}", ErrorHandler(CartIdHandler(s.HandleAddRequest))) mux.HandleFunc("POST /byid/{id}", CartIdHandler(s.ProxyHandler(s.HandleAddRequest)))
mux.HandleFunc("DELETE /byid/{id}/{itemId}", ErrorHandler(CartIdHandler(s.HandleDeleteItem))) mux.HandleFunc("DELETE /byid/{id}/{itemId}", CartIdHandler(s.ProxyHandler(s.HandleDeleteItem)))
mux.HandleFunc("PUT /byid/{id}", ErrorHandler(CartIdHandler(s.HandleQuantityChange))) mux.HandleFunc("PUT /byid/{id}", CartIdHandler(s.ProxyHandler(s.HandleQuantityChange)))
mux.HandleFunc("POST /byid/{id}/delivery", ErrorHandler(CartIdHandler(s.HandleSetDelivery))) mux.HandleFunc("POST /byid/{id}/delivery", CartIdHandler(s.ProxyHandler(s.HandleSetDelivery)))
mux.HandleFunc("DELETE /byid/{id}/delivery/{deliveryId}", ErrorHandler(CartIdHandler(s.HandleRemoveDelivery))) mux.HandleFunc("DELETE /byid/{id}/delivery/{deliveryId}", CartIdHandler(s.ProxyHandler(s.HandleRemoveDelivery)))
mux.HandleFunc("PUT /byid/{id}/delivery/{deliveryId}/pickupPoint", ErrorHandler(CartIdHandler(s.HandleSetPickupPoint))) mux.HandleFunc("PUT /byid/{id}/delivery/{deliveryId}/pickupPoint", CartIdHandler(s.ProxyHandler(s.HandleSetPickupPoint)))
mux.HandleFunc("GET /byid/{id}/checkout", ErrorHandler(CartIdHandler(s.HandleCheckout))) mux.HandleFunc("GET /byid/{id}/checkout", CartIdHandler(s.ProxyHandler(s.HandleCheckout)))
mux.HandleFunc("GET /byid/{id}/confirmation", ErrorHandler(CartIdHandler(s.HandleConfirmation))) mux.HandleFunc("GET /byid/{id}/confirmation", CartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
return mux return mux
} }

View File

@@ -16,7 +16,10 @@ func getBaseUrl(country string) string {
if country == "no" { if country == "no" {
return "http://s10n-no.s10n:8080" return "http://s10n-no.s10n:8080"
} }
if country == "se" {
return "http://s10n-se.s10n:8080" return "http://s10n-se.s10n:8080"
}
return "http://localhost:8082"
} }
func FetchItem(sku string, country string) (*index.DataItem, error) { func FetchItem(sku string, country string) (*index.DataItem, error) {

101
proto/control_plane.proto Normal file
View File

@@ -0,0 +1,101 @@
syntax = "proto3";
package messages;
option go_package = "git.tornberg.me/go-cart-actor/proto;messages";
// -----------------------------------------------------------------------------
// Control Plane gRPC API
// -----------------------------------------------------------------------------
// Replaces the legacy custom frame-based control channel (previously port 1338).
// Responsibilities:
// - Liveness (Ping)
// - Membership negotiation (Negotiate)
// - Deterministic ring-based ownership (ConfirmOwner RPC removed)
// - Cart ID listing for remote grain spawning (GetCartIds)
// - Graceful shutdown notifications (Closing)
// No authentication / TLS is defined initially (can be added later).
// -----------------------------------------------------------------------------
// Empty request placeholder (common pattern).
message Empty {}
// Ping reply includes responding host and its current unix time (seconds).
message PingReply {
string host = 1;
int64 unix_time = 2;
}
// NegotiateRequest carries the caller's full view of known hosts (including self).
message NegotiateRequest {
repeated string known_hosts = 1;
}
// NegotiateReply returns the callee's healthy hosts (including itself).
message NegotiateReply {
repeated string hosts = 1;
}
// CartIdsReply returns the list of cart IDs (string form) currently owned locally.
message ActorIdsReply {
repeated uint64 ids = 1;
}
// OwnerChangeAck retained as response type for Closing RPC (ConfirmOwner removed).
message OwnerChangeAck {
bool accepted = 1;
string message = 2;
}
// ClosingNotice notifies peers this host is terminating (so they can drop / re-resolve).
message ClosingNotice {
string host = 1;
}
// OwnershipAnnounce broadcasts first-touch ownership claims for cart IDs.
// First claim wins; receivers SHOULD NOT overwrite an existing different owner.
message OwnershipAnnounce {
string host = 1; // announcing host
repeated uint64 ids = 2; // newly claimed cart ids
}
// ExpiryAnnounce broadcasts that a host evicted the provided cart IDs.
message ExpiryAnnounce {
string host = 1;
repeated uint64 ids = 2;
}
// ControlPlane defines cluster coordination and ownership operations.
service ControlPlane {
// Ping for liveness; lightweight health signal.
rpc Ping(Empty) returns (PingReply);
// Negotiate merges host views; used during discovery & convergence.
rpc Negotiate(NegotiateRequest) returns (NegotiateReply);
// GetCartIds lists currently owned cart IDs on this node.
rpc GetLocalActorIds(Empty) returns (ActorIdsReply);
// ConfirmOwner RPC removed (was legacy ownership acknowledgement; ring-based ownership now authoritative)
// Ownership announcement: first-touch claim broadcast (idempotent; best-effort).
rpc AnnounceOwnership(OwnershipAnnounce) returns (OwnerChangeAck);
// Expiry announcement: drop remote ownership hints when local TTL expires.
rpc AnnounceExpiry(ExpiryAnnounce) returns (OwnerChangeAck);
// Closing announces graceful shutdown so peers can proactively adjust.
rpc Closing(ClosingNotice) returns (OwnerChangeAck);
}
// -----------------------------------------------------------------------------
// Generation Instructions:
// protoc --go_out=. --go_opt=paths=source_relative \
// --go-grpc_out=. --go-grpc_opt=paths=source_relative \
// control_plane.proto
//
// Future Enhancements:
// - Add a streaming membership watch (server -> client) for immediate updates.
// - Add TLS / mTLS for secure intra-cluster communication.
// - Add richer health metadata (load, grain count) in PingReply.
// -----------------------------------------------------------------------------

View File

@@ -1,6 +1,6 @@
syntax = "proto3"; syntax = "proto3";
package messages; package messages;
option go_package = ".;messages"; option go_package = "git.tornberg.me/go-cart-actor/proto;messages";
message AddRequest { message AddRequest {
int32 quantity = 1; int32 quantity = 1;
@@ -93,3 +93,13 @@ message OrderCreated {
string orderId = 1; string orderId = 1;
string status = 2; string status = 2;
} }
message Noop {
// Intentionally empty - used for ownership acquisition or health pings
}
message InitializeCheckout {
string orderId = 1;
string status = 2;
bool paymentInProgress = 3;
}

View File

@@ -1,67 +0,0 @@
// package main
// import "sync"
// type RemoteGrainPool struct {
// mu sync.RWMutex
// Host string
// grains map[CartId]*RemoteGrain
// }
// func NewRemoteGrainPool(addr string) *RemoteGrainPool {
// return &RemoteGrainPool{
// Host: addr,
// grains: make(map[CartId]*RemoteGrain),
// }
// }
// func (p *RemoteGrainPool) findRemoteGrain(id CartId) *RemoteGrain {
// p.mu.RLock()
// grain, ok := p.grains[id]
// p.mu.RUnlock()
// if !ok {
// return nil
// }
// return grain
// }
// func (p *RemoteGrainPool) findOrCreateGrain(id CartId) (*RemoteGrain, error) {
// grain := p.findRemoteGrain(id)
// if grain == nil {
// grain, err := NewRemoteGrain(id, p.Host)
// if err != nil {
// return nil, err
// }
// p.mu.Lock()
// p.grains[id] = grain
// p.mu.Unlock()
// }
// return grain, nil
// }
// func (p *RemoteGrainPool) Delete(id CartId) {
// p.mu.Lock()
// delete(p.grains, id)
// p.mu.Unlock()
// }
// func (p *RemoteGrainPool) Process(id CartId, messages ...Message) (*FrameWithPayload, error) {
// var result *FrameWithPayload
// grain, err := p.findOrCreateGrain(id)
// if err != nil {
// return nil, err
// }
// for _, message := range messages {
// result, err = grain.HandleMessage(&message, false)
// }
// return result, err
// }
// func (p *RemoteGrainPool) Get(id CartId) (*FrameWithPayload, error) {
// grain, err := p.findOrCreateGrain(id)
// if err != nil {
// return nil, err
// }
// return grain.GetCurrentState()
// }

View File

@@ -1,72 +0,0 @@
package main
import (
"fmt"
"strings"
"github.com/yudhasubki/netpool"
)
func (id CartId) String() string {
return strings.Trim(string(id[:]), "\x00")
}
type CartIdPayload struct {
Id CartId
Data []byte
}
func MakeCartInnerFrame(id CartId, payload []byte) []byte {
if payload == nil {
return id[:]
}
return append(id[:], payload...)
}
func GetCartFrame(data []byte) (*CartIdPayload, error) {
if len(data) < 16 {
return nil, fmt.Errorf("data too short")
}
return &CartIdPayload{
Id: CartId(data[:16]),
Data: data[16:],
}, nil
}
func ToCartId(id string) CartId {
var result [16]byte
copy(result[:], []byte(id))
return result
}
type RemoteGrain struct {
*Connection
Id CartId
Host string
}
func NewRemoteGrain(id CartId, host string, pool netpool.Netpooler) *RemoteGrain {
addr := fmt.Sprintf("%s:1337", host)
return &RemoteGrain{
Id: id,
Host: host,
Connection: NewConnection(addr, pool),
}
}
func (g *RemoteGrain) HandleMessage(message *Message, isReplay bool) (*FrameWithPayload, error) {
data, err := GetData(message.Write)
if err != nil {
return nil, err
}
return g.Call(RemoteHandleMutation, MakeCartInnerFrame(g.Id, data))
}
func (g *RemoteGrain) GetId() CartId {
return g.Id
}
func (g *RemoteGrain) GetCurrentState() (*FrameWithPayload, error) {
return g.Call(RemoteGetState, MakeCartInnerFrame(g.Id, nil))
}

View File

@@ -1,17 +0,0 @@
package main
import "testing"
func TestCartIdsNullData(t *testing.T) {
data := MakeCartInnerFrame(ToCartId("kalle"), nil)
cart, err := GetCartFrame(data)
if err != nil {
t.Errorf("Error getting cart: %v", err)
}
if cart.Id.String() != "kalle" {
t.Errorf("Expected kalle, got %s", cart.Id)
}
if len(cart.Data) != 0 {
t.Errorf("Expected no data, got %v", cart.Data)
}
}

View File

@@ -1,93 +0,0 @@
package main
import (
"fmt"
"log"
"strings"
"github.com/yudhasubki/netpool"
)
type RemoteHost struct {
*Connection
HostPool netpool.Netpooler
Host string
MissedPings int
}
func (h *RemoteHost) IsHealthy() bool {
return h.MissedPings < 3
}
func (h *RemoteHost) Initialize(p *SyncedPool) {
log.Printf("Initializing remote %s\n", h.Host)
ids, err := h.GetCartMappings()
if err != nil {
log.Printf("Error getting remote mappings: %v\n", err)
return
}
log.Printf("Remote %s has %d grains\n", h.Host, len(ids))
local := 0
remoteNo := 0
for _, id := range ids {
go p.SpawnRemoteGrain(id, h.Host)
remoteNo++
}
log.Printf("Removed %d local grains, added %d remote grains\n", local, remoteNo)
go p.Negotiate()
}
func (h *RemoteHost) Ping() error {
result, err := h.Call(Ping, nil)
if err != nil || result.StatusCode != 200 || result.Type != Pong {
h.MissedPings++
log.Printf("Error pinging remote %s, missed pings: %d", h.Host, h.MissedPings)
} else {
h.MissedPings = 0
}
return err
}
func (h *RemoteHost) Negotiate(knownHosts []string) ([]string, error) {
reply, err := h.Call(RemoteNegotiate, []byte(strings.Join(knownHosts, ";")))
if err != nil {
return nil, err
}
if reply.StatusCode != 200 {
return nil, fmt.Errorf("remote returned error on negotiate: %s", string(reply.Payload))
}
return strings.Split(string(reply.Payload), ";"), nil
}
func (g *RemoteHost) GetCartMappings() ([]CartId, error) {
reply, err := g.Call(GetCartIds, []byte{})
if err != nil {
return nil, err
}
if reply.StatusCode != 200 || reply.Type != CartIdsResponse {
log.Printf("Remote returned error on get cart mappings: %s", string(reply.Payload))
return nil, fmt.Errorf("remote returned incorrect data")
}
parts := strings.Split(string(reply.Payload), ";")
ids := make([]CartId, 0, len(parts))
for _, p := range parts {
ids = append(ids, ToCartId(p))
}
return ids, nil
}
func (r *RemoteHost) ConfirmChange(id CartId, host string) error {
reply, err := r.Call(RemoteGrainChanged, []byte(fmt.Sprintf("%s;%s", id, host)))
if err != nil || reply.StatusCode != 200 || reply.Type != AckChange {
return err
}
return nil
}

View File

@@ -1,69 +0,0 @@
package main
import (
"bytes"
"fmt"
)
type GrainHandler struct {
*GenericListener
pool *GrainLocalPool
}
func (h *GrainHandler) GetState(id CartId, reply *Grain) error {
grain, err := h.pool.GetGrain(id)
if err != nil {
return err
}
*reply = grain
return nil
}
func NewGrainHandler(pool *GrainLocalPool, listen string) (*GrainHandler, error) {
conn := NewConnection(listen, nil)
server, err := conn.Listen()
handler := &GrainHandler{
GenericListener: server,
pool: pool,
}
server.AddHandler(RemoteHandleMutation, handler.RemoteHandleMessageHandler)
server.AddHandler(RemoteGetState, handler.RemoteGetStateHandler)
return handler, err
}
func (h *GrainHandler) IsHealthy() bool {
return len(h.pool.grains) < h.pool.PoolSize
}
func (h *GrainHandler) RemoteHandleMessageHandler(data *FrameWithPayload, resultChan chan<- FrameWithPayload) error {
cartData, err := GetCartFrame(data.Payload)
if err != nil {
return err
}
var msg Message
err = ReadMessage(bytes.NewReader(cartData.Data), &msg)
if err != nil {
fmt.Println("Error reading message:", err)
return err
}
replyData, err := h.pool.Process(cartData.Id, msg)
if err != nil {
return err
}
resultChan <- *replyData
return err
}
func (h *GrainHandler) RemoteGetStateHandler(data *FrameWithPayload, resultChan chan<- FrameWithPayload) error {
cartData, err := GetCartFrame(data.Payload)
if err != nil {
return err
}
reply, err := h.pool.Get(cartData.Id)
if err != nil {
return err
}
resultChan <- *reply
return nil
}

View File

@@ -1,535 +0,0 @@
package main
import (
"fmt"
"log"
"net"
"strings"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/yudhasubki/netpool"
"k8s.io/apimachinery/pkg/watch"
)
type Quorum interface {
Negotiate(knownHosts []string) ([]string, error)
OwnerChanged(CartId, host string) error
}
type HealthHandler interface {
IsHealthy() bool
}
type SyncedPool struct {
Server *GenericListener
mu sync.RWMutex
discardedHostHandler *DiscardedHostHandler
Hostname string
local *GrainLocalPool
remotes map[string]*RemoteHost
remoteIndex map[CartId]*RemoteGrain
}
var (
negotiationCount = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_remote_negotiation_total",
Help: "The total number of remote negotiations",
})
grainSyncCount = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_grain_sync_total",
Help: "The total number of grain owner changes",
})
connectedRemotes = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_connected_remotes",
Help: "The number of connected remotes",
})
remoteLookupCount = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_remote_lookup_total",
Help: "The total number of remote lookups",
})
packetQueue = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_packet_queue_size",
Help: "The total number of packets in the queue",
})
packetsSent = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_pool_packets_sent_total",
Help: "The total number of packets sent",
})
packetsReceived = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_pool_packets_received_total",
Help: "The total number of packets received",
})
)
func (p *SyncedPool) PongHandler(data *FrameWithPayload, resultChan chan<- FrameWithPayload) error {
resultChan <- MakeFrameWithPayload(Pong, 200, []byte{})
return nil
}
func (p *SyncedPool) GetCartIdHandler(data *FrameWithPayload, resultChan chan<- FrameWithPayload) error {
ids := make([]string, 0, len(p.local.grains))
p.mu.RLock()
defer p.mu.RUnlock()
for id := range p.local.grains {
if p.local.grains[id] == nil {
continue
}
s := id.String()
if s == "" {
continue
}
ids = append(ids, s)
}
log.Printf("Returning %d cart ids\n", len(ids))
resultChan <- MakeFrameWithPayload(CartIdsResponse, 200, []byte(strings.Join(ids, ";")))
return nil
}
func (p *SyncedPool) NegotiateHandler(data *FrameWithPayload, resultChan chan<- FrameWithPayload) error {
negotiationCount.Inc()
log.Printf("Handling negotiation\n")
for _, host := range p.ExcludeKnown(strings.Split(string(data.Payload), ";")) {
if host == "" {
continue
}
go p.AddRemote(host)
}
p.mu.RLock()
defer p.mu.RUnlock()
hosts := make([]string, 0, len(p.remotes))
for _, r := range p.remotes {
if r.IsHealthy() {
hosts = append(hosts, r.Host)
}
}
resultChan <- MakeFrameWithPayload(RemoteNegotiateResponse, 200, []byte(strings.Join(hosts, ";")))
return nil
}
func (p *SyncedPool) GrainOwnerChangeHandler(data *FrameWithPayload, resultChan chan<- FrameWithPayload) error {
grainSyncCount.Inc()
idAndHostParts := strings.Split(string(data.Payload), ";")
if len(idAndHostParts) != 2 {
log.Printf("Invalid remote grain change message")
resultChan <- MakeFrameWithPayload(AckError, 500, []byte("invalid"))
return nil
}
id := ToCartId(idAndHostParts[0])
host := idAndHostParts[1]
log.Printf("Handling remote grain owner change to %s for id %s", host, id)
for _, r := range p.remotes {
if r.Host == host && r.IsHealthy() {
go p.SpawnRemoteGrain(id, host)
break
}
}
go p.AddRemote(host)
resultChan <- MakeFrameWithPayload(AckChange, 200, []byte("ok"))
return nil
}
func (p *SyncedPool) RemoveRemoteGrain(id CartId) {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.remoteIndex, id)
}
func (p *SyncedPool) SpawnRemoteGrain(id CartId, host string) {
if id.String() == "" {
log.Printf("Invalid grain id, %s", id)
return
}
p.mu.RLock()
localGrain, ok := p.local.grains[id]
p.mu.RUnlock()
if ok && localGrain != nil {
log.Printf("Grain %s already exists locally, owner is (%s)", id, host)
p.mu.Lock()
delete(p.local.grains, id)
p.mu.Unlock()
}
go func(i CartId, h string) {
var pool netpool.Netpooler
p.mu.RLock()
for _, r := range p.remotes {
if r.Host == h {
pool = r.HostPool
break
}
}
p.mu.RUnlock()
if pool == nil {
log.Printf("Error spawning remote grain, no pool for %s", h)
return
}
remoteGrain := NewRemoteGrain(i, h, pool)
p.mu.Lock()
p.remoteIndex[i] = remoteGrain
p.mu.Unlock()
}(id, host)
}
func (p *SyncedPool) HandleHostError(host string) {
p.mu.RLock()
defer p.mu.RUnlock()
for _, r := range p.remotes {
if r.Host == host {
if !r.IsHealthy() {
go p.RemoveHost(r)
}
return
}
}
}
func NewSyncedPool(local *GrainLocalPool, hostname string, discovery Discovery) (*SyncedPool, error) {
listen := fmt.Sprintf("%s:1338", hostname)
conn := NewConnection(listen, nil)
server, err := conn.Listen()
if err != nil {
return nil, err
}
log.Printf("Listening on %s", listen)
dh := NewDiscardedHostHandler(1338)
pool := &SyncedPool{
Server: server,
Hostname: hostname,
local: local,
discardedHostHandler: dh,
remotes: make(map[string]*RemoteHost),
remoteIndex: make(map[CartId]*RemoteGrain),
}
dh.SetReconnectHandler(pool.AddRemote)
server.AddHandler(Ping, pool.PongHandler)
server.AddHandler(GetCartIds, pool.GetCartIdHandler)
server.AddHandler(RemoteNegotiate, pool.NegotiateHandler)
server.AddHandler(RemoteGrainChanged, pool.GrainOwnerChangeHandler)
server.AddHandler(Closing, pool.HostTerminatingHandler)
if discovery != nil {
go func() {
time.Sleep(time.Second * 5)
log.Printf("Starting discovery")
ch, err := discovery.Watch()
if err != nil {
log.Printf("Error discovering hosts: %v", err)
return
}
for chng := range ch {
if chng.Host == "" {
continue
}
known := pool.IsKnown(chng.Host)
if chng.Type != watch.Deleted && !known {
log.Printf("Discovered host %s, waiting for startup", chng.Host)
time.Sleep(3 * time.Second)
pool.AddRemote(chng.Host)
} else if chng.Type == watch.Deleted && known {
log.Printf("Host removed %s, removing from index", chng.Host)
for _, r := range pool.remotes {
if r.Host == chng.Host {
pool.RemoveHost(r)
break
}
}
}
}
}()
} else {
log.Printf("No discovery, waiting for remotes to connect")
}
return pool, nil
}
func (p *SyncedPool) HostTerminatingHandler(data *FrameWithPayload, resultChan chan<- FrameWithPayload) error {
log.Printf("Remote host terminating")
host := string(data.Payload)
p.mu.RLock()
defer p.mu.RUnlock()
for _, r := range p.remotes {
if r.Host == host {
go p.RemoveHost(r)
break
}
}
resultChan <- MakeFrameWithPayload(Pong, 200, []byte("ok"))
return nil
}
func (p *SyncedPool) IsHealthy() bool {
for _, r := range p.remotes {
if !r.IsHealthy() {
return false
}
}
return true
}
func (p *SyncedPool) IsKnown(host string) bool {
for _, r := range p.remotes {
if r.Host == host {
return true
}
}
return host == p.Hostname
}
func (p *SyncedPool) ExcludeKnown(hosts []string) []string {
ret := make([]string, 0, len(hosts))
for _, h := range hosts {
if !p.IsKnown(h) {
ret = append(ret, h)
}
}
return ret
}
func (p *SyncedPool) RemoveHost(host *RemoteHost) {
p.mu.Lock()
delete(p.remotes, host.Host)
p.mu.Unlock()
p.RemoveHostMappedCarts(host)
p.discardedHostHandler.AppendHost(host.Host)
connectedRemotes.Set(float64(len(p.remotes)))
}
func (p *SyncedPool) RemoveHostMappedCarts(host *RemoteHost) {
p.mu.Lock()
defer p.mu.Unlock()
for id, r := range p.remoteIndex {
if r.Host == host.Host {
delete(p.remoteIndex, id)
}
}
}
const (
RemoteNegotiate = FrameType(3)
RemoteGrainChanged = FrameType(4)
AckChange = FrameType(5)
AckError = FrameType(6)
Ping = FrameType(7)
Pong = FrameType(8)
GetCartIds = FrameType(9)
CartIdsResponse = FrameType(10)
RemoteNegotiateResponse = FrameType(11)
Closing = FrameType(12)
)
func (p *SyncedPool) Negotiate() {
knownHosts := make([]string, 0, len(p.remotes)+1)
for _, r := range p.remotes {
knownHosts = append(knownHosts, r.Host)
}
knownHosts = append([]string{p.Hostname}, knownHosts...)
for _, r := range p.remotes {
hosts, err := r.Negotiate(knownHosts)
if err != nil {
log.Printf("Error negotiating with %s: %v\n", r.Host, err)
return
}
for _, h := range hosts {
if !p.IsKnown(h) {
p.AddRemote(h)
}
}
}
}
func (p *SyncedPool) GetHealthyRemotes() []*RemoteHost {
p.mu.RLock()
defer p.mu.RUnlock()
remotes := make([]*RemoteHost, 0, len(p.remotes))
for _, r := range p.remotes {
if r.IsHealthy() {
remotes = append(remotes, r)
}
}
return remotes
}
func (p *SyncedPool) RequestOwnership(id CartId) error {
ok := 0
all := 0
for _, r := range p.GetHealthyRemotes() {
err := r.ConfirmChange(id, p.Hostname)
all++
if err != nil {
if !r.IsHealthy() {
log.Printf("Ownership: Removing host, unable to communicate with %s", r.Host)
p.RemoveHost(r)
all--
} else {
log.Printf("Error confirming change: %v from %s\n", err, p.Hostname)
}
continue
}
//log.Printf("Remote confirmed change %s\n", r.Host)
ok++
}
if (all < 3 && ok < all) || ok < (all/2) {
p.removeLocalGrain(id)
return fmt.Errorf("quorum not reached")
}
return nil
}
func (p *SyncedPool) removeLocalGrain(id CartId) {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.local.grains, id)
}
func (p *SyncedPool) AddRemote(host string) {
p.mu.Lock()
defer p.mu.Unlock()
_, hasHost := p.remotes[host]
if host == "" || hasHost || host == p.Hostname {
return
}
host_pool, err := netpool.New(func() (net.Conn, error) {
return net.Dial("tcp", fmt.Sprintf("%s:1338", host))
}, netpool.WithMaxPool(128), netpool.WithMinPool(0))
if err != nil {
log.Printf("Error creating host pool: %v\n", err)
return
}
client := NewConnection(fmt.Sprintf("%s:1338", host), host_pool)
pings := 3
for pings >= 0 {
_, err = client.Call(Ping, nil)
if err != nil {
log.Printf("Ping failed when adding %s, trying %d more times\n", host, pings)
pings--
time.Sleep(time.Millisecond * 300)
continue
}
break
}
log.Printf("Connected to remote %s", host)
cart_pool, err := netpool.New(func() (net.Conn, error) {
return net.Dial("tcp", fmt.Sprintf("%s:1337", host))
}, netpool.WithMaxPool(128), netpool.WithMinPool(0))
if err != nil {
log.Printf("Error creating grain pool: %v\n", err)
return
}
remote := RemoteHost{
HostPool: cart_pool,
Connection: client,
MissedPings: 0,
Host: host,
}
p.remotes[host] = &remote
connectedRemotes.Set(float64(len(p.remotes)))
go p.HandlePing(&remote)
go remote.Initialize(p)
}
func (p *SyncedPool) HandlePing(remote *RemoteHost) {
for range time.Tick(time.Second * 3) {
err := remote.Ping()
for err != nil {
time.Sleep(time.Millisecond * 200)
if !remote.IsHealthy() {
log.Printf("Removing host, unable to communicate with %s", remote.Host)
p.RemoveHost(remote)
return
}
err = remote.Ping()
}
}
}
func (p *SyncedPool) getGrain(id CartId) (Grain, error) {
var err error
p.mu.RLock()
defer p.mu.RUnlock()
localGrain, ok := p.local.grains[id]
if !ok {
// check if remote grain exists
remoteGrain, ok := p.remoteIndex[id]
if ok {
remoteLookupCount.Inc()
return remoteGrain, nil
}
go p.RequestOwnership(id)
// if err != nil {
// log.Printf("Error requesting ownership: %v\n", err)
// return nil, err
// }
localGrain, err = p.local.GetGrain(id)
if err != nil {
return nil, err
}
}
return localGrain, nil
}
func (p *SyncedPool) Close() {
p.mu.Lock()
defer p.mu.Unlock()
payload := []byte(p.Hostname)
for _, r := range p.remotes {
go r.Call(Closing, payload)
}
}
func (p *SyncedPool) Process(id CartId, messages ...Message) (*FrameWithPayload, error) {
pool, err := p.getGrain(id)
var res *FrameWithPayload
if err != nil {
return nil, err
}
for _, m := range messages {
res, err = pool.HandleMessage(&m, false)
if err != nil {
return nil, err
}
}
return res, nil
}
func (p *SyncedPool) Get(id CartId) (*FrameWithPayload, error) {
grain, err := p.getGrain(id)
if err != nil {
return nil, err
}
return grain.GetCurrentState()
}

View File

@@ -1,66 +0,0 @@
package main
import (
"log"
"testing"
"time"
messages "git.tornberg.me/go-cart-actor/proto"
)
func TestConnection(t *testing.T) {
// TestConnection tests the connection to the server
localPool := NewGrainLocalPool(100, time.Minute, func(id CartId) (*CartGrain, error) {
return &CartGrain{
Id: id,
storageMessages: []Message{},
Items: []*CartItem{},
Deliveries: make([]*CartDelivery, 0),
TotalPrice: 0,
}, nil
})
hg, err := NewGrainHandler(localPool, ":1337")
if err != nil {
t.Errorf("Error creating handler: %v\n", err)
}
if hg == nil {
t.Errorf("Expected handler, got nil")
}
pool, err := NewSyncedPool(localPool, "127.0.0.1", nil)
if err != nil {
t.Errorf("Error creating pool: %v", err)
}
time.Sleep(400 * time.Millisecond)
pool.AddRemote("127.0.0.1")
go pool.Negotiate()
msg := Message{
Type: AddItemType,
//TimeStamp: time.Now().Unix(),
Content: &messages.AddItem{
Quantity: 1,
Price: 100,
Sku: "123",
Name: "Test",
Image: "Test",
},
}
data, err := pool.Process(ToCartId("kalle"), msg)
if err != nil {
t.Errorf("Error getting data: %v", err)
}
if data.StatusCode != 200 {
t.Errorf("Expected 200")
}
log.Println(data)
time.Sleep(2 * time.Millisecond)
data, err = pool.Get(ToCartId("kalle"))
if err != nil {
t.Errorf("Error getting data: %v", err)
}
if data == nil {
t.Errorf("Expected data, got nil")
}
}

View File

@@ -1,232 +0,0 @@
package main
import (
"bufio"
"encoding/binary"
"fmt"
"io"
"log"
"net"
"time"
"github.com/yudhasubki/netpool"
)
type Connection struct {
address string
pool netpool.Netpooler
count uint64
}
type FrameType uint32
type StatusCode uint32
type CheckSum uint32
type Frame struct {
Type FrameType
StatusCode StatusCode
Length uint32
Checksum CheckSum
}
func (f *Frame) IsValid() bool {
return f.Checksum == MakeChecksum(f.Type, f.StatusCode, f.Length)
}
func MakeChecksum(msg FrameType, statusCode StatusCode, length uint32) CheckSum {
sum := CheckSum((uint32(msg) + uint32(statusCode) + length) / 8)
return sum
}
type FrameWithPayload struct {
Frame
Payload []byte
}
func MakeFrameWithPayload(msg FrameType, statusCode StatusCode, payload []byte) FrameWithPayload {
len := uint32(len(payload))
return FrameWithPayload{
Frame: Frame{
Type: msg,
StatusCode: statusCode,
Length: len,
Checksum: MakeChecksum(msg, statusCode, len),
},
Payload: payload,
}
}
type FrameData interface {
ToBytes() []byte
FromBytes([]byte) error
}
func NewConnection(address string, pool netpool.Netpooler) *Connection {
return &Connection{
count: 0,
pool: pool,
address: address,
}
}
func SendFrame(conn net.Conn, data *FrameWithPayload) error {
err := binary.Write(conn, binary.LittleEndian, data.Frame)
if err != nil {
return err
}
_, err = conn.Write(data.Payload)
return err
}
func (c *Connection) CallAsync(msg FrameType, payload []byte, ch chan<- FrameWithPayload) (net.Conn, error) {
conn, err := c.pool.Get()
//conn, err := net.Dial("tcp", c.address)
if err != nil {
return conn, err
}
go WaitForFrame(conn, ch)
go func(toSend FrameWithPayload) {
err = SendFrame(conn, &toSend)
if err != nil {
log.Printf("Error sending frame: %v\n", err)
//close(ch)
//conn.Close()
}
}(MakeFrameWithPayload(msg, 1, payload))
c.count++
return conn, err
}
func (c *Connection) Call(msg FrameType, data []byte) (*FrameWithPayload, error) {
ch := make(chan FrameWithPayload, 1)
conn, err := c.CallAsync(msg, data, ch)
defer c.pool.Put(conn, err)
if err != nil {
return nil, err
}
defer close(ch)
ret := <-ch
return &ret, nil
// select {
// case ret := <-ch:
// return &ret, nil
// case <-time.After(MaxCallDuration):
// return nil, fmt.Errorf("timeout waiting for frame")
// }
}
func WaitForFrame(conn net.Conn, resultChan chan<- FrameWithPayload) error {
var err error
var frame Frame
err = binary.Read(conn, binary.LittleEndian, &frame)
if err != nil {
return err
}
if frame.IsValid() {
payload := make([]byte, frame.Length)
_, err = conn.Read(payload)
if err != nil {
conn.Close()
return err
}
resultChan <- FrameWithPayload{
Frame: frame,
Payload: payload,
}
return err
}
log.Println("Checksum mismatch")
return fmt.Errorf("checksum mismatch")
}
type GenericListener struct {
StopListener bool
handlers map[FrameType]func(*FrameWithPayload, chan<- FrameWithPayload) error
}
func (c *Connection) Listen() (*GenericListener, error) {
l, err := net.Listen("tcp", c.address)
if err != nil {
return nil, err
}
ret := &GenericListener{
handlers: make(map[FrameType]func(*FrameWithPayload, chan<- FrameWithPayload) error),
}
go func() {
for !ret.StopListener {
connection, err := l.Accept()
if err != nil {
log.Printf("Error accepting connection: %v\n", err)
continue
}
go ret.HandleConnection(connection)
}
}()
return ret, nil
}
const (
MaxCallDuration = 300 * time.Millisecond
ListenerKeepalive = 5 * time.Second
)
func (l *GenericListener) HandleConnection(conn net.Conn) {
var err error
var frame Frame
log.Printf("Server Connection accepted: %s\n", conn.RemoteAddr().String())
b := bufio.NewReader(conn)
for err != io.EOF {
err = binary.Read(b, binary.LittleEndian, &frame)
if err == nil && frame.IsValid() {
payload := make([]byte, frame.Length)
_, err = b.Read(payload)
if err == nil {
err = l.HandleFrame(conn, &FrameWithPayload{
Frame: frame,
Payload: payload,
})
}
}
}
conn.Close()
log.Printf("Server Connection closed")
}
func (l *GenericListener) AddHandler(msg FrameType, handler func(*FrameWithPayload, chan<- FrameWithPayload) error) {
l.handlers[msg] = handler
}
func (l *GenericListener) HandleFrame(conn net.Conn, frame *FrameWithPayload) error {
handler, ok := l.handlers[frame.Type]
if ok {
go func() {
resultChan := make(chan FrameWithPayload, 1)
defer close(resultChan)
err := handler(frame, resultChan)
if err != nil {
errFrame := MakeFrameWithPayload(frame.Type, 500, []byte(err.Error()))
SendFrame(conn, &errFrame)
log.Printf("Handler returned error: %s", err)
return
}
result := <-resultChan
err = SendFrame(conn, &result)
if err != nil {
log.Printf("Error sending frame: %s", err)
}
}()
} else {
conn.Close()
return fmt.Errorf("no handler for frame type %d", frame.Type)
}
return nil
}

View File

@@ -1,74 +0,0 @@
package main
import (
"fmt"
"net"
"testing"
"github.com/yudhasubki/netpool"
)
func TestGenericConnection(t *testing.T) {
listenConn := NewConnection("127.0.0.1:51337", nil)
listener, err := listenConn.Listen()
if err != nil {
t.Errorf("Error listening: %v\n", err)
}
pool, err := netpool.New(func() (net.Conn, error) {
return net.Dial("tcp", "127.0.0.1:51337")
}, netpool.WithMaxPool(128), netpool.WithMinPool(16))
if err != nil {
t.Errorf("Error creating pool: %v\n", err)
}
conn := NewConnection("127.0.0.1:51337", pool)
datta := []byte("Hello, world!")
listener.AddHandler(Ping, func(input *FrameWithPayload, resultChan chan<- FrameWithPayload) error {
resultChan <- MakeFrameWithPayload(Pong, 200, nil)
return nil
})
listener.AddHandler(1, func(input *FrameWithPayload, resultChan chan<- FrameWithPayload) error {
resultChan <- MakeFrameWithPayload(2, 200, datta)
return nil
})
listener.AddHandler(3, func(input *FrameWithPayload, resultChan chan<- FrameWithPayload) error {
return fmt.Errorf("Error in custom handler")
})
r, err := conn.Call(1, datta)
if err != nil {
t.Errorf("Error calling: %v\n", err)
}
if r.Type != 2 {
t.Errorf("Expected type 2, got %d\n", r.Type)
}
res, err := conn.Call(3, datta)
if err != nil {
t.Errorf("Did not expect error, got %v\n", err)
return
}
if res.StatusCode == 200 {
t.Errorf("Expected error, got %v\n", res)
}
i := 100
results := make(chan FrameWithPayload, i)
for i > 0 {
go conn.CallAsync(1, datta, results)
i--
}
for i < 100 {
r := <-results
if r.Type != 2 {
t.Errorf("Expected type 2, got %d\n", r.Type)
}
i++
}
response, err := conn.Call(Ping, nil)
if err != nil || response.StatusCode != 200 || response.Type != Pong {
t.Errorf("Error connecting to remote %v, err: %v\n", response, err)
}
}