Halibut icon indicating copy to clipboard operation
Halibut copied to clipboard

Add support for RPC over Redis.

Open LukeButters opened this issue 4 months ago • 0 comments

Summary

This PR adds a new execution mode where RPC requests are executed locally on the worker node that dequeues work from the IPendingRequestQueue, rather than being proxied over TCP to a remote service. This allows for executing RPCs by use of a distributed PendingRequestQueue only. This is particularly handy when all nodes already make use of a Redis, in this case RPCs can be made between those nodes using only Redis.

Motivation

Requesting Halibut logs between nodes.

Halibut provides an in memory rolling log of the last 100 log lines per Endpoint. In a multi-node setup currently one must go to each node to get these logs. Since a multi-node setup would already have a shared Redis, the support for RPC over Redis makes it trivial to request the logs from each node.

Clients behind a load balancer.

We are sometimes in the situation in which we need work to picked up by specific nodes e.g. a client is connected to only one node and we need that node to process the work.

With this change and a distributed queue (e.g. the Redis one), it would be possible to setup something like:

  • Client connects to a node lets call the Client "bob"
  • That node would call halibutRunTime.PollLocalAsync(new Uri("local://bob"), workerCts.Token) and so would begin to processes messages sent to "local://bob".
  • A different node is able to send a request to bob in the usual halibut way: var echo = client.CreateAsyncClient<IEchoService, IAsyncClientEchoService>(new ("local://test-worker");
  • and the node connected to bob will collect the request and do it.

Changes

Core Implementation

  • HalibutRuntime.PollLocalAsync() - New method that polls a local:// queue and executes RPCs locally
  • local:// URI scheme support - Added to routing logic in SendOutgoingRequestAsync()
  • Workers directly access the queue via GetQueue() and execute requests using ServiceInvoker
  • Simple polling loop: dequeue → invoke locally → apply response

Documentation

  • Comprehensive design document at /docs/LocalExecutionMode.md
  • Covers architecture, implementation details, usage examples, and performance considerations

Testing

  • LocalExecutionModeFixture with test demonstrating local execution
  • Uses shared PendingRequestQueueFactory so client and worker share the same queue

Usage

// Worker setup
var worker = new HalibutRuntime(serviceFactory);
worker.Services.AddSingleton<IMyService>(new MyServiceImpl());
await worker.PollLocalAsync(new Uri("local://worker-pool-a"), cancellationToken);

// Client usage
var client = new HalibutRuntime(serviceFactory);
var service = client.CreateAsyncClient<IMyService, IAsyncClientMyService>(
    new ServiceEndPoint("local://worker-pool-a", null));
await service.DoWorkAsync(); // Queued and executed locally by worker

Benefits

  • 10-100x lower latency - No TCP/SSL overhead
  • Higher throughput - No TCP bottleneck
  • True horizontal scaling - Multiple workers can poll the same local:// queue
  • Queue-agnostic - Works with both in-memory and Redis queues
  • Backward compatible - Existing poll:// and https:// endpoints work unchanged

Architecture

The implementation bypasses the entire PollingClient and MessageExchangeProtocol machinery:

Current TCP Polling:

Client → Queue → Worker polls → TCP RPC → Server executes → TCP response → Queue → Client

New Local Execution:

Client → Queue → Worker polls → Execute locally → Queue → Client

No TCP connection, no protocol messages, no serialization overhead.

🤖 Generated with Claude Code

LukeButters avatar Oct 20 '25 03:10 LukeButters