Compare commits
10 Commits
refactor/g
...
refactor/g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8c8ad56c7 | ||
|
|
09a68db8d5 | ||
|
|
30c89a0394 | ||
|
|
d6563d0b3a | ||
|
|
2a2ce247d5 | ||
|
|
159253b8b0 | ||
|
|
c30be581cd | ||
|
|
716f1121aa | ||
|
|
12d87036f6 | ||
|
|
e7c67fbb9b |
68
.dockerignore
Normal file
68
.dockerignore
Normal file
@@ -0,0 +1,68 @@
|
||||
# .dockerignore for go-cart-actor
|
||||
#
|
||||
# Goal: Keep Docker build context lean & reproducible.
|
||||
# Adjust as project structure evolves.
|
||||
|
||||
# Version control & CI metadata
|
||||
.git
|
||||
.git/
|
||||
.gitignore
|
||||
.github
|
||||
|
||||
# Local tooling / editors
|
||||
.vscode
|
||||
.idea
|
||||
*.iml
|
||||
|
||||
# Build artifacts / outputs
|
||||
bin/
|
||||
build/
|
||||
dist/
|
||||
out/
|
||||
coverage/
|
||||
*.coverprofile
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.log
|
||||
tmp/
|
||||
.tmp/
|
||||
|
||||
# Dependency/vendor caches (not used; rely on go modules download)
|
||||
vendor/
|
||||
|
||||
# Examples / scripts (adjust if you actually need them in build context)
|
||||
examples/
|
||||
scripts/
|
||||
|
||||
# Docs (retain README.md explicitly)
|
||||
docs/
|
||||
CHANGELOG*
|
||||
**/*.md
|
||||
!README.md
|
||||
|
||||
# Tests (not needed for production build)
|
||||
**/*_test.go
|
||||
|
||||
# Node / frontend artifacts (if any future addition)
|
||||
node_modules/
|
||||
|
||||
# Docker / container metadata not needed inside image
|
||||
Dockerfile
|
||||
|
||||
# Editor swap/backup files
|
||||
*~
|
||||
*.swp
|
||||
|
||||
# Go race / profiling outputs
|
||||
*.pprof
|
||||
|
||||
# Security / secret placeholders (ensure real secrets never copied)
|
||||
*.secret
|
||||
*.key
|
||||
*.pem
|
||||
|
||||
# Keep proto and generated code (do NOT ignore proto/)
|
||||
!proto/
|
||||
|
||||
# End of file
|
||||
@@ -1,30 +1,77 @@
|
||||
name: Build and Publish
|
||||
run-name: ${{ gitea.actor }} is building 🚀
|
||||
run-name: ${{ gitea.actor }} build 🚀
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
BuildAndDeployAmd64:
|
||||
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
|
||||
run: kubectl apply -f deployment/deployment.yaml -n cart
|
||||
- name: Rollout amd64 deployment
|
||||
run: kubectl rollout restart deployment/cart-actor-x86 -n cart
|
||||
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
|
||||
|
||||
BuildAndDeploy:
|
||||
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
|
||||
BuildAndDeployAmd64:
|
||||
needs: Metadata
|
||||
runs-on: amd64
|
||||
steps:
|
||||
- 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 (pin to version)
|
||||
run: |
|
||||
kubectl set image deployment/cart-actor-x86 -n cart cart-actor-amd64=registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }}
|
||||
kubectl rollout status deployment/cart-actor-x86 -n cart
|
||||
|
||||
BuildAndDeployArm64:
|
||||
needs: Metadata
|
||||
runs-on: arm64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build arm64 image
|
||||
run: |
|
||||
docker build \
|
||||
--build-arg VERSION=${{ needs.Metadata.outputs.version }} \
|
||||
--build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \
|
||||
--build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \
|
||||
--progress=plain \
|
||||
-t registry.knatofs.se/go-cart-actor:latest \
|
||||
-t registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }} \
|
||||
.
|
||||
- name: Push arm64 images
|
||||
run: |
|
||||
docker push registry.knatofs.se/go-cart-actor:latest
|
||||
docker push registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }}
|
||||
- name: Rollout arm64 deployment (pin to version)
|
||||
run: |
|
||||
kubectl set image deployment/cart-actor-arm64 -n cart cart-actor-arm64=registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }}
|
||||
kubectl rollout status deployment/cart-actor-arm64 -n cart
|
||||
|
||||
76
Dockerfile
76
Dockerfile
@@ -1,17 +1,75 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# Multi-stage build:
|
||||
# 1. Build static binary with pinned Go version (matching go.mod).
|
||||
# 2. Copy into distroless static nonroot runtime image.
|
||||
#
|
||||
# Build args (optional):
|
||||
# VERSION - semantic/app version (default: dev)
|
||||
# GIT_COMMIT - git SHA (default: unknown)
|
||||
# BUILD_DATE - RFC3339 build timestamp
|
||||
#
|
||||
# Example build:
|
||||
# docker build \
|
||||
# --build-arg VERSION=$(git describe --tags --always) \
|
||||
# --build-arg GIT_COMMIT=$(git rev-parse HEAD) \
|
||||
# --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
||||
# -t go-cart-actor:dev .
|
||||
#
|
||||
# If you add subpackages or directories, no Dockerfile change needed (COPY . .).
|
||||
# Ensure a .dockerignore exists to keep context lean.
|
||||
|
||||
FROM golang:alpine AS build-stage
|
||||
WORKDIR /app
|
||||
############################
|
||||
# Build Stage
|
||||
############################
|
||||
FROM golang:1.25-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Build metadata (can be overridden at build time)
|
||||
ARG VERSION=dev
|
||||
ARG GIT_COMMIT=unknown
|
||||
ARG BUILD_DATE=unknown
|
||||
|
||||
# Ensure reproducible static build
|
||||
# Multi-arch build args (TARGETOS/TARGETARCH provided automatically by buildx)
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ENV CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH}
|
||||
|
||||
# Dependency caching
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
COPY proto ./proto
|
||||
COPY *.go ./
|
||||
# Copy full source (relay on .dockerignore to prune)
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /go-cart-actor
|
||||
# (Optional) If you do NOT check in generated protobuf code, uncomment generation:
|
||||
# RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest && \
|
||||
# go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest && \
|
||||
# protoc --go_out=. --go_opt=paths=source_relative \
|
||||
# --go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
||||
# proto/*.proto
|
||||
|
||||
FROM gcr.io/distroless/base-debian11
|
||||
# Build with minimal binary size and embedded metadata
|
||||
RUN --mount=type=cache,target=/go/build-cache \
|
||||
go build -trimpath -ldflags="-s -w \
|
||||
-X main.Version=${VERSION} \
|
||||
-X main.GitCommit=${GIT_COMMIT} \
|
||||
-X main.BuildDate=${BUILD_DATE}" \
|
||||
-o /out/go-cart-actor .
|
||||
|
||||
############################
|
||||
# Runtime Stage
|
||||
############################
|
||||
# Using distroless static (nonroot) for minimal surface area.
|
||||
FROM gcr.io/distroless/static-debian12:nonroot AS runtime
|
||||
WORKDIR /
|
||||
|
||||
COPY --from=build-stage /go-cart-actor /go-cart-actor
|
||||
COPY --from=build /out/go-cart-actor /go-cart-actor
|
||||
|
||||
# Document (not expose forcibly) typical ports: 8080 (HTTP), 1337 (gRPC)
|
||||
EXPOSE 8080 1337
|
||||
|
||||
USER nonroot:nonroot
|
||||
ENTRYPOINT ["/go-cart-actor"]
|
||||
@@ -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. 300–400ms) 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 won’t 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.5–1.0d |
|
||||
| Total | ~6–7d |
|
||||
|
||||
---
|
||||
|
||||
## 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 2‑node 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
132
Makefile
Normal file
@@ -0,0 +1,132 @@
|
||||
# ------------------------------------------------------------------------------
|
||||
# Makefile for go-cart-actor
|
||||
#
|
||||
# Key targets:
|
||||
# make protogen - Generate protobuf + gRPC code into proto/
|
||||
# make clean_proto - Remove generated proto *.pb.go files
|
||||
# make verify_proto - Ensure no stray root-level *.pb.go files exist
|
||||
# make build - Build the project
|
||||
# make test - Run tests (verbose)
|
||||
# make tidy - Run go mod tidy
|
||||
# make regen - Clean proto, regenerate, tidy, verify, build
|
||||
# make help - Show this help
|
||||
#
|
||||
# Conventions:
|
||||
# - All .proto files live in $(PROTO_DIR)
|
||||
# - Generated Go code is emitted under $(PROTO_DIR) via go_package mapping
|
||||
# - go_package is set to: git.tornberg.me/go-cart-actor/proto;messages
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
MODULE_PATH := git.tornberg.me/go-cart-actor
|
||||
PROTO_DIR := proto
|
||||
PROTOS := $(PROTO_DIR)/messages.proto $(PROTO_DIR)/cart_actor.proto $(PROTO_DIR)/control_plane.proto
|
||||
|
||||
# Allow override: make PROTOC=/path/to/protoc
|
||||
PROTOC ?= protoc
|
||||
|
||||
# Tools (auto-detect; can override)
|
||||
PROTOC_GEN_GO ?= $(shell command -v protoc-gen-go 2>/dev/null)
|
||||
PROTOC_GEN_GO_GRPC ?= $(shell command -v protoc-gen-go-grpc 2>/dev/null)
|
||||
|
||||
GO ?= go
|
||||
|
||||
# Colors (optional)
|
||||
GREEN := \033[32m
|
||||
RED := \033[31m
|
||||
YELLOW := \033[33m
|
||||
RESET := \033[0m
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
.PHONY: protogen clean_proto verify_proto tidy build test regen help check_tools
|
||||
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " protogen Generate protobuf & gRPC code"
|
||||
@echo " clean_proto Remove generated *.pb.go files in $(PROTO_DIR)"
|
||||
@echo " verify_proto Ensure no root-level *.pb.go files (old layout)"
|
||||
|
||||
@echo " tidy Run go mod tidy"
|
||||
@echo " build Build the module"
|
||||
@echo " test Run tests (verbose)"
|
||||
@echo " regen Clean proto, regenerate, tidy, verify, and build"
|
||||
@echo " check_tools Verify protoc + plugins are installed"
|
||||
|
||||
check_tools:
|
||||
@if [ -z "$(PROTOC_GEN_GO)" ] || [ -z "$(PROTOC_GEN_GO_GRPC)" ]; then \
|
||||
echo "$(RED)Missing protoc-gen-go or protoc-gen-go-grpc in PATH.$(RESET)"; \
|
||||
echo "Install with:"; \
|
||||
echo " go install google.golang.org/protobuf/cmd/protoc-gen-go@latest"; \
|
||||
echo " go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if ! command -v "$(PROTOC)" >/dev/null 2>&1; then \
|
||||
echo "$(RED)protoc not found. Install protoc (e.g. via package manager)$(RESET)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "$(GREEN)All required tools detected.$(RESET)"
|
||||
|
||||
protogen: check_tools
|
||||
@echo "$(YELLOW)Generating protobuf code (outputs -> ./proto)...$(RESET)"
|
||||
$(PROTOC) -I $(PROTO_DIR) \
|
||||
--go_out=./proto --go_opt=paths=source_relative \
|
||||
--go-grpc_out=./proto --go-grpc_opt=paths=source_relative \
|
||||
$(PROTOS)
|
||||
@echo "$(GREEN)Protobuf generation complete.$(RESET)"
|
||||
|
||||
clean_proto:
|
||||
@echo "$(YELLOW)Removing generated protobuf files...$(RESET)"
|
||||
@rm -f $(PROTO_DIR)/*_grpc.pb.go $(PROTO_DIR)/*.pb.go
|
||||
@rm -f *.pb.go
|
||||
@rm -rf git.tornberg.me
|
||||
@echo "$(GREEN)Clean complete.$(RESET)"
|
||||
|
||||
verify_proto:
|
||||
@echo "$(YELLOW)Verifying proto layout...$(RESET)"
|
||||
@if ls *.pb.go >/dev/null 2>&1; then \
|
||||
echo "$(RED)ERROR: Found root-level generated *.pb.go files (should be only under $(PROTO_DIR)/).$(RESET)"; \
|
||||
ls -1 *.pb.go; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "$(GREEN)Proto layout OK (no root-level *.pb.go files).$(RESET)"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
tidy:
|
||||
@echo "$(YELLOW)Running go mod tidy...$(RESET)"
|
||||
$(GO) mod tidy
|
||||
@echo "$(GREEN)tidy complete.$(RESET)"
|
||||
|
||||
build:
|
||||
@echo "$(YELLOW)Building...$(RESET)"
|
||||
$(GO) build ./...
|
||||
@echo "$(GREEN)Build success.$(RESET)"
|
||||
|
||||
test:
|
||||
@echo "$(YELLOW)Running tests...$(RESET)"
|
||||
$(GO) test -v ./...
|
||||
@echo "$(GREEN)Tests completed.$(RESET)"
|
||||
|
||||
regen: clean_proto protogen tidy verify_proto build
|
||||
@echo "$(GREEN)Full regenerate cycle complete.$(RESET)"
|
||||
|
||||
# Utility: show proto sources and generated outputs
|
||||
print_proto:
|
||||
@echo "Proto sources:"
|
||||
@ls -1 $(PROTOS)
|
||||
@echo ""
|
||||
@echo "Generated files (if any):"
|
||||
@ls -1 $(PROTO_DIR)/*pb.go 2>/dev/null || echo "(none)"
|
||||
|
||||
# Prevent make from treating these as file targets if similarly named files appear.
|
||||
.SILENT: help check_tools protogen clean_proto verify_proto tidy build test regen print_proto
|
||||
266
README.md
266
README.md
@@ -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 in‑memory struct `*CartGrain`) that owns and mutates its own state.
|
||||
- A **local grain pool** holds grains owned by the node.
|
||||
- A **synced (cluster) pool** (`SyncedPool`) coordinates multiple nodes and exposes local or remote grains through a uniform interface (`GrainPool`).
|
||||
- All inter‑node communication is gRPC:
|
||||
- Cart mutation & state RPCs (CartActor service).
|
||||
- Control plane RPCs (ControlPlane service) for membership, ownership negotiation, liveness, and graceful shutdown.
|
||||
|
||||
### Key Processes
|
||||
|
||||
1. Client HTTP request (or gRPC client) arrives with a cart identifier (cookie or path).
|
||||
2. The pool resolves ownership:
|
||||
- If local grain exists → use it.
|
||||
- If a remote host is known owner → a remote grain proxy (`RemoteGrainGRPC`) is used; it performs gRPC calls to the owning node.
|
||||
- If ownership is unknown → node attempts to claim ownership (quorum negotiation) and spawns a local grain.
|
||||
3. Mutation is executed via the **mutation registry** (registry wraps domain logic + optional totals recomputation).
|
||||
4. Updated state returned to caller; ownership preserved unless relinquished later (not yet implemented to shed load).
|
||||
|
||||
---
|
||||
|
||||
## Grain & Mutation Model
|
||||
|
||||
- `CartGrain` holds items, deliveries, pricing aggregates, and checkout/order metadata.
|
||||
- All mutations are registered via `RegisterMutation[T]` with signature:
|
||||
```
|
||||
func(*CartGrain, *T) error
|
||||
```
|
||||
- `WithTotals()` flag triggers automatic recalculation of totals after successful handlers.
|
||||
- The old giant `switch` in `CartGrain.Apply` has been replaced by registry dispatch; unregistered mutations fail fast.
|
||||
- Adding a mutation:
|
||||
1. Define proto message.
|
||||
2. Generate code.
|
||||
3. Register handler (optionally WithTotals).
|
||||
4. Add gRPC RPC + request wrapper if the mutation must be remotely invokable.
|
||||
5. (Optional) Add HTTP endpoint mapping to the mutation.
|
||||
|
||||
---
|
||||
|
||||
## Local Grain Pool
|
||||
|
||||
- Manages an in‑memory map `map[CartId]*CartGrain`.
|
||||
- Lazy spawn: first mutation or explicit access triggers `spawn(id)`.
|
||||
- TTL / purge loop periodically removes expired grains unless they changed recently (basic memory pressure management).
|
||||
- Capacity limit (`PoolSize`); oldest expired grain evicted first when full.
|
||||
|
||||
---
|
||||
|
||||
## Synced (Cluster) Pool
|
||||
|
||||
`SyncedPool` wraps a local pool and tracks:
|
||||
|
||||
- `remoteHosts`: known peer nodes (gRPC connections).
|
||||
- `remoteIndex`: mapping of cart id → remote grain proxy (`RemoteGrainGRPC`) for carts owned elsewhere.
|
||||
|
||||
Responsibilities:
|
||||
|
||||
1. Discovery integration (via a `Discovery` interface) adds/removes hosts.
|
||||
2. Periodic ping health checks (ControlPlane.Ping).
|
||||
3. Ring-based deterministic ownership:
|
||||
- Ownership is derived directly from the consistent hashing ring (no quorum RPC or `ConfirmOwner`).
|
||||
4. Remote spawning:
|
||||
- When a remote host reports its cart ids (`GetCartIds`), the pool creates remote proxies for fast routing.
|
||||
|
||||
---
|
||||
|
||||
## Remote Grain Proxies
|
||||
|
||||
A `RemoteGrainGRPC` implements the `Grain` interface but delegates:
|
||||
|
||||
- `Apply` → Specific CartActor per‑mutation RPC (e.g., `AddItem`, `RemoveItem`) constructed from the mutation type. (Legacy envelope removed.)
|
||||
- `GetCurrentState` → `CartActor.GetState`.
|
||||
|
||||
Return path:
|
||||
|
||||
1. gRPC reply (CartMutationReply / StateReply) → proto `CartState`.
|
||||
2. `ToCartState` / mapping reconstructs a local `CartGrain` snapshot for callers expecting grain semantics.
|
||||
|
||||
---
|
||||
|
||||
## Control Plane (Inter‑Node Coordination)
|
||||
|
||||
Defined in `proto/control_plane.proto`:
|
||||
|
||||
| RPC | Purpose |
|
||||
|-----|---------|
|
||||
| `Ping` | Liveness; increments missed ping counter if failing. |
|
||||
| `Negotiate` | Merges membership views; used after discovery events. |
|
||||
| `GetCartIds` | Enumerate locally owned carts for remote index seeding. |
|
||||
| `Closing` | Graceful shutdown notice; peers remove host & associated remote grains. |
|
||||
|
||||
### Ownership / Quorum Rules
|
||||
|
||||
- If total participating hosts < 3 → all must accept.
|
||||
- Otherwise majority acceptance (`ok >= total/2`).
|
||||
- On failure → local tentative grain is removed (rollback to avoid split‑brain).
|
||||
|
||||
---
|
||||
|
||||
## Request / Mutation Flow Examples
|
||||
|
||||
### Local Mutation
|
||||
1. HTTP handler parses request → determines cart id.
|
||||
2. `SyncedPool.Apply`:
|
||||
- Finds local grain (or spawns new after quorum).
|
||||
- Executes registry mutation.
|
||||
3. Totals updated if flagged.
|
||||
4. HTTP response returns updated JSON (via `ToCartState`).
|
||||
|
||||
### Remote Mutation
|
||||
1. `SyncedPool.Apply` sees cart mapped to a remote host.
|
||||
2. Routes to `RemoteGrainGRPC.Apply`.
|
||||
3. Remote node executes mutation locally and returns updated state over gRPC.
|
||||
4. Proxy materializes snapshot locally (not authoritative, read‑only view).
|
||||
|
||||
### Checkout (Side‑Effecting, Non-Pure)
|
||||
- HTTP `/checkout` uses current grain snapshot to build payload (pure function).
|
||||
- Calls Klarna externally (not a mutation).
|
||||
- Applies `InitializeCheckout` mutation to persist reference + status.
|
||||
- Returns Klarna order JSON to client.
|
||||
|
||||
---
|
||||
|
||||
## Scaling & Deployment
|
||||
|
||||
- **Horizontal scaling**: Add more nodes; discovery layer (Kubernetes / service registry) feeds hosts to `SyncedPool`.
|
||||
- **Sharding**: Implicit by cart id hash. Ownership is first-claim with quorum acceptance.
|
||||
- **Hot spots**: A single popular cart remains on one node; for heavy multi-client concurrency, future work could add read replicas or partitioning (not implemented).
|
||||
- **Capacity tuning**: Increase `PoolSize` & memory limits; adjust TTL for stale cart eviction.
|
||||
|
||||
### Adding Nodes
|
||||
1. Node starts gRPC server (CartActor + ControlPlane).
|
||||
2. After brief delay, begins discovery watch; on event:
|
||||
- New host → dial + negotiate → seed remote cart ids.
|
||||
3. Pings maintain health; failed hosts removed (proxies invalidated).
|
||||
|
||||
---
|
||||
|
||||
## Failure Handling
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| Remote host unreachable | Pings increment `MissedPings`; after threshold host removed. |
|
||||
| Ownership negotiation fails | Tentative local grain discarded. |
|
||||
| gRPC call error on remote mutation | Error bubbled to caller; no local fallback. |
|
||||
| Missing mutation registration | Fast failure with explicit error message. |
|
||||
| Partial checkout (Klarna fails) | No local state mutation for checkout; client sees error; cart remains unchanged. |
|
||||
|
||||
---
|
||||
|
||||
## Mutation Registry Summary
|
||||
|
||||
- Central, type-safe registry prevents silent omission.
|
||||
- Each handler:
|
||||
- Validates input.
|
||||
- Mutates `*CartGrain`.
|
||||
- Returns error for rejection.
|
||||
- Automatic totals recomputation reduces boilerplate and consistency risk.
|
||||
- Coverage test (add separately) can enforce all proto mutations are registered.
|
||||
|
||||
---
|
||||
|
||||
## gRPC Interfaces
|
||||
|
||||
- **CartActor**: Per-mutation unary RPCs + `GetState`. (Checkout logic intentionally excluded; handled at HTTP layer.)
|
||||
- **ControlPlane**: Cluster coordination (Ping, Negotiate, GetCartIds, Closing) — ownership now ring-determined (no ConfirmOwner).
|
||||
|
||||
**Ports** (default / implied):
|
||||
- CartActor & ControlPlane share the same gRPC server/listener (single port, e.g. `:1337`).
|
||||
- Legacy frame/TCP code has been removed.
|
||||
|
||||
---
|
||||
|
||||
## Security & Future Enhancements
|
||||
|
||||
| Area | Potential Improvement |
|
||||
|------|------------------------|
|
||||
| Transport Security | Add TLS / mTLS to gRPC servers & clients. |
|
||||
| Auth / RBAC | Intercept CartActor RPCs with auth metadata. |
|
||||
| Backpressure | Rate-limit remote mutation calls per host. |
|
||||
| Observability | Add per-mutation Prometheus metrics & tracing spans. |
|
||||
| Ownership | Add lease timeouts / fencing tokens for stricter guarantees. |
|
||||
| Batch Ops | Introduce batch mutation RPC or streaming updates (WatchState). |
|
||||
| Persistence | Reintroduce event log or snapshot persistence layer if durability required. |
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Node (Operational Checklist)
|
||||
|
||||
1. Deploy binary/container with same proto + registry.
|
||||
2. Expose gRPC port.
|
||||
3. Ensure discovery lists the new host.
|
||||
4. Node dials peers, negotiates membership.
|
||||
5. Remote cart proxies seeded.
|
||||
6. Traffic routed automatically based on ownership.
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Mutation (Checklist Recap)
|
||||
|
||||
1. Define proto message (+ request wrapper & RPC if remote invocation needed).
|
||||
2. Regenerate protobuf code.
|
||||
3. Implement & register handler (`RegisterMutation`).
|
||||
4. Add client (HTTP/gRPC) endpoint.
|
||||
5. Write unit + integration tests.
|
||||
6. (Optional) Add to coverage test list and docs.
|
||||
|
||||
---
|
||||
|
||||
## High-Level Data Flow Diagram (Text)
|
||||
|
||||
```
|
||||
Client -> HTTP Handler -> SyncedPool -> (local?) -> Registry -> Grain State
|
||||
\-> (remote?) -> RemoteGrainGRPC -> gRPC -> Remote CartActor -> Registry -> Grain
|
||||
ControlPlane: Discovery Events <-> Negotiation/Ping <-> SyncedPool state (ring determines ownership)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely Cause | Action |
|
||||
|---------|--------------|--------|
|
||||
| New cart every request | Secure cookie over plain HTTP or not sending cookie jar | Disable Secure locally or use HTTPS & proper curl `-b` |
|
||||
| Unsupported mutation error | Missing registry handler | Add `RegisterMutation` for that proto |
|
||||
| Ownership imbalance | Ring host distribution skew or rapid host churn | Examine `cart_ring_host_share`, `cart_ring_hosts`, and logs for host add/remove; rebalance or investigate instability |
|
||||
| Remote mutation latency | Network / serialization overhead | Consider batching or colocating hot carts |
|
||||
| Checkout returns 500 | Klarna call failed | Inspect logs; no grain state mutated |
|
||||
|
||||
---
|
||||
|
||||
245
TODO.md
Normal file
245
TODO.md
Normal file
@@ -0,0 +1,245 @@
|
||||
# TODO / Roadmap
|
||||
|
||||
A living roadmap for improving the cart actor system. Focus areas:
|
||||
1. Reliability & correctness
|
||||
2. Simplicity of mutation & ownership flows
|
||||
3. Developer experience (DX)
|
||||
4. Operability (observability, tracing, metrics)
|
||||
5. Performance & scalability
|
||||
6. Security & multi-tenant readiness
|
||||
|
||||
---
|
||||
|
||||
## 1. Immediate Next Steps (High-Leverage)
|
||||
|
||||
| Priority | Task | Goal | Effort | Owner | Notes |
|
||||
|----------|------|------|--------|-------|-------|
|
||||
| P0 | Add mutation registry coverage test | Ensure no unregistered mutations silently fail | S | | Failing fast in CI |
|
||||
| P0 | Add decodeJSON helper + 400 mapping for EOF | Reduce noisy 500 logs | S | | Improves client API clarity |
|
||||
| P0 | Regenerate protos & prune unused messages (CreateCheckoutOrder, Checkout RPC remnants) | Eliminate dead types | S | | Avoid confusion |
|
||||
| P0 | Add integration test: multi-node ownership negotiation | Validate quorum logic | M | | Spin up 2–3 nodes ephemeral |
|
||||
| P1 | Export Prometheus metrics for per-mutation counts & latency | Operability | M | | Wrap registry handlers |
|
||||
| P1 | Add graceful shutdown ordering (Closing → wait for acks → stop gRPC) | Reduce in-flight mutation failures | S | | Add context cancellation |
|
||||
| P1 | Add coverage for InitializeCheckout / OrderCreated flows | Checkout reliability | S | | Simulate Klarna stub |
|
||||
| P2 | Add optional batching client (apply multiple mutations locally then persist) | Performance | M | | Only if needed |
|
||||
|
||||
---
|
||||
|
||||
## 2. Simplification Opportunities
|
||||
|
||||
### A. RemoteGrain Proxy Mapping
|
||||
Current: manual switch building each RPC call.
|
||||
Simplify by:
|
||||
- Generating a thin client adapter from proto RPC descriptors (codegen).
|
||||
- Or using a registry similar to mutation registry but for “outbound call constructors”.
|
||||
Benefit: adding a new mutation = add proto + register server handler + register outbound invoker (no switch edits).
|
||||
|
||||
### B. Ownership Negotiation
|
||||
Current: ad hoc quorum rule in `SyncedPool`.
|
||||
Simplify:
|
||||
- Introduce explicit `OwnershipLease{holder, expiresAt, version}`.
|
||||
- Use monotonic version increment—reject stale ConfirmOwner replies.
|
||||
- Optional: add randomized backoff to reduce thundering herd on contested cart ids.
|
||||
|
||||
### C. CartId Handling
|
||||
Current: ephemeral 16-byte array with trimmed string semantics.
|
||||
Simplify:
|
||||
- Use ULID / UUIDv7 (time-ordered, collision-resistant) for easier external correlation.
|
||||
- Provide helper `NewCartIdString()` and keep internal fixed-size if still desired.
|
||||
|
||||
### D. Mutation Signatures
|
||||
Current: registry assumes `func(*CartGrain, *T) error`.
|
||||
Extension option: allow pure transforms returning a delta struct (for audit/logging):
|
||||
```
|
||||
type MutationResult struct {
|
||||
Changed bool
|
||||
Events []interface{}
|
||||
}
|
||||
```
|
||||
Only implement if auditing/event-sourcing reintroduced.
|
||||
|
||||
---
|
||||
|
||||
## 3. Developer Experience Improvements
|
||||
|
||||
| Task | Rationale | Approach |
|
||||
|------|-----------|----------|
|
||||
| Makefile targets: `make run-single`, `make run-multi N=3` | Faster local cluster spin-up | Docker compose or background “mini cluster” scripts |
|
||||
| Template for new mutation (generator) | Reduce boilerplate | `go:generate` scanning proto for new RPCs |
|
||||
| Lint config (golangci-lint) | Catch subtle issues early | Add `.golangci.yml` |
|
||||
| Pre-commit hook for proto regeneration check | Avoid stale generated code | Script compares git diff after `make protogen` |
|
||||
| Example client (Go + curl snippets auto-generated) | Onboarding | Codegen a markdown from proto comments |
|
||||
|
||||
---
|
||||
|
||||
## 4. Observability / Metrics / Tracing
|
||||
|
||||
| Area | Metric / Trace | Notes |
|
||||
|------|----------------|-------|
|
||||
| Mutation registry | `cart_mutations_total{type,success}`; duration histogram | Wrap handler |
|
||||
| Ownership negotiation | `cart_ownership_attempts_total{result}` | result=accepted,rejected,timeout |
|
||||
| Remote latency | `cart_remote_mutation_seconds{method}` | Use client interceptors |
|
||||
| Pings | `cart_remote_missed_pings_total{host}` | Already count, expose |
|
||||
| Checkout flow | `checkout_attempts_total`, `checkout_failures_total` | Differentiate Klarna vs internal errors |
|
||||
| Tracing | Span: HTTP handler → SyncedPool.Apply → (Remote?) gRPC → mutation handler | Add OpenTelemetry instrumentation |
|
||||
|
||||
---
|
||||
|
||||
## 5. Performance & Scalability
|
||||
|
||||
| Concern | Idea | Trade-Off |
|
||||
|---------|------|-----------|
|
||||
| High mutation rate on single cart | Introduce optional mutation queue (serialize explicitly) | Slight latency increase per op |
|
||||
| Remote call overhead | Add client-side gRPC pooling & per-host circuit breaker | Complexity vs resilience |
|
||||
| TTL purge efficiency | Use min-heap or timing wheel instead of slice scan | More code, better big-N performance |
|
||||
| Batch network latency | Add `BatchMutate` RPC (list of mutations applied atomically) | Lost single-op simplicity |
|
||||
|
||||
---
|
||||
|
||||
## 6. Reliability Features
|
||||
|
||||
| Feature | Description | Priority |
|
||||
|---------|-------------|----------|
|
||||
| Lease fencing token | Include `ownership_version` in all remote mutate requests | M |
|
||||
| Retry policy | Limited retry for transient network errors (idempotent mutations only) | L |
|
||||
| Dead host reconciliation | On host removal, proactively attempt re-acquire of its carts | M |
|
||||
| Drain mode | Node marks itself “draining” → refuses new ownership claims | M |
|
||||
|
||||
---
|
||||
|
||||
## 7. Security & Hardening
|
||||
|
||||
| Area | Next Step | Detail |
|
||||
|------|-----------|--------|
|
||||
| Transport | mTLS on gRPC | Use SPIFFE IDs or simple CA |
|
||||
| AuthN/AuthZ | Interceptor enforcing service token | Inject metadata header |
|
||||
| Input validation | Strengthen JSON decode responses | Disallow unknown fields globally |
|
||||
| Rate limiting | Per-IP / per-cart throttling | Guard hotspot abuse |
|
||||
| Multi-tenancy | Tenant id dimension in cart id or metadata | Partition metrics & ownership |
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing Strategy Enhancements
|
||||
|
||||
| Gap | Improvement |
|
||||
|-----|------------|
|
||||
| No multi-node integration test in CI | Spin ephemeral in-process servers on randomized ports |
|
||||
| Mutation regression | Table-driven tests auto-discover handlers via registry |
|
||||
| Ownership race | Stress test: concurrent Apply on same new cart id from N goroutines |
|
||||
| Checkout external dependency | Klarna mock server (HTTptest) + deterministic responses |
|
||||
| Fuzzing | Fuzz `BuildCheckoutOrderPayload` & mutation handlers for panics |
|
||||
|
||||
---
|
||||
|
||||
## 9. Cleanup / Tech Debt
|
||||
|
||||
| Item | Action |
|
||||
|------|--------|
|
||||
| Remove deprecated proto remnants (CreateCheckoutOrder, Checkout RPC) | Delete & regenerate |
|
||||
| Consolidate duplicate tax computations | Single helper with tax config |
|
||||
| Delivery price hard-coded (4900) | Config or pricing strategy interface |
|
||||
| Mixed naming (camel vs snake JSON historically) | Provide stable external API doc; accept old forms if needed |
|
||||
| Manual remote mutation switch (if still present) | Replace with generated outbound registry |
|
||||
| Mixed error responses (string bodies) | Standardize JSON: `{ "error": "...", "code": 400 }` |
|
||||
|
||||
---
|
||||
|
||||
## 10. Potential Future Features
|
||||
|
||||
| Feature | Value | Complexity |
|
||||
|---------|-------|------------|
|
||||
| Streaming `WatchState` RPC | Real-time cart updates for clients | Medium |
|
||||
| Event sourcing / audit log | Replay, analytics, debugging | High |
|
||||
| Promotion / coupon engine plugin | Business extensibility | Medium |
|
||||
| Partial cart reservation / inventory lock | Stock accuracy under concurrency | High |
|
||||
| Multi-currency pricing | Globalization | Medium |
|
||||
| GraphQL facade | Client flexibility | Medium |
|
||||
|
||||
---
|
||||
|
||||
## 11. Suggested Prioritized Backlog (Condensed)
|
||||
|
||||
1. Coverage test + decode error mapping (P0)
|
||||
2. Proto regeneration & cleanup (P0)
|
||||
3. Metrics wrapper for registry (P1)
|
||||
4. Multi-node ownership integration test (P1)
|
||||
5. Delivery pricing abstraction (P2)
|
||||
6. Lease version in remote RPCs (P2)
|
||||
7. BatchMutate evaluation (P3)
|
||||
8. TLS / auth hardening (P3) if going multi-tenant/public
|
||||
9. Event sourcing (Evaluate after stability) (P4)
|
||||
|
||||
---
|
||||
|
||||
## 12. Simplifying the Developer Workflow
|
||||
|
||||
| Pain | Simplifier |
|
||||
|------|------------|
|
||||
| Manual mutation boilerplate | Code generator for registry stubs |
|
||||
| Forgetting totals | Enforce WithTotals lint: fail if mutation touches items/deliveries without flag |
|
||||
| Hard to inspect remote ownership | `/internal/ownership` debug endpoint (JSON of local + remoteIndex) |
|
||||
| Hard to see mutation timings | Add `?debug=latency` header to return per-mutation durations |
|
||||
| Cookie dev confusion (Secure flag) | Env var: `DEV_INSECURE_COOKIES=1` |
|
||||
|
||||
---
|
||||
|
||||
## 13. Example: Mutation Codegen Sketch (Future)
|
||||
|
||||
Input: cart_actor.proto
|
||||
Output: `mutation_auto.go`
|
||||
- Detect messages used in RPC wrappers (e.g., `AddItemRequest` → payload field).
|
||||
- Generate `RegisterMutation` template if handler not found.
|
||||
- Mark with `// TODO implement logic`.
|
||||
|
||||
---
|
||||
|
||||
## 14. Risk / Impact Matrix (Abbreviated)
|
||||
|
||||
| Change | Risk | Mitigation |
|
||||
|--------|------|-----------|
|
||||
| Replace remote switch with registry | Possible missing registration → runtime error | Coverage test gating CI |
|
||||
| Lease introduction | Split-brain if version mishandled | Increment + assert monotonic; test race |
|
||||
| BatchMutate | Large atomic operations starving others | Size limits & fair scheduling |
|
||||
| Event sourcing | Storage + replay complexity | Start with append-only log + compaction job |
|
||||
|
||||
---
|
||||
|
||||
## 15. Contributing Workflow (Proposed)
|
||||
|
||||
1. Add / modify proto → run `make protogen`
|
||||
2. Implement mutation logic → add `RegisterMutation` invocation
|
||||
3. Add/Update tests (unit + integration)
|
||||
4. Run `make verify` (lint, test, coverage, proto diff)
|
||||
5. Open PR (template auto-checklist referencing this TODO)
|
||||
6. Merge requires green CI + coverage threshold
|
||||
|
||||
---
|
||||
|
||||
## 16. Open Questions
|
||||
|
||||
| Question | Notes |
|
||||
|----------|-------|
|
||||
| Do we need sticky sessions for HTTP layer scaling? | Currently cart id routing suffices |
|
||||
| Should deliveries prune invalid line references on SetCartRequest? | Inconsistency risk; add optional cleanup |
|
||||
| Is checkout idempotency strict enough? | Multiple create vs update semantics |
|
||||
| Add version field to CartState for optimistic concurrency? | Could enable external CAS writes |
|
||||
|
||||
---
|
||||
|
||||
## 17. Tracking
|
||||
|
||||
Mark any completed tasks with `[x]`:
|
||||
|
||||
- [ ] Coverage test
|
||||
- [ ] Decode helper + 400 mapping
|
||||
- [ ] Proto cleanup
|
||||
- [ ] Registry metrics instrumentation
|
||||
- [ ] Ownership multi-node test
|
||||
- [ ] Lease versioning
|
||||
- [ ] Delivery pricing abstraction
|
||||
- [ ] TLS/mTLS internal
|
||||
- [ ] BatchMutate design doc
|
||||
|
||||
---
|
||||
|
||||
_Last updated: roadmap draft – refine after first metrics & scaling test run._
|
||||
@@ -1,83 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
type AmqpOrderHandler struct {
|
||||
Url string
|
||||
connection *amqp.Connection
|
||||
//channel *amqp.Channel
|
||||
Connection *amqp.Connection
|
||||
Channel *amqp.Channel
|
||||
}
|
||||
|
||||
const (
|
||||
topic = "order-placed"
|
||||
)
|
||||
|
||||
func (t *AmqpOrderHandler) Connect() error {
|
||||
|
||||
conn, err := amqp.DialConfig(t.Url, amqp.Config{
|
||||
//Vhost: "/",
|
||||
Properties: amqp.NewConnectionProperties(),
|
||||
})
|
||||
|
||||
func (h *AmqpOrderHandler) Connect() error {
|
||||
conn, err := amqp.Dial(h.Url)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to connect to RabbitMQ: %w", err)
|
||||
}
|
||||
t.connection = conn
|
||||
h.Connection = conn
|
||||
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer ch.Close()
|
||||
if err := ch.ExchangeDeclare(
|
||||
topic, // name
|
||||
"topic", // type
|
||||
true, // durable
|
||||
false, // auto-delete
|
||||
false, // internal
|
||||
false, // noWait
|
||||
nil, // arguments
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = ch.QueueDeclare(
|
||||
topic, // name of the queue
|
||||
true, // durable
|
||||
false, // delete when unused
|
||||
false, // exclusive
|
||||
false, // noWait
|
||||
nil, // arguments
|
||||
); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to open a channel: %w", err)
|
||||
}
|
||||
h.Channel = ch
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *AmqpOrderHandler) Close() error {
|
||||
log.Println("Closing master channel")
|
||||
return t.connection.Close()
|
||||
//return t.channel.Close()
|
||||
func (h *AmqpOrderHandler) Close() error {
|
||||
if h.Channel != nil {
|
||||
h.Channel.Close()
|
||||
}
|
||||
if h.Connection != nil {
|
||||
return h.Connection.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *AmqpOrderHandler) OrderCompleted(data []byte) error {
|
||||
ch, err := t.connection.Channel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer ch.Close()
|
||||
return ch.Publish(
|
||||
topic,
|
||||
topic,
|
||||
true,
|
||||
false,
|
||||
func (h *AmqpOrderHandler) OrderCompleted(body []byte) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := h.Channel.PublishWithContext(ctx,
|
||||
"orders", // exchange
|
||||
"new", // routing key
|
||||
false, // mandatory
|
||||
false, // immediate
|
||||
amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
Body: data,
|
||||
},
|
||||
)
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to publish a message: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
367
cart-grain.go
367
cart-grain.go
@@ -1,13 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
@@ -93,7 +89,6 @@ type CartGrain struct {
|
||||
mu sync.RWMutex
|
||||
lastItemId int
|
||||
lastDeliveryId int
|
||||
storageMessages []Message
|
||||
Id CartId `json:"id"`
|
||||
Items []*CartItem `json:"items"`
|
||||
TotalPrice int64 `json:"totalPrice"`
|
||||
@@ -108,8 +103,8 @@ type CartGrain struct {
|
||||
|
||||
type Grain interface {
|
||||
GetId() CartId
|
||||
HandleMessage(message *Message, isReplay bool) (*FrameWithPayload, error)
|
||||
GetCurrentState() (*FrameWithPayload, error)
|
||||
Apply(content interface{}, isReplay bool) (*CartGrain, error)
|
||||
GetCurrentState() (*CartGrain, error)
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetId() CartId {
|
||||
@@ -117,20 +112,12 @@ func (c *CartGrain) GetId() CartId {
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetLastChange() int64 {
|
||||
if len(c.storageMessages) == 0 {
|
||||
return 0
|
||||
}
|
||||
return *c.storageMessages[len(c.storageMessages)-1].TimeStamp
|
||||
// Legacy event log removed; return 0 to indicate no persisted mutation history.
|
||||
return 0
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetCurrentState() (*FrameWithPayload, error) {
|
||||
result, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
ret := MakeFrameWithPayload(0, 400, []byte(err.Error()))
|
||||
return &ret, nil
|
||||
}
|
||||
ret := MakeFrameWithPayload(0, 200, result)
|
||||
return &ret, nil
|
||||
func (c *CartGrain) GetCurrentState() (*CartGrain, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func getInt(data float64, ok bool) (int, error) {
|
||||
@@ -201,30 +188,23 @@ func getItemData(sku string, qty int, country string) (*messages.AddItem, error)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *CartGrain) AddItem(sku string, qty int, country string, storeId *string) (*FrameWithPayload, error) {
|
||||
func (c *CartGrain) AddItem(sku string, qty int, country string, storeId *string) (*CartGrain, error) {
|
||||
cartItem, err := getItemData(sku, qty, country)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cartItem.StoreId = storeId
|
||||
return c.HandleMessage(&Message{
|
||||
Type: 2,
|
||||
Content: cartItem,
|
||||
}, false)
|
||||
return c.Apply(cartItem, false)
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetStorageMessage(since int64) []StorableMessage {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
ret := make([]StorableMessage, 0)
|
||||
/*
|
||||
Legacy storage (event sourcing) removed in oneof refactor.
|
||||
Kept stub (commented) for potential future reintroduction using proto envelopes.
|
||||
|
||||
for _, message := range c.storageMessages {
|
||||
if *message.TimeStamp > since {
|
||||
ret = append(ret, message)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
func (c *CartGrain) GetStorageMessage(since int64) []interface{} {
|
||||
return nil
|
||||
}
|
||||
*/
|
||||
|
||||
func (c *CartGrain) GetState() ([]byte, error) {
|
||||
return json.Marshal(c)
|
||||
@@ -279,324 +259,17 @@ 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()
|
||||
}
|
||||
result, err := json.Marshal(c)
|
||||
msg := MakeFrameWithPayload(RemoteHandleMutationReply, 200, result)
|
||||
return &msg, err
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (c *CartGrain) UpdateTotals() {
|
||||
|
||||
327
cart_id.go
Normal file
327
cart_id.go
Normal file
@@ -0,0 +1,327 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// cart_id.go
|
||||
//
|
||||
// Compact CartID implementation using 64 bits of cryptographic randomness,
|
||||
// base62 encoded (0-9 A-Z a-z). Typical length is 11 characters (since 62^11 > 2^64).
|
||||
//
|
||||
// Motivation:
|
||||
// * Shorter identifiers for cookies / URLs than legacy padded 16-byte CartId
|
||||
// * O(1) hashing (raw uint64) for consistent hashing ring integration
|
||||
// * Extremely low collision probability (birthday bound negligible at scale)
|
||||
//
|
||||
// Backward Compatibility Strategy (Phased):
|
||||
// Phase 1: Introduce CartID helpers while continuing to accept legacy CartId.
|
||||
// Phase 2: Internally migrate maps to key by uint64 (CartID.Raw()).
|
||||
// Phase 3: Canonicalize all inbound IDs to short base62; reissue Set-Cart-Id header.
|
||||
//
|
||||
// NOTE:
|
||||
// The legacy type `CartId [16]byte` is still present elsewhere; helper
|
||||
// UpgradeLegacyCartId bridges that representation to the new form without
|
||||
// breaking deterministic mapping for existing carts.
|
||||
//
|
||||
// Security / Predictability:
|
||||
// Uses crypto/rand for generation. If ever required, you can layer an
|
||||
// HMAC-based derivation for additional secrecy. Current approach already
|
||||
// provides 64 bits of entropy (brute force infeasible for practical risk).
|
||||
//
|
||||
// Future Extensions:
|
||||
// * Time-sortable IDs: prepend a 48-bit timestamp field and encode 80 bits.
|
||||
// * Add metrics counters for: generated_new, parsed_existing, legacy_fallback.
|
||||
// * Add a pool of pre-generated IDs for ultra-low-latency hot paths (rarely needed).
|
||||
//
|
||||
// Public Surface Summary:
|
||||
// NewCartID() (CartID, error)
|
||||
// ParseCartID(string) (CartID, bool)
|
||||
// FallbackFromString(string) CartID
|
||||
// UpgradeLegacyCartId(CartId) CartID
|
||||
// CanonicalizeIncoming(string) (CartID, bool /*wasGenerated*/, error)
|
||||
//
|
||||
// Encoding Details:
|
||||
// encodeBase62 / decodeBase62 maintain a stable alphabet. DO NOT change
|
||||
// alphabet order once IDs are in circulation, or previously issued IDs
|
||||
// will change meaning.
|
||||
//
|
||||
// Zero Values:
|
||||
// The zero value CartID{} has raw=0, txt="0". Treat it as valid but
|
||||
// usually you will call NewCartID instead.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
// Precomputed reverse lookup table for decode (255 = invalid).
|
||||
var base62Rev [256]byte
|
||||
|
||||
func init() {
|
||||
for i := range base62Rev {
|
||||
base62Rev[i] = 0xFF
|
||||
}
|
||||
for i := 0; i < len(base62Alphabet); i++ {
|
||||
base62Rev[base62Alphabet[i]] = byte(i)
|
||||
}
|
||||
}
|
||||
|
||||
// CartID is the compact representation of a cart identifier.
|
||||
// raw: 64-bit entropy (also used directly for consistent hashing).
|
||||
// txt: cached base62 textual form.
|
||||
type CartID struct {
|
||||
raw uint64
|
||||
txt string
|
||||
}
|
||||
|
||||
// String returns the canonical base62 encoded ID.
|
||||
func (c CartID) String() string {
|
||||
if c.txt == "" { // lazily encode if constructed manually
|
||||
c.txt = encodeBase62(c.raw)
|
||||
}
|
||||
return c.txt
|
||||
}
|
||||
|
||||
// Raw returns the 64-bit numeric value (useful for hashing / ring lookup).
|
||||
func (c CartID) Raw() uint64 {
|
||||
return c.raw
|
||||
}
|
||||
|
||||
// IsZero reports whether this CartID is the zero value.
|
||||
func (c CartID) IsZero() bool {
|
||||
return c.raw == 0
|
||||
}
|
||||
|
||||
// NewCartID generates a new cryptographically random 64-bit ID.
|
||||
func NewCartID() (CartID, error) {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return CartID{}, fmt.Errorf("NewCartID: %w", err)
|
||||
}
|
||||
u := binary.BigEndian.Uint64(b[:])
|
||||
// Reject zero if you want to avoid ever producing "0" (optional).
|
||||
if u == 0 {
|
||||
// Extremely unlikely; recurse once.
|
||||
return NewCartID()
|
||||
}
|
||||
return CartID{raw: u, txt: encodeBase62(u)}, nil
|
||||
}
|
||||
|
||||
// MustNewCartID panics on failure (suitable for tests / initialization).
|
||||
func MustNewCartID() CartID {
|
||||
id, err := NewCartID()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// ParseCartID attempts to parse a base62 canonical ID.
|
||||
// Returns (id, true) if fully valid; (zero, false) otherwise.
|
||||
func ParseCartID(s string) (CartID, bool) {
|
||||
if len(s) == 0 {
|
||||
return CartID{}, false
|
||||
}
|
||||
// Basic length sanity; allow a bit of headroom for future timestamp variant.
|
||||
if len(s) > 16 {
|
||||
return CartID{}, false
|
||||
}
|
||||
u, ok := decodeBase62(s)
|
||||
if !ok {
|
||||
return CartID{}, false
|
||||
}
|
||||
return CartID{raw: u, txt: s}, true
|
||||
}
|
||||
|
||||
// FallbackFromString produces a deterministic CartID from arbitrary input
|
||||
// using a 64-bit FNV-1a hash. This allows legacy or malformed IDs to map
|
||||
// consistently into the new scheme (collision probability still low).
|
||||
func FallbackFromString(s string) CartID {
|
||||
const (
|
||||
offset64 = 1469598103934665603
|
||||
prime64 = 1099511628211
|
||||
)
|
||||
h := uint64(offset64)
|
||||
for i := 0; i < len(s); i++ {
|
||||
h ^= uint64(s[i])
|
||||
h *= prime64
|
||||
}
|
||||
return CartID{raw: h, txt: encodeBase62(h)}
|
||||
}
|
||||
|
||||
// UpgradeLegacyCartId converts the old 16-byte CartId (padded) to CartID
|
||||
// by hashing its trimmed string form. Keeps stable mapping across restarts.
|
||||
func UpgradeLegacyCartId(old CartId) CartID {
|
||||
return FallbackFromString(old.String())
|
||||
}
|
||||
|
||||
// CanonicalizeIncoming normalizes user-provided ID strings.
|
||||
// Behavior:
|
||||
//
|
||||
// Empty string -> generate new ID (wasGenerated = true)
|
||||
// Valid base62 -> parse and return (wasGenerated = false)
|
||||
// Anything else -> fallback deterministic hash (wasGenerated = false)
|
||||
//
|
||||
// Errors only occur if crypto/rand fails during generation.
|
||||
func CanonicalizeIncoming(s string) (CartID, bool, error) {
|
||||
if s == "" {
|
||||
id, err := NewCartID()
|
||||
return id, true, err
|
||||
}
|
||||
if cid, ok := ParseCartID(s); ok {
|
||||
return cid, false, nil
|
||||
}
|
||||
// Legacy heuristic: if length == 16 and contains non-base62 chars, treat as legacy padded ID.
|
||||
if len(s) == 16 && !isAllBase62(s) {
|
||||
return FallbackFromString(strings.TrimRight(s, "\x00")), false, nil
|
||||
}
|
||||
return FallbackFromString(s), false, nil
|
||||
}
|
||||
|
||||
// isAllBase62 returns true if every byte is in the base62 alphabet.
|
||||
func isAllBase62(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if base62Rev[s[i]] == 0xFF {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// encodeBase62 turns a uint64 into base62 text.
|
||||
// Complexity: O(log_62 n) ~ at most 11 iterations for 64 bits.
|
||||
func encodeBase62(u uint64) string {
|
||||
if u == 0 {
|
||||
return "0"
|
||||
}
|
||||
// 62^11 = 743008370688 > 2^39; 62^11 > 2^64? Actually 62^11 ~= 5.18e19 < 2^64 (1.84e19)? 2^64 ≈ 1.84e19.
|
||||
// 62^11 ≈ 5.18e19 > 2^64? Correction: 2^64 ≈ 1.844e19, so 62^11 > 2^64. Thus 11 chars suffice.
|
||||
var buf [11]byte
|
||||
i := len(buf)
|
||||
for u > 0 {
|
||||
i--
|
||||
buf[i] = base62Alphabet[u%62]
|
||||
u /= 62
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
// decodeBase62 converts a base62 string to uint64.
|
||||
// Returns (value, false) if any invalid character appears.
|
||||
func decodeBase62(s string) (uint64, bool) {
|
||||
var v uint64
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
d := base62Rev[c]
|
||||
if d == 0xFF {
|
||||
return 0, false
|
||||
}
|
||||
v = v*62 + uint64(d)
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// ErrInvalidCartID can be returned by higher-level validation layers if you decide
|
||||
// to reject fallback-derived IDs (currently unused here).
|
||||
var ErrInvalidCartID = errors.New("invalid cart id")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy / Compatibility Conversion Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CartIDToLegacy converts a CartID (base62) into the legacy fixed-size CartId
|
||||
// ([16]byte) by copying the textual form (truncated or zero-padded).
|
||||
// NOTE: If the base62 string is longer than 16 (should not happen with current
|
||||
// 64-bit space), it will be truncated.
|
||||
func CartIDToLegacy(c CartID) CartId {
|
||||
var id CartId
|
||||
txt := c.String()
|
||||
copy(id[:], []byte(txt))
|
||||
return id
|
||||
}
|
||||
|
||||
// LegacyToCartID upgrades a legacy CartId (padded) to a CartID by hashing its
|
||||
// trimmed string form (deterministic). This preserves stable mapping without
|
||||
// depending on original randomness.
|
||||
func LegacyToCartID(old CartId) CartID {
|
||||
return UpgradeLegacyCartId(old)
|
||||
}
|
||||
|
||||
// CartIDToKey returns the numeric key representation (uint64) for map indexing.
|
||||
func CartIDToKey(c CartID) uint64 {
|
||||
return c.Raw()
|
||||
}
|
||||
|
||||
// LegacyToCartKey converts a legacy CartId to the numeric key via deterministic
|
||||
// fallback hashing. (Uses the same logic as LegacyToCartID then returns raw.)
|
||||
func LegacyToCartKey(old CartId) uint64 {
|
||||
return LegacyToCartID(old).Raw()
|
||||
}
|
||||
|
||||
// ---------------------- Optional Helper Utilities ----------------------------
|
||||
|
||||
// CartIDOrNew tries to parse s; if empty OR invalid returns a fresh ID.
|
||||
func CartIDOrNew(s string) (CartID, bool /*wasParsed*/, error) {
|
||||
if cid, ok := ParseCartID(s); ok {
|
||||
return cid, true, nil
|
||||
}
|
||||
id, err := NewCartID()
|
||||
return id, false, err
|
||||
}
|
||||
|
||||
// MustParseCartID panics if s is not a valid base62 ID (useful in tests).
|
||||
func MustParseCartID(s string) CartID {
|
||||
if cid, ok := ParseCartID(s); ok {
|
||||
return cid
|
||||
}
|
||||
panic(fmt.Sprintf("invalid CartID: %s", s))
|
||||
}
|
||||
|
||||
// DebugString returns a verbose description (for logging / diagnostics).
|
||||
func (c CartID) DebugString() string {
|
||||
return fmt.Sprintf("CartID(raw=%d txt=%s)", c.raw, c.String())
|
||||
}
|
||||
|
||||
// Equal compares two CartIDs by raw value.
|
||||
func (c CartID) Equal(other CartID) bool {
|
||||
return c.raw == other.raw
|
||||
}
|
||||
|
||||
// CanonicalizeOrLegacy preserves legacy (non-base62) IDs without altering their
|
||||
// textual form, avoiding the previous behavior where fallback hashing replaced
|
||||
// the original string with a base62-encoded hash (which broke deterministic
|
||||
// key derivation across mixed call paths).
|
||||
//
|
||||
// Behavior:
|
||||
// - s == "" -> generate new CartID (generatedNew = true, wasBase62 = true)
|
||||
// - base62 ok -> return parsed CartID (generatedNew = false, wasBase62 = true)
|
||||
// - otherwise -> treat as legacy: raw = hash(s), txt = original s
|
||||
//
|
||||
// Returns:
|
||||
//
|
||||
// cid - CartID (txt preserved for legacy inputs)
|
||||
// generatedNew - true only when a brand new ID was created due to empty input
|
||||
// wasBase62 - true if the input was already canonical base62 (or generated)
|
||||
// err - only set if crypto/rand fails when generating a new ID
|
||||
func CanonicalizeOrLegacy(s string) (cid CartID, generatedNew bool, wasBase62 bool, err error) {
|
||||
if s == "" {
|
||||
id, e := NewCartID()
|
||||
if e != nil {
|
||||
return CartID{}, false, false, e
|
||||
}
|
||||
return id, true, true, nil
|
||||
}
|
||||
if parsed, ok := ParseCartID(s); ok {
|
||||
return parsed, false, true, nil
|
||||
}
|
||||
// Legacy path: keep original text so downstream legacy-to-key hashing
|
||||
// (which uses the visible string) yields consistent keys across code paths.
|
||||
hashCID := FallbackFromString(s)
|
||||
// Preserve original textual form
|
||||
hashCID.txt = s
|
||||
return hashCID, false, false, nil
|
||||
}
|
||||
259
cart_id_test.go
Normal file
259
cart_id_test.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
mrand "math/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEncodeDecodeBase62RoundTrip verifies encodeBase62/decodeBase62 are inverse.
|
||||
func TestEncodeDecodeBase62RoundTrip(t *testing.T) {
|
||||
mrand.Seed(42)
|
||||
for i := 0; i < 1000; i++ {
|
||||
// Random 64-bit value
|
||||
v := mrand.Uint64()
|
||||
s := encodeBase62(v)
|
||||
dec, ok := decodeBase62(s)
|
||||
if !ok {
|
||||
t.Fatalf("decodeBase62 failed for %d encoded=%s", v, s)
|
||||
}
|
||||
if dec != v {
|
||||
t.Fatalf("round trip mismatch: have %d got %d (encoded=%s)", v, dec, s)
|
||||
}
|
||||
}
|
||||
// Explicit zero test
|
||||
if s := encodeBase62(0); s != "0" {
|
||||
t.Fatalf("expected encodeBase62(0) == \"0\", got %q", s)
|
||||
}
|
||||
if v, ok := decodeBase62("0"); !ok || v != 0 {
|
||||
t.Fatalf("decodeBase62(0) unexpected result v=%d ok=%v", v, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCartIDUniqueness generates a number of IDs and checks for duplicates.
|
||||
func TestNewCartIDUniqueness(t *testing.T) {
|
||||
const n = 10000
|
||||
seen := make(map[string]struct{}, n)
|
||||
for i := 0; i < n; i++ {
|
||||
id, err := NewCartID()
|
||||
if err != nil {
|
||||
t.Fatalf("NewCartID error: %v", err)
|
||||
}
|
||||
s := id.String()
|
||||
if _, exists := seen[s]; exists {
|
||||
t.Fatalf("duplicate CartID generated: %s", s)
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
if id.IsZero() {
|
||||
t.Fatalf("NewCartID returned zero value")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseCartIDValidation tests parsing of valid and invalid base62 strings.
|
||||
func TestParseCartIDValidation(t *testing.T) {
|
||||
id, err := NewCartID()
|
||||
if err != nil {
|
||||
t.Fatalf("NewCartID error: %v", err)
|
||||
}
|
||||
parsed, ok := ParseCartID(id.String())
|
||||
if !ok {
|
||||
t.Fatalf("ParseCartID failed for valid id %s", id)
|
||||
}
|
||||
if parsed.raw != id.raw {
|
||||
t.Fatalf("parsed raw mismatch: %d vs %d", parsed.raw, id.raw)
|
||||
}
|
||||
|
||||
if _, ok := ParseCartID(""); ok {
|
||||
t.Fatalf("expected empty string to be invalid")
|
||||
}
|
||||
// Invalid char ('-')
|
||||
if _, ok := ParseCartID("abc-123"); ok {
|
||||
t.Fatalf("expected invalid chars to fail parse")
|
||||
}
|
||||
// Overly long ( >16 )
|
||||
if _, ok := ParseCartID("1234567890abcdefg"); ok {
|
||||
t.Fatalf("expected overly long string to fail parse")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFallbackDeterminism ensures fallback hashing is deterministic.
|
||||
func TestFallbackDeterminism(t *testing.T) {
|
||||
inputs := []string{
|
||||
"legacy-cart-1",
|
||||
"legacy-cart-2",
|
||||
"UPPER_lower_123",
|
||||
"🚀unicode", // unicode bytes (will hash byte sequence)
|
||||
}
|
||||
for _, in := range inputs {
|
||||
a := FallbackFromString(in)
|
||||
b := FallbackFromString(in)
|
||||
if a.raw != b.raw || a.String() != b.String() {
|
||||
t.Fatalf("fallback mismatch for %q: %+v vs %+v", in, a, b)
|
||||
}
|
||||
}
|
||||
// Distinct inputs should almost always differ; sample check
|
||||
a := FallbackFromString("distinct-A")
|
||||
b := FallbackFromString("distinct-B")
|
||||
if a.raw == b.raw {
|
||||
t.Fatalf("unexpected identical fallback hashes for distinct inputs")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanonicalizeIncomingBehavior covers main control flow branches.
|
||||
func TestCanonicalizeIncomingBehavior(t *testing.T) {
|
||||
// Empty => new id
|
||||
id1, generated, err := CanonicalizeIncoming("")
|
||||
if err != nil || !generated || id1.IsZero() {
|
||||
t.Fatalf("CanonicalizeIncoming empty failed: id=%v gen=%v err=%v", id1, generated, err)
|
||||
}
|
||||
|
||||
// Valid base62 => parse; no generation
|
||||
id2, gen2, err := CanonicalizeIncoming(id1.String())
|
||||
if err != nil || gen2 || id2.raw != id1.raw {
|
||||
t.Fatalf("CanonicalizeIncoming parse mismatch: id2=%v gen2=%v err=%v", id2, gen2, err)
|
||||
}
|
||||
|
||||
// Legacy-like random containing invalid chars -> fallback
|
||||
fallbackInput := "legacy\x00\x00padding"
|
||||
id3, gen3, err := CanonicalizeIncoming(fallbackInput)
|
||||
if err != nil || gen3 {
|
||||
t.Fatalf("CanonicalizeIncoming fallback unexpected: id3=%v gen3=%v err=%v", id3, gen3, err)
|
||||
}
|
||||
|
||||
// Deterministic fallback
|
||||
id4, _, _ := CanonicalizeIncoming(fallbackInput)
|
||||
if id3.raw != id4.raw {
|
||||
t.Fatalf("fallback canonicalization not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpgradeLegacyCartId ensures mapping of old CartId is stable.
|
||||
func TestUpgradeLegacyCartId(t *testing.T) {
|
||||
var legacy CartId
|
||||
copy(legacy[:], []byte("legacy-123456789")) // 15 bytes + padding
|
||||
up1 := UpgradeLegacyCartId(legacy)
|
||||
up2 := UpgradeLegacyCartId(legacy)
|
||||
if up1.raw != up2.raw {
|
||||
t.Fatalf("UpgradeLegacyCartId not deterministic: %v vs %v", up1, up2)
|
||||
}
|
||||
if up1.String() != up2.String() {
|
||||
t.Fatalf("UpgradeLegacyCartId string mismatch: %s vs %s", up1, up2)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkNewCartID gives a rough idea of generation cost.
|
||||
func BenchmarkNewCartID(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := NewCartID(); err != nil {
|
||||
b.Fatalf("error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEncodeBase62 measures encode speed in isolation.
|
||||
func BenchmarkEncodeBase62(b *testing.B) {
|
||||
// Random sample of values
|
||||
samples := make([]uint64, 1024)
|
||||
for i := range samples {
|
||||
var buf [8]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
b.Fatalf("rand: %v", err)
|
||||
}
|
||||
samples[i] = binary.BigEndian.Uint64(buf[:])
|
||||
}
|
||||
b.ResetTimer()
|
||||
var sink string
|
||||
for i := 0; i < b.N; i++ {
|
||||
sink = encodeBase62(samples[i%len(samples)])
|
||||
}
|
||||
_ = sink
|
||||
}
|
||||
|
||||
// BenchmarkDecodeBase62 measures decode speed.
|
||||
func BenchmarkDecodeBase62(b *testing.B) {
|
||||
// Pre-encode
|
||||
encoded := make([]string, 1024)
|
||||
for i := range encoded {
|
||||
encoded[i] = encodeBase62(uint64(i)<<32 | uint64(i))
|
||||
}
|
||||
b.ResetTimer()
|
||||
var sum uint64
|
||||
for i := 0; i < b.N; i++ {
|
||||
v, ok := decodeBase62(encoded[i%len(encoded)])
|
||||
if !ok {
|
||||
b.Fatalf("decode failed")
|
||||
}
|
||||
sum ^= v
|
||||
}
|
||||
_ = sum
|
||||
}
|
||||
|
||||
// TestLookupNDeterminism (ring integration smoke test) ensures LookupN
|
||||
// returns distinct hosts and stable ordering for a fixed ring.
|
||||
func TestLookupNDeterminism(t *testing.T) {
|
||||
rb := NewRingBuilder().WithEpoch(1).WithVnodesPerHost(8).WithHosts([]string{"a", "b", "c"})
|
||||
ring := rb.Build()
|
||||
if ring.Empty() {
|
||||
t.Fatalf("expected non-empty ring")
|
||||
}
|
||||
id := MustNewCartID()
|
||||
owners1 := ring.LookupN(id.Raw(), 3)
|
||||
owners2 := ring.LookupN(id.Raw(), 3)
|
||||
if len(owners1) != len(owners2) {
|
||||
t.Fatalf("LookupN length mismatch")
|
||||
}
|
||||
for i := range owners1 {
|
||||
if owners1[i].Host != owners2[i].Host {
|
||||
t.Fatalf("LookupN ordering instability at %d: %v vs %v", i, owners1[i], owners2[i])
|
||||
}
|
||||
}
|
||||
// Distinct host constraint
|
||||
seen := map[string]struct{}{}
|
||||
for _, v := range owners1 {
|
||||
if _, ok := seen[v.Host]; ok {
|
||||
t.Fatalf("duplicate host in LookupN result: %v", owners1)
|
||||
}
|
||||
seen[v.Host] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingFingerprintChanges ensures fingerprint updates with membership changes.
|
||||
func TestRingFingerprintChanges(t *testing.T) {
|
||||
b1 := NewRingBuilder().WithEpoch(1).WithHosts([]string{"node1", "node2"})
|
||||
r1 := b1.Build()
|
||||
b2 := NewRingBuilder().WithEpoch(2).WithHosts([]string{"node1", "node2", "node3"})
|
||||
r2 := b2.Build()
|
||||
if r1.Fingerprint() == r2.Fingerprint() {
|
||||
t.Fatalf("expected differing fingerprints after host set change")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingDiffHosts verifies added/removed host detection.
|
||||
func TestRingDiffHosts(t *testing.T) {
|
||||
r1 := NewRingBuilder().WithEpoch(1).WithHosts([]string{"a", "b"}).Build()
|
||||
r2 := NewRingBuilder().WithEpoch(2).WithHosts([]string{"b", "c"}).Build()
|
||||
added, removed := r1.DiffHosts(r2)
|
||||
if fmt.Sprintf("%v", added) != "[c]" {
|
||||
t.Fatalf("expected added [c], got %v", added)
|
||||
}
|
||||
if fmt.Sprintf("%v", removed) != "[a]" {
|
||||
t.Fatalf("expected removed [a], got %v", removed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingLookupConsistency ensures direct Lookup and LookupID are aligned.
|
||||
func TestRingLookupConsistency(t *testing.T) {
|
||||
ring := NewRingBuilder().WithEpoch(1).WithHosts([]string{"alpha", "beta"}).WithVnodesPerHost(4).Build()
|
||||
id, _ := ParseCartID("1")
|
||||
if id.IsZero() {
|
||||
t.Fatalf("expected parsed id non-zero")
|
||||
}
|
||||
v1 := ring.Lookup(id.Raw())
|
||||
v2 := ring.LookupID(id)
|
||||
if v1.Host != v2.Host || v1.Hash != v2.Hash {
|
||||
t.Fatalf("Lookup vs LookupID mismatch: %+v vs %+v", v1, v2)
|
||||
}
|
||||
}
|
||||
212
cart_state_mapper.go
Normal file
212
cart_state_mapper.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// cart_state_mapper.go
|
||||
//
|
||||
// Utilities to translate between internal CartGrain state and the gRPC
|
||||
// (typed) protobuf representation CartState. This replaces the previous
|
||||
// JSON blob framing and enables type-safe replies over gRPC, as well as
|
||||
// internal reuse for HTTP handlers without an extra marshal / unmarshal
|
||||
// hop (you can marshal CartState directly for JSON responses if desired).
|
||||
//
|
||||
// Only the one‑way mapping (CartGrain -> CartState) is strictly required
|
||||
// for mutation / state replies. A reverse helper is included in case
|
||||
// future features (e.g. snapshot import, replay, or migration) need it.
|
||||
|
||||
// ToCartState converts the in‑memory CartGrain into a protobuf CartState.
|
||||
func ToCartState(c *CartGrain) *messages.CartState {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
items := make([]*messages.CartItemState, 0, len(c.Items))
|
||||
for _, it := range c.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
itemDiscountPerUnit := max(0, it.OrgPrice-it.Price)
|
||||
itemTotalDiscount := itemDiscountPerUnit * int64(it.Quantity)
|
||||
|
||||
items = append(items, &messages.CartItemState{
|
||||
Id: int64(it.Id),
|
||||
ItemId: int64(it.ItemId),
|
||||
Sku: it.Sku,
|
||||
Name: it.Name,
|
||||
Price: it.Price,
|
||||
Qty: int32(it.Quantity),
|
||||
TotalPrice: it.TotalPrice,
|
||||
TotalTax: it.TotalTax,
|
||||
OrgPrice: it.OrgPrice,
|
||||
TaxRate: int32(it.TaxRate),
|
||||
TotalDiscount: itemTotalDiscount,
|
||||
Brand: it.Brand,
|
||||
Category: it.Category,
|
||||
Category2: it.Category2,
|
||||
Category3: it.Category3,
|
||||
Category4: it.Category4,
|
||||
Category5: it.Category5,
|
||||
Image: it.Image,
|
||||
Type: it.ArticleType,
|
||||
SellerId: it.SellerId,
|
||||
SellerName: it.SellerName,
|
||||
Disclaimer: it.Disclaimer,
|
||||
Outlet: deref(it.Outlet),
|
||||
StoreId: deref(it.StoreId),
|
||||
Stock: int32(it.Stock),
|
||||
})
|
||||
}
|
||||
|
||||
deliveries := make([]*messages.DeliveryState, 0, len(c.Deliveries))
|
||||
for _, d := range c.Deliveries {
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
itemIds := make([]int64, 0, len(d.Items))
|
||||
for _, id := range d.Items {
|
||||
itemIds = append(itemIds, int64(id))
|
||||
}
|
||||
var pp *messages.PickupPoint
|
||||
if d.PickupPoint != nil {
|
||||
// Copy to avoid accidental shared mutation (proto points are fine but explicit).
|
||||
pp = &messages.PickupPoint{
|
||||
Id: d.PickupPoint.Id,
|
||||
Name: d.PickupPoint.Name,
|
||||
Address: d.PickupPoint.Address,
|
||||
City: d.PickupPoint.City,
|
||||
Zip: d.PickupPoint.Zip,
|
||||
Country: d.PickupPoint.Country,
|
||||
}
|
||||
}
|
||||
deliveries = append(deliveries, &messages.DeliveryState{
|
||||
Id: int64(d.Id),
|
||||
Provider: d.Provider,
|
||||
Price: d.Price,
|
||||
Items: itemIds,
|
||||
PickupPoint: pp,
|
||||
})
|
||||
}
|
||||
|
||||
return &messages.CartState{
|
||||
Id: c.Id.String(),
|
||||
Items: items,
|
||||
TotalPrice: c.TotalPrice,
|
||||
TotalTax: c.TotalTax,
|
||||
TotalDiscount: c.TotalDiscount,
|
||||
Deliveries: deliveries,
|
||||
PaymentInProgress: c.PaymentInProgress,
|
||||
OrderReference: c.OrderReference,
|
||||
PaymentStatus: c.PaymentStatus,
|
||||
}
|
||||
}
|
||||
|
||||
// FromCartState merges a protobuf CartState into an existing CartGrain.
|
||||
// This is optional and primarily useful for snapshot import or testing.
|
||||
func FromCartState(cs *messages.CartState, g *CartGrain) *CartGrain {
|
||||
if cs == nil {
|
||||
return g
|
||||
}
|
||||
if g == nil {
|
||||
g = &CartGrain{}
|
||||
}
|
||||
g.Id = ToCartId(cs.Id)
|
||||
g.TotalPrice = cs.TotalPrice
|
||||
g.TotalTax = cs.TotalTax
|
||||
g.TotalDiscount = cs.TotalDiscount
|
||||
g.PaymentInProgress = cs.PaymentInProgress
|
||||
g.OrderReference = cs.OrderReference
|
||||
g.PaymentStatus = cs.PaymentStatus
|
||||
|
||||
// Items
|
||||
g.Items = g.Items[:0]
|
||||
for _, it := range cs.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
outlet := toPtr(it.Outlet)
|
||||
storeId := toPtr(it.StoreId)
|
||||
g.Items = append(g.Items, &CartItem{
|
||||
Id: int(it.Id),
|
||||
ItemId: int(it.ItemId),
|
||||
Sku: it.Sku,
|
||||
Name: it.Name,
|
||||
Price: it.Price,
|
||||
Quantity: int(it.Qty),
|
||||
TotalPrice: it.TotalPrice,
|
||||
TotalTax: it.TotalTax,
|
||||
OrgPrice: it.OrgPrice,
|
||||
TaxRate: int(it.TaxRate),
|
||||
Brand: it.Brand,
|
||||
Category: it.Category,
|
||||
Category2: it.Category2,
|
||||
Category3: it.Category3,
|
||||
Category4: it.Category4,
|
||||
Category5: it.Category5,
|
||||
Image: it.Image,
|
||||
ArticleType: it.Type,
|
||||
SellerId: it.SellerId,
|
||||
SellerName: it.SellerName,
|
||||
Disclaimer: it.Disclaimer,
|
||||
Outlet: outlet,
|
||||
StoreId: storeId,
|
||||
Stock: StockStatus(it.Stock),
|
||||
// Tax, TaxRate already set via Price / Totals if needed
|
||||
})
|
||||
if it.Id > int64(g.lastItemId) {
|
||||
g.lastItemId = int(it.Id)
|
||||
}
|
||||
}
|
||||
|
||||
// Deliveries
|
||||
g.Deliveries = g.Deliveries[:0]
|
||||
for _, d := range cs.Deliveries {
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
intIds := make([]int, 0, len(d.Items))
|
||||
for _, id := range d.Items {
|
||||
intIds = append(intIds, int(id))
|
||||
}
|
||||
var pp *messages.PickupPoint
|
||||
if d.PickupPoint != nil {
|
||||
pp = &messages.PickupPoint{
|
||||
Id: d.PickupPoint.Id,
|
||||
Name: d.PickupPoint.Name,
|
||||
Address: d.PickupPoint.Address,
|
||||
City: d.PickupPoint.City,
|
||||
Zip: d.PickupPoint.Zip,
|
||||
Country: d.PickupPoint.Country,
|
||||
}
|
||||
}
|
||||
g.Deliveries = append(g.Deliveries, &CartDelivery{
|
||||
Id: int(d.Id),
|
||||
Provider: d.Provider,
|
||||
Price: d.Price,
|
||||
Items: intIds,
|
||||
PickupPoint: pp,
|
||||
})
|
||||
if d.Id > int64(g.lastDeliveryId) {
|
||||
g.lastDeliveryId = int(d.Id)
|
||||
}
|
||||
}
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
// Helper to safely de-reference optional string pointers to value or "".
|
||||
func deref(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func toPtr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
119
checkout_builder.go
Normal file
119
checkout_builder.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// CheckoutMeta carries the external / URL metadata required to build a
|
||||
// Klarna CheckoutOrder from a CartGrain snapshot. It deliberately excludes
|
||||
// any Klarna-specific response fields (HTML snippet, client token, etc.).
|
||||
type CheckoutMeta struct {
|
||||
Terms string
|
||||
Checkout string
|
||||
Confirmation string
|
||||
Validation string
|
||||
Push string
|
||||
Country string
|
||||
Currency string // optional override (defaults to "SEK" if empty)
|
||||
Locale string // optional override (defaults to "sv-se" if empty)
|
||||
}
|
||||
|
||||
// BuildCheckoutOrderPayload converts the current cart grain + meta information
|
||||
// into a CheckoutOrder domain struct and returns its JSON-serialized payload
|
||||
// (to send to Klarna) alongside the structured CheckoutOrder object.
|
||||
//
|
||||
// This function is PURE: it does not perform any network I/O or mutate the
|
||||
// grain. The caller is responsible for:
|
||||
//
|
||||
// 1. Choosing whether to create or update the Klarna order.
|
||||
// 2. Invoking KlarnaClient.CreateOrder / UpdateOrder with the returned payload.
|
||||
// 3. Applying an InitializeCheckout mutation (or equivalent) with the
|
||||
// resulting Klarna order id + status.
|
||||
//
|
||||
// If you later need to support different tax rates per line, you can extend
|
||||
// CartItem / Delivery to expose that data and propagate it here.
|
||||
func BuildCheckoutOrderPayload(grain *CartGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
|
||||
if grain == nil {
|
||||
return nil, nil, fmt.Errorf("nil grain")
|
||||
}
|
||||
if meta == nil {
|
||||
return nil, nil, fmt.Errorf("nil checkout meta")
|
||||
}
|
||||
|
||||
currency := meta.Currency
|
||||
if currency == "" {
|
||||
currency = "SEK"
|
||||
}
|
||||
locale := meta.Locale
|
||||
if locale == "" {
|
||||
locale = "sv-se"
|
||||
}
|
||||
country := meta.Country
|
||||
if country == "" {
|
||||
country = "SE" // sensible default; adjust if multi-country support changes
|
||||
}
|
||||
|
||||
lines := make([]*Line, 0, len(grain.Items)+len(grain.Deliveries))
|
||||
|
||||
// Item lines
|
||||
for _, it := range grain.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, &Line{
|
||||
Type: "physical",
|
||||
Reference: it.Sku,
|
||||
Name: it.Name,
|
||||
Quantity: it.Quantity,
|
||||
UnitPrice: int(it.Price),
|
||||
TaxRate: 2500, // TODO: derive if variable tax rates are introduced
|
||||
QuantityUnit: "st",
|
||||
TotalAmount: int(it.TotalPrice),
|
||||
TotalTaxAmount: int(it.TotalTax),
|
||||
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", it.Image),
|
||||
})
|
||||
}
|
||||
|
||||
// Delivery lines
|
||||
for _, d := range grain.Deliveries {
|
||||
if d == nil || d.Price <= 0 {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, &Line{
|
||||
Type: "shipping_fee",
|
||||
Reference: d.Provider,
|
||||
Name: "Delivery",
|
||||
Quantity: 1,
|
||||
UnitPrice: int(d.Price),
|
||||
TaxRate: 2500,
|
||||
QuantityUnit: "st",
|
||||
TotalAmount: int(d.Price),
|
||||
TotalTaxAmount: int(GetTaxAmount(d.Price, 2500)),
|
||||
})
|
||||
}
|
||||
|
||||
order := &CheckoutOrder{
|
||||
PurchaseCountry: country,
|
||||
PurchaseCurrency: currency,
|
||||
Locale: locale,
|
||||
OrderAmount: int(grain.TotalPrice),
|
||||
OrderTaxAmount: int(grain.TotalTax),
|
||||
OrderLines: lines,
|
||||
MerchantReference1: grain.Id.String(),
|
||||
MerchantURLS: &CheckoutMerchantURLS{
|
||||
Terms: meta.Terms,
|
||||
Checkout: meta.Checkout,
|
||||
Confirmation: meta.Confirmation,
|
||||
Validation: meta.Validation,
|
||||
Push: meta.Push,
|
||||
},
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(order)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("marshal checkout order: %w", err)
|
||||
}
|
||||
|
||||
return payload, order, nil
|
||||
}
|
||||
5
cookies.txt
Normal file
5
cookies.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
# Netscape HTTP Cookie File
|
||||
# https://curl.se/docs/http-cookies.html
|
||||
# This file was generated by libcurl! Edit at your own risk.
|
||||
|
||||
#HttpOnly_localhost FALSE / FALSE 1761304670 cartid 4393545184291837
|
||||
@@ -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
|
||||
@@ -1,272 +1,252 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: klarna-api-credentials
|
||||
name: klarna-api-credentials
|
||||
data:
|
||||
username: ZjQzZDY3YjEtNzA2Yy00NTk2LTliNTgtYjg1YjU2NDEwZTUw
|
||||
password: a2xhcm5hX3Rlc3RfYXBpX0trUWhWVE5yYVZsV2FsTnhTRVp3Y1ZSSFF5UkVNRmxyY25Kd1AxSndQMGdzWmpRelpEWTNZakV0TnpBMll5MDBOVGsyTFRsaU5UZ3RZamcxWWpVMk5ERXdaVFV3TERFc2JUUkNjRFpWU1RsTllsSk1aMlEyVEc4MmRVODNZMkozUlRaaFdEZDViV3AwYkhGV1JqTjVNQzlaYXow
|
||||
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:
|
||||
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
|
||||
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: POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
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
|
||||
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: 1337
|
||||
name: rpc
|
||||
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: POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app: cart-actor
|
||||
arch: arm64
|
||||
name: cart-actor-arm64
|
||||
spec:
|
||||
replicas: 0
|
||||
selector:
|
||||
matchLabels:
|
||||
app: cart-actor
|
||||
arch: arm64
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
labels:
|
||||
app: cart-actor
|
||||
actor-pool: cart
|
||||
arch: arm64
|
||||
spec:
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/hostname
|
||||
operator: NotIn
|
||||
values:
|
||||
- masterpi
|
||||
- key: kubernetes.io/arch
|
||||
operator: In
|
||||
values:
|
||||
- arm64
|
||||
volumes:
|
||||
- name: data
|
||||
nfs:
|
||||
path: /i-data/7a8af061/nfs/cart-actor
|
||||
server: 10.10.1.10
|
||||
imagePullSecrets:
|
||||
- name: regcred
|
||||
serviceAccountName: default
|
||||
containers:
|
||||
- image: registry.knatofs.se/go-cart-actor:latest
|
||||
name: cart-actor-arm64
|
||||
imagePullPolicy: Always
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command: ["sleep", "15"]
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: web
|
||||
- containerPort: 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: POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
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
|
||||
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: 1337
|
||||
name: rpc
|
||||
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: POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
---
|
||||
kind: Service
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: cart-echo
|
||||
name: cart-actor
|
||||
annotations:
|
||||
prometheus.io/port: "8080"
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/path: "/metrics"
|
||||
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
|
||||
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
|
||||
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.tornberg.me
|
||||
secretName: cart-actor-tls-secret
|
||||
rules:
|
||||
- host: cart.tornberg.me
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: cart-actor
|
||||
port:
|
||||
number: 8080
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- cart.tornberg.me
|
||||
secretName: cart-actor-tls-secret
|
||||
rules:
|
||||
- host: cart.tornberg.me
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: cart-actor
|
||||
port:
|
||||
number: 8080
|
||||
|
||||
@@ -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"
|
||||
|
||||
95
discovery.go
95
discovery.go
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
v1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -75,3 +76,97 @@ func NewK8sDiscovery(client *kubernetes.Clientset) *K8sDiscovery {
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// MockDiscovery is an in-memory Discovery implementation for tests.
|
||||
// It allows deterministic injection of host additions/removals without
|
||||
// depending on Kubernetes API machinery.
|
||||
type MockDiscovery struct {
|
||||
mu sync.RWMutex
|
||||
hosts []string
|
||||
events chan HostChange
|
||||
closed bool
|
||||
started bool
|
||||
}
|
||||
|
||||
// NewMockDiscovery creates a mock discovery with an initial host list.
|
||||
func NewMockDiscovery(initial []string) *MockDiscovery {
|
||||
cp := make([]string, len(initial))
|
||||
copy(cp, initial)
|
||||
return &MockDiscovery{
|
||||
hosts: cp,
|
||||
events: make(chan HostChange, 32),
|
||||
}
|
||||
}
|
||||
|
||||
// Discover returns the current host snapshot.
|
||||
func (m *MockDiscovery) Discover() ([]string, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
cp := make([]string, len(m.hosts))
|
||||
copy(cp, m.hosts)
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
// Watch returns a channel that will receive HostChange events.
|
||||
// The channel is buffered; AddHost/RemoveHost push events non-blockingly.
|
||||
func (m *MockDiscovery) Watch() (<-chan HostChange, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.closed {
|
||||
return nil, context.Canceled
|
||||
}
|
||||
m.started = true
|
||||
return m.events, nil
|
||||
}
|
||||
|
||||
// AddHost inserts a new host (if absent) and emits an Added event.
|
||||
func (m *MockDiscovery) AddHost(host string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.closed {
|
||||
return
|
||||
}
|
||||
for _, h := range m.hosts {
|
||||
if h == host {
|
||||
return
|
||||
}
|
||||
}
|
||||
m.hosts = append(m.hosts, host)
|
||||
if m.started {
|
||||
m.events <- HostChange{Host: host, Type: watch.Added}
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveHost removes a host (if present) and emits a Deleted event.
|
||||
func (m *MockDiscovery) RemoveHost(host string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.closed {
|
||||
return
|
||||
}
|
||||
idx := -1
|
||||
for i, h := range m.hosts {
|
||||
if h == host {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx == -1 {
|
||||
return
|
||||
}
|
||||
m.hosts = append(m.hosts[:idx], m.hosts[idx+1:]...)
|
||||
if m.started {
|
||||
m.events <- HostChange{Host: host, Type: watch.Deleted}
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the event channel (idempotent).
|
||||
func (m *MockDiscovery) Close() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.closed {
|
||||
return
|
||||
}
|
||||
m.closed = true
|
||||
close(m.events)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
@@ -23,59 +22,18 @@ func NewDiskStorage(stateFile string) (*DiskStorage, error) {
|
||||
return ret, err
|
||||
}
|
||||
|
||||
func saveMessages(messages []StorableMessage, id CartId) error {
|
||||
|
||||
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()
|
||||
|
||||
for _, m := range messages {
|
||||
err := m.Write(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return err
|
||||
func saveMessages(_ interface{}, _ CartId) error {
|
||||
// No-op: legacy event log persistence removed in oneof refactor.
|
||||
return nil
|
||||
}
|
||||
|
||||
func getCartPath(id string) string {
|
||||
return fmt.Sprintf("data/%s.prot", id)
|
||||
}
|
||||
|
||||
func loadMessages(grain Grain, id CartId) error {
|
||||
var err error
|
||||
path := getCartPath(id.String())
|
||||
|
||||
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 loadMessages(_ Grain, _ CartId) error {
|
||||
// No-op: legacy replay removed in oneof refactor.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DiskStorage) saveState() error {
|
||||
@@ -103,15 +61,8 @@ func (s *DiskStorage) loadState() error {
|
||||
return gob.NewDecoder(file).Decode(&s.LastSaves)
|
||||
}
|
||||
|
||||
func (s *DiskStorage) Store(id CartId, grain *CartGrain) error {
|
||||
lastSavedMessage, ok := s.LastSaves[id]
|
||||
if ok && lastSavedMessage > grain.GetLastChange() {
|
||||
return nil
|
||||
}
|
||||
err := saveMessages(grain.GetStorageMessage(lastSavedMessage), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
func (s *DiskStorage) Store(id CartId, _ *CartGrain) error {
|
||||
// With the removal of the legacy message log, we only update the timestamp.
|
||||
ts := time.Now().Unix()
|
||||
s.LastSaves[id] = ts
|
||||
s.lastSave = ts
|
||||
|
||||
102
frames.go
102
frames.go
@@ -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 in‑process
|
||||
// envelope for status code + typed marker + payload bytes (JSON or proto).
|
||||
// - Message / status constants referenced in existing code paths.
|
||||
//
|
||||
// Recommended future cleanup (post‑migration):
|
||||
// - 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 in‑process 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 (200–299). This mirrors previous usage patterns.
|
||||
func (f *FrameWithPayload) IsSuccess() bool {
|
||||
return f != nil && f.StatusCode >= 200 && f.StatusCode < 300
|
||||
}
|
||||
186
grain-pool.go
186
grain-pool.go
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
@@ -11,6 +10,23 @@ import (
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
// grain-pool.go
|
||||
//
|
||||
// Migration Note:
|
||||
// This file has been migrated to use uint64 cart keys internally (derived
|
||||
// from the new CartID base62 representation). For backward compatibility,
|
||||
// a deprecated legacy map keyed by CartId is maintained so existing code
|
||||
// that directly indexes pool.grains with a CartId continues to compile
|
||||
// until the full refactor across SyncedPool / remoteIndex is completed.
|
||||
//
|
||||
// Authoritative storage: grains (map[uint64]*CartGrain)
|
||||
// Legacy compatibility: grainsLegacy (map[CartId]*CartGrain) - kept in sync.
|
||||
//
|
||||
// Once all external usages are updated to rely on helper accessors,
|
||||
// grainsLegacy can be removed.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
poolGrains = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "cart_grains_in_pool",
|
||||
@@ -26,49 +42,71 @@ var (
|
||||
})
|
||||
)
|
||||
|
||||
// GrainPool interface remains legacy-compatible.
|
||||
type GrainPool interface {
|
||||
Process(id CartId, messages ...Message) (*FrameWithPayload, error)
|
||||
Get(id CartId) (*FrameWithPayload, error)
|
||||
Apply(id CartId, mutation interface{}) (*CartGrain, error)
|
||||
Get(id CartId) (*CartGrain, error)
|
||||
}
|
||||
|
||||
// Ttl keeps expiry info
|
||||
type Ttl struct {
|
||||
Expires time.Time
|
||||
Grain *CartGrain
|
||||
}
|
||||
|
||||
// GrainLocalPool now stores grains keyed by uint64 (CartKey).
|
||||
type GrainLocalPool struct {
|
||||
mu sync.RWMutex
|
||||
grains map[CartId]*CartGrain
|
||||
grains map[uint64]*CartGrain // authoritative only
|
||||
expiry []Ttl
|
||||
spawn func(id CartId) (*CartGrain, error)
|
||||
Ttl time.Duration
|
||||
PoolSize int
|
||||
}
|
||||
|
||||
// NewGrainLocalPool constructs a new pool.
|
||||
func NewGrainLocalPool(size int, ttl time.Duration, spawn func(id CartId) (*CartGrain, error)) *GrainLocalPool {
|
||||
|
||||
ret := &GrainLocalPool{
|
||||
spawn: spawn,
|
||||
grains: make(map[CartId]*CartGrain),
|
||||
grains: make(map[uint64]*CartGrain),
|
||||
expiry: make([]Ttl, 0),
|
||||
Ttl: ttl,
|
||||
PoolSize: size,
|
||||
}
|
||||
|
||||
cartPurge := time.NewTicker(time.Minute)
|
||||
go func() {
|
||||
<-cartPurge.C
|
||||
ret.Purge()
|
||||
for range cartPurge.C {
|
||||
ret.Purge()
|
||||
}
|
||||
}()
|
||||
return ret
|
||||
}
|
||||
|
||||
// keyFromCartId derives the uint64 key from a legacy CartId deterministically.
|
||||
func keyFromCartId(id CartId) uint64 {
|
||||
return LegacyToCartKey(id)
|
||||
}
|
||||
|
||||
// storeGrain indexes a grain in both maps.
|
||||
func (p *GrainLocalPool) storeGrain(id CartId, g *CartGrain) {
|
||||
k := keyFromCartId(id)
|
||||
p.grains[k] = g
|
||||
}
|
||||
|
||||
// deleteGrain removes a grain from both maps.
|
||||
func (p *GrainLocalPool) deleteGrain(id CartId) {
|
||||
k := keyFromCartId(id)
|
||||
delete(p.grains, k)
|
||||
}
|
||||
|
||||
// SetAvailable pre-populates placeholder entries (legacy signature).
|
||||
func (p *GrainLocalPool) SetAvailable(availableWithLastChangeUnix map[CartId]int64) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for id := range availableWithLastChangeUnix {
|
||||
if _, ok := p.grains[id]; !ok {
|
||||
p.grains[id] = nil
|
||||
k := keyFromCartId(id)
|
||||
if _, ok := p.grains[k]; !ok {
|
||||
p.grains[k] = nil
|
||||
p.expiry = append(p.expiry, Ttl{
|
||||
Expires: time.Now().Add(p.Ttl),
|
||||
Grain: nil,
|
||||
@@ -77,13 +115,19 @@ func (p *GrainLocalPool) SetAvailable(availableWithLastChangeUnix map[CartId]int
|
||||
}
|
||||
}
|
||||
|
||||
// Purge removes expired grains.
|
||||
func (p *GrainLocalPool) Purge() {
|
||||
lastChangeTime := time.Now().Add(-p.Ttl)
|
||||
keepChanged := lastChangeTime.Unix()
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
for i := 0; i < len(p.expiry); i++ {
|
||||
item := p.expiry[i]
|
||||
if item.Grain == nil {
|
||||
continue
|
||||
}
|
||||
if item.Expires.Before(time.Now()) {
|
||||
if item.Grain.GetLastChange() > keepChanged {
|
||||
log.Printf("Expired item %s changed, keeping", item.Grain.GetId())
|
||||
@@ -91,12 +135,12 @@ func (p *GrainLocalPool) Purge() {
|
||||
p.expiry = append(p.expiry[:i], p.expiry[i+1:]...)
|
||||
p.expiry = append(p.expiry, item)
|
||||
} else {
|
||||
// move last to end (noop)
|
||||
p.expiry = append(p.expiry[:i], item)
|
||||
}
|
||||
|
||||
} else {
|
||||
log.Printf("Item %s expired", item.Grain.GetId())
|
||||
delete(p.grains, item.Grain.GetId())
|
||||
p.deleteGrain(item.Grain.GetId())
|
||||
if i < len(p.expiry)-1 {
|
||||
p.expiry = append(p.expiry[:i], p.expiry[i+1:]...)
|
||||
} else {
|
||||
@@ -109,60 +153,92 @@ func (p *GrainLocalPool) Purge() {
|
||||
}
|
||||
}
|
||||
|
||||
// GetGrains returns a legacy view of grains (copy) for compatibility.
|
||||
func (p *GrainLocalPool) GetGrains() map[CartId]*CartGrain {
|
||||
return p.grains
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
out := make(map[CartId]*CartGrain, len(p.grains))
|
||||
for _, g := range p.grains {
|
||||
if g != nil {
|
||||
out[g.GetId()] = g
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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))
|
||||
// statsUpdate updates Prometheus gauges asynchronously.
|
||||
func (p *GrainLocalPool) statsUpdate() {
|
||||
go func(size int) {
|
||||
l := float64(size)
|
||||
ps := float64(p.PoolSize)
|
||||
poolUsage.Set(l / ps)
|
||||
poolGrains.Set(l)
|
||||
poolSize.Set(ps)
|
||||
}()
|
||||
}(len(p.grains))
|
||||
}
|
||||
|
||||
// GetGrain retrieves or spawns a grain (legacy id signature).
|
||||
func (p *GrainLocalPool) GetGrain(id CartId) (*CartGrain, error) {
|
||||
grainLookups.Inc()
|
||||
k := keyFromCartId(id)
|
||||
|
||||
p.mu.RLock()
|
||||
grain, ok := p.grains[k]
|
||||
p.mu.RUnlock()
|
||||
|
||||
var err error
|
||||
if grain == nil || !ok {
|
||||
p.mu.Lock()
|
||||
// Re-check under write lock
|
||||
grain, ok = p.grains[k]
|
||||
if grain == nil || !ok {
|
||||
// Capacity check
|
||||
if len(p.grains) >= p.PoolSize && len(p.expiry) > 0 {
|
||||
if p.expiry[0].Expires.Before(time.Now()) && p.expiry[0].Grain != nil {
|
||||
oldId := p.expiry[0].Grain.GetId()
|
||||
p.deleteGrain(oldId)
|
||||
p.expiry = p.expiry[1:]
|
||||
} else {
|
||||
p.mu.Unlock()
|
||||
return nil, fmt.Errorf("pool is full")
|
||||
}
|
||||
}
|
||||
grain, err = p.spawn(id)
|
||||
if err == nil {
|
||||
p.storeGrain(id, grain)
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
p.statsUpdate()
|
||||
}
|
||||
|
||||
return grain, err
|
||||
}
|
||||
|
||||
func (p *GrainLocalPool) Process(id CartId, messages ...Message) (*FrameWithPayload, error) {
|
||||
// Apply applies a mutation (legacy compatibility).
|
||||
func (p *GrainLocalPool) Apply(id CartId, mutation interface{}) (*CartGrain, error) {
|
||||
grain, err := p.GetGrain(id)
|
||||
var result *FrameWithPayload
|
||||
if err == nil && grain != nil {
|
||||
for _, message := range messages {
|
||||
result, err = grain.HandleMessage(&message, false)
|
||||
}
|
||||
if err != nil || grain == nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, err
|
||||
return grain.Apply(mutation, false)
|
||||
}
|
||||
|
||||
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
|
||||
// Get returns current state (legacy wrapper).
|
||||
func (p *GrainLocalPool) Get(id CartId) (*CartGrain, error) {
|
||||
return p.GetGrain(id)
|
||||
}
|
||||
|
||||
// DebugGrainCount returns counts for debugging.
|
||||
func (p *GrainLocalPool) DebugGrainCount() (authoritative int) {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return len(p.grains)
|
||||
}
|
||||
|
||||
// UnsafePointerToLegacyMap exposes the legacy map pointer (for transitional
|
||||
// tests that still poke the field directly). DO NOT rely on this long-term.
|
||||
func (p *GrainLocalPool) UnsafePointerToLegacyMap() uintptr {
|
||||
// Legacy map removed; retained only to satisfy any transitional callers.
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -14,7 +12,7 @@ import (
|
||||
|
||||
// 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
|
||||
// This test uses the new per-mutation AddItem RPC (breaking v2 API) to avoid external product fetch logic
|
||||
// fetching logic (FetchItem) which would require network I/O.
|
||||
func TestCartActorMutationAndState(t *testing.T) {
|
||||
// Setup local grain pool + synced pool (no discovery, single host)
|
||||
@@ -62,37 +60,29 @@ func TestCartActorMutationAndState(t *testing.T) {
|
||||
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{
|
||||
// Issue AddItem RPC directly (breaking v2 API)
|
||||
addResp, err := cartClient.AddItem(context.Background(), &messages.AddItemRequest{
|
||||
CartId: cartID,
|
||||
Type: messages.MutationType(AddItemType),
|
||||
Payload: payloadData,
|
||||
ClientTimestamp: time.Now().Unix(),
|
||||
Payload: addItem,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Mutate RPC error: %v", err)
|
||||
t.Fatalf("AddItem RPC error: %v", err)
|
||||
}
|
||||
if mutResp.StatusCode != 200 {
|
||||
t.Fatalf("Mutate returned non-200 status: %d payload=%s", mutResp.StatusCode, string(mutResp.Payload))
|
||||
if addResp.StatusCode != 200 {
|
||||
t.Fatalf("AddItem returned non-200 status: %d, error: %s", addResp.StatusCode, addResp.GetError())
|
||||
}
|
||||
|
||||
// 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))
|
||||
// Validate the response state (from AddItem)
|
||||
state := addResp.GetState()
|
||||
if state == nil {
|
||||
t.Fatalf("AddItem response state is nil")
|
||||
}
|
||||
|
||||
// (Removed obsolete Mutate response handling)
|
||||
|
||||
if len(state.Items) != 1 {
|
||||
t.Fatalf("Expected 1 item after mutation, got %d", len(state.Items))
|
||||
t.Fatalf("Expected 1 item after AddItem, got %d", len(state.Items))
|
||||
}
|
||||
if state.Items[0].Sku != "test-sku" {
|
||||
t.Fatalf("Unexpected item SKU: %s", state.Items[0].Sku)
|
||||
@@ -106,13 +96,14 @@ func TestCartActorMutationAndState(t *testing.T) {
|
||||
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))
|
||||
t.Fatalf("GetState returned non-200 status: %d, error: %s", getResp.StatusCode, getResp.GetError())
|
||||
}
|
||||
|
||||
state2 := &CartGrain{}
|
||||
if err := json.Unmarshal(getResp.Payload, state2); err != nil {
|
||||
t.Fatalf("Unmarshal get state: %v", err)
|
||||
state2 := getResp.GetState()
|
||||
if state2 == nil {
|
||||
t.Fatalf("GetState response state is nil")
|
||||
}
|
||||
|
||||
if len(state2.Items) != 1 {
|
||||
t.Fatalf("Expected 1 item in GetState, got %d", len(state2.Items))
|
||||
}
|
||||
@@ -121,15 +112,4 @@ func TestCartActorMutationAndState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
// Legacy serialization helper removed (oneof envelope used directly)
|
||||
|
||||
579
grpc_server.go
579
grpc_server.go
@@ -2,378 +2,279 @@ 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"
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/grpc/reflection"
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Metrics
|
||||
// -----------------------------------------------------------------------------
|
||||
// cartActorGRPCServer implements the CartActor and ControlPlane gRPC services.
|
||||
// It delegates cart operations to a grain pool and cluster operations to a synced pool.
|
||||
type cartActorGRPCServer struct {
|
||||
messages.UnimplementedCartActorServer
|
||||
messages.UnimplementedControlPlaneServer
|
||||
|
||||
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()
|
||||
pool GrainPool // For cart state mutations and queries
|
||||
syncedPool *SyncedPool // For cluster membership and control
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// CartActor Service Implementation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
type cartActorService struct {
|
||||
proto.UnimplementedCartActorServer
|
||||
pool GrainPool
|
||||
// NewCartActorGRPCServer creates and initializes the server.
|
||||
func NewCartActorGRPCServer(pool GrainPool, syncedPool *SyncedPool) *cartActorGRPCServer {
|
||||
return &cartActorGRPCServer{
|
||||
pool: pool,
|
||||
syncedPool: syncedPool,
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
// applyMutation routes a single cart mutation to the target grain (used by per-mutation RPC handlers).
|
||||
func (s *cartActorGRPCServer) applyMutation(cartID string, mutation interface{}) *messages.CartMutationReply {
|
||||
// Canonicalize or preserve legacy id (do NOT hash-rewrite legacy textual ids)
|
||||
cid, _, wasBase62, cerr := CanonicalizeOrLegacy(cartID)
|
||||
if cerr != nil {
|
||||
return &messages.CartMutationReply{
|
||||
StatusCode: 500,
|
||||
Result: &messages.CartMutationReply_Error{Error: fmt.Sprintf("cart_id canonicalization failed: %v", cerr)},
|
||||
ServerTimestamp: time.Now().Unix(),
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
||||
_ = wasBase62 // placeholder; future: propagate canonical id in reply metadata
|
||||
legacy := CartIDToLegacy(cid)
|
||||
grain, err := s.pool.Apply(legacy, mutation)
|
||||
if err != nil {
|
||||
grpcMutateErrors.Inc()
|
||||
return nil, err
|
||||
return &messages.CartMutationReply{
|
||||
StatusCode: 500,
|
||||
Result: &messages.CartMutationReply_Error{Error: err.Error()},
|
||||
ServerTimestamp: time.Now().Unix(),
|
||||
}
|
||||
}
|
||||
cartState := ToCartState(grain)
|
||||
return &messages.CartMutationReply{
|
||||
StatusCode: 200,
|
||||
Result: &messages.CartMutationReply_State{State: cartState},
|
||||
ServerTimestamp: time.Now().Unix(),
|
||||
}
|
||||
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
|
||||
})
|
||||
func (s *cartActorGRPCServer) AddRequest(ctx context.Context, req *messages.AddRequestRequest) (*messages.CartMutationReply, error) {
|
||||
if req.GetCartId() == "" {
|
||||
return &messages.CartMutationReply{
|
||||
StatusCode: 400,
|
||||
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||
ServerTimestamp: time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||
}
|
||||
|
||||
func (s *cartActorGRPCServer) AddItem(ctx context.Context, req *messages.AddItemRequest) (*messages.CartMutationReply, error) {
|
||||
if req.GetCartId() == "" {
|
||||
return &messages.CartMutationReply{
|
||||
StatusCode: 400,
|
||||
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||
ServerTimestamp: time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||
}
|
||||
|
||||
func (s *cartActorGRPCServer) RemoveItem(ctx context.Context, req *messages.RemoveItemRequest) (*messages.CartMutationReply, error) {
|
||||
if req.GetCartId() == "" {
|
||||
return &messages.CartMutationReply{
|
||||
StatusCode: 400,
|
||||
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||
ServerTimestamp: time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||
}
|
||||
|
||||
func (s *cartActorGRPCServer) RemoveDelivery(ctx context.Context, req *messages.RemoveDeliveryRequest) (*messages.CartMutationReply, error) {
|
||||
if req.GetCartId() == "" {
|
||||
return &messages.CartMutationReply{
|
||||
StatusCode: 400,
|
||||
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||
ServerTimestamp: time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||
}
|
||||
|
||||
func (s *cartActorGRPCServer) ChangeQuantity(ctx context.Context, req *messages.ChangeQuantityRequest) (*messages.CartMutationReply, error) {
|
||||
if req.GetCartId() == "" {
|
||||
return &messages.CartMutationReply{
|
||||
StatusCode: 400,
|
||||
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||
ServerTimestamp: time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||
}
|
||||
|
||||
func (s *cartActorGRPCServer) SetDelivery(ctx context.Context, req *messages.SetDeliveryRequest) (*messages.CartMutationReply, error) {
|
||||
if req.GetCartId() == "" {
|
||||
return &messages.CartMutationReply{
|
||||
StatusCode: 400,
|
||||
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||
ServerTimestamp: time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||
}
|
||||
|
||||
func (s *cartActorGRPCServer) SetPickupPoint(ctx context.Context, req *messages.SetPickupPointRequest) (*messages.CartMutationReply, error) {
|
||||
if req.GetCartId() == "" {
|
||||
return &messages.CartMutationReply{
|
||||
StatusCode: 400,
|
||||
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||
ServerTimestamp: time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||
}
|
||||
|
||||
/*
|
||||
Checkout RPC removed. Checkout is handled at the HTTP layer (PoolServer.HandleCheckout).
|
||||
*/
|
||||
|
||||
func (s *cartActorGRPCServer) SetCartItems(ctx context.Context, req *messages.SetCartItemsRequest) (*messages.CartMutationReply, error) {
|
||||
if req.GetCartId() == "" {
|
||||
return &messages.CartMutationReply{
|
||||
StatusCode: 400,
|
||||
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||
ServerTimestamp: time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||
}
|
||||
|
||||
func (s *cartActorGRPCServer) OrderCompleted(ctx context.Context, req *messages.OrderCompletedRequest) (*messages.CartMutationReply, error) {
|
||||
if req.GetCartId() == "" {
|
||||
return &messages.CartMutationReply{
|
||||
StatusCode: 400,
|
||||
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||
ServerTimestamp: time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||
}
|
||||
|
||||
// GetState retrieves the current state of a cart grain.
|
||||
func (s *cartActorGRPCServer) GetState(ctx context.Context, req *messages.StateRequest) (*messages.StateReply, error) {
|
||||
if req.GetCartId() == "" {
|
||||
return &messages.StateReply{
|
||||
StatusCode: 400,
|
||||
Result: &messages.StateReply_Error{Error: "cart_id is required"},
|
||||
}, nil
|
||||
}
|
||||
// Canonicalize / upgrade incoming cart id (preserve legacy strings)
|
||||
cid, _, _, cerr := CanonicalizeOrLegacy(req.GetCartId())
|
||||
if cerr != nil {
|
||||
return &messages.StateReply{
|
||||
StatusCode: 500,
|
||||
Result: &messages.StateReply_Error{Error: fmt.Sprintf("cart_id canonicalization failed: %v", cerr)},
|
||||
}, nil
|
||||
}
|
||||
legacy := CartIDToLegacy(cid)
|
||||
|
||||
grain, err := s.pool.Get(legacy)
|
||||
if err != nil {
|
||||
return 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")
|
||||
return &messages.StateReply{
|
||||
StatusCode: 500,
|
||||
Result: &messages.StateReply_Error{Error: err.Error()},
|
||||
}, nil
|
||||
}
|
||||
|
||||
lis, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listen %s: %w", addr, err)
|
||||
}
|
||||
cartState := ToCartState(grain)
|
||||
|
||||
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,
|
||||
return &messages.StateReply{
|
||||
StatusCode: 200,
|
||||
Result: &messages.StateReply_State{State: cartState},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GracefulStop stops the server gracefully.
|
||||
func (s *GRPCServer) GracefulStop() {
|
||||
if s == nil || s.server == nil {
|
||||
return
|
||||
}
|
||||
s.server.GracefulStop()
|
||||
// ControlPlane: Ping
|
||||
func (s *cartActorGRPCServer) Ping(ctx context.Context, _ *messages.Empty) (*messages.PingReply, error) {
|
||||
return &messages.PingReply{
|
||||
Host: s.syncedPool.Hostname,
|
||||
UnixTime: time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Addr returns the bound address.
|
||||
func (s *GRPCServer) Addr() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
// ControlPlane: Negotiate (merge host views)
|
||||
func (s *cartActorGRPCServer) Negotiate(ctx context.Context, req *messages.NegotiateRequest) (*messages.NegotiateReply, error) {
|
||||
hostSet := make(map[string]struct{})
|
||||
// Caller view
|
||||
for _, h := range req.GetKnownHosts() {
|
||||
if h != "" {
|
||||
hostSet[h] = struct{}{}
|
||||
}
|
||||
}
|
||||
return s.addr
|
||||
// This host
|
||||
hostSet[s.syncedPool.Hostname] = struct{}{}
|
||||
// Known remotes
|
||||
s.syncedPool.mu.RLock()
|
||||
for h := range s.syncedPool.remoteHosts {
|
||||
hostSet[h] = struct{}{}
|
||||
}
|
||||
s.syncedPool.mu.RUnlock()
|
||||
|
||||
out := make([]string, 0, len(hostSet))
|
||||
for h := range hostSet {
|
||||
out = append(out, h)
|
||||
}
|
||||
return &messages.NegotiateReply{Hosts: out}, nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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(),
|
||||
// ControlPlane: GetCartIds (locally owned carts only)
|
||||
func (s *cartActorGRPCServer) GetCartIds(ctx context.Context, _ *messages.Empty) (*messages.CartIdsReply, error) {
|
||||
s.syncedPool.local.mu.RLock()
|
||||
ids := make([]string, 0, len(s.syncedPool.local.grains))
|
||||
for _, g := range s.syncedPool.local.grains {
|
||||
if g == nil {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, g.GetId().String())
|
||||
}
|
||||
dialOpts = append(dialOpts, opts...)
|
||||
ctxDial, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
conn, err := grpc.DialContext(ctxDial, target, dialOpts...)
|
||||
s.syncedPool.local.mu.RUnlock()
|
||||
return &messages.CartIdsReply{CartIds: ids}, nil
|
||||
}
|
||||
|
||||
// ControlPlane: Closing (peer shutdown notification)
|
||||
func (s *cartActorGRPCServer) Closing(ctx context.Context, req *messages.ClosingNotice) (*messages.OwnerChangeAck, error) {
|
||||
if req.GetHost() != "" {
|
||||
s.syncedPool.RemoveHost(req.GetHost())
|
||||
}
|
||||
return &messages.OwnerChangeAck{
|
||||
Accepted: true,
|
||||
Message: "removed host",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StartGRPCServer configures and starts the unified gRPC server on the given address.
|
||||
// It registers both the CartActor and ControlPlane services.
|
||||
func StartGRPCServer(addr string, pool GrainPool, syncedPool *SyncedPool) (*grpc.Server, error) {
|
||||
lis, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to listen: %w", err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Utility for converting internal errors to gRPC status (if needed later).
|
||||
// -----------------------------------------------------------------------------
|
||||
grpcServer := grpc.NewServer()
|
||||
server := NewCartActorGRPCServer(pool, syncedPool)
|
||||
|
||||
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())
|
||||
messages.RegisterCartActorServer(grpcServer, server)
|
||||
messages.RegisterControlPlaneServer(grpcServer, server)
|
||||
reflection.Register(grpcServer)
|
||||
|
||||
log.Printf("gRPC server listening on %s", addr)
|
||||
go func() {
|
||||
if err := grpcServer.Serve(lis); err != nil {
|
||||
log.Fatalf("failed to serve gRPC: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return grpcServer, nil
|
||||
}
|
||||
|
||||
73
main.go
73
main.go
@@ -38,13 +38,13 @@ 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{},
|
||||
// storageMessages removed (legacy event log deprecated)
|
||||
TotalPrice: 0,
|
||||
}
|
||||
err := loadMessages(ret, id)
|
||||
return ret, err
|
||||
@@ -97,23 +97,6 @@ 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>
|
||||
@@ -153,6 +136,23 @@ func getCheckoutOrder(host string, cartId CartId) *messages.CreateCheckoutOrder
|
||||
}
|
||||
}
|
||||
|
||||
func GetDiscovery() Discovery {
|
||||
if podIp == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
config, kerr := rest.InClusterConfig()
|
||||
|
||||
if kerr != nil {
|
||||
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
|
||||
}
|
||||
client, err := kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating client: %v\n", err)
|
||||
}
|
||||
return NewK8sDiscovery(client)
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
storage, err := NewDiskStorage(fmt.Sprintf("data/%s_state.gob", name))
|
||||
@@ -249,28 +249,20 @@ func main() {
|
||||
return
|
||||
}
|
||||
cartId := ToCartId(cookie.Value)
|
||||
reply, err := syncedServer.pool.Process(cartId, Message{
|
||||
Type: CreateCheckoutOrderType,
|
||||
Content: getCheckoutOrder(r.Host, cartId),
|
||||
})
|
||||
order, err = syncedServer.CreateOrUpdateCheckout(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()))
|
||||
return
|
||||
}
|
||||
// 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\")")
|
||||
@@ -392,12 +384,9 @@ 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,
|
||||
},
|
||||
_, err = syncedServer.pool.Apply(ToCartId(order.MerchantReference1), &messages.OrderCreated{
|
||||
OrderId: order.ID,
|
||||
Status: order.Status,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
var Handlers = map[uint16]MessageHandler{
|
||||
AddRequestType: &AddRequestHandler{},
|
||||
AddItemType: &AddItemHandler{},
|
||||
ChangeQuantityType: &ChangeQuantityHandler{},
|
||||
SetDeliveryType: &SetDeliveryHandler{},
|
||||
RemoveItemType: &RemoveItemHandler{},
|
||||
RemoveDeliveryType: &RemoveDeliveryHandler{},
|
||||
CreateCheckoutOrderType: &CheckoutHandler{},
|
||||
SetCartItemsType: &SetCartItemsHandler{},
|
||||
OrderCompletedType: &OrderCompletedHandler{},
|
||||
}
|
||||
|
||||
func GetMessageHandler(t uint16) (MessageHandler, error) {
|
||||
h, ok := Handlers[t]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no handler for message type %d", t)
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
type MessageHandler interface {
|
||||
Write(*Message, io.Writer) error
|
||||
Read(data []byte) (interface{}, error)
|
||||
Is(*Message) bool
|
||||
}
|
||||
type TypedMessageHandler struct {
|
||||
Type uint16
|
||||
}
|
||||
|
||||
type SetCartItemsHandler struct {
|
||||
TypedMessageHandler
|
||||
}
|
||||
|
||||
func (h *SetCartItemsHandler) Write(m *Message, w io.Writer) error {
|
||||
messageBytes, err := proto.Marshal(m.Content.(*messages.SetCartRequest))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Write(messageBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *SetCartItemsHandler) Read(data []byte) (interface{}, error) {
|
||||
msg := &messages.SetCartRequest{}
|
||||
|
||||
err := proto.Unmarshal(data, msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (h *SetCartItemsHandler) Is(m *Message) bool {
|
||||
if m.Type != AddRequestType {
|
||||
return false
|
||||
}
|
||||
_, ok := m.Content.(*messages.SetCartRequest)
|
||||
return ok
|
||||
}
|
||||
|
||||
type AddRequestHandler struct {
|
||||
TypedMessageHandler
|
||||
}
|
||||
|
||||
func (h *AddRequestHandler) Write(m *Message, w io.Writer) error {
|
||||
messageBytes, err := proto.Marshal(m.Content.(*messages.AddRequest))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Write(messageBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AddRequestHandler) Read(data []byte) (interface{}, error) {
|
||||
msg := &messages.AddRequest{}
|
||||
|
||||
err := proto.Unmarshal(data, msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (h *AddRequestHandler) Is(m *Message) bool {
|
||||
if m.Type != AddRequestType {
|
||||
return false
|
||||
}
|
||||
_, ok := m.Content.(*messages.AddRequest)
|
||||
return ok
|
||||
}
|
||||
|
||||
type AddItemHandler struct {
|
||||
TypedMessageHandler
|
||||
}
|
||||
|
||||
func (h *AddItemHandler) Write(m *Message, w io.Writer) error {
|
||||
messageBytes, err := proto.Marshal(m.Content.(*messages.AddItem))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Write(messageBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AddItemHandler) Read(data []byte) (interface{}, error) {
|
||||
msg := &messages.AddItem{}
|
||||
|
||||
err := proto.Unmarshal(data, msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (h *AddItemHandler) Is(m *Message) bool {
|
||||
if m.Type != AddItemType {
|
||||
return false
|
||||
}
|
||||
_, ok := m.Content.(*messages.AddItem)
|
||||
return ok
|
||||
}
|
||||
|
||||
type ChangeQuantityHandler struct {
|
||||
TypedMessageHandler
|
||||
}
|
||||
|
||||
func (h *ChangeQuantityHandler) Write(m *Message, w io.Writer) error {
|
||||
messageBytes, err := proto.Marshal(m.Content.(*messages.ChangeQuantity))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Write(messageBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ChangeQuantityHandler) Read(data []byte) (interface{}, error) {
|
||||
msg := &messages.ChangeQuantity{}
|
||||
|
||||
err := proto.Unmarshal(data, msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (h *ChangeQuantityHandler) Is(m *Message) bool {
|
||||
if m.Type != ChangeQuantityType {
|
||||
return false
|
||||
}
|
||||
_, ok := m.Content.(*messages.ChangeQuantity)
|
||||
return ok
|
||||
}
|
||||
|
||||
type SetDeliveryHandler struct {
|
||||
TypedMessageHandler
|
||||
}
|
||||
|
||||
func (h *SetDeliveryHandler) Write(m *Message, w io.Writer) error {
|
||||
messageBytes, err := proto.Marshal(m.Content.(*messages.SetDelivery))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Write(messageBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *SetDeliveryHandler) Read(data []byte) (interface{}, error) {
|
||||
msg := &messages.SetDelivery{}
|
||||
|
||||
err := proto.Unmarshal(data, msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (h *SetDeliveryHandler) Is(m *Message) bool {
|
||||
if m.Type != ChangeQuantityType {
|
||||
return false
|
||||
}
|
||||
_, ok := m.Content.(*messages.SetDelivery)
|
||||
return ok
|
||||
}
|
||||
|
||||
type RemoveItemHandler struct {
|
||||
TypedMessageHandler
|
||||
}
|
||||
|
||||
func (h *RemoveItemHandler) Write(m *Message, w io.Writer) error {
|
||||
messageBytes, err := proto.Marshal(m.Content.(*messages.RemoveItem))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Write(messageBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *RemoveItemHandler) Read(data []byte) (interface{}, error) {
|
||||
msg := &messages.RemoveItem{}
|
||||
|
||||
err := proto.Unmarshal(data, msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (h *RemoveItemHandler) Is(m *Message) bool {
|
||||
if m.Type != AddItemType {
|
||||
return false
|
||||
}
|
||||
_, ok := m.Content.(*messages.RemoveItem)
|
||||
return ok
|
||||
}
|
||||
|
||||
type RemoveDeliveryHandler struct {
|
||||
TypedMessageHandler
|
||||
}
|
||||
|
||||
func (h *RemoveDeliveryHandler) Write(m *Message, w io.Writer) error {
|
||||
messageBytes, err := proto.Marshal(m.Content.(*messages.RemoveDelivery))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Write(messageBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *RemoveDeliveryHandler) Read(data []byte) (interface{}, error) {
|
||||
msg := &messages.RemoveDelivery{}
|
||||
|
||||
err := proto.Unmarshal(data, msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (h *RemoveDeliveryHandler) Is(m *Message) bool {
|
||||
if m.Type != AddItemType {
|
||||
return false
|
||||
}
|
||||
_, ok := m.Content.(*messages.RemoveDelivery)
|
||||
return ok
|
||||
}
|
||||
|
||||
type CheckoutHandler struct {
|
||||
TypedMessageHandler
|
||||
}
|
||||
|
||||
func (h *CheckoutHandler) Write(m *Message, w io.Writer) error {
|
||||
messageBytes, err := proto.Marshal(m.Content.(*messages.CreateCheckoutOrder))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Write(messageBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *CheckoutHandler) Read(data []byte) (interface{}, error) {
|
||||
msg := &messages.CreateCheckoutOrder{}
|
||||
|
||||
err := proto.Unmarshal(data, msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (h *CheckoutHandler) Is(m *Message) bool {
|
||||
if m.Type != CreateCheckoutOrderType {
|
||||
return false
|
||||
}
|
||||
_, ok := m.Content.(*messages.CreateCheckoutOrder)
|
||||
return ok
|
||||
}
|
||||
|
||||
type OrderCompletedHandler struct {
|
||||
TypedMessageHandler
|
||||
}
|
||||
|
||||
func (h *OrderCompletedHandler) Write(m *Message, w io.Writer) error {
|
||||
messageBytes, err := proto.Marshal(m.Content.(*messages.OrderCreated))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Write(messageBytes)
|
||||
return nil
|
||||
}
|
||||
func (h *OrderCompletedHandler) Read(data []byte) (interface{}, error) {
|
||||
msg := &messages.OrderCreated{}
|
||||
|
||||
err := proto.Unmarshal(data, msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
func (h *OrderCompletedHandler) Is(m *Message) bool {
|
||||
if m.Type != OrderCompletedType {
|
||||
return false
|
||||
}
|
||||
_, ok := m.Content.(*messages.OrderCreated)
|
||||
return ok
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package main
|
||||
|
||||
const (
|
||||
AddRequestType = 1
|
||||
AddItemType = 2
|
||||
|
||||
RemoveItemType = 4
|
||||
RemoveDeliveryType = 5
|
||||
ChangeQuantityType = 6
|
||||
SetDeliveryType = 7
|
||||
SetPickupPointType = 8
|
||||
CreateCheckoutOrderType = 9
|
||||
SetCartItemsType = 10
|
||||
OrderCompletedType = 11
|
||||
)
|
||||
94
message.go
94
message.go
@@ -1,94 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
type StorableMessage interface {
|
||||
Write(w io.Writer) error
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Type uint16
|
||||
TimeStamp *int64
|
||||
Content interface{}
|
||||
}
|
||||
|
||||
type MessageWriter struct {
|
||||
io.Writer
|
||||
}
|
||||
|
||||
type StorableMessageHeader struct {
|
||||
Version uint16
|
||||
Type uint16
|
||||
TimeStamp int64
|
||||
DataLength uint64
|
||||
}
|
||||
|
||||
func GetData(fn func(w io.Writer) error) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
err := fn(&buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b := buf.Bytes()
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (m Message) Write(w io.Writer) error {
|
||||
h, err := GetMessageHandler(m.Type)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := GetData(func(w io.Writer) error {
|
||||
return h.Write(&m, w)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ts := time.Now().Unix()
|
||||
if m.TimeStamp != nil {
|
||||
ts = *m.TimeStamp
|
||||
}
|
||||
|
||||
err = binary.Write(w, binary.LittleEndian, StorableMessageHeader{
|
||||
Version: 1,
|
||||
Type: m.Type,
|
||||
TimeStamp: ts,
|
||||
DataLength: uint64(len(data)),
|
||||
})
|
||||
w.Write(data)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func ReadMessage(reader io.Reader, m *Message) error {
|
||||
|
||||
header := StorableMessageHeader{}
|
||||
err := binary.Read(reader, binary.LittleEndian, &header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
messageBytes := make([]byte, header.DataLength)
|
||||
_, err = reader.Read(messageBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h, err := GetMessageHandler(header.Type)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := h.Read(messageBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.Content = content
|
||||
|
||||
m.Type = header.Type
|
||||
m.TimeStamp = &header.TimeStamp
|
||||
|
||||
return nil
|
||||
}
|
||||
182
multi_node_ownership_test.go
Normal file
182
multi_node_ownership_test.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// TestMultiNodeOwnershipNegotiation spins up two gRPC servers (nodeA, nodeB),
|
||||
// manually links their SyncedPools (bypassing AddRemote's fixed port assumption),
|
||||
// and verifies that only one node becomes the owner of a new cart while the
|
||||
// other can still apply a mutation via the remote proxy path.
|
||||
//
|
||||
// NOTE:
|
||||
// - We manually inject RemoteHostGRPC entries because AddRemote() hard-codes
|
||||
// port 1337; to run two distinct servers concurrently we need distinct ports.
|
||||
// - This test asserts single ownership consistency rather than the complete
|
||||
// quorum semantics (which depend on real discovery + AddRemote).
|
||||
func TestMultiNodeOwnershipNegotiation(t *testing.T) {
|
||||
// Allocate distinct ports for the two nodes.
|
||||
const (
|
||||
addrA = "127.0.0.1:18081"
|
||||
addrB = "127.0.0.1:18082"
|
||||
hostA = "nodeA"
|
||||
hostB = "nodeB"
|
||||
)
|
||||
|
||||
// Create local grain pools.
|
||||
poolA := NewGrainLocalPool(1024, time.Minute, spawn)
|
||||
poolB := NewGrainLocalPool(1024, time.Minute, spawn)
|
||||
|
||||
// Create synced pools (no discovery).
|
||||
syncedA, err := NewSyncedPool(poolA, hostA, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("nodeA NewSyncedPool error: %v", err)
|
||||
}
|
||||
syncedB, err := NewSyncedPool(poolB, hostB, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("nodeB NewSyncedPool error: %v", err)
|
||||
}
|
||||
|
||||
// Start gRPC servers (CartActor + ControlPlane) on different ports.
|
||||
grpcSrvA, err := StartGRPCServer(addrA, poolA, syncedA)
|
||||
if err != nil {
|
||||
t.Fatalf("StartGRPCServer A error: %v", err)
|
||||
}
|
||||
defer grpcSrvA.GracefulStop()
|
||||
|
||||
grpcSrvB, err := StartGRPCServer(addrB, poolB, syncedB)
|
||||
if err != nil {
|
||||
t.Fatalf("StartGRPCServer B error: %v", err)
|
||||
}
|
||||
defer grpcSrvB.GracefulStop()
|
||||
|
||||
// Helper to connect one pool to the other's server (manual AddRemote equivalent).
|
||||
link := func(src *SyncedPool, remoteHost, remoteAddr string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
conn, dialErr := grpc.DialContext(ctx, remoteAddr, grpc.WithInsecure(), grpc.WithBlock())
|
||||
if dialErr != nil {
|
||||
t.Fatalf("dial %s (%s) failed: %v", remoteHost, remoteAddr, dialErr)
|
||||
}
|
||||
cartClient := messages.NewCartActorClient(conn)
|
||||
controlClient := messages.NewControlPlaneClient(conn)
|
||||
|
||||
src.mu.Lock()
|
||||
src.remoteHosts[remoteHost] = &RemoteHostGRPC{
|
||||
Host: remoteHost,
|
||||
Conn: conn,
|
||||
CartClient: cartClient,
|
||||
ControlClient: controlClient,
|
||||
}
|
||||
src.mu.Unlock()
|
||||
}
|
||||
|
||||
// Cross-link the two pools.
|
||||
link(syncedA, hostB, addrB)
|
||||
link(syncedB, hostA, addrA)
|
||||
|
||||
// Rebuild rings after manual cross-link so deterministic ownership works immediately.
|
||||
syncedA.ForceRingRefresh()
|
||||
syncedB.ForceRingRefresh()
|
||||
|
||||
// Allow brief stabilization (control plane pings / no real negotiation needed here).
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Create a deterministic cart id for test readability.
|
||||
cartID := ToCartId(fmt.Sprintf("cart-%d", time.Now().UnixNano()))
|
||||
|
||||
// Mutation payload (ring-determined ownership; no assumption about which node owns).
|
||||
addItem := &messages.AddItem{
|
||||
ItemId: 1,
|
||||
Quantity: 1,
|
||||
Price: 1500,
|
||||
OrgPrice: 1500,
|
||||
Sku: "sku-test-multi",
|
||||
Name: "Multi Node Test",
|
||||
Image: "/test.png",
|
||||
Stock: 2,
|
||||
Tax: 2500,
|
||||
Country: "se",
|
||||
}
|
||||
|
||||
// Determine ring owner and set primary / secondary references.
|
||||
ownerHost := syncedA.DebugOwnerHost(cartID)
|
||||
var ownerSynced, otherSynced *SyncedPool
|
||||
var ownerPool, otherPool *GrainLocalPool
|
||||
switch ownerHost {
|
||||
case hostA:
|
||||
ownerSynced, ownerPool = syncedA, poolA
|
||||
otherSynced, otherPool = syncedB, poolB
|
||||
case hostB:
|
||||
ownerSynced, ownerPool = syncedB, poolB
|
||||
otherSynced, otherPool = syncedA, poolA
|
||||
default:
|
||||
t.Fatalf("unexpected ring owner %s (expected %s or %s)", ownerHost, hostA, hostB)
|
||||
}
|
||||
|
||||
// Apply mutation on the ring-designated owner.
|
||||
if _, err := ownerSynced.Apply(cartID, addItem); err != nil {
|
||||
t.Fatalf("owner %s Apply addItem error: %v", ownerHost, err)
|
||||
}
|
||||
|
||||
// Validate owner pool has the grain and the other does not.
|
||||
if _, ok := ownerPool.GetGrains()[cartID]; !ok {
|
||||
t.Fatalf("expected owner %s to have local grain", ownerHost)
|
||||
}
|
||||
if _, ok := otherPool.GetGrains()[cartID]; ok {
|
||||
t.Fatalf("non-owner unexpectedly holds local grain")
|
||||
}
|
||||
|
||||
// Prepare change mutation to be applied from the non-owner (should route remotely).
|
||||
change := &messages.ChangeQuantity{
|
||||
Id: 1, // line id after first AddItem
|
||||
Quantity: 2,
|
||||
}
|
||||
// Apply remotely via the non-owner.
|
||||
if _, err := otherSynced.Apply(cartID, change); err != nil {
|
||||
t.Fatalf("non-owner remote Apply changeQuantity error: %v", err)
|
||||
}
|
||||
|
||||
// Remote re-mutation already performed via otherSynced; removed duplicate block.
|
||||
|
||||
// NodeB local grain assertion:
|
||||
// Only assert absence if nodeB is NOT the ring-designated owner. If nodeB is the owner,
|
||||
// it is expected to have a local grain (previous generic ownership assertions already ran).
|
||||
if ownerHost != hostB {
|
||||
if _, local := poolB.GetGrains()[cartID]; local {
|
||||
t.Fatalf("nodeB unexpectedly created local grain (ownership duplication)")
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch state from nodeB to ensure we see updated quantity (2).
|
||||
grainStateB, err := syncedB.Get(cartID)
|
||||
if err != nil {
|
||||
t.Fatalf("nodeB Get error: %v", err)
|
||||
}
|
||||
if len(grainStateB.Items) != 1 || grainStateB.Items[0].Quantity != 2 {
|
||||
t.Fatalf("nodeB observed inconsistent state: items=%d qty=%d (expected 1 / 2)",
|
||||
len(grainStateB.Items),
|
||||
func() int {
|
||||
if len(grainStateB.Items) == 0 {
|
||||
return -1
|
||||
}
|
||||
return grainStateB.Items[0].Quantity
|
||||
}(),
|
||||
)
|
||||
}
|
||||
|
||||
// Cross-check from nodeA (authoritative) to ensure state matches.
|
||||
grainStateA, err := syncedA.Get(cartID)
|
||||
if err != nil {
|
||||
t.Fatalf("nodeA Get error: %v", err)
|
||||
}
|
||||
if grainStateA.Items[0].Quantity != 2 {
|
||||
t.Fatalf("nodeA authoritative state mismatch: expected qty=2 got %d", grainStateA.Items[0].Quantity)
|
||||
}
|
||||
}
|
||||
304
multi_node_three_test.go
Normal file
304
multi_node_three_test.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// TestThreeNodeMajorityOwnership validates ring-determined ownership and routing
|
||||
// in a 3-node cluster (A,B,C) using the consistent hashing ring (no quorum RPC).
|
||||
// The previous ConfirmOwner / quorum semantics have been removed; ownership is
|
||||
// deterministic and derived from the ring.
|
||||
//
|
||||
// It validates:
|
||||
// 1. The ring selects exactly one primary owner for a new cart.
|
||||
// 2. Other nodes (B,C) do NOT create local grains for the cart.
|
||||
// 3. Remote proxies are installed lazily so remote mutations can route.
|
||||
// 4. A remote mutation from one non-owner updates state visible on another.
|
||||
// 5. Authoritative state on the owner matches remote observations.
|
||||
// 6. (Future) This scaffolds replication tests when RF>1 is enabled.
|
||||
//
|
||||
// (Legacy comments about ConfirmOwner acceptance thresholds have been removed.)
|
||||
// (Function name retained for historical continuity.)
|
||||
func TestThreeNodeMajorityOwnership(t *testing.T) {
|
||||
const (
|
||||
addrA = "127.0.0.1:18181"
|
||||
addrB = "127.0.0.1:18182"
|
||||
addrC = "127.0.0.1:18183"
|
||||
hostA = "nodeA3"
|
||||
hostB = "nodeB3"
|
||||
hostC = "nodeC3"
|
||||
)
|
||||
|
||||
// Local grain pools
|
||||
poolA := NewGrainLocalPool(1024, time.Minute, spawn)
|
||||
poolB := NewGrainLocalPool(1024, time.Minute, spawn)
|
||||
poolC := NewGrainLocalPool(1024, time.Minute, spawn)
|
||||
|
||||
// Synced pools (no discovery)
|
||||
syncedA, err := NewSyncedPool(poolA, hostA, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("nodeA NewSyncedPool error: %v", err)
|
||||
}
|
||||
syncedB, err := NewSyncedPool(poolB, hostB, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("nodeB NewSyncedPool error: %v", err)
|
||||
}
|
||||
syncedC, err := NewSyncedPool(poolC, hostC, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("nodeC NewSyncedPool error: %v", err)
|
||||
}
|
||||
|
||||
// Start gRPC servers
|
||||
grpcSrvA, err := StartGRPCServer(addrA, poolA, syncedA)
|
||||
if err != nil {
|
||||
t.Fatalf("StartGRPCServer A error: %v", err)
|
||||
}
|
||||
defer grpcSrvA.GracefulStop()
|
||||
grpcSrvB, err := StartGRPCServer(addrB, poolB, syncedB)
|
||||
if err != nil {
|
||||
t.Fatalf("StartGRPCServer B error: %v", err)
|
||||
}
|
||||
defer grpcSrvB.GracefulStop()
|
||||
grpcSrvC, err := StartGRPCServer(addrC, poolC, syncedC)
|
||||
if err != nil {
|
||||
t.Fatalf("StartGRPCServer C error: %v", err)
|
||||
}
|
||||
defer grpcSrvC.GracefulStop()
|
||||
|
||||
// Helper for manual cross-link (since AddRemote assumes fixed port)
|
||||
link := func(src *SyncedPool, remoteHost, remoteAddr string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
conn, dialErr := grpc.DialContext(ctx, remoteAddr, grpc.WithInsecure(), grpc.WithBlock())
|
||||
if dialErr != nil {
|
||||
t.Fatalf("dial %s (%s) failed: %v", remoteHost, remoteAddr, dialErr)
|
||||
}
|
||||
cartClient := messages.NewCartActorClient(conn)
|
||||
controlClient := messages.NewControlPlaneClient(conn)
|
||||
|
||||
src.mu.Lock()
|
||||
src.remoteHosts[remoteHost] = &RemoteHostGRPC{
|
||||
Host: remoteHost,
|
||||
Conn: conn,
|
||||
CartClient: cartClient,
|
||||
ControlClient: controlClient,
|
||||
}
|
||||
src.mu.Unlock()
|
||||
}
|
||||
|
||||
// Full mesh (each node knows all others)
|
||||
link(syncedA, hostB, addrB)
|
||||
link(syncedA, hostC, addrC)
|
||||
|
||||
link(syncedB, hostA, addrA)
|
||||
link(syncedB, hostC, addrC)
|
||||
|
||||
link(syncedC, hostA, addrA)
|
||||
link(syncedC, hostB, addrB)
|
||||
|
||||
// Rebuild rings after manual linking so ownership resolution is immediate.
|
||||
syncedA.ForceRingRefresh()
|
||||
syncedB.ForceRingRefresh()
|
||||
syncedC.ForceRingRefresh()
|
||||
|
||||
// Allow brief stabilization
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Deterministic-ish cart id
|
||||
cartID := ToCartId(fmt.Sprintf("cart3-%d", time.Now().UnixNano()))
|
||||
|
||||
addItem := &messages.AddItem{
|
||||
ItemId: 10,
|
||||
Quantity: 1,
|
||||
Price: 5000,
|
||||
OrgPrice: 5000,
|
||||
Sku: "sku-3node",
|
||||
Name: "Three Node Test",
|
||||
Image: "/t.png",
|
||||
Stock: 10,
|
||||
Tax: 2500,
|
||||
Country: "se",
|
||||
}
|
||||
|
||||
// Determine ring-designated owner (may be any of the three hosts)
|
||||
ownerPre := syncedA.DebugOwnerHost(cartID)
|
||||
if ownerPre != hostA && ownerPre != hostB && ownerPre != hostC {
|
||||
t.Fatalf("ring returned unexpected owner %s (not in set {%s,%s,%s})", ownerPre, hostA, hostB, hostC)
|
||||
}
|
||||
var ownerSynced *SyncedPool
|
||||
var ownerPool *GrainLocalPool
|
||||
switch ownerPre {
|
||||
case hostA:
|
||||
ownerSynced, ownerPool = syncedA, poolA
|
||||
case hostB:
|
||||
ownerSynced, ownerPool = syncedB, poolB
|
||||
case hostC:
|
||||
ownerSynced, ownerPool = syncedC, poolC
|
||||
}
|
||||
// Pick two distinct non-owner nodes for remote mutation assertions
|
||||
var remote1Synced, remote2Synced *SyncedPool
|
||||
switch ownerPre {
|
||||
case hostA:
|
||||
remote1Synced, remote2Synced = syncedB, syncedC
|
||||
case hostB:
|
||||
remote1Synced, remote2Synced = syncedA, syncedC
|
||||
case hostC:
|
||||
remote1Synced, remote2Synced = syncedA, syncedB
|
||||
}
|
||||
|
||||
// Apply on the ring-designated owner
|
||||
if _, err := ownerSynced.Apply(cartID, addItem); err != nil {
|
||||
t.Fatalf("owner %s Apply addItem error: %v", ownerPre, err)
|
||||
}
|
||||
|
||||
// Small wait for remote proxy spawn (ring ownership already deterministic)
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
// Assert only nodeA has local grain
|
||||
localCount := 0
|
||||
if _, ok := poolA.GetGrains()[cartID]; ok {
|
||||
localCount++
|
||||
}
|
||||
if _, ok := poolB.GetGrains()[cartID]; ok {
|
||||
localCount++
|
||||
}
|
||||
if _, ok := poolC.GetGrains()[cartID]; ok {
|
||||
localCount++
|
||||
}
|
||||
if localCount != 1 {
|
||||
t.Fatalf("expected exactly 1 local grain, got %d", localCount)
|
||||
}
|
||||
if _, ok := ownerPool.GetGrains()[cartID]; !ok {
|
||||
t.Fatalf("expected owner %s to hold local grain", ownerPre)
|
||||
}
|
||||
|
||||
// Remote proxies may not pre-exist; first remote mutation will trigger SpawnRemoteGrain lazily.
|
||||
|
||||
// Issue remote mutation from one non-owner -> ChangeQuantity (increase)
|
||||
change := &messages.ChangeQuantity{
|
||||
Id: 1,
|
||||
Quantity: 3,
|
||||
}
|
||||
if _, err := remote1Synced.Apply(cartID, change); err != nil {
|
||||
t.Fatalf("remote mutation (remote1) changeQuantity error: %v", err)
|
||||
}
|
||||
|
||||
// Validate updated state visible via nodeC
|
||||
stateC, err := remote2Synced.Get(cartID)
|
||||
if err != nil {
|
||||
t.Fatalf("nodeC Get error: %v", err)
|
||||
}
|
||||
if len(stateC.Items) != 1 || stateC.Items[0].Quantity != 3 {
|
||||
t.Fatalf("nodeC observed state mismatch: items=%d qty=%d (expected 1 / 3)",
|
||||
len(stateC.Items),
|
||||
func() int {
|
||||
if len(stateC.Items) == 0 {
|
||||
return -1
|
||||
}
|
||||
return stateC.Items[0].Quantity
|
||||
}(),
|
||||
)
|
||||
}
|
||||
|
||||
// Cross-check authoritative nodeA
|
||||
stateA, err := syncedA.Get(cartID)
|
||||
if err != nil {
|
||||
t.Fatalf("nodeA Get error: %v", err)
|
||||
}
|
||||
if stateA.Items[0].Quantity != 3 {
|
||||
t.Fatalf("nodeA authoritative state mismatch: expected qty=3 got %d", stateA.Items[0].Quantity)
|
||||
}
|
||||
}
|
||||
|
||||
// TestThreeNodeDiscoveryMajorityOwnership (placeholder)
|
||||
// This test is a scaffold demonstrating how a MockDiscovery would be wired
|
||||
// once AddRemote supports host:port (currently hard-coded to :1337).
|
||||
// It is skipped to avoid flakiness / false negatives until the production
|
||||
// AddRemote logic is enhanced to parse dynamic ports or the test harness
|
||||
// provides consistent port mapping.
|
||||
func TestThreeNodeDiscoveryMajorityOwnership(t *testing.T) {
|
||||
t.Skip("Pending enhancement: AddRemote needs host:port support to fully exercise discovery-based multi-node linking")
|
||||
// Example skeleton (non-functional with current AddRemote implementation):
|
||||
//
|
||||
// md := NewMockDiscovery([]string{"nodeB3", "nodeC3"})
|
||||
// poolA := NewGrainLocalPool(1024, time.Minute, spawn)
|
||||
// syncedA, err := NewSyncedPool(poolA, "nodeA3", md)
|
||||
// if err != nil {
|
||||
// t.Fatalf("NewSyncedPool with mock discovery error: %v", err)
|
||||
// }
|
||||
// // Start server for nodeA (would also need servers for nodeB3/nodeC3 on expected ports)
|
||||
// // grpcSrvA, _ := StartGRPCServer(":1337", poolA, syncedA)
|
||||
// // defer grpcSrvA.GracefulStop()
|
||||
//
|
||||
// // Dynamically add a host via discovery
|
||||
// // md.AddHost("nodeB3")
|
||||
// // time.Sleep(100 * time.Millisecond) // allow AddRemote attempt
|
||||
//
|
||||
// // Assertions would verify syncedA.remoteHosts contains "nodeB3"
|
||||
}
|
||||
|
||||
// TestHostRemovalAndErrorWithMockDiscovery validates behavior when:
|
||||
// 1. Discovery reports a host that cannot be dialed (AddRemote error path)
|
||||
// 2. That host is then removed (Deleted event) without leaving residual state
|
||||
// 3. A second failing host is added afterward (ensuring watcher still processes events)
|
||||
//
|
||||
// NOTE: Because AddRemote currently hard-codes :1337 and we are NOT starting a
|
||||
// real server for the bogus hosts, the dial will fail and the remote host should
|
||||
// never appear in remoteHosts. This intentionally exercises the error logging
|
||||
// path: "AddRemote: dial ... failed".
|
||||
func TestHostRemovalAndErrorWithMockDiscovery(t *testing.T) {
|
||||
// Start a real node A (acts as the observing node)
|
||||
const addrA = "127.0.0.1:18281"
|
||||
hostA := "nodeA-md"
|
||||
|
||||
poolA := NewGrainLocalPool(128, time.Minute, spawn)
|
||||
|
||||
// Mock discovery starts with one bogus host that will fail to connect.
|
||||
md := NewMockDiscovery([]string{"bogus-host-1"})
|
||||
syncedA, err := NewSyncedPool(poolA, hostA, md)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSyncedPool error: %v", err)
|
||||
}
|
||||
|
||||
grpcSrvA, err := StartGRPCServer(addrA, poolA, syncedA)
|
||||
if err != nil {
|
||||
t.Fatalf("StartGRPCServer A error: %v", err)
|
||||
}
|
||||
defer grpcSrvA.GracefulStop()
|
||||
|
||||
// Kick off watch processing by starting Watch() (NewSyncedPool does this internally
|
||||
// when discovery is non-nil, but we ensure events channel is active).
|
||||
// The initial bogus host should trigger AddRemote -> dial failure.
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
syncedA.mu.RLock()
|
||||
if len(syncedA.remoteHosts) != 0 {
|
||||
syncedA.mu.RUnlock()
|
||||
t.Fatalf("expected 0 remoteHosts after failing dial, got %d", len(syncedA.remoteHosts))
|
||||
}
|
||||
syncedA.mu.RUnlock()
|
||||
|
||||
// Remove the bogus host (should not panic; no entry to clean up).
|
||||
md.RemoveHost("bogus-host-1")
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Add another bogus host to ensure watcher still alive.
|
||||
md.AddHost("bogus-host-2")
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
syncedA.mu.RLock()
|
||||
if len(syncedA.remoteHosts) != 0 {
|
||||
syncedA.mu.RUnlock()
|
||||
t.Fatalf("expected 0 remoteHosts after second failing dial, got %d", len(syncedA.remoteHosts))
|
||||
}
|
||||
syncedA.mu.RUnlock()
|
||||
|
||||
// Clean up discovery
|
||||
md.Close()
|
||||
}
|
||||
82
mutation_add_item.go
Normal file
82
mutation_add_item.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// mutation_add_item.go
|
||||
//
|
||||
// Registers the AddItem cart mutation in the generic mutation registry.
|
||||
// This replaces the legacy switch-based logic previously found in CartGrain.Apply.
|
||||
//
|
||||
// Behavior:
|
||||
// * Validates quantity > 0
|
||||
// * If an item with same SKU exists -> increases quantity
|
||||
// * Else creates a new CartItem with computed tax amounts
|
||||
// * Totals recalculated automatically via WithTotals()
|
||||
//
|
||||
// NOTE: Any future field additions in messages.AddItem that affect pricing / tax
|
||||
// must keep this handler in sync.
|
||||
|
||||
func init() {
|
||||
RegisterMutation[messages.AddItem](
|
||||
"AddItem",
|
||||
func(g *CartGrain, m *messages.AddItem) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("AddItem: nil payload")
|
||||
}
|
||||
if m.Quantity < 1 {
|
||||
return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity)
|
||||
}
|
||||
|
||||
// Fast path: merge with existing item having same SKU
|
||||
if existing, found := g.FindItemWithSku(m.Sku); found {
|
||||
existing.Quantity += int(m.Quantity)
|
||||
return nil
|
||||
}
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
g.lastItemId++
|
||||
taxRate := 2500
|
||||
if m.Tax > 0 {
|
||||
taxRate = int(m.Tax)
|
||||
}
|
||||
taxAmountPerUnit := GetTaxAmount(m.Price, taxRate)
|
||||
|
||||
g.Items = append(g.Items, &CartItem{
|
||||
Id: g.lastItemId,
|
||||
ItemId: int(m.ItemId),
|
||||
Quantity: int(m.Quantity),
|
||||
Sku: m.Sku,
|
||||
Name: m.Name,
|
||||
Price: m.Price,
|
||||
TotalPrice: m.Price * int64(m.Quantity),
|
||||
TotalTax: int64(taxAmountPerUnit * int64(m.Quantity)),
|
||||
Image: m.Image,
|
||||
Stock: StockStatus(m.Stock),
|
||||
Disclaimer: m.Disclaimer,
|
||||
Brand: m.Brand,
|
||||
Category: m.Category,
|
||||
Category2: m.Category2,
|
||||
Category3: m.Category3,
|
||||
Category4: m.Category4,
|
||||
Category5: m.Category5,
|
||||
OrgPrice: m.OrgPrice,
|
||||
ArticleType: m.ArticleType,
|
||||
Outlet: m.Outlet,
|
||||
SellerId: m.SellerId,
|
||||
SellerName: m.SellerName,
|
||||
Tax: int(taxAmountPerUnit),
|
||||
TaxRate: taxRate,
|
||||
StoreId: m.StoreId,
|
||||
})
|
||||
|
||||
return nil
|
||||
},
|
||||
WithTotals(), // Recalculate totals after successful mutation
|
||||
)
|
||||
}
|
||||
61
mutation_add_request.go
Normal file
61
mutation_add_request.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// mutation_add_request.go
|
||||
//
|
||||
// Registers the AddRequest mutation. This mutation is a higher-level intent
|
||||
// (add by SKU + quantity) which may translate into either:
|
||||
// - Increasing quantity of an existing line (same SKU), OR
|
||||
// - Creating a new item by performing a product lookup (via getItemData inside CartGrain.AddItem)
|
||||
//
|
||||
// Behavior:
|
||||
// - Validates non-empty SKU and quantity > 0
|
||||
// - If an item with the SKU already exists: increments its quantity
|
||||
// - Else delegates to CartGrain.AddItem (which itself produces an AddItem mutation)
|
||||
// - Totals recalculated automatically (WithTotals)
|
||||
//
|
||||
// NOTE:
|
||||
// - This handler purposely avoids duplicating the detailed AddItem logic;
|
||||
// it reuses CartGrain.AddItem which then flows through the AddItem mutation
|
||||
// registry handler.
|
||||
// - Double total recalculation can occur (AddItem has WithTotals too), but
|
||||
// is acceptable for clarity. Optimize later if needed.
|
||||
//
|
||||
// Potential future improvements:
|
||||
// - Stock validation before increasing quantity
|
||||
// - Reservation logic or concurrency guards around stock updates
|
||||
// - Coupon / pricing rules applied conditionally during add-by-sku
|
||||
func init() {
|
||||
RegisterMutation[messages.AddRequest](
|
||||
"AddRequest",
|
||||
func(g *CartGrain, m *messages.AddRequest) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("AddRequest: nil payload")
|
||||
}
|
||||
if m.Sku == "" {
|
||||
return fmt.Errorf("AddRequest: sku is empty")
|
||||
}
|
||||
if m.Quantity < 1 {
|
||||
return fmt.Errorf("AddRequest: invalid quantity %d", m.Quantity)
|
||||
}
|
||||
|
||||
// Existing line: accumulate quantity only.
|
||||
if existing, found := g.FindItemWithSku(m.Sku); found {
|
||||
existing.Quantity += int(m.Quantity)
|
||||
return nil
|
||||
}
|
||||
|
||||
// New line: delegate to higher-level AddItem flow (product lookup).
|
||||
// We intentionally ignore the returned *CartGrain; registry will
|
||||
// do totals again after this handler returns (harmless).
|
||||
_, err := g.AddItem(m.Sku, int(m.Quantity), m.Country, m.StoreId)
|
||||
return err
|
||||
},
|
||||
WithTotals(),
|
||||
)
|
||||
}
|
||||
58
mutation_change_quantity.go
Normal file
58
mutation_change_quantity.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// mutation_change_quantity.go
|
||||
//
|
||||
// Registers the ChangeQuantity mutation.
|
||||
//
|
||||
// Behavior:
|
||||
// - Locates an item by its cart-local line item Id (not source item_id).
|
||||
// - If requested quantity <= 0 the line is removed.
|
||||
// - Otherwise the line's Quantity field is updated.
|
||||
// - Totals are recalculated (WithTotals).
|
||||
//
|
||||
// Error handling:
|
||||
// - Returns an error if the item Id is not found.
|
||||
// - Returns an error if payload is nil (defensive).
|
||||
//
|
||||
// Concurrency:
|
||||
// - Uses the grain's RW-safe mutation pattern: we mutate in place under
|
||||
// the grain's implicit expectation that higher layers control access.
|
||||
// (If strict locking is required around every mutation, wrap logic in
|
||||
// an explicit g.mu.Lock()/Unlock(), but current model mirrors prior code.)
|
||||
func init() {
|
||||
RegisterMutation[messages.ChangeQuantity](
|
||||
"ChangeQuantity",
|
||||
func(g *CartGrain, m *messages.ChangeQuantity) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("ChangeQuantity: nil payload")
|
||||
}
|
||||
|
||||
foundIndex := -1
|
||||
for i, it := range g.Items {
|
||||
if it.Id == int(m.Id) {
|
||||
foundIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if foundIndex == -1 {
|
||||
return fmt.Errorf("ChangeQuantity: item id %d not found", m.Id)
|
||||
}
|
||||
|
||||
if m.Quantity <= 0 {
|
||||
// Remove the item
|
||||
g.Items = append(g.Items[:foundIndex], g.Items[foundIndex+1:]...)
|
||||
return nil
|
||||
}
|
||||
|
||||
g.Items[foundIndex].Quantity = int(m.Quantity)
|
||||
return nil
|
||||
},
|
||||
WithTotals(),
|
||||
)
|
||||
}
|
||||
49
mutation_initialize_checkout.go
Normal file
49
mutation_initialize_checkout.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// mutation_initialize_checkout.go
|
||||
//
|
||||
// Registers the InitializeCheckout mutation.
|
||||
// This mutation is invoked AFTER an external Klarna checkout session
|
||||
// has been successfully created or updated. It persists the Klarna
|
||||
// order reference / status and marks the cart as having a payment in progress.
|
||||
//
|
||||
// Behavior:
|
||||
// - Sets OrderReference to the Klarna order ID (overwriting if already set).
|
||||
// - Sets PaymentStatus to the current Klarna status.
|
||||
// - Sets / updates PaymentInProgress flag.
|
||||
// - Does NOT alter pricing or line items (so no totals recalculation).
|
||||
//
|
||||
// Validation:
|
||||
// - Returns an error if payload is nil.
|
||||
// - Returns an error if orderId is empty (integrity guard).
|
||||
//
|
||||
// Concurrency:
|
||||
// - Relies on upstream mutation serialization for a single grain. If
|
||||
// parallel checkout attempts are possible, add higher-level guards
|
||||
// (e.g. reject if PaymentInProgress already true unless reusing
|
||||
// the same OrderReference).
|
||||
func init() {
|
||||
RegisterMutation[messages.InitializeCheckout](
|
||||
"InitializeCheckout",
|
||||
func(g *CartGrain, m *messages.InitializeCheckout) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("InitializeCheckout: nil payload")
|
||||
}
|
||||
if m.OrderId == "" {
|
||||
return fmt.Errorf("InitializeCheckout: missing orderId")
|
||||
}
|
||||
|
||||
g.OrderReference = m.OrderId
|
||||
g.PaymentStatus = m.Status
|
||||
g.PaymentInProgress = m.PaymentInProgress
|
||||
return nil
|
||||
},
|
||||
// No WithTotals(): monetary aggregates are unaffected.
|
||||
)
|
||||
}
|
||||
53
mutation_order_created.go
Normal file
53
mutation_order_created.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// mutation_order_created.go
|
||||
//
|
||||
// Registers the OrderCreated mutation.
|
||||
//
|
||||
// This mutation represents the completion (or state transition) of an order
|
||||
// initiated earlier via InitializeCheckout / external Klarna processing.
|
||||
// It finalizes (or updates) the cart's order metadata.
|
||||
//
|
||||
// Behavior:
|
||||
// - Validates payload non-nil and OrderId not empty.
|
||||
// - Sets (or overwrites) OrderReference with the provided OrderId.
|
||||
// - Sets PaymentStatus from payload.Status.
|
||||
// - Marks PaymentInProgress = false (checkout flow finished / acknowledged).
|
||||
// - Does NOT adjust monetary totals (no WithTotals()).
|
||||
//
|
||||
// Notes / Future Extensions:
|
||||
// - If multiple order completion events can arrive (e.g., retries / webhook
|
||||
// replays), this handler is idempotent: it simply overwrites fields.
|
||||
// - If you need to guard against conflicting order IDs, add a check:
|
||||
// if g.OrderReference != "" && g.OrderReference != m.OrderId { ... }
|
||||
// - Add audit logging or metrics here if required.
|
||||
//
|
||||
// Concurrency:
|
||||
// - Relies on the higher-level guarantee that Apply() calls are serialized
|
||||
// per grain. If out-of-order events are possible, embed versioning or
|
||||
// timestamps in the mutation and compare before applying changes.
|
||||
func init() {
|
||||
RegisterMutation[messages.OrderCreated](
|
||||
"OrderCreated",
|
||||
func(g *CartGrain, m *messages.OrderCreated) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("OrderCreated: nil payload")
|
||||
}
|
||||
if m.OrderId == "" {
|
||||
return fmt.Errorf("OrderCreated: missing orderId")
|
||||
}
|
||||
|
||||
g.OrderReference = m.OrderId
|
||||
g.PaymentStatus = m.Status
|
||||
g.PaymentInProgress = false
|
||||
return nil
|
||||
},
|
||||
// No WithTotals(): order completion does not modify pricing or taxes.
|
||||
)
|
||||
}
|
||||
301
mutation_registry.go
Normal file
301
mutation_registry.go
Normal file
@@ -0,0 +1,301 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// mutation_registry.go
|
||||
//
|
||||
// Mutation Registry Infrastructure
|
||||
// --------------------------------
|
||||
// This file introduces a generic registry for cart mutations that:
|
||||
//
|
||||
// 1. Decouples mutation logic from the large type-switch inside CartGrain.Apply.
|
||||
// 2. Enforces (at registration time) that every mutation handler has the correct
|
||||
// signature: func(*CartGrain, *T) error
|
||||
// 3. Optionally auto-updates cart totals after a mutation if flagged.
|
||||
// 4. Provides a single authoritative list of registered mutations for
|
||||
// introspection / coverage testing.
|
||||
// 5. Allows incremental migration: you can first register new mutations here,
|
||||
// and later prune the legacy switch cases.
|
||||
//
|
||||
// Usage Pattern
|
||||
// -------------
|
||||
// // Define your mutation proto message (e.g. messages.ApplyCoupon in messages.proto)
|
||||
// // Regenerate protobufs.
|
||||
//
|
||||
// // In an init() (ideally in a small file like mutations_apply_coupon.go)
|
||||
// func init() {
|
||||
// RegisterMutation[*messages.ApplyCoupon](
|
||||
// "ApplyCoupon",
|
||||
// func(g *CartGrain, m *messages.ApplyCoupon) error {
|
||||
// // domain logic ...
|
||||
// discount := int64(5000)
|
||||
// if g.TotalPrice < discount {
|
||||
// discount = g.TotalPrice
|
||||
// }
|
||||
// g.TotalDiscount += discount
|
||||
// g.TotalPrice -= discount
|
||||
// return nil
|
||||
// },
|
||||
// WithTotals(), // we changed price-related fields; recalc totals
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// // To invoke dynamically (alternative to the current switch):
|
||||
// if updated, err := ApplyRegistered(grain, incomingMessage); err == nil {
|
||||
// grain = updated
|
||||
// } else if errors.Is(err, ErrMutationNotRegistered) {
|
||||
// // fallback to legacy switch logic
|
||||
// }
|
||||
//
|
||||
// Migration Strategy
|
||||
// ------------------
|
||||
// 1. For each existing mutation handled in CartGrain.Apply, add a registry
|
||||
// registration with equivalent logic.
|
||||
// 2. Add a test that enumerates all *expected* mutation proto types and asserts
|
||||
// they are present in RegisteredMutationTypes().
|
||||
// 3. Once coverage is 100%, replace the switch in CartGrain.Apply with a call
|
||||
// to ApplyRegistered (and optionally keep a minimal default to produce an
|
||||
// "unsupported mutation" error).
|
||||
//
|
||||
// Thread Safety
|
||||
// -------------
|
||||
// Registration is typically done at init() time; a RWMutex provides safety
|
||||
// should late dynamic registration ever be introduced.
|
||||
//
|
||||
// Auto Totals
|
||||
// -----------
|
||||
// Many mutations require recomputing totals. To avoid forgetting this, pass
|
||||
// WithTotals() when registering. This will invoke grain.UpdateTotals() after
|
||||
// the handler returns successfully.
|
||||
//
|
||||
// Error Semantics
|
||||
// ---------------
|
||||
// - If a handler returns an error, totals are NOT recalculated (even if
|
||||
// WithTotals() was specified).
|
||||
// - ApplyRegistered returns (nil, ErrMutationNotRegistered) if the message type
|
||||
// is absent.
|
||||
//
|
||||
// Extensibility
|
||||
// -------------
|
||||
// It is straightforward to add options like audit hooks, metrics wrappers,
|
||||
// or optimistic concurrency guards by extending MutationOption.
|
||||
//
|
||||
// NOTE: Generics require Go 1.18+. If constrained to earlier Go versions,
|
||||
// replace the generic registration with a non-generic RegisterMutationType
|
||||
// that accepts reflect.Type and an adapter function.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
mutationRegistryMu sync.RWMutex
|
||||
mutationRegistry = make(map[reflect.Type]*registeredMutation)
|
||||
|
||||
// ErrMutationNotRegistered is returned when no handler exists for a given mutation type.
|
||||
ErrMutationNotRegistered = fmt.Errorf("mutation not registered")
|
||||
)
|
||||
|
||||
// MutationOption configures additional behavior for a registered mutation.
|
||||
type MutationOption func(*mutationOptions)
|
||||
|
||||
// mutationOptions holds flags adjustable per registration.
|
||||
type mutationOptions struct {
|
||||
updateTotals bool
|
||||
}
|
||||
|
||||
// WithTotals ensures CartGrain.UpdateTotals() is called after a successful handler.
|
||||
func WithTotals() MutationOption {
|
||||
return func(o *mutationOptions) {
|
||||
o.updateTotals = true
|
||||
}
|
||||
}
|
||||
|
||||
// registeredMutation stores metadata + the execution closure.
|
||||
type registeredMutation struct {
|
||||
name string
|
||||
handler func(*CartGrain, interface{}) error
|
||||
updateTotals bool
|
||||
msgType reflect.Type
|
||||
}
|
||||
|
||||
// RegisterMutation registers a mutation handler for a specific message type T.
|
||||
//
|
||||
// Parameters:
|
||||
//
|
||||
// name - a human-readable identifier (used for diagnostics / coverage tests).
|
||||
// handler - business logic operating on the cart grain & strongly typed message.
|
||||
// options - optional behavior flags (e.g., WithTotals()).
|
||||
//
|
||||
// Panics if:
|
||||
// - name is empty
|
||||
// - handler is nil
|
||||
// - duplicate registration for the same message type T
|
||||
//
|
||||
// Typical call is placed in an init() function.
|
||||
func RegisterMutation[T any](name string, handler func(*CartGrain, *T) error, options ...MutationOption) {
|
||||
if name == "" {
|
||||
panic("RegisterMutation: name is required")
|
||||
}
|
||||
if handler == nil {
|
||||
panic("RegisterMutation: handler is nil")
|
||||
}
|
||||
|
||||
// Derive the reflect.Type for *T then its Elem (T) for mapping.
|
||||
var zero *T
|
||||
rtPtr := reflect.TypeOf(zero)
|
||||
if rtPtr.Kind() != reflect.Ptr {
|
||||
panic("RegisterMutation: expected pointer type for generic parameter")
|
||||
}
|
||||
rt := rtPtr.Elem()
|
||||
|
||||
opts := mutationOptions{}
|
||||
for _, opt := range options {
|
||||
opt(&opts)
|
||||
}
|
||||
|
||||
wrapped := func(g *CartGrain, m interface{}) error {
|
||||
typed, ok := m.(*T)
|
||||
if !ok {
|
||||
return fmt.Errorf("mutation type mismatch: have %T want *%s", m, rt.Name())
|
||||
}
|
||||
return handler(g, typed)
|
||||
}
|
||||
|
||||
mutationRegistryMu.Lock()
|
||||
defer mutationRegistryMu.Unlock()
|
||||
|
||||
if _, exists := mutationRegistry[rt]; exists {
|
||||
panic(fmt.Sprintf("RegisterMutation: duplicate registration for type %s", rt.String()))
|
||||
}
|
||||
|
||||
mutationRegistry[rt] = ®isteredMutation{
|
||||
name: name,
|
||||
handler: wrapped,
|
||||
updateTotals: opts.updateTotals,
|
||||
msgType: rt,
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyRegistered attempts to apply a registered mutation.
|
||||
// Returns updated grain if successful.
|
||||
//
|
||||
// If the mutation is not registered, returns (nil, ErrMutationNotRegistered).
|
||||
func ApplyRegistered(grain *CartGrain, msg interface{}) (*CartGrain, error) {
|
||||
if grain == nil {
|
||||
return nil, fmt.Errorf("nil grain")
|
||||
}
|
||||
if msg == nil {
|
||||
return nil, fmt.Errorf("nil mutation message")
|
||||
}
|
||||
|
||||
rt := indirectType(reflect.TypeOf(msg))
|
||||
mutationRegistryMu.RLock()
|
||||
entry, ok := mutationRegistry[rt]
|
||||
mutationRegistryMu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return nil, ErrMutationNotRegistered
|
||||
}
|
||||
|
||||
if err := entry.handler(grain, msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if entry.updateTotals {
|
||||
grain.UpdateTotals()
|
||||
}
|
||||
|
||||
return grain, nil
|
||||
}
|
||||
|
||||
// RegisteredMutations returns metadata for all registered mutations (snapshot).
|
||||
func RegisteredMutations() []string {
|
||||
mutationRegistryMu.RLock()
|
||||
defer mutationRegistryMu.RUnlock()
|
||||
out := make([]string, 0, len(mutationRegistry))
|
||||
for _, entry := range mutationRegistry {
|
||||
out = append(out, entry.name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RegisteredMutationTypes returns the reflect.Type list of all registered messages.
|
||||
// Useful for coverage tests ensuring expected set matches actual set.
|
||||
func RegisteredMutationTypes() []reflect.Type {
|
||||
mutationRegistryMu.RLock()
|
||||
defer mutationRegistryMu.RUnlock()
|
||||
out := make([]reflect.Type, 0, len(mutationRegistry))
|
||||
for t := range mutationRegistry {
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MustAssertMutationCoverage can be called at startup to ensure every expected
|
||||
// mutation type has been registered. It panics with a descriptive message if any
|
||||
// are missing. Provide a slice of prototype pointers (e.g. []*messages.AddItem{nil} ...)
|
||||
func MustAssertMutationCoverage(expected []interface{}) {
|
||||
mutationRegistryMu.RLock()
|
||||
defer mutationRegistryMu.RUnlock()
|
||||
|
||||
missing := make([]string, 0)
|
||||
for _, ex := range expected {
|
||||
if ex == nil {
|
||||
continue
|
||||
}
|
||||
t := indirectType(reflect.TypeOf(ex))
|
||||
if _, ok := mutationRegistry[t]; !ok {
|
||||
missing = append(missing, t.String())
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
panic(fmt.Sprintf("mutation registry missing handlers for: %v", missing))
|
||||
}
|
||||
}
|
||||
|
||||
// indirectType returns the element type if given a pointer; otherwise the type itself.
|
||||
func indirectType(t reflect.Type) reflect.Type {
|
||||
for t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
/*
|
||||
Integration Guide
|
||||
-----------------
|
||||
|
||||
1. Register all existing mutations:
|
||||
|
||||
func init() {
|
||||
RegisterMutation[*messages.AddItem]("AddItem",
|
||||
func(g *CartGrain, m *messages.AddItem) error {
|
||||
// (port logic from existing switch branch)
|
||||
// ...
|
||||
return nil
|
||||
},
|
||||
WithTotals(),
|
||||
)
|
||||
// ... repeat for others
|
||||
}
|
||||
|
||||
2. In CartGrain.Apply (early in the method) add:
|
||||
|
||||
if updated, err := ApplyRegistered(c, content); err == nil {
|
||||
return updated, nil
|
||||
} else if err != ErrMutationNotRegistered {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// existing switch fallback below
|
||||
|
||||
3. Once all mutations are registered, remove the legacy switch cases
|
||||
and leave a single ErrMutationNotRegistered path for unknown types.
|
||||
|
||||
4. Add a coverage test (see docs for example; removed from source for clarity).
|
||||
5. (Optional) Add metrics / tracing wrappers for handlers.
|
||||
|
||||
*/
|
||||
53
mutation_remove_delivery.go
Normal file
53
mutation_remove_delivery.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// mutation_remove_delivery.go
|
||||
//
|
||||
// Registers the RemoveDelivery mutation.
|
||||
//
|
||||
// Behavior:
|
||||
// - Removes the delivery entry whose Id == payload.Id.
|
||||
// - If not found, returns an error.
|
||||
// - Cart totals are recalculated (WithTotals) after removal.
|
||||
// - Items previously associated with that delivery simply become "without delivery";
|
||||
// subsequent delivery mutations can reassign them.
|
||||
//
|
||||
// Differences vs legacy:
|
||||
// - Legacy logic decremented TotalPrice explicitly before recalculating.
|
||||
// Here we rely solely on UpdateTotals() to recompute from remaining
|
||||
// deliveries and items (simpler / single source of truth).
|
||||
//
|
||||
// Future considerations:
|
||||
// - If delivery pricing logic changes (e.g., dynamic taxes per delivery),
|
||||
// UpdateTotals() may need enhancement to incorporate delivery tax properly.
|
||||
func init() {
|
||||
RegisterMutation[messages.RemoveDelivery](
|
||||
"RemoveDelivery",
|
||||
func(g *CartGrain, m *messages.RemoveDelivery) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("RemoveDelivery: nil payload")
|
||||
}
|
||||
targetID := int(m.Id)
|
||||
index := -1
|
||||
for i, d := range g.Deliveries {
|
||||
if d.Id == targetID {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index == -1 {
|
||||
return fmt.Errorf("RemoveDelivery: delivery id %d not found", m.Id)
|
||||
}
|
||||
|
||||
// Remove delivery (order not preserved beyond necessity)
|
||||
g.Deliveries = append(g.Deliveries[:index], g.Deliveries[index+1:]...)
|
||||
return nil
|
||||
},
|
||||
WithTotals(),
|
||||
)
|
||||
}
|
||||
49
mutation_remove_item.go
Normal file
49
mutation_remove_item.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// mutation_remove_item.go
|
||||
//
|
||||
// Registers the RemoveItem mutation.
|
||||
//
|
||||
// Behavior:
|
||||
// - Removes the cart line whose local cart line Id == payload.Id
|
||||
// - If no such line exists returns an error
|
||||
// - Recalculates cart totals (WithTotals)
|
||||
//
|
||||
// Notes:
|
||||
// - This removes only the line item; any deliveries referencing the removed
|
||||
// item are NOT automatically adjusted (mirrors prior logic). If future
|
||||
// semantics require pruning delivery.item_ids you can extend this handler.
|
||||
// - If multiple lines somehow shared the same Id (should not happen), only
|
||||
// the first match would be removed—data integrity relies on unique line Ids.
|
||||
func init() {
|
||||
RegisterMutation[messages.RemoveItem](
|
||||
"RemoveItem",
|
||||
func(g *CartGrain, m *messages.RemoveItem) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("RemoveItem: nil payload")
|
||||
}
|
||||
targetID := int(m.Id)
|
||||
|
||||
index := -1
|
||||
for i, it := range g.Items {
|
||||
if it.Id == targetID {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index == -1 {
|
||||
return fmt.Errorf("RemoveItem: item id %d not found", m.Id)
|
||||
}
|
||||
|
||||
g.Items = append(g.Items[:index], g.Items[index+1:]...)
|
||||
return nil
|
||||
},
|
||||
WithTotals(),
|
||||
)
|
||||
}
|
||||
57
mutation_set_cart_items.go
Normal file
57
mutation_set_cart_items.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// mutation_set_cart_items.go
|
||||
//
|
||||
// Registers the SetCartRequest mutation. This mutation replaces the entire list
|
||||
// of cart items with the provided list (each entry is an AddRequest).
|
||||
//
|
||||
// Behavior:
|
||||
// - Clears existing items (but leaves deliveries intact).
|
||||
// - Iterates over each AddRequest and delegates to CartGrain.AddItem
|
||||
// (which performs product lookup, creates AddItem mutation).
|
||||
// - If any single addition fails, the mutation aborts with an error;
|
||||
// items added prior to the failure remain (consistent with previous behavior).
|
||||
// - Totals recalculated after completion via WithTotals().
|
||||
//
|
||||
// Notes:
|
||||
// - Potential optimization: batch product lookups; currently sequential.
|
||||
// - Consider adding rollback semantics if atomic replacement is desired.
|
||||
// - Deliveries might reference item IDs that are now invalid—original logic
|
||||
// also left deliveries untouched. If that becomes an issue, add a cleanup
|
||||
// pass to remove deliveries whose item IDs no longer exist.
|
||||
func init() {
|
||||
RegisterMutation[messages.SetCartRequest](
|
||||
"SetCartRequest",
|
||||
func(g *CartGrain, m *messages.SetCartRequest) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("SetCartRequest: nil payload")
|
||||
}
|
||||
|
||||
// Clear current items (keep deliveries)
|
||||
g.mu.Lock()
|
||||
g.Items = make([]*CartItem, 0, len(m.Items))
|
||||
g.mu.Unlock()
|
||||
|
||||
for _, it := range m.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
if it.Sku == "" || it.Quantity < 1 {
|
||||
return fmt.Errorf("SetCartRequest: invalid item (sku='%s' qty=%d)", it.Sku, it.Quantity)
|
||||
}
|
||||
_, err := g.AddItem(it.Sku, int(it.Quantity), it.Country, it.StoreId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SetCartRequest: add sku '%s' failed: %w", it.Sku, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
WithTotals(),
|
||||
)
|
||||
}
|
||||
101
mutation_set_delivery.go
Normal file
101
mutation_set_delivery.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// mutation_set_delivery.go
|
||||
//
|
||||
// Registers the SetDelivery mutation.
|
||||
//
|
||||
// Semantics (mirrors legacy switch logic):
|
||||
// - If the payload specifies an explicit list of item IDs (payload.Items):
|
||||
// - Each referenced cart line must exist.
|
||||
// - None of the referenced items may already belong to a delivery.
|
||||
// - Only those items are associated with the new delivery.
|
||||
// - If payload.Items is empty:
|
||||
// - All items currently without any delivery are associated with the new delivery.
|
||||
// - A new delivery line is created with:
|
||||
// - Auto-incremented delivery ID (cart-local)
|
||||
// - Provider from payload
|
||||
// - Fixed price (currently hard-coded: 4900 minor units) – adjust as needed
|
||||
// - Optional PickupPoint copied from payload
|
||||
// - Cart totals are recalculated (WithTotals)
|
||||
//
|
||||
// Error cases:
|
||||
// - Referenced item does not exist
|
||||
// - Referenced item already has a delivery
|
||||
// - No items qualify (resulting association set empty) -> returns error (prevents creating empty delivery)
|
||||
//
|
||||
// Concurrency:
|
||||
// - Uses g.mu to protect lastDeliveryId increment and append to Deliveries slice.
|
||||
// Item scans are read-only and performed outside the lock for simplicity;
|
||||
// if stricter guarantees are needed, widen the lock section.
|
||||
//
|
||||
// Future extension points:
|
||||
// - Variable delivery pricing (based on weight, distance, provider, etc.)
|
||||
// - Validation of provider codes
|
||||
// - Multi-currency delivery pricing
|
||||
func init() {
|
||||
RegisterMutation[messages.SetDelivery](
|
||||
"SetDelivery",
|
||||
func(g *CartGrain, m *messages.SetDelivery) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("SetDelivery: nil payload")
|
||||
}
|
||||
if m.Provider == "" {
|
||||
return fmt.Errorf("SetDelivery: provider is empty")
|
||||
}
|
||||
|
||||
withDelivery := g.ItemsWithDelivery()
|
||||
targetItems := make([]int, 0)
|
||||
|
||||
if len(m.Items) == 0 {
|
||||
// Use every item currently without a delivery
|
||||
targetItems = append(targetItems, g.ItemsWithoutDelivery()...)
|
||||
} else {
|
||||
// Validate explicit list
|
||||
for _, id64 := range m.Items {
|
||||
id := int(id64)
|
||||
found := false
|
||||
for _, it := range g.Items {
|
||||
if it.Id == id {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("SetDelivery: item id %d not found", id)
|
||||
}
|
||||
if slices.Contains(withDelivery, id) {
|
||||
return fmt.Errorf("SetDelivery: item id %d already has a delivery", id)
|
||||
}
|
||||
targetItems = append(targetItems, id)
|
||||
}
|
||||
}
|
||||
|
||||
if len(targetItems) == 0 {
|
||||
return fmt.Errorf("SetDelivery: no eligible items to attach")
|
||||
}
|
||||
|
||||
// Append new delivery
|
||||
g.mu.Lock()
|
||||
g.lastDeliveryId++
|
||||
newId := g.lastDeliveryId
|
||||
g.Deliveries = append(g.Deliveries, &CartDelivery{
|
||||
Id: newId,
|
||||
Provider: m.Provider,
|
||||
PickupPoint: m.PickupPoint,
|
||||
Price: 4900, // TODO: externalize pricing
|
||||
Items: targetItems,
|
||||
})
|
||||
g.mu.Unlock()
|
||||
|
||||
return nil
|
||||
},
|
||||
WithTotals(),
|
||||
)
|
||||
}
|
||||
56
mutation_set_pickup_point.go
Normal file
56
mutation_set_pickup_point.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// mutation_set_pickup_point.go
|
||||
//
|
||||
// Registers the SetPickupPoint mutation using the generic mutation registry.
|
||||
//
|
||||
// Semantics (mirrors original switch-based implementation):
|
||||
// - Locate the delivery with Id == payload.DeliveryId
|
||||
// - Set (or overwrite) its PickupPoint with the provided data
|
||||
// - Does NOT alter pricing or taxes (so no totals recalculation required)
|
||||
//
|
||||
// Validation / Error Handling:
|
||||
// - If payload is nil -> error
|
||||
// - If DeliveryId not found -> error
|
||||
//
|
||||
// Concurrency:
|
||||
// - Relies on the existing expectation that higher-level mutation routing
|
||||
// serializes Apply() calls per grain; if stricter guarantees are needed,
|
||||
// a delivery-level lock could be introduced later.
|
||||
//
|
||||
// Future Extensions:
|
||||
// - Validate pickup point fields (country code, zip format, etc.)
|
||||
// - Track history / audit of pickup point changes
|
||||
// - Trigger delivery price adjustments (which would then require WithTotals()).
|
||||
func init() {
|
||||
RegisterMutation[messages.SetPickupPoint](
|
||||
"SetPickupPoint",
|
||||
func(g *CartGrain, m *messages.SetPickupPoint) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("SetPickupPoint: nil payload")
|
||||
}
|
||||
|
||||
for _, d := range g.Deliveries {
|
||||
if d.Id == int(m.DeliveryId) {
|
||||
d.PickupPoint = &messages.PickupPoint{
|
||||
Id: m.Id,
|
||||
Name: m.Name,
|
||||
Address: m.Address,
|
||||
City: m.City,
|
||||
Zip: m.Zip,
|
||||
Country: m.Country,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("SetPickupPoint: delivery id %d not found", m.DeliveryId)
|
||||
},
|
||||
// No WithTotals(): pickup point does not change pricing / tax.
|
||||
)
|
||||
}
|
||||
235
pool-server.go
235
pool-server.go
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -24,21 +25,26 @@ func NewPoolServer(pool GrainPool, pod_name string) *PoolServer {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PoolServer) process(id CartId, mutation interface{}) (*messages.CartState, error) {
|
||||
grain, err := s.pool.Apply(id, mutation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ToCartState(grain), nil
|
||||
}
|
||||
|
||||
func (s *PoolServer) HandleGet(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
data, err := s.pool.Get(id)
|
||||
grain, err := s.pool.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.WriteResult(w, data)
|
||||
return s.WriteResult(w, ToCartState(grain))
|
||||
}
|
||||
|
||||
func (s *PoolServer) HandleAddSku(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
sku := r.PathValue("sku")
|
||||
data, err := s.pool.Process(id, Message{
|
||||
Type: AddRequestType,
|
||||
Content: &messages.AddRequest{Sku: sku, Quantity: 1},
|
||||
})
|
||||
data, err := s.process(id, &messages.AddRequest{Sku: sku, Quantity: 1})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -56,23 +62,20 @@ func ErrorHandler(fn func(w http.ResponseWriter, r *http.Request) error) func(w
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PoolServer) WriteResult(w http.ResponseWriter, result *FrameWithPayload) error {
|
||||
func (s *PoolServer) WriteResult(w http.ResponseWriter, result *messages.CartState) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("X-Pod-Name", s.pod_name)
|
||||
if result.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 +86,7 @@ func (s *PoolServer) HandleDeleteItem(w http.ResponseWriter, r *http.Request, id
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := s.pool.Process(id, Message{
|
||||
Type: RemoveItemType,
|
||||
Content: &messages.RemoveItem{Id: int64(itemId)},
|
||||
})
|
||||
data, err := s.process(id, &messages.RemoveItem{Id: int64(itemId)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -106,13 +106,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.process(id, &messages.SetDelivery{
|
||||
Provider: delivery.Provider,
|
||||
Items: delivery.Items,
|
||||
PickupPoint: delivery.PickupPoint,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -132,17 +129,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.process(id, &messages.SetPickupPoint{
|
||||
DeliveryId: int64(deliveryId),
|
||||
Id: pickupPoint.Id,
|
||||
Name: pickupPoint.Name,
|
||||
Address: pickupPoint.Address,
|
||||
City: pickupPoint.City,
|
||||
Zip: pickupPoint.Zip,
|
||||
Country: pickupPoint.Country,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -157,10 +151,7 @@ func (s *PoolServer) HandleRemoveDelivery(w http.ResponseWriter, r *http.Request
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.pool.Process(id, Message{
|
||||
Type: RemoveDeliveryType,
|
||||
Content: &messages.RemoveDelivery{Id: int64(deliveryId)},
|
||||
})
|
||||
reply, err := s.process(id, &messages.RemoveDelivery{Id: int64(deliveryId)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -173,10 +164,7 @@ func (s *PoolServer) HandleQuantityChange(w http.ResponseWriter, r *http.Request
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.pool.Process(id, Message{
|
||||
Type: ChangeQuantityType,
|
||||
Content: &changeQuantity,
|
||||
})
|
||||
reply, err := s.process(id, &changeQuantity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -189,10 +177,7 @@ func (s *PoolServer) HandleSetCartItems(w http.ResponseWriter, r *http.Request,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.pool.Process(id, Message{
|
||||
Type: SetCartItemsType,
|
||||
Content: &setCartItems,
|
||||
})
|
||||
reply, err := s.process(id, &setCartItems)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -205,10 +190,7 @@ func (s *PoolServer) HandleAddRequest(w http.ResponseWriter, r *http.Request, id
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.pool.Process(id, Message{
|
||||
Type: AddRequestType,
|
||||
Content: &addRequest,
|
||||
})
|
||||
reply, err := s.process(id, &addRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -234,58 +216,115 @@ func (s *PoolServer) HandleConfirmation(w http.ResponseWriter, r *http.Request,
|
||||
return json.NewEncoder(w).Encode(order)
|
||||
}
|
||||
|
||||
func (s *PoolServer) HandleCheckout(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
func (s *PoolServer) CreateOrUpdateCheckout(host string, id CartId) (*CheckoutOrder, error) {
|
||||
meta := &CheckoutMeta{
|
||||
Terms: fmt.Sprintf("https://%s/terms", host),
|
||||
Checkout: fmt.Sprintf("https://%s/checkout?order_id={checkout.order.id}", host),
|
||||
Confirmation: fmt.Sprintf("https://%s/confirmation/{checkout.order.id}", host),
|
||||
Validation: fmt.Sprintf("https://%s/validate", host),
|
||||
Push: fmt.Sprintf("https://%s/push?order_id={checkout.order.id}", host),
|
||||
Country: getCountryFromHost(host),
|
||||
}
|
||||
|
||||
reply, err := s.pool.Process(id, Message{
|
||||
Type: CreateCheckoutOrderType,
|
||||
Content: &messages.CreateCheckoutOrder{
|
||||
Terms: "https://slask-finder.tornberg.me/terms",
|
||||
Checkout: "https://slask-finder.tornberg.me/checkout?order_id={checkout.order.id}",
|
||||
Confirmation: "https://slask-finder.tornberg.me/confirmation/{checkout.order.id}",
|
||||
Push: "https://cart.tornberg.me/push?order_id={checkout.order.id}",
|
||||
},
|
||||
// Get current grain state (may be local or remote)
|
||||
grain, err := s.pool.Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build pure checkout payload
|
||||
payload, _, err := BuildCheckoutOrderPayload(grain, meta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if grain.OrderReference != "" {
|
||||
return KlarnaInstance.UpdateOrder(grain.OrderReference, bytes.NewReader(payload))
|
||||
} else {
|
||||
return KlarnaInstance.CreateOrder(bytes.NewReader(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PoolServer) ApplyCheckoutStarted(klarnaOrder *CheckoutOrder, id CartId) (*CartGrain, error) {
|
||||
// Persist initialization state via mutation (best-effort)
|
||||
return s.pool.Apply(id, &messages.InitializeCheckout{
|
||||
OrderId: klarnaOrder.ID,
|
||||
Status: klarnaOrder.Status,
|
||||
PaymentInProgress: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *PoolServer) HandleCheckout(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
klarnaOrder, err := s.CreateOrUpdateCheckout(r.Host, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if reply.StatusCode != 200 {
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
// 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))
|
||||
// Deprecated: legacy random/time based cart id generator.
|
||||
// Retained for compatibility; new code should prefer canonical CartID path.
|
||||
cid, err := NewCartID()
|
||||
if err != nil {
|
||||
// Fallback to legacy method only if crypto/rand fails
|
||||
id := time.Now().UnixNano() + rand.Int63()
|
||||
return ToCartId(fmt.Sprintf("%d", id))
|
||||
}
|
||||
return CartIDToLegacy(cid)
|
||||
}
|
||||
|
||||
func CookieCartIdHandler(fn func(w http.ResponseWriter, r *http.Request, cartId CartId) error) func(w http.ResponseWriter, r *http.Request) error {
|
||||
return func(w http.ResponseWriter, r *http.Request) error {
|
||||
var cartId CartId
|
||||
cartIdCookie := r.CookiesNamed("cartid")
|
||||
if cartIdCookie == nil || len(cartIdCookie) == 0 {
|
||||
cartId = NewCartId()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "cartid",
|
||||
Value: cartId.String(),
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
Expires: time.Now().AddDate(0, 0, 14),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
// Extract / normalize cookie (preserve legacy textual IDs without rewriting).
|
||||
var legacy CartId
|
||||
cookies := r.CookiesNamed("cartid")
|
||||
if len(cookies) == 0 {
|
||||
// No cookie -> generate new canonical base62 id.
|
||||
cid, generated, _, err := CanonicalizeOrLegacy("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate cart id: %w", err)
|
||||
}
|
||||
legacy = CartIDToLegacy(cid)
|
||||
if generated {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "cartid",
|
||||
Value: cid.String(),
|
||||
Secure: r.TLS != nil,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
Expires: time.Now().AddDate(0, 0, 14),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
w.Header().Set("Set-Cart-Id", cid.String())
|
||||
}
|
||||
} else {
|
||||
cartId = ToCartId(cartIdCookie[0].Value)
|
||||
raw := cookies[0].Value
|
||||
cid, generated, wasBase62, err := CanonicalizeOrLegacy(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to canonicalize cart id: %w", err)
|
||||
}
|
||||
legacy = CartIDToLegacy(cid)
|
||||
// Only set a new cookie if we actually generated a brand-new ID (empty input).
|
||||
// For legacy (non-base62) ids we preserve the original text and do not overwrite.
|
||||
if generated && wasBase62 {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "cartid",
|
||||
Value: cid.String(),
|
||||
Secure: r.TLS != nil,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
Expires: time.Now().AddDate(0, 0, 14),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
w.Header().Set("Set-Cart-Id", cid.String())
|
||||
}
|
||||
}
|
||||
return fn(w, r, cartId)
|
||||
return fn(w, r, legacy)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,7 +334,7 @@ func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, ca
|
||||
Name: "cartid",
|
||||
Value: cartId.String(),
|
||||
Path: "/",
|
||||
Secure: true,
|
||||
Secure: r.TLS != nil,
|
||||
HttpOnly: true,
|
||||
Expires: time.Unix(0, 0),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
@@ -306,8 +345,18 @@ func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, ca
|
||||
|
||||
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"))
|
||||
return fn(w, r, cartId)
|
||||
raw := r.PathValue("id")
|
||||
cid, generated, wasBase62, err := CanonicalizeOrLegacy(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid cart id: %w", err)
|
||||
}
|
||||
legacy := CartIDToLegacy(cid)
|
||||
// Only emit Set-Cart-Id header if we produced a brand-new canonical id
|
||||
// AND it is base62 (avoid rewriting legacy textual identifiers).
|
||||
if generated && wasBase62 {
|
||||
w.Header().Set("Set-Cart-Id", cid.String())
|
||||
}
|
||||
return fn(w, r, legacy)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,88 +2,186 @@ syntax = "proto3";
|
||||
|
||||
package messages;
|
||||
|
||||
option go_package = ".;messages";
|
||||
option go_package = "git.tornberg.me/go-cart-actor/proto;messages";
|
||||
|
||||
import "messages.proto";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Cart Actor gRPC API (Envelope Variant)
|
||||
// Cart Actor gRPC API (Breaking v2 - Per-Mutation RPCs)
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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.
|
||||
// This version removes the previous MutationEnvelope + Mutate RPC.
|
||||
// Each mutation now has its own request wrapper and dedicated RPC method
|
||||
// providing simpler, type-focused client stubs and enabling per-mutation
|
||||
// metrics, auth and rate limiting.
|
||||
//
|
||||
// Regenerate Go code after editing:
|
||||
// protoc --go_out=. --go_opt=paths=source_relative \
|
||||
// --go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
||||
// proto/cart_actor.proto proto/messages.proto
|
||||
//
|
||||
// Backward compatibility: This is a breaking change (old clients must update).
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// 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;
|
||||
// Shared reply for all mutation RPCs.
|
||||
message CartMutationReply {
|
||||
int32 status_code = 1; // HTTP-like status (200 success, 4xx client, 5xx server)
|
||||
oneof result {
|
||||
CartState state = 2; // Updated cart state on success
|
||||
string error = 3; // Error message on failure
|
||||
}
|
||||
int64 server_timestamp = 4; // Server-assigned Unix timestamp (optional auditing)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Fetch 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
|
||||
oneof result {
|
||||
CartState state = 2;
|
||||
string error = 3;
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
// Per-mutation request wrappers. We wrap the existing inner mutation
|
||||
// messages (defined in messages.proto) to add cart_id + optional metadata
|
||||
// without altering the inner message definitions.
|
||||
|
||||
message AddRequestRequest {
|
||||
string cart_id = 1;
|
||||
int64 client_timestamp = 2;
|
||||
AddRequest payload = 10;
|
||||
}
|
||||
|
||||
message AddItemRequest {
|
||||
string cart_id = 1;
|
||||
int64 client_timestamp = 2;
|
||||
AddItem payload = 10;
|
||||
}
|
||||
|
||||
message RemoveItemRequest {
|
||||
string cart_id = 1;
|
||||
int64 client_timestamp = 2;
|
||||
RemoveItem payload = 10;
|
||||
}
|
||||
|
||||
message RemoveDeliveryRequest {
|
||||
string cart_id = 1;
|
||||
int64 client_timestamp = 2;
|
||||
RemoveDelivery payload = 10;
|
||||
}
|
||||
|
||||
message ChangeQuantityRequest {
|
||||
string cart_id = 1;
|
||||
int64 client_timestamp = 2;
|
||||
ChangeQuantity payload = 10;
|
||||
}
|
||||
|
||||
message SetDeliveryRequest {
|
||||
string cart_id = 1;
|
||||
int64 client_timestamp = 2;
|
||||
SetDelivery payload = 10;
|
||||
}
|
||||
|
||||
message SetPickupPointRequest {
|
||||
string cart_id = 1;
|
||||
int64 client_timestamp = 2;
|
||||
SetPickupPoint payload = 10;
|
||||
}
|
||||
|
||||
message CreateCheckoutOrderRequest {
|
||||
string cart_id = 1;
|
||||
int64 client_timestamp = 2;
|
||||
CreateCheckoutOrder payload = 10;
|
||||
}
|
||||
|
||||
message SetCartItemsRequest {
|
||||
string cart_id = 1;
|
||||
int64 client_timestamp = 2;
|
||||
SetCartRequest payload = 10;
|
||||
}
|
||||
|
||||
message OrderCompletedRequest {
|
||||
string cart_id = 1;
|
||||
int64 client_timestamp = 2;
|
||||
OrderCreated payload = 10;
|
||||
}
|
||||
|
||||
// Excerpt: updated messages for camelCase JSON output
|
||||
message CartState {
|
||||
string id = 1; // was cart_id
|
||||
repeated CartItemState items = 2;
|
||||
int64 totalPrice = 3; // was total_price
|
||||
int64 totalTax = 4; // was total_tax
|
||||
int64 totalDiscount = 5; // was total_discount
|
||||
repeated DeliveryState deliveries = 6;
|
||||
bool paymentInProgress = 7; // was payment_in_progress
|
||||
string orderReference = 8; // was order_reference
|
||||
string paymentStatus = 9; // was payment_status
|
||||
bool processing = 10; // NEW (mirrors legacy CartGrain.processing)
|
||||
}
|
||||
|
||||
message CartItemState {
|
||||
int64 id = 1;
|
||||
int64 itemId = 2; // was source_item_id
|
||||
string sku = 3;
|
||||
string name = 4;
|
||||
int64 price = 5; // was unit_price
|
||||
int32 qty = 6; // was quantity
|
||||
int64 totalPrice = 7; // was total_price
|
||||
int64 totalTax = 8; // was total_tax
|
||||
int64 orgPrice = 9; // was org_price
|
||||
int32 taxRate = 10; // was tax_rate
|
||||
int64 totalDiscount = 11;
|
||||
string brand = 12;
|
||||
string category = 13;
|
||||
string category2 = 14;
|
||||
string category3 = 15;
|
||||
string category4 = 16;
|
||||
string category5 = 17;
|
||||
string image = 18;
|
||||
string type = 19; // was article_type
|
||||
string sellerId = 20; // was seller_id
|
||||
string sellerName = 21; // was seller_name
|
||||
string disclaimer = 22;
|
||||
string outlet = 23;
|
||||
string storeId = 24; // was store_id
|
||||
int32 stock = 25;
|
||||
}
|
||||
|
||||
message DeliveryState {
|
||||
int64 id = 1;
|
||||
string provider = 2;
|
||||
int64 price = 3;
|
||||
repeated int64 items = 4; // was item_ids
|
||||
PickupPoint pickupPoint = 5; // was pickup_point
|
||||
}
|
||||
|
||||
// (CheckoutRequest / CheckoutReply removed - checkout handled at HTTP layer)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Service definition (per-mutation RPCs + checkout)
|
||||
// -----------------------------------------------------------------------------
|
||||
service CartActor {
|
||||
rpc AddRequest(AddRequestRequest) returns (CartMutationReply);
|
||||
rpc AddItem(AddItemRequest) returns (CartMutationReply);
|
||||
rpc RemoveItem(RemoveItemRequest) returns (CartMutationReply);
|
||||
rpc RemoveDelivery(RemoveDeliveryRequest) returns (CartMutationReply);
|
||||
rpc ChangeQuantity(ChangeQuantityRequest) returns (CartMutationReply);
|
||||
rpc SetDelivery(SetDeliveryRequest) returns (CartMutationReply);
|
||||
rpc SetPickupPoint(SetPickupPointRequest) returns (CartMutationReply);
|
||||
// (Checkout RPC removed - handled externally)
|
||||
rpc SetCartItems(SetCartItemsRequest) returns (CartMutationReply);
|
||||
rpc OrderCompleted(OrderCompletedRequest) returns (CartMutationReply);
|
||||
|
||||
// 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.
|
||||
// Future enhancements:
|
||||
// * BatchMutate RPC (repeated heterogeneous mutations)
|
||||
// * Streaming state updates (WatchState)
|
||||
// * Versioning / optimistic concurrency control
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -19,19 +19,36 @@ import (
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
CartActor_Mutate_FullMethodName = "/messages.CartActor/Mutate"
|
||||
CartActor_GetState_FullMethodName = "/messages.CartActor/GetState"
|
||||
CartActor_AddRequest_FullMethodName = "/messages.CartActor/AddRequest"
|
||||
CartActor_AddItem_FullMethodName = "/messages.CartActor/AddItem"
|
||||
CartActor_RemoveItem_FullMethodName = "/messages.CartActor/RemoveItem"
|
||||
CartActor_RemoveDelivery_FullMethodName = "/messages.CartActor/RemoveDelivery"
|
||||
CartActor_ChangeQuantity_FullMethodName = "/messages.CartActor/ChangeQuantity"
|
||||
CartActor_SetDelivery_FullMethodName = "/messages.CartActor/SetDelivery"
|
||||
CartActor_SetPickupPoint_FullMethodName = "/messages.CartActor/SetPickupPoint"
|
||||
CartActor_SetCartItems_FullMethodName = "/messages.CartActor/SetCartItems"
|
||||
CartActor_OrderCompleted_FullMethodName = "/messages.CartActor/OrderCompleted"
|
||||
CartActor_GetState_FullMethodName = "/messages.CartActor/GetState"
|
||||
)
|
||||
|
||||
// CartActorClient is the client API for CartActor service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
//
|
||||
// CartActor exposes mutation and state retrieval for remote grains.
|
||||
// -----------------------------------------------------------------------------
|
||||
// Service definition (per-mutation RPCs + checkout)
|
||||
// -----------------------------------------------------------------------------
|
||||
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).
|
||||
AddRequest(ctx context.Context, in *AddRequestRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||
AddItem(ctx context.Context, in *AddItemRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||
RemoveItem(ctx context.Context, in *RemoveItemRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||
RemoveDelivery(ctx context.Context, in *RemoveDeliveryRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||
ChangeQuantity(ctx context.Context, in *ChangeQuantityRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||
SetDelivery(ctx context.Context, in *SetDeliveryRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||
SetPickupPoint(ctx context.Context, in *SetPickupPointRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||
// (Checkout RPC removed - handled externally)
|
||||
SetCartItems(ctx context.Context, in *SetCartItemsRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||
OrderCompleted(ctx context.Context, in *OrderCompletedRequest, opts ...grpc.CallOption) (*CartMutationReply, error)
|
||||
GetState(ctx context.Context, in *StateRequest, opts ...grpc.CallOption) (*StateReply, error)
|
||||
}
|
||||
|
||||
@@ -43,10 +60,90 @@ func NewCartActorClient(cc grpc.ClientConnInterface) CartActorClient {
|
||||
return &cartActorClient{cc}
|
||||
}
|
||||
|
||||
func (c *cartActorClient) Mutate(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*MutationReply, error) {
|
||||
func (c *cartActorClient) AddRequest(ctx context.Context, in *AddRequestRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(MutationReply)
|
||||
err := c.cc.Invoke(ctx, CartActor_Mutate_FullMethodName, in, out, cOpts...)
|
||||
out := new(CartMutationReply)
|
||||
err := c.cc.Invoke(ctx, CartActor_AddRequest_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *cartActorClient) AddItem(ctx context.Context, in *AddItemRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CartMutationReply)
|
||||
err := c.cc.Invoke(ctx, CartActor_AddItem_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *cartActorClient) RemoveItem(ctx context.Context, in *RemoveItemRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CartMutationReply)
|
||||
err := c.cc.Invoke(ctx, CartActor_RemoveItem_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *cartActorClient) RemoveDelivery(ctx context.Context, in *RemoveDeliveryRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CartMutationReply)
|
||||
err := c.cc.Invoke(ctx, CartActor_RemoveDelivery_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *cartActorClient) ChangeQuantity(ctx context.Context, in *ChangeQuantityRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CartMutationReply)
|
||||
err := c.cc.Invoke(ctx, CartActor_ChangeQuantity_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *cartActorClient) SetDelivery(ctx context.Context, in *SetDeliveryRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CartMutationReply)
|
||||
err := c.cc.Invoke(ctx, CartActor_SetDelivery_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *cartActorClient) SetPickupPoint(ctx context.Context, in *SetPickupPointRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CartMutationReply)
|
||||
err := c.cc.Invoke(ctx, CartActor_SetPickupPoint_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *cartActorClient) SetCartItems(ctx context.Context, in *SetCartItemsRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CartMutationReply)
|
||||
err := c.cc.Invoke(ctx, CartActor_SetCartItems_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *cartActorClient) OrderCompleted(ctx context.Context, in *OrderCompletedRequest, opts ...grpc.CallOption) (*CartMutationReply, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CartMutationReply)
|
||||
err := c.cc.Invoke(ctx, CartActor_OrderCompleted_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -67,11 +164,20 @@ func (c *cartActorClient) GetState(ctx context.Context, in *StateRequest, opts .
|
||||
// All implementations must embed UnimplementedCartActorServer
|
||||
// for forward compatibility.
|
||||
//
|
||||
// CartActor exposes mutation and state retrieval for remote grains.
|
||||
// -----------------------------------------------------------------------------
|
||||
// Service definition (per-mutation RPCs + checkout)
|
||||
// -----------------------------------------------------------------------------
|
||||
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).
|
||||
AddRequest(context.Context, *AddRequestRequest) (*CartMutationReply, error)
|
||||
AddItem(context.Context, *AddItemRequest) (*CartMutationReply, error)
|
||||
RemoveItem(context.Context, *RemoveItemRequest) (*CartMutationReply, error)
|
||||
RemoveDelivery(context.Context, *RemoveDeliveryRequest) (*CartMutationReply, error)
|
||||
ChangeQuantity(context.Context, *ChangeQuantityRequest) (*CartMutationReply, error)
|
||||
SetDelivery(context.Context, *SetDeliveryRequest) (*CartMutationReply, error)
|
||||
SetPickupPoint(context.Context, *SetPickupPointRequest) (*CartMutationReply, error)
|
||||
// (Checkout RPC removed - handled externally)
|
||||
SetCartItems(context.Context, *SetCartItemsRequest) (*CartMutationReply, error)
|
||||
OrderCompleted(context.Context, *OrderCompletedRequest) (*CartMutationReply, error)
|
||||
GetState(context.Context, *StateRequest) (*StateReply, error)
|
||||
mustEmbedUnimplementedCartActorServer()
|
||||
}
|
||||
@@ -83,8 +189,32 @@ type CartActorServer interface {
|
||||
// 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) AddRequest(context.Context, *AddRequestRequest) (*CartMutationReply, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method AddRequest not implemented")
|
||||
}
|
||||
func (UnimplementedCartActorServer) AddItem(context.Context, *AddItemRequest) (*CartMutationReply, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method AddItem not implemented")
|
||||
}
|
||||
func (UnimplementedCartActorServer) RemoveItem(context.Context, *RemoveItemRequest) (*CartMutationReply, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method RemoveItem not implemented")
|
||||
}
|
||||
func (UnimplementedCartActorServer) RemoveDelivery(context.Context, *RemoveDeliveryRequest) (*CartMutationReply, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method RemoveDelivery not implemented")
|
||||
}
|
||||
func (UnimplementedCartActorServer) ChangeQuantity(context.Context, *ChangeQuantityRequest) (*CartMutationReply, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ChangeQuantity not implemented")
|
||||
}
|
||||
func (UnimplementedCartActorServer) SetDelivery(context.Context, *SetDeliveryRequest) (*CartMutationReply, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SetDelivery not implemented")
|
||||
}
|
||||
func (UnimplementedCartActorServer) SetPickupPoint(context.Context, *SetPickupPointRequest) (*CartMutationReply, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SetPickupPoint not implemented")
|
||||
}
|
||||
func (UnimplementedCartActorServer) SetCartItems(context.Context, *SetCartItemsRequest) (*CartMutationReply, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SetCartItems not implemented")
|
||||
}
|
||||
func (UnimplementedCartActorServer) OrderCompleted(context.Context, *OrderCompletedRequest) (*CartMutationReply, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method OrderCompleted not implemented")
|
||||
}
|
||||
func (UnimplementedCartActorServer) GetState(context.Context, *StateRequest) (*StateReply, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetState not implemented")
|
||||
@@ -110,20 +240,164 @@ func RegisterCartActorServer(s grpc.ServiceRegistrar, srv CartActorServer) {
|
||||
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)
|
||||
func _CartActor_AddRequest_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AddRequestRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(CartActorServer).Mutate(ctx, in)
|
||||
return srv.(CartActorServer).AddRequest(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: CartActor_Mutate_FullMethodName,
|
||||
FullMethod: CartActor_AddRequest_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CartActorServer).Mutate(ctx, req.(*MutationRequest))
|
||||
return srv.(CartActorServer).AddRequest(ctx, req.(*AddRequestRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _CartActor_AddItem_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AddItemRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(CartActorServer).AddItem(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: CartActor_AddItem_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CartActorServer).AddItem(ctx, req.(*AddItemRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _CartActor_RemoveItem_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(RemoveItemRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(CartActorServer).RemoveItem(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: CartActor_RemoveItem_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CartActorServer).RemoveItem(ctx, req.(*RemoveItemRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _CartActor_RemoveDelivery_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(RemoveDeliveryRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(CartActorServer).RemoveDelivery(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: CartActor_RemoveDelivery_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CartActorServer).RemoveDelivery(ctx, req.(*RemoveDeliveryRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _CartActor_ChangeQuantity_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ChangeQuantityRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(CartActorServer).ChangeQuantity(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: CartActor_ChangeQuantity_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CartActorServer).ChangeQuantity(ctx, req.(*ChangeQuantityRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _CartActor_SetDelivery_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SetDeliveryRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(CartActorServer).SetDelivery(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: CartActor_SetDelivery_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CartActorServer).SetDelivery(ctx, req.(*SetDeliveryRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _CartActor_SetPickupPoint_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SetPickupPointRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(CartActorServer).SetPickupPoint(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: CartActor_SetPickupPoint_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CartActorServer).SetPickupPoint(ctx, req.(*SetPickupPointRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _CartActor_SetCartItems_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SetCartItemsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(CartActorServer).SetCartItems(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: CartActor_SetCartItems_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CartActorServer).SetCartItems(ctx, req.(*SetCartItemsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _CartActor_OrderCompleted_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(OrderCompletedRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(CartActorServer).OrderCompleted(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: CartActor_OrderCompleted_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CartActorServer).OrderCompleted(ctx, req.(*OrderCompletedRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
@@ -154,8 +428,40 @@ var CartActor_ServiceDesc = grpc.ServiceDesc{
|
||||
HandlerType: (*CartActorServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "Mutate",
|
||||
Handler: _CartActor_Mutate_Handler,
|
||||
MethodName: "AddRequest",
|
||||
Handler: _CartActor_AddRequest_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AddItem",
|
||||
Handler: _CartActor_AddItem_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "RemoveItem",
|
||||
Handler: _CartActor_RemoveItem_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "RemoveDelivery",
|
||||
Handler: _CartActor_RemoveDelivery_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ChangeQuantity",
|
||||
Handler: _CartActor_ChangeQuantity_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "SetDelivery",
|
||||
Handler: _CartActor_SetDelivery_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "SetPickupPoint",
|
||||
Handler: _CartActor_SetPickupPoint_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "SetCartItems",
|
||||
Handler: _CartActor_SetCartItems_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "OrderCompleted",
|
||||
Handler: _CartActor_OrderCompleted_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetState",
|
||||
|
||||
@@ -246,60 +246,7 @@ func (x *CartIdsReply) GetCartIds() []string {
|
||||
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 {
|
||||
@@ -412,23 +359,18 @@ const file_control_plane_proto_rawDesc = "" +
|
||||
"\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" +
|
||||
"\bcart_ids\x18\x01 \x03(\tR\acartIds\"F\n" +
|
||||
"\x0eOwnerChangeAck\x12\x1a\n" +
|
||||
"\baccepted\x18\x01 \x01(\bR\baccepted\x12\x18\n" +
|
||||
"\amessage\x18\x02 \x01(\tR\amessage\"#\n" +
|
||||
"\rClosingNotice\x12\x12\n" +
|
||||
"\x04host\x18\x01 \x01(\tR\x04host2\xbc\x02\n" +
|
||||
"\x04host\x18\x01 \x01(\tR\x04host2\xf4\x01\n" +
|
||||
"\fControlPlane\x12,\n" +
|
||||
"\x04Ping\x12\x0f.messages.Empty\x1a\x13.messages.PingReply\x12A\n" +
|
||||
"\tNegotiate\x12\x1a.messages.NegotiateRequest\x1a\x18.messages.NegotiateReply\x125\n" +
|
||||
"\n" +
|
||||
"GetCartIds\x12\x0f.messages.Empty\x1a\x16.messages.CartIdsReply\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"
|
||||
"GetCartIds\x12\x0f.messages.Empty\x1a\x16.messages.CartIdsReply\x12<\n" +
|
||||
"\aClosing\x12\x17.messages.ClosingNotice\x1a\x18.messages.OwnerChangeAckB.Z,git.tornberg.me/go-cart-actor/proto;messagesb\x06proto3"
|
||||
|
||||
var (
|
||||
file_control_plane_proto_rawDescOnce sync.Once
|
||||
@@ -442,30 +384,27 @@ 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, 7)
|
||||
var file_control_plane_proto_goTypes = []any{
|
||||
(*Empty)(nil), // 0: messages.Empty
|
||||
(*PingReply)(nil), // 1: messages.PingReply
|
||||
(*NegotiateRequest)(nil), // 2: messages.NegotiateRequest
|
||||
(*NegotiateReply)(nil), // 3: messages.NegotiateReply
|
||||
(*CartIdsReply)(nil), // 4: messages.CartIdsReply
|
||||
(*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
|
||||
(*CartIdsReply)(nil), // 4: messages.CartIdsReply
|
||||
(*OwnerChangeAck)(nil), // 5: messages.OwnerChangeAck
|
||||
(*ClosingNotice)(nil), // 6: messages.ClosingNotice
|
||||
}
|
||||
var file_control_plane_proto_depIdxs = []int32{
|
||||
0, // 0: messages.ControlPlane.Ping:input_type -> messages.Empty
|
||||
2, // 1: messages.ControlPlane.Negotiate:input_type -> messages.NegotiateRequest
|
||||
0, // 2: messages.ControlPlane.GetCartIds:input_type -> messages.Empty
|
||||
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
|
||||
6, // 3: messages.ControlPlane.Closing:input_type -> messages.ClosingNotice
|
||||
1, // 4: messages.ControlPlane.Ping:output_type -> messages.PingReply
|
||||
3, // 5: messages.ControlPlane.Negotiate:output_type -> messages.NegotiateReply
|
||||
4, // 6: messages.ControlPlane.GetCartIds:output_type -> messages.CartIdsReply
|
||||
5, // 7: messages.ControlPlane.Closing:output_type -> messages.OwnerChangeAck
|
||||
4, // [4:8] is the sub-list for method output_type
|
||||
0, // [0:4] is the sub-list for method input_type
|
||||
0, // [0:0] is the sub-list for extension type_name
|
||||
0, // [0:0] is the sub-list for extension extendee
|
||||
0, // [0:0] is the sub-list for field type_name
|
||||
@@ -482,7 +421,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: 7,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@@ -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).
|
||||
@@ -41,13 +41,7 @@ message CartIdsReply {
|
||||
repeated string cart_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;
|
||||
@@ -69,8 +63,7 @@ service ControlPlane {
|
||||
// GetCartIds lists currently owned cart IDs on this node.
|
||||
rpc GetCartIds(Empty) returns (CartIdsReply);
|
||||
|
||||
// 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)
|
||||
|
||||
// Closing announces graceful shutdown so peers can proactively adjust.
|
||||
rpc Closing(ClosingNotice) returns (OwnerChangeAck);
|
||||
|
||||
@@ -19,11 +19,10 @@ 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_GetCartIds_FullMethodName = "/messages.ControlPlane/GetCartIds"
|
||||
ControlPlane_Closing_FullMethodName = "/messages.ControlPlane/Closing"
|
||||
)
|
||||
|
||||
// ControlPlaneClient is the client API for ControlPlane service.
|
||||
@@ -38,8 +37,6 @@ type ControlPlaneClient interface {
|
||||
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)
|
||||
// Closing announces graceful shutdown so peers can proactively adjust.
|
||||
Closing(ctx context.Context, in *ClosingNotice, opts ...grpc.CallOption) (*OwnerChangeAck, error)
|
||||
}
|
||||
@@ -82,16 +79,6 @@ func (c *controlPlaneClient) GetCartIds(ctx context.Context, in *Empty, opts ...
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *controlPlaneClient) ConfirmOwner(ctx context.Context, in *OwnerChangeRequest, 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...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *controlPlaneClient) Closing(ctx context.Context, in *ClosingNotice, opts ...grpc.CallOption) (*OwnerChangeAck, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(OwnerChangeAck)
|
||||
@@ -114,8 +101,6 @@ type ControlPlaneServer interface {
|
||||
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)
|
||||
// Closing announces graceful shutdown so peers can proactively adjust.
|
||||
Closing(context.Context, *ClosingNotice) (*OwnerChangeAck, error)
|
||||
mustEmbedUnimplementedControlPlaneServer()
|
||||
@@ -137,9 +122,6 @@ func (UnimplementedControlPlaneServer) Negotiate(context.Context, *NegotiateRequ
|
||||
func (UnimplementedControlPlaneServer) GetCartIds(context.Context, *Empty) (*CartIdsReply, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetCartIds not implemented")
|
||||
}
|
||||
func (UnimplementedControlPlaneServer) ConfirmOwner(context.Context, *OwnerChangeRequest) (*OwnerChangeAck, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ConfirmOwner not implemented")
|
||||
}
|
||||
func (UnimplementedControlPlaneServer) Closing(context.Context, *ClosingNotice) (*OwnerChangeAck, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Closing not implemented")
|
||||
}
|
||||
@@ -218,24 +200,6 @@ func _ControlPlane_GetCartIds_Handler(srv interface{}, ctx context.Context, dec
|
||||
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)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ControlPlaneServer).ConfirmOwner(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: ControlPlane_ConfirmOwner_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ControlPlaneServer).ConfirmOwner(ctx, req.(*OwnerChangeRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ControlPlane_Closing_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ClosingNotice)
|
||||
if err := dec(in); err != nil {
|
||||
@@ -273,10 +237,6 @@ var ControlPlane_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "GetCartIds",
|
||||
Handler: _ControlPlane_GetCartIds_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ConfirmOwner",
|
||||
Handler: _ControlPlane_ConfirmOwner_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "Closing",
|
||||
Handler: _ControlPlane_Closing_Handler,
|
||||
|
||||
@@ -889,6 +889,102 @@ func (x *OrderCreated) GetStatus() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
type Noop struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *Noop) Reset() {
|
||||
*x = Noop{}
|
||||
mi := &file_messages_proto_msgTypes[11]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *Noop) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*Noop) ProtoMessage() {}
|
||||
|
||||
func (x *Noop) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_messages_proto_msgTypes[11]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use Noop.ProtoReflect.Descriptor instead.
|
||||
func (*Noop) Descriptor() ([]byte, []int) {
|
||||
return file_messages_proto_rawDescGZIP(), []int{11}
|
||||
}
|
||||
|
||||
type InitializeCheckout struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
OrderId string `protobuf:"bytes,1,opt,name=orderId,proto3" json:"orderId,omitempty"`
|
||||
Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"`
|
||||
PaymentInProgress bool `protobuf:"varint,3,opt,name=paymentInProgress,proto3" json:"paymentInProgress,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *InitializeCheckout) Reset() {
|
||||
*x = InitializeCheckout{}
|
||||
mi := &file_messages_proto_msgTypes[12]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *InitializeCheckout) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*InitializeCheckout) ProtoMessage() {}
|
||||
|
||||
func (x *InitializeCheckout) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_messages_proto_msgTypes[12]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use InitializeCheckout.ProtoReflect.Descriptor instead.
|
||||
func (*InitializeCheckout) Descriptor() ([]byte, []int) {
|
||||
return file_messages_proto_rawDescGZIP(), []int{12}
|
||||
}
|
||||
|
||||
func (x *InitializeCheckout) GetOrderId() string {
|
||||
if x != nil {
|
||||
return x.OrderId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *InitializeCheckout) GetStatus() string {
|
||||
if x != nil {
|
||||
return x.Status
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *InitializeCheckout) GetPaymentInProgress() bool {
|
||||
if x != nil {
|
||||
return x.PaymentInProgress
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var File_messages_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_messages_proto_rawDesc = "" +
|
||||
@@ -997,8 +1093,12 @@ const file_messages_proto_rawDesc = "" +
|
||||
"\acountry\x18\x06 \x01(\tR\acountry\"@\n" +
|
||||
"\fOrderCreated\x12\x18\n" +
|
||||
"\aorderId\x18\x01 \x01(\tR\aorderId\x12\x16\n" +
|
||||
"\x06status\x18\x02 \x01(\tR\x06statusB\fZ\n" +
|
||||
".;messagesb\x06proto3"
|
||||
"\x06status\x18\x02 \x01(\tR\x06status\"\x06\n" +
|
||||
"\x04Noop\"t\n" +
|
||||
"\x12InitializeCheckout\x12\x18\n" +
|
||||
"\aorderId\x18\x01 \x01(\tR\aorderId\x12\x16\n" +
|
||||
"\x06status\x18\x02 \x01(\tR\x06status\x12,\n" +
|
||||
"\x11paymentInProgress\x18\x03 \x01(\bR\x11paymentInProgressB.Z,git.tornberg.me/go-cart-actor/proto;messagesb\x06proto3"
|
||||
|
||||
var (
|
||||
file_messages_proto_rawDescOnce sync.Once
|
||||
@@ -1012,7 +1112,7 @@ func file_messages_proto_rawDescGZIP() []byte {
|
||||
return file_messages_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
|
||||
var file_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
|
||||
var file_messages_proto_goTypes = []any{
|
||||
(*AddRequest)(nil), // 0: messages.AddRequest
|
||||
(*SetCartRequest)(nil), // 1: messages.SetCartRequest
|
||||
@@ -1025,6 +1125,8 @@ var file_messages_proto_goTypes = []any{
|
||||
(*RemoveDelivery)(nil), // 8: messages.RemoveDelivery
|
||||
(*CreateCheckoutOrder)(nil), // 9: messages.CreateCheckoutOrder
|
||||
(*OrderCreated)(nil), // 10: messages.OrderCreated
|
||||
(*Noop)(nil), // 11: messages.Noop
|
||||
(*InitializeCheckout)(nil), // 12: messages.InitializeCheckout
|
||||
}
|
||||
var file_messages_proto_depIdxs = []int32{
|
||||
0, // 0: messages.SetCartRequest.items:type_name -> messages.AddRequest
|
||||
@@ -1052,7 +1154,7 @@ func file_messages_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_messages_proto_rawDesc), len(file_messages_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 11,
|
||||
NumMessages: 13,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
syntax = "proto3";
|
||||
package messages;
|
||||
option go_package = ".;messages";
|
||||
option go_package = "git.tornberg.me/go-cart-actor/proto;messages";
|
||||
|
||||
message AddRequest {
|
||||
int32 quantity = 1;
|
||||
@@ -93,3 +93,13 @@ message OrderCreated {
|
||||
string orderId = 1;
|
||||
string status = 2;
|
||||
}
|
||||
|
||||
message Noop {
|
||||
// Intentionally empty - used for ownership acquisition or health pings
|
||||
}
|
||||
|
||||
message InitializeCheckout {
|
||||
string orderId = 1;
|
||||
string status = 2;
|
||||
bool paymentInProgress = 3;
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
// package main
|
||||
|
||||
// import "sync"
|
||||
|
||||
// type RemoteGrainPool struct {
|
||||
// mu sync.RWMutex
|
||||
// Host string
|
||||
// grains map[CartId]*RemoteGrain
|
||||
// }
|
||||
|
||||
// func NewRemoteGrainPool(addr string) *RemoteGrainPool {
|
||||
// return &RemoteGrainPool{
|
||||
// Host: addr,
|
||||
// grains: make(map[CartId]*RemoteGrain),
|
||||
// }
|
||||
// }
|
||||
|
||||
// func (p *RemoteGrainPool) findRemoteGrain(id CartId) *RemoteGrain {
|
||||
// p.mu.RLock()
|
||||
// grain, ok := p.grains[id]
|
||||
// p.mu.RUnlock()
|
||||
// if !ok {
|
||||
// return nil
|
||||
// }
|
||||
// return grain
|
||||
// }
|
||||
|
||||
// func (p *RemoteGrainPool) findOrCreateGrain(id CartId) (*RemoteGrain, error) {
|
||||
// grain := p.findRemoteGrain(id)
|
||||
|
||||
// if grain == nil {
|
||||
// grain, err := NewRemoteGrain(id, p.Host)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// p.mu.Lock()
|
||||
// p.grains[id] = grain
|
||||
// p.mu.Unlock()
|
||||
// }
|
||||
// return grain, nil
|
||||
// }
|
||||
|
||||
// func (p *RemoteGrainPool) Delete(id CartId) {
|
||||
// p.mu.Lock()
|
||||
// delete(p.grains, id)
|
||||
// p.mu.Unlock()
|
||||
// }
|
||||
|
||||
// func (p *RemoteGrainPool) Process(id CartId, messages ...Message) (*FrameWithPayload, error) {
|
||||
// var result *FrameWithPayload
|
||||
// grain, err := p.findOrCreateGrain(id)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// for _, message := range messages {
|
||||
// result, err = grain.HandleMessage(&message, false)
|
||||
// }
|
||||
// return result, err
|
||||
// }
|
||||
|
||||
// func (p *RemoteGrainPool) Get(id CartId) (*FrameWithPayload, error) {
|
||||
// grain, err := p.findOrCreateGrain(id)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// return grain.GetCurrentState()
|
||||
// }
|
||||
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -68,69 +67,264 @@ 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")
|
||||
}
|
||||
// Apply executes a cart mutation via per-mutation RPCs (breaking v2 API)
|
||||
// and returns a *CartGrain reconstructed from the CartMutationReply state.
|
||||
func (g *RemoteGrainGRPC) Apply(content interface{}, isReplay bool) (*CartGrain, error) {
|
||||
if isReplay {
|
||||
// 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
|
||||
if content == nil {
|
||||
return nil, fmt.Errorf("nil mutation content")
|
||||
}
|
||||
|
||||
// Ensure timestamp set (legacy behavior)
|
||||
if message.TimeStamp == nil {
|
||||
ts := time.Now().Unix()
|
||||
message.TimeStamp = &ts
|
||||
ts := time.Now().Unix()
|
||||
|
||||
var invoke func(ctx context.Context) (*proto.CartMutationReply, error)
|
||||
|
||||
switch m := content.(type) {
|
||||
case *proto.AddRequest:
|
||||
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||
return g.client.AddRequest(ctx, &proto.AddRequestRequest{
|
||||
CartId: g.Id.String(),
|
||||
ClientTimestamp: ts,
|
||||
Payload: m,
|
||||
})
|
||||
}
|
||||
case *proto.AddItem:
|
||||
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||
return g.client.AddItem(ctx, &proto.AddItemRequest{
|
||||
CartId: g.Id.String(),
|
||||
ClientTimestamp: ts,
|
||||
Payload: m,
|
||||
})
|
||||
}
|
||||
case *proto.RemoveItem:
|
||||
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||
return g.client.RemoveItem(ctx, &proto.RemoveItemRequest{
|
||||
CartId: g.Id.String(),
|
||||
ClientTimestamp: ts,
|
||||
Payload: m,
|
||||
})
|
||||
}
|
||||
case *proto.RemoveDelivery:
|
||||
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||
return g.client.RemoveDelivery(ctx, &proto.RemoveDeliveryRequest{
|
||||
CartId: g.Id.String(),
|
||||
ClientTimestamp: ts,
|
||||
Payload: m,
|
||||
})
|
||||
}
|
||||
case *proto.ChangeQuantity:
|
||||
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||
return g.client.ChangeQuantity(ctx, &proto.ChangeQuantityRequest{
|
||||
CartId: g.Id.String(),
|
||||
ClientTimestamp: ts,
|
||||
Payload: m,
|
||||
})
|
||||
}
|
||||
case *proto.SetDelivery:
|
||||
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||
return g.client.SetDelivery(ctx, &proto.SetDeliveryRequest{
|
||||
CartId: g.Id.String(),
|
||||
ClientTimestamp: ts,
|
||||
Payload: m,
|
||||
})
|
||||
}
|
||||
case *proto.SetPickupPoint:
|
||||
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||
return g.client.SetPickupPoint(ctx, &proto.SetPickupPointRequest{
|
||||
CartId: g.Id.String(),
|
||||
ClientTimestamp: ts,
|
||||
Payload: m,
|
||||
})
|
||||
}
|
||||
case *proto.CreateCheckoutOrder:
|
||||
return nil, fmt.Errorf("CreateCheckoutOrder deprecated: checkout is handled via HTTP endpoint (HandleCheckout)")
|
||||
case *proto.SetCartRequest:
|
||||
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||
return g.client.SetCartItems(ctx, &proto.SetCartItemsRequest{
|
||||
CartId: g.Id.String(),
|
||||
ClientTimestamp: ts,
|
||||
Payload: m,
|
||||
})
|
||||
}
|
||||
case *proto.OrderCreated:
|
||||
invoke = func(ctx context.Context) (*proto.CartMutationReply, error) {
|
||||
return g.client.OrderCompleted(ctx, &proto.OrderCompletedRequest{
|
||||
CartId: g.Id.String(),
|
||||
ClientTimestamp: ts,
|
||||
Payload: m,
|
||||
})
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported mutation type %T", content)
|
||||
}
|
||||
|
||||
// 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,
|
||||
if invoke == nil {
|
||||
return nil, fmt.Errorf("no invocation mapped for mutation %T", content)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), g.mutateTimeout)
|
||||
defer cancel()
|
||||
|
||||
resp, err := g.client.Mutate(ctx, req)
|
||||
resp, err := invoke(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
frame := MakeFrameWithPayload(RemoteHandleMutationReply, StatusCode(resp.StatusCode), resp.Payload)
|
||||
return &frame, nil
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
if e := resp.GetError(); e != "" {
|
||||
return nil, fmt.Errorf("remote mutation failed %d: %s", resp.StatusCode, e)
|
||||
}
|
||||
return nil, fmt.Errorf("remote mutation failed %d", resp.StatusCode)
|
||||
}
|
||||
state := resp.GetState()
|
||||
if state == nil {
|
||||
return nil, fmt.Errorf("mutation reply missing state on success")
|
||||
}
|
||||
// Reconstruct a lightweight CartGrain (only fields we expose internally)
|
||||
grain := &CartGrain{
|
||||
Id: ToCartId(state.Id),
|
||||
TotalPrice: state.TotalPrice,
|
||||
TotalTax: state.TotalTax,
|
||||
TotalDiscount: state.TotalDiscount,
|
||||
PaymentInProgress: state.PaymentInProgress,
|
||||
OrderReference: state.OrderReference,
|
||||
PaymentStatus: state.PaymentStatus,
|
||||
}
|
||||
// Items
|
||||
for _, it := range state.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
outlet := toPtr(it.Outlet)
|
||||
storeId := toPtr(it.StoreId)
|
||||
grain.Items = append(grain.Items, &CartItem{
|
||||
Id: int(it.Id),
|
||||
ItemId: int(it.ItemId),
|
||||
Sku: it.Sku,
|
||||
Name: it.Name,
|
||||
Price: it.Price,
|
||||
Quantity: int(it.Qty),
|
||||
TotalPrice: it.TotalPrice,
|
||||
TotalTax: it.TotalTax,
|
||||
OrgPrice: it.OrgPrice,
|
||||
TaxRate: int(it.TaxRate),
|
||||
Brand: it.Brand,
|
||||
Category: it.Category,
|
||||
Category2: it.Category2,
|
||||
Category3: it.Category3,
|
||||
Category4: it.Category4,
|
||||
Category5: it.Category5,
|
||||
Image: it.Image,
|
||||
ArticleType: it.Type,
|
||||
SellerId: it.SellerId,
|
||||
SellerName: it.SellerName,
|
||||
Disclaimer: it.Disclaimer,
|
||||
Outlet: outlet,
|
||||
StoreId: storeId,
|
||||
Stock: StockStatus(it.Stock),
|
||||
})
|
||||
}
|
||||
// Deliveries
|
||||
for _, d := range state.Deliveries {
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
intIds := make([]int, 0, len(d.Items))
|
||||
for _, id := range d.Items {
|
||||
intIds = append(intIds, int(id))
|
||||
}
|
||||
grain.Deliveries = append(grain.Deliveries, &CartDelivery{
|
||||
Id: int(d.Id),
|
||||
Provider: d.Provider,
|
||||
Price: d.Price,
|
||||
Items: intIds,
|
||||
PickupPoint: d.PickupPoint,
|
||||
})
|
||||
}
|
||||
|
||||
return grain, nil
|
||||
}
|
||||
|
||||
// GetCurrentState calls CartActor.GetState and returns a FrameWithPayload
|
||||
// shaped like the legacy RemoteGetStateReply.
|
||||
func (g *RemoteGrainGRPC) GetCurrentState() (*FrameWithPayload, error) {
|
||||
// GetCurrentState retrieves the current cart state using the typed StateReply oneof.
|
||||
func (g *RemoteGrainGRPC) GetCurrentState() (*CartGrain, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), g.stateTimeout)
|
||||
defer cancel()
|
||||
|
||||
resp, err := g.client.GetState(ctx, &proto.StateRequest{
|
||||
CartId: g.Id.String(),
|
||||
})
|
||||
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
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
if e := resp.GetError(); e != "" {
|
||||
return nil, fmt.Errorf("remote get state failed %d: %s", resp.StatusCode, e)
|
||||
}
|
||||
return nil, fmt.Errorf("remote get state failed %d", resp.StatusCode)
|
||||
}
|
||||
state := resp.GetState()
|
||||
if state == nil {
|
||||
return nil, fmt.Errorf("state reply missing state on success")
|
||||
}
|
||||
grain := &CartGrain{
|
||||
Id: ToCartId(state.Id),
|
||||
TotalPrice: state.TotalPrice,
|
||||
TotalTax: state.TotalTax,
|
||||
TotalDiscount: state.TotalDiscount,
|
||||
PaymentInProgress: state.PaymentInProgress,
|
||||
OrderReference: state.OrderReference,
|
||||
PaymentStatus: state.PaymentStatus,
|
||||
}
|
||||
for _, it := range state.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
outlet := toPtr(it.Outlet)
|
||||
storeId := toPtr(it.StoreId)
|
||||
grain.Items = append(grain.Items, &CartItem{
|
||||
Id: int(it.Id),
|
||||
ItemId: int(it.ItemId),
|
||||
Sku: it.Sku,
|
||||
Name: it.Name,
|
||||
Price: it.Price,
|
||||
Quantity: int(it.Qty),
|
||||
TotalPrice: it.TotalPrice,
|
||||
TotalTax: it.TotalTax,
|
||||
OrgPrice: it.OrgPrice,
|
||||
TaxRate: int(it.TaxRate),
|
||||
Brand: it.Brand,
|
||||
Category: it.Category,
|
||||
Category2: it.Category2,
|
||||
Category3: it.Category3,
|
||||
Category4: it.Category4,
|
||||
Category5: it.Category5,
|
||||
Image: it.Image,
|
||||
ArticleType: it.Type,
|
||||
SellerId: it.SellerId,
|
||||
SellerName: it.SellerName,
|
||||
Disclaimer: it.Disclaimer,
|
||||
Outlet: outlet,
|
||||
StoreId: storeId,
|
||||
Stock: StockStatus(it.Stock),
|
||||
})
|
||||
}
|
||||
for _, d := range state.Deliveries {
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
intIds := make([]int, 0, len(d.Items))
|
||||
for _, id := range d.Items {
|
||||
intIds = append(intIds, int(id))
|
||||
}
|
||||
grain.Deliveries = append(grain.Deliveries, &CartDelivery{
|
||||
Id: int(d.Id),
|
||||
Provider: d.Provider,
|
||||
Price: d.Price,
|
||||
Items: intIds,
|
||||
PickupPoint: d.PickupPoint,
|
||||
})
|
||||
}
|
||||
|
||||
return grain, nil
|
||||
}
|
||||
|
||||
// Close closes the underlying gRPC connection if this adapter created it.
|
||||
|
||||
344
ring.go
Normal file
344
ring.go
Normal file
@@ -0,0 +1,344 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ring.go
|
||||
//
|
||||
// Consistent hashing ring skeleton for future integration.
|
||||
// --------------------------------------------------------
|
||||
// This file introduces a minimal, allocation‑light consistent hashing structure
|
||||
// intended to replace per-cart ownership negotiation. It focuses on:
|
||||
// * Deterministic lookup: O(log V) via binary search
|
||||
// * Even(ish) distribution using virtual nodes (vnodes)
|
||||
// * Epoch / fingerprint tracking to detect membership drift
|
||||
//
|
||||
// NOT YET WIRED:
|
||||
// * SyncedPool integration (ownerForCart, lazy migration)
|
||||
// * Replication factor > 1
|
||||
// * Persistent state migration
|
||||
//
|
||||
// Safe to import now; unused until explicit integration code is added.
|
||||
//
|
||||
// Design Notes
|
||||
// ------------
|
||||
// - Hosts contribute `vnodesPerHost` virtual nodes. Higher counts smooth
|
||||
// distribution at cost of memory (V = hosts * vnodesPerHost).
|
||||
// - Hash of vnode = FNV1a64(host + "#" + index). For improved quality you
|
||||
// can swap in xxhash or siphash later without changing API (but doing so
|
||||
// will reshuffle ownership).
|
||||
// - Cart ownership lookup uses either cartID.Raw() when provided (uniform
|
||||
// 64-bit space) or falls back to hashing string forms (legacy).
|
||||
// - Epoch is monotonically increasing; consumers can fence stale results.
|
||||
//
|
||||
// Future Extensions
|
||||
// -----------------
|
||||
// - Weighted hosts (proportionally more vnodes).
|
||||
// - Replication: LookupN(h, n) to return primary + replicas.
|
||||
// - Streaming / diff-based ring updates (gossip).
|
||||
// - Hash function injection for deterministic test scenarios.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Vnode represents a single virtual node position on the ring.
|
||||
type Vnode struct {
|
||||
Hash uint64 // position on the ring
|
||||
Host string // physical host owning this vnode
|
||||
Index int // per-host vnode index (0..vnodesPerHost-1)
|
||||
}
|
||||
|
||||
// Ring is an immutable consistent hash ring snapshot.
|
||||
type Ring struct {
|
||||
Epoch uint64
|
||||
Vnodes []Vnode // sorted by Hash
|
||||
hosts []string
|
||||
fingerprint uint64 // membership fingerprint (order-independent)
|
||||
}
|
||||
|
||||
// RingBuilder accumulates parameters to construct a Ring.
|
||||
type RingBuilder struct {
|
||||
epoch uint64
|
||||
vnodesPerHost int
|
||||
hosts []string
|
||||
}
|
||||
|
||||
// NewRingBuilder creates a builder with defaults.
|
||||
func NewRingBuilder() *RingBuilder {
|
||||
return &RingBuilder{
|
||||
vnodesPerHost: 64, // a reasonable default for small clusters
|
||||
}
|
||||
}
|
||||
|
||||
func (b *RingBuilder) WithEpoch(e uint64) *RingBuilder {
|
||||
b.epoch = e
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *RingBuilder) WithVnodesPerHost(n int) *RingBuilder {
|
||||
if n > 0 {
|
||||
b.vnodesPerHost = n
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *RingBuilder) WithHosts(hosts []string) *RingBuilder {
|
||||
uniq := make(map[string]struct{}, len(hosts))
|
||||
out := make([]string, 0, len(hosts))
|
||||
for _, h := range hosts {
|
||||
h = strings.TrimSpace(h)
|
||||
if h == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := uniq[h]; ok {
|
||||
continue
|
||||
}
|
||||
uniq[h] = struct{}{}
|
||||
out = append(out, h)
|
||||
}
|
||||
sort.Strings(out)
|
||||
b.hosts = out
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *RingBuilder) Build() *Ring {
|
||||
if len(b.hosts) == 0 {
|
||||
return &Ring{
|
||||
Epoch: b.epoch,
|
||||
Vnodes: nil,
|
||||
hosts: nil,
|
||||
fingerprint: 0,
|
||||
}
|
||||
}
|
||||
|
||||
totalVnodes := len(b.hosts) * b.vnodesPerHost
|
||||
vnodes := make([]Vnode, 0, totalVnodes)
|
||||
|
||||
for _, host := range b.hosts {
|
||||
for i := 0; i < b.vnodesPerHost; i++ {
|
||||
h := hashVnode(host, i)
|
||||
vnodes = append(vnodes, Vnode{
|
||||
Hash: h,
|
||||
Host: host,
|
||||
Index: i,
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.Slice(vnodes, func(i, j int) bool {
|
||||
if vnodes[i].Hash == vnodes[j].Hash {
|
||||
// Tie-break deterministically by host then index to avoid instability
|
||||
if vnodes[i].Host == vnodes[j].Host {
|
||||
return vnodes[i].Index < vnodes[j].Index
|
||||
}
|
||||
return vnodes[i].Host < vnodes[j].Host
|
||||
}
|
||||
return vnodes[i].Hash < vnodes[j].Hash
|
||||
})
|
||||
|
||||
fp := fingerprintHosts(b.hosts)
|
||||
|
||||
return &Ring{
|
||||
Epoch: b.epoch,
|
||||
Vnodes: vnodes,
|
||||
hosts: append([]string(nil), b.hosts...),
|
||||
fingerprint: fp,
|
||||
}
|
||||
}
|
||||
|
||||
// Hosts returns a copy of the host list (sorted).
|
||||
func (r *Ring) Hosts() []string {
|
||||
if len(r.hosts) == 0 {
|
||||
return nil
|
||||
}
|
||||
cp := make([]string, len(r.hosts))
|
||||
copy(cp, r.hosts)
|
||||
return cp
|
||||
}
|
||||
|
||||
// Fingerprint returns a hash representing the unordered membership set.
|
||||
func (r *Ring) Fingerprint() uint64 {
|
||||
return r.fingerprint
|
||||
}
|
||||
|
||||
// Empty indicates ring has no vnodes.
|
||||
func (r *Ring) Empty() bool {
|
||||
return len(r.Vnodes) == 0
|
||||
}
|
||||
|
||||
// Lookup returns the vnode owning a given hash value.
|
||||
func (r *Ring) Lookup(h uint64) Vnode {
|
||||
if len(r.Vnodes) == 0 {
|
||||
return Vnode{}
|
||||
}
|
||||
// Binary search: first position with Hash >= h
|
||||
i := sort.Search(len(r.Vnodes), func(i int) bool {
|
||||
return r.Vnodes[i].Hash >= h
|
||||
})
|
||||
if i == len(r.Vnodes) {
|
||||
return r.Vnodes[0]
|
||||
}
|
||||
return r.Vnodes[i]
|
||||
}
|
||||
|
||||
// LookupID selects owner vnode for a CartID (fast path).
|
||||
func (r *Ring) LookupID(id CartID) Vnode {
|
||||
return r.Lookup(id.Raw())
|
||||
}
|
||||
|
||||
// LookupString hashes an arbitrary string and looks up owner.
|
||||
func (r *Ring) LookupString(s string) Vnode {
|
||||
return r.Lookup(hashKeyString(s))
|
||||
}
|
||||
|
||||
// LookupN returns up to n distinct host vnodes in ring order
|
||||
// starting from the primary owner of hash h (for replication).
|
||||
func (r *Ring) LookupN(h uint64, n int) []Vnode {
|
||||
if n <= 0 || len(r.Vnodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
if n > len(r.hosts) {
|
||||
n = len(r.hosts)
|
||||
}
|
||||
owners := make([]Vnode, 0, n)
|
||||
seen := make(map[string]struct{}, n)
|
||||
|
||||
start := r.Lookup(h)
|
||||
|
||||
// Find index of start (can binary search again or linear scan; since we
|
||||
// already have start.Hash we do another search for clarity)
|
||||
i := sort.Search(len(r.Vnodes), func(i int) bool {
|
||||
return r.Vnodes[i].Hash >= start.Hash
|
||||
})
|
||||
if i == len(r.Vnodes) {
|
||||
i = 0
|
||||
}
|
||||
|
||||
for idx := 0; len(owners) < n && idx < len(r.Vnodes); idx++ {
|
||||
v := r.Vnodes[(i+idx)%len(r.Vnodes)]
|
||||
if _, ok := seen[v.Host]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v.Host] = struct{}{}
|
||||
owners = append(owners, v)
|
||||
}
|
||||
return owners
|
||||
}
|
||||
|
||||
// DiffHosts compares this ring's membership to another.
|
||||
func (r *Ring) DiffHosts(other *Ring) (added []string, removed []string) {
|
||||
if other == nil {
|
||||
return r.Hosts(), nil
|
||||
}
|
||||
cur := make(map[string]struct{}, len(r.hosts))
|
||||
for _, h := range r.hosts {
|
||||
cur[h] = struct{}{}
|
||||
}
|
||||
oth := make(map[string]struct{}, len(other.hosts))
|
||||
for _, h := range other.hosts {
|
||||
oth[h] = struct{}{}
|
||||
}
|
||||
for h := range cur {
|
||||
if _, ok := oth[h]; !ok {
|
||||
removed = append(removed, h)
|
||||
}
|
||||
}
|
||||
for h := range oth {
|
||||
if _, ok := cur[h]; !ok {
|
||||
added = append(added, h)
|
||||
}
|
||||
}
|
||||
sort.Strings(added)
|
||||
sort.Strings(removed)
|
||||
return
|
||||
}
|
||||
|
||||
// ---------------------------- Hash Functions ---------------------------------
|
||||
|
||||
func hashVnode(host string, idx int) uint64 {
|
||||
h := fnv.New64a()
|
||||
_, _ = h.Write([]byte(host))
|
||||
_, _ = h.Write([]byte{'#'})
|
||||
var buf [8]byte
|
||||
binary.BigEndian.PutUint64(buf[:], uint64(idx))
|
||||
_, _ = h.Write(buf[:])
|
||||
return h.Sum64()
|
||||
}
|
||||
|
||||
// hashKeyString provides a stable hash for arbitrary string keys (legacy IDs).
|
||||
func hashKeyString(s string) uint64 {
|
||||
h := fnv.New64a()
|
||||
_, _ = h.Write([]byte(s))
|
||||
return h.Sum64()
|
||||
}
|
||||
|
||||
// fingerprintHosts produces an order-insensitive hash over the host set.
|
||||
func fingerprintHosts(hosts []string) uint64 {
|
||||
if len(hosts) == 0 {
|
||||
return 0
|
||||
}
|
||||
h := fnv.New64a()
|
||||
for _, host := range hosts {
|
||||
_, _ = h.Write([]byte(host))
|
||||
_, _ = h.Write([]byte{0})
|
||||
}
|
||||
return h.Sum64()
|
||||
}
|
||||
|
||||
// --------------------------- Thread-Safe Wrapper -----------------------------
|
||||
//
|
||||
// RingRef offers atomic swap + read semantics. SyncedPool can embed or hold
|
||||
// one of these to manage live ring updates safely.
|
||||
|
||||
type RingRef struct {
|
||||
mu sync.RWMutex
|
||||
ring *Ring
|
||||
}
|
||||
|
||||
func NewRingRef(r *Ring) *RingRef {
|
||||
return &RingRef{ring: r}
|
||||
}
|
||||
|
||||
func (rr *RingRef) Get() *Ring {
|
||||
rr.mu.RLock()
|
||||
r := rr.ring
|
||||
rr.mu.RUnlock()
|
||||
return r
|
||||
}
|
||||
|
||||
func (rr *RingRef) Set(r *Ring) {
|
||||
rr.mu.Lock()
|
||||
rr.ring = r
|
||||
rr.mu.Unlock()
|
||||
}
|
||||
|
||||
func (rr *RingRef) LookupID(id CartID) Vnode {
|
||||
r := rr.Get()
|
||||
if r == nil {
|
||||
return Vnode{}
|
||||
}
|
||||
return r.LookupID(id)
|
||||
}
|
||||
|
||||
// ----------------------------- Debug Utilities -------------------------------
|
||||
|
||||
func (r *Ring) String() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Ring{epoch=%d vnodes=%d hosts=%d}\n", r.Epoch, len(r.Vnodes), len(r.hosts))
|
||||
limit := len(r.Vnodes)
|
||||
if limit > 16 {
|
||||
limit = 16
|
||||
}
|
||||
for i := 0; i < limit; i++ {
|
||||
v := r.Vnodes[i]
|
||||
fmt.Fprintf(&b, " %02d hash=%016x host=%s idx=%d\n", i, v.Hash, v.Host, v.Index)
|
||||
}
|
||||
if len(r.Vnodes) > limit {
|
||||
fmt.Fprintf(&b, " ... (%d more)\n", len(r.Vnodes)-limit)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
300
synced-pool.go
300
synced-pool.go
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"reflect"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -15,14 +16,15 @@ import (
|
||||
)
|
||||
|
||||
// SyncedPool coordinates cart grain ownership across nodes using gRPC control plane
|
||||
// and cart actor services. Legacy frame / TCP code has been removed.
|
||||
// and cart actor services.
|
||||
//
|
||||
// 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
|
||||
// - Ring based deterministic ownership (no runtime negotiation)
|
||||
// - (Scaffolding) replication factor awareness via ring.LookupN
|
||||
//
|
||||
// Thread-safety: public methods that mutate internal maps lock p.mu (RWMutex).
|
||||
type SyncedPool struct {
|
||||
@@ -40,7 +42,12 @@ type SyncedPool struct {
|
||||
// Discovery handler for re-adding hosts after failures
|
||||
discardedHostHandler *DiscardedHostHandler
|
||||
|
||||
// Metrics / instrumentation dependencies already declared globally
|
||||
// Consistent hashing ring (immutable snapshot reference)
|
||||
ringRef *RingRef
|
||||
|
||||
// Configuration
|
||||
vnodesPerHost int
|
||||
replicationFactor int // RF (>=1). Currently only primary is active; replicas are scaffolding.
|
||||
}
|
||||
|
||||
// RemoteHostGRPC tracks a remote host's clients & health.
|
||||
@@ -71,7 +78,54 @@ var (
|
||||
})
|
||||
remoteLookupCount = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "cart_remote_lookup_total",
|
||||
Help: "The total number of remote lookups",
|
||||
Help: "The total number of remote lookups (legacy counter)",
|
||||
})
|
||||
|
||||
// Ring / ownership metrics
|
||||
ringEpoch = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "cart_ring_epoch",
|
||||
Help: "Current consistent hashing ring epoch (fingerprint-based pseudo-epoch)",
|
||||
})
|
||||
ringHosts = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "cart_ring_hosts",
|
||||
Help: "Number of hosts currently in the ring",
|
||||
})
|
||||
ringVnodes = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "cart_ring_vnodes",
|
||||
Help: "Number of virtual nodes in the ring",
|
||||
})
|
||||
ringLookupLocal = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "cart_ring_lookup_local_total",
|
||||
Help: "Ring ownership lookups resolved to the local host",
|
||||
})
|
||||
ringLookupRemote = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "cart_ring_lookup_remote_total",
|
||||
Help: "Ring ownership lookups resolved to a remote host",
|
||||
})
|
||||
ringHostShare = promauto.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Name: "cart_ring_host_share",
|
||||
Help: "Fractional share of ring vnodes per host",
|
||||
}, []string{"host"})
|
||||
|
||||
cartMutationsTotal = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "cart_mutations_total",
|
||||
Help: "Total number of cart state mutations applied (local + remote routed).",
|
||||
})
|
||||
|
||||
cartMutationFailuresTotal = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "cart_mutation_failures_total",
|
||||
Help: "Total number of failed cart state mutations (local apply errors or remote routing failures).",
|
||||
})
|
||||
|
||||
cartMutationLatencySeconds = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "cart_mutation_latency_seconds",
|
||||
Help: "Latency of cart mutations (successful or failed) in seconds.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"mutation"})
|
||||
|
||||
cartActiveGrains = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "cart_active_grains",
|
||||
Help: "Number of active (resident) local grains.",
|
||||
})
|
||||
)
|
||||
|
||||
@@ -82,8 +136,12 @@ func NewSyncedPool(local *GrainLocalPool, hostname string, discovery Discovery)
|
||||
remoteHosts: make(map[string]*RemoteHostGRPC),
|
||||
remoteIndex: make(map[CartId]Grain),
|
||||
discardedHostHandler: NewDiscardedHostHandler(1338),
|
||||
vnodesPerHost: 64, // default smoothing factor; adjust if needed
|
||||
replicationFactor: 1, // RF scaffold; >1 not yet activating replicas
|
||||
}
|
||||
p.discardedHostHandler.SetReconnectHandler(p.AddRemote)
|
||||
// Initialize empty ring (will be rebuilt after first AddRemote or discovery event)
|
||||
p.rebuildRing()
|
||||
|
||||
if discovery != nil {
|
||||
go func() {
|
||||
@@ -175,6 +233,8 @@ func (p *SyncedPool) AddRemote(host string) {
|
||||
p.remoteHosts[host] = remote
|
||||
p.mu.Unlock()
|
||||
connectedRemotes.Set(float64(p.RemoteCount()))
|
||||
// Rebuild consistent hashing ring including this new host
|
||||
p.rebuildRing()
|
||||
|
||||
log.Printf("Connected to remote host %s", host)
|
||||
|
||||
@@ -222,6 +282,8 @@ func (p *SyncedPool) RemoveHost(host string) {
|
||||
remote.Conn.Close()
|
||||
}
|
||||
connectedRemotes.Set(float64(p.RemoteCount()))
|
||||
// Rebuild ring after host removal
|
||||
p.rebuildRing()
|
||||
}
|
||||
|
||||
// RemoteCount returns number of tracked remote hosts.
|
||||
@@ -302,6 +364,8 @@ func (p *SyncedPool) Negotiate() {
|
||||
}
|
||||
p.mu.RUnlock()
|
||||
|
||||
changed := false
|
||||
|
||||
for _, r := range remotes {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
reply, err := r.ControlClient.Negotiate(ctx, &proto.NegotiateRequest{KnownHosts: hosts})
|
||||
@@ -313,12 +377,18 @@ func (p *SyncedPool) Negotiate() {
|
||||
for _, h := range reply.Hosts {
|
||||
if !p.IsKnown(h) {
|
||||
p.AddRemote(h)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If new hosts were discovered during negotiation, rebuild the ring once at the end.
|
||||
if changed {
|
||||
p.rebuildRing()
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------- Grain Management ----------------------------------
|
||||
// ------------------------- Grain / Ring Ownership ----------------------------
|
||||
|
||||
// RemoveRemoteGrain removes a remote grain mapping.
|
||||
func (p *SyncedPool) RemoveRemoteGrain(id CartId) {
|
||||
@@ -333,9 +403,9 @@ func (p *SyncedPool) SpawnRemoteGrain(id CartId, host 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)
|
||||
// If local grain exists (legacy key), remove from local map (ownership moved).
|
||||
if g, ok := p.local.grains[LegacyToCartKey(id)]; ok && g != nil {
|
||||
delete(p.local.grains, LegacyToCartKey(id))
|
||||
}
|
||||
remoteHost, ok := p.remoteHosts[host]
|
||||
if !ok {
|
||||
@@ -362,88 +432,192 @@ func (p *SyncedPool) GetHealthyRemotes() []*RemoteHostGRPC {
|
||||
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++
|
||||
// rebuildRing reconstructs the consistent hashing ring from current host set
|
||||
// and updates ring-related metrics.
|
||||
func (p *SyncedPool) rebuildRing() {
|
||||
p.mu.RLock()
|
||||
hosts := make([]string, 0, len(p.remoteHosts)+1)
|
||||
hosts = append(hosts, p.Hostname)
|
||||
for h := range p.remoteHosts {
|
||||
hosts = append(hosts, h)
|
||||
}
|
||||
p.mu.RUnlock()
|
||||
|
||||
epochSeed := fingerprintHosts(hosts)
|
||||
builder := NewRingBuilder().
|
||||
WithHosts(hosts).
|
||||
WithEpoch(epochSeed).
|
||||
WithVnodesPerHost(p.vnodesPerHost)
|
||||
r := builder.Build()
|
||||
if p.ringRef == nil {
|
||||
p.ringRef = NewRingRef(r)
|
||||
} else {
|
||||
p.ringRef.Set(r)
|
||||
}
|
||||
|
||||
// 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)
|
||||
// Metrics
|
||||
ringEpoch.Set(float64(r.Epoch))
|
||||
ringHosts.Set(float64(len(r.Hosts())))
|
||||
ringVnodes.Set(float64(len(r.Vnodes)))
|
||||
ringHostShare.Reset()
|
||||
if len(r.Vnodes) > 0 {
|
||||
perHost := make(map[string]int)
|
||||
for _, v := range r.Vnodes {
|
||||
perHost[v.Host]++
|
||||
}
|
||||
total := float64(len(r.Vnodes))
|
||||
for h, c := range perHost {
|
||||
ringHostShare.WithLabelValues(h).Set(float64(c) / total)
|
||||
}
|
||||
}
|
||||
grainSyncCount.Inc()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForceRingRefresh exposes a manual ring rebuild hook (primarily for tests).
|
||||
func (p *SyncedPool) ForceRingRefresh() {
|
||||
p.rebuildRing()
|
||||
}
|
||||
|
||||
// ownersFor returns the ordered list of primary + replica owners for a cart id
|
||||
// (length min(replicationFactor, #hosts)). Currently only the first (primary)
|
||||
// is used. This scaffolds future replication work.
|
||||
func (p *SyncedPool) ownersFor(id CartId) []string {
|
||||
if p.ringRef == nil || p.replicationFactor <= 0 {
|
||||
return []string{p.Hostname}
|
||||
}
|
||||
r := p.ringRef.Get()
|
||||
if r == nil || r.Empty() {
|
||||
return []string{p.Hostname}
|
||||
}
|
||||
vnodes := r.LookupN(hashKeyString(id.String()), p.replicationFactor)
|
||||
out := make([]string, 0, len(vnodes))
|
||||
seen := make(map[string]struct{}, len(vnodes))
|
||||
for _, v := range vnodes {
|
||||
if _, ok := seen[v.Host]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v.Host] = struct{}{}
|
||||
out = append(out, v.Host)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
out = append(out, p.Hostname)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ownerHostFor returns the primary owner host for a given id.
|
||||
func (p *SyncedPool) ownerHostFor(id CartId) string {
|
||||
return p.ownersFor(id)[0]
|
||||
}
|
||||
|
||||
// DebugOwnerHost exposes (for tests) the currently computed primary owner host.
|
||||
func (p *SyncedPool) DebugOwnerHost(id CartId) string {
|
||||
return p.ownerHostFor(id)
|
||||
}
|
||||
|
||||
func (p *SyncedPool) removeLocalGrain(id CartId) {
|
||||
p.mu.Lock()
|
||||
delete(p.local.grains, id)
|
||||
delete(p.local.grains, LegacyToCartKey(id))
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// getGrain returns a local or remote grain; if absent, attempts ownership.
|
||||
// getGrain returns a local or remote grain. For remote ownership it performs a
|
||||
// bounded readiness wait (small retries) to reduce first-call failures while
|
||||
// the remote connection & proxy are initializing.
|
||||
func (p *SyncedPool) getGrain(id CartId) (Grain, error) {
|
||||
owner := p.ownerHostFor(id)
|
||||
if owner == p.Hostname {
|
||||
ringLookupLocal.Inc()
|
||||
grain, err := p.local.GetGrain(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return grain, nil
|
||||
}
|
||||
ringLookupRemote.Inc()
|
||||
|
||||
// Kick off remote dial if we don't yet know the owner.
|
||||
if !p.IsKnown(owner) {
|
||||
go p.AddRemote(owner)
|
||||
}
|
||||
|
||||
// Fast path existing proxy
|
||||
p.mu.RLock()
|
||||
localGrain, isLocal := p.local.grains[id]
|
||||
remoteGrain, isRemote := p.remoteIndex[id]
|
||||
if rg, ok := p.remoteIndex[id]; ok {
|
||||
p.mu.RUnlock()
|
||||
remoteLookupCount.Inc()
|
||||
return rg, nil
|
||||
}
|
||||
p.mu.RUnlock()
|
||||
|
||||
if isLocal && localGrain != nil {
|
||||
return localGrain, nil
|
||||
}
|
||||
if isRemote {
|
||||
remoteLookupCount.Inc()
|
||||
return remoteGrain, nil
|
||||
const (
|
||||
attempts = 5
|
||||
sleepPerTry = 40 * time.Millisecond
|
||||
)
|
||||
|
||||
for attempt := 0; attempt < attempts; attempt++ {
|
||||
// Try to spawn (idempotent if host already known)
|
||||
if p.IsKnown(owner) {
|
||||
p.SpawnRemoteGrain(id, owner)
|
||||
}
|
||||
// Check again
|
||||
p.mu.RLock()
|
||||
if rg, ok := p.remoteIndex[id]; ok {
|
||||
p.mu.RUnlock()
|
||||
remoteLookupCount.Inc()
|
||||
return rg, nil
|
||||
}
|
||||
p.mu.RUnlock()
|
||||
|
||||
// Last attempt? break to return error.
|
||||
if attempt == attempts-1 {
|
||||
break
|
||||
}
|
||||
time.Sleep(sleepPerTry)
|
||||
}
|
||||
|
||||
// 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
|
||||
return nil, fmt.Errorf("remote owner %s not yet available for cart %s (after %d attempts)", owner, id.String(), attempts)
|
||||
}
|
||||
|
||||
// Process applies mutation(s) to a grain (local or remote).
|
||||
func (p *SyncedPool) Process(id CartId, messages ...Message) (*FrameWithPayload, error) {
|
||||
// Apply applies a single mutation to a grain (local or remote).
|
||||
// Replication (RF>1) scaffolding: future enhancement will fan-out mutations
|
||||
// to replica owners (best-effort) and reconcile quorum on read.
|
||||
func (p *SyncedPool) Apply(id CartId, mutation interface{}) (*CartGrain, 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
|
||||
start := time.Now()
|
||||
result, applyErr := grain.Apply(mutation, false)
|
||||
|
||||
// Derive mutation type label (strip pointer)
|
||||
mutationType := "unknown"
|
||||
if mutation != nil {
|
||||
if t := reflect.TypeOf(mutation); t != nil {
|
||||
if t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
if t.Name() != "" {
|
||||
mutationType = t.Name()
|
||||
}
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
cartMutationLatencySeconds.WithLabelValues(mutationType).Observe(time.Since(start).Seconds())
|
||||
|
||||
if applyErr == nil && result != nil {
|
||||
cartMutationsTotal.Inc()
|
||||
if p.ownerHostFor(id) == p.Hostname {
|
||||
// Update active grains gauge only for local ownership
|
||||
cartActiveGrains.Set(float64(p.local.DebugGrainCount()))
|
||||
}
|
||||
} else if applyErr != nil {
|
||||
cartMutationFailuresTotal.Inc()
|
||||
}
|
||||
return result, applyErr
|
||||
}
|
||||
|
||||
// Get returns current state of a grain (local or remote).
|
||||
func (p *SyncedPool) Get(id CartId) (*FrameWithPayload, error) {
|
||||
// Future replication hook: Read-repair or quorum read can be added here.
|
||||
func (p *SyncedPool) Get(id CartId) (*CartGrain, error) {
|
||||
grain, err := p.getGrain(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
Reference in New Issue
Block a user