Skip to main content

gRPC-First Interface Design

Context

Prism needs a high-performance, type-safe API for client-server communication:

  • Efficient binary protocol for low latency
  • Strong typing with code generation
  • Streaming support for large datasets
  • HTTP/2 multiplexing for concurrent requests
  • Cross-language client support

Requirements:

  • Performance: Sub-millisecond overhead, 10k+ RPS per connection
  • Type safety: Compile-time validation of requests/responses
  • Streaming: Bidirectional streaming for pub/sub and pagination
  • Discoverability: Self-documenting API via protobuf
  • Evolution: Backward-compatible API changes

Decision

Use gRPC as the primary interface for Prism data access layer:

  1. gRPC over HTTP/2: Binary protocol with multiplexing
  2. Protobuf messages: All requests/responses in protobuf
  3. Streaming first-class: Unary, server-streaming, client-streaming, bidirectional
  4. No REST initially: Focus on gRPC, add REST gateway later if needed
  5. Service-per-pattern: Separate gRPC services for each access pattern

Rationale

Why gRPC

Performance benefits:

  • Binary serialization (smaller payloads than JSON)
  • HTTP/2 multiplexing (multiple requests per connection)
  • Header compression (reduces overhead)
  • Connection reuse (lower latency)

Developer experience:

  • Code generation for multiple languages
  • Type safety at compile time
  • Self-documenting via .proto files
  • Built-in deadline/timeout support
  • Rich error model with status codes

Streaming support:

  • Server streaming for pagination and pub/sub
  • Client streaming for batch uploads
  • Bidirectional streaming for real-time communication

Architecture

┌─────────────────────────────────────────────────────────┐
│ Prism gRPC Server │
│ (Port 8980) │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ gRPC Services │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ ConfigService│ │ SessionService│ │ │
│ │ └──────────────┘ └──────────────┘ │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ QueueService│ │ PubSubService│ │ │
│ │ └──────────────┘ └──────────────┘ │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ ReaderService│ │TransactService│ │ │
│ │ └──────────────┘ └──────────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘

│ HTTP/2 + Protobuf

┌────────────┴────────────┐
│ │
│ │
┌─────────▼─────────┐ ┌─────────▼─────────┐
│ Go Client │ │ Rust Client │
│ (generated code) │ │ (generated code) │
└───────────────────┘ └───────────────────┘

Service Organization

Each access pattern gets its own service:

// proto/prism/session/v1/session_service.proto
service SessionService {
rpc CreateSession(CreateSessionRequest) returns (CreateSessionResponse);
rpc CloseSession(CloseSessionRequest) returns (CloseSessionResponse);
rpc Heartbeat(HeartbeatRequest) returns (HeartbeatResponse);
}

// proto/prism/queue/v1/queue_service.proto
service QueueService {
rpc Publish(PublishRequest) returns (PublishResponse);
rpc Subscribe(SubscribeRequest) returns (stream Message);
rpc Acknowledge(AcknowledgeRequest) returns (AcknowledgeResponse);
rpc Commit(CommitRequest) returns (CommitResponse);
}

// proto/prism/pubsub/v1/pubsub_service.proto
service PubSubService {
rpc Publish(PublishRequest) returns (PublishResponse);
rpc Subscribe(SubscribeRequest) returns (stream Event);
rpc Unsubscribe(UnsubscribeRequest) returns (UnsubscribeResponse);
}

// proto/prism/reader/v1/reader_service.proto
service ReaderService {
rpc Read(ReadRequest) returns (stream Page);
rpc Query(QueryRequest) returns (stream Row);
}

// proto/prism/transact/v1/transact_service.proto
service TransactService {
rpc Write(WriteRequest) returns (WriteResponse);
rpc Transaction(stream TransactRequest) returns (stream TransactResponse);
}

Streaming Patterns

Server streaming (pagination, pub/sub):


service ReaderService {
// Server streams pages to client
rpc Read(ReadRequest) returns (stream Page) {
option (google.api.http) = {
post: "/v1/reader/read"
body: "*"
};
}
}

Server implementation:

// Server implementation
async fn read(&self, req: Request<ReadRequest>) -> Result<Response<Self::ReadStream>, Status> {
let (tx, rx) = mpsc::channel(100);

tokio::spawn(async move {
let mut offset = 0;
loop {
let page = fetch_page(offset, 100).await?;
if page.items.is_empty() {
break;
}
tx.send(Ok(page)).await?;
offset += 100;
}
});

Ok(Response::new(ReceiverStream::new(rx)))
}

Client streaming (batch writes):


service TransactService {
// Client streams write batches
rpc BatchWrite(stream WriteRequest) returns (WriteResponse);
}

Bidirectional streaming (pub/sub with acks):


service PubSubService {
// Client subscribes, server streams events, client sends acks
rpc Stream(stream ClientMessage) returns (stream ServerMessage);
}

Error Handling

Use gRPC status codes:


use tonic::{Code, Status};

// Not found
return Err(Status::not_found(format!("namespace {} not found", namespace)));

// Invalid argument
return Err(Status::invalid_argument("page size must be > 0"));

// Unavailable
return Err(Status::unavailable("backend connection failed"));

// Deadline exceeded
return Err(Status::deadline_exceeded("operation timed out"));

// Permission denied
return Err(Status::permission_denied("insufficient permissions"));

Structured error details:


import "google/rpc/error_details.proto";

message ErrorInfo {
string reason = 1;
string domain = 2;
map<string, string> metadata = 3;
}

Metadata and Context

Use gRPC metadata for cross-cutting concerns:


// Server: extract session token from metadata
let session_token = req.metadata()
.get("x-session-token")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| Status::unauthenticated("missing session token"))?;

// Client: add session token to metadata
let mut request = Request::new(read_request);
request.metadata_mut().insert(
"x-session-token",
session_token.parse().unwrap(),
);

Common metadata:

  • x-session-token: Session identifier
  • x-namespace: Namespace for multi-tenancy
  • x-request-id: Request tracing
  • x-client-version: Client version for compatibility

Performance Optimizations

Connection pooling:

// Reuse connections
let channel = Channel::from_static("http://localhost:8980")
.connect_lazy();

let client = QueueServiceClient::new(channel.clone());

Compression:

// Enable gzip compression
let channel = Channel::from_static("http://localhost:8980")
.http2_keep_alive_interval(Duration::from_secs(30))
.http2_adaptive_window(true)
.connect_lazy();

Timeouts:

service QueueService {
rpc Publish(PublishRequest) returns (PublishResponse) {
option (google.api.method_signature) = "timeout=5s";
}
}

Alternatives Considered

  1. REST/HTTP JSON API

    • Pros: Simple, widespread tooling, human-readable
    • Cons: Slower serialization, no streaming, manual typing
    • Rejected: Performance critical for Prism
  2. GraphQL

    • Pros: Flexible queries, single endpoint
    • Cons: Complexity, performance overhead, limited streaming
    • Rejected: Over-engineered for data access patterns
  3. WebSockets

    • Pros: Bidirectional, real-time
    • Cons: No type safety, manual protocol design
    • Rejected: gRPC bidirectional streaming provides same benefits
  4. Thrift or Avro

    • Pros: Binary protocols, similar performance
    • Cons: Smaller ecosystems, less tooling
    • Rejected: gRPC has better ecosystem and HTTP/2 benefits

Consequences

Positive

  • High performance: Binary protocol, HTTP/2 multiplexing
  • Type safety: Compile-time validation via protobuf
  • Streaming: First-class support for all streaming patterns
  • Multi-language: Generated clients for Go, Rust, Python, etc.
  • Self-documenting: .proto files serve as API documentation
  • Evolution: Backward-compatible changes via protobuf
  • Observability: Built-in tracing, metrics integration

Negative

  • Debugging complexity: Binary format harder to inspect than JSON
  • Tooling required: Need grpcurl, grpcui for manual testing
  • Learning curve: Teams unfamiliar with gRPC/protobuf
  • Browser limitations: No native browser support (need gRPC-Web)

Neutral

  • HTTP/2 required: Not compatible with HTTP/1.1-only infrastructure
  • REST gateway optional: Can add later with grpc-gateway

Implementation Notes

Server Implementation (Rust)

// proxy/src/main.rs
use tonic::transport::Server;

#[tokio::main]
async fn main() -> Result<()> {
let addr = "0.0.0.0:8980".parse()?;

let session_service = SessionServiceImpl::default();
let queue_service = QueueServiceImpl::default();
let pubsub_service = PubSubServiceImpl::default();

Server::builder()
.add_service(SessionServiceServer::new(session_service))
.add_service(QueueServiceServer::new(queue_service))
.add_service(PubSubServiceServer::new(pubsub_service))
.serve(addr)
.await?;

Ok(())
}

Client Implementation (Go)

// Client connection
conn, err := grpc.Dial(
"localhost:8980",
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 30 * time.Second,
Timeout: 10 * time.Second,
}),
)
defer conn.Close()

// Create typed client
client := queue.NewQueueServiceClient(conn)

// Make request
resp, err := client.Publish(ctx, &queue.PublishRequest{
Topic: "events",
Payload: data,
})

Testing with grpcurl

# List services
grpcurl -plaintext localhost:8980 list

# Describe service
grpcurl -plaintext localhost:8980 describe prism.queue.v1.QueueService

# Make request
grpcurl -plaintext -d '{"topic":"events","payload":"dGVzdA=="}' \
localhost:8980 prism.queue.v1.QueueService/Publish

Code Generation


# Generate Rust code
buf generate --template proxy/buf.gen.rust.yaml

# Generate Go code
buf generate --template tools/buf.gen.go.yaml

# Generate Python code
buf generate --template clients/python/buf.gen.python.yaml

References

Revision History

  • 2025-10-07: Initial draft and acceptance