Skip to content

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.