gRPC Fundamentals
gRPC is TinySystems' fallback cross-module wire. When TINY_NATS_URL is set, cross-module messages travel over NATS instead (see Cross-Module Communication); when it is unset, modules talk to each other over the gRPC path described here. Understanding it helps when building and debugging distributed flows.
Overview
+-----------------------------------------------------------------------------+
| GRPC IN TINYSYSTEMS (fallback wire) |
+-----------------------------------------------------------------------------+
Module A Kubernetes Module B
(common-module) Network (http-module)
| | |
| Message to | |
| http-module | |
| | | |
| v | |
| Scheduler | |
| | | |
| | Not local? | |
| | | |
| v | |
| AddressPool.Handler | |
| | | |
| v | |
| gRPC Client -------------------------------> gRPC Server
| | |
| | Scheduler.Handle()
| | |
+--------------------------+-------------------------------+
What is gRPC?
gRPC is a high-performance RPC framework that uses:
- Protocol Buffers: Efficient binary serialization
- HTTP/2: Multiplexing, streaming, header compression
- Service Definition: Strongly-typed APIs
TinySystems gRPC Service
The SDK defines a single-RPC service (internal/server/proto):
service ModuleService {
rpc Message(MessageRequest) returns (MessageResponse);
}
message MessageRequest {
string ID = 1;
string From = 2; // source full port name
string To = 3; // target full port name
bytes Payload = 4; // JSON-serialized message data
string EdgeID = 5; // edge that carried the message
}
message MessageResponse {
bytes Data = 1; // synchronous response payload (empty if none)
}
There is no error field in the response — handler failures are returned as regular gRPC status errors.
Server Implementation
Each module runs a gRPC server that dispatches into the scheduler. Simplified from internal/server/server.go:
modulepb.RegisterModuleServiceServer(srv, module.NewService(
func(ctx context.Context, req *modulepb.MessageRequest) (*modulepb.MessageResponse, error) {
// Extract message depth from gRPC metadata for cycle detection
var depth int
if md, ok := metadata.FromIncomingContext(ctx); ok {
if vals := md.Get("x-message-depth"); len(vals) > 0 {
depth, _ = strconv.Atoi(vals[0])
}
}
res, err := handler(ctx, &runner.Msg{
EdgeID: req.EdgeID,
To: req.To,
From: req.From,
Data: req.Payload,
Depth: depth,
})
if err != nil {
return nil, err // surfaces as a gRPC status error
}
if utils.IsNil(res) {
return &modulepb.MessageResponse{Data: nil}, nil
}
if resData, ok := res.([]byte); ok {
return &modulepb.MessageResponse{Data: resData}, nil
}
data, err := json.Marshal(res)
if err != nil {
return nil, err
}
return &modulepb.MessageResponse{Data: data}, nil
}))
handler is the scheduler's entry point:
Handle(ctx context.Context, msg *runner.Msg) (any, error)
The any return is the synchronous response for blocking I/O — it flows back to the caller as MessageResponse.Data.
The server also registers the standard gRPC health service and reflection, so grpcurl works out of the box.
Client Implementation
The sending side lives in the AddressPool (see Client Pool):
func (p *AddressPool) Handler(ctx context.Context, msg *runner.Msg) ([]byte, error) {
moduleName, _, err := module.ParseFullName(msg.To)
if err != nil {
return nil, err
}
addr, ok := p.addressTable.Get(moduleName)
if !ok {
return nil, fmt.Errorf("%s module address is unknown", moduleName)
}
client, err := p.getClient(ctx, addr)
if err != nil {
return nil, err
}
// Propagate message depth for cross-module cycle detection
if msg.Depth > 0 {
md := metadata.Pairs("x-message-depth", strconv.Itoa(msg.Depth))
ctx = metadata.NewOutgoingContext(ctx, md)
}
resp, err := client.Message(ctx, &module.MessageRequest{
From: msg.From,
Payload: msg.Data,
EdgeID: msg.EdgeID,
To: msg.To,
})
if err != nil {
return nil, err
}
return resp.Data, nil
}
The Message RPC is the blocking hop: the caller waits until the remote handler (and everything it triggers downstream) returns.
Connection Management
Keepalive
Both sides use keepalive to detect dead connections:
// Client side (AddressPool)
conn, err := grpc.NewClient(addr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 10 * time.Second, // ping every 10s if idle
Timeout: 3 * time.Second, // wait 3s for ping ack
PermitWithoutStream: true, // ping even without active RPCs
}),
)
// Server side
srv := grpc.NewServer(
grpc.StatsHandler(otelgrpc.NewServerHandler()),
grpc.KeepaliveParams(keepalive.ServerParameters{
Time: 10 * time.Second,
Timeout: 3 * time.Second,
}),
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
MinTime: 5 * time.Second,
PermitWithoutStream: true,
}),
)
Connections are created lazily on first use, then conn.Connect() is triggered immediately to eliminate first-message latency. One connection per remote address is shared by all messages to that module.
Connection States
+---------------------------------------------------------------------------+
| GRPC CONNECTION STATES |
+---------------------------------------------------------------------------+
IDLE ------> CONNECTING ------> READY
| | |
| | |
| v v
| TRANSIENT_FAILURE <-------
| |
| | Retry
| v
+--------> CONNECTING
|
v
SHUTDOWN
| State | Description |
|---|---|
| IDLE | No activity, will connect on demand |
| CONNECTING | Establishing connection |
| READY | Connected and healthy |
| TRANSIENT_FAILURE | Failed, will retry |
| SHUTDOWN | Connection closed |
Error Handling
gRPC Status Codes
| Code | Name | Description |
|---|---|---|
| 0 | OK | Success |
| 1 | CANCELLED | Operation cancelled |
| 2 | UNKNOWN | Unknown error (includes remote handler failures) |
| 4 | DEADLINE_EXCEEDED | Timeout |
| 14 | UNAVAILABLE | Service unavailable |
Handling Errors
import "google.golang.org/grpc/status"
resp, err := client.Message(ctx, req)
if err != nil {
st, ok := status.FromError(err)
if ok {
switch st.Code() {
case codes.Unavailable:
// Module is down
case codes.DeadlineExceeded:
// Timeout
default:
// Remote handler error or other failure
}
}
}
Addressing
Modules advertise their listen address in the TinyModule CR's status.addr; peers watch those CRs and register the address in their pool. There is no fixed well-known port — the address is whatever the module bound and published. Traffic is plaintext gRPC inside the cluster; use NetworkPolicies to restrict which namespaces can reach module pods.
Debugging gRPC
Enable Logging
export GRPC_GO_LOG_VERBOSITY_LEVEL=99
export GRPC_GO_LOG_SEVERITY_LEVEL=info
Check Connectivity
Server reflection and the health service are registered, so:
# List services on a module (use the addr from TinyModule status)
grpcurl -plaintext <module-addr> list
# Health check
grpcurl -plaintext <module-addr> grpc.health.v1.Health/Check
Monitor Connections
Look for these log patterns from the pool:
"address pool: registering module" module=http-module addr=...
"address pool: connection pre-warmed" module=http-module addr=...
"grpc client: connection failed" addr=...
Performance Considerations
Message Size
Default gRPC limits apply (4MB send/receive). For larger payloads, chunk the data or pass a reference.
Timeouts
Cross-module calls block until the remote subtree finishes. Set a deadline on the context when the caller cannot wait indefinitely:
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
Next Steps
- Internal Routing - In-module routing
- Cross-Module Communication - NATS primary wire and transport selection
- Client Pool - Connection management