Co-authored-by: matst80 <mats.tornberg@gmail.com> Reviewed-on: https://git.tornberg.me/mats/go-cart-actor/pulls/4 Co-authored-by: Mats Törnberg <mats@tornberg.me> Co-committed-by: Mats Törnberg <mats@tornberg.me>
Go Cart Actor
Migration Notes (Ring-based Ownership Transition)
This release removes the legacy ConfirmOwner ownership negotiation RPC in favor of deterministic ownership via the consistent hashing ring.
Summary of changes:
- ConfirmOwner RPC removed from the ControlPlane service.
- OwnerChangeRequest message removed (was only used by ConfirmOwner).
- OwnerChangeAck retained solely as the response type for the Closing RPC.
- SyncedPool now relies exclusively on the ring for ownership (no quorum negotiation).
- Remote proxy creation includes a bounded readiness retry to reduce first-call failures.
- New Prometheus ring metrics:
- cart_ring_epoch
- cart_ring_hosts
- cart_ring_vnodes
- cart_ring_host_share{host}
- cart_ring_lookup_local_total
- cart_ring_lookup_remote_total
Action required for consumers:
- Regenerate protobuf code after pulling (requires protoc-gen-go and protoc-gen-go-grpc installed).
- Remove any client code or automation invoking ConfirmOwner (calls will now return UNIMPLEMENTED if using stale generated stubs).
- Update monitoring/alerts that referenced ConfirmOwner or ownership quorum failures—use ring metrics instead.
- If you previously interpreted “ownership flapping” via ConfirmOwner logs, now check for:
- Rapid changes in ring epoch (cart_ring_epoch)
- Host churn (cart_ring_hosts)
- Imbalance in vnode distribution (cart_ring_host_share)
No data migration is necessary; cart IDs and grain state are unaffected.
A distributed cart management system using the actor model pattern.
Prerequisites
- Go 1.24.2+
- Protocol Buffers compiler (
protoc) - protoc-gen-go and protoc-gen-go-grpc plugins
Installing Protocol Buffers
On Windows:
winget install protobuf
On macOS:
brew install protobuf
On Linux:
# Ubuntu/Debian
sudo apt install protobuf-compiler
# Or download from: https://github.com/protocolbuffers/protobuf/releases
Installing Go protobuf plugin
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
Working with Protocol Buffers
Generating Go code from proto files
After modifying any proto (proto/messages.proto, proto/cart_actor.proto, proto/control_plane.proto), regenerate the Go code (all three share the unified messages package):
cd proto
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
messages.proto cart_actor.proto control_plane.proto
Protocol Buffer Messages
The proto/messages.proto file defines the following message types:
AddRequest- Add items to cart (includes quantity, sku, country, optional storeId)SetCartRequest- Set entire cart contentsAddItem- Complete item information for cartRemoveItem- Remove item from cartChangeQuantity- Update item quantitySetDelivery- Configure delivery optionsSetPickupPoint- Set pickup locationPickupPoint- Pickup point detailsRemoveDelivery- Remove delivery optionCreateCheckoutOrder- Initiate checkoutOrderCreated- Order creation response
Building the project
go build .
Running tests
go test ./...
HTTP API Quick Start (curl Examples)
Assuming the service is reachable at http://localhost:8080 and the cart API is mounted at /cart.
Most endpoints use an HTTP cookie named cartid to track the cart. The first request will set it.
1. Get (or create) a cart
curl -i http://localhost:8080/cart/
Response sets a cartid cookie and returns the current (possibly empty) cart JSON.
2. Add an item by SKU (implicit quantity = 1)
curl -i --cookie-jar cookies.txt http://localhost:8080/cart/add/TEST-SKU-123
Stores cookie in cookies.txt for subsequent calls.
3. Add an item with explicit payload (country, quantity)
curl -i --cookie cookies.txt \
-H "Content-Type: application/json" \
-d '{"sku":"TEST-SKU-456","quantity":2,"country":"se"}' \
http://localhost:8080/cart/
4. Change quantity of an existing line
(First list the cart to find id of the line; here we use id=1 as an example)
curl -i --cookie cookies.txt \
-X PUT -H "Content-Type: application/json" \
-d '{"id":1,"quantity":3}' \
http://localhost:8080/cart/
5. Remove an item
curl -i --cookie cookies.txt -X DELETE http://localhost:8080/cart/1
6. Set entire cart contents (overwrites items)
curl -i --cookie cookies.txt \
-X POST -H "Content-Type: application/json" \
-d '{"items":[{"sku":"TEST-SKU-AAA","quantity":1,"country":"se"},{"sku":"TEST-SKU-BBB","quantity":2,"country":"se"}]}' \
http://localhost:8080/cart/set
7. Add a delivery (provider + optional items)
If items is empty or omitted, all items without a delivery get this one.
curl -i --cookie cookies.txt \
-X POST -H "Content-Type: application/json" \
-d '{"provider":"standard","items":[1,2]}' \
http://localhost:8080/cart/delivery
8. Remove a delivery by deliveryId
curl -i --cookie cookies.txt -X DELETE http://localhost:8080/cart/delivery/1
9. Set a pickup point for a delivery
curl -i --cookie cookies.txt \
-X PUT -H "Content-Type: application/json" \
-d '{"id":"PUP123","name":"Locker 5","address":"Main St 1","city":"Stockholm","zip":"11122","country":"SE"}' \
http://localhost:8080/cart/delivery/1/pickupPoint
10. Checkout (returns HTML snippet from Klarna)
curl -i --cookie cookies.txt http://localhost:8080/cart/checkout
11. Using a known cart id directly (bypassing cookie)
If you already have a cart id (e.g. 1720000000000000):
CART_ID=1720000000000000
curl -i http://localhost:8080/cart/byid/$CART_ID
curl -i -X POST -H "Content-Type: application/json" \
-d '{"sku":"TEST-SKU-XYZ","quantity":1,"country":"se"}' \
http://localhost:8080/cart/byid/$CART_ID
12. Clear cart cookie (forces a new cart on next request)
curl -i --cookie cookies.txt -X DELETE http://localhost:8080/cart/
Tip: Use --cookie-jar and --cookie to persist the session across multiple commands:
curl --cookie-jar cookies.txt http://localhost:8080/cart/
curl --cookie cookies.txt http://localhost:8080/cart/add/TEST-SKU-123
Important Notes
- Always regenerate protobuf Go code after modifying any
.protofiles (messages/cart_actor/control_plane) - The generated
messages.pb.gofile should not be edited manually - Make sure your PATH includes the protoc-gen-go binary location (usually
$GOPATH/bin)
Architecture Overview
The system is a distributed, sharded (by cart id) actor model implementation:
- Each cart is a grain (an in‑memory struct
*CartGrain) that owns and mutates its own state. - A local grain pool holds grains owned by the node.
- A synced (cluster) pool (
SyncedPool) coordinates multiple nodes and exposes local or remote grains through a uniform interface (GrainPool). - All inter‑node communication is gRPC:
- Cart mutation & state RPCs (CartActor service).
- Control plane RPCs (ControlPlane service) for membership, ownership negotiation, liveness, and graceful shutdown.
Key Processes
- Client HTTP request (or gRPC client) arrives with a cart identifier (cookie or path).
- The pool resolves ownership:
- If local grain exists → use it.
- If a remote host is known owner → a remote grain proxy (
RemoteGrainGRPC) is used; it performs gRPC calls to the owning node. - If ownership is unknown → node attempts to claim ownership (quorum negotiation) and spawns a local grain.
- Mutation is executed via the mutation registry (registry wraps domain logic + optional totals recomputation).
- Updated state returned to caller; ownership preserved unless relinquished later (not yet implemented to shed load).
Grain & Mutation Model
CartGrainholds items, deliveries, pricing aggregates, and checkout/order metadata.- All mutations are registered via
RegisterMutation[T]with signature:func(*CartGrain, *T) error WithTotals()flag triggers automatic recalculation of totals after successful handlers.- The old giant
switchinCartGrain.Applyhas been replaced by registry dispatch; unregistered mutations fail fast. - Adding a mutation:
- Define proto message.
- Generate code.
- Register handler (optionally WithTotals).
- Add gRPC RPC + request wrapper if the mutation must be remotely invokable.
- (Optional) Add HTTP endpoint mapping to the mutation.
Local Grain Pool
- Manages an in‑memory map
map[CartId]*CartGrain. - Lazy spawn: first mutation or explicit access triggers
spawn(id). - TTL / purge loop periodically removes expired grains unless they changed recently (basic memory pressure management).
- Capacity limit (
PoolSize); oldest expired grain evicted first when full.
Synced (Cluster) Pool
SyncedPool wraps a local pool and tracks:
remoteHosts: known peer nodes (gRPC connections).remoteIndex: mapping of cart id → remote grain proxy (RemoteGrainGRPC) for carts owned elsewhere.
Responsibilities:
- Discovery integration (via a
Discoveryinterface) adds/removes hosts. - Periodic ping health checks (ControlPlane.Ping).
- Ring-based deterministic ownership:
- Ownership is derived directly from the consistent hashing ring (no quorum RPC or
ConfirmOwner).
- Ownership is derived directly from the consistent hashing ring (no quorum RPC or
- Remote spawning:
- When a remote host reports its cart ids (
GetCartIds), the pool creates remote proxies for fast routing.
- When a remote host reports its cart ids (
Remote Grain Proxies
A RemoteGrainGRPC implements the Grain interface but delegates:
Apply→ Specific CartActor per‑mutation RPC (e.g.,AddItem,RemoveItem) constructed from the mutation type. (Legacy envelope removed.)GetCurrentState→CartActor.GetState.
Return path:
- gRPC reply (CartMutationReply / StateReply) → proto
CartState. ToCartState/ mapping reconstructs a localCartGrainsnapshot for callers expecting grain semantics.
Control Plane (Inter‑Node Coordination)
Defined in proto/control_plane.proto:
| RPC | Purpose |
|---|---|
Ping |
Liveness; increments missed ping counter if failing. |
Negotiate |
Merges membership views; used after discovery events. |
GetCartIds |
Enumerate locally owned carts for remote index seeding. |
Closing |
Graceful shutdown notice; peers remove host & associated remote grains. |
Ownership / Quorum Rules
- If total participating hosts < 3 → all must accept.
- Otherwise majority acceptance (
ok >= total/2). - On failure → local tentative grain is removed (rollback to avoid split‑brain).
Request / Mutation Flow Examples
Local Mutation
- HTTP handler parses request → determines cart id.
SyncedPool.Apply:- Finds local grain (or spawns new after quorum).
- Executes registry mutation.
- Totals updated if flagged.
- HTTP response returns updated JSON (via
ToCartState).
Remote Mutation
SyncedPool.Applysees cart mapped to a remote host.- Routes to
RemoteGrainGRPC.Apply. - Remote node executes mutation locally and returns updated state over gRPC.
- Proxy materializes snapshot locally (not authoritative, read‑only view).
Checkout (Side‑Effecting, Non-Pure)
- HTTP
/checkoutuses current grain snapshot to build payload (pure function). - Calls Klarna externally (not a mutation).
- Applies
InitializeCheckoutmutation to persist reference + status. - Returns Klarna order JSON to client.
Scaling & Deployment
- Horizontal scaling: Add more nodes; discovery layer (Kubernetes / service registry) feeds hosts to
SyncedPool. - Sharding: Implicit by cart id hash. Ownership is first-claim with quorum acceptance.
- Hot spots: A single popular cart remains on one node; for heavy multi-client concurrency, future work could add read replicas or partitioning (not implemented).
- Capacity tuning: Increase
PoolSize& memory limits; adjust TTL for stale cart eviction.
Adding Nodes
- Node starts gRPC server (CartActor + ControlPlane).
- After brief delay, begins discovery watch; on event:
- New host → dial + negotiate → seed remote cart ids.
- Pings maintain health; failed hosts removed (proxies invalidated).
Failure Handling
| Scenario | Behavior |
|---|---|
| Remote host unreachable | Pings increment MissedPings; after threshold host removed. |
| Ownership negotiation fails | Tentative local grain discarded. |
| gRPC call error on remote mutation | Error bubbled to caller; no local fallback. |
| Missing mutation registration | Fast failure with explicit error message. |
| Partial checkout (Klarna fails) | No local state mutation for checkout; client sees error; cart remains unchanged. |
Mutation Registry Summary
- Central, type-safe registry prevents silent omission.
- Each handler:
- Validates input.
- Mutates
*CartGrain. - Returns error for rejection.
- Automatic totals recomputation reduces boilerplate and consistency risk.
- Coverage test (add separately) can enforce all proto mutations are registered.
gRPC Interfaces
- CartActor: Per-mutation unary RPCs +
GetState. (Checkout logic intentionally excluded; handled at HTTP layer.) - ControlPlane: Cluster coordination (Ping, Negotiate, GetCartIds, Closing) — ownership now ring-determined (no ConfirmOwner).
Ports (default / implied):
- CartActor & ControlPlane share the same gRPC server/listener (single port, e.g.
:1337). - Legacy frame/TCP code has been removed.
Security & Future Enhancements
| Area | Potential Improvement |
|---|---|
| Transport Security | Add TLS / mTLS to gRPC servers & clients. |
| Auth / RBAC | Intercept CartActor RPCs with auth metadata. |
| Backpressure | Rate-limit remote mutation calls per host. |
| Observability | Add per-mutation Prometheus metrics & tracing spans. |
| Ownership | Add lease timeouts / fencing tokens for stricter guarantees. |
| Batch Ops | Introduce batch mutation RPC or streaming updates (WatchState). |
| Persistence | Reintroduce event log or snapshot persistence layer if durability required. |
Adding a New Node (Operational Checklist)
- Deploy binary/container with same proto + registry.
- Expose gRPC port.
- Ensure discovery lists the new host.
- Node dials peers, negotiates membership.
- Remote cart proxies seeded.
- Traffic routed automatically based on ownership.
Adding a New Mutation (Checklist Recap)
- Define proto message (+ request wrapper & RPC if remote invocation needed).
- Regenerate protobuf code.
- Implement & register handler (
RegisterMutation). - Add client (HTTP/gRPC) endpoint.
- Write unit + integration tests.
- (Optional) Add to coverage test list and docs.
High-Level Data Flow Diagram (Text)
Client -> HTTP Handler -> SyncedPool -> (local?) -> Registry -> Grain State
\-> (remote?) -> RemoteGrainGRPC -> gRPC -> Remote CartActor -> Registry -> Grain
ControlPlane: Discovery Events <-> Negotiation/Ping <-> SyncedPool state (ring determines ownership)
Troubleshooting
| Symptom | Likely Cause | Action |
|---|---|---|
| New cart every request | Secure cookie over plain HTTP or not sending cookie jar | Disable Secure locally or use HTTPS & proper curl -b |
| Unsupported mutation error | Missing registry handler | Add RegisterMutation for that proto |
| Ownership imbalance | Ring host distribution skew or rapid host churn | Examine cart_ring_host_share, cart_ring_hosts, and logs for host add/remove; rebalance or investigate instability |
| Remote mutation latency | Network / serialization overhead | Consider batching or colocating hot carts |
| Checkout returns 500 | Klarna call failed | Inspect logs; no grain state mutated |