missing updates #5
@@ -1,396 +0,0 @@
|
||||
# gRPC Migration Plan
|
||||
|
||||
File: GRPC-MIGRATION-PLAN.md
|
||||
Author: (Generated plan)
|
||||
Status: Draft for review
|
||||
Target Release: Next major version (breaking change – no mixed compatibility)
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This document describes the full migration of the current custom TCP frame-based protocol (both the cart mutation/state channel on port `1337` and the control plane on port `1338`) to gRPC. We will remove all legacy packet framing (`FrameWithPayload`, `RemoteGrain`, `GenericListener` handlers for these two ports) and replace them with two gRPC services:
|
||||
|
||||
1. Cart Actor Service (mutations + state retrieval)
|
||||
2. Control Plane Service (cluster membership, negotiation, ownership change, lifecycle)
|
||||
|
||||
We intentionally keep:
|
||||
- Internal `CartGrain` logic, message storage format, disk persistence, and JSON cart serialization.
|
||||
- Existing message type numeric mapping for backward compatibility with persisted event logs.
|
||||
- HTTP/REST API layer unchanged (it still consumes JSON state from the local/remote grain pipeline).
|
||||
|
||||
We do NOT implement mixed-version compatibility; migration occurs atomically (cluster restart with new image).
|
||||
|
||||
---
|
||||
|
||||
## 2. Goals
|
||||
|
||||
- Remove custom binary frame protocol & simplify maintenance.
|
||||
- Provide clearer, strongly defined interfaces via `.proto` schemas.
|
||||
- Improve observability via gRPC interceptors (metrics & tracing hooks).
|
||||
- Reduce per-call overhead compared with the current manual connection pooling + handwritten framing (HTTP/2 multiplexing + connection reuse).
|
||||
- Prepare groundwork for future enhancements (streaming, typed state, event streaming) without rewriting again.
|
||||
|
||||
---
|
||||
|
||||
## 3. Non-Goals (Phase 1)
|
||||
|
||||
- Converting the cart state payload from JSON to a strongly typed proto.
|
||||
- Introducing authentication / mTLS (may be added later).
|
||||
- Changing persistence or replay format.
|
||||
- Changing the HTTP API contract.
|
||||
- Implementing streaming watchers or push updates.
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture After Migration
|
||||
|
||||
Ports:
|
||||
- `:1337` → gRPC CartActor service.
|
||||
- `:1338` → gRPC ControlPlane service.
|
||||
|
||||
Each node:
|
||||
- Runs one gRPC server with both services (can use a single listener bound to two services or keep two separate listeners; we will keep two ports initially to minimize operational surprise, but they could be merged later).
|
||||
- Maintains a connection pool of `*grpc.ClientConn` objects keyed by remote hostname (one per remote host, reused for both services).
|
||||
|
||||
Call Flow (Mutation):
|
||||
1. HTTP request hits `PoolServer`.
|
||||
2. `SyncedPool.getGrain(cartId)`:
|
||||
- Local: direct invocation.
|
||||
- Remote: uses `RemoteGrainGRPC` (new) which invokes `CartActor.Mutate`.
|
||||
3. Response JSON returned unchanged.
|
||||
|
||||
Control Plane Flow:
|
||||
- Discovery (K8s watch) still triggers `AddRemote(host)`.
|
||||
- Instead of custom `Ping`, `Negotiate`, etc. via frames, call gRPC methods on `ControlPlane` service.
|
||||
- Ownership changes use `ConfirmOwner` RPC.
|
||||
|
||||
---
|
||||
|
||||
## 5. Proto Design
|
||||
|
||||
### 5.1 Cart Actor Proto (Envelope Pattern)
|
||||
|
||||
We keep an envelope with `bytes payload` holding the serialized underlying cart mutation proto (existing types in `messages.proto`). This minimizes churn.
|
||||
|
||||
Indented code block (proto sketch):
|
||||
|
||||
syntax = "proto3";
|
||||
package cart;
|
||||
option go_package = "git.tornberg.me/go-cart-actor/proto;proto";
|
||||
|
||||
enum MutationType {
|
||||
MUTATION_TYPE_UNSPECIFIED = 0;
|
||||
MUTATION_ADD_REQUEST = 1;
|
||||
MUTATION_ADD_ITEM = 2;
|
||||
MUTATION_REMOVE_ITEM = 4;
|
||||
MUTATION_REMOVE_DELIVERY = 5;
|
||||
MUTATION_CHANGE_QUANTITY = 6;
|
||||
MUTATION_SET_DELIVERY = 7;
|
||||
MUTATION_SET_PICKUP_POINT = 8;
|
||||
MUTATION_CREATE_CHECKOUT_ORDER = 9;
|
||||
MUTATION_SET_CART_ITEMS = 10;
|
||||
MUTATION_ORDER_COMPLETED = 11;
|
||||
}
|
||||
|
||||
message MutationRequest {
|
||||
string cart_id = 1;
|
||||
MutationType type = 2;
|
||||
bytes payload = 3; // Serialized specific mutation proto
|
||||
int64 client_timestamp = 4; // Optional; server fills if zero
|
||||
}
|
||||
|
||||
message MutationReply {
|
||||
int32 status_code = 1;
|
||||
bytes payload = 2; // JSON cart state or error string
|
||||
}
|
||||
|
||||
message StateRequest {
|
||||
string cart_id = 1;
|
||||
}
|
||||
|
||||
message StateReply {
|
||||
int32 status_code = 1;
|
||||
bytes payload = 2; // JSON cart state
|
||||
}
|
||||
|
||||
service CartActor {
|
||||
rpc Mutate(MutationRequest) returns (MutationReply);
|
||||
rpc GetState(StateRequest) returns (StateReply);
|
||||
}
|
||||
|
||||
### 5.2 Control Plane Proto
|
||||
|
||||
syntax = "proto3";
|
||||
package control;
|
||||
option go_package = "git.tornberg.me/go-cart-actor/proto;proto";
|
||||
|
||||
message Empty {}
|
||||
|
||||
message PingReply {
|
||||
string host = 1;
|
||||
int64 unix_time = 2;
|
||||
}
|
||||
|
||||
message NegotiateRequest {
|
||||
repeated string known_hosts = 1;
|
||||
}
|
||||
message NegotiateReply {
|
||||
repeated string hosts = 1; // Healthy hosts returned
|
||||
}
|
||||
|
||||
message CartIdsReply {
|
||||
repeated string cart_ids = 1;
|
||||
}
|
||||
|
||||
message OwnerChangeRequest {
|
||||
string cart_id = 1;
|
||||
string new_host = 2;
|
||||
}
|
||||
message OwnerChangeAck {
|
||||
bool accepted = 1;
|
||||
string message = 2;
|
||||
}
|
||||
|
||||
message ClosingNotice {
|
||||
string host = 1;
|
||||
}
|
||||
|
||||
service ControlPlane {
|
||||
rpc Ping(Empty) returns (PingReply);
|
||||
rpc Negotiate(NegotiateRequest) returns (NegotiateReply);
|
||||
rpc GetCartIds(Empty) returns (CartIdsReply);
|
||||
rpc ConfirmOwner(OwnerChangeRequest) returns (OwnerChangeAck);
|
||||
rpc Closing(ClosingNotice) returns (OwnerChangeAck);
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
## 6. Message Type Mapping
|
||||
|
||||
| Legacy Constant | Numeric | New Enum Value |
|
||||
|-----------------|---------|-----------------------------|
|
||||
| AddRequestType | 1 | MUTATION_ADD_REQUEST |
|
||||
| AddItemType | 2 | MUTATION_ADD_ITEM |
|
||||
| RemoveItemType | 4 | MUTATION_REMOVE_ITEM |
|
||||
| RemoveDeliveryType | 5 | MUTATION_REMOVE_DELIVERY |
|
||||
| ChangeQuantityType | 6 | MUTATION_CHANGE_QUANTITY |
|
||||
| SetDeliveryType | 7 | MUTATION_SET_DELIVERY |
|
||||
| SetPickupPointType | 8 | MUTATION_SET_PICKUP_POINT |
|
||||
| CreateCheckoutOrderType | 9 | MUTATION_CREATE_CHECKOUT_ORDER |
|
||||
| SetCartItemsType | 10 | MUTATION_SET_CART_ITEMS |
|
||||
| OrderCompletedType | 11 | MUTATION_ORDER_COMPLETED |
|
||||
|
||||
Persisted events keep original numeric codes; reconstruction simply casts to `MutationType`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Components To Remove / Replace
|
||||
|
||||
Remove (after migration complete):
|
||||
- `remote-grain.go`
|
||||
- `rpc-server.go`
|
||||
- Any packet/frame-specific types solely used by the above (search: `FrameWithPayload`, `RemoteHandleMutation`, `RemoteGetState` where not reused by disk or internal logic).
|
||||
- The constants representing network frame types in `synced-pool.go` (RemoteNegotiate, AckChange, etc.) replaced by gRPC calls.
|
||||
- netpool usage for remote cart channel (control plane also no longer needs `Connection` abstraction).
|
||||
|
||||
Retain (until reworked or optionally cleaned later):
|
||||
- `message.go` (for persistence)
|
||||
- `message-handler.go`
|
||||
- `cart-grain.go`
|
||||
- `messages.proto` (underlying mutation messages)
|
||||
- HTTP API server and REST handlers.
|
||||
|
||||
---
|
||||
|
||||
## 8. New / Modified Components
|
||||
|
||||
New files (planned):
|
||||
- `proto/cart_actor.proto`
|
||||
- `proto/control_plane.proto`
|
||||
- `grpc/cart_actor_server.go` (server impl)
|
||||
- `grpc/cart_actor_client.go` (client adapter implementing `Grain`)
|
||||
- `grpc/control_plane_server.go`
|
||||
- `grpc/control_plane_client.go`
|
||||
- `grpc/interceptors.go` (metrics, logging, optional tracing hooks)
|
||||
- `remote_grain_grpc.go` (adapter bridging existing interfaces)
|
||||
- `control_plane_adapter.go` (replaces frame handlers in `SyncedPool`)
|
||||
|
||||
Modified:
|
||||
- `synced-pool.go` (remote host management now uses gRPC clients; negotiation logic updated)
|
||||
- `main.go` (initialize both gRPC services on startup)
|
||||
- `go.mod` (add `google.golang.org/grpc`)
|
||||
|
||||
---
|
||||
|
||||
## 9. Step-by-Step Migration Plan
|
||||
|
||||
1. Add proto files and generate Go code (`protoc --go_out --go-grpc_out`).
|
||||
2. Implement `CartActorServer`:
|
||||
- Translate `MutationRequest` to `Message`.
|
||||
- Use existing handler registry for payload encode/decode.
|
||||
- Return JSON cart state.
|
||||
3. Implement `CartActorClient` wrapper (`RemoteGrainGRPC`) implementing:
|
||||
- `HandleMessage`: Build envelope, call `Mutate`.
|
||||
- `GetCurrentState`: Call `GetState`.
|
||||
4. Implement `ControlPlaneServer` with methods:
|
||||
- `Ping`: returns host + time.
|
||||
- `Negotiate`: merge host lists; emulate old logic.
|
||||
- `GetCartIds`: iterate local grains.
|
||||
- `ConfirmOwner`: replicate quorum flow (accept always; error path for future).
|
||||
- `Closing`: schedule remote removal.
|
||||
5. Implement `ControlPlaneClient` used inside `SyncedPool.AddRemote`.
|
||||
6. Refactor `SyncedPool`:
|
||||
- Replace frame handlers registration with gRPC client calls.
|
||||
- Replace `Server.AddHandler(...)` start-up with launching gRPC server.
|
||||
- Implement periodic health checks using `Ping`.
|
||||
7. Remove old connection constructs for 1337/1338.
|
||||
8. Metrics:
|
||||
- Add unary interceptor capturing duration and status.
|
||||
- Replace packet counters with `cart_grpc_mutate_calls_total`, `cart_grpc_control_calls_total`, histograms for latency.
|
||||
9. Update `main.go` to start:
|
||||
- gRPC server(s).
|
||||
- HTTP server as before.
|
||||
10. Delete legacy files & update README build instructions.
|
||||
11. Load testing & profiling on Raspberry Pi hardware (or ARM emulation).
|
||||
12. Final cleanup & dead code removal (search for now-unused constants & structs).
|
||||
13. Tag release.
|
||||
|
||||
---
|
||||
|
||||
## 10. Performance Considerations (Raspberry Pi Focus)
|
||||
|
||||
- Single `*grpc.ClientConn` per remote host (HTTP/2 multiplexing) to reduce file descriptor and handshake overhead.
|
||||
- Use small keepalive pings (optional) only if connections drop; default may suffice.
|
||||
- Avoid reflection / dynamic dispatch in hot path: pre-build a mapping from `MutationType` to handler function.
|
||||
- Reuse byte buffers:
|
||||
- Implement a `sync.Pool` for mutation serialization to reduce GC pressure.
|
||||
- Enforce per-RPC deadlines (e.g. 300–400ms) to avoid pile-ups.
|
||||
- Backpressure:
|
||||
- Before dispatch: if local grain pool at capacity and target grain is remote, abort early with 503 to caller (optional).
|
||||
- Disable gRPC compression for small payloads (mutation messages are small). Condition compression if payload > threshold (e.g. 8KB).
|
||||
- Compile with `-ldflags="-s -w"` in production to reduce binary size (optional).
|
||||
- Enable `GOMAXPROCS` tuned to CPU cores; Pi often benefits from leaving default but monitor.
|
||||
- Use histograms with limited buckets to reduce Prometheus cardinality.
|
||||
|
||||
---
|
||||
|
||||
## 11. Testing Strategy
|
||||
|
||||
Unit:
|
||||
- Message type mapping tests (legacy -> enum).
|
||||
- Envelope roundtrip: Original proto -> payload -> gRPC -> server decode -> internal Message.
|
||||
|
||||
Integration:
|
||||
- Two-node cluster simulation:
|
||||
- Mutate cart on Node A, ownership moves, verify remote access from Node B.
|
||||
- Quorum failure simulation (temporarily reject `ConfirmOwner`).
|
||||
- Control plane negotiation: start nodes in staggered order, assert final membership.
|
||||
|
||||
Load/Perf:
|
||||
- Benchmark local mutation vs remote mutation latency.
|
||||
- High concurrency test (N goroutines each performing X mutations).
|
||||
- Memory profiling (ensure no large buffer retention).
|
||||
|
||||
Failure Injection:
|
||||
- Kill a node mid-mutation; client call should timeout and not corrupt local state.
|
||||
- Simulated network partition: drop `Ping` replies; ensure host removal path triggers.
|
||||
|
||||
---
|
||||
|
||||
## 12. Rollback Strategy
|
||||
|
||||
Because no mixed-version compatibility is provided, rollback = redeploy previous version containing legacy protocol:
|
||||
1. Stop all new-version pods.
|
||||
2. Deploy old version cluster-wide.
|
||||
3. No data migration needed (event persistence unaffected).
|
||||
|
||||
Note: Avoid partial upgrades; perform full rolling restart quickly to prevent split-brain (new nodes won’t talk to old nodes).
|
||||
|
||||
---
|
||||
|
||||
## 13. Risks & Mitigations
|
||||
|
||||
| Risk | Description | Mitigation |
|
||||
|------|-------------|------------|
|
||||
| Full-cluster restart required | No mixed compatibility | Schedule maintenance window |
|
||||
| gRPC adds CPU overhead | Envelope + marshaling cost | Buffer reuse, keep small messages uncompressed |
|
||||
| Ownership race | Timing differences after refactor | Add explicit logs + tests around `RequestOwnership` path |
|
||||
| Hidden dependency on frame-level status codes | Some code may assume `FrameWithPayload` fields | Wrap gRPC responses into minimal compatibility structs until fully removed |
|
||||
| Memory growth | Connection reuse & pooled buffers not implemented initially | Add `sync.Pool` & track memory via pprof early |
|
||||
|
||||
---
|
||||
|
||||
## 14. Logging & Observability
|
||||
|
||||
- Structured log entries for:
|
||||
- Ownership changes
|
||||
- Negotiation rounds
|
||||
- Remote spawn events
|
||||
- Mutation failures (with cart id, mutation type)
|
||||
- Metrics:
|
||||
- `cart_grpc_mutate_duration_seconds` (histogram)
|
||||
- `cart_grpc_mutate_errors_total`
|
||||
- `cart_grpc_control_duration_seconds`
|
||||
- `cart_remote_hosts` (gauge)
|
||||
- Retain existing grain counts.
|
||||
- Optional future: OpenTelemetry tracing (span per remote mutation).
|
||||
|
||||
---
|
||||
|
||||
## 15. Future Enhancements (Post-Migration)
|
||||
|
||||
- Replace JSON state with `CartState` proto and provide streaming watch API.
|
||||
- mTLS between nodes (certificate rotation via K8s Secret or SPIRE).
|
||||
- Distributed tracing integration.
|
||||
- Ownership leasing with TTL and optimistic renewal.
|
||||
- Delta replication or CRDT-based conflict resolution for experimentation.
|
||||
|
||||
---
|
||||
|
||||
## 16. Task Breakdown & Estimates
|
||||
|
||||
| Task | Estimate |
|
||||
|------|----------|
|
||||
| Proto definitions & generation | 0.5d |
|
||||
| CartActor server/client | 1.0d |
|
||||
| ControlPlane server/client | 1.0d |
|
||||
| SyncedPool refactor | 1.0d |
|
||||
| Metrics & interceptors | 0.5d |
|
||||
| Remove legacy code & cleanup | 0.5d |
|
||||
| Tests (unit + integration) | 1.5d |
|
||||
| Benchmark & tuning | 0.5–1.0d |
|
||||
| Total | ~6–7d |
|
||||
|
||||
---
|
||||
|
||||
## 17. Open Questions (Confirm Before Implementation)
|
||||
|
||||
1. Combine both services on a single port (simplify ops) or keep dual-port first? (Default here: keep dual, but easy to merge.)
|
||||
2. Minimum Go version remains 1.24.x—acceptable to add `google.golang.org/grpc` latest?
|
||||
3. Accept adding `sync.Pool` micro-optimizations in first pass or postpone?
|
||||
|
||||
---
|
||||
|
||||
## 18. Acceptance Criteria
|
||||
|
||||
- All previous integration tests (adjusted to gRPC) pass.
|
||||
- Cart operations (add, remove, delivery, checkout) function across at least a 2‑node cluster.
|
||||
- Control plane negotiation forms consistent host list.
|
||||
- Latency for a remote mutation does not degrade beyond an acceptable threshold (define baseline before merge).
|
||||
- Legacy networking code fully removed.
|
||||
|
||||
---
|
||||
|
||||
## 19. Next Steps (If Approved)
|
||||
|
||||
1. Implement proto files and commit.
|
||||
2. Scaffold server & client code.
|
||||
3. Refactor `SyncedPool` and `main.go`.
|
||||
4. Add metrics and tests.
|
||||
5. Run benchmark on target Pi hardware.
|
||||
6. Review & merge.
|
||||
|
||||
---
|
||||
|
||||
End of Plan.
|
||||
119
Makefile
Normal file
119
Makefile
Normal file
@@ -0,0 +1,119 @@
|
||||
# ------------------------------------------------------------------------------
|
||||
# Makefile for go-cart-actor
|
||||
#
|
||||
# Key targets:
|
||||
# make protogen - Generate protobuf + gRPC code into proto/
|
||||
# make clean_proto - Remove generated proto *.pb.go files
|
||||
# make verify_proto - Ensure no stray root-level *.pb.go files exist
|
||||
# make build - Build the project
|
||||
# make test - Run tests (verbose)
|
||||
# make tidy - Run go mod tidy
|
||||
# make regen - Clean proto, regenerate, tidy, verify, build
|
||||
# make help - Show this help
|
||||
#
|
||||
# Conventions:
|
||||
# - All .proto files live in $(PROTO_DIR)
|
||||
# - Generated Go code is emitted under $(PROTO_DIR) via go_package mapping
|
||||
# - go_package is set to: git.tornberg.me/go-cart-actor/proto;messages
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
MODULE_PATH := git.tornberg.me/go-cart-actor
|
||||
PROTO_DIR := proto
|
||||
PROTOS := $(PROTO_DIR)/messages.proto $(PROTO_DIR)/cart_actor.proto $(PROTO_DIR)/control_plane.proto
|
||||
|
||||
# Allow override: make PROTOC=/path/to/protoc
|
||||
PROTOC ?= protoc
|
||||
|
||||
# Tools (auto-detect; can override)
|
||||
PROTOC_GEN_GO ?= $(shell command -v protoc-gen-go 2>/dev/null)
|
||||
PROTOC_GEN_GO_GRPC ?= $(shell command -v protoc-gen-go-grpc 2>/dev/null)
|
||||
|
||||
GO ?= go
|
||||
|
||||
# Colors (optional)
|
||||
GREEN := \033[32m
|
||||
RED := \033[31m
|
||||
YELLOW := \033[33m
|
||||
RESET := \033[0m
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
.PHONY: protogen clean_proto verify_proto tidy build test regen help check_tools
|
||||
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " protogen Generate protobuf & gRPC code"
|
||||
@echo " clean_proto Remove generated *.pb.go files in $(PROTO_DIR)"
|
||||
@echo " verify_proto Ensure no root-level *.pb.go files (old layout)"
|
||||
@echo " tidy Run go mod tidy"
|
||||
@echo " build Build the module"
|
||||
@echo " test Run tests (verbose)"
|
||||
@echo " regen Clean proto, regenerate, tidy, verify, and build"
|
||||
@echo " check_tools Verify protoc + plugins are installed"
|
||||
|
||||
check_tools:
|
||||
@if [ -z "$(PROTOC_GEN_GO)" ] || [ -z "$(PROTOC_GEN_GO_GRPC)" ]; then \
|
||||
echo "$(RED)Missing protoc-gen-go or protoc-gen-go-grpc in PATH.$(RESET)"; \
|
||||
echo "Install with:"; \
|
||||
echo " go install google.golang.org/protobuf/cmd/protoc-gen-go@latest"; \
|
||||
echo " go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if ! command -v "$(PROTOC)" >/dev/null 2>&1; then \
|
||||
echo "$(RED)protoc not found. Install protoc (e.g. via package manager)$(RESET)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "$(GREEN)All required tools detected.$(RESET)"
|
||||
|
||||
protogen: check_tools
|
||||
@echo "$(YELLOW)Generating protobuf code (outputs -> ./proto)...$(RESET)"
|
||||
$(PROTOC) -I $(PROTO_DIR) \
|
||||
--go_out=./proto --go_opt=paths=source_relative \
|
||||
--go-grpc_out=./proto --go-grpc_opt=paths=source_relative \
|
||||
$(PROTOS)
|
||||
@echo "$(GREEN)Protobuf generation complete.$(RESET)"
|
||||
|
||||
clean_proto:
|
||||
@echo "$(YELLOW)Removing generated protobuf files...$(RESET)"
|
||||
@rm -f $(PROTO_DIR)/*_grpc.pb.go $(PROTO_DIR)/*.pb.go
|
||||
@rm -f *.pb.go
|
||||
@rm -rf git.tornberg.me
|
||||
@echo "$(GREEN)Clean complete.$(RESET)"
|
||||
|
||||
verify_proto:
|
||||
@echo "$(YELLOW)Verifying proto layout...$(RESET)"
|
||||
@if ls *.pb.go >/dev/null 2>&1; then \
|
||||
echo "$(RED)ERROR: Found root-level generated *.pb.go files (should be only under $(PROTO_DIR)/).$(RESET)"; \
|
||||
ls -1 *.pb.go; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "$(GREEN)Proto layout OK (no root-level *.pb.go files).$(RESET)"
|
||||
|
||||
tidy:
|
||||
@echo "$(YELLOW)Running go mod tidy...$(RESET)"
|
||||
$(GO) mod tidy
|
||||
@echo "$(GREEN)tidy complete.$(RESET)"
|
||||
|
||||
build:
|
||||
@echo "$(YELLOW)Building...$(RESET)"
|
||||
$(GO) build ./...
|
||||
@echo "$(GREEN)Build success.$(RESET)"
|
||||
|
||||
test:
|
||||
@echo "$(YELLOW)Running tests...$(RESET)"
|
||||
$(GO) test -v ./...
|
||||
@echo "$(GREEN)Tests completed.$(RESET)"
|
||||
|
||||
regen: clean_proto protogen tidy verify_proto build
|
||||
@echo "$(GREEN)Full regenerate cycle complete.$(RESET)"
|
||||
|
||||
# Utility: show proto sources and generated outputs
|
||||
print_proto:
|
||||
@echo "Proto sources:"
|
||||
@ls -1 $(PROTOS)
|
||||
@echo ""
|
||||
@echo "Generated files (if any):"
|
||||
@ls -1 $(PROTO_DIR)/*pb.go 2>/dev/null || echo "(none)"
|
||||
|
||||
# Prevent make from treating these as file targets if similarly named files appear.
|
||||
.SILENT: help check_tools protogen clean_proto verify_proto tidy build test regen print_proto
|
||||
@@ -1,83 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
type AmqpOrderHandler struct {
|
||||
Url string
|
||||
connection *amqp.Connection
|
||||
//channel *amqp.Channel
|
||||
}
|
||||
|
||||
const (
|
||||
topic = "order-placed"
|
||||
)
|
||||
|
||||
func (t *AmqpOrderHandler) Connect() error {
|
||||
|
||||
conn, err := amqp.DialConfig(t.Url, amqp.Config{
|
||||
//Vhost: "/",
|
||||
Properties: amqp.NewConnectionProperties(),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.connection = conn
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer ch.Close()
|
||||
if err := ch.ExchangeDeclare(
|
||||
topic, // name
|
||||
"topic", // type
|
||||
true, // durable
|
||||
false, // auto-delete
|
||||
false, // internal
|
||||
false, // noWait
|
||||
nil, // arguments
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = ch.QueueDeclare(
|
||||
topic, // name of the queue
|
||||
true, // durable
|
||||
false, // delete when unused
|
||||
false, // exclusive
|
||||
false, // noWait
|
||||
nil, // arguments
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *AmqpOrderHandler) Close() error {
|
||||
log.Println("Closing master channel")
|
||||
return t.connection.Close()
|
||||
//return t.channel.Close()
|
||||
}
|
||||
|
||||
func (t *AmqpOrderHandler) OrderCompleted(data []byte) error {
|
||||
ch, err := t.connection.Channel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer ch.Close()
|
||||
return ch.Publish(
|
||||
topic,
|
||||
topic,
|
||||
true,
|
||||
false,
|
||||
amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
Body: data,
|
||||
},
|
||||
)
|
||||
}
|
||||
583
cart-grain.go
583
cart-grain.go
@@ -7,7 +7,6 @@ import (
|
||||
"log"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
@@ -93,7 +92,6 @@ type CartGrain struct {
|
||||
mu sync.RWMutex
|
||||
lastItemId int
|
||||
lastDeliveryId int
|
||||
storageMessages []Message
|
||||
Id CartId `json:"id"`
|
||||
Items []*CartItem `json:"items"`
|
||||
TotalPrice int64 `json:"totalPrice"`
|
||||
@@ -108,8 +106,8 @@ type CartGrain struct {
|
||||
|
||||
type Grain interface {
|
||||
GetId() CartId
|
||||
HandleMessage(message *Message, isReplay bool) (*FrameWithPayload, error)
|
||||
GetCurrentState() (*FrameWithPayload, error)
|
||||
Apply(content interface{}, isReplay bool) (*CartGrain, error)
|
||||
GetCurrentState() (*CartGrain, error)
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetId() CartId {
|
||||
@@ -117,20 +115,12 @@ func (c *CartGrain) GetId() CartId {
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetLastChange() int64 {
|
||||
if len(c.storageMessages) == 0 {
|
||||
return 0
|
||||
}
|
||||
return *c.storageMessages[len(c.storageMessages)-1].TimeStamp
|
||||
// Legacy event log removed; return 0 to indicate no persisted mutation history.
|
||||
return 0
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetCurrentState() (*FrameWithPayload, error) {
|
||||
result, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
ret := MakeFrameWithPayload(0, 400, []byte(err.Error()))
|
||||
return &ret, nil
|
||||
}
|
||||
ret := MakeFrameWithPayload(0, 200, result)
|
||||
return &ret, nil
|
||||
func (c *CartGrain) GetCurrentState() (*CartGrain, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func getInt(data float64, ok bool) (int, error) {
|
||||
@@ -201,30 +191,23 @@ func getItemData(sku string, qty int, country string) (*messages.AddItem, error)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *CartGrain) AddItem(sku string, qty int, country string, storeId *string) (*FrameWithPayload, error) {
|
||||
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.HandleMessage(&Message{
|
||||
Type: 2,
|
||||
Content: cartItem,
|
||||
}, false)
|
||||
return c.Apply(cartItem, false)
|
||||
}
|
||||
|
||||
func (c *CartGrain) GetStorageMessage(since int64) []StorableMessage {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
ret := make([]StorableMessage, 0)
|
||||
/*
|
||||
Legacy storage (event sourcing) removed in oneof refactor.
|
||||
Kept stub (commented) for potential future reintroduction using proto envelopes.
|
||||
|
||||
for _, message := range c.storageMessages {
|
||||
if *message.TimeStamp > since {
|
||||
ret = append(ret, message)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
func (c *CartGrain) GetStorageMessage(since int64) []interface{} {
|
||||
return nil
|
||||
}
|
||||
*/
|
||||
|
||||
func (c *CartGrain) GetState() ([]byte, error) {
|
||||
return json.Marshal(c)
|
||||
@@ -279,324 +262,238 @@ func GetTaxAmount(total int64, tax int) int64 {
|
||||
return int64(float64(total) / float64((1 + taxD)))
|
||||
}
|
||||
|
||||
func (c *CartGrain) HandleMessage(message *Message, isReplay bool) (*FrameWithPayload, error) {
|
||||
if message.TimeStamp == nil {
|
||||
now := time.Now().Unix()
|
||||
message.TimeStamp = &now
|
||||
}
|
||||
func (c *CartGrain) Apply(content interface{}, isReplay bool) (*CartGrain, error) {
|
||||
grainMutations.Inc()
|
||||
var err error
|
||||
switch message.Type {
|
||||
case SetCartItemsType:
|
||||
msg, ok := message.Content.(*messages.SetCartRequest)
|
||||
if !ok {
|
||||
err = fmt.Errorf("expected SetCartItems")
|
||||
} else {
|
||||
|
||||
c.mu.Lock()
|
||||
c.Items = make([]*CartItem, 0, len(msg.Items))
|
||||
c.mu.Unlock()
|
||||
for _, item := range msg.Items {
|
||||
c.AddItem(item.Sku, int(item.Quantity), item.Country, item.StoreId)
|
||||
}
|
||||
switch msg := content.(type) {
|
||||
|
||||
}
|
||||
case AddRequestType:
|
||||
msg, ok := message.Content.(*messages.AddRequest)
|
||||
if !ok {
|
||||
err = fmt.Errorf("expected AddRequest")
|
||||
} else {
|
||||
|
||||
existingItem, found := c.FindItemWithSku(msg.Sku)
|
||||
if found {
|
||||
existingItem.Quantity += int(msg.Quantity)
|
||||
c.UpdateTotals()
|
||||
} else {
|
||||
return c.AddItem(msg.Sku, int(msg.Quantity), msg.Country, msg.StoreId)
|
||||
}
|
||||
|
||||
}
|
||||
case AddItemType:
|
||||
msg, ok := message.Content.(*messages.AddItem)
|
||||
if !ok {
|
||||
err = fmt.Errorf("expected AddItem")
|
||||
} else {
|
||||
|
||||
if msg.Quantity < 1 {
|
||||
return nil, fmt.Errorf("invalid quantity")
|
||||
}
|
||||
existingItem, found := c.FindItemWithSku(msg.Sku)
|
||||
if found {
|
||||
existingItem.Quantity += int(msg.Quantity)
|
||||
c.UpdateTotals()
|
||||
} else {
|
||||
c.mu.Lock()
|
||||
c.lastItemId++
|
||||
tax := 2500
|
||||
if msg.Tax > 0 {
|
||||
tax = int(msg.Tax)
|
||||
}
|
||||
|
||||
taxAmount := GetTaxAmount(msg.Price, tax)
|
||||
|
||||
c.Items = append(c.Items, &CartItem{
|
||||
Id: c.lastItemId,
|
||||
ItemId: int(msg.ItemId),
|
||||
Quantity: int(msg.Quantity),
|
||||
Sku: msg.Sku,
|
||||
Name: msg.Name,
|
||||
Price: msg.Price,
|
||||
TotalPrice: msg.Price * int64(msg.Quantity),
|
||||
TotalTax: int64(taxAmount * int64(msg.Quantity)),
|
||||
Image: msg.Image,
|
||||
Stock: StockStatus(msg.Stock),
|
||||
Disclaimer: msg.Disclaimer,
|
||||
Brand: msg.Brand,
|
||||
Category: msg.Category,
|
||||
Category2: msg.Category2,
|
||||
Category3: msg.Category3,
|
||||
Category4: msg.Category4,
|
||||
Category5: msg.Category5,
|
||||
OrgPrice: msg.OrgPrice,
|
||||
ArticleType: msg.ArticleType,
|
||||
Outlet: msg.Outlet,
|
||||
SellerId: msg.SellerId,
|
||||
SellerName: msg.SellerName,
|
||||
Tax: int(taxAmount),
|
||||
TaxRate: tax,
|
||||
StoreId: msg.StoreId,
|
||||
})
|
||||
c.UpdateTotals()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
}
|
||||
case ChangeQuantityType:
|
||||
msg, ok := message.Content.(*messages.ChangeQuantity)
|
||||
if !ok {
|
||||
err = fmt.Errorf("expected ChangeQuantity")
|
||||
} else {
|
||||
|
||||
for i, item := range c.Items {
|
||||
if item.Id == int(msg.Id) {
|
||||
if msg.Quantity <= 0 {
|
||||
//c.TotalPrice -= item.Price * int64(item.Quantity)
|
||||
c.Items = append(c.Items[:i], c.Items[i+1:]...)
|
||||
} else {
|
||||
//diff := int(msg.Quantity) - item.Quantity
|
||||
item.Quantity = int(msg.Quantity)
|
||||
//c.TotalPrice += item.Price * int64(diff)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
c.UpdateTotals()
|
||||
|
||||
}
|
||||
case RemoveItemType:
|
||||
msg, ok := message.Content.(*messages.RemoveItem)
|
||||
if !ok {
|
||||
err = fmt.Errorf("expected RemoveItem")
|
||||
} else {
|
||||
|
||||
items := make([]*CartItem, 0, len(c.Items))
|
||||
for _, item := range c.Items {
|
||||
if item.Id == int(msg.Id) {
|
||||
//c.TotalPrice -= item.Price * int64(item.Quantity)
|
||||
} else {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
c.Items = items
|
||||
c.UpdateTotals()
|
||||
}
|
||||
case SetDeliveryType:
|
||||
msg, ok := message.Content.(*messages.SetDelivery)
|
||||
if !ok {
|
||||
err = fmt.Errorf("expected SetDelivery")
|
||||
} else {
|
||||
|
||||
c.lastDeliveryId++
|
||||
items := make([]int, 0)
|
||||
withDelivery := c.ItemsWithDelivery()
|
||||
if len(msg.Items) == 0 {
|
||||
items = append(items, c.ItemsWithoutDelivery()...)
|
||||
} else {
|
||||
for _, id := range msg.Items {
|
||||
for _, item := range c.Items {
|
||||
if item.Id == int(id) {
|
||||
if slices.Contains(withDelivery, item.Id) {
|
||||
return nil, fmt.Errorf("item already has delivery")
|
||||
}
|
||||
items = append(items, int(item.Id))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(items) > 0 {
|
||||
c.Deliveries = append(c.Deliveries, &CartDelivery{
|
||||
Id: c.lastDeliveryId,
|
||||
Provider: msg.Provider,
|
||||
PickupPoint: msg.PickupPoint,
|
||||
Price: 4900,
|
||||
Items: items,
|
||||
})
|
||||
|
||||
c.UpdateTotals()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
case RemoveDeliveryType:
|
||||
msg, ok := message.Content.(*messages.RemoveDelivery)
|
||||
if !ok {
|
||||
err = fmt.Errorf("expected RemoveDelivery")
|
||||
} else {
|
||||
|
||||
deliveries := make([]*CartDelivery, 0, len(c.Deliveries))
|
||||
for _, delivery := range c.Deliveries {
|
||||
if delivery.Id == int(msg.Id) {
|
||||
c.TotalPrice -= delivery.Price
|
||||
} else {
|
||||
deliveries = append(deliveries, delivery)
|
||||
}
|
||||
}
|
||||
c.Deliveries = deliveries
|
||||
c.UpdateTotals()
|
||||
}
|
||||
case SetPickupPointType:
|
||||
msg, ok := message.Content.(*messages.SetPickupPoint)
|
||||
if !ok {
|
||||
err = fmt.Errorf("expected SetPickupPoint")
|
||||
} else {
|
||||
|
||||
for _, delivery := range c.Deliveries {
|
||||
if delivery.Id == int(msg.DeliveryId) {
|
||||
delivery.PickupPoint = &messages.PickupPoint{
|
||||
Id: msg.Id,
|
||||
Address: msg.Address,
|
||||
City: msg.City,
|
||||
Zip: msg.Zip,
|
||||
Country: msg.Country,
|
||||
Name: msg.Name,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
case CreateCheckoutOrderType:
|
||||
msg, ok := message.Content.(*messages.CreateCheckoutOrder)
|
||||
if !ok {
|
||||
err = fmt.Errorf("expected CreateCheckoutOrder")
|
||||
} else {
|
||||
|
||||
orderLines := make([]*Line, 0, len(c.Items))
|
||||
|
||||
c.PaymentInProgress = true
|
||||
c.Processing = true
|
||||
for _, item := range c.Items {
|
||||
|
||||
orderLines = append(orderLines, &Line{
|
||||
Type: "physical",
|
||||
Reference: item.Sku,
|
||||
Name: item.Name,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: int(item.Price),
|
||||
TaxRate: 2500, // item.TaxRate,
|
||||
QuantityUnit: "st",
|
||||
TotalAmount: int(item.TotalPrice),
|
||||
TotalTaxAmount: int(item.TotalTax),
|
||||
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", item.Image),
|
||||
})
|
||||
}
|
||||
for _, line := range c.Deliveries {
|
||||
if line.Price > 0 {
|
||||
orderLines = append(orderLines, &Line{
|
||||
Type: "shipping_fee",
|
||||
Reference: line.Provider,
|
||||
Name: "Delivery",
|
||||
Quantity: 1,
|
||||
UnitPrice: int(line.Price),
|
||||
TaxRate: 2500, // item.TaxRate,
|
||||
QuantityUnit: "st",
|
||||
TotalAmount: int(line.Price),
|
||||
TotalTaxAmount: int(GetTaxAmount(line.Price, 2500)),
|
||||
})
|
||||
}
|
||||
}
|
||||
order := CheckoutOrder{
|
||||
PurchaseCountry: "SE",
|
||||
PurchaseCurrency: "SEK",
|
||||
Locale: "sv-se",
|
||||
OrderAmount: int(c.TotalPrice),
|
||||
OrderTaxAmount: int(c.TotalTax),
|
||||
OrderLines: orderLines,
|
||||
MerchantReference1: c.Id.String(),
|
||||
MerchantURLS: &CheckoutMerchantURLS{
|
||||
Terms: msg.Terms,
|
||||
Checkout: msg.Checkout,
|
||||
Confirmation: msg.Confirmation,
|
||||
Validation: msg.Validation,
|
||||
Push: msg.Push,
|
||||
},
|
||||
}
|
||||
orderPayload, err := json.Marshal(order)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var klarnaOrder *CheckoutOrder
|
||||
if c.OrderReference != "" {
|
||||
log.Printf("Updating order id %s", c.OrderReference)
|
||||
klarnaOrder, err = KlarnaInstance.UpdateOrder(c.OrderReference, bytes.NewReader(orderPayload))
|
||||
} else {
|
||||
klarnaOrder, err = KlarnaInstance.CreateOrder(bytes.NewReader(orderPayload))
|
||||
}
|
||||
// log.Printf("Order result: %+v", klarnaOrder)
|
||||
if nil != err {
|
||||
log.Printf("error from klarna: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
if c.OrderReference == "" {
|
||||
c.OrderReference = klarnaOrder.ID
|
||||
c.PaymentStatus = klarnaOrder.Status
|
||||
}
|
||||
|
||||
orderData, err := json.Marshal(klarnaOrder)
|
||||
if nil != err {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := MakeFrameWithPayload(RemoteCreateOrderReply, 200, orderData)
|
||||
return &result, nil
|
||||
}
|
||||
case OrderCompletedType:
|
||||
msg, ok := message.Content.(*messages.OrderCreated)
|
||||
if !ok {
|
||||
log.Printf("expected OrderCompleted, got %T", message.Content)
|
||||
err = fmt.Errorf("expected OrderCompleted")
|
||||
} else {
|
||||
c.OrderReference = msg.OrderId
|
||||
c.PaymentStatus = msg.Status
|
||||
c.PaymentInProgress = false
|
||||
}
|
||||
default:
|
||||
err = fmt.Errorf("unknown message type %d", message.Type)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !isReplay {
|
||||
case *messages.SetCartRequest:
|
||||
c.mu.Lock()
|
||||
c.storageMessages = append(c.storageMessages, *message)
|
||||
c.Items = make([]*CartItem, 0, len(msg.Items))
|
||||
c.mu.Unlock()
|
||||
for _, it := range msg.Items {
|
||||
c.AddItem(it.Sku, int(it.Quantity), it.Country, it.StoreId)
|
||||
}
|
||||
|
||||
case *messages.AddRequest:
|
||||
if existing, found := c.FindItemWithSku(msg.Sku); found {
|
||||
existing.Quantity += int(msg.Quantity)
|
||||
c.UpdateTotals()
|
||||
} else {
|
||||
return c.AddItem(msg.Sku, int(msg.Quantity), msg.Country, msg.StoreId)
|
||||
}
|
||||
|
||||
case *messages.AddItem:
|
||||
if msg.Quantity < 1 {
|
||||
return nil, fmt.Errorf("invalid quantity")
|
||||
}
|
||||
if existing, found := c.FindItemWithSku(msg.Sku); found {
|
||||
existing.Quantity += int(msg.Quantity)
|
||||
c.UpdateTotals()
|
||||
} else {
|
||||
c.mu.Lock()
|
||||
c.lastItemId++
|
||||
tax := 2500
|
||||
if msg.Tax > 0 {
|
||||
tax = int(msg.Tax)
|
||||
}
|
||||
taxAmount := GetTaxAmount(msg.Price, tax)
|
||||
c.Items = append(c.Items, &CartItem{
|
||||
Id: c.lastItemId,
|
||||
ItemId: int(msg.ItemId),
|
||||
Quantity: int(msg.Quantity),
|
||||
Sku: msg.Sku,
|
||||
Name: msg.Name,
|
||||
Price: msg.Price,
|
||||
TotalPrice: msg.Price * int64(msg.Quantity),
|
||||
TotalTax: int64(taxAmount * int64(msg.Quantity)),
|
||||
Image: msg.Image,
|
||||
Stock: StockStatus(msg.Stock),
|
||||
Disclaimer: msg.Disclaimer,
|
||||
Brand: msg.Brand,
|
||||
Category: msg.Category,
|
||||
Category2: msg.Category2,
|
||||
Category3: msg.Category3,
|
||||
Category4: msg.Category4,
|
||||
Category5: msg.Category5,
|
||||
OrgPrice: msg.OrgPrice,
|
||||
ArticleType: msg.ArticleType,
|
||||
Outlet: msg.Outlet,
|
||||
SellerId: msg.SellerId,
|
||||
SellerName: msg.SellerName,
|
||||
Tax: int(taxAmount),
|
||||
TaxRate: tax,
|
||||
StoreId: msg.StoreId,
|
||||
})
|
||||
c.UpdateTotals()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
case *messages.ChangeQuantity:
|
||||
for i, item := range c.Items {
|
||||
if item.Id == int(msg.Id) {
|
||||
if msg.Quantity <= 0 {
|
||||
c.Items = append(c.Items[:i], c.Items[i+1:]...)
|
||||
} else {
|
||||
item.Quantity = int(msg.Quantity)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
c.UpdateTotals()
|
||||
|
||||
case *messages.RemoveItem:
|
||||
newItems := make([]*CartItem, 0, len(c.Items))
|
||||
for _, it := range c.Items {
|
||||
if it.Id != int(msg.Id) {
|
||||
newItems = append(newItems, it)
|
||||
}
|
||||
}
|
||||
c.Items = newItems
|
||||
c.UpdateTotals()
|
||||
|
||||
case *messages.SetDelivery:
|
||||
c.lastDeliveryId++
|
||||
items := make([]int, 0)
|
||||
withDelivery := c.ItemsWithDelivery()
|
||||
if len(msg.Items) == 0 {
|
||||
items = append(items, c.ItemsWithoutDelivery()...)
|
||||
} else {
|
||||
for _, id := range msg.Items {
|
||||
for _, it := range c.Items {
|
||||
if it.Id == int(id) {
|
||||
if slices.Contains(withDelivery, it.Id) {
|
||||
return nil, fmt.Errorf("item already has delivery")
|
||||
}
|
||||
items = append(items, int(it.Id))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(items) > 0 {
|
||||
c.Deliveries = append(c.Deliveries, &CartDelivery{
|
||||
Id: c.lastDeliveryId,
|
||||
Provider: msg.Provider,
|
||||
PickupPoint: msg.PickupPoint,
|
||||
Price: 4900,
|
||||
Items: items,
|
||||
})
|
||||
c.UpdateTotals()
|
||||
}
|
||||
|
||||
case *messages.RemoveDelivery:
|
||||
dels := make([]*CartDelivery, 0, len(c.Deliveries))
|
||||
for _, d := range c.Deliveries {
|
||||
if d.Id == int(msg.Id) {
|
||||
c.TotalPrice -= d.Price
|
||||
} else {
|
||||
dels = append(dels, d)
|
||||
}
|
||||
}
|
||||
c.Deliveries = dels
|
||||
c.UpdateTotals()
|
||||
|
||||
case *messages.SetPickupPoint:
|
||||
for _, d := range c.Deliveries {
|
||||
if d.Id == int(msg.DeliveryId) {
|
||||
d.PickupPoint = &messages.PickupPoint{
|
||||
Id: msg.Id,
|
||||
Address: msg.Address,
|
||||
City: msg.City,
|
||||
Zip: msg.Zip,
|
||||
Country: msg.Country,
|
||||
Name: msg.Name,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
case *messages.CreateCheckoutOrder:
|
||||
orderLines := make([]*Line, 0, len(c.Items))
|
||||
c.PaymentInProgress = true
|
||||
c.Processing = true
|
||||
for _, item := range c.Items {
|
||||
orderLines = append(orderLines, &Line{
|
||||
Type: "physical",
|
||||
Reference: item.Sku,
|
||||
Name: item.Name,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: int(item.Price),
|
||||
TaxRate: 2500,
|
||||
QuantityUnit: "st",
|
||||
TotalAmount: int(item.TotalPrice),
|
||||
TotalTaxAmount: int(item.TotalTax),
|
||||
ImageURL: fmt.Sprintf("https://www.elgiganten.se%s", item.Image),
|
||||
})
|
||||
}
|
||||
for _, line := range c.Deliveries {
|
||||
if line.Price > 0 {
|
||||
orderLines = append(orderLines, &Line{
|
||||
Type: "shipping_fee",
|
||||
Reference: line.Provider,
|
||||
Name: "Delivery",
|
||||
Quantity: 1,
|
||||
UnitPrice: int(line.Price),
|
||||
TaxRate: 2500,
|
||||
QuantityUnit: "st",
|
||||
TotalAmount: int(line.Price),
|
||||
TotalTaxAmount: int(GetTaxAmount(line.Price, 2500)),
|
||||
})
|
||||
}
|
||||
}
|
||||
order := CheckoutOrder{
|
||||
PurchaseCountry: "SE",
|
||||
PurchaseCurrency: "SEK",
|
||||
Locale: "sv-se",
|
||||
OrderAmount: int(c.TotalPrice),
|
||||
OrderTaxAmount: int(c.TotalTax),
|
||||
OrderLines: orderLines,
|
||||
MerchantReference1: c.Id.String(),
|
||||
MerchantURLS: &CheckoutMerchantURLS{
|
||||
Terms: msg.Terms,
|
||||
Checkout: msg.Checkout,
|
||||
Confirmation: msg.Confirmation,
|
||||
Validation: msg.Validation,
|
||||
Push: msg.Push,
|
||||
},
|
||||
}
|
||||
orderPayload, err := json.Marshal(order)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var klarnaOrder *CheckoutOrder
|
||||
var klarnaError error
|
||||
if c.OrderReference != "" {
|
||||
log.Printf("Updating order id %s", c.OrderReference)
|
||||
klarnaOrder, klarnaError = KlarnaInstance.UpdateOrder(c.OrderReference, bytes.NewReader(orderPayload))
|
||||
} else {
|
||||
klarnaOrder, klarnaError = KlarnaInstance.CreateOrder(bytes.NewReader(orderPayload))
|
||||
}
|
||||
if klarnaError != nil {
|
||||
log.Printf("error from klarna: %v", klarnaError)
|
||||
return nil, klarnaError
|
||||
}
|
||||
if c.OrderReference == "" {
|
||||
c.OrderReference = klarnaOrder.ID
|
||||
c.PaymentStatus = klarnaOrder.Status
|
||||
}
|
||||
// This originally returned a FrameWithPayload; now returns the grain state.
|
||||
// The caller (gRPC handler) is responsible for wrapping this.
|
||||
return c, nil
|
||||
|
||||
case *messages.OrderCreated:
|
||||
c.OrderReference = msg.OrderId
|
||||
c.PaymentStatus = msg.Status
|
||||
c.PaymentInProgress = false
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported mutation type %T", content)
|
||||
}
|
||||
result, err := json.Marshal(c)
|
||||
msg := MakeFrameWithPayload(RemoteHandleMutationReply, 200, result)
|
||||
return &msg, err
|
||||
|
||||
// (Optional) Append to new storage mechanism here if still required.
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *CartGrain) UpdateTotals() {
|
||||
|
||||
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
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
@@ -23,59 +22,18 @@ func NewDiskStorage(stateFile string) (*DiskStorage, error) {
|
||||
return ret, err
|
||||
}
|
||||
|
||||
func saveMessages(messages []StorableMessage, id CartId) error {
|
||||
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
log.Printf("%d messages to save for grain id %s", len(messages), id)
|
||||
var file *os.File
|
||||
var err error
|
||||
path := getCartPath(id.String())
|
||||
file, err = os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
for _, m := range messages {
|
||||
err := m.Write(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return err
|
||||
func 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 Grain, id CartId) error {
|
||||
var err error
|
||||
path := getCartPath(id.String())
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
for err == nil {
|
||||
var msg Message
|
||||
err = ReadMessage(file, &msg)
|
||||
if err == nil {
|
||||
grain.HandleMessage(&msg, true)
|
||||
}
|
||||
}
|
||||
|
||||
if err.Error() == "EOF" {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
func loadMessages(_ Grain, _ CartId) error {
|
||||
// No-op: legacy replay removed in oneof refactor.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DiskStorage) saveState() error {
|
||||
@@ -103,15 +61,8 @@ func (s *DiskStorage) loadState() error {
|
||||
return gob.NewDecoder(file).Decode(&s.LastSaves)
|
||||
}
|
||||
|
||||
func (s *DiskStorage) Store(id CartId, grain *CartGrain) error {
|
||||
lastSavedMessage, ok := s.LastSaves[id]
|
||||
if ok && lastSavedMessage > grain.GetLastChange() {
|
||||
return nil
|
||||
}
|
||||
err := saveMessages(grain.GetStorageMessage(lastSavedMessage), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
|
||||
1
go.mod
1
go.mod
@@ -6,7 +6,6 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/rabbitmq/amqp091-go v1.10.0
|
||||
google.golang.org/grpc v1.76.0
|
||||
google.golang.org/protobuf v1.36.10
|
||||
k8s.io/api v0.34.1
|
||||
|
||||
6
go.sum
6
go.sum
@@ -70,14 +70,10 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548 h1:lkxVz5lNPlU78El49cx1c3Rxo/bp7Gbzp2BjSRFgg1U=
|
||||
github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548/go.mod h1:k4lo5gFYb3AqrgftlraCnv95xvjB/w8udRqJQJ12mAE=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -104,8 +100,6 @@ github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oE
|
||||
github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q=
|
||||
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
||||
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
@@ -27,8 +26,8 @@ var (
|
||||
)
|
||||
|
||||
type GrainPool interface {
|
||||
Process(id CartId, messages ...Message) (*FrameWithPayload, error)
|
||||
Get(id CartId) (*FrameWithPayload, error)
|
||||
Process(id CartId, mutations ...interface{}) (*CartGrain, error)
|
||||
Get(id CartId) (*CartGrain, error)
|
||||
}
|
||||
|
||||
type Ttl struct {
|
||||
@@ -143,26 +142,20 @@ func (p *GrainLocalPool) GetGrain(id CartId) (*CartGrain, error) {
|
||||
return grain, err
|
||||
}
|
||||
|
||||
func (p *GrainLocalPool) Process(id CartId, messages ...Message) (*FrameWithPayload, error) {
|
||||
func (p *GrainLocalPool) Process(id CartId, mutations ...interface{}) (*CartGrain, error) {
|
||||
grain, err := p.GetGrain(id)
|
||||
var result *FrameWithPayload
|
||||
var result *CartGrain
|
||||
if err == nil && grain != nil {
|
||||
for _, message := range messages {
|
||||
result, err = grain.HandleMessage(&message, false)
|
||||
for _, m := range mutations {
|
||||
result, err = grain.Apply(m, false)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (p *GrainLocalPool) Get(id CartId) (*FrameWithPayload, error) {
|
||||
grain, err := p.GetGrain(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := json.Marshal(grain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret := MakeFrameWithPayload(0, 200, data)
|
||||
return &ret, nil
|
||||
func (p *GrainLocalPool) Get(id CartId) (*CartGrain, error) {
|
||||
return p.GetGrain(id)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -14,7 +12,7 @@ import (
|
||||
|
||||
// TestCartActorMutationAndState validates end-to-end gRPC mutation + state retrieval
|
||||
// against a locally started gRPC server (single-node scenario).
|
||||
// This test uses AddItemType directly to avoid hitting external product
|
||||
// This test uses the oneof MutationEnvelope directly to avoid hitting external product
|
||||
// fetching logic (FetchItem) which would require network I/O.
|
||||
func TestCartActorMutationAndState(t *testing.T) {
|
||||
// Setup local grain pool + synced pool (no discovery, single host)
|
||||
@@ -62,35 +60,30 @@ func TestCartActorMutationAndState(t *testing.T) {
|
||||
Country: "se",
|
||||
}
|
||||
|
||||
// Marshal underlying mutation payload using the existing handler code path
|
||||
handler, ok := Handlers[AddItemType]
|
||||
if !ok {
|
||||
t.Fatalf("Handler for AddItemType missing")
|
||||
}
|
||||
payloadData, err := getSerializedPayload(handler, AddItemType, addItem)
|
||||
if err != nil {
|
||||
t.Fatalf("serialize add item: %v", err)
|
||||
// Build oneof envelope directly (no legacy handler/enum)
|
||||
envelope := &messages.MutationEnvelope{
|
||||
CartId: cartID,
|
||||
ClientTimestamp: time.Now().Unix(),
|
||||
Mutation: &messages.MutationEnvelope_AddItem{
|
||||
AddItem: addItem,
|
||||
},
|
||||
}
|
||||
|
||||
// Issue Mutate RPC
|
||||
mutResp, err := cartClient.Mutate(context.Background(), &messages.MutationRequest{
|
||||
CartId: cartID,
|
||||
Type: messages.MutationType(AddItemType),
|
||||
Payload: payloadData,
|
||||
ClientTimestamp: time.Now().Unix(),
|
||||
})
|
||||
mutResp, err := cartClient.Mutate(context.Background(), envelope)
|
||||
if err != nil {
|
||||
t.Fatalf("Mutate RPC error: %v", err)
|
||||
}
|
||||
if mutResp.StatusCode != 200 {
|
||||
t.Fatalf("Mutate returned non-200 status: %d payload=%s", mutResp.StatusCode, string(mutResp.Payload))
|
||||
t.Fatalf("Mutate returned non-200 status: %d, error: %s", mutResp.StatusCode, mutResp.GetError())
|
||||
}
|
||||
|
||||
// Decode cart state JSON and validate
|
||||
state := &CartGrain{}
|
||||
if err := json.Unmarshal(mutResp.Payload, state); err != nil {
|
||||
t.Fatalf("Unmarshal mutate cart state: %v\nPayload: %s", err, string(mutResp.Payload))
|
||||
// Validate the response state
|
||||
state := mutResp.GetState()
|
||||
if state == nil {
|
||||
t.Fatalf("Mutate response state is nil")
|
||||
}
|
||||
|
||||
if len(state.Items) != 1 {
|
||||
t.Fatalf("Expected 1 item after mutation, got %d", len(state.Items))
|
||||
}
|
||||
@@ -106,13 +99,14 @@ func TestCartActorMutationAndState(t *testing.T) {
|
||||
t.Fatalf("GetState RPC error: %v", err)
|
||||
}
|
||||
if getResp.StatusCode != 200 {
|
||||
t.Fatalf("GetState returned non-200 status: %d payload=%s", getResp.StatusCode, string(getResp.Payload))
|
||||
t.Fatalf("GetState returned non-200 status: %d, error: %s", getResp.StatusCode, getResp.GetError())
|
||||
}
|
||||
|
||||
state2 := &CartGrain{}
|
||||
if err := json.Unmarshal(getResp.Payload, state2); err != nil {
|
||||
t.Fatalf("Unmarshal get state: %v", err)
|
||||
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))
|
||||
}
|
||||
@@ -121,15 +115,4 @@ func TestCartActorMutationAndState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// getSerializedPayload serializes a mutation proto using the registered handler.
|
||||
func getSerializedPayload(handler MessageHandler, msgType uint16, content interface{}) ([]byte, error) {
|
||||
msg := &Message{
|
||||
Type: msgType,
|
||||
Content: content,
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := handler.Write(msg, &buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
// Legacy serialization helper removed (oneof envelope used directly)
|
||||
|
||||
454
grpc_server.go
454
grpc_server.go
@@ -2,378 +2,138 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
proto "git.tornberg.me/go-cart-actor/proto" // underlying generated package name is 'messages'
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/grpc/reflection"
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Metrics
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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
|
||||
|
||||
var (
|
||||
grpcMutateDuration = promauto.NewHistogram(prometheus.HistogramOpts{
|
||||
Name: "cart_grpc_mutate_duration_seconds",
|
||||
Help: "Duration of CartActor.Mutate RPCs",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
})
|
||||
grpcMutateErrors = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "cart_grpc_mutate_errors_total",
|
||||
Help: "Total number of failed CartActor.Mutate RPCs",
|
||||
})
|
||||
grpcStateDuration = promauto.NewHistogram(prometheus.HistogramOpts{
|
||||
Name: "cart_grpc_get_state_duration_seconds",
|
||||
Help: "Duration of CartActor.GetState RPCs",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
})
|
||||
grpcControlDuration = promauto.NewHistogram(prometheus.HistogramOpts{
|
||||
Name: "cart_grpc_control_duration_seconds",
|
||||
Help: "Duration of ControlPlane RPCs",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
})
|
||||
grpcControlErrors = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "cart_grpc_control_errors_total",
|
||||
Help: "Total number of failed ControlPlane RPCs",
|
||||
})
|
||||
)
|
||||
|
||||
// timeTrack wraps a closure and records duration into the supplied histogram.
|
||||
func timeTrack(hist prometheus.Observer, fn func() error) (err error) {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
hist.Observe(time.Since(start).Seconds())
|
||||
}()
|
||||
return fn()
|
||||
pool GrainPool // For cart state mutations and queries
|
||||
syncedPool *SyncedPool // For cluster membership and control
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// CartActor Service Implementation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
type cartActorService struct {
|
||||
proto.UnimplementedCartActorServer
|
||||
pool GrainPool
|
||||
// NewCartActorGRPCServer creates and initializes the server.
|
||||
func NewCartActorGRPCServer(pool GrainPool, syncedPool *SyncedPool) *cartActorGRPCServer {
|
||||
return &cartActorGRPCServer{
|
||||
pool: pool,
|
||||
syncedPool: syncedPool,
|
||||
}
|
||||
}
|
||||
|
||||
func newCartActorService(pool GrainPool) *cartActorService {
|
||||
return &cartActorService{pool: pool}
|
||||
}
|
||||
// Mutate applies a mutation from an envelope to the corresponding cart grain.
|
||||
func (s *cartActorGRPCServer) Mutate(ctx context.Context, envelope *messages.MutationEnvelope) (*messages.MutationReply, error) {
|
||||
if envelope.GetCartId() == "" {
|
||||
return &messages.MutationReply{
|
||||
StatusCode: 400,
|
||||
Result: &messages.MutationReply_Error{Error: "cart_id is required"},
|
||||
}, nil
|
||||
}
|
||||
cartID := ToCartId(envelope.GetCartId())
|
||||
|
||||
func (s *cartActorService) Mutate(ctx context.Context, req *proto.MutationRequest) (*proto.MutationReply, error) {
|
||||
var reply *proto.MutationReply
|
||||
err := timeTrack(grpcMutateDuration, func() error {
|
||||
if req == nil {
|
||||
return status.Error(codes.InvalidArgument, "request is nil")
|
||||
}
|
||||
if req.CartId == "" {
|
||||
return status.Error(codes.InvalidArgument, "cart_id is empty")
|
||||
}
|
||||
mt := uint16(req.Type.Number())
|
||||
handler, ok := Handlers[mt]
|
||||
if !ok {
|
||||
return status.Errorf(codes.InvalidArgument, "unknown mutation type %d", mt)
|
||||
}
|
||||
content, err := handler.Read(req.Payload)
|
||||
if err != nil {
|
||||
return status.Errorf(codes.InvalidArgument, "decode payload: %v", err)
|
||||
}
|
||||
var mutation interface{}
|
||||
switch m := envelope.Mutation.(type) {
|
||||
case *messages.MutationEnvelope_AddRequest:
|
||||
mutation = m.AddRequest
|
||||
case *messages.MutationEnvelope_AddItem:
|
||||
mutation = m.AddItem
|
||||
case *messages.MutationEnvelope_RemoveItem:
|
||||
mutation = m.RemoveItem
|
||||
case *messages.MutationEnvelope_RemoveDelivery:
|
||||
mutation = m.RemoveDelivery
|
||||
case *messages.MutationEnvelope_ChangeQuantity:
|
||||
mutation = m.ChangeQuantity
|
||||
case *messages.MutationEnvelope_SetDelivery:
|
||||
mutation = m.SetDelivery
|
||||
case *messages.MutationEnvelope_SetPickupPoint:
|
||||
mutation = m.SetPickupPoint
|
||||
case *messages.MutationEnvelope_CreateCheckoutOrder:
|
||||
mutation = m.CreateCheckoutOrder
|
||||
case *messages.MutationEnvelope_SetCartItems:
|
||||
mutation = m.SetCartItems
|
||||
case *messages.MutationEnvelope_OrderCompleted:
|
||||
mutation = m.OrderCompleted
|
||||
default:
|
||||
return &messages.MutationReply{
|
||||
StatusCode: 400,
|
||||
Result: &messages.MutationReply_Error{Error: fmt.Sprintf("unsupported mutation type: %T", m)},
|
||||
}, nil
|
||||
}
|
||||
|
||||
ts := req.ClientTimestamp
|
||||
if ts == 0 {
|
||||
ts = time.Now().Unix()
|
||||
}
|
||||
msg := Message{
|
||||
Type: mt,
|
||||
TimeStamp: &ts,
|
||||
Content: content,
|
||||
}
|
||||
|
||||
frame, err := s.pool.Process(ToCartId(req.CartId), msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply = &proto.MutationReply{
|
||||
StatusCode: int32(frame.StatusCode),
|
||||
Payload: frame.Payload,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
// Delegate the mutation to the grain pool.
|
||||
// The pool is responsible for routing it to the correct grain (local or remote).
|
||||
grain, err := s.pool.Process(cartID, mutation)
|
||||
if err != nil {
|
||||
grpcMutateErrors.Inc()
|
||||
return nil, err
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
func (s *cartActorService) GetState(ctx context.Context, req *proto.StateRequest) (*proto.StateReply, error) {
|
||||
var reply *proto.StateReply
|
||||
err := timeTrack(grpcStateDuration, func() error {
|
||||
if req == nil || req.CartId == "" {
|
||||
return status.Error(codes.InvalidArgument, "cart_id is empty")
|
||||
}
|
||||
frame, err := s.pool.Get(ToCartId(req.CartId))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply = &proto.StateReply{
|
||||
StatusCode: int32(frame.StatusCode),
|
||||
Payload: frame.Payload,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// ControlPlane Service Implementation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// controlPlaneService directly leverages SyncedPool internals (same package).
|
||||
// NOTE: This is a transitional adapter; once the legacy frame-based code is
|
||||
// removed, related fields/methods in SyncedPool can be slimmed.
|
||||
type controlPlaneService struct {
|
||||
proto.UnimplementedControlPlaneServer
|
||||
pool *SyncedPool
|
||||
}
|
||||
|
||||
func newControlPlaneService(pool *SyncedPool) *controlPlaneService {
|
||||
return &controlPlaneService{pool: pool}
|
||||
}
|
||||
|
||||
func (s *controlPlaneService) Ping(ctx context.Context, _ *proto.Empty) (*proto.PingReply, error) {
|
||||
var reply *proto.PingReply
|
||||
err := timeTrack(grpcControlDuration, func() error {
|
||||
reply = &proto.PingReply{
|
||||
Host: s.pool.Hostname,
|
||||
UnixTime: time.Now().Unix(),
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
grpcControlErrors.Inc()
|
||||
return nil, err
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
func (s *controlPlaneService) Negotiate(ctx context.Context, req *proto.NegotiateRequest) (*proto.NegotiateReply, error) {
|
||||
var reply *proto.NegotiateReply
|
||||
err := timeTrack(grpcControlDuration, func() error {
|
||||
if req == nil {
|
||||
return status.Error(codes.InvalidArgument, "request is nil")
|
||||
}
|
||||
// Add unknown hosts
|
||||
for _, host := range req.KnownHosts {
|
||||
if host == "" || host == s.pool.Hostname {
|
||||
continue
|
||||
}
|
||||
if !s.pool.IsKnown(host) {
|
||||
go s.pool.AddRemote(host)
|
||||
}
|
||||
}
|
||||
// Build healthy host list
|
||||
hosts := make([]string, 0)
|
||||
for _, r := range s.pool.GetHealthyRemotes() {
|
||||
hosts = append(hosts, r.Host)
|
||||
}
|
||||
hosts = append(hosts, s.pool.Hostname)
|
||||
reply = &proto.NegotiateReply{
|
||||
Hosts: hosts,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
grpcControlErrors.Inc()
|
||||
return nil, err
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
func (s *controlPlaneService) GetCartIds(ctx context.Context, _ *proto.Empty) (*proto.CartIdsReply, error) {
|
||||
var reply *proto.CartIdsReply
|
||||
err := timeTrack(grpcControlDuration, func() error {
|
||||
s.pool.mu.RLock()
|
||||
defer s.pool.mu.RUnlock()
|
||||
ids := make([]string, 0, len(s.pool.local.grains))
|
||||
for id, g := range s.pool.local.grains {
|
||||
if g == nil {
|
||||
continue
|
||||
}
|
||||
if id.String() == "" {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, id.String())
|
||||
}
|
||||
reply = &proto.CartIdsReply{
|
||||
CartIds: ids,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
grpcControlErrors.Inc()
|
||||
return nil, err
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
func (s *controlPlaneService) ConfirmOwner(ctx context.Context, req *proto.OwnerChangeRequest) (*proto.OwnerChangeAck, error) {
|
||||
var reply *proto.OwnerChangeAck
|
||||
err := timeTrack(grpcControlDuration, func() error {
|
||||
if req == nil || req.CartId == "" || req.NewHost == "" {
|
||||
return status.Error(codes.InvalidArgument, "cart_id or new_host missing")
|
||||
}
|
||||
id := ToCartId(req.CartId)
|
||||
newHost := req.NewHost
|
||||
|
||||
// Mirror GrainOwnerChangeHandler semantics
|
||||
log.Printf("gRPC ConfirmOwner: cart %s newHost=%s", id, newHost)
|
||||
for _, r := range s.pool.remoteHosts {
|
||||
if r.Host == newHost && r.IsHealthy() {
|
||||
go s.pool.SpawnRemoteGrain(id, newHost)
|
||||
break
|
||||
}
|
||||
}
|
||||
go s.pool.AddRemote(newHost)
|
||||
|
||||
reply = &proto.OwnerChangeAck{
|
||||
Accepted: true,
|
||||
Message: "ok",
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
grpcControlErrors.Inc()
|
||||
return nil, err
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
func (s *controlPlaneService) Closing(ctx context.Context, notice *proto.ClosingNotice) (*proto.OwnerChangeAck, error) {
|
||||
var reply *proto.OwnerChangeAck
|
||||
err := timeTrack(grpcControlDuration, func() error {
|
||||
if notice == nil || notice.Host == "" {
|
||||
return status.Error(codes.InvalidArgument, "host missing")
|
||||
}
|
||||
host := notice.Host
|
||||
s.pool.mu.RLock()
|
||||
_, exists := s.pool.remoteHosts[host]
|
||||
s.pool.mu.RUnlock()
|
||||
if exists {
|
||||
go s.pool.RemoveHost(host)
|
||||
}
|
||||
reply = &proto.OwnerChangeAck{
|
||||
Accepted: true,
|
||||
Message: "removed",
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
grpcControlErrors.Inc()
|
||||
return nil, err
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Server Bootstrap
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
type GRPCServer struct {
|
||||
server *grpc.Server
|
||||
lis net.Listener
|
||||
addr string
|
||||
}
|
||||
|
||||
// StartGRPCServer sets up a gRPC server hosting both CartActor and ControlPlane services.
|
||||
// addr example: ":1337" (for combined) OR run two servers if you want separate ports.
|
||||
// For the migration we can host both on the same listener to reduce open ports.
|
||||
func StartGRPCServer(addr string, pool GrainPool, synced *SyncedPool, opts ...grpc.ServerOption) (*GRPCServer, error) {
|
||||
if pool == nil {
|
||||
return nil, errors.New("nil grain pool")
|
||||
}
|
||||
if synced == nil {
|
||||
return nil, errors.New("nil synced pool")
|
||||
return &messages.MutationReply{
|
||||
StatusCode: 500,
|
||||
Result: &messages.MutationReply_Error{Error: err.Error()},
|
||||
}, nil
|
||||
}
|
||||
|
||||
lis, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listen %s: %w", addr, err)
|
||||
}
|
||||
// Map the internal grain state to the protobuf representation.
|
||||
cartState := ToCartState(grain)
|
||||
|
||||
grpcServer := grpc.NewServer(opts...)
|
||||
proto.RegisterCartActorServer(grpcServer, newCartActorService(pool))
|
||||
proto.RegisterControlPlaneServer(grpcServer, newControlPlaneService(synced))
|
||||
|
||||
go func() {
|
||||
log.Printf("gRPC server listening on %s", addr)
|
||||
if serveErr := grpcServer.Serve(lis); serveErr != nil {
|
||||
log.Printf("gRPC server stopped: %v", serveErr)
|
||||
}
|
||||
}()
|
||||
|
||||
return &GRPCServer{
|
||||
server: grpcServer,
|
||||
lis: lis,
|
||||
addr: addr,
|
||||
return &messages.MutationReply{
|
||||
StatusCode: 200,
|
||||
Result: &messages.MutationReply_State{State: cartState},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GracefulStop stops the server gracefully.
|
||||
func (s *GRPCServer) GracefulStop() {
|
||||
if s == nil || s.server == nil {
|
||||
return
|
||||
// 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
|
||||
}
|
||||
s.server.GracefulStop()
|
||||
}
|
||||
cartID := ToCartId(req.GetCartId())
|
||||
|
||||
// Addr returns the bound address.
|
||||
func (s *GRPCServer) Addr() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.addr
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Client Dial Helpers (used later by refactored remote grain + control plane)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// DialRemote establishes (or reuses externally) a gRPC client connection.
|
||||
func DialRemote(ctx context.Context, target string, opts ...grpc.DialOption) (*grpc.ClientConn, error) {
|
||||
dialOpts := []grpc.DialOption{
|
||||
grpc.WithInsecure(), // NOTE: Intentional for initial migration; replace with TLS / mTLS later.
|
||||
grpc.WithBlock(),
|
||||
}
|
||||
dialOpts = append(dialOpts, opts...)
|
||||
ctxDial, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
conn, err := grpc.DialContext(ctxDial, target, dialOpts...)
|
||||
grain, err := s.pool.Get(cartID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return &messages.StateReply{
|
||||
StatusCode: 500,
|
||||
Result: &messages.StateReply_Error{Error: err.Error()},
|
||||
}, nil
|
||||
}
|
||||
return conn, nil
|
||||
|
||||
cartState := ToCartState(grain)
|
||||
|
||||
return &messages.StateReply{
|
||||
StatusCode: 200,
|
||||
Result: &messages.StateReply_State{State: cartState},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Utility for converting internal errors to gRPC status (if needed later).
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func grpcError(err error) error {
|
||||
if err == nil {
|
||||
return 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)
|
||||
}
|
||||
// Extend mapping if we add richer error types.
|
||||
return status.Error(codes.Internal, err.Error())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
285
main.go
285
main.go
@@ -1,21 +1,15 @@
|
||||
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"
|
||||
)
|
||||
@@ -38,13 +32,13 @@ var (
|
||||
func spawn(id CartId) (*CartGrain, error) {
|
||||
grainSpawns.Inc()
|
||||
ret := &CartGrain{
|
||||
lastItemId: 0,
|
||||
lastDeliveryId: 0,
|
||||
Deliveries: []*CartDelivery{},
|
||||
Id: id,
|
||||
Items: []*CartItem{},
|
||||
storageMessages: []Message{},
|
||||
TotalPrice: 0,
|
||||
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
|
||||
@@ -82,15 +76,7 @@ func (a *App) Save() error {
|
||||
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")
|
||||
@@ -114,44 +100,7 @@ func GetDiscovery() Discovery {
|
||||
return NewK8sDiscovery(client)
|
||||
}
|
||||
|
||||
var tpl = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>s10r testing - checkout</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
%s
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
func getCountryFromHost(host string) string {
|
||||
if strings.Contains(strings.ToLower(host), "-no") {
|
||||
return "no"
|
||||
}
|
||||
return "se"
|
||||
}
|
||||
|
||||
func getCheckoutOrder(host string, cartId CartId) *messages.CreateCheckoutOrder {
|
||||
baseUrl := fmt.Sprintf("https://%s", host)
|
||||
cartBaseUrl := os.Getenv("CART_BASE_URL")
|
||||
if cartBaseUrl == "" {
|
||||
cartBaseUrl = "https://cart.tornberg.me"
|
||||
}
|
||||
country := getCountryFromHost(host)
|
||||
|
||||
return &messages.CreateCheckoutOrder{
|
||||
Terms: fmt.Sprintf("%s/terms", baseUrl),
|
||||
Checkout: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}", baseUrl),
|
||||
Confirmation: fmt.Sprintf("%s/confirmation/{checkout.order.id}", baseUrl),
|
||||
Validation: fmt.Sprintf("%s/validation", cartBaseUrl),
|
||||
Push: fmt.Sprintf("%s/push?order_id={checkout.order.id}", cartBaseUrl),
|
||||
Country: country,
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -185,193 +134,7 @@ func main() {
|
||||
}
|
||||
}
|
||||
}()
|
||||
orderHandler := &AmqpOrderHandler{
|
||||
Url: amqpUrl,
|
||||
}
|
||||
|
||||
syncedServer := NewPoolServer(syncedPool, fmt.Sprintf("%s, %s", name, podIp))
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/cart/", http.StripPrefix("/cart", syncedServer.Serve()))
|
||||
// only for local
|
||||
// mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
||||
// syncedPool.AddRemote(r.PathValue("host"))
|
||||
// })
|
||||
// mux.HandleFunc("GET /save", app.HandleSave)
|
||||
//mux.HandleFunc("/", app.RewritePath)
|
||||
mux.HandleFunc("/debug/pprof/", pprof.Index)
|
||||
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
||||
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
||||
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||
mux.Handle("/metrics", promhttp.Handler())
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Grain pool health: simple capacity check (mirrors previous GrainHandler.IsHealthy)
|
||||
app.pool.mu.RLock()
|
||||
grainCount := len(app.pool.grains)
|
||||
capacity := app.pool.PoolSize
|
||||
app.pool.mu.RUnlock()
|
||||
if grainCount >= capacity {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("grain pool at capacity"))
|
||||
return
|
||||
}
|
||||
if !syncedPool.IsHealthy() {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("control plane not healthy"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
mux.HandleFunc("/checkout", func(w http.ResponseWriter, r *http.Request) {
|
||||
orderId := r.URL.Query().Get("order_id")
|
||||
order := &CheckoutOrder{}
|
||||
|
||||
if orderId == "" {
|
||||
cookie, err := r.Cookie("cartid")
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
if cookie.Value == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("no cart id to checkout is empty"))
|
||||
return
|
||||
}
|
||||
cartId := ToCartId(cookie.Value)
|
||||
reply, err := syncedServer.pool.Process(cartId, Message{
|
||||
Type: CreateCheckoutOrderType,
|
||||
Content: getCheckoutOrder(r.Host, cartId),
|
||||
})
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
}
|
||||
err = json.Unmarshal(reply.Payload, &order)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
prevOrder, err := KlarnaInstance.GetOrder(orderId)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
order = prevOrder
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Permissions-Policy", "payment=(self \"https://js.stripe.com\" \"https://m.stripe.network\" \"https://js.playground.kustom.co\")")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(fmt.Sprintf(tpl, order.HTMLSnippet)))
|
||||
})
|
||||
mux.HandleFunc("/confirmation/{order_id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
orderId := r.PathValue("order_id")
|
||||
order, err := KlarnaInstance.GetOrder(orderId)
|
||||
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if order.Status == "checkout_complete" {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "cartid",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
Expires: time.Unix(0, 0),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(fmt.Sprintf(tpl, order.HTMLSnippet)))
|
||||
})
|
||||
mux.HandleFunc("/validate", func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Klarna order validation, method: %s", r.Method)
|
||||
if r.Method != "POST" {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
order := &CheckoutOrder{}
|
||||
err := json.NewDecoder(r.Body).Decode(order)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
log.Printf("Klarna order validation: %s", order.ID)
|
||||
//err = confirmOrder(order, orderHandler)
|
||||
//if err != nil {
|
||||
// log.Printf("Error validating order: %v\n", err)
|
||||
// w.WriteHeader(http.StatusInternalServerError)
|
||||
// return
|
||||
//}
|
||||
//
|
||||
//err = triggerOrderCompleted(err, syncedServer, order)
|
||||
//if err != nil {
|
||||
// log.Printf("Error processing cart message: %v\n", err)
|
||||
// w.WriteHeader(http.StatusInternalServerError)
|
||||
// return
|
||||
//}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
mux.HandleFunc("/push", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
orderId := r.URL.Query().Get("order_id")
|
||||
log.Printf("Order confirmation push: %s", orderId)
|
||||
|
||||
order, err := KlarnaInstance.GetOrder(orderId)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error creating request: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = confirmOrder(order, orderHandler)
|
||||
if err != nil {
|
||||
log.Printf("Error confirming order: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = triggerOrderCompleted(err, syncedServer, order)
|
||||
if err != nil {
|
||||
log.Printf("Error processing cart message: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
err = KlarnaInstance.AcknowledgeOrder(orderId)
|
||||
if err != nil {
|
||||
log.Printf("Error acknowledging order: %v\n", err)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("1.0.0"))
|
||||
})
|
||||
|
||||
sigs := make(chan os.Signal, 1)
|
||||
done := make(chan bool, 1)
|
||||
@@ -385,37 +148,7 @@ func main() {
|
||||
done <- true
|
||||
}()
|
||||
|
||||
log.Print("Server started at port 8080")
|
||||
go http.ListenAndServe(":8080", mux)
|
||||
log.Print("Server started at port 1337")
|
||||
<-done
|
||||
|
||||
}
|
||||
|
||||
func triggerOrderCompleted(err error, syncedServer *PoolServer, order *CheckoutOrder) error {
|
||||
_, err = syncedServer.pool.Process(ToCartId(order.MerchantReference1), Message{
|
||||
Type: OrderCompletedType,
|
||||
Content: &messages.OrderCreated{
|
||||
OrderId: order.ID,
|
||||
Status: order.Status,
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func confirmOrder(order *CheckoutOrder, orderHandler *AmqpOrderHandler) error {
|
||||
orderToSend, err := json.Marshal(order)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = orderHandler.Connect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer orderHandler.Close()
|
||||
err = orderHandler.OrderCompleted(orderToSend)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
var Handlers = map[uint16]MessageHandler{
|
||||
AddRequestType: &AddRequestHandler{},
|
||||
AddItemType: &AddItemHandler{},
|
||||
ChangeQuantityType: &ChangeQuantityHandler{},
|
||||
SetDeliveryType: &SetDeliveryHandler{},
|
||||
RemoveItemType: &RemoveItemHandler{},
|
||||
RemoveDeliveryType: &RemoveDeliveryHandler{},
|
||||
CreateCheckoutOrderType: &CheckoutHandler{},
|
||||
SetCartItemsType: &SetCartItemsHandler{},
|
||||
OrderCompletedType: &OrderCompletedHandler{},
|
||||
}
|
||||
|
||||
func GetMessageHandler(t uint16) (MessageHandler, error) {
|
||||
h, ok := Handlers[t]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no handler for message type %d", t)
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
type MessageHandler interface {
|
||||
Write(*Message, io.Writer) error
|
||||
Read(data []byte) (interface{}, error)
|
||||
Is(*Message) bool
|
||||
}
|
||||
type TypedMessageHandler struct {
|
||||
Type uint16
|
||||
}
|
||||
|
||||
type SetCartItemsHandler struct {
|
||||
TypedMessageHandler
|
||||
}
|
||||
|
||||
func (h *SetCartItemsHandler) Write(m *Message, w io.Writer) error {
|
||||
messageBytes, err := proto.Marshal(m.Content.(*messages.SetCartRequest))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Write(messageBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *SetCartItemsHandler) Read(data []byte) (interface{}, error) {
|
||||
msg := &messages.SetCartRequest{}
|
||||
|
||||
err := proto.Unmarshal(data, msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (h *SetCartItemsHandler) Is(m *Message) bool {
|
||||
if m.Type != AddRequestType {
|
||||
return false
|
||||
}
|
||||
_, ok := m.Content.(*messages.SetCartRequest)
|
||||
return ok
|
||||
}
|
||||
|
||||
type AddRequestHandler struct {
|
||||
TypedMessageHandler
|
||||
}
|
||||
|
||||
func (h *AddRequestHandler) Write(m *Message, w io.Writer) error {
|
||||
messageBytes, err := proto.Marshal(m.Content.(*messages.AddRequest))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Write(messageBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AddRequestHandler) Read(data []byte) (interface{}, error) {
|
||||
msg := &messages.AddRequest{}
|
||||
|
||||
err := proto.Unmarshal(data, msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (h *AddRequestHandler) Is(m *Message) bool {
|
||||
if m.Type != AddRequestType {
|
||||
return false
|
||||
}
|
||||
_, ok := m.Content.(*messages.AddRequest)
|
||||
return ok
|
||||
}
|
||||
|
||||
type AddItemHandler struct {
|
||||
TypedMessageHandler
|
||||
}
|
||||
|
||||
func (h *AddItemHandler) Write(m *Message, w io.Writer) error {
|
||||
messageBytes, err := proto.Marshal(m.Content.(*messages.AddItem))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Write(messageBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AddItemHandler) Read(data []byte) (interface{}, error) {
|
||||
msg := &messages.AddItem{}
|
||||
|
||||
err := proto.Unmarshal(data, msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (h *AddItemHandler) Is(m *Message) bool {
|
||||
if m.Type != AddItemType {
|
||||
return false
|
||||
}
|
||||
_, ok := m.Content.(*messages.AddItem)
|
||||
return ok
|
||||
}
|
||||
|
||||
type ChangeQuantityHandler struct {
|
||||
TypedMessageHandler
|
||||
}
|
||||
|
||||
func (h *ChangeQuantityHandler) Write(m *Message, w io.Writer) error {
|
||||
messageBytes, err := proto.Marshal(m.Content.(*messages.ChangeQuantity))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Write(messageBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ChangeQuantityHandler) Read(data []byte) (interface{}, error) {
|
||||
msg := &messages.ChangeQuantity{}
|
||||
|
||||
err := proto.Unmarshal(data, msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (h *ChangeQuantityHandler) Is(m *Message) bool {
|
||||
if m.Type != ChangeQuantityType {
|
||||
return false
|
||||
}
|
||||
_, ok := m.Content.(*messages.ChangeQuantity)
|
||||
return ok
|
||||
}
|
||||
|
||||
type SetDeliveryHandler struct {
|
||||
TypedMessageHandler
|
||||
}
|
||||
|
||||
func (h *SetDeliveryHandler) Write(m *Message, w io.Writer) error {
|
||||
messageBytes, err := proto.Marshal(m.Content.(*messages.SetDelivery))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Write(messageBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *SetDeliveryHandler) Read(data []byte) (interface{}, error) {
|
||||
msg := &messages.SetDelivery{}
|
||||
|
||||
err := proto.Unmarshal(data, msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (h *SetDeliveryHandler) Is(m *Message) bool {
|
||||
if m.Type != ChangeQuantityType {
|
||||
return false
|
||||
}
|
||||
_, ok := m.Content.(*messages.SetDelivery)
|
||||
return ok
|
||||
}
|
||||
|
||||
type RemoveItemHandler struct {
|
||||
TypedMessageHandler
|
||||
}
|
||||
|
||||
func (h *RemoveItemHandler) Write(m *Message, w io.Writer) error {
|
||||
messageBytes, err := proto.Marshal(m.Content.(*messages.RemoveItem))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Write(messageBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *RemoveItemHandler) Read(data []byte) (interface{}, error) {
|
||||
msg := &messages.RemoveItem{}
|
||||
|
||||
err := proto.Unmarshal(data, msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (h *RemoveItemHandler) Is(m *Message) bool {
|
||||
if m.Type != AddItemType {
|
||||
return false
|
||||
}
|
||||
_, ok := m.Content.(*messages.RemoveItem)
|
||||
return ok
|
||||
}
|
||||
|
||||
type RemoveDeliveryHandler struct {
|
||||
TypedMessageHandler
|
||||
}
|
||||
|
||||
func (h *RemoveDeliveryHandler) Write(m *Message, w io.Writer) error {
|
||||
messageBytes, err := proto.Marshal(m.Content.(*messages.RemoveDelivery))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Write(messageBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *RemoveDeliveryHandler) Read(data []byte) (interface{}, error) {
|
||||
msg := &messages.RemoveDelivery{}
|
||||
|
||||
err := proto.Unmarshal(data, msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (h *RemoveDeliveryHandler) Is(m *Message) bool {
|
||||
if m.Type != AddItemType {
|
||||
return false
|
||||
}
|
||||
_, ok := m.Content.(*messages.RemoveDelivery)
|
||||
return ok
|
||||
}
|
||||
|
||||
type CheckoutHandler struct {
|
||||
TypedMessageHandler
|
||||
}
|
||||
|
||||
func (h *CheckoutHandler) Write(m *Message, w io.Writer) error {
|
||||
messageBytes, err := proto.Marshal(m.Content.(*messages.CreateCheckoutOrder))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Write(messageBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *CheckoutHandler) Read(data []byte) (interface{}, error) {
|
||||
msg := &messages.CreateCheckoutOrder{}
|
||||
|
||||
err := proto.Unmarshal(data, msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (h *CheckoutHandler) Is(m *Message) bool {
|
||||
if m.Type != CreateCheckoutOrderType {
|
||||
return false
|
||||
}
|
||||
_, ok := m.Content.(*messages.CreateCheckoutOrder)
|
||||
return ok
|
||||
}
|
||||
|
||||
type OrderCompletedHandler struct {
|
||||
TypedMessageHandler
|
||||
}
|
||||
|
||||
func (h *OrderCompletedHandler) Write(m *Message, w io.Writer) error {
|
||||
messageBytes, err := proto.Marshal(m.Content.(*messages.OrderCreated))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Write(messageBytes)
|
||||
return nil
|
||||
}
|
||||
func (h *OrderCompletedHandler) Read(data []byte) (interface{}, error) {
|
||||
msg := &messages.OrderCreated{}
|
||||
|
||||
err := proto.Unmarshal(data, msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
func (h *OrderCompletedHandler) Is(m *Message) bool {
|
||||
if m.Type != OrderCompletedType {
|
||||
return false
|
||||
}
|
||||
_, ok := m.Content.(*messages.OrderCreated)
|
||||
return ok
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package main
|
||||
|
||||
const (
|
||||
AddRequestType = 1
|
||||
AddItemType = 2
|
||||
|
||||
RemoveItemType = 4
|
||||
RemoveDeliveryType = 5
|
||||
ChangeQuantityType = 6
|
||||
SetDeliveryType = 7
|
||||
SetPickupPointType = 8
|
||||
CreateCheckoutOrderType = 9
|
||||
SetCartItemsType = 10
|
||||
OrderCompletedType = 11
|
||||
)
|
||||
94
message.go
94
message.go
@@ -1,94 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
type StorableMessage interface {
|
||||
Write(w io.Writer) error
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Type uint16
|
||||
TimeStamp *int64
|
||||
Content interface{}
|
||||
}
|
||||
|
||||
type MessageWriter struct {
|
||||
io.Writer
|
||||
}
|
||||
|
||||
type StorableMessageHeader struct {
|
||||
Version uint16
|
||||
Type uint16
|
||||
TimeStamp int64
|
||||
DataLength uint64
|
||||
}
|
||||
|
||||
func GetData(fn func(w io.Writer) error) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
err := fn(&buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b := buf.Bytes()
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (m Message) Write(w io.Writer) error {
|
||||
h, err := GetMessageHandler(m.Type)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := GetData(func(w io.Writer) error {
|
||||
return h.Write(&m, w)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ts := time.Now().Unix()
|
||||
if m.TimeStamp != nil {
|
||||
ts = *m.TimeStamp
|
||||
}
|
||||
|
||||
err = binary.Write(w, binary.LittleEndian, StorableMessageHeader{
|
||||
Version: 1,
|
||||
Type: m.Type,
|
||||
TimeStamp: ts,
|
||||
DataLength: uint64(len(data)),
|
||||
})
|
||||
w.Write(data)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func ReadMessage(reader io.Reader, m *Message) error {
|
||||
|
||||
header := StorableMessageHeader{}
|
||||
err := binary.Read(reader, binary.LittleEndian, &header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
messageBytes := make([]byte, header.DataLength)
|
||||
_, err = reader.Read(messageBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h, err := GetMessageHandler(header.Type)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := h.Read(messageBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.Content = content
|
||||
|
||||
m.Type = header.Type
|
||||
m.TimeStamp = &header.TimeStamp
|
||||
|
||||
return nil
|
||||
}
|
||||
349
pool-server.go
349
pool-server.go
@@ -1,349 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
messages "git.tornberg.me/go-cart-actor/proto"
|
||||
)
|
||||
|
||||
type PoolServer struct {
|
||||
pod_name string
|
||||
pool GrainPool
|
||||
}
|
||||
|
||||
func NewPoolServer(pool GrainPool, pod_name string) *PoolServer {
|
||||
return &PoolServer{
|
||||
pod_name: pod_name,
|
||||
pool: pool,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PoolServer) HandleGet(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
data, err := s.pool.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.WriteResult(w, data)
|
||||
}
|
||||
|
||||
func (s *PoolServer) HandleAddSku(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
sku := r.PathValue("sku")
|
||||
data, err := s.pool.Process(id, Message{
|
||||
Type: AddRequestType,
|
||||
Content: &messages.AddRequest{Sku: sku, Quantity: 1},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, data)
|
||||
}
|
||||
|
||||
func ErrorHandler(fn func(w http.ResponseWriter, r *http.Request) error) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
err := fn(w, r)
|
||||
if err != nil {
|
||||
log.Printf("Server error, not remote error: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PoolServer) WriteResult(w http.ResponseWriter, result *FrameWithPayload) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("X-Pod-Name", s.pod_name)
|
||||
if result.StatusCode != 200 {
|
||||
log.Printf("Call error: %d\n", result.StatusCode)
|
||||
if result.StatusCode >= 200 && result.StatusCode < 600 {
|
||||
w.WriteHeader(int(result.StatusCode))
|
||||
} else {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
w.Write([]byte(result.Payload))
|
||||
return nil
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, err := w.Write(result.Payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PoolServer) HandleDeleteItem(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
|
||||
itemIdString := r.PathValue("itemId")
|
||||
itemId, err := strconv.Atoi(itemIdString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := s.pool.Process(id, Message{
|
||||
Type: RemoveItemType,
|
||||
Content: &messages.RemoveItem{Id: int64(itemId)},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, data)
|
||||
}
|
||||
|
||||
type SetDelivery struct {
|
||||
Provider string `json:"provider"`
|
||||
Items []int64 `json:"items"`
|
||||
PickupPoint *messages.PickupPoint `json:"pickupPoint,omitempty"`
|
||||
}
|
||||
|
||||
func (s *PoolServer) HandleSetDelivery(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
|
||||
delivery := SetDelivery{}
|
||||
err := json.NewDecoder(r.Body).Decode(&delivery)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := s.pool.Process(id, Message{
|
||||
Type: SetDeliveryType,
|
||||
Content: &messages.SetDelivery{
|
||||
Provider: delivery.Provider,
|
||||
Items: delivery.Items,
|
||||
PickupPoint: delivery.PickupPoint,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, data)
|
||||
}
|
||||
|
||||
func (s *PoolServer) HandleSetPickupPoint(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
|
||||
deliveryIdString := r.PathValue("deliveryId")
|
||||
deliveryId, err := strconv.Atoi(deliveryIdString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pickupPoint := messages.PickupPoint{}
|
||||
err = json.NewDecoder(r.Body).Decode(&pickupPoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.pool.Process(id, Message{
|
||||
Type: SetPickupPointType,
|
||||
Content: &messages.SetPickupPoint{
|
||||
DeliveryId: int64(deliveryId),
|
||||
Id: pickupPoint.Id,
|
||||
Name: pickupPoint.Name,
|
||||
Address: pickupPoint.Address,
|
||||
City: pickupPoint.City,
|
||||
Zip: pickupPoint.Zip,
|
||||
Country: pickupPoint.Country,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
func (s *PoolServer) HandleRemoveDelivery(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
|
||||
deliveryIdString := r.PathValue("deliveryId")
|
||||
deliveryId, err := strconv.Atoi(deliveryIdString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.pool.Process(id, Message{
|
||||
Type: RemoveDeliveryType,
|
||||
Content: &messages.RemoveDelivery{Id: int64(deliveryId)},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
func (s *PoolServer) HandleQuantityChange(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
changeQuantity := messages.ChangeQuantity{}
|
||||
err := json.NewDecoder(r.Body).Decode(&changeQuantity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.pool.Process(id, Message{
|
||||
Type: ChangeQuantityType,
|
||||
Content: &changeQuantity,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
func (s *PoolServer) HandleSetCartItems(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
setCartItems := messages.SetCartRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(&setCartItems)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.pool.Process(id, Message{
|
||||
Type: SetCartItemsType,
|
||||
Content: &setCartItems,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
func (s *PoolServer) HandleAddRequest(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
addRequest := messages.AddRequest{}
|
||||
err := json.NewDecoder(r.Body).Decode(&addRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := s.pool.Process(id, Message{
|
||||
Type: AddRequestType,
|
||||
Content: &addRequest,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
func (s *PoolServer) HandleConfirmation(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
orderId := r.PathValue("orderId")
|
||||
if orderId == "" {
|
||||
return fmt.Errorf("orderId is empty")
|
||||
}
|
||||
order, err := KlarnaInstance.GetOrder(orderId)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("X-Pod-Name", s.pod_name)
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return json.NewEncoder(w).Encode(order)
|
||||
}
|
||||
|
||||
func (s *PoolServer) HandleCheckout(w http.ResponseWriter, r *http.Request, id CartId) error {
|
||||
|
||||
reply, err := s.pool.Process(id, Message{
|
||||
Type: CreateCheckoutOrderType,
|
||||
Content: &messages.CreateCheckoutOrder{
|
||||
Terms: "https://slask-finder.tornberg.me/terms",
|
||||
Checkout: "https://slask-finder.tornberg.me/checkout?order_id={checkout.order.id}",
|
||||
Confirmation: "https://slask-finder.tornberg.me/confirmation/{checkout.order.id}",
|
||||
Push: "https://cart.tornberg.me/push?order_id={checkout.order.id}",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if reply.StatusCode != 200 {
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
// w.Header().Set("Content-Type", "application/json")
|
||||
// w.Header().Set("X-Pod-Name", s.pod_name)
|
||||
// w.Header().Set("Cache-Control", "no-cache")
|
||||
// w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
// w.WriteHeader(http.StatusOK)
|
||||
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
func NewCartId() CartId {
|
||||
id := time.Now().UnixNano() + rand.Int63()
|
||||
|
||||
return ToCartId(fmt.Sprintf("%d", id))
|
||||
}
|
||||
|
||||
func CookieCartIdHandler(fn func(w http.ResponseWriter, r *http.Request, cartId CartId) error) func(w http.ResponseWriter, r *http.Request) error {
|
||||
return func(w http.ResponseWriter, r *http.Request) error {
|
||||
var cartId CartId
|
||||
cartIdCookie := r.CookiesNamed("cartid")
|
||||
if cartIdCookie == nil || len(cartIdCookie) == 0 {
|
||||
cartId = NewCartId()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "cartid",
|
||||
Value: cartId.String(),
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
Expires: time.Now().AddDate(0, 0, 14),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
} else {
|
||||
cartId = ToCartId(cartIdCookie[0].Value)
|
||||
}
|
||||
return fn(w, r, cartId)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PoolServer) RemoveCartCookie(w http.ResponseWriter, r *http.Request, cartId CartId) error {
|
||||
cartId = NewCartId()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "cartid",
|
||||
Value: cartId.String(),
|
||||
Path: "/",
|
||||
Secure: true,
|
||||
HttpOnly: true,
|
||||
Expires: time.Unix(0, 0),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return nil
|
||||
}
|
||||
|
||||
func CartIdHandler(fn func(w http.ResponseWriter, r *http.Request, cartId CartId) error) func(w http.ResponseWriter, r *http.Request) error {
|
||||
return func(w http.ResponseWriter, r *http.Request) error {
|
||||
cartId := ToCartId(r.PathValue("id"))
|
||||
return fn(w, r, cartId)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PoolServer) Serve() *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
//mux.HandleFunc("/", s.RewritePath)
|
||||
mux.HandleFunc("OPTIONS /", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /", ErrorHandler(CookieCartIdHandler(s.HandleGet)))
|
||||
mux.HandleFunc("GET /add/{sku}", ErrorHandler(CookieCartIdHandler(s.HandleAddSku)))
|
||||
mux.HandleFunc("POST /", ErrorHandler(CookieCartIdHandler(s.HandleAddRequest)))
|
||||
mux.HandleFunc("POST /set", ErrorHandler(CookieCartIdHandler(s.HandleSetCartItems)))
|
||||
mux.HandleFunc("DELETE /{itemId}", ErrorHandler(CookieCartIdHandler(s.HandleDeleteItem)))
|
||||
mux.HandleFunc("PUT /", ErrorHandler(CookieCartIdHandler(s.HandleQuantityChange)))
|
||||
mux.HandleFunc("DELETE /", ErrorHandler(CookieCartIdHandler(s.RemoveCartCookie)))
|
||||
mux.HandleFunc("POST /delivery", ErrorHandler(CookieCartIdHandler(s.HandleSetDelivery)))
|
||||
mux.HandleFunc("DELETE /delivery/{deliveryId}", ErrorHandler(CookieCartIdHandler(s.HandleRemoveDelivery)))
|
||||
mux.HandleFunc("PUT /delivery/{deliveryId}/pickupPoint", ErrorHandler(CookieCartIdHandler(s.HandleSetPickupPoint)))
|
||||
mux.HandleFunc("GET /checkout", ErrorHandler(CookieCartIdHandler(s.HandleCheckout)))
|
||||
mux.HandleFunc("GET /confirmation/{orderId}", ErrorHandler(CookieCartIdHandler(s.HandleConfirmation)))
|
||||
|
||||
mux.HandleFunc("GET /byid/{id}", ErrorHandler(CartIdHandler(s.HandleGet)))
|
||||
mux.HandleFunc("GET /byid/{id}/add/{sku}", ErrorHandler(CartIdHandler(s.HandleAddSku)))
|
||||
mux.HandleFunc("POST /byid/{id}", ErrorHandler(CartIdHandler(s.HandleAddRequest)))
|
||||
mux.HandleFunc("DELETE /byid/{id}/{itemId}", ErrorHandler(CartIdHandler(s.HandleDeleteItem)))
|
||||
mux.HandleFunc("PUT /byid/{id}", ErrorHandler(CartIdHandler(s.HandleQuantityChange)))
|
||||
mux.HandleFunc("POST /byid/{id}/delivery", ErrorHandler(CartIdHandler(s.HandleSetDelivery)))
|
||||
mux.HandleFunc("DELETE /byid/{id}/delivery/{deliveryId}", ErrorHandler(CartIdHandler(s.HandleRemoveDelivery)))
|
||||
mux.HandleFunc("PUT /byid/{id}/delivery/{deliveryId}/pickupPoint", ErrorHandler(CartIdHandler(s.HandleSetPickupPoint)))
|
||||
mux.HandleFunc("GET /byid/{id}/checkout", ErrorHandler(CartIdHandler(s.HandleCheckout)))
|
||||
mux.HandleFunc("GET /byid/{id}/confirmation", ErrorHandler(CartIdHandler(s.HandleConfirmation)))
|
||||
|
||||
return mux
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,88 +2,141 @@ syntax = "proto3";
|
||||
|
||||
package messages;
|
||||
|
||||
option go_package = ".;messages";
|
||||
option go_package = "git.tornberg.me/go-cart-actor/proto;messages";
|
||||
import "messages.proto";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Cart Actor gRPC API (Envelope Variant)
|
||||
// Cart Actor gRPC API (oneof Envelope Variant)
|
||||
// -----------------------------------------------------------------------------
|
||||
// This service replaces the legacy custom TCP frame protocol used on port 1337.
|
||||
// It keeps the existing per-mutation proto messages (defined in messages.proto)
|
||||
// serialized into an opaque `bytes payload` field for minimal refactor cost.
|
||||
// The numeric values in MutationType MUST match the legacy message type
|
||||
// constants (see message-types.go) so persisted event logs replay correctly.
|
||||
// This version removes the legacy numeric MutationType enum + raw bytes payload
|
||||
// approach and replaces it with a strongly typed oneof envelope. Each concrete
|
||||
// mutation proto is embedded directly, enabling:
|
||||
// * Type-safe routing server-side (simple type switch on the oneof).
|
||||
// * Direct persistence of MutationEnvelope messages (no custom binary header).
|
||||
// * Elimination of the legacy message handler registry.
|
||||
//
|
||||
// NOTE: Regenerate Go code after editing:
|
||||
// protoc --go_out=. --go_opt=paths=source_relative \
|
||||
// --go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
||||
// proto/cart_actor.proto proto/messages.proto
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// MutationType corresponds 1:1 with the legacy uint16 message type constants.
|
||||
enum MutationType {
|
||||
MUTATION_TYPE_UNSPECIFIED = 0;
|
||||
MUTATION_ADD_REQUEST = 1;
|
||||
MUTATION_ADD_ITEM = 2;
|
||||
// (3 was unused / reserved in legacy framing)
|
||||
MUTATION_REMOVE_ITEM = 4;
|
||||
MUTATION_REMOVE_DELIVERY = 5;
|
||||
MUTATION_CHANGE_QUANTITY = 6;
|
||||
MUTATION_SET_DELIVERY = 7;
|
||||
MUTATION_SET_PICKUP_POINT = 8;
|
||||
MUTATION_CREATE_CHECKOUT_ORDER = 9;
|
||||
MUTATION_SET_CART_ITEMS = 10;
|
||||
MUTATION_ORDER_COMPLETED = 11;
|
||||
// MutationEnvelope carries exactly one mutation plus metadata.
|
||||
// client_timestamp:
|
||||
// - Optional Unix timestamp provided by the client.
|
||||
// - If zero the server MAY overwrite with its local time.
|
||||
message MutationEnvelope {
|
||||
string cart_id = 1;
|
||||
int64 client_timestamp = 2;
|
||||
|
||||
oneof mutation {
|
||||
AddRequest add_request = 10;
|
||||
AddItem add_item = 11;
|
||||
RemoveItem remove_item = 12;
|
||||
RemoveDelivery remove_delivery = 13;
|
||||
ChangeQuantity change_quantity = 14;
|
||||
SetDelivery set_delivery = 15;
|
||||
SetPickupPoint set_pickup_point = 16;
|
||||
CreateCheckoutOrder create_checkout_order = 17;
|
||||
SetCartRequest set_cart_items = 18;
|
||||
OrderCreated order_completed = 19;
|
||||
}
|
||||
}
|
||||
|
||||
// MutationRequest is an envelope:
|
||||
// - cart_id: string form of CartId (legacy 16-byte array truncated/padded).
|
||||
// - type: mutation kind (see enum).
|
||||
// - payload: serialized underlying proto message (AddRequest, AddItem, etc.).
|
||||
// - client_timestamp: optional unix timestamp; server sets if zero.
|
||||
message MutationRequest {
|
||||
string cart_id = 1;
|
||||
MutationType type = 2;
|
||||
bytes payload = 3;
|
||||
int64 client_timestamp = 4;
|
||||
}
|
||||
|
||||
// MutationReply returns a status code (legacy semantics) plus a JSON payload
|
||||
// representing the full cart state (or an error message if non-200).
|
||||
// MutationReply returns a legacy-style status code plus a JSON payload
|
||||
// holding either the updated cart state (on success) or an error string.
|
||||
message MutationReply {
|
||||
int32 status_code = 1;
|
||||
bytes payload = 2; // JSON cart state or error string
|
||||
|
||||
// Exactly one of state or error will be set.
|
||||
oneof result {
|
||||
CartState state = 2;
|
||||
string error = 3;
|
||||
}
|
||||
}
|
||||
|
||||
// StateRequest fetches current cart state without mutation.
|
||||
// StateRequest fetches current cart state without mutating.
|
||||
message StateRequest {
|
||||
string cart_id = 1;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// CartState represents the full cart snapshot returned by state/mutation replies.
|
||||
// Replaces the previous raw JSON payload.
|
||||
// -----------------------------------------------------------------------------
|
||||
message CartState {
|
||||
string cart_id = 1;
|
||||
repeated CartItemState items = 2;
|
||||
int64 total_price = 3;
|
||||
int64 total_tax = 4;
|
||||
int64 total_discount = 5;
|
||||
repeated DeliveryState deliveries = 6;
|
||||
bool payment_in_progress = 7;
|
||||
string order_reference = 8;
|
||||
string payment_status = 9;
|
||||
}
|
||||
|
||||
// Lightweight representation of an item in the cart
|
||||
message CartItemState {
|
||||
int64 id = 1;
|
||||
int64 source_item_id = 2;
|
||||
string sku = 3;
|
||||
string name = 4;
|
||||
int64 unit_price = 5;
|
||||
int32 quantity = 6;
|
||||
int64 total_price = 7;
|
||||
int64 total_tax = 8;
|
||||
int64 org_price = 9;
|
||||
int32 tax_rate = 10;
|
||||
int64 total_discount = 11;
|
||||
string brand = 12;
|
||||
string category = 13;
|
||||
string category2 = 14;
|
||||
string category3 = 15;
|
||||
string category4 = 16;
|
||||
string category5 = 17;
|
||||
string image = 18;
|
||||
string article_type = 19;
|
||||
string seller_id = 20;
|
||||
string seller_name = 21;
|
||||
string disclaimer = 22;
|
||||
string outlet = 23;
|
||||
string store_id = 24;
|
||||
int32 stock = 25;
|
||||
}
|
||||
|
||||
// Delivery / shipping entry
|
||||
message DeliveryState {
|
||||
int64 id = 1;
|
||||
string provider = 2;
|
||||
int64 price = 3;
|
||||
repeated int64 item_ids = 4;
|
||||
PickupPoint pickup_point = 5;
|
||||
}
|
||||
|
||||
// StateReply mirrors MutationReply for consistency.
|
||||
message StateReply {
|
||||
int32 status_code = 1;
|
||||
bytes payload = 2; // JSON cart state or error string
|
||||
|
||||
oneof result {
|
||||
CartState state = 2;
|
||||
string error = 3;
|
||||
}
|
||||
}
|
||||
|
||||
// CartActor exposes mutation and state retrieval for remote grains.
|
||||
service CartActor {
|
||||
// Mutate applies a single mutation to a cart, creating the cart lazily if needed.
|
||||
rpc Mutate(MutationRequest) returns (MutationReply);
|
||||
rpc Mutate(MutationEnvelope) returns (MutationReply);
|
||||
|
||||
// GetState retrieves the cart's current state (JSON).
|
||||
rpc GetState(StateRequest) returns (StateReply);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Notes:
|
||||
//
|
||||
// 1. Generation:
|
||||
// protoc --go_out=. --go_opt=paths=source_relative \
|
||||
// --go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
||||
// cart_actor.proto
|
||||
//
|
||||
// 2. Underlying mutation payloads originate from messages.proto definitions.
|
||||
// The server side will route based on MutationType and decode payload bytes
|
||||
// using existing handler registry logic.
|
||||
//
|
||||
// 3. Future Enhancements:
|
||||
// - Replace JSON state payload with a strongly typed CartState proto.
|
||||
// - Add streaming RPC (e.g. WatchState) for live updates.
|
||||
// - Migrate control plane (negotiate/ownership) into a separate proto
|
||||
// (control_plane.proto) as per the migration plan.
|
||||
// Future Enhancements:
|
||||
// * Replace JSON state payload with a strongly typed CartState proto.
|
||||
// * Add streaming RPC (e.g., WatchState) for live updates.
|
||||
// * Add batch mutations (repeated MutationEnvelope) if performance requires.
|
||||
// * Introduce optimistic concurrency via version fields if external writers appear.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -30,7 +30,7 @@ const (
|
||||
// CartActor exposes mutation and state retrieval for remote grains.
|
||||
type CartActorClient interface {
|
||||
// Mutate applies a single mutation to a cart, creating the cart lazily if needed.
|
||||
Mutate(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*MutationReply, error)
|
||||
Mutate(ctx context.Context, in *MutationEnvelope, opts ...grpc.CallOption) (*MutationReply, error)
|
||||
// GetState retrieves the cart's current state (JSON).
|
||||
GetState(ctx context.Context, in *StateRequest, opts ...grpc.CallOption) (*StateReply, error)
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func NewCartActorClient(cc grpc.ClientConnInterface) CartActorClient {
|
||||
return &cartActorClient{cc}
|
||||
}
|
||||
|
||||
func (c *cartActorClient) Mutate(ctx context.Context, in *MutationRequest, opts ...grpc.CallOption) (*MutationReply, error) {
|
||||
func (c *cartActorClient) Mutate(ctx context.Context, in *MutationEnvelope, opts ...grpc.CallOption) (*MutationReply, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(MutationReply)
|
||||
err := c.cc.Invoke(ctx, CartActor_Mutate_FullMethodName, in, out, cOpts...)
|
||||
@@ -70,7 +70,7 @@ func (c *cartActorClient) GetState(ctx context.Context, in *StateRequest, opts .
|
||||
// CartActor exposes mutation and state retrieval for remote grains.
|
||||
type CartActorServer interface {
|
||||
// Mutate applies a single mutation to a cart, creating the cart lazily if needed.
|
||||
Mutate(context.Context, *MutationRequest) (*MutationReply, error)
|
||||
Mutate(context.Context, *MutationEnvelope) (*MutationReply, error)
|
||||
// GetState retrieves the cart's current state (JSON).
|
||||
GetState(context.Context, *StateRequest) (*StateReply, error)
|
||||
mustEmbedUnimplementedCartActorServer()
|
||||
@@ -83,7 +83,7 @@ type CartActorServer interface {
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedCartActorServer struct{}
|
||||
|
||||
func (UnimplementedCartActorServer) Mutate(context.Context, *MutationRequest) (*MutationReply, error) {
|
||||
func (UnimplementedCartActorServer) Mutate(context.Context, *MutationEnvelope) (*MutationReply, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Mutate not implemented")
|
||||
}
|
||||
func (UnimplementedCartActorServer) GetState(context.Context, *StateRequest) (*StateReply, error) {
|
||||
@@ -111,7 +111,7 @@ func RegisterCartActorServer(s grpc.ServiceRegistrar, srv CartActorServer) {
|
||||
}
|
||||
|
||||
func _CartActor_Mutate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(MutationRequest)
|
||||
in := new(MutationEnvelope)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func _CartActor_Mutate_Handler(srv interface{}, ctx context.Context, dec func(in
|
||||
FullMethod: CartActor_Mutate_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CartActorServer).Mutate(ctx, req.(*MutationRequest))
|
||||
return srv.(CartActorServer).Mutate(ctx, req.(*MutationEnvelope))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
@@ -427,8 +427,7 @@ const file_control_plane_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"GetCartIds\x12\x0f.messages.Empty\x1a\x16.messages.CartIdsReply\x12F\n" +
|
||||
"\fConfirmOwner\x12\x1c.messages.OwnerChangeRequest\x1a\x18.messages.OwnerChangeAck\x12<\n" +
|
||||
"\aClosing\x12\x17.messages.ClosingNotice\x1a\x18.messages.OwnerChangeAckB\fZ\n" +
|
||||
".;messagesb\x06proto3"
|
||||
"\aClosing\x12\x17.messages.ClosingNotice\x1a\x18.messages.OwnerChangeAckB.Z,git.tornberg.me/go-cart-actor/proto;messagesb\x06proto3"
|
||||
|
||||
var (
|
||||
file_control_plane_proto_rawDescOnce sync.Once
|
||||
|
||||
@@ -2,7 +2,7 @@ syntax = "proto3";
|
||||
|
||||
package messages;
|
||||
|
||||
option go_package = ".;messages";
|
||||
option go_package = "git.tornberg.me/go-cart-actor/proto;messages";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Control Plane gRPC API
|
||||
|
||||
1066
proto/messages.pb.go
1066
proto/messages.pb.go
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
syntax = "proto3";
|
||||
package messages;
|
||||
option go_package = ".;messages";
|
||||
option go_package = "git.tornberg.me/go-cart-actor/proto;messages";
|
||||
|
||||
message AddRequest {
|
||||
int32 quantity = 1;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -68,69 +67,209 @@ func (g *RemoteGrainGRPC) GetId() CartId {
|
||||
return g.Id
|
||||
}
|
||||
|
||||
// HandleMessage serializes the underlying mutation proto (without legacy message header)
|
||||
// and invokes the CartActor.Mutate RPC. It wraps the reply into a FrameWithPayload
|
||||
// for compatibility with existing higher-level code paths.
|
||||
func (g *RemoteGrainGRPC) HandleMessage(message *Message, isReplay bool) (*FrameWithPayload, error) {
|
||||
if message == nil {
|
||||
return nil, fmt.Errorf("nil message")
|
||||
}
|
||||
// Apply executes a cart mutation remotely using the typed oneof MutationEnvelope
|
||||
// and returns a *CartGrain reconstructed from the typed MutationReply (state oneof).
|
||||
func (g *RemoteGrainGRPC) Apply(content interface{}, isReplay bool) (*CartGrain, error) {
|
||||
if isReplay {
|
||||
// Remote replay not expected; ignore to keep parity with old implementation.
|
||||
return nil, fmt.Errorf("replay not supported for remote grains")
|
||||
}
|
||||
|
||||
handler, err := GetMessageHandler(message.Type)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if content == nil {
|
||||
return nil, fmt.Errorf("nil mutation content")
|
||||
}
|
||||
|
||||
// Ensure timestamp set (legacy behavior)
|
||||
if message.TimeStamp == nil {
|
||||
ts := time.Now().Unix()
|
||||
message.TimeStamp = &ts
|
||||
}
|
||||
|
||||
// Marshal underlying proto payload only (no StorableMessageHeader)
|
||||
var buf bytes.Buffer
|
||||
err = handler.Write(message, &buf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode mutation payload: %w", err)
|
||||
}
|
||||
|
||||
req := &proto.MutationRequest{
|
||||
ts := time.Now().Unix()
|
||||
env := &proto.MutationEnvelope{
|
||||
CartId: g.Id.String(),
|
||||
Type: proto.MutationType(message.Type), // numeric mapping preserved
|
||||
Payload: buf.Bytes(),
|
||||
ClientTimestamp: *message.TimeStamp,
|
||||
ClientTimestamp: ts,
|
||||
}
|
||||
|
||||
switch m := content.(type) {
|
||||
case *proto.AddRequest:
|
||||
env.Mutation = &proto.MutationEnvelope_AddRequest{AddRequest: m}
|
||||
case *proto.AddItem:
|
||||
env.Mutation = &proto.MutationEnvelope_AddItem{AddItem: m}
|
||||
case *proto.RemoveItem:
|
||||
env.Mutation = &proto.MutationEnvelope_RemoveItem{RemoveItem: m}
|
||||
case *proto.RemoveDelivery:
|
||||
env.Mutation = &proto.MutationEnvelope_RemoveDelivery{RemoveDelivery: m}
|
||||
case *proto.ChangeQuantity:
|
||||
env.Mutation = &proto.MutationEnvelope_ChangeQuantity{ChangeQuantity: m}
|
||||
case *proto.SetDelivery:
|
||||
env.Mutation = &proto.MutationEnvelope_SetDelivery{SetDelivery: m}
|
||||
case *proto.SetPickupPoint:
|
||||
env.Mutation = &proto.MutationEnvelope_SetPickupPoint{SetPickupPoint: m}
|
||||
case *proto.CreateCheckoutOrder:
|
||||
env.Mutation = &proto.MutationEnvelope_CreateCheckoutOrder{CreateCheckoutOrder: m}
|
||||
case *proto.SetCartRequest:
|
||||
env.Mutation = &proto.MutationEnvelope_SetCartItems{SetCartItems: m}
|
||||
case *proto.OrderCreated:
|
||||
env.Mutation = &proto.MutationEnvelope_OrderCompleted{OrderCompleted: m}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported mutation type %T", content)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), g.mutateTimeout)
|
||||
defer cancel()
|
||||
|
||||
resp, err := g.client.Mutate(ctx, req)
|
||||
resp, err := g.client.Mutate(ctx, env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
frame := MakeFrameWithPayload(RemoteHandleMutationReply, StatusCode(resp.StatusCode), resp.Payload)
|
||||
return &frame, nil
|
||||
// Map typed reply
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
if e := resp.GetError(); e != "" {
|
||||
return nil, fmt.Errorf("remote mutation failed %d: %s", resp.StatusCode, e)
|
||||
}
|
||||
return nil, fmt.Errorf("remote mutation failed %d", resp.StatusCode)
|
||||
}
|
||||
state := resp.GetState()
|
||||
if state == nil {
|
||||
return nil, fmt.Errorf("mutation reply missing state on success")
|
||||
}
|
||||
// Reconstruct a lightweight CartGrain (only fields we expose internally)
|
||||
grain := &CartGrain{
|
||||
Id: ToCartId(state.CartId),
|
||||
TotalPrice: state.TotalPrice,
|
||||
TotalTax: state.TotalTax,
|
||||
TotalDiscount: state.TotalDiscount,
|
||||
PaymentInProgress: state.PaymentInProgress,
|
||||
OrderReference: state.OrderReference,
|
||||
PaymentStatus: state.PaymentStatus,
|
||||
}
|
||||
// Items
|
||||
for _, it := range state.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
outlet := toPtr(it.Outlet)
|
||||
storeId := toPtr(it.StoreId)
|
||||
grain.Items = append(grain.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),
|
||||
})
|
||||
}
|
||||
// Deliveries
|
||||
for _, d := range state.Deliveries {
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
intIds := make([]int, 0, len(d.ItemIds))
|
||||
for _, id := range d.ItemIds {
|
||||
intIds = append(intIds, int(id))
|
||||
}
|
||||
grain.Deliveries = append(grain.Deliveries, &CartDelivery{
|
||||
Id: int(d.Id),
|
||||
Provider: d.Provider,
|
||||
Price: d.Price,
|
||||
Items: intIds,
|
||||
PickupPoint: d.PickupPoint,
|
||||
})
|
||||
}
|
||||
|
||||
return grain, nil
|
||||
}
|
||||
|
||||
// GetCurrentState calls CartActor.GetState and returns a FrameWithPayload
|
||||
// shaped like the legacy RemoteGetStateReply.
|
||||
func (g *RemoteGrainGRPC) GetCurrentState() (*FrameWithPayload, error) {
|
||||
// GetCurrentState retrieves the current cart state using the typed StateReply oneof.
|
||||
func (g *RemoteGrainGRPC) GetCurrentState() (*CartGrain, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), g.stateTimeout)
|
||||
defer cancel()
|
||||
|
||||
resp, err := g.client.GetState(ctx, &proto.StateRequest{
|
||||
CartId: g.Id.String(),
|
||||
})
|
||||
resp, err := g.client.GetState(ctx, &proto.StateRequest{CartId: g.Id.String()})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
frame := MakeFrameWithPayload(RemoteGetStateReply, StatusCode(resp.StatusCode), resp.Payload)
|
||||
return &frame, nil
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
if e := resp.GetError(); e != "" {
|
||||
return nil, fmt.Errorf("remote get state failed %d: %s", resp.StatusCode, e)
|
||||
}
|
||||
return nil, fmt.Errorf("remote get state failed %d", resp.StatusCode)
|
||||
}
|
||||
state := resp.GetState()
|
||||
if state == nil {
|
||||
return nil, fmt.Errorf("state reply missing state on success")
|
||||
}
|
||||
grain := &CartGrain{
|
||||
Id: ToCartId(state.CartId),
|
||||
TotalPrice: state.TotalPrice,
|
||||
TotalTax: state.TotalTax,
|
||||
TotalDiscount: state.TotalDiscount,
|
||||
PaymentInProgress: state.PaymentInProgress,
|
||||
OrderReference: state.OrderReference,
|
||||
PaymentStatus: state.PaymentStatus,
|
||||
}
|
||||
for _, it := range state.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
outlet := toPtr(it.Outlet)
|
||||
storeId := toPtr(it.StoreId)
|
||||
grain.Items = append(grain.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),
|
||||
})
|
||||
}
|
||||
for _, d := range state.Deliveries {
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
intIds := make([]int, 0, len(d.ItemIds))
|
||||
for _, id := range d.ItemIds {
|
||||
intIds = append(intIds, int(id))
|
||||
}
|
||||
grain.Deliveries = append(grain.Deliveries, &CartDelivery{
|
||||
Id: int(d.Id),
|
||||
Provider: d.Provider,
|
||||
Price: d.Price,
|
||||
Items: intIds,
|
||||
PickupPoint: d.PickupPoint,
|
||||
})
|
||||
}
|
||||
|
||||
return grain, nil
|
||||
}
|
||||
|
||||
// Close closes the underlying gRPC connection if this adapter created it.
|
||||
|
||||
@@ -427,14 +427,14 @@ func (p *SyncedPool) getGrain(id CartId) (Grain, error) {
|
||||
}
|
||||
|
||||
// Process applies mutation(s) to a grain (local or remote).
|
||||
func (p *SyncedPool) Process(id CartId, messages ...Message) (*FrameWithPayload, error) {
|
||||
func (p *SyncedPool) Process(id CartId, mutations ...interface{}) (*CartGrain, error) {
|
||||
grain, err := p.getGrain(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var res *FrameWithPayload
|
||||
for _, m := range messages {
|
||||
res, err = grain.HandleMessage(&m, false)
|
||||
var res *CartGrain
|
||||
for _, m := range mutations {
|
||||
res, err = grain.Apply(m, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -443,7 +443,7 @@ func (p *SyncedPool) Process(id CartId, messages ...Message) (*FrameWithPayload,
|
||||
}
|
||||
|
||||
// Get returns current state of a grain (local or remote).
|
||||
func (p *SyncedPool) Get(id CartId) (*FrameWithPayload, error) {
|
||||
func (p *SyncedPool) Get(id CartId) (*CartGrain, error) {
|
||||
grain, err := p.getGrain(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
Reference in New Issue
Block a user