package main import ( "context" "fmt" "log" "net" "time" messages "git.tornberg.me/go-cart-actor/proto" "google.golang.org/grpc" "google.golang.org/grpc/metadata" "google.golang.org/grpc/reflection" ) // cartActorGRPCServer implements the CartActor and ControlPlane gRPC services. // It delegates cart operations to a grain pool and cluster operations to a synced pool. type cartActorGRPCServer struct { messages.UnimplementedCartActorServer messages.UnimplementedControlPlaneServer pool GrainPool // For cart state mutations and queries syncedPool *SyncedPool // For cluster membership and control } // NewCartActorGRPCServer creates and initializes the server. func NewCartActorGRPCServer(pool GrainPool, syncedPool *SyncedPool) *cartActorGRPCServer { return &cartActorGRPCServer{ pool: pool, syncedPool: syncedPool, } } // applyMutation routes a single cart mutation to the target grain (used by per-mutation RPC handlers). func (s *cartActorGRPCServer) applyMutation(cartID string, mutation interface{}) *messages.CartMutationReply { // Canonicalize or preserve legacy id (do NOT hash-rewrite legacy textual ids) cid, _, wasBase62, cerr := CanonicalizeOrLegacy(cartID) if cerr != nil { return &messages.CartMutationReply{ StatusCode: 500, Result: &messages.CartMutationReply_Error{Error: fmt.Sprintf("cart_id canonicalization failed: %v", cerr)}, ServerTimestamp: time.Now().Unix(), } } _ = wasBase62 // placeholder; future: propagate canonical id in reply metadata legacy := CartIDToLegacy(cid) grain, err := s.pool.Apply(legacy, mutation) if err != nil { return &messages.CartMutationReply{ StatusCode: 500, Result: &messages.CartMutationReply_Error{Error: err.Error()}, ServerTimestamp: time.Now().Unix(), } } cartState := ToCartState(grain) return &messages.CartMutationReply{ StatusCode: 200, Result: &messages.CartMutationReply_State{State: cartState}, ServerTimestamp: time.Now().Unix(), } } func (s *cartActorGRPCServer) AddRequest(ctx context.Context, req *messages.AddRequestRequest) (*messages.CartMutationReply, error) { if req.GetCartId() == "" { return &messages.CartMutationReply{ StatusCode: 400, Result: &messages.CartMutationReply_Error{Error: "cart_id is required"}, ServerTimestamp: time.Now().Unix(), }, nil } return s.applyMutation(req.GetCartId(), req.GetPayload()), nil } func (s *cartActorGRPCServer) AddItem(ctx context.Context, req *messages.AddItemRequest) (*messages.CartMutationReply, error) { if req.GetCartId() == "" { return &messages.CartMutationReply{ StatusCode: 400, Result: &messages.CartMutationReply_Error{Error: "cart_id is required"}, ServerTimestamp: time.Now().Unix(), }, nil } return s.applyMutation(req.GetCartId(), req.GetPayload()), nil } func (s *cartActorGRPCServer) RemoveItem(ctx context.Context, req *messages.RemoveItemRequest) (*messages.CartMutationReply, error) { if req.GetCartId() == "" { return &messages.CartMutationReply{ StatusCode: 400, Result: &messages.CartMutationReply_Error{Error: "cart_id is required"}, ServerTimestamp: time.Now().Unix(), }, nil } return s.applyMutation(req.GetCartId(), req.GetPayload()), nil } func (s *cartActorGRPCServer) RemoveDelivery(ctx context.Context, req *messages.RemoveDeliveryRequest) (*messages.CartMutationReply, error) { if req.GetCartId() == "" { return &messages.CartMutationReply{ StatusCode: 400, Result: &messages.CartMutationReply_Error{Error: "cart_id is required"}, ServerTimestamp: time.Now().Unix(), }, nil } return s.applyMutation(req.GetCartId(), req.GetPayload()), nil } func (s *cartActorGRPCServer) ChangeQuantity(ctx context.Context, req *messages.ChangeQuantityRequest) (*messages.CartMutationReply, error) { if req.GetCartId() == "" { return &messages.CartMutationReply{ StatusCode: 400, Result: &messages.CartMutationReply_Error{Error: "cart_id is required"}, ServerTimestamp: time.Now().Unix(), }, nil } return s.applyMutation(req.GetCartId(), req.GetPayload()), nil } func (s *cartActorGRPCServer) SetDelivery(ctx context.Context, req *messages.SetDeliveryRequest) (*messages.CartMutationReply, error) { if req.GetCartId() == "" { return &messages.CartMutationReply{ StatusCode: 400, Result: &messages.CartMutationReply_Error{Error: "cart_id is required"}, ServerTimestamp: time.Now().Unix(), }, nil } return s.applyMutation(req.GetCartId(), req.GetPayload()), nil } func (s *cartActorGRPCServer) SetPickupPoint(ctx context.Context, req *messages.SetPickupPointRequest) (*messages.CartMutationReply, error) { if req.GetCartId() == "" { return &messages.CartMutationReply{ StatusCode: 400, Result: &messages.CartMutationReply_Error{Error: "cart_id is required"}, ServerTimestamp: time.Now().Unix(), }, nil } return s.applyMutation(req.GetCartId(), req.GetPayload()), nil } /* Checkout RPC removed. Checkout is handled at the HTTP layer (PoolServer.HandleCheckout). */ func (s *cartActorGRPCServer) SetCartItems(ctx context.Context, req *messages.SetCartItemsRequest) (*messages.CartMutationReply, error) { if req.GetCartId() == "" { return &messages.CartMutationReply{ StatusCode: 400, Result: &messages.CartMutationReply_Error{Error: "cart_id is required"}, ServerTimestamp: time.Now().Unix(), }, nil } return s.applyMutation(req.GetCartId(), req.GetPayload()), nil } func (s *cartActorGRPCServer) OrderCompleted(ctx context.Context, req *messages.OrderCompletedRequest) (*messages.CartMutationReply, error) { if req.GetCartId() == "" { return &messages.CartMutationReply{ StatusCode: 400, Result: &messages.CartMutationReply_Error{Error: "cart_id is required"}, ServerTimestamp: time.Now().Unix(), }, nil } return s.applyMutation(req.GetCartId(), req.GetPayload()), nil } // GetState retrieves the current state of a cart grain. func (s *cartActorGRPCServer) GetState(ctx context.Context, req *messages.StateRequest) (*messages.StateReply, error) { if req.GetCartId() == "" { return &messages.StateReply{ StatusCode: 400, Result: &messages.StateReply_Error{Error: "cart_id is required"}, }, nil } // Canonicalize / upgrade incoming cart id (preserve legacy strings) cid, _, _, cerr := CanonicalizeOrLegacy(req.GetCartId()) if cerr != nil { return &messages.StateReply{ StatusCode: 500, Result: &messages.StateReply_Error{Error: fmt.Sprintf("cart_id canonicalization failed: %v", cerr)}, }, nil } legacy := CartIDToLegacy(cid) grain, err := s.pool.Get(legacy) if err != nil { return &messages.StateReply{ StatusCode: 500, Result: &messages.StateReply_Error{Error: err.Error()}, }, nil } cartState := ToCartState(grain) return &messages.StateReply{ StatusCode: 200, Result: &messages.StateReply_State{State: cartState}, }, nil } // ControlPlane: Ping func (s *cartActorGRPCServer) Ping(ctx context.Context, _ *messages.Empty) (*messages.PingReply, error) { // Expose cart owner cookie (first-touch owner = this host) for HTTP gateways translating gRPC metadata. // Gateways that propagate Set-Cookie can help establish sticky sessions at the edge. _ = grpc.SendHeader(ctx, metadata.Pairs("set-cookie", fmt.Sprintf("cartowner=%s; Path=/; HttpOnly", s.syncedPool.Hostname()))) return &messages.PingReply{ Host: s.syncedPool.Hostname(), UnixTime: time.Now().Unix(), }, nil } // ControlPlane: Negotiate (merge host views) func (s *cartActorGRPCServer) Negotiate(ctx context.Context, req *messages.NegotiateRequest) (*messages.NegotiateReply, error) { hostSet := make(map[string]struct{}) // Caller view for _, h := range req.GetKnownHosts() { if h != "" { hostSet[h] = struct{}{} } } // This host hostSet[s.syncedPool.Hostname()] = struct{}{} // Known remotes s.syncedPool.mu.RLock() for h := range s.syncedPool.remoteHosts { hostSet[h] = struct{}{} } s.syncedPool.mu.RUnlock() out := make([]string, 0, len(hostSet)) for h := range hostSet { out = append(out, h) } return &messages.NegotiateReply{Hosts: out}, nil } // ControlPlane: GetCartIds (locally owned carts only) func (s *cartActorGRPCServer) GetCartIds(ctx context.Context, _ *messages.Empty) (*messages.CartIdsReply, error) { s.syncedPool.local.mu.RLock() ids := make([]string, 0, len(s.syncedPool.local.grains)) for _, g := range s.syncedPool.local.grains { if g == nil { continue } ids = append(ids, g.GetId().String()) } s.syncedPool.local.mu.RUnlock() return &messages.CartIdsReply{CartIds: ids}, nil } // ControlPlane: Closing (peer shutdown notification) func (s *cartActorGRPCServer) Closing(ctx context.Context, req *messages.ClosingNotice) (*messages.OwnerChangeAck, error) { if req.GetHost() != "" { s.syncedPool.RemoveHost(req.GetHost()) } return &messages.OwnerChangeAck{ Accepted: true, Message: "removed host", }, nil } // StartGRPCServer configures and starts the unified gRPC server on the given address. // It registers both the CartActor and ControlPlane services. func StartGRPCServer(addr string, pool GrainPool, syncedPool *SyncedPool) (*grpc.Server, error) { lis, err := net.Listen("tcp", addr) if err != nil { return nil, fmt.Errorf("failed to listen: %w", err) } grpcServer := grpc.NewServer() server := NewCartActorGRPCServer(pool, syncedPool) messages.RegisterCartActorServer(grpcServer, server) messages.RegisterControlPlaneServer(grpcServer, server) reflection.Register(grpcServer) log.Printf("gRPC server listening on %s", addr) go func() { if err := grpcServer.Serve(lis); err != nil { log.Fatalf("failed to serve gRPC: %v", err) } }() return grpcServer, nil }