From 4c973b239fc3340e9983f06923b10a485303be2d Mon Sep 17 00:00:00 2001 From: matst80 Date: Fri, 10 Oct 2025 06:45:23 +0000 Subject: [PATCH 1/3] complete rewrite to grpc --- GRPC-MIGRATION-PLAN.md | 396 ++++++++++++++++++ README.md | 11 +- cart-grain.go | 16 + cart-grain_test.go | 248 ------------ discarded-host.go | 7 +- discarded-host_test.go | 18 - frames.go | 102 +++++ go.mod | 4 +- go.sum | 6 +- grpc_integration_test.go | 127 ++++++ grpc_server.go | 379 +++++++++++++++++ id_test.go | 19 - main.go | 19 +- main_test.go | 286 ------------- message-handler_test.go | 129 ------ packet.go | 89 ---- proto/cart_actor.pb.go | 420 +++++++++++++++++++ proto/cart_actor.proto | 89 ++++ proto/cart_actor_grpc.pb.go | 167 ++++++++ proto/control_plane.pb.go | 496 +++++++++++++++++++++++ proto/control_plane.proto | 89 ++++ proto/control_plane_grpc.pb.go | 287 +++++++++++++ remote-grain.go | 72 ---- remote-grain_test.go | 17 - remote-host.go | 93 ----- remote_grain_grpc.go | 147 +++++++ rpc-server.go | 69 ---- synced-pool.go | 716 +++++++++++++++------------------ synced-pool_test.go | 66 --- tcp-connection.go | 232 ----------- tcp-connection_test.go | 80 +--- 31 files changed, 3080 insertions(+), 1816 deletions(-) create mode 100644 GRPC-MIGRATION-PLAN.md delete mode 100644 cart-grain_test.go delete mode 100644 discarded-host_test.go create mode 100644 frames.go create mode 100644 grpc_integration_test.go create mode 100644 grpc_server.go delete mode 100644 id_test.go delete mode 100644 main_test.go delete mode 100644 message-handler_test.go delete mode 100644 packet.go create mode 100644 proto/cart_actor.pb.go create mode 100644 proto/cart_actor.proto create mode 100644 proto/cart_actor_grpc.pb.go create mode 100644 proto/control_plane.pb.go create mode 100644 proto/control_plane.proto create mode 100644 proto/control_plane_grpc.pb.go delete mode 100644 remote-grain.go delete mode 100644 remote-grain_test.go delete mode 100644 remote-host.go create mode 100644 remote_grain_grpc.go delete mode 100644 rpc-server.go delete mode 100644 synced-pool_test.go delete mode 100644 tcp-connection.go diff --git a/GRPC-MIGRATION-PLAN.md b/GRPC-MIGRATION-PLAN.md new file mode 100644 index 0000000..e2fdc48 --- /dev/null +++ b/GRPC-MIGRATION-PLAN.md @@ -0,0 +1,396 @@ +# 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. \ No newline at end of file diff --git a/README.md b/README.md index 45973d5..6847ecd 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A distributed cart management system using the actor model pattern. - Go 1.24.2+ - Protocol Buffers compiler (`protoc`) -- protoc-gen-go plugin +- protoc-gen-go and protoc-gen-go-grpc plugins ### Installing Protocol Buffers @@ -32,17 +32,20 @@ sudo apt install protobuf-compiler ```bash 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 `proto/messages.proto`, regenerate the Go code: +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): ```bash cd proto -protoc --go_out=. --go_opt=paths=source_relative messages.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 @@ -75,6 +78,6 @@ go test ./... ## Important Notes -- Always regenerate protobuf Go code after modifying `.proto` files +- Always regenerate protobuf Go code after modifying any `.proto` files (messages/cart_actor/control_plane) - The generated `messages.pb.go` file should not be edited manually - Make sure your PATH includes the protoc-gen-go binary location (usually `$GOPATH/bin`) \ No newline at end of file diff --git a/cart-grain.go b/cart-grain.go index 07ac1ff..0bae3ea 100644 --- a/cart-grain.go +++ b/cart-grain.go @@ -14,6 +14,22 @@ import ( type CartId [16]byte +// String returns the cart id as a trimmed UTF-8 string (trailing zero bytes removed). +func (id CartId) String() string { + n := 0 + for n < len(id) && id[n] != 0 { + n++ + } + return string(id[:n]) +} + +// ToCartId converts an arbitrary string to a fixed-size CartId (truncating or padding with zeros). +func ToCartId(s string) CartId { + var id CartId + copy(id[:], []byte(s)) + return id +} + func (id CartId) MarshalJSON() ([]byte, error) { return json.Marshal(id.String()) } diff --git a/cart-grain_test.go b/cart-grain_test.go deleted file mode 100644 index 94d2e9d..0000000 --- a/cart-grain_test.go +++ /dev/null @@ -1,248 +0,0 @@ -package main - -import ( - "testing" - "time" - - messages "git.tornberg.me/go-cart-actor/proto" -) - -func GetMessage(t uint16, data interface{}) *Message { - ts := time.Now().Unix() - return &Message{ - TimeStamp: &ts, - Type: t, - Content: data, - } -} - -func TestTaxAmount(t *testing.T) { - taxAmount := GetTaxAmount(12500, 2500) - if taxAmount != 2500 { - t.Errorf("Expected 2500, got %d\n", taxAmount) - } -} - -func TestAddToCartShortCut(t *testing.T) { - grain, err := spawn(ToCartId("kalle")) - if err != nil { - t.Errorf("Error spawning: %v\n", err) - } - if len(grain.Items) != 0 { - t.Errorf("Expected 0 items, got %d\n", len(grain.Items)) - } - msg := GetMessage(AddItemType, &messages.AddItem{ - Quantity: 2, - Price: 100, - Sku: "123", - Name: "Test item", - Image: "test.jpg", - }) - - _, err = grain.HandleMessage(msg, false) - if err != nil { - t.Errorf("Error handling message: %v\n", err) - } - if len(grain.Items) != 1 { - t.Errorf("Expected 1 item, got %d\n", len(grain.Items)) - } - if grain.Items[0].Quantity != 2 { - t.Errorf("Expected quantity 2, got %d\n", grain.Items[0].Quantity) - } - if len(grain.storageMessages) != 1 { - t.Errorf("Expected 1 storage message, got %d\n", len(grain.storageMessages)) - } - shortCutMessage := GetMessage(AddRequestType, &messages.AddRequest{ - Quantity: 2, - Sku: "123", - }) - _, err = grain.HandleMessage(shortCutMessage, false) - if err != nil { - t.Errorf("Error handling message: %v\n", err) - } - if len(grain.Items) != 1 { - t.Errorf("Expected 1 item, got %d\n", len(grain.Items)) - } - if len(grain.storageMessages) != 2 { - t.Errorf("Expected 2 storage message, got %d\n", len(grain.storageMessages)) - } - if grain.storageMessages[0].Type != AddItemType { - t.Errorf("Expected AddItemType, got %d\n", grain.storageMessages[0].Type) - } - if grain.storageMessages[1].Type != AddRequestType { - t.Errorf("Expected AddRequestType, got %d\n", grain.storageMessages[1].Type) - } -} - -func TestAddRequestToGrain(t *testing.T) { - grain, err := spawn(ToCartId("kalle")) - if err != nil { - t.Errorf("Error spawning: %v\n", err) - } - msg := GetMessage(AddRequestType, &messages.AddRequest{ - Quantity: 2, - Sku: "763281", - }) - result, err := grain.HandleMessage(msg, false) - if err != nil { - t.Errorf("Error handling message: %v\n", err) - } - if result.StatusCode != 200 { - t.Errorf("Call failed\n") - } - t.Log(result) -} - -func TestAddToCart(t *testing.T) { - grain, err := spawn(ToCartId("kalle")) - if err != nil { - t.Errorf("Error spawning: %v\n", err) - } - if len(grain.Items) != 0 { - t.Errorf("Expected 0 items, got %d\n", len(grain.Items)) - } - msg := GetMessage(AddItemType, &messages.AddItem{ - Quantity: 2, - Price: 100, - Sku: "123", - Name: "Test item", - Image: "test.jpg", - }) - - result, err := grain.HandleMessage(msg, false) - - if err != nil { - t.Errorf("Error handling message: %v\n", err) - } - if result.StatusCode != 200 { - t.Errorf("Call failed\n") - } - if grain.TotalPrice != 200 { - t.Errorf("Expected total price 200, got %d\n", grain.TotalPrice) - } - if len(grain.Items) != 1 { - t.Errorf("Expected 1 item, got %d\n", len(grain.Items)) - } - if grain.Items[0].Quantity != 2 { - t.Errorf("Expected quantity 2, got %d\n", grain.Items[0].Quantity) - } - result, err = grain.HandleMessage(msg, false) - if err != nil { - t.Errorf("Error handling message: %v\n", err) - } - if result.StatusCode != 200 { - t.Errorf("Call failed\n") - } - if grain.Items[0].Quantity != 4 { - t.Errorf("Expected quantity 4, got %d\n", grain.Items[0].Quantity) - } - if grain.TotalPrice != 400 { - t.Errorf("Expected total price 400, got %d\n", grain.TotalPrice) - } -} - -func TestSetDelivery(t *testing.T) { - grain, err := spawn(ToCartId("kalle")) - if err != nil { - t.Errorf("Error spawning: %v\n", err) - } - if len(grain.Items) != 0 { - t.Errorf("Expected 0 items, got %d\n", len(grain.Items)) - } - msg := GetMessage(AddItemType, &messages.AddItem{ - Quantity: 2, - Price: 100, - Sku: "123", - Name: "Test item", - Image: "test.jpg", - }) - - grain.HandleMessage(msg, false) - - msg = GetMessage(AddItemType, &messages.AddItem{ - Quantity: 2, - Price: 100, - Sku: "123", - Name: "Test item", - Image: "test.jpg", - }) - - result, err := grain.HandleMessage(msg, false) - - if err != nil { - t.Errorf("Error handling message: %v\n", err) - } - if result.StatusCode != 200 { - t.Errorf("Call failed\n") - } - - setDelivery := GetMessage(SetDeliveryType, &messages.SetDelivery{ - Provider: "test", - Items: []int64{1}, - }) - - _, err = grain.HandleMessage(setDelivery, false) - if err != nil { - t.Errorf("Error handling message: %v\n", err) - } - if len(grain.Deliveries) != 1 { - t.Errorf("Expected 1 delivery, got %d\n", len(grain.Deliveries)) - } - if len(grain.Deliveries[0].Items) != 1 { - t.Errorf("Expected 1 items in delivery, got %d\n", len(grain.Deliveries[0].Items)) - } -} - -func TestSetDeliveryOnAll(t *testing.T) { - grain, err := spawn(ToCartId("kalle")) - if err != nil { - t.Errorf("Error spawning: %v\n", err) - } - if len(grain.Items) != 0 { - t.Errorf("Expected 0 items, got %d\n", len(grain.Items)) - } - msg := GetMessage(AddItemType, &messages.AddItem{ - Quantity: 2, - Price: 100, - Sku: "123", - Name: "Test item", - Image: "test.jpg", - }) - - grain.HandleMessage(msg, false) - - msg = GetMessage(AddItemType, &messages.AddItem{ - Quantity: 2, - Price: 100, - Sku: "1233", - Name: "Test item2", - Image: "test.jpg", - }) - - result, err := grain.HandleMessage(msg, false) - - if err != nil { - t.Errorf("Error handling message: %v\n", err) - } - if result.StatusCode != 200 { - t.Errorf("Call failed\n") - } - - setDelivery := GetMessage(SetDeliveryType, &messages.SetDelivery{ - Provider: "test", - Items: []int64{}, - }) - - _, err = grain.HandleMessage(setDelivery, false) - if err != nil { - t.Errorf("Error handling message: %v\n", err) - } - if len(grain.Deliveries) != 1 { - t.Errorf("Expected 1 delivery, got %d\n", len(grain.Deliveries)) - } - - if len(grain.Deliveries[0].Items) != 2 { - t.Errorf("Expected 2 items in delivery, got %d\n", len(grain.Deliveries[0].Items)) - } - -} diff --git a/discarded-host.go b/discarded-host.go index c50d68d..e7d560a 100644 --- a/discarded-host.go +++ b/discarded-host.go @@ -9,7 +9,6 @@ import ( ) type DiscardedHost struct { - *Connection Host string Tries int } @@ -26,7 +25,7 @@ func (d *DiscardedHostHandler) run() { d.mu.RLock() lst := make([]*DiscardedHost, 0, len(d.hosts)) for _, host := range d.hosts { - if host.Tries >= 0 || host.Tries < 5 { + if host.Tries >= 0 && host.Tries < 5 { go d.testConnection(host) lst = append(lst, host) } else { @@ -49,7 +48,9 @@ func (d *DiscardedHostHandler) testConnection(host *DiscardedHost) { if err != nil { host.Tries++ - host.Tries = -1 + if host.Tries >= 5 { + // Exceeded retry threshold; will be dropped by run loop. + } } else { conn.Close() if d.onConnection != nil { diff --git a/discarded-host_test.go b/discarded-host_test.go deleted file mode 100644 index e272bf7..0000000 --- a/discarded-host_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package main - -import ( - "testing" - "time" -) - -func TestDiscardedHost(t *testing.T) { - dh := NewDiscardedHostHandler(8080) - dh.SetReconnectHandler(func(host string) { - t.Log(host) - }) - dh.AppendHost("localhost") - time.Sleep(2 * time.Second) - if dh.hosts[0].Tries == 0 { - t.Error("Host not tested") - } -} diff --git a/frames.go b/frames.go new file mode 100644 index 0000000..9bc4242 --- /dev/null +++ b/frames.go @@ -0,0 +1,102 @@ +package main + +// Minimal frame abstractions retained after removal of the legacy TCP/frame +// networking layer. These types remain only to avoid a wide cascading refactor +// across existing grain / pool logic that still constructs and passes +// FrameWithPayload objects internally. +// +// The original responsibilities this replaces: +// - Binary framing, checksums, network IO +// - Distinction between request / reply frame types +// +// What remains: +// - A light weight container (FrameWithPayload) used as an in‑process +// envelope for status code + typed marker + payload bytes (JSON or proto). +// - Message / status constants referenced in existing code paths. +// +// Recommended future cleanup (post‑migration): +// - Remove FrameType entirely and replace with enumerated semantic results +// or error values. +// - Replace FrameWithPayload with a struct { Status int; Data []byte }. +// - Remove remote_* reply type branching once all callers rely on gRPC +// status + strongly typed responses. +// +// For now we keep this minimal surface to keep the gRPC migration focused. + +type ( + // FrameType is a symbolic identifier carried through existing code paths. + // No ordering or bit semantics are required anymore. + FrameType uint32 + StatusCode uint32 +) + +type Frame struct { + Type FrameType + StatusCode StatusCode + Length uint32 + // Checksum retained for compatibility; no longer validated. + Checksum uint32 +} + +// FrameWithPayload wraps a Frame with an opaque payload. +// Payload usually contains JSON encoded cart state or an error message. +type FrameWithPayload struct { + Frame + Payload []byte +} + +// ----------------------------------------------------------------------------- +// Legacy Frame Type Constants (minimal subset still referenced) +// ----------------------------------------------------------------------------- +const ( + RemoteGetState = FrameType(0x01) + RemoteHandleMutation = FrameType(0x02) + ResponseBody = FrameType(0x03) // (rarely used; kept for completeness) + RemoteGetStateReply = FrameType(0x04) + RemoteHandleMutationReply = FrameType(0x05) + RemoteCreateOrderReply = FrameType(0x06) +) + +// MakeFrameWithPayload constructs an in‑process frame wrapper. +// Length & Checksum are filled for backward compatibility (no validation logic +// depends on the checksum anymore). +func MakeFrameWithPayload(msg FrameType, statusCode StatusCode, payload []byte) FrameWithPayload { + length := uint32(len(payload)) + return FrameWithPayload{ + Frame: Frame{ + Type: msg, + StatusCode: statusCode, + Length: length, + Checksum: (uint32(msg) + uint32(statusCode) + length) / 8, // simple legacy formula + }, + Payload: payload, + } +} + +// Clone creates a shallow copy of the frame, duplicating the payload slice. +func (f *FrameWithPayload) Clone() *FrameWithPayload { + if f == nil { + return nil + } + cp := make([]byte, len(f.Payload)) + copy(cp, f.Payload) + return &FrameWithPayload{ + Frame: f.Frame, + Payload: cp, + } +} + +// NewErrorFrame helper for creating an error frame with a textual payload. +func NewErrorFrame(msg FrameType, code StatusCode, err error) FrameWithPayload { + var b []byte + if err != nil { + b = []byte(err.Error()) + } + return MakeFrameWithPayload(msg, code, b) +} + +// IsSuccess returns true if the status code indicates success in the +// conventional HTTP style range (200–299). This mirrors previous usage patterns. +func (f *FrameWithPayload) IsSuccess() bool { + return f != nil && f.StatusCode >= 200 && f.StatusCode < 300 +} diff --git a/go.mod b/go.mod index 59e5e67..c96f6e2 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,12 @@ module git.tornberg.me/go-cart-actor go 1.24.2 require ( + github.com/google/uuid v1.6.0 github.com/matst80/slask-finder v0.0.0-20250418094723-2eb7d6615761 github.com/prometheus/client_golang v1.22.0 github.com/rabbitmq/amqp091-go v1.10.0 github.com/yudhasubki/netpool v0.0.0-20230717065341-3c1353ca328e + google.golang.org/grpc v1.65.0 google.golang.org/protobuf v1.36.6 k8s.io/api v0.32.3 k8s.io/apimachinery v0.32.3 @@ -29,7 +31,6 @@ require ( github.com/google/gnostic-models v0.6.9 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/gofuzz v1.2.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.9.0 // indirect @@ -50,6 +51,7 @@ require ( golang.org/x/text v0.24.0 // indirect golang.org/x/time v0.11.0 // indirect golang.org/x/tools v0.32.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index e4aea59..60e7df5 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/Flaconi/go-klarna v0.0.0-20230216165926-e2f708c721d9 h1:U5gu3M9/khqtvgg6iRKo0+nxGEfPHWFHRlKrbZvFxIY= -github.com/Flaconi/go-klarna v0.0.0-20230216165926-e2f708c721d9/go.mod h1:+LVFV9FXH5cwN1VcU30WcNYRs5FhkEtL7/IqqTD42cU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -145,6 +143,10 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 h1:Zy9XzmMEflZ/MAaA7vNcoebnRAld7FsPW1EeBB7V0m8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= +google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= +google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/grpc_integration_test.go b/grpc_integration_test.go new file mode 100644 index 0000000..572821b --- /dev/null +++ b/grpc_integration_test.go @@ -0,0 +1,127 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "testing" + "time" + + messages "git.tornberg.me/go-cart-actor/proto" + "google.golang.org/grpc" +) + +// TestCartActorMutationAndState validates end-to-end gRPC mutation + state retrieval +// against a locally started gRPC server (single-node scenario). +func TestCartActorMutationAndState(t *testing.T) { + // Setup local grain pool + synced pool (no discovery, single host) + pool := NewGrainLocalPool(1024, time.Minute, spawn) + synced, err := NewSyncedPool(pool, "127.0.0.1", nil) + if err != nil { + t.Fatalf("NewSyncedPool error: %v", err) + } + + // Start gRPC server (CartActor + ControlPlane) on :1337 + grpcSrv, err := StartGRPCServer(":1337", pool, synced) + if err != nil { + t.Fatalf("StartGRPCServer error: %v", err) + } + defer grpcSrv.GracefulStop() + + // Dial the local server + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + conn, err := grpc.DialContext(ctx, "127.0.0.1:1337", + grpc.WithInsecure(), + grpc.WithBlock(), + ) + if err != nil { + t.Fatalf("grpc.Dial error: %v", err) + } + defer conn.Close() + + cartClient := messages.NewCartActorClient(conn) + + // Create a short cart id (<=16 chars so it fits into the fixed CartId 16-byte array cleanly) + cartID := fmt.Sprintf("cart-%d", time.Now().UnixNano()) + + // Build an AddRequest payload (quantity=1, sku=test-sku) + addReq := &messages.AddRequest{ + Quantity: 1, + Sku: "test-sku", + Country: "se", + } + + // Marshal underlying mutation payload using the existing handler code path + // We can directly marshal with proto since envelope expects raw bytes + handler, ok := Handlers[AddRequestType] + if !ok { + t.Fatalf("Handler for AddRequestType missing") + } + payloadData, err := getSerializedPayload(handler, addReq) + if err != nil { + t.Fatalf("serialize add request: %v", err) + } + + // Issue Mutate RPC + mutResp, err := cartClient.Mutate(context.Background(), &messages.MutationRequest{ + CartId: cartID, + Type: messages.MutationType(AddRequestType), + Payload: payloadData, + ClientTimestamp: time.Now().Unix(), + }) + 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)) + } + + // 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)) + } + if len(state.Items) != 1 { + t.Fatalf("Expected 1 item after mutation, got %d", len(state.Items)) + } + if state.Items[0].Sku != "test-sku" { + t.Fatalf("Unexpected item SKU: %s", state.Items[0].Sku) + } + + // Issue GetState RPC + getResp, err := cartClient.GetState(context.Background(), &messages.StateRequest{ + CartId: cartID, + }) + if err != nil { + t.Fatalf("GetState RPC error: %v", err) + } + if getResp.StatusCode != 200 { + t.Fatalf("GetState returned non-200 status: %d payload=%s", getResp.StatusCode, string(getResp.Payload)) + } + + state2 := &CartGrain{} + if err := json.Unmarshal(getResp.Payload, state2); err != nil { + t.Fatalf("Unmarshal get state: %v", err) + } + if len(state2.Items) != 1 { + t.Fatalf("Expected 1 item in GetState, got %d", len(state2.Items)) + } + if state2.Items[0].Sku != "test-sku" { + t.Fatalf("Unexpected SKU in GetState: %s", state2.Items[0].Sku) + } +} + +// getSerializedPayload serializes a mutation proto using the registered handler. +func getSerializedPayload(handler MessageHandler, content interface{}) ([]byte, error) { + msg := &Message{ + Type: AddRequestType, + Content: content, + } + var buf bytes.Buffer + if err := handler.Write(msg, &buf); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/grpc_server.go b/grpc_server.go new file mode 100644 index 0000000..1b10422 --- /dev/null +++ b/grpc_server.go @@ -0,0 +1,379 @@ +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" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// ----------------------------------------------------------------------------- +// Metrics +// ----------------------------------------------------------------------------- + +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() +} + +// ----------------------------------------------------------------------------- +// CartActor Service Implementation +// ----------------------------------------------------------------------------- + +type cartActorService struct { + proto.UnimplementedCartActorServer + pool GrainPool +} + +func newCartActorService(pool GrainPool) *cartActorService { + return &cartActorService{pool: pool} +} + +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) + } + + 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 + }) + 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") + } + + lis, err := net.Listen("tcp", addr) + if err != nil { + return nil, fmt.Errorf("listen %s: %w", addr, err) + } + + 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, + }, nil +} + +// GracefulStop stops the server gracefully. +func (s *GRPCServer) GracefulStop() { + if s == nil || s.server == nil { + return + } + s.server.GracefulStop() +} + +// 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...) + if err != nil { + return nil, err + } + return conn, nil +} + +// ----------------------------------------------------------------------------- +// Utility for converting internal errors to gRPC status (if needed later). +// ----------------------------------------------------------------------------- + +func grpcError(err error) error { + if err == nil { + return nil + } + // Extend mapping if we add richer error types. + return status.Error(codes.Internal, err.Error()) +} diff --git a/id_test.go b/id_test.go deleted file mode 100644 index d8953b8..0000000 --- a/id_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package main - -import ( - "testing" -) - -func TestIdGeneration(t *testing.T) { - // Generate a random ID - id := NewCartId() - - // Generate a random ID - id2 := NewCartId() - // Compare the two IDs - if id == id2 { - t.Errorf("IDs are the same: %v == %v", id, id2) - } else { - t.Log("ID generation test passed", id, id2) - } -} diff --git a/main.go b/main.go index 36823c5..3a87f4e 100644 --- a/main.go +++ b/main.go @@ -169,10 +169,13 @@ func main() { log.Fatalf("Error creating synced pool: %v\n", err) } - hg, err := NewGrainHandler(app.pool, ":1337") + // Start unified gRPC server (CartActor + ControlPlane) replacing legacy RPC server on :1337 + // TODO: Remove any remaining legacy RPC server references and deprecated frame-based code after full gRPC migration is validated. + grpcSrv, err := StartGRPCServer(":1337", app.pool, syncedPool) if err != nil { - log.Fatalf("Error creating handler: %v\n", err) + log.Fatalf("Error starting gRPC server: %v\n", err) } + defer grpcSrv.GracefulStop() go func() { for range time.Tick(time.Minute * 10) { @@ -202,17 +205,21 @@ func main() { mux.HandleFunc("/debug/pprof/trace", pprof.Trace) mux.Handle("/metrics", promhttp.Handler()) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { - if !hg.IsHealthy() { + // 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("handler not healthy")) + w.Write([]byte("grain pool at capacity")) return } if !syncedPool.IsHealthy() { w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("pool not healthy")) + w.Write([]byte("control plane not healthy")) return } - w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) }) diff --git a/main_test.go b/main_test.go deleted file mode 100644 index 1479969..0000000 --- a/main_test.go +++ /dev/null @@ -1,286 +0,0 @@ -package main - -import ( - "os" - "testing" -) - -func TestGetCountryFromHost(t *testing.T) { - tests := []struct { - name string - host string - expected string - }{ - { - name: "Norwegian host", - host: "s10n-no.tornberg.me", - expected: "no", - }, - { - name: "Swedish host", - host: "s10n-se.tornberg.me", - expected: "se", - }, - { - name: "Host with -no in the middle", - host: "api-no-staging.tornberg.me", - expected: "no", - }, - { - name: "Host without country suffix", - host: "s10n.tornberg.me", - expected: "se", - }, - { - name: "Host with different domain", - host: "example-no.com", - expected: "no", - }, - { - name: "Empty host", - host: "", - expected: "se", - }, - { - name: "Host with uppercase", - host: "S10N-NO.TORNBERG.ME", - expected: "no", - }, - { - name: "Host with mixed case", - host: "S10n-No.Tornberg.Me", - expected: "no", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := getCountryFromHost(tt.host) - if result != tt.expected { - t.Errorf("getCountryFromHost(%q) = %q, want %q", tt.host, result, tt.expected) - } - }) - } -} - -func TestGetCheckoutOrder(t *testing.T) { - // Save original environment variable and restore after test - originalCartBaseUrl := os.Getenv("CART_BASE_URL") - defer func() { - if originalCartBaseUrl == "" { - os.Unsetenv("CART_BASE_URL") - } else { - os.Setenv("CART_BASE_URL", originalCartBaseUrl) - } - }() - - tests := []struct { - name string - host string - cartId CartId - cartBaseUrl string - expectedUrls struct { - terms string - checkout string - confirmation string - validation string - push string - country string - } - }{ - { - name: "Norwegian host with default cart base URL", - host: "s10n-no.tornberg.me", - cartId: ToCartId("test-cart-123"), - cartBaseUrl: "", // Use default - expectedUrls: struct { - terms string - checkout string - confirmation string - validation string - push string - country string - }{ - terms: "https://s10n-no.tornberg.me/terms", - checkout: "https://s10n-no.tornberg.me/checkout?order_id={checkout.order.id}", - confirmation: "https://s10n-no.tornberg.me/confirmation/{checkout.order.id}", - validation: "https://cart.tornberg.me/validation", - push: "https://cart.tornberg.me/push?order_id={checkout.order.id}", - country: "no", - }, - }, - { - name: "Swedish host with default cart base URL", - host: "s10n-se.tornberg.me", - cartId: ToCartId("test-cart-456"), - cartBaseUrl: "", // Use default - expectedUrls: struct { - terms string - checkout string - confirmation string - validation string - push string - country string - }{ - terms: "https://s10n-se.tornberg.me/terms", - checkout: "https://s10n-se.tornberg.me/checkout?order_id={checkout.order.id}", - confirmation: "https://s10n-se.tornberg.me/confirmation/{checkout.order.id}", - validation: "https://cart.tornberg.me/validation", - push: "https://cart.tornberg.me/push?order_id={checkout.order.id}", - country: "se", - }, - }, - { - name: "Norwegian host with custom cart base URL", - host: "s10n-no.tornberg.me", - cartId: ToCartId("test-cart-789"), - cartBaseUrl: "https://custom-cart.example.com", - expectedUrls: struct { - terms string - checkout string - confirmation string - validation string - push string - country string - }{ - terms: "https://s10n-no.tornberg.me/terms", - checkout: "https://s10n-no.tornberg.me/checkout?order_id={checkout.order.id}", - confirmation: "https://s10n-no.tornberg.me/confirmation/{checkout.order.id}", - validation: "https://custom-cart.example.com/validation", - push: "https://custom-cart.example.com/push?order_id={checkout.order.id}", - country: "no", - }, - }, - { - name: "Host without country code defaults to Swedish", - host: "s10n.tornberg.me", - cartId: ToCartId("test-cart-default"), - cartBaseUrl: "", - expectedUrls: struct { - terms string - checkout string - confirmation string - validation string - push string - country string - }{ - terms: "https://s10n.tornberg.me/terms", - checkout: "https://s10n.tornberg.me/checkout?order_id={checkout.order.id}", - confirmation: "https://s10n.tornberg.me/confirmation/{checkout.order.id}", - validation: "https://cart.tornberg.me/validation", - push: "https://cart.tornberg.me/push?order_id={checkout.order.id}", - country: "se", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Set up environment variable for this test - if tt.cartBaseUrl == "" { - os.Unsetenv("CART_BASE_URL") - } else { - os.Setenv("CART_BASE_URL", tt.cartBaseUrl) - } - - result := getCheckoutOrder(tt.host, tt.cartId) - - // Verify the result is not nil - if result == nil { - t.Fatal("getCheckoutOrder returned nil") - } - - // Check each URL field - if result.Terms != tt.expectedUrls.terms { - t.Errorf("Terms URL: got %q, want %q", result.Terms, tt.expectedUrls.terms) - } - - if result.Checkout != tt.expectedUrls.checkout { - t.Errorf("Checkout URL: got %q, want %q", result.Checkout, tt.expectedUrls.checkout) - } - - if result.Confirmation != tt.expectedUrls.confirmation { - t.Errorf("Confirmation URL: got %q, want %q", result.Confirmation, tt.expectedUrls.confirmation) - } - - if result.Validation != tt.expectedUrls.validation { - t.Errorf("Validation URL: got %q, want %q", result.Validation, tt.expectedUrls.validation) - } - - if result.Push != tt.expectedUrls.push { - t.Errorf("Push URL: got %q, want %q", result.Push, tt.expectedUrls.push) - } - - if result.Country != tt.expectedUrls.country { - t.Errorf("Country: got %q, want %q", result.Country, tt.expectedUrls.country) - } - }) - } -} - -func TestGetCheckoutOrderIntegration(t *testing.T) { - // Test that both functions work together correctly - hosts := []string{"s10n-no.tornberg.me", "s10n-se.tornberg.me"} - cartId := ToCartId("integration-test-cart") - - for _, host := range hosts { - t.Run(host, func(t *testing.T) { - // Get country from host - country := getCountryFromHost(host) - - // Get checkout order - order := getCheckoutOrder(host, cartId) - - // Verify that the country in the order matches what getCountryFromHost returns - if order.Country != country { - t.Errorf("Country mismatch: getCountryFromHost(%q) = %q, but order.Country = %q", - host, country, order.Country) - } - - // Verify that all URLs contain the correct host - expectedBaseUrl := "https://" + host - - if !containsPrefix(order.Terms, expectedBaseUrl) { - t.Errorf("Terms URL should start with %q, got %q", expectedBaseUrl, order.Terms) - } - - if !containsPrefix(order.Checkout, expectedBaseUrl) { - t.Errorf("Checkout URL should start with %q, got %q", expectedBaseUrl, order.Checkout) - } - - if !containsPrefix(order.Confirmation, expectedBaseUrl) { - t.Errorf("Confirmation URL should start with %q, got %q", expectedBaseUrl, order.Confirmation) - } - }) - } -} - -// Helper function to check if a string starts with a prefix -func containsPrefix(s, prefix string) bool { - return len(s) >= len(prefix) && s[:len(prefix)] == prefix -} - -// Benchmark tests to measure performance -func BenchmarkGetCountryFromHost(b *testing.B) { - hosts := []string{ - "s10n-no.tornberg.me", - "s10n-se.tornberg.me", - "api-no-staging.tornberg.me", - "s10n.tornberg.me", - } - - for i := 0; i < b.N; i++ { - for _, host := range hosts { - getCountryFromHost(host) - } - } -} - -func BenchmarkGetCheckoutOrder(b *testing.B) { - host := "s10n-no.tornberg.me" - cartId := ToCartId("benchmark-cart") - - for i := 0; i < b.N; i++ { - getCheckoutOrder(host, cartId) - } -} diff --git a/message-handler_test.go b/message-handler_test.go deleted file mode 100644 index 9195457..0000000 --- a/message-handler_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package main - -import ( - "bytes" - "testing" - - messages "git.tornberg.me/go-cart-actor/proto" -) - -func TestAddRequest(t *testing.T) { - h, err := GetMessageHandler(AddRequestType) - if err != nil { - t.Errorf("Error getting message handler: %v\n", err) - } - if h == nil { - t.Errorf("Expected message handler, got nil\n") - } - message := Message{ - Type: AddRequestType, - Content: &messages.AddRequest{ - Quantity: 2, - Sku: "123", - }, - } - var b bytes.Buffer - err = h.Write(&message, &b) - if err != nil { - t.Errorf("Error writing message: %v\n", err) - } - result, err := h.Read(b.Bytes()) - if err != nil { - t.Errorf("Error reading message: %v\n", err) - } - if result == nil { - t.Errorf("Expected result, got nil\n") - } - r, ok := result.(*messages.AddRequest) - if !ok { - t.Errorf("Expected AddRequest, got %T\n", result) - } - if r.Quantity != 2 { - t.Errorf("Expected quantity 2, got %d\n", r.Quantity) - } - if r.Sku != "123" { - t.Errorf("Expected sku '123', got %s\n", r.Sku) - } -} - -func TestItemRequest(t *testing.T) { - h, err := GetMessageHandler(AddItemType) - if err != nil { - t.Errorf("Error getting message handler: %v\n", err) - } - if h == nil { - t.Errorf("Expected message handler, got nil\n") - } - message := Message{ - Type: AddItemType, - Content: &messages.AddItem{ - Quantity: 2, - Sku: "123", - Price: 100, - Name: "Test item", - Image: "test.jpg", - }, - } - var b bytes.Buffer - err = h.Write(&message, &b) - if err != nil { - t.Errorf("Error writing message: %v\n", err) - } - result, err := h.Read(b.Bytes()) - if err != nil { - t.Errorf("Error reading message: %v\n", err) - } - if result == nil { - t.Errorf("Expected result, got nil\n") - } - var r *messages.AddItem - ok := h.Is(&message) - if !ok { - t.Errorf("Expected AddRequest, got %T\n", result) - } - if r.Quantity != 2 { - t.Errorf("Expected quantity 2, got %d\n", r.Quantity) - } - if r.Sku != "123" { - t.Errorf("Expected sku '123', got %s\n", r.Sku) - } -} - -func TestSetDeliveryMssage(t *testing.T) { - h, err := GetMessageHandler(SetDeliveryType) - if err != nil { - t.Errorf("Error getting message handler: %v\n", err) - } - if h == nil { - t.Errorf("Expected message handler, got nil\n") - } - message := Message{ - Type: SetDeliveryType, - Content: &messages.SetDelivery{ - Provider: "test", - Items: []int64{1, 2}, - }, - } - var b bytes.Buffer - err = h.Write(&message, &b) - if err != nil { - t.Errorf("Error writing message: %v\n", err) - } - result, err := h.Read(b.Bytes()) - if err != nil { - t.Errorf("Error reading message: %v\n", err) - } - if result == nil { - t.Errorf("Expected result, got nil\n") - } - r, ok := result.(*messages.SetDelivery) - if !ok { - t.Errorf("Expected AddRequest, got %T\n", result) - } - if len(r.Items) != 2 { - t.Errorf("Expected 2 items, got %d\n", len(r.Items)) - } - if r.Provider != "test" { - t.Errorf("Expected provider 'test', got %s\n", r.Provider) - } -} diff --git a/packet.go b/packet.go deleted file mode 100644 index 58c223c..0000000 --- a/packet.go +++ /dev/null @@ -1,89 +0,0 @@ -package main - -const ( - RemoteGetState = FrameType(0x01) - RemoteHandleMutation = FrameType(0x02) - ResponseBody = FrameType(0x03) - RemoteGetStateReply = FrameType(0x04) - RemoteHandleMutationReply = FrameType(0x05) - RemoteCreateOrderReply = FrameType(0x06) -) - -// type CartPacket struct { -// Version PackageVersion -// MessageType CartMessage -// DataLength uint32 -// StatusCode uint32 -// Id CartId -// } - -// type Packet struct { -// Version PackageVersion -// MessageType PoolMessage -// DataLength uint32 -// StatusCode uint32 -// } - -// var headerData = make([]byte, 4) - -// func matchHeader(conn io.Reader) error { - -// pos := 0 -// for pos < 4 { - -// l, err := conn.Read(headerData) -// if err != nil { -// return err -// } -// for i := 0; i < l; i++ { -// if headerData[i] == header[pos] { -// pos++ -// if pos == 4 { -// return nil -// } -// } else { -// pos = 0 -// } -// } -// } -// return nil -// } - -// func ReadPacket(conn io.Reader, packet *Packet) error { -// err := matchHeader(conn) -// if err != nil { -// return err -// } -// return binary.Read(conn, binary.LittleEndian, packet) -// } - -// func ReadCartPacket(conn io.Reader, packet *CartPacket) error { -// err := matchHeader(conn) -// if err != nil { -// return err -// } -// return binary.Read(conn, binary.LittleEndian, packet) -// } - -// func GetPacketData(conn io.Reader, len uint32) ([]byte, error) { -// if len == 0 { -// return []byte{}, nil -// } -// data := make([]byte, len) -// _, err := conn.Read(data) -// return data, err -// } - -// func ReceivePacket(conn io.Reader) (uint32, []byte, error) { -// var packet Packet -// err := ReadPacket(conn, &packet) -// if err != nil { -// return 0, nil, err -// } - -// data, err := GetPacketData(conn, packet.DataLength) -// if err != nil { -// return 0, nil, err -// } -// return packet.MessageType, data, nil -// } diff --git a/proto/cart_actor.pb.go b/proto/cart_actor.pb.go new file mode 100644 index 0000000..7f57b85 --- /dev/null +++ b/proto/cart_actor.pb.go @@ -0,0 +1,420 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.10 +// protoc v3.21.12 +// source: cart_actor.proto + +package messages + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// MutationType corresponds 1:1 with the legacy uint16 message type constants. +type MutationType int32 + +const ( + MutationType_MUTATION_TYPE_UNSPECIFIED MutationType = 0 + MutationType_MUTATION_ADD_REQUEST MutationType = 1 + MutationType_MUTATION_ADD_ITEM MutationType = 2 + // (3 was unused / reserved in legacy framing) + MutationType_MUTATION_REMOVE_ITEM MutationType = 4 + MutationType_MUTATION_REMOVE_DELIVERY MutationType = 5 + MutationType_MUTATION_CHANGE_QUANTITY MutationType = 6 + MutationType_MUTATION_SET_DELIVERY MutationType = 7 + MutationType_MUTATION_SET_PICKUP_POINT MutationType = 8 + MutationType_MUTATION_CREATE_CHECKOUT_ORDER MutationType = 9 + MutationType_MUTATION_SET_CART_ITEMS MutationType = 10 + MutationType_MUTATION_ORDER_COMPLETED MutationType = 11 +) + +// Enum value maps for MutationType. +var ( + MutationType_name = map[int32]string{ + 0: "MUTATION_TYPE_UNSPECIFIED", + 1: "MUTATION_ADD_REQUEST", + 2: "MUTATION_ADD_ITEM", + 4: "MUTATION_REMOVE_ITEM", + 5: "MUTATION_REMOVE_DELIVERY", + 6: "MUTATION_CHANGE_QUANTITY", + 7: "MUTATION_SET_DELIVERY", + 8: "MUTATION_SET_PICKUP_POINT", + 9: "MUTATION_CREATE_CHECKOUT_ORDER", + 10: "MUTATION_SET_CART_ITEMS", + 11: "MUTATION_ORDER_COMPLETED", + } + MutationType_value = map[string]int32{ + "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, + } +) + +func (x MutationType) Enum() *MutationType { + p := new(MutationType) + *p = x + return p +} + +func (x MutationType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (MutationType) Descriptor() protoreflect.EnumDescriptor { + return file_cart_actor_proto_enumTypes[0].Descriptor() +} + +func (MutationType) Type() protoreflect.EnumType { + return &file_cart_actor_proto_enumTypes[0] +} + +func (x MutationType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use MutationType.Descriptor instead. +func (MutationType) EnumDescriptor() ([]byte, []int) { + return file_cart_actor_proto_rawDescGZIP(), []int{0} +} + +// 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. +type MutationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + CartId string `protobuf:"bytes,1,opt,name=cart_id,json=cartId,proto3" json:"cart_id,omitempty"` + Type MutationType `protobuf:"varint,2,opt,name=type,proto3,enum=messages.MutationType" json:"type,omitempty"` + Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"` + ClientTimestamp int64 `protobuf:"varint,4,opt,name=client_timestamp,json=clientTimestamp,proto3" json:"client_timestamp,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MutationRequest) Reset() { + *x = MutationRequest{} + mi := &file_cart_actor_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MutationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MutationRequest) ProtoMessage() {} + +func (x *MutationRequest) ProtoReflect() protoreflect.Message { + mi := &file_cart_actor_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MutationRequest.ProtoReflect.Descriptor instead. +func (*MutationRequest) Descriptor() ([]byte, []int) { + return file_cart_actor_proto_rawDescGZIP(), []int{0} +} + +func (x *MutationRequest) GetCartId() string { + if x != nil { + return x.CartId + } + return "" +} + +func (x *MutationRequest) GetType() MutationType { + if x != nil { + return x.Type + } + return MutationType_MUTATION_TYPE_UNSPECIFIED +} + +func (x *MutationRequest) GetPayload() []byte { + if x != nil { + return x.Payload + } + return nil +} + +func (x *MutationRequest) GetClientTimestamp() int64 { + if x != nil { + return x.ClientTimestamp + } + return 0 +} + +// MutationReply returns a status code (legacy semantics) plus a JSON payload +// representing the full cart state (or an error message if non-200). +type MutationReply struct { + state protoimpl.MessageState `protogen:"open.v1"` + StatusCode int32 `protobuf:"varint,1,opt,name=status_code,json=statusCode,proto3" json:"status_code,omitempty"` + Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` // JSON cart state or error string + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MutationReply) Reset() { + *x = MutationReply{} + mi := &file_cart_actor_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MutationReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MutationReply) ProtoMessage() {} + +func (x *MutationReply) ProtoReflect() protoreflect.Message { + mi := &file_cart_actor_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MutationReply.ProtoReflect.Descriptor instead. +func (*MutationReply) Descriptor() ([]byte, []int) { + return file_cart_actor_proto_rawDescGZIP(), []int{1} +} + +func (x *MutationReply) GetStatusCode() int32 { + if x != nil { + return x.StatusCode + } + return 0 +} + +func (x *MutationReply) GetPayload() []byte { + if x != nil { + return x.Payload + } + return nil +} + +// StateRequest fetches current cart state without mutation. +type StateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + CartId string `protobuf:"bytes,1,opt,name=cart_id,json=cartId,proto3" json:"cart_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StateRequest) Reset() { + *x = StateRequest{} + mi := &file_cart_actor_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StateRequest) ProtoMessage() {} + +func (x *StateRequest) ProtoReflect() protoreflect.Message { + mi := &file_cart_actor_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StateRequest.ProtoReflect.Descriptor instead. +func (*StateRequest) Descriptor() ([]byte, []int) { + return file_cart_actor_proto_rawDescGZIP(), []int{2} +} + +func (x *StateRequest) GetCartId() string { + if x != nil { + return x.CartId + } + return "" +} + +// StateReply mirrors MutationReply for consistency. +type StateReply struct { + state protoimpl.MessageState `protogen:"open.v1"` + StatusCode int32 `protobuf:"varint,1,opt,name=status_code,json=statusCode,proto3" json:"status_code,omitempty"` + Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` // JSON cart state or error string + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StateReply) Reset() { + *x = StateReply{} + mi := &file_cart_actor_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StateReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StateReply) ProtoMessage() {} + +func (x *StateReply) ProtoReflect() protoreflect.Message { + mi := &file_cart_actor_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StateReply.ProtoReflect.Descriptor instead. +func (*StateReply) Descriptor() ([]byte, []int) { + return file_cart_actor_proto_rawDescGZIP(), []int{3} +} + +func (x *StateReply) GetStatusCode() int32 { + if x != nil { + return x.StatusCode + } + return 0 +} + +func (x *StateReply) GetPayload() []byte { + if x != nil { + return x.Payload + } + return nil +} + +var File_cart_actor_proto protoreflect.FileDescriptor + +const file_cart_actor_proto_rawDesc = "" + + "\n" + + "\x10cart_actor.proto\x12\bmessages\"\x9b\x01\n" + + "\x0fMutationRequest\x12\x17\n" + + "\acart_id\x18\x01 \x01(\tR\x06cartId\x12*\n" + + "\x04type\x18\x02 \x01(\x0e2\x16.messages.MutationTypeR\x04type\x12\x18\n" + + "\apayload\x18\x03 \x01(\fR\apayload\x12)\n" + + "\x10client_timestamp\x18\x04 \x01(\x03R\x0fclientTimestamp\"J\n" + + "\rMutationReply\x12\x1f\n" + + "\vstatus_code\x18\x01 \x01(\x05R\n" + + "statusCode\x12\x18\n" + + "\apayload\x18\x02 \x01(\fR\apayload\"'\n" + + "\fStateRequest\x12\x17\n" + + "\acart_id\x18\x01 \x01(\tR\x06cartId\"G\n" + + "\n" + + "StateReply\x12\x1f\n" + + "\vstatus_code\x18\x01 \x01(\x05R\n" + + "statusCode\x12\x18\n" + + "\apayload\x18\x02 \x01(\fR\apayload*\xcd\x02\n" + + "\fMutationType\x12\x1d\n" + + "\x19MUTATION_TYPE_UNSPECIFIED\x10\x00\x12\x18\n" + + "\x14MUTATION_ADD_REQUEST\x10\x01\x12\x15\n" + + "\x11MUTATION_ADD_ITEM\x10\x02\x12\x18\n" + + "\x14MUTATION_REMOVE_ITEM\x10\x04\x12\x1c\n" + + "\x18MUTATION_REMOVE_DELIVERY\x10\x05\x12\x1c\n" + + "\x18MUTATION_CHANGE_QUANTITY\x10\x06\x12\x19\n" + + "\x15MUTATION_SET_DELIVERY\x10\a\x12\x1d\n" + + "\x19MUTATION_SET_PICKUP_POINT\x10\b\x12\"\n" + + "\x1eMUTATION_CREATE_CHECKOUT_ORDER\x10\t\x12\x1b\n" + + "\x17MUTATION_SET_CART_ITEMS\x10\n" + + "\x12\x1c\n" + + "\x18MUTATION_ORDER_COMPLETED\x10\v2\x83\x01\n" + + "\tCartActor\x12<\n" + + "\x06Mutate\x12\x19.messages.MutationRequest\x1a\x17.messages.MutationReply\x128\n" + + "\bGetState\x12\x16.messages.StateRequest\x1a\x14.messages.StateReplyB\fZ\n" + + ".;messagesb\x06proto3" + +var ( + file_cart_actor_proto_rawDescOnce sync.Once + file_cart_actor_proto_rawDescData []byte +) + +func file_cart_actor_proto_rawDescGZIP() []byte { + file_cart_actor_proto_rawDescOnce.Do(func() { + file_cart_actor_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_cart_actor_proto_rawDesc), len(file_cart_actor_proto_rawDesc))) + }) + return file_cart_actor_proto_rawDescData +} + +var file_cart_actor_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_cart_actor_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_cart_actor_proto_goTypes = []any{ + (MutationType)(0), // 0: messages.MutationType + (*MutationRequest)(nil), // 1: messages.MutationRequest + (*MutationReply)(nil), // 2: messages.MutationReply + (*StateRequest)(nil), // 3: messages.StateRequest + (*StateReply)(nil), // 4: messages.StateReply +} +var file_cart_actor_proto_depIdxs = []int32{ + 0, // 0: messages.MutationRequest.type:type_name -> messages.MutationType + 1, // 1: messages.CartActor.Mutate:input_type -> messages.MutationRequest + 3, // 2: messages.CartActor.GetState:input_type -> messages.StateRequest + 2, // 3: messages.CartActor.Mutate:output_type -> messages.MutationReply + 4, // 4: messages.CartActor.GetState:output_type -> messages.StateReply + 3, // [3:5] is the sub-list for method output_type + 1, // [1:3] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_cart_actor_proto_init() } +func file_cart_actor_proto_init() { + if File_cart_actor_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_cart_actor_proto_rawDesc), len(file_cart_actor_proto_rawDesc)), + NumEnums: 1, + NumMessages: 4, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_cart_actor_proto_goTypes, + DependencyIndexes: file_cart_actor_proto_depIdxs, + EnumInfos: file_cart_actor_proto_enumTypes, + MessageInfos: file_cart_actor_proto_msgTypes, + }.Build() + File_cart_actor_proto = out.File + file_cart_actor_proto_goTypes = nil + file_cart_actor_proto_depIdxs = nil +} diff --git a/proto/cart_actor.proto b/proto/cart_actor.proto new file mode 100644 index 0000000..7aa5aaf --- /dev/null +++ b/proto/cart_actor.proto @@ -0,0 +1,89 @@ +syntax = "proto3"; + +package messages; + +option go_package = ".;messages"; + +// ----------------------------------------------------------------------------- +// Cart Actor gRPC API (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. +// ----------------------------------------------------------------------------- + +// 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; +} + +// 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). +message MutationReply { + int32 status_code = 1; + bytes payload = 2; // JSON cart state or error string +} + +// StateRequest fetches current cart state without mutation. +message StateRequest { + string cart_id = 1; +} + +// StateReply mirrors MutationReply for consistency. +message StateReply { + int32 status_code = 1; + bytes payload = 2; // JSON cart state or error string +} + +// 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); + + // 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. +// ----------------------------------------------------------------------------- diff --git a/proto/cart_actor_grpc.pb.go b/proto/cart_actor_grpc.pb.go new file mode 100644 index 0000000..6d9444a --- /dev/null +++ b/proto/cart_actor_grpc.pb.go @@ -0,0 +1,167 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v3.21.12 +// source: cart_actor.proto + +package messages + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + CartActor_Mutate_FullMethodName = "/messages.CartActor/Mutate" + CartActor_GetState_FullMethodName = "/messages.CartActor/GetState" +) + +// CartActorClient is the client API for CartActor service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// 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) + // GetState retrieves the cart's current state (JSON). + GetState(ctx context.Context, in *StateRequest, opts ...grpc.CallOption) (*StateReply, error) +} + +type cartActorClient struct { + cc grpc.ClientConnInterface +} + +func NewCartActorClient(cc grpc.ClientConnInterface) CartActorClient { + return &cartActorClient{cc} +} + +func (c *cartActorClient) Mutate(ctx context.Context, in *MutationRequest, 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...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *cartActorClient) GetState(ctx context.Context, in *StateRequest, opts ...grpc.CallOption) (*StateReply, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StateReply) + err := c.cc.Invoke(ctx, CartActor_GetState_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// CartActorServer is the server API for CartActor service. +// All implementations must embed UnimplementedCartActorServer +// for forward compatibility. +// +// 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) + // GetState retrieves the cart's current state (JSON). + GetState(context.Context, *StateRequest) (*StateReply, error) + mustEmbedUnimplementedCartActorServer() +} + +// UnimplementedCartActorServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedCartActorServer struct{} + +func (UnimplementedCartActorServer) Mutate(context.Context, *MutationRequest) (*MutationReply, error) { + return nil, status.Errorf(codes.Unimplemented, "method Mutate not implemented") +} +func (UnimplementedCartActorServer) GetState(context.Context, *StateRequest) (*StateReply, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetState not implemented") +} +func (UnimplementedCartActorServer) mustEmbedUnimplementedCartActorServer() {} +func (UnimplementedCartActorServer) testEmbeddedByValue() {} + +// UnsafeCartActorServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to CartActorServer will +// result in compilation errors. +type UnsafeCartActorServer interface { + mustEmbedUnimplementedCartActorServer() +} + +func RegisterCartActorServer(s grpc.ServiceRegistrar, srv CartActorServer) { + // If the following call pancis, it indicates UnimplementedCartActorServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&CartActor_ServiceDesc, srv) +} + +func _CartActor_Mutate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MutationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CartActorServer).Mutate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CartActor_Mutate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CartActorServer).Mutate(ctx, req.(*MutationRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _CartActor_GetState_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(StateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CartActorServer).GetState(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CartActor_GetState_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CartActorServer).GetState(ctx, req.(*StateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// CartActor_ServiceDesc is the grpc.ServiceDesc for CartActor service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var CartActor_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "messages.CartActor", + HandlerType: (*CartActorServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Mutate", + Handler: _CartActor_Mutate_Handler, + }, + { + MethodName: "GetState", + Handler: _CartActor_GetState_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "cart_actor.proto", +} diff --git a/proto/control_plane.pb.go b/proto/control_plane.pb.go new file mode 100644 index 0000000..0b0252a --- /dev/null +++ b/proto/control_plane.pb.go @@ -0,0 +1,496 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.10 +// protoc v3.21.12 +// source: control_plane.proto + +package messages + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Empty request placeholder (common pattern). +type Empty struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Empty) Reset() { + *x = Empty{} + mi := &file_control_plane_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Empty) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Empty) ProtoMessage() {} + +func (x *Empty) ProtoReflect() protoreflect.Message { + mi := &file_control_plane_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Empty.ProtoReflect.Descriptor instead. +func (*Empty) Descriptor() ([]byte, []int) { + return file_control_plane_proto_rawDescGZIP(), []int{0} +} + +// Ping reply includes responding host and its current unix time (seconds). +type PingReply struct { + state protoimpl.MessageState `protogen:"open.v1"` + Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` + UnixTime int64 `protobuf:"varint,2,opt,name=unix_time,json=unixTime,proto3" json:"unix_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PingReply) Reset() { + *x = PingReply{} + mi := &file_control_plane_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PingReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PingReply) ProtoMessage() {} + +func (x *PingReply) ProtoReflect() protoreflect.Message { + mi := &file_control_plane_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PingReply.ProtoReflect.Descriptor instead. +func (*PingReply) Descriptor() ([]byte, []int) { + return file_control_plane_proto_rawDescGZIP(), []int{1} +} + +func (x *PingReply) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *PingReply) GetUnixTime() int64 { + if x != nil { + return x.UnixTime + } + return 0 +} + +// NegotiateRequest carries the caller's full view of known hosts (including self). +type NegotiateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + KnownHosts []string `protobuf:"bytes,1,rep,name=known_hosts,json=knownHosts,proto3" json:"known_hosts,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NegotiateRequest) Reset() { + *x = NegotiateRequest{} + mi := &file_control_plane_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NegotiateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NegotiateRequest) ProtoMessage() {} + +func (x *NegotiateRequest) ProtoReflect() protoreflect.Message { + mi := &file_control_plane_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NegotiateRequest.ProtoReflect.Descriptor instead. +func (*NegotiateRequest) Descriptor() ([]byte, []int) { + return file_control_plane_proto_rawDescGZIP(), []int{2} +} + +func (x *NegotiateRequest) GetKnownHosts() []string { + if x != nil { + return x.KnownHosts + } + return nil +} + +// NegotiateReply returns the callee's healthy hosts (including itself). +type NegotiateReply struct { + state protoimpl.MessageState `protogen:"open.v1"` + Hosts []string `protobuf:"bytes,1,rep,name=hosts,proto3" json:"hosts,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NegotiateReply) Reset() { + *x = NegotiateReply{} + mi := &file_control_plane_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NegotiateReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NegotiateReply) ProtoMessage() {} + +func (x *NegotiateReply) ProtoReflect() protoreflect.Message { + mi := &file_control_plane_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NegotiateReply.ProtoReflect.Descriptor instead. +func (*NegotiateReply) Descriptor() ([]byte, []int) { + return file_control_plane_proto_rawDescGZIP(), []int{3} +} + +func (x *NegotiateReply) GetHosts() []string { + if x != nil { + return x.Hosts + } + return nil +} + +// CartIdsReply returns the list of cart IDs (string form) currently owned locally. +type CartIdsReply struct { + state protoimpl.MessageState `protogen:"open.v1"` + CartIds []string `protobuf:"bytes,1,rep,name=cart_ids,json=cartIds,proto3" json:"cart_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CartIdsReply) Reset() { + *x = CartIdsReply{} + mi := &file_control_plane_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CartIdsReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CartIdsReply) ProtoMessage() {} + +func (x *CartIdsReply) ProtoReflect() protoreflect.Message { + mi := &file_control_plane_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CartIdsReply.ProtoReflect.Descriptor instead. +func (*CartIdsReply) Descriptor() ([]byte, []int) { + return file_control_plane_proto_rawDescGZIP(), []int{4} +} + +func (x *CartIdsReply) GetCartIds() []string { + if x != nil { + return x.CartIds + } + return nil +} + +// OwnerChangeRequest notifies peers that ownership of a cart moved (or is moving) to new_host. +type OwnerChangeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + CartId string `protobuf:"bytes,1,opt,name=cart_id,json=cartId,proto3" json:"cart_id,omitempty"` + NewHost string `protobuf:"bytes,2,opt,name=new_host,json=newHost,proto3" json:"new_host,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OwnerChangeRequest) Reset() { + *x = OwnerChangeRequest{} + mi := &file_control_plane_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OwnerChangeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OwnerChangeRequest) ProtoMessage() {} + +func (x *OwnerChangeRequest) ProtoReflect() protoreflect.Message { + mi := &file_control_plane_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OwnerChangeRequest.ProtoReflect.Descriptor instead. +func (*OwnerChangeRequest) Descriptor() ([]byte, []int) { + return file_control_plane_proto_rawDescGZIP(), []int{5} +} + +func (x *OwnerChangeRequest) GetCartId() string { + if x != nil { + return x.CartId + } + return "" +} + +func (x *OwnerChangeRequest) GetNewHost() string { + if x != nil { + return x.NewHost + } + return "" +} + +// OwnerChangeAck indicates acceptance or rejection of an ownership change. +type OwnerChangeAck struct { + state protoimpl.MessageState `protogen:"open.v1"` + Accepted bool `protobuf:"varint,1,opt,name=accepted,proto3" json:"accepted,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OwnerChangeAck) Reset() { + *x = OwnerChangeAck{} + mi := &file_control_plane_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OwnerChangeAck) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OwnerChangeAck) ProtoMessage() {} + +func (x *OwnerChangeAck) ProtoReflect() protoreflect.Message { + mi := &file_control_plane_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OwnerChangeAck.ProtoReflect.Descriptor instead. +func (*OwnerChangeAck) Descriptor() ([]byte, []int) { + return file_control_plane_proto_rawDescGZIP(), []int{6} +} + +func (x *OwnerChangeAck) GetAccepted() bool { + if x != nil { + return x.Accepted + } + return false +} + +func (x *OwnerChangeAck) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +// ClosingNotice notifies peers this host is terminating (so they can drop / re-resolve). +type ClosingNotice struct { + state protoimpl.MessageState `protogen:"open.v1"` + Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClosingNotice) Reset() { + *x = ClosingNotice{} + mi := &file_control_plane_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClosingNotice) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClosingNotice) ProtoMessage() {} + +func (x *ClosingNotice) ProtoReflect() protoreflect.Message { + mi := &file_control_plane_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClosingNotice.ProtoReflect.Descriptor instead. +func (*ClosingNotice) Descriptor() ([]byte, []int) { + return file_control_plane_proto_rawDescGZIP(), []int{7} +} + +func (x *ClosingNotice) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +var File_control_plane_proto protoreflect.FileDescriptor + +const file_control_plane_proto_rawDesc = "" + + "\n" + + "\x13control_plane.proto\x12\bmessages\"\a\n" + + "\x05Empty\"<\n" + + "\tPingReply\x12\x12\n" + + "\x04host\x18\x01 \x01(\tR\x04host\x12\x1b\n" + + "\tunix_time\x18\x02 \x01(\x03R\bunixTime\"3\n" + + "\x10NegotiateRequest\x12\x1f\n" + + "\vknown_hosts\x18\x01 \x03(\tR\n" + + "knownHosts\"&\n" + + "\x0eNegotiateReply\x12\x14\n" + + "\x05hosts\x18\x01 \x03(\tR\x05hosts\")\n" + + "\fCartIdsReply\x12\x19\n" + + "\bcart_ids\x18\x01 \x03(\tR\acartIds\"H\n" + + "\x12OwnerChangeRequest\x12\x17\n" + + "\acart_id\x18\x01 \x01(\tR\x06cartId\x12\x19\n" + + "\bnew_host\x18\x02 \x01(\tR\anewHost\"F\n" + + "\x0eOwnerChangeAck\x12\x1a\n" + + "\baccepted\x18\x01 \x01(\bR\baccepted\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"#\n" + + "\rClosingNotice\x12\x12\n" + + "\x04host\x18\x01 \x01(\tR\x04host2\xbc\x02\n" + + "\fControlPlane\x12,\n" + + "\x04Ping\x12\x0f.messages.Empty\x1a\x13.messages.PingReply\x12A\n" + + "\tNegotiate\x12\x1a.messages.NegotiateRequest\x1a\x18.messages.NegotiateReply\x125\n" + + "\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" + +var ( + file_control_plane_proto_rawDescOnce sync.Once + file_control_plane_proto_rawDescData []byte +) + +func file_control_plane_proto_rawDescGZIP() []byte { + file_control_plane_proto_rawDescOnce.Do(func() { + file_control_plane_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_control_plane_proto_rawDesc), len(file_control_plane_proto_rawDesc))) + }) + return file_control_plane_proto_rawDescData +} + +var file_control_plane_proto_msgTypes = make([]protoimpl.MessageInfo, 8) +var file_control_plane_proto_goTypes = []any{ + (*Empty)(nil), // 0: messages.Empty + (*PingReply)(nil), // 1: messages.PingReply + (*NegotiateRequest)(nil), // 2: messages.NegotiateRequest + (*NegotiateReply)(nil), // 3: messages.NegotiateReply + (*CartIdsReply)(nil), // 4: messages.CartIdsReply + (*OwnerChangeRequest)(nil), // 5: messages.OwnerChangeRequest + (*OwnerChangeAck)(nil), // 6: messages.OwnerChangeAck + (*ClosingNotice)(nil), // 7: messages.ClosingNotice +} +var file_control_plane_proto_depIdxs = []int32{ + 0, // 0: messages.ControlPlane.Ping:input_type -> messages.Empty + 2, // 1: messages.ControlPlane.Negotiate:input_type -> messages.NegotiateRequest + 0, // 2: messages.ControlPlane.GetCartIds:input_type -> messages.Empty + 5, // 3: messages.ControlPlane.ConfirmOwner:input_type -> messages.OwnerChangeRequest + 7, // 4: messages.ControlPlane.Closing:input_type -> messages.ClosingNotice + 1, // 5: messages.ControlPlane.Ping:output_type -> messages.PingReply + 3, // 6: messages.ControlPlane.Negotiate:output_type -> messages.NegotiateReply + 4, // 7: messages.ControlPlane.GetCartIds:output_type -> messages.CartIdsReply + 6, // 8: messages.ControlPlane.ConfirmOwner:output_type -> messages.OwnerChangeAck + 6, // 9: messages.ControlPlane.Closing:output_type -> messages.OwnerChangeAck + 5, // [5:10] is the sub-list for method output_type + 0, // [0:5] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_control_plane_proto_init() } +func file_control_plane_proto_init() { + if File_control_plane_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_control_plane_proto_rawDesc), len(file_control_plane_proto_rawDesc)), + NumEnums: 0, + NumMessages: 8, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_control_plane_proto_goTypes, + DependencyIndexes: file_control_plane_proto_depIdxs, + MessageInfos: file_control_plane_proto_msgTypes, + }.Build() + File_control_plane_proto = out.File + file_control_plane_proto_goTypes = nil + file_control_plane_proto_depIdxs = nil +} diff --git a/proto/control_plane.proto b/proto/control_plane.proto new file mode 100644 index 0000000..d528020 --- /dev/null +++ b/proto/control_plane.proto @@ -0,0 +1,89 @@ +syntax = "proto3"; + +package messages; + +option go_package = ".;messages"; + +// ----------------------------------------------------------------------------- +// Control Plane gRPC API +// ----------------------------------------------------------------------------- +// Replaces the legacy custom frame-based control channel (previously port 1338). +// Responsibilities: +// - Liveness (Ping) +// - Membership negotiation (Negotiate) +// - Cart ownership change propagation (ConfirmOwner) +// - Cart ID listing for remote grain spawning (GetCartIds) +// - Graceful shutdown notifications (Closing) +// No authentication / TLS is defined initially (can be added later). +// ----------------------------------------------------------------------------- + +// Empty request placeholder (common pattern). +message Empty {} + +// Ping reply includes responding host and its current unix time (seconds). +message PingReply { + string host = 1; + int64 unix_time = 2; +} + +// NegotiateRequest carries the caller's full view of known hosts (including self). +message NegotiateRequest { + repeated string known_hosts = 1; +} + +// NegotiateReply returns the callee's healthy hosts (including itself). +message NegotiateReply { + repeated string hosts = 1; +} + +// CartIdsReply returns the list of cart IDs (string form) currently owned locally. +message CartIdsReply { + repeated string cart_ids = 1; +} + +// OwnerChangeRequest notifies peers that ownership of a cart moved (or is moving) to new_host. +message OwnerChangeRequest { + string cart_id = 1; + string new_host = 2; +} + +// OwnerChangeAck indicates acceptance or rejection of an ownership change. +message OwnerChangeAck { + bool accepted = 1; + string message = 2; +} + +// ClosingNotice notifies peers this host is terminating (so they can drop / re-resolve). +message ClosingNotice { + string host = 1; +} + +// ControlPlane defines cluster coordination and ownership operations. +service ControlPlane { + // Ping for liveness; lightweight health signal. + rpc Ping(Empty) returns (PingReply); + + // Negotiate merges host views; used during discovery & convergence. + rpc Negotiate(NegotiateRequest) returns (NegotiateReply); + + // GetCartIds lists currently owned cart IDs on this node. + rpc GetCartIds(Empty) returns (CartIdsReply); + + // ConfirmOwner announces/asks peers to acknowledge ownership transfer. + rpc ConfirmOwner(OwnerChangeRequest) returns (OwnerChangeAck); + + // Closing announces graceful shutdown so peers can proactively adjust. + rpc Closing(ClosingNotice) returns (OwnerChangeAck); +} + +// ----------------------------------------------------------------------------- +// Generation Instructions: +// protoc --go_out=. --go_opt=paths=source_relative \ +// --go-grpc_out=. --go-grpc_opt=paths=source_relative \ +// control_plane.proto +// +// Future Enhancements: +// - Add a streaming membership watch (server -> client) for immediate updates. +// - Add TLS / mTLS for secure intra-cluster communication. +// - Add richer health metadata (load, grain count) in PingReply. +// ----------------------------------------------------------------------------- diff --git a/proto/control_plane_grpc.pb.go b/proto/control_plane_grpc.pb.go new file mode 100644 index 0000000..ddeaf2f --- /dev/null +++ b/proto/control_plane_grpc.pb.go @@ -0,0 +1,287 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v3.21.12 +// source: control_plane.proto + +package messages + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + ControlPlane_Ping_FullMethodName = "/messages.ControlPlane/Ping" + ControlPlane_Negotiate_FullMethodName = "/messages.ControlPlane/Negotiate" + ControlPlane_GetCartIds_FullMethodName = "/messages.ControlPlane/GetCartIds" + ControlPlane_ConfirmOwner_FullMethodName = "/messages.ControlPlane/ConfirmOwner" + ControlPlane_Closing_FullMethodName = "/messages.ControlPlane/Closing" +) + +// ControlPlaneClient is the client API for ControlPlane service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// ControlPlane defines cluster coordination and ownership operations. +type ControlPlaneClient interface { + // Ping for liveness; lightweight health signal. + Ping(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*PingReply, error) + // Negotiate merges host views; used during discovery & convergence. + Negotiate(ctx context.Context, in *NegotiateRequest, opts ...grpc.CallOption) (*NegotiateReply, error) + // GetCartIds lists currently owned cart IDs on this node. + GetCartIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*CartIdsReply, error) + // ConfirmOwner announces/asks peers to acknowledge ownership transfer. + ConfirmOwner(ctx context.Context, in *OwnerChangeRequest, opts ...grpc.CallOption) (*OwnerChangeAck, error) + // Closing announces graceful shutdown so peers can proactively adjust. + Closing(ctx context.Context, in *ClosingNotice, opts ...grpc.CallOption) (*OwnerChangeAck, error) +} + +type controlPlaneClient struct { + cc grpc.ClientConnInterface +} + +func NewControlPlaneClient(cc grpc.ClientConnInterface) ControlPlaneClient { + return &controlPlaneClient{cc} +} + +func (c *controlPlaneClient) Ping(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*PingReply, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(PingReply) + err := c.cc.Invoke(ctx, ControlPlane_Ping_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *controlPlaneClient) Negotiate(ctx context.Context, in *NegotiateRequest, opts ...grpc.CallOption) (*NegotiateReply, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(NegotiateReply) + err := c.cc.Invoke(ctx, ControlPlane_Negotiate_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *controlPlaneClient) GetCartIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*CartIdsReply, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CartIdsReply) + err := c.cc.Invoke(ctx, ControlPlane_GetCartIds_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *controlPlaneClient) ConfirmOwner(ctx context.Context, in *OwnerChangeRequest, opts ...grpc.CallOption) (*OwnerChangeAck, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(OwnerChangeAck) + err := c.cc.Invoke(ctx, ControlPlane_ConfirmOwner_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *controlPlaneClient) Closing(ctx context.Context, in *ClosingNotice, opts ...grpc.CallOption) (*OwnerChangeAck, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(OwnerChangeAck) + err := c.cc.Invoke(ctx, ControlPlane_Closing_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// ControlPlaneServer is the server API for ControlPlane service. +// All implementations must embed UnimplementedControlPlaneServer +// for forward compatibility. +// +// ControlPlane defines cluster coordination and ownership operations. +type ControlPlaneServer interface { + // Ping for liveness; lightweight health signal. + Ping(context.Context, *Empty) (*PingReply, error) + // Negotiate merges host views; used during discovery & convergence. + Negotiate(context.Context, *NegotiateRequest) (*NegotiateReply, error) + // GetCartIds lists currently owned cart IDs on this node. + GetCartIds(context.Context, *Empty) (*CartIdsReply, error) + // ConfirmOwner announces/asks peers to acknowledge ownership transfer. + ConfirmOwner(context.Context, *OwnerChangeRequest) (*OwnerChangeAck, error) + // Closing announces graceful shutdown so peers can proactively adjust. + Closing(context.Context, *ClosingNotice) (*OwnerChangeAck, error) + mustEmbedUnimplementedControlPlaneServer() +} + +// UnimplementedControlPlaneServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedControlPlaneServer struct{} + +func (UnimplementedControlPlaneServer) Ping(context.Context, *Empty) (*PingReply, error) { + return nil, status.Errorf(codes.Unimplemented, "method Ping not implemented") +} +func (UnimplementedControlPlaneServer) Negotiate(context.Context, *NegotiateRequest) (*NegotiateReply, error) { + return nil, status.Errorf(codes.Unimplemented, "method Negotiate not implemented") +} +func (UnimplementedControlPlaneServer) GetCartIds(context.Context, *Empty) (*CartIdsReply, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetCartIds not implemented") +} +func (UnimplementedControlPlaneServer) ConfirmOwner(context.Context, *OwnerChangeRequest) (*OwnerChangeAck, error) { + return nil, status.Errorf(codes.Unimplemented, "method ConfirmOwner not implemented") +} +func (UnimplementedControlPlaneServer) Closing(context.Context, *ClosingNotice) (*OwnerChangeAck, error) { + return nil, status.Errorf(codes.Unimplemented, "method Closing not implemented") +} +func (UnimplementedControlPlaneServer) mustEmbedUnimplementedControlPlaneServer() {} +func (UnimplementedControlPlaneServer) testEmbeddedByValue() {} + +// UnsafeControlPlaneServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ControlPlaneServer will +// result in compilation errors. +type UnsafeControlPlaneServer interface { + mustEmbedUnimplementedControlPlaneServer() +} + +func RegisterControlPlaneServer(s grpc.ServiceRegistrar, srv ControlPlaneServer) { + // If the following call pancis, it indicates UnimplementedControlPlaneServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&ControlPlane_ServiceDesc, srv) +} + +func _ControlPlane_Ping_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ControlPlaneServer).Ping(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ControlPlane_Ping_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ControlPlaneServer).Ping(ctx, req.(*Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _ControlPlane_Negotiate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(NegotiateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ControlPlaneServer).Negotiate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ControlPlane_Negotiate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ControlPlaneServer).Negotiate(ctx, req.(*NegotiateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ControlPlane_GetCartIds_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ControlPlaneServer).GetCartIds(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ControlPlane_GetCartIds_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ControlPlaneServer).GetCartIds(ctx, req.(*Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _ControlPlane_ConfirmOwner_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(OwnerChangeRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ControlPlaneServer).ConfirmOwner(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ControlPlane_ConfirmOwner_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ControlPlaneServer).ConfirmOwner(ctx, req.(*OwnerChangeRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ControlPlane_Closing_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ClosingNotice) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ControlPlaneServer).Closing(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ControlPlane_Closing_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ControlPlaneServer).Closing(ctx, req.(*ClosingNotice)) + } + return interceptor(ctx, in, info, handler) +} + +// ControlPlane_ServiceDesc is the grpc.ServiceDesc for ControlPlane service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var ControlPlane_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "messages.ControlPlane", + HandlerType: (*ControlPlaneServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Ping", + Handler: _ControlPlane_Ping_Handler, + }, + { + MethodName: "Negotiate", + Handler: _ControlPlane_Negotiate_Handler, + }, + { + MethodName: "GetCartIds", + Handler: _ControlPlane_GetCartIds_Handler, + }, + { + MethodName: "ConfirmOwner", + Handler: _ControlPlane_ConfirmOwner_Handler, + }, + { + MethodName: "Closing", + Handler: _ControlPlane_Closing_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "control_plane.proto", +} diff --git a/remote-grain.go b/remote-grain.go deleted file mode 100644 index e6cfffb..0000000 --- a/remote-grain.go +++ /dev/null @@ -1,72 +0,0 @@ -package main - -import ( - "fmt" - "strings" - - "github.com/yudhasubki/netpool" -) - -func (id CartId) String() string { - return strings.Trim(string(id[:]), "\x00") -} - -type CartIdPayload struct { - Id CartId - Data []byte -} - -func MakeCartInnerFrame(id CartId, payload []byte) []byte { - if payload == nil { - return id[:] - } - return append(id[:], payload...) -} - -func GetCartFrame(data []byte) (*CartIdPayload, error) { - if len(data) < 16 { - return nil, fmt.Errorf("data too short") - } - return &CartIdPayload{ - Id: CartId(data[:16]), - Data: data[16:], - }, nil -} - -func ToCartId(id string) CartId { - var result [16]byte - copy(result[:], []byte(id)) - return result -} - -type RemoteGrain struct { - *Connection - Id CartId - Host string -} - -func NewRemoteGrain(id CartId, host string, pool netpool.Netpooler) *RemoteGrain { - addr := fmt.Sprintf("%s:1337", host) - return &RemoteGrain{ - Id: id, - Host: host, - Connection: NewConnection(addr, pool), - } -} - -func (g *RemoteGrain) HandleMessage(message *Message, isReplay bool) (*FrameWithPayload, error) { - - data, err := GetData(message.Write) - if err != nil { - return nil, err - } - return g.Call(RemoteHandleMutation, MakeCartInnerFrame(g.Id, data)) -} - -func (g *RemoteGrain) GetId() CartId { - return g.Id -} - -func (g *RemoteGrain) GetCurrentState() (*FrameWithPayload, error) { - return g.Call(RemoteGetState, MakeCartInnerFrame(g.Id, nil)) -} diff --git a/remote-grain_test.go b/remote-grain_test.go deleted file mode 100644 index 7560a79..0000000 --- a/remote-grain_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package main - -import "testing" - -func TestCartIdsNullData(t *testing.T) { - data := MakeCartInnerFrame(ToCartId("kalle"), nil) - cart, err := GetCartFrame(data) - if err != nil { - t.Errorf("Error getting cart: %v", err) - } - if cart.Id.String() != "kalle" { - t.Errorf("Expected kalle, got %s", cart.Id) - } - if len(cart.Data) != 0 { - t.Errorf("Expected no data, got %v", cart.Data) - } -} diff --git a/remote-host.go b/remote-host.go deleted file mode 100644 index 5ca06c8..0000000 --- a/remote-host.go +++ /dev/null @@ -1,93 +0,0 @@ -package main - -import ( - "fmt" - "log" - "strings" - - "github.com/yudhasubki/netpool" -) - -type RemoteHost struct { - *Connection - HostPool netpool.Netpooler - Host string - MissedPings int -} - -func (h *RemoteHost) IsHealthy() bool { - return h.MissedPings < 3 -} - -func (h *RemoteHost) Initialize(p *SyncedPool) { - log.Printf("Initializing remote %s\n", h.Host) - ids, err := h.GetCartMappings() - if err != nil { - log.Printf("Error getting remote mappings: %v\n", err) - return - } - log.Printf("Remote %s has %d grains\n", h.Host, len(ids)) - - local := 0 - remoteNo := 0 - for _, id := range ids { - go p.SpawnRemoteGrain(id, h.Host) - remoteNo++ - } - log.Printf("Removed %d local grains, added %d remote grains\n", local, remoteNo) - - go p.Negotiate() - -} - -func (h *RemoteHost) Ping() error { - result, err := h.Call(Ping, nil) - - if err != nil || result.StatusCode != 200 || result.Type != Pong { - h.MissedPings++ - log.Printf("Error pinging remote %s, missed pings: %d", h.Host, h.MissedPings) - } else { - h.MissedPings = 0 - } - return err -} - -func (h *RemoteHost) Negotiate(knownHosts []string) ([]string, error) { - reply, err := h.Call(RemoteNegotiate, []byte(strings.Join(knownHosts, ";"))) - - if err != nil { - return nil, err - } - if reply.StatusCode != 200 { - return nil, fmt.Errorf("remote returned error on negotiate: %s", string(reply.Payload)) - } - - return strings.Split(string(reply.Payload), ";"), nil -} - -func (g *RemoteHost) GetCartMappings() ([]CartId, error) { - reply, err := g.Call(GetCartIds, []byte{}) - if err != nil { - return nil, err - } - if reply.StatusCode != 200 || reply.Type != CartIdsResponse { - log.Printf("Remote returned error on get cart mappings: %s", string(reply.Payload)) - return nil, fmt.Errorf("remote returned incorrect data") - } - parts := strings.Split(string(reply.Payload), ";") - ids := make([]CartId, 0, len(parts)) - for _, p := range parts { - ids = append(ids, ToCartId(p)) - } - return ids, nil -} - -func (r *RemoteHost) ConfirmChange(id CartId, host string) error { - reply, err := r.Call(RemoteGrainChanged, []byte(fmt.Sprintf("%s;%s", id, host))) - - if err != nil || reply.StatusCode != 200 || reply.Type != AckChange { - return err - } - - return nil -} diff --git a/remote_grain_grpc.go b/remote_grain_grpc.go new file mode 100644 index 0000000..c2237eb --- /dev/null +++ b/remote_grain_grpc.go @@ -0,0 +1,147 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "log" + "time" + + proto "git.tornberg.me/go-cart-actor/proto" // generated package name is 'messages'; aliased as proto for consistency + "google.golang.org/grpc" +) + +// RemoteGrainGRPC is the gRPC-backed implementation of a remote grain. +// It mirrors the previous RemoteGrain (TCP/frame based) while using the +// new CartActor gRPC service. It implements the Grain interface so that +// SyncedPool can remain largely unchanged when swapping transport layers. +type RemoteGrainGRPC struct { + Id CartId + Host string + client proto.CartActorClient + // Optional: keep the underlying conn so higher-level code can close if needed + conn *grpc.ClientConn + + // Per-call timeout settings (tunable) + mutateTimeout time.Duration + stateTimeout time.Duration +} + +// NewRemoteGrainGRPC constructs a remote grain adapter from an existing gRPC client. +func NewRemoteGrainGRPC(id CartId, host string, client proto.CartActorClient) *RemoteGrainGRPC { + return &RemoteGrainGRPC{ + Id: id, + Host: host, + client: client, + mutateTimeout: 800 * time.Millisecond, + stateTimeout: 400 * time.Millisecond, + } +} + +// NewRemoteGrainGRPCWithConn dials the target and creates the gRPC client. +// target should be host:port (where the CartActor service is exposed). +func NewRemoteGrainGRPCWithConn(id CartId, host string, target string, dialOpts ...grpc.DialOption) (*RemoteGrainGRPC, error) { + // NOTE: insecure for initial migration; should be replaced with TLS later. + baseOpts := []grpc.DialOption{grpc.WithInsecure(), grpc.WithBlock()} + baseOpts = append(baseOpts, dialOpts...) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + conn, err := grpc.DialContext(ctx, target, baseOpts...) + if err != nil { + return nil, err + } + + client := proto.NewCartActorClient(conn) + return &RemoteGrainGRPC{ + Id: id, + Host: host, + client: client, + conn: conn, + mutateTimeout: 800 * time.Millisecond, + stateTimeout: 400 * time.Millisecond, + }, nil +} + +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") + } + 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 + } + + // 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{ + CartId: g.Id.String(), + Type: proto.MutationType(message.Type), // numeric mapping preserved + Payload: buf.Bytes(), + ClientTimestamp: *message.TimeStamp, + } + + ctx, cancel := context.WithTimeout(context.Background(), g.mutateTimeout) + defer cancel() + + resp, err := g.client.Mutate(ctx, req) + if err != nil { + return nil, err + } + + frame := MakeFrameWithPayload(RemoteHandleMutationReply, StatusCode(resp.StatusCode), resp.Payload) + return &frame, nil +} + +// GetCurrentState calls CartActor.GetState and returns a FrameWithPayload +// shaped like the legacy RemoteGetStateReply. +func (g *RemoteGrainGRPC) GetCurrentState() (*FrameWithPayload, error) { + ctx, cancel := context.WithTimeout(context.Background(), g.stateTimeout) + defer cancel() + + 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 +} + +// Close closes the underlying gRPC connection if this adapter created it. +func (g *RemoteGrainGRPC) Close() error { + if g.conn != nil { + return g.conn.Close() + } + return nil +} + +// Debug helper to log operations (optional). +func (g *RemoteGrainGRPC) logf(format string, args ...interface{}) { + log.Printf("[remote-grain-grpc host=%s id=%s] %s", g.Host, g.Id.String(), fmt.Sprintf(format, args...)) +} diff --git a/rpc-server.go b/rpc-server.go deleted file mode 100644 index 7492a85..0000000 --- a/rpc-server.go +++ /dev/null @@ -1,69 +0,0 @@ -package main - -import ( - "bytes" - "fmt" -) - -type GrainHandler struct { - *GenericListener - pool *GrainLocalPool -} - -func (h *GrainHandler) GetState(id CartId, reply *Grain) error { - grain, err := h.pool.GetGrain(id) - if err != nil { - return err - } - *reply = grain - return nil -} - -func NewGrainHandler(pool *GrainLocalPool, listen string) (*GrainHandler, error) { - conn := NewConnection(listen, nil) - server, err := conn.Listen() - handler := &GrainHandler{ - GenericListener: server, - pool: pool, - } - server.AddHandler(RemoteHandleMutation, handler.RemoteHandleMessageHandler) - server.AddHandler(RemoteGetState, handler.RemoteGetStateHandler) - return handler, err -} - -func (h *GrainHandler) IsHealthy() bool { - return len(h.pool.grains) < h.pool.PoolSize -} - -func (h *GrainHandler) RemoteHandleMessageHandler(data *FrameWithPayload, resultChan chan<- FrameWithPayload) error { - cartData, err := GetCartFrame(data.Payload) - if err != nil { - return err - } - var msg Message - err = ReadMessage(bytes.NewReader(cartData.Data), &msg) - if err != nil { - fmt.Println("Error reading message:", err) - return err - } - - replyData, err := h.pool.Process(cartData.Id, msg) - if err != nil { - return err - } - resultChan <- *replyData - return err -} - -func (h *GrainHandler) RemoteGetStateHandler(data *FrameWithPayload, resultChan chan<- FrameWithPayload) error { - cartData, err := GetCartFrame(data.Payload) - if err != nil { - return err - } - reply, err := h.pool.Get(cartData.Id) - if err != nil { - return err - } - resultChan <- *reply - return nil -} diff --git a/synced-pool.go b/synced-pool.go index 79ad578..455acc4 100644 --- a/synced-pool.go +++ b/synced-pool.go @@ -1,36 +1,59 @@ package main import ( + "context" "fmt" "log" - "net" - "strings" "sync" "time" + proto "git.tornberg.me/go-cart-actor/proto" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" - "github.com/yudhasubki/netpool" + "google.golang.org/grpc" "k8s.io/apimachinery/pkg/watch" ) -type Quorum interface { - Negotiate(knownHosts []string) ([]string, error) - OwnerChanged(CartId, host string) error -} - -type HealthHandler interface { - IsHealthy() bool -} - +// SyncedPool coordinates cart grain ownership across nodes using gRPC control plane +// and cart actor services. Legacy frame / TCP code has been removed. +// +// Responsibilities: +// - Local grain access (delegates to GrainLocalPool) +// - Remote grain proxy management (RemoteGrainGRPC) +// - Cluster membership (AddRemote via discovery + negotiation) +// - Ownership acquisition (quorum via ConfirmOwner RPC) +// - Health/ping monitoring & remote removal +// +// Thread-safety: public methods that mutate internal maps lock p.mu (RWMutex). type SyncedPool struct { - Server *GenericListener - mu sync.RWMutex + Hostname string + local *GrainLocalPool + + mu sync.RWMutex + + // Remote host state (gRPC only) + remoteHosts map[string]*RemoteHostGRPC // host -> remote host + + // Remote grain proxies (by cart id) + remoteIndex map[CartId]Grain + + // Discovery handler for re-adding hosts after failures discardedHostHandler *DiscardedHostHandler - Hostname string - local *GrainLocalPool - remotes map[string]*RemoteHost - remoteIndex map[CartId]*RemoteGrain + + // Metrics / instrumentation dependencies already declared globally +} + +// RemoteHostGRPC tracks a remote host's clients & health. +type RemoteHostGRPC struct { + Host string + Conn *grpc.ClientConn + CartClient proto.CartActorClient + ControlClient proto.ControlPlaneClient + MissedPings int +} + +func (r *RemoteHostGRPC) IsHealthy() bool { + return r.MissedPings < 3 } var ( @@ -50,241 +73,172 @@ var ( Name: "cart_remote_lookup_total", Help: "The total number of remote lookups", }) - packetQueue = promauto.NewGauge(prometheus.GaugeOpts{ - Name: "cart_packet_queue_size", - Help: "The total number of packets in the queue", - }) - packetsSent = promauto.NewCounter(prometheus.CounterOpts{ - Name: "cart_pool_packets_sent_total", - Help: "The total number of packets sent", - }) - packetsReceived = promauto.NewCounter(prometheus.CounterOpts{ - Name: "cart_pool_packets_received_total", - Help: "The total number of packets received", - }) ) -func (p *SyncedPool) PongHandler(data *FrameWithPayload, resultChan chan<- FrameWithPayload) error { - resultChan <- MakeFrameWithPayload(Pong, 200, []byte{}) - return nil -} - -func (p *SyncedPool) GetCartIdHandler(data *FrameWithPayload, resultChan chan<- FrameWithPayload) error { - ids := make([]string, 0, len(p.local.grains)) - p.mu.RLock() - defer p.mu.RUnlock() - for id := range p.local.grains { - if p.local.grains[id] == nil { - continue - } - s := id.String() - if s == "" { - continue - } - ids = append(ids, s) - } - log.Printf("Returning %d cart ids\n", len(ids)) - resultChan <- MakeFrameWithPayload(CartIdsResponse, 200, []byte(strings.Join(ids, ";"))) - return nil -} - -func (p *SyncedPool) NegotiateHandler(data *FrameWithPayload, resultChan chan<- FrameWithPayload) error { - negotiationCount.Inc() - log.Printf("Handling negotiation\n") - for _, host := range p.ExcludeKnown(strings.Split(string(data.Payload), ";")) { - if host == "" { - continue - } - go p.AddRemote(host) - - } - p.mu.RLock() - defer p.mu.RUnlock() - hosts := make([]string, 0, len(p.remotes)) - for _, r := range p.remotes { - if r.IsHealthy() { - hosts = append(hosts, r.Host) - } - } - resultChan <- MakeFrameWithPayload(RemoteNegotiateResponse, 200, []byte(strings.Join(hosts, ";"))) - return nil -} - -func (p *SyncedPool) GrainOwnerChangeHandler(data *FrameWithPayload, resultChan chan<- FrameWithPayload) error { - grainSyncCount.Inc() - - idAndHostParts := strings.Split(string(data.Payload), ";") - if len(idAndHostParts) != 2 { - log.Printf("Invalid remote grain change message") - resultChan <- MakeFrameWithPayload(AckError, 500, []byte("invalid")) - return nil - } - id := ToCartId(idAndHostParts[0]) - host := idAndHostParts[1] - log.Printf("Handling remote grain owner change to %s for id %s", host, id) - for _, r := range p.remotes { - if r.Host == host && r.IsHealthy() { - go p.SpawnRemoteGrain(id, host) - break - } - } - go p.AddRemote(host) - resultChan <- MakeFrameWithPayload(AckChange, 200, []byte("ok")) - return nil -} - -func (p *SyncedPool) RemoveRemoteGrain(id CartId) { - p.mu.Lock() - defer p.mu.Unlock() - delete(p.remoteIndex, id) -} - -func (p *SyncedPool) SpawnRemoteGrain(id CartId, host string) { - if id.String() == "" { - log.Printf("Invalid grain id, %s", id) - return - } - p.mu.RLock() - localGrain, ok := p.local.grains[id] - p.mu.RUnlock() - - if ok && localGrain != nil { - log.Printf("Grain %s already exists locally, owner is (%s)", id, host) - p.mu.Lock() - delete(p.local.grains, id) - p.mu.Unlock() - } - - go func(i CartId, h string) { - var pool netpool.Netpooler - p.mu.RLock() - for _, r := range p.remotes { - if r.Host == h { - pool = r.HostPool - break - } - } - p.mu.RUnlock() - if pool == nil { - log.Printf("Error spawning remote grain, no pool for %s", h) - return - } - remoteGrain := NewRemoteGrain(i, h, pool) - - p.mu.Lock() - p.remoteIndex[i] = remoteGrain - p.mu.Unlock() - }(id, host) -} - -func (p *SyncedPool) HandleHostError(host string) { - p.mu.RLock() - defer p.mu.RUnlock() - for _, r := range p.remotes { - if r.Host == host { - if !r.IsHealthy() { - go p.RemoveHost(r) - } - return - } - } -} - func NewSyncedPool(local *GrainLocalPool, hostname string, discovery Discovery) (*SyncedPool, error) { - listen := fmt.Sprintf("%s:1338", hostname) - conn := NewConnection(listen, nil) - server, err := conn.Listen() - if err != nil { - return nil, err - } - - log.Printf("Listening on %s", listen) - dh := NewDiscardedHostHandler(1338) - pool := &SyncedPool{ - Server: server, + p := &SyncedPool{ Hostname: hostname, local: local, - discardedHostHandler: dh, - remotes: make(map[string]*RemoteHost), - remoteIndex: make(map[CartId]*RemoteGrain), + remoteHosts: make(map[string]*RemoteHostGRPC), + remoteIndex: make(map[CartId]Grain), + discardedHostHandler: NewDiscardedHostHandler(1338), } - dh.SetReconnectHandler(pool.AddRemote) - server.AddHandler(Ping, pool.PongHandler) - server.AddHandler(GetCartIds, pool.GetCartIdHandler) - server.AddHandler(RemoteNegotiate, pool.NegotiateHandler) - server.AddHandler(RemoteGrainChanged, pool.GrainOwnerChangeHandler) - server.AddHandler(Closing, pool.HostTerminatingHandler) + p.discardedHostHandler.SetReconnectHandler(p.AddRemote) if discovery != nil { go func() { - time.Sleep(time.Second * 5) - log.Printf("Starting discovery") + time.Sleep(3 * time.Second) // allow gRPC server startup + log.Printf("Starting discovery watcher") ch, err := discovery.Watch() if err != nil { - log.Printf("Error discovering hosts: %v", err) + log.Printf("Discovery error: %v", err) return } - for chng := range ch { - if chng.Host == "" { + for evt := range ch { + if evt.Host == "" { continue } - known := pool.IsKnown(chng.Host) - if chng.Type != watch.Deleted && !known { - - log.Printf("Discovered host %s, waiting for startup", chng.Host) - time.Sleep(3 * time.Second) - pool.AddRemote(chng.Host) - - } else if chng.Type == watch.Deleted && known { - log.Printf("Host removed %s, removing from index", chng.Host) - for _, r := range pool.remotes { - if r.Host == chng.Host { - pool.RemoveHost(r) - break - } + switch evt.Type { + case watch.Deleted: + if p.IsKnown(evt.Host) { + p.RemoveHost(evt.Host) + } + default: + if !p.IsKnown(evt.Host) { + log.Printf("Discovered host %s", evt.Host) + p.AddRemote(evt.Host) } } } }() } else { - log.Printf("No discovery, waiting for remotes to connect") + log.Printf("No discovery configured; expecting manual AddRemote or static host injection") } - return pool, nil + return p, nil } -func (p *SyncedPool) HostTerminatingHandler(data *FrameWithPayload, resultChan chan<- FrameWithPayload) error { - log.Printf("Remote host terminating") - host := string(data.Payload) - p.mu.RLock() - defer p.mu.RUnlock() - for _, r := range p.remotes { - if r.Host == host { - go p.RemoveHost(r) +// ------------------------- Remote Host Management ----------------------------- + +// AddRemote dials a remote host and initializes grain proxies. +func (p *SyncedPool) AddRemote(host string) { + if host == "" || host == p.Hostname { + return + } + + p.mu.Lock() + if _, exists := p.remoteHosts[host]; exists { + p.mu.Unlock() + return + } + p.mu.Unlock() + + target := fmt.Sprintf("%s:1337", host) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + conn, err := grpc.DialContext(ctx, target, grpc.WithInsecure(), grpc.WithBlock()) + if err != nil { + log.Printf("AddRemote: dial %s failed: %v", target, err) + return + } + + cartClient := proto.NewCartActorClient(conn) + controlClient := proto.NewControlPlaneClient(conn) + + // Health check (Ping) with limited retries + pings := 3 + for pings > 0 { + ctxPing, cancelPing := context.WithTimeout(context.Background(), 1*time.Second) + _, pingErr := controlClient.Ping(ctxPing, &proto.Empty{}) + cancelPing() + if pingErr == nil { break } - } - resultChan <- MakeFrameWithPayload(Pong, 200, []byte("ok")) - return nil -} - -func (p *SyncedPool) IsHealthy() bool { - for _, r := range p.remotes { - if !r.IsHealthy() { - return false + pings-- + time.Sleep(200 * time.Millisecond) + if pings == 0 { + log.Printf("AddRemote: ping %s failed after retries: %v", host, pingErr) + conn.Close() + return } } - return true + + remote := &RemoteHostGRPC{ + Host: host, + Conn: conn, + CartClient: cartClient, + ControlClient: controlClient, + MissedPings: 0, + } + + p.mu.Lock() + p.remoteHosts[host] = remote + p.mu.Unlock() + connectedRemotes.Set(float64(p.RemoteCount())) + + log.Printf("Connected to remote host %s", host) + + go p.pingLoop(remote) + go p.initializeRemote(remote) + go p.Negotiate() +} + +// initializeRemote fetches remote cart ids and sets up remote grain proxies. +func (p *SyncedPool) initializeRemote(remote *RemoteHostGRPC) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + reply, err := remote.ControlClient.GetCartIds(ctx, &proto.Empty{}) + if err != nil { + log.Printf("Init remote %s: GetCartIds error: %v", remote.Host, err) + return + } + count := 0 + for _, idStr := range reply.CartIds { + if idStr == "" { + continue + } + p.SpawnRemoteGrain(ToCartId(idStr), remote.Host) + count++ + } + log.Printf("Remote %s reported %d grains", remote.Host, count) +} + +// RemoveHost removes remote host and its grains. +func (p *SyncedPool) RemoveHost(host string) { + p.mu.Lock() + remote, exists := p.remoteHosts[host] + if exists { + delete(p.remoteHosts, host) + } + // remove grains pointing to host + for id, g := range p.remoteIndex { + if rg, ok := g.(*RemoteGrainGRPC); ok && rg.Host == host { + delete(p.remoteIndex, id) + } + } + p.mu.Unlock() + + if exists { + remote.Conn.Close() + } + connectedRemotes.Set(float64(p.RemoteCount())) +} + +// RemoteCount returns number of tracked remote hosts. +func (p *SyncedPool) RemoteCount() int { + p.mu.RLock() + defer p.mu.RUnlock() + return len(p.remoteHosts) } func (p *SyncedPool) IsKnown(host string) bool { - for _, r := range p.remotes { - if r.Host == host { - return true - } + if host == p.Hostname { + return true } - - return host == p.Hostname + p.mu.RLock() + defer p.mu.RUnlock() + _, ok := p.remoteHosts[host] + return ok } func (p *SyncedPool) ExcludeKnown(hosts []string) []string { @@ -297,227 +251,190 @@ func (p *SyncedPool) ExcludeKnown(hosts []string) []string { return ret } -func (p *SyncedPool) RemoveHost(host *RemoteHost) { - p.mu.Lock() - delete(p.remotes, host.Host) - p.mu.Unlock() - p.RemoveHostMappedCarts(host) - p.discardedHostHandler.AppendHost(host.Host) - connectedRemotes.Set(float64(len(p.remotes))) -} +// ------------------------- Health / Ping ------------------------------------- -func (p *SyncedPool) RemoveHostMappedCarts(host *RemoteHost) { - p.mu.Lock() - defer p.mu.Unlock() - for id, r := range p.remoteIndex { - if r.Host == host.Host { - delete(p.remoteIndex, id) +func (p *SyncedPool) pingLoop(remote *RemoteHostGRPC) { + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + for range ticker.C { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + _, err := remote.ControlClient.Ping(ctx, &proto.Empty{}) + cancel() + if err != nil { + remote.MissedPings++ + log.Printf("Ping %s failed (%d)", remote.Host, remote.MissedPings) + if !remote.IsHealthy() { + log.Printf("Remote %s unhealthy, removing", remote.Host) + p.RemoveHost(remote.Host) + return + } + continue } + remote.MissedPings = 0 } } -const ( - RemoteNegotiate = FrameType(3) - RemoteGrainChanged = FrameType(4) - AckChange = FrameType(5) - AckError = FrameType(6) - Ping = FrameType(7) - Pong = FrameType(8) - GetCartIds = FrameType(9) - CartIdsResponse = FrameType(10) - RemoteNegotiateResponse = FrameType(11) - Closing = FrameType(12) -) +func (p *SyncedPool) IsHealthy() bool { + p.mu.RLock() + defer p.mu.RUnlock() + for _, r := range p.remoteHosts { + if !r.IsHealthy() { + return false + } + } + return true +} + +// ------------------------- Negotiation --------------------------------------- func (p *SyncedPool) Negotiate() { - knownHosts := make([]string, 0, len(p.remotes)+1) - for _, r := range p.remotes { - knownHosts = append(knownHosts, r.Host) - } - knownHosts = append([]string{p.Hostname}, knownHosts...) + negotiationCount.Inc() - for _, r := range p.remotes { - hosts, err := r.Negotiate(knownHosts) + p.mu.RLock() + hosts := make([]string, 0, len(p.remoteHosts)+1) + hosts = append(hosts, p.Hostname) + for h := range p.remoteHosts { + hosts = append(hosts, h) + } + remotes := make([]*RemoteHostGRPC, 0, len(p.remoteHosts)) + for _, r := range p.remoteHosts { + remotes = append(remotes, r) + } + p.mu.RUnlock() + + for _, r := range remotes { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + reply, err := r.ControlClient.Negotiate(ctx, &proto.NegotiateRequest{KnownHosts: hosts}) + cancel() if err != nil { - log.Printf("Error negotiating with %s: %v\n", r.Host, err) - return + log.Printf("Negotiate with %s failed: %v", r.Host, err) + continue } - for _, h := range hosts { + for _, h := range reply.Hosts { if !p.IsKnown(h) { p.AddRemote(h) } } } - } -func (p *SyncedPool) GetHealthyRemotes() []*RemoteHost { +// ------------------------- Grain Management ---------------------------------- + +// RemoveRemoteGrain removes a remote grain mapping. +func (p *SyncedPool) RemoveRemoteGrain(id CartId) { + p.mu.Lock() + delete(p.remoteIndex, id) + p.mu.Unlock() +} + +// SpawnRemoteGrain creates/updates a remote grain proxy for a given host. +func (p *SyncedPool) SpawnRemoteGrain(id CartId, host string) { + if id.String() == "" { + return + } + p.mu.Lock() + // If local grain exists, remove it (ownership changed) + if g, ok := p.local.grains[id]; ok && g != nil { + delete(p.local.grains, id) + } + remoteHost, ok := p.remoteHosts[host] + if !ok { + p.mu.Unlock() + log.Printf("SpawnRemoteGrain: host %s unknown (id=%s), attempting AddRemote", host, id) + go p.AddRemote(host) + return + } + rg := NewRemoteGrainGRPC(id, host, remoteHost.CartClient) + p.remoteIndex[id] = rg + p.mu.Unlock() +} + +// GetHealthyRemotes returns a copy slice of healthy remote hosts. +func (p *SyncedPool) GetHealthyRemotes() []*RemoteHostGRPC { p.mu.RLock() defer p.mu.RUnlock() - remotes := make([]*RemoteHost, 0, len(p.remotes)) - for _, r := range p.remotes { + ret := make([]*RemoteHostGRPC, 0, len(p.remoteHosts)) + for _, r := range p.remoteHosts { if r.IsHealthy() { - remotes = append(remotes, r) + ret = append(ret, r) } } - return remotes + return ret } +// RequestOwnership attempts to become owner of a cart, requiring quorum. +// On success local grain is (or will be) created; peers spawn remote proxies. func (p *SyncedPool) RequestOwnership(id CartId) error { ok := 0 all := 0 - - for _, r := range p.GetHealthyRemotes() { - - err := r.ConfirmChange(id, p.Hostname) + remotes := p.GetHealthyRemotes() + for _, r := range remotes { + ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond) + reply, err := r.ControlClient.ConfirmOwner(ctx, &proto.OwnerChangeRequest{ + CartId: id.String(), + NewHost: p.Hostname, + }) + cancel() all++ - if err != nil { - if !r.IsHealthy() { - log.Printf("Ownership: Removing host, unable to communicate with %s", r.Host) - p.RemoveHost(r) - all-- - } else { - log.Printf("Error confirming change: %v from %s\n", err, p.Hostname) - } + if err != nil || reply == nil || !reply.Accepted { + log.Printf("ConfirmOwner failure from %s for %s: %v (reply=%v)", r.Host, id, err, reply) continue } - //log.Printf("Remote confirmed change %s\n", r.Host) ok++ } + // Quorum rule mirrors legacy: + // - If fewer than 3 total, require all. + // - Else require majority (ok >= all/2). if (all < 3 && ok < all) || ok < (all/2) { p.removeLocalGrain(id) - return fmt.Errorf("quorum not reached") + return fmt.Errorf("quorum not reached (ok=%d all=%d)", ok, all) } + grainSyncCount.Inc() return nil } func (p *SyncedPool) removeLocalGrain(id CartId) { p.mu.Lock() - defer p.mu.Unlock() delete(p.local.grains, id) + p.mu.Unlock() } -func (p *SyncedPool) AddRemote(host string) { - p.mu.Lock() - defer p.mu.Unlock() - _, hasHost := p.remotes[host] - if host == "" || hasHost || host == p.Hostname { - return - } - - host_pool, err := netpool.New(func() (net.Conn, error) { - return net.Dial("tcp", fmt.Sprintf("%s:1338", host)) - }, netpool.WithMaxPool(128), netpool.WithMinPool(0)) - - if err != nil { - log.Printf("Error creating host pool: %v\n", err) - return - } - - client := NewConnection(fmt.Sprintf("%s:1338", host), host_pool) - - pings := 3 - for pings >= 0 { - _, err = client.Call(Ping, nil) - if err != nil { - log.Printf("Ping failed when adding %s, trying %d more times\n", host, pings) - pings-- - time.Sleep(time.Millisecond * 300) - continue - } - break - } - log.Printf("Connected to remote %s", host) - - cart_pool, err := netpool.New(func() (net.Conn, error) { - return net.Dial("tcp", fmt.Sprintf("%s:1337", host)) - }, netpool.WithMaxPool(128), netpool.WithMinPool(0)) - - if err != nil { - log.Printf("Error creating grain pool: %v\n", err) - return - } - - remote := RemoteHost{ - HostPool: cart_pool, - Connection: client, - MissedPings: 0, - Host: host, - } - - p.remotes[host] = &remote - - connectedRemotes.Set(float64(len(p.remotes))) - - go p.HandlePing(&remote) - go remote.Initialize(p) -} - -func (p *SyncedPool) HandlePing(remote *RemoteHost) { - for range time.Tick(time.Second * 3) { - - err := remote.Ping() - - for err != nil { - time.Sleep(time.Millisecond * 200) - if !remote.IsHealthy() { - log.Printf("Removing host, unable to communicate with %s", remote.Host) - p.RemoveHost(remote) - return - } - err = remote.Ping() - } - } -} - +// getGrain returns a local or remote grain; if absent, attempts ownership. func (p *SyncedPool) getGrain(id CartId) (Grain, error) { - var err error p.mu.RLock() - defer p.mu.RUnlock() - localGrain, ok := p.local.grains[id] - if !ok { - // check if remote grain exists - - remoteGrain, ok := p.remoteIndex[id] - - if ok { - remoteLookupCount.Inc() - return remoteGrain, nil - } - - go p.RequestOwnership(id) - // if err != nil { - // log.Printf("Error requesting ownership: %v\n", err) - // return nil, err - // } - - localGrain, err = p.local.GetGrain(id) - if err != nil { - return nil, err - } + localGrain, isLocal := p.local.grains[id] + remoteGrain, isRemote := p.remoteIndex[id] + p.mu.RUnlock() + if isLocal && localGrain != nil { + return localGrain, nil } - return localGrain, nil -} - -func (p *SyncedPool) Close() { - p.mu.Lock() - defer p.mu.Unlock() - payload := []byte(p.Hostname) - for _, r := range p.remotes { - go r.Call(Closing, payload) + if isRemote { + remoteLookupCount.Inc() + return remoteGrain, nil } -} -func (p *SyncedPool) Process(id CartId, messages ...Message) (*FrameWithPayload, error) { - pool, err := p.getGrain(id) - var res *FrameWithPayload + // Attempt to claim ownership (async semantics preserved) + go p.RequestOwnership(id) + + // Create local grain (lazy spawn) - may be rolled back by quorum failure + grain, err := p.local.GetGrain(id) if err != nil { return nil, err } + return grain, nil +} + +// Process applies mutation(s) to a grain (local or remote). +func (p *SyncedPool) Process(id CartId, messages ...Message) (*FrameWithPayload, error) { + grain, err := p.getGrain(id) + if err != nil { + return nil, err + } + var res *FrameWithPayload for _, m := range messages { - res, err = pool.HandleMessage(&m, false) + res, err = grain.HandleMessage(&m, false) if err != nil { return nil, err } @@ -525,11 +442,32 @@ func (p *SyncedPool) Process(id CartId, messages ...Message) (*FrameWithPayload, return res, nil } +// Get returns current state of a grain (local or remote). func (p *SyncedPool) Get(id CartId) (*FrameWithPayload, error) { grain, err := p.getGrain(id) if err != nil { return nil, err } - return grain.GetCurrentState() } + +// Close notifies remotes this host is terminating. +func (p *SyncedPool) Close() { + p.mu.RLock() + remotes := make([]*RemoteHostGRPC, 0, len(p.remoteHosts)) + for _, r := range p.remoteHosts { + remotes = append(remotes, r) + } + p.mu.RUnlock() + + for _, r := range remotes { + go func(rh *RemoteHostGRPC) { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + _, err := rh.ControlClient.Closing(ctx, &proto.ClosingNotice{Host: p.Hostname}) + cancel() + if err != nil { + log.Printf("Close notify to %s failed: %v", rh.Host, err) + } + }(r) + } +} diff --git a/synced-pool_test.go b/synced-pool_test.go deleted file mode 100644 index 8845055..0000000 --- a/synced-pool_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package main - -import ( - "log" - "testing" - "time" - - messages "git.tornberg.me/go-cart-actor/proto" -) - -func TestConnection(t *testing.T) { - // TestConnection tests the connection to the server - - localPool := NewGrainLocalPool(100, time.Minute, func(id CartId) (*CartGrain, error) { - return &CartGrain{ - Id: id, - storageMessages: []Message{}, - Items: []*CartItem{}, - Deliveries: make([]*CartDelivery, 0), - TotalPrice: 0, - }, nil - }) - hg, err := NewGrainHandler(localPool, ":1337") - - if err != nil { - t.Errorf("Error creating handler: %v\n", err) - } - if hg == nil { - t.Errorf("Expected handler, got nil") - } - pool, err := NewSyncedPool(localPool, "127.0.0.1", nil) - if err != nil { - t.Errorf("Error creating pool: %v", err) - } - time.Sleep(400 * time.Millisecond) - pool.AddRemote("127.0.0.1") - - go pool.Negotiate() - msg := Message{ - Type: AddItemType, - //TimeStamp: time.Now().Unix(), - Content: &messages.AddItem{ - Quantity: 1, - Price: 100, - Sku: "123", - Name: "Test", - Image: "Test", - }, - } - data, err := pool.Process(ToCartId("kalle"), msg) - if err != nil { - t.Errorf("Error getting data: %v", err) - } - if data.StatusCode != 200 { - t.Errorf("Expected 200") - } - log.Println(data) - time.Sleep(2 * time.Millisecond) - data, err = pool.Get(ToCartId("kalle")) - if err != nil { - t.Errorf("Error getting data: %v", err) - } - if data == nil { - t.Errorf("Expected data, got nil") - } -} diff --git a/tcp-connection.go b/tcp-connection.go deleted file mode 100644 index f8e0b31..0000000 --- a/tcp-connection.go +++ /dev/null @@ -1,232 +0,0 @@ -package main - -import ( - "bufio" - "encoding/binary" - "fmt" - "io" - "log" - "net" - "time" - - "github.com/yudhasubki/netpool" -) - -type Connection struct { - address string - pool netpool.Netpooler - count uint64 -} - -type FrameType uint32 -type StatusCode uint32 -type CheckSum uint32 - -type Frame struct { - Type FrameType - StatusCode StatusCode - Length uint32 - Checksum CheckSum -} - -func (f *Frame) IsValid() bool { - return f.Checksum == MakeChecksum(f.Type, f.StatusCode, f.Length) -} - -func MakeChecksum(msg FrameType, statusCode StatusCode, length uint32) CheckSum { - sum := CheckSum((uint32(msg) + uint32(statusCode) + length) / 8) - return sum -} - -type FrameWithPayload struct { - Frame - Payload []byte -} - -func MakeFrameWithPayload(msg FrameType, statusCode StatusCode, payload []byte) FrameWithPayload { - len := uint32(len(payload)) - return FrameWithPayload{ - Frame: Frame{ - Type: msg, - StatusCode: statusCode, - Length: len, - Checksum: MakeChecksum(msg, statusCode, len), - }, - Payload: payload, - } -} - -type FrameData interface { - ToBytes() []byte - FromBytes([]byte) error -} - -func NewConnection(address string, pool netpool.Netpooler) *Connection { - return &Connection{ - count: 0, - pool: pool, - address: address, - } -} - -func SendFrame(conn net.Conn, data *FrameWithPayload) error { - - err := binary.Write(conn, binary.LittleEndian, data.Frame) - if err != nil { - return err - } - _, err = conn.Write(data.Payload) - - return err -} - -func (c *Connection) CallAsync(msg FrameType, payload []byte, ch chan<- FrameWithPayload) (net.Conn, error) { - conn, err := c.pool.Get() - //conn, err := net.Dial("tcp", c.address) - if err != nil { - return conn, err - } - go WaitForFrame(conn, ch) - - go func(toSend FrameWithPayload) { - err = SendFrame(conn, &toSend) - if err != nil { - log.Printf("Error sending frame: %v\n", err) - //close(ch) - //conn.Close() - } - }(MakeFrameWithPayload(msg, 1, payload)) - - c.count++ - return conn, err -} - -func (c *Connection) Call(msg FrameType, data []byte) (*FrameWithPayload, error) { - ch := make(chan FrameWithPayload, 1) - conn, err := c.CallAsync(msg, data, ch) - defer c.pool.Put(conn, err) - if err != nil { - return nil, err - } - - defer close(ch) - - ret := <-ch - return &ret, nil - // select { - // case ret := <-ch: - // return &ret, nil - // case <-time.After(MaxCallDuration): - // return nil, fmt.Errorf("timeout waiting for frame") - // } -} - -func WaitForFrame(conn net.Conn, resultChan chan<- FrameWithPayload) error { - var err error - var frame Frame - - err = binary.Read(conn, binary.LittleEndian, &frame) - if err != nil { - return err - } - if frame.IsValid() { - payload := make([]byte, frame.Length) - _, err = conn.Read(payload) - if err != nil { - conn.Close() - return err - } - resultChan <- FrameWithPayload{ - Frame: frame, - Payload: payload, - } - return err - } - log.Println("Checksum mismatch") - return fmt.Errorf("checksum mismatch") -} - -type GenericListener struct { - StopListener bool - handlers map[FrameType]func(*FrameWithPayload, chan<- FrameWithPayload) error -} - -func (c *Connection) Listen() (*GenericListener, error) { - l, err := net.Listen("tcp", c.address) - if err != nil { - return nil, err - } - ret := &GenericListener{ - handlers: make(map[FrameType]func(*FrameWithPayload, chan<- FrameWithPayload) error), - } - go func() { - for !ret.StopListener { - connection, err := l.Accept() - if err != nil { - log.Printf("Error accepting connection: %v\n", err) - continue - } - go ret.HandleConnection(connection) - } - }() - return ret, nil -} - -const ( - MaxCallDuration = 300 * time.Millisecond - ListenerKeepalive = 5 * time.Second -) - -func (l *GenericListener) HandleConnection(conn net.Conn) { - var err error - var frame Frame - log.Printf("Server Connection accepted: %s\n", conn.RemoteAddr().String()) - b := bufio.NewReader(conn) - for err != io.EOF { - - err = binary.Read(b, binary.LittleEndian, &frame) - - if err == nil && frame.IsValid() { - payload := make([]byte, frame.Length) - _, err = b.Read(payload) - if err == nil { - err = l.HandleFrame(conn, &FrameWithPayload{ - Frame: frame, - Payload: payload, - }) - } - } - } - conn.Close() - log.Printf("Server Connection closed") -} - -func (l *GenericListener) AddHandler(msg FrameType, handler func(*FrameWithPayload, chan<- FrameWithPayload) error) { - l.handlers[msg] = handler -} - -func (l *GenericListener) HandleFrame(conn net.Conn, frame *FrameWithPayload) error { - handler, ok := l.handlers[frame.Type] - if ok { - go func() { - resultChan := make(chan FrameWithPayload, 1) - defer close(resultChan) - err := handler(frame, resultChan) - if err != nil { - errFrame := MakeFrameWithPayload(frame.Type, 500, []byte(err.Error())) - SendFrame(conn, &errFrame) - log.Printf("Handler returned error: %s", err) - return - } - result := <-resultChan - err = SendFrame(conn, &result) - if err != nil { - log.Printf("Error sending frame: %s", err) - } - }() - } else { - conn.Close() - return fmt.Errorf("no handler for frame type %d", frame.Type) - } - return nil -} diff --git a/tcp-connection_test.go b/tcp-connection_test.go index b326271..cbe37dd 100644 --- a/tcp-connection_test.go +++ b/tcp-connection_test.go @@ -1,74 +1,8 @@ +/* +Legacy TCP networking (GenericListener / Frame protocol) has been removed +as part of the gRPC migration. This file intentionally contains no tests. + +Keeping an empty Go file (with a package declaration) ensures the old +tcp-connection test target no longer runs without causing build issues. +*/ package main - -import ( - "fmt" - "net" - "testing" - - "github.com/yudhasubki/netpool" -) - -func TestGenericConnection(t *testing.T) { - - listenConn := NewConnection("127.0.0.1:51337", nil) - listener, err := listenConn.Listen() - if err != nil { - t.Errorf("Error listening: %v\n", err) - } - pool, err := netpool.New(func() (net.Conn, error) { - return net.Dial("tcp", "127.0.0.1:51337") - }, netpool.WithMaxPool(128), netpool.WithMinPool(16)) - if err != nil { - t.Errorf("Error creating pool: %v\n", err) - } - conn := NewConnection("127.0.0.1:51337", pool) - - datta := []byte("Hello, world!") - listener.AddHandler(Ping, func(input *FrameWithPayload, resultChan chan<- FrameWithPayload) error { - resultChan <- MakeFrameWithPayload(Pong, 200, nil) - return nil - }) - listener.AddHandler(1, func(input *FrameWithPayload, resultChan chan<- FrameWithPayload) error { - resultChan <- MakeFrameWithPayload(2, 200, datta) - return nil - }) - listener.AddHandler(3, func(input *FrameWithPayload, resultChan chan<- FrameWithPayload) error { - return fmt.Errorf("Error in custom handler") - }) - r, err := conn.Call(1, datta) - if err != nil { - t.Errorf("Error calling: %v\n", err) - } - if r.Type != 2 { - t.Errorf("Expected type 2, got %d\n", r.Type) - } - - res, err := conn.Call(3, datta) - if err != nil { - t.Errorf("Did not expect error, got %v\n", err) - return - } - if res.StatusCode == 200 { - t.Errorf("Expected error, got %v\n", res) - } - - i := 100 - results := make(chan FrameWithPayload, i) - for i > 0 { - go conn.CallAsync(1, datta, results) - i-- - } - for i < 100 { - r := <-results - if r.Type != 2 { - t.Errorf("Expected type 2, got %d\n", r.Type) - } - i++ - } - - response, err := conn.Call(Ping, nil) - if err != nil || response.StatusCode != 200 || response.Type != Pong { - t.Errorf("Error connecting to remote %v, err: %v\n", response, err) - } - -} -- 2.49.1 From 2697832d98b38a32f93c6e9ef52390d475d78c22 Mon Sep 17 00:00:00 2001 From: matst80 Date: Fri, 10 Oct 2025 07:21:50 +0000 Subject: [PATCH 2/3] upgrade deps --- README.md | 95 ++++++++++++++++++ discovery_test.go | 4 +- go.mod | 90 +++++++++-------- go.sum | 212 +++++++++++++++++++++++---------------- grpc_integration_test.go | 28 ++++-- main.go | 1 + product-fetcher.go | 5 +- 7 files changed, 294 insertions(+), 141 deletions(-) diff --git a/README.md b/README.md index 6847ecd..6db9855 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,101 @@ go build . 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 +```bash +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) +```bash +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) +```bash +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) +```bash +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 +```bash +curl -i --cookie cookies.txt -X DELETE http://localhost:8080/cart/1 +``` + +### 6. Set entire cart contents (overwrites items) +```bash +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. +```bash +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 +```bash +curl -i --cookie cookies.txt -X DELETE http://localhost:8080/cart/delivery/1 +``` + +### 9. Set a pickup point for a delivery +```bash +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) +```bash +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): +```bash +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) +```bash +curl -i --cookie cookies.txt -X DELETE http://localhost:8080/cart/ +``` + +Tip: Use `--cookie-jar` and `--cookie` to persist the session across multiple commands: +```bash +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 `.proto` files (messages/cart_actor/control_plane) diff --git a/discovery_test.go b/discovery_test.go index a3b4c40..fa92ff3 100644 --- a/discovery_test.go +++ b/discovery_test.go @@ -9,7 +9,7 @@ import ( ) func TestDiscovery(t *testing.T) { - config, err := clientcmd.BuildConfigFromFlags("", "/Users/mats/.kube/config") + config, err := clientcmd.BuildConfigFromFlags("", "/home/mats/.kube/config") if err != nil { t.Errorf("Error building config: %v", err) } @@ -28,7 +28,7 @@ func TestDiscovery(t *testing.T) { } func TestWatch(t *testing.T) { - config, err := clientcmd.BuildConfigFromFlags("", "/Users/mats/.kube/config") + config, err := clientcmd.BuildConfigFromFlags("", "/home/mats/.kube/config") if err != nil { t.Errorf("Error building config: %v", err) } diff --git a/go.mod b/go.mod index c96f6e2..0148cea 100644 --- a/go.mod +++ b/go.mod @@ -1,65 +1,73 @@ module git.tornberg.me/go-cart-actor -go 1.24.2 +go 1.25.1 require ( github.com/google/uuid v1.6.0 - github.com/matst80/slask-finder v0.0.0-20250418094723-2eb7d6615761 - github.com/prometheus/client_golang v1.22.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 - github.com/yudhasubki/netpool v0.0.0-20230717065341-3c1353ca328e - google.golang.org/grpc v1.65.0 - google.golang.org/protobuf v1.36.6 - k8s.io/api v0.32.3 - k8s.io/apimachinery v0.32.3 - k8s.io/client-go v0.32.3 + google.golang.org/grpc v1.76.0 + google.golang.org/protobuf v1.36.10 + k8s.io/api v0.34.1 + k8s.io/apimachinery v0.34.1 + k8s.io/client-go v0.34.1 ) require ( + github.com/RoaringBitmap/roaring/v2 v2.10.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/bits-and-blooms/bitset v1.24.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/emicklei/go-restful/v3 v3.12.2 // indirect - github.com/fxamacker/cbor/v2 v2.8.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect - github.com/go-openapi/jsonpointer v0.21.1 // indirect - github.com/go-openapi/jsonreference v0.21.0 // indirect - github.com/go-openapi/swag v0.23.1 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.22.1 // indirect + github.com/go-openapi/jsonreference v0.21.2 // indirect + github.com/go-openapi/swag v0.25.1 // indirect + github.com/go-openapi/swag/cmdutils v0.25.1 // indirect + github.com/go-openapi/swag/conv v0.25.1 // indirect + github.com/go-openapi/swag/fileutils v0.25.1 // indirect + github.com/go-openapi/swag/jsonname v0.25.1 // indirect + github.com/go-openapi/swag/jsonutils v0.25.1 // indirect + github.com/go-openapi/swag/loading v0.25.1 // indirect + github.com/go-openapi/swag/mangling v0.25.1 // indirect + github.com/go-openapi/swag/netutils v0.25.1 // indirect + github.com/go-openapi/swag/stringutils v0.25.1 // indirect + github.com/go-openapi/swag/typeutils v0.25.1 // indirect + github.com/go-openapi/swag/yamlutils v0.25.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/protobuf v1.5.4 // indirect - github.com/google/gnostic-models v0.6.9 // indirect + github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/gofuzz v1.2.0 // indirect - github.com/josharian/intern v1.0.0 // indirect + github.com/gorilla/schema v1.4.1 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/mailru/easyjson v0.9.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/mschoch/smat v0.2.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.63.0 // indirect - github.com/prometheus/procfs v0.16.0 // indirect - github.com/redis/go-redis/v9 v9.7.0 // indirect + github.com/prometheus/common v0.67.1 // indirect + github.com/prometheus/procfs v0.17.0 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/x448/float16 v0.8.4 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/oauth2 v0.29.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/term v0.31.0 // indirect - golang.org/x/text v0.24.0 // indirect - golang.org/x/time v0.11.0 // indirect - golang.org/x/tools v0.32.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 // indirect - gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/term v0.36.0 // indirect + golang.org/x/text v0.30.0 // indirect + golang.org/x/time v0.14.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect - k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e // indirect - sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect - sigs.k8s.io/yaml v1.4.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 60e7df5..7c2ab17 100644 --- a/go.sum +++ b/go.sum @@ -1,49 +1,71 @@ +github.com/RoaringBitmap/roaring/v2 v2.10.0 h1:HbJ8Cs71lfCJyvmSptxeMX2PtvOC8yonlU0GQcy2Ak0= +github.com/RoaringBitmap/roaring/v2 v2.10.0/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= -github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= -github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= -github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM= +github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= -github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU= -github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= -github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= -github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= -github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= -github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= -github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= +github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= +github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= +github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= +github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8= +github.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo= +github.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY= +github.com/go-openapi/swag/cmdutils v0.25.1/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0= +github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= +github.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU= +github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M= +github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= +github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= +github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8= +github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg= +github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw= +github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= +github.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync= +github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ= +github.com/go-openapi/swag/netutils v0.25.1 h1:2wFLYahe40tDUHfKT1GRC4rfa5T1B4GWZ+msEFA4Fl4= +github.com/go-openapi/swag/netutils v0.25.1/go.mod h1:CAkkvqnUJX8NV96tNhEQvKz8SQo2KF0f7LleiJwIeRE= +github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw= +github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= +github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA= +github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= +github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk= +github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= -github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= +github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -56,54 +78,67 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= -github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/matst80/slask-finder v0.0.0-20250418094723-2eb7d6615761 h1:kEvcfY+vCg+sCeFxHj5AzKbLMSmxsWU53OEPeCi28RU= -github.com/matst80/slask-finder v0.0.0-20250418094723-2eb7d6615761/go.mod h1:+DqYJ8l2i/6haKggOnecs2mU7T8CC3v5XW3R4UGCgo4= +github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548 h1:lkxVz5lNPlU78El49cx1c3Rxo/bp7Gbzp2BjSRFgg1U= +github.com/matst80/slask-finder v0.0.0-20251009175145-ce05aff5a548/go.mod h1:k4lo5gFYb3AqrgftlraCnv95xvjB/w8udRqJQJ12mAE= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= +github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= -github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= -github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= -github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM= -github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg= +github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= +github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= +github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= +github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= -github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= -github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/yudhasubki/netpool v0.0.0-20230717065341-3c1353ca328e h1:fAzVSmKQkWflN25ED65CH/C1T3iVWq2BQfN7eQsg4E4= -github.com/yudhasubki/netpool v0.0.0-20230717065341-3c1353ca328e/go.mod h1:gQsFrHrY6nviQu+VX7zKWDyhtLPNzngtYZ+C+7cywdk= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -113,69 +148,72 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= -golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= -golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= -golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 h1:Zy9XzmMEflZ/MAaA7vNcoebnRAld7FsPW1EeBB7V0m8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= -google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= -google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff h1:A90eA31Wq6HOMIQlLfzFwzqGKBTuaVztYu/g8sn+8Zc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= +google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= -gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.32.3 h1:Hw7KqxRusq+6QSplE3NYG4MBxZw1BZnq4aP4cJVINls= -k8s.io/api v0.32.3/go.mod h1:2wEDTXADtm/HA7CCMD8D8bK4yuBUptzaRhYcYEEYA3k= -k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U= -k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= -k8s.io/client-go v0.32.3 h1:RKPVltzopkSgHS7aS98QdscAgtgah/+zmpAogooIqVU= -k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY= +k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= +k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= +k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= +k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= +k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= -k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= -k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e h1:KqK5c/ghOm8xkHYhlodbp6i6+r+ChV2vuAuVRdFbLro= -k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= -sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= -sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxgMd7wez1XkI= -sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/grpc_integration_test.go b/grpc_integration_test.go index 572821b..201c034 100644 --- a/grpc_integration_test.go +++ b/grpc_integration_test.go @@ -14,6 +14,8 @@ 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 +// fetching logic (FetchItem) which would require network I/O. func TestCartActorMutationAndState(t *testing.T) { // Setup local grain pool + synced pool (no discovery, single host) pool := NewGrainLocalPool(1024, time.Minute, spawn) @@ -46,28 +48,34 @@ func TestCartActorMutationAndState(t *testing.T) { // Create a short cart id (<=16 chars so it fits into the fixed CartId 16-byte array cleanly) cartID := fmt.Sprintf("cart-%d", time.Now().UnixNano()) - // Build an AddRequest payload (quantity=1, sku=test-sku) - addReq := &messages.AddRequest{ + // Build an AddItem payload (bypasses FetchItem to keep test deterministic) + addItem := &messages.AddItem{ + ItemId: 1, Quantity: 1, + Price: 1000, + OrgPrice: 1000, Sku: "test-sku", + Name: "Test SKU", + Image: "/img.png", + Stock: 2, // InStock + Tax: 2500, Country: "se", } // Marshal underlying mutation payload using the existing handler code path - // We can directly marshal with proto since envelope expects raw bytes - handler, ok := Handlers[AddRequestType] + handler, ok := Handlers[AddItemType] if !ok { - t.Fatalf("Handler for AddRequestType missing") + t.Fatalf("Handler for AddItemType missing") } - payloadData, err := getSerializedPayload(handler, addReq) + payloadData, err := getSerializedPayload(handler, AddItemType, addItem) if err != nil { - t.Fatalf("serialize add request: %v", err) + t.Fatalf("serialize add item: %v", err) } // Issue Mutate RPC mutResp, err := cartClient.Mutate(context.Background(), &messages.MutationRequest{ CartId: cartID, - Type: messages.MutationType(AddRequestType), + Type: messages.MutationType(AddItemType), Payload: payloadData, ClientTimestamp: time.Now().Unix(), }) @@ -114,9 +122,9 @@ func TestCartActorMutationAndState(t *testing.T) { } // getSerializedPayload serializes a mutation proto using the registered handler. -func getSerializedPayload(handler MessageHandler, content interface{}) ([]byte, error) { +func getSerializedPayload(handler MessageHandler, msgType uint16, content interface{}) ([]byte, error) { msg := &Message{ - Type: AddRequestType, + Type: msgType, Content: content, } var buf bytes.Buffer diff --git a/main.go b/main.go index 3a87f4e..417c3ea 100644 --- a/main.go +++ b/main.go @@ -385,6 +385,7 @@ func main() { done <- true }() + log.Print("Server started at port 8080") go http.ListenAndServe(":8080", mux) <-done diff --git a/product-fetcher.go b/product-fetcher.go index 12a98fe..972ea98 100644 --- a/product-fetcher.go +++ b/product-fetcher.go @@ -16,7 +16,10 @@ func getBaseUrl(country string) string { if country == "no" { return "http://s10n-no.s10n:8080" } - return "http://s10n-se.s10n:8080" + if country == "se" { + return "http://s10n-se.s10n:8080" + } + return "http://localhost:8082" } func FetchItem(sku string, country string) (*index.DataItem, error) { -- 2.49.1 From b97eb8f285cba2e9ec6b003a66aa5b75ec00b564 Mon Sep 17 00:00:00 2001 From: matst80 Date: Fri, 10 Oct 2025 07:35:49 +0000 Subject: [PATCH 3/3] upgrade cartgrain --- cart-grain.go | 37 +++++++++++++++++-------------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/cart-grain.go b/cart-grain.go index 0bae3ea..9a08a62 100644 --- a/cart-grain.go +++ b/cart-grain.go @@ -133,15 +133,11 @@ func (c *CartGrain) GetCurrentState() (*FrameWithPayload, error) { return &ret, nil } -func getInt(data interface{}) (int, error) { - switch v := data.(type) { - case float64: - return int(v), nil - case int: - return v, nil - default: +func getInt(data float64, ok bool) (int, error) { + if !ok { return 0, fmt.Errorf("invalid type") } + return int(data), nil } func getItemData(sku string, qty int, country string) (*messages.AddItem, error) { @@ -149,35 +145,36 @@ func getItemData(sku string, qty int, country string) (*messages.AddItem, error) if err != nil { return nil, err } - orgPrice, _ := getInt(item.Fields[5]) + orgPrice, _ := getInt(item.GetNumberFieldValue(5)) // getInt(item.Fields[5]) - price, priceErr := getInt(item.Fields[4]) + price, priceErr := getInt(item.GetNumberFieldValue(4)) //Fields[4] if priceErr != nil { return nil, fmt.Errorf("invalid price") } stock := InStock + /*item.t if item.StockLevel == "0" || item.StockLevel == "" { stock = OutOfStock } else if item.StockLevel == "5+" { stock = LowStock - } - articleType, _ := item.Fields[1].(string) - outletGrade, ok := item.Fields[20].(string) + }*/ + articleType, _ := item.GetStringFieldValue(1) //.Fields[1].(string) + outletGrade, ok := item.GetStringFieldValue(20) //.Fields[20].(string) var outlet *string if ok { outlet = &outletGrade } - sellerId, _ := item.Fields[24].(string) - sellerName, _ := item.Fields[9].(string) + sellerId, _ := item.GetStringFieldValue(24) // .Fields[24].(string) + sellerName, _ := item.GetStringFieldValue(9) // .Fields[9].(string) - brand, _ := item.Fields[2].(string) - category, _ := item.Fields[10].(string) - category2, _ := item.Fields[11].(string) - category3, _ := item.Fields[12].(string) - category4, _ := item.Fields[13].(string) - category5, _ := item.Fields[14].(string) + brand, _ := item.GetStringFieldValue(2) //.Fields[2].(string) + category, _ := item.GetStringFieldValue(10) //.Fields[10].(string) + category2, _ := item.GetStringFieldValue(11) //.Fields[11].(string) + category3, _ := item.GetStringFieldValue(12) //.Fields[12].(string) + category4, _ := item.GetStringFieldValue(13) //Fields[13].(string) + category5, _ := item.GetStringFieldValue(14) //.Fields[14].(string) return &messages.AddItem{ ItemId: int64(item.Id), -- 2.49.1