30 Commits

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

68
.dockerignore Normal file
View File

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

View File

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

View File

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

View File

@@ -1,396 +0,0 @@
# gRPC Migration Plan
File: GRPC-MIGRATION-PLAN.md
Author: (Generated plan)
Status: Draft for review
Target Release: Next major version (breaking change no mixed compatibility)
---
## 1. Overview
This document describes the full migration of the current custom TCP frame-based protocol (both the cart mutation/state channel on port `1337` and the control plane on port `1338`) to gRPC. We will remove all legacy packet framing (`FrameWithPayload`, `RemoteGrain`, `GenericListener` handlers for these two ports) and replace them with two gRPC services:
1. Cart Actor Service (mutations + state retrieval)
2. Control Plane Service (cluster membership, negotiation, ownership change, lifecycle)
We intentionally keep:
- Internal `CartGrain` logic, message storage format, disk persistence, and JSON cart serialization.
- Existing message type numeric mapping for backward compatibility with persisted event logs.
- HTTP/REST API layer unchanged (it still consumes JSON state from the local/remote grain pipeline).
We do NOT implement mixed-version compatibility; migration occurs atomically (cluster restart with new image).
---
## 2. Goals
- Remove custom binary frame protocol & simplify maintenance.
- Provide clearer, strongly defined interfaces via `.proto` schemas.
- Improve observability via gRPC interceptors (metrics & tracing hooks).
- Reduce per-call overhead compared with the current manual connection pooling + handwritten framing (HTTP/2 multiplexing + connection reuse).
- Prepare groundwork for future enhancements (streaming, typed state, event streaming) without rewriting again.
---
## 3. Non-Goals (Phase 1)
- Converting the cart state payload from JSON to a strongly typed proto.
- Introducing authentication / mTLS (may be added later).
- Changing persistence or replay format.
- Changing the HTTP API contract.
- Implementing streaming watchers or push updates.
---
## 4. Architecture After Migration
Ports:
- `:1337` → gRPC CartActor service.
- `:1338` → gRPC ControlPlane service.
Each node:
- Runs one gRPC server with both services (can use a single listener bound to two services or keep two separate listeners; we will keep two ports initially to minimize operational surprise, but they could be merged later).
- Maintains a connection pool of `*grpc.ClientConn` objects keyed by remote hostname (one per remote host, reused for both services).
Call Flow (Mutation):
1. HTTP request hits `PoolServer`.
2. `SyncedPool.getGrain(cartId)`:
- Local: direct invocation.
- Remote: uses `RemoteGrainGRPC` (new) which invokes `CartActor.Mutate`.
3. Response JSON returned unchanged.
Control Plane Flow:
- Discovery (K8s watch) still triggers `AddRemote(host)`.
- Instead of custom `Ping`, `Negotiate`, etc. via frames, call gRPC methods on `ControlPlane` service.
- Ownership changes use `ConfirmOwner` RPC.
---
## 5. Proto Design
### 5.1 Cart Actor Proto (Envelope Pattern)
We keep an envelope with `bytes payload` holding the serialized underlying cart mutation proto (existing types in `messages.proto`). This minimizes churn.
Indented code block (proto sketch):
syntax = "proto3";
package cart;
option go_package = "git.tornberg.me/go-cart-actor/proto;proto";
enum MutationType {
MUTATION_TYPE_UNSPECIFIED = 0;
MUTATION_ADD_REQUEST = 1;
MUTATION_ADD_ITEM = 2;
MUTATION_REMOVE_ITEM = 4;
MUTATION_REMOVE_DELIVERY = 5;
MUTATION_CHANGE_QUANTITY = 6;
MUTATION_SET_DELIVERY = 7;
MUTATION_SET_PICKUP_POINT = 8;
MUTATION_CREATE_CHECKOUT_ORDER = 9;
MUTATION_SET_CART_ITEMS = 10;
MUTATION_ORDER_COMPLETED = 11;
}
message MutationRequest {
string cart_id = 1;
MutationType type = 2;
bytes payload = 3; // Serialized specific mutation proto
int64 client_timestamp = 4; // Optional; server fills if zero
}
message MutationReply {
int32 status_code = 1;
bytes payload = 2; // JSON cart state or error string
}
message StateRequest {
string cart_id = 1;
}
message StateReply {
int32 status_code = 1;
bytes payload = 2; // JSON cart state
}
service CartActor {
rpc Mutate(MutationRequest) returns (MutationReply);
rpc GetState(StateRequest) returns (StateReply);
}
### 5.2 Control Plane Proto
syntax = "proto3";
package control;
option go_package = "git.tornberg.me/go-cart-actor/proto;proto";
message Empty {}
message PingReply {
string host = 1;
int64 unix_time = 2;
}
message NegotiateRequest {
repeated string known_hosts = 1;
}
message NegotiateReply {
repeated string hosts = 1; // Healthy hosts returned
}
message CartIdsReply {
repeated string cart_ids = 1;
}
message OwnerChangeRequest {
string cart_id = 1;
string new_host = 2;
}
message OwnerChangeAck {
bool accepted = 1;
string message = 2;
}
message ClosingNotice {
string host = 1;
}
service ControlPlane {
rpc Ping(Empty) returns (PingReply);
rpc Negotiate(NegotiateRequest) returns (NegotiateReply);
rpc GetCartIds(Empty) returns (CartIdsReply);
rpc ConfirmOwner(OwnerChangeRequest) returns (OwnerChangeAck);
rpc Closing(ClosingNotice) returns (OwnerChangeAck);
}
---
## 6. Message Type Mapping
| Legacy Constant | Numeric | New Enum Value |
|-----------------|---------|-----------------------------|
| AddRequestType | 1 | MUTATION_ADD_REQUEST |
| AddItemType | 2 | MUTATION_ADD_ITEM |
| RemoveItemType | 4 | MUTATION_REMOVE_ITEM |
| RemoveDeliveryType | 5 | MUTATION_REMOVE_DELIVERY |
| ChangeQuantityType | 6 | MUTATION_CHANGE_QUANTITY |
| SetDeliveryType | 7 | MUTATION_SET_DELIVERY |
| SetPickupPointType | 8 | MUTATION_SET_PICKUP_POINT |
| CreateCheckoutOrderType | 9 | MUTATION_CREATE_CHECKOUT_ORDER |
| SetCartItemsType | 10 | MUTATION_SET_CART_ITEMS |
| OrderCompletedType | 11 | MUTATION_ORDER_COMPLETED |
Persisted events keep original numeric codes; reconstruction simply casts to `MutationType`.
---
## 7. Components To Remove / Replace
Remove (after migration complete):
- `remote-grain.go`
- `rpc-server.go`
- Any packet/frame-specific types solely used by the above (search: `FrameWithPayload`, `RemoteHandleMutation`, `RemoteGetState` where not reused by disk or internal logic).
- The constants representing network frame types in `synced-pool.go` (RemoteNegotiate, AckChange, etc.) replaced by gRPC calls.
- netpool usage for remote cart channel (control plane also no longer needs `Connection` abstraction).
Retain (until reworked or optionally cleaned later):
- `message.go` (for persistence)
- `message-handler.go`
- `cart-grain.go`
- `messages.proto` (underlying mutation messages)
- HTTP API server and REST handlers.
---
## 8. New / Modified Components
New files (planned):
- `proto/cart_actor.proto`
- `proto/control_plane.proto`
- `grpc/cart_actor_server.go` (server impl)
- `grpc/cart_actor_client.go` (client adapter implementing `Grain`)
- `grpc/control_plane_server.go`
- `grpc/control_plane_client.go`
- `grpc/interceptors.go` (metrics, logging, optional tracing hooks)
- `remote_grain_grpc.go` (adapter bridging existing interfaces)
- `control_plane_adapter.go` (replaces frame handlers in `SyncedPool`)
Modified:
- `synced-pool.go` (remote host management now uses gRPC clients; negotiation logic updated)
- `main.go` (initialize both gRPC services on startup)
- `go.mod` (add `google.golang.org/grpc`)
---
## 9. Step-by-Step Migration Plan
1. Add proto files and generate Go code (`protoc --go_out --go-grpc_out`).
2. Implement `CartActorServer`:
- Translate `MutationRequest` to `Message`.
- Use existing handler registry for payload encode/decode.
- Return JSON cart state.
3. Implement `CartActorClient` wrapper (`RemoteGrainGRPC`) implementing:
- `HandleMessage`: Build envelope, call `Mutate`.
- `GetCurrentState`: Call `GetState`.
4. Implement `ControlPlaneServer` with methods:
- `Ping`: returns host + time.
- `Negotiate`: merge host lists; emulate old logic.
- `GetCartIds`: iterate local grains.
- `ConfirmOwner`: replicate quorum flow (accept always; error path for future).
- `Closing`: schedule remote removal.
5. Implement `ControlPlaneClient` used inside `SyncedPool.AddRemote`.
6. Refactor `SyncedPool`:
- Replace frame handlers registration with gRPC client calls.
- Replace `Server.AddHandler(...)` start-up with launching gRPC server.
- Implement periodic health checks using `Ping`.
7. Remove old connection constructs for 1337/1338.
8. Metrics:
- Add unary interceptor capturing duration and status.
- Replace packet counters with `cart_grpc_mutate_calls_total`, `cart_grpc_control_calls_total`, histograms for latency.
9. Update `main.go` to start:
- gRPC server(s).
- HTTP server as before.
10. Delete legacy files & update README build instructions.
11. Load testing & profiling on Raspberry Pi hardware (or ARM emulation).
12. Final cleanup & dead code removal (search for now-unused constants & structs).
13. Tag release.
---
## 10. Performance Considerations (Raspberry Pi Focus)
- Single `*grpc.ClientConn` per remote host (HTTP/2 multiplexing) to reduce file descriptor and handshake overhead.
- Use small keepalive pings (optional) only if connections drop; default may suffice.
- Avoid reflection / dynamic dispatch in hot path: pre-build a mapping from `MutationType` to handler function.
- Reuse byte buffers:
- Implement a `sync.Pool` for mutation serialization to reduce GC pressure.
- Enforce per-RPC deadlines (e.g. 300400ms) to avoid pile-ups.
- Backpressure:
- Before dispatch: if local grain pool at capacity and target grain is remote, abort early with 503 to caller (optional).
- Disable gRPC compression for small payloads (mutation messages are small). Condition compression if payload > threshold (e.g. 8KB).
- Compile with `-ldflags="-s -w"` in production to reduce binary size (optional).
- Enable `GOMAXPROCS` tuned to CPU cores; Pi often benefits from leaving default but monitor.
- Use histograms with limited buckets to reduce Prometheus cardinality.
---
## 11. Testing Strategy
Unit:
- Message type mapping tests (legacy -> enum).
- Envelope roundtrip: Original proto -> payload -> gRPC -> server decode -> internal Message.
Integration:
- Two-node cluster simulation:
- Mutate cart on Node A, ownership moves, verify remote access from Node B.
- Quorum failure simulation (temporarily reject `ConfirmOwner`).
- Control plane negotiation: start nodes in staggered order, assert final membership.
Load/Perf:
- Benchmark local mutation vs remote mutation latency.
- High concurrency test (N goroutines each performing X mutations).
- Memory profiling (ensure no large buffer retention).
Failure Injection:
- Kill a node mid-mutation; client call should timeout and not corrupt local state.
- Simulated network partition: drop `Ping` replies; ensure host removal path triggers.
---
## 12. Rollback Strategy
Because no mixed-version compatibility is provided, rollback = redeploy previous version containing legacy protocol:
1. Stop all new-version pods.
2. Deploy old version cluster-wide.
3. No data migration needed (event persistence unaffected).
Note: Avoid partial upgrades; perform full rolling restart quickly to prevent split-brain (new nodes wont talk to old nodes).
---
## 13. Risks & Mitigations
| Risk | Description | Mitigation |
|------|-------------|------------|
| Full-cluster restart required | No mixed compatibility | Schedule maintenance window |
| gRPC adds CPU overhead | Envelope + marshaling cost | Buffer reuse, keep small messages uncompressed |
| Ownership race | Timing differences after refactor | Add explicit logs + tests around `RequestOwnership` path |
| Hidden dependency on frame-level status codes | Some code may assume `FrameWithPayload` fields | Wrap gRPC responses into minimal compatibility structs until fully removed |
| Memory growth | Connection reuse & pooled buffers not implemented initially | Add `sync.Pool` & track memory via pprof early |
---
## 14. Logging & Observability
- Structured log entries for:
- Ownership changes
- Negotiation rounds
- Remote spawn events
- Mutation failures (with cart id, mutation type)
- Metrics:
- `cart_grpc_mutate_duration_seconds` (histogram)
- `cart_grpc_mutate_errors_total`
- `cart_grpc_control_duration_seconds`
- `cart_remote_hosts` (gauge)
- Retain existing grain counts.
- Optional future: OpenTelemetry tracing (span per remote mutation).
---
## 15. Future Enhancements (Post-Migration)
- Replace JSON state with `CartState` proto and provide streaming watch API.
- mTLS between nodes (certificate rotation via K8s Secret or SPIRE).
- Distributed tracing integration.
- Ownership leasing with TTL and optimistic renewal.
- Delta replication or CRDT-based conflict resolution for experimentation.
---
## 16. Task Breakdown & Estimates
| Task | Estimate |
|------|----------|
| Proto definitions & generation | 0.5d |
| CartActor server/client | 1.0d |
| ControlPlane server/client | 1.0d |
| SyncedPool refactor | 1.0d |
| Metrics & interceptors | 0.5d |
| Remove legacy code & cleanup | 0.5d |
| Tests (unit + integration) | 1.5d |
| Benchmark & tuning | 0.51.0d |
| Total | ~67d |
---
## 17. Open Questions (Confirm Before Implementation)
1. Combine both services on a single port (simplify ops) or keep dual-port first? (Default here: keep dual, but easy to merge.)
2. Minimum Go version remains 1.24.x—acceptable to add `google.golang.org/grpc` latest?
3. Accept adding `sync.Pool` micro-optimizations in first pass or postpone?
---
## 18. Acceptance Criteria
- All previous integration tests (adjusted to gRPC) pass.
- Cart operations (add, remove, delivery, checkout) function across at least a 2node cluster.
- Control plane negotiation forms consistent host list.
- Latency for a remote mutation does not degrade beyond an acceptable threshold (define baseline before merge).
- Legacy networking code fully removed.
---
## 19. Next Steps (If Approved)
1. Implement proto files and commit.
2. Scaffold server & client code.
3. Refactor `SyncedPool` and `main.go`.
4. Add metrics and tests.
5. Run benchmark on target Pi hardware.
6. Review & merge.
---
End of Plan.

132
Makefile Normal file
View File

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

266
README.md
View File

@@ -1,5 +1,36 @@
# 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
@@ -176,3 +207,238 @@ curl --cookie cookies.txt http://localhost:8080/cart/add/TEST-SKU-123
- 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 inmemory struct `*CartGrain`) that owns and mutates its own state.
- A **local grain pool** holds grains owned by the node.
- A **synced (cluster) pool** (`SyncedPool`) coordinates multiple nodes and exposes local or remote grains through a uniform interface (`GrainPool`).
- All internode communication is gRPC:
- Cart mutation & state RPCs (CartActor service).
- Control plane RPCs (ControlPlane service) for membership, ownership negotiation, liveness, and graceful shutdown.
### Key Processes
1. Client HTTP request (or gRPC client) arrives with a cart identifier (cookie or path).
2. The pool resolves ownership:
- If local grain exists → use it.
- If a remote host is known owner → a remote grain proxy (`RemoteGrainGRPC`) is used; it performs gRPC calls to the owning node.
- If ownership is unknown → node attempts to claim ownership (quorum negotiation) and spawns a local grain.
3. Mutation is executed via the **mutation registry** (registry wraps domain logic + optional totals recomputation).
4. Updated state returned to caller; ownership preserved unless relinquished later (not yet implemented to shed load).
---
## Grain & Mutation Model
- `CartGrain` holds items, deliveries, pricing aggregates, and checkout/order metadata.
- All mutations are registered via `RegisterMutation[T]` with signature:
```
func(*CartGrain, *T) error
```
- `WithTotals()` flag triggers automatic recalculation of totals after successful handlers.
- The old giant `switch` in `CartGrain.Apply` has been replaced by registry dispatch; unregistered mutations fail fast.
- Adding a mutation:
1. Define proto message.
2. Generate code.
3. Register handler (optionally WithTotals).
4. Add gRPC RPC + request wrapper if the mutation must be remotely invokable.
5. (Optional) Add HTTP endpoint mapping to the mutation.
---
## Local Grain Pool
- Manages an inmemory map `map[CartId]*CartGrain`.
- Lazy spawn: first mutation or explicit access triggers `spawn(id)`.
- TTL / purge loop periodically removes expired grains unless they changed recently (basic memory pressure management).
- Capacity limit (`PoolSize`); oldest expired grain evicted first when full.
---
## Synced (Cluster) Pool
`SyncedPool` wraps a local pool and tracks:
- `remoteHosts`: known peer nodes (gRPC connections).
- `remoteIndex`: mapping of cart id → remote grain proxy (`RemoteGrainGRPC`) for carts owned elsewhere.
Responsibilities:
1. Discovery integration (via a `Discovery` interface) adds/removes hosts.
2. Periodic ping health checks (ControlPlane.Ping).
3. Ring-based deterministic ownership:
- Ownership is derived directly from the consistent hashing ring (no quorum RPC or `ConfirmOwner`).
4. Remote spawning:
- When a remote host reports its cart ids (`GetCartIds`), the pool creates remote proxies for fast routing.
---
## Remote Grain Proxies
A `RemoteGrainGRPC` implements the `Grain` interface but delegates:
- `Apply` → Specific CartActor permutation RPC (e.g., `AddItem`, `RemoveItem`) constructed from the mutation type. (Legacy envelope removed.)
- `GetCurrentState` → `CartActor.GetState`.
Return path:
1. gRPC reply (CartMutationReply / StateReply) → proto `CartState`.
2. `ToCartState` / mapping reconstructs a local `CartGrain` snapshot for callers expecting grain semantics.
---
## Control Plane (InterNode Coordination)
Defined in `proto/control_plane.proto`:
| RPC | Purpose |
|-----|---------|
| `Ping` | Liveness; increments missed ping counter if failing. |
| `Negotiate` | Merges membership views; used after discovery events. |
| `GetCartIds` | Enumerate locally owned carts for remote index seeding. |
| `Closing` | Graceful shutdown notice; peers remove host & associated remote grains. |
### Ownership / Quorum Rules
- If total participating hosts < 3 → all must accept.
- Otherwise majority acceptance (`ok >= total/2`).
- On failure → local tentative grain is removed (rollback to avoid splitbrain).
---
## Request / Mutation Flow Examples
### Local Mutation
1. HTTP handler parses request → determines cart id.
2. `SyncedPool.Apply`:
- Finds local grain (or spawns new after quorum).
- Executes registry mutation.
3. Totals updated if flagged.
4. HTTP response returns updated JSON (via `ToCartState`).
### Remote Mutation
1. `SyncedPool.Apply` sees cart mapped to a remote host.
2. Routes to `RemoteGrainGRPC.Apply`.
3. Remote node executes mutation locally and returns updated state over gRPC.
4. Proxy materializes snapshot locally (not authoritative, readonly view).
### Checkout (SideEffecting, Non-Pure)
- HTTP `/checkout` uses current grain snapshot to build payload (pure function).
- Calls Klarna externally (not a mutation).
- Applies `InitializeCheckout` mutation to persist reference + status.
- Returns Klarna order JSON to client.
---
## Scaling & Deployment
- **Horizontal scaling**: Add more nodes; discovery layer (Kubernetes / service registry) feeds hosts to `SyncedPool`.
- **Sharding**: Implicit by cart id hash. Ownership is first-claim with quorum acceptance.
- **Hot spots**: A single popular cart remains on one node; for heavy multi-client concurrency, future work could add read replicas or partitioning (not implemented).
- **Capacity tuning**: Increase `PoolSize` & memory limits; adjust TTL for stale cart eviction.
### Adding Nodes
1. Node starts gRPC server (CartActor + ControlPlane).
2. After brief delay, begins discovery watch; on event:
- New host → dial + negotiate → seed remote cart ids.
3. Pings maintain health; failed hosts removed (proxies invalidated).
---
## Failure Handling
| Scenario | Behavior |
|----------|----------|
| Remote host unreachable | Pings increment `MissedPings`; after threshold host removed. |
| Ownership negotiation fails | Tentative local grain discarded. |
| gRPC call error on remote mutation | Error bubbled to caller; no local fallback. |
| Missing mutation registration | Fast failure with explicit error message. |
| Partial checkout (Klarna fails) | No local state mutation for checkout; client sees error; cart remains unchanged. |
---
## Mutation Registry Summary
- Central, type-safe registry prevents silent omission.
- Each handler:
- Validates input.
- Mutates `*CartGrain`.
- Returns error for rejection.
- Automatic totals recomputation reduces boilerplate and consistency risk.
- Coverage test (add separately) can enforce all proto mutations are registered.
---
## gRPC Interfaces
- **CartActor**: Per-mutation unary RPCs + `GetState`. (Checkout logic intentionally excluded; handled at HTTP layer.)
- **ControlPlane**: Cluster coordination (Ping, Negotiate, GetCartIds, Closing) — ownership now ring-determined (no ConfirmOwner).
**Ports** (default / implied):
- CartActor & ControlPlane share the same gRPC server/listener (single port, e.g. `:1337`).
- Legacy frame/TCP code has been removed.
---
## Security & Future Enhancements
| Area | Potential Improvement |
|------|------------------------|
| Transport Security | Add TLS / mTLS to gRPC servers & clients. |
| Auth / RBAC | Intercept CartActor RPCs with auth metadata. |
| Backpressure | Rate-limit remote mutation calls per host. |
| Observability | Add per-mutation Prometheus metrics & tracing spans. |
| Ownership | Add lease timeouts / fencing tokens for stricter guarantees. |
| Batch Ops | Introduce batch mutation RPC or streaming updates (WatchState). |
| Persistence | Reintroduce event log or snapshot persistence layer if durability required. |
---
## Adding a New Node (Operational Checklist)
1. Deploy binary/container with same proto + registry.
2. Expose gRPC port.
3. Ensure discovery lists the new host.
4. Node dials peers, negotiates membership.
5. Remote cart proxies seeded.
6. Traffic routed automatically based on ownership.
---
## Adding a New Mutation (Checklist Recap)
1. Define proto message (+ request wrapper & RPC if remote invocation needed).
2. Regenerate protobuf code.
3. Implement & register handler (`RegisterMutation`).
4. Add client (HTTP/gRPC) endpoint.
5. Write unit + integration tests.
6. (Optional) Add to coverage test list and docs.
---
## High-Level Data Flow Diagram (Text)
```
Client -> HTTP Handler -> SyncedPool -> (local?) -> Registry -> Grain State
\-> (remote?) -> RemoteGrainGRPC -> gRPC -> Remote CartActor -> Registry -> Grain
ControlPlane: Discovery Events <-> Negotiation/Ping <-> SyncedPool state (ring determines ownership)
```
---
## Troubleshooting
| Symptom | Likely Cause | Action |
|---------|--------------|--------|
| New cart every request | Secure cookie over plain HTTP or not sending cookie jar | Disable Secure locally or use HTTPS & proper curl `-b` |
| Unsupported mutation error | Missing registry handler | Add `RegisterMutation` for that proto |
| Ownership imbalance | Ring host distribution skew or rapid host churn | Examine `cart_ring_host_share`, `cart_ring_hosts`, and logs for host add/remove; rebalance or investigate instability |
| Remote mutation latency | Network / serialization overhead | Consider batching or colocating hot carts |
| Checkout returns 500 | Klarna call failed | Inspect logs; no grain state mutated |
---

245
TODO.md Normal file
View File

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

View File

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

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

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

View File

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

159
cart_id.go Normal file
View File

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

185
cart_id_test.go Normal file
View File

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

119
checkout_builder.go Normal file
View File

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

5
cookies.txt Normal file
View File

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

View File

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

View File

@@ -1,7 +1,7 @@
apiVersion: v1
kind: Secret
metadata:
name: klarna-api-credentials
name: klarna-api-credentials
data:
username: ZjQzZDY3YjEtNzA2Yy00NTk2LTliNTgtYjg1YjU2NDEwZTUw
password: a2xhcm5hX3Rlc3RfYXBpX0trUWhWVE5yYVZsV2FsTnhTRVp3Y1ZSSFF5UkVNRmxyY25Kd1AxSndQMGdzWmpRelpEWTNZakV0TnpBMll5MDBOVGsyTFRsaU5UZ3RZamcxWWpVMk5ERXdaVFV3TERFc2JUUkNjRFpWU1RsTllsSk1aMlEyVEc4MmRVODNZMkozUlRaaFdEZDViV3AwYkhGV1JqTjVNQzlaYXow
@@ -15,7 +15,7 @@ metadata:
arch: amd64
name: cart-actor-x86
spec:
replicas: 0
replicas: 3
selector:
matchLabels:
app: cart-actor
@@ -32,10 +32,10 @@ spec:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/arch
operator: NotIn
values:
- arm64
- key: kubernetes.io/arch
operator: NotIn
values:
- arm64
volumes:
- name: data
nfs:
@@ -55,12 +55,8 @@ spec:
ports:
- containerPort: 8080
name: web
- containerPort: 1234
name: echo
- containerPort: 1337
name: rpc
- containerPort: 1338
name: quorum
livenessProbe:
httpGet:
path: /livez
@@ -134,14 +130,14 @@ spec:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: NotIn
values:
- masterpi
- key: kubernetes.io/arch
operator: In
values:
- arm64
- key: kubernetes.io/hostname
operator: NotIn
values:
- masterpi
- key: kubernetes.io/arch
operator: In
values:
- arm64
volumes:
- name: data
nfs:
@@ -161,12 +157,8 @@ spec:
ports:
- containerPort: 8080
name: web
- containerPort: 1234
name: echo
- containerPort: 1337
name: rpc
- containerPort: 1338
name: quorum
livenessProbe:
httpGet:
path: /livez
@@ -217,18 +209,6 @@ spec:
---
kind: Service
apiVersion: v1
metadata:
name: cart-echo
spec:
selector:
app: cart-actor
type: LoadBalancer
ports:
- name: echo
port: 1234
---
kind: Service
apiVersion: v1
metadata:
name: cart-actor
annotations:
@@ -248,10 +228,10 @@ metadata:
name: cart-ingress
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
# nginx.ingress.kubernetes.io/affinity: "cookie"
# nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity"
# nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
# nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
nginx.ingress.kubernetes.io/affinity: "cookie"
nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity"
nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
nginx.ingress.kubernetes.io/proxy-body-size: 4m
spec:
ingressClassName: nginx

View File

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

View File

@@ -1,84 +0,0 @@
package main
import (
"fmt"
"log"
"net"
"sync"
"time"
)
type DiscardedHost struct {
Host string
Tries int
}
type DiscardedHostHandler struct {
mu sync.RWMutex
port int
hosts []*DiscardedHost
onConnection *func(string)
}
func (d *DiscardedHostHandler) run() {
for range time.Tick(time.Second) {
d.mu.RLock()
lst := make([]*DiscardedHost, 0, len(d.hosts))
for _, host := range d.hosts {
if host.Tries >= 0 && host.Tries < 5 {
go d.testConnection(host)
lst = append(lst, host)
} else {
if host.Tries > 0 {
log.Printf("Host %s discarded after %d tries", host.Host, host.Tries)
}
}
}
d.mu.RUnlock()
d.mu.Lock()
d.hosts = lst
d.mu.Unlock()
}
}
func (d *DiscardedHostHandler) testConnection(host *DiscardedHost) {
addr := fmt.Sprintf("%s:%d", host.Host, d.port)
conn, err := net.Dial("tcp", addr)
if err != nil {
host.Tries++
if host.Tries >= 5 {
// Exceeded retry threshold; will be dropped by run loop.
}
} else {
conn.Close()
if d.onConnection != nil {
fn := *d.onConnection
fn(host.Host)
}
}
}
func NewDiscardedHostHandler(port int) *DiscardedHostHandler {
ret := &DiscardedHostHandler{
hosts: make([]*DiscardedHost, 0),
port: port,
}
go ret.run()
return ret
}
func (d *DiscardedHostHandler) SetReconnectHandler(fn func(string)) {
d.onConnection = &fn
}
func (d *DiscardedHostHandler) AppendHost(host string) {
d.mu.Lock()
defer d.mu.Unlock()
log.Printf("Adding host %s to retry list", host)
d.hosts = append(d.hosts, &DiscardedHost{
Host: host,
Tries: 0,
})
}

View File

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

288
event_log.go Normal file
View File

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

102
frames.go
View File

@@ -1,102 +0,0 @@
package main
// Minimal frame abstractions retained after removal of the legacy TCP/frame
// networking layer. These types remain only to avoid a wide cascading refactor
// across existing grain / pool logic that still constructs and passes
// FrameWithPayload objects internally.
//
// The original responsibilities this replaces:
// - Binary framing, checksums, network IO
// - Distinction between request / reply frame types
//
// What remains:
// - A light weight container (FrameWithPayload) used as an inprocess
// envelope for status code + typed marker + payload bytes (JSON or proto).
// - Message / status constants referenced in existing code paths.
//
// Recommended future cleanup (postmigration):
// - Remove FrameType entirely and replace with enumerated semantic results
// or error values.
// - Replace FrameWithPayload with a struct { Status int; Data []byte }.
// - Remove remote_* reply type branching once all callers rely on gRPC
// status + strongly typed responses.
//
// For now we keep this minimal surface to keep the gRPC migration focused.
type (
// FrameType is a symbolic identifier carried through existing code paths.
// No ordering or bit semantics are required anymore.
FrameType uint32
StatusCode uint32
)
type Frame struct {
Type FrameType
StatusCode StatusCode
Length uint32
// Checksum retained for compatibility; no longer validated.
Checksum uint32
}
// FrameWithPayload wraps a Frame with an opaque payload.
// Payload usually contains JSON encoded cart state or an error message.
type FrameWithPayload struct {
Frame
Payload []byte
}
// -----------------------------------------------------------------------------
// Legacy Frame Type Constants (minimal subset still referenced)
// -----------------------------------------------------------------------------
const (
RemoteGetState = FrameType(0x01)
RemoteHandleMutation = FrameType(0x02)
ResponseBody = FrameType(0x03) // (rarely used; kept for completeness)
RemoteGetStateReply = FrameType(0x04)
RemoteHandleMutationReply = FrameType(0x05)
RemoteCreateOrderReply = FrameType(0x06)
)
// MakeFrameWithPayload constructs an inprocess frame wrapper.
// Length & Checksum are filled for backward compatibility (no validation logic
// depends on the checksum anymore).
func MakeFrameWithPayload(msg FrameType, statusCode StatusCode, payload []byte) FrameWithPayload {
length := uint32(len(payload))
return FrameWithPayload{
Frame: Frame{
Type: msg,
StatusCode: statusCode,
Length: length,
Checksum: (uint32(msg) + uint32(statusCode) + length) / 8, // simple legacy formula
},
Payload: payload,
}
}
// Clone creates a shallow copy of the frame, duplicating the payload slice.
func (f *FrameWithPayload) Clone() *FrameWithPayload {
if f == nil {
return nil
}
cp := make([]byte, len(f.Payload))
copy(cp, f.Payload)
return &FrameWithPayload{
Frame: f.Frame,
Payload: cp,
}
}
// NewErrorFrame helper for creating an error frame with a textual payload.
func NewErrorFrame(msg FrameType, code StatusCode, err error) FrameWithPayload {
var b []byte
if err != nil {
b = []byte(err.Error())
}
return MakeFrameWithPayload(msg, code, b)
}
// IsSuccess returns true if the status code indicates success in the
// conventional HTTP style range (200299). This mirrors previous usage patterns.
func (f *FrameWithPayload) IsSuccess() bool {
return f != nil && f.StatusCode >= 200 && f.StatusCode < 300
}

327
grafana_dashboard_cart.json Normal file
View File

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

View File

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

View File

@@ -1,135 +0,0 @@
package main
import (
"bytes"
"context"
"encoding/json"
"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 AddItemType directly to avoid hitting external product
// 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",
}
// Marshal underlying mutation payload using the existing handler code path
handler, ok := Handlers[AddItemType]
if !ok {
t.Fatalf("Handler for AddItemType missing")
}
payloadData, err := getSerializedPayload(handler, AddItemType, addItem)
if err != nil {
t.Fatalf("serialize add item: %v", err)
}
// Issue Mutate RPC
mutResp, err := cartClient.Mutate(context.Background(), &messages.MutationRequest{
CartId: cartID,
Type: messages.MutationType(AddItemType),
Payload: payloadData,
ClientTimestamp: time.Now().Unix(),
})
if err != nil {
t.Fatalf("Mutate RPC error: %v", err)
}
if mutResp.StatusCode != 200 {
t.Fatalf("Mutate returned non-200 status: %d payload=%s", mutResp.StatusCode, string(mutResp.Payload))
}
// Decode cart state JSON and validate
state := &CartGrain{}
if err := json.Unmarshal(mutResp.Payload, state); err != nil {
t.Fatalf("Unmarshal mutate cart state: %v\nPayload: %s", err, string(mutResp.Payload))
}
if len(state.Items) != 1 {
t.Fatalf("Expected 1 item after mutation, 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 payload=%s", getResp.StatusCode, string(getResp.Payload))
}
state2 := &CartGrain{}
if err := json.Unmarshal(getResp.Payload, state2); err != nil {
t.Fatalf("Unmarshal get state: %v", err)
}
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)
}
}
// getSerializedPayload serializes a mutation proto using the registered handler.
func getSerializedPayload(handler MessageHandler, msgType uint16, content interface{}) ([]byte, error) {
msg := &Message{
Type: msgType,
Content: content,
}
var buf bytes.Buffer
if err := handler.Write(msg, &buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}

View File

@@ -1,379 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"log"
"net"
"time"
proto "git.tornberg.me/go-cart-actor/proto" // underlying generated package name is 'messages'
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// -----------------------------------------------------------------------------
// Metrics
// -----------------------------------------------------------------------------
var (
grpcMutateDuration = promauto.NewHistogram(prometheus.HistogramOpts{
Name: "cart_grpc_mutate_duration_seconds",
Help: "Duration of CartActor.Mutate RPCs",
Buckets: prometheus.DefBuckets,
})
grpcMutateErrors = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_grpc_mutate_errors_total",
Help: "Total number of failed CartActor.Mutate RPCs",
})
grpcStateDuration = promauto.NewHistogram(prometheus.HistogramOpts{
Name: "cart_grpc_get_state_duration_seconds",
Help: "Duration of CartActor.GetState RPCs",
Buckets: prometheus.DefBuckets,
})
grpcControlDuration = promauto.NewHistogram(prometheus.HistogramOpts{
Name: "cart_grpc_control_duration_seconds",
Help: "Duration of ControlPlane RPCs",
Buckets: prometheus.DefBuckets,
})
grpcControlErrors = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_grpc_control_errors_total",
Help: "Total number of failed ControlPlane RPCs",
})
)
// timeTrack wraps a closure and records duration into the supplied histogram.
func timeTrack(hist prometheus.Observer, fn func() error) (err error) {
start := time.Now()
defer func() {
hist.Observe(time.Since(start).Seconds())
}()
return fn()
}
// -----------------------------------------------------------------------------
// CartActor Service Implementation
// -----------------------------------------------------------------------------
type cartActorService struct {
proto.UnimplementedCartActorServer
pool GrainPool
}
func newCartActorService(pool GrainPool) *cartActorService {
return &cartActorService{pool: pool}
}
func (s *cartActorService) Mutate(ctx context.Context, req *proto.MutationRequest) (*proto.MutationReply, error) {
var reply *proto.MutationReply
err := timeTrack(grpcMutateDuration, func() error {
if req == nil {
return status.Error(codes.InvalidArgument, "request is nil")
}
if req.CartId == "" {
return status.Error(codes.InvalidArgument, "cart_id is empty")
}
mt := uint16(req.Type.Number())
handler, ok := Handlers[mt]
if !ok {
return status.Errorf(codes.InvalidArgument, "unknown mutation type %d", mt)
}
content, err := handler.Read(req.Payload)
if err != nil {
return status.Errorf(codes.InvalidArgument, "decode payload: %v", err)
}
ts := req.ClientTimestamp
if ts == 0 {
ts = time.Now().Unix()
}
msg := Message{
Type: mt,
TimeStamp: &ts,
Content: content,
}
frame, err := s.pool.Process(ToCartId(req.CartId), msg)
if err != nil {
return err
}
reply = &proto.MutationReply{
StatusCode: int32(frame.StatusCode),
Payload: frame.Payload,
}
return nil
})
if err != nil {
grpcMutateErrors.Inc()
return nil, err
}
return reply, nil
}
func (s *cartActorService) GetState(ctx context.Context, req *proto.StateRequest) (*proto.StateReply, error) {
var reply *proto.StateReply
err := timeTrack(grpcStateDuration, func() error {
if req == nil || req.CartId == "" {
return status.Error(codes.InvalidArgument, "cart_id is empty")
}
frame, err := s.pool.Get(ToCartId(req.CartId))
if err != nil {
return err
}
reply = &proto.StateReply{
StatusCode: int32(frame.StatusCode),
Payload: frame.Payload,
}
return nil
})
if err != nil {
return nil, err
}
return reply, nil
}
// -----------------------------------------------------------------------------
// ControlPlane Service Implementation
// -----------------------------------------------------------------------------
// controlPlaneService directly leverages SyncedPool internals (same package).
// NOTE: This is a transitional adapter; once the legacy frame-based code is
// removed, related fields/methods in SyncedPool can be slimmed.
type controlPlaneService struct {
proto.UnimplementedControlPlaneServer
pool *SyncedPool
}
func newControlPlaneService(pool *SyncedPool) *controlPlaneService {
return &controlPlaneService{pool: pool}
}
func (s *controlPlaneService) Ping(ctx context.Context, _ *proto.Empty) (*proto.PingReply, error) {
var reply *proto.PingReply
err := timeTrack(grpcControlDuration, func() error {
reply = &proto.PingReply{
Host: s.pool.Hostname,
UnixTime: time.Now().Unix(),
}
return nil
})
if err != nil {
grpcControlErrors.Inc()
return nil, err
}
return reply, nil
}
func (s *controlPlaneService) Negotiate(ctx context.Context, req *proto.NegotiateRequest) (*proto.NegotiateReply, error) {
var reply *proto.NegotiateReply
err := timeTrack(grpcControlDuration, func() error {
if req == nil {
return status.Error(codes.InvalidArgument, "request is nil")
}
// Add unknown hosts
for _, host := range req.KnownHosts {
if host == "" || host == s.pool.Hostname {
continue
}
if !s.pool.IsKnown(host) {
go s.pool.AddRemote(host)
}
}
// Build healthy host list
hosts := make([]string, 0)
for _, r := range s.pool.GetHealthyRemotes() {
hosts = append(hosts, r.Host)
}
hosts = append(hosts, s.pool.Hostname)
reply = &proto.NegotiateReply{
Hosts: hosts,
}
return nil
})
if err != nil {
grpcControlErrors.Inc()
return nil, err
}
return reply, nil
}
func (s *controlPlaneService) GetCartIds(ctx context.Context, _ *proto.Empty) (*proto.CartIdsReply, error) {
var reply *proto.CartIdsReply
err := timeTrack(grpcControlDuration, func() error {
s.pool.mu.RLock()
defer s.pool.mu.RUnlock()
ids := make([]string, 0, len(s.pool.local.grains))
for id, g := range s.pool.local.grains {
if g == nil {
continue
}
if id.String() == "" {
continue
}
ids = append(ids, id.String())
}
reply = &proto.CartIdsReply{
CartIds: ids,
}
return nil
})
if err != nil {
grpcControlErrors.Inc()
return nil, err
}
return reply, nil
}
func (s *controlPlaneService) ConfirmOwner(ctx context.Context, req *proto.OwnerChangeRequest) (*proto.OwnerChangeAck, error) {
var reply *proto.OwnerChangeAck
err := timeTrack(grpcControlDuration, func() error {
if req == nil || req.CartId == "" || req.NewHost == "" {
return status.Error(codes.InvalidArgument, "cart_id or new_host missing")
}
id := ToCartId(req.CartId)
newHost := req.NewHost
// Mirror GrainOwnerChangeHandler semantics
log.Printf("gRPC ConfirmOwner: cart %s newHost=%s", id, newHost)
for _, r := range s.pool.remoteHosts {
if r.Host == newHost && r.IsHealthy() {
go s.pool.SpawnRemoteGrain(id, newHost)
break
}
}
go s.pool.AddRemote(newHost)
reply = &proto.OwnerChangeAck{
Accepted: true,
Message: "ok",
}
return nil
})
if err != nil {
grpcControlErrors.Inc()
return nil, err
}
return reply, nil
}
func (s *controlPlaneService) Closing(ctx context.Context, notice *proto.ClosingNotice) (*proto.OwnerChangeAck, error) {
var reply *proto.OwnerChangeAck
err := timeTrack(grpcControlDuration, func() error {
if notice == nil || notice.Host == "" {
return status.Error(codes.InvalidArgument, "host missing")
}
host := notice.Host
s.pool.mu.RLock()
_, exists := s.pool.remoteHosts[host]
s.pool.mu.RUnlock()
if exists {
go s.pool.RemoveHost(host)
}
reply = &proto.OwnerChangeAck{
Accepted: true,
Message: "removed",
}
return nil
})
if err != nil {
grpcControlErrors.Inc()
return nil, err
}
return reply, nil
}
// -----------------------------------------------------------------------------
// Server Bootstrap
// -----------------------------------------------------------------------------
type GRPCServer struct {
server *grpc.Server
lis net.Listener
addr string
}
// StartGRPCServer sets up a gRPC server hosting both CartActor and ControlPlane services.
// addr example: ":1337" (for combined) OR run two servers if you want separate ports.
// For the migration we can host both on the same listener to reduce open ports.
func StartGRPCServer(addr string, pool GrainPool, synced *SyncedPool, opts ...grpc.ServerOption) (*GRPCServer, error) {
if pool == nil {
return nil, errors.New("nil grain pool")
}
if synced == nil {
return nil, errors.New("nil synced pool")
}
lis, err := net.Listen("tcp", addr)
if err != nil {
return nil, fmt.Errorf("listen %s: %w", addr, err)
}
grpcServer := grpc.NewServer(opts...)
proto.RegisterCartActorServer(grpcServer, newCartActorService(pool))
proto.RegisterControlPlaneServer(grpcServer, newControlPlaneService(synced))
go func() {
log.Printf("gRPC server listening on %s", addr)
if serveErr := grpcServer.Serve(lis); serveErr != nil {
log.Printf("gRPC server stopped: %v", serveErr)
}
}()
return &GRPCServer{
server: grpcServer,
lis: lis,
addr: addr,
}, nil
}
// GracefulStop stops the server gracefully.
func (s *GRPCServer) GracefulStop() {
if s == nil || s.server == nil {
return
}
s.server.GracefulStop()
}
// Addr returns the bound address.
func (s *GRPCServer) Addr() string {
if s == nil {
return ""
}
return s.addr
}
// -----------------------------------------------------------------------------
// Client Dial Helpers (used later by refactored remote grain + control plane)
// -----------------------------------------------------------------------------
// DialRemote establishes (or reuses externally) a gRPC client connection.
func DialRemote(ctx context.Context, target string, opts ...grpc.DialOption) (*grpc.ClientConn, error) {
dialOpts := []grpc.DialOption{
grpc.WithInsecure(), // NOTE: Intentional for initial migration; replace with TLS / mTLS later.
grpc.WithBlock(),
}
dialOpts = append(dialOpts, opts...)
ctxDial, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
conn, err := grpc.DialContext(ctxDial, target, dialOpts...)
if err != nil {
return nil, err
}
return conn, nil
}
// -----------------------------------------------------------------------------
// Utility for converting internal errors to gRPC status (if needed later).
// -----------------------------------------------------------------------------
func grpcError(err error) error {
if err == nil {
return nil
}
// Extend mapping if we add richer error types.
return status.Error(codes.Internal, err.Error())
}

174
k6/README.md Normal file
View File

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

248
k6/cart_load_test.js Normal file
View File

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

218
main.go
View File

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

View File

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

View File

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

View File

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

82
mutation_add_item.go Normal file
View File

@@ -0,0 +1,82 @@
package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_add_item.go
//
// Registers the AddItem cart mutation in the generic mutation registry.
// This replaces the legacy switch-based logic previously found in CartGrain.Apply.
//
// Behavior:
// * Validates quantity > 0
// * If an item with same SKU exists -> increases quantity
// * Else creates a new CartItem with computed tax amounts
// * Totals recalculated automatically via WithTotals()
//
// NOTE: Any future field additions in messages.AddItem that affect pricing / tax
// must keep this handler in sync.
func init() {
RegisterMutation[messages.AddItem](
"AddItem",
func(g *CartGrain, m *messages.AddItem) error {
if m == nil {
return fmt.Errorf("AddItem: nil payload")
}
if m.Quantity < 1 {
return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity)
}
// Fast path: merge with existing item having same SKU
if existing, found := g.FindItemWithSku(m.Sku); found {
existing.Quantity += int(m.Quantity)
return nil
}
g.mu.Lock()
defer g.mu.Unlock()
g.lastItemId++
taxRate := 2500
if m.Tax > 0 {
taxRate = int(m.Tax)
}
taxAmountPerUnit := GetTaxAmount(m.Price, taxRate)
g.Items = append(g.Items, &CartItem{
Id: g.lastItemId,
ItemId: int(m.ItemId),
Quantity: int(m.Quantity),
Sku: m.Sku,
Name: m.Name,
Price: m.Price,
TotalPrice: m.Price * int64(m.Quantity),
TotalTax: int64(taxAmountPerUnit * int64(m.Quantity)),
Image: m.Image,
Stock: StockStatus(m.Stock),
Disclaimer: m.Disclaimer,
Brand: m.Brand,
Category: m.Category,
Category2: m.Category2,
Category3: m.Category3,
Category4: m.Category4,
Category5: m.Category5,
OrgPrice: m.OrgPrice,
ArticleType: m.ArticleType,
Outlet: m.Outlet,
SellerId: m.SellerId,
SellerName: m.SellerName,
Tax: int(taxAmountPerUnit),
TaxRate: taxRate,
StoreId: m.StoreId,
})
return nil
},
WithTotals(), // Recalculate totals after successful mutation
)
}

61
mutation_add_request.go Normal file
View File

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

View File

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

View File

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

53
mutation_order_created.go Normal file
View File

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

301
mutation_registry.go Normal file
View File

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

View File

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

49
mutation_remove_item.go Normal file
View File

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

View File

@@ -0,0 +1,57 @@
package main
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_set_cart_items.go
//
// Registers the SetCartRequest mutation. This mutation replaces the entire list
// of cart items with the provided list (each entry is an AddRequest).
//
// Behavior:
// - Clears existing items (but leaves deliveries intact).
// - Iterates over each AddRequest and delegates to CartGrain.AddItem
// (which performs product lookup, creates AddItem mutation).
// - If any single addition fails, the mutation aborts with an error;
// items added prior to the failure remain (consistent with previous behavior).
// - Totals recalculated after completion via WithTotals().
//
// Notes:
// - Potential optimization: batch product lookups; currently sequential.
// - Consider adding rollback semantics if atomic replacement is desired.
// - Deliveries might reference item IDs that are now invalid—original logic
// also left deliveries untouched. If that becomes an issue, add a cleanup
// pass to remove deliveries whose item IDs no longer exist.
func init() {
RegisterMutation[messages.SetCartRequest](
"SetCartRequest",
func(g *CartGrain, m *messages.SetCartRequest) error {
if m == nil {
return fmt.Errorf("SetCartRequest: nil payload")
}
// Clear current items (keep deliveries)
g.mu.Lock()
g.Items = make([]*CartItem, 0, len(m.Items))
g.mu.Unlock()
for _, it := range m.Items {
if it == nil {
continue
}
if it.Sku == "" || it.Quantity < 1 {
return fmt.Errorf("SetCartRequest: invalid item (sku='%s' qty=%d)", it.Sku, it.Quantity)
}
_, err := g.AddItem(it.Sku, int(it.Quantity), it.Country, it.StoreId)
if err != nil {
return fmt.Errorf("SetCartRequest: add sku '%s' failed: %w", it.Sku, err)
}
}
return nil
},
WithTotals(),
)
}

101
mutation_set_delivery.go Normal file
View File

@@ -0,0 +1,101 @@
package main
import (
"fmt"
"slices"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_set_delivery.go
//
// Registers the SetDelivery mutation.
//
// Semantics (mirrors legacy switch logic):
// - If the payload specifies an explicit list of item IDs (payload.Items):
// - Each referenced cart line must exist.
// - None of the referenced items may already belong to a delivery.
// - Only those items are associated with the new delivery.
// - If payload.Items is empty:
// - All items currently without any delivery are associated with the new delivery.
// - A new delivery line is created with:
// - Auto-incremented delivery ID (cart-local)
// - Provider from payload
// - Fixed price (currently hard-coded: 4900 minor units) adjust as needed
// - Optional PickupPoint copied from payload
// - Cart totals are recalculated (WithTotals)
//
// Error cases:
// - Referenced item does not exist
// - Referenced item already has a delivery
// - No items qualify (resulting association set empty) -> returns error (prevents creating empty delivery)
//
// Concurrency:
// - Uses g.mu to protect lastDeliveryId increment and append to Deliveries slice.
// Item scans are read-only and performed outside the lock for simplicity;
// if stricter guarantees are needed, widen the lock section.
//
// Future extension points:
// - Variable delivery pricing (based on weight, distance, provider, etc.)
// - Validation of provider codes
// - Multi-currency delivery pricing
func init() {
RegisterMutation[messages.SetDelivery](
"SetDelivery",
func(g *CartGrain, m *messages.SetDelivery) error {
if m == nil {
return fmt.Errorf("SetDelivery: nil payload")
}
if m.Provider == "" {
return fmt.Errorf("SetDelivery: provider is empty")
}
withDelivery := g.ItemsWithDelivery()
targetItems := make([]int, 0)
if len(m.Items) == 0 {
// Use every item currently without a delivery
targetItems = append(targetItems, g.ItemsWithoutDelivery()...)
} else {
// Validate explicit list
for _, id64 := range m.Items {
id := int(id64)
found := false
for _, it := range g.Items {
if it.Id == id {
found = true
break
}
}
if !found {
return fmt.Errorf("SetDelivery: item id %d not found", id)
}
if slices.Contains(withDelivery, id) {
return fmt.Errorf("SetDelivery: item id %d already has a delivery", id)
}
targetItems = append(targetItems, id)
}
}
if len(targetItems) == 0 {
return fmt.Errorf("SetDelivery: no eligible items to attach")
}
// Append new delivery
g.mu.Lock()
g.lastDeliveryId++
newId := g.lastDeliveryId
g.Deliveries = append(g.Deliveries, &CartDelivery{
Id: newId,
Provider: m.Provider,
PickupPoint: m.PickupPoint,
Price: 4900, // TODO: externalize pricing
Items: targetItems,
})
g.mu.Unlock()
return nil
},
WithTotals(),
)
}

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package main
package discovery
import (
"testing"

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

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

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.10
// protoc v3.21.12
// protoc v6.32.1
// source: control_plane.proto
package messages
@@ -202,27 +202,27 @@ func (x *NegotiateReply) GetHosts() []string {
}
// CartIdsReply returns the list of cart IDs (string form) currently owned locally.
type CartIdsReply struct {
type ActorIdsReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
CartIds []string `protobuf:"bytes,1,rep,name=cart_ids,json=cartIds,proto3" json:"cart_ids,omitempty"`
Ids []uint64 `protobuf:"varint,1,rep,packed,name=ids,proto3" json:"ids,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CartIdsReply) Reset() {
*x = CartIdsReply{}
func (x *ActorIdsReply) Reset() {
*x = ActorIdsReply{}
mi := &file_control_plane_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CartIdsReply) String() string {
func (x *ActorIdsReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CartIdsReply) ProtoMessage() {}
func (*ActorIdsReply) ProtoMessage() {}
func (x *CartIdsReply) ProtoReflect() protoreflect.Message {
func (x *ActorIdsReply) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
@@ -234,72 +234,19 @@ func (x *CartIdsReply) ProtoReflect() protoreflect.Message {
return mi.MessageOf(x)
}
// Deprecated: Use CartIdsReply.ProtoReflect.Descriptor instead.
func (*CartIdsReply) Descriptor() ([]byte, []int) {
// Deprecated: Use ActorIdsReply.ProtoReflect.Descriptor instead.
func (*ActorIdsReply) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{4}
}
func (x *CartIdsReply) GetCartIds() []string {
func (x *ActorIdsReply) GetIds() []uint64 {
if x != nil {
return x.CartIds
return x.Ids
}
return nil
}
// OwnerChangeRequest notifies peers that ownership of a cart moved (or is moving) to new_host.
type OwnerChangeRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
CartId string `protobuf:"bytes,1,opt,name=cart_id,json=cartId,proto3" json:"cart_id,omitempty"`
NewHost string `protobuf:"bytes,2,opt,name=new_host,json=newHost,proto3" json:"new_host,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *OwnerChangeRequest) Reset() {
*x = OwnerChangeRequest{}
mi := &file_control_plane_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *OwnerChangeRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*OwnerChangeRequest) ProtoMessage() {}
func (x *OwnerChangeRequest) 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 OwnerChangeRequest.ProtoReflect.Descriptor instead.
func (*OwnerChangeRequest) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{5}
}
func (x *OwnerChangeRequest) GetCartId() string {
if x != nil {
return x.CartId
}
return ""
}
func (x *OwnerChangeRequest) GetNewHost() string {
if x != nil {
return x.NewHost
}
return ""
}
// OwnerChangeAck indicates acceptance or rejection of an ownership change.
// 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"`
@@ -310,7 +257,7 @@ type OwnerChangeAck struct {
func (x *OwnerChangeAck) Reset() {
*x = OwnerChangeAck{}
mi := &file_control_plane_proto_msgTypes[6]
mi := &file_control_plane_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -322,7 +269,7 @@ func (x *OwnerChangeAck) String() string {
func (*OwnerChangeAck) ProtoMessage() {}
func (x *OwnerChangeAck) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[6]
mi := &file_control_plane_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -335,7 +282,7 @@ func (x *OwnerChangeAck) ProtoReflect() protoreflect.Message {
// Deprecated: Use OwnerChangeAck.ProtoReflect.Descriptor instead.
func (*OwnerChangeAck) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{6}
return file_control_plane_proto_rawDescGZIP(), []int{5}
}
func (x *OwnerChangeAck) GetAccepted() bool {
@@ -362,7 +309,7 @@ type ClosingNotice struct {
func (x *ClosingNotice) Reset() {
*x = ClosingNotice{}
mi := &file_control_plane_proto_msgTypes[7]
mi := &file_control_plane_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -374,7 +321,7 @@ func (x *ClosingNotice) String() string {
func (*ClosingNotice) ProtoMessage() {}
func (x *ClosingNotice) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[7]
mi := &file_control_plane_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -387,7 +334,7 @@ func (x *ClosingNotice) ProtoReflect() protoreflect.Message {
// Deprecated: Use ClosingNotice.ProtoReflect.Descriptor instead.
func (*ClosingNotice) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{7}
return file_control_plane_proto_rawDescGZIP(), []int{6}
}
func (x *ClosingNotice) GetHost() string {
@@ -397,6 +344,113 @@ func (x *ClosingNotice) GetHost() string {
return ""
}
// OwnershipAnnounce broadcasts first-touch ownership claims for cart IDs.
// First claim wins; receivers SHOULD NOT overwrite an existing different owner.
type OwnershipAnnounce struct {
state protoimpl.MessageState `protogen:"open.v1"`
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` // announcing host
Ids []uint64 `protobuf:"varint,2,rep,packed,name=ids,proto3" json:"ids,omitempty"` // newly claimed cart ids
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *OwnershipAnnounce) Reset() {
*x = OwnershipAnnounce{}
mi := &file_control_plane_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *OwnershipAnnounce) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*OwnershipAnnounce) ProtoMessage() {}
func (x *OwnershipAnnounce) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use OwnershipAnnounce.ProtoReflect.Descriptor instead.
func (*OwnershipAnnounce) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{7}
}
func (x *OwnershipAnnounce) GetHost() string {
if x != nil {
return x.Host
}
return ""
}
func (x *OwnershipAnnounce) GetIds() []uint64 {
if x != nil {
return x.Ids
}
return nil
}
// ExpiryAnnounce broadcasts that a host evicted the provided cart IDs.
type ExpiryAnnounce struct {
state protoimpl.MessageState `protogen:"open.v1"`
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
Ids []uint64 `protobuf:"varint,2,rep,packed,name=ids,proto3" json:"ids,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExpiryAnnounce) Reset() {
*x = ExpiryAnnounce{}
mi := &file_control_plane_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExpiryAnnounce) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExpiryAnnounce) ProtoMessage() {}
func (x *ExpiryAnnounce) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExpiryAnnounce.ProtoReflect.Descriptor instead.
func (*ExpiryAnnounce) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{8}
}
func (x *ExpiryAnnounce) GetHost() string {
if x != nil {
return x.Host
}
return ""
}
func (x *ExpiryAnnounce) GetIds() []uint64 {
if x != nil {
return x.Ids
}
return nil
}
var File_control_plane_proto protoreflect.FileDescriptor
const file_control_plane_proto_rawDesc = "" +
@@ -410,25 +464,27 @@ const file_control_plane_proto_rawDesc = "" +
"\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\"H\n" +
"\x12OwnerChangeRequest\x12\x17\n" +
"\acart_id\x18\x01 \x01(\tR\x06cartId\x12\x19\n" +
"\bnew_host\x18\x02 \x01(\tR\anewHost\"F\n" +
"\x05hosts\x18\x01 \x03(\tR\x05hosts\"!\n" +
"\rActorIdsReply\x12\x10\n" +
"\x03ids\x18\x01 \x03(\x04R\x03ids\"F\n" +
"\x0eOwnerChangeAck\x12\x1a\n" +
"\baccepted\x18\x01 \x01(\bR\baccepted\x12\x18\n" +
"\amessage\x18\x02 \x01(\tR\amessage\"#\n" +
"\rClosingNotice\x12\x12\n" +
"\x04host\x18\x01 \x01(\tR\x04host2\xbc\x02\n" +
"\x04host\x18\x01 \x01(\tR\x04host\"9\n" +
"\x11OwnershipAnnounce\x12\x12\n" +
"\x04host\x18\x01 \x01(\tR\x04host\x12\x10\n" +
"\x03ids\x18\x02 \x03(\x04R\x03ids\"6\n" +
"\x0eExpiryAnnounce\x12\x12\n" +
"\x04host\x18\x01 \x01(\tR\x04host\x12\x10\n" +
"\x03ids\x18\x02 \x03(\x04R\x03ids2\x8d\x03\n" +
"\fControlPlane\x12,\n" +
"\x04Ping\x12\x0f.messages.Empty\x1a\x13.messages.PingReply\x12A\n" +
"\tNegotiate\x12\x1a.messages.NegotiateRequest\x1a\x18.messages.NegotiateReply\x125\n" +
"\n" +
"GetCartIds\x12\x0f.messages.Empty\x1a\x16.messages.CartIdsReply\x12F\n" +
"\fConfirmOwner\x12\x1c.messages.OwnerChangeRequest\x1a\x18.messages.OwnerChangeAck\x12<\n" +
"\aClosing\x12\x17.messages.ClosingNotice\x1a\x18.messages.OwnerChangeAckB\fZ\n" +
".;messagesb\x06proto3"
"\tNegotiate\x12\x1a.messages.NegotiateRequest\x1a\x18.messages.NegotiateReply\x12<\n" +
"\x10GetLocalActorIds\x12\x0f.messages.Empty\x1a\x17.messages.ActorIdsReply\x12J\n" +
"\x11AnnounceOwnership\x12\x1b.messages.OwnershipAnnounce\x1a\x18.messages.OwnerChangeAck\x12D\n" +
"\x0eAnnounceExpiry\x12\x18.messages.ExpiryAnnounce\x1a\x18.messages.OwnerChangeAck\x12<\n" +
"\aClosing\x12\x17.messages.ClosingNotice\x1a\x18.messages.OwnerChangeAckB.Z,git.tornberg.me/go-cart-actor/proto;messagesb\x06proto3"
var (
file_control_plane_proto_rawDescOnce sync.Once
@@ -442,30 +498,33 @@ func file_control_plane_proto_rawDescGZIP() []byte {
return file_control_plane_proto_rawDescData
}
var file_control_plane_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
var file_control_plane_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
var file_control_plane_proto_goTypes = []any{
(*Empty)(nil), // 0: messages.Empty
(*PingReply)(nil), // 1: messages.PingReply
(*NegotiateRequest)(nil), // 2: messages.NegotiateRequest
(*NegotiateReply)(nil), // 3: messages.NegotiateReply
(*CartIdsReply)(nil), // 4: messages.CartIdsReply
(*OwnerChangeRequest)(nil), // 5: messages.OwnerChangeRequest
(*OwnerChangeAck)(nil), // 6: messages.OwnerChangeAck
(*ClosingNotice)(nil), // 7: messages.ClosingNotice
(*Empty)(nil), // 0: messages.Empty
(*PingReply)(nil), // 1: messages.PingReply
(*NegotiateRequest)(nil), // 2: messages.NegotiateRequest
(*NegotiateReply)(nil), // 3: messages.NegotiateReply
(*ActorIdsReply)(nil), // 4: messages.ActorIdsReply
(*OwnerChangeAck)(nil), // 5: messages.OwnerChangeAck
(*ClosingNotice)(nil), // 6: messages.ClosingNotice
(*OwnershipAnnounce)(nil), // 7: messages.OwnershipAnnounce
(*ExpiryAnnounce)(nil), // 8: messages.ExpiryAnnounce
}
var file_control_plane_proto_depIdxs = []int32{
0, // 0: messages.ControlPlane.Ping:input_type -> messages.Empty
2, // 1: messages.ControlPlane.Negotiate:input_type -> messages.NegotiateRequest
0, // 2: messages.ControlPlane.GetCartIds:input_type -> messages.Empty
5, // 3: messages.ControlPlane.ConfirmOwner:input_type -> messages.OwnerChangeRequest
7, // 4: messages.ControlPlane.Closing:input_type -> messages.ClosingNotice
1, // 5: messages.ControlPlane.Ping:output_type -> messages.PingReply
3, // 6: messages.ControlPlane.Negotiate:output_type -> messages.NegotiateReply
4, // 7: messages.ControlPlane.GetCartIds:output_type -> messages.CartIdsReply
6, // 8: messages.ControlPlane.ConfirmOwner:output_type -> messages.OwnerChangeAck
6, // 9: messages.ControlPlane.Closing:output_type -> messages.OwnerChangeAck
5, // [5:10] is the sub-list for method output_type
0, // [0:5] is the sub-list for method input_type
0, // 2: messages.ControlPlane.GetLocalActorIds:input_type -> messages.Empty
7, // 3: messages.ControlPlane.AnnounceOwnership:input_type -> messages.OwnershipAnnounce
8, // 4: messages.ControlPlane.AnnounceExpiry:input_type -> messages.ExpiryAnnounce
6, // 5: messages.ControlPlane.Closing:input_type -> messages.ClosingNotice
1, // 6: messages.ControlPlane.Ping:output_type -> messages.PingReply
3, // 7: messages.ControlPlane.Negotiate:output_type -> messages.NegotiateReply
4, // 8: messages.ControlPlane.GetLocalActorIds:output_type -> messages.ActorIdsReply
5, // 9: messages.ControlPlane.AnnounceOwnership:output_type -> messages.OwnerChangeAck
5, // 10: messages.ControlPlane.AnnounceExpiry:output_type -> messages.OwnerChangeAck
5, // 11: messages.ControlPlane.Closing:output_type -> messages.OwnerChangeAck
6, // [6:12] is the sub-list for method output_type
0, // [0:6] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
@@ -482,7 +541,7 @@ func file_control_plane_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_control_plane_proto_rawDesc), len(file_control_plane_proto_rawDesc)),
NumEnums: 0,
NumMessages: 8,
NumMessages: 9,
NumExtensions: 0,
NumServices: 1,
},

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v3.21.12
// - protoc v6.32.1
// source: control_plane.proto
package messages
@@ -19,11 +19,12 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
ControlPlane_Ping_FullMethodName = "/messages.ControlPlane/Ping"
ControlPlane_Negotiate_FullMethodName = "/messages.ControlPlane/Negotiate"
ControlPlane_GetCartIds_FullMethodName = "/messages.ControlPlane/GetCartIds"
ControlPlane_ConfirmOwner_FullMethodName = "/messages.ControlPlane/ConfirmOwner"
ControlPlane_Closing_FullMethodName = "/messages.ControlPlane/Closing"
ControlPlane_Ping_FullMethodName = "/messages.ControlPlane/Ping"
ControlPlane_Negotiate_FullMethodName = "/messages.ControlPlane/Negotiate"
ControlPlane_GetLocalActorIds_FullMethodName = "/messages.ControlPlane/GetLocalActorIds"
ControlPlane_AnnounceOwnership_FullMethodName = "/messages.ControlPlane/AnnounceOwnership"
ControlPlane_AnnounceExpiry_FullMethodName = "/messages.ControlPlane/AnnounceExpiry"
ControlPlane_Closing_FullMethodName = "/messages.ControlPlane/Closing"
)
// ControlPlaneClient is the client API for ControlPlane service.
@@ -37,9 +38,11 @@ type ControlPlaneClient interface {
// 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)
// ConfirmOwner announces/asks peers to acknowledge ownership transfer.
ConfirmOwner(ctx context.Context, in *OwnerChangeRequest, opts ...grpc.CallOption) (*OwnerChangeAck, error)
GetLocalActorIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*ActorIdsReply, error)
// Ownership announcement: first-touch claim broadcast (idempotent; best-effort).
AnnounceOwnership(ctx context.Context, in *OwnershipAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error)
// Expiry announcement: drop remote ownership hints when local TTL expires.
AnnounceExpiry(ctx context.Context, in *ExpiryAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error)
// Closing announces graceful shutdown so peers can proactively adjust.
Closing(ctx context.Context, in *ClosingNotice, opts ...grpc.CallOption) (*OwnerChangeAck, error)
}
@@ -72,20 +75,30 @@ func (c *controlPlaneClient) Negotiate(ctx context.Context, in *NegotiateRequest
return out, nil
}
func (c *controlPlaneClient) GetCartIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*CartIdsReply, error) {
func (c *controlPlaneClient) GetLocalActorIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*ActorIdsReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CartIdsReply)
err := c.cc.Invoke(ctx, ControlPlane_GetCartIds_FullMethodName, in, out, cOpts...)
out := new(ActorIdsReply)
err := c.cc.Invoke(ctx, ControlPlane_GetLocalActorIds_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *controlPlaneClient) ConfirmOwner(ctx context.Context, in *OwnerChangeRequest, opts ...grpc.CallOption) (*OwnerChangeAck, error) {
func (c *controlPlaneClient) AnnounceOwnership(ctx context.Context, in *OwnershipAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(OwnerChangeAck)
err := c.cc.Invoke(ctx, ControlPlane_ConfirmOwner_FullMethodName, in, out, cOpts...)
err := c.cc.Invoke(ctx, ControlPlane_AnnounceOwnership_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *controlPlaneClient) AnnounceExpiry(ctx context.Context, in *ExpiryAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(OwnerChangeAck)
err := c.cc.Invoke(ctx, ControlPlane_AnnounceExpiry_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
@@ -113,9 +126,11 @@ type ControlPlaneServer interface {
// 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)
// ConfirmOwner announces/asks peers to acknowledge ownership transfer.
ConfirmOwner(context.Context, *OwnerChangeRequest) (*OwnerChangeAck, error)
GetLocalActorIds(context.Context, *Empty) (*ActorIdsReply, error)
// Ownership announcement: first-touch claim broadcast (idempotent; best-effort).
AnnounceOwnership(context.Context, *OwnershipAnnounce) (*OwnerChangeAck, error)
// Expiry announcement: drop remote ownership hints when local TTL expires.
AnnounceExpiry(context.Context, *ExpiryAnnounce) (*OwnerChangeAck, error)
// Closing announces graceful shutdown so peers can proactively adjust.
Closing(context.Context, *ClosingNotice) (*OwnerChangeAck, error)
mustEmbedUnimplementedControlPlaneServer()
@@ -134,11 +149,14 @@ func (UnimplementedControlPlaneServer) Ping(context.Context, *Empty) (*PingReply
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) GetLocalActorIds(context.Context, *Empty) (*ActorIdsReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetLocalActorIds not implemented")
}
func (UnimplementedControlPlaneServer) ConfirmOwner(context.Context, *OwnerChangeRequest) (*OwnerChangeAck, error) {
return nil, status.Errorf(codes.Unimplemented, "method ConfirmOwner not implemented")
func (UnimplementedControlPlaneServer) AnnounceOwnership(context.Context, *OwnershipAnnounce) (*OwnerChangeAck, error) {
return nil, status.Errorf(codes.Unimplemented, "method AnnounceOwnership not implemented")
}
func (UnimplementedControlPlaneServer) AnnounceExpiry(context.Context, *ExpiryAnnounce) (*OwnerChangeAck, error) {
return nil, status.Errorf(codes.Unimplemented, "method AnnounceExpiry not implemented")
}
func (UnimplementedControlPlaneServer) Closing(context.Context, *ClosingNotice) (*OwnerChangeAck, error) {
return nil, status.Errorf(codes.Unimplemented, "method Closing not implemented")
@@ -200,38 +218,56 @@ func _ControlPlane_Negotiate_Handler(srv interface{}, ctx context.Context, dec f
return interceptor(ctx, in, info, handler)
}
func _ControlPlane_GetCartIds_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
func _ControlPlane_GetLocalActorIds_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(Empty)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ControlPlaneServer).GetCartIds(ctx, in)
return srv.(ControlPlaneServer).GetLocalActorIds(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ControlPlane_GetCartIds_FullMethodName,
FullMethod: ControlPlane_GetLocalActorIds_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ControlPlaneServer).GetCartIds(ctx, req.(*Empty))
return srv.(ControlPlaneServer).GetLocalActorIds(ctx, req.(*Empty))
}
return interceptor(ctx, in, info, handler)
}
func _ControlPlane_ConfirmOwner_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(OwnerChangeRequest)
func _ControlPlane_AnnounceOwnership_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(OwnershipAnnounce)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ControlPlaneServer).ConfirmOwner(ctx, in)
return srv.(ControlPlaneServer).AnnounceOwnership(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ControlPlane_ConfirmOwner_FullMethodName,
FullMethod: ControlPlane_AnnounceOwnership_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ControlPlaneServer).ConfirmOwner(ctx, req.(*OwnerChangeRequest))
return srv.(ControlPlaneServer).AnnounceOwnership(ctx, req.(*OwnershipAnnounce))
}
return interceptor(ctx, in, info, handler)
}
func _ControlPlane_AnnounceExpiry_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ExpiryAnnounce)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ControlPlaneServer).AnnounceExpiry(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ControlPlane_AnnounceExpiry_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ControlPlaneServer).AnnounceExpiry(ctx, req.(*ExpiryAnnounce))
}
return interceptor(ctx, in, info, handler)
}
@@ -270,12 +306,16 @@ var ControlPlane_ServiceDesc = grpc.ServiceDesc{
Handler: _ControlPlane_Negotiate_Handler,
},
{
MethodName: "GetCartIds",
Handler: _ControlPlane_GetCartIds_Handler,
MethodName: "GetLocalActorIds",
Handler: _ControlPlane_GetLocalActorIds_Handler,
},
{
MethodName: "ConfirmOwner",
Handler: _ControlPlane_ConfirmOwner_Handler,
MethodName: "AnnounceOwnership",
Handler: _ControlPlane_AnnounceOwnership_Handler,
},
{
MethodName: "AnnounceExpiry",
Handler: _ControlPlane_AnnounceExpiry_Handler,
},
{
MethodName: "Closing",

View File

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

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

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

View File

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

View File

@@ -1,420 +0,0 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.10
// protoc v3.21.12
// source: cart_actor.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)
)
// MutationType corresponds 1:1 with the legacy uint16 message type constants.
type MutationType int32
const (
MutationType_MUTATION_TYPE_UNSPECIFIED MutationType = 0
MutationType_MUTATION_ADD_REQUEST MutationType = 1
MutationType_MUTATION_ADD_ITEM MutationType = 2
// (3 was unused / reserved in legacy framing)
MutationType_MUTATION_REMOVE_ITEM MutationType = 4
MutationType_MUTATION_REMOVE_DELIVERY MutationType = 5
MutationType_MUTATION_CHANGE_QUANTITY MutationType = 6
MutationType_MUTATION_SET_DELIVERY MutationType = 7
MutationType_MUTATION_SET_PICKUP_POINT MutationType = 8
MutationType_MUTATION_CREATE_CHECKOUT_ORDER MutationType = 9
MutationType_MUTATION_SET_CART_ITEMS MutationType = 10
MutationType_MUTATION_ORDER_COMPLETED MutationType = 11
)
// Enum value maps for MutationType.
var (
MutationType_name = map[int32]string{
0: "MUTATION_TYPE_UNSPECIFIED",
1: "MUTATION_ADD_REQUEST",
2: "MUTATION_ADD_ITEM",
4: "MUTATION_REMOVE_ITEM",
5: "MUTATION_REMOVE_DELIVERY",
6: "MUTATION_CHANGE_QUANTITY",
7: "MUTATION_SET_DELIVERY",
8: "MUTATION_SET_PICKUP_POINT",
9: "MUTATION_CREATE_CHECKOUT_ORDER",
10: "MUTATION_SET_CART_ITEMS",
11: "MUTATION_ORDER_COMPLETED",
}
MutationType_value = map[string]int32{
"MUTATION_TYPE_UNSPECIFIED": 0,
"MUTATION_ADD_REQUEST": 1,
"MUTATION_ADD_ITEM": 2,
"MUTATION_REMOVE_ITEM": 4,
"MUTATION_REMOVE_DELIVERY": 5,
"MUTATION_CHANGE_QUANTITY": 6,
"MUTATION_SET_DELIVERY": 7,
"MUTATION_SET_PICKUP_POINT": 8,
"MUTATION_CREATE_CHECKOUT_ORDER": 9,
"MUTATION_SET_CART_ITEMS": 10,
"MUTATION_ORDER_COMPLETED": 11,
}
)
func (x MutationType) Enum() *MutationType {
p := new(MutationType)
*p = x
return p
}
func (x MutationType) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (MutationType) Descriptor() protoreflect.EnumDescriptor {
return file_cart_actor_proto_enumTypes[0].Descriptor()
}
func (MutationType) Type() protoreflect.EnumType {
return &file_cart_actor_proto_enumTypes[0]
}
func (x MutationType) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use MutationType.Descriptor instead.
func (MutationType) EnumDescriptor() ([]byte, []int) {
return file_cart_actor_proto_rawDescGZIP(), []int{0}
}
// MutationRequest is an envelope:
// - cart_id: string form of CartId (legacy 16-byte array truncated/padded).
// - type: mutation kind (see enum).
// - payload: serialized underlying proto message (AddRequest, AddItem, etc.).
// - client_timestamp: optional unix timestamp; server sets if zero.
type MutationRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
CartId string `protobuf:"bytes,1,opt,name=cart_id,json=cartId,proto3" json:"cart_id,omitempty"`
Type MutationType `protobuf:"varint,2,opt,name=type,proto3,enum=messages.MutationType" json:"type,omitempty"`
Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"`
ClientTimestamp int64 `protobuf:"varint,4,opt,name=client_timestamp,json=clientTimestamp,proto3" json:"client_timestamp,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *MutationRequest) Reset() {
*x = MutationRequest{}
mi := &file_cart_actor_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MutationRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MutationRequest) ProtoMessage() {}
func (x *MutationRequest) ProtoReflect() protoreflect.Message {
mi := &file_cart_actor_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 MutationRequest.ProtoReflect.Descriptor instead.
func (*MutationRequest) Descriptor() ([]byte, []int) {
return file_cart_actor_proto_rawDescGZIP(), []int{0}
}
func (x *MutationRequest) GetCartId() string {
if x != nil {
return x.CartId
}
return ""
}
func (x *MutationRequest) GetType() MutationType {
if x != nil {
return x.Type
}
return MutationType_MUTATION_TYPE_UNSPECIFIED
}
func (x *MutationRequest) GetPayload() []byte {
if x != nil {
return x.Payload
}
return nil
}
func (x *MutationRequest) GetClientTimestamp() int64 {
if x != nil {
return x.ClientTimestamp
}
return 0
}
// MutationReply returns a status code (legacy semantics) plus a JSON payload
// representing the full cart state (or an error message if non-200).
type MutationReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
StatusCode int32 `protobuf:"varint,1,opt,name=status_code,json=statusCode,proto3" json:"status_code,omitempty"`
Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` // JSON cart state or error string
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *MutationReply) Reset() {
*x = MutationReply{}
mi := &file_cart_actor_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MutationReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MutationReply) ProtoMessage() {}
func (x *MutationReply) ProtoReflect() protoreflect.Message {
mi := &file_cart_actor_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 MutationReply.ProtoReflect.Descriptor instead.
func (*MutationReply) Descriptor() ([]byte, []int) {
return file_cart_actor_proto_rawDescGZIP(), []int{1}
}
func (x *MutationReply) GetStatusCode() int32 {
if x != nil {
return x.StatusCode
}
return 0
}
func (x *MutationReply) GetPayload() []byte {
if x != nil {
return x.Payload
}
return nil
}
// StateRequest fetches current cart state without mutation.
type StateRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
CartId string `protobuf:"bytes,1,opt,name=cart_id,json=cartId,proto3" json:"cart_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StateRequest) Reset() {
*x = StateRequest{}
mi := &file_cart_actor_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StateRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StateRequest) ProtoMessage() {}
func (x *StateRequest) ProtoReflect() protoreflect.Message {
mi := &file_cart_actor_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 StateRequest.ProtoReflect.Descriptor instead.
func (*StateRequest) Descriptor() ([]byte, []int) {
return file_cart_actor_proto_rawDescGZIP(), []int{2}
}
func (x *StateRequest) GetCartId() string {
if x != nil {
return x.CartId
}
return ""
}
// StateReply mirrors MutationReply for consistency.
type StateReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
StatusCode int32 `protobuf:"varint,1,opt,name=status_code,json=statusCode,proto3" json:"status_code,omitempty"`
Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` // JSON cart state or error string
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StateReply) Reset() {
*x = StateReply{}
mi := &file_cart_actor_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StateReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StateReply) ProtoMessage() {}
func (x *StateReply) ProtoReflect() protoreflect.Message {
mi := &file_cart_actor_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 StateReply.ProtoReflect.Descriptor instead.
func (*StateReply) Descriptor() ([]byte, []int) {
return file_cart_actor_proto_rawDescGZIP(), []int{3}
}
func (x *StateReply) GetStatusCode() int32 {
if x != nil {
return x.StatusCode
}
return 0
}
func (x *StateReply) GetPayload() []byte {
if x != nil {
return x.Payload
}
return nil
}
var File_cart_actor_proto protoreflect.FileDescriptor
const file_cart_actor_proto_rawDesc = "" +
"\n" +
"\x10cart_actor.proto\x12\bmessages\"\x9b\x01\n" +
"\x0fMutationRequest\x12\x17\n" +
"\acart_id\x18\x01 \x01(\tR\x06cartId\x12*\n" +
"\x04type\x18\x02 \x01(\x0e2\x16.messages.MutationTypeR\x04type\x12\x18\n" +
"\apayload\x18\x03 \x01(\fR\apayload\x12)\n" +
"\x10client_timestamp\x18\x04 \x01(\x03R\x0fclientTimestamp\"J\n" +
"\rMutationReply\x12\x1f\n" +
"\vstatus_code\x18\x01 \x01(\x05R\n" +
"statusCode\x12\x18\n" +
"\apayload\x18\x02 \x01(\fR\apayload\"'\n" +
"\fStateRequest\x12\x17\n" +
"\acart_id\x18\x01 \x01(\tR\x06cartId\"G\n" +
"\n" +
"StateReply\x12\x1f\n" +
"\vstatus_code\x18\x01 \x01(\x05R\n" +
"statusCode\x12\x18\n" +
"\apayload\x18\x02 \x01(\fR\apayload*\xcd\x02\n" +
"\fMutationType\x12\x1d\n" +
"\x19MUTATION_TYPE_UNSPECIFIED\x10\x00\x12\x18\n" +
"\x14MUTATION_ADD_REQUEST\x10\x01\x12\x15\n" +
"\x11MUTATION_ADD_ITEM\x10\x02\x12\x18\n" +
"\x14MUTATION_REMOVE_ITEM\x10\x04\x12\x1c\n" +
"\x18MUTATION_REMOVE_DELIVERY\x10\x05\x12\x1c\n" +
"\x18MUTATION_CHANGE_QUANTITY\x10\x06\x12\x19\n" +
"\x15MUTATION_SET_DELIVERY\x10\a\x12\x1d\n" +
"\x19MUTATION_SET_PICKUP_POINT\x10\b\x12\"\n" +
"\x1eMUTATION_CREATE_CHECKOUT_ORDER\x10\t\x12\x1b\n" +
"\x17MUTATION_SET_CART_ITEMS\x10\n" +
"\x12\x1c\n" +
"\x18MUTATION_ORDER_COMPLETED\x10\v2\x83\x01\n" +
"\tCartActor\x12<\n" +
"\x06Mutate\x12\x19.messages.MutationRequest\x1a\x17.messages.MutationReply\x128\n" +
"\bGetState\x12\x16.messages.StateRequest\x1a\x14.messages.StateReplyB\fZ\n" +
".;messagesb\x06proto3"
var (
file_cart_actor_proto_rawDescOnce sync.Once
file_cart_actor_proto_rawDescData []byte
)
func file_cart_actor_proto_rawDescGZIP() []byte {
file_cart_actor_proto_rawDescOnce.Do(func() {
file_cart_actor_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_cart_actor_proto_rawDesc), len(file_cart_actor_proto_rawDesc)))
})
return file_cart_actor_proto_rawDescData
}
var file_cart_actor_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_cart_actor_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
var file_cart_actor_proto_goTypes = []any{
(MutationType)(0), // 0: messages.MutationType
(*MutationRequest)(nil), // 1: messages.MutationRequest
(*MutationReply)(nil), // 2: messages.MutationReply
(*StateRequest)(nil), // 3: messages.StateRequest
(*StateReply)(nil), // 4: messages.StateReply
}
var file_cart_actor_proto_depIdxs = []int32{
0, // 0: messages.MutationRequest.type:type_name -> messages.MutationType
1, // 1: messages.CartActor.Mutate:input_type -> messages.MutationRequest
3, // 2: messages.CartActor.GetState:input_type -> messages.StateRequest
2, // 3: messages.CartActor.Mutate:output_type -> messages.MutationReply
4, // 4: messages.CartActor.GetState:output_type -> messages.StateReply
3, // [3:5] is the sub-list for method output_type
1, // [1:3] is the sub-list for method input_type
1, // [1:1] is the sub-list for extension type_name
1, // [1:1] is the sub-list for extension extendee
0, // [0:1] is the sub-list for field type_name
}
func init() { file_cart_actor_proto_init() }
func file_cart_actor_proto_init() {
if File_cart_actor_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_cart_actor_proto_rawDesc), len(file_cart_actor_proto_rawDesc)),
NumEnums: 1,
NumMessages: 4,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_cart_actor_proto_goTypes,
DependencyIndexes: file_cart_actor_proto_depIdxs,
EnumInfos: file_cart_actor_proto_enumTypes,
MessageInfos: file_cart_actor_proto_msgTypes,
}.Build()
File_cart_actor_proto = out.File
file_cart_actor_proto_goTypes = nil
file_cart_actor_proto_depIdxs = nil
}

View File

@@ -1,89 +0,0 @@
syntax = "proto3";
package messages;
option go_package = ".;messages";
// -----------------------------------------------------------------------------
// Cart Actor gRPC API (Envelope Variant)
// -----------------------------------------------------------------------------
// This service replaces the legacy custom TCP frame protocol used on port 1337.
// It keeps the existing per-mutation proto messages (defined in messages.proto)
// serialized into an opaque `bytes payload` field for minimal refactor cost.
// The numeric values in MutationType MUST match the legacy message type
// constants (see message-types.go) so persisted event logs replay correctly.
// -----------------------------------------------------------------------------
// MutationType corresponds 1:1 with the legacy uint16 message type constants.
enum MutationType {
MUTATION_TYPE_UNSPECIFIED = 0;
MUTATION_ADD_REQUEST = 1;
MUTATION_ADD_ITEM = 2;
// (3 was unused / reserved in legacy framing)
MUTATION_REMOVE_ITEM = 4;
MUTATION_REMOVE_DELIVERY = 5;
MUTATION_CHANGE_QUANTITY = 6;
MUTATION_SET_DELIVERY = 7;
MUTATION_SET_PICKUP_POINT = 8;
MUTATION_CREATE_CHECKOUT_ORDER = 9;
MUTATION_SET_CART_ITEMS = 10;
MUTATION_ORDER_COMPLETED = 11;
}
// MutationRequest is an envelope:
// - cart_id: string form of CartId (legacy 16-byte array truncated/padded).
// - type: mutation kind (see enum).
// - payload: serialized underlying proto message (AddRequest, AddItem, etc.).
// - client_timestamp: optional unix timestamp; server sets if zero.
message MutationRequest {
string cart_id = 1;
MutationType type = 2;
bytes payload = 3;
int64 client_timestamp = 4;
}
// MutationReply returns a status code (legacy semantics) plus a JSON payload
// representing the full cart state (or an error message if non-200).
message MutationReply {
int32 status_code = 1;
bytes payload = 2; // JSON cart state or error string
}
// StateRequest fetches current cart state without mutation.
message StateRequest {
string cart_id = 1;
}
// StateReply mirrors MutationReply for consistency.
message StateReply {
int32 status_code = 1;
bytes payload = 2; // JSON cart state or error string
}
// CartActor exposes mutation and state retrieval for remote grains.
service CartActor {
// Mutate applies a single mutation to a cart, creating the cart lazily if needed.
rpc Mutate(MutationRequest) returns (MutationReply);
// GetState retrieves the cart's current state (JSON).
rpc GetState(StateRequest) returns (StateReply);
}
// -----------------------------------------------------------------------------
// Notes:
//
// 1. Generation:
// protoc --go_out=. --go_opt=paths=source_relative \
// --go-grpc_out=. --go-grpc_opt=paths=source_relative \
// cart_actor.proto
//
// 2. Underlying mutation payloads originate from messages.proto definitions.
// The server side will route based on MutationType and decode payload bytes
// using existing handler registry logic.
//
// 3. Future Enhancements:
// - Replace JSON state payload with a strongly typed CartState proto.
// - Add streaming RPC (e.g. WatchState) for live updates.
// - Migrate control plane (negotiate/ownership) into a separate proto
// (control_plane.proto) as per the migration plan.
// -----------------------------------------------------------------------------

View File

@@ -1,167 +0,0 @@
// 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_Mutate_FullMethodName = "/messages.CartActor/Mutate"
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.
//
// CartActor exposes mutation and state retrieval for remote grains.
type CartActorClient interface {
// Mutate applies a single mutation to a cart, creating the cart lazily if needed.
Mutate(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*MutationReply, error)
// GetState retrieves the cart's current state (JSON).
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) Mutate(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*MutationReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(MutationReply)
err := c.cc.Invoke(ctx, CartActor_Mutate_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.
//
// CartActor exposes mutation and state retrieval for remote grains.
type CartActorServer interface {
// Mutate applies a single mutation to a cart, creating the cart lazily if needed.
Mutate(context.Context, *MutationRequest) (*MutationReply, error)
// GetState retrieves the cart's current state (JSON).
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) Mutate(context.Context, *MutationRequest) (*MutationReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method Mutate 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_Mutate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(MutationRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CartActorServer).Mutate(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CartActor_Mutate_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CartActorServer).Mutate(ctx, req.(*MutationRequest))
}
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: "Mutate",
Handler: _CartActor_Mutate_Handler,
},
{
MethodName: "GetState",
Handler: _CartActor_GetState_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "cart_actor.proto",
}

View File

@@ -2,7 +2,7 @@ syntax = "proto3";
package messages;
option go_package = ".;messages";
option go_package = "git.tornberg.me/go-cart-actor/proto;messages";
// -----------------------------------------------------------------------------
// Control Plane gRPC API
@@ -11,7 +11,7 @@ option go_package = ".;messages";
// Responsibilities:
// - Liveness (Ping)
// - Membership negotiation (Negotiate)
// - Cart ownership change propagation (ConfirmOwner)
// - 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).
@@ -37,17 +37,11 @@ message NegotiateReply {
}
// CartIdsReply returns the list of cart IDs (string form) currently owned locally.
message CartIdsReply {
repeated string cart_ids = 1;
message ActorIdsReply {
repeated uint64 ids = 1;
}
// OwnerChangeRequest notifies peers that ownership of a cart moved (or is moving) to new_host.
message OwnerChangeRequest {
string cart_id = 1;
string new_host = 2;
}
// OwnerChangeAck indicates acceptance or rejection of an ownership change.
// OwnerChangeAck retained as response type for Closing RPC (ConfirmOwner removed).
message OwnerChangeAck {
bool accepted = 1;
string message = 2;
@@ -58,6 +52,19 @@ message ClosingNotice {
string host = 1;
}
// OwnershipAnnounce broadcasts first-touch ownership claims for cart IDs.
// First claim wins; receivers SHOULD NOT overwrite an existing different owner.
message OwnershipAnnounce {
string host = 1; // announcing host
repeated uint64 ids = 2; // newly claimed cart ids
}
// ExpiryAnnounce broadcasts that a host evicted the provided cart IDs.
message ExpiryAnnounce {
string host = 1;
repeated uint64 ids = 2;
}
// ControlPlane defines cluster coordination and ownership operations.
service ControlPlane {
// Ping for liveness; lightweight health signal.
@@ -67,10 +74,15 @@ service ControlPlane {
rpc Negotiate(NegotiateRequest) returns (NegotiateReply);
// GetCartIds lists currently owned cart IDs on this node.
rpc GetCartIds(Empty) returns (CartIdsReply);
rpc GetLocalActorIds(Empty) returns (ActorIdsReply);
// ConfirmOwner announces/asks peers to acknowledge ownership transfer.
rpc ConfirmOwner(OwnerChangeRequest) returns (OwnerChangeAck);
// ConfirmOwner RPC removed (was legacy ownership acknowledgement; ring-based ownership now authoritative)
// Ownership announcement: first-touch claim broadcast (idempotent; best-effort).
rpc AnnounceOwnership(OwnershipAnnounce) returns (OwnerChangeAck);
// Expiry announcement: drop remote ownership hints when local TTL expires.
rpc AnnounceExpiry(ExpiryAnnounce) returns (OwnerChangeAck);
// Closing announces graceful shutdown so peers can proactively adjust.
rpc Closing(ClosingNotice) returns (OwnerChangeAck);

View File

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

View File

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

View File

@@ -1,147 +0,0 @@
package main
import (
"bytes"
"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
}
// HandleMessage serializes the underlying mutation proto (without legacy message header)
// and invokes the CartActor.Mutate RPC. It wraps the reply into a FrameWithPayload
// for compatibility with existing higher-level code paths.
func (g *RemoteGrainGRPC) HandleMessage(message *Message, isReplay bool) (*FrameWithPayload, error) {
if message == nil {
return nil, fmt.Errorf("nil message")
}
if isReplay {
// Remote replay not expected; ignore to keep parity with old implementation.
return nil, fmt.Errorf("replay not supported for remote grains")
}
handler, err := GetMessageHandler(message.Type)
if err != nil {
return nil, err
}
// Ensure timestamp set (legacy behavior)
if message.TimeStamp == nil {
ts := time.Now().Unix()
message.TimeStamp = &ts
}
// Marshal underlying proto payload only (no StorableMessageHeader)
var buf bytes.Buffer
err = handler.Write(message, &buf)
if err != nil {
return nil, fmt.Errorf("encode mutation payload: %w", err)
}
req := &proto.MutationRequest{
CartId: g.Id.String(),
Type: proto.MutationType(message.Type), // numeric mapping preserved
Payload: buf.Bytes(),
ClientTimestamp: *message.TimeStamp,
}
ctx, cancel := context.WithTimeout(context.Background(), g.mutateTimeout)
defer cancel()
resp, err := g.client.Mutate(ctx, req)
if err != nil {
return nil, err
}
frame := MakeFrameWithPayload(RemoteHandleMutationReply, StatusCode(resp.StatusCode), resp.Payload)
return &frame, nil
}
// GetCurrentState calls CartActor.GetState and returns a FrameWithPayload
// shaped like the legacy RemoteGetStateReply.
func (g *RemoteGrainGRPC) GetCurrentState() (*FrameWithPayload, 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
}
frame := MakeFrameWithPayload(RemoteGetStateReply, StatusCode(resp.StatusCode), resp.Payload)
return &frame, 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...))
}

View File

@@ -1,473 +0,0 @@
package main
import (
"context"
"fmt"
"log"
"sync"
"time"
proto "git.tornberg.me/go-cart-actor/proto"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"google.golang.org/grpc"
"k8s.io/apimachinery/pkg/watch"
)
// SyncedPool coordinates cart grain ownership across nodes using gRPC control plane
// and cart actor services. Legacy frame / TCP code has been removed.
//
// Responsibilities:
// - Local grain access (delegates to GrainLocalPool)
// - Remote grain proxy management (RemoteGrainGRPC)
// - Cluster membership (AddRemote via discovery + negotiation)
// - Ownership acquisition (quorum via ConfirmOwner RPC)
// - Health/ping monitoring & remote removal
//
// Thread-safety: public methods that mutate internal maps lock p.mu (RWMutex).
type SyncedPool struct {
Hostname string
local *GrainLocalPool
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
// Metrics / instrumentation dependencies already declared globally
}
// 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 (
negotiationCount = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_remote_negotiation_total",
Help: "The total number of remote negotiations",
})
grainSyncCount = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_grain_sync_total",
Help: "The total number of grain owner changes",
})
connectedRemotes = promauto.NewGauge(prometheus.GaugeOpts{
Name: "cart_connected_remotes",
Help: "The number of connected remotes",
})
remoteLookupCount = promauto.NewCounter(prometheus.CounterOpts{
Name: "cart_remote_lookup_total",
Help: "The total number of remote lookups",
})
)
func NewSyncedPool(local *GrainLocalPool, hostname string, discovery Discovery) (*SyncedPool, error) {
p := &SyncedPool{
Hostname: hostname,
local: local,
remoteHosts: make(map[string]*RemoteHostGRPC),
remoteIndex: make(map[CartId]Grain),
discardedHostHandler: NewDiscardedHostHandler(1338),
}
p.discardedHostHandler.SetReconnectHandler(p.AddRemote)
if discovery != nil {
go func() {
time.Sleep(3 * time.Second) // allow gRPC server startup
log.Printf("Starting discovery watcher")
ch, err := discovery.Watch()
if err != nil {
log.Printf("Discovery error: %v", err)
return
}
for evt := range ch {
if evt.Host == "" {
continue
}
switch evt.Type {
case watch.Deleted:
if p.IsKnown(evt.Host) {
p.RemoveHost(evt.Host)
}
default:
if !p.IsKnown(evt.Host) {
log.Printf("Discovered host %s", evt.Host)
p.AddRemote(evt.Host)
}
}
}
}()
} else {
log.Printf("No discovery configured; expecting manual AddRemote or static host injection")
}
return p, nil
}
// ------------------------- Remote Host Management -----------------------------
// AddRemote dials a remote host and initializes grain proxies.
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
}
}
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()))
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()))
}
// 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 {
if host == p.Hostname {
return true
}
p.mu.RLock()
defer p.mu.RUnlock()
_, ok := p.remoteHosts[host]
return ok
}
func (p *SyncedPool) ExcludeKnown(hosts []string) []string {
ret := make([]string, 0, len(hosts))
for _, h := range hosts {
if !p.IsKnown(h) {
ret = append(ret, h)
}
}
return ret
}
// ------------------------- Health / Ping -------------------------------------
func (p *SyncedPool) pingLoop(remote *RemoteHostGRPC) {
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for range ticker.C {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
_, err := remote.ControlClient.Ping(ctx, &proto.Empty{})
cancel()
if err != nil {
remote.MissedPings++
log.Printf("Ping %s failed (%d)", remote.Host, remote.MissedPings)
if !remote.IsHealthy() {
log.Printf("Remote %s unhealthy, removing", remote.Host)
p.RemoveHost(remote.Host)
return
}
continue
}
remote.MissedPings = 0
}
}
func (p *SyncedPool) IsHealthy() bool {
p.mu.RLock()
defer p.mu.RUnlock()
for _, r := range p.remoteHosts {
if !r.IsHealthy() {
return false
}
}
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()
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)
}
}
}
}
// ------------------------- Grain Management ----------------------------------
// 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, remove it (ownership changed)
if g, ok := p.local.grains[id]; ok && g != nil {
delete(p.local.grains, 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
}
// RequestOwnership attempts to become owner of a cart, requiring quorum.
// On success local grain is (or will be) created; peers spawn remote proxies.
func (p *SyncedPool) RequestOwnership(id CartId) error {
ok := 0
all := 0
remotes := p.GetHealthyRemotes()
for _, r := range remotes {
ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond)
reply, err := r.ControlClient.ConfirmOwner(ctx, &proto.OwnerChangeRequest{
CartId: id.String(),
NewHost: p.Hostname,
})
cancel()
all++
if err != nil || reply == nil || !reply.Accepted {
log.Printf("ConfirmOwner failure from %s for %s: %v (reply=%v)", r.Host, id, err, reply)
continue
}
ok++
}
// Quorum rule mirrors legacy:
// - If fewer than 3 total, require all.
// - Else require majority (ok >= all/2).
if (all < 3 && ok < all) || ok < (all/2) {
p.removeLocalGrain(id)
return fmt.Errorf("quorum not reached (ok=%d all=%d)", ok, all)
}
grainSyncCount.Inc()
return nil
}
func (p *SyncedPool) removeLocalGrain(id CartId) {
p.mu.Lock()
delete(p.local.grains, id)
p.mu.Unlock()
}
// getGrain returns a local or remote grain; if absent, attempts ownership.
func (p *SyncedPool) getGrain(id CartId) (Grain, error) {
p.mu.RLock()
localGrain, isLocal := p.local.grains[id]
remoteGrain, isRemote := p.remoteIndex[id]
p.mu.RUnlock()
if isLocal && localGrain != nil {
return localGrain, nil
}
if isRemote {
remoteLookupCount.Inc()
return remoteGrain, nil
}
// Attempt to claim ownership (async semantics preserved)
go p.RequestOwnership(id)
// Create local grain (lazy spawn) - may be rolled back by quorum failure
grain, err := p.local.GetGrain(id)
if err != nil {
return nil, err
}
return grain, nil
}
// Process applies mutation(s) to a grain (local or remote).
func (p *SyncedPool) Process(id CartId, messages ...Message) (*FrameWithPayload, error) {
grain, err := p.getGrain(id)
if err != nil {
return nil, err
}
var res *FrameWithPayload
for _, m := range messages {
res, err = grain.HandleMessage(&m, false)
if err != nil {
return nil, err
}
}
return res, nil
}
// Get returns current state of a grain (local or remote).
func (p *SyncedPool) Get(id CartId) (*FrameWithPayload, error) {
grain, err := p.getGrain(id)
if err != nil {
return nil, err
}
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)
}
}

View File

@@ -1,8 +0,0 @@
/*
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