21 Commits

Author SHA1 Message Date
matst80
16948fcbdb testing new structure
Some checks failed
Build and Publish / Metadata (push) Successful in 13s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
2025-10-15 22:39:55 +02:00
matst80
3942ea911e clean 2025-10-15 22:27:36 +02:00
matst80
1c589e0558 add sys to se what we get 2025-10-15 22:20:33 +02:00
e0207a8638 update
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m11s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m0s
2025-10-15 19:47:42 +02:00
813d232921 update
Some checks failed
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m27s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-15 19:44:51 +02:00
matst80
c61adb3b9d clean and change type
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m27s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m24s
2025-10-15 18:21:16 +02:00
matst80
8456184973 less overhead
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m45s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m20s
2025-10-15 18:09:04 +02:00
matst80
9dc5bab7c5 test
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m40s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m12s
2025-10-15 18:02:30 +02:00
matst80
55d0595161 append asap
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m19s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m20s
2025-10-15 17:06:45 +02:00
matst80
4268616cbe dont pin
All checks were successful
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m20s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m10s
2025-10-15 14:46:28 +02:00
matst80
a8a697d113 ack messages
Some checks failed
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 1m2s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m24s
2025-10-15 14:40:03 +02:00
matst80
abca60490b test build
Some checks failed
Build and Publish / Metadata (push) Successful in 14s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 57s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m12s
2025-10-15 12:52:49 +02:00
matst80
9d202af55b more logs
Some checks failed
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Has started running
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-15 12:51:25 +02:00
matst80
b918f406df listen to correct topic
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m26s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m32s
2025-10-15 09:52:43 +02:00
matst80
a5d61ce56f fix
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m25s
Build and Publish / BuildAndDeployArm64 (push) Successful in 3m53s
2025-10-15 09:35:35 +02:00
matst80
21fd5c8446 id fix
Some checks failed
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m15s
Build and Publish / BuildAndDeployArm64 (push) Has been cancelled
2025-10-15 09:31:54 +02:00
matst80
e1302a8ffa update
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m30s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m22s
2025-10-15 09:22:00 +02:00
matst80
9ba9117a0f allow cors
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m18s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m44s
2025-10-15 09:07:09 +02:00
matst80
f1e82c74d8 livez endpoint
All checks were successful
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m27s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m39s
2025-10-15 08:52:29 +02:00
matst80
a1ba48053a update backoffice
All checks were successful
Build and Publish / Metadata (push) Successful in 8s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m33s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m13s
2025-10-15 08:47:05 +02:00
matst80
f543ed1d74 add backoffice and move stuff
All checks were successful
Build and Publish / Metadata (push) Successful in 9s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m40s
Build and Publish / BuildAndDeployArm64 (push) Successful in 4m2s
2025-10-15 08:34:08 +02:00
108 changed files with 3396 additions and 13152 deletions

View File

@@ -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."
}
]
}

View File

@@ -1,47 +1,80 @@
name: Build and Publish
run-name: ${{ gitea.actor }} build 🚀
on:
push:
branches:
- main
on: [push]
jobs:
Metadata:
runs-on: arm64
outputs:
version: ${{ steps.meta.outputs.version }}
git_commit: ${{ steps.meta.outputs.git_commit }}
build_date: ${{ steps.meta.outputs.build_date }}
steps:
- name: Checkout
uses: actions/checkout@v4
- id: meta
name: Derive build metadata
run: |
GIT_COMMIT=$(git rev-parse HEAD)
if git describe --tags --exact-match >/dev/null 2>&1; then
VERSION=$(git describe --tags --exact-match)
else
VERSION=$(git rev-parse --short=12 HEAD)
fi
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
echo "git_commit=$GIT_COMMIT" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "build_date=$BUILD_DATE" >> $GITHUB_OUTPUT
BuildAndDeployAmd64:
needs: Metadata
runs-on: amd64
steps:
- uses: actions/checkout@v5
- 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.k6n.net/go-cart-actor-amd64:latest \
-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.k6n.net/go-cart-actor-amd64:latest
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 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
- 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.k6n.net/go-cart-actor:latest \
-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.k6n.net/go-cart-actor:latest
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
# 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

View File

@@ -1,50 +0,0 @@
# GitHub Copilot Instructions for Go Cart Actor
This repository contains a distributed cart management system implemented in Go using the actor model pattern. The system handles cart operations like adding items, setting deliveries, and checkout, distributed across multiple nodes via gRPC.
## Project Structure
- `cmd/`: Entry points for the application.
- `pkg/`: Core packages including grain logic, pools, and HTTP handlers.
- `proto/`: Protocol Buffer definitions for messages and services.
- `api-tests/`: Tests for the HTTP API.
- `deployment/`: Deployment configurations.
- `k6/`: Load testing scripts.
## Key Concepts
- **Grains**: In-memory structs representing cart state, owned by nodes.
- **Pools**: Local and synced pools manage grains and handle ownership.
- **Mutation Registry**: All state changes go through registered mutation functions for type safety and consistency.
- **Ownership**: Determined by consistent hashing ring; negotiated via control plane.
## Coding Guidelines
- Follow standard Go conventions (gofmt, go vet, golint).
- Never say "your right", just be correct and clear.
- Don't edit the *.gb.go files manually, they are generated by the proto files.
- Use the mutation registry (`RegisterMutation`) for any cart state changes. Do not mutate grain state directly outside registered handlers.
- Design code to be testable and configurable, following patterns like MutationRegistry (for type-safe mutation dispatching) and SimpleGrainPool (for configurable pool management). Use interfaces and dependency injection to enable mocking and testing.
- After modifying `.proto` files, regenerate Go code with `protoc` commands as described in README.md. Never edit generated `.pb.go` files manually.
- Use meaningful variable names and add comments for complex logic.
- Handle errors explicitly; use Go's error handling patterns.
- For HTTP endpoints, ensure proper cookie handling for cart IDs.
- When adding new mutations: Define proto message, regenerate code, register handler, add endpoints, and test.
- Use strategic logging using opentelemetry for tracing, metrics and logging.
- Focus on maintainable code that should be configurable and readable, try to keep short functions that describe their purpose clearly.
## Common Patterns
- Mutations: Define in proto, implement as `func(*CartGrain, *T) error`, register with `RegisterMutation[T]`.
- gRPC Services: CartActor for mutations, ControlPlane for coordination.
- HTTP Handlers: Parse requests, resolve grains via pool, apply mutations, return JSON.
## Avoid
- Direct state mutations outside the registry.
- Hardcoding values; use configuration or constants.
- Ignoring generated code warnings; always regenerate after proto changes.
- Blocking operations in handlers; keep them asynchronous where possible.
## Testing
- Write unit tests for mutations and handlers.
- Use integration tests for API endpoints.
- Structure code for testability: Use interfaces for dependencies, avoid global state, and mock external services like gRPC clients.
- Run `go test ./...` to ensure all tests pass.
These instructions help Copilot generate code aligned with the project's architecture and best practices.

3
.gitignore vendored
View File

@@ -1,5 +1,4 @@
__debug*
go-cart-actor
data/*.prot
data/*.go*
data/se/*
data/*.go*

View File

@@ -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.

View File

@@ -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,30 +52,20 @@ COPY . .
# proto/*.proto
# Build with minimal binary size and embedded metadata
RUN go build -trimpath -ldflags="-s -w \
RUN --mount=type=cache,target=/go/build-cache \
go build -trimpath -ldflags="-s -w \
-X main.Version=${VERSION} \
-X main.GitCommit=${GIT_COMMIT} \
-X main.BuildDate=${BUILD_DATE}" \
-o /out/go-cart-actor ./cmd/cart
RUN go build -trimpath -ldflags="-s -w \
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-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
############################
# Runtime Stage
############################
@@ -85,9 +74,7 @@ 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

View File

@@ -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)/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=./pkg/messages --go_opt=paths=source_relative \
--go-grpc_out=./pkg/messages --go-grpc_opt=paths=source_relative \
$(PROTOS)
@echo "$(GREEN)Protobuf generation complete.$(RESET)"
clean_proto:
@echo "$(YELLOW)Removing generated protobuf files...$(RESET)"
@rm -f $(PROTO_DIR)/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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

BIN
cart Executable file

Binary file not shown.

View File

@@ -5,37 +5,28 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"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"
"git.tornberg.me/go-cart-actor/pkg/actor"
"git.tornberg.me/go-cart-actor/pkg/cart"
)
type FileServer struct {
// Define fields here
dataDir string
checkoutDataDir string
storage actor.LogStorage[cart.CartGrain]
checkoutStorage actor.LogStorage[checkout.CheckoutGrain]
dataDir string
storage actor.LogStorage[cart.CartGrain]
}
func NewFileServer(dataDir string, checkoutDataDir string, storage actor.LogStorage[cart.CartGrain], checkoutStorage actor.LogStorage[checkout.CheckoutGrain]) *FileServer {
func NewFileServer(dataDir string) *FileServer {
return &FileServer{
dataDir: dataDir,
checkoutDataDir: checkoutDataDir,
storage: storage,
checkoutStorage: checkoutStorage,
dataDir: dataDir,
}
}
@@ -60,47 +51,17 @@ func isValidFileId(name string) (uint64, bool) {
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
// }
var cartFileRe = regexp.MustCompile(`^(\d+)\.events\.log$`)
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) {
func listCartFiles(dir string) ([]CartFileInfo, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []*CartFileInfo{}, nil
return []CartFileInfo{}, nil
}
return nil, err
}
out := make([]*CartFileInfo, 0)
out := make([]CartFileInfo, 0)
for _, e := range entries {
if e.IsDir() {
continue
@@ -115,40 +76,13 @@ func listCartFiles(dir string) ([]*CartFileInfo, error) {
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),
}))
out = append(out, CartFileInfo{
ID: fmt.Sprintf("%d", id),
CartId: cart.CartId(id),
Size: info.Size(),
Modified: info.ModTime(),
System: info.Sys(),
})
}
return out, nil
}
@@ -199,113 +133,10 @@ func (fs *FileServer) CartsHandler(w http.ResponseWriter, r *http.Request) {
})
}
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 == "" {
@@ -315,31 +146,11 @@ func (fs *FileServer) CartHandler(w http.ResponseWriter, r *http.Request) {
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)
err := fs.storage.LoadEvents(id, grain)
if err != nil {
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
return
@@ -360,80 +171,15 @@ func (fs *FileServer) CartHandler(w http.ResponseWriter, r *http.Request) {
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,
"id": id,
"cartId": cart.CartId(id).String(),
"state": grain,
"rawLog": lines,
"meta": map[string]any{
"size": info.Size(),
"modified": info.ModTime(),
"path": path,
"system": info.Sys(),
},
})
}

View File

@@ -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")
}
}

View File

@@ -2,20 +2,16 @@ 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"
actor "git.tornberg.me/go-cart-actor/pkg/actor"
"git.tornberg.me/go-cart-actor/pkg/cart"
"github.com/matst80/slask-finder/pkg/messaging"
amqp "github.com/rabbitmq/amqp091-go"
"github.com/redis/go-redis/v9"
)
type CartFileInfo struct {
@@ -23,13 +19,7 @@ type CartFileInfo struct {
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"`
System any `json:"system"`
}
func envOrDefault(key, def string) string {
@@ -39,6 +29,8 @@ func envOrDefault(key, def string) string {
return def
}
var globalDisk *actor.DiskStorage[cart.CartGrain]
func startMutationConsumer(ctx context.Context, conn *amqp.Connection, hub *Hub) error {
ch, err := conn.Channel()
if err != nil {
@@ -82,36 +74,17 @@ func startMutationConsumer(ctx context.Context, conn *amqp.Connection, hub *Hub)
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)
reg := cart.NewCartMultationRegistry()
globalDisk = 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)
fs := NewFileServer(dataDir)
hub := NewHub()
go hub.Run()
@@ -119,38 +92,6 @@ func main() {
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)
@@ -181,7 +122,7 @@ func main() {
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadTimeout: 15 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
@@ -192,7 +133,7 @@ func main() {
if amqpURL != "" {
conn, err := amqp.Dial(amqpURL)
if err != nil {
log.Fatalf("failed to connect to RabbitMQ: %v", err)
log.Fatalf("failed to connect to RabbitMQ: %w", err)
}
if err := startMutationConsumer(ctx, conn, hub); err != nil {
log.Printf("AMQP listener disabled: %v", err)

View File

@@ -9,8 +9,7 @@ import (
)
type AmqpOrderHandler struct {
conn *amqp.Connection
queue *amqp.Queue
conn *amqp.Connection
}
func NewAmqpOrderHandler(conn *amqp.Connection) *AmqpOrderHandler {
@@ -19,25 +18,26 @@ func NewAmqpOrderHandler(conn *amqp.Connection) *AmqpOrderHandler {
}
}
func (h *AmqpOrderHandler) DefineQueue() error {
func (h *AmqpOrderHandler) DefineTopics() 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,
err = ch.ExchangeDeclare(
"orders", // name
"direct", // type
true, // durable
false, // auto-deleted
false, // internal
false, // no-wait
nil, // arguments
)
if err != nil {
return fmt.Errorf("failed to declare an exchange: %w", err)
}
h.queue = &queue
return nil
}
@@ -51,12 +51,11 @@ func (h *AmqpOrderHandler) OrderCompleted(body []byte) error {
defer cancel()
return ch.PublishWithContext(ctx,
"", // exchange
h.queue.Name, // routing key
false, // mandatory
false, // immediate
"orders", // exchange
"new", // routing key
false, // mandatory
false, // immediate
amqp.Publishing{
//DeliveryMode: amqp.,
ContentType: "application/json",
Body: body,
})

View File

@@ -0,0 +1,121 @@
package main
import (
"encoding/json"
"fmt"
"git.tornberg.me/go-cart-actor/pkg/cart"
)
// CheckoutMeta carries the external / URL metadata required to build a
// Klarna CheckoutOrder from a CartGrain snapshot. It deliberately excludes
// any Klarna-specific response fields (HTML snippet, client token, etc.).
type CheckoutMeta struct {
Terms string
Checkout string
Confirmation string
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 *cart.CartGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
if grain == nil {
return nil, nil, fmt.Errorf("nil grain")
}
if meta == nil {
return nil, nil, fmt.Errorf("nil checkout meta")
}
currency := meta.Currency
if currency == "" {
currency = "SEK"
}
locale := meta.Locale
if locale == "" {
locale = "sv-se"
}
country := meta.Country
if country == "" {
country = "SE" // sensible default; adjust if multi-country support changes
}
lines := make([]*Line, 0, len(grain.Items)+len(grain.Deliveries))
// Item lines
for _, it := range grain.Items {
if it == nil {
continue
}
lines = append(lines, &Line{
Type: "physical",
Reference: it.Sku,
Name: it.Meta.Name,
Quantity: it.Quantity,
UnitPrice: int(it.Price.IncVat),
TaxRate: 2500, // TODO: derive if variable tax rates are introduced
QuantityUnit: "st",
TotalAmount: int(it.TotalPrice.IncVat),
TotalTaxAmount: int(it.TotalPrice.TotalVat()),
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", it.Meta.Image),
})
}
// Delivery lines
for _, d := range grain.Deliveries {
if d == nil || d.Price.IncVat <= 0 {
continue
}
lines = append(lines, &Line{
Type: "shipping_fee",
Reference: d.Provider,
Name: "Delivery",
Quantity: 1,
UnitPrice: int(d.Price.IncVat),
TaxRate: 2500,
QuantityUnit: "st",
TotalAmount: int(d.Price.IncVat),
TotalTaxAmount: int(d.Price.TotalVat()),
})
}
order := &CheckoutOrder{
PurchaseCountry: country,
PurchaseCurrency: currency,
Locale: locale,
OrderAmount: int(grain.TotalPrice.IncVat),
OrderTaxAmount: int(grain.TotalPrice.TotalVat()),
OrderLines: lines,
MerchantReference1: grain.Id.String(),
MerchantURLS: &CheckoutMerchantURLS{
Terms: meta.Terms,
Checkout: meta.Checkout,
Confirmation: meta.Confirmation,
Validation: meta.Validation,
Push: meta.Push,
},
}
payload, err := json.Marshal(order)
if err != nil {
return nil, nil, fmt.Errorf("marshal checkout order: %w", err)
}
return payload, order, nil
}

View File

@@ -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())
}

View File

@@ -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
}

View File

@@ -1,28 +1,30 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"net/http"
"net/http/pprof"
"os"
"os/signal"
"strings"
"syscall"
"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"
"git.tornberg.me/go-cart-actor/pkg/actor"
"git.tornberg.me/go-cart-actor/pkg/cart"
"git.tornberg.me/go-cart-actor/pkg/discovery"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"git.tornberg.me/go-cart-actor/pkg/proxy"
"git.tornberg.me/go-cart-actor/pkg/voucher"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
amqp "github.com/rabbitmq/amqp091-go"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
var (
@@ -30,6 +32,14 @@ var (
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 init() {
@@ -37,15 +47,26 @@ func init() {
}
type App struct {
pool *actor.SimpleGrainPool[cart.CartGrain]
server *PoolServer
pool *actor.SimpleGrainPool[cart.CartGrain]
}
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 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") {
@@ -57,6 +78,23 @@ func getCountryFromHost(host string) string {
return ""
}
func GetDiscovery() discovery.Discovery {
if podIp == "" {
return nil
}
config, kerr := rest.InClusterConfig()
if kerr != nil {
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
}
client, err := kubernetes.NewForConfig(config)
if err != nil {
log.Fatalf("Error creating client: %v\n", err)
}
return discovery.NewK8sDiscovery(client)
}
type MutationContext struct {
VoucherService voucher.Service
}
@@ -66,122 +104,28 @@ type CartChangeEvent struct {
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
}),
)
reg := cart.NewCartMultationRegistry()
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()
Spawn: func(id uint64) (actor.Grain[cart.CartGrain], error) {
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)
}
}
}
}
}
err := diskStorage.LoadEvents(id, ret)
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, error) {
return proxy.NewRemoteHost(host)
},
SpawnHost: func(host string) (actor.Host[cart.CartGrain], error) {
return proxy.NewRemoteHost[cart.CartGrain](host)
},
TTL: 5 * time.Minute,
TTL: 15 * time.Minute,
PoolSize: 2 * 65535,
Hostname: podIp,
}
@@ -190,46 +134,80 @@ func main() {
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,
pool: pool,
}
mux := http.NewServeMux()
debugMux := http.NewServeMux()
conn, err := amqp.Dial(amqpUrl)
if err != nil {
fmt.Errorf("failed to connect to RabbitMQ: %w", err)
}
grpcSrv, err := actor.NewControlServer[cart.CartGrain](controlPlaneConfig, pool)
amqpListener := actor.NewAmqpListener(conn, func(id uint64, msg []actor.ApplyResult) (any, error) {
return &CartChangeEvent{
CartId: cart.CartId(id),
Mutations: msg,
}, nil
})
amqpListener.DefineTopics()
pool.AddListener(amqpListener)
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()
go func(hw discovery.Discovery) {
if hw == nil {
log.Print("No discovery service available")
return
}
ch, err := hw.Watch()
if err != nil {
log.Printf("Discovery error: %v", err)
return
}
for evt := range ch {
if evt.Host == "" {
continue
}
switch evt.Type {
case watch.Deleted:
if pool.IsKnown(evt.Host) {
pool.RemoveHost(evt.Host)
}
default:
if !pool.IsKnown(evt.Host) {
log.Printf("Discovered host %s", evt.Host)
pool.AddRemote(evt.Host)
}
}
}
}(GetDiscovery())
otelShutdown, err := setupOTelSDK(ctx)
if err != nil {
log.Fatalf("Unable to start otel %v", err)
}
orderHandler := NewAmqpOrderHandler(conn)
orderHandler.DefineTopics()
klarnaClient := NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
syncedServer.Serve(mux)
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient)
mux := http.NewServeMux()
mux.Handle("/cart/", http.StripPrefix("/cart", syncedServer.Serve()))
// only for local
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
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("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)
grainCount, capacity := app.pool.LocalUsage()
@@ -255,6 +233,152 @@ func main() {
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
}
parsed, ok := cart.ParseCartId(cookie.Value)
if !ok {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("invalid cart id format"))
return
}
cartId := parsed
syncedServer.ProxyHandler(func(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
order, err = syncedServer.CreateOrUpdateCheckout(r.Host, cartId)
if err != nil {
return err
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, tpl, order.HTMLSnippet)
return nil
})(cartId, w, r)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
// v2: Apply now returns *CartGrain; order creation handled inside grain (no payload to unmarshal)
} else {
order, err = klarnaClient.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)
fmt.Fprintf(w, tpl, order.HTMLSnippet)
}
})
mux.HandleFunc("/confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) {
orderId := r.PathValue("order_id")
order, err := klarnaClient.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)
fmt.Fprintf(w, tpl, order.HTMLSnippet)
})
mux.HandleFunc("/validate", func(w http.ResponseWriter, r *http.Request) {
log.Printf("Klarna order validation, method: %s", r.Method)
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 := klarnaClient.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(syncedServer, order)
if err != nil {
log.Printf("Error processing cart message: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
err = klarnaClient.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"))
@@ -262,54 +386,49 @@ func main() {
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, "/"),
}
sigs := make(chan os.Signal, 1)
done := make(chan bool, 1)
signal.Notify(sigs, syscall.SIGTERM)
defer func() {
fmt.Println("Shutting down due to signal")
otelShutdown(context.Background())
go func() {
sig := <-sigs
fmt.Println("Shutting down due to signal:", sig)
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)
}
done <- true
}()
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()
}
go http.ListenAndServe(":8080", mux)
<-done
}
func triggerOrderCompleted(syncedServer *PoolServer, order *CheckoutOrder) error {
mutation := &messages.OrderCreated{
OrderId: order.ID,
Status: order.Status,
}
cid, ok := cart.ParseCartId(order.MerchantReference1)
if !ok {
return fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
}
_, applyErr := syncedServer.Apply(uint64(cid), mutation)
return applyErr
}
func confirmOrder(order *CheckoutOrder, orderHandler *AmqpOrderHandler) error {
orderToSend, err := json.Marshal(order)
if err != nil {
return err
}
err = orderHandler.OrderCompleted(orderToSend)
if err != nil {
return err
}
return nil
}

View File

@@ -7,7 +7,7 @@
},
"servers": [
{
"url": "https://cart.k6n.net",
"url": "https://cart.tornberg.me",
"description": "Production server"
},
{
@@ -16,7 +16,7 @@
}
],
"paths": {
"/cart": {
"/cart/": {
"get": {
"summary": "Get (or create) current cart (cookie based)",
"description": "Returns the current cart. If no cartid cookie is present a new cart is created and Set-Cart-Id response header plus a Set-Cookie header are sent.",
@@ -263,188 +263,6 @@
}
}
},
"/cart/voucher": {
"put": {
"summary": "Add voucher to cart",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/AddVoucherRequest" }
}
}
},
"responses": {
"200": {
"description": "Voucher added",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body" },
"500": { "description": "Server error" }
}
}
},
"/cart/subscription-details": {
"put": {
"summary": "Upsert subscription details",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpsertSubscriptionDetails"
}
}
}
},
"responses": {
"200": {
"description": "Subscription details updated",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body" },
"500": { "description": "Server error" }
}
}
},
"/cart/voucher/{voucherId}": {
"delete": {
"summary": "Remove voucher from cart",
"parameters": [{ "$ref": "#/components/parameters/VoucherIdParam" }],
"responses": {
"200": {
"description": "Voucher removed",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid id" },
"500": { "description": "Server error" }
}
}
},
"/cart/user": {
"put": {
"summary": "Set user ID for cart",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/SetUserIdRequest" }
}
}
},
"responses": {
"200": {
"description": "User ID set",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body" },
"500": { "description": "Server error" }
}
}
},
"/cart/item/{itemId}/marking": {
"put": {
"summary": "Set marking for line item",
"parameters": [
{
"name": "itemId",
"in": "path",
"required": true,
"schema": { "type": "integer", "format": "int64", "minimum": 0 },
"description": "Internal cart line item identifier."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LineItemMarkingRequest"
}
}
}
},
"responses": {
"200": {
"description": "Marking set",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body or id" },
"500": { "description": "Server error" }
}
},
"delete": {
"summary": "Remove marking from line item",
"parameters": [
{
"name": "itemId",
"in": "path",
"required": true,
"schema": { "type": "integer", "format": "int64", "minimum": 0 },
"description": "Internal cart line item identifier."
}
],
"responses": {
"200": {
"description": "Marking removed",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid id" },
"500": { "description": "Server error" }
}
}
},
"/cart/checkout-order": {
"post": {
"summary": "Create checkout order",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateCheckoutOrderRequest"
}
}
}
},
"responses": {
"200": {
"description": "Checkout order created",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body" },
"500": { "description": "Server error" }
}
}
},
"/cart/byid/{id}": {
"get": {
"summary": "Get cart by explicit id",
@@ -634,169 +452,6 @@
}
}
},
"/cart/byid/{id}/voucher": {
"put": {
"summary": "Add voucher (by id variant)",
"parameters": [{ "$ref": "#/components/parameters/CartIdParam" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/AddVoucherRequest" }
}
}
},
"responses": {
"200": {
"description": "Voucher added",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body" },
"500": { "description": "Server error" }
}
}
},
"/cart/byid/{id}/voucher/{voucherId}": {
"delete": {
"summary": "Remove voucher (by id variant)",
"parameters": [
{ "$ref": "#/components/parameters/CartIdParam" },
{ "$ref": "#/components/parameters/VoucherIdParam" }
],
"responses": {
"200": {
"description": "Voucher removed",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid ids" },
"500": { "description": "Server error" }
}
}
},
"/cart/byid/{id}/user": {
"put": {
"summary": "Set user ID (by id variant)",
"parameters": [{ "$ref": "#/components/parameters/CartIdParam" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/SetUserIdRequest" }
}
}
},
"responses": {
"200": {
"description": "User ID set",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body" },
"500": { "description": "Server error" }
}
}
},
"/cart/byid/{id}/item/{itemId}/marking": {
"put": {
"summary": "Set marking (by id variant)",
"parameters": [
{ "$ref": "#/components/parameters/CartIdParam" },
{
"name": "itemId",
"in": "path",
"required": true,
"schema": { "type": "integer", "format": "int64", "minimum": 0 },
"description": "Internal cart line item identifier."
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LineItemMarkingRequest"
}
}
}
},
"responses": {
"200": {
"description": "Marking set",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body or ids" },
"500": { "description": "Server error" }
}
},
"delete": {
"summary": "Remove marking (by id variant)",
"parameters": [
{ "$ref": "#/components/parameters/CartIdParam" },
{
"name": "itemId",
"in": "path",
"required": true,
"schema": { "type": "integer", "format": "int64", "minimum": 0 },
"description": "Internal cart line item identifier."
}
],
"responses": {
"200": {
"description": "Marking removed",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid ids" },
"500": { "description": "Server error" }
}
}
},
"/cart/byid/{id}/checkout-order": {
"post": {
"summary": "Create checkout order (by id variant)",
"parameters": [{ "$ref": "#/components/parameters/CartIdParam" }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateCheckoutOrderRequest"
}
}
}
},
"responses": {
"200": {
"description": "Checkout order created",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CartGrain" }
}
}
},
"400": { "description": "Invalid body" },
"500": { "description": "Server error" }
}
}
},
"/healthz": {
"get": {
"summary": "Liveness & capacity probe",
@@ -862,52 +517,9 @@
"in": "path",
"required": true,
"schema": { "type": "integer", "format": "int64", "minimum": 0 }
},
"VoucherIdParam": {
"name": "voucherId",
"in": "path",
"required": true,
"schema": { "type": "integer", "format": "int64", "minimum": 0 }
}
},
"schemas": {
"Price": {
"type": "object",
"properties": {
"exVat": { "type": "integer", "format": "int64" },
"incVat": { "type": "integer", "format": "int64" },
"vat": {
"type": "object",
"additionalProperties": { "type": "integer", "format": "int64" }
}
},
"required": ["exVat", "incVat"]
},
"ItemMeta": {
"type": "object",
"properties": {
"name": { "type": "string" },
"brand": { "type": "string" },
"category": { "type": "string" },
"category2": { "type": "string" },
"category3": { "type": "string" },
"category4": { "type": "string" },
"category5": { "type": "string" },
"sellerId": { "type": "string" },
"sellerName": { "type": "string" },
"image": { "type": "string" },
"outlet": { "type": "string", "nullable": true }
}
},
"ConfirmationStatus": {
"type": "object",
"properties": {
"code": { "type": "string", "nullable": true },
"viewCount": { "type": "integer" },
"lastViewedAt": { "type": "string", "format": "date-time" }
},
"required": ["viewCount", "lastViewedAt"]
},
"CartGrain": {
"type": "object",
"description": "Cart aggregate (actor state)",
@@ -920,9 +532,9 @@
"type": "array",
"items": { "$ref": "#/components/schemas/CartItem" }
},
"totalPrice": { "$ref": "#/components/schemas/Price" },
"totalPrice": { "type": "integer", "format": "int64" },
"totalTax": { "type": "integer", "format": "int64" },
"totalDiscount": { "$ref": "#/components/schemas/Price" },
"totalDiscount": { "type": "integer", "format": "int64" },
"deliveries": {
"type": "array",
"items": { "$ref": "#/components/schemas/CartDelivery" }
@@ -930,26 +542,7 @@
"processing": { "type": "boolean" },
"paymentInProgress": { "type": "boolean" },
"orderReference": { "type": "string" },
"paymentStatus": { "type": "string" },
"vouchers": {
"type": "array",
"items": { "$ref": "#/components/schemas/Voucher" }
},
"subscriptionDetails": {
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/SubscriptionDetails"
}
},
"userId": { "type": "string" },
"confirmation": { "$ref": "#/components/schemas/ConfirmationStatus" },
"checkoutOrderId": { "type": "string" },
"checkoutStatus": { "type": "string" },
"checkoutCountry": { "type": "string" },
"paymentDeclinedNotices": {
"type": "array",
"items": { "$ref": "#/components/schemas/Notice" }
}
"paymentStatus": { "type": "string" }
},
"required": ["id", "items", "totalPrice", "totalTax", "totalDiscount"]
},
@@ -960,33 +553,40 @@
"itemId": { "type": "integer" },
"parentId": { "type": "integer" },
"sku": { "type": "string" },
"price": { "$ref": "#/components/schemas/Price" },
"totalPrice": { "$ref": "#/components/schemas/Price" },
"orgPrice": { "$ref": "#/components/schemas/Price" },
"name": { "type": "string" },
"price": { "type": "integer", "format": "int64" },
"totalPrice": { "type": "integer", "format": "int64" },
"totalTax": { "type": "integer", "format": "int64" },
"orgPrice": { "type": "integer", "format": "int64" },
"stock": {
"type": "integer",
"description": "0=OutOfStock,1=LowStock,2=InStock"
},
"qty": { "type": "integer" },
"discount": { "$ref": "#/components/schemas/Price" },
"tax": { "type": "integer" },
"taxRate": { "type": "integer" },
"brand": { "type": "string" },
"category": { "type": "string" },
"category2": { "type": "string" },
"category3": { "type": "string" },
"category4": { "type": "string" },
"category5": { "type": "string" },
"disclaimer": { "type": "string" },
"sellerId": { "type": "string" },
"sellerName": { "type": "string" },
"type": { "type": "string", "description": "Article type" },
"storeId": { "type": "string", "nullable": true },
"meta": { "$ref": "#/components/schemas/ItemMeta" },
"saleStatus": { "type": "string" },
"marking": { "$ref": "#/components/schemas/Marking" },
"subscriptionDetailsId": { "type": "string" },
"orderReference": { "type": "string" },
"isSubscribed": { "type": "boolean" }
"image": { "type": "string" },
"outlet": { "type": "string", "nullable": true },
"storeId": { "type": "string", "nullable": true }
},
"required": ["id", "sku", "price", "qty"]
"required": ["id", "sku", "name", "price", "qty", "tax"]
},
"CartDelivery": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"provider": { "type": "string" },
"price": { "$ref": "#/components/schemas/Price" },
"price": { "type": "integer", "format": "int64" },
"items": {
"type": "array",
"items": { "type": "integer" }
@@ -1070,88 +670,6 @@
"pickupPoint": { "$ref": "#/components/schemas/PickupPoint" }
},
"required": ["provider", "items"]
},
"AddVoucherRequest": {
"type": "object",
"properties": {
"code": { "type": "string" }
},
"required": ["code"]
},
"UpsertSubscriptionDetails": {
"type": "object",
"properties": {
"id": { "type": "string" },
"offeringCode": { "type": "string" },
"signingType": { "type": "string" },
"data": { "type": "object" }
},
"required": ["offeringCode", "signingType"]
},
"Voucher": {
"type": "object",
"properties": {
"code": { "type": "string" },
"applied": { "type": "boolean" },
"rules": {
"type": "array",
"items": { "type": "string" }
},
"description": { "type": "string" },
"id": { "type": "integer", "format": "int64" },
"value": { "type": "integer", "format": "int64" }
},
"required": ["code", "applied", "rules", "id", "value"]
},
"SubscriptionDetails": {
"type": "object",
"properties": {
"id": { "type": "string" },
"offeringCode": { "type": "string" },
"signingType": { "type": "string" },
"data": { "type": "object" }
},
"required": ["id"]
},
"Marking": {
"type": "object",
"properties": {
"type": { "type": "integer" },
"text": { "type": "string" }
},
"required": ["type", "text"]
},
"Notice": {
"type": "object",
"properties": {
"timestamp": { "type": "string", "format": "date-time" },
"message": { "type": "string" },
"code": { "type": "string", "nullable": true }
},
"required": ["timestamp", "message"]
},
"SetUserIdRequest": {
"type": "object",
"properties": {
"userId": { "type": "string" }
},
"required": ["userId"]
},
"LineItemMarkingRequest": {
"type": "object",
"properties": {
"type": { "type": "integer" },
"marking": { "type": "string" }
},
"required": ["type", "marking"]
},
"CreateCheckoutOrderRequest": {
"type": "object",
"properties": {
"terms": { "type": "string" },
"country": { "type": "string" }
},
"required": ["terms", "country"]
}
}
},

View File

@@ -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
}

View File

@@ -1,69 +1,42 @@
package main
import (
"context"
"bytes"
"encoding/json"
"io"
"fmt"
"log"
"net/http"
"strconv"
"sync"
"time"
"git.k6n.net/go-cart-actor/pkg/actor"
"git.k6n.net/go-cart-actor/pkg/cart"
messages "git.k6n.net/go-cart-actor/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",
})
"git.tornberg.me/go-cart-actor/pkg/actor"
"git.tornberg.me/go-cart-actor/pkg/cart"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"git.tornberg.me/go-cart-actor/pkg/voucher"
"github.com/gogo/protobuf/proto"
)
type PoolServer struct {
actor.GrainPool[cart.CartGrain]
pod_name string
inventoryService inventory.InventoryService
reservationService inventory.CartReservationService
actor.GrainPool[*cart.CartGrain]
pod_name string
klarnaClient *KlarnaClient
}
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,
func NewPoolServer(pool actor.GrainPool[*cart.CartGrain], pod_name string, klarnaClient *KlarnaClient) *PoolServer {
return &PoolServer{
GrainPool: pool,
pod_name: pod_name,
klarnaClient: klarnaClient,
}
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) ApplyLocal(id cart.CartId, mutation ...proto.Message) (*actor.MutationResult[*cart.CartGrain], error) {
return s.Apply(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))
grain, err := s.Get(uint64(id))
if err != nil {
return err
}
@@ -73,16 +46,14 @@ func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id c
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)
msg, err := GetItemAddMessage(sku, 1, getCountryFromHost(r.Host), nil)
if err != nil {
return err
}
data, err := s.ApplyLocal(r.Context(), id, msg)
data, err := s.ApplyLocal(id, msg)
if err != nil {
return err
}
grainMutations.Add(float64(len(data.Mutations)))
return s.WriteResult(w, data)
}
@@ -110,20 +81,85 @@ func (s *PoolServer) DeleteItemHandler(w http.ResponseWriter, r *http.Request, i
if err != nil {
return err
}
data, err := s.ApplyLocal(r.Context(), id, &messages.RemoveItem{Id: uint32(itemId)})
data, err := s.ApplyLocal(id, &messages.RemoveItem{Id: uint32(itemId)})
if err != nil {
return err
}
return s.WriteResult(w, data)
}
type SetDeliveryRequest struct {
Provider string `json:"provider"`
Items []uint32 `json:"items"`
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
}
func (s *PoolServer) SetDeliveryHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
delivery := SetDeliveryRequest{}
err := json.NewDecoder(r.Body).Decode(&delivery)
if err != nil {
return err
}
data, err := s.ApplyLocal(id, &messages.SetDelivery{
Provider: delivery.Provider,
Items: delivery.Items,
PickupPoint: delivery.PickupPoint,
})
if err != nil {
return err
}
return s.WriteResult(w, data)
}
func (s *PoolServer) SetPickupPointHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
deliveryIdString := r.PathValue("deliveryId")
deliveryId, err := strconv.ParseInt(deliveryIdString, 10, 64)
if err != nil {
return err
}
pickupPoint := messages.PickupPoint{}
err = json.NewDecoder(r.Body).Decode(&pickupPoint)
if err != nil {
return err
}
reply, err := s.ApplyLocal(id, &messages.SetPickupPoint{
DeliveryId: uint32(deliveryId),
Id: pickupPoint.Id,
Name: pickupPoint.Name,
Address: pickupPoint.Address,
City: pickupPoint.City,
Zip: pickupPoint.Zip,
Country: pickupPoint.Country,
})
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) RemoveDeliveryHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
deliveryIdString := r.PathValue("deliveryId")
deliveryId, err := strconv.Atoi(deliveryIdString)
if err != nil {
return err
}
reply, err := s.ApplyLocal(id, &messages.RemoveDelivery{Id: uint32(deliveryId)})
if err != nil {
return err
}
return s.WriteResult(w, reply)
}
func (s *PoolServer) QuantityChangeHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
changeQuantity := messages.ChangeQuantity{}
err := json.NewDecoder(r.Body).Decode(&changeQuantity)
if err != nil {
return err
}
reply, err := s.ApplyLocal(r.Context(), id, &changeQuantity)
reply, err := s.ApplyLocal(id, &changeQuantity)
if err != nil {
return err
}
@@ -141,14 +177,14 @@ type SetCartItems struct {
Items []Item `json:"items"`
}
func getMultipleAddMessages(ctx context.Context, items []Item, country string) []proto.Message {
func getMultipleAddMessages(items []Item, country string) []proto.Message {
wg := sync.WaitGroup{}
mu := sync.Mutex{}
msgs := make([]proto.Message, 0, len(items))
for _, itm := range items {
wg.Go(
func() {
msg, err := GetItemAddMessage(ctx, itm.Sku, itm.Quantity, country, itm.StoreId)
msg, err := GetItemAddMessage(itm.Sku, itm.Quantity, country, itm.StoreId)
if err != nil {
log.Printf("error adding item %s: %v", itm.Sku, err)
return
@@ -171,9 +207,9 @@ func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request,
msgs := make([]proto.Message, 0, len(setCartItems.Items)+1)
msgs = append(msgs, &messages.ClearCartRequest{})
msgs = append(msgs, getMultipleAddMessages(r.Context(), setCartItems.Items, setCartItems.Country)...)
msgs = append(msgs, getMultipleAddMessages(setCartItems.Items, setCartItems.Country)...)
reply, err := s.ApplyLocal(r.Context(), id, msgs...)
reply, err := s.ApplyLocal(id, msgs...)
if err != nil {
return err
}
@@ -187,9 +223,7 @@ func (s *PoolServer) AddMultipleItemHandler(w http.ResponseWriter, r *http.Reque
return err
}
msgs := getMultipleAddMessages(r.Context(), setCartItems.Items, setCartItems.Country)
reply, err := s.ApplyLocal(r.Context(), id, msgs...)
reply, err := s.ApplyLocal(id, getMultipleAddMessages(setCartItems.Items, setCartItems.Country)...)
if err != nil {
return err
}
@@ -209,12 +243,11 @@ func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request
if err != nil {
return err
}
msg, err := GetItemAddMessage(r.Context(), addRequest.Sku, int(addRequest.Quantity), addRequest.Country, addRequest.StoreId)
msg, err := GetItemAddMessage(addRequest.Sku, int(addRequest.Quantity), addRequest.Country, addRequest.StoreId)
if err != nil {
return err
}
reply, err := s.ApplyLocal(r.Context(), id, msg)
reply, err := s.ApplyLocal(id, msg)
if err != nil {
return err
}
@@ -241,6 +274,61 @@ func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request
// return json.NewEncoder(w).Encode(order)
// }
func getCurrency(country string) string {
if country == "no" {
return "NOK"
}
return "SEK"
}
func getLocale(country string) string {
if country == "no" {
return "nb-no"
}
return "sv-se"
}
func (s *PoolServer) CreateOrUpdateCheckout(host string, id cart.CartId) (*CheckoutOrder, error) {
country := getCountryFromHost(host)
meta := &CheckoutMeta{
Terms: fmt.Sprintf("https://%s/terms", host),
Checkout: fmt.Sprintf("https://%s/checkout?order_id={checkout.order.id}", host),
Confirmation: fmt.Sprintf("https://%s/confirmation/{checkout.order.id}", host),
Validation: fmt.Sprintf("https://%s/validate", host),
Push: fmt.Sprintf("https://%s/push?order_id={checkout.order.id}", host),
Country: country,
Currency: getCurrency(country),
Locale: getLocale(country),
}
// Get current grain state (may be local or remote)
grain, err := s.Get(uint64(id))
if err != nil {
return nil, err
}
// Build pure checkout payload
payload, _, err := BuildCheckoutOrderPayload(grain, meta)
if err != nil {
return nil, err
}
if grain.OrderReference != "" {
return s.klarnaClient.UpdateOrder(grain.OrderReference, bytes.NewReader(payload))
} else {
return s.klarnaClient.CreateOrder(bytes.NewReader(payload))
}
}
func (s *PoolServer) ApplyCheckoutStarted(klarnaOrder *CheckoutOrder, id cart.CartId) (*actor.MutationResult[*cart.CartGrain], error) {
// Persist initialization state via mutation (best-effort)
return s.ApplyLocal(id, &messages.InitializeCheckout{
OrderId: klarnaOrder.ID,
Status: klarnaOrder.Status,
PaymentInProgress: true,
})
}
// func (s *PoolServer) HandleCheckout(w http.ResponseWriter, r *http.Request, id CartId) error {
// klarnaOrder, err := s.CreateOrUpdateCheckout(r.Host, id)
// if err != nil {
@@ -254,6 +342,51 @@ func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request
// }
//
func CookieCartIdHandler(fn func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var id cart.CartId
cookie, err := r.Cookie("cartid")
if err != nil || cookie.Value == "" {
id = cart.MustNewCartId()
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: id.String(),
Secure: r.TLS != nil,
HttpOnly: true,
Path: "/",
Expires: time.Now().AddDate(0, 0, 14),
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Set-Cart-Id", id.String())
} else {
parsed, ok := cart.ParseCartId(cookie.Value)
if !ok {
id = cart.MustNewCartId()
http.SetCookie(w, &http.Cookie{
Name: "cartid",
Value: id.String(),
Secure: r.TLS != nil,
HttpOnly: true,
Path: "/",
Expires: time.Now().AddDate(0, 0, 14),
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Set-Cart-Id", id.String())
} else {
id = parsed
}
}
err = fn(id, w, r)
if err != nil {
log.Printf("Server error, not remote error: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
}
}
// Removed leftover legacy block after CookieCartIdHandler (obsolete code referencing cid/legacy)
func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
@@ -271,49 +404,48 @@ func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, ca
return nil
}
func CartIdHandler(fn func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var id cart.CartId
raw := r.PathValue("id")
// If no id supplied, generate a new one
if raw == "" {
id := cart.MustNewCartId()
w.Header().Set("Set-Cart-Id", id.String())
} else {
// Parse base62 cart id
if parsedId, ok := cart.ParseCartId(raw); !ok {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("cart id is invalid"))
return
} else {
id = parsedId
}
}
err := fn(id, w, r)
if err != nil {
log.Printf("Server error, not remote error: %v\n", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
}
}
}
func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error) func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error {
return func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error {
if ownerHost, ok := s.OwnerHost(uint64(cartId)); ok {
ctx, span := tracer.Start(r.Context(), "proxy")
defer span.End()
span.SetAttributes(attribute.String("cartid", cartId.String()))
hostAttr := attribute.String("other host", ownerHost.Name())
span.SetAttributes(hostAttr)
logger.InfoContext(ctx, "cart proxyed", "result", ownerHost.Name())
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
handled, err := ownerHost.Proxy(uint64(cartId), w, r, nil)
grainLookups.Inc()
handled, err := ownerHost.Proxy(uint64(cartId), w, r)
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"`
}
@@ -328,41 +460,7 @@ func (s *PoolServer) AddVoucherHandler(w http.ResponseWriter, r *http.Request, c
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())
reply, err := s.ApplyLocal(cartId, msg)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
@@ -381,7 +479,7 @@ func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request
w.Write([]byte(err.Error()))
return err
}
reply, err := s.ApplyLocal(r.Context(), cartId, &messages.RemoveVoucher{Id: uint32(id)})
reply, err := s.ApplyLocal(cartId, &messages.RemoveVoucher{Id: uint32(id)})
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
@@ -391,140 +489,45 @@ func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request
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)))
func (s *PoolServer) Serve() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("OPTIONS /", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("GET /", CookieCartIdHandler(s.ProxyHandler(s.GetCartHandler)))
mux.HandleFunc("GET /add/{sku}", CookieCartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
mux.HandleFunc("POST /add", CookieCartIdHandler(s.ProxyHandler(s.AddMultipleItemHandler)))
mux.HandleFunc("POST /", CookieCartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
mux.HandleFunc("POST /set", CookieCartIdHandler(s.ProxyHandler(s.SetCartItemsHandler)))
mux.HandleFunc("DELETE /{itemId}", CookieCartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
mux.HandleFunc("PUT /", CookieCartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
mux.HandleFunc("DELETE /", CookieCartIdHandler(s.ProxyHandler(s.RemoveCartCookie)))
mux.HandleFunc("POST /delivery", CookieCartIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
mux.HandleFunc("DELETE /delivery/{deliveryId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
mux.HandleFunc("PUT /delivery/{deliveryId}/pickupPoint", CookieCartIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
mux.HandleFunc("PUT /voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
mux.HandleFunc("DELETE /voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
//mux.HandleFunc("GET /checkout", CookieCartIdHandler(s.ProxyHandler(s.HandleCheckout)))
//mux.HandleFunc("GET /confirmation/{orderId}", CookieCartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
mux.HandleFunc("GET /byid/{id}", CartIdHandler(s.ProxyHandler(s.GetCartHandler)))
mux.HandleFunc("GET /byid/{id}/add/{sku}", CartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
mux.HandleFunc("POST /byid/{id}", CartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
mux.HandleFunc("DELETE /byid/{id}/{itemId}", CartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
mux.HandleFunc("PUT /byid/{id}", CartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
mux.HandleFunc("POST /byid/{id}/delivery", CartIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
mux.HandleFunc("DELETE /byid/{id}/delivery/{deliveryId}", CartIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
mux.HandleFunc("PUT /byid/{id}/delivery/{deliveryId}/pickupPoint", CartIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
mux.HandleFunc("PUT /byid/{id}/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
mux.HandleFunc("DELETE /byid/{id}/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
//mux.HandleFunc("GET /byid/{id}/checkout", CartIdHandler(s.ProxyHandler(s.HandleCheckout)))
//mux.HandleFunc("GET /byid/{id}/confirmation", CartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
return mux
}

View File

@@ -1,4 +1,4 @@
package cart
package main
import (
"encoding/json"
@@ -33,14 +33,14 @@ func TestPriceMarshalJSON(t *testing.T) {
}
func TestNewPriceFromIncVat(t *testing.T) {
p := NewPriceFromIncVat(1250, 25)
if p.IncVat != 1250 {
t.Fatalf("expected IncVat %d got %d", 1250, p.IncVat)
p := NewPriceFromIncVat(1000, 0.25)
if p.IncVat != 1000 {
t.Fatalf("expected IncVat %d got %d", 1000, p.IncVat)
}
if p.VatRates[25] != 250 {
t.Fatalf("expected VAT 25 rate %d got %d", 250, p.VatRates[25])
}
if p.ValueExVat() != 1000 {
if p.ValueExVat() != 750 {
t.Fatalf("expected exVat %d got %d", 750, p.ValueExVat())
}
}
@@ -133,106 +133,3 @@ func TestPriceMultiplyMethod(t *testing.T) {
t.Fatalf("expected exVat %d got %d", exBefore*2, p.ValueExVat())
}
}
func TestGetTaxAmount(t *testing.T) {
tests := []struct {
total int64
tax int
expected int64
desc string
}{
{1250, 2500, 250, "25% VAT"}, // 1250 / (1 + 100/25) = 1250 / 5 = 250
{1000, 2000, 166, "20% VAT"}, // 1000 / (1 + 100/20) = 1000 / 6 ≈ 166
{1200, 2500, 240, "25% VAT on 1200"},
{0, 2500, 0, "zero total"},
{100, 1000, 9, "10% VAT"}, // tax=1000 for 10%, 100 / (1 + 100/10) = 100 / 11 ≈ 9
{100, 10000, 50, "100% VAT"}, // tax=10000 for 100%, 100 / (1 + 100/100) = 100 / 2 = 50
}
for _, tt := range tests {
result := GetTaxAmount(tt.total, tt.tax)
if result != tt.expected {
t.Errorf("GetTaxAmount(%d, %d) [%s] = %d; expected %d", tt.total, tt.tax, tt.desc, result, tt.expected)
}
}
}
func TestNewPriceFromIncVatEdgeCases(t *testing.T) {
// Zero VAT rate
p := NewPriceFromIncVat(1000, 0)
if p.IncVat != 1000 {
t.Errorf("expected IncVat 1000, got %d", p.IncVat)
}
if len(p.VatRates) != 1 || p.VatRates[0] != 0 {
t.Errorf("expected VAT 0 for rate 0, got %v", p.VatRates)
}
if p.ValueExVat() != 1000 {
t.Errorf("expected exVat 1000, got %d", p.ValueExVat())
}
// High VAT rate, e.g., 50%
p = NewPriceFromIncVat(1500, 50)
expectedVat := int64(1500 / (1 + 100/50)) // 1500 / 3 = 500
if p.VatRates[50] != expectedVat {
t.Errorf("expected VAT %d for 50%%, got %d", expectedVat, p.VatRates[50])
}
if p.ValueExVat() != 1500-expectedVat {
t.Errorf("expected exVat %d, got %d", 1500-expectedVat, p.ValueExVat())
}
}
func TestPriceValueExVatAndTotalVat(t *testing.T) {
p := Price{IncVat: 13700, VatRates: map[float32]int64{25: 2500, 12: 1200}}
exVat := p.ValueExVat()
totalVat := p.TotalVat()
if exVat != 10000 {
t.Errorf("expected exVat 10000, got %d", exVat)
}
if totalVat != 3700 {
t.Errorf("expected totalVat 3700, got %d", totalVat)
}
if exVat+totalVat != p.IncVat {
t.Errorf("exVat + totalVat should equal IncVat: %d + %d != %d", exVat, totalVat, p.IncVat)
}
// Empty VAT rates
p2 := Price{IncVat: 500, VatRates: nil}
if p2.ValueExVat() != 500 {
t.Errorf("expected exVat 500 for no VAT, got %d", p2.ValueExVat())
}
if p2.TotalVat() != 0 {
t.Errorf("expected totalVat 0, got %d", p2.TotalVat())
}
}
func TestMultiplyPriceWithZeroQty(t *testing.T) {
base := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}}
multiplied := MultiplyPrice(base, 0)
if multiplied.IncVat != 0 {
t.Errorf("expected IncVat 0, got %d", multiplied.IncVat)
}
if len(multiplied.VatRates) != 1 || multiplied.VatRates[25] != 0 {
t.Errorf("expected VAT 0, got %v", multiplied.VatRates)
}
}
func TestPriceAddSubtractEdgeCases(t *testing.T) {
a := Price{IncVat: 1000, VatRates: map[float32]int64{25: 200}}
b := Price{IncVat: 500, VatRates: map[float32]int64{12: 54}} // Different rate
acc := NewPrice()
acc.Add(a)
acc.Add(b)
if acc.VatRates[25] != 200 || acc.VatRates[12] != 54 {
t.Errorf("expected VAT 25:200, 12:54, got %v", acc.VatRates)
}
// Subtract more than added (negative VAT)
acc.Subtract(a)
acc.Subtract(b)
acc.Subtract(a) // Subtract extra a
if acc.VatRates[25] != -200 || acc.VatRates[12] != 0 {
t.Errorf("expected negative VAT for 25 after over-subtract, got %v", acc.VatRates)
}
}

View File

@@ -1,14 +1,14 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"git.k6n.net/go-cart-actor/pkg/cart"
messages "git.k6n.net/go-cart-actor/proto/cart"
"git.tornberg.me/go-cart-actor/pkg/cart"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"github.com/matst80/slask-finder/pkg/index"
)
@@ -26,16 +26,9 @@ func getBaseUrl(country string) string {
return "http://localhost:8082"
}
func FetchItem(ctx context.Context, sku string, country string) (*index.DataItem, error) {
func FetchItem(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)
res, err := http.Get(fmt.Sprintf("%s/api/by-sku/%s", baseUrl, sku))
if err != nil {
return nil, err
}
@@ -45,46 +38,36 @@ func FetchItem(ctx context.Context, sku string, country string) (*index.DataItem
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)
func GetItemAddMessage(sku string, qty int, country string, storeId *string) (*messages.AddItem, error) {
item, err := FetchItem(sku, country)
if err != nil {
return nil, err
}
return ToItemAddMessage(item, storeId, qty, country)
return ToItemAddMessage(item, storeId, qty, country), nil
}
func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country string) (*messages.AddItem, error) {
func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country string) *messages.AddItem {
orgPrice, _ := getInt(item.GetNumberFieldValue(5)) // getInt(item.Fields[5])
price, err := getInt(item.GetNumberFieldValue(4)) //Fields[4]
if err != nil {
return nil, err
return nil
}
stk := item.GetStock()
stock := cart.StockStatus(0)
centralStockValue, ok := item.GetStringFieldValue(3)
if storeId == nil {
centralStock, ok := stk[country]
if ok {
if !item.Buyable {
return nil, fmt.Errorf("item not available")
pureNumber := strings.Replace(centralStockValue, "+", "", -1)
if centralStock, err := strconv.ParseInt(pureNumber, 10, 64); err == nil {
stock = cart.StockStatus(centralStock)
}
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]
storeStock, ok := item.Stock.GetStock()[*storeId]
if ok {
stock = cart.StockStatus(storeStock)
}
}
articleType, _ := item.GetStringFieldValue(1) //.Fields[1].(string)
@@ -103,8 +86,6 @@ func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country st
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),
@@ -128,9 +109,7 @@ func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country st
Country: country,
Outlet: outlet,
StoreId: storeId,
SaleStatus: item.SaleStatus,
Cgm: cgm,
}, nil
}
}
func getTax(articleType string) int32 {

View File

@@ -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()))
}
}
}

View File

@@ -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(&notificationRequest); 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)
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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())
}

View File

@@ -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
}

View File

@@ -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()
}
}

View File

@@ -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
}

View File

@@ -1,557 +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) CancelPaymentHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
paymentId := r.PathValue("id")
result, err := s.ApplyLocal(r.Context(), checkoutId, &messages.CancelPayment{
PaymentId: paymentId,
CancelledAt: timestamppb.New(time.Now()),
})
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("DELETE /payment/{id}", CookieCheckoutIdHandler(s.ProxyHandler(s.CancelPaymentHandler)))
// 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)
}

View File

@@ -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)
}
}

View File

@@ -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))
}

View File

@@ -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)
}
})
}

View File

@@ -38,11 +38,13 @@ spec:
volumes:
- name: data
nfs:
path: /i-data/7a8af061/nfs/
path: /i-data/7a8af061/nfs/cart-actor
server: 10.10.1.10
imagePullSecrets:
- name: regcred
serviceAccountName: default
containers:
- image: registry.k6n.net/go-cart-actor-amd64:latest
- image: registry.knatofs.se/go-cart-actor-amd64:latest
name: cart-actor-amd64
imagePullPolicy: Always
command: ["/go-cart-backoffice"]
@@ -58,14 +60,14 @@ spec:
path: /livez
port: web
failureThreshold: 1
periodSeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /readyz
port: web
failureThreshold: 2
initialDelaySeconds: 2
periodSeconds: 30
periodSeconds: 10
volumeMounts:
- mountPath: "/data"
name: data
@@ -76,26 +78,8 @@ spec:
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:
@@ -145,9 +129,11 @@ spec:
nfs:
path: /i-data/7a8af061/nfs/cart-actor
server: 10.10.1.10
imagePullSecrets:
- name: regcred
serviceAccountName: default
containers:
- image: registry.k6n.net/go-cart-actor-amd64:latest
- image: registry.knatofs.se/go-cart-actor-amd64:latest
name: cart-actor-amd64
imagePullPolicy: Always
lifecycle:
@@ -157,8 +143,6 @@ spec:
ports:
- containerPort: 8080
name: web
- containerPort: 8081
name: debug
- containerPort: 1337
name: rpc
livenessProbe:
@@ -166,14 +150,14 @@ spec:
path: /livez
port: web
failureThreshold: 1
periodSeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /readyz
port: web
failureThreshold: 2
initialDelaySeconds: 2
periodSeconds: 50
periodSeconds: 10
volumeMounts:
- mountPath: "/data"
name: data
@@ -184,10 +168,6 @@ spec:
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
@@ -195,24 +175,6 @@ spec:
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:
@@ -269,9 +231,11 @@ spec:
nfs:
path: /i-data/7a8af061/nfs/cart-actor
server: 10.10.1.10
imagePullSecrets:
- name: regcred
serviceAccountName: default
containers:
- image: registry.k6n.net/go-cart-actor:latest
- image: registry.knatofs.se/go-cart-actor:latest
name: cart-actor-arm64
imagePullPolicy: Always
lifecycle:
@@ -281,8 +245,6 @@ spec:
ports:
- containerPort: 8080
name: web
- containerPort: 8081
name: debug
- containerPort: 1337
name: rpc
livenessProbe:
@@ -290,14 +252,14 @@ spec:
path: /livez
port: web
failureThreshold: 1
periodSeconds: 15
periodSeconds: 10
readinessProbe:
httpGet:
path: /readyz
port: web
failureThreshold: 2
initialDelaySeconds: 2
periodSeconds: 15
periodSeconds: 10
volumeMounts:
- mountPath: "/data"
name: data
@@ -310,24 +272,6 @@ spec:
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:
@@ -356,7 +300,7 @@ apiVersion: v1
metadata:
name: cart-actor
annotations:
prometheus.io/port: "8081"
prometheus.io/port: "8080"
prometheus.io/scrape: "true"
prometheus.io/path: "/metrics"
spec:
@@ -365,140 +309,6 @@ spec:
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
@@ -526,10 +336,10 @@ spec:
ingressClassName: nginx
tls:
- hosts:
- cart.k6n.net
- cart.tornberg.me
secretName: cart-actor-tls-secret
rules:
- host: cart.k6n.net
- host: cart.tornberg.me
http:
paths:
- path: /
@@ -539,20 +349,6 @@ spec:
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
@@ -564,10 +360,10 @@ spec:
ingressClassName: nginx
tls:
- hosts:
- slask-cart.k6n.net
- slask-cart.tornberg.me
secretName: cart-backoffice-actor-tls-secret
rules:
- host: slask-cart.k6n.net
- host: slask-cart.tornberg.me
http:
paths:
- path: /
@@ -577,85 +373,3 @@ spec:
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

118
go.mod
View File

@@ -1,117 +1,91 @@
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/gogo/protobuf v1.3.2
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/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // 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/getkin/kin-openapi v0.132.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/gogo/protobuf v1.3.2 // indirect
github.com/google/gnostic-models v0.7.1 // indirect
github.com/go-openapi/jsonpointer v0.22.1 // indirect
github.com/go-openapi/jsonreference v0.21.2 // indirect
github.com/go-openapi/swag v0.25.1 // indirect
github.com/go-openapi/swag/cmdutils v0.25.1 // indirect
github.com/go-openapi/swag/conv v0.25.1 // indirect
github.com/go-openapi/swag/fileutils v0.25.1 // indirect
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
github.com/go-openapi/swag/loading v0.25.1 // indirect
github.com/go-openapi/swag/mangling v0.25.1 // indirect
github.com/go-openapi/swag/netutils v0.25.1 // indirect
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/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/mailru/easyjson v0.7.7 // 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/oapi-codegen/oapi-codegen/v2 v2.5.0 // indirect
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pmezard/go-difflib v1.0.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/prometheus/common v0.67.1 // indirect
github.com/prometheus/procfs v0.17.0 // indirect
github.com/speakeasy-api/jsonpath v0.6.0 // indirect
github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
github.com/woodsbury/decimal128 v1.4.0 // 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/mod v0.28.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/oauth2 v0.32.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.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
golang.org/x/tools v0.37.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-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
)

330
go.sum
View File

@@ -1,80 +1,67 @@
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/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/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/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/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/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/getkin/kin-openapi v0.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/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.22.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 v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
@@ -82,24 +69,34 @@ github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncV
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/gnostic-models v0.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.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/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/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@@ -117,12 +114,10 @@ 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/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548 h1:lkxVz5lNPlU78El49cx1c3Rxo/bp7Gbzp2BjSRFgg1U=
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548/go.mod h1:k4lo5gFYb3AqrgftlraCnv95xvjB/w8udRqJQJ12mAE=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -135,164 +130,167 @@ 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/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 h1:iJvF8SdB/3/+eGOXEpsWkD8FQAHj6mqkb6Fnsoc8MFU=
github.com/oapi-codegen/oapi-codegen/v2 v2.5.0/go.mod h1:fwlMxUEMuQK5ih9aymrxKPQqNm2n8bdLk1ppjH+lr9w=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.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/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/speakeasy-api/jsonpath v0.6.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/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8=
github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw=
github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU=
github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.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/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.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/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.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.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.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 v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -309,29 +307,31 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.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=

View File

@@ -18,10 +18,10 @@ This directory contains a k6 script (`cart_load_test.js`) to stress and observe
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
1. `GET /cart/` ensure / fetch cart state (creates cart if missing; sets `cartid` & `cartowner` cookies)
2. `POST /cart/` add item mutation (random SKU & quantity)
3. `GET /cart/` fetch after mutations
4. `GET /cart/checkout` occasionally (~2% of iterations) to simulate checkout start
You can extend it easily to hit deliveries, quantity changes, or removal endpoints.
@@ -40,9 +40,9 @@ Example run:
```bash
k6 run \
-e BASE_URL=https://cart.k6n.net/cart \
-e BASE_URL=https://cart.prod.example.com/cart \
-e VUS=40 \
-e DURATION=1m \
-e DURATION=10m \
-e RAMP_TARGET=120 \
k6/cart_load_test.js
```
@@ -171,4 +171,4 @@ Feel free to request:
- WebSocket / long poll integration (if added later)
- Synthetic error injection harness
Happy load testing!
Happy load testing!

View File

@@ -4,39 +4,43 @@ 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,
// 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",
},
{
duration: "1m",
target: __ENV.RAMP_TARGET ? parseInt(__ENV.RAMP_TARGET, 10) : 50,
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",
},
{ 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)"],
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 ----------------
@@ -48,197 +52,197 @@ 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",
"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)];
return SKUS[Math.floor(Math.random() * SKUS.length)];
}
function randomQty() {
return 1 + Math.floor(Math.random() * 3); // 1..3
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";
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;
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 };
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,
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;
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);
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,
});
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,
});
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 });
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 });
// }
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 };
// Provide SKU list length for summary
return { skuCount: SKUS.length };
}
export default function (data) {
group("cart flow", () => {
// Create or reuse cart
ensureCart();
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();
}
// 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();
// Fetch state
fetchCart();
// Optional checkout attempt
maybeCheckout();
});
// Optional checkout attempt
maybeCheckout();
});
// Small think time
sleep(Math.random() * 0.5);
// 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}`);
// 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,
),
};
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,
),
};
}

View File

@@ -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
}

View File

@@ -1,7 +1,6 @@
package actor
import (
"context"
"errors"
"fmt"
"log"
@@ -10,7 +9,7 @@ import (
"sync"
"time"
"google.golang.org/protobuf/proto"
"github.com/gogo/protobuf/proto"
)
type QueueEvent struct {
@@ -26,8 +25,7 @@ type DiskStorage[V any] struct {
}
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
LoadEvents(id uint64, grain Grain[V]) error
AppendMutations(id uint64, msg ...proto.Message) error
}
@@ -88,7 +86,7 @@ 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 {
func (s *DiskStorage[V]) LoadEvents(id uint64, grain Grain[V]) error {
path := s.logPath(id)
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
// No log -> nothing to replay
@@ -100,36 +98,13 @@ func (s *DiskStorage[V]) LoadEventsFunc(ctx context.Context, id uint64, grain Gr
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)
return s.Load(fh, func(msg proto.Message) {
s.registry.Apply(grain, msg)
})
}
func (s *DiskStorage[V]) Close() {
if s.queue != nil {
s.save()
}
s.save()
close(s.done)
}

View File

@@ -1,11 +1,9 @@
package actor
import (
"context"
"io"
"net/http"
"google.golang.org/protobuf/proto"
"github.com/gogo/protobuf/proto"
)
type MutationResult[V any] struct {
@@ -14,30 +12,27 @@ type MutationResult[V any] struct {
}
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)
Apply(id uint64, mutation ...proto.Message) (*MutationResult[V], error)
Get(id uint64) (V, error)
OwnerHost(id uint64) (Host, bool)
Hostname() string
TakeOwnership(id uint64)
HandleOwnershipChange(host string, ids []uint64) error
HandleRemoteExpiry(host string, ids []uint64) error
Negotiate(otherHosts []string)
GetLocalIds() []uint64
IsHealthy() bool
Close()
IsKnown(string) bool
RemoveHost(host string)
AddRemoteHost(host string)
IsHealthy() bool
IsKnown(string) bool
Close()
}
// Host abstracts a remote node capable of proxying cart requests.
type Host[V any] interface {
type Host 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)
Proxy(id uint64, w http.ResponseWriter, r *http.Request) (bool, error)
GetActorIds() []uint64
Close() error
Ping() bool

View File

@@ -2,21 +2,14 @@ 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"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"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.
@@ -26,126 +19,24 @@ type ControlServer[V any] struct {
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))
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",
@@ -153,104 +44,30 @@ func (s *ControlServer[V]) AnnounceExpiry(ctx context.Context, req *messages.Exp
}
// 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)))
func (s *ControlServer[V]) Ping(ctx context.Context, _ *messages.Empty) (*messages.PingReply, error) {
// log.Printf("got ping")
return &messages.PingReply{
Host: host,
Host: s.pool.Hostname(),
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
func (s *ControlServer[V]) GetLocalActorIds(ctx context.Context, _ *messages.Empty) (*messages.ActorIdsReply, error) {
return &messages.ActorIdsReply{Ids: s.pool.GetLocalIds()}, nil
}
// ControlPlane: Closing (peer shutdown notification)
func (s *ControlServer[V]) Closing(ctx context.Context, req *messages.ClosingNotice) (*messages.OwnerChangeAck, error) {
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)
if req.GetHost() != "" {
s.pool.RemoveHost(req.GetHost())
}
return &messages.OwnerChangeAck{
Accepted: true,

View File

@@ -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")
}
}

View File

@@ -1,14 +1,12 @@
package actor
import (
"context"
"fmt"
"log"
"reflect"
"sync"
"go.opentelemetry.io/otel/attribute"
"google.golang.org/protobuf/proto"
"github.com/gogo/protobuf/proto"
)
type ApplyResult struct {
@@ -17,30 +15,11 @@ type ApplyResult struct {
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)
Apply(grain any, msg ...proto.Message) ([]ApplyResult, error)
RegisterMutations(handlers ...MutationHandler)
Create(typeName string) (proto.Message, bool)
GetTypeName(msg proto.Message) (string, bool)
RegisterProcessor(processor ...MutationProcessor)
//GetStorageEvent(msg proto.Message) StorageEvent
//FromStorageEvent(event StorageEvent) (proto.Message, error)
}
@@ -48,7 +27,6 @@ type MutationRegistry interface {
type ProtoMutationRegistry struct {
mutationRegistryMu sync.RWMutex
mutationRegistry map[reflect.Type]MutationHandler
processors []MutationProcessor
}
var (
@@ -95,26 +73,17 @@ type MutationHandler interface {
type RegisteredMutation[V any, T proto.Message] struct {
name string
handler func(*V, T) error
create func() proto.Message
create func() T
msgType reflect.Type
}
func NewMutation[V any, T proto.Message](handler func(*V, T) error) *RegisteredMutation[V, T] {
func NewMutation[V any, T proto.Message](handler func(*V, T) error, create func() T) *RegisteredMutation[V, T] {
// Derive the name and message type from a concrete instance produced by create().
// This avoids relying on reflect.TypeFor (which can yield unexpected results in some toolchains)
// and ensures we always peel off the pointer layer for proto messages.
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 {
if rt.Kind() == reflect.Ptr {
rt = rt.Elem()
}
return &RegisteredMutation[V, T]{
@@ -145,14 +114,9 @@ func NewMutationRegistry() MutationRegistry {
return &ProtoMutationRegistry{
mutationRegistry: make(map[reflect.Type]MutationHandler),
mutationRegistryMu: sync.RWMutex{},
processors: make([]MutationProcessor, 0),
}
}
func (r *ProtoMutationRegistry) RegisterProcessor(processors ...MutationProcessor) {
r.processors = append(r.processors, processors...)
}
func (r *ProtoMutationRegistry) RegisterMutations(handlers ...MutationHandler) {
r.mutationRegistryMu.Lock()
defer r.mutationRegistryMu.Unlock()
@@ -201,71 +165,33 @@ func (r *ProtoMutationRegistry) Create(typeName string) (proto.Message, bool) {
// 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)),
)
func (r *ProtoMutationRegistry) Apply(grain any, msg ...proto.Message) ([]ApplyResult, error) {
results := make([]ApplyResult, 0, len(msg))
if grain == nil {
return results, fmt.Errorf("nil grain")
}
// 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)
}
results = append(results, ApplyResult{Error: err, Type: rt.Name(), Mutation: m})
}
msgSpan.End()
err := entry.Handle(grain, m)
results = append(results, ApplyResult{Error: err, Type: rt.Name(), Mutation: m})
}
if len(results) > 0 {
processCtx, processSpan := tracer.Start(ctx, "after mutation processors")
defer processSpan.End()
for _, processor := range r.processors {
// if entry.updateTotals {
// grain.UpdateTotals()
// }
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
}

View File

@@ -1,37 +1,38 @@
package actor
import (
"context"
"errors"
"reflect"
"slices"
"testing"
cart_messages "git.k6n.net/go-cart-actor/proto/cart"
"git.tornberg.me/go-cart-actor/pkg/messages"
)
type cartState struct {
calls int
lastAdded *cart_messages.AddItem
lastAdded *messages.AddItem
}
func TestRegisteredMutationBasics(t *testing.T) {
reg := NewMutationRegistry().(*ProtoMutationRegistry)
addItemMutation := NewMutation(
func(state *cartState, msg *cart_messages.AddItem) error {
func(state *cartState, msg *messages.AddItem) error {
state.calls++
// copy to avoid external mutation side-effects (not strictly necessary for the test)
cp := msg
state.lastAdded = cp
cp := *msg
state.lastAdded = &cp
return nil
},
func() *messages.AddItem { return &messages.AddItem{} },
)
// Sanity check on mutation metadata
if addItemMutation.Name() != "AddItem" {
t.Fatalf("expected mutation Name() == AddItem, got %s", addItemMutation.Name())
}
if got, want := addItemMutation.Type(), reflect.TypeOf(cart_messages.AddItem{}); got != want {
if got, want := addItemMutation.Type(), reflect.TypeOf(messages.AddItem{}); got != want {
t.Fatalf("expected Type() == %v, got %v", want, got)
}
@@ -45,18 +46,18 @@ func TestRegisteredMutationBasics(t *testing.T) {
// RegisteredMutationTypes: membership (order not guaranteed)
types := reg.RegisteredMutationTypes()
if !slices.Contains(types, reflect.TypeOf(cart_messages.AddItem{})) {
if !slices.Contains(types, reflect.TypeOf(messages.AddItem{})) {
t.Fatalf("RegisteredMutationTypes missing AddItem type, got %v", types)
}
// GetTypeName should resolve for a pointer instance
name, ok := reg.GetTypeName(&cart_messages.AddItem{})
name, ok := reg.GetTypeName(&messages.AddItem{})
if !ok || name != "AddItem" {
t.Fatalf("GetTypeName returned (%q,%v), expected (AddItem,true)", name, ok)
}
// GetTypeName should fail for unregistered type
if name, ok := reg.GetTypeName(&cart_messages.RemoveItem{}); ok || name != "" {
if name, ok := reg.GetTypeName(&messages.Noop{}); ok || name != "" {
t.Fatalf("expected GetTypeName to fail for unregistered message, got (%q,%v)", name, ok)
}
@@ -65,7 +66,7 @@ func TestRegisteredMutationBasics(t *testing.T) {
if !ok {
t.Fatalf("Create failed for registered mutation")
}
if _, isAddItem := msg.(*cart_messages.AddItem); !isAddItem {
if _, isAddItem := msg.(*messages.AddItem); !isAddItem {
t.Fatalf("Create returned wrong concrete type: %T", msg)
}
@@ -76,8 +77,8 @@ func TestRegisteredMutationBasics(t *testing.T) {
// 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 {
add := &messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
if _, err := reg.Apply(state, add); err != nil {
t.Fatalf("Apply returned error: %v", err)
}
if state.calls != 1 {
@@ -88,18 +89,17 @@ func TestRegisteredMutationBasics(t *testing.T) {
}
// Apply nil grain
if _, err := reg.Apply(context.Background(), nil, add); err == nil {
if _, err := reg.Apply(nil, add); err == nil {
t.Fatalf("expected error for nil grain")
}
// Apply nil message
if _, err := reg.Apply(context.Background(), state, nil); err == nil {
if _, err := reg.Apply(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 {
if _, err := reg.Apply(state, &messages.Noop{}); !errors.Is(err, ErrMutationNotRegistered) {
t.Fatalf("expected ErrMutationNotRegistered, got %v", err)
}
}

View File

@@ -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)
}
}

View File

@@ -1,14 +1,13 @@
package actor
import (
"context"
"fmt"
"log"
"maps"
"sync"
"time"
"google.golang.org/protobuf/proto"
"github.com/gogo/protobuf/proto"
)
type SimpleGrainPool[V any] struct {
@@ -16,9 +15,8 @@ type SimpleGrainPool[V any] struct {
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)
spawn func(id uint64) (Grain[V], error)
spawnHost func(host string) (Host, error)
listeners []LogListener
storage LogStorage[V]
ttl time.Duration
@@ -27,8 +25,8 @@ type SimpleGrainPool[V any] struct {
// Cluster coordination --------------------------------------------------
hostname string
remoteMu sync.RWMutex
remoteOwners map[uint64]Host[V]
remoteHosts map[string]Host[V]
remoteOwners map[uint64]Host
remoteHosts map[string]Host
//discardedHostHandler *DiscardedHostHandler
// House-keeping ---------------------------------------------------------
@@ -37,9 +35,8 @@ type SimpleGrainPool[V any] struct {
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
Spawn func(id uint64) (Grain[V], error)
SpawnHost func(host string) (Host, error)
TTL time.Duration
PoolSize int
MutationRegistry MutationRegistry
@@ -53,12 +50,11 @@ func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V],
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]),
remoteOwners: make(map[uint64]Host),
remoteHosts: make(map[string]Host),
}
p.purgeTicker = time.NewTicker(time.Minute)
@@ -91,15 +87,12 @@ func (p *SimpleGrainPool[V]) purge() {
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]) {
p.forAllHosts(func(remote Host) {
remote.AnnounceExpiry(purgedIds)
})
@@ -136,6 +129,7 @@ func (p *SimpleGrainPool[V]) HandleRemoteExpiry(host string, ids []uint64) error
}
func (p *SimpleGrainPool[V]) HandleOwnershipChange(host string, ids []uint64) error {
log.Printf("host %s now owns %d cart ids", host, len(ids))
p.remoteMu.RLock()
remoteHost, exists := p.remoteHosts[host]
p.remoteMu.RUnlock()
@@ -163,11 +157,7 @@ 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) {
func (p *SimpleGrainPool[V]) AddRemote(host string) (Host, error) {
if host == "" {
return nil, fmt.Errorf("host is empty")
}
@@ -199,7 +189,7 @@ func (p *SimpleGrainPool[V]) AddRemote(host string) (Host[V], error) {
return remote, nil
}
func (p *SimpleGrainPool[V]) initializeRemote(remote Host[V]) {
func (p *SimpleGrainPool[V]) initializeRemote(remote Host) {
remotesIds := remote.GetActorIds()
@@ -267,7 +257,7 @@ func (p *SimpleGrainPool[V]) IsKnown(host string) bool {
return ok
}
func (p *SimpleGrainPool[V]) pingLoop(remote Host[V]) {
func (p *SimpleGrainPool[V]) pingLoop(remote Host) {
remote.Ping()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
@@ -315,14 +305,14 @@ func (p *SimpleGrainPool[V]) SendNegotiation() {
p.remoteMu.RLock()
hosts := make([]string, 0, len(p.remoteHosts)+1)
hosts = append(hosts, p.hostname)
remotes := make([]Host[V], 0, len(p.remoteHosts))
remotes := make([]Host, 0, len(p.remoteHosts))
for h, r := range p.remoteHosts {
hosts = append(hosts, h)
remotes = append(remotes, r)
}
p.remoteMu.RUnlock()
p.forAllHosts(func(remote Host[V]) {
p.forAllHosts(func(remote Host) {
knownByRemote, err := remote.Negotiate(hosts)
if err != nil {
@@ -337,7 +327,7 @@ func (p *SimpleGrainPool[V]) SendNegotiation() {
})
}
func (p *SimpleGrainPool[V]) forAllHosts(fn func(Host[V])) {
func (p *SimpleGrainPool[V]) forAllHosts(fn func(Host)) {
p.remoteMu.RLock()
rh := maps.Clone(p.remoteHosts)
p.remoteMu.RUnlock()
@@ -365,14 +355,14 @@ func (p *SimpleGrainPool[V]) broadcastOwnership(ids []uint64) {
return
}
p.forAllHosts(func(rh Host[V]) {
p.forAllHosts(func(rh Host) {
rh.AnnounceOwnership(p.hostname, ids)
})
log.Printf("%s taking ownership of %d ids", p.hostname, len(ids))
// go p.statsUpdate()
}
func (p *SimpleGrainPool[V]) getOrClaimGrain(ctx context.Context, id uint64) (Grain[V], error) {
func (p *SimpleGrainPool[V]) getOrClaimGrain(id uint64) (Grain[V], error) {
p.localMu.RLock()
grain, exists := p.grains[id]
p.localMu.RUnlock()
@@ -380,15 +370,14 @@ func (p *SimpleGrainPool[V]) getOrClaimGrain(ctx context.Context, id uint64) (Gr
return grain, nil
}
grain, err := p.spawn(ctx, id)
grain, err := p.spawn(id)
if err != nil {
return nil, err
}
go p.broadcastOwnership([]uint64{id})
p.localMu.Lock()
p.grains[id] = grain
p.localMu.Unlock()
go p.broadcastOwnership([]uint64{id})
return grain, nil
}
@@ -396,13 +385,13 @@ func (p *SimpleGrainPool[V]) getOrClaimGrain(ctx context.Context, id uint64) (Gr
// 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)
func (p *SimpleGrainPool[V]) Apply(id uint64, mutation ...proto.Message) (*MutationResult[*V], error) {
grain, err := p.getOrClaimGrain(id)
if err != nil {
return nil, err
}
mutations, err := p.mutationRegistry.Apply(ctx, grain, mutation...)
mutations, err := p.mutationRegistry.Apply(grain, mutation...)
if err != nil {
return nil, err
}
@@ -420,16 +409,15 @@ func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...p
if err != nil {
return nil, err
}
return &MutationResult[V]{
Result: *result,
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)
func (p *SimpleGrainPool[V]) Get(id uint64) (*V, error) {
grain, err := p.getOrClaimGrain(id)
if err != nil {
return nil, err
}
@@ -437,7 +425,7 @@ func (p *SimpleGrainPool[V]) Get(ctx context.Context, id uint64) (*V, error) {
}
// OwnerHost reports the remote owner (if any) for the supplied cart id.
func (p *SimpleGrainPool[V]) OwnerHost(id uint64) (Host[V], bool) {
func (p *SimpleGrainPool[V]) OwnerHost(id uint64) (Host, bool) {
p.remoteMu.RLock()
defer p.remoteMu.RUnlock()
owner, ok := p.remoteOwners[id]
@@ -452,7 +440,7 @@ func (p *SimpleGrainPool[V]) Hostname() string {
// Close notifies remotes that this host is shutting down.
func (p *SimpleGrainPool[V]) Close() {
p.forAllHosts(func(rh Host[V]) {
p.forAllHosts(func(rh Host) {
rh.Close()
})

View File

@@ -7,7 +7,7 @@ import (
"io"
"time"
"google.golang.org/protobuf/proto"
"github.com/gogo/protobuf/proto"
)
type StateStorage struct {
@@ -34,14 +34,14 @@ func NewState(registry MutationRegistry) *StateStorage {
var ErrUnknownType = errors.New("unknown type")
func (s *StateStorage) Load(r io.Reader, onMessage func(msg proto.Message, timeStamp time.Time)) error {
func (s *StateStorage) Load(r io.Reader, onMessage func(msg proto.Message)) error {
var err error
var evt *StorageEvent
scanner := bufio.NewScanner(r)
for err == nil {
evt, err = s.Read(scanner)
if err == nil {
onMessage(evt.Mutation, evt.TimeStamp)
onMessage(evt.Mutation)
}
}
if err == io.EOF {

View File

@@ -2,11 +2,12 @@ package cart
import (
"encoding/json"
"slices"
"sync"
"time"
"git.k6n.net/go-cart-actor/pkg/voucher"
"github.com/matst80/go-redis-inventory/pkg/inventory"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"git.tornberg.me/go-cart-actor/pkg/voucher"
)
// Legacy padded [16]byte CartId and its helper methods removed.
@@ -22,35 +23,35 @@ type ItemMeta struct {
Category3 string `json:"category3,omitempty"`
Category4 string `json:"category4,omitempty"`
Category5 string `json:"category5,omitempty"`
SellerId string `json:"sellerId,omitempty"`
SellerName string `json:"sellerName,omitempty"`
Image string `json:"image,omitempty"`
Outlet *string `json:"outlet,omitempty"`
}
type CartItem struct {
Id uint32 `json:"id"`
ItemId uint32 `json:"itemId,omitempty"`
ParentId *uint32 `json:"parentId,omitempty"`
Sku string `json:"sku"`
Price Price `json:"price"`
TotalPrice Price `json:"totalPrice"`
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"`
Id uint32 `json:"id"`
ItemId uint32 `json:"itemId,omitempty"`
ParentId uint32 `json:"parentId,omitempty"`
Sku string `json:"sku"`
Price Price `json:"price"`
TotalPrice Price `json:"totalPrice"`
OrgPrice *Price `json:"orgPrice,omitempty"`
Stock StockStatus `json:"stock"`
Quantity int `json:"qty"`
Discount *Price `json:"discount,omitempty"`
Disclaimer string `json:"disclaimer,omitempty"`
ArticleType string `json:"type,omitempty"`
StoreId *string `json:"storeId,omitempty"`
Meta *ItemMeta `json:"meta,omitempty"`
}
type CartDelivery struct {
Id uint32 `json:"id"`
Provider string `json:"provider"`
Price Price `json:"price"`
Items []uint32 `json:"items"`
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
}
type CartNotification struct {
@@ -60,69 +61,32 @@ type CartNotification struct {
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
lastDeliveryId 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"`
Id CartId `json:"id"`
Items []*CartItem `json:"items"`
TotalPrice *Price `json:"totalPrice"`
TotalDiscount *Price `json:"totalDiscount"`
Deliveries []*CartDelivery `json:"deliveries,omitempty"`
Processing bool `json:"processing"`
PaymentInProgress bool `json:"paymentInProgress"`
OrderReference string `json:"orderReference,omitempty"`
PaymentStatus string `json:"paymentStatus,omitempty"`
Vouchers []*Voucher `json:"vouchers,omitempty"`
Notifications []CartNotification `json:"cartNotification,omitempty"`
}
type Voucher struct {
Code string `json:"code"`
Applied bool `json:"applied"`
Rules []string `json:"rules"`
Description string `json:"description,omitempty"`
Id uint32 `json:"id"`
Value int64 `json:"value"`
Code string `json:"code"`
Rules []*messages.VoucherRule `json:"rules"`
Id uint32 `json:"id"`
Value int64 `json:"value"`
}
func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
@@ -154,8 +118,8 @@ func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
}
// All voucher rules must pass (logical AND)
for _, expr := range v.Rules {
for _, rule := range v.Rules {
expr := rule.GetCondition()
if expr == "" {
// Empty condition treated as pass (acts like a comment / placeholder)
continue
@@ -175,16 +139,17 @@ func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
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),
lastItemId: 0,
lastDeliveryId: 0,
lastVoucherId: 0,
lastAccess: ts,
lastChange: ts,
TotalDiscount: NewPrice(),
Vouchers: []*Voucher{},
Deliveries: []*CartDelivery{},
Id: CartId(id),
Items: []*CartItem{},
TotalPrice: NewPrice(),
}
}
@@ -205,23 +170,37 @@ func (c *CartGrain) GetCurrentState() (*CartGrain, error) {
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) ItemsWithDelivery() []uint32 {
ret := make([]uint32, 0, len(c.Items))
for _, item := range c.Items {
for _, delivery := range c.Deliveries {
for _, id := range delivery.Items {
if item.Id == id {
ret = append(ret, id)
}
}
}
}
return ret
}
func (c *CartGrain) ItemsWithoutDelivery() []uint32 {
ret := make([]uint32, 0, len(c.Items))
hasDelivery := c.ItemsWithDelivery()
for _, item := range c.Items {
found := slices.Contains(hasDelivery, item.Id)
if !found {
ret = append(ret, item.Id)
}
}
return ret
}
func (c *CartGrain) FindItemWithSku(sku string) (*CartItem, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
@@ -233,6 +212,26 @@ func (c *CartGrain) FindItemWithSku(sku string) (*CartItem, bool) {
return nil, false
}
// func (c *CartGrain) Apply(content proto.Message, isReplay bool) (*CartGrain, error) {
// updated, err := ApplyRegistered(c, content)
// if err != nil {
// if err == ErrMutationNotRegistered {
// return nil, fmt.Errorf("unsupported mutation type %T (not registered)", content)
// }
// return nil, err
// }
// // Sliding TTL: update lastChange only for non-replay successful mutations.
// if updated != nil && !isReplay {
// c.lastChange = time.Now()
// c.lastAccess = time.Now()
// go AppendCartEvent(c.Id, content)
// }
// return updated, nil
// }
func (c *CartGrain) UpdateTotals() {
c.TotalPrice = NewPrice()
c.TotalDiscount = NewPrice()
@@ -240,37 +239,33 @@ func (c *CartGrain) UpdateTotals() {
for _, item := range c.Items {
rowTotal := MultiplyPrice(item.Price, int64(item.Quantity))
item.TotalPrice = *rowTotal
c.TotalPrice.Add(*rowTotal)
if item.OrgPrice != nil {
diff := NewPrice()
diff.Add(*item.OrgPrice)
diff.Subtract(item.Price)
diff.Multiply(int64(item.Quantity))
//rowTotal.Subtract(*diff)
item.Discount = diff
if diff.IncVat > 0 {
c.TotalDiscount.Add(*diff)
}
}
item.TotalPrice = *rowTotal
c.TotalPrice.Add(*rowTotal)
}
for _, delivery := range c.Deliveries {
c.TotalPrice.Add(delivery.Price)
}
for _, voucher := range c.Vouchers {
_, ok := voucher.AppliesTo(c)
voucher.Applied = false
if ok {
if _, ok := voucher.AppliesTo(c); 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)
}
}
}

View File

@@ -1,86 +1,47 @@
package cart
import (
"context"
"time"
"git.k6n.net/go-cart-actor/pkg/actor"
"github.com/matst80/go-redis-inventory/pkg/inventory"
"git.tornberg.me/go-cart-actor/pkg/actor"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
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 {
func NewCartMultationRegistry() 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),
actor.NewMutation(AddItem, func() *messages.AddItem {
return &messages.AddItem{}
}),
actor.NewMutation(ChangeQuantity, func() *messages.ChangeQuantity {
return &messages.ChangeQuantity{}
}),
actor.NewMutation(RemoveItem, func() *messages.RemoveItem {
return &messages.RemoveItem{}
}),
actor.NewMutation(InitializeCheckout, func() *messages.InitializeCheckout {
return &messages.InitializeCheckout{}
}),
actor.NewMutation(OrderCreated, func() *messages.OrderCreated {
return &messages.OrderCreated{}
}),
actor.NewMutation(RemoveDelivery, func() *messages.RemoveDelivery {
return &messages.RemoveDelivery{}
}),
actor.NewMutation(SetDelivery, func() *messages.SetDelivery {
return &messages.SetDelivery{}
}),
actor.NewMutation(SetPickupPoint, func() *messages.SetPickupPoint {
return &messages.SetPickupPoint{}
}),
actor.NewMutation(ClearCart, func() *messages.ClearCartRequest {
return &messages.ClearCartRequest{}
}),
actor.NewMutation(AddVoucher, func() *messages.AddVoucher {
return &messages.AddVoucher{}
}),
actor.NewMutation(RemoveVoucher, func() *messages.RemoveVoucher {
return &messages.RemoveVoucher{}
}),
)
return reg

View File

@@ -0,0 +1,48 @@
package cart
import (
"testing"
)
// helper to create a cart grain with items and deliveries
func newTestCart() *CartGrain {
return &CartGrain{Items: []*CartItem{}, Deliveries: []*CartDelivery{}, Vouchers: []*Voucher{}, Notifications: []CartNotification{}}
}
func TestCartGrainUpdateTotalsBasic(t *testing.T) {
c := newTestCart()
// Item1 price 1250 (ex 1000 vat 250) org price higher -> discount 200 per unit
item1Price := Price{IncVat: 1250, VatRates: map[float32]int64{25: 250}}
item1Org := &Price{IncVat: 1500, VatRates: map[float32]int64{25: 300}}
item2Price := Price{IncVat: 2000, VatRates: map[float32]int64{25: 400}}
c.Items = []*CartItem{
{Id: 1, Price: item1Price, OrgPrice: item1Org, Quantity: 2},
{Id: 2, Price: item2Price, OrgPrice: &item2Price, Quantity: 1},
}
deliveryPrice := Price{IncVat: 4900, VatRates: map[float32]int64{25: 980}}
c.Deliveries = []*CartDelivery{{Id: 1, Price: deliveryPrice, Items: []uint32{1, 2}}}
c.UpdateTotals()
// Expected totals: sum inc vat of items * qty plus delivery
// item1 total inc = 1250*2 = 2500
// item2 total inc = 2000*1 = 2000
// delivery inc = 4900
expectedInc := int64(2500 + 2000 + 4900)
if c.TotalPrice.IncVat != expectedInc {
t.Fatalf("TotalPrice IncVat expected %d got %d", expectedInc, c.TotalPrice.IncVat)
}
// Discount: current implementation computes (OrgPrice - Price) ignoring quantity -> 1500-1250=250
if c.TotalDiscount.IncVat != 250 {
t.Fatalf("TotalDiscount expected 250 got %d", c.TotalDiscount.IncVat)
}
}
func TestCartGrainUpdateTotalsNoItems(t *testing.T) {
c := newTestCart()
c.UpdateTotals()
if c.TotalPrice.IncVat != 0 || c.TotalDiscount.IncVat != 0 {
t.Fatalf("expected zero totals got %+v", c)
}
}

View File

@@ -4,8 +4,6 @@ import (
"crypto/rand"
"encoding/json"
"fmt"
"git.k6n.net/go-cart-actor/pkg/actor"
)
// cart_id.go
@@ -36,7 +34,7 @@ import (
//
// ---------------------------------------------------------------------------
type CartId actor.GrainId
type CartId uint64
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"

View File

@@ -1,14 +1,9 @@
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"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_add_item.go
@@ -17,17 +12,15 @@ import (
// 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()
// * 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()
func AddItem(g *CartGrain, m *messages.AddItem) error {
if m == nil {
return fmt.Errorf("AddItem: nil payload")
}
@@ -35,34 +28,14 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
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 {
// Fast path: merge with existing item having same SKU
if existing, found := g.FindItemWithSku(m.Sku); found {
if existing.StoreId == m.StoreId {
existing.Quantity += int(m.Quantity)
existing.Stock = StockStatus(m.Stock)
existing.StoreId = m.StoreId
return nil
}
return nil
}
g.mu.Lock()
@@ -76,17 +49,11 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
pricePerItem := NewPriceFromIncVat(m.Price, taxRate)
needsReservation := true
if m.ReservationEndTime != nil {
needsReservation = m.ReservationEndTime.AsTime().Before(time.Now())
}
cartItem := &CartItem{
g.Items = append(g.Items, &CartItem{
Id: g.lastItemId,
ItemId: uint32(m.ItemId),
Quantity: uint16(m.Quantity),
Quantity: int(m.Quantity),
Sku: m.Sku,
Tax: int(taxRate * 100),
Meta: &ItemMeta{
Name: m.Name,
Image: m.Image,
@@ -97,38 +64,21 @@ func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) er
Category4: m.Category4,
Category5: m.Category5,
Outlet: m.Outlet,
SellerId: m.SellerId,
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),
Stock: StockStatus(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
}

View File

@@ -3,8 +3,8 @@ package cart
import (
"slices"
"git.k6n.net/go-cart-actor/pkg/actor"
messages "git.k6n.net/go-cart-actor/proto/cart"
"git.tornberg.me/go-cart-actor/pkg/actor"
"git.tornberg.me/go-cart-actor/pkg/messages"
)
func RemoveVoucher(g *CartGrain, m *messages.RemoveVoucher) error {
@@ -15,9 +15,6 @@ func RemoveVoucher(g *CartGrain, m *messages.RemoveVoucher) error {
StatusCode: 400,
}
}
if g.CheckoutStatus != nil {
return ErrPaymentInProgress
}
if !slices.ContainsFunc(g.Vouchers, func(v *Voucher) bool {
return v.Id == m.Id
@@ -57,12 +54,10 @@ func AddVoucher(g *CartGrain, m *messages.AddVoucher) error {
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,
Id: g.lastVoucherId,
Code: m.Code,
Rules: m.VoucherRules,
Value: m.Value,
})
g.UpdateTotals()
return nil

View File

@@ -1,12 +1,9 @@
package cart
import (
"context"
"fmt"
"log"
"time"
messages "git.k6n.net/go-cart-actor/proto/cart"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_change_quantity.go
@@ -29,13 +26,11 @@ import (
// (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 {
func 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) {
@@ -49,37 +44,11 @@ func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *messages.ChangeQua
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.Items[foundIndex].Quantity = int(m.Quantity)
g.UpdateTotals()
return nil
}

View File

@@ -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
}

View File

@@ -0,0 +1,44 @@
package cart
import (
"fmt"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_initialize_checkout.go
//
// Registers the InitializeCheckout mutation.
// This mutation is invoked AFTER an external Klarna checkout session
// has been successfully created or updated. It persists the Klarna
// order reference / status and marks the cart as having a payment in progress.
//
// Behavior:
// - Sets OrderReference to the Klarna order ID (overwriting if already set).
// - Sets PaymentStatus to the current Klarna status.
// - Sets / updates PaymentInProgress flag.
// - Does NOT alter pricing or line items (so no totals recalculation).
//
// Validation:
// - Returns an error if payload is nil.
// - Returns an error if orderId is empty (integrity guard).
//
// Concurrency:
// - Relies on upstream mutation serialization for a single grain. If
// parallel checkout attempts are possible, add higher-level guards
// (e.g. reject if PaymentInProgress already true unless reusing
// the same OrderReference).
func InitializeCheckout(g *CartGrain, m *messages.InitializeCheckout) error {
if m == nil {
return fmt.Errorf("InitializeCheckout: nil payload")
}
if m.OrderId == "" {
return fmt.Errorf("InitializeCheckout: missing orderId")
}
g.OrderReference = m.OrderId
g.PaymentStatus = m.Status
g.PaymentInProgress = m.PaymentInProgress
return nil
}

View File

@@ -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)
}

View File

@@ -1,9 +1,9 @@
package checkout
package cart
import (
"fmt"
messages "git.k6n.net/go-cart-actor/proto/checkout"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_order_created.go
@@ -11,20 +11,21 @@ import (
// Registers the OrderCreated mutation.
//
// This mutation represents the completion (or state transition) of an order
// initiated earlier via InitializeCheckout / external processing.
// It finalizes (or updates) the checkout's order metadata.
// 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 Status from payload.Status.
// - Sets OrderId if not already set.
// - Does NOT adjust monetary totals.
// - 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.OrderId != "" && g.OrderId != m.OrderId { ... }
// if g.OrderReference != "" && g.OrderReference != m.OrderId { ... }
// - Add audit logging or metrics here if required.
//
// Concurrency:
@@ -32,18 +33,16 @@ import (
// per grain. If out-of-order events are possible, embed versioning or
// timestamps in the mutation and compare before applying changes.
func HandleOrderCreated(g *CheckoutGrain, m *messages.OrderCreated) error {
func OrderCreated(g *CartGrain, m *messages.OrderCreated) error {
if m == nil {
return fmt.Errorf("HandleOrderCreated: nil payload")
return fmt.Errorf("OrderCreated: nil payload")
}
if m.OrderId == "" {
return fmt.Errorf("OrderCreated: missing orderId")
}
if g.OrderId == nil {
g.OrderId = &m.OrderId
} else if *g.OrderId != m.OrderId {
return fmt.Errorf("OrderCreated: conflicting order ID")
}
g.OrderReference = m.OrderId
g.PaymentStatus = m.Status
g.PaymentInProgress = false
return nil
}

View File

@@ -1,9 +1,9 @@
package checkout
package cart
import (
"fmt"
messages "git.k6n.net/go-cart-actor/proto/checkout"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_remove_delivery.go
@@ -13,10 +13,20 @@ import (
// 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 HandleRemoveDelivery(g *CheckoutGrain, m *messages.RemoveDelivery) error {
func RemoveDelivery(g *CartGrain, m *messages.RemoveDelivery) error {
if m == nil {
return fmt.Errorf("RemoveDelivery: nil payload")
}
@@ -34,5 +44,6 @@ func HandleRemoveDelivery(g *CheckoutGrain, m *messages.RemoveDelivery) error {
// Remove delivery (order not preserved beyond necessity)
g.Deliveries = append(g.Deliveries[:index], g.Deliveries[index+1:]...)
g.UpdateTotals()
return nil
}

View File

@@ -1,12 +1,9 @@
package cart
import (
"context"
"fmt"
"log"
"time"
messages "git.k6n.net/go-cart-actor/proto/cart"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_remove_item.go
@@ -25,11 +22,10 @@ import (
// - 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 {
func RemoveItem(g *CartGrain, m *messages.RemoveItem) error {
if m == nil {
return fmt.Errorf("RemoveItem: nil payload")
}
targetID := uint32(m.Id)
index := -1
@@ -43,14 +39,6 @@ func (c *CartMutationContext) RemoveItem(g *CartGrain, m *messages.RemoveItem) e
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

View File

@@ -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)
}

View File

@@ -0,0 +1,96 @@
package cart
import (
"fmt"
"slices"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_set_delivery.go
//
// Registers the SetDelivery mutation.
//
// Semantics (mirrors legacy switch logic):
// - If the payload specifies an explicit list of item IDs (payload.Items):
// - Each referenced cart line must exist.
// - None of the referenced items may already belong to a delivery.
// - Only those items are associated with the new delivery.
// - If payload.Items is empty:
// - All items currently without any delivery are associated with the new delivery.
// - A new delivery line is created with:
// - Auto-incremented delivery ID (cart-local)
// - Provider from payload
// - Fixed price (currently hard-coded: 4900 minor units) adjust as needed
// - Optional PickupPoint copied from payload
// - Cart totals are recalculated (WithTotals)
//
// Error cases:
// - Referenced item does not exist
// - Referenced item already has a delivery
// - No items qualify (resulting association set empty) -> returns error (prevents creating empty delivery)
//
// Concurrency:
// - Uses g.mu to protect lastDeliveryId increment and append to Deliveries slice.
// Item scans are read-only and performed outside the lock for simplicity;
// if stricter guarantees are needed, widen the lock section.
//
// Future extension points:
// - Variable delivery pricing (based on weight, distance, provider, etc.)
// - Validation of provider codes
// - Multi-currency delivery pricing
func SetDelivery(g *CartGrain, m *messages.SetDelivery) error {
if m == nil {
return fmt.Errorf("SetDelivery: nil payload")
}
if m.Provider == "" {
return fmt.Errorf("SetDelivery: provider is empty")
}
withDelivery := g.ItemsWithDelivery()
targetItems := make([]uint32, 0)
if len(m.Items) == 0 {
// Use every item currently without a delivery
targetItems = append(targetItems, g.ItemsWithoutDelivery()...)
} else {
// Validate explicit list
for _, id64 := range m.Items {
id := uint32(id64)
found := false
for _, it := range g.Items {
if it.Id == id {
found = true
break
}
}
if !found {
return fmt.Errorf("SetDelivery: item id %d not found", id)
}
if slices.Contains(withDelivery, id) {
return fmt.Errorf("SetDelivery: item id %d already has a delivery", id)
}
targetItems = append(targetItems, id)
}
}
if len(targetItems) == 0 {
return fmt.Errorf("SetDelivery: no eligible items to attach")
}
// Append new delivery
g.mu.Lock()
g.lastDeliveryId++
newId := g.lastDeliveryId
g.Deliveries = append(g.Deliveries, &CartDelivery{
Id: newId,
Provider: m.Provider,
PickupPoint: m.PickupPoint,
Price: *NewPriceFromIncVat(4900, 25.0),
Items: targetItems,
})
g.mu.Unlock()
return nil
}

View File

@@ -1,9 +1,9 @@
package checkout
package cart
import (
"fmt"
messages "git.k6n.net/go-cart-actor/proto/checkout"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
)
// mutation_set_pickup_point.go
@@ -29,16 +29,34 @@ import (
// - Track history / audit of pickup point changes
// - Trigger delivery price adjustments (which would then require WithTotals()).
func HandleSetPickupPoint(g *CheckoutGrain, m *messages.SetPickupPoint) error {
func SetPickupPoint(g *CartGrain, m *messages.SetPickupPoint) error {
if m == nil {
return fmt.Errorf("SetPickupPoint: nil payload")
}
for _, d := range g.Deliveries {
if d.Id == uint32(m.DeliveryId) {
d.PickupPoint = asPickupPoint(m.PickupPoint, d.Id)
d.PickupPoint = &messages.PickupPoint{
Id: m.Id,
Name: m.Name,
Address: m.Address,
City: m.City,
Zip: m.Zip,
Country: m.Country,
}
return nil
}
}
return fmt.Errorf("SetPickupPoint: delivery id %d not found", m.DeliveryId)
}
func ClearCart(g *CartGrain, m *messages.ClearCartRequest) error {
if m == nil {
return fmt.Errorf("ClearCart: nil payload")
}
// maybe check if payment is done?
g.Deliveries = g.Deliveries[:0]
g.Items = g.Items[:0]
g.UpdateTotals()
return nil
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -1,65 +0,0 @@
package cart
import (
"encoding/json"
"fmt"
messages "git.k6n.net/go-cart-actor/proto/cart"
)
func UpsertSubscriptionDetails(g *CartGrain, m *messages.UpsertSubscriptionDetails) error {
if m == nil {
return nil
}
metaBytes := m.Data.GetValue()
// Create new subscription details when Id is nil.
if m.Id == nil {
// Validate JSON if provided.
var meta json.RawMessage
if metaBytes != nil {
if !json.Valid(metaBytes) {
return fmt.Errorf("subscription details invalid json")
}
meta = json.RawMessage(metaBytes)
}
id := MustNewCartId().String()
g.SubscriptionDetails[id] = &SubscriptionDetails{
Id: id,
Version: 1,
OfferingCode: m.OfferingCode,
SigningType: m.SigningType,
Meta: meta,
}
return nil
}
// Update existing entry.
existing, ok := g.SubscriptionDetails[*m.Id]
if !ok {
return fmt.Errorf("subscription details with id %s not found", *m.Id)
}
changed := false
if m.OfferingCode != "" {
existing.OfferingCode = m.OfferingCode
changed = true
}
if m.SigningType != "" {
existing.SigningType = m.SigningType
changed = true
}
if metaBytes != nil {
if !json.Valid(metaBytes) {
return fmt.Errorf("subscription details invalid json")
}
existing.Meta = json.RawMessage(metaBytes)
changed = true
}
if changed {
existing.Version++
}
return nil
}

View File

@@ -87,34 +87,6 @@ func (p Price) MarshalJSON() ([]byte, error) {
return json.Marshal(wire{ExVat: exVat, IncVat: p.IncVat, Vat: vat})
}
func (p *Price) UnmarshalJSON(data []byte) error {
type wire struct {
ExVat int64 `json:"exVat"`
IncVat int64 `json:"incVat"`
Vat map[string]int64 `json:"vat,omitempty"`
}
var w wire
if err := json.Unmarshal(data, &w); err != nil {
return err
}
p.IncVat = w.IncVat
if len(w.Vat) > 0 {
p.VatRates = make(map[float32]int64, len(w.Vat))
for rateStr, amount := range w.Vat {
rate, err := strconv.ParseFloat(rateStr, 32)
if err != nil {
return err
}
p.VatRates[float32(rate)] = amount
}
} else {
p.VatRates = make(map[float32]int64)
}
return nil
}
// trimFloat converts a float32 tax rate like 25 or 12.5 into a compact string without
// unnecessary decimals ("25", "12.5").
func trimFloat(f float32) string {

View File

@@ -1,198 +0,0 @@
package checkout
import (
"encoding/json"
"sync"
"time"
"git.k6n.net/go-cart-actor/pkg/cart"
)
// CheckoutId is the same as CartId for simplicity
type CheckoutId = cart.CartId
type PickupPoint struct {
DeliveryId uint32 `json:"deliveryId,omitempty"`
Id string `json:"id"`
Name *string `json:"name,omitempty"`
Address *string `json:"address,omitempty"`
City *string `json:"city,omitempty"`
Zip *string `json:"zip,omitempty"`
Country *string `json:"country,omitempty"`
}
type CheckoutDelivery struct {
Id uint32 `json:"id"`
Provider string `json:"provider"`
Price cart.Price `json:"price"`
Items []uint32 `json:"items"`
PickupPoint *PickupPoint `json:"pickupPoint,omitempty"`
}
type PaymentStatus string
type CheckoutPaymentStatus PaymentStatus
const (
PaymentStatusPending PaymentStatus = "pending"
PaymentStatusFailed PaymentStatus = "failed"
PaymentStatusSuccess PaymentStatus = "success"
CheckoutPaymentStatusPending CheckoutPaymentStatus = "pending"
CheckoutPaymentStatusFailed CheckoutPaymentStatus = "failed"
CheckoutPaymentStatusSuccess CheckoutPaymentStatus = "success"
CheckoutPaymentStatusCancelled CheckoutPaymentStatus = "partial"
)
type Payment struct {
PaymentId string `json:"paymentId"`
Status PaymentStatus `json:"status"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
Provider string `json:"provider,omitempty"`
Method *string `json:"method,omitempty"`
SessionData *json.RawMessage `json:"sessionData,omitempty"`
Events []PaymentEvent `json:"events,omitempty"`
ProcessorReference *string `json:"processorReference,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
}
type PaymentEvent struct {
Name string `json:"name"`
Success bool `json:"success"`
Data json.RawMessage `json:"data"`
}
func (p *Payment) IsSettled() bool {
if p == nil {
return false
}
switch p.Status {
case PaymentStatusSuccess:
return true
default:
return false
}
}
type ContactDetails struct {
Email *string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
Name *string `json:"name,omitempty"`
PostalCode *string `json:"zip,omitempty"`
}
type ConfirmationStatus struct {
Code *string `json:"code,omitempty"`
ViewCount int `json:"viewCount"`
LastViewedAt time.Time `json:"lastViewedAt"`
}
type CheckoutGrain struct {
mu sync.RWMutex
lastDeliveryId uint32
lastGiftcardId uint32
lastAccess time.Time
lastChange time.Time
Version uint32 `json:"version"`
Id CheckoutId `json:"id"`
CartId cart.CartId `json:"cartId"`
CartVersion uint64 `json:"cartVersion"`
CartState *cart.CartGrain `json:"cartState"` // snapshot of items
CartTotalPrice *cart.Price `json:"cartTotalPrice"`
OrderId *string `json:"orderId"`
Deliveries []*CheckoutDelivery `json:"deliveries,omitempty"`
PaymentInProgress uint16 `json:"paymentInProgress"`
AmountInCentsRemaining int64 `json:"amountRemaining"`
AmountInCentsStarted int64 `json:"amountActive"`
InventoryReserved bool `json:"inventoryReserved"`
Confirmation *ConfirmationStatus `json:"confirmationViewed,omitempty"`
Payments []*Payment `json:"payments,omitempty"`
ContactDetails *ContactDetails `json:"contactDetails,omitempty"`
}
func NewCheckoutGrain(id uint64, cartId cart.CartId, cartVersion uint64, ts time.Time, cartState *cart.CartGrain) *CheckoutGrain {
r := &CheckoutGrain{
lastDeliveryId: 0,
lastGiftcardId: 0,
lastAccess: ts,
lastChange: ts,
Id: CheckoutId(id),
CartId: cartId,
CartVersion: cartVersion,
Deliveries: []*CheckoutDelivery{},
Payments: []*Payment{},
}
if cartState != nil {
r.CartState = cartState
r.CartTotalPrice = cartState.TotalPrice
}
return r
}
func (c *CheckoutGrain) GetId() uint64 {
return uint64(c.Id)
}
func (c *CheckoutGrain) GetLastChange() time.Time {
return c.lastChange
}
func (c *CheckoutGrain) GetLastAccess() time.Time {
return c.lastAccess
}
func (c *CheckoutGrain) GetCurrentState() (*CheckoutGrain, error) {
c.lastAccess = time.Now()
return c, nil
}
func (c *CheckoutGrain) GetState() ([]byte, error) {
return json.Marshal(c)
}
func (c *CheckoutGrain) FindPayment(paymentId string) (*Payment, bool) {
if paymentId == "" {
return nil, false
}
for _, payment := range c.Payments {
if payment != nil && payment.PaymentId == paymentId {
return payment, true
}
}
return nil, false
}
func (c *CheckoutGrain) SettledPayments() []*Payment {
if len(c.Payments) == 0 {
return nil
}
settled := make([]*Payment, 0, len(c.Payments))
for _, payment := range c.Payments {
if payment != nil && payment.IsSettled() {
settled = append(settled, payment)
}
}
if len(settled) == 0 {
return nil
}
return settled
}
func (c *CheckoutGrain) OpenPayments() []*Payment {
if len(c.Payments) == 0 {
return nil
}
pending := make([]*Payment, 0, len(c.Payments))
for _, payment := range c.Payments {
if payment == nil {
continue
}
if !payment.IsSettled() {
pending = append(pending, payment)
}
}
if len(pending) == 0 {
return nil
}
return pending
}

View File

@@ -1,33 +0,0 @@
package checkout
import (
"git.k6n.net/go-cart-actor/pkg/actor"
)
type CheckoutMutationContext struct {
// Add any services needed, e.g., for delivery calculations, but since inventory is pre-handled, maybe none
}
func NewCheckoutMutationContext() *CheckoutMutationContext {
return &CheckoutMutationContext{}
}
func NewCheckoutMutationRegistry(ctx *CheckoutMutationContext) actor.MutationRegistry {
reg := actor.NewMutationRegistry()
reg.RegisterMutations(
actor.NewMutation(HandleInitializeCheckout),
actor.NewMutation(HandlePaymentStarted),
actor.NewMutation(HandlePaymentCompleted),
actor.NewMutation(HandlePaymentDeclined),
actor.NewMutation(HandlePaymentEvent),
actor.NewMutation(HandleConfirmationViewed),
actor.NewMutation(HandleOrderCreated),
actor.NewMutation(HandleInventoryReserved),
actor.NewMutation(HandleSetDelivery),
actor.NewMutation(HandleSetPickupPoint),
actor.NewMutation(HandleRemoveDelivery),
actor.NewMutation(HandleContactDetailsUpdated),
actor.NewMutation(HandlePaymentCancelled),
)
return reg
}

View File

@@ -1,30 +0,0 @@
package checkout
import (
"errors"
"slices"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
func HandlePaymentCancelled(g *CheckoutGrain, m *messages.CancelPayment) error {
payment, found := g.FindPayment(m.PaymentId)
if !found {
return ErrPaymentNotFound
}
if payment.CompletedAt != nil {
return errors.New("payment already completed")
}
g.PaymentInProgress--
g.AmountInCentsStarted -= payment.Amount
g.Payments = removePayment(g.Payments, payment.PaymentId)
return nil
}
func removePayment(payment []*Payment, s string) []*Payment {
return slices.DeleteFunc(payment, func(p *Payment) bool {
return p.PaymentId == s
})
}

View File

@@ -1,26 +0,0 @@
package checkout
import (
"fmt"
"time"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
func HandleConfirmationViewed(g *CheckoutGrain, m *messages.ConfirmationViewed) error {
if m == nil {
return fmt.Errorf("ConfirmationViewed: nil payload")
}
if g.Confirmation != nil {
g.Confirmation = &ConfirmationStatus{
ViewCount: 1,
LastViewedAt: time.Now(),
}
} else {
g.Confirmation.ViewCount++
g.Confirmation.LastViewedAt = time.Now()
}
return nil
}

View File

@@ -1,34 +0,0 @@
package checkout
import (
"fmt"
checkout_messages "git.k6n.net/go-cart-actor/proto/checkout"
)
// HandleContactDetailsUpdated mutation
func HandleContactDetailsUpdated(g *CheckoutGrain, m *checkout_messages.ContactDetailsUpdated) error {
if m == nil {
return fmt.Errorf("HandleContactDetailsUpdated: nil payload")
}
if g.ContactDetails == nil {
g.ContactDetails = &ContactDetails{}
}
if m.PostalCode != nil {
g.ContactDetails.PostalCode = m.PostalCode
}
if m.Email != nil {
g.ContactDetails.Email = m.Email
}
if m.Phone != nil {
g.ContactDetails.Phone = m.Phone
}
if m.Name != nil {
g.ContactDetails.Name = m.Name
}
return nil
}

View File

@@ -1,94 +0,0 @@
package checkout
import (
"testing"
checkout_messages "git.k6n.net/go-cart-actor/proto/checkout"
)
func TestHandleContactDetailsUpdated(t *testing.T) {
tests := []struct {
name string
initial *ContactDetails
update *checkout_messages.ContactDetailsUpdated
expected *ContactDetails
}{
{
name: "update all fields",
initial: nil,
update: &checkout_messages.ContactDetailsUpdated{
Email: stringPtr("test@example.com"),
Phone: stringPtr("123456789"),
Name: stringPtr("John Doe"),
},
expected: &ContactDetails{
Email: stringPtr("test@example.com"),
Phone: stringPtr("123456789"),
Name: stringPtr("John Doe"),
},
},
{
name: "update partial fields",
initial: &ContactDetails{
Email: stringPtr("old@example.com"),
Phone: nil,
Name: stringPtr("Old Name"),
},
update: &checkout_messages.ContactDetailsUpdated{
Phone: stringPtr("987654321"),
},
expected: &ContactDetails{
Email: stringPtr("old@example.com"),
Phone: stringPtr("987654321"),
Name: stringPtr("Old Name"),
},
},
{
name: "nil message",
initial: nil,
update: nil,
expected: &ContactDetails{}, // but error expected
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
grain := &CheckoutGrain{
ContactDetails: tt.initial,
}
err := HandleContactDetailsUpdated(grain, tt.update)
if tt.name == "nil message" {
if err == nil {
t.Errorf("expected error for nil message, got nil")
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if grain.ContactDetails == nil && tt.expected != nil {
t.Errorf("expected contact details, got nil")
} else if grain.ContactDetails != nil && tt.expected == nil {
t.Errorf("expected nil contact details, got %v", grain.ContactDetails)
} else if grain.ContactDetails != nil && tt.expected != nil {
if (grain.ContactDetails.Email == nil && tt.expected.Email != nil) || (grain.ContactDetails.Email != nil && tt.expected.Email == nil) || (grain.ContactDetails.Email != nil && *grain.ContactDetails.Email != *tt.expected.Email) {
t.Errorf("email mismatch: got %v, expected %v", grain.ContactDetails.Email, tt.expected.Email)
}
if (grain.ContactDetails.Phone == nil && tt.expected.Phone != nil) || (grain.ContactDetails.Phone != nil && tt.expected.Phone == nil) || (grain.ContactDetails.Phone != nil && *grain.ContactDetails.Phone != *tt.expected.Phone) {
t.Errorf("phone mismatch: got %v, expected %v", grain.ContactDetails.Phone, tt.expected.Phone)
}
if (grain.ContactDetails.Name == nil && tt.expected.Name != nil) || (grain.ContactDetails.Name != nil && tt.expected.Name == nil) || (grain.ContactDetails.Name != nil && *grain.ContactDetails.Name != *tt.expected.Name) {
t.Errorf("name mismatch: got %v, expected %v", grain.ContactDetails.Name, tt.expected.Name)
}
}
})
}
}
func stringPtr(s string) *string {
return &s
}

View File

@@ -1,63 +0,0 @@
package checkout
import (
"fmt"
"git.k6n.net/go-cart-actor/pkg/cart"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
func asPickupPoint(p *messages.PickupPoint, deliveryId uint32) *PickupPoint {
if p == nil {
return nil
}
if p.Address == nil {
return &PickupPoint{
Id: p.Id,
Name: p.Name,
DeliveryId: deliveryId,
}
}
return &PickupPoint{
DeliveryId: deliveryId,
Id: p.Id,
Name: p.Name,
Address: &p.Address.AddressLine1,
City: &p.Address.City,
Zip: &p.Address.Zip,
Country: &p.Address.Country,
}
}
// HandleSetDelivery mutation
// HandleSetDelivery mutation
func HandleSetDelivery(g *CheckoutGrain, m *messages.SetDelivery) error {
if m == nil {
return fmt.Errorf("HandleSetDelivery: nil payload")
}
if m.Provider == "" {
return fmt.Errorf("HandleSetDelivery: missing provider")
}
// Check if delivery already exists, update or add
for _, d := range g.Deliveries {
if d.Provider == m.Provider {
// Update existing
d.Items = m.Items
d.PickupPoint = asPickupPoint(m.PickupPoint, d.Id)
return nil
}
}
// Add new delivery
g.lastDeliveryId++
delivery := &CheckoutDelivery{
Id: g.lastDeliveryId,
Provider: m.Provider,
Items: m.Items,
PickupPoint: asPickupPoint(m.PickupPoint, g.lastDeliveryId),
Price: *cart.NewPrice(), // Price might need calculation, but for now zero
}
g.Deliveries = append(g.Deliveries, delivery)
return nil
}

View File

@@ -1,53 +0,0 @@
package checkout
import (
"encoding/json"
"fmt"
"git.k6n.net/go-cart-actor/pkg/cart"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
// mutation_initialize_checkout.go
//
// Registers the InitializeCheckout mutation.
// This mutation is invoked AFTER an external checkout session
// has been successfully created or updated. It persists the
// order reference / status and marks the checkout as having a payment in progress.
//
// Behavior:
// - Sets OrderId to the order ID.
// - Sets Status to the current status.
// - Sets PaymentInProgress flag.
// - Assumes inventory is already reserved.
//
// Validation:
// - Returns an error if payload is nil.
// - Returns an error if orderId is empty.
func HandleInitializeCheckout(g *CheckoutGrain, m *messages.InitializeCheckout) error {
if m == nil {
return fmt.Errorf("InitializeCheckout: nil payload")
}
// if g.OrderId == "" {
// return fmt.Errorf("InitializeCheckout: missing orderId")
// }
if g.CartState != nil {
if g.CartId != cart.CartId(m.CartId) {
return fmt.Errorf("InitializeCheckout: cart ID mismatch")
}
if g.PaymentInProgress > 0 || g.OrderId != nil {
return fmt.Errorf("InitializeCheckout: payment already in progress")
}
}
err := json.Unmarshal(m.CartState.Value, &g.CartState)
if err != nil {
return err
}
g.CartTotalPrice = g.CartState.TotalPrice
g.AmountInCentsRemaining = g.CartTotalPrice.IncVat
return nil
}

View File

@@ -1,16 +0,0 @@
package checkout
import (
"fmt"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
func HandleInventoryReserved(g *CheckoutGrain, m *messages.InventoryReserved) error {
if m == nil {
return fmt.Errorf("HandleInventoryReserved: nil payload")
}
g.InventoryReserved = m.Status == "success"
return nil
}

View File

@@ -1,47 +0,0 @@
package checkout
import (
"fmt"
"time"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
// PaymentCompleted registers the completion of a payment for a checkout.
func HandlePaymentCompleted(g *CheckoutGrain, m *messages.PaymentCompleted) error {
if m == nil {
return fmt.Errorf("PaymentCompleted: nil payload")
}
paymentId := m.PaymentId
payment, found := g.FindPayment(paymentId)
if !found {
return fmt.Errorf("PaymentCompleted: payment not found")
}
payment.ProcessorReference = m.ProcessorReference
payment.Status = PaymentStatusSuccess
if m.Amount > 0 {
payment.Amount = m.Amount
}
if m.Currency != "" {
payment.Currency = m.Currency
}
if m.CompletedAt != nil {
payment.CompletedAt = asPointer(m.CompletedAt.AsTime())
} else {
payment.CompletedAt = asPointer(time.Now())
}
// Update checkout status
g.PaymentInProgress--
sum := g.CartState.TotalPrice.IncVat
for _, payment := range g.SettledPayments() {
sum -= payment.Amount
}
g.AmountInCentsRemaining = sum
return nil
}

View File

@@ -1,29 +0,0 @@
package checkout
import (
"errors"
"time"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
func asPointer[T any](value T) *T {
return &value
}
var ErrPaymentNotFound = errors.New("payment not found")
func HandlePaymentDeclined(g *CheckoutGrain, m *messages.PaymentDeclined) error {
payment, found := g.FindPayment(m.PaymentId)
if !found {
return ErrPaymentNotFound
}
payment.CompletedAt = asPointer(time.Now())
payment.Status = "failed"
g.PaymentInProgress--
g.AmountInCentsStarted -= payment.Amount
return nil
}

View File

@@ -1,20 +0,0 @@
package checkout
import (
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
func HandlePaymentEvent(g *CheckoutGrain, m *messages.PaymentEvent) error {
payment, found := g.FindPayment(m.PaymentId)
if !found {
return ErrPaymentNotFound
}
metaBytes := m.Data.Value
payment.Events = append(payment.Events, PaymentEvent{
Name: m.Name,
Success: m.Success,
Data: metaBytes,
})
return nil
}

View File

@@ -1,124 +0,0 @@
package checkout
import (
"encoding/json"
"testing"
checkout_messages "git.k6n.net/go-cart-actor/proto/checkout"
"google.golang.org/protobuf/types/known/anypb"
)
func TestHandlePaymentEvent(t *testing.T) {
tests := []struct {
name string
initialPayments []*Payment
event *checkout_messages.PaymentEvent
expectedEvents []PaymentEvent
expectError bool
}{
{
name: "add event to existing payment",
initialPayments: []*Payment{
{
PaymentId: "pay123",
Events: []PaymentEvent{
{Name: "init", Success: true, Data: json.RawMessage(`{"key":"value"}`)},
},
},
},
event: &checkout_messages.PaymentEvent{
PaymentId: "pay123",
Name: "capture",
Success: true,
Data: &anypb.Any{Value: []byte(`{"amount":100}`)},
},
expectedEvents: []PaymentEvent{
{Name: "init", Success: true, Data: json.RawMessage(`{"key":"value"}`)},
{Name: "capture", Success: true, Data: json.RawMessage(`{"amount":100}`)},
},
expectError: false,
},
{
name: "add event to payment with no initial events",
initialPayments: []*Payment{
{
PaymentId: "pay456",
Events: []PaymentEvent{},
},
},
event: &checkout_messages.PaymentEvent{
PaymentId: "pay456",
Name: "refund",
Success: false,
Data: &anypb.Any{Value: []byte(`{"reason":"failed"}`)},
},
expectedEvents: []PaymentEvent{
{Name: "refund", Success: false, Data: json.RawMessage(`{"reason":"failed"}`)},
},
expectError: false,
},
{
name: "payment not found",
initialPayments: []*Payment{
{
PaymentId: "pay123",
},
},
event: &checkout_messages.PaymentEvent{
PaymentId: "nonexistent",
Name: "test",
Success: true,
},
expectedEvents: nil,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
grain := &CheckoutGrain{
Payments: tt.initialPayments,
}
err := HandlePaymentEvent(grain, tt.event)
if tt.expectError {
if err == nil {
t.Errorf("expected error, got nil")
} else if err != ErrPaymentNotFound {
t.Errorf("expected ErrPaymentNotFound, got %v", err)
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
payment, found := grain.FindPayment(tt.event.PaymentId)
if !found {
t.Errorf("payment not found after handling event")
return
}
if len(payment.Events) != len(tt.expectedEvents) {
t.Errorf("expected %d events, got %d", len(tt.expectedEvents), len(payment.Events))
return
}
for i, expected := range tt.expectedEvents {
actual := payment.Events[i]
if actual.Name != expected.Name {
t.Errorf("event %d name mismatch: got %s, expected %s", i, actual.Name, expected.Name)
}
if actual.Success != expected.Success {
t.Errorf("event %d success mismatch: got %t, expected %t", i, actual.Success, expected.Success)
}
if string(actual.Data) != string(expected.Data) {
t.Errorf("event %d data mismatch: got %s, expected %s", i, string(actual.Data), string(expected.Data))
}
}
})
}
}

View File

@@ -1,95 +0,0 @@
package checkout
import (
"encoding/json"
"fmt"
"strings"
"time"
messages "git.k6n.net/go-cart-actor/proto/checkout"
)
// PaymentStarted registers the beginning of a payment attempt for a checkout.
// It either upserts the payment entry (based on paymentId) or creates a new one,
// marks the checkout as having a payment in progress.
func HandlePaymentStarted(g *CheckoutGrain, m *messages.PaymentStarted) error {
if m == nil {
return fmt.Errorf("PaymentStarted: nil payload")
}
paymentID := strings.TrimSpace(m.PaymentId)
if paymentID == "" {
return fmt.Errorf("PaymentStarted: missing paymentId")
}
if m.Amount < 0 {
return fmt.Errorf("PaymentStarted: amount cannot be negative")
}
currency := strings.TrimSpace(m.Currency)
provider := strings.TrimSpace(m.Provider)
method := copyOptionalString(m.Method)
startedAt := time.Now().UTC()
if m.StartedAt != nil {
startedAt = m.StartedAt.AsTime()
}
payment, found := g.FindPayment(paymentID)
var sessionData *json.RawMessage
if m.SessionData != nil {
sessionData = (*json.RawMessage)(&m.SessionData.Value)
}
if found {
if payment.Status != "pending" {
return fmt.Errorf("PaymentStarted: payment already started")
}
if payment.PaymentId != paymentID {
payment.PaymentId = paymentID
}
payment.Status = "pending"
payment.Amount = m.Amount
if currency != "" {
payment.Currency = currency
}
if provider != "" {
payment.Provider = provider
}
if method != nil {
payment.Method = method
}
if m.SessionData != nil {
payment.SessionData = sessionData
}
payment.StartedAt = &startedAt
} else {
g.PaymentInProgress++
g.Payments = append(g.Payments, &Payment{
PaymentId: paymentID,
Status: "pending",
Amount: m.Amount,
SessionData: sessionData,
Currency: currency,
Provider: provider,
Method: method,
StartedAt: &startedAt,
})
}
g.AmountInCentsStarted += m.Amount
return nil
}
func copyOptionalString(src *string) *string {
if src == nil {
return nil
}
trimmed := strings.TrimSpace(*src)
if trimmed == "" {
return nil
}
dst := trimmed
return &dst
}

View File

@@ -2,8 +2,6 @@ package discovery
import (
"context"
"slices"
"sync"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -14,39 +12,39 @@ import (
)
type K8sDiscovery struct {
ctx context.Context
client *kubernetes.Clientset
listOptions metav1.ListOptions
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, k.listOptions)
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 {
if hasReadyCondition(&pod) {
hosts = append(hosts, pod.Status.PodIP)
}
hosts = append(hosts, pod.Status.PodIP)
}
return hosts, nil
}
func hasReadyCondition(pod *v1.Pod) bool {
return slices.ContainsFunc(pod.Status.Conditions, func(condition v1.PodCondition) bool {
return condition.Type == v1.PodReady && condition.Status == v1.ConditionTrue
})
type HostChange struct {
Host string
Type watch.EventType
}
func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
ipsThatAreReady := make(map[string]bool)
m := sync.Mutex{}
timeout := int64(30)
watcherFn := func(options metav1.ListOptions) (watch.Interface, error) {
return k.client.CoreV1().Pods("").Watch(k.ctx, k.listOptions)
return k.client.CoreV1().Pods("").Watch(k.ctx, metav1.ListOptions{
LabelSelector: "actor-pool=cart",
TimeoutSeconds: &timeout,
})
}
watcher, err := toolsWatch.NewRetryWatcherWithContext(k.ctx, "1", &cache.ListWatch{WatchFunc: watcherFn})
if err != nil {
@@ -57,30 +55,19 @@ func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
for event := range watcher.ResultChan() {
pod := event.Object.(*v1.Pod)
isReady := hasReadyCondition(pod)
m.Lock()
oldState := ipsThatAreReady[pod.Status.PodIP]
ipsThatAreReady[pod.Status.PodIP] = isReady
m.Unlock()
if oldState != isReady {
ch <- HostChange{
Host: pod.Status.PodIP,
IsReady: isReady,
}
}
// log.Printf("pod change %+v", pod.Status.Phase == v1.PodRunning)
ch <- HostChange{
Host: pod.Status.PodIP,
IsReady: isReady,
Host: pod.Status.PodIP,
Type: event.Type,
}
}
}()
return ch, nil
}
func NewK8sDiscovery(client *kubernetes.Clientset, listOptions metav1.ListOptions) *K8sDiscovery {
func NewK8sDiscovery(client *kubernetes.Clientset) *K8sDiscovery {
return &K8sDiscovery{
ctx: context.Background(),
client: client,
listOptions: listOptions,
ctx: context.Background(),
client: client,
}
}

View File

@@ -2,8 +2,9 @@ package discovery
import (
"context"
"slices"
"sync"
"k8s.io/apimachinery/pkg/watch"
)
// MockDiscovery is an in-memory Discovery implementation for tests.
@@ -55,12 +56,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 +86,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}
}
}

View File

@@ -4,7 +4,6 @@ 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)

View File

@@ -1,17 +1,6 @@
package discovery
type HostChange struct {
Host string
IsReady bool
}
type Discovery interface {
Discover() ([]string, error)
Watch() (<-chan HostChange, error)
}
type DiscoveryTarget interface {
IsKnown(string) bool
RemoveHost(host string)
AddRemoteHost(host string)
}

View File

@@ -1,15 +1,14 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.10
// protoc v6.33.1
// protoc-gen-go v1.36.5
// protoc v5.29.3
// source: control_plane.proto
package control_plane_messages
package messages
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
anypb "google.golang.org/protobuf/types/known/anypb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
@@ -452,310 +451,67 @@ func (x *ExpiryAnnounce) GetIds() []uint64 {
return nil
}
type ApplyRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
Messages []*anypb.Any `protobuf:"bytes,2,rep,name=messages,proto3" json:"messages,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ApplyRequest) Reset() {
*x = ApplyRequest{}
mi := &file_control_plane_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ApplyRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ApplyRequest) ProtoMessage() {}
func (x *ApplyRequest) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ApplyRequest.ProtoReflect.Descriptor instead.
func (*ApplyRequest) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{9}
}
func (x *ApplyRequest) GetId() uint64 {
if x != nil {
return x.Id
}
return 0
}
func (x *ApplyRequest) GetMessages() []*anypb.Any {
if x != nil {
return x.Messages
}
return nil
}
type GetRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetRequest) Reset() {
*x = GetRequest{}
mi := &file_control_plane_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetRequest) ProtoMessage() {}
func (x *GetRequest) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetRequest.ProtoReflect.Descriptor instead.
func (*GetRequest) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{10}
}
func (x *GetRequest) GetId() uint64 {
if x != nil {
return x.Id
}
return 0
}
type GetReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Grain *anypb.Any `protobuf:"bytes,1,opt,name=grain,proto3" json:"grain,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetReply) Reset() {
*x = GetReply{}
mi := &file_control_plane_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetReply) ProtoMessage() {}
func (x *GetReply) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetReply.ProtoReflect.Descriptor instead.
func (*GetReply) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{11}
}
func (x *GetReply) GetGrain() *anypb.Any {
if x != nil {
return x.Grain
}
return nil
}
type MutationResult struct {
state protoimpl.MessageState `protogen:"open.v1"`
Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"`
Message *anypb.Any `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
Error *string `protobuf:"bytes,3,opt,name=error,proto3,oneof" json:"error,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *MutationResult) Reset() {
*x = MutationResult{}
mi := &file_control_plane_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MutationResult) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MutationResult) ProtoMessage() {}
func (x *MutationResult) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use MutationResult.ProtoReflect.Descriptor instead.
func (*MutationResult) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{12}
}
func (x *MutationResult) GetType() string {
if x != nil {
return x.Type
}
return ""
}
func (x *MutationResult) GetMessage() *anypb.Any {
if x != nil {
return x.Message
}
return nil
}
func (x *MutationResult) GetError() string {
if x != nil && x.Error != nil {
return *x.Error
}
return ""
}
type ApplyResult struct {
state protoimpl.MessageState `protogen:"open.v1"`
State *anypb.Any `protobuf:"bytes,1,opt,name=state,proto3" json:"state,omitempty"`
Mutations []*MutationResult `protobuf:"bytes,2,rep,name=mutations,proto3" json:"mutations,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ApplyResult) Reset() {
*x = ApplyResult{}
mi := &file_control_plane_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ApplyResult) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ApplyResult) ProtoMessage() {}
func (x *ApplyResult) ProtoReflect() protoreflect.Message {
mi := &file_control_plane_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ApplyResult.ProtoReflect.Descriptor instead.
func (*ApplyResult) Descriptor() ([]byte, []int) {
return file_control_plane_proto_rawDescGZIP(), []int{13}
}
func (x *ApplyResult) GetState() *anypb.Any {
if x != nil {
return x.State
}
return nil
}
func (x *ApplyResult) GetMutations() []*MutationResult {
if x != nil {
return x.Mutations
}
return nil
}
var File_control_plane_proto protoreflect.FileDescriptor
const file_control_plane_proto_rawDesc = "" +
"\n" +
"\x13control_plane.proto\x12\x16control_plane_messages\x1a\x19google/protobuf/any.proto\"\a\n" +
"\x05Empty\"<\n" +
"\tPingReply\x12\x12\n" +
"\x04host\x18\x01 \x01(\tR\x04host\x12\x1b\n" +
"\tunix_time\x18\x02 \x01(\x03R\bunixTime\"3\n" +
"\x10NegotiateRequest\x12\x1f\n" +
"\vknown_hosts\x18\x01 \x03(\tR\n" +
"knownHosts\"&\n" +
"\x0eNegotiateReply\x12\x14\n" +
"\x05hosts\x18\x01 \x03(\tR\x05hosts\"!\n" +
"\rActorIdsReply\x12\x10\n" +
"\x03ids\x18\x01 \x03(\x04R\x03ids\"F\n" +
"\x0eOwnerChangeAck\x12\x1a\n" +
"\baccepted\x18\x01 \x01(\bR\baccepted\x12\x18\n" +
"\amessage\x18\x02 \x01(\tR\amessage\"#\n" +
"\rClosingNotice\x12\x12\n" +
"\x04host\x18\x01 \x01(\tR\x04host\"9\n" +
"\x11OwnershipAnnounce\x12\x12\n" +
"\x04host\x18\x01 \x01(\tR\x04host\x12\x10\n" +
"\x03ids\x18\x02 \x03(\x04R\x03ids\"6\n" +
"\x0eExpiryAnnounce\x12\x12\n" +
"\x04host\x18\x01 \x01(\tR\x04host\x12\x10\n" +
"\x03ids\x18\x02 \x03(\x04R\x03ids\"P\n" +
"\fApplyRequest\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x04R\x02id\x120\n" +
"\bmessages\x18\x02 \x03(\v2\x14.google.protobuf.AnyR\bmessages\"\x1c\n" +
"\n" +
"GetRequest\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x04R\x02id\"6\n" +
"\bGetReply\x12*\n" +
"\x05grain\x18\x01 \x01(\v2\x14.google.protobuf.AnyR\x05grain\"y\n" +
"\x0eMutationResult\x12\x12\n" +
"\x04type\x18\x01 \x01(\tR\x04type\x12.\n" +
"\amessage\x18\x02 \x01(\v2\x14.google.protobuf.AnyR\amessage\x12\x19\n" +
"\x05error\x18\x03 \x01(\tH\x00R\x05error\x88\x01\x01B\b\n" +
"\x06_error\"\x7f\n" +
"\vApplyResult\x12*\n" +
"\x05state\x18\x01 \x01(\v2\x14.google.protobuf.AnyR\x05state\x12D\n" +
"\tmutations\x18\x02 \x03(\v2&.control_plane_messages.MutationResultR\tmutations2\xd6\x05\n" +
"\fControlPlane\x12H\n" +
"\x04Ping\x12\x1d.control_plane_messages.Empty\x1a!.control_plane_messages.PingReply\x12]\n" +
"\tNegotiate\x12(.control_plane_messages.NegotiateRequest\x1a&.control_plane_messages.NegotiateReply\x12X\n" +
"\x10GetLocalActorIds\x12\x1d.control_plane_messages.Empty\x1a%.control_plane_messages.ActorIdsReply\x12f\n" +
"\x11AnnounceOwnership\x12).control_plane_messages.OwnershipAnnounce\x1a&.control_plane_messages.OwnerChangeAck\x12R\n" +
"\x05Apply\x12$.control_plane_messages.ApplyRequest\x1a#.control_plane_messages.ApplyResult\x12`\n" +
"\x0eAnnounceExpiry\x12&.control_plane_messages.ExpiryAnnounce\x1a&.control_plane_messages.OwnerChangeAck\x12X\n" +
"\aClosing\x12%.control_plane_messages.ClosingNotice\x1a&.control_plane_messages.OwnerChangeAck\x12K\n" +
"\x03Get\x12\".control_plane_messages.GetRequest\x1a .control_plane_messages.GetReplyB@Z>git.k6n.net/go-cart-actor/proto/control;control_plane_messagesb\x06proto3"
var file_control_plane_proto_rawDesc = string([]byte{
0x0a, 0x13, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22,
0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x3c, 0x0a, 0x09, 0x50, 0x69, 0x6e, 0x67,
0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20,
0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x75, 0x6e, 0x69,
0x78, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x75, 0x6e,
0x69, 0x78, 0x54, 0x69, 0x6d, 0x65, 0x22, 0x33, 0x0a, 0x10, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69,
0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x6b, 0x6e,
0x6f, 0x77, 0x6e, 0x5f, 0x68, 0x6f, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52,
0x0a, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x48, 0x6f, 0x73, 0x74, 0x73, 0x22, 0x26, 0x0a, 0x0e, 0x4e,
0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x14, 0x0a,
0x05, 0x68, 0x6f, 0x73, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x68, 0x6f,
0x73, 0x74, 0x73, 0x22, 0x21, 0x0a, 0x0d, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x73, 0x52,
0x65, 0x70, 0x6c, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28,
0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22, 0x46, 0x0a, 0x0e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43,
0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65,
0x70, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65,
0x70, 0x74, 0x65, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18,
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x23,
0x0a, 0x0d, 0x43, 0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67, 0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x12,
0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68,
0x6f, 0x73, 0x74, 0x22, 0x39, 0x0a, 0x11, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70,
0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03,
0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22, 0x36,
0x0a, 0x0e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x79, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65,
0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
0x68, 0x6f, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28,
0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x32, 0x8d, 0x03, 0x0a, 0x0c, 0x43, 0x6f, 0x6e, 0x74, 0x72,
0x6f, 0x6c, 0x50, 0x6c, 0x61, 0x6e, 0x65, 0x12, 0x2c, 0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, 0x12,
0x0f, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79,
0x1a, 0x13, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x50, 0x69, 0x6e, 0x67,
0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x41, 0x0a, 0x09, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69, 0x61,
0x74, 0x65, 0x12, 0x1a, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4e, 0x65,
0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18,
0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69,
0x61, 0x74, 0x65, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x3c, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4c,
0x6f, 0x63, 0x61, 0x6c, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x73, 0x12, 0x0f, 0x2e, 0x6d,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x17, 0x2e,
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64,
0x73, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x4a, 0x0a, 0x11, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e,
0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x12, 0x1b, 0x2e, 0x6d, 0x65,
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70,
0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x1a, 0x18, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61,
0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x41,
0x63, 0x6b, 0x12, 0x44, 0x0a, 0x0e, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x45, 0x78,
0x70, 0x69, 0x72, 0x79, 0x12, 0x18, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
0x45, 0x78, 0x70, 0x69, 0x72, 0x79, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x1a, 0x18,
0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43,
0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x12, 0x3c, 0x0a, 0x07, 0x43, 0x6c, 0x6f, 0x73,
0x69, 0x6e, 0x67, 0x12, 0x17, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43,
0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67, 0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x1a, 0x18, 0x2e, 0x6d,
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61,
0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x42, 0x2e, 0x5a, 0x2c, 0x67, 0x69, 0x74, 0x2e, 0x74, 0x6f,
0x72, 0x6e, 0x62, 0x65, 0x72, 0x67, 0x2e, 0x6d, 0x65, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72,
0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x3b, 0x6d, 0x65,
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
})
var (
file_control_plane_proto_rawDescOnce sync.Once
@@ -769,51 +525,36 @@ func file_control_plane_proto_rawDescGZIP() []byte {
return file_control_plane_proto_rawDescData
}
var file_control_plane_proto_msgTypes = make([]protoimpl.MessageInfo, 14)
var file_control_plane_proto_msgTypes = make([]protoimpl.MessageInfo, 9)
var file_control_plane_proto_goTypes = []any{
(*Empty)(nil), // 0: control_plane_messages.Empty
(*PingReply)(nil), // 1: control_plane_messages.PingReply
(*NegotiateRequest)(nil), // 2: control_plane_messages.NegotiateRequest
(*NegotiateReply)(nil), // 3: control_plane_messages.NegotiateReply
(*ActorIdsReply)(nil), // 4: control_plane_messages.ActorIdsReply
(*OwnerChangeAck)(nil), // 5: control_plane_messages.OwnerChangeAck
(*ClosingNotice)(nil), // 6: control_plane_messages.ClosingNotice
(*OwnershipAnnounce)(nil), // 7: control_plane_messages.OwnershipAnnounce
(*ExpiryAnnounce)(nil), // 8: control_plane_messages.ExpiryAnnounce
(*ApplyRequest)(nil), // 9: control_plane_messages.ApplyRequest
(*GetRequest)(nil), // 10: control_plane_messages.GetRequest
(*GetReply)(nil), // 11: control_plane_messages.GetReply
(*MutationResult)(nil), // 12: control_plane_messages.MutationResult
(*ApplyResult)(nil), // 13: control_plane_messages.ApplyResult
(*anypb.Any)(nil), // 14: google.protobuf.Any
(*Empty)(nil), // 0: messages.Empty
(*PingReply)(nil), // 1: messages.PingReply
(*NegotiateRequest)(nil), // 2: messages.NegotiateRequest
(*NegotiateReply)(nil), // 3: messages.NegotiateReply
(*ActorIdsReply)(nil), // 4: messages.ActorIdsReply
(*OwnerChangeAck)(nil), // 5: messages.OwnerChangeAck
(*ClosingNotice)(nil), // 6: messages.ClosingNotice
(*OwnershipAnnounce)(nil), // 7: messages.OwnershipAnnounce
(*ExpiryAnnounce)(nil), // 8: messages.ExpiryAnnounce
}
var file_control_plane_proto_depIdxs = []int32{
14, // 0: control_plane_messages.ApplyRequest.messages:type_name -> google.protobuf.Any
14, // 1: control_plane_messages.GetReply.grain:type_name -> google.protobuf.Any
14, // 2: control_plane_messages.MutationResult.message:type_name -> google.protobuf.Any
14, // 3: control_plane_messages.ApplyResult.state:type_name -> google.protobuf.Any
12, // 4: control_plane_messages.ApplyResult.mutations:type_name -> control_plane_messages.MutationResult
0, // 5: control_plane_messages.ControlPlane.Ping:input_type -> control_plane_messages.Empty
2, // 6: control_plane_messages.ControlPlane.Negotiate:input_type -> control_plane_messages.NegotiateRequest
0, // 7: control_plane_messages.ControlPlane.GetLocalActorIds:input_type -> control_plane_messages.Empty
7, // 8: control_plane_messages.ControlPlane.AnnounceOwnership:input_type -> control_plane_messages.OwnershipAnnounce
9, // 9: control_plane_messages.ControlPlane.Apply:input_type -> control_plane_messages.ApplyRequest
8, // 10: control_plane_messages.ControlPlane.AnnounceExpiry:input_type -> control_plane_messages.ExpiryAnnounce
6, // 11: control_plane_messages.ControlPlane.Closing:input_type -> control_plane_messages.ClosingNotice
10, // 12: control_plane_messages.ControlPlane.Get:input_type -> control_plane_messages.GetRequest
1, // 13: control_plane_messages.ControlPlane.Ping:output_type -> control_plane_messages.PingReply
3, // 14: control_plane_messages.ControlPlane.Negotiate:output_type -> control_plane_messages.NegotiateReply
4, // 15: control_plane_messages.ControlPlane.GetLocalActorIds:output_type -> control_plane_messages.ActorIdsReply
5, // 16: control_plane_messages.ControlPlane.AnnounceOwnership:output_type -> control_plane_messages.OwnerChangeAck
13, // 17: control_plane_messages.ControlPlane.Apply:output_type -> control_plane_messages.ApplyResult
5, // 18: control_plane_messages.ControlPlane.AnnounceExpiry:output_type -> control_plane_messages.OwnerChangeAck
5, // 19: control_plane_messages.ControlPlane.Closing:output_type -> control_plane_messages.OwnerChangeAck
11, // 20: control_plane_messages.ControlPlane.Get:output_type -> control_plane_messages.GetReply
13, // [13:21] is the sub-list for method output_type
5, // [5:13] is the sub-list for method input_type
5, // [5:5] is the sub-list for extension type_name
5, // [5:5] is the sub-list for extension extendee
0, // [0:5] is the sub-list for field type_name
0, // 0: messages.ControlPlane.Ping:input_type -> messages.Empty
2, // 1: messages.ControlPlane.Negotiate:input_type -> messages.NegotiateRequest
0, // 2: messages.ControlPlane.GetLocalActorIds:input_type -> messages.Empty
7, // 3: messages.ControlPlane.AnnounceOwnership:input_type -> messages.OwnershipAnnounce
8, // 4: messages.ControlPlane.AnnounceExpiry:input_type -> messages.ExpiryAnnounce
6, // 5: messages.ControlPlane.Closing:input_type -> messages.ClosingNotice
1, // 6: messages.ControlPlane.Ping:output_type -> messages.PingReply
3, // 7: messages.ControlPlane.Negotiate:output_type -> messages.NegotiateReply
4, // 8: messages.ControlPlane.GetLocalActorIds:output_type -> messages.ActorIdsReply
5, // 9: messages.ControlPlane.AnnounceOwnership:output_type -> messages.OwnerChangeAck
5, // 10: messages.ControlPlane.AnnounceExpiry:output_type -> messages.OwnerChangeAck
5, // 11: messages.ControlPlane.Closing:output_type -> messages.OwnerChangeAck
6, // [6:12] is the sub-list for method output_type
0, // [0:6] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_control_plane_proto_init() }
@@ -821,14 +562,13 @@ func file_control_plane_proto_init() {
if File_control_plane_proto != nil {
return
}
file_control_plane_proto_msgTypes[12].OneofWrappers = []any{}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_control_plane_proto_rawDesc), len(file_control_plane_proto_rawDesc)),
NumEnums: 0,
NumMessages: 14,
NumMessages: 9,
NumExtensions: 0,
NumServices: 1,
},

View File

@@ -1,10 +1,10 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v6.33.1
// - protoc v5.29.3
// source: control_plane.proto
package control_plane_messages
package messages
import (
context "context"
@@ -19,14 +19,12 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
ControlPlane_Ping_FullMethodName = "/control_plane_messages.ControlPlane/Ping"
ControlPlane_Negotiate_FullMethodName = "/control_plane_messages.ControlPlane/Negotiate"
ControlPlane_GetLocalActorIds_FullMethodName = "/control_plane_messages.ControlPlane/GetLocalActorIds"
ControlPlane_AnnounceOwnership_FullMethodName = "/control_plane_messages.ControlPlane/AnnounceOwnership"
ControlPlane_Apply_FullMethodName = "/control_plane_messages.ControlPlane/Apply"
ControlPlane_AnnounceExpiry_FullMethodName = "/control_plane_messages.ControlPlane/AnnounceExpiry"
ControlPlane_Closing_FullMethodName = "/control_plane_messages.ControlPlane/Closing"
ControlPlane_Get_FullMethodName = "/control_plane_messages.ControlPlane/Get"
ControlPlane_Ping_FullMethodName = "/messages.ControlPlane/Ping"
ControlPlane_Negotiate_FullMethodName = "/messages.ControlPlane/Negotiate"
ControlPlane_GetLocalActorIds_FullMethodName = "/messages.ControlPlane/GetLocalActorIds"
ControlPlane_AnnounceOwnership_FullMethodName = "/messages.ControlPlane/AnnounceOwnership"
ControlPlane_AnnounceExpiry_FullMethodName = "/messages.ControlPlane/AnnounceExpiry"
ControlPlane_Closing_FullMethodName = "/messages.ControlPlane/Closing"
)
// ControlPlaneClient is the client API for ControlPlane service.
@@ -43,12 +41,10 @@ type ControlPlaneClient interface {
GetLocalActorIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*ActorIdsReply, error)
// Ownership announcement: first-touch claim broadcast (idempotent; best-effort).
AnnounceOwnership(ctx context.Context, in *OwnershipAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error)
Apply(ctx context.Context, in *ApplyRequest, opts ...grpc.CallOption) (*ApplyResult, error)
// Expiry announcement: drop remote ownership hints when local TTL expires.
AnnounceExpiry(ctx context.Context, in *ExpiryAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error)
// Closing announces graceful shutdown so peers can proactively adjust.
Closing(ctx context.Context, in *ClosingNotice, opts ...grpc.CallOption) (*OwnerChangeAck, error)
Get(ctx context.Context, in *GetRequest, opts ...grpc.CallOption) (*GetReply, error)
}
type controlPlaneClient struct {
@@ -99,16 +95,6 @@ func (c *controlPlaneClient) AnnounceOwnership(ctx context.Context, in *Ownershi
return out, nil
}
func (c *controlPlaneClient) Apply(ctx context.Context, in *ApplyRequest, opts ...grpc.CallOption) (*ApplyResult, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ApplyResult)
err := c.cc.Invoke(ctx, ControlPlane_Apply_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *controlPlaneClient) AnnounceExpiry(ctx context.Context, in *ExpiryAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(OwnerChangeAck)
@@ -129,16 +115,6 @@ func (c *controlPlaneClient) Closing(ctx context.Context, in *ClosingNotice, opt
return out, nil
}
func (c *controlPlaneClient) Get(ctx context.Context, in *GetRequest, opts ...grpc.CallOption) (*GetReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetReply)
err := c.cc.Invoke(ctx, ControlPlane_Get_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// ControlPlaneServer is the server API for ControlPlane service.
// All implementations must embed UnimplementedControlPlaneServer
// for forward compatibility.
@@ -153,12 +129,10 @@ type ControlPlaneServer interface {
GetLocalActorIds(context.Context, *Empty) (*ActorIdsReply, error)
// Ownership announcement: first-touch claim broadcast (idempotent; best-effort).
AnnounceOwnership(context.Context, *OwnershipAnnounce) (*OwnerChangeAck, error)
Apply(context.Context, *ApplyRequest) (*ApplyResult, error)
// Expiry announcement: drop remote ownership hints when local TTL expires.
AnnounceExpiry(context.Context, *ExpiryAnnounce) (*OwnerChangeAck, error)
// Closing announces graceful shutdown so peers can proactively adjust.
Closing(context.Context, *ClosingNotice) (*OwnerChangeAck, error)
Get(context.Context, *GetRequest) (*GetReply, error)
mustEmbedUnimplementedControlPlaneServer()
}
@@ -181,18 +155,12 @@ func (UnimplementedControlPlaneServer) GetLocalActorIds(context.Context, *Empty)
func (UnimplementedControlPlaneServer) AnnounceOwnership(context.Context, *OwnershipAnnounce) (*OwnerChangeAck, error) {
return nil, status.Errorf(codes.Unimplemented, "method AnnounceOwnership not implemented")
}
func (UnimplementedControlPlaneServer) Apply(context.Context, *ApplyRequest) (*ApplyResult, error) {
return nil, status.Errorf(codes.Unimplemented, "method Apply not implemented")
}
func (UnimplementedControlPlaneServer) AnnounceExpiry(context.Context, *ExpiryAnnounce) (*OwnerChangeAck, error) {
return nil, status.Errorf(codes.Unimplemented, "method AnnounceExpiry not implemented")
}
func (UnimplementedControlPlaneServer) Closing(context.Context, *ClosingNotice) (*OwnerChangeAck, error) {
return nil, status.Errorf(codes.Unimplemented, "method Closing not implemented")
}
func (UnimplementedControlPlaneServer) Get(context.Context, *GetRequest) (*GetReply, error) {
return nil, status.Errorf(codes.Unimplemented, "method Get not implemented")
}
func (UnimplementedControlPlaneServer) mustEmbedUnimplementedControlPlaneServer() {}
func (UnimplementedControlPlaneServer) testEmbeddedByValue() {}
@@ -286,24 +254,6 @@ func _ControlPlane_AnnounceOwnership_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler)
}
func _ControlPlane_Apply_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ApplyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ControlPlaneServer).Apply(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ControlPlane_Apply_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ControlPlaneServer).Apply(ctx, req.(*ApplyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ControlPlane_AnnounceExpiry_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ExpiryAnnounce)
if err := dec(in); err != nil {
@@ -340,29 +290,11 @@ func _ControlPlane_Closing_Handler(srv interface{}, ctx context.Context, dec fun
return interceptor(ctx, in, info, handler)
}
func _ControlPlane_Get_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ControlPlaneServer).Get(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ControlPlane_Get_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ControlPlaneServer).Get(ctx, req.(*GetRequest))
}
return interceptor(ctx, in, info, handler)
}
// ControlPlane_ServiceDesc is the grpc.ServiceDesc for ControlPlane service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var ControlPlane_ServiceDesc = grpc.ServiceDesc{
ServiceName: "control_plane_messages.ControlPlane",
ServiceName: "messages.ControlPlane",
HandlerType: (*ControlPlaneServer)(nil),
Methods: []grpc.MethodDesc{
{
@@ -381,10 +313,6 @@ var ControlPlane_ServiceDesc = grpc.ServiceDesc{
MethodName: "AnnounceOwnership",
Handler: _ControlPlane_AnnounceOwnership_Handler,
},
{
MethodName: "Apply",
Handler: _ControlPlane_Apply_Handler,
},
{
MethodName: "AnnounceExpiry",
Handler: _ControlPlane_AnnounceExpiry_Handler,
@@ -393,10 +321,6 @@ var ControlPlane_ServiceDesc = grpc.ServiceDesc{
MethodName: "Closing",
Handler: _ControlPlane_Closing_Handler,
},
{
MethodName: "Get",
Handler: _ControlPlane_Get_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "control_plane.proto",

1306
pkg/messages/messages.pb.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,724 +0,0 @@
package promotions
import (
"errors"
"fmt"
"slices"
"strings"
"time"
"git.k6n.net/go-cart-actor/pkg/cart"
)
var errInvalidTimeFormat = errors.New("invalid time format")
// Tracer allows callers to receive structured debug information during evaluation.
type Tracer interface {
Trace(event string, data map[string]any)
}
// NoopTracer is used when no tracer provided.
type NoopTracer struct{}
func (NoopTracer) Trace(string, map[string]any) {}
// PromotionItem is a lightweight abstraction derived from cart.CartItem
// for the purpose of promotion condition evaluation.
type PromotionItem struct {
SKU string
Quantity uint16
Category string
PriceIncVat int64
}
// PromotionEvalContext carries all dynamic data required to evaluate promotion
// conditions. It can be constructed from a cart.CartGrain plus optional
// customer/order metadata.
type PromotionEvalContext struct {
CartTotalIncVat int64
TotalItemQuantity uint32
Items []PromotionItem
CustomerSegment string
CustomerLifetimeValue float64
OrderCount int
Now time.Time
}
// ContextOption allows customization of fields when building a PromotionEvalContext.
type ContextOption func(*PromotionEvalContext)
// WithCustomerSegment sets the customer segment.
func WithCustomerSegment(seg string) ContextOption {
return func(c *PromotionEvalContext) { c.CustomerSegment = seg }
}
// WithCustomerLifetimeValue sets lifetime value metric.
func WithCustomerLifetimeValue(v float64) ContextOption {
return func(c *PromotionEvalContext) { c.CustomerLifetimeValue = v }
}
// WithOrderCount sets historical order count.
func WithOrderCount(n int) ContextOption {
return func(c *PromotionEvalContext) { c.OrderCount = n }
}
// WithNow overrides the timestamp used for date/time related conditions.
func WithNow(t time.Time) ContextOption {
return func(c *PromotionEvalContext) { c.Now = t }
}
// NewContextFromCart builds a PromotionEvalContext from a CartGrain and optional metadata.
func NewContextFromCart(g *cart.CartGrain, opts ...ContextOption) *PromotionEvalContext {
ctx := &PromotionEvalContext{
Items: make([]PromotionItem, 0, len(g.Items)),
CartTotalIncVat: 0,
TotalItemQuantity: 0,
Now: time.Now(),
}
if g.TotalPrice != nil {
ctx.CartTotalIncVat = g.TotalPrice.IncVat
}
for _, it := range g.Items {
category := ""
if it.Meta != nil {
category = it.Meta.Category
}
ctx.Items = append(ctx.Items, PromotionItem{
SKU: it.Sku,
Quantity: it.Quantity,
Category: strings.ToLower(category),
PriceIncVat: it.Price.IncVat,
})
ctx.TotalItemQuantity += uint32(it.Quantity)
}
for _, o := range opts {
o(ctx)
}
return ctx
}
// PromotionService evaluates PromotionRules against a PromotionEvalContext.
type PromotionService struct {
tracer Tracer
}
// NewPromotionService constructs a PromotionService with an optional tracer.
func NewPromotionService(t Tracer) *PromotionService {
if t == nil {
t = NoopTracer{}
}
return &PromotionService{tracer: t}
}
// EvaluationResult holds the outcome of evaluating a single rule.
type EvaluationResult struct {
Rule PromotionRule
Applicable bool
FailedReason string
MatchedActions []Action
}
// EvaluateRule determines if a single PromotionRule applies to the provided context.
// Returns an EvaluationResult with applicability and actions (if applicable).
func (s *PromotionService) EvaluateRule(rule PromotionRule, ctx *PromotionEvalContext) EvaluationResult {
s.tracer.Trace("rule_start", map[string]any{
"rule_id": rule.ID,
"priority": rule.Priority,
"status": rule.Status,
"startDate": rule.StartDate,
"endDate": rule.EndDate,
})
// Status gate
now := ctx.Now
switch rule.Status {
case StatusInactive, StatusExpired:
s.tracer.Trace("rule_skip_status", map[string]any{"rule_id": rule.ID, "status": rule.Status})
return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "status"}
case StatusScheduled:
// Allow scheduled only if current time >= startDate (and within endDate if present)
}
// Date window checks (if parseable)
if rule.StartDate != "" {
if tStart, err := parseDate(rule.StartDate); err == nil {
if now.Before(tStart) {
s.tracer.Trace("rule_skip_before_start", map[string]any{"rule_id": rule.ID})
return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "before_start"}
}
}
}
if rule.EndDate != nil && *rule.EndDate != "" {
if tEnd, err := parseDate(*rule.EndDate); err == nil {
if now.After(tEnd.Add(23*time.Hour + 59*time.Minute + 59*time.Second)) { // inclusive day
s.tracer.Trace("rule_skip_after_end", map[string]any{"rule_id": rule.ID})
return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "after_end"}
}
}
}
// Usage limit
if rule.UsageLimit != nil && rule.UsageCount >= *rule.UsageLimit {
s.tracer.Trace("rule_skip_usage_limit", map[string]any{"rule_id": rule.ID, "usageCount": rule.UsageCount, "limit": *rule.UsageLimit})
return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "usage_limit_exhausted"}
}
if !evaluateConditionsTrace(rule.Conditions, ctx, s.tracer, rule.ID) {
s.tracer.Trace("rule_conditions_failed", map[string]any{"rule_id": rule.ID})
return EvaluationResult{Rule: rule, Applicable: false, FailedReason: "conditions"}
}
s.tracer.Trace("rule_applicable", map[string]any{"rule_id": rule.ID})
return EvaluationResult{
Rule: rule,
Applicable: true,
MatchedActions: rule.Actions,
}
}
// EvaluateAll returns all applicable promotion actions given a list of rules and context.
// Rules marked Applicable are sorted by Priority (ascending: lower number = higher precedence).
func (s *PromotionService) EvaluateAll(rules []PromotionRule, ctx *PromotionEvalContext) ([]EvaluationResult, []Action) {
results := make([]EvaluationResult, 0, len(rules))
for _, r := range rules {
res := s.EvaluateRule(r, ctx)
results = append(results, res)
}
actions := make([]Action, 0)
for _, res := range orderedByPriority(results) {
if res.Applicable {
s.tracer.Trace("actions_add", map[string]any{
"rule_id": res.Rule.ID,
"count": len(res.MatchedActions),
"priority": res.Rule.Priority,
})
actions = append(actions, res.MatchedActions...)
}
}
s.tracer.Trace("evaluation_complete", map[string]any{
"rules_total": len(rules),
"actions": len(actions),
})
return results, actions
}
// orderedByPriority returns results sorted by PromotionRule.Priority ascending (stable).
func orderedByPriority(in []EvaluationResult) []EvaluationResult {
out := make([]EvaluationResult, len(in))
copy(out, in)
for i := 1; i < len(out); i++ {
j := i
for j > 0 && out[j-1].Rule.Priority > out[j].Rule.Priority {
out[j-1], out[j] = out[j], out[j-1]
j--
}
}
return out
}
// ----------------------------
// Condition evaluation (with tracing)
// ----------------------------
func evaluateConditionsTrace(conds Conditions, ctx *PromotionEvalContext, t Tracer, ruleID string) bool {
for idx, c := range conds {
if !evaluateConditionTrace(c, ctx, t, ruleID, fmt.Sprintf("root[%d]", idx)) {
return false
}
}
return true
}
func evaluateConditionTrace(c Condition, ctx *PromotionEvalContext, t Tracer, ruleID, path string) bool {
if grp, ok := c.(ConditionGroup); ok {
return evaluateGroupTrace(grp, ctx, t, ruleID, path)
}
bc, ok := c.(BaseCondition)
if !ok {
t.Trace("cond_invalid_type", map[string]any{"rule_id": ruleID, "path": path})
return false
}
res := evaluateBaseCondition(bc, ctx)
t.Trace("cond_base", map[string]any{
"rule_id": ruleID,
"path": path,
"type": bc.Type,
"op": bc.Operator,
"value": bc.Value.String(),
"result": res,
})
return res
}
func evaluateGroupTrace(g ConditionGroup, ctx *PromotionEvalContext, t Tracer, ruleID, path string) bool {
op := normalizeLogicOperator(string(g.Operator))
if len(g.Conditions) == 0 {
t.Trace("cond_group_empty", map[string]any{"rule_id": ruleID, "path": path})
return true
}
if op == string(LogicAND) {
for i, child := range g.Conditions {
if !evaluateConditionTrace(child, ctx, t, ruleID, path+fmt.Sprintf(".AND[%d]", i)) {
t.Trace("cond_group_and_fail", map[string]any{"rule_id": ruleID, "path": path})
return false
}
}
t.Trace("cond_group_and_pass", map[string]any{"rule_id": ruleID, "path": path})
return true
}
for i, child := range g.Conditions {
if evaluateConditionTrace(child, ctx, t, ruleID, path+fmt.Sprintf(".OR[%d]", i)) {
t.Trace("cond_group_or_pass", map[string]any{"rule_id": ruleID, "path": path})
return true
}
}
t.Trace("cond_group_or_fail", map[string]any{"rule_id": ruleID, "path": path})
return false
}
// Fallback non-traced evaluation (used internally by traced path)
func evaluateConditions(conds Conditions, ctx *PromotionEvalContext) bool {
for _, c := range conds {
if !evaluateCondition(c, ctx) {
return false
}
}
return true
}
func evaluateCondition(c Condition, ctx *PromotionEvalContext) bool {
if grp, ok := c.(ConditionGroup); ok {
return evaluateGroup(grp, ctx)
}
bc, ok := c.(BaseCondition)
if !ok {
return false
}
return evaluateBaseCondition(bc, ctx)
}
func evaluateGroup(g ConditionGroup, ctx *PromotionEvalContext) bool {
op := normalizeLogicOperator(string(g.Operator))
if len(g.Conditions) == 0 {
return true
}
if op == string(LogicAND) {
for _, child := range g.Conditions {
if !evaluateCondition(child, ctx) {
return false
}
}
return true
}
for _, child := range g.Conditions {
if evaluateCondition(child, ctx) {
return true
}
}
return false
}
func evaluateBaseCondition(b BaseCondition, ctx *PromotionEvalContext) bool {
switch b.Type {
case CondCartTotal:
return evalNumberCompare(float64(ctx.CartTotalIncVat), b)
case CondItemQuantity:
return evalNumberCompare(float64(ctx.TotalItemQuantity), b)
case CondCustomerSegment:
return evalStringCompare(ctx.CustomerSegment, b)
case CondProductCategory:
return evalAnyItemMatch(func(it PromotionItem) bool {
return evalValueAgainstTarget(strings.ToLower(it.Category), b)
}, b, ctx)
case CondProductID:
return evalAnyItemMatch(func(it PromotionItem) bool {
return evalValueAgainstTarget(strings.ToLower(it.SKU), b)
}, b, ctx)
case CondCustomerLifetime:
return evalNumberCompare(ctx.CustomerLifetimeValue, b)
case CondOrderCount:
return evalNumberCompare(float64(ctx.OrderCount), b)
case CondDateRange:
return evalDateRange(ctx.Now, b)
case CondDayOfWeek:
return evalDayOfWeek(ctx.Now, b)
case CondTimeOfDay:
return evalTimeOfDay(ctx.Now, b)
default:
return false
}
}
func evalAnyItemMatch(pred func(PromotionItem) bool, b BaseCondition, ctx *PromotionEvalContext) bool {
if slices.ContainsFunc(ctx.Items, pred) {
return true
}
switch normalizeOperator(string(b.Operator)) {
case string(OpNotIn), string(OpNotContains):
return true
}
return false
}
// ----------------------------
// Primitive evaluators
// ----------------------------
func evalNumberCompare(target float64, b BaseCondition) bool {
op := normalizeOperator(string(b.Operator))
cond, ok := b.Value.AsFloat64()
if !ok {
if list, okL := b.Value.AsFloat64Slice(); okL && (op == string(OpIn) || op == string(OpNotIn)) {
found := sliceFloatContains(list, target)
if op == string(OpIn) {
return found
}
return !found
}
return false
}
switch op {
case string(OpEquals):
return target == cond
case string(OpNotEquals):
return target != cond
case string(OpGreaterThan):
return target > cond
case string(OpLessThan):
return target < cond
case string(OpGreaterOrEqual):
return target >= cond
case string(OpLessOrEqual):
return target <= cond
default:
return false
}
}
func evalStringCompare(target string, b BaseCondition) bool {
op := normalizeOperator(string(b.Operator))
targetLower := strings.ToLower(target)
if s, ok := b.Value.AsString(); ok {
condLower := strings.ToLower(s)
switch op {
case string(OpEquals):
return targetLower == condLower
case string(OpNotEquals):
return targetLower != condLower
case string(OpContains):
return strings.Contains(targetLower, condLower)
case string(OpNotContains):
return !strings.Contains(targetLower, condLower)
}
}
if arr, ok := b.Value.AsStringSlice(); ok {
switch op {
case string(OpIn):
for _, v := range arr {
if targetLower == strings.ToLower(v) {
return true
}
}
return false
case string(OpNotIn):
for _, v := range arr {
if targetLower == strings.ToLower(v) {
return false
}
}
return true
case string(OpContains):
for _, v := range arr {
if strings.Contains(targetLower, strings.ToLower(v)) {
return true
}
}
return false
case string(OpNotContains):
for _, v := range arr {
if strings.Contains(targetLower, strings.ToLower(v)) {
return false
}
}
return true
}
}
return false
}
func evalValueAgainstTarget(target string, b BaseCondition) bool {
op := normalizeOperator(string(b.Operator))
tLower := strings.ToLower(target)
if s, ok := b.Value.AsString(); ok {
vLower := strings.ToLower(s)
switch op {
case string(OpEquals):
return tLower == vLower
case string(OpNotEquals):
return tLower != vLower
case string(OpContains):
return strings.Contains(tLower, vLower)
case string(OpNotContains):
return !strings.Contains(tLower, vLower)
case string(OpIn):
return tLower == vLower
case string(OpNotIn):
return tLower != vLower
}
}
if list, ok := b.Value.AsStringSlice(); ok {
found := false
for _, v := range list {
if tLower == strings.ToLower(v) {
found = true
break
}
}
switch op {
case string(OpIn):
return found
case string(OpNotIn):
return !found
case string(OpContains):
for _, v := range list {
if strings.Contains(tLower, strings.ToLower(v)) {
return true
}
}
return false
case string(OpNotContains):
for _, v := range list {
if strings.Contains(tLower, strings.ToLower(v)) {
return false
}
}
return true
}
}
return false
}
func evalDateRange(now time.Time, b BaseCondition) bool {
var start, end time.Time
if ss, ok := b.Value.AsStringSlice(); ok && len(ss) == 2 {
t0, e0 := parseDate(ss[0])
t1, e1 := parseDate(ss[1])
if e0 != nil || e1 != nil {
return false
}
start, end = t0, t1
} else if s, ok := b.Value.AsString(); ok {
parts := strings.Split(s, "..")
if len(parts) != 2 {
return false
}
t0, e0 := parseDate(parts[0])
t1, e1 := parseDate(parts[1])
if e0 != nil || e1 != nil {
return false
}
start, end = t0, t1
} else {
return false
}
endInclusive := end.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
return (now.Equal(start) || now.After(start)) && (now.Equal(endInclusive) || now.Before(endInclusive))
}
func evalDayOfWeek(now time.Time, b BaseCondition) bool {
dow := int(now.Weekday())
allowed := make(map[int]struct{})
if arr, ok := b.Value.AsStringSlice(); ok {
for _, v := range arr {
if idx, ok := parseDayOfWeek(v); ok {
allowed[idx] = struct{}{}
}
}
} else if s, ok := b.Value.AsString(); ok {
for _, part := range strings.Split(s, "|") {
if idx, ok := parseDayOfWeek(strings.TrimSpace(part)); ok {
allowed[idx] = struct{}{}
}
}
} else {
return false
}
if len(allowed) == 0 {
return false
}
op := normalizeOperator(string(b.Operator))
_, present := allowed[dow]
if op == string(OpIn) || op == string(OpEquals) || op == "" {
return present
}
if op == string(OpNotIn) || op == string(OpNotEquals) {
return !present
}
return present
}
func evalTimeOfDay(now time.Time, b BaseCondition) bool {
var startMin, endMin int
if arr, ok := b.Value.AsStringSlice(); ok && len(arr) == 2 {
s0, e0 := parseClock(arr[0])
s1, e1 := parseClock(arr[1])
if e0 != nil || e1 != nil {
return false
}
startMin, endMin = s0, s1
} else if s, ok := b.Value.AsString(); ok {
parts := strings.Split(s, "-")
if len(parts) != 2 {
return false
}
s0, e0 := parseClock(parts[0])
s1, e1 := parseClock(parts[1])
if e0 != nil || e1 != nil {
return false
}
startMin, endMin = s0, s1
} else {
return false
}
todayMin := now.Hour()*60 + now.Minute()
if startMin > endMin {
return todayMin >= startMin || todayMin <= endMin
}
return todayMin >= startMin && todayMin <= endMin
}
// ----------------------------
// Parsing helpers
// ----------------------------
func parseDate(s string) (time.Time, error) {
layouts := []string{
"2006-01-02",
time.RFC3339,
"2006-01-02T15:04:05Z07:00",
}
for _, l := range layouts {
if t, err := time.Parse(l, strings.TrimSpace(s)); err == nil {
return t, nil
}
}
return time.Time{}, errInvalidTimeFormat
}
func parseDayOfWeek(s string) (int, bool) {
sl := strings.ToLower(strings.TrimSpace(s))
switch sl {
case "sun", "sunday", "0":
return 0, true
case "mon", "monday", "1":
return 1, true
case "tue", "tues", "tuesday", "2":
return 2, true
case "wed", "weds", "wednesday", "3":
return 3, true
case "thu", "thur", "thurs", "thursday", "4":
return 4, true
case "fri", "friday", "5":
return 5, true
case "sat", "saturday", "6":
return 6, true
default:
return 0, false
}
}
func parseClock(s string) (int, error) {
s = strings.TrimSpace(s)
parts := strings.Split(s, ":")
if len(parts) != 2 {
return 0, errInvalidTimeFormat
}
h, err := parsePositiveInt(parts[0])
if err != nil {
return 0, err
}
m, err := parsePositiveInt(parts[1])
if err != nil {
return 0, err
}
if h < 0 || h > 23 || m < 0 || m > 59 {
return 0, errInvalidTimeFormat
}
return h*60 + m, nil
}
func parsePositiveInt(s string) (int, error) {
n := 0
for _, r := range s {
if r < '0' || r > '9' {
return 0, errInvalidTimeFormat
}
n = n*10 + int(r-'0')
}
return n, nil
}
func normalizeOperator(op string) string {
o := strings.ToLower(strings.TrimSpace(op))
switch o {
case "=", "equals", "eq":
return string(OpEquals)
case "!=", "not_equals", "neq":
return string(OpNotEquals)
case ">", "greater_than", "gt":
return string(OpGreaterThan)
case "<", "less_than", "lt":
return string(OpLessThan)
case ">=", "greater_or_equal", "ge", "gte":
return string(OpGreaterOrEqual)
case "<=", "less_or_equal", "le", "lte":
return string(OpLessOrEqual)
case "contains":
return string(OpContains)
case "not_contains":
return string(OpNotContains)
case "in":
return string(OpIn)
case "not_in":
return string(OpNotIn)
default:
return o
}
}
func normalizeLogicOperator(op string) string {
o := strings.ToLower(strings.TrimSpace(op))
switch o {
case "&&", "and":
return string(LogicAND)
case "||", "or":
return string(LogicOR)
default:
return o
}
}
func sliceFloatContains(list []float64, v float64) bool {
return slices.Contains(list, v)
}
// ----------------------------
// Potential extension hooks
// ----------------------------
//
// Future ideas:
// - Conflict resolution strategies (e.g., best discount wins, stackable tags)
// - Action transformation (e.g., applying tiered logic or bundles carefully)
// - Recording evaluation traces for debugging / analytics.
// - Tracing instrumentation for condition evaluation.
//
// These can be integrated by adding strategy interfaces or injecting evaluators
// into PromotionService.
//
// ----------------------------
// End of file
// ----------------------------

View File

@@ -1,448 +0,0 @@
package promotions
import (
"encoding/json"
"slices"
"testing"
"time"
"git.k6n.net/go-cart-actor/pkg/cart"
)
// --- Helpers ---------------------------------------------------------------
func cvNum(n float64) ConditionValue {
b, _ := json.Marshal(n)
return ConditionValue{Raw: b}
}
func cvString(s string) ConditionValue {
b, _ := json.Marshal(s)
return ConditionValue{Raw: b}
}
func cvStrings(ss []string) ConditionValue {
b, _ := json.Marshal(ss)
return ConditionValue{Raw: b}
}
// testTracer captures trace events for assertions.
type testTracer struct {
events []traceEvent
}
type traceEvent struct {
event string
data map[string]any
}
func (t *testTracer) Trace(event string, data map[string]any) {
t.events = append(t.events, traceEvent{event: event, data: data})
}
func (t *testTracer) HasEvent(name string) bool {
return slices.ContainsFunc(t.events, func(e traceEvent) bool { return e.event == name })
}
func (t *testTracer) Count(name string) int {
c := 0
for _, e := range t.events {
if e.event == name {
c++
}
}
return c
}
// makeCart creates a cart with given total and items (each item quantity & price IncVat).
func makeCart(totalOverride int64, items []struct {
sku string
category string
qty uint16
priceInc int64
}) *cart.CartGrain {
g := cart.NewCartGrain(1, time.Now())
for _, it := range items {
p := cart.NewPriceFromIncVat(it.priceInc, 0.25)
g.Items = append(g.Items, &cart.CartItem{
Id: uint32(len(g.Items) + 1),
Sku: it.sku,
Price: *p,
TotalPrice: cart.Price{
IncVat: p.IncVat * int64(it.qty),
VatRates: p.VatRates,
},
Quantity: it.qty,
Meta: &cart.ItemMeta{
Category: it.category,
},
})
}
// Recalculate totals
g.UpdateTotals()
if totalOverride >= 0 {
g.TotalPrice.IncVat = totalOverride
}
return g
}
// --- Tests -----------------------------------------------------------------
func TestEvaluateRuleBasicAND(t *testing.T) {
g := makeCart(12000, []struct {
sku string
category string
qty uint16
priceInc int64
}{
{"SKU-1", "summer", 2, 3000},
{"SKU-2", "winter", 1, 6000},
})
ctx := NewContextFromCart(g,
WithCustomerSegment("vip"),
WithOrderCount(10),
WithCustomerLifetimeValue(1234.56),
WithNow(time.Date(2024, 6, 10, 12, 0, 0, 0, time.UTC)),
)
// Conditions: cart_total >= 10000 AND item_quantity >= 3 AND customer_segment = vip
conds := Conditions{
ConditionGroup{
ID: "grp",
Type: "group",
Operator: LogicAND,
Conditions: Conditions{
BaseCondition{
ID: "c_cart_total",
Type: CondCartTotal,
Operator: OpGreaterOrEqual,
Value: cvNum(10000),
},
BaseCondition{
ID: "c_item_qty",
Type: CondItemQuantity,
Operator: OpGreaterOrEqual,
Value: cvNum(3),
},
BaseCondition{
ID: "c_segment",
Type: CondCustomerSegment,
Operator: OpEquals,
Value: cvString("vip"),
},
},
},
}
rule := PromotionRule{
ID: "rule-AND",
Name: "VIP Summer",
Description: "Test rule",
Status: StatusActive,
Priority: 1,
StartDate: "2024-06-01",
EndDate: ptr("2024-06-30"),
Conditions: conds,
Actions: []Action{
{ID: "a1", Type: ActionPercentageDiscount, Value: 10.0},
},
UsageLimit: ptrInt(100),
UsageCount: 5,
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
CreatedBy: "tester",
}
tracer := &testTracer{}
svc := NewPromotionService(tracer)
res := svc.EvaluateRule(rule, ctx)
if !res.Applicable {
t.Fatalf("expected rule to be applicable, failedReason=%s", res.FailedReason)
}
if len(res.MatchedActions) != 1 {
t.Fatalf("expected 1 action, got %d", len(res.MatchedActions))
}
if !tracer.HasEvent("rule_applicable") {
t.Errorf("expected tracing event rule_applicable")
}
}
func TestEvaluateRuleUsageLimitExhausted(t *testing.T) {
rule := PromotionRule{
ID: "limit",
Name: "Limit",
Status: StatusActive,
Priority: 1,
StartDate: "2024-01-01",
EndDate: nil,
Conditions: Conditions{},
UsageLimit: ptrInt(5),
UsageCount: 5,
}
ctx := &PromotionEvalContext{Now: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)}
tracer := &testTracer{}
svc := NewPromotionService(tracer)
res := svc.EvaluateRule(rule, ctx)
if res.Applicable {
t.Fatalf("expected rule NOT applicable due to usage limit")
}
if res.FailedReason != "usage_limit_exhausted" {
t.Fatalf("expected failedReason usage_limit_exhausted, got %s", res.FailedReason)
}
if !tracer.HasEvent("rule_skip_usage_limit") {
t.Errorf("tracer missing rule_skip_usage_limit event")
}
}
func TestEvaluateRuleDateWindow(t *testing.T) {
// Start in future
rule := PromotionRule{
ID: "date",
Name: "DateWindow",
Status: StatusActive,
Priority: 1,
StartDate: "2025-01-01",
EndDate: ptr("2025-01-31"),
Conditions: Conditions{},
}
ctx := &PromotionEvalContext{Now: time.Date(2024, 12, 15, 12, 0, 0, 0, time.UTC)}
tracer := &testTracer{}
svc := NewPromotionService(tracer)
res := svc.EvaluateRule(rule, ctx)
if res.Applicable {
t.Fatalf("expected rule NOT applicable (before start)")
}
if res.FailedReason != "before_start" {
t.Fatalf("expected failedReason before_start, got %s", res.FailedReason)
}
if !tracer.HasEvent("rule_skip_before_start") {
t.Errorf("missing rule_skip_before_start trace event")
}
}
func TestEvaluateProductCategoryIN(t *testing.T) {
g := makeCart(-1, []struct {
sku string
category string
qty uint16
priceInc int64
}{
{"A", "shoes", 1, 5000},
{"B", "bags", 1, 7000},
})
ctx := NewContextFromCart(g)
conds := Conditions{
BaseCondition{
ID: "c_category",
Type: CondProductCategory,
Operator: OpIn,
Value: cvStrings([]string{"shoes", "hats"}),
},
}
rule := PromotionRule{
ID: "cat-in",
Status: StatusActive,
Priority: 10,
StartDate: "2024-01-01",
EndDate: nil,
Conditions: conds,
Actions: []Action{
{ID: "discount", Type: ActionFixedDiscount, Value: 1000},
},
}
tracer := &testTracer{}
svc := NewPromotionService(tracer)
res := svc.EvaluateRule(rule, ctx)
if !res.Applicable {
t.Fatalf("expected category IN rule to apply")
}
if !tracer.HasEvent("rule_applicable") {
t.Errorf("tracing missing rule_applicable")
}
}
func TestEvaluateGroupOR(t *testing.T) {
g := makeCart(3000, []struct {
sku string
category string
qty uint16
priceInc int64
}{
{"ONE", "x", 1, 3000},
})
ctx := NewContextFromCart(g)
// OR group: (cart_total >= 10000) OR (item_quantity >= 1)
group := ConditionGroup{
ID: "grp-or",
Type: "group",
Operator: LogicOR,
Conditions: Conditions{
BaseCondition{
ID: "c_total",
Type: CondCartTotal,
Operator: OpGreaterOrEqual,
Value: cvNum(10000),
},
BaseCondition{
ID: "c_qty",
Type: CondItemQuantity,
Operator: OpGreaterOrEqual,
Value: cvNum(1),
},
},
}
rule := PromotionRule{
ID: "or-rule",
Status: StatusActive,
Priority: 5,
StartDate: "2024-01-01",
EndDate: nil,
Conditions: Conditions{group},
Actions: []Action{{ID: "a", Type: ActionFixedDiscount, Value: 500}},
}
tracer := &testTracer{}
svc := NewPromotionService(tracer)
res := svc.EvaluateRule(rule, ctx)
if !res.Applicable {
t.Fatalf("expected OR rule to apply (second condition true)")
}
// Ensure group pass event
if !tracer.HasEvent("cond_group_or_pass") {
t.Errorf("expected cond_group_or_pass trace event")
}
}
func TestEvaluateAllPriorityOrdering(t *testing.T) {
ctx := &PromotionEvalContext{
CartTotalIncVat: 20000,
TotalItemQuantity: 2,
Items: []PromotionItem{
{SKU: "X", Quantity: 2, Category: "general", PriceIncVat: 10000},
},
Now: time.Date(2024, 5, 10, 10, 0, 0, 0, time.UTC),
}
// Rule A: priority 5
ruleA := PromotionRule{
ID: "A",
Status: StatusActive,
Priority: 5,
StartDate: "2024-01-01",
EndDate: nil,
Conditions: Conditions{},
Actions: []Action{
{ID: "actionA", Type: ActionFixedDiscount, Value: 100},
},
}
// Rule B: priority 1
ruleB := PromotionRule{
ID: "B",
Status: StatusActive,
Priority: 1,
StartDate: "2024-01-01",
EndDate: nil,
Conditions: Conditions{},
Actions: []Action{
{ID: "actionB", Type: ActionFixedDiscount, Value: 200},
},
}
tracer := &testTracer{}
svc := NewPromotionService(tracer)
results, actions := svc.EvaluateAll([]PromotionRule{ruleA, ruleB}, ctx)
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
if len(actions) != 2 {
t.Fatalf("expected 2 actions, got %d", len(actions))
}
// Actions should follow priority order: ruleB (1) then ruleA (5)
if actions[0].ID != "actionB" || actions[1].ID != "actionA" {
t.Fatalf("actions order invalid: %+v", actions)
}
if tracer.Count("actions_add") != 2 {
t.Errorf("expected 2 actions_add trace events, got %d", tracer.Count("actions_add"))
}
if !tracer.HasEvent("evaluation_complete") {
t.Errorf("missing evaluation_complete trace")
}
}
func TestDayOfWeekAndTimeOfDay(t *testing.T) {
// Wednesday 14:30 UTC
now := time.Date(2024, 6, 12, 14, 30, 0, 0, time.UTC) // 2024-06-12 is Wednesday
ctx := &PromotionEvalContext{
Now: now,
}
condDay := BaseCondition{
ID: "dow",
Type: CondDayOfWeek,
Operator: OpIn,
Value: cvStrings([]string{"wed", "fri"}),
}
condTime := BaseCondition{
ID: "tod",
Type: CondTimeOfDay,
Operator: OpEquals, // operator is ignored for time-of-day internally
Value: cvString("13:00-15:00"),
}
rule := PromotionRule{
ID: "day-time",
Status: StatusActive,
Priority: 1,
StartDate: "2024-01-01",
EndDate: nil,
Conditions: Conditions{condDay, condTime},
Actions: []Action{{ID: "a", Type: ActionPercentageDiscount, Value: 15}},
}
tracer := &testTracer{}
svc := NewPromotionService(tracer)
res := svc.EvaluateRule(rule, ctx)
if !res.Applicable {
t.Fatalf("expected rule to apply for Wednesday 14:30 in window 13-15")
}
}
func TestDateRangeCondition(t *testing.T) {
now := time.Date(2024, 3, 15, 9, 0, 0, 0, time.UTC)
ctx := &PromotionEvalContext{Now: now}
condRange := BaseCondition{
ID: "date_range",
Type: CondDateRange,
Operator: OpEquals, // not used
Value: cvStrings([]string{"2024-03-01", "2024-03-31"}),
}
rule := PromotionRule{
ID: "range",
Status: StatusActive,
Priority: 1,
StartDate: "2024-01-01",
EndDate: nil,
Conditions: Conditions{condRange},
Actions: []Action{{ID: "a", Type: ActionFixedDiscount, Value: 500}},
}
tracer := &testTracer{}
svc := NewPromotionService(tracer)
res := svc.EvaluateRule(rule, ctx)
if !res.Applicable {
t.Fatalf("expected date range rule to apply for 2024-03-15")
}
}
// --- Utilities -------------------------------------------------------------
func ptr(s string) *string { return &s }
func ptrInt(i int) *int { return &i }

View File

@@ -1,443 +0,0 @@
package promotions
import (
"encoding/json"
"testing"
)
// sampleJSON mirrors the user's full example data (all three rules)
var sampleJSON = []byte(`[
{
"id": "1",
"name": "Summer Sale 2024",
"description": "20% off on all summer items",
"status": "active",
"priority": 1,
"startDate": "2024-06-01",
"endDate": "2024-08-31",
"conditions": [
{
"id": "group1",
"type": "group",
"operator": "AND",
"conditions": [
{
"id": "c1",
"type": "product_category",
"operator": "in",
"value": ["summer", "beachwear"],
"label": "Product category is Summer or Beachwear"
},
{
"id": "c2",
"type": "cart_total",
"operator": "greater_or_equal",
"value": 50,
"label": "Cart total is at least $50"
}
]
}
],
"actions": [
{
"id": "a1",
"type": "percentage_discount",
"value": 20,
"label": "20% discount"
}
],
"usageLimit": 1000,
"usageCount": 342,
"createdAt": "2024-05-15T10:00:00Z",
"updatedAt": "2024-05-20T14:30:00Z",
"createdBy": "admin@example.com",
"tags": ["seasonal", "summer"]
},
{
"id": "2",
"name": "VIP Customer Exclusive",
"description": "Free shipping for VIP customers",
"status": "active",
"priority": 2,
"startDate": "2024-01-01",
"endDate": null,
"conditions": [
{
"id": "c3",
"type": "customer_segment",
"operator": "equals",
"value": "vip",
"label": "Customer segment is VIP"
}
],
"actions": [
{
"id": "a2",
"type": "free_shipping",
"value": 0,
"label": "Free shipping"
}
],
"usageCount": 1523,
"createdAt": "2023-12-15T09:00:00Z",
"updatedAt": "2024-01-05T11:20:00Z",
"createdBy": "marketing@example.com",
"tags": ["vip", "loyalty"]
},
{
"id": "3",
"name": "Buy 2 Get 1 Free",
"description": "Buy 2 items, get the cheapest one free",
"status": "scheduled",
"priority": 3,
"startDate": "2024-12-01",
"endDate": "2024-12-25",
"conditions": [
{
"id": "c4",
"type": "item_quantity",
"operator": "greater_or_equal",
"value": 3,
"label": "Cart has at least 3 items"
}
],
"actions": [
{
"id": "a3",
"type": "buy_x_get_y",
"value": 0,
"config": { "buy": 2, "get": 1, "discount": 100 },
"label": "Buy 2 Get 1 Free"
}
],
"usageCount": 0,
"createdAt": "2024-11-01T08:00:00Z",
"updatedAt": "2024-11-01T08:00:00Z",
"createdBy": "admin@example.com",
"tags": ["holiday", "christmas"]
}
]`)
func TestDecodePromotionRulesBasic(t *testing.T) {
rules, err := DecodePromotionRules(sampleJSON)
if err != nil {
t.Fatalf("DecodePromotionRules failed: %v", err)
}
if len(rules) != 3 {
t.Fatalf("expected 3 rules, got %d", len(rules))
}
// Rule 1 checks
r1 := rules[0]
if r1.ID != "1" {
t.Errorf("rule[0].ID = %s, want 1", r1.ID)
}
if r1.Status != StatusActive {
t.Errorf("rule[0].Status = %s, want %s", r1.Status, StatusActive)
}
if r1.EndDate == nil || *r1.EndDate != "2024-08-31" {
t.Errorf("rule[0].EndDate = %v, want 2024-08-31", r1.EndDate)
}
if r1.UsageLimit == nil || *r1.UsageLimit != 1000 {
t.Errorf("rule[0].UsageLimit = %v, want 1000", r1.UsageLimit)
}
// Rule 2 checks
r2 := rules[1]
if r2.ID != "2" {
t.Errorf("rule[1].ID = %s, want 2", r2.ID)
}
if r2.EndDate != nil {
t.Errorf("rule[1].EndDate should be nil (from null), got %v", *r2.EndDate)
}
if r2.UsageLimit != nil {
t.Errorf("rule[1].UsageLimit should be nil (missing), got %v", *r2.UsageLimit)
}
}
func TestConditionDecoding(t *testing.T) {
rules, err := DecodePromotionRules(sampleJSON)
if err != nil {
t.Fatalf("DecodePromotionRules failed: %v", err)
}
r1 := rules[0]
if len(r1.Conditions) != 1 {
t.Fatalf("expected 1 top-level condition group, got %d", len(r1.Conditions))
}
grp, ok := r1.Conditions[0].(ConditionGroup)
if !ok {
t.Fatalf("top-level condition is not a group, type=%T", r1.Conditions[0])
}
if grp.Operator != LogicAND {
t.Errorf("group operator = %s, want AND", grp.Operator)
}
if len(grp.Conditions) != 2 {
t.Fatalf("expected 2 child conditions, got %d", len(grp.Conditions))
}
// First child: product_category condition with slice value
c0, ok := grp.Conditions[0].(BaseCondition)
if !ok {
t.Fatalf("first child not BaseCondition, got %T", grp.Conditions[0])
}
if c0.Type != CondProductCategory {
t.Errorf("first child type = %s, want %s", c0.Type, CondProductCategory)
}
if c0.Operator != OpIn {
t.Errorf("first child operator = %s, want %s", c0.Operator, OpIn)
}
if arr, ok := c0.Value.AsStringSlice(); !ok || len(arr) != 2 || arr[0] != "summer" {
t.Errorf("expected string slice value [summer,...], got %v", arr)
}
// Second child: cart_total numeric
c1, ok := grp.Conditions[1].(BaseCondition)
if !ok {
t.Fatalf("second child not BaseCondition, got %T", grp.Conditions[1])
}
if c1.Type != CondCartTotal {
t.Errorf("second child type = %s, want %s", c1.Type, CondCartTotal)
}
if val, ok := c1.Value.AsFloat64(); !ok || val != 50 {
t.Errorf("expected numeric value 50, got %v (ok=%v)", val, ok)
}
}
func TestConditionValueHelpers(t *testing.T) {
tests := []struct {
name string
jsonVal string
wantStr string
wantNum *float64
wantSS []string
wantFS []float64
}{
{
name: "string value",
jsonVal: `"vip"`,
wantStr: "vip",
},
{
name: "number value",
jsonVal: `42`,
wantNum: floatPtr(42),
},
{
name: "string slice",
jsonVal: `["a","b"]`,
wantSS: []string{"a", "b"},
},
{
name: "number slice int",
jsonVal: `[1,2,3]`,
wantFS: []float64{1, 2, 3},
},
{
name: "number slice float",
jsonVal: `[1.5,2.25]`,
wantFS: []float64{1.5, 2.25},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var cv ConditionValue
if err := json.Unmarshal([]byte(tc.jsonVal), &cv); err != nil {
t.Fatalf("unmarshal value failed: %v", err)
}
if tc.wantStr != "" {
if got, ok := cv.AsString(); !ok || got != tc.wantStr {
t.Errorf("AsString got=%q ok=%v want=%q", got, ok, tc.wantStr)
}
}
if tc.wantNum != nil {
if got, ok := cv.AsFloat64(); !ok || got != *tc.wantNum {
t.Errorf("AsFloat64 got=%v ok=%v want=%v", got, ok, *tc.wantNum)
}
}
if tc.wantSS != nil {
if got, ok := cv.AsStringSlice(); !ok || len(got) != len(tc.wantSS) || got[0] != tc.wantSS[0] {
t.Errorf("AsStringSlice got=%v ok=%v want=%v", got, ok, tc.wantSS)
}
}
if tc.wantFS != nil {
if got, ok := cv.AsFloat64Slice(); !ok || len(got) != len(tc.wantFS) {
t.Errorf("AsFloat64Slice got=%v ok=%v want=%v", got, ok, tc.wantFS)
} else {
for i := range got {
if got[i] != tc.wantFS[i] {
t.Errorf("AsFloat64Slice[%d]=%v want=%v", i, got[i], tc.wantFS[i])
}
}
}
}
})
}
}
func TestWalkConditionsTraversal(t *testing.T) {
rules, err := DecodePromotionRules(sampleJSON)
if err != nil {
t.Fatalf("DecodePromotionRules failed: %v", err)
}
visited := 0
WalkConditions(rules[0].Conditions, func(c Condition) bool {
visited++
return true
})
// group + 2 children
if visited != 3 {
t.Errorf("expected 3 visited conditions, got %d", visited)
}
}
func TestActionBundleConfigParsing(t *testing.T) {
jsonData := []byte(`[
{
"id": "bundle-1",
"name": "Bundle Deal",
"description": "Fixed price bundle",
"status": "active",
"priority": 1,
"startDate": "2024-01-01",
"endDate": null,
"conditions": [],
"actions": [
{
"id": "act-bundle",
"type": "bundle_discount",
"value": 0,
"bundleConfig": {
"containers": [
{
"id": "cont1",
"name": "Shoes",
"quantity": 2,
"selectionType": "any",
"qualifyingRules": {
"type": "category",
"value": "shoes"
}
},
{
"id": "cont2",
"name": "Socks",
"quantity": 3,
"selectionType": "specific",
"qualifyingRules": {
"type": "product_ids",
"value": ["sock-1", "sock-2"]
},
"allowedProducts": ["sock-1","sock-2"]
}
],
"pricing": {
"type": "fixed_price",
"value": 49.99
},
"requireAllContainers": true
},
"config": { "note": "Bundle applies to footwear + socks" }
}
],
"usageCount": 0,
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"createdBy": "bundle@example.com",
"tags": ["bundle","footwear"]
}
]`)
rules, err := DecodePromotionRules(jsonData)
if err != nil {
t.Fatalf("decode failed: %v", err)
}
if len(rules) != 1 {
t.Fatalf("expected 1 rule, got %d", len(rules))
}
if len(rules[0].Actions) != 1 {
t.Fatalf("expected 1 action, got %d", len(rules[0].Actions))
}
act := rules[0].Actions[0]
if act.Type != ActionBundleDiscount {
t.Fatalf("action type = %s, want %s", act.Type, ActionBundleDiscount)
}
if act.BundleConfig == nil {
t.Fatalf("bundleConfig nil")
}
if act.BundleConfig.Pricing.Type != "fixed_price" {
t.Errorf("pricing.type = %s, want fixed_price", act.BundleConfig.Pricing.Type)
}
if act.BundleConfig.Pricing.Value != 49.99 {
t.Errorf("pricing.value = %v, want 49.99", act.BundleConfig.Pricing.Value)
}
if !act.BundleConfig.RequireAllContainers {
t.Errorf("RequireAllContainers expected true")
}
if len(act.BundleConfig.Containers) != 2 {
t.Fatalf("expected 2 containers, got %d", len(act.BundleConfig.Containers))
}
if act.Config == nil || act.Config["note"] != "Bundle applies to footwear + socks" {
t.Errorf("config.note mismatch: %v", act.Config)
}
}
func TestConditionValueInvalidTypes(t *testing.T) {
cases := []struct {
name string
raw string
}{
{"object", `{}`},
{"booleanTrue", `true`},
{"booleanFalse", `false`},
{"null", `null`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var cv ConditionValue
if err := json.Unmarshal([]byte(tc.raw), &cv); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if s, ok := cv.AsString(); ok {
t.Errorf("AsString unexpectedly succeeded (%q) for %s", s, tc.name)
}
if n, ok := cv.AsFloat64(); ok {
t.Errorf("AsFloat64 unexpectedly succeeded (%v) for %s", n, tc.name)
}
if ss, ok := cv.AsStringSlice(); ok {
t.Errorf("AsStringSlice unexpectedly succeeded (%v) for %s", ss, tc.name)
}
if fs, ok := cv.AsFloat64Slice(); ok {
t.Errorf("AsFloat64Slice unexpectedly succeeded (%v) for %s", fs, tc.name)
}
})
}
}
func TestPromotionRuleRoundTrip(t *testing.T) {
orig, err := DecodePromotionRules(sampleJSON)
if err != nil {
t.Fatalf("initial decode failed: %v", err)
}
data, err := json.Marshal(orig)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
round, err := DecodePromotionRules(data)
if err != nil {
t.Fatalf("round-trip decode failed: %v", err)
}
if len(orig) != len(round) {
t.Fatalf("rule count mismatch: orig=%d round=%d", len(orig), len(round))
}
// spot-check first rule
if orig[0].Name != round[0].Name {
t.Errorf("first rule name mismatch: %s vs %s", orig[0].Name, round[0].Name)
}
if len(orig[0].Conditions) != len(round[0].Conditions) {
t.Errorf("first rule condition count mismatch: %d vs %d", len(orig[0].Conditions), len(round[0].Conditions))
}
}
func floatPtr(f float64) *float64 { return &f }

View File

@@ -1,413 +0,0 @@
package promotions
import (
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"strings"
)
// -----------------------------
// Enum-like string types
// -----------------------------
type ConditionOperator string
const (
OpEquals ConditionOperator = "="
OpNotEquals ConditionOperator = "!="
OpGreaterThan ConditionOperator = ">"
OpLessThan ConditionOperator = "<"
OpGreaterOrEqual ConditionOperator = ">="
OpLessOrEqual ConditionOperator = "<="
OpContains ConditionOperator = "contains"
OpNotContains ConditionOperator = "not_contains"
OpIn ConditionOperator = "in"
OpNotIn ConditionOperator = "not_in"
)
type ConditionType string
const (
CondCartTotal ConditionType = "cart_total"
CondItemQuantity ConditionType = "item_quantity"
CondCustomerSegment ConditionType = "customer_segment"
CondProductCategory ConditionType = "product_category"
CondProductID ConditionType = "product_id"
CondCustomerLifetime ConditionType = "customer_lifetime_value"
CondOrderCount ConditionType = "order_count"
CondDateRange ConditionType = "date_range"
CondDayOfWeek ConditionType = "day_of_week"
CondTimeOfDay ConditionType = "time_of_day"
CondGroup ConditionType = "group" // synthetic value for groups
)
type ActionType string
const (
ActionPercentageDiscount ActionType = "percentage_discount"
ActionFixedDiscount ActionType = "fixed_discount"
ActionFreeShipping ActionType = "free_shipping"
ActionBuyXGetY ActionType = "buy_x_get_y"
ActionTieredDiscount ActionType = "tiered_discount"
ActionBundleDiscount ActionType = "bundle_discount"
)
type LogicOperator string
const (
LogicAND LogicOperator = "&&"
LogicOR LogicOperator = "||"
)
type PromotionStatus string
const (
StatusActive PromotionStatus = "active"
StatusInactive PromotionStatus = "inactive"
StatusScheduled PromotionStatus = "scheduled"
StatusExpired PromotionStatus = "expired"
)
// -----------------------------
// Condition Value (union)
// -----------------------------
//
// Represents: string | number | []string | []number
// We store raw JSON and lazily interpret.
type ConditionValue struct {
Raw json.RawMessage
}
func (cv *ConditionValue) UnmarshalJSON(b []byte) error {
if len(b) == 0 {
return errors.New("empty ConditionValue")
}
// Just store raw; interpretation happens via helpers.
cv.Raw = append(cv.Raw[0:0], b...)
return nil
}
// Helpers to interpret value:
func (cv ConditionValue) AsString() (string, bool) {
// Treat explicit JSON null as invalid
if string(cv.Raw) == "null" {
return "", false
}
var s string
if err := json.Unmarshal(cv.Raw, &s); err == nil {
return s, true
}
return "", false
}
func (cv ConditionValue) AsFloat64() (float64, bool) {
if string(cv.Raw) == "null" {
return 0, false
}
var f float64
if err := json.Unmarshal(cv.Raw, &f); err == nil {
return f, true
}
// Attempt integer decode into float64
var i int64
if err := json.Unmarshal(cv.Raw, &i); err == nil {
return float64(i), true
}
return 0, false
}
func (cv ConditionValue) AsStringSlice() ([]string, bool) {
if string(cv.Raw) == "null" {
return nil, false
}
var arr []string
if err := json.Unmarshal(cv.Raw, &arr); err == nil {
return arr, true
}
return nil, false
}
func (cv ConditionValue) AsFloat64Slice() ([]float64, bool) {
if string(cv.Raw) == "null" {
return nil, false
}
var arrNum []float64
if err := json.Unmarshal(cv.Raw, &arrNum); err == nil {
return arrNum, true
}
// Try []int -> []float64
var arrInt []int64
if err := json.Unmarshal(cv.Raw, &arrInt); err == nil {
out := make([]float64, len(arrInt))
for i, v := range arrInt {
out[i] = float64(v)
}
return out, true
}
return nil, false
}
func (cv ConditionValue) String() string {
if s, ok := cv.AsString(); ok {
return s
}
if f, ok := cv.AsFloat64(); ok {
return strconv.FormatFloat(f, 'f', -1, 64)
}
if ss, ok := cv.AsStringSlice(); ok {
return fmt.Sprintf("%v", ss)
}
if fs, ok := cv.AsFloat64Slice(); ok {
return fmt.Sprintf("%v", fs)
}
return string(cv.Raw)
}
// -----------------------------
// BaseCondition
// -----------------------------
type BaseCondition struct {
ID string `json:"id"`
Type ConditionType `json:"type"`
Operator ConditionOperator `json:"operator"`
Value ConditionValue `json:"value"`
Label *string `json:"label,omitempty"`
}
func (b BaseCondition) IsGroup() bool { return false }
// -----------------------------
// ConditionGroup
// -----------------------------
type ConditionGroup struct {
ID string `json:"id"`
Type string `json:"type"` // always "group"
Operator LogicOperator `json:"operator"`
Conditions Conditions `json:"conditions"`
}
// Custom unmarshaller ensures nested polymorphic conditions are decoded
// using the Conditions type (which applies the raw element discriminator).
func (g *ConditionGroup) UnmarshalJSON(b []byte) error {
type alias struct {
ID string `json:"id"`
Type string `json:"type"`
Operator LogicOperator `json:"operator"`
Conditions json.RawMessage `json:"conditions"`
}
var a alias
if err := json.Unmarshal(b, &a); err != nil {
return err
}
// Basic validation
if a.Type != "group" {
return fmt.Errorf("ConditionGroup expected type 'group', got %q", a.Type)
}
var conds Conditions
if len(a.Conditions) > 0 {
if err := json.Unmarshal(a.Conditions, &conds); err != nil {
return err
}
}
g.ID = a.ID
g.Type = a.Type
switch strings.ToLower(string(a.Operator)) {
case "and":
g.Operator = LogicAND
case "or":
g.Operator = LogicOR
default:
g.Operator = a.Operator
}
g.Conditions = conds
return nil
}
func (g ConditionGroup) IsGroup() bool { return true }
// -----------------------------
// Condition interface + slice
// -----------------------------
type Condition interface {
IsGroup() bool
}
// Internal wrapper to help decode each element.
type rawCond struct {
Type string `json:"type"`
}
// Custom unmarshaler for a Condition slice
type Conditions []Condition
func (cs *Conditions) UnmarshalJSON(b []byte) error {
var rawList []json.RawMessage
if err := json.Unmarshal(b, &rawList); err != nil {
return err
}
out := make([]Condition, 0, len(rawList))
for _, elem := range rawList {
var hdr rawCond
if err := json.Unmarshal(elem, &hdr); err != nil {
return err
}
if hdr.Type == "group" {
var grp ConditionGroup
if err := json.Unmarshal(elem, &grp); err != nil {
return err
}
// Recursively decode grp.Conditions (already handled because field type is []Condition)
out = append(out, grp)
} else {
var bc BaseCondition
if err := json.Unmarshal(elem, &bc); err != nil {
return err
}
out = append(out, bc)
}
}
*cs = out
return nil
}
// -----------------------------
// Bundle Structures
// -----------------------------
type BundleQualifyingRules struct {
Type string `json:"type"` // "category" | "product_ids" | "tag" | "all"
Value interface{} `json:"value"` // string or []string
}
type BundleContainer struct {
ID string `json:"id"`
Name string `json:"name"`
Quantity int `json:"quantity"`
SelectionType string `json:"selectionType"` // "any" | "specific"
QualifyingRules BundleQualifyingRules `json:"qualifyingRules"`
AllowedProducts []string `json:"allowedProducts,omitempty"`
}
type BundlePricing struct {
Type string `json:"type"` // "fixed_price" | "percentage_discount" | "fixed_discount"
Value float64 `json:"value"`
}
type BundleConfig struct {
Containers []BundleContainer `json:"containers"`
Pricing BundlePricing `json:"pricing"`
RequireAllContainers bool `json:"requireAllContainers"`
}
// -----------------------------
// Action
// -----------------------------
type Action struct {
ID string `json:"id"`
Type ActionType `json:"type"`
Value interface{} `json:"value"` // number or string
Config map[string]interface{} `json:"config,omitempty"`
BundleConfig *BundleConfig `json:"bundleConfig,omitempty"`
Label *string `json:"label,omitempty"`
}
// -----------------------------
// Promotion Rule
// -----------------------------
type PromotionRule struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Status PromotionStatus `json:"status"`
Priority int `json:"priority"`
StartDate string `json:"startDate"`
EndDate *string `json:"endDate"` // null -> nil
Conditions Conditions `json:"conditions"`
Actions []Action `json:"actions"`
UsageLimit *int `json:"usageLimit,omitempty"`
UsageCount int `json:"usageCount"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
CreatedBy string `json:"createdBy"`
Tags []string `json:"tags"`
}
// -----------------------------
// Promotion Stats
// -----------------------------
type PromotionStats struct {
TotalPromotions int `json:"totalPromotions"`
ActivePromotions int `json:"activePromotions"`
TotalRevenue float64 `json:"totalRevenue"`
TotalOrders int `json:"totalOrders"`
AverageDiscount float64 `json:"averageDiscount"`
}
// -----------------------------
// Utility: Decode array of rules
// -----------------------------
func DecodePromotionRules(data []byte) ([]PromotionRule, error) {
var rules []PromotionRule
if err := json.Unmarshal(data, &rules); err != nil {
return nil, err
}
return rules, nil
}
// Example helper to inspect conditions programmatically.
func WalkConditions(conds []Condition, fn func(c Condition) bool) {
for _, c := range conds {
if !fn(c) {
return
}
if grp, ok := c.(ConditionGroup); ok {
WalkConditions(grp.Conditions, fn)
}
}
}
type PromotionState struct {
Promotions []PromotionRule `json:"promotions"`
}
type StateFile struct {
State PromotionState `json:"state"`
Version int `json:"version"`
}
func (sf *StateFile) GetPromotion(id string) (*PromotionRule, bool) {
for _, v := range sf.State.Promotions {
if v.ID == id {
return &v, true
}
}
return nil, false
}
func LoadStateFile(fileName string) (*StateFile, error) {
f, err := os.Open(fileName)
if err != nil {
return nil, err
}
defer f.Close()
dec := json.NewDecoder(f)
sf := &StateFile{}
err = dec.Decode(sf)
if err != nil {
return nil, err
}
return sf, nil
}

View File

@@ -1,9 +1,7 @@
package proxy
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -11,20 +9,14 @@ import (
"net/http"
"time"
"git.k6n.net/go-cart-actor/pkg/actor"
messages "git.k6n.net/go-cart-actor/proto/control"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
messages "git.tornberg.me/go-cart-actor/pkg/messages"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
)
// RemoteHost mirrors the lightweight controller used for remote node
// interaction.
type RemoteHost[V any] struct {
type RemoteHost struct {
host string
httpBase string
conn *grpc.ClientConn
@@ -34,42 +26,7 @@ type RemoteHost[V any] struct {
missedPings int
}
const name = "proxy"
var (
tracer = otel.Tracer(name)
meter = otel.Meter(name)
logger = otelslog.NewLogger(name)
)
// MockResponseWriter implements http.ResponseWriter to capture responses for proxy calls.
type MockResponseWriter struct {
StatusCode int
HeaderMap http.Header
Body *bytes.Buffer
}
func NewMockResponseWriter() *MockResponseWriter {
return &MockResponseWriter{
StatusCode: 200,
HeaderMap: make(http.Header),
Body: &bytes.Buffer{},
}
}
func (m *MockResponseWriter) Header() http.Header {
return m.HeaderMap
}
func (m *MockResponseWriter) Write(data []byte) (int, error) {
return m.Body.Write(data)
}
func (m *MockResponseWriter) WriteHeader(statusCode int) {
m.StatusCode = statusCode
}
func NewRemoteHost[V any](host string) (*RemoteHost[V], error) {
func NewRemoteHost(host string) (*RemoteHost, error) {
target := fmt.Sprintf("%s:1337", host)
@@ -90,9 +47,9 @@ func NewRemoteHost[V any](host string) (*RemoteHost[V], error) {
}
client := &http.Client{Transport: transport, Timeout: 10 * time.Second}
return &RemoteHost[V]{
return &RemoteHost{
host: host,
httpBase: fmt.Sprintf("http://%s:8080", host),
httpBase: fmt.Sprintf("http://%s:8080/cart", host),
conn: conn,
transport: transport,
client: client,
@@ -101,18 +58,18 @@ func NewRemoteHost[V any](host string) (*RemoteHost[V], error) {
}, nil
}
func (h *RemoteHost[V]) Name() string {
func (h *RemoteHost) Name() string {
return h.host
}
func (h *RemoteHost[V]) Close() error {
func (h *RemoteHost) Close() error {
if h.conn != nil {
h.conn.Close()
}
return nil
}
func (h *RemoteHost[V]) Ping() bool {
func (h *RemoteHost) Ping() bool {
var err error = errors.ErrUnsupported
for err != nil {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
@@ -120,6 +77,7 @@ func (h *RemoteHost[V]) Ping() bool {
cancel()
if err != nil {
h.missedPings++
log.Printf("Ping %s failed (%d) %v", h.host, h.missedPings, err)
}
if !h.IsHealthy() {
return false
@@ -131,71 +89,7 @@ func (h *RemoteHost[V]) Ping() bool {
return true
}
func (h *RemoteHost[V]) Get(ctx context.Context, id uint64) (*V, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
reply, error := h.controlClient.Get(ctx, &messages.GetRequest{Id: id})
if error != nil {
return nil, error
}
var grain V
err := json.Unmarshal(reply.Grain.Value, &grain)
if err != nil {
return nil, fmt.Errorf("failed to unpack state: %w", err)
}
return &grain, nil
}
func (h *RemoteHost[V]) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[V], error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
toSend := make([]*anypb.Any, len(mutation))
for i, msg := range mutation {
anyMsg, err := anypb.New(msg)
if err != nil {
return nil, fmt.Errorf("failed to pack message: %w", err)
}
toSend[i] = anyMsg
}
resp, err := h.controlClient.Apply(ctx, &messages.ApplyRequest{
Id: id,
Messages: toSend,
})
if err != nil {
h.missedPings++
log.Printf("Apply %s failed: %v", h.host, err)
return nil, err
}
h.missedPings = 0
var grain V
err = json.Unmarshal(resp.State.Value, &grain)
if err != nil {
return nil, fmt.Errorf("failed to unpack state: %w", err)
}
var mutationList []actor.ApplyResult
for _, msg := range resp.Mutations {
mutation, err := anypb.UnmarshalNew(msg.Message, proto.UnmarshalOptions{})
if err != nil {
return nil, fmt.Errorf("failed to unpack mutation: %w", err)
}
if msg.Error != nil {
err = errors.New(*msg.Error)
}
mutationList = append(mutationList, actor.ApplyResult{
Mutation: mutation,
Error: err,
})
}
res := &actor.MutationResult[V]{
Result: grain,
Mutations: mutationList,
}
return res, nil
}
func (h *RemoteHost[V]) Negotiate(knownHosts []string) ([]string, error) {
func (h *RemoteHost) Negotiate(knownHosts []string) ([]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -211,7 +105,7 @@ func (h *RemoteHost[V]) Negotiate(knownHosts []string) ([]string, error) {
return resp.Hosts, nil
}
func (h *RemoteHost[V]) GetActorIds() []uint64 {
func (h *RemoteHost) GetActorIds() []uint64 {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
reply, err := h.controlClient.GetLocalActorIds(ctx, &messages.Empty{})
@@ -223,7 +117,7 @@ func (h *RemoteHost[V]) GetActorIds() []uint64 {
return reply.GetIds()
}
func (h *RemoteHost[V]) AnnounceOwnership(ownerHost string, uids []uint64) {
func (h *RemoteHost) AnnounceOwnership(ownerHost string, uids []uint64) {
_, err := h.controlClient.AnnounceOwnership(context.Background(), &messages.OwnershipAnnounce{
Host: ownerHost,
Ids: uids,
@@ -236,7 +130,7 @@ func (h *RemoteHost[V]) AnnounceOwnership(ownerHost string, uids []uint64) {
h.missedPings = 0
}
func (h *RemoteHost[V]) AnnounceExpiry(uids []uint64) {
func (h *RemoteHost) AnnounceExpiry(uids []uint64) {
_, err := h.controlClient.AnnounceExpiry(context.Background(), &messages.ExpiryAnnounce{
Host: h.host,
Ids: uids,
@@ -249,27 +143,11 @@ func (h *RemoteHost[V]) AnnounceExpiry(uids []uint64) {
h.missedPings = 0
}
func (h *RemoteHost[V]) Proxy(id uint64, w http.ResponseWriter, r *http.Request, customBody io.Reader) (bool, error) {
func (h *RemoteHost) Proxy(id uint64, w http.ResponseWriter, r *http.Request) (bool, error) {
target := fmt.Sprintf("%s%s", h.httpBase, r.URL.RequestURI())
ctx, span := tracer.Start(r.Context(), "remote_proxy")
defer span.End()
span.SetAttributes(
attribute.String("component", "proxy"),
attribute.String("cartid", fmt.Sprintf("%d", id)),
attribute.String("host", h.host),
attribute.String("method", r.Method),
attribute.String("target", target),
)
logger.InfoContext(ctx, "proxying request", "cartid", id, "host", h.host, "method", r.Method)
var bdy io.Reader = r.Body
if customBody != nil {
bdy = customBody
}
req, err := http.NewRequestWithContext(ctx, r.Method, target, bdy)
req, err := http.NewRequestWithContext(r.Context(), r.Method, target, r.Body)
if err != nil {
span.RecordError(err)
http.Error(w, "proxy build error", http.StatusBadGateway)
return false, err
}
@@ -283,29 +161,27 @@ func (h *RemoteHost[V]) Proxy(id uint64, w http.ResponseWriter, r *http.Request,
}
res, err := h.client.Do(req)
if err != nil {
span.RecordError(err)
http.Error(w, "proxy request error", http.StatusBadGateway)
return false, err
}
defer res.Body.Close()
span.SetAttributes(attribute.Int("status_code", res.StatusCode))
for k, v := range res.Header {
for _, vv := range v {
w.Header().Add(k, vv)
}
}
w.Header().Set("X-Cart-Owner-Routed", "true")
w.WriteHeader(res.StatusCode)
_, copyErr := io.Copy(w, res.Body)
if copyErr != nil {
span.RecordError(copyErr)
return true, copyErr
if res.StatusCode >= 200 && res.StatusCode <= 299 {
w.WriteHeader(res.StatusCode)
_, copyErr := io.Copy(w, res.Body)
if copyErr != nil {
return true, copyErr
}
return true, nil
}
return true, nil
return false, fmt.Errorf("proxy response status %d", res.StatusCode)
}
func (r *RemoteHost[V]) IsHealthy() bool {
func (r *RemoteHost) IsHealthy() bool {
return r.missedPings < 3
}

View File

@@ -3,7 +3,6 @@ package voucher
import (
"errors"
"fmt"
"slices"
"strconv"
"strings"
"unicode"
@@ -138,7 +137,12 @@ func (rs *RuleSet) Applies(ctx EvalContext) bool {
// anyItem returns true if predicate matches any item.
func anyItem(items []Item, pred func(Item) bool) bool {
return slices.ContainsFunc(items, pred)
for _, it := range items {
if pred(it) {
return true
}
}
return false
}
// ParseRules parses a rule expression into a RuleSet.

Some files were not shown because too many files have changed in this diff Show More