Compare commits
13 Commits
feature/tr
...
refactor/g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8c8ad56c7 | ||
|
|
09a68db8d5 | ||
|
|
30c89a0394 | ||
|
|
d6563d0b3a | ||
|
|
2a2ce247d5 | ||
|
|
159253b8b0 | ||
|
|
c30be581cd | ||
|
|
716f1121aa | ||
|
|
12d87036f6 | ||
|
|
e7c67fbb9b | ||
|
|
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,47 +1,77 @@
|
||||
name: Build and Publish
|
||||
run-name: ${{ gitea.actor }} build 🚀
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
BuildAndDeployAmd64:
|
||||
runs-on: amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Build amd64 image
|
||||
run: |
|
||||
docker build \
|
||||
--progress=plain \
|
||||
-t registry.k6n.net/go-cart-actor-amd64:latest \
|
||||
.
|
||||
- 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
|
||||
- name: Rollout amd64 backoffice deployment
|
||||
run: |
|
||||
kubectl rollout restart deployment/cart-backoffice-x86 -n cart
|
||||
kubectl rollout restart deployment/cart-actor-x86 -n cart
|
||||
kubectl rollout restart deployment/checkout-actor-x86 -n cart
|
||||
Metadata:
|
||||
runs-on: arm64
|
||||
outputs:
|
||||
version: ${{ steps.meta.outputs.version }}
|
||||
git_commit: ${{ steps.meta.outputs.git_commit }}
|
||||
build_date: ${{ steps.meta.outputs.build_date }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- id: meta
|
||||
name: Derive build metadata
|
||||
run: |
|
||||
GIT_COMMIT=$(git rev-parse HEAD)
|
||||
if git describe --tags --exact-match >/dev/null 2>&1; then
|
||||
VERSION=$(git describe --tags --exact-match)
|
||||
else
|
||||
VERSION=$(git rev-parse --short=12 HEAD)
|
||||
fi
|
||||
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
echo "git_commit=$GIT_COMMIT" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "build_date=$BUILD_DATE" >> $GITHUB_OUTPUT
|
||||
|
||||
BuildAndDeployArm64:
|
||||
runs-on: arm64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build arm64 image
|
||||
run: |
|
||||
docker build \
|
||||
--progress=plain \
|
||||
-t registry.k6n.net/go-cart-actor:latest \
|
||||
.
|
||||
- name: Push arm64 images
|
||||
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
|
||||
BuildAndDeployAmd64:
|
||||
needs: Metadata
|
||||
runs-on: amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build amd64 image
|
||||
run: |
|
||||
docker build \
|
||||
--build-arg VERSION=${{ needs.Metadata.outputs.version }} \
|
||||
--build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \
|
||||
--build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \
|
||||
--progress=plain \
|
||||
-t registry.knatofs.se/go-cart-actor-amd64:latest \
|
||||
-t registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }} \
|
||||
.
|
||||
- name: Push amd64 images
|
||||
run: |
|
||||
docker push registry.knatofs.se/go-cart-actor-amd64:latest
|
||||
docker push registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }}
|
||||
- name: Apply deployment manifests
|
||||
run: kubectl apply -f deployment/deployment.yaml -n cart
|
||||
- name: Rollout amd64 deployment (pin to version)
|
||||
run: |
|
||||
kubectl set image deployment/cart-actor-x86 -n cart cart-actor-amd64=registry.knatofs.se/go-cart-actor-amd64:${{ needs.Metadata.outputs.version }}
|
||||
kubectl rollout status deployment/cart-actor-x86 -n cart
|
||||
|
||||
BuildAndDeployArm64:
|
||||
needs: Metadata
|
||||
runs-on: arm64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build arm64 image
|
||||
run: |
|
||||
docker build \
|
||||
--build-arg VERSION=${{ needs.Metadata.outputs.version }} \
|
||||
--build-arg GIT_COMMIT=${{ needs.Metadata.outputs.git_commit }} \
|
||||
--build-arg BUILD_DATE=${{ needs.Metadata.outputs.build_date }} \
|
||||
--progress=plain \
|
||||
-t registry.knatofs.se/go-cart-actor:latest \
|
||||
-t registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }} \
|
||||
.
|
||||
- name: Push arm64 images
|
||||
run: |
|
||||
docker push registry.knatofs.se/go-cart-actor:latest
|
||||
docker push registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }}
|
||||
- name: Rollout arm64 deployment (pin to version)
|
||||
run: |
|
||||
kubectl set image deployment/cart-actor-arm64 -n cart cart-actor-arm64=registry.knatofs.se/go-cart-actor:${{ needs.Metadata.outputs.version }}
|
||||
kubectl rollout status deployment/cart-actor-arm64 -n cart
|
||||
|
||||
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.
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,4 +2,3 @@ __debug*
|
||||
go-cart-actor
|
||||
data/*.prot
|
||||
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.
|
||||
37
Dockerfile
37
Dockerfile
@@ -25,8 +25,6 @@
|
||||
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
|
||||
@@ -40,7 +38,8 @@ ENV CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH}
|
||||
|
||||
# Dependency caching
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
# Copy full source (relay on .dockerignore to prune)
|
||||
COPY . .
|
||||
@@ -53,29 +52,12 @@ COPY . .
|
||||
# proto/*.proto
|
||||
|
||||
# Build with minimal binary size and embedded metadata
|
||||
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
|
||||
|
||||
RUN go build -trimpath -ldflags="-s -w \
|
||||
-X main.Version=${VERSION} \
|
||||
-X main.GitCommit=${GIT_COMMIT} \
|
||||
-X main.BuildDate=${BUILD_DATE}" \
|
||||
-o /out/go-checkout-actor ./cmd/checkout
|
||||
RUN --mount=type=cache,target=/go/build-cache \
|
||||
go build -trimpath -ldflags="-s -w \
|
||||
-X main.Version=${VERSION} \
|
||||
-X main.GitCommit=${GIT_COMMIT} \
|
||||
-X main.BuildDate=${BUILD_DATE}" \
|
||||
-o /out/go-cart-actor .
|
||||
|
||||
############################
|
||||
# Runtime Stage
|
||||
@@ -85,9 +67,6 @@ FROM gcr.io/distroless/static-debian12:nonroot AS runtime
|
||||
WORKDIR /
|
||||
|
||||
COPY --from=build /out/go-cart-actor /go-cart-actor
|
||||
COPY --from=build /out/go-checkout-actor /go-checkout-actor
|
||||
COPY --from=build /out/go-cart-backoffice /go-cart-backoffice
|
||||
COPY --from=build /out/go-cart-inventory /go-cart-inventory
|
||||
|
||||
# Document (not expose forcibly) typical ports: 8080 (HTTP), 1337 (gRPC)
|
||||
EXPOSE 8080 1337
|
||||
|
||||
31
Makefile
31
Makefile
@@ -14,15 +14,12 @@
|
||||
# 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
|
||||
# - go_package is set to: git.tornberg.me/go-cart-actor/proto;messages
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
MODULE_PATH := git.k6n.net/go-cart-actor
|
||||
MODULE_PATH := git.tornberg.me/go-cart-actor
|
||||
PROTO_DIR := proto
|
||||
PROTOS := $(PROTO_DIR)/cart.proto $(PROTO_DIR)/control_plane.proto $(PROTO_DIR)/checkout.proto
|
||||
CART_PROTO_DIR := $(PROTO_DIR)/cart
|
||||
CONTROL_PROTO_DIR := $(PROTO_DIR)/control
|
||||
CHECKOUT_PROTO_DIR := $(PROTO_DIR)/checkout
|
||||
PROTOS := $(PROTO_DIR)/messages.proto $(PROTO_DIR)/cart_actor.proto $(PROTO_DIR)/control_plane.proto
|
||||
|
||||
# Allow override: make PROTOC=/path/to/protoc
|
||||
PROTOC ?= protoc
|
||||
@@ -72,30 +69,22 @@ check_tools:
|
||||
protogen: check_tools
|
||||
@echo "$(YELLOW)Generating protobuf code (outputs -> ./proto)...$(RESET)"
|
||||
$(PROTOC) -I $(PROTO_DIR) \
|
||||
--go_out=./proto/cart --go_opt=paths=source_relative \
|
||||
--go-grpc_out=./proto/cart --go-grpc_opt=paths=source_relative \
|
||||
$(PROTO_DIR)/cart.proto
|
||||
$(PROTOC) -I $(PROTO_DIR) \
|
||||
--go_out=./proto/control --go_opt=paths=source_relative \
|
||||
--go-grpc_out=./proto/control --go-grpc_opt=paths=source_relative \
|
||||
$(PROTO_DIR)/control_plane.proto
|
||||
$(PROTOC) -I $(PROTO_DIR) \
|
||||
--go_out=./proto/checkout --go_opt=paths=source_relative \
|
||||
--go-grpc_out=./proto/checkout --go-grpc_opt=paths=source_relative \
|
||||
$(PROTO_DIR)/checkout.proto
|
||||
--go_out=./proto --go_opt=paths=source_relative \
|
||||
--go-grpc_out=./proto --go-grpc_opt=paths=source_relative \
|
||||
$(PROTOS)
|
||||
@echo "$(GREEN)Protobuf generation complete.$(RESET)"
|
||||
|
||||
clean_proto:
|
||||
@echo "$(YELLOW)Removing generated protobuf files...$(RESET)"
|
||||
@rm -f $(PROTO_DIR)/cart/*_grpc.pb.go $(PROTO_DIR)/cart/*.pb.go
|
||||
@rm -f $(PROTO_DIR)/control/*_grpc.pb.go $(PROTO_DIR)/control/*.pb.go
|
||||
@rm -f $(PROTO_DIR)/checkout/*_grpc.pb.go $(PROTO_DIR)/checkout/*.pb.go
|
||||
@rm -f $(PROTO_DIR)/*_grpc.pb.go $(PROTO_DIR)/*.pb.go
|
||||
@rm -f *.pb.go
|
||||
@rm -rf git.tornberg.me
|
||||
@echo "$(GREEN)Clean complete.$(RESET)"
|
||||
|
||||
verify_proto:
|
||||
@echo "$(YELLOW)Verifying proto layout...$(RESET)"
|
||||
@if ls *.pb.go >/dev/null 2>&1; then \
|
||||
echo "$(RED)ERROR: Found root-level generated *.pb.go files (should be only under $(PROTO_DIR)/ subdirs).$(RESET)"; \
|
||||
echo "$(RED)ERROR: Found root-level generated *.pb.go files (should be only under $(PROTO_DIR)/).$(RESET)"; \
|
||||
ls -1 *.pb.go; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
@@ -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.
|
||||
31
README.md
31
README.md
@@ -1,5 +1,36 @@
|
||||
# Go Cart Actor
|
||||
|
||||
## Migration Notes (Ring-based Ownership Transition)
|
||||
|
||||
This release removes the legacy ConfirmOwner ownership negotiation RPC in favor of deterministic ownership via the consistent hashing ring.
|
||||
|
||||
Summary of changes:
|
||||
- ConfirmOwner RPC removed from the ControlPlane service.
|
||||
- OwnerChangeRequest message removed (was only used by ConfirmOwner).
|
||||
- OwnerChangeAck retained solely as the response type for the Closing RPC.
|
||||
- SyncedPool now relies exclusively on the ring for ownership (no quorum negotiation).
|
||||
- Remote proxy creation includes a bounded readiness retry to reduce first-call failures.
|
||||
- New Prometheus ring metrics:
|
||||
- cart_ring_epoch
|
||||
- cart_ring_hosts
|
||||
- cart_ring_vnodes
|
||||
- cart_ring_host_share{host}
|
||||
- cart_ring_lookup_local_total
|
||||
- cart_ring_lookup_remote_total
|
||||
|
||||
Action required for consumers:
|
||||
1. Regenerate protobuf code after pulling (requires protoc-gen-go and protoc-gen-go-grpc installed).
|
||||
2. Remove any client code or automation invoking ConfirmOwner (calls will now return UNIMPLEMENTED if using stale generated stubs).
|
||||
3. Update monitoring/alerts that referenced ConfirmOwner or ownership quorum failures—use ring metrics instead.
|
||||
4. If you previously interpreted “ownership flapping” via ConfirmOwner logs, now check for:
|
||||
- Rapid changes in ring epoch (cart_ring_epoch)
|
||||
- Host churn (cart_ring_hosts)
|
||||
- Imbalance in vnode distribution (cart_ring_host_share)
|
||||
|
||||
No data migration is necessary; cart IDs and grain state are unaffected.
|
||||
|
||||
---
|
||||
|
||||
A distributed cart management system using the actor model pattern.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
61
amqp-order-handler.go
Normal file
61
amqp-order-handler.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
type AmqpOrderHandler struct {
|
||||
Url string
|
||||
Connection *amqp.Connection
|
||||
Channel *amqp.Channel
|
||||
}
|
||||
|
||||
func (h *AmqpOrderHandler) Connect() error {
|
||||
conn, err := amqp.Dial(h.Url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to RabbitMQ: %w", err)
|
||||
}
|
||||
h.Connection = conn
|
||||
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open a channel: %w", err)
|
||||
}
|
||||
h.Channel = ch
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AmqpOrderHandler) Close() error {
|
||||
if h.Channel != nil {
|
||||
h.Channel.Close()
|
||||
}
|
||||
if h.Connection != nil {
|
||||
return h.Connection.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AmqpOrderHandler) OrderCompleted(body []byte) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := h.Channel.PublishWithContext(ctx,
|
||||
"orders", // exchange
|
||||
"new", // routing key
|
||||
false, // mandatory
|
||||
false, // immediate
|
||||
amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to publish a message: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
### Add item to cart
|
||||
POST https://cart.k6n.net/api/12345
|
||||
POST https://cart.tornberg.me/api/12345
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@@ -9,7 +9,7 @@ Content-Type: application/json
|
||||
}
|
||||
|
||||
### Update quanity of item in cart
|
||||
PUT https://cart.k6n.net/api/12345
|
||||
PUT https://cart.tornberg.me/api/12345
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@@ -18,12 +18,12 @@ Content-Type: application/json
|
||||
}
|
||||
|
||||
### Delete item from cart
|
||||
DELETE https://cart.k6n.net/api/1002/1
|
||||
DELETE https://cart.tornberg.me/api/1002/1
|
||||
|
||||
|
||||
### Set delivery
|
||||
|
||||
POST https://cart.k6n.net/api/1002/delivery
|
||||
POST https://cart.tornberg.me/api/1002/delivery
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@@ -33,8 +33,10 @@ Content-Type: application/json
|
||||
|
||||
|
||||
### Get cart
|
||||
GET https://cart.k6n.net/api/12345
|
||||
GET https://cart.tornberg.me/api/12345
|
||||
|
||||
|
||||
### Remove delivery method
|
||||
DELETE https://cart.k6n.net/api/12345/delivery/2
|
||||
DELETE https://cart.tornberg.me/api/12345/delivery/2
|
||||
|
||||
|
||||
|
||||
294
cart-grain.go
Normal file
294
cart-grain.go
Normal file
@@ -0,0 +1,294 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
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
|
||||
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
|
||||
Apply(content interface{}, isReplay bool) (*CartGrain, error)
|
||||
GetCurrentState() (*CartGrain, error)
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetId() CartId {
|
||||
return c.Id
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetLastChange() int64 {
|
||||
// Legacy event log removed; return 0 to indicate no persisted mutation history.
|
||||
return 0
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetCurrentState() (*CartGrain, error) {
|
||||
return c, 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) (*CartGrain, error) {
|
||||
cartItem, err := getItemData(sku, qty, country)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cartItem.StoreId = storeId
|
||||
return c.Apply(cartItem, false)
|
||||
}
|
||||
|
||||
/*
|
||||
Legacy storage (event sourcing) removed in oneof refactor.
|
||||
Kept stub (commented) for potential future reintroduction using proto envelopes.
|
||||
|
||||
func (c *CartGrain) GetStorageMessage(since int64) []interface{} {
|
||||
return nil
|
||||
}
|
||||
*/
|
||||
|
||||
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) Apply(content interface{}, isReplay bool) (*CartGrain, error) {
|
||||
grainMutations.Inc()
|
||||
|
||||
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
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
327
cart_id.go
Normal file
327
cart_id.go
Normal file
@@ -0,0 +1,327 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// cart_id.go
|
||||
//
|
||||
// Compact CartID implementation using 64 bits of cryptographic randomness,
|
||||
// base62 encoded (0-9 A-Z a-z). Typical length is 11 characters (since 62^11 > 2^64).
|
||||
//
|
||||
// Motivation:
|
||||
// * Shorter identifiers for cookies / URLs than legacy padded 16-byte CartId
|
||||
// * O(1) hashing (raw uint64) for consistent hashing ring integration
|
||||
// * Extremely low collision probability (birthday bound negligible at scale)
|
||||
//
|
||||
// Backward Compatibility Strategy (Phased):
|
||||
// Phase 1: Introduce CartID helpers while continuing to accept legacy CartId.
|
||||
// Phase 2: Internally migrate maps to key by uint64 (CartID.Raw()).
|
||||
// Phase 3: Canonicalize all inbound IDs to short base62; reissue Set-Cart-Id header.
|
||||
//
|
||||
// NOTE:
|
||||
// The legacy type `CartId [16]byte` is still present elsewhere; helper
|
||||
// UpgradeLegacyCartId bridges that representation to the new form without
|
||||
// breaking deterministic mapping for existing carts.
|
||||
//
|
||||
// Security / Predictability:
|
||||
// Uses crypto/rand for generation. If ever required, you can layer an
|
||||
// HMAC-based derivation for additional secrecy. Current approach already
|
||||
// provides 64 bits of entropy (brute force infeasible for practical risk).
|
||||
//
|
||||
// Future Extensions:
|
||||
// * Time-sortable IDs: prepend a 48-bit timestamp field and encode 80 bits.
|
||||
// * Add metrics counters for: generated_new, parsed_existing, legacy_fallback.
|
||||
// * Add a pool of pre-generated IDs for ultra-low-latency hot paths (rarely needed).
|
||||
//
|
||||
// Public Surface Summary:
|
||||
// NewCartID() (CartID, error)
|
||||
// ParseCartID(string) (CartID, bool)
|
||||
// FallbackFromString(string) CartID
|
||||
// UpgradeLegacyCartId(CartId) CartID
|
||||
// CanonicalizeIncoming(string) (CartID, bool /*wasGenerated*/, error)
|
||||
//
|
||||
// Encoding Details:
|
||||
// encodeBase62 / decodeBase62 maintain a stable alphabet. DO NOT change
|
||||
// alphabet order once IDs are in circulation, or previously issued IDs
|
||||
// will change meaning.
|
||||
//
|
||||
// Zero Values:
|
||||
// The zero value CartID{} has raw=0, txt="0". Treat it as valid but
|
||||
// usually you will call NewCartID instead.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
// Precomputed reverse lookup table for decode (255 = invalid).
|
||||
var base62Rev [256]byte
|
||||
|
||||
func init() {
|
||||
for i := range base62Rev {
|
||||
base62Rev[i] = 0xFF
|
||||
}
|
||||
for i := 0; i < len(base62Alphabet); i++ {
|
||||
base62Rev[base62Alphabet[i]] = byte(i)
|
||||
}
|
||||
}
|
||||
|
||||
// CartID is the compact representation of a cart identifier.
|
||||
// raw: 64-bit entropy (also used directly for consistent hashing).
|
||||
// txt: cached base62 textual form.
|
||||
type CartID struct {
|
||||
raw uint64
|
||||
txt string
|
||||
}
|
||||
|
||||
// String returns the canonical base62 encoded ID.
|
||||
func (c CartID) String() string {
|
||||
if c.txt == "" { // lazily encode if constructed manually
|
||||
c.txt = encodeBase62(c.raw)
|
||||
}
|
||||
return c.txt
|
||||
}
|
||||
|
||||
// Raw returns the 64-bit numeric value (useful for hashing / ring lookup).
|
||||
func (c CartID) Raw() uint64 {
|
||||
return c.raw
|
||||
}
|
||||
|
||||
// IsZero reports whether this CartID is the zero value.
|
||||
func (c CartID) IsZero() bool {
|
||||
return c.raw == 0
|
||||
}
|
||||
|
||||
// NewCartID generates a new cryptographically random 64-bit ID.
|
||||
func NewCartID() (CartID, error) {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return CartID{}, fmt.Errorf("NewCartID: %w", err)
|
||||
}
|
||||
u := binary.BigEndian.Uint64(b[:])
|
||||
// Reject zero if you want to avoid ever producing "0" (optional).
|
||||
if u == 0 {
|
||||
// Extremely unlikely; recurse once.
|
||||
return NewCartID()
|
||||
}
|
||||
return CartID{raw: u, txt: encodeBase62(u)}, nil
|
||||
}
|
||||
|
||||
// MustNewCartID panics on failure (suitable for tests / initialization).
|
||||
func MustNewCartID() CartID {
|
||||
id, err := NewCartID()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// ParseCartID attempts to parse a base62 canonical ID.
|
||||
// Returns (id, true) if fully valid; (zero, false) otherwise.
|
||||
func ParseCartID(s string) (CartID, bool) {
|
||||
if len(s) == 0 {
|
||||
return CartID{}, false
|
||||
}
|
||||
// Basic length sanity; allow a bit of headroom for future timestamp variant.
|
||||
if len(s) > 16 {
|
||||
return CartID{}, false
|
||||
}
|
||||
u, ok := decodeBase62(s)
|
||||
if !ok {
|
||||
return CartID{}, false
|
||||
}
|
||||
return CartID{raw: u, txt: s}, true
|
||||
}
|
||||
|
||||
// FallbackFromString produces a deterministic CartID from arbitrary input
|
||||
// using a 64-bit FNV-1a hash. This allows legacy or malformed IDs to map
|
||||
// consistently into the new scheme (collision probability still low).
|
||||
func FallbackFromString(s string) CartID {
|
||||
const (
|
||||
offset64 = 1469598103934665603
|
||||
prime64 = 1099511628211
|
||||
)
|
||||
h := uint64(offset64)
|
||||
for i := 0; i < len(s); i++ {
|
||||
h ^= uint64(s[i])
|
||||
h *= prime64
|
||||
}
|
||||
return CartID{raw: h, txt: encodeBase62(h)}
|
||||
}
|
||||
|
||||
// UpgradeLegacyCartId converts the old 16-byte CartId (padded) to CartID
|
||||
// by hashing its trimmed string form. Keeps stable mapping across restarts.
|
||||
func UpgradeLegacyCartId(old CartId) CartID {
|
||||
return FallbackFromString(old.String())
|
||||
}
|
||||
|
||||
// CanonicalizeIncoming normalizes user-provided ID strings.
|
||||
// Behavior:
|
||||
//
|
||||
// Empty string -> generate new ID (wasGenerated = true)
|
||||
// Valid base62 -> parse and return (wasGenerated = false)
|
||||
// Anything else -> fallback deterministic hash (wasGenerated = false)
|
||||
//
|
||||
// Errors only occur if crypto/rand fails during generation.
|
||||
func CanonicalizeIncoming(s string) (CartID, bool, error) {
|
||||
if s == "" {
|
||||
id, err := NewCartID()
|
||||
return id, true, err
|
||||
}
|
||||
if cid, ok := ParseCartID(s); ok {
|
||||
return cid, false, nil
|
||||
}
|
||||
// Legacy heuristic: if length == 16 and contains non-base62 chars, treat as legacy padded ID.
|
||||
if len(s) == 16 && !isAllBase62(s) {
|
||||
return FallbackFromString(strings.TrimRight(s, "\x00")), false, nil
|
||||
}
|
||||
return FallbackFromString(s), false, nil
|
||||
}
|
||||
|
||||
// isAllBase62 returns true if every byte is in the base62 alphabet.
|
||||
func isAllBase62(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if base62Rev[s[i]] == 0xFF {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// encodeBase62 turns a uint64 into base62 text.
|
||||
// Complexity: O(log_62 n) ~ at most 11 iterations for 64 bits.
|
||||
func encodeBase62(u uint64) string {
|
||||
if u == 0 {
|
||||
return "0"
|
||||
}
|
||||
// 62^11 = 743008370688 > 2^39; 62^11 > 2^64? Actually 62^11 ~= 5.18e19 < 2^64 (1.84e19)? 2^64 ≈ 1.84e19.
|
||||
// 62^11 ≈ 5.18e19 > 2^64? Correction: 2^64 ≈ 1.844e19, so 62^11 > 2^64. Thus 11 chars suffice.
|
||||
var buf [11]byte
|
||||
i := len(buf)
|
||||
for u > 0 {
|
||||
i--
|
||||
buf[i] = base62Alphabet[u%62]
|
||||
u /= 62
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
// decodeBase62 converts a base62 string to uint64.
|
||||
// Returns (value, false) if any invalid character appears.
|
||||
func decodeBase62(s string) (uint64, bool) {
|
||||
var v uint64
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
d := base62Rev[c]
|
||||
if d == 0xFF {
|
||||
return 0, false
|
||||
}
|
||||
v = v*62 + uint64(d)
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// ErrInvalidCartID can be returned by higher-level validation layers if you decide
|
||||
// to reject fallback-derived IDs (currently unused here).
|
||||
var ErrInvalidCartID = errors.New("invalid cart id")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy / Compatibility Conversion Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CartIDToLegacy converts a CartID (base62) into the legacy fixed-size CartId
|
||||
// ([16]byte) by copying the textual form (truncated or zero-padded).
|
||||
// NOTE: If the base62 string is longer than 16 (should not happen with current
|
||||
// 64-bit space), it will be truncated.
|
||||
func CartIDToLegacy(c CartID) CartId {
|
||||
var id CartId
|
||||
txt := c.String()
|
||||
copy(id[:], []byte(txt))
|
||||
return id
|
||||
}
|
||||
|
||||
// LegacyToCartID upgrades a legacy CartId (padded) to a CartID by hashing its
|
||||
// trimmed string form (deterministic). This preserves stable mapping without
|
||||
// depending on original randomness.
|
||||
func LegacyToCartID(old CartId) CartID {
|
||||
return UpgradeLegacyCartId(old)
|
||||
}
|
||||
|
||||
// CartIDToKey returns the numeric key representation (uint64) for map indexing.
|
||||
func CartIDToKey(c CartID) uint64 {
|
||||
return c.Raw()
|
||||
}
|
||||
|
||||
// LegacyToCartKey converts a legacy CartId to the numeric key via deterministic
|
||||
// fallback hashing. (Uses the same logic as LegacyToCartID then returns raw.)
|
||||
func LegacyToCartKey(old CartId) uint64 {
|
||||
return LegacyToCartID(old).Raw()
|
||||
}
|
||||
|
||||
// ---------------------- Optional Helper Utilities ----------------------------
|
||||
|
||||
// CartIDOrNew tries to parse s; if empty OR invalid returns a fresh ID.
|
||||
func CartIDOrNew(s string) (CartID, bool /*wasParsed*/, error) {
|
||||
if cid, ok := ParseCartID(s); ok {
|
||||
return cid, true, nil
|
||||
}
|
||||
id, err := NewCartID()
|
||||
return id, false, err
|
||||
}
|
||||
|
||||
// MustParseCartID panics if s is not a valid base62 ID (useful in tests).
|
||||
func MustParseCartID(s string) CartID {
|
||||
if cid, ok := ParseCartID(s); ok {
|
||||
return cid
|
||||
}
|
||||
panic(fmt.Sprintf("invalid CartID: %s", s))
|
||||
}
|
||||
|
||||
// DebugString returns a verbose description (for logging / diagnostics).
|
||||
func (c CartID) DebugString() string {
|
||||
return fmt.Sprintf("CartID(raw=%d txt=%s)", c.raw, c.String())
|
||||
}
|
||||
|
||||
// Equal compares two CartIDs by raw value.
|
||||
func (c CartID) Equal(other CartID) bool {
|
||||
return c.raw == other.raw
|
||||
}
|
||||
|
||||
// CanonicalizeOrLegacy preserves legacy (non-base62) IDs without altering their
|
||||
// textual form, avoiding the previous behavior where fallback hashing replaced
|
||||
// the original string with a base62-encoded hash (which broke deterministic
|
||||
// key derivation across mixed call paths).
|
||||
//
|
||||
// Behavior:
|
||||
// - s == "" -> generate new CartID (generatedNew = true, wasBase62 = true)
|
||||
// - base62 ok -> return parsed CartID (generatedNew = false, wasBase62 = true)
|
||||
// - otherwise -> treat as legacy: raw = hash(s), txt = original s
|
||||
//
|
||||
// Returns:
|
||||
//
|
||||
// cid - CartID (txt preserved for legacy inputs)
|
||||
// generatedNew - true only when a brand new ID was created due to empty input
|
||||
// wasBase62 - true if the input was already canonical base62 (or generated)
|
||||
// err - only set if crypto/rand fails when generating a new ID
|
||||
func CanonicalizeOrLegacy(s string) (cid CartID, generatedNew bool, wasBase62 bool, err error) {
|
||||
if s == "" {
|
||||
id, e := NewCartID()
|
||||
if e != nil {
|
||||
return CartID{}, false, false, e
|
||||
}
|
||||
return id, true, true, nil
|
||||
}
|
||||
if parsed, ok := ParseCartID(s); ok {
|
||||
return parsed, false, true, nil
|
||||
}
|
||||
// Legacy path: keep original text so downstream legacy-to-key hashing
|
||||
// (which uses the visible string) yields consistent keys across code paths.
|
||||
hashCID := FallbackFromString(s)
|
||||
// Preserve original textual form
|
||||
hashCID.txt = s
|
||||
return hashCID, false, false, nil
|
||||
}
|
||||
259
cart_id_test.go
Normal file
259
cart_id_test.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
mrand "math/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEncodeDecodeBase62RoundTrip verifies encodeBase62/decodeBase62 are inverse.
|
||||
func TestEncodeDecodeBase62RoundTrip(t *testing.T) {
|
||||
mrand.Seed(42)
|
||||
for i := 0; i < 1000; i++ {
|
||||
// Random 64-bit value
|
||||
v := mrand.Uint64()
|
||||
s := encodeBase62(v)
|
||||
dec, ok := decodeBase62(s)
|
||||
if !ok {
|
||||
t.Fatalf("decodeBase62 failed for %d encoded=%s", v, s)
|
||||
}
|
||||
if dec != v {
|
||||
t.Fatalf("round trip mismatch: have %d got %d (encoded=%s)", v, dec, s)
|
||||
}
|
||||
}
|
||||
// Explicit zero test
|
||||
if s := encodeBase62(0); s != "0" {
|
||||
t.Fatalf("expected encodeBase62(0) == \"0\", got %q", s)
|
||||
}
|
||||
if v, ok := decodeBase62("0"); !ok || v != 0 {
|
||||
t.Fatalf("decodeBase62(0) unexpected result v=%d ok=%v", v, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCartIDUniqueness generates a number of IDs and checks for duplicates.
|
||||
func TestNewCartIDUniqueness(t *testing.T) {
|
||||
const n = 10000
|
||||
seen := make(map[string]struct{}, n)
|
||||
for i := 0; i < n; i++ {
|
||||
id, err := NewCartID()
|
||||
if err != nil {
|
||||
t.Fatalf("NewCartID error: %v", err)
|
||||
}
|
||||
s := id.String()
|
||||
if _, exists := seen[s]; exists {
|
||||
t.Fatalf("duplicate CartID generated: %s", s)
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
if id.IsZero() {
|
||||
t.Fatalf("NewCartID returned zero value")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseCartIDValidation tests parsing of valid and invalid base62 strings.
|
||||
func TestParseCartIDValidation(t *testing.T) {
|
||||
id, err := NewCartID()
|
||||
if err != nil {
|
||||
t.Fatalf("NewCartID error: %v", err)
|
||||
}
|
||||
parsed, ok := ParseCartID(id.String())
|
||||
if !ok {
|
||||
t.Fatalf("ParseCartID failed for valid id %s", id)
|
||||
}
|
||||
if parsed.raw != id.raw {
|
||||
t.Fatalf("parsed raw mismatch: %d vs %d", parsed.raw, id.raw)
|
||||
}
|
||||
|
||||
if _, ok := ParseCartID(""); ok {
|
||||
t.Fatalf("expected empty string to be invalid")
|
||||
}
|
||||
// Invalid char ('-')
|
||||
if _, ok := ParseCartID("abc-123"); ok {
|
||||
t.Fatalf("expected invalid chars to fail parse")
|
||||
}
|
||||
// Overly long ( >16 )
|
||||
if _, ok := ParseCartID("1234567890abcdefg"); ok {
|
||||
t.Fatalf("expected overly long string to fail parse")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFallbackDeterminism ensures fallback hashing is deterministic.
|
||||
func TestFallbackDeterminism(t *testing.T) {
|
||||
inputs := []string{
|
||||
"legacy-cart-1",
|
||||
"legacy-cart-2",
|
||||
"UPPER_lower_123",
|
||||
"🚀unicode", // unicode bytes (will hash byte sequence)
|
||||
}
|
||||
for _, in := range inputs {
|
||||
a := FallbackFromString(in)
|
||||
b := FallbackFromString(in)
|
||||
if a.raw != b.raw || a.String() != b.String() {
|
||||
t.Fatalf("fallback mismatch for %q: %+v vs %+v", in, a, b)
|
||||
}
|
||||
}
|
||||
// Distinct inputs should almost always differ; sample check
|
||||
a := FallbackFromString("distinct-A")
|
||||
b := FallbackFromString("distinct-B")
|
||||
if a.raw == b.raw {
|
||||
t.Fatalf("unexpected identical fallback hashes for distinct inputs")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanonicalizeIncomingBehavior covers main control flow branches.
|
||||
func TestCanonicalizeIncomingBehavior(t *testing.T) {
|
||||
// Empty => new id
|
||||
id1, generated, err := CanonicalizeIncoming("")
|
||||
if err != nil || !generated || id1.IsZero() {
|
||||
t.Fatalf("CanonicalizeIncoming empty failed: id=%v gen=%v err=%v", id1, generated, err)
|
||||
}
|
||||
|
||||
// Valid base62 => parse; no generation
|
||||
id2, gen2, err := CanonicalizeIncoming(id1.String())
|
||||
if err != nil || gen2 || id2.raw != id1.raw {
|
||||
t.Fatalf("CanonicalizeIncoming parse mismatch: id2=%v gen2=%v err=%v", id2, gen2, err)
|
||||
}
|
||||
|
||||
// Legacy-like random containing invalid chars -> fallback
|
||||
fallbackInput := "legacy\x00\x00padding"
|
||||
id3, gen3, err := CanonicalizeIncoming(fallbackInput)
|
||||
if err != nil || gen3 {
|
||||
t.Fatalf("CanonicalizeIncoming fallback unexpected: id3=%v gen3=%v err=%v", id3, gen3, err)
|
||||
}
|
||||
|
||||
// Deterministic fallback
|
||||
id4, _, _ := CanonicalizeIncoming(fallbackInput)
|
||||
if id3.raw != id4.raw {
|
||||
t.Fatalf("fallback canonicalization not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpgradeLegacyCartId ensures mapping of old CartId is stable.
|
||||
func TestUpgradeLegacyCartId(t *testing.T) {
|
||||
var legacy CartId
|
||||
copy(legacy[:], []byte("legacy-123456789")) // 15 bytes + padding
|
||||
up1 := UpgradeLegacyCartId(legacy)
|
||||
up2 := UpgradeLegacyCartId(legacy)
|
||||
if up1.raw != up2.raw {
|
||||
t.Fatalf("UpgradeLegacyCartId not deterministic: %v vs %v", up1, up2)
|
||||
}
|
||||
if up1.String() != up2.String() {
|
||||
t.Fatalf("UpgradeLegacyCartId string mismatch: %s vs %s", up1, up2)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkNewCartID gives a rough idea of generation cost.
|
||||
func BenchmarkNewCartID(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := NewCartID(); err != nil {
|
||||
b.Fatalf("error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEncodeBase62 measures encode speed in isolation.
|
||||
func BenchmarkEncodeBase62(b *testing.B) {
|
||||
// Random sample of values
|
||||
samples := make([]uint64, 1024)
|
||||
for i := range samples {
|
||||
var buf [8]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
b.Fatalf("rand: %v", err)
|
||||
}
|
||||
samples[i] = binary.BigEndian.Uint64(buf[:])
|
||||
}
|
||||
b.ResetTimer()
|
||||
var sink string
|
||||
for i := 0; i < b.N; i++ {
|
||||
sink = encodeBase62(samples[i%len(samples)])
|
||||
}
|
||||
_ = sink
|
||||
}
|
||||
|
||||
// BenchmarkDecodeBase62 measures decode speed.
|
||||
func BenchmarkDecodeBase62(b *testing.B) {
|
||||
// Pre-encode
|
||||
encoded := make([]string, 1024)
|
||||
for i := range encoded {
|
||||
encoded[i] = encodeBase62(uint64(i)<<32 | uint64(i))
|
||||
}
|
||||
b.ResetTimer()
|
||||
var sum uint64
|
||||
for i := 0; i < b.N; i++ {
|
||||
v, ok := decodeBase62(encoded[i%len(encoded)])
|
||||
if !ok {
|
||||
b.Fatalf("decode failed")
|
||||
}
|
||||
sum ^= v
|
||||
}
|
||||
_ = sum
|
||||
}
|
||||
|
||||
// TestLookupNDeterminism (ring integration smoke test) ensures LookupN
|
||||
// returns distinct hosts and stable ordering for a fixed ring.
|
||||
func TestLookupNDeterminism(t *testing.T) {
|
||||
rb := NewRingBuilder().WithEpoch(1).WithVnodesPerHost(8).WithHosts([]string{"a", "b", "c"})
|
||||
ring := rb.Build()
|
||||
if ring.Empty() {
|
||||
t.Fatalf("expected non-empty ring")
|
||||
}
|
||||
id := MustNewCartID()
|
||||
owners1 := ring.LookupN(id.Raw(), 3)
|
||||
owners2 := ring.LookupN(id.Raw(), 3)
|
||||
if len(owners1) != len(owners2) {
|
||||
t.Fatalf("LookupN length mismatch")
|
||||
}
|
||||
for i := range owners1 {
|
||||
if owners1[i].Host != owners2[i].Host {
|
||||
t.Fatalf("LookupN ordering instability at %d: %v vs %v", i, owners1[i], owners2[i])
|
||||
}
|
||||
}
|
||||
// Distinct host constraint
|
||||
seen := map[string]struct{}{}
|
||||
for _, v := range owners1 {
|
||||
if _, ok := seen[v.Host]; ok {
|
||||
t.Fatalf("duplicate host in LookupN result: %v", owners1)
|
||||
}
|
||||
seen[v.Host] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingFingerprintChanges ensures fingerprint updates with membership changes.
|
||||
func TestRingFingerprintChanges(t *testing.T) {
|
||||
b1 := NewRingBuilder().WithEpoch(1).WithHosts([]string{"node1", "node2"})
|
||||
r1 := b1.Build()
|
||||
b2 := NewRingBuilder().WithEpoch(2).WithHosts([]string{"node1", "node2", "node3"})
|
||||
r2 := b2.Build()
|
||||
if r1.Fingerprint() == r2.Fingerprint() {
|
||||
t.Fatalf("expected differing fingerprints after host set change")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingDiffHosts verifies added/removed host detection.
|
||||
func TestRingDiffHosts(t *testing.T) {
|
||||
r1 := NewRingBuilder().WithEpoch(1).WithHosts([]string{"a", "b"}).Build()
|
||||
r2 := NewRingBuilder().WithEpoch(2).WithHosts([]string{"b", "c"}).Build()
|
||||
added, removed := r1.DiffHosts(r2)
|
||||
if fmt.Sprintf("%v", added) != "[c]" {
|
||||
t.Fatalf("expected added [c], got %v", added)
|
||||
}
|
||||
if fmt.Sprintf("%v", removed) != "[a]" {
|
||||
t.Fatalf("expected removed [a], got %v", removed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRingLookupConsistency ensures direct Lookup and LookupID are aligned.
|
||||
func TestRingLookupConsistency(t *testing.T) {
|
||||
ring := NewRingBuilder().WithEpoch(1).WithHosts([]string{"alpha", "beta"}).WithVnodesPerHost(4).Build()
|
||||
id, _ := ParseCartID("1")
|
||||
if id.IsZero() {
|
||||
t.Fatalf("expected parsed id non-zero")
|
||||
}
|
||||
v1 := ring.Lookup(id.Raw())
|
||||
v2 := ring.LookupID(id)
|
||||
if v1.Host != v2.Host || v1.Hash != v2.Hash {
|
||||
t.Fatalf("Lookup vs LookupID mismatch: %+v vs %+v", v1, v2)
|
||||
}
|
||||
}
|
||||
212
cart_state_mapper.go
Normal file
212
cart_state_mapper.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// cart_state_mapper.go
|
||||
//
|
||||
// Utilities to translate between internal CartGrain state and the gRPC
|
||||
// (typed) protobuf representation CartState. This replaces the previous
|
||||
// JSON blob framing and enables type-safe replies over gRPC, as well as
|
||||
// internal reuse for HTTP handlers without an extra marshal / unmarshal
|
||||
// hop (you can marshal CartState directly for JSON responses if desired).
|
||||
//
|
||||
// Only the one‑way mapping (CartGrain -> CartState) is strictly required
|
||||
// for mutation / state replies. A reverse helper is included in case
|
||||
// future features (e.g. snapshot import, replay, or migration) need it.
|
||||
|
||||
// ToCartState converts the in‑memory CartGrain into a protobuf CartState.
|
||||
func ToCartState(c *CartGrain) *messages.CartState {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
items := make([]*messages.CartItemState, 0, len(c.Items))
|
||||
for _, it := range c.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
itemDiscountPerUnit := max(0, it.OrgPrice-it.Price)
|
||||
itemTotalDiscount := itemDiscountPerUnit * int64(it.Quantity)
|
||||
|
||||
items = append(items, &messages.CartItemState{
|
||||
Id: int64(it.Id),
|
||||
ItemId: int64(it.ItemId),
|
||||
Sku: it.Sku,
|
||||
Name: it.Name,
|
||||
Price: it.Price,
|
||||
Qty: int32(it.Quantity),
|
||||
TotalPrice: it.TotalPrice,
|
||||
TotalTax: it.TotalTax,
|
||||
OrgPrice: it.OrgPrice,
|
||||
TaxRate: int32(it.TaxRate),
|
||||
TotalDiscount: itemTotalDiscount,
|
||||
Brand: it.Brand,
|
||||
Category: it.Category,
|
||||
Category2: it.Category2,
|
||||
Category3: it.Category3,
|
||||
Category4: it.Category4,
|
||||
Category5: it.Category5,
|
||||
Image: it.Image,
|
||||
Type: it.ArticleType,
|
||||
SellerId: it.SellerId,
|
||||
SellerName: it.SellerName,
|
||||
Disclaimer: it.Disclaimer,
|
||||
Outlet: deref(it.Outlet),
|
||||
StoreId: deref(it.StoreId),
|
||||
Stock: int32(it.Stock),
|
||||
})
|
||||
}
|
||||
|
||||
deliveries := make([]*messages.DeliveryState, 0, len(c.Deliveries))
|
||||
for _, d := range c.Deliveries {
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
itemIds := make([]int64, 0, len(d.Items))
|
||||
for _, id := range d.Items {
|
||||
itemIds = append(itemIds, int64(id))
|
||||
}
|
||||
var pp *messages.PickupPoint
|
||||
if d.PickupPoint != nil {
|
||||
// Copy to avoid accidental shared mutation (proto points are fine but explicit).
|
||||
pp = &messages.PickupPoint{
|
||||
Id: d.PickupPoint.Id,
|
||||
Name: d.PickupPoint.Name,
|
||||
Address: d.PickupPoint.Address,
|
||||
City: d.PickupPoint.City,
|
||||
Zip: d.PickupPoint.Zip,
|
||||
Country: d.PickupPoint.Country,
|
||||
}
|
||||
}
|
||||
deliveries = append(deliveries, &messages.DeliveryState{
|
||||
Id: int64(d.Id),
|
||||
Provider: d.Provider,
|
||||
Price: d.Price,
|
||||
Items: itemIds,
|
||||
PickupPoint: pp,
|
||||
})
|
||||
}
|
||||
|
||||
return &messages.CartState{
|
||||
Id: c.Id.String(),
|
||||
Items: items,
|
||||
TotalPrice: c.TotalPrice,
|
||||
TotalTax: c.TotalTax,
|
||||
TotalDiscount: c.TotalDiscount,
|
||||
Deliveries: deliveries,
|
||||
PaymentInProgress: c.PaymentInProgress,
|
||||
OrderReference: c.OrderReference,
|
||||
PaymentStatus: c.PaymentStatus,
|
||||
}
|
||||
}
|
||||
|
||||
// FromCartState merges a protobuf CartState into an existing CartGrain.
|
||||
// This is optional and primarily useful for snapshot import or testing.
|
||||
func FromCartState(cs *messages.CartState, g *CartGrain) *CartGrain {
|
||||
if cs == nil {
|
||||
return g
|
||||
}
|
||||
if g == nil {
|
||||
g = &CartGrain{}
|
||||
}
|
||||
g.Id = ToCartId(cs.Id)
|
||||
g.TotalPrice = cs.TotalPrice
|
||||
g.TotalTax = cs.TotalTax
|
||||
g.TotalDiscount = cs.TotalDiscount
|
||||
g.PaymentInProgress = cs.PaymentInProgress
|
||||
g.OrderReference = cs.OrderReference
|
||||
g.PaymentStatus = cs.PaymentStatus
|
||||
|
||||
// Items
|
||||
g.Items = g.Items[:0]
|
||||
for _, it := range cs.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
outlet := toPtr(it.Outlet)
|
||||
storeId := toPtr(it.StoreId)
|
||||
g.Items = append(g.Items, &CartItem{
|
||||
Id: int(it.Id),
|
||||
ItemId: int(it.ItemId),
|
||||
Sku: it.Sku,
|
||||
Name: it.Name,
|
||||
Price: it.Price,
|
||||
Quantity: int(it.Qty),
|
||||
TotalPrice: it.TotalPrice,
|
||||
TotalTax: it.TotalTax,
|
||||
OrgPrice: it.OrgPrice,
|
||||
TaxRate: int(it.TaxRate),
|
||||
Brand: it.Brand,
|
||||
Category: it.Category,
|
||||
Category2: it.Category2,
|
||||
Category3: it.Category3,
|
||||
Category4: it.Category4,
|
||||
Category5: it.Category5,
|
||||
Image: it.Image,
|
||||
ArticleType: it.Type,
|
||||
SellerId: it.SellerId,
|
||||
SellerName: it.SellerName,
|
||||
Disclaimer: it.Disclaimer,
|
||||
Outlet: outlet,
|
||||
StoreId: storeId,
|
||||
Stock: StockStatus(it.Stock),
|
||||
// Tax, TaxRate already set via Price / Totals if needed
|
||||
})
|
||||
if it.Id > int64(g.lastItemId) {
|
||||
g.lastItemId = int(it.Id)
|
||||
}
|
||||
}
|
||||
|
||||
// Deliveries
|
||||
g.Deliveries = g.Deliveries[:0]
|
||||
for _, d := range cs.Deliveries {
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
intIds := make([]int, 0, len(d.Items))
|
||||
for _, id := range d.Items {
|
||||
intIds = append(intIds, int(id))
|
||||
}
|
||||
var pp *messages.PickupPoint
|
||||
if d.PickupPoint != nil {
|
||||
pp = &messages.PickupPoint{
|
||||
Id: d.PickupPoint.Id,
|
||||
Name: d.PickupPoint.Name,
|
||||
Address: d.PickupPoint.Address,
|
||||
City: d.PickupPoint.City,
|
||||
Zip: d.PickupPoint.Zip,
|
||||
Country: d.PickupPoint.Country,
|
||||
}
|
||||
}
|
||||
g.Deliveries = append(g.Deliveries, &CartDelivery{
|
||||
Id: int(d.Id),
|
||||
Provider: d.Provider,
|
||||
Price: d.Price,
|
||||
Items: intIds,
|
||||
PickupPoint: pp,
|
||||
})
|
||||
if d.Id > int64(g.lastDeliveryId) {
|
||||
g.lastDeliveryId = int(d.Id)
|
||||
}
|
||||
}
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
// Helper to safely de-reference optional string pointers to value or "".
|
||||
func deref(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func toPtr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
119
checkout_builder.go
Normal file
119
checkout_builder.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// CheckoutMeta carries the external / URL metadata required to build a
|
||||
// Klarna CheckoutOrder from a CartGrain snapshot. It deliberately excludes
|
||||
// any Klarna-specific response fields (HTML snippet, client token, etc.).
|
||||
type CheckoutMeta struct {
|
||||
Terms string
|
||||
Checkout string
|
||||
Confirmation string
|
||||
Validation string
|
||||
Push string
|
||||
Country string
|
||||
Currency string // optional override (defaults to "SEK" if empty)
|
||||
Locale string // optional override (defaults to "sv-se" if empty)
|
||||
}
|
||||
|
||||
// BuildCheckoutOrderPayload converts the current cart grain + meta information
|
||||
// into a CheckoutOrder domain struct and returns its JSON-serialized payload
|
||||
// (to send to Klarna) alongside the structured CheckoutOrder object.
|
||||
//
|
||||
// This function is PURE: it does not perform any network I/O or mutate the
|
||||
// grain. The caller is responsible for:
|
||||
//
|
||||
// 1. Choosing whether to create or update the Klarna order.
|
||||
// 2. Invoking KlarnaClient.CreateOrder / UpdateOrder with the returned payload.
|
||||
// 3. Applying an InitializeCheckout mutation (or equivalent) with the
|
||||
// resulting Klarna order id + status.
|
||||
//
|
||||
// If you later need to support different tax rates per line, you can extend
|
||||
// CartItem / Delivery to expose that data and propagate it here.
|
||||
func BuildCheckoutOrderPayload(grain *CartGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
|
||||
if grain == nil {
|
||||
return nil, nil, fmt.Errorf("nil grain")
|
||||
}
|
||||
if meta == nil {
|
||||
return nil, nil, fmt.Errorf("nil checkout meta")
|
||||
}
|
||||
|
||||
currency := meta.Currency
|
||||
if currency == "" {
|
||||
currency = "SEK"
|
||||
}
|
||||
locale := meta.Locale
|
||||
if locale == "" {
|
||||
locale = "sv-se"
|
||||
}
|
||||
country := meta.Country
|
||||
if country == "" {
|
||||
country = "SE" // sensible default; adjust if multi-country support changes
|
||||
}
|
||||
|
||||
lines := make([]*Line, 0, len(grain.Items)+len(grain.Deliveries))
|
||||
|
||||
// Item lines
|
||||
for _, it := range grain.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, &Line{
|
||||
Type: "physical",
|
||||
Reference: it.Sku,
|
||||
Name: it.Name,
|
||||
Quantity: it.Quantity,
|
||||
UnitPrice: int(it.Price),
|
||||
TaxRate: 2500, // TODO: derive if variable tax rates are introduced
|
||||
QuantityUnit: "st",
|
||||
TotalAmount: int(it.TotalPrice),
|
||||
TotalTaxAmount: int(it.TotalTax),
|
||||
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", it.Image),
|
||||
})
|
||||
}
|
||||
|
||||
// Delivery lines
|
||||
for _, d := range grain.Deliveries {
|
||||
if d == nil || d.Price <= 0 {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, &Line{
|
||||
Type: "shipping_fee",
|
||||
Reference: d.Provider,
|
||||
Name: "Delivery",
|
||||
Quantity: 1,
|
||||
UnitPrice: int(d.Price),
|
||||
TaxRate: 2500,
|
||||
QuantityUnit: "st",
|
||||
TotalAmount: int(d.Price),
|
||||
TotalTaxAmount: int(GetTaxAmount(d.Price, 2500)),
|
||||
})
|
||||
}
|
||||
|
||||
order := &CheckoutOrder{
|
||||
PurchaseCountry: country,
|
||||
PurchaseCurrency: currency,
|
||||
Locale: locale,
|
||||
OrderAmount: int(grain.TotalPrice),
|
||||
OrderTaxAmount: int(grain.TotalTax),
|
||||
OrderLines: lines,
|
||||
MerchantReference1: grain.Id.String(),
|
||||
MerchantURLS: &CheckoutMerchantURLS{
|
||||
Terms: meta.Terms,
|
||||
Checkout: meta.Checkout,
|
||||
Confirmation: meta.Confirmation,
|
||||
Validation: meta.Validation,
|
||||
Push: meta.Push,
|
||||
},
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(order)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("marshal checkout order: %w", err)
|
||||
}
|
||||
|
||||
return payload, order, nil
|
||||
}
|
||||
@@ -1,439 +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"
|
||||
"git.k6n.net/go-cart-actor/pkg/checkout"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type FileServer struct {
|
||||
// Define fields here
|
||||
dataDir string
|
||||
checkoutDataDir string
|
||||
storage actor.LogStorage[cart.CartGrain]
|
||||
checkoutStorage actor.LogStorage[checkout.CheckoutGrain]
|
||||
}
|
||||
|
||||
func NewFileServer(dataDir string, checkoutDataDir string, storage actor.LogStorage[cart.CartGrain], checkoutStorage actor.LogStorage[checkout.CheckoutGrain]) *FileServer {
|
||||
return &FileServer{
|
||||
dataDir: dataDir,
|
||||
checkoutDataDir: checkoutDataDir,
|
||||
storage: storage,
|
||||
checkoutStorage: checkoutStorage,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func appendCheckoutFileInfo(info fs.FileInfo, out *CheckoutFileInfo) *CheckoutFileInfo {
|
||||
out.Size = info.Size()
|
||||
out.Modified = info.ModTime()
|
||||
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 listCheckoutFiles(dir string) ([]*CheckoutFileInfo, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []*CheckoutFileInfo{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*CheckoutFileInfo, 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
|
||||
}
|
||||
out = append(out, appendCheckoutFileInfo(info, &CheckoutFileInfo{
|
||||
ID: fmt.Sprintf("%d", id),
|
||||
CheckoutId: checkout.CheckoutId(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) CheckoutsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := listCheckoutFiles(fs.checkoutDataDir)
|
||||
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),
|
||||
"checkouts": 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (fs *FileServer) CheckoutHandler(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 := checkout.NewCheckoutGrain(id, cart.CartId(id), 0, time.Now(), nil)
|
||||
err := fs.checkoutStorage.LoadEventsFunc(r.Context(), id, grain, filterFunction)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
path := filepath.Join(fs.checkoutDataDir, 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: "checkout 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,
|
||||
"checkoutId": checkout.CheckoutId(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,210 +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"
|
||||
"git.k6n.net/go-cart-actor/pkg/checkout"
|
||||
"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"`
|
||||
}
|
||||
|
||||
type CheckoutFileInfo struct {
|
||||
ID string `json:"id"`
|
||||
CheckoutId checkout.CheckoutId `json:"checkoutId"`
|
||||
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(cart.NewCartMutationContext(nil))
|
||||
diskStorage := actor.NewDiskStorage[cart.CartGrain](dataDir, reg)
|
||||
|
||||
checkoutDataDir := envOrDefault("CHECKOUT_DATA_DIR", "checkout-data")
|
||||
_ = os.MkdirAll(checkoutDataDir, 0755)
|
||||
|
||||
regCheckout := checkout.NewCheckoutMutationRegistry(checkout.NewCheckoutMutationContext())
|
||||
diskStorageCheckout := actor.NewDiskStorage[checkout.CheckoutGrain](checkoutDataDir, regCheckout)
|
||||
|
||||
fs := NewFileServer(dataDir, checkoutDataDir, diskStorage, diskStorageCheckout)
|
||||
|
||||
hub := NewHub()
|
||||
go hub.Run()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /carts", fs.CartsHandler)
|
||||
mux.HandleFunc("GET /cart/{id}", fs.CartHandler)
|
||||
mux.HandleFunc("GET /checkouts", fs.CheckoutsHandler)
|
||||
mux.HandleFunc("GET /checkout/{id}", fs.CheckoutHandler)
|
||||
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,63 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"git.k6n.net/go-cart-actor/pkg/discovery"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"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)
|
||||
}
|
||||
timeout := int64(30)
|
||||
return discovery.NewK8sDiscovery(client, v1.ListOptions{
|
||||
LabelSelector: "actor-pool=cart",
|
||||
TimeoutSeconds: &timeout,
|
||||
})
|
||||
}
|
||||
|
||||
func UseDiscovery(pool discovery.DiscoveryTarget) {
|
||||
|
||||
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())
|
||||
}
|
||||
315
cmd/cart/main.go
315
cmd/cart/main.go
@@ -1,315 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
"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
|
||||
}
|
||||
|
||||
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 matchesSkuAndLocation(update inventory.InventoryResult, item cart.CartItem) bool {
|
||||
if string(update.SKU) == item.Sku {
|
||||
if update.LocationID == "se" && item.StoreId == nil {
|
||||
return true
|
||||
}
|
||||
if item.StoreId == nil {
|
||||
return false
|
||||
}
|
||||
if *item.StoreId == string(update.LocationID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
inventoryPubSub := actor.NewPubSub[inventory.InventoryChange]()
|
||||
|
||||
// promotionService := promotions.NewPromotionService(nil)
|
||||
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)
|
||||
}
|
||||
|
||||
inventoryReservationService, err := inventory.NewRedisCartReservationService(rdb)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating inventory reservation service: %v\n", err)
|
||||
}
|
||||
|
||||
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(inventoryReservationService))
|
||||
reg.RegisterProcessor(
|
||||
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
|
||||
_, span := tracer.Start(ctx, "Totals and promotions")
|
||||
defer span.End()
|
||||
g.UpdateTotals()
|
||||
g.Version++
|
||||
// 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.
|
||||
|
||||
inventoryPubSub.Subscribe(ret.HandleInventoryChange)
|
||||
err := diskStorage.LoadEvents(ctx, id, ret)
|
||||
if err == nil && inventoryService != nil {
|
||||
refs := make([]*inventory.InventoryReference, 0)
|
||||
for _, item := range ret.Items {
|
||||
refs = append(refs, &inventory.InventoryReference{
|
||||
SKU: inventory.SKU(item.Sku),
|
||||
LocationID: getLocationId(item),
|
||||
})
|
||||
}
|
||||
_, span := tracer.Start(ctx, "update inventory")
|
||||
defer span.End()
|
||||
res, err := inventoryService.GetInventoryBatch(ctx, refs...)
|
||||
if err != nil {
|
||||
log.Printf("unable to update inventory %v", err)
|
||||
} else {
|
||||
for _, update := range res {
|
||||
for _, item := range ret.Items {
|
||||
if matchesSkuAndLocation(update, *item) && update.Quantity != uint32(item.Stock) {
|
||||
// maybe apply an update to give visibility to the cart
|
||||
item.Stock = uint16(update.Quantity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ret, err
|
||||
},
|
||||
Destroy: func(grain actor.Grain[cart.CartGrain]) error {
|
||||
cart, err := grain.GetCurrentState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inventoryPubSub.Unsubscribe(cart.HandleInventoryChange)
|
||||
|
||||
return nil
|
||||
},
|
||||
SpawnHost: func(host string) (actor.Host[cart.CartGrain], error) {
|
||||
return proxy.NewRemoteHost[cart.CartGrain](host)
|
||||
},
|
||||
TTL: 5 * time.Minute,
|
||||
PoolSize: 2 * 65535,
|
||||
Hostname: podIp,
|
||||
}
|
||||
|
||||
pool, err := actor.NewSimpleGrainPool(poolConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating cart pool: %v\n", err)
|
||||
}
|
||||
|
||||
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), inventoryService, inventoryReservationService)
|
||||
|
||||
app := &App{
|
||||
pool: pool,
|
||||
server: syncedServer,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
debugMux := http.NewServeMux()
|
||||
|
||||
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()
|
||||
}()
|
||||
|
||||
listener := inventory.NewInventoryChangeListener(rdb, context.Background(), func(changes []inventory.InventoryChange) {
|
||||
for _, change := range changes {
|
||||
log.Printf("inventory change: %v", change)
|
||||
inventoryPubSub.Publish(change)
|
||||
}
|
||||
})
|
||||
|
||||
go func() {
|
||||
err := listener.Start()
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to start inventory listener: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
}
|
||||
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,530 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"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/proto/cart"
|
||||
|
||||
"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"
|
||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"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
|
||||
inventoryService inventory.InventoryService
|
||||
reservationService inventory.CartReservationService
|
||||
}
|
||||
|
||||
func NewPoolServer(pool actor.GrainPool[cart.CartGrain], pod_name string, inventoryService inventory.InventoryService, inventoryReservationService inventory.CartReservationService) *PoolServer {
|
||||
srv := &PoolServer{
|
||||
GrainPool: pool,
|
||||
pod_name: pod_name,
|
||||
inventoryService: inventoryService,
|
||||
reservationService: inventoryReservationService,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 (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)
|
||||
// }
|
||||
//
|
||||
|
||||
// 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 (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, nil)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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 {
|
||||
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) 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) InternalApplyMutationHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return nil
|
||||
}
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mutation := &messages.Mutation{}
|
||||
err = proto.Unmarshal(data, mutation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(r.Context(), cartId, mutation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
func (s *PoolServer) GetAnywhere(ctx context.Context, cartId cart.CartId) (*cart.CartGrain, error) {
|
||||
id := uint64(cartId)
|
||||
if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost {
|
||||
return host.Get(ctx, id)
|
||||
}
|
||||
return s.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (s *PoolServer) ApplyAnywhere(ctx context.Context, cartId cart.CartId, msgs ...proto.Message) error {
|
||||
id := uint64(cartId)
|
||||
if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost {
|
||||
_, err := host.Apply(ctx, id, msgs...)
|
||||
return err
|
||||
}
|
||||
_, err := s.Apply(ctx, id, msgs...)
|
||||
return err
|
||||
}
|
||||
|
||||
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("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)))
|
||||
|
||||
//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("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("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)))
|
||||
}
|
||||
@@ -1,150 +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/proto/cart"
|
||||
|
||||
"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,138 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
||||
)
|
||||
|
||||
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{
|
||||
InventoryReference: &inventory.InventoryReference{
|
||||
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 getClientIp(r *http.Request) string {
|
||||
ip := r.Header.Get("X-Forwarded-For")
|
||||
if ip == "" {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
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()))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||
messages "git.k6n.net/go-cart-actor/proto/checkout"
|
||||
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
|
||||
"github.com/adyen/adyen-go-api-library/v21/src/common"
|
||||
"github.com/adyen/adyen-go-api-library/v21/src/hmacvalidator"
|
||||
"github.com/adyen/adyen-go-api-library/v21/src/webhook"
|
||||
"github.com/google/uuid"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/anypb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
type SessionRequest struct {
|
||||
SessionId *string `json:"sessionId,omitempty"`
|
||||
SessionResult string `json:"sessionResult"`
|
||||
SessionData *string `json:"sessionData,omitempty"`
|
||||
}
|
||||
|
||||
// func (s *CheckoutPoolServer) AdyenSessionHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||
|
||||
// grain, err := s.Get(r.Context(), uint64(cartId))
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// if r.Method == http.MethodGet {
|
||||
// service := s.adyenClient.Checkout()
|
||||
// req := service.PaymentsApi.GetResultOfPaymentSessionInput(pa).SessionResult(payload.SessionResult)
|
||||
// res, _, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// return s.WriteResult(w, res)
|
||||
// } else {
|
||||
// payload := &SessionRequest{}
|
||||
// if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
|
||||
// return err
|
||||
// }
|
||||
// service := s.adyenClient.Checkout()
|
||||
// req := service.PaymentsApi.GetResultOfPaymentSessionInput(payload.SessionId).SessionResult(payload.SessionResult)
|
||||
// res, _, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// return s.WriteResult(w, res)
|
||||
// }
|
||||
|
||||
// }
|
||||
|
||||
func getCheckoutIdFromNotificationItem(item webhook.NotificationRequestItem) (*cart.CartId, error) {
|
||||
cartId, ok := cart.ParseCartId(item.MerchantReference)
|
||||
if !ok {
|
||||
log.Printf("The notification does not have a valid cartId: %s", item.MerchantReference)
|
||||
return nil, errors.New("invalid cart id")
|
||||
}
|
||||
return &cartId, nil
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) AdyenHookHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var notificationRequest webhook.Webhook
|
||||
service := s.adyenClient.Checkout()
|
||||
if err := json.NewDecoder(r.Body).Decode(¬ificationRequest); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
for _, notificationItem := range *notificationRequest.NotificationItems {
|
||||
item := notificationItem.NotificationRequestItem
|
||||
log.Printf("Recieved notification event code: %s, %+v", item.EventCode, item)
|
||||
|
||||
isValid := hmacvalidator.ValidateHmac(item, hmacKey)
|
||||
if !isValid {
|
||||
log.Printf("notification hmac not valid %s, %v", item.EventCode, item)
|
||||
http.Error(w, "Invalid HMAC", http.StatusUnauthorized)
|
||||
return
|
||||
} else {
|
||||
// Marshal item data for PaymentEvent
|
||||
dataBytes, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
log.Printf("error marshaling item: %v", err)
|
||||
http.Error(w, "Error marshaling item", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
switch item.EventCode {
|
||||
case "CAPTURE":
|
||||
checkoutId, err := getCheckoutIdFromNotificationItem(item)
|
||||
if err != nil {
|
||||
log.Printf("Could not get checkout id: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Capture status: %v", item.Success)
|
||||
isSuccess := item.Success == "true"
|
||||
|
||||
// If successful, apply payment completed
|
||||
//if isSuccess {
|
||||
if err := s.ApplyAnywhere(r.Context(), *checkoutId,
|
||||
&messages.PaymentEvent{
|
||||
PaymentId: item.PspReference,
|
||||
Success: isSuccess,
|
||||
Name: item.EventCode,
|
||||
Data: &anypb.Any{Value: dataBytes},
|
||||
}, &messages.PaymentCompleted{
|
||||
PaymentId: item.PspReference,
|
||||
Status: item.Success,
|
||||
Amount: item.Amount.Value,
|
||||
Currency: item.Amount.Currency,
|
||||
ProcessorReference: &item.PspReference,
|
||||
CompletedAt: timestamppb.New(time.Now()),
|
||||
}); err != nil {
|
||||
http.Error(w, "Message not parsed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
//}
|
||||
|
||||
case "AUTHORISATION":
|
||||
|
||||
isSuccess := item.Success == "true"
|
||||
log.Printf("Handling auth: %+v", item)
|
||||
checkoutId, err := getCheckoutIdFromNotificationItem(item)
|
||||
if err != nil {
|
||||
log.Printf("Could not get checkout id: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
msgs := []proto.Message{
|
||||
&messages.PaymentEvent{
|
||||
PaymentId: item.PspReference,
|
||||
Success: isSuccess,
|
||||
Name: item.EventCode,
|
||||
Data: &anypb.Any{Value: dataBytes},
|
||||
},
|
||||
}
|
||||
if isSuccess {
|
||||
msgs = append(msgs, &messages.PaymentCompleted{
|
||||
PaymentId: item.PspReference,
|
||||
Status: item.Success,
|
||||
Amount: item.Amount.Value,
|
||||
CompletedAt: timestamppb.Now(),
|
||||
})
|
||||
} else {
|
||||
msgs = append(msgs, &messages.PaymentDeclined{
|
||||
PaymentId: item.PspReference,
|
||||
Message: item.Reason,
|
||||
})
|
||||
}
|
||||
if err := s.ApplyAnywhere(r.Context(), *checkoutId, msgs...); err != nil {
|
||||
log.Printf("error applying authorization event: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// If successful authorization, trigger capture
|
||||
if isSuccess {
|
||||
|
||||
pspReference := item.PspReference
|
||||
uid := uuid.New().String()
|
||||
ref := checkoutId.String()
|
||||
req := service.ModificationsApi.CaptureAuthorisedPaymentInput(pspReference).IdempotencyKey(uid).PaymentCaptureRequest(adyenCheckout.PaymentCaptureRequest{
|
||||
Amount: adyenCheckout.Amount(item.Amount),
|
||||
MerchantAccount: "ElgigantenECOM",
|
||||
Reference: &ref,
|
||||
})
|
||||
res, _, err := service.ModificationsApi.CaptureAuthorisedPayment(r.Context(), req)
|
||||
if err != nil {
|
||||
log.Printf("Error capturing payment: %v", err)
|
||||
} else {
|
||||
log.Printf("Payment captured successfully: %+v", res)
|
||||
s.ApplyAnywhere(r.Context(), *checkoutId, &messages.OrderCreated{
|
||||
OrderId: res.PaymentPspReference,
|
||||
Status: item.EventCode,
|
||||
})
|
||||
}
|
||||
}
|
||||
default:
|
||||
log.Printf("Unknown event code: %s", item.EventCode)
|
||||
log.Printf("Item data: %+v", item)
|
||||
isSuccess := item.Success == "true"
|
||||
checkoutId, err := getCheckoutIdFromNotificationItem(item)
|
||||
if err != nil {
|
||||
log.Printf("Could not get checkout id: %v", err)
|
||||
|
||||
} else {
|
||||
if err := s.ApplyAnywhere(r.Context(), *checkoutId, &messages.PaymentEvent{
|
||||
PaymentId: item.PspReference,
|
||||
Success: isSuccess,
|
||||
Name: item.EventCode,
|
||||
Data: &anypb.Any{Value: dataBytes},
|
||||
}); err != nil {
|
||||
log.Printf("error applying payment event: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) AdyenReturnHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log.Println("Redirect received")
|
||||
|
||||
service := s.adyenClient.Checkout()
|
||||
|
||||
req := service.PaymentsApi.GetResultOfPaymentSessionInput(r.URL.Query().Get("sessionId"))
|
||||
|
||||
res, httpRes, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
|
||||
log.Printf("got payment session %+v", res)
|
||||
|
||||
dreq := service.PaymentsApi.PaymentsDetailsInput()
|
||||
dreq = dreq.PaymentDetailsRequest(adyenCheckout.PaymentDetailsRequest{
|
||||
Details: adyenCheckout.PaymentCompletionDetails{
|
||||
RedirectResult: common.PtrString(r.URL.Query().Get("redirectResult")),
|
||||
Payload: common.PtrString(r.URL.Query().Get("payload")),
|
||||
},
|
||||
})
|
||||
|
||||
dres, httpRes, err := service.PaymentsApi.PaymentsDetails(r.Context(), dreq)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.Printf("Payment details response: %+v", dres)
|
||||
|
||||
if !common.IsNil(dres.PspReference) && *dres.PspReference != "" {
|
||||
var redirectURL string
|
||||
// Conditionally handle different result codes for the shopper
|
||||
switch *dres.ResultCode {
|
||||
case "Authorised":
|
||||
redirectURL = "/result/success"
|
||||
case "Pending", "Received":
|
||||
redirectURL = "/result/pending"
|
||||
case "Refused":
|
||||
redirectURL = "/result/failed"
|
||||
default:
|
||||
reason := ""
|
||||
if dres.RefusalReason != nil {
|
||||
reason = *dres.RefusalReason
|
||||
} else {
|
||||
reason = *dres.ResultCode
|
||||
}
|
||||
log.Printf("Payment failed: %s", reason)
|
||||
redirectURL = fmt.Sprintf("/result/error?reason=%s", url.QueryEscape(reason))
|
||||
}
|
||||
http.Redirect(w, r, redirectURL, http.StatusFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(httpRes.StatusCode)
|
||||
json.NewEncoder(w).Encode(httpRes.Status)
|
||||
}
|
||||
@@ -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,67 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||
)
|
||||
|
||||
type CartClient struct {
|
||||
httpClient *http.Client
|
||||
baseUrl string
|
||||
}
|
||||
|
||||
func NewCartClient(baseUrl string) *CartClient {
|
||||
return &CartClient{
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
baseUrl: baseUrl,
|
||||
}
|
||||
}
|
||||
|
||||
// func (c *CartClient) ApplyMutation(cartId cart.CartId, mutation proto.Message) error {
|
||||
// url := fmt.Sprintf("%s/internal/cart/%s/mutation", c.baseUrl, cartId.String())
|
||||
// data, err := proto.Marshal(mutation)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// req, err := http.NewRequest("POST", url, bytes.NewReader(data))
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// req.Header.Set("Content-Type", "application/protobuf")
|
||||
// resp, err := c.httpClient.Do(req)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// defer resp.Body.Close()
|
||||
// if resp.StatusCode != http.StatusOK {
|
||||
// return fmt.Errorf("cart mutation failed: %s", resp.Status)
|
||||
// }
|
||||
// return nil
|
||||
// }
|
||||
|
||||
func (s *CartClient) getCartGrain(ctx context.Context, cartId cart.CartId) (*cart.CartGrain, error) {
|
||||
// Call cart service to get grain
|
||||
url := fmt.Sprintf("%s/cart/byid/%s", s.baseUrl, cartId.String())
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Printf("request to %s failed with status %s", url, resp.Status)
|
||||
return nil, fmt.Errorf("failed to get cart: %s", resp.Status)
|
||||
}
|
||||
var grain cart.CartGrain
|
||||
err = json.NewDecoder(resp.Body).Decode(&grain)
|
||||
return &grain, err
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||
)
|
||||
|
||||
// TestGetCartGrain_RealService tests against the actual service at https://cart.k6n.net/
|
||||
// This test is skipped by default and can be run with: go test -run TestGetCartGrain_RealService
|
||||
func TestGetCartGrain_RealService(t *testing.T) {
|
||||
t.Skip("Skipping integration test against real service")
|
||||
|
||||
client := NewCartClient("https://cart.k6n.net")
|
||||
|
||||
// You would need a real cart ID that exists in the system
|
||||
// For example: cartId := cart.NewCartId(123, 456)
|
||||
cartId := cart.MustParseCartId("JkfG6bRNLMy")
|
||||
|
||||
grain, err := client.getCartGrain(context.Background(), cartId)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get cart grain: %v", err)
|
||||
}
|
||||
|
||||
if grain == nil {
|
||||
t.Fatal("Expected grain to be non-nil")
|
||||
}
|
||||
|
||||
t.Logf("Successfully retrieved cart grain: %+v", grain)
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/go-cart-actor/pkg/checkout"
|
||||
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
|
||||
"github.com/adyen/adyen-go-api-library/v21/src/common"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
SiteUrl string
|
||||
// Terms string
|
||||
// Checkout string
|
||||
// Confirmation string
|
||||
ClientIp 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 *checkout.CheckoutGrain, 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.CartState.Items)+len(grain.Deliveries))
|
||||
|
||||
// Item lines
|
||||
for _, it := range grain.CartState.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, &Line{
|
||||
Type: "physical",
|
||||
Reference: it.Sku,
|
||||
Name: it.Meta.Name,
|
||||
Quantity: int(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),
|
||||
})
|
||||
}
|
||||
|
||||
total := cart.NewPrice()
|
||||
total.Add(*grain.CartState.TotalPrice)
|
||||
|
||||
// Delivery lines
|
||||
for _, d := range grain.Deliveries {
|
||||
if d == nil || d.Price.IncVat <= 0 {
|
||||
continue
|
||||
}
|
||||
//total.Add(d.Price)
|
||||
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(total.IncVat),
|
||||
OrderTaxAmount: int(total.TotalVat()),
|
||||
OrderLines: lines,
|
||||
MerchantReference1: grain.Id.String(),
|
||||
MerchantURLS: &CheckoutMerchantURLS{
|
||||
Terms: fmt.Sprintf("%s/terms", meta.SiteUrl),
|
||||
Checkout: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl),
|
||||
Confirmation: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl),
|
||||
Notification: "https://cart.k6n.net/payment/klarna/notification",
|
||||
Validation: "https://cart.k6n.net/payment/klarna/validate",
|
||||
Push: "https://cart.k6n.net/payment/klarna/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
|
||||
}
|
||||
|
||||
func GetCheckoutMetaFromRequest(r *http.Request) *CheckoutMeta {
|
||||
host := getOriginalHost(r)
|
||||
country := getCountryFromHost(host)
|
||||
return &CheckoutMeta{
|
||||
ClientIp: getClientIp(r),
|
||||
SiteUrl: fmt.Sprintf("https://%s", host),
|
||||
Country: country,
|
||||
Currency: getCurrency(country),
|
||||
Locale: getLocale(country),
|
||||
}
|
||||
}
|
||||
|
||||
func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta) (*adyenCheckout.CreateCheckoutSessionRequest, error) {
|
||||
if grain == nil {
|
||||
return nil, fmt.Errorf("nil grain")
|
||||
}
|
||||
if meta == nil {
|
||||
return nil, fmt.Errorf("nil checkout meta")
|
||||
}
|
||||
|
||||
currency := meta.Currency
|
||||
if currency == "" {
|
||||
currency = "SEK"
|
||||
}
|
||||
country := meta.Country
|
||||
if country == "" {
|
||||
country = "SE"
|
||||
}
|
||||
|
||||
lineItems := make([]adyenCheckout.LineItem, 0, len(grain.CartState.Items)+len(grain.Deliveries))
|
||||
|
||||
// Item lines
|
||||
for _, it := range grain.CartState.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
lineItems = append(lineItems, adyenCheckout.LineItem{
|
||||
Quantity: common.PtrInt64(int64(it.Quantity)),
|
||||
AmountIncludingTax: common.PtrInt64(it.TotalPrice.IncVat),
|
||||
Description: common.PtrString(it.Meta.Name),
|
||||
AmountExcludingTax: common.PtrInt64(it.TotalPrice.ValueExVat()),
|
||||
TaxAmount: common.PtrInt64(it.TotalPrice.TotalVat()),
|
||||
TaxPercentage: common.PtrInt64(int64(it.Tax)),
|
||||
})
|
||||
}
|
||||
total := cart.NewPrice()
|
||||
total.Add(*grain.CartState.TotalPrice)
|
||||
|
||||
// Delivery lines
|
||||
for _, d := range grain.Deliveries {
|
||||
if d == nil || d.Price.IncVat <= 0 {
|
||||
continue
|
||||
}
|
||||
lineItems = append(lineItems, adyenCheckout.LineItem{
|
||||
Quantity: common.PtrInt64(1),
|
||||
AmountIncludingTax: common.PtrInt64(d.Price.IncVat),
|
||||
Description: common.PtrString("Delivery"),
|
||||
AmountExcludingTax: common.PtrInt64(d.Price.ValueExVat()),
|
||||
TaxPercentage: common.PtrInt64(25),
|
||||
})
|
||||
}
|
||||
|
||||
return &adyenCheckout.CreateCheckoutSessionRequest{
|
||||
Reference: grain.Id.String(),
|
||||
Amount: adyenCheckout.Amount{
|
||||
Value: total.IncVat,
|
||||
Currency: currency,
|
||||
},
|
||||
CountryCode: common.PtrString(country),
|
||||
MerchantAccount: "ElgigantenECOM",
|
||||
Channel: common.PtrString("Web"),
|
||||
ShopperIP: common.PtrString(meta.ClientIp),
|
||||
ReturnUrl: fmt.Sprintf("%s/payment/adyen/return", meta.SiteUrl),
|
||||
LineItems: lineItems,
|
||||
}, nil
|
||||
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"git.k6n.net/go-cart-actor/pkg/discovery"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"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)
|
||||
}
|
||||
timeout := int64(30)
|
||||
return discovery.NewK8sDiscovery(client, v1.ListOptions{
|
||||
LabelSelector: "actor-pool=checkout",
|
||||
TimeoutSeconds: &timeout,
|
||||
})
|
||||
}
|
||||
|
||||
func UseDiscovery(pool discovery.DiscoveryTarget) {
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/go-cart-actor/pkg/checkout"
|
||||
messages "git.k6n.net/go-cart-actor/proto/checkout"
|
||||
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
/*
|
||||
*
|
||||
*
|
||||
* s.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
|
||||
})
|
||||
*/
|
||||
|
||||
// func (s *CheckoutPoolServer) KlarnaHtmlCheckoutHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
|
||||
|
||||
// orderId := r.URL.Query().Get("order_id")
|
||||
// var order *CheckoutOrder
|
||||
// var err error
|
||||
// if orderId == "" {
|
||||
// order, err = s.CreateOrUpdateCheckout(r, checkoutId)
|
||||
// if err != nil {
|
||||
// logger.Error("unable to create klarna session", "error", err)
|
||||
// return err
|
||||
// }
|
||||
// // s.ApplyKlarnaPaymentStarted(r.Context(), order, checkoutId)
|
||||
|
||||
// }
|
||||
// order, err = s.klarnaClient.GetOrder(r.Context(), orderId)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
// w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
|
||||
// w.WriteHeader(http.StatusOK)
|
||||
// _, err = fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
||||
// return err
|
||||
|
||||
// }
|
||||
|
||||
// func (s *CheckoutPoolServer) KlarnaSessionHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
|
||||
|
||||
// orderId := r.URL.Query().Get("order_id")
|
||||
// var order *CheckoutOrder
|
||||
// var err error
|
||||
// if orderId == "" {
|
||||
// order, err = s.CreateOrUpdateCheckout(r, checkoutId)
|
||||
// if err != nil {
|
||||
// logger.Error("unable to create klarna session", "error", err)
|
||||
// return err
|
||||
// }
|
||||
// // s.ApplyKlarnaPaymentStarted(r.Context(), order, checkoutId)
|
||||
|
||||
// }
|
||||
// order, err = s.klarnaClient.GetOrder(r.Context(), orderId)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
// return json.NewEncoder(w).Encode(order)
|
||||
|
||||
// }
|
||||
|
||||
func (s *CheckoutPoolServer) KlarnaConfirmationHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
orderId := r.PathValue("order_id")
|
||||
order, err := s.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 {
|
||||
s.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)
|
||||
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) KlarnaValidationHandler(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)
|
||||
return
|
||||
}
|
||||
logger.InfoContext(r.Context(), "Klarna order validation received", "order_id", order.ID, "cart_id", order.MerchantReference1)
|
||||
grain, err := s.getGrainFromKlarnaOrder(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
|
||||
}
|
||||
s.reserveInventory(r.Context(), grain)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) KlarnaNotificationHandler(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)
|
||||
return
|
||||
}
|
||||
log.Printf("Klarna order notification: %s", order.ID)
|
||||
logger.InfoContext(r.Context(), "Klarna order notification received", "order_id", order.ID)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) KlarnaPushHandler(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 := s.klarnaClient.GetOrder(r.Context(), orderId)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error creating request: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
grain, err := s.getGrainFromKlarnaOrder(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 s.inventoryService != nil {
|
||||
inventoryRequests := getInventoryRequests(grain.CartState.Items)
|
||||
err = s.inventoryService.ReserveInventory(r.Context(), inventoryRequests...)
|
||||
|
||||
if err != nil {
|
||||
logger.WarnContext(r.Context(), "placeorder inventory reservation failed")
|
||||
w.WriteHeader(http.StatusNotAcceptable)
|
||||
return
|
||||
}
|
||||
s.Apply(r.Context(), uint64(grain.Id), &messages.InventoryReserved{
|
||||
Id: grain.Id.String(),
|
||||
Status: "success",
|
||||
})
|
||||
}
|
||||
|
||||
s.ApplyAnywhere(r.Context(), grain.Id, &messages.PaymentCompleted{
|
||||
PaymentId: orderId,
|
||||
Status: "completed",
|
||||
ProcessorReference: &order.ID,
|
||||
Amount: int64(order.OrderAmount),
|
||||
Currency: order.PurchaseCurrency,
|
||||
CompletedAt: timestamppb.Now(),
|
||||
})
|
||||
|
||||
// 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 = s.klarnaClient.AcknowledgeOrder(r.Context(), orderId)
|
||||
if err != nil {
|
||||
log.Printf("Error acknowledging order: %v\n", err)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
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 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{
|
||||
InventoryReference: &inventory.InventoryReference{
|
||||
SKU: inventory.SKU(item.Sku),
|
||||
LocationID: getLocationId(item),
|
||||
},
|
||||
Quantity: uint32(item.Quantity),
|
||||
})
|
||||
}
|
||||
return requests
|
||||
}
|
||||
|
||||
func (a *CheckoutPoolServer) getGrainFromKlarnaOrder(ctx context.Context, order *CheckoutOrder) (*checkout.CheckoutGrain, 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.GetAnywhere(ctx, cartId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get cart grain: %w", err)
|
||||
}
|
||||
return grain, nil
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/go-cart-actor/pkg/checkout"
|
||||
"git.k6n.net/go-cart-actor/pkg/proxy"
|
||||
"github.com/adyen/adyen-go-api-library/v21/src/adyen"
|
||||
"github.com/adyen/adyen-go-api-library/v21/src/common"
|
||||
"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/instrumentation/net/http/otelhttp"
|
||||
)
|
||||
|
||||
var (
|
||||
grainSpawns = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "checkout_grain_spawned_total",
|
||||
Help: "The total number of spawned checkout grains",
|
||||
})
|
||||
)
|
||||
|
||||
func init() {
|
||||
os.Mkdir("data", 0755)
|
||||
}
|
||||
|
||||
type App struct {
|
||||
pool *actor.SimpleGrainPool[checkout.CheckoutGrain]
|
||||
server *CheckoutPoolServer
|
||||
klarnaClient *KlarnaClient
|
||||
cartClient *CartClient // For internal communication to cart
|
||||
}
|
||||
|
||||
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")
|
||||
var cartInternalUrl = os.Getenv("CART_INTERNAL_URL") // e.g., http://cart-service:8081
|
||||
|
||||
func main() {
|
||||
|
||||
controlPlaneConfig := actor.DefaultServerConfig()
|
||||
|
||||
reg := checkout.NewCheckoutMutationRegistry(checkout.NewCheckoutMutationContext())
|
||||
reg.RegisterProcessor(
|
||||
actor.NewMutationProcessor(func(ctx context.Context, g *checkout.CheckoutGrain) error {
|
||||
g.Version++
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
diskStorage := actor.NewDiskStorage[checkout.CheckoutGrain]("data", reg)
|
||||
|
||||
poolConfig := actor.GrainPoolConfig[checkout.CheckoutGrain]{
|
||||
MutationRegistry: reg,
|
||||
Storage: diskStorage,
|
||||
Spawn: func(ctx context.Context, id uint64) (actor.Grain[checkout.CheckoutGrain], error) {
|
||||
_, span := tracer.Start(ctx, fmt.Sprintf("Spawn checkout id %d", id))
|
||||
defer span.End()
|
||||
grainSpawns.Inc()
|
||||
|
||||
ret := checkout.NewCheckoutGrain(id, 0, 0, time.Now(), nil) // version to be set later
|
||||
// Load persisted events/state for this checkout if present
|
||||
if err := diskStorage.LoadEvents(ctx, id, ret); err != nil {
|
||||
// Return the grain along with error (e.g., not found) so callers can decide
|
||||
return ret, err
|
||||
}
|
||||
return ret, nil
|
||||
},
|
||||
Destroy: func(grain actor.Grain[checkout.CheckoutGrain]) error {
|
||||
return nil
|
||||
},
|
||||
SpawnHost: func(host string) (actor.Host[checkout.CheckoutGrain], error) {
|
||||
return proxy.NewRemoteHost[checkout.CheckoutGrain](host)
|
||||
},
|
||||
TTL: 1 * time.Hour, // Longer TTL for checkout
|
||||
PoolSize: 65535,
|
||||
Hostname: podIp,
|
||||
}
|
||||
|
||||
pool, err := actor.NewSimpleGrainPool(poolConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating checkout pool: %v\n", err)
|
||||
}
|
||||
|
||||
adyenClient := adyen.NewClient(&common.Config{
|
||||
ApiKey: os.Getenv("ADYEN_API_KEY"),
|
||||
Environment: common.TestEnv,
|
||||
})
|
||||
|
||||
klarnaClient := NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
|
||||
|
||||
cartClient := NewCartClient(cartInternalUrl)
|
||||
|
||||
syncedServer := NewCheckoutPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, cartClient, adyenClient)
|
||||
syncedServer.inventoryService = inventoryService
|
||||
|
||||
mux := http.NewServeMux()
|
||||
debugMux := http.NewServeMux()
|
||||
|
||||
if amqpUrl == "" {
|
||||
log.Fatalf("no connection to amqp defined")
|
||||
}
|
||||
|
||||
grpcSrv, err := actor.NewControlServer[checkout.CheckoutGrain](controlPlaneConfig, pool)
|
||||
if err != nil {
|
||||
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
|
||||
}
|
||||
defer grpcSrv.GracefulStop()
|
||||
|
||||
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)
|
||||
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
grainCount, capacity := 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"))
|
||||
})
|
||||
|
||||
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("Checkout server started at port 8080")
|
||||
|
||||
go http.ListenAndServe(":8081", debugMux)
|
||||
|
||||
select {
|
||||
case err = <-srvErr:
|
||||
log.Fatalf("Unable to start server: %v", err)
|
||||
case <-ctx.Done():
|
||||
stop()
|
||||
}
|
||||
}
|
||||
@@ -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,543 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/go-cart-actor/pkg/checkout"
|
||||
messages "git.k6n.net/go-cart-actor/proto/checkout"
|
||||
|
||||
adyen "github.com/adyen/adyen-go-api-library/v21/src/adyen"
|
||||
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/anypb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"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: "checkout_grain_mutations_total",
|
||||
Help: "The total number of mutations",
|
||||
})
|
||||
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "checkout_grain_lookups_total",
|
||||
Help: "The total number of lookups",
|
||||
})
|
||||
)
|
||||
|
||||
type CheckoutPoolServer struct {
|
||||
actor.GrainPool[checkout.CheckoutGrain]
|
||||
pod_name string
|
||||
klarnaClient *KlarnaClient
|
||||
adyenClient *adyen.APIClient
|
||||
cartClient *CartClient
|
||||
inventoryService *inventory.RedisInventoryService
|
||||
}
|
||||
|
||||
func NewCheckoutPoolServer(pool actor.GrainPool[checkout.CheckoutGrain], pod_name string, klarnaClient *KlarnaClient, cartClient *CartClient, adyenClient *adyen.APIClient) *CheckoutPoolServer {
|
||||
srv := &CheckoutPoolServer{
|
||||
GrainPool: pool,
|
||||
pod_name: pod_name,
|
||||
klarnaClient: klarnaClient,
|
||||
cartClient: cartClient,
|
||||
adyenClient: adyenClient,
|
||||
}
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) ApplyLocal(ctx context.Context, id checkout.CheckoutId, mutation ...proto.Message) (*actor.MutationResult[checkout.CheckoutGrain], error) {
|
||||
return s.Apply(ctx, uint64(id), mutation...)
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) GetCheckoutHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
||||
grain, err := s.Get(r.Context(), uint64(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.WriteResult(w, grain)
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) SetDeliveryHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
||||
var msg messages.SetDelivery
|
||||
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.ApplyLocal(r.Context(), id, &msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.WriteResult(w, result)
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) RemoveDeliveryHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
||||
deliveryId := r.PathValue("id")
|
||||
uintDeliveryId, err := strconv.ParseUint(deliveryId, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg := &messages.RemoveDelivery{
|
||||
Id: uint32(uintDeliveryId),
|
||||
}
|
||||
|
||||
result, err := s.ApplyLocal(r.Context(), id, msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.WriteResult(w, result)
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) SetPickupPointHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
||||
var msg messages.SetPickupPoint
|
||||
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.ApplyLocal(r.Context(), id, &msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.WriteResult(w, result)
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) InitializeCheckoutHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
||||
var msg messages.InitializeCheckout
|
||||
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.ApplyLocal(r.Context(), id, &msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.WriteResult(w, result)
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) InventoryReservedHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
||||
var msg messages.InventoryReserved
|
||||
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.ApplyLocal(r.Context(), id, &msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.WriteResult(w, result)
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) OrderCreatedHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
||||
var msg messages.OrderCreated
|
||||
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.ApplyLocal(r.Context(), id, &msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.WriteResult(w, result)
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) ConfirmationViewedHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
||||
var msg messages.ConfirmationViewed
|
||||
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.ApplyLocal(r.Context(), id, &msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.WriteResult(w, result)
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) ContactDetailsUpdatedHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
||||
var msg messages.ContactDetailsUpdated
|
||||
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.ApplyLocal(r.Context(), id, &msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.WriteResult(w, result)
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) StartCheckoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
cartIdStr := r.PathValue("cartid")
|
||||
if cartIdStr == "" {
|
||||
http.Error(w, "cart id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cartId, ok := cart.ParseCartId(cartIdStr)
|
||||
if !ok {
|
||||
http.Error(w, "invalid cart id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch cart state from cart service
|
||||
cartGrain, err := s.cartClient.getCartGrain(r.Context(), cartId)
|
||||
if err != nil {
|
||||
logger.Error("failed to fetch cart", "error", err, "cartId", cartId)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Serialize cart state to Any
|
||||
cartStateBytes, err := json.Marshal(cartGrain)
|
||||
if err != nil {
|
||||
logger.Error("failed to marshal cart state", "error", err)
|
||||
http.Error(w, "failed to process cart state", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Create checkout with same ID as cart
|
||||
var checkoutId checkout.CheckoutId = cart.MustNewCartId()
|
||||
cookie, err := r.Cookie(checkoutCookieName)
|
||||
if err == nil {
|
||||
parsed, ok := cart.ParseCartId(cookie.Value)
|
||||
if ok {
|
||||
checkoutId = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize checkout with cart state wrapped in Any
|
||||
cartStateAny := &messages.InitializeCheckout{
|
||||
OrderId: "",
|
||||
CartId: uint64(cartId),
|
||||
Version: uint32(cartGrain.Version),
|
||||
CartState: &anypb.Any{
|
||||
TypeUrl: "type.googleapis.com/cart.CartGrain",
|
||||
Value: cartStateBytes,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := s.ApplyLocal(r.Context(), checkoutId, cartStateAny)
|
||||
if err != nil {
|
||||
setCheckoutCookie(w, 0, r.TLS != nil)
|
||||
logger.Error("failed to initialize checkout", "error", err)
|
||||
http.Error(w, "failed to initialize checkout", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Set checkout cookie
|
||||
setCheckoutCookie(w, checkoutId, r.TLS != nil)
|
||||
|
||||
if err := s.WriteResult(w, &result.Result); err != nil {
|
||||
logger.Error("failed to write result", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) 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 *CheckoutPoolServer) CreateOrUpdateCheckout(r *http.Request, grain *checkout.CheckoutGrain, orderId *string) (*CheckoutOrder, error) {
|
||||
|
||||
meta := GetCheckoutMetaFromRequest(r)
|
||||
|
||||
payload, _, err := BuildCheckoutOrderPayload(grain, meta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var payment *checkout.Payment
|
||||
if orderId != nil {
|
||||
payment, _ = grain.FindPayment(*orderId)
|
||||
}
|
||||
|
||||
if payment != nil && payment.PaymentId != "" {
|
||||
return s.klarnaClient.UpdateOrder(r.Context(), payment.PaymentId, bytes.NewReader(payload))
|
||||
} else {
|
||||
return s.klarnaClient.CreateOrder(r.Context(), bytes.NewReader(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) ApplyKlarnaPaymentStarted(ctx context.Context, klarnaOrder *CheckoutOrder, id checkout.CheckoutId) (*actor.MutationResult[checkout.CheckoutGrain], error) {
|
||||
method := "checkout"
|
||||
return s.ApplyLocal(ctx, id, &messages.PaymentStarted{
|
||||
PaymentId: klarnaOrder.ID,
|
||||
Amount: int64(klarnaOrder.OrderAmount),
|
||||
Currency: klarnaOrder.PurchaseCurrency,
|
||||
Provider: "klarna",
|
||||
Method: &method,
|
||||
StartedAt: timestamppb.New(time.Now()),
|
||||
})
|
||||
}
|
||||
|
||||
var (
|
||||
tracer = otel.Tracer(name)
|
||||
hmacKey = os.Getenv("ADYEN_HMAC")
|
||||
meter = otel.Meter(name)
|
||||
logger = otelslog.NewLogger(name)
|
||||
proxyCalls 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)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) GetAnywhere(ctx context.Context, checkoutId cart.CartId) (*checkout.CheckoutGrain, error) {
|
||||
id := uint64(checkoutId)
|
||||
if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost {
|
||||
return host.Get(ctx, id)
|
||||
}
|
||||
return s.Get(ctx, id)
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) ApplyAnywhere(ctx context.Context, checkoutId cart.CartId, msgs ...proto.Message) error {
|
||||
id := uint64(checkoutId)
|
||||
if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost {
|
||||
_, err := host.Apply(ctx, id, msgs...)
|
||||
return err
|
||||
}
|
||||
_, err := s.Apply(ctx, id, msgs...)
|
||||
return err
|
||||
}
|
||||
|
||||
type StartPayment struct {
|
||||
Provider string `json:"provider"`
|
||||
Method string `json:"method,omitempty"`
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) GetPaymentSessionHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
|
||||
paymentId := r.PathValue("id")
|
||||
grain, err := s.Get(r.Context(), uint64(checkoutId))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payment, ok := grain.FindPayment(paymentId)
|
||||
if !ok {
|
||||
http.Error(w, "payment not found", http.StatusNotFound)
|
||||
return nil
|
||||
}
|
||||
switch payment.Provider {
|
||||
case "adyen":
|
||||
payload := &SessionRequest{
|
||||
SessionResult: "",
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
service := s.adyenClient.Checkout()
|
||||
req := service.PaymentsApi.GetResultOfPaymentSessionInput(paymentId).SessionResult(payload.SessionResult)
|
||||
|
||||
res, _, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.Status != nil && *res.Status == "completed" {
|
||||
_, err := s.ApplyLocal(r.Context(), checkoutId, &messages.PaymentCompleted{
|
||||
PaymentId: paymentId,
|
||||
Status: *res.Status,
|
||||
ProcessorReference: res.Id,
|
||||
CompletedAt: timestamppb.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("unable to apply payment started mutation", "error", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return s.WriteResult(w, res)
|
||||
case "klarna":
|
||||
current, err := s.klarnaClient.GetOrder(r.Context(), paymentId)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
}
|
||||
return s.WriteResult(w, current)
|
||||
}
|
||||
|
||||
return errors.New("unsupported payment provider")
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) StartPaymentHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
|
||||
grain, err := s.Get(r.Context(), uint64(checkoutId))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := &StartPayment{}
|
||||
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
switch payload.Provider {
|
||||
case "adyen":
|
||||
meta := GetCheckoutMetaFromRequest(r)
|
||||
sessionData, err := BuildAdyenCheckoutSession(grain, meta)
|
||||
if err != nil {
|
||||
logger.Error("unable to build adyen session", "error", err)
|
||||
return err
|
||||
}
|
||||
service := s.adyenClient.Checkout()
|
||||
req := service.PaymentsApi.SessionsInput().CreateCheckoutSessionRequest(*sessionData)
|
||||
session, _, err := service.PaymentsApi.Sessions(r.Context(), req)
|
||||
if err != nil {
|
||||
logger.Error("unable to create adyen session", "error", err)
|
||||
return err
|
||||
}
|
||||
sessionBytes, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
logger.Error("unable to marshal session", "error", err)
|
||||
return err
|
||||
}
|
||||
// Apply PaymentStarted mutation
|
||||
result, err := s.ApplyLocal(r.Context(), checkoutId, &messages.PaymentStarted{
|
||||
PaymentId: session.Id,
|
||||
Amount: session.Amount.Value,
|
||||
Currency: session.Amount.Currency,
|
||||
Provider: "adyen",
|
||||
SessionData: &anypb.Any{
|
||||
TypeUrl: "type.googleapis.com/google.protobuf.StringValue",
|
||||
Value: sessionBytes,
|
||||
},
|
||||
Method: &payload.Method,
|
||||
StartedAt: timestamppb.New(time.Now()),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("unable to apply payment started mutation", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return s.WriteResult(w, result)
|
||||
|
||||
case "klarna":
|
||||
order, err := s.CreateOrUpdateCheckout(r, grain, nil)
|
||||
if err != nil {
|
||||
logger.Error("unable to create klarna session", "error", err)
|
||||
return err
|
||||
}
|
||||
orderBytes, err := json.Marshal(order)
|
||||
if err != nil {
|
||||
logger.Error("unable to marshal order", "error", err)
|
||||
return err
|
||||
}
|
||||
result, err := s.ApplyLocal(r.Context(), checkoutId, &messages.PaymentStarted{
|
||||
PaymentId: order.ID,
|
||||
Amount: int64(order.OrderAmount),
|
||||
Currency: order.PurchaseCurrency,
|
||||
Provider: "klarna",
|
||||
Method: &payload.Method,
|
||||
SessionData: &anypb.Any{
|
||||
TypeUrl: "type.googleapis.com/google.protobuf.StringValue",
|
||||
Value: orderBytes,
|
||||
},
|
||||
StartedAt: timestamppb.New(time.Now()),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("unable to apply payment started mutation", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return s.WriteResult(w, result)
|
||||
|
||||
default:
|
||||
http.Error(w, "unsupported payment provider", http.StatusBadRequest)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) {
|
||||
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("/payment/adyen/session", CookieCheckoutIdHandler(s.AdyenSessionHandler))
|
||||
handleFunc("/payment/adyen/push", s.AdyenHookHandler)
|
||||
handleFunc("/payment/adyen/return", s.AdyenReturnHandler)
|
||||
//handleFunc("/payment/adyen/cancel", s.AdyenCancelHandler)
|
||||
|
||||
handleFunc("/payment/klarna/validate", s.KlarnaValidationHandler)
|
||||
handleFunc("/payment/klarna/push", s.KlarnaPushHandler)
|
||||
handleFunc("/payment/klarna/notification", s.KlarnaNotificationHandler)
|
||||
|
||||
conn, err := amqp.Dial(amqpUrl)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect to RabbitMQ: %v", err)
|
||||
}
|
||||
|
||||
orderHandler := NewAmqpOrderHandler(conn)
|
||||
orderHandler.DefineQueue()
|
||||
|
||||
handleFunc("POST /api/checkout/start/{cartid}", s.StartCheckoutHandler)
|
||||
handleFunc("GET /api/checkout", CookieCheckoutIdHandler(s.ProxyHandler(s.GetCheckoutHandler)))
|
||||
handleFunc("POST /api/checkout/delivery", CookieCheckoutIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
|
||||
handleFunc("DELETE /api/checkout/delivery/{id}", CookieCheckoutIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
|
||||
handleFunc("POST /api/checkout/pickup-point", CookieCheckoutIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
|
||||
handleFunc("POST /api/checkout/contact-details", CookieCheckoutIdHandler(s.ProxyHandler(s.ContactDetailsUpdatedHandler)))
|
||||
handleFunc("POST /payment", CookieCheckoutIdHandler(s.ProxyHandler(s.StartPaymentHandler)))
|
||||
handleFunc("POST /payment/{id}/session", CookieCheckoutIdHandler(s.ProxyHandler(s.GetPaymentSessionHandler)))
|
||||
// handleFunc("POST /api/checkout/initialize", CookieCheckoutIdHandler(s.ProxyHandler(s.InitializeCheckoutHandler)))
|
||||
// handleFunc("POST /api/checkout/inventory-reserved", CookieCheckoutIdHandler(s.ProxyHandler(s.InventoryReservedHandler)))
|
||||
// handleFunc("POST /api/checkout/order-created", CookieCheckoutIdHandler(s.ProxyHandler(s.OrderCreatedHandler)))
|
||||
// handleFunc("POST /api/checkout/confirmation-viewed", CookieCheckoutIdHandler(s.ProxyHandler(s.ConfirmationViewedHandler)))
|
||||
|
||||
//handleFunc("GET /payment/klarna/session", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaSessionHandler)))
|
||||
//handleFunc("GET /payment/klarna/checkout", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaHtmlCheckoutHandler)))
|
||||
|
||||
handleFunc("GET /payment/klarna/confirmation/{order_id}", s.KlarnaConfirmationHandler)
|
||||
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/go-cart-actor/pkg/checkout"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
)
|
||||
|
||||
func getOriginalHost(r *http.Request) string {
|
||||
proxyHost := r.Header.Get("X-Forwarded-Host")
|
||||
if proxyHost != "" {
|
||||
return proxyHost
|
||||
}
|
||||
return r.Host
|
||||
}
|
||||
|
||||
func getClientIp(r *http.Request) string {
|
||||
ip := r.Header.Get("X-Forwarded-For")
|
||||
if ip == "" {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
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 getCountryFromHost(host string) string {
|
||||
if strings.Contains(strings.ToLower(host), "-no") {
|
||||
return "no"
|
||||
}
|
||||
if strings.Contains(strings.ToLower(host), "-se") {
|
||||
return "se"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *CheckoutPoolServer) reserveInventory(ctx context.Context, grain *checkout.CheckoutGrain) error {
|
||||
if a.inventoryService != nil {
|
||||
inventoryRequests := getInventoryRequests(grain.CartState.Items)
|
||||
_, err := a.inventoryService.ReservationCheck(ctx, inventoryRequests...)
|
||||
if err != nil {
|
||||
logger.WarnContext(ctx, "placeorder inventory check failed")
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const checkoutCookieName = "checkoutid"
|
||||
|
||||
func setCheckoutCookie(w http.ResponseWriter, checkoutId checkout.CheckoutId, tls bool) {
|
||||
if checkoutId == 0 {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: checkoutCookieName,
|
||||
Value: checkoutId.String(),
|
||||
Secure: tls,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
Expires: time.Unix(0, 0),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
} else {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: checkoutCookieName,
|
||||
Value: checkoutId.String(),
|
||||
Secure: tls,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
Expires: time.Now().AddDate(0, 0, 14),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func CookieCheckoutIdHandler(fn func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var id checkout.CheckoutId
|
||||
cookie, err := r.Cookie(checkoutCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
w.WriteHeader(http.StatusNotAcceptable)
|
||||
return
|
||||
} else {
|
||||
parsed, ok := cart.ParseCartId(cookie.Value)
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotAcceptable)
|
||||
return
|
||||
} else {
|
||||
id = parsed
|
||||
}
|
||||
}
|
||||
|
||||
err = fn(w, r, id)
|
||||
if err != nil {
|
||||
log.Printf("Server error, not remote error: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error) func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
|
||||
return func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
|
||||
if ownerHost, ok := s.OwnerHost(uint64(checkoutId)); ok {
|
||||
ctx, span := tracer.Start(r.Context(), "proxy")
|
||||
defer span.End()
|
||||
span.SetAttributes(attribute.String("checkoutid", checkoutId.String()))
|
||||
hostAttr := attribute.String("other host", ownerHost.Name())
|
||||
span.SetAttributes(hostAttr)
|
||||
logger.InfoContext(ctx, "checkout proxyed", "result", ownerHost.Name())
|
||||
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
|
||||
handled, err := ownerHost.Proxy(uint64(checkoutId), w, r, nil)
|
||||
|
||||
grainLookups.Inc()
|
||||
if err == nil && handled {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
_, span := tracer.Start(r.Context(), "own")
|
||||
span.SetAttributes(attribute.String("checkoutid", checkoutId.String()))
|
||||
defer span.End()
|
||||
return fn(w, r, checkoutId)
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"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 {
|
||||
inventoryService *inventory.RedisInventoryService
|
||||
reservationService *inventory.RedisCartReservationService
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
sku := inventory.SKU(r.PathValue("sku"))
|
||||
locationID := inventory.LocationID(r.PathValue("locationId"))
|
||||
|
||||
quantity, err := srv.inventoryService.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)
|
||||
}
|
||||
|
||||
func (srv *Server) getReservationHandler(w http.ResponseWriter, r *http.Request) {
|
||||
sku := inventory.SKU(r.PathValue("sku"))
|
||||
locationID := inventory.LocationID(r.PathValue("locationId"))
|
||||
|
||||
summary, err := srv.reservationService.GetReservationSummary(r.Context(), sku, locationID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(summary)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
r, err := inventory.NewRedisCartReservationService(rdb)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to connect to reservation redis: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
server := &Server{inventoryService: s, reservationService: r}
|
||||
|
||||
// Set up HTTP routes
|
||||
http.HandleFunc("/livez", server.livezHandler)
|
||||
http.HandleFunc("/readyz", server.readyzHandler)
|
||||
http.HandleFunc("/inventory/{sku}/{locationId}", server.getInventoryHandler)
|
||||
http.HandleFunc("/reservations/{sku}/{locationId}", server.getReservationHandler)
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
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.
@@ -1,661 +1,252 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: klarna-api-credentials
|
||||
name: klarna-api-credentials
|
||||
data:
|
||||
username: ZjQzZDY3YjEtNzA2Yy00NTk2LTliNTgtYjg1YjU2NDEwZTUw
|
||||
password: a2xhcm5hX3Rlc3RfYXBpX0trUWhWVE5yYVZsV2FsTnhTRVp3Y1ZSSFF5UkVNRmxyY25Kd1AxSndQMGdzWmpRelpEWTNZakV0TnpBMll5MDBOVGsyTFRsaU5UZ3RZamcxWWpVMk5ERXdaVFV3TERFc2JUUkNjRFpWU1RsTllsSk1aMlEyVEc4MmRVODNZMkozUlRaaFdEZDViV3AwYkhGV1JqTjVNQzlaYXow
|
||||
username: ZjQzZDY3YjEtNzA2Yy00NTk2LTliNTgtYjg1YjU2NDEwZTUw
|
||||
password: a2xhcm5hX3Rlc3RfYXBpX0trUWhWVE5yYVZsV2FsTnhTRVp3Y1ZSSFF5UkVNRmxyY25Kd1AxSndQMGdzWmpRelpEWTNZakV0TnpBMll5MDBOVGsyTFRsaU5UZ3RZamcxWWpVMk5ERXdaVFV3TERFc2JUUkNjRFpWU1RsTllsSk1aMlEyVEc4MmRVODNZMkozUlRaaFdEZDViV3AwYkhGV1JqTjVNQzlaYXow
|
||||
type: Opaque
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app: cart-backoffice
|
||||
arch: amd64
|
||||
name: cart-backoffice-x86
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: cart-backoffice
|
||||
arch: amd64
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: cart-backoffice
|
||||
labels:
|
||||
app: cart-actor
|
||||
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/
|
||||
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: DATA_DIR
|
||||
value: "/data/cart-actor"
|
||||
- name: CHECKOUT_DATA_DIR
|
||||
value: "/data/checkout-actor"
|
||||
- name: TZ
|
||||
value: "Europe/Stockholm"
|
||||
- name: REDIS_ADDRESS
|
||||
value: "10.10.3.18:6379"
|
||||
- name: REDIS_PASSWORD
|
||||
value: "slaskredis"
|
||||
- name: ADYEN_HMAC
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: adyen
|
||||
key: HMAC
|
||||
- name: ADYEN_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: adyen
|
||||
key: API_KEY
|
||||
- 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"
|
||||
name: cart-actor-x86
|
||||
spec:
|
||||
replicas: 0
|
||||
selector:
|
||||
matchLabels:
|
||||
app: cart-actor
|
||||
arch: amd64
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: cart-actor
|
||||
actor-pool: cart
|
||||
arch: amd64
|
||||
spec:
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/arch
|
||||
operator: NotIn
|
||||
values:
|
||||
- arm64
|
||||
volumes:
|
||||
- name: data
|
||||
nfs:
|
||||
path: /i-data/7a8af061/nfs/cart-actor
|
||||
server: 10.10.1.10
|
||||
imagePullSecrets:
|
||||
- name: regcred
|
||||
serviceAccountName: default
|
||||
containers:
|
||||
- image: registry.knatofs.se/go-cart-actor-amd64:latest
|
||||
name: cart-actor-amd64
|
||||
imagePullPolicy: Always
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command: ["sleep", "15"]
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: web
|
||||
- containerPort: 1337
|
||||
name: rpc
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /livez
|
||||
port: web
|
||||
failureThreshold: 1
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: web
|
||||
failureThreshold: 2
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 10
|
||||
volumeMounts:
|
||||
- mountPath: "/data"
|
||||
name: data
|
||||
resources:
|
||||
limits:
|
||||
memory: "768Mi"
|
||||
requests:
|
||||
memory: "70Mi"
|
||||
cpu: "1200m"
|
||||
env:
|
||||
- name: TZ
|
||||
value: "Europe/Stockholm"
|
||||
- name: KLARNA_API_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: klarna-api-credentials
|
||||
key: username
|
||||
- name: KLARNA_API_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: klarna-api-credentials
|
||||
key: password
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.podIP
|
||||
- name: AMQP_URL
|
||||
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
|
||||
# - name: BASE_URL
|
||||
# value: "https://s10n-no.tornberg.me"
|
||||
- name: POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app: cart-actor
|
||||
arch: amd64
|
||||
name: cart-actor-x86
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: cart-actor
|
||||
arch: amd64
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
labels:
|
||||
app: cart-actor
|
||||
actor-pool: cart
|
||||
arch: amd64
|
||||
spec:
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/arch
|
||||
operator: NotIn
|
||||
values:
|
||||
- arm64
|
||||
volumes:
|
||||
- name: data
|
||||
nfs:
|
||||
path: /i-data/7a8af061/nfs/cart-actor
|
||||
server: 10.10.1.10
|
||||
serviceAccountName: default
|
||||
containers:
|
||||
- image: registry.k6n.net/go-cart-actor-amd64:latest
|
||||
name: cart-actor-amd64
|
||||
imagePullPolicy: Always
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command: ["sleep", "15"]
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: web
|
||||
- containerPort: 8081
|
||||
name: debug
|
||||
- containerPort: 1337
|
||||
name: rpc
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /livez
|
||||
port: web
|
||||
failureThreshold: 1
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: web
|
||||
failureThreshold: 2
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 50
|
||||
volumeMounts:
|
||||
- mountPath: "/data"
|
||||
name: data
|
||||
resources:
|
||||
limits:
|
||||
memory: "768Mi"
|
||||
requests:
|
||||
memory: "70Mi"
|
||||
cpu: "1200m"
|
||||
env:
|
||||
- name: DATA_DIR
|
||||
value: "/data/cart-actor"
|
||||
- name: CHECKOUT_DATA_DIR
|
||||
value: "/data/checkout-actor"
|
||||
- name: TZ
|
||||
value: "Europe/Stockholm"
|
||||
- name: KLARNA_API_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: klarna-api-credentials
|
||||
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: ADYEN_HMAC
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: adyen
|
||||
key: HMAC
|
||||
- name: ADYEN_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: adyen
|
||||
key: API_KEY
|
||||
- name: KLARNA_API_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: klarna-api-credentials
|
||||
key: password
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.podIP
|
||||
- name: AMQP_URL
|
||||
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
|
||||
# - name: BASE_URL
|
||||
# value: "https://s10n-no.tornberg.me"
|
||||
- name: POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app: cart-actor
|
||||
arch: arm64
|
||||
name: cart-actor-arm64
|
||||
spec:
|
||||
replicas: 0
|
||||
selector:
|
||||
matchLabels:
|
||||
app: cart-actor
|
||||
arch: arm64
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: cart-actor
|
||||
actor-pool: cart
|
||||
arch: arm64
|
||||
spec:
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/hostname
|
||||
operator: NotIn
|
||||
values:
|
||||
- masterpi
|
||||
- key: kubernetes.io/arch
|
||||
operator: In
|
||||
values:
|
||||
- arm64
|
||||
volumes:
|
||||
- name: data
|
||||
nfs:
|
||||
path: /i-data/7a8af061/nfs/cart-actor
|
||||
server: 10.10.1.10
|
||||
serviceAccountName: default
|
||||
containers:
|
||||
- image: registry.k6n.net/go-cart-actor:latest
|
||||
name: cart-actor-arm64
|
||||
imagePullPolicy: Always
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command: ["sleep", "15"]
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: web
|
||||
- containerPort: 8081
|
||||
name: debug
|
||||
- containerPort: 1337
|
||||
name: rpc
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /livez
|
||||
port: web
|
||||
failureThreshold: 1
|
||||
periodSeconds: 15
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: web
|
||||
failureThreshold: 2
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 15
|
||||
volumeMounts:
|
||||
- mountPath: "/data"
|
||||
name: data
|
||||
resources:
|
||||
limits:
|
||||
memory: "768Mi"
|
||||
requests:
|
||||
memory: "70Mi"
|
||||
cpu: "1200m"
|
||||
env:
|
||||
- name: TZ
|
||||
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: ADYEN_HMAC
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: adyen
|
||||
key: HMAC
|
||||
- name: ADYEN_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: adyen
|
||||
key: API_KEY
|
||||
- name: KLARNA_API_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: klarna-api-credentials
|
||||
key: username
|
||||
- name: KLARNA_API_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: klarna-api-credentials
|
||||
key: password
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.podIP
|
||||
- name: AMQP_URL
|
||||
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
|
||||
# - name: BASE_URL
|
||||
# value: "https://s10n-no.tornberg.me"
|
||||
- name: POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
name: cart-actor-arm64
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: cart-actor
|
||||
arch: arm64
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: cart-actor
|
||||
actor-pool: cart
|
||||
arch: arm64
|
||||
spec:
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: kubernetes.io/hostname
|
||||
operator: NotIn
|
||||
values:
|
||||
- masterpi
|
||||
- key: kubernetes.io/arch
|
||||
operator: In
|
||||
values:
|
||||
- arm64
|
||||
volumes:
|
||||
- name: data
|
||||
nfs:
|
||||
path: /i-data/7a8af061/nfs/cart-actor
|
||||
server: 10.10.1.10
|
||||
imagePullSecrets:
|
||||
- name: regcred
|
||||
serviceAccountName: default
|
||||
containers:
|
||||
- image: registry.knatofs.se/go-cart-actor:latest
|
||||
name: cart-actor-arm64
|
||||
imagePullPolicy: Always
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command: ["sleep", "15"]
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: web
|
||||
- containerPort: 1337
|
||||
name: rpc
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /livez
|
||||
port: web
|
||||
failureThreshold: 1
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: web
|
||||
failureThreshold: 2
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 10
|
||||
volumeMounts:
|
||||
- mountPath: "/data"
|
||||
name: data
|
||||
resources:
|
||||
limits:
|
||||
memory: "768Mi"
|
||||
requests:
|
||||
memory: "70Mi"
|
||||
cpu: "1200m"
|
||||
env:
|
||||
- name: TZ
|
||||
value: "Europe/Stockholm"
|
||||
- name: KLARNA_API_USERNAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: klarna-api-credentials
|
||||
key: username
|
||||
- name: KLARNA_API_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: klarna-api-credentials
|
||||
key: password
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.podIP
|
||||
- name: AMQP_URL
|
||||
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
|
||||
# - name: BASE_URL
|
||||
# value: "https://s10n-no.tornberg.me"
|
||||
- name: POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
---
|
||||
kind: Service
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: cart-actor
|
||||
annotations:
|
||||
prometheus.io/port: "8081"
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/path: "/metrics"
|
||||
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
|
||||
- name: internal
|
||||
port: 8081
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
app: checkout-actor
|
||||
arch: amd64
|
||||
name: checkout-actor-x86
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: checkout-actor
|
||||
arch: amd64
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: checkout-actor
|
||||
actor-pool: checkout
|
||||
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/checkout-actor
|
||||
server: 10.10.1.10
|
||||
serviceAccountName: default
|
||||
containers:
|
||||
- image: registry.k6n.net/go-cart-actor-amd64:latest
|
||||
name: checkout-actor-amd64
|
||||
imagePullPolicy: Always
|
||||
command: ["/go-checkout-actor"]
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command: ["sleep", "15"]
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: web
|
||||
- containerPort: 8081
|
||||
name: debug
|
||||
- containerPort: 1337
|
||||
name: rpc
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /livez
|
||||
port: web
|
||||
failureThreshold: 1
|
||||
periodSeconds: 15
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /readyz
|
||||
port: web
|
||||
failureThreshold: 2
|
||||
initialDelaySeconds: 1
|
||||
periodSeconds: 15
|
||||
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: OTEL_RESOURCE_ATTRIBUTES
|
||||
value: "service.name=checkout,service.version=0.1.2"
|
||||
- name: OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
value: "http://otel-debug-service.monitoring:4317"
|
||||
- name: ADYEN_HMAC
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: adyen
|
||||
key: HMAC
|
||||
- name: ADYEN_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: adyen
|
||||
key: API_KEY
|
||||
- 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: CART_INTERNAL_URL
|
||||
value: "http://cart-actor:8080"
|
||||
- name: POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
---
|
||||
kind: Service
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: checkout-actor
|
||||
annotations:
|
||||
prometheus.io/port: "8081"
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/path: "/metrics"
|
||||
spec:
|
||||
selector:
|
||||
app: checkout-actor
|
||||
ports:
|
||||
- name: web
|
||||
port: 8080
|
||||
---
|
||||
kind: Service
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: cart-backoffice
|
||||
spec:
|
||||
selector:
|
||||
app: cart-backoffice
|
||||
ports:
|
||||
- name: web
|
||||
port: 8080
|
||||
selector:
|
||||
app: cart-actor
|
||||
ports:
|
||||
- name: web
|
||||
port: 8080
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: cart-ingress
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
nginx.ingress.kubernetes.io/affinity: "cookie"
|
||||
nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity"
|
||||
nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
|
||||
nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: 4m
|
||||
name: cart-ingress
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
# nginx.ingress.kubernetes.io/affinity: "cookie"
|
||||
# nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity"
|
||||
# nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
|
||||
# nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: 4m
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- cart.k6n.net
|
||||
secretName: cart-actor-tls-secret
|
||||
rules:
|
||||
- host: cart.k6n.net
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: cart-actor
|
||||
port:
|
||||
number: 8080
|
||||
- path: /api/checkout
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: checkout-actor
|
||||
port:
|
||||
number: 8080
|
||||
- path: /payment
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: checkout-actor
|
||||
port:
|
||||
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"
|
||||
---
|
||||
kind: Service
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: inventory
|
||||
spec:
|
||||
selector:
|
||||
app: cart-inventory
|
||||
ports:
|
||||
- name: web
|
||||
port: 8080
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- cart.tornberg.me
|
||||
secretName: cart-actor-tls-secret
|
||||
rules:
|
||||
- host: cart.tornberg.me
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: cart-actor
|
||||
port:
|
||||
number: 8080
|
||||
|
||||
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,11 +1,82 @@
|
||||
package discovery
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
v1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/watch"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/tools/cache"
|
||||
toolsWatch "k8s.io/client-go/tools/watch"
|
||||
)
|
||||
|
||||
type Discovery interface {
|
||||
Discover() ([]string, error)
|
||||
Watch() (<-chan HostChange, error)
|
||||
}
|
||||
|
||||
type K8sDiscovery struct {
|
||||
ctx context.Context
|
||||
client *kubernetes.Clientset
|
||||
}
|
||||
|
||||
func (k *K8sDiscovery) Discover() ([]string, error) {
|
||||
return k.DiscoverInNamespace("")
|
||||
}
|
||||
func (k *K8sDiscovery) DiscoverInNamespace(namespace string) ([]string, error) {
|
||||
pods, err := k.client.CoreV1().Pods(namespace).List(k.ctx, metav1.ListOptions{
|
||||
LabelSelector: "actor-pool=cart",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hosts := make([]string, 0, len(pods.Items))
|
||||
for _, pod := range pods.Items {
|
||||
hosts = append(hosts, pod.Status.PodIP)
|
||||
}
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
type HostChange struct {
|
||||
Host string
|
||||
Type watch.EventType
|
||||
}
|
||||
|
||||
func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
|
||||
timeout := int64(30)
|
||||
watcherFn := func(options metav1.ListOptions) (watch.Interface, error) {
|
||||
return k.client.CoreV1().Pods("").Watch(k.ctx, metav1.ListOptions{
|
||||
LabelSelector: "actor-pool=cart",
|
||||
TimeoutSeconds: &timeout,
|
||||
})
|
||||
}
|
||||
watcher, err := toolsWatch.NewRetryWatcher("1", &cache.ListWatch{WatchFunc: watcherFn})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan HostChange)
|
||||
go func() {
|
||||
for event := range watcher.ResultChan() {
|
||||
|
||||
pod := event.Object.(*v1.Pod)
|
||||
ch <- HostChange{
|
||||
Host: pod.Status.PodIP,
|
||||
Type: event.Type,
|
||||
}
|
||||
}
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func NewK8sDiscovery(client *kubernetes.Clientset) *K8sDiscovery {
|
||||
return &K8sDiscovery{
|
||||
ctx: context.Background(),
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// MockDiscovery is an in-memory Discovery implementation for tests.
|
||||
// It allows deterministic injection of host additions/removals without
|
||||
// depending on Kubernetes API machinery.
|
||||
@@ -55,12 +126,14 @@ func (m *MockDiscovery) AddHost(host string) {
|
||||
if m.closed {
|
||||
return
|
||||
}
|
||||
if slices.Contains(m.hosts, host) {
|
||||
return
|
||||
for _, h := range m.hosts {
|
||||
if h == host {
|
||||
return
|
||||
}
|
||||
}
|
||||
m.hosts = append(m.hosts, host)
|
||||
if m.started {
|
||||
m.events <- HostChange{Host: host, IsReady: true}
|
||||
m.events <- HostChange{Host: host, Type: watch.Added}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +156,7 @@ func (m *MockDiscovery) RemoveHost(host string) {
|
||||
}
|
||||
m.hosts = append(m.hosts[:idx], m.hosts[idx+1:]...)
|
||||
if m.started {
|
||||
m.events <- HostChange{Host: host, IsReady: false}
|
||||
m.events <- HostChange{Host: host, Type: watch.Deleted}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package discovery
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
)
|
||||
@@ -18,9 +17,7 @@ func TestDiscovery(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Errorf("Error creating client: %v", err)
|
||||
}
|
||||
d := NewK8sDiscovery(client, metav1.ListOptions{
|
||||
LabelSelector: "app",
|
||||
})
|
||||
d := NewK8sDiscovery(client)
|
||||
res, err := d.DiscoverInNamespace("")
|
||||
if err != nil {
|
||||
t.Errorf("Error discovering: %v", err)
|
||||
@@ -39,9 +36,7 @@ func TestWatch(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Errorf("Error creating client: %v", err)
|
||||
}
|
||||
d := NewK8sDiscovery(client, metav1.ListOptions{
|
||||
LabelSelector: "app",
|
||||
})
|
||||
d := NewK8sDiscovery(client)
|
||||
ch, err := d.Watch()
|
||||
if err != nil {
|
||||
t.Errorf("Error watching: %v", err)
|
||||
70
disk-storage.go
Normal file
70
disk-storage.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"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(_ interface{}, _ CartId) error {
|
||||
// No-op: legacy event log persistence removed in oneof refactor.
|
||||
return nil
|
||||
}
|
||||
|
||||
func getCartPath(id string) string {
|
||||
return fmt.Sprintf("data/%s.prot", id)
|
||||
}
|
||||
|
||||
func loadMessages(_ Grain, _ CartId) error {
|
||||
// No-op: legacy replay removed in oneof refactor.
|
||||
return nil
|
||||
}
|
||||
|
||||
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, _ *CartGrain) error {
|
||||
// With the removal of the legacy message log, we only update the timestamp.
|
||||
ts := time.Now().Unix()
|
||||
s.LastSaves[id] = ts
|
||||
s.lastSave = ts
|
||||
return nil
|
||||
}
|
||||
117
go.mod
117
go.mod
@@ -1,118 +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 (
|
||||
github.com/adyen/adyen-go-api-library/v21 v21.1.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/matst80/go-redis-inventory v0.0.0-20251126173508-51b30de2d86e
|
||||
github.com/matst80/slask-finder v0.0.0-20251125182907-9e57f193127a
|
||||
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/rabbitmq/amqp091-go v1.10.0
|
||||
github.com/redis/go-redis/v9 v9.17.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/grpc v1.76.0
|
||||
google.golang.org/protobuf v1.36.10
|
||||
k8s.io/api v0.34.2
|
||||
k8s.io/apimachinery v0.34.2
|
||||
k8s.io/client-go v0.34.2
|
||||
k8s.io/api v0.34.1
|
||||
k8s.io/apimachinery v0.34.1
|
||||
k8s.io/client-go v0.34.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/RoaringBitmap/roaring/v2 v2.14.4 // indirect
|
||||
github.com/RoaringBitmap/roaring/v2 v2.10.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589 // 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/getkin/kin-openapi v0.133.0 // 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.3 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.3 // indirect
|
||||
github.com/go-openapi/swag v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/fileutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/mangling v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/netutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.1 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.2 // indirect
|
||||
github.com/go-openapi/swag v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/cmdutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/fileutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/mangling v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/netutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
|
||||
github.com/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/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/mailru/easyjson v0.9.1 // 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/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/mschoch/smat v0.2.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 // 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/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.4 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/speakeasy-api/jsonpath v0.6.2 // 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/prometheus/common v0.67.1 // indirect
|
||||
github.com/prometheus/procfs v0.17.0 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // 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/v3 v3.0.4 // indirect
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
|
||||
golang.org/x/mod v0.30.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/oauth2 v0.33.0 // indirect
|
||||
golang.org/x/sync v0.18.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/net v0.46.0 // indirect
|
||||
golang.org/x/oauth2 v0.32.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/term v0.36.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.39.0 // 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
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
k8s.io/klog/v2 v2.130.1 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
|
||||
tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
|
||||
|
||||
298
go.sum
298
go.sum
@@ -1,91 +1,62 @@
|
||||
github.com/RoaringBitmap/roaring/v2 v2.14.4 h1:4aKySrrg9G/5oRtJ3TrZLObVqxgQ9f1znCRBwEwjuVw=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.14.4/go.mod h1:oMvV6omPWr+2ifRdeZvVJyaz+aoEUopyv5iH0u/+wbY=
|
||||
github.com/adyen/adyen-go-api-library/v21 v21.1.0 h1:QIKtn99yoBdt2R4PhuMdmY/DTm6Ex5HYd0cB7Sh3y6Y=
|
||||
github.com/adyen/adyen-go-api-library/v21 v21.1.0/go.mod h1:qsAGYetm761eDAz+f2OQoY4qC+tKNhZOHil1b4FO5zE=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.10.0 h1:HbJ8Cs71lfCJyvmSptxeMX2PtvOC8yonlU0GQcy2Ak0=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.10.0/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
|
||||
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/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/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM=
|
||||
github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/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/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/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/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
|
||||
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
|
||||
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
|
||||
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
|
||||
github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU=
|
||||
github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ=
|
||||
github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4=
|
||||
github.com/go-openapi/swag/cmdutils v0.25.4/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.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
|
||||
github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y=
|
||||
github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
|
||||
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
|
||||
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
|
||||
github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48=
|
||||
github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg=
|
||||
github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0=
|
||||
github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
|
||||
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-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
|
||||
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
|
||||
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
|
||||
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
|
||||
github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8=
|
||||
github.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo=
|
||||
github.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY=
|
||||
github.com/go-openapi/swag/cmdutils v0.25.1/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
|
||||
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
|
||||
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
|
||||
github.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU=
|
||||
github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M=
|
||||
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
|
||||
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
|
||||
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
|
||||
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
|
||||
github.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync=
|
||||
github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ=
|
||||
github.com/go-openapi/swag/netutils v0.25.1 h1:2wFLYahe40tDUHfKT1GRC4rfa5T1B4GWZ+msEFA4Fl4=
|
||||
github.com/go-openapi/swag/netutils v0.25.1/go.mod h1:CAkkvqnUJX8NV96tNhEQvKz8SQo2KF0f7LleiJwIeRE=
|
||||
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
|
||||
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
|
||||
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
|
||||
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
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.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
||||
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -95,243 +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/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
||||
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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
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/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
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/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/matst80/go-redis-inventory v0.0.0-20251126173508-51b30de2d86e h1:Z7A73W6jsxFuFKWvB1efQmTjs0s7+x2B7IBM2ukkI6Y=
|
||||
github.com/matst80/go-redis-inventory v0.0.0-20251126173508-51b30de2d86e/go.mod h1:9P52UwIlLWLZvObfO29aKTWUCA9Gm62IuPJ/qv4Xvs0=
|
||||
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/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548 h1:lkxVz5lNPlU78El49cx1c3Rxo/bp7Gbzp2BjSRFgg1U=
|
||||
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548/go.mod h1:k4lo5gFYb3AqrgftlraCnv95xvjB/w8udRqJQJ12mAE=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
|
||||
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nxadm/tail v1.4.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/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
||||
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
|
||||
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
|
||||
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI=
|
||||
github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q=
|
||||
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM=
|
||||
github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
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/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
|
||||
github.com/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/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
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/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 h1:bwnLpizECbPr1RrQ27waeY2SPIPeccCx/xLuoYADZ9s=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0/go.mod h1:3nWlOiiqA9UtUnrcNk82mYasNxD8ehOspL0gOfEo6Y4=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 h1:OMqPldHt79PqWKOMYIAQs3CxAi7RLgPxwfFSwr4ZxtM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0/go.mod h1:1biG4qiqTxKiUCtoWDPpL3fB3KxVwCiGw81j3nKMuHE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 h1:vl9obrcoWVKp/lwl8tRE33853I8Xru9HFbw/skNeLs8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0/go.mod h1:GAXRxmLJcVM3u22IjTg74zWBrRCKq8BnOqUVLodpcpw=
|
||||
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.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.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-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-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
||||
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.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-20190412213103-97732733099d/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.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846 h1:ZdyUkS9po3H7G0tuh955QVyyotWvOD4W0aEapeGeUYk=
|
||||
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-20251124214823-79d6a2a48846 h1:Wgl1rcDNThT+Zn47YyCXOXyX/COgMTIdhJ717F0l4xk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
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/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff h1:A90eA31Wq6HOMIQlLfzFwzqGKBTuaVztYu/g8sn+8Zc=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY=
|
||||
k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw=
|
||||
k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4=
|
||||
k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
|
||||
k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M=
|
||||
k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE=
|
||||
k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM=
|
||||
k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk=
|
||||
k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4=
|
||||
k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
|
||||
k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY=
|
||||
k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8=
|
||||
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ=
|
||||
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
||||
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E=
|
||||
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 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
244
grain-pool.go
Normal file
244
grain-pool.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
// grain-pool.go
|
||||
//
|
||||
// Migration Note:
|
||||
// This file has been migrated to use uint64 cart keys internally (derived
|
||||
// from the new CartID base62 representation). For backward compatibility,
|
||||
// a deprecated legacy map keyed by CartId is maintained so existing code
|
||||
// that directly indexes pool.grains with a CartId continues to compile
|
||||
// until the full refactor across SyncedPool / remoteIndex is completed.
|
||||
//
|
||||
// Authoritative storage: grains (map[uint64]*CartGrain)
|
||||
// Legacy compatibility: grainsLegacy (map[CartId]*CartGrain) - kept in sync.
|
||||
//
|
||||
// Once all external usages are updated to rely on helper accessors,
|
||||
// grainsLegacy can be removed.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
poolGrains = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "cart_grains_in_pool",
|
||||
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",
|
||||
})
|
||||
)
|
||||
|
||||
// GrainPool interface remains legacy-compatible.
|
||||
type GrainPool interface {
|
||||
Apply(id CartId, mutation interface{}) (*CartGrain, error)
|
||||
Get(id CartId) (*CartGrain, error)
|
||||
}
|
||||
|
||||
// Ttl keeps expiry info
|
||||
type Ttl struct {
|
||||
Expires time.Time
|
||||
Grain *CartGrain
|
||||
}
|
||||
|
||||
// GrainLocalPool now stores grains keyed by uint64 (CartKey).
|
||||
type GrainLocalPool struct {
|
||||
mu sync.RWMutex
|
||||
grains map[uint64]*CartGrain // authoritative only
|
||||
expiry []Ttl
|
||||
spawn func(id CartId) (*CartGrain, error)
|
||||
Ttl time.Duration
|
||||
PoolSize int
|
||||
}
|
||||
|
||||
// NewGrainLocalPool constructs a new pool.
|
||||
func NewGrainLocalPool(size int, ttl time.Duration, spawn func(id CartId) (*CartGrain, error)) *GrainLocalPool {
|
||||
ret := &GrainLocalPool{
|
||||
spawn: spawn,
|
||||
grains: make(map[uint64]*CartGrain),
|
||||
expiry: make([]Ttl, 0),
|
||||
Ttl: ttl,
|
||||
PoolSize: size,
|
||||
}
|
||||
cartPurge := time.NewTicker(time.Minute)
|
||||
go func() {
|
||||
for range cartPurge.C {
|
||||
ret.Purge()
|
||||
}
|
||||
}()
|
||||
return ret
|
||||
}
|
||||
|
||||
// keyFromCartId derives the uint64 key from a legacy CartId deterministically.
|
||||
func keyFromCartId(id CartId) uint64 {
|
||||
return LegacyToCartKey(id)
|
||||
}
|
||||
|
||||
// storeGrain indexes a grain in both maps.
|
||||
func (p *GrainLocalPool) storeGrain(id CartId, g *CartGrain) {
|
||||
k := keyFromCartId(id)
|
||||
p.grains[k] = g
|
||||
}
|
||||
|
||||
// deleteGrain removes a grain from both maps.
|
||||
func (p *GrainLocalPool) deleteGrain(id CartId) {
|
||||
k := keyFromCartId(id)
|
||||
delete(p.grains, k)
|
||||
}
|
||||
|
||||
// SetAvailable pre-populates placeholder entries (legacy signature).
|
||||
func (p *GrainLocalPool) SetAvailable(availableWithLastChangeUnix map[CartId]int64) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for id := range availableWithLastChangeUnix {
|
||||
k := keyFromCartId(id)
|
||||
if _, ok := p.grains[k]; !ok {
|
||||
p.grains[k] = nil
|
||||
p.expiry = append(p.expiry, Ttl{
|
||||
Expires: time.Now().Add(p.Ttl),
|
||||
Grain: nil,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Purge removes expired grains.
|
||||
func (p *GrainLocalPool) Purge() {
|
||||
lastChangeTime := time.Now().Add(-p.Ttl)
|
||||
keepChanged := lastChangeTime.Unix()
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
for i := 0; i < len(p.expiry); i++ {
|
||||
item := p.expiry[i]
|
||||
if item.Grain == nil {
|
||||
continue
|
||||
}
|
||||
if item.Expires.Before(time.Now()) {
|
||||
if item.Grain.GetLastChange() > keepChanged {
|
||||
log.Printf("Expired item %s changed, keeping", item.Grain.GetId())
|
||||
if i < len(p.expiry)-1 {
|
||||
p.expiry = append(p.expiry[:i], p.expiry[i+1:]...)
|
||||
p.expiry = append(p.expiry, item)
|
||||
} else {
|
||||
// move last to end (noop)
|
||||
p.expiry = append(p.expiry[:i], item)
|
||||
}
|
||||
} else {
|
||||
log.Printf("Item %s expired", item.Grain.GetId())
|
||||
p.deleteGrain(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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetGrains returns a legacy view of grains (copy) for compatibility.
|
||||
func (p *GrainLocalPool) GetGrains() map[CartId]*CartGrain {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
out := make(map[CartId]*CartGrain, len(p.grains))
|
||||
for _, g := range p.grains {
|
||||
if g != nil {
|
||||
out[g.GetId()] = g
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// statsUpdate updates Prometheus gauges asynchronously.
|
||||
func (p *GrainLocalPool) statsUpdate() {
|
||||
go func(size int) {
|
||||
l := float64(size)
|
||||
ps := float64(p.PoolSize)
|
||||
poolUsage.Set(l / ps)
|
||||
poolGrains.Set(l)
|
||||
poolSize.Set(ps)
|
||||
}(len(p.grains))
|
||||
}
|
||||
|
||||
// GetGrain retrieves or spawns a grain (legacy id signature).
|
||||
func (p *GrainLocalPool) GetGrain(id CartId) (*CartGrain, error) {
|
||||
grainLookups.Inc()
|
||||
k := keyFromCartId(id)
|
||||
|
||||
p.mu.RLock()
|
||||
grain, ok := p.grains[k]
|
||||
p.mu.RUnlock()
|
||||
|
||||
var err error
|
||||
if grain == nil || !ok {
|
||||
p.mu.Lock()
|
||||
// Re-check under write lock
|
||||
grain, ok = p.grains[k]
|
||||
if grain == nil || !ok {
|
||||
// Capacity check
|
||||
if len(p.grains) >= p.PoolSize && len(p.expiry) > 0 {
|
||||
if p.expiry[0].Expires.Before(time.Now()) && p.expiry[0].Grain != nil {
|
||||
oldId := p.expiry[0].Grain.GetId()
|
||||
p.deleteGrain(oldId)
|
||||
p.expiry = p.expiry[1:]
|
||||
} else {
|
||||
p.mu.Unlock()
|
||||
return nil, fmt.Errorf("pool is full")
|
||||
}
|
||||
}
|
||||
grain, err = p.spawn(id)
|
||||
if err == nil {
|
||||
p.storeGrain(id, grain)
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
p.statsUpdate()
|
||||
}
|
||||
|
||||
return grain, err
|
||||
}
|
||||
|
||||
// Apply applies a mutation (legacy compatibility).
|
||||
func (p *GrainLocalPool) Apply(id CartId, mutation interface{}) (*CartGrain, error) {
|
||||
grain, err := p.GetGrain(id)
|
||||
if err != nil || grain == nil {
|
||||
return nil, err
|
||||
}
|
||||
return grain.Apply(mutation, false)
|
||||
}
|
||||
|
||||
// Get returns current state (legacy wrapper).
|
||||
func (p *GrainLocalPool) Get(id CartId) (*CartGrain, error) {
|
||||
return p.GetGrain(id)
|
||||
}
|
||||
|
||||
// DebugGrainCount returns counts for debugging.
|
||||
func (p *GrainLocalPool) DebugGrainCount() (authoritative int) {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return len(p.grains)
|
||||
}
|
||||
|
||||
// UnsafePointerToLegacyMap exposes the legacy map pointer (for transitional
|
||||
// tests that still poke the field directly). DO NOT rely on this long-term.
|
||||
func (p *GrainLocalPool) UnsafePointerToLegacyMap() uintptr {
|
||||
// Legacy map removed; retained only to satisfy any transitional callers.
|
||||
return 0
|
||||
}
|
||||
115
grpc_integration_test.go
Normal file
115
grpc_integration_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 the new per-mutation AddItem RPC (breaking v2 API) to avoid external product fetch logic
|
||||
// fetching logic (FetchItem) which would require network I/O.
|
||||
func TestCartActorMutationAndState(t *testing.T) {
|
||||
// Setup local grain pool + synced pool (no discovery, single host)
|
||||
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",
|
||||
}
|
||||
|
||||
// Issue AddItem RPC directly (breaking v2 API)
|
||||
addResp, err := cartClient.AddItem(context.Background(), &messages.AddItemRequest{
|
||||
CartId: cartID,
|
||||
ClientTimestamp: time.Now().Unix(),
|
||||
Payload: addItem,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddItem RPC error: %v", err)
|
||||
}
|
||||
if addResp.StatusCode != 200 {
|
||||
t.Fatalf("AddItem returned non-200 status: %d, error: %s", addResp.StatusCode, addResp.GetError())
|
||||
}
|
||||
|
||||
// Validate the response state (from AddItem)
|
||||
state := addResp.GetState()
|
||||
if state == nil {
|
||||
t.Fatalf("AddItem response state is nil")
|
||||
}
|
||||
|
||||
// (Removed obsolete Mutate response handling)
|
||||
|
||||
if len(state.Items) != 1 {
|
||||
t.Fatalf("Expected 1 item after AddItem, 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, error: %s", getResp.StatusCode, getResp.GetError())
|
||||
}
|
||||
|
||||
state2 := getResp.GetState()
|
||||
if state2 == nil {
|
||||
t.Fatalf("GetState response state is nil")
|
||||
}
|
||||
|
||||
if len(state2.Items) != 1 {
|
||||
t.Fatalf("Expected 1 item in GetState, got %d", len(state2.Items))
|
||||
}
|
||||
if state2.Items[0].Sku != "test-sku" {
|
||||
t.Fatalf("Unexpected SKU in GetState: %s", state2.Items[0].Sku)
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy serialization helper removed (oneof envelope used directly)
|
||||
280
grpc_server.go
Normal file
280
grpc_server.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/reflection"
|
||||
)
|
||||
|
||||
// cartActorGRPCServer implements the CartActor and ControlPlane gRPC services.
|
||||
// It delegates cart operations to a grain pool and cluster operations to a synced pool.
|
||||
type cartActorGRPCServer struct {
|
||||
messages.UnimplementedCartActorServer
|
||||
messages.UnimplementedControlPlaneServer
|
||||
|
||||
pool GrainPool // For cart state mutations and queries
|
||||
syncedPool *SyncedPool // For cluster membership and control
|
||||
}
|
||||
|
||||
// NewCartActorGRPCServer creates and initializes the server.
|
||||
func NewCartActorGRPCServer(pool GrainPool, syncedPool *SyncedPool) *cartActorGRPCServer {
|
||||
return &cartActorGRPCServer{
|
||||
pool: pool,
|
||||
syncedPool: syncedPool,
|
||||
}
|
||||
}
|
||||
|
||||
// applyMutation routes a single cart mutation to the target grain (used by per-mutation RPC handlers).
|
||||
func (s *cartActorGRPCServer) applyMutation(cartID string, mutation interface{}) *messages.CartMutationReply {
|
||||
// Canonicalize or preserve legacy id (do NOT hash-rewrite legacy textual ids)
|
||||
cid, _, wasBase62, cerr := CanonicalizeOrLegacy(cartID)
|
||||
if cerr != nil {
|
||||
return &messages.CartMutationReply{
|
||||
StatusCode: 500,
|
||||
Result: &messages.CartMutationReply_Error{Error: fmt.Sprintf("cart_id canonicalization failed: %v", cerr)},
|
||||
ServerTimestamp: time.Now().Unix(),
|
||||
}
|
||||
}
|
||||
_ = wasBase62 // placeholder; future: propagate canonical id in reply metadata
|
||||
legacy := CartIDToLegacy(cid)
|
||||
grain, err := s.pool.Apply(legacy, mutation)
|
||||
if err != nil {
|
||||
return &messages.CartMutationReply{
|
||||
StatusCode: 500,
|
||||
Result: &messages.CartMutationReply_Error{Error: err.Error()},
|
||||
ServerTimestamp: time.Now().Unix(),
|
||||
}
|
||||
}
|
||||
cartState := ToCartState(grain)
|
||||
return &messages.CartMutationReply{
|
||||
StatusCode: 200,
|
||||
Result: &messages.CartMutationReply_State{State: cartState},
|
||||
ServerTimestamp: time.Now().Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *cartActorGRPCServer) AddRequest(ctx context.Context, req *messages.AddRequestRequest) (*messages.CartMutationReply, error) {
|
||||
if req.GetCartId() == "" {
|
||||
return &messages.CartMutationReply{
|
||||
StatusCode: 400,
|
||||
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||
ServerTimestamp: time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||
}
|
||||
|
||||
func (s *cartActorGRPCServer) AddItem(ctx context.Context, req *messages.AddItemRequest) (*messages.CartMutationReply, error) {
|
||||
if req.GetCartId() == "" {
|
||||
return &messages.CartMutationReply{
|
||||
StatusCode: 400,
|
||||
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||
ServerTimestamp: time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||
}
|
||||
|
||||
func (s *cartActorGRPCServer) RemoveItem(ctx context.Context, req *messages.RemoveItemRequest) (*messages.CartMutationReply, error) {
|
||||
if req.GetCartId() == "" {
|
||||
return &messages.CartMutationReply{
|
||||
StatusCode: 400,
|
||||
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||
ServerTimestamp: time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||
}
|
||||
|
||||
func (s *cartActorGRPCServer) RemoveDelivery(ctx context.Context, req *messages.RemoveDeliveryRequest) (*messages.CartMutationReply, error) {
|
||||
if req.GetCartId() == "" {
|
||||
return &messages.CartMutationReply{
|
||||
StatusCode: 400,
|
||||
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||
ServerTimestamp: time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||
}
|
||||
|
||||
func (s *cartActorGRPCServer) ChangeQuantity(ctx context.Context, req *messages.ChangeQuantityRequest) (*messages.CartMutationReply, error) {
|
||||
if req.GetCartId() == "" {
|
||||
return &messages.CartMutationReply{
|
||||
StatusCode: 400,
|
||||
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||
ServerTimestamp: time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||
}
|
||||
|
||||
func (s *cartActorGRPCServer) SetDelivery(ctx context.Context, req *messages.SetDeliveryRequest) (*messages.CartMutationReply, error) {
|
||||
if req.GetCartId() == "" {
|
||||
return &messages.CartMutationReply{
|
||||
StatusCode: 400,
|
||||
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||
ServerTimestamp: time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||
}
|
||||
|
||||
func (s *cartActorGRPCServer) SetPickupPoint(ctx context.Context, req *messages.SetPickupPointRequest) (*messages.CartMutationReply, error) {
|
||||
if req.GetCartId() == "" {
|
||||
return &messages.CartMutationReply{
|
||||
StatusCode: 400,
|
||||
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||
ServerTimestamp: time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||
}
|
||||
|
||||
/*
|
||||
Checkout RPC removed. Checkout is handled at the HTTP layer (PoolServer.HandleCheckout).
|
||||
*/
|
||||
|
||||
func (s *cartActorGRPCServer) SetCartItems(ctx context.Context, req *messages.SetCartItemsRequest) (*messages.CartMutationReply, error) {
|
||||
if req.GetCartId() == "" {
|
||||
return &messages.CartMutationReply{
|
||||
StatusCode: 400,
|
||||
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||
ServerTimestamp: time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||
}
|
||||
|
||||
func (s *cartActorGRPCServer) OrderCompleted(ctx context.Context, req *messages.OrderCompletedRequest) (*messages.CartMutationReply, error) {
|
||||
if req.GetCartId() == "" {
|
||||
return &messages.CartMutationReply{
|
||||
StatusCode: 400,
|
||||
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||
ServerTimestamp: time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||
}
|
||||
|
||||
// GetState retrieves the current state of a cart grain.
|
||||
func (s *cartActorGRPCServer) GetState(ctx context.Context, req *messages.StateRequest) (*messages.StateReply, error) {
|
||||
if req.GetCartId() == "" {
|
||||
return &messages.StateReply{
|
||||
StatusCode: 400,
|
||||
Result: &messages.StateReply_Error{Error: "cart_id is required"},
|
||||
}, nil
|
||||
}
|
||||
// Canonicalize / upgrade incoming cart id (preserve legacy strings)
|
||||
cid, _, _, cerr := CanonicalizeOrLegacy(req.GetCartId())
|
||||
if cerr != nil {
|
||||
return &messages.StateReply{
|
||||
StatusCode: 500,
|
||||
Result: &messages.StateReply_Error{Error: fmt.Sprintf("cart_id canonicalization failed: %v", cerr)},
|
||||
}, nil
|
||||
}
|
||||
legacy := CartIDToLegacy(cid)
|
||||
|
||||
grain, err := s.pool.Get(legacy)
|
||||
if err != nil {
|
||||
return &messages.StateReply{
|
||||
StatusCode: 500,
|
||||
Result: &messages.StateReply_Error{Error: err.Error()},
|
||||
}, nil
|
||||
}
|
||||
|
||||
cartState := ToCartState(grain)
|
||||
|
||||
return &messages.StateReply{
|
||||
StatusCode: 200,
|
||||
Result: &messages.StateReply_State{State: cartState},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ControlPlane: Ping
|
||||
func (s *cartActorGRPCServer) Ping(ctx context.Context, _ *messages.Empty) (*messages.PingReply, error) {
|
||||
return &messages.PingReply{
|
||||
Host: s.syncedPool.Hostname,
|
||||
UnixTime: time.Now().Unix(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ControlPlane: Negotiate (merge host views)
|
||||
func (s *cartActorGRPCServer) Negotiate(ctx context.Context, req *messages.NegotiateRequest) (*messages.NegotiateReply, error) {
|
||||
hostSet := make(map[string]struct{})
|
||||
// Caller view
|
||||
for _, h := range req.GetKnownHosts() {
|
||||
if h != "" {
|
||||
hostSet[h] = struct{}{}
|
||||
}
|
||||
}
|
||||
// This host
|
||||
hostSet[s.syncedPool.Hostname] = struct{}{}
|
||||
// Known remotes
|
||||
s.syncedPool.mu.RLock()
|
||||
for h := range s.syncedPool.remoteHosts {
|
||||
hostSet[h] = struct{}{}
|
||||
}
|
||||
s.syncedPool.mu.RUnlock()
|
||||
|
||||
out := make([]string, 0, len(hostSet))
|
||||
for h := range hostSet {
|
||||
out = append(out, h)
|
||||
}
|
||||
return &messages.NegotiateReply{Hosts: out}, nil
|
||||
}
|
||||
|
||||
// ControlPlane: GetCartIds (locally owned carts only)
|
||||
func (s *cartActorGRPCServer) GetCartIds(ctx context.Context, _ *messages.Empty) (*messages.CartIdsReply, error) {
|
||||
s.syncedPool.local.mu.RLock()
|
||||
ids := make([]string, 0, len(s.syncedPool.local.grains))
|
||||
for _, g := range s.syncedPool.local.grains {
|
||||
if g == nil {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, g.GetId().String())
|
||||
}
|
||||
s.syncedPool.local.mu.RUnlock()
|
||||
return &messages.CartIdsReply{CartIds: ids}, nil
|
||||
}
|
||||
|
||||
// ControlPlane: Closing (peer shutdown notification)
|
||||
func (s *cartActorGRPCServer) Closing(ctx context.Context, req *messages.ClosingNotice) (*messages.OwnerChangeAck, error) {
|
||||
if req.GetHost() != "" {
|
||||
s.syncedPool.RemoveHost(req.GetHost())
|
||||
}
|
||||
return &messages.OwnerChangeAck{
|
||||
Accepted: true,
|
||||
Message: "removed host",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StartGRPCServer configures and starts the unified gRPC server on the given address.
|
||||
// It registers both the CartActor and ControlPlane services.
|
||||
func StartGRPCServer(addr string, pool GrainPool, syncedPool *SyncedPool) (*grpc.Server, error) {
|
||||
lis, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to listen: %w", err)
|
||||
}
|
||||
|
||||
grpcServer := grpc.NewServer()
|
||||
server := NewCartActorGRPCServer(pool, syncedPool)
|
||||
|
||||
messages.RegisterCartActorServer(grpcServer, server)
|
||||
messages.RegisterControlPlaneServer(grpcServer, server)
|
||||
reflection.Register(grpcServer)
|
||||
|
||||
log.Printf("gRPC server listening on %s", addr)
|
||||
go func() {
|
||||
if err := grpcServer.Serve(lis); err != nil {
|
||||
log.Fatalf("failed to serve gRPC: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return grpcServer, nil
|
||||
}
|
||||
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 /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.k6n.net/cart \
|
||||
-e VUS=40 \
|
||||
-e DURATION=1m \
|
||||
-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,244 +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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -31,14 +30,11 @@ const (
|
||||
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)
|
||||
if err != nil {
|
||||
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.SetBasicAuth(k.UserName, k.Password)
|
||||
|
||||
@@ -68,19 +64,17 @@ func (k *KlarnaClient) getOrderResponse(res *http.Response) (*CheckoutOrder, err
|
||||
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)
|
||||
req, err := http.NewRequest("POST", k.Url+"/checkout/v3/orders", reader)
|
||||
if err != nil {
|
||||
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.SetBasicAuth(k.UserName, k.Password)
|
||||
|
||||
res, err := k.client.Do(req)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if nil != err {
|
||||
return nil, err
|
||||
}
|
||||
@@ -88,20 +82,17 @@ func (k *KlarnaClient) CreateOrder(ctx context.Context, reader io.Reader) (*Chec
|
||||
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)
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s", k.Url, orderId), reader)
|
||||
if err != nil {
|
||||
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.SetBasicAuth(k.UserName, k.Password)
|
||||
|
||||
res, err := k.client.Do(req)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if nil != err {
|
||||
return nil, err
|
||||
}
|
||||
@@ -109,34 +100,29 @@ func (k *KlarnaClient) UpdateOrder(ctx context.Context, orderId string, reader i
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spanCtx, span := tracer.Start(ctx, "Abort klarna order")
|
||||
defer span.End()
|
||||
req = req.WithContext(spanCtx)
|
||||
|
||||
req.SetBasicAuth(k.UserName, k.Password)
|
||||
|
||||
_, err = k.client.Do(req)
|
||||
_, err = http.DefaultClient.Do(req)
|
||||
return err
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spanCtx, span := tracer.Start(ctx, "Acknowledge klarna order")
|
||||
defer span.End()
|
||||
req = req.WithContext(spanCtx)
|
||||
id := uuid.New()
|
||||
|
||||
req.SetBasicAuth(k.UserName, k.Password)
|
||||
req.Header.Add("Klarna-Idempotency-Key", id.String())
|
||||
|
||||
_, err = k.client.Do(req)
|
||||
_, err = http.DefaultClient.Do(req)
|
||||
return err
|
||||
}
|
||||
410
main.go
Normal file
410
main.go
Normal file
@@ -0,0 +1,410 @@
|
||||
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 removed (legacy event log deprecated)
|
||||
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"))
|
||||
|
||||
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 GetDiscovery() Discovery {
|
||||
if podIp == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
config, kerr := rest.InClusterConfig()
|
||||
|
||||
if kerr != nil {
|
||||
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
|
||||
}
|
||||
client, err := kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating client: %v\n", err)
|
||||
}
|
||||
return NewK8sDiscovery(client)
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
storage, err := NewDiskStorage(fmt.Sprintf("data/%s_state.gob", name))
|
||||
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)
|
||||
order, err = syncedServer.CreateOrUpdateCheckout(r.Host, cartId)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
}
|
||||
// v2: Apply now returns *CartGrain; order creation handled inside grain (no payload to unmarshal)
|
||||
} else {
|
||||
order, err = KlarnaInstance.GetOrder(orderId)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
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.Apply(ToCartId(order.MerchantReference1), &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
|
||||
}
|
||||
182
multi_node_ownership_test.go
Normal file
182
multi_node_ownership_test.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// TestMultiNodeOwnershipNegotiation spins up two gRPC servers (nodeA, nodeB),
|
||||
// manually links their SyncedPools (bypassing AddRemote's fixed port assumption),
|
||||
// and verifies that only one node becomes the owner of a new cart while the
|
||||
// other can still apply a mutation via the remote proxy path.
|
||||
//
|
||||
// NOTE:
|
||||
// - We manually inject RemoteHostGRPC entries because AddRemote() hard-codes
|
||||
// port 1337; to run two distinct servers concurrently we need distinct ports.
|
||||
// - This test asserts single ownership consistency rather than the complete
|
||||
// quorum semantics (which depend on real discovery + AddRemote).
|
||||
func TestMultiNodeOwnershipNegotiation(t *testing.T) {
|
||||
// Allocate distinct ports for the two nodes.
|
||||
const (
|
||||
addrA = "127.0.0.1:18081"
|
||||
addrB = "127.0.0.1:18082"
|
||||
hostA = "nodeA"
|
||||
hostB = "nodeB"
|
||||
)
|
||||
|
||||
// Create local grain pools.
|
||||
poolA := NewGrainLocalPool(1024, time.Minute, spawn)
|
||||
poolB := NewGrainLocalPool(1024, time.Minute, spawn)
|
||||
|
||||
// Create synced pools (no discovery).
|
||||
syncedA, err := NewSyncedPool(poolA, hostA, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("nodeA NewSyncedPool error: %v", err)
|
||||
}
|
||||
syncedB, err := NewSyncedPool(poolB, hostB, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("nodeB NewSyncedPool error: %v", err)
|
||||
}
|
||||
|
||||
// Start gRPC servers (CartActor + ControlPlane) on different ports.
|
||||
grpcSrvA, err := StartGRPCServer(addrA, poolA, syncedA)
|
||||
if err != nil {
|
||||
t.Fatalf("StartGRPCServer A error: %v", err)
|
||||
}
|
||||
defer grpcSrvA.GracefulStop()
|
||||
|
||||
grpcSrvB, err := StartGRPCServer(addrB, poolB, syncedB)
|
||||
if err != nil {
|
||||
t.Fatalf("StartGRPCServer B error: %v", err)
|
||||
}
|
||||
defer grpcSrvB.GracefulStop()
|
||||
|
||||
// Helper to connect one pool to the other's server (manual AddRemote equivalent).
|
||||
link := func(src *SyncedPool, remoteHost, remoteAddr string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
conn, dialErr := grpc.DialContext(ctx, remoteAddr, grpc.WithInsecure(), grpc.WithBlock())
|
||||
if dialErr != nil {
|
||||
t.Fatalf("dial %s (%s) failed: %v", remoteHost, remoteAddr, dialErr)
|
||||
}
|
||||
cartClient := messages.NewCartActorClient(conn)
|
||||
controlClient := messages.NewControlPlaneClient(conn)
|
||||
|
||||
src.mu.Lock()
|
||||
src.remoteHosts[remoteHost] = &RemoteHostGRPC{
|
||||
Host: remoteHost,
|
||||
Conn: conn,
|
||||
CartClient: cartClient,
|
||||
ControlClient: controlClient,
|
||||
}
|
||||
src.mu.Unlock()
|
||||
}
|
||||
|
||||
// Cross-link the two pools.
|
||||
link(syncedA, hostB, addrB)
|
||||
link(syncedB, hostA, addrA)
|
||||
|
||||
// Rebuild rings after manual cross-link so deterministic ownership works immediately.
|
||||
syncedA.ForceRingRefresh()
|
||||
syncedB.ForceRingRefresh()
|
||||
|
||||
// Allow brief stabilization (control plane pings / no real negotiation needed here).
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Create a deterministic cart id for test readability.
|
||||
cartID := ToCartId(fmt.Sprintf("cart-%d", time.Now().UnixNano()))
|
||||
|
||||
// Mutation payload (ring-determined ownership; no assumption about which node owns).
|
||||
addItem := &messages.AddItem{
|
||||
ItemId: 1,
|
||||
Quantity: 1,
|
||||
Price: 1500,
|
||||
OrgPrice: 1500,
|
||||
Sku: "sku-test-multi",
|
||||
Name: "Multi Node Test",
|
||||
Image: "/test.png",
|
||||
Stock: 2,
|
||||
Tax: 2500,
|
||||
Country: "se",
|
||||
}
|
||||
|
||||
// Determine ring owner and set primary / secondary references.
|
||||
ownerHost := syncedA.DebugOwnerHost(cartID)
|
||||
var ownerSynced, otherSynced *SyncedPool
|
||||
var ownerPool, otherPool *GrainLocalPool
|
||||
switch ownerHost {
|
||||
case hostA:
|
||||
ownerSynced, ownerPool = syncedA, poolA
|
||||
otherSynced, otherPool = syncedB, poolB
|
||||
case hostB:
|
||||
ownerSynced, ownerPool = syncedB, poolB
|
||||
otherSynced, otherPool = syncedA, poolA
|
||||
default:
|
||||
t.Fatalf("unexpected ring owner %s (expected %s or %s)", ownerHost, hostA, hostB)
|
||||
}
|
||||
|
||||
// Apply mutation on the ring-designated owner.
|
||||
if _, err := ownerSynced.Apply(cartID, addItem); err != nil {
|
||||
t.Fatalf("owner %s Apply addItem error: %v", ownerHost, err)
|
||||
}
|
||||
|
||||
// Validate owner pool has the grain and the other does not.
|
||||
if _, ok := ownerPool.GetGrains()[cartID]; !ok {
|
||||
t.Fatalf("expected owner %s to have local grain", ownerHost)
|
||||
}
|
||||
if _, ok := otherPool.GetGrains()[cartID]; ok {
|
||||
t.Fatalf("non-owner unexpectedly holds local grain")
|
||||
}
|
||||
|
||||
// Prepare change mutation to be applied from the non-owner (should route remotely).
|
||||
change := &messages.ChangeQuantity{
|
||||
Id: 1, // line id after first AddItem
|
||||
Quantity: 2,
|
||||
}
|
||||
// Apply remotely via the non-owner.
|
||||
if _, err := otherSynced.Apply(cartID, change); err != nil {
|
||||
t.Fatalf("non-owner remote Apply changeQuantity error: %v", err)
|
||||
}
|
||||
|
||||
// Remote re-mutation already performed via otherSynced; removed duplicate block.
|
||||
|
||||
// NodeB local grain assertion:
|
||||
// Only assert absence if nodeB is NOT the ring-designated owner. If nodeB is the owner,
|
||||
// it is expected to have a local grain (previous generic ownership assertions already ran).
|
||||
if ownerHost != hostB {
|
||||
if _, local := poolB.GetGrains()[cartID]; local {
|
||||
t.Fatalf("nodeB unexpectedly created local grain (ownership duplication)")
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch state from nodeB to ensure we see updated quantity (2).
|
||||
grainStateB, err := syncedB.Get(cartID)
|
||||
if err != nil {
|
||||
t.Fatalf("nodeB Get error: %v", err)
|
||||
}
|
||||
if len(grainStateB.Items) != 1 || grainStateB.Items[0].Quantity != 2 {
|
||||
t.Fatalf("nodeB observed inconsistent state: items=%d qty=%d (expected 1 / 2)",
|
||||
len(grainStateB.Items),
|
||||
func() int {
|
||||
if len(grainStateB.Items) == 0 {
|
||||
return -1
|
||||
}
|
||||
return grainStateB.Items[0].Quantity
|
||||
}(),
|
||||
)
|
||||
}
|
||||
|
||||
// Cross-check from nodeA (authoritative) to ensure state matches.
|
||||
grainStateA, err := syncedA.Get(cartID)
|
||||
if err != nil {
|
||||
t.Fatalf("nodeA Get error: %v", err)
|
||||
}
|
||||
if grainStateA.Items[0].Quantity != 2 {
|
||||
t.Fatalf("nodeA authoritative state mismatch: expected qty=2 got %d", grainStateA.Items[0].Quantity)
|
||||
}
|
||||
}
|
||||
304
multi_node_three_test.go
Normal file
304
multi_node_three_test.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// TestThreeNodeMajorityOwnership validates ring-determined ownership and routing
|
||||
// in a 3-node cluster (A,B,C) using the consistent hashing ring (no quorum RPC).
|
||||
// The previous ConfirmOwner / quorum semantics have been removed; ownership is
|
||||
// deterministic and derived from the ring.
|
||||
//
|
||||
// It validates:
|
||||
// 1. The ring selects exactly one primary owner for a new cart.
|
||||
// 2. Other nodes (B,C) do NOT create local grains for the cart.
|
||||
// 3. Remote proxies are installed lazily so remote mutations can route.
|
||||
// 4. A remote mutation from one non-owner updates state visible on another.
|
||||
// 5. Authoritative state on the owner matches remote observations.
|
||||
// 6. (Future) This scaffolds replication tests when RF>1 is enabled.
|
||||
//
|
||||
// (Legacy comments about ConfirmOwner acceptance thresholds have been removed.)
|
||||
// (Function name retained for historical continuity.)
|
||||
func TestThreeNodeMajorityOwnership(t *testing.T) {
|
||||
const (
|
||||
addrA = "127.0.0.1:18181"
|
||||
addrB = "127.0.0.1:18182"
|
||||
addrC = "127.0.0.1:18183"
|
||||
hostA = "nodeA3"
|
||||
hostB = "nodeB3"
|
||||
hostC = "nodeC3"
|
||||
)
|
||||
|
||||
// Local grain pools
|
||||
poolA := NewGrainLocalPool(1024, time.Minute, spawn)
|
||||
poolB := NewGrainLocalPool(1024, time.Minute, spawn)
|
||||
poolC := NewGrainLocalPool(1024, time.Minute, spawn)
|
||||
|
||||
// Synced pools (no discovery)
|
||||
syncedA, err := NewSyncedPool(poolA, hostA, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("nodeA NewSyncedPool error: %v", err)
|
||||
}
|
||||
syncedB, err := NewSyncedPool(poolB, hostB, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("nodeB NewSyncedPool error: %v", err)
|
||||
}
|
||||
syncedC, err := NewSyncedPool(poolC, hostC, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("nodeC NewSyncedPool error: %v", err)
|
||||
}
|
||||
|
||||
// Start gRPC servers
|
||||
grpcSrvA, err := StartGRPCServer(addrA, poolA, syncedA)
|
||||
if err != nil {
|
||||
t.Fatalf("StartGRPCServer A error: %v", err)
|
||||
}
|
||||
defer grpcSrvA.GracefulStop()
|
||||
grpcSrvB, err := StartGRPCServer(addrB, poolB, syncedB)
|
||||
if err != nil {
|
||||
t.Fatalf("StartGRPCServer B error: %v", err)
|
||||
}
|
||||
defer grpcSrvB.GracefulStop()
|
||||
grpcSrvC, err := StartGRPCServer(addrC, poolC, syncedC)
|
||||
if err != nil {
|
||||
t.Fatalf("StartGRPCServer C error: %v", err)
|
||||
}
|
||||
defer grpcSrvC.GracefulStop()
|
||||
|
||||
// Helper for manual cross-link (since AddRemote assumes fixed port)
|
||||
link := func(src *SyncedPool, remoteHost, remoteAddr string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
conn, dialErr := grpc.DialContext(ctx, remoteAddr, grpc.WithInsecure(), grpc.WithBlock())
|
||||
if dialErr != nil {
|
||||
t.Fatalf("dial %s (%s) failed: %v", remoteHost, remoteAddr, dialErr)
|
||||
}
|
||||
cartClient := messages.NewCartActorClient(conn)
|
||||
controlClient := messages.NewControlPlaneClient(conn)
|
||||
|
||||
src.mu.Lock()
|
||||
src.remoteHosts[remoteHost] = &RemoteHostGRPC{
|
||||
Host: remoteHost,
|
||||
Conn: conn,
|
||||
CartClient: cartClient,
|
||||
ControlClient: controlClient,
|
||||
}
|
||||
src.mu.Unlock()
|
||||
}
|
||||
|
||||
// Full mesh (each node knows all others)
|
||||
link(syncedA, hostB, addrB)
|
||||
link(syncedA, hostC, addrC)
|
||||
|
||||
link(syncedB, hostA, addrA)
|
||||
link(syncedB, hostC, addrC)
|
||||
|
||||
link(syncedC, hostA, addrA)
|
||||
link(syncedC, hostB, addrB)
|
||||
|
||||
// Rebuild rings after manual linking so ownership resolution is immediate.
|
||||
syncedA.ForceRingRefresh()
|
||||
syncedB.ForceRingRefresh()
|
||||
syncedC.ForceRingRefresh()
|
||||
|
||||
// Allow brief stabilization
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Deterministic-ish cart id
|
||||
cartID := ToCartId(fmt.Sprintf("cart3-%d", time.Now().UnixNano()))
|
||||
|
||||
addItem := &messages.AddItem{
|
||||
ItemId: 10,
|
||||
Quantity: 1,
|
||||
Price: 5000,
|
||||
OrgPrice: 5000,
|
||||
Sku: "sku-3node",
|
||||
Name: "Three Node Test",
|
||||
Image: "/t.png",
|
||||
Stock: 10,
|
||||
Tax: 2500,
|
||||
Country: "se",
|
||||
}
|
||||
|
||||
// Determine ring-designated owner (may be any of the three hosts)
|
||||
ownerPre := syncedA.DebugOwnerHost(cartID)
|
||||
if ownerPre != hostA && ownerPre != hostB && ownerPre != hostC {
|
||||
t.Fatalf("ring returned unexpected owner %s (not in set {%s,%s,%s})", ownerPre, hostA, hostB, hostC)
|
||||
}
|
||||
var ownerSynced *SyncedPool
|
||||
var ownerPool *GrainLocalPool
|
||||
switch ownerPre {
|
||||
case hostA:
|
||||
ownerSynced, ownerPool = syncedA, poolA
|
||||
case hostB:
|
||||
ownerSynced, ownerPool = syncedB, poolB
|
||||
case hostC:
|
||||
ownerSynced, ownerPool = syncedC, poolC
|
||||
}
|
||||
// Pick two distinct non-owner nodes for remote mutation assertions
|
||||
var remote1Synced, remote2Synced *SyncedPool
|
||||
switch ownerPre {
|
||||
case hostA:
|
||||
remote1Synced, remote2Synced = syncedB, syncedC
|
||||
case hostB:
|
||||
remote1Synced, remote2Synced = syncedA, syncedC
|
||||
case hostC:
|
||||
remote1Synced, remote2Synced = syncedA, syncedB
|
||||
}
|
||||
|
||||
// Apply on the ring-designated owner
|
||||
if _, err := ownerSynced.Apply(cartID, addItem); err != nil {
|
||||
t.Fatalf("owner %s Apply addItem error: %v", ownerPre, err)
|
||||
}
|
||||
|
||||
// Small wait for remote proxy spawn (ring ownership already deterministic)
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
// Assert only nodeA has local grain
|
||||
localCount := 0
|
||||
if _, ok := poolA.GetGrains()[cartID]; ok {
|
||||
localCount++
|
||||
}
|
||||
if _, ok := poolB.GetGrains()[cartID]; ok {
|
||||
localCount++
|
||||
}
|
||||
if _, ok := poolC.GetGrains()[cartID]; ok {
|
||||
localCount++
|
||||
}
|
||||
if localCount != 1 {
|
||||
t.Fatalf("expected exactly 1 local grain, got %d", localCount)
|
||||
}
|
||||
if _, ok := ownerPool.GetGrains()[cartID]; !ok {
|
||||
t.Fatalf("expected owner %s to hold local grain", ownerPre)
|
||||
}
|
||||
|
||||
// Remote proxies may not pre-exist; first remote mutation will trigger SpawnRemoteGrain lazily.
|
||||
|
||||
// Issue remote mutation from one non-owner -> ChangeQuantity (increase)
|
||||
change := &messages.ChangeQuantity{
|
||||
Id: 1,
|
||||
Quantity: 3,
|
||||
}
|
||||
if _, err := remote1Synced.Apply(cartID, change); err != nil {
|
||||
t.Fatalf("remote mutation (remote1) changeQuantity error: %v", err)
|
||||
}
|
||||
|
||||
// Validate updated state visible via nodeC
|
||||
stateC, err := remote2Synced.Get(cartID)
|
||||
if err != nil {
|
||||
t.Fatalf("nodeC Get error: %v", err)
|
||||
}
|
||||
if len(stateC.Items) != 1 || stateC.Items[0].Quantity != 3 {
|
||||
t.Fatalf("nodeC observed state mismatch: items=%d qty=%d (expected 1 / 3)",
|
||||
len(stateC.Items),
|
||||
func() int {
|
||||
if len(stateC.Items) == 0 {
|
||||
return -1
|
||||
}
|
||||
return stateC.Items[0].Quantity
|
||||
}(),
|
||||
)
|
||||
}
|
||||
|
||||
// Cross-check authoritative nodeA
|
||||
stateA, err := syncedA.Get(cartID)
|
||||
if err != nil {
|
||||
t.Fatalf("nodeA Get error: %v", err)
|
||||
}
|
||||
if stateA.Items[0].Quantity != 3 {
|
||||
t.Fatalf("nodeA authoritative state mismatch: expected qty=3 got %d", stateA.Items[0].Quantity)
|
||||
}
|
||||
}
|
||||
|
||||
// TestThreeNodeDiscoveryMajorityOwnership (placeholder)
|
||||
// This test is a scaffold demonstrating how a MockDiscovery would be wired
|
||||
// once AddRemote supports host:port (currently hard-coded to :1337).
|
||||
// It is skipped to avoid flakiness / false negatives until the production
|
||||
// AddRemote logic is enhanced to parse dynamic ports or the test harness
|
||||
// provides consistent port mapping.
|
||||
func TestThreeNodeDiscoveryMajorityOwnership(t *testing.T) {
|
||||
t.Skip("Pending enhancement: AddRemote needs host:port support to fully exercise discovery-based multi-node linking")
|
||||
// Example skeleton (non-functional with current AddRemote implementation):
|
||||
//
|
||||
// md := NewMockDiscovery([]string{"nodeB3", "nodeC3"})
|
||||
// poolA := NewGrainLocalPool(1024, time.Minute, spawn)
|
||||
// syncedA, err := NewSyncedPool(poolA, "nodeA3", md)
|
||||
// if err != nil {
|
||||
// t.Fatalf("NewSyncedPool with mock discovery error: %v", err)
|
||||
// }
|
||||
// // Start server for nodeA (would also need servers for nodeB3/nodeC3 on expected ports)
|
||||
// // grpcSrvA, _ := StartGRPCServer(":1337", poolA, syncedA)
|
||||
// // defer grpcSrvA.GracefulStop()
|
||||
//
|
||||
// // Dynamically add a host via discovery
|
||||
// // md.AddHost("nodeB3")
|
||||
// // time.Sleep(100 * time.Millisecond) // allow AddRemote attempt
|
||||
//
|
||||
// // Assertions would verify syncedA.remoteHosts contains "nodeB3"
|
||||
}
|
||||
|
||||
// TestHostRemovalAndErrorWithMockDiscovery validates behavior when:
|
||||
// 1. Discovery reports a host that cannot be dialed (AddRemote error path)
|
||||
// 2. That host is then removed (Deleted event) without leaving residual state
|
||||
// 3. A second failing host is added afterward (ensuring watcher still processes events)
|
||||
//
|
||||
// NOTE: Because AddRemote currently hard-codes :1337 and we are NOT starting a
|
||||
// real server for the bogus hosts, the dial will fail and the remote host should
|
||||
// never appear in remoteHosts. This intentionally exercises the error logging
|
||||
// path: "AddRemote: dial ... failed".
|
||||
func TestHostRemovalAndErrorWithMockDiscovery(t *testing.T) {
|
||||
// Start a real node A (acts as the observing node)
|
||||
const addrA = "127.0.0.1:18281"
|
||||
hostA := "nodeA-md"
|
||||
|
||||
poolA := NewGrainLocalPool(128, time.Minute, spawn)
|
||||
|
||||
// Mock discovery starts with one bogus host that will fail to connect.
|
||||
md := NewMockDiscovery([]string{"bogus-host-1"})
|
||||
syncedA, err := NewSyncedPool(poolA, hostA, md)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSyncedPool error: %v", err)
|
||||
}
|
||||
|
||||
grpcSrvA, err := StartGRPCServer(addrA, poolA, syncedA)
|
||||
if err != nil {
|
||||
t.Fatalf("StartGRPCServer A error: %v", err)
|
||||
}
|
||||
defer grpcSrvA.GracefulStop()
|
||||
|
||||
// Kick off watch processing by starting Watch() (NewSyncedPool does this internally
|
||||
// when discovery is non-nil, but we ensure events channel is active).
|
||||
// The initial bogus host should trigger AddRemote -> dial failure.
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
syncedA.mu.RLock()
|
||||
if len(syncedA.remoteHosts) != 0 {
|
||||
syncedA.mu.RUnlock()
|
||||
t.Fatalf("expected 0 remoteHosts after failing dial, got %d", len(syncedA.remoteHosts))
|
||||
}
|
||||
syncedA.mu.RUnlock()
|
||||
|
||||
// Remove the bogus host (should not panic; no entry to clean up).
|
||||
md.RemoveHost("bogus-host-1")
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Add another bogus host to ensure watcher still alive.
|
||||
md.AddHost("bogus-host-2")
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
syncedA.mu.RLock()
|
||||
if len(syncedA.remoteHosts) != 0 {
|
||||
syncedA.mu.RUnlock()
|
||||
t.Fatalf("expected 0 remoteHosts after second failing dial, got %d", len(syncedA.remoteHosts))
|
||||
}
|
||||
syncedA.mu.RUnlock()
|
||||
|
||||
// Clean up discovery
|
||||
md.Close()
|
||||
}
|
||||
82
mutation_add_item.go
Normal file
82
mutation_add_item.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// mutation_add_item.go
|
||||
//
|
||||
// Registers the AddItem cart mutation in the generic mutation registry.
|
||||
// This replaces the legacy switch-based logic previously found in CartGrain.Apply.
|
||||
//
|
||||
// Behavior:
|
||||
// * Validates quantity > 0
|
||||
// * If an item with same SKU exists -> increases quantity
|
||||
// * Else creates a new CartItem with computed tax amounts
|
||||
// * Totals recalculated automatically via WithTotals()
|
||||
//
|
||||
// NOTE: Any future field additions in messages.AddItem that affect pricing / tax
|
||||
// must keep this handler in sync.
|
||||
|
||||
func init() {
|
||||
RegisterMutation[messages.AddItem](
|
||||
"AddItem",
|
||||
func(g *CartGrain, m *messages.AddItem) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("AddItem: nil payload")
|
||||
}
|
||||
if m.Quantity < 1 {
|
||||
return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity)
|
||||
}
|
||||
|
||||
// Fast path: merge with existing item having same SKU
|
||||
if existing, found := g.FindItemWithSku(m.Sku); found {
|
||||
existing.Quantity += int(m.Quantity)
|
||||
return nil
|
||||
}
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
g.lastItemId++
|
||||
taxRate := 2500
|
||||
if m.Tax > 0 {
|
||||
taxRate = int(m.Tax)
|
||||
}
|
||||
taxAmountPerUnit := GetTaxAmount(m.Price, taxRate)
|
||||
|
||||
g.Items = append(g.Items, &CartItem{
|
||||
Id: g.lastItemId,
|
||||
ItemId: int(m.ItemId),
|
||||
Quantity: int(m.Quantity),
|
||||
Sku: m.Sku,
|
||||
Name: m.Name,
|
||||
Price: m.Price,
|
||||
TotalPrice: m.Price * int64(m.Quantity),
|
||||
TotalTax: int64(taxAmountPerUnit * int64(m.Quantity)),
|
||||
Image: m.Image,
|
||||
Stock: StockStatus(m.Stock),
|
||||
Disclaimer: m.Disclaimer,
|
||||
Brand: m.Brand,
|
||||
Category: m.Category,
|
||||
Category2: m.Category2,
|
||||
Category3: m.Category3,
|
||||
Category4: m.Category4,
|
||||
Category5: m.Category5,
|
||||
OrgPrice: m.OrgPrice,
|
||||
ArticleType: m.ArticleType,
|
||||
Outlet: m.Outlet,
|
||||
SellerId: m.SellerId,
|
||||
SellerName: m.SellerName,
|
||||
Tax: int(taxAmountPerUnit),
|
||||
TaxRate: taxRate,
|
||||
StoreId: m.StoreId,
|
||||
})
|
||||
|
||||
return nil
|
||||
},
|
||||
WithTotals(), // Recalculate totals after successful mutation
|
||||
)
|
||||
}
|
||||
61
mutation_add_request.go
Normal file
61
mutation_add_request.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// mutation_add_request.go
|
||||
//
|
||||
// Registers the AddRequest mutation. This mutation is a higher-level intent
|
||||
// (add by SKU + quantity) which may translate into either:
|
||||
// - Increasing quantity of an existing line (same SKU), OR
|
||||
// - Creating a new item by performing a product lookup (via getItemData inside CartGrain.AddItem)
|
||||
//
|
||||
// Behavior:
|
||||
// - Validates non-empty SKU and quantity > 0
|
||||
// - If an item with the SKU already exists: increments its quantity
|
||||
// - Else delegates to CartGrain.AddItem (which itself produces an AddItem mutation)
|
||||
// - Totals recalculated automatically (WithTotals)
|
||||
//
|
||||
// NOTE:
|
||||
// - This handler purposely avoids duplicating the detailed AddItem logic;
|
||||
// it reuses CartGrain.AddItem which then flows through the AddItem mutation
|
||||
// registry handler.
|
||||
// - Double total recalculation can occur (AddItem has WithTotals too), but
|
||||
// is acceptable for clarity. Optimize later if needed.
|
||||
//
|
||||
// Potential future improvements:
|
||||
// - Stock validation before increasing quantity
|
||||
// - Reservation logic or concurrency guards around stock updates
|
||||
// - Coupon / pricing rules applied conditionally during add-by-sku
|
||||
func init() {
|
||||
RegisterMutation[messages.AddRequest](
|
||||
"AddRequest",
|
||||
func(g *CartGrain, m *messages.AddRequest) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("AddRequest: nil payload")
|
||||
}
|
||||
if m.Sku == "" {
|
||||
return fmt.Errorf("AddRequest: sku is empty")
|
||||
}
|
||||
if m.Quantity < 1 {
|
||||
return fmt.Errorf("AddRequest: invalid quantity %d", m.Quantity)
|
||||
}
|
||||
|
||||
// Existing line: accumulate quantity only.
|
||||
if existing, found := g.FindItemWithSku(m.Sku); found {
|
||||
existing.Quantity += int(m.Quantity)
|
||||
return nil
|
||||
}
|
||||
|
||||
// New line: delegate to higher-level AddItem flow (product lookup).
|
||||
// We intentionally ignore the returned *CartGrain; registry will
|
||||
// do totals again after this handler returns (harmless).
|
||||
_, err := g.AddItem(m.Sku, int(m.Quantity), m.Country, m.StoreId)
|
||||
return err
|
||||
},
|
||||
WithTotals(),
|
||||
)
|
||||
}
|
||||
58
mutation_change_quantity.go
Normal file
58
mutation_change_quantity.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// mutation_change_quantity.go
|
||||
//
|
||||
// Registers the ChangeQuantity mutation.
|
||||
//
|
||||
// Behavior:
|
||||
// - Locates an item by its cart-local line item Id (not source item_id).
|
||||
// - If requested quantity <= 0 the line is removed.
|
||||
// - Otherwise the line's Quantity field is updated.
|
||||
// - Totals are recalculated (WithTotals).
|
||||
//
|
||||
// Error handling:
|
||||
// - Returns an error if the item Id is not found.
|
||||
// - Returns an error if payload is nil (defensive).
|
||||
//
|
||||
// Concurrency:
|
||||
// - Uses the grain's RW-safe mutation pattern: we mutate in place under
|
||||
// the grain's implicit expectation that higher layers control access.
|
||||
// (If strict locking is required around every mutation, wrap logic in
|
||||
// an explicit g.mu.Lock()/Unlock(), but current model mirrors prior code.)
|
||||
func init() {
|
||||
RegisterMutation[messages.ChangeQuantity](
|
||||
"ChangeQuantity",
|
||||
func(g *CartGrain, m *messages.ChangeQuantity) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("ChangeQuantity: nil payload")
|
||||
}
|
||||
|
||||
foundIndex := -1
|
||||
for i, it := range g.Items {
|
||||
if it.Id == int(m.Id) {
|
||||
foundIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if foundIndex == -1 {
|
||||
return fmt.Errorf("ChangeQuantity: item id %d not found", m.Id)
|
||||
}
|
||||
|
||||
if m.Quantity <= 0 {
|
||||
// Remove the item
|
||||
g.Items = append(g.Items[:foundIndex], g.Items[foundIndex+1:]...)
|
||||
return nil
|
||||
}
|
||||
|
||||
g.Items[foundIndex].Quantity = int(m.Quantity)
|
||||
return nil
|
||||
},
|
||||
WithTotals(),
|
||||
)
|
||||
}
|
||||
49
mutation_initialize_checkout.go
Normal file
49
mutation_initialize_checkout.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// mutation_initialize_checkout.go
|
||||
//
|
||||
// Registers the InitializeCheckout mutation.
|
||||
// This mutation is invoked AFTER an external Klarna checkout session
|
||||
// has been successfully created or updated. It persists the Klarna
|
||||
// order reference / status and marks the cart as having a payment in progress.
|
||||
//
|
||||
// Behavior:
|
||||
// - Sets OrderReference to the Klarna order ID (overwriting if already set).
|
||||
// - Sets PaymentStatus to the current Klarna status.
|
||||
// - Sets / updates PaymentInProgress flag.
|
||||
// - Does NOT alter pricing or line items (so no totals recalculation).
|
||||
//
|
||||
// Validation:
|
||||
// - Returns an error if payload is nil.
|
||||
// - Returns an error if orderId is empty (integrity guard).
|
||||
//
|
||||
// Concurrency:
|
||||
// - Relies on upstream mutation serialization for a single grain. If
|
||||
// parallel checkout attempts are possible, add higher-level guards
|
||||
// (e.g. reject if PaymentInProgress already true unless reusing
|
||||
// the same OrderReference).
|
||||
func init() {
|
||||
RegisterMutation[messages.InitializeCheckout](
|
||||
"InitializeCheckout",
|
||||
func(g *CartGrain, m *messages.InitializeCheckout) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("InitializeCheckout: nil payload")
|
||||
}
|
||||
if m.OrderId == "" {
|
||||
return fmt.Errorf("InitializeCheckout: missing orderId")
|
||||
}
|
||||
|
||||
g.OrderReference = m.OrderId
|
||||
g.PaymentStatus = m.Status
|
||||
g.PaymentInProgress = m.PaymentInProgress
|
||||
return nil
|
||||
},
|
||||
// No WithTotals(): monetary aggregates are unaffected.
|
||||
)
|
||||
}
|
||||
53
mutation_order_created.go
Normal file
53
mutation_order_created.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// mutation_order_created.go
|
||||
//
|
||||
// Registers the OrderCreated mutation.
|
||||
//
|
||||
// This mutation represents the completion (or state transition) of an order
|
||||
// initiated earlier via InitializeCheckout / external Klarna processing.
|
||||
// It finalizes (or updates) the cart's order metadata.
|
||||
//
|
||||
// Behavior:
|
||||
// - Validates payload non-nil and OrderId not empty.
|
||||
// - Sets (or overwrites) OrderReference with the provided OrderId.
|
||||
// - Sets PaymentStatus from payload.Status.
|
||||
// - Marks PaymentInProgress = false (checkout flow finished / acknowledged).
|
||||
// - Does NOT adjust monetary totals (no WithTotals()).
|
||||
//
|
||||
// Notes / Future Extensions:
|
||||
// - If multiple order completion events can arrive (e.g., retries / webhook
|
||||
// replays), this handler is idempotent: it simply overwrites fields.
|
||||
// - If you need to guard against conflicting order IDs, add a check:
|
||||
// if g.OrderReference != "" && g.OrderReference != m.OrderId { ... }
|
||||
// - Add audit logging or metrics here if required.
|
||||
//
|
||||
// Concurrency:
|
||||
// - Relies on the higher-level guarantee that Apply() calls are serialized
|
||||
// per grain. If out-of-order events are possible, embed versioning or
|
||||
// timestamps in the mutation and compare before applying changes.
|
||||
func init() {
|
||||
RegisterMutation[messages.OrderCreated](
|
||||
"OrderCreated",
|
||||
func(g *CartGrain, m *messages.OrderCreated) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("OrderCreated: nil payload")
|
||||
}
|
||||
if m.OrderId == "" {
|
||||
return fmt.Errorf("OrderCreated: missing orderId")
|
||||
}
|
||||
|
||||
g.OrderReference = m.OrderId
|
||||
g.PaymentStatus = m.Status
|
||||
g.PaymentInProgress = false
|
||||
return nil
|
||||
},
|
||||
// No WithTotals(): order completion does not modify pricing or taxes.
|
||||
)
|
||||
}
|
||||
301
mutation_registry.go
Normal file
301
mutation_registry.go
Normal file
@@ -0,0 +1,301 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// mutation_registry.go
|
||||
//
|
||||
// Mutation Registry Infrastructure
|
||||
// --------------------------------
|
||||
// This file introduces a generic registry for cart mutations that:
|
||||
//
|
||||
// 1. Decouples mutation logic from the large type-switch inside CartGrain.Apply.
|
||||
// 2. Enforces (at registration time) that every mutation handler has the correct
|
||||
// signature: func(*CartGrain, *T) error
|
||||
// 3. Optionally auto-updates cart totals after a mutation if flagged.
|
||||
// 4. Provides a single authoritative list of registered mutations for
|
||||
// introspection / coverage testing.
|
||||
// 5. Allows incremental migration: you can first register new mutations here,
|
||||
// and later prune the legacy switch cases.
|
||||
//
|
||||
// Usage Pattern
|
||||
// -------------
|
||||
// // Define your mutation proto message (e.g. messages.ApplyCoupon in messages.proto)
|
||||
// // Regenerate protobufs.
|
||||
//
|
||||
// // In an init() (ideally in a small file like mutations_apply_coupon.go)
|
||||
// func init() {
|
||||
// RegisterMutation[*messages.ApplyCoupon](
|
||||
// "ApplyCoupon",
|
||||
// func(g *CartGrain, m *messages.ApplyCoupon) error {
|
||||
// // domain logic ...
|
||||
// discount := int64(5000)
|
||||
// if g.TotalPrice < discount {
|
||||
// discount = g.TotalPrice
|
||||
// }
|
||||
// g.TotalDiscount += discount
|
||||
// g.TotalPrice -= discount
|
||||
// return nil
|
||||
// },
|
||||
// WithTotals(), // we changed price-related fields; recalc totals
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// // To invoke dynamically (alternative to the current switch):
|
||||
// if updated, err := ApplyRegistered(grain, incomingMessage); err == nil {
|
||||
// grain = updated
|
||||
// } else if errors.Is(err, ErrMutationNotRegistered) {
|
||||
// // fallback to legacy switch logic
|
||||
// }
|
||||
//
|
||||
// Migration Strategy
|
||||
// ------------------
|
||||
// 1. For each existing mutation handled in CartGrain.Apply, add a registry
|
||||
// registration with equivalent logic.
|
||||
// 2. Add a test that enumerates all *expected* mutation proto types and asserts
|
||||
// they are present in RegisteredMutationTypes().
|
||||
// 3. Once coverage is 100%, replace the switch in CartGrain.Apply with a call
|
||||
// to ApplyRegistered (and optionally keep a minimal default to produce an
|
||||
// "unsupported mutation" error).
|
||||
//
|
||||
// Thread Safety
|
||||
// -------------
|
||||
// Registration is typically done at init() time; a RWMutex provides safety
|
||||
// should late dynamic registration ever be introduced.
|
||||
//
|
||||
// Auto Totals
|
||||
// -----------
|
||||
// Many mutations require recomputing totals. To avoid forgetting this, pass
|
||||
// WithTotals() when registering. This will invoke grain.UpdateTotals() after
|
||||
// the handler returns successfully.
|
||||
//
|
||||
// Error Semantics
|
||||
// ---------------
|
||||
// - If a handler returns an error, totals are NOT recalculated (even if
|
||||
// WithTotals() was specified).
|
||||
// - ApplyRegistered returns (nil, ErrMutationNotRegistered) if the message type
|
||||
// is absent.
|
||||
//
|
||||
// Extensibility
|
||||
// -------------
|
||||
// It is straightforward to add options like audit hooks, metrics wrappers,
|
||||
// or optimistic concurrency guards by extending MutationOption.
|
||||
//
|
||||
// NOTE: Generics require Go 1.18+. If constrained to earlier Go versions,
|
||||
// replace the generic registration with a non-generic RegisterMutationType
|
||||
// that accepts reflect.Type and an adapter function.
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
mutationRegistryMu sync.RWMutex
|
||||
mutationRegistry = make(map[reflect.Type]*registeredMutation)
|
||||
|
||||
// ErrMutationNotRegistered is returned when no handler exists for a given mutation type.
|
||||
ErrMutationNotRegistered = fmt.Errorf("mutation not registered")
|
||||
)
|
||||
|
||||
// MutationOption configures additional behavior for a registered mutation.
|
||||
type MutationOption func(*mutationOptions)
|
||||
|
||||
// mutationOptions holds flags adjustable per registration.
|
||||
type mutationOptions struct {
|
||||
updateTotals bool
|
||||
}
|
||||
|
||||
// WithTotals ensures CartGrain.UpdateTotals() is called after a successful handler.
|
||||
func WithTotals() MutationOption {
|
||||
return func(o *mutationOptions) {
|
||||
o.updateTotals = true
|
||||
}
|
||||
}
|
||||
|
||||
// registeredMutation stores metadata + the execution closure.
|
||||
type registeredMutation struct {
|
||||
name string
|
||||
handler func(*CartGrain, interface{}) error
|
||||
updateTotals bool
|
||||
msgType reflect.Type
|
||||
}
|
||||
|
||||
// RegisterMutation registers a mutation handler for a specific message type T.
|
||||
//
|
||||
// Parameters:
|
||||
//
|
||||
// name - a human-readable identifier (used for diagnostics / coverage tests).
|
||||
// handler - business logic operating on the cart grain & strongly typed message.
|
||||
// options - optional behavior flags (e.g., WithTotals()).
|
||||
//
|
||||
// Panics if:
|
||||
// - name is empty
|
||||
// - handler is nil
|
||||
// - duplicate registration for the same message type T
|
||||
//
|
||||
// Typical call is placed in an init() function.
|
||||
func RegisterMutation[T any](name string, handler func(*CartGrain, *T) error, options ...MutationOption) {
|
||||
if name == "" {
|
||||
panic("RegisterMutation: name is required")
|
||||
}
|
||||
if handler == nil {
|
||||
panic("RegisterMutation: handler is nil")
|
||||
}
|
||||
|
||||
// Derive the reflect.Type for *T then its Elem (T) for mapping.
|
||||
var zero *T
|
||||
rtPtr := reflect.TypeOf(zero)
|
||||
if rtPtr.Kind() != reflect.Ptr {
|
||||
panic("RegisterMutation: expected pointer type for generic parameter")
|
||||
}
|
||||
rt := rtPtr.Elem()
|
||||
|
||||
opts := mutationOptions{}
|
||||
for _, opt := range options {
|
||||
opt(&opts)
|
||||
}
|
||||
|
||||
wrapped := func(g *CartGrain, m interface{}) error {
|
||||
typed, ok := m.(*T)
|
||||
if !ok {
|
||||
return fmt.Errorf("mutation type mismatch: have %T want *%s", m, rt.Name())
|
||||
}
|
||||
return handler(g, typed)
|
||||
}
|
||||
|
||||
mutationRegistryMu.Lock()
|
||||
defer mutationRegistryMu.Unlock()
|
||||
|
||||
if _, exists := mutationRegistry[rt]; exists {
|
||||
panic(fmt.Sprintf("RegisterMutation: duplicate registration for type %s", rt.String()))
|
||||
}
|
||||
|
||||
mutationRegistry[rt] = ®isteredMutation{
|
||||
name: name,
|
||||
handler: wrapped,
|
||||
updateTotals: opts.updateTotals,
|
||||
msgType: rt,
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyRegistered attempts to apply a registered mutation.
|
||||
// Returns updated grain if successful.
|
||||
//
|
||||
// If the mutation is not registered, returns (nil, ErrMutationNotRegistered).
|
||||
func ApplyRegistered(grain *CartGrain, msg interface{}) (*CartGrain, error) {
|
||||
if grain == nil {
|
||||
return nil, fmt.Errorf("nil grain")
|
||||
}
|
||||
if msg == nil {
|
||||
return nil, fmt.Errorf("nil mutation message")
|
||||
}
|
||||
|
||||
rt := indirectType(reflect.TypeOf(msg))
|
||||
mutationRegistryMu.RLock()
|
||||
entry, ok := mutationRegistry[rt]
|
||||
mutationRegistryMu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return nil, ErrMutationNotRegistered
|
||||
}
|
||||
|
||||
if err := entry.handler(grain, msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if entry.updateTotals {
|
||||
grain.UpdateTotals()
|
||||
}
|
||||
|
||||
return grain, nil
|
||||
}
|
||||
|
||||
// RegisteredMutations returns metadata for all registered mutations (snapshot).
|
||||
func RegisteredMutations() []string {
|
||||
mutationRegistryMu.RLock()
|
||||
defer mutationRegistryMu.RUnlock()
|
||||
out := make([]string, 0, len(mutationRegistry))
|
||||
for _, entry := range mutationRegistry {
|
||||
out = append(out, entry.name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RegisteredMutationTypes returns the reflect.Type list of all registered messages.
|
||||
// Useful for coverage tests ensuring expected set matches actual set.
|
||||
func RegisteredMutationTypes() []reflect.Type {
|
||||
mutationRegistryMu.RLock()
|
||||
defer mutationRegistryMu.RUnlock()
|
||||
out := make([]reflect.Type, 0, len(mutationRegistry))
|
||||
for t := range mutationRegistry {
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MustAssertMutationCoverage can be called at startup to ensure every expected
|
||||
// mutation type has been registered. It panics with a descriptive message if any
|
||||
// are missing. Provide a slice of prototype pointers (e.g. []*messages.AddItem{nil} ...)
|
||||
func MustAssertMutationCoverage(expected []interface{}) {
|
||||
mutationRegistryMu.RLock()
|
||||
defer mutationRegistryMu.RUnlock()
|
||||
|
||||
missing := make([]string, 0)
|
||||
for _, ex := range expected {
|
||||
if ex == nil {
|
||||
continue
|
||||
}
|
||||
t := indirectType(reflect.TypeOf(ex))
|
||||
if _, ok := mutationRegistry[t]; !ok {
|
||||
missing = append(missing, t.String())
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
panic(fmt.Sprintf("mutation registry missing handlers for: %v", missing))
|
||||
}
|
||||
}
|
||||
|
||||
// indirectType returns the element type if given a pointer; otherwise the type itself.
|
||||
func indirectType(t reflect.Type) reflect.Type {
|
||||
for t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
/*
|
||||
Integration Guide
|
||||
-----------------
|
||||
|
||||
1. Register all existing mutations:
|
||||
|
||||
func init() {
|
||||
RegisterMutation[*messages.AddItem]("AddItem",
|
||||
func(g *CartGrain, m *messages.AddItem) error {
|
||||
// (port logic from existing switch branch)
|
||||
// ...
|
||||
return nil
|
||||
},
|
||||
WithTotals(),
|
||||
)
|
||||
// ... repeat for others
|
||||
}
|
||||
|
||||
2. In CartGrain.Apply (early in the method) add:
|
||||
|
||||
if updated, err := ApplyRegistered(c, content); err == nil {
|
||||
return updated, nil
|
||||
} else if err != ErrMutationNotRegistered {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// existing switch fallback below
|
||||
|
||||
3. Once all mutations are registered, remove the legacy switch cases
|
||||
and leave a single ErrMutationNotRegistered path for unknown types.
|
||||
|
||||
4. Add a coverage test (see docs for example; removed from source for clarity).
|
||||
5. (Optional) Add metrics / tracing wrappers for handlers.
|
||||
|
||||
*/
|
||||
53
mutation_remove_delivery.go
Normal file
53
mutation_remove_delivery.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// mutation_remove_delivery.go
|
||||
//
|
||||
// Registers the RemoveDelivery mutation.
|
||||
//
|
||||
// Behavior:
|
||||
// - Removes the delivery entry whose Id == payload.Id.
|
||||
// - If not found, returns an error.
|
||||
// - Cart totals are recalculated (WithTotals) after removal.
|
||||
// - Items previously associated with that delivery simply become "without delivery";
|
||||
// subsequent delivery mutations can reassign them.
|
||||
//
|
||||
// Differences vs legacy:
|
||||
// - Legacy logic decremented TotalPrice explicitly before recalculating.
|
||||
// Here we rely solely on UpdateTotals() to recompute from remaining
|
||||
// deliveries and items (simpler / single source of truth).
|
||||
//
|
||||
// Future considerations:
|
||||
// - If delivery pricing logic changes (e.g., dynamic taxes per delivery),
|
||||
// UpdateTotals() may need enhancement to incorporate delivery tax properly.
|
||||
func init() {
|
||||
RegisterMutation[messages.RemoveDelivery](
|
||||
"RemoveDelivery",
|
||||
func(g *CartGrain, m *messages.RemoveDelivery) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("RemoveDelivery: nil payload")
|
||||
}
|
||||
targetID := int(m.Id)
|
||||
index := -1
|
||||
for i, d := range g.Deliveries {
|
||||
if d.Id == targetID {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index == -1 {
|
||||
return fmt.Errorf("RemoveDelivery: delivery id %d not found", m.Id)
|
||||
}
|
||||
|
||||
// Remove delivery (order not preserved beyond necessity)
|
||||
g.Deliveries = append(g.Deliveries[:index], g.Deliveries[index+1:]...)
|
||||
return nil
|
||||
},
|
||||
WithTotals(),
|
||||
)
|
||||
}
|
||||
49
mutation_remove_item.go
Normal file
49
mutation_remove_item.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// mutation_remove_item.go
|
||||
//
|
||||
// Registers the RemoveItem mutation.
|
||||
//
|
||||
// Behavior:
|
||||
// - Removes the cart line whose local cart line Id == payload.Id
|
||||
// - If no such line exists returns an error
|
||||
// - Recalculates cart totals (WithTotals)
|
||||
//
|
||||
// Notes:
|
||||
// - This removes only the line item; any deliveries referencing the removed
|
||||
// item are NOT automatically adjusted (mirrors prior logic). If future
|
||||
// semantics require pruning delivery.item_ids you can extend this handler.
|
||||
// - If multiple lines somehow shared the same Id (should not happen), only
|
||||
// the first match would be removed—data integrity relies on unique line Ids.
|
||||
func init() {
|
||||
RegisterMutation[messages.RemoveItem](
|
||||
"RemoveItem",
|
||||
func(g *CartGrain, m *messages.RemoveItem) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("RemoveItem: nil payload")
|
||||
}
|
||||
targetID := int(m.Id)
|
||||
|
||||
index := -1
|
||||
for i, it := range g.Items {
|
||||
if it.Id == targetID {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index == -1 {
|
||||
return fmt.Errorf("RemoveItem: item id %d not found", m.Id)
|
||||
}
|
||||
|
||||
g.Items = append(g.Items[:index], g.Items[index+1:]...)
|
||||
return nil
|
||||
},
|
||||
WithTotals(),
|
||||
)
|
||||
}
|
||||
57
mutation_set_cart_items.go
Normal file
57
mutation_set_cart_items.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// mutation_set_cart_items.go
|
||||
//
|
||||
// Registers the SetCartRequest mutation. This mutation replaces the entire list
|
||||
// of cart items with the provided list (each entry is an AddRequest).
|
||||
//
|
||||
// Behavior:
|
||||
// - Clears existing items (but leaves deliveries intact).
|
||||
// - Iterates over each AddRequest and delegates to CartGrain.AddItem
|
||||
// (which performs product lookup, creates AddItem mutation).
|
||||
// - If any single addition fails, the mutation aborts with an error;
|
||||
// items added prior to the failure remain (consistent with previous behavior).
|
||||
// - Totals recalculated after completion via WithTotals().
|
||||
//
|
||||
// Notes:
|
||||
// - Potential optimization: batch product lookups; currently sequential.
|
||||
// - Consider adding rollback semantics if atomic replacement is desired.
|
||||
// - Deliveries might reference item IDs that are now invalid—original logic
|
||||
// also left deliveries untouched. If that becomes an issue, add a cleanup
|
||||
// pass to remove deliveries whose item IDs no longer exist.
|
||||
func init() {
|
||||
RegisterMutation[messages.SetCartRequest](
|
||||
"SetCartRequest",
|
||||
func(g *CartGrain, m *messages.SetCartRequest) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("SetCartRequest: nil payload")
|
||||
}
|
||||
|
||||
// Clear current items (keep deliveries)
|
||||
g.mu.Lock()
|
||||
g.Items = make([]*CartItem, 0, len(m.Items))
|
||||
g.mu.Unlock()
|
||||
|
||||
for _, it := range m.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
if it.Sku == "" || it.Quantity < 1 {
|
||||
return fmt.Errorf("SetCartRequest: invalid item (sku='%s' qty=%d)", it.Sku, it.Quantity)
|
||||
}
|
||||
_, err := g.AddItem(it.Sku, int(it.Quantity), it.Country, it.StoreId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SetCartRequest: add sku '%s' failed: %w", it.Sku, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
WithTotals(),
|
||||
)
|
||||
}
|
||||
101
mutation_set_delivery.go
Normal file
101
mutation_set_delivery.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// mutation_set_delivery.go
|
||||
//
|
||||
// Registers the SetDelivery mutation.
|
||||
//
|
||||
// Semantics (mirrors legacy switch logic):
|
||||
// - If the payload specifies an explicit list of item IDs (payload.Items):
|
||||
// - Each referenced cart line must exist.
|
||||
// - None of the referenced items may already belong to a delivery.
|
||||
// - Only those items are associated with the new delivery.
|
||||
// - If payload.Items is empty:
|
||||
// - All items currently without any delivery are associated with the new delivery.
|
||||
// - A new delivery line is created with:
|
||||
// - Auto-incremented delivery ID (cart-local)
|
||||
// - Provider from payload
|
||||
// - Fixed price (currently hard-coded: 4900 minor units) – adjust as needed
|
||||
// - Optional PickupPoint copied from payload
|
||||
// - Cart totals are recalculated (WithTotals)
|
||||
//
|
||||
// Error cases:
|
||||
// - Referenced item does not exist
|
||||
// - Referenced item already has a delivery
|
||||
// - No items qualify (resulting association set empty) -> returns error (prevents creating empty delivery)
|
||||
//
|
||||
// Concurrency:
|
||||
// - Uses g.mu to protect lastDeliveryId increment and append to Deliveries slice.
|
||||
// Item scans are read-only and performed outside the lock for simplicity;
|
||||
// if stricter guarantees are needed, widen the lock section.
|
||||
//
|
||||
// Future extension points:
|
||||
// - Variable delivery pricing (based on weight, distance, provider, etc.)
|
||||
// - Validation of provider codes
|
||||
// - Multi-currency delivery pricing
|
||||
func init() {
|
||||
RegisterMutation[messages.SetDelivery](
|
||||
"SetDelivery",
|
||||
func(g *CartGrain, m *messages.SetDelivery) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("SetDelivery: nil payload")
|
||||
}
|
||||
if m.Provider == "" {
|
||||
return fmt.Errorf("SetDelivery: provider is empty")
|
||||
}
|
||||
|
||||
withDelivery := g.ItemsWithDelivery()
|
||||
targetItems := make([]int, 0)
|
||||
|
||||
if len(m.Items) == 0 {
|
||||
// Use every item currently without a delivery
|
||||
targetItems = append(targetItems, g.ItemsWithoutDelivery()...)
|
||||
} else {
|
||||
// Validate explicit list
|
||||
for _, id64 := range m.Items {
|
||||
id := int(id64)
|
||||
found := false
|
||||
for _, it := range g.Items {
|
||||
if it.Id == id {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("SetDelivery: item id %d not found", id)
|
||||
}
|
||||
if slices.Contains(withDelivery, id) {
|
||||
return fmt.Errorf("SetDelivery: item id %d already has a delivery", id)
|
||||
}
|
||||
targetItems = append(targetItems, id)
|
||||
}
|
||||
}
|
||||
|
||||
if len(targetItems) == 0 {
|
||||
return fmt.Errorf("SetDelivery: no eligible items to attach")
|
||||
}
|
||||
|
||||
// Append new delivery
|
||||
g.mu.Lock()
|
||||
g.lastDeliveryId++
|
||||
newId := g.lastDeliveryId
|
||||
g.Deliveries = append(g.Deliveries, &CartDelivery{
|
||||
Id: newId,
|
||||
Provider: m.Provider,
|
||||
PickupPoint: m.PickupPoint,
|
||||
Price: 4900, // TODO: externalize pricing
|
||||
Items: targetItems,
|
||||
})
|
||||
g.mu.Unlock()
|
||||
|
||||
return nil
|
||||
},
|
||||
WithTotals(),
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package checkout
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.k6n.net/go-cart-actor/proto/checkout"
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
// mutation_set_pickup_point.go
|
||||
@@ -28,24 +28,29 @@ import (
|
||||
// - 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 HandleSetPickupPoint(g *CheckoutGrain, 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 = &PickupPoint{
|
||||
Id: m.PickupPoint.Id,
|
||||
Name: m.PickupPoint.Name,
|
||||
Address: m.PickupPoint.Address,
|
||||
City: m.PickupPoint.City,
|
||||
Zip: m.PickupPoint.Zip,
|
||||
Country: m.PickupPoint.Country,
|
||||
func init() {
|
||||
RegisterMutation[messages.SetPickupPoint](
|
||||
"SetPickupPoint",
|
||||
func(g *CartGrain, m *messages.SetPickupPoint) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("SetPickupPoint: nil payload")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("SetPickupPoint: delivery id %d not found", m.DeliveryId)
|
||||
|
||||
for _, d := range g.Deliveries {
|
||||
if d.Id == int(m.DeliveryId) {
|
||||
d.PickupPoint = &messages.PickupPoint{
|
||||
Id: m.Id,
|
||||
Name: m.Name,
|
||||
Address: m.Address,
|
||||
City: m.City,
|
||||
Zip: m.Zip,
|
||||
Country: m.Country,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("SetPickupPoint: delivery id %d not found", m.DeliveryId)
|
||||
},
|
||||
// No WithTotals(): pickup point does not change pricing / tax.
|
||||
)
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type GrainId 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 GrainId) String() string {
|
||||
return encodeBase62(uint64(id))
|
||||
}
|
||||
|
||||
// MarshalJSON encodes the cart id as a JSON string.
|
||||
func (id GrainId) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(id.String())
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes a cart id from a JSON string containing base62 text.
|
||||
func (id *GrainId) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
parsed, ok := ParseGrainId(s)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid cart id: %q", s)
|
||||
}
|
||||
*id = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewGrainId generates a new cryptographically random non-zero 64-bit id.
|
||||
func NewGrainId() (GrainId, error) {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return 0, fmt.Errorf("NewGrainId: %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 NewGrainId()
|
||||
}
|
||||
return GrainId(u), nil
|
||||
}
|
||||
|
||||
// MustNewGrainId panics if generation fails.
|
||||
func MustNewGrainId() GrainId {
|
||||
id, err := NewGrainId()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// ParseGrainId parses a base62 string into a GrainId.
|
||||
// Returns (0,false) for invalid input.
|
||||
func ParseGrainId(s string) (GrainId, 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 GrainId(u), true
|
||||
}
|
||||
|
||||
// MustParseGrainId panics on invalid base62 input.
|
||||
func MustParseGrainId(s string) GrainId {
|
||||
id, ok := ParseGrainId(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,162 +0,0 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"google.golang.org/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,46 +0,0 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"google.golang.org/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[V], bool)
|
||||
Hostname() string
|
||||
TakeOwnership(id uint64)
|
||||
HandleOwnershipChange(host string, ids []uint64) error
|
||||
HandleRemoteExpiry(host string, ids []uint64) error
|
||||
Negotiate(otherHosts []string)
|
||||
GetLocalIds() []uint64
|
||||
IsHealthy() bool
|
||||
Close()
|
||||
IsKnown(string) bool
|
||||
RemoveHost(host string)
|
||||
AddRemoteHost(host string)
|
||||
}
|
||||
|
||||
// Host abstracts a remote node capable of proxying cart requests.
|
||||
type Host[V any] interface {
|
||||
AnnounceExpiry(ids []uint64)
|
||||
Negotiate(otherHosts []string) ([]string, error)
|
||||
Name() string
|
||||
Proxy(id uint64, w http.ResponseWriter, r *http.Request, customBody io.Reader) (bool, error)
|
||||
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[V], error)
|
||||
Get(ctx context.Context, id uint64) (*V, error)
|
||||
GetActorIds() []uint64
|
||||
Close() error
|
||||
Ping() bool
|
||||
IsHealthy() bool
|
||||
AnnounceOwnership(ownerHost string, ids []uint64)
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
messages "git.k6n.net/go-cart-actor/proto/control"
|
||||
"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"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/anypb"
|
||||
)
|
||||
|
||||
// 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 toAny[V any](grain V) (*anypb.Any, error) {
|
||||
data, err := json.Marshal(grain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &anypb.Any{
|
||||
Value: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ControlServer[V]) Get(ctx context.Context, req *messages.GetRequest) (*messages.GetReply, error) {
|
||||
grain, err := s.pool.Get(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
grainAny, err := toAny(grain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &messages.GetReply{
|
||||
Grain: grainAny,
|
||||
}, 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, req *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
|
||||
}
|
||||
|
||||
func (s *ControlServer[V]) Apply(ctx context.Context, in *messages.ApplyRequest) (*messages.ApplyResult, error) {
|
||||
msgs := make([]proto.Message, len(in.Messages))
|
||||
for i, anyMsg := range in.Messages {
|
||||
msg, err := anyMsg.UnmarshalNew()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal message: %w", err)
|
||||
}
|
||||
msgs[i] = msg
|
||||
}
|
||||
r, err := s.pool.Apply(ctx, in.Id, msgs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
grainAny, err := toAny(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mutList := make([]*messages.MutationResult, len(in.Messages))
|
||||
for i, msg := range r.Mutations {
|
||||
mut, err := anypb.New(msg.Mutation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var errString *string
|
||||
if msg.Error != nil {
|
||||
s := msg.Error.Error()
|
||||
errString = &s
|
||||
}
|
||||
mutList[i] = &messages.MutationResult{
|
||||
Type: msg.Type,
|
||||
Message: mut,
|
||||
Error: errString,
|
||||
}
|
||||
}
|
||||
|
||||
return &messages.ApplyResult{
|
||||
State: grainAny,
|
||||
Mutations: mutList,
|
||||
}, 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, req *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,150 +0,0 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
cart_messages "git.k6n.net/go-cart-actor/proto/cart"
|
||||
control_plane_messages "git.k6n.net/go-cart-actor/proto/control"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/anypb"
|
||||
)
|
||||
|
||||
// MockGrainPool for testing
|
||||
type mockGrainPool struct {
|
||||
applied []proto.Message
|
||||
}
|
||||
|
||||
func (m *mockGrainPool) Apply(ctx context.Context, id uint64, mutations ...proto.Message) (*MutationResult[mockGrain], error) {
|
||||
m.applied = mutations
|
||||
// Simulate successful application
|
||||
return &MutationResult[mockGrain]{
|
||||
Result: mockGrain{},
|
||||
Mutations: []ApplyResult{
|
||||
{Type: "AddItem", Mutation: &cart_messages.AddItem{ItemId: 1, Quantity: 2}, Error: nil},
|
||||
{Type: "RemoveItem", Mutation: &cart_messages.RemoveItem{Id: 1}, Error: nil},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockGrainPool) Get(ctx context.Context, id uint64) (*mockGrain, error) {
|
||||
return &mockGrain{}, nil
|
||||
}
|
||||
|
||||
func (m *mockGrainPool) OwnerHost(id uint64) (Host[mockGrain], bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m *mockGrainPool) TakeOwnership(id uint64) {}
|
||||
|
||||
func (m *mockGrainPool) Hostname() string { return "test-host" }
|
||||
|
||||
func (m *mockGrainPool) HandleOwnershipChange(host string, ids []uint64) error { return nil }
|
||||
func (m *mockGrainPool) HandleRemoteExpiry(host string, ids []uint64) error { return nil }
|
||||
func (m *mockGrainPool) Negotiate(hosts []string) {}
|
||||
func (m *mockGrainPool) GetLocalIds() []uint64 { return []uint64{} }
|
||||
func (m *mockGrainPool) RemoveHost(host string) {}
|
||||
func (m *mockGrainPool) AddRemoteHost(host string) {}
|
||||
func (m *mockGrainPool) IsHealthy() bool { return true }
|
||||
func (m *mockGrainPool) IsKnown(host string) bool { return false }
|
||||
func (m *mockGrainPool) Close() {}
|
||||
|
||||
type mockGrain struct{}
|
||||
|
||||
func TestApplyRequestWithMutations(t *testing.T) {
|
||||
// Setup mock pool
|
||||
pool := &mockGrainPool{}
|
||||
|
||||
// Create gRPC server
|
||||
server, err := NewControlServer[mockGrain](DefaultServerConfig(), pool)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server: %v", err)
|
||||
}
|
||||
defer server.GracefulStop()
|
||||
|
||||
// Create client connection
|
||||
conn, err := grpc.Dial("localhost:1337", grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client := control_plane_messages.NewControlPlaneClient(conn)
|
||||
|
||||
// Prepare ApplyRequest with multiple Any messages
|
||||
addItemAny, _ := anypb.New(&cart_messages.AddItem{ItemId: 1, Quantity: 2})
|
||||
removeItemAny, _ := anypb.New(&cart_messages.RemoveItem{Id: 1})
|
||||
req := &control_plane_messages.ApplyRequest{
|
||||
Id: 123,
|
||||
Messages: []*anypb.Any{addItemAny, removeItemAny},
|
||||
}
|
||||
|
||||
// Call Apply
|
||||
resp, err := client.Apply(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Apply failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify response
|
||||
if resp.State == nil {
|
||||
t.Errorf("expected State to be non-nil")
|
||||
}
|
||||
if len(resp.Mutations) != 2 {
|
||||
t.Errorf("expected 2 mutation results, got %d", len(resp.Mutations))
|
||||
}
|
||||
for i, mut := range resp.Mutations {
|
||||
if mut.Error != nil {
|
||||
t.Errorf("expected no error in mutation %d, got %s", i, *mut.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify mutations were extracted and applied
|
||||
if len(pool.applied) != 2 {
|
||||
t.Errorf("expected 2 mutations applied, got %d", len(pool.applied))
|
||||
}
|
||||
if addItem, ok := pool.applied[0].(*cart_messages.AddItem); !ok || addItem.ItemId != 1 {
|
||||
t.Errorf("expected AddItem with ItemId=1, got %v", pool.applied[0])
|
||||
}
|
||||
if removeItem, ok := pool.applied[1].(*cart_messages.RemoveItem); !ok || removeItem.Id != 1 {
|
||||
t.Errorf("expected RemoveItem with Id=1, got %v", pool.applied[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRequest(t *testing.T) {
|
||||
// Setup mock pool
|
||||
pool := &mockGrainPool{}
|
||||
|
||||
// Create gRPC server
|
||||
server, err := NewControlServer[mockGrain](DefaultServerConfig(), pool)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create server: %v", err)
|
||||
}
|
||||
defer server.GracefulStop()
|
||||
|
||||
// Create client connection
|
||||
conn, err := grpc.Dial("localhost:1337", grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to dial: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client := control_plane_messages.NewControlPlaneClient(conn)
|
||||
|
||||
// Prepare GetRequest
|
||||
req := &control_plane_messages.GetRequest{
|
||||
Id: 123,
|
||||
}
|
||||
|
||||
// Call Get
|
||||
resp, err := client.Get(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Get failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify response
|
||||
if resp.Grain == nil {
|
||||
t.Errorf("expected Grain to be non-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,361 +0,0 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"reflect"
|
||||
"sync"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
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)
|
||||
RegisterTrigger(trigger ...TriggerHandler)
|
||||
SetEventChannel(ch chan<- ApplyResult)
|
||||
}
|
||||
|
||||
type ProtoMutationRegistry struct {
|
||||
mutationRegistryMu sync.RWMutex
|
||||
mutationRegistry map[reflect.Type]MutationHandler
|
||||
triggers map[reflect.Type][]TriggerHandler
|
||||
processors []MutationProcessor
|
||||
eventChannel chan<- ApplyResult
|
||||
}
|
||||
|
||||
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 TriggerHandler interface {
|
||||
Handle(state any, msg proto.Message) []proto.Message
|
||||
Name() string
|
||||
Type() reflect.Type
|
||||
}
|
||||
|
||||
type RegisteredTrigger[V any, I proto.Message] struct {
|
||||
name string
|
||||
handler func(state any, msg proto.Message) []proto.Message
|
||||
msgType reflect.Type
|
||||
}
|
||||
|
||||
func NewTrigger[V any, I proto.Message](name string, handler func(state any, msg proto.Message) []proto.Message) *RegisteredTrigger[V, I] {
|
||||
return &RegisteredTrigger[V, I]{
|
||||
name: name,
|
||||
handler: handler,
|
||||
msgType: reflect.TypeOf((*I)(nil)).Elem(),
|
||||
}
|
||||
}
|
||||
|
||||
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() proto.Message
|
||||
msgType reflect.Type
|
||||
}
|
||||
|
||||
func NewMutation[V any, T proto.Message](handler func(*V, T) error) *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.
|
||||
create := func() proto.Message {
|
||||
var t T
|
||||
rt := reflect.TypeOf(t)
|
||||
if rt != nil && rt.Kind() == reflect.Pointer {
|
||||
return reflect.New(rt.Elem()).Interface().(proto.Message)
|
||||
}
|
||||
log.Fatalf("expected to create proto message got %+v", rt)
|
||||
return nil
|
||||
}
|
||||
instance := create()
|
||||
rt := reflect.TypeOf(instance)
|
||||
if rt.Kind() == reflect.Pointer {
|
||||
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{},
|
||||
triggers: make(map[reflect.Type][]TriggerHandler),
|
||||
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) RegisterTrigger(triggers ...TriggerHandler) {
|
||||
r.mutationRegistryMu.Lock()
|
||||
defer r.mutationRegistryMu.Unlock()
|
||||
|
||||
for _, trigger := range triggers {
|
||||
existingTriggers, ok := r.triggers[trigger.Type()]
|
||||
if !ok {
|
||||
r.triggers[trigger.Type()] = []TriggerHandler{trigger}
|
||||
} else {
|
||||
r.triggers[trigger.Type()] = append(existingTriggers, trigger)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ProtoMutationRegistry) SetEventChannel(ch chan<- ApplyResult) {
|
||||
r.eventChannel = ch
|
||||
}
|
||||
|
||||
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 {
|
||||
// Error if any mutation element is nil.
|
||||
if m == nil {
|
||||
return results, fmt.Errorf("nil mutation message")
|
||||
}
|
||||
// Typed nil: interface holds concrete proto message type whose pointer value is nil.
|
||||
rv := reflect.ValueOf(m)
|
||||
if rv.Kind() == reflect.Pointer && 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)
|
||||
}
|
||||
if r.eventChannel != nil {
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Handle panic from sending to closed channel
|
||||
log.Printf("event channel closed: %v", r)
|
||||
}
|
||||
}()
|
||||
for _, tr := range r.triggers[rt] {
|
||||
for _, msg := range tr.Handle(grain, m) {
|
||||
select {
|
||||
case r.eventChannel <- msg:
|
||||
default:
|
||||
// Channel full or no receiver, skip to avoid blocking
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
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 error for unregistered mutations
|
||||
for _, res := range results {
|
||||
if res.Error == ErrMutationNotRegistered {
|
||||
return results, res.Error
|
||||
}
|
||||
}
|
||||
|
||||
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,207 +0,0 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cart_messages "git.k6n.net/go-cart-actor/proto/cart"
|
||||
)
|
||||
|
||||
type cartState struct {
|
||||
calls int
|
||||
lastAdded *cart_messages.AddItem
|
||||
}
|
||||
|
||||
func TestRegisteredMutationBasics(t *testing.T) {
|
||||
reg := NewMutationRegistry().(*ProtoMutationRegistry)
|
||||
|
||||
addItemMutation := NewMutation(
|
||||
func(state *cartState, msg *cart_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
|
||||
},
|
||||
)
|
||||
|
||||
// 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(cart_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(cart_messages.AddItem{})) {
|
||||
t.Fatalf("RegisteredMutationTypes missing AddItem type, got %v", types)
|
||||
}
|
||||
|
||||
// GetTypeName should resolve for a pointer instance
|
||||
name, ok := reg.GetTypeName(&cart_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(&cart_messages.RemoveItem{}); 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.(*cart_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 := &cart_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(context.Background(), 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
|
||||
_, err := reg.Apply(context.Background(), state, &cart_messages.RemoveItem{})
|
||||
if err != ErrMutationNotRegistered {
|
||||
t.Fatalf("expected ErrMutationNotRegistered, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventChannel(t *testing.T) {
|
||||
reg := NewMutationRegistry().(*ProtoMutationRegistry)
|
||||
|
||||
addItemMutation := NewMutation(
|
||||
func(state *cartState, msg *cart_messages.AddItem) error {
|
||||
state.calls++
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
reg.RegisterMutations(addItemMutation)
|
||||
|
||||
eventCh := make(chan ApplyResult, 10)
|
||||
reg.SetEventChannel(eventCh)
|
||||
|
||||
state := &cartState{}
|
||||
add := &cart_messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
|
||||
results, err := reg.Apply(context.Background(), state, add)
|
||||
if err != nil {
|
||||
t.Fatalf("Apply returned error: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
|
||||
// Receive from channel with timeout
|
||||
select {
|
||||
case res := <-eventCh:
|
||||
if res.Type != "AddItem" {
|
||||
t.Fatalf("expected type AddItem, got %s", res.Type)
|
||||
}
|
||||
if res.Error != nil {
|
||||
t.Fatalf("expected no error, got %v", res.Error)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("expected to receive event on channel within timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventChannelClosed(t *testing.T) {
|
||||
reg := NewMutationRegistry().(*ProtoMutationRegistry)
|
||||
|
||||
addItemMutation := NewMutation(
|
||||
func(state *cartState, msg *cart_messages.AddItem) error {
|
||||
state.calls++
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
reg.RegisterMutations(addItemMutation)
|
||||
|
||||
eventCh := make(chan ApplyResult, 10)
|
||||
reg.SetEventChannel(eventCh)
|
||||
|
||||
close(eventCh) // Close the channel to simulate external close
|
||||
|
||||
state := &cartState{}
|
||||
add := &cart_messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
|
||||
// This should not panic due to recover in goroutine
|
||||
results, err := reg.Apply(context.Background(), state, add)
|
||||
if err != nil {
|
||||
t.Fatalf("Apply returned error: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
// Test passes if no panic occurs
|
||||
}
|
||||
|
||||
func TestEventChannelUnbufferedNoListener(t *testing.T) {
|
||||
reg := NewMutationRegistry().(*ProtoMutationRegistry)
|
||||
|
||||
addItemMutation := NewMutation(
|
||||
func(state *cartState, msg *cart_messages.AddItem) error {
|
||||
state.calls++
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
reg.RegisterMutations(addItemMutation)
|
||||
|
||||
eventCh := make(chan ApplyResult) // unbuffered
|
||||
reg.SetEventChannel(eventCh)
|
||||
|
||||
// No goroutine reading from eventCh
|
||||
|
||||
state := &cartState{}
|
||||
add := &cart_messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
|
||||
results, err := reg.Apply(context.Background(), state, add)
|
||||
if err != nil {
|
||||
t.Fatalf("Apply returned error: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
// Since no listener, the send should go to default and not block
|
||||
// Test passes if Apply completes without hanging
|
||||
}
|
||||
|
||||
// Helpers
|
||||
@@ -1,78 +0,0 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"iter"
|
||||
"slices"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type ReceiverFunc[V any] func(event V)
|
||||
|
||||
type PubSub[V any] struct {
|
||||
subscribers []*ReceiverFunc[V]
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewPubSub creates a new PubSub instance.
|
||||
func NewPubSub[V any]() *PubSub[V] {
|
||||
return &PubSub[V]{
|
||||
subscribers: make([]*ReceiverFunc[V], 0),
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe adds a grain ID to the subscribers of a topic.
|
||||
func (p *PubSub[V]) Subscribe(receiver ReceiverFunc[V]) {
|
||||
if receiver == nil {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
// log.Printf("adding subscriber")
|
||||
p.subscribers = append(p.subscribers, &receiver)
|
||||
}
|
||||
|
||||
// Unsubscribe removes a grain ID from the subscribers of a topic.
|
||||
func (p *PubSub[V]) Unsubscribe(receiver ReceiverFunc[V]) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
list := p.subscribers
|
||||
prt := &receiver
|
||||
for i, sub := range list {
|
||||
if sub == nil {
|
||||
continue
|
||||
}
|
||||
if sub == prt {
|
||||
// log.Printf("removing subscriber")
|
||||
p.subscribers = append(list[:i], list[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
p.subscribers = slices.DeleteFunc(p.subscribers, func(fn *ReceiverFunc[V]) bool {
|
||||
return fn == nil
|
||||
})
|
||||
// If list is empty, could delete, but not necessary
|
||||
}
|
||||
|
||||
// GetSubscribers returns a copy of the subscriber IDs for a topic.
|
||||
func (p *PubSub[V]) GetSubscribers() iter.Seq[ReceiverFunc[V]] {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
return func(yield func(ReceiverFunc[V]) bool) {
|
||||
for _, sub := range p.subscribers {
|
||||
if sub == nil {
|
||||
continue
|
||||
}
|
||||
if !yield(*sub) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Publish sends an event to all subscribers of the topic.
|
||||
func (p *PubSub[V]) Publish(event V) {
|
||||
for notify := range p.GetSubscribers() {
|
||||
notify(event)
|
||||
}
|
||||
}
|
||||
@@ -1,462 +0,0 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"maps"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"google.golang.org/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)
|
||||
destroy func(grain Grain[V]) error
|
||||
spawnHost func(host string) (Host[V], error)
|
||||
listeners []LogListener
|
||||
storage LogStorage[V]
|
||||
ttl time.Duration
|
||||
poolSize int
|
||||
|
||||
// Cluster coordination --------------------------------------------------
|
||||
hostname string
|
||||
remoteMu sync.RWMutex
|
||||
remoteOwners map[uint64]Host[V]
|
||||
remoteHosts map[string]Host[V]
|
||||
//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[V], error)
|
||||
Destroy func(grain Grain[V]) 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,
|
||||
destroy: config.Destroy,
|
||||
ttl: config.TTL,
|
||||
poolSize: config.PoolSize,
|
||||
hostname: config.Hostname,
|
||||
remoteOwners: make(map[uint64]Host[V]),
|
||||
remoteHosts: make(map[string]Host[V]),
|
||||
}
|
||||
|
||||
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 err := p.destroy(grain); err != nil {
|
||||
log.Printf("failed to destroy grain %d: %v", id, err)
|
||||
}
|
||||
|
||||
delete(p.grains, id)
|
||||
}
|
||||
}
|
||||
p.localMu.Unlock()
|
||||
p.forAllHosts(func(remote Host[V]) {
|
||||
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 {
|
||||
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[V], 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[V]) {
|
||||
|
||||
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[V]) {
|
||||
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[V], 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[V]) {
|
||||
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[V])) {
|
||||
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[V]) {
|
||||
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
|
||||
}
|
||||
go p.broadcastOwnership([]uint64{id})
|
||||
p.localMu.Lock()
|
||||
p.grains[id] = grain
|
||||
p.localMu.Unlock()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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[V], bool) {
|
||||
p.remoteMu.RLock()
|
||||
defer p.remoteMu.RUnlock()
|
||||
owner, ok := p.remoteOwners[id]
|
||||
return owner, ok
|
||||
}
|
||||
|
||||
// Hostname returns the local hostname (pod IP).
|
||||
func (p *SimpleGrainPool[V]) Hostname() string {
|
||||
return p.hostname
|
||||
}
|
||||
|
||||
// Close notifies remotes that this host is shutting down.
|
||||
func (p *SimpleGrainPool[V]) Close() {
|
||||
|
||||
p.forAllHosts(func(rh Host[V]) {
|
||||
rh.Close()
|
||||
})
|
||||
|
||||
if p.purgeTicker != nil {
|
||||
p.purgeTicker.Stop()
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"google.golang.org/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,276 +0,0 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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 uint16 `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"`
|
||||
ReservationEndTime *time.Time `json:"reservationEndTime,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 CartPaymentStatus string
|
||||
|
||||
const (
|
||||
CartPaymentStatusPending CartPaymentStatus = "pending"
|
||||
CartPaymentStatusFailed CartPaymentStatus = "failed"
|
||||
CartPaymentStatusSuccess CartPaymentStatus = "success"
|
||||
CartPaymentStatusCancelled CartPaymentStatus = "partial"
|
||||
)
|
||||
|
||||
type Marking struct {
|
||||
Type uint32 `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type CartGrain struct {
|
||||
mu sync.RWMutex
|
||||
lastItemId uint32
|
||||
lastVoucherId uint32
|
||||
lastAccess time.Time
|
||||
lastChange time.Time // unix seconds of last successful mutation (replay sets from event ts)
|
||||
userId string
|
||||
Currency string `json:"currency"`
|
||||
Language string `json:"language"`
|
||||
Version uint `json:"version"`
|
||||
InventoryReserved bool `json:"inventoryReserved"`
|
||||
Id CartId `json:"id"`
|
||||
Items []*CartItem `json:"items"`
|
||||
TotalPrice *Price `json:"totalPrice"`
|
||||
TotalDiscount *Price `json:"totalDiscount"`
|
||||
Processing bool `json:"processing"`
|
||||
//PaymentInProgress uint16 `json:"paymentInProgress"`
|
||||
OrderReference string `json:"orderReference,omitempty"`
|
||||
|
||||
Vouchers []*Voucher `json:"vouchers,omitempty"`
|
||||
Notifications []CartNotification `json:"cartNotification,omitempty"`
|
||||
SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"`
|
||||
|
||||
//CheckoutOrderId string `json:"checkoutOrderId,omitempty"`
|
||||
CheckoutStatus *CartPaymentStatus `json:"checkoutStatus,omitempty"`
|
||||
//CheckoutCountry string `json:"checkoutCountry,omitempty"`
|
||||
}
|
||||
|
||||
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,
|
||||
lastVoucherId: 0,
|
||||
lastAccess: ts,
|
||||
lastChange: ts,
|
||||
TotalDiscount: NewPrice(),
|
||||
Vouchers: []*Voucher{},
|
||||
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
|
||||
}
|
||||
|
||||
func (c *CartGrain) HandleInventoryChange(change inventory.InventoryChange) {
|
||||
for _, item := range c.Items {
|
||||
l := "se"
|
||||
if item.StoreId != nil {
|
||||
l = *item.StoreId
|
||||
}
|
||||
if item.Sku == change.SKU && change.StockLocationID == l {
|
||||
item.Stock = uint16(change.Value)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetState() ([]byte, error) {
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
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) 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 _, 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,87 +0,0 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/go-cart-actor/pkg/actor"
|
||||
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
||||
)
|
||||
|
||||
type CartMutationContext struct {
|
||||
reservationService inventory.CartReservationService
|
||||
}
|
||||
|
||||
func NewCartMutationContext(reservationService inventory.CartReservationService) *CartMutationContext {
|
||||
return &CartMutationContext{
|
||||
reservationService: reservationService,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CartMutationContext) ReserveItem(ctx context.Context, cartId CartId, sku string, locationId *string, quantity uint16) (*time.Time, error) {
|
||||
|
||||
if quantity <= 0 || c.reservationService == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
l := inventory.LocationID("se")
|
||||
if locationId != nil {
|
||||
l = inventory.LocationID(*locationId)
|
||||
}
|
||||
ttl := time.Minute * 15
|
||||
endTime := time.Now().Add(ttl)
|
||||
err := c.reservationService.ReserveForCart(ctx, inventory.CartReserveRequest{
|
||||
CartID: inventory.CartID(cartId.String()),
|
||||
InventoryReference: &inventory.InventoryReference{
|
||||
SKU: inventory.SKU(sku),
|
||||
LocationID: l,
|
||||
},
|
||||
TTL: ttl,
|
||||
Quantity: uint32(quantity),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &endTime, nil
|
||||
|
||||
}
|
||||
|
||||
func (c *CartMutationContext) UseReservations(item *CartItem) bool {
|
||||
if item.ReservationEndTime != nil {
|
||||
return true
|
||||
}
|
||||
return item.Cgm == "55010"
|
||||
}
|
||||
|
||||
func (c *CartMutationContext) ReleaseItem(ctx context.Context, cartId CartId, sku string, locationId *string) error {
|
||||
if c.reservationService == nil {
|
||||
return nil
|
||||
}
|
||||
l := inventory.LocationID("se")
|
||||
if locationId != nil {
|
||||
l = inventory.LocationID(*locationId)
|
||||
}
|
||||
return c.reservationService.ReleaseForCart(ctx, inventory.SKU(sku), l, inventory.CartID(cartId.String()))
|
||||
}
|
||||
|
||||
func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegistry {
|
||||
|
||||
reg := actor.NewMutationRegistry()
|
||||
reg.RegisterMutations(
|
||||
actor.NewMutation(context.AddItem),
|
||||
actor.NewMutation(context.ChangeQuantity),
|
||||
actor.NewMutation(context.RemoveItem),
|
||||
actor.NewMutation(ClearCart),
|
||||
actor.NewMutation(AddVoucher),
|
||||
actor.NewMutation(RemoveVoucher),
|
||||
actor.NewMutation(UpsertSubscriptionDetails),
|
||||
actor.NewMutation(SetUserId),
|
||||
actor.NewMutation(LineItemMarking),
|
||||
actor.NewMutation(RemoveLineItemMarking),
|
||||
actor.NewMutation(SubscriptionAdded),
|
||||
// actor.NewMutation(SubscriptionRemoved),
|
||||
)
|
||||
return reg
|
||||
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.k6n.net/go-cart-actor/pkg/actor"
|
||||
)
|
||||
|
||||
// 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 actor.GrainId
|
||||
|
||||
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,141 +0,0 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
cart_messages "git.k6n.net/go-cart-actor/proto/cart"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
// 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.
|
||||
var ErrPaymentInProgress = errors.New("payment in progress")
|
||||
|
||||
func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) error {
|
||||
ctx := context.Background()
|
||||
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
|
||||
}
|
||||
if c.UseReservations(existing) {
|
||||
if err := c.ReleaseItem(ctx, g.Id, existing.Sku, existing.StoreId); err != nil {
|
||||
log.Printf("failed to release item %d: %v", existing.Id, err)
|
||||
}
|
||||
endTime, err := c.ReserveItem(ctx, g.Id, existing.Sku, existing.StoreId, existing.Quantity+uint16(m.Quantity))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing.ReservationEndTime = endTime
|
||||
}
|
||||
existing.Quantity += uint16(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)
|
||||
|
||||
needsReservation := true
|
||||
if m.ReservationEndTime != nil {
|
||||
needsReservation = m.ReservationEndTime.AsTime().Before(time.Now())
|
||||
}
|
||||
|
||||
cartItem := &CartItem{
|
||||
Id: g.lastItemId,
|
||||
ItemId: uint32(m.ItemId),
|
||||
Quantity: uint16(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,
|
||||
}
|
||||
|
||||
if needsReservation && c.UseReservations(cartItem) {
|
||||
endTime, err := c.ReserveItem(ctx, g.Id, m.Sku, m.StoreId, uint16(m.Quantity))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if endTime != nil {
|
||||
m.ReservationEndTime = timestamppb.New(*endTime)
|
||||
t := m.ReservationEndTime.AsTime()
|
||||
cartItem.ReservationEndTime = &t
|
||||
}
|
||||
}
|
||||
|
||||
g.Items = append(g.Items, cartItem)
|
||||
g.UpdateTotals()
|
||||
return nil
|
||||
}
|
||||
|
||||
func getOrgPrice(orgPrice int64, taxRate float32) *Price {
|
||||
if orgPrice <= 0 {
|
||||
return nil
|
||||
}
|
||||
return NewPriceFromIncVat(orgPrice, taxRate)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"git.k6n.net/go-cart-actor/pkg/actor"
|
||||
messages "git.k6n.net/go-cart-actor/proto/cart"
|
||||
)
|
||||
|
||||
func RemoveVoucher(g *CartGrain, m *messages.RemoveVoucher) error {
|
||||
if m == nil {
|
||||
return &actor.MutationError{
|
||||
Message: "RemoveVoucher: nil payload",
|
||||
Code: 1003,
|
||||
StatusCode: 400,
|
||||
}
|
||||
}
|
||||
if g.CheckoutStatus != nil {
|
||||
return ErrPaymentInProgress
|
||||
}
|
||||
|
||||
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,85 +0,0 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
messages "git.k6n.net/go-cart-actor/proto/cart"
|
||||
)
|
||||
|
||||
// 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 (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *messages.ChangeQuantity) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("ChangeQuantity: nil payload")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
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
|
||||
itemToRemove := g.Items[foundIndex]
|
||||
if itemToRemove.ReservationEndTime != nil && itemToRemove.ReservationEndTime.Before(time.Now()) {
|
||||
err := c.ReleaseItem(ctx, g.Id, itemToRemove.Sku, itemToRemove.StoreId)
|
||||
if err != nil {
|
||||
log.Printf("unable to release reservation for %s in location: %v", itemToRemove.Sku, itemToRemove.StoreId)
|
||||
}
|
||||
}
|
||||
g.Items = append(g.Items[:foundIndex], g.Items[foundIndex+1:]...)
|
||||
g.UpdateTotals()
|
||||
return nil
|
||||
}
|
||||
item := g.Items[foundIndex]
|
||||
if item == nil {
|
||||
return fmt.Errorf("ChangeQuantity: item id %d not found", m.Id)
|
||||
}
|
||||
if c.UseReservations(item) {
|
||||
if item.ReservationEndTime != nil {
|
||||
err := c.ReleaseItem(ctx, g.Id, item.Sku, item.StoreId)
|
||||
if err != nil {
|
||||
log.Printf("unable to release reservation for %s in location: %v", item.Sku, item.StoreId)
|
||||
}
|
||||
|
||||
}
|
||||
endTime, err := c.ReserveItem(ctx, g.Id, item.Sku, item.StoreId, uint16(m.Quantity))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
item.ReservationEndTime = endTime
|
||||
}
|
||||
item.Quantity = uint16(m.Quantity)
|
||||
g.UpdateTotals()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.k6n.net/go-cart-actor/proto/cart"
|
||||
)
|
||||
|
||||
func ClearCart(g *CartGrain, m *messages.ClearCartRequest) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("ClearCart: nil payload")
|
||||
}
|
||||
if g.CheckoutStatus != nil {
|
||||
return fmt.Errorf("ClearCart: cart is in checkout")
|
||||
}
|
||||
// Clear items, vouchers, etc., but keep userId, etc.
|
||||
g.Items = g.Items[:0]
|
||||
g.Vouchers = g.Vouchers[:0]
|
||||
g.Notifications = g.Notifications[:0]
|
||||
g.OrderReference = ""
|
||||
g.Processing = false
|
||||
|
||||
// g.InventoryReserved = false maybe should release inventory
|
||||
g.UpdateTotals()
|
||||
return nil
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.k6n.net/go-cart-actor/proto/cart"
|
||||
)
|
||||
|
||||
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,57 +0,0 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
messages "git.k6n.net/go-cart-actor/proto/cart"
|
||||
)
|
||||
|
||||
// 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 (c *CartMutationContext) 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)
|
||||
}
|
||||
|
||||
item := g.Items[index]
|
||||
if item.ReservationEndTime != nil && item.ReservationEndTime.After(time.Now()) {
|
||||
err := c.ReleaseItem(context.Background(), g.Id, item.Sku, item.StoreId)
|
||||
if err != nil {
|
||||
log.Printf("unable to release item reservation")
|
||||
}
|
||||
}
|
||||
|
||||
g.Items = append(g.Items[:index], g.Items[index+1:]...)
|
||||
g.UpdateTotals()
|
||||
return nil
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.k6n.net/go-cart-actor/proto/cart"
|
||||
)
|
||||
|
||||
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,15 +0,0 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
messages "git.k6n.net/go-cart-actor/proto/cart"
|
||||
)
|
||||
|
||||
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,20 +0,0 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
messages "git.k6n.net/go-cart-actor/proto/cart"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user