Compare commits
184 Commits
48f9347c91
...
refactor/g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8c8ad56c7 | ||
|
|
09a68db8d5 | ||
|
|
30c89a0394 | ||
|
|
d6563d0b3a | ||
|
|
2a2ce247d5 | ||
|
|
159253b8b0 | ||
|
|
c30be581cd | ||
|
|
716f1121aa | ||
|
|
12d87036f6 | ||
|
|
e7c67fbb9b | ||
|
|
b97eb8f285 | ||
|
|
2697832d98 | ||
|
|
4c973b239f | ||
|
|
f735540c3d | ||
| 92ebb49b09 | |||
| d98122756a | |||
| b4c9d09657 | |||
| fec181f640 | |||
| 8c8ff75b2d | |||
| 5c7b8d87e8 | |||
| 25cf3f8005 | |||
|
|
9e4d5df733 | ||
|
|
08064f958a | ||
|
|
b8b79dfb35 | ||
|
|
d2218cca37 | ||
|
|
81898648f5 | ||
|
|
e87ec1a8c1 | ||
|
|
ed7b025134 | ||
|
|
c090a23c4d | ||
|
|
7a5f1e3609 | ||
|
|
8d26c35f61 | ||
|
|
045c15b41a | ||
|
|
00b465a8c9 | ||
|
|
54037741be | ||
|
|
2f04c1d754 | ||
|
|
4cb181578a | ||
|
|
79fc990a98 | ||
|
|
ea173260b9 | ||
|
|
f485dfa038 | ||
|
|
cf31f17d57 | ||
|
|
191171e431 | ||
|
|
5ab2818e8d | ||
|
|
82528e35b4 | ||
|
|
954d23df63 | ||
|
|
0f55b86bfd | ||
|
|
5d8a4f8e83 | ||
|
|
9ba5384615 | ||
|
|
f079d0b649 | ||
|
|
ebd54b1476 | ||
|
|
08a4a5c216 | ||
|
|
2af4657b06 | ||
|
|
c4fbc89a0f | ||
|
|
f10769f0a5 | ||
|
|
dfbc2c31d4 | ||
|
|
e88e312150 | ||
|
|
f1d2591a6c | ||
|
|
4056da81ea | ||
|
|
19bbf32e68 | ||
|
|
6c2328495b | ||
|
|
b51fc78dd5 | ||
|
|
d865d8fe10 | ||
|
|
ef9d191625 | ||
|
|
01a64c1fd1 | ||
|
|
f8c629e1bc | ||
|
|
2324caed49 | ||
|
|
8f10b58d94 | ||
|
|
86141c565a | ||
|
|
4cc1851626 | ||
|
|
b63415bc2c | ||
|
|
ee292b30cb | ||
|
|
49cebad861 | ||
|
|
00e39cf7fa | ||
|
|
0408524d41 | ||
|
|
b2dabc1e35 | ||
|
|
9f585eec2e | ||
|
|
7d27257a71 | ||
|
|
1397822a1a | ||
|
|
10c52ac409 | ||
|
|
9fa69225c8 | ||
|
|
d60936c4f8 | ||
|
|
7f0530e990 | ||
|
|
ce40af5bdb | ||
|
|
03123bac89 | ||
| d35a87e549 | |||
|
|
abc887d4fc | ||
|
|
89f8a695f7 | ||
| 5675686f47 | |||
|
|
15e7bc6c6d | ||
|
|
354964040a | ||
|
|
af5b060d09 | ||
|
|
006d3ab4d8 | ||
|
|
9b137e2ff1 | ||
|
|
c17a8a4219 | ||
|
|
933f0bb04f | ||
|
|
3d55c4ed1f | ||
|
|
d5efb39872 | ||
|
|
5348c33f3b | ||
|
|
1f7f161e62 | ||
|
|
d8f364c329 | ||
|
|
85e4a55418 | ||
|
|
b2b431f2de | ||
|
|
4dd303454c | ||
|
|
fcbf4038f3 | ||
|
|
895502824f | ||
|
|
bc8ffd1878 | ||
|
|
21b51cf020 | ||
|
|
238fa5a1e6 | ||
|
|
256511243e | ||
|
|
893abe9ae2 | ||
|
|
86d310c80f | ||
|
|
bb55c5cb6f | ||
|
|
2575634d72 | ||
|
|
95efa0c4d8 | ||
|
|
4c21ab47e9 | ||
|
|
d77b1edacb | ||
|
|
75796e7da1 | ||
|
|
1562b193ff | ||
|
|
dd7f599c24 | ||
|
|
85d2a0a572 | ||
|
|
7bf3e3dccd | ||
|
|
de3d6a8beb | ||
|
|
79b66c216d | ||
|
|
26a3b76ec4 | ||
|
|
3cf8d3c0b8 | ||
|
|
0837be452e | ||
|
|
00d65035d0 | ||
|
|
9aa64b2808 | ||
|
|
b79f7904b1 | ||
|
|
12d695bec3 | ||
|
|
69d92716c3 | ||
|
|
0870a37d90 | ||
|
|
c8cd8bc745 | ||
|
|
2852ab5bb4 | ||
|
|
b987f79173 | ||
|
|
4112666457 | ||
|
|
352c570f0b | ||
|
|
9a80d74325 | ||
|
|
1c6c9b1894 | ||
|
|
3f6f78c839 | ||
|
|
d617fd9657 | ||
|
|
0fe6cb0920 | ||
|
|
d9563460f7 | ||
|
|
8bbe3a6f51 | ||
|
|
d5bfb87b68 | ||
|
|
7795fc9c39 | ||
|
|
ababb55b17 | ||
|
|
9f6a4025eb | ||
|
|
dc08040870 | ||
|
|
c5a07cde2f | ||
|
|
6b08efdc80 | ||
|
|
78abef7d73 | ||
|
|
0a0fd1738c | ||
|
|
4d3e49b53d | ||
|
|
83fcc10f1b | ||
|
|
2a7f279cd5 | ||
|
|
9e87351681 | ||
|
|
2539a05b71 | ||
|
|
abf561c3fe | ||
|
|
9f7c8227c2 | ||
|
|
dafefa377f | ||
|
|
edc8d471ab | ||
|
|
7cc9b48720 | ||
|
|
3afffe7815 | ||
|
|
745fe00d75 | ||
|
|
11a871d2ce | ||
|
|
fdded53ea8 | ||
|
|
6e00bd7472 | ||
|
|
fc25119a1a | ||
|
|
589a7c159b | ||
|
|
73b1616d4b | ||
|
|
173c4d3151 | ||
|
|
bc6666f31a | ||
|
|
d481bcdc77 | ||
|
|
e1470c02b6 | ||
|
|
1e21275ad5 | ||
|
|
47f6269f60 | ||
|
|
09b9714083 | ||
|
|
95720ededd | ||
|
|
3615d2d7d1 | ||
|
|
c9a7113e12 | ||
|
|
ce5f19d287 | ||
|
|
42c6536cd6 | ||
|
|
c2ae713b46 | ||
|
|
defef53bce |
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
|
name: Build and Publish
|
||||||
run-name: ${{ gitea.actor }} is building 🚀
|
run-name: ${{ gitea.actor }} build 🚀
|
||||||
on: [push]
|
on: [push]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
Metadata:
|
||||||
|
runs-on: arm64
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.meta.outputs.version }}
|
||||||
|
git_commit: ${{ steps.meta.outputs.git_commit }}
|
||||||
|
build_date: ${{ steps.meta.outputs.build_date }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- id: meta
|
||||||
|
name: Derive build metadata
|
||||||
|
run: |
|
||||||
|
GIT_COMMIT=$(git rev-parse HEAD)
|
||||||
|
if git describe --tags --exact-match >/dev/null 2>&1; then
|
||||||
|
VERSION=$(git describe --tags --exact-match)
|
||||||
|
else
|
||||||
|
VERSION=$(git rev-parse --short=12 HEAD)
|
||||||
|
fi
|
||||||
|
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
echo "git_commit=$GIT_COMMIT" >> $GITHUB_OUTPUT
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "build_date=$BUILD_DATE" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
BuildAndDeployAmd64:
|
BuildAndDeployAmd64:
|
||||||
|
needs: Metadata
|
||||||
runs-on: amd64
|
runs-on: amd64
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- name: Build amd64 image
|
||||||
- name: Build docker image
|
run: |
|
||||||
run: docker build --progress=plain -t registry.knatofs.se/go-cart-actor-amd64:latest .
|
docker build \
|
||||||
- name: Push to registry
|
--build-arg VERSION=${{ needs.Metadata.outputs.version }} \
|
||||||
run: docker push registry.knatofs.se/go-cart-actor-amd64:latest
|
--build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \
|
||||||
- name: Rollout amd64 deployment
|
--build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \
|
||||||
run: kubectl rollout restart deployment/cart-actor-x86 -n cart
|
--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 (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
|
runs-on: arm64
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- name: Build arm64 image
|
||||||
- name: Build docker image
|
run: |
|
||||||
run: docker build --progress=plain -t registry.knatofs.se/go-cart-actor .
|
docker build \
|
||||||
- name: Push to registry
|
--build-arg VERSION=${{ needs.Metadata.outputs.version }} \
|
||||||
run: docker push registry.knatofs.se/go-cart-actor
|
--build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \
|
||||||
- name: Deploy to Kubernetes
|
--build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \
|
||||||
run: kubectl apply -f deployment/deployment.yaml -n cart
|
--progress=plain \
|
||||||
- name: Rollout arm64 deployment
|
-t registry.knatofs.se/go-cart-actor:latest \
|
||||||
run: kubectl rollout restart deployment/cart-actor-arm64 -n cart
|
-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 ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
|
go mod download
|
||||||
|
|
||||||
COPY proto ./proto
|
# Copy full source (relay on .dockerignore to prune)
|
||||||
COPY *.go ./
|
COPY . .
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /go-cart-actor
|
# (Optional) If you do NOT check in generated protobuf code, uncomment generation:
|
||||||
|
# RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest && \
|
||||||
|
# go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest && \
|
||||||
|
# protoc --go_out=. --go_opt=paths=source_relative \
|
||||||
|
# --go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
||||||
|
# proto/*.proto
|
||||||
|
|
||||||
FROM gcr.io/distroless/base-debian11
|
# Build with minimal binary size and embedded metadata
|
||||||
|
RUN --mount=type=cache,target=/go/build-cache \
|
||||||
|
go build -trimpath -ldflags="-s -w \
|
||||||
|
-X main.Version=${VERSION} \
|
||||||
|
-X main.GitCommit=${GIT_COMMIT} \
|
||||||
|
-X main.BuildDate=${BUILD_DATE}" \
|
||||||
|
-o /out/go-cart-actor .
|
||||||
|
|
||||||
|
############################
|
||||||
|
# Runtime Stage
|
||||||
|
############################
|
||||||
|
# Using distroless static (nonroot) for minimal surface area.
|
||||||
|
FROM gcr.io/distroless/static-debian12:nonroot AS runtime
|
||||||
WORKDIR /
|
WORKDIR /
|
||||||
|
|
||||||
COPY --from=build-stage /go-cart-actor /go-cart-actor
|
COPY --from=build /out/go-cart-actor /go-cart-actor
|
||||||
|
|
||||||
|
# Document (not expose forcibly) typical ports: 8080 (HTTP), 1337 (gRPC)
|
||||||
|
EXPOSE 8080 1337
|
||||||
|
|
||||||
|
USER nonroot:nonroot
|
||||||
ENTRYPOINT ["/go-cart-actor"]
|
ENTRYPOINT ["/go-cart-actor"]
|
||||||
132
Makefile
Normal file
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)/cart_actor.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
|
||||||
445
README.md
445
README.md
@@ -1 +1,444 @@
|
|||||||
protoc --proto_path=proto --go_out=out --go_opt=paths=source_relative proto/messages.proto
|
# 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 and protoc-gen-go-grpc plugins
|
||||||
|
|
||||||
|
### Installing Protocol Buffers
|
||||||
|
|
||||||
|
On Windows:
|
||||||
|
```powershell
|
||||||
|
winget install protobuf
|
||||||
|
```
|
||||||
|
|
||||||
|
On macOS:
|
||||||
|
```bash
|
||||||
|
brew install protobuf
|
||||||
|
```
|
||||||
|
|
||||||
|
On Linux:
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo apt install protobuf-compiler
|
||||||
|
|
||||||
|
# Or download from: https://github.com/protocolbuffers/protobuf/releases
|
||||||
|
```
|
||||||
|
|
||||||
|
### Installing Go protobuf plugin
|
||||||
|
|
||||||
|
```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 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 \
|
||||||
|
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
||||||
|
messages.proto cart_actor.proto control_plane.proto
|
||||||
|
```
|
||||||
|
|
||||||
|
### Protocol Buffer Messages
|
||||||
|
|
||||||
|
The `proto/messages.proto` file defines the following message types:
|
||||||
|
|
||||||
|
- `AddRequest` - Add items to cart (includes quantity, sku, country, optional storeId)
|
||||||
|
- `SetCartRequest` - Set entire cart contents
|
||||||
|
- `AddItem` - Complete item information for cart
|
||||||
|
- `RemoveItem` - Remove item from cart
|
||||||
|
- `ChangeQuantity` - Update item quantity
|
||||||
|
- `SetDelivery` - Configure delivery options
|
||||||
|
- `SetPickupPoint` - Set pickup location
|
||||||
|
- `PickupPoint` - Pickup point details
|
||||||
|
- `RemoveDelivery` - Remove delivery option
|
||||||
|
- `CreateCheckoutOrder` - Initiate checkout
|
||||||
|
- `OrderCreated` - Order creation response
|
||||||
|
|
||||||
|
### Building the project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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 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._
|
||||||
61
amqp-order-handler.go
Normal file
61
amqp-order-handler.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AmqpOrderHandler struct {
|
||||||
|
Url string
|
||||||
|
Connection *amqp.Connection
|
||||||
|
Channel *amqp.Channel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AmqpOrderHandler) Connect() error {
|
||||||
|
conn, err := amqp.Dial(h.Url)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to RabbitMQ: %w", err)
|
||||||
|
}
|
||||||
|
h.Connection = conn
|
||||||
|
|
||||||
|
ch, err := conn.Channel()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open a channel: %w", err)
|
||||||
|
}
|
||||||
|
h.Channel = ch
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AmqpOrderHandler) Close() error {
|
||||||
|
if h.Channel != nil {
|
||||||
|
h.Channel.Close()
|
||||||
|
}
|
||||||
|
if h.Connection != nil {
|
||||||
|
return h.Connection.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AmqpOrderHandler) OrderCompleted(body []byte) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := h.Channel.PublishWithContext(ctx,
|
||||||
|
"orders", // exchange
|
||||||
|
"new", // routing key
|
||||||
|
false, // mandatory
|
||||||
|
false, // immediate
|
||||||
|
amqp.Publishing{
|
||||||
|
ContentType: "application/json",
|
||||||
|
Body: body,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to publish a message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
42
api-tests/cart.http
Normal file
42
api-tests/cart.http
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
|
||||||
|
### Add item to cart
|
||||||
|
POST https://cart.tornberg.me/api/12345
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"sku": "763281",
|
||||||
|
"quantity": 1
|
||||||
|
}
|
||||||
|
|
||||||
|
### Update quanity of item in cart
|
||||||
|
PUT https://cart.tornberg.me/api/12345
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"quantity": 1
|
||||||
|
}
|
||||||
|
|
||||||
|
### Delete item from cart
|
||||||
|
DELETE https://cart.tornberg.me/api/1002/1
|
||||||
|
|
||||||
|
|
||||||
|
### Set delivery
|
||||||
|
|
||||||
|
POST https://cart.tornberg.me/api/1002/delivery
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"provider": "postnord",
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
### Get cart
|
||||||
|
GET https://cart.tornberg.me/api/12345
|
||||||
|
|
||||||
|
|
||||||
|
### Remove delivery method
|
||||||
|
DELETE https://cart.tornberg.me/api/12345/delivery/2
|
||||||
|
|
||||||
|
|
||||||
318
cart-grain.go
318
cart-grain.go
@@ -3,15 +3,29 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
messages "git.tornberg.me/go-cart-actor/proto"
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CartId [16]byte
|
type CartId [16]byte
|
||||||
|
|
||||||
|
// String returns the cart id as a trimmed UTF-8 string (trailing zero bytes removed).
|
||||||
|
func (id CartId) String() string {
|
||||||
|
n := 0
|
||||||
|
for n < len(id) && id[n] != 0 {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return string(id[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToCartId converts an arbitrary string to a fixed-size CartId (truncating or padding with zeros).
|
||||||
|
func ToCartId(s string) CartId {
|
||||||
|
var id CartId
|
||||||
|
copy(id[:], []byte(s))
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
func (id CartId) MarshalJSON() ([]byte, error) {
|
func (id CartId) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(id.String())
|
return json.Marshal(id.String())
|
||||||
}
|
}
|
||||||
@@ -26,36 +40,71 @@ func (id *CartId) UnmarshalJSON(data []byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StockStatus int
|
||||||
|
|
||||||
|
const (
|
||||||
|
OutOfStock StockStatus = 0
|
||||||
|
LowStock StockStatus = 1
|
||||||
|
InStock StockStatus = 2
|
||||||
|
)
|
||||||
|
|
||||||
type CartItem struct {
|
type CartItem struct {
|
||||||
Id int `json:"id"`
|
Id int `json:"id"`
|
||||||
|
ItemId int `json:"itemId,omitempty"`
|
||||||
|
ParentId int `json:"parentId,omitempty"`
|
||||||
Sku string `json:"sku"`
|
Sku string `json:"sku"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Price int64 `json:"price"`
|
Price int64 `json:"price"`
|
||||||
|
TotalPrice int64 `json:"totalPrice"`
|
||||||
|
TotalTax int64 `json:"totalTax"`
|
||||||
|
OrgPrice int64 `json:"orgPrice"`
|
||||||
|
Stock StockStatus `json:"stock"`
|
||||||
Quantity int `json:"qty"`
|
Quantity int `json:"qty"`
|
||||||
Image string `json:"image"`
|
Tax int `json:"tax"`
|
||||||
|
TaxRate int `json:"taxRate"`
|
||||||
|
Brand string `json:"brand,omitempty"`
|
||||||
|
Category string `json:"category,omitempty"`
|
||||||
|
Category2 string `json:"category2,omitempty"`
|
||||||
|
Category3 string `json:"category3,omitempty"`
|
||||||
|
Category4 string `json:"category4,omitempty"`
|
||||||
|
Category5 string `json:"category5,omitempty"`
|
||||||
|
Disclaimer string `json:"disclaimer,omitempty"`
|
||||||
|
SellerId string `json:"sellerId,omitempty"`
|
||||||
|
SellerName string `json:"sellerName,omitempty"`
|
||||||
|
ArticleType string `json:"type,omitempty"`
|
||||||
|
Image string `json:"image,omitempty"`
|
||||||
|
Outlet *string `json:"outlet,omitempty"`
|
||||||
|
StoreId *string `json:"storeId,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CartDelivery struct {
|
type CartDelivery struct {
|
||||||
|
Id int `json:"id"`
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
Price int64 `json:"price"`
|
Price int64 `json:"price"`
|
||||||
Items []int `json:"items"`
|
Items []int `json:"items"`
|
||||||
|
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CartGrain struct {
|
type CartGrain struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
lastItemId int
|
lastItemId int
|
||||||
lastDeliveryId int
|
lastDeliveryId int
|
||||||
storageMessages []Message
|
|
||||||
Id CartId `json:"id"`
|
Id CartId `json:"id"`
|
||||||
Items []*CartItem `json:"items"`
|
Items []*CartItem `json:"items"`
|
||||||
TotalPrice int64 `json:"totalPrice"`
|
TotalPrice int64 `json:"totalPrice"`
|
||||||
Deliveries []CartDelivery `json:"deliveries,omitempty"`
|
TotalTax int64 `json:"totalTax"`
|
||||||
|
TotalDiscount int64 `json:"totalDiscount"`
|
||||||
|
Deliveries []*CartDelivery `json:"deliveries,omitempty"`
|
||||||
|
Processing bool `json:"processing"`
|
||||||
|
PaymentInProgress bool `json:"paymentInProgress"`
|
||||||
|
OrderReference string `json:"orderReference,omitempty"`
|
||||||
|
PaymentStatus string `json:"paymentStatus,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Grain interface {
|
type Grain interface {
|
||||||
GetId() CartId
|
GetId() CartId
|
||||||
HandleMessage(message *Message, isReplay bool) (*CallResult, error)
|
Apply(content interface{}, isReplay bool) (*CartGrain, error)
|
||||||
GetCurrentState() (*CallResult, error)
|
GetCurrentState() (*CartGrain, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CartGrain) GetId() CartId {
|
func (c *CartGrain) GetId() CartId {
|
||||||
@@ -63,74 +112,99 @@ func (c *CartGrain) GetId() CartId {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *CartGrain) GetLastChange() int64 {
|
func (c *CartGrain) GetLastChange() int64 {
|
||||||
if len(c.storageMessages) == 0 {
|
// Legacy event log removed; return 0 to indicate no persisted mutation history.
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return *c.storageMessages[len(c.storageMessages)-1].TimeStamp
|
|
||||||
|
func (c *CartGrain) GetCurrentState() (*CartGrain, error) {
|
||||||
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CartGrain) GetCurrentState() (*CallResult, error) {
|
func getInt(data float64, ok bool) (int, error) {
|
||||||
result, err := json.Marshal(c)
|
if !ok {
|
||||||
return &CallResult{
|
return 0, fmt.Errorf("invalid type")
|
||||||
StatusCode: 200,
|
}
|
||||||
Data: result,
|
return int(data), nil
|
||||||
}, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getItemData(sku string, qty int) (*messages.AddItem, error) {
|
func getItemData(sku string, qty int, country string) (*messages.AddItem, error) {
|
||||||
item, err := FetchItem(sku)
|
item, err := FetchItem(sku, country)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
price := 0
|
orgPrice, _ := getInt(item.GetNumberFieldValue(5)) // getInt(item.Fields[5])
|
||||||
priceField, ok := item.Fields[4]
|
|
||||||
if ok {
|
price, priceErr := getInt(item.GetNumberFieldValue(4)) //Fields[4]
|
||||||
priceFloat, ok := priceField.(float64)
|
|
||||||
if !ok {
|
if priceErr != nil {
|
||||||
price, ok = priceField.(int)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("invalid price type")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
price = int(priceFloat)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if price == 0 {
|
|
||||||
return nil, fmt.Errorf("invalid price")
|
return nil, fmt.Errorf("invalid price")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stock := InStock
|
||||||
|
/*item.t
|
||||||
|
if item.StockLevel == "0" || item.StockLevel == "" {
|
||||||
|
stock = OutOfStock
|
||||||
|
} else if item.StockLevel == "5+" {
|
||||||
|
stock = LowStock
|
||||||
|
}*/
|
||||||
|
articleType, _ := item.GetStringFieldValue(1) //.Fields[1].(string)
|
||||||
|
outletGrade, ok := item.GetStringFieldValue(20) //.Fields[20].(string)
|
||||||
|
var outlet *string
|
||||||
|
if ok {
|
||||||
|
outlet = &outletGrade
|
||||||
|
}
|
||||||
|
sellerId, _ := item.GetStringFieldValue(24) // .Fields[24].(string)
|
||||||
|
sellerName, _ := item.GetStringFieldValue(9) // .Fields[9].(string)
|
||||||
|
|
||||||
|
brand, _ := item.GetStringFieldValue(2) //.Fields[2].(string)
|
||||||
|
category, _ := item.GetStringFieldValue(10) //.Fields[10].(string)
|
||||||
|
category2, _ := item.GetStringFieldValue(11) //.Fields[11].(string)
|
||||||
|
category3, _ := item.GetStringFieldValue(12) //.Fields[12].(string)
|
||||||
|
category4, _ := item.GetStringFieldValue(13) //Fields[13].(string)
|
||||||
|
category5, _ := item.GetStringFieldValue(14) //.Fields[14].(string)
|
||||||
|
|
||||||
return &messages.AddItem{
|
return &messages.AddItem{
|
||||||
|
ItemId: int64(item.Id),
|
||||||
Quantity: int32(qty),
|
Quantity: int32(qty),
|
||||||
Price: int64(price),
|
Price: int64(price),
|
||||||
|
OrgPrice: int64(orgPrice),
|
||||||
Sku: sku,
|
Sku: sku,
|
||||||
Name: item.Title,
|
Name: item.Title,
|
||||||
Image: item.Img,
|
Image: item.Img,
|
||||||
|
Stock: int32(stock),
|
||||||
|
Brand: brand,
|
||||||
|
Category: category,
|
||||||
|
Category2: category2,
|
||||||
|
Category3: category3,
|
||||||
|
Category4: category4,
|
||||||
|
Category5: category5,
|
||||||
|
Tax: 2500,
|
||||||
|
SellerId: sellerId,
|
||||||
|
SellerName: sellerName,
|
||||||
|
ArticleType: articleType,
|
||||||
|
Disclaimer: item.Disclaimer,
|
||||||
|
Country: country,
|
||||||
|
Outlet: outlet,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CartGrain) AddItem(sku string, qty int) (*CallResult, error) {
|
func (c *CartGrain) AddItem(sku string, qty int, country string, storeId *string) (*CartGrain, error) {
|
||||||
cartItem, err := getItemData(sku, qty)
|
cartItem, err := getItemData(sku, qty, country)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return c.HandleMessage(&Message{
|
cartItem.StoreId = storeId
|
||||||
Type: 2,
|
return c.Apply(cartItem, false)
|
||||||
Content: cartItem,
|
|
||||||
}, false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CartGrain) GetStorageMessage(since int64) []StorableMessage {
|
/*
|
||||||
c.mu.RLock()
|
Legacy storage (event sourcing) removed in oneof refactor.
|
||||||
defer c.mu.RUnlock()
|
Kept stub (commented) for potential future reintroduction using proto envelopes.
|
||||||
ret := make([]StorableMessage, 0)
|
|
||||||
|
|
||||||
for _, message := range c.storageMessages {
|
func (c *CartGrain) GetStorageMessage(since int64) []interface{} {
|
||||||
if *message.TimeStamp > since {
|
return nil
|
||||||
ret = append(ret, message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
func (c *CartGrain) GetState() ([]byte, error) {
|
func (c *CartGrain) GetState() ([]byte, error) {
|
||||||
return json.Marshal(c)
|
return json.Marshal(c)
|
||||||
@@ -180,133 +254,41 @@ func (c *CartGrain) FindItemWithSku(sku string) (*CartItem, bool) {
|
|||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CartGrain) HandleMessage(message *Message, isReplay bool) (*CallResult, error) {
|
func GetTaxAmount(total int64, tax int) int64 {
|
||||||
if message.TimeStamp == nil {
|
taxD := 10000 / float64(tax)
|
||||||
now := time.Now().Unix()
|
return int64(float64(total) / float64((1 + taxD)))
|
||||||
message.TimeStamp = &now
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) Apply(content interface{}, isReplay bool) (*CartGrain, error) {
|
||||||
grainMutations.Inc()
|
grainMutations.Inc()
|
||||||
var err error
|
|
||||||
switch message.Type {
|
|
||||||
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.TotalPrice += existingItem.Price * int64(msg.Quantity)
|
|
||||||
} else {
|
|
||||||
return c.AddItem(msg.Sku, int(msg.Quantity)) // extent AddRequest to include quantity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case AddItemType:
|
|
||||||
msg, ok := message.Content.(*messages.AddItem)
|
|
||||||
if !ok {
|
|
||||||
err = fmt.Errorf("expected AddItem")
|
|
||||||
} else {
|
|
||||||
|
|
||||||
if msg.Quantity < 1 {
|
updated, err := ApplyRegistered(c, content)
|
||||||
return nil, fmt.Errorf("invalid quantity")
|
|
||||||
}
|
|
||||||
existingItem, found := c.FindItemWithSku(msg.Sku)
|
|
||||||
if found {
|
|
||||||
existingItem.Quantity += int(msg.Quantity)
|
|
||||||
c.TotalPrice += existingItem.Price * int64(msg.Quantity)
|
|
||||||
} else {
|
|
||||||
c.mu.Lock()
|
|
||||||
c.lastItemId++
|
|
||||||
c.Items = append(c.Items, &CartItem{
|
|
||||||
Id: c.lastItemId,
|
|
||||||
Quantity: int(msg.Quantity),
|
|
||||||
Sku: msg.Sku,
|
|
||||||
Name: msg.Name,
|
|
||||||
Price: msg.Price,
|
|
||||||
Image: msg.Image,
|
|
||||||
})
|
|
||||||
c.TotalPrice += msg.Price * int64(msg.Quantity)
|
|
||||||
c.mu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case ChangeQuantityType:
|
|
||||||
msg, ok := message.Content.(*messages.ChangeQuantity)
|
|
||||||
if !ok {
|
|
||||||
err = fmt.Errorf("expected RemoveItem")
|
|
||||||
} else {
|
|
||||||
for i, item := range c.Items {
|
|
||||||
if item.Id == int(msg.Id) {
|
|
||||||
if item.Quantity <= int(msg.Quantity) {
|
|
||||||
c.Items = append(c.Items[:i], c.Items[i+1:]...)
|
|
||||||
} else {
|
|
||||||
item.Quantity -= int(msg.Quantity)
|
|
||||||
}
|
|
||||||
c.TotalPrice -= item.Price * int64(msg.Quantity)
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.Deliveries = append(c.Deliveries, CartDelivery{
|
|
||||||
Provider: msg.Provider,
|
|
||||||
Price: 49,
|
|
||||||
Items: items,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
case RemoveDeliveryType:
|
|
||||||
default:
|
|
||||||
err = fmt.Errorf("unknown message type %d", message.Type)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if err == ErrMutationNotRegistered {
|
||||||
|
return nil, fmt.Errorf("unsupported mutation type %T (not registered)", content)
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
if !isReplay {
|
func (c *CartGrain) UpdateTotals() {
|
||||||
c.mu.Lock()
|
c.TotalPrice = 0
|
||||||
c.storageMessages = append(c.storageMessages, *message)
|
c.TotalTax = 0
|
||||||
c.mu.Unlock()
|
c.TotalDiscount = 0
|
||||||
|
for _, item := range c.Items {
|
||||||
|
rowTotal := item.Price * int64(item.Quantity)
|
||||||
|
rowTax := int64(item.Tax) * int64(item.Quantity)
|
||||||
|
item.TotalPrice = rowTotal
|
||||||
|
item.TotalTax = rowTax
|
||||||
|
c.TotalPrice += rowTotal
|
||||||
|
c.TotalTax += rowTax
|
||||||
|
itemDiff := max(0, item.OrgPrice-item.Price)
|
||||||
|
c.TotalDiscount += itemDiff * int64(item.Quantity)
|
||||||
}
|
}
|
||||||
result, err := json.Marshal(c)
|
for _, delivery := range c.Deliveries {
|
||||||
return &CallResult{
|
c.TotalPrice += delivery.Price
|
||||||
StatusCode: 200,
|
c.TotalTax += GetTaxAmount(delivery.Price, 2500)
|
||||||
Data: result,
|
}
|
||||||
}, err
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,222 +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 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 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))
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CartPacketQueue struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
expectedPackages map[CartMessage]*CartListener
|
|
||||||
}
|
|
||||||
|
|
||||||
const CurrentPacketVersion = 2
|
|
||||||
|
|
||||||
type CartListener map[CartId]Listener
|
|
||||||
|
|
||||||
func NewCartPacketQueue(connection *PersistentConnection) *CartPacketQueue {
|
|
||||||
queue := &CartPacketQueue{
|
|
||||||
expectedPackages: make(map[CartMessage]*CartListener),
|
|
||||||
}
|
|
||||||
go queue.HandleConnection(connection)
|
|
||||||
return queue
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *CartPacketQueue) RemoveListeners() {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
for _, l := range p.expectedPackages {
|
|
||||||
for _, l := range *l {
|
|
||||||
close(l.Chan)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p.expectedPackages = make(map[CartMessage]*CartListener)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *CartPacketQueue) HandleConnection(connection *PersistentConnection) error {
|
|
||||||
defer p.RemoveListeners()
|
|
||||||
defer connection.Close()
|
|
||||||
var packet CartPacket
|
|
||||||
reader := bufio.NewReader(connection)
|
|
||||||
for {
|
|
||||||
err := ReadCartPacket(reader, &packet)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error receiving packet: %v\n", err)
|
|
||||||
return connection.HandleConnectionError(err)
|
|
||||||
}
|
|
||||||
if packet.Version != CurrentPacketVersion {
|
|
||||||
log.Printf("Incorrect version: %v\n", packet.Version)
|
|
||||||
return connection.HandleConnectionError(fmt.Errorf("incorrect version: %d", packet.Version))
|
|
||||||
}
|
|
||||||
if packet.DataLength == 0 {
|
|
||||||
go p.HandleData(packet.MessageType, packet.Id, CallResult{
|
|
||||||
StatusCode: packet.StatusCode,
|
|
||||||
Data: []byte{},
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
data, err := GetPacketData(reader, packet.DataLength)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error receiving packet data: %v\n", err)
|
|
||||||
return connection.HandleConnectionError(err)
|
|
||||||
}
|
|
||||||
go p.HandleData(packet.MessageType, packet.Id, CallResult{
|
|
||||||
StatusCode: packet.StatusCode,
|
|
||||||
Data: data,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *CartPacketQueue) HandleData(t CartMessage, id CartId, data CallResult) {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
pl, ok := p.expectedPackages[t]
|
|
||||||
if ok {
|
|
||||||
l, ok := (*pl)[id]
|
|
||||||
if ok {
|
|
||||||
l.Chan <- data
|
|
||||||
l.Count--
|
|
||||||
if l.Count == 0 {
|
|
||||||
close(l.Chan)
|
|
||||||
delete(*pl, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *CartPacketQueue) Expect(messageType CartMessage, id CartId) <-chan CallResult {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
l, ok := p.expectedPackages[messageType]
|
|
||||||
if ok {
|
|
||||||
if idl, idOk := (*l)[id]; idOk {
|
|
||||||
idl.Count++
|
|
||||||
return idl.Chan
|
|
||||||
}
|
|
||||||
ch := make(chan CallResult)
|
|
||||||
(*l)[id] = Listener{
|
|
||||||
Chan: ch,
|
|
||||||
Count: 1,
|
|
||||||
}
|
|
||||||
return ch
|
|
||||||
}
|
|
||||||
|
|
||||||
ch := make(chan CallResult)
|
|
||||||
p.expectedPackages[messageType] = &CartListener{
|
|
||||||
id: Listener{
|
|
||||||
Chan: ch,
|
|
||||||
Count: 1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return ch
|
|
||||||
|
|
||||||
}
|
|
||||||
327
cart_id.go
Normal file
327
cart_id.go
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cart_id.go
|
||||||
|
//
|
||||||
|
// Compact CartID implementation using 64 bits of cryptographic randomness,
|
||||||
|
// base62 encoded (0-9 A-Z a-z). Typical length is 11 characters (since 62^11 > 2^64).
|
||||||
|
//
|
||||||
|
// Motivation:
|
||||||
|
// * Shorter identifiers for cookies / URLs than legacy padded 16-byte CartId
|
||||||
|
// * O(1) hashing (raw uint64) for consistent hashing ring integration
|
||||||
|
// * Extremely low collision probability (birthday bound negligible at scale)
|
||||||
|
//
|
||||||
|
// Backward Compatibility Strategy (Phased):
|
||||||
|
// Phase 1: Introduce CartID helpers while continuing to accept legacy CartId.
|
||||||
|
// Phase 2: Internally migrate maps to key by uint64 (CartID.Raw()).
|
||||||
|
// Phase 3: Canonicalize all inbound IDs to short base62; reissue Set-Cart-Id header.
|
||||||
|
//
|
||||||
|
// NOTE:
|
||||||
|
// The legacy type `CartId [16]byte` is still present elsewhere; helper
|
||||||
|
// UpgradeLegacyCartId bridges that representation to the new form without
|
||||||
|
// breaking deterministic mapping for existing carts.
|
||||||
|
//
|
||||||
|
// Security / Predictability:
|
||||||
|
// Uses crypto/rand for generation. If ever required, you can layer an
|
||||||
|
// HMAC-based derivation for additional secrecy. Current approach already
|
||||||
|
// provides 64 bits of entropy (brute force infeasible for practical risk).
|
||||||
|
//
|
||||||
|
// Future Extensions:
|
||||||
|
// * Time-sortable IDs: prepend a 48-bit timestamp field and encode 80 bits.
|
||||||
|
// * Add metrics counters for: generated_new, parsed_existing, legacy_fallback.
|
||||||
|
// * Add a pool of pre-generated IDs for ultra-low-latency hot paths (rarely needed).
|
||||||
|
//
|
||||||
|
// Public Surface Summary:
|
||||||
|
// NewCartID() (CartID, error)
|
||||||
|
// ParseCartID(string) (CartID, bool)
|
||||||
|
// FallbackFromString(string) CartID
|
||||||
|
// UpgradeLegacyCartId(CartId) CartID
|
||||||
|
// CanonicalizeIncoming(string) (CartID, bool /*wasGenerated*/, error)
|
||||||
|
//
|
||||||
|
// Encoding Details:
|
||||||
|
// encodeBase62 / decodeBase62 maintain a stable alphabet. DO NOT change
|
||||||
|
// alphabet order once IDs are in circulation, or previously issued IDs
|
||||||
|
// will change meaning.
|
||||||
|
//
|
||||||
|
// Zero Values:
|
||||||
|
// The zero value CartID{} has raw=0, txt="0". Treat it as valid but
|
||||||
|
// usually you will call NewCartID instead.
|
||||||
|
//
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||||
|
|
||||||
|
// Precomputed reverse lookup table for decode (255 = invalid).
|
||||||
|
var base62Rev [256]byte
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
for i := range base62Rev {
|
||||||
|
base62Rev[i] = 0xFF
|
||||||
|
}
|
||||||
|
for i := 0; i < len(base62Alphabet); i++ {
|
||||||
|
base62Rev[base62Alphabet[i]] = byte(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CartID is the compact representation of a cart identifier.
|
||||||
|
// raw: 64-bit entropy (also used directly for consistent hashing).
|
||||||
|
// txt: cached base62 textual form.
|
||||||
|
type CartID struct {
|
||||||
|
raw uint64
|
||||||
|
txt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the canonical base62 encoded ID.
|
||||||
|
func (c CartID) String() string {
|
||||||
|
if c.txt == "" { // lazily encode if constructed manually
|
||||||
|
c.txt = encodeBase62(c.raw)
|
||||||
|
}
|
||||||
|
return c.txt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw returns the 64-bit numeric value (useful for hashing / ring lookup).
|
||||||
|
func (c CartID) Raw() uint64 {
|
||||||
|
return c.raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsZero reports whether this CartID is the zero value.
|
||||||
|
func (c CartID) IsZero() bool {
|
||||||
|
return c.raw == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCartID generates a new cryptographically random 64-bit ID.
|
||||||
|
func NewCartID() (CartID, error) {
|
||||||
|
var b [8]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return CartID{}, fmt.Errorf("NewCartID: %w", err)
|
||||||
|
}
|
||||||
|
u := binary.BigEndian.Uint64(b[:])
|
||||||
|
// Reject zero if you want to avoid ever producing "0" (optional).
|
||||||
|
if u == 0 {
|
||||||
|
// Extremely unlikely; recurse once.
|
||||||
|
return NewCartID()
|
||||||
|
}
|
||||||
|
return CartID{raw: u, txt: encodeBase62(u)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustNewCartID panics on failure (suitable for tests / initialization).
|
||||||
|
func MustNewCartID() CartID {
|
||||||
|
id, err := NewCartID()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCartID attempts to parse a base62 canonical ID.
|
||||||
|
// Returns (id, true) if fully valid; (zero, false) otherwise.
|
||||||
|
func ParseCartID(s string) (CartID, bool) {
|
||||||
|
if len(s) == 0 {
|
||||||
|
return CartID{}, false
|
||||||
|
}
|
||||||
|
// Basic length sanity; allow a bit of headroom for future timestamp variant.
|
||||||
|
if len(s) > 16 {
|
||||||
|
return CartID{}, false
|
||||||
|
}
|
||||||
|
u, ok := decodeBase62(s)
|
||||||
|
if !ok {
|
||||||
|
return CartID{}, false
|
||||||
|
}
|
||||||
|
return CartID{raw: u, txt: s}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// FallbackFromString produces a deterministic CartID from arbitrary input
|
||||||
|
// using a 64-bit FNV-1a hash. This allows legacy or malformed IDs to map
|
||||||
|
// consistently into the new scheme (collision probability still low).
|
||||||
|
func FallbackFromString(s string) CartID {
|
||||||
|
const (
|
||||||
|
offset64 = 1469598103934665603
|
||||||
|
prime64 = 1099511628211
|
||||||
|
)
|
||||||
|
h := uint64(offset64)
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
h ^= uint64(s[i])
|
||||||
|
h *= prime64
|
||||||
|
}
|
||||||
|
return CartID{raw: h, txt: encodeBase62(h)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpgradeLegacyCartId converts the old 16-byte CartId (padded) to CartID
|
||||||
|
// by hashing its trimmed string form. Keeps stable mapping across restarts.
|
||||||
|
func UpgradeLegacyCartId(old CartId) CartID {
|
||||||
|
return FallbackFromString(old.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanonicalizeIncoming normalizes user-provided ID strings.
|
||||||
|
// Behavior:
|
||||||
|
//
|
||||||
|
// Empty string -> generate new ID (wasGenerated = true)
|
||||||
|
// Valid base62 -> parse and return (wasGenerated = false)
|
||||||
|
// Anything else -> fallback deterministic hash (wasGenerated = false)
|
||||||
|
//
|
||||||
|
// Errors only occur if crypto/rand fails during generation.
|
||||||
|
func CanonicalizeIncoming(s string) (CartID, bool, error) {
|
||||||
|
if s == "" {
|
||||||
|
id, err := NewCartID()
|
||||||
|
return id, true, err
|
||||||
|
}
|
||||||
|
if cid, ok := ParseCartID(s); ok {
|
||||||
|
return cid, false, nil
|
||||||
|
}
|
||||||
|
// Legacy heuristic: if length == 16 and contains non-base62 chars, treat as legacy padded ID.
|
||||||
|
if len(s) == 16 && !isAllBase62(s) {
|
||||||
|
return FallbackFromString(strings.TrimRight(s, "\x00")), false, nil
|
||||||
|
}
|
||||||
|
return FallbackFromString(s), false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAllBase62 returns true if every byte is in the base62 alphabet.
|
||||||
|
func isAllBase62(s string) bool {
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if base62Rev[s[i]] == 0xFF {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeBase62 turns a uint64 into base62 text.
|
||||||
|
// Complexity: O(log_62 n) ~ at most 11 iterations for 64 bits.
|
||||||
|
func encodeBase62(u uint64) string {
|
||||||
|
if u == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
// 62^11 = 743008370688 > 2^39; 62^11 > 2^64? Actually 62^11 ~= 5.18e19 < 2^64 (1.84e19)? 2^64 ≈ 1.84e19.
|
||||||
|
// 62^11 ≈ 5.18e19 > 2^64? Correction: 2^64 ≈ 1.844e19, so 62^11 > 2^64. Thus 11 chars suffice.
|
||||||
|
var buf [11]byte
|
||||||
|
i := len(buf)
|
||||||
|
for u > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = base62Alphabet[u%62]
|
||||||
|
u /= 62
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeBase62 converts a base62 string to uint64.
|
||||||
|
// Returns (value, false) if any invalid character appears.
|
||||||
|
func decodeBase62(s string) (uint64, bool) {
|
||||||
|
var v uint64
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
d := base62Rev[c]
|
||||||
|
if d == 0xFF {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
v = v*62 + uint64(d)
|
||||||
|
}
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrInvalidCartID can be returned by higher-level validation layers if you decide
|
||||||
|
// to reject fallback-derived IDs (currently unused here).
|
||||||
|
var ErrInvalidCartID = errors.New("invalid cart id")
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Legacy / Compatibility Conversion Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// CartIDToLegacy converts a CartID (base62) into the legacy fixed-size CartId
|
||||||
|
// ([16]byte) by copying the textual form (truncated or zero-padded).
|
||||||
|
// NOTE: If the base62 string is longer than 16 (should not happen with current
|
||||||
|
// 64-bit space), it will be truncated.
|
||||||
|
func CartIDToLegacy(c CartID) CartId {
|
||||||
|
var id CartId
|
||||||
|
txt := c.String()
|
||||||
|
copy(id[:], []byte(txt))
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// LegacyToCartID upgrades a legacy CartId (padded) to a CartID by hashing its
|
||||||
|
// trimmed string form (deterministic). This preserves stable mapping without
|
||||||
|
// depending on original randomness.
|
||||||
|
func LegacyToCartID(old CartId) CartID {
|
||||||
|
return UpgradeLegacyCartId(old)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CartIDToKey returns the numeric key representation (uint64) for map indexing.
|
||||||
|
func CartIDToKey(c CartID) uint64 {
|
||||||
|
return c.Raw()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LegacyToCartKey converts a legacy CartId to the numeric key via deterministic
|
||||||
|
// fallback hashing. (Uses the same logic as LegacyToCartID then returns raw.)
|
||||||
|
func LegacyToCartKey(old CartId) uint64 {
|
||||||
|
return LegacyToCartID(old).Raw()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------- Optional Helper Utilities ----------------------------
|
||||||
|
|
||||||
|
// CartIDOrNew tries to parse s; if empty OR invalid returns a fresh ID.
|
||||||
|
func CartIDOrNew(s string) (CartID, bool /*wasParsed*/, error) {
|
||||||
|
if cid, ok := ParseCartID(s); ok {
|
||||||
|
return cid, true, nil
|
||||||
|
}
|
||||||
|
id, err := NewCartID()
|
||||||
|
return id, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustParseCartID panics if s is not a valid base62 ID (useful in tests).
|
||||||
|
func MustParseCartID(s string) CartID {
|
||||||
|
if cid, ok := ParseCartID(s); ok {
|
||||||
|
return cid
|
||||||
|
}
|
||||||
|
panic(fmt.Sprintf("invalid CartID: %s", s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DebugString returns a verbose description (for logging / diagnostics).
|
||||||
|
func (c CartID) DebugString() string {
|
||||||
|
return fmt.Sprintf("CartID(raw=%d txt=%s)", c.raw, c.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equal compares two CartIDs by raw value.
|
||||||
|
func (c CartID) Equal(other CartID) bool {
|
||||||
|
return c.raw == other.raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanonicalizeOrLegacy preserves legacy (non-base62) IDs without altering their
|
||||||
|
// textual form, avoiding the previous behavior where fallback hashing replaced
|
||||||
|
// the original string with a base62-encoded hash (which broke deterministic
|
||||||
|
// key derivation across mixed call paths).
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - s == "" -> generate new CartID (generatedNew = true, wasBase62 = true)
|
||||||
|
// - base62 ok -> return parsed CartID (generatedNew = false, wasBase62 = true)
|
||||||
|
// - otherwise -> treat as legacy: raw = hash(s), txt = original s
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
//
|
||||||
|
// cid - CartID (txt preserved for legacy inputs)
|
||||||
|
// generatedNew - true only when a brand new ID was created due to empty input
|
||||||
|
// wasBase62 - true if the input was already canonical base62 (or generated)
|
||||||
|
// err - only set if crypto/rand fails when generating a new ID
|
||||||
|
func CanonicalizeOrLegacy(s string) (cid CartID, generatedNew bool, wasBase62 bool, err error) {
|
||||||
|
if s == "" {
|
||||||
|
id, e := NewCartID()
|
||||||
|
if e != nil {
|
||||||
|
return CartID{}, false, false, e
|
||||||
|
}
|
||||||
|
return id, true, true, nil
|
||||||
|
}
|
||||||
|
if parsed, ok := ParseCartID(s); ok {
|
||||||
|
return parsed, false, true, nil
|
||||||
|
}
|
||||||
|
// Legacy path: keep original text so downstream legacy-to-key hashing
|
||||||
|
// (which uses the visible string) yields consistent keys across code paths.
|
||||||
|
hashCID := FallbackFromString(s)
|
||||||
|
// Preserve original textual form
|
||||||
|
hashCID.txt = s
|
||||||
|
return hashCID, false, false, nil
|
||||||
|
}
|
||||||
259
cart_id_test.go
Normal file
259
cart_id_test.go
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
mrand "math/rand"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEncodeDecodeBase62RoundTrip verifies encodeBase62/decodeBase62 are inverse.
|
||||||
|
func TestEncodeDecodeBase62RoundTrip(t *testing.T) {
|
||||||
|
mrand.Seed(42)
|
||||||
|
for i := 0; i < 1000; i++ {
|
||||||
|
// Random 64-bit value
|
||||||
|
v := mrand.Uint64()
|
||||||
|
s := encodeBase62(v)
|
||||||
|
dec, ok := decodeBase62(s)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("decodeBase62 failed for %d encoded=%s", v, s)
|
||||||
|
}
|
||||||
|
if dec != v {
|
||||||
|
t.Fatalf("round trip mismatch: have %d got %d (encoded=%s)", v, dec, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Explicit zero test
|
||||||
|
if s := encodeBase62(0); s != "0" {
|
||||||
|
t.Fatalf("expected encodeBase62(0) == \"0\", got %q", s)
|
||||||
|
}
|
||||||
|
if v, ok := decodeBase62("0"); !ok || v != 0 {
|
||||||
|
t.Fatalf("decodeBase62(0) unexpected result v=%d ok=%v", v, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewCartIDUniqueness generates a number of IDs and checks for duplicates.
|
||||||
|
func TestNewCartIDUniqueness(t *testing.T) {
|
||||||
|
const n = 10000
|
||||||
|
seen := make(map[string]struct{}, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
id, err := NewCartID()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCartID error: %v", err)
|
||||||
|
}
|
||||||
|
s := id.String()
|
||||||
|
if _, exists := seen[s]; exists {
|
||||||
|
t.Fatalf("duplicate CartID generated: %s", s)
|
||||||
|
}
|
||||||
|
seen[s] = struct{}{}
|
||||||
|
if id.IsZero() {
|
||||||
|
t.Fatalf("NewCartID returned zero value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseCartIDValidation tests parsing of valid and invalid base62 strings.
|
||||||
|
func TestParseCartIDValidation(t *testing.T) {
|
||||||
|
id, err := NewCartID()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCartID error: %v", err)
|
||||||
|
}
|
||||||
|
parsed, ok := ParseCartID(id.String())
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("ParseCartID failed for valid id %s", id)
|
||||||
|
}
|
||||||
|
if parsed.raw != id.raw {
|
||||||
|
t.Fatalf("parsed raw mismatch: %d vs %d", parsed.raw, id.raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := ParseCartID(""); ok {
|
||||||
|
t.Fatalf("expected empty string to be invalid")
|
||||||
|
}
|
||||||
|
// Invalid char ('-')
|
||||||
|
if _, ok := ParseCartID("abc-123"); ok {
|
||||||
|
t.Fatalf("expected invalid chars to fail parse")
|
||||||
|
}
|
||||||
|
// Overly long ( >16 )
|
||||||
|
if _, ok := ParseCartID("1234567890abcdefg"); ok {
|
||||||
|
t.Fatalf("expected overly long string to fail parse")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFallbackDeterminism ensures fallback hashing is deterministic.
|
||||||
|
func TestFallbackDeterminism(t *testing.T) {
|
||||||
|
inputs := []string{
|
||||||
|
"legacy-cart-1",
|
||||||
|
"legacy-cart-2",
|
||||||
|
"UPPER_lower_123",
|
||||||
|
"🚀unicode", // unicode bytes (will hash byte sequence)
|
||||||
|
}
|
||||||
|
for _, in := range inputs {
|
||||||
|
a := FallbackFromString(in)
|
||||||
|
b := FallbackFromString(in)
|
||||||
|
if a.raw != b.raw || a.String() != b.String() {
|
||||||
|
t.Fatalf("fallback mismatch for %q: %+v vs %+v", in, a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Distinct inputs should almost always differ; sample check
|
||||||
|
a := FallbackFromString("distinct-A")
|
||||||
|
b := FallbackFromString("distinct-B")
|
||||||
|
if a.raw == b.raw {
|
||||||
|
t.Fatalf("unexpected identical fallback hashes for distinct inputs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCanonicalizeIncomingBehavior covers main control flow branches.
|
||||||
|
func TestCanonicalizeIncomingBehavior(t *testing.T) {
|
||||||
|
// Empty => new id
|
||||||
|
id1, generated, err := CanonicalizeIncoming("")
|
||||||
|
if err != nil || !generated || id1.IsZero() {
|
||||||
|
t.Fatalf("CanonicalizeIncoming empty failed: id=%v gen=%v err=%v", id1, generated, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid base62 => parse; no generation
|
||||||
|
id2, gen2, err := CanonicalizeIncoming(id1.String())
|
||||||
|
if err != nil || gen2 || id2.raw != id1.raw {
|
||||||
|
t.Fatalf("CanonicalizeIncoming parse mismatch: id2=%v gen2=%v err=%v", id2, gen2, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy-like random containing invalid chars -> fallback
|
||||||
|
fallbackInput := "legacy\x00\x00padding"
|
||||||
|
id3, gen3, err := CanonicalizeIncoming(fallbackInput)
|
||||||
|
if err != nil || gen3 {
|
||||||
|
t.Fatalf("CanonicalizeIncoming fallback unexpected: id3=%v gen3=%v err=%v", id3, gen3, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic fallback
|
||||||
|
id4, _, _ := CanonicalizeIncoming(fallbackInput)
|
||||||
|
if id3.raw != id4.raw {
|
||||||
|
t.Fatalf("fallback canonicalization not deterministic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUpgradeLegacyCartId ensures mapping of old CartId is stable.
|
||||||
|
func TestUpgradeLegacyCartId(t *testing.T) {
|
||||||
|
var legacy CartId
|
||||||
|
copy(legacy[:], []byte("legacy-123456789")) // 15 bytes + padding
|
||||||
|
up1 := UpgradeLegacyCartId(legacy)
|
||||||
|
up2 := UpgradeLegacyCartId(legacy)
|
||||||
|
if up1.raw != up2.raw {
|
||||||
|
t.Fatalf("UpgradeLegacyCartId not deterministic: %v vs %v", up1, up2)
|
||||||
|
}
|
||||||
|
if up1.String() != up2.String() {
|
||||||
|
t.Fatalf("UpgradeLegacyCartId string mismatch: %s vs %s", up1, up2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkNewCartID gives a rough idea of generation cost.
|
||||||
|
func BenchmarkNewCartID(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
if _, err := NewCartID(); err != nil {
|
||||||
|
b.Fatalf("error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkEncodeBase62 measures encode speed in isolation.
|
||||||
|
func BenchmarkEncodeBase62(b *testing.B) {
|
||||||
|
// Random sample of values
|
||||||
|
samples := make([]uint64, 1024)
|
||||||
|
for i := range samples {
|
||||||
|
var buf [8]byte
|
||||||
|
if _, err := rand.Read(buf[:]); err != nil {
|
||||||
|
b.Fatalf("rand: %v", err)
|
||||||
|
}
|
||||||
|
samples[i] = binary.BigEndian.Uint64(buf[:])
|
||||||
|
}
|
||||||
|
b.ResetTimer()
|
||||||
|
var sink string
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
sink = encodeBase62(samples[i%len(samples)])
|
||||||
|
}
|
||||||
|
_ = sink
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkDecodeBase62 measures decode speed.
|
||||||
|
func BenchmarkDecodeBase62(b *testing.B) {
|
||||||
|
// Pre-encode
|
||||||
|
encoded := make([]string, 1024)
|
||||||
|
for i := range encoded {
|
||||||
|
encoded[i] = encodeBase62(uint64(i)<<32 | uint64(i))
|
||||||
|
}
|
||||||
|
b.ResetTimer()
|
||||||
|
var sum uint64
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
v, ok := decodeBase62(encoded[i%len(encoded)])
|
||||||
|
if !ok {
|
||||||
|
b.Fatalf("decode failed")
|
||||||
|
}
|
||||||
|
sum ^= v
|
||||||
|
}
|
||||||
|
_ = sum
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLookupNDeterminism (ring integration smoke test) ensures LookupN
|
||||||
|
// returns distinct hosts and stable ordering for a fixed ring.
|
||||||
|
func TestLookupNDeterminism(t *testing.T) {
|
||||||
|
rb := NewRingBuilder().WithEpoch(1).WithVnodesPerHost(8).WithHosts([]string{"a", "b", "c"})
|
||||||
|
ring := rb.Build()
|
||||||
|
if ring.Empty() {
|
||||||
|
t.Fatalf("expected non-empty ring")
|
||||||
|
}
|
||||||
|
id := MustNewCartID()
|
||||||
|
owners1 := ring.LookupN(id.Raw(), 3)
|
||||||
|
owners2 := ring.LookupN(id.Raw(), 3)
|
||||||
|
if len(owners1) != len(owners2) {
|
||||||
|
t.Fatalf("LookupN length mismatch")
|
||||||
|
}
|
||||||
|
for i := range owners1 {
|
||||||
|
if owners1[i].Host != owners2[i].Host {
|
||||||
|
t.Fatalf("LookupN ordering instability at %d: %v vs %v", i, owners1[i], owners2[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Distinct host constraint
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, v := range owners1 {
|
||||||
|
if _, ok := seen[v.Host]; ok {
|
||||||
|
t.Fatalf("duplicate host in LookupN result: %v", owners1)
|
||||||
|
}
|
||||||
|
seen[v.Host] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRingFingerprintChanges ensures fingerprint updates with membership changes.
|
||||||
|
func TestRingFingerprintChanges(t *testing.T) {
|
||||||
|
b1 := NewRingBuilder().WithEpoch(1).WithHosts([]string{"node1", "node2"})
|
||||||
|
r1 := b1.Build()
|
||||||
|
b2 := NewRingBuilder().WithEpoch(2).WithHosts([]string{"node1", "node2", "node3"})
|
||||||
|
r2 := b2.Build()
|
||||||
|
if r1.Fingerprint() == r2.Fingerprint() {
|
||||||
|
t.Fatalf("expected differing fingerprints after host set change")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRingDiffHosts verifies added/removed host detection.
|
||||||
|
func TestRingDiffHosts(t *testing.T) {
|
||||||
|
r1 := NewRingBuilder().WithEpoch(1).WithHosts([]string{"a", "b"}).Build()
|
||||||
|
r2 := NewRingBuilder().WithEpoch(2).WithHosts([]string{"b", "c"}).Build()
|
||||||
|
added, removed := r1.DiffHosts(r2)
|
||||||
|
if fmt.Sprintf("%v", added) != "[c]" {
|
||||||
|
t.Fatalf("expected added [c], got %v", added)
|
||||||
|
}
|
||||||
|
if fmt.Sprintf("%v", removed) != "[a]" {
|
||||||
|
t.Fatalf("expected removed [a], got %v", removed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRingLookupConsistency ensures direct Lookup and LookupID are aligned.
|
||||||
|
func TestRingLookupConsistency(t *testing.T) {
|
||||||
|
ring := NewRingBuilder().WithEpoch(1).WithHosts([]string{"alpha", "beta"}).WithVnodesPerHost(4).Build()
|
||||||
|
id, _ := ParseCartID("1")
|
||||||
|
if id.IsZero() {
|
||||||
|
t.Fatalf("expected parsed id non-zero")
|
||||||
|
}
|
||||||
|
v1 := ring.Lookup(id.Raw())
|
||||||
|
v2 := ring.LookupID(id)
|
||||||
|
if v1.Host != v2.Host || v1.Hash != v2.Hash {
|
||||||
|
t.Fatalf("Lookup vs LookupID mismatch: %+v vs %+v", v1, v2)
|
||||||
|
}
|
||||||
|
}
|
||||||
212
cart_state_mapper.go
Normal file
212
cart_state_mapper.go
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cart_state_mapper.go
|
||||||
|
//
|
||||||
|
// Utilities to translate between internal CartGrain state and the gRPC
|
||||||
|
// (typed) protobuf representation CartState. This replaces the previous
|
||||||
|
// JSON blob framing and enables type-safe replies over gRPC, as well as
|
||||||
|
// internal reuse for HTTP handlers without an extra marshal / unmarshal
|
||||||
|
// hop (you can marshal CartState directly for JSON responses if desired).
|
||||||
|
//
|
||||||
|
// Only the one‑way mapping (CartGrain -> CartState) is strictly required
|
||||||
|
// for mutation / state replies. A reverse helper is included in case
|
||||||
|
// future features (e.g. snapshot import, replay, or migration) need it.
|
||||||
|
|
||||||
|
// ToCartState converts the in‑memory CartGrain into a protobuf CartState.
|
||||||
|
func ToCartState(c *CartGrain) *messages.CartState {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]*messages.CartItemState, 0, len(c.Items))
|
||||||
|
for _, it := range c.Items {
|
||||||
|
if it == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
itemDiscountPerUnit := max(0, it.OrgPrice-it.Price)
|
||||||
|
itemTotalDiscount := itemDiscountPerUnit * int64(it.Quantity)
|
||||||
|
|
||||||
|
items = append(items, &messages.CartItemState{
|
||||||
|
Id: int64(it.Id),
|
||||||
|
ItemId: int64(it.ItemId),
|
||||||
|
Sku: it.Sku,
|
||||||
|
Name: it.Name,
|
||||||
|
Price: it.Price,
|
||||||
|
Qty: int32(it.Quantity),
|
||||||
|
TotalPrice: it.TotalPrice,
|
||||||
|
TotalTax: it.TotalTax,
|
||||||
|
OrgPrice: it.OrgPrice,
|
||||||
|
TaxRate: int32(it.TaxRate),
|
||||||
|
TotalDiscount: itemTotalDiscount,
|
||||||
|
Brand: it.Brand,
|
||||||
|
Category: it.Category,
|
||||||
|
Category2: it.Category2,
|
||||||
|
Category3: it.Category3,
|
||||||
|
Category4: it.Category4,
|
||||||
|
Category5: it.Category5,
|
||||||
|
Image: it.Image,
|
||||||
|
Type: it.ArticleType,
|
||||||
|
SellerId: it.SellerId,
|
||||||
|
SellerName: it.SellerName,
|
||||||
|
Disclaimer: it.Disclaimer,
|
||||||
|
Outlet: deref(it.Outlet),
|
||||||
|
StoreId: deref(it.StoreId),
|
||||||
|
Stock: int32(it.Stock),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
deliveries := make([]*messages.DeliveryState, 0, len(c.Deliveries))
|
||||||
|
for _, d := range c.Deliveries {
|
||||||
|
if d == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
itemIds := make([]int64, 0, len(d.Items))
|
||||||
|
for _, id := range d.Items {
|
||||||
|
itemIds = append(itemIds, int64(id))
|
||||||
|
}
|
||||||
|
var pp *messages.PickupPoint
|
||||||
|
if d.PickupPoint != nil {
|
||||||
|
// Copy to avoid accidental shared mutation (proto points are fine but explicit).
|
||||||
|
pp = &messages.PickupPoint{
|
||||||
|
Id: d.PickupPoint.Id,
|
||||||
|
Name: d.PickupPoint.Name,
|
||||||
|
Address: d.PickupPoint.Address,
|
||||||
|
City: d.PickupPoint.City,
|
||||||
|
Zip: d.PickupPoint.Zip,
|
||||||
|
Country: d.PickupPoint.Country,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deliveries = append(deliveries, &messages.DeliveryState{
|
||||||
|
Id: int64(d.Id),
|
||||||
|
Provider: d.Provider,
|
||||||
|
Price: d.Price,
|
||||||
|
Items: itemIds,
|
||||||
|
PickupPoint: pp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &messages.CartState{
|
||||||
|
Id: c.Id.String(),
|
||||||
|
Items: items,
|
||||||
|
TotalPrice: c.TotalPrice,
|
||||||
|
TotalTax: c.TotalTax,
|
||||||
|
TotalDiscount: c.TotalDiscount,
|
||||||
|
Deliveries: deliveries,
|
||||||
|
PaymentInProgress: c.PaymentInProgress,
|
||||||
|
OrderReference: c.OrderReference,
|
||||||
|
PaymentStatus: c.PaymentStatus,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromCartState merges a protobuf CartState into an existing CartGrain.
|
||||||
|
// This is optional and primarily useful for snapshot import or testing.
|
||||||
|
func FromCartState(cs *messages.CartState, g *CartGrain) *CartGrain {
|
||||||
|
if cs == nil {
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
if g == nil {
|
||||||
|
g = &CartGrain{}
|
||||||
|
}
|
||||||
|
g.Id = ToCartId(cs.Id)
|
||||||
|
g.TotalPrice = cs.TotalPrice
|
||||||
|
g.TotalTax = cs.TotalTax
|
||||||
|
g.TotalDiscount = cs.TotalDiscount
|
||||||
|
g.PaymentInProgress = cs.PaymentInProgress
|
||||||
|
g.OrderReference = cs.OrderReference
|
||||||
|
g.PaymentStatus = cs.PaymentStatus
|
||||||
|
|
||||||
|
// Items
|
||||||
|
g.Items = g.Items[:0]
|
||||||
|
for _, it := range cs.Items {
|
||||||
|
if it == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outlet := toPtr(it.Outlet)
|
||||||
|
storeId := toPtr(it.StoreId)
|
||||||
|
g.Items = append(g.Items, &CartItem{
|
||||||
|
Id: int(it.Id),
|
||||||
|
ItemId: int(it.ItemId),
|
||||||
|
Sku: it.Sku,
|
||||||
|
Name: it.Name,
|
||||||
|
Price: it.Price,
|
||||||
|
Quantity: int(it.Qty),
|
||||||
|
TotalPrice: it.TotalPrice,
|
||||||
|
TotalTax: it.TotalTax,
|
||||||
|
OrgPrice: it.OrgPrice,
|
||||||
|
TaxRate: int(it.TaxRate),
|
||||||
|
Brand: it.Brand,
|
||||||
|
Category: it.Category,
|
||||||
|
Category2: it.Category2,
|
||||||
|
Category3: it.Category3,
|
||||||
|
Category4: it.Category4,
|
||||||
|
Category5: it.Category5,
|
||||||
|
Image: it.Image,
|
||||||
|
ArticleType: it.Type,
|
||||||
|
SellerId: it.SellerId,
|
||||||
|
SellerName: it.SellerName,
|
||||||
|
Disclaimer: it.Disclaimer,
|
||||||
|
Outlet: outlet,
|
||||||
|
StoreId: storeId,
|
||||||
|
Stock: StockStatus(it.Stock),
|
||||||
|
// Tax, TaxRate already set via Price / Totals if needed
|
||||||
|
})
|
||||||
|
if it.Id > int64(g.lastItemId) {
|
||||||
|
g.lastItemId = int(it.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliveries
|
||||||
|
g.Deliveries = g.Deliveries[:0]
|
||||||
|
for _, d := range cs.Deliveries {
|
||||||
|
if d == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
intIds := make([]int, 0, len(d.Items))
|
||||||
|
for _, id := range d.Items {
|
||||||
|
intIds = append(intIds, int(id))
|
||||||
|
}
|
||||||
|
var pp *messages.PickupPoint
|
||||||
|
if d.PickupPoint != nil {
|
||||||
|
pp = &messages.PickupPoint{
|
||||||
|
Id: d.PickupPoint.Id,
|
||||||
|
Name: d.PickupPoint.Name,
|
||||||
|
Address: d.PickupPoint.Address,
|
||||||
|
City: d.PickupPoint.City,
|
||||||
|
Zip: d.PickupPoint.Zip,
|
||||||
|
Country: d.PickupPoint.Country,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.Deliveries = append(g.Deliveries, &CartDelivery{
|
||||||
|
Id: int(d.Id),
|
||||||
|
Provider: d.Provider,
|
||||||
|
Price: d.Price,
|
||||||
|
Items: intIds,
|
||||||
|
PickupPoint: pp,
|
||||||
|
})
|
||||||
|
if d.Id > int64(g.lastDeliveryId) {
|
||||||
|
g.lastDeliveryId = int(d.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to safely de-reference optional string pointers to value or "".
|
||||||
|
func deref(p *string) string {
|
||||||
|
if p == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
func toPtr(s string) *string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
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
|
||||||
BIN
data/1.prot
BIN
data/1.prot
Binary file not shown.
@@ -1,3 +1,12 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: klarna-api-credentials
|
||||||
|
data:
|
||||||
|
username: ZjQzZDY3YjEtNzA2Yy00NTk2LTliNTgtYjg1YjU2NDEwZTUw
|
||||||
|
password: a2xhcm5hX3Rlc3RfYXBpX0trUWhWVE5yYVZsV2FsTnhTRVp3Y1ZSSFF5UkVNRmxyY25Kd1AxSndQMGdzWmpRelpEWTNZakV0TnpBMll5MDBOVGsyTFRsaU5UZ3RZamcxWWpVMk5ERXdaVFV3TERFc2JUUkNjRFpWU1RsTllsSk1aMlEyVEc4MmRVODNZMkozUlRaaFdEZDViV3AwYkhGV1JqTjVNQzlaYXow
|
||||||
|
type: Opaque
|
||||||
|
---
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
@@ -48,8 +57,6 @@ spec:
|
|||||||
name: web
|
name: web
|
||||||
- containerPort: 1337
|
- containerPort: 1337
|
||||||
name: rpc
|
name: rpc
|
||||||
- containerPort: 1338
|
|
||||||
name: quorum
|
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /livez
|
path: /livez
|
||||||
@@ -75,10 +82,24 @@ spec:
|
|||||||
env:
|
env:
|
||||||
- name: TZ
|
- name: TZ
|
||||||
value: "Europe/Stockholm"
|
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
|
- name: POD_IP
|
||||||
valueFrom:
|
valueFrom:
|
||||||
fieldRef:
|
fieldRef:
|
||||||
fieldPath: status.podIP
|
fieldPath: status.podIP
|
||||||
|
- name: AMQP_URL
|
||||||
|
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
|
||||||
|
# - name: BASE_URL
|
||||||
|
# value: "https://s10n-no.tornberg.me"
|
||||||
- name: POD_NAME
|
- name: POD_NAME
|
||||||
valueFrom:
|
valueFrom:
|
||||||
fieldRef:
|
fieldRef:
|
||||||
@@ -138,8 +159,6 @@ spec:
|
|||||||
name: web
|
name: web
|
||||||
- containerPort: 1337
|
- containerPort: 1337
|
||||||
name: rpc
|
name: rpc
|
||||||
- containerPort: 1338
|
|
||||||
name: quorum
|
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /livez
|
path: /livez
|
||||||
@@ -165,10 +184,24 @@ spec:
|
|||||||
env:
|
env:
|
||||||
- name: TZ
|
- name: TZ
|
||||||
value: "Europe/Stockholm"
|
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
|
- name: POD_IP
|
||||||
valueFrom:
|
valueFrom:
|
||||||
fieldRef:
|
fieldRef:
|
||||||
fieldPath: status.podIP
|
fieldPath: status.podIP
|
||||||
|
- name: AMQP_URL
|
||||||
|
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
|
||||||
|
# - name: BASE_URL
|
||||||
|
# value: "https://s10n-no.tornberg.me"
|
||||||
- name: POD_NAME
|
- name: POD_NAME
|
||||||
valueFrom:
|
valueFrom:
|
||||||
fieldRef:
|
fieldRef:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
apiVersion: autoscaling/v1
|
apiVersion: autoscaling/v2
|
||||||
kind: HorizontalPodAutoscaler
|
kind: HorizontalPodAutoscaler
|
||||||
metadata:
|
metadata:
|
||||||
name: cart-scaler-amd
|
name: cart-scaler-amd
|
||||||
@@ -9,9 +9,47 @@ spec:
|
|||||||
name: cart-actor-x86
|
name: cart-actor-x86
|
||||||
minReplicas: 3
|
minReplicas: 3
|
||||||
maxReplicas: 9
|
maxReplicas: 9
|
||||||
targetCPUUtilizationPercentage: 30
|
behavior:
|
||||||
|
scaleUp:
|
||||||
|
stabilizationWindowSeconds: 60
|
||||||
|
policies:
|
||||||
|
- type: Percent
|
||||||
|
value: 100
|
||||||
|
periodSeconds: 60
|
||||||
|
scaleDown:
|
||||||
|
stabilizationWindowSeconds: 180
|
||||||
|
policies:
|
||||||
|
- type: Percent
|
||||||
|
value: 50
|
||||||
|
periodSeconds: 60
|
||||||
|
metrics:
|
||||||
|
- type: Resource
|
||||||
|
resource:
|
||||||
|
name: cpu
|
||||||
|
target:
|
||||||
|
type: Utilization
|
||||||
|
averageUtilization: 50
|
||||||
|
# Future custom metric (example):
|
||||||
|
# - type: Pods
|
||||||
|
# pods:
|
||||||
|
# metric:
|
||||||
|
# name: cart_mutations_per_second
|
||||||
|
# target:
|
||||||
|
# type: AverageValue
|
||||||
|
# averageValue: "15"
|
||||||
|
# - type: Object
|
||||||
|
# object:
|
||||||
|
# describedObject:
|
||||||
|
# apiVersion: networking.k8s.io/v1
|
||||||
|
# kind: Ingress
|
||||||
|
# name: cart-ingress
|
||||||
|
# metric:
|
||||||
|
# name: http_requests_per_second
|
||||||
|
# target:
|
||||||
|
# type: Value
|
||||||
|
# value: "100"
|
||||||
---
|
---
|
||||||
apiVersion: autoscaling/v1
|
apiVersion: autoscaling/v2
|
||||||
kind: HorizontalPodAutoscaler
|
kind: HorizontalPodAutoscaler
|
||||||
metadata:
|
metadata:
|
||||||
name: cart-scaler-arm
|
name: cart-scaler-arm
|
||||||
@@ -22,4 +60,42 @@ spec:
|
|||||||
name: cart-actor-arm64
|
name: cart-actor-arm64
|
||||||
minReplicas: 3
|
minReplicas: 3
|
||||||
maxReplicas: 9
|
maxReplicas: 9
|
||||||
targetCPUUtilizationPercentage: 30
|
behavior:
|
||||||
|
scaleUp:
|
||||||
|
stabilizationWindowSeconds: 60
|
||||||
|
policies:
|
||||||
|
- type: Percent
|
||||||
|
value: 100
|
||||||
|
periodSeconds: 60
|
||||||
|
scaleDown:
|
||||||
|
stabilizationWindowSeconds: 180
|
||||||
|
policies:
|
||||||
|
- type: Percent
|
||||||
|
value: 50
|
||||||
|
periodSeconds: 60
|
||||||
|
metrics:
|
||||||
|
- type: Resource
|
||||||
|
resource:
|
||||||
|
name: cpu
|
||||||
|
target:
|
||||||
|
type: Utilization
|
||||||
|
averageUtilization: 50
|
||||||
|
# Future custom metric (example):
|
||||||
|
# - type: Pods
|
||||||
|
# pods:
|
||||||
|
# metric:
|
||||||
|
# name: cart_mutations_per_second
|
||||||
|
# target:
|
||||||
|
# type: AverageValue
|
||||||
|
# averageValue: "15"
|
||||||
|
# - type: Object
|
||||||
|
# object:
|
||||||
|
# describedObject:
|
||||||
|
# apiVersion: networking.k8s.io/v1
|
||||||
|
# kind: Ingress
|
||||||
|
# name: cart-ingress
|
||||||
|
# metric:
|
||||||
|
# name: http_requests_per_second
|
||||||
|
# target:
|
||||||
|
# type: Value
|
||||||
|
# value: "100"
|
||||||
|
|||||||
84
discarded-host.go
Normal file
84
discarded-host.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DiscardedHost struct {
|
||||||
|
Host string
|
||||||
|
Tries int
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiscardedHostHandler struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
port int
|
||||||
|
hosts []*DiscardedHost
|
||||||
|
onConnection *func(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DiscardedHostHandler) run() {
|
||||||
|
for range time.Tick(time.Second) {
|
||||||
|
d.mu.RLock()
|
||||||
|
lst := make([]*DiscardedHost, 0, len(d.hosts))
|
||||||
|
for _, host := range d.hosts {
|
||||||
|
if host.Tries >= 0 && host.Tries < 5 {
|
||||||
|
go d.testConnection(host)
|
||||||
|
lst = append(lst, host)
|
||||||
|
} else {
|
||||||
|
if host.Tries > 0 {
|
||||||
|
log.Printf("Host %s discarded after %d tries", host.Host, host.Tries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.mu.RUnlock()
|
||||||
|
d.mu.Lock()
|
||||||
|
d.hosts = lst
|
||||||
|
d.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DiscardedHostHandler) testConnection(host *DiscardedHost) {
|
||||||
|
addr := fmt.Sprintf("%s:%d", host.Host, d.port)
|
||||||
|
conn, err := net.Dial("tcp", addr)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
host.Tries++
|
||||||
|
if host.Tries >= 5 {
|
||||||
|
// Exceeded retry threshold; will be dropped by run loop.
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
conn.Close()
|
||||||
|
if d.onConnection != nil {
|
||||||
|
fn := *d.onConnection
|
||||||
|
fn(host.Host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDiscardedHostHandler(port int) *DiscardedHostHandler {
|
||||||
|
ret := &DiscardedHostHandler{
|
||||||
|
hosts: make([]*DiscardedHost, 0),
|
||||||
|
port: port,
|
||||||
|
}
|
||||||
|
go ret.run()
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DiscardedHostHandler) SetReconnectHandler(fn func(string)) {
|
||||||
|
d.onConnection = &fn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DiscardedHostHandler) AppendHost(host string) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
log.Printf("Adding host %s to retry list", host)
|
||||||
|
d.hosts = append(d.hosts, &DiscardedHost{
|
||||||
|
Host: host,
|
||||||
|
Tries: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
95
discovery.go
95
discovery.go
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
v1 "k8s.io/api/core/v1"
|
v1 "k8s.io/api/core/v1"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
@@ -75,3 +76,97 @@ func NewK8sDiscovery(client *kubernetes.Clientset) *K8sDiscovery {
|
|||||||
client: client,
|
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) {
|
func TestDiscovery(t *testing.T) {
|
||||||
config, err := clientcmd.BuildConfigFromFlags("", "/Users/mats/.kube/config")
|
config, err := clientcmd.BuildConfigFromFlags("", "/home/mats/.kube/config")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Error building config: %v", err)
|
t.Errorf("Error building config: %v", err)
|
||||||
}
|
}
|
||||||
@@ -28,7 +28,7 @@ func TestDiscovery(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestWatch(t *testing.T) {
|
func TestWatch(t *testing.T) {
|
||||||
config, err := clientcmd.BuildConfigFromFlags("", "/Users/mats/.kube/config")
|
config, err := clientcmd.BuildConfigFromFlags("", "/home/mats/.kube/config")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Error building config: %v", err)
|
t.Errorf("Error building config: %v", err)
|
||||||
}
|
}
|
||||||
@@ -41,8 +41,10 @@ func TestWatch(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Error watching: %v", err)
|
t.Errorf("Error watching: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ch:
|
case m := <-ch:
|
||||||
|
t.Logf("Received watch %v", m)
|
||||||
case <-time.After(5 * time.Second):
|
case <-time.After(5 * time.Second):
|
||||||
t.Errorf("Timeout waiting for watch")
|
t.Errorf("Timeout waiting for watch")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/gob"
|
"encoding/gob"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -23,60 +22,19 @@ func NewDiskStorage(stateFile string) (*DiskStorage, error) {
|
|||||||
return ret, err
|
return ret, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveMessages(messages []StorableMessage, id CartId) error {
|
func saveMessages(_ interface{}, _ CartId) error {
|
||||||
|
// No-op: legacy event log persistence removed in oneof refactor.
|
||||||
if len(messages) == 0 {
|
|
||||||
return nil
|
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()
|
|
||||||
|
|
||||||
for _, m := range messages {
|
|
||||||
err := m.Write(file)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCartPath(id string) string {
|
func getCartPath(id string) string {
|
||||||
return fmt.Sprintf("data/%s.prot", id)
|
return fmt.Sprintf("data/%s.prot", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadMessages(grain Grain, id CartId) error {
|
func loadMessages(_ Grain, _ CartId) error {
|
||||||
var err error
|
// No-op: legacy replay removed in oneof refactor.
|
||||||
path := getCartPath(id.String())
|
|
||||||
|
|
||||||
file, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil
|
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 {
|
func (s *DiskStorage) saveState() error {
|
||||||
tmpFile := s.stateFile + "_tmp"
|
tmpFile := s.stateFile + "_tmp"
|
||||||
@@ -103,15 +61,8 @@ func (s *DiskStorage) loadState() error {
|
|||||||
return gob.NewDecoder(file).Decode(&s.LastSaves)
|
return gob.NewDecoder(file).Decode(&s.LastSaves)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DiskStorage) Store(id CartId, grain *CartGrain) error {
|
func (s *DiskStorage) Store(id CartId, _ *CartGrain) error {
|
||||||
lastSavedMessage, ok := s.LastSaves[id]
|
// With the removal of the legacy message log, we only update the timestamp.
|
||||||
if ok && lastSavedMessage > grain.GetLastChange() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
err := saveMessages(grain.GetStorageMessage(lastSavedMessage), id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
ts := time.Now().Unix()
|
ts := time.Now().Unix()
|
||||||
s.LastSaves[id] = ts
|
s.LastSaves[id] = ts
|
||||||
s.lastSave = ts
|
s.lastSave = ts
|
||||||
|
|||||||
94
go.mod
94
go.mod
@@ -1,57 +1,73 @@
|
|||||||
module git.tornberg.me/go-cart-actor
|
module git.tornberg.me/go-cart-actor
|
||||||
|
|
||||||
go 1.23.3
|
go 1.25.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/prometheus/client_golang v1.20.5
|
github.com/google/uuid v1.6.0
|
||||||
google.golang.org/protobuf v1.34.2
|
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548
|
||||||
k8s.io/api v0.31.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
k8s.io/apimachinery v0.31.2
|
github.com/rabbitmq/amqp091-go v1.10.0
|
||||||
k8s.io/client-go v0.31.2
|
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 (
|
require (
|
||||||
|
github.com/RoaringBitmap/roaring/v2 v2.10.0 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/bits-and-blooms/bitset v1.24.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
|
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.2 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.19.6 // indirect
|
github.com/go-openapi/jsonpointer v0.22.1 // indirect
|
||||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
github.com/go-openapi/jsonreference v0.21.2 // indirect
|
||||||
github.com/go-openapi/swag v0.22.4 // 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/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/golang/protobuf v1.5.4 // indirect
|
github.com/google/gnostic-models v0.7.0 // indirect
|
||||||
github.com/google/gnostic-models v0.6.8 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/google/go-cmp v0.6.0 // indirect
|
github.com/gorilla/schema v1.4.1 // indirect
|
||||||
github.com/google/gofuzz v1.2.0 // indirect
|
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
|
||||||
github.com/imdario/mergo v0.3.6 // indirect
|
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/compress v1.17.9 // indirect
|
|
||||||
github.com/mailru/easyjson v0.7.7 // indirect
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||||
|
github.com/mschoch/smat v0.2.0 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/prometheus/client_model v0.6.1 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/common v0.55.0 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/procfs v0.15.1 // indirect
|
github.com/prometheus/common v0.67.1 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // 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
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
golang.org/x/net v0.26.0 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
golang.org/x/oauth2 v0.21.0 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/sys v0.22.0 // indirect
|
golang.org/x/net v0.46.0 // indirect
|
||||||
golang.org/x/term v0.21.0 // indirect
|
golang.org/x/oauth2 v0.32.0 // indirect
|
||||||
golang.org/x/text v0.16.0 // indirect
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
golang.org/x/time v0.3.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/inf.v0 v0.9.1 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
k8s.io/klog/v2 v2.130.1 // indirect
|
k8s.io/klog/v2 v2.130.1 // indirect
|
||||||
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect
|
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
|
||||||
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect
|
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
||||||
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
|
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||||
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
|
sigs.k8s.io/randfill v1.0.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
|
||||||
)
|
)
|
||||||
|
|||||||
243
go.sum
243
go.sum
@@ -1,105 +1,144 @@
|
|||||||
|
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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||||
|
github.com/bits-and-blooms/bitset v1.24.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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
|
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
|
||||||
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
github.com/fxamacker/cbor/v2 v2.9.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.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
|
github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
|
||||||
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
|
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
|
||||||
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
|
||||||
github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU=
|
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
|
||||||
github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
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 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
|
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
||||||
github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
|
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
|
||||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||||
github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af h1:kmjWCqn2qkEml422C2Rrd27c3VGxi6a/6HNq8QmHRKM=
|
|
||||||
github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28=
|
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
||||||
github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
|
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548 h1:lkxVz5lNPlU78El49cx1c3Rxo/bp7Gbzp2BjSRFgg1U=
|
||||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
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-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||||
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
|
||||||
|
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA=
|
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
|
||||||
github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To=
|
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
||||||
github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
|
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
|
||||||
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
|
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||||
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI=
|
||||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q=
|
||||||
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||||
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
||||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.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-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
@@ -109,66 +148,72 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
|
|||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
|
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||||
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
|
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||||
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
|
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||||
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
|
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff h1:A90eA31Wq6HOMIQlLfzFwzqGKBTuaVztYu/g8sn+8Zc=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||||
|
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||||
|
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||||
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
|
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
|
||||||
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
k8s.io/api v0.31.2 h1:3wLBbL5Uom/8Zy98GRPXpJ254nEFpl+hwndmk9RwmL0=
|
k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM=
|
||||||
k8s.io/api v0.31.2/go.mod h1:bWmGvrGPssSK1ljmLzd3pwCQ9MgoTsRCuK35u6SygUk=
|
k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk=
|
||||||
k8s.io/apimachinery v0.31.2 h1:i4vUt2hPK56W6mlT7Ry+AO8eEsyxMD1U44NR22CLTYw=
|
k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4=
|
||||||
k8s.io/apimachinery v0.31.2/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
|
k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
|
||||||
k8s.io/client-go v0.31.2 h1:Y2F4dxU5d3AQj+ybwSMqQnpZH9F30//1ObxOKlTI9yc=
|
k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY=
|
||||||
k8s.io/client-go v0.31.2/go.mod h1:NPa74jSVR/+eez2dFsEIHNa+3o09vtNaWwWwb1qSxSs=
|
k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8=
|
||||||
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||||
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||||
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag=
|
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
|
||||||
k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98=
|
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
|
||||||
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A=
|
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
|
||||||
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||||
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
|
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
|
||||||
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
|
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||||
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4=
|
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
||||||
sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08=
|
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||||
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
|
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
|
||||||
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
|
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=
|
||||||
|
|||||||
170
grain-pool.go
170
grain-pool.go
@@ -1,7 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -11,6 +10,23 @@ import (
|
|||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// grain-pool.go
|
||||||
|
//
|
||||||
|
// Migration Note:
|
||||||
|
// This file has been migrated to use uint64 cart keys internally (derived
|
||||||
|
// from the new CartID base62 representation). For backward compatibility,
|
||||||
|
// a deprecated legacy map keyed by CartId is maintained so existing code
|
||||||
|
// that directly indexes pool.grains with a CartId continues to compile
|
||||||
|
// until the full refactor across SyncedPool / remoteIndex is completed.
|
||||||
|
//
|
||||||
|
// Authoritative storage: grains (map[uint64]*CartGrain)
|
||||||
|
// Legacy compatibility: grainsLegacy (map[CartId]*CartGrain) - kept in sync.
|
||||||
|
//
|
||||||
|
// Once all external usages are updated to rely on helper accessors,
|
||||||
|
// grainsLegacy can be removed.
|
||||||
|
//
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
var (
|
var (
|
||||||
poolGrains = promauto.NewGauge(prometheus.GaugeOpts{
|
poolGrains = promauto.NewGauge(prometheus.GaugeOpts{
|
||||||
Name: "cart_grains_in_pool",
|
Name: "cart_grains_in_pool",
|
||||||
@@ -26,49 +42,71 @@ var (
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// GrainPool interface remains legacy-compatible.
|
||||||
type GrainPool interface {
|
type GrainPool interface {
|
||||||
Process(id CartId, messages ...Message) (*CallResult, error)
|
Apply(id CartId, mutation interface{}) (*CartGrain, error)
|
||||||
Get(id CartId) (*CallResult, error)
|
Get(id CartId) (*CartGrain, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ttl keeps expiry info
|
||||||
type Ttl struct {
|
type Ttl struct {
|
||||||
Expires time.Time
|
Expires time.Time
|
||||||
Grain *CartGrain
|
Grain *CartGrain
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GrainLocalPool now stores grains keyed by uint64 (CartKey).
|
||||||
type GrainLocalPool struct {
|
type GrainLocalPool struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
grains map[CartId]*CartGrain
|
grains map[uint64]*CartGrain // authoritative only
|
||||||
expiry []Ttl
|
expiry []Ttl
|
||||||
spawn func(id CartId) (*CartGrain, error)
|
spawn func(id CartId) (*CartGrain, error)
|
||||||
Ttl time.Duration
|
Ttl time.Duration
|
||||||
PoolSize int
|
PoolSize int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewGrainLocalPool constructs a new pool.
|
||||||
func NewGrainLocalPool(size int, ttl time.Duration, spawn func(id CartId) (*CartGrain, error)) *GrainLocalPool {
|
func NewGrainLocalPool(size int, ttl time.Duration, spawn func(id CartId) (*CartGrain, error)) *GrainLocalPool {
|
||||||
|
|
||||||
ret := &GrainLocalPool{
|
ret := &GrainLocalPool{
|
||||||
spawn: spawn,
|
spawn: spawn,
|
||||||
grains: make(map[CartId]*CartGrain),
|
grains: make(map[uint64]*CartGrain),
|
||||||
expiry: make([]Ttl, 0),
|
expiry: make([]Ttl, 0),
|
||||||
Ttl: ttl,
|
Ttl: ttl,
|
||||||
PoolSize: size,
|
PoolSize: size,
|
||||||
}
|
}
|
||||||
|
|
||||||
cartPurge := time.NewTicker(time.Minute)
|
cartPurge := time.NewTicker(time.Minute)
|
||||||
go func() {
|
go func() {
|
||||||
<-cartPurge.C
|
for range cartPurge.C {
|
||||||
ret.Purge()
|
ret.Purge()
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// keyFromCartId derives the uint64 key from a legacy CartId deterministically.
|
||||||
|
func keyFromCartId(id CartId) uint64 {
|
||||||
|
return LegacyToCartKey(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeGrain indexes a grain in both maps.
|
||||||
|
func (p *GrainLocalPool) storeGrain(id CartId, g *CartGrain) {
|
||||||
|
k := keyFromCartId(id)
|
||||||
|
p.grains[k] = g
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteGrain removes a grain from both maps.
|
||||||
|
func (p *GrainLocalPool) deleteGrain(id CartId) {
|
||||||
|
k := keyFromCartId(id)
|
||||||
|
delete(p.grains, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAvailable pre-populates placeholder entries (legacy signature).
|
||||||
func (p *GrainLocalPool) SetAvailable(availableWithLastChangeUnix map[CartId]int64) {
|
func (p *GrainLocalPool) SetAvailable(availableWithLastChangeUnix map[CartId]int64) {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
for id := range availableWithLastChangeUnix {
|
for id := range availableWithLastChangeUnix {
|
||||||
if _, ok := p.grains[id]; !ok {
|
k := keyFromCartId(id)
|
||||||
p.grains[id] = nil
|
if _, ok := p.grains[k]; !ok {
|
||||||
|
p.grains[k] = nil
|
||||||
p.expiry = append(p.expiry, Ttl{
|
p.expiry = append(p.expiry, Ttl{
|
||||||
Expires: time.Now().Add(p.Ttl),
|
Expires: time.Now().Add(p.Ttl),
|
||||||
Grain: nil,
|
Grain: nil,
|
||||||
@@ -77,13 +115,19 @@ func (p *GrainLocalPool) SetAvailable(availableWithLastChangeUnix map[CartId]int
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Purge removes expired grains.
|
||||||
func (p *GrainLocalPool) Purge() {
|
func (p *GrainLocalPool) Purge() {
|
||||||
lastChangeTime := time.Now().Add(-p.Ttl)
|
lastChangeTime := time.Now().Add(-p.Ttl)
|
||||||
keepChanged := lastChangeTime.Unix()
|
keepChanged := lastChangeTime.Unix()
|
||||||
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
for i := 0; i < len(p.expiry); i++ {
|
for i := 0; i < len(p.expiry); i++ {
|
||||||
item := p.expiry[i]
|
item := p.expiry[i]
|
||||||
|
if item.Grain == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if item.Expires.Before(time.Now()) {
|
if item.Expires.Before(time.Now()) {
|
||||||
if item.Grain.GetLastChange() > keepChanged {
|
if item.Grain.GetLastChange() > keepChanged {
|
||||||
log.Printf("Expired item %s changed, keeping", item.Grain.GetId())
|
log.Printf("Expired item %s changed, keeping", item.Grain.GetId())
|
||||||
@@ -91,12 +135,12 @@ func (p *GrainLocalPool) Purge() {
|
|||||||
p.expiry = append(p.expiry[:i], p.expiry[i+1:]...)
|
p.expiry = append(p.expiry[:i], p.expiry[i+1:]...)
|
||||||
p.expiry = append(p.expiry, item)
|
p.expiry = append(p.expiry, item)
|
||||||
} else {
|
} else {
|
||||||
|
// move last to end (noop)
|
||||||
p.expiry = append(p.expiry[:i], item)
|
p.expiry = append(p.expiry[:i], item)
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Item %s expired", item.Grain.GetId())
|
log.Printf("Item %s expired", item.Grain.GetId())
|
||||||
delete(p.grains, item.Grain.GetId())
|
p.deleteGrain(item.Grain.GetId())
|
||||||
if i < len(p.expiry)-1 {
|
if i < len(p.expiry)-1 {
|
||||||
p.expiry = append(p.expiry[:i], p.expiry[i+1:]...)
|
p.expiry = append(p.expiry[:i], p.expiry[i+1:]...)
|
||||||
} else {
|
} else {
|
||||||
@@ -109,56 +153,92 @@ func (p *GrainLocalPool) Purge() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetGrains returns a legacy view of grains (copy) for compatibility.
|
||||||
func (p *GrainLocalPool) GetGrains() map[CartId]*CartGrain {
|
func (p *GrainLocalPool) GetGrains() map[CartId]*CartGrain {
|
||||||
return p.grains
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *GrainLocalPool) GetGrain(id CartId) (*CartGrain, error) {
|
|
||||||
var err error
|
|
||||||
p.mu.RLock()
|
p.mu.RLock()
|
||||||
defer p.mu.RUnlock()
|
defer p.mu.RUnlock()
|
||||||
grain, ok := p.grains[id]
|
out := make(map[CartId]*CartGrain, len(p.grains))
|
||||||
grainLookups.Inc()
|
for _, g := range p.grains {
|
||||||
if grain == nil || !ok {
|
if g != nil {
|
||||||
if len(p.grains) >= p.PoolSize {
|
out[g.GetId()] = g
|
||||||
if p.expiry[0].Expires.Before(time.Now()) {
|
|
||||||
delete(p.grains, p.expiry[0].Grain.GetId())
|
|
||||||
p.expiry = p.expiry[1:]
|
|
||||||
} else {
|
|
||||||
return nil, fmt.Errorf("pool is full")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
grain, err = p.spawn(id)
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
p.grains[id] = grain
|
// statsUpdate updates Prometheus gauges asynchronously.
|
||||||
}
|
func (p *GrainLocalPool) statsUpdate() {
|
||||||
go func() {
|
go func(size int) {
|
||||||
l := float64(len(p.grains))
|
l := float64(size)
|
||||||
ps := float64(p.PoolSize)
|
ps := float64(p.PoolSize)
|
||||||
poolUsage.Set(l / ps)
|
poolUsage.Set(l / ps)
|
||||||
poolGrains.Set(l)
|
poolGrains.Set(l)
|
||||||
poolSize.Set(ps)
|
poolSize.Set(ps)
|
||||||
}()
|
}(len(p.grains))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGrain retrieves or spawns a grain (legacy id signature).
|
||||||
|
func (p *GrainLocalPool) GetGrain(id CartId) (*CartGrain, error) {
|
||||||
|
grainLookups.Inc()
|
||||||
|
k := keyFromCartId(id)
|
||||||
|
|
||||||
|
p.mu.RLock()
|
||||||
|
grain, ok := p.grains[k]
|
||||||
|
p.mu.RUnlock()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if grain == nil || !ok {
|
||||||
|
p.mu.Lock()
|
||||||
|
// Re-check under write lock
|
||||||
|
grain, ok = p.grains[k]
|
||||||
|
if grain == nil || !ok {
|
||||||
|
// Capacity check
|
||||||
|
if len(p.grains) >= p.PoolSize && len(p.expiry) > 0 {
|
||||||
|
if p.expiry[0].Expires.Before(time.Now()) && p.expiry[0].Grain != nil {
|
||||||
|
oldId := p.expiry[0].Grain.GetId()
|
||||||
|
p.deleteGrain(oldId)
|
||||||
|
p.expiry = p.expiry[1:]
|
||||||
|
} else {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return nil, fmt.Errorf("pool is full")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
grain, err = p.spawn(id)
|
||||||
|
if err == nil {
|
||||||
|
p.storeGrain(id, grain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
p.statsUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
return grain, err
|
return grain, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *GrainLocalPool) Process(id CartId, messages ...Message) ([]byte, error) {
|
// Apply applies a mutation (legacy compatibility).
|
||||||
|
func (p *GrainLocalPool) Apply(id CartId, mutation interface{}) (*CartGrain, error) {
|
||||||
grain, err := p.GetGrain(id)
|
grain, err := p.GetGrain(id)
|
||||||
if err == nil && grain != nil {
|
if err != nil || grain == nil {
|
||||||
for _, message := range messages {
|
|
||||||
_, err = grain.HandleMessage(&message, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return json.Marshal(grain)
|
return grain.Apply(mutation, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *GrainLocalPool) Get(id CartId) ([]byte, error) {
|
// Get returns current state (legacy wrapper).
|
||||||
grain, err := p.GetGrain(id)
|
func (p *GrainLocalPool) Get(id CartId) (*CartGrain, error) {
|
||||||
if err != nil {
|
return p.GetGrain(id)
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
return json.Marshal(grain)
|
|
||||||
|
// DebugGrainCount returns counts for debugging.
|
||||||
|
func (p *GrainLocalPool) DebugGrainCount() (authoritative int) {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
return len(p.grains)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnsafePointerToLegacyMap exposes the legacy map pointer (for transitional
|
||||||
|
// tests that still poke the field directly). DO NOT rely on this long-term.
|
||||||
|
func (p *GrainLocalPool) UnsafePointerToLegacyMap() uintptr {
|
||||||
|
// Legacy map removed; retained only to satisfy any transitional callers.
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
115
grpc_integration_test.go
Normal file
115
grpc_integration_test.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCartActorMutationAndState validates end-to-end gRPC mutation + state retrieval
|
||||||
|
// against a locally started gRPC server (single-node scenario).
|
||||||
|
// This test uses the new per-mutation AddItem RPC (breaking v2 API) to avoid external product fetch logic
|
||||||
|
// fetching logic (FetchItem) which would require network I/O.
|
||||||
|
func TestCartActorMutationAndState(t *testing.T) {
|
||||||
|
// Setup local grain pool + synced pool (no discovery, single host)
|
||||||
|
pool := NewGrainLocalPool(1024, time.Minute, spawn)
|
||||||
|
synced, err := NewSyncedPool(pool, "127.0.0.1", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSyncedPool error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start gRPC server (CartActor + ControlPlane) on :1337
|
||||||
|
grpcSrv, err := StartGRPCServer(":1337", pool, synced)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StartGRPCServer error: %v", err)
|
||||||
|
}
|
||||||
|
defer grpcSrv.GracefulStop()
|
||||||
|
|
||||||
|
// Dial the local server
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
conn, err := grpc.DialContext(ctx, "127.0.0.1:1337",
|
||||||
|
grpc.WithInsecure(),
|
||||||
|
grpc.WithBlock(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("grpc.Dial error: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
cartClient := messages.NewCartActorClient(conn)
|
||||||
|
|
||||||
|
// Create a short cart id (<=16 chars so it fits into the fixed CartId 16-byte array cleanly)
|
||||||
|
cartID := fmt.Sprintf("cart-%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
// Build an AddItem payload (bypasses FetchItem to keep test deterministic)
|
||||||
|
addItem := &messages.AddItem{
|
||||||
|
ItemId: 1,
|
||||||
|
Quantity: 1,
|
||||||
|
Price: 1000,
|
||||||
|
OrgPrice: 1000,
|
||||||
|
Sku: "test-sku",
|
||||||
|
Name: "Test SKU",
|
||||||
|
Image: "/img.png",
|
||||||
|
Stock: 2, // InStock
|
||||||
|
Tax: 2500,
|
||||||
|
Country: "se",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue AddItem RPC directly (breaking v2 API)
|
||||||
|
addResp, err := cartClient.AddItem(context.Background(), &messages.AddItemRequest{
|
||||||
|
CartId: cartID,
|
||||||
|
ClientTimestamp: time.Now().Unix(),
|
||||||
|
Payload: addItem,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddItem RPC error: %v", err)
|
||||||
|
}
|
||||||
|
if addResp.StatusCode != 200 {
|
||||||
|
t.Fatalf("AddItem returned non-200 status: %d, error: %s", addResp.StatusCode, addResp.GetError())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the response state (from AddItem)
|
||||||
|
state := addResp.GetState()
|
||||||
|
if state == nil {
|
||||||
|
t.Fatalf("AddItem response state is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// (Removed obsolete Mutate response handling)
|
||||||
|
|
||||||
|
if len(state.Items) != 1 {
|
||||||
|
t.Fatalf("Expected 1 item after AddItem, got %d", len(state.Items))
|
||||||
|
}
|
||||||
|
if state.Items[0].Sku != "test-sku" {
|
||||||
|
t.Fatalf("Unexpected item SKU: %s", state.Items[0].Sku)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue GetState RPC
|
||||||
|
getResp, err := cartClient.GetState(context.Background(), &messages.StateRequest{
|
||||||
|
CartId: cartID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetState RPC error: %v", err)
|
||||||
|
}
|
||||||
|
if getResp.StatusCode != 200 {
|
||||||
|
t.Fatalf("GetState returned non-200 status: %d, error: %s", getResp.StatusCode, getResp.GetError())
|
||||||
|
}
|
||||||
|
|
||||||
|
state2 := getResp.GetState()
|
||||||
|
if state2 == nil {
|
||||||
|
t.Fatalf("GetState response state is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(state2.Items) != 1 {
|
||||||
|
t.Fatalf("Expected 1 item in GetState, got %d", len(state2.Items))
|
||||||
|
}
|
||||||
|
if state2.Items[0].Sku != "test-sku" {
|
||||||
|
t.Fatalf("Unexpected SKU in GetState: %s", state2.Items[0].Sku)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy serialization helper removed (oneof envelope used directly)
|
||||||
280
grpc_server.go
Normal file
280
grpc_server.go
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/reflection"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cartActorGRPCServer implements the CartActor and ControlPlane gRPC services.
|
||||||
|
// It delegates cart operations to a grain pool and cluster operations to a synced pool.
|
||||||
|
type cartActorGRPCServer struct {
|
||||||
|
messages.UnimplementedCartActorServer
|
||||||
|
messages.UnimplementedControlPlaneServer
|
||||||
|
|
||||||
|
pool GrainPool // For cart state mutations and queries
|
||||||
|
syncedPool *SyncedPool // For cluster membership and control
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCartActorGRPCServer creates and initializes the server.
|
||||||
|
func NewCartActorGRPCServer(pool GrainPool, syncedPool *SyncedPool) *cartActorGRPCServer {
|
||||||
|
return &cartActorGRPCServer{
|
||||||
|
pool: pool,
|
||||||
|
syncedPool: syncedPool,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyMutation routes a single cart mutation to the target grain (used by per-mutation RPC handlers).
|
||||||
|
func (s *cartActorGRPCServer) applyMutation(cartID string, mutation interface{}) *messages.CartMutationReply {
|
||||||
|
// Canonicalize or preserve legacy id (do NOT hash-rewrite legacy textual ids)
|
||||||
|
cid, _, wasBase62, cerr := CanonicalizeOrLegacy(cartID)
|
||||||
|
if cerr != nil {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 500,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: fmt.Sprintf("cart_id canonicalization failed: %v", cerr)},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = wasBase62 // placeholder; future: propagate canonical id in reply metadata
|
||||||
|
legacy := CartIDToLegacy(cid)
|
||||||
|
grain, err := s.pool.Apply(legacy, mutation)
|
||||||
|
if err != nil {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 500,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: err.Error()},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cartState := ToCartState(grain)
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 200,
|
||||||
|
Result: &messages.CartMutationReply_State{State: cartState},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cartActorGRPCServer) AddRequest(ctx context.Context, req *messages.AddRequestRequest) (*messages.CartMutationReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cartActorGRPCServer) AddItem(ctx context.Context, req *messages.AddItemRequest) (*messages.CartMutationReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cartActorGRPCServer) RemoveItem(ctx context.Context, req *messages.RemoveItemRequest) (*messages.CartMutationReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cartActorGRPCServer) RemoveDelivery(ctx context.Context, req *messages.RemoveDeliveryRequest) (*messages.CartMutationReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cartActorGRPCServer) ChangeQuantity(ctx context.Context, req *messages.ChangeQuantityRequest) (*messages.CartMutationReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cartActorGRPCServer) SetDelivery(ctx context.Context, req *messages.SetDeliveryRequest) (*messages.CartMutationReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cartActorGRPCServer) SetPickupPoint(ctx context.Context, req *messages.SetPickupPointRequest) (*messages.CartMutationReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Checkout RPC removed. Checkout is handled at the HTTP layer (PoolServer.HandleCheckout).
|
||||||
|
*/
|
||||||
|
|
||||||
|
func (s *cartActorGRPCServer) SetCartItems(ctx context.Context, req *messages.SetCartItemsRequest) (*messages.CartMutationReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cartActorGRPCServer) OrderCompleted(ctx context.Context, req *messages.OrderCompletedRequest) (*messages.CartMutationReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetState retrieves the current state of a cart grain.
|
||||||
|
func (s *cartActorGRPCServer) GetState(ctx context.Context, req *messages.StateRequest) (*messages.StateReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.StateReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.StateReply_Error{Error: "cart_id is required"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
// Canonicalize / upgrade incoming cart id (preserve legacy strings)
|
||||||
|
cid, _, _, cerr := CanonicalizeOrLegacy(req.GetCartId())
|
||||||
|
if cerr != nil {
|
||||||
|
return &messages.StateReply{
|
||||||
|
StatusCode: 500,
|
||||||
|
Result: &messages.StateReply_Error{Error: fmt.Sprintf("cart_id canonicalization failed: %v", cerr)},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
legacy := CartIDToLegacy(cid)
|
||||||
|
|
||||||
|
grain, err := s.pool.Get(legacy)
|
||||||
|
if err != nil {
|
||||||
|
return &messages.StateReply{
|
||||||
|
StatusCode: 500,
|
||||||
|
Result: &messages.StateReply_Error{Error: err.Error()},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cartState := ToCartState(grain)
|
||||||
|
|
||||||
|
return &messages.StateReply{
|
||||||
|
StatusCode: 200,
|
||||||
|
Result: &messages.StateReply_State{State: cartState},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ControlPlane: Ping
|
||||||
|
func (s *cartActorGRPCServer) Ping(ctx context.Context, _ *messages.Empty) (*messages.PingReply, error) {
|
||||||
|
return &messages.PingReply{
|
||||||
|
Host: s.syncedPool.Hostname,
|
||||||
|
UnixTime: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ControlPlane: Negotiate (merge host views)
|
||||||
|
func (s *cartActorGRPCServer) Negotiate(ctx context.Context, req *messages.NegotiateRequest) (*messages.NegotiateReply, error) {
|
||||||
|
hostSet := make(map[string]struct{})
|
||||||
|
// Caller view
|
||||||
|
for _, h := range req.GetKnownHosts() {
|
||||||
|
if h != "" {
|
||||||
|
hostSet[h] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// This host
|
||||||
|
hostSet[s.syncedPool.Hostname] = struct{}{}
|
||||||
|
// Known remotes
|
||||||
|
s.syncedPool.mu.RLock()
|
||||||
|
for h := range s.syncedPool.remoteHosts {
|
||||||
|
hostSet[h] = struct{}{}
|
||||||
|
}
|
||||||
|
s.syncedPool.mu.RUnlock()
|
||||||
|
|
||||||
|
out := make([]string, 0, len(hostSet))
|
||||||
|
for h := range hostSet {
|
||||||
|
out = append(out, h)
|
||||||
|
}
|
||||||
|
return &messages.NegotiateReply{Hosts: out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ControlPlane: GetCartIds (locally owned carts only)
|
||||||
|
func (s *cartActorGRPCServer) GetCartIds(ctx context.Context, _ *messages.Empty) (*messages.CartIdsReply, error) {
|
||||||
|
s.syncedPool.local.mu.RLock()
|
||||||
|
ids := make([]string, 0, len(s.syncedPool.local.grains))
|
||||||
|
for _, g := range s.syncedPool.local.grains {
|
||||||
|
if g == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ids = append(ids, g.GetId().String())
|
||||||
|
}
|
||||||
|
s.syncedPool.local.mu.RUnlock()
|
||||||
|
return &messages.CartIdsReply{CartIds: ids}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ControlPlane: Closing (peer shutdown notification)
|
||||||
|
func (s *cartActorGRPCServer) Closing(ctx context.Context, req *messages.ClosingNotice) (*messages.OwnerChangeAck, error) {
|
||||||
|
if req.GetHost() != "" {
|
||||||
|
s.syncedPool.RemoveHost(req.GetHost())
|
||||||
|
}
|
||||||
|
return &messages.OwnerChangeAck{
|
||||||
|
Accepted: true,
|
||||||
|
Message: "removed host",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartGRPCServer configures and starts the unified gRPC server on the given address.
|
||||||
|
// It registers both the CartActor and ControlPlane services.
|
||||||
|
func StartGRPCServer(addr string, pool GrainPool, syncedPool *SyncedPool) (*grpc.Server, error) {
|
||||||
|
lis, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to listen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
grpcServer := grpc.NewServer()
|
||||||
|
server := NewCartActorGRPCServer(pool, syncedPool)
|
||||||
|
|
||||||
|
messages.RegisterCartActorServer(grpcServer, server)
|
||||||
|
messages.RegisterControlPlaneServer(grpcServer, server)
|
||||||
|
reflection.Register(grpcServer)
|
||||||
|
|
||||||
|
log.Printf("gRPC server listening on %s", addr)
|
||||||
|
go func() {
|
||||||
|
if err := grpcServer.Serve(lis); err != nil {
|
||||||
|
log.Fatalf("failed to serve gRPC: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return grpcServer, nil
|
||||||
|
}
|
||||||
128
klarna-client.go
Normal file
128
klarna-client.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type KlarnaClient struct {
|
||||||
|
Url string
|
||||||
|
UserName string
|
||||||
|
Password string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewKlarnaClient(url, userName, password string) *KlarnaClient {
|
||||||
|
return &KlarnaClient{
|
||||||
|
Url: url,
|
||||||
|
UserName: userName,
|
||||||
|
Password: password,
|
||||||
|
client: &http.Client{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
KlarnaPlaygroundUrl = "https://api.playground.klarna.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (k *KlarnaClient) GetOrder(orderId string) (*CheckoutOrder, error) {
|
||||||
|
req, err := http.NewRequest("GET", k.Url+"/checkout/v3/orders/"+orderId, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Add("Content-Type", "application/json")
|
||||||
|
req.SetBasicAuth(k.UserName, k.Password)
|
||||||
|
|
||||||
|
res, err := k.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
return k.getOrderResponse(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *KlarnaClient) getOrderResponse(res *http.Response) (*CheckoutOrder, error) {
|
||||||
|
var err error
|
||||||
|
var klarnaOrderResponse CheckoutOrder
|
||||||
|
if res.StatusCode >= 200 && res.StatusCode <= 299 {
|
||||||
|
err = json.NewDecoder(res.Body).Decode(&klarnaOrderResponse)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &klarnaOrderResponse, nil
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(res.Body)
|
||||||
|
if err == nil {
|
||||||
|
log.Println(string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("%s", res.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *KlarnaClient) CreateOrder(reader io.Reader) (*CheckoutOrder, error) {
|
||||||
|
//bytes.NewReader(reply.Payload)
|
||||||
|
req, err := http.NewRequest("POST", k.Url+"/checkout/v3/orders", reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Add("Content-Type", "application/json")
|
||||||
|
req.SetBasicAuth(k.UserName, k.Password)
|
||||||
|
|
||||||
|
res, err := http.DefaultClient.Do(req)
|
||||||
|
if nil != err {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
return k.getOrderResponse(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *KlarnaClient) UpdateOrder(orderId string, reader io.Reader) (*CheckoutOrder, error) {
|
||||||
|
//bytes.NewReader(reply.Payload)
|
||||||
|
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s", k.Url, orderId), reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Add("Content-Type", "application/json")
|
||||||
|
req.SetBasicAuth(k.UserName, k.Password)
|
||||||
|
|
||||||
|
res, err := http.DefaultClient.Do(req)
|
||||||
|
if nil != err {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
return k.getOrderResponse(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *KlarnaClient) AbortOrder(orderId string) error {
|
||||||
|
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s/abort", k.Url, orderId), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.SetBasicAuth(k.UserName, k.Password)
|
||||||
|
|
||||||
|
_, err = http.DefaultClient.Do(req)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ordermanagement/v1/orders/{order_id}/acknowledge
|
||||||
|
func (k *KlarnaClient) AcknowledgeOrder(orderId string) error {
|
||||||
|
req, err := http.NewRequest("POST", fmt.Sprintf("%s/ordermanagement/v1/orders/%s/acknowledge", k.Url, orderId), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id := uuid.New()
|
||||||
|
|
||||||
|
req.SetBasicAuth(k.UserName, k.Password)
|
||||||
|
req.Header.Add("Klarna-Idempotency-Key", id.String())
|
||||||
|
|
||||||
|
_, err = http.DefaultClient.Do(req)
|
||||||
|
return err
|
||||||
|
}
|
||||||
169
klarna-types.go
Normal file
169
klarna-types.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
type (
|
||||||
|
LineType string
|
||||||
|
|
||||||
|
// CheckoutOrder type is the request structure to create a new order from the Checkout API
|
||||||
|
CheckoutOrder struct {
|
||||||
|
ID string `json:"order_id,omitempty"`
|
||||||
|
PurchaseCountry string `json:"purchase_country"`
|
||||||
|
PurchaseCurrency string `json:"purchase_currency"`
|
||||||
|
Locale string `json:"locale"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
BillingAddress *Address `json:"billing_address,omitempty"`
|
||||||
|
ShippingAddress *Address `json:"shipping_address,omitempty"`
|
||||||
|
OrderAmount int `json:"order_amount"`
|
||||||
|
OrderTaxAmount int `json:"order_tax_amount"`
|
||||||
|
OrderLines []*Line `json:"order_lines"`
|
||||||
|
Customer *CheckoutCustomer `json:"customer,omitempty"`
|
||||||
|
MerchantURLS *CheckoutMerchantURLS `json:"merchant_urls"`
|
||||||
|
HTMLSnippet string `json:"html_snippet,omitempty"`
|
||||||
|
MerchantReference1 string `json:"merchant_reference1,omitempty"`
|
||||||
|
MerchantReference2 string `json:"merchant_reference2,omitempty"`
|
||||||
|
StartedAt string `json:"started_at,omitempty"`
|
||||||
|
CompletedAt string `json:"completed_at,omitempty"`
|
||||||
|
LastModifiedAt string `json:"last_modified_at,omitempty"`
|
||||||
|
Options *CheckoutOptions `json:"options,omitempty"`
|
||||||
|
Attachment *Attachment `json:"attachment,omitempty"`
|
||||||
|
ExternalPaymentMethods []*PaymentProvider `json:"external_payment_methods,omitempty"`
|
||||||
|
ExternalCheckouts []*PaymentProvider `json:"external_checkouts,omitempty"`
|
||||||
|
ShippingCountries []string `json:"shipping_countries,omitempty"`
|
||||||
|
ShippingOptions []*ShippingOption `json:"shipping_options,omitempty"`
|
||||||
|
MerchantData string `json:"merchant_data,omitempty"`
|
||||||
|
GUI *GUI `json:"gui,omitempty"`
|
||||||
|
MerchantRequested *AdditionalCheckBox `json:"merchant_requested,omitempty"`
|
||||||
|
SelectedShippingOption *ShippingOption `json:"selected_shipping_option,omitempty"`
|
||||||
|
ErrorCode string `json:"error_code,omitempty"`
|
||||||
|
ErrorMessages []string `json:"error_messages,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GUI type wraps the GUI options
|
||||||
|
GUI struct {
|
||||||
|
Options []string `json:"options,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShippingOption type is part of the CheckoutOrder structure, represent the shipping options field
|
||||||
|
ShippingOption struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Promo string `json:"promo,omitempty"`
|
||||||
|
Price int `json:"price"`
|
||||||
|
TaxAmount int `json:"tax_amount"`
|
||||||
|
TaxRate int `json:"tax_rate"`
|
||||||
|
Preselected bool `json:"preselected,omitempty"`
|
||||||
|
ShippingMethod string `json:"shipping_method,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaymentProvider type is part of the CheckoutOrder structure, represent the ExternalPaymentMethods and
|
||||||
|
// ExternalCheckouts field
|
||||||
|
PaymentProvider struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
RedirectURL string `json:"redirect_url"`
|
||||||
|
ImageURL string `json:"image_url,omitempty"`
|
||||||
|
Fee int `json:"fee,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Countries []string `json:"countries,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
Attachment struct {
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
CheckoutOptions struct {
|
||||||
|
AcquiringChannel string `json:"acquiring_channel,omitempty"`
|
||||||
|
AllowSeparateShippingAddress bool `json:"allow_separate_shipping_address,omitempty"`
|
||||||
|
ColorButton string `json:"color_button,omitempty"`
|
||||||
|
ColorButtonText string `json:"color_button_text,omitempty"`
|
||||||
|
ColorCheckbox string `json:"color_checkbox,omitempty"`
|
||||||
|
ColorCheckboxCheckmark string `json:"color_checkbox_checkmark,omitempty"`
|
||||||
|
ColorHeader string `json:"color_header,omitempty"`
|
||||||
|
ColorLink string `json:"color_link,omitempty"`
|
||||||
|
DateOfBirthMandatory bool `json:"date_of_birth_mandatory,omitempty"`
|
||||||
|
ShippingDetails string `json:"shipping_details,omitempty"`
|
||||||
|
TitleMandatory bool `json:"title_mandatory,omitempty"`
|
||||||
|
AdditionalCheckbox *AdditionalCheckBox `json:"additional_checkbox"`
|
||||||
|
RadiusBorder string `json:"radius_border,omitempty"`
|
||||||
|
ShowSubtotalDetail bool `json:"show_subtotal_detail,omitempty"`
|
||||||
|
RequireValidateCallbackSuccess bool `json:"require_validate_callback_success,omitempty"`
|
||||||
|
AllowGlobalBillingCountries bool `json:"allow_global_billing_countries,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
AdditionalCheckBox struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Checked bool `json:"checked"`
|
||||||
|
Required bool `json:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
CheckoutMerchantURLS struct {
|
||||||
|
// URL of merchant terms and conditions. Should be different than checkout, confirmation and push URLs.
|
||||||
|
// (max 2000 characters)
|
||||||
|
Terms string `json:"terms"`
|
||||||
|
|
||||||
|
// URL of merchant checkout page. Should be different than terms, confirmation and push URLs.
|
||||||
|
// (max 2000 characters)
|
||||||
|
Checkout string `json:"checkout"`
|
||||||
|
|
||||||
|
// URL of merchant confirmation page. Should be different than checkout and confirmation URLs.
|
||||||
|
// (max 2000 characters)
|
||||||
|
Confirmation string `json:"confirmation"`
|
||||||
|
|
||||||
|
// URL that will be requested when an order is completed. Should be different than checkout and
|
||||||
|
// confirmation URLs. (max 2000 characters)
|
||||||
|
Push string `json:"push"`
|
||||||
|
// URL that will be requested for final merchant validation. (must be https, max 2000 characters)
|
||||||
|
Validation string `json:"validation,omitempty"`
|
||||||
|
|
||||||
|
// URL for shipping option update. (must be https, max 2000 characters)
|
||||||
|
ShippingOptionUpdate string `json:"shipping_option_update,omitempty"`
|
||||||
|
|
||||||
|
// URL for shipping, tax and purchase currency updates. Will be called on address changes.
|
||||||
|
// (must be https, max 2000 characters)
|
||||||
|
AddressUpdate string `json:"address_update,omitempty"`
|
||||||
|
|
||||||
|
// URL for notifications on pending orders. (max 2000 characters)
|
||||||
|
Notification string `json:"notification,omitempty"`
|
||||||
|
|
||||||
|
// URL for shipping, tax and purchase currency updates. Will be called on purchase country changes.
|
||||||
|
// (must be https, max 2000 characters)
|
||||||
|
CountryChange string `json:"country_change,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
CheckoutCustomer struct {
|
||||||
|
// DateOfBirth in string representation 2006-01-02
|
||||||
|
DateOfBirth string `json:"date_of_birth"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address type define the address object (json serializable) being used for the API to represent billing &
|
||||||
|
// shipping addresses
|
||||||
|
Address struct {
|
||||||
|
GivenName string `json:"given_name,omitempty"`
|
||||||
|
FamilyName string `json:"family_name,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
StreetAddress string `json:"street_address,omitempty"`
|
||||||
|
StreetAddress2 string `json:"street_address2,omitempty"`
|
||||||
|
PostalCode string `json:"postal_code,omitempty"`
|
||||||
|
City string `json:"city,omitempty"`
|
||||||
|
Region string `json:"region,omitempty"`
|
||||||
|
Phone string `json:"phone,omitempty"`
|
||||||
|
Country string `json:"country,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
Line struct {
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Reference string `json:"reference,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
QuantityUnit string `json:"quantity_unit,omitempty"`
|
||||||
|
UnitPrice int `json:"unit_price"`
|
||||||
|
TaxRate int `json:"tax_rate"`
|
||||||
|
TotalAmount int `json:"total_amount"`
|
||||||
|
TotalDiscountAmount int `json:"total_discount_amount,omitempty"`
|
||||||
|
TotalTaxAmount int `json:"total_tax_amount"`
|
||||||
|
MerchantData string `json:"merchant_data,omitempty"`
|
||||||
|
ProductURL string `json:"product_url,omitempty"`
|
||||||
|
ImageURL string `json:"image_url,omitempty"`
|
||||||
|
}
|
||||||
|
)
|
||||||
238
main.go
238
main.go
@@ -1,15 +1,18 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/pprof"
|
"net/http/pprof"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
@@ -37,10 +40,10 @@ func spawn(id CartId) (*CartGrain, error) {
|
|||||||
ret := &CartGrain{
|
ret := &CartGrain{
|
||||||
lastItemId: 0,
|
lastItemId: 0,
|
||||||
lastDeliveryId: 0,
|
lastDeliveryId: 0,
|
||||||
Deliveries: []CartDelivery{},
|
Deliveries: []*CartDelivery{},
|
||||||
Id: id,
|
Id: id,
|
||||||
Items: []*CartItem{},
|
Items: []*CartItem{},
|
||||||
storageMessages: []Message{},
|
// storageMessages removed (legacy event log deprecated)
|
||||||
TotalPrice: 0,
|
TotalPrice: 0,
|
||||||
}
|
}
|
||||||
err := loadMessages(ret, id)
|
err := loadMessages(ret, id)
|
||||||
@@ -91,6 +94,47 @@ func (a *App) HandleSave(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
var podIp = os.Getenv("POD_IP")
|
var podIp = os.Getenv("POD_IP")
|
||||||
var name = os.Getenv("POD_NAME")
|
var name = os.Getenv("POD_NAME")
|
||||||
|
var amqpUrl = os.Getenv("AMQP_URL")
|
||||||
|
var KlarnaInstance = NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
|
||||||
|
|
||||||
|
var tpl = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>s10r testing - checkout</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
%s
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|
||||||
|
func getCountryFromHost(host string) string {
|
||||||
|
if strings.Contains(strings.ToLower(host), "-no") {
|
||||||
|
return "no"
|
||||||
|
}
|
||||||
|
return "se"
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCheckoutOrder(host string, cartId CartId) *messages.CreateCheckoutOrder {
|
||||||
|
baseUrl := fmt.Sprintf("https://%s", host)
|
||||||
|
cartBaseUrl := os.Getenv("CART_BASE_URL")
|
||||||
|
if cartBaseUrl == "" {
|
||||||
|
cartBaseUrl = "https://cart.tornberg.me"
|
||||||
|
}
|
||||||
|
country := getCountryFromHost(host)
|
||||||
|
|
||||||
|
return &messages.CreateCheckoutOrder{
|
||||||
|
Terms: fmt.Sprintf("%s/terms", baseUrl),
|
||||||
|
Checkout: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}", baseUrl),
|
||||||
|
Confirmation: fmt.Sprintf("%s/confirmation/{checkout.order.id}", baseUrl),
|
||||||
|
Validation: fmt.Sprintf("%s/validation", cartBaseUrl),
|
||||||
|
Push: fmt.Sprintf("%s/push?order_id={checkout.order.id}", cartBaseUrl),
|
||||||
|
Country: country,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func GetDiscovery() Discovery {
|
func GetDiscovery() Discovery {
|
||||||
if podIp == "" {
|
if podIp == "" {
|
||||||
@@ -110,7 +154,7 @@ func GetDiscovery() Discovery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Create a new instance of the server
|
|
||||||
storage, err := NewDiskStorage(fmt.Sprintf("data/%s_state.gob", name))
|
storage, err := NewDiskStorage(fmt.Sprintf("data/%s_state.gob", name))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error loading state: %v\n", err)
|
log.Printf("Error loading state: %v\n", err)
|
||||||
@@ -125,30 +169,35 @@ func main() {
|
|||||||
log.Fatalf("Error creating synced pool: %v\n", err)
|
log.Fatalf("Error creating synced pool: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
hg, err := NewGrainHandler(app.pool, ":1337")
|
// Start unified gRPC server (CartActor + ControlPlane) replacing legacy RPC server on :1337
|
||||||
|
// TODO: Remove any remaining legacy RPC server references and deprecated frame-based code after full gRPC migration is validated.
|
||||||
|
grpcSrv, err := StartGRPCServer(":1337", app.pool, syncedPool)
|
||||||
if err != nil {
|
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() {
|
go func() {
|
||||||
for range time.Tick(time.Minute * 10) {
|
for range time.Tick(time.Minute * 10) {
|
||||||
|
|
||||||
err := app.Save()
|
err := app.Save()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error saving: %v\n", err)
|
log.Printf("Error saving: %v\n", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
orderHandler := &AmqpOrderHandler{
|
||||||
|
Url: amqpUrl,
|
||||||
|
}
|
||||||
|
|
||||||
syncedServer := NewPoolServer(syncedPool, fmt.Sprintf("%s, %s", name, podIp))
|
syncedServer := NewPoolServer(syncedPool, fmt.Sprintf("%s, %s", name, podIp))
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("/api/", http.StripPrefix("/api", syncedServer.Serve()))
|
mux.Handle("/cart/", http.StripPrefix("/cart", syncedServer.Serve()))
|
||||||
// only for local
|
// only for local
|
||||||
// mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
// mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
// syncedPool.AddRemote(r.PathValue("host"))
|
// syncedPool.AddRemote(r.PathValue("host"))
|
||||||
// })
|
// })
|
||||||
// mux.HandleFunc("GET /save", app.HandleSave)
|
// mux.HandleFunc("GET /save", app.HandleSave)
|
||||||
|
//mux.HandleFunc("/", app.RewritePath)
|
||||||
mux.HandleFunc("/debug/pprof/", pprof.Index)
|
mux.HandleFunc("/debug/pprof/", pprof.Index)
|
||||||
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||||
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
||||||
@@ -156,17 +205,21 @@ func main() {
|
|||||||
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||||
mux.Handle("/metrics", promhttp.Handler())
|
mux.Handle("/metrics", promhttp.Handler())
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if !hg.IsHealthy() {
|
// Grain pool health: simple capacity check (mirrors previous GrainHandler.IsHealthy)
|
||||||
|
app.pool.mu.RLock()
|
||||||
|
grainCount := len(app.pool.grains)
|
||||||
|
capacity := app.pool.PoolSize
|
||||||
|
app.pool.mu.RUnlock()
|
||||||
|
if grainCount >= capacity {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
w.Write([]byte("handler not healthy"))
|
w.Write([]byte("grain pool at capacity"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !syncedPool.IsHealthy() {
|
if !syncedPool.IsHealthy() {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
w.Write([]byte("pool not healthy"))
|
w.Write([]byte("control plane not healthy"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
w.Write([]byte("ok"))
|
w.Write([]byte("ok"))
|
||||||
})
|
})
|
||||||
@@ -179,6 +232,139 @@ func main() {
|
|||||||
w.Write([]byte("ok"))
|
w.Write([]byte("ok"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/checkout", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
orderId := r.URL.Query().Get("order_id")
|
||||||
|
order := &CheckoutOrder{}
|
||||||
|
|
||||||
|
if orderId == "" {
|
||||||
|
cookie, err := r.Cookie("cartid")
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cookie.Value == "" {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte("no cart id to checkout is empty"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cartId := ToCartId(cookie.Value)
|
||||||
|
order, err = syncedServer.CreateOrUpdateCheckout(r.Host, cartId)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
}
|
||||||
|
// v2: Apply now returns *CartGrain; order creation handled inside grain (no payload to unmarshal)
|
||||||
|
} else {
|
||||||
|
order, err = KlarnaInstance.GetOrder(orderId)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(fmt.Sprintf(tpl, order.HTMLSnippet)))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
orderId := r.PathValue("order_id")
|
||||||
|
order, err := KlarnaInstance.GetOrder(orderId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if order.Status == "checkout_complete" {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "cartid",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
Secure: true,
|
||||||
|
HttpOnly: true,
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(fmt.Sprintf(tpl, order.HTMLSnippet)))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/validate", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Printf("Klarna order validation, method: %s", r.Method)
|
||||||
|
if r.Method != "POST" {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
order := &CheckoutOrder{}
|
||||||
|
err := json.NewDecoder(r.Body).Decode(order)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
log.Printf("Klarna order validation: %s", order.ID)
|
||||||
|
//err = confirmOrder(order, orderHandler)
|
||||||
|
//if err != nil {
|
||||||
|
// log.Printf("Error validating order: %v\n", err)
|
||||||
|
// w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
// return
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//err = triggerOrderCompleted(err, syncedServer, order)
|
||||||
|
//if err != nil {
|
||||||
|
// log.Printf("Error processing cart message: %v\n", err)
|
||||||
|
// w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
// return
|
||||||
|
//}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/push", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orderId := r.URL.Query().Get("order_id")
|
||||||
|
log.Printf("Order confirmation push: %s", orderId)
|
||||||
|
|
||||||
|
order, err := KlarnaInstance.GetOrder(orderId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error creating request: %v\n", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = confirmOrder(order, orderHandler)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error confirming order: %v\n", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = triggerOrderCompleted(err, syncedServer, order)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error processing cart message: %v\n", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = KlarnaInstance.AcknowledgeOrder(orderId)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error acknowledging order: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("1.0.0"))
|
||||||
|
})
|
||||||
|
|
||||||
sigs := make(chan os.Signal, 1)
|
sigs := make(chan os.Signal, 1)
|
||||||
done := make(chan bool, 1)
|
done := make(chan bool, 1)
|
||||||
signal.Notify(sigs, syscall.SIGTERM)
|
signal.Notify(sigs, syscall.SIGTERM)
|
||||||
@@ -186,11 +372,39 @@ func main() {
|
|||||||
go func() {
|
go func() {
|
||||||
sig := <-sigs
|
sig := <-sigs
|
||||||
fmt.Println("Shutting down due to signal:", sig)
|
fmt.Println("Shutting down due to signal:", sig)
|
||||||
|
go syncedPool.Close()
|
||||||
app.Save()
|
app.Save()
|
||||||
done <- true
|
done <- true
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
log.Print("Server started at port 8080")
|
||||||
go http.ListenAndServe(":8080", mux)
|
go http.ListenAndServe(":8080", mux)
|
||||||
<-done
|
<-done
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func triggerOrderCompleted(err error, syncedServer *PoolServer, order *CheckoutOrder) error {
|
||||||
|
_, err = syncedServer.pool.Apply(ToCartId(order.MerchantReference1), &messages.OrderCreated{
|
||||||
|
OrderId: order.ID,
|
||||||
|
Status: order.Status,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func confirmOrder(order *CheckoutOrder, orderHandler *AmqpOrderHandler) error {
|
||||||
|
orderToSend, err := json.Marshal(order)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = orderHandler.Connect()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer orderHandler.Close()
|
||||||
|
err = orderHandler.OrderCompleted(orderToSend)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,221 +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{},
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetMessageHandler(t uint16) (MessageHandler, error) {
|
|
||||||
h, ok := Handlers[t]
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("no handler for 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 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
|
|
||||||
}
|
|
||||||
@@ -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,11 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
const (
|
|
||||||
AddRequestType = 1
|
|
||||||
AddItemType = 2
|
|
||||||
//AddDeliveryType = 3
|
|
||||||
RemoveItemType = 4
|
|
||||||
RemoveDeliveryType = 5
|
|
||||||
ChangeQuantityType = 6
|
|
||||||
SetDeliveryType = 7
|
|
||||||
)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
182
multi_node_ownership_test.go
Normal file
182
multi_node_ownership_test.go
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMultiNodeOwnershipNegotiation spins up two gRPC servers (nodeA, nodeB),
|
||||||
|
// manually links their SyncedPools (bypassing AddRemote's fixed port assumption),
|
||||||
|
// and verifies that only one node becomes the owner of a new cart while the
|
||||||
|
// other can still apply a mutation via the remote proxy path.
|
||||||
|
//
|
||||||
|
// NOTE:
|
||||||
|
// - We manually inject RemoteHostGRPC entries because AddRemote() hard-codes
|
||||||
|
// port 1337; to run two distinct servers concurrently we need distinct ports.
|
||||||
|
// - This test asserts single ownership consistency rather than the complete
|
||||||
|
// quorum semantics (which depend on real discovery + AddRemote).
|
||||||
|
func TestMultiNodeOwnershipNegotiation(t *testing.T) {
|
||||||
|
// Allocate distinct ports for the two nodes.
|
||||||
|
const (
|
||||||
|
addrA = "127.0.0.1:18081"
|
||||||
|
addrB = "127.0.0.1:18082"
|
||||||
|
hostA = "nodeA"
|
||||||
|
hostB = "nodeB"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create local grain pools.
|
||||||
|
poolA := NewGrainLocalPool(1024, time.Minute, spawn)
|
||||||
|
poolB := NewGrainLocalPool(1024, time.Minute, spawn)
|
||||||
|
|
||||||
|
// Create synced pools (no discovery).
|
||||||
|
syncedA, err := NewSyncedPool(poolA, hostA, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("nodeA NewSyncedPool error: %v", err)
|
||||||
|
}
|
||||||
|
syncedB, err := NewSyncedPool(poolB, hostB, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("nodeB NewSyncedPool error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start gRPC servers (CartActor + ControlPlane) on different ports.
|
||||||
|
grpcSrvA, err := StartGRPCServer(addrA, poolA, syncedA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StartGRPCServer A error: %v", err)
|
||||||
|
}
|
||||||
|
defer grpcSrvA.GracefulStop()
|
||||||
|
|
||||||
|
grpcSrvB, err := StartGRPCServer(addrB, poolB, syncedB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StartGRPCServer B error: %v", err)
|
||||||
|
}
|
||||||
|
defer grpcSrvB.GracefulStop()
|
||||||
|
|
||||||
|
// Helper to connect one pool to the other's server (manual AddRemote equivalent).
|
||||||
|
link := func(src *SyncedPool, remoteHost, remoteAddr string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
conn, dialErr := grpc.DialContext(ctx, remoteAddr, grpc.WithInsecure(), grpc.WithBlock())
|
||||||
|
if dialErr != nil {
|
||||||
|
t.Fatalf("dial %s (%s) failed: %v", remoteHost, remoteAddr, dialErr)
|
||||||
|
}
|
||||||
|
cartClient := messages.NewCartActorClient(conn)
|
||||||
|
controlClient := messages.NewControlPlaneClient(conn)
|
||||||
|
|
||||||
|
src.mu.Lock()
|
||||||
|
src.remoteHosts[remoteHost] = &RemoteHostGRPC{
|
||||||
|
Host: remoteHost,
|
||||||
|
Conn: conn,
|
||||||
|
CartClient: cartClient,
|
||||||
|
ControlClient: controlClient,
|
||||||
|
}
|
||||||
|
src.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-link the two pools.
|
||||||
|
link(syncedA, hostB, addrB)
|
||||||
|
link(syncedB, hostA, addrA)
|
||||||
|
|
||||||
|
// Rebuild rings after manual cross-link so deterministic ownership works immediately.
|
||||||
|
syncedA.ForceRingRefresh()
|
||||||
|
syncedB.ForceRingRefresh()
|
||||||
|
|
||||||
|
// Allow brief stabilization (control plane pings / no real negotiation needed here).
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
// Create a deterministic cart id for test readability.
|
||||||
|
cartID := ToCartId(fmt.Sprintf("cart-%d", time.Now().UnixNano()))
|
||||||
|
|
||||||
|
// Mutation payload (ring-determined ownership; no assumption about which node owns).
|
||||||
|
addItem := &messages.AddItem{
|
||||||
|
ItemId: 1,
|
||||||
|
Quantity: 1,
|
||||||
|
Price: 1500,
|
||||||
|
OrgPrice: 1500,
|
||||||
|
Sku: "sku-test-multi",
|
||||||
|
Name: "Multi Node Test",
|
||||||
|
Image: "/test.png",
|
||||||
|
Stock: 2,
|
||||||
|
Tax: 2500,
|
||||||
|
Country: "se",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine ring owner and set primary / secondary references.
|
||||||
|
ownerHost := syncedA.DebugOwnerHost(cartID)
|
||||||
|
var ownerSynced, otherSynced *SyncedPool
|
||||||
|
var ownerPool, otherPool *GrainLocalPool
|
||||||
|
switch ownerHost {
|
||||||
|
case hostA:
|
||||||
|
ownerSynced, ownerPool = syncedA, poolA
|
||||||
|
otherSynced, otherPool = syncedB, poolB
|
||||||
|
case hostB:
|
||||||
|
ownerSynced, ownerPool = syncedB, poolB
|
||||||
|
otherSynced, otherPool = syncedA, poolA
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected ring owner %s (expected %s or %s)", ownerHost, hostA, hostB)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply mutation on the ring-designated owner.
|
||||||
|
if _, err := ownerSynced.Apply(cartID, addItem); err != nil {
|
||||||
|
t.Fatalf("owner %s Apply addItem error: %v", ownerHost, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate owner pool has the grain and the other does not.
|
||||||
|
if _, ok := ownerPool.GetGrains()[cartID]; !ok {
|
||||||
|
t.Fatalf("expected owner %s to have local grain", ownerHost)
|
||||||
|
}
|
||||||
|
if _, ok := otherPool.GetGrains()[cartID]; ok {
|
||||||
|
t.Fatalf("non-owner unexpectedly holds local grain")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare change mutation to be applied from the non-owner (should route remotely).
|
||||||
|
change := &messages.ChangeQuantity{
|
||||||
|
Id: 1, // line id after first AddItem
|
||||||
|
Quantity: 2,
|
||||||
|
}
|
||||||
|
// Apply remotely via the non-owner.
|
||||||
|
if _, err := otherSynced.Apply(cartID, change); err != nil {
|
||||||
|
t.Fatalf("non-owner remote Apply changeQuantity error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote re-mutation already performed via otherSynced; removed duplicate block.
|
||||||
|
|
||||||
|
// NodeB local grain assertion:
|
||||||
|
// Only assert absence if nodeB is NOT the ring-designated owner. If nodeB is the owner,
|
||||||
|
// it is expected to have a local grain (previous generic ownership assertions already ran).
|
||||||
|
if ownerHost != hostB {
|
||||||
|
if _, local := poolB.GetGrains()[cartID]; local {
|
||||||
|
t.Fatalf("nodeB unexpectedly created local grain (ownership duplication)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch state from nodeB to ensure we see updated quantity (2).
|
||||||
|
grainStateB, err := syncedB.Get(cartID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("nodeB Get error: %v", err)
|
||||||
|
}
|
||||||
|
if len(grainStateB.Items) != 1 || grainStateB.Items[0].Quantity != 2 {
|
||||||
|
t.Fatalf("nodeB observed inconsistent state: items=%d qty=%d (expected 1 / 2)",
|
||||||
|
len(grainStateB.Items),
|
||||||
|
func() int {
|
||||||
|
if len(grainStateB.Items) == 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return grainStateB.Items[0].Quantity
|
||||||
|
}(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-check from nodeA (authoritative) to ensure state matches.
|
||||||
|
grainStateA, err := syncedA.Get(cartID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("nodeA Get error: %v", err)
|
||||||
|
}
|
||||||
|
if grainStateA.Items[0].Quantity != 2 {
|
||||||
|
t.Fatalf("nodeA authoritative state mismatch: expected qty=2 got %d", grainStateA.Items[0].Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
304
multi_node_three_test.go
Normal file
304
multi_node_three_test.go
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestThreeNodeMajorityOwnership validates ring-determined ownership and routing
|
||||||
|
// in a 3-node cluster (A,B,C) using the consistent hashing ring (no quorum RPC).
|
||||||
|
// The previous ConfirmOwner / quorum semantics have been removed; ownership is
|
||||||
|
// deterministic and derived from the ring.
|
||||||
|
//
|
||||||
|
// It validates:
|
||||||
|
// 1. The ring selects exactly one primary owner for a new cart.
|
||||||
|
// 2. Other nodes (B,C) do NOT create local grains for the cart.
|
||||||
|
// 3. Remote proxies are installed lazily so remote mutations can route.
|
||||||
|
// 4. A remote mutation from one non-owner updates state visible on another.
|
||||||
|
// 5. Authoritative state on the owner matches remote observations.
|
||||||
|
// 6. (Future) This scaffolds replication tests when RF>1 is enabled.
|
||||||
|
//
|
||||||
|
// (Legacy comments about ConfirmOwner acceptance thresholds have been removed.)
|
||||||
|
// (Function name retained for historical continuity.)
|
||||||
|
func TestThreeNodeMajorityOwnership(t *testing.T) {
|
||||||
|
const (
|
||||||
|
addrA = "127.0.0.1:18181"
|
||||||
|
addrB = "127.0.0.1:18182"
|
||||||
|
addrC = "127.0.0.1:18183"
|
||||||
|
hostA = "nodeA3"
|
||||||
|
hostB = "nodeB3"
|
||||||
|
hostC = "nodeC3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Local grain pools
|
||||||
|
poolA := NewGrainLocalPool(1024, time.Minute, spawn)
|
||||||
|
poolB := NewGrainLocalPool(1024, time.Minute, spawn)
|
||||||
|
poolC := NewGrainLocalPool(1024, time.Minute, spawn)
|
||||||
|
|
||||||
|
// Synced pools (no discovery)
|
||||||
|
syncedA, err := NewSyncedPool(poolA, hostA, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("nodeA NewSyncedPool error: %v", err)
|
||||||
|
}
|
||||||
|
syncedB, err := NewSyncedPool(poolB, hostB, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("nodeB NewSyncedPool error: %v", err)
|
||||||
|
}
|
||||||
|
syncedC, err := NewSyncedPool(poolC, hostC, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("nodeC NewSyncedPool error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start gRPC servers
|
||||||
|
grpcSrvA, err := StartGRPCServer(addrA, poolA, syncedA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StartGRPCServer A error: %v", err)
|
||||||
|
}
|
||||||
|
defer grpcSrvA.GracefulStop()
|
||||||
|
grpcSrvB, err := StartGRPCServer(addrB, poolB, syncedB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StartGRPCServer B error: %v", err)
|
||||||
|
}
|
||||||
|
defer grpcSrvB.GracefulStop()
|
||||||
|
grpcSrvC, err := StartGRPCServer(addrC, poolC, syncedC)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StartGRPCServer C error: %v", err)
|
||||||
|
}
|
||||||
|
defer grpcSrvC.GracefulStop()
|
||||||
|
|
||||||
|
// Helper for manual cross-link (since AddRemote assumes fixed port)
|
||||||
|
link := func(src *SyncedPool, remoteHost, remoteAddr string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
conn, dialErr := grpc.DialContext(ctx, remoteAddr, grpc.WithInsecure(), grpc.WithBlock())
|
||||||
|
if dialErr != nil {
|
||||||
|
t.Fatalf("dial %s (%s) failed: %v", remoteHost, remoteAddr, dialErr)
|
||||||
|
}
|
||||||
|
cartClient := messages.NewCartActorClient(conn)
|
||||||
|
controlClient := messages.NewControlPlaneClient(conn)
|
||||||
|
|
||||||
|
src.mu.Lock()
|
||||||
|
src.remoteHosts[remoteHost] = &RemoteHostGRPC{
|
||||||
|
Host: remoteHost,
|
||||||
|
Conn: conn,
|
||||||
|
CartClient: cartClient,
|
||||||
|
ControlClient: controlClient,
|
||||||
|
}
|
||||||
|
src.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full mesh (each node knows all others)
|
||||||
|
link(syncedA, hostB, addrB)
|
||||||
|
link(syncedA, hostC, addrC)
|
||||||
|
|
||||||
|
link(syncedB, hostA, addrA)
|
||||||
|
link(syncedB, hostC, addrC)
|
||||||
|
|
||||||
|
link(syncedC, hostA, addrA)
|
||||||
|
link(syncedC, hostB, addrB)
|
||||||
|
|
||||||
|
// Rebuild rings after manual linking so ownership resolution is immediate.
|
||||||
|
syncedA.ForceRingRefresh()
|
||||||
|
syncedB.ForceRingRefresh()
|
||||||
|
syncedC.ForceRingRefresh()
|
||||||
|
|
||||||
|
// Allow brief stabilization
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
// Deterministic-ish cart id
|
||||||
|
cartID := ToCartId(fmt.Sprintf("cart3-%d", time.Now().UnixNano()))
|
||||||
|
|
||||||
|
addItem := &messages.AddItem{
|
||||||
|
ItemId: 10,
|
||||||
|
Quantity: 1,
|
||||||
|
Price: 5000,
|
||||||
|
OrgPrice: 5000,
|
||||||
|
Sku: "sku-3node",
|
||||||
|
Name: "Three Node Test",
|
||||||
|
Image: "/t.png",
|
||||||
|
Stock: 10,
|
||||||
|
Tax: 2500,
|
||||||
|
Country: "se",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine ring-designated owner (may be any of the three hosts)
|
||||||
|
ownerPre := syncedA.DebugOwnerHost(cartID)
|
||||||
|
if ownerPre != hostA && ownerPre != hostB && ownerPre != hostC {
|
||||||
|
t.Fatalf("ring returned unexpected owner %s (not in set {%s,%s,%s})", ownerPre, hostA, hostB, hostC)
|
||||||
|
}
|
||||||
|
var ownerSynced *SyncedPool
|
||||||
|
var ownerPool *GrainLocalPool
|
||||||
|
switch ownerPre {
|
||||||
|
case hostA:
|
||||||
|
ownerSynced, ownerPool = syncedA, poolA
|
||||||
|
case hostB:
|
||||||
|
ownerSynced, ownerPool = syncedB, poolB
|
||||||
|
case hostC:
|
||||||
|
ownerSynced, ownerPool = syncedC, poolC
|
||||||
|
}
|
||||||
|
// Pick two distinct non-owner nodes for remote mutation assertions
|
||||||
|
var remote1Synced, remote2Synced *SyncedPool
|
||||||
|
switch ownerPre {
|
||||||
|
case hostA:
|
||||||
|
remote1Synced, remote2Synced = syncedB, syncedC
|
||||||
|
case hostB:
|
||||||
|
remote1Synced, remote2Synced = syncedA, syncedC
|
||||||
|
case hostC:
|
||||||
|
remote1Synced, remote2Synced = syncedA, syncedB
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply on the ring-designated owner
|
||||||
|
if _, err := ownerSynced.Apply(cartID, addItem); err != nil {
|
||||||
|
t.Fatalf("owner %s Apply addItem error: %v", ownerPre, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small wait for remote proxy spawn (ring ownership already deterministic)
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
|
||||||
|
// Assert only nodeA has local grain
|
||||||
|
localCount := 0
|
||||||
|
if _, ok := poolA.GetGrains()[cartID]; ok {
|
||||||
|
localCount++
|
||||||
|
}
|
||||||
|
if _, ok := poolB.GetGrains()[cartID]; ok {
|
||||||
|
localCount++
|
||||||
|
}
|
||||||
|
if _, ok := poolC.GetGrains()[cartID]; ok {
|
||||||
|
localCount++
|
||||||
|
}
|
||||||
|
if localCount != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 local grain, got %d", localCount)
|
||||||
|
}
|
||||||
|
if _, ok := ownerPool.GetGrains()[cartID]; !ok {
|
||||||
|
t.Fatalf("expected owner %s to hold local grain", ownerPre)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote proxies may not pre-exist; first remote mutation will trigger SpawnRemoteGrain lazily.
|
||||||
|
|
||||||
|
// Issue remote mutation from one non-owner -> ChangeQuantity (increase)
|
||||||
|
change := &messages.ChangeQuantity{
|
||||||
|
Id: 1,
|
||||||
|
Quantity: 3,
|
||||||
|
}
|
||||||
|
if _, err := remote1Synced.Apply(cartID, change); err != nil {
|
||||||
|
t.Fatalf("remote mutation (remote1) changeQuantity error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate updated state visible via nodeC
|
||||||
|
stateC, err := remote2Synced.Get(cartID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("nodeC Get error: %v", err)
|
||||||
|
}
|
||||||
|
if len(stateC.Items) != 1 || stateC.Items[0].Quantity != 3 {
|
||||||
|
t.Fatalf("nodeC observed state mismatch: items=%d qty=%d (expected 1 / 3)",
|
||||||
|
len(stateC.Items),
|
||||||
|
func() int {
|
||||||
|
if len(stateC.Items) == 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return stateC.Items[0].Quantity
|
||||||
|
}(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-check authoritative nodeA
|
||||||
|
stateA, err := syncedA.Get(cartID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("nodeA Get error: %v", err)
|
||||||
|
}
|
||||||
|
if stateA.Items[0].Quantity != 3 {
|
||||||
|
t.Fatalf("nodeA authoritative state mismatch: expected qty=3 got %d", stateA.Items[0].Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestThreeNodeDiscoveryMajorityOwnership (placeholder)
|
||||||
|
// This test is a scaffold demonstrating how a MockDiscovery would be wired
|
||||||
|
// once AddRemote supports host:port (currently hard-coded to :1337).
|
||||||
|
// It is skipped to avoid flakiness / false negatives until the production
|
||||||
|
// AddRemote logic is enhanced to parse dynamic ports or the test harness
|
||||||
|
// provides consistent port mapping.
|
||||||
|
func TestThreeNodeDiscoveryMajorityOwnership(t *testing.T) {
|
||||||
|
t.Skip("Pending enhancement: AddRemote needs host:port support to fully exercise discovery-based multi-node linking")
|
||||||
|
// Example skeleton (non-functional with current AddRemote implementation):
|
||||||
|
//
|
||||||
|
// md := NewMockDiscovery([]string{"nodeB3", "nodeC3"})
|
||||||
|
// poolA := NewGrainLocalPool(1024, time.Minute, spawn)
|
||||||
|
// syncedA, err := NewSyncedPool(poolA, "nodeA3", md)
|
||||||
|
// if err != nil {
|
||||||
|
// t.Fatalf("NewSyncedPool with mock discovery error: %v", err)
|
||||||
|
// }
|
||||||
|
// // Start server for nodeA (would also need servers for nodeB3/nodeC3 on expected ports)
|
||||||
|
// // grpcSrvA, _ := StartGRPCServer(":1337", poolA, syncedA)
|
||||||
|
// // defer grpcSrvA.GracefulStop()
|
||||||
|
//
|
||||||
|
// // Dynamically add a host via discovery
|
||||||
|
// // md.AddHost("nodeB3")
|
||||||
|
// // time.Sleep(100 * time.Millisecond) // allow AddRemote attempt
|
||||||
|
//
|
||||||
|
// // Assertions would verify syncedA.remoteHosts contains "nodeB3"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHostRemovalAndErrorWithMockDiscovery validates behavior when:
|
||||||
|
// 1. Discovery reports a host that cannot be dialed (AddRemote error path)
|
||||||
|
// 2. That host is then removed (Deleted event) without leaving residual state
|
||||||
|
// 3. A second failing host is added afterward (ensuring watcher still processes events)
|
||||||
|
//
|
||||||
|
// NOTE: Because AddRemote currently hard-codes :1337 and we are NOT starting a
|
||||||
|
// real server for the bogus hosts, the dial will fail and the remote host should
|
||||||
|
// never appear in remoteHosts. This intentionally exercises the error logging
|
||||||
|
// path: "AddRemote: dial ... failed".
|
||||||
|
func TestHostRemovalAndErrorWithMockDiscovery(t *testing.T) {
|
||||||
|
// Start a real node A (acts as the observing node)
|
||||||
|
const addrA = "127.0.0.1:18281"
|
||||||
|
hostA := "nodeA-md"
|
||||||
|
|
||||||
|
poolA := NewGrainLocalPool(128, time.Minute, spawn)
|
||||||
|
|
||||||
|
// Mock discovery starts with one bogus host that will fail to connect.
|
||||||
|
md := NewMockDiscovery([]string{"bogus-host-1"})
|
||||||
|
syncedA, err := NewSyncedPool(poolA, hostA, md)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSyncedPool error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
grpcSrvA, err := StartGRPCServer(addrA, poolA, syncedA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StartGRPCServer A error: %v", err)
|
||||||
|
}
|
||||||
|
defer grpcSrvA.GracefulStop()
|
||||||
|
|
||||||
|
// Kick off watch processing by starting Watch() (NewSyncedPool does this internally
|
||||||
|
// when discovery is non-nil, but we ensure events channel is active).
|
||||||
|
// The initial bogus host should trigger AddRemote -> dial failure.
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
syncedA.mu.RLock()
|
||||||
|
if len(syncedA.remoteHosts) != 0 {
|
||||||
|
syncedA.mu.RUnlock()
|
||||||
|
t.Fatalf("expected 0 remoteHosts after failing dial, got %d", len(syncedA.remoteHosts))
|
||||||
|
}
|
||||||
|
syncedA.mu.RUnlock()
|
||||||
|
|
||||||
|
// Remove the bogus host (should not panic; no entry to clean up).
|
||||||
|
md.RemoveHost("bogus-host-1")
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Add another bogus host to ensure watcher still alive.
|
||||||
|
md.AddHost("bogus-host-2")
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
syncedA.mu.RLock()
|
||||||
|
if len(syncedA.remoteHosts) != 0 {
|
||||||
|
syncedA.mu.RUnlock()
|
||||||
|
t.Fatalf("expected 0 remoteHosts after second failing dial, got %d", len(syncedA.remoteHosts))
|
||||||
|
}
|
||||||
|
syncedA.mu.RUnlock()
|
||||||
|
|
||||||
|
// Clean up discovery
|
||||||
|
md.Close()
|
||||||
|
}
|
||||||
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.
|
||||||
|
)
|
||||||
|
}
|
||||||
109
packet-queue.go
109
packet-queue.go
@@ -1,109 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PacketQueue struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
expectedPackages map[PoolMessage]*Listener
|
|
||||||
}
|
|
||||||
|
|
||||||
type CallResult struct {
|
|
||||||
StatusCode uint32
|
|
||||||
Data []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
type Listener struct {
|
|
||||||
Count int
|
|
||||||
Chan chan CallResult
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPacketQueue(connection *PersistentConnection) *PacketQueue {
|
|
||||||
queue := &PacketQueue{
|
|
||||||
expectedPackages: make(map[PoolMessage]*Listener),
|
|
||||||
}
|
|
||||||
go queue.HandleConnection(connection)
|
|
||||||
return queue
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *PacketQueue) RemoveListeners() {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
for _, l := range p.expectedPackages {
|
|
||||||
close(l.Chan)
|
|
||||||
}
|
|
||||||
p.expectedPackages = make(map[PoolMessage]*Listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *PacketQueue) HandleConnection(connection *PersistentConnection) error {
|
|
||||||
defer connection.Close()
|
|
||||||
defer p.RemoveListeners()
|
|
||||||
var packet Packet
|
|
||||||
reader := bufio.NewReader(connection)
|
|
||||||
|
|
||||||
for {
|
|
||||||
err := ReadPacket(reader, &packet)
|
|
||||||
if err != nil {
|
|
||||||
return connection.HandleConnectionError(err)
|
|
||||||
}
|
|
||||||
if packet.Version != CurrentPacketVersion {
|
|
||||||
log.Printf("Incorrect packet version: %v\n", packet.Version)
|
|
||||||
return connection.HandleConnectionError(fmt.Errorf("incorrect packet version: %d", packet.Version))
|
|
||||||
}
|
|
||||||
if packet.DataLength == 0 {
|
|
||||||
go p.HandleData(packet.MessageType, CallResult{
|
|
||||||
StatusCode: packet.StatusCode,
|
|
||||||
Data: []byte{},
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
data, err := GetPacketData(reader, packet.DataLength)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error receiving packet data: %v\n", err)
|
|
||||||
return connection.HandleConnectionError(err)
|
|
||||||
} else {
|
|
||||||
go p.HandleData(packet.MessageType, CallResult{
|
|
||||||
StatusCode: packet.StatusCode,
|
|
||||||
Data: data,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *PacketQueue) HandleData(t PoolMessage, data CallResult) {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
l, ok := p.expectedPackages[t]
|
|
||||||
if ok {
|
|
||||||
l.Chan <- data
|
|
||||||
l.Count--
|
|
||||||
if l.Count == 0 {
|
|
||||||
close(l.Chan)
|
|
||||||
delete(p.expectedPackages, t)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *PacketQueue) Expect(messageType PoolMessage) <-chan CallResult {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
l, ok := p.expectedPackages[messageType]
|
|
||||||
if ok {
|
|
||||||
l.Count++
|
|
||||||
return l.Chan
|
|
||||||
}
|
|
||||||
|
|
||||||
ch := make(chan CallResult)
|
|
||||||
p.expectedPackages[messageType] = &Listener{
|
|
||||||
Count: 1,
|
|
||||||
Chan: ch,
|
|
||||||
}
|
|
||||||
|
|
||||||
return ch
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestQueue(t *testing.T) {
|
|
||||||
localPool := NewGrainLocalPool(100, time.Minute, func(id CartId) (*CartGrain, error) {
|
|
||||||
return &CartGrain{
|
|
||||||
Id: id,
|
|
||||||
storageMessages: []Message{},
|
|
||||||
Items: []*CartItem{},
|
|
||||||
TotalPrice: 0,
|
|
||||||
}, nil
|
|
||||||
})
|
|
||||||
pool, err := NewSyncedPool(localPool, "localhost", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Error creating pool: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = pool.AddRemote("localhost")
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Error adding remote: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
96
packet.go
96
packet.go
@@ -1,96 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/binary"
|
|
||||||
"io"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CartMessage uint32
|
|
||||||
type PackageVersion uint32
|
|
||||||
|
|
||||||
const (
|
|
||||||
RemoteGetState = CartMessage(0x01)
|
|
||||||
RemoteHandleMutation = CartMessage(0x02)
|
|
||||||
ResponseBody = CartMessage(0x03)
|
|
||||||
RemoteGetStateReply = CartMessage(0x04)
|
|
||||||
RemoteHandleMutationReply = CartMessage(0x05)
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
// }
|
|
||||||
345
pool-server.go
345
pool-server.go
@@ -1,9 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
messages "git.tornberg.me/go-cart-actor/proto"
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
)
|
)
|
||||||
@@ -20,22 +25,26 @@ func NewPoolServer(pool GrainPool, pod_name string) *PoolServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PoolServer) HandleGet(w http.ResponseWriter, r *http.Request) error {
|
func (s *PoolServer) process(id CartId, mutation interface{}) (*messages.CartState, error) {
|
||||||
id := r.PathValue("id")
|
grain, err := s.pool.Apply(id, mutation)
|
||||||
data, err := s.pool.Get(ToCartId(id))
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ToCartState(grain), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) HandleGet(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
grain, err := s.pool.Get(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.WriteResult(w, data)
|
|
||||||
|
return s.WriteResult(w, ToCartState(grain))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PoolServer) HandleAddSku(w http.ResponseWriter, r *http.Request) error {
|
func (s *PoolServer) HandleAddSku(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
id := r.PathValue("id")
|
|
||||||
sku := r.PathValue("sku")
|
sku := r.PathValue("sku")
|
||||||
data, err := s.pool.Process(ToCartId(id), Message{
|
data, err := s.process(id, &messages.AddRequest{Sku: sku, Quantity: 1})
|
||||||
Type: AddRequestType,
|
|
||||||
Content: &messages.AddRequest{Sku: sku, Quantity: 1},
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -46,40 +55,38 @@ func ErrorHandler(fn func(w http.ResponseWriter, r *http.Request) error) func(w
|
|||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
err := fn(w, r)
|
err := fn(w, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("Server error, not remote error: %v\n", err)
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
w.Write([]byte(err.Error()))
|
w.Write([]byte(err.Error()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PoolServer) WriteResult(w http.ResponseWriter, result *CallResult) error {
|
func (s *PoolServer) WriteResult(w http.ResponseWriter, result *messages.CartState) error {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
w.Header().Set("X-Pod-Name", s.pod_name)
|
w.Header().Set("X-Pod-Name", s.pod_name)
|
||||||
if result.StatusCode != 200 {
|
if result == nil {
|
||||||
if result.StatusCode >= 200 && result.StatusCode < 600 {
|
|
||||||
w.WriteHeader(int(result.StatusCode))
|
|
||||||
} else {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
}
|
|
||||||
w.Write([]byte(result.Data))
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, err := w.Write(result.Data)
|
enc := json.NewEncoder(w)
|
||||||
|
err := enc.Encode(result)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PoolServer) HandleDeleteItem(w http.ResponseWriter, r *http.Request) error {
|
func (s *PoolServer) HandleDeleteItem(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
id := r.PathValue("id")
|
|
||||||
itemIdString := r.PathValue("itemId")
|
itemIdString := r.PathValue("itemId")
|
||||||
itemId, err := strconv.Atoi(itemIdString)
|
itemId, err := strconv.Atoi(itemIdString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
data, err := s.pool.Process(ToCartId(id), Message{
|
data, err := s.process(id, &messages.RemoveItem{Id: int64(itemId)})
|
||||||
Type: RemoveItemType,
|
|
||||||
Content: &messages.RemoveItem{Id: int64(itemId)},
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -89,21 +96,20 @@ func (s *PoolServer) HandleDeleteItem(w http.ResponseWriter, r *http.Request) er
|
|||||||
type SetDelivery struct {
|
type SetDelivery struct {
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
Items []int64 `json:"items"`
|
Items []int64 `json:"items"`
|
||||||
|
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PoolServer) HandleSetDelivery(w http.ResponseWriter, r *http.Request) error {
|
func (s *PoolServer) HandleSetDelivery(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
id := r.PathValue("id")
|
|
||||||
delivery := SetDelivery{}
|
delivery := SetDelivery{}
|
||||||
err := json.NewDecoder(r.Body).Decode(&delivery)
|
err := json.NewDecoder(r.Body).Decode(&delivery)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
data, err := s.pool.Process(ToCartId(id), Message{
|
data, err := s.process(id, &messages.SetDelivery{
|
||||||
Type: SetDeliveryType,
|
|
||||||
Content: &messages.SetDelivery{
|
|
||||||
Provider: delivery.Provider,
|
Provider: delivery.Provider,
|
||||||
Items: delivery.Items,
|
Items: delivery.Items,
|
||||||
},
|
PickupPoint: delivery.PickupPoint,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -111,11 +117,282 @@ func (s *PoolServer) HandleSetDelivery(w http.ResponseWriter, r *http.Request) e
|
|||||||
return s.WriteResult(w, data)
|
return s.WriteResult(w, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) HandleSetPickupPoint(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
|
||||||
|
deliveryIdString := r.PathValue("deliveryId")
|
||||||
|
deliveryId, err := strconv.Atoi(deliveryIdString)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pickupPoint := messages.PickupPoint{}
|
||||||
|
err = json.NewDecoder(r.Body).Decode(&pickupPoint)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reply, err := s.process(id, &messages.SetPickupPoint{
|
||||||
|
DeliveryId: int64(deliveryId),
|
||||||
|
Id: pickupPoint.Id,
|
||||||
|
Name: pickupPoint.Name,
|
||||||
|
Address: pickupPoint.Address,
|
||||||
|
City: pickupPoint.City,
|
||||||
|
Zip: pickupPoint.Zip,
|
||||||
|
Country: pickupPoint.Country,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.WriteResult(w, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) HandleRemoveDelivery(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
|
||||||
|
deliveryIdString := r.PathValue("deliveryId")
|
||||||
|
deliveryId, err := strconv.Atoi(deliveryIdString)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reply, err := s.process(id, &messages.RemoveDelivery{Id: int64(deliveryId)})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.WriteResult(w, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) HandleQuantityChange(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
changeQuantity := messages.ChangeQuantity{}
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&changeQuantity)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reply, err := s.process(id, &changeQuantity)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.WriteResult(w, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) HandleSetCartItems(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
setCartItems := messages.SetCartRequest{}
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&setCartItems)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reply, err := s.process(id, &setCartItems)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.WriteResult(w, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) HandleAddRequest(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
addRequest := messages.AddRequest{}
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&addRequest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reply, err := s.process(id, &addRequest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.WriteResult(w, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) HandleConfirmation(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
orderId := r.PathValue("orderId")
|
||||||
|
if orderId == "" {
|
||||||
|
return fmt.Errorf("orderId is empty")
|
||||||
|
}
|
||||||
|
order, err := KlarnaInstance.GetOrder(orderId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("X-Pod-Name", s.pod_name)
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return json.NewEncoder(w).Encode(order)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) CreateOrUpdateCheckout(host string, id CartId) (*CheckoutOrder, error) {
|
||||||
|
meta := &CheckoutMeta{
|
||||||
|
Terms: fmt.Sprintf("https://%s/terms", host),
|
||||||
|
Checkout: fmt.Sprintf("https://%s/checkout?order_id={checkout.order.id}", host),
|
||||||
|
Confirmation: fmt.Sprintf("https://%s/confirmation/{checkout.order.id}", host),
|
||||||
|
Validation: fmt.Sprintf("https://%s/validate", host),
|
||||||
|
Push: fmt.Sprintf("https://%s/push?order_id={checkout.order.id}", host),
|
||||||
|
Country: getCountryFromHost(host),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current grain state (may be local or remote)
|
||||||
|
grain, err := s.pool.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build pure checkout payload
|
||||||
|
payload, _, err := BuildCheckoutOrderPayload(grain, meta)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if grain.OrderReference != "" {
|
||||||
|
return KlarnaInstance.UpdateOrder(grain.OrderReference, bytes.NewReader(payload))
|
||||||
|
} else {
|
||||||
|
return KlarnaInstance.CreateOrder(bytes.NewReader(payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) ApplyCheckoutStarted(klarnaOrder *CheckoutOrder, id CartId) (*CartGrain, error) {
|
||||||
|
// Persist initialization state via mutation (best-effort)
|
||||||
|
return s.pool.Apply(id, &messages.InitializeCheckout{
|
||||||
|
OrderId: klarnaOrder.ID,
|
||||||
|
Status: klarnaOrder.Status,
|
||||||
|
PaymentInProgress: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) HandleCheckout(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
klarnaOrder, err := s.CreateOrUpdateCheckout(r.Host, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.ApplyCheckoutStarted(klarnaOrder, id)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
return json.NewEncoder(w).Encode(klarnaOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCartId() CartId {
|
||||||
|
// Deprecated: legacy random/time based cart id generator.
|
||||||
|
// Retained for compatibility; new code should prefer canonical CartID path.
|
||||||
|
cid, err := NewCartID()
|
||||||
|
if err != nil {
|
||||||
|
// Fallback to legacy method only if crypto/rand fails
|
||||||
|
id := time.Now().UnixNano() + rand.Int63()
|
||||||
|
return ToCartId(fmt.Sprintf("%d", id))
|
||||||
|
}
|
||||||
|
return CartIDToLegacy(cid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CookieCartIdHandler(fn func(w http.ResponseWriter, r *http.Request, cartId CartId) error) func(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
// Extract / normalize cookie (preserve legacy textual IDs without rewriting).
|
||||||
|
var legacy CartId
|
||||||
|
cookies := r.CookiesNamed("cartid")
|
||||||
|
if len(cookies) == 0 {
|
||||||
|
// No cookie -> generate new canonical base62 id.
|
||||||
|
cid, generated, _, err := CanonicalizeOrLegacy("")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to generate cart id: %w", err)
|
||||||
|
}
|
||||||
|
legacy = CartIDToLegacy(cid)
|
||||||
|
if generated {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "cartid",
|
||||||
|
Value: cid.String(),
|
||||||
|
Secure: r.TLS != nil,
|
||||||
|
HttpOnly: true,
|
||||||
|
Path: "/",
|
||||||
|
Expires: time.Now().AddDate(0, 0, 14),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
w.Header().Set("Set-Cart-Id", cid.String())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
raw := cookies[0].Value
|
||||||
|
cid, generated, wasBase62, err := CanonicalizeOrLegacy(raw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to canonicalize cart id: %w", err)
|
||||||
|
}
|
||||||
|
legacy = CartIDToLegacy(cid)
|
||||||
|
// Only set a new cookie if we actually generated a brand-new ID (empty input).
|
||||||
|
// For legacy (non-base62) ids we preserve the original text and do not overwrite.
|
||||||
|
if generated && wasBase62 {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "cartid",
|
||||||
|
Value: cid.String(),
|
||||||
|
Secure: r.TLS != nil,
|
||||||
|
HttpOnly: true,
|
||||||
|
Path: "/",
|
||||||
|
Expires: time.Now().AddDate(0, 0, 14),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
w.Header().Set("Set-Cart-Id", cid.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fn(w, r, legacy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId CartId) error {
|
||||||
|
cartId = NewCartId()
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "cartid",
|
||||||
|
Value: cartId.String(),
|
||||||
|
Path: "/",
|
||||||
|
Secure: r.TLS != nil,
|
||||||
|
HttpOnly: true,
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CartIdHandler(fn func(w http.ResponseWriter, r *http.Request, cartId CartId) error) func(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
raw := r.PathValue("id")
|
||||||
|
cid, generated, wasBase62, err := CanonicalizeOrLegacy(raw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid cart id: %w", err)
|
||||||
|
}
|
||||||
|
legacy := CartIDToLegacy(cid)
|
||||||
|
// Only emit Set-Cart-Id header if we produced a brand-new canonical id
|
||||||
|
// AND it is base62 (avoid rewriting legacy textual identifiers).
|
||||||
|
if generated && wasBase62 {
|
||||||
|
w.Header().Set("Set-Cart-Id", cid.String())
|
||||||
|
}
|
||||||
|
return fn(w, r, legacy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *PoolServer) Serve() *http.ServeMux {
|
func (s *PoolServer) Serve() *http.ServeMux {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /{id}", ErrorHandler(s.HandleGet))
|
//mux.HandleFunc("/", s.RewritePath)
|
||||||
mux.HandleFunc("GET /{id}/add/{sku}", ErrorHandler(s.HandleAddSku))
|
mux.HandleFunc("OPTIONS /", func(w http.ResponseWriter, r *http.Request) {
|
||||||
mux.HandleFunc("DELETE /{id}/{itemId}", ErrorHandler(s.HandleDeleteItem))
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
mux.HandleFunc("POST /{id}/delivery", ErrorHandler(s.HandleSetDelivery))
|
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /", ErrorHandler(CookieCartIdHandler(s.HandleGet)))
|
||||||
|
mux.HandleFunc("GET /add/{sku}", ErrorHandler(CookieCartIdHandler(s.HandleAddSku)))
|
||||||
|
mux.HandleFunc("POST /", ErrorHandler(CookieCartIdHandler(s.HandleAddRequest)))
|
||||||
|
mux.HandleFunc("POST /set", ErrorHandler(CookieCartIdHandler(s.HandleSetCartItems)))
|
||||||
|
mux.HandleFunc("DELETE /{itemId}", ErrorHandler(CookieCartIdHandler(s.HandleDeleteItem)))
|
||||||
|
mux.HandleFunc("PUT /", ErrorHandler(CookieCartIdHandler(s.HandleQuantityChange)))
|
||||||
|
mux.HandleFunc("DELETE /", ErrorHandler(CookieCartIdHandler(s.RemoveCartCookie)))
|
||||||
|
mux.HandleFunc("POST /delivery", ErrorHandler(CookieCartIdHandler(s.HandleSetDelivery)))
|
||||||
|
mux.HandleFunc("DELETE /delivery/{deliveryId}", ErrorHandler(CookieCartIdHandler(s.HandleRemoveDelivery)))
|
||||||
|
mux.HandleFunc("PUT /delivery/{deliveryId}/pickupPoint", ErrorHandler(CookieCartIdHandler(s.HandleSetPickupPoint)))
|
||||||
|
mux.HandleFunc("GET /checkout", ErrorHandler(CookieCartIdHandler(s.HandleCheckout)))
|
||||||
|
mux.HandleFunc("GET /confirmation/{orderId}", ErrorHandler(CookieCartIdHandler(s.HandleConfirmation)))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /byid/{id}", ErrorHandler(CartIdHandler(s.HandleGet)))
|
||||||
|
mux.HandleFunc("GET /byid/{id}/add/{sku}", ErrorHandler(CartIdHandler(s.HandleAddSku)))
|
||||||
|
mux.HandleFunc("POST /byid/{id}", ErrorHandler(CartIdHandler(s.HandleAddRequest)))
|
||||||
|
mux.HandleFunc("DELETE /byid/{id}/{itemId}", ErrorHandler(CartIdHandler(s.HandleDeleteItem)))
|
||||||
|
mux.HandleFunc("PUT /byid/{id}", ErrorHandler(CartIdHandler(s.HandleQuantityChange)))
|
||||||
|
mux.HandleFunc("POST /byid/{id}/delivery", ErrorHandler(CartIdHandler(s.HandleSetDelivery)))
|
||||||
|
mux.HandleFunc("DELETE /byid/{id}/delivery/{deliveryId}", ErrorHandler(CartIdHandler(s.HandleRemoveDelivery)))
|
||||||
|
mux.HandleFunc("PUT /byid/{id}/delivery/{deliveryId}/pickupPoint", ErrorHandler(CartIdHandler(s.HandleSetPickupPoint)))
|
||||||
|
mux.HandleFunc("GET /byid/{id}/checkout", ErrorHandler(CartIdHandler(s.HandleCheckout)))
|
||||||
|
mux.HandleFunc("GET /byid/{id}/confirmation", ErrorHandler(CartIdHandler(s.HandleConfirmation)))
|
||||||
|
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,75 +2,34 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/matst80/slask-finder/pkg/index"
|
||||||
)
|
)
|
||||||
|
|
||||||
type EnergyRating struct {
|
// TODO make this configurable
|
||||||
Value string `json:"value,omitempty"`
|
func getBaseUrl(country string) string {
|
||||||
Min string `json:"min,omitempty"`
|
// if country == "se" {
|
||||||
Max string `json:"max,omitempty"`
|
// return "http://s10n-se:8080"
|
||||||
|
// }
|
||||||
|
if country == "no" {
|
||||||
|
return "http://s10n-no.s10n:8080"
|
||||||
|
}
|
||||||
|
if country == "se" {
|
||||||
|
return "http://s10n-se.s10n:8080"
|
||||||
|
}
|
||||||
|
return "http://localhost:8082"
|
||||||
}
|
}
|
||||||
|
|
||||||
type PriceTuple struct {
|
func FetchItem(sku string, country string) (*index.DataItem, error) {
|
||||||
IncVat int `json:"inc"`
|
baseUrl := getBaseUrl(country)
|
||||||
ExVat int `json:"exl"`
|
res, err := http.Get(fmt.Sprintf("%s/api/by-sku/%s", baseUrl, sku))
|
||||||
}
|
|
||||||
|
|
||||||
type OutletItem struct {
|
|
||||||
ArticleNumber string `json:"sku,omitempty"`
|
|
||||||
Price PriceTuple `json:"price,omitempty"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ItemProp struct {
|
|
||||||
Url string `json:"url"`
|
|
||||||
Disclaimer string `json:"disclaimer,omitempty"`
|
|
||||||
ReleaseDate string `json:"releaseDate,omitempty"`
|
|
||||||
SaleStatus string `json:"saleStatus"`
|
|
||||||
MarginPercent float64 `json:"mp,omitempty"`
|
|
||||||
PresaleDate string `json:"presaleDate,omitempty"`
|
|
||||||
Restock string `json:"restock,omitempty"`
|
|
||||||
AdvertisingText string `json:"advertisingText,omitempty"`
|
|
||||||
Img string `json:"img,omitempty"`
|
|
||||||
BadgeUrl string `json:"badgeUrl,omitempty"`
|
|
||||||
EnergyRating *EnergyRating `json:"energyRating,omitempty"`
|
|
||||||
BulletPoints string `json:"bp,omitempty"`
|
|
||||||
LastUpdate int64 `json:"lastUpdate,omitempty"`
|
|
||||||
Created int64 `json:"created,omitempty"`
|
|
||||||
Buyable bool `json:"buyable"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
BuyableInStore bool `json:"buyableInStore"`
|
|
||||||
BoxSize string `json:"boxSize,omitempty"`
|
|
||||||
CheapestBItem *OutletItem `json:"bItem,omitempty"`
|
|
||||||
AItem *OutletItem `json:"aItem,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BaseItem struct {
|
|
||||||
ItemProp
|
|
||||||
StockLevel string `json:"stockLevel,omitempty"`
|
|
||||||
Stock LocationStock `json:"stock"`
|
|
||||||
Id uint `json:"id"`
|
|
||||||
Sku string `json:"sku"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DataItem struct {
|
|
||||||
*BaseItem
|
|
||||||
Fields map[uint]interface{} `json:"values"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type LocationStock []struct {
|
|
||||||
Id string `json:"id"`
|
|
||||||
Level string `json:"level"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func FetchItem(sku string) (*DataItem, error) {
|
|
||||||
res, err := http.Get("https://slask-finder.tornberg.me/api/get/" + sku)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer res.Body.Close()
|
defer res.Body.Close()
|
||||||
var item DataItem
|
var item index.DataItem
|
||||||
err = json.NewDecoder(res.Body).Decode(&item)
|
err = json.NewDecoder(res.Body).Decode(&item)
|
||||||
return &item, err
|
return &item, err
|
||||||
}
|
}
|
||||||
|
|||||||
1545
proto/cart_actor.pb.go
Normal file
1545
proto/cart_actor.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
187
proto/cart_actor.proto
Normal file
187
proto/cart_actor.proto
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package messages;
|
||||||
|
|
||||||
|
option go_package = "git.tornberg.me/go-cart-actor/proto;messages";
|
||||||
|
|
||||||
|
import "messages.proto";
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Cart Actor gRPC API (Breaking v2 - Per-Mutation RPCs)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// This version removes the previous MutationEnvelope + Mutate RPC.
|
||||||
|
// Each mutation now has its own request wrapper and dedicated RPC method
|
||||||
|
// providing simpler, type-focused client stubs and enabling per-mutation
|
||||||
|
// metrics, auth and rate limiting.
|
||||||
|
//
|
||||||
|
// Regenerate Go code after editing:
|
||||||
|
// protoc --go_out=. --go_opt=paths=source_relative \
|
||||||
|
// --go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
||||||
|
// proto/cart_actor.proto proto/messages.proto
|
||||||
|
//
|
||||||
|
// Backward compatibility: This is a breaking change (old clients must update).
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Shared reply for all mutation RPCs.
|
||||||
|
message CartMutationReply {
|
||||||
|
int32 status_code = 1; // HTTP-like status (200 success, 4xx client, 5xx server)
|
||||||
|
oneof result {
|
||||||
|
CartState state = 2; // Updated cart state on success
|
||||||
|
string error = 3; // Error message on failure
|
||||||
|
}
|
||||||
|
int64 server_timestamp = 4; // Server-assigned Unix timestamp (optional auditing)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch current cart state without mutation.
|
||||||
|
message StateRequest {
|
||||||
|
string cart_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message StateReply {
|
||||||
|
int32 status_code = 1;
|
||||||
|
oneof result {
|
||||||
|
CartState state = 2;
|
||||||
|
string error = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-mutation request wrappers. We wrap the existing inner mutation
|
||||||
|
// messages (defined in messages.proto) to add cart_id + optional metadata
|
||||||
|
// without altering the inner message definitions.
|
||||||
|
|
||||||
|
message AddRequestRequest {
|
||||||
|
string cart_id = 1;
|
||||||
|
int64 client_timestamp = 2;
|
||||||
|
AddRequest payload = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AddItemRequest {
|
||||||
|
string cart_id = 1;
|
||||||
|
int64 client_timestamp = 2;
|
||||||
|
AddItem payload = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RemoveItemRequest {
|
||||||
|
string cart_id = 1;
|
||||||
|
int64 client_timestamp = 2;
|
||||||
|
RemoveItem payload = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RemoveDeliveryRequest {
|
||||||
|
string cart_id = 1;
|
||||||
|
int64 client_timestamp = 2;
|
||||||
|
RemoveDelivery payload = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ChangeQuantityRequest {
|
||||||
|
string cart_id = 1;
|
||||||
|
int64 client_timestamp = 2;
|
||||||
|
ChangeQuantity payload = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SetDeliveryRequest {
|
||||||
|
string cart_id = 1;
|
||||||
|
int64 client_timestamp = 2;
|
||||||
|
SetDelivery payload = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SetPickupPointRequest {
|
||||||
|
string cart_id = 1;
|
||||||
|
int64 client_timestamp = 2;
|
||||||
|
SetPickupPoint payload = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CreateCheckoutOrderRequest {
|
||||||
|
string cart_id = 1;
|
||||||
|
int64 client_timestamp = 2;
|
||||||
|
CreateCheckoutOrder payload = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SetCartItemsRequest {
|
||||||
|
string cart_id = 1;
|
||||||
|
int64 client_timestamp = 2;
|
||||||
|
SetCartRequest payload = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OrderCompletedRequest {
|
||||||
|
string cart_id = 1;
|
||||||
|
int64 client_timestamp = 2;
|
||||||
|
OrderCreated payload = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Excerpt: updated messages for camelCase JSON output
|
||||||
|
message CartState {
|
||||||
|
string id = 1; // was cart_id
|
||||||
|
repeated CartItemState items = 2;
|
||||||
|
int64 totalPrice = 3; // was total_price
|
||||||
|
int64 totalTax = 4; // was total_tax
|
||||||
|
int64 totalDiscount = 5; // was total_discount
|
||||||
|
repeated DeliveryState deliveries = 6;
|
||||||
|
bool paymentInProgress = 7; // was payment_in_progress
|
||||||
|
string orderReference = 8; // was order_reference
|
||||||
|
string paymentStatus = 9; // was payment_status
|
||||||
|
bool processing = 10; // NEW (mirrors legacy CartGrain.processing)
|
||||||
|
}
|
||||||
|
|
||||||
|
message CartItemState {
|
||||||
|
int64 id = 1;
|
||||||
|
int64 itemId = 2; // was source_item_id
|
||||||
|
string sku = 3;
|
||||||
|
string name = 4;
|
||||||
|
int64 price = 5; // was unit_price
|
||||||
|
int32 qty = 6; // was quantity
|
||||||
|
int64 totalPrice = 7; // was total_price
|
||||||
|
int64 totalTax = 8; // was total_tax
|
||||||
|
int64 orgPrice = 9; // was org_price
|
||||||
|
int32 taxRate = 10; // was tax_rate
|
||||||
|
int64 totalDiscount = 11;
|
||||||
|
string brand = 12;
|
||||||
|
string category = 13;
|
||||||
|
string category2 = 14;
|
||||||
|
string category3 = 15;
|
||||||
|
string category4 = 16;
|
||||||
|
string category5 = 17;
|
||||||
|
string image = 18;
|
||||||
|
string type = 19; // was article_type
|
||||||
|
string sellerId = 20; // was seller_id
|
||||||
|
string sellerName = 21; // was seller_name
|
||||||
|
string disclaimer = 22;
|
||||||
|
string outlet = 23;
|
||||||
|
string storeId = 24; // was store_id
|
||||||
|
int32 stock = 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeliveryState {
|
||||||
|
int64 id = 1;
|
||||||
|
string provider = 2;
|
||||||
|
int64 price = 3;
|
||||||
|
repeated int64 items = 4; // was item_ids
|
||||||
|
PickupPoint pickupPoint = 5; // was pickup_point
|
||||||
|
}
|
||||||
|
|
||||||
|
// (CheckoutRequest / CheckoutReply removed - checkout handled at HTTP layer)
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Service definition (per-mutation RPCs + checkout)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
service CartActor {
|
||||||
|
rpc AddRequest(AddRequestRequest) returns (CartMutationReply);
|
||||||
|
rpc AddItem(AddItemRequest) returns (CartMutationReply);
|
||||||
|
rpc RemoveItem(RemoveItemRequest) returns (CartMutationReply);
|
||||||
|
rpc RemoveDelivery(RemoveDeliveryRequest) returns (CartMutationReply);
|
||||||
|
rpc ChangeQuantity(ChangeQuantityRequest) returns (CartMutationReply);
|
||||||
|
rpc SetDelivery(SetDeliveryRequest) returns (CartMutationReply);
|
||||||
|
rpc SetPickupPoint(SetPickupPointRequest) returns (CartMutationReply);
|
||||||
|
// (Checkout RPC removed - handled externally)
|
||||||
|
rpc SetCartItems(SetCartItemsRequest) returns (CartMutationReply);
|
||||||
|
rpc OrderCompleted(OrderCompletedRequest) returns (CartMutationReply);
|
||||||
|
|
||||||
|
rpc GetState(StateRequest) returns (StateReply);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Future enhancements:
|
||||||
|
// * BatchMutate RPC (repeated heterogeneous mutations)
|
||||||
|
// * Streaming state updates (WatchState)
|
||||||
|
// * Versioning / optimistic concurrency control
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
473
proto/cart_actor_grpc.pb.go
Normal file
473
proto/cart_actor_grpc.pb.go
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// - protoc-gen-go-grpc v1.5.1
|
||||||
|
// - protoc v3.21.12
|
||||||
|
// source: cart_actor.proto
|
||||||
|
|
||||||
|
package messages
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
grpc "google.golang.org/grpc"
|
||||||
|
codes "google.golang.org/grpc/codes"
|
||||||
|
status "google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is a compile-time assertion to ensure that this generated file
|
||||||
|
// is compatible with the grpc package it is being compiled against.
|
||||||
|
// Requires gRPC-Go v1.64.0 or later.
|
||||||
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
|
const (
|
||||||
|
CartActor_AddRequest_FullMethodName = "/messages.CartActor/AddRequest"
|
||||||
|
CartActor_AddItem_FullMethodName = "/messages.CartActor/AddItem"
|
||||||
|
CartActor_RemoveItem_FullMethodName = "/messages.CartActor/RemoveItem"
|
||||||
|
CartActor_RemoveDelivery_FullMethodName = "/messages.CartActor/RemoveDelivery"
|
||||||
|
CartActor_ChangeQuantity_FullMethodName = "/messages.CartActor/ChangeQuantity"
|
||||||
|
CartActor_SetDelivery_FullMethodName = "/messages.CartActor/SetDelivery"
|
||||||
|
CartActor_SetPickupPoint_FullMethodName = "/messages.CartActor/SetPickupPoint"
|
||||||
|
CartActor_SetCartItems_FullMethodName = "/messages.CartActor/SetCartItems"
|
||||||
|
CartActor_OrderCompleted_FullMethodName = "/messages.CartActor/OrderCompleted"
|
||||||
|
CartActor_GetState_FullMethodName = "/messages.CartActor/GetState"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CartActorClient is the client API for CartActor service.
|
||||||
|
//
|
||||||
|
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||||
|
//
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Service definition (per-mutation RPCs + checkout)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
type CartActorClient interface {
|
||||||
|
AddRequest(ctx context.Context, in *AddRequestRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||||
|
AddItem(ctx context.Context, in *AddItemRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||||
|
RemoveItem(ctx context.Context, in *RemoveItemRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||||
|
RemoveDelivery(ctx context.Context, in *RemoveDeliveryRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||||
|
ChangeQuantity(ctx context.Context, in *ChangeQuantityRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||||
|
SetDelivery(ctx context.Context, in *SetDeliveryRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||||
|
SetPickupPoint(ctx context.Context, in *SetPickupPointRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||||
|
// (Checkout RPC removed - handled externally)
|
||||||
|
SetCartItems(ctx context.Context, in *SetCartItemsRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||||
|
OrderCompleted(ctx context.Context, in *OrderCompletedRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||||
|
GetState(ctx context.Context, in *StateRequest, opts ...grpc.CallOption) (*StateReply, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type cartActorClient struct {
|
||||||
|
cc grpc.ClientConnInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCartActorClient(cc grpc.ClientConnInterface) CartActorClient {
|
||||||
|
return &cartActorClient{cc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cartActorClient) AddRequest(ctx context.Context, in *AddRequestRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CartMutationReply)
|
||||||
|
err := c.cc.Invoke(ctx, CartActor_AddRequest_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cartActorClient) AddItem(ctx context.Context, in *AddItemRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CartMutationReply)
|
||||||
|
err := c.cc.Invoke(ctx, CartActor_AddItem_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cartActorClient) RemoveItem(ctx context.Context, in *RemoveItemRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CartMutationReply)
|
||||||
|
err := c.cc.Invoke(ctx, CartActor_RemoveItem_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cartActorClient) RemoveDelivery(ctx context.Context, in *RemoveDeliveryRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CartMutationReply)
|
||||||
|
err := c.cc.Invoke(ctx, CartActor_RemoveDelivery_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cartActorClient) ChangeQuantity(ctx context.Context, in *ChangeQuantityRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CartMutationReply)
|
||||||
|
err := c.cc.Invoke(ctx, CartActor_ChangeQuantity_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cartActorClient) SetDelivery(ctx context.Context, in *SetDeliveryRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CartMutationReply)
|
||||||
|
err := c.cc.Invoke(ctx, CartActor_SetDelivery_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cartActorClient) SetPickupPoint(ctx context.Context, in *SetPickupPointRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CartMutationReply)
|
||||||
|
err := c.cc.Invoke(ctx, CartActor_SetPickupPoint_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cartActorClient) SetCartItems(ctx context.Context, in *SetCartItemsRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CartMutationReply)
|
||||||
|
err := c.cc.Invoke(ctx, CartActor_SetCartItems_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cartActorClient) OrderCompleted(ctx context.Context, in *OrderCompletedRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(CartMutationReply)
|
||||||
|
err := c.cc.Invoke(ctx, CartActor_OrderCompleted_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cartActorClient) GetState(ctx context.Context, in *StateRequest, opts ...grpc.CallOption) (*StateReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(StateReply)
|
||||||
|
err := c.cc.Invoke(ctx, CartActor_GetState_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CartActorServer is the server API for CartActor service.
|
||||||
|
// All implementations must embed UnimplementedCartActorServer
|
||||||
|
// for forward compatibility.
|
||||||
|
//
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Service definition (per-mutation RPCs + checkout)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
type CartActorServer interface {
|
||||||
|
AddRequest(context.Context, *AddRequestRequest) (*CartMutationReply, error)
|
||||||
|
AddItem(context.Context, *AddItemRequest) (*CartMutationReply, error)
|
||||||
|
RemoveItem(context.Context, *RemoveItemRequest) (*CartMutationReply, error)
|
||||||
|
RemoveDelivery(context.Context, *RemoveDeliveryRequest) (*CartMutationReply, error)
|
||||||
|
ChangeQuantity(context.Context, *ChangeQuantityRequest) (*CartMutationReply, error)
|
||||||
|
SetDelivery(context.Context, *SetDeliveryRequest) (*CartMutationReply, error)
|
||||||
|
SetPickupPoint(context.Context, *SetPickupPointRequest) (*CartMutationReply, error)
|
||||||
|
// (Checkout RPC removed - handled externally)
|
||||||
|
SetCartItems(context.Context, *SetCartItemsRequest) (*CartMutationReply, error)
|
||||||
|
OrderCompleted(context.Context, *OrderCompletedRequest) (*CartMutationReply, error)
|
||||||
|
GetState(context.Context, *StateRequest) (*StateReply, error)
|
||||||
|
mustEmbedUnimplementedCartActorServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnimplementedCartActorServer must be embedded to have
|
||||||
|
// forward compatible implementations.
|
||||||
|
//
|
||||||
|
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||||
|
// pointer dereference when methods are called.
|
||||||
|
type UnimplementedCartActorServer struct{}
|
||||||
|
|
||||||
|
func (UnimplementedCartActorServer) AddRequest(context.Context, *AddRequestRequest) (*CartMutationReply, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method AddRequest not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedCartActorServer) AddItem(context.Context, *AddItemRequest) (*CartMutationReply, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method AddItem not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedCartActorServer) RemoveItem(context.Context, *RemoveItemRequest) (*CartMutationReply, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method RemoveItem not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedCartActorServer) RemoveDelivery(context.Context, *RemoveDeliveryRequest) (*CartMutationReply, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method RemoveDelivery not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedCartActorServer) ChangeQuantity(context.Context, *ChangeQuantityRequest) (*CartMutationReply, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method ChangeQuantity not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedCartActorServer) SetDelivery(context.Context, *SetDeliveryRequest) (*CartMutationReply, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method SetDelivery not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedCartActorServer) SetPickupPoint(context.Context, *SetPickupPointRequest) (*CartMutationReply, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method SetPickupPoint not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedCartActorServer) SetCartItems(context.Context, *SetCartItemsRequest) (*CartMutationReply, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method SetCartItems not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedCartActorServer) OrderCompleted(context.Context, *OrderCompletedRequest) (*CartMutationReply, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method OrderCompleted not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedCartActorServer) GetState(context.Context, *StateRequest) (*StateReply, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method GetState not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedCartActorServer) mustEmbedUnimplementedCartActorServer() {}
|
||||||
|
func (UnimplementedCartActorServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
|
// UnsafeCartActorServer may be embedded to opt out of forward compatibility for this service.
|
||||||
|
// Use of this interface is not recommended, as added methods to CartActorServer will
|
||||||
|
// result in compilation errors.
|
||||||
|
type UnsafeCartActorServer interface {
|
||||||
|
mustEmbedUnimplementedCartActorServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterCartActorServer(s grpc.ServiceRegistrar, srv CartActorServer) {
|
||||||
|
// If the following call pancis, it indicates UnimplementedCartActorServer was
|
||||||
|
// embedded by pointer and is nil. This will cause panics if an
|
||||||
|
// unimplemented method is ever invoked, so we test this at initialization
|
||||||
|
// time to prevent it from happening at runtime later due to I/O.
|
||||||
|
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||||
|
t.testEmbeddedByValue()
|
||||||
|
}
|
||||||
|
s.RegisterService(&CartActor_ServiceDesc, srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _CartActor_AddRequest_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(AddRequestRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(CartActorServer).AddRequest(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: CartActor_AddRequest_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(CartActorServer).AddRequest(ctx, req.(*AddRequestRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _CartActor_AddItem_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(AddItemRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(CartActorServer).AddItem(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: CartActor_AddItem_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(CartActorServer).AddItem(ctx, req.(*AddItemRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _CartActor_RemoveItem_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(RemoveItemRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(CartActorServer).RemoveItem(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: CartActor_RemoveItem_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(CartActorServer).RemoveItem(ctx, req.(*RemoveItemRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _CartActor_RemoveDelivery_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(RemoveDeliveryRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(CartActorServer).RemoveDelivery(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: CartActor_RemoveDelivery_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(CartActorServer).RemoveDelivery(ctx, req.(*RemoveDeliveryRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _CartActor_ChangeQuantity_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(ChangeQuantityRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(CartActorServer).ChangeQuantity(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: CartActor_ChangeQuantity_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(CartActorServer).ChangeQuantity(ctx, req.(*ChangeQuantityRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _CartActor_SetDelivery_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(SetDeliveryRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(CartActorServer).SetDelivery(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: CartActor_SetDelivery_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(CartActorServer).SetDelivery(ctx, req.(*SetDeliveryRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _CartActor_SetPickupPoint_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(SetPickupPointRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(CartActorServer).SetPickupPoint(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: CartActor_SetPickupPoint_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(CartActorServer).SetPickupPoint(ctx, req.(*SetPickupPointRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _CartActor_SetCartItems_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(SetCartItemsRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(CartActorServer).SetCartItems(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: CartActor_SetCartItems_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(CartActorServer).SetCartItems(ctx, req.(*SetCartItemsRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _CartActor_OrderCompleted_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(OrderCompletedRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(CartActorServer).OrderCompleted(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: CartActor_OrderCompleted_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(CartActorServer).OrderCompleted(ctx, req.(*OrderCompletedRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _CartActor_GetState_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(StateRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(CartActorServer).GetState(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: CartActor_GetState_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(CartActorServer).GetState(ctx, req.(*StateRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CartActor_ServiceDesc is the grpc.ServiceDesc for CartActor service.
|
||||||
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
|
// and not to be introspected or modified (even as a copy)
|
||||||
|
var CartActor_ServiceDesc = grpc.ServiceDesc{
|
||||||
|
ServiceName: "messages.CartActor",
|
||||||
|
HandlerType: (*CartActorServer)(nil),
|
||||||
|
Methods: []grpc.MethodDesc{
|
||||||
|
{
|
||||||
|
MethodName: "AddRequest",
|
||||||
|
Handler: _CartActor_AddRequest_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "AddItem",
|
||||||
|
Handler: _CartActor_AddItem_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "RemoveItem",
|
||||||
|
Handler: _CartActor_RemoveItem_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "RemoveDelivery",
|
||||||
|
Handler: _CartActor_RemoveDelivery_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ChangeQuantity",
|
||||||
|
Handler: _CartActor_ChangeQuantity_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "SetDelivery",
|
||||||
|
Handler: _CartActor_SetDelivery_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "SetPickupPoint",
|
||||||
|
Handler: _CartActor_SetPickupPoint_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "SetCartItems",
|
||||||
|
Handler: _CartActor_SetCartItems_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "OrderCompleted",
|
||||||
|
Handler: _CartActor_OrderCompleted_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "GetState",
|
||||||
|
Handler: _CartActor_GetState_Handler,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Streams: []grpc.StreamDesc{},
|
||||||
|
Metadata: "cart_actor.proto",
|
||||||
|
}
|
||||||
435
proto/control_plane.pb.go
Normal file
435
proto/control_plane.pb.go
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// protoc-gen-go v1.36.10
|
||||||
|
// protoc v3.21.12
|
||||||
|
// source: control_plane.proto
|
||||||
|
|
||||||
|
package messages
|
||||||
|
|
||||||
|
import (
|
||||||
|
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||||
|
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||||
|
reflect "reflect"
|
||||||
|
sync "sync"
|
||||||
|
unsafe "unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Verify that this generated code is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||||
|
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Empty request placeholder (common pattern).
|
||||||
|
type Empty struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Empty) Reset() {
|
||||||
|
*x = Empty{}
|
||||||
|
mi := &file_control_plane_proto_msgTypes[0]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Empty) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Empty) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *Empty) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_control_plane_proto_msgTypes[0]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use Empty.ProtoReflect.Descriptor instead.
|
||||||
|
func (*Empty) Descriptor() ([]byte, []int) {
|
||||||
|
return file_control_plane_proto_rawDescGZIP(), []int{0}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping reply includes responding host and its current unix time (seconds).
|
||||||
|
type PingReply struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
|
||||||
|
UnixTime int64 `protobuf:"varint,2,opt,name=unix_time,json=unixTime,proto3" json:"unix_time,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PingReply) Reset() {
|
||||||
|
*x = PingReply{}
|
||||||
|
mi := &file_control_plane_proto_msgTypes[1]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PingReply) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*PingReply) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *PingReply) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_control_plane_proto_msgTypes[1]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use PingReply.ProtoReflect.Descriptor instead.
|
||||||
|
func (*PingReply) Descriptor() ([]byte, []int) {
|
||||||
|
return file_control_plane_proto_rawDescGZIP(), []int{1}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PingReply) GetHost() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Host
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PingReply) GetUnixTime() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.UnixTime
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// NegotiateRequest carries the caller's full view of known hosts (including self).
|
||||||
|
type NegotiateRequest struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
KnownHosts []string `protobuf:"bytes,1,rep,name=known_hosts,json=knownHosts,proto3" json:"known_hosts,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *NegotiateRequest) Reset() {
|
||||||
|
*x = NegotiateRequest{}
|
||||||
|
mi := &file_control_plane_proto_msgTypes[2]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *NegotiateRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*NegotiateRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *NegotiateRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_control_plane_proto_msgTypes[2]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use NegotiateRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*NegotiateRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_control_plane_proto_rawDescGZIP(), []int{2}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *NegotiateRequest) GetKnownHosts() []string {
|
||||||
|
if x != nil {
|
||||||
|
return x.KnownHosts
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NegotiateReply returns the callee's healthy hosts (including itself).
|
||||||
|
type NegotiateReply struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Hosts []string `protobuf:"bytes,1,rep,name=hosts,proto3" json:"hosts,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *NegotiateReply) Reset() {
|
||||||
|
*x = NegotiateReply{}
|
||||||
|
mi := &file_control_plane_proto_msgTypes[3]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *NegotiateReply) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*NegotiateReply) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *NegotiateReply) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_control_plane_proto_msgTypes[3]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use NegotiateReply.ProtoReflect.Descriptor instead.
|
||||||
|
func (*NegotiateReply) Descriptor() ([]byte, []int) {
|
||||||
|
return file_control_plane_proto_rawDescGZIP(), []int{3}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *NegotiateReply) GetHosts() []string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Hosts
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CartIdsReply returns the list of cart IDs (string form) currently owned locally.
|
||||||
|
type CartIdsReply struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
CartIds []string `protobuf:"bytes,1,rep,name=cart_ids,json=cartIds,proto3" json:"cart_ids,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CartIdsReply) Reset() {
|
||||||
|
*x = CartIdsReply{}
|
||||||
|
mi := &file_control_plane_proto_msgTypes[4]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CartIdsReply) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*CartIdsReply) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *CartIdsReply) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_control_plane_proto_msgTypes[4]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use CartIdsReply.ProtoReflect.Descriptor instead.
|
||||||
|
func (*CartIdsReply) Descriptor() ([]byte, []int) {
|
||||||
|
return file_control_plane_proto_rawDescGZIP(), []int{4}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *CartIdsReply) GetCartIds() []string {
|
||||||
|
if x != nil {
|
||||||
|
return x.CartIds
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OwnerChangeAck retained as response type for Closing RPC (ConfirmOwner removed).
|
||||||
|
type OwnerChangeAck struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Accepted bool `protobuf:"varint,1,opt,name=accepted,proto3" json:"accepted,omitempty"`
|
||||||
|
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OwnerChangeAck) Reset() {
|
||||||
|
*x = OwnerChangeAck{}
|
||||||
|
mi := &file_control_plane_proto_msgTypes[5]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OwnerChangeAck) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*OwnerChangeAck) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *OwnerChangeAck) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_control_plane_proto_msgTypes[5]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use OwnerChangeAck.ProtoReflect.Descriptor instead.
|
||||||
|
func (*OwnerChangeAck) Descriptor() ([]byte, []int) {
|
||||||
|
return file_control_plane_proto_rawDescGZIP(), []int{5}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OwnerChangeAck) GetAccepted() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.Accepted
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OwnerChangeAck) GetMessage() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Message
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClosingNotice notifies peers this host is terminating (so they can drop / re-resolve).
|
||||||
|
type ClosingNotice struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ClosingNotice) Reset() {
|
||||||
|
*x = ClosingNotice{}
|
||||||
|
mi := &file_control_plane_proto_msgTypes[6]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ClosingNotice) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ClosingNotice) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *ClosingNotice) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_control_plane_proto_msgTypes[6]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use ClosingNotice.ProtoReflect.Descriptor instead.
|
||||||
|
func (*ClosingNotice) Descriptor() ([]byte, []int) {
|
||||||
|
return file_control_plane_proto_rawDescGZIP(), []int{6}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ClosingNotice) GetHost() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Host
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var File_control_plane_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
|
const file_control_plane_proto_rawDesc = "" +
|
||||||
|
"\n" +
|
||||||
|
"\x13control_plane.proto\x12\bmessages\"\a\n" +
|
||||||
|
"\x05Empty\"<\n" +
|
||||||
|
"\tPingReply\x12\x12\n" +
|
||||||
|
"\x04host\x18\x01 \x01(\tR\x04host\x12\x1b\n" +
|
||||||
|
"\tunix_time\x18\x02 \x01(\x03R\bunixTime\"3\n" +
|
||||||
|
"\x10NegotiateRequest\x12\x1f\n" +
|
||||||
|
"\vknown_hosts\x18\x01 \x03(\tR\n" +
|
||||||
|
"knownHosts\"&\n" +
|
||||||
|
"\x0eNegotiateReply\x12\x14\n" +
|
||||||
|
"\x05hosts\x18\x01 \x03(\tR\x05hosts\")\n" +
|
||||||
|
"\fCartIdsReply\x12\x19\n" +
|
||||||
|
"\bcart_ids\x18\x01 \x03(\tR\acartIds\"F\n" +
|
||||||
|
"\x0eOwnerChangeAck\x12\x1a\n" +
|
||||||
|
"\baccepted\x18\x01 \x01(\bR\baccepted\x12\x18\n" +
|
||||||
|
"\amessage\x18\x02 \x01(\tR\amessage\"#\n" +
|
||||||
|
"\rClosingNotice\x12\x12\n" +
|
||||||
|
"\x04host\x18\x01 \x01(\tR\x04host2\xf4\x01\n" +
|
||||||
|
"\fControlPlane\x12,\n" +
|
||||||
|
"\x04Ping\x12\x0f.messages.Empty\x1a\x13.messages.PingReply\x12A\n" +
|
||||||
|
"\tNegotiate\x12\x1a.messages.NegotiateRequest\x1a\x18.messages.NegotiateReply\x125\n" +
|
||||||
|
"\n" +
|
||||||
|
"GetCartIds\x12\x0f.messages.Empty\x1a\x16.messages.CartIdsReply\x12<\n" +
|
||||||
|
"\aClosing\x12\x17.messages.ClosingNotice\x1a\x18.messages.OwnerChangeAckB.Z,git.tornberg.me/go-cart-actor/proto;messagesb\x06proto3"
|
||||||
|
|
||||||
|
var (
|
||||||
|
file_control_plane_proto_rawDescOnce sync.Once
|
||||||
|
file_control_plane_proto_rawDescData []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
func file_control_plane_proto_rawDescGZIP() []byte {
|
||||||
|
file_control_plane_proto_rawDescOnce.Do(func() {
|
||||||
|
file_control_plane_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_control_plane_proto_rawDesc), len(file_control_plane_proto_rawDesc)))
|
||||||
|
})
|
||||||
|
return file_control_plane_proto_rawDescData
|
||||||
|
}
|
||||||
|
|
||||||
|
var file_control_plane_proto_msgTypes = make([]protoimpl.MessageInfo, 7)
|
||||||
|
var file_control_plane_proto_goTypes = []any{
|
||||||
|
(*Empty)(nil), // 0: messages.Empty
|
||||||
|
(*PingReply)(nil), // 1: messages.PingReply
|
||||||
|
(*NegotiateRequest)(nil), // 2: messages.NegotiateRequest
|
||||||
|
(*NegotiateReply)(nil), // 3: messages.NegotiateReply
|
||||||
|
(*CartIdsReply)(nil), // 4: messages.CartIdsReply
|
||||||
|
(*OwnerChangeAck)(nil), // 5: messages.OwnerChangeAck
|
||||||
|
(*ClosingNotice)(nil), // 6: messages.ClosingNotice
|
||||||
|
}
|
||||||
|
var file_control_plane_proto_depIdxs = []int32{
|
||||||
|
0, // 0: messages.ControlPlane.Ping:input_type -> messages.Empty
|
||||||
|
2, // 1: messages.ControlPlane.Negotiate:input_type -> messages.NegotiateRequest
|
||||||
|
0, // 2: messages.ControlPlane.GetCartIds:input_type -> messages.Empty
|
||||||
|
6, // 3: messages.ControlPlane.Closing:input_type -> messages.ClosingNotice
|
||||||
|
1, // 4: messages.ControlPlane.Ping:output_type -> messages.PingReply
|
||||||
|
3, // 5: messages.ControlPlane.Negotiate:output_type -> messages.NegotiateReply
|
||||||
|
4, // 6: messages.ControlPlane.GetCartIds:output_type -> messages.CartIdsReply
|
||||||
|
5, // 7: messages.ControlPlane.Closing:output_type -> messages.OwnerChangeAck
|
||||||
|
4, // [4:8] is the sub-list for method output_type
|
||||||
|
0, // [0:4] is the sub-list for method input_type
|
||||||
|
0, // [0:0] is the sub-list for extension type_name
|
||||||
|
0, // [0:0] is the sub-list for extension extendee
|
||||||
|
0, // [0:0] is the sub-list for field type_name
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { file_control_plane_proto_init() }
|
||||||
|
func file_control_plane_proto_init() {
|
||||||
|
if File_control_plane_proto != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type x struct{}
|
||||||
|
out := protoimpl.TypeBuilder{
|
||||||
|
File: protoimpl.DescBuilder{
|
||||||
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_control_plane_proto_rawDesc), len(file_control_plane_proto_rawDesc)),
|
||||||
|
NumEnums: 0,
|
||||||
|
NumMessages: 7,
|
||||||
|
NumExtensions: 0,
|
||||||
|
NumServices: 1,
|
||||||
|
},
|
||||||
|
GoTypes: file_control_plane_proto_goTypes,
|
||||||
|
DependencyIndexes: file_control_plane_proto_depIdxs,
|
||||||
|
MessageInfos: file_control_plane_proto_msgTypes,
|
||||||
|
}.Build()
|
||||||
|
File_control_plane_proto = out.File
|
||||||
|
file_control_plane_proto_goTypes = nil
|
||||||
|
file_control_plane_proto_depIdxs = nil
|
||||||
|
}
|
||||||
82
proto/control_plane.proto
Normal file
82
proto/control_plane.proto
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
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 string 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
247
proto/control_plane_grpc.pb.go
Normal file
247
proto/control_plane_grpc.pb.go
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// - protoc-gen-go-grpc v1.5.1
|
||||||
|
// - protoc v3.21.12
|
||||||
|
// source: control_plane.proto
|
||||||
|
|
||||||
|
package messages
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
grpc "google.golang.org/grpc"
|
||||||
|
codes "google.golang.org/grpc/codes"
|
||||||
|
status "google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is a compile-time assertion to ensure that this generated file
|
||||||
|
// is compatible with the grpc package it is being compiled against.
|
||||||
|
// Requires gRPC-Go v1.64.0 or later.
|
||||||
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
|
const (
|
||||||
|
ControlPlane_Ping_FullMethodName = "/messages.ControlPlane/Ping"
|
||||||
|
ControlPlane_Negotiate_FullMethodName = "/messages.ControlPlane/Negotiate"
|
||||||
|
ControlPlane_GetCartIds_FullMethodName = "/messages.ControlPlane/GetCartIds"
|
||||||
|
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)
|
||||||
|
// 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) 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)
|
||||||
|
// 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) 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_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: "Closing",
|
||||||
|
Handler: _ControlPlane_Closing_Handler,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Streams: []grpc.StreamDesc{},
|
||||||
|
Metadata: "control_plane.proto",
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,41 @@
|
|||||||
syntax = "proto3";
|
syntax = "proto3";
|
||||||
package messages;
|
package messages;
|
||||||
option go_package = ".;messages";
|
option go_package = "git.tornberg.me/go-cart-actor/proto;messages";
|
||||||
|
|
||||||
message AddRequest {
|
message AddRequest {
|
||||||
int32 Quantity = 1;
|
int32 quantity = 1;
|
||||||
string Sku = 2;
|
string sku = 2;
|
||||||
|
string country = 3;
|
||||||
|
optional string storeId = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SetCartRequest {
|
||||||
|
repeated AddRequest items = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AddItem {
|
message AddItem {
|
||||||
int32 Quantity = 2;
|
int64 item_id = 1;
|
||||||
int64 Price = 3;
|
int32 quantity = 2;
|
||||||
string Sku = 4;
|
int64 price = 3;
|
||||||
string Name = 5;
|
int64 orgPrice = 9;
|
||||||
string Image = 6;
|
string sku = 4;
|
||||||
|
string name = 5;
|
||||||
|
string image = 6;
|
||||||
|
int32 stock = 7;
|
||||||
|
int32 tax = 8;
|
||||||
|
string brand = 13;
|
||||||
|
string category = 14;
|
||||||
|
string category2 = 15;
|
||||||
|
string category3 = 16;
|
||||||
|
string category4 = 17;
|
||||||
|
string category5 = 18;
|
||||||
|
string disclaimer = 10;
|
||||||
|
string articleType = 11;
|
||||||
|
string sellerId = 19;
|
||||||
|
string sellerName = 20;
|
||||||
|
string country = 21;
|
||||||
|
optional string outlet = 12;
|
||||||
|
optional string storeId = 22;
|
||||||
}
|
}
|
||||||
|
|
||||||
message RemoveItem {
|
message RemoveItem {
|
||||||
@@ -20,17 +43,63 @@ message RemoveItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message ChangeQuantity {
|
message ChangeQuantity {
|
||||||
int64 Id = 1;
|
int64 id = 1;
|
||||||
int32 Quantity = 2;
|
int32 quantity = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message SetDelivery {
|
message SetDelivery {
|
||||||
string Provider = 1;
|
string provider = 1;
|
||||||
repeated int64 Items = 2;
|
repeated int64 items = 2;
|
||||||
|
optional PickupPoint pickupPoint = 3;
|
||||||
|
string country = 4;
|
||||||
|
string zip = 5;
|
||||||
|
optional string address = 6;
|
||||||
|
optional string city = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SetPickupPoint {
|
||||||
|
int64 deliveryId = 1;
|
||||||
|
string id = 2;
|
||||||
|
optional string name = 3;
|
||||||
|
optional string address = 4;
|
||||||
|
optional string city = 5;
|
||||||
|
optional string zip = 6;
|
||||||
|
optional string country = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PickupPoint {
|
||||||
|
string id = 1;
|
||||||
|
optional string name = 2;
|
||||||
|
optional string address = 3;
|
||||||
|
optional string city = 4;
|
||||||
|
optional string zip = 5;
|
||||||
|
optional string country = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message RemoveDelivery {
|
message RemoveDelivery {
|
||||||
int64 Id = 1;
|
int64 id = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message CreateCheckoutOrder {
|
||||||
|
string terms = 1;
|
||||||
|
string checkout = 2;
|
||||||
|
string confirmation = 3;
|
||||||
|
string push = 4;
|
||||||
|
string validation = 5;
|
||||||
|
string country = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) (*CallResult, error) {
|
|
||||||
var result *CallResult
|
|
||||||
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) (*CallResult, error) {
|
|
||||||
grain, err := p.findOrCreateGrain(id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return grain.GetCurrentState()
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (id CartId) String() string {
|
|
||||||
return strings.Trim(string(id[:]), "\x00")
|
|
||||||
}
|
|
||||||
|
|
||||||
func ToCartId(id string) CartId {
|
|
||||||
var result [16]byte
|
|
||||||
copy(result[:], []byte(id))
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
type RemoteGrain struct {
|
|
||||||
*CartClient
|
|
||||||
Id CartId
|
|
||||||
Host string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRemoteGrain(id CartId, host string) (*RemoteGrain, error) {
|
|
||||||
client, err := CartDial(fmt.Sprintf("%s:1337", host))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &RemoteGrain{
|
|
||||||
Id: id,
|
|
||||||
Host: host,
|
|
||||||
CartClient: client,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
remoteCartLatency = promauto.NewCounter(prometheus.CounterOpts{
|
|
||||||
Name: "cart_remote_grain_calls_total_latency",
|
|
||||||
Help: "The total latency of remote grains",
|
|
||||||
})
|
|
||||||
remoteCartCallsTotal = promauto.NewCounter(prometheus.CounterOpts{
|
|
||||||
Name: "cart_remote_grain_calls_total",
|
|
||||||
Help: "The total number of calls to remote grains",
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
var start time.Time
|
|
||||||
|
|
||||||
func MeasureLatency(fn func() (*CallResult, error)) (*CallResult, error) {
|
|
||||||
start = time.Now()
|
|
||||||
data, err := fn()
|
|
||||||
if err != nil {
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
elapsed := time.Since(start).Milliseconds()
|
|
||||||
go func() {
|
|
||||||
remoteCartLatency.Add(float64(elapsed))
|
|
||||||
remoteCartCallsTotal.Inc()
|
|
||||||
}()
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *RemoteGrain) HandleMessage(message *Message, isReplay bool) (*CallResult, error) {
|
|
||||||
|
|
||||||
data, err := GetData(message.Write)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
reply, err := MeasureLatency(func() (*CallResult, error) {
|
|
||||||
return g.Call(RemoteHandleMutation, g.Id, RemoteHandleMutationReply, data)
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return reply, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *RemoteGrain) GetId() CartId {
|
|
||||||
return g.Id
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *RemoteGrain) GetCurrentState() (*CallResult, error) {
|
|
||||||
return MeasureLatency(func() (*CallResult, error) { return g.Call(RemoteGetState, g.Id, RemoteGetStateReply, []byte{}) })
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type RemoteHost struct {
|
|
||||||
*Client
|
|
||||||
Host string
|
|
||||||
MissedPings int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *RemoteHost) IsHealthy() bool {
|
|
||||||
return !h.Dead && h.MissedPings < 3
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *RemoteHost) Initialize(p *SyncedPool) {
|
|
||||||
|
|
||||||
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))
|
|
||||||
p.mu.Lock()
|
|
||||||
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)
|
|
||||||
p.mu.Unlock()
|
|
||||||
go p.Negotiate()
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *RemoteHost) Ping() error {
|
|
||||||
_, err := h.Call(Ping, Pong, []byte{})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
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, RemoteNegotiateResponse, []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.Data))
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Split(string(reply.Data), ";"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *RemoteHost) GetCartMappings() ([]CartId, error) {
|
|
||||||
reply, err := g.Call(GetCartIds, CartIdsResponse, []byte{})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if reply.StatusCode != 200 {
|
|
||||||
log.Printf("Remote returned error on get cart mappings: %s", string(reply.Data))
|
|
||||||
return nil, fmt.Errorf("remote returned error: %s", string(reply.Data))
|
|
||||||
}
|
|
||||||
parts := strings.Split(string(reply.Data), ";")
|
|
||||||
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, AckChange, []byte(fmt.Sprintf("%s;%s", id, host)))
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if string(reply.Data) != "ok" {
|
|
||||||
return fmt.Errorf("remote grain change failed %s", string(reply.Data))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
341
remote_grain_grpc.go
Normal file
341
remote_grain_grpc.go
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
proto "git.tornberg.me/go-cart-actor/proto" // generated package name is 'messages'; aliased as proto for consistency
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RemoteGrainGRPC is the gRPC-backed implementation of a remote grain.
|
||||||
|
// It mirrors the previous RemoteGrain (TCP/frame based) while using the
|
||||||
|
// new CartActor gRPC service. It implements the Grain interface so that
|
||||||
|
// SyncedPool can remain largely unchanged when swapping transport layers.
|
||||||
|
type RemoteGrainGRPC struct {
|
||||||
|
Id CartId
|
||||||
|
Host string
|
||||||
|
client proto.CartActorClient
|
||||||
|
// Optional: keep the underlying conn so higher-level code can close if needed
|
||||||
|
conn *grpc.ClientConn
|
||||||
|
|
||||||
|
// Per-call timeout settings (tunable)
|
||||||
|
mutateTimeout time.Duration
|
||||||
|
stateTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRemoteGrainGRPC constructs a remote grain adapter from an existing gRPC client.
|
||||||
|
func NewRemoteGrainGRPC(id CartId, host string, client proto.CartActorClient) *RemoteGrainGRPC {
|
||||||
|
return &RemoteGrainGRPC{
|
||||||
|
Id: id,
|
||||||
|
Host: host,
|
||||||
|
client: client,
|
||||||
|
mutateTimeout: 800 * time.Millisecond,
|
||||||
|
stateTimeout: 400 * time.Millisecond,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRemoteGrainGRPCWithConn dials the target and creates the gRPC client.
|
||||||
|
// target should be host:port (where the CartActor service is exposed).
|
||||||
|
func NewRemoteGrainGRPCWithConn(id CartId, host string, target string, dialOpts ...grpc.DialOption) (*RemoteGrainGRPC, error) {
|
||||||
|
// NOTE: insecure for initial migration; should be replaced with TLS later.
|
||||||
|
baseOpts := []grpc.DialOption{grpc.WithInsecure(), grpc.WithBlock()}
|
||||||
|
baseOpts = append(baseOpts, dialOpts...)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
conn, err := grpc.DialContext(ctx, target, baseOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := proto.NewCartActorClient(conn)
|
||||||
|
return &RemoteGrainGRPC{
|
||||||
|
Id: id,
|
||||||
|
Host: host,
|
||||||
|
client: client,
|
||||||
|
conn: conn,
|
||||||
|
mutateTimeout: 800 * time.Millisecond,
|
||||||
|
stateTimeout: 400 * time.Millisecond,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *RemoteGrainGRPC) GetId() CartId {
|
||||||
|
return g.Id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply executes a cart mutation via per-mutation RPCs (breaking v2 API)
|
||||||
|
// and returns a *CartGrain reconstructed from the CartMutationReply state.
|
||||||
|
func (g *RemoteGrainGRPC) Apply(content interface{}, isReplay bool) (*CartGrain, error) {
|
||||||
|
if isReplay {
|
||||||
|
return nil, fmt.Errorf("replay not supported for remote grains")
|
||||||
|
}
|
||||||
|
if content == nil {
|
||||||
|
return nil, fmt.Errorf("nil mutation content")
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := time.Now().Unix()
|
||||||
|
|
||||||
|
var invoke func(ctx context.Context) (*proto.CartMutationReply, error)
|
||||||
|
|
||||||
|
switch m := content.(type) {
|
||||||
|
case *proto.AddRequest:
|
||||||
|
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||||
|
return g.client.AddRequest(ctx, &proto.AddRequestRequest{
|
||||||
|
CartId: g.Id.String(),
|
||||||
|
ClientTimestamp: ts,
|
||||||
|
Payload: m,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case *proto.AddItem:
|
||||||
|
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||||
|
return g.client.AddItem(ctx, &proto.AddItemRequest{
|
||||||
|
CartId: g.Id.String(),
|
||||||
|
ClientTimestamp: ts,
|
||||||
|
Payload: m,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case *proto.RemoveItem:
|
||||||
|
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||||
|
return g.client.RemoveItem(ctx, &proto.RemoveItemRequest{
|
||||||
|
CartId: g.Id.String(),
|
||||||
|
ClientTimestamp: ts,
|
||||||
|
Payload: m,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case *proto.RemoveDelivery:
|
||||||
|
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||||
|
return g.client.RemoveDelivery(ctx, &proto.RemoveDeliveryRequest{
|
||||||
|
CartId: g.Id.String(),
|
||||||
|
ClientTimestamp: ts,
|
||||||
|
Payload: m,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case *proto.ChangeQuantity:
|
||||||
|
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||||
|
return g.client.ChangeQuantity(ctx, &proto.ChangeQuantityRequest{
|
||||||
|
CartId: g.Id.String(),
|
||||||
|
ClientTimestamp: ts,
|
||||||
|
Payload: m,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case *proto.SetDelivery:
|
||||||
|
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||||
|
return g.client.SetDelivery(ctx, &proto.SetDeliveryRequest{
|
||||||
|
CartId: g.Id.String(),
|
||||||
|
ClientTimestamp: ts,
|
||||||
|
Payload: m,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case *proto.SetPickupPoint:
|
||||||
|
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||||
|
return g.client.SetPickupPoint(ctx, &proto.SetPickupPointRequest{
|
||||||
|
CartId: g.Id.String(),
|
||||||
|
ClientTimestamp: ts,
|
||||||
|
Payload: m,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case *proto.CreateCheckoutOrder:
|
||||||
|
return nil, fmt.Errorf("CreateCheckoutOrder deprecated: checkout is handled via HTTP endpoint (HandleCheckout)")
|
||||||
|
case *proto.SetCartRequest:
|
||||||
|
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||||
|
return g.client.SetCartItems(ctx, &proto.SetCartItemsRequest{
|
||||||
|
CartId: g.Id.String(),
|
||||||
|
ClientTimestamp: ts,
|
||||||
|
Payload: m,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case *proto.OrderCreated:
|
||||||
|
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||||
|
return g.client.OrderCompleted(ctx, &proto.OrderCompletedRequest{
|
||||||
|
CartId: g.Id.String(),
|
||||||
|
ClientTimestamp: ts,
|
||||||
|
Payload: m,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported mutation type %T", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
if invoke == nil {
|
||||||
|
return nil, fmt.Errorf("no invocation mapped for mutation %T", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), g.mutateTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, err := invoke(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
if e := resp.GetError(); e != "" {
|
||||||
|
return nil, fmt.Errorf("remote mutation failed %d: %s", resp.StatusCode, e)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("remote mutation failed %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
state := resp.GetState()
|
||||||
|
if state == nil {
|
||||||
|
return nil, fmt.Errorf("mutation reply missing state on success")
|
||||||
|
}
|
||||||
|
// Reconstruct a lightweight CartGrain (only fields we expose internally)
|
||||||
|
grain := &CartGrain{
|
||||||
|
Id: ToCartId(state.Id),
|
||||||
|
TotalPrice: state.TotalPrice,
|
||||||
|
TotalTax: state.TotalTax,
|
||||||
|
TotalDiscount: state.TotalDiscount,
|
||||||
|
PaymentInProgress: state.PaymentInProgress,
|
||||||
|
OrderReference: state.OrderReference,
|
||||||
|
PaymentStatus: state.PaymentStatus,
|
||||||
|
}
|
||||||
|
// Items
|
||||||
|
for _, it := range state.Items {
|
||||||
|
if it == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outlet := toPtr(it.Outlet)
|
||||||
|
storeId := toPtr(it.StoreId)
|
||||||
|
grain.Items = append(grain.Items, &CartItem{
|
||||||
|
Id: int(it.Id),
|
||||||
|
ItemId: int(it.ItemId),
|
||||||
|
Sku: it.Sku,
|
||||||
|
Name: it.Name,
|
||||||
|
Price: it.Price,
|
||||||
|
Quantity: int(it.Qty),
|
||||||
|
TotalPrice: it.TotalPrice,
|
||||||
|
TotalTax: it.TotalTax,
|
||||||
|
OrgPrice: it.OrgPrice,
|
||||||
|
TaxRate: int(it.TaxRate),
|
||||||
|
Brand: it.Brand,
|
||||||
|
Category: it.Category,
|
||||||
|
Category2: it.Category2,
|
||||||
|
Category3: it.Category3,
|
||||||
|
Category4: it.Category4,
|
||||||
|
Category5: it.Category5,
|
||||||
|
Image: it.Image,
|
||||||
|
ArticleType: it.Type,
|
||||||
|
SellerId: it.SellerId,
|
||||||
|
SellerName: it.SellerName,
|
||||||
|
Disclaimer: it.Disclaimer,
|
||||||
|
Outlet: outlet,
|
||||||
|
StoreId: storeId,
|
||||||
|
Stock: StockStatus(it.Stock),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Deliveries
|
||||||
|
for _, d := range state.Deliveries {
|
||||||
|
if d == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
intIds := make([]int, 0, len(d.Items))
|
||||||
|
for _, id := range d.Items {
|
||||||
|
intIds = append(intIds, int(id))
|
||||||
|
}
|
||||||
|
grain.Deliveries = append(grain.Deliveries, &CartDelivery{
|
||||||
|
Id: int(d.Id),
|
||||||
|
Provider: d.Provider,
|
||||||
|
Price: d.Price,
|
||||||
|
Items: intIds,
|
||||||
|
PickupPoint: d.PickupPoint,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return grain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentState retrieves the current cart state using the typed StateReply oneof.
|
||||||
|
func (g *RemoteGrainGRPC) GetCurrentState() (*CartGrain, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), g.stateTimeout)
|
||||||
|
defer cancel()
|
||||||
|
resp, err := g.client.GetState(ctx, &proto.StateRequest{CartId: g.Id.String()})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
if e := resp.GetError(); e != "" {
|
||||||
|
return nil, fmt.Errorf("remote get state failed %d: %s", resp.StatusCode, e)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("remote get state failed %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
state := resp.GetState()
|
||||||
|
if state == nil {
|
||||||
|
return nil, fmt.Errorf("state reply missing state on success")
|
||||||
|
}
|
||||||
|
grain := &CartGrain{
|
||||||
|
Id: ToCartId(state.Id),
|
||||||
|
TotalPrice: state.TotalPrice,
|
||||||
|
TotalTax: state.TotalTax,
|
||||||
|
TotalDiscount: state.TotalDiscount,
|
||||||
|
PaymentInProgress: state.PaymentInProgress,
|
||||||
|
OrderReference: state.OrderReference,
|
||||||
|
PaymentStatus: state.PaymentStatus,
|
||||||
|
}
|
||||||
|
for _, it := range state.Items {
|
||||||
|
if it == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outlet := toPtr(it.Outlet)
|
||||||
|
storeId := toPtr(it.StoreId)
|
||||||
|
grain.Items = append(grain.Items, &CartItem{
|
||||||
|
Id: int(it.Id),
|
||||||
|
ItemId: int(it.ItemId),
|
||||||
|
Sku: it.Sku,
|
||||||
|
Name: it.Name,
|
||||||
|
Price: it.Price,
|
||||||
|
Quantity: int(it.Qty),
|
||||||
|
TotalPrice: it.TotalPrice,
|
||||||
|
TotalTax: it.TotalTax,
|
||||||
|
OrgPrice: it.OrgPrice,
|
||||||
|
TaxRate: int(it.TaxRate),
|
||||||
|
Brand: it.Brand,
|
||||||
|
Category: it.Category,
|
||||||
|
Category2: it.Category2,
|
||||||
|
Category3: it.Category3,
|
||||||
|
Category4: it.Category4,
|
||||||
|
Category5: it.Category5,
|
||||||
|
Image: it.Image,
|
||||||
|
ArticleType: it.Type,
|
||||||
|
SellerId: it.SellerId,
|
||||||
|
SellerName: it.SellerName,
|
||||||
|
Disclaimer: it.Disclaimer,
|
||||||
|
Outlet: outlet,
|
||||||
|
StoreId: storeId,
|
||||||
|
Stock: StockStatus(it.Stock),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, d := range state.Deliveries {
|
||||||
|
if d == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
intIds := make([]int, 0, len(d.Items))
|
||||||
|
for _, id := range d.Items {
|
||||||
|
intIds = append(intIds, int(id))
|
||||||
|
}
|
||||||
|
grain.Deliveries = append(grain.Deliveries, &CartDelivery{
|
||||||
|
Id: int(d.Id),
|
||||||
|
Provider: d.Provider,
|
||||||
|
Price: d.Price,
|
||||||
|
Items: intIds,
|
||||||
|
PickupPoint: d.PickupPoint,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return grain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the underlying gRPC connection if this adapter created it.
|
||||||
|
func (g *RemoteGrainGRPC) Close() error {
|
||||||
|
if g.conn != nil {
|
||||||
|
return g.conn.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug helper to log operations (optional).
|
||||||
|
func (g *RemoteGrainGRPC) logf(format string, args ...interface{}) {
|
||||||
|
log.Printf("[remote-grain-grpc host=%s id=%s] %s", g.Host, g.Id.String(), fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
344
ring.go
Normal file
344
ring.go
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ring.go
|
||||||
|
//
|
||||||
|
// Consistent hashing ring skeleton for future integration.
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// This file introduces a minimal, allocation‑light consistent hashing structure
|
||||||
|
// intended to replace per-cart ownership negotiation. It focuses on:
|
||||||
|
// * Deterministic lookup: O(log V) via binary search
|
||||||
|
// * Even(ish) distribution using virtual nodes (vnodes)
|
||||||
|
// * Epoch / fingerprint tracking to detect membership drift
|
||||||
|
//
|
||||||
|
// NOT YET WIRED:
|
||||||
|
// * SyncedPool integration (ownerForCart, lazy migration)
|
||||||
|
// * Replication factor > 1
|
||||||
|
// * Persistent state migration
|
||||||
|
//
|
||||||
|
// Safe to import now; unused until explicit integration code is added.
|
||||||
|
//
|
||||||
|
// Design Notes
|
||||||
|
// ------------
|
||||||
|
// - Hosts contribute `vnodesPerHost` virtual nodes. Higher counts smooth
|
||||||
|
// distribution at cost of memory (V = hosts * vnodesPerHost).
|
||||||
|
// - Hash of vnode = FNV1a64(host + "#" + index). For improved quality you
|
||||||
|
// can swap in xxhash or siphash later without changing API (but doing so
|
||||||
|
// will reshuffle ownership).
|
||||||
|
// - Cart ownership lookup uses either cartID.Raw() when provided (uniform
|
||||||
|
// 64-bit space) or falls back to hashing string forms (legacy).
|
||||||
|
// - Epoch is monotonically increasing; consumers can fence stale results.
|
||||||
|
//
|
||||||
|
// Future Extensions
|
||||||
|
// -----------------
|
||||||
|
// - Weighted hosts (proportionally more vnodes).
|
||||||
|
// - Replication: LookupN(h, n) to return primary + replicas.
|
||||||
|
// - Streaming / diff-based ring updates (gossip).
|
||||||
|
// - Hash function injection for deterministic test scenarios.
|
||||||
|
//
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Vnode represents a single virtual node position on the ring.
|
||||||
|
type Vnode struct {
|
||||||
|
Hash uint64 // position on the ring
|
||||||
|
Host string // physical host owning this vnode
|
||||||
|
Index int // per-host vnode index (0..vnodesPerHost-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ring is an immutable consistent hash ring snapshot.
|
||||||
|
type Ring struct {
|
||||||
|
Epoch uint64
|
||||||
|
Vnodes []Vnode // sorted by Hash
|
||||||
|
hosts []string
|
||||||
|
fingerprint uint64 // membership fingerprint (order-independent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RingBuilder accumulates parameters to construct a Ring.
|
||||||
|
type RingBuilder struct {
|
||||||
|
epoch uint64
|
||||||
|
vnodesPerHost int
|
||||||
|
hosts []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRingBuilder creates a builder with defaults.
|
||||||
|
func NewRingBuilder() *RingBuilder {
|
||||||
|
return &RingBuilder{
|
||||||
|
vnodesPerHost: 64, // a reasonable default for small clusters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *RingBuilder) WithEpoch(e uint64) *RingBuilder {
|
||||||
|
b.epoch = e
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *RingBuilder) WithVnodesPerHost(n int) *RingBuilder {
|
||||||
|
if n > 0 {
|
||||||
|
b.vnodesPerHost = n
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *RingBuilder) WithHosts(hosts []string) *RingBuilder {
|
||||||
|
uniq := make(map[string]struct{}, len(hosts))
|
||||||
|
out := make([]string, 0, len(hosts))
|
||||||
|
for _, h := range hosts {
|
||||||
|
h = strings.TrimSpace(h)
|
||||||
|
if h == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := uniq[h]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
uniq[h] = struct{}{}
|
||||||
|
out = append(out, h)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
b.hosts = out
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *RingBuilder) Build() *Ring {
|
||||||
|
if len(b.hosts) == 0 {
|
||||||
|
return &Ring{
|
||||||
|
Epoch: b.epoch,
|
||||||
|
Vnodes: nil,
|
||||||
|
hosts: nil,
|
||||||
|
fingerprint: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalVnodes := len(b.hosts) * b.vnodesPerHost
|
||||||
|
vnodes := make([]Vnode, 0, totalVnodes)
|
||||||
|
|
||||||
|
for _, host := range b.hosts {
|
||||||
|
for i := 0; i < b.vnodesPerHost; i++ {
|
||||||
|
h := hashVnode(host, i)
|
||||||
|
vnodes = append(vnodes, Vnode{
|
||||||
|
Hash: h,
|
||||||
|
Host: host,
|
||||||
|
Index: i,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(vnodes, func(i, j int) bool {
|
||||||
|
if vnodes[i].Hash == vnodes[j].Hash {
|
||||||
|
// Tie-break deterministically by host then index to avoid instability
|
||||||
|
if vnodes[i].Host == vnodes[j].Host {
|
||||||
|
return vnodes[i].Index < vnodes[j].Index
|
||||||
|
}
|
||||||
|
return vnodes[i].Host < vnodes[j].Host
|
||||||
|
}
|
||||||
|
return vnodes[i].Hash < vnodes[j].Hash
|
||||||
|
})
|
||||||
|
|
||||||
|
fp := fingerprintHosts(b.hosts)
|
||||||
|
|
||||||
|
return &Ring{
|
||||||
|
Epoch: b.epoch,
|
||||||
|
Vnodes: vnodes,
|
||||||
|
hosts: append([]string(nil), b.hosts...),
|
||||||
|
fingerprint: fp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hosts returns a copy of the host list (sorted).
|
||||||
|
func (r *Ring) Hosts() []string {
|
||||||
|
if len(r.hosts) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cp := make([]string, len(r.hosts))
|
||||||
|
copy(cp, r.hosts)
|
||||||
|
return cp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fingerprint returns a hash representing the unordered membership set.
|
||||||
|
func (r *Ring) Fingerprint() uint64 {
|
||||||
|
return r.fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty indicates ring has no vnodes.
|
||||||
|
func (r *Ring) Empty() bool {
|
||||||
|
return len(r.Vnodes) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup returns the vnode owning a given hash value.
|
||||||
|
func (r *Ring) Lookup(h uint64) Vnode {
|
||||||
|
if len(r.Vnodes) == 0 {
|
||||||
|
return Vnode{}
|
||||||
|
}
|
||||||
|
// Binary search: first position with Hash >= h
|
||||||
|
i := sort.Search(len(r.Vnodes), func(i int) bool {
|
||||||
|
return r.Vnodes[i].Hash >= h
|
||||||
|
})
|
||||||
|
if i == len(r.Vnodes) {
|
||||||
|
return r.Vnodes[0]
|
||||||
|
}
|
||||||
|
return r.Vnodes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupID selects owner vnode for a CartID (fast path).
|
||||||
|
func (r *Ring) LookupID(id CartID) Vnode {
|
||||||
|
return r.Lookup(id.Raw())
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupString hashes an arbitrary string and looks up owner.
|
||||||
|
func (r *Ring) LookupString(s string) Vnode {
|
||||||
|
return r.Lookup(hashKeyString(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupN returns up to n distinct host vnodes in ring order
|
||||||
|
// starting from the primary owner of hash h (for replication).
|
||||||
|
func (r *Ring) LookupN(h uint64, n int) []Vnode {
|
||||||
|
if n <= 0 || len(r.Vnodes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if n > len(r.hosts) {
|
||||||
|
n = len(r.hosts)
|
||||||
|
}
|
||||||
|
owners := make([]Vnode, 0, n)
|
||||||
|
seen := make(map[string]struct{}, n)
|
||||||
|
|
||||||
|
start := r.Lookup(h)
|
||||||
|
|
||||||
|
// Find index of start (can binary search again or linear scan; since we
|
||||||
|
// already have start.Hash we do another search for clarity)
|
||||||
|
i := sort.Search(len(r.Vnodes), func(i int) bool {
|
||||||
|
return r.Vnodes[i].Hash >= start.Hash
|
||||||
|
})
|
||||||
|
if i == len(r.Vnodes) {
|
||||||
|
i = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx := 0; len(owners) < n && idx < len(r.Vnodes); idx++ {
|
||||||
|
v := r.Vnodes[(i+idx)%len(r.Vnodes)]
|
||||||
|
if _, ok := seen[v.Host]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[v.Host] = struct{}{}
|
||||||
|
owners = append(owners, v)
|
||||||
|
}
|
||||||
|
return owners
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiffHosts compares this ring's membership to another.
|
||||||
|
func (r *Ring) DiffHosts(other *Ring) (added []string, removed []string) {
|
||||||
|
if other == nil {
|
||||||
|
return r.Hosts(), nil
|
||||||
|
}
|
||||||
|
cur := make(map[string]struct{}, len(r.hosts))
|
||||||
|
for _, h := range r.hosts {
|
||||||
|
cur[h] = struct{}{}
|
||||||
|
}
|
||||||
|
oth := make(map[string]struct{}, len(other.hosts))
|
||||||
|
for _, h := range other.hosts {
|
||||||
|
oth[h] = struct{}{}
|
||||||
|
}
|
||||||
|
for h := range cur {
|
||||||
|
if _, ok := oth[h]; !ok {
|
||||||
|
removed = append(removed, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for h := range oth {
|
||||||
|
if _, ok := cur[h]; !ok {
|
||||||
|
added = append(added, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(added)
|
||||||
|
sort.Strings(removed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------- Hash Functions ---------------------------------
|
||||||
|
|
||||||
|
func hashVnode(host string, idx int) uint64 {
|
||||||
|
h := fnv.New64a()
|
||||||
|
_, _ = h.Write([]byte(host))
|
||||||
|
_, _ = h.Write([]byte{'#'})
|
||||||
|
var buf [8]byte
|
||||||
|
binary.BigEndian.PutUint64(buf[:], uint64(idx))
|
||||||
|
_, _ = h.Write(buf[:])
|
||||||
|
return h.Sum64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashKeyString provides a stable hash for arbitrary string keys (legacy IDs).
|
||||||
|
func hashKeyString(s string) uint64 {
|
||||||
|
h := fnv.New64a()
|
||||||
|
_, _ = h.Write([]byte(s))
|
||||||
|
return h.Sum64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// fingerprintHosts produces an order-insensitive hash over the host set.
|
||||||
|
func fingerprintHosts(hosts []string) uint64 {
|
||||||
|
if len(hosts) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
h := fnv.New64a()
|
||||||
|
for _, host := range hosts {
|
||||||
|
_, _ = h.Write([]byte(host))
|
||||||
|
_, _ = h.Write([]byte{0})
|
||||||
|
}
|
||||||
|
return h.Sum64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------- Thread-Safe Wrapper -----------------------------
|
||||||
|
//
|
||||||
|
// RingRef offers atomic swap + read semantics. SyncedPool can embed or hold
|
||||||
|
// one of these to manage live ring updates safely.
|
||||||
|
|
||||||
|
type RingRef struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
ring *Ring
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRingRef(r *Ring) *RingRef {
|
||||||
|
return &RingRef{ring: r}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rr *RingRef) Get() *Ring {
|
||||||
|
rr.mu.RLock()
|
||||||
|
r := rr.ring
|
||||||
|
rr.mu.RUnlock()
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rr *RingRef) Set(r *Ring) {
|
||||||
|
rr.mu.Lock()
|
||||||
|
rr.ring = r
|
||||||
|
rr.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rr *RingRef) LookupID(id CartID) Vnode {
|
||||||
|
r := rr.Get()
|
||||||
|
if r == nil {
|
||||||
|
return Vnode{}
|
||||||
|
}
|
||||||
|
return r.LookupID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------- Debug Utilities -------------------------------
|
||||||
|
|
||||||
|
func (r *Ring) String() string {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "Ring{epoch=%d vnodes=%d hosts=%d}\n", r.Epoch, len(r.Vnodes), len(r.hosts))
|
||||||
|
limit := len(r.Vnodes)
|
||||||
|
if limit > 16 {
|
||||||
|
limit = 16
|
||||||
|
}
|
||||||
|
for i := 0; i < limit; i++ {
|
||||||
|
v := r.Vnodes[i]
|
||||||
|
fmt.Fprintf(&b, " %02d hash=%016x host=%s idx=%d\n", i, v.Hash, v.Host, v.Index)
|
||||||
|
}
|
||||||
|
if len(r.Vnodes) > limit {
|
||||||
|
fmt.Fprintf(&b, " ... (%d more)\n", len(r.Vnodes)-limit)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
type GrainHandler struct {
|
|
||||||
*CartServer
|
|
||||||
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) {
|
|
||||||
server, err := CartListen(listen)
|
|
||||||
handler := &GrainHandler{
|
|
||||||
CartServer: server,
|
|
||||||
pool: pool,
|
|
||||||
}
|
|
||||||
server.HandleCall(RemoteHandleMutation, handler.RemoteHandleMessageHandler)
|
|
||||||
server.HandleCall(RemoteGetState, handler.RemoteGetStateHandler)
|
|
||||||
return handler, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *GrainHandler) IsHealthy() bool {
|
|
||||||
return len(h.pool.grains) < h.pool.PoolSize
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *GrainHandler) RemoteHandleMessageHandler(id CartId, data []byte) (CartMessage, []byte, error) {
|
|
||||||
var msg Message
|
|
||||||
err := ReadMessage(bytes.NewReader(data), &msg)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("Error reading message:", err)
|
|
||||||
return RemoteHandleMutationReply, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
replyData, err := h.pool.Process(id, msg)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("Error handling message:", err)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return RemoteHandleMutationReply, nil, err
|
|
||||||
}
|
|
||||||
return RemoteHandleMutationReply, replyData, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *GrainHandler) RemoteGetStateHandler(id CartId, data []byte) (CartMessage, []byte, error) {
|
|
||||||
reply, err := h.pool.Get(id)
|
|
||||||
if err != nil {
|
|
||||||
return RemoteGetStateReply, nil, err
|
|
||||||
}
|
|
||||||
return RemoteGetStateReply, reply, nil
|
|
||||||
}
|
|
||||||
871
synced-pool.go
871
synced-pool.go
@@ -1,33 +1,66 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
"reflect"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
proto "git.tornberg.me/go-cart-actor/proto"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
"google.golang.org/grpc"
|
||||||
"k8s.io/apimachinery/pkg/watch"
|
"k8s.io/apimachinery/pkg/watch"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Quorum interface {
|
// SyncedPool coordinates cart grain ownership across nodes using gRPC control plane
|
||||||
Negotiate(knownHosts []string) ([]string, error)
|
// and cart actor services.
|
||||||
OwnerChanged(CartId, host string) error
|
//
|
||||||
}
|
// Responsibilities:
|
||||||
|
// - Local grain access (delegates to GrainLocalPool)
|
||||||
type HealthHandler interface {
|
// - Remote grain proxy management (RemoteGrainGRPC)
|
||||||
IsHealthy() bool
|
// - Cluster membership (AddRemote via discovery + negotiation)
|
||||||
}
|
// - Health/ping monitoring & remote removal
|
||||||
|
// - Ring based deterministic ownership (no runtime negotiation)
|
||||||
|
// - (Scaffolding) replication factor awareness via ring.LookupN
|
||||||
|
//
|
||||||
|
// Thread-safety: public methods that mutate internal maps lock p.mu (RWMutex).
|
||||||
type SyncedPool struct {
|
type SyncedPool struct {
|
||||||
*Server
|
|
||||||
mu sync.RWMutex
|
|
||||||
Hostname string
|
Hostname string
|
||||||
local *GrainLocalPool
|
local *GrainLocalPool
|
||||||
remotes []*RemoteHost
|
|
||||||
remoteIndex map[CartId]*RemoteGrain
|
mu sync.RWMutex
|
||||||
|
|
||||||
|
// Remote host state (gRPC only)
|
||||||
|
remoteHosts map[string]*RemoteHostGRPC // host -> remote host
|
||||||
|
|
||||||
|
// Remote grain proxies (by cart id)
|
||||||
|
remoteIndex map[CartId]Grain
|
||||||
|
|
||||||
|
// Discovery handler for re-adding hosts after failures
|
||||||
|
discardedHostHandler *DiscardedHostHandler
|
||||||
|
|
||||||
|
// Consistent hashing ring (immutable snapshot reference)
|
||||||
|
ringRef *RingRef
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
vnodesPerHost int
|
||||||
|
replicationFactor int // RF (>=1). Currently only primary is active; replicas are scaffolding.
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoteHostGRPC tracks a remote host's clients & health.
|
||||||
|
type RemoteHostGRPC struct {
|
||||||
|
Host string
|
||||||
|
Conn *grpc.ClientConn
|
||||||
|
CartClient proto.CartActorClient
|
||||||
|
ControlClient proto.ControlPlaneClient
|
||||||
|
MissedPings int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RemoteHostGRPC) IsHealthy() bool {
|
||||||
|
return r.MissedPings < 3
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -45,202 +78,229 @@ var (
|
|||||||
})
|
})
|
||||||
remoteLookupCount = promauto.NewCounter(prometheus.CounterOpts{
|
remoteLookupCount = promauto.NewCounter(prometheus.CounterOpts{
|
||||||
Name: "cart_remote_lookup_total",
|
Name: "cart_remote_lookup_total",
|
||||||
Help: "The total number of remote lookups",
|
Help: "The total number of remote lookups (legacy counter)",
|
||||||
})
|
})
|
||||||
packetQueue = promauto.NewGauge(prometheus.GaugeOpts{
|
|
||||||
Name: "cart_packet_queue_size",
|
// Ring / ownership metrics
|
||||||
Help: "The total number of packets in the queue",
|
ringEpoch = promauto.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "cart_ring_epoch",
|
||||||
|
Help: "Current consistent hashing ring epoch (fingerprint-based pseudo-epoch)",
|
||||||
})
|
})
|
||||||
packetsSent = promauto.NewCounter(prometheus.CounterOpts{
|
ringHosts = promauto.NewGauge(prometheus.GaugeOpts{
|
||||||
Name: "cart_pool_packets_sent_total",
|
Name: "cart_ring_hosts",
|
||||||
Help: "The total number of packets sent",
|
Help: "Number of hosts currently in the ring",
|
||||||
})
|
})
|
||||||
packetsReceived = promauto.NewCounter(prometheus.CounterOpts{
|
ringVnodes = promauto.NewGauge(prometheus.GaugeOpts{
|
||||||
Name: "cart_pool_packets_received_total",
|
Name: "cart_ring_vnodes",
|
||||||
Help: "The total number of packets received",
|
Help: "Number of virtual nodes in the ring",
|
||||||
|
})
|
||||||
|
ringLookupLocal = promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "cart_ring_lookup_local_total",
|
||||||
|
Help: "Ring ownership lookups resolved to the local host",
|
||||||
|
})
|
||||||
|
ringLookupRemote = promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "cart_ring_lookup_remote_total",
|
||||||
|
Help: "Ring ownership lookups resolved to a remote host",
|
||||||
|
})
|
||||||
|
ringHostShare = promauto.NewGaugeVec(prometheus.GaugeOpts{
|
||||||
|
Name: "cart_ring_host_share",
|
||||||
|
Help: "Fractional share of ring vnodes per host",
|
||||||
|
}, []string{"host"})
|
||||||
|
|
||||||
|
cartMutationsTotal = promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "cart_mutations_total",
|
||||||
|
Help: "Total number of cart state mutations applied (local + remote routed).",
|
||||||
|
})
|
||||||
|
|
||||||
|
cartMutationFailuresTotal = promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "cart_mutation_failures_total",
|
||||||
|
Help: "Total number of failed cart state mutations (local apply errors or remote routing failures).",
|
||||||
|
})
|
||||||
|
|
||||||
|
cartMutationLatencySeconds = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||||
|
Name: "cart_mutation_latency_seconds",
|
||||||
|
Help: "Latency of cart mutations (successful or failed) in seconds.",
|
||||||
|
Buckets: prometheus.DefBuckets,
|
||||||
|
}, []string{"mutation"})
|
||||||
|
|
||||||
|
cartActiveGrains = promauto.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "cart_active_grains",
|
||||||
|
Help: "Number of active (resident) local grains.",
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
func (p *SyncedPool) PongHandler(data []byte) (PoolMessage, []byte, error) {
|
|
||||||
return Pong, data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SyncedPool) GetCartIdHandler(data []byte) (PoolMessage, []byte, error) {
|
|
||||||
ids := make([]string, 0, len(p.local.grains))
|
|
||||||
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))
|
|
||||||
return CartIdsResponse, []byte(strings.Join(ids, ";")), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SyncedPool) NegotiateHandler(data []byte) (PoolMessage, []byte, error) {
|
|
||||||
negotiationCount.Inc()
|
|
||||||
log.Printf("Handling negotiation\n")
|
|
||||||
for _, host := range p.ExcludeKnown(strings.Split(string(data), ";")) {
|
|
||||||
if host == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
go p.AddRemote(host)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
return RemoteNegotiateResponse, []byte("ok"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SyncedPool) GrainOwnerChangeHandler(data []byte) (PoolMessage, []byte, error) {
|
|
||||||
grainSyncCount.Inc()
|
|
||||||
|
|
||||||
idAndHostParts := strings.Split(string(data), ";")
|
|
||||||
if len(idAndHostParts) != 2 {
|
|
||||||
log.Printf("Invalid remote grain change message\n")
|
|
||||||
return AckChange, []byte("incorrect"), fmt.Errorf("invalid remote grain change message")
|
|
||||||
}
|
|
||||||
id := ToCartId(idAndHostParts[0])
|
|
||||||
host := idAndHostParts[1]
|
|
||||||
log.Printf("Handling remote grain owner change to %s for id %s\n", host, id)
|
|
||||||
for _, r := range p.remotes {
|
|
||||||
if r.Host == host {
|
|
||||||
// log.Printf("Remote grain %s changed to %s\n", id, host)
|
|
||||||
|
|
||||||
go p.SpawnRemoteGrain(id, host)
|
|
||||||
|
|
||||||
return AckChange, []byte("ok"), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
go p.AddRemote(host)
|
|
||||||
return AckChange, []byte("ok"), 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 p.local.grains[id] != nil {
|
|
||||||
log.Printf("Grain %s already exists locally, owner is (%s)\n", id, host)
|
|
||||||
p.mu.Lock()
|
|
||||||
delete(p.local.grains, id)
|
|
||||||
p.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
remote, err := NewRemoteGrain(id, host)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error creating remote grain %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
<-remote.Died
|
|
||||||
p.RemoveRemoteGrain(id)
|
|
||||||
p.HandleHostError(host)
|
|
||||||
log.Printf("Remote grain %s died, host: %s\n", id.String(), host)
|
|
||||||
}()
|
|
||||||
|
|
||||||
p.mu.Lock()
|
|
||||||
p.remoteIndex[id] = remote
|
|
||||||
p.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SyncedPool) HandleHostError(host string) {
|
|
||||||
for _, r := range p.remotes {
|
|
||||||
if r.Host == host {
|
|
||||||
if !r.IsHealthy() {
|
|
||||||
p.RemoveHost(r)
|
|
||||||
} else {
|
|
||||||
r.ErrorCount++
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSyncedPool(local *GrainLocalPool, hostname string, discovery Discovery) (*SyncedPool, error) {
|
func NewSyncedPool(local *GrainLocalPool, hostname string, discovery Discovery) (*SyncedPool, error) {
|
||||||
listen := fmt.Sprintf("%s:1338", hostname)
|
p := &SyncedPool{
|
||||||
|
|
||||||
server, err := Listen(listen)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("Listening on %s", listen)
|
|
||||||
|
|
||||||
pool := &SyncedPool{
|
|
||||||
Server: server,
|
|
||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
local: local,
|
local: local,
|
||||||
|
remoteHosts: make(map[string]*RemoteHostGRPC),
|
||||||
remotes: make([]*RemoteHost, 0),
|
remoteIndex: make(map[CartId]Grain),
|
||||||
remoteIndex: make(map[CartId]*RemoteGrain),
|
discardedHostHandler: NewDiscardedHostHandler(1338),
|
||||||
|
vnodesPerHost: 64, // default smoothing factor; adjust if needed
|
||||||
|
replicationFactor: 1, // RF scaffold; >1 not yet activating replicas
|
||||||
}
|
}
|
||||||
|
p.discardedHostHandler.SetReconnectHandler(p.AddRemote)
|
||||||
server.HandleCall(Ping, pool.PongHandler)
|
// Initialize empty ring (will be rebuilt after first AddRemote or discovery event)
|
||||||
server.HandleCall(GetCartIds, pool.GetCartIdHandler)
|
p.rebuildRing()
|
||||||
server.HandleCall(RemoteNegotiate, pool.NegotiateHandler)
|
|
||||||
server.HandleCall(RemoteGrainChanged, pool.GrainOwnerChangeHandler)
|
|
||||||
|
|
||||||
if discovery != nil {
|
if discovery != nil {
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(time.Second * 5)
|
time.Sleep(3 * time.Second) // allow gRPC server startup
|
||||||
log.Printf("Starting discovery")
|
log.Printf("Starting discovery watcher")
|
||||||
ch, err := discovery.Watch()
|
ch, err := discovery.Watch()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error discovering hosts: %v", err)
|
log.Printf("Discovery error: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for chng := range ch {
|
for evt := range ch {
|
||||||
if chng.Host == "" {
|
if evt.Host == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
known := pool.IsKnown(chng.Host)
|
switch evt.Type {
|
||||||
if chng.Type != watch.Deleted && !known {
|
case watch.Deleted:
|
||||||
go func(h string) {
|
if p.IsKnown(evt.Host) {
|
||||||
log.Printf("Discovered host %s, waiting for startup", h)
|
p.RemoveHost(evt.Host)
|
||||||
time.Sleep(time.Second)
|
|
||||||
pool.AddRemote(h)
|
|
||||||
}(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
|
|
||||||
}
|
}
|
||||||
|
default:
|
||||||
|
if !p.IsKnown(evt.Host) {
|
||||||
|
log.Printf("Discovered host %s", evt.Host)
|
||||||
|
p.AddRemote(evt.Host)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
} else {
|
} else {
|
||||||
log.Printf("No discovery, waiting for remotes to connect")
|
log.Printf("No discovery configured; expecting manual AddRemote or static host injection")
|
||||||
}
|
}
|
||||||
|
|
||||||
return pool, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SyncedPool) IsHealthy() bool {
|
// ------------------------- Remote Host Management -----------------------------
|
||||||
for _, r := range p.remotes {
|
|
||||||
if !r.IsHealthy() {
|
// AddRemote dials a remote host and initializes grain proxies.
|
||||||
return false
|
func (p *SyncedPool) AddRemote(host string) {
|
||||||
|
if host == "" || host == p.Hostname {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
if _, exists := p.remoteHosts[host]; exists {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
target := fmt.Sprintf("%s:1337", host)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
conn, err := grpc.DialContext(ctx, target, grpc.WithInsecure(), grpc.WithBlock())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("AddRemote: dial %s failed: %v", target, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cartClient := proto.NewCartActorClient(conn)
|
||||||
|
controlClient := proto.NewControlPlaneClient(conn)
|
||||||
|
|
||||||
|
// Health check (Ping) with limited retries
|
||||||
|
pings := 3
|
||||||
|
for pings > 0 {
|
||||||
|
ctxPing, cancelPing := context.WithTimeout(context.Background(), 1*time.Second)
|
||||||
|
_, pingErr := controlClient.Ping(ctxPing, &proto.Empty{})
|
||||||
|
cancelPing()
|
||||||
|
if pingErr == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
pings--
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
if pings == 0 {
|
||||||
|
log.Printf("AddRemote: ping %s failed after retries: %v", host, pingErr)
|
||||||
|
conn.Close()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
|
||||||
|
remote := &RemoteHostGRPC{
|
||||||
|
Host: host,
|
||||||
|
Conn: conn,
|
||||||
|
CartClient: cartClient,
|
||||||
|
ControlClient: controlClient,
|
||||||
|
MissedPings: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
p.remoteHosts[host] = remote
|
||||||
|
p.mu.Unlock()
|
||||||
|
connectedRemotes.Set(float64(p.RemoteCount()))
|
||||||
|
// Rebuild consistent hashing ring including this new host
|
||||||
|
p.rebuildRing()
|
||||||
|
|
||||||
|
log.Printf("Connected to remote host %s", host)
|
||||||
|
|
||||||
|
go p.pingLoop(remote)
|
||||||
|
go p.initializeRemote(remote)
|
||||||
|
go p.Negotiate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// initializeRemote fetches remote cart ids and sets up remote grain proxies.
|
||||||
|
func (p *SyncedPool) initializeRemote(remote *RemoteHostGRPC) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
reply, err := remote.ControlClient.GetCartIds(ctx, &proto.Empty{})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Init remote %s: GetCartIds error: %v", remote.Host, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for _, idStr := range reply.CartIds {
|
||||||
|
if idStr == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.SpawnRemoteGrain(ToCartId(idStr), remote.Host)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
log.Printf("Remote %s reported %d grains", remote.Host, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveHost removes remote host and its grains.
|
||||||
|
func (p *SyncedPool) RemoveHost(host string) {
|
||||||
|
p.mu.Lock()
|
||||||
|
remote, exists := p.remoteHosts[host]
|
||||||
|
if exists {
|
||||||
|
delete(p.remoteHosts, host)
|
||||||
|
}
|
||||||
|
// remove grains pointing to host
|
||||||
|
for id, g := range p.remoteIndex {
|
||||||
|
if rg, ok := g.(*RemoteGrainGRPC); ok && rg.Host == host {
|
||||||
|
delete(p.remoteIndex, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
remote.Conn.Close()
|
||||||
|
}
|
||||||
|
connectedRemotes.Set(float64(p.RemoteCount()))
|
||||||
|
// Rebuild ring after host removal
|
||||||
|
p.rebuildRing()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoteCount returns number of tracked remote hosts.
|
||||||
|
func (p *SyncedPool) RemoteCount() int {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
return len(p.remoteHosts)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SyncedPool) IsKnown(host string) bool {
|
func (p *SyncedPool) IsKnown(host string) bool {
|
||||||
for _, r := range p.remotes {
|
if host == p.Hostname {
|
||||||
if r.Host == host {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
p.mu.RLock()
|
||||||
return host == p.Hostname
|
defer p.mu.RUnlock()
|
||||||
|
_, ok := p.remoteHosts[host]
|
||||||
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SyncedPool) ExcludeKnown(hosts []string) []string {
|
func (p *SyncedPool) ExcludeKnown(hosts []string) []string {
|
||||||
@@ -253,200 +313,335 @@ func (p *SyncedPool) ExcludeKnown(hosts []string) []string {
|
|||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SyncedPool) RemoveHost(host *RemoteHost) {
|
// ------------------------- Health / Ping -------------------------------------
|
||||||
toKeep := make([]*RemoteHost, 0, len(p.remotes))
|
|
||||||
for _, r := range p.remotes {
|
|
||||||
if r == host {
|
|
||||||
p.RemoveHostMappedCarts(r)
|
|
||||||
|
|
||||||
} else {
|
func (p *SyncedPool) pingLoop(remote *RemoteHostGRPC) {
|
||||||
toKeep = append(toKeep, r)
|
ticker := time.NewTicker(3 * time.Second)
|
||||||
}
|
defer ticker.Stop()
|
||||||
}
|
for range ticker.C {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||||
p.remotes = toKeep
|
_, err := remote.ControlClient.Ping(ctx, &proto.Empty{})
|
||||||
|
cancel()
|
||||||
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 {
|
|
||||||
p.remoteIndex[id].Close()
|
|
||||||
delete(p.remoteIndex, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type PoolMessage uint32
|
|
||||||
|
|
||||||
const (
|
|
||||||
RemoteNegotiate = PoolMessage(3)
|
|
||||||
RemoteGrainChanged = PoolMessage(4)
|
|
||||||
AckChange = PoolMessage(5)
|
|
||||||
//AckError = PoolMessage(6)
|
|
||||||
Ping = PoolMessage(7)
|
|
||||||
Pong = PoolMessage(8)
|
|
||||||
GetCartIds = PoolMessage(9)
|
|
||||||
CartIdsResponse = PoolMessage(10)
|
|
||||||
RemoteNegotiateResponse = PoolMessage(11)
|
|
||||||
)
|
|
||||||
|
|
||||||
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 {
|
if err != nil {
|
||||||
log.Printf("Error negotiating with %s: %v\n", r.Host, err)
|
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
|
return
|
||||||
}
|
}
|
||||||
for _, h := range hosts {
|
|
||||||
if !p.IsKnown(h) {
|
|
||||||
p.AddRemote(h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SyncedPool) RequestOwnership(id CartId) error {
|
|
||||||
ok := 0
|
|
||||||
all := 0
|
|
||||||
for _, r := range p.remotes {
|
|
||||||
log.Printf("Asking for confirmation change of %s to %s (me) with %s\n", id, p.Hostname, r.Host)
|
|
||||||
err := r.ConfirmChange(id, p.Hostname)
|
|
||||||
all++
|
|
||||||
if err != nil {
|
|
||||||
if !r.IsHealthy() {
|
|
||||||
p.RemoveHost(r)
|
|
||||||
all--
|
|
||||||
} else {
|
|
||||||
log.Printf("Error confirming change: %v from %s\n", err, p.Hostname)
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ok++
|
remote.MissedPings = 0
|
||||||
}
|
}
|
||||||
if ok == 0 && all > 0 {
|
|
||||||
p.removeLocalGrain(id)
|
|
||||||
return fmt.Errorf("no remotes confirmed change")
|
|
||||||
}
|
}
|
||||||
if (all < 3 && ok < all) || ok < (all/2) {
|
|
||||||
p.removeLocalGrain(id)
|
func (p *SyncedPool) IsHealthy() bool {
|
||||||
return fmt.Errorf("quorum not reached")
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
for _, r := range p.remoteHosts {
|
||||||
|
if !r.IsHealthy() {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
return nil
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------- Negotiation ---------------------------------------
|
||||||
|
|
||||||
|
func (p *SyncedPool) Negotiate() {
|
||||||
|
negotiationCount.Inc()
|
||||||
|
|
||||||
|
p.mu.RLock()
|
||||||
|
hosts := make([]string, 0, len(p.remoteHosts)+1)
|
||||||
|
hosts = append(hosts, p.Hostname)
|
||||||
|
for h := range p.remoteHosts {
|
||||||
|
hosts = append(hosts, h)
|
||||||
|
}
|
||||||
|
remotes := make([]*RemoteHostGRPC, 0, len(p.remoteHosts))
|
||||||
|
for _, r := range p.remoteHosts {
|
||||||
|
remotes = append(remotes, r)
|
||||||
|
}
|
||||||
|
p.mu.RUnlock()
|
||||||
|
|
||||||
|
changed := false
|
||||||
|
|
||||||
|
for _, r := range remotes {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
reply, err := r.ControlClient.Negotiate(ctx, &proto.NegotiateRequest{KnownHosts: hosts})
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Negotiate with %s failed: %v", r.Host, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, h := range reply.Hosts {
|
||||||
|
if !p.IsKnown(h) {
|
||||||
|
p.AddRemote(h)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If new hosts were discovered during negotiation, rebuild the ring once at the end.
|
||||||
|
if changed {
|
||||||
|
p.rebuildRing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------- Grain / Ring Ownership ----------------------------
|
||||||
|
|
||||||
|
// RemoveRemoteGrain removes a remote grain mapping.
|
||||||
|
func (p *SyncedPool) RemoveRemoteGrain(id CartId) {
|
||||||
|
p.mu.Lock()
|
||||||
|
delete(p.remoteIndex, id)
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpawnRemoteGrain creates/updates a remote grain proxy for a given host.
|
||||||
|
func (p *SyncedPool) SpawnRemoteGrain(id CartId, host string) {
|
||||||
|
if id.String() == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
// If local grain exists (legacy key), remove from local map (ownership moved).
|
||||||
|
if g, ok := p.local.grains[LegacyToCartKey(id)]; ok && g != nil {
|
||||||
|
delete(p.local.grains, LegacyToCartKey(id))
|
||||||
|
}
|
||||||
|
remoteHost, ok := p.remoteHosts[host]
|
||||||
|
if !ok {
|
||||||
|
p.mu.Unlock()
|
||||||
|
log.Printf("SpawnRemoteGrain: host %s unknown (id=%s), attempting AddRemote", host, id)
|
||||||
|
go p.AddRemote(host)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rg := NewRemoteGrainGRPC(id, host, remoteHost.CartClient)
|
||||||
|
p.remoteIndex[id] = rg
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHealthyRemotes returns a copy slice of healthy remote hosts.
|
||||||
|
func (p *SyncedPool) GetHealthyRemotes() []*RemoteHostGRPC {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
ret := make([]*RemoteHostGRPC, 0, len(p.remoteHosts))
|
||||||
|
for _, r := range p.remoteHosts {
|
||||||
|
if r.IsHealthy() {
|
||||||
|
ret = append(ret, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
// rebuildRing reconstructs the consistent hashing ring from current host set
|
||||||
|
// and updates ring-related metrics.
|
||||||
|
func (p *SyncedPool) rebuildRing() {
|
||||||
|
p.mu.RLock()
|
||||||
|
hosts := make([]string, 0, len(p.remoteHosts)+1)
|
||||||
|
hosts = append(hosts, p.Hostname)
|
||||||
|
for h := range p.remoteHosts {
|
||||||
|
hosts = append(hosts, h)
|
||||||
|
}
|
||||||
|
p.mu.RUnlock()
|
||||||
|
|
||||||
|
epochSeed := fingerprintHosts(hosts)
|
||||||
|
builder := NewRingBuilder().
|
||||||
|
WithHosts(hosts).
|
||||||
|
WithEpoch(epochSeed).
|
||||||
|
WithVnodesPerHost(p.vnodesPerHost)
|
||||||
|
r := builder.Build()
|
||||||
|
if p.ringRef == nil {
|
||||||
|
p.ringRef = NewRingRef(r)
|
||||||
|
} else {
|
||||||
|
p.ringRef.Set(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
ringEpoch.Set(float64(r.Epoch))
|
||||||
|
ringHosts.Set(float64(len(r.Hosts())))
|
||||||
|
ringVnodes.Set(float64(len(r.Vnodes)))
|
||||||
|
ringHostShare.Reset()
|
||||||
|
if len(r.Vnodes) > 0 {
|
||||||
|
perHost := make(map[string]int)
|
||||||
|
for _, v := range r.Vnodes {
|
||||||
|
perHost[v.Host]++
|
||||||
|
}
|
||||||
|
total := float64(len(r.Vnodes))
|
||||||
|
for h, c := range perHost {
|
||||||
|
ringHostShare.WithLabelValues(h).Set(float64(c) / total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForceRingRefresh exposes a manual ring rebuild hook (primarily for tests).
|
||||||
|
func (p *SyncedPool) ForceRingRefresh() {
|
||||||
|
p.rebuildRing()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownersFor returns the ordered list of primary + replica owners for a cart id
|
||||||
|
// (length min(replicationFactor, #hosts)). Currently only the first (primary)
|
||||||
|
// is used. This scaffolds future replication work.
|
||||||
|
func (p *SyncedPool) ownersFor(id CartId) []string {
|
||||||
|
if p.ringRef == nil || p.replicationFactor <= 0 {
|
||||||
|
return []string{p.Hostname}
|
||||||
|
}
|
||||||
|
r := p.ringRef.Get()
|
||||||
|
if r == nil || r.Empty() {
|
||||||
|
return []string{p.Hostname}
|
||||||
|
}
|
||||||
|
vnodes := r.LookupN(hashKeyString(id.String()), p.replicationFactor)
|
||||||
|
out := make([]string, 0, len(vnodes))
|
||||||
|
seen := make(map[string]struct{}, len(vnodes))
|
||||||
|
for _, v := range vnodes {
|
||||||
|
if _, ok := seen[v.Host]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[v.Host] = struct{}{}
|
||||||
|
out = append(out, v.Host)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
out = append(out, p.Hostname)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownerHostFor returns the primary owner host for a given id.
|
||||||
|
func (p *SyncedPool) ownerHostFor(id CartId) string {
|
||||||
|
return p.ownersFor(id)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// DebugOwnerHost exposes (for tests) the currently computed primary owner host.
|
||||||
|
func (p *SyncedPool) DebugOwnerHost(id CartId) string {
|
||||||
|
return p.ownerHostFor(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SyncedPool) removeLocalGrain(id CartId) {
|
func (p *SyncedPool) removeLocalGrain(id CartId) {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
delete(p.local.grains, LegacyToCartKey(id))
|
||||||
delete(p.local.grains, id)
|
p.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SyncedPool) addRemoteHost(address string, remote *RemoteHost) {
|
// getGrain returns a local or remote grain. For remote ownership it performs a
|
||||||
|
// bounded readiness wait (small retries) to reduce first-call failures while
|
||||||
p.remotes = append(p.remotes, remote)
|
// the remote connection & proxy are initializing.
|
||||||
connectedRemotes.Set(float64(len(p.remotes)))
|
func (p *SyncedPool) getGrain(id CartId) (Grain, error) {
|
||||||
log.Printf("Added remote %s\n", remote.Host)
|
owner := p.ownerHostFor(id)
|
||||||
|
if owner == p.Hostname {
|
||||||
go remote.Initialize(p)
|
ringLookupLocal.Inc()
|
||||||
}
|
grain, err := p.local.GetGrain(id)
|
||||||
|
|
||||||
func (p *SyncedPool) AddRemote(host string) error {
|
|
||||||
if host == "" || p.IsKnown(host) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
client, err := Dial(fmt.Sprintf("%s:1338", host))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error connecting to remote %s: %v\n", host, err)
|
return nil, err
|
||||||
return err
|
}
|
||||||
|
return grain, nil
|
||||||
|
}
|
||||||
|
ringLookupRemote.Inc()
|
||||||
|
|
||||||
|
// Kick off remote dial if we don't yet know the owner.
|
||||||
|
if !p.IsKnown(owner) {
|
||||||
|
go p.AddRemote(owner)
|
||||||
}
|
}
|
||||||
|
|
||||||
remote := RemoteHost{
|
// Fast path existing proxy
|
||||||
Client: client,
|
p.mu.RLock()
|
||||||
MissedPings: 0,
|
if rg, ok := p.remoteIndex[id]; ok {
|
||||||
Host: host,
|
p.mu.RUnlock()
|
||||||
|
remoteLookupCount.Inc()
|
||||||
|
return rg, nil
|
||||||
}
|
}
|
||||||
go func() {
|
p.mu.RUnlock()
|
||||||
<-remote.Died
|
|
||||||
log.Printf("Removing host, remote died %s", host)
|
|
||||||
p.RemoveHost(&remote)
|
|
||||||
}()
|
|
||||||
go func() {
|
|
||||||
for range time.Tick(time.Second * 3) {
|
|
||||||
err := remote.Ping()
|
|
||||||
|
|
||||||
for err != nil {
|
const (
|
||||||
time.Sleep(time.Millisecond * 200)
|
attempts = 5
|
||||||
err = remote.Ping()
|
sleepPerTry = 40 * time.Millisecond
|
||||||
if !remote.IsHealthy() {
|
)
|
||||||
log.Printf("Removing host, unable to communicate with %s", host)
|
|
||||||
p.RemoveHost(&remote)
|
for attempt := 0; attempt < attempts; attempt++ {
|
||||||
|
// Try to spawn (idempotent if host already known)
|
||||||
|
if p.IsKnown(owner) {
|
||||||
|
p.SpawnRemoteGrain(id, owner)
|
||||||
|
}
|
||||||
|
// Check again
|
||||||
|
p.mu.RLock()
|
||||||
|
if rg, ok := p.remoteIndex[id]; ok {
|
||||||
|
p.mu.RUnlock()
|
||||||
|
remoteLookupCount.Inc()
|
||||||
|
return rg, nil
|
||||||
|
}
|
||||||
|
p.mu.RUnlock()
|
||||||
|
|
||||||
|
// Last attempt? break to return error.
|
||||||
|
if attempt == attempts-1 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
time.Sleep(sleepPerTry)
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
go p.addRemoteHost(host, &remote)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SyncedPool) getGrain(id CartId) (Grain, error) {
|
return nil, fmt.Errorf("remote owner %s not yet available for cart %s (after %d attempts)", owner, id.String(), attempts)
|
||||||
localGrain, ok := p.local.grains[id]
|
|
||||||
if !ok {
|
|
||||||
// check if remote grain exists
|
|
||||||
p.mu.RLock()
|
|
||||||
remoteGrain, ok := p.remoteIndex[id]
|
|
||||||
p.mu.RUnlock()
|
|
||||||
if ok {
|
|
||||||
remoteLookupCount.Inc()
|
|
||||||
return remoteGrain, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err := p.RequestOwnership(id)
|
// Apply applies a single mutation to a grain (local or remote).
|
||||||
if err != nil {
|
// Replication (RF>1) scaffolding: future enhancement will fan-out mutations
|
||||||
log.Printf("Error requesting ownership: %v\n", err)
|
// to replica owners (best-effort) and reconcile quorum on read.
|
||||||
return nil, err
|
func (p *SyncedPool) Apply(id CartId, mutation interface{}) (*CartGrain, error) {
|
||||||
}
|
|
||||||
|
|
||||||
localGrain, err = p.local.GetGrain(id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
return localGrain, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SyncedPool) Process(id CartId, messages ...Message) (*CallResult, error) {
|
|
||||||
pool, err := p.getGrain(id)
|
|
||||||
var res *CallResult
|
|
||||||
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) (*CallResult, error) {
|
|
||||||
grain, err := p.getGrain(id)
|
grain, err := p.getGrain(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
start := time.Now()
|
||||||
|
result, applyErr := grain.Apply(mutation, false)
|
||||||
|
|
||||||
|
// Derive mutation type label (strip pointer)
|
||||||
|
mutationType := "unknown"
|
||||||
|
if mutation != nil {
|
||||||
|
if t := reflect.TypeOf(mutation); t != nil {
|
||||||
|
if t.Kind() == reflect.Ptr {
|
||||||
|
t = t.Elem()
|
||||||
|
}
|
||||||
|
if t.Name() != "" {
|
||||||
|
mutationType = t.Name()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cartMutationLatencySeconds.WithLabelValues(mutationType).Observe(time.Since(start).Seconds())
|
||||||
|
|
||||||
|
if applyErr == nil && result != nil {
|
||||||
|
cartMutationsTotal.Inc()
|
||||||
|
if p.ownerHostFor(id) == p.Hostname {
|
||||||
|
// Update active grains gauge only for local ownership
|
||||||
|
cartActiveGrains.Set(float64(p.local.DebugGrainCount()))
|
||||||
|
}
|
||||||
|
} else if applyErr != nil {
|
||||||
|
cartMutationFailuresTotal.Inc()
|
||||||
|
}
|
||||||
|
return result, applyErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns current state of a grain (local or remote).
|
||||||
|
// Future replication hook: Read-repair or quorum read can be added here.
|
||||||
|
func (p *SyncedPool) Get(id CartId) (*CartGrain, error) {
|
||||||
|
grain, err := p.getGrain(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return grain.GetCurrentState()
|
return grain.GetCurrentState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close notifies remotes this host is terminating.
|
||||||
|
func (p *SyncedPool) Close() {
|
||||||
|
p.mu.RLock()
|
||||||
|
remotes := make([]*RemoteHostGRPC, 0, len(p.remoteHosts))
|
||||||
|
for _, r := range p.remoteHosts {
|
||||||
|
remotes = append(remotes, r)
|
||||||
|
}
|
||||||
|
p.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, r := range remotes {
|
||||||
|
go func(rh *RemoteHostGRPC) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||||
|
_, err := rh.ControlClient.Closing(ctx, &proto.ClosingNotice{Host: p.Hostname})
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Close notify to %s failed: %v", rh.Host, err)
|
||||||
|
}
|
||||||
|
}(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
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{},
|
|
||||||
TotalPrice: 0,
|
|
||||||
}, nil
|
|
||||||
})
|
|
||||||
pool, err := NewSyncedPool(localPool, "localhost", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Error creating pool: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = pool.AddRemote("localhost")
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Error adding remote: %v", err)
|
|
||||||
}
|
|
||||||
go pool.Negotiate()
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
time.Sleep(2 * time.Millisecond)
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/binary"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CartClient struct {
|
|
||||||
*CartTCPClient
|
|
||||||
}
|
|
||||||
|
|
||||||
func CartDial(address string) (*CartClient, error) {
|
|
||||||
|
|
||||||
mux, err := NewCartTCPClient(address)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
client := &CartClient{
|
|
||||||
CartTCPClient: mux,
|
|
||||||
}
|
|
||||||
return client, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) Close() {
|
|
||||||
c.Conn.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
type CartTCPClient struct {
|
|
||||||
*PersistentConnection
|
|
||||||
sendMux sync.Mutex
|
|
||||||
ErrorCount int
|
|
||||||
address string
|
|
||||||
*CartPacketQueue
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCartTCPClient(address string) (*CartTCPClient, error) {
|
|
||||||
connection, err := NewPersistentConnection(address)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &CartTCPClient{
|
|
||||||
ErrorCount: 0,
|
|
||||||
PersistentConnection: connection,
|
|
||||||
address: address,
|
|
||||||
CartPacketQueue: NewCartPacketQueue(connection),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *CartTCPClient) SendPacket(messageType CartMessage, id CartId, data []byte) error {
|
|
||||||
m.sendMux.Lock()
|
|
||||||
defer m.sendMux.Unlock()
|
|
||||||
m.Conn.Write(header[:])
|
|
||||||
err := binary.Write(m.Conn, binary.LittleEndian, CartPacket{
|
|
||||||
Version: CurrentPacketVersion,
|
|
||||||
MessageType: messageType,
|
|
||||||
DataLength: uint32(len(data)),
|
|
||||||
Id: id,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return m.HandleConnectionError(err)
|
|
||||||
}
|
|
||||||
_, err = m.Conn.Write(data)
|
|
||||||
return m.HandleConnectionError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *CartTCPClient) Call(messageType CartMessage, id CartId, responseType CartMessage, data []byte) (*CallResult, error) {
|
|
||||||
packetChan := m.Expect(responseType, id)
|
|
||||||
err := m.SendPacket(messageType, id, data)
|
|
||||||
if err != nil {
|
|
||||||
return nil, m.HandleConnectionError(err)
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case ret := <-packetChan:
|
|
||||||
return &ret, nil
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
log.Printf("Timeout waiting for cart response to message type %d\n", responseType)
|
|
||||||
return nil, m.HandleConnectionError(fmt.Errorf("timeout"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"encoding/binary"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CartServer struct {
|
|
||||||
*TCPCartServerMux
|
|
||||||
}
|
|
||||||
|
|
||||||
func CartListen(address string) (*CartServer, error) {
|
|
||||||
listener, err := net.Listen("tcp", address)
|
|
||||||
server := &CartServer{
|
|
||||||
NewCartTCPServerMux(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
conn, err := listener.Accept()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error accepting connection: %v\n", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
go server.HandleConnection(conn)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return server, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type TCPCartServerMux struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
sendMux sync.Mutex
|
|
||||||
listeners map[CartMessage]func(CartId, []byte) error
|
|
||||||
functions map[CartMessage]func(CartId, []byte) (CartMessage, []byte, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCartTCPServerMux() *TCPCartServerMux {
|
|
||||||
m := &TCPCartServerMux{
|
|
||||||
mu: sync.RWMutex{},
|
|
||||||
listeners: make(map[CartMessage]func(CartId, []byte) error),
|
|
||||||
functions: make(map[CartMessage]func(CartId, []byte) (CartMessage, []byte, error)),
|
|
||||||
}
|
|
||||||
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *TCPCartServerMux) handleListener(messageType CartMessage, id CartId, data []byte) (bool, error) {
|
|
||||||
m.mu.RLock()
|
|
||||||
handler, ok := m.listeners[messageType]
|
|
||||||
m.mu.RUnlock()
|
|
||||||
if ok {
|
|
||||||
err := handler(id, data)
|
|
||||||
if err != nil {
|
|
||||||
return true, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *TCPCartServerMux) handleFunction(connection net.Conn, messageType CartMessage, id CartId, data []byte) (bool, error) {
|
|
||||||
m.mu.RLock()
|
|
||||||
fn, ok := m.functions[messageType]
|
|
||||||
m.mu.RUnlock()
|
|
||||||
m.sendMux.Lock()
|
|
||||||
defer m.sendMux.Unlock()
|
|
||||||
if ok {
|
|
||||||
responseType, responseData, err := fn(id, data)
|
|
||||||
connection.Write(header[:])
|
|
||||||
if err != nil {
|
|
||||||
errData := []byte(err.Error())
|
|
||||||
err = binary.Write(connection, binary.LittleEndian, CartPacket{
|
|
||||||
Version: CurrentPacketVersion,
|
|
||||||
MessageType: responseType,
|
|
||||||
DataLength: uint32(len(errData)),
|
|
||||||
StatusCode: 500,
|
|
||||||
Id: id,
|
|
||||||
})
|
|
||||||
_, err = connection.Write(errData)
|
|
||||||
return true, err
|
|
||||||
}
|
|
||||||
err = binary.Write(connection, binary.LittleEndian, CartPacket{
|
|
||||||
Version: CurrentPacketVersion,
|
|
||||||
MessageType: responseType,
|
|
||||||
DataLength: uint32(len(responseData)),
|
|
||||||
StatusCode: 200,
|
|
||||||
Id: id,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return true, err
|
|
||||||
}
|
|
||||||
packetsSent.Inc()
|
|
||||||
_, err = connection.Write(responseData)
|
|
||||||
return true, err
|
|
||||||
} else {
|
|
||||||
log.Printf("No cart handler for type: %d\n", messageType)
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *TCPCartServerMux) HandleConnection(connection net.Conn) error {
|
|
||||||
var packet CartPacket
|
|
||||||
var err error
|
|
||||||
defer connection.Close()
|
|
||||||
reader := bufio.NewReader(connection)
|
|
||||||
for {
|
|
||||||
err = ReadCartPacket(reader, &packet)
|
|
||||||
if err != nil {
|
|
||||||
if err == io.EOF {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
log.Printf("Error receiving packet: %v\n", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if packet.Version != CurrentPacketVersion {
|
|
||||||
log.Printf("Incorrect packet version: %d\n", packet.Version)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
data, err := GetPacketData(reader, packet.DataLength)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error getting packet data: %v\n", err)
|
|
||||||
}
|
|
||||||
go m.HandleData(connection, packet.MessageType, packet.Id, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *TCPCartServerMux) HandleData(connection net.Conn, t CartMessage, id CartId, data []byte) {
|
|
||||||
status, err := m.handleListener(t, id, data)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error handling listener: %v\n", err)
|
|
||||||
}
|
|
||||||
if !status {
|
|
||||||
status, err = m.handleFunction(connection, t, id, data)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error handling function: %v\n", err)
|
|
||||||
}
|
|
||||||
if !status {
|
|
||||||
log.Printf("Unknown message type: %d\n", t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *TCPCartServerMux) ListenFor(messageType CartMessage, handler func(CartId, []byte) error) {
|
|
||||||
m.mu.Lock()
|
|
||||||
m.listeners[messageType] = handler
|
|
||||||
m.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *TCPCartServerMux) HandleCall(messageType CartMessage, handler func(CartId, []byte) (CartMessage, []byte, error)) {
|
|
||||||
m.mu.Lock()
|
|
||||||
m.functions[messageType] = handler
|
|
||||||
m.mu.Unlock()
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCartTcpHelpers(t *testing.T) {
|
|
||||||
|
|
||||||
server, err := CartListen("localhost:51337")
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Error listening: %v\n", err)
|
|
||||||
}
|
|
||||||
client, err := CartDial("localhost:51337")
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Error dialing: %v\n", err)
|
|
||||||
}
|
|
||||||
var messageData string
|
|
||||||
server.ListenFor(1, func(id CartId, data []byte) error {
|
|
||||||
log.Printf("Received message: %s\n", string(data))
|
|
||||||
messageData = string(data)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
server.HandleCall(666, func(id CartId, data []byte) (CartMessage, []byte, error) {
|
|
||||||
log.Printf("Received 666 call: %s\n", string(data))
|
|
||||||
return 3, []byte("Hello, client!"), fmt.Errorf("Det blev fel")
|
|
||||||
})
|
|
||||||
server.HandleCall(2, func(id CartId, data []byte) (CartMessage, []byte, error) {
|
|
||||||
log.Printf("Received 2 call: %s\n", string(data))
|
|
||||||
return 4, []byte("Hello, client!"), nil
|
|
||||||
})
|
|
||||||
// server.HandleCall(Ping, func(id CartId, data []byte) (CartMessage, []byte, error) {
|
|
||||||
// return Pong, nil, nil
|
|
||||||
// })
|
|
||||||
id := ToCartId("kalle")
|
|
||||||
client.SendPacket(1, id, []byte("Hello, world!"))
|
|
||||||
answer, err := client.Call(2, id, 4, []byte("Hello, server!"))
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Error calling: %v\n", err)
|
|
||||||
}
|
|
||||||
s, err := client.Call(666, id, 3, []byte("Hello, server!"))
|
|
||||||
client.Close()
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Error calling: %v\n", err)
|
|
||||||
}
|
|
||||||
if s.StatusCode != 500 {
|
|
||||||
t.Errorf("Expected 500, got %d\n", s.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
if string(answer.Data) != "Hello, client!" {
|
|
||||||
t.Errorf("Expected answer 'Hello, client!', got %s\n", string(answer.Data))
|
|
||||||
}
|
|
||||||
if messageData != "Hello, world!" {
|
|
||||||
t.Errorf("Expected message 'Hello, world!', got %s\n", messageData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
135
tcp-client.go
135
tcp-client.go
@@ -1,135 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/binary"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Client struct {
|
|
||||||
*TCPClient
|
|
||||||
}
|
|
||||||
|
|
||||||
func Dial(address string) (*Client, error) {
|
|
||||||
|
|
||||||
mux, err := NewTCPClient(address)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
client := &Client{
|
|
||||||
TCPClient: mux,
|
|
||||||
}
|
|
||||||
return client, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type TCPClient struct {
|
|
||||||
*PersistentConnection
|
|
||||||
sendMux sync.Mutex
|
|
||||||
ErrorCount int
|
|
||||||
address string
|
|
||||||
*PacketQueue
|
|
||||||
}
|
|
||||||
|
|
||||||
type PersistentConnection struct {
|
|
||||||
net.Conn
|
|
||||||
Died chan bool
|
|
||||||
Dead bool
|
|
||||||
address string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPersistentConnection(address string) (*PersistentConnection, error) {
|
|
||||||
connection, err := net.Dial("tcp", address)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &PersistentConnection{
|
|
||||||
Conn: connection,
|
|
||||||
Died: make(chan bool, 1),
|
|
||||||
Dead: false,
|
|
||||||
address: address,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *PersistentConnection) Connect() error {
|
|
||||||
if !m.Dead {
|
|
||||||
connection, err := net.Dial("tcp", m.address)
|
|
||||||
if err != nil {
|
|
||||||
m.Died <- true
|
|
||||||
m.Dead = true
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
m.Conn = connection
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *PersistentConnection) Close() {
|
|
||||||
m.Conn.Close()
|
|
||||||
m.Died <- true
|
|
||||||
m.Dead = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *PersistentConnection) HandleConnectionError(err error) error {
|
|
||||||
if err != nil {
|
|
||||||
m.Conn.Close()
|
|
||||||
m.Connect()
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTCPClient(address string) (*TCPClient, error) {
|
|
||||||
|
|
||||||
connection, err := NewPersistentConnection(address)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &TCPClient{
|
|
||||||
ErrorCount: 0,
|
|
||||||
PersistentConnection: connection,
|
|
||||||
address: address,
|
|
||||||
PacketQueue: NewPacketQueue(connection),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type PacketHeader [4]byte
|
|
||||||
|
|
||||||
var (
|
|
||||||
header = PacketHeader([4]byte{0x01, 0x02, 0x03, 0x04})
|
|
||||||
)
|
|
||||||
|
|
||||||
func (m *TCPClient) SendPacket(messageType PoolMessage, data []byte) error {
|
|
||||||
m.sendMux.Lock()
|
|
||||||
defer m.sendMux.Unlock()
|
|
||||||
m.Conn.Write(header[:])
|
|
||||||
err := binary.Write(m.Conn, binary.LittleEndian, Packet{
|
|
||||||
Version: CurrentPacketVersion,
|
|
||||||
MessageType: messageType,
|
|
||||||
StatusCode: 0,
|
|
||||||
DataLength: uint32(len(data)),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return m.HandleConnectionError(err)
|
|
||||||
}
|
|
||||||
_, err = m.Conn.Write(data)
|
|
||||||
return m.HandleConnectionError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *TCPClient) Call(messageType PoolMessage, responseType PoolMessage, data []byte) (*CallResult, error) {
|
|
||||||
packetChan := m.Expect(responseType)
|
|
||||||
err := m.SendPacket(messageType, data)
|
|
||||||
if err != nil {
|
|
||||||
m.RemoveListeners()
|
|
||||||
return nil, m.HandleConnectionError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case ret := <-packetChan:
|
|
||||||
return &ret, nil
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
log.Printf("Timeout waiting for cart response to message type %d\n", responseType)
|
|
||||||
return nil, m.HandleConnectionError(fmt.Errorf("timeout"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
8
tcp-connection_test.go
Normal file
8
tcp-connection_test.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
Legacy TCP networking (GenericListener / Frame protocol) has been removed
|
||||||
|
as part of the gRPC migration. This file intentionally contains no tests.
|
||||||
|
|
||||||
|
Keeping an empty Go file (with a package declaration) ensures the old
|
||||||
|
tcp-connection test target no longer runs without causing build issues.
|
||||||
|
*/
|
||||||
|
package main
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"encoding/binary"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Server struct {
|
|
||||||
*TCPServerMux
|
|
||||||
}
|
|
||||||
|
|
||||||
func Listen(address string) (*Server, error) {
|
|
||||||
listener, err := net.Listen("tcp", address)
|
|
||||||
server := &Server{
|
|
||||||
NewTCPServerMux(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
conn, err := listener.Accept()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error accepting connection: %v\n", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
go server.HandleConnection(conn)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return server, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type TCPServerMux struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
sendMux sync.Mutex
|
|
||||||
listeners map[PoolMessage]func(data []byte) error
|
|
||||||
functions map[PoolMessage]func(data []byte) (PoolMessage, []byte, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTCPServerMux() *TCPServerMux {
|
|
||||||
m := &TCPServerMux{
|
|
||||||
mu: sync.RWMutex{},
|
|
||||||
listeners: make(map[PoolMessage]func(data []byte) error),
|
|
||||||
functions: make(map[PoolMessage]func(data []byte) (PoolMessage, []byte, error)),
|
|
||||||
}
|
|
||||||
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *TCPServerMux) handleListener(messageType PoolMessage, data []byte) (bool, error) {
|
|
||||||
m.mu.RLock()
|
|
||||||
handler, ok := m.listeners[messageType]
|
|
||||||
m.mu.RUnlock()
|
|
||||||
if ok {
|
|
||||||
err := handler(data)
|
|
||||||
if err != nil {
|
|
||||||
return true, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *TCPServerMux) handleFunction(connection net.Conn, messageType PoolMessage, data []byte) (bool, error) {
|
|
||||||
m.mu.RLock()
|
|
||||||
function, ok := m.functions[messageType]
|
|
||||||
m.mu.RUnlock()
|
|
||||||
m.sendMux.Lock()
|
|
||||||
defer m.sendMux.Unlock()
|
|
||||||
if ok {
|
|
||||||
connection.Write(header[:])
|
|
||||||
responseType, responseData, err := function(data)
|
|
||||||
if err != nil {
|
|
||||||
errData := []byte(err.Error())
|
|
||||||
err = binary.Write(connection, binary.LittleEndian, Packet{
|
|
||||||
Version: CurrentPacketVersion,
|
|
||||||
MessageType: responseType,
|
|
||||||
StatusCode: 500,
|
|
||||||
DataLength: uint32(len(errData)),
|
|
||||||
})
|
|
||||||
_, err = connection.Write(errData)
|
|
||||||
return true, err
|
|
||||||
}
|
|
||||||
err = binary.Write(connection, binary.LittleEndian, Packet{
|
|
||||||
Version: CurrentPacketVersion,
|
|
||||||
MessageType: responseType,
|
|
||||||
StatusCode: 200,
|
|
||||||
DataLength: uint32(len(responseData)),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return true, err
|
|
||||||
}
|
|
||||||
packetsSent.Inc()
|
|
||||||
_, err = connection.Write(responseData)
|
|
||||||
return true, err
|
|
||||||
} else {
|
|
||||||
log.Printf("No pool handler for type: %d\n", messageType)
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *TCPServerMux) HandleConnection(connection net.Conn) error {
|
|
||||||
|
|
||||||
defer connection.Close()
|
|
||||||
var packet Packet
|
|
||||||
reader := bufio.NewReader(connection)
|
|
||||||
for {
|
|
||||||
err := ReadPacket(reader, &packet)
|
|
||||||
if err != nil {
|
|
||||||
if err == io.EOF {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
log.Printf("Error receiving packet: %v\n", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if packet.Version != CurrentPacketVersion {
|
|
||||||
log.Printf("Incorrect package version: %v\n", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
data, err := GetPacketData(reader, packet.DataLength)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error receiving packet data: %v\n", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
go m.HandleData(connection, packet.MessageType, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *TCPServerMux) HandleData(connection net.Conn, t PoolMessage, data []byte) {
|
|
||||||
// listener := m.listeners[t]
|
|
||||||
// handler := m.functions[t]
|
|
||||||
status, err := m.handleListener(t, data)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error handling listener: %v\n", err)
|
|
||||||
}
|
|
||||||
if !status {
|
|
||||||
status, err = m.handleFunction(connection, t, data)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error handling function: %v\n", err)
|
|
||||||
}
|
|
||||||
if !status {
|
|
||||||
log.Printf("Unknown message type: %d\n", t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *TCPServerMux) ListenFor(messageType PoolMessage, handler func(data []byte) error) {
|
|
||||||
m.mu.Lock()
|
|
||||||
m.listeners[messageType] = handler
|
|
||||||
m.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *TCPServerMux) HandleCall(messageType PoolMessage, handler func(data []byte) (PoolMessage, []byte, error)) {
|
|
||||||
m.mu.Lock()
|
|
||||||
m.functions[messageType] = handler
|
|
||||||
m.mu.Unlock()
|
|
||||||
}
|
|
||||||
54
tcp_test.go
54
tcp_test.go
@@ -1,54 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestTcpHelpers(t *testing.T) {
|
|
||||||
|
|
||||||
server, err := Listen("localhost:51337")
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Error listening: %v\n", err)
|
|
||||||
}
|
|
||||||
client, err := Dial("localhost:51337")
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Error dialing: %v\n", err)
|
|
||||||
}
|
|
||||||
var messageData string
|
|
||||||
server.ListenFor(1, func(data []byte) error {
|
|
||||||
log.Printf("Received message: %s\n", string(data))
|
|
||||||
messageData = string(data)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
server.HandleCall(2, func(data []byte) (PoolMessage, []byte, error) {
|
|
||||||
log.Printf("Received call: %s\n", string(data))
|
|
||||||
return 3, []byte("Hello, client!"), nil
|
|
||||||
})
|
|
||||||
server.HandleCall(Ping, func(data []byte) (PoolMessage, []byte, error) {
|
|
||||||
return Pong, nil, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
client.SendPacket(1, []byte("Hello, world!"))
|
|
||||||
answer, err := client.Call(2, 3, []byte("Hello, server!"))
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Error calling: %v\n", err)
|
|
||||||
}
|
|
||||||
for i := 0; i < 100; i++ {
|
|
||||||
_, err = client.Call(Ping, Pong, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Error calling: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_, err = client.Call(Ping, Pong, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Error calling: %v\n", err)
|
|
||||||
}
|
|
||||||
client.Close()
|
|
||||||
if string(answer.Data) != "Hello, client!" {
|
|
||||||
t.Errorf("Expected answer 'Hello, client!', got %s\n", string(answer.Data))
|
|
||||||
}
|
|
||||||
if messageData != "Hello, world!" {
|
|
||||||
t.Errorf("Expected message 'Hello, world!', got %s\n", messageData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user