Compare commits
3 Commits
82650bd3dd
...
refactor/g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b97eb8f285 | ||
|
|
2697832d98 | ||
|
|
4c973b239f |
40
.cursorrules
40
.cursorrules
@@ -1,40 +0,0 @@
|
|||||||
{
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"description": "Project Overview",
|
|
||||||
"rule": "This is a distributed cart management system in Go using the actor model. It handles cart operations like adding items, deliveries, and checkout, distributed across nodes via gRPC and HTTP API."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Key Architecture",
|
|
||||||
"rule": "Use grains for cart state, pools for management, mutation registry for state changes, and control plane for node coordination. Ownership via consistent hashing ring."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Coding Standards",
|
|
||||||
"rule": "Follow Go conventions. Use mutation registry for all state changes. Regenerate protobuf code after proto changes; never edit .pb.go files. Handle errors properly, use cookies for cart IDs in HTTP."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Mutation Pattern",
|
|
||||||
"rule": "For new mutations: Define proto message, regenerate code, implement handler as func(*CartGrain, *T) error, register with RegisterMutation[T], add endpoints, test."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Avoid Direct Mutations",
|
|
||||||
"rule": "Do not mutate CartGrain state directly outside registered handlers. Use the registry for consistency."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Protobuf Handling",
|
|
||||||
"rule": "After modifying proto files, run protoc commands to regenerate Go code. Ensure protoc-gen-go and protoc-gen-go-grpc are installed."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Testing",
|
|
||||||
"rule": "Write unit tests for mutations, integration tests for APIs. Run go test ./... regularly."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Testability and Configurability",
|
|
||||||
"rule": "Design code to be testable and configurable, following examples like MutationRegistry (for type-safe mutation dispatching) and SimpleGrainPool (for configurable pool management). Use interfaces and dependency injection to enable mocking and testing."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Common Patterns",
|
|
||||||
"rule": "HTTP handlers parse requests, resolve grains via SyncedPool, apply mutations, return JSON. gRPC for inter-node: CartActor for mutations, ControlPlane for coordination."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# .dockerignore for go-cart-actor
|
|
||||||
#
|
|
||||||
# Goal: Keep Docker build context lean & reproducible.
|
|
||||||
# Adjust as project structure evolves.
|
|
||||||
|
|
||||||
# Version control & CI metadata
|
|
||||||
.git
|
|
||||||
.git/
|
|
||||||
.gitignore
|
|
||||||
.github
|
|
||||||
|
|
||||||
# Local tooling / editors
|
|
||||||
.vscode
|
|
||||||
.idea
|
|
||||||
*.iml
|
|
||||||
|
|
||||||
# Build artifacts / outputs
|
|
||||||
bin/
|
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
out/
|
|
||||||
coverage/
|
|
||||||
*.coverprofile
|
|
||||||
|
|
||||||
# Temporary files
|
|
||||||
*.tmp
|
|
||||||
*.log
|
|
||||||
tmp/
|
|
||||||
.tmp/
|
|
||||||
|
|
||||||
# Dependency/vendor caches (not used; rely on go modules download)
|
|
||||||
vendor/
|
|
||||||
|
|
||||||
# Examples / scripts (adjust if you actually need them in build context)
|
|
||||||
examples/
|
|
||||||
scripts/
|
|
||||||
|
|
||||||
# Docs (retain README.md explicitly)
|
|
||||||
docs/
|
|
||||||
CHANGELOG*
|
|
||||||
**/*.md
|
|
||||||
!README.md
|
|
||||||
|
|
||||||
# Tests (not needed for production build)
|
|
||||||
**/*_test.go
|
|
||||||
|
|
||||||
# Node / frontend artifacts (if any future addition)
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Docker / container metadata not needed inside image
|
|
||||||
Dockerfile
|
|
||||||
|
|
||||||
# Editor swap/backup files
|
|
||||||
*~
|
|
||||||
*.swp
|
|
||||||
|
|
||||||
# Go race / profiling outputs
|
|
||||||
*.pprof
|
|
||||||
|
|
||||||
# Security / secret placeholders (ensure real secrets never copied)
|
|
||||||
*.secret
|
|
||||||
*.key
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# Keep proto and generated code (do NOT ignore proto/)
|
|
||||||
!proto/
|
|
||||||
|
|
||||||
# End of file
|
|
||||||
@@ -1,43 +1,30 @@
|
|||||||
name: Build and Publish
|
name: Build and Publish
|
||||||
run-name: ${{ gitea.actor }} build 🚀
|
run-name: ${{ gitea.actor }} is building 🚀
|
||||||
on: [push]
|
on: [push]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
BuildAndDeployAmd64:
|
BuildAndDeployAmd64:
|
||||||
runs-on: amd64
|
runs-on: amd64
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- name: Check out repository code
|
||||||
- name: Build amd64 image
|
uses: actions/checkout@v4
|
||||||
run: |
|
- name: Build docker image
|
||||||
docker build \
|
run: docker build --progress=plain -t registry.knatofs.se/go-cart-actor-amd64:latest .
|
||||||
--progress=plain \
|
- name: Push to registry
|
||||||
-t registry.k6n.net/go-cart-actor-amd64:latest \
|
run: docker push registry.knatofs.se/go-cart-actor-amd64:latest
|
||||||
.
|
- name: Deploy to Kubernetes
|
||||||
- name: Push amd64 images
|
|
||||||
run: |
|
|
||||||
docker push registry.k6n.net/go-cart-actor-amd64:latest
|
|
||||||
- name: Apply deployment manifests
|
|
||||||
run: kubectl apply -f deployment/deployment.yaml -n cart
|
run: kubectl apply -f deployment/deployment.yaml -n cart
|
||||||
- name: Rollout amd64 backoffice deployment
|
- name: Rollout amd64 deployment
|
||||||
run: |
|
run: kubectl rollout restart deployment/cart-actor-x86 -n cart
|
||||||
kubectl rollout restart deployment/cart-backoffice-x86 -n cart
|
|
||||||
kubectl rollout restart deployment/cart-actor-x86 -n cart
|
BuildAndDeploy:
|
||||||
|
|
||||||
BuildAndDeployArm64:
|
|
||||||
runs-on: arm64
|
runs-on: arm64
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Check out repository code
|
||||||
- name: Build arm64 image
|
uses: actions/checkout@v4
|
||||||
run: |
|
- name: Build docker image
|
||||||
docker build \
|
run: docker build --progress=plain -t registry.knatofs.se/go-cart-actor .
|
||||||
--progress=plain \
|
- name: Push to registry
|
||||||
-t registry.k6n.net/go-cart-actor:latest \
|
run: docker push registry.knatofs.se/go-cart-actor
|
||||||
.
|
- name: Rollout arm64 deployment
|
||||||
- name: Push arm64 images
|
run: kubectl rollout restart deployment/cart-actor-arm64 -n cart
|
||||||
run: |
|
|
||||||
docker push registry.k6n.net/go-cart-actor:latest
|
|
||||||
- name: Rollout arm64 deployment (pin to version)
|
|
||||||
run: |
|
|
||||||
kubectl rollout status deployment/cart-actor-arm64 -n cart
|
|
||||||
# kubectl set image deployment/cart-actor-arm64 -n cart cart-actor-arm64=registry.k6n.net/go-cart-actor:${{ needs.Metadata.outputs.version }}
|
|
||||||
# # kubectl rollout status deployment/cart-actor-arm64 -n cart
|
|
||||||
50
.github/copilot-instructions.md
vendored
50
.github/copilot-instructions.md
vendored
@@ -1,50 +0,0 @@
|
|||||||
# GitHub Copilot Instructions for Go Cart Actor
|
|
||||||
|
|
||||||
This repository contains a distributed cart management system implemented in Go using the actor model pattern. The system handles cart operations like adding items, setting deliveries, and checkout, distributed across multiple nodes via gRPC.
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
- `cmd/`: Entry points for the application.
|
|
||||||
- `pkg/`: Core packages including grain logic, pools, and HTTP handlers.
|
|
||||||
- `proto/`: Protocol Buffer definitions for messages and services.
|
|
||||||
- `api-tests/`: Tests for the HTTP API.
|
|
||||||
- `deployment/`: Deployment configurations.
|
|
||||||
- `k6/`: Load testing scripts.
|
|
||||||
|
|
||||||
## Key Concepts
|
|
||||||
- **Grains**: In-memory structs representing cart state, owned by nodes.
|
|
||||||
- **Pools**: Local and synced pools manage grains and handle ownership.
|
|
||||||
- **Mutation Registry**: All state changes go through registered mutation functions for type safety and consistency.
|
|
||||||
- **Ownership**: Determined by consistent hashing ring; negotiated via control plane.
|
|
||||||
|
|
||||||
## Coding Guidelines
|
|
||||||
- Follow standard Go conventions (gofmt, go vet, golint).
|
|
||||||
- Never say "your right", just be correct and clear.
|
|
||||||
- Don't edit the *.gb.go files manually, they are generated by the proto files.
|
|
||||||
- Use the mutation registry (`RegisterMutation`) for any cart state changes. Do not mutate grain state directly outside registered handlers.
|
|
||||||
- Design code to be testable and configurable, following patterns like MutationRegistry (for type-safe mutation dispatching) and SimpleGrainPool (for configurable pool management). Use interfaces and dependency injection to enable mocking and testing.
|
|
||||||
- After modifying `.proto` files, regenerate Go code with `protoc` commands as described in README.md. Never edit generated `.pb.go` files manually.
|
|
||||||
- Use meaningful variable names and add comments for complex logic.
|
|
||||||
- Handle errors explicitly; use Go's error handling patterns.
|
|
||||||
- For HTTP endpoints, ensure proper cookie handling for cart IDs.
|
|
||||||
- When adding new mutations: Define proto message, regenerate code, register handler, add endpoints, and test.
|
|
||||||
- Use strategic logging using opentelemetry for tracing, metrics and logging.
|
|
||||||
- Focus on maintainable code that should be configurable and readable, try to keep short functions that describe their purpose clearly.
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
- Mutations: Define in proto, implement as `func(*CartGrain, *T) error`, register with `RegisterMutation[T]`.
|
|
||||||
- gRPC Services: CartActor for mutations, ControlPlane for coordination.
|
|
||||||
- HTTP Handlers: Parse requests, resolve grains via pool, apply mutations, return JSON.
|
|
||||||
|
|
||||||
## Avoid
|
|
||||||
- Direct state mutations outside the registry.
|
|
||||||
- Hardcoding values; use configuration or constants.
|
|
||||||
- Ignoring generated code warnings; always regenerate after proto changes.
|
|
||||||
- Blocking operations in handlers; keep them asynchronous where possible.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
- Write unit tests for mutations and handlers.
|
|
||||||
- Use integration tests for API endpoints.
|
|
||||||
- Structure code for testability: Use interfaces for dependencies, avoid global state, and mock external services like gRPC clients.
|
|
||||||
- Run `go test ./...` to ensure all tests pass.
|
|
||||||
|
|
||||||
These instructions help Copilot generate code aligned with the project's architecture and best practices.
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,4 @@
|
|||||||
__debug*
|
__debug*
|
||||||
go-cart-actor
|
go-cart-actor
|
||||||
data/*.prot
|
data/*.prot
|
||||||
data/*.go*
|
data/*.go*
|
||||||
data/se/*
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
go-cart-actor/CGM_LINE_ITEMS_SPEC.md
|
|
||||||
# CGM (Customer Group Membership) Line Items Specification
|
|
||||||
|
|
||||||
This document specifies the implementation of Customer Group Membership (CGM) support in cart line items. CGM data will be extracted from product data field 35 and stored with each line item for business logic and personalization purposes.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
CGM represents customer group membership information associated with products. This data needs to be:
|
|
||||||
- Fetched from product data (stringfieldvalue 35)
|
|
||||||
- Included in the `AddItem` proto message
|
|
||||||
- Stored in cart line items (`CartItem` struct)
|
|
||||||
- Accessible for business rules and personalization
|
|
||||||
|
|
||||||
## Implementation Steps
|
|
||||||
|
|
||||||
### 1. Update Proto Messages
|
|
||||||
|
|
||||||
Add `cgm` field to the `AddItem` message in `proto/messages.proto`:
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message AddItem {
|
|
||||||
// ... existing fields ...
|
|
||||||
string cgm = 25; // Customer Group Membership from field 35
|
|
||||||
// ... existing fields ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note**: Use field number 25 (next available after existing fields).
|
|
||||||
|
|
||||||
### 2. Update Product Fetcher
|
|
||||||
|
|
||||||
Modify `cmd/cart/product-fetcher.go` to extract CGM from field 35:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country string) (*messages.AddItem, error) {
|
|
||||||
// ... existing code ...
|
|
||||||
|
|
||||||
cgm, _ := item.GetStringFieldValue(35) // Extract CGM from field 35
|
|
||||||
|
|
||||||
return &messages.AddItem{
|
|
||||||
// ... existing fields ...
|
|
||||||
Cgm: cgm, // Add CGM field
|
|
||||||
// ... existing fields ...
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Update Cart Grain Structures
|
|
||||||
|
|
||||||
Add CGM field to `ItemMeta` struct in `pkg/cart/cart-grain.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type ItemMeta struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Brand string `json:"brand,omitempty"`
|
|
||||||
Category string `json:"category,omitempty"`
|
|
||||||
// ... existing fields ...
|
|
||||||
Cgm string `json:"cgm,omitempty"` // Customer Group Membership
|
|
||||||
// ... existing fields ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Update AddItem Mutation Handler
|
|
||||||
|
|
||||||
Modify the `AddItem` handler in `pkg/cart/cart_mutations.go` to populate the CGM field:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func AddItem(grain *CartGrain, req *messages.AddItem) error {
|
|
||||||
// ... existing validation ...
|
|
||||||
|
|
||||||
item := &CartItem{
|
|
||||||
// ... existing fields ...
|
|
||||||
Meta: &ItemMeta{
|
|
||||||
Name: req.Name,
|
|
||||||
Brand: req.Brand,
|
|
||||||
// ... existing meta fields ...
|
|
||||||
Cgm: req.Cgm, // Add CGM to item meta
|
|
||||||
// ... existing meta fields ...
|
|
||||||
},
|
|
||||||
// ... existing fields ...
|
|
||||||
}
|
|
||||||
|
|
||||||
// ... rest of handler ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Regenerate Proto Code
|
|
||||||
|
|
||||||
After updating `proto/messages.proto`, regenerate Go code:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd proto
|
|
||||||
protoc --go_out=. --go_opt=paths=source_relative \
|
|
||||||
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
|
||||||
messages.proto cart_actor.proto control_plane.proto
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Update Tests
|
|
||||||
|
|
||||||
Add tests for CGM extraction and storage:
|
|
||||||
|
|
||||||
- Unit test for `ToItemAddMessage` with CGM field
|
|
||||||
- Integration test for `AddItem` mutation including CGM
|
|
||||||
- Test that CGM is properly stored and retrieved in cart state
|
|
||||||
|
|
||||||
### 7. Update API Documentation
|
|
||||||
|
|
||||||
Update README.md and API examples to mention CGM field in line items.
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
1. **Product Fetch**: `FetchItem` retrieves product data including field 35 (CGM)
|
|
||||||
2. **Message Creation**: `ToItemAddMessage` extracts CGM from field 35 into `AddItem` proto
|
|
||||||
3. **Mutation Processing**: `AddItem` handler stores CGM in `CartItem.Meta.Cgm`
|
|
||||||
4. **State Persistence**: CGM is included in cart JSON serialization
|
|
||||||
5. **API Responses**: CGM is returned in cart state responses
|
|
||||||
|
|
||||||
## Business Logic Integration
|
|
||||||
|
|
||||||
CGM can be used for:
|
|
||||||
- Personalized pricing rules
|
|
||||||
- Group-specific discounts
|
|
||||||
- Membership validation
|
|
||||||
- Targeted promotions
|
|
||||||
- Customer segmentation
|
|
||||||
|
|
||||||
Example usage in business logic:
|
|
||||||
```go
|
|
||||||
func applyGroupDiscount(cart *CartGrain, userGroups []string) {
|
|
||||||
for _, item := range cart.Items {
|
|
||||||
if item.Meta != nil && slices.Contains(userGroups, item.Meta.Cgm) {
|
|
||||||
// Apply group-specific discount
|
|
||||||
item.Price = applyDiscount(item.Price, groupDiscountRate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Backward Compatibility
|
|
||||||
|
|
||||||
- CGM field is optional in proto (no required validation)
|
|
||||||
- Existing carts without CGM will have empty string
|
|
||||||
- Product fetcher gracefully handles missing field 35
|
|
||||||
- API responses include CGM field (empty if not set)
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
- [ ] Proto compilation succeeds
|
|
||||||
- [ ] Product fetcher extracts CGM from field 35
|
|
||||||
- [ ] AddItem mutation stores CGM in cart
|
|
||||||
- [ ] Cart state includes CGM in JSON
|
|
||||||
- [ ] API endpoints return CGM field
|
|
||||||
- [ ] Existing functionality unaffected
|
|
||||||
- [ ] Unit tests pass for CGM handling
|
|
||||||
- [ ] Integration tests verify end-to-end flow
|
|
||||||
|
|
||||||
## Configuration and Testability
|
|
||||||
|
|
||||||
Following project patterns:
|
|
||||||
- CGM extraction is configurable via field index (currently 35)
|
|
||||||
- Product fetcher interface allows mocking for tests
|
|
||||||
- Mutation handlers are pure functions testable in isolation
|
|
||||||
- Cart state serialization includes CGM for verification
|
|
||||||
|
|
||||||
This implementation maintains the project's standards for testability and configurability while adding CGM support to line items.
|
|
||||||
90
Dockerfile
90
Dockerfile
@@ -1,89 +1,17 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1
|
||||||
#
|
|
||||||
# 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
|
||||||
# Build Stage
|
WORKDIR /app
|
||||||
############################
|
|
||||||
FROM golang:1.25-alpine AS build
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
RUN apk add --no-cache git
|
|
||||||
|
|
||||||
# Build metadata (can be overridden at build time)
|
|
||||||
ARG VERSION=dev
|
|
||||||
ARG GIT_COMMIT=unknown
|
|
||||||
ARG BUILD_DATE=unknown
|
|
||||||
|
|
||||||
# Ensure reproducible static build
|
|
||||||
# Multi-arch build args (TARGETOS/TARGETARCH provided automatically by buildx)
|
|
||||||
ARG TARGETOS
|
|
||||||
ARG TARGETARCH
|
|
||||||
ENV CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH}
|
|
||||||
|
|
||||||
# Dependency caching
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# Copy full source (relay on .dockerignore to prune)
|
COPY proto ./proto
|
||||||
COPY . .
|
COPY *.go ./
|
||||||
|
|
||||||
# (Optional) If you do NOT check in generated protobuf code, uncomment generation:
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /go-cart-actor
|
||||||
# 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
|
|
||||||
|
|
||||||
# Build with minimal binary size and embedded metadata
|
FROM gcr.io/distroless/base-debian11
|
||||||
RUN 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
|
|
||||||
|
|
||||||
RUN go build -trimpath -ldflags="-s -w \
|
|
||||||
-X main.Version=${VERSION} \
|
|
||||||
-X main.GitCommit=${GIT_COMMIT} \
|
|
||||||
-X main.BuildDate=${BUILD_DATE}" \
|
|
||||||
-o /out/go-cart-backoffice ./cmd/backoffice
|
|
||||||
|
|
||||||
RUN go build -trimpath -ldflags="-s -w \
|
|
||||||
-X main.Version=${VERSION} \
|
|
||||||
-X main.GitCommit=${GIT_COMMIT} \
|
|
||||||
-X main.BuildDate=${BUILD_DATE}" \
|
|
||||||
-o /out/go-cart-inventory ./cmd/inventory
|
|
||||||
|
|
||||||
############################
|
|
||||||
# Runtime Stage
|
|
||||||
############################
|
|
||||||
# Using distroless static (nonroot) for minimal surface area.
|
|
||||||
FROM gcr.io/distroless/static-debian12:nonroot AS runtime
|
|
||||||
WORKDIR /
|
WORKDIR /
|
||||||
|
|
||||||
COPY --from=build /out/go-cart-actor /go-cart-actor
|
COPY --from=build-stage /go-cart-actor /go-cart-actor
|
||||||
COPY --from=build /out/go-cart-backoffice /go-cart-backoffice
|
ENTRYPOINT ["/go-cart-actor"]
|
||||||
COPY --from=build /out/go-cart-inventory /go-cart-inventory
|
|
||||||
|
|
||||||
# Document (not expose forcibly) typical ports: 8080 (HTTP), 1337 (gRPC)
|
|
||||||
EXPOSE 8080 1337
|
|
||||||
|
|
||||||
USER nonroot:nonroot
|
|
||||||
ENTRYPOINT ["/go-cart-actor"]
|
|
||||||
396
GRPC-MIGRATION-PLAN.md
Normal file
396
GRPC-MIGRATION-PLAN.md
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
# gRPC Migration Plan
|
||||||
|
|
||||||
|
File: GRPC-MIGRATION-PLAN.md
|
||||||
|
Author: (Generated plan)
|
||||||
|
Status: Draft for review
|
||||||
|
Target Release: Next major version (breaking change – no mixed compatibility)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
This document describes the full migration of the current custom TCP frame-based protocol (both the cart mutation/state channel on port `1337` and the control plane on port `1338`) to gRPC. We will remove all legacy packet framing (`FrameWithPayload`, `RemoteGrain`, `GenericListener` handlers for these two ports) and replace them with two gRPC services:
|
||||||
|
|
||||||
|
1. Cart Actor Service (mutations + state retrieval)
|
||||||
|
2. Control Plane Service (cluster membership, negotiation, ownership change, lifecycle)
|
||||||
|
|
||||||
|
We intentionally keep:
|
||||||
|
- Internal `CartGrain` logic, message storage format, disk persistence, and JSON cart serialization.
|
||||||
|
- Existing message type numeric mapping for backward compatibility with persisted event logs.
|
||||||
|
- HTTP/REST API layer unchanged (it still consumes JSON state from the local/remote grain pipeline).
|
||||||
|
|
||||||
|
We do NOT implement mixed-version compatibility; migration occurs atomically (cluster restart with new image).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Goals
|
||||||
|
|
||||||
|
- Remove custom binary frame protocol & simplify maintenance.
|
||||||
|
- Provide clearer, strongly defined interfaces via `.proto` schemas.
|
||||||
|
- Improve observability via gRPC interceptors (metrics & tracing hooks).
|
||||||
|
- Reduce per-call overhead compared with the current manual connection pooling + handwritten framing (HTTP/2 multiplexing + connection reuse).
|
||||||
|
- Prepare groundwork for future enhancements (streaming, typed state, event streaming) without rewriting again.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Non-Goals (Phase 1)
|
||||||
|
|
||||||
|
- Converting the cart state payload from JSON to a strongly typed proto.
|
||||||
|
- Introducing authentication / mTLS (may be added later).
|
||||||
|
- Changing persistence or replay format.
|
||||||
|
- Changing the HTTP API contract.
|
||||||
|
- Implementing streaming watchers or push updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Architecture After Migration
|
||||||
|
|
||||||
|
Ports:
|
||||||
|
- `:1337` → gRPC CartActor service.
|
||||||
|
- `:1338` → gRPC ControlPlane service.
|
||||||
|
|
||||||
|
Each node:
|
||||||
|
- Runs one gRPC server with both services (can use a single listener bound to two services or keep two separate listeners; we will keep two ports initially to minimize operational surprise, but they could be merged later).
|
||||||
|
- Maintains a connection pool of `*grpc.ClientConn` objects keyed by remote hostname (one per remote host, reused for both services).
|
||||||
|
|
||||||
|
Call Flow (Mutation):
|
||||||
|
1. HTTP request hits `PoolServer`.
|
||||||
|
2. `SyncedPool.getGrain(cartId)`:
|
||||||
|
- Local: direct invocation.
|
||||||
|
- Remote: uses `RemoteGrainGRPC` (new) which invokes `CartActor.Mutate`.
|
||||||
|
3. Response JSON returned unchanged.
|
||||||
|
|
||||||
|
Control Plane Flow:
|
||||||
|
- Discovery (K8s watch) still triggers `AddRemote(host)`.
|
||||||
|
- Instead of custom `Ping`, `Negotiate`, etc. via frames, call gRPC methods on `ControlPlane` service.
|
||||||
|
- Ownership changes use `ConfirmOwner` RPC.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Proto Design
|
||||||
|
|
||||||
|
### 5.1 Cart Actor Proto (Envelope Pattern)
|
||||||
|
|
||||||
|
We keep an envelope with `bytes payload` holding the serialized underlying cart mutation proto (existing types in `messages.proto`). This minimizes churn.
|
||||||
|
|
||||||
|
Indented code block (proto sketch):
|
||||||
|
|
||||||
|
syntax = "proto3";
|
||||||
|
package cart;
|
||||||
|
option go_package = "git.tornberg.me/go-cart-actor/proto;proto";
|
||||||
|
|
||||||
|
enum MutationType {
|
||||||
|
MUTATION_TYPE_UNSPECIFIED = 0;
|
||||||
|
MUTATION_ADD_REQUEST = 1;
|
||||||
|
MUTATION_ADD_ITEM = 2;
|
||||||
|
MUTATION_REMOVE_ITEM = 4;
|
||||||
|
MUTATION_REMOVE_DELIVERY = 5;
|
||||||
|
MUTATION_CHANGE_QUANTITY = 6;
|
||||||
|
MUTATION_SET_DELIVERY = 7;
|
||||||
|
MUTATION_SET_PICKUP_POINT = 8;
|
||||||
|
MUTATION_CREATE_CHECKOUT_ORDER = 9;
|
||||||
|
MUTATION_SET_CART_ITEMS = 10;
|
||||||
|
MUTATION_ORDER_COMPLETED = 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MutationRequest {
|
||||||
|
string cart_id = 1;
|
||||||
|
MutationType type = 2;
|
||||||
|
bytes payload = 3; // Serialized specific mutation proto
|
||||||
|
int64 client_timestamp = 4; // Optional; server fills if zero
|
||||||
|
}
|
||||||
|
|
||||||
|
message MutationReply {
|
||||||
|
int32 status_code = 1;
|
||||||
|
bytes payload = 2; // JSON cart state or error string
|
||||||
|
}
|
||||||
|
|
||||||
|
message StateRequest {
|
||||||
|
string cart_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message StateReply {
|
||||||
|
int32 status_code = 1;
|
||||||
|
bytes payload = 2; // JSON cart state
|
||||||
|
}
|
||||||
|
|
||||||
|
service CartActor {
|
||||||
|
rpc Mutate(MutationRequest) returns (MutationReply);
|
||||||
|
rpc GetState(StateRequest) returns (StateReply);
|
||||||
|
}
|
||||||
|
|
||||||
|
### 5.2 Control Plane Proto
|
||||||
|
|
||||||
|
syntax = "proto3";
|
||||||
|
package control;
|
||||||
|
option go_package = "git.tornberg.me/go-cart-actor/proto;proto";
|
||||||
|
|
||||||
|
message Empty {}
|
||||||
|
|
||||||
|
message PingReply {
|
||||||
|
string host = 1;
|
||||||
|
int64 unix_time = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message NegotiateRequest {
|
||||||
|
repeated string known_hosts = 1;
|
||||||
|
}
|
||||||
|
message NegotiateReply {
|
||||||
|
repeated string hosts = 1; // Healthy hosts returned
|
||||||
|
}
|
||||||
|
|
||||||
|
message CartIdsReply {
|
||||||
|
repeated string cart_ids = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OwnerChangeRequest {
|
||||||
|
string cart_id = 1;
|
||||||
|
string new_host = 2;
|
||||||
|
}
|
||||||
|
message OwnerChangeAck {
|
||||||
|
bool accepted = 1;
|
||||||
|
string message = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ClosingNotice {
|
||||||
|
string host = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
service ControlPlane {
|
||||||
|
rpc Ping(Empty) returns (PingReply);
|
||||||
|
rpc Negotiate(NegotiateRequest) returns (NegotiateReply);
|
||||||
|
rpc GetCartIds(Empty) returns (CartIdsReply);
|
||||||
|
rpc ConfirmOwner(OwnerChangeRequest) returns (OwnerChangeAck);
|
||||||
|
rpc Closing(ClosingNotice) returns (OwnerChangeAck);
|
||||||
|
}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Message Type Mapping
|
||||||
|
|
||||||
|
| Legacy Constant | Numeric | New Enum Value |
|
||||||
|
|-----------------|---------|-----------------------------|
|
||||||
|
| AddRequestType | 1 | MUTATION_ADD_REQUEST |
|
||||||
|
| AddItemType | 2 | MUTATION_ADD_ITEM |
|
||||||
|
| RemoveItemType | 4 | MUTATION_REMOVE_ITEM |
|
||||||
|
| RemoveDeliveryType | 5 | MUTATION_REMOVE_DELIVERY |
|
||||||
|
| ChangeQuantityType | 6 | MUTATION_CHANGE_QUANTITY |
|
||||||
|
| SetDeliveryType | 7 | MUTATION_SET_DELIVERY |
|
||||||
|
| SetPickupPointType | 8 | MUTATION_SET_PICKUP_POINT |
|
||||||
|
| CreateCheckoutOrderType | 9 | MUTATION_CREATE_CHECKOUT_ORDER |
|
||||||
|
| SetCartItemsType | 10 | MUTATION_SET_CART_ITEMS |
|
||||||
|
| OrderCompletedType | 11 | MUTATION_ORDER_COMPLETED |
|
||||||
|
|
||||||
|
Persisted events keep original numeric codes; reconstruction simply casts to `MutationType`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Components To Remove / Replace
|
||||||
|
|
||||||
|
Remove (after migration complete):
|
||||||
|
- `remote-grain.go`
|
||||||
|
- `rpc-server.go`
|
||||||
|
- Any packet/frame-specific types solely used by the above (search: `FrameWithPayload`, `RemoteHandleMutation`, `RemoteGetState` where not reused by disk or internal logic).
|
||||||
|
- The constants representing network frame types in `synced-pool.go` (RemoteNegotiate, AckChange, etc.) replaced by gRPC calls.
|
||||||
|
- netpool usage for remote cart channel (control plane also no longer needs `Connection` abstraction).
|
||||||
|
|
||||||
|
Retain (until reworked or optionally cleaned later):
|
||||||
|
- `message.go` (for persistence)
|
||||||
|
- `message-handler.go`
|
||||||
|
- `cart-grain.go`
|
||||||
|
- `messages.proto` (underlying mutation messages)
|
||||||
|
- HTTP API server and REST handlers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. New / Modified Components
|
||||||
|
|
||||||
|
New files (planned):
|
||||||
|
- `proto/cart_actor.proto`
|
||||||
|
- `proto/control_plane.proto`
|
||||||
|
- `grpc/cart_actor_server.go` (server impl)
|
||||||
|
- `grpc/cart_actor_client.go` (client adapter implementing `Grain`)
|
||||||
|
- `grpc/control_plane_server.go`
|
||||||
|
- `grpc/control_plane_client.go`
|
||||||
|
- `grpc/interceptors.go` (metrics, logging, optional tracing hooks)
|
||||||
|
- `remote_grain_grpc.go` (adapter bridging existing interfaces)
|
||||||
|
- `control_plane_adapter.go` (replaces frame handlers in `SyncedPool`)
|
||||||
|
|
||||||
|
Modified:
|
||||||
|
- `synced-pool.go` (remote host management now uses gRPC clients; negotiation logic updated)
|
||||||
|
- `main.go` (initialize both gRPC services on startup)
|
||||||
|
- `go.mod` (add `google.golang.org/grpc`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Step-by-Step Migration Plan
|
||||||
|
|
||||||
|
1. Add proto files and generate Go code (`protoc --go_out --go-grpc_out`).
|
||||||
|
2. Implement `CartActorServer`:
|
||||||
|
- Translate `MutationRequest` to `Message`.
|
||||||
|
- Use existing handler registry for payload encode/decode.
|
||||||
|
- Return JSON cart state.
|
||||||
|
3. Implement `CartActorClient` wrapper (`RemoteGrainGRPC`) implementing:
|
||||||
|
- `HandleMessage`: Build envelope, call `Mutate`.
|
||||||
|
- `GetCurrentState`: Call `GetState`.
|
||||||
|
4. Implement `ControlPlaneServer` with methods:
|
||||||
|
- `Ping`: returns host + time.
|
||||||
|
- `Negotiate`: merge host lists; emulate old logic.
|
||||||
|
- `GetCartIds`: iterate local grains.
|
||||||
|
- `ConfirmOwner`: replicate quorum flow (accept always; error path for future).
|
||||||
|
- `Closing`: schedule remote removal.
|
||||||
|
5. Implement `ControlPlaneClient` used inside `SyncedPool.AddRemote`.
|
||||||
|
6. Refactor `SyncedPool`:
|
||||||
|
- Replace frame handlers registration with gRPC client calls.
|
||||||
|
- Replace `Server.AddHandler(...)` start-up with launching gRPC server.
|
||||||
|
- Implement periodic health checks using `Ping`.
|
||||||
|
7. Remove old connection constructs for 1337/1338.
|
||||||
|
8. Metrics:
|
||||||
|
- Add unary interceptor capturing duration and status.
|
||||||
|
- Replace packet counters with `cart_grpc_mutate_calls_total`, `cart_grpc_control_calls_total`, histograms for latency.
|
||||||
|
9. Update `main.go` to start:
|
||||||
|
- gRPC server(s).
|
||||||
|
- HTTP server as before.
|
||||||
|
10. Delete legacy files & update README build instructions.
|
||||||
|
11. Load testing & profiling on Raspberry Pi hardware (or ARM emulation).
|
||||||
|
12. Final cleanup & dead code removal (search for now-unused constants & structs).
|
||||||
|
13. Tag release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Performance Considerations (Raspberry Pi Focus)
|
||||||
|
|
||||||
|
- Single `*grpc.ClientConn` per remote host (HTTP/2 multiplexing) to reduce file descriptor and handshake overhead.
|
||||||
|
- Use small keepalive pings (optional) only if connections drop; default may suffice.
|
||||||
|
- Avoid reflection / dynamic dispatch in hot path: pre-build a mapping from `MutationType` to handler function.
|
||||||
|
- Reuse byte buffers:
|
||||||
|
- Implement a `sync.Pool` for mutation serialization to reduce GC pressure.
|
||||||
|
- Enforce per-RPC deadlines (e.g. 300–400ms) to avoid pile-ups.
|
||||||
|
- Backpressure:
|
||||||
|
- Before dispatch: if local grain pool at capacity and target grain is remote, abort early with 503 to caller (optional).
|
||||||
|
- Disable gRPC compression for small payloads (mutation messages are small). Condition compression if payload > threshold (e.g. 8KB).
|
||||||
|
- Compile with `-ldflags="-s -w"` in production to reduce binary size (optional).
|
||||||
|
- Enable `GOMAXPROCS` tuned to CPU cores; Pi often benefits from leaving default but monitor.
|
||||||
|
- Use histograms with limited buckets to reduce Prometheus cardinality.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Testing Strategy
|
||||||
|
|
||||||
|
Unit:
|
||||||
|
- Message type mapping tests (legacy -> enum).
|
||||||
|
- Envelope roundtrip: Original proto -> payload -> gRPC -> server decode -> internal Message.
|
||||||
|
|
||||||
|
Integration:
|
||||||
|
- Two-node cluster simulation:
|
||||||
|
- Mutate cart on Node A, ownership moves, verify remote access from Node B.
|
||||||
|
- Quorum failure simulation (temporarily reject `ConfirmOwner`).
|
||||||
|
- Control plane negotiation: start nodes in staggered order, assert final membership.
|
||||||
|
|
||||||
|
Load/Perf:
|
||||||
|
- Benchmark local mutation vs remote mutation latency.
|
||||||
|
- High concurrency test (N goroutines each performing X mutations).
|
||||||
|
- Memory profiling (ensure no large buffer retention).
|
||||||
|
|
||||||
|
Failure Injection:
|
||||||
|
- Kill a node mid-mutation; client call should timeout and not corrupt local state.
|
||||||
|
- Simulated network partition: drop `Ping` replies; ensure host removal path triggers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Rollback Strategy
|
||||||
|
|
||||||
|
Because no mixed-version compatibility is provided, rollback = redeploy previous version containing legacy protocol:
|
||||||
|
1. Stop all new-version pods.
|
||||||
|
2. Deploy old version cluster-wide.
|
||||||
|
3. No data migration needed (event persistence unaffected).
|
||||||
|
|
||||||
|
Note: Avoid partial upgrades; perform full rolling restart quickly to prevent split-brain (new nodes won’t talk to old nodes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Risks & Mitigations
|
||||||
|
|
||||||
|
| Risk | Description | Mitigation |
|
||||||
|
|------|-------------|------------|
|
||||||
|
| Full-cluster restart required | No mixed compatibility | Schedule maintenance window |
|
||||||
|
| gRPC adds CPU overhead | Envelope + marshaling cost | Buffer reuse, keep small messages uncompressed |
|
||||||
|
| Ownership race | Timing differences after refactor | Add explicit logs + tests around `RequestOwnership` path |
|
||||||
|
| Hidden dependency on frame-level status codes | Some code may assume `FrameWithPayload` fields | Wrap gRPC responses into minimal compatibility structs until fully removed |
|
||||||
|
| Memory growth | Connection reuse & pooled buffers not implemented initially | Add `sync.Pool` & track memory via pprof early |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Logging & Observability
|
||||||
|
|
||||||
|
- Structured log entries for:
|
||||||
|
- Ownership changes
|
||||||
|
- Negotiation rounds
|
||||||
|
- Remote spawn events
|
||||||
|
- Mutation failures (with cart id, mutation type)
|
||||||
|
- Metrics:
|
||||||
|
- `cart_grpc_mutate_duration_seconds` (histogram)
|
||||||
|
- `cart_grpc_mutate_errors_total`
|
||||||
|
- `cart_grpc_control_duration_seconds`
|
||||||
|
- `cart_remote_hosts` (gauge)
|
||||||
|
- Retain existing grain counts.
|
||||||
|
- Optional future: OpenTelemetry tracing (span per remote mutation).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Future Enhancements (Post-Migration)
|
||||||
|
|
||||||
|
- Replace JSON state with `CartState` proto and provide streaming watch API.
|
||||||
|
- mTLS between nodes (certificate rotation via K8s Secret or SPIRE).
|
||||||
|
- Distributed tracing integration.
|
||||||
|
- Ownership leasing with TTL and optimistic renewal.
|
||||||
|
- Delta replication or CRDT-based conflict resolution for experimentation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Task Breakdown & Estimates
|
||||||
|
|
||||||
|
| Task | Estimate |
|
||||||
|
|------|----------|
|
||||||
|
| Proto definitions & generation | 0.5d |
|
||||||
|
| CartActor server/client | 1.0d |
|
||||||
|
| ControlPlane server/client | 1.0d |
|
||||||
|
| SyncedPool refactor | 1.0d |
|
||||||
|
| Metrics & interceptors | 0.5d |
|
||||||
|
| Remove legacy code & cleanup | 0.5d |
|
||||||
|
| Tests (unit + integration) | 1.5d |
|
||||||
|
| Benchmark & tuning | 0.5–1.0d |
|
||||||
|
| Total | ~6–7d |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Open Questions (Confirm Before Implementation)
|
||||||
|
|
||||||
|
1. Combine both services on a single port (simplify ops) or keep dual-port first? (Default here: keep dual, but easy to merge.)
|
||||||
|
2. Minimum Go version remains 1.24.x—acceptable to add `google.golang.org/grpc` latest?
|
||||||
|
3. Accept adding `sync.Pool` micro-optimizations in first pass or postpone?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Acceptance Criteria
|
||||||
|
|
||||||
|
- All previous integration tests (adjusted to gRPC) pass.
|
||||||
|
- Cart operations (add, remove, delivery, checkout) function across at least a 2‑node cluster.
|
||||||
|
- Control plane negotiation forms consistent host list.
|
||||||
|
- Latency for a remote mutation does not degrade beyond an acceptable threshold (define baseline before merge).
|
||||||
|
- Legacy networking code fully removed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. Next Steps (If Approved)
|
||||||
|
|
||||||
|
1. Implement proto files and commit.
|
||||||
|
2. Scaffold server & client code.
|
||||||
|
3. Refactor `SyncedPool` and `main.go`.
|
||||||
|
4. Add metrics and tests.
|
||||||
|
5. Run benchmark on target Pi hardware.
|
||||||
|
6. Review & merge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
End of Plan.
|
||||||
131
Makefile
131
Makefile
@@ -1,131 +0,0 @@
|
|||||||
# ------------------------------------------------------------------------------
|
|
||||||
# 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.k6n.net/go-cart-actor/proto;messages
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
MODULE_PATH := git.k6n.net/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
|
|
||||||
@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
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
go-cart-actor/NEW_MUTATIONS_SPEC.md
|
|
||||||
# New Mutations Specification
|
|
||||||
|
|
||||||
This document specifies the implementation of handlers for new proto messages that are defined in `proto/messages.proto` but not yet registered in the mutation registry. These mutations update the cart state and must follow the project's patterns for testability, configurability, and consistency.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The following messages are defined in the proto but lack registered handlers:
|
|
||||||
- `SetUserId`
|
|
||||||
- `LineItemMarking`
|
|
||||||
- `SubscriptionAdded`
|
|
||||||
- `PaymentDeclined`
|
|
||||||
- `ConfirmationViewed`
|
|
||||||
- `CreateCheckoutOrder`
|
|
||||||
|
|
||||||
Each mutation must:
|
|
||||||
1. Define a handler function with signature `func(*CartGrain, *T) error`
|
|
||||||
2. Be registered in `NewCartMultationRegistry()` using `actor.NewMutation`
|
|
||||||
3. Include unit tests
|
|
||||||
4. Optionally add HTTP/gRPC endpoints if client-invokable
|
|
||||||
5. Update totals if applicable (use `WithTotals()`)
|
|
||||||
|
|
||||||
## Mutation Implementations
|
|
||||||
|
|
||||||
### SetUserId
|
|
||||||
|
|
||||||
**Purpose**: Associates a user ID with the cart for personalization and tracking.
|
|
||||||
|
|
||||||
**Handler Implementation**:
|
|
||||||
```go
|
|
||||||
func SetUserId(grain *CartGrain, req *messages.SetUserId) error {
|
|
||||||
if req.UserId == "" {
|
|
||||||
return errors.New("user ID cannot be empty")
|
|
||||||
}
|
|
||||||
grain.UserId = req.UserId
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Registration**:
|
|
||||||
```go
|
|
||||||
actor.NewMutation(SetUserId, func() *messages.SetUserId {
|
|
||||||
return &messages.SetUserId{}
|
|
||||||
}),
|
|
||||||
```
|
|
||||||
|
|
||||||
**Notes**: This is a simple state update. No totals recalculation needed.
|
|
||||||
|
|
||||||
### LineItemMarking
|
|
||||||
|
|
||||||
**Purpose**: Adds or updates a marking (e.g., gift message, special instructions) on a specific line item.
|
|
||||||
|
|
||||||
**Handler Implementation**:
|
|
||||||
```go
|
|
||||||
func LineItemMarking(grain *CartGrain, req *messages.LineItemMarking) error {
|
|
||||||
for i, item := range grain.Items {
|
|
||||||
if item.Id == req.Id {
|
|
||||||
grain.Items[i].Marking = &Marking{
|
|
||||||
Type: req.Type,
|
|
||||||
Text: req.Marking,
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("item with ID %d not found", req.Id)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Registration**:
|
|
||||||
```go
|
|
||||||
actor.NewMutation(LineItemMarking, func() *messages.LineItemMarking {
|
|
||||||
return &messages.LineItemMarking{}
|
|
||||||
}),
|
|
||||||
```
|
|
||||||
|
|
||||||
**Notes**: Assumes `CartGrain.Items` has a `Marking` field (single marking per item). If not, add it to the grain struct.
|
|
||||||
|
|
||||||
### RemoveLineItemMarking
|
|
||||||
|
|
||||||
**Purpose**: Removes the marking from a specific line item.
|
|
||||||
|
|
||||||
**Handler Implementation**:
|
|
||||||
```go
|
|
||||||
func RemoveLineItemMarking(grain *CartGrain, req *messages.RemoveLineItemMarking) error {
|
|
||||||
for i, item := range grain.Items {
|
|
||||||
if item.Id == req.Id {
|
|
||||||
grain.Items[i].Marking = nil
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("item with ID %d not found", req.Id)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Registration**:
|
|
||||||
```go
|
|
||||||
actor.NewMutation(RemoveLineItemMarking, func() *messages.RemoveLineItemMarking {
|
|
||||||
return &messages.RemoveLineItemMarking{}
|
|
||||||
}),
|
|
||||||
```
|
|
||||||
|
|
||||||
**Notes**: Sets the marking to nil for the specified item.
|
|
||||||
|
|
||||||
### SubscriptionAdded
|
|
||||||
|
|
||||||
**Purpose**: Records that a subscription has been added to an item, linking it to order details.
|
|
||||||
|
|
||||||
**Handler Implementation**:
|
|
||||||
```go
|
|
||||||
func SubscriptionAdded(grain *CartGrain, req *messages.SubscriptionAdded) error {
|
|
||||||
for i, item := range grain.Items {
|
|
||||||
if item.Id == req.ItemId {
|
|
||||||
grain.Items[i].SubscriptionDetailsId = req.DetailsId
|
|
||||||
grain.Items[i].OrderReference = req.OrderReference
|
|
||||||
grain.Items[i].IsSubscribed = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("item with ID %d not found", req.ItemId)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Registration**:
|
|
||||||
```go
|
|
||||||
actor.NewMutation(SubscriptionAdded, func() *messages.SubscriptionAdded {
|
|
||||||
return &messages.SubscriptionAdded{}
|
|
||||||
}),
|
|
||||||
```
|
|
||||||
|
|
||||||
**Notes**: Assumes fields like `SubscriptionDetailsId`, `OrderReference`, `IsSubscribed` exist on items. Add to grain if needed.
|
|
||||||
|
|
||||||
### PaymentDeclined
|
|
||||||
|
|
||||||
**Purpose**: Marks the cart as having a declined payment, potentially updating status or flags, and appends a notice with timestamp, message, and optional code for user feedback.
|
|
||||||
|
|
||||||
**Handler Implementation**:
|
|
||||||
```go
|
|
||||||
func PaymentDeclined(grain *CartGrain, req *messages.PaymentDeclined) error {
|
|
||||||
grain.PaymentStatus = "declined"
|
|
||||||
grain.PaymentDeclinedNotices = append(grain.PaymentDeclinedNotices, Notice{
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
Message: req.Message,
|
|
||||||
Code: req.Code,
|
|
||||||
})
|
|
||||||
// Optionally clear checkout order if in progress
|
|
||||||
if grain.CheckoutOrderId != "" {
|
|
||||||
grain.CheckoutOrderId = ""
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Registration**:
|
|
||||||
```go
|
|
||||||
actor.NewMutation(PaymentDeclined, func() *messages.PaymentDeclined {
|
|
||||||
return &messages.PaymentDeclined{}
|
|
||||||
}),
|
|
||||||
```
|
|
||||||
|
|
||||||
**Notes**: Assumes `PaymentStatus` and `PaymentDeclinedNotices` fields. Add status tracking and error notices list to grain. Notice struct has Timestamp, Message, and optional Code.
|
|
||||||
|
|
||||||
### ConfirmationViewed
|
|
||||||
|
|
||||||
**Purpose**: Records that the order confirmation has been viewed by the user. Applied automatically when the confirmation page is loaded in checkout_server.go.
|
|
||||||
|
|
||||||
**Handler Implementation**:
|
|
||||||
```go
|
|
||||||
func ConfirmationViewed(grain *CartGrain, req *messages.ConfirmationViewed) error {
|
|
||||||
grain.ConfirmationViewCount++
|
|
||||||
grain.ConfirmationLastViewedAt = time.Now()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Registration**:
|
|
||||||
```go
|
|
||||||
actor.NewMutation(ConfirmationViewed, func() *messages.ConfirmationViewed {
|
|
||||||
return &messages.ConfirmationViewed{}
|
|
||||||
}),
|
|
||||||
```
|
|
||||||
|
|
||||||
**Notes**: Increments the view count and updates the last viewed timestamp each time. Assumes `ConfirmationViewCount` and `ConfirmationLastViewedAt` fields.
|
|
||||||
|
|
||||||
### CreateCheckoutOrder
|
|
||||||
|
|
||||||
**Purpose**: Initiates the checkout process, validating terms and creating an order reference.
|
|
||||||
|
|
||||||
**Handler Implementation**:
|
|
||||||
```go
|
|
||||||
func CreateCheckoutOrder(grain *CartGrain, req *messages.CreateCheckoutOrder) error {
|
|
||||||
if len(grain.Items) == 0 {
|
|
||||||
return errors.New("cannot checkout empty cart")
|
|
||||||
}
|
|
||||||
if req.Terms != "accepted" {
|
|
||||||
return errors.New("terms must be accepted")
|
|
||||||
}
|
|
||||||
// Validate other fields as needed
|
|
||||||
grain.CheckoutOrderId = generateOrderId()
|
|
||||||
grain.CheckoutStatus = "pending"
|
|
||||||
grain.CheckoutCountry = req.Country
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Registration**:
|
|
||||||
```go
|
|
||||||
actor.NewMutation(CreateCheckoutOrder, func() *messages.CreateCheckoutOrder {
|
|
||||||
return &messages.CreateCheckoutOrder{}
|
|
||||||
}).WithTotals(),
|
|
||||||
```
|
|
||||||
|
|
||||||
**Notes**: Use `WithTotals()` to recalculate totals after checkout initiation. Assumes order ID generation function.
|
|
||||||
|
|
||||||
## Implementation Steps
|
|
||||||
|
|
||||||
For each mutation:
|
|
||||||
|
|
||||||
1. **Add Handler Function**: Implement in `pkg/cart/` (e.g., `cart_mutations.go`).
|
|
||||||
2. **Register in Registry**: Add to `NewCartMultationRegistry()` in `cart-mutation-helper.go`.
|
|
||||||
3. **Regenerate Proto**: Run `protoc` commands after any proto changes.
|
|
||||||
4. **Add Tests**: Create unit tests in `pkg/cart/` testing the handler logic.
|
|
||||||
5. **Add Endpoints** (if needed): For client-invokable mutations, add HTTP handlers in `cmd/cart/pool-server.go`. ConfirmationViewed is handled in `cmd/cart/checkout_server.go` when the confirmation page is viewed.
|
|
||||||
6. **Update Grain Struct**: Add any new fields to `CartGrain` in `pkg/cart/grain.go`.
|
|
||||||
7. **Run Tests**: Ensure `go test ./...` passes.
|
|
||||||
|
|
||||||
## Testing Guidelines
|
|
||||||
|
|
||||||
- Mock dependencies using interfaces.
|
|
||||||
- Test error cases (e.g., invalid IDs, empty carts).
|
|
||||||
- Verify state changes and totals recalculation.
|
|
||||||
- Use table-driven tests for multiple scenarios.
|
|
||||||
|
|
||||||
## Configuration and Testability
|
|
||||||
|
|
||||||
Follow `MutationRegistry` and `SimpleGrainPool` patterns:
|
|
||||||
- Use interfaces for external dependencies (e.g., ID generators).
|
|
||||||
- Inject configurations via constructor parameters.
|
|
||||||
- Avoid global state; make handlers pure functions where possible.
|
|
||||||
237
README.md
237
README.md
@@ -175,239 +175,4 @@ curl --cookie cookies.txt http://localhost:8080/cart/add/TEST-SKU-123
|
|||||||
|
|
||||||
- Always regenerate protobuf Go code after modifying any `.proto` files (messages/cart_actor/control_plane)
|
- Always regenerate protobuf Go code after modifying any `.proto` files (messages/cart_actor/control_plane)
|
||||||
- The generated `messages.pb.go` file should not be edited manually
|
- The generated `messages.pb.go` file should not be edited manually
|
||||||
- Make sure your PATH includes the protoc-gen-go binary location (usually `$GOPATH/bin`)
|
- Make sure your PATH includes the protoc-gen-go binary location (usually `$GOPATH/bin`)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
The system is a distributed, sharded (by cart id) actor model implementation:
|
|
||||||
|
|
||||||
- Each cart is a grain (an in‑memory struct `*CartGrain`) that owns and mutates its own state.
|
|
||||||
- A **local grain pool** holds grains owned by the node.
|
|
||||||
- A **synced (cluster) pool** (`SyncedPool`) coordinates multiple nodes and exposes local or remote grains through a uniform interface (`GrainPool`).
|
|
||||||
- All inter‑node communication is gRPC:
|
|
||||||
- Cart mutation & state RPCs (CartActor service).
|
|
||||||
- Control plane RPCs (ControlPlane service) for membership, ownership negotiation, liveness, and graceful shutdown.
|
|
||||||
|
|
||||||
### Key Processes
|
|
||||||
|
|
||||||
1. Client HTTP request (or gRPC client) arrives with a cart identifier (cookie or path).
|
|
||||||
2. The pool resolves ownership:
|
|
||||||
- If local grain exists → use it.
|
|
||||||
- If a remote host is known owner → a remote grain proxy (`RemoteGrainGRPC`) is used; it performs gRPC calls to the owning node.
|
|
||||||
- If ownership is unknown → node attempts to claim ownership (quorum negotiation) and spawns a local grain.
|
|
||||||
3. Mutation is executed via the **mutation registry** (registry wraps domain logic + optional totals recomputation).
|
|
||||||
4. Updated state returned to caller; ownership preserved unless relinquished later (not yet implemented to shed load).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Grain & Mutation Model
|
|
||||||
|
|
||||||
- `CartGrain` holds items, deliveries, pricing aggregates, and checkout/order metadata.
|
|
||||||
- All mutations are registered via `RegisterMutation[T]` with signature:
|
|
||||||
```
|
|
||||||
func(*CartGrain, *T) error
|
|
||||||
```
|
|
||||||
- `WithTotals()` flag triggers automatic recalculation of totals after successful handlers.
|
|
||||||
- The old giant `switch` in `CartGrain.Apply` has been replaced by registry dispatch; unregistered mutations fail fast.
|
|
||||||
- Adding a mutation:
|
|
||||||
1. Define proto message.
|
|
||||||
2. Generate code.
|
|
||||||
3. Register handler (optionally WithTotals).
|
|
||||||
4. Add gRPC RPC + request wrapper if the mutation must be remotely invokable.
|
|
||||||
5. (Optional) Add HTTP endpoint mapping to the mutation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Local Grain Pool
|
|
||||||
|
|
||||||
- Manages an in‑memory map `map[CartId]*CartGrain`.
|
|
||||||
- Lazy spawn: first mutation or explicit access triggers `spawn(id)`.
|
|
||||||
- TTL / purge loop periodically removes expired grains unless they changed recently (basic memory pressure management).
|
|
||||||
- Capacity limit (`PoolSize`); oldest expired grain evicted first when full.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Synced (Cluster) Pool
|
|
||||||
|
|
||||||
`SyncedPool` wraps a local pool and tracks:
|
|
||||||
|
|
||||||
- `remoteHosts`: known peer nodes (gRPC connections).
|
|
||||||
- `remoteIndex`: mapping of cart id → remote grain proxy (`RemoteGrainGRPC`) for carts owned elsewhere.
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
|
|
||||||
1. Discovery integration (via a `Discovery` interface) adds/removes hosts.
|
|
||||||
2. Periodic ping health checks (ControlPlane.Ping).
|
|
||||||
3. Ring-based deterministic ownership:
|
|
||||||
- Ownership is derived directly from the consistent hashing ring (no quorum RPC or `ConfirmOwner`).
|
|
||||||
4. Remote spawning:
|
|
||||||
- When a remote host reports its cart ids (`GetCartIds`), the pool creates remote proxies for fast routing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Remote Grain Proxies
|
|
||||||
|
|
||||||
A `RemoteGrainGRPC` implements the `Grain` interface but delegates:
|
|
||||||
|
|
||||||
- `Apply` → Specific CartActor per‑mutation RPC (e.g., `AddItem`, `RemoveItem`) constructed from the mutation type. (Legacy envelope removed.)
|
|
||||||
- `GetCurrentState` → `CartActor.GetState`.
|
|
||||||
|
|
||||||
Return path:
|
|
||||||
|
|
||||||
1. gRPC reply (CartMutationReply / StateReply) → proto `CartState`.
|
|
||||||
2. `ToCartState` / mapping reconstructs a local `CartGrain` snapshot for callers expecting grain semantics.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Control Plane (Inter‑Node Coordination)
|
|
||||||
|
|
||||||
Defined in `proto/control_plane.proto`:
|
|
||||||
|
|
||||||
| RPC | Purpose |
|
|
||||||
|-----|---------|
|
|
||||||
| `Ping` | Liveness; increments missed ping counter if failing. |
|
|
||||||
| `Negotiate` | Merges membership views; used after discovery events. |
|
|
||||||
| `GetCartIds` | Enumerate locally owned carts for remote index seeding. |
|
|
||||||
| `Closing` | Graceful shutdown notice; peers remove host & associated remote grains. |
|
|
||||||
|
|
||||||
### Ownership / Quorum Rules
|
|
||||||
|
|
||||||
- If total participating hosts < 3 → all must accept.
|
|
||||||
- Otherwise majority acceptance (`ok >= total/2`).
|
|
||||||
- On failure → local tentative grain is removed (rollback to avoid split‑brain).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Request / Mutation Flow Examples
|
|
||||||
|
|
||||||
### Local Mutation
|
|
||||||
1. HTTP handler parses request → determines cart id.
|
|
||||||
2. `SyncedPool.Apply`:
|
|
||||||
- Finds local grain (or spawns new after quorum).
|
|
||||||
- Executes registry mutation.
|
|
||||||
3. Totals updated if flagged.
|
|
||||||
4. HTTP response returns updated JSON (via `ToCartState`).
|
|
||||||
|
|
||||||
### Remote Mutation
|
|
||||||
1. `SyncedPool.Apply` sees cart mapped to a remote host.
|
|
||||||
2. Routes to `RemoteGrainGRPC.Apply`.
|
|
||||||
3. Remote node executes mutation locally and returns updated state over gRPC.
|
|
||||||
4. Proxy materializes snapshot locally (not authoritative, read‑only view).
|
|
||||||
|
|
||||||
### Checkout (Side‑Effecting, Non-Pure)
|
|
||||||
- HTTP `/checkout` uses current grain snapshot to build payload (pure function).
|
|
||||||
- Calls Klarna externally (not a mutation).
|
|
||||||
- Applies `InitializeCheckout` mutation to persist reference + status.
|
|
||||||
- Returns Klarna order JSON to client.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scaling & Deployment
|
|
||||||
|
|
||||||
- **Horizontal scaling**: Add more nodes; discovery layer (Kubernetes / service registry) feeds hosts to `SyncedPool`.
|
|
||||||
- **Sharding**: Implicit by cart id hash. Ownership is first-claim with quorum acceptance.
|
|
||||||
- **Hot spots**: A single popular cart remains on one node; for heavy multi-client concurrency, future work could add read replicas or partitioning (not implemented).
|
|
||||||
- **Capacity tuning**: Increase `PoolSize` & memory limits; adjust TTL for stale cart eviction.
|
|
||||||
|
|
||||||
### Adding Nodes
|
|
||||||
1. Node starts gRPC server (CartActor + ControlPlane).
|
|
||||||
2. After brief delay, begins discovery watch; on event:
|
|
||||||
- New host → dial + negotiate → seed remote cart ids.
|
|
||||||
3. Pings maintain health; failed hosts removed (proxies invalidated).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Failure Handling
|
|
||||||
|
|
||||||
| Scenario | Behavior |
|
|
||||||
|----------|----------|
|
|
||||||
| Remote host unreachable | Pings increment `MissedPings`; after threshold host removed. |
|
|
||||||
| Ownership negotiation fails | Tentative local grain discarded. |
|
|
||||||
| gRPC call error on remote mutation | Error bubbled to caller; no local fallback. |
|
|
||||||
| Missing mutation registration | Fast failure with explicit error message. |
|
|
||||||
| Partial checkout (Klarna fails) | No local state mutation for checkout; client sees error; cart remains unchanged. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Mutation Registry Summary
|
|
||||||
|
|
||||||
- Central, type-safe registry prevents silent omission.
|
|
||||||
- Each handler:
|
|
||||||
- Validates input.
|
|
||||||
- Mutates `*CartGrain`.
|
|
||||||
- Returns error for rejection.
|
|
||||||
- Automatic totals recomputation reduces boilerplate and consistency risk.
|
|
||||||
- Coverage test (add separately) can enforce all proto mutations are registered.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## gRPC Interfaces
|
|
||||||
|
|
||||||
- **CartActor**: Per-mutation unary RPCs + `GetState`. (Checkout logic intentionally excluded; handled at HTTP layer.)
|
|
||||||
- **ControlPlane**: Cluster coordination (Ping, Negotiate, GetCartIds, Closing) — ownership now ring-determined (no ConfirmOwner).
|
|
||||||
|
|
||||||
**Ports** (default / implied):
|
|
||||||
- CartActor & ControlPlane share the same gRPC server/listener (single port, e.g. `:1337`).
|
|
||||||
- Legacy frame/TCP code has been removed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security & Future Enhancements
|
|
||||||
|
|
||||||
| Area | Potential Improvement |
|
|
||||||
|------|------------------------|
|
|
||||||
| Transport Security | Add TLS / mTLS to gRPC servers & clients. |
|
|
||||||
| Auth / RBAC | Intercept CartActor RPCs with auth metadata. |
|
|
||||||
| Backpressure | Rate-limit remote mutation calls per host. |
|
|
||||||
| Observability | Add per-mutation Prometheus metrics & tracing spans. |
|
|
||||||
| Ownership | Add lease timeouts / fencing tokens for stricter guarantees. |
|
|
||||||
| Batch Ops | Introduce batch mutation RPC or streaming updates (WatchState). |
|
|
||||||
| Persistence | Reintroduce event log or snapshot persistence layer if durability required. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Adding a New Node (Operational Checklist)
|
|
||||||
|
|
||||||
1. Deploy binary/container with same proto + registry.
|
|
||||||
2. Expose gRPC port.
|
|
||||||
3. Ensure discovery lists the new host.
|
|
||||||
4. Node dials peers, negotiates membership.
|
|
||||||
5. Remote cart proxies seeded.
|
|
||||||
6. Traffic routed automatically based on ownership.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Adding a New Mutation (Checklist Recap)
|
|
||||||
|
|
||||||
1. Define proto message (+ request wrapper & RPC if remote invocation needed).
|
|
||||||
2. Regenerate protobuf code.
|
|
||||||
3. Implement & register handler (`RegisterMutation`).
|
|
||||||
4. Add client (HTTP/gRPC) endpoint.
|
|
||||||
5. Write unit + integration tests.
|
|
||||||
6. (Optional) Add to coverage test list and docs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## High-Level Data Flow Diagram (Text)
|
|
||||||
|
|
||||||
```
|
|
||||||
Client -> HTTP Handler -> SyncedPool -> (local?) -> Registry -> Grain State
|
|
||||||
\-> (remote?) -> RemoteGrainGRPC -> gRPC -> Remote CartActor -> Registry -> Grain
|
|
||||||
ControlPlane: Discovery Events <-> Negotiation/Ping <-> SyncedPool state (ring determines ownership)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
| Symptom | Likely Cause | Action |
|
|
||||||
|---------|--------------|--------|
|
|
||||||
| New cart every request | Secure cookie over plain HTTP or not sending cookie jar | Disable Secure locally or use HTTPS & proper curl `-b` |
|
|
||||||
| Unsupported mutation error | Missing registry handler | Add `RegisterMutation` for that proto |
|
|
||||||
| Ownership imbalance | Ring host distribution skew or rapid host churn | Examine `cart_ring_host_share`, `cart_ring_hosts`, and logs for host add/remove; rebalance or investigate instability |
|
|
||||||
| Remote mutation latency | Network / serialization overhead | Consider batching or colocating hot carts |
|
|
||||||
| Checkout returns 500 | Klarna call failed | Inspect logs; no grain state mutated |
|
|
||||||
|
|
||||||
---
|
|
||||||
245
TODO.md
245
TODO.md
@@ -1,245 +0,0 @@
|
|||||||
# 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._
|
|
||||||
83
amqp-order-handler.go
Normal file
83
amqp-order-handler.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
### Add item to cart
|
### Add item to cart
|
||||||
POST https://cart.k6n.net/api/12345
|
POST https://cart.tornberg.me/api/12345
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -9,7 +9,7 @@ Content-Type: application/json
|
|||||||
}
|
}
|
||||||
|
|
||||||
### Update quanity of item in cart
|
### Update quanity of item in cart
|
||||||
PUT https://cart.k6n.net/api/12345
|
PUT https://cart.tornberg.me/api/12345
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -18,12 +18,12 @@ Content-Type: application/json
|
|||||||
}
|
}
|
||||||
|
|
||||||
### Delete item from cart
|
### Delete item from cart
|
||||||
DELETE https://cart.k6n.net/api/1002/1
|
DELETE https://cart.tornberg.me/api/1002/1
|
||||||
|
|
||||||
|
|
||||||
### Set delivery
|
### Set delivery
|
||||||
|
|
||||||
POST https://cart.k6n.net/api/1002/delivery
|
POST https://cart.tornberg.me/api/1002/delivery
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -33,8 +33,10 @@ Content-Type: application/json
|
|||||||
|
|
||||||
|
|
||||||
### Get cart
|
### Get cart
|
||||||
GET https://cart.k6n.net/api/12345
|
GET https://cart.tornberg.me/api/12345
|
||||||
|
|
||||||
|
|
||||||
### Remove delivery method
|
### Remove delivery method
|
||||||
DELETE https://cart.k6n.net/api/12345/delivery/2
|
DELETE https://cart.tornberg.me/api/12345/delivery/2
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
621
cart-grain.go
Normal file
621
cart-grain.go
Normal file
@@ -0,0 +1,621 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CartId [16]byte
|
||||||
|
|
||||||
|
// String returns the cart id as a trimmed UTF-8 string (trailing zero bytes removed).
|
||||||
|
func (id CartId) String() string {
|
||||||
|
n := 0
|
||||||
|
for n < len(id) && id[n] != 0 {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return string(id[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToCartId converts an arbitrary string to a fixed-size CartId (truncating or padding with zeros).
|
||||||
|
func ToCartId(s string) CartId {
|
||||||
|
var id CartId
|
||||||
|
copy(id[:], []byte(s))
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (id CartId) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(id.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (id *CartId) UnmarshalJSON(data []byte) error {
|
||||||
|
var str string
|
||||||
|
err := json.Unmarshal(data, &str)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
copy(id[:], []byte(str))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type StockStatus int
|
||||||
|
|
||||||
|
const (
|
||||||
|
OutOfStock StockStatus = 0
|
||||||
|
LowStock StockStatus = 1
|
||||||
|
InStock StockStatus = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
type CartItem struct {
|
||||||
|
Id int `json:"id"`
|
||||||
|
ItemId int `json:"itemId,omitempty"`
|
||||||
|
ParentId int `json:"parentId,omitempty"`
|
||||||
|
Sku string `json:"sku"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Price int64 `json:"price"`
|
||||||
|
TotalPrice int64 `json:"totalPrice"`
|
||||||
|
TotalTax int64 `json:"totalTax"`
|
||||||
|
OrgPrice int64 `json:"orgPrice"`
|
||||||
|
Stock StockStatus `json:"stock"`
|
||||||
|
Quantity int `json:"qty"`
|
||||||
|
Tax int `json:"tax"`
|
||||||
|
TaxRate int `json:"taxRate"`
|
||||||
|
Brand string `json:"brand,omitempty"`
|
||||||
|
Category string `json:"category,omitempty"`
|
||||||
|
Category2 string `json:"category2,omitempty"`
|
||||||
|
Category3 string `json:"category3,omitempty"`
|
||||||
|
Category4 string `json:"category4,omitempty"`
|
||||||
|
Category5 string `json:"category5,omitempty"`
|
||||||
|
Disclaimer string `json:"disclaimer,omitempty"`
|
||||||
|
SellerId string `json:"sellerId,omitempty"`
|
||||||
|
SellerName string `json:"sellerName,omitempty"`
|
||||||
|
ArticleType string `json:"type,omitempty"`
|
||||||
|
Image string `json:"image,omitempty"`
|
||||||
|
Outlet *string `json:"outlet,omitempty"`
|
||||||
|
StoreId *string `json:"storeId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CartDelivery struct {
|
||||||
|
Id int `json:"id"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Price int64 `json:"price"`
|
||||||
|
Items []int `json:"items"`
|
||||||
|
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CartGrain struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
lastItemId int
|
||||||
|
lastDeliveryId int
|
||||||
|
storageMessages []Message
|
||||||
|
Id CartId `json:"id"`
|
||||||
|
Items []*CartItem `json:"items"`
|
||||||
|
TotalPrice int64 `json:"totalPrice"`
|
||||||
|
TotalTax int64 `json:"totalTax"`
|
||||||
|
TotalDiscount int64 `json:"totalDiscount"`
|
||||||
|
Deliveries []*CartDelivery `json:"deliveries,omitempty"`
|
||||||
|
Processing bool `json:"processing"`
|
||||||
|
PaymentInProgress bool `json:"paymentInProgress"`
|
||||||
|
OrderReference string `json:"orderReference,omitempty"`
|
||||||
|
PaymentStatus string `json:"paymentStatus,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Grain interface {
|
||||||
|
GetId() CartId
|
||||||
|
HandleMessage(message *Message, isReplay bool) (*FrameWithPayload, error)
|
||||||
|
GetCurrentState() (*FrameWithPayload, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) GetId() CartId {
|
||||||
|
return c.Id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) GetLastChange() int64 {
|
||||||
|
if len(c.storageMessages) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *c.storageMessages[len(c.storageMessages)-1].TimeStamp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) GetCurrentState() (*FrameWithPayload, error) {
|
||||||
|
result, err := json.Marshal(c)
|
||||||
|
if err != nil {
|
||||||
|
ret := MakeFrameWithPayload(0, 400, []byte(err.Error()))
|
||||||
|
return &ret, nil
|
||||||
|
}
|
||||||
|
ret := MakeFrameWithPayload(0, 200, result)
|
||||||
|
return &ret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getInt(data float64, ok bool) (int, error) {
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("invalid type")
|
||||||
|
}
|
||||||
|
return int(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getItemData(sku string, qty int, country string) (*messages.AddItem, error) {
|
||||||
|
item, err := FetchItem(sku, country)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
orgPrice, _ := getInt(item.GetNumberFieldValue(5)) // getInt(item.Fields[5])
|
||||||
|
|
||||||
|
price, priceErr := getInt(item.GetNumberFieldValue(4)) //Fields[4]
|
||||||
|
|
||||||
|
if priceErr != nil {
|
||||||
|
return nil, fmt.Errorf("invalid price")
|
||||||
|
}
|
||||||
|
|
||||||
|
stock := InStock
|
||||||
|
/*item.t
|
||||||
|
if item.StockLevel == "0" || item.StockLevel == "" {
|
||||||
|
stock = OutOfStock
|
||||||
|
} else if item.StockLevel == "5+" {
|
||||||
|
stock = LowStock
|
||||||
|
}*/
|
||||||
|
articleType, _ := item.GetStringFieldValue(1) //.Fields[1].(string)
|
||||||
|
outletGrade, ok := item.GetStringFieldValue(20) //.Fields[20].(string)
|
||||||
|
var outlet *string
|
||||||
|
if ok {
|
||||||
|
outlet = &outletGrade
|
||||||
|
}
|
||||||
|
sellerId, _ := item.GetStringFieldValue(24) // .Fields[24].(string)
|
||||||
|
sellerName, _ := item.GetStringFieldValue(9) // .Fields[9].(string)
|
||||||
|
|
||||||
|
brand, _ := item.GetStringFieldValue(2) //.Fields[2].(string)
|
||||||
|
category, _ := item.GetStringFieldValue(10) //.Fields[10].(string)
|
||||||
|
category2, _ := item.GetStringFieldValue(11) //.Fields[11].(string)
|
||||||
|
category3, _ := item.GetStringFieldValue(12) //.Fields[12].(string)
|
||||||
|
category4, _ := item.GetStringFieldValue(13) //Fields[13].(string)
|
||||||
|
category5, _ := item.GetStringFieldValue(14) //.Fields[14].(string)
|
||||||
|
|
||||||
|
return &messages.AddItem{
|
||||||
|
ItemId: int64(item.Id),
|
||||||
|
Quantity: int32(qty),
|
||||||
|
Price: int64(price),
|
||||||
|
OrgPrice: int64(orgPrice),
|
||||||
|
Sku: sku,
|
||||||
|
Name: item.Title,
|
||||||
|
Image: item.Img,
|
||||||
|
Stock: int32(stock),
|
||||||
|
Brand: brand,
|
||||||
|
Category: category,
|
||||||
|
Category2: category2,
|
||||||
|
Category3: category3,
|
||||||
|
Category4: category4,
|
||||||
|
Category5: category5,
|
||||||
|
Tax: 2500,
|
||||||
|
SellerId: sellerId,
|
||||||
|
SellerName: sellerName,
|
||||||
|
ArticleType: articleType,
|
||||||
|
Disclaimer: item.Disclaimer,
|
||||||
|
Country: country,
|
||||||
|
Outlet: outlet,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) AddItem(sku string, qty int, country string, storeId *string) (*FrameWithPayload, error) {
|
||||||
|
cartItem, err := getItemData(sku, qty, country)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cartItem.StoreId = storeId
|
||||||
|
return c.HandleMessage(&Message{
|
||||||
|
Type: 2,
|
||||||
|
Content: cartItem,
|
||||||
|
}, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) GetStorageMessage(since int64) []StorableMessage {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
ret := make([]StorableMessage, 0)
|
||||||
|
|
||||||
|
for _, message := range c.storageMessages {
|
||||||
|
if *message.TimeStamp > since {
|
||||||
|
ret = append(ret, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) GetState() ([]byte, error) {
|
||||||
|
return json.Marshal(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) ItemsWithDelivery() []int {
|
||||||
|
ret := make([]int, 0, len(c.Items))
|
||||||
|
for _, item := range c.Items {
|
||||||
|
for _, delivery := range c.Deliveries {
|
||||||
|
for _, id := range delivery.Items {
|
||||||
|
if item.Id == id {
|
||||||
|
ret = append(ret, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) ItemsWithoutDelivery() []int {
|
||||||
|
ret := make([]int, 0, len(c.Items))
|
||||||
|
hasDelivery := c.ItemsWithDelivery()
|
||||||
|
for _, item := range c.Items {
|
||||||
|
found := false
|
||||||
|
for _, id := range hasDelivery {
|
||||||
|
if item.Id == id {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
ret = append(ret, item.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) FindItemWithSku(sku string) (*CartItem, bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
for _, item := range c.Items {
|
||||||
|
if item.Sku == sku {
|
||||||
|
return item, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTaxAmount(total int64, tax int) int64 {
|
||||||
|
taxD := 10000 / float64(tax)
|
||||||
|
return int64(float64(total) / float64((1 + taxD)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) HandleMessage(message *Message, isReplay bool) (*FrameWithPayload, error) {
|
||||||
|
if message.TimeStamp == nil {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
message.TimeStamp = &now
|
||||||
|
}
|
||||||
|
grainMutations.Inc()
|
||||||
|
var err error
|
||||||
|
switch message.Type {
|
||||||
|
case SetCartItemsType:
|
||||||
|
msg, ok := message.Content.(*messages.SetCartRequest)
|
||||||
|
if !ok {
|
||||||
|
err = fmt.Errorf("expected SetCartItems")
|
||||||
|
} else {
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.Items = make([]*CartItem, 0, len(msg.Items))
|
||||||
|
c.mu.Unlock()
|
||||||
|
for _, item := range msg.Items {
|
||||||
|
c.AddItem(item.Sku, int(item.Quantity), item.Country, item.StoreId)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
case AddRequestType:
|
||||||
|
msg, ok := message.Content.(*messages.AddRequest)
|
||||||
|
if !ok {
|
||||||
|
err = fmt.Errorf("expected AddRequest")
|
||||||
|
} else {
|
||||||
|
|
||||||
|
existingItem, found := c.FindItemWithSku(msg.Sku)
|
||||||
|
if found {
|
||||||
|
existingItem.Quantity += int(msg.Quantity)
|
||||||
|
c.UpdateTotals()
|
||||||
|
} else {
|
||||||
|
return c.AddItem(msg.Sku, int(msg.Quantity), msg.Country, msg.StoreId)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
case AddItemType:
|
||||||
|
msg, ok := message.Content.(*messages.AddItem)
|
||||||
|
if !ok {
|
||||||
|
err = fmt.Errorf("expected AddItem")
|
||||||
|
} else {
|
||||||
|
|
||||||
|
if msg.Quantity < 1 {
|
||||||
|
return nil, fmt.Errorf("invalid quantity")
|
||||||
|
}
|
||||||
|
existingItem, found := c.FindItemWithSku(msg.Sku)
|
||||||
|
if found {
|
||||||
|
existingItem.Quantity += int(msg.Quantity)
|
||||||
|
c.UpdateTotals()
|
||||||
|
} else {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.lastItemId++
|
||||||
|
tax := 2500
|
||||||
|
if msg.Tax > 0 {
|
||||||
|
tax = int(msg.Tax)
|
||||||
|
}
|
||||||
|
|
||||||
|
taxAmount := GetTaxAmount(msg.Price, tax)
|
||||||
|
|
||||||
|
c.Items = append(c.Items, &CartItem{
|
||||||
|
Id: c.lastItemId,
|
||||||
|
ItemId: int(msg.ItemId),
|
||||||
|
Quantity: int(msg.Quantity),
|
||||||
|
Sku: msg.Sku,
|
||||||
|
Name: msg.Name,
|
||||||
|
Price: msg.Price,
|
||||||
|
TotalPrice: msg.Price * int64(msg.Quantity),
|
||||||
|
TotalTax: int64(taxAmount * int64(msg.Quantity)),
|
||||||
|
Image: msg.Image,
|
||||||
|
Stock: StockStatus(msg.Stock),
|
||||||
|
Disclaimer: msg.Disclaimer,
|
||||||
|
Brand: msg.Brand,
|
||||||
|
Category: msg.Category,
|
||||||
|
Category2: msg.Category2,
|
||||||
|
Category3: msg.Category3,
|
||||||
|
Category4: msg.Category4,
|
||||||
|
Category5: msg.Category5,
|
||||||
|
OrgPrice: msg.OrgPrice,
|
||||||
|
ArticleType: msg.ArticleType,
|
||||||
|
Outlet: msg.Outlet,
|
||||||
|
SellerId: msg.SellerId,
|
||||||
|
SellerName: msg.SellerName,
|
||||||
|
Tax: int(taxAmount),
|
||||||
|
TaxRate: tax,
|
||||||
|
StoreId: msg.StoreId,
|
||||||
|
})
|
||||||
|
c.UpdateTotals()
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
case ChangeQuantityType:
|
||||||
|
msg, ok := message.Content.(*messages.ChangeQuantity)
|
||||||
|
if !ok {
|
||||||
|
err = fmt.Errorf("expected ChangeQuantity")
|
||||||
|
} else {
|
||||||
|
|
||||||
|
for i, item := range c.Items {
|
||||||
|
if item.Id == int(msg.Id) {
|
||||||
|
if msg.Quantity <= 0 {
|
||||||
|
//c.TotalPrice -= item.Price * int64(item.Quantity)
|
||||||
|
c.Items = append(c.Items[:i], c.Items[i+1:]...)
|
||||||
|
} else {
|
||||||
|
//diff := int(msg.Quantity) - item.Quantity
|
||||||
|
item.Quantity = int(msg.Quantity)
|
||||||
|
//c.TotalPrice += item.Price * int64(diff)
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.UpdateTotals()
|
||||||
|
|
||||||
|
}
|
||||||
|
case RemoveItemType:
|
||||||
|
msg, ok := message.Content.(*messages.RemoveItem)
|
||||||
|
if !ok {
|
||||||
|
err = fmt.Errorf("expected RemoveItem")
|
||||||
|
} else {
|
||||||
|
|
||||||
|
items := make([]*CartItem, 0, len(c.Items))
|
||||||
|
for _, item := range c.Items {
|
||||||
|
if item.Id == int(msg.Id) {
|
||||||
|
//c.TotalPrice -= item.Price * int64(item.Quantity)
|
||||||
|
} else {
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Items = items
|
||||||
|
c.UpdateTotals()
|
||||||
|
}
|
||||||
|
case SetDeliveryType:
|
||||||
|
msg, ok := message.Content.(*messages.SetDelivery)
|
||||||
|
if !ok {
|
||||||
|
err = fmt.Errorf("expected SetDelivery")
|
||||||
|
} else {
|
||||||
|
|
||||||
|
c.lastDeliveryId++
|
||||||
|
items := make([]int, 0)
|
||||||
|
withDelivery := c.ItemsWithDelivery()
|
||||||
|
if len(msg.Items) == 0 {
|
||||||
|
items = append(items, c.ItemsWithoutDelivery()...)
|
||||||
|
} else {
|
||||||
|
for _, id := range msg.Items {
|
||||||
|
for _, item := range c.Items {
|
||||||
|
if item.Id == int(id) {
|
||||||
|
if slices.Contains(withDelivery, item.Id) {
|
||||||
|
return nil, fmt.Errorf("item already has delivery")
|
||||||
|
}
|
||||||
|
items = append(items, int(item.Id))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(items) > 0 {
|
||||||
|
c.Deliveries = append(c.Deliveries, &CartDelivery{
|
||||||
|
Id: c.lastDeliveryId,
|
||||||
|
Provider: msg.Provider,
|
||||||
|
PickupPoint: msg.PickupPoint,
|
||||||
|
Price: 4900,
|
||||||
|
Items: items,
|
||||||
|
})
|
||||||
|
|
||||||
|
c.UpdateTotals()
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
case RemoveDeliveryType:
|
||||||
|
msg, ok := message.Content.(*messages.RemoveDelivery)
|
||||||
|
if !ok {
|
||||||
|
err = fmt.Errorf("expected RemoveDelivery")
|
||||||
|
} else {
|
||||||
|
|
||||||
|
deliveries := make([]*CartDelivery, 0, len(c.Deliveries))
|
||||||
|
for _, delivery := range c.Deliveries {
|
||||||
|
if delivery.Id == int(msg.Id) {
|
||||||
|
c.TotalPrice -= delivery.Price
|
||||||
|
} else {
|
||||||
|
deliveries = append(deliveries, delivery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Deliveries = deliveries
|
||||||
|
c.UpdateTotals()
|
||||||
|
}
|
||||||
|
case SetPickupPointType:
|
||||||
|
msg, ok := message.Content.(*messages.SetPickupPoint)
|
||||||
|
if !ok {
|
||||||
|
err = fmt.Errorf("expected SetPickupPoint")
|
||||||
|
} else {
|
||||||
|
|
||||||
|
for _, delivery := range c.Deliveries {
|
||||||
|
if delivery.Id == int(msg.DeliveryId) {
|
||||||
|
delivery.PickupPoint = &messages.PickupPoint{
|
||||||
|
Id: msg.Id,
|
||||||
|
Address: msg.Address,
|
||||||
|
City: msg.City,
|
||||||
|
Zip: msg.Zip,
|
||||||
|
Country: msg.Country,
|
||||||
|
Name: msg.Name,
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
case CreateCheckoutOrderType:
|
||||||
|
msg, ok := message.Content.(*messages.CreateCheckoutOrder)
|
||||||
|
if !ok {
|
||||||
|
err = fmt.Errorf("expected CreateCheckoutOrder")
|
||||||
|
} else {
|
||||||
|
|
||||||
|
orderLines := make([]*Line, 0, len(c.Items))
|
||||||
|
|
||||||
|
c.PaymentInProgress = true
|
||||||
|
c.Processing = true
|
||||||
|
for _, item := range c.Items {
|
||||||
|
|
||||||
|
orderLines = append(orderLines, &Line{
|
||||||
|
Type: "physical",
|
||||||
|
Reference: item.Sku,
|
||||||
|
Name: item.Name,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: int(item.Price),
|
||||||
|
TaxRate: 2500, // item.TaxRate,
|
||||||
|
QuantityUnit: "st",
|
||||||
|
TotalAmount: int(item.TotalPrice),
|
||||||
|
TotalTaxAmount: int(item.TotalTax),
|
||||||
|
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", item.Image),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, line := range c.Deliveries {
|
||||||
|
if line.Price > 0 {
|
||||||
|
orderLines = append(orderLines, &Line{
|
||||||
|
Type: "shipping_fee",
|
||||||
|
Reference: line.Provider,
|
||||||
|
Name: "Delivery",
|
||||||
|
Quantity: 1,
|
||||||
|
UnitPrice: int(line.Price),
|
||||||
|
TaxRate: 2500, // item.TaxRate,
|
||||||
|
QuantityUnit: "st",
|
||||||
|
TotalAmount: int(line.Price),
|
||||||
|
TotalTaxAmount: int(GetTaxAmount(line.Price, 2500)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
order := CheckoutOrder{
|
||||||
|
PurchaseCountry: "SE",
|
||||||
|
PurchaseCurrency: "SEK",
|
||||||
|
Locale: "sv-se",
|
||||||
|
OrderAmount: int(c.TotalPrice),
|
||||||
|
OrderTaxAmount: int(c.TotalTax),
|
||||||
|
OrderLines: orderLines,
|
||||||
|
MerchantReference1: c.Id.String(),
|
||||||
|
MerchantURLS: &CheckoutMerchantURLS{
|
||||||
|
Terms: msg.Terms,
|
||||||
|
Checkout: msg.Checkout,
|
||||||
|
Confirmation: msg.Confirmation,
|
||||||
|
Validation: msg.Validation,
|
||||||
|
Push: msg.Push,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
orderPayload, err := json.Marshal(order)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var klarnaOrder *CheckoutOrder
|
||||||
|
if c.OrderReference != "" {
|
||||||
|
log.Printf("Updating order id %s", c.OrderReference)
|
||||||
|
klarnaOrder, err = KlarnaInstance.UpdateOrder(c.OrderReference, bytes.NewReader(orderPayload))
|
||||||
|
} else {
|
||||||
|
klarnaOrder, err = KlarnaInstance.CreateOrder(bytes.NewReader(orderPayload))
|
||||||
|
}
|
||||||
|
// log.Printf("Order result: %+v", klarnaOrder)
|
||||||
|
if nil != err {
|
||||||
|
log.Printf("error from klarna: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c.OrderReference == "" {
|
||||||
|
c.OrderReference = klarnaOrder.ID
|
||||||
|
c.PaymentStatus = klarnaOrder.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
orderData, err := json.Marshal(klarnaOrder)
|
||||||
|
if nil != err {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := MakeFrameWithPayload(RemoteCreateOrderReply, 200, orderData)
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
case OrderCompletedType:
|
||||||
|
msg, ok := message.Content.(*messages.OrderCreated)
|
||||||
|
if !ok {
|
||||||
|
log.Printf("expected OrderCompleted, got %T", message.Content)
|
||||||
|
err = fmt.Errorf("expected OrderCompleted")
|
||||||
|
} else {
|
||||||
|
c.OrderReference = msg.OrderId
|
||||||
|
c.PaymentStatus = msg.Status
|
||||||
|
c.PaymentInProgress = false
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("unknown message type %d", message.Type)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isReplay {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.storageMessages = append(c.storageMessages, *message)
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
result, err := json.Marshal(c)
|
||||||
|
msg := MakeFrameWithPayload(RemoteHandleMutationReply, 200, result)
|
||||||
|
return &msg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) UpdateTotals() {
|
||||||
|
c.TotalPrice = 0
|
||||||
|
c.TotalTax = 0
|
||||||
|
c.TotalDiscount = 0
|
||||||
|
for _, item := range c.Items {
|
||||||
|
rowTotal := item.Price * int64(item.Quantity)
|
||||||
|
rowTax := int64(item.Tax) * int64(item.Quantity)
|
||||||
|
item.TotalPrice = rowTotal
|
||||||
|
item.TotalTax = rowTax
|
||||||
|
c.TotalPrice += rowTotal
|
||||||
|
c.TotalTax += rowTax
|
||||||
|
itemDiff := max(0, item.OrgPrice-item.Price)
|
||||||
|
c.TotalDiscount += itemDiff * int64(item.Quantity)
|
||||||
|
}
|
||||||
|
for _, delivery := range c.Deliveries {
|
||||||
|
c.TotalPrice += delivery.Price
|
||||||
|
c.TotalTax += GetTaxAmount(delivery.Price, 2500)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/actor"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
|
||||||
"github.com/gogo/protobuf/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
type FileServer struct {
|
|
||||||
// Define fields here
|
|
||||||
dataDir string
|
|
||||||
storage actor.LogStorage[cart.CartGrain]
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewFileServer(dataDir string, storage actor.LogStorage[cart.CartGrain]) *FileServer {
|
|
||||||
return &FileServer{
|
|
||||||
dataDir: dataDir,
|
|
||||||
storage: storage,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isValidId(id string) (uint64, bool) {
|
|
||||||
if nr, err := strconv.ParseUint(id, 10, 64); err == nil {
|
|
||||||
return nr, true
|
|
||||||
}
|
|
||||||
if nr, ok := cart.ParseCartId(id); ok {
|
|
||||||
return uint64(nr), true
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func isValidFileId(name string) (uint64, bool) {
|
|
||||||
|
|
||||||
parts := strings.Split(name, ".")
|
|
||||||
if len(parts) > 1 && parts[1] == "events" {
|
|
||||||
idStr := parts[0]
|
|
||||||
|
|
||||||
return isValidId(idStr)
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// func AccessTime(info os.FileInfo) (time.Time, bool) {
|
|
||||||
// switch stat := info.Sys().(type) {
|
|
||||||
// case *syscall.Stat_t:
|
|
||||||
// // Linux: Atim; macOS/BSD: Atimespec
|
|
||||||
// // Use reflection or build tags if naming differs.
|
|
||||||
// // Linux:
|
|
||||||
// if stat.Atim.Sec != 0 || stat.Atim.Nsec != 0 {
|
|
||||||
// return time.Unix(int64(stat.Atim.Sec), int64(stat.Atim.Nsec)), true
|
|
||||||
// }
|
|
||||||
// // macOS/BSD example (uncomment if needed):
|
|
||||||
// //return time.Unix(int64(stat.Atimespec.Sec), int64(stat.Atimespec.Nsec)), true
|
|
||||||
// }
|
|
||||||
// return time.Time{}, false
|
|
||||||
// }
|
|
||||||
|
|
||||||
func appendFileInfo(info fs.FileInfo, out *CartFileInfo) *CartFileInfo {
|
|
||||||
//sys := info.Sys()
|
|
||||||
//fmt.Printf("sys type %T", sys)
|
|
||||||
out.Size = info.Size()
|
|
||||||
out.Modified = info.ModTime()
|
|
||||||
//out.Accessed, _ = AccessTime(info)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// var cartFileRe = regexp.MustCompile(`^(\d+)\.events\.log$`)
|
|
||||||
|
|
||||||
func listCartFiles(dir string) ([]*CartFileInfo, error) {
|
|
||||||
entries, err := os.ReadDir(dir)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return []*CartFileInfo{}, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out := make([]*CartFileInfo, 0)
|
|
||||||
for _, e := range entries {
|
|
||||||
if e.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
id, valid := isValidFileId(e.Name())
|
|
||||||
if !valid {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := e.Info()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
info.Sys()
|
|
||||||
out = append(out, appendFileInfo(info, &CartFileInfo{
|
|
||||||
ID: fmt.Sprintf("%d", id),
|
|
||||||
CartId: cart.CartId(id),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readRawLogLines(path string) ([]json.RawMessage, error) {
|
|
||||||
fh, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer fh.Close()
|
|
||||||
lines := make([]json.RawMessage, 0, 64)
|
|
||||||
s := bufio.NewScanner(fh)
|
|
||||||
// increase buffer to handle larger JSON lines
|
|
||||||
buf := make([]byte, 0, 1024*1024)
|
|
||||||
s.Buffer(buf, 1024*1024)
|
|
||||||
for s.Scan() {
|
|
||||||
line := s.Bytes()
|
|
||||||
if line == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lines = append(lines, line)
|
|
||||||
}
|
|
||||||
if err := s.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return lines, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fs *FileServer) CartsHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
list, err := listCartFiles(fs.dataDir)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// sort by modified desc
|
|
||||||
sort.Slice(list, func(i, j int) bool { return list[i].Modified.After(list[j].Modified) })
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"count": len(list),
|
|
||||||
"carts": list,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fs *FileServer) PromotionsHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
fileName := filepath.Join(fs.dataDir, "promotions.json")
|
|
||||||
if r.Method == http.MethodGet {
|
|
||||||
file, err := os.Open(fileName)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
io.Copy(w, file)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Method == http.MethodPost {
|
|
||||||
file, err := os.Create(fileName)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
io.Copy(file, r.Body)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fs *FileServer) VoucherHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
fileName := filepath.Join(fs.dataDir, "vouchers.json")
|
|
||||||
if r.Method == http.MethodGet {
|
|
||||||
file, err := os.Open(fileName)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
io.Copy(w, file)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Method == http.MethodPost {
|
|
||||||
file, err := os.Create(fileName)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
io.Copy(file, r.Body)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fs *FileServer) PromotionPartHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
idStr := r.PathValue("id")
|
|
||||||
if idStr == "" {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
fmt.Fprintf(w, "missing id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, ok := isValidId(idStr)
|
|
||||||
if !ok {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
fmt.Fprintf(w, "invalid id %s", idStr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusNotImplemented)
|
|
||||||
}
|
|
||||||
|
|
||||||
type JsonError struct {
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func acceptAll(_ proto.Message, _ int, _ time.Time) bool {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func acceptUntilIndex(maxIndex int) func(msg proto.Message, index int, when time.Time) bool {
|
|
||||||
return func(msg proto.Message, index int, when time.Time) bool {
|
|
||||||
return index < maxIndex
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func acceptUntilTimestamp(until time.Time) func(msg proto.Message, index int, when time.Time) bool {
|
|
||||||
return func(msg proto.Message, index int, when time.Time) bool {
|
|
||||||
return when.Before(until)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fs *FileServer) CartHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
idStr := r.PathValue("id")
|
|
||||||
if idStr == "" {
|
|
||||||
writeJSON(w, http.StatusBadRequest, JsonError{Error: "missing id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id, ok := isValidId(idStr)
|
|
||||||
if !ok {
|
|
||||||
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// parse query parameters for filtering
|
|
||||||
query := r.URL.Query()
|
|
||||||
filterFunction := acceptAll
|
|
||||||
if maxIndexStr := query.Get("maxIndex"); maxIndexStr != "" {
|
|
||||||
log.Printf("filter maxIndex: %s", maxIndexStr)
|
|
||||||
maxIndex, err := strconv.Atoi(maxIndexStr)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid maxIndex"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
filterFunction = acceptUntilIndex(maxIndex)
|
|
||||||
} else if untilStr := query.Get("until"); untilStr != "" {
|
|
||||||
log.Printf("filter until: %s", untilStr)
|
|
||||||
until, err := time.Parse(time.RFC3339, untilStr)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid until timestamp"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
filterFunction = acceptUntilTimestamp(until)
|
|
||||||
}
|
|
||||||
// reconstruct state from event log if present
|
|
||||||
grain := cart.NewCartGrain(id, time.Now())
|
|
||||||
err := fs.storage.LoadEventsFunc(r.Context(), id, grain, filterFunction)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
path := filepath.Join(fs.dataDir, fmt.Sprintf("%d.events.log", id))
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil && errors.Is(err, os.ErrNotExist) {
|
|
||||||
writeJSON(w, http.StatusNotFound, JsonError{Error: "cart not found"})
|
|
||||||
return
|
|
||||||
} else if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lines, err := readRawLogLines(path)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"id": id,
|
|
||||||
"cartId": cart.CartId(id).String(),
|
|
||||||
"state": grain,
|
|
||||||
"mutations": lines,
|
|
||||||
"meta": map[string]any{
|
|
||||||
"size": info.Size(),
|
|
||||||
"modified": info.ModTime(),
|
|
||||||
"path": path,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math/rand"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestAppendFileInfoRandomProjectFile picks a random existing .go source file in the
|
|
||||||
// repository (from a small curated list to keep the test hermetic) and verifies
|
|
||||||
// that appendFileInfo populates Size, Modified and System without mutating the
|
|
||||||
// identity fields (ID, CartId). The randomness is only to satisfy the requirement
|
|
||||||
// of using "a random project file"; the test behavior is deterministic enough for
|
|
||||||
// CI because all chosen files are expected to exist.
|
|
||||||
func TestAppendFileInfoRandomProjectFile(t *testing.T) {
|
|
||||||
candidates := []string{
|
|
||||||
filepath.FromSlash("../../pkg/cart/cart_id.go"),
|
|
||||||
filepath.FromSlash("../../pkg/actor/grain.go"),
|
|
||||||
filepath.FromSlash("../../cmd/cart/main.go"),
|
|
||||||
}
|
|
||||||
// Pick one at random.
|
|
||||||
rand.Seed(time.Now().UnixNano())
|
|
||||||
path := candidates[rand.Intn(len(candidates))]
|
|
||||||
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("stat failed for %s: %v", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-populate a CartFileInfo with identity fields.
|
|
||||||
origID := "test-id"
|
|
||||||
origCartId := cart.CartId(12345)
|
|
||||||
cf := &CartFileInfo{ID: origID, CartId: origCartId}
|
|
||||||
|
|
||||||
// Call function under test.
|
|
||||||
got := appendFileInfo(info, cf)
|
|
||||||
|
|
||||||
if got != cf {
|
|
||||||
t.Fatalf("appendFileInfo should return the same pointer instance")
|
|
||||||
}
|
|
||||||
|
|
||||||
if cf.ID != origID {
|
|
||||||
t.Fatalf("ID mutated: expected %q got %q", origID, cf.ID)
|
|
||||||
}
|
|
||||||
if cf.CartId != origCartId {
|
|
||||||
t.Fatalf("CartId mutated: expected %v got %v", origCartId, cf.CartId)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cf.Size != info.Size() {
|
|
||||||
t.Fatalf("Size mismatch: expected %d got %d", info.Size(), cf.Size)
|
|
||||||
}
|
|
||||||
|
|
||||||
mod := info.ModTime()
|
|
||||||
// Allow small clock skew / coarse timestamp truncation.
|
|
||||||
if cf.Modified.Before(mod.Add(-2*time.Second)) || cf.Modified.After(mod.Add(2*time.Second)) {
|
|
||||||
t.Fatalf("Modified not within expected range: want ~%v got %v", mod, cf.Modified)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAppendFileInfoTempFile creates a temporary file to ensure Size and Modified
|
|
||||||
// are updated for a freshly written file with known content length.
|
|
||||||
func TestAppendFileInfoTempFile(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "temp.events.log")
|
|
||||||
content := []byte("hello world\nanother line\n")
|
|
||||||
if err := os.WriteFile(path, content, 0644); err != nil {
|
|
||||||
t.Fatalf("write temp file failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("stat temp file failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cf := &CartFileInfo{ID: "temp", CartId: cart.CartId(0)}
|
|
||||||
appendFileInfo(info, cf)
|
|
||||||
|
|
||||||
if cf.Size != int64(len(content)) {
|
|
||||||
t.Fatalf("expected Size %d got %d", len(content), cf.Size)
|
|
||||||
}
|
|
||||||
if cf.Modified.IsZero() {
|
|
||||||
t.Fatalf("Modified should be set")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"crypto/sha1"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/binary"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Hub manages websocket clients and broadcasts messages to them.
|
|
||||||
type Hub struct {
|
|
||||||
register chan *Client
|
|
||||||
unregister chan *Client
|
|
||||||
broadcast chan []byte
|
|
||||||
clients map[*Client]bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client represents a single websocket client connection.
|
|
||||||
type Client struct {
|
|
||||||
hub *Hub
|
|
||||||
conn net.Conn
|
|
||||||
send chan []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewHub constructs a new Hub instance.
|
|
||||||
func NewHub() *Hub {
|
|
||||||
return &Hub{
|
|
||||||
register: make(chan *Client),
|
|
||||||
unregister: make(chan *Client),
|
|
||||||
broadcast: make(chan []byte, 1024),
|
|
||||||
clients: make(map[*Client]bool),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run starts the hub event loop.
|
|
||||||
func (h *Hub) Run() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case c := <-h.register:
|
|
||||||
h.clients[c] = true
|
|
||||||
case c := <-h.unregister:
|
|
||||||
if _, ok := h.clients[c]; ok {
|
|
||||||
delete(h.clients, c)
|
|
||||||
close(c.send)
|
|
||||||
_ = c.conn.Close()
|
|
||||||
}
|
|
||||||
case msg := <-h.broadcast:
|
|
||||||
for c := range h.clients {
|
|
||||||
select {
|
|
||||||
case c.send <- msg:
|
|
||||||
default:
|
|
||||||
// Client is slow or dead; drop it.
|
|
||||||
delete(h.clients, c)
|
|
||||||
close(c.send)
|
|
||||||
_ = c.conn.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// computeAccept computes the Sec-WebSocket-Accept header value.
|
|
||||||
func computeAccept(key string) string {
|
|
||||||
const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
||||||
h := sha1.New()
|
|
||||||
h.Write([]byte(key + magic))
|
|
||||||
return base64.StdEncoding.EncodeToString(h.Sum(nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeWS upgrades the HTTP request to a WebSocket connection and registers a client.
|
|
||||||
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") || strings.ToLower(r.Header.Get("Upgrade")) != "websocket" {
|
|
||||||
http.Error(w, "upgrade required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
key := r.Header.Get("Sec-WebSocket-Key")
|
|
||||||
if key == "" {
|
|
||||||
http.Error(w, "missing Sec-WebSocket-Key", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
accept := computeAccept(key)
|
|
||||||
|
|
||||||
hj, ok := w.(http.Hijacker)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "websocket not supported", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
conn, buf, err := hj.Hijack()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write the upgrade response
|
|
||||||
response := "HTTP/1.1 101 Switching Protocols\r\n" +
|
|
||||||
"Upgrade: websocket\r\n" +
|
|
||||||
"Connection: Upgrade\r\n" +
|
|
||||||
"Sec-WebSocket-Accept: " + accept + "\r\n" +
|
|
||||||
"\r\n"
|
|
||||||
if _, err := buf.WriteString(response); err != nil {
|
|
||||||
_ = conn.Close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := buf.Flush(); err != nil {
|
|
||||||
_ = conn.Close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &Client{
|
|
||||||
hub: h,
|
|
||||||
conn: conn,
|
|
||||||
send: make(chan []byte, 256),
|
|
||||||
}
|
|
||||||
h.register <- client
|
|
||||||
go client.writePump()
|
|
||||||
go client.readPump()
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeWSFrame writes a single WebSocket frame to the writer.
|
|
||||||
func writeWSFrame(w io.Writer, opcode byte, payload []byte) error {
|
|
||||||
// FIN set, opcode as provided
|
|
||||||
header := []byte{0x80 | (opcode & 0x0F)}
|
|
||||||
l := len(payload)
|
|
||||||
switch {
|
|
||||||
case l < 126:
|
|
||||||
header = append(header, byte(l))
|
|
||||||
case l <= 65535:
|
|
||||||
ext := make([]byte, 2)
|
|
||||||
binary.BigEndian.PutUint16(ext, uint16(l))
|
|
||||||
header = append(header, 126)
|
|
||||||
header = append(header, ext...)
|
|
||||||
default:
|
|
||||||
ext := make([]byte, 8)
|
|
||||||
binary.BigEndian.PutUint64(ext, uint64(l))
|
|
||||||
header = append(header, 127)
|
|
||||||
header = append(header, ext...)
|
|
||||||
}
|
|
||||||
if _, err := w.Write(header); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if l > 0 {
|
|
||||||
if _, err := w.Write(payload); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// readPump handles control frames from the client and discards other incoming frames.
|
|
||||||
// This server is broadcast-only to clients.
|
|
||||||
func (c *Client) readPump() {
|
|
||||||
defer func() {
|
|
||||||
c.hub.unregister <- c
|
|
||||||
}()
|
|
||||||
reader := bufio.NewReader(c.conn)
|
|
||||||
for {
|
|
||||||
// Read first two bytes
|
|
||||||
b1, err := reader.ReadByte()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
b2, err := reader.ReadByte()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
opcode := b1 & 0x0F
|
|
||||||
masked := (b2 & 0x80) != 0
|
|
||||||
length := int64(b2 & 0x7F)
|
|
||||||
if length == 126 {
|
|
||||||
ext := make([]byte, 2)
|
|
||||||
if _, err := io.ReadFull(reader, ext); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
length = int64(binary.BigEndian.Uint16(ext))
|
|
||||||
} else if length == 127 {
|
|
||||||
ext := make([]byte, 8)
|
|
||||||
if _, err := io.ReadFull(reader, ext); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
length = int64(binary.BigEndian.Uint64(ext))
|
|
||||||
}
|
|
||||||
var maskKey [4]byte
|
|
||||||
if masked {
|
|
||||||
if _, err := io.ReadFull(reader, maskKey[:]); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle Ping -> Pong
|
|
||||||
if opcode == 0x9 && length <= 125 {
|
|
||||||
payload := make([]byte, length)
|
|
||||||
if _, err := io.ReadFull(reader, payload); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Unmask if masked
|
|
||||||
if masked {
|
|
||||||
for i := int64(0); i < length; i++ {
|
|
||||||
payload[i] ^= maskKey[i%4]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = writeWSFrame(c.conn, 0xA, payload) // best-effort pong
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close frame
|
|
||||||
if opcode == 0x8 {
|
|
||||||
// Drain payload if any, then exit
|
|
||||||
if _, err := io.CopyN(io.Discard, reader, length); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// For other frames, just discard payload
|
|
||||||
if _, err := io.CopyN(io.Discard, reader, length); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// writePump sends queued messages to the client and pings periodically to keep the connection alive.
|
|
||||||
func (c *Client) writePump() {
|
|
||||||
ticker := time.NewTicker(30 * time.Second)
|
|
||||||
defer func() {
|
|
||||||
ticker.Stop()
|
|
||||||
_ = c.conn.Close()
|
|
||||||
}()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case msg, ok := <-c.send:
|
|
||||||
if !ok {
|
|
||||||
// try to send close frame
|
|
||||||
_ = writeWSFrame(c.conn, 0x8, nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := writeWSFrame(c.conn, 0x1, msg); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case <-ticker.C:
|
|
||||||
// Send a ping to keep connections alive behind proxies
|
|
||||||
_ = writeWSFrame(c.conn, 0x9, []byte("ping"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
actor "git.k6n.net/go-cart-actor/pkg/actor"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
|
||||||
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
|
||||||
"github.com/matst80/slask-finder/pkg/messaging"
|
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CartFileInfo struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
CartId cart.CartId `json:"cartId"`
|
|
||||||
Size int64 `json:"size"`
|
|
||||||
Modified time.Time `json:"modified"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func envOrDefault(key, def string) string {
|
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
|
|
||||||
func startMutationConsumer(ctx context.Context, conn *amqp.Connection, hub *Hub) error {
|
|
||||||
ch, err := conn.Channel()
|
|
||||||
if err != nil {
|
|
||||||
_ = conn.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
msgs, err := messaging.DeclareBindAndConsume(ch, "cart", "mutation")
|
|
||||||
if err != nil {
|
|
||||||
_ = ch.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer ch.Close()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case m, ok := <-msgs:
|
|
||||||
if !ok {
|
|
||||||
log.Fatalf("connection closed")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Log and broadcast to all websocket clients
|
|
||||||
log.Printf("mutation event: %s", string(m.Body))
|
|
||||||
|
|
||||||
if hub != nil {
|
|
||||||
select {
|
|
||||||
case hub.broadcast <- m.Body:
|
|
||||||
default:
|
|
||||||
// if hub queue is full, drop to avoid blocking
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := m.Ack(false); err != nil {
|
|
||||||
log.Printf("error acknowledging message: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var redisAddress = os.Getenv("REDIS_ADDRESS")
|
|
||||||
var redisPassword = os.Getenv("REDIS_PASSWORD")
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
dataDir := envOrDefault("DATA_DIR", "data")
|
|
||||||
addr := envOrDefault("ADDR", ":8080")
|
|
||||||
amqpURL := os.Getenv("AMQP_URL")
|
|
||||||
|
|
||||||
rdb := redis.NewClient(&redis.Options{
|
|
||||||
Addr: redisAddress,
|
|
||||||
Password: redisPassword,
|
|
||||||
DB: 0,
|
|
||||||
})
|
|
||||||
inventoryService, err := inventory.NewRedisInventoryService(rdb)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error creating inventory service: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = os.MkdirAll(dataDir, 0755)
|
|
||||||
|
|
||||||
reg := cart.NewCartMultationRegistry()
|
|
||||||
diskStorage := actor.NewDiskStorage[cart.CartGrain](dataDir, reg)
|
|
||||||
|
|
||||||
fs := NewFileServer(dataDir, diskStorage)
|
|
||||||
|
|
||||||
hub := NewHub()
|
|
||||||
go hub.Run()
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("GET /carts", fs.CartsHandler)
|
|
||||||
mux.HandleFunc("GET /cart/{id}", fs.CartHandler)
|
|
||||||
mux.HandleFunc("PUT /inventory/{locationId}/{sku}", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
inventoryLocationId := inventory.LocationID(r.PathValue("locationId"))
|
|
||||||
inventorySku := inventory.SKU(r.PathValue("sku"))
|
|
||||||
pipe := rdb.Pipeline()
|
|
||||||
var payload struct {
|
|
||||||
Quantity int64 `json:"quantity"`
|
|
||||||
}
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&payload)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
inventoryService.UpdateInventory(r.Context(), pipe, inventorySku, inventoryLocationId, payload.Quantity)
|
|
||||||
|
|
||||||
_, err = pipe.Exec(r.Context())
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "failed to update inventory", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = inventoryService.SendInventoryChanged(r.Context(), inventorySku, inventoryLocationId)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/promotions", fs.PromotionsHandler)
|
|
||||||
mux.HandleFunc("/vouchers", fs.VoucherHandler)
|
|
||||||
mux.HandleFunc("/promotion/{id}", fs.PromotionPartHandler)
|
|
||||||
|
|
||||||
mux.HandleFunc("/ws", hub.ServeWS)
|
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Global CORS middleware allowing all origins and handling preflight
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
|
||||||
w.Header().Set("Access-Control-Expose-Headers", "*")
|
|
||||||
if r.Method == http.MethodOptions {
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mux.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
|
|
||||||
srv := &http.Server{
|
|
||||||
Addr: addr,
|
|
||||||
Handler: handler,
|
|
||||||
ReadTimeout: 15 * time.Second,
|
|
||||||
WriteTimeout: 30 * time.Second,
|
|
||||||
IdleTimeout: 60 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
if amqpURL != "" {
|
|
||||||
conn, err := amqp.Dial(amqpURL)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("failed to connect to RabbitMQ: %v", err)
|
|
||||||
}
|
|
||||||
if err := startMutationConsumer(ctx, conn, hub); err != nil {
|
|
||||||
log.Printf("AMQP listener disabled: %v", err)
|
|
||||||
} else {
|
|
||||||
log.Printf("AMQP listener connected")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("backoffice HTTP listening on %s (dataDir=%s)", addr, dataDir)
|
|
||||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
||||||
log.Fatalf("http server error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// server stopped
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AmqpOrderHandler struct {
|
|
||||||
conn *amqp.Connection
|
|
||||||
queue *amqp.Queue
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAmqpOrderHandler(conn *amqp.Connection) *AmqpOrderHandler {
|
|
||||||
return &AmqpOrderHandler{
|
|
||||||
conn: conn,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AmqpOrderHandler) DefineQueue() error {
|
|
||||||
ch, err := h.conn.Channel()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to open a channel: %w", err)
|
|
||||||
}
|
|
||||||
defer ch.Close()
|
|
||||||
|
|
||||||
queue, err := ch.QueueDeclare(
|
|
||||||
"order-queue", // name
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to declare an exchange: %w", err)
|
|
||||||
}
|
|
||||||
h.queue = &queue
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AmqpOrderHandler) OrderCompleted(body []byte) error {
|
|
||||||
ch, err := h.conn.Channel()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to open a channel: %w", err)
|
|
||||||
}
|
|
||||||
defer ch.Close()
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
return ch.PublishWithContext(ctx,
|
|
||||||
"", // exchange
|
|
||||||
h.queue.Name, // routing key
|
|
||||||
false, // mandatory
|
|
||||||
false, // immediate
|
|
||||||
amqp.Publishing{
|
|
||||||
//DeliveryMode: amqp.,
|
|
||||||
ContentType: "application/json",
|
|
||||||
Body: body,
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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
|
|
||||||
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 *cart.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: it.Tax, // 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,
|
|
||||||
Notification: "https://cart.k6n.net/notification",
|
|
||||||
Validation: "https://cart.k6n.net/validate",
|
|
||||||
Push: "https://cart.k6n.net/push?order_id={checkout.order.id}",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := json.Marshal(order)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("marshal checkout order: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload, order, nil
|
|
||||||
}
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/actor"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/messages"
|
|
||||||
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
|
||||||
)
|
|
||||||
|
|
||||||
var tpl = `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>s10r testing - checkout</title>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
%s
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`
|
|
||||||
|
|
||||||
func (a *App) getGrainFromOrder(ctx context.Context, order *CheckoutOrder) (*cart.CartGrain, error) {
|
|
||||||
cartId, ok := cart.ParseCartId(order.MerchantReference1)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
|
|
||||||
}
|
|
||||||
grain, err := a.pool.Get(ctx, uint64(cartId))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get cart grain: %w", err)
|
|
||||||
}
|
|
||||||
return grain, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) HandleCheckoutRequests(amqpUrl string, mux *http.ServeMux, inventoryService inventory.InventoryService) {
|
|
||||||
conn, err := amqp.Dial(amqpUrl)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("failed to connect to RabbitMQ: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
amqpListener := actor.NewAmqpListener(conn, func(id uint64, msg []actor.ApplyResult) (any, error) {
|
|
||||||
return &CartChangeEvent{
|
|
||||||
CartId: cart.CartId(id),
|
|
||||||
Mutations: msg,
|
|
||||||
}, nil
|
|
||||||
})
|
|
||||||
amqpListener.DefineTopics()
|
|
||||||
a.pool.AddListener(amqpListener)
|
|
||||||
orderHandler := NewAmqpOrderHandler(conn)
|
|
||||||
orderHandler.DefineQueue()
|
|
||||||
|
|
||||||
mux.HandleFunc("/push", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
log.Printf("Klarna order confirmation push, method: %s", r.Method)
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
orderId := r.URL.Query().Get("order_id")
|
|
||||||
log.Printf("Order confirmation push: %s", orderId)
|
|
||||||
|
|
||||||
order, err := a.klarnaClient.GetOrder(r.Context(), orderId)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error creating request: %v\n", err)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
grain, err := a.getGrainFromOrder(r.Context(), order)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorContext(r.Context(), "Unable to get grain from klarna order", "error", err.Error())
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if inventoryService != nil {
|
|
||||||
inventoryRequests := getInventoryRequests(grain.Items)
|
|
||||||
err = inventoryService.ReserveInventory(r.Context(), inventoryRequests...)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
logger.WarnContext(r.Context(), "placeorder inventory reservation failed")
|
|
||||||
w.WriteHeader(http.StatusNotAcceptable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
a.pool.Apply(r.Context(), uint64(grain.Id), &messages.InventoryReserved{
|
|
||||||
Id: grain.Id.String(),
|
|
||||||
Status: "success",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
err = confirmOrder(r.Context(), order, orderHandler)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error confirming order: %v\n", err)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = triggerOrderCompleted(r.Context(), a.server, order)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error processing cart message: %v\n", err)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = a.klarnaClient.AcknowledgeOrder(r.Context(), orderId)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error acknowledging order: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
mux.HandleFunc("GET /checkout", a.server.CheckoutHandler(func(order *CheckoutOrder, w http.ResponseWriter) error {
|
|
||||||
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)
|
|
||||||
_, err := fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
|
||||||
return err
|
|
||||||
}))
|
|
||||||
|
|
||||||
mux.HandleFunc("GET /confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
|
|
||||||
orderId := r.PathValue("order_id")
|
|
||||||
order, err := a.klarnaClient.GetOrder(r.Context(), orderId)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply ConfirmationViewed mutation
|
|
||||||
cartId, ok := cart.ParseCartId(order.MerchantReference1)
|
|
||||||
if ok {
|
|
||||||
a.pool.Apply(r.Context(), uint64(cartId), &messages.ConfirmationViewed{})
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
if order.Status == "checkout_complete" {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "cartid",
|
|
||||||
Value: "",
|
|
||||||
Path: "/",
|
|
||||||
Secure: true,
|
|
||||||
HttpOnly: true,
|
|
||||||
Expires: time.Unix(0, 0),
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/notification", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
log.Printf("Klarna order notification, method: %s", r.Method)
|
|
||||||
logger.InfoContext(r.Context(), "Klarna order notification received", "method", r.Method)
|
|
||||||
if r.Method != "POST" {
|
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
order := &CheckoutOrder{}
|
|
||||||
err := json.NewDecoder(r.Body).Decode(order)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
log.Printf("Klarna order notification: %s", order.ID)
|
|
||||||
logger.InfoContext(r.Context(), "Klarna order notification received", "order_id", order.ID)
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
mux.HandleFunc("POST /validate", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
log.Printf("Klarna order validation, method: %s", r.Method)
|
|
||||||
if r.Method != "POST" {
|
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
order := &CheckoutOrder{}
|
|
||||||
err := json.NewDecoder(r.Body).Decode(order)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
logger.InfoContext(r.Context(), "Klarna order validation received", "order_id", order.ID, "cart_id", order.MerchantReference1)
|
|
||||||
grain, err := a.getGrainFromOrder(r.Context(), order)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorContext(r.Context(), "Unable to get grain from klarna order", "error", err.Error())
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if inventoryService != nil {
|
|
||||||
inventoryRequests := getInventoryRequests(grain.Items)
|
|
||||||
_, err = inventoryService.ReservationCheck(r.Context(), inventoryRequests...)
|
|
||||||
if err != nil {
|
|
||||||
logger.WarnContext(r.Context(), "placeorder inventory check failed")
|
|
||||||
w.WriteHeader(http.StatusNotAcceptable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/actor"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/discovery"
|
|
||||||
"k8s.io/client-go/kubernetes"
|
|
||||||
"k8s.io/client-go/rest"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetDiscovery() discovery.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 discovery.NewK8sDiscovery(client)
|
|
||||||
}
|
|
||||||
|
|
||||||
func UseDiscovery(pool actor.GrainPool[*cart.CartGrain]) {
|
|
||||||
|
|
||||||
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.IsReady {
|
|
||||||
case false:
|
|
||||||
if pool.IsKnown(evt.Host) {
|
|
||||||
log.Printf("Host %s is not ready, removing", evt.Host)
|
|
||||||
pool.RemoveHost(evt.Host)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
if !pool.IsKnown(evt.Host) {
|
|
||||||
log.Printf("Discovered host %s", evt.Host)
|
|
||||||
pool.AddRemoteHost(evt.Host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}(GetDiscovery())
|
|
||||||
}
|
|
||||||
290
cmd/cart/main.go
290
cmd/cart/main.go
@@ -1,290 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/pprof"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/actor"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
|
||||||
messages "git.k6n.net/go-cart-actor/pkg/messages"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/promotions"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/proxy"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/voucher"
|
|
||||||
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
grainSpawns = promauto.NewCounter(prometheus.CounterOpts{
|
|
||||||
Name: "cart_grain_spawned_total",
|
|
||||||
Help: "The total number of spawned grains",
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
os.Mkdir("data", 0755)
|
|
||||||
}
|
|
||||||
|
|
||||||
type App struct {
|
|
||||||
pool *actor.SimpleGrainPool[cart.CartGrain]
|
|
||||||
server *PoolServer
|
|
||||||
klarnaClient *KlarnaClient
|
|
||||||
}
|
|
||||||
|
|
||||||
var podIp = os.Getenv("POD_IP")
|
|
||||||
var name = os.Getenv("POD_NAME")
|
|
||||||
var amqpUrl = os.Getenv("AMQP_URL")
|
|
||||||
var redisAddress = os.Getenv("REDIS_ADDRESS")
|
|
||||||
var redisPassword = os.Getenv("REDIS_PASSWORD")
|
|
||||||
|
|
||||||
func getCountryFromHost(host string) string {
|
|
||||||
if strings.Contains(strings.ToLower(host), "-no") {
|
|
||||||
return "no"
|
|
||||||
}
|
|
||||||
if strings.Contains(strings.ToLower(host), "-se") {
|
|
||||||
return "se"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
type MutationContext struct {
|
|
||||||
VoucherService voucher.Service
|
|
||||||
}
|
|
||||||
|
|
||||||
type CartChangeEvent struct {
|
|
||||||
CartId cart.CartId `json:"cartId"`
|
|
||||||
Mutations []actor.ApplyResult `json:"mutations"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
|
|
||||||
controlPlaneConfig := actor.DefaultServerConfig()
|
|
||||||
|
|
||||||
promotionData, err := promotions.LoadStateFile("data/promotions.json")
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error loading promotions: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("loaded %d promotions", len(promotionData.State.Promotions))
|
|
||||||
|
|
||||||
promotionService := promotions.NewPromotionService(nil)
|
|
||||||
|
|
||||||
reg := cart.NewCartMultationRegistry()
|
|
||||||
reg.RegisterProcessor(
|
|
||||||
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
|
|
||||||
_, span := tracer.Start(ctx, "Totals and promotions")
|
|
||||||
defer span.End()
|
|
||||||
g.UpdateTotals()
|
|
||||||
|
|
||||||
promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()), promotions.WithCustomerSegment("vip"))
|
|
||||||
_, actions := promotionService.EvaluateAll(promotionData.State.Promotions, promotionCtx)
|
|
||||||
for _, action := range actions {
|
|
||||||
log.Printf("apply: %+v", action)
|
|
||||||
g.UpdateTotals()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
diskStorage := actor.NewDiskStorage[cart.CartGrain]("data", reg)
|
|
||||||
poolConfig := actor.GrainPoolConfig[cart.CartGrain]{
|
|
||||||
MutationRegistry: reg,
|
|
||||||
Storage: diskStorage,
|
|
||||||
Spawn: func(ctx context.Context, id uint64) (actor.Grain[cart.CartGrain], error) {
|
|
||||||
_, span := tracer.Start(ctx, fmt.Sprintf("Spawn cart id %d", id))
|
|
||||||
defer span.End()
|
|
||||||
grainSpawns.Inc()
|
|
||||||
ret := cart.NewCartGrain(id, time.Now())
|
|
||||||
// Set baseline lastChange at spawn; replay may update it to last event timestamp.
|
|
||||||
|
|
||||||
err := diskStorage.LoadEvents(ctx, 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.Fatalf("Error creating cart pool: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pool.SetPubSub(actor.NewPubSub(func(id uint64, event actor.Event) {
|
|
||||||
grain, _ := pool.Get(context.Background(), id)
|
|
||||||
if sub, ok := any(grain).(actor.Subscribable); ok {
|
|
||||||
sub.Notify(event)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
klarnaClient := NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
|
|
||||||
rdb := redis.NewClient(&redis.Options{
|
|
||||||
Addr: redisAddress,
|
|
||||||
Password: redisPassword,
|
|
||||||
DB: 0,
|
|
||||||
})
|
|
||||||
inventoryService, err := inventory.NewRedisInventoryService(rdb)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error creating inventory service: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, inventoryService, rdb)
|
|
||||||
|
|
||||||
app := &App{
|
|
||||||
pool: pool,
|
|
||||||
server: syncedServer,
|
|
||||||
klarnaClient: klarnaClient,
|
|
||||||
}
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
debugMux := http.NewServeMux()
|
|
||||||
|
|
||||||
if amqpUrl == "" {
|
|
||||||
log.Printf("no connection to amqp defined")
|
|
||||||
} else {
|
|
||||||
app.HandleCheckoutRequests(amqpUrl, mux, inventoryService)
|
|
||||||
}
|
|
||||||
|
|
||||||
grpcSrv, err := actor.NewControlServer[*cart.CartGrain](controlPlaneConfig, pool)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
|
|
||||||
}
|
|
||||||
defer grpcSrv.GracefulStop()
|
|
||||||
|
|
||||||
// go diskStorage.SaveLoop(10 * time.Second)
|
|
||||||
UseDiscovery(pool)
|
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
|
||||||
defer stop()
|
|
||||||
|
|
||||||
otelShutdown, err := setupOTelSDK(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Unable to start otel %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
syncedServer.Serve(mux)
|
|
||||||
// only for local
|
|
||||||
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
pool.AddRemote(r.PathValue("host"))
|
|
||||||
})
|
|
||||||
|
|
||||||
debugMux.HandleFunc("/debug/pprof/", pprof.Index)
|
|
||||||
debugMux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
|
||||||
debugMux.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
|
||||||
debugMux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
|
||||||
debugMux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
|
||||||
debugMux.Handle("/metrics", promhttp.Handler())
|
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// 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("grain pool at capacity"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !pool.IsHealthy() {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte("control plane not healthy"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
|
|
||||||
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("1.0.0"))
|
|
||||||
})
|
|
||||||
|
|
||||||
mux.HandleFunc("/openapi.json", ServeEmbeddedOpenAPI)
|
|
||||||
|
|
||||||
srv := &http.Server{
|
|
||||||
Addr: ":8080",
|
|
||||||
BaseContext: func(net.Listener) context.Context { return ctx },
|
|
||||||
ReadTimeout: 10 * time.Second,
|
|
||||||
WriteTimeout: 20 * time.Second,
|
|
||||||
Handler: otelhttp.NewHandler(mux, "/"),
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
|
|
||||||
fmt.Println("Shutting down due to signal")
|
|
||||||
otelShutdown(context.Background())
|
|
||||||
diskStorage.Close()
|
|
||||||
pool.Close()
|
|
||||||
|
|
||||||
}()
|
|
||||||
|
|
||||||
srvErr := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
srvErr <- srv.ListenAndServe()
|
|
||||||
}()
|
|
||||||
|
|
||||||
log.Print("Server started at port 8080")
|
|
||||||
|
|
||||||
go http.ListenAndServe(":8081", debugMux)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err = <-srvErr:
|
|
||||||
// Error when starting HTTP server.
|
|
||||||
log.Fatalf("Unable to start server: %v", err)
|
|
||||||
case <-ctx.Done():
|
|
||||||
// Wait for first CTRL+C.
|
|
||||||
// Stop receiving signal notifications as soon as possible.
|
|
||||||
stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func triggerOrderCompleted(ctx context.Context, syncedServer *PoolServer, order *CheckoutOrder) error {
|
|
||||||
mutation := &messages.OrderCreated{
|
|
||||||
OrderId: order.ID,
|
|
||||||
Status: order.Status,
|
|
||||||
}
|
|
||||||
cid, ok := cart.ParseCartId(order.MerchantReference1)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
|
|
||||||
}
|
|
||||||
_, applyErr := syncedServer.Apply(ctx, uint64(cid), mutation)
|
|
||||||
|
|
||||||
return applyErr
|
|
||||||
}
|
|
||||||
|
|
||||||
func confirmOrder(ctx context.Context, order *CheckoutOrder, orderHandler *AmqpOrderHandler) error {
|
|
||||||
orderToSend, err := json.Marshal(order)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = orderHandler.OrderCompleted(orderToSend)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,69 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
117
cmd/cart/otel.go
117
cmd/cart/otel.go
@@ -1,117 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.opentelemetry.io/otel"
|
|
||||||
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
|
|
||||||
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
|
|
||||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
|
||||||
"go.opentelemetry.io/otel/log/global"
|
|
||||||
"go.opentelemetry.io/otel/propagation"
|
|
||||||
"go.opentelemetry.io/otel/sdk/log"
|
|
||||||
"go.opentelemetry.io/otel/sdk/metric"
|
|
||||||
"go.opentelemetry.io/otel/sdk/trace"
|
|
||||||
)
|
|
||||||
|
|
||||||
// setupOTelSDK bootstraps the OpenTelemetry pipeline.
|
|
||||||
// If it does not return an error, make sure to call shutdown for proper cleanup.
|
|
||||||
func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
|
|
||||||
var shutdownFuncs []func(context.Context) error
|
|
||||||
var err error
|
|
||||||
|
|
||||||
// shutdown calls cleanup functions registered via shutdownFuncs.
|
|
||||||
// The errors from the calls are joined.
|
|
||||||
// Each registered cleanup will be invoked once.
|
|
||||||
shutdown := func(ctx context.Context) error {
|
|
||||||
var err error
|
|
||||||
for _, fn := range shutdownFuncs {
|
|
||||||
err = errors.Join(err, fn(ctx))
|
|
||||||
}
|
|
||||||
shutdownFuncs = nil
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleErr calls shutdown for cleanup and makes sure that all errors are returned.
|
|
||||||
handleErr := func(inErr error) {
|
|
||||||
err = errors.Join(inErr, shutdown(ctx))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up propagator.
|
|
||||||
prop := newPropagator()
|
|
||||||
otel.SetTextMapPropagator(prop)
|
|
||||||
|
|
||||||
// Set up trace provider.
|
|
||||||
tracerProvider, err := newTracerProvider()
|
|
||||||
if err != nil {
|
|
||||||
handleErr(err)
|
|
||||||
return shutdown, err
|
|
||||||
}
|
|
||||||
shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
|
|
||||||
otel.SetTracerProvider(tracerProvider)
|
|
||||||
|
|
||||||
// Set up meter provider.
|
|
||||||
meterProvider, err := newMeterProvider()
|
|
||||||
if err != nil {
|
|
||||||
handleErr(err)
|
|
||||||
return shutdown, err
|
|
||||||
}
|
|
||||||
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
|
|
||||||
otel.SetMeterProvider(meterProvider)
|
|
||||||
|
|
||||||
// Set up logger provider.
|
|
||||||
loggerProvider, err := newLoggerProvider()
|
|
||||||
if err != nil {
|
|
||||||
handleErr(err)
|
|
||||||
return shutdown, err
|
|
||||||
}
|
|
||||||
shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
|
|
||||||
global.SetLoggerProvider(loggerProvider)
|
|
||||||
|
|
||||||
return shutdown, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func newPropagator() propagation.TextMapPropagator {
|
|
||||||
return propagation.NewCompositeTextMapPropagator(
|
|
||||||
propagation.TraceContext{},
|
|
||||||
propagation.Baggage{},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTracerProvider() (*trace.TracerProvider, error) {
|
|
||||||
traceExporter, err := otlptracegrpc.New(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
tracerProvider := trace.NewTracerProvider(
|
|
||||||
trace.WithBatcher(traceExporter,
|
|
||||||
// Default is 5s. Set to 1s for demonstrative purposes.
|
|
||||||
trace.WithBatchTimeout(time.Second)),
|
|
||||||
)
|
|
||||||
return tracerProvider, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMeterProvider() (*metric.MeterProvider, error) {
|
|
||||||
exporter, err := otlpmetricgrpc.New(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
provider := metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(exporter)))
|
|
||||||
return provider, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newLoggerProvider() (*log.LoggerProvider, error) {
|
|
||||||
logExporter, err := otlploggrpc.New(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
loggerProvider := log.NewLoggerProvider(
|
|
||||||
log.WithProcessor(log.NewBatchProcessor(logExporter)),
|
|
||||||
)
|
|
||||||
return loggerProvider, nil
|
|
||||||
}
|
|
||||||
@@ -1,780 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/actor"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
|
||||||
messages "git.k6n.net/go-cart-actor/pkg/messages"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/voucher"
|
|
||||||
"github.com/gogo/protobuf/proto"
|
|
||||||
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
|
||||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
|
||||||
"google.golang.org/protobuf/types/known/anypb"
|
|
||||||
|
|
||||||
"go.opentelemetry.io/otel"
|
|
||||||
"go.opentelemetry.io/otel/attribute"
|
|
||||||
"go.opentelemetry.io/otel/metric"
|
|
||||||
"go.opentelemetry.io/otel/trace"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
|
|
||||||
Name: "cart_grain_mutations_total",
|
|
||||||
Help: "The total number of mutations",
|
|
||||||
})
|
|
||||||
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
|
|
||||||
Name: "cart_grain_lookups_total",
|
|
||||||
Help: "The total number of lookups",
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
type PoolServer struct {
|
|
||||||
actor.GrainPool[*cart.CartGrain]
|
|
||||||
pod_name string
|
|
||||||
klarnaClient *KlarnaClient
|
|
||||||
inventoryService inventory.InventoryService
|
|
||||||
inventoryListener *inventory.InventoryChangeListener
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPoolServer(pool actor.GrainPool[*cart.CartGrain], pod_name string, klarnaClient *KlarnaClient, inventoryService inventory.InventoryService, inventoryRedisClient *redis.Client) *PoolServer {
|
|
||||||
srv := &PoolServer{
|
|
||||||
GrainPool: pool,
|
|
||||||
pod_name: pod_name,
|
|
||||||
klarnaClient: klarnaClient,
|
|
||||||
inventoryService: inventoryService,
|
|
||||||
}
|
|
||||||
listener := inventory.NewInventoryChangeListener(inventoryRedisClient, context.Background(), func(changes []inventory.InventoryChange) {
|
|
||||||
for _, change := range changes {
|
|
||||||
srv.GrainPool.GetPubSub().Publish(actor.Event{
|
|
||||||
Topic: fmt.Sprintf("inventory:%s", change.SKU),
|
|
||||||
Payload: change,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
srv.inventoryListener = listener
|
|
||||||
go listener.Start()
|
|
||||||
return srv
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) ApplyLocal(ctx context.Context, id cart.CartId, mutation ...proto.Message) (*actor.MutationResult[*cart.CartGrain], error) {
|
|
||||||
return s.Apply(ctx, uint64(id), mutation...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
|
||||||
grain, err := s.Get(r.Context(), uint64(id))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.WriteResult(w, grain)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
|
||||||
sku := r.PathValue("sku")
|
|
||||||
msg, err := GetItemAddMessage(r.Context(), sku, 1, getCountryFromHost(r.Host), nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data, err := s.ApplyLocal(r.Context(), id, msg)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
grainMutations.Add(float64(len(data.Mutations)))
|
|
||||||
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 cart.CartId) error {
|
|
||||||
|
|
||||||
itemIdString := r.PathValue("itemId")
|
|
||||||
itemId, err := strconv.ParseInt(itemIdString, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data, err := s.ApplyLocal(r.Context(), 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 cart.CartId) error {
|
|
||||||
|
|
||||||
delivery := SetDeliveryRequest{}
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&delivery)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data, err := s.ApplyLocal(r.Context(), 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 cart.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(r.Context(), 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 cart.CartId) error {
|
|
||||||
|
|
||||||
deliveryIdString := r.PathValue("deliveryId")
|
|
||||||
deliveryId, err := strconv.Atoi(deliveryIdString)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
reply, err := s.ApplyLocal(r.Context(), 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 cart.CartId) error {
|
|
||||||
changeQuantity := messages.ChangeQuantity{}
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&changeQuantity)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
reply, err := s.ApplyLocal(r.Context(), 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(ctx context.Context, 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(ctx, 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 cart.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(r.Context(), setCartItems.Items, setCartItems.Country)...)
|
|
||||||
|
|
||||||
reply, err := s.ApplyLocal(r.Context(), id, msgs...)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) AddMultipleItemHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
|
||||||
setCartItems := SetCartItems{}
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&setCartItems)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
reply, err := s.ApplyLocal(r.Context(), id, getMultipleAddMessages(r.Context(), 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 cart.CartId) error {
|
|
||||||
addRequest := AddRequest{Quantity: 1}
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&addRequest)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
msg, err := GetItemAddMessage(r.Context(), addRequest.Sku, int(addRequest.Quantity), addRequest.Country, addRequest.StoreId)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
reply, err := s.ApplyLocal(r.Context(), 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 getLocationId(item *cart.CartItem) inventory.LocationID {
|
|
||||||
if item.StoreId == nil || *item.StoreId == "" {
|
|
||||||
return "se"
|
|
||||||
}
|
|
||||||
return inventory.LocationID(*item.StoreId)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
|
|
||||||
var requests []inventory.ReserveRequest
|
|
||||||
for _, item := range items {
|
|
||||||
if item == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
requests = append(requests, inventory.ReserveRequest{
|
|
||||||
SKU: inventory.SKU(item.Sku),
|
|
||||||
LocationID: getLocationId(item),
|
|
||||||
Quantity: uint32(item.Quantity),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return requests
|
|
||||||
}
|
|
||||||
|
|
||||||
func getOriginalHost(r *http.Request) string {
|
|
||||||
proxyHost := r.Header.Get("X-Forwarded-Host")
|
|
||||||
if proxyHost != "" {
|
|
||||||
return proxyHost
|
|
||||||
}
|
|
||||||
return r.Host
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) CreateOrUpdateCheckout(r *http.Request, id cart.CartId) (*CheckoutOrder, error) {
|
|
||||||
host := getOriginalHost(r)
|
|
||||||
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),
|
|
||||||
Country: country,
|
|
||||||
Currency: getCurrency(country),
|
|
||||||
Locale: getLocale(country),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current grain state (may be local or remote)
|
|
||||||
grain, err := s.Get(r.Context(), uint64(id))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if s.inventoryService != nil {
|
|
||||||
inventoryRequests := getInventoryRequests(grain.Items)
|
|
||||||
failingRequest, err := s.inventoryService.ReservationCheck(r.Context(), inventoryRequests...)
|
|
||||||
if err != nil {
|
|
||||||
logger.WarnContext(r.Context(), "inventory check failed", string(failingRequest.SKU), string(failingRequest.LocationID))
|
|
||||||
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(r.Context(), grain.OrderReference, bytes.NewReader(payload))
|
|
||||||
} else {
|
|
||||||
return s.klarnaClient.CreateOrder(r.Context(), bytes.NewReader(payload))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) ApplyCheckoutStarted(ctx context.Context, klarnaOrder *CheckoutOrder, id cart.CartId) (*actor.MutationResult[*cart.CartGrain], error) {
|
|
||||||
// Persist initialization state via mutation (best-effort)
|
|
||||||
return s.ApplyLocal(ctx, 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 cart.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 cart.CartId
|
|
||||||
cookie, err := r.Cookie("cartid")
|
|
||||||
if err != nil || cookie.Value == "" {
|
|
||||||
id = cart.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 := cart.ParseCartId(cookie.Value)
|
|
||||||
if !ok {
|
|
||||||
id = cart.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 cart.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 cart.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 cart.CartId
|
|
||||||
raw := r.PathValue("id")
|
|
||||||
// If no id supplied, generate a new one
|
|
||||||
if raw == "" {
|
|
||||||
id := cart.MustNewCartId()
|
|
||||||
w.Header().Set("Set-Cart-Id", id.String())
|
|
||||||
} else {
|
|
||||||
// Parse base62 cart id
|
|
||||||
if parsedId, ok := cart.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 cart.CartId) error) func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error {
|
|
||||||
return func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error {
|
|
||||||
if ownerHost, ok := s.OwnerHost(uint64(cartId)); ok {
|
|
||||||
ctx, span := tracer.Start(r.Context(), "proxy")
|
|
||||||
defer span.End()
|
|
||||||
span.SetAttributes(attribute.String("cartid", cartId.String()))
|
|
||||||
hostAttr := attribute.String("other host", ownerHost.Name())
|
|
||||||
span.SetAttributes(hostAttr)
|
|
||||||
logger.InfoContext(ctx, "cart proxyed", "result", ownerHost.Name())
|
|
||||||
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
|
|
||||||
handled, err := ownerHost.Proxy(uint64(cartId), w, r)
|
|
||||||
|
|
||||||
grainLookups.Inc()
|
|
||||||
if err == nil && handled {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_, span := tracer.Start(r.Context(), "own")
|
|
||||||
span.SetAttributes(attribute.String("cartid", cartId.String()))
|
|
||||||
defer span.End()
|
|
||||||
return fn(w, r, cartId)
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
tracer = otel.Tracer(name)
|
|
||||||
|
|
||||||
meter = otel.Meter(name)
|
|
||||||
logger = otelslog.NewLogger(name)
|
|
||||||
proxyCalls metric.Int64Counter
|
|
||||||
|
|
||||||
// rollCnt metric.Int64Counter
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
var err error
|
|
||||||
proxyCalls, err = meter.Int64Counter("proxy.calls",
|
|
||||||
metric.WithDescription("Number of proxy calls"),
|
|
||||||
metric.WithUnit("{calls}"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
type AddVoucherRequest struct {
|
|
||||||
VoucherCode string `json:"code"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) AddVoucherHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
|
||||||
data := &AddVoucherRequest{}
|
|
||||||
json.NewDecoder(r.Body).Decode(data)
|
|
||||||
v := voucher.Service{}
|
|
||||||
msg, err := v.GetVoucher(data.VoucherCode)
|
|
||||||
if err != nil {
|
|
||||||
s.ApplyLocal(r.Context(), cartId, &messages.PreConditionFailed{
|
|
||||||
Operation: "AddVoucher",
|
|
||||||
Error: err.Error(),
|
|
||||||
})
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte(err.Error()))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
reply, err := s.ApplyLocal(r.Context(), cartId, msg)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte(err.Error()))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
s.WriteResult(w, reply)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type SubscriptionDetailsRequest struct {
|
|
||||||
Id *string `json:"id,omitempty"`
|
|
||||||
OfferingCode string `json:"offeringCode,omitempty"`
|
|
||||||
SigningType string `json:"signingType,omitempty"`
|
|
||||||
Data json.RawMessage `json:"data,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sd *SubscriptionDetailsRequest) ToMessage() *messages.UpsertSubscriptionDetails {
|
|
||||||
return &messages.UpsertSubscriptionDetails{
|
|
||||||
Id: sd.Id,
|
|
||||||
OfferingCode: sd.OfferingCode,
|
|
||||||
SigningType: sd.SigningType,
|
|
||||||
Data: &anypb.Any{Value: sd.Data},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) SubscriptionDetailsHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
|
||||||
data := &SubscriptionDetailsRequest{}
|
|
||||||
err := json.NewDecoder(r.Body).Decode(data)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte(err.Error()))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
reply, err := s.ApplyLocal(r.Context(), cartId, data.ToMessage())
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte(err.Error()))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
s.WriteResult(w, reply)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) CheckoutHandler(fn func(order *CheckoutOrder, w http.ResponseWriter) error) func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
return CookieCartIdHandler(s.ProxyHandler(func(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
|
||||||
orderId := r.URL.Query().Get("order_id")
|
|
||||||
if orderId == "" {
|
|
||||||
order, err := s.CreateOrUpdateCheckout(r, cartId)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("unable to create klarna session", "error", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
s.ApplyCheckoutStarted(r.Context(), order, cartId)
|
|
||||||
return fn(order, w)
|
|
||||||
}
|
|
||||||
order, err := s.klarnaClient.GetOrder(r.Context(), orderId)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return fn(order, w)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request, cartId cart.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(r.Context(), 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) SetUserIdHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
|
||||||
setUserId := messages.SetUserId{}
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&setUserId)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
reply, err := s.ApplyLocal(r.Context(), cartId, &setUserId)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) LineItemMarkingHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
|
||||||
itemIdStr := r.PathValue("itemId")
|
|
||||||
itemId, err := strconv.ParseInt(itemIdStr, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
lineItemMarking := messages.LineItemMarking{Id: uint32(itemId)}
|
|
||||||
err = json.NewDecoder(r.Body).Decode(&lineItemMarking)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
reply, err := s.ApplyLocal(r.Context(), cartId, &lineItemMarking)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) RemoveLineItemMarkingHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
|
||||||
itemIdStr := r.PathValue("itemId")
|
|
||||||
itemId, err := strconv.ParseInt(itemIdStr, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
removeLineItemMarking := messages.RemoveLineItemMarking{Id: uint32(itemId)}
|
|
||||||
reply, err := s.ApplyLocal(r.Context(), cartId, &removeLineItemMarking)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) CreateCheckoutOrderHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
|
||||||
createCheckoutOrder := messages.CreateCheckoutOrder{}
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&createCheckoutOrder)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
reply, err := s.ApplyLocal(r.Context(), cartId, &createCheckoutOrder)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) Serve(mux *http.ServeMux) {
|
|
||||||
|
|
||||||
// mux.HandleFunc("OPTIONS /cart", 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)
|
|
||||||
// })
|
|
||||||
|
|
||||||
handleFunc := func(pattern string, handlerFunc func(http.ResponseWriter, *http.Request)) {
|
|
||||||
attr := attribute.String("http.route", pattern)
|
|
||||||
mux.HandleFunc(pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
span := trace.SpanFromContext(r.Context())
|
|
||||||
span.SetName(pattern)
|
|
||||||
span.SetAttributes(attr)
|
|
||||||
|
|
||||||
labeler, _ := otelhttp.LabelerFromContext(r.Context())
|
|
||||||
labeler.Add(attr)
|
|
||||||
|
|
||||||
handlerFunc(w, r)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
handleFunc("GET /cart", CookieCartIdHandler(s.ProxyHandler(s.GetCartHandler)))
|
|
||||||
handleFunc("GET /cart/add/{sku}", CookieCartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
|
|
||||||
handleFunc("POST /cart/add", CookieCartIdHandler(s.ProxyHandler(s.AddMultipleItemHandler)))
|
|
||||||
handleFunc("POST /cart", CookieCartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
|
|
||||||
handleFunc("POST /cart/set", CookieCartIdHandler(s.ProxyHandler(s.SetCartItemsHandler)))
|
|
||||||
handleFunc("DELETE /cart/{itemId}", CookieCartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
|
|
||||||
handleFunc("PUT /cart", CookieCartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
|
|
||||||
handleFunc("DELETE /cart", CookieCartIdHandler(s.ProxyHandler(s.RemoveCartCookie)))
|
|
||||||
handleFunc("POST /cart/delivery", CookieCartIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
|
|
||||||
handleFunc("DELETE /cart/delivery/{deliveryId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
|
|
||||||
handleFunc("PUT /cart/delivery/{deliveryId}/pickupPoint", CookieCartIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
|
|
||||||
handleFunc("PUT /cart/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
|
|
||||||
handleFunc("PUT /cart/subscription-details", CookieCartIdHandler(s.ProxyHandler(s.SubscriptionDetailsHandler)))
|
|
||||||
handleFunc("DELETE /cart/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
|
||||||
handleFunc("PUT /cart/user", CookieCartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
|
|
||||||
handleFunc("PUT /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
|
|
||||||
handleFunc("DELETE /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
|
|
||||||
|
|
||||||
handleFunc("POST /cart/checkout-order", CookieCartIdHandler(s.ProxyHandler(s.CreateCheckoutOrderHandler)))
|
|
||||||
|
|
||||||
//mux.HandleFunc("GET /cart/checkout", CookieCartIdHandler(s.ProxyHandler(s.HandleCheckout)))
|
|
||||||
//mux.HandleFunc("GET /cart/confirmation/{orderId}", CookieCartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
|
|
||||||
|
|
||||||
handleFunc("GET /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.GetCartHandler)))
|
|
||||||
handleFunc("GET /cart/byid/{id}/add/{sku}", CartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
|
|
||||||
handleFunc("POST /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
|
|
||||||
handleFunc("DELETE /cart/byid/{id}/{itemId}", CartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
|
|
||||||
handleFunc("PUT /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
|
|
||||||
handleFunc("POST /cart/byid/{id}/delivery", CartIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
|
|
||||||
handleFunc("DELETE /cart/byid/{id}/delivery/{deliveryId}", CartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
|
|
||||||
handleFunc("PUT /cart/byid/{id}/delivery/{deliveryId}/pickupPoint", CartIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
|
|
||||||
handleFunc("PUT /cart/byid/{id}/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
|
|
||||||
handleFunc("DELETE /cart/byid/{id}/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
|
||||||
handleFunc("PUT /cart/byid/{id}/user", CartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
|
|
||||||
handleFunc("PUT /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
|
|
||||||
handleFunc("DELETE /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
|
|
||||||
|
|
||||||
handleFunc("POST /cart/byid/{id}/checkout-order", CartIdHandler(s.ProxyHandler(s.CreateCheckoutOrderHandler)))
|
|
||||||
//mux.HandleFunc("GET /cart/byid/{id}/checkout", CartIdHandler(s.ProxyHandler(s.HandleCheckout)))
|
|
||||||
//mux.HandleFunc("GET /cart/byid/{id}/confirmation", CartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
|
||||||
messages "git.k6n.net/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(ctx context.Context, sku string, country string) (*index.DataItem, error) {
|
|
||||||
baseUrl := getBaseUrl(country)
|
|
||||||
req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/by-sku/%s", baseUrl, sku), nil)
|
|
||||||
innerCtx, span := tracer.Start(ctx, fmt.Sprintf("fetching data for %s", sku))
|
|
||||||
defer span.End()
|
|
||||||
req = req.WithContext(innerCtx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
res, err := http.DefaultClient.Do(req)
|
|
||||||
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(ctx context.Context, sku string, qty int, country string, storeId *string) (*messages.AddItem, error) {
|
|
||||||
item, err := FetchItem(ctx, sku, country)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return ToItemAddMessage(item, storeId, qty, country)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country string) (*messages.AddItem, error) {
|
|
||||||
orgPrice, _ := getInt(item.GetNumberFieldValue(5)) // getInt(item.Fields[5])
|
|
||||||
|
|
||||||
price, err := getInt(item.GetNumberFieldValue(4)) //Fields[4]
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
stk := item.GetStock()
|
|
||||||
stock := cart.StockStatus(0)
|
|
||||||
|
|
||||||
if storeId == nil {
|
|
||||||
centralStock, ok := stk[country]
|
|
||||||
if ok {
|
|
||||||
if !item.Buyable {
|
|
||||||
return nil, fmt.Errorf("item not available")
|
|
||||||
}
|
|
||||||
|
|
||||||
if centralStock == 0 && item.SaleStatus == "TBD" {
|
|
||||||
return nil, fmt.Errorf("no items available")
|
|
||||||
}
|
|
||||||
stock = cart.StockStatus(centralStock)
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
|
||||||
if !item.BuyableInStore {
|
|
||||||
return nil, fmt.Errorf("item not available in store")
|
|
||||||
}
|
|
||||||
storeStock, ok := stk[*storeId]
|
|
||||||
if ok {
|
|
||||||
stock = cart.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)
|
|
||||||
|
|
||||||
cgm, _ := item.GetStringFieldValue(35) // Customer Group Membership
|
|
||||||
|
|
||||||
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,
|
|
||||||
SaleStatus: item.SaleStatus,
|
|
||||||
Cgm: cgm,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getTax(articleType string) int32 {
|
|
||||||
switch articleType {
|
|
||||||
case "ZDIE":
|
|
||||||
return 600
|
|
||||||
default:
|
|
||||||
return 2500
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getInt(data float64, ok bool) (int, error) {
|
|
||||||
if !ok {
|
|
||||||
return 0, fmt.Errorf("invalid type")
|
|
||||||
}
|
|
||||||
return int(data), nil
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
|
||||||
"github.com/matst80/slask-finder/pkg/index"
|
|
||||||
"github.com/matst80/slask-finder/pkg/messaging"
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
"github.com/redis/go-redis/v9/maintnotifications"
|
|
||||||
|
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Server struct {
|
|
||||||
service *inventory.RedisInventoryService
|
|
||||||
}
|
|
||||||
|
|
||||||
func (srv *Server) livezHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("OK"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (srv *Server) readyzHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("OK"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (srv *Server) getInventoryHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Parse path: /inventory/{sku}/{location}
|
|
||||||
path := r.URL.Path
|
|
||||||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
||||||
if len(parts) != 3 || parts[0] != "inventory" {
|
|
||||||
http.Error(w, "Invalid path", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sku := inventory.SKU(parts[1])
|
|
||||||
locationID := inventory.LocationID(parts[2])
|
|
||||||
|
|
||||||
quantity, err := srv.service.GetInventory(r.Context(), sku, locationID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
response := map[string]int64{"quantity": quantity}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
var country = "se"
|
|
||||||
var redisAddress = "10.10.3.18:6379"
|
|
||||||
var redisPassword = "slaskredis"
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
// Override redis config from environment variables if set
|
|
||||||
if addr, ok := os.LookupEnv("REDIS_ADDRESS"); ok {
|
|
||||||
redisAddress = addr
|
|
||||||
}
|
|
||||||
if password, ok := os.LookupEnv("REDIS_PASSWORD"); ok {
|
|
||||||
redisPassword = password
|
|
||||||
}
|
|
||||||
if ctry, ok := os.LookupEnv("COUNTRY"); ok {
|
|
||||||
country = ctry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
var ctx = context.Background()
|
|
||||||
rdb := redis.NewClient(&redis.Options{
|
|
||||||
Addr: redisAddress,
|
|
||||||
Password: redisPassword, // no password set
|
|
||||||
DB: 0, // use default DB
|
|
||||||
MaintNotificationsConfig: &maintnotifications.Config{
|
|
||||||
Mode: maintnotifications.ModeDisabled,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
s, err := inventory.NewRedisInventoryService(rdb)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Unable to connect to inventory redis: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
server := &Server{service: s}
|
|
||||||
|
|
||||||
// Set up HTTP routes
|
|
||||||
http.HandleFunc("/livez", server.livezHandler)
|
|
||||||
http.HandleFunc("/readyz", server.readyzHandler)
|
|
||||||
http.HandleFunc("/inventory/", server.getInventoryHandler)
|
|
||||||
|
|
||||||
stockhandler := &StockHandler{
|
|
||||||
MainStockLocationID: inventory.LocationID(country),
|
|
||||||
rdb: rdb,
|
|
||||||
ctx: ctx,
|
|
||||||
svc: *s,
|
|
||||||
}
|
|
||||||
|
|
||||||
amqpUrl, ok := os.LookupEnv("RABBIT_HOST")
|
|
||||||
if ok {
|
|
||||||
log.Printf("Connecting to rabbitmq")
|
|
||||||
conn, err := amqp.DialConfig(amqpUrl, amqp.Config{
|
|
||||||
Properties: amqp.NewConnectionProperties(),
|
|
||||||
})
|
|
||||||
//a.conn = conn
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to connect to RabbitMQ: %v", err)
|
|
||||||
}
|
|
||||||
ch, err := conn.Channel()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to open a channel: %v", err)
|
|
||||||
}
|
|
||||||
// items listener
|
|
||||||
err = messaging.ListenToTopic(ch, country, "item_added", func(d amqp.Delivery) error {
|
|
||||||
var items []*index.DataItem
|
|
||||||
err := json.Unmarshal(d.Body, &items)
|
|
||||||
if err == nil {
|
|
||||||
log.Printf("Got upserts %d, message count %d", len(items), d.MessageCount)
|
|
||||||
wg := &sync.WaitGroup{}
|
|
||||||
for _, item := range items {
|
|
||||||
stockhandler.HandleItem(item, wg)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
log.Print("Batch done...")
|
|
||||||
} else {
|
|
||||||
log.Printf("Failed to unmarshal upsert message %v", err)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to listen to item_added topic: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start HTTP server
|
|
||||||
log.Println("Starting HTTP server on :8080")
|
|
||||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
|
||||||
"github.com/matst80/slask-finder/pkg/types"
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
)
|
|
||||||
|
|
||||||
type StockHandler struct {
|
|
||||||
rdb *redis.Client
|
|
||||||
ctx context.Context
|
|
||||||
svc inventory.RedisInventoryService
|
|
||||||
MainStockLocationID inventory.LocationID
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *StockHandler) HandleItem(item types.Item, wg *sync.WaitGroup) {
|
|
||||||
wg.Go(func() {
|
|
||||||
ctx := s.ctx
|
|
||||||
pipe := s.rdb.Pipeline()
|
|
||||||
centralStockString, ok := item.GetStringFieldValue(3)
|
|
||||||
if !ok {
|
|
||||||
centralStockString = "0"
|
|
||||||
}
|
|
||||||
centralStockString = strings.Replace(centralStockString, "+", "", -1)
|
|
||||||
centralStockString = strings.Replace(centralStockString, "<", "", -1)
|
|
||||||
centralStockString = strings.Replace(centralStockString, ">", "", -1)
|
|
||||||
centralStock, err := strconv.ParseInt(centralStockString, 10, 64)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("unable to parse central stock for item %s: %v", item.GetSku(), err)
|
|
||||||
centralStock = 0
|
|
||||||
} else {
|
|
||||||
s.svc.UpdateInventory(ctx, pipe, inventory.SKU(item.GetSku()), s.MainStockLocationID, int64(centralStock))
|
|
||||||
}
|
|
||||||
for id, value := range item.GetStock() {
|
|
||||||
s.svc.UpdateInventory(ctx, pipe, inventory.SKU(item.GetSku()), inventory.LocationID(id), int64(value))
|
|
||||||
}
|
|
||||||
_, err = pipe.Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("unable to update stock: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Netscape HTTP Cookie File
|
|
||||||
# https://curl.se/docs/http-cookies.html
|
|
||||||
# This file was generated by libcurl! Edit at your own risk.
|
|
||||||
|
|
||||||
#HttpOnly_localhost FALSE / FALSE 1761304670 cartid 4393545184291837
|
|
||||||
BIN
data/1.prot
Normal file
BIN
data/1.prot
Normal file
Binary file not shown.
BIN
data/4.prot
Normal file
BIN
data/4.prot
Normal file
Binary file not shown.
BIN
data/5.prot
Normal file
BIN
data/5.prot
Normal file
Binary file not shown.
BIN
data/state.gob
Normal file
BIN
data/state.gob
Normal file
Binary file not shown.
BIN
data/state.gob.bak
Normal file
BIN
data/state.gob.bak
Normal file
Binary file not shown.
276
deployment/deployment-no.yaml
Normal file
276
deployment/deployment-no.yaml
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: klarna-api-credentials
|
||||||
|
data:
|
||||||
|
username: ZjQzZDY3YjEtNzA2Yy00NTk2LTliNTgtYjg1YjU2NDEwZTUw
|
||||||
|
password: a2xhcm5hX3Rlc3RfYXBpX0trUWhWVE5yYVZsV2FsTnhTRVp3Y1ZSSFF5UkVNRmxyY25Kd1AxSndQMGdzWmpRelpEWTNZakV0TnpBMll5MDBOVGsyTFRsaU5UZ3RZamcxWWpVMk5ERXdaVFV3TERFc2JUUkNjRFpWU1RsTllsSk1aMlEyVEc4MmRVODNZMkozUlRaaFdEZDViV3AwYkhGV1JqTjVNQzlaYXow
|
||||||
|
type: Opaque
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: cart-actor
|
||||||
|
arch: amd64
|
||||||
|
name: cart-actor-x86
|
||||||
|
spec:
|
||||||
|
replicas: 0
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: cart-actor
|
||||||
|
arch: amd64
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: cart-actor
|
||||||
|
actor-pool: cart
|
||||||
|
arch: amd64
|
||||||
|
spec:
|
||||||
|
affinity:
|
||||||
|
nodeAffinity:
|
||||||
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
nodeSelectorTerms:
|
||||||
|
- matchExpressions:
|
||||||
|
- key: kubernetes.io/arch
|
||||||
|
operator: NotIn
|
||||||
|
values:
|
||||||
|
- arm64
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
nfs:
|
||||||
|
path: /i-data/7a8af061/nfs/cart-actor-no
|
||||||
|
server: 10.10.1.10
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: regcred
|
||||||
|
serviceAccountName: default
|
||||||
|
containers:
|
||||||
|
- image: registry.knatofs.se/go-cart-actor-amd64:latest
|
||||||
|
name: cart-actor-amd64
|
||||||
|
imagePullPolicy: Always
|
||||||
|
lifecycle:
|
||||||
|
preStop:
|
||||||
|
exec:
|
||||||
|
command: ["sleep", "15"]
|
||||||
|
ports:
|
||||||
|
- containerPort: 8080
|
||||||
|
name: web
|
||||||
|
- containerPort: 1234
|
||||||
|
name: echo
|
||||||
|
- containerPort: 1337
|
||||||
|
name: rpc
|
||||||
|
- containerPort: 1338
|
||||||
|
name: quorum
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /livez
|
||||||
|
port: web
|
||||||
|
failureThreshold: 1
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /readyz
|
||||||
|
port: web
|
||||||
|
failureThreshold: 2
|
||||||
|
initialDelaySeconds: 2
|
||||||
|
periodSeconds: 10
|
||||||
|
volumeMounts:
|
||||||
|
- mountPath: "/data"
|
||||||
|
name: data
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: "768Mi"
|
||||||
|
requests:
|
||||||
|
memory: "70Mi"
|
||||||
|
cpu: "1200m"
|
||||||
|
env:
|
||||||
|
- name: TZ
|
||||||
|
value: "Europe/Stockholm"
|
||||||
|
- name: KLARNA_API_USERNAME
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: klarna-api-credentials
|
||||||
|
key: username
|
||||||
|
- name: KLARNA_API_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: klarna-api-credentials
|
||||||
|
key: password
|
||||||
|
- name: POD_IP
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: status.podIP
|
||||||
|
- name: AMQP_URL
|
||||||
|
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
|
||||||
|
- name: BASE_URL
|
||||||
|
value: "https://s10n-no.tornberg.me"
|
||||||
|
- name: CART_BASE_URL
|
||||||
|
value: "https://cart-no.tornberg.me"
|
||||||
|
- name: POD_NAME
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.name
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: cart-actor
|
||||||
|
arch: arm64
|
||||||
|
name: cart-actor-arm64
|
||||||
|
spec:
|
||||||
|
replicas: 3
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: cart-actor
|
||||||
|
arch: arm64
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: cart-actor
|
||||||
|
actor-pool: cart
|
||||||
|
arch: arm64
|
||||||
|
spec:
|
||||||
|
affinity:
|
||||||
|
nodeAffinity:
|
||||||
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
nodeSelectorTerms:
|
||||||
|
- matchExpressions:
|
||||||
|
- key: kubernetes.io/hostname
|
||||||
|
operator: NotIn
|
||||||
|
values:
|
||||||
|
- masterpi
|
||||||
|
- key: kubernetes.io/arch
|
||||||
|
operator: In
|
||||||
|
values:
|
||||||
|
- arm64
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
nfs:
|
||||||
|
path: /i-data/7a8af061/nfs/cart-actor-no
|
||||||
|
server: 10.10.1.10
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: regcred
|
||||||
|
serviceAccountName: default
|
||||||
|
containers:
|
||||||
|
- image: registry.knatofs.se/go-cart-actor:latest
|
||||||
|
name: cart-actor-arm64
|
||||||
|
imagePullPolicy: Always
|
||||||
|
lifecycle:
|
||||||
|
preStop:
|
||||||
|
exec:
|
||||||
|
command: ["sleep", "15"]
|
||||||
|
ports:
|
||||||
|
- containerPort: 8080
|
||||||
|
name: web
|
||||||
|
- containerPort: 1234
|
||||||
|
name: echo
|
||||||
|
- containerPort: 1337
|
||||||
|
name: rpc
|
||||||
|
- containerPort: 1338
|
||||||
|
name: quorum
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /livez
|
||||||
|
port: web
|
||||||
|
failureThreshold: 1
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /readyz
|
||||||
|
port: web
|
||||||
|
failureThreshold: 2
|
||||||
|
initialDelaySeconds: 2
|
||||||
|
periodSeconds: 10
|
||||||
|
volumeMounts:
|
||||||
|
- mountPath: "/data"
|
||||||
|
name: data
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: "768Mi"
|
||||||
|
requests:
|
||||||
|
memory: "70Mi"
|
||||||
|
cpu: "1200m"
|
||||||
|
env:
|
||||||
|
- name: TZ
|
||||||
|
value: "Europe/Stockholm"
|
||||||
|
- name: KLARNA_API_USERNAME
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: klarna-api-credentials
|
||||||
|
key: username
|
||||||
|
- name: KLARNA_API_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: klarna-api-credentials
|
||||||
|
key: password
|
||||||
|
- name: POD_IP
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: status.podIP
|
||||||
|
- name: AMQP_URL
|
||||||
|
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
|
||||||
|
- name: BASE_URL
|
||||||
|
value: "https://s10n-no.tornberg.me"
|
||||||
|
- name: CART_BASE_URL
|
||||||
|
value: "https://cart-no.tornberg.me"
|
||||||
|
- name: POD_NAME
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.name
|
||||||
|
---
|
||||||
|
kind: Service
|
||||||
|
apiVersion: v1
|
||||||
|
metadata:
|
||||||
|
name: cart-echo
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: cart-actor
|
||||||
|
type: LoadBalancer
|
||||||
|
ports:
|
||||||
|
- name: echo
|
||||||
|
port: 1234
|
||||||
|
---
|
||||||
|
kind: Service
|
||||||
|
apiVersion: v1
|
||||||
|
metadata:
|
||||||
|
name: cart-actor
|
||||||
|
annotations:
|
||||||
|
prometheus.io/port: "8080"
|
||||||
|
prometheus.io/scrape: "true"
|
||||||
|
prometheus.io/path: "/metrics"
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: cart-actor
|
||||||
|
ports:
|
||||||
|
- name: web
|
||||||
|
port: 8080
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: cart-ingress
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
# nginx.ingress.kubernetes.io/affinity: "cookie"
|
||||||
|
# nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity"
|
||||||
|
# nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
|
||||||
|
# nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-body-size: 4m
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- cart-no.tornberg.me
|
||||||
|
secretName: cart-actor-no-tls-secret
|
||||||
|
rules:
|
||||||
|
- host: cart-no.tornberg.me
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: cart-actor
|
||||||
|
port:
|
||||||
|
number: 8080
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
name: klarna-api-credentials
|
name: klarna-api-credentials
|
||||||
data:
|
data:
|
||||||
username: ZjQzZDY3YjEtNzA2Yy00NTk2LTliNTgtYjg1YjU2NDEwZTUw
|
username: ZjQzZDY3YjEtNzA2Yy00NTk2LTliNTgtYjg1YjU2NDEwZTUw
|
||||||
password: a2xhcm5hX3Rlc3RfYXBpX0trUWhWVE5yYVZsV2FsTnhTRVp3Y1ZSSFF5UkVNRmxyY25Kd1AxSndQMGdzWmpRelpEWTNZakV0TnpBMll5MDBOVGsyTFRsaU5UZ3RZamcxWWpVMk5ERXdaVFV3TERFc2JUUkNjRFpWU1RsTllsSk1aMlEyVEc4MmRVODNZMkozUlRaaFdEZDViV3AwYkhGV1JqTjVNQzlaYXow
|
password: a2xhcm5hX3Rlc3RfYXBpX0trUWhWVE5yYVZsV2FsTnhTRVp3Y1ZSSFF5UkVNRmxyY25Kd1AxSndQMGdzWmpRelpEWTNZakV0TnpBMll5MDBOVGsyTFRsaU5UZ3RZamcxWWpVMk5ERXdaVFV3TERFc2JUUkNjRFpWU1RsTllsSk1aMlEyVEc4MmRVODNZMkozUlRaaFdEZDViV3AwYkhGV1JqTjVNQzlaYXow
|
||||||
@@ -9,103 +9,13 @@ type: Opaque
|
|||||||
---
|
---
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: cart-backoffice
|
|
||||||
arch: amd64
|
|
||||||
name: cart-backoffice-x86
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: cart-backoffice
|
|
||||||
arch: amd64
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: cart-backoffice
|
|
||||||
arch: amd64
|
|
||||||
spec:
|
|
||||||
affinity:
|
|
||||||
nodeAffinity:
|
|
||||||
requiredDuringSchedulingIgnoredDuringExecution:
|
|
||||||
nodeSelectorTerms:
|
|
||||||
- matchExpressions:
|
|
||||||
- key: kubernetes.io/arch
|
|
||||||
operator: NotIn
|
|
||||||
values:
|
|
||||||
- arm64
|
|
||||||
volumes:
|
|
||||||
- name: data
|
|
||||||
nfs:
|
|
||||||
path: /i-data/7a8af061/nfs/cart-actor
|
|
||||||
server: 10.10.1.10
|
|
||||||
serviceAccountName: default
|
|
||||||
containers:
|
|
||||||
- image: registry.k6n.net/go-cart-actor-amd64:latest
|
|
||||||
name: cart-actor-amd64
|
|
||||||
imagePullPolicy: Always
|
|
||||||
command: ["/go-cart-backoffice"]
|
|
||||||
lifecycle:
|
|
||||||
preStop:
|
|
||||||
exec:
|
|
||||||
command: ["sleep", "15"]
|
|
||||||
ports:
|
|
||||||
- containerPort: 8080
|
|
||||||
name: web
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /livez
|
|
||||||
port: web
|
|
||||||
failureThreshold: 1
|
|
||||||
periodSeconds: 30
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /readyz
|
|
||||||
port: web
|
|
||||||
failureThreshold: 2
|
|
||||||
initialDelaySeconds: 2
|
|
||||||
periodSeconds: 30
|
|
||||||
volumeMounts:
|
|
||||||
- mountPath: "/data"
|
|
||||||
name: data
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: "768Mi"
|
|
||||||
requests:
|
|
||||||
memory: "70Mi"
|
|
||||||
cpu: "1200m"
|
|
||||||
env:
|
|
||||||
- name: TZ
|
|
||||||
value: "Europe/Stockholm"
|
|
||||||
- name: REDIS_ADDRESS
|
|
||||||
value: "10.10.3.18:6379"
|
|
||||||
- name: REDIS_PASSWORD
|
|
||||||
value: "slaskredis"
|
|
||||||
- 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: AMQP_URL
|
|
||||||
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
|
|
||||||
# - name: BASE_URL
|
|
||||||
# value: "https://s10n-no.tornberg.me"
|
|
||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app: cart-actor
|
app: cart-actor
|
||||||
arch: amd64
|
arch: amd64
|
||||||
name: cart-actor-x86
|
name: cart-actor-x86
|
||||||
spec:
|
spec:
|
||||||
replicas: 3
|
replicas: 0
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: cart-actor
|
app: cart-actor
|
||||||
@@ -122,18 +32,20 @@ spec:
|
|||||||
requiredDuringSchedulingIgnoredDuringExecution:
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
nodeSelectorTerms:
|
nodeSelectorTerms:
|
||||||
- matchExpressions:
|
- matchExpressions:
|
||||||
- key: kubernetes.io/arch
|
- key: kubernetes.io/arch
|
||||||
operator: NotIn
|
operator: NotIn
|
||||||
values:
|
values:
|
||||||
- arm64
|
- arm64
|
||||||
volumes:
|
volumes:
|
||||||
- name: data
|
- name: data
|
||||||
nfs:
|
nfs:
|
||||||
path: /i-data/7a8af061/nfs/cart-actor
|
path: /i-data/7a8af061/nfs/cart-actor
|
||||||
server: 10.10.1.10
|
server: 10.10.1.10
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: regcred
|
||||||
serviceAccountName: default
|
serviceAccountName: default
|
||||||
containers:
|
containers:
|
||||||
- image: registry.k6n.net/go-cart-actor-amd64:latest
|
- image: registry.knatofs.se/go-cart-actor-amd64:latest
|
||||||
name: cart-actor-amd64
|
name: cart-actor-amd64
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
lifecycle:
|
lifecycle:
|
||||||
@@ -143,23 +55,25 @@ spec:
|
|||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
name: web
|
name: web
|
||||||
- containerPort: 8081
|
- containerPort: 1234
|
||||||
name: debug
|
name: echo
|
||||||
- containerPort: 1337
|
- containerPort: 1337
|
||||||
name: rpc
|
name: rpc
|
||||||
|
- containerPort: 1338
|
||||||
|
name: quorum
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /livez
|
path: /livez
|
||||||
port: web
|
port: web
|
||||||
failureThreshold: 1
|
failureThreshold: 1
|
||||||
periodSeconds: 30
|
periodSeconds: 10
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /readyz
|
path: /readyz
|
||||||
port: web
|
port: web
|
||||||
failureThreshold: 2
|
failureThreshold: 2
|
||||||
initialDelaySeconds: 2
|
initialDelaySeconds: 2
|
||||||
periodSeconds: 50
|
periodSeconds: 10
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- mountPath: "/data"
|
- mountPath: "/data"
|
||||||
name: data
|
name: data
|
||||||
@@ -177,14 +91,6 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: klarna-api-credentials
|
name: klarna-api-credentials
|
||||||
key: username
|
key: username
|
||||||
- name: REDIS_ADDRESS
|
|
||||||
value: "10.10.3.18:6379"
|
|
||||||
- name: REDIS_PASSWORD
|
|
||||||
value: "slaskredis"
|
|
||||||
- name: OTEL_RESOURCE_ATTRIBUTES
|
|
||||||
value: "service.name=cart,service.version=0.1.2"
|
|
||||||
- name: OTEL_EXPORTER_OTLP_ENDPOINT
|
|
||||||
value: "http://otel-debug-service.monitoring:4317"
|
|
||||||
- name: KLARNA_API_PASSWORD
|
- name: KLARNA_API_PASSWORD
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
@@ -228,22 +134,24 @@ spec:
|
|||||||
requiredDuringSchedulingIgnoredDuringExecution:
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
nodeSelectorTerms:
|
nodeSelectorTerms:
|
||||||
- matchExpressions:
|
- matchExpressions:
|
||||||
- key: kubernetes.io/hostname
|
- key: kubernetes.io/hostname
|
||||||
operator: NotIn
|
operator: NotIn
|
||||||
values:
|
values:
|
||||||
- masterpi
|
- masterpi
|
||||||
- key: kubernetes.io/arch
|
- key: kubernetes.io/arch
|
||||||
operator: In
|
operator: In
|
||||||
values:
|
values:
|
||||||
- arm64
|
- arm64
|
||||||
volumes:
|
volumes:
|
||||||
- name: data
|
- name: data
|
||||||
nfs:
|
nfs:
|
||||||
path: /i-data/7a8af061/nfs/cart-actor
|
path: /i-data/7a8af061/nfs/cart-actor
|
||||||
server: 10.10.1.10
|
server: 10.10.1.10
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: regcred
|
||||||
serviceAccountName: default
|
serviceAccountName: default
|
||||||
containers:
|
containers:
|
||||||
- image: registry.k6n.net/go-cart-actor:latest
|
- image: registry.knatofs.se/go-cart-actor:latest
|
||||||
name: cart-actor-arm64
|
name: cart-actor-arm64
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
lifecycle:
|
lifecycle:
|
||||||
@@ -253,23 +161,25 @@ spec:
|
|||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
name: web
|
name: web
|
||||||
- containerPort: 8081
|
- containerPort: 1234
|
||||||
name: debug
|
name: echo
|
||||||
- containerPort: 1337
|
- containerPort: 1337
|
||||||
name: rpc
|
name: rpc
|
||||||
|
- containerPort: 1338
|
||||||
|
name: quorum
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /livez
|
path: /livez
|
||||||
port: web
|
port: web
|
||||||
failureThreshold: 1
|
failureThreshold: 1
|
||||||
periodSeconds: 30
|
periodSeconds: 10
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /readyz
|
path: /readyz
|
||||||
port: web
|
port: web
|
||||||
failureThreshold: 2
|
failureThreshold: 2
|
||||||
initialDelaySeconds: 2
|
initialDelaySeconds: 2
|
||||||
periodSeconds: 30
|
periodSeconds: 10
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- mountPath: "/data"
|
- mountPath: "/data"
|
||||||
name: data
|
name: data
|
||||||
@@ -282,14 +192,6 @@ spec:
|
|||||||
env:
|
env:
|
||||||
- name: TZ
|
- name: TZ
|
||||||
value: "Europe/Stockholm"
|
value: "Europe/Stockholm"
|
||||||
- name: REDIS_ADDRESS
|
|
||||||
value: "redis.home:6379"
|
|
||||||
- name: REDIS_PASSWORD
|
|
||||||
value: "slaskredis"
|
|
||||||
- name: OTEL_RESOURCE_ATTRIBUTES
|
|
||||||
value: "service.name=cart,service.version=0.1.2"
|
|
||||||
- name: OTEL_EXPORTER_OTLP_ENDPOINT
|
|
||||||
value: "http://otel-debug-service.monitoring:4317"
|
|
||||||
- name: KLARNA_API_USERNAME
|
- name: KLARNA_API_USERNAME
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
@@ -315,10 +217,22 @@ spec:
|
|||||||
---
|
---
|
||||||
kind: Service
|
kind: Service
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
|
metadata:
|
||||||
|
name: cart-echo
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: cart-actor
|
||||||
|
type: LoadBalancer
|
||||||
|
ports:
|
||||||
|
- name: echo
|
||||||
|
port: 1234
|
||||||
|
---
|
||||||
|
kind: Service
|
||||||
|
apiVersion: v1
|
||||||
metadata:
|
metadata:
|
||||||
name: cart-actor
|
name: cart-actor
|
||||||
annotations:
|
annotations:
|
||||||
prometheus.io/port: "8081"
|
prometheus.io/port: "8080"
|
||||||
prometheus.io/scrape: "true"
|
prometheus.io/scrape: "true"
|
||||||
prometheus.io/path: "/metrics"
|
prometheus.io/path: "/metrics"
|
||||||
spec:
|
spec:
|
||||||
@@ -328,36 +242,25 @@ spec:
|
|||||||
- name: web
|
- name: web
|
||||||
port: 8080
|
port: 8080
|
||||||
---
|
---
|
||||||
kind: Service
|
|
||||||
apiVersion: v1
|
|
||||||
metadata:
|
|
||||||
name: cart-backoffice
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: cart-backoffice
|
|
||||||
ports:
|
|
||||||
- name: web
|
|
||||||
port: 8080
|
|
||||||
---
|
|
||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: Ingress
|
kind: Ingress
|
||||||
metadata:
|
metadata:
|
||||||
name: cart-ingress
|
name: cart-ingress
|
||||||
annotations:
|
annotations:
|
||||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
nginx.ingress.kubernetes.io/affinity: "cookie"
|
# nginx.ingress.kubernetes.io/affinity: "cookie"
|
||||||
nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity"
|
# nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity"
|
||||||
nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
|
# nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
|
||||||
nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
|
# nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
|
||||||
nginx.ingress.kubernetes.io/proxy-body-size: 4m
|
nginx.ingress.kubernetes.io/proxy-body-size: 4m
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: nginx
|
ingressClassName: nginx
|
||||||
tls:
|
tls:
|
||||||
- hosts:
|
- hosts:
|
||||||
- cart.k6n.net
|
- cart.tornberg.me
|
||||||
secretName: cart-actor-tls-secret
|
secretName: cart-actor-tls-secret
|
||||||
rules:
|
rules:
|
||||||
- host: cart.k6n.net
|
- host: cart.tornberg.me
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
- path: /
|
- path: /
|
||||||
@@ -366,99 +269,4 @@ spec:
|
|||||||
service:
|
service:
|
||||||
name: cart-actor
|
name: cart-actor
|
||||||
port:
|
port:
|
||||||
number: 8080
|
number: 8080
|
||||||
---
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: cart-backend-ingress
|
|
||||||
annotations:
|
|
||||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
|
||||||
spec:
|
|
||||||
ingressClassName: nginx
|
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- slask-cart.k6n.net
|
|
||||||
secretName: cart-backoffice-actor-tls-secret
|
|
||||||
rules:
|
|
||||||
- host: slask-cart.k6n.net
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: cart-backoffice
|
|
||||||
port:
|
|
||||||
number: 8080
|
|
||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: cart-inventory
|
|
||||||
arch: amd64
|
|
||||||
name: cart-inventory-x86
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: cart-inventory
|
|
||||||
arch: amd64
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: cart-inventory
|
|
||||||
arch: amd64
|
|
||||||
spec:
|
|
||||||
affinity:
|
|
||||||
nodeAffinity:
|
|
||||||
requiredDuringSchedulingIgnoredDuringExecution:
|
|
||||||
nodeSelectorTerms:
|
|
||||||
- matchExpressions:
|
|
||||||
- key: kubernetes.io/arch
|
|
||||||
operator: NotIn
|
|
||||||
values:
|
|
||||||
- arm64
|
|
||||||
serviceAccountName: default
|
|
||||||
containers:
|
|
||||||
- image: registry.k6n.net/go-cart-actor-amd64:latest
|
|
||||||
name: cart-inventory-amd64
|
|
||||||
imagePullPolicy: Always
|
|
||||||
command: ["/go-cart-inventory"]
|
|
||||||
lifecycle:
|
|
||||||
preStop:
|
|
||||||
exec:
|
|
||||||
command: ["sleep", "15"]
|
|
||||||
ports:
|
|
||||||
- containerPort: 8080
|
|
||||||
name: web
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /livez
|
|
||||||
port: web
|
|
||||||
failureThreshold: 1
|
|
||||||
periodSeconds: 30
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /readyz
|
|
||||||
port: web
|
|
||||||
failureThreshold: 2
|
|
||||||
initialDelaySeconds: 2
|
|
||||||
periodSeconds: 30
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: "256Mi"
|
|
||||||
cpu: "500m"
|
|
||||||
requests:
|
|
||||||
memory: "50Mi"
|
|
||||||
cpu: "500m"
|
|
||||||
env:
|
|
||||||
- name: TZ
|
|
||||||
value: "Europe/Stockholm"
|
|
||||||
- name: RABBIT_HOST
|
|
||||||
value: amqp://admin:12bananer@rabbitmq.s10n:5672/
|
|
||||||
- name: REDIS_ADDRESS
|
|
||||||
value: "redis.home:6379"
|
|
||||||
- name: REDIS_PASSWORD
|
|
||||||
value: "slaskredis"
|
|
||||||
@@ -1,101 +1,25 @@
|
|||||||
apiVersion: autoscaling/v2
|
apiVersion: autoscaling/v1
|
||||||
kind: HorizontalPodAutoscaler
|
kind: HorizontalPodAutoscaler
|
||||||
metadata:
|
metadata:
|
||||||
name: cart-scaler-amd
|
name: cart-scaler-amd
|
||||||
spec:
|
spec:
|
||||||
scaleTargetRef:
|
scaleTargetRef:
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
name: cart-actor-x86
|
name: cart-actor-x86
|
||||||
minReplicas: 3
|
minReplicas: 3
|
||||||
maxReplicas: 9
|
maxReplicas: 9
|
||||||
behavior:
|
targetCPUUtilizationPercentage: 30
|
||||||
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/v2
|
apiVersion: autoscaling/v1
|
||||||
kind: HorizontalPodAutoscaler
|
kind: HorizontalPodAutoscaler
|
||||||
metadata:
|
metadata:
|
||||||
name: cart-scaler-arm
|
name: cart-scaler-arm
|
||||||
spec:
|
spec:
|
||||||
scaleTargetRef:
|
scaleTargetRef:
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
name: cart-actor-arm64
|
name: cart-actor-arm64
|
||||||
minReplicas: 3
|
minReplicas: 3
|
||||||
maxReplicas: 9
|
maxReplicas: 9
|
||||||
behavior:
|
targetCPUUtilizationPercentage: 30
|
||||||
scaleUp:
|
|
||||||
stabilizationWindowSeconds: 60
|
|
||||||
policies:
|
|
||||||
- type: Percent
|
|
||||||
value: 100
|
|
||||||
periodSeconds: 60
|
|
||||||
scaleDown:
|
|
||||||
stabilizationWindowSeconds: 180
|
|
||||||
policies:
|
|
||||||
- type: Percent
|
|
||||||
value: 50
|
|
||||||
periodSeconds: 60
|
|
||||||
metrics:
|
|
||||||
- type: Resource
|
|
||||||
resource:
|
|
||||||
name: cpu
|
|
||||||
target:
|
|
||||||
type: Utilization
|
|
||||||
averageUtilization: 50
|
|
||||||
# Future custom metric (example):
|
|
||||||
# - type: Pods
|
|
||||||
# pods:
|
|
||||||
# metric:
|
|
||||||
# name: cart_mutations_per_second
|
|
||||||
# target:
|
|
||||||
# type: AverageValue
|
|
||||||
# averageValue: "15"
|
|
||||||
# - type: Object
|
|
||||||
# object:
|
|
||||||
# describedObject:
|
|
||||||
# apiVersion: networking.k8s.io/v1
|
|
||||||
# kind: Ingress
|
|
||||||
# name: cart-ingress
|
|
||||||
# metric:
|
|
||||||
# name: http_requests_per_second
|
|
||||||
# target:
|
|
||||||
# type: Value
|
|
||||||
# value: "100"
|
|
||||||
84
discarded-host.go
Normal file
84
discarded-host.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DiscardedHost struct {
|
||||||
|
Host string
|
||||||
|
Tries int
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiscardedHostHandler struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
port int
|
||||||
|
hosts []*DiscardedHost
|
||||||
|
onConnection *func(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DiscardedHostHandler) run() {
|
||||||
|
for range time.Tick(time.Second) {
|
||||||
|
d.mu.RLock()
|
||||||
|
lst := make([]*DiscardedHost, 0, len(d.hosts))
|
||||||
|
for _, host := range d.hosts {
|
||||||
|
if host.Tries >= 0 && host.Tries < 5 {
|
||||||
|
go d.testConnection(host)
|
||||||
|
lst = append(lst, host)
|
||||||
|
} else {
|
||||||
|
if host.Tries > 0 {
|
||||||
|
log.Printf("Host %s discarded after %d tries", host.Host, host.Tries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.mu.RUnlock()
|
||||||
|
d.mu.Lock()
|
||||||
|
d.hosts = lst
|
||||||
|
d.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DiscardedHostHandler) testConnection(host *DiscardedHost) {
|
||||||
|
addr := fmt.Sprintf("%s:%d", host.Host, d.port)
|
||||||
|
conn, err := net.Dial("tcp", addr)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
host.Tries++
|
||||||
|
if host.Tries >= 5 {
|
||||||
|
// Exceeded retry threshold; will be dropped by run loop.
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
conn.Close()
|
||||||
|
if d.onConnection != nil {
|
||||||
|
fn := *d.onConnection
|
||||||
|
fn(host.Host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDiscardedHostHandler(port int) *DiscardedHostHandler {
|
||||||
|
ret := &DiscardedHostHandler{
|
||||||
|
hosts: make([]*DiscardedHost, 0),
|
||||||
|
port: port,
|
||||||
|
}
|
||||||
|
go ret.run()
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DiscardedHostHandler) SetReconnectHandler(fn func(string)) {
|
||||||
|
d.onConnection = &fn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DiscardedHostHandler) AppendHost(host string) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
log.Printf("Adding host %s to retry list", host)
|
||||||
|
d.hosts = append(d.hosts, &DiscardedHost{
|
||||||
|
Host: host,
|
||||||
|
Tries: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
package discovery
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"slices"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
v1 "k8s.io/api/core/v1"
|
v1 "k8s.io/api/core/v1"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
@@ -13,6 +11,11 @@ import (
|
|||||||
toolsWatch "k8s.io/client-go/tools/watch"
|
toolsWatch "k8s.io/client-go/tools/watch"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Discovery interface {
|
||||||
|
Discover() ([]string, error)
|
||||||
|
Watch() (<-chan HostChange, error)
|
||||||
|
}
|
||||||
|
|
||||||
type K8sDiscovery struct {
|
type K8sDiscovery struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
client *kubernetes.Clientset
|
client *kubernetes.Clientset
|
||||||
@@ -35,17 +38,20 @@ func (k *K8sDiscovery) DiscoverInNamespace(namespace string) ([]string, error) {
|
|||||||
return hosts, nil
|
return hosts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HostChange struct {
|
||||||
|
Host string
|
||||||
|
Type watch.EventType
|
||||||
|
}
|
||||||
|
|
||||||
func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
|
func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
|
||||||
timeout := int64(30)
|
timeout := int64(30)
|
||||||
ipsThatAreReady := make(map[string]bool)
|
|
||||||
m := sync.Mutex{}
|
|
||||||
watcherFn := func(options metav1.ListOptions) (watch.Interface, error) {
|
watcherFn := func(options metav1.ListOptions) (watch.Interface, error) {
|
||||||
return k.client.CoreV1().Pods("").Watch(k.ctx, metav1.ListOptions{
|
return k.client.CoreV1().Pods("").Watch(k.ctx, metav1.ListOptions{
|
||||||
LabelSelector: "actor-pool=cart",
|
LabelSelector: "actor-pool=cart",
|
||||||
TimeoutSeconds: &timeout,
|
TimeoutSeconds: &timeout,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
watcher, err := toolsWatch.NewRetryWatcherWithContext(k.ctx, "1", &cache.ListWatch{WatchFunc: watcherFn})
|
watcher, err := toolsWatch.NewRetryWatcher("1", &cache.ListWatch{WatchFunc: watcherFn})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -54,22 +60,9 @@ func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
|
|||||||
for event := range watcher.ResultChan() {
|
for event := range watcher.ResultChan() {
|
||||||
|
|
||||||
pod := event.Object.(*v1.Pod)
|
pod := event.Object.(*v1.Pod)
|
||||||
isReady := slices.ContainsFunc(pod.Status.Conditions, func(condition v1.PodCondition) bool {
|
|
||||||
return condition.Type == v1.PodReady && condition.Status == v1.ConditionTrue
|
|
||||||
})
|
|
||||||
m.Lock()
|
|
||||||
oldState := ipsThatAreReady[pod.Status.PodIP]
|
|
||||||
ipsThatAreReady[pod.Status.PodIP] = isReady
|
|
||||||
m.Unlock()
|
|
||||||
if oldState != isReady {
|
|
||||||
ch <- HostChange{
|
|
||||||
Host: pod.Status.PodIP,
|
|
||||||
IsReady: isReady,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ch <- HostChange{
|
ch <- HostChange{
|
||||||
Host: pod.Status.PodIP,
|
Host: pod.Status.PodIP,
|
||||||
IsReady: isReady,
|
Type: event.Type,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package discovery
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
119
disk-storage.go
Normal file
119
disk-storage.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/gob"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DiskStorage struct {
|
||||||
|
stateFile string
|
||||||
|
lastSave int64
|
||||||
|
LastSaves map[CartId]int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDiskStorage(stateFile string) (*DiskStorage, error) {
|
||||||
|
ret := &DiskStorage{
|
||||||
|
stateFile: stateFile,
|
||||||
|
LastSaves: make(map[CartId]int64),
|
||||||
|
}
|
||||||
|
err := ret.loadState()
|
||||||
|
return ret, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveMessages(messages []StorableMessage, id CartId) error {
|
||||||
|
|
||||||
|
if len(messages) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Printf("%d messages to save for grain id %s", len(messages), id)
|
||||||
|
var file *os.File
|
||||||
|
var err error
|
||||||
|
path := getCartPath(id.String())
|
||||||
|
file, err = os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
for _, m := range messages {
|
||||||
|
err := m.Write(file)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCartPath(id string) string {
|
||||||
|
return fmt.Sprintf("data/%s.prot", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMessages(grain Grain, id CartId) error {
|
||||||
|
var err error
|
||||||
|
path := getCartPath(id.String())
|
||||||
|
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
for err == nil {
|
||||||
|
var msg Message
|
||||||
|
err = ReadMessage(file, &msg)
|
||||||
|
if err == nil {
|
||||||
|
grain.HandleMessage(&msg, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err.Error() == "EOF" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage) saveState() error {
|
||||||
|
tmpFile := s.stateFile + "_tmp"
|
||||||
|
file, err := os.Create(tmpFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
err = gob.NewEncoder(file).Encode(s.LastSaves)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
os.Remove(s.stateFile + ".bak")
|
||||||
|
os.Rename(s.stateFile, s.stateFile+".bak")
|
||||||
|
return os.Rename(tmpFile, s.stateFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage) loadState() error {
|
||||||
|
file, err := os.Open(s.stateFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
return gob.NewDecoder(file).Decode(&s.LastSaves)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage) Store(id CartId, grain *CartGrain) error {
|
||||||
|
lastSavedMessage, ok := s.LastSaves[id]
|
||||||
|
if ok && lastSavedMessage > grain.GetLastChange() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := saveMessages(grain.GetStorageMessage(lastSavedMessage), id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ts := time.Now().Unix()
|
||||||
|
s.LastSaves[id] = ts
|
||||||
|
s.lastSave = ts
|
||||||
|
return nil
|
||||||
|
}
|
||||||
102
frames.go
Normal file
102
frames.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Minimal frame abstractions retained after removal of the legacy TCP/frame
|
||||||
|
// networking layer. These types remain only to avoid a wide cascading refactor
|
||||||
|
// across existing grain / pool logic that still constructs and passes
|
||||||
|
// FrameWithPayload objects internally.
|
||||||
|
//
|
||||||
|
// The original responsibilities this replaces:
|
||||||
|
// - Binary framing, checksums, network IO
|
||||||
|
// - Distinction between request / reply frame types
|
||||||
|
//
|
||||||
|
// What remains:
|
||||||
|
// - A light weight container (FrameWithPayload) used as an in‑process
|
||||||
|
// envelope for status code + typed marker + payload bytes (JSON or proto).
|
||||||
|
// - Message / status constants referenced in existing code paths.
|
||||||
|
//
|
||||||
|
// Recommended future cleanup (post‑migration):
|
||||||
|
// - Remove FrameType entirely and replace with enumerated semantic results
|
||||||
|
// or error values.
|
||||||
|
// - Replace FrameWithPayload with a struct { Status int; Data []byte }.
|
||||||
|
// - Remove remote_* reply type branching once all callers rely on gRPC
|
||||||
|
// status + strongly typed responses.
|
||||||
|
//
|
||||||
|
// For now we keep this minimal surface to keep the gRPC migration focused.
|
||||||
|
|
||||||
|
type (
|
||||||
|
// FrameType is a symbolic identifier carried through existing code paths.
|
||||||
|
// No ordering or bit semantics are required anymore.
|
||||||
|
FrameType uint32
|
||||||
|
StatusCode uint32
|
||||||
|
)
|
||||||
|
|
||||||
|
type Frame struct {
|
||||||
|
Type FrameType
|
||||||
|
StatusCode StatusCode
|
||||||
|
Length uint32
|
||||||
|
// Checksum retained for compatibility; no longer validated.
|
||||||
|
Checksum uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// FrameWithPayload wraps a Frame with an opaque payload.
|
||||||
|
// Payload usually contains JSON encoded cart state or an error message.
|
||||||
|
type FrameWithPayload struct {
|
||||||
|
Frame
|
||||||
|
Payload []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Legacy Frame Type Constants (minimal subset still referenced)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
const (
|
||||||
|
RemoteGetState = FrameType(0x01)
|
||||||
|
RemoteHandleMutation = FrameType(0x02)
|
||||||
|
ResponseBody = FrameType(0x03) // (rarely used; kept for completeness)
|
||||||
|
RemoteGetStateReply = FrameType(0x04)
|
||||||
|
RemoteHandleMutationReply = FrameType(0x05)
|
||||||
|
RemoteCreateOrderReply = FrameType(0x06)
|
||||||
|
)
|
||||||
|
|
||||||
|
// MakeFrameWithPayload constructs an in‑process frame wrapper.
|
||||||
|
// Length & Checksum are filled for backward compatibility (no validation logic
|
||||||
|
// depends on the checksum anymore).
|
||||||
|
func MakeFrameWithPayload(msg FrameType, statusCode StatusCode, payload []byte) FrameWithPayload {
|
||||||
|
length := uint32(len(payload))
|
||||||
|
return FrameWithPayload{
|
||||||
|
Frame: Frame{
|
||||||
|
Type: msg,
|
||||||
|
StatusCode: statusCode,
|
||||||
|
Length: length,
|
||||||
|
Checksum: (uint32(msg) + uint32(statusCode) + length) / 8, // simple legacy formula
|
||||||
|
},
|
||||||
|
Payload: payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone creates a shallow copy of the frame, duplicating the payload slice.
|
||||||
|
func (f *FrameWithPayload) Clone() *FrameWithPayload {
|
||||||
|
if f == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cp := make([]byte, len(f.Payload))
|
||||||
|
copy(cp, f.Payload)
|
||||||
|
return &FrameWithPayload{
|
||||||
|
Frame: f.Frame,
|
||||||
|
Payload: cp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewErrorFrame helper for creating an error frame with a textual payload.
|
||||||
|
func NewErrorFrame(msg FrameType, code StatusCode, err error) FrameWithPayload {
|
||||||
|
var b []byte
|
||||||
|
if err != nil {
|
||||||
|
b = []byte(err.Error())
|
||||||
|
}
|
||||||
|
return MakeFrameWithPayload(msg, code, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSuccess returns true if the status code indicates success in the
|
||||||
|
// conventional HTTP style range (200–299). This mirrors previous usage patterns.
|
||||||
|
func (f *FrameWithPayload) IsSuccess() bool {
|
||||||
|
return f != nil && f.StatusCode >= 200 && f.StatusCode < 300
|
||||||
|
}
|
||||||
118
go.mod
118
go.mod
@@ -1,117 +1,73 @@
|
|||||||
module git.k6n.net/go-cart-actor
|
module git.tornberg.me/go-cart-actor
|
||||||
|
|
||||||
go 1.25.4
|
go 1.25.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gogo/protobuf v1.3.2
|
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/matst80/go-redis-inventory v0.0.0-20251125181530-7ebbc97e3841
|
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548
|
||||||
github.com/matst80/slask-finder v0.0.0-20251125182907-9e57f193127a
|
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/rabbitmq/amqp091-go v1.10.0
|
github.com/rabbitmq/amqp091-go v1.10.0
|
||||||
github.com/redis/go-redis/v9 v9.17.0
|
google.golang.org/grpc v1.76.0
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0
|
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0
|
|
||||||
go.opentelemetry.io/otel v1.38.0
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0
|
|
||||||
go.opentelemetry.io/otel/log v0.14.0
|
|
||||||
go.opentelemetry.io/otel/metric v1.38.0
|
|
||||||
go.opentelemetry.io/otel/sdk v1.38.0
|
|
||||||
go.opentelemetry.io/otel/sdk/log v0.14.0
|
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.38.0
|
|
||||||
go.opentelemetry.io/otel/trace v1.38.0
|
|
||||||
google.golang.org/grpc v1.77.0
|
|
||||||
google.golang.org/protobuf v1.36.10
|
google.golang.org/protobuf v1.36.10
|
||||||
k8s.io/api v0.34.2
|
k8s.io/api v0.34.1
|
||||||
k8s.io/apimachinery v0.34.2
|
k8s.io/apimachinery v0.34.1
|
||||||
k8s.io/client-go v0.34.2
|
k8s.io/client-go v0.34.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/RoaringBitmap/roaring/v2 v2.14.4 // indirect
|
github.com/RoaringBitmap/roaring/v2 v2.10.0 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
github.com/bits-and-blooms/bitset v1.24.0 // indirect
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
|
||||||
github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589 // indirect
|
|
||||||
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||||
github.com/getkin/kin-openapi v0.133.0 // indirect
|
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-openapi/jsonpointer v0.22.1 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.22.3 // indirect
|
github.com/go-openapi/jsonreference v0.21.2 // indirect
|
||||||
github.com/go-openapi/jsonreference v0.21.3 // indirect
|
github.com/go-openapi/swag v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag v0.25.4 // indirect
|
github.com/go-openapi/swag/cmdutils v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
|
github.com/go-openapi/swag/conv v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag/conv v0.25.4 // indirect
|
github.com/go-openapi/swag/fileutils v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag/fileutils v0.25.4 // indirect
|
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
|
github.com/go-openapi/swag/loading v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag/loading v0.25.4 // indirect
|
github.com/go-openapi/swag/mangling v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag/mangling v0.25.4 // indirect
|
github.com/go-openapi/swag/netutils v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag/netutils v0.25.4 // indirect
|
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
|
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
|
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/google/gnostic-models v0.7.1 // indirect
|
github.com/google/gnostic-models v0.7.0 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/gorilla/schema v1.4.1 // indirect
|
github.com/gorilla/schema v1.4.1 // indirect
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
|
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/mailru/easyjson v0.9.1 // indirect
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
|
||||||
github.com/mschoch/smat v0.2.0 // indirect
|
github.com/mschoch/smat v0.2.0 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 // indirect
|
github.com/pmezard/go-difflib v1.0.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.1-0.20181226105442-5d4384ee4fb2 // indirect
|
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.4 // indirect
|
github.com/prometheus/common v0.67.1 // indirect
|
||||||
github.com/prometheus/procfs v0.19.2 // indirect
|
github.com/prometheus/procfs v0.17.0 // indirect
|
||||||
github.com/speakeasy-api/jsonpath v0.6.2 // indirect
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
github.com/speakeasy-api/openapi-overlay v0.10.3 // indirect
|
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
|
||||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
|
||||||
github.com/woodsbury/decimal128 v1.4.0 // indirect
|
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
|
|
||||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
|
golang.org/x/net v0.46.0 // indirect
|
||||||
golang.org/x/mod v0.30.0 // indirect
|
golang.org/x/oauth2 v0.32.0 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
golang.org/x/oauth2 v0.33.0 // indirect
|
golang.org/x/term v0.36.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/text v0.30.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
|
||||||
golang.org/x/term v0.37.0 // indirect
|
|
||||||
golang.org/x/text v0.31.0 // indirect
|
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
golang.org/x/tools v0.39.0 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846 // indirect
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect
|
|
||||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
k8s.io/klog/v2 v2.130.1 // indirect
|
k8s.io/klog/v2 v2.130.1 // indirect
|
||||||
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // indirect
|
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
|
||||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
||||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect
|
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
|
||||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
|
|
||||||
|
|||||||
290
go.sum
290
go.sum
@@ -1,87 +1,62 @@
|
|||||||
github.com/RoaringBitmap/roaring/v2 v2.14.4 h1:4aKySrrg9G/5oRtJ3TrZLObVqxgQ9f1znCRBwEwjuVw=
|
github.com/RoaringBitmap/roaring/v2 v2.10.0 h1:HbJ8Cs71lfCJyvmSptxeMX2PtvOC8yonlU0GQcy2Ak0=
|
||||||
github.com/RoaringBitmap/roaring/v2 v2.14.4/go.mod h1:oMvV6omPWr+2ifRdeZvVJyaz+aoEUopyv5iH0u/+wbY=
|
github.com/RoaringBitmap/roaring/v2 v2.10.0/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
|
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||||
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||||
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/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/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/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
|
||||||
github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589 h1:VJ/jVUWr+r4MQA7U/cscbbXRuwh1PfPCUUItYAjlKN4=
|
|
||||||
github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589/go.mod h1:IeI20psFPeg2n1jxwbkYCmkpYsXsJqB7qmoqCIlX80s=
|
|
||||||
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
|
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
|
||||||
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
|
||||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
|
github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
|
||||||
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
|
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
|
||||||
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
|
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
|
||||||
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
|
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
|
||||||
github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU=
|
github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8=
|
||||||
github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ=
|
github.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo=
|
||||||
github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4=
|
github.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY=
|
||||||
github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
|
github.com/go-openapi/swag/cmdutils v0.25.1/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
|
||||||
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
|
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
|
||||||
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
|
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
|
||||||
github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y=
|
github.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU=
|
||||||
github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk=
|
github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M=
|
||||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
|
||||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
|
||||||
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
|
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
|
||||||
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
|
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
|
||||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
|
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.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
|
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
|
||||||
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
|
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
|
||||||
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
|
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
|
||||||
github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48=
|
github.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync=
|
||||||
github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg=
|
github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ=
|
||||||
github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0=
|
github.com/go-openapi/swag/netutils v0.25.1 h1:2wFLYahe40tDUHfKT1GRC4rfa5T1B4GWZ+msEFA4Fl4=
|
||||||
github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg=
|
github.com/go-openapi/swag/netutils v0.25.1/go.mod h1:CAkkvqnUJX8NV96tNhEQvKz8SQo2KF0f7LleiJwIeRE=
|
||||||
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
|
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
|
||||||
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
|
||||||
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
|
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
|
||||||
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
|
||||||
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
|
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
|
||||||
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
|
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
|
||||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
|
|
||||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
|
|
||||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
|
||||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
|
||||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
|
||||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
|
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
||||||
github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
@@ -91,239 +66,154 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
||||||
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
|
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
|
|
||||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548 h1:lkxVz5lNPlU78El49cx1c3Rxo/bp7Gbzp2BjSRFgg1U=
|
||||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548/go.mod h1:k4lo5gFYb3AqrgftlraCnv95xvjB/w8udRqJQJ12mAE=
|
||||||
github.com/matst80/go-redis-inventory v0.0.0-20251125181530-7ebbc97e3841 h1:CCHa+LuxwGOPPbIpuFYNT7VtC5rHjSulB337Y+Yyys8=
|
|
||||||
github.com/matst80/go-redis-inventory v0.0.0-20251125181530-7ebbc97e3841/go.mod h1:d42+bHxhmMg73OQM+5i5U7vyEQfuVLJkt7QPsEbr8kI=
|
|
||||||
github.com/matst80/slask-finder v0.0.0-20251125182907-9e57f193127a h1:EfUO5BNDK3a563zQlwJYTNNv46aJFT9gbSItAwZOZ/Y=
|
|
||||||
github.com/matst80/slask-finder v0.0.0-20251125182907-9e57f193127a/go.mod h1:VIPNkIvU0dZKwbSuv75zZcB93MXISm2UyiIPly/ucXQ=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
|
||||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
|
||||||
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
|
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
|
||||||
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
|
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
|
|
||||||
github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
|
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 h1:5vHNY1uuPBRBWqB2Dp0G7YB03phxLQZupZTIZaeorjc=
|
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ=
|
|
||||||
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.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
|
||||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
|
||||||
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
|
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
|
||||||
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
||||||
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
|
||||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
||||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
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 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
|
github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI=
|
||||||
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q=
|
||||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
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 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
||||||
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||||
github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM=
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
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.2 h1:Mys71yd6u8kuowNCR0gCVPlVAHCmKtoGXYoAtcEbqXQ=
|
|
||||||
github.com/speakeasy-api/jsonpath v0.6.2/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw=
|
|
||||||
github.com/speakeasy-api/openapi-overlay v0.10.3 h1:70een4vwHyslIp796vM+ox6VISClhtXsCjrQNhxwvWs=
|
|
||||||
github.com/speakeasy-api/openapi-overlay v0.10.3/go.mod h1:RJjV0jbUHqXLS0/Mxv5XE7LAnJHqHw+01RDdpoGqiyY=
|
|
||||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
|
||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
|
||||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
|
||||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
|
|
||||||
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
|
|
||||||
github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc=
|
|
||||||
github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU=
|
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 h1:bwnLpizECbPr1RrQ27waeY2SPIPeccCx/xLuoYADZ9s=
|
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0/go.mod h1:3nWlOiiqA9UtUnrcNk82mYasNxD8ehOspL0gOfEo6Y4=
|
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
|
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
|
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 h1:OMqPldHt79PqWKOMYIAQs3CxAi7RLgPxwfFSwr4ZxtM=
|
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0/go.mod h1:1biG4qiqTxKiUCtoWDPpL3fB3KxVwCiGw81j3nKMuHE=
|
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 h1:vl9obrcoWVKp/lwl8tRE33853I8Xru9HFbw/skNeLs8=
|
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0/go.mod h1:GAXRxmLJcVM3u22IjTg74zWBrRCKq8BnOqUVLodpcpw=
|
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54=
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk=
|
|
||||||
go.opentelemetry.io/otel/log v0.14.0 h1:2rzJ+pOAZ8qmZ3DDHg73NEKzSZkhkGIua9gXtxNGgrM=
|
|
||||||
go.opentelemetry.io/otel/log v0.14.0/go.mod h1:5jRG92fEAgx0SU/vFPxmJvhIuDU9E1SUnEQrMlJpOno=
|
|
||||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
|
||||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
|
||||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
|
||||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
|
||||||
go.opentelemetry.io/otel/sdk/log v0.14.0 h1:JU/U3O7N6fsAXj0+CXz21Czg532dW2V4gG1HE/e8Zrg=
|
|
||||||
go.opentelemetry.io/otel/sdk/log v0.14.0/go.mod h1:imQvII+0ZylXfKU7/wtOND8Hn4OpT3YUoIgqJVksUkM=
|
|
||||||
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM=
|
|
||||||
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA=
|
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
|
||||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
|
||||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
|
||||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
|
||||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
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 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
|
||||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||||
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||||
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846 h1:ZdyUkS9po3H7G0tuh955QVyyotWvOD4W0aEapeGeUYk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff h1:A90eA31Wq6HOMIQlLfzFwzqGKBTuaVztYu/g8sn+8Zc=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846/go.mod h1:Fk4kyraUvqD7i5H6S43sj2W98fbZa75lpZz/eUyhfO0=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 h1:Wgl1rcDNThT+Zn47YyCXOXyX/COgMTIdhJ717F0l4xk=
|
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
|
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
|
||||||
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
|
||||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
|
||||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
|
||||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY=
|
k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM=
|
||||||
k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw=
|
k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk=
|
||||||
k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4=
|
k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4=
|
||||||
k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
|
k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
|
||||||
k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M=
|
k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY=
|
||||||
k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE=
|
k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8=
|
||||||
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||||
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||||
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ=
|
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
|
||||||
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
|
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 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
|
||||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
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 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
|
||||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
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 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
||||||
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E=
|
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
|
||||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
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 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||||
|
|||||||
@@ -1,327 +0,0 @@
|
|||||||
{
|
|
||||||
"uid": "cart-actors",
|
|
||||||
"title": "Cart Actor Cluster",
|
|
||||||
"timezone": "browser",
|
|
||||||
"refresh": "30s",
|
|
||||||
"schemaVersion": 38,
|
|
||||||
"version": 1,
|
|
||||||
"editable": true,
|
|
||||||
"graphTooltip": 0,
|
|
||||||
"panels": [
|
|
||||||
{
|
|
||||||
"type": "row",
|
|
||||||
"title": "Overview",
|
|
||||||
"gridPos": { "x": 0, "y": 0, "w": 24, "h": 1 },
|
|
||||||
"id": 1,
|
|
||||||
"collapsed": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Active Grains",
|
|
||||||
"id": 2,
|
|
||||||
"gridPos": { "x": 0, "y": 1, "w": 6, "h": 4 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{ "refId": "A", "expr": "cart_active_grains" }
|
|
||||||
],
|
|
||||||
"options": {
|
|
||||||
"colorMode": "value",
|
|
||||||
"graphMode": "none",
|
|
||||||
"justifyMode": "center",
|
|
||||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Grains In Pool",
|
|
||||||
"id": 3,
|
|
||||||
"gridPos": { "x": 6, "y": 1, "w": 6, "h": 4 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{ "refId": "A", "expr": "cart_grains_in_pool" }
|
|
||||||
],
|
|
||||||
"options": {
|
|
||||||
"colorMode": "value",
|
|
||||||
"graphMode": "none",
|
|
||||||
"justifyMode": "center",
|
|
||||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Pool Usage %",
|
|
||||||
"id": 4,
|
|
||||||
"gridPos": { "x": 12, "y": 1, "w": 6, "h": 4 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{ "refId": "A", "expr": "cart_grain_pool_usage * 100" }
|
|
||||||
],
|
|
||||||
"units": "percent",
|
|
||||||
"options": {
|
|
||||||
"colorMode": "value",
|
|
||||||
"graphMode": "none",
|
|
||||||
"justifyMode": "center",
|
|
||||||
"reduceOptions": { "calcs": ["lastNotNull"] }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Connected Remotes",
|
|
||||||
"id": 5,
|
|
||||||
"gridPos": { "x": 18, "y": 1, "w": 6, "h": 4 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{ "refId": "A", "expr": "connected_remotes" }
|
|
||||||
],
|
|
||||||
"options": {
|
|
||||||
"colorMode": "value",
|
|
||||||
"graphMode": "none",
|
|
||||||
"justifyMode": "center",
|
|
||||||
"reduceOptions": { "calcs": ["lastNotNull"] }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
"type": "row",
|
|
||||||
"title": "Mutations",
|
|
||||||
"gridPos": { "x": 0, "y": 5, "w": 24, "h": 1 },
|
|
||||||
"id": 6,
|
|
||||||
"collapsed": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timeseries",
|
|
||||||
"title": "Mutation Rate (1m)",
|
|
||||||
"id": 7,
|
|
||||||
"gridPos": { "x": 0, "y": 6, "w": 12, "h": 8 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{ "refId": "A", "expr": "rate(cart_mutations_total[1m])", "legendFormat": "mutations/s" },
|
|
||||||
{ "refId": "B", "expr": "rate(cart_mutation_failures_total[1m])", "legendFormat": "failures/s" }
|
|
||||||
],
|
|
||||||
"fieldConfig": { "defaults": { "unit": "ops" } }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Failure % (5m)",
|
|
||||||
"id": 8,
|
|
||||||
"gridPos": { "x": 12, "y": 6, "w": 6, "h": 4 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"refId": "A",
|
|
||||||
"expr": "100 * (increase(cart_mutation_failures_total[5m]) / clamp_max(increase(cart_mutations_total[5m]), 1))"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"options": {
|
|
||||||
"colorMode": "value",
|
|
||||||
"graphMode": "none",
|
|
||||||
"justifyMode": "center",
|
|
||||||
"reduceOptions": { "calcs": ["lastNotNull"] }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timeseries",
|
|
||||||
"title": "Mutation Latency Quantiles",
|
|
||||||
"id": 9,
|
|
||||||
"gridPos": { "x": 18, "y": 6, "w": 6, "h": 8 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"refId": "A",
|
|
||||||
"expr": "histogram_quantile(0.50, sum(rate(cart_mutation_latency_seconds_bucket[5m])) by (le))",
|
|
||||||
"legendFormat": "p50"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"refId": "B",
|
|
||||||
"expr": "histogram_quantile(0.90, sum(rate(cart_mutation_latency_seconds_bucket[5m])) by (le))",
|
|
||||||
"legendFormat": "p90"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"refId": "C",
|
|
||||||
"expr": "histogram_quantile(0.99, sum(rate(cart_mutation_latency_seconds_bucket[5m])) by (le))",
|
|
||||||
"legendFormat": "p99"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"fieldConfig": { "defaults": { "unit": "s" } }
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
"type": "row",
|
|
||||||
"title": "Event Log",
|
|
||||||
"gridPos": { "x": 0, "y": 14, "w": 24, "h": 1 },
|
|
||||||
"id": 10,
|
|
||||||
"collapsed": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timeseries",
|
|
||||||
"title": "Event Append Rate (5m)",
|
|
||||||
"id": 11,
|
|
||||||
"gridPos": { "x": 0, "y": 15, "w": 8, "h": 6 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{ "refId": "A", "expr": "rate(cart_event_log_appends_total[5m])", "legendFormat": "appends/s" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timeseries",
|
|
||||||
"title": "Event Bytes Written Rate (5m)",
|
|
||||||
"id": 12,
|
|
||||||
"gridPos": { "x": 8, "y": 15, "w": 8, "h": 6 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{ "refId": "A", "expr": "rate(cart_event_log_bytes_written_total[5m])", "legendFormat": "bytes/s" }
|
|
||||||
],
|
|
||||||
"fieldConfig": { "defaults": { "unit": "Bps" } }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Existing Log Files",
|
|
||||||
"id": 13,
|
|
||||||
"gridPos": { "x": 16, "y": 15, "w": 4, "h": 3 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [{ "refId": "A", "expr": "cart_event_log_files_existing" }],
|
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Last Append Age (s)",
|
|
||||||
"id": 14,
|
|
||||||
"gridPos": { "x": 20, "y": 15, "w": 4, "h": 3 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{ "refId": "A", "expr": "(time() - cart_event_log_last_append_unix)" }
|
|
||||||
],
|
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Replay Failures Total",
|
|
||||||
"id": 15,
|
|
||||||
"gridPos": { "x": 16, "y": 18, "w": 4, "h": 3 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [{ "refId": "A", "expr": "cart_event_log_replay_failures_total" }],
|
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Replay Duration p95 (5m)",
|
|
||||||
"id": 16,
|
|
||||||
"gridPos": { "x": 20, "y": 18, "w": 4, "h": 3 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"refId": "A",
|
|
||||||
"expr": "histogram_quantile(0.95, sum(rate(cart_event_log_replay_duration_seconds_bucket[5m])) by (le))"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "fieldConfig": { "defaults": { "unit": "s" } } }
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
"type": "row",
|
|
||||||
"title": "Grain Lifecycle",
|
|
||||||
"gridPos": { "x": 0, "y": 21, "w": 24, "h": 1 },
|
|
||||||
"id": 17,
|
|
||||||
"collapsed": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timeseries",
|
|
||||||
"title": "Spawn & Lookup Rates (1m)",
|
|
||||||
"id": 18,
|
|
||||||
"gridPos": { "x": 0, "y": 22, "w": 12, "h": 8 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{ "refId": "A", "expr": "rate(cart_grain_spawned_total[1m])", "legendFormat": "spawns/s" },
|
|
||||||
{ "refId": "B", "expr": "rate(cart_grain_lookups_total[1m])", "legendFormat": "lookups/s" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Negotiations Rate (5m)",
|
|
||||||
"id": 19,
|
|
||||||
"gridPos": { "x": 12, "y": 22, "w": 6, "h": 4 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{ "refId": "A", "expr": "rate(cart_remote_negotiation_total[5m])" }
|
|
||||||
],
|
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "orientation": "horizontal" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Mutations Total",
|
|
||||||
"id": 20,
|
|
||||||
"gridPos": { "x": 18, "y": 22, "w": 6, "h": 4 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [{ "refId": "A", "expr": "cart_mutations_total" }],
|
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
"type": "row",
|
|
||||||
"title": "Event Log Errors",
|
|
||||||
"gridPos": { "x": 0, "y": 30, "w": 24, "h": 1 },
|
|
||||||
"id": 21,
|
|
||||||
"collapsed": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Unknown Event Types",
|
|
||||||
"id": 22,
|
|
||||||
"gridPos": { "x": 0, "y": 31, "w": 6, "h": 4 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [{ "refId": "A", "expr": "cart_event_log_unknown_types_total" }],
|
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Event Mutation Errors",
|
|
||||||
"id": 23,
|
|
||||||
"gridPos": { "x": 6, "y": 31, "w": 6, "h": 4 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [{ "refId": "A", "expr": "cart_event_log_mutation_errors_total" }],
|
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Replay Success Total",
|
|
||||||
"id": 24,
|
|
||||||
"gridPos": { "x": 12, "y": 31, "w": 6, "h": 4 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [{ "refId": "A", "expr": "cart_event_log_replay_total" }],
|
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Replay Duration p50 (5m)",
|
|
||||||
"id": 25,
|
|
||||||
"gridPos": { "x": 18, "y": 31, "w": 6, "h": 4 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"refId": "A",
|
|
||||||
"expr": "histogram_quantile(0.50, sum(rate(cart_event_log_replay_duration_seconds_bucket[5m])) by (le))"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "fieldConfig": { "defaults": { "unit": "s" } } }
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"templating": {
|
|
||||||
"list": [
|
|
||||||
{
|
|
||||||
"name": "DS_PROMETHEUS",
|
|
||||||
"label": "Prometheus",
|
|
||||||
"type": "datasource",
|
|
||||||
"query": "prometheus",
|
|
||||||
"current": { "text": "Prometheus", "value": "Prometheus" }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"time": {
|
|
||||||
"from": "now-6h",
|
|
||||||
"to": "now"
|
|
||||||
},
|
|
||||||
"timepicker": {
|
|
||||||
"refresh_intervals": ["5s","10s","30s","1m","5m","15m","30m","1h"],
|
|
||||||
"time_options": ["5m","15m","30m","1h","6h","12h","24h","2d","7d"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
168
grain-pool.go
Normal file
168
grain-pool.go
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
135
grpc_integration_test.go
Normal file
135
grpc_integration_test.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCartActorMutationAndState validates end-to-end gRPC mutation + state retrieval
|
||||||
|
// against a locally started gRPC server (single-node scenario).
|
||||||
|
// This test uses AddItemType directly to avoid hitting external product
|
||||||
|
// fetching logic (FetchItem) which would require network I/O.
|
||||||
|
func TestCartActorMutationAndState(t *testing.T) {
|
||||||
|
// Setup local grain pool + synced pool (no discovery, single host)
|
||||||
|
pool := NewGrainLocalPool(1024, time.Minute, spawn)
|
||||||
|
synced, err := NewSyncedPool(pool, "127.0.0.1", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSyncedPool error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start gRPC server (CartActor + ControlPlane) on :1337
|
||||||
|
grpcSrv, err := StartGRPCServer(":1337", pool, synced)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StartGRPCServer error: %v", err)
|
||||||
|
}
|
||||||
|
defer grpcSrv.GracefulStop()
|
||||||
|
|
||||||
|
// Dial the local server
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
conn, err := grpc.DialContext(ctx, "127.0.0.1:1337",
|
||||||
|
grpc.WithInsecure(),
|
||||||
|
grpc.WithBlock(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("grpc.Dial error: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
cartClient := messages.NewCartActorClient(conn)
|
||||||
|
|
||||||
|
// Create a short cart id (<=16 chars so it fits into the fixed CartId 16-byte array cleanly)
|
||||||
|
cartID := fmt.Sprintf("cart-%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
// Build an AddItem payload (bypasses FetchItem to keep test deterministic)
|
||||||
|
addItem := &messages.AddItem{
|
||||||
|
ItemId: 1,
|
||||||
|
Quantity: 1,
|
||||||
|
Price: 1000,
|
||||||
|
OrgPrice: 1000,
|
||||||
|
Sku: "test-sku",
|
||||||
|
Name: "Test SKU",
|
||||||
|
Image: "/img.png",
|
||||||
|
Stock: 2, // InStock
|
||||||
|
Tax: 2500,
|
||||||
|
Country: "se",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal underlying mutation payload using the existing handler code path
|
||||||
|
handler, ok := Handlers[AddItemType]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Handler for AddItemType missing")
|
||||||
|
}
|
||||||
|
payloadData, err := getSerializedPayload(handler, AddItemType, addItem)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("serialize add item: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue Mutate RPC
|
||||||
|
mutResp, err := cartClient.Mutate(context.Background(), &messages.MutationRequest{
|
||||||
|
CartId: cartID,
|
||||||
|
Type: messages.MutationType(AddItemType),
|
||||||
|
Payload: payloadData,
|
||||||
|
ClientTimestamp: time.Now().Unix(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Mutate RPC error: %v", err)
|
||||||
|
}
|
||||||
|
if mutResp.StatusCode != 200 {
|
||||||
|
t.Fatalf("Mutate returned non-200 status: %d payload=%s", mutResp.StatusCode, string(mutResp.Payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode cart state JSON and validate
|
||||||
|
state := &CartGrain{}
|
||||||
|
if err := json.Unmarshal(mutResp.Payload, state); err != nil {
|
||||||
|
t.Fatalf("Unmarshal mutate cart state: %v\nPayload: %s", err, string(mutResp.Payload))
|
||||||
|
}
|
||||||
|
if len(state.Items) != 1 {
|
||||||
|
t.Fatalf("Expected 1 item after mutation, got %d", len(state.Items))
|
||||||
|
}
|
||||||
|
if state.Items[0].Sku != "test-sku" {
|
||||||
|
t.Fatalf("Unexpected item SKU: %s", state.Items[0].Sku)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue GetState RPC
|
||||||
|
getResp, err := cartClient.GetState(context.Background(), &messages.StateRequest{
|
||||||
|
CartId: cartID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetState RPC error: %v", err)
|
||||||
|
}
|
||||||
|
if getResp.StatusCode != 200 {
|
||||||
|
t.Fatalf("GetState returned non-200 status: %d payload=%s", getResp.StatusCode, string(getResp.Payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
state2 := &CartGrain{}
|
||||||
|
if err := json.Unmarshal(getResp.Payload, state2); err != nil {
|
||||||
|
t.Fatalf("Unmarshal get state: %v", err)
|
||||||
|
}
|
||||||
|
if len(state2.Items) != 1 {
|
||||||
|
t.Fatalf("Expected 1 item in GetState, got %d", len(state2.Items))
|
||||||
|
}
|
||||||
|
if state2.Items[0].Sku != "test-sku" {
|
||||||
|
t.Fatalf("Unexpected SKU in GetState: %s", state2.Items[0].Sku)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSerializedPayload serializes a mutation proto using the registered handler.
|
||||||
|
func getSerializedPayload(handler MessageHandler, msgType uint16, content interface{}) ([]byte, error) {
|
||||||
|
msg := &Message{
|
||||||
|
Type: msgType,
|
||||||
|
Content: content,
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := handler.Write(msg, &buf); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
379
grpc_server.go
Normal file
379
grpc_server.go
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
proto "git.tornberg.me/go-cart-actor/proto" // underlying generated package name is 'messages'
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Metrics
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
var (
|
||||||
|
grpcMutateDuration = promauto.NewHistogram(prometheus.HistogramOpts{
|
||||||
|
Name: "cart_grpc_mutate_duration_seconds",
|
||||||
|
Help: "Duration of CartActor.Mutate RPCs",
|
||||||
|
Buckets: prometheus.DefBuckets,
|
||||||
|
})
|
||||||
|
grpcMutateErrors = promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "cart_grpc_mutate_errors_total",
|
||||||
|
Help: "Total number of failed CartActor.Mutate RPCs",
|
||||||
|
})
|
||||||
|
grpcStateDuration = promauto.NewHistogram(prometheus.HistogramOpts{
|
||||||
|
Name: "cart_grpc_get_state_duration_seconds",
|
||||||
|
Help: "Duration of CartActor.GetState RPCs",
|
||||||
|
Buckets: prometheus.DefBuckets,
|
||||||
|
})
|
||||||
|
grpcControlDuration = promauto.NewHistogram(prometheus.HistogramOpts{
|
||||||
|
Name: "cart_grpc_control_duration_seconds",
|
||||||
|
Help: "Duration of ControlPlane RPCs",
|
||||||
|
Buckets: prometheus.DefBuckets,
|
||||||
|
})
|
||||||
|
grpcControlErrors = promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "cart_grpc_control_errors_total",
|
||||||
|
Help: "Total number of failed ControlPlane RPCs",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// timeTrack wraps a closure and records duration into the supplied histogram.
|
||||||
|
func timeTrack(hist prometheus.Observer, fn func() error) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
defer func() {
|
||||||
|
hist.Observe(time.Since(start).Seconds())
|
||||||
|
}()
|
||||||
|
return fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// CartActor Service Implementation
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type cartActorService struct {
|
||||||
|
proto.UnimplementedCartActorServer
|
||||||
|
pool GrainPool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCartActorService(pool GrainPool) *cartActorService {
|
||||||
|
return &cartActorService{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cartActorService) Mutate(ctx context.Context, req *proto.MutationRequest) (*proto.MutationReply, error) {
|
||||||
|
var reply *proto.MutationReply
|
||||||
|
err := timeTrack(grpcMutateDuration, func() error {
|
||||||
|
if req == nil {
|
||||||
|
return status.Error(codes.InvalidArgument, "request is nil")
|
||||||
|
}
|
||||||
|
if req.CartId == "" {
|
||||||
|
return status.Error(codes.InvalidArgument, "cart_id is empty")
|
||||||
|
}
|
||||||
|
mt := uint16(req.Type.Number())
|
||||||
|
handler, ok := Handlers[mt]
|
||||||
|
if !ok {
|
||||||
|
return status.Errorf(codes.InvalidArgument, "unknown mutation type %d", mt)
|
||||||
|
}
|
||||||
|
content, err := handler.Read(req.Payload)
|
||||||
|
if err != nil {
|
||||||
|
return status.Errorf(codes.InvalidArgument, "decode payload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := req.ClientTimestamp
|
||||||
|
if ts == 0 {
|
||||||
|
ts = time.Now().Unix()
|
||||||
|
}
|
||||||
|
msg := Message{
|
||||||
|
Type: mt,
|
||||||
|
TimeStamp: &ts,
|
||||||
|
Content: content,
|
||||||
|
}
|
||||||
|
|
||||||
|
frame, err := s.pool.Process(ToCartId(req.CartId), msg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reply = &proto.MutationReply{
|
||||||
|
StatusCode: int32(frame.StatusCode),
|
||||||
|
Payload: frame.Payload,
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
grpcMutateErrors.Inc()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cartActorService) GetState(ctx context.Context, req *proto.StateRequest) (*proto.StateReply, error) {
|
||||||
|
var reply *proto.StateReply
|
||||||
|
err := timeTrack(grpcStateDuration, func() error {
|
||||||
|
if req == nil || req.CartId == "" {
|
||||||
|
return status.Error(codes.InvalidArgument, "cart_id is empty")
|
||||||
|
}
|
||||||
|
frame, err := s.pool.Get(ToCartId(req.CartId))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reply = &proto.StateReply{
|
||||||
|
StatusCode: int32(frame.StatusCode),
|
||||||
|
Payload: frame.Payload,
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// ControlPlane Service Implementation
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// controlPlaneService directly leverages SyncedPool internals (same package).
|
||||||
|
// NOTE: This is a transitional adapter; once the legacy frame-based code is
|
||||||
|
// removed, related fields/methods in SyncedPool can be slimmed.
|
||||||
|
type controlPlaneService struct {
|
||||||
|
proto.UnimplementedControlPlaneServer
|
||||||
|
pool *SyncedPool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newControlPlaneService(pool *SyncedPool) *controlPlaneService {
|
||||||
|
return &controlPlaneService{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *controlPlaneService) Ping(ctx context.Context, _ *proto.Empty) (*proto.PingReply, error) {
|
||||||
|
var reply *proto.PingReply
|
||||||
|
err := timeTrack(grpcControlDuration, func() error {
|
||||||
|
reply = &proto.PingReply{
|
||||||
|
Host: s.pool.Hostname,
|
||||||
|
UnixTime: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
grpcControlErrors.Inc()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *controlPlaneService) Negotiate(ctx context.Context, req *proto.NegotiateRequest) (*proto.NegotiateReply, error) {
|
||||||
|
var reply *proto.NegotiateReply
|
||||||
|
err := timeTrack(grpcControlDuration, func() error {
|
||||||
|
if req == nil {
|
||||||
|
return status.Error(codes.InvalidArgument, "request is nil")
|
||||||
|
}
|
||||||
|
// Add unknown hosts
|
||||||
|
for _, host := range req.KnownHosts {
|
||||||
|
if host == "" || host == s.pool.Hostname {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !s.pool.IsKnown(host) {
|
||||||
|
go s.pool.AddRemote(host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Build healthy host list
|
||||||
|
hosts := make([]string, 0)
|
||||||
|
for _, r := range s.pool.GetHealthyRemotes() {
|
||||||
|
hosts = append(hosts, r.Host)
|
||||||
|
}
|
||||||
|
hosts = append(hosts, s.pool.Hostname)
|
||||||
|
reply = &proto.NegotiateReply{
|
||||||
|
Hosts: hosts,
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
grpcControlErrors.Inc()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *controlPlaneService) GetCartIds(ctx context.Context, _ *proto.Empty) (*proto.CartIdsReply, error) {
|
||||||
|
var reply *proto.CartIdsReply
|
||||||
|
err := timeTrack(grpcControlDuration, func() error {
|
||||||
|
s.pool.mu.RLock()
|
||||||
|
defer s.pool.mu.RUnlock()
|
||||||
|
ids := make([]string, 0, len(s.pool.local.grains))
|
||||||
|
for id, g := range s.pool.local.grains {
|
||||||
|
if g == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if id.String() == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ids = append(ids, id.String())
|
||||||
|
}
|
||||||
|
reply = &proto.CartIdsReply{
|
||||||
|
CartIds: ids,
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
grpcControlErrors.Inc()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *controlPlaneService) ConfirmOwner(ctx context.Context, req *proto.OwnerChangeRequest) (*proto.OwnerChangeAck, error) {
|
||||||
|
var reply *proto.OwnerChangeAck
|
||||||
|
err := timeTrack(grpcControlDuration, func() error {
|
||||||
|
if req == nil || req.CartId == "" || req.NewHost == "" {
|
||||||
|
return status.Error(codes.InvalidArgument, "cart_id or new_host missing")
|
||||||
|
}
|
||||||
|
id := ToCartId(req.CartId)
|
||||||
|
newHost := req.NewHost
|
||||||
|
|
||||||
|
// Mirror GrainOwnerChangeHandler semantics
|
||||||
|
log.Printf("gRPC ConfirmOwner: cart %s newHost=%s", id, newHost)
|
||||||
|
for _, r := range s.pool.remoteHosts {
|
||||||
|
if r.Host == newHost && r.IsHealthy() {
|
||||||
|
go s.pool.SpawnRemoteGrain(id, newHost)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
go s.pool.AddRemote(newHost)
|
||||||
|
|
||||||
|
reply = &proto.OwnerChangeAck{
|
||||||
|
Accepted: true,
|
||||||
|
Message: "ok",
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
grpcControlErrors.Inc()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *controlPlaneService) Closing(ctx context.Context, notice *proto.ClosingNotice) (*proto.OwnerChangeAck, error) {
|
||||||
|
var reply *proto.OwnerChangeAck
|
||||||
|
err := timeTrack(grpcControlDuration, func() error {
|
||||||
|
if notice == nil || notice.Host == "" {
|
||||||
|
return status.Error(codes.InvalidArgument, "host missing")
|
||||||
|
}
|
||||||
|
host := notice.Host
|
||||||
|
s.pool.mu.RLock()
|
||||||
|
_, exists := s.pool.remoteHosts[host]
|
||||||
|
s.pool.mu.RUnlock()
|
||||||
|
if exists {
|
||||||
|
go s.pool.RemoveHost(host)
|
||||||
|
}
|
||||||
|
reply = &proto.OwnerChangeAck{
|
||||||
|
Accepted: true,
|
||||||
|
Message: "removed",
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
grpcControlErrors.Inc()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return reply, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Server Bootstrap
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type GRPCServer struct {
|
||||||
|
server *grpc.Server
|
||||||
|
lis net.Listener
|
||||||
|
addr string
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartGRPCServer sets up a gRPC server hosting both CartActor and ControlPlane services.
|
||||||
|
// addr example: ":1337" (for combined) OR run two servers if you want separate ports.
|
||||||
|
// For the migration we can host both on the same listener to reduce open ports.
|
||||||
|
func StartGRPCServer(addr string, pool GrainPool, synced *SyncedPool, opts ...grpc.ServerOption) (*GRPCServer, error) {
|
||||||
|
if pool == nil {
|
||||||
|
return nil, errors.New("nil grain pool")
|
||||||
|
}
|
||||||
|
if synced == nil {
|
||||||
|
return nil, errors.New("nil synced pool")
|
||||||
|
}
|
||||||
|
|
||||||
|
lis, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listen %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
grpcServer := grpc.NewServer(opts...)
|
||||||
|
proto.RegisterCartActorServer(grpcServer, newCartActorService(pool))
|
||||||
|
proto.RegisterControlPlaneServer(grpcServer, newControlPlaneService(synced))
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Printf("gRPC server listening on %s", addr)
|
||||||
|
if serveErr := grpcServer.Serve(lis); serveErr != nil {
|
||||||
|
log.Printf("gRPC server stopped: %v", serveErr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return &GRPCServer{
|
||||||
|
server: grpcServer,
|
||||||
|
lis: lis,
|
||||||
|
addr: addr,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GracefulStop stops the server gracefully.
|
||||||
|
func (s *GRPCServer) GracefulStop() {
|
||||||
|
if s == nil || s.server == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.server.GracefulStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Addr returns the bound address.
|
||||||
|
func (s *GRPCServer) Addr() string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.addr
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Client Dial Helpers (used later by refactored remote grain + control plane)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// DialRemote establishes (or reuses externally) a gRPC client connection.
|
||||||
|
func DialRemote(ctx context.Context, target string, opts ...grpc.DialOption) (*grpc.ClientConn, error) {
|
||||||
|
dialOpts := []grpc.DialOption{
|
||||||
|
grpc.WithInsecure(), // NOTE: Intentional for initial migration; replace with TLS / mTLS later.
|
||||||
|
grpc.WithBlock(),
|
||||||
|
}
|
||||||
|
dialOpts = append(dialOpts, opts...)
|
||||||
|
ctxDial, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
conn, err := grpc.DialContext(ctxDial, target, dialOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Utility for converting internal errors to gRPC status (if needed later).
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func grpcError(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Extend mapping if we add richer error types.
|
||||||
|
return status.Error(codes.Internal, err.Error())
|
||||||
|
}
|
||||||
174
k6/README.md
174
k6/README.md
@@ -1,174 +0,0 @@
|
|||||||
# 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!
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
import http from "k6/http";
|
|
||||||
import { check, sleep, group } from "k6";
|
|
||||||
import { Counter, Trend } from "k6/metrics";
|
|
||||||
|
|
||||||
// ---------------- Configuration ----------------
|
|
||||||
export const options = {
|
|
||||||
// Adjust vus/duration for your environment
|
|
||||||
scenarios: {
|
|
||||||
steady_mutations: {
|
|
||||||
executor: "constant-vus",
|
|
||||||
vus: __ENV.VUS ? parseInt(__ENV.VUS, 10) : 20,
|
|
||||||
duration: __ENV.DURATION || "5m",
|
|
||||||
gracefulStop: "30s",
|
|
||||||
},
|
|
||||||
ramp_up: {
|
|
||||||
executor: "ramping-vus",
|
|
||||||
startVUs: 0,
|
|
||||||
stages: [
|
|
||||||
{
|
|
||||||
duration: "1m",
|
|
||||||
target: __ENV.RAMP_TARGET
|
|
||||||
? parseInt(__ENV.RAMP_TARGET, 10)
|
|
||||||
: 50,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
duration: "1m",
|
|
||||||
target: __ENV.RAMP_TARGET
|
|
||||||
? parseInt(__ENV.RAMP_TARGET, 10)
|
|
||||||
: 50,
|
|
||||||
},
|
|
||||||
{ duration: "1m", target: 0 },
|
|
||||||
],
|
|
||||||
gracefulStop: "30s",
|
|
||||||
startTime: "30s",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
thresholds: {
|
|
||||||
http_req_failed: ["rate<0.02"], // < 2% failures
|
|
||||||
http_req_duration: ["p(90)<800", "p(99)<1500"], // latency SLO
|
|
||||||
"cart_add_item_duration{op:add}": ["p(90)<800"],
|
|
||||||
"cart_fetch_duration{op:get}": ["p(90)<600"],
|
|
||||||
},
|
|
||||||
summaryTrendStats: ["avg", "min", "med", "max", "p(90)", "p(95)", "p(99)"],
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------- Metrics ----------------
|
|
||||||
const addItemTrend = new Trend("cart_add_item_duration", true);
|
|
||||||
const fetchTrend = new Trend("cart_fetch_duration", true);
|
|
||||||
const checkoutTrend = new Trend("cart_checkout_duration", true);
|
|
||||||
const addedItemsCounter = new Counter("cart_items_added");
|
|
||||||
const checkoutCounter = new Counter("cart_checkout_calls");
|
|
||||||
|
|
||||||
// ---------------- SKUs ----------------
|
|
||||||
const SKUS = [
|
|
||||||
"778290",
|
|
||||||
"778345",
|
|
||||||
"778317",
|
|
||||||
"778277",
|
|
||||||
"778267",
|
|
||||||
"778376",
|
|
||||||
"778244",
|
|
||||||
"778384",
|
|
||||||
"778365",
|
|
||||||
"778377",
|
|
||||||
"778255",
|
|
||||||
"778286",
|
|
||||||
"778246",
|
|
||||||
"778270",
|
|
||||||
"778266",
|
|
||||||
"778285",
|
|
||||||
"778329",
|
|
||||||
"778425",
|
|
||||||
"778407",
|
|
||||||
"778418",
|
|
||||||
"778430",
|
|
||||||
"778469",
|
|
||||||
"778358",
|
|
||||||
"778351",
|
|
||||||
"778319",
|
|
||||||
"778307",
|
|
||||||
"778278",
|
|
||||||
"778251",
|
|
||||||
"778253",
|
|
||||||
"778261",
|
|
||||||
"778263",
|
|
||||||
"778273",
|
|
||||||
"778281",
|
|
||||||
"778294",
|
|
||||||
"778297",
|
|
||||||
"778302",
|
|
||||||
];
|
|
||||||
|
|
||||||
// ---------------- Helpers ----------------
|
|
||||||
function randomSku() {
|
|
||||||
return SKUS[Math.floor(Math.random() * SKUS.length)];
|
|
||||||
}
|
|
||||||
function randomQty() {
|
|
||||||
return 1 + Math.floor(Math.random() * 3); // 1..3
|
|
||||||
}
|
|
||||||
function baseUrl() {
|
|
||||||
const u = __ENV.BASE_URL || "http://localhost:8080/cart";
|
|
||||||
// Allow user to pass either root host or full /cart path
|
|
||||||
return u.endsWith("/cart") ? u : u.replace(/\/+$/, "") + "/cart";
|
|
||||||
}
|
|
||||||
function extractCookie(res, name) {
|
|
||||||
const cookies = res.cookies[name];
|
|
||||||
if (!cookies || cookies.length === 0) return null;
|
|
||||||
return cookies[0].value;
|
|
||||||
}
|
|
||||||
function withCookies(headers, cookieJar) {
|
|
||||||
if (!cookieJar || Object.keys(cookieJar).length === 0) return headers;
|
|
||||||
const cookieStr = Object.entries(cookieJar)
|
|
||||||
.map(([k, v]) => `${k}=${v}`)
|
|
||||||
.join("; ");
|
|
||||||
return { ...headers, Cookie: cookieStr };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Maintain cart + owner cookies per VU
|
|
||||||
let cartState = {
|
|
||||||
cartid: null,
|
|
||||||
cartowner: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Refresh cookies from response
|
|
||||||
function updateCookies(res) {
|
|
||||||
const cid = extractCookie(res, "cartid");
|
|
||||||
if (cid) cartState.cartid = cid;
|
|
||||||
const owner = extractCookie(res, "cartowner");
|
|
||||||
if (owner) cartState.cartowner = owner;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build headers
|
|
||||||
function headers() {
|
|
||||||
const h = { "Content-Type": "application/json" };
|
|
||||||
const jar = {};
|
|
||||||
if (cartState.cartid) jar["cartid"] = cartState.cartid;
|
|
||||||
if (cartState.cartowner) jar["cartowner"] = cartState.cartowner;
|
|
||||||
return withCookies(h, jar);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure cart exists (GET /)
|
|
||||||
function ensureCart() {
|
|
||||||
if (cartState.cartid) return;
|
|
||||||
const res = http.get(baseUrl() + "/", { headers: headers() });
|
|
||||||
updateCookies(res);
|
|
||||||
check(res, {
|
|
||||||
"ensure cart status 200": (r) => r.status === 200,
|
|
||||||
"ensure cart has id": () => !!cartState.cartid,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add random item
|
|
||||||
function addRandomItem() {
|
|
||||||
const payload = JSON.stringify({
|
|
||||||
sku: randomSku(),
|
|
||||||
quantity: randomQty(),
|
|
||||||
country: "no",
|
|
||||||
});
|
|
||||||
const start = Date.now();
|
|
||||||
const res = http.post(baseUrl(), payload, { headers: headers() });
|
|
||||||
const dur = Date.now() - start;
|
|
||||||
addItemTrend.add(dur, { op: "add" });
|
|
||||||
if (res.status === 200) {
|
|
||||||
addedItemsCounter.add(1);
|
|
||||||
}
|
|
||||||
updateCookies(res);
|
|
||||||
check(res, {
|
|
||||||
"add item status ok": (r) => r.status === 200,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch cart state
|
|
||||||
function fetchCart() {
|
|
||||||
const start = Date.now();
|
|
||||||
const res = http.get(baseUrl() + "/", { headers: headers() });
|
|
||||||
const dur = Date.now() - start;
|
|
||||||
fetchTrend.add(dur, { op: "get" });
|
|
||||||
updateCookies(res);
|
|
||||||
check(res, { "fetch status ok": (r) => r.status === 200 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Occasional checkout trigger
|
|
||||||
function maybeCheckout() {
|
|
||||||
if (!cartState.cartid) return;
|
|
||||||
// // Small probability
|
|
||||||
// if (Math.random() < 0.02) {
|
|
||||||
// const start = Date.now();
|
|
||||||
// const res = http.get(baseUrl() + "/checkout", { headers: headers() });
|
|
||||||
// const dur = Date.now() - start;
|
|
||||||
// checkoutTrend.add(dur, { op: "checkout" });
|
|
||||||
// updateCookies(res);
|
|
||||||
// if (res.status === 200) checkoutCounter.add(1);
|
|
||||||
// check(res, { "checkout status ok": (r) => r.status === 200 });
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------- k6 lifecycle ----------------
|
|
||||||
export function setup() {
|
|
||||||
// Provide SKU list length for summary
|
|
||||||
return { skuCount: SKUS.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function (data) {
|
|
||||||
group("cart flow", () => {
|
|
||||||
// Create or reuse cart
|
|
||||||
ensureCart();
|
|
||||||
|
|
||||||
// Random number of item mutations per iteration (1..5)
|
|
||||||
const ops = 1 + Math.floor(Math.random() * 5);
|
|
||||||
for (let i = 0; i < ops; i++) {
|
|
||||||
addRandomItem();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch state
|
|
||||||
fetchCart();
|
|
||||||
|
|
||||||
// Optional checkout attempt
|
|
||||||
maybeCheckout();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Small think time
|
|
||||||
sleep(Math.random() * 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function teardown(data) {
|
|
||||||
// Optionally we could GET confirmation or clear cart cookie
|
|
||||||
// Not implemented for load purpose.
|
|
||||||
console.log(`Test complete. SKU count: ${data.skuCount}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------- Summary ----------------
|
|
||||||
export function handleSummary(data) {
|
|
||||||
return {
|
|
||||||
stdout: JSON.stringify(
|
|
||||||
{
|
|
||||||
metrics: {
|
|
||||||
mutations_avg: data.metrics.cart_add_item_duration?.avg,
|
|
||||||
mutations_p95: data.metrics.cart_add_item_duration?.p(95),
|
|
||||||
fetch_p95: data.metrics.cart_fetch_duration?.p(95),
|
|
||||||
checkout_count: data.metrics.cart_checkout_calls?.count,
|
|
||||||
},
|
|
||||||
checks: data.root_checks,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -31,14 +30,11 @@ const (
|
|||||||
KlarnaPlaygroundUrl = "https://api.playground.klarna.com"
|
KlarnaPlaygroundUrl = "https://api.playground.klarna.com"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (k *KlarnaClient) GetOrder(ctx context.Context, orderId string) (*CheckoutOrder, error) {
|
func (k *KlarnaClient) GetOrder(orderId string) (*CheckoutOrder, error) {
|
||||||
req, err := http.NewRequest("GET", k.Url+"/checkout/v3/orders/"+orderId, nil)
|
req, err := http.NewRequest("GET", k.Url+"/checkout/v3/orders/"+orderId, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
spanCtx, span := tracer.Start(ctx, "Get klarna order")
|
|
||||||
defer span.End()
|
|
||||||
req = req.WithContext(spanCtx)
|
|
||||||
req.Header.Add("Content-Type", "application/json")
|
req.Header.Add("Content-Type", "application/json")
|
||||||
req.SetBasicAuth(k.UserName, k.Password)
|
req.SetBasicAuth(k.UserName, k.Password)
|
||||||
|
|
||||||
@@ -68,15 +64,13 @@ func (k *KlarnaClient) getOrderResponse(res *http.Response) (*CheckoutOrder, err
|
|||||||
return nil, fmt.Errorf("%s", res.Status)
|
return nil, fmt.Errorf("%s", res.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *KlarnaClient) CreateOrder(ctx context.Context, reader io.Reader) (*CheckoutOrder, error) {
|
func (k *KlarnaClient) CreateOrder(reader io.Reader) (*CheckoutOrder, error) {
|
||||||
//bytes.NewReader(reply.Payload)
|
//bytes.NewReader(reply.Payload)
|
||||||
req, err := http.NewRequest("POST", k.Url+"/checkout/v3/orders", reader)
|
req, err := http.NewRequest("POST", k.Url+"/checkout/v3/orders", reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
spanCtx, span := tracer.Start(ctx, "Create klarna order")
|
|
||||||
defer span.End()
|
|
||||||
req = req.WithContext(spanCtx)
|
|
||||||
req.Header.Add("Content-Type", "application/json")
|
req.Header.Add("Content-Type", "application/json")
|
||||||
req.SetBasicAuth(k.UserName, k.Password)
|
req.SetBasicAuth(k.UserName, k.Password)
|
||||||
|
|
||||||
@@ -88,16 +82,13 @@ func (k *KlarnaClient) CreateOrder(ctx context.Context, reader io.Reader) (*Chec
|
|||||||
return k.getOrderResponse(res)
|
return k.getOrderResponse(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *KlarnaClient) UpdateOrder(ctx context.Context, orderId string, reader io.Reader) (*CheckoutOrder, error) {
|
func (k *KlarnaClient) UpdateOrder(orderId string, reader io.Reader) (*CheckoutOrder, error) {
|
||||||
//bytes.NewReader(reply.Payload)
|
//bytes.NewReader(reply.Payload)
|
||||||
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s", k.Url, orderId), reader)
|
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s", k.Url, orderId), reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
spanCtx, span := tracer.Start(ctx, "Update klarna order")
|
|
||||||
defer span.End()
|
|
||||||
req = req.WithContext(spanCtx)
|
|
||||||
req.Header.Add("Content-Type", "application/json")
|
req.Header.Add("Content-Type", "application/json")
|
||||||
req.SetBasicAuth(k.UserName, k.Password)
|
req.SetBasicAuth(k.UserName, k.Password)
|
||||||
|
|
||||||
@@ -109,14 +100,12 @@ func (k *KlarnaClient) UpdateOrder(ctx context.Context, orderId string, reader i
|
|||||||
return k.getOrderResponse(res)
|
return k.getOrderResponse(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *KlarnaClient) AbortOrder(ctx context.Context, orderId string) error {
|
func (k *KlarnaClient) AbortOrder(orderId string) error {
|
||||||
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s/abort", k.Url, orderId), nil)
|
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s/abort", k.Url, orderId), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
spanCtx, span := tracer.Start(ctx, "Abort klarna order")
|
|
||||||
defer span.End()
|
|
||||||
req = req.WithContext(spanCtx)
|
|
||||||
req.SetBasicAuth(k.UserName, k.Password)
|
req.SetBasicAuth(k.UserName, k.Password)
|
||||||
|
|
||||||
_, err = http.DefaultClient.Do(req)
|
_, err = http.DefaultClient.Do(req)
|
||||||
@@ -124,14 +113,11 @@ func (k *KlarnaClient) AbortOrder(ctx context.Context, orderId string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ordermanagement/v1/orders/{order_id}/acknowledge
|
// ordermanagement/v1/orders/{order_id}/acknowledge
|
||||||
func (k *KlarnaClient) AcknowledgeOrder(ctx context.Context, orderId string) error {
|
func (k *KlarnaClient) AcknowledgeOrder(orderId string) error {
|
||||||
req, err := http.NewRequest("POST", fmt.Sprintf("%s/ordermanagement/v1/orders/%s/acknowledge", k.Url, orderId), nil)
|
req, err := http.NewRequest("POST", fmt.Sprintf("%s/ordermanagement/v1/orders/%s/acknowledge", k.Url, orderId), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
spanCtx, span := tracer.Start(ctx, "Acknowledge klarna order")
|
|
||||||
defer span.End()
|
|
||||||
req = req.WithContext(spanCtx)
|
|
||||||
id := uuid.New()
|
id := uuid.New()
|
||||||
|
|
||||||
req.SetBasicAuth(k.UserName, k.Password)
|
req.SetBasicAuth(k.UserName, k.Password)
|
||||||
421
main.go
Normal file
421
main.go
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/http/pprof"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
|
"k8s.io/client-go/rest"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
grainSpawns = promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "cart_grain_spawned_total",
|
||||||
|
Help: "The total number of spawned grains",
|
||||||
|
})
|
||||||
|
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "cart_grain_mutations_total",
|
||||||
|
Help: "The total number of mutations",
|
||||||
|
})
|
||||||
|
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "cart_grain_lookups_total",
|
||||||
|
Help: "The total number of lookups",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var podIp = os.Getenv("POD_IP")
|
||||||
|
var name = os.Getenv("POD_NAME")
|
||||||
|
var amqpUrl = os.Getenv("AMQP_URL")
|
||||||
|
var KlarnaInstance = NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
|
||||||
|
|
||||||
|
func GetDiscovery() Discovery {
|
||||||
|
if podIp == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
config, kerr := rest.InClusterConfig()
|
||||||
|
|
||||||
|
if kerr != nil {
|
||||||
|
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
|
||||||
|
}
|
||||||
|
client, err := kubernetes.NewForConfig(config)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error creating client: %v\n", err)
|
||||||
|
}
|
||||||
|
return NewK8sDiscovery(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tpl = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>s10r testing - checkout</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
%s
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|
||||||
|
func getCountryFromHost(host string) string {
|
||||||
|
if strings.Contains(strings.ToLower(host), "-no") {
|
||||||
|
return "no"
|
||||||
|
}
|
||||||
|
return "se"
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCheckoutOrder(host string, cartId CartId) *messages.CreateCheckoutOrder {
|
||||||
|
baseUrl := fmt.Sprintf("https://%s", host)
|
||||||
|
cartBaseUrl := os.Getenv("CART_BASE_URL")
|
||||||
|
if cartBaseUrl == "" {
|
||||||
|
cartBaseUrl = "https://cart.tornberg.me"
|
||||||
|
}
|
||||||
|
country := getCountryFromHost(host)
|
||||||
|
|
||||||
|
return &messages.CreateCheckoutOrder{
|
||||||
|
Terms: fmt.Sprintf("%s/terms", baseUrl),
|
||||||
|
Checkout: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}", baseUrl),
|
||||||
|
Confirmation: fmt.Sprintf("%s/confirmation/{checkout.order.id}", baseUrl),
|
||||||
|
Validation: fmt.Sprintf("%s/validation", cartBaseUrl),
|
||||||
|
Push: fmt.Sprintf("%s/push?order_id={checkout.order.id}", cartBaseUrl),
|
||||||
|
Country: country,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
|
||||||
|
storage, err := NewDiskStorage(fmt.Sprintf("data/%s_state.gob", name))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error loading state: %v\n", err)
|
||||||
|
}
|
||||||
|
app := &App{
|
||||||
|
pool: NewGrainLocalPool(65535, 5*time.Minute, spawn),
|
||||||
|
storage: storage,
|
||||||
|
}
|
||||||
|
|
||||||
|
syncedPool, err := NewSyncedPool(app.pool, podIp, GetDiscovery())
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error creating synced pool: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start unified gRPC server (CartActor + ControlPlane) replacing legacy RPC server on :1337
|
||||||
|
// TODO: Remove any remaining legacy RPC server references and deprecated frame-based code after full gRPC migration is validated.
|
||||||
|
grpcSrv, err := StartGRPCServer(":1337", app.pool, syncedPool)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error starting gRPC server: %v\n", err)
|
||||||
|
}
|
||||||
|
defer grpcSrv.GracefulStop()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for range time.Tick(time.Minute * 10) {
|
||||||
|
err := app.Save()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error saving: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
orderHandler := &AmqpOrderHandler{
|
||||||
|
Url: amqpUrl,
|
||||||
|
}
|
||||||
|
|
||||||
|
syncedServer := NewPoolServer(syncedPool, fmt.Sprintf("%s, %s", name, podIp))
|
||||||
|
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 /save", app.HandleSave)
|
||||||
|
//mux.HandleFunc("/", app.RewritePath)
|
||||||
|
mux.HandleFunc("/debug/pprof/", pprof.Index)
|
||||||
|
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||||
|
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
||||||
|
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
||||||
|
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||||
|
mux.Handle("/metrics", promhttp.Handler())
|
||||||
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Grain pool health: simple capacity check (mirrors previous GrainHandler.IsHealthy)
|
||||||
|
app.pool.mu.RLock()
|
||||||
|
grainCount := len(app.pool.grains)
|
||||||
|
capacity := app.pool.PoolSize
|
||||||
|
app.pool.mu.RUnlock()
|
||||||
|
if grainCount >= capacity {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte("grain pool at capacity"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !syncedPool.IsHealthy() {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte("control plane not healthy"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/checkout", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
orderId := r.URL.Query().Get("order_id")
|
||||||
|
order := &CheckoutOrder{}
|
||||||
|
|
||||||
|
if orderId == "" {
|
||||||
|
cookie, err := r.Cookie("cartid")
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cookie.Value == "" {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte("no cart id to checkout is empty"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cartId := ToCartId(cookie.Value)
|
||||||
|
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()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
prevOrder, err := KlarnaInstance.GetOrder(orderId)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
order = prevOrder
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(fmt.Sprintf(tpl, order.HTMLSnippet)))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
orderId := r.PathValue("order_id")
|
||||||
|
order, err := KlarnaInstance.GetOrder(orderId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if order.Status == "checkout_complete" {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "cartid",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
Secure: true,
|
||||||
|
HttpOnly: true,
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(fmt.Sprintf(tpl, order.HTMLSnippet)))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/validate", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Printf("Klarna order validation, method: %s", r.Method)
|
||||||
|
if r.Method != "POST" {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
order := &CheckoutOrder{}
|
||||||
|
err := json.NewDecoder(r.Body).Decode(order)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
log.Printf("Klarna order validation: %s", order.ID)
|
||||||
|
//err = confirmOrder(order, orderHandler)
|
||||||
|
//if err != nil {
|
||||||
|
// log.Printf("Error validating order: %v\n", err)
|
||||||
|
// w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
// return
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//err = triggerOrderCompleted(err, syncedServer, order)
|
||||||
|
//if err != nil {
|
||||||
|
// log.Printf("Error processing cart message: %v\n", err)
|
||||||
|
// w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
// return
|
||||||
|
//}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/push", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orderId := r.URL.Query().Get("order_id")
|
||||||
|
log.Printf("Order confirmation push: %s", orderId)
|
||||||
|
|
||||||
|
order, err := KlarnaInstance.GetOrder(orderId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error creating request: %v\n", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = confirmOrder(order, orderHandler)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error confirming order: %v\n", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = triggerOrderCompleted(err, syncedServer, order)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error processing cart message: %v\n", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = KlarnaInstance.AcknowledgeOrder(orderId)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error acknowledging order: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("1.0.0"))
|
||||||
|
})
|
||||||
|
|
||||||
|
sigs := make(chan os.Signal, 1)
|
||||||
|
done := make(chan bool, 1)
|
||||||
|
signal.Notify(sigs, syscall.SIGTERM)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
sig := <-sigs
|
||||||
|
fmt.Println("Shutting down due to signal:", sig)
|
||||||
|
go syncedPool.Close()
|
||||||
|
app.Save()
|
||||||
|
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 confirmOrder(order *CheckoutOrder, orderHandler *AmqpOrderHandler) error {
|
||||||
|
orderToSend, err := json.Marshal(order)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = orderHandler.Connect()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer orderHandler.Close()
|
||||||
|
err = orderHandler.OrderCompleted(orderToSend)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
315
message-handler.go
Normal file
315
message-handler.go
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
15
message-types.go
Normal file
15
message-types.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
const (
|
||||||
|
AddRequestType = 1
|
||||||
|
AddItemType = 2
|
||||||
|
|
||||||
|
RemoveItemType = 4
|
||||||
|
RemoveDeliveryType = 5
|
||||||
|
ChangeQuantityType = 6
|
||||||
|
SetDeliveryType = 7
|
||||||
|
SetPickupPointType = 8
|
||||||
|
CreateCheckoutOrderType = 9
|
||||||
|
SetCartItemsType = 10
|
||||||
|
OrderCompletedType = 11
|
||||||
|
)
|
||||||
94
message.go
Normal file
94
message.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
package actor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"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(ctx context.Context, id uint64, grain Grain[V]) error
|
|
||||||
LoadEventsFunc(ctx context.Context, id uint64, grain Grain[V], condition func(msg proto.Message, index int, timeStamp time.Time) bool) error
|
|
||||||
AppendMutations(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]) LoadEventsFunc(ctx context.Context, id uint64, grain Grain[V], condition func(msg proto.Message, index int, timeStamp time.Time) bool) 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()
|
|
||||||
index := 0
|
|
||||||
return s.Load(fh, func(msg proto.Message, when time.Time) {
|
|
||||||
if condition(msg, index, when) {
|
|
||||||
s.registry.Apply(ctx, grain, msg)
|
|
||||||
}
|
|
||||||
index++
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *DiskStorage[V]) LoadEvents(ctx context.Context, 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, _ time.Time) {
|
|
||||||
s.registry.Apply(ctx, grain, msg)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *DiskStorage[V]) Close() {
|
|
||||||
if s.queue != nil {
|
|
||||||
s.save()
|
|
||||||
}
|
|
||||||
close(s.done)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *DiskStorage[V]) AppendMutations(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
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package actor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Grain[V any] interface {
|
|
||||||
GetId() uint64
|
|
||||||
|
|
||||||
GetLastAccess() time.Time
|
|
||||||
GetLastChange() time.Time
|
|
||||||
GetCurrentState() (*V, error)
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package actor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"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(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[V], error)
|
|
||||||
Get(ctx context.Context, 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)
|
|
||||||
AddRemoteHost(host string)
|
|
||||||
IsHealthy() bool
|
|
||||||
IsKnown(string) bool
|
|
||||||
GetPubSub() *PubSub
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
package actor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
messages "git.k6n.net/go-cart-actor/pkg/messages"
|
|
||||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
|
||||||
"go.opentelemetry.io/otel"
|
|
||||||
"go.opentelemetry.io/otel/attribute"
|
|
||||||
"go.opentelemetry.io/otel/metric"
|
|
||||||
"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]
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = "grpc_server"
|
|
||||||
|
|
||||||
var (
|
|
||||||
tracer = otel.Tracer(name)
|
|
||||||
meter = otel.Meter(name)
|
|
||||||
logger = otelslog.NewLogger(name)
|
|
||||||
pingCalls metric.Int64Counter
|
|
||||||
negotiateCalls metric.Int64Counter
|
|
||||||
getLocalActorIdsCalls metric.Int64Counter
|
|
||||||
announceOwnershipCalls metric.Int64Counter
|
|
||||||
announceExpiryCalls metric.Int64Counter
|
|
||||||
closingCalls metric.Int64Counter
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
var err error
|
|
||||||
pingCalls, err = meter.Int64Counter("grpc.ping_calls",
|
|
||||||
metric.WithDescription("Number of ping calls"),
|
|
||||||
metric.WithUnit("{calls}"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
negotiateCalls, err = meter.Int64Counter("grpc.negotiate_calls",
|
|
||||||
metric.WithDescription("Number of negotiate calls"),
|
|
||||||
metric.WithUnit("{calls}"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
getLocalActorIdsCalls, err = meter.Int64Counter("grpc.get_local_actor_ids_calls",
|
|
||||||
metric.WithDescription("Number of get local actor ids calls"),
|
|
||||||
metric.WithUnit("{calls}"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
announceOwnershipCalls, err = meter.Int64Counter("grpc.announce_ownership_calls",
|
|
||||||
metric.WithDescription("Number of announce ownership calls"),
|
|
||||||
metric.WithUnit("{calls}"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
announceExpiryCalls, err = meter.Int64Counter("grpc.announce_expiry_calls",
|
|
||||||
metric.WithDescription("Number of announce expiry calls"),
|
|
||||||
metric.WithUnit("{calls}"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
closingCalls, err = meter.Int64Counter("grpc.closing_calls",
|
|
||||||
metric.WithDescription("Number of closing calls"),
|
|
||||||
metric.WithUnit("{calls}"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ControlServer[V]) AnnounceOwnership(ctx context.Context, req *messages.OwnershipAnnounce) (*messages.OwnerChangeAck, error) {
|
|
||||||
ctx, span := tracer.Start(ctx, "grpc_announce_ownership")
|
|
||||||
defer span.End()
|
|
||||||
span.SetAttributes(
|
|
||||||
attribute.String("component", "controlplane"),
|
|
||||||
attribute.String("host", req.Host),
|
|
||||||
attribute.Int("id_count", len(req.Ids)),
|
|
||||||
)
|
|
||||||
logger.InfoContext(ctx, "announce ownership", "host", req.Host, "id_count", len(req.Ids))
|
|
||||||
announceOwnershipCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
|
|
||||||
|
|
||||||
err := s.pool.HandleOwnershipChange(req.Host, req.Ids)
|
|
||||||
if err != nil {
|
|
||||||
span.RecordError(err)
|
|
||||||
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) {
|
|
||||||
ctx, span := tracer.Start(ctx, "grpc_announce_expiry")
|
|
||||||
defer span.End()
|
|
||||||
span.SetAttributes(
|
|
||||||
attribute.String("component", "controlplane"),
|
|
||||||
attribute.String("host", req.Host),
|
|
||||||
attribute.Int("id_count", len(req.Ids)),
|
|
||||||
)
|
|
||||||
logger.InfoContext(ctx, "announce expiry", "host", req.Host, "id_count", len(req.Ids))
|
|
||||||
announceExpiryCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
|
|
||||||
|
|
||||||
err := s.pool.HandleRemoteExpiry(req.Host, req.Ids)
|
|
||||||
if err != nil {
|
|
||||||
span.RecordError(err)
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
|
|
||||||
host := s.pool.Hostname()
|
|
||||||
|
|
||||||
pingCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", host)))
|
|
||||||
|
|
||||||
// log.Printf("got ping")
|
|
||||||
return &messages.PingReply{
|
|
||||||
Host: host,
|
|
||||||
UnixTime: time.Now().Unix(),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ControlPlane: Negotiate (merge host views)
|
|
||||||
func (s *ControlServer[V]) Negotiate(ctx context.Context, req *messages.NegotiateRequest) (*messages.NegotiateReply, error) {
|
|
||||||
ctx, span := tracer.Start(ctx, "grpc_negotiate")
|
|
||||||
defer span.End()
|
|
||||||
span.SetAttributes(
|
|
||||||
attribute.String("component", "controlplane"),
|
|
||||||
attribute.Int("known_hosts_count", len(req.KnownHosts)),
|
|
||||||
)
|
|
||||||
logger.InfoContext(ctx, "negotiate", "known_hosts_count", len(req.KnownHosts))
|
|
||||||
negotiateCalls.Add(ctx, 1)
|
|
||||||
|
|
||||||
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) {
|
|
||||||
ctx, span := tracer.Start(ctx, "grpc_get_local_actor_ids")
|
|
||||||
defer span.End()
|
|
||||||
ids := s.pool.GetLocalIds()
|
|
||||||
span.SetAttributes(
|
|
||||||
attribute.String("component", "controlplane"),
|
|
||||||
attribute.Int("id_count", len(ids)),
|
|
||||||
)
|
|
||||||
logger.InfoContext(ctx, "get local actor ids", "id_count", len(ids))
|
|
||||||
getLocalActorIdsCalls.Add(ctx, 1)
|
|
||||||
|
|
||||||
return &messages.ActorIdsReply{Ids: ids}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ControlPlane: Closing (peer shutdown notification)
|
|
||||||
func (s *ControlServer[V]) Closing(ctx context.Context, req *messages.ClosingNotice) (*messages.OwnerChangeAck, error) {
|
|
||||||
ctx, span := tracer.Start(ctx, "grpc_closing")
|
|
||||||
defer span.End()
|
|
||||||
host := req.GetHost()
|
|
||||||
span.SetAttributes(
|
|
||||||
attribute.String("component", "controlplane"),
|
|
||||||
attribute.String("host", host),
|
|
||||||
)
|
|
||||||
logger.InfoContext(ctx, "closing notice", "host", host)
|
|
||||||
closingCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", host)))
|
|
||||||
|
|
||||||
if host != "" {
|
|
||||||
s.pool.RemoveHost(host)
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package actor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"github.com/matst80/slask-finder/pkg/messaging"
|
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
|
||||||
)
|
|
||||||
|
|
||||||
type LogListener interface {
|
|
||||||
AppendMutations(id uint64, msg ...ApplyResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
type AmqpListener struct {
|
|
||||||
conn *amqp.Connection
|
|
||||||
transformer func(id uint64, msg []ApplyResult) (any, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAmqpListener(conn *amqp.Connection, transformer func(id uint64, msg []ApplyResult) (any, error)) *AmqpListener {
|
|
||||||
return &AmqpListener{
|
|
||||||
conn: conn,
|
|
||||||
transformer: transformer,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *AmqpListener) DefineTopics() {
|
|
||||||
ch, err := l.conn.Channel()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to open a channel: %v", err)
|
|
||||||
}
|
|
||||||
defer ch.Close()
|
|
||||||
if err := messaging.DefineTopic(ch, "cart", "mutation"); err != nil {
|
|
||||||
log.Fatalf("Failed to declare topic mutation: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *AmqpListener) AppendMutations(id uint64, msg ...ApplyResult) {
|
|
||||||
data, err := l.transformer(id, msg)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to transform mutation event: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = messaging.SendChange(l.conn, "cart", "mutation", data)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to send mutation event: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
package actor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"reflect"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/gogo/protobuf/proto"
|
|
||||||
"go.opentelemetry.io/otel/attribute"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ApplyResult struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Mutation proto.Message `json:"mutation"`
|
|
||||||
Error error `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MutationProcessor interface {
|
|
||||||
Process(ctx context.Context, grain any) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type BasicMutationProcessor[V any] struct {
|
|
||||||
processor func(ctx context.Context, grain V) error
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewMutationProcessor[V any](process func(ctx context.Context, grain V) error) MutationProcessor {
|
|
||||||
return &BasicMutationProcessor[V]{
|
|
||||||
processor: process,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *BasicMutationProcessor[V]) Process(ctx context.Context, grain any) error {
|
|
||||||
return p.processor(ctx, grain.(V))
|
|
||||||
}
|
|
||||||
|
|
||||||
type MutationRegistry interface {
|
|
||||||
Apply(ctx context.Context, grain any, msg ...proto.Message) ([]ApplyResult, error)
|
|
||||||
RegisterMutations(handlers ...MutationHandler)
|
|
||||||
Create(typeName string) (proto.Message, bool)
|
|
||||||
GetTypeName(msg proto.Message) (string, bool)
|
|
||||||
RegisterProcessor(processor ...MutationProcessor)
|
|
||||||
//GetStorageEvent(msg proto.Message) StorageEvent
|
|
||||||
//FromStorageEvent(event StorageEvent) (proto.Message, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProtoMutationRegistry struct {
|
|
||||||
mutationRegistryMu sync.RWMutex
|
|
||||||
mutationRegistry map[reflect.Type]MutationHandler
|
|
||||||
processors []MutationProcessor
|
|
||||||
}
|
|
||||||
|
|
||||||
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{},
|
|
||||||
processors: make([]MutationProcessor, 0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ProtoMutationRegistry) RegisterProcessor(processors ...MutationProcessor) {
|
|
||||||
r.processors = append(r.processors, processors...)
|
|
||||||
}
|
|
||||||
|
|
||||||
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(ctx context.Context, grain any, msg ...proto.Message) ([]ApplyResult, error) {
|
|
||||||
|
|
||||||
parentCtx, span := tracer.Start(ctx, "apply mutations")
|
|
||||||
defer span.End()
|
|
||||||
span.SetAttributes(
|
|
||||||
attribute.String("component", "registry"),
|
|
||||||
attribute.Int("mutations", len(msg)),
|
|
||||||
)
|
|
||||||
|
|
||||||
results := make([]ApplyResult, 0, len(msg))
|
|
||||||
|
|
||||||
if grain == nil {
|
|
||||||
return results, fmt.Errorf("nil grain")
|
|
||||||
}
|
|
||||||
// Nil slice of mutations still treated as an error (call contract violation).
|
|
||||||
if msg == nil {
|
|
||||||
return results, fmt.Errorf("nil mutation message")
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, m := range msg {
|
|
||||||
// Ignore nil mutation elements (untyped or typed nil pointers) silently; they carry no data.
|
|
||||||
if m == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Typed nil: interface holds concrete proto message type whose pointer value is nil.
|
|
||||||
rv := reflect.ValueOf(m)
|
|
||||||
if rv.Kind() == reflect.Ptr && rv.IsNil() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rt := indirectType(reflect.TypeOf(m))
|
|
||||||
_, msgSpan := tracer.Start(parentCtx, rt.Name())
|
|
||||||
|
|
||||||
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
|
|
||||||
} else {
|
|
||||||
err := entry.Handle(grain, m)
|
|
||||||
if err != nil {
|
|
||||||
msgSpan.RecordError(err)
|
|
||||||
}
|
|
||||||
results = append(results, ApplyResult{Error: err, Type: rt.Name(), Mutation: m})
|
|
||||||
}
|
|
||||||
msgSpan.End()
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(results) > 0 {
|
|
||||||
processCtx, processSpan := tracer.Start(ctx, "after mutation processors")
|
|
||||||
defer processSpan.End()
|
|
||||||
for _, processor := range r.processors {
|
|
||||||
|
|
||||||
err := processor.Process(processCtx, grain)
|
|
||||||
if err != nil {
|
|
||||||
return results, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
package actor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"reflect"
|
|
||||||
"slices"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.k6n.net/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(context.Background(), 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(context.Background(), state, nil); err == nil {
|
|
||||||
t.Fatalf("expected error for nil mutation message")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply unregistered message
|
|
||||||
if _, err := reg.Apply(context.Background(), 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
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
package actor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Event represents an event to be published.
|
|
||||||
type Event struct {
|
|
||||||
Topic string
|
|
||||||
Payload interface{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NotifyFunc is a function to notify a grain of an event.
|
|
||||||
type NotifyFunc func(grainID uint64, event Event)
|
|
||||||
|
|
||||||
// Subscribable is an interface for grains that can receive notifications.
|
|
||||||
type Subscribable interface {
|
|
||||||
Notify(event Event)
|
|
||||||
UpdateSubscriptions(pubsub *PubSub)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PubSub manages subscriptions for grains to topics.
|
|
||||||
// Topics are strings, e.g., "sku:12345"
|
|
||||||
// Subscribers are grain IDs (uint64)
|
|
||||||
type PubSub struct {
|
|
||||||
subscribers map[string][]uint64
|
|
||||||
mu sync.RWMutex
|
|
||||||
notify NotifyFunc
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPubSub creates a new PubSub instance.
|
|
||||||
func NewPubSub(notify NotifyFunc) *PubSub {
|
|
||||||
return &PubSub{
|
|
||||||
subscribers: make(map[string][]uint64),
|
|
||||||
notify: notify,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe adds a grain ID to the subscribers of a topic.
|
|
||||||
func (p *PubSub) Subscribe(topic string, grainID uint64) {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
p.subscribers[topic] = append(p.subscribers[topic], grainID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unsubscribe removes a grain ID from the subscribers of a topic.
|
|
||||||
func (p *PubSub) Unsubscribe(topic string, grainID uint64) {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
list := p.subscribers[topic]
|
|
||||||
for i, id := range list {
|
|
||||||
if id == grainID {
|
|
||||||
p.subscribers[topic] = append(list[:i], list[i+1:]...)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If list is empty, could delete, but not necessary
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnsubscribeAll removes the grain ID from all topics.
|
|
||||||
func (p *PubSub) UnsubscribeAll(grainID uint64) {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
for topic, list := range p.subscribers {
|
|
||||||
newList := make([]uint64, 0, len(list))
|
|
||||||
for _, id := range list {
|
|
||||||
if id != grainID {
|
|
||||||
newList = append(newList, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(newList) == 0 {
|
|
||||||
delete(p.subscribers, topic)
|
|
||||||
} else {
|
|
||||||
p.subscribers[topic] = newList
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSubscribers returns a copy of the subscriber IDs for a topic.
|
|
||||||
func (p *PubSub) GetSubscribers(topic string) []uint64 {
|
|
||||||
p.mu.RLock()
|
|
||||||
defer p.mu.RUnlock()
|
|
||||||
list := p.subscribers[topic]
|
|
||||||
return append([]uint64(nil), list...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish sends an event to all subscribers of the topic.
|
|
||||||
func (p *PubSub) Publish(event Event) {
|
|
||||||
subs := p.GetSubscribers(event.Topic)
|
|
||||||
for _, id := range subs {
|
|
||||||
p.notify(id, event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,478 +0,0 @@
|
|||||||
package actor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"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(ctx context.Context, id uint64) (Grain[V], error)
|
|
||||||
spawnHost func(host string) (Host, error)
|
|
||||||
listeners []LogListener
|
|
||||||
storage LogStorage[V]
|
|
||||||
ttl time.Duration
|
|
||||||
poolSize int
|
|
||||||
pubsub *PubSub
|
|
||||||
|
|
||||||
// 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(ctx context.Context, 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]) AddListener(listener LogListener) {
|
|
||||||
p.listeners = append(p.listeners, listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) RemoveListener(listener LogListener) {
|
|
||||||
for i, l := range p.listeners {
|
|
||||||
if l == listener {
|
|
||||||
p.listeners = append(p.listeners[:i], p.listeners[i+1:]...)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
if p.pubsub != nil {
|
|
||||||
p.pubsub.UnsubscribeAll(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]) AddRemoteHost(host string) {
|
|
||||||
p.AddRemote(host)
|
|
||||||
}
|
|
||||||
|
|
||||||
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(ctx context.Context, 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(ctx, 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(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[*V], error) {
|
|
||||||
grain, err := p.getOrClaimGrain(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
mutations, err := p.mutationRegistry.Apply(ctx, grain, mutation...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if p.storage != nil {
|
|
||||||
go func() {
|
|
||||||
if err := p.storage.AppendMutations(id, mutation...); err != nil {
|
|
||||||
log.Printf("failed to store mutation for grain %d: %v", id, err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
for _, listener := range p.listeners {
|
|
||||||
go listener.AppendMutations(id, mutations...)
|
|
||||||
}
|
|
||||||
result, err := grain.GetCurrentState()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if p.pubsub != nil {
|
|
||||||
if sub, ok := any(grain).(Subscribable); ok {
|
|
||||||
sub.UpdateSubscriptions(p.pubsub)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &MutationResult[*V]{
|
|
||||||
Result: result,
|
|
||||||
Mutations: mutations,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns the current state of a grain.
|
|
||||||
func (p *SimpleGrainPool[V]) Get(ctx context.Context, id uint64) (*V, error) {
|
|
||||||
grain, err := p.getOrClaimGrain(ctx, 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPubSub returns the pubsub instance.
|
|
||||||
func (p *SimpleGrainPool[V]) GetPubSub() *PubSub {
|
|
||||||
return p.pubsub
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) SetPubSub(pubsub *PubSub) {
|
|
||||||
p.pubsub = pubsub
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) Publish(event Event) {
|
|
||||||
if p.pubsub != nil {
|
|
||||||
p.pubsub.Publish(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
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, timeStamp time.Time)) 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, evt.TimeStamp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err == io.EOF {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *StateStorage) Append(io io.Writer, mutation proto.Message, timeStamp time.Time) error {
|
|
||||||
typeName, ok := s.registry.GetTypeName(mutation)
|
|
||||||
if !ok {
|
|
||||||
return ErrUnknownType
|
|
||||||
}
|
|
||||||
event := &StorageEvent{
|
|
||||||
Type: typeName,
|
|
||||||
TimeStamp: timeStamp,
|
|
||||||
Mutation: mutation,
|
|
||||||
}
|
|
||||||
jsonBytes, err := json.Marshal(event)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := io.Write(jsonBytes); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
io.Write([]byte("\n"))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *StateStorage) Read(r *bufio.Scanner) (*StorageEvent, error) {
|
|
||||||
var event rawEvent
|
|
||||||
|
|
||||||
if r.Scan() {
|
|
||||||
b := r.Bytes()
|
|
||||||
err := json.Unmarshal(b, &event)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
typeName := event.Type
|
|
||||||
mutation, ok := s.registry.Create(typeName)
|
|
||||||
if !ok {
|
|
||||||
return nil, ErrUnknownType
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(event.Mutation, mutation); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &StorageEvent{
|
|
||||||
Type: typeName,
|
|
||||||
TimeStamp: event.TimeStamp,
|
|
||||||
Mutation: mutation,
|
|
||||||
}, r.Err()
|
|
||||||
}
|
|
||||||
return nil, io.EOF
|
|
||||||
}
|
|
||||||
@@ -1,379 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"log"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/actor"
|
|
||||||
messages "git.k6n.net/go-cart-actor/pkg/messages"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/voucher"
|
|
||||||
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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"`
|
|
||||||
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"`
|
|
||||||
SellerId string `json:"sellerId,omitempty"`
|
|
||||||
OrgPrice *Price `json:"orgPrice,omitempty"`
|
|
||||||
Cgm string `json:"cgm,omitempty"`
|
|
||||||
Tax int
|
|
||||||
Stock uint16 `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"`
|
|
||||||
SaleStatus string `json:"saleStatus"`
|
|
||||||
Marking *Marking `json:"marking,omitempty"`
|
|
||||||
SubscriptionDetailsId string `json:"subscriptionDetailsId,omitempty"`
|
|
||||||
OrderReference string `json:"orderReference,omitempty"`
|
|
||||||
IsSubscribed bool `json:"isSubscribed,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 SubscriptionDetails struct {
|
|
||||||
Id string `json:"id,omitempty"`
|
|
||||||
Version uint16 `json:"version"`
|
|
||||||
OfferingCode string `json:"offeringCode,omitempty"`
|
|
||||||
SigningType string `json:"signingType,omitempty"`
|
|
||||||
Meta json.RawMessage `json:"data,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Notice struct {
|
|
||||||
Timestamp time.Time `json:"timestamp"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Code *string `json:"code,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Marking struct {
|
|
||||||
Type uint32 `json:"type"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GiftcardItem struct {
|
|
||||||
Id uint32 `json:"id"`
|
|
||||||
Value Price `json:"value"`
|
|
||||||
DeliveryDate string `json:"deliveryDate"`
|
|
||||||
Recipient string `json:"recipient"`
|
|
||||||
RecipientType string `json:"recipientType"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
DesignConfig json.RawMessage `json:"designConfig,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CartGrain struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
lastItemId uint32
|
|
||||||
lastDeliveryId uint32
|
|
||||||
lastVoucherId uint32
|
|
||||||
lastGiftcardId uint32
|
|
||||||
lastAccess time.Time
|
|
||||||
lastChange time.Time // unix seconds of last successful mutation (replay sets from event ts)
|
|
||||||
userId string
|
|
||||||
InventoryReserved bool `json:"inventoryReserved"`
|
|
||||||
Id CartId `json:"id"`
|
|
||||||
Items []*CartItem `json:"items"`
|
|
||||||
Giftcards []*GiftcardItem `json:"giftcards,omitempty"`
|
|
||||||
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"`
|
|
||||||
SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"`
|
|
||||||
PaymentDeclinedNotices []Notice `json:"paymentDeclinedNotices,omitempty"`
|
|
||||||
Confirmation *ConfirmationStatus `json:"confirmation,omitempty"`
|
|
||||||
CheckoutOrderId string `json:"checkoutOrderId,omitempty"`
|
|
||||||
CheckoutStatus string `json:"checkoutStatus,omitempty"`
|
|
||||||
CheckoutCountry string `json:"checkoutCountry,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ConfirmationStatus struct {
|
|
||||||
Code *string `json:"code,omitempty"`
|
|
||||||
ViewCount int `json:"viewCount"`
|
|
||||||
LastViewedAt time.Time `json:"lastViewedAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Voucher struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
Applied bool `json:"applied"`
|
|
||||||
Rules []string `json:"rules"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
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 _, expr := range v.Rules {
|
|
||||||
|
|
||||||
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 NewCartGrain(id uint64, ts time.Time) *CartGrain {
|
|
||||||
return &CartGrain{
|
|
||||||
lastItemId: 0,
|
|
||||||
lastDeliveryId: 0,
|
|
||||||
lastVoucherId: 0,
|
|
||||||
lastGiftcardId: 0,
|
|
||||||
lastAccess: ts,
|
|
||||||
lastChange: ts,
|
|
||||||
TotalDiscount: NewPrice(),
|
|
||||||
Vouchers: []*Voucher{},
|
|
||||||
Deliveries: []*CartDelivery{},
|
|
||||||
Giftcards: []*GiftcardItem{},
|
|
||||||
Id: CartId(id),
|
|
||||||
Items: []*CartItem{},
|
|
||||||
TotalPrice: NewPrice(),
|
|
||||||
SubscriptionDetails: make(map[string]*SubscriptionDetails),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify handles incoming events, e.g., inventory changes.
|
|
||||||
func (c *CartGrain) Notify(event actor.Event) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
// Example: if event is inventory change for a SKU in the cart
|
|
||||||
if strings.HasPrefix(event.Topic, "inventory:") {
|
|
||||||
sku := strings.TrimPrefix(event.Topic, "inventory:")
|
|
||||||
|
|
||||||
log.Printf("cart grain got inventory update: %s", event.Topic)
|
|
||||||
update, ok := event.Payload.(inventory.InventoryChange)
|
|
||||||
if !ok {
|
|
||||||
log.Printf("cart grain inventory update has invalid payload")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, item := range c.Items {
|
|
||||||
|
|
||||||
if item.Sku == sku && update.StockLocationID == *item.StoreId {
|
|
||||||
// Update stock status based on payload, e.g., if payload is bool available
|
|
||||||
|
|
||||||
log.Printf("cart grain got item stock update %d", update.Value)
|
|
||||||
|
|
||||||
item.Stock = uint16(update.Value)
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CartGrain) UpdateSubscriptions(pubsub *actor.PubSub) {
|
|
||||||
pubsub.UnsubscribeAll(c.GetId())
|
|
||||||
skuSet := make(map[string]bool)
|
|
||||||
for _, item := range c.Items {
|
|
||||||
skuSet[item.Sku] = true
|
|
||||||
}
|
|
||||||
for sku := range skuSet {
|
|
||||||
pubsub.Subscribe("inventory:"+sku, c.GetId())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
|
|
||||||
if item.OrgPrice != nil {
|
|
||||||
diff := NewPrice()
|
|
||||||
diff.Add(*item.OrgPrice)
|
|
||||||
diff.Subtract(item.Price)
|
|
||||||
diff.Multiply(int64(item.Quantity))
|
|
||||||
//rowTotal.Subtract(*diff)
|
|
||||||
item.Discount = diff
|
|
||||||
if diff.IncVat > 0 {
|
|
||||||
c.TotalDiscount.Add(*diff)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
item.TotalPrice = *rowTotal
|
|
||||||
|
|
||||||
c.TotalPrice.Add(*rowTotal)
|
|
||||||
|
|
||||||
}
|
|
||||||
for _, delivery := range c.Deliveries {
|
|
||||||
c.TotalPrice.Add(delivery.Price)
|
|
||||||
}
|
|
||||||
for _, giftcard := range c.Giftcards {
|
|
||||||
c.TotalPrice.Add(giftcard.Value)
|
|
||||||
}
|
|
||||||
for _, voucher := range c.Vouchers {
|
|
||||||
_, ok := voucher.AppliesTo(c)
|
|
||||||
voucher.Applied = false
|
|
||||||
if ok {
|
|
||||||
value := NewPriceFromIncVat(voucher.Value, 25)
|
|
||||||
if c.TotalPrice.IncVat <= value.IncVat {
|
|
||||||
// don't apply discounts to more than the total price
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
voucher.Applied = true
|
|
||||||
c.TotalDiscount.Add(*value)
|
|
||||||
c.TotalPrice.Subtract(*value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/actor"
|
|
||||||
messages "git.k6n.net/go-cart-actor/pkg/messages"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewCartMultationRegistry() actor.MutationRegistry {
|
|
||||||
|
|
||||||
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{}
|
|
||||||
}),
|
|
||||||
actor.NewMutation(UpsertSubscriptionDetails, func() *messages.UpsertSubscriptionDetails {
|
|
||||||
return &messages.UpsertSubscriptionDetails{}
|
|
||||||
}),
|
|
||||||
actor.NewMutation(InventoryReserved, func() *messages.InventoryReserved {
|
|
||||||
return &messages.InventoryReserved{}
|
|
||||||
}),
|
|
||||||
actor.NewMutation(PreConditionFailed, func() *messages.PreConditionFailed {
|
|
||||||
return &messages.PreConditionFailed{}
|
|
||||||
}),
|
|
||||||
actor.NewMutation(SetUserId, func() *messages.SetUserId {
|
|
||||||
return &messages.SetUserId{}
|
|
||||||
}),
|
|
||||||
actor.NewMutation(LineItemMarking, func() *messages.LineItemMarking {
|
|
||||||
return &messages.LineItemMarking{}
|
|
||||||
}),
|
|
||||||
actor.NewMutation(RemoveLineItemMarking, func() *messages.RemoveLineItemMarking {
|
|
||||||
return &messages.RemoveLineItemMarking{}
|
|
||||||
}),
|
|
||||||
actor.NewMutation(SubscriptionAdded, func() *messages.SubscriptionAdded {
|
|
||||||
return &messages.SubscriptionAdded{}
|
|
||||||
}),
|
|
||||||
actor.NewMutation(PaymentDeclined, func() *messages.PaymentDeclined {
|
|
||||||
return &messages.PaymentDeclined{}
|
|
||||||
}),
|
|
||||||
actor.NewMutation(ConfirmationViewed, func() *messages.ConfirmationViewed {
|
|
||||||
return &messages.ConfirmationViewed{}
|
|
||||||
}),
|
|
||||||
actor.NewMutation(CreateCheckoutOrder, func() *messages.CreateCheckoutOrder {
|
|
||||||
return &messages.CreateCheckoutOrder{}
|
|
||||||
}),
|
|
||||||
actor.NewMutation(AddGiftcard, func() *messages.AddGiftcard {
|
|
||||||
return &messages.AddGiftcard{}
|
|
||||||
}),
|
|
||||||
actor.NewMutation(RemoveGiftcard, func() *messages.RemoveGiftcard {
|
|
||||||
return &messages.RemoveGiftcard{}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return reg
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
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 != 500 {
|
|
||||||
t.Fatalf("TotalDiscount expected 500 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
messages "git.k6n.net/go-cart-actor/pkg/messages"
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
func AddGiftcard(grain *CartGrain, req *messages.AddGiftcard) error {
|
|
||||||
if req.Giftcard == nil {
|
|
||||||
return fmt.Errorf("giftcard cannot be nil")
|
|
||||||
}
|
|
||||||
if req.Giftcard.Value <= 0 {
|
|
||||||
return fmt.Errorf("giftcard value must be positive")
|
|
||||||
}
|
|
||||||
grain.lastGiftcardId++
|
|
||||||
designConfig := json.RawMessage{}
|
|
||||||
if req.Giftcard.DesignConfig != nil {
|
|
||||||
// Convert Any to RawMessage
|
|
||||||
data, err := proto.Marshal(req.Giftcard.DesignConfig)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to marshal designConfig: %w", err)
|
|
||||||
}
|
|
||||||
designConfig = data
|
|
||||||
}
|
|
||||||
value := NewPriceFromIncVat(req.Giftcard.Value, 25) // Assuming 25% tax; adjust as needed
|
|
||||||
item := &GiftcardItem{
|
|
||||||
Id: grain.lastGiftcardId,
|
|
||||||
Value: *value,
|
|
||||||
DeliveryDate: req.Giftcard.DeliveryDate,
|
|
||||||
Recipient: req.Giftcard.Recipient,
|
|
||||||
RecipientType: req.Giftcard.RecipientType,
|
|
||||||
Message: req.Giftcard.Message,
|
|
||||||
DesignConfig: designConfig,
|
|
||||||
}
|
|
||||||
grain.Giftcards = append(grain.Giftcards, item)
|
|
||||||
grain.UpdateTotals()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
messages "git.k6n.net/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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge with any existing item having same SKU and matching StoreId (including both nil).
|
|
||||||
for _, existing := range g.Items {
|
|
||||||
if existing.Sku != m.Sku {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sameStore := (existing.StoreId == nil && m.StoreId == nil) ||
|
|
||||||
(existing.StoreId != nil && m.StoreId != nil && *existing.StoreId == *m.StoreId)
|
|
||||||
if !sameStore {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
existing.Quantity += int(m.Quantity)
|
|
||||||
existing.Stock = uint16(m.Stock)
|
|
||||||
// If existing had nil store but new has one, adopt it.
|
|
||||||
if existing.StoreId == nil && m.StoreId != nil {
|
|
||||||
existing.StoreId = m.StoreId
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
g.mu.Lock()
|
|
||||||
defer g.mu.Unlock()
|
|
||||||
|
|
||||||
g.lastItemId++
|
|
||||||
taxRate := float32(25.0)
|
|
||||||
if m.Tax > 0 {
|
|
||||||
taxRate = float32(int(m.Tax) / 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
pricePerItem := NewPriceFromIncVat(m.Price, taxRate)
|
|
||||||
|
|
||||||
g.Items = append(g.Items, &CartItem{
|
|
||||||
Id: g.lastItemId,
|
|
||||||
ItemId: uint32(m.ItemId),
|
|
||||||
Quantity: int(m.Quantity),
|
|
||||||
Sku: m.Sku,
|
|
||||||
Tax: int(taxRate * 100),
|
|
||||||
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,
|
|
||||||
SellerName: m.SellerName,
|
|
||||||
},
|
|
||||||
SellerId: m.SellerId,
|
|
||||||
Cgm: m.Cgm,
|
|
||||||
SaleStatus: m.SaleStatus,
|
|
||||||
ParentId: m.ParentId,
|
|
||||||
|
|
||||||
Price: *pricePerItem,
|
|
||||||
TotalPrice: *MultiplyPrice(*pricePerItem, int64(m.Quantity)),
|
|
||||||
|
|
||||||
Stock: uint16(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)
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"slices"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/actor"
|
|
||||||
"git.k6n.net/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,
|
|
||||||
Applied: false,
|
|
||||||
Description: m.Description,
|
|
||||||
Code: m.Code,
|
|
||||||
Rules: m.VoucherRules,
|
|
||||||
Value: m.Value,
|
|
||||||
})
|
|
||||||
g.UpdateTotals()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
messages "git.k6n.net/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:]...)
|
|
||||||
g.UpdateTotals()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
g.Items[foundIndex].Quantity = int(m.Quantity)
|
|
||||||
g.UpdateTotals()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
messages "git.k6n.net/go-cart-actor/pkg/messages"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ConfirmationViewed(grain *CartGrain, req *messages.ConfirmationViewed) error {
|
|
||||||
if grain.Confirmation == nil {
|
|
||||||
grain.Confirmation = &ConfirmationStatus{
|
|
||||||
ViewCount: 1,
|
|
||||||
LastViewedAt: time.Now(),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
grain.Confirmation.ViewCount++
|
|
||||||
grain.Confirmation.LastViewedAt = time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
messages "git.k6n.net/go-cart-actor/pkg/messages"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
func CreateCheckoutOrder(grain *CartGrain, req *messages.CreateCheckoutOrder) error {
|
|
||||||
if len(grain.Items) == 0 {
|
|
||||||
return errors.New("cannot checkout empty cart")
|
|
||||||
}
|
|
||||||
if req.Terms != "accepted" {
|
|
||||||
return errors.New("terms must be accepted")
|
|
||||||
}
|
|
||||||
// Validate other fields as needed
|
|
||||||
grain.CheckoutOrderId = uuid.New().String()
|
|
||||||
grain.CheckoutStatus = "pending"
|
|
||||||
grain.CheckoutCountry = req.Country
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
messages "git.k6n.net/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
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import "git.k6n.net/go-cart-actor/pkg/messages"
|
|
||||||
|
|
||||||
func InventoryReserved(g *CartGrain, m *messages.InventoryReserved) error {
|
|
||||||
g.InventoryReserved = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
messages "git.k6n.net/go-cart-actor/pkg/messages"
|
|
||||||
)
|
|
||||||
|
|
||||||
func LineItemMarking(grain *CartGrain, req *messages.LineItemMarking) error {
|
|
||||||
for i, item := range grain.Items {
|
|
||||||
if item.Id == req.Id {
|
|
||||||
grain.Items[i].Marking = &Marking{
|
|
||||||
Type: req.Type,
|
|
||||||
Text: req.Marking,
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("item with ID %d not found", req.Id)
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
messages "git.k6n.net/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
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
messages "git.k6n.net/go-cart-actor/pkg/messages"
|
|
||||||
)
|
|
||||||
|
|
||||||
func PaymentDeclined(grain *CartGrain, req *messages.PaymentDeclined) error {
|
|
||||||
grain.PaymentStatus = "declined"
|
|
||||||
grain.PaymentDeclinedNotices = append(grain.PaymentDeclinedNotices, Notice{
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
Message: req.Message,
|
|
||||||
Code: req.Code,
|
|
||||||
})
|
|
||||||
// Optionally clear checkout order if in progress
|
|
||||||
if grain.CheckoutOrderId != "" {
|
|
||||||
grain.CheckoutOrderId = ""
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import messages "git.k6n.net/go-cart-actor/pkg/messages"
|
|
||||||
|
|
||||||
func PreConditionFailed(g *CartGrain, m *messages.PreConditionFailed) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
messages "git.k6n.net/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
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
messages "git.k6n.net/go-cart-actor/pkg/messages"
|
|
||||||
)
|
|
||||||
|
|
||||||
func RemoveGiftcard(grain *CartGrain, req *messages.RemoveGiftcard) error {
|
|
||||||
for i, item := range grain.Giftcards {
|
|
||||||
if item.Id == req.Id {
|
|
||||||
grain.Giftcards = append(grain.Giftcards[:i], grain.Giftcards[i+1:]...)
|
|
||||||
grain.UpdateTotals()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("giftcard with ID %d not found", req.Id)
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
messages "git.k6n.net/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
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
messages "git.k6n.net/go-cart-actor/pkg/messages"
|
|
||||||
)
|
|
||||||
|
|
||||||
func RemoveLineItemMarking(grain *CartGrain, req *messages.RemoveLineItemMarking) error {
|
|
||||||
for i, item := range grain.Items {
|
|
||||||
if item.Id == req.Id {
|
|
||||||
grain.Items[i].Marking = nil
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("item with ID %d not found", req.Id)
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"slices"
|
|
||||||
|
|
||||||
messages "git.k6n.net/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
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
messages "git.k6n.net/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
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
messages "git.k6n.net/go-cart-actor/pkg/messages"
|
|
||||||
)
|
|
||||||
|
|
||||||
func SetUserId(grain *CartGrain, req *messages.SetUserId) error {
|
|
||||||
if req.UserId == "" {
|
|
||||||
return errors.New("user ID cannot be empty")
|
|
||||||
}
|
|
||||||
grain.userId = req.UserId
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
messages "git.k6n.net/go-cart-actor/pkg/messages"
|
|
||||||
)
|
|
||||||
|
|
||||||
func SubscriptionAdded(grain *CartGrain, req *messages.SubscriptionAdded) error {
|
|
||||||
for i, item := range grain.Items {
|
|
||||||
if item.Id == req.ItemId {
|
|
||||||
grain.Items[i].SubscriptionDetailsId = req.DetailsId
|
|
||||||
grain.Items[i].OrderReference = req.OrderReference
|
|
||||||
grain.Items[i].IsSubscribed = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("item with ID %d not found", req.ItemId)
|
|
||||||
}
|
|
||||||
@@ -1,778 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"reflect"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gogo/protobuf/proto"
|
|
||||||
"google.golang.org/protobuf/types/known/anypb"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/actor"
|
|
||||||
messages "git.k6n.net/go-cart-actor/pkg/messages"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ----------------------
|
|
||||||
// Helper constructors
|
|
||||||
// ----------------------
|
|
||||||
|
|
||||||
func newTestGrain() *CartGrain {
|
|
||||||
return NewCartGrain(123, time.Now())
|
|
||||||
}
|
|
||||||
|
|
||||||
func newRegistry() actor.MutationRegistry {
|
|
||||||
return NewCartMultationRegistry()
|
|
||||||
}
|
|
||||||
|
|
||||||
func msgAddItem(sku string, price int64, qty int32, storePtr *string) *messages.AddItem {
|
|
||||||
return &messages.AddItem{
|
|
||||||
Sku: sku,
|
|
||||||
Price: price,
|
|
||||||
Quantity: qty,
|
|
||||||
// Tax left 0 -> handler uses default 25%
|
|
||||||
StoreId: storePtr,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func msgChangeQty(id uint32, qty int32) *messages.ChangeQuantity {
|
|
||||||
return &messages.ChangeQuantity{Id: id, Quantity: qty}
|
|
||||||
}
|
|
||||||
|
|
||||||
func msgRemoveItem(id uint32) *messages.RemoveItem {
|
|
||||||
return &messages.RemoveItem{Id: id}
|
|
||||||
}
|
|
||||||
|
|
||||||
func msgSetDelivery(provider string, items ...uint32) *messages.SetDelivery {
|
|
||||||
uitems := make([]uint32, len(items))
|
|
||||||
copy(uitems, items)
|
|
||||||
return &messages.SetDelivery{Provider: provider, Items: uitems}
|
|
||||||
}
|
|
||||||
|
|
||||||
func msgSetPickupPoint(deliveryId uint32, id string) *messages.SetPickupPoint {
|
|
||||||
return &messages.SetPickupPoint{
|
|
||||||
DeliveryId: deliveryId,
|
|
||||||
Id: id,
|
|
||||||
Name: ptr("Pickup"),
|
|
||||||
Address: ptr("Street 1"),
|
|
||||||
City: ptr("Town"),
|
|
||||||
Zip: ptr("12345"),
|
|
||||||
Country: ptr("SE"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func msgClearCart() *messages.ClearCartRequest {
|
|
||||||
return &messages.ClearCartRequest{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func msgAddVoucher(code string, value int64, rules ...string) *messages.AddVoucher {
|
|
||||||
return &messages.AddVoucher{Code: code, Value: value, VoucherRules: rules}
|
|
||||||
}
|
|
||||||
|
|
||||||
func msgRemoveVoucher(id uint32) *messages.RemoveVoucher {
|
|
||||||
return &messages.RemoveVoucher{Id: id}
|
|
||||||
}
|
|
||||||
|
|
||||||
func msgInitializeCheckout(orderId, status string, inProgress bool) *messages.InitializeCheckout {
|
|
||||||
return &messages.InitializeCheckout{OrderId: orderId, Status: status, PaymentInProgress: inProgress}
|
|
||||||
}
|
|
||||||
|
|
||||||
func msgOrderCreated(orderId, status string) *messages.OrderCreated {
|
|
||||||
return &messages.OrderCreated{OrderId: orderId, Status: status}
|
|
||||||
}
|
|
||||||
|
|
||||||
func msgSetUserId(userId string) *messages.SetUserId {
|
|
||||||
return &messages.SetUserId{UserId: userId}
|
|
||||||
}
|
|
||||||
|
|
||||||
func msgLineItemMarking(id uint32, typ uint32, marking string) *messages.LineItemMarking {
|
|
||||||
return &messages.LineItemMarking{Id: id, Type: typ, Marking: marking}
|
|
||||||
}
|
|
||||||
|
|
||||||
func msgRemoveLineItemMarking(id uint32) *messages.RemoveLineItemMarking {
|
|
||||||
return &messages.RemoveLineItemMarking{Id: id}
|
|
||||||
}
|
|
||||||
|
|
||||||
func msgSubscriptionAdded(itemId uint32, detailsId, orderRef string) *messages.SubscriptionAdded {
|
|
||||||
return &messages.SubscriptionAdded{ItemId: itemId, DetailsId: detailsId, OrderReference: orderRef}
|
|
||||||
}
|
|
||||||
|
|
||||||
func msgPaymentDeclined(message, code string) *messages.PaymentDeclined {
|
|
||||||
return &messages.PaymentDeclined{Message: message, Code: &code}
|
|
||||||
}
|
|
||||||
|
|
||||||
func msgConfirmationViewed() *messages.ConfirmationViewed {
|
|
||||||
return &messages.ConfirmationViewed{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func msgCreateCheckoutOrder(terms, country string) *messages.CreateCheckoutOrder {
|
|
||||||
return &messages.CreateCheckoutOrder{Terms: terms, Country: country}
|
|
||||||
}
|
|
||||||
|
|
||||||
func msgAddGiftcard(value int64, deliveryDate, recipient, recipientType, message string, designConfig *anypb.Any) *messages.AddGiftcard {
|
|
||||||
return &messages.AddGiftcard{
|
|
||||||
Giftcard: &messages.GiftcardItem{
|
|
||||||
Value: value,
|
|
||||||
DeliveryDate: deliveryDate,
|
|
||||||
Recipient: recipient,
|
|
||||||
RecipientType: recipientType,
|
|
||||||
Message: message,
|
|
||||||
DesignConfig: designConfig,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func msgRemoveGiftcard(id uint32) *messages.RemoveGiftcard {
|
|
||||||
return &messages.RemoveGiftcard{Id: id}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ptr[T any](v T) *T { return &v }
|
|
||||||
|
|
||||||
// ----------------------
|
|
||||||
// Apply helpers
|
|
||||||
// ----------------------
|
|
||||||
|
|
||||||
func applyOne(t *testing.T, reg actor.MutationRegistry, g *CartGrain, msg proto.Message) actor.ApplyResult {
|
|
||||||
t.Helper()
|
|
||||||
results, err := reg.Apply(context.Background(), g, msg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected registry-level error applying %T: %v", msg, err)
|
|
||||||
}
|
|
||||||
if len(results) != 1 {
|
|
||||||
t.Fatalf("expected exactly one ApplyResult, got %d", len(results))
|
|
||||||
}
|
|
||||||
return results[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expect success (nil error inside ApplyResult).
|
|
||||||
func applyOK(t *testing.T, reg actor.MutationRegistry, g *CartGrain, msg proto.Message) {
|
|
||||||
t.Helper()
|
|
||||||
res := applyOne(t, reg, g, msg)
|
|
||||||
if res.Error != nil {
|
|
||||||
t.Fatalf("expected mutation %s (%T) to succeed, got error: %v", res.Type, msg, res.Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expect an error matching substring.
|
|
||||||
func applyErrorContains(t *testing.T, reg actor.MutationRegistry, g *CartGrain, msg proto.Message, substr string) {
|
|
||||||
t.Helper()
|
|
||||||
res := applyOne(t, reg, g, msg)
|
|
||||||
if res.Error == nil {
|
|
||||||
t.Fatalf("expected error applying %T, got nil", msg)
|
|
||||||
}
|
|
||||||
if substr != "" && !strings.Contains(res.Error.Error(), substr) {
|
|
||||||
t.Fatalf("error mismatch, want substring %q got %q", substr, res.Error.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------
|
|
||||||
// Tests
|
|
||||||
// ----------------------
|
|
||||||
|
|
||||||
func TestMutationRegistryCoverage(t *testing.T) {
|
|
||||||
reg := newRegistry()
|
|
||||||
|
|
||||||
expected := []string{
|
|
||||||
"AddItem",
|
|
||||||
"ChangeQuantity",
|
|
||||||
"RemoveItem",
|
|
||||||
"InitializeCheckout",
|
|
||||||
"OrderCreated",
|
|
||||||
"RemoveDelivery",
|
|
||||||
"SetDelivery",
|
|
||||||
"SetPickupPoint",
|
|
||||||
"ClearCartRequest",
|
|
||||||
"AddVoucher",
|
|
||||||
"RemoveVoucher",
|
|
||||||
"UpsertSubscriptionDetails",
|
|
||||||
"InventoryReserved",
|
|
||||||
"PreConditionFailed",
|
|
||||||
"SetUserId",
|
|
||||||
"LineItemMarking",
|
|
||||||
"RemoveLineItemMarking",
|
|
||||||
"SubscriptionAdded",
|
|
||||||
"PaymentDeclined",
|
|
||||||
"ConfirmationViewed",
|
|
||||||
"CreateCheckoutOrder",
|
|
||||||
"AddGiftcard",
|
|
||||||
"RemoveGiftcard",
|
|
||||||
}
|
|
||||||
|
|
||||||
names := reg.(*actor.ProtoMutationRegistry).RegisteredMutations()
|
|
||||||
for _, want := range expected {
|
|
||||||
if !slices.Contains(names, want) {
|
|
||||||
t.Fatalf("registry missing mutation %s; got %v", want, names)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create() by name returns correct concrete type.
|
|
||||||
for _, name := range expected {
|
|
||||||
msg, ok := reg.Create(name)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("Create failed for %s", name)
|
|
||||||
}
|
|
||||||
rt := reflect.TypeOf(msg)
|
|
||||||
if rt.Kind() == reflect.Ptr {
|
|
||||||
rt = rt.Elem()
|
|
||||||
}
|
|
||||||
if rt.Name() != name {
|
|
||||||
t.Fatalf("Create(%s) returned wrong type %s", name, rt.Name())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unregistered create
|
|
||||||
if m, ok := reg.Create("DoesNotExist"); ok || m != nil {
|
|
||||||
t.Fatalf("Create should fail for unknown; got (%T,%v)", m, ok)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTypeName sanity
|
|
||||||
add := &messages.AddItem{}
|
|
||||||
nm, ok := reg.GetTypeName(add)
|
|
||||||
if !ok || nm != "AddItem" {
|
|
||||||
t.Fatalf("GetTypeName failed for AddItem, got (%q,%v)", nm, ok)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply unregistered message -> result should contain ErrMutationNotRegistered, no top-level error
|
|
||||||
results, err := reg.Apply(context.Background(), newTestGrain(), &messages.Noop{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected top-level error applying unregistered mutation: %v", err)
|
|
||||||
}
|
|
||||||
if len(results) != 1 || results[0].Error == nil || results[0].Error != actor.ErrMutationNotRegistered {
|
|
||||||
t.Fatalf("expected ApplyResult with ErrMutationNotRegistered, got %#v", results)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAddItemAndMerging(t *testing.T) {
|
|
||||||
reg := newRegistry()
|
|
||||||
g := newTestGrain()
|
|
||||||
|
|
||||||
// Merge scenario (same SKU + same store pointer)
|
|
||||||
add1 := msgAddItem("SKU-1", 1000, 2, nil)
|
|
||||||
applyOK(t, reg, g, add1)
|
|
||||||
|
|
||||||
if len(g.Items) != 1 || g.Items[0].Quantity != 2 {
|
|
||||||
t.Fatalf("expected first item added; items=%d qty=%d", len(g.Items), g.Items[0].Quantity)
|
|
||||||
}
|
|
||||||
|
|
||||||
applyOK(t, reg, g, msgAddItem("SKU-1", 1000, 3, nil)) // should merge
|
|
||||||
if len(g.Items) != 1 || g.Items[0].Quantity != 5 {
|
|
||||||
t.Fatalf("expected merge quantity=5 items=%d qty=%d", len(g.Items), g.Items[0].Quantity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Different store pointer -> new line
|
|
||||||
store := "S1"
|
|
||||||
applyOK(t, reg, g, msgAddItem("SKU-1", 1000, 1, &store))
|
|
||||||
if len(g.Items) != 2 {
|
|
||||||
t.Fatalf("expected second line for different store pointer; items=%d", len(g.Items))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same store pointer & SKU -> merge with second line
|
|
||||||
applyOK(t, reg, g, msgAddItem("SKU-1", 1000, 4, &store))
|
|
||||||
if len(g.Items) != 2 || g.Items[1].Quantity != 5 {
|
|
||||||
t.Fatalf("expected merge on second line; items=%d second.qty=%d", len(g.Items), g.Items[1].Quantity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalid quantity
|
|
||||||
applyErrorContains(t, reg, g, msgAddItem("BAD", 1000, 0, nil), "invalid quantity")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChangeQuantityBehavior(t *testing.T) {
|
|
||||||
reg := newRegistry()
|
|
||||||
g := newTestGrain()
|
|
||||||
|
|
||||||
applyOK(t, reg, g, msgAddItem("A", 1500, 2, nil))
|
|
||||||
id := g.Items[0].Id
|
|
||||||
|
|
||||||
// Increase quantity
|
|
||||||
applyOK(t, reg, g, msgChangeQty(id, 5))
|
|
||||||
if g.Items[0].Quantity != 5 {
|
|
||||||
t.Fatalf("quantity not updated expected=5 got=%d", g.Items[0].Quantity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove item by setting <=0
|
|
||||||
applyOK(t, reg, g, msgChangeQty(id, 0))
|
|
||||||
if len(g.Items) != 0 {
|
|
||||||
t.Fatalf("expected item removed; items=%d", len(g.Items))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not found
|
|
||||||
applyErrorContains(t, reg, g, msgChangeQty(9999, 1), "not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRemoveItemBehavior(t *testing.T) {
|
|
||||||
reg := newRegistry()
|
|
||||||
g := newTestGrain()
|
|
||||||
|
|
||||||
applyOK(t, reg, g, msgAddItem("X", 1200, 1, nil))
|
|
||||||
id := g.Items[0].Id
|
|
||||||
|
|
||||||
applyOK(t, reg, g, msgRemoveItem(id))
|
|
||||||
if len(g.Items) != 0 {
|
|
||||||
t.Fatalf("expected item removed; items=%d", len(g.Items))
|
|
||||||
}
|
|
||||||
|
|
||||||
applyErrorContains(t, reg, g, msgRemoveItem(id), "not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeliveryMutations(t *testing.T) {
|
|
||||||
reg := newRegistry()
|
|
||||||
g := newTestGrain()
|
|
||||||
|
|
||||||
applyOK(t, reg, g, msgAddItem("D1", 1000, 1, nil))
|
|
||||||
applyOK(t, reg, g, msgAddItem("D2", 2000, 1, nil))
|
|
||||||
i1 := g.Items[0].Id
|
|
||||||
|
|
||||||
// Explicit items
|
|
||||||
applyOK(t, reg, g, msgSetDelivery("POSTNORD", i1))
|
|
||||||
if len(g.Deliveries) != 1 || len(g.Deliveries[0].Items) != 1 || g.Deliveries[0].Items[0] != i1 {
|
|
||||||
t.Fatalf("delivery not created as expected: %+v", g.Deliveries)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt to attach an already-delivered item
|
|
||||||
applyErrorContains(t, reg, g, msgSetDelivery("POSTNORD", i1), "already has a delivery")
|
|
||||||
|
|
||||||
// Attach remaining item via empty list (auto include items without delivery)
|
|
||||||
applyOK(t, reg, g, msgSetDelivery("DHL"))
|
|
||||||
if len(g.Deliveries) != 2 {
|
|
||||||
t.Fatalf("expected second delivery; deliveries=%d", len(g.Deliveries))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-existent item
|
|
||||||
applyErrorContains(t, reg, g, msgSetDelivery("UPS", 99999), "not found")
|
|
||||||
|
|
||||||
// No eligible items left
|
|
||||||
applyErrorContains(t, reg, g, msgSetDelivery("UPS"), "no eligible items")
|
|
||||||
|
|
||||||
// Set pickup point on first delivery
|
|
||||||
did := g.Deliveries[0].Id
|
|
||||||
applyOK(t, reg, g, msgSetPickupPoint(did, "PP1"))
|
|
||||||
if g.Deliveries[0].PickupPoint == nil || g.Deliveries[0].PickupPoint.Id != "PP1" {
|
|
||||||
t.Fatalf("pickup point not set correctly: %+v", g.Deliveries[0].PickupPoint)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bad delivery id
|
|
||||||
applyErrorContains(t, reg, g, msgSetPickupPoint(9999, "PPX"), "delivery id")
|
|
||||||
|
|
||||||
// Remove delivery
|
|
||||||
applyOK(t, reg, g, &messages.RemoveDelivery{Id: did})
|
|
||||||
if len(g.Deliveries) != 1 || g.Deliveries[0].Id == did {
|
|
||||||
t.Fatalf("expected first delivery removed, remaining: %+v", g.Deliveries)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove delivery not found
|
|
||||||
applyErrorContains(t, reg, g, &messages.RemoveDelivery{Id: did}, "not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClearCart(t *testing.T) {
|
|
||||||
reg := newRegistry()
|
|
||||||
g := newTestGrain()
|
|
||||||
|
|
||||||
applyOK(t, reg, g, msgAddItem("X", 1000, 2, nil))
|
|
||||||
applyOK(t, reg, g, msgSetDelivery("P", g.Items[0].Id))
|
|
||||||
|
|
||||||
applyOK(t, reg, g, msgClearCart())
|
|
||||||
|
|
||||||
if len(g.Items) != 0 || len(g.Deliveries) != 0 {
|
|
||||||
t.Fatalf("expected cart cleared; items=%d deliveries=%d", len(g.Items), len(g.Deliveries))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVoucherMutations(t *testing.T) {
|
|
||||||
reg := newRegistry()
|
|
||||||
g := newTestGrain()
|
|
||||||
|
|
||||||
applyOK(t, reg, g, msgAddItem("VOUCH", 10000, 1, nil))
|
|
||||||
applyOK(t, reg, g, msgAddVoucher("PROMO", 5000))
|
|
||||||
|
|
||||||
if len(g.Vouchers) != 1 {
|
|
||||||
t.Fatalf("voucher not stored")
|
|
||||||
}
|
|
||||||
if g.TotalDiscount.IncVat != 5000 {
|
|
||||||
t.Fatalf("expected discount 5000 got %d", g.TotalDiscount.IncVat)
|
|
||||||
}
|
|
||||||
if g.TotalPrice.IncVat != 5000 {
|
|
||||||
t.Fatalf("expected total price 5000 got %d", g.TotalPrice.IncVat)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Duplicate voucher code
|
|
||||||
applyErrorContains(t, reg, g, msgAddVoucher("PROMO", 1000), "already applied")
|
|
||||||
|
|
||||||
// Add a large voucher (should not apply because value > total price)
|
|
||||||
applyOK(t, reg, g, msgAddVoucher("BIG", 100000))
|
|
||||||
if len(g.Vouchers) != 2 {
|
|
||||||
t.Fatalf("expected second voucher stored")
|
|
||||||
}
|
|
||||||
if g.TotalDiscount.IncVat != 5000 || g.TotalPrice.IncVat != 5000 {
|
|
||||||
t.Fatalf("large voucher incorrectly applied discount=%d total=%d",
|
|
||||||
g.TotalDiscount.IncVat, g.TotalPrice.IncVat)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove existing voucher
|
|
||||||
firstId := g.Vouchers[0].Id
|
|
||||||
applyOK(t, reg, g, msgRemoveVoucher(firstId))
|
|
||||||
|
|
||||||
if slices.ContainsFunc(g.Vouchers, func(v *Voucher) bool { return v.Id == firstId }) {
|
|
||||||
t.Fatalf("voucher id %d not removed", firstId)
|
|
||||||
}
|
|
||||||
// After removing PROMO, BIG remains but is not applied (exceeds price)
|
|
||||||
if g.TotalDiscount.IncVat != 0 || g.TotalPrice.IncVat != 10000 {
|
|
||||||
t.Fatalf("totals incorrect after removal discount=%d total=%d",
|
|
||||||
g.TotalDiscount.IncVat, g.TotalPrice.IncVat)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove not applied
|
|
||||||
applyErrorContains(t, reg, g, msgRemoveVoucher(firstId), "not applied")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckoutMutations(t *testing.T) {
|
|
||||||
reg := newRegistry()
|
|
||||||
g := newTestGrain()
|
|
||||||
|
|
||||||
applyOK(t, reg, g, msgInitializeCheckout("ORD-1", "PENDING", true))
|
|
||||||
if g.OrderReference != "ORD-1" || g.PaymentStatus != "PENDING" || !g.PaymentInProgress {
|
|
||||||
t.Fatalf("initialize checkout failed: ref=%s status=%s inProgress=%v",
|
|
||||||
g.OrderReference, g.PaymentStatus, g.PaymentInProgress)
|
|
||||||
}
|
|
||||||
|
|
||||||
applyOK(t, reg, g, msgOrderCreated("ORD-1", "COMPLETED"))
|
|
||||||
if g.OrderReference != "ORD-1" || g.PaymentStatus != "COMPLETED" || g.PaymentInProgress {
|
|
||||||
t.Fatalf("order created mutation failed: ref=%s status=%s inProgress=%v",
|
|
||||||
g.OrderReference, g.PaymentStatus, g.PaymentInProgress)
|
|
||||||
}
|
|
||||||
|
|
||||||
applyErrorContains(t, reg, g, msgInitializeCheckout("", "X", true), "missing orderId")
|
|
||||||
applyErrorContains(t, reg, g, msgOrderCreated("", "X"), "missing orderId")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSubscriptionDetailsMutation(t *testing.T) {
|
|
||||||
reg := newRegistry()
|
|
||||||
g := newTestGrain()
|
|
||||||
|
|
||||||
// Upsert new (Id == nil)
|
|
||||||
msgNew := &messages.UpsertSubscriptionDetails{
|
|
||||||
OfferingCode: "OFF1",
|
|
||||||
SigningType: "TYPE1",
|
|
||||||
}
|
|
||||||
applyOK(t, reg, g, msgNew)
|
|
||||||
if len(g.SubscriptionDetails) != 1 {
|
|
||||||
t.Fatalf("expected one subscription detail; got=%d", len(g.SubscriptionDetails))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture created id
|
|
||||||
var createdId string
|
|
||||||
for k := range g.SubscriptionDetails {
|
|
||||||
createdId = k
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update existing
|
|
||||||
msgUpdate := &messages.UpsertSubscriptionDetails{
|
|
||||||
Id: &createdId,
|
|
||||||
OfferingCode: "OFF2",
|
|
||||||
SigningType: "TYPE2",
|
|
||||||
}
|
|
||||||
applyOK(t, reg, g, msgUpdate)
|
|
||||||
if g.SubscriptionDetails[createdId].OfferingCode != "OFF2" ||
|
|
||||||
g.SubscriptionDetails[createdId].SigningType != "TYPE2" {
|
|
||||||
t.Fatalf("subscription details not updated: %+v", g.SubscriptionDetails[createdId])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update non-existent
|
|
||||||
badId := "NON_EXISTENT"
|
|
||||||
applyErrorContains(t, reg, g, &messages.UpsertSubscriptionDetails{Id: &badId}, "not found")
|
|
||||||
|
|
||||||
// Nil mutation should be ignored and produce zero results.
|
|
||||||
resultsNil, errNil := reg.Apply(context.Background(), g, (*messages.UpsertSubscriptionDetails)(nil))
|
|
||||||
if errNil != nil {
|
|
||||||
t.Fatalf("unexpected error for nil mutation element: %v", errNil)
|
|
||||||
}
|
|
||||||
if len(resultsNil) != 0 {
|
|
||||||
t.Fatalf("expected zero results for nil mutation, got %d", len(resultsNil))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure registry Apply handles nil grain and nil message defensive errors consistently.
|
|
||||||
func TestRegistryDefensiveErrors(t *testing.T) {
|
|
||||||
reg := newRegistry()
|
|
||||||
g := newTestGrain()
|
|
||||||
|
|
||||||
// Nil grain
|
|
||||||
results, err := reg.Apply(context.Background(), nil, &messages.AddItem{})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("expected error for nil grain")
|
|
||||||
}
|
|
||||||
if len(results) != 0 {
|
|
||||||
t.Fatalf("expected no results for nil grain")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nil message slice
|
|
||||||
results, _ = reg.Apply(context.Background(), g, nil)
|
|
||||||
|
|
||||||
if len(results) != 0 {
|
|
||||||
t.Fatalf("expected no results when message slice nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type SubscriptionDetailsRequest struct {
|
|
||||||
Id *string `json:"id,omitempty"`
|
|
||||||
OfferingCode string `json:"offeringCode,omitempty"`
|
|
||||||
SigningType string `json:"signingType,omitempty"`
|
|
||||||
Data json.RawMessage `json:"data,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sd *SubscriptionDetailsRequest) ToMessage() *messages.UpsertSubscriptionDetails {
|
|
||||||
return &messages.UpsertSubscriptionDetails{
|
|
||||||
Id: sd.Id,
|
|
||||||
OfferingCode: sd.OfferingCode,
|
|
||||||
SigningType: sd.SigningType,
|
|
||||||
Data: &anypb.Any{Value: sd.Data},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSubscriptionDetailsJSONValidation(t *testing.T) {
|
|
||||||
reg := newRegistry()
|
|
||||||
g := newTestGrain()
|
|
||||||
|
|
||||||
// Valid JSON on create
|
|
||||||
jsonStr := `{"offeringCode": "OFFJSON", "signingType": "TYPEJSON", "data": {"value":"test","a":1}}`
|
|
||||||
var validCreate SubscriptionDetailsRequest
|
|
||||||
if err := json.Unmarshal([]byte(jsonStr), &validCreate); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
applyOK(t, reg, g, validCreate.ToMessage())
|
|
||||||
if len(g.SubscriptionDetails) != 1 {
|
|
||||||
t.Fatalf("expected one subscription detail after valid create, got %d", len(g.SubscriptionDetails))
|
|
||||||
}
|
|
||||||
var id string
|
|
||||||
for k := range g.SubscriptionDetails {
|
|
||||||
id = k
|
|
||||||
}
|
|
||||||
if string(g.SubscriptionDetails[id].Meta) != `{"value":"test","a":1}` {
|
|
||||||
t.Fatalf("expected meta stored as valid json, got %s", string(g.SubscriptionDetails[id].Meta))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update with valid JSON replaces meta
|
|
||||||
jsonStr2 := fmt.Sprintf(`{"id": "%s", "data": {"value": "eyJjaGFuZ2VkIjoxMjN9"}}`, id)
|
|
||||||
var updateValid messages.UpsertSubscriptionDetails
|
|
||||||
if err := json.Unmarshal([]byte(jsonStr2), &updateValid); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
applyOK(t, reg, g, &updateValid)
|
|
||||||
if string(g.SubscriptionDetails[id].Meta) != `{"changed":123}` {
|
|
||||||
t.Fatalf("expected meta updated to new json, got %s", string(g.SubscriptionDetails[id].Meta))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalid JSON on create
|
|
||||||
jsonStr3 := `{"offeringCode": "BAD", "signingType": "TYPE", "data": {"value": "eyJicm9rZW4iO30="}}`
|
|
||||||
var invalidCreate messages.UpsertSubscriptionDetails
|
|
||||||
if err := json.Unmarshal([]byte(jsonStr3), &invalidCreate); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
res := applyOne(t, reg, g, &invalidCreate)
|
|
||||||
if res.Error == nil || !strings.Contains(res.Error.Error(), "invalid json") {
|
|
||||||
t.Fatalf("expected invalid json error on create, got %v", res.Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invalid JSON on update
|
|
||||||
jsonStr4 := fmt.Sprintf(`{"id": "%s", "data": {"value": "e29vcHM="}}`, id)
|
|
||||||
var badUpdate messages.UpsertSubscriptionDetails
|
|
||||||
if err := json.Unmarshal([]byte(jsonStr4), &badUpdate); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
res2 := applyOne(t, reg, g, &badUpdate)
|
|
||||||
if res2.Error == nil || !strings.Contains(res2.Error.Error(), "invalid json") {
|
|
||||||
t.Fatalf("expected invalid json error on update, got %v", res2.Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty Data should not overwrite existing meta
|
|
||||||
jsonStr5 := fmt.Sprintf(`{"id": "%s"}`, id)
|
|
||||||
var emptyUpdate messages.UpsertSubscriptionDetails
|
|
||||||
if err := json.Unmarshal([]byte(jsonStr5), &emptyUpdate); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
applyOK(t, reg, g, &emptyUpdate)
|
|
||||||
if string(g.SubscriptionDetails[id].Meta) != `{"changed":123}` {
|
|
||||||
t.Fatalf("empty update should not change meta, got %s", string(g.SubscriptionDetails[id].Meta))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetUserId(t *testing.T) {
|
|
||||||
reg := newRegistry()
|
|
||||||
g := newTestGrain()
|
|
||||||
|
|
||||||
applyOK(t, reg, g, msgSetUserId("user123"))
|
|
||||||
if g.userId != "user123" {
|
|
||||||
t.Fatalf("expected userId=user123, got %s", g.userId)
|
|
||||||
}
|
|
||||||
|
|
||||||
applyErrorContains(t, reg, g, msgSetUserId(""), "cannot be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLineItemMarking(t *testing.T) {
|
|
||||||
reg := newRegistry()
|
|
||||||
g := newTestGrain()
|
|
||||||
|
|
||||||
applyOK(t, reg, g, msgAddItem("MARK", 1000, 1, nil))
|
|
||||||
id := g.Items[0].Id
|
|
||||||
|
|
||||||
applyOK(t, reg, g, msgLineItemMarking(id, 1, "Gift message"))
|
|
||||||
if g.Items[0].Marking == nil || g.Items[0].Marking.Type != 1 || g.Items[0].Marking.Text != "Gift message" {
|
|
||||||
t.Fatalf("marking not set correctly: %+v", g.Items[0].Marking)
|
|
||||||
}
|
|
||||||
|
|
||||||
applyErrorContains(t, reg, g, msgLineItemMarking(9999, 2, "Test"), "not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRemoveLineItemMarking(t *testing.T) {
|
|
||||||
reg := newRegistry()
|
|
||||||
g := newTestGrain()
|
|
||||||
|
|
||||||
applyOK(t, reg, g, msgAddItem("REMOVE", 1000, 1, nil))
|
|
||||||
id := g.Items[0].Id
|
|
||||||
|
|
||||||
// First set a marking
|
|
||||||
applyOK(t, reg, g, msgLineItemMarking(id, 1, "Test marking"))
|
|
||||||
if g.Items[0].Marking == nil || g.Items[0].Marking.Text != "Test marking" {
|
|
||||||
t.Fatalf("marking not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now remove it
|
|
||||||
applyOK(t, reg, g, msgRemoveLineItemMarking(id))
|
|
||||||
if g.Items[0].Marking != nil {
|
|
||||||
t.Fatalf("marking not removed")
|
|
||||||
}
|
|
||||||
|
|
||||||
applyErrorContains(t, reg, g, msgRemoveLineItemMarking(9999), "not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSubscriptionAdded(t *testing.T) {
|
|
||||||
reg := newRegistry()
|
|
||||||
g := newTestGrain()
|
|
||||||
|
|
||||||
applyOK(t, reg, g, msgAddItem("SUB", 1000, 1, nil))
|
|
||||||
id := g.Items[0].Id
|
|
||||||
|
|
||||||
applyOK(t, reg, g, msgSubscriptionAdded(id, "det123", "ord456"))
|
|
||||||
if g.Items[0].SubscriptionDetailsId != "det123" || g.Items[0].OrderReference != "ord456" || !g.Items[0].IsSubscribed {
|
|
||||||
t.Fatalf("subscription not added: detailsId=%s orderRef=%s isSubscribed=%v",
|
|
||||||
g.Items[0].SubscriptionDetailsId, g.Items[0].OrderReference, g.Items[0].IsSubscribed)
|
|
||||||
}
|
|
||||||
|
|
||||||
applyErrorContains(t, reg, g, msgSubscriptionAdded(9999, "", ""), "not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPaymentDeclined(t *testing.T) {
|
|
||||||
reg := newRegistry()
|
|
||||||
g := newTestGrain()
|
|
||||||
|
|
||||||
g.CheckoutOrderId = "test-order"
|
|
||||||
applyOK(t, reg, g, msgPaymentDeclined("Payment failed due to insufficient funds", "INSUFFICIENT_FUNDS"))
|
|
||||||
if g.PaymentStatus != "declined" || g.CheckoutOrderId != "" {
|
|
||||||
t.Fatalf("payment declined not handled: status=%s checkoutId=%s", g.PaymentStatus, g.CheckoutOrderId)
|
|
||||||
}
|
|
||||||
if len(g.PaymentDeclinedNotices) != 1 {
|
|
||||||
t.Fatalf("expected 1 notice, got %d", len(g.PaymentDeclinedNotices))
|
|
||||||
}
|
|
||||||
notice := g.PaymentDeclinedNotices[0]
|
|
||||||
if notice.Message != "Payment failed due to insufficient funds" {
|
|
||||||
t.Fatalf("notice message not set correctly: %s", notice.Message)
|
|
||||||
}
|
|
||||||
if notice.Code == nil || *notice.Code != "INSUFFICIENT_FUNDS" {
|
|
||||||
t.Fatalf("notice code not set correctly: %v", notice.Code)
|
|
||||||
}
|
|
||||||
if notice.Timestamp.IsZero() {
|
|
||||||
t.Fatalf("notice timestamp not set")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfirmationViewed(t *testing.T) {
|
|
||||||
reg := newRegistry()
|
|
||||||
g := newTestGrain()
|
|
||||||
|
|
||||||
// Initial state
|
|
||||||
if g.Confirmation != nil {
|
|
||||||
t.Fatalf("confirmation should be nil, got %v", g.Confirmation)
|
|
||||||
}
|
|
||||||
|
|
||||||
// First view
|
|
||||||
applyOK(t, reg, g, msgConfirmationViewed())
|
|
||||||
if g.Confirmation.ViewCount != 1 {
|
|
||||||
t.Fatalf("view count should be 1, got %d", g.Confirmation.ViewCount)
|
|
||||||
}
|
|
||||||
if g.Confirmation.LastViewedAt.IsZero() {
|
|
||||||
t.Fatalf("ConfirmationLastViewedAt not set")
|
|
||||||
}
|
|
||||||
firstTime := g.Confirmation.LastViewedAt
|
|
||||||
|
|
||||||
// Second view
|
|
||||||
applyOK(t, reg, g, msgConfirmationViewed())
|
|
||||||
if g.Confirmation.ViewCount != 2 {
|
|
||||||
t.Fatalf("view count should be 2, got %d", g.Confirmation.ViewCount)
|
|
||||||
}
|
|
||||||
if g.Confirmation.LastViewedAt == firstTime {
|
|
||||||
t.Fatalf("ConfirmationLastViewedAt should have updated")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateCheckoutOrder(t *testing.T) {
|
|
||||||
reg := newRegistry()
|
|
||||||
g := newTestGrain()
|
|
||||||
|
|
||||||
applyOK(t, reg, g, msgAddItem("CHECKOUT", 1000, 1, nil))
|
|
||||||
|
|
||||||
applyOK(t, reg, g, msgCreateCheckoutOrder("accepted", "SE"))
|
|
||||||
if g.CheckoutOrderId == "" || g.CheckoutStatus != "pending" || g.CheckoutCountry != "SE" {
|
|
||||||
t.Fatalf("checkout order not created: id=%s status=%s country=%s",
|
|
||||||
g.CheckoutOrderId, g.CheckoutStatus, g.CheckoutCountry)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty cart
|
|
||||||
g2 := newTestGrain()
|
|
||||||
applyErrorContains(t, reg, g2, msgCreateCheckoutOrder("accepted", ""), "empty cart")
|
|
||||||
|
|
||||||
// Terms not accepted
|
|
||||||
applyErrorContains(t, reg, g, msgCreateCheckoutOrder("no", ""), "terms must be accepted")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAddGiftcard(t *testing.T) {
|
|
||||||
reg := newRegistry()
|
|
||||||
g := newTestGrain()
|
|
||||||
|
|
||||||
designConfig, _ := anypb.New(&messages.AddItem{}) // example
|
|
||||||
applyOK(t, reg, g, msgAddGiftcard(5000, "2023-12-25", "John", "email", "Happy Birthday!", designConfig))
|
|
||||||
|
|
||||||
if len(g.Giftcards) != 1 {
|
|
||||||
t.Fatalf("expected 1 giftcard, got %d", len(g.Giftcards))
|
|
||||||
}
|
|
||||||
gc := g.Giftcards[0]
|
|
||||||
if gc.Value.IncVat != 5000 || gc.DeliveryDate != "2023-12-25" || gc.Recipient != "John" || gc.RecipientType != "email" || gc.Message != "Happy Birthday!" {
|
|
||||||
t.Fatalf("giftcard not set correctly: %+v", gc)
|
|
||||||
}
|
|
||||||
if g.TotalPrice.IncVat != 5000 {
|
|
||||||
t.Fatalf("total price not updated, got %d", g.TotalPrice.IncVat)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test invalid value
|
|
||||||
applyErrorContains(t, reg, g, msgAddGiftcard(0, "", "", "", "", nil), "must be positive")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRemoveGiftcard(t *testing.T) {
|
|
||||||
reg := newRegistry()
|
|
||||||
g := newTestGrain()
|
|
||||||
|
|
||||||
applyOK(t, reg, g, msgAddGiftcard(1000, "2023-01-01", "Jane", "sms", "Cheers!", nil))
|
|
||||||
id := g.Giftcards[0].Id
|
|
||||||
|
|
||||||
applyOK(t, reg, g, msgRemoveGiftcard(id))
|
|
||||||
if len(g.Giftcards) != 0 {
|
|
||||||
t.Fatalf("giftcard not removed")
|
|
||||||
}
|
|
||||||
if g.TotalPrice.IncVat != 0 {
|
|
||||||
t.Fatalf("total price not updated after removal, got %d", g.TotalPrice.IncVat)
|
|
||||||
}
|
|
||||||
|
|
||||||
applyErrorContains(t, reg, g, msgRemoveGiftcard(id), "not found")
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
messages "git.k6n.net/go-cart-actor/pkg/messages"
|
|
||||||
)
|
|
||||||
|
|
||||||
func UpsertSubscriptionDetails(g *CartGrain, m *messages.UpsertSubscriptionDetails) error {
|
|
||||||
if m == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
metaBytes := m.Data.GetValue()
|
|
||||||
|
|
||||||
// Create new subscription details when Id is nil.
|
|
||||||
if m.Id == nil {
|
|
||||||
// Validate JSON if provided.
|
|
||||||
var meta json.RawMessage
|
|
||||||
if metaBytes != nil {
|
|
||||||
if !json.Valid(metaBytes) {
|
|
||||||
return fmt.Errorf("subscription details invalid json")
|
|
||||||
}
|
|
||||||
meta = json.RawMessage(metaBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
id := MustNewCartId().String()
|
|
||||||
|
|
||||||
g.SubscriptionDetails[id] = &SubscriptionDetails{
|
|
||||||
Id: id,
|
|
||||||
Version: 1,
|
|
||||||
OfferingCode: m.OfferingCode,
|
|
||||||
SigningType: m.SigningType,
|
|
||||||
Meta: meta,
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update existing entry.
|
|
||||||
existing, ok := g.SubscriptionDetails[*m.Id]
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("subscription details with id %s not found", *m.Id)
|
|
||||||
}
|
|
||||||
changed := false
|
|
||||||
if m.OfferingCode != "" {
|
|
||||||
existing.OfferingCode = m.OfferingCode
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
if m.SigningType != "" {
|
|
||||||
existing.SigningType = m.SigningType
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
if metaBytes != nil {
|
|
||||||
if !json.Valid(metaBytes) {
|
|
||||||
return fmt.Errorf("subscription details invalid json")
|
|
||||||
}
|
|
||||||
existing.Meta = json.RawMessage(metaBytes)
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
if changed {
|
|
||||||
existing.Version++
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
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(1250, 25)
|
|
||||||
if p.IncVat != 1250 {
|
|
||||||
t.Fatalf("expected IncVat %d got %d", 1250, p.IncVat)
|
|
||||||
}
|
|
||||||
if p.VatRates[25] != 250 {
|
|
||||||
t.Fatalf("expected VAT 25 rate %d got %d", 250, p.VatRates[25])
|
|
||||||
}
|
|
||||||
if p.ValueExVat() != 1000 {
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetTaxAmount(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
total int64
|
|
||||||
tax int
|
|
||||||
expected int64
|
|
||||||
desc string
|
|
||||||
}{
|
|
||||||
{1250, 2500, 250, "25% VAT"}, // 1250 / (1 + 100/25) = 1250 / 5 = 250
|
|
||||||
{1000, 2000, 166, "20% VAT"}, // 1000 / (1 + 100/20) = 1000 / 6 ≈ 166
|
|
||||||
{1200, 2500, 240, "25% VAT on 1200"},
|
|
||||||
{0, 2500, 0, "zero total"},
|
|
||||||
{100, 1000, 9, "10% VAT"}, // tax=1000 for 10%, 100 / (1 + 100/10) = 100 / 11 ≈ 9
|
|
||||||
{100, 10000, 50, "100% VAT"}, // tax=10000 for 100%, 100 / (1 + 100/100) = 100 / 2 = 50
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
result := GetTaxAmount(tt.total, tt.tax)
|
|
||||||
if result != tt.expected {
|
|
||||||
t.Errorf("GetTaxAmount(%d, %d) [%s] = %d; expected %d", tt.total, tt.tax, tt.desc, result, tt.expected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewPriceFromIncVatEdgeCases(t *testing.T) {
|
|
||||||
// Zero VAT rate
|
|
||||||
p := NewPriceFromIncVat(1000, 0)
|
|
||||||
if p.IncVat != 1000 {
|
|
||||||
t.Errorf("expected IncVat 1000, got %d", p.IncVat)
|
|
||||||
}
|
|
||||||
if len(p.VatRates) != 1 || p.VatRates[0] != 0 {
|
|
||||||
t.Errorf("expected VAT 0 for rate 0, got %v", p.VatRates)
|
|
||||||
}
|
|
||||||
if p.ValueExVat() != 1000 {
|
|
||||||
t.Errorf("expected exVat 1000, got %d", p.ValueExVat())
|
|
||||||
}
|
|
||||||
|
|
||||||
// High VAT rate, e.g., 50%
|
|
||||||
p = NewPriceFromIncVat(1500, 50)
|
|
||||||
expectedVat := int64(1500 / (1 + 100/50)) // 1500 / 3 = 500
|
|
||||||
if p.VatRates[50] != expectedVat {
|
|
||||||
t.Errorf("expected VAT %d for 50%%, got %d", expectedVat, p.VatRates[50])
|
|
||||||
}
|
|
||||||
if p.ValueExVat() != 1500-expectedVat {
|
|
||||||
t.Errorf("expected exVat %d, got %d", 1500-expectedVat, p.ValueExVat())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPriceValueExVatAndTotalVat(t *testing.T) {
|
|
||||||
p := Price{IncVat: 13700, VatRates: map[float32]int64{25: 2500, 12: 1200}}
|
|
||||||
exVat := p.ValueExVat()
|
|
||||||
totalVat := p.TotalVat()
|
|
||||||
if exVat != 10000 {
|
|
||||||
t.Errorf("expected exVat 10000, got %d", exVat)
|
|
||||||
}
|
|
||||||
if totalVat != 3700 {
|
|
||||||
t.Errorf("expected totalVat 3700, got %d", totalVat)
|
|
||||||
}
|
|
||||||
if exVat+totalVat != p.IncVat {
|
|
||||||
t.Errorf("exVat + totalVat should equal IncVat: %d + %d != %d", exVat, totalVat, p.IncVat)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty VAT rates
|
|
||||||
p2 := Price{IncVat: 500, VatRates: nil}
|
|
||||||
if p2.ValueExVat() != 500 {
|
|
||||||
t.Errorf("expected exVat 500 for no VAT, got %d", p2.ValueExVat())
|
|
||||||
}
|
|
||||||
if p2.TotalVat() != 0 {
|
|
||||||
t.Errorf("expected totalVat 0, got %d", p2.TotalVat())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMultiplyPriceWithZeroQty(t *testing.T) {
|
|
||||||
base := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}}
|
|
||||||
multiplied := MultiplyPrice(base, 0)
|
|
||||||
if multiplied.IncVat != 0 {
|
|
||||||
t.Errorf("expected IncVat 0, got %d", multiplied.IncVat)
|
|
||||||
}
|
|
||||||
if len(multiplied.VatRates) != 1 || multiplied.VatRates[25] != 0 {
|
|
||||||
t.Errorf("expected VAT 0, got %v", multiplied.VatRates)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPriceAddSubtractEdgeCases(t *testing.T) {
|
|
||||||
a := Price{IncVat: 1000, VatRates: map[float32]int64{25: 200}}
|
|
||||||
b := Price{IncVat: 500, VatRates: map[float32]int64{12: 54}} // Different rate
|
|
||||||
|
|
||||||
acc := NewPrice()
|
|
||||||
acc.Add(a)
|
|
||||||
acc.Add(b)
|
|
||||||
|
|
||||||
if acc.VatRates[25] != 200 || acc.VatRates[12] != 54 {
|
|
||||||
t.Errorf("expected VAT 25:200, 12:54, got %v", acc.VatRates)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subtract more than added (negative VAT)
|
|
||||||
acc.Subtract(a)
|
|
||||||
acc.Subtract(b)
|
|
||||||
acc.Subtract(a) // Subtract extra a
|
|
||||||
if acc.VatRates[25] != -200 || acc.VatRates[12] != 0 {
|
|
||||||
t.Errorf("expected negative VAT for 25 after over-subtract, got %v", acc.VatRates)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
package discovery
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"slices"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
if slices.Contains(m.hosts, host) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
m.hosts = append(m.hosts, host)
|
|
||||||
if m.started {
|
|
||||||
m.events <- HostChange{Host: host, IsReady: true}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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, IsReady: false}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close closes the event channel (idempotent).
|
|
||||||
func (m *MockDiscovery) Close() {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
if m.closed {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
m.closed = true
|
|
||||||
close(m.events)
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package discovery
|
|
||||||
|
|
||||||
type HostChange struct {
|
|
||||||
Host string
|
|
||||||
IsReady bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type Discovery interface {
|
|
||||||
Discover() ([]string, error)
|
|
||||||
Watch() (<-chan HostChange, error)
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user