Compare commits
22 Commits
main
...
9df2f3362a
| Author | SHA1 | Date | |
|---|---|---|---|
| 9df2f3362a | |||
| 6345d91ef7 | |||
| 4cacc0ee2d | |||
| 24cd0b6ad7 | |||
|
|
e48a2590bd | ||
|
|
b0e6c8eca8 | ||
|
|
7814f33a06 | ||
|
|
fb111ebf97 | ||
|
|
5525e91ecc | ||
|
|
f8c8ad56c7 | ||
|
|
09a68db8d5 | ||
|
|
30c89a0394 | ||
|
|
d6563d0b3a | ||
|
|
2a2ce247d5 | ||
|
|
159253b8b0 | ||
|
|
c30be581cd | ||
|
|
716f1121aa | ||
|
|
12d87036f6 | ||
|
|
e7c67fbb9b | ||
|
|
b97eb8f285 | ||
|
|
2697832d98 | ||
|
|
4c973b239f |
68
.dockerignore
Normal file
68
.dockerignore
Normal 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
|
||||
@@ -1,30 +1,77 @@
|
||||
name: Build and Publish
|
||||
run-name: ${{ gitea.actor }} is building 🚀
|
||||
run-name: ${{ gitea.actor }} build 🚀
|
||||
on: [push]
|
||||
|
||||
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:
|
||||
needs: Metadata
|
||||
runs-on: amd64
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
- name: Build docker image
|
||||
run: docker build --progress=plain -t registry.knatofs.se/go-cart-actor-amd64:latest .
|
||||
- name: Push to registry
|
||||
run: docker push registry.knatofs.se/go-cart-actor-amd64:latest
|
||||
- name: Deploy to Kubernetes
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build amd64 image
|
||||
run: |
|
||||
docker build \
|
||||
--build-arg VERSION=${{ needs.Metadata.outputs.version }} \
|
||||
--build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \
|
||||
--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
|
||||
- name: Rollout amd64 deployment
|
||||
run: kubectl rollout restart deployment/cart-actor-x86 -n cart
|
||||
- name: Rollout amd64 deployment (pin to version)
|
||||
run: |
|
||||
kubectl set image deployment/cart-actor-x86 -n cart cart-actor-amd64=registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }}
|
||||
kubectl rollout status deployment/cart-actor-x86 -n cart
|
||||
|
||||
BuildAndDeploy:
|
||||
BuildAndDeployArm64:
|
||||
needs: Metadata
|
||||
runs-on: arm64
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
- name: Build docker image
|
||||
run: docker build --progress=plain -t registry.knatofs.se/go-cart-actor .
|
||||
- name: Push to registry
|
||||
run: docker push registry.knatofs.se/go-cart-actor
|
||||
- name: Rollout arm64 deployment
|
||||
run: kubectl rollout restart deployment/cart-actor-arm64 -n cart
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build arm64 image
|
||||
run: |
|
||||
docker build \
|
||||
--build-arg VERSION=${{ needs.Metadata.outputs.version }} \
|
||||
--build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \
|
||||
--build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \
|
||||
--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
|
||||
|
||||
76
Dockerfile
76
Dockerfile
@@ -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 ./
|
||||
RUN go mod download
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
COPY proto ./proto
|
||||
COPY *.go ./
|
||||
# Copy full source (relay on .dockerignore to prune)
|
||||
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 /
|
||||
|
||||
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"]
|
||||
132
Makefile
Normal file
132
Makefile
Normal 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=./proto --go_opt=paths=source_relative \
|
||||
--go-grpc_out=./proto --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
372
README.md
@@ -1,12 +1,43 @@
|
||||
# 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.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.24.2+
|
||||
- Protocol Buffers compiler (`protoc`)
|
||||
- protoc-gen-go plugin
|
||||
- protoc-gen-go and protoc-gen-go-grpc plugins
|
||||
|
||||
### Installing Protocol Buffers
|
||||
|
||||
@@ -32,17 +63,20 @@ sudo apt install protobuf-compiler
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
### 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
|
||||
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
|
||||
@@ -73,8 +107,338 @@ go build .
|
||||
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
|
||||
|
||||
- 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
|
||||
- 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 in‑memory 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 inter‑node 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 in‑memory 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 per‑mutation 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 (Inter‑Node 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 split‑brain).
|
||||
|
||||
---
|
||||
|
||||
## 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, read‑only view).
|
||||
|
||||
### Checkout (Side‑Effecting, 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
245
TODO.md
Normal 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 2–3 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._
|
||||
@@ -1,83 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
type AmqpOrderHandler struct {
|
||||
Url string
|
||||
connection *amqp.Connection
|
||||
//channel *amqp.Channel
|
||||
Connection *amqp.Connection
|
||||
Channel *amqp.Channel
|
||||
}
|
||||
|
||||
const (
|
||||
topic = "order-placed"
|
||||
)
|
||||
|
||||
func (t *AmqpOrderHandler) Connect() error {
|
||||
|
||||
conn, err := amqp.DialConfig(t.Url, amqp.Config{
|
||||
//Vhost: "/",
|
||||
Properties: amqp.NewConnectionProperties(),
|
||||
})
|
||||
|
||||
func (h *AmqpOrderHandler) Connect() error {
|
||||
conn, err := amqp.Dial(h.Url)
|
||||
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()
|
||||
if err != nil {
|
||||
return 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
|
||||
return fmt.Errorf("failed to open a channel: %w", err)
|
||||
}
|
||||
h.Channel = ch
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *AmqpOrderHandler) Close() error {
|
||||
log.Println("Closing master channel")
|
||||
return t.connection.Close()
|
||||
//return t.channel.Close()
|
||||
func (h *AmqpOrderHandler) Close() error {
|
||||
if h.Channel != nil {
|
||||
h.Channel.Close()
|
||||
}
|
||||
if h.Connection != nil {
|
||||
return h.Connection.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *AmqpOrderHandler) OrderCompleted(data []byte) error {
|
||||
ch, err := t.connection.Channel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer ch.Close()
|
||||
return ch.Publish(
|
||||
topic,
|
||||
topic,
|
||||
true,
|
||||
false,
|
||||
func (h *AmqpOrderHandler) OrderCompleted(body []byte) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := h.Channel.PublishWithContext(ctx,
|
||||
"orders", // exchange
|
||||
"new", // routing key
|
||||
false, // mandatory
|
||||
false, // immediate
|
||||
amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
Body: data,
|
||||
},
|
||||
)
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to publish a message: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
419
cart-grain.go
419
cart-grain.go
@@ -1,32 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
type CartId [16]byte
|
||||
|
||||
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
|
||||
}
|
||||
// Legacy padded [16]byte CartId and its helper methods removed.
|
||||
// Unified CartId (uint64 with base62 string form) now defined in cart_id.go.
|
||||
|
||||
type StockStatus int
|
||||
|
||||
@@ -77,7 +61,7 @@ type CartGrain struct {
|
||||
mu sync.RWMutex
|
||||
lastItemId int
|
||||
lastDeliveryId int
|
||||
storageMessages []Message
|
||||
lastChange int64 // unix seconds of last successful mutation (replay sets from event ts)
|
||||
Id CartId `json:"id"`
|
||||
Items []*CartItem `json:"items"`
|
||||
TotalPrice int64 `json:"totalPrice"`
|
||||
@@ -92,8 +76,8 @@ type CartGrain struct {
|
||||
|
||||
type Grain interface {
|
||||
GetId() CartId
|
||||
HandleMessage(message *Message, isReplay bool) (*FrameWithPayload, error)
|
||||
GetCurrentState() (*FrameWithPayload, error)
|
||||
Apply(content interface{}, isReplay bool) (*CartGrain, error)
|
||||
GetCurrentState() (*CartGrain, error)
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetId() CartId {
|
||||
@@ -101,31 +85,18 @@ func (c *CartGrain) GetId() CartId {
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetLastChange() int64 {
|
||||
if len(c.storageMessages) == 0 {
|
||||
return 0
|
||||
}
|
||||
return *c.storageMessages[len(c.storageMessages)-1].TimeStamp
|
||||
return c.lastChange
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetCurrentState() (*FrameWithPayload, error) {
|
||||
result, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
ret := MakeFrameWithPayload(0, 400, []byte(err.Error()))
|
||||
return &ret, nil
|
||||
}
|
||||
ret := MakeFrameWithPayload(0, 200, result)
|
||||
return &ret, nil
|
||||
func (c *CartGrain) GetCurrentState() (*CartGrain, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func getInt(data interface{}) (int, error) {
|
||||
switch v := data.(type) {
|
||||
case float64:
|
||||
return int(v), nil
|
||||
case int:
|
||||
return v, nil
|
||||
default:
|
||||
func getInt(data float64, ok bool) (int, error) {
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("invalid type")
|
||||
}
|
||||
return int(data), nil
|
||||
}
|
||||
|
||||
func getItemData(sku string, qty int, country string) (*messages.AddItem, error) {
|
||||
@@ -133,35 +104,36 @@ func getItemData(sku string, qty int, country string) (*messages.AddItem, error)
|
||||
if err != nil {
|
||||
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 {
|
||||
return nil, fmt.Errorf("invalid price")
|
||||
}
|
||||
|
||||
stock := InStock
|
||||
/*item.t
|
||||
if item.StockLevel == "0" || item.StockLevel == "" {
|
||||
stock = OutOfStock
|
||||
} else if item.StockLevel == "5+" {
|
||||
stock = LowStock
|
||||
}
|
||||
articleType, _ := item.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
|
||||
if ok {
|
||||
outlet = &outletGrade
|
||||
}
|
||||
sellerId, _ := item.Fields[24].(string)
|
||||
sellerName, _ := item.Fields[9].(string)
|
||||
sellerId, _ := item.GetStringFieldValue(24) // .Fields[24].(string)
|
||||
sellerName, _ := item.GetStringFieldValue(9) // .Fields[9].(string)
|
||||
|
||||
brand, _ := item.Fields[2].(string)
|
||||
category, _ := item.Fields[10].(string)
|
||||
category2, _ := item.Fields[11].(string)
|
||||
category3, _ := item.Fields[12].(string)
|
||||
category4, _ := item.Fields[13].(string)
|
||||
category5, _ := item.Fields[14].(string)
|
||||
brand, _ := item.GetStringFieldValue(2) //.Fields[2].(string)
|
||||
category, _ := item.GetStringFieldValue(10) //.Fields[10].(string)
|
||||
category2, _ := item.GetStringFieldValue(11) //.Fields[11].(string)
|
||||
category3, _ := item.GetStringFieldValue(12) //.Fields[12].(string)
|
||||
category4, _ := item.GetStringFieldValue(13) //Fields[13].(string)
|
||||
category5, _ := item.GetStringFieldValue(14) //.Fields[14].(string)
|
||||
|
||||
return &messages.AddItem{
|
||||
ItemId: int64(item.Id),
|
||||
@@ -188,30 +160,23 @@ func getItemData(sku string, qty int, country string) (*messages.AddItem, error)
|
||||
}, 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cartItem.StoreId = storeId
|
||||
return c.HandleMessage(&Message{
|
||||
Type: 2,
|
||||
Content: cartItem,
|
||||
}, false)
|
||||
return c.Apply(cartItem, false)
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetStorageMessage(since int64) []StorableMessage {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
ret := make([]StorableMessage, 0)
|
||||
/*
|
||||
Legacy storage (event sourcing) removed in oneof refactor.
|
||||
Kept stub (commented) for potential future reintroduction using proto envelopes.
|
||||
|
||||
for _, message := range c.storageMessages {
|
||||
if *message.TimeStamp > since {
|
||||
ret = append(ret, message)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
func (c *CartGrain) GetStorageMessage(since int64) []interface{} {
|
||||
return nil
|
||||
}
|
||||
*/
|
||||
|
||||
func (c *CartGrain) GetState() ([]byte, error) {
|
||||
return json.Marshal(c)
|
||||
@@ -266,324 +231,24 @@ func GetTaxAmount(total int64, tax int) int64 {
|
||||
return int64(float64(total) / float64((1 + taxD)))
|
||||
}
|
||||
|
||||
func (c *CartGrain) HandleMessage(message *Message, isReplay bool) (*FrameWithPayload, error) {
|
||||
if message.TimeStamp == nil {
|
||||
now := time.Now().Unix()
|
||||
message.TimeStamp = &now
|
||||
}
|
||||
func (c *CartGrain) Apply(content interface{}, isReplay bool) (*CartGrain, error) {
|
||||
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()
|
||||
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)
|
||||
updated, err := ApplyRegistered(c, content)
|
||||
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
|
||||
}
|
||||
|
||||
result := MakeFrameWithPayload(RemoteCreateOrderReply, 200, orderData)
|
||||
return &result, nil
|
||||
}
|
||||
case OrderCompletedType:
|
||||
msg, ok := message.Content.(*messages.OrderCreated)
|
||||
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
|
||||
// Sliding TTL: update lastChange only for non-replay successful mutations.
|
||||
if updated != nil && !isReplay {
|
||||
c.lastChange = time.Now().Unix()
|
||||
_ = AppendCartEvent(c.Id, content)
|
||||
}
|
||||
|
||||
if !isReplay {
|
||||
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
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (c *CartGrain) UpdateTotals() {
|
||||
|
||||
@@ -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
159
cart_id.go
Normal 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
185
cart_id_test.go
Normal 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
119
checkout_builder.go
Normal 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
5
cookies.txt
Normal 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
|
||||
@@ -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
|
||||
@@ -55,12 +55,8 @@ spec:
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: web
|
||||
- containerPort: 1234
|
||||
name: echo
|
||||
- containerPort: 1337
|
||||
name: rpc
|
||||
- containerPort: 1338
|
||||
name: quorum
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /livez
|
||||
@@ -117,7 +113,7 @@ metadata:
|
||||
arch: arm64
|
||||
name: cart-actor-arm64
|
||||
spec:
|
||||
replicas: 0
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: cart-actor
|
||||
@@ -161,12 +157,8 @@ spec:
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: web
|
||||
- containerPort: 1234
|
||||
name: echo
|
||||
- containerPort: 1337
|
||||
name: rpc
|
||||
- containerPort: 1338
|
||||
name: quorum
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /livez
|
||||
@@ -217,18 +209,6 @@ spec:
|
||||
---
|
||||
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:
|
||||
@@ -248,10 +228,10 @@ metadata:
|
||||
name: cart-ingress
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
# nginx.ingress.kubernetes.io/affinity: "cookie"
|
||||
# nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity"
|
||||
# nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
|
||||
# nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
|
||||
nginx.ingress.kubernetes.io/affinity: "cookie"
|
||||
nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity"
|
||||
nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
|
||||
nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: 4m
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
apiVersion: autoscaling/v1
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: cart-scaler-amd
|
||||
@@ -9,9 +9,47 @@ spec:
|
||||
name: cart-actor-x86
|
||||
minReplicas: 3
|
||||
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
|
||||
metadata:
|
||||
name: cart-scaler-arm
|
||||
@@ -22,4 +60,42 @@ spec:
|
||||
name: cart-actor-arm64
|
||||
minReplicas: 3
|
||||
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"
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
)
|
||||
|
||||
type DiscardedHost struct {
|
||||
*Connection
|
||||
Host string
|
||||
Tries int
|
||||
}
|
||||
@@ -26,7 +25,7 @@ func (d *DiscardedHostHandler) run() {
|
||||
d.mu.RLock()
|
||||
lst := make([]*DiscardedHost, 0, len(d.hosts))
|
||||
for _, host := range d.hosts {
|
||||
if host.Tries >= 0 || host.Tries < 5 {
|
||||
if host.Tries >= 0 && host.Tries < 5 {
|
||||
go d.testConnection(host)
|
||||
lst = append(lst, host)
|
||||
} else {
|
||||
@@ -49,7 +48,9 @@ func (d *DiscardedHostHandler) testConnection(host *DiscardedHost) {
|
||||
|
||||
if err != nil {
|
||||
host.Tries++
|
||||
host.Tries = -1
|
||||
if host.Tries >= 5 {
|
||||
// Exceeded retry threshold; will be dropped by run loop.
|
||||
}
|
||||
} else {
|
||||
conn.Close()
|
||||
if d.onConnection != nil {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
95
discovery.go
95
discovery.go
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
v1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -75,3 +76,97 @@ func NewK8sDiscovery(client *kubernetes.Clientset) *K8sDiscovery {
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// MockDiscovery is an in-memory Discovery implementation for tests.
|
||||
// It allows deterministic injection of host additions/removals without
|
||||
// depending on Kubernetes API machinery.
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
func TestDiscovery(t *testing.T) {
|
||||
config, err := clientcmd.BuildConfigFromFlags("", "/Users/mats/.kube/config")
|
||||
config, err := clientcmd.BuildConfigFromFlags("", "/home/mats/.kube/config")
|
||||
if err != nil {
|
||||
t.Errorf("Error building config: %v", err)
|
||||
}
|
||||
@@ -28,7 +28,7 @@ func TestDiscovery(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 {
|
||||
t.Errorf("Error building config: %v", err)
|
||||
}
|
||||
|
||||
138
disk-storage.go
138
disk-storage.go
@@ -2,118 +2,72 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
gob.Register(map[uint64]int64{})
|
||||
}
|
||||
|
||||
type DiskStorage struct {
|
||||
stateFile string
|
||||
lastSave int64
|
||||
LastSaves map[CartId]int64
|
||||
LastSaves map[uint64]int64
|
||||
}
|
||||
|
||||
func NewDiskStorage(stateFile string) (*DiskStorage, error) {
|
||||
ret := &DiskStorage{
|
||||
stateFile: stateFile,
|
||||
LastSaves: make(map[CartId]int64),
|
||||
LastSaves: make(map[uint64]int64),
|
||||
}
|
||||
err := ret.loadState()
|
||||
return ret, err
|
||||
//err := ret.loadState()
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
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()
|
||||
// func getCartPath(id string) string {
|
||||
// return fmt.Sprintf("data/%s.prot", id)
|
||||
// }
|
||||
|
||||
for _, m := range messages {
|
||||
err := m.Write(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
// func loadMessages(_ Grain, _ CartId) error {
|
||||
// // No-op: legacy replay removed in oneof refactor.
|
||||
// return nil
|
||||
// }
|
||||
|
||||
func getCartPath(id string) string {
|
||||
return fmt.Sprintf("data/%s.prot", id)
|
||||
}
|
||||
// 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 loadMessages(grain Grain, id CartId) error {
|
||||
var err error
|
||||
path := getCartPath(id.String())
|
||||
// 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)
|
||||
// }
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
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
|
||||
}
|
||||
func (s *DiskStorage) Store(id CartId, _ *CartGrain) error {
|
||||
// With the removal of the legacy message log, we only update the timestamp.
|
||||
ts := time.Now().Unix()
|
||||
s.LastSaves[id] = ts
|
||||
s.LastSaves[uint64(id)] = ts
|
||||
s.lastSave = ts
|
||||
return nil
|
||||
}
|
||||
|
||||
288
event_log.go
Normal file
288
event_log.go
Normal 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/proto"
|
||||
"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 int64 `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
90
go.mod
@@ -1,63 +1,73 @@
|
||||
module git.tornberg.me/go-cart-actor
|
||||
|
||||
go 1.24.2
|
||||
go 1.25.1
|
||||
|
||||
require (
|
||||
github.com/matst80/slask-finder v0.0.0-20250418094723-2eb7d6615761
|
||||
github.com/prometheus/client_golang v1.22.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/rabbitmq/amqp091-go v1.10.0
|
||||
github.com/yudhasubki/netpool v0.0.0-20230717065341-3c1353ca328e
|
||||
google.golang.org/protobuf v1.36.6
|
||||
k8s.io/api v0.32.3
|
||||
k8s.io/apimachinery v0.32.3
|
||||
k8s.io/client-go v0.32.3
|
||||
google.golang.org/grpc v1.76.0
|
||||
google.golang.org/protobuf v1.36.10
|
||||
k8s.io/api v0.34.1
|
||||
k8s.io/apimachinery v0.34.1
|
||||
k8s.io/client-go v0.34.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/RoaringBitmap/roaring/v2 v2.10.0 // 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/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.12.2 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.1 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.1 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.1 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.2 // indirect
|
||||
github.com/go-openapi/swag v0.25.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/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/gnostic-models v0.6.9 // indirect
|
||||
github.com/google/gnostic-models v0.7.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/gofuzz v1.2.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/gorilla/schema v1.4.1 // 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/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/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/common v0.63.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.0 // indirect
|
||||
github.com/redis/go-redis/v9 v9.7.0 // indirect
|
||||
github.com/prometheus/common v0.67.1 // indirect
|
||||
github.com/prometheus/procfs v0.17.0 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/net v0.39.0 // indirect
|
||||
golang.org/x/oauth2 v0.29.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/term v0.31.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
golang.org/x/time v0.11.0 // indirect
|
||||
golang.org/x/tools v0.32.0 // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/oauth2 v0.32.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/term v0.36.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
k8s.io/klog/v2 v2.130.1 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect
|
||||
k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e // indirect
|
||||
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect
|
||||
sigs.k8s.io/yaml v1.4.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
|
||||
210
go.sum
210
go.sum
@@ -1,51 +1,71 @@
|
||||
github.com/Flaconi/go-klarna v0.0.0-20230216165926-e2f708c721d9 h1:U5gu3M9/khqtvgg6iRKo0+nxGEfPHWFHRlKrbZvFxIY=
|
||||
github.com/Flaconi/go-klarna v0.0.0-20230216165926-e2f708c721d9/go.mod h1:+LVFV9FXH5cwN1VcU30WcNYRs5FhkEtL7/IqqTD42cU=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.10.0 h1:HbJ8Cs71lfCJyvmSptxeMX2PtvOC8yonlU0GQcy2Ak0=
|
||||
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/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM=
|
||||
github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
|
||||
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
|
||||
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
|
||||
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
|
||||
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
|
||||
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
|
||||
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
|
||||
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
|
||||
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
|
||||
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
|
||||
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
|
||||
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
|
||||
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
|
||||
github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8=
|
||||
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/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
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.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
||||
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/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/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
||||
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/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
||||
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
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/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548 h1:lkxVz5lNPlU78El49cx1c3Rxo/bp7Gbzp2BjSRFgg1U=
|
||||
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548/go.mod h1:k4lo5gFYb3AqrgftlraCnv95xvjB/w8udRqJQJ12mAE=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
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/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.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/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
|
||||
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
||||
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
|
||||
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
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.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
|
||||
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
|
||||
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
|
||||
github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI=
|
||||
github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q=
|
||||
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||
github.com/prometheus/procfs v0.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/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||
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/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
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.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/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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
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-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.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
|
||||
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/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-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
|
||||
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
|
||||
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
|
||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
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-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-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
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 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||
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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
k8s.io/api v0.32.3 h1:Hw7KqxRusq+6QSplE3NYG4MBxZw1BZnq4aP4cJVINls=
|
||||
k8s.io/api v0.32.3/go.mod h1:2wEDTXADtm/HA7CCMD8D8bK4yuBUptzaRhYcYEEYA3k=
|
||||
k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U=
|
||||
k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE=
|
||||
k8s.io/client-go v0.32.3 h1:RKPVltzopkSgHS7aS98QdscAgtgah/+zmpAogooIqVU=
|
||||
k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY=
|
||||
k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM=
|
||||
k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk=
|
||||
k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4=
|
||||
k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
|
||||
k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY=
|
||||
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/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4=
|
||||
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8=
|
||||
k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e h1:KqK5c/ghOm8xkHYhlodbp6i6+r+ChV2vuAuVRdFbLro=
|
||||
k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
|
||||
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||
sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||
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/structured-merge-diff/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxgMd7wez1XkI=
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps=
|
||||
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
|
||||
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||
|
||||
327
grafana_dashboard_cart.json
Normal file
327
grafana_dashboard_cart.json
Normal file
@@ -0,0 +1,327 @@
|
||||
{
|
||||
"uid": "cart-actors",
|
||||
"title": "Cart Actor Cluster",
|
||||
"timezone": "browser",
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 38,
|
||||
"version": 1,
|
||||
"editable": true,
|
||||
"graphTooltip": 0,
|
||||
"panels": [
|
||||
{
|
||||
"type": "row",
|
||||
"title": "Overview",
|
||||
"gridPos": { "x": 0, "y": 0, "w": 24, "h": 1 },
|
||||
"id": 1,
|
||||
"collapsed": false
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Active Grains",
|
||||
"id": 2,
|
||||
"gridPos": { "x": 0, "y": 1, "w": 6, "h": 4 },
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "cart_active_grains" }
|
||||
],
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "center",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Grains In Pool",
|
||||
"id": 3,
|
||||
"gridPos": { "x": 6, "y": 1, "w": 6, "h": 4 },
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "cart_grains_in_pool" }
|
||||
],
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "center",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Pool Usage %",
|
||||
"id": 4,
|
||||
"gridPos": { "x": 12, "y": 1, "w": 6, "h": 4 },
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "cart_grain_pool_usage * 100" }
|
||||
],
|
||||
"units": "percent",
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "center",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Connected Remotes",
|
||||
"id": 5,
|
||||
"gridPos": { "x": 18, "y": 1, "w": 6, "h": 4 },
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "connected_remotes" }
|
||||
],
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "center",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"] }
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"type": "row",
|
||||
"title": "Mutations",
|
||||
"gridPos": { "x": 0, "y": 5, "w": 24, "h": 1 },
|
||||
"id": 6,
|
||||
"collapsed": false
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Mutation Rate (1m)",
|
||||
"id": 7,
|
||||
"gridPos": { "x": 0, "y": 6, "w": 12, "h": 8 },
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "rate(cart_mutations_total[1m])", "legendFormat": "mutations/s" },
|
||||
{ "refId": "B", "expr": "rate(cart_mutation_failures_total[1m])", "legendFormat": "failures/s" }
|
||||
],
|
||||
"fieldConfig": { "defaults": { "unit": "ops" } }
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Failure % (5m)",
|
||||
"id": 8,
|
||||
"gridPos": { "x": 12, "y": 6, "w": 6, "h": 4 },
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "100 * (increase(cart_mutation_failures_total[5m]) / clamp_max(increase(cart_mutations_total[5m]), 1))"
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "center",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Mutation Latency Quantiles",
|
||||
"id": 9,
|
||||
"gridPos": { "x": 18, "y": 6, "w": 6, "h": 8 },
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "histogram_quantile(0.50, sum(rate(cart_mutation_latency_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p50"
|
||||
},
|
||||
{
|
||||
"refId": "B",
|
||||
"expr": "histogram_quantile(0.90, sum(rate(cart_mutation_latency_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p90"
|
||||
},
|
||||
{
|
||||
"refId": "C",
|
||||
"expr": "histogram_quantile(0.99, sum(rate(cart_mutation_latency_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p99"
|
||||
}
|
||||
],
|
||||
"fieldConfig": { "defaults": { "unit": "s" } }
|
||||
},
|
||||
|
||||
{
|
||||
"type": "row",
|
||||
"title": "Event Log",
|
||||
"gridPos": { "x": 0, "y": 14, "w": 24, "h": 1 },
|
||||
"id": 10,
|
||||
"collapsed": false
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Event Append Rate (5m)",
|
||||
"id": 11,
|
||||
"gridPos": { "x": 0, "y": 15, "w": 8, "h": 6 },
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "rate(cart_event_log_appends_total[5m])", "legendFormat": "appends/s" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Event Bytes Written Rate (5m)",
|
||||
"id": 12,
|
||||
"gridPos": { "x": 8, "y": 15, "w": 8, "h": 6 },
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "rate(cart_event_log_bytes_written_total[5m])", "legendFormat": "bytes/s" }
|
||||
],
|
||||
"fieldConfig": { "defaults": { "unit": "Bps" } }
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Existing Log Files",
|
||||
"id": 13,
|
||||
"gridPos": { "x": 16, "y": 15, "w": 4, "h": 3 },
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"targets": [{ "refId": "A", "expr": "cart_event_log_files_existing" }],
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Last Append Age (s)",
|
||||
"id": 14,
|
||||
"gridPos": { "x": 20, "y": 15, "w": 4, "h": 3 },
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "(time() - cart_event_log_last_append_unix)" }
|
||||
],
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Replay Failures Total",
|
||||
"id": 15,
|
||||
"gridPos": { "x": 16, "y": 18, "w": 4, "h": 3 },
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"targets": [{ "refId": "A", "expr": "cart_event_log_replay_failures_total" }],
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Replay Duration p95 (5m)",
|
||||
"id": 16,
|
||||
"gridPos": { "x": 20, "y": 18, "w": 4, "h": 3 },
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "histogram_quantile(0.95, sum(rate(cart_event_log_replay_duration_seconds_bucket[5m])) by (le))"
|
||||
}
|
||||
],
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "fieldConfig": { "defaults": { "unit": "s" } } }
|
||||
},
|
||||
|
||||
{
|
||||
"type": "row",
|
||||
"title": "Grain Lifecycle",
|
||||
"gridPos": { "x": 0, "y": 21, "w": 24, "h": 1 },
|
||||
"id": 17,
|
||||
"collapsed": false
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Spawn & Lookup Rates (1m)",
|
||||
"id": 18,
|
||||
"gridPos": { "x": 0, "y": 22, "w": 12, "h": 8 },
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "rate(cart_grain_spawned_total[1m])", "legendFormat": "spawns/s" },
|
||||
{ "refId": "B", "expr": "rate(cart_grain_lookups_total[1m])", "legendFormat": "lookups/s" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Negotiations Rate (5m)",
|
||||
"id": 19,
|
||||
"gridPos": { "x": 12, "y": 22, "w": 6, "h": 4 },
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "rate(cart_remote_negotiation_total[5m])" }
|
||||
],
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "orientation": "horizontal" }
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Mutations Total",
|
||||
"id": 20,
|
||||
"gridPos": { "x": 18, "y": 22, "w": 6, "h": 4 },
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"targets": [{ "refId": "A", "expr": "cart_mutations_total" }],
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
||||
},
|
||||
|
||||
{
|
||||
"type": "row",
|
||||
"title": "Event Log Errors",
|
||||
"gridPos": { "x": 0, "y": 30, "w": 24, "h": 1 },
|
||||
"id": 21,
|
||||
"collapsed": false
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Unknown Event Types",
|
||||
"id": 22,
|
||||
"gridPos": { "x": 0, "y": 31, "w": 6, "h": 4 },
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"targets": [{ "refId": "A", "expr": "cart_event_log_unknown_types_total" }],
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Event Mutation Errors",
|
||||
"id": 23,
|
||||
"gridPos": { "x": 6, "y": 31, "w": 6, "h": 4 },
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"targets": [{ "refId": "A", "expr": "cart_event_log_mutation_errors_total" }],
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Replay Success Total",
|
||||
"id": 24,
|
||||
"gridPos": { "x": 12, "y": 31, "w": 6, "h": 4 },
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"targets": [{ "refId": "A", "expr": "cart_event_log_replay_total" }],
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Replay Duration p50 (5m)",
|
||||
"id": 25,
|
||||
"gridPos": { "x": 18, "y": 31, "w": 6, "h": 4 },
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "histogram_quantile(0.50, sum(rate(cart_event_log_replay_duration_seconds_bucket[5m])) by (le))"
|
||||
}
|
||||
],
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "fieldConfig": { "defaults": { "unit": "s" } } }
|
||||
}
|
||||
],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"name": "DS_PROMETHEUS",
|
||||
"label": "Prometheus",
|
||||
"type": "datasource",
|
||||
"query": "prometheus",
|
||||
"current": { "text": "Prometheus", "value": "Prometheus" }
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {
|
||||
"refresh_intervals": ["5s","10s","30s","1m","5m","15m","30m","1h"],
|
||||
"time_options": ["5m","15m","30m","1h","6h","12h","24h","2d","7d"]
|
||||
}
|
||||
}
|
||||
843
grain-pool.go
843
grain-pool.go
@@ -1,168 +1,827 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"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 pool",
|
||||
Help: "The total number of grains in the local pool",
|
||||
})
|
||||
poolSize = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "cart_pool_size",
|
||||
Help: "The total number of mutations",
|
||||
Help: "Configured capacity of the cart pool",
|
||||
})
|
||||
poolUsage = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "cart_grain_pool_usage",
|
||||
Help: "The current usage of the grain pool",
|
||||
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"})
|
||||
cartActiveGrains = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "cart_active_grains",
|
||||
Help: "Number of active (resident) local grains",
|
||||
})
|
||||
)
|
||||
|
||||
// GrainPool is the interface exposed to HTTP handlers and other subsystems.
|
||||
type GrainPool interface {
|
||||
Process(id CartId, messages ...Message) (*FrameWithPayload, error)
|
||||
Get(id CartId) (*FrameWithPayload, error)
|
||||
Apply(id CartId, mutation interface{}) (*CartGrain, error)
|
||||
Get(id CartId) (*CartGrain, error)
|
||||
OwnerHost(id CartId) (Host, bool)
|
||||
Hostname() string
|
||||
TakeOwnership(id CartId)
|
||||
IsHealthy() bool
|
||||
Close()
|
||||
}
|
||||
|
||||
// Host abstracts a remote node capable of proxying cart requests.
|
||||
type Host interface {
|
||||
Name() string
|
||||
Proxy(id CartId, w http.ResponseWriter, r *http.Request) (bool, error)
|
||||
}
|
||||
|
||||
// Ttl tracks the expiry deadline for an in-memory grain.
|
||||
type Ttl struct {
|
||||
Expires time.Time
|
||||
Grain *CartGrain
|
||||
}
|
||||
|
||||
type GrainLocalPool struct {
|
||||
mu sync.RWMutex
|
||||
grains map[CartId]*CartGrain
|
||||
// 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
|
||||
expiry []Ttl
|
||||
spawn func(id CartId) (*CartGrain, error)
|
||||
Ttl time.Duration
|
||||
PoolSize int
|
||||
ttl time.Duration
|
||||
poolSize int
|
||||
|
||||
// Cluster coordination --------------------------------------------------
|
||||
hostname string
|
||||
remoteMu sync.RWMutex
|
||||
remoteOwners map[CartId]*RemoteHostGRPC
|
||||
remoteHosts map[string]*RemoteHostGRPC
|
||||
discardedHostHandler *DiscardedHostHandler
|
||||
|
||||
// House-keeping ---------------------------------------------------------
|
||||
purgeTicker *time.Ticker
|
||||
}
|
||||
|
||||
func NewGrainLocalPool(size int, ttl time.Duration, spawn func(id CartId) (*CartGrain, error)) *GrainLocalPool {
|
||||
// RemoteHostGRPC mirrors the lightweight controller used for remote node
|
||||
// interaction.
|
||||
type RemoteHostGRPC struct {
|
||||
Host string
|
||||
HTTPBase string
|
||||
Conn *grpc.ClientConn
|
||||
Transport *http.Transport
|
||||
Client *http.Client
|
||||
ControlClient messages.ControlPlaneClient
|
||||
MissedPings int
|
||||
}
|
||||
|
||||
ret := &GrainLocalPool{
|
||||
spawn: spawn,
|
||||
grains: make(map[CartId]*CartGrain),
|
||||
func (h *RemoteHostGRPC) Name() string {
|
||||
return h.Host
|
||||
}
|
||||
|
||||
func (h *RemoteHostGRPC) Proxy(id CartId, 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, reqBody)
|
||||
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)
|
||||
if idStr := id.String(); idStr != "" {
|
||||
req.Header.Set("X-Cart-Id", idStr)
|
||||
}
|
||||
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 *RemoteHostGRPC) IsHealthy() bool {
|
||||
return r.MissedPings < 3
|
||||
}
|
||||
|
||||
// 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), discovery Discovery) (*CartPool, error) {
|
||||
p := &CartPool{
|
||||
grains: make(map[uint64]*CartGrain),
|
||||
expiry: make([]Ttl, 0),
|
||||
Ttl: ttl,
|
||||
PoolSize: size,
|
||||
spawn: spawn,
|
||||
ttl: ttl,
|
||||
poolSize: size,
|
||||
hostname: hostname,
|
||||
remoteOwners: make(map[CartId]*RemoteHostGRPC),
|
||||
remoteHosts: make(map[string]*RemoteHostGRPC),
|
||||
}
|
||||
|
||||
cartPurge := time.NewTicker(time.Minute)
|
||||
p.discardedHostHandler = NewDiscardedHostHandler(1338)
|
||||
p.discardedHostHandler.SetReconnectHandler(p.AddRemote)
|
||||
|
||||
p.purgeTicker = time.NewTicker(time.Minute)
|
||||
go func() {
|
||||
<-cartPurge.C
|
||||
ret.Purge()
|
||||
for range p.purgeTicker.C {
|
||||
p.Purge()
|
||||
}
|
||||
}()
|
||||
return ret
|
||||
|
||||
if discovery != nil {
|
||||
go p.startDiscovery(discovery)
|
||||
} else {
|
||||
log.Printf("No discovery configured; expecting manual AddRemote or static host injection")
|
||||
}
|
||||
|
||||
func (p *GrainLocalPool) SetAvailable(availableWithLastChangeUnix map[CartId]int64) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// startDiscovery subscribes to cluster events and adds/removes hosts.
|
||||
func (p *CartPool) startDiscovery(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
|
||||
}
|
||||
|
||||
// SetAvailable pre-populates placeholder entries.
|
||||
func (p *CartPool) SetAvailable(availableWithLastChangeUnix map[CartId]int64) {
|
||||
p.localMu.Lock()
|
||||
defer p.localMu.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,
|
||||
})
|
||||
k := uint64(id)
|
||||
if _, ok := p.grains[k]; !ok {
|
||||
p.grains[k] = nil
|
||||
p.expiry = append(p.expiry, Ttl{Expires: time.Now().Add(p.ttl)})
|
||||
}
|
||||
}
|
||||
p.statsUpdate()
|
||||
}
|
||||
|
||||
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 {
|
||||
// Purge removes expired grains and broadcasts expiry announcements so that
|
||||
// other hosts drop stale ownership hints.
|
||||
func (p *CartPool) Purge() {
|
||||
now := time.Now()
|
||||
keepChanged := now.Add(-p.ttl).Unix()
|
||||
var expired []CartId
|
||||
|
||||
p.localMu.Lock()
|
||||
for i := 0; i < len(p.expiry); {
|
||||
entry := p.expiry[i]
|
||||
if entry.Grain == nil {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if entry.Expires.After(now) {
|
||||
break
|
||||
}
|
||||
if entry.Grain.GetLastChange() > keepChanged {
|
||||
// Recently mutated: move to back.
|
||||
p.expiry = append(p.expiry[:i], p.expiry[i+1:]...)
|
||||
p.expiry = append(p.expiry, item)
|
||||
} else {
|
||||
p.expiry = append(p.expiry[:i], item)
|
||||
p.expiry = append(p.expiry, entry)
|
||||
continue
|
||||
}
|
||||
|
||||
} else {
|
||||
log.Printf("Item %s expired", item.Grain.GetId())
|
||||
delete(p.grains, item.Grain.GetId())
|
||||
id := entry.Grain.GetId()
|
||||
delete(p.grains, uint64(id))
|
||||
expired = append(expired, id)
|
||||
if i < len(p.expiry)-1 {
|
||||
p.expiry = append(p.expiry[:i], p.expiry[i+1:]...)
|
||||
} else {
|
||||
p.expiry = p.expiry[:i]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
p.localMu.Unlock()
|
||||
|
||||
if len(expired) > 0 {
|
||||
p.statsUpdate()
|
||||
go p.broadcastExpiry(expired)
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshExpiry updates the TTL entry for a given grain.
|
||||
func (p *CartPool) RefreshExpiry(id CartId) {
|
||||
p.localMu.Lock()
|
||||
defer p.localMu.Unlock()
|
||||
for i := range p.expiry {
|
||||
g := p.expiry[i].Grain
|
||||
if g != nil && g.Id == id {
|
||||
p.expiry[i].Expires = time.Now().Add(p.ttl)
|
||||
return
|
||||
}
|
||||
}
|
||||
// If no entry existed, append one (safeguard for newly spawned grains).
|
||||
p.expiry = append(p.expiry, Ttl{Expires: time.Now().Add(p.ttl), Grain: p.grains[uint64(id)]})
|
||||
}
|
||||
|
||||
// DebugGrainCount returns the number of locally resident grains.
|
||||
func (p *CartPool) DebugGrainCount() int {
|
||||
p.localMu.RLock()
|
||||
defer p.localMu.RUnlock()
|
||||
return len(p.grains)
|
||||
}
|
||||
|
||||
// LocalCartIDs returns the currently owned cart ids (for control-plane RPCs).
|
||||
func (p *CartPool) LocalCartIDs() []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
|
||||
}
|
||||
|
||||
// SnapshotGrains returns a copy of the currently resident grains keyed by id.
|
||||
func (p *CartPool) SnapshotGrains() map[CartId]*CartGrain {
|
||||
p.localMu.RLock()
|
||||
defer p.localMu.RUnlock()
|
||||
out := make(map[CartId]*CartGrain, len(p.grains))
|
||||
for _, g := range p.grains {
|
||||
if g != nil {
|
||||
out[g.GetId()] = g
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (p *CartPool) removeLocalGrain(id CartId) {
|
||||
p.localMu.Lock()
|
||||
delete(p.grains, uint64(id))
|
||||
for i := range p.expiry {
|
||||
if p.expiry[i].Grain != nil && p.expiry[i].Grain.GetId() == id {
|
||||
p.expiry = append(p.expiry[:i], p.expiry[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
p.localMu.Unlock()
|
||||
p.statsUpdate()
|
||||
}
|
||||
|
||||
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]
|
||||
func (p *CartPool) getLocalGrain(id CartId) (*CartGrain, error) {
|
||||
key := uint64(id)
|
||||
grainLookups.Inc()
|
||||
|
||||
p.localMu.RLock()
|
||||
grain, ok := p.grains[key]
|
||||
p.localMu.RUnlock()
|
||||
if grain != nil && ok {
|
||||
return grain, nil
|
||||
}
|
||||
|
||||
p.localMu.Lock()
|
||||
defer p.localMu.Unlock()
|
||||
grain, ok = p.grains[key]
|
||||
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())
|
||||
if len(p.grains) >= p.poolSize && len(p.expiry) > 0 {
|
||||
if p.expiry[0].Expires.Before(time.Now()) && p.expiry[0].Grain != nil {
|
||||
oldID := p.expiry[0].Grain.GetId()
|
||||
delete(p.grains, uint64(oldID))
|
||||
p.expiry = p.expiry[1:]
|
||||
go p.broadcastExpiry([]CartId{oldID})
|
||||
} 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)
|
||||
spawned, err := p.spawn(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := json.Marshal(grain)
|
||||
p.grains[key] = spawned
|
||||
p.expiry = append(p.expiry, Ttl{Expires: time.Now().Add(p.ttl), Grain: spawned})
|
||||
grain = spawned
|
||||
}
|
||||
go p.statsUpdate()
|
||||
return grain, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cluster ownership and coordination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (p *CartPool) TakeOwnership(id CartId) {
|
||||
p.broadcastOwnership([]CartId{id})
|
||||
}
|
||||
|
||||
func (p *CartPool) AddRemote(host string) {
|
||||
if host == "" || host == p.hostname {
|
||||
return
|
||||
}
|
||||
|
||||
p.remoteMu.Lock()
|
||||
if _, exists := p.remoteHosts[host]; exists {
|
||||
p.remoteMu.Unlock()
|
||||
return
|
||||
}
|
||||
p.remoteMu.Unlock()
|
||||
|
||||
target := fmt.Sprintf("%s:1337", host)
|
||||
dialCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
conn, err := grpc.DialContext(dialCtx, target, grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock())
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("AddRemote: dial %s failed: %v", target, err)
|
||||
return
|
||||
}
|
||||
|
||||
controlClient := messages.NewControlPlaneClient(conn)
|
||||
for retries := 0; retries < 3; retries++ {
|
||||
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
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 100,
|
||||
IdleConnTimeout: 120 * time.Second,
|
||||
}
|
||||
client := &http.Client{Transport: transport, Timeout: 10 * time.Second}
|
||||
|
||||
remote := &RemoteHostGRPC{
|
||||
Host: host,
|
||||
HTTPBase: fmt.Sprintf("http://%s:8080/cart", host),
|
||||
Conn: conn,
|
||||
Transport: transport,
|
||||
Client: client,
|
||||
ControlClient: controlClient,
|
||||
}
|
||||
|
||||
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.Negotiate()
|
||||
}
|
||||
|
||||
func (p *CartPool) initializeRemote(remote *RemoteHostGRPC) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
reply, err := remote.ControlClient.GetCartIds(ctx, &messages.Empty{})
|
||||
if err != nil {
|
||||
log.Printf("Init remote %s: GetCartIds error: %v", remote.Host, err)
|
||||
return
|
||||
}
|
||||
count := 0
|
||||
p.remoteMu.Lock()
|
||||
for _, cid := range reply.CartIds {
|
||||
id := CartId(cid)
|
||||
if _, exists := p.remoteOwners[id]; !exists {
|
||||
p.remoteOwners[id] = remote
|
||||
}
|
||||
count++
|
||||
}
|
||||
p.remoteMu.Unlock()
|
||||
log.Printf("Remote %s reported %d remote-owned carts", remote.Host, count)
|
||||
}
|
||||
|
||||
func (p *CartPool) RemoveHost(host string) {
|
||||
p.remoteMu.Lock()
|
||||
remote, exists := p.remoteHosts[host]
|
||||
if exists {
|
||||
delete(p.remoteHosts, host)
|
||||
}
|
||||
for id, owner := range p.remoteOwners {
|
||||
if owner.Host == host {
|
||||
delete(p.remoteOwners, id)
|
||||
}
|
||||
}
|
||||
p.remoteMu.Unlock()
|
||||
|
||||
if exists {
|
||||
remote.Conn.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 *RemoteHostGRPC) {
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
_, err := remote.ControlClient.Ping(ctx, &messages.Empty{})
|
||||
cancel()
|
||||
if err != nil {
|
||||
remote.MissedPings++
|
||||
log.Printf("Ping %s failed (%d)", remote.Host, remote.MissedPings)
|
||||
if !remote.IsHealthy() {
|
||||
log.Printf("Remote %s unhealthy, removing", remote.Host)
|
||||
p.RemoveHost(remote.Host)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
remote.MissedPings = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (p *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() {
|
||||
negotiationCount.Inc()
|
||||
|
||||
p.remoteMu.RLock()
|
||||
hosts := make([]string, 0, len(p.remoteHosts)+1)
|
||||
hosts = append(hosts, p.hostname)
|
||||
remotes := make([]*RemoteHostGRPC, 0, len(p.remoteHosts))
|
||||
for h, r := range p.remoteHosts {
|
||||
hosts = append(hosts, h)
|
||||
remotes = append(remotes, r)
|
||||
}
|
||||
p.remoteMu.RUnlock()
|
||||
|
||||
for _, r := range remotes {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
reply, err := r.ControlClient.Negotiate(ctx, &messages.NegotiateRequest{KnownHosts: hosts})
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("Negotiate with %s failed: %v", r.Host, err)
|
||||
continue
|
||||
}
|
||||
for _, h := range reply.Hosts {
|
||||
if !p.IsKnown(h) {
|
||||
p.AddRemote(h)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *CartPool) broadcastOwnership(ids []CartId) {
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
uids := make([]uint64, len(ids))
|
||||
for i, id := range ids {
|
||||
uids[i] = uint64(id)
|
||||
}
|
||||
|
||||
p.remoteMu.RLock()
|
||||
remotes := make([]*RemoteHostGRPC, 0, len(p.remoteHosts))
|
||||
for _, r := range p.remoteHosts {
|
||||
if r.IsHealthy() {
|
||||
remotes = append(remotes, r)
|
||||
}
|
||||
}
|
||||
p.remoteMu.RUnlock()
|
||||
|
||||
for _, remote := range remotes {
|
||||
go func(rh *RemoteHostGRPC) {
|
||||
_, err := rh.ControlClient.AnnounceOwnership(context.Background(), &messages.OwnershipAnnounce{
|
||||
Host: p.hostname,
|
||||
CartIds: uids,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("ownership announce to %s failed: %v", rh.Host, err)
|
||||
}
|
||||
}(remote)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *CartPool) broadcastExpiry(ids []CartId) {
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
uids := make([]uint64, len(ids))
|
||||
for i, id := range ids {
|
||||
uids[i] = uint64(id)
|
||||
}
|
||||
|
||||
p.remoteMu.RLock()
|
||||
remotes := make([]*RemoteHostGRPC, 0, len(p.remoteHosts))
|
||||
for _, r := range p.remoteHosts {
|
||||
if r.IsHealthy() {
|
||||
remotes = append(remotes, r)
|
||||
}
|
||||
}
|
||||
p.remoteMu.RUnlock()
|
||||
|
||||
for _, remote := range remotes {
|
||||
go func(rh *RemoteHostGRPC) {
|
||||
_, err := rh.ControlClient.AnnounceExpiry(context.Background(), &messages.ExpiryAnnounce{
|
||||
Host: p.hostname,
|
||||
CartIds: uids,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("expiry announce to %s failed: %v", rh.Host, err)
|
||||
}
|
||||
}(remote)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *CartPool) AdoptRemoteOwnership(host string, ids []string) {
|
||||
if host == "" || host == p.hostname {
|
||||
return
|
||||
}
|
||||
remoteHost, ok := p.remoteHosts[host]
|
||||
if !ok {
|
||||
log.Printf("AdoptRemoteOwnership: unknown host %s", host)
|
||||
}
|
||||
p.remoteMu.Lock()
|
||||
defer p.remoteMu.Unlock()
|
||||
for _, s := range ids {
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
parsed, ok := ParseCartId(s)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if existing, ok := p.remoteOwners[parsed]; ok && existing != remoteHost {
|
||||
continue
|
||||
}
|
||||
p.localMu.RLock()
|
||||
_, localHas := p.grains[uint64(parsed)]
|
||||
p.localMu.RUnlock()
|
||||
if localHas {
|
||||
continue
|
||||
}
|
||||
p.remoteOwners[parsed] = remoteHost
|
||||
}
|
||||
}
|
||||
|
||||
func (p *CartPool) HandleRemoteExpiry(host string, ids []uint64) {
|
||||
if host == "" || host == p.hostname {
|
||||
return
|
||||
}
|
||||
p.remoteMu.Lock()
|
||||
defer p.remoteMu.Unlock()
|
||||
for _, raw := range ids {
|
||||
id := CartId(raw)
|
||||
if owner, ok := p.remoteOwners[id]; ok && owner.Host == host {
|
||||
delete(p.remoteOwners, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *CartPool) getOrClaimGrain(id CartId) (*CartGrain, error) {
|
||||
p.localMu.RLock()
|
||||
grain, exists := p.grains[uint64(id)]
|
||||
p.localMu.RUnlock()
|
||||
if exists && grain != nil {
|
||||
return grain, nil
|
||||
}
|
||||
|
||||
p.remoteMu.RLock()
|
||||
remoteHost, found := p.remoteOwners[id]
|
||||
p.remoteMu.RUnlock()
|
||||
if found && remoteHost != nil && remoteHost.Host != "" {
|
||||
return nil, ErrNotOwner
|
||||
}
|
||||
|
||||
grain, err := p.getLocalGrain(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret := MakeFrameWithPayload(0, 200, data)
|
||||
return &ret, nil
|
||||
go p.broadcastOwnership([]CartId{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 CartId, mutation interface{}) (*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.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
if t.Name() != "" {
|
||||
mutationType = t.Name()
|
||||
}
|
||||
}
|
||||
}
|
||||
cartMutationLatencySeconds.WithLabelValues(mutationType).Observe(time.Since(start).Seconds())
|
||||
|
||||
if applyErr == nil && result != nil {
|
||||
cartMutationsTotal.Inc()
|
||||
p.RefreshExpiry(id)
|
||||
cartActiveGrains.Set(float64(p.DebugGrainCount()))
|
||||
} else if applyErr != nil {
|
||||
cartMutationFailuresTotal.Inc()
|
||||
}
|
||||
|
||||
return result, applyErr
|
||||
}
|
||||
|
||||
// Get returns the current state of a grain.
|
||||
func (p *CartPool) Get(id CartId) (*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 CartId) (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 *RemoteHostGRPC) {
|
||||
_, err := rh.ControlClient.Closing(context.Background(), &messages.ClosingNotice{Host: p.hostname})
|
||||
if err != nil {
|
||||
log.Printf("Close notify to %s failed: %v", rh.Host, err)
|
||||
}
|
||||
}(r)
|
||||
}
|
||||
if p.purgeTicker != nil {
|
||||
p.purgeTicker.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
121
grpc_server.go
Normal file
121
grpc_server.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/reflection"
|
||||
)
|
||||
|
||||
// cartActorGRPCServer implements the ControlPlane gRPC services.
|
||||
// It delegates cart operations to a grain pool and cluster operations to a synced pool.
|
||||
type cartActorGRPCServer struct {
|
||||
messages.UnimplementedControlPlaneServer
|
||||
|
||||
pool *CartPool
|
||||
}
|
||||
|
||||
// NewCartActorGRPCServer creates and initializes the server.
|
||||
func NewCartActorGRPCServer(pool *CartPool) *cartActorGRPCServer {
|
||||
return &cartActorGRPCServer{
|
||||
pool: pool,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *cartActorGRPCServer) AnnounceOwnership(ctx context.Context, req *messages.OwnershipAnnounce) (*messages.OwnerChangeAck, error) {
|
||||
for _, cartId := range req.CartIds {
|
||||
s.pool.removeLocalGrain(CartId(cartId))
|
||||
}
|
||||
log.Printf("Ack count: %d", len(req.CartIds))
|
||||
return &messages.OwnerChangeAck{
|
||||
Accepted: true,
|
||||
Message: "ownership announced",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *cartActorGRPCServer) AnnounceExpiry(ctx context.Context, req *messages.ExpiryAnnounce) (*messages.OwnerChangeAck, error) {
|
||||
s.pool.HandleRemoteExpiry(req.GetHost(), req.GetCartIds())
|
||||
return &messages.OwnerChangeAck{
|
||||
Accepted: true,
|
||||
Message: "expiry acknowledged",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ControlPlane: Ping
|
||||
func (s *cartActorGRPCServer) Ping(ctx context.Context, _ *messages.Empty) (*messages.PingReply, error) {
|
||||
// Expose cart owner cookie (first-touch owner = this host) for HTTP gateways translating gRPC metadata.
|
||||
// Gateways that propagate Set-Cookie can help establish sticky sessions at the edge.
|
||||
//_ = grpc.SendHeader(ctx, metadata.Pairs("set-cookie", fmt.Sprintf("cartowner=%s; Path=/; HttpOnly", s.syncedPool.Hostname())))
|
||||
return &messages.PingReply{
|
||||
Host: s.pool.Hostname(),
|
||||
UnixTime: time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ControlPlane: Negotiate (merge host views)
|
||||
func (s *cartActorGRPCServer) Negotiate(ctx context.Context, req *messages.NegotiateRequest) (*messages.NegotiateReply, error) {
|
||||
hostSet := make(map[string]struct{})
|
||||
// Caller view
|
||||
for _, h := range req.GetKnownHosts() {
|
||||
if h != "" {
|
||||
hostSet[h] = struct{}{}
|
||||
}
|
||||
}
|
||||
// This host
|
||||
hostSet[s.pool.Hostname()] = struct{}{}
|
||||
// Known remotes
|
||||
for _, h := range s.pool.RemoteHostNames() {
|
||||
hostSet[h] = struct{}{}
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(hostSet))
|
||||
for h := range hostSet {
|
||||
out = append(out, h)
|
||||
}
|
||||
return &messages.NegotiateReply{Hosts: out}, nil
|
||||
}
|
||||
|
||||
// ControlPlane: GetCartIds (locally owned carts only)
|
||||
func (s *cartActorGRPCServer) GetCartIds(ctx context.Context, _ *messages.Empty) (*messages.CartIdsReply, error) {
|
||||
return &messages.CartIdsReply{CartIds: s.pool.LocalCartIDs()}, nil
|
||||
}
|
||||
|
||||
// ControlPlane: Closing (peer shutdown notification)
|
||||
func (s *cartActorGRPCServer) 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 StartGRPCServer(addr string, pool *CartPool) (*grpc.Server, error) {
|
||||
lis, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to listen: %w", err)
|
||||
}
|
||||
|
||||
grpcServer := grpc.NewServer()
|
||||
server := NewCartActorGRPCServer(pool)
|
||||
|
||||
messages.RegisterControlPlaneServer(grpcServer, server)
|
||||
reflection.Register(grpcServer)
|
||||
|
||||
log.Printf("gRPC server listening on %s", addr)
|
||||
go func() {
|
||||
if err := grpcServer.Serve(lis); err != nil {
|
||||
log.Fatalf("failed to serve gRPC: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return grpcServer, nil
|
||||
}
|
||||
19
id_test.go
19
id_test.go
@@ -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
174
k6/README.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# k6 Load Tests for Cart API
|
||||
|
||||
This directory contains a k6 script (`cart_load_test.js`) to stress and observe the cart actor HTTP API.
|
||||
|
||||
## Contents
|
||||
|
||||
- `cart_load_test.js` – primary k6 scenario script
|
||||
- `README.md` – this file
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node not required (k6 runs standalone)
|
||||
- k6 installed (>= v0.43 recommended)
|
||||
- Prometheus + Grafana (optional) if you want to correlate with the dashboard you generated
|
||||
- A running cart service exposing HTTP endpoints at (default) `http://localhost:8080/cart`
|
||||
|
||||
## Endpoints Exercised
|
||||
|
||||
The script exercises (per iteration):
|
||||
|
||||
1. `GET /cart/` – ensure / fetch cart state (creates cart if missing; sets `cartid` & `cartowner` cookies)
|
||||
2. `POST /cart/` – add item mutation (random SKU & quantity)
|
||||
3. `GET /cart/` – fetch after mutations
|
||||
4. `GET /cart/checkout` – occasionally (~2% of iterations) to simulate checkout start
|
||||
|
||||
You can extend it easily to hit deliveries, quantity changes, or removal endpoints.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|-----------------|----------------------------------------------|-------------------------|
|
||||
| `BASE_URL` | Base URL root (either host or host/cart) | `http://localhost:8080/cart` |
|
||||
| `VUS` | VUs for steady_mutations scenario | `20` |
|
||||
| `DURATION` | Duration for steady_mutations scenario | `5m` |
|
||||
| `RAMP_TARGET` | Peak VUs for ramp_up scenario | `50` |
|
||||
|
||||
You can also disable one scenario by editing `options.scenarios` inside the script.
|
||||
|
||||
Example run:
|
||||
|
||||
```bash
|
||||
k6 run \
|
||||
-e BASE_URL=https://cart.prod.example.com/cart \
|
||||
-e VUS=40 \
|
||||
-e DURATION=10m \
|
||||
-e RAMP_TARGET=120 \
|
||||
k6/cart_load_test.js
|
||||
```
|
||||
|
||||
## Metrics (Custom)
|
||||
|
||||
The script defines additional k6 metrics:
|
||||
|
||||
- `cart_add_item_duration` (Trend) – latency of POST add item
|
||||
- `cart_fetch_duration` (Trend) – latency of GET cart state
|
||||
- `cart_checkout_duration` (Trend) – latency of checkout
|
||||
- `cart_items_added` (Counter) – successful add item operations
|
||||
- `cart_checkout_calls` (Counter) – successful checkout calls
|
||||
|
||||
Thresholds (in `options.thresholds`) enforce basic SLO:
|
||||
- Mutation failure rate < 2%
|
||||
- p90 mutation latency < 800 ms
|
||||
- p99 overall HTTP latency < 1500 ms
|
||||
|
||||
Adjust thresholds to your environment if they trigger prematurely.
|
||||
|
||||
## Cookies & Stickiness
|
||||
|
||||
The script preserves:
|
||||
- `cartid` – cart identity (server sets expiry separately)
|
||||
- `cartowner` – owning host for sticky routing
|
||||
|
||||
If your load balancer or ingress enforces affinity based on these cookies, traffic will naturally concentrate on the originally claimed host for each cart under test.
|
||||
|
||||
## SKU Set
|
||||
|
||||
SKUs used (randomly selected each mutation):
|
||||
|
||||
```
|
||||
778290 778345 778317 778277 778267 778376 778244 778384
|
||||
778365 778377 778255 778286 778246 778270 778266 778285
|
||||
778329 778425 778407 778418 778430 778469 778358 778351
|
||||
778319 778307 778278 778251 778253 778261 778263 778273
|
||||
778281 778294 778297 778302
|
||||
```
|
||||
|
||||
To add/remove SKUs, edit the `SKUS` array. Keeping it non-empty and moderately sized helps randomization.
|
||||
|
||||
## Extending the Script
|
||||
|
||||
### Add Quantity Change
|
||||
|
||||
```js
|
||||
function changeQuantity(itemId, newQty) {
|
||||
const payload = JSON.stringify({ Id: itemId, Qty: newQty });
|
||||
http.put(baseUrl() + '/', payload, { headers: headers() });
|
||||
}
|
||||
```
|
||||
|
||||
### Remove Item
|
||||
|
||||
```js
|
||||
function removeItem(itemId) {
|
||||
http.del(baseUrl() + '/' + itemId, null, { headers: headers() });
|
||||
}
|
||||
```
|
||||
|
||||
### Add Delivery
|
||||
|
||||
```js
|
||||
function addDelivery(itemIds) {
|
||||
const payload = JSON.stringify({ provider: "POSTNORD", items: itemIds });
|
||||
http.post(baseUrl() + '/delivery', payload, { headers: headers() });
|
||||
}
|
||||
```
|
||||
|
||||
You can integrate these into the iteration loop with probabilities.
|
||||
|
||||
## Output Summary
|
||||
|
||||
`handleSummary` outputs a JSON summary to stdout:
|
||||
- Average & p95 mutation latencies (if present)
|
||||
- Fetch p95
|
||||
- Checkout count
|
||||
- Check statuses
|
||||
|
||||
Redirect or parse that output for CI pipelines.
|
||||
|
||||
## Running in CI
|
||||
|
||||
Use shorter durations (e.g. `DURATION=2m VUS=10`) to keep builds fast. Fail build on threshold breaches:
|
||||
|
||||
```bash
|
||||
k6 run -e BASE_URL=$TARGET -e VUS=10 -e DURATION=2m k6/cart_load_test.js || exit 1
|
||||
```
|
||||
|
||||
## Correlating with Prometheus / Grafana
|
||||
|
||||
During load, observe:
|
||||
- `cart_mutations_total` growth and latency histograms
|
||||
- Event log write rate (`cart_event_log_appends_total`)
|
||||
- Pool usage (`cart_grain_pool_usage`) and spawn rate (`cart_grain_spawned_total`)
|
||||
- Failure counters (`cart_mutation_failures_total`) ensure they remain low
|
||||
|
||||
If mutation latency spikes without high error rate, inspect external dependencies (e.g., product fetcher or Klarna endpoints).
|
||||
|
||||
## Common Tuning Tips
|
||||
|
||||
| Symptom | Potential Adjustment |
|
||||
|------------------------------------|---------------------------------------------------|
|
||||
| High latency p99 | Increase CPU/memory, optimize mutation handlers |
|
||||
| Pool at capacity | Raise pool size argument or TTL |
|
||||
| Frequent cart eviction mid-test | Confirm TTL is sliding (now 2h on mutation) |
|
||||
| High replay duration | Consider snapshot + truncate event logs |
|
||||
| Uneven host load | Verify `cartowner` cookie is respected upstream |
|
||||
|
||||
## Safety / Load Guardrails
|
||||
|
||||
- Start with low VUs (5–10) and short duration.
|
||||
- Scale incrementally to find saturation points.
|
||||
- If using production endpoints, coordinate off-peak runs.
|
||||
|
||||
## License / Attribution
|
||||
|
||||
This test script is tailored for your internal cart actor system; adapt freely. k6 is open-source (AGPL v3). Ensure compliance if redistributing.
|
||||
|
||||
---
|
||||
|
||||
Feel free to request:
|
||||
- A variant script for spike tests
|
||||
- WebSocket / long poll integration (if added later)
|
||||
- Synthetic error injection harness
|
||||
|
||||
Happy load testing!
|
||||
248
k6/cart_load_test.js
Normal file
248
k6/cart_load_test.js
Normal file
@@ -0,0 +1,248 @@
|
||||
import http from "k6/http";
|
||||
import { check, sleep, group } from "k6";
|
||||
import { Counter, Trend } from "k6/metrics";
|
||||
|
||||
// ---------------- Configuration ----------------
|
||||
export const options = {
|
||||
// Adjust vus/duration for your environment
|
||||
scenarios: {
|
||||
steady_mutations: {
|
||||
executor: "constant-vus",
|
||||
vus: __ENV.VUS ? parseInt(__ENV.VUS, 10) : 20,
|
||||
duration: __ENV.DURATION || "5m",
|
||||
gracefulStop: "30s",
|
||||
},
|
||||
ramp_up: {
|
||||
executor: "ramping-vus",
|
||||
startVUs: 0,
|
||||
stages: [
|
||||
{
|
||||
duration: "1m",
|
||||
target: __ENV.RAMP_TARGET
|
||||
? parseInt(__ENV.RAMP_TARGET, 10)
|
||||
: 50,
|
||||
},
|
||||
{
|
||||
duration: "1m",
|
||||
target: __ENV.RAMP_TARGET
|
||||
? parseInt(__ENV.RAMP_TARGET, 10)
|
||||
: 50,
|
||||
},
|
||||
{ duration: "1m", target: 0 },
|
||||
],
|
||||
gracefulStop: "30s",
|
||||
startTime: "30s",
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
http_req_failed: ["rate<0.02"], // < 2% failures
|
||||
http_req_duration: ["p(90)<800", "p(99)<1500"], // latency SLO
|
||||
"cart_add_item_duration{op:add}": ["p(90)<800"],
|
||||
"cart_fetch_duration{op:get}": ["p(90)<600"],
|
||||
},
|
||||
summaryTrendStats: ["avg", "min", "med", "max", "p(90)", "p(95)", "p(99)"],
|
||||
};
|
||||
|
||||
// ---------------- Metrics ----------------
|
||||
const addItemTrend = new Trend("cart_add_item_duration", true);
|
||||
const fetchTrend = new Trend("cart_fetch_duration", true);
|
||||
const checkoutTrend = new Trend("cart_checkout_duration", true);
|
||||
const addedItemsCounter = new Counter("cart_items_added");
|
||||
const checkoutCounter = new Counter("cart_checkout_calls");
|
||||
|
||||
// ---------------- SKUs ----------------
|
||||
const SKUS = [
|
||||
"778290",
|
||||
"778345",
|
||||
"778317",
|
||||
"778277",
|
||||
"778267",
|
||||
"778376",
|
||||
"778244",
|
||||
"778384",
|
||||
"778365",
|
||||
"778377",
|
||||
"778255",
|
||||
"778286",
|
||||
"778246",
|
||||
"778270",
|
||||
"778266",
|
||||
"778285",
|
||||
"778329",
|
||||
"778425",
|
||||
"778407",
|
||||
"778418",
|
||||
"778430",
|
||||
"778469",
|
||||
"778358",
|
||||
"778351",
|
||||
"778319",
|
||||
"778307",
|
||||
"778278",
|
||||
"778251",
|
||||
"778253",
|
||||
"778261",
|
||||
"778263",
|
||||
"778273",
|
||||
"778281",
|
||||
"778294",
|
||||
"778297",
|
||||
"778302",
|
||||
];
|
||||
|
||||
// ---------------- Helpers ----------------
|
||||
function randomSku() {
|
||||
return SKUS[Math.floor(Math.random() * SKUS.length)];
|
||||
}
|
||||
function randomQty() {
|
||||
return 1 + Math.floor(Math.random() * 3); // 1..3
|
||||
}
|
||||
function baseUrl() {
|
||||
const u = __ENV.BASE_URL || "http://localhost:8080/cart";
|
||||
// Allow user to pass either root host or full /cart path
|
||||
return u.endsWith("/cart") ? u : u.replace(/\/+$/, "") + "/cart";
|
||||
}
|
||||
function extractCookie(res, name) {
|
||||
const cookies = res.cookies[name];
|
||||
if (!cookies || cookies.length === 0) return null;
|
||||
return cookies[0].value;
|
||||
}
|
||||
function withCookies(headers, cookieJar) {
|
||||
if (!cookieJar || Object.keys(cookieJar).length === 0) return headers;
|
||||
const cookieStr = Object.entries(cookieJar)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("; ");
|
||||
return { ...headers, Cookie: cookieStr };
|
||||
}
|
||||
|
||||
// Maintain cart + owner cookies per VU
|
||||
let cartState = {
|
||||
cartid: null,
|
||||
cartowner: null,
|
||||
};
|
||||
|
||||
// Refresh cookies from response
|
||||
function updateCookies(res) {
|
||||
const cid = extractCookie(res, "cartid");
|
||||
if (cid) cartState.cartid = cid;
|
||||
const owner = extractCookie(res, "cartowner");
|
||||
if (owner) cartState.cartowner = owner;
|
||||
}
|
||||
|
||||
// Build headers
|
||||
function headers() {
|
||||
const h = { "Content-Type": "application/json" };
|
||||
const jar = {};
|
||||
if (cartState.cartid) jar["cartid"] = cartState.cartid;
|
||||
if (cartState.cartowner) jar["cartowner"] = cartState.cartowner;
|
||||
return withCookies(h, jar);
|
||||
}
|
||||
|
||||
// Ensure cart exists (GET /)
|
||||
function ensureCart() {
|
||||
if (cartState.cartid) return;
|
||||
const res = http.get(baseUrl() + "/", { headers: headers() });
|
||||
updateCookies(res);
|
||||
check(res, {
|
||||
"ensure cart status 200": (r) => r.status === 200,
|
||||
"ensure cart has id": () => !!cartState.cartid,
|
||||
});
|
||||
}
|
||||
|
||||
// Add random item
|
||||
function addRandomItem() {
|
||||
const payload = JSON.stringify({
|
||||
sku: randomSku(),
|
||||
quantity: randomQty(),
|
||||
country: "no",
|
||||
});
|
||||
const start = Date.now();
|
||||
const res = http.post(baseUrl(), payload, { headers: headers() });
|
||||
const dur = Date.now() - start;
|
||||
addItemTrend.add(dur, { op: "add" });
|
||||
if (res.status === 200) {
|
||||
addedItemsCounter.add(1);
|
||||
}
|
||||
updateCookies(res);
|
||||
check(res, {
|
||||
"add item status ok": (r) => r.status === 200,
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch cart state
|
||||
function fetchCart() {
|
||||
const start = Date.now();
|
||||
const res = http.get(baseUrl() + "/", { headers: headers() });
|
||||
const dur = Date.now() - start;
|
||||
fetchTrend.add(dur, { op: "get" });
|
||||
updateCookies(res);
|
||||
check(res, { "fetch status ok": (r) => r.status === 200 });
|
||||
}
|
||||
|
||||
// Occasional checkout trigger
|
||||
function maybeCheckout() {
|
||||
if (!cartState.cartid) return;
|
||||
// // Small probability
|
||||
// if (Math.random() < 0.02) {
|
||||
// const start = Date.now();
|
||||
// const res = http.get(baseUrl() + "/checkout", { headers: headers() });
|
||||
// const dur = Date.now() - start;
|
||||
// checkoutTrend.add(dur, { op: "checkout" });
|
||||
// updateCookies(res);
|
||||
// if (res.status === 200) checkoutCounter.add(1);
|
||||
// check(res, { "checkout status ok": (r) => r.status === 200 });
|
||||
// }
|
||||
}
|
||||
|
||||
// ---------------- k6 lifecycle ----------------
|
||||
export function setup() {
|
||||
// Provide SKU list length for summary
|
||||
return { skuCount: SKUS.length };
|
||||
}
|
||||
|
||||
export default function (data) {
|
||||
group("cart flow", () => {
|
||||
// Create or reuse cart
|
||||
ensureCart();
|
||||
|
||||
// Random number of item mutations per iteration (1..5)
|
||||
const ops = 1 + Math.floor(Math.random() * 5);
|
||||
for (let i = 0; i < ops; i++) {
|
||||
addRandomItem();
|
||||
}
|
||||
|
||||
// Fetch state
|
||||
fetchCart();
|
||||
|
||||
// Optional checkout attempt
|
||||
maybeCheckout();
|
||||
});
|
||||
|
||||
// Small think time
|
||||
sleep(Math.random() * 0.5);
|
||||
}
|
||||
|
||||
export function teardown(data) {
|
||||
// Optionally we could GET confirmation or clear cart cookie
|
||||
// Not implemented for load purpose.
|
||||
console.log(`Test complete. SKU count: ${data.skuCount}`);
|
||||
}
|
||||
|
||||
// ---------------- Summary ----------------
|
||||
export function handleSummary(data) {
|
||||
return {
|
||||
stdout: JSON.stringify(
|
||||
{
|
||||
metrics: {
|
||||
mutations_avg: data.metrics.cart_add_item_duration?.avg,
|
||||
mutations_p95: data.metrics.cart_add_item_duration?.p(95),
|
||||
fetch_p95: data.metrics.cart_fetch_duration?.p(95),
|
||||
checkout_count: data.metrics.cart_checkout_calls?.count,
|
||||
},
|
||||
checks: data.root_checks,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
};
|
||||
}
|
||||
159
main.go
159
main.go
@@ -43,10 +43,15 @@ func spawn(id CartId) (*CartGrain, error) {
|
||||
Deliveries: []*CartDelivery{},
|
||||
Id: id,
|
||||
Items: []*CartItem{},
|
||||
storageMessages: []Message{},
|
||||
TotalPrice: 0,
|
||||
}
|
||||
err := loadMessages(ret, id)
|
||||
// Set baseline lastChange at spawn; replay may update it to last event timestamp.
|
||||
ret.lastChange = time.Now().Unix()
|
||||
|
||||
// Legacy loadMessages (no-op) retained; then replay append-only event log
|
||||
//_ = loadMessages(ret, id)
|
||||
err := ReplayCartEvents(ret, id)
|
||||
|
||||
return ret, err
|
||||
}
|
||||
|
||||
@@ -55,65 +60,31 @@ func init() {
|
||||
}
|
||||
|
||||
type App struct {
|
||||
pool *GrainLocalPool
|
||||
pool *CartPool
|
||||
storage *DiskStorage
|
||||
}
|
||||
|
||||
func (a *App) Save() error {
|
||||
hasChanges := false
|
||||
a.pool.mu.RLock()
|
||||
defer a.pool.mu.RUnlock()
|
||||
for id, grain := range a.pool.GetGrains() {
|
||||
for id, grain := range a.pool.SnapshotGrains() {
|
||||
if grain == nil {
|
||||
continue
|
||||
}
|
||||
if grain.GetLastChange() > a.storage.LastSaves[id] {
|
||||
hasChanges = true
|
||||
if grain.GetLastChange() > a.storage.LastSaves[uint64(id)] {
|
||||
|
||||
err := a.storage.Store(id, grain)
|
||||
if err != nil {
|
||||
log.Printf("Error saving grain %s: %v\n", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasChanges {
|
||||
return nil
|
||||
}
|
||||
return a.storage.saveState()
|
||||
}
|
||||
|
||||
func (a *App) HandleSave(w http.ResponseWriter, r *http.Request) {
|
||||
err := a.Save()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
} else {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
}
|
||||
|
||||
var podIp = os.Getenv("POD_IP")
|
||||
var name = os.Getenv("POD_NAME")
|
||||
var amqpUrl = os.Getenv("AMQP_URL")
|
||||
var KlarnaInstance = NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
|
||||
|
||||
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>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -135,47 +106,45 @@ func getCountryFromHost(host string) string {
|
||||
return "se"
|
||||
}
|
||||
|
||||
func getCheckoutOrder(host string, cartId CartId) *messages.CreateCheckoutOrder {
|
||||
baseUrl := fmt.Sprintf("https://%s", host)
|
||||
cartBaseUrl := os.Getenv("CART_BASE_URL")
|
||||
if cartBaseUrl == "" {
|
||||
cartBaseUrl = "https://cart.tornberg.me"
|
||||
func GetDiscovery() Discovery {
|
||||
if podIp == "" {
|
||||
return nil
|
||||
}
|
||||
country := getCountryFromHost(host)
|
||||
|
||||
return &messages.CreateCheckoutOrder{
|
||||
Terms: fmt.Sprintf("%s/terms", baseUrl),
|
||||
Checkout: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}", baseUrl),
|
||||
Confirmation: fmt.Sprintf("%s/confirmation/{checkout.order.id}", baseUrl),
|
||||
Validation: fmt.Sprintf("%s/validation", cartBaseUrl),
|
||||
Push: fmt.Sprintf("%s/push?order_id={checkout.order.id}", cartBaseUrl),
|
||||
Country: country,
|
||||
config, kerr := rest.InClusterConfig()
|
||||
|
||||
if kerr != nil {
|
||||
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
|
||||
}
|
||||
client, err := kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating client: %v\n", err)
|
||||
}
|
||||
return NewK8sDiscovery(client)
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
storage, err := NewDiskStorage(fmt.Sprintf("data/%s_state.gob", name))
|
||||
storage, err := NewDiskStorage(fmt.Sprintf("data/s_%s.gob", name))
|
||||
if err != nil {
|
||||
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{
|
||||
pool: NewGrainLocalPool(65535, 5*time.Minute, spawn),
|
||||
pool: pool,
|
||||
storage: storage,
|
||||
}
|
||||
|
||||
syncedPool, err := NewSyncedPool(app.pool, podIp, GetDiscovery())
|
||||
grpcSrv, err := StartGRPCServer(":1337", pool)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating synced pool: %v\n", err)
|
||||
}
|
||||
|
||||
hg, err := NewGrainHandler(app.pool, ":1337")
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating handler: %v\n", err)
|
||||
log.Fatalf("Error starting gRPC server: %v\n", err)
|
||||
}
|
||||
defer grpcSrv.GracefulStop()
|
||||
|
||||
go func() {
|
||||
for range time.Tick(time.Minute * 10) {
|
||||
for range time.Tick(time.Minute * 5) {
|
||||
err := app.Save()
|
||||
if err != nil {
|
||||
log.Printf("Error saving: %v\n", err)
|
||||
@@ -186,7 +155,7 @@ func main() {
|
||||
Url: amqpUrl,
|
||||
}
|
||||
|
||||
syncedServer := NewPoolServer(syncedPool, fmt.Sprintf("%s, %s", name, podIp))
|
||||
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp))
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/cart/", http.StripPrefix("/cart", syncedServer.Serve()))
|
||||
// only for local
|
||||
@@ -202,17 +171,18 @@ func main() {
|
||||
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||
mux.Handle("/metrics", promhttp.Handler())
|
||||
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.Write([]byte("handler not healthy"))
|
||||
w.Write([]byte("grain pool at capacity"))
|
||||
return
|
||||
}
|
||||
if !syncedPool.IsHealthy() {
|
||||
if !pool.IsHealthy() {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("pool not healthy"))
|
||||
w.Write([]byte("control plane not healthy"))
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
@@ -241,29 +211,27 @@ func main() {
|
||||
w.Write([]byte("no cart id to checkout is empty"))
|
||||
return
|
||||
}
|
||||
cartId := ToCartId(cookie.Value)
|
||||
reply, err := syncedServer.pool.Process(cartId, Message{
|
||||
Type: CreateCheckoutOrderType,
|
||||
Content: getCheckoutOrder(r.Host, cartId),
|
||||
})
|
||||
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()))
|
||||
parsed, ok := ParseCartId(cookie.Value)
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("invalid cart id format"))
|
||||
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 {
|
||||
prevOrder, err := KlarnaInstance.GetOrder(orderId)
|
||||
order, err = KlarnaInstance.GetOrder(orderId)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
order = prevOrder
|
||||
|
||||
}
|
||||
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\")")
|
||||
@@ -373,25 +341,32 @@ func main() {
|
||||
go func() {
|
||||
sig := <-sigs
|
||||
fmt.Println("Shutting down due to signal:", sig)
|
||||
go syncedPool.Close()
|
||||
app.Save()
|
||||
pool.Close()
|
||||
|
||||
done <- true
|
||||
}()
|
||||
|
||||
log.Print("Server started at port 8080")
|
||||
go http.ListenAndServe(":8080", mux)
|
||||
<-done
|
||||
|
||||
}
|
||||
|
||||
func triggerOrderCompleted(err error, syncedServer *PoolServer, order *CheckoutOrder) error {
|
||||
_, err = syncedServer.pool.Process(ToCartId(order.MerchantReference1), Message{
|
||||
Type: OrderCompletedType,
|
||||
Content: &messages.OrderCreated{
|
||||
mutation := &messages.OrderCreated{
|
||||
OrderId: order.ID,
|
||||
Status: order.Status,
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
cid, ok := ParseCartId(order.MerchantReference1)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
|
||||
}
|
||||
_, applyErr := syncedServer.pool.Apply(cid, mutation)
|
||||
if applyErr == nil {
|
||||
_ = AppendCartEvent(cid, mutation)
|
||||
}
|
||||
return applyErr
|
||||
}
|
||||
|
||||
func confirmOrder(order *CheckoutOrder, orderHandler *AmqpOrderHandler) error {
|
||||
|
||||
286
main_test.go
286
main_test.go
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
94
message.go
94
message.go
@@ -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
82
mutation_add_item.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// mutation_add_item.go
|
||||
//
|
||||
// Registers the AddItem cart mutation in the generic mutation registry.
|
||||
// This replaces the legacy switch-based logic previously found in CartGrain.Apply.
|
||||
//
|
||||
// Behavior:
|
||||
// * Validates quantity > 0
|
||||
// * If an item with same SKU exists -> increases quantity
|
||||
// * Else creates a new CartItem with computed tax amounts
|
||||
// * Totals recalculated automatically via WithTotals()
|
||||
//
|
||||
// NOTE: Any future field additions in messages.AddItem that affect pricing / tax
|
||||
// must keep this handler in sync.
|
||||
|
||||
func init() {
|
||||
RegisterMutation[messages.AddItem](
|
||||
"AddItem",
|
||||
func(g *CartGrain, m *messages.AddItem) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("AddItem: nil payload")
|
||||
}
|
||||
if m.Quantity < 1 {
|
||||
return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity)
|
||||
}
|
||||
|
||||
// Fast path: merge with existing item having same SKU
|
||||
if existing, found := g.FindItemWithSku(m.Sku); found {
|
||||
existing.Quantity += int(m.Quantity)
|
||||
return nil
|
||||
}
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
g.lastItemId++
|
||||
taxRate := 2500
|
||||
if m.Tax > 0 {
|
||||
taxRate = int(m.Tax)
|
||||
}
|
||||
taxAmountPerUnit := GetTaxAmount(m.Price, taxRate)
|
||||
|
||||
g.Items = append(g.Items, &CartItem{
|
||||
Id: g.lastItemId,
|
||||
ItemId: int(m.ItemId),
|
||||
Quantity: int(m.Quantity),
|
||||
Sku: m.Sku,
|
||||
Name: m.Name,
|
||||
Price: m.Price,
|
||||
TotalPrice: m.Price * int64(m.Quantity),
|
||||
TotalTax: int64(taxAmountPerUnit * int64(m.Quantity)),
|
||||
Image: m.Image,
|
||||
Stock: StockStatus(m.Stock),
|
||||
Disclaimer: m.Disclaimer,
|
||||
Brand: m.Brand,
|
||||
Category: m.Category,
|
||||
Category2: m.Category2,
|
||||
Category3: m.Category3,
|
||||
Category4: m.Category4,
|
||||
Category5: m.Category5,
|
||||
OrgPrice: m.OrgPrice,
|
||||
ArticleType: m.ArticleType,
|
||||
Outlet: m.Outlet,
|
||||
SellerId: m.SellerId,
|
||||
SellerName: m.SellerName,
|
||||
Tax: int(taxAmountPerUnit),
|
||||
TaxRate: taxRate,
|
||||
StoreId: m.StoreId,
|
||||
})
|
||||
|
||||
return nil
|
||||
},
|
||||
WithTotals(), // Recalculate totals after successful mutation
|
||||
)
|
||||
}
|
||||
61
mutation_add_request.go
Normal file
61
mutation_add_request.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// mutation_add_request.go
|
||||
//
|
||||
// Registers the AddRequest mutation. This mutation is a higher-level intent
|
||||
// (add by SKU + quantity) which may translate into either:
|
||||
// - Increasing quantity of an existing line (same SKU), OR
|
||||
// - Creating a new item by performing a product lookup (via getItemData inside CartGrain.AddItem)
|
||||
//
|
||||
// Behavior:
|
||||
// - Validates non-empty SKU and quantity > 0
|
||||
// - If an item with the SKU already exists: increments its quantity
|
||||
// - Else delegates to CartGrain.AddItem (which itself produces an AddItem mutation)
|
||||
// - Totals recalculated automatically (WithTotals)
|
||||
//
|
||||
// NOTE:
|
||||
// - This handler purposely avoids duplicating the detailed AddItem logic;
|
||||
// it reuses CartGrain.AddItem which then flows through the AddItem mutation
|
||||
// registry handler.
|
||||
// - Double total recalculation can occur (AddItem has WithTotals too), but
|
||||
// is acceptable for clarity. Optimize later if needed.
|
||||
//
|
||||
// Potential future improvements:
|
||||
// - Stock validation before increasing quantity
|
||||
// - Reservation logic or concurrency guards around stock updates
|
||||
// - Coupon / pricing rules applied conditionally during add-by-sku
|
||||
func init() {
|
||||
RegisterMutation[messages.AddRequest](
|
||||
"AddRequest",
|
||||
func(g *CartGrain, m *messages.AddRequest) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("AddRequest: nil payload")
|
||||
}
|
||||
if m.Sku == "" {
|
||||
return fmt.Errorf("AddRequest: sku is empty")
|
||||
}
|
||||
if m.Quantity < 1 {
|
||||
return fmt.Errorf("AddRequest: invalid quantity %d", m.Quantity)
|
||||
}
|
||||
|
||||
// Existing line: accumulate quantity only.
|
||||
if existing, found := g.FindItemWithSku(m.Sku); found {
|
||||
existing.Quantity += int(m.Quantity)
|
||||
return nil
|
||||
}
|
||||
|
||||
// New line: delegate to higher-level AddItem flow (product lookup).
|
||||
// We intentionally ignore the returned *CartGrain; registry will
|
||||
// do totals again after this handler returns (harmless).
|
||||
_, err := g.AddItem(m.Sku, int(m.Quantity), m.Country, m.StoreId)
|
||||
return err
|
||||
},
|
||||
WithTotals(),
|
||||
)
|
||||
}
|
||||
58
mutation_change_quantity.go
Normal file
58
mutation_change_quantity.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// 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(),
|
||||
)
|
||||
}
|
||||
49
mutation_initialize_checkout.go
Normal file
49
mutation_initialize_checkout.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// 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
53
mutation_order_created.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// 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
301
mutation_registry.go
Normal 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] = ®isteredMutation{
|
||||
name: name,
|
||||
handler: wrapped,
|
||||
updateTotals: opts.updateTotals,
|
||||
msgType: rt,
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyRegistered attempts to apply a registered mutation.
|
||||
// Returns updated grain if successful.
|
||||
//
|
||||
// If the mutation is not registered, returns (nil, ErrMutationNotRegistered).
|
||||
func ApplyRegistered(grain *CartGrain, msg interface{}) (*CartGrain, error) {
|
||||
if grain == nil {
|
||||
return nil, fmt.Errorf("nil grain")
|
||||
}
|
||||
if msg == nil {
|
||||
return nil, fmt.Errorf("nil mutation message")
|
||||
}
|
||||
|
||||
rt := indirectType(reflect.TypeOf(msg))
|
||||
mutationRegistryMu.RLock()
|
||||
entry, ok := mutationRegistry[rt]
|
||||
mutationRegistryMu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return nil, ErrMutationNotRegistered
|
||||
}
|
||||
|
||||
if err := entry.handler(grain, msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if entry.updateTotals {
|
||||
grain.UpdateTotals()
|
||||
}
|
||||
|
||||
return grain, nil
|
||||
}
|
||||
|
||||
// RegisteredMutations returns metadata for all registered mutations (snapshot).
|
||||
func RegisteredMutations() []string {
|
||||
mutationRegistryMu.RLock()
|
||||
defer mutationRegistryMu.RUnlock()
|
||||
out := make([]string, 0, len(mutationRegistry))
|
||||
for _, entry := range mutationRegistry {
|
||||
out = append(out, entry.name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RegisteredMutationTypes returns the reflect.Type list of all registered messages.
|
||||
// Useful for coverage tests ensuring expected set matches actual set.
|
||||
func RegisteredMutationTypes() []reflect.Type {
|
||||
mutationRegistryMu.RLock()
|
||||
defer mutationRegistryMu.RUnlock()
|
||||
out := make([]reflect.Type, 0, len(mutationRegistry))
|
||||
for t := range mutationRegistry {
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MustAssertMutationCoverage can be called at startup to ensure every expected
|
||||
// mutation type has been registered. It panics with a descriptive message if any
|
||||
// are missing. Provide a slice of prototype pointers (e.g. []*messages.AddItem{nil} ...)
|
||||
func MustAssertMutationCoverage(expected []interface{}) {
|
||||
mutationRegistryMu.RLock()
|
||||
defer mutationRegistryMu.RUnlock()
|
||||
|
||||
missing := make([]string, 0)
|
||||
for _, ex := range expected {
|
||||
if ex == nil {
|
||||
continue
|
||||
}
|
||||
t := indirectType(reflect.TypeOf(ex))
|
||||
if _, ok := mutationRegistry[t]; !ok {
|
||||
missing = append(missing, t.String())
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
panic(fmt.Sprintf("mutation registry missing handlers for: %v", missing))
|
||||
}
|
||||
}
|
||||
|
||||
// indirectType returns the element type if given a pointer; otherwise the type itself.
|
||||
func indirectType(t reflect.Type) reflect.Type {
|
||||
for t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
/*
|
||||
Integration Guide
|
||||
-----------------
|
||||
|
||||
1. Register all existing mutations:
|
||||
|
||||
func init() {
|
||||
RegisterMutation[*messages.AddItem]("AddItem",
|
||||
func(g *CartGrain, m *messages.AddItem) error {
|
||||
// (port logic from existing switch branch)
|
||||
// ...
|
||||
return nil
|
||||
},
|
||||
WithTotals(),
|
||||
)
|
||||
// ... repeat for others
|
||||
}
|
||||
|
||||
2. In CartGrain.Apply (early in the method) add:
|
||||
|
||||
if updated, err := ApplyRegistered(c, content); err == nil {
|
||||
return updated, nil
|
||||
} else if err != ErrMutationNotRegistered {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// existing switch fallback below
|
||||
|
||||
3. Once all mutations are registered, remove the legacy switch cases
|
||||
and leave a single ErrMutationNotRegistered path for unknown types.
|
||||
|
||||
4. Add a coverage test (see docs for example; removed from source for clarity).
|
||||
5. (Optional) Add metrics / tracing wrappers for handlers.
|
||||
|
||||
*/
|
||||
53
mutation_remove_delivery.go
Normal file
53
mutation_remove_delivery.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// 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
49
mutation_remove_item.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// 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(),
|
||||
)
|
||||
}
|
||||
57
mutation_set_cart_items.go
Normal file
57
mutation_set_cart_items.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// mutation_set_cart_items.go
|
||||
//
|
||||
// Registers the SetCartRequest mutation. This mutation replaces the entire list
|
||||
// of cart items with the provided list (each entry is an AddRequest).
|
||||
//
|
||||
// Behavior:
|
||||
// - Clears existing items (but leaves deliveries intact).
|
||||
// - Iterates over each AddRequest and delegates to CartGrain.AddItem
|
||||
// (which performs product lookup, creates AddItem mutation).
|
||||
// - If any single addition fails, the mutation aborts with an error;
|
||||
// items added prior to the failure remain (consistent with previous behavior).
|
||||
// - Totals recalculated after completion via WithTotals().
|
||||
//
|
||||
// Notes:
|
||||
// - Potential optimization: batch product lookups; currently sequential.
|
||||
// - Consider adding rollback semantics if atomic replacement is desired.
|
||||
// - Deliveries might reference item IDs that are now invalid—original logic
|
||||
// also left deliveries untouched. If that becomes an issue, add a cleanup
|
||||
// pass to remove deliveries whose item IDs no longer exist.
|
||||
func init() {
|
||||
RegisterMutation[messages.SetCartRequest](
|
||||
"SetCartRequest",
|
||||
func(g *CartGrain, m *messages.SetCartRequest) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("SetCartRequest: nil payload")
|
||||
}
|
||||
|
||||
// Clear current items (keep deliveries)
|
||||
g.mu.Lock()
|
||||
g.Items = make([]*CartItem, 0, len(m.Items))
|
||||
g.mu.Unlock()
|
||||
|
||||
for _, it := range m.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
if it.Sku == "" || it.Quantity < 1 {
|
||||
return fmt.Errorf("SetCartRequest: invalid item (sku='%s' qty=%d)", it.Sku, it.Quantity)
|
||||
}
|
||||
_, err := g.AddItem(it.Sku, int(it.Quantity), it.Country, it.StoreId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SetCartRequest: add sku '%s' failed: %w", it.Sku, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
WithTotals(),
|
||||
)
|
||||
}
|
||||
101
mutation_set_delivery.go
Normal file
101
mutation_set_delivery.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// mutation_set_delivery.go
|
||||
//
|
||||
// Registers the SetDelivery mutation.
|
||||
//
|
||||
// Semantics (mirrors legacy switch logic):
|
||||
// - If the payload specifies an explicit list of item IDs (payload.Items):
|
||||
// - Each referenced cart line must exist.
|
||||
// - None of the referenced items may already belong to a delivery.
|
||||
// - Only those items are associated with the new delivery.
|
||||
// - If payload.Items is empty:
|
||||
// - All items currently without any delivery are associated with the new delivery.
|
||||
// - A new delivery line is created with:
|
||||
// - Auto-incremented delivery ID (cart-local)
|
||||
// - Provider from payload
|
||||
// - Fixed price (currently hard-coded: 4900 minor units) – adjust as needed
|
||||
// - Optional PickupPoint copied from payload
|
||||
// - Cart totals are recalculated (WithTotals)
|
||||
//
|
||||
// Error cases:
|
||||
// - Referenced item does not exist
|
||||
// - Referenced item already has a delivery
|
||||
// - No items qualify (resulting association set empty) -> returns error (prevents creating empty delivery)
|
||||
//
|
||||
// Concurrency:
|
||||
// - Uses g.mu to protect lastDeliveryId increment and append to Deliveries slice.
|
||||
// Item scans are read-only and performed outside the lock for simplicity;
|
||||
// if stricter guarantees are needed, widen the lock section.
|
||||
//
|
||||
// Future extension points:
|
||||
// - Variable delivery pricing (based on weight, distance, provider, etc.)
|
||||
// - Validation of provider codes
|
||||
// - Multi-currency delivery pricing
|
||||
func init() {
|
||||
RegisterMutation[messages.SetDelivery](
|
||||
"SetDelivery",
|
||||
func(g *CartGrain, m *messages.SetDelivery) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("SetDelivery: nil payload")
|
||||
}
|
||||
if m.Provider == "" {
|
||||
return fmt.Errorf("SetDelivery: provider is empty")
|
||||
}
|
||||
|
||||
withDelivery := g.ItemsWithDelivery()
|
||||
targetItems := make([]int, 0)
|
||||
|
||||
if len(m.Items) == 0 {
|
||||
// Use every item currently without a delivery
|
||||
targetItems = append(targetItems, g.ItemsWithoutDelivery()...)
|
||||
} else {
|
||||
// Validate explicit list
|
||||
for _, id64 := range m.Items {
|
||||
id := int(id64)
|
||||
found := false
|
||||
for _, it := range g.Items {
|
||||
if it.Id == id {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("SetDelivery: item id %d not found", id)
|
||||
}
|
||||
if slices.Contains(withDelivery, id) {
|
||||
return fmt.Errorf("SetDelivery: item id %d already has a delivery", id)
|
||||
}
|
||||
targetItems = append(targetItems, id)
|
||||
}
|
||||
}
|
||||
|
||||
if len(targetItems) == 0 {
|
||||
return fmt.Errorf("SetDelivery: no eligible items to attach")
|
||||
}
|
||||
|
||||
// Append new delivery
|
||||
g.mu.Lock()
|
||||
g.lastDeliveryId++
|
||||
newId := g.lastDeliveryId
|
||||
g.Deliveries = append(g.Deliveries, &CartDelivery{
|
||||
Id: newId,
|
||||
Provider: m.Provider,
|
||||
PickupPoint: m.PickupPoint,
|
||||
Price: 4900, // TODO: externalize pricing
|
||||
Items: targetItems,
|
||||
})
|
||||
g.mu.Unlock()
|
||||
|
||||
return nil
|
||||
},
|
||||
WithTotals(),
|
||||
)
|
||||
}
|
||||
56
mutation_set_pickup_point.go
Normal file
56
mutation_set_pickup_point.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// 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.
|
||||
)
|
||||
}
|
||||
89
packet.go
89
packet.go
@@ -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
|
||||
// }
|
||||
264
pool-server.go
264
pool-server.go
@@ -1,10 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -24,21 +24,26 @@ func NewPoolServer(pool GrainPool, pod_name string) *PoolServer {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PoolServer) process(id CartId, mutation interface{}) (*CartGrain, error) {
|
||||
grain, err := s.pool.Apply(id, mutation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return grain, nil
|
||||
}
|
||||
|
||||
func (s *PoolServer) HandleGet(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
data, err := s.pool.Get(id)
|
||||
grain, err := s.pool.Get(id)
|
||||
if err != nil {
|
||||
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 {
|
||||
sku := r.PathValue("sku")
|
||||
data, err := s.pool.Process(id, Message{
|
||||
Type: AddRequestType,
|
||||
Content: &messages.AddRequest{Sku: sku, Quantity: 1},
|
||||
})
|
||||
data, err := s.process(id, &messages.AddRequest{Sku: sku, Quantity: 1})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -56,23 +61,20 @@ func ErrorHandler(fn func(w http.ResponseWriter, r *http.Request) error) func(w
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PoolServer) WriteResult(w http.ResponseWriter, result *FrameWithPayload) error {
|
||||
func (s *PoolServer) WriteResult(w http.ResponseWriter, result *CartGrain) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("X-Pod-Name", s.pod_name)
|
||||
if result.StatusCode != 200 {
|
||||
log.Printf("Call error: %d\n", result.StatusCode)
|
||||
if result.StatusCode >= 200 && result.StatusCode < 600 {
|
||||
w.WriteHeader(int(result.StatusCode))
|
||||
} else {
|
||||
if result == nil {
|
||||
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
w.Write([]byte(result.Payload))
|
||||
|
||||
return nil
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, err := w.Write(result.Payload)
|
||||
enc := json.NewEncoder(w)
|
||||
err := enc.Encode(result)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -83,10 +85,7 @@ func (s *PoolServer) HandleDeleteItem(w http.ResponseWriter, r *http.Request, id
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := s.pool.Process(id, Message{
|
||||
Type: RemoveItemType,
|
||||
Content: &messages.RemoveItem{Id: int64(itemId)},
|
||||
})
|
||||
data, err := s.process(id, &messages.RemoveItem{Id: int64(itemId)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -106,13 +105,10 @@ func (s *PoolServer) HandleSetDelivery(w http.ResponseWriter, r *http.Request, i
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := s.pool.Process(id, Message{
|
||||
Type: SetDeliveryType,
|
||||
Content: &messages.SetDelivery{
|
||||
data, err := s.process(id, &messages.SetDelivery{
|
||||
Provider: delivery.Provider,
|
||||
Items: delivery.Items,
|
||||
PickupPoint: delivery.PickupPoint,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -132,9 +128,7 @@ func (s *PoolServer) HandleSetPickupPoint(w http.ResponseWriter, r *http.Request
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.pool.Process(id, Message{
|
||||
Type: SetPickupPointType,
|
||||
Content: &messages.SetPickupPoint{
|
||||
reply, err := s.process(id, &messages.SetPickupPoint{
|
||||
DeliveryId: int64(deliveryId),
|
||||
Id: pickupPoint.Id,
|
||||
Name: pickupPoint.Name,
|
||||
@@ -142,7 +136,6 @@ func (s *PoolServer) HandleSetPickupPoint(w http.ResponseWriter, r *http.Request
|
||||
City: pickupPoint.City,
|
||||
Zip: pickupPoint.Zip,
|
||||
Country: pickupPoint.Country,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -157,10 +150,7 @@ func (s *PoolServer) HandleRemoveDelivery(w http.ResponseWriter, r *http.Request
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.pool.Process(id, Message{
|
||||
Type: RemoveDeliveryType,
|
||||
Content: &messages.RemoveDelivery{Id: int64(deliveryId)},
|
||||
})
|
||||
reply, err := s.process(id, &messages.RemoveDelivery{Id: int64(deliveryId)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -173,10 +163,7 @@ func (s *PoolServer) HandleQuantityChange(w http.ResponseWriter, r *http.Request
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.pool.Process(id, Message{
|
||||
Type: ChangeQuantityType,
|
||||
Content: &changeQuantity,
|
||||
})
|
||||
reply, err := s.process(id, &changeQuantity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -189,10 +176,7 @@ func (s *PoolServer) HandleSetCartItems(w http.ResponseWriter, r *http.Request,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.pool.Process(id, Message{
|
||||
Type: SetCartItemsType,
|
||||
Content: &setCartItems,
|
||||
})
|
||||
reply, err := s.process(id, &setCartItems)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -205,10 +189,7 @@ func (s *PoolServer) HandleAddRequest(w http.ResponseWriter, r *http.Request, id
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.pool.Process(id, Message{
|
||||
Type: AddRequestType,
|
||||
Content: &addRequest,
|
||||
})
|
||||
reply, err := s.process(id, &addRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -234,68 +215,104 @@ func (s *PoolServer) HandleConfirmation(w http.ResponseWriter, r *http.Request,
|
||||
return json.NewEncoder(w).Encode(order)
|
||||
}
|
||||
|
||||
func (s *PoolServer) HandleCheckout(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
func (s *PoolServer) CreateOrUpdateCheckout(host string, id CartId) (*CheckoutOrder, error) {
|
||||
meta := &CheckoutMeta{
|
||||
Terms: fmt.Sprintf("https://%s/terms", host),
|
||||
Checkout: fmt.Sprintf("https://%s/checkout?order_id={checkout.order.id}", host),
|
||||
Confirmation: fmt.Sprintf("https://%s/confirmation/{checkout.order.id}", host),
|
||||
Validation: fmt.Sprintf("https://%s/validate", host),
|
||||
Push: fmt.Sprintf("https://%s/push?order_id={checkout.order.id}", host),
|
||||
Country: getCountryFromHost(host),
|
||||
}
|
||||
|
||||
reply, err := s.pool.Process(id, Message{
|
||||
Type: CreateCheckoutOrderType,
|
||||
Content: &messages.CreateCheckoutOrder{
|
||||
Terms: "https://slask-finder.tornberg.me/terms",
|
||||
Checkout: "https://slask-finder.tornberg.me/checkout?order_id={checkout.order.id}",
|
||||
Confirmation: "https://slask-finder.tornberg.me/confirmation/{checkout.order.id}",
|
||||
Push: "https://cart.tornberg.me/push?order_id={checkout.order.id}",
|
||||
},
|
||||
// Get current grain state (may be local or remote)
|
||||
grain, err := s.pool.Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build pure checkout payload
|
||||
payload, _, err := BuildCheckoutOrderPayload(grain, meta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if grain.OrderReference != "" {
|
||||
return KlarnaInstance.UpdateOrder(grain.OrderReference, bytes.NewReader(payload))
|
||||
} else {
|
||||
return KlarnaInstance.CreateOrder(bytes.NewReader(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PoolServer) ApplyCheckoutStarted(klarnaOrder *CheckoutOrder, id CartId) (*CartGrain, error) {
|
||||
// Persist initialization state via mutation (best-effort)
|
||||
return s.pool.Apply(id, &messages.InitializeCheckout{
|
||||
OrderId: klarnaOrder.ID,
|
||||
Status: klarnaOrder.Status,
|
||||
PaymentInProgress: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *PoolServer) HandleCheckout(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
klarnaOrder, err := s.CreateOrUpdateCheckout(r.Host, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if reply.StatusCode != 200 {
|
||||
return s.WriteResult(w, reply)
|
||||
|
||||
s.ApplyCheckoutStarted(klarnaOrder, id)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
return json.NewEncoder(w).Encode(klarnaOrder)
|
||||
}
|
||||
|
||||
// w.Header().Set("Content-Type", "application/json")
|
||||
// w.Header().Set("X-Pod-Name", s.pod_name)
|
||||
// w.Header().Set("Cache-Control", "no-cache")
|
||||
// w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
// w.WriteHeader(http.StatusOK)
|
||||
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
func NewCartId() CartId {
|
||||
id := time.Now().UnixNano() + rand.Int63()
|
||||
|
||||
return ToCartId(fmt.Sprintf("%d", id))
|
||||
}
|
||||
|
||||
func CookieCartIdHandler(fn func(w http.ResponseWriter, r *http.Request, cartId CartId) error) func(w http.ResponseWriter, r *http.Request) error {
|
||||
func CookieCartIdHandler(fn func(cartId CartId, w http.ResponseWriter, r *http.Request) 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()
|
||||
var id CartId
|
||||
cookie, err := r.Cookie("cartid")
|
||||
if err != nil || cookie.Value == "" {
|
||||
id = MustNewCartId()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "cartid",
|
||||
Value: cartId.String(),
|
||||
Secure: true,
|
||||
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 {
|
||||
cartId = ToCartId(cartIdCookie[0].Value)
|
||||
}
|
||||
return fn(w, r, cartId)
|
||||
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(id, w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// Removed leftover legacy block after CookieCartIdHandler (obsolete code referencing cid/legacy)
|
||||
|
||||
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{
|
||||
Name: "cartid",
|
||||
Value: cartId.String(),
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Secure: true,
|
||||
Secure: r.TLS != nil,
|
||||
HttpOnly: true,
|
||||
Expires: time.Unix(0, 0),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
@@ -304,16 +321,47 @@ func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, ca
|
||||
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) error {
|
||||
return func(w http.ResponseWriter, r *http.Request) error {
|
||||
cartId := ToCartId(r.PathValue("id"))
|
||||
raw := r.PathValue("id")
|
||||
// If no id supplied, generate a new one
|
||||
if raw == "" {
|
||||
id := MustNewCartId()
|
||||
w.Header().Set("Set-Cart-Id", id.String())
|
||||
|
||||
return fn(id, w, r)
|
||||
}
|
||||
// Parse base62 cart id
|
||||
id, ok := ParseCartId(raw)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid cart id format")
|
||||
}
|
||||
|
||||
return fn(id, w, r)
|
||||
}
|
||||
}
|
||||
|
||||
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(cartId); ok {
|
||||
handled, err := ownerHost.Proxy(cartId, w, r)
|
||||
if err != nil {
|
||||
log.Printf("proxy failed: %v, taking ownership", err)
|
||||
s.pool.TakeOwnership(cartId)
|
||||
} else if handled {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// Local ownership or no owner known, proceed with local handling
|
||||
|
||||
return fn(w, r, cartId)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PoolServer) Serve() *http.ServeMux {
|
||||
|
||||
mux := http.NewServeMux()
|
||||
//mux.HandleFunc("/", s.RewritePath)
|
||||
mux.HandleFunc("OPTIONS /", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE")
|
||||
@@ -321,29 +369,29 @@ func (s *PoolServer) Serve() *http.ServeMux {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /", ErrorHandler(CookieCartIdHandler(s.HandleGet)))
|
||||
mux.HandleFunc("GET /add/{sku}", ErrorHandler(CookieCartIdHandler(s.HandleAddSku)))
|
||||
mux.HandleFunc("POST /", ErrorHandler(CookieCartIdHandler(s.HandleAddRequest)))
|
||||
mux.HandleFunc("POST /set", ErrorHandler(CookieCartIdHandler(s.HandleSetCartItems)))
|
||||
mux.HandleFunc("DELETE /{itemId}", ErrorHandler(CookieCartIdHandler(s.HandleDeleteItem)))
|
||||
mux.HandleFunc("PUT /", ErrorHandler(CookieCartIdHandler(s.HandleQuantityChange)))
|
||||
mux.HandleFunc("DELETE /", ErrorHandler(CookieCartIdHandler(s.RemoveCartCookie)))
|
||||
mux.HandleFunc("POST /delivery", ErrorHandler(CookieCartIdHandler(s.HandleSetDelivery)))
|
||||
mux.HandleFunc("DELETE /delivery/{deliveryId}", ErrorHandler(CookieCartIdHandler(s.HandleRemoveDelivery)))
|
||||
mux.HandleFunc("PUT /delivery/{deliveryId}/pickupPoint", ErrorHandler(CookieCartIdHandler(s.HandleSetPickupPoint)))
|
||||
mux.HandleFunc("GET /checkout", ErrorHandler(CookieCartIdHandler(s.HandleCheckout)))
|
||||
mux.HandleFunc("GET /confirmation/{orderId}", ErrorHandler(CookieCartIdHandler(s.HandleConfirmation)))
|
||||
mux.HandleFunc("GET /", ErrorHandler(CookieCartIdHandler(s.ProxyHandler(s.HandleGet))))
|
||||
mux.HandleFunc("GET /add/{sku}", ErrorHandler(CookieCartIdHandler(s.ProxyHandler(s.HandleAddSku))))
|
||||
mux.HandleFunc("POST /", ErrorHandler(CookieCartIdHandler(s.ProxyHandler(s.HandleAddRequest))))
|
||||
mux.HandleFunc("POST /set", ErrorHandler(CookieCartIdHandler(s.ProxyHandler(s.HandleSetCartItems))))
|
||||
mux.HandleFunc("DELETE /{itemId}", ErrorHandler(CookieCartIdHandler(s.ProxyHandler(s.HandleDeleteItem))))
|
||||
mux.HandleFunc("PUT /", ErrorHandler(CookieCartIdHandler(s.ProxyHandler(s.HandleQuantityChange))))
|
||||
mux.HandleFunc("DELETE /", ErrorHandler(CookieCartIdHandler(s.ProxyHandler(s.RemoveCartCookie))))
|
||||
mux.HandleFunc("POST /delivery", ErrorHandler(CookieCartIdHandler(s.ProxyHandler(s.HandleSetDelivery))))
|
||||
mux.HandleFunc("DELETE /delivery/{deliveryId}", ErrorHandler(CookieCartIdHandler(s.ProxyHandler(s.HandleRemoveDelivery))))
|
||||
mux.HandleFunc("PUT /delivery/{deliveryId}/pickupPoint", ErrorHandler(CookieCartIdHandler(s.ProxyHandler(s.HandleSetPickupPoint))))
|
||||
mux.HandleFunc("GET /checkout", ErrorHandler(CookieCartIdHandler(s.ProxyHandler(s.HandleCheckout))))
|
||||
mux.HandleFunc("GET /confirmation/{orderId}", ErrorHandler(CookieCartIdHandler(s.ProxyHandler(s.HandleConfirmation))))
|
||||
|
||||
mux.HandleFunc("GET /byid/{id}", ErrorHandler(CartIdHandler(s.HandleGet)))
|
||||
mux.HandleFunc("GET /byid/{id}/add/{sku}", ErrorHandler(CartIdHandler(s.HandleAddSku)))
|
||||
mux.HandleFunc("POST /byid/{id}", ErrorHandler(CartIdHandler(s.HandleAddRequest)))
|
||||
mux.HandleFunc("DELETE /byid/{id}/{itemId}", ErrorHandler(CartIdHandler(s.HandleDeleteItem)))
|
||||
mux.HandleFunc("PUT /byid/{id}", ErrorHandler(CartIdHandler(s.HandleQuantityChange)))
|
||||
mux.HandleFunc("POST /byid/{id}/delivery", ErrorHandler(CartIdHandler(s.HandleSetDelivery)))
|
||||
mux.HandleFunc("DELETE /byid/{id}/delivery/{deliveryId}", ErrorHandler(CartIdHandler(s.HandleRemoveDelivery)))
|
||||
mux.HandleFunc("PUT /byid/{id}/delivery/{deliveryId}/pickupPoint", ErrorHandler(CartIdHandler(s.HandleSetPickupPoint)))
|
||||
mux.HandleFunc("GET /byid/{id}/checkout", ErrorHandler(CartIdHandler(s.HandleCheckout)))
|
||||
mux.HandleFunc("GET /byid/{id}/confirmation", ErrorHandler(CartIdHandler(s.HandleConfirmation)))
|
||||
mux.HandleFunc("GET /byid/{id}", ErrorHandler(CartIdHandler(s.ProxyHandler(s.HandleGet))))
|
||||
mux.HandleFunc("GET /byid/{id}/add/{sku}", ErrorHandler(CartIdHandler(s.ProxyHandler(s.HandleAddSku))))
|
||||
mux.HandleFunc("POST /byid/{id}", ErrorHandler(CartIdHandler(s.ProxyHandler(s.HandleAddRequest))))
|
||||
mux.HandleFunc("DELETE /byid/{id}/{itemId}", ErrorHandler(CartIdHandler(s.ProxyHandler(s.HandleDeleteItem))))
|
||||
mux.HandleFunc("PUT /byid/{id}", ErrorHandler(CartIdHandler(s.ProxyHandler(s.HandleQuantityChange))))
|
||||
mux.HandleFunc("POST /byid/{id}/delivery", ErrorHandler(CartIdHandler(s.ProxyHandler(s.HandleSetDelivery))))
|
||||
mux.HandleFunc("DELETE /byid/{id}/delivery/{deliveryId}", ErrorHandler(CartIdHandler(s.ProxyHandler(s.HandleRemoveDelivery))))
|
||||
mux.HandleFunc("PUT /byid/{id}/delivery/{deliveryId}/pickupPoint", ErrorHandler(CartIdHandler(s.ProxyHandler(s.HandleSetPickupPoint))))
|
||||
mux.HandleFunc("GET /byid/{id}/checkout", ErrorHandler(CartIdHandler(s.ProxyHandler(s.HandleCheckout))))
|
||||
mux.HandleFunc("GET /byid/{id}/confirmation", ErrorHandler(CartIdHandler(s.ProxyHandler(s.HandleConfirmation))))
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
@@ -16,8 +16,11 @@ func getBaseUrl(country string) string {
|
||||
if country == "no" {
|
||||
return "http://s10n-no.s10n:8080"
|
||||
}
|
||||
if country == "se" {
|
||||
return "http://s10n-se.s10n:8080"
|
||||
}
|
||||
return "http://localhost:8082"
|
||||
}
|
||||
|
||||
func FetchItem(sku string, country string) (*index.DataItem, error) {
|
||||
baseUrl := getBaseUrl(country)
|
||||
|
||||
556
proto/control_plane.pb.go
Normal file
556
proto/control_plane.pb.go
Normal file
@@ -0,0 +1,556 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.9
|
||||
// protoc v6.32.1
|
||||
// source: proto/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_proto_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_proto_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_proto_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_proto_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_proto_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_proto_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_proto_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_proto_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_proto_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_proto_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_proto_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_proto_control_plane_proto_rawDescGZIP(), []int{3}
|
||||
}
|
||||
|
||||
func (x *NegotiateReply) GetHosts() []string {
|
||||
if x != nil {
|
||||
return x.Hosts
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CartIdsReply returns the list of cart IDs (string form) currently owned locally.
|
||||
type CartIdsReply struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
CartIds []uint64 `protobuf:"varint,1,rep,packed,name=cart_ids,json=cartIds,proto3" json:"cart_ids,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *CartIdsReply) Reset() {
|
||||
*x = CartIdsReply{}
|
||||
mi := &file_proto_control_plane_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *CartIdsReply) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*CartIdsReply) ProtoMessage() {}
|
||||
|
||||
func (x *CartIdsReply) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_control_plane_proto_msgTypes[4]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use CartIdsReply.ProtoReflect.Descriptor instead.
|
||||
func (*CartIdsReply) Descriptor() ([]byte, []int) {
|
||||
return file_proto_control_plane_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *CartIdsReply) GetCartIds() []uint64 {
|
||||
if x != nil {
|
||||
return x.CartIds
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OwnerChangeAck retained as response type for Closing RPC (ConfirmOwner removed).
|
||||
type OwnerChangeAck struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Accepted bool `protobuf:"varint,1,opt,name=accepted,proto3" json:"accepted,omitempty"`
|
||||
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *OwnerChangeAck) Reset() {
|
||||
*x = OwnerChangeAck{}
|
||||
mi := &file_proto_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_proto_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_proto_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_proto_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_proto_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_proto_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
|
||||
CartIds []uint64 `protobuf:"varint,2,rep,packed,name=cart_ids,json=cartIds,proto3" json:"cart_ids,omitempty"` // newly claimed cart ids
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *OwnershipAnnounce) Reset() {
|
||||
*x = OwnershipAnnounce{}
|
||||
mi := &file_proto_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_proto_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_proto_control_plane_proto_rawDescGZIP(), []int{7}
|
||||
}
|
||||
|
||||
func (x *OwnershipAnnounce) GetHost() string {
|
||||
if x != nil {
|
||||
return x.Host
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *OwnershipAnnounce) GetCartIds() []uint64 {
|
||||
if x != nil {
|
||||
return x.CartIds
|
||||
}
|
||||
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"`
|
||||
CartIds []uint64 `protobuf:"varint,2,rep,packed,name=cart_ids,json=cartIds,proto3" json:"cart_ids,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *ExpiryAnnounce) Reset() {
|
||||
*x = ExpiryAnnounce{}
|
||||
mi := &file_proto_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_proto_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_proto_control_plane_proto_rawDescGZIP(), []int{8}
|
||||
}
|
||||
|
||||
func (x *ExpiryAnnounce) GetHost() string {
|
||||
if x != nil {
|
||||
return x.Host
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ExpiryAnnounce) GetCartIds() []uint64 {
|
||||
if x != nil {
|
||||
return x.CartIds
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var File_proto_control_plane_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_proto_control_plane_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x19proto/control_plane.proto\x12\bmessages\"\a\n" +
|
||||
"\x05Empty\"<\n" +
|
||||
"\tPingReply\x12\x12\n" +
|
||||
"\x04host\x18\x01 \x01(\tR\x04host\x12\x1b\n" +
|
||||
"\tunix_time\x18\x02 \x01(\x03R\bunixTime\"3\n" +
|
||||
"\x10NegotiateRequest\x12\x1f\n" +
|
||||
"\vknown_hosts\x18\x01 \x03(\tR\n" +
|
||||
"knownHosts\"&\n" +
|
||||
"\x0eNegotiateReply\x12\x14\n" +
|
||||
"\x05hosts\x18\x01 \x03(\tR\x05hosts\")\n" +
|
||||
"\fCartIdsReply\x12\x19\n" +
|
||||
"\bcart_ids\x18\x01 \x03(\x04R\acartIds\"F\n" +
|
||||
"\x0eOwnerChangeAck\x12\x1a\n" +
|
||||
"\baccepted\x18\x01 \x01(\bR\baccepted\x12\x18\n" +
|
||||
"\amessage\x18\x02 \x01(\tR\amessage\"#\n" +
|
||||
"\rClosingNotice\x12\x12\n" +
|
||||
"\x04host\x18\x01 \x01(\tR\x04host\"B\n" +
|
||||
"\x11OwnershipAnnounce\x12\x12\n" +
|
||||
"\x04host\x18\x01 \x01(\tR\x04host\x12\x19\n" +
|
||||
"\bcart_ids\x18\x02 \x03(\x04R\acartIds\"?\n" +
|
||||
"\x0eExpiryAnnounce\x12\x12\n" +
|
||||
"\x04host\x18\x01 \x01(\tR\x04host\x12\x19\n" +
|
||||
"\bcart_ids\x18\x02 \x03(\x04R\acartIds2\x86\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\x125\n" +
|
||||
"\n" +
|
||||
"GetCartIds\x12\x0f.messages.Empty\x1a\x16.messages.CartIdsReply\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_proto_control_plane_proto_rawDescOnce sync.Once
|
||||
file_proto_control_plane_proto_rawDescData []byte
|
||||
)
|
||||
|
||||
func file_proto_control_plane_proto_rawDescGZIP() []byte {
|
||||
file_proto_control_plane_proto_rawDescOnce.Do(func() {
|
||||
file_proto_control_plane_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_control_plane_proto_rawDesc), len(file_proto_control_plane_proto_rawDesc)))
|
||||
})
|
||||
return file_proto_control_plane_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_proto_control_plane_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
|
||||
var file_proto_control_plane_proto_goTypes = []any{
|
||||
(*Empty)(nil), // 0: messages.Empty
|
||||
(*PingReply)(nil), // 1: messages.PingReply
|
||||
(*NegotiateRequest)(nil), // 2: messages.NegotiateRequest
|
||||
(*NegotiateReply)(nil), // 3: messages.NegotiateReply
|
||||
(*CartIdsReply)(nil), // 4: messages.CartIdsReply
|
||||
(*OwnerChangeAck)(nil), // 5: messages.OwnerChangeAck
|
||||
(*ClosingNotice)(nil), // 6: messages.ClosingNotice
|
||||
(*OwnershipAnnounce)(nil), // 7: messages.OwnershipAnnounce
|
||||
(*ExpiryAnnounce)(nil), // 8: messages.ExpiryAnnounce
|
||||
}
|
||||
var file_proto_control_plane_proto_depIdxs = []int32{
|
||||
0, // 0: messages.ControlPlane.Ping:input_type -> messages.Empty
|
||||
2, // 1: messages.ControlPlane.Negotiate:input_type -> messages.NegotiateRequest
|
||||
0, // 2: messages.ControlPlane.GetCartIds:input_type -> messages.Empty
|
||||
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.GetCartIds:output_type -> messages.CartIdsReply
|
||||
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_proto_control_plane_proto_init() }
|
||||
func file_proto_control_plane_proto_init() {
|
||||
if File_proto_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_proto_control_plane_proto_rawDesc), len(file_proto_control_plane_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 9,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
GoTypes: file_proto_control_plane_proto_goTypes,
|
||||
DependencyIndexes: file_proto_control_plane_proto_depIdxs,
|
||||
MessageInfos: file_proto_control_plane_proto_msgTypes,
|
||||
}.Build()
|
||||
File_proto_control_plane_proto = out.File
|
||||
file_proto_control_plane_proto_goTypes = nil
|
||||
file_proto_control_plane_proto_depIdxs = nil
|
||||
}
|
||||
101
proto/control_plane.proto
Normal file
101
proto/control_plane.proto
Normal 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 CartIdsReply {
|
||||
repeated uint64 cart_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 cart_ids = 2; // newly claimed cart ids
|
||||
}
|
||||
|
||||
// ExpiryAnnounce broadcasts that a host evicted the provided cart IDs.
|
||||
message ExpiryAnnounce {
|
||||
string host = 1;
|
||||
repeated uint64 cart_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 GetCartIds(Empty) returns (CartIdsReply);
|
||||
|
||||
// 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.
|
||||
// -----------------------------------------------------------------------------
|
||||
327
proto/control_plane_grpc.pb.go
Normal file
327
proto/control_plane_grpc.pb.go
Normal 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: proto/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_GetCartIds_FullMethodName = "/messages.ControlPlane/GetCartIds"
|
||||
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.
|
||||
GetCartIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*CartIdsReply, 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) GetCartIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*CartIdsReply, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CartIdsReply)
|
||||
err := c.cc.Invoke(ctx, ControlPlane_GetCartIds_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.
|
||||
GetCartIds(context.Context, *Empty) (*CartIdsReply, 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) GetCartIds(context.Context, *Empty) (*CartIdsReply, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetCartIds 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_GetCartIds_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(Empty)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ControlPlaneServer).GetCartIds(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ControlPlane_GetCartIds_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ControlPlaneServer).GetCartIds(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: "GetCartIds",
|
||||
Handler: _ControlPlane_GetCartIds_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AnnounceOwnership",
|
||||
Handler: _ControlPlane_AnnounceOwnership_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AnnounceExpiry",
|
||||
Handler: _ControlPlane_AnnounceExpiry_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "Closing",
|
||||
Handler: _ControlPlane_Closing_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "proto/control_plane.proto",
|
||||
}
|
||||
@@ -889,6 +889,102 @@ func (x *OrderCreated) GetStatus() string {
|
||||
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
|
||||
|
||||
const file_messages_proto_rawDesc = "" +
|
||||
@@ -997,8 +1093,12 @@ const file_messages_proto_rawDesc = "" +
|
||||
"\acountry\x18\x06 \x01(\tR\acountry\"@\n" +
|
||||
"\fOrderCreated\x12\x18\n" +
|
||||
"\aorderId\x18\x01 \x01(\tR\aorderId\x12\x16\n" +
|
||||
"\x06status\x18\x02 \x01(\tR\x06statusB\fZ\n" +
|
||||
".;messagesb\x06proto3"
|
||||
"\x06status\x18\x02 \x01(\tR\x06status\"\x06\n" +
|
||||
"\x04Noop\"t\n" +
|
||||
"\x12InitializeCheckout\x12\x18\n" +
|
||||
"\aorderId\x18\x01 \x01(\tR\aorderId\x12\x16\n" +
|
||||
"\x06status\x18\x02 \x01(\tR\x06status\x12,\n" +
|
||||
"\x11paymentInProgress\x18\x03 \x01(\bR\x11paymentInProgressB.Z,git.tornberg.me/go-cart-actor/proto;messagesb\x06proto3"
|
||||
|
||||
var (
|
||||
file_messages_proto_rawDescOnce sync.Once
|
||||
@@ -1012,7 +1112,7 @@ func file_messages_proto_rawDescGZIP() []byte {
|
||||
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{
|
||||
(*AddRequest)(nil), // 0: messages.AddRequest
|
||||
(*SetCartRequest)(nil), // 1: messages.SetCartRequest
|
||||
@@ -1025,6 +1125,8 @@ var file_messages_proto_goTypes = []any{
|
||||
(*RemoveDelivery)(nil), // 8: messages.RemoveDelivery
|
||||
(*CreateCheckoutOrder)(nil), // 9: messages.CreateCheckoutOrder
|
||||
(*OrderCreated)(nil), // 10: messages.OrderCreated
|
||||
(*Noop)(nil), // 11: messages.Noop
|
||||
(*InitializeCheckout)(nil), // 12: messages.InitializeCheckout
|
||||
}
|
||||
var file_messages_proto_depIdxs = []int32{
|
||||
0, // 0: messages.SetCartRequest.items:type_name -> messages.AddRequest
|
||||
@@ -1052,7 +1154,7 @@ func file_messages_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_messages_proto_rawDesc), len(file_messages_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 11,
|
||||
NumMessages: 13,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
syntax = "proto3";
|
||||
package messages;
|
||||
option go_package = ".;messages";
|
||||
option go_package = "git.tornberg.me/go-cart-actor/proto;messages";
|
||||
|
||||
message AddRequest {
|
||||
int32 quantity = 1;
|
||||
@@ -93,3 +93,13 @@ message OrderCreated {
|
||||
string orderId = 1;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
// }
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
535
synced-pool.go
535
synced-pool.go
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user