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.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, } } // 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.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 }