diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bcb802a --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 5ef4385..7a3f23a 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -1,30 +1,77 @@ name: Build and Publish -run-name: ${{ gitea.actor }} is building 🚀 +run-name: ${{ gitea.actor }} build 🚀 on: [push] jobs: + Metadata: + runs-on: arm64 + outputs: + version: ${{ steps.meta.outputs.version }} + git_commit: ${{ steps.meta.outputs.git_commit }} + build_date: ${{ steps.meta.outputs.build_date }} + steps: + - name: Checkout + uses: actions/checkout@v4 + - id: meta + name: Derive build metadata + run: | + GIT_COMMIT=$(git rev-parse HEAD) + if git describe --tags --exact-match >/dev/null 2>&1; then + VERSION=$(git describe --tags --exact-match) + else + VERSION=$(git rev-parse --short=12 HEAD) + fi + BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + echo "git_commit=$GIT_COMMIT" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "build_date=$BUILD_DATE" >> $GITHUB_OUTPUT + BuildAndDeployAmd64: + needs: Metadata runs-on: amd64 steps: - - name: Check out repository code - uses: actions/checkout@v4 - - name: Build docker image - run: docker build --progress=plain -t registry.knatofs.se/go-cart-actor-amd64:latest . - - name: Push to registry - run: docker push registry.knatofs.se/go-cart-actor-amd64:latest - - name: Deploy to Kubernetes + - uses: actions/checkout@v4 + - name: Build amd64 image + run: | + docker build \ + --build-arg VERSION=${{ needs.Metadata.outputs.version }} \ + --build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \ + --build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \ + --progress=plain \ + -t registry.knatofs.se/go-cart-actor-amd64:latest \ + -t registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }} \ + . + - name: Push amd64 images + run: | + docker push registry.knatofs.se/go-cart-actor-amd64:latest + docker push registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }} + - name: Apply deployment manifests run: kubectl apply -f deployment/deployment.yaml -n cart - - name: Rollout amd64 deployment - run: kubectl rollout restart deployment/cart-actor-x86 -n cart - - BuildAndDeploy: + - name: Rollout amd64 deployment (pin to version) + run: | + kubectl set image deployment/cart-actor-x86 -n cart cart-actor-amd64=registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }} + kubectl rollout status deployment/cart-actor-x86 -n cart + + BuildAndDeployArm64: + needs: Metadata runs-on: arm64 steps: - - name: Check out repository code - uses: actions/checkout@v4 - - name: Build docker image - run: docker build --progress=plain -t registry.knatofs.se/go-cart-actor . - - name: Push to registry - run: docker push registry.knatofs.se/go-cart-actor - - name: Rollout arm64 deployment - run: kubectl rollout restart deployment/cart-actor-arm64 -n cart \ No newline at end of file + - uses: actions/checkout@v4 + - name: Build arm64 image + run: | + docker build \ + --build-arg VERSION=${{ needs.Metadata.outputs.version }} \ + --build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \ + --build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \ + --progress=plain \ + -t registry.knatofs.se/go-cart-actor:latest \ + -t registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }} \ + . + - name: Push arm64 images + run: | + docker push registry.knatofs.se/go-cart-actor:latest + docker push registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }} + - name: Rollout arm64 deployment (pin to version) + run: | + kubectl set image deployment/cart-actor-arm64 -n cart cart-actor-arm64=registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }} + kubectl rollout status deployment/cart-actor-arm64 -n cart diff --git a/Dockerfile b/Dockerfile index 2c7abc3..1fa7879 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,75 @@ -# syntax=docker/dockerfile:1 +# syntax=docker/dockerfile:1.7 +# +# Multi-stage build: +# 1. Build static binary with pinned Go version (matching go.mod). +# 2. Copy into distroless static nonroot runtime image. +# +# Build args (optional): +# VERSION - semantic/app version (default: dev) +# GIT_COMMIT - git SHA (default: unknown) +# BUILD_DATE - RFC3339 build timestamp +# +# Example build: +# docker build \ +# --build-arg VERSION=$(git describe --tags --always) \ +# --build-arg GIT_COMMIT=$(git rev-parse HEAD) \ +# --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \ +# -t go-cart-actor:dev . +# +# If you add subpackages or directories, no Dockerfile change needed (COPY . .). +# Ensure a .dockerignore exists to keep context lean. -FROM golang:alpine AS build-stage -WORKDIR /app +############################ +# Build Stage +############################ +FROM golang:1.25-alpine AS build +WORKDIR /src + +# Build metadata (can be overridden at build time) +ARG VERSION=dev +ARG GIT_COMMIT=unknown +ARG BUILD_DATE=unknown + +# Ensure reproducible static build +# Multi-arch build args (TARGETOS/TARGETARCH provided automatically by buildx) +ARG TARGETOS +ARG TARGETARCH +ENV CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} + +# Dependency caching COPY go.mod go.sum ./ -RUN go mod download +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download -COPY proto ./proto -COPY *.go ./ +# Copy full source (relay on .dockerignore to prune) +COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -o /go-cart-actor +# (Optional) If you do NOT check in generated protobuf code, uncomment generation: +# RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest && \ +# go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest && \ +# protoc --go_out=. --go_opt=paths=source_relative \ +# --go-grpc_out=. --go-grpc_opt=paths=source_relative \ +# proto/*.proto -FROM gcr.io/distroless/base-debian11 +# Build with minimal binary size and embedded metadata +RUN --mount=type=cache,target=/go/build-cache \ + go build -trimpath -ldflags="-s -w \ + -X main.Version=${VERSION} \ + -X main.GitCommit=${GIT_COMMIT} \ + -X main.BuildDate=${BUILD_DATE}" \ + -o /out/go-cart-actor ./cmd/cart + +############################ +# Runtime Stage +############################ +# Using distroless static (nonroot) for minimal surface area. +FROM gcr.io/distroless/static-debian12:nonroot AS runtime WORKDIR / -COPY --from=build-stage /go-cart-actor /go-cart-actor -ENTRYPOINT ["/go-cart-actor"] \ No newline at end of file +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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cc5b50b --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 45973d5..5fa0634 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,43 @@ # Go Cart Actor +## Migration Notes (Ring-based Ownership Transition) + +This release removes the legacy ConfirmOwner ownership negotiation RPC in favor of deterministic ownership via the consistent hashing ring. + +Summary of changes: +- ConfirmOwner RPC removed from the ControlPlane service. +- OwnerChangeRequest message removed (was only used by ConfirmOwner). +- OwnerChangeAck retained solely as the response type for the Closing RPC. +- SyncedPool now relies exclusively on the ring for ownership (no quorum negotiation). +- Remote proxy creation includes a bounded readiness retry to reduce first-call failures. +- New Prometheus ring metrics: + - cart_ring_epoch + - cart_ring_hosts + - cart_ring_vnodes + - cart_ring_host_share{host} + - cart_ring_lookup_local_total + - cart_ring_lookup_remote_total + +Action required for consumers: +1. Regenerate protobuf code after pulling (requires protoc-gen-go and protoc-gen-go-grpc installed). +2. Remove any client code or automation invoking ConfirmOwner (calls will now return UNIMPLEMENTED if using stale generated stubs). +3. Update monitoring/alerts that referenced ConfirmOwner or ownership quorum failures—use ring metrics instead. +4. If you previously interpreted “ownership flapping” via ConfirmOwner logs, now check for: + - Rapid changes in ring epoch (cart_ring_epoch) + - Host churn (cart_ring_hosts) + - Imbalance in vnode distribution (cart_ring_host_share) + +No data migration is necessary; cart IDs and grain state are unaffected. + +--- + A distributed cart management system using the actor model pattern. ## Prerequisites - Go 1.24.2+ - Protocol Buffers compiler (`protoc`) -- protoc-gen-go plugin +- protoc-gen-go and protoc-gen-go-grpc plugins ### Installing Protocol Buffers @@ -32,17 +63,20 @@ sudo apt install protobuf-compiler ```bash go install google.golang.org/protobuf/cmd/protoc-gen-go@latest +go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest ``` ## Working with Protocol Buffers ### Generating Go code from proto files -After modifying `proto/messages.proto`, regenerate the Go code: +After modifying any proto (`proto/messages.proto`, `proto/cart_actor.proto`, `proto/control_plane.proto`), regenerate the Go code (all three share the unified `messages` package): ```bash cd proto -protoc --go_out=. --go_opt=paths=source_relative messages.proto +protoc --go_out=. --go_opt=paths=source_relative \ + --go-grpc_out=. --go-grpc_opt=paths=source_relative \ + messages.proto cart_actor.proto control_plane.proto ``` ### Protocol Buffer Messages @@ -73,8 +107,338 @@ go build . go test ./... ``` +## HTTP API Quick Start (curl Examples) + +Assuming the service is reachable at http://localhost:8080 and the cart API is mounted at /cart. +Most endpoints use an HTTP cookie named `cartid` to track the cart. The first request will set it. + +### 1. Get (or create) a cart +```bash +curl -i http://localhost:8080/cart/ +``` +Response sets a `cartid` cookie and returns the current (possibly empty) cart JSON. + +### 2. Add an item by SKU (implicit quantity = 1) +```bash +curl -i --cookie-jar cookies.txt http://localhost:8080/cart/add/TEST-SKU-123 +``` +Stores cookie in `cookies.txt` for subsequent calls. + +### 3. Add an item with explicit payload (country, quantity) +```bash +curl -i --cookie cookies.txt \ + -H "Content-Type: application/json" \ + -d '{"sku":"TEST-SKU-456","quantity":2,"country":"se"}' \ + http://localhost:8080/cart/ +``` + +### 4. Change quantity of an existing line +(First list the cart to find `id` of the line; here we use id=1 as an example) +```bash +curl -i --cookie cookies.txt \ + -X PUT -H "Content-Type: application/json" \ + -d '{"id":1,"quantity":3}' \ + http://localhost:8080/cart/ +``` + +### 5. Remove an item +```bash +curl -i --cookie cookies.txt -X DELETE http://localhost:8080/cart/1 +``` + +### 6. Set entire cart contents (overwrites items) +```bash +curl -i --cookie cookies.txt \ + -X POST -H "Content-Type: application/json" \ + -d '{"items":[{"sku":"TEST-SKU-AAA","quantity":1,"country":"se"},{"sku":"TEST-SKU-BBB","quantity":2,"country":"se"}]}' \ + http://localhost:8080/cart/set +``` + +### 7. Add a delivery (provider + optional items) +If `items` is empty or omitted, all items without a delivery get this one. +```bash +curl -i --cookie cookies.txt \ + -X POST -H "Content-Type: application/json" \ + -d '{"provider":"standard","items":[1,2]}' \ + http://localhost:8080/cart/delivery +``` + +### 8. Remove a delivery by deliveryId +```bash +curl -i --cookie cookies.txt -X DELETE http://localhost:8080/cart/delivery/1 +``` + +### 9. Set a pickup point for a delivery +```bash +curl -i --cookie cookies.txt \ + -X PUT -H "Content-Type: application/json" \ + -d '{"id":"PUP123","name":"Locker 5","address":"Main St 1","city":"Stockholm","zip":"11122","country":"SE"}' \ + http://localhost:8080/cart/delivery/1/pickupPoint +``` + +### 10. Checkout (returns HTML snippet from Klarna) +```bash +curl -i --cookie cookies.txt http://localhost:8080/cart/checkout +``` + +### 11. Using a known cart id directly (bypassing cookie) +If you already have a cart id (e.g. 1720000000000000): +```bash +CART_ID=1720000000000000 +curl -i http://localhost:8080/cart/byid/$CART_ID +curl -i -X POST -H "Content-Type: application/json" \ + -d '{"sku":"TEST-SKU-XYZ","quantity":1,"country":"se"}' \ + http://localhost:8080/cart/byid/$CART_ID +``` + +### 12. Clear cart cookie (forces a new cart on next request) +```bash +curl -i --cookie cookies.txt -X DELETE http://localhost:8080/cart/ +``` + +Tip: Use `--cookie-jar` and `--cookie` to persist the session across multiple commands: +```bash +curl --cookie-jar cookies.txt http://localhost:8080/cart/ +curl --cookie cookies.txt http://localhost:8080/cart/add/TEST-SKU-123 +``` + ## Important Notes -- Always regenerate protobuf Go code after modifying `.proto` files +- Always regenerate protobuf Go code after modifying any `.proto` files (messages/cart_actor/control_plane) - The generated `messages.pb.go` file should not be edited manually -- Make sure your PATH includes the protoc-gen-go binary location (usually `$GOPATH/bin`) \ No newline at end of file +- 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 | + +--- diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..3ec9585 --- /dev/null +++ b/TODO.md @@ -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._ \ No newline at end of file diff --git a/amqp-order-handler.go b/amqp-order-handler.go deleted file mode 100644 index 776cfe7..0000000 --- a/amqp-order-handler.go +++ /dev/null @@ -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, - }, - ) -} diff --git a/cart b/cart new file mode 100755 index 0000000..e77343b Binary files /dev/null and b/cart differ diff --git a/cart-grain.go b/cart-grain.go deleted file mode 100644 index 07ac1ff..0000000 --- a/cart-grain.go +++ /dev/null @@ -1,608 +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 - -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 interface{}) (int, error) { - switch v := data.(type) { - case float64: - return int(v), nil - case int: - return v, nil - default: - return 0, fmt.Errorf("invalid type") - } -} - -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.Fields[5]) - - price, priceErr := getInt(item.Fields[4]) - - if priceErr != nil { - return nil, fmt.Errorf("invalid price") - } - - stock := InStock - if item.StockLevel == "0" || item.StockLevel == "" { - stock = OutOfStock - } else if item.StockLevel == "5+" { - stock = LowStock - } - articleType, _ := item.Fields[1].(string) - outletGrade, ok := item.Fields[20].(string) - var outlet *string - if ok { - outlet = &outletGrade - } - sellerId, _ := item.Fields[24].(string) - sellerName, _ := item.Fields[9].(string) - - brand, _ := item.Fields[2].(string) - category, _ := item.Fields[10].(string) - category2, _ := item.Fields[11].(string) - category3, _ := item.Fields[12].(string) - category4, _ := item.Fields[13].(string) - category5, _ := item.Fields[14].(string) - - 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) - } - -} diff --git a/cart-grain_test.go b/cart-grain_test.go deleted file mode 100644 index 94d2e9d..0000000 --- a/cart-grain_test.go +++ /dev/null @@ -1,248 +0,0 @@ -package main - -import ( - "testing" - "time" - - messages "git.tornberg.me/go-cart-actor/proto" -) - -func GetMessage(t uint16, data interface{}) *Message { - ts := time.Now().Unix() - return &Message{ - TimeStamp: &ts, - Type: t, - Content: data, - } -} - -func TestTaxAmount(t *testing.T) { - taxAmount := GetTaxAmount(12500, 2500) - if taxAmount != 2500 { - t.Errorf("Expected 2500, got %d\n", taxAmount) - } -} - -func TestAddToCartShortCut(t *testing.T) { - grain, err := spawn(ToCartId("kalle")) - if err != nil { - t.Errorf("Error spawning: %v\n", err) - } - if len(grain.Items) != 0 { - t.Errorf("Expected 0 items, got %d\n", len(grain.Items)) - } - msg := GetMessage(AddItemType, &messages.AddItem{ - Quantity: 2, - Price: 100, - Sku: "123", - Name: "Test item", - Image: "test.jpg", - }) - - _, err = grain.HandleMessage(msg, false) - if err != nil { - t.Errorf("Error handling message: %v\n", err) - } - if len(grain.Items) != 1 { - t.Errorf("Expected 1 item, got %d\n", len(grain.Items)) - } - if grain.Items[0].Quantity != 2 { - t.Errorf("Expected quantity 2, got %d\n", grain.Items[0].Quantity) - } - if len(grain.storageMessages) != 1 { - t.Errorf("Expected 1 storage message, got %d\n", len(grain.storageMessages)) - } - shortCutMessage := GetMessage(AddRequestType, &messages.AddRequest{ - Quantity: 2, - Sku: "123", - }) - _, err = grain.HandleMessage(shortCutMessage, false) - if err != nil { - t.Errorf("Error handling message: %v\n", err) - } - if len(grain.Items) != 1 { - t.Errorf("Expected 1 item, got %d\n", len(grain.Items)) - } - if len(grain.storageMessages) != 2 { - t.Errorf("Expected 2 storage message, got %d\n", len(grain.storageMessages)) - } - if grain.storageMessages[0].Type != AddItemType { - t.Errorf("Expected AddItemType, got %d\n", grain.storageMessages[0].Type) - } - if grain.storageMessages[1].Type != AddRequestType { - t.Errorf("Expected AddRequestType, got %d\n", grain.storageMessages[1].Type) - } -} - -func TestAddRequestToGrain(t *testing.T) { - grain, err := spawn(ToCartId("kalle")) - if err != nil { - t.Errorf("Error spawning: %v\n", err) - } - msg := GetMessage(AddRequestType, &messages.AddRequest{ - Quantity: 2, - Sku: "763281", - }) - result, err := grain.HandleMessage(msg, false) - if err != nil { - t.Errorf("Error handling message: %v\n", err) - } - if result.StatusCode != 200 { - t.Errorf("Call failed\n") - } - t.Log(result) -} - -func TestAddToCart(t *testing.T) { - grain, err := spawn(ToCartId("kalle")) - if err != nil { - t.Errorf("Error spawning: %v\n", err) - } - if len(grain.Items) != 0 { - t.Errorf("Expected 0 items, got %d\n", len(grain.Items)) - } - msg := GetMessage(AddItemType, &messages.AddItem{ - Quantity: 2, - Price: 100, - Sku: "123", - Name: "Test item", - Image: "test.jpg", - }) - - result, err := grain.HandleMessage(msg, false) - - if err != nil { - t.Errorf("Error handling message: %v\n", err) - } - if result.StatusCode != 200 { - t.Errorf("Call failed\n") - } - if grain.TotalPrice != 200 { - t.Errorf("Expected total price 200, got %d\n", grain.TotalPrice) - } - if len(grain.Items) != 1 { - t.Errorf("Expected 1 item, got %d\n", len(grain.Items)) - } - if grain.Items[0].Quantity != 2 { - t.Errorf("Expected quantity 2, got %d\n", grain.Items[0].Quantity) - } - result, err = grain.HandleMessage(msg, false) - if err != nil { - t.Errorf("Error handling message: %v\n", err) - } - if result.StatusCode != 200 { - t.Errorf("Call failed\n") - } - if grain.Items[0].Quantity != 4 { - t.Errorf("Expected quantity 4, got %d\n", grain.Items[0].Quantity) - } - if grain.TotalPrice != 400 { - t.Errorf("Expected total price 400, got %d\n", grain.TotalPrice) - } -} - -func TestSetDelivery(t *testing.T) { - grain, err := spawn(ToCartId("kalle")) - if err != nil { - t.Errorf("Error spawning: %v\n", err) - } - if len(grain.Items) != 0 { - t.Errorf("Expected 0 items, got %d\n", len(grain.Items)) - } - msg := GetMessage(AddItemType, &messages.AddItem{ - Quantity: 2, - Price: 100, - Sku: "123", - Name: "Test item", - Image: "test.jpg", - }) - - grain.HandleMessage(msg, false) - - msg = GetMessage(AddItemType, &messages.AddItem{ - Quantity: 2, - Price: 100, - Sku: "123", - Name: "Test item", - Image: "test.jpg", - }) - - result, err := grain.HandleMessage(msg, false) - - if err != nil { - t.Errorf("Error handling message: %v\n", err) - } - if result.StatusCode != 200 { - t.Errorf("Call failed\n") - } - - setDelivery := GetMessage(SetDeliveryType, &messages.SetDelivery{ - Provider: "test", - Items: []int64{1}, - }) - - _, err = grain.HandleMessage(setDelivery, false) - if err != nil { - t.Errorf("Error handling message: %v\n", err) - } - if len(grain.Deliveries) != 1 { - t.Errorf("Expected 1 delivery, got %d\n", len(grain.Deliveries)) - } - if len(grain.Deliveries[0].Items) != 1 { - t.Errorf("Expected 1 items in delivery, got %d\n", len(grain.Deliveries[0].Items)) - } -} - -func TestSetDeliveryOnAll(t *testing.T) { - grain, err := spawn(ToCartId("kalle")) - if err != nil { - t.Errorf("Error spawning: %v\n", err) - } - if len(grain.Items) != 0 { - t.Errorf("Expected 0 items, got %d\n", len(grain.Items)) - } - msg := GetMessage(AddItemType, &messages.AddItem{ - Quantity: 2, - Price: 100, - Sku: "123", - Name: "Test item", - Image: "test.jpg", - }) - - grain.HandleMessage(msg, false) - - msg = GetMessage(AddItemType, &messages.AddItem{ - Quantity: 2, - Price: 100, - Sku: "1233", - Name: "Test item2", - Image: "test.jpg", - }) - - result, err := grain.HandleMessage(msg, false) - - if err != nil { - t.Errorf("Error handling message: %v\n", err) - } - if result.StatusCode != 200 { - t.Errorf("Call failed\n") - } - - setDelivery := GetMessage(SetDeliveryType, &messages.SetDelivery{ - Provider: "test", - Items: []int64{}, - }) - - _, err = grain.HandleMessage(setDelivery, false) - if err != nil { - t.Errorf("Error handling message: %v\n", err) - } - if len(grain.Deliveries) != 1 { - t.Errorf("Expected 1 delivery, got %d\n", len(grain.Deliveries)) - } - - if len(grain.Deliveries[0].Items) != 2 { - t.Errorf("Expected 2 items in delivery, got %d\n", len(grain.Deliveries[0].Items)) - } - -} diff --git a/cmd/backoffice/main.go b/cmd/backoffice/main.go new file mode 100644 index 0000000..1f2ab91 --- /dev/null +++ b/cmd/backoffice/main.go @@ -0,0 +1,5 @@ +package main + +func main() { + // Your code here +} diff --git a/cmd/cart/amqp-order-handler.go b/cmd/cart/amqp-order-handler.go new file mode 100644 index 0000000..5003732 --- /dev/null +++ b/cmd/cart/amqp-order-handler.go @@ -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 +} diff --git a/cmd/cart/cart-grain.go b/cmd/cart/cart-grain.go new file mode 100644 index 0000000..326f8e4 --- /dev/null +++ b/cmd/cart/cart-grain.go @@ -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) + } + } +} diff --git a/cmd/cart/cart_grain_totals_test.go b/cmd/cart/cart_grain_totals_test.go new file mode 100644 index 0000000..ae0dd7e --- /dev/null +++ b/cmd/cart/cart_grain_totals_test.go @@ -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) + } +} diff --git a/cmd/cart/cart_id.go b/cmd/cart/cart_id.go new file mode 100644 index 0000000..6039101 --- /dev/null +++ b/cmd/cart/cart_id.go @@ -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 +} diff --git a/cmd/cart/cart_id_test.go b/cmd/cart/cart_id_test.go new file mode 100644 index 0000000..f7d1883 --- /dev/null +++ b/cmd/cart/cart_id_test.go @@ -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 +} diff --git a/cmd/cart/checkout_builder.go b/cmd/cart/checkout_builder.go new file mode 100644 index 0000000..cca5c30 --- /dev/null +++ b/cmd/cart/checkout_builder.go @@ -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 +} diff --git a/klarna-client.go b/cmd/cart/klarna-client.go similarity index 100% rename from klarna-client.go rename to cmd/cart/klarna-client.go diff --git a/klarna-types.go b/cmd/cart/klarna-types.go similarity index 100% rename from klarna-types.go rename to cmd/cart/klarna-types.go diff --git a/main.go b/cmd/cart/main.go similarity index 52% rename from main.go rename to cmd/cart/main.go index 36823c5..41ce388 100644 --- a/main.go +++ b/cmd/cart/main.go @@ -12,10 +12,15 @@ import ( "syscall" "time" - messages "git.tornberg.me/go-cart-actor/proto" + "git.tornberg.me/go-cart-actor/pkg/actor" + "git.tornberg.me/go-cart-actor/pkg/discovery" + messages "git.tornberg.me/go-cart-actor/pkg/messages" + "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/promauto" "github.com/prometheus/client_golang/prometheus/promhttp" + "k8s.io/apimachinery/pkg/watch" "k8s.io/client-go/kubernetes" "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() { os.Mkdir("data", 0755) } type App struct { - pool *GrainLocalPool - 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) - } + pool *actor.SimpleGrainPool[CartGrain] } var podIp = os.Getenv("POD_IP") var name = os.Getenv("POD_NAME") var amqpUrl = os.Getenv("AMQP_URL") -var KlarnaInstance = NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD")) - -func GetDiscovery() Discovery { - if podIp == "" { - return nil - } - - config, kerr := rest.InClusterConfig() - - if kerr != nil { - log.Fatalf("Error creating kubernetes client: %v\n", kerr) - } - client, err := kubernetes.NewForConfig(config) - if err != nil { - log.Fatalf("Error creating client: %v\n", err) - } - return NewK8sDiscovery(client) -} var tpl = ` @@ -132,67 +70,159 @@ func getCountryFromHost(host string) string { if strings.Contains(strings.ToLower(host), "-no") { return "no" } - return "se" + if strings.Contains(strings.ToLower(host), "-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" +func GetDiscovery() discovery.Discovery { + if podIp == "" { + return nil } - country := getCountryFromHost(host) - return &messages.CreateCheckoutOrder{ - Terms: fmt.Sprintf("%s/terms", baseUrl), - Checkout: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}", baseUrl), - Confirmation: fmt.Sprintf("%s/confirmation/{checkout.order.id}", baseUrl), - Validation: fmt.Sprintf("%s/validation", cartBaseUrl), - Push: fmt.Sprintf("%s/push?order_id={checkout.order.id}", cartBaseUrl), - Country: country, + config, kerr := rest.InClusterConfig() + + if kerr != nil { + log.Fatalf("Error creating kubernetes client: %v\n", kerr) } + client, err := kubernetes.NewForConfig(config) + if err != nil { + log.Fatalf("Error creating client: %v\n", err) + } + return discovery.NewK8sDiscovery(client) +} + +type MutationContext struct { + VoucherService voucher.Service } 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 { - log.Printf("Error loading state: %v\n", err) + log.Fatalf("Error creating cart pool: %v\n", err) } app := &App{ - pool: NewGrainLocalPool(65535, 5*time.Minute, spawn), - storage: storage, + pool: pool, } - syncedPool, err := NewSyncedPool(app.pool, podIp, GetDiscovery()) + grpcSrv, err := actor.NewControlServer[*CartGrain](controlPlaneConfig, pool) if err != nil { - log.Fatalf("Error creating synced pool: %v\n", err) + log.Fatalf("Error starting control plane gRPC server: %v\n", err) } + defer grpcSrv.GracefulStop() - hg, err := NewGrainHandler(app.pool, ":1337") - if err != nil { - log.Fatalf("Error creating handler: %v\n", err) - } + go diskStorage.SaveLoop(10 * time.Second) - go func() { - for range time.Tick(time.Minute * 10) { - err := app.Save() - if err != nil { - log.Printf("Error saving: %v\n", err) + go func(hw discovery.Discovery) { + if hw == nil { + log.Print("No discovery service available") + return + } + ch, err := hw.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 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{ 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.Handle("/cart/", http.StripPrefix("/cart", syncedServer.Serve())) // only for local - // mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) { - // syncedPool.AddRemote(r.PathValue("host")) - // }) + mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) { + pool.AddRemote(r.PathValue("host")) + }) // mux.HandleFunc("GET /save", app.HandleSave) //mux.HandleFunc("/", app.RewritePath) mux.HandleFunc("/debug/pprof/", pprof.Index) @@ -202,17 +232,18 @@ func main() { mux.HandleFunc("/debug/pprof/trace", pprof.Trace) mux.Handle("/metrics", promhttp.Handler()) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { - if !hg.IsHealthy() { + // Grain pool health: simple capacity check (mirrors previous GrainHandler.IsHealthy) + grainCount, capacity := app.pool.LocalUsage() + if grainCount >= capacity { w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("handler not healthy")) + w.Write([]byte("grain pool at capacity")) return } - if !syncedPool.IsHealthy() { + if !pool.IsHealthy() { w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("pool not healthy")) + w.Write([]byte("control plane not healthy")) return } - w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) }) @@ -241,39 +272,49 @@ func main() { w.Write([]byte("no cart id to checkout is empty")) return } - cartId := ToCartId(cookie.Value) - reply, err := syncedServer.pool.Process(cartId, Message{ - Type: CreateCheckoutOrderType, - Content: getCheckoutOrder(r.Host, cartId), - }) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(err.Error())) - } - err = json.Unmarshal(reply.Payload, &order) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(err.Error())) + parsed, ok := ParseCartId(cookie.Value) + if !ok { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("invalid cart id format")) return } + cartId := parsed + 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 { - prevOrder, err := KlarnaInstance.GetOrder(orderId) + order, err = klarnaClient.GetOrder(orderId) if err != nil { w.WriteHeader(http.StatusBadRequest) w.Write([]byte(err.Error())) return } - order = prevOrder + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, tpl, order.HTMLSnippet) } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")") - w.WriteHeader(http.StatusOK) - w.Write([]byte(fmt.Sprintf(tpl, order.HTMLSnippet))) + }) mux.HandleFunc("/confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) { orderId := r.PathValue("order_id") - order, err := KlarnaInstance.GetOrder(orderId) + order, err := klarnaClient.GetOrder(orderId) if err != nil { w.WriteHeader(http.StatusInternalServerError) @@ -295,7 +336,7 @@ func main() { } w.WriteHeader(http.StatusOK) - w.Write([]byte(fmt.Sprintf(tpl, order.HTMLSnippet))) + fmt.Fprintf(w, tpl, order.HTMLSnippet) }) mux.HandleFunc("/validate", func(w http.ResponseWriter, r *http.Request) { log.Printf("Klarna order validation, method: %s", r.Method) @@ -333,7 +374,7 @@ func main() { orderId := r.URL.Query().Get("order_id") log.Printf("Order confirmation push: %s", orderId) - order, err := KlarnaInstance.GetOrder(orderId) + order, err := klarnaClient.GetOrder(orderId) if err != nil { log.Printf("Error creating request: %v\n", err) @@ -348,13 +389,13 @@ func main() { return } - err = triggerOrderCompleted(err, syncedServer, order) + err = triggerOrderCompleted(syncedServer, order) if err != nil { log.Printf("Error processing cart message: %v\n", err) w.WriteHeader(http.StatusInternalServerError) return } - err = KlarnaInstance.AcknowledgeOrder(orderId) + err = klarnaClient.AcknowledgeOrder(orderId) if err != nil { log.Printf("Error acknowledging order: %v\n", err) } @@ -366,6 +407,8 @@ func main() { w.Write([]byte("1.0.0")) }) + mux.HandleFunc("/openapi.json", ServeEmbeddedOpenAPI) + sigs := make(chan os.Signal, 1) done := make(chan bool, 1) signal.Notify(sigs, syscall.SIGTERM) @@ -373,25 +416,30 @@ func main() { go func() { sig := <-sigs fmt.Println("Shutting down due to signal:", sig) - go syncedPool.Close() - app.Save() + diskStorage.Close() + pool.Close() + done <- true }() + log.Print("Server started at port 8080") go http.ListenAndServe(":8080", mux) <-done } -func triggerOrderCompleted(err error, syncedServer *PoolServer, order *CheckoutOrder) error { - _, err = syncedServer.pool.Process(ToCartId(order.MerchantReference1), Message{ - Type: OrderCompletedType, - Content: &messages.OrderCreated{ - OrderId: order.ID, - Status: order.Status, - }, - }) - return err +func triggerOrderCompleted(syncedServer *PoolServer, order *CheckoutOrder) error { + mutation := &messages.OrderCreated{ + OrderId: order.ID, + Status: order.Status, + } + cid, ok := ParseCartId(order.MerchantReference1) + if !ok { + return fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1) + } + _, applyErr := syncedServer.Apply(uint64(cid), mutation) + + return applyErr } func confirmOrder(order *CheckoutOrder, orderHandler *AmqpOrderHandler) error { diff --git a/cmd/cart/mutation_add_item.go b/cmd/cart/mutation_add_item.go new file mode 100644 index 0000000..5f5c07b --- /dev/null +++ b/cmd/cart/mutation_add_item.go @@ -0,0 +1,87 @@ +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 { + existing.Quantity += int(m.Quantity) + 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) +} diff --git a/cmd/cart/mutation_add_voucher.go b/cmd/cart/mutation_add_voucher.go new file mode 100644 index 0000000..c29af7c --- /dev/null +++ b/cmd/cart/mutation_add_voucher.go @@ -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 +} diff --git a/cmd/cart/mutation_change_quantity.go b/cmd/cart/mutation_change_quantity.go new file mode 100644 index 0000000..1de8745 --- /dev/null +++ b/cmd/cart/mutation_change_quantity.go @@ -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 +} diff --git a/cmd/cart/mutation_initialize_checkout.go b/cmd/cart/mutation_initialize_checkout.go new file mode 100644 index 0000000..dcc2d50 --- /dev/null +++ b/cmd/cart/mutation_initialize_checkout.go @@ -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 +} diff --git a/cmd/cart/mutation_order_created.go b/cmd/cart/mutation_order_created.go new file mode 100644 index 0000000..a197929 --- /dev/null +++ b/cmd/cart/mutation_order_created.go @@ -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 +} diff --git a/cmd/cart/mutation_remove_delivery.go b/cmd/cart/mutation_remove_delivery.go new file mode 100644 index 0000000..3ec92f9 --- /dev/null +++ b/cmd/cart/mutation_remove_delivery.go @@ -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 +} diff --git a/cmd/cart/mutation_remove_item.go b/cmd/cart/mutation_remove_item.go new file mode 100644 index 0000000..c5ecd3c --- /dev/null +++ b/cmd/cart/mutation_remove_item.go @@ -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 +} diff --git a/cmd/cart/mutation_set_delivery.go b/cmd/cart/mutation_set_delivery.go new file mode 100644 index 0000000..8dc9df3 --- /dev/null +++ b/cmd/cart/mutation_set_delivery.go @@ -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 +} diff --git a/cmd/cart/mutation_set_pickup_point.go b/cmd/cart/mutation_set_pickup_point.go new file mode 100644 index 0000000..caf72d2 --- /dev/null +++ b/cmd/cart/mutation_set_pickup_point.go @@ -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 +} diff --git a/cmd/cart/openapi.json b/cmd/cart/openapi.json new file mode 100644 index 0000000..45a5f6f --- /dev/null +++ b/cmd/cart/openapi.json @@ -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" }] +} diff --git a/cmd/cart/openapi_embed.go b/cmd/cart/openapi_embed.go new file mode 100644 index 0000000..2739496 --- /dev/null +++ b/cmd/cart/openapi_embed.go @@ -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 +} diff --git a/cmd/cart/pool-server.go b/cmd/cart/pool-server.go new file mode 100644 index 0000000..f4f4e69 --- /dev/null +++ b/cmd/cart/pool-server.go @@ -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 +} diff --git a/cmd/cart/price.go b/cmd/cart/price.go new file mode 100644 index 0000000..4d66ee4 --- /dev/null +++ b/cmd/cart/price.go @@ -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 +} diff --git a/cmd/cart/price_test.go b/cmd/cart/price_test.go new file mode 100644 index 0000000..b7d697a --- /dev/null +++ b/cmd/cart/price_test.go @@ -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()) + } +} diff --git a/cmd/cart/product-fetcher.go b/cmd/cart/product-fetcher.go new file mode 100644 index 0000000..23ea6fd --- /dev/null +++ b/cmd/cart/product-fetcher.go @@ -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 + } +} diff --git a/cookies.txt b/cookies.txt new file mode 100644 index 0000000..be2ec67 --- /dev/null +++ b/cookies.txt @@ -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 diff --git a/deployment/deployment-no.yaml b/deployment/deployment-no.yaml deleted file mode 100644 index 18bf8c9..0000000 --- a/deployment/deployment-no.yaml +++ /dev/null @@ -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 \ No newline at end of file diff --git a/deployment/deployment.yaml b/deployment/deployment.yaml index a2eb933..8f63485 100644 --- a/deployment/deployment.yaml +++ b/deployment/deployment.yaml @@ -1,7 +1,7 @@ apiVersion: v1 kind: Secret metadata: - name: klarna-api-credentials + name: klarna-api-credentials data: username: ZjQzZDY3YjEtNzA2Yy00NTk2LTliNTgtYjg1YjU2NDEwZTUw password: a2xhcm5hX3Rlc3RfYXBpX0trUWhWVE5yYVZsV2FsTnhTRVp3Y1ZSSFF5UkVNRmxyY25Kd1AxSndQMGdzWmpRelpEWTNZakV0TnpBMll5MDBOVGsyTFRsaU5UZ3RZamcxWWpVMk5ERXdaVFV3TERFc2JUUkNjRFpWU1RsTllsSk1aMlEyVEc4MmRVODNZMkozUlRaaFdEZDViV3AwYkhGV1JqTjVNQzlaYXow @@ -15,7 +15,7 @@ metadata: arch: amd64 name: cart-actor-x86 spec: - replicas: 0 + replicas: 3 selector: matchLabels: app: cart-actor @@ -32,10 +32,10 @@ spec: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - - key: kubernetes.io/arch - operator: NotIn - values: - - arm64 + - key: kubernetes.io/arch + operator: NotIn + values: + - arm64 volumes: - name: data nfs: @@ -55,12 +55,8 @@ spec: ports: - containerPort: 8080 name: web - - containerPort: 1234 - name: echo - containerPort: 1337 name: rpc - - containerPort: 1338 - name: quorum livenessProbe: httpGet: path: /livez @@ -134,14 +130,14 @@ spec: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - - key: kubernetes.io/hostname - operator: NotIn - values: - - masterpi - - key: kubernetes.io/arch - operator: In - values: - - arm64 + - key: kubernetes.io/hostname + operator: NotIn + values: + - masterpi + - key: kubernetes.io/arch + operator: In + values: + - arm64 volumes: - name: data nfs: @@ -161,12 +157,8 @@ spec: ports: - containerPort: 8080 name: web - - containerPort: 1234 - name: echo - containerPort: 1337 name: rpc - - containerPort: 1338 - name: quorum livenessProbe: httpGet: path: /livez @@ -217,18 +209,6 @@ spec: --- kind: Service apiVersion: v1 -metadata: - name: cart-echo -spec: - selector: - app: cart-actor - type: LoadBalancer - ports: - - name: echo - port: 1234 ---- -kind: Service -apiVersion: v1 metadata: name: cart-actor annotations: @@ -248,10 +228,10 @@ metadata: name: cart-ingress annotations: cert-manager.io/cluster-issuer: letsencrypt-prod - # nginx.ingress.kubernetes.io/affinity: "cookie" - # nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity" - # nginx.ingress.kubernetes.io/session-cookie-expires: "172800" - # nginx.ingress.kubernetes.io/session-cookie-max-age: "172800" + nginx.ingress.kubernetes.io/affinity: "cookie" + nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity" + nginx.ingress.kubernetes.io/session-cookie-expires: "172800" + nginx.ingress.kubernetes.io/session-cookie-max-age: "172800" nginx.ingress.kubernetes.io/proxy-body-size: 4m spec: ingressClassName: nginx @@ -269,4 +249,4 @@ spec: service: name: cart-actor port: - number: 8080 \ No newline at end of file + number: 8080 diff --git a/deployment/scaling.yaml b/deployment/scaling.yaml index 6024cad..ea7b32c 100644 --- a/deployment/scaling.yaml +++ b/deployment/scaling.yaml @@ -1,25 +1,101 @@ -apiVersion: autoscaling/v1 +apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: - name: cart-scaler-amd + name: cart-scaler-amd spec: - scaleTargetRef: - apiVersion: apps/v1 - kind: Deployment - name: cart-actor-x86 - minReplicas: 3 - maxReplicas: 9 - targetCPUUtilizationPercentage: 30 + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: cart-actor-x86 + minReplicas: 3 + maxReplicas: 9 + behavior: + scaleUp: + stabilizationWindowSeconds: 60 + policies: + - type: Percent + value: 100 + periodSeconds: 60 + scaleDown: + stabilizationWindowSeconds: 180 + policies: + - type: Percent + value: 50 + periodSeconds: 60 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 50 + # Future custom metric (example): + # - type: Pods + # pods: + # metric: + # name: cart_mutations_per_second + # target: + # type: AverageValue + # averageValue: "15" + # - type: Object + # object: + # describedObject: + # apiVersion: networking.k8s.io/v1 + # kind: Ingress + # name: cart-ingress + # metric: + # name: http_requests_per_second + # target: + # type: Value + # value: "100" --- -apiVersion: autoscaling/v1 +apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: - name: cart-scaler-arm + name: cart-scaler-arm spec: - scaleTargetRef: - apiVersion: apps/v1 - kind: Deployment - name: cart-actor-arm64 - minReplicas: 3 - maxReplicas: 9 - targetCPUUtilizationPercentage: 30 \ No newline at end of file + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: cart-actor-arm64 + minReplicas: 3 + maxReplicas: 9 + behavior: + scaleUp: + stabilizationWindowSeconds: 60 + policies: + - type: Percent + value: 100 + periodSeconds: 60 + scaleDown: + stabilizationWindowSeconds: 180 + policies: + - type: Percent + value: 50 + periodSeconds: 60 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 50 + # Future custom metric (example): + # - type: Pods + # pods: + # metric: + # name: cart_mutations_per_second + # target: + # type: AverageValue + # averageValue: "15" + # - type: Object + # object: + # describedObject: + # apiVersion: networking.k8s.io/v1 + # kind: Ingress + # name: cart-ingress + # metric: + # name: http_requests_per_second + # target: + # type: Value + # value: "100" diff --git a/discarded-host.go b/discarded-host.go deleted file mode 100644 index c50d68d..0000000 --- a/discarded-host.go +++ /dev/null @@ -1,83 +0,0 @@ -package main - -import ( - "fmt" - "log" - "net" - "sync" - "time" -) - -type DiscardedHost struct { - *Connection - 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++ - host.Tries = -1 - } 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, - }) -} diff --git a/discarded-host_test.go b/discarded-host_test.go deleted file mode 100644 index e272bf7..0000000 --- a/discarded-host_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package main - -import ( - "testing" - "time" -) - -func TestDiscardedHost(t *testing.T) { - dh := NewDiscardedHostHandler(8080) - dh.SetReconnectHandler(func(host string) { - t.Log(host) - }) - dh.AppendHost("localhost") - time.Sleep(2 * time.Second) - if dh.hosts[0].Tries == 0 { - t.Error("Host not tested") - } -} diff --git a/disk-storage.go b/disk-storage.go deleted file mode 100644 index e6876ce..0000000 --- a/disk-storage.go +++ /dev/null @@ -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 -} diff --git a/go.mod b/go.mod index 59e5e67..b736325 100644 --- a/go.mod +++ b/go.mod @@ -1,63 +1,92 @@ module git.tornberg.me/go-cart-actor -go 1.24.2 +go 1.25.1 require ( - github.com/matst80/slask-finder v0.0.0-20250418094723-2eb7d6615761 - github.com/prometheus/client_golang v1.22.0 + github.com/gogo/protobuf v1.3.2 + github.com/google/uuid v1.6.0 + github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548 + github.com/prometheus/client_golang v1.23.2 github.com/rabbitmq/amqp091-go v1.10.0 - github.com/yudhasubki/netpool v0.0.0-20230717065341-3c1353ca328e - google.golang.org/protobuf v1.36.6 - k8s.io/api v0.32.3 - k8s.io/apimachinery v0.32.3 - k8s.io/client-go v0.32.3 + google.golang.org/grpc v1.76.0 + google.golang.org/protobuf v1.36.10 + k8s.io/api v0.34.1 + k8s.io/apimachinery v0.34.1 + k8s.io/client-go v0.34.1 ) require ( + github.com/RoaringBitmap/roaring/v2 v2.10.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/bits-and-blooms/bitset v1.24.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/emicklei/go-restful/v3 v3.12.2 // indirect - github.com/fxamacker/cbor/v2 v2.8.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect - github.com/go-openapi/jsonpointer v0.21.1 // indirect - github.com/go-openapi/jsonreference v0.21.0 // indirect - github.com/go-openapi/swag v0.23.1 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/protobuf v1.5.4 // indirect - github.com/google/gnostic-models v0.6.9 // indirect + github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect + github.com/emicklei/go-restful/v3 v3.13.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-openapi/jsonpointer v0.22.1 // indirect + github.com/go-openapi/jsonreference v0.21.2 // indirect + github.com/go-openapi/swag v0.25.1 // indirect + github.com/go-openapi/swag/cmdutils v0.25.1 // indirect + github.com/go-openapi/swag/conv v0.25.1 // indirect + github.com/go-openapi/swag/fileutils v0.25.1 // indirect + github.com/go-openapi/swag/jsonname v0.25.1 // indirect + github.com/go-openapi/swag/jsonutils v0.25.1 // indirect + github.com/go-openapi/swag/loading v0.25.1 // indirect + github.com/go-openapi/swag/mangling v0.25.1 // indirect + github.com/go-openapi/swag/netutils v0.25.1 // indirect + github.com/go-openapi/swag/stringutils v0.25.1 // indirect + github.com/go-openapi/swag/typeutils v0.25.1 // indirect + github.com/go-openapi/swag/yamlutils v0.25.1 // indirect + github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/gofuzz v1.2.0 // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/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/mailru/easyjson v0.9.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/mschoch/smat v0.2.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/pkg/errors v0.9.1 // indirect + github.com/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/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.63.0 // indirect - github.com/prometheus/procfs v0.16.0 // indirect - github.com/redis/go-redis/v9 v9.7.0 // indirect + github.com/prometheus/common v0.67.1 // indirect + github.com/prometheus/procfs v0.17.0 // indirect + github.com/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/vmware-labs/yaml-jsonpath v0.3.2 // indirect github.com/x448/float16 v0.8.4 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/oauth2 v0.29.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/term v0.31.0 // indirect - golang.org/x/text v0.24.0 // indirect - golang.org/x/time v0.11.0 // indirect - golang.org/x/tools v0.32.0 // indirect - gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.28.0 // indirect + golang.org/x/net v0.46.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/term v0.36.0 // indirect + golang.org/x/text v0.30.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 + gopkg.in/evanphx/json-patch.v4 v4.13.0 // 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/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect - k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e // indirect - sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect - sigs.k8s.io/yaml v1.4.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) + +tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen diff --git a/go.sum b/go.sum index e4aea59..1a1162d 100644 --- a/go.sum +++ b/go.sum @@ -1,49 +1,102 @@ -github.com/Flaconi/go-klarna v0.0.0-20230216165926-e2f708c721d9 h1:U5gu3M9/khqtvgg6iRKo0+nxGEfPHWFHRlKrbZvFxIY= -github.com/Flaconi/go-klarna v0.0.0-20230216165926-e2f708c721d9/go.mod h1:+LVFV9FXH5cwN1VcU30WcNYRs5FhkEtL7/IqqTD42cU= +github.com/RoaringBitmap/roaring/v2 v2.10.0 h1:HbJ8Cs71lfCJyvmSptxeMX2PtvOC8yonlU0GQcy2Ak0= +github.com/RoaringBitmap/roaring/v2 v2.10.0/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= -github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= -github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= -github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM= +github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/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.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= -github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU= -github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= -github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= -github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= -github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= -github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= -github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/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/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/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/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= +github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= +github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= +github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= +github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8= +github.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo= +github.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY= +github.com/go-openapi/swag/cmdutils v0.25.1/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0= +github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= +github.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU= +github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M= +github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= +github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= +github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8= +github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg= +github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw= +github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= +github.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync= +github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ= +github.com/go-openapi/swag/netutils v0.25.1 h1:2wFLYahe40tDUHfKT1GRC4rfa5T1B4GWZ+msEFA4Fl4= +github.com/go-openapi/swag/netutils v0.25.1/go.mod h1:CAkkvqnUJX8NV96tNhEQvKz8SQo2KF0f7LleiJwIeRE= +github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw= +github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= +github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA= +github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= +github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk= +github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= +github.com/go-task/slim-sprig 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/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/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/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= -github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.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/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-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/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= +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= @@ -52,128 +105,233 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI 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/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/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/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= -github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/matst80/slask-finder v0.0.0-20250418094723-2eb7d6615761 h1:kEvcfY+vCg+sCeFxHj5AzKbLMSmxsWU53OEPeCi28RU= -github.com/matst80/slask-finder v0.0.0-20250418094723-2eb7d6615761/go.mod h1:+DqYJ8l2i/6haKggOnecs2mU7T8CC3v5XW3R4UGCgo4= +github.com/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/go.mod h1:k4lo5gFYb3AqrgftlraCnv95xvjB/w8udRqJQJ12mAE= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/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/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/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/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/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= -github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= -github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM= -github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg= +github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= +github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= +github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= +github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= -github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= -github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/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/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +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/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/yudhasubki/netpool v0.0.0-20230717065341-3c1353ca328e h1:fAzVSmKQkWflN25ED65CH/C1T3iVWq2BQfN7eQsg4E4= -github.com/yudhasubki/netpool v0.0.0-20230717065341-3c1353ca328e/go.mod h1:gQsFrHrY6nviQu+VX7zKWDyhtLPNzngtYZ+C+7cywdk= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 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.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-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-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.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= -golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= -golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/net v0.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/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-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-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.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-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.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/sys v0.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/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/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/text v0.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/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= -golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff h1:A90eA31Wq6HOMIQlLfzFwzqGKBTuaVztYu/g8sn+8Zc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= +google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/protobuf 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/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-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/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= -gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/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/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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.32.3 h1:Hw7KqxRusq+6QSplE3NYG4MBxZw1BZnq4aP4cJVINls= -k8s.io/api v0.32.3/go.mod h1:2wEDTXADtm/HA7CCMD8D8bK4yuBUptzaRhYcYEEYA3k= -k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U= -k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= -k8s.io/client-go v0.32.3 h1:RKPVltzopkSgHS7aS98QdscAgtgah/+zmpAogooIqVU= -k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY= +k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= +k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= +k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= +k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= +k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= -k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= -k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e h1:KqK5c/ghOm8xkHYhlodbp6i6+r+ChV2vuAuVRdFbLro= -k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= -sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= -sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxgMd7wez1XkI= -sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/grafana_dashboard_cart.json b/grafana_dashboard_cart.json new file mode 100644 index 0000000..8379c13 --- /dev/null +++ b/grafana_dashboard_cart.json @@ -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"] + } +} diff --git a/grain-pool.go b/grain-pool.go deleted file mode 100644 index f69a8fa..0000000 --- a/grain-pool.go +++ /dev/null @@ -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 -} diff --git a/id_test.go b/id_test.go deleted file mode 100644 index d8953b8..0000000 --- a/id_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package main - -import ( - "testing" -) - -func TestIdGeneration(t *testing.T) { - // Generate a random ID - id := NewCartId() - - // Generate a random ID - id2 := NewCartId() - // Compare the two IDs - if id == id2 { - t.Errorf("IDs are the same: %v == %v", id, id2) - } else { - t.Log("ID generation test passed", id, id2) - } -} diff --git a/k6/README.md b/k6/README.md new file mode 100644 index 0000000..0818791 --- /dev/null +++ b/k6/README.md @@ -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! \ No newline at end of file diff --git a/k6/cart_load_test.js b/k6/cart_load_test.js new file mode 100644 index 0000000..2fff92d --- /dev/null +++ b/k6/cart_load_test.js @@ -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, + ), + }; +} diff --git a/main_test.go b/main_test.go deleted file mode 100644 index 1479969..0000000 --- a/main_test.go +++ /dev/null @@ -1,286 +0,0 @@ -package main - -import ( - "os" - "testing" -) - -func TestGetCountryFromHost(t *testing.T) { - tests := []struct { - name string - host string - expected string - }{ - { - name: "Norwegian host", - host: "s10n-no.tornberg.me", - expected: "no", - }, - { - name: "Swedish host", - host: "s10n-se.tornberg.me", - expected: "se", - }, - { - name: "Host with -no in the middle", - host: "api-no-staging.tornberg.me", - expected: "no", - }, - { - name: "Host without country suffix", - host: "s10n.tornberg.me", - expected: "se", - }, - { - name: "Host with different domain", - host: "example-no.com", - expected: "no", - }, - { - name: "Empty host", - host: "", - expected: "se", - }, - { - name: "Host with uppercase", - host: "S10N-NO.TORNBERG.ME", - expected: "no", - }, - { - name: "Host with mixed case", - host: "S10n-No.Tornberg.Me", - expected: "no", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := getCountryFromHost(tt.host) - if result != tt.expected { - t.Errorf("getCountryFromHost(%q) = %q, want %q", tt.host, result, tt.expected) - } - }) - } -} - -func TestGetCheckoutOrder(t *testing.T) { - // Save original environment variable and restore after test - originalCartBaseUrl := os.Getenv("CART_BASE_URL") - defer func() { - if originalCartBaseUrl == "" { - os.Unsetenv("CART_BASE_URL") - } else { - os.Setenv("CART_BASE_URL", originalCartBaseUrl) - } - }() - - tests := []struct { - name string - host string - cartId CartId - cartBaseUrl string - expectedUrls struct { - terms string - checkout string - confirmation string - validation string - push string - country string - } - }{ - { - name: "Norwegian host with default cart base URL", - host: "s10n-no.tornberg.me", - cartId: ToCartId("test-cart-123"), - cartBaseUrl: "", // Use default - expectedUrls: struct { - terms string - checkout string - confirmation string - validation string - push string - country string - }{ - terms: "https://s10n-no.tornberg.me/terms", - checkout: "https://s10n-no.tornberg.me/checkout?order_id={checkout.order.id}", - confirmation: "https://s10n-no.tornberg.me/confirmation/{checkout.order.id}", - validation: "https://cart.tornberg.me/validation", - push: "https://cart.tornberg.me/push?order_id={checkout.order.id}", - country: "no", - }, - }, - { - name: "Swedish host with default cart base URL", - host: "s10n-se.tornberg.me", - cartId: ToCartId("test-cart-456"), - cartBaseUrl: "", // Use default - expectedUrls: struct { - terms string - checkout string - confirmation string - validation string - push string - country string - }{ - terms: "https://s10n-se.tornberg.me/terms", - checkout: "https://s10n-se.tornberg.me/checkout?order_id={checkout.order.id}", - confirmation: "https://s10n-se.tornberg.me/confirmation/{checkout.order.id}", - validation: "https://cart.tornberg.me/validation", - push: "https://cart.tornberg.me/push?order_id={checkout.order.id}", - country: "se", - }, - }, - { - name: "Norwegian host with custom cart base URL", - host: "s10n-no.tornberg.me", - cartId: ToCartId("test-cart-789"), - cartBaseUrl: "https://custom-cart.example.com", - expectedUrls: struct { - terms string - checkout string - confirmation string - validation string - push string - country string - }{ - terms: "https://s10n-no.tornberg.me/terms", - checkout: "https://s10n-no.tornberg.me/checkout?order_id={checkout.order.id}", - confirmation: "https://s10n-no.tornberg.me/confirmation/{checkout.order.id}", - validation: "https://custom-cart.example.com/validation", - push: "https://custom-cart.example.com/push?order_id={checkout.order.id}", - country: "no", - }, - }, - { - name: "Host without country code defaults to Swedish", - host: "s10n.tornberg.me", - cartId: ToCartId("test-cart-default"), - cartBaseUrl: "", - expectedUrls: struct { - terms string - checkout string - confirmation string - validation string - push string - country string - }{ - terms: "https://s10n.tornberg.me/terms", - checkout: "https://s10n.tornberg.me/checkout?order_id={checkout.order.id}", - confirmation: "https://s10n.tornberg.me/confirmation/{checkout.order.id}", - validation: "https://cart.tornberg.me/validation", - push: "https://cart.tornberg.me/push?order_id={checkout.order.id}", - country: "se", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Set up environment variable for this test - if tt.cartBaseUrl == "" { - os.Unsetenv("CART_BASE_URL") - } else { - os.Setenv("CART_BASE_URL", tt.cartBaseUrl) - } - - result := getCheckoutOrder(tt.host, tt.cartId) - - // Verify the result is not nil - if result == nil { - t.Fatal("getCheckoutOrder returned nil") - } - - // Check each URL field - if result.Terms != tt.expectedUrls.terms { - t.Errorf("Terms URL: got %q, want %q", result.Terms, tt.expectedUrls.terms) - } - - if result.Checkout != tt.expectedUrls.checkout { - t.Errorf("Checkout URL: got %q, want %q", result.Checkout, tt.expectedUrls.checkout) - } - - if result.Confirmation != tt.expectedUrls.confirmation { - t.Errorf("Confirmation URL: got %q, want %q", result.Confirmation, tt.expectedUrls.confirmation) - } - - if result.Validation != tt.expectedUrls.validation { - t.Errorf("Validation URL: got %q, want %q", result.Validation, tt.expectedUrls.validation) - } - - if result.Push != tt.expectedUrls.push { - t.Errorf("Push URL: got %q, want %q", result.Push, tt.expectedUrls.push) - } - - if result.Country != tt.expectedUrls.country { - t.Errorf("Country: got %q, want %q", result.Country, tt.expectedUrls.country) - } - }) - } -} - -func TestGetCheckoutOrderIntegration(t *testing.T) { - // Test that both functions work together correctly - hosts := []string{"s10n-no.tornberg.me", "s10n-se.tornberg.me"} - cartId := ToCartId("integration-test-cart") - - for _, host := range hosts { - t.Run(host, func(t *testing.T) { - // Get country from host - country := getCountryFromHost(host) - - // Get checkout order - order := getCheckoutOrder(host, cartId) - - // Verify that the country in the order matches what getCountryFromHost returns - if order.Country != country { - t.Errorf("Country mismatch: getCountryFromHost(%q) = %q, but order.Country = %q", - host, country, order.Country) - } - - // Verify that all URLs contain the correct host - expectedBaseUrl := "https://" + host - - if !containsPrefix(order.Terms, expectedBaseUrl) { - t.Errorf("Terms URL should start with %q, got %q", expectedBaseUrl, order.Terms) - } - - if !containsPrefix(order.Checkout, expectedBaseUrl) { - t.Errorf("Checkout URL should start with %q, got %q", expectedBaseUrl, order.Checkout) - } - - if !containsPrefix(order.Confirmation, expectedBaseUrl) { - t.Errorf("Confirmation URL should start with %q, got %q", expectedBaseUrl, order.Confirmation) - } - }) - } -} - -// Helper function to check if a string starts with a prefix -func containsPrefix(s, prefix string) bool { - return len(s) >= len(prefix) && s[:len(prefix)] == prefix -} - -// Benchmark tests to measure performance -func BenchmarkGetCountryFromHost(b *testing.B) { - hosts := []string{ - "s10n-no.tornberg.me", - "s10n-se.tornberg.me", - "api-no-staging.tornberg.me", - "s10n.tornberg.me", - } - - for i := 0; i < b.N; i++ { - for _, host := range hosts { - getCountryFromHost(host) - } - } -} - -func BenchmarkGetCheckoutOrder(b *testing.B) { - host := "s10n-no.tornberg.me" - cartId := ToCartId("benchmark-cart") - - for i := 0; i < b.N; i++ { - getCheckoutOrder(host, cartId) - } -} diff --git a/message-handler.go b/message-handler.go deleted file mode 100644 index 36ab61c..0000000 --- a/message-handler.go +++ /dev/null @@ -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 -} diff --git a/message-handler_test.go b/message-handler_test.go deleted file mode 100644 index 9195457..0000000 --- a/message-handler_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package main - -import ( - "bytes" - "testing" - - messages "git.tornberg.me/go-cart-actor/proto" -) - -func TestAddRequest(t *testing.T) { - h, err := GetMessageHandler(AddRequestType) - if err != nil { - t.Errorf("Error getting message handler: %v\n", err) - } - if h == nil { - t.Errorf("Expected message handler, got nil\n") - } - message := Message{ - Type: AddRequestType, - Content: &messages.AddRequest{ - Quantity: 2, - Sku: "123", - }, - } - var b bytes.Buffer - err = h.Write(&message, &b) - if err != nil { - t.Errorf("Error writing message: %v\n", err) - } - result, err := h.Read(b.Bytes()) - if err != nil { - t.Errorf("Error reading message: %v\n", err) - } - if result == nil { - t.Errorf("Expected result, got nil\n") - } - r, ok := result.(*messages.AddRequest) - if !ok { - t.Errorf("Expected AddRequest, got %T\n", result) - } - if r.Quantity != 2 { - t.Errorf("Expected quantity 2, got %d\n", r.Quantity) - } - if r.Sku != "123" { - t.Errorf("Expected sku '123', got %s\n", r.Sku) - } -} - -func TestItemRequest(t *testing.T) { - h, err := GetMessageHandler(AddItemType) - if err != nil { - t.Errorf("Error getting message handler: %v\n", err) - } - if h == nil { - t.Errorf("Expected message handler, got nil\n") - } - message := Message{ - Type: AddItemType, - Content: &messages.AddItem{ - Quantity: 2, - Sku: "123", - Price: 100, - Name: "Test item", - Image: "test.jpg", - }, - } - var b bytes.Buffer - err = h.Write(&message, &b) - if err != nil { - t.Errorf("Error writing message: %v\n", err) - } - result, err := h.Read(b.Bytes()) - if err != nil { - t.Errorf("Error reading message: %v\n", err) - } - if result == nil { - t.Errorf("Expected result, got nil\n") - } - var r *messages.AddItem - ok := h.Is(&message) - if !ok { - t.Errorf("Expected AddRequest, got %T\n", result) - } - if r.Quantity != 2 { - t.Errorf("Expected quantity 2, got %d\n", r.Quantity) - } - if r.Sku != "123" { - t.Errorf("Expected sku '123', got %s\n", r.Sku) - } -} - -func TestSetDeliveryMssage(t *testing.T) { - h, err := GetMessageHandler(SetDeliveryType) - if err != nil { - t.Errorf("Error getting message handler: %v\n", err) - } - if h == nil { - t.Errorf("Expected message handler, got nil\n") - } - message := Message{ - Type: SetDeliveryType, - Content: &messages.SetDelivery{ - Provider: "test", - Items: []int64{1, 2}, - }, - } - var b bytes.Buffer - err = h.Write(&message, &b) - if err != nil { - t.Errorf("Error writing message: %v\n", err) - } - result, err := h.Read(b.Bytes()) - if err != nil { - t.Errorf("Error reading message: %v\n", err) - } - if result == nil { - t.Errorf("Expected result, got nil\n") - } - r, ok := result.(*messages.SetDelivery) - if !ok { - t.Errorf("Expected AddRequest, got %T\n", result) - } - if len(r.Items) != 2 { - t.Errorf("Expected 2 items, got %d\n", len(r.Items)) - } - if r.Provider != "test" { - t.Errorf("Expected provider 'test', got %s\n", r.Provider) - } -} diff --git a/message-types.go b/message-types.go deleted file mode 100644 index 28b11fc..0000000 --- a/message-types.go +++ /dev/null @@ -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 -) diff --git a/message.go b/message.go deleted file mode 100644 index 748a205..0000000 --- a/message.go +++ /dev/null @@ -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 -} diff --git a/packet.go b/packet.go deleted file mode 100644 index 58c223c..0000000 --- a/packet.go +++ /dev/null @@ -1,89 +0,0 @@ -package main - -const ( - RemoteGetState = FrameType(0x01) - RemoteHandleMutation = FrameType(0x02) - ResponseBody = FrameType(0x03) - RemoteGetStateReply = FrameType(0x04) - RemoteHandleMutationReply = FrameType(0x05) - RemoteCreateOrderReply = FrameType(0x06) -) - -// type CartPacket struct { -// Version PackageVersion -// MessageType CartMessage -// DataLength uint32 -// StatusCode uint32 -// Id CartId -// } - -// type Packet struct { -// Version PackageVersion -// MessageType PoolMessage -// DataLength uint32 -// StatusCode uint32 -// } - -// var headerData = make([]byte, 4) - -// func matchHeader(conn io.Reader) error { - -// pos := 0 -// for pos < 4 { - -// l, err := conn.Read(headerData) -// if err != nil { -// return err -// } -// for i := 0; i < l; i++ { -// if headerData[i] == header[pos] { -// pos++ -// if pos == 4 { -// return nil -// } -// } else { -// pos = 0 -// } -// } -// } -// return nil -// } - -// func ReadPacket(conn io.Reader, packet *Packet) error { -// err := matchHeader(conn) -// if err != nil { -// return err -// } -// return binary.Read(conn, binary.LittleEndian, packet) -// } - -// func ReadCartPacket(conn io.Reader, packet *CartPacket) error { -// err := matchHeader(conn) -// if err != nil { -// return err -// } -// return binary.Read(conn, binary.LittleEndian, packet) -// } - -// func GetPacketData(conn io.Reader, len uint32) ([]byte, error) { -// if len == 0 { -// return []byte{}, nil -// } -// data := make([]byte, len) -// _, err := conn.Read(data) -// return data, err -// } - -// func ReceivePacket(conn io.Reader) (uint32, []byte, error) { -// var packet Packet -// err := ReadPacket(conn, &packet) -// if err != nil { -// return 0, nil, err -// } - -// data, err := GetPacketData(conn, packet.DataLength) -// if err != nil { -// return 0, nil, err -// } -// return packet.MessageType, data, nil -// } diff --git a/pkg/actor/disk_storage.go b/pkg/actor/disk_storage.go new file mode 100644 index 0000000..2b71093 --- /dev/null +++ b/pkg/actor/disk_storage.go @@ -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 + } + +} diff --git a/pkg/actor/grain.go b/pkg/actor/grain.go new file mode 100644 index 0000000..6457d09 --- /dev/null +++ b/pkg/actor/grain.go @@ -0,0 +1,13 @@ +package actor + +import ( + "time" +) + +type Grain[V any] interface { + GetId() uint64 + + GetLastAccess() time.Time + GetLastChange() time.Time + GetCurrentState() (*V, error) +} diff --git a/pkg/actor/grain_pool.go b/pkg/actor/grain_pool.go new file mode 100644 index 0000000..d81e2f3 --- /dev/null +++ b/pkg/actor/grain_pool.go @@ -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) +} diff --git a/pkg/actor/grpc_server.go b/pkg/actor/grpc_server.go new file mode 100644 index 0000000..76b24da --- /dev/null +++ b/pkg/actor/grpc_server.go @@ -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 +} diff --git a/pkg/actor/mutation_registry.go b/pkg/actor/mutation_registry.go new file mode 100644 index 0000000..8b3d59d --- /dev/null +++ b/pkg/actor/mutation_registry.go @@ -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 +} diff --git a/pkg/actor/mutation_registry_test.go b/pkg/actor/mutation_registry_test.go new file mode 100644 index 0000000..58e134e --- /dev/null +++ b/pkg/actor/mutation_registry_test.go @@ -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 diff --git a/pkg/actor/simple_grain_pool.go b/pkg/actor/simple_grain_pool.go new file mode 100644 index 0000000..77b53c5 --- /dev/null +++ b/pkg/actor/simple_grain_pool.go @@ -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() + } +} diff --git a/pkg/actor/state.go b/pkg/actor/state.go new file mode 100644 index 0000000..14020d9 --- /dev/null +++ b/pkg/actor/state.go @@ -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 +} diff --git a/discovery.go b/pkg/discovery/discovery.go similarity index 88% rename from discovery.go rename to pkg/discovery/discovery.go index 20952a9..df19716 100644 --- a/discovery.go +++ b/pkg/discovery/discovery.go @@ -1,4 +1,4 @@ -package main +package discovery import ( "context" @@ -11,11 +11,6 @@ import ( toolsWatch "k8s.io/client-go/tools/watch" ) -type Discovery interface { - Discover() ([]string, error) - Watch() (<-chan HostChange, error) -} - type K8sDiscovery struct { ctx context.Context client *kubernetes.Clientset @@ -51,7 +46,7 @@ func (k *K8sDiscovery) Watch() (<-chan HostChange, error) { 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 { return nil, err } @@ -60,6 +55,7 @@ func (k *K8sDiscovery) Watch() (<-chan HostChange, error) { for event := range watcher.ResultChan() { pod := event.Object.(*v1.Pod) + // log.Printf("pod change %+v", pod.Status.Phase == v1.PodRunning) ch <- HostChange{ Host: pod.Status.PodIP, Type: event.Type, diff --git a/pkg/discovery/discovery_mock.go b/pkg/discovery/discovery_mock.go new file mode 100644 index 0000000..85f791a --- /dev/null +++ b/pkg/discovery/discovery_mock.go @@ -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) +} diff --git a/discovery_test.go b/pkg/discovery/discovery_test.go similarity index 84% rename from discovery_test.go rename to pkg/discovery/discovery_test.go index a3b4c40..e2a955c 100644 --- a/discovery_test.go +++ b/pkg/discovery/discovery_test.go @@ -1,4 +1,4 @@ -package main +package discovery import ( "testing" @@ -9,7 +9,7 @@ import ( ) func TestDiscovery(t *testing.T) { - config, err := clientcmd.BuildConfigFromFlags("", "/Users/mats/.kube/config") + config, err := clientcmd.BuildConfigFromFlags("", "/home/mats/.kube/config") if err != nil { t.Errorf("Error building config: %v", err) } @@ -28,7 +28,7 @@ func TestDiscovery(t *testing.T) { } func TestWatch(t *testing.T) { - config, err := clientcmd.BuildConfigFromFlags("", "/Users/mats/.kube/config") + config, err := clientcmd.BuildConfigFromFlags("", "/home/mats/.kube/config") if err != nil { t.Errorf("Error building config: %v", err) } diff --git a/pkg/discovery/types.go b/pkg/discovery/types.go new file mode 100644 index 0000000..6613553 --- /dev/null +++ b/pkg/discovery/types.go @@ -0,0 +1,6 @@ +package discovery + +type Discovery interface { + Discover() ([]string, error) + Watch() (<-chan HostChange, error) +} diff --git a/pkg/messages/control_plane.pb.go b/pkg/messages/control_plane.pb.go new file mode 100644 index 0000000..95aed7d --- /dev/null +++ b/pkg/messages/control_plane.pb.go @@ -0,0 +1,582 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.5 +// protoc v5.29.3 +// source: control_plane.proto + +package messages + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Empty request placeholder (common pattern). +type Empty struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Empty) Reset() { + *x = Empty{} + mi := &file_control_plane_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Empty) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Empty) ProtoMessage() {} + +func (x *Empty) ProtoReflect() protoreflect.Message { + mi := &file_control_plane_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Empty.ProtoReflect.Descriptor instead. +func (*Empty) Descriptor() ([]byte, []int) { + return file_control_plane_proto_rawDescGZIP(), []int{0} +} + +// Ping reply includes responding host and its current unix time (seconds). +type PingReply struct { + state protoimpl.MessageState `protogen:"open.v1"` + Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` + UnixTime int64 `protobuf:"varint,2,opt,name=unix_time,json=unixTime,proto3" json:"unix_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PingReply) Reset() { + *x = PingReply{} + mi := &file_control_plane_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PingReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PingReply) ProtoMessage() {} + +func (x *PingReply) ProtoReflect() protoreflect.Message { + mi := &file_control_plane_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PingReply.ProtoReflect.Descriptor instead. +func (*PingReply) Descriptor() ([]byte, []int) { + return file_control_plane_proto_rawDescGZIP(), []int{1} +} + +func (x *PingReply) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *PingReply) GetUnixTime() int64 { + if x != nil { + return x.UnixTime + } + return 0 +} + +// NegotiateRequest carries the caller's full view of known hosts (including self). +type NegotiateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + KnownHosts []string `protobuf:"bytes,1,rep,name=known_hosts,json=knownHosts,proto3" json:"known_hosts,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NegotiateRequest) Reset() { + *x = NegotiateRequest{} + mi := &file_control_plane_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NegotiateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NegotiateRequest) ProtoMessage() {} + +func (x *NegotiateRequest) ProtoReflect() protoreflect.Message { + mi := &file_control_plane_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NegotiateRequest.ProtoReflect.Descriptor instead. +func (*NegotiateRequest) Descriptor() ([]byte, []int) { + return file_control_plane_proto_rawDescGZIP(), []int{2} +} + +func (x *NegotiateRequest) GetKnownHosts() []string { + if x != nil { + return x.KnownHosts + } + return nil +} + +// NegotiateReply returns the callee's healthy hosts (including itself). +type NegotiateReply struct { + state protoimpl.MessageState `protogen:"open.v1"` + Hosts []string `protobuf:"bytes,1,rep,name=hosts,proto3" json:"hosts,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NegotiateReply) Reset() { + *x = NegotiateReply{} + mi := &file_control_plane_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NegotiateReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NegotiateReply) ProtoMessage() {} + +func (x *NegotiateReply) ProtoReflect() protoreflect.Message { + mi := &file_control_plane_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NegotiateReply.ProtoReflect.Descriptor instead. +func (*NegotiateReply) Descriptor() ([]byte, []int) { + return file_control_plane_proto_rawDescGZIP(), []int{3} +} + +func (x *NegotiateReply) GetHosts() []string { + if x != nil { + return x.Hosts + } + return nil +} + +// CartIdsReply returns the list of cart IDs (string form) currently owned locally. +type ActorIdsReply struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ids []uint64 `protobuf:"varint,1,rep,packed,name=ids,proto3" json:"ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ActorIdsReply) Reset() { + *x = ActorIdsReply{} + mi := &file_control_plane_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ActorIdsReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ActorIdsReply) ProtoMessage() {} + +func (x *ActorIdsReply) ProtoReflect() protoreflect.Message { + mi := &file_control_plane_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ActorIdsReply.ProtoReflect.Descriptor instead. +func (*ActorIdsReply) Descriptor() ([]byte, []int) { + return file_control_plane_proto_rawDescGZIP(), []int{4} +} + +func (x *ActorIdsReply) GetIds() []uint64 { + if x != nil { + return x.Ids + } + return nil +} + +// OwnerChangeAck retained as response type for Closing RPC (ConfirmOwner removed). +type OwnerChangeAck struct { + state protoimpl.MessageState `protogen:"open.v1"` + Accepted bool `protobuf:"varint,1,opt,name=accepted,proto3" json:"accepted,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OwnerChangeAck) Reset() { + *x = OwnerChangeAck{} + mi := &file_control_plane_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OwnerChangeAck) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OwnerChangeAck) ProtoMessage() {} + +func (x *OwnerChangeAck) ProtoReflect() protoreflect.Message { + mi := &file_control_plane_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OwnerChangeAck.ProtoReflect.Descriptor instead. +func (*OwnerChangeAck) Descriptor() ([]byte, []int) { + return file_control_plane_proto_rawDescGZIP(), []int{5} +} + +func (x *OwnerChangeAck) GetAccepted() bool { + if x != nil { + return x.Accepted + } + return false +} + +func (x *OwnerChangeAck) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +// ClosingNotice notifies peers this host is terminating (so they can drop / re-resolve). +type ClosingNotice struct { + state protoimpl.MessageState `protogen:"open.v1"` + Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClosingNotice) Reset() { + *x = ClosingNotice{} + mi := &file_control_plane_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClosingNotice) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClosingNotice) ProtoMessage() {} + +func (x *ClosingNotice) ProtoReflect() protoreflect.Message { + mi := &file_control_plane_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClosingNotice.ProtoReflect.Descriptor instead. +func (*ClosingNotice) Descriptor() ([]byte, []int) { + return file_control_plane_proto_rawDescGZIP(), []int{6} +} + +func (x *ClosingNotice) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +// 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_rawDesc = string([]byte{ + 0x0a, 0x13, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, + 0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x3c, 0x0a, 0x09, 0x50, 0x69, 0x6e, 0x67, + 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x75, 0x6e, 0x69, + 0x78, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x75, 0x6e, + 0x69, 0x78, 0x54, 0x69, 0x6d, 0x65, 0x22, 0x33, 0x0a, 0x10, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69, + 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x6b, 0x6e, + 0x6f, 0x77, 0x6e, 0x5f, 0x68, 0x6f, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, + 0x0a, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x48, 0x6f, 0x73, 0x74, 0x73, 0x22, 0x26, 0x0a, 0x0e, 0x4e, + 0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x14, 0x0a, + 0x05, 0x68, 0x6f, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x68, 0x6f, + 0x73, 0x74, 0x73, 0x22, 0x21, 0x0a, 0x0d, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x73, 0x52, + 0x65, 0x70, 0x6c, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, + 0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22, 0x46, 0x0a, 0x0e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, + 0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, + 0x70, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, + 0x70, 0x74, 0x65, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x23, + 0x0a, 0x0d, 0x43, 0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67, 0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x12, + 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, + 0x6f, 0x73, 0x74, 0x22, 0x39, 0x0a, 0x11, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, + 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, + 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22, 0x36, + 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x79, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, + 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, + 0x68, 0x6f, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, + 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 ( + file_control_plane_proto_rawDescOnce sync.Once + file_control_plane_proto_rawDescData []byte +) + +func file_control_plane_proto_rawDescGZIP() []byte { + file_control_plane_proto_rawDescOnce.Do(func() { + file_control_plane_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_control_plane_proto_rawDesc), len(file_control_plane_proto_rawDesc))) + }) + return file_control_plane_proto_rawDescData +} + +var file_control_plane_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_control_plane_proto_goTypes = []any{ + (*Empty)(nil), // 0: messages.Empty + (*PingReply)(nil), // 1: messages.PingReply + (*NegotiateRequest)(nil), // 2: messages.NegotiateRequest + (*NegotiateReply)(nil), // 3: messages.NegotiateReply + (*ActorIdsReply)(nil), // 4: messages.ActorIdsReply + (*OwnerChangeAck)(nil), // 5: messages.OwnerChangeAck + (*ClosingNotice)(nil), // 6: messages.ClosingNotice + (*OwnershipAnnounce)(nil), // 7: messages.OwnershipAnnounce + (*ExpiryAnnounce)(nil), // 8: messages.ExpiryAnnounce +} +var file_control_plane_proto_depIdxs = []int32{ + 0, // 0: messages.ControlPlane.Ping:input_type -> messages.Empty + 2, // 1: messages.ControlPlane.Negotiate:input_type -> messages.NegotiateRequest + 0, // 2: messages.ControlPlane.GetLocalActorIds:input_type -> messages.Empty + 7, // 3: messages.ControlPlane.AnnounceOwnership:input_type -> messages.OwnershipAnnounce + 8, // 4: messages.ControlPlane.AnnounceExpiry:input_type -> messages.ExpiryAnnounce + 6, // 5: messages.ControlPlane.Closing:input_type -> messages.ClosingNotice + 1, // 6: messages.ControlPlane.Ping:output_type -> messages.PingReply + 3, // 7: messages.ControlPlane.Negotiate:output_type -> messages.NegotiateReply + 4, // 8: messages.ControlPlane.GetLocalActorIds:output_type -> messages.ActorIdsReply + 5, // 9: messages.ControlPlane.AnnounceOwnership:output_type -> messages.OwnerChangeAck + 5, // 10: messages.ControlPlane.AnnounceExpiry:output_type -> messages.OwnerChangeAck + 5, // 11: messages.ControlPlane.Closing:output_type -> messages.OwnerChangeAck + 6, // [6:12] is the sub-list for method output_type + 0, // [0:6] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_control_plane_proto_init() } +func file_control_plane_proto_init() { + if File_control_plane_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_control_plane_proto_rawDesc), len(file_control_plane_proto_rawDesc)), + NumEnums: 0, + NumMessages: 9, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_control_plane_proto_goTypes, + DependencyIndexes: file_control_plane_proto_depIdxs, + MessageInfos: file_control_plane_proto_msgTypes, + }.Build() + File_control_plane_proto = out.File + file_control_plane_proto_goTypes = nil + file_control_plane_proto_depIdxs = nil +} diff --git a/pkg/messages/control_plane_grpc.pb.go b/pkg/messages/control_plane_grpc.pb.go new file mode 100644 index 0000000..d0c9cf5 --- /dev/null +++ b/pkg/messages/control_plane_grpc.pb.go @@ -0,0 +1,327 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v5.29.3 +// source: control_plane.proto + +package messages + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + ControlPlane_Ping_FullMethodName = "/messages.ControlPlane/Ping" + ControlPlane_Negotiate_FullMethodName = "/messages.ControlPlane/Negotiate" + ControlPlane_GetLocalActorIds_FullMethodName = "/messages.ControlPlane/GetLocalActorIds" + ControlPlane_AnnounceOwnership_FullMethodName = "/messages.ControlPlane/AnnounceOwnership" + ControlPlane_AnnounceExpiry_FullMethodName = "/messages.ControlPlane/AnnounceExpiry" + ControlPlane_Closing_FullMethodName = "/messages.ControlPlane/Closing" +) + +// ControlPlaneClient is the client API for ControlPlane service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// ControlPlane defines cluster coordination and ownership operations. +type ControlPlaneClient interface { + // Ping for liveness; lightweight health signal. + Ping(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*PingReply, error) + // Negotiate merges host views; used during discovery & convergence. + Negotiate(ctx context.Context, in *NegotiateRequest, opts ...grpc.CallOption) (*NegotiateReply, error) + // GetCartIds lists currently owned cart IDs on this node. + GetLocalActorIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*ActorIdsReply, error) + // Ownership announcement: first-touch claim broadcast (idempotent; best-effort). + AnnounceOwnership(ctx context.Context, in *OwnershipAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error) + // Expiry announcement: drop remote ownership hints when local TTL expires. + AnnounceExpiry(ctx context.Context, in *ExpiryAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error) + // Closing announces graceful shutdown so peers can proactively adjust. + Closing(ctx context.Context, in *ClosingNotice, opts ...grpc.CallOption) (*OwnerChangeAck, error) +} + +type controlPlaneClient struct { + cc grpc.ClientConnInterface +} + +func NewControlPlaneClient(cc grpc.ClientConnInterface) ControlPlaneClient { + return &controlPlaneClient{cc} +} + +func (c *controlPlaneClient) Ping(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*PingReply, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(PingReply) + err := c.cc.Invoke(ctx, ControlPlane_Ping_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *controlPlaneClient) Negotiate(ctx context.Context, in *NegotiateRequest, opts ...grpc.CallOption) (*NegotiateReply, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(NegotiateReply) + err := c.cc.Invoke(ctx, ControlPlane_Negotiate_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *controlPlaneClient) GetLocalActorIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*ActorIdsReply, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ActorIdsReply) + err := c.cc.Invoke(ctx, ControlPlane_GetLocalActorIds_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *controlPlaneClient) AnnounceOwnership(ctx context.Context, in *OwnershipAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(OwnerChangeAck) + err := c.cc.Invoke(ctx, ControlPlane_AnnounceOwnership_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *controlPlaneClient) AnnounceExpiry(ctx context.Context, in *ExpiryAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(OwnerChangeAck) + err := c.cc.Invoke(ctx, ControlPlane_AnnounceExpiry_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *controlPlaneClient) Closing(ctx context.Context, in *ClosingNotice, opts ...grpc.CallOption) (*OwnerChangeAck, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(OwnerChangeAck) + err := c.cc.Invoke(ctx, ControlPlane_Closing_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// ControlPlaneServer is the server API for ControlPlane service. +// All implementations must embed UnimplementedControlPlaneServer +// for forward compatibility. +// +// ControlPlane defines cluster coordination and ownership operations. +type ControlPlaneServer interface { + // Ping for liveness; lightweight health signal. + Ping(context.Context, *Empty) (*PingReply, error) + // Negotiate merges host views; used during discovery & convergence. + Negotiate(context.Context, *NegotiateRequest) (*NegotiateReply, error) + // GetCartIds lists currently owned cart IDs on this node. + GetLocalActorIds(context.Context, *Empty) (*ActorIdsReply, error) + // Ownership announcement: first-touch claim broadcast (idempotent; best-effort). + AnnounceOwnership(context.Context, *OwnershipAnnounce) (*OwnerChangeAck, error) + // Expiry announcement: drop remote ownership hints when local TTL expires. + AnnounceExpiry(context.Context, *ExpiryAnnounce) (*OwnerChangeAck, error) + // Closing announces graceful shutdown so peers can proactively adjust. + Closing(context.Context, *ClosingNotice) (*OwnerChangeAck, error) + mustEmbedUnimplementedControlPlaneServer() +} + +// UnimplementedControlPlaneServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedControlPlaneServer struct{} + +func (UnimplementedControlPlaneServer) Ping(context.Context, *Empty) (*PingReply, error) { + return nil, status.Errorf(codes.Unimplemented, "method Ping not implemented") +} +func (UnimplementedControlPlaneServer) Negotiate(context.Context, *NegotiateRequest) (*NegotiateReply, error) { + return nil, status.Errorf(codes.Unimplemented, "method Negotiate not implemented") +} +func (UnimplementedControlPlaneServer) GetLocalActorIds(context.Context, *Empty) (*ActorIdsReply, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetLocalActorIds not implemented") +} +func (UnimplementedControlPlaneServer) AnnounceOwnership(context.Context, *OwnershipAnnounce) (*OwnerChangeAck, error) { + return nil, status.Errorf(codes.Unimplemented, "method AnnounceOwnership not implemented") +} +func (UnimplementedControlPlaneServer) AnnounceExpiry(context.Context, *ExpiryAnnounce) (*OwnerChangeAck, error) { + return nil, status.Errorf(codes.Unimplemented, "method AnnounceExpiry not implemented") +} +func (UnimplementedControlPlaneServer) Closing(context.Context, *ClosingNotice) (*OwnerChangeAck, error) { + return nil, status.Errorf(codes.Unimplemented, "method Closing not implemented") +} +func (UnimplementedControlPlaneServer) mustEmbedUnimplementedControlPlaneServer() {} +func (UnimplementedControlPlaneServer) testEmbeddedByValue() {} + +// UnsafeControlPlaneServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ControlPlaneServer will +// result in compilation errors. +type UnsafeControlPlaneServer interface { + mustEmbedUnimplementedControlPlaneServer() +} + +func RegisterControlPlaneServer(s grpc.ServiceRegistrar, srv ControlPlaneServer) { + // If the following call pancis, it indicates UnimplementedControlPlaneServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&ControlPlane_ServiceDesc, srv) +} + +func _ControlPlane_Ping_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ControlPlaneServer).Ping(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ControlPlane_Ping_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ControlPlaneServer).Ping(ctx, req.(*Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _ControlPlane_Negotiate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(NegotiateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ControlPlaneServer).Negotiate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ControlPlane_Negotiate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ControlPlaneServer).Negotiate(ctx, req.(*NegotiateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ControlPlane_GetLocalActorIds_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ControlPlaneServer).GetLocalActorIds(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ControlPlane_GetLocalActorIds_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ControlPlaneServer).GetLocalActorIds(ctx, req.(*Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _ControlPlane_AnnounceOwnership_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OwnershipAnnounce) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ControlPlaneServer).AnnounceOwnership(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ControlPlane_AnnounceOwnership_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ControlPlaneServer).AnnounceOwnership(ctx, req.(*OwnershipAnnounce)) + } + return interceptor(ctx, in, info, handler) +} + +func _ControlPlane_AnnounceExpiry_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ExpiryAnnounce) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ControlPlaneServer).AnnounceExpiry(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ControlPlane_AnnounceExpiry_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ControlPlaneServer).AnnounceExpiry(ctx, req.(*ExpiryAnnounce)) + } + return interceptor(ctx, in, info, handler) +} + +func _ControlPlane_Closing_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ClosingNotice) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ControlPlaneServer).Closing(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ControlPlane_Closing_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ControlPlaneServer).Closing(ctx, req.(*ClosingNotice)) + } + return interceptor(ctx, in, info, handler) +} + +// ControlPlane_ServiceDesc is the grpc.ServiceDesc for ControlPlane service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var ControlPlane_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "messages.ControlPlane", + HandlerType: (*ControlPlaneServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Ping", + Handler: _ControlPlane_Ping_Handler, + }, + { + MethodName: "Negotiate", + Handler: _ControlPlane_Negotiate_Handler, + }, + { + MethodName: "GetLocalActorIds", + Handler: _ControlPlane_GetLocalActorIds_Handler, + }, + { + MethodName: "AnnounceOwnership", + Handler: _ControlPlane_AnnounceOwnership_Handler, + }, + { + MethodName: "AnnounceExpiry", + Handler: _ControlPlane_AnnounceExpiry_Handler, + }, + { + MethodName: "Closing", + Handler: _ControlPlane_Closing_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "control_plane.proto", +} diff --git a/pkg/messages/messages.pb.go b/pkg/messages/messages.pb.go new file mode 100644 index 0000000..e6e6da1 --- /dev/null +++ b/pkg/messages/messages.pb.go @@ -0,0 +1,1306 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.5 +// protoc v5.29.3 +// source: messages.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) +) + +type ClearCartRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClearCartRequest) Reset() { + *x = ClearCartRequest{} + mi := &file_messages_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClearCartRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClearCartRequest) ProtoMessage() {} + +func (x *ClearCartRequest) ProtoReflect() protoreflect.Message { + mi := &file_messages_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 ClearCartRequest.ProtoReflect.Descriptor instead. +func (*ClearCartRequest) Descriptor() ([]byte, []int) { + return file_messages_proto_rawDescGZIP(), []int{0} +} + +type AddItem struct { + state protoimpl.MessageState `protogen:"open.v1"` + ItemId uint32 `protobuf:"varint,1,opt,name=item_id,json=itemId,proto3" json:"item_id,omitempty"` + Quantity int32 `protobuf:"varint,2,opt,name=quantity,proto3" json:"quantity,omitempty"` + Price int64 `protobuf:"varint,3,opt,name=price,proto3" json:"price,omitempty"` + OrgPrice int64 `protobuf:"varint,9,opt,name=orgPrice,proto3" json:"orgPrice,omitempty"` + Sku string `protobuf:"bytes,4,opt,name=sku,proto3" json:"sku,omitempty"` + Name string `protobuf:"bytes,5,opt,name=name,proto3" json:"name,omitempty"` + Image string `protobuf:"bytes,6,opt,name=image,proto3" json:"image,omitempty"` + Stock int32 `protobuf:"varint,7,opt,name=stock,proto3" json:"stock,omitempty"` + Tax int32 `protobuf:"varint,8,opt,name=tax,proto3" json:"tax,omitempty"` + Brand string `protobuf:"bytes,13,opt,name=brand,proto3" json:"brand,omitempty"` + Category string `protobuf:"bytes,14,opt,name=category,proto3" json:"category,omitempty"` + Category2 string `protobuf:"bytes,15,opt,name=category2,proto3" json:"category2,omitempty"` + Category3 string `protobuf:"bytes,16,opt,name=category3,proto3" json:"category3,omitempty"` + Category4 string `protobuf:"bytes,17,opt,name=category4,proto3" json:"category4,omitempty"` + Category5 string `protobuf:"bytes,18,opt,name=category5,proto3" json:"category5,omitempty"` + Disclaimer string `protobuf:"bytes,10,opt,name=disclaimer,proto3" json:"disclaimer,omitempty"` + ArticleType string `protobuf:"bytes,11,opt,name=articleType,proto3" json:"articleType,omitempty"` + SellerId string `protobuf:"bytes,19,opt,name=sellerId,proto3" json:"sellerId,omitempty"` + SellerName string `protobuf:"bytes,20,opt,name=sellerName,proto3" json:"sellerName,omitempty"` + Country string `protobuf:"bytes,21,opt,name=country,proto3" json:"country,omitempty"` + Outlet *string `protobuf:"bytes,12,opt,name=outlet,proto3,oneof" json:"outlet,omitempty"` + StoreId *string `protobuf:"bytes,22,opt,name=storeId,proto3,oneof" json:"storeId,omitempty"` + ParentId *uint32 `protobuf:"varint,23,opt,name=parentId,proto3,oneof" json:"parentId,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddItem) Reset() { + *x = AddItem{} + mi := &file_messages_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddItem) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddItem) ProtoMessage() {} + +func (x *AddItem) ProtoReflect() protoreflect.Message { + mi := &file_messages_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 AddItem.ProtoReflect.Descriptor instead. +func (*AddItem) Descriptor() ([]byte, []int) { + return file_messages_proto_rawDescGZIP(), []int{1} +} + +func (x *AddItem) GetItemId() uint32 { + if x != nil { + return x.ItemId + } + return 0 +} + +func (x *AddItem) GetQuantity() int32 { + if x != nil { + return x.Quantity + } + return 0 +} + +func (x *AddItem) GetPrice() int64 { + if x != nil { + return x.Price + } + return 0 +} + +func (x *AddItem) GetOrgPrice() int64 { + if x != nil { + return x.OrgPrice + } + return 0 +} + +func (x *AddItem) GetSku() string { + if x != nil { + return x.Sku + } + return "" +} + +func (x *AddItem) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *AddItem) GetImage() string { + if x != nil { + return x.Image + } + return "" +} + +func (x *AddItem) GetStock() int32 { + if x != nil { + return x.Stock + } + return 0 +} + +func (x *AddItem) GetTax() int32 { + if x != nil { + return x.Tax + } + return 0 +} + +func (x *AddItem) GetBrand() string { + if x != nil { + return x.Brand + } + return "" +} + +func (x *AddItem) GetCategory() string { + if x != nil { + return x.Category + } + return "" +} + +func (x *AddItem) GetCategory2() string { + if x != nil { + return x.Category2 + } + return "" +} + +func (x *AddItem) GetCategory3() string { + if x != nil { + return x.Category3 + } + return "" +} + +func (x *AddItem) GetCategory4() string { + if x != nil { + return x.Category4 + } + return "" +} + +func (x *AddItem) GetCategory5() string { + if x != nil { + return x.Category5 + } + return "" +} + +func (x *AddItem) GetDisclaimer() string { + if x != nil { + return x.Disclaimer + } + return "" +} + +func (x *AddItem) GetArticleType() string { + if x != nil { + return x.ArticleType + } + return "" +} + +func (x *AddItem) GetSellerId() string { + if x != nil { + return x.SellerId + } + return "" +} + +func (x *AddItem) GetSellerName() string { + if x != nil { + return x.SellerName + } + return "" +} + +func (x *AddItem) GetCountry() string { + if x != nil { + return x.Country + } + return "" +} + +func (x *AddItem) GetOutlet() string { + if x != nil && x.Outlet != nil { + return *x.Outlet + } + return "" +} + +func (x *AddItem) GetStoreId() string { + if x != nil && x.StoreId != nil { + return *x.StoreId + } + return "" +} + +func (x *AddItem) GetParentId() uint32 { + if x != nil && x.ParentId != nil { + return *x.ParentId + } + return 0 +} + +type RemoveItem struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id uint32 `protobuf:"varint,1,opt,name=Id,proto3" json:"Id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveItem) Reset() { + *x = RemoveItem{} + mi := &file_messages_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveItem) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveItem) ProtoMessage() {} + +func (x *RemoveItem) ProtoReflect() protoreflect.Message { + mi := &file_messages_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 RemoveItem.ProtoReflect.Descriptor instead. +func (*RemoveItem) Descriptor() ([]byte, []int) { + return file_messages_proto_rawDescGZIP(), []int{2} +} + +func (x *RemoveItem) GetId() uint32 { + if x != nil { + return x.Id + } + return 0 +} + +type ChangeQuantity struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id uint32 `protobuf:"varint,1,opt,name=Id,proto3" json:"Id,omitempty"` + Quantity int32 `protobuf:"varint,2,opt,name=quantity,proto3" json:"quantity,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChangeQuantity) Reset() { + *x = ChangeQuantity{} + mi := &file_messages_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChangeQuantity) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChangeQuantity) ProtoMessage() {} + +func (x *ChangeQuantity) ProtoReflect() protoreflect.Message { + mi := &file_messages_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 ChangeQuantity.ProtoReflect.Descriptor instead. +func (*ChangeQuantity) Descriptor() ([]byte, []int) { + return file_messages_proto_rawDescGZIP(), []int{3} +} + +func (x *ChangeQuantity) GetId() uint32 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *ChangeQuantity) GetQuantity() int32 { + if x != nil { + return x.Quantity + } + return 0 +} + +type SetDelivery struct { + state protoimpl.MessageState `protogen:"open.v1"` + Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` + Items []uint32 `protobuf:"varint,2,rep,packed,name=items,proto3" json:"items,omitempty"` + PickupPoint *PickupPoint `protobuf:"bytes,3,opt,name=pickupPoint,proto3,oneof" json:"pickupPoint,omitempty"` + Country string `protobuf:"bytes,4,opt,name=country,proto3" json:"country,omitempty"` + Zip string `protobuf:"bytes,5,opt,name=zip,proto3" json:"zip,omitempty"` + Address *string `protobuf:"bytes,6,opt,name=address,proto3,oneof" json:"address,omitempty"` + City *string `protobuf:"bytes,7,opt,name=city,proto3,oneof" json:"city,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetDelivery) Reset() { + *x = SetDelivery{} + mi := &file_messages_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetDelivery) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetDelivery) ProtoMessage() {} + +func (x *SetDelivery) ProtoReflect() protoreflect.Message { + mi := &file_messages_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetDelivery.ProtoReflect.Descriptor instead. +func (*SetDelivery) Descriptor() ([]byte, []int) { + return file_messages_proto_rawDescGZIP(), []int{4} +} + +func (x *SetDelivery) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +func (x *SetDelivery) GetItems() []uint32 { + if x != nil { + return x.Items + } + return nil +} + +func (x *SetDelivery) GetPickupPoint() *PickupPoint { + if x != nil { + return x.PickupPoint + } + return nil +} + +func (x *SetDelivery) GetCountry() string { + if x != nil { + return x.Country + } + return "" +} + +func (x *SetDelivery) GetZip() string { + if x != nil { + return x.Zip + } + return "" +} + +func (x *SetDelivery) GetAddress() string { + if x != nil && x.Address != nil { + return *x.Address + } + return "" +} + +func (x *SetDelivery) GetCity() string { + if x != nil && x.City != nil { + return *x.City + } + return "" +} + +type SetPickupPoint struct { + state protoimpl.MessageState `protogen:"open.v1"` + DeliveryId uint32 `protobuf:"varint,1,opt,name=deliveryId,proto3" json:"deliveryId,omitempty"` + Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` + Name *string `protobuf:"bytes,3,opt,name=name,proto3,oneof" json:"name,omitempty"` + Address *string `protobuf:"bytes,4,opt,name=address,proto3,oneof" json:"address,omitempty"` + City *string `protobuf:"bytes,5,opt,name=city,proto3,oneof" json:"city,omitempty"` + Zip *string `protobuf:"bytes,6,opt,name=zip,proto3,oneof" json:"zip,omitempty"` + Country *string `protobuf:"bytes,7,opt,name=country,proto3,oneof" json:"country,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetPickupPoint) Reset() { + *x = SetPickupPoint{} + mi := &file_messages_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetPickupPoint) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetPickupPoint) ProtoMessage() {} + +func (x *SetPickupPoint) ProtoReflect() protoreflect.Message { + mi := &file_messages_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 SetPickupPoint.ProtoReflect.Descriptor instead. +func (*SetPickupPoint) Descriptor() ([]byte, []int) { + return file_messages_proto_rawDescGZIP(), []int{5} +} + +func (x *SetPickupPoint) GetDeliveryId() uint32 { + if x != nil { + return x.DeliveryId + } + return 0 +} + +func (x *SetPickupPoint) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *SetPickupPoint) GetName() string { + if x != nil && x.Name != nil { + return *x.Name + } + return "" +} + +func (x *SetPickupPoint) GetAddress() string { + if x != nil && x.Address != nil { + return *x.Address + } + return "" +} + +func (x *SetPickupPoint) GetCity() string { + if x != nil && x.City != nil { + return *x.City + } + return "" +} + +func (x *SetPickupPoint) GetZip() string { + if x != nil && x.Zip != nil { + return *x.Zip + } + return "" +} + +func (x *SetPickupPoint) GetCountry() string { + if x != nil && x.Country != nil { + return *x.Country + } + return "" +} + +type PickupPoint struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name *string `protobuf:"bytes,2,opt,name=name,proto3,oneof" json:"name,omitempty"` + Address *string `protobuf:"bytes,3,opt,name=address,proto3,oneof" json:"address,omitempty"` + City *string `protobuf:"bytes,4,opt,name=city,proto3,oneof" json:"city,omitempty"` + Zip *string `protobuf:"bytes,5,opt,name=zip,proto3,oneof" json:"zip,omitempty"` + Country *string `protobuf:"bytes,6,opt,name=country,proto3,oneof" json:"country,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PickupPoint) Reset() { + *x = PickupPoint{} + mi := &file_messages_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PickupPoint) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PickupPoint) ProtoMessage() {} + +func (x *PickupPoint) ProtoReflect() protoreflect.Message { + mi := &file_messages_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PickupPoint.ProtoReflect.Descriptor instead. +func (*PickupPoint) Descriptor() ([]byte, []int) { + return file_messages_proto_rawDescGZIP(), []int{6} +} + +func (x *PickupPoint) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *PickupPoint) GetName() string { + if x != nil && x.Name != nil { + return *x.Name + } + return "" +} + +func (x *PickupPoint) GetAddress() string { + if x != nil && x.Address != nil { + return *x.Address + } + return "" +} + +func (x *PickupPoint) GetCity() string { + if x != nil && x.City != nil { + return *x.City + } + return "" +} + +func (x *PickupPoint) GetZip() string { + if x != nil && x.Zip != nil { + return *x.Zip + } + return "" +} + +func (x *PickupPoint) GetCountry() string { + if x != nil && x.Country != nil { + return *x.Country + } + return "" +} + +type RemoveDelivery struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveDelivery) Reset() { + *x = RemoveDelivery{} + mi := &file_messages_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveDelivery) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveDelivery) ProtoMessage() {} + +func (x *RemoveDelivery) ProtoReflect() protoreflect.Message { + mi := &file_messages_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 RemoveDelivery.ProtoReflect.Descriptor instead. +func (*RemoveDelivery) Descriptor() ([]byte, []int) { + return file_messages_proto_rawDescGZIP(), []int{7} +} + +func (x *RemoveDelivery) GetId() uint32 { + if x != nil { + return x.Id + } + return 0 +} + +type CreateCheckoutOrder struct { + state protoimpl.MessageState `protogen:"open.v1"` + Terms string `protobuf:"bytes,1,opt,name=terms,proto3" json:"terms,omitempty"` + Checkout string `protobuf:"bytes,2,opt,name=checkout,proto3" json:"checkout,omitempty"` + Confirmation string `protobuf:"bytes,3,opt,name=confirmation,proto3" json:"confirmation,omitempty"` + Push string `protobuf:"bytes,4,opt,name=push,proto3" json:"push,omitempty"` + Validation string `protobuf:"bytes,5,opt,name=validation,proto3" json:"validation,omitempty"` + Country string `protobuf:"bytes,6,opt,name=country,proto3" json:"country,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateCheckoutOrder) Reset() { + *x = CreateCheckoutOrder{} + mi := &file_messages_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateCheckoutOrder) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateCheckoutOrder) ProtoMessage() {} + +func (x *CreateCheckoutOrder) ProtoReflect() protoreflect.Message { + mi := &file_messages_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 CreateCheckoutOrder.ProtoReflect.Descriptor instead. +func (*CreateCheckoutOrder) Descriptor() ([]byte, []int) { + return file_messages_proto_rawDescGZIP(), []int{8} +} + +func (x *CreateCheckoutOrder) GetTerms() string { + if x != nil { + return x.Terms + } + return "" +} + +func (x *CreateCheckoutOrder) GetCheckout() string { + if x != nil { + return x.Checkout + } + return "" +} + +func (x *CreateCheckoutOrder) GetConfirmation() string { + if x != nil { + return x.Confirmation + } + return "" +} + +func (x *CreateCheckoutOrder) GetPush() string { + if x != nil { + return x.Push + } + return "" +} + +func (x *CreateCheckoutOrder) GetValidation() string { + if x != nil { + return x.Validation + } + return "" +} + +func (x *CreateCheckoutOrder) GetCountry() string { + if x != nil { + return x.Country + } + return "" +} + +type OrderCreated struct { + state protoimpl.MessageState `protogen:"open.v1"` + OrderId string `protobuf:"bytes,1,opt,name=orderId,proto3" json:"orderId,omitempty"` + Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OrderCreated) Reset() { + *x = OrderCreated{} + mi := &file_messages_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OrderCreated) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OrderCreated) ProtoMessage() {} + +func (x *OrderCreated) ProtoReflect() protoreflect.Message { + mi := &file_messages_proto_msgTypes[9] + 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 OrderCreated.ProtoReflect.Descriptor instead. +func (*OrderCreated) Descriptor() ([]byte, []int) { + return file_messages_proto_rawDescGZIP(), []int{9} +} + +func (x *OrderCreated) GetOrderId() string { + if x != nil { + return x.OrderId + } + return "" +} + +func (x *OrderCreated) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +type Noop struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Noop) Reset() { + *x = Noop{} + mi := &file_messages_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Noop) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Noop) ProtoMessage() {} + +func (x *Noop) ProtoReflect() protoreflect.Message { + mi := &file_messages_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Noop.ProtoReflect.Descriptor instead. +func (*Noop) Descriptor() ([]byte, []int) { + return file_messages_proto_rawDescGZIP(), []int{10} +} + +type InitializeCheckout struct { + state protoimpl.MessageState `protogen:"open.v1"` + OrderId string `protobuf:"bytes,1,opt,name=orderId,proto3" json:"orderId,omitempty"` + Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` + PaymentInProgress bool `protobuf:"varint,3,opt,name=paymentInProgress,proto3" json:"paymentInProgress,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InitializeCheckout) Reset() { + *x = InitializeCheckout{} + mi := &file_messages_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InitializeCheckout) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InitializeCheckout) ProtoMessage() {} + +func (x *InitializeCheckout) ProtoReflect() protoreflect.Message { + mi := &file_messages_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InitializeCheckout.ProtoReflect.Descriptor instead. +func (*InitializeCheckout) Descriptor() ([]byte, []int) { + return file_messages_proto_rawDescGZIP(), []int{11} +} + +func (x *InitializeCheckout) GetOrderId() string { + if x != nil { + return x.OrderId + } + return "" +} + +func (x *InitializeCheckout) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *InitializeCheckout) GetPaymentInProgress() bool { + if x != nil { + return x.PaymentInProgress + } + return false +} + +type VoucherRule struct { + state protoimpl.MessageState `protogen:"open.v1"` + Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` + Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` + Condition string `protobuf:"bytes,4,opt,name=condition,proto3" json:"condition,omitempty"` + Action string `protobuf:"bytes,5,opt,name=action,proto3" json:"action,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VoucherRule) Reset() { + *x = VoucherRule{} + mi := &file_messages_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VoucherRule) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VoucherRule) ProtoMessage() {} + +func (x *VoucherRule) ProtoReflect() protoreflect.Message { + mi := &file_messages_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VoucherRule.ProtoReflect.Descriptor instead. +func (*VoucherRule) Descriptor() ([]byte, []int) { + return file_messages_proto_rawDescGZIP(), []int{12} +} + +func (x *VoucherRule) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *VoucherRule) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *VoucherRule) GetCondition() string { + if x != nil { + return x.Condition + } + return "" +} + +func (x *VoucherRule) GetAction() string { + if x != nil { + return x.Action + } + return "" +} + +type AddVoucher struct { + state protoimpl.MessageState `protogen:"open.v1"` + Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` + Value int64 `protobuf:"varint,2,opt,name=value,proto3" json:"value,omitempty"` + VoucherRules []*VoucherRule `protobuf:"bytes,3,rep,name=voucherRules,proto3" json:"voucherRules,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddVoucher) Reset() { + *x = AddVoucher{} + mi := &file_messages_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddVoucher) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddVoucher) ProtoMessage() {} + +func (x *AddVoucher) ProtoReflect() protoreflect.Message { + mi := &file_messages_proto_msgTypes[13] + 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 AddVoucher.ProtoReflect.Descriptor instead. +func (*AddVoucher) Descriptor() ([]byte, []int) { + return file_messages_proto_rawDescGZIP(), []int{13} +} + +func (x *AddVoucher) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +func (x *AddVoucher) GetValue() int64 { + if x != nil { + return x.Value + } + return 0 +} + +func (x *AddVoucher) GetVoucherRules() []*VoucherRule { + if x != nil { + return x.VoucherRules + } + return nil +} + +type RemoveVoucher struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveVoucher) Reset() { + *x = RemoveVoucher{} + mi := &file_messages_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveVoucher) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveVoucher) ProtoMessage() {} + +func (x *RemoveVoucher) ProtoReflect() protoreflect.Message { + mi := &file_messages_proto_msgTypes[14] + 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 RemoveVoucher.ProtoReflect.Descriptor instead. +func (*RemoveVoucher) Descriptor() ([]byte, []int) { + return file_messages_proto_rawDescGZIP(), []int{14} +} + +func (x *RemoveVoucher) GetId() uint32 { + if x != nil { + return x.Id + } + return 0 +} + +var File_messages_proto protoreflect.FileDescriptor + +var file_messages_proto_rawDesc = string([]byte{ + 0x0a, 0x0e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x12, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0x12, 0x0a, 0x10, 0x43, 0x6c, + 0x65, 0x61, 0x72, 0x43, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x97, + 0x05, 0x0a, 0x07, 0x41, 0x64, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x69, 0x74, + 0x65, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x69, 0x74, 0x65, + 0x6d, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, + 0x14, 0x0a, 0x05, 0x70, 0x72, 0x69, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, + 0x70, 0x72, 0x69, 0x63, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x6f, 0x72, 0x67, 0x50, 0x72, 0x69, 0x63, + 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x6f, 0x72, 0x67, 0x50, 0x72, 0x69, 0x63, + 0x65, 0x12, 0x10, 0x0a, 0x03, 0x73, 0x6b, 0x75, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, + 0x73, 0x6b, 0x75, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x14, 0x0a, + 0x05, 0x73, 0x74, 0x6f, 0x63, 0x6b, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x73, 0x74, + 0x6f, 0x63, 0x6b, 0x12, 0x10, 0x0a, 0x03, 0x74, 0x61, 0x78, 0x18, 0x08, 0x20, 0x01, 0x28, 0x05, + 0x52, 0x03, 0x74, 0x61, 0x78, 0x12, 0x14, 0x0a, 0x05, 0x62, 0x72, 0x61, 0x6e, 0x64, 0x18, 0x0d, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x62, 0x72, 0x61, 0x6e, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63, + 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, + 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, + 0x6f, 0x72, 0x79, 0x32, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, + 0x67, 0x6f, 0x72, 0x79, 0x32, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, + 0x79, 0x33, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, + 0x72, 0x79, 0x33, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x34, + 0x18, 0x11, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, + 0x34, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x35, 0x18, 0x12, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x35, 0x12, + 0x1e, 0x0a, 0x0a, 0x64, 0x69, 0x73, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x65, 0x72, 0x18, 0x0a, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0a, 0x64, 0x69, 0x73, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x65, 0x72, 0x12, + 0x20, 0x0a, 0x0b, 0x61, 0x72, 0x74, 0x69, 0x63, 0x6c, 0x65, 0x54, 0x79, 0x70, 0x65, 0x18, 0x0b, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x72, 0x74, 0x69, 0x63, 0x6c, 0x65, 0x54, 0x79, 0x70, + 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x49, 0x64, 0x18, 0x13, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1e, 0x0a, + 0x0a, 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x14, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0a, 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, + 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x15, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x1b, 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x6c, 0x65, + 0x74, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x06, 0x6f, 0x75, 0x74, 0x6c, 0x65, + 0x74, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x49, 0x64, 0x18, + 0x16, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x49, 0x64, + 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x18, + 0x17, 0x20, 0x01, 0x28, 0x0d, 0x48, 0x02, 0x52, 0x08, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49, + 0x64, 0x88, 0x01, 0x01, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x6f, 0x75, 0x74, 0x6c, 0x65, 0x74, 0x42, + 0x0a, 0x0a, 0x08, 0x5f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x49, 0x64, 0x42, 0x0b, 0x0a, 0x09, 0x5f, + 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x22, 0x1c, 0x0a, 0x0a, 0x52, 0x65, 0x6d, 0x6f, + 0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x22, 0x3c, 0x0a, 0x0e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, + 0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e, + 0x74, 0x69, 0x74, 0x79, 0x22, 0x86, 0x02, 0x0a, 0x0b, 0x53, 0x65, 0x74, 0x44, 0x65, 0x6c, 0x69, + 0x76, 0x65, 0x72, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, + 0x12, 0x14, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0d, 0x52, + 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x12, 0x3c, 0x0a, 0x0b, 0x70, 0x69, 0x63, 0x6b, 0x75, 0x70, + 0x50, 0x6f, 0x69, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x50, 0x69, 0x63, 0x6b, 0x75, 0x70, 0x50, 0x6f, 0x69, + 0x6e, 0x74, 0x48, 0x00, 0x52, 0x0b, 0x70, 0x69, 0x63, 0x6b, 0x75, 0x70, 0x50, 0x6f, 0x69, 0x6e, + 0x74, 0x88, 0x01, 0x01, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, + 0x0a, 0x03, 0x7a, 0x69, 0x70, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x7a, 0x69, 0x70, + 0x12, 0x1d, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, + 0x09, 0x48, 0x01, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x88, 0x01, 0x01, 0x12, + 0x17, 0x0a, 0x04, 0x63, 0x69, 0x74, 0x79, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, + 0x04, 0x63, 0x69, 0x74, 0x79, 0x88, 0x01, 0x01, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x70, 0x69, 0x63, + 0x6b, 0x75, 0x70, 0x50, 0x6f, 0x69, 0x6e, 0x74, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x61, 0x64, 0x64, + 0x72, 0x65, 0x73, 0x73, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x63, 0x69, 0x74, 0x79, 0x22, 0xf9, 0x01, + 0x0a, 0x0e, 0x53, 0x65, 0x74, 0x50, 0x69, 0x63, 0x6b, 0x75, 0x70, 0x50, 0x6f, 0x69, 0x6e, 0x74, + 0x12, 0x1e, 0x0a, 0x0a, 0x64, 0x65, 0x6c, 0x69, 0x76, 0x65, 0x72, 0x79, 0x49, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x64, 0x65, 0x6c, 0x69, 0x76, 0x65, 0x72, 0x79, 0x49, 0x64, + 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, + 0x12, 0x17, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, + 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x61, 0x64, 0x64, + 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x61, 0x64, + 0x64, 0x72, 0x65, 0x73, 0x73, 0x88, 0x01, 0x01, 0x12, 0x17, 0x0a, 0x04, 0x63, 0x69, 0x74, 0x79, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, 0x04, 0x63, 0x69, 0x74, 0x79, 0x88, 0x01, + 0x01, 0x12, 0x15, 0x0a, 0x03, 0x7a, 0x69, 0x70, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x48, 0x03, + 0x52, 0x03, 0x7a, 0x69, 0x70, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, + 0x74, 0x72, 0x79, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x04, 0x52, 0x07, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x72, 0x79, 0x88, 0x01, 0x01, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x6e, 0x61, 0x6d, 0x65, + 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x42, 0x07, 0x0a, 0x05, + 0x5f, 0x63, 0x69, 0x74, 0x79, 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x7a, 0x69, 0x70, 0x42, 0x0a, 0x0a, + 0x08, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x22, 0xd6, 0x01, 0x0a, 0x0b, 0x50, 0x69, + 0x63, 0x6b, 0x75, 0x70, 0x50, 0x6f, 0x69, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x04, 0x6e, 0x61, 0x6d, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x88, + 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x88, 0x01, + 0x01, 0x12, 0x17, 0x0a, 0x04, 0x63, 0x69, 0x74, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, + 0x02, 0x52, 0x04, 0x63, 0x69, 0x74, 0x79, 0x88, 0x01, 0x01, 0x12, 0x15, 0x0a, 0x03, 0x7a, 0x69, + 0x70, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x03, 0x52, 0x03, 0x7a, 0x69, 0x70, 0x88, 0x01, + 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x09, 0x48, 0x04, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x88, 0x01, 0x01, + 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x61, 0x64, + 0x64, 0x72, 0x65, 0x73, 0x73, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x63, 0x69, 0x74, 0x79, 0x42, 0x06, + 0x0a, 0x04, 0x5f, 0x7a, 0x69, 0x70, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, + 0x72, 0x79, 0x22, 0x20, 0x0a, 0x0e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x44, 0x65, 0x6c, 0x69, + 0x76, 0x65, 0x72, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, + 0x52, 0x02, 0x69, 0x64, 0x22, 0xb9, 0x01, 0x0a, 0x13, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, + 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x14, 0x0a, 0x05, + 0x74, 0x65, 0x72, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x65, 0x72, + 0x6d, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x12, 0x22, + 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x72, 0x6d, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x72, 0x6d, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x75, 0x73, 0x68, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x70, 0x75, 0x73, 0x68, 0x12, 0x1e, 0x0a, 0x0a, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x76, 0x61, 0x6c, 0x69, + 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, + 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, + 0x22, 0x40, 0x0a, 0x0c, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, + 0x12, 0x18, 0x0a, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x22, 0x06, 0x0a, 0x04, 0x4e, 0x6f, 0x6f, 0x70, 0x22, 0x74, 0x0a, 0x12, 0x49, 0x6e, + 0x69, 0x74, 0x69, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, + 0x12, 0x18, 0x0a, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x12, 0x2c, 0x0a, 0x11, 0x70, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x6e, 0x50, + 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x70, + 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x6e, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, + 0x22, 0x79, 0x0a, 0x0b, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x12, + 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, + 0x79, 0x70, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, + 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, + 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, 0x69, + 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x64, 0x69, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x71, 0x0a, 0x0a, 0x41, + 0x64, 0x64, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, + 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x14, 0x0a, + 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x12, 0x39, 0x0a, 0x0c, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, + 0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x73, 0x2e, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, + 0x52, 0x0c, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x22, 0x1f, + 0x0a, 0x0d, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x12, + 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 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 ( + file_messages_proto_rawDescOnce sync.Once + file_messages_proto_rawDescData []byte +) + +func file_messages_proto_rawDescGZIP() []byte { + file_messages_proto_rawDescOnce.Do(func() { + file_messages_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_messages_proto_rawDesc), len(file_messages_proto_rawDesc))) + }) + return file_messages_proto_rawDescData +} + +var file_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 15) +var file_messages_proto_goTypes = []any{ + (*ClearCartRequest)(nil), // 0: messages.ClearCartRequest + (*AddItem)(nil), // 1: messages.AddItem + (*RemoveItem)(nil), // 2: messages.RemoveItem + (*ChangeQuantity)(nil), // 3: messages.ChangeQuantity + (*SetDelivery)(nil), // 4: messages.SetDelivery + (*SetPickupPoint)(nil), // 5: messages.SetPickupPoint + (*PickupPoint)(nil), // 6: messages.PickupPoint + (*RemoveDelivery)(nil), // 7: messages.RemoveDelivery + (*CreateCheckoutOrder)(nil), // 8: messages.CreateCheckoutOrder + (*OrderCreated)(nil), // 9: messages.OrderCreated + (*Noop)(nil), // 10: messages.Noop + (*InitializeCheckout)(nil), // 11: messages.InitializeCheckout + (*VoucherRule)(nil), // 12: messages.VoucherRule + (*AddVoucher)(nil), // 13: messages.AddVoucher + (*RemoveVoucher)(nil), // 14: messages.RemoveVoucher +} +var file_messages_proto_depIdxs = []int32{ + 6, // 0: messages.SetDelivery.pickupPoint:type_name -> messages.PickupPoint + 12, // 1: messages.AddVoucher.voucherRules:type_name -> messages.VoucherRule + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_messages_proto_init() } +func file_messages_proto_init() { + if File_messages_proto != nil { + return + } + file_messages_proto_msgTypes[1].OneofWrappers = []any{} + file_messages_proto_msgTypes[4].OneofWrappers = []any{} + file_messages_proto_msgTypes[5].OneofWrappers = []any{} + file_messages_proto_msgTypes[6].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_messages_proto_rawDesc), len(file_messages_proto_rawDesc)), + NumEnums: 0, + NumMessages: 15, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_messages_proto_goTypes, + DependencyIndexes: file_messages_proto_depIdxs, + MessageInfos: file_messages_proto_msgTypes, + }.Build() + File_messages_proto = out.File + file_messages_proto_goTypes = nil + file_messages_proto_depIdxs = nil +} diff --git a/pkg/proxy/remotehost.go b/pkg/proxy/remotehost.go new file mode 100644 index 0000000..a931dd5 --- /dev/null +++ b/pkg/proxy/remotehost.go @@ -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 +} diff --git a/pkg/voucher/parser.go b/pkg/voucher/parser.go new file mode 100644 index 0000000..c76102f --- /dev/null +++ b/pkg/voucher/parser.go @@ -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: >= 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 (>= ) + 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 "" + } + 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 +} diff --git a/pkg/voucher/parser_test.go b/pkg/voucher/parser_test.go new file mode 100644 index 0000000..c618e69 --- /dev/null +++ b/pkg/voucher/parser_test.go @@ -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 ~~") +} diff --git a/pkg/voucher/service.go b/pkg/voucher/service.go new file mode 100644 index 0000000..fb14ef4 --- /dev/null +++ b/pkg/voucher/service.go @@ -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 +} diff --git a/pool-server.go b/pool-server.go deleted file mode 100644 index 26f77f4..0000000 --- a/pool-server.go +++ /dev/null @@ -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 -} diff --git a/product-fetcher.go b/product-fetcher.go deleted file mode 100644 index 12a98fe..0000000 --- a/product-fetcher.go +++ /dev/null @@ -1,32 +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" - } - return "http://s10n-se.s10n:8080" -} - -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 -} diff --git a/proto/control_plane.proto b/proto/control_plane.proto new file mode 100644 index 0000000..69570fd --- /dev/null +++ b/proto/control_plane.proto @@ -0,0 +1,101 @@ +syntax = "proto3"; + +package messages; + +option go_package = "git.tornberg.me/go-cart-actor/proto;messages"; + +// ----------------------------------------------------------------------------- +// Control Plane gRPC API +// ----------------------------------------------------------------------------- +// Replaces the legacy custom frame-based control channel (previously port 1338). +// Responsibilities: +// - Liveness (Ping) +// - Membership negotiation (Negotiate) +// - Deterministic ring-based ownership (ConfirmOwner RPC removed) +// - Actor ID listing for remote grain spawning (GetActorIds) +// - Graceful shutdown notifications (Closing) +// No authentication / TLS is defined initially (can be added later). +// ----------------------------------------------------------------------------- + +// Empty request placeholder (common pattern). +message Empty {} + +// Ping reply includes responding host and its current unix time (seconds). +message PingReply { + string host = 1; + int64 unix_time = 2; +} + +// NegotiateRequest carries the caller's full view of known hosts (including self). +message NegotiateRequest { + repeated string known_hosts = 1; +} + +// NegotiateReply returns the callee's healthy hosts (including itself). +message NegotiateReply { + repeated string hosts = 1; +} + +// CartIdsReply returns the list of cart IDs (string form) currently owned locally. +message ActorIdsReply { + repeated uint64 ids = 1; +} + +// OwnerChangeAck retained as response type for Closing RPC (ConfirmOwner removed). +message OwnerChangeAck { + bool accepted = 1; + string message = 2; +} + +// ClosingNotice notifies peers this host is terminating (so they can drop / re-resolve). +message ClosingNotice { + string host = 1; +} + +// OwnershipAnnounce broadcasts first-touch ownership claims for cart IDs. +// First claim wins; receivers SHOULD NOT overwrite an existing different owner. +message OwnershipAnnounce { + string host = 1; // announcing host + repeated uint64 ids = 2; // newly claimed cart ids +} + +// ExpiryAnnounce broadcasts that a host evicted the provided cart IDs. +message ExpiryAnnounce { + string host = 1; + repeated uint64 ids = 2; +} + +// ControlPlane defines cluster coordination and ownership operations. +service ControlPlane { + // Ping for liveness; lightweight health signal. + rpc Ping(Empty) returns (PingReply); + + // Negotiate merges host views; used during discovery & convergence. + rpc Negotiate(NegotiateRequest) returns (NegotiateReply); + + // GetCartIds lists currently owned cart IDs on this node. + rpc GetLocalActorIds(Empty) returns (ActorIdsReply); + + // ConfirmOwner RPC removed (was legacy ownership acknowledgement; ring-based ownership now authoritative) + + // Ownership announcement: first-touch claim broadcast (idempotent; best-effort). + rpc AnnounceOwnership(OwnershipAnnounce) returns (OwnerChangeAck); + + // Expiry announcement: drop remote ownership hints when local TTL expires. + rpc AnnounceExpiry(ExpiryAnnounce) returns (OwnerChangeAck); + + // Closing announces graceful shutdown so peers can proactively adjust. + rpc Closing(ClosingNotice) returns (OwnerChangeAck); +} + +// ----------------------------------------------------------------------------- +// Generation Instructions: +// protoc --go_out=. --go_opt=paths=source_relative \ +// --go-grpc_out=. --go-grpc_opt=paths=source_relative \ +// control_plane.proto +// +// Future Enhancements: +// - Add a streaming membership watch (server -> client) for immediate updates. +// - Add TLS / mTLS for secure intra-cluster communication. +// - Add richer health metadata (load, grain count) in PingReply. +// ----------------------------------------------------------------------------- diff --git a/proto/messages.pb.go b/proto/messages.pb.go deleted file mode 100644 index 755a0bf..0000000 --- a/proto/messages.pb.go +++ /dev/null @@ -1,1066 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.10 -// protoc v3.21.12 -// source: messages.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) -) - -type AddRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Quantity int32 `protobuf:"varint,1,opt,name=quantity,proto3" json:"quantity,omitempty"` - Sku string `protobuf:"bytes,2,opt,name=sku,proto3" json:"sku,omitempty"` - Country string `protobuf:"bytes,3,opt,name=country,proto3" json:"country,omitempty"` - StoreId *string `protobuf:"bytes,4,opt,name=storeId,proto3,oneof" json:"storeId,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AddRequest) Reset() { - *x = AddRequest{} - mi := &file_messages_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AddRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AddRequest) ProtoMessage() {} - -func (x *AddRequest) ProtoReflect() protoreflect.Message { - mi := &file_messages_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 AddRequest.ProtoReflect.Descriptor instead. -func (*AddRequest) Descriptor() ([]byte, []int) { - return file_messages_proto_rawDescGZIP(), []int{0} -} - -func (x *AddRequest) GetQuantity() int32 { - if x != nil { - return x.Quantity - } - return 0 -} - -func (x *AddRequest) GetSku() string { - if x != nil { - return x.Sku - } - return "" -} - -func (x *AddRequest) GetCountry() string { - if x != nil { - return x.Country - } - return "" -} - -func (x *AddRequest) GetStoreId() string { - if x != nil && x.StoreId != nil { - return *x.StoreId - } - return "" -} - -type SetCartRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Items []*AddRequest `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SetCartRequest) Reset() { - *x = SetCartRequest{} - mi := &file_messages_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SetCartRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SetCartRequest) ProtoMessage() {} - -func (x *SetCartRequest) ProtoReflect() protoreflect.Message { - mi := &file_messages_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 SetCartRequest.ProtoReflect.Descriptor instead. -func (*SetCartRequest) Descriptor() ([]byte, []int) { - return file_messages_proto_rawDescGZIP(), []int{1} -} - -func (x *SetCartRequest) GetItems() []*AddRequest { - if x != nil { - return x.Items - } - return nil -} - -type AddItem struct { - state protoimpl.MessageState `protogen:"open.v1"` - ItemId int64 `protobuf:"varint,1,opt,name=item_id,json=itemId,proto3" json:"item_id,omitempty"` - Quantity int32 `protobuf:"varint,2,opt,name=quantity,proto3" json:"quantity,omitempty"` - Price int64 `protobuf:"varint,3,opt,name=price,proto3" json:"price,omitempty"` - OrgPrice int64 `protobuf:"varint,9,opt,name=orgPrice,proto3" json:"orgPrice,omitempty"` - Sku string `protobuf:"bytes,4,opt,name=sku,proto3" json:"sku,omitempty"` - Name string `protobuf:"bytes,5,opt,name=name,proto3" json:"name,omitempty"` - Image string `protobuf:"bytes,6,opt,name=image,proto3" json:"image,omitempty"` - Stock int32 `protobuf:"varint,7,opt,name=stock,proto3" json:"stock,omitempty"` - Tax int32 `protobuf:"varint,8,opt,name=tax,proto3" json:"tax,omitempty"` - Brand string `protobuf:"bytes,13,opt,name=brand,proto3" json:"brand,omitempty"` - Category string `protobuf:"bytes,14,opt,name=category,proto3" json:"category,omitempty"` - Category2 string `protobuf:"bytes,15,opt,name=category2,proto3" json:"category2,omitempty"` - Category3 string `protobuf:"bytes,16,opt,name=category3,proto3" json:"category3,omitempty"` - Category4 string `protobuf:"bytes,17,opt,name=category4,proto3" json:"category4,omitempty"` - Category5 string `protobuf:"bytes,18,opt,name=category5,proto3" json:"category5,omitempty"` - Disclaimer string `protobuf:"bytes,10,opt,name=disclaimer,proto3" json:"disclaimer,omitempty"` - ArticleType string `protobuf:"bytes,11,opt,name=articleType,proto3" json:"articleType,omitempty"` - SellerId string `protobuf:"bytes,19,opt,name=sellerId,proto3" json:"sellerId,omitempty"` - SellerName string `protobuf:"bytes,20,opt,name=sellerName,proto3" json:"sellerName,omitempty"` - Country string `protobuf:"bytes,21,opt,name=country,proto3" json:"country,omitempty"` - Outlet *string `protobuf:"bytes,12,opt,name=outlet,proto3,oneof" json:"outlet,omitempty"` - StoreId *string `protobuf:"bytes,22,opt,name=storeId,proto3,oneof" json:"storeId,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AddItem) Reset() { - *x = AddItem{} - mi := &file_messages_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AddItem) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AddItem) ProtoMessage() {} - -func (x *AddItem) ProtoReflect() protoreflect.Message { - mi := &file_messages_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 AddItem.ProtoReflect.Descriptor instead. -func (*AddItem) Descriptor() ([]byte, []int) { - return file_messages_proto_rawDescGZIP(), []int{2} -} - -func (x *AddItem) GetItemId() int64 { - if x != nil { - return x.ItemId - } - return 0 -} - -func (x *AddItem) GetQuantity() int32 { - if x != nil { - return x.Quantity - } - return 0 -} - -func (x *AddItem) GetPrice() int64 { - if x != nil { - return x.Price - } - return 0 -} - -func (x *AddItem) GetOrgPrice() int64 { - if x != nil { - return x.OrgPrice - } - return 0 -} - -func (x *AddItem) GetSku() string { - if x != nil { - return x.Sku - } - return "" -} - -func (x *AddItem) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *AddItem) GetImage() string { - if x != nil { - return x.Image - } - return "" -} - -func (x *AddItem) GetStock() int32 { - if x != nil { - return x.Stock - } - return 0 -} - -func (x *AddItem) GetTax() int32 { - if x != nil { - return x.Tax - } - return 0 -} - -func (x *AddItem) GetBrand() string { - if x != nil { - return x.Brand - } - return "" -} - -func (x *AddItem) GetCategory() string { - if x != nil { - return x.Category - } - return "" -} - -func (x *AddItem) GetCategory2() string { - if x != nil { - return x.Category2 - } - return "" -} - -func (x *AddItem) GetCategory3() string { - if x != nil { - return x.Category3 - } - return "" -} - -func (x *AddItem) GetCategory4() string { - if x != nil { - return x.Category4 - } - return "" -} - -func (x *AddItem) GetCategory5() string { - if x != nil { - return x.Category5 - } - return "" -} - -func (x *AddItem) GetDisclaimer() string { - if x != nil { - return x.Disclaimer - } - return "" -} - -func (x *AddItem) GetArticleType() string { - if x != nil { - return x.ArticleType - } - return "" -} - -func (x *AddItem) GetSellerId() string { - if x != nil { - return x.SellerId - } - return "" -} - -func (x *AddItem) GetSellerName() string { - if x != nil { - return x.SellerName - } - return "" -} - -func (x *AddItem) GetCountry() string { - if x != nil { - return x.Country - } - return "" -} - -func (x *AddItem) GetOutlet() string { - if x != nil && x.Outlet != nil { - return *x.Outlet - } - return "" -} - -func (x *AddItem) GetStoreId() string { - if x != nil && x.StoreId != nil { - return *x.StoreId - } - return "" -} - -type RemoveItem struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id int64 `protobuf:"varint,1,opt,name=Id,proto3" json:"Id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RemoveItem) Reset() { - *x = RemoveItem{} - mi := &file_messages_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RemoveItem) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RemoveItem) ProtoMessage() {} - -func (x *RemoveItem) ProtoReflect() protoreflect.Message { - mi := &file_messages_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 RemoveItem.ProtoReflect.Descriptor instead. -func (*RemoveItem) Descriptor() ([]byte, []int) { - return file_messages_proto_rawDescGZIP(), []int{3} -} - -func (x *RemoveItem) GetId() int64 { - if x != nil { - return x.Id - } - return 0 -} - -type ChangeQuantity struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` - Quantity int32 `protobuf:"varint,2,opt,name=quantity,proto3" json:"quantity,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ChangeQuantity) Reset() { - *x = ChangeQuantity{} - mi := &file_messages_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ChangeQuantity) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ChangeQuantity) ProtoMessage() {} - -func (x *ChangeQuantity) ProtoReflect() protoreflect.Message { - mi := &file_messages_proto_msgTypes[4] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ChangeQuantity.ProtoReflect.Descriptor instead. -func (*ChangeQuantity) Descriptor() ([]byte, []int) { - return file_messages_proto_rawDescGZIP(), []int{4} -} - -func (x *ChangeQuantity) GetId() int64 { - if x != nil { - return x.Id - } - return 0 -} - -func (x *ChangeQuantity) GetQuantity() int32 { - if x != nil { - return x.Quantity - } - return 0 -} - -type SetDelivery struct { - state protoimpl.MessageState `protogen:"open.v1"` - Provider string `protobuf:"bytes,1,opt,name=provider,proto3" json:"provider,omitempty"` - Items []int64 `protobuf:"varint,2,rep,packed,name=items,proto3" json:"items,omitempty"` - PickupPoint *PickupPoint `protobuf:"bytes,3,opt,name=pickupPoint,proto3,oneof" json:"pickupPoint,omitempty"` - Country string `protobuf:"bytes,4,opt,name=country,proto3" json:"country,omitempty"` - Zip string `protobuf:"bytes,5,opt,name=zip,proto3" json:"zip,omitempty"` - Address *string `protobuf:"bytes,6,opt,name=address,proto3,oneof" json:"address,omitempty"` - City *string `protobuf:"bytes,7,opt,name=city,proto3,oneof" json:"city,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SetDelivery) Reset() { - *x = SetDelivery{} - mi := &file_messages_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SetDelivery) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SetDelivery) ProtoMessage() {} - -func (x *SetDelivery) ProtoReflect() protoreflect.Message { - mi := &file_messages_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 SetDelivery.ProtoReflect.Descriptor instead. -func (*SetDelivery) Descriptor() ([]byte, []int) { - return file_messages_proto_rawDescGZIP(), []int{5} -} - -func (x *SetDelivery) GetProvider() string { - if x != nil { - return x.Provider - } - return "" -} - -func (x *SetDelivery) GetItems() []int64 { - if x != nil { - return x.Items - } - return nil -} - -func (x *SetDelivery) GetPickupPoint() *PickupPoint { - if x != nil { - return x.PickupPoint - } - return nil -} - -func (x *SetDelivery) GetCountry() string { - if x != nil { - return x.Country - } - return "" -} - -func (x *SetDelivery) GetZip() string { - if x != nil { - return x.Zip - } - return "" -} - -func (x *SetDelivery) GetAddress() string { - if x != nil && x.Address != nil { - return *x.Address - } - return "" -} - -func (x *SetDelivery) GetCity() string { - if x != nil && x.City != nil { - return *x.City - } - return "" -} - -type SetPickupPoint struct { - state protoimpl.MessageState `protogen:"open.v1"` - DeliveryId int64 `protobuf:"varint,1,opt,name=deliveryId,proto3" json:"deliveryId,omitempty"` - Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` - Name *string `protobuf:"bytes,3,opt,name=name,proto3,oneof" json:"name,omitempty"` - Address *string `protobuf:"bytes,4,opt,name=address,proto3,oneof" json:"address,omitempty"` - City *string `protobuf:"bytes,5,opt,name=city,proto3,oneof" json:"city,omitempty"` - Zip *string `protobuf:"bytes,6,opt,name=zip,proto3,oneof" json:"zip,omitempty"` - Country *string `protobuf:"bytes,7,opt,name=country,proto3,oneof" json:"country,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SetPickupPoint) Reset() { - *x = SetPickupPoint{} - mi := &file_messages_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SetPickupPoint) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SetPickupPoint) ProtoMessage() {} - -func (x *SetPickupPoint) ProtoReflect() protoreflect.Message { - mi := &file_messages_proto_msgTypes[6] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SetPickupPoint.ProtoReflect.Descriptor instead. -func (*SetPickupPoint) Descriptor() ([]byte, []int) { - return file_messages_proto_rawDescGZIP(), []int{6} -} - -func (x *SetPickupPoint) GetDeliveryId() int64 { - if x != nil { - return x.DeliveryId - } - return 0 -} - -func (x *SetPickupPoint) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -func (x *SetPickupPoint) GetName() string { - if x != nil && x.Name != nil { - return *x.Name - } - return "" -} - -func (x *SetPickupPoint) GetAddress() string { - if x != nil && x.Address != nil { - return *x.Address - } - return "" -} - -func (x *SetPickupPoint) GetCity() string { - if x != nil && x.City != nil { - return *x.City - } - return "" -} - -func (x *SetPickupPoint) GetZip() string { - if x != nil && x.Zip != nil { - return *x.Zip - } - return "" -} - -func (x *SetPickupPoint) GetCountry() string { - if x != nil && x.Country != nil { - return *x.Country - } - return "" -} - -type PickupPoint struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - Name *string `protobuf:"bytes,2,opt,name=name,proto3,oneof" json:"name,omitempty"` - Address *string `protobuf:"bytes,3,opt,name=address,proto3,oneof" json:"address,omitempty"` - City *string `protobuf:"bytes,4,opt,name=city,proto3,oneof" json:"city,omitempty"` - Zip *string `protobuf:"bytes,5,opt,name=zip,proto3,oneof" json:"zip,omitempty"` - Country *string `protobuf:"bytes,6,opt,name=country,proto3,oneof" json:"country,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *PickupPoint) Reset() { - *x = PickupPoint{} - mi := &file_messages_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *PickupPoint) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PickupPoint) ProtoMessage() {} - -func (x *PickupPoint) ProtoReflect() protoreflect.Message { - mi := &file_messages_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 PickupPoint.ProtoReflect.Descriptor instead. -func (*PickupPoint) Descriptor() ([]byte, []int) { - return file_messages_proto_rawDescGZIP(), []int{7} -} - -func (x *PickupPoint) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -func (x *PickupPoint) GetName() string { - if x != nil && x.Name != nil { - return *x.Name - } - return "" -} - -func (x *PickupPoint) GetAddress() string { - if x != nil && x.Address != nil { - return *x.Address - } - return "" -} - -func (x *PickupPoint) GetCity() string { - if x != nil && x.City != nil { - return *x.City - } - return "" -} - -func (x *PickupPoint) GetZip() string { - if x != nil && x.Zip != nil { - return *x.Zip - } - return "" -} - -func (x *PickupPoint) GetCountry() string { - if x != nil && x.Country != nil { - return *x.Country - } - return "" -} - -type RemoveDelivery struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RemoveDelivery) Reset() { - *x = RemoveDelivery{} - mi := &file_messages_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RemoveDelivery) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RemoveDelivery) ProtoMessage() {} - -func (x *RemoveDelivery) ProtoReflect() protoreflect.Message { - mi := &file_messages_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 RemoveDelivery.ProtoReflect.Descriptor instead. -func (*RemoveDelivery) Descriptor() ([]byte, []int) { - return file_messages_proto_rawDescGZIP(), []int{8} -} - -func (x *RemoveDelivery) GetId() int64 { - if x != nil { - return x.Id - } - return 0 -} - -type CreateCheckoutOrder struct { - state protoimpl.MessageState `protogen:"open.v1"` - Terms string `protobuf:"bytes,1,opt,name=terms,proto3" json:"terms,omitempty"` - Checkout string `protobuf:"bytes,2,opt,name=checkout,proto3" json:"checkout,omitempty"` - Confirmation string `protobuf:"bytes,3,opt,name=confirmation,proto3" json:"confirmation,omitempty"` - Push string `protobuf:"bytes,4,opt,name=push,proto3" json:"push,omitempty"` - Validation string `protobuf:"bytes,5,opt,name=validation,proto3" json:"validation,omitempty"` - Country string `protobuf:"bytes,6,opt,name=country,proto3" json:"country,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CreateCheckoutOrder) Reset() { - *x = CreateCheckoutOrder{} - mi := &file_messages_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CreateCheckoutOrder) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CreateCheckoutOrder) ProtoMessage() {} - -func (x *CreateCheckoutOrder) ProtoReflect() protoreflect.Message { - mi := &file_messages_proto_msgTypes[9] - 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 CreateCheckoutOrder.ProtoReflect.Descriptor instead. -func (*CreateCheckoutOrder) Descriptor() ([]byte, []int) { - return file_messages_proto_rawDescGZIP(), []int{9} -} - -func (x *CreateCheckoutOrder) GetTerms() string { - if x != nil { - return x.Terms - } - return "" -} - -func (x *CreateCheckoutOrder) GetCheckout() string { - if x != nil { - return x.Checkout - } - return "" -} - -func (x *CreateCheckoutOrder) GetConfirmation() string { - if x != nil { - return x.Confirmation - } - return "" -} - -func (x *CreateCheckoutOrder) GetPush() string { - if x != nil { - return x.Push - } - return "" -} - -func (x *CreateCheckoutOrder) GetValidation() string { - if x != nil { - return x.Validation - } - return "" -} - -func (x *CreateCheckoutOrder) GetCountry() string { - if x != nil { - return x.Country - } - return "" -} - -type OrderCreated struct { - state protoimpl.MessageState `protogen:"open.v1"` - OrderId string `protobuf:"bytes,1,opt,name=orderId,proto3" json:"orderId,omitempty"` - Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *OrderCreated) Reset() { - *x = OrderCreated{} - mi := &file_messages_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *OrderCreated) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*OrderCreated) ProtoMessage() {} - -func (x *OrderCreated) ProtoReflect() protoreflect.Message { - mi := &file_messages_proto_msgTypes[10] - 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 OrderCreated.ProtoReflect.Descriptor instead. -func (*OrderCreated) Descriptor() ([]byte, []int) { - return file_messages_proto_rawDescGZIP(), []int{10} -} - -func (x *OrderCreated) GetOrderId() string { - if x != nil { - return x.OrderId - } - return "" -} - -func (x *OrderCreated) GetStatus() string { - if x != nil { - return x.Status - } - return "" -} - -var File_messages_proto protoreflect.FileDescriptor - -const file_messages_proto_rawDesc = "" + - "\n" + - "\x0emessages.proto\x12\bmessages\"\x7f\n" + - "\n" + - "AddRequest\x12\x1a\n" + - "\bquantity\x18\x01 \x01(\x05R\bquantity\x12\x10\n" + - "\x03sku\x18\x02 \x01(\tR\x03sku\x12\x18\n" + - "\acountry\x18\x03 \x01(\tR\acountry\x12\x1d\n" + - "\astoreId\x18\x04 \x01(\tH\x00R\astoreId\x88\x01\x01B\n" + - "\n" + - "\b_storeId\"<\n" + - "\x0eSetCartRequest\x12*\n" + - "\x05items\x18\x01 \x03(\v2\x14.messages.AddRequestR\x05items\"\xe9\x04\n" + - "\aAddItem\x12\x17\n" + - "\aitem_id\x18\x01 \x01(\x03R\x06itemId\x12\x1a\n" + - "\bquantity\x18\x02 \x01(\x05R\bquantity\x12\x14\n" + - "\x05price\x18\x03 \x01(\x03R\x05price\x12\x1a\n" + - "\borgPrice\x18\t \x01(\x03R\borgPrice\x12\x10\n" + - "\x03sku\x18\x04 \x01(\tR\x03sku\x12\x12\n" + - "\x04name\x18\x05 \x01(\tR\x04name\x12\x14\n" + - "\x05image\x18\x06 \x01(\tR\x05image\x12\x14\n" + - "\x05stock\x18\a \x01(\x05R\x05stock\x12\x10\n" + - "\x03tax\x18\b \x01(\x05R\x03tax\x12\x14\n" + - "\x05brand\x18\r \x01(\tR\x05brand\x12\x1a\n" + - "\bcategory\x18\x0e \x01(\tR\bcategory\x12\x1c\n" + - "\tcategory2\x18\x0f \x01(\tR\tcategory2\x12\x1c\n" + - "\tcategory3\x18\x10 \x01(\tR\tcategory3\x12\x1c\n" + - "\tcategory4\x18\x11 \x01(\tR\tcategory4\x12\x1c\n" + - "\tcategory5\x18\x12 \x01(\tR\tcategory5\x12\x1e\n" + - "\n" + - "disclaimer\x18\n" + - " \x01(\tR\n" + - "disclaimer\x12 \n" + - "\varticleType\x18\v \x01(\tR\varticleType\x12\x1a\n" + - "\bsellerId\x18\x13 \x01(\tR\bsellerId\x12\x1e\n" + - "\n" + - "sellerName\x18\x14 \x01(\tR\n" + - "sellerName\x12\x18\n" + - "\acountry\x18\x15 \x01(\tR\acountry\x12\x1b\n" + - "\x06outlet\x18\f \x01(\tH\x00R\x06outlet\x88\x01\x01\x12\x1d\n" + - "\astoreId\x18\x16 \x01(\tH\x01R\astoreId\x88\x01\x01B\t\n" + - "\a_outletB\n" + - "\n" + - "\b_storeId\"\x1c\n" + - "\n" + - "RemoveItem\x12\x0e\n" + - "\x02Id\x18\x01 \x01(\x03R\x02Id\"<\n" + - "\x0eChangeQuantity\x12\x0e\n" + - "\x02id\x18\x01 \x01(\x03R\x02id\x12\x1a\n" + - "\bquantity\x18\x02 \x01(\x05R\bquantity\"\x86\x02\n" + - "\vSetDelivery\x12\x1a\n" + - "\bprovider\x18\x01 \x01(\tR\bprovider\x12\x14\n" + - "\x05items\x18\x02 \x03(\x03R\x05items\x12<\n" + - "\vpickupPoint\x18\x03 \x01(\v2\x15.messages.PickupPointH\x00R\vpickupPoint\x88\x01\x01\x12\x18\n" + - "\acountry\x18\x04 \x01(\tR\acountry\x12\x10\n" + - "\x03zip\x18\x05 \x01(\tR\x03zip\x12\x1d\n" + - "\aaddress\x18\x06 \x01(\tH\x01R\aaddress\x88\x01\x01\x12\x17\n" + - "\x04city\x18\a \x01(\tH\x02R\x04city\x88\x01\x01B\x0e\n" + - "\f_pickupPointB\n" + - "\n" + - "\b_addressB\a\n" + - "\x05_city\"\xf9\x01\n" + - "\x0eSetPickupPoint\x12\x1e\n" + - "\n" + - "deliveryId\x18\x01 \x01(\x03R\n" + - "deliveryId\x12\x0e\n" + - "\x02id\x18\x02 \x01(\tR\x02id\x12\x17\n" + - "\x04name\x18\x03 \x01(\tH\x00R\x04name\x88\x01\x01\x12\x1d\n" + - "\aaddress\x18\x04 \x01(\tH\x01R\aaddress\x88\x01\x01\x12\x17\n" + - "\x04city\x18\x05 \x01(\tH\x02R\x04city\x88\x01\x01\x12\x15\n" + - "\x03zip\x18\x06 \x01(\tH\x03R\x03zip\x88\x01\x01\x12\x1d\n" + - "\acountry\x18\a \x01(\tH\x04R\acountry\x88\x01\x01B\a\n" + - "\x05_nameB\n" + - "\n" + - "\b_addressB\a\n" + - "\x05_cityB\x06\n" + - "\x04_zipB\n" + - "\n" + - "\b_country\"\xd6\x01\n" + - "\vPickupPoint\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\x12\x17\n" + - "\x04name\x18\x02 \x01(\tH\x00R\x04name\x88\x01\x01\x12\x1d\n" + - "\aaddress\x18\x03 \x01(\tH\x01R\aaddress\x88\x01\x01\x12\x17\n" + - "\x04city\x18\x04 \x01(\tH\x02R\x04city\x88\x01\x01\x12\x15\n" + - "\x03zip\x18\x05 \x01(\tH\x03R\x03zip\x88\x01\x01\x12\x1d\n" + - "\acountry\x18\x06 \x01(\tH\x04R\acountry\x88\x01\x01B\a\n" + - "\x05_nameB\n" + - "\n" + - "\b_addressB\a\n" + - "\x05_cityB\x06\n" + - "\x04_zipB\n" + - "\n" + - "\b_country\" \n" + - "\x0eRemoveDelivery\x12\x0e\n" + - "\x02id\x18\x01 \x01(\x03R\x02id\"\xb9\x01\n" + - "\x13CreateCheckoutOrder\x12\x14\n" + - "\x05terms\x18\x01 \x01(\tR\x05terms\x12\x1a\n" + - "\bcheckout\x18\x02 \x01(\tR\bcheckout\x12\"\n" + - "\fconfirmation\x18\x03 \x01(\tR\fconfirmation\x12\x12\n" + - "\x04push\x18\x04 \x01(\tR\x04push\x12\x1e\n" + - "\n" + - "validation\x18\x05 \x01(\tR\n" + - "validation\x12\x18\n" + - "\acountry\x18\x06 \x01(\tR\acountry\"@\n" + - "\fOrderCreated\x12\x18\n" + - "\aorderId\x18\x01 \x01(\tR\aorderId\x12\x16\n" + - "\x06status\x18\x02 \x01(\tR\x06statusB\fZ\n" + - ".;messagesb\x06proto3" - -var ( - file_messages_proto_rawDescOnce sync.Once - file_messages_proto_rawDescData []byte -) - -func file_messages_proto_rawDescGZIP() []byte { - file_messages_proto_rawDescOnce.Do(func() { - file_messages_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_messages_proto_rawDesc), len(file_messages_proto_rawDesc))) - }) - return file_messages_proto_rawDescData -} - -var file_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 11) -var file_messages_proto_goTypes = []any{ - (*AddRequest)(nil), // 0: messages.AddRequest - (*SetCartRequest)(nil), // 1: messages.SetCartRequest - (*AddItem)(nil), // 2: messages.AddItem - (*RemoveItem)(nil), // 3: messages.RemoveItem - (*ChangeQuantity)(nil), // 4: messages.ChangeQuantity - (*SetDelivery)(nil), // 5: messages.SetDelivery - (*SetPickupPoint)(nil), // 6: messages.SetPickupPoint - (*PickupPoint)(nil), // 7: messages.PickupPoint - (*RemoveDelivery)(nil), // 8: messages.RemoveDelivery - (*CreateCheckoutOrder)(nil), // 9: messages.CreateCheckoutOrder - (*OrderCreated)(nil), // 10: messages.OrderCreated -} -var file_messages_proto_depIdxs = []int32{ - 0, // 0: messages.SetCartRequest.items:type_name -> messages.AddRequest - 7, // 1: messages.SetDelivery.pickupPoint:type_name -> messages.PickupPoint - 2, // [2:2] is the sub-list for method output_type - 2, // [2:2] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name -} - -func init() { file_messages_proto_init() } -func file_messages_proto_init() { - if File_messages_proto != nil { - return - } - file_messages_proto_msgTypes[0].OneofWrappers = []any{} - file_messages_proto_msgTypes[2].OneofWrappers = []any{} - file_messages_proto_msgTypes[5].OneofWrappers = []any{} - file_messages_proto_msgTypes[6].OneofWrappers = []any{} - file_messages_proto_msgTypes[7].OneofWrappers = []any{} - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_messages_proto_rawDesc), len(file_messages_proto_rawDesc)), - NumEnums: 0, - NumMessages: 11, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_messages_proto_goTypes, - DependencyIndexes: file_messages_proto_depIdxs, - MessageInfos: file_messages_proto_msgTypes, - }.Build() - File_messages_proto = out.File - file_messages_proto_goTypes = nil - file_messages_proto_depIdxs = nil -} diff --git a/proto/messages.proto b/proto/messages.proto index 9d50ae6..42a94ac 100644 --- a/proto/messages.proto +++ b/proto/messages.proto @@ -1,83 +1,77 @@ syntax = "proto3"; package messages; -option go_package = ".;messages"; +option go_package = "git.tornberg.me/go-cart-actor/proto;messages"; -message AddRequest { - int32 quantity = 1; - string sku = 2; - string country = 3; - optional string storeId = 4; -} +message ClearCartRequest { -message SetCartRequest { - repeated AddRequest items = 1; } message AddItem { - int64 item_id = 1; - int32 quantity = 2; - int64 price = 3; - int64 orgPrice = 9; - string sku = 4; - string name = 5; - string image = 6; - int32 stock = 7; - int32 tax = 8; - string brand = 13; - string category = 14; - string category2 = 15; - string category3 = 16; - string category4 = 17; - string category5 = 18; - string disclaimer = 10; - string articleType = 11; - string sellerId = 19; - string sellerName = 20; - string country = 21; - optional string outlet = 12; - optional string storeId = 22; + uint32 item_id = 1; + int32 quantity = 2; + int64 price = 3; + int64 orgPrice = 9; + string sku = 4; + string name = 5; + string image = 6; + int32 stock = 7; + int32 tax = 8; + string brand = 13; + string category = 14; + string category2 = 15; + string category3 = 16; + string category4 = 17; + string category5 = 18; + string disclaimer = 10; + string articleType = 11; + string sellerId = 19; + string sellerName = 20; + string country = 21; + optional string outlet = 12; + optional string storeId = 22; + optional uint32 parentId = 23; } message RemoveItem { - int64 Id = 1; + uint32 Id = 1; } message ChangeQuantity { - int64 id = 1; - int32 quantity = 2; + uint32 Id = 1; + int32 quantity = 2; } message SetDelivery { - string provider = 1; - repeated int64 items = 2; - optional PickupPoint pickupPoint = 3; - string country = 4; - string zip = 5; - optional string address = 6; - optional string city = 7; + string provider = 1; + repeated uint32 items = 2; + optional PickupPoint pickupPoint = 3; + string country = 4; + string zip = 5; + optional string address = 6; + optional string city = 7; } message SetPickupPoint { - int64 deliveryId = 1; - string id = 2; - optional string name = 3; - optional string address = 4; - optional string city = 5; - optional string zip = 6; - optional string country = 7; + uint32 deliveryId = 1; + string id = 2; + optional string name = 3; + optional string address = 4; + optional string city = 5; + optional string zip = 6; + optional string country = 7; } message PickupPoint { - string id = 1; - optional string name = 2; - optional string address = 3; - optional string city = 4; - optional string zip = 5; - optional string country = 6; + string id = 1; + optional string name = 2; + optional string address = 3; + optional string city = 4; + optional string zip = 5; + optional string country = 6; } message RemoveDelivery { - int64 id = 1; + uint32 id = 1; } message CreateCheckoutOrder { @@ -90,6 +84,33 @@ message CreateCheckoutOrder { } message OrderCreated { - string orderId = 1; - string status = 2; + string orderId = 1; + string status = 2; +} + +message Noop { + // Intentionally empty - used for ownership acquisition or health pings +} + +message InitializeCheckout { + string orderId = 1; + string status = 2; + bool paymentInProgress = 3; +} + +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; } diff --git a/remote-grain-pool._go b/remote-grain-pool._go deleted file mode 100644 index bc13b41..0000000 --- a/remote-grain-pool._go +++ /dev/null @@ -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() -// } diff --git a/remote-grain.go b/remote-grain.go deleted file mode 100644 index e6cfffb..0000000 --- a/remote-grain.go +++ /dev/null @@ -1,72 +0,0 @@ -package main - -import ( - "fmt" - "strings" - - "github.com/yudhasubki/netpool" -) - -func (id CartId) String() string { - return strings.Trim(string(id[:]), "\x00") -} - -type CartIdPayload struct { - Id CartId - Data []byte -} - -func MakeCartInnerFrame(id CartId, payload []byte) []byte { - if payload == nil { - return id[:] - } - return append(id[:], payload...) -} - -func GetCartFrame(data []byte) (*CartIdPayload, error) { - if len(data) < 16 { - return nil, fmt.Errorf("data too short") - } - return &CartIdPayload{ - Id: CartId(data[:16]), - Data: data[16:], - }, nil -} - -func ToCartId(id string) CartId { - var result [16]byte - copy(result[:], []byte(id)) - return result -} - -type RemoteGrain struct { - *Connection - Id CartId - Host string -} - -func NewRemoteGrain(id CartId, host string, pool netpool.Netpooler) *RemoteGrain { - addr := fmt.Sprintf("%s:1337", host) - return &RemoteGrain{ - Id: id, - Host: host, - Connection: NewConnection(addr, pool), - } -} - -func (g *RemoteGrain) HandleMessage(message *Message, isReplay bool) (*FrameWithPayload, error) { - - data, err := GetData(message.Write) - if err != nil { - return nil, err - } - return g.Call(RemoteHandleMutation, MakeCartInnerFrame(g.Id, data)) -} - -func (g *RemoteGrain) GetId() CartId { - return g.Id -} - -func (g *RemoteGrain) GetCurrentState() (*FrameWithPayload, error) { - return g.Call(RemoteGetState, MakeCartInnerFrame(g.Id, nil)) -} diff --git a/remote-grain_test.go b/remote-grain_test.go deleted file mode 100644 index 7560a79..0000000 --- a/remote-grain_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package main - -import "testing" - -func TestCartIdsNullData(t *testing.T) { - data := MakeCartInnerFrame(ToCartId("kalle"), nil) - cart, err := GetCartFrame(data) - if err != nil { - t.Errorf("Error getting cart: %v", err) - } - if cart.Id.String() != "kalle" { - t.Errorf("Expected kalle, got %s", cart.Id) - } - if len(cart.Data) != 0 { - t.Errorf("Expected no data, got %v", cart.Data) - } -} diff --git a/remote-host.go b/remote-host.go deleted file mode 100644 index 5ca06c8..0000000 --- a/remote-host.go +++ /dev/null @@ -1,93 +0,0 @@ -package main - -import ( - "fmt" - "log" - "strings" - - "github.com/yudhasubki/netpool" -) - -type RemoteHost struct { - *Connection - HostPool netpool.Netpooler - Host string - MissedPings int -} - -func (h *RemoteHost) IsHealthy() bool { - return h.MissedPings < 3 -} - -func (h *RemoteHost) Initialize(p *SyncedPool) { - log.Printf("Initializing remote %s\n", h.Host) - ids, err := h.GetCartMappings() - if err != nil { - log.Printf("Error getting remote mappings: %v\n", err) - return - } - log.Printf("Remote %s has %d grains\n", h.Host, len(ids)) - - local := 0 - remoteNo := 0 - for _, id := range ids { - go p.SpawnRemoteGrain(id, h.Host) - remoteNo++ - } - log.Printf("Removed %d local grains, added %d remote grains\n", local, remoteNo) - - go p.Negotiate() - -} - -func (h *RemoteHost) Ping() error { - result, err := h.Call(Ping, nil) - - if err != nil || result.StatusCode != 200 || result.Type != Pong { - h.MissedPings++ - log.Printf("Error pinging remote %s, missed pings: %d", h.Host, h.MissedPings) - } else { - h.MissedPings = 0 - } - return err -} - -func (h *RemoteHost) Negotiate(knownHosts []string) ([]string, error) { - reply, err := h.Call(RemoteNegotiate, []byte(strings.Join(knownHosts, ";"))) - - if err != nil { - return nil, err - } - if reply.StatusCode != 200 { - return nil, fmt.Errorf("remote returned error on negotiate: %s", string(reply.Payload)) - } - - return strings.Split(string(reply.Payload), ";"), nil -} - -func (g *RemoteHost) GetCartMappings() ([]CartId, error) { - reply, err := g.Call(GetCartIds, []byte{}) - if err != nil { - return nil, err - } - if reply.StatusCode != 200 || reply.Type != CartIdsResponse { - log.Printf("Remote returned error on get cart mappings: %s", string(reply.Payload)) - return nil, fmt.Errorf("remote returned incorrect data") - } - parts := strings.Split(string(reply.Payload), ";") - ids := make([]CartId, 0, len(parts)) - for _, p := range parts { - ids = append(ids, ToCartId(p)) - } - return ids, nil -} - -func (r *RemoteHost) ConfirmChange(id CartId, host string) error { - reply, err := r.Call(RemoteGrainChanged, []byte(fmt.Sprintf("%s;%s", id, host))) - - if err != nil || reply.StatusCode != 200 || reply.Type != AckChange { - return err - } - - return nil -} diff --git a/rpc-server.go b/rpc-server.go deleted file mode 100644 index 7492a85..0000000 --- a/rpc-server.go +++ /dev/null @@ -1,69 +0,0 @@ -package main - -import ( - "bytes" - "fmt" -) - -type GrainHandler struct { - *GenericListener - pool *GrainLocalPool -} - -func (h *GrainHandler) GetState(id CartId, reply *Grain) error { - grain, err := h.pool.GetGrain(id) - if err != nil { - return err - } - *reply = grain - return nil -} - -func NewGrainHandler(pool *GrainLocalPool, listen string) (*GrainHandler, error) { - conn := NewConnection(listen, nil) - server, err := conn.Listen() - handler := &GrainHandler{ - GenericListener: server, - pool: pool, - } - server.AddHandler(RemoteHandleMutation, handler.RemoteHandleMessageHandler) - server.AddHandler(RemoteGetState, handler.RemoteGetStateHandler) - return handler, err -} - -func (h *GrainHandler) IsHealthy() bool { - return len(h.pool.grains) < h.pool.PoolSize -} - -func (h *GrainHandler) RemoteHandleMessageHandler(data *FrameWithPayload, resultChan chan<- FrameWithPayload) error { - cartData, err := GetCartFrame(data.Payload) - if err != nil { - return err - } - var msg Message - err = ReadMessage(bytes.NewReader(cartData.Data), &msg) - if err != nil { - fmt.Println("Error reading message:", err) - return err - } - - replyData, err := h.pool.Process(cartData.Id, msg) - if err != nil { - return err - } - resultChan <- *replyData - return err -} - -func (h *GrainHandler) RemoteGetStateHandler(data *FrameWithPayload, resultChan chan<- FrameWithPayload) error { - cartData, err := GetCartFrame(data.Payload) - if err != nil { - return err - } - reply, err := h.pool.Get(cartData.Id) - if err != nil { - return err - } - resultChan <- *reply - return nil -} diff --git a/synced-pool.go b/synced-pool.go deleted file mode 100644 index 79ad578..0000000 --- a/synced-pool.go +++ /dev/null @@ -1,535 +0,0 @@ -package main - -import ( - "fmt" - "log" - "net" - "strings" - "sync" - "time" - - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" - "github.com/yudhasubki/netpool" - "k8s.io/apimachinery/pkg/watch" -) - -type Quorum interface { - Negotiate(knownHosts []string) ([]string, error) - OwnerChanged(CartId, host string) error -} - -type HealthHandler interface { - IsHealthy() bool -} - -type SyncedPool struct { - Server *GenericListener - mu sync.RWMutex - discardedHostHandler *DiscardedHostHandler - Hostname string - local *GrainLocalPool - remotes map[string]*RemoteHost - remoteIndex map[CartId]*RemoteGrain -} - -var ( - negotiationCount = promauto.NewCounter(prometheus.CounterOpts{ - Name: "cart_remote_negotiation_total", - Help: "The total number of remote negotiations", - }) - grainSyncCount = promauto.NewCounter(prometheus.CounterOpts{ - Name: "cart_grain_sync_total", - Help: "The total number of grain owner changes", - }) - connectedRemotes = promauto.NewGauge(prometheus.GaugeOpts{ - Name: "cart_connected_remotes", - Help: "The number of connected remotes", - }) - remoteLookupCount = promauto.NewCounter(prometheus.CounterOpts{ - Name: "cart_remote_lookup_total", - Help: "The total number of remote lookups", - }) - packetQueue = promauto.NewGauge(prometheus.GaugeOpts{ - Name: "cart_packet_queue_size", - Help: "The total number of packets in the queue", - }) - packetsSent = promauto.NewCounter(prometheus.CounterOpts{ - Name: "cart_pool_packets_sent_total", - Help: "The total number of packets sent", - }) - packetsReceived = promauto.NewCounter(prometheus.CounterOpts{ - Name: "cart_pool_packets_received_total", - Help: "The total number of packets received", - }) -) - -func (p *SyncedPool) PongHandler(data *FrameWithPayload, resultChan chan<- FrameWithPayload) error { - resultChan <- MakeFrameWithPayload(Pong, 200, []byte{}) - return nil -} - -func (p *SyncedPool) GetCartIdHandler(data *FrameWithPayload, resultChan chan<- FrameWithPayload) error { - ids := make([]string, 0, len(p.local.grains)) - p.mu.RLock() - defer p.mu.RUnlock() - for id := range p.local.grains { - if p.local.grains[id] == nil { - continue - } - s := id.String() - if s == "" { - continue - } - ids = append(ids, s) - } - log.Printf("Returning %d cart ids\n", len(ids)) - resultChan <- MakeFrameWithPayload(CartIdsResponse, 200, []byte(strings.Join(ids, ";"))) - return nil -} - -func (p *SyncedPool) NegotiateHandler(data *FrameWithPayload, resultChan chan<- FrameWithPayload) error { - negotiationCount.Inc() - log.Printf("Handling negotiation\n") - for _, host := range p.ExcludeKnown(strings.Split(string(data.Payload), ";")) { - if host == "" { - continue - } - go p.AddRemote(host) - - } - p.mu.RLock() - defer p.mu.RUnlock() - hosts := make([]string, 0, len(p.remotes)) - for _, r := range p.remotes { - if r.IsHealthy() { - hosts = append(hosts, r.Host) - } - } - resultChan <- MakeFrameWithPayload(RemoteNegotiateResponse, 200, []byte(strings.Join(hosts, ";"))) - return nil -} - -func (p *SyncedPool) GrainOwnerChangeHandler(data *FrameWithPayload, resultChan chan<- FrameWithPayload) error { - grainSyncCount.Inc() - - idAndHostParts := strings.Split(string(data.Payload), ";") - if len(idAndHostParts) != 2 { - log.Printf("Invalid remote grain change message") - resultChan <- MakeFrameWithPayload(AckError, 500, []byte("invalid")) - return nil - } - id := ToCartId(idAndHostParts[0]) - host := idAndHostParts[1] - log.Printf("Handling remote grain owner change to %s for id %s", host, id) - for _, r := range p.remotes { - if r.Host == host && r.IsHealthy() { - go p.SpawnRemoteGrain(id, host) - break - } - } - go p.AddRemote(host) - resultChan <- MakeFrameWithPayload(AckChange, 200, []byte("ok")) - return nil -} - -func (p *SyncedPool) RemoveRemoteGrain(id CartId) { - p.mu.Lock() - defer p.mu.Unlock() - delete(p.remoteIndex, id) -} - -func (p *SyncedPool) SpawnRemoteGrain(id CartId, host string) { - if id.String() == "" { - log.Printf("Invalid grain id, %s", id) - return - } - p.mu.RLock() - localGrain, ok := p.local.grains[id] - p.mu.RUnlock() - - if ok && localGrain != nil { - log.Printf("Grain %s already exists locally, owner is (%s)", id, host) - p.mu.Lock() - delete(p.local.grains, id) - p.mu.Unlock() - } - - go func(i CartId, h string) { - var pool netpool.Netpooler - p.mu.RLock() - for _, r := range p.remotes { - if r.Host == h { - pool = r.HostPool - break - } - } - p.mu.RUnlock() - if pool == nil { - log.Printf("Error spawning remote grain, no pool for %s", h) - return - } - remoteGrain := NewRemoteGrain(i, h, pool) - - p.mu.Lock() - p.remoteIndex[i] = remoteGrain - p.mu.Unlock() - }(id, host) -} - -func (p *SyncedPool) HandleHostError(host string) { - p.mu.RLock() - defer p.mu.RUnlock() - for _, r := range p.remotes { - if r.Host == host { - if !r.IsHealthy() { - go p.RemoveHost(r) - } - return - } - } -} - -func NewSyncedPool(local *GrainLocalPool, hostname string, discovery Discovery) (*SyncedPool, error) { - listen := fmt.Sprintf("%s:1338", hostname) - conn := NewConnection(listen, nil) - server, err := conn.Listen() - if err != nil { - return nil, err - } - - log.Printf("Listening on %s", listen) - dh := NewDiscardedHostHandler(1338) - pool := &SyncedPool{ - Server: server, - Hostname: hostname, - local: local, - discardedHostHandler: dh, - remotes: make(map[string]*RemoteHost), - remoteIndex: make(map[CartId]*RemoteGrain), - } - dh.SetReconnectHandler(pool.AddRemote) - server.AddHandler(Ping, pool.PongHandler) - server.AddHandler(GetCartIds, pool.GetCartIdHandler) - server.AddHandler(RemoteNegotiate, pool.NegotiateHandler) - server.AddHandler(RemoteGrainChanged, pool.GrainOwnerChangeHandler) - server.AddHandler(Closing, pool.HostTerminatingHandler) - - if discovery != nil { - go func() { - time.Sleep(time.Second * 5) - log.Printf("Starting discovery") - ch, err := discovery.Watch() - if err != nil { - log.Printf("Error discovering hosts: %v", err) - return - } - for chng := range ch { - if chng.Host == "" { - continue - } - known := pool.IsKnown(chng.Host) - if chng.Type != watch.Deleted && !known { - - log.Printf("Discovered host %s, waiting for startup", chng.Host) - time.Sleep(3 * time.Second) - pool.AddRemote(chng.Host) - - } else if chng.Type == watch.Deleted && known { - log.Printf("Host removed %s, removing from index", chng.Host) - for _, r := range pool.remotes { - if r.Host == chng.Host { - pool.RemoveHost(r) - break - } - } - } - } - }() - } else { - log.Printf("No discovery, waiting for remotes to connect") - } - - return pool, nil -} - -func (p *SyncedPool) HostTerminatingHandler(data *FrameWithPayload, resultChan chan<- FrameWithPayload) error { - log.Printf("Remote host terminating") - host := string(data.Payload) - p.mu.RLock() - defer p.mu.RUnlock() - for _, r := range p.remotes { - if r.Host == host { - go p.RemoveHost(r) - break - } - } - resultChan <- MakeFrameWithPayload(Pong, 200, []byte("ok")) - return nil -} - -func (p *SyncedPool) IsHealthy() bool { - for _, r := range p.remotes { - if !r.IsHealthy() { - return false - } - } - return true -} - -func (p *SyncedPool) IsKnown(host string) bool { - for _, r := range p.remotes { - if r.Host == host { - return true - } - } - - return host == p.Hostname -} - -func (p *SyncedPool) ExcludeKnown(hosts []string) []string { - ret := make([]string, 0, len(hosts)) - for _, h := range hosts { - if !p.IsKnown(h) { - ret = append(ret, h) - } - } - return ret -} - -func (p *SyncedPool) RemoveHost(host *RemoteHost) { - p.mu.Lock() - delete(p.remotes, host.Host) - p.mu.Unlock() - p.RemoveHostMappedCarts(host) - p.discardedHostHandler.AppendHost(host.Host) - connectedRemotes.Set(float64(len(p.remotes))) -} - -func (p *SyncedPool) RemoveHostMappedCarts(host *RemoteHost) { - p.mu.Lock() - defer p.mu.Unlock() - for id, r := range p.remoteIndex { - if r.Host == host.Host { - delete(p.remoteIndex, id) - } - } -} - -const ( - RemoteNegotiate = FrameType(3) - RemoteGrainChanged = FrameType(4) - AckChange = FrameType(5) - AckError = FrameType(6) - Ping = FrameType(7) - Pong = FrameType(8) - GetCartIds = FrameType(9) - CartIdsResponse = FrameType(10) - RemoteNegotiateResponse = FrameType(11) - Closing = FrameType(12) -) - -func (p *SyncedPool) Negotiate() { - knownHosts := make([]string, 0, len(p.remotes)+1) - for _, r := range p.remotes { - knownHosts = append(knownHosts, r.Host) - } - knownHosts = append([]string{p.Hostname}, knownHosts...) - - for _, r := range p.remotes { - hosts, err := r.Negotiate(knownHosts) - if err != nil { - log.Printf("Error negotiating with %s: %v\n", r.Host, err) - return - } - for _, h := range hosts { - if !p.IsKnown(h) { - p.AddRemote(h) - } - } - } - -} - -func (p *SyncedPool) GetHealthyRemotes() []*RemoteHost { - p.mu.RLock() - defer p.mu.RUnlock() - remotes := make([]*RemoteHost, 0, len(p.remotes)) - for _, r := range p.remotes { - if r.IsHealthy() { - remotes = append(remotes, r) - } - } - return remotes -} - -func (p *SyncedPool) RequestOwnership(id CartId) error { - ok := 0 - all := 0 - - for _, r := range p.GetHealthyRemotes() { - - err := r.ConfirmChange(id, p.Hostname) - all++ - if err != nil { - if !r.IsHealthy() { - log.Printf("Ownership: Removing host, unable to communicate with %s", r.Host) - p.RemoveHost(r) - all-- - } else { - log.Printf("Error confirming change: %v from %s\n", err, p.Hostname) - } - continue - } - //log.Printf("Remote confirmed change %s\n", r.Host) - ok++ - } - - if (all < 3 && ok < all) || ok < (all/2) { - p.removeLocalGrain(id) - return fmt.Errorf("quorum not reached") - } - return nil -} - -func (p *SyncedPool) removeLocalGrain(id CartId) { - p.mu.Lock() - defer p.mu.Unlock() - delete(p.local.grains, id) -} - -func (p *SyncedPool) AddRemote(host string) { - p.mu.Lock() - defer p.mu.Unlock() - _, hasHost := p.remotes[host] - if host == "" || hasHost || host == p.Hostname { - return - } - - host_pool, err := netpool.New(func() (net.Conn, error) { - return net.Dial("tcp", fmt.Sprintf("%s:1338", host)) - }, netpool.WithMaxPool(128), netpool.WithMinPool(0)) - - if err != nil { - log.Printf("Error creating host pool: %v\n", err) - return - } - - client := NewConnection(fmt.Sprintf("%s:1338", host), host_pool) - - pings := 3 - for pings >= 0 { - _, err = client.Call(Ping, nil) - if err != nil { - log.Printf("Ping failed when adding %s, trying %d more times\n", host, pings) - pings-- - time.Sleep(time.Millisecond * 300) - continue - } - break - } - log.Printf("Connected to remote %s", host) - - cart_pool, err := netpool.New(func() (net.Conn, error) { - return net.Dial("tcp", fmt.Sprintf("%s:1337", host)) - }, netpool.WithMaxPool(128), netpool.WithMinPool(0)) - - if err != nil { - log.Printf("Error creating grain pool: %v\n", err) - return - } - - remote := RemoteHost{ - HostPool: cart_pool, - Connection: client, - MissedPings: 0, - Host: host, - } - - p.remotes[host] = &remote - - connectedRemotes.Set(float64(len(p.remotes))) - - go p.HandlePing(&remote) - go remote.Initialize(p) -} - -func (p *SyncedPool) HandlePing(remote *RemoteHost) { - for range time.Tick(time.Second * 3) { - - err := remote.Ping() - - for err != nil { - time.Sleep(time.Millisecond * 200) - if !remote.IsHealthy() { - log.Printf("Removing host, unable to communicate with %s", remote.Host) - p.RemoveHost(remote) - return - } - err = remote.Ping() - } - } -} - -func (p *SyncedPool) getGrain(id CartId) (Grain, error) { - var err error - p.mu.RLock() - defer p.mu.RUnlock() - localGrain, ok := p.local.grains[id] - if !ok { - // check if remote grain exists - - remoteGrain, ok := p.remoteIndex[id] - - if ok { - remoteLookupCount.Inc() - return remoteGrain, nil - } - - go p.RequestOwnership(id) - // if err != nil { - // log.Printf("Error requesting ownership: %v\n", err) - // return nil, err - // } - - localGrain, err = p.local.GetGrain(id) - if err != nil { - return nil, err - } - - } - return localGrain, nil -} - -func (p *SyncedPool) Close() { - p.mu.Lock() - defer p.mu.Unlock() - payload := []byte(p.Hostname) - for _, r := range p.remotes { - go r.Call(Closing, payload) - } -} - -func (p *SyncedPool) Process(id CartId, messages ...Message) (*FrameWithPayload, error) { - pool, err := p.getGrain(id) - var res *FrameWithPayload - if err != nil { - return nil, err - } - for _, m := range messages { - res, err = pool.HandleMessage(&m, false) - if err != nil { - return nil, err - } - } - return res, nil -} - -func (p *SyncedPool) Get(id CartId) (*FrameWithPayload, error) { - grain, err := p.getGrain(id) - if err != nil { - return nil, err - } - - return grain.GetCurrentState() -} diff --git a/synced-pool_test.go b/synced-pool_test.go deleted file mode 100644 index 8845055..0000000 --- a/synced-pool_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package main - -import ( - "log" - "testing" - "time" - - messages "git.tornberg.me/go-cart-actor/proto" -) - -func TestConnection(t *testing.T) { - // TestConnection tests the connection to the server - - localPool := NewGrainLocalPool(100, time.Minute, func(id CartId) (*CartGrain, error) { - return &CartGrain{ - Id: id, - storageMessages: []Message{}, - Items: []*CartItem{}, - Deliveries: make([]*CartDelivery, 0), - TotalPrice: 0, - }, nil - }) - hg, err := NewGrainHandler(localPool, ":1337") - - if err != nil { - t.Errorf("Error creating handler: %v\n", err) - } - if hg == nil { - t.Errorf("Expected handler, got nil") - } - pool, err := NewSyncedPool(localPool, "127.0.0.1", nil) - if err != nil { - t.Errorf("Error creating pool: %v", err) - } - time.Sleep(400 * time.Millisecond) - pool.AddRemote("127.0.0.1") - - go pool.Negotiate() - msg := Message{ - Type: AddItemType, - //TimeStamp: time.Now().Unix(), - Content: &messages.AddItem{ - Quantity: 1, - Price: 100, - Sku: "123", - Name: "Test", - Image: "Test", - }, - } - data, err := pool.Process(ToCartId("kalle"), msg) - if err != nil { - t.Errorf("Error getting data: %v", err) - } - if data.StatusCode != 200 { - t.Errorf("Expected 200") - } - log.Println(data) - time.Sleep(2 * time.Millisecond) - data, err = pool.Get(ToCartId("kalle")) - if err != nil { - t.Errorf("Error getting data: %v", err) - } - if data == nil { - t.Errorf("Expected data, got nil") - } -} diff --git a/tcp-connection.go b/tcp-connection.go deleted file mode 100644 index f8e0b31..0000000 --- a/tcp-connection.go +++ /dev/null @@ -1,232 +0,0 @@ -package main - -import ( - "bufio" - "encoding/binary" - "fmt" - "io" - "log" - "net" - "time" - - "github.com/yudhasubki/netpool" -) - -type Connection struct { - address string - pool netpool.Netpooler - count uint64 -} - -type FrameType uint32 -type StatusCode uint32 -type CheckSum uint32 - -type Frame struct { - Type FrameType - StatusCode StatusCode - Length uint32 - Checksum CheckSum -} - -func (f *Frame) IsValid() bool { - return f.Checksum == MakeChecksum(f.Type, f.StatusCode, f.Length) -} - -func MakeChecksum(msg FrameType, statusCode StatusCode, length uint32) CheckSum { - sum := CheckSum((uint32(msg) + uint32(statusCode) + length) / 8) - return sum -} - -type FrameWithPayload struct { - Frame - Payload []byte -} - -func MakeFrameWithPayload(msg FrameType, statusCode StatusCode, payload []byte) FrameWithPayload { - len := uint32(len(payload)) - return FrameWithPayload{ - Frame: Frame{ - Type: msg, - StatusCode: statusCode, - Length: len, - Checksum: MakeChecksum(msg, statusCode, len), - }, - Payload: payload, - } -} - -type FrameData interface { - ToBytes() []byte - FromBytes([]byte) error -} - -func NewConnection(address string, pool netpool.Netpooler) *Connection { - return &Connection{ - count: 0, - pool: pool, - address: address, - } -} - -func SendFrame(conn net.Conn, data *FrameWithPayload) error { - - err := binary.Write(conn, binary.LittleEndian, data.Frame) - if err != nil { - return err - } - _, err = conn.Write(data.Payload) - - return err -} - -func (c *Connection) CallAsync(msg FrameType, payload []byte, ch chan<- FrameWithPayload) (net.Conn, error) { - conn, err := c.pool.Get() - //conn, err := net.Dial("tcp", c.address) - if err != nil { - return conn, err - } - go WaitForFrame(conn, ch) - - go func(toSend FrameWithPayload) { - err = SendFrame(conn, &toSend) - if err != nil { - log.Printf("Error sending frame: %v\n", err) - //close(ch) - //conn.Close() - } - }(MakeFrameWithPayload(msg, 1, payload)) - - c.count++ - return conn, err -} - -func (c *Connection) Call(msg FrameType, data []byte) (*FrameWithPayload, error) { - ch := make(chan FrameWithPayload, 1) - conn, err := c.CallAsync(msg, data, ch) - defer c.pool.Put(conn, err) - if err != nil { - return nil, err - } - - defer close(ch) - - ret := <-ch - return &ret, nil - // select { - // case ret := <-ch: - // return &ret, nil - // case <-time.After(MaxCallDuration): - // return nil, fmt.Errorf("timeout waiting for frame") - // } -} - -func WaitForFrame(conn net.Conn, resultChan chan<- FrameWithPayload) error { - var err error - var frame Frame - - err = binary.Read(conn, binary.LittleEndian, &frame) - if err != nil { - return err - } - if frame.IsValid() { - payload := make([]byte, frame.Length) - _, err = conn.Read(payload) - if err != nil { - conn.Close() - return err - } - resultChan <- FrameWithPayload{ - Frame: frame, - Payload: payload, - } - return err - } - log.Println("Checksum mismatch") - return fmt.Errorf("checksum mismatch") -} - -type GenericListener struct { - StopListener bool - handlers map[FrameType]func(*FrameWithPayload, chan<- FrameWithPayload) error -} - -func (c *Connection) Listen() (*GenericListener, error) { - l, err := net.Listen("tcp", c.address) - if err != nil { - return nil, err - } - ret := &GenericListener{ - handlers: make(map[FrameType]func(*FrameWithPayload, chan<- FrameWithPayload) error), - } - go func() { - for !ret.StopListener { - connection, err := l.Accept() - if err != nil { - log.Printf("Error accepting connection: %v\n", err) - continue - } - go ret.HandleConnection(connection) - } - }() - return ret, nil -} - -const ( - MaxCallDuration = 300 * time.Millisecond - ListenerKeepalive = 5 * time.Second -) - -func (l *GenericListener) HandleConnection(conn net.Conn) { - var err error - var frame Frame - log.Printf("Server Connection accepted: %s\n", conn.RemoteAddr().String()) - b := bufio.NewReader(conn) - for err != io.EOF { - - err = binary.Read(b, binary.LittleEndian, &frame) - - if err == nil && frame.IsValid() { - payload := make([]byte, frame.Length) - _, err = b.Read(payload) - if err == nil { - err = l.HandleFrame(conn, &FrameWithPayload{ - Frame: frame, - Payload: payload, - }) - } - } - } - conn.Close() - log.Printf("Server Connection closed") -} - -func (l *GenericListener) AddHandler(msg FrameType, handler func(*FrameWithPayload, chan<- FrameWithPayload) error) { - l.handlers[msg] = handler -} - -func (l *GenericListener) HandleFrame(conn net.Conn, frame *FrameWithPayload) error { - handler, ok := l.handlers[frame.Type] - if ok { - go func() { - resultChan := make(chan FrameWithPayload, 1) - defer close(resultChan) - err := handler(frame, resultChan) - if err != nil { - errFrame := MakeFrameWithPayload(frame.Type, 500, []byte(err.Error())) - SendFrame(conn, &errFrame) - log.Printf("Handler returned error: %s", err) - return - } - result := <-resultChan - err = SendFrame(conn, &result) - if err != nil { - log.Printf("Error sending frame: %s", err) - } - }() - } else { - conn.Close() - return fmt.Errorf("no handler for frame type %d", frame.Type) - } - return nil -} diff --git a/tcp-connection_test.go b/tcp-connection_test.go deleted file mode 100644 index b326271..0000000 --- a/tcp-connection_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package main - -import ( - "fmt" - "net" - "testing" - - "github.com/yudhasubki/netpool" -) - -func TestGenericConnection(t *testing.T) { - - listenConn := NewConnection("127.0.0.1:51337", nil) - listener, err := listenConn.Listen() - if err != nil { - t.Errorf("Error listening: %v\n", err) - } - pool, err := netpool.New(func() (net.Conn, error) { - return net.Dial("tcp", "127.0.0.1:51337") - }, netpool.WithMaxPool(128), netpool.WithMinPool(16)) - if err != nil { - t.Errorf("Error creating pool: %v\n", err) - } - conn := NewConnection("127.0.0.1:51337", pool) - - datta := []byte("Hello, world!") - listener.AddHandler(Ping, func(input *FrameWithPayload, resultChan chan<- FrameWithPayload) error { - resultChan <- MakeFrameWithPayload(Pong, 200, nil) - return nil - }) - listener.AddHandler(1, func(input *FrameWithPayload, resultChan chan<- FrameWithPayload) error { - resultChan <- MakeFrameWithPayload(2, 200, datta) - return nil - }) - listener.AddHandler(3, func(input *FrameWithPayload, resultChan chan<- FrameWithPayload) error { - return fmt.Errorf("Error in custom handler") - }) - r, err := conn.Call(1, datta) - if err != nil { - t.Errorf("Error calling: %v\n", err) - } - if r.Type != 2 { - t.Errorf("Expected type 2, got %d\n", r.Type) - } - - res, err := conn.Call(3, datta) - if err != nil { - t.Errorf("Did not expect error, got %v\n", err) - return - } - if res.StatusCode == 200 { - t.Errorf("Expected error, got %v\n", res) - } - - i := 100 - results := make(chan FrameWithPayload, i) - for i > 0 { - go conn.CallAsync(1, datta, results) - i-- - } - for i < 100 { - r := <-results - if r.Type != 2 { - t.Errorf("Expected type 2, got %d\n", r.Type) - } - i++ - } - - response, err := conn.Call(Ping, nil) - if err != nil || response.StatusCode != 200 || response.Type != Pong { - t.Errorf("Error connecting to remote %v, err: %v\n", response, err) - } - -}