Compare commits
6 Commits
feature/tr
...
716f1121aa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
716f1121aa | ||
|
|
12d87036f6 | ||
|
|
e7c67fbb9b | ||
|
|
b97eb8f285 | ||
|
|
2697832d98 | ||
|
|
4c973b239f |
40
.cursorrules
40
.cursorrules
@@ -1,40 +0,0 @@
|
|||||||
{
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"description": "Project Overview",
|
|
||||||
"rule": "This is a distributed cart management system in Go using the actor model. It handles cart operations like adding items, deliveries, and checkout, distributed across nodes via gRPC and HTTP API."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Key Architecture",
|
|
||||||
"rule": "Use grains for cart state, pools for management, mutation registry for state changes, and control plane for node coordination. Ownership via consistent hashing ring."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Coding Standards",
|
|
||||||
"rule": "Follow Go conventions. Use mutation registry for all state changes. Regenerate protobuf code after proto changes; never edit .pb.go files. Handle errors properly, use cookies for cart IDs in HTTP."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Mutation Pattern",
|
|
||||||
"rule": "For new mutations: Define proto message, regenerate code, implement handler as func(*CartGrain, *T) error, register with RegisterMutation[T], add endpoints, test."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Avoid Direct Mutations",
|
|
||||||
"rule": "Do not mutate CartGrain state directly outside registered handlers. Use the registry for consistency."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Protobuf Handling",
|
|
||||||
"rule": "After modifying proto files, run protoc commands to regenerate Go code. Ensure protoc-gen-go and protoc-gen-go-grpc are installed."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Testing",
|
|
||||||
"rule": "Write unit tests for mutations, integration tests for APIs. Run go test ./... regularly."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Testability and Configurability",
|
|
||||||
"rule": "Design code to be testable and configurable, following examples like MutationRegistry (for type-safe mutation dispatching) and SimpleGrainPool (for configurable pool management). Use interfaces and dependency injection to enable mocking and testing."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Common Patterns",
|
|
||||||
"rule": "HTTP handlers parse requests, resolve grains via SyncedPool, apply mutations, return JSON. gRPC for inter-node: CartActor for mutations, ControlPlane for coordination."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# .dockerignore for go-cart-actor
|
|
||||||
#
|
|
||||||
# Goal: Keep Docker build context lean & reproducible.
|
|
||||||
# Adjust as project structure evolves.
|
|
||||||
|
|
||||||
# Version control & CI metadata
|
|
||||||
.git
|
|
||||||
.git/
|
|
||||||
.gitignore
|
|
||||||
.github
|
|
||||||
|
|
||||||
# Local tooling / editors
|
|
||||||
.vscode
|
|
||||||
.idea
|
|
||||||
*.iml
|
|
||||||
|
|
||||||
# Build artifacts / outputs
|
|
||||||
bin/
|
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
out/
|
|
||||||
coverage/
|
|
||||||
*.coverprofile
|
|
||||||
|
|
||||||
# Temporary files
|
|
||||||
*.tmp
|
|
||||||
*.log
|
|
||||||
tmp/
|
|
||||||
.tmp/
|
|
||||||
|
|
||||||
# Dependency/vendor caches (not used; rely on go modules download)
|
|
||||||
vendor/
|
|
||||||
|
|
||||||
# Examples / scripts (adjust if you actually need them in build context)
|
|
||||||
examples/
|
|
||||||
scripts/
|
|
||||||
|
|
||||||
# Docs (retain README.md explicitly)
|
|
||||||
docs/
|
|
||||||
CHANGELOG*
|
|
||||||
**/*.md
|
|
||||||
!README.md
|
|
||||||
|
|
||||||
# Tests (not needed for production build)
|
|
||||||
**/*_test.go
|
|
||||||
|
|
||||||
# Node / frontend artifacts (if any future addition)
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Docker / container metadata not needed inside image
|
|
||||||
Dockerfile
|
|
||||||
|
|
||||||
# Editor swap/backup files
|
|
||||||
*~
|
|
||||||
*.swp
|
|
||||||
|
|
||||||
# Go race / profiling outputs
|
|
||||||
*.pprof
|
|
||||||
|
|
||||||
# Security / secret placeholders (ensure real secrets never copied)
|
|
||||||
*.secret
|
|
||||||
*.key
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# Keep proto and generated code (do NOT ignore proto/)
|
|
||||||
!proto/
|
|
||||||
|
|
||||||
# End of file
|
|
||||||
@@ -1,47 +1,30 @@
|
|||||||
name: Build and Publish
|
name: Build and Publish
|
||||||
run-name: ${{ gitea.actor }} build 🚀
|
run-name: ${{ gitea.actor }} is building 🚀
|
||||||
on:
|
on: [push]
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
BuildAndDeployAmd64:
|
BuildAndDeployAmd64:
|
||||||
runs-on: amd64
|
runs-on: amd64
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- name: Check out repository code
|
||||||
- name: Build amd64 image
|
uses: actions/checkout@v4
|
||||||
run: |
|
- name: Build docker image
|
||||||
docker build \
|
run: docker build --progress=plain -t registry.knatofs.se/go-cart-actor-amd64:latest .
|
||||||
--progress=plain \
|
- name: Push to registry
|
||||||
-t registry.k6n.net/go-cart-actor-amd64:latest \
|
run: docker push registry.knatofs.se/go-cart-actor-amd64:latest
|
||||||
.
|
- name: Deploy to Kubernetes
|
||||||
- name: Push amd64 images
|
|
||||||
run: |
|
|
||||||
docker push registry.k6n.net/go-cart-actor-amd64:latest
|
|
||||||
- name: Apply deployment manifests
|
|
||||||
run: kubectl apply -f deployment/deployment.yaml -n cart
|
run: kubectl apply -f deployment/deployment.yaml -n cart
|
||||||
- name: Rollout amd64 backoffice deployment
|
- name: Rollout amd64 deployment
|
||||||
run: |
|
run: kubectl rollout restart deployment/cart-actor-x86 -n cart
|
||||||
kubectl rollout restart deployment/cart-backoffice-x86 -n cart
|
|
||||||
kubectl rollout restart deployment/cart-actor-x86 -n cart
|
|
||||||
kubectl rollout restart deployment/checkout-actor-x86 -n cart
|
|
||||||
|
|
||||||
BuildAndDeployArm64:
|
BuildAndDeploy:
|
||||||
runs-on: arm64
|
runs-on: arm64
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Check out repository code
|
||||||
- name: Build arm64 image
|
uses: actions/checkout@v4
|
||||||
run: |
|
- name: Build docker image
|
||||||
docker build \
|
run: docker build --progress=plain -t registry.knatofs.se/go-cart-actor .
|
||||||
--progress=plain \
|
- name: Push to registry
|
||||||
-t registry.k6n.net/go-cart-actor:latest \
|
run: docker push registry.knatofs.se/go-cart-actor
|
||||||
.
|
- name: Rollout arm64 deployment
|
||||||
- name: Push arm64 images
|
run: kubectl rollout restart deployment/cart-actor-arm64 -n cart
|
||||||
run: |
|
|
||||||
docker push registry.k6n.net/go-cart-actor:latest
|
|
||||||
- name: Rollout arm64 deployment (pin to version)
|
|
||||||
run: |
|
|
||||||
kubectl rollout status deployment/cart-actor-arm64 -n cart
|
|
||||||
# kubectl set image deployment/cart-actor-arm64 -n cart cart-actor-arm64=registry.k6n.net/go-cart-actor:${{ needs.Metadata.outputs.version }}
|
|
||||||
# # kubectl rollout status deployment/cart-actor-arm64 -n cart
|
|
||||||
50
.github/copilot-instructions.md
vendored
50
.github/copilot-instructions.md
vendored
@@ -1,50 +0,0 @@
|
|||||||
# GitHub Copilot Instructions for Go Cart Actor
|
|
||||||
|
|
||||||
This repository contains a distributed cart management system implemented in Go using the actor model pattern. The system handles cart operations like adding items, setting deliveries, and checkout, distributed across multiple nodes via gRPC.
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
- `cmd/`: Entry points for the application.
|
|
||||||
- `pkg/`: Core packages including grain logic, pools, and HTTP handlers.
|
|
||||||
- `proto/`: Protocol Buffer definitions for messages and services.
|
|
||||||
- `api-tests/`: Tests for the HTTP API.
|
|
||||||
- `deployment/`: Deployment configurations.
|
|
||||||
- `k6/`: Load testing scripts.
|
|
||||||
|
|
||||||
## Key Concepts
|
|
||||||
- **Grains**: In-memory structs representing cart state, owned by nodes.
|
|
||||||
- **Pools**: Local and synced pools manage grains and handle ownership.
|
|
||||||
- **Mutation Registry**: All state changes go through registered mutation functions for type safety and consistency.
|
|
||||||
- **Ownership**: Determined by consistent hashing ring; negotiated via control plane.
|
|
||||||
|
|
||||||
## Coding Guidelines
|
|
||||||
- Follow standard Go conventions (gofmt, go vet, golint).
|
|
||||||
- Never say "your right", just be correct and clear.
|
|
||||||
- Don't edit the *.gb.go files manually, they are generated by the proto files.
|
|
||||||
- Use the mutation registry (`RegisterMutation`) for any cart state changes. Do not mutate grain state directly outside registered handlers.
|
|
||||||
- Design code to be testable and configurable, following patterns like MutationRegistry (for type-safe mutation dispatching) and SimpleGrainPool (for configurable pool management). Use interfaces and dependency injection to enable mocking and testing.
|
|
||||||
- After modifying `.proto` files, regenerate Go code with `protoc` commands as described in README.md. Never edit generated `.pb.go` files manually.
|
|
||||||
- Use meaningful variable names and add comments for complex logic.
|
|
||||||
- Handle errors explicitly; use Go's error handling patterns.
|
|
||||||
- For HTTP endpoints, ensure proper cookie handling for cart IDs.
|
|
||||||
- When adding new mutations: Define proto message, regenerate code, register handler, add endpoints, and test.
|
|
||||||
- Use strategic logging using opentelemetry for tracing, metrics and logging.
|
|
||||||
- Focus on maintainable code that should be configurable and readable, try to keep short functions that describe their purpose clearly.
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
- Mutations: Define in proto, implement as `func(*CartGrain, *T) error`, register with `RegisterMutation[T]`.
|
|
||||||
- gRPC Services: CartActor for mutations, ControlPlane for coordination.
|
|
||||||
- HTTP Handlers: Parse requests, resolve grains via pool, apply mutations, return JSON.
|
|
||||||
|
|
||||||
## Avoid
|
|
||||||
- Direct state mutations outside the registry.
|
|
||||||
- Hardcoding values; use configuration or constants.
|
|
||||||
- Ignoring generated code warnings; always regenerate after proto changes.
|
|
||||||
- Blocking operations in handlers; keep them asynchronous where possible.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
- Write unit tests for mutations and handlers.
|
|
||||||
- Use integration tests for API endpoints.
|
|
||||||
- Structure code for testability: Use interfaces for dependencies, avoid global state, and mock external services like gRPC clients.
|
|
||||||
- Run `go test ./...` to ensure all tests pass.
|
|
||||||
|
|
||||||
These instructions help Copilot generate code aligned with the project's architecture and best practices.
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,4 +2,3 @@ __debug*
|
|||||||
go-cart-actor
|
go-cart-actor
|
||||||
data/*.prot
|
data/*.prot
|
||||||
data/*.go*
|
data/*.go*
|
||||||
data/se/*
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
go-cart-actor/CGM_LINE_ITEMS_SPEC.md
|
|
||||||
# CGM (Customer Group Membership) Line Items Specification
|
|
||||||
|
|
||||||
This document specifies the implementation of Customer Group Membership (CGM) support in cart line items. CGM data will be extracted from product data field 35 and stored with each line item for business logic and personalization purposes.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
CGM represents customer group membership information associated with products. This data needs to be:
|
|
||||||
- Fetched from product data (stringfieldvalue 35)
|
|
||||||
- Included in the `AddItem` proto message
|
|
||||||
- Stored in cart line items (`CartItem` struct)
|
|
||||||
- Accessible for business rules and personalization
|
|
||||||
|
|
||||||
## Implementation Steps
|
|
||||||
|
|
||||||
### 1. Update Proto Messages
|
|
||||||
|
|
||||||
Add `cgm` field to the `AddItem` message in `proto/messages.proto`:
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message AddItem {
|
|
||||||
// ... existing fields ...
|
|
||||||
string cgm = 25; // Customer Group Membership from field 35
|
|
||||||
// ... existing fields ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note**: Use field number 25 (next available after existing fields).
|
|
||||||
|
|
||||||
### 2. Update Product Fetcher
|
|
||||||
|
|
||||||
Modify `cmd/cart/product-fetcher.go` to extract CGM from field 35:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country string) (*messages.AddItem, error) {
|
|
||||||
// ... existing code ...
|
|
||||||
|
|
||||||
cgm, _ := item.GetStringFieldValue(35) // Extract CGM from field 35
|
|
||||||
|
|
||||||
return &messages.AddItem{
|
|
||||||
// ... existing fields ...
|
|
||||||
Cgm: cgm, // Add CGM field
|
|
||||||
// ... existing fields ...
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Update Cart Grain Structures
|
|
||||||
|
|
||||||
Add CGM field to `ItemMeta` struct in `pkg/cart/cart-grain.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type ItemMeta struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Brand string `json:"brand,omitempty"`
|
|
||||||
Category string `json:"category,omitempty"`
|
|
||||||
// ... existing fields ...
|
|
||||||
Cgm string `json:"cgm,omitempty"` // Customer Group Membership
|
|
||||||
// ... existing fields ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Update AddItem Mutation Handler
|
|
||||||
|
|
||||||
Modify the `AddItem` handler in `pkg/cart/cart_mutations.go` to populate the CGM field:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func AddItem(grain *CartGrain, req *messages.AddItem) error {
|
|
||||||
// ... existing validation ...
|
|
||||||
|
|
||||||
item := &CartItem{
|
|
||||||
// ... existing fields ...
|
|
||||||
Meta: &ItemMeta{
|
|
||||||
Name: req.Name,
|
|
||||||
Brand: req.Brand,
|
|
||||||
// ... existing meta fields ...
|
|
||||||
Cgm: req.Cgm, // Add CGM to item meta
|
|
||||||
// ... existing meta fields ...
|
|
||||||
},
|
|
||||||
// ... existing fields ...
|
|
||||||
}
|
|
||||||
|
|
||||||
// ... rest of handler ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Regenerate Proto Code
|
|
||||||
|
|
||||||
After updating `proto/messages.proto`, regenerate Go code:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd proto
|
|
||||||
protoc --go_out=. --go_opt=paths=source_relative \
|
|
||||||
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
|
||||||
messages.proto cart_actor.proto control_plane.proto
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Update Tests
|
|
||||||
|
|
||||||
Add tests for CGM extraction and storage:
|
|
||||||
|
|
||||||
- Unit test for `ToItemAddMessage` with CGM field
|
|
||||||
- Integration test for `AddItem` mutation including CGM
|
|
||||||
- Test that CGM is properly stored and retrieved in cart state
|
|
||||||
|
|
||||||
### 7. Update API Documentation
|
|
||||||
|
|
||||||
Update README.md and API examples to mention CGM field in line items.
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
1. **Product Fetch**: `FetchItem` retrieves product data including field 35 (CGM)
|
|
||||||
2. **Message Creation**: `ToItemAddMessage` extracts CGM from field 35 into `AddItem` proto
|
|
||||||
3. **Mutation Processing**: `AddItem` handler stores CGM in `CartItem.Meta.Cgm`
|
|
||||||
4. **State Persistence**: CGM is included in cart JSON serialization
|
|
||||||
5. **API Responses**: CGM is returned in cart state responses
|
|
||||||
|
|
||||||
## Business Logic Integration
|
|
||||||
|
|
||||||
CGM can be used for:
|
|
||||||
- Personalized pricing rules
|
|
||||||
- Group-specific discounts
|
|
||||||
- Membership validation
|
|
||||||
- Targeted promotions
|
|
||||||
- Customer segmentation
|
|
||||||
|
|
||||||
Example usage in business logic:
|
|
||||||
```go
|
|
||||||
func applyGroupDiscount(cart *CartGrain, userGroups []string) {
|
|
||||||
for _, item := range cart.Items {
|
|
||||||
if item.Meta != nil && slices.Contains(userGroups, item.Meta.Cgm) {
|
|
||||||
// Apply group-specific discount
|
|
||||||
item.Price = applyDiscount(item.Price, groupDiscountRate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Backward Compatibility
|
|
||||||
|
|
||||||
- CGM field is optional in proto (no required validation)
|
|
||||||
- Existing carts without CGM will have empty string
|
|
||||||
- Product fetcher gracefully handles missing field 35
|
|
||||||
- API responses include CGM field (empty if not set)
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
- [ ] Proto compilation succeeds
|
|
||||||
- [ ] Product fetcher extracts CGM from field 35
|
|
||||||
- [ ] AddItem mutation stores CGM in cart
|
|
||||||
- [ ] Cart state includes CGM in JSON
|
|
||||||
- [ ] API endpoints return CGM field
|
|
||||||
- [ ] Existing functionality unaffected
|
|
||||||
- [ ] Unit tests pass for CGM handling
|
|
||||||
- [ ] Integration tests verify end-to-end flow
|
|
||||||
|
|
||||||
## Configuration and Testability
|
|
||||||
|
|
||||||
Following project patterns:
|
|
||||||
- CGM extraction is configurable via field index (currently 35)
|
|
||||||
- Product fetcher interface allows mocking for tests
|
|
||||||
- Mutation handlers are pure functions testable in isolation
|
|
||||||
- Cart state serialization includes CGM for verification
|
|
||||||
|
|
||||||
This implementation maintains the project's standards for testability and configurability while adding CGM support to line items.
|
|
||||||
95
Dockerfile
95
Dockerfile
@@ -1,96 +1,17 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1
|
||||||
#
|
|
||||||
# Multi-stage build:
|
|
||||||
# 1. Build static binary with pinned Go version (matching go.mod).
|
|
||||||
# 2. Copy into distroless static nonroot runtime image.
|
|
||||||
#
|
|
||||||
# Build args (optional):
|
|
||||||
# VERSION - semantic/app version (default: dev)
|
|
||||||
# GIT_COMMIT - git SHA (default: unknown)
|
|
||||||
# BUILD_DATE - RFC3339 build timestamp
|
|
||||||
#
|
|
||||||
# Example build:
|
|
||||||
# docker build \
|
|
||||||
# --build-arg VERSION=$(git describe --tags --always) \
|
|
||||||
# --build-arg GIT_COMMIT=$(git rev-parse HEAD) \
|
|
||||||
# --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
|
||||||
# -t go-cart-actor:dev .
|
|
||||||
#
|
|
||||||
# If you add subpackages or directories, no Dockerfile change needed (COPY . .).
|
|
||||||
# Ensure a .dockerignore exists to keep context lean.
|
|
||||||
|
|
||||||
############################
|
FROM golang:alpine AS build-stage
|
||||||
# Build Stage
|
WORKDIR /app
|
||||||
############################
|
|
||||||
FROM golang:1.25-alpine AS build
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
RUN apk add --no-cache git
|
|
||||||
|
|
||||||
# Build metadata (can be overridden at build time)
|
|
||||||
ARG VERSION=dev
|
|
||||||
ARG GIT_COMMIT=unknown
|
|
||||||
ARG BUILD_DATE=unknown
|
|
||||||
|
|
||||||
# Ensure reproducible static build
|
|
||||||
# Multi-arch build args (TARGETOS/TARGETARCH provided automatically by buildx)
|
|
||||||
ARG TARGETOS
|
|
||||||
ARG TARGETARCH
|
|
||||||
ENV CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH}
|
|
||||||
|
|
||||||
# Dependency caching
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# Copy full source (relay on .dockerignore to prune)
|
COPY proto ./proto
|
||||||
COPY . .
|
COPY *.go ./
|
||||||
|
|
||||||
# (Optional) If you do NOT check in generated protobuf code, uncomment generation:
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /go-cart-actor
|
||||||
# RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest && \
|
|
||||||
# go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest && \
|
|
||||||
# protoc --go_out=. --go_opt=paths=source_relative \
|
|
||||||
# --go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
|
||||||
# proto/*.proto
|
|
||||||
|
|
||||||
# Build with minimal binary size and embedded metadata
|
FROM gcr.io/distroless/base-debian11
|
||||||
RUN go build -trimpath -ldflags="-s -w \
|
|
||||||
-X main.Version=${VERSION} \
|
|
||||||
-X main.GitCommit=${GIT_COMMIT} \
|
|
||||||
-X main.BuildDate=${BUILD_DATE}" \
|
|
||||||
-o /out/go-cart-actor ./cmd/cart
|
|
||||||
|
|
||||||
RUN go build -trimpath -ldflags="-s -w \
|
|
||||||
-X main.Version=${VERSION} \
|
|
||||||
-X main.GitCommit=${GIT_COMMIT} \
|
|
||||||
-X main.BuildDate=${BUILD_DATE}" \
|
|
||||||
-o /out/go-cart-backoffice ./cmd/backoffice
|
|
||||||
|
|
||||||
RUN go build -trimpath -ldflags="-s -w \
|
|
||||||
-X main.Version=${VERSION} \
|
|
||||||
-X main.GitCommit=${GIT_COMMIT} \
|
|
||||||
-X main.BuildDate=${BUILD_DATE}" \
|
|
||||||
-o /out/go-cart-inventory ./cmd/inventory
|
|
||||||
|
|
||||||
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
|
|
||||||
############################
|
|
||||||
# Using distroless static (nonroot) for minimal surface area.
|
|
||||||
FROM gcr.io/distroless/static-debian12:nonroot AS runtime
|
|
||||||
WORKDIR /
|
WORKDIR /
|
||||||
|
|
||||||
COPY --from=build /out/go-cart-actor /go-cart-actor
|
COPY --from=build-stage /go-cart-actor /go-cart-actor
|
||||||
COPY --from=build /out/go-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
|
|
||||||
|
|
||||||
USER nonroot:nonroot
|
|
||||||
ENTRYPOINT ["/go-cart-actor"]
|
ENTRYPOINT ["/go-cart-actor"]
|
||||||
44
Makefile
44
Makefile
@@ -14,15 +14,12 @@
|
|||||||
# Conventions:
|
# Conventions:
|
||||||
# - All .proto files live in $(PROTO_DIR)
|
# - All .proto files live in $(PROTO_DIR)
|
||||||
# - Generated Go code is emitted under $(PROTO_DIR) via go_package mapping
|
# - 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
|
PROTO_DIR := proto
|
||||||
PROTOS := $(PROTO_DIR)/cart.proto $(PROTO_DIR)/control_plane.proto $(PROTO_DIR)/checkout.proto
|
PROTOS := $(PROTO_DIR)/messages.proto $(PROTO_DIR)/cart_actor.proto $(PROTO_DIR)/control_plane.proto
|
||||||
CART_PROTO_DIR := $(PROTO_DIR)/cart
|
|
||||||
CONTROL_PROTO_DIR := $(PROTO_DIR)/control
|
|
||||||
CHECKOUT_PROTO_DIR := $(PROTO_DIR)/checkout
|
|
||||||
|
|
||||||
# Allow override: make PROTOC=/path/to/protoc
|
# Allow override: make PROTOC=/path/to/protoc
|
||||||
PROTOC ?= protoc
|
PROTOC ?= protoc
|
||||||
@@ -48,7 +45,6 @@ help:
|
|||||||
@echo " protogen Generate protobuf & gRPC code"
|
@echo " protogen Generate protobuf & gRPC code"
|
||||||
@echo " clean_proto Remove generated *.pb.go files in $(PROTO_DIR)"
|
@echo " clean_proto Remove generated *.pb.go files in $(PROTO_DIR)"
|
||||||
@echo " verify_proto Ensure no root-level *.pb.go files (old layout)"
|
@echo " verify_proto Ensure no root-level *.pb.go files (old layout)"
|
||||||
|
|
||||||
@echo " tidy Run go mod tidy"
|
@echo " tidy Run go mod tidy"
|
||||||
@echo " build Build the module"
|
@echo " build Build the module"
|
||||||
@echo " test Run tests (verbose)"
|
@echo " test Run tests (verbose)"
|
||||||
@@ -72,47 +68,27 @@ check_tools:
|
|||||||
protogen: check_tools
|
protogen: check_tools
|
||||||
@echo "$(YELLOW)Generating protobuf code (outputs -> ./proto)...$(RESET)"
|
@echo "$(YELLOW)Generating protobuf code (outputs -> ./proto)...$(RESET)"
|
||||||
$(PROTOC) -I $(PROTO_DIR) \
|
$(PROTOC) -I $(PROTO_DIR) \
|
||||||
--go_out=./proto/cart --go_opt=paths=source_relative \
|
--go_out=./proto --go_opt=paths=source_relative \
|
||||||
--go-grpc_out=./proto/cart --go-grpc_opt=paths=source_relative \
|
--go-grpc_out=./proto --go-grpc_opt=paths=source_relative \
|
||||||
$(PROTO_DIR)/cart.proto
|
$(PROTOS)
|
||||||
$(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
|
|
||||||
@echo "$(GREEN)Protobuf generation complete.$(RESET)"
|
@echo "$(GREEN)Protobuf generation complete.$(RESET)"
|
||||||
|
|
||||||
clean_proto:
|
clean_proto:
|
||||||
@echo "$(YELLOW)Removing generated protobuf files...$(RESET)"
|
@echo "$(YELLOW)Removing generated protobuf files...$(RESET)"
|
||||||
@rm -f $(PROTO_DIR)/cart/*_grpc.pb.go $(PROTO_DIR)/cart/*.pb.go
|
@rm -f $(PROTO_DIR)/*_grpc.pb.go $(PROTO_DIR)/*.pb.go
|
||||||
@rm -f $(PROTO_DIR)/control/*_grpc.pb.go $(PROTO_DIR)/control/*.pb.go
|
@rm -f *.pb.go
|
||||||
@rm -f $(PROTO_DIR)/checkout/*_grpc.pb.go $(PROTO_DIR)/checkout/*.pb.go
|
@rm -rf git.tornberg.me
|
||||||
@echo "$(GREEN)Clean complete.$(RESET)"
|
@echo "$(GREEN)Clean complete.$(RESET)"
|
||||||
|
|
||||||
verify_proto:
|
verify_proto:
|
||||||
@echo "$(YELLOW)Verifying proto layout...$(RESET)"
|
@echo "$(YELLOW)Verifying proto layout...$(RESET)"
|
||||||
@if ls *.pb.go >/dev/null 2>&1; then \
|
@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; \
|
ls -1 *.pb.go; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
fi
|
fi
|
||||||
@echo "$(GREEN)Proto layout OK (no root-level *.pb.go files).$(RESET)"
|
@echo "$(GREEN)Proto layout OK (no root-level *.pb.go files).$(RESET)"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
tidy:
|
tidy:
|
||||||
@echo "$(YELLOW)Running go mod tidy...$(RESET)"
|
@echo "$(YELLOW)Running go mod tidy...$(RESET)"
|
||||||
$(GO) mod tidy
|
$(GO) mod tidy
|
||||||
|
|||||||
@@ -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.
|
|
||||||
11
README.md
11
README.md
@@ -240,8 +240,8 @@ Responsibilities:
|
|||||||
|
|
||||||
1. Discovery integration (via a `Discovery` interface) adds/removes hosts.
|
1. Discovery integration (via a `Discovery` interface) adds/removes hosts.
|
||||||
2. Periodic ping health checks (ControlPlane.Ping).
|
2. Periodic ping health checks (ControlPlane.Ping).
|
||||||
3. Ring-based deterministic ownership:
|
3. Ownership negotiation:
|
||||||
- Ownership is derived directly from the consistent hashing ring (no quorum RPC or `ConfirmOwner`).
|
- On first contention / unknown owner, node calls `ConfirmOwner` on peers to achieve quorum before making a local grain authoritative.
|
||||||
4. Remote spawning:
|
4. Remote spawning:
|
||||||
- When a remote host reports its cart ids (`GetCartIds`), the pool creates remote proxies for fast routing.
|
- When a remote host reports its cart ids (`GetCartIds`), the pool creates remote proxies for fast routing.
|
||||||
|
|
||||||
@@ -270,6 +270,7 @@ Defined in `proto/control_plane.proto`:
|
|||||||
| `Ping` | Liveness; increments missed ping counter if failing. |
|
| `Ping` | Liveness; increments missed ping counter if failing. |
|
||||||
| `Negotiate` | Merges membership views; used after discovery events. |
|
| `Negotiate` | Merges membership views; used after discovery events. |
|
||||||
| `GetCartIds` | Enumerate locally owned carts for remote index seeding. |
|
| `GetCartIds` | Enumerate locally owned carts for remote index seeding. |
|
||||||
|
| `ConfirmOwner` | Quorum acknowledgment for ownership claim. |
|
||||||
| `Closing` | Graceful shutdown notice; peers remove host & associated remote grains. |
|
| `Closing` | Graceful shutdown notice; peers remove host & associated remote grains. |
|
||||||
|
|
||||||
### Ownership / Quorum Rules
|
### Ownership / Quorum Rules
|
||||||
@@ -346,7 +347,7 @@ Defined in `proto/control_plane.proto`:
|
|||||||
## gRPC Interfaces
|
## gRPC Interfaces
|
||||||
|
|
||||||
- **CartActor**: Per-mutation unary RPCs + `GetState`. (Checkout logic intentionally excluded; handled at HTTP layer.)
|
- **CartActor**: Per-mutation unary RPCs + `GetState`. (Checkout logic intentionally excluded; handled at HTTP layer.)
|
||||||
- **ControlPlane**: Cluster coordination (Ping, Negotiate, GetCartIds, Closing) — ownership now ring-determined (no ConfirmOwner).
|
- **ControlPlane**: Cluster coordination (Ping, Negotiate, ConfirmOwner, etc.).
|
||||||
|
|
||||||
**Ports** (default / implied):
|
**Ports** (default / implied):
|
||||||
- CartActor & ControlPlane share the same gRPC server/listener (single port, e.g. `:1337`).
|
- CartActor & ControlPlane share the same gRPC server/listener (single port, e.g. `:1337`).
|
||||||
@@ -395,7 +396,7 @@ Defined in `proto/control_plane.proto`:
|
|||||||
```
|
```
|
||||||
Client -> HTTP Handler -> SyncedPool -> (local?) -> Registry -> Grain State
|
Client -> HTTP Handler -> SyncedPool -> (local?) -> Registry -> Grain State
|
||||||
\-> (remote?) -> RemoteGrainGRPC -> gRPC -> Remote CartActor -> Registry -> Grain
|
\-> (remote?) -> RemoteGrainGRPC -> gRPC -> Remote CartActor -> Registry -> Grain
|
||||||
ControlPlane: Discovery Events <-> Negotiation/Ping <-> SyncedPool state (ring determines ownership)
|
ControlPlane: Discovery Events <-> Negotiation/Ping/ConfirmOwner <-> SyncedPool state
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -406,7 +407,7 @@ ControlPlane: Discovery Events <-> Negotiation/Ping <-> SyncedPool state (ring d
|
|||||||
|---------|--------------|--------|
|
|---------|--------------|--------|
|
||||||
| New cart every request | Secure cookie over plain HTTP or not sending cookie jar | Disable Secure locally or use HTTPS & proper curl `-b` |
|
| New cart every request | Secure cookie over plain HTTP or not sending cookie jar | Disable Secure locally or use HTTPS & proper curl `-b` |
|
||||||
| Unsupported mutation error | Missing registry handler | Add `RegisterMutation` for that proto |
|
| Unsupported mutation error | Missing registry handler | Add `RegisterMutation` for that proto |
|
||||||
| Ownership imbalance | Ring host distribution skew or rapid host churn | Examine `cart_ring_host_share`, `cart_ring_hosts`, and logs for host add/remove; rebalance or investigate instability |
|
| Ownership flapping | Quorum failing due to intermittent peers | Investigate `ConfirmOwner` errors / network |
|
||||||
| Remote mutation latency | Network / serialization overhead | Consider batching or colocating hot carts |
|
| Remote mutation latency | Network / serialization overhead | Consider batching or colocating hot carts |
|
||||||
| Checkout returns 500 | Klarna call failed | Inspect logs; no grain state mutated |
|
| Checkout returns 500 | Klarna call failed | Inspect logs; no grain state mutated |
|
||||||
|
|
||||||
|
|||||||
61
amqp-order-handler.go
Normal file
61
amqp-order-handler.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AmqpOrderHandler struct {
|
||||||
|
Url string
|
||||||
|
Connection *amqp.Connection
|
||||||
|
Channel *amqp.Channel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AmqpOrderHandler) Connect() error {
|
||||||
|
conn, err := amqp.Dial(h.Url)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to RabbitMQ: %w", err)
|
||||||
|
}
|
||||||
|
h.Connection = conn
|
||||||
|
|
||||||
|
ch, err := conn.Channel()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open a channel: %w", err)
|
||||||
|
}
|
||||||
|
h.Channel = ch
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AmqpOrderHandler) Close() error {
|
||||||
|
if h.Channel != nil {
|
||||||
|
h.Channel.Close()
|
||||||
|
}
|
||||||
|
if h.Connection != nil {
|
||||||
|
return h.Connection.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AmqpOrderHandler) OrderCompleted(body []byte) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := h.Channel.PublishWithContext(ctx,
|
||||||
|
"orders", // exchange
|
||||||
|
"new", // routing key
|
||||||
|
false, // mandatory
|
||||||
|
false, // immediate
|
||||||
|
amqp.Publishing{
|
||||||
|
ContentType: "application/json",
|
||||||
|
Body: body,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to publish a message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
### Add item to cart
|
### Add item to cart
|
||||||
POST https://cart.k6n.net/api/12345
|
POST https://cart.tornberg.me/api/12345
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -9,7 +9,7 @@ Content-Type: application/json
|
|||||||
}
|
}
|
||||||
|
|
||||||
### Update quanity of item in cart
|
### Update quanity of item in cart
|
||||||
PUT https://cart.k6n.net/api/12345
|
PUT https://cart.tornberg.me/api/12345
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -18,12 +18,12 @@ Content-Type: application/json
|
|||||||
}
|
}
|
||||||
|
|
||||||
### Delete item from cart
|
### Delete item from cart
|
||||||
DELETE https://cart.k6n.net/api/1002/1
|
DELETE https://cart.tornberg.me/api/1002/1
|
||||||
|
|
||||||
|
|
||||||
### Set delivery
|
### Set delivery
|
||||||
|
|
||||||
POST https://cart.k6n.net/api/1002/delivery
|
POST https://cart.tornberg.me/api/1002/delivery
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -33,8 +33,10 @@ Content-Type: application/json
|
|||||||
|
|
||||||
|
|
||||||
### Get cart
|
### Get cart
|
||||||
GET https://cart.k6n.net/api/12345
|
GET https://cart.tornberg.me/api/12345
|
||||||
|
|
||||||
|
|
||||||
### Remove delivery method
|
### Remove delivery method
|
||||||
DELETE https://cart.k6n.net/api/12345/delivery/2
|
DELETE https://cart.tornberg.me/api/12345/delivery/2
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
294
cart-grain.go
Normal file
294
cart-grain.go
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CartId [16]byte
|
||||||
|
|
||||||
|
// String returns the cart id as a trimmed UTF-8 string (trailing zero bytes removed).
|
||||||
|
func (id CartId) String() string {
|
||||||
|
n := 0
|
||||||
|
for n < len(id) && id[n] != 0 {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return string(id[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToCartId converts an arbitrary string to a fixed-size CartId (truncating or padding with zeros).
|
||||||
|
func ToCartId(s string) CartId {
|
||||||
|
var id CartId
|
||||||
|
copy(id[:], []byte(s))
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (id CartId) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(id.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (id *CartId) UnmarshalJSON(data []byte) error {
|
||||||
|
var str string
|
||||||
|
err := json.Unmarshal(data, &str)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
copy(id[:], []byte(str))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type StockStatus int
|
||||||
|
|
||||||
|
const (
|
||||||
|
OutOfStock StockStatus = 0
|
||||||
|
LowStock StockStatus = 1
|
||||||
|
InStock StockStatus = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
type CartItem struct {
|
||||||
|
Id int `json:"id"`
|
||||||
|
ItemId int `json:"itemId,omitempty"`
|
||||||
|
ParentId int `json:"parentId,omitempty"`
|
||||||
|
Sku string `json:"sku"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Price int64 `json:"price"`
|
||||||
|
TotalPrice int64 `json:"totalPrice"`
|
||||||
|
TotalTax int64 `json:"totalTax"`
|
||||||
|
OrgPrice int64 `json:"orgPrice"`
|
||||||
|
Stock StockStatus `json:"stock"`
|
||||||
|
Quantity int `json:"qty"`
|
||||||
|
Tax int `json:"tax"`
|
||||||
|
TaxRate int `json:"taxRate"`
|
||||||
|
Brand string `json:"brand,omitempty"`
|
||||||
|
Category string `json:"category,omitempty"`
|
||||||
|
Category2 string `json:"category2,omitempty"`
|
||||||
|
Category3 string `json:"category3,omitempty"`
|
||||||
|
Category4 string `json:"category4,omitempty"`
|
||||||
|
Category5 string `json:"category5,omitempty"`
|
||||||
|
Disclaimer string `json:"disclaimer,omitempty"`
|
||||||
|
SellerId string `json:"sellerId,omitempty"`
|
||||||
|
SellerName string `json:"sellerName,omitempty"`
|
||||||
|
ArticleType string `json:"type,omitempty"`
|
||||||
|
Image string `json:"image,omitempty"`
|
||||||
|
Outlet *string `json:"outlet,omitempty"`
|
||||||
|
StoreId *string `json:"storeId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CartDelivery struct {
|
||||||
|
Id int `json:"id"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Price int64 `json:"price"`
|
||||||
|
Items []int `json:"items"`
|
||||||
|
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CartGrain struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
lastItemId int
|
||||||
|
lastDeliveryId int
|
||||||
|
Id CartId `json:"id"`
|
||||||
|
Items []*CartItem `json:"items"`
|
||||||
|
TotalPrice int64 `json:"totalPrice"`
|
||||||
|
TotalTax int64 `json:"totalTax"`
|
||||||
|
TotalDiscount int64 `json:"totalDiscount"`
|
||||||
|
Deliveries []*CartDelivery `json:"deliveries,omitempty"`
|
||||||
|
Processing bool `json:"processing"`
|
||||||
|
PaymentInProgress bool `json:"paymentInProgress"`
|
||||||
|
OrderReference string `json:"orderReference,omitempty"`
|
||||||
|
PaymentStatus string `json:"paymentStatus,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Grain interface {
|
||||||
|
GetId() CartId
|
||||||
|
Apply(content interface{}, isReplay bool) (*CartGrain, error)
|
||||||
|
GetCurrentState() (*CartGrain, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) GetId() CartId {
|
||||||
|
return c.Id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) GetLastChange() int64 {
|
||||||
|
// Legacy event log removed; return 0 to indicate no persisted mutation history.
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) GetCurrentState() (*CartGrain, error) {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getInt(data float64, ok bool) (int, error) {
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("invalid type")
|
||||||
|
}
|
||||||
|
return int(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getItemData(sku string, qty int, country string) (*messages.AddItem, error) {
|
||||||
|
item, err := FetchItem(sku, country)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
orgPrice, _ := getInt(item.GetNumberFieldValue(5)) // getInt(item.Fields[5])
|
||||||
|
|
||||||
|
price, priceErr := getInt(item.GetNumberFieldValue(4)) //Fields[4]
|
||||||
|
|
||||||
|
if priceErr != nil {
|
||||||
|
return nil, fmt.Errorf("invalid price")
|
||||||
|
}
|
||||||
|
|
||||||
|
stock := InStock
|
||||||
|
/*item.t
|
||||||
|
if item.StockLevel == "0" || item.StockLevel == "" {
|
||||||
|
stock = OutOfStock
|
||||||
|
} else if item.StockLevel == "5+" {
|
||||||
|
stock = LowStock
|
||||||
|
}*/
|
||||||
|
articleType, _ := item.GetStringFieldValue(1) //.Fields[1].(string)
|
||||||
|
outletGrade, ok := item.GetStringFieldValue(20) //.Fields[20].(string)
|
||||||
|
var outlet *string
|
||||||
|
if ok {
|
||||||
|
outlet = &outletGrade
|
||||||
|
}
|
||||||
|
sellerId, _ := item.GetStringFieldValue(24) // .Fields[24].(string)
|
||||||
|
sellerName, _ := item.GetStringFieldValue(9) // .Fields[9].(string)
|
||||||
|
|
||||||
|
brand, _ := item.GetStringFieldValue(2) //.Fields[2].(string)
|
||||||
|
category, _ := item.GetStringFieldValue(10) //.Fields[10].(string)
|
||||||
|
category2, _ := item.GetStringFieldValue(11) //.Fields[11].(string)
|
||||||
|
category3, _ := item.GetStringFieldValue(12) //.Fields[12].(string)
|
||||||
|
category4, _ := item.GetStringFieldValue(13) //Fields[13].(string)
|
||||||
|
category5, _ := item.GetStringFieldValue(14) //.Fields[14].(string)
|
||||||
|
|
||||||
|
return &messages.AddItem{
|
||||||
|
ItemId: int64(item.Id),
|
||||||
|
Quantity: int32(qty),
|
||||||
|
Price: int64(price),
|
||||||
|
OrgPrice: int64(orgPrice),
|
||||||
|
Sku: sku,
|
||||||
|
Name: item.Title,
|
||||||
|
Image: item.Img,
|
||||||
|
Stock: int32(stock),
|
||||||
|
Brand: brand,
|
||||||
|
Category: category,
|
||||||
|
Category2: category2,
|
||||||
|
Category3: category3,
|
||||||
|
Category4: category4,
|
||||||
|
Category5: category5,
|
||||||
|
Tax: 2500,
|
||||||
|
SellerId: sellerId,
|
||||||
|
SellerName: sellerName,
|
||||||
|
ArticleType: articleType,
|
||||||
|
Disclaimer: item.Disclaimer,
|
||||||
|
Country: country,
|
||||||
|
Outlet: outlet,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) AddItem(sku string, qty int, country string, storeId *string) (*CartGrain, error) {
|
||||||
|
cartItem, err := getItemData(sku, qty, country)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cartItem.StoreId = storeId
|
||||||
|
return c.Apply(cartItem, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Legacy storage (event sourcing) removed in oneof refactor.
|
||||||
|
Kept stub (commented) for potential future reintroduction using proto envelopes.
|
||||||
|
|
||||||
|
func (c *CartGrain) GetStorageMessage(since int64) []interface{} {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
func (c *CartGrain) GetState() ([]byte, error) {
|
||||||
|
return json.Marshal(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) ItemsWithDelivery() []int {
|
||||||
|
ret := make([]int, 0, len(c.Items))
|
||||||
|
for _, item := range c.Items {
|
||||||
|
for _, delivery := range c.Deliveries {
|
||||||
|
for _, id := range delivery.Items {
|
||||||
|
if item.Id == id {
|
||||||
|
ret = append(ret, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) ItemsWithoutDelivery() []int {
|
||||||
|
ret := make([]int, 0, len(c.Items))
|
||||||
|
hasDelivery := c.ItemsWithDelivery()
|
||||||
|
for _, item := range c.Items {
|
||||||
|
found := false
|
||||||
|
for _, id := range hasDelivery {
|
||||||
|
if item.Id == id {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
ret = append(ret, item.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) FindItemWithSku(sku string) (*CartItem, bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
for _, item := range c.Items {
|
||||||
|
if item.Sku == sku {
|
||||||
|
return item, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTaxAmount(total int64, tax int) int64 {
|
||||||
|
taxD := 10000 / float64(tax)
|
||||||
|
return int64(float64(total) / float64((1 + taxD)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) Apply(content interface{}, isReplay bool) (*CartGrain, error) {
|
||||||
|
grainMutations.Inc()
|
||||||
|
|
||||||
|
updated, err := ApplyRegistered(c, content)
|
||||||
|
if err != nil {
|
||||||
|
if err == ErrMutationNotRegistered {
|
||||||
|
return nil, fmt.Errorf("unsupported mutation type %T (not registered)", content)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CartGrain) UpdateTotals() {
|
||||||
|
c.TotalPrice = 0
|
||||||
|
c.TotalTax = 0
|
||||||
|
c.TotalDiscount = 0
|
||||||
|
for _, item := range c.Items {
|
||||||
|
rowTotal := item.Price * int64(item.Quantity)
|
||||||
|
rowTax := int64(item.Tax) * int64(item.Quantity)
|
||||||
|
item.TotalPrice = rowTotal
|
||||||
|
item.TotalTax = rowTax
|
||||||
|
c.TotalPrice += rowTotal
|
||||||
|
c.TotalTax += rowTax
|
||||||
|
itemDiff := max(0, item.OrgPrice-item.Price)
|
||||||
|
c.TotalDiscount += itemDiff * int64(item.Quantity)
|
||||||
|
}
|
||||||
|
for _, delivery := range c.Deliveries {
|
||||||
|
c.TotalPrice += delivery.Price
|
||||||
|
c.TotalTax += GetTaxAmount(delivery.Price, 2500)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
211
cart_state_mapper.go
Normal file
211
cart_state_mapper.go
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cart_state_mapper.go
|
||||||
|
//
|
||||||
|
// Utilities to translate between internal CartGrain state and the gRPC
|
||||||
|
// (typed) protobuf representation CartState. This replaces the previous
|
||||||
|
// JSON blob framing and enables type-safe replies over gRPC, as well as
|
||||||
|
// internal reuse for HTTP handlers without an extra marshal / unmarshal
|
||||||
|
// hop (you can marshal CartState directly for JSON responses if desired).
|
||||||
|
//
|
||||||
|
// Only the one‑way mapping (CartGrain -> CartState) is strictly required
|
||||||
|
// for mutation / state replies. A reverse helper is included in case
|
||||||
|
// future features (e.g. snapshot import, replay, or migration) need it.
|
||||||
|
|
||||||
|
// ToCartState converts the in‑memory CartGrain into a protobuf CartState.
|
||||||
|
func ToCartState(c *CartGrain) *messages.CartState {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]*messages.CartItemState, 0, len(c.Items))
|
||||||
|
for _, it := range c.Items {
|
||||||
|
if it == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
itemDiscountPerUnit := max(0, it.OrgPrice-it.Price)
|
||||||
|
itemTotalDiscount := itemDiscountPerUnit * int64(it.Quantity)
|
||||||
|
|
||||||
|
items = append(items, &messages.CartItemState{
|
||||||
|
Id: int64(it.Id),
|
||||||
|
SourceItemId: int64(it.ItemId),
|
||||||
|
Sku: it.Sku,
|
||||||
|
Name: it.Name,
|
||||||
|
UnitPrice: it.Price,
|
||||||
|
Quantity: int32(it.Quantity),
|
||||||
|
TotalPrice: it.TotalPrice,
|
||||||
|
TotalTax: it.TotalTax,
|
||||||
|
OrgPrice: it.OrgPrice,
|
||||||
|
TaxRate: int32(it.TaxRate),
|
||||||
|
TotalDiscount: itemTotalDiscount,
|
||||||
|
Brand: it.Brand,
|
||||||
|
Category: it.Category,
|
||||||
|
Category2: it.Category2,
|
||||||
|
Category3: it.Category3,
|
||||||
|
Category4: it.Category4,
|
||||||
|
Category5: it.Category5,
|
||||||
|
Image: it.Image,
|
||||||
|
ArticleType: it.ArticleType,
|
||||||
|
SellerId: it.SellerId,
|
||||||
|
SellerName: it.SellerName,
|
||||||
|
Disclaimer: it.Disclaimer,
|
||||||
|
Outlet: deref(it.Outlet),
|
||||||
|
StoreId: deref(it.StoreId),
|
||||||
|
Stock: int32(it.Stock),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
deliveries := make([]*messages.DeliveryState, 0, len(c.Deliveries))
|
||||||
|
for _, d := range c.Deliveries {
|
||||||
|
if d == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
itemIds := make([]int64, 0, len(d.Items))
|
||||||
|
for _, id := range d.Items {
|
||||||
|
itemIds = append(itemIds, int64(id))
|
||||||
|
}
|
||||||
|
var pp *messages.PickupPoint
|
||||||
|
if d.PickupPoint != nil {
|
||||||
|
// Copy to avoid accidental shared mutation (proto points are fine but explicit).
|
||||||
|
pp = &messages.PickupPoint{
|
||||||
|
Id: d.PickupPoint.Id,
|
||||||
|
Name: d.PickupPoint.Name,
|
||||||
|
Address: d.PickupPoint.Address,
|
||||||
|
City: d.PickupPoint.City,
|
||||||
|
Zip: d.PickupPoint.Zip,
|
||||||
|
Country: d.PickupPoint.Country,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deliveries = append(deliveries, &messages.DeliveryState{
|
||||||
|
Id: int64(d.Id),
|
||||||
|
Provider: d.Provider,
|
||||||
|
Price: d.Price,
|
||||||
|
ItemIds: itemIds,
|
||||||
|
PickupPoint: pp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &messages.CartState{
|
||||||
|
CartId: c.Id.String(),
|
||||||
|
Items: items,
|
||||||
|
TotalPrice: c.TotalPrice,
|
||||||
|
TotalTax: c.TotalTax,
|
||||||
|
TotalDiscount: c.TotalDiscount,
|
||||||
|
Deliveries: deliveries,
|
||||||
|
PaymentInProgress: c.PaymentInProgress,
|
||||||
|
OrderReference: c.OrderReference,
|
||||||
|
PaymentStatus: c.PaymentStatus,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromCartState merges a protobuf CartState into an existing CartGrain.
|
||||||
|
// This is optional and primarily useful for snapshot import or testing.
|
||||||
|
func FromCartState(cs *messages.CartState, g *CartGrain) *CartGrain {
|
||||||
|
if cs == nil {
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
if g == nil {
|
||||||
|
g = &CartGrain{}
|
||||||
|
}
|
||||||
|
g.Id = ToCartId(cs.CartId)
|
||||||
|
g.TotalPrice = cs.TotalPrice
|
||||||
|
g.TotalTax = cs.TotalTax
|
||||||
|
g.TotalDiscount = cs.TotalDiscount
|
||||||
|
g.PaymentInProgress = cs.PaymentInProgress
|
||||||
|
g.OrderReference = cs.OrderReference
|
||||||
|
g.PaymentStatus = cs.PaymentStatus
|
||||||
|
|
||||||
|
// Items
|
||||||
|
g.Items = g.Items[:0]
|
||||||
|
for _, it := range cs.Items {
|
||||||
|
if it == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outlet := toPtr(it.Outlet)
|
||||||
|
storeId := toPtr(it.StoreId)
|
||||||
|
g.Items = append(g.Items, &CartItem{
|
||||||
|
Id: int(it.Id),
|
||||||
|
ItemId: int(it.SourceItemId),
|
||||||
|
Sku: it.Sku,
|
||||||
|
Name: it.Name,
|
||||||
|
Price: it.UnitPrice,
|
||||||
|
Quantity: int(it.Quantity),
|
||||||
|
TotalPrice: it.TotalPrice,
|
||||||
|
TotalTax: it.TotalTax,
|
||||||
|
OrgPrice: it.OrgPrice,
|
||||||
|
TaxRate: int(it.TaxRate),
|
||||||
|
Brand: it.Brand,
|
||||||
|
Category: it.Category,
|
||||||
|
Category2: it.Category2,
|
||||||
|
Category3: it.Category3,
|
||||||
|
Category4: it.Category4,
|
||||||
|
Category5: it.Category5,
|
||||||
|
Image: it.Image,
|
||||||
|
ArticleType: it.ArticleType,
|
||||||
|
SellerId: it.SellerId,
|
||||||
|
SellerName: it.SellerName,
|
||||||
|
Disclaimer: it.Disclaimer,
|
||||||
|
Outlet: outlet,
|
||||||
|
StoreId: storeId,
|
||||||
|
Stock: StockStatus(it.Stock),
|
||||||
|
// Tax, TaxRate already set via Price / Totals if needed
|
||||||
|
})
|
||||||
|
if it.Id > int64(g.lastItemId) {
|
||||||
|
g.lastItemId = int(it.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliveries
|
||||||
|
g.Deliveries = g.Deliveries[:0]
|
||||||
|
for _, d := range cs.Deliveries {
|
||||||
|
if d == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
intIds := make([]int, 0, len(d.ItemIds))
|
||||||
|
for _, id := range d.ItemIds {
|
||||||
|
intIds = append(intIds, int(id))
|
||||||
|
}
|
||||||
|
var pp *messages.PickupPoint
|
||||||
|
if d.PickupPoint != nil {
|
||||||
|
pp = &messages.PickupPoint{
|
||||||
|
Id: d.PickupPoint.Id,
|
||||||
|
Name: d.PickupPoint.Name,
|
||||||
|
Address: d.PickupPoint.Address,
|
||||||
|
City: d.PickupPoint.City,
|
||||||
|
Zip: d.PickupPoint.Zip,
|
||||||
|
Country: d.PickupPoint.Country,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.Deliveries = append(g.Deliveries, &CartDelivery{
|
||||||
|
Id: int(d.Id),
|
||||||
|
Provider: d.Provider,
|
||||||
|
Price: d.Price,
|
||||||
|
Items: intIds,
|
||||||
|
PickupPoint: pp,
|
||||||
|
})
|
||||||
|
if d.Id > int64(g.lastDeliveryId) {
|
||||||
|
g.lastDeliveryId = int(d.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to safely de-reference optional string pointers to value or "".
|
||||||
|
func deref(p *string) string {
|
||||||
|
if p == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
func toPtr(s string) *string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
119
checkout_builder.go
Normal file
119
checkout_builder.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CheckoutMeta carries the external / URL metadata required to build a
|
||||||
|
// Klarna CheckoutOrder from a CartGrain snapshot. It deliberately excludes
|
||||||
|
// any Klarna-specific response fields (HTML snippet, client token, etc.).
|
||||||
|
type CheckoutMeta struct {
|
||||||
|
Terms string
|
||||||
|
Checkout string
|
||||||
|
Confirmation string
|
||||||
|
Validation string
|
||||||
|
Push string
|
||||||
|
Country string
|
||||||
|
Currency string // optional override (defaults to "SEK" if empty)
|
||||||
|
Locale string // optional override (defaults to "sv-se" if empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildCheckoutOrderPayload converts the current cart grain + meta information
|
||||||
|
// into a CheckoutOrder domain struct and returns its JSON-serialized payload
|
||||||
|
// (to send to Klarna) alongside the structured CheckoutOrder object.
|
||||||
|
//
|
||||||
|
// This function is PURE: it does not perform any network I/O or mutate the
|
||||||
|
// grain. The caller is responsible for:
|
||||||
|
//
|
||||||
|
// 1. Choosing whether to create or update the Klarna order.
|
||||||
|
// 2. Invoking KlarnaClient.CreateOrder / UpdateOrder with the returned payload.
|
||||||
|
// 3. Applying an InitializeCheckout mutation (or equivalent) with the
|
||||||
|
// resulting Klarna order id + status.
|
||||||
|
//
|
||||||
|
// If you later need to support different tax rates per line, you can extend
|
||||||
|
// CartItem / Delivery to expose that data and propagate it here.
|
||||||
|
func BuildCheckoutOrderPayload(grain *CartGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
|
||||||
|
if grain == nil {
|
||||||
|
return nil, nil, fmt.Errorf("nil grain")
|
||||||
|
}
|
||||||
|
if meta == nil {
|
||||||
|
return nil, nil, fmt.Errorf("nil checkout meta")
|
||||||
|
}
|
||||||
|
|
||||||
|
currency := meta.Currency
|
||||||
|
if currency == "" {
|
||||||
|
currency = "SEK"
|
||||||
|
}
|
||||||
|
locale := meta.Locale
|
||||||
|
if locale == "" {
|
||||||
|
locale = "sv-se"
|
||||||
|
}
|
||||||
|
country := meta.Country
|
||||||
|
if country == "" {
|
||||||
|
country = "SE" // sensible default; adjust if multi-country support changes
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := make([]*Line, 0, len(grain.Items)+len(grain.Deliveries))
|
||||||
|
|
||||||
|
// Item lines
|
||||||
|
for _, it := range grain.Items {
|
||||||
|
if it == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, &Line{
|
||||||
|
Type: "physical",
|
||||||
|
Reference: it.Sku,
|
||||||
|
Name: it.Name,
|
||||||
|
Quantity: it.Quantity,
|
||||||
|
UnitPrice: int(it.Price),
|
||||||
|
TaxRate: 2500, // TODO: derive if variable tax rates are introduced
|
||||||
|
QuantityUnit: "st",
|
||||||
|
TotalAmount: int(it.TotalPrice),
|
||||||
|
TotalTaxAmount: int(it.TotalTax),
|
||||||
|
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", it.Image),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delivery lines
|
||||||
|
for _, d := range grain.Deliveries {
|
||||||
|
if d == nil || d.Price <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, &Line{
|
||||||
|
Type: "shipping_fee",
|
||||||
|
Reference: d.Provider,
|
||||||
|
Name: "Delivery",
|
||||||
|
Quantity: 1,
|
||||||
|
UnitPrice: int(d.Price),
|
||||||
|
TaxRate: 2500,
|
||||||
|
QuantityUnit: "st",
|
||||||
|
TotalAmount: int(d.Price),
|
||||||
|
TotalTaxAmount: int(GetTaxAmount(d.Price, 2500)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
order := &CheckoutOrder{
|
||||||
|
PurchaseCountry: country,
|
||||||
|
PurchaseCurrency: currency,
|
||||||
|
Locale: locale,
|
||||||
|
OrderAmount: int(grain.TotalPrice),
|
||||||
|
OrderTaxAmount: int(grain.TotalTax),
|
||||||
|
OrderLines: lines,
|
||||||
|
MerchantReference1: grain.Id.String(),
|
||||||
|
MerchantURLS: &CheckoutMerchantURLS{
|
||||||
|
Terms: meta.Terms,
|
||||||
|
Checkout: meta.Checkout,
|
||||||
|
Confirmation: meta.Confirmation,
|
||||||
|
Validation: meta.Validation,
|
||||||
|
Push: meta.Push,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(order)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("marshal checkout order: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload, order, nil
|
||||||
|
}
|
||||||
@@ -1,439 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/actor"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/checkout"
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
type FileServer struct {
|
|
||||||
// Define fields here
|
|
||||||
dataDir string
|
|
||||||
checkoutDataDir string
|
|
||||||
storage actor.LogStorage[cart.CartGrain]
|
|
||||||
checkoutStorage actor.LogStorage[checkout.CheckoutGrain]
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewFileServer(dataDir string, checkoutDataDir string, storage actor.LogStorage[cart.CartGrain], checkoutStorage actor.LogStorage[checkout.CheckoutGrain]) *FileServer {
|
|
||||||
return &FileServer{
|
|
||||||
dataDir: dataDir,
|
|
||||||
checkoutDataDir: checkoutDataDir,
|
|
||||||
storage: storage,
|
|
||||||
checkoutStorage: checkoutStorage,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isValidId(id string) (uint64, bool) {
|
|
||||||
if nr, err := strconv.ParseUint(id, 10, 64); err == nil {
|
|
||||||
return nr, true
|
|
||||||
}
|
|
||||||
if nr, ok := cart.ParseCartId(id); ok {
|
|
||||||
return uint64(nr), true
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func isValidFileId(name string) (uint64, bool) {
|
|
||||||
|
|
||||||
parts := strings.Split(name, ".")
|
|
||||||
if len(parts) > 1 && parts[1] == "events" {
|
|
||||||
idStr := parts[0]
|
|
||||||
|
|
||||||
return isValidId(idStr)
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// func AccessTime(info os.FileInfo) (time.Time, bool) {
|
|
||||||
// switch stat := info.Sys().(type) {
|
|
||||||
// case *syscall.Stat_t:
|
|
||||||
// // Linux: Atim; macOS/BSD: Atimespec
|
|
||||||
// // Use reflection or build tags if naming differs.
|
|
||||||
// // Linux:
|
|
||||||
// if stat.Atim.Sec != 0 || stat.Atim.Nsec != 0 {
|
|
||||||
// return time.Unix(int64(stat.Atim.Sec), int64(stat.Atim.Nsec)), true
|
|
||||||
// }
|
|
||||||
// // macOS/BSD example (uncomment if needed):
|
|
||||||
// //return time.Unix(int64(stat.Atimespec.Sec), int64(stat.Atimespec.Nsec)), true
|
|
||||||
// }
|
|
||||||
// return time.Time{}, false
|
|
||||||
// }
|
|
||||||
|
|
||||||
func appendFileInfo(info fs.FileInfo, out *CartFileInfo) *CartFileInfo {
|
|
||||||
//sys := info.Sys()
|
|
||||||
//fmt.Printf("sys type %T", sys)
|
|
||||||
out.Size = info.Size()
|
|
||||||
out.Modified = info.ModTime()
|
|
||||||
//out.Accessed, _ = AccessTime(info)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func appendCheckoutFileInfo(info fs.FileInfo, out *CheckoutFileInfo) *CheckoutFileInfo {
|
|
||||||
out.Size = info.Size()
|
|
||||||
out.Modified = info.ModTime()
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// var cartFileRe = regexp.MustCompile(`^(\d+)\.events\.log$`)
|
|
||||||
|
|
||||||
func listCartFiles(dir string) ([]*CartFileInfo, error) {
|
|
||||||
entries, err := os.ReadDir(dir)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return []*CartFileInfo{}, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out := make([]*CartFileInfo, 0)
|
|
||||||
for _, e := range entries {
|
|
||||||
if e.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
id, valid := isValidFileId(e.Name())
|
|
||||||
if !valid {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := e.Info()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
info.Sys()
|
|
||||||
out = append(out, appendFileInfo(info, &CartFileInfo{
|
|
||||||
ID: fmt.Sprintf("%d", id),
|
|
||||||
CartId: cart.CartId(id),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func listCheckoutFiles(dir string) ([]*CheckoutFileInfo, error) {
|
|
||||||
entries, err := os.ReadDir(dir)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return []*CheckoutFileInfo{}, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out := make([]*CheckoutFileInfo, 0)
|
|
||||||
for _, e := range entries {
|
|
||||||
if e.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
id, valid := isValidFileId(e.Name())
|
|
||||||
if !valid {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := e.Info()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, appendCheckoutFileInfo(info, &CheckoutFileInfo{
|
|
||||||
ID: fmt.Sprintf("%d", id),
|
|
||||||
CheckoutId: checkout.CheckoutId(id),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readRawLogLines(path string) ([]json.RawMessage, error) {
|
|
||||||
fh, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer fh.Close()
|
|
||||||
lines := make([]json.RawMessage, 0, 64)
|
|
||||||
s := bufio.NewScanner(fh)
|
|
||||||
// increase buffer to handle larger JSON lines
|
|
||||||
buf := make([]byte, 0, 1024*1024)
|
|
||||||
s.Buffer(buf, 1024*1024)
|
|
||||||
for s.Scan() {
|
|
||||||
line := s.Bytes()
|
|
||||||
if line == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lines = append(lines, line)
|
|
||||||
}
|
|
||||||
if err := s.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return lines, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fs *FileServer) CartsHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
list, err := listCartFiles(fs.dataDir)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// sort by modified desc
|
|
||||||
sort.Slice(list, func(i, j int) bool { return list[i].Modified.After(list[j].Modified) })
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"count": len(list),
|
|
||||||
"carts": list,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fs *FileServer) CheckoutsHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
list, err := listCheckoutFiles(fs.checkoutDataDir)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// sort by modified desc
|
|
||||||
sort.Slice(list, func(i, j int) bool { return list[i].Modified.After(list[j].Modified) })
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"count": len(list),
|
|
||||||
"checkouts": list,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fs *FileServer) PromotionsHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
fileName := filepath.Join(fs.dataDir, "promotions.json")
|
|
||||||
if r.Method == http.MethodGet {
|
|
||||||
file, err := os.Open(fileName)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
io.Copy(w, file)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Method == http.MethodPost {
|
|
||||||
file, err := os.Create(fileName)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
io.Copy(file, r.Body)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fs *FileServer) VoucherHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
fileName := filepath.Join(fs.dataDir, "vouchers.json")
|
|
||||||
if r.Method == http.MethodGet {
|
|
||||||
file, err := os.Open(fileName)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
io.Copy(w, file)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Method == http.MethodPost {
|
|
||||||
file, err := os.Create(fileName)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
io.Copy(file, r.Body)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fs *FileServer) PromotionPartHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
idStr := r.PathValue("id")
|
|
||||||
if idStr == "" {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
fmt.Fprintf(w, "missing id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, ok := isValidId(idStr)
|
|
||||||
if !ok {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
fmt.Fprintf(w, "invalid id %s", idStr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusNotImplemented)
|
|
||||||
}
|
|
||||||
|
|
||||||
type JsonError struct {
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func acceptAll(_ proto.Message, _ int, _ time.Time) bool {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func acceptUntilIndex(maxIndex int) func(msg proto.Message, index int, when time.Time) bool {
|
|
||||||
return func(msg proto.Message, index int, when time.Time) bool {
|
|
||||||
return index < maxIndex
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func acceptUntilTimestamp(until time.Time) func(msg proto.Message, index int, when time.Time) bool {
|
|
||||||
return func(msg proto.Message, index int, when time.Time) bool {
|
|
||||||
return when.Before(until)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fs *FileServer) CartHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
idStr := r.PathValue("id")
|
|
||||||
if idStr == "" {
|
|
||||||
writeJSON(w, http.StatusBadRequest, JsonError{Error: "missing id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id, ok := isValidId(idStr)
|
|
||||||
if !ok {
|
|
||||||
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// parse query parameters for filtering
|
|
||||||
query := r.URL.Query()
|
|
||||||
filterFunction := acceptAll
|
|
||||||
if maxIndexStr := query.Get("maxIndex"); maxIndexStr != "" {
|
|
||||||
log.Printf("filter maxIndex: %s", maxIndexStr)
|
|
||||||
maxIndex, err := strconv.Atoi(maxIndexStr)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid maxIndex"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
filterFunction = acceptUntilIndex(maxIndex)
|
|
||||||
} else if untilStr := query.Get("until"); untilStr != "" {
|
|
||||||
log.Printf("filter until: %s", untilStr)
|
|
||||||
until, err := time.Parse(time.RFC3339, untilStr)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid until timestamp"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
filterFunction = acceptUntilTimestamp(until)
|
|
||||||
}
|
|
||||||
// reconstruct state from event log if present
|
|
||||||
grain := cart.NewCartGrain(id, time.Now())
|
|
||||||
err := fs.storage.LoadEventsFunc(r.Context(), id, grain, filterFunction)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
path := filepath.Join(fs.dataDir, fmt.Sprintf("%d.events.log", id))
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil && errors.Is(err, os.ErrNotExist) {
|
|
||||||
writeJSON(w, http.StatusNotFound, JsonError{Error: "cart not found"})
|
|
||||||
return
|
|
||||||
} else if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lines, err := readRawLogLines(path)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"id": id,
|
|
||||||
"cartId": cart.CartId(id).String(),
|
|
||||||
"state": grain,
|
|
||||||
"mutations": lines,
|
|
||||||
"meta": map[string]any{
|
|
||||||
"size": info.Size(),
|
|
||||||
"modified": info.ModTime(),
|
|
||||||
"path": path,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fs *FileServer) CheckoutHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
idStr := r.PathValue("id")
|
|
||||||
if idStr == "" {
|
|
||||||
writeJSON(w, http.StatusBadRequest, JsonError{Error: "missing id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id, ok := isValidId(idStr)
|
|
||||||
if !ok {
|
|
||||||
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// parse query parameters for filtering
|
|
||||||
query := r.URL.Query()
|
|
||||||
filterFunction := acceptAll
|
|
||||||
if maxIndexStr := query.Get("maxIndex"); maxIndexStr != "" {
|
|
||||||
log.Printf("filter maxIndex: %s", maxIndexStr)
|
|
||||||
maxIndex, err := strconv.Atoi(maxIndexStr)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid maxIndex"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
filterFunction = acceptUntilIndex(maxIndex)
|
|
||||||
} else if untilStr := query.Get("until"); untilStr != "" {
|
|
||||||
log.Printf("filter until: %s", untilStr)
|
|
||||||
until, err := time.Parse(time.RFC3339, untilStr)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid until timestamp"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
filterFunction = acceptUntilTimestamp(until)
|
|
||||||
}
|
|
||||||
// reconstruct state from event log if present
|
|
||||||
grain := checkout.NewCheckoutGrain(id, cart.CartId(id), 0, time.Now(), nil)
|
|
||||||
err := fs.checkoutStorage.LoadEventsFunc(r.Context(), id, grain, filterFunction)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
path := filepath.Join(fs.checkoutDataDir, fmt.Sprintf("%d.events.log", id))
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil && errors.Is(err, os.ErrNotExist) {
|
|
||||||
writeJSON(w, http.StatusNotFound, JsonError{Error: "checkout not found"})
|
|
||||||
return
|
|
||||||
} else if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lines, err := readRawLogLines(path)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"id": id,
|
|
||||||
"checkoutId": checkout.CheckoutId(id).String(),
|
|
||||||
"state": grain,
|
|
||||||
"mutations": lines,
|
|
||||||
"meta": map[string]any{
|
|
||||||
"size": info.Size(),
|
|
||||||
"modified": info.ModTime(),
|
|
||||||
"path": path,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math/rand"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestAppendFileInfoRandomProjectFile picks a random existing .go source file in the
|
|
||||||
// repository (from a small curated list to keep the test hermetic) and verifies
|
|
||||||
// that appendFileInfo populates Size, Modified and System without mutating the
|
|
||||||
// identity fields (ID, CartId). The randomness is only to satisfy the requirement
|
|
||||||
// of using "a random project file"; the test behavior is deterministic enough for
|
|
||||||
// CI because all chosen files are expected to exist.
|
|
||||||
func TestAppendFileInfoRandomProjectFile(t *testing.T) {
|
|
||||||
candidates := []string{
|
|
||||||
filepath.FromSlash("../../pkg/cart/cart_id.go"),
|
|
||||||
filepath.FromSlash("../../pkg/actor/grain.go"),
|
|
||||||
filepath.FromSlash("../../cmd/cart/main.go"),
|
|
||||||
}
|
|
||||||
// Pick one at random.
|
|
||||||
rand.Seed(time.Now().UnixNano())
|
|
||||||
path := candidates[rand.Intn(len(candidates))]
|
|
||||||
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("stat failed for %s: %v", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-populate a CartFileInfo with identity fields.
|
|
||||||
origID := "test-id"
|
|
||||||
origCartId := cart.CartId(12345)
|
|
||||||
cf := &CartFileInfo{ID: origID, CartId: origCartId}
|
|
||||||
|
|
||||||
// Call function under test.
|
|
||||||
got := appendFileInfo(info, cf)
|
|
||||||
|
|
||||||
if got != cf {
|
|
||||||
t.Fatalf("appendFileInfo should return the same pointer instance")
|
|
||||||
}
|
|
||||||
|
|
||||||
if cf.ID != origID {
|
|
||||||
t.Fatalf("ID mutated: expected %q got %q", origID, cf.ID)
|
|
||||||
}
|
|
||||||
if cf.CartId != origCartId {
|
|
||||||
t.Fatalf("CartId mutated: expected %v got %v", origCartId, cf.CartId)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cf.Size != info.Size() {
|
|
||||||
t.Fatalf("Size mismatch: expected %d got %d", info.Size(), cf.Size)
|
|
||||||
}
|
|
||||||
|
|
||||||
mod := info.ModTime()
|
|
||||||
// Allow small clock skew / coarse timestamp truncation.
|
|
||||||
if cf.Modified.Before(mod.Add(-2*time.Second)) || cf.Modified.After(mod.Add(2*time.Second)) {
|
|
||||||
t.Fatalf("Modified not within expected range: want ~%v got %v", mod, cf.Modified)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAppendFileInfoTempFile creates a temporary file to ensure Size and Modified
|
|
||||||
// are updated for a freshly written file with known content length.
|
|
||||||
func TestAppendFileInfoTempFile(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "temp.events.log")
|
|
||||||
content := []byte("hello world\nanother line\n")
|
|
||||||
if err := os.WriteFile(path, content, 0644); err != nil {
|
|
||||||
t.Fatalf("write temp file failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("stat temp file failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cf := &CartFileInfo{ID: "temp", CartId: cart.CartId(0)}
|
|
||||||
appendFileInfo(info, cf)
|
|
||||||
|
|
||||||
if cf.Size != int64(len(content)) {
|
|
||||||
t.Fatalf("expected Size %d got %d", len(content), cf.Size)
|
|
||||||
}
|
|
||||||
if cf.Modified.IsZero() {
|
|
||||||
t.Fatalf("Modified should be set")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"crypto/sha1"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/binary"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Hub manages websocket clients and broadcasts messages to them.
|
|
||||||
type Hub struct {
|
|
||||||
register chan *Client
|
|
||||||
unregister chan *Client
|
|
||||||
broadcast chan []byte
|
|
||||||
clients map[*Client]bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client represents a single websocket client connection.
|
|
||||||
type Client struct {
|
|
||||||
hub *Hub
|
|
||||||
conn net.Conn
|
|
||||||
send chan []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewHub constructs a new Hub instance.
|
|
||||||
func NewHub() *Hub {
|
|
||||||
return &Hub{
|
|
||||||
register: make(chan *Client),
|
|
||||||
unregister: make(chan *Client),
|
|
||||||
broadcast: make(chan []byte, 1024),
|
|
||||||
clients: make(map[*Client]bool),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run starts the hub event loop.
|
|
||||||
func (h *Hub) Run() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case c := <-h.register:
|
|
||||||
h.clients[c] = true
|
|
||||||
case c := <-h.unregister:
|
|
||||||
if _, ok := h.clients[c]; ok {
|
|
||||||
delete(h.clients, c)
|
|
||||||
close(c.send)
|
|
||||||
_ = c.conn.Close()
|
|
||||||
}
|
|
||||||
case msg := <-h.broadcast:
|
|
||||||
for c := range h.clients {
|
|
||||||
select {
|
|
||||||
case c.send <- msg:
|
|
||||||
default:
|
|
||||||
// Client is slow or dead; drop it.
|
|
||||||
delete(h.clients, c)
|
|
||||||
close(c.send)
|
|
||||||
_ = c.conn.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// computeAccept computes the Sec-WebSocket-Accept header value.
|
|
||||||
func computeAccept(key string) string {
|
|
||||||
const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
||||||
h := sha1.New()
|
|
||||||
h.Write([]byte(key + magic))
|
|
||||||
return base64.StdEncoding.EncodeToString(h.Sum(nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeWS upgrades the HTTP request to a WebSocket connection and registers a client.
|
|
||||||
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") || strings.ToLower(r.Header.Get("Upgrade")) != "websocket" {
|
|
||||||
http.Error(w, "upgrade required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
key := r.Header.Get("Sec-WebSocket-Key")
|
|
||||||
if key == "" {
|
|
||||||
http.Error(w, "missing Sec-WebSocket-Key", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
accept := computeAccept(key)
|
|
||||||
|
|
||||||
hj, ok := w.(http.Hijacker)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "websocket not supported", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
conn, buf, err := hj.Hijack()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write the upgrade response
|
|
||||||
response := "HTTP/1.1 101 Switching Protocols\r\n" +
|
|
||||||
"Upgrade: websocket\r\n" +
|
|
||||||
"Connection: Upgrade\r\n" +
|
|
||||||
"Sec-WebSocket-Accept: " + accept + "\r\n" +
|
|
||||||
"\r\n"
|
|
||||||
if _, err := buf.WriteString(response); err != nil {
|
|
||||||
_ = conn.Close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := buf.Flush(); err != nil {
|
|
||||||
_ = conn.Close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &Client{
|
|
||||||
hub: h,
|
|
||||||
conn: conn,
|
|
||||||
send: make(chan []byte, 256),
|
|
||||||
}
|
|
||||||
h.register <- client
|
|
||||||
go client.writePump()
|
|
||||||
go client.readPump()
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeWSFrame writes a single WebSocket frame to the writer.
|
|
||||||
func writeWSFrame(w io.Writer, opcode byte, payload []byte) error {
|
|
||||||
// FIN set, opcode as provided
|
|
||||||
header := []byte{0x80 | (opcode & 0x0F)}
|
|
||||||
l := len(payload)
|
|
||||||
switch {
|
|
||||||
case l < 126:
|
|
||||||
header = append(header, byte(l))
|
|
||||||
case l <= 65535:
|
|
||||||
ext := make([]byte, 2)
|
|
||||||
binary.BigEndian.PutUint16(ext, uint16(l))
|
|
||||||
header = append(header, 126)
|
|
||||||
header = append(header, ext...)
|
|
||||||
default:
|
|
||||||
ext := make([]byte, 8)
|
|
||||||
binary.BigEndian.PutUint64(ext, uint64(l))
|
|
||||||
header = append(header, 127)
|
|
||||||
header = append(header, ext...)
|
|
||||||
}
|
|
||||||
if _, err := w.Write(header); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if l > 0 {
|
|
||||||
if _, err := w.Write(payload); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// readPump handles control frames from the client and discards other incoming frames.
|
|
||||||
// This server is broadcast-only to clients.
|
|
||||||
func (c *Client) readPump() {
|
|
||||||
defer func() {
|
|
||||||
c.hub.unregister <- c
|
|
||||||
}()
|
|
||||||
reader := bufio.NewReader(c.conn)
|
|
||||||
for {
|
|
||||||
// Read first two bytes
|
|
||||||
b1, err := reader.ReadByte()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
b2, err := reader.ReadByte()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
opcode := b1 & 0x0F
|
|
||||||
masked := (b2 & 0x80) != 0
|
|
||||||
length := int64(b2 & 0x7F)
|
|
||||||
if length == 126 {
|
|
||||||
ext := make([]byte, 2)
|
|
||||||
if _, err := io.ReadFull(reader, ext); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
length = int64(binary.BigEndian.Uint16(ext))
|
|
||||||
} else if length == 127 {
|
|
||||||
ext := make([]byte, 8)
|
|
||||||
if _, err := io.ReadFull(reader, ext); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
length = int64(binary.BigEndian.Uint64(ext))
|
|
||||||
}
|
|
||||||
var maskKey [4]byte
|
|
||||||
if masked {
|
|
||||||
if _, err := io.ReadFull(reader, maskKey[:]); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle Ping -> Pong
|
|
||||||
if opcode == 0x9 && length <= 125 {
|
|
||||||
payload := make([]byte, length)
|
|
||||||
if _, err := io.ReadFull(reader, payload); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Unmask if masked
|
|
||||||
if masked {
|
|
||||||
for i := int64(0); i < length; i++ {
|
|
||||||
payload[i] ^= maskKey[i%4]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = writeWSFrame(c.conn, 0xA, payload) // best-effort pong
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close frame
|
|
||||||
if opcode == 0x8 {
|
|
||||||
// Drain payload if any, then exit
|
|
||||||
if _, err := io.CopyN(io.Discard, reader, length); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// For other frames, just discard payload
|
|
||||||
if _, err := io.CopyN(io.Discard, reader, length); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// writePump sends queued messages to the client and pings periodically to keep the connection alive.
|
|
||||||
func (c *Client) writePump() {
|
|
||||||
ticker := time.NewTicker(30 * time.Second)
|
|
||||||
defer func() {
|
|
||||||
ticker.Stop()
|
|
||||||
_ = c.conn.Close()
|
|
||||||
}()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case msg, ok := <-c.send:
|
|
||||||
if !ok {
|
|
||||||
// try to send close frame
|
|
||||||
_ = writeWSFrame(c.conn, 0x8, nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := writeWSFrame(c.conn, 0x1, msg); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case <-ticker.C:
|
|
||||||
// Send a ping to keep connections alive behind proxies
|
|
||||||
_ = writeWSFrame(c.conn, 0x9, []byte("ping"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
actor "git.k6n.net/go-cart-actor/pkg/actor"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/checkout"
|
|
||||||
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
|
||||||
"github.com/matst80/slask-finder/pkg/messaging"
|
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CartFileInfo struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
CartId cart.CartId `json:"cartId"`
|
|
||||||
Size int64 `json:"size"`
|
|
||||||
Modified time.Time `json:"modified"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CheckoutFileInfo struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
CheckoutId checkout.CheckoutId `json:"checkoutId"`
|
|
||||||
Size int64 `json:"size"`
|
|
||||||
Modified time.Time `json:"modified"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func envOrDefault(key, def string) string {
|
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
|
|
||||||
func startMutationConsumer(ctx context.Context, conn *amqp.Connection, hub *Hub) error {
|
|
||||||
ch, err := conn.Channel()
|
|
||||||
if err != nil {
|
|
||||||
_ = conn.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
msgs, err := messaging.DeclareBindAndConsume(ch, "cart", "mutation")
|
|
||||||
if err != nil {
|
|
||||||
_ = ch.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer ch.Close()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case m, ok := <-msgs:
|
|
||||||
if !ok {
|
|
||||||
log.Fatalf("connection closed")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Log and broadcast to all websocket clients
|
|
||||||
log.Printf("mutation event: %s", string(m.Body))
|
|
||||||
|
|
||||||
if hub != nil {
|
|
||||||
select {
|
|
||||||
case hub.broadcast <- m.Body:
|
|
||||||
default:
|
|
||||||
// if hub queue is full, drop to avoid blocking
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := m.Ack(false); err != nil {
|
|
||||||
log.Printf("error acknowledging message: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var redisAddress = os.Getenv("REDIS_ADDRESS")
|
|
||||||
var redisPassword = os.Getenv("REDIS_PASSWORD")
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
dataDir := envOrDefault("DATA_DIR", "data")
|
|
||||||
addr := envOrDefault("ADDR", ":8080")
|
|
||||||
amqpURL := os.Getenv("AMQP_URL")
|
|
||||||
|
|
||||||
rdb := redis.NewClient(&redis.Options{
|
|
||||||
Addr: redisAddress,
|
|
||||||
Password: redisPassword,
|
|
||||||
DB: 0,
|
|
||||||
})
|
|
||||||
inventoryService, err := inventory.NewRedisInventoryService(rdb)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error creating inventory service: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = os.MkdirAll(dataDir, 0755)
|
|
||||||
|
|
||||||
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
|
||||||
diskStorage := actor.NewDiskStorage[cart.CartGrain](dataDir, reg)
|
|
||||||
|
|
||||||
checkoutDataDir := envOrDefault("CHECKOUT_DATA_DIR", "checkout-data")
|
|
||||||
_ = os.MkdirAll(checkoutDataDir, 0755)
|
|
||||||
|
|
||||||
regCheckout := checkout.NewCheckoutMutationRegistry(checkout.NewCheckoutMutationContext())
|
|
||||||
diskStorageCheckout := actor.NewDiskStorage[checkout.CheckoutGrain](checkoutDataDir, regCheckout)
|
|
||||||
|
|
||||||
fs := NewFileServer(dataDir, checkoutDataDir, diskStorage, diskStorageCheckout)
|
|
||||||
|
|
||||||
hub := NewHub()
|
|
||||||
go hub.Run()
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("GET /carts", fs.CartsHandler)
|
|
||||||
mux.HandleFunc("GET /cart/{id}", fs.CartHandler)
|
|
||||||
mux.HandleFunc("GET /checkouts", fs.CheckoutsHandler)
|
|
||||||
mux.HandleFunc("GET /checkout/{id}", fs.CheckoutHandler)
|
|
||||||
mux.HandleFunc("PUT /inventory/{locationId}/{sku}", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
inventoryLocationId := inventory.LocationID(r.PathValue("locationId"))
|
|
||||||
inventorySku := inventory.SKU(r.PathValue("sku"))
|
|
||||||
pipe := rdb.Pipeline()
|
|
||||||
var payload struct {
|
|
||||||
Quantity int64 `json:"quantity"`
|
|
||||||
}
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&payload)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
inventoryService.UpdateInventory(r.Context(), pipe, inventorySku, inventoryLocationId, payload.Quantity)
|
|
||||||
|
|
||||||
_, err = pipe.Exec(r.Context())
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "failed to update inventory", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = inventoryService.SendInventoryChanged(r.Context(), inventorySku, inventoryLocationId)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/promotions", fs.PromotionsHandler)
|
|
||||||
mux.HandleFunc("/vouchers", fs.VoucherHandler)
|
|
||||||
mux.HandleFunc("/promotion/{id}", fs.PromotionPartHandler)
|
|
||||||
|
|
||||||
mux.HandleFunc("/ws", hub.ServeWS)
|
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Global CORS middleware allowing all origins and handling preflight
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
|
||||||
w.Header().Set("Access-Control-Expose-Headers", "*")
|
|
||||||
if r.Method == http.MethodOptions {
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mux.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
|
|
||||||
srv := &http.Server{
|
|
||||||
Addr: addr,
|
|
||||||
Handler: handler,
|
|
||||||
ReadTimeout: 15 * time.Second,
|
|
||||||
WriteTimeout: 30 * time.Second,
|
|
||||||
IdleTimeout: 60 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
if amqpURL != "" {
|
|
||||||
conn, err := amqp.Dial(amqpURL)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("failed to connect to RabbitMQ: %v", err)
|
|
||||||
}
|
|
||||||
if err := startMutationConsumer(ctx, conn, hub); err != nil {
|
|
||||||
log.Printf("AMQP listener disabled: %v", err)
|
|
||||||
} else {
|
|
||||||
log.Printf("AMQP listener connected")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("backoffice HTTP listening on %s (dataDir=%s)", addr, dataDir)
|
|
||||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
||||||
log.Fatalf("http server error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// server stopped
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/discovery"
|
|
||||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"k8s.io/client-go/kubernetes"
|
|
||||||
"k8s.io/client-go/rest"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetDiscovery() discovery.Discovery {
|
|
||||||
if podIp == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
config, kerr := rest.InClusterConfig()
|
|
||||||
|
|
||||||
if kerr != nil {
|
|
||||||
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
|
|
||||||
}
|
|
||||||
client, err := kubernetes.NewForConfig(config)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error creating client: %v\n", err)
|
|
||||||
}
|
|
||||||
timeout := int64(30)
|
|
||||||
return discovery.NewK8sDiscovery(client, v1.ListOptions{
|
|
||||||
LabelSelector: "actor-pool=cart",
|
|
||||||
TimeoutSeconds: &timeout,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func UseDiscovery(pool discovery.DiscoveryTarget) {
|
|
||||||
|
|
||||||
go func(hw discovery.Discovery) {
|
|
||||||
if hw == nil {
|
|
||||||
log.Print("No discovery service available")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ch, err := hw.Watch()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Discovery error: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for evt := range ch {
|
|
||||||
if evt.Host == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch evt.IsReady {
|
|
||||||
case false:
|
|
||||||
if pool.IsKnown(evt.Host) {
|
|
||||||
log.Printf("Host %s is not ready, removing", evt.Host)
|
|
||||||
pool.RemoveHost(evt.Host)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
if !pool.IsKnown(evt.Host) {
|
|
||||||
log.Printf("Discovered host %s", evt.Host)
|
|
||||||
pool.AddRemoteHost(evt.Host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}(GetDiscovery())
|
|
||||||
}
|
|
||||||
315
cmd/cart/main.go
315
cmd/cart/main.go
@@ -1,315 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/pprof"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/actor"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/promotions"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/proxy"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/voucher"
|
|
||||||
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
grainSpawns = promauto.NewCounter(prometheus.CounterOpts{
|
|
||||||
Name: "cart_grain_spawned_total",
|
|
||||||
Help: "The total number of spawned grains",
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
os.Mkdir("data", 0755)
|
|
||||||
}
|
|
||||||
|
|
||||||
type App struct {
|
|
||||||
pool *actor.SimpleGrainPool[cart.CartGrain]
|
|
||||||
server *PoolServer
|
|
||||||
}
|
|
||||||
|
|
||||||
var podIp = os.Getenv("POD_IP")
|
|
||||||
var name = os.Getenv("POD_NAME")
|
|
||||||
var amqpUrl = os.Getenv("AMQP_URL")
|
|
||||||
var redisAddress = os.Getenv("REDIS_ADDRESS")
|
|
||||||
var redisPassword = os.Getenv("REDIS_PASSWORD")
|
|
||||||
|
|
||||||
func getCountryFromHost(host string) string {
|
|
||||||
if strings.Contains(strings.ToLower(host), "-no") {
|
|
||||||
return "no"
|
|
||||||
}
|
|
||||||
if strings.Contains(strings.ToLower(host), "-se") {
|
|
||||||
return "se"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
type MutationContext struct {
|
|
||||||
VoucherService voucher.Service
|
|
||||||
}
|
|
||||||
|
|
||||||
type CartChangeEvent struct {
|
|
||||||
CartId cart.CartId `json:"cartId"`
|
|
||||||
Mutations []actor.ApplyResult `json:"mutations"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func matchesSkuAndLocation(update inventory.InventoryResult, item cart.CartItem) bool {
|
|
||||||
if string(update.SKU) == item.Sku {
|
|
||||||
if update.LocationID == "se" && item.StoreId == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if item.StoreId == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if *item.StoreId == string(update.LocationID) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
|
|
||||||
controlPlaneConfig := actor.DefaultServerConfig()
|
|
||||||
|
|
||||||
promotionData, err := promotions.LoadStateFile("data/promotions.json")
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error loading promotions: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("loaded %d promotions", len(promotionData.State.Promotions))
|
|
||||||
|
|
||||||
inventoryPubSub := actor.NewPubSub[inventory.InventoryChange]()
|
|
||||||
|
|
||||||
// promotionService := promotions.NewPromotionService(nil)
|
|
||||||
rdb := redis.NewClient(&redis.Options{
|
|
||||||
Addr: redisAddress,
|
|
||||||
Password: redisPassword,
|
|
||||||
DB: 0,
|
|
||||||
})
|
|
||||||
inventoryService, err := inventory.NewRedisInventoryService(rdb)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error creating inventory service: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
inventoryReservationService, err := inventory.NewRedisCartReservationService(rdb)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error creating inventory reservation service: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(inventoryReservationService))
|
|
||||||
reg.RegisterProcessor(
|
|
||||||
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
|
|
||||||
_, span := tracer.Start(ctx, "Totals and promotions")
|
|
||||||
defer span.End()
|
|
||||||
g.UpdateTotals()
|
|
||||||
g.Version++
|
|
||||||
// promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()), promotions.WithCustomerSegment("vip"))
|
|
||||||
// _, actions := promotionService.EvaluateAll(promotionData.State.Promotions, promotionCtx)
|
|
||||||
// for _, action := range actions {
|
|
||||||
|
|
||||||
// log.Printf("apply: %+v", action)
|
|
||||||
|
|
||||||
// g.UpdateTotals()
|
|
||||||
// }
|
|
||||||
return nil
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
diskStorage := actor.NewDiskStorage[cart.CartGrain]("data", reg)
|
|
||||||
poolConfig := actor.GrainPoolConfig[cart.CartGrain]{
|
|
||||||
MutationRegistry: reg,
|
|
||||||
Storage: diskStorage,
|
|
||||||
Spawn: func(ctx context.Context, id uint64) (actor.Grain[cart.CartGrain], error) {
|
|
||||||
_, span := tracer.Start(ctx, fmt.Sprintf("Spawn cart id %d", id))
|
|
||||||
defer span.End()
|
|
||||||
grainSpawns.Inc()
|
|
||||||
ret := cart.NewCartGrain(id, time.Now())
|
|
||||||
// Set baseline lastChange at spawn; replay may update it to last event timestamp.
|
|
||||||
|
|
||||||
inventoryPubSub.Subscribe(ret.HandleInventoryChange)
|
|
||||||
err := diskStorage.LoadEvents(ctx, id, ret)
|
|
||||||
if err == nil && inventoryService != nil {
|
|
||||||
refs := make([]*inventory.InventoryReference, 0)
|
|
||||||
for _, item := range ret.Items {
|
|
||||||
refs = append(refs, &inventory.InventoryReference{
|
|
||||||
SKU: inventory.SKU(item.Sku),
|
|
||||||
LocationID: getLocationId(item),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
_, span := tracer.Start(ctx, "update inventory")
|
|
||||||
defer span.End()
|
|
||||||
res, err := inventoryService.GetInventoryBatch(ctx, refs...)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("unable to update inventory %v", err)
|
|
||||||
} else {
|
|
||||||
for _, update := range res {
|
|
||||||
for _, item := range ret.Items {
|
|
||||||
if matchesSkuAndLocation(update, *item) && update.Quantity != uint32(item.Stock) {
|
|
||||||
// maybe apply an update to give visibility to the cart
|
|
||||||
item.Stock = uint16(update.Quantity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ret, err
|
|
||||||
},
|
|
||||||
Destroy: func(grain actor.Grain[cart.CartGrain]) error {
|
|
||||||
cart, err := grain.GetCurrentState()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
inventoryPubSub.Unsubscribe(cart.HandleInventoryChange)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
SpawnHost: func(host string) (actor.Host[cart.CartGrain], error) {
|
|
||||||
return proxy.NewRemoteHost[cart.CartGrain](host)
|
|
||||||
},
|
|
||||||
TTL: 5 * time.Minute,
|
|
||||||
PoolSize: 2 * 65535,
|
|
||||||
Hostname: podIp,
|
|
||||||
}
|
|
||||||
|
|
||||||
pool, err := actor.NewSimpleGrainPool(poolConfig)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error creating cart pool: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), inventoryService, inventoryReservationService)
|
|
||||||
|
|
||||||
app := &App{
|
|
||||||
pool: pool,
|
|
||||||
server: syncedServer,
|
|
||||||
}
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
debugMux := http.NewServeMux()
|
|
||||||
|
|
||||||
grpcSrv, err := actor.NewControlServer[cart.CartGrain](controlPlaneConfig, pool)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
|
|
||||||
}
|
|
||||||
defer grpcSrv.GracefulStop()
|
|
||||||
|
|
||||||
// go diskStorage.SaveLoop(10 * time.Second)
|
|
||||||
UseDiscovery(pool)
|
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
|
||||||
defer stop()
|
|
||||||
|
|
||||||
otelShutdown, err := setupOTelSDK(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Unable to start otel %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
syncedServer.Serve(mux)
|
|
||||||
// only for local
|
|
||||||
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
pool.AddRemote(r.PathValue("host"))
|
|
||||||
})
|
|
||||||
|
|
||||||
debugMux.HandleFunc("/debug/pprof/", pprof.Index)
|
|
||||||
debugMux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
|
||||||
debugMux.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
|
||||||
debugMux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
|
||||||
debugMux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
|
||||||
debugMux.Handle("/metrics", promhttp.Handler())
|
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Grain pool health: simple capacity check (mirrors previous GrainHandler.IsHealthy)
|
|
||||||
grainCount, capacity := app.pool.LocalUsage()
|
|
||||||
if grainCount >= capacity {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte("grain pool at capacity"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !pool.IsHealthy() {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte("control plane not healthy"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
|
|
||||||
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("1.0.0"))
|
|
||||||
})
|
|
||||||
|
|
||||||
mux.HandleFunc("/openapi.json", ServeEmbeddedOpenAPI)
|
|
||||||
|
|
||||||
srv := &http.Server{
|
|
||||||
Addr: ":8080",
|
|
||||||
BaseContext: func(net.Listener) context.Context { return ctx },
|
|
||||||
ReadTimeout: 10 * time.Second,
|
|
||||||
WriteTimeout: 20 * time.Second,
|
|
||||||
Handler: otelhttp.NewHandler(mux, "/"),
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
|
|
||||||
fmt.Println("Shutting down due to signal")
|
|
||||||
otelShutdown(context.Background())
|
|
||||||
diskStorage.Close()
|
|
||||||
pool.Close()
|
|
||||||
|
|
||||||
}()
|
|
||||||
|
|
||||||
srvErr := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
srvErr <- srv.ListenAndServe()
|
|
||||||
}()
|
|
||||||
|
|
||||||
listener := inventory.NewInventoryChangeListener(rdb, context.Background(), func(changes []inventory.InventoryChange) {
|
|
||||||
for _, change := range changes {
|
|
||||||
log.Printf("inventory change: %v", change)
|
|
||||||
inventoryPubSub.Publish(change)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
err := listener.Start()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Unable to start inventory listener: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
log.Print("Server started at port 8080")
|
|
||||||
|
|
||||||
go http.ListenAndServe(":8081", debugMux)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err = <-srvErr:
|
|
||||||
// Error when starting HTTP server.
|
|
||||||
log.Fatalf("Unable to start server: %v", err)
|
|
||||||
case <-ctx.Done():
|
|
||||||
// Wait for first CTRL+C.
|
|
||||||
// Stop receiving signal notifications as soon as possible.
|
|
||||||
stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,69 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"crypto/sha256"
|
|
||||||
_ "embed"
|
|
||||||
"encoding/hex"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// openapi_embed.go: Provides embedded OpenAPI spec and helper to mount handler.
|
|
||||||
|
|
||||||
//go:embed openapi.json
|
|
||||||
var openapiJSON []byte
|
|
||||||
|
|
||||||
var (
|
|
||||||
openapiOnce sync.Once
|
|
||||||
openapiETag string
|
|
||||||
)
|
|
||||||
|
|
||||||
// initOpenAPIMetadata computes immutable metadata for the embedded spec.
|
|
||||||
func initOpenAPIMetadata() {
|
|
||||||
sum := sha256.Sum256(openapiJSON)
|
|
||||||
openapiETag = `W/"` + hex.EncodeToString(sum[:8]) + `"` // weak ETag with first 8 bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeEmbeddedOpenAPI serves the embedded OpenAPI JSON spec at /openapi.json.
|
|
||||||
// It supports GET and HEAD and implements basic ETag caching.
|
|
||||||
func ServeEmbeddedOpenAPI(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
||||||
w.Header().Set("Allow", "GET, HEAD")
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
openapiOnce.Do(initOpenAPIMetadata)
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.Header().Set("ETag", openapiETag)
|
|
||||||
|
|
||||||
if match := r.Header.Get("If-None-Match"); match != "" {
|
|
||||||
if bytes.Contains([]byte(match), []byte(openapiETag)) {
|
|
||||||
w.WriteHeader(http.StatusNotModified)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Method == http.MethodHead {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write(openapiJSON)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional: function to access raw spec bytes programmatically.
|
|
||||||
func OpenAPISpecBytes() []byte {
|
|
||||||
return openapiJSON
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional: function to access current ETag.
|
|
||||||
func OpenAPIETag() string {
|
|
||||||
openapiOnce.Do(initOpenAPIMetadata)
|
|
||||||
return openapiETag
|
|
||||||
}
|
|
||||||
117
cmd/cart/otel.go
117
cmd/cart/otel.go
@@ -1,117 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.opentelemetry.io/otel"
|
|
||||||
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
|
|
||||||
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
|
|
||||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
|
||||||
"go.opentelemetry.io/otel/log/global"
|
|
||||||
"go.opentelemetry.io/otel/propagation"
|
|
||||||
"go.opentelemetry.io/otel/sdk/log"
|
|
||||||
"go.opentelemetry.io/otel/sdk/metric"
|
|
||||||
"go.opentelemetry.io/otel/sdk/trace"
|
|
||||||
)
|
|
||||||
|
|
||||||
// setupOTelSDK bootstraps the OpenTelemetry pipeline.
|
|
||||||
// If it does not return an error, make sure to call shutdown for proper cleanup.
|
|
||||||
func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
|
|
||||||
var shutdownFuncs []func(context.Context) error
|
|
||||||
var err error
|
|
||||||
|
|
||||||
// shutdown calls cleanup functions registered via shutdownFuncs.
|
|
||||||
// The errors from the calls are joined.
|
|
||||||
// Each registered cleanup will be invoked once.
|
|
||||||
shutdown := func(ctx context.Context) error {
|
|
||||||
var err error
|
|
||||||
for _, fn := range shutdownFuncs {
|
|
||||||
err = errors.Join(err, fn(ctx))
|
|
||||||
}
|
|
||||||
shutdownFuncs = nil
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleErr calls shutdown for cleanup and makes sure that all errors are returned.
|
|
||||||
handleErr := func(inErr error) {
|
|
||||||
err = errors.Join(inErr, shutdown(ctx))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up propagator.
|
|
||||||
prop := newPropagator()
|
|
||||||
otel.SetTextMapPropagator(prop)
|
|
||||||
|
|
||||||
// Set up trace provider.
|
|
||||||
tracerProvider, err := newTracerProvider()
|
|
||||||
if err != nil {
|
|
||||||
handleErr(err)
|
|
||||||
return shutdown, err
|
|
||||||
}
|
|
||||||
shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
|
|
||||||
otel.SetTracerProvider(tracerProvider)
|
|
||||||
|
|
||||||
// Set up meter provider.
|
|
||||||
meterProvider, err := newMeterProvider()
|
|
||||||
if err != nil {
|
|
||||||
handleErr(err)
|
|
||||||
return shutdown, err
|
|
||||||
}
|
|
||||||
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
|
|
||||||
otel.SetMeterProvider(meterProvider)
|
|
||||||
|
|
||||||
// Set up logger provider.
|
|
||||||
loggerProvider, err := newLoggerProvider()
|
|
||||||
if err != nil {
|
|
||||||
handleErr(err)
|
|
||||||
return shutdown, err
|
|
||||||
}
|
|
||||||
shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
|
|
||||||
global.SetLoggerProvider(loggerProvider)
|
|
||||||
|
|
||||||
return shutdown, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func newPropagator() propagation.TextMapPropagator {
|
|
||||||
return propagation.NewCompositeTextMapPropagator(
|
|
||||||
propagation.TraceContext{},
|
|
||||||
propagation.Baggage{},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTracerProvider() (*trace.TracerProvider, error) {
|
|
||||||
traceExporter, err := otlptracegrpc.New(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
tracerProvider := trace.NewTracerProvider(
|
|
||||||
trace.WithBatcher(traceExporter,
|
|
||||||
// Default is 5s. Set to 1s for demonstrative purposes.
|
|
||||||
trace.WithBatchTimeout(time.Second)),
|
|
||||||
)
|
|
||||||
return tracerProvider, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMeterProvider() (*metric.MeterProvider, error) {
|
|
||||||
exporter, err := otlpmetricgrpc.New(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
provider := metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(exporter)))
|
|
||||||
return provider, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newLoggerProvider() (*log.LoggerProvider, error) {
|
|
||||||
logExporter, err := otlploggrpc.New(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
loggerProvider := log.NewLoggerProvider(
|
|
||||||
log.WithProcessor(log.NewBatchProcessor(logExporter)),
|
|
||||||
)
|
|
||||||
return loggerProvider, nil
|
|
||||||
}
|
|
||||||
@@ -1,530 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/actor"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
|
||||||
messages "git.k6n.net/go-cart-actor/proto/cart"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/voucher"
|
|
||||||
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
|
||||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
"google.golang.org/protobuf/types/known/anypb"
|
|
||||||
|
|
||||||
"go.opentelemetry.io/otel"
|
|
||||||
"go.opentelemetry.io/otel/attribute"
|
|
||||||
"go.opentelemetry.io/otel/metric"
|
|
||||||
"go.opentelemetry.io/otel/trace"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
|
|
||||||
Name: "cart_grain_mutations_total",
|
|
||||||
Help: "The total number of mutations",
|
|
||||||
})
|
|
||||||
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
|
|
||||||
Name: "cart_grain_lookups_total",
|
|
||||||
Help: "The total number of lookups",
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
type PoolServer struct {
|
|
||||||
actor.GrainPool[cart.CartGrain]
|
|
||||||
pod_name string
|
|
||||||
inventoryService inventory.InventoryService
|
|
||||||
reservationService inventory.CartReservationService
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPoolServer(pool actor.GrainPool[cart.CartGrain], pod_name string, inventoryService inventory.InventoryService, inventoryReservationService inventory.CartReservationService) *PoolServer {
|
|
||||||
srv := &PoolServer{
|
|
||||||
GrainPool: pool,
|
|
||||||
pod_name: pod_name,
|
|
||||||
inventoryService: inventoryService,
|
|
||||||
reservationService: inventoryReservationService,
|
|
||||||
}
|
|
||||||
|
|
||||||
return srv
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) ApplyLocal(ctx context.Context, id cart.CartId, mutation ...proto.Message) (*actor.MutationResult[cart.CartGrain], error) {
|
|
||||||
return s.Apply(ctx, uint64(id), mutation...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
|
||||||
grain, err := s.Get(r.Context(), uint64(id))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.WriteResult(w, grain)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
|
||||||
sku := r.PathValue("sku")
|
|
||||||
msg, err := GetItemAddMessage(r.Context(), sku, 1, getCountryFromHost(r.Host), nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := s.ApplyLocal(r.Context(), id, msg)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
grainMutations.Add(float64(len(data.Mutations)))
|
|
||||||
return s.WriteResult(w, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) WriteResult(w http.ResponseWriter, result any) error {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.Header().Set("X-Pod-Name", s.pod_name)
|
|
||||||
if result == nil {
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
enc := json.NewEncoder(w)
|
|
||||||
err := enc.Encode(result)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) DeleteItemHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
|
||||||
|
|
||||||
itemIdString := r.PathValue("itemId")
|
|
||||||
itemId, err := strconv.ParseInt(itemIdString, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data, err := s.ApplyLocal(r.Context(), id, &messages.RemoveItem{Id: uint32(itemId)})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) QuantityChangeHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
|
||||||
changeQuantity := messages.ChangeQuantity{}
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&changeQuantity)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
reply, err := s.ApplyLocal(r.Context(), id, &changeQuantity)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Item struct {
|
|
||||||
Sku string `json:"sku"`
|
|
||||||
Quantity int `json:"quantity"`
|
|
||||||
StoreId *string `json:"storeId,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SetCartItems struct {
|
|
||||||
Country string `json:"country"`
|
|
||||||
Items []Item `json:"items"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func getMultipleAddMessages(ctx context.Context, items []Item, country string) []proto.Message {
|
|
||||||
wg := sync.WaitGroup{}
|
|
||||||
mu := sync.Mutex{}
|
|
||||||
msgs := make([]proto.Message, 0, len(items))
|
|
||||||
for _, itm := range items {
|
|
||||||
wg.Go(
|
|
||||||
func() {
|
|
||||||
msg, err := GetItemAddMessage(ctx, itm.Sku, itm.Quantity, country, itm.StoreId)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("error adding item %s: %v", itm.Sku, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mu.Lock()
|
|
||||||
msgs = append(msgs, msg)
|
|
||||||
mu.Unlock()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
return msgs
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
|
||||||
setCartItems := SetCartItems{}
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&setCartItems)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
msgs := make([]proto.Message, 0, len(setCartItems.Items)+1)
|
|
||||||
msgs = append(msgs, &messages.ClearCartRequest{})
|
|
||||||
msgs = append(msgs, getMultipleAddMessages(r.Context(), setCartItems.Items, setCartItems.Country)...)
|
|
||||||
|
|
||||||
reply, err := s.ApplyLocal(r.Context(), id, msgs...)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) AddMultipleItemHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
|
||||||
setCartItems := SetCartItems{}
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&setCartItems)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
msgs := getMultipleAddMessages(r.Context(), setCartItems.Items, setCartItems.Country)
|
|
||||||
|
|
||||||
reply, err := s.ApplyLocal(r.Context(), id, msgs...)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
type AddRequest struct {
|
|
||||||
Sku string `json:"sku"`
|
|
||||||
Quantity int32 `json:"quantity"`
|
|
||||||
Country string `json:"country"`
|
|
||||||
StoreId *string `json:"storeId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
|
||||||
addRequest := AddRequest{Quantity: 1}
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&addRequest)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
msg, err := GetItemAddMessage(r.Context(), addRequest.Sku, int(addRequest.Quantity), addRequest.Country, addRequest.StoreId)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
reply, err := s.ApplyLocal(r.Context(), id, msg)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.WriteResult(w, reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
// func (s *PoolServer) HandleConfirmation(w http.ResponseWriter, r *http.Request, id CartId) error {
|
|
||||||
// orderId := r.PathValue("orderId")
|
|
||||||
// if orderId == "" {
|
|
||||||
// return fmt.Errorf("orderId is empty")
|
|
||||||
// }
|
|
||||||
// order, err := KlarnaInstance.GetOrder(orderId)
|
|
||||||
|
|
||||||
// if err != nil {
|
|
||||||
// return err
|
|
||||||
// }
|
|
||||||
|
|
||||||
// w.Header().Set("Content-Type", "application/json")
|
|
||||||
// w.Header().Set("X-Pod-Name", s.pod_name)
|
|
||||||
// w.Header().Set("Cache-Control", "no-cache")
|
|
||||||
// w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
// w.WriteHeader(http.StatusOK)
|
|
||||||
// return json.NewEncoder(w).Encode(order)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (s *PoolServer) HandleCheckout(w http.ResponseWriter, r *http.Request, id CartId) error {
|
|
||||||
// klarnaOrder, err := s.CreateOrUpdateCheckout(r.Host, id)
|
|
||||||
// if err != nil {
|
|
||||||
// return err
|
|
||||||
// }
|
|
||||||
|
|
||||||
// s.ApplyCheckoutStarted(klarnaOrder, id)
|
|
||||||
|
|
||||||
// w.Header().Set("Content-Type", "application/json")
|
|
||||||
// return json.NewEncoder(w).Encode(klarnaOrder)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
|
|
||||||
// Removed leftover legacy block after CookieCartIdHandler (obsolete code referencing cid/legacy)
|
|
||||||
|
|
||||||
func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
|
||||||
// Clear cart cookie (breaking change: do not issue a new legacy id here)
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "cartid",
|
|
||||||
Value: "",
|
|
||||||
Path: "/",
|
|
||||||
Secure: r.TLS != nil,
|
|
||||||
HttpOnly: true,
|
|
||||||
Expires: time.Unix(0, 0),
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error) func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error {
|
|
||||||
return func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error {
|
|
||||||
if ownerHost, ok := s.OwnerHost(uint64(cartId)); ok {
|
|
||||||
ctx, span := tracer.Start(r.Context(), "proxy")
|
|
||||||
defer span.End()
|
|
||||||
span.SetAttributes(attribute.String("cartid", cartId.String()))
|
|
||||||
hostAttr := attribute.String("other host", ownerHost.Name())
|
|
||||||
span.SetAttributes(hostAttr)
|
|
||||||
logger.InfoContext(ctx, "cart proxyed", "result", ownerHost.Name())
|
|
||||||
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
|
|
||||||
handled, err := ownerHost.Proxy(uint64(cartId), w, r, nil)
|
|
||||||
|
|
||||||
grainLookups.Inc()
|
|
||||||
if err == nil && handled {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_, span := tracer.Start(r.Context(), "own")
|
|
||||||
span.SetAttributes(attribute.String("cartid", cartId.String()))
|
|
||||||
defer span.End()
|
|
||||||
return fn(w, r, cartId)
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
tracer = otel.Tracer(name)
|
|
||||||
meter = otel.Meter(name)
|
|
||||||
logger = otelslog.NewLogger(name)
|
|
||||||
proxyCalls metric.Int64Counter
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
var err error
|
|
||||||
proxyCalls, err = meter.Int64Counter("proxy.calls",
|
|
||||||
metric.WithDescription("Number of proxy calls"),
|
|
||||||
metric.WithUnit("{calls}"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
type AddVoucherRequest struct {
|
|
||||||
VoucherCode string `json:"code"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) AddVoucherHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
|
||||||
data := &AddVoucherRequest{}
|
|
||||||
json.NewDecoder(r.Body).Decode(data)
|
|
||||||
v := voucher.Service{}
|
|
||||||
msg, err := v.GetVoucher(data.VoucherCode)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte(err.Error()))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
reply, err := s.ApplyLocal(r.Context(), cartId, msg)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte(err.Error()))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
s.WriteResult(w, reply)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type SubscriptionDetailsRequest struct {
|
|
||||||
Id *string `json:"id,omitempty"`
|
|
||||||
OfferingCode string `json:"offeringCode,omitempty"`
|
|
||||||
SigningType string `json:"signingType,omitempty"`
|
|
||||||
Data json.RawMessage `json:"data,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sd *SubscriptionDetailsRequest) ToMessage() *messages.UpsertSubscriptionDetails {
|
|
||||||
return &messages.UpsertSubscriptionDetails{
|
|
||||||
Id: sd.Id,
|
|
||||||
OfferingCode: sd.OfferingCode,
|
|
||||||
SigningType: sd.SigningType,
|
|
||||||
Data: &anypb.Any{Value: sd.Data},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) SubscriptionDetailsHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
|
||||||
data := &SubscriptionDetailsRequest{}
|
|
||||||
err := json.NewDecoder(r.Body).Decode(data)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte(err.Error()))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
reply, err := s.ApplyLocal(r.Context(), cartId, data.ToMessage())
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte(err.Error()))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
s.WriteResult(w, reply)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
|
||||||
|
|
||||||
idStr := r.PathValue("voucherId")
|
|
||||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte(err.Error()))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
reply, err := s.ApplyLocal(r.Context(), cartId, &messages.RemoveVoucher{Id: uint32(id)})
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte(err.Error()))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
s.WriteResult(w, reply)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) SetUserIdHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
|
||||||
setUserId := messages.SetUserId{}
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&setUserId)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
reply, err := s.ApplyLocal(r.Context(), cartId, &setUserId)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) LineItemMarkingHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
|
||||||
itemIdStr := r.PathValue("itemId")
|
|
||||||
itemId, err := strconv.ParseInt(itemIdStr, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
lineItemMarking := messages.LineItemMarking{Id: uint32(itemId)}
|
|
||||||
err = json.NewDecoder(r.Body).Decode(&lineItemMarking)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
reply, err := s.ApplyLocal(r.Context(), cartId, &lineItemMarking)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) RemoveLineItemMarkingHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
|
||||||
itemIdStr := r.PathValue("itemId")
|
|
||||||
itemId, err := strconv.ParseInt(itemIdStr, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
removeLineItemMarking := messages.RemoveLineItemMarking{Id: uint32(itemId)}
|
|
||||||
reply, err := s.ApplyLocal(r.Context(), cartId, &removeLineItemMarking)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) InternalApplyMutationHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
data, err := io.ReadAll(r.Body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
mutation := &messages.Mutation{}
|
|
||||||
err = proto.Unmarshal(data, mutation)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
reply, err := s.ApplyLocal(r.Context(), cartId, mutation)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) GetAnywhere(ctx context.Context, cartId cart.CartId) (*cart.CartGrain, error) {
|
|
||||||
id := uint64(cartId)
|
|
||||||
if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost {
|
|
||||||
return host.Get(ctx, id)
|
|
||||||
}
|
|
||||||
return s.Get(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) ApplyAnywhere(ctx context.Context, cartId cart.CartId, msgs ...proto.Message) error {
|
|
||||||
id := uint64(cartId)
|
|
||||||
if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost {
|
|
||||||
_, err := host.Apply(ctx, id, msgs...)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err := s.Apply(ctx, id, msgs...)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) Serve(mux *http.ServeMux) {
|
|
||||||
|
|
||||||
// mux.HandleFunc("OPTIONS /cart", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
// w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE")
|
|
||||||
// w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
||||||
// w.WriteHeader(http.StatusOK)
|
|
||||||
// })
|
|
||||||
|
|
||||||
handleFunc := func(pattern string, handlerFunc func(http.ResponseWriter, *http.Request)) {
|
|
||||||
attr := attribute.String("http.route", pattern)
|
|
||||||
mux.HandleFunc(pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
span := trace.SpanFromContext(r.Context())
|
|
||||||
span.SetName(pattern)
|
|
||||||
span.SetAttributes(attr)
|
|
||||||
|
|
||||||
labeler, _ := otelhttp.LabelerFromContext(r.Context())
|
|
||||||
labeler.Add(attr)
|
|
||||||
|
|
||||||
handlerFunc(w, r)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
handleFunc("GET /cart", CookieCartIdHandler(s.ProxyHandler(s.GetCartHandler)))
|
|
||||||
handleFunc("GET /cart/add/{sku}", CookieCartIdHandler(s.ProxyHandler(s.AddSkuToCartHandler)))
|
|
||||||
handleFunc("POST /cart/add", CookieCartIdHandler(s.ProxyHandler(s.AddMultipleItemHandler)))
|
|
||||||
handleFunc("POST /cart", CookieCartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
|
|
||||||
handleFunc("POST /cart/set", CookieCartIdHandler(s.ProxyHandler(s.SetCartItemsHandler)))
|
|
||||||
handleFunc("DELETE /cart/{itemId}", CookieCartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
|
|
||||||
handleFunc("PUT /cart", CookieCartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
|
|
||||||
handleFunc("DELETE /cart", CookieCartIdHandler(s.ProxyHandler(s.RemoveCartCookie)))
|
|
||||||
handleFunc("PUT /cart/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
|
|
||||||
handleFunc("PUT /cart/subscription-details", CookieCartIdHandler(s.ProxyHandler(s.SubscriptionDetailsHandler)))
|
|
||||||
handleFunc("DELETE /cart/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
|
||||||
handleFunc("PUT /cart/user", CookieCartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
|
|
||||||
|
|
||||||
handleFunc("PUT /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
|
|
||||||
handleFunc("DELETE /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
|
|
||||||
|
|
||||||
//mux.HandleFunc("GET /cart/checkout", CookieCartIdHandler(s.ProxyHandler(s.HandleCheckout)))
|
|
||||||
//mux.HandleFunc("GET /cart/confirmation/{orderId}", CookieCartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
|
|
||||||
|
|
||||||
handleFunc("GET /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.GetCartHandler)))
|
|
||||||
|
|
||||||
handleFunc("POST /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.AddSkuRequestHandler)))
|
|
||||||
handleFunc("DELETE /cart/byid/{id}/{itemId}", CartIdHandler(s.ProxyHandler(s.DeleteItemHandler)))
|
|
||||||
handleFunc("PUT /cart/byid/{id}", CartIdHandler(s.ProxyHandler(s.QuantityChangeHandler)))
|
|
||||||
handleFunc("PUT /cart/byid/{id}/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
|
|
||||||
handleFunc("DELETE /cart/byid/{id}/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
|
||||||
handleFunc("PUT /cart/byid/{id}/user", CartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
|
|
||||||
handleFunc("PUT /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
|
|
||||||
handleFunc("DELETE /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
|
||||||
messages "git.k6n.net/go-cart-actor/proto/cart"
|
|
||||||
|
|
||||||
"github.com/matst80/slask-finder/pkg/index"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TODO make this configurable
|
|
||||||
func getBaseUrl(country string) string {
|
|
||||||
// if country == "se" {
|
|
||||||
// return "http://s10n-se:8080"
|
|
||||||
// }
|
|
||||||
if country == "no" {
|
|
||||||
return "http://s10n-no.s10n:8080"
|
|
||||||
}
|
|
||||||
if country == "se" {
|
|
||||||
return "http://s10n-se.s10n:8080"
|
|
||||||
}
|
|
||||||
return "http://localhost:8082"
|
|
||||||
}
|
|
||||||
|
|
||||||
func FetchItem(ctx context.Context, sku string, country string) (*index.DataItem, error) {
|
|
||||||
baseUrl := getBaseUrl(country)
|
|
||||||
req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/by-sku/%s", baseUrl, sku), nil)
|
|
||||||
innerCtx, span := tracer.Start(ctx, fmt.Sprintf("fetching data for %s", sku))
|
|
||||||
defer span.End()
|
|
||||||
req = req.WithContext(innerCtx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
res, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer res.Body.Close()
|
|
||||||
var item index.DataItem
|
|
||||||
err = json.NewDecoder(res.Body).Decode(&item)
|
|
||||||
return &item, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetItemAddMessage(ctx context.Context, sku string, qty int, country string, storeId *string) (*messages.AddItem, error) {
|
|
||||||
item, err := FetchItem(ctx, sku, country)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return ToItemAddMessage(item, storeId, qty, country)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country string) (*messages.AddItem, error) {
|
|
||||||
orgPrice, _ := getInt(item.GetNumberFieldValue(5)) // getInt(item.Fields[5])
|
|
||||||
|
|
||||||
price, err := getInt(item.GetNumberFieldValue(4)) //Fields[4]
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
stk := item.GetStock()
|
|
||||||
stock := cart.StockStatus(0)
|
|
||||||
|
|
||||||
if storeId == nil {
|
|
||||||
centralStock, ok := stk[country]
|
|
||||||
if ok {
|
|
||||||
if !item.Buyable {
|
|
||||||
return nil, fmt.Errorf("item not available")
|
|
||||||
}
|
|
||||||
|
|
||||||
if centralStock == 0 && item.SaleStatus == "TBD" {
|
|
||||||
return nil, fmt.Errorf("no items available")
|
|
||||||
}
|
|
||||||
stock = cart.StockStatus(centralStock)
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
|
||||||
if !item.BuyableInStore {
|
|
||||||
return nil, fmt.Errorf("item not available in store")
|
|
||||||
}
|
|
||||||
storeStock, ok := stk[*storeId]
|
|
||||||
if ok {
|
|
||||||
stock = cart.StockStatus(storeStock)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
articleType, _ := item.GetStringFieldValue(1) //.Fields[1].(string)
|
|
||||||
outletGrade, ok := item.GetStringFieldValue(20) //.Fields[20].(string)
|
|
||||||
var outlet *string
|
|
||||||
if ok {
|
|
||||||
outlet = &outletGrade
|
|
||||||
}
|
|
||||||
sellerId, _ := item.GetStringFieldValue(24) // .Fields[24].(string)
|
|
||||||
sellerName, _ := item.GetStringFieldValue(9) // .Fields[9].(string)
|
|
||||||
|
|
||||||
brand, _ := item.GetStringFieldValue(2) //.Fields[2].(string)
|
|
||||||
category, _ := item.GetStringFieldValue(10) //.Fields[10].(string)
|
|
||||||
category2, _ := item.GetStringFieldValue(11) //.Fields[11].(string)
|
|
||||||
category3, _ := item.GetStringFieldValue(12) //.Fields[12].(string)
|
|
||||||
category4, _ := item.GetStringFieldValue(13) //Fields[13].(string)
|
|
||||||
category5, _ := item.GetStringFieldValue(14) //.Fields[14].(string)
|
|
||||||
|
|
||||||
cgm, _ := item.GetStringFieldValue(35) // Customer Group Membership
|
|
||||||
|
|
||||||
return &messages.AddItem{
|
|
||||||
ItemId: uint32(item.Id),
|
|
||||||
Quantity: int32(qty),
|
|
||||||
Price: int64(price),
|
|
||||||
OrgPrice: int64(orgPrice),
|
|
||||||
Sku: item.GetSku(),
|
|
||||||
Name: item.Title,
|
|
||||||
Image: item.Img,
|
|
||||||
Stock: int32(stock),
|
|
||||||
Brand: brand,
|
|
||||||
Category: category,
|
|
||||||
Category2: category2,
|
|
||||||
Category3: category3,
|
|
||||||
Category4: category4,
|
|
||||||
Category5: category5,
|
|
||||||
Tax: getTax(articleType),
|
|
||||||
SellerId: sellerId,
|
|
||||||
SellerName: sellerName,
|
|
||||||
ArticleType: articleType,
|
|
||||||
Disclaimer: item.Disclaimer,
|
|
||||||
Country: country,
|
|
||||||
Outlet: outlet,
|
|
||||||
StoreId: storeId,
|
|
||||||
SaleStatus: item.SaleStatus,
|
|
||||||
Cgm: cgm,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getTax(articleType string) int32 {
|
|
||||||
switch articleType {
|
|
||||||
case "ZDIE":
|
|
||||||
return 600
|
|
||||||
default:
|
|
||||||
return 2500
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getInt(data float64, ok bool) (int, error) {
|
|
||||||
if !ok {
|
|
||||||
return 0, fmt.Errorf("invalid type")
|
|
||||||
}
|
|
||||||
return int(data), nil
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
|
||||||
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
|
||||||
)
|
|
||||||
|
|
||||||
func getCurrency(country string) string {
|
|
||||||
if country == "no" {
|
|
||||||
return "NOK"
|
|
||||||
}
|
|
||||||
return "SEK"
|
|
||||||
}
|
|
||||||
|
|
||||||
func getLocale(country string) string {
|
|
||||||
if country == "no" {
|
|
||||||
return "nb-no"
|
|
||||||
}
|
|
||||||
return "sv-se"
|
|
||||||
}
|
|
||||||
|
|
||||||
func getLocationId(item *cart.CartItem) inventory.LocationID {
|
|
||||||
if item.StoreId == nil || *item.StoreId == "" {
|
|
||||||
return "se"
|
|
||||||
}
|
|
||||||
return inventory.LocationID(*item.StoreId)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
|
|
||||||
var requests []inventory.ReserveRequest
|
|
||||||
for _, item := range items {
|
|
||||||
if item == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
requests = append(requests, inventory.ReserveRequest{
|
|
||||||
InventoryReference: &inventory.InventoryReference{
|
|
||||||
SKU: inventory.SKU(item.Sku),
|
|
||||||
LocationID: getLocationId(item),
|
|
||||||
},
|
|
||||||
Quantity: uint32(item.Quantity),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return requests
|
|
||||||
}
|
|
||||||
|
|
||||||
func getOriginalHost(r *http.Request) string {
|
|
||||||
proxyHost := r.Header.Get("X-Forwarded-Host")
|
|
||||||
if proxyHost != "" {
|
|
||||||
return proxyHost
|
|
||||||
}
|
|
||||||
return r.Host
|
|
||||||
}
|
|
||||||
|
|
||||||
func getClientIp(r *http.Request) string {
|
|
||||||
ip := r.Header.Get("X-Forwarded-For")
|
|
||||||
if ip == "" {
|
|
||||||
ip = r.RemoteAddr
|
|
||||||
}
|
|
||||||
return ip
|
|
||||||
}
|
|
||||||
|
|
||||||
func CookieCartIdHandler(fn func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
|
|
||||||
var id cart.CartId
|
|
||||||
cookie, err := r.Cookie("cartid")
|
|
||||||
if err != nil || cookie.Value == "" {
|
|
||||||
id = cart.MustNewCartId()
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "cartid",
|
|
||||||
Value: id.String(),
|
|
||||||
Secure: r.TLS != nil,
|
|
||||||
HttpOnly: true,
|
|
||||||
Path: "/",
|
|
||||||
Expires: time.Now().AddDate(0, 0, 14),
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
w.Header().Set("Set-Cart-Id", id.String())
|
|
||||||
} else {
|
|
||||||
parsed, ok := cart.ParseCartId(cookie.Value)
|
|
||||||
if !ok {
|
|
||||||
id = cart.MustNewCartId()
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "cartid",
|
|
||||||
Value: id.String(),
|
|
||||||
Secure: r.TLS != nil,
|
|
||||||
HttpOnly: true,
|
|
||||||
Path: "/",
|
|
||||||
Expires: time.Now().AddDate(0, 0, 14),
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
w.Header().Set("Set-Cart-Id", id.String())
|
|
||||||
} else {
|
|
||||||
id = parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = fn(id, w, r)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Server error, not remote error: %v\n", err)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte(err.Error()))
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func CartIdHandler(fn func(cartId cart.CartId, w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var id cart.CartId
|
|
||||||
raw := r.PathValue("id")
|
|
||||||
// If no id supplied, generate a new one
|
|
||||||
if raw == "" {
|
|
||||||
id := cart.MustNewCartId()
|
|
||||||
w.Header().Set("Set-Cart-Id", id.String())
|
|
||||||
} else {
|
|
||||||
// Parse base62 cart id
|
|
||||||
if parsedId, ok := cart.ParseCartId(raw); !ok {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
w.Write([]byte("cart id is invalid"))
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
id = parsedId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err := fn(id, w, r)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Server error, not remote error: %v\n", err)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte(err.Error()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
|
||||||
messages "git.k6n.net/go-cart-actor/proto/checkout"
|
|
||||||
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
|
|
||||||
"github.com/adyen/adyen-go-api-library/v21/src/common"
|
|
||||||
"github.com/adyen/adyen-go-api-library/v21/src/hmacvalidator"
|
|
||||||
"github.com/adyen/adyen-go-api-library/v21/src/webhook"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
"google.golang.org/protobuf/types/known/anypb"
|
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SessionRequest struct {
|
|
||||||
SessionId *string `json:"sessionId,omitempty"`
|
|
||||||
SessionResult string `json:"sessionResult"`
|
|
||||||
SessionData *string `json:"sessionData,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// func (s *CheckoutPoolServer) AdyenSessionHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
|
||||||
|
|
||||||
// grain, err := s.Get(r.Context(), uint64(cartId))
|
|
||||||
// if err != nil {
|
|
||||||
// return err
|
|
||||||
// }
|
|
||||||
// if r.Method == http.MethodGet {
|
|
||||||
// service := s.adyenClient.Checkout()
|
|
||||||
// req := service.PaymentsApi.GetResultOfPaymentSessionInput(pa).SessionResult(payload.SessionResult)
|
|
||||||
// res, _, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
|
|
||||||
// if err != nil {
|
|
||||||
// return err
|
|
||||||
// }
|
|
||||||
// return s.WriteResult(w, res)
|
|
||||||
// } else {
|
|
||||||
// payload := &SessionRequest{}
|
|
||||||
// if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
|
|
||||||
// return err
|
|
||||||
// }
|
|
||||||
// service := s.adyenClient.Checkout()
|
|
||||||
// req := service.PaymentsApi.GetResultOfPaymentSessionInput(payload.SessionId).SessionResult(payload.SessionResult)
|
|
||||||
// res, _, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
|
|
||||||
// if err != nil {
|
|
||||||
// return err
|
|
||||||
// }
|
|
||||||
// return s.WriteResult(w, res)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// }
|
|
||||||
|
|
||||||
func getCheckoutIdFromNotificationItem(item webhook.NotificationRequestItem) (*cart.CartId, error) {
|
|
||||||
cartId, ok := cart.ParseCartId(item.MerchantReference)
|
|
||||||
if !ok {
|
|
||||||
log.Printf("The notification does not have a valid cartId: %s", item.MerchantReference)
|
|
||||||
return nil, errors.New("invalid cart id")
|
|
||||||
}
|
|
||||||
return &cartId, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) AdyenHookHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var notificationRequest webhook.Webhook
|
|
||||||
service := s.adyenClient.Checkout()
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(¬ificationRequest); err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, notificationItem := range *notificationRequest.NotificationItems {
|
|
||||||
item := notificationItem.NotificationRequestItem
|
|
||||||
log.Printf("Recieved notification event code: %s, %+v", item.EventCode, item)
|
|
||||||
|
|
||||||
isValid := hmacvalidator.ValidateHmac(item, hmacKey)
|
|
||||||
if !isValid {
|
|
||||||
log.Printf("notification hmac not valid %s, %v", item.EventCode, item)
|
|
||||||
http.Error(w, "Invalid HMAC", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
// Marshal item data for PaymentEvent
|
|
||||||
dataBytes, err := json.Marshal(item)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("error marshaling item: %v", err)
|
|
||||||
http.Error(w, "Error marshaling item", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch item.EventCode {
|
|
||||||
case "CAPTURE":
|
|
||||||
checkoutId, err := getCheckoutIdFromNotificationItem(item)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Could not get checkout id: %v", err)
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("Capture status: %v", item.Success)
|
|
||||||
isSuccess := item.Success == "true"
|
|
||||||
|
|
||||||
// If successful, apply payment completed
|
|
||||||
//if isSuccess {
|
|
||||||
if err := s.ApplyAnywhere(r.Context(), *checkoutId,
|
|
||||||
&messages.PaymentEvent{
|
|
||||||
PaymentId: item.PspReference,
|
|
||||||
Success: isSuccess,
|
|
||||||
Name: item.EventCode,
|
|
||||||
Data: &anypb.Any{Value: dataBytes},
|
|
||||||
}, &messages.PaymentCompleted{
|
|
||||||
PaymentId: item.PspReference,
|
|
||||||
Status: item.Success,
|
|
||||||
Amount: item.Amount.Value,
|
|
||||||
Currency: item.Amount.Currency,
|
|
||||||
ProcessorReference: &item.PspReference,
|
|
||||||
CompletedAt: timestamppb.New(time.Now()),
|
|
||||||
}); err != nil {
|
|
||||||
http.Error(w, "Message not parsed", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
//}
|
|
||||||
|
|
||||||
case "AUTHORISATION":
|
|
||||||
|
|
||||||
isSuccess := item.Success == "true"
|
|
||||||
log.Printf("Handling auth: %+v", item)
|
|
||||||
checkoutId, err := getCheckoutIdFromNotificationItem(item)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Could not get checkout id: %v", err)
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
msgs := []proto.Message{
|
|
||||||
&messages.PaymentEvent{
|
|
||||||
PaymentId: item.PspReference,
|
|
||||||
Success: isSuccess,
|
|
||||||
Name: item.EventCode,
|
|
||||||
Data: &anypb.Any{Value: dataBytes},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if isSuccess {
|
|
||||||
msgs = append(msgs, &messages.PaymentCompleted{
|
|
||||||
PaymentId: item.PspReference,
|
|
||||||
Status: item.Success,
|
|
||||||
Amount: item.Amount.Value,
|
|
||||||
CompletedAt: timestamppb.Now(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
msgs = append(msgs, &messages.PaymentDeclined{
|
|
||||||
PaymentId: item.PspReference,
|
|
||||||
Message: item.Reason,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if err := s.ApplyAnywhere(r.Context(), *checkoutId, msgs...); err != nil {
|
|
||||||
log.Printf("error applying authorization event: %v", err)
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If successful authorization, trigger capture
|
|
||||||
if isSuccess {
|
|
||||||
|
|
||||||
pspReference := item.PspReference
|
|
||||||
uid := uuid.New().String()
|
|
||||||
ref := checkoutId.String()
|
|
||||||
req := service.ModificationsApi.CaptureAuthorisedPaymentInput(pspReference).IdempotencyKey(uid).PaymentCaptureRequest(adyenCheckout.PaymentCaptureRequest{
|
|
||||||
Amount: adyenCheckout.Amount(item.Amount),
|
|
||||||
MerchantAccount: "ElgigantenECOM",
|
|
||||||
Reference: &ref,
|
|
||||||
})
|
|
||||||
res, _, err := service.ModificationsApi.CaptureAuthorisedPayment(r.Context(), req)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error capturing payment: %v", err)
|
|
||||||
} else {
|
|
||||||
log.Printf("Payment captured successfully: %+v", res)
|
|
||||||
s.ApplyAnywhere(r.Context(), *checkoutId, &messages.OrderCreated{
|
|
||||||
OrderId: res.PaymentPspReference,
|
|
||||||
Status: item.EventCode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
log.Printf("Unknown event code: %s", item.EventCode)
|
|
||||||
log.Printf("Item data: %+v", item)
|
|
||||||
isSuccess := item.Success == "true"
|
|
||||||
checkoutId, err := getCheckoutIdFromNotificationItem(item)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Could not get checkout id: %v", err)
|
|
||||||
|
|
||||||
} else {
|
|
||||||
if err := s.ApplyAnywhere(r.Context(), *checkoutId, &messages.PaymentEvent{
|
|
||||||
PaymentId: item.PspReference,
|
|
||||||
Success: isSuccess,
|
|
||||||
Name: item.EventCode,
|
|
||||||
Data: &anypb.Any{Value: dataBytes},
|
|
||||||
}); err != nil {
|
|
||||||
log.Printf("error applying payment event: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusAccepted)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) AdyenReturnHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
log.Println("Redirect received")
|
|
||||||
|
|
||||||
service := s.adyenClient.Checkout()
|
|
||||||
|
|
||||||
req := service.PaymentsApi.GetResultOfPaymentSessionInput(r.URL.Query().Get("sessionId"))
|
|
||||||
|
|
||||||
res, httpRes, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
|
|
||||||
log.Printf("got payment session %+v", res)
|
|
||||||
|
|
||||||
dreq := service.PaymentsApi.PaymentsDetailsInput()
|
|
||||||
dreq = dreq.PaymentDetailsRequest(adyenCheckout.PaymentDetailsRequest{
|
|
||||||
Details: adyenCheckout.PaymentCompletionDetails{
|
|
||||||
RedirectResult: common.PtrString(r.URL.Query().Get("redirectResult")),
|
|
||||||
Payload: common.PtrString(r.URL.Query().Get("payload")),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
dres, httpRes, err := service.PaymentsApi.PaymentsDetails(r.Context(), dreq)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("Payment details response: %+v", dres)
|
|
||||||
|
|
||||||
if !common.IsNil(dres.PspReference) && *dres.PspReference != "" {
|
|
||||||
var redirectURL string
|
|
||||||
// Conditionally handle different result codes for the shopper
|
|
||||||
switch *dres.ResultCode {
|
|
||||||
case "Authorised":
|
|
||||||
redirectURL = "/result/success"
|
|
||||||
case "Pending", "Received":
|
|
||||||
redirectURL = "/result/pending"
|
|
||||||
case "Refused":
|
|
||||||
redirectURL = "/result/failed"
|
|
||||||
default:
|
|
||||||
reason := ""
|
|
||||||
if dres.RefusalReason != nil {
|
|
||||||
reason = *dres.RefusalReason
|
|
||||||
} else {
|
|
||||||
reason = *dres.ResultCode
|
|
||||||
}
|
|
||||||
log.Printf("Payment failed: %s", reason)
|
|
||||||
redirectURL = fmt.Sprintf("/result/error?reason=%s", url.QueryEscape(reason))
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, redirectURL, http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(httpRes.StatusCode)
|
|
||||||
json.NewEncoder(w).Encode(httpRes.Status)
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AmqpOrderHandler struct {
|
|
||||||
conn *amqp.Connection
|
|
||||||
queue *amqp.Queue
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAmqpOrderHandler(conn *amqp.Connection) *AmqpOrderHandler {
|
|
||||||
return &AmqpOrderHandler{
|
|
||||||
conn: conn,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AmqpOrderHandler) DefineQueue() error {
|
|
||||||
ch, err := h.conn.Channel()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to open a channel: %w", err)
|
|
||||||
}
|
|
||||||
defer ch.Close()
|
|
||||||
|
|
||||||
queue, err := ch.QueueDeclare(
|
|
||||||
"order-queue", // name
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to declare an exchange: %w", err)
|
|
||||||
}
|
|
||||||
h.queue = &queue
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AmqpOrderHandler) OrderCompleted(body []byte) error {
|
|
||||||
ch, err := h.conn.Channel()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to open a channel: %w", err)
|
|
||||||
}
|
|
||||||
defer ch.Close()
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
return ch.PublishWithContext(ctx,
|
|
||||||
"", // exchange
|
|
||||||
h.queue.Name, // routing key
|
|
||||||
false, // mandatory
|
|
||||||
false, // immediate
|
|
||||||
amqp.Publishing{
|
|
||||||
//DeliveryMode: amqp.,
|
|
||||||
ContentType: "application/json",
|
|
||||||
Body: body,
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CartClient struct {
|
|
||||||
httpClient *http.Client
|
|
||||||
baseUrl string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCartClient(baseUrl string) *CartClient {
|
|
||||||
return &CartClient{
|
|
||||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
||||||
baseUrl: baseUrl,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// func (c *CartClient) ApplyMutation(cartId cart.CartId, mutation proto.Message) error {
|
|
||||||
// url := fmt.Sprintf("%s/internal/cart/%s/mutation", c.baseUrl, cartId.String())
|
|
||||||
// data, err := proto.Marshal(mutation)
|
|
||||||
// if err != nil {
|
|
||||||
// return err
|
|
||||||
// }
|
|
||||||
// req, err := http.NewRequest("POST", url, bytes.NewReader(data))
|
|
||||||
// if err != nil {
|
|
||||||
// return err
|
|
||||||
// }
|
|
||||||
// req.Header.Set("Content-Type", "application/protobuf")
|
|
||||||
// resp, err := c.httpClient.Do(req)
|
|
||||||
// if err != nil {
|
|
||||||
// return err
|
|
||||||
// }
|
|
||||||
// defer resp.Body.Close()
|
|
||||||
// if resp.StatusCode != http.StatusOK {
|
|
||||||
// return fmt.Errorf("cart mutation failed: %s", resp.Status)
|
|
||||||
// }
|
|
||||||
// return nil
|
|
||||||
// }
|
|
||||||
|
|
||||||
func (s *CartClient) getCartGrain(ctx context.Context, cartId cart.CartId) (*cart.CartGrain, error) {
|
|
||||||
// Call cart service to get grain
|
|
||||||
url := fmt.Sprintf("%s/cart/byid/%s", s.baseUrl, cartId.String())
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
resp, err := s.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
log.Printf("request to %s failed with status %s", url, resp.Status)
|
|
||||||
return nil, fmt.Errorf("failed to get cart: %s", resp.Status)
|
|
||||||
}
|
|
||||||
var grain cart.CartGrain
|
|
||||||
err = json.NewDecoder(resp.Body).Decode(&grain)
|
|
||||||
return &grain, err
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestGetCartGrain_RealService tests against the actual service at https://cart.k6n.net/
|
|
||||||
// This test is skipped by default and can be run with: go test -run TestGetCartGrain_RealService
|
|
||||||
func TestGetCartGrain_RealService(t *testing.T) {
|
|
||||||
t.Skip("Skipping integration test against real service")
|
|
||||||
|
|
||||||
client := NewCartClient("https://cart.k6n.net")
|
|
||||||
|
|
||||||
// You would need a real cart ID that exists in the system
|
|
||||||
// For example: cartId := cart.NewCartId(123, 456)
|
|
||||||
cartId := cart.MustParseCartId("JkfG6bRNLMy")
|
|
||||||
|
|
||||||
grain, err := client.getCartGrain(context.Background(), cartId)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get cart grain: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if grain == nil {
|
|
||||||
t.Fatal("Expected grain to be non-nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("Successfully retrieved cart grain: %+v", grain)
|
|
||||||
}
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/checkout"
|
|
||||||
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
|
|
||||||
"github.com/adyen/adyen-go-api-library/v21/src/common"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CheckoutMeta carries the external / URL metadata required to build a
|
|
||||||
// Klarna CheckoutOrder from a CartGrain snapshot. It deliberately excludes
|
|
||||||
// any Klarna-specific response fields (HTML snippet, client token, etc.).
|
|
||||||
type CheckoutMeta struct {
|
|
||||||
SiteUrl string
|
|
||||||
// Terms string
|
|
||||||
// Checkout string
|
|
||||||
// Confirmation string
|
|
||||||
ClientIp string
|
|
||||||
Country string
|
|
||||||
Currency string // optional override (defaults to "SEK" if empty)
|
|
||||||
Locale string // optional override (defaults to "sv-se" if empty)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildCheckoutOrderPayload converts the current cart grain + meta information
|
|
||||||
// into a CheckoutOrder domain struct and returns its JSON-serialized payload
|
|
||||||
// (to send to Klarna) alongside the structured CheckoutOrder object.
|
|
||||||
//
|
|
||||||
// This function is PURE: it does not perform any network I/O or mutate the
|
|
||||||
// grain. The caller is responsible for:
|
|
||||||
//
|
|
||||||
// 1. Choosing whether to create or update the Klarna order.
|
|
||||||
// 2. Invoking KlarnaClient.CreateOrder / UpdateOrder with the returned payload.
|
|
||||||
// 3. Applying an InitializeCheckout mutation (or equivalent) with the
|
|
||||||
// resulting Klarna order id + status.
|
|
||||||
//
|
|
||||||
// If you later need to support different tax rates per line, you can extend
|
|
||||||
// CartItem / Delivery to expose that data and propagate it here.
|
|
||||||
func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
|
|
||||||
if grain == nil {
|
|
||||||
return nil, nil, fmt.Errorf("nil grain")
|
|
||||||
}
|
|
||||||
if meta == nil {
|
|
||||||
return nil, nil, fmt.Errorf("nil checkout meta")
|
|
||||||
}
|
|
||||||
|
|
||||||
currency := meta.Currency
|
|
||||||
if currency == "" {
|
|
||||||
currency = "SEK"
|
|
||||||
}
|
|
||||||
locale := meta.Locale
|
|
||||||
if locale == "" {
|
|
||||||
locale = "sv-se"
|
|
||||||
}
|
|
||||||
country := meta.Country
|
|
||||||
if country == "" {
|
|
||||||
country = "SE" // sensible default; adjust if multi-country support changes
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := make([]*Line, 0, len(grain.CartState.Items)+len(grain.Deliveries))
|
|
||||||
|
|
||||||
// Item lines
|
|
||||||
for _, it := range grain.CartState.Items {
|
|
||||||
if it == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lines = append(lines, &Line{
|
|
||||||
Type: "physical",
|
|
||||||
Reference: it.Sku,
|
|
||||||
Name: it.Meta.Name,
|
|
||||||
Quantity: int(it.Quantity),
|
|
||||||
UnitPrice: int(it.Price.IncVat),
|
|
||||||
TaxRate: it.Tax, // TODO: derive if variable tax rates are introduced
|
|
||||||
QuantityUnit: "st",
|
|
||||||
TotalAmount: int(it.TotalPrice.IncVat),
|
|
||||||
TotalTaxAmount: int(it.TotalPrice.TotalVat()),
|
|
||||||
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", it.Meta.Image),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
total := cart.NewPrice()
|
|
||||||
total.Add(*grain.CartState.TotalPrice)
|
|
||||||
|
|
||||||
// Delivery lines
|
|
||||||
for _, d := range grain.Deliveries {
|
|
||||||
if d == nil || d.Price.IncVat <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
//total.Add(d.Price)
|
|
||||||
lines = append(lines, &Line{
|
|
||||||
Type: "shipping_fee",
|
|
||||||
Reference: d.Provider,
|
|
||||||
Name: "Delivery",
|
|
||||||
Quantity: 1,
|
|
||||||
UnitPrice: int(d.Price.IncVat),
|
|
||||||
TaxRate: 2500,
|
|
||||||
QuantityUnit: "st",
|
|
||||||
TotalAmount: int(d.Price.IncVat),
|
|
||||||
TotalTaxAmount: int(d.Price.TotalVat()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
order := &CheckoutOrder{
|
|
||||||
PurchaseCountry: country,
|
|
||||||
PurchaseCurrency: currency,
|
|
||||||
Locale: locale,
|
|
||||||
OrderAmount: int(total.IncVat),
|
|
||||||
OrderTaxAmount: int(total.TotalVat()),
|
|
||||||
OrderLines: lines,
|
|
||||||
MerchantReference1: grain.Id.String(),
|
|
||||||
MerchantURLS: &CheckoutMerchantURLS{
|
|
||||||
Terms: fmt.Sprintf("%s/terms", meta.SiteUrl),
|
|
||||||
Checkout: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl),
|
|
||||||
Confirmation: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl),
|
|
||||||
Notification: "https://cart.k6n.net/payment/klarna/notification",
|
|
||||||
Validation: "https://cart.k6n.net/payment/klarna/validate",
|
|
||||||
Push: "https://cart.k6n.net/payment/klarna/push?order_id={checkout.order.id}",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := json.Marshal(order)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("marshal checkout order: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload, order, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetCheckoutMetaFromRequest(r *http.Request) *CheckoutMeta {
|
|
||||||
host := getOriginalHost(r)
|
|
||||||
country := getCountryFromHost(host)
|
|
||||||
return &CheckoutMeta{
|
|
||||||
ClientIp: getClientIp(r),
|
|
||||||
SiteUrl: fmt.Sprintf("https://%s", host),
|
|
||||||
Country: country,
|
|
||||||
Currency: getCurrency(country),
|
|
||||||
Locale: getLocale(country),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta) (*adyenCheckout.CreateCheckoutSessionRequest, error) {
|
|
||||||
if grain == nil {
|
|
||||||
return nil, fmt.Errorf("nil grain")
|
|
||||||
}
|
|
||||||
if meta == nil {
|
|
||||||
return nil, fmt.Errorf("nil checkout meta")
|
|
||||||
}
|
|
||||||
|
|
||||||
currency := meta.Currency
|
|
||||||
if currency == "" {
|
|
||||||
currency = "SEK"
|
|
||||||
}
|
|
||||||
country := meta.Country
|
|
||||||
if country == "" {
|
|
||||||
country = "SE"
|
|
||||||
}
|
|
||||||
|
|
||||||
lineItems := make([]adyenCheckout.LineItem, 0, len(grain.CartState.Items)+len(grain.Deliveries))
|
|
||||||
|
|
||||||
// Item lines
|
|
||||||
for _, it := range grain.CartState.Items {
|
|
||||||
if it == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lineItems = append(lineItems, adyenCheckout.LineItem{
|
|
||||||
Quantity: common.PtrInt64(int64(it.Quantity)),
|
|
||||||
AmountIncludingTax: common.PtrInt64(it.TotalPrice.IncVat),
|
|
||||||
Description: common.PtrString(it.Meta.Name),
|
|
||||||
AmountExcludingTax: common.PtrInt64(it.TotalPrice.ValueExVat()),
|
|
||||||
TaxAmount: common.PtrInt64(it.TotalPrice.TotalVat()),
|
|
||||||
TaxPercentage: common.PtrInt64(int64(it.Tax)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
total := cart.NewPrice()
|
|
||||||
total.Add(*grain.CartState.TotalPrice)
|
|
||||||
|
|
||||||
// Delivery lines
|
|
||||||
for _, d := range grain.Deliveries {
|
|
||||||
if d == nil || d.Price.IncVat <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lineItems = append(lineItems, adyenCheckout.LineItem{
|
|
||||||
Quantity: common.PtrInt64(1),
|
|
||||||
AmountIncludingTax: common.PtrInt64(d.Price.IncVat),
|
|
||||||
Description: common.PtrString("Delivery"),
|
|
||||||
AmountExcludingTax: common.PtrInt64(d.Price.ValueExVat()),
|
|
||||||
TaxPercentage: common.PtrInt64(25),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return &adyenCheckout.CreateCheckoutSessionRequest{
|
|
||||||
Reference: grain.Id.String(),
|
|
||||||
Amount: adyenCheckout.Amount{
|
|
||||||
Value: total.IncVat,
|
|
||||||
Currency: currency,
|
|
||||||
},
|
|
||||||
CountryCode: common.PtrString(country),
|
|
||||||
MerchantAccount: "ElgigantenECOM",
|
|
||||||
Channel: common.PtrString("Web"),
|
|
||||||
ShopperIP: common.PtrString(meta.ClientIp),
|
|
||||||
ReturnUrl: fmt.Sprintf("%s/payment/adyen/return", meta.SiteUrl),
|
|
||||||
LineItems: lineItems,
|
|
||||||
}, nil
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/discovery"
|
|
||||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"k8s.io/client-go/kubernetes"
|
|
||||||
"k8s.io/client-go/rest"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetDiscovery() discovery.Discovery {
|
|
||||||
if podIp == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
config, kerr := rest.InClusterConfig()
|
|
||||||
|
|
||||||
if kerr != nil {
|
|
||||||
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
|
|
||||||
}
|
|
||||||
client, err := kubernetes.NewForConfig(config)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error creating client: %v\n", err)
|
|
||||||
}
|
|
||||||
timeout := int64(30)
|
|
||||||
return discovery.NewK8sDiscovery(client, v1.ListOptions{
|
|
||||||
LabelSelector: "actor-pool=checkout",
|
|
||||||
TimeoutSeconds: &timeout,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func UseDiscovery(pool discovery.DiscoveryTarget) {
|
|
||||||
|
|
||||||
go func(hw discovery.Discovery) {
|
|
||||||
if hw == nil {
|
|
||||||
log.Print("No discovery service available")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ch, err := hw.Watch()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Discovery error: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for evt := range ch {
|
|
||||||
if evt.Host == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch evt.IsReady {
|
|
||||||
case false:
|
|
||||||
if pool.IsKnown(evt.Host) {
|
|
||||||
log.Printf("Host %s is not ready, removing", evt.Host)
|
|
||||||
pool.RemoveHost(evt.Host)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
if !pool.IsKnown(evt.Host) {
|
|
||||||
log.Printf("Discovered host %s", evt.Host)
|
|
||||||
pool.AddRemoteHost(evt.Host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}(GetDiscovery())
|
|
||||||
}
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/checkout"
|
|
||||||
messages "git.k6n.net/go-cart-actor/proto/checkout"
|
|
||||||
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
|
||||||
)
|
|
||||||
|
|
||||||
/*
|
|
||||||
*
|
|
||||||
*
|
|
||||||
* s.CheckoutHandler(func(order *CheckoutOrder, w http.ResponseWriter) error {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, err := fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
*/
|
|
||||||
|
|
||||||
// func (s *CheckoutPoolServer) KlarnaHtmlCheckoutHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
|
|
||||||
|
|
||||||
// orderId := r.URL.Query().Get("order_id")
|
|
||||||
// var order *CheckoutOrder
|
|
||||||
// var err error
|
|
||||||
// if orderId == "" {
|
|
||||||
// order, err = s.CreateOrUpdateCheckout(r, checkoutId)
|
|
||||||
// if err != nil {
|
|
||||||
// logger.Error("unable to create klarna session", "error", err)
|
|
||||||
// return err
|
|
||||||
// }
|
|
||||||
// // s.ApplyKlarnaPaymentStarted(r.Context(), order, checkoutId)
|
|
||||||
|
|
||||||
// }
|
|
||||||
// order, err = s.klarnaClient.GetOrder(r.Context(), orderId)
|
|
||||||
// if err != nil {
|
|
||||||
// return err
|
|
||||||
// }
|
|
||||||
// w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
// w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
|
|
||||||
// w.WriteHeader(http.StatusOK)
|
|
||||||
// _, err = fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
|
||||||
// return err
|
|
||||||
|
|
||||||
// }
|
|
||||||
|
|
||||||
// func (s *CheckoutPoolServer) KlarnaSessionHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
|
|
||||||
|
|
||||||
// orderId := r.URL.Query().Get("order_id")
|
|
||||||
// var order *CheckoutOrder
|
|
||||||
// var err error
|
|
||||||
// if orderId == "" {
|
|
||||||
// order, err = s.CreateOrUpdateCheckout(r, checkoutId)
|
|
||||||
// if err != nil {
|
|
||||||
// logger.Error("unable to create klarna session", "error", err)
|
|
||||||
// return err
|
|
||||||
// }
|
|
||||||
// // s.ApplyKlarnaPaymentStarted(r.Context(), order, checkoutId)
|
|
||||||
|
|
||||||
// }
|
|
||||||
// order, err = s.klarnaClient.GetOrder(r.Context(), orderId)
|
|
||||||
// if err != nil {
|
|
||||||
// return err
|
|
||||||
// }
|
|
||||||
// w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
||||||
// return json.NewEncoder(w).Encode(order)
|
|
||||||
|
|
||||||
// }
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) KlarnaConfirmationHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
|
|
||||||
orderId := r.PathValue("order_id")
|
|
||||||
order, err := s.klarnaClient.GetOrder(r.Context(), orderId)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply ConfirmationViewed mutation
|
|
||||||
cartId, ok := cart.ParseCartId(order.MerchantReference1)
|
|
||||||
if ok {
|
|
||||||
s.Apply(r.Context(), uint64(cartId), &messages.ConfirmationViewed{})
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
if order.Status == "checkout_complete" {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "cartid",
|
|
||||||
Value: "",
|
|
||||||
Path: "/",
|
|
||||||
Secure: true,
|
|
||||||
HttpOnly: true,
|
|
||||||
Expires: time.Unix(0, 0),
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
fmt.Fprintf(w, tpl, order.HTMLSnippet)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) KlarnaValidationHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
log.Printf("Klarna order validation, method: %s", r.Method)
|
|
||||||
if r.Method != "POST" {
|
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
order := &CheckoutOrder{}
|
|
||||||
err := json.NewDecoder(r.Body).Decode(order)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logger.InfoContext(r.Context(), "Klarna order validation received", "order_id", order.ID, "cart_id", order.MerchantReference1)
|
|
||||||
grain, err := s.getGrainFromKlarnaOrder(r.Context(), order)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorContext(r.Context(), "Unable to get grain from klarna order", "error", err.Error())
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.reserveInventory(r.Context(), grain)
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) KlarnaNotificationHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
|
|
||||||
log.Printf("Klarna order notification, method: %s", r.Method)
|
|
||||||
logger.InfoContext(r.Context(), "Klarna order notification received", "method", r.Method)
|
|
||||||
if r.Method != "POST" {
|
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
order := &CheckoutOrder{}
|
|
||||||
err := json.NewDecoder(r.Body).Decode(order)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("Klarna order notification: %s", order.ID)
|
|
||||||
logger.InfoContext(r.Context(), "Klarna order notification received", "order_id", order.ID)
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
log.Printf("Klarna order confirmation push, method: %s", r.Method)
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
orderId := r.URL.Query().Get("order_id")
|
|
||||||
log.Printf("Order confirmation push: %s", orderId)
|
|
||||||
|
|
||||||
order, err := s.klarnaClient.GetOrder(r.Context(), orderId)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error creating request: %v\n", err)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
grain, err := s.getGrainFromKlarnaOrder(r.Context(), order)
|
|
||||||
if err != nil {
|
|
||||||
logger.ErrorContext(r.Context(), "Unable to get grain from klarna order", "error", err.Error())
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.inventoryService != nil {
|
|
||||||
inventoryRequests := getInventoryRequests(grain.CartState.Items)
|
|
||||||
err = s.inventoryService.ReserveInventory(r.Context(), inventoryRequests...)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
logger.WarnContext(r.Context(), "placeorder inventory reservation failed")
|
|
||||||
w.WriteHeader(http.StatusNotAcceptable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.Apply(r.Context(), uint64(grain.Id), &messages.InventoryReserved{
|
|
||||||
Id: grain.Id.String(),
|
|
||||||
Status: "success",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
s.ApplyAnywhere(r.Context(), grain.Id, &messages.PaymentCompleted{
|
|
||||||
PaymentId: orderId,
|
|
||||||
Status: "completed",
|
|
||||||
ProcessorReference: &order.ID,
|
|
||||||
Amount: int64(order.OrderAmount),
|
|
||||||
Currency: order.PurchaseCurrency,
|
|
||||||
CompletedAt: timestamppb.Now(),
|
|
||||||
})
|
|
||||||
|
|
||||||
// err = confirmOrder(r.Context(), order, orderHandler)
|
|
||||||
// if err != nil {
|
|
||||||
// log.Printf("Error confirming order: %v\n", err)
|
|
||||||
// w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
|
|
||||||
// err = triggerOrderCompleted(r.Context(), a.server, order)
|
|
||||||
// if err != nil {
|
|
||||||
// log.Printf("Error processing cart message: %v\n", err)
|
|
||||||
// w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
err = s.klarnaClient.AcknowledgeOrder(r.Context(), orderId)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error acknowledging order: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
var tpl = `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>s10r testing - checkout</title>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
%s
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`
|
|
||||||
|
|
||||||
func getLocationId(item *cart.CartItem) inventory.LocationID {
|
|
||||||
if item.StoreId == nil || *item.StoreId == "" {
|
|
||||||
return "se"
|
|
||||||
}
|
|
||||||
return inventory.LocationID(*item.StoreId)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
|
|
||||||
var requests []inventory.ReserveRequest
|
|
||||||
for _, item := range items {
|
|
||||||
if item == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
requests = append(requests, inventory.ReserveRequest{
|
|
||||||
InventoryReference: &inventory.InventoryReference{
|
|
||||||
SKU: inventory.SKU(item.Sku),
|
|
||||||
LocationID: getLocationId(item),
|
|
||||||
},
|
|
||||||
Quantity: uint32(item.Quantity),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return requests
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *CheckoutPoolServer) getGrainFromKlarnaOrder(ctx context.Context, order *CheckoutOrder) (*checkout.CheckoutGrain, error) {
|
|
||||||
cartId, ok := cart.ParseCartId(order.MerchantReference1)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("invalid cart id in order reference: %s", order.MerchantReference1)
|
|
||||||
}
|
|
||||||
grain, err := a.GetAnywhere(ctx, cartId)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get cart grain: %w", err)
|
|
||||||
}
|
|
||||||
return grain, nil
|
|
||||||
}
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/actor"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/checkout"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/proxy"
|
|
||||||
"github.com/adyen/adyen-go-api-library/v21/src/adyen"
|
|
||||||
"github.com/adyen/adyen-go-api-library/v21/src/common"
|
|
||||||
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
grainSpawns = promauto.NewCounter(prometheus.CounterOpts{
|
|
||||||
Name: "checkout_grain_spawned_total",
|
|
||||||
Help: "The total number of spawned checkout grains",
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
os.Mkdir("data", 0755)
|
|
||||||
}
|
|
||||||
|
|
||||||
type App struct {
|
|
||||||
pool *actor.SimpleGrainPool[checkout.CheckoutGrain]
|
|
||||||
server *CheckoutPoolServer
|
|
||||||
klarnaClient *KlarnaClient
|
|
||||||
cartClient *CartClient // For internal communication to cart
|
|
||||||
}
|
|
||||||
|
|
||||||
var podIp = os.Getenv("POD_IP")
|
|
||||||
var name = os.Getenv("POD_NAME")
|
|
||||||
var amqpUrl = os.Getenv("AMQP_URL")
|
|
||||||
var redisAddress = os.Getenv("REDIS_ADDRESS")
|
|
||||||
var redisPassword = os.Getenv("REDIS_PASSWORD")
|
|
||||||
var cartInternalUrl = os.Getenv("CART_INTERNAL_URL") // e.g., http://cart-service:8081
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
|
|
||||||
controlPlaneConfig := actor.DefaultServerConfig()
|
|
||||||
|
|
||||||
reg := checkout.NewCheckoutMutationRegistry(checkout.NewCheckoutMutationContext())
|
|
||||||
reg.RegisterProcessor(
|
|
||||||
actor.NewMutationProcessor(func(ctx context.Context, g *checkout.CheckoutGrain) error {
|
|
||||||
g.Version++
|
|
||||||
return nil
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
rdb := redis.NewClient(&redis.Options{
|
|
||||||
Addr: redisAddress,
|
|
||||||
Password: redisPassword,
|
|
||||||
DB: 0,
|
|
||||||
})
|
|
||||||
inventoryService, err := inventory.NewRedisInventoryService(rdb)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error creating inventory service: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
diskStorage := actor.NewDiskStorage[checkout.CheckoutGrain]("data", reg)
|
|
||||||
|
|
||||||
poolConfig := actor.GrainPoolConfig[checkout.CheckoutGrain]{
|
|
||||||
MutationRegistry: reg,
|
|
||||||
Storage: diskStorage,
|
|
||||||
Spawn: func(ctx context.Context, id uint64) (actor.Grain[checkout.CheckoutGrain], error) {
|
|
||||||
_, span := tracer.Start(ctx, fmt.Sprintf("Spawn checkout id %d", id))
|
|
||||||
defer span.End()
|
|
||||||
grainSpawns.Inc()
|
|
||||||
|
|
||||||
ret := checkout.NewCheckoutGrain(id, 0, 0, time.Now(), nil) // version to be set later
|
|
||||||
// Load persisted events/state for this checkout if present
|
|
||||||
if err := diskStorage.LoadEvents(ctx, id, ret); err != nil {
|
|
||||||
// Return the grain along with error (e.g., not found) so callers can decide
|
|
||||||
return ret, err
|
|
||||||
}
|
|
||||||
return ret, nil
|
|
||||||
},
|
|
||||||
Destroy: func(grain actor.Grain[checkout.CheckoutGrain]) error {
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
SpawnHost: func(host string) (actor.Host[checkout.CheckoutGrain], error) {
|
|
||||||
return proxy.NewRemoteHost[checkout.CheckoutGrain](host)
|
|
||||||
},
|
|
||||||
TTL: 1 * time.Hour, // Longer TTL for checkout
|
|
||||||
PoolSize: 65535,
|
|
||||||
Hostname: podIp,
|
|
||||||
}
|
|
||||||
|
|
||||||
pool, err := actor.NewSimpleGrainPool(poolConfig)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error creating checkout pool: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
adyenClient := adyen.NewClient(&common.Config{
|
|
||||||
ApiKey: os.Getenv("ADYEN_API_KEY"),
|
|
||||||
Environment: common.TestEnv,
|
|
||||||
})
|
|
||||||
|
|
||||||
klarnaClient := NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
|
|
||||||
|
|
||||||
cartClient := NewCartClient(cartInternalUrl)
|
|
||||||
|
|
||||||
syncedServer := NewCheckoutPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, cartClient, adyenClient)
|
|
||||||
syncedServer.inventoryService = inventoryService
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
debugMux := http.NewServeMux()
|
|
||||||
|
|
||||||
if amqpUrl == "" {
|
|
||||||
log.Fatalf("no connection to amqp defined")
|
|
||||||
}
|
|
||||||
|
|
||||||
grpcSrv, err := actor.NewControlServer[checkout.CheckoutGrain](controlPlaneConfig, pool)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
|
|
||||||
}
|
|
||||||
defer grpcSrv.GracefulStop()
|
|
||||||
|
|
||||||
UseDiscovery(pool)
|
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
|
||||||
defer stop()
|
|
||||||
|
|
||||||
otelShutdown, err := setupOTelSDK(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Unable to start otel %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
syncedServer.Serve(mux)
|
|
||||||
|
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
grainCount, capacity := pool.LocalUsage()
|
|
||||||
if grainCount >= capacity {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte("grain pool at capacity"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !pool.IsHealthy() {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte("control plane not healthy"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
|
|
||||||
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("1.0.0"))
|
|
||||||
})
|
|
||||||
|
|
||||||
srv := &http.Server{
|
|
||||||
Addr: ":8080",
|
|
||||||
BaseContext: func(net.Listener) context.Context { return ctx },
|
|
||||||
ReadTimeout: 10 * time.Second,
|
|
||||||
WriteTimeout: 20 * time.Second,
|
|
||||||
Handler: otelhttp.NewHandler(mux, "/"),
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
fmt.Println("Shutting down due to signal")
|
|
||||||
otelShutdown(context.Background())
|
|
||||||
diskStorage.Close()
|
|
||||||
pool.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
srvErr := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
srvErr <- srv.ListenAndServe()
|
|
||||||
}()
|
|
||||||
|
|
||||||
log.Print("Checkout server started at port 8080")
|
|
||||||
|
|
||||||
go http.ListenAndServe(":8081", debugMux)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err = <-srvErr:
|
|
||||||
log.Fatalf("Unable to start server: %v", err)
|
|
||||||
case <-ctx.Done():
|
|
||||||
stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.opentelemetry.io/otel"
|
|
||||||
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
|
|
||||||
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
|
|
||||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
|
||||||
"go.opentelemetry.io/otel/log/global"
|
|
||||||
"go.opentelemetry.io/otel/propagation"
|
|
||||||
"go.opentelemetry.io/otel/sdk/log"
|
|
||||||
"go.opentelemetry.io/otel/sdk/metric"
|
|
||||||
"go.opentelemetry.io/otel/sdk/trace"
|
|
||||||
)
|
|
||||||
|
|
||||||
// setupOTelSDK bootstraps the OpenTelemetry pipeline.
|
|
||||||
// If it does not return an error, make sure to call shutdown for proper cleanup.
|
|
||||||
func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
|
|
||||||
var shutdownFuncs []func(context.Context) error
|
|
||||||
var err error
|
|
||||||
|
|
||||||
// shutdown calls cleanup functions registered via shutdownFuncs.
|
|
||||||
// The errors from the calls are joined.
|
|
||||||
// Each registered cleanup will be invoked once.
|
|
||||||
shutdown := func(ctx context.Context) error {
|
|
||||||
var err error
|
|
||||||
for _, fn := range shutdownFuncs {
|
|
||||||
err = errors.Join(err, fn(ctx))
|
|
||||||
}
|
|
||||||
shutdownFuncs = nil
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleErr calls shutdown for cleanup and makes sure that all errors are returned.
|
|
||||||
handleErr := func(inErr error) {
|
|
||||||
err = errors.Join(inErr, shutdown(ctx))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up propagator.
|
|
||||||
prop := newPropagator()
|
|
||||||
otel.SetTextMapPropagator(prop)
|
|
||||||
|
|
||||||
// Set up trace provider.
|
|
||||||
tracerProvider, err := newTracerProvider()
|
|
||||||
if err != nil {
|
|
||||||
handleErr(err)
|
|
||||||
return shutdown, err
|
|
||||||
}
|
|
||||||
shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
|
|
||||||
otel.SetTracerProvider(tracerProvider)
|
|
||||||
|
|
||||||
// Set up meter provider.
|
|
||||||
meterProvider, err := newMeterProvider()
|
|
||||||
if err != nil {
|
|
||||||
handleErr(err)
|
|
||||||
return shutdown, err
|
|
||||||
}
|
|
||||||
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
|
|
||||||
otel.SetMeterProvider(meterProvider)
|
|
||||||
|
|
||||||
// Set up logger provider.
|
|
||||||
loggerProvider, err := newLoggerProvider()
|
|
||||||
if err != nil {
|
|
||||||
handleErr(err)
|
|
||||||
return shutdown, err
|
|
||||||
}
|
|
||||||
shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
|
|
||||||
global.SetLoggerProvider(loggerProvider)
|
|
||||||
|
|
||||||
return shutdown, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func newPropagator() propagation.TextMapPropagator {
|
|
||||||
return propagation.NewCompositeTextMapPropagator(
|
|
||||||
propagation.TraceContext{},
|
|
||||||
propagation.Baggage{},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTracerProvider() (*trace.TracerProvider, error) {
|
|
||||||
traceExporter, err := otlptracegrpc.New(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
tracerProvider := trace.NewTracerProvider(
|
|
||||||
trace.WithBatcher(traceExporter,
|
|
||||||
// Default is 5s. Set to 1s for demonstrative purposes.
|
|
||||||
trace.WithBatchTimeout(time.Second)),
|
|
||||||
)
|
|
||||||
return tracerProvider, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMeterProvider() (*metric.MeterProvider, error) {
|
|
||||||
exporter, err := otlpmetricgrpc.New(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
provider := metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(exporter)))
|
|
||||||
return provider, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newLoggerProvider() (*log.LoggerProvider, error) {
|
|
||||||
logExporter, err := otlploggrpc.New(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
loggerProvider := log.NewLoggerProvider(
|
|
||||||
log.WithProcessor(log.NewBatchProcessor(logExporter)),
|
|
||||||
)
|
|
||||||
return loggerProvider, nil
|
|
||||||
}
|
|
||||||
@@ -1,543 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/actor"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/checkout"
|
|
||||||
messages "git.k6n.net/go-cart-actor/proto/checkout"
|
|
||||||
|
|
||||||
adyen "github.com/adyen/adyen-go-api-library/v21/src/adyen"
|
|
||||||
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
|
||||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
"google.golang.org/protobuf/types/known/anypb"
|
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
|
||||||
|
|
||||||
"go.opentelemetry.io/otel"
|
|
||||||
"go.opentelemetry.io/otel/attribute"
|
|
||||||
"go.opentelemetry.io/otel/metric"
|
|
||||||
"go.opentelemetry.io/otel/trace"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
|
|
||||||
Name: "checkout_grain_mutations_total",
|
|
||||||
Help: "The total number of mutations",
|
|
||||||
})
|
|
||||||
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
|
|
||||||
Name: "checkout_grain_lookups_total",
|
|
||||||
Help: "The total number of lookups",
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
type CheckoutPoolServer struct {
|
|
||||||
actor.GrainPool[checkout.CheckoutGrain]
|
|
||||||
pod_name string
|
|
||||||
klarnaClient *KlarnaClient
|
|
||||||
adyenClient *adyen.APIClient
|
|
||||||
cartClient *CartClient
|
|
||||||
inventoryService *inventory.RedisInventoryService
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCheckoutPoolServer(pool actor.GrainPool[checkout.CheckoutGrain], pod_name string, klarnaClient *KlarnaClient, cartClient *CartClient, adyenClient *adyen.APIClient) *CheckoutPoolServer {
|
|
||||||
srv := &CheckoutPoolServer{
|
|
||||||
GrainPool: pool,
|
|
||||||
pod_name: pod_name,
|
|
||||||
klarnaClient: klarnaClient,
|
|
||||||
cartClient: cartClient,
|
|
||||||
adyenClient: adyenClient,
|
|
||||||
}
|
|
||||||
|
|
||||||
return srv
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) ApplyLocal(ctx context.Context, id checkout.CheckoutId, mutation ...proto.Message) (*actor.MutationResult[checkout.CheckoutGrain], error) {
|
|
||||||
return s.Apply(ctx, uint64(id), mutation...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) GetCheckoutHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
|
||||||
grain, err := s.Get(r.Context(), uint64(id))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.WriteResult(w, grain)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) SetDeliveryHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
|
||||||
var msg messages.SetDelivery
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := s.ApplyLocal(r.Context(), id, &msg)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.WriteResult(w, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) RemoveDeliveryHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
|
||||||
deliveryId := r.PathValue("id")
|
|
||||||
uintDeliveryId, err := strconv.ParseUint(deliveryId, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
msg := &messages.RemoveDelivery{
|
|
||||||
Id: uint32(uintDeliveryId),
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := s.ApplyLocal(r.Context(), id, msg)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.WriteResult(w, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) SetPickupPointHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
|
||||||
var msg messages.SetPickupPoint
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := s.ApplyLocal(r.Context(), id, &msg)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.WriteResult(w, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) InitializeCheckoutHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
|
||||||
var msg messages.InitializeCheckout
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := s.ApplyLocal(r.Context(), id, &msg)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.WriteResult(w, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) InventoryReservedHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
|
||||||
var msg messages.InventoryReserved
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := s.ApplyLocal(r.Context(), id, &msg)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.WriteResult(w, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) OrderCreatedHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
|
||||||
var msg messages.OrderCreated
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := s.ApplyLocal(r.Context(), id, &msg)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.WriteResult(w, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) ConfirmationViewedHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
|
||||||
var msg messages.ConfirmationViewed
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := s.ApplyLocal(r.Context(), id, &msg)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.WriteResult(w, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) ContactDetailsUpdatedHandler(w http.ResponseWriter, r *http.Request, id checkout.CheckoutId) error {
|
|
||||||
var msg messages.ContactDetailsUpdated
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := s.ApplyLocal(r.Context(), id, &msg)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.WriteResult(w, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) StartCheckoutHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
cartIdStr := r.PathValue("cartid")
|
|
||||||
if cartIdStr == "" {
|
|
||||||
http.Error(w, "cart id required", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cartId, ok := cart.ParseCartId(cartIdStr)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "invalid cart id", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch cart state from cart service
|
|
||||||
cartGrain, err := s.cartClient.getCartGrain(r.Context(), cartId)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("failed to fetch cart", "error", err, "cartId", cartId)
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serialize cart state to Any
|
|
||||||
cartStateBytes, err := json.Marshal(cartGrain)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("failed to marshal cart state", "error", err)
|
|
||||||
http.Error(w, "failed to process cart state", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create checkout with same ID as cart
|
|
||||||
var checkoutId checkout.CheckoutId = cart.MustNewCartId()
|
|
||||||
cookie, err := r.Cookie(checkoutCookieName)
|
|
||||||
if err == nil {
|
|
||||||
parsed, ok := cart.ParseCartId(cookie.Value)
|
|
||||||
if ok {
|
|
||||||
checkoutId = parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize checkout with cart state wrapped in Any
|
|
||||||
cartStateAny := &messages.InitializeCheckout{
|
|
||||||
OrderId: "",
|
|
||||||
CartId: uint64(cartId),
|
|
||||||
Version: uint32(cartGrain.Version),
|
|
||||||
CartState: &anypb.Any{
|
|
||||||
TypeUrl: "type.googleapis.com/cart.CartGrain",
|
|
||||||
Value: cartStateBytes,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := s.ApplyLocal(r.Context(), checkoutId, cartStateAny)
|
|
||||||
if err != nil {
|
|
||||||
setCheckoutCookie(w, 0, r.TLS != nil)
|
|
||||||
logger.Error("failed to initialize checkout", "error", err)
|
|
||||||
http.Error(w, "failed to initialize checkout", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set checkout cookie
|
|
||||||
setCheckoutCookie(w, checkoutId, r.TLS != nil)
|
|
||||||
|
|
||||||
if err := s.WriteResult(w, &result.Result); err != nil {
|
|
||||||
logger.Error("failed to write result", "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) WriteResult(w http.ResponseWriter, result any) error {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.Header().Set("X-Pod-Name", s.pod_name)
|
|
||||||
if result == nil {
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
enc := json.NewEncoder(w)
|
|
||||||
err := enc.Encode(result)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) CreateOrUpdateCheckout(r *http.Request, grain *checkout.CheckoutGrain, orderId *string) (*CheckoutOrder, error) {
|
|
||||||
|
|
||||||
meta := GetCheckoutMetaFromRequest(r)
|
|
||||||
|
|
||||||
payload, _, err := BuildCheckoutOrderPayload(grain, meta)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var payment *checkout.Payment
|
|
||||||
if orderId != nil {
|
|
||||||
payment, _ = grain.FindPayment(*orderId)
|
|
||||||
}
|
|
||||||
|
|
||||||
if payment != nil && payment.PaymentId != "" {
|
|
||||||
return s.klarnaClient.UpdateOrder(r.Context(), payment.PaymentId, bytes.NewReader(payload))
|
|
||||||
} else {
|
|
||||||
return s.klarnaClient.CreateOrder(r.Context(), bytes.NewReader(payload))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) ApplyKlarnaPaymentStarted(ctx context.Context, klarnaOrder *CheckoutOrder, id checkout.CheckoutId) (*actor.MutationResult[checkout.CheckoutGrain], error) {
|
|
||||||
method := "checkout"
|
|
||||||
return s.ApplyLocal(ctx, id, &messages.PaymentStarted{
|
|
||||||
PaymentId: klarnaOrder.ID,
|
|
||||||
Amount: int64(klarnaOrder.OrderAmount),
|
|
||||||
Currency: klarnaOrder.PurchaseCurrency,
|
|
||||||
Provider: "klarna",
|
|
||||||
Method: &method,
|
|
||||||
StartedAt: timestamppb.New(time.Now()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
tracer = otel.Tracer(name)
|
|
||||||
hmacKey = os.Getenv("ADYEN_HMAC")
|
|
||||||
meter = otel.Meter(name)
|
|
||||||
logger = otelslog.NewLogger(name)
|
|
||||||
proxyCalls metric.Int64Counter
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
var err error
|
|
||||||
proxyCalls, err = meter.Int64Counter("proxy.calls",
|
|
||||||
metric.WithDescription("Number of proxy calls"),
|
|
||||||
metric.WithUnit("{calls}"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) GetAnywhere(ctx context.Context, checkoutId cart.CartId) (*checkout.CheckoutGrain, error) {
|
|
||||||
id := uint64(checkoutId)
|
|
||||||
if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost {
|
|
||||||
return host.Get(ctx, id)
|
|
||||||
}
|
|
||||||
return s.Get(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) ApplyAnywhere(ctx context.Context, checkoutId cart.CartId, msgs ...proto.Message) error {
|
|
||||||
id := uint64(checkoutId)
|
|
||||||
if host, isOnOtherHost := s.OwnerHost(id); isOnOtherHost {
|
|
||||||
_, err := host.Apply(ctx, id, msgs...)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err := s.Apply(ctx, id, msgs...)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
type StartPayment struct {
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
Method string `json:"method,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) GetPaymentSessionHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
|
|
||||||
paymentId := r.PathValue("id")
|
|
||||||
grain, err := s.Get(r.Context(), uint64(checkoutId))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
payment, ok := grain.FindPayment(paymentId)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "payment not found", http.StatusNotFound)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
switch payment.Provider {
|
|
||||||
case "adyen":
|
|
||||||
payload := &SessionRequest{
|
|
||||||
SessionResult: "",
|
|
||||||
}
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
service := s.adyenClient.Checkout()
|
|
||||||
req := service.PaymentsApi.GetResultOfPaymentSessionInput(paymentId).SessionResult(payload.SessionResult)
|
|
||||||
|
|
||||||
res, _, err := service.PaymentsApi.GetResultOfPaymentSession(r.Context(), req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if res.Status != nil && *res.Status == "completed" {
|
|
||||||
_, err := s.ApplyLocal(r.Context(), checkoutId, &messages.PaymentCompleted{
|
|
||||||
PaymentId: paymentId,
|
|
||||||
Status: *res.Status,
|
|
||||||
ProcessorReference: res.Id,
|
|
||||||
CompletedAt: timestamppb.Now(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("unable to apply payment started mutation", "error", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.WriteResult(w, res)
|
|
||||||
case "klarna":
|
|
||||||
current, err := s.klarnaClient.GetOrder(r.Context(), paymentId)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, current)
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors.New("unsupported payment provider")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) StartPaymentHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
|
|
||||||
grain, err := s.Get(r.Context(), uint64(checkoutId))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
payload := &StartPayment{}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
switch payload.Provider {
|
|
||||||
case "adyen":
|
|
||||||
meta := GetCheckoutMetaFromRequest(r)
|
|
||||||
sessionData, err := BuildAdyenCheckoutSession(grain, meta)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("unable to build adyen session", "error", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
service := s.adyenClient.Checkout()
|
|
||||||
req := service.PaymentsApi.SessionsInput().CreateCheckoutSessionRequest(*sessionData)
|
|
||||||
session, _, err := service.PaymentsApi.Sessions(r.Context(), req)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("unable to create adyen session", "error", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
sessionBytes, err := json.Marshal(session)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("unable to marshal session", "error", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Apply PaymentStarted mutation
|
|
||||||
result, err := s.ApplyLocal(r.Context(), checkoutId, &messages.PaymentStarted{
|
|
||||||
PaymentId: session.Id,
|
|
||||||
Amount: session.Amount.Value,
|
|
||||||
Currency: session.Amount.Currency,
|
|
||||||
Provider: "adyen",
|
|
||||||
SessionData: &anypb.Any{
|
|
||||||
TypeUrl: "type.googleapis.com/google.protobuf.StringValue",
|
|
||||||
Value: sessionBytes,
|
|
||||||
},
|
|
||||||
Method: &payload.Method,
|
|
||||||
StartedAt: timestamppb.New(time.Now()),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("unable to apply payment started mutation", "error", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.WriteResult(w, result)
|
|
||||||
|
|
||||||
case "klarna":
|
|
||||||
order, err := s.CreateOrUpdateCheckout(r, grain, nil)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("unable to create klarna session", "error", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
orderBytes, err := json.Marshal(order)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("unable to marshal order", "error", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
result, err := s.ApplyLocal(r.Context(), checkoutId, &messages.PaymentStarted{
|
|
||||||
PaymentId: order.ID,
|
|
||||||
Amount: int64(order.OrderAmount),
|
|
||||||
Currency: order.PurchaseCurrency,
|
|
||||||
Provider: "klarna",
|
|
||||||
Method: &payload.Method,
|
|
||||||
SessionData: &anypb.Any{
|
|
||||||
TypeUrl: "type.googleapis.com/google.protobuf.StringValue",
|
|
||||||
Value: orderBytes,
|
|
||||||
},
|
|
||||||
StartedAt: timestamppb.New(time.Now()),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("unable to apply payment started mutation", "error", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.WriteResult(w, result)
|
|
||||||
|
|
||||||
default:
|
|
||||||
http.Error(w, "unsupported payment provider", http.StatusBadRequest)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) {
|
|
||||||
handleFunc := func(pattern string, handlerFunc func(http.ResponseWriter, *http.Request)) {
|
|
||||||
attr := attribute.String("http.route", pattern)
|
|
||||||
mux.HandleFunc(pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
span := trace.SpanFromContext(r.Context())
|
|
||||||
span.SetName(pattern)
|
|
||||||
span.SetAttributes(attr)
|
|
||||||
|
|
||||||
labeler, _ := otelhttp.LabelerFromContext(r.Context())
|
|
||||||
labeler.Add(attr)
|
|
||||||
|
|
||||||
handlerFunc(w, r)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
//handleFunc("/payment/adyen/session", CookieCheckoutIdHandler(s.AdyenSessionHandler))
|
|
||||||
handleFunc("/payment/adyen/push", s.AdyenHookHandler)
|
|
||||||
handleFunc("/payment/adyen/return", s.AdyenReturnHandler)
|
|
||||||
//handleFunc("/payment/adyen/cancel", s.AdyenCancelHandler)
|
|
||||||
|
|
||||||
handleFunc("/payment/klarna/validate", s.KlarnaValidationHandler)
|
|
||||||
handleFunc("/payment/klarna/push", s.KlarnaPushHandler)
|
|
||||||
handleFunc("/payment/klarna/notification", s.KlarnaNotificationHandler)
|
|
||||||
|
|
||||||
conn, err := amqp.Dial(amqpUrl)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("failed to connect to RabbitMQ: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
orderHandler := NewAmqpOrderHandler(conn)
|
|
||||||
orderHandler.DefineQueue()
|
|
||||||
|
|
||||||
handleFunc("POST /api/checkout/start/{cartid}", s.StartCheckoutHandler)
|
|
||||||
handleFunc("GET /api/checkout", CookieCheckoutIdHandler(s.ProxyHandler(s.GetCheckoutHandler)))
|
|
||||||
handleFunc("POST /api/checkout/delivery", CookieCheckoutIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
|
|
||||||
handleFunc("DELETE /api/checkout/delivery/{id}", CookieCheckoutIdHandler(s.ProxyHandler(s.RemoveDeliveryHandler)))
|
|
||||||
handleFunc("POST /api/checkout/pickup-point", CookieCheckoutIdHandler(s.ProxyHandler(s.SetPickupPointHandler)))
|
|
||||||
handleFunc("POST /api/checkout/contact-details", CookieCheckoutIdHandler(s.ProxyHandler(s.ContactDetailsUpdatedHandler)))
|
|
||||||
handleFunc("POST /payment", CookieCheckoutIdHandler(s.ProxyHandler(s.StartPaymentHandler)))
|
|
||||||
handleFunc("POST /payment/{id}/session", CookieCheckoutIdHandler(s.ProxyHandler(s.GetPaymentSessionHandler)))
|
|
||||||
// handleFunc("POST /api/checkout/initialize", CookieCheckoutIdHandler(s.ProxyHandler(s.InitializeCheckoutHandler)))
|
|
||||||
// handleFunc("POST /api/checkout/inventory-reserved", CookieCheckoutIdHandler(s.ProxyHandler(s.InventoryReservedHandler)))
|
|
||||||
// handleFunc("POST /api/checkout/order-created", CookieCheckoutIdHandler(s.ProxyHandler(s.OrderCreatedHandler)))
|
|
||||||
// handleFunc("POST /api/checkout/confirmation-viewed", CookieCheckoutIdHandler(s.ProxyHandler(s.ConfirmationViewedHandler)))
|
|
||||||
|
|
||||||
//handleFunc("GET /payment/klarna/session", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaSessionHandler)))
|
|
||||||
//handleFunc("GET /payment/klarna/checkout", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaHtmlCheckoutHandler)))
|
|
||||||
|
|
||||||
handleFunc("GET /payment/klarna/confirmation/{order_id}", s.KlarnaConfirmationHandler)
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/cart"
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/checkout"
|
|
||||||
"go.opentelemetry.io/otel/attribute"
|
|
||||||
"go.opentelemetry.io/otel/metric"
|
|
||||||
)
|
|
||||||
|
|
||||||
func getOriginalHost(r *http.Request) string {
|
|
||||||
proxyHost := r.Header.Get("X-Forwarded-Host")
|
|
||||||
if proxyHost != "" {
|
|
||||||
return proxyHost
|
|
||||||
}
|
|
||||||
return r.Host
|
|
||||||
}
|
|
||||||
|
|
||||||
func getClientIp(r *http.Request) string {
|
|
||||||
ip := r.Header.Get("X-Forwarded-For")
|
|
||||||
if ip == "" {
|
|
||||||
ip = r.RemoteAddr
|
|
||||||
}
|
|
||||||
return ip
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCurrency(country string) string {
|
|
||||||
if country == "no" {
|
|
||||||
return "NOK"
|
|
||||||
}
|
|
||||||
return "SEK"
|
|
||||||
}
|
|
||||||
|
|
||||||
func getLocale(country string) string {
|
|
||||||
if country == "no" {
|
|
||||||
return "nb-no"
|
|
||||||
}
|
|
||||||
return "sv-se"
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCountryFromHost(host string) string {
|
|
||||||
if strings.Contains(strings.ToLower(host), "-no") {
|
|
||||||
return "no"
|
|
||||||
}
|
|
||||||
if strings.Contains(strings.ToLower(host), "-se") {
|
|
||||||
return "se"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *CheckoutPoolServer) reserveInventory(ctx context.Context, grain *checkout.CheckoutGrain) error {
|
|
||||||
if a.inventoryService != nil {
|
|
||||||
inventoryRequests := getInventoryRequests(grain.CartState.Items)
|
|
||||||
_, err := a.inventoryService.ReservationCheck(ctx, inventoryRequests...)
|
|
||||||
if err != nil {
|
|
||||||
logger.WarnContext(ctx, "placeorder inventory check failed")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkoutCookieName = "checkoutid"
|
|
||||||
|
|
||||||
func setCheckoutCookie(w http.ResponseWriter, checkoutId checkout.CheckoutId, tls bool) {
|
|
||||||
if checkoutId == 0 {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: checkoutCookieName,
|
|
||||||
Value: checkoutId.String(),
|
|
||||||
Secure: tls,
|
|
||||||
HttpOnly: true,
|
|
||||||
Path: "/",
|
|
||||||
Expires: time.Unix(0, 0),
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: checkoutCookieName,
|
|
||||||
Value: checkoutId.String(),
|
|
||||||
Secure: tls,
|
|
||||||
HttpOnly: true,
|
|
||||||
Path: "/",
|
|
||||||
Expires: time.Now().AddDate(0, 0, 14),
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func CookieCheckoutIdHandler(fn func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error) func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
|
|
||||||
var id checkout.CheckoutId
|
|
||||||
cookie, err := r.Cookie(checkoutCookieName)
|
|
||||||
if err != nil || cookie.Value == "" {
|
|
||||||
w.WriteHeader(http.StatusNotAcceptable)
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
parsed, ok := cart.ParseCartId(cookie.Value)
|
|
||||||
if !ok {
|
|
||||||
w.WriteHeader(http.StatusNotAcceptable)
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
id = parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = fn(w, r, id)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Server error, not remote error: %v\n", err)
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte(err.Error()))
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error) func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
|
|
||||||
if ownerHost, ok := s.OwnerHost(uint64(checkoutId)); ok {
|
|
||||||
ctx, span := tracer.Start(r.Context(), "proxy")
|
|
||||||
defer span.End()
|
|
||||||
span.SetAttributes(attribute.String("checkoutid", checkoutId.String()))
|
|
||||||
hostAttr := attribute.String("other host", ownerHost.Name())
|
|
||||||
span.SetAttributes(hostAttr)
|
|
||||||
logger.InfoContext(ctx, "checkout proxyed", "result", ownerHost.Name())
|
|
||||||
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
|
|
||||||
handled, err := ownerHost.Proxy(uint64(checkoutId), w, r, nil)
|
|
||||||
|
|
||||||
grainLookups.Inc()
|
|
||||||
if err == nil && handled {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_, span := tracer.Start(r.Context(), "own")
|
|
||||||
span.SetAttributes(attribute.String("checkoutid", checkoutId.String()))
|
|
||||||
defer span.End()
|
|
||||||
return fn(w, r, checkoutId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
|
||||||
"github.com/matst80/slask-finder/pkg/index"
|
|
||||||
"github.com/matst80/slask-finder/pkg/messaging"
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
"github.com/redis/go-redis/v9/maintnotifications"
|
|
||||||
|
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Server struct {
|
|
||||||
inventoryService *inventory.RedisInventoryService
|
|
||||||
reservationService *inventory.RedisCartReservationService
|
|
||||||
}
|
|
||||||
|
|
||||||
func (srv *Server) livezHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("OK"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (srv *Server) readyzHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("OK"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (srv *Server) getInventoryHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
|
|
||||||
sku := inventory.SKU(r.PathValue("sku"))
|
|
||||||
locationID := inventory.LocationID(r.PathValue("locationId"))
|
|
||||||
|
|
||||||
quantity, err := srv.inventoryService.GetInventory(r.Context(), sku, locationID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
response := map[string]int64{"quantity": quantity}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (srv *Server) getReservationHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
sku := inventory.SKU(r.PathValue("sku"))
|
|
||||||
locationID := inventory.LocationID(r.PathValue("locationId"))
|
|
||||||
|
|
||||||
summary, err := srv.reservationService.GetReservationSummary(r.Context(), sku, locationID)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(summary)
|
|
||||||
}
|
|
||||||
|
|
||||||
var country = "se"
|
|
||||||
var redisAddress = "10.10.3.18:6379"
|
|
||||||
var redisPassword = "slaskredis"
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
// Override redis config from environment variables if set
|
|
||||||
if addr, ok := os.LookupEnv("REDIS_ADDRESS"); ok {
|
|
||||||
redisAddress = addr
|
|
||||||
}
|
|
||||||
if password, ok := os.LookupEnv("REDIS_PASSWORD"); ok {
|
|
||||||
redisPassword = password
|
|
||||||
}
|
|
||||||
if ctry, ok := os.LookupEnv("COUNTRY"); ok {
|
|
||||||
country = ctry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
var ctx = context.Background()
|
|
||||||
rdb := redis.NewClient(&redis.Options{
|
|
||||||
Addr: redisAddress,
|
|
||||||
Password: redisPassword, // no password set
|
|
||||||
DB: 0, // use default DB
|
|
||||||
MaintNotificationsConfig: &maintnotifications.Config{
|
|
||||||
Mode: maintnotifications.ModeDisabled,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
s, err := inventory.NewRedisInventoryService(rdb)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Unable to connect to inventory redis: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
r, err := inventory.NewRedisCartReservationService(rdb)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Unable to connect to reservation redis: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
server := &Server{inventoryService: s, reservationService: r}
|
|
||||||
|
|
||||||
// Set up HTTP routes
|
|
||||||
http.HandleFunc("/livez", server.livezHandler)
|
|
||||||
http.HandleFunc("/readyz", server.readyzHandler)
|
|
||||||
http.HandleFunc("/inventory/{sku}/{locationId}", server.getInventoryHandler)
|
|
||||||
http.HandleFunc("/reservations/{sku}/{locationId}", server.getReservationHandler)
|
|
||||||
|
|
||||||
stockhandler := &StockHandler{
|
|
||||||
MainStockLocationID: inventory.LocationID(country),
|
|
||||||
rdb: rdb,
|
|
||||||
ctx: ctx,
|
|
||||||
svc: *s,
|
|
||||||
}
|
|
||||||
|
|
||||||
amqpUrl, ok := os.LookupEnv("RABBIT_HOST")
|
|
||||||
if ok {
|
|
||||||
log.Printf("Connecting to rabbitmq")
|
|
||||||
conn, err := amqp.DialConfig(amqpUrl, amqp.Config{
|
|
||||||
Properties: amqp.NewConnectionProperties(),
|
|
||||||
})
|
|
||||||
//a.conn = conn
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to connect to RabbitMQ: %v", err)
|
|
||||||
}
|
|
||||||
ch, err := conn.Channel()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to open a channel: %v", err)
|
|
||||||
}
|
|
||||||
// items listener
|
|
||||||
err = messaging.ListenToTopic(ch, country, "item_added", func(d amqp.Delivery) error {
|
|
||||||
var items []*index.DataItem
|
|
||||||
err := json.Unmarshal(d.Body, &items)
|
|
||||||
if err == nil {
|
|
||||||
log.Printf("Got upserts %d, message count %d", len(items), d.MessageCount)
|
|
||||||
wg := &sync.WaitGroup{}
|
|
||||||
for _, item := range items {
|
|
||||||
stockhandler.HandleItem(item, wg)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
log.Print("Batch done...")
|
|
||||||
} else {
|
|
||||||
log.Printf("Failed to unmarshal upsert message %v", err)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to listen to item_added topic: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start HTTP server
|
|
||||||
log.Println("Starting HTTP server on :8080")
|
|
||||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
|
||||||
"github.com/matst80/slask-finder/pkg/types"
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
)
|
|
||||||
|
|
||||||
type StockHandler struct {
|
|
||||||
rdb *redis.Client
|
|
||||||
ctx context.Context
|
|
||||||
svc inventory.RedisInventoryService
|
|
||||||
MainStockLocationID inventory.LocationID
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *StockHandler) HandleItem(item types.Item, wg *sync.WaitGroup) {
|
|
||||||
wg.Go(func() {
|
|
||||||
ctx := s.ctx
|
|
||||||
pipe := s.rdb.Pipeline()
|
|
||||||
centralStockString, ok := item.GetStringFieldValue(3)
|
|
||||||
if !ok {
|
|
||||||
centralStockString = "0"
|
|
||||||
}
|
|
||||||
centralStockString = strings.Replace(centralStockString, "+", "", -1)
|
|
||||||
centralStockString = strings.Replace(centralStockString, "<", "", -1)
|
|
||||||
centralStockString = strings.Replace(centralStockString, ">", "", -1)
|
|
||||||
centralStock, err := strconv.ParseInt(centralStockString, 10, 64)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("unable to parse central stock for item %s: %v", item.GetSku(), err)
|
|
||||||
centralStock = 0
|
|
||||||
} else {
|
|
||||||
s.svc.UpdateInventory(ctx, pipe, inventory.SKU(item.GetSku()), s.MainStockLocationID, int64(centralStock))
|
|
||||||
}
|
|
||||||
for id, value := range item.GetStock() {
|
|
||||||
s.svc.UpdateInventory(ctx, pipe, inventory.SKU(item.GetSku()), inventory.LocationID(id), int64(value))
|
|
||||||
}
|
|
||||||
_, err = pipe.Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("unable to update stock: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
BIN
data/1.prot
Normal file
BIN
data/1.prot
Normal file
Binary file not shown.
BIN
data/4.prot
Normal file
BIN
data/4.prot
Normal file
Binary file not shown.
BIN
data/5.prot
Normal file
BIN
data/5.prot
Normal file
Binary file not shown.
BIN
data/state.gob
Normal file
BIN
data/state.gob
Normal file
Binary file not shown.
BIN
data/state.gob.bak
Normal file
BIN
data/state.gob.bak
Normal file
Binary file not shown.
276
deployment/deployment-no.yaml
Normal file
276
deployment/deployment-no.yaml
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: klarna-api-credentials
|
||||||
|
data:
|
||||||
|
username: ZjQzZDY3YjEtNzA2Yy00NTk2LTliNTgtYjg1YjU2NDEwZTUw
|
||||||
|
password: a2xhcm5hX3Rlc3RfYXBpX0trUWhWVE5yYVZsV2FsTnhTRVp3Y1ZSSFF5UkVNRmxyY25Kd1AxSndQMGdzWmpRelpEWTNZakV0TnpBMll5MDBOVGsyTFRsaU5UZ3RZamcxWWpVMk5ERXdaVFV3TERFc2JUUkNjRFpWU1RsTllsSk1aMlEyVEc4MmRVODNZMkozUlRaaFdEZDViV3AwYkhGV1JqTjVNQzlaYXow
|
||||||
|
type: Opaque
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: cart-actor
|
||||||
|
arch: amd64
|
||||||
|
name: cart-actor-x86
|
||||||
|
spec:
|
||||||
|
replicas: 0
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: cart-actor
|
||||||
|
arch: amd64
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: cart-actor
|
||||||
|
actor-pool: cart
|
||||||
|
arch: amd64
|
||||||
|
spec:
|
||||||
|
affinity:
|
||||||
|
nodeAffinity:
|
||||||
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
nodeSelectorTerms:
|
||||||
|
- matchExpressions:
|
||||||
|
- key: kubernetes.io/arch
|
||||||
|
operator: NotIn
|
||||||
|
values:
|
||||||
|
- arm64
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
nfs:
|
||||||
|
path: /i-data/7a8af061/nfs/cart-actor-no
|
||||||
|
server: 10.10.1.10
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: regcred
|
||||||
|
serviceAccountName: default
|
||||||
|
containers:
|
||||||
|
- image: registry.knatofs.se/go-cart-actor-amd64:latest
|
||||||
|
name: cart-actor-amd64
|
||||||
|
imagePullPolicy: Always
|
||||||
|
lifecycle:
|
||||||
|
preStop:
|
||||||
|
exec:
|
||||||
|
command: ["sleep", "15"]
|
||||||
|
ports:
|
||||||
|
- containerPort: 8080
|
||||||
|
name: web
|
||||||
|
- containerPort: 1234
|
||||||
|
name: echo
|
||||||
|
- containerPort: 1337
|
||||||
|
name: rpc
|
||||||
|
- containerPort: 1338
|
||||||
|
name: quorum
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /livez
|
||||||
|
port: web
|
||||||
|
failureThreshold: 1
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /readyz
|
||||||
|
port: web
|
||||||
|
failureThreshold: 2
|
||||||
|
initialDelaySeconds: 2
|
||||||
|
periodSeconds: 10
|
||||||
|
volumeMounts:
|
||||||
|
- mountPath: "/data"
|
||||||
|
name: data
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: "768Mi"
|
||||||
|
requests:
|
||||||
|
memory: "70Mi"
|
||||||
|
cpu: "1200m"
|
||||||
|
env:
|
||||||
|
- name: TZ
|
||||||
|
value: "Europe/Stockholm"
|
||||||
|
- name: KLARNA_API_USERNAME
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: klarna-api-credentials
|
||||||
|
key: username
|
||||||
|
- name: KLARNA_API_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: klarna-api-credentials
|
||||||
|
key: password
|
||||||
|
- name: POD_IP
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: status.podIP
|
||||||
|
- name: AMQP_URL
|
||||||
|
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
|
||||||
|
- name: BASE_URL
|
||||||
|
value: "https://s10n-no.tornberg.me"
|
||||||
|
- name: CART_BASE_URL
|
||||||
|
value: "https://cart-no.tornberg.me"
|
||||||
|
- name: POD_NAME
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.name
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: cart-actor
|
||||||
|
arch: arm64
|
||||||
|
name: cart-actor-arm64
|
||||||
|
spec:
|
||||||
|
replicas: 3
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: cart-actor
|
||||||
|
arch: arm64
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: cart-actor
|
||||||
|
actor-pool: cart
|
||||||
|
arch: arm64
|
||||||
|
spec:
|
||||||
|
affinity:
|
||||||
|
nodeAffinity:
|
||||||
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
nodeSelectorTerms:
|
||||||
|
- matchExpressions:
|
||||||
|
- key: kubernetes.io/hostname
|
||||||
|
operator: NotIn
|
||||||
|
values:
|
||||||
|
- masterpi
|
||||||
|
- key: kubernetes.io/arch
|
||||||
|
operator: In
|
||||||
|
values:
|
||||||
|
- arm64
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
nfs:
|
||||||
|
path: /i-data/7a8af061/nfs/cart-actor-no
|
||||||
|
server: 10.10.1.10
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: regcred
|
||||||
|
serviceAccountName: default
|
||||||
|
containers:
|
||||||
|
- image: registry.knatofs.se/go-cart-actor:latest
|
||||||
|
name: cart-actor-arm64
|
||||||
|
imagePullPolicy: Always
|
||||||
|
lifecycle:
|
||||||
|
preStop:
|
||||||
|
exec:
|
||||||
|
command: ["sleep", "15"]
|
||||||
|
ports:
|
||||||
|
- containerPort: 8080
|
||||||
|
name: web
|
||||||
|
- containerPort: 1234
|
||||||
|
name: echo
|
||||||
|
- containerPort: 1337
|
||||||
|
name: rpc
|
||||||
|
- containerPort: 1338
|
||||||
|
name: quorum
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /livez
|
||||||
|
port: web
|
||||||
|
failureThreshold: 1
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /readyz
|
||||||
|
port: web
|
||||||
|
failureThreshold: 2
|
||||||
|
initialDelaySeconds: 2
|
||||||
|
periodSeconds: 10
|
||||||
|
volumeMounts:
|
||||||
|
- mountPath: "/data"
|
||||||
|
name: data
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: "768Mi"
|
||||||
|
requests:
|
||||||
|
memory: "70Mi"
|
||||||
|
cpu: "1200m"
|
||||||
|
env:
|
||||||
|
- name: TZ
|
||||||
|
value: "Europe/Stockholm"
|
||||||
|
- name: KLARNA_API_USERNAME
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: klarna-api-credentials
|
||||||
|
key: username
|
||||||
|
- name: KLARNA_API_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: klarna-api-credentials
|
||||||
|
key: password
|
||||||
|
- name: POD_IP
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: status.podIP
|
||||||
|
- name: AMQP_URL
|
||||||
|
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
|
||||||
|
- name: BASE_URL
|
||||||
|
value: "https://s10n-no.tornberg.me"
|
||||||
|
- name: CART_BASE_URL
|
||||||
|
value: "https://cart-no.tornberg.me"
|
||||||
|
- name: POD_NAME
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.name
|
||||||
|
---
|
||||||
|
kind: Service
|
||||||
|
apiVersion: v1
|
||||||
|
metadata:
|
||||||
|
name: cart-echo
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: cart-actor
|
||||||
|
type: LoadBalancer
|
||||||
|
ports:
|
||||||
|
- name: echo
|
||||||
|
port: 1234
|
||||||
|
---
|
||||||
|
kind: Service
|
||||||
|
apiVersion: v1
|
||||||
|
metadata:
|
||||||
|
name: cart-actor
|
||||||
|
annotations:
|
||||||
|
prometheus.io/port: "8080"
|
||||||
|
prometheus.io/scrape: "true"
|
||||||
|
prometheus.io/path: "/metrics"
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: cart-actor
|
||||||
|
ports:
|
||||||
|
- name: web
|
||||||
|
port: 8080
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: cart-ingress
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
# nginx.ingress.kubernetes.io/affinity: "cookie"
|
||||||
|
# nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity"
|
||||||
|
# nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
|
||||||
|
# nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-body-size: 4m
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- cart-no.tornberg.me
|
||||||
|
secretName: cart-actor-no-tls-secret
|
||||||
|
rules:
|
||||||
|
- host: cart-no.tornberg.me
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: cart-actor
|
||||||
|
port:
|
||||||
|
number: 8080
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
name: klarna-api-credentials
|
name: klarna-api-credentials
|
||||||
data:
|
data:
|
||||||
username: ZjQzZDY3YjEtNzA2Yy00NTk2LTliNTgtYjg1YjU2NDEwZTUw
|
username: ZjQzZDY3YjEtNzA2Yy00NTk2LTliNTgtYjg1YjU2NDEwZTUw
|
||||||
password: a2xhcm5hX3Rlc3RfYXBpX0trUWhWVE5yYVZsV2FsTnhTRVp3Y1ZSSFF5UkVNRmxyY25Kd1AxSndQMGdzWmpRelpEWTNZakV0TnpBMll5MDBOVGsyTFRsaU5UZ3RZamcxWWpVMk5ERXdaVFV3TERFc2JUUkNjRFpWU1RsTllsSk1aMlEyVEc4MmRVODNZMkozUlRaaFdEZDViV3AwYkhGV1JqTjVNQzlaYXow
|
password: a2xhcm5hX3Rlc3RfYXBpX0trUWhWVE5yYVZsV2FsTnhTRVp3Y1ZSSFF5UkVNRmxyY25Kd1AxSndQMGdzWmpRelpEWTNZakV0TnpBMll5MDBOVGsyTFRsaU5UZ3RZamcxWWpVMk5ERXdaVFV3TERFc2JUUkNjRFpWU1RsTllsSk1aMlEyVEc4MmRVODNZMkozUlRaaFdEZDViV3AwYkhGV1JqTjVNQzlaYXow
|
||||||
@@ -9,117 +9,13 @@ type: Opaque
|
|||||||
---
|
---
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: cart-backoffice
|
|
||||||
arch: amd64
|
|
||||||
name: cart-backoffice-x86
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: cart-backoffice
|
|
||||||
arch: amd64
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: cart-backoffice
|
|
||||||
arch: amd64
|
|
||||||
spec:
|
|
||||||
affinity:
|
|
||||||
nodeAffinity:
|
|
||||||
requiredDuringSchedulingIgnoredDuringExecution:
|
|
||||||
nodeSelectorTerms:
|
|
||||||
- matchExpressions:
|
|
||||||
- key: kubernetes.io/arch
|
|
||||||
operator: NotIn
|
|
||||||
values:
|
|
||||||
- arm64
|
|
||||||
volumes:
|
|
||||||
- name: data
|
|
||||||
nfs:
|
|
||||||
path: /i-data/7a8af061/nfs/
|
|
||||||
server: 10.10.1.10
|
|
||||||
serviceAccountName: default
|
|
||||||
containers:
|
|
||||||
- image: registry.k6n.net/go-cart-actor-amd64:latest
|
|
||||||
name: cart-actor-amd64
|
|
||||||
imagePullPolicy: Always
|
|
||||||
command: ["/go-cart-backoffice"]
|
|
||||||
lifecycle:
|
|
||||||
preStop:
|
|
||||||
exec:
|
|
||||||
command: ["sleep", "15"]
|
|
||||||
ports:
|
|
||||||
- containerPort: 8080
|
|
||||||
name: web
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /livez
|
|
||||||
port: web
|
|
||||||
failureThreshold: 1
|
|
||||||
periodSeconds: 30
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /readyz
|
|
||||||
port: web
|
|
||||||
failureThreshold: 2
|
|
||||||
initialDelaySeconds: 2
|
|
||||||
periodSeconds: 30
|
|
||||||
volumeMounts:
|
|
||||||
- mountPath: "/data"
|
|
||||||
name: data
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: "768Mi"
|
|
||||||
requests:
|
|
||||||
memory: "70Mi"
|
|
||||||
cpu: "1200m"
|
|
||||||
env:
|
|
||||||
- name: DATA_DIR
|
|
||||||
value: "/data/cart-actor"
|
|
||||||
- name: CHECKOUT_DATA_DIR
|
|
||||||
value: "/data/checkout-actor"
|
|
||||||
- name: TZ
|
|
||||||
value: "Europe/Stockholm"
|
|
||||||
- name: REDIS_ADDRESS
|
|
||||||
value: "10.10.3.18:6379"
|
|
||||||
- name: REDIS_PASSWORD
|
|
||||||
value: "slaskredis"
|
|
||||||
- name: ADYEN_HMAC
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: adyen
|
|
||||||
key: HMAC
|
|
||||||
- name: ADYEN_API_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: adyen
|
|
||||||
key: API_KEY
|
|
||||||
- name: KLARNA_API_USERNAME
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: klarna-api-credentials
|
|
||||||
key: username
|
|
||||||
- name: KLARNA_API_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: klarna-api-credentials
|
|
||||||
key: password
|
|
||||||
- name: AMQP_URL
|
|
||||||
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
|
|
||||||
# - name: BASE_URL
|
|
||||||
# value: "https://s10n-no.tornberg.me"
|
|
||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app: cart-actor
|
app: cart-actor
|
||||||
arch: amd64
|
arch: amd64
|
||||||
name: cart-actor-x86
|
name: cart-actor-x86
|
||||||
spec:
|
spec:
|
||||||
replicas: 3
|
replicas: 0
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: cart-actor
|
app: cart-actor
|
||||||
@@ -136,18 +32,20 @@ spec:
|
|||||||
requiredDuringSchedulingIgnoredDuringExecution:
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
nodeSelectorTerms:
|
nodeSelectorTerms:
|
||||||
- matchExpressions:
|
- matchExpressions:
|
||||||
- key: kubernetes.io/arch
|
- key: kubernetes.io/arch
|
||||||
operator: NotIn
|
operator: NotIn
|
||||||
values:
|
values:
|
||||||
- arm64
|
- arm64
|
||||||
volumes:
|
volumes:
|
||||||
- name: data
|
- name: data
|
||||||
nfs:
|
nfs:
|
||||||
path: /i-data/7a8af061/nfs/cart-actor
|
path: /i-data/7a8af061/nfs/cart-actor
|
||||||
server: 10.10.1.10
|
server: 10.10.1.10
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: regcred
|
||||||
serviceAccountName: default
|
serviceAccountName: default
|
||||||
containers:
|
containers:
|
||||||
- image: registry.k6n.net/go-cart-actor-amd64:latest
|
- image: registry.knatofs.se/go-cart-actor-amd64:latest
|
||||||
name: cart-actor-amd64
|
name: cart-actor-amd64
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
lifecycle:
|
lifecycle:
|
||||||
@@ -157,23 +55,25 @@ spec:
|
|||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
name: web
|
name: web
|
||||||
- containerPort: 8081
|
- containerPort: 1234
|
||||||
name: debug
|
name: echo
|
||||||
- containerPort: 1337
|
- containerPort: 1337
|
||||||
name: rpc
|
name: rpc
|
||||||
|
- containerPort: 1338
|
||||||
|
name: quorum
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /livez
|
path: /livez
|
||||||
port: web
|
port: web
|
||||||
failureThreshold: 1
|
failureThreshold: 1
|
||||||
periodSeconds: 30
|
periodSeconds: 10
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /readyz
|
path: /readyz
|
||||||
port: web
|
port: web
|
||||||
failureThreshold: 2
|
failureThreshold: 2
|
||||||
initialDelaySeconds: 2
|
initialDelaySeconds: 2
|
||||||
periodSeconds: 50
|
periodSeconds: 10
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- mountPath: "/data"
|
- mountPath: "/data"
|
||||||
name: data
|
name: data
|
||||||
@@ -184,10 +84,6 @@ spec:
|
|||||||
memory: "70Mi"
|
memory: "70Mi"
|
||||||
cpu: "1200m"
|
cpu: "1200m"
|
||||||
env:
|
env:
|
||||||
- name: DATA_DIR
|
|
||||||
value: "/data/cart-actor"
|
|
||||||
- name: CHECKOUT_DATA_DIR
|
|
||||||
value: "/data/checkout-actor"
|
|
||||||
- name: TZ
|
- name: TZ
|
||||||
value: "Europe/Stockholm"
|
value: "Europe/Stockholm"
|
||||||
- name: KLARNA_API_USERNAME
|
- name: KLARNA_API_USERNAME
|
||||||
@@ -195,24 +91,6 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: klarna-api-credentials
|
name: klarna-api-credentials
|
||||||
key: username
|
key: username
|
||||||
- name: REDIS_ADDRESS
|
|
||||||
value: "10.10.3.18:6379"
|
|
||||||
- name: REDIS_PASSWORD
|
|
||||||
value: "slaskredis"
|
|
||||||
- name: OTEL_RESOURCE_ATTRIBUTES
|
|
||||||
value: "service.name=cart,service.version=0.1.2"
|
|
||||||
- name: OTEL_EXPORTER_OTLP_ENDPOINT
|
|
||||||
value: "http://otel-debug-service.monitoring:4317"
|
|
||||||
- name: ADYEN_HMAC
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: adyen
|
|
||||||
key: HMAC
|
|
||||||
- name: ADYEN_API_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: adyen
|
|
||||||
key: API_KEY
|
|
||||||
- name: KLARNA_API_PASSWORD
|
- name: KLARNA_API_PASSWORD
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
@@ -256,22 +134,24 @@ spec:
|
|||||||
requiredDuringSchedulingIgnoredDuringExecution:
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
nodeSelectorTerms:
|
nodeSelectorTerms:
|
||||||
- matchExpressions:
|
- matchExpressions:
|
||||||
- key: kubernetes.io/hostname
|
- key: kubernetes.io/hostname
|
||||||
operator: NotIn
|
operator: NotIn
|
||||||
values:
|
values:
|
||||||
- masterpi
|
- masterpi
|
||||||
- key: kubernetes.io/arch
|
- key: kubernetes.io/arch
|
||||||
operator: In
|
operator: In
|
||||||
values:
|
values:
|
||||||
- arm64
|
- arm64
|
||||||
volumes:
|
volumes:
|
||||||
- name: data
|
- name: data
|
||||||
nfs:
|
nfs:
|
||||||
path: /i-data/7a8af061/nfs/cart-actor
|
path: /i-data/7a8af061/nfs/cart-actor
|
||||||
server: 10.10.1.10
|
server: 10.10.1.10
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: regcred
|
||||||
serviceAccountName: default
|
serviceAccountName: default
|
||||||
containers:
|
containers:
|
||||||
- image: registry.k6n.net/go-cart-actor:latest
|
- image: registry.knatofs.se/go-cart-actor:latest
|
||||||
name: cart-actor-arm64
|
name: cart-actor-arm64
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
lifecycle:
|
lifecycle:
|
||||||
@@ -281,23 +161,25 @@ spec:
|
|||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
name: web
|
name: web
|
||||||
- containerPort: 8081
|
- containerPort: 1234
|
||||||
name: debug
|
name: echo
|
||||||
- containerPort: 1337
|
- containerPort: 1337
|
||||||
name: rpc
|
name: rpc
|
||||||
|
- containerPort: 1338
|
||||||
|
name: quorum
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /livez
|
path: /livez
|
||||||
port: web
|
port: web
|
||||||
failureThreshold: 1
|
failureThreshold: 1
|
||||||
periodSeconds: 15
|
periodSeconds: 10
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /readyz
|
path: /readyz
|
||||||
port: web
|
port: web
|
||||||
failureThreshold: 2
|
failureThreshold: 2
|
||||||
initialDelaySeconds: 2
|
initialDelaySeconds: 2
|
||||||
periodSeconds: 15
|
periodSeconds: 10
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- mountPath: "/data"
|
- mountPath: "/data"
|
||||||
name: data
|
name: data
|
||||||
@@ -310,24 +192,6 @@ spec:
|
|||||||
env:
|
env:
|
||||||
- name: TZ
|
- name: TZ
|
||||||
value: "Europe/Stockholm"
|
value: "Europe/Stockholm"
|
||||||
- name: REDIS_ADDRESS
|
|
||||||
value: "redis.home:6379"
|
|
||||||
- name: REDIS_PASSWORD
|
|
||||||
value: "slaskredis"
|
|
||||||
- name: OTEL_RESOURCE_ATTRIBUTES
|
|
||||||
value: "service.name=cart,service.version=0.1.2"
|
|
||||||
- name: OTEL_EXPORTER_OTLP_ENDPOINT
|
|
||||||
value: "http://otel-debug-service.monitoring:4317"
|
|
||||||
- name: ADYEN_HMAC
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: adyen
|
|
||||||
key: HMAC
|
|
||||||
- name: ADYEN_API_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: adyen
|
|
||||||
key: API_KEY
|
|
||||||
- name: KLARNA_API_USERNAME
|
- name: KLARNA_API_USERNAME
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
@@ -353,10 +217,22 @@ spec:
|
|||||||
---
|
---
|
||||||
kind: Service
|
kind: Service
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
|
metadata:
|
||||||
|
name: cart-echo
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: cart-actor
|
||||||
|
type: LoadBalancer
|
||||||
|
ports:
|
||||||
|
- name: echo
|
||||||
|
port: 1234
|
||||||
|
---
|
||||||
|
kind: Service
|
||||||
|
apiVersion: v1
|
||||||
metadata:
|
metadata:
|
||||||
name: cart-actor
|
name: cart-actor
|
||||||
annotations:
|
annotations:
|
||||||
prometheus.io/port: "8081"
|
prometheus.io/port: "8080"
|
||||||
prometheus.io/scrape: "true"
|
prometheus.io/scrape: "true"
|
||||||
prometheus.io/path: "/metrics"
|
prometheus.io/path: "/metrics"
|
||||||
spec:
|
spec:
|
||||||
@@ -365,151 +241,6 @@ spec:
|
|||||||
ports:
|
ports:
|
||||||
- name: web
|
- name: web
|
||||||
port: 8080
|
port: 8080
|
||||||
- name: internal
|
|
||||||
port: 8081
|
|
||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: checkout-actor
|
|
||||||
arch: amd64
|
|
||||||
name: checkout-actor-x86
|
|
||||||
spec:
|
|
||||||
replicas: 3
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: checkout-actor
|
|
||||||
arch: amd64
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: checkout-actor
|
|
||||||
actor-pool: checkout
|
|
||||||
arch: amd64
|
|
||||||
spec:
|
|
||||||
affinity:
|
|
||||||
nodeAffinity:
|
|
||||||
requiredDuringSchedulingIgnoredDuringExecution:
|
|
||||||
nodeSelectorTerms:
|
|
||||||
- matchExpressions:
|
|
||||||
- key: kubernetes.io/arch
|
|
||||||
operator: NotIn
|
|
||||||
values:
|
|
||||||
- arm64
|
|
||||||
volumes:
|
|
||||||
- name: data
|
|
||||||
nfs:
|
|
||||||
path: /i-data/7a8af061/nfs/checkout-actor
|
|
||||||
server: 10.10.1.10
|
|
||||||
serviceAccountName: default
|
|
||||||
containers:
|
|
||||||
- image: registry.k6n.net/go-cart-actor-amd64:latest
|
|
||||||
name: checkout-actor-amd64
|
|
||||||
imagePullPolicy: Always
|
|
||||||
command: ["/go-checkout-actor"]
|
|
||||||
lifecycle:
|
|
||||||
preStop:
|
|
||||||
exec:
|
|
||||||
command: ["sleep", "15"]
|
|
||||||
ports:
|
|
||||||
- containerPort: 8080
|
|
||||||
name: web
|
|
||||||
- containerPort: 8081
|
|
||||||
name: debug
|
|
||||||
- containerPort: 1337
|
|
||||||
name: rpc
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /livez
|
|
||||||
port: web
|
|
||||||
failureThreshold: 1
|
|
||||||
periodSeconds: 15
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /readyz
|
|
||||||
port: web
|
|
||||||
failureThreshold: 2
|
|
||||||
initialDelaySeconds: 1
|
|
||||||
periodSeconds: 15
|
|
||||||
volumeMounts:
|
|
||||||
- mountPath: "/data"
|
|
||||||
name: data
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: "768Mi"
|
|
||||||
requests:
|
|
||||||
memory: "70Mi"
|
|
||||||
cpu: "1200m"
|
|
||||||
env:
|
|
||||||
- name: TZ
|
|
||||||
value: "Europe/Stockholm"
|
|
||||||
- name: REDIS_ADDRESS
|
|
||||||
value: "10.10.3.18:6379"
|
|
||||||
- name: REDIS_PASSWORD
|
|
||||||
value: "slaskredis"
|
|
||||||
- name: OTEL_RESOURCE_ATTRIBUTES
|
|
||||||
value: "service.name=checkout,service.version=0.1.2"
|
|
||||||
- name: OTEL_EXPORTER_OTLP_ENDPOINT
|
|
||||||
value: "http://otel-debug-service.monitoring:4317"
|
|
||||||
- name: ADYEN_HMAC
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: adyen
|
|
||||||
key: HMAC
|
|
||||||
- name: ADYEN_API_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: adyen
|
|
||||||
key: API_KEY
|
|
||||||
- name: KLARNA_API_USERNAME
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: klarna-api-credentials
|
|
||||||
key: username
|
|
||||||
- name: KLARNA_API_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: klarna-api-credentials
|
|
||||||
key: password
|
|
||||||
- name: POD_IP
|
|
||||||
valueFrom:
|
|
||||||
fieldRef:
|
|
||||||
fieldPath: status.podIP
|
|
||||||
- name: AMQP_URL
|
|
||||||
value: "amqp://admin:12bananer@rabbitmq.dev:5672/"
|
|
||||||
- name: CART_INTERNAL_URL
|
|
||||||
value: "http://cart-actor:8080"
|
|
||||||
- name: POD_NAME
|
|
||||||
valueFrom:
|
|
||||||
fieldRef:
|
|
||||||
fieldPath: metadata.name
|
|
||||||
---
|
|
||||||
kind: Service
|
|
||||||
apiVersion: v1
|
|
||||||
metadata:
|
|
||||||
name: checkout-actor
|
|
||||||
annotations:
|
|
||||||
prometheus.io/port: "8081"
|
|
||||||
prometheus.io/scrape: "true"
|
|
||||||
prometheus.io/path: "/metrics"
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: checkout-actor
|
|
||||||
ports:
|
|
||||||
- name: web
|
|
||||||
port: 8080
|
|
||||||
---
|
|
||||||
kind: Service
|
|
||||||
apiVersion: v1
|
|
||||||
metadata:
|
|
||||||
name: cart-backoffice
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: cart-backoffice
|
|
||||||
ports:
|
|
||||||
- name: web
|
|
||||||
port: 8080
|
|
||||||
---
|
---
|
||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: Ingress
|
kind: Ingress
|
||||||
@@ -517,19 +248,19 @@ metadata:
|
|||||||
name: cart-ingress
|
name: cart-ingress
|
||||||
annotations:
|
annotations:
|
||||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
nginx.ingress.kubernetes.io/affinity: "cookie"
|
# nginx.ingress.kubernetes.io/affinity: "cookie"
|
||||||
nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity"
|
# nginx.ingress.kubernetes.io/session-cookie-name: "cart-affinity"
|
||||||
nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
|
# nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
|
||||||
nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
|
# nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
|
||||||
nginx.ingress.kubernetes.io/proxy-body-size: 4m
|
nginx.ingress.kubernetes.io/proxy-body-size: 4m
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: nginx
|
ingressClassName: nginx
|
||||||
tls:
|
tls:
|
||||||
- hosts:
|
- hosts:
|
||||||
- cart.k6n.net
|
- cart.tornberg.me
|
||||||
secretName: cart-actor-tls-secret
|
secretName: cart-actor-tls-secret
|
||||||
rules:
|
rules:
|
||||||
- host: cart.k6n.net
|
- host: cart.tornberg.me
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
- path: /
|
- path: /
|
||||||
@@ -539,123 +270,3 @@ spec:
|
|||||||
name: cart-actor
|
name: cart-actor
|
||||||
port:
|
port:
|
||||||
number: 8080
|
number: 8080
|
||||||
- path: /api/checkout
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: checkout-actor
|
|
||||||
port:
|
|
||||||
number: 8080
|
|
||||||
- path: /payment
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: checkout-actor
|
|
||||||
port:
|
|
||||||
number: 8080
|
|
||||||
---
|
|
||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: cart-backend-ingress
|
|
||||||
annotations:
|
|
||||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
|
||||||
spec:
|
|
||||||
ingressClassName: nginx
|
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- slask-cart.k6n.net
|
|
||||||
secretName: cart-backoffice-actor-tls-secret
|
|
||||||
rules:
|
|
||||||
- host: slask-cart.k6n.net
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: cart-backoffice
|
|
||||||
port:
|
|
||||||
number: 8080
|
|
||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: cart-inventory
|
|
||||||
arch: amd64
|
|
||||||
name: cart-inventory-x86
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: cart-inventory
|
|
||||||
arch: amd64
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: cart-inventory
|
|
||||||
arch: amd64
|
|
||||||
spec:
|
|
||||||
affinity:
|
|
||||||
nodeAffinity:
|
|
||||||
requiredDuringSchedulingIgnoredDuringExecution:
|
|
||||||
nodeSelectorTerms:
|
|
||||||
- matchExpressions:
|
|
||||||
- key: kubernetes.io/arch
|
|
||||||
operator: NotIn
|
|
||||||
values:
|
|
||||||
- arm64
|
|
||||||
serviceAccountName: default
|
|
||||||
containers:
|
|
||||||
- image: registry.k6n.net/go-cart-actor-amd64:latest
|
|
||||||
name: cart-inventory-amd64
|
|
||||||
imagePullPolicy: Always
|
|
||||||
command: ["/go-cart-inventory"]
|
|
||||||
lifecycle:
|
|
||||||
preStop:
|
|
||||||
exec:
|
|
||||||
command: ["sleep", "15"]
|
|
||||||
ports:
|
|
||||||
- containerPort: 8080
|
|
||||||
name: web
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /livez
|
|
||||||
port: web
|
|
||||||
failureThreshold: 1
|
|
||||||
periodSeconds: 30
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /readyz
|
|
||||||
port: web
|
|
||||||
failureThreshold: 2
|
|
||||||
initialDelaySeconds: 2
|
|
||||||
periodSeconds: 30
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: "256Mi"
|
|
||||||
cpu: "500m"
|
|
||||||
requests:
|
|
||||||
memory: "50Mi"
|
|
||||||
cpu: "500m"
|
|
||||||
env:
|
|
||||||
- name: TZ
|
|
||||||
value: "Europe/Stockholm"
|
|
||||||
- name: RABBIT_HOST
|
|
||||||
value: amqp://admin:12bananer@rabbitmq.s10n:5672/
|
|
||||||
- name: REDIS_ADDRESS
|
|
||||||
value: "redis.home:6379"
|
|
||||||
- name: REDIS_PASSWORD
|
|
||||||
value: "slaskredis"
|
|
||||||
---
|
|
||||||
kind: Service
|
|
||||||
apiVersion: v1
|
|
||||||
metadata:
|
|
||||||
name: inventory
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: cart-inventory
|
|
||||||
ports:
|
|
||||||
- name: web
|
|
||||||
port: 8080
|
|
||||||
|
|||||||
@@ -1,101 +1,25 @@
|
|||||||
apiVersion: autoscaling/v2
|
apiVersion: autoscaling/v1
|
||||||
kind: HorizontalPodAutoscaler
|
kind: HorizontalPodAutoscaler
|
||||||
metadata:
|
metadata:
|
||||||
name: cart-scaler-amd
|
name: cart-scaler-amd
|
||||||
spec:
|
spec:
|
||||||
scaleTargetRef:
|
scaleTargetRef:
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
name: cart-actor-x86
|
name: cart-actor-x86
|
||||||
minReplicas: 3
|
minReplicas: 3
|
||||||
maxReplicas: 9
|
maxReplicas: 9
|
||||||
behavior:
|
targetCPUUtilizationPercentage: 30
|
||||||
scaleUp:
|
|
||||||
stabilizationWindowSeconds: 60
|
|
||||||
policies:
|
|
||||||
- type: Percent
|
|
||||||
value: 100
|
|
||||||
periodSeconds: 60
|
|
||||||
scaleDown:
|
|
||||||
stabilizationWindowSeconds: 180
|
|
||||||
policies:
|
|
||||||
- type: Percent
|
|
||||||
value: 50
|
|
||||||
periodSeconds: 60
|
|
||||||
metrics:
|
|
||||||
- type: Resource
|
|
||||||
resource:
|
|
||||||
name: cpu
|
|
||||||
target:
|
|
||||||
type: Utilization
|
|
||||||
averageUtilization: 50
|
|
||||||
# Future custom metric (example):
|
|
||||||
# - type: Pods
|
|
||||||
# pods:
|
|
||||||
# metric:
|
|
||||||
# name: cart_mutations_per_second
|
|
||||||
# target:
|
|
||||||
# type: AverageValue
|
|
||||||
# averageValue: "15"
|
|
||||||
# - type: Object
|
|
||||||
# object:
|
|
||||||
# describedObject:
|
|
||||||
# apiVersion: networking.k8s.io/v1
|
|
||||||
# kind: Ingress
|
|
||||||
# name: cart-ingress
|
|
||||||
# metric:
|
|
||||||
# name: http_requests_per_second
|
|
||||||
# target:
|
|
||||||
# type: Value
|
|
||||||
# value: "100"
|
|
||||||
---
|
---
|
||||||
apiVersion: autoscaling/v2
|
apiVersion: autoscaling/v1
|
||||||
kind: HorizontalPodAutoscaler
|
kind: HorizontalPodAutoscaler
|
||||||
metadata:
|
metadata:
|
||||||
name: cart-scaler-arm
|
name: cart-scaler-arm
|
||||||
spec:
|
spec:
|
||||||
scaleTargetRef:
|
scaleTargetRef:
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
name: cart-actor-arm64
|
name: cart-actor-arm64
|
||||||
minReplicas: 3
|
minReplicas: 3
|
||||||
maxReplicas: 9
|
maxReplicas: 9
|
||||||
behavior:
|
targetCPUUtilizationPercentage: 30
|
||||||
scaleUp:
|
|
||||||
stabilizationWindowSeconds: 60
|
|
||||||
policies:
|
|
||||||
- type: Percent
|
|
||||||
value: 100
|
|
||||||
periodSeconds: 60
|
|
||||||
scaleDown:
|
|
||||||
stabilizationWindowSeconds: 180
|
|
||||||
policies:
|
|
||||||
- type: Percent
|
|
||||||
value: 50
|
|
||||||
periodSeconds: 60
|
|
||||||
metrics:
|
|
||||||
- type: Resource
|
|
||||||
resource:
|
|
||||||
name: cpu
|
|
||||||
target:
|
|
||||||
type: Utilization
|
|
||||||
averageUtilization: 50
|
|
||||||
# Future custom metric (example):
|
|
||||||
# - type: Pods
|
|
||||||
# pods:
|
|
||||||
# metric:
|
|
||||||
# name: cart_mutations_per_second
|
|
||||||
# target:
|
|
||||||
# type: AverageValue
|
|
||||||
# averageValue: "15"
|
|
||||||
# - type: Object
|
|
||||||
# object:
|
|
||||||
# describedObject:
|
|
||||||
# apiVersion: networking.k8s.io/v1
|
|
||||||
# kind: Ingress
|
|
||||||
# name: cart-ingress
|
|
||||||
# metric:
|
|
||||||
# name: http_requests_per_second
|
|
||||||
# target:
|
|
||||||
# type: Value
|
|
||||||
# value: "100"
|
|
||||||
84
discarded-host.go
Normal file
84
discarded-host.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DiscardedHost struct {
|
||||||
|
Host string
|
||||||
|
Tries int
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiscardedHostHandler struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
port int
|
||||||
|
hosts []*DiscardedHost
|
||||||
|
onConnection *func(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DiscardedHostHandler) run() {
|
||||||
|
for range time.Tick(time.Second) {
|
||||||
|
d.mu.RLock()
|
||||||
|
lst := make([]*DiscardedHost, 0, len(d.hosts))
|
||||||
|
for _, host := range d.hosts {
|
||||||
|
if host.Tries >= 0 && host.Tries < 5 {
|
||||||
|
go d.testConnection(host)
|
||||||
|
lst = append(lst, host)
|
||||||
|
} else {
|
||||||
|
if host.Tries > 0 {
|
||||||
|
log.Printf("Host %s discarded after %d tries", host.Host, host.Tries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.mu.RUnlock()
|
||||||
|
d.mu.Lock()
|
||||||
|
d.hosts = lst
|
||||||
|
d.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DiscardedHostHandler) testConnection(host *DiscardedHost) {
|
||||||
|
addr := fmt.Sprintf("%s:%d", host.Host, d.port)
|
||||||
|
conn, err := net.Dial("tcp", addr)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
host.Tries++
|
||||||
|
if host.Tries >= 5 {
|
||||||
|
// Exceeded retry threshold; will be dropped by run loop.
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
conn.Close()
|
||||||
|
if d.onConnection != nil {
|
||||||
|
fn := *d.onConnection
|
||||||
|
fn(host.Host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDiscardedHostHandler(port int) *DiscardedHostHandler {
|
||||||
|
ret := &DiscardedHostHandler{
|
||||||
|
hosts: make([]*DiscardedHost, 0),
|
||||||
|
port: port,
|
||||||
|
}
|
||||||
|
go ret.run()
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DiscardedHostHandler) SetReconnectHandler(fn func(string)) {
|
||||||
|
d.onConnection = &fn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DiscardedHostHandler) AppendHost(host string) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
log.Printf("Adding host %s to retry list", host)
|
||||||
|
d.hosts = append(d.hosts, &DiscardedHost{
|
||||||
|
Host: host,
|
||||||
|
Tries: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
77
discovery.go
Normal file
77
discovery.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
v1 "k8s.io/api/core/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/watch"
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
|
"k8s.io/client-go/tools/cache"
|
||||||
|
toolsWatch "k8s.io/client-go/tools/watch"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Discovery interface {
|
||||||
|
Discover() ([]string, error)
|
||||||
|
Watch() (<-chan HostChange, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type K8sDiscovery struct {
|
||||||
|
ctx context.Context
|
||||||
|
client *kubernetes.Clientset
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *K8sDiscovery) Discover() ([]string, error) {
|
||||||
|
return k.DiscoverInNamespace("")
|
||||||
|
}
|
||||||
|
func (k *K8sDiscovery) DiscoverInNamespace(namespace string) ([]string, error) {
|
||||||
|
pods, err := k.client.CoreV1().Pods(namespace).List(k.ctx, metav1.ListOptions{
|
||||||
|
LabelSelector: "actor-pool=cart",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
hosts := make([]string, 0, len(pods.Items))
|
||||||
|
for _, pod := range pods.Items {
|
||||||
|
hosts = append(hosts, pod.Status.PodIP)
|
||||||
|
}
|
||||||
|
return hosts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type HostChange struct {
|
||||||
|
Host string
|
||||||
|
Type watch.EventType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *K8sDiscovery) Watch() (<-chan HostChange, error) {
|
||||||
|
timeout := int64(30)
|
||||||
|
watcherFn := func(options metav1.ListOptions) (watch.Interface, error) {
|
||||||
|
return k.client.CoreV1().Pods("").Watch(k.ctx, metav1.ListOptions{
|
||||||
|
LabelSelector: "actor-pool=cart",
|
||||||
|
TimeoutSeconds: &timeout,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
watcher, err := toolsWatch.NewRetryWatcher("1", &cache.ListWatch{WatchFunc: watcherFn})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ch := make(chan HostChange)
|
||||||
|
go func() {
|
||||||
|
for event := range watcher.ResultChan() {
|
||||||
|
|
||||||
|
pod := event.Object.(*v1.Pod)
|
||||||
|
ch <- HostChange{
|
||||||
|
Host: pod.Status.PodIP,
|
||||||
|
Type: event.Type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewK8sDiscovery(client *kubernetes.Clientset) *K8sDiscovery {
|
||||||
|
return &K8sDiscovery{
|
||||||
|
ctx: context.Background(),
|
||||||
|
client: client,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
package discovery
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
"k8s.io/client-go/kubernetes"
|
"k8s.io/client-go/kubernetes"
|
||||||
"k8s.io/client-go/tools/clientcmd"
|
"k8s.io/client-go/tools/clientcmd"
|
||||||
)
|
)
|
||||||
@@ -18,9 +17,7 @@ func TestDiscovery(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Error creating client: %v", err)
|
t.Errorf("Error creating client: %v", err)
|
||||||
}
|
}
|
||||||
d := NewK8sDiscovery(client, metav1.ListOptions{
|
d := NewK8sDiscovery(client)
|
||||||
LabelSelector: "app",
|
|
||||||
})
|
|
||||||
res, err := d.DiscoverInNamespace("")
|
res, err := d.DiscoverInNamespace("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Error discovering: %v", err)
|
t.Errorf("Error discovering: %v", err)
|
||||||
@@ -39,9 +36,7 @@ func TestWatch(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Error creating client: %v", err)
|
t.Errorf("Error creating client: %v", err)
|
||||||
}
|
}
|
||||||
d := NewK8sDiscovery(client, metav1.ListOptions{
|
d := NewK8sDiscovery(client)
|
||||||
LabelSelector: "app",
|
|
||||||
})
|
|
||||||
ch, err := d.Watch()
|
ch, err := d.Watch()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Error watching: %v", err)
|
t.Errorf("Error watching: %v", err)
|
||||||
70
disk-storage.go
Normal file
70
disk-storage.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/gob"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DiskStorage struct {
|
||||||
|
stateFile string
|
||||||
|
lastSave int64
|
||||||
|
LastSaves map[CartId]int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDiskStorage(stateFile string) (*DiskStorage, error) {
|
||||||
|
ret := &DiskStorage{
|
||||||
|
stateFile: stateFile,
|
||||||
|
LastSaves: make(map[CartId]int64),
|
||||||
|
}
|
||||||
|
err := ret.loadState()
|
||||||
|
return ret, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveMessages(_ interface{}, _ CartId) error {
|
||||||
|
// No-op: legacy event log persistence removed in oneof refactor.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCartPath(id string) string {
|
||||||
|
return fmt.Sprintf("data/%s.prot", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMessages(_ Grain, _ CartId) error {
|
||||||
|
// No-op: legacy replay removed in oneof refactor.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage) saveState() error {
|
||||||
|
tmpFile := s.stateFile + "_tmp"
|
||||||
|
file, err := os.Create(tmpFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
err = gob.NewEncoder(file).Encode(s.LastSaves)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
os.Remove(s.stateFile + ".bak")
|
||||||
|
os.Rename(s.stateFile, s.stateFile+".bak")
|
||||||
|
return os.Rename(tmpFile, s.stateFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage) loadState() error {
|
||||||
|
file, err := os.Open(s.stateFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
return gob.NewDecoder(file).Decode(&s.LastSaves)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage) Store(id CartId, _ *CartGrain) error {
|
||||||
|
// With the removal of the legacy message log, we only update the timestamp.
|
||||||
|
ts := time.Now().Unix()
|
||||||
|
s.LastSaves[id] = ts
|
||||||
|
s.lastSave = ts
|
||||||
|
return nil
|
||||||
|
}
|
||||||
102
frames.go
Normal file
102
frames.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Minimal frame abstractions retained after removal of the legacy TCP/frame
|
||||||
|
// networking layer. These types remain only to avoid a wide cascading refactor
|
||||||
|
// across existing grain / pool logic that still constructs and passes
|
||||||
|
// FrameWithPayload objects internally.
|
||||||
|
//
|
||||||
|
// The original responsibilities this replaces:
|
||||||
|
// - Binary framing, checksums, network IO
|
||||||
|
// - Distinction between request / reply frame types
|
||||||
|
//
|
||||||
|
// What remains:
|
||||||
|
// - A light weight container (FrameWithPayload) used as an in‑process
|
||||||
|
// envelope for status code + typed marker + payload bytes (JSON or proto).
|
||||||
|
// - Message / status constants referenced in existing code paths.
|
||||||
|
//
|
||||||
|
// Recommended future cleanup (post‑migration):
|
||||||
|
// - Remove FrameType entirely and replace with enumerated semantic results
|
||||||
|
// or error values.
|
||||||
|
// - Replace FrameWithPayload with a struct { Status int; Data []byte }.
|
||||||
|
// - Remove remote_* reply type branching once all callers rely on gRPC
|
||||||
|
// status + strongly typed responses.
|
||||||
|
//
|
||||||
|
// For now we keep this minimal surface to keep the gRPC migration focused.
|
||||||
|
|
||||||
|
type (
|
||||||
|
// FrameType is a symbolic identifier carried through existing code paths.
|
||||||
|
// No ordering or bit semantics are required anymore.
|
||||||
|
FrameType uint32
|
||||||
|
StatusCode uint32
|
||||||
|
)
|
||||||
|
|
||||||
|
type Frame struct {
|
||||||
|
Type FrameType
|
||||||
|
StatusCode StatusCode
|
||||||
|
Length uint32
|
||||||
|
// Checksum retained for compatibility; no longer validated.
|
||||||
|
Checksum uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// FrameWithPayload wraps a Frame with an opaque payload.
|
||||||
|
// Payload usually contains JSON encoded cart state or an error message.
|
||||||
|
type FrameWithPayload struct {
|
||||||
|
Frame
|
||||||
|
Payload []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Legacy Frame Type Constants (minimal subset still referenced)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
const (
|
||||||
|
RemoteGetState = FrameType(0x01)
|
||||||
|
RemoteHandleMutation = FrameType(0x02)
|
||||||
|
ResponseBody = FrameType(0x03) // (rarely used; kept for completeness)
|
||||||
|
RemoteGetStateReply = FrameType(0x04)
|
||||||
|
RemoteHandleMutationReply = FrameType(0x05)
|
||||||
|
RemoteCreateOrderReply = FrameType(0x06)
|
||||||
|
)
|
||||||
|
|
||||||
|
// MakeFrameWithPayload constructs an in‑process frame wrapper.
|
||||||
|
// Length & Checksum are filled for backward compatibility (no validation logic
|
||||||
|
// depends on the checksum anymore).
|
||||||
|
func MakeFrameWithPayload(msg FrameType, statusCode StatusCode, payload []byte) FrameWithPayload {
|
||||||
|
length := uint32(len(payload))
|
||||||
|
return FrameWithPayload{
|
||||||
|
Frame: Frame{
|
||||||
|
Type: msg,
|
||||||
|
StatusCode: statusCode,
|
||||||
|
Length: length,
|
||||||
|
Checksum: (uint32(msg) + uint32(statusCode) + length) / 8, // simple legacy formula
|
||||||
|
},
|
||||||
|
Payload: payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone creates a shallow copy of the frame, duplicating the payload slice.
|
||||||
|
func (f *FrameWithPayload) Clone() *FrameWithPayload {
|
||||||
|
if f == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cp := make([]byte, len(f.Payload))
|
||||||
|
copy(cp, f.Payload)
|
||||||
|
return &FrameWithPayload{
|
||||||
|
Frame: f.Frame,
|
||||||
|
Payload: cp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewErrorFrame helper for creating an error frame with a textual payload.
|
||||||
|
func NewErrorFrame(msg FrameType, code StatusCode, err error) FrameWithPayload {
|
||||||
|
var b []byte
|
||||||
|
if err != nil {
|
||||||
|
b = []byte(err.Error())
|
||||||
|
}
|
||||||
|
return MakeFrameWithPayload(msg, code, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSuccess returns true if the status code indicates success in the
|
||||||
|
// conventional HTTP style range (200–299). This mirrors previous usage patterns.
|
||||||
|
func (f *FrameWithPayload) IsSuccess() bool {
|
||||||
|
return f != nil && f.StatusCode >= 200 && f.StatusCode < 300
|
||||||
|
}
|
||||||
117
go.mod
117
go.mod
@@ -1,118 +1,73 @@
|
|||||||
module git.k6n.net/go-cart-actor
|
module git.tornberg.me/go-cart-actor
|
||||||
|
|
||||||
go 1.25.4
|
go 1.25.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/adyen/adyen-go-api-library/v21 v21.1.0
|
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/matst80/go-redis-inventory v0.0.0-20251126173508-51b30de2d86e
|
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548
|
||||||
github.com/matst80/slask-finder v0.0.0-20251125182907-9e57f193127a
|
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/rabbitmq/amqp091-go v1.10.0
|
github.com/rabbitmq/amqp091-go v1.10.0
|
||||||
github.com/redis/go-redis/v9 v9.17.0
|
google.golang.org/grpc v1.76.0
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0
|
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0
|
|
||||||
go.opentelemetry.io/otel v1.38.0
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0
|
|
||||||
go.opentelemetry.io/otel/log v0.14.0
|
|
||||||
go.opentelemetry.io/otel/metric v1.38.0
|
|
||||||
go.opentelemetry.io/otel/sdk v1.38.0
|
|
||||||
go.opentelemetry.io/otel/sdk/log v0.14.0
|
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.38.0
|
|
||||||
go.opentelemetry.io/otel/trace v1.38.0
|
|
||||||
google.golang.org/grpc v1.77.0
|
|
||||||
google.golang.org/protobuf v1.36.10
|
google.golang.org/protobuf v1.36.10
|
||||||
k8s.io/api v0.34.2
|
k8s.io/api v0.34.1
|
||||||
k8s.io/apimachinery v0.34.2
|
k8s.io/apimachinery v0.34.1
|
||||||
k8s.io/client-go v0.34.2
|
k8s.io/client-go v0.34.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/RoaringBitmap/roaring/v2 v2.14.4 // indirect
|
github.com/RoaringBitmap/roaring/v2 v2.10.0 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
github.com/bits-and-blooms/bitset v1.24.0 // indirect
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
|
||||||
github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589 // indirect
|
|
||||||
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||||
github.com/getkin/kin-openapi v0.133.0 // indirect
|
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-openapi/jsonpointer v0.22.1 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.22.3 // indirect
|
github.com/go-openapi/jsonreference v0.21.2 // indirect
|
||||||
github.com/go-openapi/jsonreference v0.21.3 // indirect
|
github.com/go-openapi/swag v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag v0.25.4 // indirect
|
github.com/go-openapi/swag/cmdutils v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
|
github.com/go-openapi/swag/conv v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag/conv v0.25.4 // indirect
|
github.com/go-openapi/swag/fileutils v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag/fileutils v0.25.4 // indirect
|
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
|
github.com/go-openapi/swag/loading v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag/loading v0.25.4 // indirect
|
github.com/go-openapi/swag/mangling v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag/mangling v0.25.4 // indirect
|
github.com/go-openapi/swag/netutils v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag/netutils v0.25.4 // indirect
|
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
|
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
|
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
|
||||||
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
|
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/google/gnostic-models v0.7.1 // indirect
|
github.com/google/gnostic-models v0.7.0 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/gorilla/schema v1.4.1 // indirect
|
github.com/gorilla/schema v1.4.1 // indirect
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
|
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/mailru/easyjson v0.9.1 // indirect
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
|
||||||
github.com/mschoch/smat v0.2.0 // indirect
|
github.com/mschoch/smat v0.2.0 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
|
|
||||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
|
|
||||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.4 // indirect
|
github.com/prometheus/common v0.67.1 // indirect
|
||||||
github.com/prometheus/procfs v0.19.2 // indirect
|
github.com/prometheus/procfs v0.17.0 // indirect
|
||||||
github.com/speakeasy-api/jsonpath v0.6.2 // indirect
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
github.com/speakeasy-api/openapi-overlay v0.10.3 // indirect
|
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
|
||||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
|
||||||
github.com/woodsbury/decimal128 v1.4.0 // indirect
|
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
|
|
||||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
|
golang.org/x/net v0.46.0 // indirect
|
||||||
golang.org/x/mod v0.30.0 // indirect
|
golang.org/x/oauth2 v0.32.0 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
golang.org/x/oauth2 v0.33.0 // indirect
|
golang.org/x/term v0.36.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/text v0.30.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
|
||||||
golang.org/x/term v0.37.0 // indirect
|
|
||||||
golang.org/x/text v0.31.0 // indirect
|
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
golang.org/x/tools v0.39.0 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846 // indirect
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect
|
|
||||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
k8s.io/klog/v2 v2.130.1 // indirect
|
k8s.io/klog/v2 v2.130.1 // indirect
|
||||||
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // indirect
|
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
|
||||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
||||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect
|
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
|
||||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
|
|
||||||
|
|||||||
298
go.sum
298
go.sum
@@ -1,91 +1,62 @@
|
|||||||
github.com/RoaringBitmap/roaring/v2 v2.14.4 h1:4aKySrrg9G/5oRtJ3TrZLObVqxgQ9f1znCRBwEwjuVw=
|
github.com/RoaringBitmap/roaring/v2 v2.10.0 h1:HbJ8Cs71lfCJyvmSptxeMX2PtvOC8yonlU0GQcy2Ak0=
|
||||||
github.com/RoaringBitmap/roaring/v2 v2.14.4/go.mod h1:oMvV6omPWr+2ifRdeZvVJyaz+aoEUopyv5iH0u/+wbY=
|
github.com/RoaringBitmap/roaring/v2 v2.10.0/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
|
||||||
github.com/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/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
|
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||||
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
|
||||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
|
||||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
|
||||||
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
|
||||||
github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589 h1:VJ/jVUWr+r4MQA7U/cscbbXRuwh1PfPCUUItYAjlKN4=
|
|
||||||
github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589/go.mod h1:IeI20psFPeg2n1jxwbkYCmkpYsXsJqB7qmoqCIlX80s=
|
|
||||||
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
|
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
|
||||||
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
|
||||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
|
github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
|
||||||
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
|
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
|
||||||
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
|
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
|
||||||
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
|
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
|
||||||
github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU=
|
github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8=
|
||||||
github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ=
|
github.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo=
|
||||||
github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4=
|
github.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY=
|
||||||
github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
|
github.com/go-openapi/swag/cmdutils v0.25.1/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
|
||||||
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
|
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
|
||||||
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
|
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
|
||||||
github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y=
|
github.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU=
|
||||||
github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk=
|
github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M=
|
||||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
|
||||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
|
||||||
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
|
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
|
||||||
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
|
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
|
||||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
|
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
|
||||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
|
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
|
||||||
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
|
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
|
||||||
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
|
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
|
||||||
github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48=
|
github.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync=
|
||||||
github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg=
|
github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ=
|
||||||
github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0=
|
github.com/go-openapi/swag/netutils v0.25.1 h1:2wFLYahe40tDUHfKT1GRC4rfa5T1B4GWZ+msEFA4Fl4=
|
||||||
github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg=
|
github.com/go-openapi/swag/netutils v0.25.1/go.mod h1:CAkkvqnUJX8NV96tNhEQvKz8SQo2KF0f7LleiJwIeRE=
|
||||||
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
|
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
|
||||||
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
|
||||||
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
|
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
|
||||||
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
|
||||||
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
|
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
|
||||||
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
|
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
|
||||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
|
|
||||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
|
|
||||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
|
||||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
|
||||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
|
||||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
|
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
||||||
github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
@@ -95,243 +66,154 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
||||||
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
|
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
|
|
||||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548 h1:lkxVz5lNPlU78El49cx1c3Rxo/bp7Gbzp2BjSRFgg1U=
|
||||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548/go.mod h1:k4lo5gFYb3AqrgftlraCnv95xvjB/w8udRqJQJ12mAE=
|
||||||
github.com/matst80/go-redis-inventory v0.0.0-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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
|
||||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
|
||||||
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
|
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
|
||||||
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
|
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
|
|
||||||
github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
|
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 h1:5vHNY1uuPBRBWqB2Dp0G7YB03phxLQZupZTIZaeorjc=
|
|
||||||
github.com/oapi-codegen/oapi-codegen/v2 v2.5.1/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ=
|
|
||||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
|
||||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
|
|
||||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
|
|
||||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
|
||||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
|
||||||
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
|
||||||
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
|
||||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
|
||||||
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
|
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
|
||||||
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
||||||
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
|
||||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
||||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
|
||||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
|
github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI=
|
||||||
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q=
|
||||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||||
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
||||||
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||||
github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM=
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
|
||||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
|
||||||
github.com/speakeasy-api/jsonpath v0.6.2 h1:Mys71yd6u8kuowNCR0gCVPlVAHCmKtoGXYoAtcEbqXQ=
|
|
||||||
github.com/speakeasy-api/jsonpath v0.6.2/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw=
|
|
||||||
github.com/speakeasy-api/openapi-overlay v0.10.3 h1:70een4vwHyslIp796vM+ox6VISClhtXsCjrQNhxwvWs=
|
|
||||||
github.com/speakeasy-api/openapi-overlay v0.10.3/go.mod h1:RJjV0jbUHqXLS0/Mxv5XE7LAnJHqHw+01RDdpoGqiyY=
|
|
||||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
|
||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
|
||||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
|
||||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
|
|
||||||
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
|
|
||||||
github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc=
|
|
||||||
github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU=
|
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 h1:bwnLpizECbPr1RrQ27waeY2SPIPeccCx/xLuoYADZ9s=
|
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0/go.mod h1:3nWlOiiqA9UtUnrcNk82mYasNxD8ehOspL0gOfEo6Y4=
|
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
|
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
|
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 h1:OMqPldHt79PqWKOMYIAQs3CxAi7RLgPxwfFSwr4ZxtM=
|
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0/go.mod h1:1biG4qiqTxKiUCtoWDPpL3fB3KxVwCiGw81j3nKMuHE=
|
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||||
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.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
|
||||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||||
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||||
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846 h1:ZdyUkS9po3H7G0tuh955QVyyotWvOD4W0aEapeGeUYk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff h1:A90eA31Wq6HOMIQlLfzFwzqGKBTuaVztYu/g8sn+8Zc=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846/go.mod h1:Fk4kyraUvqD7i5H6S43sj2W98fbZa75lpZz/eUyhfO0=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 h1:Wgl1rcDNThT+Zn47YyCXOXyX/COgMTIdhJ717F0l4xk=
|
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
|
||||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
|
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
|
||||||
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
|
||||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
|
||||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
|
||||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY=
|
k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM=
|
||||||
k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw=
|
k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk=
|
||||||
k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4=
|
k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4=
|
||||||
k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
|
k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
|
||||||
k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M=
|
k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY=
|
||||||
k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE=
|
k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8=
|
||||||
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||||
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||||
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ=
|
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
|
||||||
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
|
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
|
||||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
|
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
|
||||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
|
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
|
||||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||||
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
||||||
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E=
|
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
|
||||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||||
|
|||||||
@@ -1,327 +0,0 @@
|
|||||||
{
|
|
||||||
"uid": "cart-actors",
|
|
||||||
"title": "Cart Actor Cluster",
|
|
||||||
"timezone": "browser",
|
|
||||||
"refresh": "30s",
|
|
||||||
"schemaVersion": 38,
|
|
||||||
"version": 1,
|
|
||||||
"editable": true,
|
|
||||||
"graphTooltip": 0,
|
|
||||||
"panels": [
|
|
||||||
{
|
|
||||||
"type": "row",
|
|
||||||
"title": "Overview",
|
|
||||||
"gridPos": { "x": 0, "y": 0, "w": 24, "h": 1 },
|
|
||||||
"id": 1,
|
|
||||||
"collapsed": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Active Grains",
|
|
||||||
"id": 2,
|
|
||||||
"gridPos": { "x": 0, "y": 1, "w": 6, "h": 4 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{ "refId": "A", "expr": "cart_active_grains" }
|
|
||||||
],
|
|
||||||
"options": {
|
|
||||||
"colorMode": "value",
|
|
||||||
"graphMode": "none",
|
|
||||||
"justifyMode": "center",
|
|
||||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Grains In Pool",
|
|
||||||
"id": 3,
|
|
||||||
"gridPos": { "x": 6, "y": 1, "w": 6, "h": 4 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{ "refId": "A", "expr": "cart_grains_in_pool" }
|
|
||||||
],
|
|
||||||
"options": {
|
|
||||||
"colorMode": "value",
|
|
||||||
"graphMode": "none",
|
|
||||||
"justifyMode": "center",
|
|
||||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Pool Usage %",
|
|
||||||
"id": 4,
|
|
||||||
"gridPos": { "x": 12, "y": 1, "w": 6, "h": 4 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{ "refId": "A", "expr": "cart_grain_pool_usage * 100" }
|
|
||||||
],
|
|
||||||
"units": "percent",
|
|
||||||
"options": {
|
|
||||||
"colorMode": "value",
|
|
||||||
"graphMode": "none",
|
|
||||||
"justifyMode": "center",
|
|
||||||
"reduceOptions": { "calcs": ["lastNotNull"] }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Connected Remotes",
|
|
||||||
"id": 5,
|
|
||||||
"gridPos": { "x": 18, "y": 1, "w": 6, "h": 4 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{ "refId": "A", "expr": "connected_remotes" }
|
|
||||||
],
|
|
||||||
"options": {
|
|
||||||
"colorMode": "value",
|
|
||||||
"graphMode": "none",
|
|
||||||
"justifyMode": "center",
|
|
||||||
"reduceOptions": { "calcs": ["lastNotNull"] }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
"type": "row",
|
|
||||||
"title": "Mutations",
|
|
||||||
"gridPos": { "x": 0, "y": 5, "w": 24, "h": 1 },
|
|
||||||
"id": 6,
|
|
||||||
"collapsed": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timeseries",
|
|
||||||
"title": "Mutation Rate (1m)",
|
|
||||||
"id": 7,
|
|
||||||
"gridPos": { "x": 0, "y": 6, "w": 12, "h": 8 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{ "refId": "A", "expr": "rate(cart_mutations_total[1m])", "legendFormat": "mutations/s" },
|
|
||||||
{ "refId": "B", "expr": "rate(cart_mutation_failures_total[1m])", "legendFormat": "failures/s" }
|
|
||||||
],
|
|
||||||
"fieldConfig": { "defaults": { "unit": "ops" } }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Failure % (5m)",
|
|
||||||
"id": 8,
|
|
||||||
"gridPos": { "x": 12, "y": 6, "w": 6, "h": 4 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"refId": "A",
|
|
||||||
"expr": "100 * (increase(cart_mutation_failures_total[5m]) / clamp_max(increase(cart_mutations_total[5m]), 1))"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"options": {
|
|
||||||
"colorMode": "value",
|
|
||||||
"graphMode": "none",
|
|
||||||
"justifyMode": "center",
|
|
||||||
"reduceOptions": { "calcs": ["lastNotNull"] }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timeseries",
|
|
||||||
"title": "Mutation Latency Quantiles",
|
|
||||||
"id": 9,
|
|
||||||
"gridPos": { "x": 18, "y": 6, "w": 6, "h": 8 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"refId": "A",
|
|
||||||
"expr": "histogram_quantile(0.50, sum(rate(cart_mutation_latency_seconds_bucket[5m])) by (le))",
|
|
||||||
"legendFormat": "p50"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"refId": "B",
|
|
||||||
"expr": "histogram_quantile(0.90, sum(rate(cart_mutation_latency_seconds_bucket[5m])) by (le))",
|
|
||||||
"legendFormat": "p90"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"refId": "C",
|
|
||||||
"expr": "histogram_quantile(0.99, sum(rate(cart_mutation_latency_seconds_bucket[5m])) by (le))",
|
|
||||||
"legendFormat": "p99"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"fieldConfig": { "defaults": { "unit": "s" } }
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
"type": "row",
|
|
||||||
"title": "Event Log",
|
|
||||||
"gridPos": { "x": 0, "y": 14, "w": 24, "h": 1 },
|
|
||||||
"id": 10,
|
|
||||||
"collapsed": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timeseries",
|
|
||||||
"title": "Event Append Rate (5m)",
|
|
||||||
"id": 11,
|
|
||||||
"gridPos": { "x": 0, "y": 15, "w": 8, "h": 6 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{ "refId": "A", "expr": "rate(cart_event_log_appends_total[5m])", "legendFormat": "appends/s" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timeseries",
|
|
||||||
"title": "Event Bytes Written Rate (5m)",
|
|
||||||
"id": 12,
|
|
||||||
"gridPos": { "x": 8, "y": 15, "w": 8, "h": 6 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{ "refId": "A", "expr": "rate(cart_event_log_bytes_written_total[5m])", "legendFormat": "bytes/s" }
|
|
||||||
],
|
|
||||||
"fieldConfig": { "defaults": { "unit": "Bps" } }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Existing Log Files",
|
|
||||||
"id": 13,
|
|
||||||
"gridPos": { "x": 16, "y": 15, "w": 4, "h": 3 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [{ "refId": "A", "expr": "cart_event_log_files_existing" }],
|
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Last Append Age (s)",
|
|
||||||
"id": 14,
|
|
||||||
"gridPos": { "x": 20, "y": 15, "w": 4, "h": 3 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{ "refId": "A", "expr": "(time() - cart_event_log_last_append_unix)" }
|
|
||||||
],
|
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Replay Failures Total",
|
|
||||||
"id": 15,
|
|
||||||
"gridPos": { "x": 16, "y": 18, "w": 4, "h": 3 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [{ "refId": "A", "expr": "cart_event_log_replay_failures_total" }],
|
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Replay Duration p95 (5m)",
|
|
||||||
"id": 16,
|
|
||||||
"gridPos": { "x": 20, "y": 18, "w": 4, "h": 3 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"refId": "A",
|
|
||||||
"expr": "histogram_quantile(0.95, sum(rate(cart_event_log_replay_duration_seconds_bucket[5m])) by (le))"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "fieldConfig": { "defaults": { "unit": "s" } } }
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
"type": "row",
|
|
||||||
"title": "Grain Lifecycle",
|
|
||||||
"gridPos": { "x": 0, "y": 21, "w": 24, "h": 1 },
|
|
||||||
"id": 17,
|
|
||||||
"collapsed": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timeseries",
|
|
||||||
"title": "Spawn & Lookup Rates (1m)",
|
|
||||||
"id": 18,
|
|
||||||
"gridPos": { "x": 0, "y": 22, "w": 12, "h": 8 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{ "refId": "A", "expr": "rate(cart_grain_spawned_total[1m])", "legendFormat": "spawns/s" },
|
|
||||||
{ "refId": "B", "expr": "rate(cart_grain_lookups_total[1m])", "legendFormat": "lookups/s" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Negotiations Rate (5m)",
|
|
||||||
"id": 19,
|
|
||||||
"gridPos": { "x": 12, "y": 22, "w": 6, "h": 4 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{ "refId": "A", "expr": "rate(cart_remote_negotiation_total[5m])" }
|
|
||||||
],
|
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "orientation": "horizontal" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Mutations Total",
|
|
||||||
"id": 20,
|
|
||||||
"gridPos": { "x": 18, "y": 22, "w": 6, "h": 4 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [{ "refId": "A", "expr": "cart_mutations_total" }],
|
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
"type": "row",
|
|
||||||
"title": "Event Log Errors",
|
|
||||||
"gridPos": { "x": 0, "y": 30, "w": 24, "h": 1 },
|
|
||||||
"id": 21,
|
|
||||||
"collapsed": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Unknown Event Types",
|
|
||||||
"id": 22,
|
|
||||||
"gridPos": { "x": 0, "y": 31, "w": 6, "h": 4 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [{ "refId": "A", "expr": "cart_event_log_unknown_types_total" }],
|
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Event Mutation Errors",
|
|
||||||
"id": 23,
|
|
||||||
"gridPos": { "x": 6, "y": 31, "w": 6, "h": 4 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [{ "refId": "A", "expr": "cart_event_log_mutation_errors_total" }],
|
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Replay Success Total",
|
|
||||||
"id": 24,
|
|
||||||
"gridPos": { "x": 12, "y": 31, "w": 6, "h": 4 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [{ "refId": "A", "expr": "cart_event_log_replay_total" }],
|
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "stat",
|
|
||||||
"title": "Replay Duration p50 (5m)",
|
|
||||||
"id": 25,
|
|
||||||
"gridPos": { "x": 18, "y": 31, "w": 6, "h": 4 },
|
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"refId": "A",
|
|
||||||
"expr": "histogram_quantile(0.50, sum(rate(cart_event_log_replay_duration_seconds_bucket[5m])) by (le))"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "fieldConfig": { "defaults": { "unit": "s" } } }
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"templating": {
|
|
||||||
"list": [
|
|
||||||
{
|
|
||||||
"name": "DS_PROMETHEUS",
|
|
||||||
"label": "Prometheus",
|
|
||||||
"type": "datasource",
|
|
||||||
"query": "prometheus",
|
|
||||||
"current": { "text": "Prometheus", "value": "Prometheus" }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"time": {
|
|
||||||
"from": "now-6h",
|
|
||||||
"to": "now"
|
|
||||||
},
|
|
||||||
"timepicker": {
|
|
||||||
"refresh_intervals": ["5s","10s","30s","1m","5m","15m","30m","1h"],
|
|
||||||
"time_options": ["5m","15m","30m","1h","6h","12h","24h","2d","7d"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
155
grain-pool.go
Normal file
155
grain-pool.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
poolGrains = promauto.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "cart_grains_in_pool",
|
||||||
|
Help: "The total number of grains in the pool",
|
||||||
|
})
|
||||||
|
poolSize = promauto.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "cart_pool_size",
|
||||||
|
Help: "The total number of mutations",
|
||||||
|
})
|
||||||
|
poolUsage = promauto.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "cart_grain_pool_usage",
|
||||||
|
Help: "The current usage of the grain pool",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
type GrainPool interface {
|
||||||
|
Apply(id CartId, mutation interface{}) (*CartGrain, error)
|
||||||
|
Get(id CartId) (*CartGrain, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Ttl struct {
|
||||||
|
Expires time.Time
|
||||||
|
Grain *CartGrain
|
||||||
|
}
|
||||||
|
|
||||||
|
type GrainLocalPool struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
grains map[CartId]*CartGrain
|
||||||
|
expiry []Ttl
|
||||||
|
spawn func(id CartId) (*CartGrain, error)
|
||||||
|
Ttl time.Duration
|
||||||
|
PoolSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGrainLocalPool(size int, ttl time.Duration, spawn func(id CartId) (*CartGrain, error)) *GrainLocalPool {
|
||||||
|
|
||||||
|
ret := &GrainLocalPool{
|
||||||
|
spawn: spawn,
|
||||||
|
grains: make(map[CartId]*CartGrain),
|
||||||
|
expiry: make([]Ttl, 0),
|
||||||
|
Ttl: ttl,
|
||||||
|
PoolSize: size,
|
||||||
|
}
|
||||||
|
|
||||||
|
cartPurge := time.NewTicker(time.Minute)
|
||||||
|
go func() {
|
||||||
|
<-cartPurge.C
|
||||||
|
ret.Purge()
|
||||||
|
}()
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GrainLocalPool) SetAvailable(availableWithLastChangeUnix map[CartId]int64) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
for id := range availableWithLastChangeUnix {
|
||||||
|
if _, ok := p.grains[id]; !ok {
|
||||||
|
p.grains[id] = nil
|
||||||
|
p.expiry = append(p.expiry, Ttl{
|
||||||
|
Expires: time.Now().Add(p.Ttl),
|
||||||
|
Grain: nil,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GrainLocalPool) Purge() {
|
||||||
|
lastChangeTime := time.Now().Add(-p.Ttl)
|
||||||
|
keepChanged := lastChangeTime.Unix()
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
for i := 0; i < len(p.expiry); i++ {
|
||||||
|
item := p.expiry[i]
|
||||||
|
if item.Expires.Before(time.Now()) {
|
||||||
|
if item.Grain.GetLastChange() > keepChanged {
|
||||||
|
log.Printf("Expired item %s changed, keeping", item.Grain.GetId())
|
||||||
|
if i < len(p.expiry)-1 {
|
||||||
|
p.expiry = append(p.expiry[:i], p.expiry[i+1:]...)
|
||||||
|
p.expiry = append(p.expiry, item)
|
||||||
|
} else {
|
||||||
|
p.expiry = append(p.expiry[:i], item)
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
log.Printf("Item %s expired", item.Grain.GetId())
|
||||||
|
delete(p.grains, item.Grain.GetId())
|
||||||
|
if i < len(p.expiry)-1 {
|
||||||
|
p.expiry = append(p.expiry[:i], p.expiry[i+1:]...)
|
||||||
|
} else {
|
||||||
|
p.expiry = p.expiry[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GrainLocalPool) GetGrains() map[CartId]*CartGrain {
|
||||||
|
return p.grains
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GrainLocalPool) GetGrain(id CartId) (*CartGrain, error) {
|
||||||
|
var err error
|
||||||
|
// p.mu.RLock()
|
||||||
|
// defer p.mu.RUnlock()
|
||||||
|
grain, ok := p.grains[id]
|
||||||
|
grainLookups.Inc()
|
||||||
|
if grain == nil || !ok {
|
||||||
|
if len(p.grains) >= p.PoolSize {
|
||||||
|
if p.expiry[0].Expires.Before(time.Now()) {
|
||||||
|
delete(p.grains, p.expiry[0].Grain.GetId())
|
||||||
|
p.expiry = p.expiry[1:]
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("pool is full")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
grain, err = p.spawn(id)
|
||||||
|
p.mu.Lock()
|
||||||
|
p.grains[id] = grain
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
l := float64(len(p.grains))
|
||||||
|
ps := float64(p.PoolSize)
|
||||||
|
poolUsage.Set(l / ps)
|
||||||
|
poolGrains.Set(l)
|
||||||
|
poolSize.Set(ps)
|
||||||
|
}()
|
||||||
|
return grain, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GrainLocalPool) Apply(id CartId, mutation interface{}) (*CartGrain, error) {
|
||||||
|
grain, err := p.GetGrain(id)
|
||||||
|
if err != nil || grain == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return grain.Apply(mutation, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GrainLocalPool) Get(id CartId) (*CartGrain, error) {
|
||||||
|
return p.GetGrain(id)
|
||||||
|
}
|
||||||
115
grpc_integration_test.go
Normal file
115
grpc_integration_test.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCartActorMutationAndState validates end-to-end gRPC mutation + state retrieval
|
||||||
|
// against a locally started gRPC server (single-node scenario).
|
||||||
|
// This test uses the new per-mutation AddItem RPC (breaking v2 API) to avoid external product fetch logic
|
||||||
|
// fetching logic (FetchItem) which would require network I/O.
|
||||||
|
func TestCartActorMutationAndState(t *testing.T) {
|
||||||
|
// Setup local grain pool + synced pool (no discovery, single host)
|
||||||
|
pool := NewGrainLocalPool(1024, time.Minute, spawn)
|
||||||
|
synced, err := NewSyncedPool(pool, "127.0.0.1", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSyncedPool error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start gRPC server (CartActor + ControlPlane) on :1337
|
||||||
|
grpcSrv, err := StartGRPCServer(":1337", pool, synced)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StartGRPCServer error: %v", err)
|
||||||
|
}
|
||||||
|
defer grpcSrv.GracefulStop()
|
||||||
|
|
||||||
|
// Dial the local server
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
conn, err := grpc.DialContext(ctx, "127.0.0.1:1337",
|
||||||
|
grpc.WithInsecure(),
|
||||||
|
grpc.WithBlock(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("grpc.Dial error: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
cartClient := messages.NewCartActorClient(conn)
|
||||||
|
|
||||||
|
// Create a short cart id (<=16 chars so it fits into the fixed CartId 16-byte array cleanly)
|
||||||
|
cartID := fmt.Sprintf("cart-%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
// Build an AddItem payload (bypasses FetchItem to keep test deterministic)
|
||||||
|
addItem := &messages.AddItem{
|
||||||
|
ItemId: 1,
|
||||||
|
Quantity: 1,
|
||||||
|
Price: 1000,
|
||||||
|
OrgPrice: 1000,
|
||||||
|
Sku: "test-sku",
|
||||||
|
Name: "Test SKU",
|
||||||
|
Image: "/img.png",
|
||||||
|
Stock: 2, // InStock
|
||||||
|
Tax: 2500,
|
||||||
|
Country: "se",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue AddItem RPC directly (breaking v2 API)
|
||||||
|
addResp, err := cartClient.AddItem(context.Background(), &messages.AddItemRequest{
|
||||||
|
CartId: cartID,
|
||||||
|
ClientTimestamp: time.Now().Unix(),
|
||||||
|
Payload: addItem,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddItem RPC error: %v", err)
|
||||||
|
}
|
||||||
|
if addResp.StatusCode != 200 {
|
||||||
|
t.Fatalf("AddItem returned non-200 status: %d, error: %s", addResp.StatusCode, addResp.GetError())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the response state (from AddItem)
|
||||||
|
state := addResp.GetState()
|
||||||
|
if state == nil {
|
||||||
|
t.Fatalf("AddItem response state is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// (Removed obsolete Mutate response handling)
|
||||||
|
|
||||||
|
if len(state.Items) != 1 {
|
||||||
|
t.Fatalf("Expected 1 item after AddItem, got %d", len(state.Items))
|
||||||
|
}
|
||||||
|
if state.Items[0].Sku != "test-sku" {
|
||||||
|
t.Fatalf("Unexpected item SKU: %s", state.Items[0].Sku)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue GetState RPC
|
||||||
|
getResp, err := cartClient.GetState(context.Background(), &messages.StateRequest{
|
||||||
|
CartId: cartID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetState RPC error: %v", err)
|
||||||
|
}
|
||||||
|
if getResp.StatusCode != 200 {
|
||||||
|
t.Fatalf("GetState returned non-200 status: %d, error: %s", getResp.StatusCode, getResp.GetError())
|
||||||
|
}
|
||||||
|
|
||||||
|
state2 := getResp.GetState()
|
||||||
|
if state2 == nil {
|
||||||
|
t.Fatalf("GetState response state is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(state2.Items) != 1 {
|
||||||
|
t.Fatalf("Expected 1 item in GetState, got %d", len(state2.Items))
|
||||||
|
}
|
||||||
|
if state2.Items[0].Sku != "test-sku" {
|
||||||
|
t.Fatalf("Unexpected SKU in GetState: %s", state2.Items[0].Sku)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy serialization helper removed (oneof envelope used directly)
|
||||||
203
grpc_server.go
Normal file
203
grpc_server.go
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/reflection"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cartActorGRPCServer implements the CartActor and ControlPlane gRPC services.
|
||||||
|
// It delegates cart operations to a grain pool and cluster operations to a synced pool.
|
||||||
|
type cartActorGRPCServer struct {
|
||||||
|
messages.UnimplementedCartActorServer
|
||||||
|
messages.UnimplementedControlPlaneServer
|
||||||
|
|
||||||
|
pool GrainPool // For cart state mutations and queries
|
||||||
|
syncedPool *SyncedPool // For cluster membership and control
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCartActorGRPCServer creates and initializes the server.
|
||||||
|
func NewCartActorGRPCServer(pool GrainPool, syncedPool *SyncedPool) *cartActorGRPCServer {
|
||||||
|
return &cartActorGRPCServer{
|
||||||
|
pool: pool,
|
||||||
|
syncedPool: syncedPool,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyMutation routes a single cart mutation to the target grain (used by per-mutation RPC handlers).
|
||||||
|
func (s *cartActorGRPCServer) applyMutation(cartID string, mutation interface{}) *messages.CartMutationReply {
|
||||||
|
grain, err := s.pool.Apply(ToCartId(cartID), mutation)
|
||||||
|
if err != nil {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 500,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: err.Error()},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cartState := ToCartState(grain)
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 200,
|
||||||
|
Result: &messages.CartMutationReply_State{State: cartState},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cartActorGRPCServer) AddRequest(ctx context.Context, req *messages.AddRequestRequest) (*messages.CartMutationReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cartActorGRPCServer) AddItem(ctx context.Context, req *messages.AddItemRequest) (*messages.CartMutationReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cartActorGRPCServer) RemoveItem(ctx context.Context, req *messages.RemoveItemRequest) (*messages.CartMutationReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cartActorGRPCServer) RemoveDelivery(ctx context.Context, req *messages.RemoveDeliveryRequest) (*messages.CartMutationReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cartActorGRPCServer) ChangeQuantity(ctx context.Context, req *messages.ChangeQuantityRequest) (*messages.CartMutationReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cartActorGRPCServer) SetDelivery(ctx context.Context, req *messages.SetDeliveryRequest) (*messages.CartMutationReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cartActorGRPCServer) SetPickupPoint(ctx context.Context, req *messages.SetPickupPointRequest) (*messages.CartMutationReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Checkout RPC removed. Checkout is handled at the HTTP layer (PoolServer.HandleCheckout).
|
||||||
|
*/
|
||||||
|
|
||||||
|
func (s *cartActorGRPCServer) SetCartItems(ctx context.Context, req *messages.SetCartItemsRequest) (*messages.CartMutationReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *cartActorGRPCServer) OrderCompleted(ctx context.Context, req *messages.OrderCompletedRequest) (*messages.CartMutationReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.CartMutationReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.CartMutationReply_Error{Error: "cart_id is required"},
|
||||||
|
ServerTimestamp: time.Now().Unix(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return s.applyMutation(req.GetCartId(), req.GetPayload()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetState retrieves the current state of a cart grain.
|
||||||
|
func (s *cartActorGRPCServer) GetState(ctx context.Context, req *messages.StateRequest) (*messages.StateReply, error) {
|
||||||
|
if req.GetCartId() == "" {
|
||||||
|
return &messages.StateReply{
|
||||||
|
StatusCode: 400,
|
||||||
|
Result: &messages.StateReply_Error{Error: "cart_id is required"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
cartID := ToCartId(req.GetCartId())
|
||||||
|
|
||||||
|
grain, err := s.pool.Get(cartID)
|
||||||
|
if err != nil {
|
||||||
|
return &messages.StateReply{
|
||||||
|
StatusCode: 500,
|
||||||
|
Result: &messages.StateReply_Error{Error: err.Error()},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cartState := ToCartState(grain)
|
||||||
|
|
||||||
|
return &messages.StateReply{
|
||||||
|
StatusCode: 200,
|
||||||
|
Result: &messages.StateReply_State{State: cartState},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartGRPCServer configures and starts the unified gRPC server on the given address.
|
||||||
|
// It registers both the CartActor and ControlPlane services.
|
||||||
|
func StartGRPCServer(addr string, pool GrainPool, syncedPool *SyncedPool) (*grpc.Server, error) {
|
||||||
|
lis, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to listen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
grpcServer := grpc.NewServer()
|
||||||
|
server := NewCartActorGRPCServer(pool, syncedPool)
|
||||||
|
|
||||||
|
messages.RegisterCartActorServer(grpcServer, server)
|
||||||
|
messages.RegisterControlPlaneServer(grpcServer, server)
|
||||||
|
reflection.Register(grpcServer)
|
||||||
|
|
||||||
|
log.Printf("gRPC server listening on %s", addr)
|
||||||
|
go func() {
|
||||||
|
if err := grpcServer.Serve(lis); err != nil {
|
||||||
|
log.Fatalf("failed to serve gRPC: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return grpcServer, nil
|
||||||
|
}
|
||||||
174
k6/README.md
174
k6/README.md
@@ -1,174 +0,0 @@
|
|||||||
# k6 Load Tests for Cart API
|
|
||||||
|
|
||||||
This directory contains a k6 script (`cart_load_test.js`) to stress and observe the cart actor HTTP API.
|
|
||||||
|
|
||||||
## Contents
|
|
||||||
|
|
||||||
- `cart_load_test.js` – primary k6 scenario script
|
|
||||||
- `README.md` – this file
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Node not required (k6 runs standalone)
|
|
||||||
- k6 installed (>= v0.43 recommended)
|
|
||||||
- Prometheus + Grafana (optional) if you want to correlate with the dashboard you generated
|
|
||||||
- A running cart service exposing HTTP endpoints at (default) `http://localhost:8080/cart`
|
|
||||||
|
|
||||||
## Endpoints Exercised
|
|
||||||
|
|
||||||
The script exercises (per iteration):
|
|
||||||
|
|
||||||
1. `GET /cart` – ensure / fetch cart state (creates cart if missing; sets `cartid` & `cartowner` cookies)
|
|
||||||
2. `POST /cart` – add item mutation (random SKU & quantity)
|
|
||||||
3. `GET /cart` – fetch after mutations
|
|
||||||
4. `GET /checkout` – occasionally (~2% of iterations) to simulate checkout start
|
|
||||||
|
|
||||||
You can extend it easily to hit deliveries, quantity changes, or removal endpoints.
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
| Variable | Purpose | Default |
|
|
||||||
|-----------------|----------------------------------------------|-------------------------|
|
|
||||||
| `BASE_URL` | Base URL root (either host or host/cart) | `http://localhost:8080/cart` |
|
|
||||||
| `VUS` | VUs for steady_mutations scenario | `20` |
|
|
||||||
| `DURATION` | Duration for steady_mutations scenario | `5m` |
|
|
||||||
| `RAMP_TARGET` | Peak VUs for ramp_up scenario | `50` |
|
|
||||||
|
|
||||||
You can also disable one scenario by editing `options.scenarios` inside the script.
|
|
||||||
|
|
||||||
Example run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
k6 run \
|
|
||||||
-e BASE_URL=https://cart.k6n.net/cart \
|
|
||||||
-e VUS=40 \
|
|
||||||
-e DURATION=1m \
|
|
||||||
-e RAMP_TARGET=120 \
|
|
||||||
k6/cart_load_test.js
|
|
||||||
```
|
|
||||||
|
|
||||||
## Metrics (Custom)
|
|
||||||
|
|
||||||
The script defines additional k6 metrics:
|
|
||||||
|
|
||||||
- `cart_add_item_duration` (Trend) – latency of POST add item
|
|
||||||
- `cart_fetch_duration` (Trend) – latency of GET cart state
|
|
||||||
- `cart_checkout_duration` (Trend) – latency of checkout
|
|
||||||
- `cart_items_added` (Counter) – successful add item operations
|
|
||||||
- `cart_checkout_calls` (Counter) – successful checkout calls
|
|
||||||
|
|
||||||
Thresholds (in `options.thresholds`) enforce basic SLO:
|
|
||||||
- Mutation failure rate < 2%
|
|
||||||
- p90 mutation latency < 800 ms
|
|
||||||
- p99 overall HTTP latency < 1500 ms
|
|
||||||
|
|
||||||
Adjust thresholds to your environment if they trigger prematurely.
|
|
||||||
|
|
||||||
## Cookies & Stickiness
|
|
||||||
|
|
||||||
The script preserves:
|
|
||||||
- `cartid` – cart identity (server sets expiry separately)
|
|
||||||
- `cartowner` – owning host for sticky routing
|
|
||||||
|
|
||||||
If your load balancer or ingress enforces affinity based on these cookies, traffic will naturally concentrate on the originally claimed host for each cart under test.
|
|
||||||
|
|
||||||
## SKU Set
|
|
||||||
|
|
||||||
SKUs used (randomly selected each mutation):
|
|
||||||
|
|
||||||
```
|
|
||||||
778290 778345 778317 778277 778267 778376 778244 778384
|
|
||||||
778365 778377 778255 778286 778246 778270 778266 778285
|
|
||||||
778329 778425 778407 778418 778430 778469 778358 778351
|
|
||||||
778319 778307 778278 778251 778253 778261 778263 778273
|
|
||||||
778281 778294 778297 778302
|
|
||||||
```
|
|
||||||
|
|
||||||
To add/remove SKUs, edit the `SKUS` array. Keeping it non-empty and moderately sized helps randomization.
|
|
||||||
|
|
||||||
## Extending the Script
|
|
||||||
|
|
||||||
### Add Quantity Change
|
|
||||||
|
|
||||||
```js
|
|
||||||
function changeQuantity(itemId, newQty) {
|
|
||||||
const payload = JSON.stringify({ Id: itemId, Qty: newQty });
|
|
||||||
http.put(baseUrl() + '/', payload, { headers: headers() });
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Remove Item
|
|
||||||
|
|
||||||
```js
|
|
||||||
function removeItem(itemId) {
|
|
||||||
http.del(baseUrl() + '/' + itemId, null, { headers: headers() });
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Add Delivery
|
|
||||||
|
|
||||||
```js
|
|
||||||
function addDelivery(itemIds) {
|
|
||||||
const payload = JSON.stringify({ provider: "POSTNORD", items: itemIds });
|
|
||||||
http.post(baseUrl() + '/delivery', payload, { headers: headers() });
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
You can integrate these into the iteration loop with probabilities.
|
|
||||||
|
|
||||||
## Output Summary
|
|
||||||
|
|
||||||
`handleSummary` outputs a JSON summary to stdout:
|
|
||||||
- Average & p95 mutation latencies (if present)
|
|
||||||
- Fetch p95
|
|
||||||
- Checkout count
|
|
||||||
- Check statuses
|
|
||||||
|
|
||||||
Redirect or parse that output for CI pipelines.
|
|
||||||
|
|
||||||
## Running in CI
|
|
||||||
|
|
||||||
Use shorter durations (e.g. `DURATION=2m VUS=10`) to keep builds fast. Fail build on threshold breaches:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
k6 run -e BASE_URL=$TARGET -e VUS=10 -e DURATION=2m k6/cart_load_test.js || exit 1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Correlating with Prometheus / Grafana
|
|
||||||
|
|
||||||
During load, observe:
|
|
||||||
- `cart_mutations_total` growth and latency histograms
|
|
||||||
- Event log write rate (`cart_event_log_appends_total`)
|
|
||||||
- Pool usage (`cart_grain_pool_usage`) and spawn rate (`cart_grain_spawned_total`)
|
|
||||||
- Failure counters (`cart_mutation_failures_total`) ensure they remain low
|
|
||||||
|
|
||||||
If mutation latency spikes without high error rate, inspect external dependencies (e.g., product fetcher or Klarna endpoints).
|
|
||||||
|
|
||||||
## Common Tuning Tips
|
|
||||||
|
|
||||||
| Symptom | Potential Adjustment |
|
|
||||||
|------------------------------------|---------------------------------------------------|
|
|
||||||
| High latency p99 | Increase CPU/memory, optimize mutation handlers |
|
|
||||||
| Pool at capacity | Raise pool size argument or TTL |
|
|
||||||
| Frequent cart eviction mid-test | Confirm TTL is sliding (now 2h on mutation) |
|
|
||||||
| High replay duration | Consider snapshot + truncate event logs |
|
|
||||||
| Uneven host load | Verify `cartowner` cookie is respected upstream |
|
|
||||||
|
|
||||||
## Safety / Load Guardrails
|
|
||||||
|
|
||||||
- Start with low VUs (5–10) and short duration.
|
|
||||||
- Scale incrementally to find saturation points.
|
|
||||||
- If using production endpoints, coordinate off-peak runs.
|
|
||||||
|
|
||||||
## License / Attribution
|
|
||||||
|
|
||||||
This test script is tailored for your internal cart actor system; adapt freely. k6 is open-source (AGPL v3). Ensure compliance if redistributing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Feel free to request:
|
|
||||||
- A variant script for spike tests
|
|
||||||
- WebSocket / long poll integration (if added later)
|
|
||||||
- Synthetic error injection harness
|
|
||||||
|
|
||||||
Happy load testing!
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
import http from "k6/http";
|
|
||||||
import { check, sleep, group } from "k6";
|
|
||||||
import { Counter, Trend } from "k6/metrics";
|
|
||||||
|
|
||||||
// ---------------- Configuration ----------------
|
|
||||||
export const options = {
|
|
||||||
// Adjust vus/duration for your environment
|
|
||||||
scenarios: {
|
|
||||||
steady_mutations: {
|
|
||||||
executor: "constant-vus",
|
|
||||||
vus: __ENV.VUS ? parseInt(__ENV.VUS, 10) : 20,
|
|
||||||
duration: __ENV.DURATION || "5m",
|
|
||||||
gracefulStop: "30s",
|
|
||||||
},
|
|
||||||
ramp_up: {
|
|
||||||
executor: "ramping-vus",
|
|
||||||
startVUs: 0,
|
|
||||||
stages: [
|
|
||||||
{
|
|
||||||
duration: "1m",
|
|
||||||
target: __ENV.RAMP_TARGET ? parseInt(__ENV.RAMP_TARGET, 10) : 50,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
duration: "1m",
|
|
||||||
target: __ENV.RAMP_TARGET ? parseInt(__ENV.RAMP_TARGET, 10) : 50,
|
|
||||||
},
|
|
||||||
{ duration: "1m", target: 0 },
|
|
||||||
],
|
|
||||||
gracefulStop: "30s",
|
|
||||||
startTime: "30s",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
thresholds: {
|
|
||||||
http_req_failed: ["rate<0.02"], // < 2% failures
|
|
||||||
http_req_duration: ["p(90)<800", "p(99)<1500"], // latency SLO
|
|
||||||
"cart_add_item_duration{op:add}": ["p(90)<800"],
|
|
||||||
"cart_fetch_duration{op:get}": ["p(90)<600"],
|
|
||||||
},
|
|
||||||
summaryTrendStats: ["avg", "min", "med", "max", "p(90)", "p(95)", "p(99)"],
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------- Metrics ----------------
|
|
||||||
const addItemTrend = new Trend("cart_add_item_duration", true);
|
|
||||||
const fetchTrend = new Trend("cart_fetch_duration", true);
|
|
||||||
const checkoutTrend = new Trend("cart_checkout_duration", true);
|
|
||||||
const addedItemsCounter = new Counter("cart_items_added");
|
|
||||||
const checkoutCounter = new Counter("cart_checkout_calls");
|
|
||||||
|
|
||||||
// ---------------- SKUs ----------------
|
|
||||||
const SKUS = [
|
|
||||||
"778290",
|
|
||||||
"778345",
|
|
||||||
"778317",
|
|
||||||
"778277",
|
|
||||||
"778267",
|
|
||||||
"778376",
|
|
||||||
"778244",
|
|
||||||
"778384",
|
|
||||||
"778365",
|
|
||||||
"778377",
|
|
||||||
"778255",
|
|
||||||
"778286",
|
|
||||||
"778246",
|
|
||||||
"778270",
|
|
||||||
"778266",
|
|
||||||
"778285",
|
|
||||||
"778329",
|
|
||||||
"778425",
|
|
||||||
"778407",
|
|
||||||
"778418",
|
|
||||||
"778430",
|
|
||||||
"778469",
|
|
||||||
"778358",
|
|
||||||
"778351",
|
|
||||||
"778319",
|
|
||||||
"778307",
|
|
||||||
"778278",
|
|
||||||
"778251",
|
|
||||||
"778253",
|
|
||||||
"778261",
|
|
||||||
"778263",
|
|
||||||
"778273",
|
|
||||||
"778281",
|
|
||||||
"778294",
|
|
||||||
"778297",
|
|
||||||
"778302",
|
|
||||||
];
|
|
||||||
|
|
||||||
// ---------------- Helpers ----------------
|
|
||||||
function randomSku() {
|
|
||||||
return SKUS[Math.floor(Math.random() * SKUS.length)];
|
|
||||||
}
|
|
||||||
function randomQty() {
|
|
||||||
return 1 + Math.floor(Math.random() * 3); // 1..3
|
|
||||||
}
|
|
||||||
function baseUrl() {
|
|
||||||
const u = __ENV.BASE_URL || "http://localhost:8080/cart";
|
|
||||||
// Allow user to pass either root host or full /cart path
|
|
||||||
return u.endsWith("/cart") ? u : u.replace(/\/+$/, "") + "/cart";
|
|
||||||
}
|
|
||||||
function extractCookie(res, name) {
|
|
||||||
const cookies = res.cookies[name];
|
|
||||||
if (!cookies || cookies.length === 0) return null;
|
|
||||||
return cookies[0].value;
|
|
||||||
}
|
|
||||||
function withCookies(headers, cookieJar) {
|
|
||||||
if (!cookieJar || Object.keys(cookieJar).length === 0) return headers;
|
|
||||||
const cookieStr = Object.entries(cookieJar)
|
|
||||||
.map(([k, v]) => `${k}=${v}`)
|
|
||||||
.join("; ");
|
|
||||||
return { ...headers, Cookie: cookieStr };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Maintain cart + owner cookies per VU
|
|
||||||
let cartState = {
|
|
||||||
cartid: null,
|
|
||||||
cartowner: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Refresh cookies from response
|
|
||||||
function updateCookies(res) {
|
|
||||||
const cid = extractCookie(res, "cartid");
|
|
||||||
if (cid) cartState.cartid = cid;
|
|
||||||
const owner = extractCookie(res, "cartowner");
|
|
||||||
if (owner) cartState.cartowner = owner;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build headers
|
|
||||||
function headers() {
|
|
||||||
const h = { "Content-Type": "application/json" };
|
|
||||||
const jar = {};
|
|
||||||
if (cartState.cartid) jar["cartid"] = cartState.cartid;
|
|
||||||
if (cartState.cartowner) jar["cartowner"] = cartState.cartowner;
|
|
||||||
return withCookies(h, jar);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure cart exists (GET /)
|
|
||||||
function ensureCart() {
|
|
||||||
if (cartState.cartid) return;
|
|
||||||
const res = http.get(baseUrl(), { headers: headers() });
|
|
||||||
updateCookies(res);
|
|
||||||
check(res, {
|
|
||||||
"ensure cart status 200": (r) => r.status === 200,
|
|
||||||
"ensure cart has id": () => !!cartState.cartid,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add random item
|
|
||||||
function addRandomItem() {
|
|
||||||
const payload = JSON.stringify({
|
|
||||||
sku: randomSku(),
|
|
||||||
quantity: randomQty(),
|
|
||||||
country: "no",
|
|
||||||
});
|
|
||||||
const start = Date.now();
|
|
||||||
const res = http.post(baseUrl(), payload, { headers: headers() });
|
|
||||||
const dur = Date.now() - start;
|
|
||||||
addItemTrend.add(dur, { op: "add" });
|
|
||||||
if (res.status === 200) {
|
|
||||||
addedItemsCounter.add(1);
|
|
||||||
}
|
|
||||||
updateCookies(res);
|
|
||||||
check(res, {
|
|
||||||
"add item status ok": (r) => r.status === 200,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch cart state
|
|
||||||
function fetchCart() {
|
|
||||||
const start = Date.now();
|
|
||||||
const res = http.get(baseUrl(), { headers: headers() });
|
|
||||||
const dur = Date.now() - start;
|
|
||||||
fetchTrend.add(dur, { op: "get" });
|
|
||||||
updateCookies(res);
|
|
||||||
check(res, { "fetch status ok": (r) => r.status === 200 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Occasional checkout trigger
|
|
||||||
function maybeCheckout() {
|
|
||||||
if (!cartState.cartid) return;
|
|
||||||
// // Small probability
|
|
||||||
// if (Math.random() < 0.02) {
|
|
||||||
// const start = Date.now();
|
|
||||||
// const res = http.get(baseUrl() + "/checkout", { headers: headers() });
|
|
||||||
// const dur = Date.now() - start;
|
|
||||||
// checkoutTrend.add(dur, { op: "checkout" });
|
|
||||||
// updateCookies(res);
|
|
||||||
// if (res.status === 200) checkoutCounter.add(1);
|
|
||||||
// check(res, { "checkout status ok": (r) => r.status === 200 });
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------- k6 lifecycle ----------------
|
|
||||||
export function setup() {
|
|
||||||
// Provide SKU list length for summary
|
|
||||||
return { skuCount: SKUS.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function (data) {
|
|
||||||
group("cart flow", () => {
|
|
||||||
// Create or reuse cart
|
|
||||||
ensureCart();
|
|
||||||
|
|
||||||
// Random number of item mutations per iteration (1..5)
|
|
||||||
const ops = 1 + Math.floor(Math.random() * 5);
|
|
||||||
for (let i = 0; i < ops; i++) {
|
|
||||||
addRandomItem();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch state
|
|
||||||
fetchCart();
|
|
||||||
|
|
||||||
// Optional checkout attempt
|
|
||||||
maybeCheckout();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Small think time
|
|
||||||
sleep(Math.random() * 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function teardown(data) {
|
|
||||||
// Optionally we could GET confirmation or clear cart cookie
|
|
||||||
// Not implemented for load purpose.
|
|
||||||
console.log(`Test complete. SKU count: ${data.skuCount}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------- Summary ----------------
|
|
||||||
export function handleSummary(data) {
|
|
||||||
return {
|
|
||||||
stdout: JSON.stringify(
|
|
||||||
{
|
|
||||||
metrics: {
|
|
||||||
mutations_avg: data.metrics.cart_add_item_duration?.avg,
|
|
||||||
mutations_p95: data.metrics.cart_add_item_duration?.p(95),
|
|
||||||
fetch_p95: data.metrics.cart_fetch_duration?.p(95),
|
|
||||||
checkout_count: data.metrics.cart_checkout_calls?.count,
|
|
||||||
},
|
|
||||||
checks: data.root_checks,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -31,14 +30,11 @@ const (
|
|||||||
KlarnaPlaygroundUrl = "https://api.playground.klarna.com"
|
KlarnaPlaygroundUrl = "https://api.playground.klarna.com"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (k *KlarnaClient) GetOrder(ctx context.Context, orderId string) (*CheckoutOrder, error) {
|
func (k *KlarnaClient) GetOrder(orderId string) (*CheckoutOrder, error) {
|
||||||
req, err := http.NewRequest("GET", k.Url+"/checkout/v3/orders/"+orderId, nil)
|
req, err := http.NewRequest("GET", k.Url+"/checkout/v3/orders/"+orderId, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
spanCtx, span := tracer.Start(ctx, "Get klarna order")
|
|
||||||
defer span.End()
|
|
||||||
req = req.WithContext(spanCtx)
|
|
||||||
req.Header.Add("Content-Type", "application/json")
|
req.Header.Add("Content-Type", "application/json")
|
||||||
req.SetBasicAuth(k.UserName, k.Password)
|
req.SetBasicAuth(k.UserName, k.Password)
|
||||||
|
|
||||||
@@ -68,19 +64,17 @@ func (k *KlarnaClient) getOrderResponse(res *http.Response) (*CheckoutOrder, err
|
|||||||
return nil, fmt.Errorf("%s", res.Status)
|
return nil, fmt.Errorf("%s", res.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *KlarnaClient) CreateOrder(ctx context.Context, reader io.Reader) (*CheckoutOrder, error) {
|
func (k *KlarnaClient) CreateOrder(reader io.Reader) (*CheckoutOrder, error) {
|
||||||
//bytes.NewReader(reply.Payload)
|
//bytes.NewReader(reply.Payload)
|
||||||
req, err := http.NewRequest("POST", k.Url+"/checkout/v3/orders", reader)
|
req, err := http.NewRequest("POST", k.Url+"/checkout/v3/orders", reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
spanCtx, span := tracer.Start(ctx, "Create klarna order")
|
|
||||||
defer span.End()
|
|
||||||
req = req.WithContext(spanCtx)
|
|
||||||
req.Header.Add("Content-Type", "application/json")
|
req.Header.Add("Content-Type", "application/json")
|
||||||
req.SetBasicAuth(k.UserName, k.Password)
|
req.SetBasicAuth(k.UserName, k.Password)
|
||||||
|
|
||||||
res, err := k.client.Do(req)
|
res, err := http.DefaultClient.Do(req)
|
||||||
if nil != err {
|
if nil != err {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -88,20 +82,17 @@ func (k *KlarnaClient) CreateOrder(ctx context.Context, reader io.Reader) (*Chec
|
|||||||
return k.getOrderResponse(res)
|
return k.getOrderResponse(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *KlarnaClient) UpdateOrder(ctx context.Context, orderId string, reader io.Reader) (*CheckoutOrder, error) {
|
func (k *KlarnaClient) UpdateOrder(orderId string, reader io.Reader) (*CheckoutOrder, error) {
|
||||||
//bytes.NewReader(reply.Payload)
|
//bytes.NewReader(reply.Payload)
|
||||||
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s", k.Url, orderId), reader)
|
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s", k.Url, orderId), reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
spanCtx, span := tracer.Start(ctx, "Update klarna order")
|
|
||||||
defer span.End()
|
|
||||||
req = req.WithContext(spanCtx)
|
|
||||||
req.Header.Add("Content-Type", "application/json")
|
req.Header.Add("Content-Type", "application/json")
|
||||||
req.SetBasicAuth(k.UserName, k.Password)
|
req.SetBasicAuth(k.UserName, k.Password)
|
||||||
|
|
||||||
res, err := k.client.Do(req)
|
res, err := http.DefaultClient.Do(req)
|
||||||
if nil != err {
|
if nil != err {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -109,34 +100,29 @@ func (k *KlarnaClient) UpdateOrder(ctx context.Context, orderId string, reader i
|
|||||||
return k.getOrderResponse(res)
|
return k.getOrderResponse(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *KlarnaClient) AbortOrder(ctx context.Context, orderId string) error {
|
func (k *KlarnaClient) AbortOrder(orderId string) error {
|
||||||
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s/abort", k.Url, orderId), nil)
|
req, err := http.NewRequest("POST", fmt.Sprintf("%s/checkout/v3/orders/%s/abort", k.Url, orderId), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
spanCtx, span := tracer.Start(ctx, "Abort klarna order")
|
|
||||||
defer span.End()
|
|
||||||
req = req.WithContext(spanCtx)
|
|
||||||
req.SetBasicAuth(k.UserName, k.Password)
|
req.SetBasicAuth(k.UserName, k.Password)
|
||||||
|
|
||||||
_, err = k.client.Do(req)
|
_, err = http.DefaultClient.Do(req)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ordermanagement/v1/orders/{order_id}/acknowledge
|
// ordermanagement/v1/orders/{order_id}/acknowledge
|
||||||
func (k *KlarnaClient) AcknowledgeOrder(ctx context.Context, orderId string) error {
|
func (k *KlarnaClient) AcknowledgeOrder(orderId string) error {
|
||||||
req, err := http.NewRequest("POST", fmt.Sprintf("%s/ordermanagement/v1/orders/%s/acknowledge", k.Url, orderId), nil)
|
req, err := http.NewRequest("POST", fmt.Sprintf("%s/ordermanagement/v1/orders/%s/acknowledge", k.Url, orderId), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
spanCtx, span := tracer.Start(ctx, "Acknowledge klarna order")
|
|
||||||
defer span.End()
|
|
||||||
req = req.WithContext(spanCtx)
|
|
||||||
id := uuid.New()
|
id := uuid.New()
|
||||||
|
|
||||||
req.SetBasicAuth(k.UserName, k.Password)
|
req.SetBasicAuth(k.UserName, k.Password)
|
||||||
req.Header.Add("Klarna-Idempotency-Key", id.String())
|
req.Header.Add("Klarna-Idempotency-Key", id.String())
|
||||||
|
|
||||||
_, err = k.client.Do(req)
|
_, err = http.DefaultClient.Do(req)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
410
main.go
Normal file
410
main.go
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/http/pprof"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
|
"k8s.io/client-go/rest"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
grainSpawns = promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "cart_grain_spawned_total",
|
||||||
|
Help: "The total number of spawned grains",
|
||||||
|
})
|
||||||
|
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "cart_grain_mutations_total",
|
||||||
|
Help: "The total number of mutations",
|
||||||
|
})
|
||||||
|
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: "cart_grain_lookups_total",
|
||||||
|
Help: "The total number of lookups",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
func spawn(id CartId) (*CartGrain, error) {
|
||||||
|
grainSpawns.Inc()
|
||||||
|
ret := &CartGrain{
|
||||||
|
lastItemId: 0,
|
||||||
|
lastDeliveryId: 0,
|
||||||
|
Deliveries: []*CartDelivery{},
|
||||||
|
Id: id,
|
||||||
|
Items: []*CartItem{},
|
||||||
|
// storageMessages removed (legacy event log deprecated)
|
||||||
|
TotalPrice: 0,
|
||||||
|
}
|
||||||
|
err := loadMessages(ret, id)
|
||||||
|
return ret, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
os.Mkdir("data", 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
pool *GrainLocalPool
|
||||||
|
storage *DiskStorage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Save() error {
|
||||||
|
hasChanges := false
|
||||||
|
a.pool.mu.RLock()
|
||||||
|
defer a.pool.mu.RUnlock()
|
||||||
|
for id, grain := range a.pool.GetGrains() {
|
||||||
|
if grain == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if grain.GetLastChange() > a.storage.LastSaves[id] {
|
||||||
|
hasChanges = true
|
||||||
|
err := a.storage.Store(id, grain)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error saving grain %s: %v\n", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasChanges {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return a.storage.saveState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) HandleSave(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := a.Save()
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var podIp = os.Getenv("POD_IP")
|
||||||
|
var name = os.Getenv("POD_NAME")
|
||||||
|
var amqpUrl = os.Getenv("AMQP_URL")
|
||||||
|
var KlarnaInstance = NewKlarnaClient(KlarnaPlaygroundUrl, os.Getenv("KLARNA_API_USERNAME"), os.Getenv("KLARNA_API_PASSWORD"))
|
||||||
|
|
||||||
|
var tpl = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>s10r testing - checkout</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
%s
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|
||||||
|
func getCountryFromHost(host string) string {
|
||||||
|
if strings.Contains(strings.ToLower(host), "-no") {
|
||||||
|
return "no"
|
||||||
|
}
|
||||||
|
return "se"
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCheckoutOrder(host string, cartId CartId) *messages.CreateCheckoutOrder {
|
||||||
|
baseUrl := fmt.Sprintf("https://%s", host)
|
||||||
|
cartBaseUrl := os.Getenv("CART_BASE_URL")
|
||||||
|
if cartBaseUrl == "" {
|
||||||
|
cartBaseUrl = "https://cart.tornberg.me"
|
||||||
|
}
|
||||||
|
country := getCountryFromHost(host)
|
||||||
|
|
||||||
|
return &messages.CreateCheckoutOrder{
|
||||||
|
Terms: fmt.Sprintf("%s/terms", baseUrl),
|
||||||
|
Checkout: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}", baseUrl),
|
||||||
|
Confirmation: fmt.Sprintf("%s/confirmation/{checkout.order.id}", baseUrl),
|
||||||
|
Validation: fmt.Sprintf("%s/validation", cartBaseUrl),
|
||||||
|
Push: fmt.Sprintf("%s/push?order_id={checkout.order.id}", cartBaseUrl),
|
||||||
|
Country: country,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetDiscovery() Discovery {
|
||||||
|
if podIp == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
config, kerr := rest.InClusterConfig()
|
||||||
|
|
||||||
|
if kerr != nil {
|
||||||
|
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
|
||||||
|
}
|
||||||
|
client, err := kubernetes.NewForConfig(config)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error creating client: %v\n", err)
|
||||||
|
}
|
||||||
|
return NewK8sDiscovery(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
|
||||||
|
storage, err := NewDiskStorage(fmt.Sprintf("data/%s_state.gob", name))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error loading state: %v\n", err)
|
||||||
|
}
|
||||||
|
app := &App{
|
||||||
|
pool: NewGrainLocalPool(65535, 5*time.Minute, spawn),
|
||||||
|
storage: storage,
|
||||||
|
}
|
||||||
|
|
||||||
|
syncedPool, err := NewSyncedPool(app.pool, podIp, GetDiscovery())
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error creating synced pool: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start unified gRPC server (CartActor + ControlPlane) replacing legacy RPC server on :1337
|
||||||
|
// TODO: Remove any remaining legacy RPC server references and deprecated frame-based code after full gRPC migration is validated.
|
||||||
|
grpcSrv, err := StartGRPCServer(":1337", app.pool, syncedPool)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error starting gRPC server: %v\n", err)
|
||||||
|
}
|
||||||
|
defer grpcSrv.GracefulStop()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for range time.Tick(time.Minute * 10) {
|
||||||
|
err := app.Save()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error saving: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
orderHandler := &AmqpOrderHandler{
|
||||||
|
Url: amqpUrl,
|
||||||
|
}
|
||||||
|
|
||||||
|
syncedServer := NewPoolServer(syncedPool, fmt.Sprintf("%s, %s", name, podIp))
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("/cart/", http.StripPrefix("/cart", syncedServer.Serve()))
|
||||||
|
// only for local
|
||||||
|
// mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// syncedPool.AddRemote(r.PathValue("host"))
|
||||||
|
// })
|
||||||
|
// mux.HandleFunc("GET /save", app.HandleSave)
|
||||||
|
//mux.HandleFunc("/", app.RewritePath)
|
||||||
|
mux.HandleFunc("/debug/pprof/", pprof.Index)
|
||||||
|
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||||
|
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
||||||
|
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
||||||
|
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||||
|
mux.Handle("/metrics", promhttp.Handler())
|
||||||
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Grain pool health: simple capacity check (mirrors previous GrainHandler.IsHealthy)
|
||||||
|
app.pool.mu.RLock()
|
||||||
|
grainCount := len(app.pool.grains)
|
||||||
|
capacity := app.pool.PoolSize
|
||||||
|
app.pool.mu.RUnlock()
|
||||||
|
if grainCount >= capacity {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte("grain pool at capacity"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !syncedPool.IsHealthy() {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte("control plane not healthy"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/checkout", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
orderId := r.URL.Query().Get("order_id")
|
||||||
|
order := &CheckoutOrder{}
|
||||||
|
|
||||||
|
if orderId == "" {
|
||||||
|
cookie, err := r.Cookie("cartid")
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cookie.Value == "" {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte("no cart id to checkout is empty"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cartId := ToCartId(cookie.Value)
|
||||||
|
_, err = syncedServer.pool.Apply(cartId, getCheckoutOrder(r.Host, cartId))
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
}
|
||||||
|
// v2: Apply now returns *CartGrain; order creation handled inside grain (no payload to unmarshal)
|
||||||
|
} else {
|
||||||
|
prevOrder, err := KlarnaInstance.GetOrder(orderId)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
order = prevOrder
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(fmt.Sprintf(tpl, order.HTMLSnippet)))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
orderId := r.PathValue("order_id")
|
||||||
|
order, err := KlarnaInstance.GetOrder(orderId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if order.Status == "checkout_complete" {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "cartid",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
Secure: true,
|
||||||
|
HttpOnly: true,
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(fmt.Sprintf(tpl, order.HTMLSnippet)))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/validate", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Printf("Klarna order validation, method: %s", r.Method)
|
||||||
|
if r.Method != "POST" {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
order := &CheckoutOrder{}
|
||||||
|
err := json.NewDecoder(r.Body).Decode(order)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
log.Printf("Klarna order validation: %s", order.ID)
|
||||||
|
//err = confirmOrder(order, orderHandler)
|
||||||
|
//if err != nil {
|
||||||
|
// log.Printf("Error validating order: %v\n", err)
|
||||||
|
// w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
// return
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//err = triggerOrderCompleted(err, syncedServer, order)
|
||||||
|
//if err != nil {
|
||||||
|
// log.Printf("Error processing cart message: %v\n", err)
|
||||||
|
// w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
// return
|
||||||
|
//}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/push", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orderId := r.URL.Query().Get("order_id")
|
||||||
|
log.Printf("Order confirmation push: %s", orderId)
|
||||||
|
|
||||||
|
order, err := KlarnaInstance.GetOrder(orderId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error creating request: %v\n", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = confirmOrder(order, orderHandler)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error confirming order: %v\n", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = triggerOrderCompleted(err, syncedServer, order)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error processing cart message: %v\n", err)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = KlarnaInstance.AcknowledgeOrder(orderId)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error acknowledging order: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("1.0.0"))
|
||||||
|
})
|
||||||
|
|
||||||
|
sigs := make(chan os.Signal, 1)
|
||||||
|
done := make(chan bool, 1)
|
||||||
|
signal.Notify(sigs, syscall.SIGTERM)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
sig := <-sigs
|
||||||
|
fmt.Println("Shutting down due to signal:", sig)
|
||||||
|
go syncedPool.Close()
|
||||||
|
app.Save()
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Print("Server started at port 8083")
|
||||||
|
go http.ListenAndServe(":8083", mux)
|
||||||
|
<-done
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func triggerOrderCompleted(err error, syncedServer *PoolServer, order *CheckoutOrder) error {
|
||||||
|
_, err = syncedServer.pool.Apply(ToCartId(order.MerchantReference1), &messages.OrderCreated{
|
||||||
|
OrderId: order.ID,
|
||||||
|
Status: order.Status,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func confirmOrder(order *CheckoutOrder, orderHandler *AmqpOrderHandler) error {
|
||||||
|
orderToSend, err := json.Marshal(order)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = orderHandler.Connect()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer orderHandler.Close()
|
||||||
|
err = orderHandler.OrderCompleted(orderToSend)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
82
mutation_add_item.go
Normal file
82
mutation_add_item.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_add_item.go
|
||||||
|
//
|
||||||
|
// Registers the AddItem cart mutation in the generic mutation registry.
|
||||||
|
// This replaces the legacy switch-based logic previously found in CartGrain.Apply.
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// * Validates quantity > 0
|
||||||
|
// * If an item with same SKU exists -> increases quantity
|
||||||
|
// * Else creates a new CartItem with computed tax amounts
|
||||||
|
// * Totals recalculated automatically via WithTotals()
|
||||||
|
//
|
||||||
|
// NOTE: Any future field additions in messages.AddItem that affect pricing / tax
|
||||||
|
// must keep this handler in sync.
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterMutation[messages.AddItem](
|
||||||
|
"AddItem",
|
||||||
|
func(g *CartGrain, m *messages.AddItem) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("AddItem: nil payload")
|
||||||
|
}
|
||||||
|
if m.Quantity < 1 {
|
||||||
|
return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast path: merge with existing item having same SKU
|
||||||
|
if existing, found := g.FindItemWithSku(m.Sku); found {
|
||||||
|
existing.Quantity += int(m.Quantity)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
|
||||||
|
g.lastItemId++
|
||||||
|
taxRate := 2500
|
||||||
|
if m.Tax > 0 {
|
||||||
|
taxRate = int(m.Tax)
|
||||||
|
}
|
||||||
|
taxAmountPerUnit := GetTaxAmount(m.Price, taxRate)
|
||||||
|
|
||||||
|
g.Items = append(g.Items, &CartItem{
|
||||||
|
Id: g.lastItemId,
|
||||||
|
ItemId: int(m.ItemId),
|
||||||
|
Quantity: int(m.Quantity),
|
||||||
|
Sku: m.Sku,
|
||||||
|
Name: m.Name,
|
||||||
|
Price: m.Price,
|
||||||
|
TotalPrice: m.Price * int64(m.Quantity),
|
||||||
|
TotalTax: int64(taxAmountPerUnit * int64(m.Quantity)),
|
||||||
|
Image: m.Image,
|
||||||
|
Stock: StockStatus(m.Stock),
|
||||||
|
Disclaimer: m.Disclaimer,
|
||||||
|
Brand: m.Brand,
|
||||||
|
Category: m.Category,
|
||||||
|
Category2: m.Category2,
|
||||||
|
Category3: m.Category3,
|
||||||
|
Category4: m.Category4,
|
||||||
|
Category5: m.Category5,
|
||||||
|
OrgPrice: m.OrgPrice,
|
||||||
|
ArticleType: m.ArticleType,
|
||||||
|
Outlet: m.Outlet,
|
||||||
|
SellerId: m.SellerId,
|
||||||
|
SellerName: m.SellerName,
|
||||||
|
Tax: int(taxAmountPerUnit),
|
||||||
|
TaxRate: taxRate,
|
||||||
|
StoreId: m.StoreId,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
WithTotals(), // Recalculate totals after successful mutation
|
||||||
|
)
|
||||||
|
}
|
||||||
61
mutation_add_request.go
Normal file
61
mutation_add_request.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_add_request.go
|
||||||
|
//
|
||||||
|
// Registers the AddRequest mutation. This mutation is a higher-level intent
|
||||||
|
// (add by SKU + quantity) which may translate into either:
|
||||||
|
// - Increasing quantity of an existing line (same SKU), OR
|
||||||
|
// - Creating a new item by performing a product lookup (via getItemData inside CartGrain.AddItem)
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - Validates non-empty SKU and quantity > 0
|
||||||
|
// - If an item with the SKU already exists: increments its quantity
|
||||||
|
// - Else delegates to CartGrain.AddItem (which itself produces an AddItem mutation)
|
||||||
|
// - Totals recalculated automatically (WithTotals)
|
||||||
|
//
|
||||||
|
// NOTE:
|
||||||
|
// - This handler purposely avoids duplicating the detailed AddItem logic;
|
||||||
|
// it reuses CartGrain.AddItem which then flows through the AddItem mutation
|
||||||
|
// registry handler.
|
||||||
|
// - Double total recalculation can occur (AddItem has WithTotals too), but
|
||||||
|
// is acceptable for clarity. Optimize later if needed.
|
||||||
|
//
|
||||||
|
// Potential future improvements:
|
||||||
|
// - Stock validation before increasing quantity
|
||||||
|
// - Reservation logic or concurrency guards around stock updates
|
||||||
|
// - Coupon / pricing rules applied conditionally during add-by-sku
|
||||||
|
func init() {
|
||||||
|
RegisterMutation[messages.AddRequest](
|
||||||
|
"AddRequest",
|
||||||
|
func(g *CartGrain, m *messages.AddRequest) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("AddRequest: nil payload")
|
||||||
|
}
|
||||||
|
if m.Sku == "" {
|
||||||
|
return fmt.Errorf("AddRequest: sku is empty")
|
||||||
|
}
|
||||||
|
if m.Quantity < 1 {
|
||||||
|
return fmt.Errorf("AddRequest: invalid quantity %d", m.Quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing line: accumulate quantity only.
|
||||||
|
if existing, found := g.FindItemWithSku(m.Sku); found {
|
||||||
|
existing.Quantity += int(m.Quantity)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// New line: delegate to higher-level AddItem flow (product lookup).
|
||||||
|
// We intentionally ignore the returned *CartGrain; registry will
|
||||||
|
// do totals again after this handler returns (harmless).
|
||||||
|
_, err := g.AddItem(m.Sku, int(m.Quantity), m.Country, m.StoreId)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
WithTotals(),
|
||||||
|
)
|
||||||
|
}
|
||||||
58
mutation_change_quantity.go
Normal file
58
mutation_change_quantity.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_change_quantity.go
|
||||||
|
//
|
||||||
|
// Registers the ChangeQuantity mutation.
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - Locates an item by its cart-local line item Id (not source item_id).
|
||||||
|
// - If requested quantity <= 0 the line is removed.
|
||||||
|
// - Otherwise the line's Quantity field is updated.
|
||||||
|
// - Totals are recalculated (WithTotals).
|
||||||
|
//
|
||||||
|
// Error handling:
|
||||||
|
// - Returns an error if the item Id is not found.
|
||||||
|
// - Returns an error if payload is nil (defensive).
|
||||||
|
//
|
||||||
|
// Concurrency:
|
||||||
|
// - Uses the grain's RW-safe mutation pattern: we mutate in place under
|
||||||
|
// the grain's implicit expectation that higher layers control access.
|
||||||
|
// (If strict locking is required around every mutation, wrap logic in
|
||||||
|
// an explicit g.mu.Lock()/Unlock(), but current model mirrors prior code.)
|
||||||
|
func init() {
|
||||||
|
RegisterMutation[messages.ChangeQuantity](
|
||||||
|
"ChangeQuantity",
|
||||||
|
func(g *CartGrain, m *messages.ChangeQuantity) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("ChangeQuantity: nil payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
foundIndex := -1
|
||||||
|
for i, it := range g.Items {
|
||||||
|
if it.Id == int(m.Id) {
|
||||||
|
foundIndex = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if foundIndex == -1 {
|
||||||
|
return fmt.Errorf("ChangeQuantity: item id %d not found", m.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Quantity <= 0 {
|
||||||
|
// Remove the item
|
||||||
|
g.Items = append(g.Items[:foundIndex], g.Items[foundIndex+1:]...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Items[foundIndex].Quantity = int(m.Quantity)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
WithTotals(),
|
||||||
|
)
|
||||||
|
}
|
||||||
49
mutation_initialize_checkout.go
Normal file
49
mutation_initialize_checkout.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_initialize_checkout.go
|
||||||
|
//
|
||||||
|
// Registers the InitializeCheckout mutation.
|
||||||
|
// This mutation is invoked AFTER an external Klarna checkout session
|
||||||
|
// has been successfully created or updated. It persists the Klarna
|
||||||
|
// order reference / status and marks the cart as having a payment in progress.
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - Sets OrderReference to the Klarna order ID (overwriting if already set).
|
||||||
|
// - Sets PaymentStatus to the current Klarna status.
|
||||||
|
// - Sets / updates PaymentInProgress flag.
|
||||||
|
// - Does NOT alter pricing or line items (so no totals recalculation).
|
||||||
|
//
|
||||||
|
// Validation:
|
||||||
|
// - Returns an error if payload is nil.
|
||||||
|
// - Returns an error if orderId is empty (integrity guard).
|
||||||
|
//
|
||||||
|
// Concurrency:
|
||||||
|
// - Relies on upstream mutation serialization for a single grain. If
|
||||||
|
// parallel checkout attempts are possible, add higher-level guards
|
||||||
|
// (e.g. reject if PaymentInProgress already true unless reusing
|
||||||
|
// the same OrderReference).
|
||||||
|
func init() {
|
||||||
|
RegisterMutation[messages.InitializeCheckout](
|
||||||
|
"InitializeCheckout",
|
||||||
|
func(g *CartGrain, m *messages.InitializeCheckout) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("InitializeCheckout: nil payload")
|
||||||
|
}
|
||||||
|
if m.OrderId == "" {
|
||||||
|
return fmt.Errorf("InitializeCheckout: missing orderId")
|
||||||
|
}
|
||||||
|
|
||||||
|
g.OrderReference = m.OrderId
|
||||||
|
g.PaymentStatus = m.Status
|
||||||
|
g.PaymentInProgress = m.PaymentInProgress
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
// No WithTotals(): monetary aggregates are unaffected.
|
||||||
|
)
|
||||||
|
}
|
||||||
53
mutation_order_created.go
Normal file
53
mutation_order_created.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_order_created.go
|
||||||
|
//
|
||||||
|
// Registers the OrderCreated mutation.
|
||||||
|
//
|
||||||
|
// This mutation represents the completion (or state transition) of an order
|
||||||
|
// initiated earlier via InitializeCheckout / external Klarna processing.
|
||||||
|
// It finalizes (or updates) the cart's order metadata.
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - Validates payload non-nil and OrderId not empty.
|
||||||
|
// - Sets (or overwrites) OrderReference with the provided OrderId.
|
||||||
|
// - Sets PaymentStatus from payload.Status.
|
||||||
|
// - Marks PaymentInProgress = false (checkout flow finished / acknowledged).
|
||||||
|
// - Does NOT adjust monetary totals (no WithTotals()).
|
||||||
|
//
|
||||||
|
// Notes / Future Extensions:
|
||||||
|
// - If multiple order completion events can arrive (e.g., retries / webhook
|
||||||
|
// replays), this handler is idempotent: it simply overwrites fields.
|
||||||
|
// - If you need to guard against conflicting order IDs, add a check:
|
||||||
|
// if g.OrderReference != "" && g.OrderReference != m.OrderId { ... }
|
||||||
|
// - Add audit logging or metrics here if required.
|
||||||
|
//
|
||||||
|
// Concurrency:
|
||||||
|
// - Relies on the higher-level guarantee that Apply() calls are serialized
|
||||||
|
// per grain. If out-of-order events are possible, embed versioning or
|
||||||
|
// timestamps in the mutation and compare before applying changes.
|
||||||
|
func init() {
|
||||||
|
RegisterMutation[messages.OrderCreated](
|
||||||
|
"OrderCreated",
|
||||||
|
func(g *CartGrain, m *messages.OrderCreated) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("OrderCreated: nil payload")
|
||||||
|
}
|
||||||
|
if m.OrderId == "" {
|
||||||
|
return fmt.Errorf("OrderCreated: missing orderId")
|
||||||
|
}
|
||||||
|
|
||||||
|
g.OrderReference = m.OrderId
|
||||||
|
g.PaymentStatus = m.Status
|
||||||
|
g.PaymentInProgress = false
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
// No WithTotals(): order completion does not modify pricing or taxes.
|
||||||
|
)
|
||||||
|
}
|
||||||
301
mutation_registry.go
Normal file
301
mutation_registry.go
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_registry.go
|
||||||
|
//
|
||||||
|
// Mutation Registry Infrastructure
|
||||||
|
// --------------------------------
|
||||||
|
// This file introduces a generic registry for cart mutations that:
|
||||||
|
//
|
||||||
|
// 1. Decouples mutation logic from the large type-switch inside CartGrain.Apply.
|
||||||
|
// 2. Enforces (at registration time) that every mutation handler has the correct
|
||||||
|
// signature: func(*CartGrain, *T) error
|
||||||
|
// 3. Optionally auto-updates cart totals after a mutation if flagged.
|
||||||
|
// 4. Provides a single authoritative list of registered mutations for
|
||||||
|
// introspection / coverage testing.
|
||||||
|
// 5. Allows incremental migration: you can first register new mutations here,
|
||||||
|
// and later prune the legacy switch cases.
|
||||||
|
//
|
||||||
|
// Usage Pattern
|
||||||
|
// -------------
|
||||||
|
// // Define your mutation proto message (e.g. messages.ApplyCoupon in messages.proto)
|
||||||
|
// // Regenerate protobufs.
|
||||||
|
//
|
||||||
|
// // In an init() (ideally in a small file like mutations_apply_coupon.go)
|
||||||
|
// func init() {
|
||||||
|
// RegisterMutation[*messages.ApplyCoupon](
|
||||||
|
// "ApplyCoupon",
|
||||||
|
// func(g *CartGrain, m *messages.ApplyCoupon) error {
|
||||||
|
// // domain logic ...
|
||||||
|
// discount := int64(5000)
|
||||||
|
// if g.TotalPrice < discount {
|
||||||
|
// discount = g.TotalPrice
|
||||||
|
// }
|
||||||
|
// g.TotalDiscount += discount
|
||||||
|
// g.TotalPrice -= discount
|
||||||
|
// return nil
|
||||||
|
// },
|
||||||
|
// WithTotals(), // we changed price-related fields; recalc totals
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // To invoke dynamically (alternative to the current switch):
|
||||||
|
// if updated, err := ApplyRegistered(grain, incomingMessage); err == nil {
|
||||||
|
// grain = updated
|
||||||
|
// } else if errors.Is(err, ErrMutationNotRegistered) {
|
||||||
|
// // fallback to legacy switch logic
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Migration Strategy
|
||||||
|
// ------------------
|
||||||
|
// 1. For each existing mutation handled in CartGrain.Apply, add a registry
|
||||||
|
// registration with equivalent logic.
|
||||||
|
// 2. Add a test that enumerates all *expected* mutation proto types and asserts
|
||||||
|
// they are present in RegisteredMutationTypes().
|
||||||
|
// 3. Once coverage is 100%, replace the switch in CartGrain.Apply with a call
|
||||||
|
// to ApplyRegistered (and optionally keep a minimal default to produce an
|
||||||
|
// "unsupported mutation" error).
|
||||||
|
//
|
||||||
|
// Thread Safety
|
||||||
|
// -------------
|
||||||
|
// Registration is typically done at init() time; a RWMutex provides safety
|
||||||
|
// should late dynamic registration ever be introduced.
|
||||||
|
//
|
||||||
|
// Auto Totals
|
||||||
|
// -----------
|
||||||
|
// Many mutations require recomputing totals. To avoid forgetting this, pass
|
||||||
|
// WithTotals() when registering. This will invoke grain.UpdateTotals() after
|
||||||
|
// the handler returns successfully.
|
||||||
|
//
|
||||||
|
// Error Semantics
|
||||||
|
// ---------------
|
||||||
|
// - If a handler returns an error, totals are NOT recalculated (even if
|
||||||
|
// WithTotals() was specified).
|
||||||
|
// - ApplyRegistered returns (nil, ErrMutationNotRegistered) if the message type
|
||||||
|
// is absent.
|
||||||
|
//
|
||||||
|
// Extensibility
|
||||||
|
// -------------
|
||||||
|
// It is straightforward to add options like audit hooks, metrics wrappers,
|
||||||
|
// or optimistic concurrency guards by extending MutationOption.
|
||||||
|
//
|
||||||
|
// NOTE: Generics require Go 1.18+. If constrained to earlier Go versions,
|
||||||
|
// replace the generic registration with a non-generic RegisterMutationType
|
||||||
|
// that accepts reflect.Type and an adapter function.
|
||||||
|
//
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
var (
|
||||||
|
mutationRegistryMu sync.RWMutex
|
||||||
|
mutationRegistry = make(map[reflect.Type]*registeredMutation)
|
||||||
|
|
||||||
|
// ErrMutationNotRegistered is returned when no handler exists for a given mutation type.
|
||||||
|
ErrMutationNotRegistered = fmt.Errorf("mutation not registered")
|
||||||
|
)
|
||||||
|
|
||||||
|
// MutationOption configures additional behavior for a registered mutation.
|
||||||
|
type MutationOption func(*mutationOptions)
|
||||||
|
|
||||||
|
// mutationOptions holds flags adjustable per registration.
|
||||||
|
type mutationOptions struct {
|
||||||
|
updateTotals bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTotals ensures CartGrain.UpdateTotals() is called after a successful handler.
|
||||||
|
func WithTotals() MutationOption {
|
||||||
|
return func(o *mutationOptions) {
|
||||||
|
o.updateTotals = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// registeredMutation stores metadata + the execution closure.
|
||||||
|
type registeredMutation struct {
|
||||||
|
name string
|
||||||
|
handler func(*CartGrain, interface{}) error
|
||||||
|
updateTotals bool
|
||||||
|
msgType reflect.Type
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterMutation registers a mutation handler for a specific message type T.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
//
|
||||||
|
// name - a human-readable identifier (used for diagnostics / coverage tests).
|
||||||
|
// handler - business logic operating on the cart grain & strongly typed message.
|
||||||
|
// options - optional behavior flags (e.g., WithTotals()).
|
||||||
|
//
|
||||||
|
// Panics if:
|
||||||
|
// - name is empty
|
||||||
|
// - handler is nil
|
||||||
|
// - duplicate registration for the same message type T
|
||||||
|
//
|
||||||
|
// Typical call is placed in an init() function.
|
||||||
|
func RegisterMutation[T any](name string, handler func(*CartGrain, *T) error, options ...MutationOption) {
|
||||||
|
if name == "" {
|
||||||
|
panic("RegisterMutation: name is required")
|
||||||
|
}
|
||||||
|
if handler == nil {
|
||||||
|
panic("RegisterMutation: handler is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive the reflect.Type for *T then its Elem (T) for mapping.
|
||||||
|
var zero *T
|
||||||
|
rtPtr := reflect.TypeOf(zero)
|
||||||
|
if rtPtr.Kind() != reflect.Ptr {
|
||||||
|
panic("RegisterMutation: expected pointer type for generic parameter")
|
||||||
|
}
|
||||||
|
rt := rtPtr.Elem()
|
||||||
|
|
||||||
|
opts := mutationOptions{}
|
||||||
|
for _, opt := range options {
|
||||||
|
opt(&opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapped := func(g *CartGrain, m interface{}) error {
|
||||||
|
typed, ok := m.(*T)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("mutation type mismatch: have %T want *%s", m, rt.Name())
|
||||||
|
}
|
||||||
|
return handler(g, typed)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutationRegistryMu.Lock()
|
||||||
|
defer mutationRegistryMu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := mutationRegistry[rt]; exists {
|
||||||
|
panic(fmt.Sprintf("RegisterMutation: duplicate registration for type %s", rt.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
mutationRegistry[rt] = ®isteredMutation{
|
||||||
|
name: name,
|
||||||
|
handler: wrapped,
|
||||||
|
updateTotals: opts.updateTotals,
|
||||||
|
msgType: rt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyRegistered attempts to apply a registered mutation.
|
||||||
|
// Returns updated grain if successful.
|
||||||
|
//
|
||||||
|
// If the mutation is not registered, returns (nil, ErrMutationNotRegistered).
|
||||||
|
func ApplyRegistered(grain *CartGrain, msg interface{}) (*CartGrain, error) {
|
||||||
|
if grain == nil {
|
||||||
|
return nil, fmt.Errorf("nil grain")
|
||||||
|
}
|
||||||
|
if msg == nil {
|
||||||
|
return nil, fmt.Errorf("nil mutation message")
|
||||||
|
}
|
||||||
|
|
||||||
|
rt := indirectType(reflect.TypeOf(msg))
|
||||||
|
mutationRegistryMu.RLock()
|
||||||
|
entry, ok := mutationRegistry[rt]
|
||||||
|
mutationRegistryMu.RUnlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrMutationNotRegistered
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := entry.handler(grain, msg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.updateTotals {
|
||||||
|
grain.UpdateTotals()
|
||||||
|
}
|
||||||
|
|
||||||
|
return grain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisteredMutations returns metadata for all registered mutations (snapshot).
|
||||||
|
func RegisteredMutations() []string {
|
||||||
|
mutationRegistryMu.RLock()
|
||||||
|
defer mutationRegistryMu.RUnlock()
|
||||||
|
out := make([]string, 0, len(mutationRegistry))
|
||||||
|
for _, entry := range mutationRegistry {
|
||||||
|
out = append(out, entry.name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisteredMutationTypes returns the reflect.Type list of all registered messages.
|
||||||
|
// Useful for coverage tests ensuring expected set matches actual set.
|
||||||
|
func RegisteredMutationTypes() []reflect.Type {
|
||||||
|
mutationRegistryMu.RLock()
|
||||||
|
defer mutationRegistryMu.RUnlock()
|
||||||
|
out := make([]reflect.Type, 0, len(mutationRegistry))
|
||||||
|
for t := range mutationRegistry {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustAssertMutationCoverage can be called at startup to ensure every expected
|
||||||
|
// mutation type has been registered. It panics with a descriptive message if any
|
||||||
|
// are missing. Provide a slice of prototype pointers (e.g. []*messages.AddItem{nil} ...)
|
||||||
|
func MustAssertMutationCoverage(expected []interface{}) {
|
||||||
|
mutationRegistryMu.RLock()
|
||||||
|
defer mutationRegistryMu.RUnlock()
|
||||||
|
|
||||||
|
missing := make([]string, 0)
|
||||||
|
for _, ex := range expected {
|
||||||
|
if ex == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t := indirectType(reflect.TypeOf(ex))
|
||||||
|
if _, ok := mutationRegistry[t]; !ok {
|
||||||
|
missing = append(missing, t.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
panic(fmt.Sprintf("mutation registry missing handlers for: %v", missing))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// indirectType returns the element type if given a pointer; otherwise the type itself.
|
||||||
|
func indirectType(t reflect.Type) reflect.Type {
|
||||||
|
for t.Kind() == reflect.Ptr {
|
||||||
|
t = t.Elem()
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Integration Guide
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
1. Register all existing mutations:
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterMutation[*messages.AddItem]("AddItem",
|
||||||
|
func(g *CartGrain, m *messages.AddItem) error {
|
||||||
|
// (port logic from existing switch branch)
|
||||||
|
// ...
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
WithTotals(),
|
||||||
|
)
|
||||||
|
// ... repeat for others
|
||||||
|
}
|
||||||
|
|
||||||
|
2. In CartGrain.Apply (early in the method) add:
|
||||||
|
|
||||||
|
if updated, err := ApplyRegistered(c, content); err == nil {
|
||||||
|
return updated, nil
|
||||||
|
} else if err != ErrMutationNotRegistered {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// existing switch fallback below
|
||||||
|
|
||||||
|
3. Once all mutations are registered, remove the legacy switch cases
|
||||||
|
and leave a single ErrMutationNotRegistered path for unknown types.
|
||||||
|
|
||||||
|
4. Add a coverage test (see docs for example; removed from source for clarity).
|
||||||
|
5. (Optional) Add metrics / tracing wrappers for handlers.
|
||||||
|
|
||||||
|
*/
|
||||||
53
mutation_remove_delivery.go
Normal file
53
mutation_remove_delivery.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_remove_delivery.go
|
||||||
|
//
|
||||||
|
// Registers the RemoveDelivery mutation.
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - Removes the delivery entry whose Id == payload.Id.
|
||||||
|
// - If not found, returns an error.
|
||||||
|
// - Cart totals are recalculated (WithTotals) after removal.
|
||||||
|
// - Items previously associated with that delivery simply become "without delivery";
|
||||||
|
// subsequent delivery mutations can reassign them.
|
||||||
|
//
|
||||||
|
// Differences vs legacy:
|
||||||
|
// - Legacy logic decremented TotalPrice explicitly before recalculating.
|
||||||
|
// Here we rely solely on UpdateTotals() to recompute from remaining
|
||||||
|
// deliveries and items (simpler / single source of truth).
|
||||||
|
//
|
||||||
|
// Future considerations:
|
||||||
|
// - If delivery pricing logic changes (e.g., dynamic taxes per delivery),
|
||||||
|
// UpdateTotals() may need enhancement to incorporate delivery tax properly.
|
||||||
|
func init() {
|
||||||
|
RegisterMutation[messages.RemoveDelivery](
|
||||||
|
"RemoveDelivery",
|
||||||
|
func(g *CartGrain, m *messages.RemoveDelivery) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("RemoveDelivery: nil payload")
|
||||||
|
}
|
||||||
|
targetID := int(m.Id)
|
||||||
|
index := -1
|
||||||
|
for i, d := range g.Deliveries {
|
||||||
|
if d.Id == targetID {
|
||||||
|
index = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if index == -1 {
|
||||||
|
return fmt.Errorf("RemoveDelivery: delivery id %d not found", m.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove delivery (order not preserved beyond necessity)
|
||||||
|
g.Deliveries = append(g.Deliveries[:index], g.Deliveries[index+1:]...)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
WithTotals(),
|
||||||
|
)
|
||||||
|
}
|
||||||
49
mutation_remove_item.go
Normal file
49
mutation_remove_item.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_remove_item.go
|
||||||
|
//
|
||||||
|
// Registers the RemoveItem mutation.
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - Removes the cart line whose local cart line Id == payload.Id
|
||||||
|
// - If no such line exists returns an error
|
||||||
|
// - Recalculates cart totals (WithTotals)
|
||||||
|
//
|
||||||
|
// Notes:
|
||||||
|
// - This removes only the line item; any deliveries referencing the removed
|
||||||
|
// item are NOT automatically adjusted (mirrors prior logic). If future
|
||||||
|
// semantics require pruning delivery.item_ids you can extend this handler.
|
||||||
|
// - If multiple lines somehow shared the same Id (should not happen), only
|
||||||
|
// the first match would be removed—data integrity relies on unique line Ids.
|
||||||
|
func init() {
|
||||||
|
RegisterMutation[messages.RemoveItem](
|
||||||
|
"RemoveItem",
|
||||||
|
func(g *CartGrain, m *messages.RemoveItem) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("RemoveItem: nil payload")
|
||||||
|
}
|
||||||
|
targetID := int(m.Id)
|
||||||
|
|
||||||
|
index := -1
|
||||||
|
for i, it := range g.Items {
|
||||||
|
if it.Id == targetID {
|
||||||
|
index = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if index == -1 {
|
||||||
|
return fmt.Errorf("RemoveItem: item id %d not found", m.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Items = append(g.Items[:index], g.Items[index+1:]...)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
WithTotals(),
|
||||||
|
)
|
||||||
|
}
|
||||||
57
mutation_set_cart_items.go
Normal file
57
mutation_set_cart_items.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_set_cart_items.go
|
||||||
|
//
|
||||||
|
// Registers the SetCartRequest mutation. This mutation replaces the entire list
|
||||||
|
// of cart items with the provided list (each entry is an AddRequest).
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - Clears existing items (but leaves deliveries intact).
|
||||||
|
// - Iterates over each AddRequest and delegates to CartGrain.AddItem
|
||||||
|
// (which performs product lookup, creates AddItem mutation).
|
||||||
|
// - If any single addition fails, the mutation aborts with an error;
|
||||||
|
// items added prior to the failure remain (consistent with previous behavior).
|
||||||
|
// - Totals recalculated after completion via WithTotals().
|
||||||
|
//
|
||||||
|
// Notes:
|
||||||
|
// - Potential optimization: batch product lookups; currently sequential.
|
||||||
|
// - Consider adding rollback semantics if atomic replacement is desired.
|
||||||
|
// - Deliveries might reference item IDs that are now invalid—original logic
|
||||||
|
// also left deliveries untouched. If that becomes an issue, add a cleanup
|
||||||
|
// pass to remove deliveries whose item IDs no longer exist.
|
||||||
|
func init() {
|
||||||
|
RegisterMutation[messages.SetCartRequest](
|
||||||
|
"SetCartRequest",
|
||||||
|
func(g *CartGrain, m *messages.SetCartRequest) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("SetCartRequest: nil payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear current items (keep deliveries)
|
||||||
|
g.mu.Lock()
|
||||||
|
g.Items = make([]*CartItem, 0, len(m.Items))
|
||||||
|
g.mu.Unlock()
|
||||||
|
|
||||||
|
for _, it := range m.Items {
|
||||||
|
if it == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if it.Sku == "" || it.Quantity < 1 {
|
||||||
|
return fmt.Errorf("SetCartRequest: invalid item (sku='%s' qty=%d)", it.Sku, it.Quantity)
|
||||||
|
}
|
||||||
|
_, err := g.AddItem(it.Sku, int(it.Quantity), it.Country, it.StoreId)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("SetCartRequest: add sku '%s' failed: %w", it.Sku, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
WithTotals(),
|
||||||
|
)
|
||||||
|
}
|
||||||
101
mutation_set_delivery.go
Normal file
101
mutation_set_delivery.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mutation_set_delivery.go
|
||||||
|
//
|
||||||
|
// Registers the SetDelivery mutation.
|
||||||
|
//
|
||||||
|
// Semantics (mirrors legacy switch logic):
|
||||||
|
// - If the payload specifies an explicit list of item IDs (payload.Items):
|
||||||
|
// - Each referenced cart line must exist.
|
||||||
|
// - None of the referenced items may already belong to a delivery.
|
||||||
|
// - Only those items are associated with the new delivery.
|
||||||
|
// - If payload.Items is empty:
|
||||||
|
// - All items currently without any delivery are associated with the new delivery.
|
||||||
|
// - A new delivery line is created with:
|
||||||
|
// - Auto-incremented delivery ID (cart-local)
|
||||||
|
// - Provider from payload
|
||||||
|
// - Fixed price (currently hard-coded: 4900 minor units) – adjust as needed
|
||||||
|
// - Optional PickupPoint copied from payload
|
||||||
|
// - Cart totals are recalculated (WithTotals)
|
||||||
|
//
|
||||||
|
// Error cases:
|
||||||
|
// - Referenced item does not exist
|
||||||
|
// - Referenced item already has a delivery
|
||||||
|
// - No items qualify (resulting association set empty) -> returns error (prevents creating empty delivery)
|
||||||
|
//
|
||||||
|
// Concurrency:
|
||||||
|
// - Uses g.mu to protect lastDeliveryId increment and append to Deliveries slice.
|
||||||
|
// Item scans are read-only and performed outside the lock for simplicity;
|
||||||
|
// if stricter guarantees are needed, widen the lock section.
|
||||||
|
//
|
||||||
|
// Future extension points:
|
||||||
|
// - Variable delivery pricing (based on weight, distance, provider, etc.)
|
||||||
|
// - Validation of provider codes
|
||||||
|
// - Multi-currency delivery pricing
|
||||||
|
func init() {
|
||||||
|
RegisterMutation[messages.SetDelivery](
|
||||||
|
"SetDelivery",
|
||||||
|
func(g *CartGrain, m *messages.SetDelivery) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("SetDelivery: nil payload")
|
||||||
|
}
|
||||||
|
if m.Provider == "" {
|
||||||
|
return fmt.Errorf("SetDelivery: provider is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
withDelivery := g.ItemsWithDelivery()
|
||||||
|
targetItems := make([]int, 0)
|
||||||
|
|
||||||
|
if len(m.Items) == 0 {
|
||||||
|
// Use every item currently without a delivery
|
||||||
|
targetItems = append(targetItems, g.ItemsWithoutDelivery()...)
|
||||||
|
} else {
|
||||||
|
// Validate explicit list
|
||||||
|
for _, id64 := range m.Items {
|
||||||
|
id := int(id64)
|
||||||
|
found := false
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if it.Id == id {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("SetDelivery: item id %d not found", id)
|
||||||
|
}
|
||||||
|
if slices.Contains(withDelivery, id) {
|
||||||
|
return fmt.Errorf("SetDelivery: item id %d already has a delivery", id)
|
||||||
|
}
|
||||||
|
targetItems = append(targetItems, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(targetItems) == 0 {
|
||||||
|
return fmt.Errorf("SetDelivery: no eligible items to attach")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append new delivery
|
||||||
|
g.mu.Lock()
|
||||||
|
g.lastDeliveryId++
|
||||||
|
newId := g.lastDeliveryId
|
||||||
|
g.Deliveries = append(g.Deliveries, &CartDelivery{
|
||||||
|
Id: newId,
|
||||||
|
Provider: m.Provider,
|
||||||
|
PickupPoint: m.PickupPoint,
|
||||||
|
Price: 4900, // TODO: externalize pricing
|
||||||
|
Items: targetItems,
|
||||||
|
})
|
||||||
|
g.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
WithTotals(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package checkout
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
messages "git.k6n.net/go-cart-actor/proto/checkout"
|
messages "git.tornberg.me/go-cart-actor/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mutation_set_pickup_point.go
|
// mutation_set_pickup_point.go
|
||||||
@@ -28,24 +28,29 @@ import (
|
|||||||
// - Validate pickup point fields (country code, zip format, etc.)
|
// - Validate pickup point fields (country code, zip format, etc.)
|
||||||
// - Track history / audit of pickup point changes
|
// - Track history / audit of pickup point changes
|
||||||
// - Trigger delivery price adjustments (which would then require WithTotals()).
|
// - Trigger delivery price adjustments (which would then require WithTotals()).
|
||||||
|
func init() {
|
||||||
func HandleSetPickupPoint(g *CheckoutGrain, m *messages.SetPickupPoint) error {
|
RegisterMutation[messages.SetPickupPoint](
|
||||||
if m == nil {
|
"SetPickupPoint",
|
||||||
return fmt.Errorf("SetPickupPoint: nil payload")
|
func(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 = &PickupPoint{
|
|
||||||
Id: m.PickupPoint.Id,
|
|
||||||
Name: m.PickupPoint.Name,
|
|
||||||
Address: m.PickupPoint.Address,
|
|
||||||
City: m.PickupPoint.City,
|
|
||||||
Zip: m.PickupPoint.Zip,
|
|
||||||
Country: m.PickupPoint.Country,
|
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
for _, d := range g.Deliveries {
|
||||||
}
|
if d.Id == int(m.DeliveryId) {
|
||||||
return fmt.Errorf("SetPickupPoint: delivery id %d not found", m.DeliveryId)
|
d.PickupPoint = &messages.PickupPoint{
|
||||||
|
Id: m.Id,
|
||||||
|
Name: m.Name,
|
||||||
|
Address: m.Address,
|
||||||
|
City: m.City,
|
||||||
|
Zip: m.Zip,
|
||||||
|
Country: m.Country,
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("SetPickupPoint: delivery id %d not found", m.DeliveryId)
|
||||||
|
},
|
||||||
|
// No WithTotals(): pickup point does not change pricing / tax.
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
package actor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
type GrainId uint64
|
|
||||||
|
|
||||||
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
||||||
|
|
||||||
// Reverse lookup (0xFF marks invalid)
|
|
||||||
var base62Rev [256]byte
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
for i := range base62Rev {
|
|
||||||
base62Rev[i] = 0xFF
|
|
||||||
}
|
|
||||||
for i := 0; i < len(base62Alphabet); i++ {
|
|
||||||
base62Rev[base62Alphabet[i]] = byte(i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// String returns the canonical base62 encoding of the 64-bit id.
|
|
||||||
func (id GrainId) String() string {
|
|
||||||
return encodeBase62(uint64(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarshalJSON encodes the cart id as a JSON string.
|
|
||||||
func (id GrainId) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(id.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnmarshalJSON decodes a cart id from a JSON string containing base62 text.
|
|
||||||
func (id *GrainId) UnmarshalJSON(data []byte) error {
|
|
||||||
var s string
|
|
||||||
if err := json.Unmarshal(data, &s); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
parsed, ok := ParseGrainId(s)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("invalid cart id: %q", s)
|
|
||||||
}
|
|
||||||
*id = parsed
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewGrainId generates a new cryptographically random non-zero 64-bit id.
|
|
||||||
func NewGrainId() (GrainId, error) {
|
|
||||||
var b [8]byte
|
|
||||||
if _, err := rand.Read(b[:]); err != nil {
|
|
||||||
return 0, fmt.Errorf("NewGrainId: %w", err)
|
|
||||||
}
|
|
||||||
u := (uint64(b[0]) << 56) |
|
|
||||||
(uint64(b[1]) << 48) |
|
|
||||||
(uint64(b[2]) << 40) |
|
|
||||||
(uint64(b[3]) << 32) |
|
|
||||||
(uint64(b[4]) << 24) |
|
|
||||||
(uint64(b[5]) << 16) |
|
|
||||||
(uint64(b[6]) << 8) |
|
|
||||||
uint64(b[7])
|
|
||||||
if u == 0 {
|
|
||||||
// Extremely unlikely; regenerate once to avoid "0" identifier if desired.
|
|
||||||
return NewGrainId()
|
|
||||||
}
|
|
||||||
return GrainId(u), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MustNewGrainId panics if generation fails.
|
|
||||||
func MustNewGrainId() GrainId {
|
|
||||||
id, err := NewGrainId()
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseGrainId parses a base62 string into a GrainId.
|
|
||||||
// Returns (0,false) for invalid input.
|
|
||||||
func ParseGrainId(s string) (GrainId, bool) {
|
|
||||||
// Accept length 1..11 (11 sufficient for 64 bits). Reject >11 immediately.
|
|
||||||
// Provide a slightly looser upper bound (<=16) only if you anticipate future
|
|
||||||
// extensions; here we stay strict.
|
|
||||||
if len(s) == 0 || len(s) > 11 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
u, ok := decodeBase62(s)
|
|
||||||
if !ok {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return GrainId(u), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// MustParseGrainId panics on invalid base62 input.
|
|
||||||
func MustParseGrainId(s string) GrainId {
|
|
||||||
id, ok := ParseGrainId(s)
|
|
||||||
if !ok {
|
|
||||||
panic(fmt.Sprintf("invalid cart id: %q", s))
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
// encodeBase62 converts a uint64 to base62 (shortest form).
|
|
||||||
func encodeBase62(u uint64) string {
|
|
||||||
if u == 0 {
|
|
||||||
return "0"
|
|
||||||
}
|
|
||||||
var buf [11]byte
|
|
||||||
i := len(buf)
|
|
||||||
for u > 0 {
|
|
||||||
i--
|
|
||||||
buf[i] = base62Alphabet[u%62]
|
|
||||||
u /= 62
|
|
||||||
}
|
|
||||||
return string(buf[i:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// decodeBase62 converts base62 text to uint64.
|
|
||||||
func decodeBase62(s string) (uint64, bool) {
|
|
||||||
var v uint64
|
|
||||||
for i := 0; i < len(s); i++ {
|
|
||||||
c := s[i]
|
|
||||||
d := base62Rev[c]
|
|
||||||
if d == 0xFF {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
v = v*62 + uint64(d)
|
|
||||||
}
|
|
||||||
return v, true
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
package actor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
type QueueEvent struct {
|
|
||||||
TimeStamp time.Time
|
|
||||||
Message proto.Message
|
|
||||||
}
|
|
||||||
|
|
||||||
type DiskStorage[V any] struct {
|
|
||||||
*StateStorage
|
|
||||||
path string
|
|
||||||
done chan struct{}
|
|
||||||
queue *sync.Map // map[uint64][]QueueEvent
|
|
||||||
}
|
|
||||||
|
|
||||||
type LogStorage[V any] interface {
|
|
||||||
LoadEvents(ctx context.Context, id uint64, grain Grain[V]) error
|
|
||||||
LoadEventsFunc(ctx context.Context, id uint64, grain Grain[V], condition func(msg proto.Message, index int, timeStamp time.Time) bool) error
|
|
||||||
AppendMutations(id uint64, msg ...proto.Message) error
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDiskStorage[V any](path string, registry MutationRegistry) *DiskStorage[V] {
|
|
||||||
return &DiskStorage[V]{
|
|
||||||
StateStorage: NewState(registry),
|
|
||||||
path: path,
|
|
||||||
done: make(chan struct{}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *DiskStorage[V]) SaveLoop(duration time.Duration) {
|
|
||||||
s.queue = &sync.Map{}
|
|
||||||
ticker := time.NewTicker(duration)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-s.done:
|
|
||||||
s.save()
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
s.save()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *DiskStorage[V]) save() {
|
|
||||||
carts := 0
|
|
||||||
lines := 0
|
|
||||||
s.queue.Range(func(key, value any) bool {
|
|
||||||
id := key.(uint64)
|
|
||||||
path := s.logPath(id)
|
|
||||||
fh, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("failed to open event log file: %v", err)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
defer fh.Close()
|
|
||||||
|
|
||||||
if qe, ok := value.([]QueueEvent); ok {
|
|
||||||
for _, msg := range qe {
|
|
||||||
if err := s.Append(fh, msg.Message, msg.TimeStamp); err != nil {
|
|
||||||
log.Printf("failed to append event to log file: %v", err)
|
|
||||||
}
|
|
||||||
lines++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
carts++
|
|
||||||
s.queue.Delete(id)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
if lines > 0 {
|
|
||||||
log.Printf("Appended %d carts and %d lines to disk", carts, lines)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *DiskStorage[V]) logPath(id uint64) string {
|
|
||||||
return filepath.Join(s.path, fmt.Sprintf("%d.events.log", id))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *DiskStorage[V]) LoadEventsFunc(ctx context.Context, id uint64, grain Grain[V], condition func(msg proto.Message, index int, timeStamp time.Time) bool) error {
|
|
||||||
path := s.logPath(id)
|
|
||||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
|
||||||
// No log -> nothing to replay
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fh, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open replay file: %w", err)
|
|
||||||
}
|
|
||||||
defer fh.Close()
|
|
||||||
index := 0
|
|
||||||
return s.Load(fh, func(msg proto.Message, when time.Time) {
|
|
||||||
if condition(msg, index, when) {
|
|
||||||
s.registry.Apply(ctx, grain, msg)
|
|
||||||
}
|
|
||||||
index++
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *DiskStorage[V]) LoadEvents(ctx context.Context, id uint64, grain Grain[V]) error {
|
|
||||||
path := s.logPath(id)
|
|
||||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
|
||||||
// No log -> nothing to replay
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fh, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open replay file: %w", err)
|
|
||||||
}
|
|
||||||
defer fh.Close()
|
|
||||||
return s.Load(fh, func(msg proto.Message, _ time.Time) {
|
|
||||||
s.registry.Apply(ctx, grain, msg)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *DiskStorage[V]) Close() {
|
|
||||||
if s.queue != nil {
|
|
||||||
s.save()
|
|
||||||
}
|
|
||||||
close(s.done)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *DiskStorage[V]) AppendMutations(id uint64, msg ...proto.Message) error {
|
|
||||||
if s.queue != nil {
|
|
||||||
queue := make([]QueueEvent, 0)
|
|
||||||
data, found := s.queue.Load(id)
|
|
||||||
if found {
|
|
||||||
queue = data.([]QueueEvent)
|
|
||||||
}
|
|
||||||
for _, m := range msg {
|
|
||||||
queue = append(queue, QueueEvent{Message: m, TimeStamp: time.Now()})
|
|
||||||
}
|
|
||||||
s.queue.Store(id, queue)
|
|
||||||
return nil
|
|
||||||
} else {
|
|
||||||
path := s.logPath(id)
|
|
||||||
fh, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("failed to open event log file: %v", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer fh.Close()
|
|
||||||
for _, m := range msg {
|
|
||||||
err = s.Append(fh, m, time.Now())
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package actor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Grain[V any] interface {
|
|
||||||
GetId() uint64
|
|
||||||
|
|
||||||
GetLastAccess() time.Time
|
|
||||||
GetLastChange() time.Time
|
|
||||||
GetCurrentState() (*V, error)
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package actor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MutationResult[V any] struct {
|
|
||||||
Result V `json:"result"`
|
|
||||||
Mutations []ApplyResult `json:"mutations,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GrainPool[V any] interface {
|
|
||||||
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[V], error)
|
|
||||||
Get(ctx context.Context, id uint64) (*V, error)
|
|
||||||
OwnerHost(id uint64) (Host[V], bool)
|
|
||||||
Hostname() string
|
|
||||||
TakeOwnership(id uint64)
|
|
||||||
HandleOwnershipChange(host string, ids []uint64) error
|
|
||||||
HandleRemoteExpiry(host string, ids []uint64) error
|
|
||||||
Negotiate(otherHosts []string)
|
|
||||||
GetLocalIds() []uint64
|
|
||||||
IsHealthy() bool
|
|
||||||
Close()
|
|
||||||
IsKnown(string) bool
|
|
||||||
RemoveHost(host string)
|
|
||||||
AddRemoteHost(host string)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Host abstracts a remote node capable of proxying cart requests.
|
|
||||||
type Host[V any] interface {
|
|
||||||
AnnounceExpiry(ids []uint64)
|
|
||||||
Negotiate(otherHosts []string) ([]string, error)
|
|
||||||
Name() string
|
|
||||||
Proxy(id uint64, w http.ResponseWriter, r *http.Request, customBody io.Reader) (bool, error)
|
|
||||||
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[V], error)
|
|
||||||
Get(ctx context.Context, id uint64) (*V, error)
|
|
||||||
GetActorIds() []uint64
|
|
||||||
Close() error
|
|
||||||
Ping() bool
|
|
||||||
IsHealthy() bool
|
|
||||||
AnnounceOwnership(ownerHost string, ids []uint64)
|
|
||||||
}
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
package actor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
messages "git.k6n.net/go-cart-actor/proto/control"
|
|
||||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
|
||||||
"go.opentelemetry.io/otel"
|
|
||||||
"go.opentelemetry.io/otel/attribute"
|
|
||||||
"go.opentelemetry.io/otel/metric"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
"google.golang.org/grpc/reflection"
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
"google.golang.org/protobuf/types/known/anypb"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ControlServer implements the ControlPlane gRPC services.
|
|
||||||
// It delegates to a grain pool and cluster operations to a synced pool.
|
|
||||||
type ControlServer[V any] struct {
|
|
||||||
messages.UnimplementedControlPlaneServer
|
|
||||||
pool GrainPool[V]
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = "grpc_server"
|
|
||||||
|
|
||||||
var (
|
|
||||||
tracer = otel.Tracer(name)
|
|
||||||
meter = otel.Meter(name)
|
|
||||||
logger = otelslog.NewLogger(name)
|
|
||||||
pingCalls metric.Int64Counter
|
|
||||||
negotiateCalls metric.Int64Counter
|
|
||||||
getLocalActorIdsCalls metric.Int64Counter
|
|
||||||
announceOwnershipCalls metric.Int64Counter
|
|
||||||
announceExpiryCalls metric.Int64Counter
|
|
||||||
closingCalls metric.Int64Counter
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
var err error
|
|
||||||
pingCalls, err = meter.Int64Counter("grpc.ping_calls",
|
|
||||||
metric.WithDescription("Number of ping calls"),
|
|
||||||
metric.WithUnit("{calls}"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
negotiateCalls, err = meter.Int64Counter("grpc.negotiate_calls",
|
|
||||||
metric.WithDescription("Number of negotiate calls"),
|
|
||||||
metric.WithUnit("{calls}"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
getLocalActorIdsCalls, err = meter.Int64Counter("grpc.get_local_actor_ids_calls",
|
|
||||||
metric.WithDescription("Number of get local actor ids calls"),
|
|
||||||
metric.WithUnit("{calls}"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
announceOwnershipCalls, err = meter.Int64Counter("grpc.announce_ownership_calls",
|
|
||||||
metric.WithDescription("Number of announce ownership calls"),
|
|
||||||
metric.WithUnit("{calls}"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
announceExpiryCalls, err = meter.Int64Counter("grpc.announce_expiry_calls",
|
|
||||||
metric.WithDescription("Number of announce expiry calls"),
|
|
||||||
metric.WithUnit("{calls}"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
closingCalls, err = meter.Int64Counter("grpc.closing_calls",
|
|
||||||
metric.WithDescription("Number of closing calls"),
|
|
||||||
metric.WithUnit("{calls}"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ControlServer[V]) AnnounceOwnership(ctx context.Context, req *messages.OwnershipAnnounce) (*messages.OwnerChangeAck, error) {
|
|
||||||
ctx, span := tracer.Start(ctx, "grpc_announce_ownership")
|
|
||||||
defer span.End()
|
|
||||||
span.SetAttributes(
|
|
||||||
attribute.String("component", "controlplane"),
|
|
||||||
attribute.String("host", req.Host),
|
|
||||||
attribute.Int("id_count", len(req.Ids)),
|
|
||||||
)
|
|
||||||
logger.InfoContext(ctx, "announce ownership", "host", req.Host, "id_count", len(req.Ids))
|
|
||||||
announceOwnershipCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
|
|
||||||
|
|
||||||
err := s.pool.HandleOwnershipChange(req.Host, req.Ids)
|
|
||||||
if err != nil {
|
|
||||||
span.RecordError(err)
|
|
||||||
return &messages.OwnerChangeAck{
|
|
||||||
Accepted: false,
|
|
||||||
Message: "owner change failed",
|
|
||||||
}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// log.Printf("Ack count: %d", len(req.Ids))
|
|
||||||
return &messages.OwnerChangeAck{
|
|
||||||
Accepted: true,
|
|
||||||
Message: "ownership announced",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func toAny[V any](grain V) (*anypb.Any, error) {
|
|
||||||
data, err := json.Marshal(grain)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &anypb.Any{
|
|
||||||
Value: data,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ControlServer[V]) Get(ctx context.Context, req *messages.GetRequest) (*messages.GetReply, error) {
|
|
||||||
grain, err := s.pool.Get(ctx, req.Id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
grainAny, err := toAny(grain)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &messages.GetReply{
|
|
||||||
Grain: grainAny,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ControlServer[V]) AnnounceExpiry(ctx context.Context, req *messages.ExpiryAnnounce) (*messages.OwnerChangeAck, error) {
|
|
||||||
ctx, span := tracer.Start(ctx, "grpc_announce_expiry")
|
|
||||||
defer span.End()
|
|
||||||
span.SetAttributes(
|
|
||||||
attribute.String("component", "controlplane"),
|
|
||||||
attribute.String("host", req.Host),
|
|
||||||
attribute.Int("id_count", len(req.Ids)),
|
|
||||||
)
|
|
||||||
logger.InfoContext(ctx, "announce expiry", "host", req.Host, "id_count", len(req.Ids))
|
|
||||||
announceExpiryCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
|
|
||||||
|
|
||||||
err := s.pool.HandleRemoteExpiry(req.Host, req.Ids)
|
|
||||||
if err != nil {
|
|
||||||
span.RecordError(err)
|
|
||||||
}
|
|
||||||
return &messages.OwnerChangeAck{
|
|
||||||
Accepted: err == nil,
|
|
||||||
Message: "expiry acknowledged",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ControlPlane: Ping
|
|
||||||
func (s *ControlServer[V]) Ping(ctx context.Context, req *messages.Empty) (*messages.PingReply, error) {
|
|
||||||
|
|
||||||
host := s.pool.Hostname()
|
|
||||||
|
|
||||||
pingCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", host)))
|
|
||||||
|
|
||||||
// log.Printf("got ping")
|
|
||||||
return &messages.PingReply{
|
|
||||||
Host: host,
|
|
||||||
UnixTime: time.Now().Unix(),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ControlServer[V]) Apply(ctx context.Context, in *messages.ApplyRequest) (*messages.ApplyResult, error) {
|
|
||||||
msgs := make([]proto.Message, len(in.Messages))
|
|
||||||
for i, anyMsg := range in.Messages {
|
|
||||||
msg, err := anyMsg.UnmarshalNew()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to unmarshal message: %w", err)
|
|
||||||
}
|
|
||||||
msgs[i] = msg
|
|
||||||
}
|
|
||||||
r, err := s.pool.Apply(ctx, in.Id, msgs...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
grainAny, err := toAny(r)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
mutList := make([]*messages.MutationResult, len(in.Messages))
|
|
||||||
for i, msg := range r.Mutations {
|
|
||||||
mut, err := anypb.New(msg.Mutation)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var errString *string
|
|
||||||
if msg.Error != nil {
|
|
||||||
s := msg.Error.Error()
|
|
||||||
errString = &s
|
|
||||||
}
|
|
||||||
mutList[i] = &messages.MutationResult{
|
|
||||||
Type: msg.Type,
|
|
||||||
Message: mut,
|
|
||||||
Error: errString,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &messages.ApplyResult{
|
|
||||||
State: grainAny,
|
|
||||||
Mutations: mutList,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ControlPlane: Negotiate (merge host views)
|
|
||||||
func (s *ControlServer[V]) Negotiate(ctx context.Context, req *messages.NegotiateRequest) (*messages.NegotiateReply, error) {
|
|
||||||
ctx, span := tracer.Start(ctx, "grpc_negotiate")
|
|
||||||
defer span.End()
|
|
||||||
span.SetAttributes(
|
|
||||||
attribute.String("component", "controlplane"),
|
|
||||||
attribute.Int("known_hosts_count", len(req.KnownHosts)),
|
|
||||||
)
|
|
||||||
logger.InfoContext(ctx, "negotiate", "known_hosts_count", len(req.KnownHosts))
|
|
||||||
negotiateCalls.Add(ctx, 1)
|
|
||||||
|
|
||||||
s.pool.Negotiate(req.KnownHosts)
|
|
||||||
return &messages.NegotiateReply{Hosts: req.GetKnownHosts()}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ControlPlane: GetCartIds (locally owned carts only)
|
|
||||||
func (s *ControlServer[V]) GetLocalActorIds(ctx context.Context, req *messages.Empty) (*messages.ActorIdsReply, error) {
|
|
||||||
ctx, span := tracer.Start(ctx, "grpc_get_local_actor_ids")
|
|
||||||
defer span.End()
|
|
||||||
ids := s.pool.GetLocalIds()
|
|
||||||
span.SetAttributes(
|
|
||||||
attribute.String("component", "controlplane"),
|
|
||||||
attribute.Int("id_count", len(ids)),
|
|
||||||
)
|
|
||||||
logger.InfoContext(ctx, "get local actor ids", "id_count", len(ids))
|
|
||||||
getLocalActorIdsCalls.Add(ctx, 1)
|
|
||||||
|
|
||||||
return &messages.ActorIdsReply{Ids: ids}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ControlPlane: Closing (peer shutdown notification)
|
|
||||||
func (s *ControlServer[V]) Closing(ctx context.Context, req *messages.ClosingNotice) (*messages.OwnerChangeAck, error) {
|
|
||||||
ctx, span := tracer.Start(ctx, "grpc_closing")
|
|
||||||
defer span.End()
|
|
||||||
host := req.GetHost()
|
|
||||||
span.SetAttributes(
|
|
||||||
attribute.String("component", "controlplane"),
|
|
||||||
attribute.String("host", host),
|
|
||||||
)
|
|
||||||
logger.InfoContext(ctx, "closing notice", "host", host)
|
|
||||||
closingCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", host)))
|
|
||||||
|
|
||||||
if host != "" {
|
|
||||||
s.pool.RemoveHost(host)
|
|
||||||
}
|
|
||||||
return &messages.OwnerChangeAck{
|
|
||||||
Accepted: true,
|
|
||||||
Message: "removed host",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type ServerConfig struct {
|
|
||||||
Addr string
|
|
||||||
Options []grpc.ServerOption
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewServerConfig(addr string, options ...grpc.ServerOption) ServerConfig {
|
|
||||||
return ServerConfig{
|
|
||||||
Addr: addr,
|
|
||||||
Options: options,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func DefaultServerConfig() ServerConfig {
|
|
||||||
return NewServerConfig(":1337")
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartGRPCServer configures and starts the unified gRPC server on the given address.
|
|
||||||
// It registers both the CartActor and ControlPlane services.
|
|
||||||
func NewControlServer[V any](config ServerConfig, pool GrainPool[V]) (*grpc.Server, error) {
|
|
||||||
lis, err := net.Listen("tcp", config.Addr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to listen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
grpcServer := grpc.NewServer(config.Options...)
|
|
||||||
server := &ControlServer[V]{
|
|
||||||
pool: pool,
|
|
||||||
}
|
|
||||||
|
|
||||||
messages.RegisterControlPlaneServer(grpcServer, server)
|
|
||||||
reflection.Register(grpcServer)
|
|
||||||
|
|
||||||
log.Printf("gRPC server listening as %s on %s", pool.Hostname(), config.Addr)
|
|
||||||
go func() {
|
|
||||||
if err := grpcServer.Serve(lis); err != nil {
|
|
||||||
log.Fatalf("failed to serve gRPC: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return grpcServer, nil
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
package actor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
cart_messages "git.k6n.net/go-cart-actor/proto/cart"
|
|
||||||
control_plane_messages "git.k6n.net/go-cart-actor/proto/control"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
"google.golang.org/protobuf/types/known/anypb"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MockGrainPool for testing
|
|
||||||
type mockGrainPool struct {
|
|
||||||
applied []proto.Message
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockGrainPool) Apply(ctx context.Context, id uint64, mutations ...proto.Message) (*MutationResult[mockGrain], error) {
|
|
||||||
m.applied = mutations
|
|
||||||
// Simulate successful application
|
|
||||||
return &MutationResult[mockGrain]{
|
|
||||||
Result: mockGrain{},
|
|
||||||
Mutations: []ApplyResult{
|
|
||||||
{Type: "AddItem", Mutation: &cart_messages.AddItem{ItemId: 1, Quantity: 2}, Error: nil},
|
|
||||||
{Type: "RemoveItem", Mutation: &cart_messages.RemoveItem{Id: 1}, Error: nil},
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockGrainPool) Get(ctx context.Context, id uint64) (*mockGrain, error) {
|
|
||||||
return &mockGrain{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockGrainPool) OwnerHost(id uint64) (Host[mockGrain], bool) {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockGrainPool) TakeOwnership(id uint64) {}
|
|
||||||
|
|
||||||
func (m *mockGrainPool) Hostname() string { return "test-host" }
|
|
||||||
|
|
||||||
func (m *mockGrainPool) HandleOwnershipChange(host string, ids []uint64) error { return nil }
|
|
||||||
func (m *mockGrainPool) HandleRemoteExpiry(host string, ids []uint64) error { return nil }
|
|
||||||
func (m *mockGrainPool) Negotiate(hosts []string) {}
|
|
||||||
func (m *mockGrainPool) GetLocalIds() []uint64 { return []uint64{} }
|
|
||||||
func (m *mockGrainPool) RemoveHost(host string) {}
|
|
||||||
func (m *mockGrainPool) AddRemoteHost(host string) {}
|
|
||||||
func (m *mockGrainPool) IsHealthy() bool { return true }
|
|
||||||
func (m *mockGrainPool) IsKnown(host string) bool { return false }
|
|
||||||
func (m *mockGrainPool) Close() {}
|
|
||||||
|
|
||||||
type mockGrain struct{}
|
|
||||||
|
|
||||||
func TestApplyRequestWithMutations(t *testing.T) {
|
|
||||||
// Setup mock pool
|
|
||||||
pool := &mockGrainPool{}
|
|
||||||
|
|
||||||
// Create gRPC server
|
|
||||||
server, err := NewControlServer[mockGrain](DefaultServerConfig(), pool)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create server: %v", err)
|
|
||||||
}
|
|
||||||
defer server.GracefulStop()
|
|
||||||
|
|
||||||
// Create client connection
|
|
||||||
conn, err := grpc.Dial("localhost:1337", grpc.WithTransportCredentials(insecure.NewCredentials()))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to dial: %v", err)
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
client := control_plane_messages.NewControlPlaneClient(conn)
|
|
||||||
|
|
||||||
// Prepare ApplyRequest with multiple Any messages
|
|
||||||
addItemAny, _ := anypb.New(&cart_messages.AddItem{ItemId: 1, Quantity: 2})
|
|
||||||
removeItemAny, _ := anypb.New(&cart_messages.RemoveItem{Id: 1})
|
|
||||||
req := &control_plane_messages.ApplyRequest{
|
|
||||||
Id: 123,
|
|
||||||
Messages: []*anypb.Any{addItemAny, removeItemAny},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call Apply
|
|
||||||
resp, err := client.Apply(context.Background(), req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Apply failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify response
|
|
||||||
if resp.State == nil {
|
|
||||||
t.Errorf("expected State to be non-nil")
|
|
||||||
}
|
|
||||||
if len(resp.Mutations) != 2 {
|
|
||||||
t.Errorf("expected 2 mutation results, got %d", len(resp.Mutations))
|
|
||||||
}
|
|
||||||
for i, mut := range resp.Mutations {
|
|
||||||
if mut.Error != nil {
|
|
||||||
t.Errorf("expected no error in mutation %d, got %s", i, *mut.Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify mutations were extracted and applied
|
|
||||||
if len(pool.applied) != 2 {
|
|
||||||
t.Errorf("expected 2 mutations applied, got %d", len(pool.applied))
|
|
||||||
}
|
|
||||||
if addItem, ok := pool.applied[0].(*cart_messages.AddItem); !ok || addItem.ItemId != 1 {
|
|
||||||
t.Errorf("expected AddItem with ItemId=1, got %v", pool.applied[0])
|
|
||||||
}
|
|
||||||
if removeItem, ok := pool.applied[1].(*cart_messages.RemoveItem); !ok || removeItem.Id != 1 {
|
|
||||||
t.Errorf("expected RemoveItem with Id=1, got %v", pool.applied[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetRequest(t *testing.T) {
|
|
||||||
// Setup mock pool
|
|
||||||
pool := &mockGrainPool{}
|
|
||||||
|
|
||||||
// Create gRPC server
|
|
||||||
server, err := NewControlServer[mockGrain](DefaultServerConfig(), pool)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create server: %v", err)
|
|
||||||
}
|
|
||||||
defer server.GracefulStop()
|
|
||||||
|
|
||||||
// Create client connection
|
|
||||||
conn, err := grpc.Dial("localhost:1337", grpc.WithTransportCredentials(insecure.NewCredentials()))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to dial: %v", err)
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
client := control_plane_messages.NewControlPlaneClient(conn)
|
|
||||||
|
|
||||||
// Prepare GetRequest
|
|
||||||
req := &control_plane_messages.GetRequest{
|
|
||||||
Id: 123,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call Get
|
|
||||||
resp, err := client.Get(context.Background(), req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Get failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify response
|
|
||||||
if resp.Grain == nil {
|
|
||||||
t.Errorf("expected Grain to be non-nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package actor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"github.com/matst80/slask-finder/pkg/messaging"
|
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
|
||||||
)
|
|
||||||
|
|
||||||
type LogListener interface {
|
|
||||||
AppendMutations(id uint64, msg ...ApplyResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
type AmqpListener struct {
|
|
||||||
conn *amqp.Connection
|
|
||||||
transformer func(id uint64, msg []ApplyResult) (any, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAmqpListener(conn *amqp.Connection, transformer func(id uint64, msg []ApplyResult) (any, error)) *AmqpListener {
|
|
||||||
return &AmqpListener{
|
|
||||||
conn: conn,
|
|
||||||
transformer: transformer,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *AmqpListener) DefineTopics() {
|
|
||||||
ch, err := l.conn.Channel()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to open a channel: %v", err)
|
|
||||||
}
|
|
||||||
defer ch.Close()
|
|
||||||
if err := messaging.DefineTopic(ch, "cart", "mutation"); err != nil {
|
|
||||||
log.Fatalf("Failed to declare topic mutation: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *AmqpListener) AppendMutations(id uint64, msg ...ApplyResult) {
|
|
||||||
data, err := l.transformer(id, msg)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to transform mutation event: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = messaging.SendChange(l.conn, "cart", "mutation", data)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to send mutation event: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,361 +0,0 @@
|
|||||||
package actor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"reflect"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"go.opentelemetry.io/otel/attribute"
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ApplyResult struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Mutation proto.Message `json:"mutation"`
|
|
||||||
Error error `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MutationProcessor interface {
|
|
||||||
Process(ctx context.Context, grain any) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type BasicMutationProcessor[V any] struct {
|
|
||||||
processor func(ctx context.Context, grain V) error
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewMutationProcessor[V any](process func(ctx context.Context, grain V) error) MutationProcessor {
|
|
||||||
return &BasicMutationProcessor[V]{
|
|
||||||
processor: process,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *BasicMutationProcessor[V]) Process(ctx context.Context, grain any) error {
|
|
||||||
return p.processor(ctx, grain.(V))
|
|
||||||
}
|
|
||||||
|
|
||||||
type MutationRegistry interface {
|
|
||||||
Apply(ctx context.Context, grain any, msg ...proto.Message) ([]ApplyResult, error)
|
|
||||||
RegisterMutations(handlers ...MutationHandler)
|
|
||||||
Create(typeName string) (proto.Message, bool)
|
|
||||||
GetTypeName(msg proto.Message) (string, bool)
|
|
||||||
RegisterProcessor(processor ...MutationProcessor)
|
|
||||||
RegisterTrigger(trigger ...TriggerHandler)
|
|
||||||
SetEventChannel(ch chan<- ApplyResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProtoMutationRegistry struct {
|
|
||||||
mutationRegistryMu sync.RWMutex
|
|
||||||
mutationRegistry map[reflect.Type]MutationHandler
|
|
||||||
triggers map[reflect.Type][]TriggerHandler
|
|
||||||
processors []MutationProcessor
|
|
||||||
eventChannel chan<- ApplyResult
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrMutationNotRegistered = &MutationError{
|
|
||||||
Message: "mutation not registered",
|
|
||||||
Code: 255,
|
|
||||||
StatusCode: 500,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
type MutationError struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
Code uint32 `json:"code"`
|
|
||||||
StatusCode uint32 `json:"status_code"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m MutationError) Error() string {
|
|
||||||
return m.Message
|
|
||||||
}
|
|
||||||
|
|
||||||
// MutationOption configures additional behavior for a registered mutation.
|
|
||||||
type MutationOption func(*mutationOptions)
|
|
||||||
|
|
||||||
// mutationOptions holds flags adjustable per registration.
|
|
||||||
type mutationOptions struct {
|
|
||||||
updateTotals bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithTotals ensures CartGrain.UpdateTotals() is called after a successful handler.
|
|
||||||
func WithTotals() MutationOption {
|
|
||||||
return func(o *mutationOptions) {
|
|
||||||
o.updateTotals = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type TriggerHandler interface {
|
|
||||||
Handle(state any, msg proto.Message) []proto.Message
|
|
||||||
Name() string
|
|
||||||
Type() reflect.Type
|
|
||||||
}
|
|
||||||
|
|
||||||
type RegisteredTrigger[V any, I proto.Message] struct {
|
|
||||||
name string
|
|
||||||
handler func(state any, msg proto.Message) []proto.Message
|
|
||||||
msgType reflect.Type
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTrigger[V any, I proto.Message](name string, handler func(state any, msg proto.Message) []proto.Message) *RegisteredTrigger[V, I] {
|
|
||||||
return &RegisteredTrigger[V, I]{
|
|
||||||
name: name,
|
|
||||||
handler: handler,
|
|
||||||
msgType: reflect.TypeOf((*I)(nil)).Elem(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type MutationHandler interface {
|
|
||||||
Handle(state any, msg proto.Message) error
|
|
||||||
Name() string
|
|
||||||
Type() reflect.Type
|
|
||||||
Create() proto.Message
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisteredMutation stores metadata + the execution closure.
|
|
||||||
type RegisteredMutation[V any, T proto.Message] struct {
|
|
||||||
name string
|
|
||||||
handler func(*V, T) error
|
|
||||||
create func() proto.Message
|
|
||||||
msgType reflect.Type
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewMutation[V any, T proto.Message](handler func(*V, T) error) *RegisteredMutation[V, T] {
|
|
||||||
// Derive the name and message type from a concrete instance produced by create().
|
|
||||||
// This avoids relying on reflect.TypeFor (which can yield unexpected results in some toolchains)
|
|
||||||
// and ensures we always peel off the pointer layer for proto messages.
|
|
||||||
create := func() proto.Message {
|
|
||||||
var t T
|
|
||||||
rt := reflect.TypeOf(t)
|
|
||||||
if rt != nil && rt.Kind() == reflect.Pointer {
|
|
||||||
return reflect.New(rt.Elem()).Interface().(proto.Message)
|
|
||||||
}
|
|
||||||
log.Fatalf("expected to create proto message got %+v", rt)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
instance := create()
|
|
||||||
rt := reflect.TypeOf(instance)
|
|
||||||
if rt.Kind() == reflect.Pointer {
|
|
||||||
rt = rt.Elem()
|
|
||||||
}
|
|
||||||
return &RegisteredMutation[V, T]{
|
|
||||||
name: rt.Name(),
|
|
||||||
handler: handler,
|
|
||||||
create: create,
|
|
||||||
msgType: rt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *RegisteredMutation[V, T]) Handle(state any, msg proto.Message) error {
|
|
||||||
return m.handler(state.(*V), msg.(T))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *RegisteredMutation[V, T]) Name() string {
|
|
||||||
return m.name
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *RegisteredMutation[V, T]) Create() proto.Message {
|
|
||||||
return m.create()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *RegisteredMutation[V, T]) Type() reflect.Type {
|
|
||||||
return m.msgType
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewMutationRegistry() MutationRegistry {
|
|
||||||
return &ProtoMutationRegistry{
|
|
||||||
mutationRegistry: make(map[reflect.Type]MutationHandler),
|
|
||||||
mutationRegistryMu: sync.RWMutex{},
|
|
||||||
triggers: make(map[reflect.Type][]TriggerHandler),
|
|
||||||
processors: make([]MutationProcessor, 0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ProtoMutationRegistry) RegisterProcessor(processors ...MutationProcessor) {
|
|
||||||
r.processors = append(r.processors, processors...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ProtoMutationRegistry) RegisterMutations(handlers ...MutationHandler) {
|
|
||||||
r.mutationRegistryMu.Lock()
|
|
||||||
defer r.mutationRegistryMu.Unlock()
|
|
||||||
|
|
||||||
for _, handler := range handlers {
|
|
||||||
r.mutationRegistry[handler.Type()] = handler
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ProtoMutationRegistry) RegisterTrigger(triggers ...TriggerHandler) {
|
|
||||||
r.mutationRegistryMu.Lock()
|
|
||||||
defer r.mutationRegistryMu.Unlock()
|
|
||||||
|
|
||||||
for _, trigger := range triggers {
|
|
||||||
existingTriggers, ok := r.triggers[trigger.Type()]
|
|
||||||
if !ok {
|
|
||||||
r.triggers[trigger.Type()] = []TriggerHandler{trigger}
|
|
||||||
} else {
|
|
||||||
r.triggers[trigger.Type()] = append(existingTriggers, trigger)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ProtoMutationRegistry) SetEventChannel(ch chan<- ApplyResult) {
|
|
||||||
r.eventChannel = ch
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ProtoMutationRegistry) GetTypeName(msg proto.Message) (string, bool) {
|
|
||||||
r.mutationRegistryMu.RLock()
|
|
||||||
defer r.mutationRegistryMu.RUnlock()
|
|
||||||
|
|
||||||
rt := indirectType(reflect.TypeOf(msg))
|
|
||||||
if handler, ok := r.mutationRegistry[rt]; ok {
|
|
||||||
return handler.Name(), true
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ProtoMutationRegistry) getHandler(typeName string) MutationHandler {
|
|
||||||
r.mutationRegistryMu.Lock()
|
|
||||||
defer r.mutationRegistryMu.Unlock()
|
|
||||||
|
|
||||||
for _, handler := range r.mutationRegistry {
|
|
||||||
if handler.Name() == typeName {
|
|
||||||
return handler
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *ProtoMutationRegistry) Create(typeName string) (proto.Message, bool) {
|
|
||||||
|
|
||||||
handler := r.getHandler(typeName)
|
|
||||||
if handler == nil {
|
|
||||||
log.Printf("missing handler for %s", typeName)
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
return handler.Create(), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyRegistered attempts to apply a registered mutation.
|
|
||||||
// Returns updated grain if successful.
|
|
||||||
//
|
|
||||||
// If the mutation is not registered, returns (nil, ErrMutationNotRegistered).
|
|
||||||
func (r *ProtoMutationRegistry) Apply(ctx context.Context, grain any, msg ...proto.Message) ([]ApplyResult, error) {
|
|
||||||
|
|
||||||
parentCtx, span := tracer.Start(ctx, "apply mutations")
|
|
||||||
defer span.End()
|
|
||||||
span.SetAttributes(
|
|
||||||
attribute.String("component", "registry"),
|
|
||||||
attribute.Int("mutations", len(msg)),
|
|
||||||
)
|
|
||||||
|
|
||||||
results := make([]ApplyResult, 0, len(msg))
|
|
||||||
|
|
||||||
if grain == nil {
|
|
||||||
return results, fmt.Errorf("nil grain")
|
|
||||||
}
|
|
||||||
// Nil slice of mutations still treated as an error (call contract violation).
|
|
||||||
if msg == nil {
|
|
||||||
return results, fmt.Errorf("nil mutation message")
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, m := range msg {
|
|
||||||
// Error if any mutation element is nil.
|
|
||||||
if m == nil {
|
|
||||||
return results, fmt.Errorf("nil mutation message")
|
|
||||||
}
|
|
||||||
// Typed nil: interface holds concrete proto message type whose pointer value is nil.
|
|
||||||
rv := reflect.ValueOf(m)
|
|
||||||
if rv.Kind() == reflect.Pointer && rv.IsNil() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rt := indirectType(reflect.TypeOf(m))
|
|
||||||
_, msgSpan := tracer.Start(parentCtx, rt.Name())
|
|
||||||
|
|
||||||
r.mutationRegistryMu.RLock()
|
|
||||||
entry, ok := r.mutationRegistry[rt]
|
|
||||||
r.mutationRegistryMu.RUnlock()
|
|
||||||
if !ok {
|
|
||||||
results = append(results, ApplyResult{Error: ErrMutationNotRegistered, Type: rt.Name(), Mutation: m})
|
|
||||||
continue
|
|
||||||
} else {
|
|
||||||
err := entry.Handle(grain, m)
|
|
||||||
if err != nil {
|
|
||||||
msgSpan.RecordError(err)
|
|
||||||
}
|
|
||||||
if r.eventChannel != nil {
|
|
||||||
go func() {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
// Handle panic from sending to closed channel
|
|
||||||
log.Printf("event channel closed: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
for _, tr := range r.triggers[rt] {
|
|
||||||
for _, msg := range tr.Handle(grain, m) {
|
|
||||||
select {
|
|
||||||
case r.eventChannel <- msg:
|
|
||||||
default:
|
|
||||||
// Channel full or no receiver, skip to avoid blocking
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
results = append(results, ApplyResult{Error: err, Type: rt.Name(), Mutation: m})
|
|
||||||
}
|
|
||||||
msgSpan.End()
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(results) > 0 {
|
|
||||||
processCtx, processSpan := tracer.Start(ctx, "after mutation processors")
|
|
||||||
defer processSpan.End()
|
|
||||||
for _, processor := range r.processors {
|
|
||||||
|
|
||||||
err := processor.Process(processCtx, grain)
|
|
||||||
if err != nil {
|
|
||||||
return results, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Return error for unregistered mutations
|
|
||||||
for _, res := range results {
|
|
||||||
if res.Error == ErrMutationNotRegistered {
|
|
||||||
return results, res.Error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisteredMutations returns metadata for all registered mutations (snapshot).
|
|
||||||
func (r *ProtoMutationRegistry) RegisteredMutations() []string {
|
|
||||||
r.mutationRegistryMu.RLock()
|
|
||||||
defer r.mutationRegistryMu.RUnlock()
|
|
||||||
out := make([]string, 0, len(r.mutationRegistry))
|
|
||||||
for _, entry := range r.mutationRegistry {
|
|
||||||
out = append(out, entry.Name())
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisteredMutationTypes returns the reflect.Type list of all registered messages.
|
|
||||||
// Useful for coverage tests ensuring expected set matches actual set.
|
|
||||||
func (r *ProtoMutationRegistry) RegisteredMutationTypes() []reflect.Type {
|
|
||||||
r.mutationRegistryMu.RLock()
|
|
||||||
defer r.mutationRegistryMu.RUnlock()
|
|
||||||
out := make([]reflect.Type, 0, len(r.mutationRegistry))
|
|
||||||
for _, entry := range r.mutationRegistry {
|
|
||||||
out = append(out, entry.Type())
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func indirectType(t reflect.Type) reflect.Type {
|
|
||||||
for t.Kind() == reflect.Ptr {
|
|
||||||
t = t.Elem()
|
|
||||||
}
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
package actor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"reflect"
|
|
||||||
"slices"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
cart_messages "git.k6n.net/go-cart-actor/proto/cart"
|
|
||||||
)
|
|
||||||
|
|
||||||
type cartState struct {
|
|
||||||
calls int
|
|
||||||
lastAdded *cart_messages.AddItem
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRegisteredMutationBasics(t *testing.T) {
|
|
||||||
reg := NewMutationRegistry().(*ProtoMutationRegistry)
|
|
||||||
|
|
||||||
addItemMutation := NewMutation(
|
|
||||||
func(state *cartState, msg *cart_messages.AddItem) error {
|
|
||||||
state.calls++
|
|
||||||
// copy to avoid external mutation side-effects (not strictly necessary for the test)
|
|
||||||
cp := msg
|
|
||||||
state.lastAdded = cp
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// Sanity check on mutation metadata
|
|
||||||
if addItemMutation.Name() != "AddItem" {
|
|
||||||
t.Fatalf("expected mutation Name() == AddItem, got %s", addItemMutation.Name())
|
|
||||||
}
|
|
||||||
if got, want := addItemMutation.Type(), reflect.TypeOf(cart_messages.AddItem{}); got != want {
|
|
||||||
t.Fatalf("expected Type() == %v, got %v", want, got)
|
|
||||||
}
|
|
||||||
|
|
||||||
reg.RegisterMutations(addItemMutation)
|
|
||||||
|
|
||||||
// RegisteredMutations: membership (order not guaranteed)
|
|
||||||
names := reg.RegisteredMutations()
|
|
||||||
if !slices.Contains(names, "AddItem") {
|
|
||||||
t.Fatalf("RegisteredMutations missing AddItem, got %v", names)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisteredMutationTypes: membership (order not guaranteed)
|
|
||||||
types := reg.RegisteredMutationTypes()
|
|
||||||
if !slices.Contains(types, reflect.TypeOf(cart_messages.AddItem{})) {
|
|
||||||
t.Fatalf("RegisteredMutationTypes missing AddItem type, got %v", types)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTypeName should resolve for a pointer instance
|
|
||||||
name, ok := reg.GetTypeName(&cart_messages.AddItem{})
|
|
||||||
if !ok || name != "AddItem" {
|
|
||||||
t.Fatalf("GetTypeName returned (%q,%v), expected (AddItem,true)", name, ok)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTypeName should fail for unregistered type
|
|
||||||
if name, ok := reg.GetTypeName(&cart_messages.RemoveItem{}); ok || name != "" {
|
|
||||||
t.Fatalf("expected GetTypeName to fail for unregistered message, got (%q,%v)", name, ok)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create by name
|
|
||||||
msg, ok := reg.Create("AddItem")
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("Create failed for registered mutation")
|
|
||||||
}
|
|
||||||
if _, isAddItem := msg.(*cart_messages.AddItem); !isAddItem {
|
|
||||||
t.Fatalf("Create returned wrong concrete type: %T", msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create unknown
|
|
||||||
if m2, ok := reg.Create("Unknown"); ok || m2 != nil {
|
|
||||||
t.Fatalf("Create should fail for unknown mutation, got (%T,%v)", m2, ok)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply happy path
|
|
||||||
state := &cartState{}
|
|
||||||
add := &cart_messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
|
|
||||||
if _, err := reg.Apply(context.Background(), state, add); err != nil {
|
|
||||||
t.Fatalf("Apply returned error: %v", err)
|
|
||||||
}
|
|
||||||
if state.calls != 1 {
|
|
||||||
t.Fatalf("handler not invoked expected calls=1 got=%d", state.calls)
|
|
||||||
}
|
|
||||||
if state.lastAdded == nil || state.lastAdded.ItemId != 42 || state.lastAdded.Quantity != 3 {
|
|
||||||
t.Fatalf("state not updated correctly: %+v", state.lastAdded)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply nil grain
|
|
||||||
if _, err := reg.Apply(context.Background(), nil, add); err == nil {
|
|
||||||
t.Fatalf("expected error for nil grain")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply nil message
|
|
||||||
if _, err := reg.Apply(context.Background(), state, nil); err == nil {
|
|
||||||
t.Fatalf("expected error for nil mutation message")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply unregistered message
|
|
||||||
_, err := reg.Apply(context.Background(), state, &cart_messages.RemoveItem{})
|
|
||||||
if err != ErrMutationNotRegistered {
|
|
||||||
t.Fatalf("expected ErrMutationNotRegistered, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEventChannel(t *testing.T) {
|
|
||||||
reg := NewMutationRegistry().(*ProtoMutationRegistry)
|
|
||||||
|
|
||||||
addItemMutation := NewMutation(
|
|
||||||
func(state *cartState, msg *cart_messages.AddItem) error {
|
|
||||||
state.calls++
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
reg.RegisterMutations(addItemMutation)
|
|
||||||
|
|
||||||
eventCh := make(chan ApplyResult, 10)
|
|
||||||
reg.SetEventChannel(eventCh)
|
|
||||||
|
|
||||||
state := &cartState{}
|
|
||||||
add := &cart_messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
|
|
||||||
results, err := reg.Apply(context.Background(), state, add)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Apply returned error: %v", err)
|
|
||||||
}
|
|
||||||
if len(results) != 1 {
|
|
||||||
t.Fatalf("expected 1 result, got %d", len(results))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Receive from channel with timeout
|
|
||||||
select {
|
|
||||||
case res := <-eventCh:
|
|
||||||
if res.Type != "AddItem" {
|
|
||||||
t.Fatalf("expected type AddItem, got %s", res.Type)
|
|
||||||
}
|
|
||||||
if res.Error != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", res.Error)
|
|
||||||
}
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatalf("expected to receive event on channel within timeout")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEventChannelClosed(t *testing.T) {
|
|
||||||
reg := NewMutationRegistry().(*ProtoMutationRegistry)
|
|
||||||
|
|
||||||
addItemMutation := NewMutation(
|
|
||||||
func(state *cartState, msg *cart_messages.AddItem) error {
|
|
||||||
state.calls++
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
reg.RegisterMutations(addItemMutation)
|
|
||||||
|
|
||||||
eventCh := make(chan ApplyResult, 10)
|
|
||||||
reg.SetEventChannel(eventCh)
|
|
||||||
|
|
||||||
close(eventCh) // Close the channel to simulate external close
|
|
||||||
|
|
||||||
state := &cartState{}
|
|
||||||
add := &cart_messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
|
|
||||||
// This should not panic due to recover in goroutine
|
|
||||||
results, err := reg.Apply(context.Background(), state, add)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Apply returned error: %v", err)
|
|
||||||
}
|
|
||||||
if len(results) != 1 {
|
|
||||||
t.Fatalf("expected 1 result, got %d", len(results))
|
|
||||||
}
|
|
||||||
// Test passes if no panic occurs
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEventChannelUnbufferedNoListener(t *testing.T) {
|
|
||||||
reg := NewMutationRegistry().(*ProtoMutationRegistry)
|
|
||||||
|
|
||||||
addItemMutation := NewMutation(
|
|
||||||
func(state *cartState, msg *cart_messages.AddItem) error {
|
|
||||||
state.calls++
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
reg.RegisterMutations(addItemMutation)
|
|
||||||
|
|
||||||
eventCh := make(chan ApplyResult) // unbuffered
|
|
||||||
reg.SetEventChannel(eventCh)
|
|
||||||
|
|
||||||
// No goroutine reading from eventCh
|
|
||||||
|
|
||||||
state := &cartState{}
|
|
||||||
add := &cart_messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
|
|
||||||
results, err := reg.Apply(context.Background(), state, add)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Apply returned error: %v", err)
|
|
||||||
}
|
|
||||||
if len(results) != 1 {
|
|
||||||
t.Fatalf("expected 1 result, got %d", len(results))
|
|
||||||
}
|
|
||||||
// Since no listener, the send should go to default and not block
|
|
||||||
// Test passes if Apply completes without hanging
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helpers
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
package actor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"iter"
|
|
||||||
"slices"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ReceiverFunc[V any] func(event V)
|
|
||||||
|
|
||||||
type PubSub[V any] struct {
|
|
||||||
subscribers []*ReceiverFunc[V]
|
|
||||||
mu sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPubSub creates a new PubSub instance.
|
|
||||||
func NewPubSub[V any]() *PubSub[V] {
|
|
||||||
return &PubSub[V]{
|
|
||||||
subscribers: make([]*ReceiverFunc[V], 0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe adds a grain ID to the subscribers of a topic.
|
|
||||||
func (p *PubSub[V]) Subscribe(receiver ReceiverFunc[V]) {
|
|
||||||
if receiver == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
// log.Printf("adding subscriber")
|
|
||||||
p.subscribers = append(p.subscribers, &receiver)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unsubscribe removes a grain ID from the subscribers of a topic.
|
|
||||||
func (p *PubSub[V]) Unsubscribe(receiver ReceiverFunc[V]) {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
list := p.subscribers
|
|
||||||
prt := &receiver
|
|
||||||
for i, sub := range list {
|
|
||||||
if sub == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if sub == prt {
|
|
||||||
// log.Printf("removing subscriber")
|
|
||||||
p.subscribers = append(list[:i], list[i+1:]...)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p.subscribers = slices.DeleteFunc(p.subscribers, func(fn *ReceiverFunc[V]) bool {
|
|
||||||
return fn == nil
|
|
||||||
})
|
|
||||||
// If list is empty, could delete, but not necessary
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSubscribers returns a copy of the subscriber IDs for a topic.
|
|
||||||
func (p *PubSub[V]) GetSubscribers() iter.Seq[ReceiverFunc[V]] {
|
|
||||||
p.mu.RLock()
|
|
||||||
defer p.mu.RUnlock()
|
|
||||||
|
|
||||||
return func(yield func(ReceiverFunc[V]) bool) {
|
|
||||||
for _, sub := range p.subscribers {
|
|
||||||
if sub == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !yield(*sub) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish sends an event to all subscribers of the topic.
|
|
||||||
func (p *PubSub[V]) Publish(event V) {
|
|
||||||
for notify := range p.GetSubscribers() {
|
|
||||||
notify(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,462 +0,0 @@
|
|||||||
package actor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"maps"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SimpleGrainPool[V any] struct {
|
|
||||||
// fields and methods
|
|
||||||
localMu sync.RWMutex
|
|
||||||
grains map[uint64]Grain[V]
|
|
||||||
mutationRegistry MutationRegistry
|
|
||||||
spawn func(ctx context.Context, id uint64) (Grain[V], error)
|
|
||||||
destroy func(grain Grain[V]) error
|
|
||||||
spawnHost func(host string) (Host[V], error)
|
|
||||||
listeners []LogListener
|
|
||||||
storage LogStorage[V]
|
|
||||||
ttl time.Duration
|
|
||||||
poolSize int
|
|
||||||
|
|
||||||
// Cluster coordination --------------------------------------------------
|
|
||||||
hostname string
|
|
||||||
remoteMu sync.RWMutex
|
|
||||||
remoteOwners map[uint64]Host[V]
|
|
||||||
remoteHosts map[string]Host[V]
|
|
||||||
//discardedHostHandler *DiscardedHostHandler
|
|
||||||
|
|
||||||
// House-keeping ---------------------------------------------------------
|
|
||||||
purgeTicker *time.Ticker
|
|
||||||
}
|
|
||||||
|
|
||||||
type GrainPoolConfig[V any] struct {
|
|
||||||
Hostname string
|
|
||||||
Spawn func(ctx context.Context, id uint64) (Grain[V], error)
|
|
||||||
SpawnHost func(host string) (Host[V], error)
|
|
||||||
Destroy func(grain Grain[V]) error
|
|
||||||
TTL time.Duration
|
|
||||||
PoolSize int
|
|
||||||
MutationRegistry MutationRegistry
|
|
||||||
Storage LogStorage[V]
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V], error) {
|
|
||||||
p := &SimpleGrainPool[V]{
|
|
||||||
grains: make(map[uint64]Grain[V]),
|
|
||||||
mutationRegistry: config.MutationRegistry,
|
|
||||||
storage: config.Storage,
|
|
||||||
spawn: config.Spawn,
|
|
||||||
spawnHost: config.SpawnHost,
|
|
||||||
destroy: config.Destroy,
|
|
||||||
ttl: config.TTL,
|
|
||||||
poolSize: config.PoolSize,
|
|
||||||
hostname: config.Hostname,
|
|
||||||
remoteOwners: make(map[uint64]Host[V]),
|
|
||||||
remoteHosts: make(map[string]Host[V]),
|
|
||||||
}
|
|
||||||
|
|
||||||
p.purgeTicker = time.NewTicker(time.Minute)
|
|
||||||
go func() {
|
|
||||||
for range p.purgeTicker.C {
|
|
||||||
p.purge()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) AddListener(listener LogListener) {
|
|
||||||
p.listeners = append(p.listeners, listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) RemoveListener(listener LogListener) {
|
|
||||||
for i, l := range p.listeners {
|
|
||||||
if l == listener {
|
|
||||||
p.listeners = append(p.listeners[:i], p.listeners[i+1:]...)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) purge() {
|
|
||||||
purgeLimit := time.Now().Add(-p.ttl)
|
|
||||||
purgedIds := make([]uint64, 0, len(p.grains))
|
|
||||||
p.localMu.Lock()
|
|
||||||
for id, grain := range p.grains {
|
|
||||||
if grain.GetLastAccess().Before(purgeLimit) {
|
|
||||||
purgedIds = append(purgedIds, id)
|
|
||||||
if err := p.destroy(grain); err != nil {
|
|
||||||
log.Printf("failed to destroy grain %d: %v", id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(p.grains, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p.localMu.Unlock()
|
|
||||||
p.forAllHosts(func(remote Host[V]) {
|
|
||||||
remote.AnnounceExpiry(purgedIds)
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// LocalUsage returns the number of resident grains and configured capacity.
|
|
||||||
func (p *SimpleGrainPool[V]) LocalUsage() (int, int) {
|
|
||||||
p.localMu.RLock()
|
|
||||||
defer p.localMu.RUnlock()
|
|
||||||
return len(p.grains), p.poolSize
|
|
||||||
}
|
|
||||||
|
|
||||||
// LocalCartIDs returns the currently owned cart ids (for control-plane RPCs).
|
|
||||||
func (p *SimpleGrainPool[V]) GetLocalIds() []uint64 {
|
|
||||||
p.localMu.RLock()
|
|
||||||
defer p.localMu.RUnlock()
|
|
||||||
ids := make([]uint64, 0, len(p.grains))
|
|
||||||
for _, g := range p.grains {
|
|
||||||
if g == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ids = append(ids, uint64(g.GetId()))
|
|
||||||
}
|
|
||||||
return ids
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) HandleRemoteExpiry(host string, ids []uint64) error {
|
|
||||||
p.remoteMu.Lock()
|
|
||||||
defer p.remoteMu.Unlock()
|
|
||||||
for _, id := range ids {
|
|
||||||
delete(p.remoteOwners, id)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) HandleOwnershipChange(host string, ids []uint64) error {
|
|
||||||
p.remoteMu.RLock()
|
|
||||||
remoteHost, exists := p.remoteHosts[host]
|
|
||||||
p.remoteMu.RUnlock()
|
|
||||||
if !exists {
|
|
||||||
createdHost, err := p.AddRemote(host)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
remoteHost = createdHost
|
|
||||||
}
|
|
||||||
p.remoteMu.Lock()
|
|
||||||
defer p.remoteMu.Unlock()
|
|
||||||
p.localMu.Lock()
|
|
||||||
defer p.localMu.Unlock()
|
|
||||||
for _, id := range ids {
|
|
||||||
log.Printf("Handling ownership change for cart %d to host %s", id, host)
|
|
||||||
delete(p.grains, id)
|
|
||||||
p.remoteOwners[id] = remoteHost
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TakeOwnership takes ownership of a grain.
|
|
||||||
func (p *SimpleGrainPool[V]) TakeOwnership(id uint64) {
|
|
||||||
p.broadcastOwnership([]uint64{id})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) AddRemoteHost(host string) {
|
|
||||||
p.AddRemote(host)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) AddRemote(host string) (Host[V], error) {
|
|
||||||
if host == "" {
|
|
||||||
return nil, fmt.Errorf("host is empty")
|
|
||||||
}
|
|
||||||
if host == p.hostname {
|
|
||||||
return nil, fmt.Errorf("same host, this should not happen")
|
|
||||||
}
|
|
||||||
p.remoteMu.RLock()
|
|
||||||
existing, found := p.remoteHosts[host]
|
|
||||||
p.remoteMu.RUnlock()
|
|
||||||
if found {
|
|
||||||
return existing, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
remote, err := p.spawnHost(host)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("AddRemote %s failed: %v", host, err)
|
|
||||||
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
p.remoteMu.Lock()
|
|
||||||
p.remoteHosts[host] = remote
|
|
||||||
p.remoteMu.Unlock()
|
|
||||||
// connectedRemotes.Set(float64(p.RemoteCount()))
|
|
||||||
|
|
||||||
log.Printf("Connected to remote host %s", host)
|
|
||||||
go p.pingLoop(remote)
|
|
||||||
go p.initializeRemote(remote)
|
|
||||||
go p.SendNegotiation()
|
|
||||||
return remote, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) initializeRemote(remote Host[V]) {
|
|
||||||
|
|
||||||
remotesIds := remote.GetActorIds()
|
|
||||||
|
|
||||||
p.remoteMu.Lock()
|
|
||||||
for _, id := range remotesIds {
|
|
||||||
p.localMu.Lock()
|
|
||||||
delete(p.grains, id)
|
|
||||||
p.localMu.Unlock()
|
|
||||||
if _, exists := p.remoteOwners[id]; !exists {
|
|
||||||
p.remoteOwners[id] = remote
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p.remoteMu.Unlock()
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) RemoveHost(host string) {
|
|
||||||
p.remoteMu.Lock()
|
|
||||||
remote, exists := p.remoteHosts[host]
|
|
||||||
|
|
||||||
if exists {
|
|
||||||
go remote.Close()
|
|
||||||
delete(p.remoteHosts, host)
|
|
||||||
}
|
|
||||||
count := 0
|
|
||||||
for id, owner := range p.remoteOwners {
|
|
||||||
if owner.Name() == host {
|
|
||||||
count++
|
|
||||||
delete(p.remoteOwners, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.Printf("Removing host %s, grains: %d", host, count)
|
|
||||||
p.remoteMu.Unlock()
|
|
||||||
|
|
||||||
if exists {
|
|
||||||
remote.Close()
|
|
||||||
}
|
|
||||||
// connectedRemotes.Set(float64(p.RemoteCount()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) RemoteCount() int {
|
|
||||||
p.remoteMu.RLock()
|
|
||||||
defer p.remoteMu.RUnlock()
|
|
||||||
return len(p.remoteHosts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoteHostNames returns a snapshot of connected remote host identifiers.
|
|
||||||
func (p *SimpleGrainPool[V]) RemoteHostNames() []string {
|
|
||||||
p.remoteMu.RLock()
|
|
||||||
defer p.remoteMu.RUnlock()
|
|
||||||
hosts := make([]string, 0, len(p.remoteHosts))
|
|
||||||
for host := range p.remoteHosts {
|
|
||||||
hosts = append(hosts, host)
|
|
||||||
}
|
|
||||||
return hosts
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) IsKnown(host string) bool {
|
|
||||||
if host == p.hostname {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
p.remoteMu.RLock()
|
|
||||||
defer p.remoteMu.RUnlock()
|
|
||||||
_, ok := p.remoteHosts[host]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) pingLoop(remote Host[V]) {
|
|
||||||
remote.Ping()
|
|
||||||
ticker := time.NewTicker(5 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for range ticker.C {
|
|
||||||
if !remote.Ping() {
|
|
||||||
if !remote.IsHealthy() {
|
|
||||||
log.Printf("Remote %s unhealthy, removing", remote.Name())
|
|
||||||
p.Close()
|
|
||||||
p.RemoveHost(remote.Name())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) IsHealthy() bool {
|
|
||||||
p.remoteMu.RLock()
|
|
||||||
defer p.remoteMu.RUnlock()
|
|
||||||
for _, r := range p.remoteHosts {
|
|
||||||
if !r.IsHealthy() {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) Negotiate(otherHosts []string) {
|
|
||||||
|
|
||||||
for _, host := range otherHosts {
|
|
||||||
if host != p.hostname {
|
|
||||||
p.remoteMu.RLock()
|
|
||||||
_, ok := p.remoteHosts[host]
|
|
||||||
p.remoteMu.RUnlock()
|
|
||||||
if !ok {
|
|
||||||
go p.AddRemote(host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) SendNegotiation() {
|
|
||||||
//negotiationCount.Inc()
|
|
||||||
|
|
||||||
p.remoteMu.RLock()
|
|
||||||
hosts := make([]string, 0, len(p.remoteHosts)+1)
|
|
||||||
hosts = append(hosts, p.hostname)
|
|
||||||
remotes := make([]Host[V], 0, len(p.remoteHosts))
|
|
||||||
for h, r := range p.remoteHosts {
|
|
||||||
hosts = append(hosts, h)
|
|
||||||
remotes = append(remotes, r)
|
|
||||||
}
|
|
||||||
p.remoteMu.RUnlock()
|
|
||||||
|
|
||||||
p.forAllHosts(func(remote Host[V]) {
|
|
||||||
knownByRemote, err := remote.Negotiate(hosts)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Negotiate with %s failed: %v", remote.Name(), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, h := range knownByRemote {
|
|
||||||
if !p.IsKnown(h) {
|
|
||||||
go p.AddRemote(h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) forAllHosts(fn func(Host[V])) {
|
|
||||||
p.remoteMu.RLock()
|
|
||||||
rh := maps.Clone(p.remoteHosts)
|
|
||||||
p.remoteMu.RUnlock()
|
|
||||||
|
|
||||||
wg := sync.WaitGroup{}
|
|
||||||
for _, host := range rh {
|
|
||||||
|
|
||||||
wg.Go(func() { fn(host) })
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, host := range rh {
|
|
||||||
if !host.IsHealthy() {
|
|
||||||
host.Close()
|
|
||||||
p.remoteMu.Lock()
|
|
||||||
delete(p.remoteHosts, name)
|
|
||||||
p.remoteMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) broadcastOwnership(ids []uint64) {
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
p.forAllHosts(func(rh Host[V]) {
|
|
||||||
rh.AnnounceOwnership(p.hostname, ids)
|
|
||||||
})
|
|
||||||
log.Printf("%s taking ownership of %d ids", p.hostname, len(ids))
|
|
||||||
// go p.statsUpdate()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) getOrClaimGrain(ctx context.Context, id uint64) (Grain[V], error) {
|
|
||||||
p.localMu.RLock()
|
|
||||||
grain, exists := p.grains[id]
|
|
||||||
p.localMu.RUnlock()
|
|
||||||
if exists && grain != nil {
|
|
||||||
return grain, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
grain, err := p.spawn(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
go p.broadcastOwnership([]uint64{id})
|
|
||||||
p.localMu.Lock()
|
|
||||||
p.grains[id] = grain
|
|
||||||
p.localMu.Unlock()
|
|
||||||
|
|
||||||
return grain, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// // ErrNotOwner is returned when a cart belongs to another host.
|
|
||||||
// var ErrNotOwner = fmt.Errorf("not owner")
|
|
||||||
|
|
||||||
// Apply applies a mutation to a grain.
|
|
||||||
func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[V], error) {
|
|
||||||
grain, err := p.getOrClaimGrain(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
mutations, err := p.mutationRegistry.Apply(ctx, grain, mutation...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if p.storage != nil {
|
|
||||||
go func() {
|
|
||||||
if err := p.storage.AppendMutations(id, mutation...); err != nil {
|
|
||||||
log.Printf("failed to store mutation for grain %d: %v", id, err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
for _, listener := range p.listeners {
|
|
||||||
go listener.AppendMutations(id, mutations...)
|
|
||||||
}
|
|
||||||
result, err := grain.GetCurrentState()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &MutationResult[V]{
|
|
||||||
Result: *result,
|
|
||||||
Mutations: mutations,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns the current state of a grain.
|
|
||||||
func (p *SimpleGrainPool[V]) Get(ctx context.Context, id uint64) (*V, error) {
|
|
||||||
grain, err := p.getOrClaimGrain(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return grain.GetCurrentState()
|
|
||||||
}
|
|
||||||
|
|
||||||
// OwnerHost reports the remote owner (if any) for the supplied cart id.
|
|
||||||
func (p *SimpleGrainPool[V]) OwnerHost(id uint64) (Host[V], bool) {
|
|
||||||
p.remoteMu.RLock()
|
|
||||||
defer p.remoteMu.RUnlock()
|
|
||||||
owner, ok := p.remoteOwners[id]
|
|
||||||
return owner, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hostname returns the local hostname (pod IP).
|
|
||||||
func (p *SimpleGrainPool[V]) Hostname() string {
|
|
||||||
return p.hostname
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close notifies remotes that this host is shutting down.
|
|
||||||
func (p *SimpleGrainPool[V]) Close() {
|
|
||||||
|
|
||||||
p.forAllHosts(func(rh Host[V]) {
|
|
||||||
rh.Close()
|
|
||||||
})
|
|
||||||
|
|
||||||
if p.purgeTicker != nil {
|
|
||||||
p.purgeTicker.Stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
package actor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
type StateStorage struct {
|
|
||||||
registry MutationRegistry
|
|
||||||
}
|
|
||||||
|
|
||||||
type StorageEvent struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
TimeStamp time.Time `json:"timestamp"`
|
|
||||||
Mutation proto.Message `json:"mutation"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type rawEvent struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
TimeStamp time.Time `json:"timestamp"`
|
|
||||||
Mutation json.RawMessage `json:"mutation"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewState(registry MutationRegistry) *StateStorage {
|
|
||||||
return &StateStorage{
|
|
||||||
registry: registry,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var ErrUnknownType = errors.New("unknown type")
|
|
||||||
|
|
||||||
func (s *StateStorage) Load(r io.Reader, onMessage func(msg proto.Message, timeStamp time.Time)) error {
|
|
||||||
var err error
|
|
||||||
var evt *StorageEvent
|
|
||||||
scanner := bufio.NewScanner(r)
|
|
||||||
for err == nil {
|
|
||||||
evt, err = s.Read(scanner)
|
|
||||||
if err == nil {
|
|
||||||
onMessage(evt.Mutation, evt.TimeStamp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err == io.EOF {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *StateStorage) Append(io io.Writer, mutation proto.Message, timeStamp time.Time) error {
|
|
||||||
typeName, ok := s.registry.GetTypeName(mutation)
|
|
||||||
if !ok {
|
|
||||||
return ErrUnknownType
|
|
||||||
}
|
|
||||||
event := &StorageEvent{
|
|
||||||
Type: typeName,
|
|
||||||
TimeStamp: timeStamp,
|
|
||||||
Mutation: mutation,
|
|
||||||
}
|
|
||||||
jsonBytes, err := json.Marshal(event)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := io.Write(jsonBytes); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
io.Write([]byte("\n"))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *StateStorage) Read(r *bufio.Scanner) (*StorageEvent, error) {
|
|
||||||
var event rawEvent
|
|
||||||
|
|
||||||
if r.Scan() {
|
|
||||||
b := r.Bytes()
|
|
||||||
err := json.Unmarshal(b, &event)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
typeName := event.Type
|
|
||||||
mutation, ok := s.registry.Create(typeName)
|
|
||||||
if !ok {
|
|
||||||
return nil, ErrUnknownType
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(event.Mutation, mutation); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &StorageEvent{
|
|
||||||
Type: typeName,
|
|
||||||
TimeStamp: event.TimeStamp,
|
|
||||||
Mutation: mutation,
|
|
||||||
}, r.Err()
|
|
||||||
}
|
|
||||||
return nil, io.EOF
|
|
||||||
}
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/voucher"
|
|
||||||
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Legacy padded [16]byte CartId and its helper methods removed.
|
|
||||||
// Unified CartId (uint64 with base62 string form) now defined in cart_id.go.
|
|
||||||
|
|
||||||
type StockStatus int
|
|
||||||
|
|
||||||
type ItemMeta struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Brand string `json:"brand,omitempty"`
|
|
||||||
Category string `json:"category,omitempty"`
|
|
||||||
Category2 string `json:"category2,omitempty"`
|
|
||||||
Category3 string `json:"category3,omitempty"`
|
|
||||||
Category4 string `json:"category4,omitempty"`
|
|
||||||
Category5 string `json:"category5,omitempty"`
|
|
||||||
SellerName string `json:"sellerName,omitempty"`
|
|
||||||
Image string `json:"image,omitempty"`
|
|
||||||
Outlet *string `json:"outlet,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CartItem struct {
|
|
||||||
Id uint32 `json:"id"`
|
|
||||||
ItemId uint32 `json:"itemId,omitempty"`
|
|
||||||
ParentId *uint32 `json:"parentId,omitempty"`
|
|
||||||
Sku string `json:"sku"`
|
|
||||||
Price Price `json:"price"`
|
|
||||||
TotalPrice Price `json:"totalPrice"`
|
|
||||||
SellerId string `json:"sellerId,omitempty"`
|
|
||||||
OrgPrice *Price `json:"orgPrice,omitempty"`
|
|
||||||
Cgm string `json:"cgm,omitempty"`
|
|
||||||
Tax int
|
|
||||||
Stock uint16 `json:"stock"`
|
|
||||||
Quantity uint16 `json:"qty"`
|
|
||||||
Discount *Price `json:"discount,omitempty"`
|
|
||||||
Disclaimer string `json:"disclaimer,omitempty"`
|
|
||||||
ArticleType string `json:"type,omitempty"`
|
|
||||||
StoreId *string `json:"storeId,omitempty"`
|
|
||||||
Meta *ItemMeta `json:"meta,omitempty"`
|
|
||||||
SaleStatus string `json:"saleStatus"`
|
|
||||||
Marking *Marking `json:"marking,omitempty"`
|
|
||||||
SubscriptionDetailsId string `json:"subscriptionDetailsId,omitempty"`
|
|
||||||
OrderReference string `json:"orderReference,omitempty"`
|
|
||||||
IsSubscribed bool `json:"isSubscribed,omitempty"`
|
|
||||||
ReservationEndTime *time.Time `json:"reservationEndTime,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CartNotification struct {
|
|
||||||
LinkedId int `json:"id"`
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SubscriptionDetails struct {
|
|
||||||
Id string `json:"id,omitempty"`
|
|
||||||
Version uint16 `json:"version"`
|
|
||||||
OfferingCode string `json:"offeringCode,omitempty"`
|
|
||||||
SigningType string `json:"signingType,omitempty"`
|
|
||||||
Meta json.RawMessage `json:"data,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Notice struct {
|
|
||||||
Timestamp time.Time `json:"timestamp"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Code *string `json:"code,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CartPaymentStatus string
|
|
||||||
|
|
||||||
const (
|
|
||||||
CartPaymentStatusPending CartPaymentStatus = "pending"
|
|
||||||
CartPaymentStatusFailed CartPaymentStatus = "failed"
|
|
||||||
CartPaymentStatusSuccess CartPaymentStatus = "success"
|
|
||||||
CartPaymentStatusCancelled CartPaymentStatus = "partial"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Marking struct {
|
|
||||||
Type uint32 `json:"type"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CartGrain struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
lastItemId uint32
|
|
||||||
lastVoucherId uint32
|
|
||||||
lastAccess time.Time
|
|
||||||
lastChange time.Time // unix seconds of last successful mutation (replay sets from event ts)
|
|
||||||
userId string
|
|
||||||
Currency string `json:"currency"`
|
|
||||||
Language string `json:"language"`
|
|
||||||
Version uint `json:"version"`
|
|
||||||
InventoryReserved bool `json:"inventoryReserved"`
|
|
||||||
Id CartId `json:"id"`
|
|
||||||
Items []*CartItem `json:"items"`
|
|
||||||
TotalPrice *Price `json:"totalPrice"`
|
|
||||||
TotalDiscount *Price `json:"totalDiscount"`
|
|
||||||
Processing bool `json:"processing"`
|
|
||||||
//PaymentInProgress uint16 `json:"paymentInProgress"`
|
|
||||||
OrderReference string `json:"orderReference,omitempty"`
|
|
||||||
|
|
||||||
Vouchers []*Voucher `json:"vouchers,omitempty"`
|
|
||||||
Notifications []CartNotification `json:"cartNotification,omitempty"`
|
|
||||||
SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"`
|
|
||||||
|
|
||||||
//CheckoutOrderId string `json:"checkoutOrderId,omitempty"`
|
|
||||||
CheckoutStatus *CartPaymentStatus `json:"checkoutStatus,omitempty"`
|
|
||||||
//CheckoutCountry string `json:"checkoutCountry,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Voucher struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
Applied bool `json:"applied"`
|
|
||||||
Rules []string `json:"rules"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
Id uint32 `json:"id"`
|
|
||||||
Value int64 `json:"value"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
|
|
||||||
// No rules -> applies to entire cart
|
|
||||||
if len(v.Rules) == 0 {
|
|
||||||
return cart.Items, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build evaluation context once
|
|
||||||
ctx := voucher.EvalContext{
|
|
||||||
Items: make([]voucher.Item, 0, len(cart.Items)),
|
|
||||||
CartTotalInc: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
if cart.TotalPrice != nil {
|
|
||||||
ctx.CartTotalInc = cart.TotalPrice.IncVat
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, it := range cart.Items {
|
|
||||||
category := ""
|
|
||||||
if it.Meta != nil {
|
|
||||||
category = it.Meta.Category
|
|
||||||
}
|
|
||||||
ctx.Items = append(ctx.Items, voucher.Item{
|
|
||||||
Sku: it.Sku,
|
|
||||||
Category: category,
|
|
||||||
UnitPrice: it.Price.IncVat,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// All voucher rules must pass (logical AND)
|
|
||||||
for _, expr := range v.Rules {
|
|
||||||
|
|
||||||
if expr == "" {
|
|
||||||
// Empty condition treated as pass (acts like a comment / placeholder)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rs, err := voucher.ParseRules(expr)
|
|
||||||
if err != nil {
|
|
||||||
// Fail closed on parse error
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
if !rs.Applies(ctx) {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cart.Items, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCartGrain(id uint64, ts time.Time) *CartGrain {
|
|
||||||
return &CartGrain{
|
|
||||||
lastItemId: 0,
|
|
||||||
lastVoucherId: 0,
|
|
||||||
lastAccess: ts,
|
|
||||||
lastChange: ts,
|
|
||||||
TotalDiscount: NewPrice(),
|
|
||||||
Vouchers: []*Voucher{},
|
|
||||||
Id: CartId(id),
|
|
||||||
Items: []*CartItem{},
|
|
||||||
TotalPrice: NewPrice(),
|
|
||||||
SubscriptionDetails: make(map[string]*SubscriptionDetails),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CartGrain) GetId() uint64 {
|
|
||||||
return uint64(c.Id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CartGrain) GetLastChange() time.Time {
|
|
||||||
return c.lastChange
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CartGrain) GetLastAccess() time.Time {
|
|
||||||
return c.lastAccess
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CartGrain) GetCurrentState() (*CartGrain, error) {
|
|
||||||
c.lastAccess = time.Now()
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CartGrain) HandleInventoryChange(change inventory.InventoryChange) {
|
|
||||||
for _, item := range c.Items {
|
|
||||||
l := "se"
|
|
||||||
if item.StoreId != nil {
|
|
||||||
l = *item.StoreId
|
|
||||||
}
|
|
||||||
if item.Sku == change.SKU && change.StockLocationID == l {
|
|
||||||
item.Stock = uint16(change.Value)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CartGrain) GetState() ([]byte, error) {
|
|
||||||
return json.Marshal(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CartGrain) FindItemWithSku(sku string) (*CartItem, bool) {
|
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
for _, item := range c.Items {
|
|
||||||
if item.Sku == sku {
|
|
||||||
return item, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CartGrain) UpdateTotals() {
|
|
||||||
c.TotalPrice = NewPrice()
|
|
||||||
c.TotalDiscount = NewPrice()
|
|
||||||
|
|
||||||
for _, item := range c.Items {
|
|
||||||
rowTotal := MultiplyPrice(item.Price, int64(item.Quantity))
|
|
||||||
|
|
||||||
if item.OrgPrice != nil {
|
|
||||||
diff := NewPrice()
|
|
||||||
diff.Add(*item.OrgPrice)
|
|
||||||
diff.Subtract(item.Price)
|
|
||||||
diff.Multiply(int64(item.Quantity))
|
|
||||||
//rowTotal.Subtract(*diff)
|
|
||||||
item.Discount = diff
|
|
||||||
if diff.IncVat > 0 {
|
|
||||||
c.TotalDiscount.Add(*diff)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
item.TotalPrice = *rowTotal
|
|
||||||
|
|
||||||
c.TotalPrice.Add(*rowTotal)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, voucher := range c.Vouchers {
|
|
||||||
_, ok := voucher.AppliesTo(c)
|
|
||||||
voucher.Applied = false
|
|
||||||
if ok {
|
|
||||||
value := NewPriceFromIncVat(voucher.Value, 25)
|
|
||||||
if c.TotalPrice.IncVat <= value.IncVat {
|
|
||||||
// don't apply discounts to more than the total price
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
voucher.Applied = true
|
|
||||||
c.TotalDiscount.Add(*value)
|
|
||||||
c.TotalPrice.Subtract(*value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/actor"
|
|
||||||
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CartMutationContext struct {
|
|
||||||
reservationService inventory.CartReservationService
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCartMutationContext(reservationService inventory.CartReservationService) *CartMutationContext {
|
|
||||||
return &CartMutationContext{
|
|
||||||
reservationService: reservationService,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CartMutationContext) ReserveItem(ctx context.Context, cartId CartId, sku string, locationId *string, quantity uint16) (*time.Time, error) {
|
|
||||||
|
|
||||||
if quantity <= 0 || c.reservationService == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
l := inventory.LocationID("se")
|
|
||||||
if locationId != nil {
|
|
||||||
l = inventory.LocationID(*locationId)
|
|
||||||
}
|
|
||||||
ttl := time.Minute * 15
|
|
||||||
endTime := time.Now().Add(ttl)
|
|
||||||
err := c.reservationService.ReserveForCart(ctx, inventory.CartReserveRequest{
|
|
||||||
CartID: inventory.CartID(cartId.String()),
|
|
||||||
InventoryReference: &inventory.InventoryReference{
|
|
||||||
SKU: inventory.SKU(sku),
|
|
||||||
LocationID: l,
|
|
||||||
},
|
|
||||||
TTL: ttl,
|
|
||||||
Quantity: uint32(quantity),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &endTime, nil
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CartMutationContext) UseReservations(item *CartItem) bool {
|
|
||||||
if item.ReservationEndTime != nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return item.Cgm == "55010"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CartMutationContext) ReleaseItem(ctx context.Context, cartId CartId, sku string, locationId *string) error {
|
|
||||||
if c.reservationService == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
l := inventory.LocationID("se")
|
|
||||||
if locationId != nil {
|
|
||||||
l = inventory.LocationID(*locationId)
|
|
||||||
}
|
|
||||||
return c.reservationService.ReleaseForCart(ctx, inventory.SKU(sku), l, inventory.CartID(cartId.String()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegistry {
|
|
||||||
|
|
||||||
reg := actor.NewMutationRegistry()
|
|
||||||
reg.RegisterMutations(
|
|
||||||
actor.NewMutation(context.AddItem),
|
|
||||||
actor.NewMutation(context.ChangeQuantity),
|
|
||||||
actor.NewMutation(context.RemoveItem),
|
|
||||||
actor.NewMutation(ClearCart),
|
|
||||||
actor.NewMutation(AddVoucher),
|
|
||||||
actor.NewMutation(RemoveVoucher),
|
|
||||||
actor.NewMutation(UpsertSubscriptionDetails),
|
|
||||||
actor.NewMutation(SetUserId),
|
|
||||||
actor.NewMutation(LineItemMarking),
|
|
||||||
actor.NewMutation(RemoveLineItemMarking),
|
|
||||||
actor.NewMutation(SubscriptionAdded),
|
|
||||||
// actor.NewMutation(SubscriptionRemoved),
|
|
||||||
)
|
|
||||||
return reg
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/actor"
|
|
||||||
)
|
|
||||||
|
|
||||||
// cart_id.go
|
|
||||||
//
|
|
||||||
// Breaking change:
|
|
||||||
// Unified cart identifier as a raw 64-bit unsigned integer (type CartId uint64).
|
|
||||||
// External textual representation: base62 (0-9 A-Z a-z), shortest possible
|
|
||||||
// encoding for 64 bits (max 11 characters, since 62^11 > 2^64).
|
|
||||||
//
|
|
||||||
// Rationale:
|
|
||||||
// - Replaces legacy fixed [16]byte padded string and transitional CartID wrapper.
|
|
||||||
// - Provides compact, URL/cookie-friendly identifiers.
|
|
||||||
// - O(1) hashing and minimal memory footprint.
|
|
||||||
// - 64 bits of crypto randomness => negligible collision probability at realistic scale.
|
|
||||||
//
|
|
||||||
// Public API:
|
|
||||||
// type CartId uint64
|
|
||||||
// func NewCartId() (CartId, error)
|
|
||||||
// func MustNewCartId() CartId
|
|
||||||
// func ParseCartId(string) (CartId, bool)
|
|
||||||
// func MustParseCartId(string) CartId
|
|
||||||
// (CartId).String() string
|
|
||||||
// (CartId).MarshalJSON() / UnmarshalJSON()
|
|
||||||
//
|
|
||||||
// NOTE:
|
|
||||||
// All legacy helpers (UpgradeLegacyCartId, Fallback hashing, Canonicalize variants,
|
|
||||||
// CartIDToLegacy, LegacyToCartID) have been removed as part of the breaking change.
|
|
||||||
//
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type CartId actor.GrainId
|
|
||||||
|
|
||||||
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
||||||
|
|
||||||
// Reverse lookup (0xFF marks invalid)
|
|
||||||
var base62Rev [256]byte
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
for i := range base62Rev {
|
|
||||||
base62Rev[i] = 0xFF
|
|
||||||
}
|
|
||||||
for i := 0; i < len(base62Alphabet); i++ {
|
|
||||||
base62Rev[base62Alphabet[i]] = byte(i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// String returns the canonical base62 encoding of the 64-bit id.
|
|
||||||
func (id CartId) String() string {
|
|
||||||
return encodeBase62(uint64(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarshalJSON encodes the cart id as a JSON string.
|
|
||||||
func (id CartId) MarshalJSON() ([]byte, error) {
|
|
||||||
return json.Marshal(id.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnmarshalJSON decodes a cart id from a JSON string containing base62 text.
|
|
||||||
func (id *CartId) UnmarshalJSON(data []byte) error {
|
|
||||||
var s string
|
|
||||||
if err := json.Unmarshal(data, &s); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
parsed, ok := ParseCartId(s)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("invalid cart id: %q", s)
|
|
||||||
}
|
|
||||||
*id = parsed
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewCartId generates a new cryptographically random non-zero 64-bit id.
|
|
||||||
func NewCartId() (CartId, error) {
|
|
||||||
var b [8]byte
|
|
||||||
if _, err := rand.Read(b[:]); err != nil {
|
|
||||||
return 0, fmt.Errorf("NewCartId: %w", err)
|
|
||||||
}
|
|
||||||
u := (uint64(b[0]) << 56) |
|
|
||||||
(uint64(b[1]) << 48) |
|
|
||||||
(uint64(b[2]) << 40) |
|
|
||||||
(uint64(b[3]) << 32) |
|
|
||||||
(uint64(b[4]) << 24) |
|
|
||||||
(uint64(b[5]) << 16) |
|
|
||||||
(uint64(b[6]) << 8) |
|
|
||||||
uint64(b[7])
|
|
||||||
if u == 0 {
|
|
||||||
// Extremely unlikely; regenerate once to avoid "0" identifier if desired.
|
|
||||||
return NewCartId()
|
|
||||||
}
|
|
||||||
return CartId(u), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MustNewCartId panics if generation fails.
|
|
||||||
func MustNewCartId() CartId {
|
|
||||||
id, err := NewCartId()
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseCartId parses a base62 string into a CartId.
|
|
||||||
// Returns (0,false) for invalid input.
|
|
||||||
func ParseCartId(s string) (CartId, bool) {
|
|
||||||
// Accept length 1..11 (11 sufficient for 64 bits). Reject >11 immediately.
|
|
||||||
// Provide a slightly looser upper bound (<=16) only if you anticipate future
|
|
||||||
// extensions; here we stay strict.
|
|
||||||
if len(s) == 0 || len(s) > 11 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
u, ok := decodeBase62(s)
|
|
||||||
if !ok {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return CartId(u), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// MustParseCartId panics on invalid base62 input.
|
|
||||||
func MustParseCartId(s string) CartId {
|
|
||||||
id, ok := ParseCartId(s)
|
|
||||||
if !ok {
|
|
||||||
panic(fmt.Sprintf("invalid cart id: %q", s))
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
// encodeBase62 converts a uint64 to base62 (shortest form).
|
|
||||||
func encodeBase62(u uint64) string {
|
|
||||||
if u == 0 {
|
|
||||||
return "0"
|
|
||||||
}
|
|
||||||
var buf [11]byte
|
|
||||||
i := len(buf)
|
|
||||||
for u > 0 {
|
|
||||||
i--
|
|
||||||
buf[i] = base62Alphabet[u%62]
|
|
||||||
u /= 62
|
|
||||||
}
|
|
||||||
return string(buf[i:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// decodeBase62 converts base62 text to uint64.
|
|
||||||
func decodeBase62(s string) (uint64, bool) {
|
|
||||||
var v uint64
|
|
||||||
for i := 0; i < len(s); i++ {
|
|
||||||
c := s[i]
|
|
||||||
d := base62Rev[c]
|
|
||||||
if d == 0xFF {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
v = v*62 + uint64(d)
|
|
||||||
}
|
|
||||||
return v, true
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestNewCartIdUniqueness generates many ids and checks for collisions.
|
|
||||||
func TestNewCartIdUniqueness(t *testing.T) {
|
|
||||||
const n = 20000
|
|
||||||
seen := make(map[string]struct{}, n)
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
id, err := NewCartId()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewCartId error: %v", err)
|
|
||||||
}
|
|
||||||
s := id.String()
|
|
||||||
if _, exists := seen[s]; exists {
|
|
||||||
t.Fatalf("duplicate id encountered: %s", s)
|
|
||||||
}
|
|
||||||
seen[s] = struct{}{}
|
|
||||||
if s == "" {
|
|
||||||
t.Fatalf("empty string representation for id %d", id)
|
|
||||||
}
|
|
||||||
if len(s) > 11 {
|
|
||||||
t.Fatalf("encoded id length exceeds 11 chars: %s (%d)", s, len(s))
|
|
||||||
}
|
|
||||||
if id == 0 {
|
|
||||||
// We force regeneration on zero, extremely unlikely but test guards intent.
|
|
||||||
t.Fatalf("zero id generated (should be regenerated)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestParseCartIdRoundTrip ensures parse -> string -> parse is stable.
|
|
||||||
func TestParseCartIdRoundTrip(t *testing.T) {
|
|
||||||
id := MustNewCartId()
|
|
||||||
txt := id.String()
|
|
||||||
parsed, ok := ParseCartId(txt)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("ParseCartId failed for valid text %q", txt)
|
|
||||||
}
|
|
||||||
if parsed != id {
|
|
||||||
t.Fatalf("round trip mismatch: original=%d parsed=%d txt=%s", id, parsed, txt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestParseCartIdInvalid covers invalid inputs.
|
|
||||||
func TestParseCartIdInvalid(t *testing.T) {
|
|
||||||
invalid := []string{
|
|
||||||
"", // empty
|
|
||||||
" ", // space
|
|
||||||
"01234567890abc", // >11 chars
|
|
||||||
"!!!!", // invalid chars
|
|
||||||
"-underscore-", // invalid chars
|
|
||||||
"abc_def", // underscore invalid for base62
|
|
||||||
"0123456789ABCD", // 14 chars
|
|
||||||
}
|
|
||||||
for _, s := range invalid {
|
|
||||||
if _, ok := ParseCartId(s); ok {
|
|
||||||
t.Fatalf("expected parse failure for %q", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMustParseCartIdPanics verifies panic behavior for invalid input.
|
|
||||||
func TestMustParseCartIdPanics(t *testing.T) {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r == nil {
|
|
||||||
t.Fatalf("expected panic for invalid MustParseCartId input")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
_ = MustParseCartId("not*base62")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestJSONMarshalUnmarshalCartId verifies JSON round trip.
|
|
||||||
func TestJSONMarshalUnmarshalCartId(t *testing.T) {
|
|
||||||
id := MustNewCartId()
|
|
||||||
data, err := json.Marshal(struct {
|
|
||||||
Cart CartId `json:"cart"`
|
|
||||||
}{Cart: id})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal error: %v", err)
|
|
||||||
}
|
|
||||||
var out struct {
|
|
||||||
Cart CartId `json:"cart"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(data, &out); err != nil {
|
|
||||||
t.Fatalf("unmarshal error: %v", err)
|
|
||||||
}
|
|
||||||
if out.Cart != id {
|
|
||||||
t.Fatalf("JSON round trip mismatch: have %d got %d", id, out.Cart)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBase62LengthBound checks worst-case length (near max uint64).
|
|
||||||
func TestBase62LengthBound(t *testing.T) {
|
|
||||||
// Largest uint64
|
|
||||||
const maxU64 = ^uint64(0)
|
|
||||||
s := encodeBase62(maxU64)
|
|
||||||
if len(s) > 11 {
|
|
||||||
t.Fatalf("max uint64 encoded length > 11: %d (%s)", len(s), s)
|
|
||||||
}
|
|
||||||
dec, ok := decodeBase62(s)
|
|
||||||
if !ok || dec != maxU64 {
|
|
||||||
t.Fatalf("decode failed for max uint64: ok=%v dec=%d want=%d", ok, dec, maxU64)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestZeroEncoding ensures zero value encodes to "0" and parses back.
|
|
||||||
func TestZeroEncoding(t *testing.T) {
|
|
||||||
if s := encodeBase62(0); s != "0" {
|
|
||||||
t.Fatalf("encodeBase62(0) expected '0', got %q", s)
|
|
||||||
}
|
|
||||||
v, ok := decodeBase62("0")
|
|
||||||
if !ok || v != 0 {
|
|
||||||
t.Fatalf("decodeBase62('0') failed: ok=%v v=%d", ok, v)
|
|
||||||
}
|
|
||||||
if _, ok := ParseCartId("0"); !ok {
|
|
||||||
t.Fatalf("ParseCartId(\"0\") should succeed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSequentialParse ensures sequentially generated ids parse correctly.
|
|
||||||
func TestSequentialParse(t *testing.T) {
|
|
||||||
for i := 0; i < 1000; i++ {
|
|
||||||
id := MustNewCartId()
|
|
||||||
txt := id.String()
|
|
||||||
parsed, ok := ParseCartId(txt)
|
|
||||||
if !ok || parsed != id {
|
|
||||||
t.Fatalf("sequential parse mismatch: idx=%d orig=%d parsed=%d txt=%s", i, id, parsed, txt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// BenchmarkNewCartId measures generation performance.
|
|
||||||
func BenchmarkNewCartId(b *testing.B) {
|
|
||||||
for i := 0; i < b.N; i++ {
|
|
||||||
if _, err := NewCartId(); err != nil {
|
|
||||||
b.Fatalf("NewCartId error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// BenchmarkEncodeBase62 measures encoding performance.
|
|
||||||
func BenchmarkEncodeBase62(b *testing.B) {
|
|
||||||
// Precompute sample values
|
|
||||||
samples := make([]uint64, 1024)
|
|
||||||
for i := range samples {
|
|
||||||
// Spread bits without crypto randomness overhead
|
|
||||||
samples[i] = (uint64(i) << 53) ^ (uint64(i) * 0x9E3779B185EBCA87)
|
|
||||||
}
|
|
||||||
b.ResetTimer()
|
|
||||||
var sink string
|
|
||||||
for i := 0; i < b.N; i++ {
|
|
||||||
sink = encodeBase62(samples[i%len(samples)])
|
|
||||||
}
|
|
||||||
_ = sink
|
|
||||||
}
|
|
||||||
|
|
||||||
// BenchmarkDecodeBase62 measures decoding performance.
|
|
||||||
func BenchmarkDecodeBase62(b *testing.B) {
|
|
||||||
encoded := make([]string, 1024)
|
|
||||||
for i := range encoded {
|
|
||||||
encoded[i] = encodeBase62((uint64(i) << 32) | uint64(i))
|
|
||||||
}
|
|
||||||
b.ResetTimer()
|
|
||||||
var sum uint64
|
|
||||||
for i := 0; i < b.N; i++ {
|
|
||||||
v, ok := decodeBase62(encoded[i%len(encoded)])
|
|
||||||
if !ok {
|
|
||||||
b.Fatalf("decode failure for %s", encoded[i%len(encoded)])
|
|
||||||
}
|
|
||||||
sum ^= v
|
|
||||||
}
|
|
||||||
_ = sum
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExampleCartIdString documents usage of CartId string form.
|
|
||||||
func ExampleCartId_string() {
|
|
||||||
id := MustNewCartId()
|
|
||||||
fmt.Println(len(id.String()) <= 11) // outputs true
|
|
||||||
// Output: true
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
cart_messages "git.k6n.net/go-cart-actor/proto/cart"
|
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
|
||||||
)
|
|
||||||
|
|
||||||
// mutation_add_item.go
|
|
||||||
//
|
|
||||||
// Registers the AddItem cart mutation in the generic mutation registry.
|
|
||||||
// This replaces the legacy switch-based logic previously found in CartGrain.Apply.
|
|
||||||
//
|
|
||||||
// Behavior:
|
|
||||||
// - Validates quantity > 0
|
|
||||||
// - If an item with same SKU exists -> increases quantity
|
|
||||||
// - Else creates a new CartItem with computed tax amounts
|
|
||||||
// - Totals recalculated automatically via WithTotals()
|
|
||||||
//
|
|
||||||
// NOTE: Any future field additions in messages.AddItem that affect pricing / tax
|
|
||||||
// must keep this handler in sync.
|
|
||||||
var ErrPaymentInProgress = errors.New("payment in progress")
|
|
||||||
|
|
||||||
func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) error {
|
|
||||||
ctx := context.Background()
|
|
||||||
if m == nil {
|
|
||||||
return fmt.Errorf("AddItem: nil payload")
|
|
||||||
}
|
|
||||||
if m.Quantity < 1 {
|
|
||||||
return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge with any existing item having same SKU and matching StoreId (including both nil).
|
|
||||||
for _, existing := range g.Items {
|
|
||||||
if existing.Sku != m.Sku {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sameStore := (existing.StoreId == nil && m.StoreId == nil) ||
|
|
||||||
(existing.StoreId != nil && m.StoreId != nil && *existing.StoreId == *m.StoreId)
|
|
||||||
if !sameStore {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if c.UseReservations(existing) {
|
|
||||||
if err := c.ReleaseItem(ctx, g.Id, existing.Sku, existing.StoreId); err != nil {
|
|
||||||
log.Printf("failed to release item %d: %v", existing.Id, err)
|
|
||||||
}
|
|
||||||
endTime, err := c.ReserveItem(ctx, g.Id, existing.Sku, existing.StoreId, existing.Quantity+uint16(m.Quantity))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
existing.ReservationEndTime = endTime
|
|
||||||
}
|
|
||||||
existing.Quantity += uint16(m.Quantity)
|
|
||||||
existing.Stock = uint16(m.Stock)
|
|
||||||
// If existing had nil store but new has one, adopt it.
|
|
||||||
if existing.StoreId == nil && m.StoreId != nil {
|
|
||||||
existing.StoreId = m.StoreId
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
g.mu.Lock()
|
|
||||||
defer g.mu.Unlock()
|
|
||||||
|
|
||||||
g.lastItemId++
|
|
||||||
taxRate := float32(25.0)
|
|
||||||
if m.Tax > 0 {
|
|
||||||
taxRate = float32(int(m.Tax) / 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
pricePerItem := NewPriceFromIncVat(m.Price, taxRate)
|
|
||||||
|
|
||||||
needsReservation := true
|
|
||||||
if m.ReservationEndTime != nil {
|
|
||||||
needsReservation = m.ReservationEndTime.AsTime().Before(time.Now())
|
|
||||||
}
|
|
||||||
|
|
||||||
cartItem := &CartItem{
|
|
||||||
Id: g.lastItemId,
|
|
||||||
ItemId: uint32(m.ItemId),
|
|
||||||
Quantity: uint16(m.Quantity),
|
|
||||||
Sku: m.Sku,
|
|
||||||
Tax: int(taxRate * 100),
|
|
||||||
Meta: &ItemMeta{
|
|
||||||
Name: m.Name,
|
|
||||||
Image: m.Image,
|
|
||||||
Brand: m.Brand,
|
|
||||||
Category: m.Category,
|
|
||||||
Category2: m.Category2,
|
|
||||||
Category3: m.Category3,
|
|
||||||
Category4: m.Category4,
|
|
||||||
Category5: m.Category5,
|
|
||||||
Outlet: m.Outlet,
|
|
||||||
SellerName: m.SellerName,
|
|
||||||
},
|
|
||||||
SellerId: m.SellerId,
|
|
||||||
Cgm: m.Cgm,
|
|
||||||
SaleStatus: m.SaleStatus,
|
|
||||||
ParentId: m.ParentId,
|
|
||||||
|
|
||||||
Price: *pricePerItem,
|
|
||||||
TotalPrice: *MultiplyPrice(*pricePerItem, int64(m.Quantity)),
|
|
||||||
|
|
||||||
Stock: uint16(m.Stock),
|
|
||||||
Disclaimer: m.Disclaimer,
|
|
||||||
|
|
||||||
OrgPrice: getOrgPrice(m.OrgPrice, taxRate),
|
|
||||||
ArticleType: m.ArticleType,
|
|
||||||
|
|
||||||
StoreId: m.StoreId,
|
|
||||||
}
|
|
||||||
|
|
||||||
if needsReservation && c.UseReservations(cartItem) {
|
|
||||||
endTime, err := c.ReserveItem(ctx, g.Id, m.Sku, m.StoreId, uint16(m.Quantity))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if endTime != nil {
|
|
||||||
m.ReservationEndTime = timestamppb.New(*endTime)
|
|
||||||
t := m.ReservationEndTime.AsTime()
|
|
||||||
cartItem.ReservationEndTime = &t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
g.Items = append(g.Items, cartItem)
|
|
||||||
g.UpdateTotals()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getOrgPrice(orgPrice int64, taxRate float32) *Price {
|
|
||||||
if orgPrice <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return NewPriceFromIncVat(orgPrice, taxRate)
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"slices"
|
|
||||||
|
|
||||||
"git.k6n.net/go-cart-actor/pkg/actor"
|
|
||||||
messages "git.k6n.net/go-cart-actor/proto/cart"
|
|
||||||
)
|
|
||||||
|
|
||||||
func RemoveVoucher(g *CartGrain, m *messages.RemoveVoucher) error {
|
|
||||||
if m == nil {
|
|
||||||
return &actor.MutationError{
|
|
||||||
Message: "RemoveVoucher: nil payload",
|
|
||||||
Code: 1003,
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if g.CheckoutStatus != nil {
|
|
||||||
return ErrPaymentInProgress
|
|
||||||
}
|
|
||||||
|
|
||||||
if !slices.ContainsFunc(g.Vouchers, func(v *Voucher) bool {
|
|
||||||
return v.Id == m.Id
|
|
||||||
}) {
|
|
||||||
return &actor.MutationError{
|
|
||||||
Message: "voucher not applied",
|
|
||||||
Code: 1004,
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
g.Vouchers = slices.DeleteFunc(g.Vouchers, func(v *Voucher) bool {
|
|
||||||
return v.Id == m.Id
|
|
||||||
})
|
|
||||||
g.UpdateTotals()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func AddVoucher(g *CartGrain, m *messages.AddVoucher) error {
|
|
||||||
if m == nil {
|
|
||||||
return &actor.MutationError{
|
|
||||||
Message: "AddVoucher: nil payload",
|
|
||||||
Code: 1001,
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if slices.ContainsFunc(g.Vouchers, func(v *Voucher) bool {
|
|
||||||
return v.Code == m.Code
|
|
||||||
}) {
|
|
||||||
return &actor.MutationError{
|
|
||||||
Message: "voucher already applied",
|
|
||||||
Code: 1002,
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
g.lastVoucherId++
|
|
||||||
g.Vouchers = append(g.Vouchers, &Voucher{
|
|
||||||
Id: g.lastVoucherId,
|
|
||||||
Applied: false,
|
|
||||||
Description: m.Description,
|
|
||||||
Code: m.Code,
|
|
||||||
Rules: m.VoucherRules,
|
|
||||||
Value: m.Value,
|
|
||||||
})
|
|
||||||
g.UpdateTotals()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
messages "git.k6n.net/go-cart-actor/proto/cart"
|
|
||||||
)
|
|
||||||
|
|
||||||
// mutation_change_quantity.go
|
|
||||||
//
|
|
||||||
// Registers the ChangeQuantity mutation.
|
|
||||||
//
|
|
||||||
// Behavior:
|
|
||||||
// - Locates an item by its cart-local line item Id (not source item_id).
|
|
||||||
// - If requested quantity <= 0 the line is removed.
|
|
||||||
// - Otherwise the line's Quantity field is updated.
|
|
||||||
// - Totals are recalculated (WithTotals).
|
|
||||||
//
|
|
||||||
// Error handling:
|
|
||||||
// - Returns an error if the item Id is not found.
|
|
||||||
// - Returns an error if payload is nil (defensive).
|
|
||||||
//
|
|
||||||
// Concurrency:
|
|
||||||
// - Uses the grain's RW-safe mutation pattern: we mutate in place under
|
|
||||||
// the grain's implicit expectation that higher layers control access.
|
|
||||||
// (If strict locking is required around every mutation, wrap logic in
|
|
||||||
// an explicit g.mu.Lock()/Unlock(), but current model mirrors prior code.)
|
|
||||||
|
|
||||||
func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *messages.ChangeQuantity) error {
|
|
||||||
if m == nil {
|
|
||||||
return fmt.Errorf("ChangeQuantity: nil payload")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
foundIndex := -1
|
|
||||||
for i, it := range g.Items {
|
|
||||||
if it.Id == uint32(m.Id) {
|
|
||||||
foundIndex = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if foundIndex == -1 {
|
|
||||||
return fmt.Errorf("ChangeQuantity: item id %d not found", m.Id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.Quantity <= 0 {
|
|
||||||
// Remove the item
|
|
||||||
itemToRemove := g.Items[foundIndex]
|
|
||||||
if itemToRemove.ReservationEndTime != nil && itemToRemove.ReservationEndTime.Before(time.Now()) {
|
|
||||||
err := c.ReleaseItem(ctx, g.Id, itemToRemove.Sku, itemToRemove.StoreId)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("unable to release reservation for %s in location: %v", itemToRemove.Sku, itemToRemove.StoreId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
g.Items = append(g.Items[:foundIndex], g.Items[foundIndex+1:]...)
|
|
||||||
g.UpdateTotals()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
item := g.Items[foundIndex]
|
|
||||||
if item == nil {
|
|
||||||
return fmt.Errorf("ChangeQuantity: item id %d not found", m.Id)
|
|
||||||
}
|
|
||||||
if c.UseReservations(item) {
|
|
||||||
if item.ReservationEndTime != nil {
|
|
||||||
err := c.ReleaseItem(ctx, g.Id, item.Sku, item.StoreId)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("unable to release reservation for %s in location: %v", item.Sku, item.StoreId)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
endTime, err := c.ReserveItem(ctx, g.Id, item.Sku, item.StoreId, uint16(m.Quantity))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
item.ReservationEndTime = endTime
|
|
||||||
}
|
|
||||||
item.Quantity = uint16(m.Quantity)
|
|
||||||
g.UpdateTotals()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
messages "git.k6n.net/go-cart-actor/proto/cart"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ClearCart(g *CartGrain, m *messages.ClearCartRequest) error {
|
|
||||||
if m == nil {
|
|
||||||
return fmt.Errorf("ClearCart: nil payload")
|
|
||||||
}
|
|
||||||
if g.CheckoutStatus != nil {
|
|
||||||
return fmt.Errorf("ClearCart: cart is in checkout")
|
|
||||||
}
|
|
||||||
// Clear items, vouchers, etc., but keep userId, etc.
|
|
||||||
g.Items = g.Items[:0]
|
|
||||||
g.Vouchers = g.Vouchers[:0]
|
|
||||||
g.Notifications = g.Notifications[:0]
|
|
||||||
g.OrderReference = ""
|
|
||||||
g.Processing = false
|
|
||||||
|
|
||||||
// g.InventoryReserved = false maybe should release inventory
|
|
||||||
g.UpdateTotals()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
messages "git.k6n.net/go-cart-actor/proto/cart"
|
|
||||||
)
|
|
||||||
|
|
||||||
func LineItemMarking(grain *CartGrain, req *messages.LineItemMarking) error {
|
|
||||||
for i, item := range grain.Items {
|
|
||||||
if item.Id == req.Id {
|
|
||||||
grain.Items[i].Marking = &Marking{
|
|
||||||
Type: req.Type,
|
|
||||||
Text: req.Marking,
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("item with ID %d not found", req.Id)
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
messages "git.k6n.net/go-cart-actor/proto/cart"
|
|
||||||
)
|
|
||||||
|
|
||||||
// mutation_remove_item.go
|
|
||||||
//
|
|
||||||
// Registers the RemoveItem mutation.
|
|
||||||
//
|
|
||||||
// Behavior:
|
|
||||||
// - Removes the cart line whose local cart line Id == payload.Id
|
|
||||||
// - If no such line exists returns an error
|
|
||||||
// - Recalculates cart totals (WithTotals)
|
|
||||||
//
|
|
||||||
// Notes:
|
|
||||||
// - This removes only the line item; any deliveries referencing the removed
|
|
||||||
// item are NOT automatically adjusted (mirrors prior logic). If future
|
|
||||||
// semantics require pruning delivery.item_ids you can extend this handler.
|
|
||||||
// - If multiple lines somehow shared the same Id (should not happen), only
|
|
||||||
// the first match would be removed—data integrity relies on unique line Ids.
|
|
||||||
|
|
||||||
func (c *CartMutationContext) RemoveItem(g *CartGrain, m *messages.RemoveItem) error {
|
|
||||||
if m == nil {
|
|
||||||
return fmt.Errorf("RemoveItem: nil payload")
|
|
||||||
}
|
|
||||||
|
|
||||||
targetID := uint32(m.Id)
|
|
||||||
|
|
||||||
index := -1
|
|
||||||
for i, it := range g.Items {
|
|
||||||
if it.Id == targetID {
|
|
||||||
index = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if index == -1 {
|
|
||||||
return fmt.Errorf("RemoveItem: item id %d not found", m.Id)
|
|
||||||
}
|
|
||||||
|
|
||||||
item := g.Items[index]
|
|
||||||
if item.ReservationEndTime != nil && item.ReservationEndTime.After(time.Now()) {
|
|
||||||
err := c.ReleaseItem(context.Background(), g.Id, item.Sku, item.StoreId)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("unable to release item reservation")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
g.Items = append(g.Items[:index], g.Items[index+1:]...)
|
|
||||||
g.UpdateTotals()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
messages "git.k6n.net/go-cart-actor/proto/cart"
|
|
||||||
)
|
|
||||||
|
|
||||||
func RemoveLineItemMarking(grain *CartGrain, req *messages.RemoveLineItemMarking) error {
|
|
||||||
|
|
||||||
for i, item := range grain.Items {
|
|
||||||
if item.Id == req.Id {
|
|
||||||
grain.Items[i].Marking = nil
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("item with ID %d not found", req.Id)
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
messages "git.k6n.net/go-cart-actor/proto/cart"
|
|
||||||
)
|
|
||||||
|
|
||||||
func SetUserId(grain *CartGrain, req *messages.SetUserId) error {
|
|
||||||
if req.UserId == "" {
|
|
||||||
return errors.New("user ID cannot be empty")
|
|
||||||
}
|
|
||||||
grain.userId = req.UserId
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
messages "git.k6n.net/go-cart-actor/proto/cart"
|
|
||||||
)
|
|
||||||
|
|
||||||
func SubscriptionAdded(grain *CartGrain, req *messages.SubscriptionAdded) error {
|
|
||||||
|
|
||||||
for i, item := range grain.Items {
|
|
||||||
if item.Id == req.ItemId {
|
|
||||||
grain.Items[i].SubscriptionDetailsId = req.DetailsId
|
|
||||||
grain.Items[i].OrderReference = req.OrderReference
|
|
||||||
grain.Items[i].IsSubscribed = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("item with ID %d not found", req.ItemId)
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user