SLIMRPC (SLIM Remote Procedure Call)
SLIMRPC, or SLIM Remote Procedure Call, is a mechanism designed to enable Protocol Buffers (protobuf) RPC over SLIM (Secure Low-latency Inter-process Messaging). This is analogous to gRPC, which leverages HTTP/2 as its underlying transport layer for protobuf RPC.
A key advantage of SLIMRPC lies in its ability to seamlessly integrate SLIM as the transport protocol for inter-application message exchange. This significantly simplifies development: a protobuf file can be compiled to generate code that utilizes SLIM for communication. Application developers can then interact with the generated code much like they would with standard gRPC, while benefiting from the inherent security features and efficiency provided by the SLIM protocol.
This documentation explains how SLIMRPC works and how you can implement it in your applications. For detailed instructions on compiling a protobuf file to obtain the necessary SLIMRPC stub code, please refer to the dedicated SLIMRPC compiler documentation.
SLIM naming in SLIMRPC
In SLIMRPC, each service and its individual RPC handlers are assigned a SLIM name, facilitating efficient message routing and processing. Consider the example protobuf definition, which defines four distinct services:
syntax = "proto3";
package example_service;
service Test {
rpc ExampleUnaryUnary(ExampleRequest) returns (ExampleResponse);
rpc ExampleUnaryStream(ExampleRequest) returns (stream ExampleResponse);
rpc ExampleStreamUnary(stream ExampleRequest) returns (ExampleResponse);
rpc ExampleStreamStream(stream ExampleRequest) returns (stream ExampleResponse);
}
message ExampleRequest {
string example_string = 1;
int64 example_integer = 2;
}
message ExampleResponse {
string example_string = 1;
int64 example_integer = 2;
}
This example showcases the four primary communication patterns supported by gRPC:
- Unary-Unary
- Unary-Stream
- Stream-Unary
- Stream-Stream
For SLIMRPC, a specific SLIM name is generated for each handler within a service. This naming convention allows an application exposing the service to listen for its name and process messages intended for a particular RPC method. The format for these names is:
{package-name}.{service-name}-{handler_name}
Based on the example_service.Test definition, the names for each handler would be:
example_service.Test-ExampleUnaryUnary
example_service.Test-ExampleUnaryStream
example_service.Test-ExampleStreamUnary
example_service.Test-ExampleStreamStream
This handler name is appended to the second component of the SLIM name
associated with the running application. For instance, to receive messages for
example_service.Test-ExampleUnaryUnary
, an application would subscribe to:
component[0]/component[1]/component[2]-example_service.Test-ExampleUnaryUnary/component[3]
The subscription process is entirely managed by the SLIMRPC package. Application developers are not required to explicitly handle SLIM name subscriptions. Instead, they only need to implement the specific functions that will be invoked when a message arrives for a defined RPC method, exactly as they would with standard gRPC.
Example
This section provides a detailed walkthrough of a basic SLIMRPC client-server interaction, leveraging the simple example provided in example folder.
Generated Code
The foundation of this example is the example.proto
file, which is a standard
Protocol Buffers definition file. This file is compiled using the SLIMRPC
compiler to generate the necessary Python stub
code. The generated code is available in two files:
example_pb2.py
and
example_pb2_slimrpc.py.
Specifically, example_pb2_slimrpc.py
contains the SLIMRPC-specific stubs for
both client and server implementations. Below are the key classes and functions
generated by the compiler:
Client Stub (TestStub): The TestStub class represents the client-side interface for interacting with the Test service. It provides methods for each RPC defined in example.proto, allowing clients to initiate calls to the server.
class TestStub:
"""Client stub for Test."""
def __init__(self, channel):
"""Constructor.
Args:
channel: A slimrpc.Channel.
"""
self.ExampleUnaryUnary = channel.unary_unary(
"/example_service.Test/ExampleUnaryUnary",
request_serializer=pb2.ExampleRequest.SerializeToString,
response_deserializer=pb2.ExampleResponse.FromString,
)
self.ExampleUnaryStream = channel.unary_stream(
"/example_service.Test/ExampleUnaryStream",
request_serializer=pb2.ExampleRequest.SerializeToString,
response_deserializer=pb2.ExampleResponse.FromString,
)
self.ExampleStreamUnary = channel.stream_unary(
"/example_service.Test/ExampleStreamUnary",
request_serializer=pb2.ExampleRequest.SerializeToString,
response_deserializer=pb2.ExampleResponse.FromString,
)
self.ExampleStreamStream = channel.stream_stream(
"/example_service.Test/ExampleStreamStream",
request_serializer=pb2.ExampleRequest.SerializeToString,
response_deserializer=pb2.ExampleResponse.FromString,
)
Server Servicer (TestServicer): The TestServicer class defines the server-side interface. Developers implement this class to provide the actual logic for each RPC method.
class TestServicer():
"""Server servicer for Test. Implement this class to provide your service logic."""
def ExampleUnaryUnary(self, request, context):
"""Method for ExampleUnaryUnary. Implement your service logic here."""
raise slimrpc_rpc.SRPCResponseError(
code=code__pb2.UNIMPLEMENTED, message="Method not implemented!"
)
def ExampleUnaryStream(self, request, context):
"""Method for ExampleUnaryStream. Implement your service logic here."""
raise slimrpc_rpc.SRPCResponseError(
code=code__pb2.UNIMPLEMENTED, message="Method not implemented!"
)
def ExampleStreamUnary(self, request_iterator, context):
"""Method for ExampleStreamUnary. Implement your service logic here."""
raise slimrpc_rpc.SRPCResponseError(
code=code__pb2.UNIMPLEMENTED, message="Method not implemented!"
)
def ExampleStreamStream(self, request_iterator, context):
"""Method for ExampleStreamStream. Implement your service logic here."""
raise slimrpc_rpc.SRPCResponseError(
code=code__pb2.UNIMPLEMENTED, message="Method not implemented!"
)
Server Registration Function (add_TestServicer_to_server): This utility function registers an implemented TestServicer instance with an SLIMRPC server. It maps RPC method names to their corresponding handlers and specifies the request deserialization and response serialization routines.
def add_TestServicer_to_server(servicer, server: slimrpc.Server):
rpc_method_handlers = {
"ExampleUnaryUnary": slimrpc.unary_unary_rpc_method_handler(
behaviour=servicer.ExampleUnaryUnary,
request_deserializer=pb2.ExampleRequest.FromString,
response_serializer=pb2.ExampleResponse.SerializeToString,
),
"ExampleUnaryStream": slimrpc.unary_stream_rpc_method_handler(
behaviour=servicer.ExampleUnaryStream,
request_deserializer=pb2.ExampleRequest.FromString,
response_serializer=pb2.ExampleResponse.SerializeToString,
),
"ExampleStreamUnary": slimrpc.stream_unary_rpc_method_handler(
behaviour=servicer.ExampleStreamUnary,
request_deserializer=pb2.ExampleRequest.FromString,
response_serializer=pb2.ExampleResponse.SerializeToString,
),
"ExampleStreamStream": slimrpc.stream_stream_rpc_method_handler(
behaviour=servicer.ExampleStreamStream,
request_deserializer=pb2.ExampleRequest.FromString,
response_serializer=pb2.ExampleResponse.SerializeToString,
),
}
server.register_method_handlers(
"example_service.Test",
rpc_method_handlers,
)
Server implementation
The server-side logic is defined in server.py. Similar to standard gRPC implementations, the core service functionality is provided by the TestService class, which inherits from TestServicer (as introduced in the previous section). This class contains the concrete implementations for each of the defined RPC methods.
The SLIM-specific code and configuration is handled within the amain() asynchronous function. This function utilizes the create_server helper to instantiate an SLIMRPC server:
def create_server(
local: str,
slim: dict,
enable_opentelemetry: bool = False,
shared_secret: str = "",
) -> Server:
"""
Create a new SLIMRPC server instance.
"""
server = Server(
local=local,
slim=slim,
enable_opentelemetry=enable_opentelemetry,
shared_secret=shared_secret,
)
return server
async def amain() -> None:
server = create_server(
local="agntcy/grpc/server",
slim={
"endpoint": "http://localhost:46357",
"tls": {
"insecure": True,
},
},
enable_opentelemetry=False,
shared_secret="my_shared_secret",
)
# Create RPCs
add_TestServicer_to_server(
TestService(),
server,
)
await server.run()
A new server application is created using the create_server
function. The
local parameter, set to "agntcy/grpc/server", assigns a SLIM name to this server
application.
This name is then used to construct the full SLIMRPC names for each method:
agntcy/grpc/server-example_service.Test-ExampleUnaryUnary
agntcy/grpc/server-example_service.Test-ExampleUnaryStream
agntcy/grpc/server-example_service.Test-ExampleStreamUnary
agntcy/grpc/server-example_service.Test-ExampleStreamStream
Additionally, the slim
dictionary configures the server to connect to a SLIM
node running at http://localhost:46357
. The tls setting insecure: True
disables TLS for simplicity in this example. The shared_secret
parameter is
used for initializing the Message Layer Security (MLS) protocol. Note that using
a hardcoded shared_secret like "my_shared_secret" is not recommended, please
refer to the documentation for proper MLS
configuration.
Finally, the add_TestServicer_to_server function is called to register the implemented TestService with the SLIMRPC server, making its RPC methods available.
# Create RPCs
add_TestServicer_to_server(
TestService(),
server,
)
Client implementation
The client-side implementation, found in client.py, largely mirrors the structure of a standard gRPC client. The primary distinction and SLIM-specific aspect lies in the creation of the SLIMRPC channel:
channel_factory = slimrpc.ChannelFactory(
slim_app_config=slimrpc.SLIMAppConfig(
identity="agntcy/grpc/client",
slim_client_config={
"endpoint": "http://localhost:46357",
"tls": {
"insecure": True,
},
},
enable_opentelemetry=False,
shared_secret="my_shared_secret",
),
)
channel = channel_factory.new_channel(remote="agntcy/grpc/server")
# Stubs
stubs = TestStub(channel)
As opposite to the server, the client application only register its local name
agntcy/grpc/client
in the SLIM network. This is done through the identity
parameter in the SLIMAppConfig
class. This name will be then used by the
server to send back the response to the client.
Also, like in the case of the server application, the slim
dictionary
specifies the SLIM node endpoint (http://localhost:46357
) and TLS settings,
consistent with the server's configuration, while shared_secret
is used to
initialize MLS.
The remote parameter, set to agntcy/grpc/server
, explicitly identifies the
SLIM name of the target server application. This allows the SLIMRPC channel to
correctly route messages to the appropriate server endpoint within the SLIM
network. Since both client and server use the same protobuf definition, the
client can invoke specific services and methods with type safety and
consistency.
SLIMRPC under the Hood
SLIMRPC was introduced to simplify the integration of existing applications with SLIM. From a developer's perspective, using SLIMRPC or gRPC is almost identical. Application developers do not need to manage endpoint names or connectivity details, as these aspects are handled automatically by SLIMRPC and SLIM.
All RPC services underneath utilize a sticky point-to-point session. The SLIM session creation is implemented in inside SLIMRPC in channel.py:
# Create a session
session = await self.local_app.create_session(
slim_bindings.PySessionConfiguration.FireAndForget(
max_retries=10,
timeout=datetime.timedelta(seconds=1),
sticky=True,
)
)
This session used by SLIMRPC is also reliable. For each message, the sender
waits for an acknowledgment (ACK) packet for 1 second
(timeout=datetime.timedelta(seconds=1)
). If no acknowledgment is received, the
message will be re-sent up to 10 times (max_retries=10
) before notifying the
application of a communication error.
Since the session is sticky, all messages in a streaming communication will be forwarded to the same application instance. Let's illustrate this with an example using the client and server applications described above.
Imagine two server instances running the same RPC service. In this example we will
focus on the Stream-Unary service, which is served by both server instances
under the general name
agntcy/grpc/server-example_service.Test-ExampleStreamUnary
. In SLIM, each
application receives a unique ID. Thus, the full service name will include a
fourth component containing the server's ID. This ID is generated by SLIM itself
(see the doc for more details). Here we will use
instance-1 and instance-2 for simplicity. So, the two full names for the
services will be:
agntcy/grpc/server-example_service.Test-ExampleStreamUnary/instance-1
agntcy/grpc/server-example_service.Test-ExampleStreamUnary/instance-2
Now, if a new client wants to use the Stream-Unary service it needs to knows
only the general name
agntcy/grpc/server-example_service.Test-ExampleStreamUnary
. SLIMRPC will
leverage SLIM's capabilities to first discover one of the available services,
and then SLIMRPC will use its full, specific name to consistently communicate
with that same endpoint.
The client will register to SLIM with the name agntcy/grpc/client
, and it will
get an unique ID assigned by SLIM, for example instance-1
. So the full name of
the client will be agntcy/grpc/client/instance-1
.
sequenceDiagram
autonumber
participant Client (instance-1)
participant SLIM Node
participant Server (instance-1)
participant Server (instance-2)
Note over Client (instance-1),Server (instance-1): Discovery
Client (instance-1)->>SLIM Node: Discover agntcy/grpc/server-example_service.Test-ExampleStreamUnary
SLIM Node->>Server (instance-1): Discover agntcy/grpc/server-example_service.Test-ExampleStreamUnary
Server (instance-1)->>SLIM Node: Ack from agntcy/grpc/server-example_service.Test-ExampleStreamUnary/instance-1
SLIM Node->>Client (instance-1): Ack from agntcy/grpc/server-example_service.Test-ExampleStreamUnary/instance-1
Note over Client (instance-1),Server (instance-1): Stream
Client (instance-1)->>SLIM Node: Msg to agntcy/grpc/server-example_service.Test-ExampleStreamUnary/instance-1
SLIM Node->>Server (instance-1): Msg to agntcy/grpc/server-example_service.Test-ExampleStreamUnary/instance-1
Server (instance-1)->>SLIM Node: Ack from agntcy/grpc/server-example_service.Test-ExampleStreamUnary/instance-1
SLIM Node->>Client (instance-1): Ack from agntcy/grpc/server-example_service.Test-ExampleStreamUnary/instance-1
Client (instance-1)->>SLIM Node: Msg to agntcy/grpc/server-example_service.Test-ExampleStreamUnary/instance-1
SLIM Node->>Server (instance-1): Msg to agntcy/grpc/server-example_service.Test-ExampleStreamUnary/instance-1
Server (instance-1)->>SLIM Node: Ack from agntcy/grpc/server-example_service.Test-ExampleStreamUnary/instance-1
SLIM Node->>Client (instance-1): Ack from agntcy/grpc/server-example_service.Test-ExampleStreamUnary/instance-1
Note over Client (instance-1),Server (instance-1): Unary
Server (instance-1)->>SLIM Node: Msg to agntcy/grpc/client/instance-1
SLIM Node->>Client (instance-1): Msg to agntcy/grpc/client/instance-1
Client (instance-1)->>SLIM Node: Ack to agntcy/grpc/server-example_service.Test-ExampleStreamUnary/instance-1
SLIM Node->>Server (instance-1): Ack to agntcy/grpc/server-example_service.Test-ExampleStreamUnary/instance-1
The initial messages in the sequence diagram are used for the discovery phase.
After this step, the client application knows the specific name of the service
running on instance-1. It's important to note that the first message in the
discovery phase is sent in anycast from the SLIM node, meaning it could be
forwarded to either of the two running servers. For instance, a subsequent
call of the same RPC from the same client might be served by the server
with id instance-2
.
After the discovery, the client will always send messages to the same endpoint, as demonstrated in the streaming session phase in the example.
Finally, the server is expected to send one message to the client to close the service. The server learns the client's address (where to forward the message) by examining the source field of the received messages.