Compare commits
75 Commits
refactor/g
...
refactor/h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8fd0a21b1 | ||
|
|
137958b83b | ||
| f5014fe906 | |||
|
|
ee5d714220 | ||
|
|
5ca21e214f | ||
|
|
2df64e715e | ||
|
|
e0ea03f6cf | ||
|
|
a7cbdcd0da | ||
|
|
f0b6a733f1 | ||
|
|
c86a189795 | ||
|
|
1565cfdaba | ||
|
|
4bec3f8cc0 | ||
|
|
5e2025e30f | ||
| e908a4130b | |||
| 606df6218a | |||
|
|
9f83717ea9 | ||
|
|
b029a9d05a | ||
|
|
cbbae3dfd3 | ||
|
|
b842efae6a | ||
|
|
bb9a595d25 | ||
|
|
de62b0664d | ||
|
|
0b9c14c231 | ||
|
|
7a79efbb9f | ||
|
|
6c44a03dd1 | ||
|
|
11e82de114 | ||
|
|
67b9a739fe | ||
|
|
5c67579464 | ||
|
|
22ac64c14d | ||
| 27c866ce58 | |||
| c9701803d1 | |||
| dc12e8c3d5 | |||
| 30b98f93ce | |||
| 8e60cc2239 | |||
|
|
91e398dcc3 | ||
|
|
f3e92c7d65 | ||
|
|
6fbd62936f | ||
|
|
2bf0475335 | ||
|
|
10e1affad0 | ||
|
|
e26ad676b3 | ||
|
|
9fc3871e84 | ||
|
|
6094da99f3 | ||
|
|
1575b3a829 | ||
|
|
c6671ceef0 | ||
|
|
6cb46b4e16 | ||
|
|
4e4d5371ec | ||
|
|
c4f0c67580 | ||
|
|
6a9ebbf453 | ||
|
|
ea35871676 | ||
|
|
7ad28966fb | ||
|
|
a7a778caaf | ||
|
|
873fb6c97b | ||
|
|
33ef868295 | ||
|
|
8d73f856bf | ||
|
|
b591e3d3f5 | ||
|
|
b8266d80f9 | ||
| 0ba7410162 | |||
| 9df2f3362a | |||
| 6345d91ef7 | |||
| 4cacc0ee2d | |||
| 24cd0b6ad7 | |||
|
|
e48a2590bd | ||
|
|
b0e6c8eca8 | ||
|
|
7814f33a06 | ||
|
|
fb111ebf97 | ||
|
|
5525e91ecc | ||
|
|
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
|
name: Build and Publish
|
||||||
run-name: ${{ gitea.actor }} is building 🚀
|
run-name: ${{ gitea.actor }} build 🚀
|
||||||
on: [push]
|
on: [push]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
Metadata:
|
||||||
|
runs-on: arm64
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.meta.outputs.version }}
|
||||||
|
git_commit: ${{ steps.meta.outputs.git_commit }}
|
||||||
|
build_date: ${{ steps.meta.outputs.build_date }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- id: meta
|
||||||
|
name: Derive build metadata
|
||||||
|
run: |
|
||||||
|
GIT_COMMIT=$(git rev-parse HEAD)
|
||||||
|
if git describe --tags --exact-match >/dev/null 2>&1; then
|
||||||
|
VERSION=$(git describe --tags --exact-match)
|
||||||
|
else
|
||||||
|
VERSION=$(git rev-parse --short=12 HEAD)
|
||||||
|
fi
|
||||||
|
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
echo "git_commit=$GIT_COMMIT" >> $GITHUB_OUTPUT
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "build_date=$BUILD_DATE" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
BuildAndDeployAmd64:
|
BuildAndDeployAmd64:
|
||||||
|
needs: Metadata
|
||||||
runs-on: amd64
|
runs-on: amd64
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- name: Build amd64 image
|
||||||
- name: Build docker image
|
run: |
|
||||||
run: docker build --progress=plain -t registry.knatofs.se/go-cart-actor-amd64:latest .
|
docker build \
|
||||||
- name: Push to registry
|
--build-arg VERSION=${{ needs.Metadata.outputs.version }} \
|
||||||
run: docker push registry.knatofs.se/go-cart-actor-amd64:latest
|
--build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \
|
||||||
- name: Deploy to Kubernetes
|
--build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \
|
||||||
|
--progress=plain \
|
||||||
|
-t registry.knatofs.se/go-cart-actor-amd64:latest \
|
||||||
|
-t registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }} \
|
||||||
|
.
|
||||||
|
- name: Push amd64 images
|
||||||
|
run: |
|
||||||
|
docker push registry.knatofs.se/go-cart-actor-amd64:latest
|
||||||
|
docker push registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }}
|
||||||
|
- name: Apply deployment manifests
|
||||||
run: kubectl apply -f deployment/deployment.yaml -n cart
|
run: kubectl apply -f deployment/deployment.yaml -n cart
|
||||||
- name: Rollout amd64 deployment
|
- name: Rollout amd64 deployment (pin to version)
|
||||||
run: kubectl rollout restart deployment/cart-actor-x86 -n cart
|
run: |
|
||||||
|
kubectl set image deployment/cart-actor-x86 -n cart cart-actor-amd64=registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }}
|
||||||
|
kubectl rollout status deployment/cart-actor-x86 -n cart
|
||||||
|
|
||||||
BuildAndDeploy:
|
BuildAndDeployArm64:
|
||||||
|
needs: Metadata
|
||||||
runs-on: arm64
|
runs-on: arm64
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository code
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- name: Build arm64 image
|
||||||
- name: Build docker image
|
run: |
|
||||||
run: docker build --progress=plain -t registry.knatofs.se/go-cart-actor .
|
docker build \
|
||||||
- name: Push to registry
|
--build-arg VERSION=${{ needs.Metadata.outputs.version }} \
|
||||||
run: docker push registry.knatofs.se/go-cart-actor
|
--build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \
|
||||||
- name: Rollout arm64 deployment
|
--build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \
|
||||||
run: kubectl rollout restart deployment/cart-actor-arm64 -n cart
|
--progress=plain \
|
||||||
|
-t registry.knatofs.se/go-cart-actor:latest \
|
||||||
|
-t registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }} \
|
||||||
|
.
|
||||||
|
- name: Push arm64 images
|
||||||
|
run: |
|
||||||
|
docker push registry.knatofs.se/go-cart-actor:latest
|
||||||
|
docker push registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }}
|
||||||
|
- name: Rollout arm64 deployment (pin to version)
|
||||||
|
run: |
|
||||||
|
kubectl set image deployment/cart-actor-arm64 -n cart cart-actor-arm64=registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }}
|
||||||
|
kubectl rollout status deployment/cart-actor-arm64 -n cart
|
||||||
|
|||||||
76
Dockerfile
76
Dockerfile
@@ -1,17 +1,75 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1.7
|
||||||
|
#
|
||||||
|
# Multi-stage build:
|
||||||
|
# 1. Build static binary with pinned Go version (matching go.mod).
|
||||||
|
# 2. Copy into distroless static nonroot runtime image.
|
||||||
|
#
|
||||||
|
# Build args (optional):
|
||||||
|
# VERSION - semantic/app version (default: dev)
|
||||||
|
# GIT_COMMIT - git SHA (default: unknown)
|
||||||
|
# BUILD_DATE - RFC3339 build timestamp
|
||||||
|
#
|
||||||
|
# Example build:
|
||||||
|
# docker build \
|
||||||
|
# --build-arg VERSION=$(git describe --tags --always) \
|
||||||
|
# --build-arg GIT_COMMIT=$(git rev-parse HEAD) \
|
||||||
|
# --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
||||||
|
# -t go-cart-actor:dev .
|
||||||
|
#
|
||||||
|
# If you add subpackages or directories, no Dockerfile change needed (COPY . .).
|
||||||
|
# Ensure a .dockerignore exists to keep context lean.
|
||||||
|
|
||||||
FROM golang:alpine AS build-stage
|
############################
|
||||||
WORKDIR /app
|
# Build Stage
|
||||||
|
############################
|
||||||
|
FROM golang:1.25-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Build metadata (can be overridden at build time)
|
||||||
|
ARG VERSION=dev
|
||||||
|
ARG GIT_COMMIT=unknown
|
||||||
|
ARG BUILD_DATE=unknown
|
||||||
|
|
||||||
|
# Ensure reproducible static build
|
||||||
|
# Multi-arch build args (TARGETOS/TARGETARCH provided automatically by buildx)
|
||||||
|
ARG TARGETOS
|
||||||
|
ARG TARGETARCH
|
||||||
|
ENV CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH}
|
||||||
|
|
||||||
|
# Dependency caching
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
|
go mod download
|
||||||
|
|
||||||
COPY proto ./proto
|
# Copy full source (relay on .dockerignore to prune)
|
||||||
COPY *.go ./
|
COPY . .
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /go-cart-actor
|
# (Optional) If you do NOT check in generated protobuf code, uncomment generation:
|
||||||
|
# RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest && \
|
||||||
|
# go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest && \
|
||||||
|
# protoc --go_out=. --go_opt=paths=source_relative \
|
||||||
|
# --go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
||||||
|
# proto/*.proto
|
||||||
|
|
||||||
FROM gcr.io/distroless/base-debian11
|
# Build with minimal binary size and embedded metadata
|
||||||
|
RUN --mount=type=cache,target=/go/build-cache \
|
||||||
|
go build -trimpath -ldflags="-s -w \
|
||||||
|
-X main.Version=${VERSION} \
|
||||||
|
-X main.GitCommit=${GIT_COMMIT} \
|
||||||
|
-X main.BuildDate=${BUILD_DATE}" \
|
||||||
|
-o /out/go-cart-actor ./cmd/cart
|
||||||
|
|
||||||
|
############################
|
||||||
|
# Runtime Stage
|
||||||
|
############################
|
||||||
|
# Using distroless static (nonroot) for minimal surface area.
|
||||||
|
FROM gcr.io/distroless/static-debian12:nonroot AS runtime
|
||||||
WORKDIR /
|
WORKDIR /
|
||||||
|
|
||||||
COPY --from=build-stage /go-cart-actor /go-cart-actor
|
COPY --from=build /out/go-cart-actor /go-cart-actor
|
||||||
|
|
||||||
|
# Document (not expose forcibly) typical ports: 8080 (HTTP), 1337 (gRPC)
|
||||||
|
EXPOSE 8080 1337
|
||||||
|
|
||||||
|
USER nonroot:nonroot
|
||||||
ENTRYPOINT ["/go-cart-actor"]
|
ENTRYPOINT ["/go-cart-actor"]
|
||||||
@@ -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)/control_plane.proto
|
||||||
|
|
||||||
|
# Allow override: make PROTOC=/path/to/protoc
|
||||||
|
PROTOC ?= protoc
|
||||||
|
|
||||||
|
# Tools (auto-detect; can override)
|
||||||
|
PROTOC_GEN_GO ?= $(shell command -v protoc-gen-go 2>/dev/null)
|
||||||
|
PROTOC_GEN_GO_GRPC ?= $(shell command -v protoc-gen-go-grpc 2>/dev/null)
|
||||||
|
|
||||||
|
GO ?= go
|
||||||
|
|
||||||
|
# Colors (optional)
|
||||||
|
GREEN := \033[32m
|
||||||
|
RED := \033[31m
|
||||||
|
YELLOW := \033[33m
|
||||||
|
RESET := \033[0m
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
.PHONY: protogen clean_proto verify_proto tidy build test regen help check_tools
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "Available targets:"
|
||||||
|
@echo " protogen Generate protobuf & gRPC code"
|
||||||
|
@echo " clean_proto Remove generated *.pb.go files in $(PROTO_DIR)"
|
||||||
|
@echo " verify_proto Ensure no root-level *.pb.go files (old layout)"
|
||||||
|
|
||||||
|
@echo " tidy Run go mod tidy"
|
||||||
|
@echo " build Build the module"
|
||||||
|
@echo " test Run tests (verbose)"
|
||||||
|
@echo " regen Clean proto, regenerate, tidy, verify, and build"
|
||||||
|
@echo " check_tools Verify protoc + plugins are installed"
|
||||||
|
|
||||||
|
check_tools:
|
||||||
|
@if [ -z "$(PROTOC_GEN_GO)" ] || [ -z "$(PROTOC_GEN_GO_GRPC)" ]; then \
|
||||||
|
echo "$(RED)Missing protoc-gen-go or protoc-gen-go-grpc in PATH.$(RESET)"; \
|
||||||
|
echo "Install with:"; \
|
||||||
|
echo " go install google.golang.org/protobuf/cmd/protoc-gen-go@latest"; \
|
||||||
|
echo " go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@if ! command -v "$(PROTOC)" >/dev/null 2>&1; then \
|
||||||
|
echo "$(RED)protoc not found. Install protoc (e.g. via package manager)$(RESET)"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@echo "$(GREEN)All required tools detected.$(RESET)"
|
||||||
|
|
||||||
|
protogen: check_tools
|
||||||
|
@echo "$(YELLOW)Generating protobuf code (outputs -> ./proto)...$(RESET)"
|
||||||
|
$(PROTOC) -I $(PROTO_DIR) \
|
||||||
|
--go_out=./pkg/messages --go_opt=paths=source_relative \
|
||||||
|
--go-grpc_out=./pkg/messages --go-grpc_opt=paths=source_relative \
|
||||||
|
$(PROTOS)
|
||||||
|
@echo "$(GREEN)Protobuf generation complete.$(RESET)"
|
||||||
|
|
||||||
|
clean_proto:
|
||||||
|
@echo "$(YELLOW)Removing generated protobuf files...$(RESET)"
|
||||||
|
@rm -f $(PROTO_DIR)/*_grpc.pb.go $(PROTO_DIR)/*.pb.go
|
||||||
|
@rm -f *.pb.go
|
||||||
|
@rm -rf git.tornberg.me
|
||||||
|
@echo "$(GREEN)Clean complete.$(RESET)"
|
||||||
|
|
||||||
|
verify_proto:
|
||||||
|
@echo "$(YELLOW)Verifying proto layout...$(RESET)"
|
||||||
|
@if ls *.pb.go >/dev/null 2>&1; then \
|
||||||
|
echo "$(RED)ERROR: Found root-level generated *.pb.go files (should be only under $(PROTO_DIR)/).$(RESET)"; \
|
||||||
|
ls -1 *.pb.go; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@echo "$(GREEN)Proto layout OK (no root-level *.pb.go files).$(RESET)"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
tidy:
|
||||||
|
@echo "$(YELLOW)Running go mod tidy...$(RESET)"
|
||||||
|
$(GO) mod tidy
|
||||||
|
@echo "$(GREEN)tidy complete.$(RESET)"
|
||||||
|
|
||||||
|
build:
|
||||||
|
@echo "$(YELLOW)Building...$(RESET)"
|
||||||
|
$(GO) build ./...
|
||||||
|
@echo "$(GREEN)Build success.$(RESET)"
|
||||||
|
|
||||||
|
test:
|
||||||
|
@echo "$(YELLOW)Running tests...$(RESET)"
|
||||||
|
$(GO) test -v ./...
|
||||||
|
@echo "$(GREEN)Tests completed.$(RESET)"
|
||||||
|
|
||||||
|
regen: clean_proto protogen tidy verify_proto build
|
||||||
|
@echo "$(GREEN)Full regenerate cycle complete.$(RESET)"
|
||||||
|
|
||||||
|
# Utility: show proto sources and generated outputs
|
||||||
|
print_proto:
|
||||||
|
@echo "Proto sources:"
|
||||||
|
@ls -1 $(PROTOS)
|
||||||
|
@echo ""
|
||||||
|
@echo "Generated files (if any):"
|
||||||
|
@ls -1 $(PROTO_DIR)/*pb.go 2>/dev/null || echo "(none)"
|
||||||
|
|
||||||
|
# Prevent make from treating these as file targets if similarly named files appear.
|
||||||
|
.SILENT: help check_tools protogen clean_proto verify_proto tidy build test regen print_proto
|
||||||
266
README.md
266
README.md
@@ -1,5 +1,36 @@
|
|||||||
# Go Cart Actor
|
# Go Cart Actor
|
||||||
|
|
||||||
|
## Migration Notes (Ring-based Ownership Transition)
|
||||||
|
|
||||||
|
This release removes the legacy ConfirmOwner ownership negotiation RPC in favor of deterministic ownership via the consistent hashing ring.
|
||||||
|
|
||||||
|
Summary of changes:
|
||||||
|
- ConfirmOwner RPC removed from the ControlPlane service.
|
||||||
|
- OwnerChangeRequest message removed (was only used by ConfirmOwner).
|
||||||
|
- OwnerChangeAck retained solely as the response type for the Closing RPC.
|
||||||
|
- SyncedPool now relies exclusively on the ring for ownership (no quorum negotiation).
|
||||||
|
- Remote proxy creation includes a bounded readiness retry to reduce first-call failures.
|
||||||
|
- New Prometheus ring metrics:
|
||||||
|
- cart_ring_epoch
|
||||||
|
- cart_ring_hosts
|
||||||
|
- cart_ring_vnodes
|
||||||
|
- cart_ring_host_share{host}
|
||||||
|
- cart_ring_lookup_local_total
|
||||||
|
- cart_ring_lookup_remote_total
|
||||||
|
|
||||||
|
Action required for consumers:
|
||||||
|
1. Regenerate protobuf code after pulling (requires protoc-gen-go and protoc-gen-go-grpc installed).
|
||||||
|
2. Remove any client code or automation invoking ConfirmOwner (calls will now return UNIMPLEMENTED if using stale generated stubs).
|
||||||
|
3. Update monitoring/alerts that referenced ConfirmOwner or ownership quorum failures—use ring metrics instead.
|
||||||
|
4. If you previously interpreted “ownership flapping” via ConfirmOwner logs, now check for:
|
||||||
|
- Rapid changes in ring epoch (cart_ring_epoch)
|
||||||
|
- Host churn (cart_ring_hosts)
|
||||||
|
- Imbalance in vnode distribution (cart_ring_host_share)
|
||||||
|
|
||||||
|
No data migration is necessary; cart IDs and grain state are unaffected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
A distributed cart management system using the actor model pattern.
|
A distributed cart management system using the actor model pattern.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
@@ -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)
|
- Always regenerate protobuf Go code after modifying any `.proto` files (messages/cart_actor/control_plane)
|
||||||
- The generated `messages.pb.go` file should not be edited manually
|
- The generated `messages.pb.go` file should not be edited manually
|
||||||
- Make sure your PATH includes the protoc-gen-go binary location (usually `$GOPATH/bin`)
|
- Make sure your PATH includes the protoc-gen-go binary location (usually `$GOPATH/bin`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
The system is a distributed, sharded (by cart id) actor model implementation:
|
||||||
|
|
||||||
|
- Each cart is a grain (an 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 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
|
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AmqpOrderHandler struct {
|
|
||||||
Url string
|
|
||||||
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(),
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
t.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 nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *AmqpOrderHandler) Close() error {
|
|
||||||
log.Println("Closing master channel")
|
|
||||||
return t.connection.Close()
|
|
||||||
//return t.channel.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
amqp.Publishing{
|
|
||||||
ContentType: "application/json",
|
|
||||||
Body: data,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
621
cart-grain.go
621
cart-grain.go
@@ -1,621 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"slices"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
messages "git.tornberg.me/go-cart-actor/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CartId [16]byte
|
|
||||||
|
|
||||||
// String returns the cart id as a trimmed UTF-8 string (trailing zero bytes removed).
|
|
||||||
func (id CartId) String() string {
|
|
||||||
n := 0
|
|
||||||
for n < len(id) && id[n] != 0 {
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
return string(id[:n])
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToCartId converts an arbitrary string to a fixed-size CartId (truncating or padding with zeros).
|
|
||||||
func ToCartId(s string) CartId {
|
|
||||||
var id CartId
|
|
||||||
copy(id[:], []byte(s))
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
func (id CartId) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(id.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (id *CartId) UnmarshalJSON(data []byte) error {
|
|
||||||
var str string
|
|
||||||
err := json.Unmarshal(data, &str)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
copy(id[:], []byte(str))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type StockStatus int
|
|
||||||
|
|
||||||
const (
|
|
||||||
OutOfStock StockStatus = 0
|
|
||||||
LowStock StockStatus = 1
|
|
||||||
InStock StockStatus = 2
|
|
||||||
)
|
|
||||||
|
|
||||||
type CartItem struct {
|
|
||||||
Id int `json:"id"`
|
|
||||||
ItemId int `json:"itemId,omitempty"`
|
|
||||||
ParentId int `json:"parentId,omitempty"`
|
|
||||||
Sku string `json:"sku"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Price int64 `json:"price"`
|
|
||||||
TotalPrice int64 `json:"totalPrice"`
|
|
||||||
TotalTax int64 `json:"totalTax"`
|
|
||||||
OrgPrice int64 `json:"orgPrice"`
|
|
||||||
Stock StockStatus `json:"stock"`
|
|
||||||
Quantity int `json:"qty"`
|
|
||||||
Tax int `json:"tax"`
|
|
||||||
TaxRate int `json:"taxRate"`
|
|
||||||
Brand string `json:"brand,omitempty"`
|
|
||||||
Category string `json:"category,omitempty"`
|
|
||||||
Category2 string `json:"category2,omitempty"`
|
|
||||||
Category3 string `json:"category3,omitempty"`
|
|
||||||
Category4 string `json:"category4,omitempty"`
|
|
||||||
Category5 string `json:"category5,omitempty"`
|
|
||||||
Disclaimer string `json:"disclaimer,omitempty"`
|
|
||||||
SellerId string `json:"sellerId,omitempty"`
|
|
||||||
SellerName string `json:"sellerName,omitempty"`
|
|
||||||
ArticleType string `json:"type,omitempty"`
|
|
||||||
Image string `json:"image,omitempty"`
|
|
||||||
Outlet *string `json:"outlet,omitempty"`
|
|
||||||
StoreId *string `json:"storeId,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CartDelivery struct {
|
|
||||||
Id int `json:"id"`
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
Price int64 `json:"price"`
|
|
||||||
Items []int `json:"items"`
|
|
||||||
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CartGrain struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
lastItemId int
|
|
||||||
lastDeliveryId int
|
|
||||||
storageMessages []Message
|
|
||||||
Id CartId `json:"id"`
|
|
||||||
Items []*CartItem `json:"items"`
|
|
||||||
TotalPrice int64 `json:"totalPrice"`
|
|
||||||
TotalTax int64 `json:"totalTax"`
|
|
||||||
TotalDiscount int64 `json:"totalDiscount"`
|
|
||||||
Deliveries []*CartDelivery `json:"deliveries,omitempty"`
|
|
||||||
Processing bool `json:"processing"`
|
|
||||||
PaymentInProgress bool `json:"paymentInProgress"`
|
|
||||||
OrderReference string `json:"orderReference,omitempty"`
|
|
||||||
PaymentStatus string `json:"paymentStatus,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Grain interface {
|
|
||||||
GetId() CartId
|
|
||||||
HandleMessage(message *Message, isReplay bool) (*FrameWithPayload, error)
|
|
||||||
GetCurrentState() (*FrameWithPayload, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CartGrain) GetId() CartId {
|
|
||||||
return c.Id
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CartGrain) GetLastChange() int64 {
|
|
||||||
if len(c.storageMessages) == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return *c.storageMessages[len(c.storageMessages)-1].TimeStamp
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CartGrain) 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 getInt(data float64, ok bool) (int, error) {
|
|
||||||
if !ok {
|
|
||||||
return 0, fmt.Errorf("invalid type")
|
|
||||||
}
|
|
||||||
return int(data), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getItemData(sku string, qty int, country string) (*messages.AddItem, error) {
|
|
||||||
item, err := FetchItem(sku, country)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
orgPrice, _ := getInt(item.GetNumberFieldValue(5)) // getInt(item.Fields[5])
|
|
||||||
|
|
||||||
price, priceErr := getInt(item.GetNumberFieldValue(4)) //Fields[4]
|
|
||||||
|
|
||||||
if priceErr != nil {
|
|
||||||
return nil, fmt.Errorf("invalid price")
|
|
||||||
}
|
|
||||||
|
|
||||||
stock := InStock
|
|
||||||
/*item.t
|
|
||||||
if item.StockLevel == "0" || item.StockLevel == "" {
|
|
||||||
stock = OutOfStock
|
|
||||||
} else if item.StockLevel == "5+" {
|
|
||||||
stock = LowStock
|
|
||||||
}*/
|
|
||||||
articleType, _ := item.GetStringFieldValue(1) //.Fields[1].(string)
|
|
||||||
outletGrade, ok := item.GetStringFieldValue(20) //.Fields[20].(string)
|
|
||||||
var outlet *string
|
|
||||||
if ok {
|
|
||||||
outlet = &outletGrade
|
|
||||||
}
|
|
||||||
sellerId, _ := item.GetStringFieldValue(24) // .Fields[24].(string)
|
|
||||||
sellerName, _ := item.GetStringFieldValue(9) // .Fields[9].(string)
|
|
||||||
|
|
||||||
brand, _ := item.GetStringFieldValue(2) //.Fields[2].(string)
|
|
||||||
category, _ := item.GetStringFieldValue(10) //.Fields[10].(string)
|
|
||||||
category2, _ := item.GetStringFieldValue(11) //.Fields[11].(string)
|
|
||||||
category3, _ := item.GetStringFieldValue(12) //.Fields[12].(string)
|
|
||||||
category4, _ := item.GetStringFieldValue(13) //Fields[13].(string)
|
|
||||||
category5, _ := item.GetStringFieldValue(14) //.Fields[14].(string)
|
|
||||||
|
|
||||||
return &messages.AddItem{
|
|
||||||
ItemId: int64(item.Id),
|
|
||||||
Quantity: int32(qty),
|
|
||||||
Price: int64(price),
|
|
||||||
OrgPrice: int64(orgPrice),
|
|
||||||
Sku: sku,
|
|
||||||
Name: item.Title,
|
|
||||||
Image: item.Img,
|
|
||||||
Stock: int32(stock),
|
|
||||||
Brand: brand,
|
|
||||||
Category: category,
|
|
||||||
Category2: category2,
|
|
||||||
Category3: category3,
|
|
||||||
Category4: category4,
|
|
||||||
Category5: category5,
|
|
||||||
Tax: 2500,
|
|
||||||
SellerId: sellerId,
|
|
||||||
SellerName: sellerName,
|
|
||||||
ArticleType: articleType,
|
|
||||||
Disclaimer: item.Disclaimer,
|
|
||||||
Country: country,
|
|
||||||
Outlet: outlet,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CartGrain) AddItem(sku string, qty int, country string, storeId *string) (*FrameWithPayload, error) {
|
|
||||||
cartItem, err := getItemData(sku, qty, country)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
cartItem.StoreId = storeId
|
|
||||||
return c.HandleMessage(&Message{
|
|
||||||
Type: 2,
|
|
||||||
Content: cartItem,
|
|
||||||
}, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CartGrain) GetStorageMessage(since int64) []StorableMessage {
|
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
ret := make([]StorableMessage, 0)
|
|
||||||
|
|
||||||
for _, message := range c.storageMessages {
|
|
||||||
if *message.TimeStamp > since {
|
|
||||||
ret = append(ret, message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CartGrain) GetState() ([]byte, error) {
|
|
||||||
return json.Marshal(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CartGrain) ItemsWithDelivery() []int {
|
|
||||||
ret := make([]int, 0, len(c.Items))
|
|
||||||
for _, item := range c.Items {
|
|
||||||
for _, delivery := range c.Deliveries {
|
|
||||||
for _, id := range delivery.Items {
|
|
||||||
if item.Id == id {
|
|
||||||
ret = append(ret, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CartGrain) ItemsWithoutDelivery() []int {
|
|
||||||
ret := make([]int, 0, len(c.Items))
|
|
||||||
hasDelivery := c.ItemsWithDelivery()
|
|
||||||
for _, item := range c.Items {
|
|
||||||
found := false
|
|
||||||
for _, id := range hasDelivery {
|
|
||||||
if item.Id == id {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
ret = append(ret, item.Id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CartGrain) FindItemWithSku(sku string) (*CartItem, bool) {
|
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
for _, item := range c.Items {
|
|
||||||
if item.Sku == sku {
|
|
||||||
return item, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetTaxAmount(total int64, tax int) int64 {
|
|
||||||
taxD := 10000 / float64(tax)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CartGrain) UpdateTotals() {
|
|
||||||
c.TotalPrice = 0
|
|
||||||
c.TotalTax = 0
|
|
||||||
c.TotalDiscount = 0
|
|
||||||
for _, item := range c.Items {
|
|
||||||
rowTotal := item.Price * int64(item.Quantity)
|
|
||||||
rowTax := int64(item.Tax) * int64(item.Quantity)
|
|
||||||
item.TotalPrice = rowTotal
|
|
||||||
item.TotalTax = rowTax
|
|
||||||
c.TotalPrice += rowTotal
|
|
||||||
c.TotalTax += rowTax
|
|
||||||
itemDiff := max(0, item.OrgPrice-item.Price)
|
|
||||||
c.TotalDiscount += itemDiff * int64(item.Quantity)
|
|
||||||
}
|
|
||||||
for _, delivery := range c.Deliveries {
|
|
||||||
c.TotalPrice += delivery.Price
|
|
||||||
c.TotalTax += GetTaxAmount(delivery.Price, 2500)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
5
cmd/backoffice/main.go
Normal file
5
cmd/backoffice/main.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Your code here
|
||||||
|
}
|
||||||
61
cmd/cart/amqp-order-handler.go
Normal file
61
cmd/cart/amqp-order-handler.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AmqpOrderHandler struct {
|
||||||
|
Url string
|
||||||
|
Connection *amqp.Connection
|
||||||
|
Channel *amqp.Channel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AmqpOrderHandler) Connect() error {
|
||||||
|
conn, err := amqp.Dial(h.Url)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to RabbitMQ: %w", err)
|
||||||
|
}
|
||||||
|
h.Connection = conn
|
||||||
|
|
||||||
|
ch, err := conn.Channel()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open a channel: %w", err)
|
||||||
|
}
|
||||||
|
h.Channel = ch
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AmqpOrderHandler) Close() error {
|
||||||
|
if h.Channel != nil {
|
||||||
|
h.Channel.Close()
|
||||||
|
}
|
||||||
|
if h.Connection != nil {
|
||||||
|
return h.Connection.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AmqpOrderHandler) OrderCompleted(body []byte) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := h.Channel.PublishWithContext(ctx,
|
||||||
|
"orders", // exchange
|
||||||
|
"new", // routing key
|
||||||
|
false, // mandatory
|
||||||
|
false, // immediate
|
||||||
|
amqp.Publishing{
|
||||||
|
ContentType: "application/json",
|
||||||
|
Body: body,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to publish a message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
268
cmd/cart/cart-grain.go
Normal file
268
cmd/cart/cart-grain.go
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/voucher"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Legacy padded [16]byte CartId and its helper methods removed.
|
||||||
|
// Unified CartId (uint64 with base62 string form) now defined in cart_id.go.
|
||||||
|
|
||||||
|
type StockStatus int
|
||||||
|
|
||||||
|
type ItemMeta struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Brand string `json:"brand,omitempty"`
|
||||||
|
Category string `json:"category,omitempty"`
|
||||||
|
Category2 string `json:"category2,omitempty"`
|
||||||
|
Category3 string `json:"category3,omitempty"`
|
||||||
|
Category4 string `json:"category4,omitempty"`
|
||||||
|
Category5 string `json:"category5,omitempty"`
|
||||||
|
SellerId string `json:"sellerId,omitempty"`
|
||||||
|
SellerName string `json:"sellerName,omitempty"`
|
||||||
|
Image string `json:"image,omitempty"`
|
||||||
|
Outlet *string `json:"outlet,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CartItem struct {
|
||||||
|
Id uint32 `json:"id"`
|
||||||
|
ItemId uint32 `json:"itemId,omitempty"`
|
||||||
|
ParentId uint32 `json:"parentId,omitempty"`
|
||||||
|
Sku string `json:"sku"`
|
||||||
|
Price Price `json:"price"`
|
||||||
|
TotalPrice Price `json:"totalPrice"`
|
||||||
|
OrgPrice *Price `json:"orgPrice,omitempty"`
|
||||||
|
Stock StockStatus `json:"stock"`
|
||||||
|
Quantity int `json:"qty"`
|
||||||
|
Discount *Price `json:"discount,omitempty"`
|
||||||
|
Disclaimer string `json:"disclaimer,omitempty"`
|
||||||
|
ArticleType string `json:"type,omitempty"`
|
||||||
|
StoreId *string `json:"storeId,omitempty"`
|
||||||
|
Meta *ItemMeta `json:"meta,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CartDelivery struct {
|
||||||
|
Id uint32 `json:"id"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Price Price `json:"price"`
|
||||||
|
Items []uint32 `json:"items"`
|
||||||
|
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CartNotification struct {
|
||||||
|
LinkedId int `json:"id"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CartGrain struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
lastItemId uint32
|
||||||
|
lastDeliveryId uint32
|
||||||
|
lastVoucherId uint32
|
||||||
|
lastAccess time.Time
|
||||||
|
lastChange time.Time // unix seconds of last successful mutation (replay sets from event ts)
|
||||||
|
userId string
|
||||||
|
Id CartId `json:"id"`
|
||||||
|
Items []*CartItem `json:"items"`
|
||||||
|
TotalPrice *Price `json:"totalPrice"`
|
||||||
|
TotalDiscount *Price `json:"totalDiscount"`
|
||||||
|
Deliveries []*CartDelivery `json:"deliveries,omitempty"`
|
||||||
|
Processing bool `json:"processing"`
|
||||||
|
PaymentInProgress bool `json:"paymentInProgress"`
|
||||||
|
OrderReference string `json:"orderReference,omitempty"`
|
||||||
|
PaymentStatus string `json:"paymentStatus,omitempty"`
|
||||||
|
Vouchers []*Voucher `json:"vouchers,omitempty"`
|
||||||
|
Notifications []CartNotification `json:"cartNotification,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Voucher struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Rules []*messages.VoucherRule `json:"rules"`
|
||||||
|
Id uint32 `json:"id"`
|
||||||
|
Value int64 `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
|
||||||
|
// No rules -> applies to entire cart
|
||||||
|
if len(v.Rules) == 0 {
|
||||||
|
return cart.Items, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build evaluation context once
|
||||||
|
ctx := voucher.EvalContext{
|
||||||
|
Items: make([]voucher.Item, 0, len(cart.Items)),
|
||||||
|
CartTotalInc: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if cart.TotalPrice != nil {
|
||||||
|
ctx.CartTotalInc = cart.TotalPrice.IncVat
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, it := range cart.Items {
|
||||||
|
category := ""
|
||||||
|
if it.Meta != nil {
|
||||||
|
category = it.Meta.Category
|
||||||
|
}
|
||||||
|
ctx.Items = append(ctx.Items, voucher.Item{
|
||||||
|
Sku: it.Sku,
|
||||||
|
Category: category,
|
||||||
|
UnitPrice: it.Price.IncVat,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// All voucher rules must pass (logical AND)
|
||||||
|
for _, rule := range v.Rules {
|
||||||
|
expr := rule.GetCondition()
|
||||||
|
if expr == "" {
|
||||||
|
// Empty condition treated as pass (acts like a comment / placeholder)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rs, err := voucher.ParseRules(expr)
|
||||||
|
if err != nil {
|
||||||
|
// Fail closed on parse error
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if !rs.Applies(ctx) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cart.Items, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) GetId() uint64 {
|
||||||
|
return uint64(c.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) GetLastChange() time.Time {
|
||||||
|
return c.lastChange
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) GetLastAccess() time.Time {
|
||||||
|
return c.lastAccess
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) GetCurrentState() (*CartGrain, error) {
|
||||||
|
c.lastAccess = time.Now()
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getInt(data float64, ok bool) (int, error) {
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("invalid type")
|
||||||
|
}
|
||||||
|
return int(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.Apply(cartItem, false)
|
||||||
|
// }
|
||||||
|
|
||||||
|
func (c *CartGrain) GetState() ([]byte, error) {
|
||||||
|
return json.Marshal(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) ItemsWithDelivery() []uint32 {
|
||||||
|
ret := make([]uint32, 0, len(c.Items))
|
||||||
|
for _, item := range c.Items {
|
||||||
|
for _, delivery := range c.Deliveries {
|
||||||
|
for _, id := range delivery.Items {
|
||||||
|
if item.Id == id {
|
||||||
|
ret = append(ret, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) ItemsWithoutDelivery() []uint32 {
|
||||||
|
ret := make([]uint32, 0, len(c.Items))
|
||||||
|
hasDelivery := c.ItemsWithDelivery()
|
||||||
|
for _, item := range c.Items {
|
||||||
|
found := slices.Contains(hasDelivery, item.Id)
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
ret = append(ret, item.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) FindItemWithSku(sku string) (*CartItem, bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
for _, item := range c.Items {
|
||||||
|
if item.Sku == sku {
|
||||||
|
return item, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// func (c *CartGrain) Apply(content proto.Message, isReplay bool) (*CartGrain, error) {
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Sliding TTL: update lastChange only for non-replay successful mutations.
|
||||||
|
// if updated != nil && !isReplay {
|
||||||
|
// c.lastChange = time.Now()
|
||||||
|
// c.lastAccess = time.Now()
|
||||||
|
// go AppendCartEvent(c.Id, content)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return updated, nil
|
||||||
|
// }
|
||||||
|
|
||||||
|
func (c *CartGrain) UpdateTotals() {
|
||||||
|
c.TotalPrice = NewPrice()
|
||||||
|
c.TotalDiscount = NewPrice()
|
||||||
|
|
||||||
|
for _, item := range c.Items {
|
||||||
|
rowTotal := MultiplyPrice(item.Price, int64(item.Quantity))
|
||||||
|
|
||||||
|
item.TotalPrice = *rowTotal
|
||||||
|
|
||||||
|
c.TotalPrice.Add(*rowTotal)
|
||||||
|
|
||||||
|
if item.OrgPrice != nil {
|
||||||
|
diff := NewPrice()
|
||||||
|
diff.Add(*item.OrgPrice)
|
||||||
|
diff.Subtract(item.Price)
|
||||||
|
if diff.IncVat > 0 {
|
||||||
|
c.TotalDiscount.Add(*diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
for _, delivery := range c.Deliveries {
|
||||||
|
c.TotalPrice.Add(delivery.Price)
|
||||||
|
}
|
||||||
|
for _, voucher := range c.Vouchers {
|
||||||
|
if _, ok := voucher.AppliesTo(c); ok {
|
||||||
|
value := NewPriceFromIncVat(voucher.Value, 25)
|
||||||
|
|
||||||
|
c.TotalDiscount.Add(*value)
|
||||||
|
c.TotalPrice.Subtract(*value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
cmd/cart/cart_grain_totals_test.go
Normal file
48
cmd/cart/cart_grain_totals_test.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// helper to create a cart grain with items and deliveries
|
||||||
|
func newTestCart() *CartGrain {
|
||||||
|
return &CartGrain{Items: []*CartItem{}, Deliveries: []*CartDelivery{}, Vouchers: []*Voucher{}, Notifications: []CartNotification{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCartGrainUpdateTotalsBasic(t *testing.T) {
|
||||||
|
c := newTestCart()
|
||||||
|
// Item1 price 1250 (ex 1000 vat 250) org price higher -> discount 200 per unit
|
||||||
|
item1Price := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}}
|
||||||
|
item1Org := &Price{IncVat: 1500, VatRates: map[float32]int64{25: 300}}
|
||||||
|
item2Price := Price{IncVat: 2000, VatRates: map[float32]int64{25: 400}}
|
||||||
|
c.Items = []*CartItem{
|
||||||
|
{Id: 1, Price: item1Price, OrgPrice: item1Org, Quantity: 2},
|
||||||
|
{Id: 2, Price: item2Price, OrgPrice: &item2Price, Quantity: 1},
|
||||||
|
}
|
||||||
|
deliveryPrice := Price{IncVat: 4900, VatRates: map[float32]int64{25: 980}}
|
||||||
|
c.Deliveries = []*CartDelivery{{Id: 1, Price: deliveryPrice, Items: []uint32{1, 2}}}
|
||||||
|
|
||||||
|
c.UpdateTotals()
|
||||||
|
|
||||||
|
// Expected totals: sum inc vat of items * qty plus delivery
|
||||||
|
// item1 total inc = 1250*2 = 2500
|
||||||
|
// item2 total inc = 2000*1 = 2000
|
||||||
|
// delivery inc = 4900
|
||||||
|
expectedInc := int64(2500 + 2000 + 4900)
|
||||||
|
if c.TotalPrice.IncVat != expectedInc {
|
||||||
|
t.Fatalf("TotalPrice IncVat expected %d got %d", expectedInc, c.TotalPrice.IncVat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discount: current implementation computes (OrgPrice - Price) ignoring quantity -> 1500-1250=250
|
||||||
|
if c.TotalDiscount.IncVat != 250 {
|
||||||
|
t.Fatalf("TotalDiscount expected 250 got %d", c.TotalDiscount.IncVat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCartGrainUpdateTotalsNoItems(t *testing.T) {
|
||||||
|
c := newTestCart()
|
||||||
|
c.UpdateTotals()
|
||||||
|
if c.TotalPrice.IncVat != 0 || c.TotalDiscount.IncVat != 0 {
|
||||||
|
t.Fatalf("expected zero totals got %+v", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
159
cmd/cart/cart_id.go
Normal file
159
cmd/cart/cart_id.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cart_id.go
|
||||||
|
//
|
||||||
|
// Breaking change:
|
||||||
|
// Unified cart identifier as a raw 64-bit unsigned integer (type CartId uint64).
|
||||||
|
// External textual representation: base62 (0-9 A-Z a-z), shortest possible
|
||||||
|
// encoding for 64 bits (max 11 characters, since 62^11 > 2^64).
|
||||||
|
//
|
||||||
|
// Rationale:
|
||||||
|
// - Replaces legacy fixed [16]byte padded string and transitional CartID wrapper.
|
||||||
|
// - Provides compact, URL/cookie-friendly identifiers.
|
||||||
|
// - O(1) hashing and minimal memory footprint.
|
||||||
|
// - 64 bits of crypto randomness => negligible collision probability at realistic scale.
|
||||||
|
//
|
||||||
|
// Public API:
|
||||||
|
// type CartId uint64
|
||||||
|
// func NewCartId() (CartId, error)
|
||||||
|
// func MustNewCartId() CartId
|
||||||
|
// func ParseCartId(string) (CartId, bool)
|
||||||
|
// func MustParseCartId(string) CartId
|
||||||
|
// (CartId).String() string
|
||||||
|
// (CartId).MarshalJSON() / UnmarshalJSON()
|
||||||
|
//
|
||||||
|
// NOTE:
|
||||||
|
// All legacy helpers (UpgradeLegacyCartId, Fallback hashing, Canonicalize variants,
|
||||||
|
// CartIDToLegacy, LegacyToCartID) have been removed as part of the breaking change.
|
||||||
|
//
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type CartId uint64
|
||||||
|
|
||||||
|
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||||
|
|
||||||
|
// Reverse lookup (0xFF marks invalid)
|
||||||
|
var base62Rev [256]byte
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
for i := range base62Rev {
|
||||||
|
base62Rev[i] = 0xFF
|
||||||
|
}
|
||||||
|
for i := 0; i < len(base62Alphabet); i++ {
|
||||||
|
base62Rev[base62Alphabet[i]] = byte(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the canonical base62 encoding of the 64-bit id.
|
||||||
|
func (id CartId) String() string {
|
||||||
|
return encodeBase62(uint64(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON encodes the cart id as a JSON string.
|
||||||
|
func (id CartId) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(id.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON decodes a cart id from a JSON string containing base62 text.
|
||||||
|
func (id *CartId) UnmarshalJSON(data []byte) error {
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(data, &s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
parsed, ok := ParseCartId(s)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("invalid cart id: %q", s)
|
||||||
|
}
|
||||||
|
*id = parsed
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCartId generates a new cryptographically random non-zero 64-bit id.
|
||||||
|
func NewCartId() (CartId, error) {
|
||||||
|
var b [8]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return 0, fmt.Errorf("NewCartId: %w", err)
|
||||||
|
}
|
||||||
|
u := (uint64(b[0]) << 56) |
|
||||||
|
(uint64(b[1]) << 48) |
|
||||||
|
(uint64(b[2]) << 40) |
|
||||||
|
(uint64(b[3]) << 32) |
|
||||||
|
(uint64(b[4]) << 24) |
|
||||||
|
(uint64(b[5]) << 16) |
|
||||||
|
(uint64(b[6]) << 8) |
|
||||||
|
uint64(b[7])
|
||||||
|
if u == 0 {
|
||||||
|
// Extremely unlikely; regenerate once to avoid "0" identifier if desired.
|
||||||
|
return NewCartId()
|
||||||
|
}
|
||||||
|
return CartId(u), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustNewCartId panics if generation fails.
|
||||||
|
func MustNewCartId() CartId {
|
||||||
|
id, err := NewCartId()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCartId parses a base62 string into a CartId.
|
||||||
|
// Returns (0,false) for invalid input.
|
||||||
|
func ParseCartId(s string) (CartId, bool) {
|
||||||
|
// Accept length 1..11 (11 sufficient for 64 bits). Reject >11 immediately.
|
||||||
|
// Provide a slightly looser upper bound (<=16) only if you anticipate future
|
||||||
|
// extensions; here we stay strict.
|
||||||
|
if len(s) == 0 || len(s) > 11 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
u, ok := decodeBase62(s)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return CartId(u), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustParseCartId panics on invalid base62 input.
|
||||||
|
func MustParseCartId(s string) CartId {
|
||||||
|
id, ok := ParseCartId(s)
|
||||||
|
if !ok {
|
||||||
|
panic(fmt.Sprintf("invalid cart id: %q", s))
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeBase62 converts a uint64 to base62 (shortest form).
|
||||||
|
func encodeBase62(u uint64) string {
|
||||||
|
if u == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var buf [11]byte
|
||||||
|
i := len(buf)
|
||||||
|
for u > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = base62Alphabet[u%62]
|
||||||
|
u /= 62
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeBase62 converts base62 text to uint64.
|
||||||
|
func decodeBase62(s string) (uint64, bool) {
|
||||||
|
var v uint64
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
d := base62Rev[c]
|
||||||
|
if d == 0xFF {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
v = v*62 + uint64(d)
|
||||||
|
}
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
185
cmd/cart/cart_id_test.go
Normal file
185
cmd/cart/cart_id_test.go
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNewCartIdUniqueness generates many ids and checks for collisions.
|
||||||
|
func TestNewCartIdUniqueness(t *testing.T) {
|
||||||
|
const n = 20000
|
||||||
|
seen := make(map[string]struct{}, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
id, err := NewCartId()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCartId error: %v", err)
|
||||||
|
}
|
||||||
|
s := id.String()
|
||||||
|
if _, exists := seen[s]; exists {
|
||||||
|
t.Fatalf("duplicate id encountered: %s", s)
|
||||||
|
}
|
||||||
|
seen[s] = struct{}{}
|
||||||
|
if s == "" {
|
||||||
|
t.Fatalf("empty string representation for id %d", id)
|
||||||
|
}
|
||||||
|
if len(s) > 11 {
|
||||||
|
t.Fatalf("encoded id length exceeds 11 chars: %s (%d)", s, len(s))
|
||||||
|
}
|
||||||
|
if id == 0 {
|
||||||
|
// We force regeneration on zero, extremely unlikely but test guards intent.
|
||||||
|
t.Fatalf("zero id generated (should be regenerated)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseCartIdRoundTrip ensures parse -> string -> parse is stable.
|
||||||
|
func TestParseCartIdRoundTrip(t *testing.T) {
|
||||||
|
id := MustNewCartId()
|
||||||
|
txt := id.String()
|
||||||
|
parsed, ok := ParseCartId(txt)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("ParseCartId failed for valid text %q", txt)
|
||||||
|
}
|
||||||
|
if parsed != id {
|
||||||
|
t.Fatalf("round trip mismatch: original=%d parsed=%d txt=%s", id, parsed, txt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseCartIdInvalid covers invalid inputs.
|
||||||
|
func TestParseCartIdInvalid(t *testing.T) {
|
||||||
|
invalid := []string{
|
||||||
|
"", // empty
|
||||||
|
" ", // space
|
||||||
|
"01234567890abc", // >11 chars
|
||||||
|
"!!!!", // invalid chars
|
||||||
|
"-underscore-", // invalid chars
|
||||||
|
"abc_def", // underscore invalid for base62
|
||||||
|
"0123456789ABCD", // 14 chars
|
||||||
|
}
|
||||||
|
for _, s := range invalid {
|
||||||
|
if _, ok := ParseCartId(s); ok {
|
||||||
|
t.Fatalf("expected parse failure for %q", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMustParseCartIdPanics verifies panic behavior for invalid input.
|
||||||
|
func TestMustParseCartIdPanics(t *testing.T) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r == nil {
|
||||||
|
t.Fatalf("expected panic for invalid MustParseCartId input")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
_ = MustParseCartId("not*base62")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJSONMarshalUnmarshalCartId verifies JSON round trip.
|
||||||
|
func TestJSONMarshalUnmarshalCartId(t *testing.T) {
|
||||||
|
id := MustNewCartId()
|
||||||
|
data, err := json.Marshal(struct {
|
||||||
|
Cart CartId `json:"cart"`
|
||||||
|
}{Cart: id})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal error: %v", err)
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Cart CartId `json:"cart"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &out); err != nil {
|
||||||
|
t.Fatalf("unmarshal error: %v", err)
|
||||||
|
}
|
||||||
|
if out.Cart != id {
|
||||||
|
t.Fatalf("JSON round trip mismatch: have %d got %d", id, out.Cart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBase62LengthBound checks worst-case length (near max uint64).
|
||||||
|
func TestBase62LengthBound(t *testing.T) {
|
||||||
|
// Largest uint64
|
||||||
|
const maxU64 = ^uint64(0)
|
||||||
|
s := encodeBase62(maxU64)
|
||||||
|
if len(s) > 11 {
|
||||||
|
t.Fatalf("max uint64 encoded length > 11: %d (%s)", len(s), s)
|
||||||
|
}
|
||||||
|
dec, ok := decodeBase62(s)
|
||||||
|
if !ok || dec != maxU64 {
|
||||||
|
t.Fatalf("decode failed for max uint64: ok=%v dec=%d want=%d", ok, dec, maxU64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestZeroEncoding ensures zero value encodes to "0" and parses back.
|
||||||
|
func TestZeroEncoding(t *testing.T) {
|
||||||
|
if s := encodeBase62(0); s != "0" {
|
||||||
|
t.Fatalf("encodeBase62(0) expected '0', got %q", s)
|
||||||
|
}
|
||||||
|
v, ok := decodeBase62("0")
|
||||||
|
if !ok || v != 0 {
|
||||||
|
t.Fatalf("decodeBase62('0') failed: ok=%v v=%d", ok, v)
|
||||||
|
}
|
||||||
|
if _, ok := ParseCartId("0"); !ok {
|
||||||
|
t.Fatalf("ParseCartId(\"0\") should succeed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSequentialParse ensures sequentially generated ids parse correctly.
|
||||||
|
func TestSequentialParse(t *testing.T) {
|
||||||
|
for i := 0; i < 1000; i++ {
|
||||||
|
id := MustNewCartId()
|
||||||
|
txt := id.String()
|
||||||
|
parsed, ok := ParseCartId(txt)
|
||||||
|
if !ok || parsed != id {
|
||||||
|
t.Fatalf("sequential parse mismatch: idx=%d orig=%d parsed=%d txt=%s", i, id, parsed, txt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkNewCartId measures generation performance.
|
||||||
|
func BenchmarkNewCartId(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
if _, err := NewCartId(); err != nil {
|
||||||
|
b.Fatalf("NewCartId error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkEncodeBase62 measures encoding performance.
|
||||||
|
func BenchmarkEncodeBase62(b *testing.B) {
|
||||||
|
// Precompute sample values
|
||||||
|
samples := make([]uint64, 1024)
|
||||||
|
for i := range samples {
|
||||||
|
// Spread bits without crypto randomness overhead
|
||||||
|
samples[i] = (uint64(i) << 53) ^ (uint64(i) * 0x9E3779B185EBCA87)
|
||||||
|
}
|
||||||
|
b.ResetTimer()
|
||||||
|
var sink string
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
sink = encodeBase62(samples[i%len(samples)])
|
||||||
|
}
|
||||||
|
_ = sink
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkDecodeBase62 measures decoding performance.
|
||||||
|
func BenchmarkDecodeBase62(b *testing.B) {
|
||||||
|
encoded := make([]string, 1024)
|
||||||
|
for i := range encoded {
|
||||||
|
encoded[i] = encodeBase62((uint64(i) << 32) | uint64(i))
|
||||||
|
}
|
||||||
|
b.ResetTimer()
|
||||||
|
var sum uint64
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
v, ok := decodeBase62(encoded[i%len(encoded)])
|
||||||
|
if !ok {
|
||||||
|
b.Fatalf("decode failure for %s", encoded[i%len(encoded)])
|
||||||
|
}
|
||||||
|
sum ^= v
|
||||||
|
}
|
||||||
|
_ = sum
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExampleCartIdString documents usage of CartId string form.
|
||||||
|
func ExampleCartId_string() {
|
||||||
|
id := MustNewCartId()
|
||||||
|
fmt.Println(len(id.String()) <= 11) // outputs true
|
||||||
|
// Output: true
|
||||||
|
}
|
||||||
119
cmd/cart/checkout_builder.go
Normal file
119
cmd/cart/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.Meta.Name,
|
||||||
|
Quantity: it.Quantity,
|
||||||
|
UnitPrice: int(it.Price.IncVat),
|
||||||
|
TaxRate: 2500, // TODO: derive if variable tax rates are introduced
|
||||||
|
QuantityUnit: "st",
|
||||||
|
TotalAmount: int(it.TotalPrice.IncVat),
|
||||||
|
TotalTaxAmount: int(it.TotalPrice.TotalVat()),
|
||||||
|
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", it.Meta.Image),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delivery lines
|
||||||
|
for _, d := range grain.Deliveries {
|
||||||
|
if d == nil || d.Price.IncVat <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, &Line{
|
||||||
|
Type: "shipping_fee",
|
||||||
|
Reference: d.Provider,
|
||||||
|
Name: "Delivery",
|
||||||
|
Quantity: 1,
|
||||||
|
UnitPrice: int(d.Price.IncVat),
|
||||||
|
TaxRate: 2500,
|
||||||
|
QuantityUnit: "st",
|
||||||
|
TotalAmount: int(d.Price.IncVat),
|
||||||
|
TotalTaxAmount: int(d.Price.TotalVat()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
order := &CheckoutOrder{
|
||||||
|
PurchaseCountry: country,
|
||||||
|
PurchaseCurrency: currency,
|
||||||
|
Locale: locale,
|
||||||
|
OrderAmount: int(grain.TotalPrice.IncVat),
|
||||||
|
OrderTaxAmount: int(grain.TotalPrice.TotalVat()),
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -12,10 +12,15 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
messages "git.tornberg.me/go-cart-actor/proto"
|
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/discovery"
|
||||||
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/proxy"
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/voucher"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
"k8s.io/apimachinery/pkg/watch"
|
||||||
"k8s.io/client-go/kubernetes"
|
"k8s.io/client-go/kubernetes"
|
||||||
"k8s.io/client-go/rest"
|
"k8s.io/client-go/rest"
|
||||||
)
|
)
|
||||||
@@ -35,84 +40,17 @@ 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,
|
|
||||||
}
|
|
||||||
err := loadMessages(ret, id)
|
|
||||||
return ret, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
os.Mkdir("data", 0755)
|
os.Mkdir("data", 0755)
|
||||||
}
|
}
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
pool *GrainLocalPool
|
pool *actor.SimpleGrainPool[CartGrain]
|
||||||
storage *DiskStorage
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) Save() error {
|
|
||||||
hasChanges := false
|
|
||||||
a.pool.mu.RLock()
|
|
||||||
defer a.pool.mu.RUnlock()
|
|
||||||
for id, grain := range a.pool.GetGrains() {
|
|
||||||
if grain == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if grain.GetLastChange() > a.storage.LastSaves[id] {
|
|
||||||
hasChanges = true
|
|
||||||
err := a.storage.Store(id, grain)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error saving grain %s: %v\n", id, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasChanges {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return a.storage.saveState()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) HandleSave(w http.ResponseWriter, r *http.Request) {
|
|
||||||
err := a.Save()
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte(err.Error()))
|
|
||||||
} else {
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var podIp = os.Getenv("POD_IP")
|
var podIp = os.Getenv("POD_IP")
|
||||||
var name = os.Getenv("POD_NAME")
|
var name = os.Getenv("POD_NAME")
|
||||||
var amqpUrl = os.Getenv("AMQP_URL")
|
var amqpUrl = os.Getenv("AMQP_URL")
|
||||||
var KlarnaInstance = NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
|
|
||||||
|
|
||||||
func GetDiscovery() Discovery {
|
|
||||||
if podIp == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
config, kerr := rest.InClusterConfig()
|
|
||||||
|
|
||||||
if kerr != nil {
|
|
||||||
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
|
|
||||||
}
|
|
||||||
client, err := kubernetes.NewForConfig(config)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error creating client: %v\n", err)
|
|
||||||
}
|
|
||||||
return NewK8sDiscovery(client)
|
|
||||||
}
|
|
||||||
|
|
||||||
var tpl = `<!DOCTYPE html>
|
var tpl = `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -132,70 +70,159 @@ func getCountryFromHost(host string) string {
|
|||||||
if strings.Contains(strings.ToLower(host), "-no") {
|
if strings.Contains(strings.ToLower(host), "-no") {
|
||||||
return "no"
|
return "no"
|
||||||
}
|
}
|
||||||
|
if strings.Contains(strings.ToLower(host), "-se") {
|
||||||
return "se"
|
return "se"
|
||||||
}
|
}
|
||||||
|
return ""
|
||||||
func getCheckoutOrder(host string, cartId CartId) *messages.CreateCheckoutOrder {
|
|
||||||
baseUrl := fmt.Sprintf("https://%s", host)
|
|
||||||
cartBaseUrl := os.Getenv("CART_BASE_URL")
|
|
||||||
if cartBaseUrl == "" {
|
|
||||||
cartBaseUrl = "https://cart.tornberg.me"
|
|
||||||
}
|
}
|
||||||
country := getCountryFromHost(host)
|
|
||||||
|
|
||||||
return &messages.CreateCheckoutOrder{
|
func GetDiscovery() discovery.Discovery {
|
||||||
Terms: fmt.Sprintf("%s/terms", baseUrl),
|
if podIp == "" {
|
||||||
Checkout: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}", baseUrl),
|
return nil
|
||||||
Confirmation: fmt.Sprintf("%s/confirmation/{checkout.order.id}", baseUrl),
|
|
||||||
Validation: fmt.Sprintf("%s/validation", cartBaseUrl),
|
|
||||||
Push: fmt.Sprintf("%s/push?order_id={checkout.order.id}", cartBaseUrl),
|
|
||||||
Country: country,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
config, kerr := rest.InClusterConfig()
|
||||||
|
|
||||||
|
if kerr != nil {
|
||||||
|
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
|
||||||
|
}
|
||||||
|
client, err := kubernetes.NewForConfig(config)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error creating client: %v\n", err)
|
||||||
|
}
|
||||||
|
return discovery.NewK8sDiscovery(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
type MutationContext struct {
|
||||||
|
VoucherService voucher.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
||||||
storage, err := NewDiskStorage(fmt.Sprintf("data/%s_state.gob", name))
|
controlPlaneConfig := actor.DefaultServerConfig()
|
||||||
|
|
||||||
|
reg := actor.NewMutationRegistry()
|
||||||
|
reg.RegisterMutations(
|
||||||
|
actor.NewMutation(AddItem, func() *messages.AddItem {
|
||||||
|
return &messages.AddItem{}
|
||||||
|
}),
|
||||||
|
actor.NewMutation(ChangeQuantity, func() *messages.ChangeQuantity {
|
||||||
|
return &messages.ChangeQuantity{}
|
||||||
|
}),
|
||||||
|
actor.NewMutation(RemoveItem, func() *messages.RemoveItem {
|
||||||
|
return &messages.RemoveItem{}
|
||||||
|
}),
|
||||||
|
actor.NewMutation(InitializeCheckout, func() *messages.InitializeCheckout {
|
||||||
|
return &messages.InitializeCheckout{}
|
||||||
|
}),
|
||||||
|
actor.NewMutation(OrderCreated, func() *messages.OrderCreated {
|
||||||
|
return &messages.OrderCreated{}
|
||||||
|
}),
|
||||||
|
actor.NewMutation(RemoveDelivery, func() *messages.RemoveDelivery {
|
||||||
|
return &messages.RemoveDelivery{}
|
||||||
|
}),
|
||||||
|
actor.NewMutation(SetDelivery, func() *messages.SetDelivery {
|
||||||
|
return &messages.SetDelivery{}
|
||||||
|
}),
|
||||||
|
actor.NewMutation(SetPickupPoint, func() *messages.SetPickupPoint {
|
||||||
|
return &messages.SetPickupPoint{}
|
||||||
|
}),
|
||||||
|
actor.NewMutation(ClearCart, func() *messages.ClearCartRequest {
|
||||||
|
return &messages.ClearCartRequest{}
|
||||||
|
}),
|
||||||
|
actor.NewMutation(AddVoucher, func() *messages.AddVoucher {
|
||||||
|
return &messages.AddVoucher{}
|
||||||
|
}),
|
||||||
|
actor.NewMutation(RemoveVoucher, func() *messages.RemoveVoucher {
|
||||||
|
return &messages.RemoveVoucher{}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
diskStorage := actor.NewDiskStorage[CartGrain]("data", reg)
|
||||||
|
poolConfig := actor.GrainPoolConfig[CartGrain]{
|
||||||
|
MutationRegistry: reg,
|
||||||
|
Storage: diskStorage,
|
||||||
|
Spawn: func(id uint64) (actor.Grain[CartGrain], error) {
|
||||||
|
grainSpawns.Inc()
|
||||||
|
ret := &CartGrain{
|
||||||
|
lastItemId: 0,
|
||||||
|
lastDeliveryId: 0,
|
||||||
|
Deliveries: []*CartDelivery{},
|
||||||
|
Id: CartId(id),
|
||||||
|
Items: []*CartItem{},
|
||||||
|
TotalPrice: NewPrice(),
|
||||||
|
}
|
||||||
|
// Set baseline lastChange at spawn; replay may update it to last event timestamp.
|
||||||
|
ret.lastChange = time.Now()
|
||||||
|
ret.lastAccess = time.Now()
|
||||||
|
|
||||||
|
err := diskStorage.LoadEvents(id, ret)
|
||||||
|
|
||||||
|
return ret, err
|
||||||
|
},
|
||||||
|
SpawnHost: func(host string) (actor.Host, error) {
|
||||||
|
return proxy.NewRemoteHost(host)
|
||||||
|
},
|
||||||
|
TTL: 15 * time.Minute,
|
||||||
|
PoolSize: 2 * 65535,
|
||||||
|
Hostname: podIp,
|
||||||
|
}
|
||||||
|
|
||||||
|
pool, err := actor.NewSimpleGrainPool(poolConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error loading state: %v\n", err)
|
log.Fatalf("Error creating cart pool: %v\n", err)
|
||||||
}
|
}
|
||||||
app := &App{
|
app := &App{
|
||||||
pool: NewGrainLocalPool(65535, 5*time.Minute, spawn),
|
pool: pool,
|
||||||
storage: storage,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
syncedPool, err := NewSyncedPool(app.pool, podIp, GetDiscovery())
|
grpcSrv, err := actor.NewControlServer[*CartGrain](controlPlaneConfig, pool)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error creating synced pool: %v\n", err)
|
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
|
||||||
}
|
|
||||||
|
|
||||||
// Start unified gRPC server (CartActor + ControlPlane) replacing legacy RPC server on :1337
|
|
||||||
// TODO: Remove any remaining legacy RPC server references and deprecated frame-based code after full gRPC migration is validated.
|
|
||||||
grpcSrv, err := StartGRPCServer(":1337", app.pool, syncedPool)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error starting gRPC server: %v\n", err)
|
|
||||||
}
|
}
|
||||||
defer grpcSrv.GracefulStop()
|
defer grpcSrv.GracefulStop()
|
||||||
|
|
||||||
go func() {
|
go diskStorage.SaveLoop(10 * time.Second)
|
||||||
for range time.Tick(time.Minute * 10) {
|
|
||||||
err := app.Save()
|
go func(hw discovery.Discovery) {
|
||||||
|
if hw == nil {
|
||||||
|
log.Print("No discovery service available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch, err := hw.Watch()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error saving: %v\n", err)
|
log.Printf("Discovery error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for evt := range ch {
|
||||||
|
if evt.Host == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch evt.Type {
|
||||||
|
case watch.Deleted:
|
||||||
|
if pool.IsKnown(evt.Host) {
|
||||||
|
pool.RemoveHost(evt.Host)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if !pool.IsKnown(evt.Host) {
|
||||||
|
log.Printf("Discovered host %s", evt.Host)
|
||||||
|
pool.AddRemote(evt.Host)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}
|
||||||
|
}(GetDiscovery())
|
||||||
|
|
||||||
orderHandler := &AmqpOrderHandler{
|
orderHandler := &AmqpOrderHandler{
|
||||||
Url: amqpUrl,
|
Url: amqpUrl,
|
||||||
}
|
}
|
||||||
|
klarnaClient := NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
|
||||||
|
|
||||||
syncedServer := NewPoolServer(syncedPool, fmt.Sprintf("%s, %s", name, podIp))
|
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient)
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("/cart/", http.StripPrefix("/cart", syncedServer.Serve()))
|
mux.Handle("/cart/", http.StripPrefix("/cart", syncedServer.Serve()))
|
||||||
// only for local
|
// only for local
|
||||||
// mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
// syncedPool.AddRemote(r.PathValue("host"))
|
pool.AddRemote(r.PathValue("host"))
|
||||||
// })
|
})
|
||||||
// mux.HandleFunc("GET /save", app.HandleSave)
|
// mux.HandleFunc("GET /save", app.HandleSave)
|
||||||
//mux.HandleFunc("/", app.RewritePath)
|
//mux.HandleFunc("/", app.RewritePath)
|
||||||
mux.HandleFunc("/debug/pprof/", pprof.Index)
|
mux.HandleFunc("/debug/pprof/", pprof.Index)
|
||||||
@@ -206,16 +233,13 @@ func main() {
|
|||||||
mux.Handle("/metrics", promhttp.Handler())
|
mux.Handle("/metrics", promhttp.Handler())
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Grain pool health: simple capacity check (mirrors previous GrainHandler.IsHealthy)
|
// Grain pool health: simple capacity check (mirrors previous GrainHandler.IsHealthy)
|
||||||
app.pool.mu.RLock()
|
grainCount, capacity := app.pool.LocalUsage()
|
||||||
grainCount := len(app.pool.grains)
|
|
||||||
capacity := app.pool.PoolSize
|
|
||||||
app.pool.mu.RUnlock()
|
|
||||||
if grainCount >= capacity {
|
if grainCount >= capacity {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
w.Write([]byte("grain pool at capacity"))
|
w.Write([]byte("grain pool at capacity"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !syncedPool.IsHealthy() {
|
if !pool.IsHealthy() {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
w.Write([]byte("control plane not healthy"))
|
w.Write([]byte("control plane not healthy"))
|
||||||
return
|
return
|
||||||
@@ -248,39 +272,49 @@ func main() {
|
|||||||
w.Write([]byte("no cart id to checkout is empty"))
|
w.Write([]byte("no cart id to checkout is empty"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cartId := ToCartId(cookie.Value)
|
parsed, ok := ParseCartId(cookie.Value)
|
||||||
reply, err := syncedServer.pool.Process(cartId, Message{
|
if !ok {
|
||||||
Type: CreateCheckoutOrderType,
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
Content: getCheckoutOrder(r.Host, cartId),
|
w.Write([]byte("invalid cart id format"))
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte(err.Error()))
|
|
||||||
}
|
|
||||||
err = json.Unmarshal(reply.Payload, &order)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte(err.Error()))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
cartId := parsed
|
||||||
|
syncedServer.ProxyHandler(func(w http.ResponseWriter, r *http.Request, cartId CartId) error {
|
||||||
|
order, err = syncedServer.CreateOrUpdateCheckout(r.Host, cartId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
||||||
|
return nil
|
||||||
|
})(cartId, w, r)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// v2: Apply now returns *CartGrain; order creation handled inside grain (no payload to unmarshal)
|
||||||
} else {
|
} else {
|
||||||
prevOrder, err := KlarnaInstance.GetOrder(orderId)
|
order, err = klarnaClient.GetOrder(orderId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
w.Write([]byte(err.Error()))
|
w.Write([]byte(err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
order = prevOrder
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
|
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
w.Write([]byte(fmt.Sprintf(tpl, order.HTMLSnippet)))
|
fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
||||||
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
orderId := r.PathValue("order_id")
|
orderId := r.PathValue("order_id")
|
||||||
order, err := KlarnaInstance.GetOrder(orderId)
|
order, err := klarnaClient.GetOrder(orderId)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
@@ -302,7 +336,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
w.Write([]byte(fmt.Sprintf(tpl, order.HTMLSnippet)))
|
fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/validate", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/validate", func(w http.ResponseWriter, r *http.Request) {
|
||||||
log.Printf("Klarna order validation, method: %s", r.Method)
|
log.Printf("Klarna order validation, method: %s", r.Method)
|
||||||
@@ -340,7 +374,7 @@ func main() {
|
|||||||
orderId := r.URL.Query().Get("order_id")
|
orderId := r.URL.Query().Get("order_id")
|
||||||
log.Printf("Order confirmation push: %s", orderId)
|
log.Printf("Order confirmation push: %s", orderId)
|
||||||
|
|
||||||
order, err := KlarnaInstance.GetOrder(orderId)
|
order, err := klarnaClient.GetOrder(orderId)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error creating request: %v\n", err)
|
log.Printf("Error creating request: %v\n", err)
|
||||||
@@ -355,13 +389,13 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = triggerOrderCompleted(err, syncedServer, order)
|
err = triggerOrderCompleted(syncedServer, order)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error processing cart message: %v\n", err)
|
log.Printf("Error processing cart message: %v\n", err)
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err = KlarnaInstance.AcknowledgeOrder(orderId)
|
err = klarnaClient.AcknowledgeOrder(orderId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error acknowledging order: %v\n", err)
|
log.Printf("Error acknowledging order: %v\n", err)
|
||||||
}
|
}
|
||||||
@@ -373,6 +407,8 @@ func main() {
|
|||||||
w.Write([]byte("1.0.0"))
|
w.Write([]byte("1.0.0"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/openapi.json", ServeEmbeddedOpenAPI)
|
||||||
|
|
||||||
sigs := make(chan os.Signal, 1)
|
sigs := make(chan os.Signal, 1)
|
||||||
done := make(chan bool, 1)
|
done := make(chan bool, 1)
|
||||||
signal.Notify(sigs, syscall.SIGTERM)
|
signal.Notify(sigs, syscall.SIGTERM)
|
||||||
@@ -380,8 +416,9 @@ func main() {
|
|||||||
go func() {
|
go func() {
|
||||||
sig := <-sigs
|
sig := <-sigs
|
||||||
fmt.Println("Shutting down due to signal:", sig)
|
fmt.Println("Shutting down due to signal:", sig)
|
||||||
go syncedPool.Close()
|
diskStorage.Close()
|
||||||
app.Save()
|
pool.Close()
|
||||||
|
|
||||||
done <- true
|
done <- true
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -391,15 +428,18 @@ func main() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func triggerOrderCompleted(err error, syncedServer *PoolServer, order *CheckoutOrder) error {
|
func triggerOrderCompleted(syncedServer *PoolServer, order *CheckoutOrder) error {
|
||||||
_, err = syncedServer.pool.Process(ToCartId(order.MerchantReference1), Message{
|
mutation := &messages.OrderCreated{
|
||||||
Type: OrderCompletedType,
|
|
||||||
Content: &messages.OrderCreated{
|
|
||||||
OrderId: order.ID,
|
OrderId: order.ID,
|
||||||
Status: order.Status,
|
Status: order.Status,
|
||||||
},
|
}
|
||||||
})
|
cid, ok := ParseCartId(order.MerchantReference1)
|
||||||
return err
|
if !ok {
|
||||||
|
return fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
|
||||||
|
}
|
||||||
|
_, applyErr := syncedServer.Apply(uint64(cid), mutation)
|
||||||
|
|
||||||
|
return applyErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func confirmOrder(order *CheckoutOrder, orderHandler *AmqpOrderHandler) error {
|
func confirmOrder(order *CheckoutOrder, orderHandler *AmqpOrderHandler) error {
|
||||||
91
cmd/cart/mutation_add_item.go
Normal file
91
cmd/cart/mutation_add_item.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_add_item.go
|
||||||
|
//
|
||||||
|
// Registers the AddItem cart mutation in the generic mutation registry.
|
||||||
|
// This replaces the legacy switch-based logic previously found in CartGrain.Apply.
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// * Validates quantity > 0
|
||||||
|
// * If an item with same SKU exists -> increases quantity
|
||||||
|
// * Else creates a new CartItem with computed tax amounts
|
||||||
|
// * Totals recalculated automatically via WithTotals()
|
||||||
|
//
|
||||||
|
// NOTE: Any future field additions in messages.AddItem that affect pricing / tax
|
||||||
|
// must keep this handler in sync.
|
||||||
|
|
||||||
|
func AddItem(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 {
|
||||||
|
if existing.StoreId == m.StoreId {
|
||||||
|
existing.Quantity += int(m.Quantity)
|
||||||
|
existing.Stock = StockStatus(m.Stock)
|
||||||
|
existing.StoreId = m.StoreId
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
|
||||||
|
g.lastItemId++
|
||||||
|
taxRate := float32(25.0)
|
||||||
|
if m.Tax > 0 {
|
||||||
|
taxRate = float32(int(m.Tax) / 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
pricePerItem := NewPriceFromIncVat(m.Price, taxRate)
|
||||||
|
|
||||||
|
g.Items = append(g.Items, &CartItem{
|
||||||
|
Id: g.lastItemId,
|
||||||
|
ItemId: uint32(m.ItemId),
|
||||||
|
Quantity: int(m.Quantity),
|
||||||
|
Sku: m.Sku,
|
||||||
|
Meta: &ItemMeta{
|
||||||
|
Name: m.Name,
|
||||||
|
Image: m.Image,
|
||||||
|
Brand: m.Brand,
|
||||||
|
Category: m.Category,
|
||||||
|
Category2: m.Category2,
|
||||||
|
Category3: m.Category3,
|
||||||
|
Category4: m.Category4,
|
||||||
|
Category5: m.Category5,
|
||||||
|
Outlet: m.Outlet,
|
||||||
|
SellerId: m.SellerId,
|
||||||
|
SellerName: m.SellerName,
|
||||||
|
},
|
||||||
|
|
||||||
|
Price: *pricePerItem,
|
||||||
|
TotalPrice: *MultiplyPrice(*pricePerItem, int64(m.Quantity)),
|
||||||
|
|
||||||
|
Stock: StockStatus(m.Stock),
|
||||||
|
Disclaimer: m.Disclaimer,
|
||||||
|
|
||||||
|
OrgPrice: getOrgPrice(m.OrgPrice, taxRate),
|
||||||
|
ArticleType: m.ArticleType,
|
||||||
|
|
||||||
|
StoreId: m.StoreId,
|
||||||
|
})
|
||||||
|
g.UpdateTotals()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOrgPrice(orgPrice int64, taxRate float32) *Price {
|
||||||
|
if orgPrice <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return NewPriceFromIncVat(orgPrice, taxRate)
|
||||||
|
}
|
||||||
64
cmd/cart/mutation_add_voucher.go
Normal file
64
cmd/cart/mutation_add_voucher.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RemoveVoucher(g *CartGrain, m *messages.RemoveVoucher) error {
|
||||||
|
if m == nil {
|
||||||
|
return &actor.MutationError{
|
||||||
|
Message: "RemoveVoucher: nil payload",
|
||||||
|
Code: 1003,
|
||||||
|
StatusCode: 400,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.ContainsFunc(g.Vouchers, func(v *Voucher) bool {
|
||||||
|
return v.Id == m.Id
|
||||||
|
}) {
|
||||||
|
return &actor.MutationError{
|
||||||
|
Message: "voucher not applied",
|
||||||
|
Code: 1004,
|
||||||
|
StatusCode: 400,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Vouchers = slices.DeleteFunc(g.Vouchers, func(v *Voucher) bool {
|
||||||
|
return v.Id == m.Id
|
||||||
|
})
|
||||||
|
g.UpdateTotals()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddVoucher(g *CartGrain, m *messages.AddVoucher) error {
|
||||||
|
if m == nil {
|
||||||
|
return &actor.MutationError{
|
||||||
|
Message: "AddVoucher: nil payload",
|
||||||
|
Code: 1001,
|
||||||
|
StatusCode: 400,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.ContainsFunc(g.Vouchers, func(v *Voucher) bool {
|
||||||
|
return v.Code == m.Code
|
||||||
|
}) {
|
||||||
|
return &actor.MutationError{
|
||||||
|
Message: "voucher already applied",
|
||||||
|
Code: 1002,
|
||||||
|
StatusCode: 400,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g.lastVoucherId++
|
||||||
|
g.Vouchers = append(g.Vouchers, &Voucher{
|
||||||
|
Id: g.lastVoucherId,
|
||||||
|
Code: m.Code,
|
||||||
|
Rules: m.VoucherRules,
|
||||||
|
Value: m.Value,
|
||||||
|
})
|
||||||
|
g.UpdateTotals()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
54
cmd/cart/mutation_change_quantity.go
Normal file
54
cmd/cart/mutation_change_quantity.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_change_quantity.go
|
||||||
|
//
|
||||||
|
// Registers the ChangeQuantity mutation.
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - Locates an item by its cart-local line item Id (not source item_id).
|
||||||
|
// - If requested quantity <= 0 the line is removed.
|
||||||
|
// - Otherwise the line's Quantity field is updated.
|
||||||
|
// - Totals are recalculated (WithTotals).
|
||||||
|
//
|
||||||
|
// Error handling:
|
||||||
|
// - Returns an error if the item Id is not found.
|
||||||
|
// - Returns an error if payload is nil (defensive).
|
||||||
|
//
|
||||||
|
// Concurrency:
|
||||||
|
// - Uses the grain's RW-safe mutation pattern: we mutate in place under
|
||||||
|
// the grain's implicit expectation that higher layers control access.
|
||||||
|
// (If strict locking is required around every mutation, wrap logic in
|
||||||
|
// an explicit g.mu.Lock()/Unlock(), but current model mirrors prior code.)
|
||||||
|
|
||||||
|
func ChangeQuantity(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 == uint32(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)
|
||||||
|
g.UpdateTotals()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
44
cmd/cart/mutation_initialize_checkout.go
Normal file
44
cmd/cart/mutation_initialize_checkout.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_initialize_checkout.go
|
||||||
|
//
|
||||||
|
// Registers the InitializeCheckout mutation.
|
||||||
|
// This mutation is invoked AFTER an external Klarna checkout session
|
||||||
|
// has been successfully created or updated. It persists the Klarna
|
||||||
|
// order reference / status and marks the cart as having a payment in progress.
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - Sets OrderReference to the Klarna order ID (overwriting if already set).
|
||||||
|
// - Sets PaymentStatus to the current Klarna status.
|
||||||
|
// - Sets / updates PaymentInProgress flag.
|
||||||
|
// - Does NOT alter pricing or line items (so no totals recalculation).
|
||||||
|
//
|
||||||
|
// Validation:
|
||||||
|
// - Returns an error if payload is nil.
|
||||||
|
// - Returns an error if orderId is empty (integrity guard).
|
||||||
|
//
|
||||||
|
// Concurrency:
|
||||||
|
// - Relies on upstream mutation serialization for a single grain. If
|
||||||
|
// parallel checkout attempts are possible, add higher-level guards
|
||||||
|
// (e.g. reject if PaymentInProgress already true unless reusing
|
||||||
|
// the same OrderReference).
|
||||||
|
|
||||||
|
func InitializeCheckout(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
|
||||||
|
}
|
||||||
48
cmd/cart/mutation_order_created.go
Normal file
48
cmd/cart/mutation_order_created.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_order_created.go
|
||||||
|
//
|
||||||
|
// Registers the OrderCreated mutation.
|
||||||
|
//
|
||||||
|
// This mutation represents the completion (or state transition) of an order
|
||||||
|
// initiated earlier via InitializeCheckout / external Klarna processing.
|
||||||
|
// It finalizes (or updates) the cart's order metadata.
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - Validates payload non-nil and OrderId not empty.
|
||||||
|
// - Sets (or overwrites) OrderReference with the provided OrderId.
|
||||||
|
// - Sets PaymentStatus from payload.Status.
|
||||||
|
// - Marks PaymentInProgress = false (checkout flow finished / acknowledged).
|
||||||
|
// - Does NOT adjust monetary totals (no WithTotals()).
|
||||||
|
//
|
||||||
|
// Notes / Future Extensions:
|
||||||
|
// - If multiple order completion events can arrive (e.g., retries / webhook
|
||||||
|
// replays), this handler is idempotent: it simply overwrites fields.
|
||||||
|
// - If you need to guard against conflicting order IDs, add a check:
|
||||||
|
// if g.OrderReference != "" && g.OrderReference != m.OrderId { ... }
|
||||||
|
// - Add audit logging or metrics here if required.
|
||||||
|
//
|
||||||
|
// Concurrency:
|
||||||
|
// - Relies on the higher-level guarantee that Apply() calls are serialized
|
||||||
|
// per grain. If out-of-order events are possible, embed versioning or
|
||||||
|
// timestamps in the mutation and compare before applying changes.
|
||||||
|
|
||||||
|
func OrderCreated(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
|
||||||
|
}
|
||||||
49
cmd/cart/mutation_remove_delivery.go
Normal file
49
cmd/cart/mutation_remove_delivery.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_remove_delivery.go
|
||||||
|
//
|
||||||
|
// Registers the RemoveDelivery mutation.
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - Removes the delivery entry whose Id == payload.Id.
|
||||||
|
// - If not found, returns an error.
|
||||||
|
// - Cart totals are recalculated (WithTotals) after removal.
|
||||||
|
// - Items previously associated with that delivery simply become "without delivery";
|
||||||
|
// subsequent delivery mutations can reassign them.
|
||||||
|
//
|
||||||
|
// Differences vs legacy:
|
||||||
|
// - Legacy logic decremented TotalPrice explicitly before recalculating.
|
||||||
|
// Here we rely solely on UpdateTotals() to recompute from remaining
|
||||||
|
// deliveries and items (simpler / single source of truth).
|
||||||
|
//
|
||||||
|
// Future considerations:
|
||||||
|
// - If delivery pricing logic changes (e.g., dynamic taxes per delivery),
|
||||||
|
// UpdateTotals() may need enhancement to incorporate delivery tax properly.
|
||||||
|
|
||||||
|
func RemoveDelivery(g *CartGrain, m *messages.RemoveDelivery) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("RemoveDelivery: nil payload")
|
||||||
|
}
|
||||||
|
targetID := uint32(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:]...)
|
||||||
|
g.UpdateTotals()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
45
cmd/cart/mutation_remove_item.go
Normal file
45
cmd/cart/mutation_remove_item.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_remove_item.go
|
||||||
|
//
|
||||||
|
// Registers the RemoveItem mutation.
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - Removes the cart line whose local cart line Id == payload.Id
|
||||||
|
// - If no such line exists returns an error
|
||||||
|
// - Recalculates cart totals (WithTotals)
|
||||||
|
//
|
||||||
|
// Notes:
|
||||||
|
// - This removes only the line item; any deliveries referencing the removed
|
||||||
|
// item are NOT automatically adjusted (mirrors prior logic). If future
|
||||||
|
// semantics require pruning delivery.item_ids you can extend this handler.
|
||||||
|
// - If multiple lines somehow shared the same Id (should not happen), only
|
||||||
|
// the first match would be removed—data integrity relies on unique line Ids.
|
||||||
|
|
||||||
|
func RemoveItem(g *CartGrain, m *messages.RemoveItem) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("RemoveItem: nil payload")
|
||||||
|
}
|
||||||
|
targetID := uint32(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:]...)
|
||||||
|
g.UpdateTotals()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
96
cmd/cart/mutation_set_delivery.go
Normal file
96
cmd/cart/mutation_set_delivery.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_set_delivery.go
|
||||||
|
//
|
||||||
|
// Registers the SetDelivery mutation.
|
||||||
|
//
|
||||||
|
// Semantics (mirrors legacy switch logic):
|
||||||
|
// - If the payload specifies an explicit list of item IDs (payload.Items):
|
||||||
|
// - Each referenced cart line must exist.
|
||||||
|
// - None of the referenced items may already belong to a delivery.
|
||||||
|
// - Only those items are associated with the new delivery.
|
||||||
|
// - If payload.Items is empty:
|
||||||
|
// - All items currently without any delivery are associated with the new delivery.
|
||||||
|
// - A new delivery line is created with:
|
||||||
|
// - Auto-incremented delivery ID (cart-local)
|
||||||
|
// - Provider from payload
|
||||||
|
// - Fixed price (currently hard-coded: 4900 minor units) – adjust as needed
|
||||||
|
// - Optional PickupPoint copied from payload
|
||||||
|
// - Cart totals are recalculated (WithTotals)
|
||||||
|
//
|
||||||
|
// Error cases:
|
||||||
|
// - Referenced item does not exist
|
||||||
|
// - Referenced item already has a delivery
|
||||||
|
// - No items qualify (resulting association set empty) -> returns error (prevents creating empty delivery)
|
||||||
|
//
|
||||||
|
// Concurrency:
|
||||||
|
// - Uses g.mu to protect lastDeliveryId increment and append to Deliveries slice.
|
||||||
|
// Item scans are read-only and performed outside the lock for simplicity;
|
||||||
|
// if stricter guarantees are needed, widen the lock section.
|
||||||
|
//
|
||||||
|
// Future extension points:
|
||||||
|
// - Variable delivery pricing (based on weight, distance, provider, etc.)
|
||||||
|
// - Validation of provider codes
|
||||||
|
// - Multi-currency delivery pricing
|
||||||
|
|
||||||
|
func SetDelivery(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([]uint32, 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 := uint32(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: *NewPriceFromIncVat(4900, 25.0),
|
||||||
|
Items: targetItems,
|
||||||
|
})
|
||||||
|
g.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
62
cmd/cart/mutation_set_pickup_point.go
Normal file
62
cmd/cart/mutation_set_pickup_point.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_set_pickup_point.go
|
||||||
|
//
|
||||||
|
// Registers the SetPickupPoint mutation using the generic mutation registry.
|
||||||
|
//
|
||||||
|
// Semantics (mirrors original switch-based implementation):
|
||||||
|
// - Locate the delivery with Id == payload.DeliveryId
|
||||||
|
// - Set (or overwrite) its PickupPoint with the provided data
|
||||||
|
// - Does NOT alter pricing or taxes (so no totals recalculation required)
|
||||||
|
//
|
||||||
|
// Validation / Error Handling:
|
||||||
|
// - If payload is nil -> error
|
||||||
|
// - If DeliveryId not found -> error
|
||||||
|
//
|
||||||
|
// Concurrency:
|
||||||
|
// - Relies on the existing expectation that higher-level mutation routing
|
||||||
|
// serializes Apply() calls per grain; if stricter guarantees are needed,
|
||||||
|
// a delivery-level lock could be introduced later.
|
||||||
|
//
|
||||||
|
// Future Extensions:
|
||||||
|
// - Validate pickup point fields (country code, zip format, etc.)
|
||||||
|
// - Track history / audit of pickup point changes
|
||||||
|
// - Trigger delivery price adjustments (which would then require WithTotals()).
|
||||||
|
|
||||||
|
func SetPickupPoint(g *CartGrain, m *messages.SetPickupPoint) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("SetPickupPoint: nil payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, d := range g.Deliveries {
|
||||||
|
if d.Id == uint32(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClearCart(g *CartGrain, m *messages.ClearCartRequest) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("ClearCart: nil payload")
|
||||||
|
}
|
||||||
|
// maybe check if payment is done?
|
||||||
|
g.Deliveries = g.Deliveries[:0]
|
||||||
|
g.Items = g.Items[:0]
|
||||||
|
g.UpdateTotals()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
677
cmd/cart/openapi.json
Normal file
677
cmd/cart/openapi.json
Normal file
@@ -0,0 +1,677 @@
|
|||||||
|
{
|
||||||
|
"openapi": "3.0.3",
|
||||||
|
"info": {
|
||||||
|
"title": "Cart Service API",
|
||||||
|
"description": "HTTP API for shopping cart operations (cookie-based or explicit id): retrieve cart, add/replace items, update quantity, manage deliveries.",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"url": "https://cart.tornberg.me",
|
||||||
|
"description": "Production server"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://localhost:8080",
|
||||||
|
"description": "Local development (cart API mounted under /cart)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"/cart/": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Get (or create) current cart (cookie based)",
|
||||||
|
"description": "Returns the current cart. If no cartid cookie is present a new cart is created and Set-Cart-Id response header plus a Set-Cookie header are sent.",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Cart retrieved",
|
||||||
|
"headers": {
|
||||||
|
"Set-Cart-Id": {
|
||||||
|
"description": "Returned when a new cart was created this request",
|
||||||
|
"schema": { "type": "string" }
|
||||||
|
},
|
||||||
|
"X-Pod-Name": {
|
||||||
|
"description": "Pod identifier serving the request",
|
||||||
|
"schema": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": { "description": "Server error" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"summary": "Add single SKU (body)",
|
||||||
|
"description": "Adds (or increases quantity of) a single SKU using request body.",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/AddRequest" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Item added",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": { "description": "Invalid request body" },
|
||||||
|
"500": { "description": "Server error" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"summary": "Change quantity of an item",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/ChangeQuantity" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Quantity updated",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": { "description": "Invalid request body" },
|
||||||
|
"500": { "description": "Server error" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"summary": "Clear cart cookie (logical cart reused only if referenced later)",
|
||||||
|
"description": "Removes the cartid cookie by expiring it. Does not mutate server-side cart state.",
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Cookie cleared (empty body)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/cart/add/{sku}": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Add a SKU (path)",
|
||||||
|
"description": "Adds a single SKU with implicit quantity 1. Country inferred from Host header (-se / -no).",
|
||||||
|
"parameters": [{ "$ref": "#/components/parameters/SkuParam" }],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Item added",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": { "description": "Server error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/cart/add": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Add multiple items (append)",
|
||||||
|
"description": "Adds multiple items to the cart without clearing existing contents.",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/SetCartItems" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Items added",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": { "description": "Invalid body" },
|
||||||
|
"500": { "description": "Server error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/cart/set": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Replace cart contents",
|
||||||
|
"description": "Clears the cart first, then adds the provided items (idempotent with respect to target set).",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/SetCartItems" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Cart replaced",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": { "description": "Invalid body" },
|
||||||
|
"500": { "description": "Server error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/cart/{itemId}": {
|
||||||
|
"delete": {
|
||||||
|
"summary": "Remove item by line id",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "itemId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": { "type": "integer", "format": "int64", "minimum": 0 },
|
||||||
|
"description": "Internal cart line item identifier (not SKU)."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Item removed",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": { "description": "Bad id" },
|
||||||
|
"500": { "description": "Server error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/cart/delivery": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Set (add) delivery",
|
||||||
|
"description": "Adds a delivery option referencing one or more line item ids.",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/SetDeliveryRequest" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Delivery added/updated",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": { "description": "Invalid body" },
|
||||||
|
"500": { "description": "Server error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/cart/delivery/{deliveryId}": {
|
||||||
|
"delete": {
|
||||||
|
"summary": "Remove delivery",
|
||||||
|
"parameters": [{ "$ref": "#/components/parameters/DeliveryIdParam" }],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Delivery removed",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": { "description": "Bad id" },
|
||||||
|
"500": { "description": "Server error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/cart/delivery/{deliveryId}/pickupPoint": {
|
||||||
|
"put": {
|
||||||
|
"summary": "Set pickup point for delivery",
|
||||||
|
"parameters": [{ "$ref": "#/components/parameters/DeliveryIdParam" }],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/PickupPoint" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Pickup point set",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": { "description": "Invalid body" },
|
||||||
|
"500": { "description": "Server error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/cart/byid/{id}": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Get cart by explicit id",
|
||||||
|
"parameters": [{ "$ref": "#/components/parameters/CartIdParam" }],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Cart retrieved",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": { "description": "Invalid id" },
|
||||||
|
"500": { "description": "Server error" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"summary": "Add single SKU (body) by cart id",
|
||||||
|
"parameters": [{ "$ref": "#/components/parameters/CartIdParam" }],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/AddRequest" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Item added",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": { "description": "Invalid body" },
|
||||||
|
"500": { "description": "Server error" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"summary": "Change quantity (by id variant)",
|
||||||
|
"parameters": [{ "$ref": "#/components/parameters/CartIdParam" }],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/ChangeQuantity" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Quantity updated",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": { "description": "Invalid body" },
|
||||||
|
"500": { "description": "Server error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/cart/byid/{id}/add/{sku}": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Add SKU (path) by explicit cart id",
|
||||||
|
"parameters": [
|
||||||
|
{ "$ref": "#/components/parameters/CartIdParam" },
|
||||||
|
{ "$ref": "#/components/parameters/SkuParam" }
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Item added",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": { "description": "Invalid id/sku" },
|
||||||
|
"500": { "description": "Server error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/cart/byid/{id}/{itemId}": {
|
||||||
|
"delete": {
|
||||||
|
"summary": "Remove item (by id variant)",
|
||||||
|
"parameters": [
|
||||||
|
{ "$ref": "#/components/parameters/CartIdParam" },
|
||||||
|
{
|
||||||
|
"name": "itemId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": { "type": "integer", "format": "int64", "minimum": 0 }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Item removed",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": { "description": "Invalid id" },
|
||||||
|
"500": { "description": "Server error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/cart/byid/{id}/delivery": {
|
||||||
|
"post": {
|
||||||
|
"summary": "Set delivery (by id variant)",
|
||||||
|
"parameters": [{ "$ref": "#/components/parameters/CartIdParam" }],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/SetDeliveryRequest" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Delivery added/updated",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": { "description": "Invalid body" },
|
||||||
|
"500": { "description": "Server error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/cart/byid/{id}/delivery/{deliveryId}": {
|
||||||
|
"delete": {
|
||||||
|
"summary": "Remove delivery (by id variant)",
|
||||||
|
"parameters": [
|
||||||
|
{ "$ref": "#/components/parameters/CartIdParam" },
|
||||||
|
{ "$ref": "#/components/parameters/DeliveryIdParam" }
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Delivery removed",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": { "description": "Invalid ids" },
|
||||||
|
"500": { "description": "Server error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/cart/byid/{id}/delivery/{deliveryId}/pickupPoint": {
|
||||||
|
"put": {
|
||||||
|
"summary": "Set pickup point (by id variant)",
|
||||||
|
"parameters": [
|
||||||
|
{ "$ref": "#/components/parameters/CartIdParam" },
|
||||||
|
{ "$ref": "#/components/parameters/DeliveryIdParam" }
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/PickupPoint" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Pickup point updated",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": { "description": "Invalid body" },
|
||||||
|
"500": { "description": "Server error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/healthz": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Liveness & capacity probe",
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Healthy" },
|
||||||
|
"500": { "description": "Unhealthy" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/readyz": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Readiness probe",
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Ready" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/livez": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Liveness probe",
|
||||||
|
"responses": {
|
||||||
|
"200": { "description": "Alive" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/version": {
|
||||||
|
"get": {
|
||||||
|
"summary": "Service version",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Version string",
|
||||||
|
"content": {
|
||||||
|
"text/plain": {
|
||||||
|
"schema": { "type": "string", "example": "1.0.0" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"parameters": {
|
||||||
|
"SkuParam": {
|
||||||
|
"name": "sku",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": { "type": "string" }
|
||||||
|
},
|
||||||
|
"CartIdParam": {
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"description": "Base62 encoded cart id",
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-9A-Za-z]+$",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"DeliveryIdParam": {
|
||||||
|
"name": "deliveryId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": { "type": "integer", "format": "int64", "minimum": 0 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {
|
||||||
|
"CartGrain": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Cart aggregate (actor state)",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Cart id (base62 encoded uint64)"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/components/schemas/CartItem" }
|
||||||
|
},
|
||||||
|
"totalPrice": { "type": "integer", "format": "int64" },
|
||||||
|
"totalTax": { "type": "integer", "format": "int64" },
|
||||||
|
"totalDiscount": { "type": "integer", "format": "int64" },
|
||||||
|
"deliveries": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/components/schemas/CartDelivery" }
|
||||||
|
},
|
||||||
|
"processing": { "type": "boolean" },
|
||||||
|
"paymentInProgress": { "type": "boolean" },
|
||||||
|
"orderReference": { "type": "string" },
|
||||||
|
"paymentStatus": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["id", "items", "totalPrice", "totalTax", "totalDiscount"]
|
||||||
|
},
|
||||||
|
"CartItem": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer" },
|
||||||
|
"itemId": { "type": "integer" },
|
||||||
|
"parentId": { "type": "integer" },
|
||||||
|
"sku": { "type": "string" },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"price": { "type": "integer", "format": "int64" },
|
||||||
|
"totalPrice": { "type": "integer", "format": "int64" },
|
||||||
|
"totalTax": { "type": "integer", "format": "int64" },
|
||||||
|
"orgPrice": { "type": "integer", "format": "int64" },
|
||||||
|
"stock": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "0=OutOfStock,1=LowStock,2=InStock"
|
||||||
|
},
|
||||||
|
"qty": { "type": "integer" },
|
||||||
|
"tax": { "type": "integer" },
|
||||||
|
"taxRate": { "type": "integer" },
|
||||||
|
"brand": { "type": "string" },
|
||||||
|
"category": { "type": "string" },
|
||||||
|
"category2": { "type": "string" },
|
||||||
|
"category3": { "type": "string" },
|
||||||
|
"category4": { "type": "string" },
|
||||||
|
"category5": { "type": "string" },
|
||||||
|
"disclaimer": { "type": "string" },
|
||||||
|
"sellerId": { "type": "string" },
|
||||||
|
"sellerName": { "type": "string" },
|
||||||
|
"type": { "type": "string", "description": "Article type" },
|
||||||
|
"image": { "type": "string" },
|
||||||
|
"outlet": { "type": "string", "nullable": true },
|
||||||
|
"storeId": { "type": "string", "nullable": true }
|
||||||
|
},
|
||||||
|
"required": ["id", "sku", "name", "price", "qty", "tax"]
|
||||||
|
},
|
||||||
|
"CartDelivery": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer" },
|
||||||
|
"provider": { "type": "string" },
|
||||||
|
"price": { "type": "integer", "format": "int64" },
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "integer" }
|
||||||
|
},
|
||||||
|
"pickupPoint": { "$ref": "#/components/schemas/PickupPoint" }
|
||||||
|
},
|
||||||
|
"required": ["id", "provider", "price", "items"]
|
||||||
|
},
|
||||||
|
"PickupPoint": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string" },
|
||||||
|
"name": { "type": "string", "nullable": true },
|
||||||
|
"address": { "type": "string", "nullable": true },
|
||||||
|
"city": { "type": "string", "nullable": true },
|
||||||
|
"zip": { "type": "string", "nullable": true },
|
||||||
|
"country": { "type": "string", "nullable": true }
|
||||||
|
},
|
||||||
|
"required": ["id"]
|
||||||
|
},
|
||||||
|
"AddRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"quantity": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"minimum": 1,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"sku": { "type": "string" },
|
||||||
|
"country": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Two-letter country code (inferred if omitted)"
|
||||||
|
},
|
||||||
|
"storeId": { "type": "string", "nullable": true }
|
||||||
|
},
|
||||||
|
"required": ["sku"]
|
||||||
|
},
|
||||||
|
"ChangeQuantity": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"description": "Cart line item id"
|
||||||
|
},
|
||||||
|
"quantity": { "type": "integer", "format": "int32", "minimum": 0 }
|
||||||
|
},
|
||||||
|
"required": ["id", "quantity"]
|
||||||
|
},
|
||||||
|
"Item": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"sku": { "type": "string" },
|
||||||
|
"quantity": { "type": "integer", "minimum": 1 },
|
||||||
|
"storeId": { "type": "string", "nullable": true }
|
||||||
|
},
|
||||||
|
"required": ["sku", "quantity"]
|
||||||
|
},
|
||||||
|
"SetCartItems": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"country": { "type": "string" },
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/components/schemas/Item" },
|
||||||
|
"minItems": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["items"]
|
||||||
|
},
|
||||||
|
"SetDeliveryRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"provider": { "type": "string" },
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "integer", "format": "int64" },
|
||||||
|
"description": "Line item ids served by this delivery"
|
||||||
|
},
|
||||||
|
"pickupPoint": { "$ref": "#/components/schemas/PickupPoint" }
|
||||||
|
},
|
||||||
|
"required": ["provider", "items"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [{ "name": "Cart" }, { "name": "Delivery" }, { "name": "System" }]
|
||||||
|
}
|
||||||
69
cmd/cart/openapi_embed.go
Normal file
69
cmd/cart/openapi_embed.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// openapi_embed.go: Provides embedded OpenAPI spec and helper to mount handler.
|
||||||
|
|
||||||
|
//go:embed openapi.json
|
||||||
|
var openapiJSON []byte
|
||||||
|
|
||||||
|
var (
|
||||||
|
openapiOnce sync.Once
|
||||||
|
openapiETag string
|
||||||
|
)
|
||||||
|
|
||||||
|
// initOpenAPIMetadata computes immutable metadata for the embedded spec.
|
||||||
|
func initOpenAPIMetadata() {
|
||||||
|
sum := sha256.Sum256(openapiJSON)
|
||||||
|
openapiETag = `W/"` + hex.EncodeToString(sum[:8]) + `"` // weak ETag with first 8 bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeEmbeddedOpenAPI serves the embedded OpenAPI JSON spec at /openapi.json.
|
||||||
|
// It supports GET and HEAD and implements basic ETag caching.
|
||||||
|
func ServeEmbeddedOpenAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
|
w.Header().Set("Allow", "GET, HEAD")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
openapiOnce.Do(initOpenAPIMetadata)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("ETag", openapiETag)
|
||||||
|
|
||||||
|
if match := r.Header.Get("If-None-Match"); match != "" {
|
||||||
|
if bytes.Contains([]byte(match), []byte(openapiETag)) {
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write(openapiJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: function to access raw spec bytes programmatically.
|
||||||
|
func OpenAPISpecBytes() []byte {
|
||||||
|
return openapiJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: function to access current ETag.
|
||||||
|
func OpenAPIETag() string {
|
||||||
|
openapiOnce.Do(initOpenAPIMetadata)
|
||||||
|
return openapiETag
|
||||||
|
}
|
||||||
532
cmd/cart/pool-server.go
Normal file
532
cmd/cart/pool-server.go
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/actor"
|
||||||
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/voucher"
|
||||||
|
"github.com/gogo/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PoolServer struct {
|
||||||
|
actor.GrainPool[*CartGrain]
|
||||||
|
pod_name string
|
||||||
|
klarnaClient *KlarnaClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPoolServer(pool actor.GrainPool[*CartGrain], pod_name string, klarnaClient *KlarnaClient) *PoolServer {
|
||||||
|
return &PoolServer{
|
||||||
|
GrainPool: pool,
|
||||||
|
pod_name: pod_name,
|
||||||
|
klarnaClient: klarnaClient,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) ApplyLocal(id CartId, mutation ...proto.Message) (*actor.MutationResult[*CartGrain], error) {
|
||||||
|
return s.Apply(uint64(id), mutation...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
grain, err := s.Get(uint64(id))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.WriteResult(w, grain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
sku := r.PathValue("sku")
|
||||||
|
msg, err := GetItemAddMessage(sku, 1, getCountryFromHost(r.Host), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := s.ApplyLocal(id, msg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.WriteResult(w, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) WriteResult(w http.ResponseWriter, result any) 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 == nil {
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
enc := json.NewEncoder(w)
|
||||||
|
err := enc.Encode(result)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) DeleteItemHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
|
||||||
|
itemIdString := r.PathValue("itemId")
|
||||||
|
itemId, err := strconv.ParseInt(itemIdString, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := s.ApplyLocal(id, &messages.RemoveItem{Id: uint32(itemId)})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.WriteResult(w, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetDeliveryRequest struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Items []uint32 `json:"items"`
|
||||||
|
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) SetDeliveryHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
|
||||||
|
delivery := SetDeliveryRequest{}
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&delivery)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := s.ApplyLocal(id, &messages.SetDelivery{
|
||||||
|
Provider: delivery.Provider,
|
||||||
|
Items: delivery.Items,
|
||||||
|
PickupPoint: delivery.PickupPoint,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.WriteResult(w, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) SetPickupPointHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
|
||||||
|
deliveryIdString := r.PathValue("deliveryId")
|
||||||
|
deliveryId, err := strconv.ParseInt(deliveryIdString, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pickupPoint := messages.PickupPoint{}
|
||||||
|
err = json.NewDecoder(r.Body).Decode(&pickupPoint)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reply, err := s.ApplyLocal(id, &messages.SetPickupPoint{
|
||||||
|
DeliveryId: uint32(deliveryId),
|
||||||
|
Id: pickupPoint.Id,
|
||||||
|
Name: pickupPoint.Name,
|
||||||
|
Address: pickupPoint.Address,
|
||||||
|
City: pickupPoint.City,
|
||||||
|
Zip: pickupPoint.Zip,
|
||||||
|
Country: pickupPoint.Country,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.WriteResult(w, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) RemoveDeliveryHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
|
||||||
|
deliveryIdString := r.PathValue("deliveryId")
|
||||||
|
deliveryId, err := strconv.Atoi(deliveryIdString)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reply, err := s.ApplyLocal(id, &messages.RemoveDelivery{Id: uint32(deliveryId)})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.WriteResult(w, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) QuantityChangeHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
changeQuantity := messages.ChangeQuantity{}
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&changeQuantity)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reply, err := s.ApplyLocal(id, &changeQuantity)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.WriteResult(w, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Item struct {
|
||||||
|
Sku string `json:"sku"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
StoreId *string `json:"storeId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetCartItems struct {
|
||||||
|
Country string `json:"country"`
|
||||||
|
Items []Item `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMultipleAddMessages(items []Item, country string) []proto.Message {
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
mu := sync.Mutex{}
|
||||||
|
msgs := make([]proto.Message, 0, len(items))
|
||||||
|
for _, itm := range items {
|
||||||
|
wg.Go(
|
||||||
|
func() {
|
||||||
|
msg, err := GetItemAddMessage(itm.Sku, itm.Quantity, country, itm.StoreId)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("error adding item %s: %v", itm.Sku, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
msgs = append(msgs, msg)
|
||||||
|
mu.Unlock()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
setCartItems := SetCartItems{}
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&setCartItems)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs := make([]proto.Message, 0, len(setCartItems.Items)+1)
|
||||||
|
msgs = append(msgs, &messages.ClearCartRequest{})
|
||||||
|
msgs = append(msgs, getMultipleAddMessages(setCartItems.Items, setCartItems.Country)...)
|
||||||
|
|
||||||
|
reply, err := s.ApplyLocal(id, msgs...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.WriteResult(w, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) AddMultipleItemHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
setCartItems := SetCartItems{}
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&setCartItems)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
reply, err := s.ApplyLocal(id, getMultipleAddMessages(setCartItems.Items, setCartItems.Country)...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.WriteResult(w, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddRequest struct {
|
||||||
|
Sku string `json:"sku"`
|
||||||
|
Quantity int32 `json:"quantity"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
StoreId *string `json:"storeId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
addRequest := AddRequest{Quantity: 1}
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&addRequest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
msg, err := GetItemAddMessage(addRequest.Sku, int(addRequest.Quantity), addRequest.Country, addRequest.StoreId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reply, err := s.ApplyLocal(id, msg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.WriteResult(w, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
// func (s *PoolServer) HandleConfirmation(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
// orderId := r.PathValue("orderId")
|
||||||
|
// if orderId == "" {
|
||||||
|
// return fmt.Errorf("orderId is empty")
|
||||||
|
// }
|
||||||
|
// order, err := KlarnaInstance.GetOrder(orderId)
|
||||||
|
|
||||||
|
// if err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
|
||||||
|
// w.Header().Set("Content-Type", "application/json")
|
||||||
|
// w.Header().Set("X-Pod-Name", s.pod_name)
|
||||||
|
// w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
// w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
// w.WriteHeader(http.StatusOK)
|
||||||
|
// return json.NewEncoder(w).Encode(order)
|
||||||
|
// }
|
||||||
|
|
||||||
|
func getCurrency(country string) string {
|
||||||
|
if country == "no" {
|
||||||
|
return "NOK"
|
||||||
|
}
|
||||||
|
return "SEK"
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLocale(country string) string {
|
||||||
|
if country == "no" {
|
||||||
|
return "nb-no"
|
||||||
|
}
|
||||||
|
return "sv-se"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) CreateOrUpdateCheckout(host string, id CartId) (*CheckoutOrder, error) {
|
||||||
|
country := getCountryFromHost(host)
|
||||||
|
meta := &CheckoutMeta{
|
||||||
|
Terms: fmt.Sprintf("https://%s/terms", host),
|
||||||
|
Checkout: fmt.Sprintf("https://%s/checkout?order_id={checkout.order.id}", host),
|
||||||
|
Confirmation: fmt.Sprintf("https://%s/confirmation/{checkout.order.id}", host),
|
||||||
|
Validation: fmt.Sprintf("https://%s/validate", host),
|
||||||
|
Push: fmt.Sprintf("https://%s/push?order_id={checkout.order.id}", host),
|
||||||
|
Country: country,
|
||||||
|
Currency: getCurrency(country),
|
||||||
|
Locale: getLocale(country),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current grain state (may be local or remote)
|
||||||
|
grain, err := s.Get(uint64(id))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build pure checkout payload
|
||||||
|
payload, _, err := BuildCheckoutOrderPayload(grain, meta)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if grain.OrderReference != "" {
|
||||||
|
return s.klarnaClient.UpdateOrder(grain.OrderReference, bytes.NewReader(payload))
|
||||||
|
} else {
|
||||||
|
return s.klarnaClient.CreateOrder(bytes.NewReader(payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) ApplyCheckoutStarted(klarnaOrder *CheckoutOrder, id CartId) (*actor.MutationResult[*CartGrain], error) {
|
||||||
|
// Persist initialization state via mutation (best-effort)
|
||||||
|
return s.ApplyLocal(id, &messages.InitializeCheckout{
|
||||||
|
OrderId: klarnaOrder.ID,
|
||||||
|
Status: klarnaOrder.Status,
|
||||||
|
PaymentInProgress: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// func (s *PoolServer) HandleCheckout(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||||
|
// klarnaOrder, err := s.CreateOrUpdateCheckout(r.Host, id)
|
||||||
|
// if err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
|
||||||
|
// s.ApplyCheckoutStarted(klarnaOrder, id)
|
||||||
|
|
||||||
|
// w.Header().Set("Content-Type", "application/json")
|
||||||
|
// return json.NewEncoder(w).Encode(klarnaOrder)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
|
||||||
|
func CookieCartIdHandler(fn func(cartId CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var id CartId
|
||||||
|
cookie, err := r.Cookie("cartid")
|
||||||
|
if err != nil || cookie.Value == "" {
|
||||||
|
id = MustNewCartId()
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "cartid",
|
||||||
|
Value: id.String(),
|
||||||
|
Secure: r.TLS != nil,
|
||||||
|
HttpOnly: true,
|
||||||
|
Path: "/",
|
||||||
|
Expires: time.Now().AddDate(0, 0, 14),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
w.Header().Set("Set-Cart-Id", id.String())
|
||||||
|
} else {
|
||||||
|
parsed, ok := ParseCartId(cookie.Value)
|
||||||
|
if !ok {
|
||||||
|
id = MustNewCartId()
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "cartid",
|
||||||
|
Value: id.String(),
|
||||||
|
Secure: r.TLS != nil,
|
||||||
|
HttpOnly: true,
|
||||||
|
Path: "/",
|
||||||
|
Expires: time.Now().AddDate(0, 0, 14),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
w.Header().Set("Set-Cart-Id", id.String())
|
||||||
|
} else {
|
||||||
|
id = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = fn(id, w, r)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Server error, not remote error: %v\n", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removed leftover legacy block after CookieCartIdHandler (obsolete code referencing cid/legacy)
|
||||||
|
|
||||||
|
func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId CartId) error {
|
||||||
|
// Clear cart cookie (breaking change: do not issue a new legacy id here)
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "cartid",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
Secure: r.TLS != nil,
|
||||||
|
HttpOnly: true,
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CartIdHandler(fn func(cartId CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var id CartId
|
||||||
|
raw := r.PathValue("id")
|
||||||
|
// If no id supplied, generate a new one
|
||||||
|
if raw == "" {
|
||||||
|
id := MustNewCartId()
|
||||||
|
w.Header().Set("Set-Cart-Id", id.String())
|
||||||
|
} else {
|
||||||
|
// Parse base62 cart id
|
||||||
|
if parsedId, ok := ParseCartId(raw); !ok {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte("cart id is invalid"))
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
id = parsedId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := fn(id, w, r)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Server error, not remote error: %v\n", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request, cartId CartId) error) func(cartId CartId, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
return func(cartId CartId, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
if ownerHost, ok := s.OwnerHost(uint64(cartId)); ok {
|
||||||
|
handled, err := ownerHost.Proxy(uint64(cartId), w, r)
|
||||||
|
if err == nil && handled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fn(w, r, cartId)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddVoucherRequest struct {
|
||||||
|
VoucherCode string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) AddVoucherHandler(w http.ResponseWriter, r *http.Request, cartId CartId) error {
|
||||||
|
data := &AddVoucherRequest{}
|
||||||
|
json.NewDecoder(r.Body).Decode(data)
|
||||||
|
v := voucher.Service{}
|
||||||
|
msg, err := v.GetVoucher(data.VoucherCode)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reply, err := s.ApplyLocal(cartId, msg)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.WriteResult(w, reply)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request, cartId CartId) error {
|
||||||
|
|
||||||
|
idStr := r.PathValue("voucherId")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reply, err := s.ApplyLocal(cartId, &messages.RemoveVoucher{Id: uint32(id)})
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.WriteResult(w, reply)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) Serve() *http.ServeMux {
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("OPTIONS /", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /", CookieCartIdHandler(s.ProxyHandler(s.GetCartHandler)))
|
||||||
|
mux.HandleFunc("GET /add/{sku}", CookieCartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
|
||||||
|
mux.HandleFunc("POST /add", CookieCartIdHandler(s.ProxyHandler(s.AddMultipleItemHandler)))
|
||||||
|
mux.HandleFunc("POST /", CookieCartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
|
||||||
|
mux.HandleFunc("POST /set", CookieCartIdHandler(s.ProxyHandler(s.SetCartItemsHandler)))
|
||||||
|
mux.HandleFunc("DELETE /{itemId}", CookieCartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
|
||||||
|
mux.HandleFunc("PUT /", CookieCartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
|
||||||
|
mux.HandleFunc("DELETE /", CookieCartIdHandler(s.ProxyHandler(s.RemoveCartCookie)))
|
||||||
|
mux.HandleFunc("POST /delivery", CookieCartIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
|
||||||
|
mux.HandleFunc("DELETE /delivery/{deliveryId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
|
||||||
|
mux.HandleFunc("PUT /delivery/{deliveryId}/pickupPoint", CookieCartIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
|
||||||
|
mux.HandleFunc("PUT /voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
|
||||||
|
mux.HandleFunc("DELETE /voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
||||||
|
|
||||||
|
//mux.HandleFunc("GET /checkout", CookieCartIdHandler(s.ProxyHandler(s.HandleCheckout)))
|
||||||
|
//mux.HandleFunc("GET /confirmation/{orderId}", CookieCartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /byid/{id}", CartIdHandler(s.ProxyHandler(s.GetCartHandler)))
|
||||||
|
mux.HandleFunc("GET /byid/{id}/add/{sku}", CartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
|
||||||
|
mux.HandleFunc("POST /byid/{id}", CartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
|
||||||
|
mux.HandleFunc("DELETE /byid/{id}/{itemId}", CartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
|
||||||
|
mux.HandleFunc("PUT /byid/{id}", CartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
|
||||||
|
mux.HandleFunc("POST /byid/{id}/delivery", CartIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
|
||||||
|
mux.HandleFunc("DELETE /byid/{id}/delivery/{deliveryId}", CartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
|
||||||
|
mux.HandleFunc("PUT /byid/{id}/delivery/{deliveryId}/pickupPoint", CartIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
|
||||||
|
mux.HandleFunc("PUT /byid/{id}/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
|
||||||
|
mux.HandleFunc("DELETE /byid/{id}/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
||||||
|
//mux.HandleFunc("GET /byid/{id}/checkout", CartIdHandler(s.ProxyHandler(s.HandleCheckout)))
|
||||||
|
//mux.HandleFunc("GET /byid/{id}/confirmation", CartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
|
||||||
|
|
||||||
|
return mux
|
||||||
|
}
|
||||||
131
cmd/cart/price.go
Normal file
131
cmd/cart/price.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetTaxAmount(total int64, tax int) int64 {
|
||||||
|
taxD := 10000 / float64(tax)
|
||||||
|
return int64(float64(total) / float64((1 + taxD)))
|
||||||
|
}
|
||||||
|
|
||||||
|
type Price struct {
|
||||||
|
IncVat int64 `json:"incVat"`
|
||||||
|
VatRates map[float32]int64 `json:"vat,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPrice() *Price {
|
||||||
|
return &Price{
|
||||||
|
IncVat: 0,
|
||||||
|
VatRates: make(map[float32]int64),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPriceFromIncVat(incVat int64, taxRate float32) *Price {
|
||||||
|
tax := GetTaxAmount(incVat, int(taxRate*100))
|
||||||
|
return &Price{
|
||||||
|
IncVat: incVat,
|
||||||
|
VatRates: map[float32]int64{
|
||||||
|
taxRate: tax,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Price) ValueExVat() int64 {
|
||||||
|
exVat := p.IncVat
|
||||||
|
for _, amount := range p.VatRates {
|
||||||
|
exVat -= amount
|
||||||
|
}
|
||||||
|
return exVat
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Price) TotalVat() int64 {
|
||||||
|
total := int64(0)
|
||||||
|
for _, amount := range p.VatRates {
|
||||||
|
total += amount
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
func MultiplyPrice(p Price, qty int64) *Price {
|
||||||
|
ret := &Price{
|
||||||
|
IncVat: p.IncVat * qty,
|
||||||
|
VatRates: make(map[float32]int64),
|
||||||
|
}
|
||||||
|
for rate, amount := range p.VatRates {
|
||||||
|
ret.VatRates[rate] = amount * qty
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Price) Multiply(qty int64) {
|
||||||
|
p.IncVat *= qty
|
||||||
|
for rate, amount := range p.VatRates {
|
||||||
|
p.VatRates[rate] = amount * qty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Price) MarshalJSON() ([]byte, error) {
|
||||||
|
// Build a stable wire format without calling Price.MarshalJSON recursively
|
||||||
|
exVat := p.ValueExVat()
|
||||||
|
var vat map[string]int64
|
||||||
|
if len(p.VatRates) > 0 {
|
||||||
|
vat = make(map[string]int64, len(p.VatRates))
|
||||||
|
for rate, amount := range p.VatRates {
|
||||||
|
// Rely on default formatting that trims trailing zeros for whole numbers
|
||||||
|
// Using %g could output scientific notation for large numbers; float32 rates here are small.
|
||||||
|
key := trimFloat(rate)
|
||||||
|
vat[key] = amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type wire struct {
|
||||||
|
ExVat int64 `json:"exVat"`
|
||||||
|
IncVat int64 `json:"incVat"`
|
||||||
|
Vat map[string]int64 `json:"vat,omitempty"`
|
||||||
|
}
|
||||||
|
return json.Marshal(wire{ExVat: exVat, IncVat: p.IncVat, Vat: vat})
|
||||||
|
}
|
||||||
|
|
||||||
|
// trimFloat converts a float32 tax rate like 25 or 12.5 into a compact string without
|
||||||
|
// unnecessary decimals ("25", "12.5").
|
||||||
|
func trimFloat(f float32) string {
|
||||||
|
// Convert via FormatFloat then trim trailing zeros and dot.
|
||||||
|
s := strconv.FormatFloat(float64(f), 'f', -1, 32)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Price) Add(price Price) {
|
||||||
|
p.IncVat += price.IncVat
|
||||||
|
for rate, amount := range price.VatRates {
|
||||||
|
p.VatRates[rate] += amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Price) Subtract(price Price) {
|
||||||
|
p.IncVat -= price.IncVat
|
||||||
|
for rate, amount := range price.VatRates {
|
||||||
|
p.VatRates[rate] -= amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SumPrices(prices ...Price) *Price {
|
||||||
|
if len(prices) == 0 {
|
||||||
|
return NewPrice()
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregated := NewPrice()
|
||||||
|
|
||||||
|
for _, price := range prices {
|
||||||
|
aggregated.IncVat += price.IncVat
|
||||||
|
for rate, amount := range price.VatRates {
|
||||||
|
aggregated.VatRates[rate] += amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(aggregated.VatRates) == 0 {
|
||||||
|
aggregated.VatRates = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return aggregated
|
||||||
|
}
|
||||||
135
cmd/cart/price_test.go
Normal file
135
cmd/cart/price_test.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPriceMarshalJSON(t *testing.T) {
|
||||||
|
p := Price{IncVat: 13700, VatRates: map[float32]int64{25: 2500, 12: 1200}}
|
||||||
|
// ExVat = 13700 - (2500+1200) = 10000
|
||||||
|
data, err := json.Marshal(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal error: %v", err)
|
||||||
|
}
|
||||||
|
// Unmarshal into a generic struct to validate fields
|
||||||
|
var out struct {
|
||||||
|
ExVat int64 `json:"exVat"`
|
||||||
|
IncVat int64 `json:"incVat"`
|
||||||
|
Vat map[string]int64 `json:"vat"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &out); err != nil {
|
||||||
|
t.Fatalf("unmarshal error: %v", err)
|
||||||
|
}
|
||||||
|
if out.ExVat != 10000 {
|
||||||
|
t.Fatalf("expected exVat 10000 got %d", out.ExVat)
|
||||||
|
}
|
||||||
|
if out.IncVat != 13700 {
|
||||||
|
t.Fatalf("expected incVat 13700 got %d", out.IncVat)
|
||||||
|
}
|
||||||
|
if out.Vat["25"] != 2500 || out.Vat["12"] != 1200 {
|
||||||
|
t.Fatalf("unexpected vat map: %#v", out.Vat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewPriceFromIncVat(t *testing.T) {
|
||||||
|
p := NewPriceFromIncVat(1000, 0.25)
|
||||||
|
if p.IncVat != 1000 {
|
||||||
|
t.Fatalf("expected IncVat %d got %d", 1000, p.IncVat)
|
||||||
|
}
|
||||||
|
if p.VatRates[25] != 250 {
|
||||||
|
t.Fatalf("expected VAT 25 rate %d got %d", 250, p.VatRates[25])
|
||||||
|
}
|
||||||
|
if p.ValueExVat() != 750 {
|
||||||
|
t.Fatalf("expected exVat %d got %d", 750, p.ValueExVat())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSumPrices(t *testing.T) {
|
||||||
|
// We'll construct prices via raw struct since constructor expects tax math.
|
||||||
|
// IncVat already includes vat portions.
|
||||||
|
a := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}} // ex=1000
|
||||||
|
b := Price{IncVat: 2740, VatRates: map[float32]int64{25: 500, 12: 240}} // ex=2000
|
||||||
|
c := Price{IncVat: 0, VatRates: nil}
|
||||||
|
|
||||||
|
sum := SumPrices(a, b, c)
|
||||||
|
|
||||||
|
if sum.IncVat != 3990 { // 1250+2740
|
||||||
|
t.Fatalf("expected incVat 3990 got %d", sum.IncVat)
|
||||||
|
}
|
||||||
|
if len(sum.VatRates) != 2 {
|
||||||
|
t.Fatalf("expected 2 vat rates got %d", len(sum.VatRates))
|
||||||
|
}
|
||||||
|
if sum.VatRates[25] != 750 {
|
||||||
|
t.Fatalf("expected 25%% vat 750 got %d", sum.VatRates[25])
|
||||||
|
}
|
||||||
|
if sum.VatRates[12] != 240 {
|
||||||
|
t.Fatalf("expected 12%% vat 240 got %d", sum.VatRates[12])
|
||||||
|
}
|
||||||
|
if sum.ValueExVat() != 3000 { // 3990 - (750+240)
|
||||||
|
t.Fatalf("expected exVat 3000 got %d", sum.ValueExVat())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSumPricesEmpty(t *testing.T) {
|
||||||
|
sum := SumPrices()
|
||||||
|
if sum.IncVat != 0 || sum.VatRates == nil { // constructor sets empty map
|
||||||
|
t.Fatalf("expected zero price got %#v", sum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiplyPriceFunction(t *testing.T) {
|
||||||
|
base := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}}
|
||||||
|
multiplied := MultiplyPrice(base, 3)
|
||||||
|
if multiplied.IncVat != 1250*3 {
|
||||||
|
t.Fatalf("expected IncVat %d got %d", 1250*3, multiplied.IncVat)
|
||||||
|
}
|
||||||
|
if multiplied.VatRates[25] != 250*3 {
|
||||||
|
t.Fatalf("expected VAT 25 rate %d got %d", 250*3, multiplied.VatRates[25])
|
||||||
|
}
|
||||||
|
if multiplied.ValueExVat() != (1250-250)*3 {
|
||||||
|
t.Fatalf("expected exVat %d got %d", (1250-250)*3, multiplied.ValueExVat())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPriceAddSubtract(t *testing.T) {
|
||||||
|
a := Price{IncVat: 1000, VatRates: map[float32]int64{25: 200}}
|
||||||
|
b := Price{IncVat: 500, VatRates: map[float32]int64{25: 100, 12: 54}}
|
||||||
|
|
||||||
|
acc := NewPrice()
|
||||||
|
acc.Add(a)
|
||||||
|
acc.Add(b)
|
||||||
|
|
||||||
|
if acc.IncVat != 1500 {
|
||||||
|
t.Fatalf("expected IncVat 1500 got %d", acc.IncVat)
|
||||||
|
}
|
||||||
|
if acc.VatRates[25] != 300 || acc.VatRates[12] != 54 {
|
||||||
|
t.Fatalf("unexpected VAT map: %#v", acc.VatRates)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtract b then a returns to zero
|
||||||
|
acc.Subtract(b)
|
||||||
|
acc.Subtract(a)
|
||||||
|
if acc.IncVat != 0 {
|
||||||
|
t.Fatalf("expected IncVat 0 got %d", acc.IncVat)
|
||||||
|
}
|
||||||
|
if len(acc.VatRates) != 2 || acc.VatRates[25] != 0 || acc.VatRates[12] != 0 {
|
||||||
|
t.Fatalf("expected zeroed vat rates got %#v", acc.VatRates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPriceMultiplyMethod(t *testing.T) {
|
||||||
|
p := Price{IncVat: 2000, VatRates: map[float32]int64{25: 400}}
|
||||||
|
// Value before multiply
|
||||||
|
exBefore := p.ValueExVat()
|
||||||
|
p.Multiply(2)
|
||||||
|
if p.IncVat != 4000 {
|
||||||
|
t.Fatalf("expected IncVat 4000 got %d", p.IncVat)
|
||||||
|
}
|
||||||
|
if p.VatRates[25] != 800 {
|
||||||
|
t.Fatalf("expected VAT 800 got %d", p.VatRates[25])
|
||||||
|
}
|
||||||
|
if p.ValueExVat() != exBefore*2 {
|
||||||
|
t.Fatalf("expected exVat %d got %d", exBefore*2, p.ValueExVat())
|
||||||
|
}
|
||||||
|
}
|
||||||
121
cmd/cart/product-fetcher.go
Normal file
121
cmd/cart/product-fetcher.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
|
"github.com/matst80/slask-finder/pkg/index"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO make this configurable
|
||||||
|
func getBaseUrl(country string) string {
|
||||||
|
// if country == "se" {
|
||||||
|
// return "http://s10n-se:8080"
|
||||||
|
// }
|
||||||
|
if country == "no" {
|
||||||
|
return "http://s10n-no.s10n:8080"
|
||||||
|
}
|
||||||
|
if country == "se" {
|
||||||
|
return "http://s10n-se.s10n:8080"
|
||||||
|
}
|
||||||
|
return "http://localhost:8082"
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchItem(sku string, country string) (*index.DataItem, error) {
|
||||||
|
baseUrl := getBaseUrl(country)
|
||||||
|
res, err := http.Get(fmt.Sprintf("%s/api/by-sku/%s", baseUrl, sku))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
var item index.DataItem
|
||||||
|
err = json.NewDecoder(res.Body).Decode(&item)
|
||||||
|
return &item, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetItemAddMessage(sku string, qty int, country string, storeId *string) (*messages.AddItem, error) {
|
||||||
|
item, err := FetchItem(sku, country)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ToItemAddMessage(item, storeId, qty, country), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country string) *messages.AddItem {
|
||||||
|
orgPrice, _ := getInt(item.GetNumberFieldValue(5)) // getInt(item.Fields[5])
|
||||||
|
|
||||||
|
price, err := getInt(item.GetNumberFieldValue(4)) //Fields[4]
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
stock := StockStatus(0)
|
||||||
|
centralStockValue, ok := item.GetStringFieldValue(3)
|
||||||
|
if storeId == nil {
|
||||||
|
if ok {
|
||||||
|
pureNumber := strings.Replace(centralStockValue, "+", "", -1)
|
||||||
|
if centralStock, err := strconv.ParseInt(pureNumber, 10, 64); err == nil {
|
||||||
|
stock = StockStatus(centralStock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
storeStock, ok := item.Stock.GetStock()[*storeId]
|
||||||
|
if ok {
|
||||||
|
stock = StockStatus(storeStock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
articleType, _ := item.GetStringFieldValue(1) //.Fields[1].(string)
|
||||||
|
outletGrade, ok := item.GetStringFieldValue(20) //.Fields[20].(string)
|
||||||
|
var outlet *string
|
||||||
|
if ok {
|
||||||
|
outlet = &outletGrade
|
||||||
|
}
|
||||||
|
sellerId, _ := item.GetStringFieldValue(24) // .Fields[24].(string)
|
||||||
|
sellerName, _ := item.GetStringFieldValue(9) // .Fields[9].(string)
|
||||||
|
|
||||||
|
brand, _ := item.GetStringFieldValue(2) //.Fields[2].(string)
|
||||||
|
category, _ := item.GetStringFieldValue(10) //.Fields[10].(string)
|
||||||
|
category2, _ := item.GetStringFieldValue(11) //.Fields[11].(string)
|
||||||
|
category3, _ := item.GetStringFieldValue(12) //.Fields[12].(string)
|
||||||
|
category4, _ := item.GetStringFieldValue(13) //Fields[13].(string)
|
||||||
|
category5, _ := item.GetStringFieldValue(14) //.Fields[14].(string)
|
||||||
|
|
||||||
|
return &messages.AddItem{
|
||||||
|
ItemId: uint32(item.Id),
|
||||||
|
Quantity: int32(qty),
|
||||||
|
Price: int64(price),
|
||||||
|
OrgPrice: int64(orgPrice),
|
||||||
|
Sku: item.GetSku(),
|
||||||
|
Name: item.Title,
|
||||||
|
Image: item.Img,
|
||||||
|
Stock: int32(stock),
|
||||||
|
Brand: brand,
|
||||||
|
Category: category,
|
||||||
|
Category2: category2,
|
||||||
|
Category3: category3,
|
||||||
|
Category4: category4,
|
||||||
|
Category5: category5,
|
||||||
|
Tax: getTax(articleType),
|
||||||
|
SellerId: sellerId,
|
||||||
|
SellerName: sellerName,
|
||||||
|
ArticleType: articleType,
|
||||||
|
Disclaimer: item.Disclaimer,
|
||||||
|
Country: country,
|
||||||
|
Outlet: outlet,
|
||||||
|
StoreId: storeId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTax(articleType string) int32 {
|
||||||
|
switch articleType {
|
||||||
|
case "ZDIE":
|
||||||
|
return 600
|
||||||
|
default:
|
||||||
|
return 2500
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
|
||||||
@@ -15,7 +15,7 @@ metadata:
|
|||||||
arch: amd64
|
arch: amd64
|
||||||
name: cart-actor-x86
|
name: cart-actor-x86
|
||||||
spec:
|
spec:
|
||||||
replicas: 0
|
replicas: 3
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: cart-actor
|
app: cart-actor
|
||||||
@@ -55,12 +55,8 @@ spec:
|
|||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
name: web
|
name: web
|
||||||
- containerPort: 1234
|
|
||||||
name: echo
|
|
||||||
- containerPort: 1337
|
- containerPort: 1337
|
||||||
name: rpc
|
name: rpc
|
||||||
- containerPort: 1338
|
|
||||||
name: quorum
|
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /livez
|
path: /livez
|
||||||
@@ -161,12 +157,8 @@ spec:
|
|||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
name: web
|
name: web
|
||||||
- containerPort: 1234
|
|
||||||
name: echo
|
|
||||||
- containerPort: 1337
|
- containerPort: 1337
|
||||||
name: rpc
|
name: rpc
|
||||||
- containerPort: 1338
|
|
||||||
name: quorum
|
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /livez
|
path: /livez
|
||||||
@@ -217,18 +209,6 @@ spec:
|
|||||||
---
|
---
|
||||||
kind: Service
|
kind: Service
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
metadata:
|
|
||||||
name: cart-echo
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: cart-actor
|
|
||||||
type: LoadBalancer
|
|
||||||
ports:
|
|
||||||
- name: echo
|
|
||||||
port: 1234
|
|
||||||
---
|
|
||||||
kind: Service
|
|
||||||
apiVersion: v1
|
|
||||||
metadata:
|
metadata:
|
||||||
name: cart-actor
|
name: cart-actor
|
||||||
annotations:
|
annotations:
|
||||||
@@ -248,10 +228,10 @@ metadata:
|
|||||||
name: cart-ingress
|
name: cart-ingress
|
||||||
annotations:
|
annotations:
|
||||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
# nginx.ingress.kubernetes.io/affinity: "cookie"
|
nginx.ingress.kubernetes.io/affinity: "cookie"
|
||||||
# nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity"
|
nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity"
|
||||||
# nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
|
nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
|
||||||
# nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
|
nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
|
||||||
nginx.ingress.kubernetes.io/proxy-body-size: 4m
|
nginx.ingress.kubernetes.io/proxy-body-size: 4m
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: nginx
|
ingressClassName: nginx
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
apiVersion: autoscaling/v1
|
apiVersion: autoscaling/v2
|
||||||
kind: HorizontalPodAutoscaler
|
kind: HorizontalPodAutoscaler
|
||||||
metadata:
|
metadata:
|
||||||
name: cart-scaler-amd
|
name: cart-scaler-amd
|
||||||
@@ -9,9 +9,47 @@ spec:
|
|||||||
name: cart-actor-x86
|
name: cart-actor-x86
|
||||||
minReplicas: 3
|
minReplicas: 3
|
||||||
maxReplicas: 9
|
maxReplicas: 9
|
||||||
targetCPUUtilizationPercentage: 30
|
behavior:
|
||||||
|
scaleUp:
|
||||||
|
stabilizationWindowSeconds: 60
|
||||||
|
policies:
|
||||||
|
- type: Percent
|
||||||
|
value: 100
|
||||||
|
periodSeconds: 60
|
||||||
|
scaleDown:
|
||||||
|
stabilizationWindowSeconds: 180
|
||||||
|
policies:
|
||||||
|
- type: Percent
|
||||||
|
value: 50
|
||||||
|
periodSeconds: 60
|
||||||
|
metrics:
|
||||||
|
- type: Resource
|
||||||
|
resource:
|
||||||
|
name: cpu
|
||||||
|
target:
|
||||||
|
type: Utilization
|
||||||
|
averageUtilization: 50
|
||||||
|
# Future custom metric (example):
|
||||||
|
# - type: Pods
|
||||||
|
# pods:
|
||||||
|
# metric:
|
||||||
|
# name: cart_mutations_per_second
|
||||||
|
# target:
|
||||||
|
# type: AverageValue
|
||||||
|
# averageValue: "15"
|
||||||
|
# - type: Object
|
||||||
|
# object:
|
||||||
|
# describedObject:
|
||||||
|
# apiVersion: networking.k8s.io/v1
|
||||||
|
# kind: Ingress
|
||||||
|
# name: cart-ingress
|
||||||
|
# metric:
|
||||||
|
# name: http_requests_per_second
|
||||||
|
# target:
|
||||||
|
# type: Value
|
||||||
|
# value: "100"
|
||||||
---
|
---
|
||||||
apiVersion: autoscaling/v1
|
apiVersion: autoscaling/v2
|
||||||
kind: HorizontalPodAutoscaler
|
kind: HorizontalPodAutoscaler
|
||||||
metadata:
|
metadata:
|
||||||
name: cart-scaler-arm
|
name: cart-scaler-arm
|
||||||
@@ -22,4 +60,42 @@ spec:
|
|||||||
name: cart-actor-arm64
|
name: cart-actor-arm64
|
||||||
minReplicas: 3
|
minReplicas: 3
|
||||||
maxReplicas: 9
|
maxReplicas: 9
|
||||||
targetCPUUtilizationPercentage: 30
|
behavior:
|
||||||
|
scaleUp:
|
||||||
|
stabilizationWindowSeconds: 60
|
||||||
|
policies:
|
||||||
|
- type: Percent
|
||||||
|
value: 100
|
||||||
|
periodSeconds: 60
|
||||||
|
scaleDown:
|
||||||
|
stabilizationWindowSeconds: 180
|
||||||
|
policies:
|
||||||
|
- type: Percent
|
||||||
|
value: 50
|
||||||
|
periodSeconds: 60
|
||||||
|
metrics:
|
||||||
|
- type: Resource
|
||||||
|
resource:
|
||||||
|
name: cpu
|
||||||
|
target:
|
||||||
|
type: Utilization
|
||||||
|
averageUtilization: 50
|
||||||
|
# Future custom metric (example):
|
||||||
|
# - type: Pods
|
||||||
|
# pods:
|
||||||
|
# metric:
|
||||||
|
# name: cart_mutations_per_second
|
||||||
|
# target:
|
||||||
|
# type: AverageValue
|
||||||
|
# averageValue: "15"
|
||||||
|
# - type: Object
|
||||||
|
# object:
|
||||||
|
# describedObject:
|
||||||
|
# apiVersion: networking.k8s.io/v1
|
||||||
|
# kind: Ingress
|
||||||
|
# name: cart-ingress
|
||||||
|
# metric:
|
||||||
|
# name: http_requests_per_second
|
||||||
|
# target:
|
||||||
|
# type: Value
|
||||||
|
# value: "100"
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DiscardedHost struct {
|
|
||||||
Host string
|
|
||||||
Tries int
|
|
||||||
}
|
|
||||||
|
|
||||||
type DiscardedHostHandler struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
port int
|
|
||||||
hosts []*DiscardedHost
|
|
||||||
onConnection *func(string)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DiscardedHostHandler) run() {
|
|
||||||
for range time.Tick(time.Second) {
|
|
||||||
d.mu.RLock()
|
|
||||||
lst := make([]*DiscardedHost, 0, len(d.hosts))
|
|
||||||
for _, host := range d.hosts {
|
|
||||||
if host.Tries >= 0 && host.Tries < 5 {
|
|
||||||
go d.testConnection(host)
|
|
||||||
lst = append(lst, host)
|
|
||||||
} else {
|
|
||||||
if host.Tries > 0 {
|
|
||||||
log.Printf("Host %s discarded after %d tries", host.Host, host.Tries)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
d.mu.RUnlock()
|
|
||||||
d.mu.Lock()
|
|
||||||
d.hosts = lst
|
|
||||||
d.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DiscardedHostHandler) testConnection(host *DiscardedHost) {
|
|
||||||
addr := fmt.Sprintf("%s:%d", host.Host, d.port)
|
|
||||||
conn, err := net.Dial("tcp", addr)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
host.Tries++
|
|
||||||
if host.Tries >= 5 {
|
|
||||||
// Exceeded retry threshold; will be dropped by run loop.
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
conn.Close()
|
|
||||||
if d.onConnection != nil {
|
|
||||||
fn := *d.onConnection
|
|
||||||
fn(host.Host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDiscardedHostHandler(port int) *DiscardedHostHandler {
|
|
||||||
ret := &DiscardedHostHandler{
|
|
||||||
hosts: make([]*DiscardedHost, 0),
|
|
||||||
port: port,
|
|
||||||
}
|
|
||||||
go ret.run()
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DiscardedHostHandler) SetReconnectHandler(fn func(string)) {
|
|
||||||
d.onConnection = &fn
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DiscardedHostHandler) AppendHost(host string) {
|
|
||||||
d.mu.Lock()
|
|
||||||
defer d.mu.Unlock()
|
|
||||||
log.Printf("Adding host %s to retry list", host)
|
|
||||||
d.hosts = append(d.hosts, &DiscardedHost{
|
|
||||||
Host: host,
|
|
||||||
Tries: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
119
disk-storage.go
119
disk-storage.go
@@ -1,119 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/gob"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DiskStorage struct {
|
|
||||||
stateFile string
|
|
||||||
lastSave int64
|
|
||||||
LastSaves map[CartId]int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDiskStorage(stateFile string) (*DiskStorage, error) {
|
|
||||||
ret := &DiskStorage{
|
|
||||||
stateFile: stateFile,
|
|
||||||
LastSaves: make(map[CartId]int64),
|
|
||||||
}
|
|
||||||
err := ret.loadState()
|
|
||||||
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 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 (s *DiskStorage) saveState() error {
|
|
||||||
tmpFile := s.stateFile + "_tmp"
|
|
||||||
file, err := os.Create(tmpFile)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
err = gob.NewEncoder(file).Encode(s.LastSaves)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
os.Remove(s.stateFile + ".bak")
|
|
||||||
os.Rename(s.stateFile, s.stateFile+".bak")
|
|
||||||
return os.Rename(tmpFile, s.stateFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *DiskStorage) loadState() error {
|
|
||||||
file, err := os.Open(s.stateFile)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
return gob.NewDecoder(file).Decode(&s.LastSaves)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *DiskStorage) Store(id CartId, grain *CartGrain) error {
|
|
||||||
lastSavedMessage, ok := s.LastSaves[id]
|
|
||||||
if ok && lastSavedMessage > grain.GetLastChange() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
err := saveMessages(grain.GetStorageMessage(lastSavedMessage), id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
ts := time.Now().Unix()
|
|
||||||
s.LastSaves[id] = ts
|
|
||||||
s.lastSave = ts
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
21
go.mod
21
go.mod
@@ -3,6 +3,7 @@ module git.tornberg.me/go-cart-actor
|
|||||||
go 1.25.1
|
go 1.25.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/gogo/protobuf v1.3.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548
|
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
@@ -20,8 +21,10 @@ require (
|
|||||||
github.com/bits-and-blooms/bitset v1.24.0 // indirect
|
github.com/bits-and-blooms/bitset v1.24.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
|
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||||
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||||
|
github.com/getkin/kin-openapi v0.132.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.22.1 // indirect
|
github.com/go-openapi/jsonpointer v0.22.1 // indirect
|
||||||
github.com/go-openapi/jsonreference v0.21.2 // indirect
|
github.com/go-openapi/jsonreference v0.21.2 // indirect
|
||||||
@@ -37,32 +40,46 @@ require (
|
|||||||
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
|
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
|
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
|
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
|
||||||
github.com/google/gnostic-models v0.7.0 // indirect
|
github.com/google/gnostic-models v0.7.0 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/gorilla/schema v1.4.1 // indirect
|
github.com/gorilla/schema v1.4.1 // indirect
|
||||||
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||||
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||||
github.com/mschoch/smat v0.2.0 // indirect
|
github.com/mschoch/smat v0.2.0 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 // indirect
|
||||||
|
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
|
||||||
|
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
|
||||||
|
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.1 // indirect
|
github.com/prometheus/common v0.67.1 // indirect
|
||||||
github.com/prometheus/procfs v0.17.0 // indirect
|
github.com/prometheus/procfs v0.17.0 // indirect
|
||||||
|
github.com/speakeasy-api/jsonpath v0.6.0 // indirect
|
||||||
|
github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // indirect
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
|
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/mod v0.28.0 // indirect
|
||||||
golang.org/x/net v0.46.0 // indirect
|
golang.org/x/net v0.46.0 // indirect
|
||||||
golang.org/x/oauth2 v0.32.0 // indirect
|
golang.org/x/oauth2 v0.32.0 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
golang.org/x/sys v0.37.0 // indirect
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
golang.org/x/term v0.36.0 // indirect
|
golang.org/x/term v0.36.0 // indirect
|
||||||
golang.org/x/text v0.30.0 // indirect
|
golang.org/x/text v0.30.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
|
golang.org/x/tools v0.37.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff // indirect
|
||||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
k8s.io/klog/v2 v2.130.1 // indirect
|
k8s.io/klog/v2 v2.130.1 // indirect
|
||||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
|
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
|
||||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
||||||
@@ -71,3 +88,5 @@ require (
|
|||||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
|
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
|
||||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
|
||||||
|
|||||||
118
go.sum
118
go.sum
@@ -7,14 +7,25 @@ github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJiz
|
|||||||
github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||||
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||||
|
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
||||||
|
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
|
||||||
|
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
|
||||||
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
|
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
|
||||||
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
|
github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
|
||||||
|
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
@@ -49,35 +60,62 @@ github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3
|
|||||||
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
|
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
|
||||||
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
|
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
|
||||||
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
|
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
|
||||||
|
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I=
|
||||||
|
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||||
|
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||||
|
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||||
|
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
||||||
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||||
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
|
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
|
||||||
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
||||||
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
||||||
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
|
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
|
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
|
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548 h1:lkxVz5lNPlU78El49cx1c3Rxo/bp7Gbzp2BjSRFgg1U=
|
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548 h1:lkxVz5lNPlU78El49cx1c3Rxo/bp7Gbzp2BjSRFgg1U=
|
||||||
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548/go.mod h1:k4lo5gFYb3AqrgftlraCnv95xvjB/w8udRqJQJ12mAE=
|
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548/go.mod h1:k4lo5gFYb3AqrgftlraCnv95xvjB/w8udRqJQJ12mAE=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -86,14 +124,38 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
|
|||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||||
|
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||||
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
|
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
|
||||||
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
|
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
|
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||||
|
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||||
|
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 h1:iJvF8SdB/3/+eGOXEpsWkD8FQAHj6mqkb6Fnsoc8MFU=
|
||||||
|
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0/go.mod h1:fwlMxUEMuQK5ih9aymrxKPQqNm2n8bdLk1ppjH+lr9w=
|
||||||
|
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
||||||
|
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
|
||||||
|
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
|
||||||
|
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
||||||
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
|
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
|
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||||
|
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
|
||||||
|
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
||||||
|
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
|
||||||
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
|
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
|
||||||
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
||||||
|
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||||
|
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||||
|
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||||
|
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||||
|
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
|
||||||
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
|
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
|
||||||
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
||||||
|
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||||
|
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
@@ -108,15 +170,27 @@ github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzuk
|
|||||||
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||||
|
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||||
|
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||||
|
github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8=
|
||||||
|
github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw=
|
||||||
|
github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU=
|
||||||
|
github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg=
|
||||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||||
|
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
|
||||||
|
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
@@ -144,26 +218,50 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
|||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||||
|
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||||
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||||
|
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
@@ -171,6 +269,7 @@ golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
|||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||||
@@ -184,15 +283,34 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff h1:
|
|||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||||
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||||
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
|
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||||
|
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||||
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
|
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
|
||||||
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||||
|
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||||
|
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
327
grafana_dashboard_cart.json
Normal file
327
grafana_dashboard_cart.json
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
{
|
||||||
|
"uid": "cart-actors",
|
||||||
|
"title": "Cart Actor Cluster",
|
||||||
|
"timezone": "browser",
|
||||||
|
"refresh": "30s",
|
||||||
|
"schemaVersion": 38,
|
||||||
|
"version": 1,
|
||||||
|
"editable": true,
|
||||||
|
"graphTooltip": 0,
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"type": "row",
|
||||||
|
"title": "Overview",
|
||||||
|
"gridPos": { "x": 0, "y": 0, "w": 24, "h": 1 },
|
||||||
|
"id": 1,
|
||||||
|
"collapsed": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Active Grains",
|
||||||
|
"id": 2,
|
||||||
|
"gridPos": { "x": 0, "y": 1, "w": 6, "h": 4 },
|
||||||
|
"datasource": "${DS_PROMETHEUS}",
|
||||||
|
"targets": [
|
||||||
|
{ "refId": "A", "expr": "cart_active_grains" }
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "center",
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Grains In Pool",
|
||||||
|
"id": 3,
|
||||||
|
"gridPos": { "x": 6, "y": 1, "w": 6, "h": 4 },
|
||||||
|
"datasource": "${DS_PROMETHEUS}",
|
||||||
|
"targets": [
|
||||||
|
{ "refId": "A", "expr": "cart_grains_in_pool" }
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "center",
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Pool Usage %",
|
||||||
|
"id": 4,
|
||||||
|
"gridPos": { "x": 12, "y": 1, "w": 6, "h": 4 },
|
||||||
|
"datasource": "${DS_PROMETHEUS}",
|
||||||
|
"targets": [
|
||||||
|
{ "refId": "A", "expr": "cart_grain_pool_usage * 100" }
|
||||||
|
],
|
||||||
|
"units": "percent",
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "center",
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Connected Remotes",
|
||||||
|
"id": 5,
|
||||||
|
"gridPos": { "x": 18, "y": 1, "w": 6, "h": 4 },
|
||||||
|
"datasource": "${DS_PROMETHEUS}",
|
||||||
|
"targets": [
|
||||||
|
{ "refId": "A", "expr": "connected_remotes" }
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "center",
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "row",
|
||||||
|
"title": "Mutations",
|
||||||
|
"gridPos": { "x": 0, "y": 5, "w": 24, "h": 1 },
|
||||||
|
"id": 6,
|
||||||
|
"collapsed": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Mutation Rate (1m)",
|
||||||
|
"id": 7,
|
||||||
|
"gridPos": { "x": 0, "y": 6, "w": 12, "h": 8 },
|
||||||
|
"datasource": "${DS_PROMETHEUS}",
|
||||||
|
"targets": [
|
||||||
|
{ "refId": "A", "expr": "rate(cart_mutations_total[1m])", "legendFormat": "mutations/s" },
|
||||||
|
{ "refId": "B", "expr": "rate(cart_mutation_failures_total[1m])", "legendFormat": "failures/s" }
|
||||||
|
],
|
||||||
|
"fieldConfig": { "defaults": { "unit": "ops" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Failure % (5m)",
|
||||||
|
"id": 8,
|
||||||
|
"gridPos": { "x": 12, "y": 6, "w": 6, "h": 4 },
|
||||||
|
"datasource": "${DS_PROMETHEUS}",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "100 * (increase(cart_mutation_failures_total[5m]) / clamp_max(increase(cart_mutations_total[5m]), 1))"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "center",
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Mutation Latency Quantiles",
|
||||||
|
"id": 9,
|
||||||
|
"gridPos": { "x": 18, "y": 6, "w": 6, "h": 8 },
|
||||||
|
"datasource": "${DS_PROMETHEUS}",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "histogram_quantile(0.50, sum(rate(cart_mutation_latency_seconds_bucket[5m])) by (le))",
|
||||||
|
"legendFormat": "p50"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"refId": "B",
|
||||||
|
"expr": "histogram_quantile(0.90, sum(rate(cart_mutation_latency_seconds_bucket[5m])) by (le))",
|
||||||
|
"legendFormat": "p90"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"refId": "C",
|
||||||
|
"expr": "histogram_quantile(0.99, sum(rate(cart_mutation_latency_seconds_bucket[5m])) by (le))",
|
||||||
|
"legendFormat": "p99"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": { "defaults": { "unit": "s" } }
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "row",
|
||||||
|
"title": "Event Log",
|
||||||
|
"gridPos": { "x": 0, "y": 14, "w": 24, "h": 1 },
|
||||||
|
"id": 10,
|
||||||
|
"collapsed": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Event Append Rate (5m)",
|
||||||
|
"id": 11,
|
||||||
|
"gridPos": { "x": 0, "y": 15, "w": 8, "h": 6 },
|
||||||
|
"datasource": "${DS_PROMETHEUS}",
|
||||||
|
"targets": [
|
||||||
|
{ "refId": "A", "expr": "rate(cart_event_log_appends_total[5m])", "legendFormat": "appends/s" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Event Bytes Written Rate (5m)",
|
||||||
|
"id": 12,
|
||||||
|
"gridPos": { "x": 8, "y": 15, "w": 8, "h": 6 },
|
||||||
|
"datasource": "${DS_PROMETHEUS}",
|
||||||
|
"targets": [
|
||||||
|
{ "refId": "A", "expr": "rate(cart_event_log_bytes_written_total[5m])", "legendFormat": "bytes/s" }
|
||||||
|
],
|
||||||
|
"fieldConfig": { "defaults": { "unit": "Bps" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Existing Log Files",
|
||||||
|
"id": 13,
|
||||||
|
"gridPos": { "x": 16, "y": 15, "w": 4, "h": 3 },
|
||||||
|
"datasource": "${DS_PROMETHEUS}",
|
||||||
|
"targets": [{ "refId": "A", "expr": "cart_event_log_files_existing" }],
|
||||||
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Last Append Age (s)",
|
||||||
|
"id": 14,
|
||||||
|
"gridPos": { "x": 20, "y": 15, "w": 4, "h": 3 },
|
||||||
|
"datasource": "${DS_PROMETHEUS}",
|
||||||
|
"targets": [
|
||||||
|
{ "refId": "A", "expr": "(time() - cart_event_log_last_append_unix)" }
|
||||||
|
],
|
||||||
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Replay Failures Total",
|
||||||
|
"id": 15,
|
||||||
|
"gridPos": { "x": 16, "y": 18, "w": 4, "h": 3 },
|
||||||
|
"datasource": "${DS_PROMETHEUS}",
|
||||||
|
"targets": [{ "refId": "A", "expr": "cart_event_log_replay_failures_total" }],
|
||||||
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Replay Duration p95 (5m)",
|
||||||
|
"id": 16,
|
||||||
|
"gridPos": { "x": 20, "y": 18, "w": 4, "h": 3 },
|
||||||
|
"datasource": "${DS_PROMETHEUS}",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "histogram_quantile(0.95, sum(rate(cart_event_log_replay_duration_seconds_bucket[5m])) by (le))"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "fieldConfig": { "defaults": { "unit": "s" } } }
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "row",
|
||||||
|
"title": "Grain Lifecycle",
|
||||||
|
"gridPos": { "x": 0, "y": 21, "w": 24, "h": 1 },
|
||||||
|
"id": 17,
|
||||||
|
"collapsed": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "Spawn & Lookup Rates (1m)",
|
||||||
|
"id": 18,
|
||||||
|
"gridPos": { "x": 0, "y": 22, "w": 12, "h": 8 },
|
||||||
|
"datasource": "${DS_PROMETHEUS}",
|
||||||
|
"targets": [
|
||||||
|
{ "refId": "A", "expr": "rate(cart_grain_spawned_total[1m])", "legendFormat": "spawns/s" },
|
||||||
|
{ "refId": "B", "expr": "rate(cart_grain_lookups_total[1m])", "legendFormat": "lookups/s" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Negotiations Rate (5m)",
|
||||||
|
"id": 19,
|
||||||
|
"gridPos": { "x": 12, "y": 22, "w": 6, "h": 4 },
|
||||||
|
"datasource": "${DS_PROMETHEUS}",
|
||||||
|
"targets": [
|
||||||
|
{ "refId": "A", "expr": "rate(cart_remote_negotiation_total[5m])" }
|
||||||
|
],
|
||||||
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "orientation": "horizontal" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Mutations Total",
|
||||||
|
"id": 20,
|
||||||
|
"gridPos": { "x": 18, "y": 22, "w": 6, "h": 4 },
|
||||||
|
"datasource": "${DS_PROMETHEUS}",
|
||||||
|
"targets": [{ "refId": "A", "expr": "cart_mutations_total" }],
|
||||||
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"type": "row",
|
||||||
|
"title": "Event Log Errors",
|
||||||
|
"gridPos": { "x": 0, "y": 30, "w": 24, "h": 1 },
|
||||||
|
"id": 21,
|
||||||
|
"collapsed": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Unknown Event Types",
|
||||||
|
"id": 22,
|
||||||
|
"gridPos": { "x": 0, "y": 31, "w": 6, "h": 4 },
|
||||||
|
"datasource": "${DS_PROMETHEUS}",
|
||||||
|
"targets": [{ "refId": "A", "expr": "cart_event_log_unknown_types_total" }],
|
||||||
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Event Mutation Errors",
|
||||||
|
"id": 23,
|
||||||
|
"gridPos": { "x": 6, "y": 31, "w": 6, "h": 4 },
|
||||||
|
"datasource": "${DS_PROMETHEUS}",
|
||||||
|
"targets": [{ "refId": "A", "expr": "cart_event_log_mutation_errors_total" }],
|
||||||
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Replay Success Total",
|
||||||
|
"id": 24,
|
||||||
|
"gridPos": { "x": 12, "y": 31, "w": 6, "h": 4 },
|
||||||
|
"datasource": "${DS_PROMETHEUS}",
|
||||||
|
"targets": [{ "refId": "A", "expr": "cart_event_log_replay_total" }],
|
||||||
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Replay Duration p50 (5m)",
|
||||||
|
"id": 25,
|
||||||
|
"gridPos": { "x": 18, "y": 31, "w": 6, "h": 4 },
|
||||||
|
"datasource": "${DS_PROMETHEUS}",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"refId": "A",
|
||||||
|
"expr": "histogram_quantile(0.50, sum(rate(cart_event_log_replay_duration_seconds_bucket[5m])) by (le))"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "fieldConfig": { "defaults": { "unit": "s" } } }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"templating": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"name": "DS_PROMETHEUS",
|
||||||
|
"label": "Prometheus",
|
||||||
|
"type": "datasource",
|
||||||
|
"query": "prometheus",
|
||||||
|
"current": { "text": "Prometheus", "value": "Prometheus" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"from": "now-6h",
|
||||||
|
"to": "now"
|
||||||
|
},
|
||||||
|
"timepicker": {
|
||||||
|
"refresh_intervals": ["5s","10s","30s","1m","5m","15m","30m","1h"],
|
||||||
|
"time_options": ["5m","15m","30m","1h","6h","12h","24h","2d","7d"]
|
||||||
|
}
|
||||||
|
}
|
||||||
168
grain-pool.go
168
grain-pool.go
@@ -1,168 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
poolGrains = promauto.NewGauge(prometheus.GaugeOpts{
|
|
||||||
Name: "cart_grains_in_pool",
|
|
||||||
Help: "The total number of grains in the pool",
|
|
||||||
})
|
|
||||||
poolSize = promauto.NewGauge(prometheus.GaugeOpts{
|
|
||||||
Name: "cart_pool_size",
|
|
||||||
Help: "The total number of mutations",
|
|
||||||
})
|
|
||||||
poolUsage = promauto.NewGauge(prometheus.GaugeOpts{
|
|
||||||
Name: "cart_grain_pool_usage",
|
|
||||||
Help: "The current usage of the grain pool",
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
type GrainPool interface {
|
|
||||||
Process(id CartId, messages ...Message) (*FrameWithPayload, error)
|
|
||||||
Get(id CartId) (*FrameWithPayload, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Ttl struct {
|
|
||||||
Expires time.Time
|
|
||||||
Grain *CartGrain
|
|
||||||
}
|
|
||||||
|
|
||||||
type GrainLocalPool struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
grains map[CartId]*CartGrain
|
|
||||||
expiry []Ttl
|
|
||||||
spawn func(id CartId) (*CartGrain, error)
|
|
||||||
Ttl time.Duration
|
|
||||||
PoolSize int
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewGrainLocalPool(size int, ttl time.Duration, spawn func(id CartId) (*CartGrain, error)) *GrainLocalPool {
|
|
||||||
|
|
||||||
ret := &GrainLocalPool{
|
|
||||||
spawn: spawn,
|
|
||||||
grains: make(map[CartId]*CartGrain),
|
|
||||||
expiry: make([]Ttl, 0),
|
|
||||||
Ttl: ttl,
|
|
||||||
PoolSize: size,
|
|
||||||
}
|
|
||||||
|
|
||||||
cartPurge := time.NewTicker(time.Minute)
|
|
||||||
go func() {
|
|
||||||
<-cartPurge.C
|
|
||||||
ret.Purge()
|
|
||||||
}()
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *GrainLocalPool) SetAvailable(availableWithLastChangeUnix map[CartId]int64) {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
for id := range availableWithLastChangeUnix {
|
|
||||||
if _, ok := p.grains[id]; !ok {
|
|
||||||
p.grains[id] = nil
|
|
||||||
p.expiry = append(p.expiry, Ttl{
|
|
||||||
Expires: time.Now().Add(p.Ttl),
|
|
||||||
Grain: nil,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *GrainLocalPool) Purge() {
|
|
||||||
lastChangeTime := time.Now().Add(-p.Ttl)
|
|
||||||
keepChanged := lastChangeTime.Unix()
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
for i := 0; i < len(p.expiry); i++ {
|
|
||||||
item := p.expiry[i]
|
|
||||||
if item.Expires.Before(time.Now()) {
|
|
||||||
if item.Grain.GetLastChange() > keepChanged {
|
|
||||||
log.Printf("Expired item %s changed, keeping", item.Grain.GetId())
|
|
||||||
if i < len(p.expiry)-1 {
|
|
||||||
p.expiry = append(p.expiry[:i], p.expiry[i+1:]...)
|
|
||||||
p.expiry = append(p.expiry, item)
|
|
||||||
} else {
|
|
||||||
p.expiry = append(p.expiry[:i], item)
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
|
||||||
log.Printf("Item %s expired", item.Grain.GetId())
|
|
||||||
delete(p.grains, item.Grain.GetId())
|
|
||||||
if i < len(p.expiry)-1 {
|
|
||||||
p.expiry = append(p.expiry[:i], p.expiry[i+1:]...)
|
|
||||||
} else {
|
|
||||||
p.expiry = p.expiry[:i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *GrainLocalPool) GetGrains() map[CartId]*CartGrain {
|
|
||||||
return p.grains
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *GrainLocalPool) GetGrain(id CartId) (*CartGrain, error) {
|
|
||||||
var err error
|
|
||||||
// p.mu.RLock()
|
|
||||||
// defer p.mu.RUnlock()
|
|
||||||
grain, ok := p.grains[id]
|
|
||||||
grainLookups.Inc()
|
|
||||||
if grain == nil || !ok {
|
|
||||||
if len(p.grains) >= p.PoolSize {
|
|
||||||
if p.expiry[0].Expires.Before(time.Now()) {
|
|
||||||
delete(p.grains, p.expiry[0].Grain.GetId())
|
|
||||||
p.expiry = p.expiry[1:]
|
|
||||||
} else {
|
|
||||||
return nil, fmt.Errorf("pool is full")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
grain, err = p.spawn(id)
|
|
||||||
p.mu.Lock()
|
|
||||||
p.grains[id] = grain
|
|
||||||
p.mu.Unlock()
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
l := float64(len(p.grains))
|
|
||||||
ps := float64(p.PoolSize)
|
|
||||||
poolUsage.Set(l / ps)
|
|
||||||
poolGrains.Set(l)
|
|
||||||
poolSize.Set(ps)
|
|
||||||
}()
|
|
||||||
return grain, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *GrainLocalPool) Process(id CartId, messages ...Message) (*FrameWithPayload, error) {
|
|
||||||
grain, err := p.GetGrain(id)
|
|
||||||
var result *FrameWithPayload
|
|
||||||
if err == nil && grain != nil {
|
|
||||||
for _, message := range messages {
|
|
||||||
result, err = grain.HandleMessage(&message, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *GrainLocalPool) Get(id CartId) (*FrameWithPayload, error) {
|
|
||||||
grain, err := p.GetGrain(id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
data, err := json.Marshal(grain)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ret := MakeFrameWithPayload(0, 200, data)
|
|
||||||
return &ret, nil
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
messages "git.tornberg.me/go-cart-actor/proto"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestCartActorMutationAndState validates end-to-end gRPC mutation + state retrieval
|
|
||||||
// against a locally started gRPC server (single-node scenario).
|
|
||||||
// This test uses AddItemType directly to avoid hitting external product
|
|
||||||
// fetching logic (FetchItem) which would require network I/O.
|
|
||||||
func TestCartActorMutationAndState(t *testing.T) {
|
|
||||||
// Setup local grain pool + synced pool (no discovery, single host)
|
|
||||||
pool := NewGrainLocalPool(1024, time.Minute, spawn)
|
|
||||||
synced, err := NewSyncedPool(pool, "127.0.0.1", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewSyncedPool error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start gRPC server (CartActor + ControlPlane) on :1337
|
|
||||||
grpcSrv, err := StartGRPCServer(":1337", pool, synced)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("StartGRPCServer error: %v", err)
|
|
||||||
}
|
|
||||||
defer grpcSrv.GracefulStop()
|
|
||||||
|
|
||||||
// Dial the local server
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
conn, err := grpc.DialContext(ctx, "127.0.0.1:1337",
|
|
||||||
grpc.WithInsecure(),
|
|
||||||
grpc.WithBlock(),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("grpc.Dial error: %v", err)
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
cartClient := messages.NewCartActorClient(conn)
|
|
||||||
|
|
||||||
// Create a short cart id (<=16 chars so it fits into the fixed CartId 16-byte array cleanly)
|
|
||||||
cartID := fmt.Sprintf("cart-%d", time.Now().UnixNano())
|
|
||||||
|
|
||||||
// Build an AddItem payload (bypasses FetchItem to keep test deterministic)
|
|
||||||
addItem := &messages.AddItem{
|
|
||||||
ItemId: 1,
|
|
||||||
Quantity: 1,
|
|
||||||
Price: 1000,
|
|
||||||
OrgPrice: 1000,
|
|
||||||
Sku: "test-sku",
|
|
||||||
Name: "Test SKU",
|
|
||||||
Image: "/img.png",
|
|
||||||
Stock: 2, // InStock
|
|
||||||
Tax: 2500,
|
|
||||||
Country: "se",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marshal underlying mutation payload using the existing handler code path
|
|
||||||
handler, ok := Handlers[AddItemType]
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("Handler for AddItemType missing")
|
|
||||||
}
|
|
||||||
payloadData, err := getSerializedPayload(handler, AddItemType, addItem)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("serialize add item: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Issue Mutate RPC
|
|
||||||
mutResp, err := cartClient.Mutate(context.Background(), &messages.MutationRequest{
|
|
||||||
CartId: cartID,
|
|
||||||
Type: messages.MutationType(AddItemType),
|
|
||||||
Payload: payloadData,
|
|
||||||
ClientTimestamp: time.Now().Unix(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Mutate RPC error: %v", err)
|
|
||||||
}
|
|
||||||
if mutResp.StatusCode != 200 {
|
|
||||||
t.Fatalf("Mutate returned non-200 status: %d payload=%s", mutResp.StatusCode, string(mutResp.Payload))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode cart state JSON and validate
|
|
||||||
state := &CartGrain{}
|
|
||||||
if err := json.Unmarshal(mutResp.Payload, state); err != nil {
|
|
||||||
t.Fatalf("Unmarshal mutate cart state: %v\nPayload: %s", err, string(mutResp.Payload))
|
|
||||||
}
|
|
||||||
if len(state.Items) != 1 {
|
|
||||||
t.Fatalf("Expected 1 item after mutation, got %d", len(state.Items))
|
|
||||||
}
|
|
||||||
if state.Items[0].Sku != "test-sku" {
|
|
||||||
t.Fatalf("Unexpected item SKU: %s", state.Items[0].Sku)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Issue GetState RPC
|
|
||||||
getResp, err := cartClient.GetState(context.Background(), &messages.StateRequest{
|
|
||||||
CartId: cartID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetState RPC error: %v", err)
|
|
||||||
}
|
|
||||||
if getResp.StatusCode != 200 {
|
|
||||||
t.Fatalf("GetState returned non-200 status: %d payload=%s", getResp.StatusCode, string(getResp.Payload))
|
|
||||||
}
|
|
||||||
|
|
||||||
state2 := &CartGrain{}
|
|
||||||
if err := json.Unmarshal(getResp.Payload, state2); err != nil {
|
|
||||||
t.Fatalf("Unmarshal get state: %v", err)
|
|
||||||
}
|
|
||||||
if len(state2.Items) != 1 {
|
|
||||||
t.Fatalf("Expected 1 item in GetState, got %d", len(state2.Items))
|
|
||||||
}
|
|
||||||
if state2.Items[0].Sku != "test-sku" {
|
|
||||||
t.Fatalf("Unexpected SKU in GetState: %s", state2.Items[0].Sku)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getSerializedPayload serializes a mutation proto using the registered handler.
|
|
||||||
func getSerializedPayload(handler MessageHandler, msgType uint16, content interface{}) ([]byte, error) {
|
|
||||||
msg := &Message{
|
|
||||||
Type: msgType,
|
|
||||||
Content: content,
|
|
||||||
}
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := handler.Write(msg, &buf); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return buf.Bytes(), nil
|
|
||||||
}
|
|
||||||
379
grpc_server.go
379
grpc_server.go
@@ -1,379 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
proto "git.tornberg.me/go-cart-actor/proto" // underlying generated package name is 'messages'
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
)
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Metrics
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
var (
|
|
||||||
grpcMutateDuration = promauto.NewHistogram(prometheus.HistogramOpts{
|
|
||||||
Name: "cart_grpc_mutate_duration_seconds",
|
|
||||||
Help: "Duration of CartActor.Mutate RPCs",
|
|
||||||
Buckets: prometheus.DefBuckets,
|
|
||||||
})
|
|
||||||
grpcMutateErrors = promauto.NewCounter(prometheus.CounterOpts{
|
|
||||||
Name: "cart_grpc_mutate_errors_total",
|
|
||||||
Help: "Total number of failed CartActor.Mutate RPCs",
|
|
||||||
})
|
|
||||||
grpcStateDuration = promauto.NewHistogram(prometheus.HistogramOpts{
|
|
||||||
Name: "cart_grpc_get_state_duration_seconds",
|
|
||||||
Help: "Duration of CartActor.GetState RPCs",
|
|
||||||
Buckets: prometheus.DefBuckets,
|
|
||||||
})
|
|
||||||
grpcControlDuration = promauto.NewHistogram(prometheus.HistogramOpts{
|
|
||||||
Name: "cart_grpc_control_duration_seconds",
|
|
||||||
Help: "Duration of ControlPlane RPCs",
|
|
||||||
Buckets: prometheus.DefBuckets,
|
|
||||||
})
|
|
||||||
grpcControlErrors = promauto.NewCounter(prometheus.CounterOpts{
|
|
||||||
Name: "cart_grpc_control_errors_total",
|
|
||||||
Help: "Total number of failed ControlPlane RPCs",
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// timeTrack wraps a closure and records duration into the supplied histogram.
|
|
||||||
func timeTrack(hist prometheus.Observer, fn func() error) (err error) {
|
|
||||||
start := time.Now()
|
|
||||||
defer func() {
|
|
||||||
hist.Observe(time.Since(start).Seconds())
|
|
||||||
}()
|
|
||||||
return fn()
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// CartActor Service Implementation
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type cartActorService struct {
|
|
||||||
proto.UnimplementedCartActorServer
|
|
||||||
pool GrainPool
|
|
||||||
}
|
|
||||||
|
|
||||||
func newCartActorService(pool GrainPool) *cartActorService {
|
|
||||||
return &cartActorService{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *cartActorService) Mutate(ctx context.Context, req *proto.MutationRequest) (*proto.MutationReply, error) {
|
|
||||||
var reply *proto.MutationReply
|
|
||||||
err := timeTrack(grpcMutateDuration, func() error {
|
|
||||||
if req == nil {
|
|
||||||
return status.Error(codes.InvalidArgument, "request is nil")
|
|
||||||
}
|
|
||||||
if req.CartId == "" {
|
|
||||||
return status.Error(codes.InvalidArgument, "cart_id is empty")
|
|
||||||
}
|
|
||||||
mt := uint16(req.Type.Number())
|
|
||||||
handler, ok := Handlers[mt]
|
|
||||||
if !ok {
|
|
||||||
return status.Errorf(codes.InvalidArgument, "unknown mutation type %d", mt)
|
|
||||||
}
|
|
||||||
content, err := handler.Read(req.Payload)
|
|
||||||
if err != nil {
|
|
||||||
return status.Errorf(codes.InvalidArgument, "decode payload: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ts := req.ClientTimestamp
|
|
||||||
if ts == 0 {
|
|
||||||
ts = time.Now().Unix()
|
|
||||||
}
|
|
||||||
msg := Message{
|
|
||||||
Type: mt,
|
|
||||||
TimeStamp: &ts,
|
|
||||||
Content: content,
|
|
||||||
}
|
|
||||||
|
|
||||||
frame, err := s.pool.Process(ToCartId(req.CartId), msg)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
reply = &proto.MutationReply{
|
|
||||||
StatusCode: int32(frame.StatusCode),
|
|
||||||
Payload: frame.Payload,
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
grpcMutateErrors.Inc()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return reply, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *cartActorService) GetState(ctx context.Context, req *proto.StateRequest) (*proto.StateReply, error) {
|
|
||||||
var reply *proto.StateReply
|
|
||||||
err := timeTrack(grpcStateDuration, func() error {
|
|
||||||
if req == nil || req.CartId == "" {
|
|
||||||
return status.Error(codes.InvalidArgument, "cart_id is empty")
|
|
||||||
}
|
|
||||||
frame, err := s.pool.Get(ToCartId(req.CartId))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
reply = &proto.StateReply{
|
|
||||||
StatusCode: int32(frame.StatusCode),
|
|
||||||
Payload: frame.Payload,
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return reply, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// ControlPlane Service Implementation
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// controlPlaneService directly leverages SyncedPool internals (same package).
|
|
||||||
// NOTE: This is a transitional adapter; once the legacy frame-based code is
|
|
||||||
// removed, related fields/methods in SyncedPool can be slimmed.
|
|
||||||
type controlPlaneService struct {
|
|
||||||
proto.UnimplementedControlPlaneServer
|
|
||||||
pool *SyncedPool
|
|
||||||
}
|
|
||||||
|
|
||||||
func newControlPlaneService(pool *SyncedPool) *controlPlaneService {
|
|
||||||
return &controlPlaneService{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *controlPlaneService) Ping(ctx context.Context, _ *proto.Empty) (*proto.PingReply, error) {
|
|
||||||
var reply *proto.PingReply
|
|
||||||
err := timeTrack(grpcControlDuration, func() error {
|
|
||||||
reply = &proto.PingReply{
|
|
||||||
Host: s.pool.Hostname,
|
|
||||||
UnixTime: time.Now().Unix(),
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
grpcControlErrors.Inc()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return reply, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *controlPlaneService) Negotiate(ctx context.Context, req *proto.NegotiateRequest) (*proto.NegotiateReply, error) {
|
|
||||||
var reply *proto.NegotiateReply
|
|
||||||
err := timeTrack(grpcControlDuration, func() error {
|
|
||||||
if req == nil {
|
|
||||||
return status.Error(codes.InvalidArgument, "request is nil")
|
|
||||||
}
|
|
||||||
// Add unknown hosts
|
|
||||||
for _, host := range req.KnownHosts {
|
|
||||||
if host == "" || host == s.pool.Hostname {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !s.pool.IsKnown(host) {
|
|
||||||
go s.pool.AddRemote(host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Build healthy host list
|
|
||||||
hosts := make([]string, 0)
|
|
||||||
for _, r := range s.pool.GetHealthyRemotes() {
|
|
||||||
hosts = append(hosts, r.Host)
|
|
||||||
}
|
|
||||||
hosts = append(hosts, s.pool.Hostname)
|
|
||||||
reply = &proto.NegotiateReply{
|
|
||||||
Hosts: hosts,
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
grpcControlErrors.Inc()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return reply, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *controlPlaneService) GetCartIds(ctx context.Context, _ *proto.Empty) (*proto.CartIdsReply, error) {
|
|
||||||
var reply *proto.CartIdsReply
|
|
||||||
err := timeTrack(grpcControlDuration, func() error {
|
|
||||||
s.pool.mu.RLock()
|
|
||||||
defer s.pool.mu.RUnlock()
|
|
||||||
ids := make([]string, 0, len(s.pool.local.grains))
|
|
||||||
for id, g := range s.pool.local.grains {
|
|
||||||
if g == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if id.String() == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ids = append(ids, id.String())
|
|
||||||
}
|
|
||||||
reply = &proto.CartIdsReply{
|
|
||||||
CartIds: ids,
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
grpcControlErrors.Inc()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return reply, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *controlPlaneService) ConfirmOwner(ctx context.Context, req *proto.OwnerChangeRequest) (*proto.OwnerChangeAck, error) {
|
|
||||||
var reply *proto.OwnerChangeAck
|
|
||||||
err := timeTrack(grpcControlDuration, func() error {
|
|
||||||
if req == nil || req.CartId == "" || req.NewHost == "" {
|
|
||||||
return status.Error(codes.InvalidArgument, "cart_id or new_host missing")
|
|
||||||
}
|
|
||||||
id := ToCartId(req.CartId)
|
|
||||||
newHost := req.NewHost
|
|
||||||
|
|
||||||
// Mirror GrainOwnerChangeHandler semantics
|
|
||||||
log.Printf("gRPC ConfirmOwner: cart %s newHost=%s", id, newHost)
|
|
||||||
for _, r := range s.pool.remoteHosts {
|
|
||||||
if r.Host == newHost && r.IsHealthy() {
|
|
||||||
go s.pool.SpawnRemoteGrain(id, newHost)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
go s.pool.AddRemote(newHost)
|
|
||||||
|
|
||||||
reply = &proto.OwnerChangeAck{
|
|
||||||
Accepted: true,
|
|
||||||
Message: "ok",
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
grpcControlErrors.Inc()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return reply, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *controlPlaneService) Closing(ctx context.Context, notice *proto.ClosingNotice) (*proto.OwnerChangeAck, error) {
|
|
||||||
var reply *proto.OwnerChangeAck
|
|
||||||
err := timeTrack(grpcControlDuration, func() error {
|
|
||||||
if notice == nil || notice.Host == "" {
|
|
||||||
return status.Error(codes.InvalidArgument, "host missing")
|
|
||||||
}
|
|
||||||
host := notice.Host
|
|
||||||
s.pool.mu.RLock()
|
|
||||||
_, exists := s.pool.remoteHosts[host]
|
|
||||||
s.pool.mu.RUnlock()
|
|
||||||
if exists {
|
|
||||||
go s.pool.RemoveHost(host)
|
|
||||||
}
|
|
||||||
reply = &proto.OwnerChangeAck{
|
|
||||||
Accepted: true,
|
|
||||||
Message: "removed",
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
grpcControlErrors.Inc()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return reply, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Server Bootstrap
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type GRPCServer struct {
|
|
||||||
server *grpc.Server
|
|
||||||
lis net.Listener
|
|
||||||
addr string
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartGRPCServer sets up a gRPC server hosting both CartActor and ControlPlane services.
|
|
||||||
// addr example: ":1337" (for combined) OR run two servers if you want separate ports.
|
|
||||||
// For the migration we can host both on the same listener to reduce open ports.
|
|
||||||
func StartGRPCServer(addr string, pool GrainPool, synced *SyncedPool, opts ...grpc.ServerOption) (*GRPCServer, error) {
|
|
||||||
if pool == nil {
|
|
||||||
return nil, errors.New("nil grain pool")
|
|
||||||
}
|
|
||||||
if synced == nil {
|
|
||||||
return nil, errors.New("nil synced pool")
|
|
||||||
}
|
|
||||||
|
|
||||||
lis, err := net.Listen("tcp", addr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("listen %s: %w", addr, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
grpcServer := grpc.NewServer(opts...)
|
|
||||||
proto.RegisterCartActorServer(grpcServer, newCartActorService(pool))
|
|
||||||
proto.RegisterControlPlaneServer(grpcServer, newControlPlaneService(synced))
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
log.Printf("gRPC server listening on %s", addr)
|
|
||||||
if serveErr := grpcServer.Serve(lis); serveErr != nil {
|
|
||||||
log.Printf("gRPC server stopped: %v", serveErr)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return &GRPCServer{
|
|
||||||
server: grpcServer,
|
|
||||||
lis: lis,
|
|
||||||
addr: addr,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GracefulStop stops the server gracefully.
|
|
||||||
func (s *GRPCServer) GracefulStop() {
|
|
||||||
if s == nil || s.server == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.server.GracefulStop()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Addr returns the bound address.
|
|
||||||
func (s *GRPCServer) Addr() string {
|
|
||||||
if s == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return s.addr
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Client Dial Helpers (used later by refactored remote grain + control plane)
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// DialRemote establishes (or reuses externally) a gRPC client connection.
|
|
||||||
func DialRemote(ctx context.Context, target string, opts ...grpc.DialOption) (*grpc.ClientConn, error) {
|
|
||||||
dialOpts := []grpc.DialOption{
|
|
||||||
grpc.WithInsecure(), // NOTE: Intentional for initial migration; replace with TLS / mTLS later.
|
|
||||||
grpc.WithBlock(),
|
|
||||||
}
|
|
||||||
dialOpts = append(dialOpts, opts...)
|
|
||||||
ctxDial, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
conn, err := grpc.DialContext(ctxDial, target, dialOpts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return conn, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Utility for converting internal errors to gRPC status (if needed later).
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func grpcError(err error) error {
|
|
||||||
if err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Extend mapping if we add richer error types.
|
|
||||||
return status.Error(codes.Internal, err.Error())
|
|
||||||
}
|
|
||||||
174
k6/README.md
Normal file
174
k6/README.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# k6 Load Tests for Cart API
|
||||||
|
|
||||||
|
This directory contains a k6 script (`cart_load_test.js`) to stress and observe the cart actor HTTP API.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- `cart_load_test.js` – primary k6 scenario script
|
||||||
|
- `README.md` – this file
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node not required (k6 runs standalone)
|
||||||
|
- k6 installed (>= v0.43 recommended)
|
||||||
|
- Prometheus + Grafana (optional) if you want to correlate with the dashboard you generated
|
||||||
|
- A running cart service exposing HTTP endpoints at (default) `http://localhost:8080/cart`
|
||||||
|
|
||||||
|
## Endpoints Exercised
|
||||||
|
|
||||||
|
The script exercises (per iteration):
|
||||||
|
|
||||||
|
1. `GET /cart/` – ensure / fetch cart state (creates cart if missing; sets `cartid` & `cartowner` cookies)
|
||||||
|
2. `POST /cart/` – add item mutation (random SKU & quantity)
|
||||||
|
3. `GET /cart/` – fetch after mutations
|
||||||
|
4. `GET /cart/checkout` – occasionally (~2% of iterations) to simulate checkout start
|
||||||
|
|
||||||
|
You can extend it easily to hit deliveries, quantity changes, or removal endpoints.
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Purpose | Default |
|
||||||
|
|-----------------|----------------------------------------------|-------------------------|
|
||||||
|
| `BASE_URL` | Base URL root (either host or host/cart) | `http://localhost:8080/cart` |
|
||||||
|
| `VUS` | VUs for steady_mutations scenario | `20` |
|
||||||
|
| `DURATION` | Duration for steady_mutations scenario | `5m` |
|
||||||
|
| `RAMP_TARGET` | Peak VUs for ramp_up scenario | `50` |
|
||||||
|
|
||||||
|
You can also disable one scenario by editing `options.scenarios` inside the script.
|
||||||
|
|
||||||
|
Example run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
k6 run \
|
||||||
|
-e BASE_URL=https://cart.prod.example.com/cart \
|
||||||
|
-e VUS=40 \
|
||||||
|
-e DURATION=10m \
|
||||||
|
-e RAMP_TARGET=120 \
|
||||||
|
k6/cart_load_test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Metrics (Custom)
|
||||||
|
|
||||||
|
The script defines additional k6 metrics:
|
||||||
|
|
||||||
|
- `cart_add_item_duration` (Trend) – latency of POST add item
|
||||||
|
- `cart_fetch_duration` (Trend) – latency of GET cart state
|
||||||
|
- `cart_checkout_duration` (Trend) – latency of checkout
|
||||||
|
- `cart_items_added` (Counter) – successful add item operations
|
||||||
|
- `cart_checkout_calls` (Counter) – successful checkout calls
|
||||||
|
|
||||||
|
Thresholds (in `options.thresholds`) enforce basic SLO:
|
||||||
|
- Mutation failure rate < 2%
|
||||||
|
- p90 mutation latency < 800 ms
|
||||||
|
- p99 overall HTTP latency < 1500 ms
|
||||||
|
|
||||||
|
Adjust thresholds to your environment if they trigger prematurely.
|
||||||
|
|
||||||
|
## Cookies & Stickiness
|
||||||
|
|
||||||
|
The script preserves:
|
||||||
|
- `cartid` – cart identity (server sets expiry separately)
|
||||||
|
- `cartowner` – owning host for sticky routing
|
||||||
|
|
||||||
|
If your load balancer or ingress enforces affinity based on these cookies, traffic will naturally concentrate on the originally claimed host for each cart under test.
|
||||||
|
|
||||||
|
## SKU Set
|
||||||
|
|
||||||
|
SKUs used (randomly selected each mutation):
|
||||||
|
|
||||||
|
```
|
||||||
|
778290 778345 778317 778277 778267 778376 778244 778384
|
||||||
|
778365 778377 778255 778286 778246 778270 778266 778285
|
||||||
|
778329 778425 778407 778418 778430 778469 778358 778351
|
||||||
|
778319 778307 778278 778251 778253 778261 778263 778273
|
||||||
|
778281 778294 778297 778302
|
||||||
|
```
|
||||||
|
|
||||||
|
To add/remove SKUs, edit the `SKUS` array. Keeping it non-empty and moderately sized helps randomization.
|
||||||
|
|
||||||
|
## Extending the Script
|
||||||
|
|
||||||
|
### Add Quantity Change
|
||||||
|
|
||||||
|
```js
|
||||||
|
function changeQuantity(itemId, newQty) {
|
||||||
|
const payload = JSON.stringify({ Id: itemId, Qty: newQty });
|
||||||
|
http.put(baseUrl() + '/', payload, { headers: headers() });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remove Item
|
||||||
|
|
||||||
|
```js
|
||||||
|
function removeItem(itemId) {
|
||||||
|
http.del(baseUrl() + '/' + itemId, null, { headers: headers() });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add Delivery
|
||||||
|
|
||||||
|
```js
|
||||||
|
function addDelivery(itemIds) {
|
||||||
|
const payload = JSON.stringify({ provider: "POSTNORD", items: itemIds });
|
||||||
|
http.post(baseUrl() + '/delivery', payload, { headers: headers() });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can integrate these into the iteration loop with probabilities.
|
||||||
|
|
||||||
|
## Output Summary
|
||||||
|
|
||||||
|
`handleSummary` outputs a JSON summary to stdout:
|
||||||
|
- Average & p95 mutation latencies (if present)
|
||||||
|
- Fetch p95
|
||||||
|
- Checkout count
|
||||||
|
- Check statuses
|
||||||
|
|
||||||
|
Redirect or parse that output for CI pipelines.
|
||||||
|
|
||||||
|
## Running in CI
|
||||||
|
|
||||||
|
Use shorter durations (e.g. `DURATION=2m VUS=10`) to keep builds fast. Fail build on threshold breaches:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
k6 run -e BASE_URL=$TARGET -e VUS=10 -e DURATION=2m k6/cart_load_test.js || exit 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Correlating with Prometheus / Grafana
|
||||||
|
|
||||||
|
During load, observe:
|
||||||
|
- `cart_mutations_total` growth and latency histograms
|
||||||
|
- Event log write rate (`cart_event_log_appends_total`)
|
||||||
|
- Pool usage (`cart_grain_pool_usage`) and spawn rate (`cart_grain_spawned_total`)
|
||||||
|
- Failure counters (`cart_mutation_failures_total`) ensure they remain low
|
||||||
|
|
||||||
|
If mutation latency spikes without high error rate, inspect external dependencies (e.g., product fetcher or Klarna endpoints).
|
||||||
|
|
||||||
|
## Common Tuning Tips
|
||||||
|
|
||||||
|
| Symptom | Potential Adjustment |
|
||||||
|
|------------------------------------|---------------------------------------------------|
|
||||||
|
| High latency p99 | Increase CPU/memory, optimize mutation handlers |
|
||||||
|
| Pool at capacity | Raise pool size argument or TTL |
|
||||||
|
| Frequent cart eviction mid-test | Confirm TTL is sliding (now 2h on mutation) |
|
||||||
|
| High replay duration | Consider snapshot + truncate event logs |
|
||||||
|
| Uneven host load | Verify `cartowner` cookie is respected upstream |
|
||||||
|
|
||||||
|
## Safety / Load Guardrails
|
||||||
|
|
||||||
|
- Start with low VUs (5–10) and short duration.
|
||||||
|
- Scale incrementally to find saturation points.
|
||||||
|
- If using production endpoints, coordinate off-peak runs.
|
||||||
|
|
||||||
|
## License / Attribution
|
||||||
|
|
||||||
|
This test script is tailored for your internal cart actor system; adapt freely. k6 is open-source (AGPL v3). Ensure compliance if redistributing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Feel free to request:
|
||||||
|
- A variant script for spike tests
|
||||||
|
- WebSocket / long poll integration (if added later)
|
||||||
|
- Synthetic error injection harness
|
||||||
|
|
||||||
|
Happy load testing!
|
||||||
248
k6/cart_load_test.js
Normal file
248
k6/cart_load_test.js
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import http from "k6/http";
|
||||||
|
import { check, sleep, group } from "k6";
|
||||||
|
import { Counter, Trend } from "k6/metrics";
|
||||||
|
|
||||||
|
// ---------------- Configuration ----------------
|
||||||
|
export const options = {
|
||||||
|
// Adjust vus/duration for your environment
|
||||||
|
scenarios: {
|
||||||
|
steady_mutations: {
|
||||||
|
executor: "constant-vus",
|
||||||
|
vus: __ENV.VUS ? parseInt(__ENV.VUS, 10) : 20,
|
||||||
|
duration: __ENV.DURATION || "5m",
|
||||||
|
gracefulStop: "30s",
|
||||||
|
},
|
||||||
|
ramp_up: {
|
||||||
|
executor: "ramping-vus",
|
||||||
|
startVUs: 0,
|
||||||
|
stages: [
|
||||||
|
{
|
||||||
|
duration: "1m",
|
||||||
|
target: __ENV.RAMP_TARGET
|
||||||
|
? parseInt(__ENV.RAMP_TARGET, 10)
|
||||||
|
: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
duration: "1m",
|
||||||
|
target: __ENV.RAMP_TARGET
|
||||||
|
? parseInt(__ENV.RAMP_TARGET, 10)
|
||||||
|
: 50,
|
||||||
|
},
|
||||||
|
{ duration: "1m", target: 0 },
|
||||||
|
],
|
||||||
|
gracefulStop: "30s",
|
||||||
|
startTime: "30s",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
http_req_failed: ["rate<0.02"], // < 2% failures
|
||||||
|
http_req_duration: ["p(90)<800", "p(99)<1500"], // latency SLO
|
||||||
|
"cart_add_item_duration{op:add}": ["p(90)<800"],
|
||||||
|
"cart_fetch_duration{op:get}": ["p(90)<600"],
|
||||||
|
},
|
||||||
|
summaryTrendStats: ["avg", "min", "med", "max", "p(90)", "p(95)", "p(99)"],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------- Metrics ----------------
|
||||||
|
const addItemTrend = new Trend("cart_add_item_duration", true);
|
||||||
|
const fetchTrend = new Trend("cart_fetch_duration", true);
|
||||||
|
const checkoutTrend = new Trend("cart_checkout_duration", true);
|
||||||
|
const addedItemsCounter = new Counter("cart_items_added");
|
||||||
|
const checkoutCounter = new Counter("cart_checkout_calls");
|
||||||
|
|
||||||
|
// ---------------- SKUs ----------------
|
||||||
|
const SKUS = [
|
||||||
|
"778290",
|
||||||
|
"778345",
|
||||||
|
"778317",
|
||||||
|
"778277",
|
||||||
|
"778267",
|
||||||
|
"778376",
|
||||||
|
"778244",
|
||||||
|
"778384",
|
||||||
|
"778365",
|
||||||
|
"778377",
|
||||||
|
"778255",
|
||||||
|
"778286",
|
||||||
|
"778246",
|
||||||
|
"778270",
|
||||||
|
"778266",
|
||||||
|
"778285",
|
||||||
|
"778329",
|
||||||
|
"778425",
|
||||||
|
"778407",
|
||||||
|
"778418",
|
||||||
|
"778430",
|
||||||
|
"778469",
|
||||||
|
"778358",
|
||||||
|
"778351",
|
||||||
|
"778319",
|
||||||
|
"778307",
|
||||||
|
"778278",
|
||||||
|
"778251",
|
||||||
|
"778253",
|
||||||
|
"778261",
|
||||||
|
"778263",
|
||||||
|
"778273",
|
||||||
|
"778281",
|
||||||
|
"778294",
|
||||||
|
"778297",
|
||||||
|
"778302",
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------- Helpers ----------------
|
||||||
|
function randomSku() {
|
||||||
|
return SKUS[Math.floor(Math.random() * SKUS.length)];
|
||||||
|
}
|
||||||
|
function randomQty() {
|
||||||
|
return 1 + Math.floor(Math.random() * 3); // 1..3
|
||||||
|
}
|
||||||
|
function baseUrl() {
|
||||||
|
const u = __ENV.BASE_URL || "http://localhost:8080/cart";
|
||||||
|
// Allow user to pass either root host or full /cart path
|
||||||
|
return u.endsWith("/cart") ? u : u.replace(/\/+$/, "") + "/cart";
|
||||||
|
}
|
||||||
|
function extractCookie(res, name) {
|
||||||
|
const cookies = res.cookies[name];
|
||||||
|
if (!cookies || cookies.length === 0) return null;
|
||||||
|
return cookies[0].value;
|
||||||
|
}
|
||||||
|
function withCookies(headers, cookieJar) {
|
||||||
|
if (!cookieJar || Object.keys(cookieJar).length === 0) return headers;
|
||||||
|
const cookieStr = Object.entries(cookieJar)
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join("; ");
|
||||||
|
return { ...headers, Cookie: cookieStr };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maintain cart + owner cookies per VU
|
||||||
|
let cartState = {
|
||||||
|
cartid: null,
|
||||||
|
cartowner: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Refresh cookies from response
|
||||||
|
function updateCookies(res) {
|
||||||
|
const cid = extractCookie(res, "cartid");
|
||||||
|
if (cid) cartState.cartid = cid;
|
||||||
|
const owner = extractCookie(res, "cartowner");
|
||||||
|
if (owner) cartState.cartowner = owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build headers
|
||||||
|
function headers() {
|
||||||
|
const h = { "Content-Type": "application/json" };
|
||||||
|
const jar = {};
|
||||||
|
if (cartState.cartid) jar["cartid"] = cartState.cartid;
|
||||||
|
if (cartState.cartowner) jar["cartowner"] = cartState.cartowner;
|
||||||
|
return withCookies(h, jar);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure cart exists (GET /)
|
||||||
|
function ensureCart() {
|
||||||
|
if (cartState.cartid) return;
|
||||||
|
const res = http.get(baseUrl() + "/", { headers: headers() });
|
||||||
|
updateCookies(res);
|
||||||
|
check(res, {
|
||||||
|
"ensure cart status 200": (r) => r.status === 200,
|
||||||
|
"ensure cart has id": () => !!cartState.cartid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add random item
|
||||||
|
function addRandomItem() {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
sku: randomSku(),
|
||||||
|
quantity: randomQty(),
|
||||||
|
country: "no",
|
||||||
|
});
|
||||||
|
const start = Date.now();
|
||||||
|
const res = http.post(baseUrl(), payload, { headers: headers() });
|
||||||
|
const dur = Date.now() - start;
|
||||||
|
addItemTrend.add(dur, { op: "add" });
|
||||||
|
if (res.status === 200) {
|
||||||
|
addedItemsCounter.add(1);
|
||||||
|
}
|
||||||
|
updateCookies(res);
|
||||||
|
check(res, {
|
||||||
|
"add item status ok": (r) => r.status === 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch cart state
|
||||||
|
function fetchCart() {
|
||||||
|
const start = Date.now();
|
||||||
|
const res = http.get(baseUrl() + "/", { headers: headers() });
|
||||||
|
const dur = Date.now() - start;
|
||||||
|
fetchTrend.add(dur, { op: "get" });
|
||||||
|
updateCookies(res);
|
||||||
|
check(res, { "fetch status ok": (r) => r.status === 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Occasional checkout trigger
|
||||||
|
function maybeCheckout() {
|
||||||
|
if (!cartState.cartid) return;
|
||||||
|
// // Small probability
|
||||||
|
// if (Math.random() < 0.02) {
|
||||||
|
// const start = Date.now();
|
||||||
|
// const res = http.get(baseUrl() + "/checkout", { headers: headers() });
|
||||||
|
// const dur = Date.now() - start;
|
||||||
|
// checkoutTrend.add(dur, { op: "checkout" });
|
||||||
|
// updateCookies(res);
|
||||||
|
// if (res.status === 200) checkoutCounter.add(1);
|
||||||
|
// check(res, { "checkout status ok": (r) => r.status === 200 });
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- k6 lifecycle ----------------
|
||||||
|
export function setup() {
|
||||||
|
// Provide SKU list length for summary
|
||||||
|
return { skuCount: SKUS.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function (data) {
|
||||||
|
group("cart flow", () => {
|
||||||
|
// Create or reuse cart
|
||||||
|
ensureCart();
|
||||||
|
|
||||||
|
// Random number of item mutations per iteration (1..5)
|
||||||
|
const ops = 1 + Math.floor(Math.random() * 5);
|
||||||
|
for (let i = 0; i < ops; i++) {
|
||||||
|
addRandomItem();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch state
|
||||||
|
fetchCart();
|
||||||
|
|
||||||
|
// Optional checkout attempt
|
||||||
|
maybeCheckout();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Small think time
|
||||||
|
sleep(Math.random() * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function teardown(data) {
|
||||||
|
// Optionally we could GET confirmation or clear cart cookie
|
||||||
|
// Not implemented for load purpose.
|
||||||
|
console.log(`Test complete. SKU count: ${data.skuCount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Summary ----------------
|
||||||
|
export function handleSummary(data) {
|
||||||
|
return {
|
||||||
|
stdout: JSON.stringify(
|
||||||
|
{
|
||||||
|
metrics: {
|
||||||
|
mutations_avg: data.metrics.cart_add_item_duration?.avg,
|
||||||
|
mutations_p95: data.metrics.cart_add_item_duration?.p(95),
|
||||||
|
fetch_p95: data.metrics.cart_fetch_duration?.p(95),
|
||||||
|
checkout_count: data.metrics.cart_checkout_calls?.count,
|
||||||
|
},
|
||||||
|
checks: data.root_checks,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
137
pkg/actor/disk_storage.go
Normal file
137
pkg/actor/disk_storage.go
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
package actor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gogo/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type QueueEvent struct {
|
||||||
|
TimeStamp time.Time
|
||||||
|
Message proto.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiskStorage[V any] struct {
|
||||||
|
*StateStorage
|
||||||
|
path string
|
||||||
|
done chan struct{}
|
||||||
|
queue *sync.Map // map[uint64][]QueueEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogStorage[V any] interface {
|
||||||
|
LoadEvents(id uint64, grain Grain[V]) error
|
||||||
|
AppendEvent(id uint64, msg ...proto.Message) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDiskStorage[V any](path string, registry MutationRegistry) *DiskStorage[V] {
|
||||||
|
return &DiskStorage[V]{
|
||||||
|
StateStorage: NewState(registry),
|
||||||
|
path: path,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage[V]) SaveLoop(duration time.Duration) {
|
||||||
|
s.queue = &sync.Map{}
|
||||||
|
ticker := time.NewTicker(duration)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.done:
|
||||||
|
s.save()
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage[V]) save() {
|
||||||
|
carts := 0
|
||||||
|
lines := 0
|
||||||
|
s.queue.Range(func(key, value any) bool {
|
||||||
|
id := key.(uint64)
|
||||||
|
path := s.logPath(id)
|
||||||
|
fh, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("failed to open event log file: %v", err)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
|
||||||
|
if qe, ok := value.([]QueueEvent); ok {
|
||||||
|
for _, msg := range qe {
|
||||||
|
if err := s.Append(fh, msg.Message, msg.TimeStamp); err != nil {
|
||||||
|
log.Printf("failed to append event to log file: %v", err)
|
||||||
|
}
|
||||||
|
lines++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
carts++
|
||||||
|
s.queue.Delete(id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if lines > 0 {
|
||||||
|
log.Printf("Appended %d carts and %d lines to disk", carts, lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage[V]) logPath(id uint64) string {
|
||||||
|
return filepath.Join(s.path, fmt.Sprintf("%d.events.log", id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage[V]) LoadEvents(id uint64, grain Grain[V]) error {
|
||||||
|
path := s.logPath(id)
|
||||||
|
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||||
|
// No log -> nothing to replay
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fh, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open replay file: %w", err)
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
return s.Load(fh, func(msg proto.Message) {
|
||||||
|
s.registry.Apply(grain, msg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage[V]) Close() {
|
||||||
|
s.save()
|
||||||
|
close(s.done)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage[V]) AppendEvent(id uint64, msg ...proto.Message) error {
|
||||||
|
if s.queue != nil {
|
||||||
|
queue := make([]QueueEvent, 0)
|
||||||
|
data, found := s.queue.Load(id)
|
||||||
|
if found {
|
||||||
|
queue = data.([]QueueEvent)
|
||||||
|
}
|
||||||
|
for _, m := range msg {
|
||||||
|
queue = append(queue, QueueEvent{Message: m, TimeStamp: time.Now()})
|
||||||
|
}
|
||||||
|
s.queue.Store(id, queue)
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
path := s.logPath(id)
|
||||||
|
fh, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("failed to open event log file: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
for _, m := range msg {
|
||||||
|
err = s.Append(fh, m, time.Now())
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
13
pkg/actor/grain.go
Normal file
13
pkg/actor/grain.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package actor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Grain[V any] interface {
|
||||||
|
GetId() uint64
|
||||||
|
|
||||||
|
GetLastAccess() time.Time
|
||||||
|
GetLastChange() time.Time
|
||||||
|
GetCurrentState() (*V, error)
|
||||||
|
}
|
||||||
41
pkg/actor/grain_pool.go
Normal file
41
pkg/actor/grain_pool.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package actor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gogo/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MutationResult[V any] struct {
|
||||||
|
Result V `json:"result"`
|
||||||
|
Mutations []ApplyResult `json:"mutations,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GrainPool[V any] interface {
|
||||||
|
Apply(id uint64, mutation ...proto.Message) (*MutationResult[V], error)
|
||||||
|
Get(id uint64) (V, error)
|
||||||
|
OwnerHost(id uint64) (Host, bool)
|
||||||
|
Hostname() string
|
||||||
|
TakeOwnership(id uint64)
|
||||||
|
HandleOwnershipChange(host string, ids []uint64) error
|
||||||
|
HandleRemoteExpiry(host string, ids []uint64) error
|
||||||
|
Negotiate(otherHosts []string)
|
||||||
|
GetLocalIds() []uint64
|
||||||
|
RemoveHost(host string)
|
||||||
|
IsHealthy() bool
|
||||||
|
IsKnown(string) bool
|
||||||
|
Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host abstracts a remote node capable of proxying cart requests.
|
||||||
|
type Host interface {
|
||||||
|
AnnounceExpiry(ids []uint64)
|
||||||
|
Negotiate(otherHosts []string) ([]string, error)
|
||||||
|
Name() string
|
||||||
|
Proxy(id uint64, w http.ResponseWriter, r *http.Request) (bool, error)
|
||||||
|
GetActorIds() []uint64
|
||||||
|
Close() error
|
||||||
|
Ping() bool
|
||||||
|
IsHealthy() bool
|
||||||
|
AnnounceOwnership(ownerHost string, ids []uint64)
|
||||||
|
}
|
||||||
119
pkg/actor/grpc_server.go
Normal file
119
pkg/actor/grpc_server.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package actor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/reflection"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ControlServer implements the ControlPlane gRPC services.
|
||||||
|
// It delegates to a grain pool and cluster operations to a synced pool.
|
||||||
|
type ControlServer[V any] struct {
|
||||||
|
messages.UnimplementedControlPlaneServer
|
||||||
|
|
||||||
|
pool GrainPool[V]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ControlServer[V]) AnnounceOwnership(ctx context.Context, req *messages.OwnershipAnnounce) (*messages.OwnerChangeAck, error) {
|
||||||
|
err := s.pool.HandleOwnershipChange(req.Host, req.Ids)
|
||||||
|
if err != nil {
|
||||||
|
return &messages.OwnerChangeAck{
|
||||||
|
Accepted: false,
|
||||||
|
Message: "owner change failed",
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Ack count: %d", len(req.Ids))
|
||||||
|
return &messages.OwnerChangeAck{
|
||||||
|
Accepted: true,
|
||||||
|
Message: "ownership announced",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ControlServer[V]) AnnounceExpiry(ctx context.Context, req *messages.ExpiryAnnounce) (*messages.OwnerChangeAck, error) {
|
||||||
|
err := s.pool.HandleRemoteExpiry(req.Host, req.Ids)
|
||||||
|
return &messages.OwnerChangeAck{
|
||||||
|
Accepted: err == nil,
|
||||||
|
Message: "expiry acknowledged",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ControlPlane: Ping
|
||||||
|
func (s *ControlServer[V]) Ping(ctx context.Context, _ *messages.Empty) (*messages.PingReply, error) {
|
||||||
|
// log.Printf("got ping")
|
||||||
|
return &messages.PingReply{
|
||||||
|
Host: s.pool.Hostname(),
|
||||||
|
UnixTime: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ControlPlane: Negotiate (merge host views)
|
||||||
|
func (s *ControlServer[V]) Negotiate(ctx context.Context, req *messages.NegotiateRequest) (*messages.NegotiateReply, error) {
|
||||||
|
|
||||||
|
s.pool.Negotiate(req.KnownHosts)
|
||||||
|
return &messages.NegotiateReply{Hosts: req.GetKnownHosts()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ControlPlane: GetCartIds (locally owned carts only)
|
||||||
|
func (s *ControlServer[V]) GetLocalActorIds(ctx context.Context, _ *messages.Empty) (*messages.ActorIdsReply, error) {
|
||||||
|
return &messages.ActorIdsReply{Ids: s.pool.GetLocalIds()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ControlPlane: Closing (peer shutdown notification)
|
||||||
|
func (s *ControlServer[V]) Closing(ctx context.Context, req *messages.ClosingNotice) (*messages.OwnerChangeAck, error) {
|
||||||
|
if req.GetHost() != "" {
|
||||||
|
s.pool.RemoveHost(req.GetHost())
|
||||||
|
}
|
||||||
|
return &messages.OwnerChangeAck{
|
||||||
|
Accepted: true,
|
||||||
|
Message: "removed host",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerConfig struct {
|
||||||
|
Addr string
|
||||||
|
Options []grpc.ServerOption
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServerConfig(addr string, options ...grpc.ServerOption) ServerConfig {
|
||||||
|
return ServerConfig{
|
||||||
|
Addr: addr,
|
||||||
|
Options: options,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultServerConfig() ServerConfig {
|
||||||
|
return NewServerConfig(":1337")
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartGRPCServer configures and starts the unified gRPC server on the given address.
|
||||||
|
// It registers both the CartActor and ControlPlane services.
|
||||||
|
func NewControlServer[V any](config ServerConfig, pool GrainPool[V]) (*grpc.Server, error) {
|
||||||
|
lis, err := net.Listen("tcp", config.Addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to listen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
grpcServer := grpc.NewServer(config.Options...)
|
||||||
|
server := &ControlServer[V]{
|
||||||
|
pool: pool,
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.RegisterControlPlaneServer(grpcServer, server)
|
||||||
|
reflection.Register(grpcServer)
|
||||||
|
|
||||||
|
log.Printf("gRPC server listening as %s on %s", pool.Hostname(), config.Addr)
|
||||||
|
go func() {
|
||||||
|
if err := grpcServer.Serve(lis); err != nil {
|
||||||
|
log.Fatalf("failed to serve gRPC: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return grpcServer, nil
|
||||||
|
}
|
||||||
226
pkg/actor/mutation_registry.go
Normal file
226
pkg/actor/mutation_registry.go
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
package actor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"reflect"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/gogo/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ApplyResult struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Mutation proto.Message `json:"mutation"`
|
||||||
|
Error error `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MutationRegistry interface {
|
||||||
|
Apply(grain any, msg ...proto.Message) ([]ApplyResult, error)
|
||||||
|
RegisterMutations(handlers ...MutationHandler)
|
||||||
|
Create(typeName string) (proto.Message, bool)
|
||||||
|
GetTypeName(msg proto.Message) (string, bool)
|
||||||
|
//GetStorageEvent(msg proto.Message) StorageEvent
|
||||||
|
//FromStorageEvent(event StorageEvent) (proto.Message, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProtoMutationRegistry struct {
|
||||||
|
mutationRegistryMu sync.RWMutex
|
||||||
|
mutationRegistry map[reflect.Type]MutationHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrMutationNotRegistered = &MutationError{
|
||||||
|
Message: "mutation not registered",
|
||||||
|
Code: 255,
|
||||||
|
StatusCode: 500,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
type MutationError struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code uint32 `json:"code"`
|
||||||
|
StatusCode uint32 `json:"status_code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m MutationError) Error() string {
|
||||||
|
return m.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type MutationHandler interface {
|
||||||
|
Handle(state any, msg proto.Message) error
|
||||||
|
Name() string
|
||||||
|
Type() reflect.Type
|
||||||
|
Create() proto.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisteredMutation stores metadata + the execution closure.
|
||||||
|
type RegisteredMutation[V any, T proto.Message] struct {
|
||||||
|
name string
|
||||||
|
handler func(*V, T) error
|
||||||
|
create func() T
|
||||||
|
msgType reflect.Type
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMutation[V any, T proto.Message](handler func(*V, T) error, create func() T) *RegisteredMutation[V, T] {
|
||||||
|
// Derive the name and message type from a concrete instance produced by create().
|
||||||
|
// This avoids relying on reflect.TypeFor (which can yield unexpected results in some toolchains)
|
||||||
|
// and ensures we always peel off the pointer layer for proto messages.
|
||||||
|
instance := create()
|
||||||
|
rt := reflect.TypeOf(instance)
|
||||||
|
if rt.Kind() == reflect.Ptr {
|
||||||
|
rt = rt.Elem()
|
||||||
|
}
|
||||||
|
return &RegisteredMutation[V, T]{
|
||||||
|
name: rt.Name(),
|
||||||
|
handler: handler,
|
||||||
|
create: create,
|
||||||
|
msgType: rt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *RegisteredMutation[V, T]) Handle(state any, msg proto.Message) error {
|
||||||
|
return m.handler(state.(*V), msg.(T))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *RegisteredMutation[V, T]) Name() string {
|
||||||
|
return m.name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *RegisteredMutation[V, T]) Create() proto.Message {
|
||||||
|
return m.create()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *RegisteredMutation[V, T]) Type() reflect.Type {
|
||||||
|
return m.msgType
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMutationRegistry() MutationRegistry {
|
||||||
|
return &ProtoMutationRegistry{
|
||||||
|
mutationRegistry: make(map[reflect.Type]MutationHandler),
|
||||||
|
mutationRegistryMu: sync.RWMutex{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProtoMutationRegistry) RegisterMutations(handlers ...MutationHandler) {
|
||||||
|
r.mutationRegistryMu.Lock()
|
||||||
|
defer r.mutationRegistryMu.Unlock()
|
||||||
|
|
||||||
|
for _, handler := range handlers {
|
||||||
|
r.mutationRegistry[handler.Type()] = handler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProtoMutationRegistry) GetTypeName(msg proto.Message) (string, bool) {
|
||||||
|
r.mutationRegistryMu.RLock()
|
||||||
|
defer r.mutationRegistryMu.RUnlock()
|
||||||
|
|
||||||
|
rt := indirectType(reflect.TypeOf(msg))
|
||||||
|
if handler, ok := r.mutationRegistry[rt]; ok {
|
||||||
|
return handler.Name(), true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProtoMutationRegistry) getHandler(typeName string) MutationHandler {
|
||||||
|
r.mutationRegistryMu.Lock()
|
||||||
|
defer r.mutationRegistryMu.Unlock()
|
||||||
|
|
||||||
|
for _, handler := range r.mutationRegistry {
|
||||||
|
if handler.Name() == typeName {
|
||||||
|
return handler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProtoMutationRegistry) Create(typeName string) (proto.Message, bool) {
|
||||||
|
|
||||||
|
handler := r.getHandler(typeName)
|
||||||
|
if handler == nil {
|
||||||
|
log.Printf("missing handler for %s", typeName)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler.Create(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyRegistered attempts to apply a registered mutation.
|
||||||
|
// Returns updated grain if successful.
|
||||||
|
//
|
||||||
|
// If the mutation is not registered, returns (nil, ErrMutationNotRegistered).
|
||||||
|
func (r *ProtoMutationRegistry) Apply(grain any, msg ...proto.Message) ([]ApplyResult, error) {
|
||||||
|
results := make([]ApplyResult, 0, len(msg))
|
||||||
|
|
||||||
|
if grain == nil {
|
||||||
|
return results, fmt.Errorf("nil grain")
|
||||||
|
}
|
||||||
|
if msg == nil {
|
||||||
|
return results, fmt.Errorf("nil mutation message")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range msg {
|
||||||
|
rt := indirectType(reflect.TypeOf(m))
|
||||||
|
r.mutationRegistryMu.RLock()
|
||||||
|
entry, ok := r.mutationRegistry[rt]
|
||||||
|
r.mutationRegistryMu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
results = append(results, ApplyResult{Error: ErrMutationNotRegistered, Type: rt.Name(), Mutation: m})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
err := entry.Handle(grain, m)
|
||||||
|
results = append(results, ApplyResult{Error: err, Type: rt.Name(), Mutation: m})
|
||||||
|
}
|
||||||
|
|
||||||
|
// if entry.updateTotals {
|
||||||
|
// grain.UpdateTotals()
|
||||||
|
// }
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisteredMutations returns metadata for all registered mutations (snapshot).
|
||||||
|
func (r *ProtoMutationRegistry) RegisteredMutations() []string {
|
||||||
|
r.mutationRegistryMu.RLock()
|
||||||
|
defer r.mutationRegistryMu.RUnlock()
|
||||||
|
out := make([]string, 0, len(r.mutationRegistry))
|
||||||
|
for _, entry := range r.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 (r *ProtoMutationRegistry) RegisteredMutationTypes() []reflect.Type {
|
||||||
|
r.mutationRegistryMu.RLock()
|
||||||
|
defer r.mutationRegistryMu.RUnlock()
|
||||||
|
out := make([]reflect.Type, 0, len(r.mutationRegistry))
|
||||||
|
for _, entry := range r.mutationRegistry {
|
||||||
|
out = append(out, entry.Type())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func indirectType(t reflect.Type) reflect.Type {
|
||||||
|
for t.Kind() == reflect.Ptr {
|
||||||
|
t = t.Elem()
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
134
pkg/actor/mutation_registry_test.go
Normal file
134
pkg/actor/mutation_registry_test.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package actor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
type cartState struct {
|
||||||
|
calls int
|
||||||
|
lastAdded *messages.AddItem
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisteredMutationBasics(t *testing.T) {
|
||||||
|
reg := NewMutationRegistry().(*ProtoMutationRegistry)
|
||||||
|
|
||||||
|
addItemMutation := NewMutation(
|
||||||
|
func(state *cartState, msg *messages.AddItem) error {
|
||||||
|
state.calls++
|
||||||
|
// copy to avoid external mutation side-effects (not strictly necessary for the test)
|
||||||
|
cp := *msg
|
||||||
|
state.lastAdded = &cp
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
func() *messages.AddItem { return &messages.AddItem{} },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sanity check on mutation metadata
|
||||||
|
if addItemMutation.Name() != "AddItem" {
|
||||||
|
t.Fatalf("expected mutation Name() == AddItem, got %s", addItemMutation.Name())
|
||||||
|
}
|
||||||
|
if got, want := addItemMutation.Type(), reflect.TypeOf(messages.AddItem{}); got != want {
|
||||||
|
t.Fatalf("expected Type() == %v, got %v", want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
reg.RegisterMutations(addItemMutation)
|
||||||
|
|
||||||
|
// RegisteredMutations: membership (order not guaranteed)
|
||||||
|
names := reg.RegisteredMutations()
|
||||||
|
if !slices.Contains(names, "AddItem") {
|
||||||
|
t.Fatalf("RegisteredMutations missing AddItem, got %v", names)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisteredMutationTypes: membership (order not guaranteed)
|
||||||
|
types := reg.RegisteredMutationTypes()
|
||||||
|
if !slices.Contains(types, reflect.TypeOf(messages.AddItem{})) {
|
||||||
|
t.Fatalf("RegisteredMutationTypes missing AddItem type, got %v", types)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTypeName should resolve for a pointer instance
|
||||||
|
name, ok := reg.GetTypeName(&messages.AddItem{})
|
||||||
|
if !ok || name != "AddItem" {
|
||||||
|
t.Fatalf("GetTypeName returned (%q,%v), expected (AddItem,true)", name, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTypeName should fail for unregistered type
|
||||||
|
if name, ok := reg.GetTypeName(&messages.Noop{}); ok || name != "" {
|
||||||
|
t.Fatalf("expected GetTypeName to fail for unregistered message, got (%q,%v)", name, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create by name
|
||||||
|
msg, ok := reg.Create("AddItem")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Create failed for registered mutation")
|
||||||
|
}
|
||||||
|
if _, isAddItem := msg.(*messages.AddItem); !isAddItem {
|
||||||
|
t.Fatalf("Create returned wrong concrete type: %T", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create unknown
|
||||||
|
if m2, ok := reg.Create("Unknown"); ok || m2 != nil {
|
||||||
|
t.Fatalf("Create should fail for unknown mutation, got (%T,%v)", m2, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply happy path
|
||||||
|
state := &cartState{}
|
||||||
|
add := &messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
|
||||||
|
if _, err := reg.Apply(state, add); err != nil {
|
||||||
|
t.Fatalf("Apply returned error: %v", err)
|
||||||
|
}
|
||||||
|
if state.calls != 1 {
|
||||||
|
t.Fatalf("handler not invoked expected calls=1 got=%d", state.calls)
|
||||||
|
}
|
||||||
|
if state.lastAdded == nil || state.lastAdded.ItemId != 42 || state.lastAdded.Quantity != 3 {
|
||||||
|
t.Fatalf("state not updated correctly: %+v", state.lastAdded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply nil grain
|
||||||
|
if _, err := reg.Apply(nil, add); err == nil {
|
||||||
|
t.Fatalf("expected error for nil grain")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply nil message
|
||||||
|
if _, err := reg.Apply(state, nil); err == nil {
|
||||||
|
t.Fatalf("expected error for nil mutation message")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply unregistered message
|
||||||
|
if _, err := reg.Apply(state, &messages.Noop{}); !errors.Is(err, ErrMutationNotRegistered) {
|
||||||
|
t.Fatalf("expected ErrMutationNotRegistered, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// func TestConcurrentSafeRegistrationLookup(t *testing.T) {
|
||||||
|
// // This test is light-weight; it ensures locks don't deadlock under simple concurrent access.
|
||||||
|
// reg := NewMutationRegistry().(*ProtoMutationRegistry)
|
||||||
|
// mut := NewMutation[cartState, *messages.Noop](
|
||||||
|
// func(state *cartState, msg *messages.Noop) error { state.calls++; return nil },
|
||||||
|
// func() *messages.Noop { return &messages.Noop{} },
|
||||||
|
// )
|
||||||
|
// reg.RegisterMutations(mut)
|
||||||
|
|
||||||
|
// done := make(chan struct{})
|
||||||
|
// const workers = 25
|
||||||
|
// for i := 0; i < workers; i++ {
|
||||||
|
// go func() {
|
||||||
|
// for j := 0; j < 100; j++ {
|
||||||
|
// _, _ = reg.Create("Noop")
|
||||||
|
// _, _ = reg.GetTypeName(&messages.Noop{})
|
||||||
|
// _ = reg.Apply(&cartState{}, &messages.Noop{})
|
||||||
|
// }
|
||||||
|
// done <- struct{}{}
|
||||||
|
// }()
|
||||||
|
// }
|
||||||
|
|
||||||
|
// for i := 0; i < workers; i++ {
|
||||||
|
// <-done
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Helpers
|
||||||
433
pkg/actor/simple_grain_pool.go
Normal file
433
pkg/actor/simple_grain_pool.go
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
package actor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"maps"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gogo/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SimpleGrainPool[V any] struct {
|
||||||
|
// fields and methods
|
||||||
|
localMu sync.RWMutex
|
||||||
|
grains map[uint64]Grain[V]
|
||||||
|
mutationRegistry MutationRegistry
|
||||||
|
spawn func(id uint64) (Grain[V], error)
|
||||||
|
spawnHost func(host string) (Host, error)
|
||||||
|
storage LogStorage[V]
|
||||||
|
ttl time.Duration
|
||||||
|
poolSize int
|
||||||
|
|
||||||
|
// Cluster coordination --------------------------------------------------
|
||||||
|
hostname string
|
||||||
|
remoteMu sync.RWMutex
|
||||||
|
remoteOwners map[uint64]Host
|
||||||
|
remoteHosts map[string]Host
|
||||||
|
//discardedHostHandler *DiscardedHostHandler
|
||||||
|
|
||||||
|
// House-keeping ---------------------------------------------------------
|
||||||
|
purgeTicker *time.Ticker
|
||||||
|
}
|
||||||
|
|
||||||
|
type GrainPoolConfig[V any] struct {
|
||||||
|
Hostname string
|
||||||
|
Spawn func(id uint64) (Grain[V], error)
|
||||||
|
SpawnHost func(host string) (Host, error)
|
||||||
|
TTL time.Duration
|
||||||
|
PoolSize int
|
||||||
|
MutationRegistry MutationRegistry
|
||||||
|
Storage LogStorage[V]
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V], error) {
|
||||||
|
p := &SimpleGrainPool[V]{
|
||||||
|
grains: make(map[uint64]Grain[V]),
|
||||||
|
mutationRegistry: config.MutationRegistry,
|
||||||
|
storage: config.Storage,
|
||||||
|
spawn: config.Spawn,
|
||||||
|
spawnHost: config.SpawnHost,
|
||||||
|
ttl: config.TTL,
|
||||||
|
poolSize: config.PoolSize,
|
||||||
|
hostname: config.Hostname,
|
||||||
|
remoteOwners: make(map[uint64]Host),
|
||||||
|
remoteHosts: make(map[string]Host),
|
||||||
|
}
|
||||||
|
|
||||||
|
p.purgeTicker = time.NewTicker(time.Minute)
|
||||||
|
go func() {
|
||||||
|
for range p.purgeTicker.C {
|
||||||
|
p.purge()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SimpleGrainPool[V]) purge() {
|
||||||
|
purgeLimit := time.Now().Add(-p.ttl)
|
||||||
|
purgedIds := make([]uint64, 0, len(p.grains))
|
||||||
|
p.localMu.Lock()
|
||||||
|
for id, grain := range p.grains {
|
||||||
|
if grain.GetLastAccess().Before(purgeLimit) {
|
||||||
|
purgedIds = append(purgedIds, id)
|
||||||
|
|
||||||
|
delete(p.grains, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.localMu.Unlock()
|
||||||
|
p.forAllHosts(func(remote Host) {
|
||||||
|
remote.AnnounceExpiry(purgedIds)
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalUsage returns the number of resident grains and configured capacity.
|
||||||
|
func (p *SimpleGrainPool[V]) LocalUsage() (int, int) {
|
||||||
|
p.localMu.RLock()
|
||||||
|
defer p.localMu.RUnlock()
|
||||||
|
return len(p.grains), p.poolSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalCartIDs returns the currently owned cart ids (for control-plane RPCs).
|
||||||
|
func (p *SimpleGrainPool[V]) GetLocalIds() []uint64 {
|
||||||
|
p.localMu.RLock()
|
||||||
|
defer p.localMu.RUnlock()
|
||||||
|
ids := make([]uint64, 0, len(p.grains))
|
||||||
|
for _, g := range p.grains {
|
||||||
|
if g == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ids = append(ids, uint64(g.GetId()))
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SimpleGrainPool[V]) HandleRemoteExpiry(host string, ids []uint64) error {
|
||||||
|
p.remoteMu.Lock()
|
||||||
|
defer p.remoteMu.Unlock()
|
||||||
|
for _, id := range ids {
|
||||||
|
delete(p.remoteOwners, id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SimpleGrainPool[V]) HandleOwnershipChange(host string, ids []uint64) error {
|
||||||
|
log.Printf("host %s now owns %d cart ids", host, len(ids))
|
||||||
|
p.remoteMu.RLock()
|
||||||
|
remoteHost, exists := p.remoteHosts[host]
|
||||||
|
p.remoteMu.RUnlock()
|
||||||
|
if !exists {
|
||||||
|
createdHost, err := p.AddRemote(host)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
remoteHost = createdHost
|
||||||
|
}
|
||||||
|
p.remoteMu.Lock()
|
||||||
|
defer p.remoteMu.Unlock()
|
||||||
|
p.localMu.Lock()
|
||||||
|
defer p.localMu.Unlock()
|
||||||
|
for _, id := range ids {
|
||||||
|
log.Printf("Handling ownership change for cart %d to host %s", id, host)
|
||||||
|
delete(p.grains, id)
|
||||||
|
p.remoteOwners[id] = remoteHost
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TakeOwnership takes ownership of a grain.
|
||||||
|
func (p *SimpleGrainPool[V]) TakeOwnership(id uint64) {
|
||||||
|
p.broadcastOwnership([]uint64{id})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SimpleGrainPool[V]) AddRemote(host string) (Host, error) {
|
||||||
|
if host == "" {
|
||||||
|
return nil, fmt.Errorf("host is empty")
|
||||||
|
}
|
||||||
|
if host == p.hostname {
|
||||||
|
return nil, fmt.Errorf("same host, this should not happen")
|
||||||
|
}
|
||||||
|
p.remoteMu.RLock()
|
||||||
|
existing, found := p.remoteHosts[host]
|
||||||
|
p.remoteMu.RUnlock()
|
||||||
|
if found {
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
remote, err := p.spawnHost(host)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("AddRemote %s failed: %v", host, err)
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.remoteMu.Lock()
|
||||||
|
p.remoteHosts[host] = remote
|
||||||
|
p.remoteMu.Unlock()
|
||||||
|
// connectedRemotes.Set(float64(p.RemoteCount()))
|
||||||
|
|
||||||
|
log.Printf("Connected to remote host %s", host)
|
||||||
|
go p.pingLoop(remote)
|
||||||
|
go p.initializeRemote(remote)
|
||||||
|
go p.SendNegotiation()
|
||||||
|
return remote, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SimpleGrainPool[V]) initializeRemote(remote Host) {
|
||||||
|
|
||||||
|
remotesIds := remote.GetActorIds()
|
||||||
|
|
||||||
|
p.remoteMu.Lock()
|
||||||
|
for _, id := range remotesIds {
|
||||||
|
p.localMu.Lock()
|
||||||
|
delete(p.grains, id)
|
||||||
|
p.localMu.Unlock()
|
||||||
|
if _, exists := p.remoteOwners[id]; !exists {
|
||||||
|
p.remoteOwners[id] = remote
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.remoteMu.Unlock()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SimpleGrainPool[V]) RemoveHost(host string) {
|
||||||
|
p.remoteMu.Lock()
|
||||||
|
remote, exists := p.remoteHosts[host]
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
go remote.Close()
|
||||||
|
delete(p.remoteHosts, host)
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for id, owner := range p.remoteOwners {
|
||||||
|
if owner.Name() == host {
|
||||||
|
count++
|
||||||
|
delete(p.remoteOwners, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("Removing host %s, grains: %d", host, count)
|
||||||
|
p.remoteMu.Unlock()
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
remote.Close()
|
||||||
|
}
|
||||||
|
// connectedRemotes.Set(float64(p.RemoteCount()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SimpleGrainPool[V]) RemoteCount() int {
|
||||||
|
p.remoteMu.RLock()
|
||||||
|
defer p.remoteMu.RUnlock()
|
||||||
|
return len(p.remoteHosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoteHostNames returns a snapshot of connected remote host identifiers.
|
||||||
|
func (p *SimpleGrainPool[V]) RemoteHostNames() []string {
|
||||||
|
p.remoteMu.RLock()
|
||||||
|
defer p.remoteMu.RUnlock()
|
||||||
|
hosts := make([]string, 0, len(p.remoteHosts))
|
||||||
|
for host := range p.remoteHosts {
|
||||||
|
hosts = append(hosts, host)
|
||||||
|
}
|
||||||
|
return hosts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SimpleGrainPool[V]) IsKnown(host string) bool {
|
||||||
|
if host == p.hostname {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
p.remoteMu.RLock()
|
||||||
|
defer p.remoteMu.RUnlock()
|
||||||
|
_, ok := p.remoteHosts[host]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SimpleGrainPool[V]) pingLoop(remote Host) {
|
||||||
|
remote.Ping()
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
if !remote.Ping() {
|
||||||
|
if !remote.IsHealthy() {
|
||||||
|
log.Printf("Remote %s unhealthy, removing", remote.Name())
|
||||||
|
p.Close()
|
||||||
|
p.RemoveHost(remote.Name())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SimpleGrainPool[V]) IsHealthy() bool {
|
||||||
|
p.remoteMu.RLock()
|
||||||
|
defer p.remoteMu.RUnlock()
|
||||||
|
for _, r := range p.remoteHosts {
|
||||||
|
if !r.IsHealthy() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SimpleGrainPool[V]) Negotiate(otherHosts []string) {
|
||||||
|
|
||||||
|
for _, host := range otherHosts {
|
||||||
|
if host != p.hostname {
|
||||||
|
p.remoteMu.RLock()
|
||||||
|
_, ok := p.remoteHosts[host]
|
||||||
|
p.remoteMu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
go p.AddRemote(host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SimpleGrainPool[V]) SendNegotiation() {
|
||||||
|
//negotiationCount.Inc()
|
||||||
|
|
||||||
|
p.remoteMu.RLock()
|
||||||
|
hosts := make([]string, 0, len(p.remoteHosts)+1)
|
||||||
|
hosts = append(hosts, p.hostname)
|
||||||
|
remotes := make([]Host, 0, len(p.remoteHosts))
|
||||||
|
for h, r := range p.remoteHosts {
|
||||||
|
hosts = append(hosts, h)
|
||||||
|
remotes = append(remotes, r)
|
||||||
|
}
|
||||||
|
p.remoteMu.RUnlock()
|
||||||
|
|
||||||
|
p.forAllHosts(func(remote Host) {
|
||||||
|
knownByRemote, err := remote.Negotiate(hosts)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Negotiate with %s failed: %v", remote.Name(), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, h := range knownByRemote {
|
||||||
|
if !p.IsKnown(h) {
|
||||||
|
go p.AddRemote(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SimpleGrainPool[V]) forAllHosts(fn func(Host)) {
|
||||||
|
p.remoteMu.RLock()
|
||||||
|
rh := maps.Clone(p.remoteHosts)
|
||||||
|
p.remoteMu.RUnlock()
|
||||||
|
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
for _, host := range rh {
|
||||||
|
|
||||||
|
wg.Go(func() { fn(host) })
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, host := range rh {
|
||||||
|
if !host.IsHealthy() {
|
||||||
|
host.Close()
|
||||||
|
p.remoteMu.Lock()
|
||||||
|
delete(p.remoteHosts, name)
|
||||||
|
p.remoteMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SimpleGrainPool[V]) broadcastOwnership(ids []uint64) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.forAllHosts(func(rh Host) {
|
||||||
|
rh.AnnounceOwnership(p.hostname, ids)
|
||||||
|
})
|
||||||
|
log.Printf("%s taking ownership of %d ids", p.hostname, len(ids))
|
||||||
|
// go p.statsUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SimpleGrainPool[V]) getOrClaimGrain(id uint64) (Grain[V], error) {
|
||||||
|
p.localMu.RLock()
|
||||||
|
grain, exists := p.grains[id]
|
||||||
|
p.localMu.RUnlock()
|
||||||
|
if exists && grain != nil {
|
||||||
|
return grain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
grain, err := p.spawn(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.localMu.Lock()
|
||||||
|
p.grains[id] = grain
|
||||||
|
p.localMu.Unlock()
|
||||||
|
go p.broadcastOwnership([]uint64{id})
|
||||||
|
return grain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// // ErrNotOwner is returned when a cart belongs to another host.
|
||||||
|
// var ErrNotOwner = fmt.Errorf("not owner")
|
||||||
|
|
||||||
|
// Apply applies a mutation to a grain.
|
||||||
|
func (p *SimpleGrainPool[V]) Apply(id uint64, mutation ...proto.Message) (*MutationResult[*V], error) {
|
||||||
|
grain, err := p.getOrClaimGrain(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mutations, err := p.mutationRegistry.Apply(grain, mutation...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if p.storage != nil {
|
||||||
|
go func() {
|
||||||
|
if err := p.storage.AppendEvent(id, mutation...); err != nil {
|
||||||
|
log.Printf("failed to store mutation for grain %d: %v", id, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
result, err := grain.GetCurrentState()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &MutationResult[*V]{
|
||||||
|
Result: result,
|
||||||
|
Mutations: mutations,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the current state of a grain.
|
||||||
|
func (p *SimpleGrainPool[V]) Get(id uint64) (*V, error) {
|
||||||
|
grain, err := p.getOrClaimGrain(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return grain.GetCurrentState()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OwnerHost reports the remote owner (if any) for the supplied cart id.
|
||||||
|
func (p *SimpleGrainPool[V]) OwnerHost(id uint64) (Host, bool) {
|
||||||
|
p.remoteMu.RLock()
|
||||||
|
defer p.remoteMu.RUnlock()
|
||||||
|
owner, ok := p.remoteOwners[id]
|
||||||
|
return owner, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hostname returns the local hostname (pod IP).
|
||||||
|
func (p *SimpleGrainPool[V]) Hostname() string {
|
||||||
|
return p.hostname
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close notifies remotes that this host is shutting down.
|
||||||
|
func (p *SimpleGrainPool[V]) Close() {
|
||||||
|
|
||||||
|
p.forAllHosts(func(rh Host) {
|
||||||
|
rh.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
if p.purgeTicker != nil {
|
||||||
|
p.purgeTicker.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
98
pkg/actor/state.go
Normal file
98
pkg/actor/state.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package actor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gogo/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StateStorage struct {
|
||||||
|
registry MutationRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorageEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
TimeStamp time.Time `json:"timestamp"`
|
||||||
|
Mutation proto.Message `json:"mutation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
TimeStamp time.Time `json:"timestamp"`
|
||||||
|
Mutation json.RawMessage `json:"mutation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewState(registry MutationRegistry) *StateStorage {
|
||||||
|
return &StateStorage{
|
||||||
|
registry: registry,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrUnknownType = errors.New("unknown type")
|
||||||
|
|
||||||
|
func (s *StateStorage) Load(r io.Reader, onMessage func(msg proto.Message)) error {
|
||||||
|
var err error
|
||||||
|
var evt *StorageEvent
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
for err == nil {
|
||||||
|
evt, err = s.Read(scanner)
|
||||||
|
if err == nil {
|
||||||
|
onMessage(evt.Mutation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StateStorage) Append(io io.Writer, mutation proto.Message, timeStamp time.Time) error {
|
||||||
|
typeName, ok := s.registry.GetTypeName(mutation)
|
||||||
|
if !ok {
|
||||||
|
return ErrUnknownType
|
||||||
|
}
|
||||||
|
event := &StorageEvent{
|
||||||
|
Type: typeName,
|
||||||
|
TimeStamp: timeStamp,
|
||||||
|
Mutation: mutation,
|
||||||
|
}
|
||||||
|
jsonBytes, err := json.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Write(jsonBytes); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
io.Write([]byte("\n"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StateStorage) Read(r *bufio.Scanner) (*StorageEvent, error) {
|
||||||
|
var event rawEvent
|
||||||
|
|
||||||
|
if r.Scan() {
|
||||||
|
b := r.Bytes()
|
||||||
|
err := json.Unmarshal(b, &event)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
typeName := event.Type
|
||||||
|
mutation, ok := s.registry.Create(typeName)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrUnknownType
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(event.Mutation, mutation); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &StorageEvent{
|
||||||
|
Type: typeName,
|
||||||
|
TimeStamp: event.TimeStamp,
|
||||||
|
Mutation: mutation,
|
||||||
|
}, r.Err()
|
||||||
|
}
|
||||||
|
return nil, io.EOF
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package discovery
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -11,11 +11,6 @@ import (
|
|||||||
toolsWatch "k8s.io/client-go/tools/watch"
|
toolsWatch "k8s.io/client-go/tools/watch"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Discovery interface {
|
|
||||||
Discover() ([]string, error)
|
|
||||||
Watch() (<-chan HostChange, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type K8sDiscovery struct {
|
type K8sDiscovery struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
client *kubernetes.Clientset
|
client *kubernetes.Clientset
|
||||||
@@ -51,7 +46,7 @@ func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
|
|||||||
TimeoutSeconds: &timeout,
|
TimeoutSeconds: &timeout,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
watcher, err := toolsWatch.NewRetryWatcher("1", &cache.ListWatch{WatchFunc: watcherFn})
|
watcher, err := toolsWatch.NewRetryWatcherWithContext(k.ctx, "1", &cache.ListWatch{WatchFunc: watcherFn})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -60,6 +55,7 @@ func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
|
|||||||
for event := range watcher.ResultChan() {
|
for event := range watcher.ResultChan() {
|
||||||
|
|
||||||
pod := event.Object.(*v1.Pod)
|
pod := event.Object.(*v1.Pod)
|
||||||
|
// log.Printf("pod change %+v", pod.Status.Phase == v1.PodRunning)
|
||||||
ch <- HostChange{
|
ch <- HostChange{
|
||||||
Host: pod.Status.PodIP,
|
Host: pod.Status.PodIP,
|
||||||
Type: event.Type,
|
Type: event.Type,
|
||||||
102
pkg/discovery/discovery_mock.go
Normal file
102
pkg/discovery/discovery_mock.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package discovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"k8s.io/apimachinery/pkg/watch"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockDiscovery is an in-memory Discovery implementation for tests.
|
||||||
|
// It allows deterministic injection of host additions/removals without
|
||||||
|
// depending on Kubernetes API machinery.
|
||||||
|
type MockDiscovery struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
hosts []string
|
||||||
|
events chan HostChange
|
||||||
|
closed bool
|
||||||
|
started bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMockDiscovery creates a mock discovery with an initial host list.
|
||||||
|
func NewMockDiscovery(initial []string) *MockDiscovery {
|
||||||
|
cp := make([]string, len(initial))
|
||||||
|
copy(cp, initial)
|
||||||
|
return &MockDiscovery{
|
||||||
|
hosts: cp,
|
||||||
|
events: make(chan HostChange, 32),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover returns the current host snapshot.
|
||||||
|
func (m *MockDiscovery) Discover() ([]string, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
cp := make([]string, len(m.hosts))
|
||||||
|
copy(cp, m.hosts)
|
||||||
|
return cp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch returns a channel that will receive HostChange events.
|
||||||
|
// The channel is buffered; AddHost/RemoveHost push events non-blockingly.
|
||||||
|
func (m *MockDiscovery) Watch() (<-chan HostChange, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.closed {
|
||||||
|
return nil, context.Canceled
|
||||||
|
}
|
||||||
|
m.started = true
|
||||||
|
return m.events, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddHost inserts a new host (if absent) and emits an Added event.
|
||||||
|
func (m *MockDiscovery) AddHost(host string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.closed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, h := range m.hosts {
|
||||||
|
if h == host {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.hosts = append(m.hosts, host)
|
||||||
|
if m.started {
|
||||||
|
m.events <- HostChange{Host: host, Type: watch.Added}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveHost removes a host (if present) and emits a Deleted event.
|
||||||
|
func (m *MockDiscovery) RemoveHost(host string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.closed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idx := -1
|
||||||
|
for i, h := range m.hosts {
|
||||||
|
if h == host {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx == -1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.hosts = append(m.hosts[:idx], m.hosts[idx+1:]...)
|
||||||
|
if m.started {
|
||||||
|
m.events <- HostChange{Host: host, Type: watch.Deleted}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the event channel (idempotent).
|
||||||
|
func (m *MockDiscovery) Close() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.closed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.closed = true
|
||||||
|
close(m.events)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package discovery
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
6
pkg/discovery/types.go
Normal file
6
pkg/discovery/types.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package discovery
|
||||||
|
|
||||||
|
type Discovery interface {
|
||||||
|
Discover() ([]string, error)
|
||||||
|
Watch() (<-chan HostChange, error)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.10
|
// protoc-gen-go v1.36.5
|
||||||
// protoc v3.21.12
|
// protoc v5.29.3
|
||||||
// source: control_plane.proto
|
// source: control_plane.proto
|
||||||
|
|
||||||
package messages
|
package messages
|
||||||
@@ -202,27 +202,27 @@ func (x *NegotiateReply) GetHosts() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CartIdsReply returns the list of cart IDs (string form) currently owned locally.
|
// CartIdsReply returns the list of cart IDs (string form) currently owned locally.
|
||||||
type CartIdsReply struct {
|
type ActorIdsReply struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
CartIds []string `protobuf:"bytes,1,rep,name=cart_ids,json=cartIds,proto3" json:"cart_ids,omitempty"`
|
Ids []uint64 `protobuf:"varint,1,rep,packed,name=ids,proto3" json:"ids,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *CartIdsReply) Reset() {
|
func (x *ActorIdsReply) Reset() {
|
||||||
*x = CartIdsReply{}
|
*x = ActorIdsReply{}
|
||||||
mi := &file_control_plane_proto_msgTypes[4]
|
mi := &file_control_plane_proto_msgTypes[4]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *CartIdsReply) String() string {
|
func (x *ActorIdsReply) String() string {
|
||||||
return protoimpl.X.MessageStringOf(x)
|
return protoimpl.X.MessageStringOf(x)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*CartIdsReply) ProtoMessage() {}
|
func (*ActorIdsReply) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *CartIdsReply) ProtoReflect() protoreflect.Message {
|
func (x *ActorIdsReply) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_control_plane_proto_msgTypes[4]
|
mi := &file_control_plane_proto_msgTypes[4]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
@@ -234,72 +234,19 @@ func (x *CartIdsReply) ProtoReflect() protoreflect.Message {
|
|||||||
return mi.MessageOf(x)
|
return mi.MessageOf(x)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deprecated: Use CartIdsReply.ProtoReflect.Descriptor instead.
|
// Deprecated: Use ActorIdsReply.ProtoReflect.Descriptor instead.
|
||||||
func (*CartIdsReply) Descriptor() ([]byte, []int) {
|
func (*ActorIdsReply) Descriptor() ([]byte, []int) {
|
||||||
return file_control_plane_proto_rawDescGZIP(), []int{4}
|
return file_control_plane_proto_rawDescGZIP(), []int{4}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *CartIdsReply) GetCartIds() []string {
|
func (x *ActorIdsReply) GetIds() []uint64 {
|
||||||
if x != nil {
|
if x != nil {
|
||||||
return x.CartIds
|
return x.Ids
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// OwnerChangeRequest notifies peers that ownership of a cart moved (or is moving) to new_host.
|
// OwnerChangeAck retained as response type for Closing RPC (ConfirmOwner removed).
|
||||||
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.
|
|
||||||
type OwnerChangeAck struct {
|
type OwnerChangeAck struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Accepted bool `protobuf:"varint,1,opt,name=accepted,proto3" json:"accepted,omitempty"`
|
Accepted bool `protobuf:"varint,1,opt,name=accepted,proto3" json:"accepted,omitempty"`
|
||||||
@@ -310,7 +257,7 @@ type OwnerChangeAck struct {
|
|||||||
|
|
||||||
func (x *OwnerChangeAck) Reset() {
|
func (x *OwnerChangeAck) Reset() {
|
||||||
*x = OwnerChangeAck{}
|
*x = OwnerChangeAck{}
|
||||||
mi := &file_control_plane_proto_msgTypes[6]
|
mi := &file_control_plane_proto_msgTypes[5]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -322,7 +269,7 @@ func (x *OwnerChangeAck) String() string {
|
|||||||
func (*OwnerChangeAck) ProtoMessage() {}
|
func (*OwnerChangeAck) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *OwnerChangeAck) ProtoReflect() protoreflect.Message {
|
func (x *OwnerChangeAck) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_control_plane_proto_msgTypes[6]
|
mi := &file_control_plane_proto_msgTypes[5]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -335,7 +282,7 @@ func (x *OwnerChangeAck) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use OwnerChangeAck.ProtoReflect.Descriptor instead.
|
// Deprecated: Use OwnerChangeAck.ProtoReflect.Descriptor instead.
|
||||||
func (*OwnerChangeAck) Descriptor() ([]byte, []int) {
|
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 {
|
func (x *OwnerChangeAck) GetAccepted() bool {
|
||||||
@@ -362,7 +309,7 @@ type ClosingNotice struct {
|
|||||||
|
|
||||||
func (x *ClosingNotice) Reset() {
|
func (x *ClosingNotice) Reset() {
|
||||||
*x = ClosingNotice{}
|
*x = ClosingNotice{}
|
||||||
mi := &file_control_plane_proto_msgTypes[7]
|
mi := &file_control_plane_proto_msgTypes[6]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -374,7 +321,7 @@ func (x *ClosingNotice) String() string {
|
|||||||
func (*ClosingNotice) ProtoMessage() {}
|
func (*ClosingNotice) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *ClosingNotice) ProtoReflect() protoreflect.Message {
|
func (x *ClosingNotice) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_control_plane_proto_msgTypes[7]
|
mi := &file_control_plane_proto_msgTypes[6]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -387,7 +334,7 @@ func (x *ClosingNotice) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use ClosingNotice.ProtoReflect.Descriptor instead.
|
// Deprecated: Use ClosingNotice.ProtoReflect.Descriptor instead.
|
||||||
func (*ClosingNotice) Descriptor() ([]byte, []int) {
|
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 {
|
func (x *ClosingNotice) GetHost() string {
|
||||||
@@ -397,38 +344,174 @@ func (x *ClosingNotice) GetHost() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OwnershipAnnounce broadcasts first-touch ownership claims for cart IDs.
|
||||||
|
// First claim wins; receivers SHOULD NOT overwrite an existing different owner.
|
||||||
|
type OwnershipAnnounce struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` // announcing host
|
||||||
|
Ids []uint64 `protobuf:"varint,2,rep,packed,name=ids,proto3" json:"ids,omitempty"` // newly claimed cart ids
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OwnershipAnnounce) Reset() {
|
||||||
|
*x = OwnershipAnnounce{}
|
||||||
|
mi := &file_control_plane_proto_msgTypes[7]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OwnershipAnnounce) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*OwnershipAnnounce) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *OwnershipAnnounce) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_control_plane_proto_msgTypes[7]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use OwnershipAnnounce.ProtoReflect.Descriptor instead.
|
||||||
|
func (*OwnershipAnnounce) Descriptor() ([]byte, []int) {
|
||||||
|
return file_control_plane_proto_rawDescGZIP(), []int{7}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OwnershipAnnounce) GetHost() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Host
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OwnershipAnnounce) GetIds() []uint64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Ids
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpiryAnnounce broadcasts that a host evicted the provided cart IDs.
|
||||||
|
type ExpiryAnnounce struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
|
||||||
|
Ids []uint64 `protobuf:"varint,2,rep,packed,name=ids,proto3" json:"ids,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ExpiryAnnounce) Reset() {
|
||||||
|
*x = ExpiryAnnounce{}
|
||||||
|
mi := &file_control_plane_proto_msgTypes[8]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ExpiryAnnounce) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ExpiryAnnounce) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *ExpiryAnnounce) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_control_plane_proto_msgTypes[8]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use ExpiryAnnounce.ProtoReflect.Descriptor instead.
|
||||||
|
func (*ExpiryAnnounce) Descriptor() ([]byte, []int) {
|
||||||
|
return file_control_plane_proto_rawDescGZIP(), []int{8}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ExpiryAnnounce) GetHost() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Host
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ExpiryAnnounce) GetIds() []uint64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Ids
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var File_control_plane_proto protoreflect.FileDescriptor
|
var File_control_plane_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
const file_control_plane_proto_rawDesc = "" +
|
var file_control_plane_proto_rawDesc = string([]byte{
|
||||||
"\n" +
|
0x0a, 0x13, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e,
|
||||||
"\x13control_plane.proto\x12\bmessages\"\a\n" +
|
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22,
|
||||||
"\x05Empty\"<\n" +
|
0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x3c, 0x0a, 0x09, 0x50, 0x69, 0x6e, 0x67,
|
||||||
"\tPingReply\x12\x12\n" +
|
0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20,
|
||||||
"\x04host\x18\x01 \x01(\tR\x04host\x12\x1b\n" +
|
0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x75, 0x6e, 0x69,
|
||||||
"\tunix_time\x18\x02 \x01(\x03R\bunixTime\"3\n" +
|
0x78, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x75, 0x6e,
|
||||||
"\x10NegotiateRequest\x12\x1f\n" +
|
0x69, 0x78, 0x54, 0x69, 0x6d, 0x65, 0x22, 0x33, 0x0a, 0x10, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69,
|
||||||
"\vknown_hosts\x18\x01 \x03(\tR\n" +
|
0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x6b, 0x6e,
|
||||||
"knownHosts\"&\n" +
|
0x6f, 0x77, 0x6e, 0x5f, 0x68, 0x6f, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52,
|
||||||
"\x0eNegotiateReply\x12\x14\n" +
|
0x0a, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x48, 0x6f, 0x73, 0x74, 0x73, 0x22, 0x26, 0x0a, 0x0e, 0x4e,
|
||||||
"\x05hosts\x18\x01 \x03(\tR\x05hosts\")\n" +
|
0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x14, 0x0a,
|
||||||
"\fCartIdsReply\x12\x19\n" +
|
0x05, 0x68, 0x6f, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x68, 0x6f,
|
||||||
"\bcart_ids\x18\x01 \x03(\tR\acartIds\"H\n" +
|
0x73, 0x74, 0x73, 0x22, 0x21, 0x0a, 0x0d, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x73, 0x52,
|
||||||
"\x12OwnerChangeRequest\x12\x17\n" +
|
0x65, 0x70, 0x6c, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28,
|
||||||
"\acart_id\x18\x01 \x01(\tR\x06cartId\x12\x19\n" +
|
0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22, 0x46, 0x0a, 0x0e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43,
|
||||||
"\bnew_host\x18\x02 \x01(\tR\anewHost\"F\n" +
|
0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65,
|
||||||
"\x0eOwnerChangeAck\x12\x1a\n" +
|
0x70, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65,
|
||||||
"\baccepted\x18\x01 \x01(\bR\baccepted\x12\x18\n" +
|
0x70, 0x74, 0x65, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18,
|
||||||
"\amessage\x18\x02 \x01(\tR\amessage\"#\n" +
|
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x23,
|
||||||
"\rClosingNotice\x12\x12\n" +
|
0x0a, 0x0d, 0x43, 0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67, 0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x12,
|
||||||
"\x04host\x18\x01 \x01(\tR\x04host2\xbc\x02\n" +
|
0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68,
|
||||||
"\fControlPlane\x12,\n" +
|
0x6f, 0x73, 0x74, 0x22, 0x39, 0x0a, 0x11, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70,
|
||||||
"\x04Ping\x12\x0f.messages.Empty\x1a\x13.messages.PingReply\x12A\n" +
|
0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74,
|
||||||
"\tNegotiate\x12\x1a.messages.NegotiateRequest\x1a\x18.messages.NegotiateReply\x125\n" +
|
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03,
|
||||||
"\n" +
|
0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22, 0x36,
|
||||||
"GetCartIds\x12\x0f.messages.Empty\x1a\x16.messages.CartIdsReply\x12F\n" +
|
0x0a, 0x0e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x79, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65,
|
||||||
"\fConfirmOwner\x12\x1c.messages.OwnerChangeRequest\x1a\x18.messages.OwnerChangeAck\x12<\n" +
|
0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
|
||||||
"\aClosing\x12\x17.messages.ClosingNotice\x1a\x18.messages.OwnerChangeAckB\fZ\n" +
|
0x68, 0x6f, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28,
|
||||||
".;messagesb\x06proto3"
|
0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x32, 0x8d, 0x03, 0x0a, 0x0c, 0x43, 0x6f, 0x6e, 0x74, 0x72,
|
||||||
|
0x6f, 0x6c, 0x50, 0x6c, 0x61, 0x6e, 0x65, 0x12, 0x2c, 0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, 0x12,
|
||||||
|
0x0f, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79,
|
||||||
|
0x1a, 0x13, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x50, 0x69, 0x6e, 0x67,
|
||||||
|
0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x41, 0x0a, 0x09, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69, 0x61,
|
||||||
|
0x74, 0x65, 0x12, 0x1a, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4e, 0x65,
|
||||||
|
0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18,
|
||||||
|
0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69,
|
||||||
|
0x61, 0x74, 0x65, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x3c, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4c,
|
||||||
|
0x6f, 0x63, 0x61, 0x6c, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x73, 0x12, 0x0f, 0x2e, 0x6d,
|
||||||
|
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x17, 0x2e,
|
||||||
|
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64,
|
||||||
|
0x73, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x4a, 0x0a, 0x11, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e,
|
||||||
|
0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x12, 0x1b, 0x2e, 0x6d, 0x65,
|
||||||
|
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70,
|
||||||
|
0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x1a, 0x18, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61,
|
||||||
|
0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x41,
|
||||||
|
0x63, 0x6b, 0x12, 0x44, 0x0a, 0x0e, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x45, 0x78,
|
||||||
|
0x70, 0x69, 0x72, 0x79, 0x12, 0x18, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
|
||||||
|
0x45, 0x78, 0x70, 0x69, 0x72, 0x79, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x1a, 0x18,
|
||||||
|
0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43,
|
||||||
|
0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x12, 0x3c, 0x0a, 0x07, 0x43, 0x6c, 0x6f, 0x73,
|
||||||
|
0x69, 0x6e, 0x67, 0x12, 0x17, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43,
|
||||||
|
0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67, 0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x1a, 0x18, 0x2e, 0x6d,
|
||||||
|
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61,
|
||||||
|
0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x42, 0x2e, 0x5a, 0x2c, 0x67, 0x69, 0x74, 0x2e, 0x74, 0x6f,
|
||||||
|
0x72, 0x6e, 0x62, 0x65, 0x72, 0x67, 0x2e, 0x6d, 0x65, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72,
|
||||||
|
0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x3b, 0x6d, 0x65,
|
||||||
|
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||||
|
})
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_control_plane_proto_rawDescOnce sync.Once
|
file_control_plane_proto_rawDescOnce sync.Once
|
||||||
@@ -442,30 +525,33 @@ func file_control_plane_proto_rawDescGZIP() []byte {
|
|||||||
return file_control_plane_proto_rawDescData
|
return file_control_plane_proto_rawDescData
|
||||||
}
|
}
|
||||||
|
|
||||||
var file_control_plane_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
|
var file_control_plane_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
|
||||||
var file_control_plane_proto_goTypes = []any{
|
var file_control_plane_proto_goTypes = []any{
|
||||||
(*Empty)(nil), // 0: messages.Empty
|
(*Empty)(nil), // 0: messages.Empty
|
||||||
(*PingReply)(nil), // 1: messages.PingReply
|
(*PingReply)(nil), // 1: messages.PingReply
|
||||||
(*NegotiateRequest)(nil), // 2: messages.NegotiateRequest
|
(*NegotiateRequest)(nil), // 2: messages.NegotiateRequest
|
||||||
(*NegotiateReply)(nil), // 3: messages.NegotiateReply
|
(*NegotiateReply)(nil), // 3: messages.NegotiateReply
|
||||||
(*CartIdsReply)(nil), // 4: messages.CartIdsReply
|
(*ActorIdsReply)(nil), // 4: messages.ActorIdsReply
|
||||||
(*OwnerChangeRequest)(nil), // 5: messages.OwnerChangeRequest
|
(*OwnerChangeAck)(nil), // 5: messages.OwnerChangeAck
|
||||||
(*OwnerChangeAck)(nil), // 6: messages.OwnerChangeAck
|
(*ClosingNotice)(nil), // 6: messages.ClosingNotice
|
||||||
(*ClosingNotice)(nil), // 7: messages.ClosingNotice
|
(*OwnershipAnnounce)(nil), // 7: messages.OwnershipAnnounce
|
||||||
|
(*ExpiryAnnounce)(nil), // 8: messages.ExpiryAnnounce
|
||||||
}
|
}
|
||||||
var file_control_plane_proto_depIdxs = []int32{
|
var file_control_plane_proto_depIdxs = []int32{
|
||||||
0, // 0: messages.ControlPlane.Ping:input_type -> messages.Empty
|
0, // 0: messages.ControlPlane.Ping:input_type -> messages.Empty
|
||||||
2, // 1: messages.ControlPlane.Negotiate:input_type -> messages.NegotiateRequest
|
2, // 1: messages.ControlPlane.Negotiate:input_type -> messages.NegotiateRequest
|
||||||
0, // 2: messages.ControlPlane.GetCartIds:input_type -> messages.Empty
|
0, // 2: messages.ControlPlane.GetLocalActorIds:input_type -> messages.Empty
|
||||||
5, // 3: messages.ControlPlane.ConfirmOwner:input_type -> messages.OwnerChangeRequest
|
7, // 3: messages.ControlPlane.AnnounceOwnership:input_type -> messages.OwnershipAnnounce
|
||||||
7, // 4: messages.ControlPlane.Closing:input_type -> messages.ClosingNotice
|
8, // 4: messages.ControlPlane.AnnounceExpiry:input_type -> messages.ExpiryAnnounce
|
||||||
1, // 5: messages.ControlPlane.Ping:output_type -> messages.PingReply
|
6, // 5: messages.ControlPlane.Closing:input_type -> messages.ClosingNotice
|
||||||
3, // 6: messages.ControlPlane.Negotiate:output_type -> messages.NegotiateReply
|
1, // 6: messages.ControlPlane.Ping:output_type -> messages.PingReply
|
||||||
4, // 7: messages.ControlPlane.GetCartIds:output_type -> messages.CartIdsReply
|
3, // 7: messages.ControlPlane.Negotiate:output_type -> messages.NegotiateReply
|
||||||
6, // 8: messages.ControlPlane.ConfirmOwner:output_type -> messages.OwnerChangeAck
|
4, // 8: messages.ControlPlane.GetLocalActorIds:output_type -> messages.ActorIdsReply
|
||||||
6, // 9: messages.ControlPlane.Closing:output_type -> messages.OwnerChangeAck
|
5, // 9: messages.ControlPlane.AnnounceOwnership:output_type -> messages.OwnerChangeAck
|
||||||
5, // [5:10] is the sub-list for method output_type
|
5, // 10: messages.ControlPlane.AnnounceExpiry:output_type -> messages.OwnerChangeAck
|
||||||
0, // [0:5] is the sub-list for method input_type
|
5, // 11: messages.ControlPlane.Closing:output_type -> messages.OwnerChangeAck
|
||||||
|
6, // [6:12] is the sub-list for method output_type
|
||||||
|
0, // [0:6] is the sub-list for method input_type
|
||||||
0, // [0:0] is the sub-list for extension type_name
|
0, // [0:0] is the sub-list for extension type_name
|
||||||
0, // [0:0] is the sub-list for extension extendee
|
0, // [0:0] is the sub-list for extension extendee
|
||||||
0, // [0:0] is the sub-list for field type_name
|
0, // [0:0] is the sub-list for field type_name
|
||||||
@@ -482,7 +568,7 @@ func file_control_plane_proto_init() {
|
|||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_control_plane_proto_rawDesc), len(file_control_plane_proto_rawDesc)),
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_control_plane_proto_rawDesc), len(file_control_plane_proto_rawDesc)),
|
||||||
NumEnums: 0,
|
NumEnums: 0,
|
||||||
NumMessages: 8,
|
NumMessages: 9,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 1,
|
NumServices: 1,
|
||||||
},
|
},
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.5.1
|
// - protoc-gen-go-grpc v1.5.1
|
||||||
// - protoc v3.21.12
|
// - protoc v5.29.3
|
||||||
// source: control_plane.proto
|
// source: control_plane.proto
|
||||||
|
|
||||||
package messages
|
package messages
|
||||||
@@ -21,8 +21,9 @@ const _ = grpc.SupportPackageIsVersion9
|
|||||||
const (
|
const (
|
||||||
ControlPlane_Ping_FullMethodName = "/messages.ControlPlane/Ping"
|
ControlPlane_Ping_FullMethodName = "/messages.ControlPlane/Ping"
|
||||||
ControlPlane_Negotiate_FullMethodName = "/messages.ControlPlane/Negotiate"
|
ControlPlane_Negotiate_FullMethodName = "/messages.ControlPlane/Negotiate"
|
||||||
ControlPlane_GetCartIds_FullMethodName = "/messages.ControlPlane/GetCartIds"
|
ControlPlane_GetLocalActorIds_FullMethodName = "/messages.ControlPlane/GetLocalActorIds"
|
||||||
ControlPlane_ConfirmOwner_FullMethodName = "/messages.ControlPlane/ConfirmOwner"
|
ControlPlane_AnnounceOwnership_FullMethodName = "/messages.ControlPlane/AnnounceOwnership"
|
||||||
|
ControlPlane_AnnounceExpiry_FullMethodName = "/messages.ControlPlane/AnnounceExpiry"
|
||||||
ControlPlane_Closing_FullMethodName = "/messages.ControlPlane/Closing"
|
ControlPlane_Closing_FullMethodName = "/messages.ControlPlane/Closing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,9 +38,11 @@ type ControlPlaneClient interface {
|
|||||||
// Negotiate merges host views; used during discovery & convergence.
|
// Negotiate merges host views; used during discovery & convergence.
|
||||||
Negotiate(ctx context.Context, in *NegotiateRequest, opts ...grpc.CallOption) (*NegotiateReply, error)
|
Negotiate(ctx context.Context, in *NegotiateRequest, opts ...grpc.CallOption) (*NegotiateReply, error)
|
||||||
// GetCartIds lists currently owned cart IDs on this node.
|
// GetCartIds lists currently owned cart IDs on this node.
|
||||||
GetCartIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*CartIdsReply, error)
|
GetLocalActorIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*ActorIdsReply, error)
|
||||||
// ConfirmOwner announces/asks peers to acknowledge ownership transfer.
|
// Ownership announcement: first-touch claim broadcast (idempotent; best-effort).
|
||||||
ConfirmOwner(ctx context.Context, in *OwnerChangeRequest, opts ...grpc.CallOption) (*OwnerChangeAck, error)
|
AnnounceOwnership(ctx context.Context, in *OwnershipAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error)
|
||||||
|
// Expiry announcement: drop remote ownership hints when local TTL expires.
|
||||||
|
AnnounceExpiry(ctx context.Context, in *ExpiryAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error)
|
||||||
// Closing announces graceful shutdown so peers can proactively adjust.
|
// Closing announces graceful shutdown so peers can proactively adjust.
|
||||||
Closing(ctx context.Context, in *ClosingNotice, opts ...grpc.CallOption) (*OwnerChangeAck, error)
|
Closing(ctx context.Context, in *ClosingNotice, opts ...grpc.CallOption) (*OwnerChangeAck, error)
|
||||||
}
|
}
|
||||||
@@ -72,20 +75,30 @@ func (c *controlPlaneClient) Negotiate(ctx context.Context, in *NegotiateRequest
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *controlPlaneClient) GetCartIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*CartIdsReply, error) {
|
func (c *controlPlaneClient) GetLocalActorIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*ActorIdsReply, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(CartIdsReply)
|
out := new(ActorIdsReply)
|
||||||
err := c.cc.Invoke(ctx, ControlPlane_GetCartIds_FullMethodName, in, out, cOpts...)
|
err := c.cc.Invoke(ctx, ControlPlane_GetLocalActorIds_FullMethodName, in, out, cOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *controlPlaneClient) ConfirmOwner(ctx context.Context, in *OwnerChangeRequest, opts ...grpc.CallOption) (*OwnerChangeAck, error) {
|
func (c *controlPlaneClient) AnnounceOwnership(ctx context.Context, in *OwnershipAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(OwnerChangeAck)
|
out := new(OwnerChangeAck)
|
||||||
err := c.cc.Invoke(ctx, ControlPlane_ConfirmOwner_FullMethodName, in, out, cOpts...)
|
err := c.cc.Invoke(ctx, ControlPlane_AnnounceOwnership_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *controlPlaneClient) AnnounceExpiry(ctx context.Context, in *ExpiryAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(OwnerChangeAck)
|
||||||
|
err := c.cc.Invoke(ctx, ControlPlane_AnnounceExpiry_FullMethodName, in, out, cOpts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -113,9 +126,11 @@ type ControlPlaneServer interface {
|
|||||||
// Negotiate merges host views; used during discovery & convergence.
|
// Negotiate merges host views; used during discovery & convergence.
|
||||||
Negotiate(context.Context, *NegotiateRequest) (*NegotiateReply, error)
|
Negotiate(context.Context, *NegotiateRequest) (*NegotiateReply, error)
|
||||||
// GetCartIds lists currently owned cart IDs on this node.
|
// GetCartIds lists currently owned cart IDs on this node.
|
||||||
GetCartIds(context.Context, *Empty) (*CartIdsReply, error)
|
GetLocalActorIds(context.Context, *Empty) (*ActorIdsReply, error)
|
||||||
// ConfirmOwner announces/asks peers to acknowledge ownership transfer.
|
// Ownership announcement: first-touch claim broadcast (idempotent; best-effort).
|
||||||
ConfirmOwner(context.Context, *OwnerChangeRequest) (*OwnerChangeAck, error)
|
AnnounceOwnership(context.Context, *OwnershipAnnounce) (*OwnerChangeAck, error)
|
||||||
|
// Expiry announcement: drop remote ownership hints when local TTL expires.
|
||||||
|
AnnounceExpiry(context.Context, *ExpiryAnnounce) (*OwnerChangeAck, error)
|
||||||
// Closing announces graceful shutdown so peers can proactively adjust.
|
// Closing announces graceful shutdown so peers can proactively adjust.
|
||||||
Closing(context.Context, *ClosingNotice) (*OwnerChangeAck, error)
|
Closing(context.Context, *ClosingNotice) (*OwnerChangeAck, error)
|
||||||
mustEmbedUnimplementedControlPlaneServer()
|
mustEmbedUnimplementedControlPlaneServer()
|
||||||
@@ -134,11 +149,14 @@ func (UnimplementedControlPlaneServer) Ping(context.Context, *Empty) (*PingReply
|
|||||||
func (UnimplementedControlPlaneServer) Negotiate(context.Context, *NegotiateRequest) (*NegotiateReply, error) {
|
func (UnimplementedControlPlaneServer) Negotiate(context.Context, *NegotiateRequest) (*NegotiateReply, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method Negotiate not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method Negotiate not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedControlPlaneServer) GetCartIds(context.Context, *Empty) (*CartIdsReply, error) {
|
func (UnimplementedControlPlaneServer) GetLocalActorIds(context.Context, *Empty) (*ActorIdsReply, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method GetCartIds not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method GetLocalActorIds not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedControlPlaneServer) ConfirmOwner(context.Context, *OwnerChangeRequest) (*OwnerChangeAck, error) {
|
func (UnimplementedControlPlaneServer) AnnounceOwnership(context.Context, *OwnershipAnnounce) (*OwnerChangeAck, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method ConfirmOwner not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method AnnounceOwnership not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedControlPlaneServer) AnnounceExpiry(context.Context, *ExpiryAnnounce) (*OwnerChangeAck, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method AnnounceExpiry not implemented")
|
||||||
}
|
}
|
||||||
func (UnimplementedControlPlaneServer) Closing(context.Context, *ClosingNotice) (*OwnerChangeAck, error) {
|
func (UnimplementedControlPlaneServer) Closing(context.Context, *ClosingNotice) (*OwnerChangeAck, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method Closing not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method Closing not implemented")
|
||||||
@@ -200,38 +218,56 @@ func _ControlPlane_Negotiate_Handler(srv interface{}, ctx context.Context, dec f
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
func _ControlPlane_GetCartIds_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
func _ControlPlane_GetLocalActorIds_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(Empty)
|
in := new(Empty)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if interceptor == nil {
|
if interceptor == nil {
|
||||||
return srv.(ControlPlaneServer).GetCartIds(ctx, in)
|
return srv.(ControlPlaneServer).GetLocalActorIds(ctx, in)
|
||||||
}
|
}
|
||||||
info := &grpc.UnaryServerInfo{
|
info := &grpc.UnaryServerInfo{
|
||||||
Server: srv,
|
Server: srv,
|
||||||
FullMethod: ControlPlane_GetCartIds_FullMethodName,
|
FullMethod: ControlPlane_GetLocalActorIds_FullMethodName,
|
||||||
}
|
}
|
||||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
return srv.(ControlPlaneServer).GetCartIds(ctx, req.(*Empty))
|
return srv.(ControlPlaneServer).GetLocalActorIds(ctx, req.(*Empty))
|
||||||
}
|
}
|
||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
func _ControlPlane_ConfirmOwner_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
func _ControlPlane_AnnounceOwnership_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(OwnerChangeRequest)
|
in := new(OwnershipAnnounce)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if interceptor == nil {
|
if interceptor == nil {
|
||||||
return srv.(ControlPlaneServer).ConfirmOwner(ctx, in)
|
return srv.(ControlPlaneServer).AnnounceOwnership(ctx, in)
|
||||||
}
|
}
|
||||||
info := &grpc.UnaryServerInfo{
|
info := &grpc.UnaryServerInfo{
|
||||||
Server: srv,
|
Server: srv,
|
||||||
FullMethod: ControlPlane_ConfirmOwner_FullMethodName,
|
FullMethod: ControlPlane_AnnounceOwnership_FullMethodName,
|
||||||
}
|
}
|
||||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
return srv.(ControlPlaneServer).ConfirmOwner(ctx, req.(*OwnerChangeRequest))
|
return srv.(ControlPlaneServer).AnnounceOwnership(ctx, req.(*OwnershipAnnounce))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _ControlPlane_AnnounceExpiry_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(ExpiryAnnounce)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(ControlPlaneServer).AnnounceExpiry(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: ControlPlane_AnnounceExpiry_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(ControlPlaneServer).AnnounceExpiry(ctx, req.(*ExpiryAnnounce))
|
||||||
}
|
}
|
||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
@@ -270,12 +306,16 @@ var ControlPlane_ServiceDesc = grpc.ServiceDesc{
|
|||||||
Handler: _ControlPlane_Negotiate_Handler,
|
Handler: _ControlPlane_Negotiate_Handler,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
MethodName: "GetCartIds",
|
MethodName: "GetLocalActorIds",
|
||||||
Handler: _ControlPlane_GetCartIds_Handler,
|
Handler: _ControlPlane_GetLocalActorIds_Handler,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
MethodName: "ConfirmOwner",
|
MethodName: "AnnounceOwnership",
|
||||||
Handler: _ControlPlane_ConfirmOwner_Handler,
|
Handler: _ControlPlane_AnnounceOwnership_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "AnnounceExpiry",
|
||||||
|
Handler: _ControlPlane_AnnounceExpiry_Handler,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
MethodName: "Closing",
|
MethodName: "Closing",
|
||||||
1306
pkg/messages/messages.pb.go
Normal file
1306
pkg/messages/messages.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
187
pkg/proxy/remotehost.go
Normal file
187
pkg/proxy/remotehost.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RemoteHost mirrors the lightweight controller used for remote node
|
||||||
|
// interaction.
|
||||||
|
type RemoteHost struct {
|
||||||
|
host string
|
||||||
|
httpBase string
|
||||||
|
conn *grpc.ClientConn
|
||||||
|
transport *http.Transport
|
||||||
|
client *http.Client
|
||||||
|
controlClient messages.ControlPlaneClient
|
||||||
|
missedPings int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRemoteHost(host string) (*RemoteHost, error) {
|
||||||
|
|
||||||
|
target := fmt.Sprintf("%s:1337", host)
|
||||||
|
|
||||||
|
conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("AddRemote: dial %s failed: %v", target, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
controlClient := messages.NewControlPlaneClient(conn)
|
||||||
|
|
||||||
|
transport := &http.Transport{
|
||||||
|
MaxIdleConns: 100,
|
||||||
|
MaxIdleConnsPerHost: 100,
|
||||||
|
DisableKeepAlives: false,
|
||||||
|
IdleConnTimeout: 120 * time.Second,
|
||||||
|
}
|
||||||
|
client := &http.Client{Transport: transport, Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
return &RemoteHost{
|
||||||
|
host: host,
|
||||||
|
httpBase: fmt.Sprintf("http://%s:8080/cart", host),
|
||||||
|
conn: conn,
|
||||||
|
transport: transport,
|
||||||
|
client: client,
|
||||||
|
controlClient: controlClient,
|
||||||
|
missedPings: 0,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RemoteHost) Name() string {
|
||||||
|
return h.host
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RemoteHost) Close() error {
|
||||||
|
if h.conn != nil {
|
||||||
|
h.conn.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RemoteHost) Ping() bool {
|
||||||
|
var err error = errors.ErrUnsupported
|
||||||
|
for err != nil {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
_, err = h.controlClient.Ping(ctx, &messages.Empty{})
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
h.missedPings++
|
||||||
|
log.Printf("Ping %s failed (%d) %v", h.host, h.missedPings, err)
|
||||||
|
}
|
||||||
|
if !h.IsHealthy() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond * 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.missedPings = 0
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RemoteHost) Negotiate(knownHosts []string) ([]string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resp, err := h.controlClient.Negotiate(ctx, &messages.NegotiateRequest{
|
||||||
|
KnownHosts: knownHosts,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.missedPings++
|
||||||
|
log.Printf("Negotiate %s failed: %v", h.host, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
h.missedPings = 0
|
||||||
|
return resp.Hosts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RemoteHost) GetActorIds() []uint64 {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
reply, err := h.controlClient.GetLocalActorIds(ctx, &messages.Empty{})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Init remote %s: GetCartIds error: %v", h.host, err)
|
||||||
|
h.missedPings++
|
||||||
|
return []uint64{}
|
||||||
|
}
|
||||||
|
return reply.GetIds()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RemoteHost) AnnounceOwnership(ownerHost string, uids []uint64) {
|
||||||
|
_, err := h.controlClient.AnnounceOwnership(context.Background(), &messages.OwnershipAnnounce{
|
||||||
|
Host: ownerHost,
|
||||||
|
Ids: uids,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ownership announce to %s failed: %v", h.host, err)
|
||||||
|
h.missedPings++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.missedPings = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RemoteHost) AnnounceExpiry(uids []uint64) {
|
||||||
|
_, err := h.controlClient.AnnounceExpiry(context.Background(), &messages.ExpiryAnnounce{
|
||||||
|
Host: h.host,
|
||||||
|
Ids: uids,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("expiry announce to %s failed: %v", h.host, err)
|
||||||
|
h.missedPings++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.missedPings = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *RemoteHost) Proxy(id uint64, w http.ResponseWriter, r *http.Request) (bool, error) {
|
||||||
|
target := fmt.Sprintf("%s%s", h.httpBase, r.URL.RequestURI())
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(r.Context(), r.Method, target, r.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "proxy build error", http.StatusBadGateway)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
//r.Body = io.NopCloser(bytes.NewReader(bodyCopy))
|
||||||
|
req.Header.Set("X-Forwarded-Host", r.Host)
|
||||||
|
|
||||||
|
for k, v := range r.Header {
|
||||||
|
for _, vv := range v {
|
||||||
|
req.Header.Add(k, vv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res, err := h.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "proxy request error", http.StatusBadGateway)
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
for k, v := range res.Header {
|
||||||
|
for _, vv := range v {
|
||||||
|
w.Header().Add(k, vv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("X-Cart-Owner-Routed", "true")
|
||||||
|
if res.StatusCode >= 200 && res.StatusCode <= 299 {
|
||||||
|
w.WriteHeader(res.StatusCode)
|
||||||
|
_, copyErr := io.Copy(w, res.Body)
|
||||||
|
if copyErr != nil {
|
||||||
|
return true, copyErr
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("proxy response status %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RemoteHost) IsHealthy() bool {
|
||||||
|
return r.missedPings < 3
|
||||||
|
}
|
||||||
347
pkg/voucher/parser.go
Normal file
347
pkg/voucher/parser.go
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
package voucher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
Package voucher - rule parser
|
||||||
|
|
||||||
|
A lightweight parser for voucher rule expressions.
|
||||||
|
|
||||||
|
Supported rule kinds (case-insensitive keywords):
|
||||||
|
|
||||||
|
sku=SKU1|SKU2|SKU3
|
||||||
|
- At least one of the listed SKUs must be present in the cart.
|
||||||
|
|
||||||
|
category=CatA|CatB|CatC
|
||||||
|
- At least one of the listed categories must be present.
|
||||||
|
|
||||||
|
min_total>=12345
|
||||||
|
- Cart total (Inc VAT) must be at least this value (int64).
|
||||||
|
|
||||||
|
min_item_price>=5000
|
||||||
|
- At least one individual item (Inc VAT single unit price) must be at least this value (int64).
|
||||||
|
|
||||||
|
Rule list grammar (simplified):
|
||||||
|
rules := rule (sep rule)*
|
||||||
|
rule := (sku|category) '=' valueList
|
||||||
|
| (min_total|min_item_price) comparator number
|
||||||
|
valueList := value ('|' value)*
|
||||||
|
comparator := '>=' (only comparator currently supported for numeric rules)
|
||||||
|
sep := ';' | ',' | newline
|
||||||
|
|
||||||
|
Whitespace is ignored around tokens.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
sku=ABC123|XYZ999; category=Shoes|Bags
|
||||||
|
min_total>=10000
|
||||||
|
min_item_price>=2500, category=Accessories
|
||||||
|
|
||||||
|
Parsing returns a RuleSet which can later be evaluated against a generic context.
|
||||||
|
The evaluation context uses simple Item abstractions to avoid tight coupling with
|
||||||
|
the cart implementation (which currently lives under cmd/cart and cannot be
|
||||||
|
imported due to being in package main).
|
||||||
|
|
||||||
|
This is intentionally conservative and extensible:
|
||||||
|
* Adding new rule kinds: extend RuleKind constants, add parse + evaluate logic.
|
||||||
|
* Supporting new operators: extend numeric rule parsing & evaluation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrEmptyExpression is returned when the input string has only whitespace.
|
||||||
|
ErrEmptyExpression = errors.New("voucher: empty rule expression")
|
||||||
|
// ErrInvalidRule indicates a syntactic or semantic issue with a single rule fragment.
|
||||||
|
ErrInvalidRule = errors.New("voucher: invalid rule")
|
||||||
|
)
|
||||||
|
|
||||||
|
// RuleKind enumerates supported rule kinds.
|
||||||
|
type RuleKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RuleSku RuleKind = "sku"
|
||||||
|
RuleCategory RuleKind = "category"
|
||||||
|
RuleMinTotal RuleKind = "min_total"
|
||||||
|
RuleMinItemPrice RuleKind = "min_item_price"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ruleCondition represents a single, parsed rule.
|
||||||
|
type ruleCondition struct {
|
||||||
|
Kind RuleKind
|
||||||
|
StringVals []string // For sku / category multi-value list
|
||||||
|
MinValue *int64 // For numeric threshold rules
|
||||||
|
// Operator reserved for future (e.g., >, >=, ==). Currently always ">=" for numeric kinds.
|
||||||
|
Operator string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleSet groups multiple rule conditions (logical AND).
|
||||||
|
// All conditions must pass for Applies() to return true.
|
||||||
|
type RuleSet struct {
|
||||||
|
Conditions []ruleCondition
|
||||||
|
Source string // original, trimmed source string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item is a minimal abstraction for evaluation (decoupled from cart domain structs).
|
||||||
|
type Item struct {
|
||||||
|
Sku string
|
||||||
|
Category string
|
||||||
|
UnitPrice int64 // Inc VAT (single unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvalContext bundles cart-like data necessary for evaluation.
|
||||||
|
type EvalContext struct {
|
||||||
|
Items []Item
|
||||||
|
CartTotalInc int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies returns true if all rule conditions pass for the context.
|
||||||
|
func (rs *RuleSet) Applies(ctx EvalContext) bool {
|
||||||
|
for _, c := range rs.Conditions {
|
||||||
|
switch c.Kind {
|
||||||
|
case RuleSku:
|
||||||
|
if !anyItem(ctx.Items, func(it Item) bool {
|
||||||
|
return containsFold(c.StringVals, it.Sku)
|
||||||
|
}) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case RuleCategory:
|
||||||
|
if !anyItem(ctx.Items, func(it Item) bool {
|
||||||
|
return containsFold(c.StringVals, it.Category)
|
||||||
|
}) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case RuleMinTotal:
|
||||||
|
if c.MinValue == nil || ctx.CartTotalInc < *c.MinValue {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case RuleMinItemPrice:
|
||||||
|
if c.MinValue == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !anyItem(ctx.Items, func(it Item) bool {
|
||||||
|
return it.UnitPrice >= *c.MinValue
|
||||||
|
}) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Unknown kinds fail closed to avoid granting unintended discounts.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// anyItem returns true if predicate matches any item.
|
||||||
|
func anyItem(items []Item, pred func(Item) bool) bool {
|
||||||
|
for _, it := range items {
|
||||||
|
if pred(it) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseRules parses a rule expression into a RuleSet.
|
||||||
|
func ParseRules(input string) (*RuleSet, error) {
|
||||||
|
trimmed := strings.TrimSpace(input)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil, ErrEmptyExpression
|
||||||
|
}
|
||||||
|
|
||||||
|
fragments := splitRuleFragments(trimmed)
|
||||||
|
if len(fragments) == 0 {
|
||||||
|
return nil, ErrInvalidRule
|
||||||
|
}
|
||||||
|
|
||||||
|
var conditions []ruleCondition
|
||||||
|
for _, frag := range fragments {
|
||||||
|
if frag == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c, err := parseFragment(frag)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %s (%v)", ErrInvalidRule, frag, err)
|
||||||
|
}
|
||||||
|
conditions = append(conditions, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(conditions) == 0 {
|
||||||
|
return nil, ErrInvalidRule
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RuleSet{
|
||||||
|
Conditions: conditions,
|
||||||
|
Source: trimmed,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitRuleFragments splits on ; , or newline, while respecting basic structure.
|
||||||
|
func splitRuleFragments(s string) []string {
|
||||||
|
// Normalize line endings
|
||||||
|
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||||
|
|
||||||
|
// We allow separators: newline, semicolon, comma.
|
||||||
|
seps := func(r rune) bool {
|
||||||
|
return r == ';' || r == '\n' || r == ','
|
||||||
|
}
|
||||||
|
raw := strings.FieldsFunc(s, seps)
|
||||||
|
out := make([]string, 0, len(raw))
|
||||||
|
for _, f := range raw {
|
||||||
|
t := strings.TrimSpace(f)
|
||||||
|
if t != "" {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFragment parses an individual rule fragment.
|
||||||
|
func parseFragment(frag string) (ruleCondition, error) {
|
||||||
|
lower := strings.ToLower(frag)
|
||||||
|
|
||||||
|
// Numeric rules have form: <kind> >= number
|
||||||
|
if strings.HasPrefix(lower, string(RuleMinTotal)) ||
|
||||||
|
strings.HasPrefix(lower, string(RuleMinItemPrice)) {
|
||||||
|
|
||||||
|
return parseNumericRule(frag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key=Value list rules (sku / category).
|
||||||
|
if i := strings.Index(frag, "="); i > 0 {
|
||||||
|
key := strings.TrimSpace(frag[:i])
|
||||||
|
valPart := strings.TrimSpace(frag[i+1:])
|
||||||
|
if key == "" || valPart == "" {
|
||||||
|
return ruleCondition{}, errors.New("empty key/value")
|
||||||
|
}
|
||||||
|
kind := RuleKind(strings.ToLower(key))
|
||||||
|
switch kind {
|
||||||
|
case RuleSku, RuleCategory:
|
||||||
|
values := splitAndClean(valPart, "|")
|
||||||
|
if len(values) == 0 {
|
||||||
|
return ruleCondition{}, errors.New("empty value list")
|
||||||
|
}
|
||||||
|
return ruleCondition{
|
||||||
|
Kind: kind,
|
||||||
|
StringVals: values,
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return ruleCondition{}, fmt.Errorf("unsupported key '%s'", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ruleCondition{}, fmt.Errorf("unrecognized fragment '%s'", frag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseNumericRule(frag string) (ruleCondition, error) {
|
||||||
|
// Support only '>=' for now.
|
||||||
|
var kind RuleKind
|
||||||
|
var rest string
|
||||||
|
|
||||||
|
fragTrim := strings.TrimSpace(frag)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(strings.ToLower(fragTrim), string(RuleMinTotal)):
|
||||||
|
kind = RuleMinTotal
|
||||||
|
rest = strings.TrimSpace(fragTrim[len(RuleMinTotal):])
|
||||||
|
case strings.HasPrefix(strings.ToLower(fragTrim), string(RuleMinItemPrice)):
|
||||||
|
kind = RuleMinItemPrice
|
||||||
|
rest = strings.TrimSpace(fragTrim[len(RuleMinItemPrice):])
|
||||||
|
default:
|
||||||
|
return ruleCondition{}, fmt.Errorf("unknown numeric rule '%s'", frag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect operator and number (>= <number>)
|
||||||
|
rest = stripLeadingSpace(rest)
|
||||||
|
if !strings.HasPrefix(rest, ">=") {
|
||||||
|
return ruleCondition{}, fmt.Errorf("expected '>=' in '%s'", frag)
|
||||||
|
}
|
||||||
|
numStr := strings.TrimSpace(rest[2:])
|
||||||
|
if numStr == "" {
|
||||||
|
return ruleCondition{}, fmt.Errorf("missing numeric value in '%s'", frag)
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := strconv.ParseInt(numStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return ruleCondition{}, fmt.Errorf("invalid number '%s': %v", numStr, err)
|
||||||
|
}
|
||||||
|
if value < 0 {
|
||||||
|
return ruleCondition{}, fmt.Errorf("negative threshold %d", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ruleCondition{
|
||||||
|
Kind: kind,
|
||||||
|
MinValue: &value,
|
||||||
|
Operator: ">=",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripLeadingSpace(s string) string {
|
||||||
|
for len(s) > 0 && unicode.IsSpace(rune(s[0])) {
|
||||||
|
s = s[1:]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitAndClean(s string, sep string) []string {
|
||||||
|
raw := strings.Split(s, sep)
|
||||||
|
out := make([]string, 0, len(raw))
|
||||||
|
for _, r := range raw {
|
||||||
|
t := strings.TrimSpace(r)
|
||||||
|
if t != "" {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsFold(list []string, candidate string) bool {
|
||||||
|
for _, v := range list {
|
||||||
|
if strings.EqualFold(v, candidate) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Describe returns a human-friendly summary of the parsed rule set.
|
||||||
|
func (rs *RuleSet) Describe() string {
|
||||||
|
if rs == nil {
|
||||||
|
return "<nil>"
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
for _, c := range rs.Conditions {
|
||||||
|
switch c.Kind {
|
||||||
|
case RuleSku, RuleCategory:
|
||||||
|
parts = append(parts, fmt.Sprintf("%s in (%s)", c.Kind, strings.Join(c.StringVals, "|")))
|
||||||
|
case RuleMinTotal, RuleMinItemPrice:
|
||||||
|
if c.MinValue != nil {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s %s %d", c.Kind, c.OperatorOr(">="), *c.MinValue))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
parts = append(parts, fmt.Sprintf("unknown(%s)", c.Kind))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " AND ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c ruleCondition) OperatorOr(def string) string {
|
||||||
|
if c.Operator == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return c.Operator
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Convenience helpers for incremental adoption ---
|
||||||
|
|
||||||
|
// MustParseRules panics on parse error (useful in tests or static initialization).
|
||||||
|
func MustParseRules(expr string) *RuleSet {
|
||||||
|
rs, err := ParseRules(expr)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return rs
|
||||||
|
}
|
||||||
179
pkg/voucher/parser_test.go
Normal file
179
pkg/voucher/parser_test.go
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
package voucher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseRules_SimpleSku(t *testing.T) {
|
||||||
|
rs, err := ParseRules("sku=ABC123|XYZ999|def456")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(rs.Conditions) != 1 {
|
||||||
|
t.Fatalf("expected 1 condition got %d", len(rs.Conditions))
|
||||||
|
}
|
||||||
|
c := rs.Conditions[0]
|
||||||
|
if c.Kind != RuleSku {
|
||||||
|
t.Fatalf("expected kind sku got %s", c.Kind)
|
||||||
|
}
|
||||||
|
if len(c.StringVals) != 3 {
|
||||||
|
t.Fatalf("expected 3 sku values got %d", len(c.StringVals))
|
||||||
|
}
|
||||||
|
want := []string{"ABC123", "XYZ999", "def456"}
|
||||||
|
for i, v := range want {
|
||||||
|
if c.StringVals[i] != v {
|
||||||
|
t.Fatalf("expected sku[%d]=%s got %s", i, v, c.StringVals[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRules_CategoryAndSkuMixedSeparators(t *testing.T) {
|
||||||
|
rs, err := ParseRules(" category=Shoes|Bags ; sku= A | B , min_total>=1000\nmin_item_price>=500")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(rs.Conditions) != 4 {
|
||||||
|
t.Fatalf("expected 4 conditions got %d", len(rs.Conditions))
|
||||||
|
}
|
||||||
|
|
||||||
|
kinds := []RuleKind{RuleCategory, RuleSku, RuleMinTotal, RuleMinItemPrice}
|
||||||
|
for i, k := range kinds {
|
||||||
|
if rs.Conditions[i].Kind != k {
|
||||||
|
t.Fatalf("expected condition[%d] kind %s got %s", i, k, rs.Conditions[i].Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate numeric thresholds
|
||||||
|
if rs.Conditions[2].MinValue == nil || *rs.Conditions[2].MinValue != 1000 {
|
||||||
|
t.Fatalf("expected min_total>=1000 got %+v", rs.Conditions[2])
|
||||||
|
}
|
||||||
|
if rs.Conditions[3].MinValue == nil || *rs.Conditions[3].MinValue != 500 {
|
||||||
|
t.Fatalf("expected min_item_price>=500 got %+v", rs.Conditions[3])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRules_Empty(t *testing.T) {
|
||||||
|
_, err := ParseRules(" \n ")
|
||||||
|
if !errors.Is(err, ErrEmptyExpression) {
|
||||||
|
t.Fatalf("expected ErrEmptyExpression got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRules_Invalid(t *testing.T) {
|
||||||
|
_, err := ParseRules("unknown=foo")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for unknown key")
|
||||||
|
}
|
||||||
|
_, err = ParseRules("min_total>100") // wrong operator
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for wrong operator")
|
||||||
|
}
|
||||||
|
_, err = ParseRules("min_total>=") // missing value
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing numeric value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleSet_Applies(t *testing.T) {
|
||||||
|
rs := MustParseRules("sku=ABC123|XYZ999; category=Shoes|min_total>=10000; min_item_price>=3000")
|
||||||
|
|
||||||
|
ctx := EvalContext{
|
||||||
|
Items: []Item{
|
||||||
|
{Sku: "ABC123", Category: "Shoes", UnitPrice: 2500},
|
||||||
|
{Sku: "FFF000", Category: "Accessories", UnitPrice: 3200},
|
||||||
|
},
|
||||||
|
CartTotalInc: 12000,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !rs.Applies(ctx) {
|
||||||
|
t.Fatalf("expected rules to apply")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail due to missing sku/category
|
||||||
|
ctx2 := EvalContext{
|
||||||
|
Items: []Item{
|
||||||
|
{Sku: "NOPE", Category: "Different", UnitPrice: 4000},
|
||||||
|
},
|
||||||
|
CartTotalInc: 20000,
|
||||||
|
}
|
||||||
|
if rs.Applies(ctx2) {
|
||||||
|
t.Fatalf("expected rules NOT to apply (sku/category mismatch)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail due to min_total
|
||||||
|
ctx3 := EvalContext{
|
||||||
|
Items: []Item{
|
||||||
|
{Sku: "ABC123", Category: "Shoes", UnitPrice: 2500},
|
||||||
|
{Sku: "FFF000", Category: "Accessories", UnitPrice: 3200},
|
||||||
|
},
|
||||||
|
CartTotalInc: 9000,
|
||||||
|
}
|
||||||
|
if rs.Applies(ctx3) {
|
||||||
|
t.Fatalf("expected rules NOT to apply (min_total not reached)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail due to min_item_price (no item >=3000)
|
||||||
|
ctx4 := EvalContext{
|
||||||
|
Items: []Item{
|
||||||
|
{Sku: "ABC123", Category: "Shoes", UnitPrice: 2500},
|
||||||
|
{Sku: "FFF000", Category: "Accessories", UnitPrice: 2800},
|
||||||
|
},
|
||||||
|
CartTotalInc: 15000,
|
||||||
|
}
|
||||||
|
if rs.Applies(ctx4) {
|
||||||
|
t.Fatalf("expected rules NOT to apply (min_item_price not satisfied)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleSet_Applies_CaseInsensitive(t *testing.T) {
|
||||||
|
rs := MustParseRules("SKU=abc123|xyz999; CATEGORY=Shoes")
|
||||||
|
ctx := EvalContext{
|
||||||
|
Items: []Item{
|
||||||
|
{Sku: "AbC123", Category: "shoes", UnitPrice: 1000},
|
||||||
|
},
|
||||||
|
CartTotalInc: 1000,
|
||||||
|
}
|
||||||
|
if !rs.Applies(ctx) {
|
||||||
|
t.Fatalf("expected rules to apply (case-insensitive match)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDescribe(t *testing.T) {
|
||||||
|
rs := MustParseRules("sku=A|B|min_total>=500")
|
||||||
|
desc := rs.Describe()
|
||||||
|
// Loose assertions to avoid over-specification
|
||||||
|
if desc == "" {
|
||||||
|
t.Fatalf("expected non-empty description")
|
||||||
|
}
|
||||||
|
if !(contains(desc, "sku") && contains(desc, "min_total")) {
|
||||||
|
t.Fatalf("description missing expected parts: %s", desc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(haystack, needle string) bool {
|
||||||
|
return len(haystack) >= len(needle) && indexOf(haystack, needle) >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple substring search (avoid importing strings to show intent explicitly here)
|
||||||
|
func indexOf(s, sub string) int {
|
||||||
|
outer:
|
||||||
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
|
for j := 0; j < len(sub); j++ {
|
||||||
|
if s[i+j] != sub[j] {
|
||||||
|
continue outer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustParseRules_Panics(t *testing.T) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r == nil {
|
||||||
|
t.Fatalf("expected panic for invalid expression")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
MustParseRules("~~ totally invalid ~~")
|
||||||
|
}
|
||||||
39
pkg/voucher/service.go
Normal file
39
pkg/voucher/service.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package voucher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"git.tornberg.me/go-cart-actor/pkg/messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Rule struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Value int64 `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Voucher struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Value int64 `json:"discount"`
|
||||||
|
TaxValue int64 `json:"taxValue"`
|
||||||
|
TaxRate int `json:"taxRate"`
|
||||||
|
rules []Rule `json:"rules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
// Add fields here
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrInvalidCode = errors.New("invalid vouchercode")
|
||||||
|
|
||||||
|
func (s *Service) GetVoucher(code string) (*messages.AddVoucher, error) {
|
||||||
|
if code == "" {
|
||||||
|
return nil, ErrInvalidCode
|
||||||
|
}
|
||||||
|
value := int64(250_00)
|
||||||
|
return &messages.AddVoucher{
|
||||||
|
Code: code,
|
||||||
|
Value: value,
|
||||||
|
VoucherRules: make([]*messages.VoucherRule, 0),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
349
pool-server.go
349
pool-server.go
@@ -1,349 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"math/rand"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
messages "git.tornberg.me/go-cart-actor/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PoolServer struct {
|
|
||||||
pod_name string
|
|
||||||
pool GrainPool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPoolServer(pool GrainPool, pod_name string) *PoolServer {
|
|
||||||
return &PoolServer{
|
|
||||||
pod_name: pod_name,
|
|
||||||
pool: pool,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) HandleGet(w http.ResponseWriter, r *http.Request, id CartId) error {
|
|
||||||
data, err := s.pool.Get(id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.WriteResult(w, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
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},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ErrorHandler(fn func(w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
err := fn(w, r)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Server error, not remote error: %v\n", err)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte(err.Error()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) WriteResult(w http.ResponseWriter, result *FrameWithPayload) error {
|
|
||||||
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))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, err := w.Write(result.Payload)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) HandleDeleteItem(w http.ResponseWriter, r *http.Request, id CartId) error {
|
|
||||||
|
|
||||||
itemIdString := r.PathValue("itemId")
|
|
||||||
itemId, err := strconv.Atoi(itemIdString)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data, err := s.pool.Process(id, Message{
|
|
||||||
Type: RemoveItemType,
|
|
||||||
Content: &messages.RemoveItem{Id: int64(itemId)},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
type SetDelivery struct {
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
Items []int64 `json:"items"`
|
|
||||||
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) HandleSetDelivery(w http.ResponseWriter, r *http.Request, id CartId) error {
|
|
||||||
|
|
||||||
delivery := SetDelivery{}
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&delivery)
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) HandleSetPickupPoint(w http.ResponseWriter, r *http.Request, id CartId) error {
|
|
||||||
|
|
||||||
deliveryIdString := r.PathValue("deliveryId")
|
|
||||||
deliveryId, err := strconv.Atoi(deliveryIdString)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
pickupPoint := messages.PickupPoint{}
|
|
||||||
err = json.NewDecoder(r.Body).Decode(&pickupPoint)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
reply, err := s.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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) HandleRemoveDelivery(w http.ResponseWriter, r *http.Request, id CartId) error {
|
|
||||||
|
|
||||||
deliveryIdString := r.PathValue("deliveryId")
|
|
||||||
deliveryId, err := strconv.Atoi(deliveryIdString)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
reply, err := s.pool.Process(id, Message{
|
|
||||||
Type: RemoveDeliveryType,
|
|
||||||
Content: &messages.RemoveDelivery{Id: int64(deliveryId)},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) HandleQuantityChange(w http.ResponseWriter, r *http.Request, id CartId) error {
|
|
||||||
changeQuantity := messages.ChangeQuantity{}
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&changeQuantity)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
reply, err := s.pool.Process(id, Message{
|
|
||||||
Type: ChangeQuantityType,
|
|
||||||
Content: &changeQuantity,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) HandleSetCartItems(w http.ResponseWriter, r *http.Request, id CartId) error {
|
|
||||||
setCartItems := messages.SetCartRequest{}
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&setCartItems)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
reply, err := s.pool.Process(id, Message{
|
|
||||||
Type: SetCartItemsType,
|
|
||||||
Content: &setCartItems,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) HandleAddRequest(w http.ResponseWriter, r *http.Request, id CartId) error {
|
|
||||||
addRequest := messages.AddRequest{}
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&addRequest)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
reply, err := s.pool.Process(id, Message{
|
|
||||||
Type: AddRequestType,
|
|
||||||
Content: &addRequest,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) HandleConfirmation(w http.ResponseWriter, r *http.Request, id CartId) error {
|
|
||||||
orderId := r.PathValue("orderId")
|
|
||||||
if orderId == "" {
|
|
||||||
return fmt.Errorf("orderId is empty")
|
|
||||||
}
|
|
||||||
order, err := KlarnaInstance.GetOrder(orderId)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("X-Pod-Name", s.pod_name)
|
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return json.NewEncoder(w).Encode(order)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) HandleCheckout(w http.ResponseWriter, r *http.Request, id CartId) error {
|
|
||||||
|
|
||||||
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}",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
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)
|
|
||||||
|
|
||||||
return s.WriteResult(w, reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCartId() CartId {
|
|
||||||
id := time.Now().UnixNano() + rand.Int63()
|
|
||||||
|
|
||||||
return ToCartId(fmt.Sprintf("%d", id))
|
|
||||||
}
|
|
||||||
|
|
||||||
func CookieCartIdHandler(fn func(w http.ResponseWriter, r *http.Request, cartId CartId) error) func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
cartId = ToCartId(cartIdCookie[0].Value)
|
|
||||||
}
|
|
||||||
return fn(w, r, cartId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId CartId) error {
|
|
||||||
cartId = NewCartId()
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "cartid",
|
|
||||||
Value: cartId.String(),
|
|
||||||
Path: "/",
|
|
||||||
Secure: true,
|
|
||||||
HttpOnly: true,
|
|
||||||
Expires: time.Unix(0, 0),
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func CartIdHandler(fn func(w http.ResponseWriter, r *http.Request, cartId CartId) error) func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
cartId := ToCartId(r.PathValue("id"))
|
|
||||||
return fn(w, r, cartId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) Serve() *http.ServeMux {
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
//mux.HandleFunc("/", s.RewritePath)
|
|
||||||
mux.HandleFunc("OPTIONS /", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE")
|
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
mux.HandleFunc("GET /", ErrorHandler(CookieCartIdHandler(s.HandleGet)))
|
|
||||||
mux.HandleFunc("GET /add/{sku}", ErrorHandler(CookieCartIdHandler(s.HandleAddSku)))
|
|
||||||
mux.HandleFunc("POST /", ErrorHandler(CookieCartIdHandler(s.HandleAddRequest)))
|
|
||||||
mux.HandleFunc("POST /set", ErrorHandler(CookieCartIdHandler(s.HandleSetCartItems)))
|
|
||||||
mux.HandleFunc("DELETE /{itemId}", ErrorHandler(CookieCartIdHandler(s.HandleDeleteItem)))
|
|
||||||
mux.HandleFunc("PUT /", ErrorHandler(CookieCartIdHandler(s.HandleQuantityChange)))
|
|
||||||
mux.HandleFunc("DELETE /", ErrorHandler(CookieCartIdHandler(s.RemoveCartCookie)))
|
|
||||||
mux.HandleFunc("POST /delivery", ErrorHandler(CookieCartIdHandler(s.HandleSetDelivery)))
|
|
||||||
mux.HandleFunc("DELETE /delivery/{deliveryId}", ErrorHandler(CookieCartIdHandler(s.HandleRemoveDelivery)))
|
|
||||||
mux.HandleFunc("PUT /delivery/{deliveryId}/pickupPoint", ErrorHandler(CookieCartIdHandler(s.HandleSetPickupPoint)))
|
|
||||||
mux.HandleFunc("GET /checkout", ErrorHandler(CookieCartIdHandler(s.HandleCheckout)))
|
|
||||||
mux.HandleFunc("GET /confirmation/{orderId}", ErrorHandler(CookieCartIdHandler(s.HandleConfirmation)))
|
|
||||||
|
|
||||||
mux.HandleFunc("GET /byid/{id}", ErrorHandler(CartIdHandler(s.HandleGet)))
|
|
||||||
mux.HandleFunc("GET /byid/{id}/add/{sku}", ErrorHandler(CartIdHandler(s.HandleAddSku)))
|
|
||||||
mux.HandleFunc("POST /byid/{id}", ErrorHandler(CartIdHandler(s.HandleAddRequest)))
|
|
||||||
mux.HandleFunc("DELETE /byid/{id}/{itemId}", ErrorHandler(CartIdHandler(s.HandleDeleteItem)))
|
|
||||||
mux.HandleFunc("PUT /byid/{id}", ErrorHandler(CartIdHandler(s.HandleQuantityChange)))
|
|
||||||
mux.HandleFunc("POST /byid/{id}/delivery", ErrorHandler(CartIdHandler(s.HandleSetDelivery)))
|
|
||||||
mux.HandleFunc("DELETE /byid/{id}/delivery/{deliveryId}", ErrorHandler(CartIdHandler(s.HandleRemoveDelivery)))
|
|
||||||
mux.HandleFunc("PUT /byid/{id}/delivery/{deliveryId}/pickupPoint", ErrorHandler(CartIdHandler(s.HandleSetPickupPoint)))
|
|
||||||
mux.HandleFunc("GET /byid/{id}/checkout", ErrorHandler(CartIdHandler(s.HandleCheckout)))
|
|
||||||
mux.HandleFunc("GET /byid/{id}/confirmation", ErrorHandler(CartIdHandler(s.HandleConfirmation)))
|
|
||||||
|
|
||||||
return mux
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/matst80/slask-finder/pkg/index"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TODO make this configurable
|
|
||||||
func getBaseUrl(country string) string {
|
|
||||||
// if country == "se" {
|
|
||||||
// return "http://s10n-se:8080"
|
|
||||||
// }
|
|
||||||
if country == "no" {
|
|
||||||
return "http://s10n-no.s10n:8080"
|
|
||||||
}
|
|
||||||
if country == "se" {
|
|
||||||
return "http://s10n-se.s10n:8080"
|
|
||||||
}
|
|
||||||
return "http://localhost:8082"
|
|
||||||
}
|
|
||||||
|
|
||||||
func FetchItem(sku string, country string) (*index.DataItem, error) {
|
|
||||||
baseUrl := getBaseUrl(country)
|
|
||||||
res, err := http.Get(fmt.Sprintf("%s/api/by-sku/%s", baseUrl, sku))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer res.Body.Close()
|
|
||||||
var item index.DataItem
|
|
||||||
err = json.NewDecoder(res.Body).Decode(&item)
|
|
||||||
return &item, err
|
|
||||||
}
|
|
||||||
@@ -1,420 +0,0 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
|
||||||
// versions:
|
|
||||||
// protoc-gen-go v1.36.10
|
|
||||||
// protoc v3.21.12
|
|
||||||
// source: cart_actor.proto
|
|
||||||
|
|
||||||
package messages
|
|
||||||
|
|
||||||
import (
|
|
||||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
|
||||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
|
||||||
reflect "reflect"
|
|
||||||
sync "sync"
|
|
||||||
unsafe "unsafe"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// Verify that this generated code is sufficiently up-to-date.
|
|
||||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
|
||||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
|
||||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
|
||||||
)
|
|
||||||
|
|
||||||
// MutationType corresponds 1:1 with the legacy uint16 message type constants.
|
|
||||||
type MutationType int32
|
|
||||||
|
|
||||||
const (
|
|
||||||
MutationType_MUTATION_TYPE_UNSPECIFIED MutationType = 0
|
|
||||||
MutationType_MUTATION_ADD_REQUEST MutationType = 1
|
|
||||||
MutationType_MUTATION_ADD_ITEM MutationType = 2
|
|
||||||
// (3 was unused / reserved in legacy framing)
|
|
||||||
MutationType_MUTATION_REMOVE_ITEM MutationType = 4
|
|
||||||
MutationType_MUTATION_REMOVE_DELIVERY MutationType = 5
|
|
||||||
MutationType_MUTATION_CHANGE_QUANTITY MutationType = 6
|
|
||||||
MutationType_MUTATION_SET_DELIVERY MutationType = 7
|
|
||||||
MutationType_MUTATION_SET_PICKUP_POINT MutationType = 8
|
|
||||||
MutationType_MUTATION_CREATE_CHECKOUT_ORDER MutationType = 9
|
|
||||||
MutationType_MUTATION_SET_CART_ITEMS MutationType = 10
|
|
||||||
MutationType_MUTATION_ORDER_COMPLETED MutationType = 11
|
|
||||||
)
|
|
||||||
|
|
||||||
// Enum value maps for MutationType.
|
|
||||||
var (
|
|
||||||
MutationType_name = map[int32]string{
|
|
||||||
0: "MUTATION_TYPE_UNSPECIFIED",
|
|
||||||
1: "MUTATION_ADD_REQUEST",
|
|
||||||
2: "MUTATION_ADD_ITEM",
|
|
||||||
4: "MUTATION_REMOVE_ITEM",
|
|
||||||
5: "MUTATION_REMOVE_DELIVERY",
|
|
||||||
6: "MUTATION_CHANGE_QUANTITY",
|
|
||||||
7: "MUTATION_SET_DELIVERY",
|
|
||||||
8: "MUTATION_SET_PICKUP_POINT",
|
|
||||||
9: "MUTATION_CREATE_CHECKOUT_ORDER",
|
|
||||||
10: "MUTATION_SET_CART_ITEMS",
|
|
||||||
11: "MUTATION_ORDER_COMPLETED",
|
|
||||||
}
|
|
||||||
MutationType_value = map[string]int32{
|
|
||||||
"MUTATION_TYPE_UNSPECIFIED": 0,
|
|
||||||
"MUTATION_ADD_REQUEST": 1,
|
|
||||||
"MUTATION_ADD_ITEM": 2,
|
|
||||||
"MUTATION_REMOVE_ITEM": 4,
|
|
||||||
"MUTATION_REMOVE_DELIVERY": 5,
|
|
||||||
"MUTATION_CHANGE_QUANTITY": 6,
|
|
||||||
"MUTATION_SET_DELIVERY": 7,
|
|
||||||
"MUTATION_SET_PICKUP_POINT": 8,
|
|
||||||
"MUTATION_CREATE_CHECKOUT_ORDER": 9,
|
|
||||||
"MUTATION_SET_CART_ITEMS": 10,
|
|
||||||
"MUTATION_ORDER_COMPLETED": 11,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
func (x MutationType) Enum() *MutationType {
|
|
||||||
p := new(MutationType)
|
|
||||||
*p = x
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x MutationType) String() string {
|
|
||||||
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (MutationType) Descriptor() protoreflect.EnumDescriptor {
|
|
||||||
return file_cart_actor_proto_enumTypes[0].Descriptor()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (MutationType) Type() protoreflect.EnumType {
|
|
||||||
return &file_cart_actor_proto_enumTypes[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x MutationType) Number() protoreflect.EnumNumber {
|
|
||||||
return protoreflect.EnumNumber(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deprecated: Use MutationType.Descriptor instead.
|
|
||||||
func (MutationType) EnumDescriptor() ([]byte, []int) {
|
|
||||||
return file_cart_actor_proto_rawDescGZIP(), []int{0}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MutationRequest is an envelope:
|
|
||||||
// - cart_id: string form of CartId (legacy 16-byte array truncated/padded).
|
|
||||||
// - type: mutation kind (see enum).
|
|
||||||
// - payload: serialized underlying proto message (AddRequest, AddItem, etc.).
|
|
||||||
// - client_timestamp: optional unix timestamp; server sets if zero.
|
|
||||||
type MutationRequest struct {
|
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
|
||||||
CartId string `protobuf:"bytes,1,opt,name=cart_id,json=cartId,proto3" json:"cart_id,omitempty"`
|
|
||||||
Type MutationType `protobuf:"varint,2,opt,name=type,proto3,enum=messages.MutationType" json:"type,omitempty"`
|
|
||||||
Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"`
|
|
||||||
ClientTimestamp int64 `protobuf:"varint,4,opt,name=client_timestamp,json=clientTimestamp,proto3" json:"client_timestamp,omitempty"`
|
|
||||||
unknownFields protoimpl.UnknownFields
|
|
||||||
sizeCache protoimpl.SizeCache
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *MutationRequest) Reset() {
|
|
||||||
*x = MutationRequest{}
|
|
||||||
mi := &file_cart_actor_proto_msgTypes[0]
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *MutationRequest) String() string {
|
|
||||||
return protoimpl.X.MessageStringOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*MutationRequest) ProtoMessage() {}
|
|
||||||
|
|
||||||
func (x *MutationRequest) ProtoReflect() protoreflect.Message {
|
|
||||||
mi := &file_cart_actor_proto_msgTypes[0]
|
|
||||||
if x != nil {
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
if ms.LoadMessageInfo() == nil {
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
return ms
|
|
||||||
}
|
|
||||||
return mi.MessageOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deprecated: Use MutationRequest.ProtoReflect.Descriptor instead.
|
|
||||||
func (*MutationRequest) Descriptor() ([]byte, []int) {
|
|
||||||
return file_cart_actor_proto_rawDescGZIP(), []int{0}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *MutationRequest) GetCartId() string {
|
|
||||||
if x != nil {
|
|
||||||
return x.CartId
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *MutationRequest) GetType() MutationType {
|
|
||||||
if x != nil {
|
|
||||||
return x.Type
|
|
||||||
}
|
|
||||||
return MutationType_MUTATION_TYPE_UNSPECIFIED
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *MutationRequest) GetPayload() []byte {
|
|
||||||
if x != nil {
|
|
||||||
return x.Payload
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *MutationRequest) GetClientTimestamp() int64 {
|
|
||||||
if x != nil {
|
|
||||||
return x.ClientTimestamp
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// MutationReply returns a status code (legacy semantics) plus a JSON payload
|
|
||||||
// representing the full cart state (or an error message if non-200).
|
|
||||||
type MutationReply struct {
|
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
|
||||||
StatusCode int32 `protobuf:"varint,1,opt,name=status_code,json=statusCode,proto3" json:"status_code,omitempty"`
|
|
||||||
Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` // JSON cart state or error string
|
|
||||||
unknownFields protoimpl.UnknownFields
|
|
||||||
sizeCache protoimpl.SizeCache
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *MutationReply) Reset() {
|
|
||||||
*x = MutationReply{}
|
|
||||||
mi := &file_cart_actor_proto_msgTypes[1]
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *MutationReply) String() string {
|
|
||||||
return protoimpl.X.MessageStringOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*MutationReply) ProtoMessage() {}
|
|
||||||
|
|
||||||
func (x *MutationReply) ProtoReflect() protoreflect.Message {
|
|
||||||
mi := &file_cart_actor_proto_msgTypes[1]
|
|
||||||
if x != nil {
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
if ms.LoadMessageInfo() == nil {
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
return ms
|
|
||||||
}
|
|
||||||
return mi.MessageOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deprecated: Use MutationReply.ProtoReflect.Descriptor instead.
|
|
||||||
func (*MutationReply) Descriptor() ([]byte, []int) {
|
|
||||||
return file_cart_actor_proto_rawDescGZIP(), []int{1}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *MutationReply) GetStatusCode() int32 {
|
|
||||||
if x != nil {
|
|
||||||
return x.StatusCode
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *MutationReply) GetPayload() []byte {
|
|
||||||
if x != nil {
|
|
||||||
return x.Payload
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StateRequest fetches current cart state without mutation.
|
|
||||||
type StateRequest struct {
|
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
|
||||||
CartId string `protobuf:"bytes,1,opt,name=cart_id,json=cartId,proto3" json:"cart_id,omitempty"`
|
|
||||||
unknownFields protoimpl.UnknownFields
|
|
||||||
sizeCache protoimpl.SizeCache
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StateRequest) Reset() {
|
|
||||||
*x = StateRequest{}
|
|
||||||
mi := &file_cart_actor_proto_msgTypes[2]
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StateRequest) String() string {
|
|
||||||
return protoimpl.X.MessageStringOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*StateRequest) ProtoMessage() {}
|
|
||||||
|
|
||||||
func (x *StateRequest) ProtoReflect() protoreflect.Message {
|
|
||||||
mi := &file_cart_actor_proto_msgTypes[2]
|
|
||||||
if x != nil {
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
if ms.LoadMessageInfo() == nil {
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
return ms
|
|
||||||
}
|
|
||||||
return mi.MessageOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deprecated: Use StateRequest.ProtoReflect.Descriptor instead.
|
|
||||||
func (*StateRequest) Descriptor() ([]byte, []int) {
|
|
||||||
return file_cart_actor_proto_rawDescGZIP(), []int{2}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StateRequest) GetCartId() string {
|
|
||||||
if x != nil {
|
|
||||||
return x.CartId
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// StateReply mirrors MutationReply for consistency.
|
|
||||||
type StateReply struct {
|
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
|
||||||
StatusCode int32 `protobuf:"varint,1,opt,name=status_code,json=statusCode,proto3" json:"status_code,omitempty"`
|
|
||||||
Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` // JSON cart state or error string
|
|
||||||
unknownFields protoimpl.UnknownFields
|
|
||||||
sizeCache protoimpl.SizeCache
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StateReply) Reset() {
|
|
||||||
*x = StateReply{}
|
|
||||||
mi := &file_cart_actor_proto_msgTypes[3]
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StateReply) String() string {
|
|
||||||
return protoimpl.X.MessageStringOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (*StateReply) ProtoMessage() {}
|
|
||||||
|
|
||||||
func (x *StateReply) ProtoReflect() protoreflect.Message {
|
|
||||||
mi := &file_cart_actor_proto_msgTypes[3]
|
|
||||||
if x != nil {
|
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
|
||||||
if ms.LoadMessageInfo() == nil {
|
|
||||||
ms.StoreMessageInfo(mi)
|
|
||||||
}
|
|
||||||
return ms
|
|
||||||
}
|
|
||||||
return mi.MessageOf(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deprecated: Use StateReply.ProtoReflect.Descriptor instead.
|
|
||||||
func (*StateReply) Descriptor() ([]byte, []int) {
|
|
||||||
return file_cart_actor_proto_rawDescGZIP(), []int{3}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StateReply) GetStatusCode() int32 {
|
|
||||||
if x != nil {
|
|
||||||
return x.StatusCode
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (x *StateReply) GetPayload() []byte {
|
|
||||||
if x != nil {
|
|
||||||
return x.Payload
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var File_cart_actor_proto protoreflect.FileDescriptor
|
|
||||||
|
|
||||||
const file_cart_actor_proto_rawDesc = "" +
|
|
||||||
"\n" +
|
|
||||||
"\x10cart_actor.proto\x12\bmessages\"\x9b\x01\n" +
|
|
||||||
"\x0fMutationRequest\x12\x17\n" +
|
|
||||||
"\acart_id\x18\x01 \x01(\tR\x06cartId\x12*\n" +
|
|
||||||
"\x04type\x18\x02 \x01(\x0e2\x16.messages.MutationTypeR\x04type\x12\x18\n" +
|
|
||||||
"\apayload\x18\x03 \x01(\fR\apayload\x12)\n" +
|
|
||||||
"\x10client_timestamp\x18\x04 \x01(\x03R\x0fclientTimestamp\"J\n" +
|
|
||||||
"\rMutationReply\x12\x1f\n" +
|
|
||||||
"\vstatus_code\x18\x01 \x01(\x05R\n" +
|
|
||||||
"statusCode\x12\x18\n" +
|
|
||||||
"\apayload\x18\x02 \x01(\fR\apayload\"'\n" +
|
|
||||||
"\fStateRequest\x12\x17\n" +
|
|
||||||
"\acart_id\x18\x01 \x01(\tR\x06cartId\"G\n" +
|
|
||||||
"\n" +
|
|
||||||
"StateReply\x12\x1f\n" +
|
|
||||||
"\vstatus_code\x18\x01 \x01(\x05R\n" +
|
|
||||||
"statusCode\x12\x18\n" +
|
|
||||||
"\apayload\x18\x02 \x01(\fR\apayload*\xcd\x02\n" +
|
|
||||||
"\fMutationType\x12\x1d\n" +
|
|
||||||
"\x19MUTATION_TYPE_UNSPECIFIED\x10\x00\x12\x18\n" +
|
|
||||||
"\x14MUTATION_ADD_REQUEST\x10\x01\x12\x15\n" +
|
|
||||||
"\x11MUTATION_ADD_ITEM\x10\x02\x12\x18\n" +
|
|
||||||
"\x14MUTATION_REMOVE_ITEM\x10\x04\x12\x1c\n" +
|
|
||||||
"\x18MUTATION_REMOVE_DELIVERY\x10\x05\x12\x1c\n" +
|
|
||||||
"\x18MUTATION_CHANGE_QUANTITY\x10\x06\x12\x19\n" +
|
|
||||||
"\x15MUTATION_SET_DELIVERY\x10\a\x12\x1d\n" +
|
|
||||||
"\x19MUTATION_SET_PICKUP_POINT\x10\b\x12\"\n" +
|
|
||||||
"\x1eMUTATION_CREATE_CHECKOUT_ORDER\x10\t\x12\x1b\n" +
|
|
||||||
"\x17MUTATION_SET_CART_ITEMS\x10\n" +
|
|
||||||
"\x12\x1c\n" +
|
|
||||||
"\x18MUTATION_ORDER_COMPLETED\x10\v2\x83\x01\n" +
|
|
||||||
"\tCartActor\x12<\n" +
|
|
||||||
"\x06Mutate\x12\x19.messages.MutationRequest\x1a\x17.messages.MutationReply\x128\n" +
|
|
||||||
"\bGetState\x12\x16.messages.StateRequest\x1a\x14.messages.StateReplyB\fZ\n" +
|
|
||||||
".;messagesb\x06proto3"
|
|
||||||
|
|
||||||
var (
|
|
||||||
file_cart_actor_proto_rawDescOnce sync.Once
|
|
||||||
file_cart_actor_proto_rawDescData []byte
|
|
||||||
)
|
|
||||||
|
|
||||||
func file_cart_actor_proto_rawDescGZIP() []byte {
|
|
||||||
file_cart_actor_proto_rawDescOnce.Do(func() {
|
|
||||||
file_cart_actor_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_cart_actor_proto_rawDesc), len(file_cart_actor_proto_rawDesc)))
|
|
||||||
})
|
|
||||||
return file_cart_actor_proto_rawDescData
|
|
||||||
}
|
|
||||||
|
|
||||||
var file_cart_actor_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
|
||||||
var file_cart_actor_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
|
|
||||||
var file_cart_actor_proto_goTypes = []any{
|
|
||||||
(MutationType)(0), // 0: messages.MutationType
|
|
||||||
(*MutationRequest)(nil), // 1: messages.MutationRequest
|
|
||||||
(*MutationReply)(nil), // 2: messages.MutationReply
|
|
||||||
(*StateRequest)(nil), // 3: messages.StateRequest
|
|
||||||
(*StateReply)(nil), // 4: messages.StateReply
|
|
||||||
}
|
|
||||||
var file_cart_actor_proto_depIdxs = []int32{
|
|
||||||
0, // 0: messages.MutationRequest.type:type_name -> messages.MutationType
|
|
||||||
1, // 1: messages.CartActor.Mutate:input_type -> messages.MutationRequest
|
|
||||||
3, // 2: messages.CartActor.GetState:input_type -> messages.StateRequest
|
|
||||||
2, // 3: messages.CartActor.Mutate:output_type -> messages.MutationReply
|
|
||||||
4, // 4: messages.CartActor.GetState:output_type -> messages.StateReply
|
|
||||||
3, // [3:5] is the sub-list for method output_type
|
|
||||||
1, // [1:3] is the sub-list for method input_type
|
|
||||||
1, // [1:1] is the sub-list for extension type_name
|
|
||||||
1, // [1:1] is the sub-list for extension extendee
|
|
||||||
0, // [0:1] is the sub-list for field type_name
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() { file_cart_actor_proto_init() }
|
|
||||||
func file_cart_actor_proto_init() {
|
|
||||||
if File_cart_actor_proto != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
type x struct{}
|
|
||||||
out := protoimpl.TypeBuilder{
|
|
||||||
File: protoimpl.DescBuilder{
|
|
||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
|
||||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_cart_actor_proto_rawDesc), len(file_cart_actor_proto_rawDesc)),
|
|
||||||
NumEnums: 1,
|
|
||||||
NumMessages: 4,
|
|
||||||
NumExtensions: 0,
|
|
||||||
NumServices: 1,
|
|
||||||
},
|
|
||||||
GoTypes: file_cart_actor_proto_goTypes,
|
|
||||||
DependencyIndexes: file_cart_actor_proto_depIdxs,
|
|
||||||
EnumInfos: file_cart_actor_proto_enumTypes,
|
|
||||||
MessageInfos: file_cart_actor_proto_msgTypes,
|
|
||||||
}.Build()
|
|
||||||
File_cart_actor_proto = out.File
|
|
||||||
file_cart_actor_proto_goTypes = nil
|
|
||||||
file_cart_actor_proto_depIdxs = nil
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
syntax = "proto3";
|
|
||||||
|
|
||||||
package messages;
|
|
||||||
|
|
||||||
option go_package = ".;messages";
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Cart Actor gRPC API (Envelope Variant)
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// This service replaces the legacy custom TCP frame protocol used on port 1337.
|
|
||||||
// It keeps the existing per-mutation proto messages (defined in messages.proto)
|
|
||||||
// serialized into an opaque `bytes payload` field for minimal refactor cost.
|
|
||||||
// The numeric values in MutationType MUST match the legacy message type
|
|
||||||
// constants (see message-types.go) so persisted event logs replay correctly.
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// MutationType corresponds 1:1 with the legacy uint16 message type constants.
|
|
||||||
enum MutationType {
|
|
||||||
MUTATION_TYPE_UNSPECIFIED = 0;
|
|
||||||
MUTATION_ADD_REQUEST = 1;
|
|
||||||
MUTATION_ADD_ITEM = 2;
|
|
||||||
// (3 was unused / reserved in legacy framing)
|
|
||||||
MUTATION_REMOVE_ITEM = 4;
|
|
||||||
MUTATION_REMOVE_DELIVERY = 5;
|
|
||||||
MUTATION_CHANGE_QUANTITY = 6;
|
|
||||||
MUTATION_SET_DELIVERY = 7;
|
|
||||||
MUTATION_SET_PICKUP_POINT = 8;
|
|
||||||
MUTATION_CREATE_CHECKOUT_ORDER = 9;
|
|
||||||
MUTATION_SET_CART_ITEMS = 10;
|
|
||||||
MUTATION_ORDER_COMPLETED = 11;
|
|
||||||
}
|
|
||||||
|
|
||||||
// MutationRequest is an envelope:
|
|
||||||
// - cart_id: string form of CartId (legacy 16-byte array truncated/padded).
|
|
||||||
// - type: mutation kind (see enum).
|
|
||||||
// - payload: serialized underlying proto message (AddRequest, AddItem, etc.).
|
|
||||||
// - client_timestamp: optional unix timestamp; server sets if zero.
|
|
||||||
message MutationRequest {
|
|
||||||
string cart_id = 1;
|
|
||||||
MutationType type = 2;
|
|
||||||
bytes payload = 3;
|
|
||||||
int64 client_timestamp = 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
// MutationReply returns a status code (legacy semantics) plus a JSON payload
|
|
||||||
// representing the full cart state (or an error message if non-200).
|
|
||||||
message MutationReply {
|
|
||||||
int32 status_code = 1;
|
|
||||||
bytes payload = 2; // JSON cart state or error string
|
|
||||||
}
|
|
||||||
|
|
||||||
// StateRequest fetches current cart state without mutation.
|
|
||||||
message StateRequest {
|
|
||||||
string cart_id = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// StateReply mirrors MutationReply for consistency.
|
|
||||||
message StateReply {
|
|
||||||
int32 status_code = 1;
|
|
||||||
bytes payload = 2; // JSON cart state or error string
|
|
||||||
}
|
|
||||||
|
|
||||||
// CartActor exposes mutation and state retrieval for remote grains.
|
|
||||||
service CartActor {
|
|
||||||
// Mutate applies a single mutation to a cart, creating the cart lazily if needed.
|
|
||||||
rpc Mutate(MutationRequest) returns (MutationReply);
|
|
||||||
|
|
||||||
// GetState retrieves the cart's current state (JSON).
|
|
||||||
rpc GetState(StateRequest) returns (StateReply);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Notes:
|
|
||||||
//
|
|
||||||
// 1. Generation:
|
|
||||||
// protoc --go_out=. --go_opt=paths=source_relative \
|
|
||||||
// --go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
|
||||||
// cart_actor.proto
|
|
||||||
//
|
|
||||||
// 2. Underlying mutation payloads originate from messages.proto definitions.
|
|
||||||
// The server side will route based on MutationType and decode payload bytes
|
|
||||||
// using existing handler registry logic.
|
|
||||||
//
|
|
||||||
// 3. Future Enhancements:
|
|
||||||
// - Replace JSON state payload with a strongly typed CartState proto.
|
|
||||||
// - Add streaming RPC (e.g. WatchState) for live updates.
|
|
||||||
// - Migrate control plane (negotiate/ownership) into a separate proto
|
|
||||||
// (control_plane.proto) as per the migration plan.
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
|
||||||
// versions:
|
|
||||||
// - protoc-gen-go-grpc v1.5.1
|
|
||||||
// - protoc v3.21.12
|
|
||||||
// source: cart_actor.proto
|
|
||||||
|
|
||||||
package messages
|
|
||||||
|
|
||||||
import (
|
|
||||||
context "context"
|
|
||||||
grpc "google.golang.org/grpc"
|
|
||||||
codes "google.golang.org/grpc/codes"
|
|
||||||
status "google.golang.org/grpc/status"
|
|
||||||
)
|
|
||||||
|
|
||||||
// This is a compile-time assertion to ensure that this generated file
|
|
||||||
// is compatible with the grpc package it is being compiled against.
|
|
||||||
// Requires gRPC-Go v1.64.0 or later.
|
|
||||||
const _ = grpc.SupportPackageIsVersion9
|
|
||||||
|
|
||||||
const (
|
|
||||||
CartActor_Mutate_FullMethodName = "/messages.CartActor/Mutate"
|
|
||||||
CartActor_GetState_FullMethodName = "/messages.CartActor/GetState"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CartActorClient is the client API for CartActor service.
|
|
||||||
//
|
|
||||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
|
||||||
//
|
|
||||||
// CartActor exposes mutation and state retrieval for remote grains.
|
|
||||||
type CartActorClient interface {
|
|
||||||
// Mutate applies a single mutation to a cart, creating the cart lazily if needed.
|
|
||||||
Mutate(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*MutationReply, error)
|
|
||||||
// GetState retrieves the cart's current state (JSON).
|
|
||||||
GetState(ctx context.Context, in *StateRequest, opts ...grpc.CallOption) (*StateReply, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type cartActorClient struct {
|
|
||||||
cc grpc.ClientConnInterface
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCartActorClient(cc grpc.ClientConnInterface) CartActorClient {
|
|
||||||
return &cartActorClient{cc}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cartActorClient) Mutate(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*MutationReply, error) {
|
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
|
||||||
out := new(MutationReply)
|
|
||||||
err := c.cc.Invoke(ctx, CartActor_Mutate_FullMethodName, in, out, cOpts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cartActorClient) GetState(ctx context.Context, in *StateRequest, opts ...grpc.CallOption) (*StateReply, error) {
|
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
|
||||||
out := new(StateReply)
|
|
||||||
err := c.cc.Invoke(ctx, CartActor_GetState_FullMethodName, in, out, cOpts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CartActorServer is the server API for CartActor service.
|
|
||||||
// All implementations must embed UnimplementedCartActorServer
|
|
||||||
// for forward compatibility.
|
|
||||||
//
|
|
||||||
// CartActor exposes mutation and state retrieval for remote grains.
|
|
||||||
type CartActorServer interface {
|
|
||||||
// Mutate applies a single mutation to a cart, creating the cart lazily if needed.
|
|
||||||
Mutate(context.Context, *MutationRequest) (*MutationReply, error)
|
|
||||||
// GetState retrieves the cart's current state (JSON).
|
|
||||||
GetState(context.Context, *StateRequest) (*StateReply, error)
|
|
||||||
mustEmbedUnimplementedCartActorServer()
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnimplementedCartActorServer must be embedded to have
|
|
||||||
// forward compatible implementations.
|
|
||||||
//
|
|
||||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
|
||||||
// pointer dereference when methods are called.
|
|
||||||
type UnimplementedCartActorServer struct{}
|
|
||||||
|
|
||||||
func (UnimplementedCartActorServer) Mutate(context.Context, *MutationRequest) (*MutationReply, error) {
|
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method Mutate not implemented")
|
|
||||||
}
|
|
||||||
func (UnimplementedCartActorServer) GetState(context.Context, *StateRequest) (*StateReply, error) {
|
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method GetState not implemented")
|
|
||||||
}
|
|
||||||
func (UnimplementedCartActorServer) mustEmbedUnimplementedCartActorServer() {}
|
|
||||||
func (UnimplementedCartActorServer) testEmbeddedByValue() {}
|
|
||||||
|
|
||||||
// UnsafeCartActorServer may be embedded to opt out of forward compatibility for this service.
|
|
||||||
// Use of this interface is not recommended, as added methods to CartActorServer will
|
|
||||||
// result in compilation errors.
|
|
||||||
type UnsafeCartActorServer interface {
|
|
||||||
mustEmbedUnimplementedCartActorServer()
|
|
||||||
}
|
|
||||||
|
|
||||||
func RegisterCartActorServer(s grpc.ServiceRegistrar, srv CartActorServer) {
|
|
||||||
// If the following call pancis, it indicates UnimplementedCartActorServer was
|
|
||||||
// embedded by pointer and is nil. This will cause panics if an
|
|
||||||
// unimplemented method is ever invoked, so we test this at initialization
|
|
||||||
// time to prevent it from happening at runtime later due to I/O.
|
|
||||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
|
||||||
t.testEmbeddedByValue()
|
|
||||||
}
|
|
||||||
s.RegisterService(&CartActor_ServiceDesc, srv)
|
|
||||||
}
|
|
||||||
|
|
||||||
func _CartActor_Mutate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
|
||||||
in := new(MutationRequest)
|
|
||||||
if err := dec(in); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if interceptor == nil {
|
|
||||||
return srv.(CartActorServer).Mutate(ctx, in)
|
|
||||||
}
|
|
||||||
info := &grpc.UnaryServerInfo{
|
|
||||||
Server: srv,
|
|
||||||
FullMethod: CartActor_Mutate_FullMethodName,
|
|
||||||
}
|
|
||||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
|
||||||
return srv.(CartActorServer).Mutate(ctx, req.(*MutationRequest))
|
|
||||||
}
|
|
||||||
return interceptor(ctx, in, info, handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
func _CartActor_GetState_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
|
||||||
in := new(StateRequest)
|
|
||||||
if err := dec(in); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if interceptor == nil {
|
|
||||||
return srv.(CartActorServer).GetState(ctx, in)
|
|
||||||
}
|
|
||||||
info := &grpc.UnaryServerInfo{
|
|
||||||
Server: srv,
|
|
||||||
FullMethod: CartActor_GetState_FullMethodName,
|
|
||||||
}
|
|
||||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
|
||||||
return srv.(CartActorServer).GetState(ctx, req.(*StateRequest))
|
|
||||||
}
|
|
||||||
return interceptor(ctx, in, info, handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CartActor_ServiceDesc is the grpc.ServiceDesc for CartActor service.
|
|
||||||
// It's only intended for direct use with grpc.RegisterService,
|
|
||||||
// and not to be introspected or modified (even as a copy)
|
|
||||||
var CartActor_ServiceDesc = grpc.ServiceDesc{
|
|
||||||
ServiceName: "messages.CartActor",
|
|
||||||
HandlerType: (*CartActorServer)(nil),
|
|
||||||
Methods: []grpc.MethodDesc{
|
|
||||||
{
|
|
||||||
MethodName: "Mutate",
|
|
||||||
Handler: _CartActor_Mutate_Handler,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
MethodName: "GetState",
|
|
||||||
Handler: _CartActor_GetState_Handler,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Streams: []grpc.StreamDesc{},
|
|
||||||
Metadata: "cart_actor.proto",
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ syntax = "proto3";
|
|||||||
|
|
||||||
package messages;
|
package messages;
|
||||||
|
|
||||||
option go_package = ".;messages";
|
option go_package = "git.tornberg.me/go-cart-actor/proto;messages";
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Control Plane gRPC API
|
// Control Plane gRPC API
|
||||||
@@ -11,8 +11,8 @@ option go_package = ".;messages";
|
|||||||
// Responsibilities:
|
// Responsibilities:
|
||||||
// - Liveness (Ping)
|
// - Liveness (Ping)
|
||||||
// - Membership negotiation (Negotiate)
|
// - Membership negotiation (Negotiate)
|
||||||
// - Cart ownership change propagation (ConfirmOwner)
|
// - Deterministic ring-based ownership (ConfirmOwner RPC removed)
|
||||||
// - Cart ID listing for remote grain spawning (GetCartIds)
|
// - Actor ID listing for remote grain spawning (GetActorIds)
|
||||||
// - Graceful shutdown notifications (Closing)
|
// - Graceful shutdown notifications (Closing)
|
||||||
// No authentication / TLS is defined initially (can be added later).
|
// No authentication / TLS is defined initially (can be added later).
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -37,17 +37,11 @@ message NegotiateReply {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CartIdsReply returns the list of cart IDs (string form) currently owned locally.
|
// CartIdsReply returns the list of cart IDs (string form) currently owned locally.
|
||||||
message CartIdsReply {
|
message ActorIdsReply {
|
||||||
repeated string cart_ids = 1;
|
repeated uint64 ids = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// OwnerChangeRequest notifies peers that ownership of a cart moved (or is moving) to new_host.
|
// OwnerChangeAck retained as response type for Closing RPC (ConfirmOwner removed).
|
||||||
message OwnerChangeRequest {
|
|
||||||
string cart_id = 1;
|
|
||||||
string new_host = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
// OwnerChangeAck indicates acceptance or rejection of an ownership change.
|
|
||||||
message OwnerChangeAck {
|
message OwnerChangeAck {
|
||||||
bool accepted = 1;
|
bool accepted = 1;
|
||||||
string message = 2;
|
string message = 2;
|
||||||
@@ -58,6 +52,19 @@ message ClosingNotice {
|
|||||||
string host = 1;
|
string host = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OwnershipAnnounce broadcasts first-touch ownership claims for cart IDs.
|
||||||
|
// First claim wins; receivers SHOULD NOT overwrite an existing different owner.
|
||||||
|
message OwnershipAnnounce {
|
||||||
|
string host = 1; // announcing host
|
||||||
|
repeated uint64 ids = 2; // newly claimed cart ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpiryAnnounce broadcasts that a host evicted the provided cart IDs.
|
||||||
|
message ExpiryAnnounce {
|
||||||
|
string host = 1;
|
||||||
|
repeated uint64 ids = 2;
|
||||||
|
}
|
||||||
|
|
||||||
// ControlPlane defines cluster coordination and ownership operations.
|
// ControlPlane defines cluster coordination and ownership operations.
|
||||||
service ControlPlane {
|
service ControlPlane {
|
||||||
// Ping for liveness; lightweight health signal.
|
// Ping for liveness; lightweight health signal.
|
||||||
@@ -67,10 +74,15 @@ service ControlPlane {
|
|||||||
rpc Negotiate(NegotiateRequest) returns (NegotiateReply);
|
rpc Negotiate(NegotiateRequest) returns (NegotiateReply);
|
||||||
|
|
||||||
// GetCartIds lists currently owned cart IDs on this node.
|
// GetCartIds lists currently owned cart IDs on this node.
|
||||||
rpc GetCartIds(Empty) returns (CartIdsReply);
|
rpc GetLocalActorIds(Empty) returns (ActorIdsReply);
|
||||||
|
|
||||||
// ConfirmOwner announces/asks peers to acknowledge ownership transfer.
|
// ConfirmOwner RPC removed (was legacy ownership acknowledgement; ring-based ownership now authoritative)
|
||||||
rpc ConfirmOwner(OwnerChangeRequest) returns (OwnerChangeAck);
|
|
||||||
|
// Ownership announcement: first-touch claim broadcast (idempotent; best-effort).
|
||||||
|
rpc AnnounceOwnership(OwnershipAnnounce) returns (OwnerChangeAck);
|
||||||
|
|
||||||
|
// Expiry announcement: drop remote ownership hints when local TTL expires.
|
||||||
|
rpc AnnounceExpiry(ExpiryAnnounce) returns (OwnerChangeAck);
|
||||||
|
|
||||||
// Closing announces graceful shutdown so peers can proactively adjust.
|
// Closing announces graceful shutdown so peers can proactively adjust.
|
||||||
rpc Closing(ClosingNotice) returns (OwnerChangeAck);
|
rpc Closing(ClosingNotice) returns (OwnerChangeAck);
|
||||||
|
|||||||
1066
proto/messages.pb.go
1066
proto/messages.pb.go
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,13 @@
|
|||||||
syntax = "proto3";
|
syntax = "proto3";
|
||||||
package messages;
|
package messages;
|
||||||
option go_package = ".;messages";
|
option go_package = "git.tornberg.me/go-cart-actor/proto;messages";
|
||||||
|
|
||||||
message AddRequest {
|
message ClearCartRequest {
|
||||||
int32 quantity = 1;
|
|
||||||
string sku = 2;
|
|
||||||
string country = 3;
|
|
||||||
optional string storeId = 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
message SetCartRequest {
|
|
||||||
repeated AddRequest items = 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message AddItem {
|
message AddItem {
|
||||||
int64 item_id = 1;
|
uint32 item_id = 1;
|
||||||
int32 quantity = 2;
|
int32 quantity = 2;
|
||||||
int64 price = 3;
|
int64 price = 3;
|
||||||
int64 orgPrice = 9;
|
int64 orgPrice = 9;
|
||||||
@@ -36,20 +29,21 @@ message AddItem {
|
|||||||
string country = 21;
|
string country = 21;
|
||||||
optional string outlet = 12;
|
optional string outlet = 12;
|
||||||
optional string storeId = 22;
|
optional string storeId = 22;
|
||||||
|
optional uint32 parentId = 23;
|
||||||
}
|
}
|
||||||
|
|
||||||
message RemoveItem {
|
message RemoveItem {
|
||||||
int64 Id = 1;
|
uint32 Id = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ChangeQuantity {
|
message ChangeQuantity {
|
||||||
int64 id = 1;
|
uint32 Id = 1;
|
||||||
int32 quantity = 2;
|
int32 quantity = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message SetDelivery {
|
message SetDelivery {
|
||||||
string provider = 1;
|
string provider = 1;
|
||||||
repeated int64 items = 2;
|
repeated uint32 items = 2;
|
||||||
optional PickupPoint pickupPoint = 3;
|
optional PickupPoint pickupPoint = 3;
|
||||||
string country = 4;
|
string country = 4;
|
||||||
string zip = 5;
|
string zip = 5;
|
||||||
@@ -58,7 +52,7 @@ message SetDelivery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message SetPickupPoint {
|
message SetPickupPoint {
|
||||||
int64 deliveryId = 1;
|
uint32 deliveryId = 1;
|
||||||
string id = 2;
|
string id = 2;
|
||||||
optional string name = 3;
|
optional string name = 3;
|
||||||
optional string address = 4;
|
optional string address = 4;
|
||||||
@@ -77,7 +71,7 @@ message PickupPoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message RemoveDelivery {
|
message RemoveDelivery {
|
||||||
int64 id = 1;
|
uint32 id = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
message CreateCheckoutOrder {
|
message CreateCheckoutOrder {
|
||||||
@@ -93,3 +87,30 @@ message OrderCreated {
|
|||||||
string orderId = 1;
|
string orderId = 1;
|
||||||
string status = 2;
|
string status = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message Noop {
|
||||||
|
// Intentionally empty - used for ownership acquisition or health pings
|
||||||
|
}
|
||||||
|
|
||||||
|
message InitializeCheckout {
|
||||||
|
string orderId = 1;
|
||||||
|
string status = 2;
|
||||||
|
bool paymentInProgress = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message VoucherRule {
|
||||||
|
string type = 2;
|
||||||
|
string description = 3;
|
||||||
|
string condition = 4;
|
||||||
|
string action = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AddVoucher {
|
||||||
|
string code = 1;
|
||||||
|
int64 value = 2;
|
||||||
|
repeated VoucherRule voucherRules = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RemoveVoucher {
|
||||||
|
uint32 id = 1;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,147 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
proto "git.tornberg.me/go-cart-actor/proto" // generated package name is 'messages'; aliased as proto for consistency
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RemoteGrainGRPC is the gRPC-backed implementation of a remote grain.
|
|
||||||
// It mirrors the previous RemoteGrain (TCP/frame based) while using the
|
|
||||||
// new CartActor gRPC service. It implements the Grain interface so that
|
|
||||||
// SyncedPool can remain largely unchanged when swapping transport layers.
|
|
||||||
type RemoteGrainGRPC struct {
|
|
||||||
Id CartId
|
|
||||||
Host string
|
|
||||||
client proto.CartActorClient
|
|
||||||
// Optional: keep the underlying conn so higher-level code can close if needed
|
|
||||||
conn *grpc.ClientConn
|
|
||||||
|
|
||||||
// Per-call timeout settings (tunable)
|
|
||||||
mutateTimeout time.Duration
|
|
||||||
stateTimeout time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRemoteGrainGRPC constructs a remote grain adapter from an existing gRPC client.
|
|
||||||
func NewRemoteGrainGRPC(id CartId, host string, client proto.CartActorClient) *RemoteGrainGRPC {
|
|
||||||
return &RemoteGrainGRPC{
|
|
||||||
Id: id,
|
|
||||||
Host: host,
|
|
||||||
client: client,
|
|
||||||
mutateTimeout: 800 * time.Millisecond,
|
|
||||||
stateTimeout: 400 * time.Millisecond,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRemoteGrainGRPCWithConn dials the target and creates the gRPC client.
|
|
||||||
// target should be host:port (where the CartActor service is exposed).
|
|
||||||
func NewRemoteGrainGRPCWithConn(id CartId, host string, target string, dialOpts ...grpc.DialOption) (*RemoteGrainGRPC, error) {
|
|
||||||
// NOTE: insecure for initial migration; should be replaced with TLS later.
|
|
||||||
baseOpts := []grpc.DialOption{grpc.WithInsecure(), grpc.WithBlock()}
|
|
||||||
baseOpts = append(baseOpts, dialOpts...)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
conn, err := grpc.DialContext(ctx, target, baseOpts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
client := proto.NewCartActorClient(conn)
|
|
||||||
return &RemoteGrainGRPC{
|
|
||||||
Id: id,
|
|
||||||
Host: host,
|
|
||||||
client: client,
|
|
||||||
conn: conn,
|
|
||||||
mutateTimeout: 800 * time.Millisecond,
|
|
||||||
stateTimeout: 400 * time.Millisecond,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *RemoteGrainGRPC) GetId() CartId {
|
|
||||||
return g.Id
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleMessage serializes the underlying mutation proto (without legacy message header)
|
|
||||||
// and invokes the CartActor.Mutate RPC. It wraps the reply into a FrameWithPayload
|
|
||||||
// for compatibility with existing higher-level code paths.
|
|
||||||
func (g *RemoteGrainGRPC) HandleMessage(message *Message, isReplay bool) (*FrameWithPayload, error) {
|
|
||||||
if message == nil {
|
|
||||||
return nil, fmt.Errorf("nil message")
|
|
||||||
}
|
|
||||||
if isReplay {
|
|
||||||
// Remote replay not expected; ignore to keep parity with old implementation.
|
|
||||||
return nil, fmt.Errorf("replay not supported for remote grains")
|
|
||||||
}
|
|
||||||
|
|
||||||
handler, err := GetMessageHandler(message.Type)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure timestamp set (legacy behavior)
|
|
||||||
if message.TimeStamp == nil {
|
|
||||||
ts := time.Now().Unix()
|
|
||||||
message.TimeStamp = &ts
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marshal underlying proto payload only (no StorableMessageHeader)
|
|
||||||
var buf bytes.Buffer
|
|
||||||
err = handler.Write(message, &buf)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("encode mutation payload: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req := &proto.MutationRequest{
|
|
||||||
CartId: g.Id.String(),
|
|
||||||
Type: proto.MutationType(message.Type), // numeric mapping preserved
|
|
||||||
Payload: buf.Bytes(),
|
|
||||||
ClientTimestamp: *message.TimeStamp,
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), g.mutateTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
resp, err := g.client.Mutate(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
frame := MakeFrameWithPayload(RemoteHandleMutationReply, StatusCode(resp.StatusCode), resp.Payload)
|
|
||||||
return &frame, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCurrentState calls CartActor.GetState and returns a FrameWithPayload
|
|
||||||
// shaped like the legacy RemoteGetStateReply.
|
|
||||||
func (g *RemoteGrainGRPC) GetCurrentState() (*FrameWithPayload, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), g.stateTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
resp, err := g.client.GetState(ctx, &proto.StateRequest{
|
|
||||||
CartId: g.Id.String(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
frame := MakeFrameWithPayload(RemoteGetStateReply, StatusCode(resp.StatusCode), resp.Payload)
|
|
||||||
return &frame, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close closes the underlying gRPC connection if this adapter created it.
|
|
||||||
func (g *RemoteGrainGRPC) Close() error {
|
|
||||||
if g.conn != nil {
|
|
||||||
return g.conn.Close()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug helper to log operations (optional).
|
|
||||||
func (g *RemoteGrainGRPC) logf(format string, args ...interface{}) {
|
|
||||||
log.Printf("[remote-grain-grpc host=%s id=%s] %s", g.Host, g.Id.String(), fmt.Sprintf(format, args...))
|
|
||||||
}
|
|
||||||
473
synced-pool.go
473
synced-pool.go
@@ -1,473 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
proto "git.tornberg.me/go-cart-actor/proto"
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
"k8s.io/apimachinery/pkg/watch"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SyncedPool coordinates cart grain ownership across nodes using gRPC control plane
|
|
||||||
// and cart actor services. Legacy frame / TCP code has been removed.
|
|
||||||
//
|
|
||||||
// Responsibilities:
|
|
||||||
// - Local grain access (delegates to GrainLocalPool)
|
|
||||||
// - Remote grain proxy management (RemoteGrainGRPC)
|
|
||||||
// - Cluster membership (AddRemote via discovery + negotiation)
|
|
||||||
// - Ownership acquisition (quorum via ConfirmOwner RPC)
|
|
||||||
// - Health/ping monitoring & remote removal
|
|
||||||
//
|
|
||||||
// Thread-safety: public methods that mutate internal maps lock p.mu (RWMutex).
|
|
||||||
type SyncedPool struct {
|
|
||||||
Hostname string
|
|
||||||
local *GrainLocalPool
|
|
||||||
|
|
||||||
mu sync.RWMutex
|
|
||||||
|
|
||||||
// Remote host state (gRPC only)
|
|
||||||
remoteHosts map[string]*RemoteHostGRPC // host -> remote host
|
|
||||||
|
|
||||||
// Remote grain proxies (by cart id)
|
|
||||||
remoteIndex map[CartId]Grain
|
|
||||||
|
|
||||||
// Discovery handler for re-adding hosts after failures
|
|
||||||
discardedHostHandler *DiscardedHostHandler
|
|
||||||
|
|
||||||
// Metrics / instrumentation dependencies already declared globally
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoteHostGRPC tracks a remote host's clients & health.
|
|
||||||
type RemoteHostGRPC struct {
|
|
||||||
Host string
|
|
||||||
Conn *grpc.ClientConn
|
|
||||||
CartClient proto.CartActorClient
|
|
||||||
ControlClient proto.ControlPlaneClient
|
|
||||||
MissedPings int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RemoteHostGRPC) IsHealthy() bool {
|
|
||||||
return r.MissedPings < 3
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
negotiationCount = promauto.NewCounter(prometheus.CounterOpts{
|
|
||||||
Name: "cart_remote_negotiation_total",
|
|
||||||
Help: "The total number of remote negotiations",
|
|
||||||
})
|
|
||||||
grainSyncCount = promauto.NewCounter(prometheus.CounterOpts{
|
|
||||||
Name: "cart_grain_sync_total",
|
|
||||||
Help: "The total number of grain owner changes",
|
|
||||||
})
|
|
||||||
connectedRemotes = promauto.NewGauge(prometheus.GaugeOpts{
|
|
||||||
Name: "cart_connected_remotes",
|
|
||||||
Help: "The number of connected remotes",
|
|
||||||
})
|
|
||||||
remoteLookupCount = promauto.NewCounter(prometheus.CounterOpts{
|
|
||||||
Name: "cart_remote_lookup_total",
|
|
||||||
Help: "The total number of remote lookups",
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewSyncedPool(local *GrainLocalPool, hostname string, discovery Discovery) (*SyncedPool, error) {
|
|
||||||
p := &SyncedPool{
|
|
||||||
Hostname: hostname,
|
|
||||||
local: local,
|
|
||||||
remoteHosts: make(map[string]*RemoteHostGRPC),
|
|
||||||
remoteIndex: make(map[CartId]Grain),
|
|
||||||
discardedHostHandler: NewDiscardedHostHandler(1338),
|
|
||||||
}
|
|
||||||
p.discardedHostHandler.SetReconnectHandler(p.AddRemote)
|
|
||||||
|
|
||||||
if discovery != nil {
|
|
||||||
go func() {
|
|
||||||
time.Sleep(3 * time.Second) // allow gRPC server startup
|
|
||||||
log.Printf("Starting discovery watcher")
|
|
||||||
ch, err := discovery.Watch()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Discovery error: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for evt := range ch {
|
|
||||||
if evt.Host == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch evt.Type {
|
|
||||||
case watch.Deleted:
|
|
||||||
if p.IsKnown(evt.Host) {
|
|
||||||
p.RemoveHost(evt.Host)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
if !p.IsKnown(evt.Host) {
|
|
||||||
log.Printf("Discovered host %s", evt.Host)
|
|
||||||
p.AddRemote(evt.Host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
} else {
|
|
||||||
log.Printf("No discovery configured; expecting manual AddRemote or static host injection")
|
|
||||||
}
|
|
||||||
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------- Remote Host Management -----------------------------
|
|
||||||
|
|
||||||
// AddRemote dials a remote host and initializes grain proxies.
|
|
||||||
func (p *SyncedPool) AddRemote(host string) {
|
|
||||||
if host == "" || host == p.Hostname {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
p.mu.Lock()
|
|
||||||
if _, exists := p.remoteHosts[host]; exists {
|
|
||||||
p.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p.mu.Unlock()
|
|
||||||
|
|
||||||
target := fmt.Sprintf("%s:1337", host)
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
conn, err := grpc.DialContext(ctx, target, grpc.WithInsecure(), grpc.WithBlock())
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("AddRemote: dial %s failed: %v", target, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cartClient := proto.NewCartActorClient(conn)
|
|
||||||
controlClient := proto.NewControlPlaneClient(conn)
|
|
||||||
|
|
||||||
// Health check (Ping) with limited retries
|
|
||||||
pings := 3
|
|
||||||
for pings > 0 {
|
|
||||||
ctxPing, cancelPing := context.WithTimeout(context.Background(), 1*time.Second)
|
|
||||||
_, pingErr := controlClient.Ping(ctxPing, &proto.Empty{})
|
|
||||||
cancelPing()
|
|
||||||
if pingErr == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
pings--
|
|
||||||
time.Sleep(200 * time.Millisecond)
|
|
||||||
if pings == 0 {
|
|
||||||
log.Printf("AddRemote: ping %s failed after retries: %v", host, pingErr)
|
|
||||||
conn.Close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
remote := &RemoteHostGRPC{
|
|
||||||
Host: host,
|
|
||||||
Conn: conn,
|
|
||||||
CartClient: cartClient,
|
|
||||||
ControlClient: controlClient,
|
|
||||||
MissedPings: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
p.mu.Lock()
|
|
||||||
p.remoteHosts[host] = remote
|
|
||||||
p.mu.Unlock()
|
|
||||||
connectedRemotes.Set(float64(p.RemoteCount()))
|
|
||||||
|
|
||||||
log.Printf("Connected to remote host %s", host)
|
|
||||||
|
|
||||||
go p.pingLoop(remote)
|
|
||||||
go p.initializeRemote(remote)
|
|
||||||
go p.Negotiate()
|
|
||||||
}
|
|
||||||
|
|
||||||
// initializeRemote fetches remote cart ids and sets up remote grain proxies.
|
|
||||||
func (p *SyncedPool) initializeRemote(remote *RemoteHostGRPC) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
reply, err := remote.ControlClient.GetCartIds(ctx, &proto.Empty{})
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Init remote %s: GetCartIds error: %v", remote.Host, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
count := 0
|
|
||||||
for _, idStr := range reply.CartIds {
|
|
||||||
if idStr == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
p.SpawnRemoteGrain(ToCartId(idStr), remote.Host)
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
log.Printf("Remote %s reported %d grains", remote.Host, count)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveHost removes remote host and its grains.
|
|
||||||
func (p *SyncedPool) RemoveHost(host string) {
|
|
||||||
p.mu.Lock()
|
|
||||||
remote, exists := p.remoteHosts[host]
|
|
||||||
if exists {
|
|
||||||
delete(p.remoteHosts, host)
|
|
||||||
}
|
|
||||||
// remove grains pointing to host
|
|
||||||
for id, g := range p.remoteIndex {
|
|
||||||
if rg, ok := g.(*RemoteGrainGRPC); ok && rg.Host == host {
|
|
||||||
delete(p.remoteIndex, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p.mu.Unlock()
|
|
||||||
|
|
||||||
if exists {
|
|
||||||
remote.Conn.Close()
|
|
||||||
}
|
|
||||||
connectedRemotes.Set(float64(p.RemoteCount()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoteCount returns number of tracked remote hosts.
|
|
||||||
func (p *SyncedPool) RemoteCount() int {
|
|
||||||
p.mu.RLock()
|
|
||||||
defer p.mu.RUnlock()
|
|
||||||
return len(p.remoteHosts)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SyncedPool) IsKnown(host string) bool {
|
|
||||||
if host == p.Hostname {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
p.mu.RLock()
|
|
||||||
defer p.mu.RUnlock()
|
|
||||||
_, ok := p.remoteHosts[host]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SyncedPool) ExcludeKnown(hosts []string) []string {
|
|
||||||
ret := make([]string, 0, len(hosts))
|
|
||||||
for _, h := range hosts {
|
|
||||||
if !p.IsKnown(h) {
|
|
||||||
ret = append(ret, h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------- Health / Ping -------------------------------------
|
|
||||||
|
|
||||||
func (p *SyncedPool) pingLoop(remote *RemoteHostGRPC) {
|
|
||||||
ticker := time.NewTicker(3 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for range ticker.C {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
|
||||||
_, err := remote.ControlClient.Ping(ctx, &proto.Empty{})
|
|
||||||
cancel()
|
|
||||||
if err != nil {
|
|
||||||
remote.MissedPings++
|
|
||||||
log.Printf("Ping %s failed (%d)", remote.Host, remote.MissedPings)
|
|
||||||
if !remote.IsHealthy() {
|
|
||||||
log.Printf("Remote %s unhealthy, removing", remote.Host)
|
|
||||||
p.RemoveHost(remote.Host)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
remote.MissedPings = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SyncedPool) IsHealthy() bool {
|
|
||||||
p.mu.RLock()
|
|
||||||
defer p.mu.RUnlock()
|
|
||||||
for _, r := range p.remoteHosts {
|
|
||||||
if !r.IsHealthy() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------- Negotiation ---------------------------------------
|
|
||||||
|
|
||||||
func (p *SyncedPool) Negotiate() {
|
|
||||||
negotiationCount.Inc()
|
|
||||||
|
|
||||||
p.mu.RLock()
|
|
||||||
hosts := make([]string, 0, len(p.remoteHosts)+1)
|
|
||||||
hosts = append(hosts, p.Hostname)
|
|
||||||
for h := range p.remoteHosts {
|
|
||||||
hosts = append(hosts, h)
|
|
||||||
}
|
|
||||||
remotes := make([]*RemoteHostGRPC, 0, len(p.remoteHosts))
|
|
||||||
for _, r := range p.remoteHosts {
|
|
||||||
remotes = append(remotes, r)
|
|
||||||
}
|
|
||||||
p.mu.RUnlock()
|
|
||||||
|
|
||||||
for _, r := range remotes {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
||||||
reply, err := r.ControlClient.Negotiate(ctx, &proto.NegotiateRequest{KnownHosts: hosts})
|
|
||||||
cancel()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Negotiate with %s failed: %v", r.Host, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, h := range reply.Hosts {
|
|
||||||
if !p.IsKnown(h) {
|
|
||||||
p.AddRemote(h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------- Grain Management ----------------------------------
|
|
||||||
|
|
||||||
// RemoveRemoteGrain removes a remote grain mapping.
|
|
||||||
func (p *SyncedPool) RemoveRemoteGrain(id CartId) {
|
|
||||||
p.mu.Lock()
|
|
||||||
delete(p.remoteIndex, id)
|
|
||||||
p.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SpawnRemoteGrain creates/updates a remote grain proxy for a given host.
|
|
||||||
func (p *SyncedPool) SpawnRemoteGrain(id CartId, host string) {
|
|
||||||
if id.String() == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p.mu.Lock()
|
|
||||||
// If local grain exists, remove it (ownership changed)
|
|
||||||
if g, ok := p.local.grains[id]; ok && g != nil {
|
|
||||||
delete(p.local.grains, id)
|
|
||||||
}
|
|
||||||
remoteHost, ok := p.remoteHosts[host]
|
|
||||||
if !ok {
|
|
||||||
p.mu.Unlock()
|
|
||||||
log.Printf("SpawnRemoteGrain: host %s unknown (id=%s), attempting AddRemote", host, id)
|
|
||||||
go p.AddRemote(host)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rg := NewRemoteGrainGRPC(id, host, remoteHost.CartClient)
|
|
||||||
p.remoteIndex[id] = rg
|
|
||||||
p.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetHealthyRemotes returns a copy slice of healthy remote hosts.
|
|
||||||
func (p *SyncedPool) GetHealthyRemotes() []*RemoteHostGRPC {
|
|
||||||
p.mu.RLock()
|
|
||||||
defer p.mu.RUnlock()
|
|
||||||
ret := make([]*RemoteHostGRPC, 0, len(p.remoteHosts))
|
|
||||||
for _, r := range p.remoteHosts {
|
|
||||||
if r.IsHealthy() {
|
|
||||||
ret = append(ret, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ret
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequestOwnership attempts to become owner of a cart, requiring quorum.
|
|
||||||
// On success local grain is (or will be) created; peers spawn remote proxies.
|
|
||||||
func (p *SyncedPool) RequestOwnership(id CartId) error {
|
|
||||||
ok := 0
|
|
||||||
all := 0
|
|
||||||
remotes := p.GetHealthyRemotes()
|
|
||||||
for _, r := range remotes {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond)
|
|
||||||
reply, err := r.ControlClient.ConfirmOwner(ctx, &proto.OwnerChangeRequest{
|
|
||||||
CartId: id.String(),
|
|
||||||
NewHost: p.Hostname,
|
|
||||||
})
|
|
||||||
cancel()
|
|
||||||
all++
|
|
||||||
if err != nil || reply == nil || !reply.Accepted {
|
|
||||||
log.Printf("ConfirmOwner failure from %s for %s: %v (reply=%v)", r.Host, id, err, reply)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ok++
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quorum rule mirrors legacy:
|
|
||||||
// - If fewer than 3 total, require all.
|
|
||||||
// - Else require majority (ok >= all/2).
|
|
||||||
if (all < 3 && ok < all) || ok < (all/2) {
|
|
||||||
p.removeLocalGrain(id)
|
|
||||||
return fmt.Errorf("quorum not reached (ok=%d all=%d)", ok, all)
|
|
||||||
}
|
|
||||||
grainSyncCount.Inc()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SyncedPool) removeLocalGrain(id CartId) {
|
|
||||||
p.mu.Lock()
|
|
||||||
delete(p.local.grains, id)
|
|
||||||
p.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// getGrain returns a local or remote grain; if absent, attempts ownership.
|
|
||||||
func (p *SyncedPool) getGrain(id CartId) (Grain, error) {
|
|
||||||
p.mu.RLock()
|
|
||||||
localGrain, isLocal := p.local.grains[id]
|
|
||||||
remoteGrain, isRemote := p.remoteIndex[id]
|
|
||||||
p.mu.RUnlock()
|
|
||||||
|
|
||||||
if isLocal && localGrain != nil {
|
|
||||||
return localGrain, nil
|
|
||||||
}
|
|
||||||
if isRemote {
|
|
||||||
remoteLookupCount.Inc()
|
|
||||||
return remoteGrain, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt to claim ownership (async semantics preserved)
|
|
||||||
go p.RequestOwnership(id)
|
|
||||||
|
|
||||||
// Create local grain (lazy spawn) - may be rolled back by quorum failure
|
|
||||||
grain, err := p.local.GetGrain(id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return grain, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process applies mutation(s) to a grain (local or remote).
|
|
||||||
func (p *SyncedPool) Process(id CartId, messages ...Message) (*FrameWithPayload, error) {
|
|
||||||
grain, err := p.getGrain(id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var res *FrameWithPayload
|
|
||||||
for _, m := range messages {
|
|
||||||
res, err = grain.HandleMessage(&m, false)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns current state of a grain (local or remote).
|
|
||||||
func (p *SyncedPool) Get(id CartId) (*FrameWithPayload, error) {
|
|
||||||
grain, err := p.getGrain(id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return grain.GetCurrentState()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close notifies remotes this host is terminating.
|
|
||||||
func (p *SyncedPool) Close() {
|
|
||||||
p.mu.RLock()
|
|
||||||
remotes := make([]*RemoteHostGRPC, 0, len(p.remoteHosts))
|
|
||||||
for _, r := range p.remoteHosts {
|
|
||||||
remotes = append(remotes, r)
|
|
||||||
}
|
|
||||||
p.mu.RUnlock()
|
|
||||||
|
|
||||||
for _, r := range remotes {
|
|
||||||
go func(rh *RemoteHostGRPC) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
|
||||||
_, err := rh.ControlClient.Closing(ctx, &proto.ClosingNotice{Host: p.Hostname})
|
|
||||||
cancel()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Close notify to %s failed: %v", rh.Host, err)
|
|
||||||
}
|
|
||||||
}(r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/*
|
|
||||||
Legacy TCP networking (GenericListener / Frame protocol) has been removed
|
|
||||||
as part of the gRPC migration. This file intentionally contains no tests.
|
|
||||||
|
|
||||||
Keeping an empty Go file (with a package declaration) ensures the old
|
|
||||||
tcp-connection test target no longer runs without causing build issues.
|
|
||||||
*/
|
|
||||||
package main
|
|
||||||
Reference in New Issue
Block a user