🪲 Building a Tigerbeetle Client in Unison

Unison's got FFI recently. And I thought of integrating with Tigerbeetle, which has recently enamoured me with its fantastic ideas and implementations. But then, I got these couple of small problems:

Tigerbeetle clients are callback-driven, i.e., they need a function pointer to call with the relevant packet from the TB cluster in a separate thread.

Unison FFI lacks passing pointers to Unison function to an foreign C library, i.e. it lacks function pointers

So, I went yak-shaving to create a shim-library, tb-unison-shim, that wraps the original TB C-library to expose a callback-free C ABI for the Unison FFI to work.

tb-unison-shim

Tigerbeetle comes with a native C Client library, libtb_client , which is single-threaded, callback-driven, and has a strict packet-based protocol. Tigerbeetle adheres to all of that when creating its client libraries in languages such as Python, Rust, Go, Node, Java, and dotnet. Most of those clients then abstract the callback approach via their language's IO primitives. E.g., both Python and Rust provide sync and async options for programs to interact with their respective clients.

So, for my shim library, I resorted to a timeout-based blocking C ABI which Unison could then interact with. I chose to implement this in Zig - part coz it's easy to understand and replicate most of the internals if I do it in the same host language. And the other reason this gave me was that it was the best way to learn Zig. And also the hardest.

After going through the docs and client implementations especially the Rust one, I zeroed on my shim's design:

Callback result lifetime must be copied before returning - TigerBeetle callback result memory is explicitly temporary, so the shim must copy any result bytes before handing data to the Unison-visible poll API.

Inflight packet routing via request id/user_data - Existing clients stash inflight state keyed by packet.user_data, then resolve the waiting caller on callback

Non-blocking callback, thread-safe handoff to runtime-facing waiter - The callback should only record completion and signal; heavy work/user logic happens elsewhere.

Here's the rough flow of control

For more details, please checkout the ARCHITECTURE.md

Unison Client

@kaychaks/tigerbeetle | Unison Share

Once the initial version of the shim with a couple of functions was ready, the Unison Tigerbeetle client had some straightforward tasks to complete.

Load the tb_unison_shim DLL via FFI.DLL.open

Load all the symbols following the C ABI captured in tb_unison_shim.h and store them in a SymbolTable, which is a Map of foreign function name to their Specification in Unison, wrapped in an existential type, as the Spec's contained type is not fixed

type SymbolTable
= SymbolTable (Map Text (Exists Spec))

Create all the necessary pointer/newtype wrappers for client handle, request ids, completion structs, status codes.

pinned-byte and pointer helpers to marshal request/result buffers safely.

When Unison gains proper C callback function pointer support, the intended end state is to remove the shim and bind the Unison client directly to the official C client library. And hence the design of the Unison client library is based on abilities, and the current shim is just one handler to that ability. When interfacing with the underlying TB client, a new handler will be created, so consumers of this Unison client library don't have to change their programs. Effect systems FTW.

Here's a basic example of how to interface with Unison client. Detailed instruction on how to have end-to-end flow with live TB replica is present in the README.

https://share.unison-lang.org/@kaychaks/tigerbeetle/code/main/@cpfl9kvd5rt90dhadipahf2834dag3rvj4b6elunop13llm6856o6ub1sbiu9u1urf1qjcvsv0dgmhmc0s0gprpnpfqmn63t8t51858/terms/example/basic