Here's a preview of something I'm working on:
app [main!] { pf: platform "../platform/main.roc" }
import pf.Stdout
import pf.TigerBeetle as Tb
# Requires a local TigerBeetle running on 127.0.0.1:3000 (cluster 0):
# tigerbeetle format --cluster=0 --replica=0 --replica-count=1 0_0.tigerbeetle
# tigerbeetle start --addresses=3000 0_0.tigerbeetle
main! : List(Str) => Try({}, [Exit(I32)])
main! = |_args| {
# Two valid accounts plus one invalid (id 0) to show both result paths.
accounts = [
Tb.Account.init({ id: 1, ledger: 700 }).code(10),
Tb.Account.init({ id: 2, ledger: 700 }).code(10),
Tb.Account.init({ id: 0, ledger: 700 }).code(10),
]
Stdout.line!("Creating ${accounts.len().to_str()} accounts...")
results = Tb.create_accounts!(accounts)
for { status, timestamp } in results {
msg = match status {
Created => " created (timestamp=${timestamp.to_str()})"
Exists => " exists (timestamp=${timestamp.to_str()})"
IdMustNotBeZero => " error: id must not be zero"
LedgerMustNotBeZero => " error: ledger must not be zero"
CodeMustNotBeZero => " error: code must not be zero"
_ => " error: other"
}
Stdout.line!(msg)
}
Ok({})
}
output:
![]()
Is this embedded in tigerbeetle? like a plugin script?
No. I started with your Zig platform template and linked in the Tigerbeetle C client. Tigerbeetle is running as a separate process.
So I guess basically the same way the Official tigerbeetle clients for Node, Go, etc work except that mine is nowhere near feature complete :sweat_smile:
What API did you go with for crossing over to the host from the platform? Any issues with roc glue?
The only issue with Roc glue is one place where it's using the old pre 0.16 IO. There is some marshaling required for the tigerbeetle account struct because Roc orders the fields differently.
I had Claude do a basic semaphore thing since the tb client is async and runs on a separate thread. I need to review that to a) understand it fully and b) find opportunities for optimizations/cleanup.
WIP here for the curious https://github.com/sandprickle/roc-tigerbeetle-demo
Bryce Miller said:
I need to review that to a) understand it fully and b) find opportunities for optimizations/cleanup.
For example, seems like there's got to be a better way to handle this :sweat_smile:
// Roc reorders record fields, so marshal field-by-field rather than reinterpret.
const tb_accounts = gpa.alloc(tb.Account, n) catch fatal("out of memory");
defer gpa.free(tb_accounts);
for (roc_accounts, tb_accounts) |src, *dst| {
dst.* = .{
.id = src.id,
.debits_pending = src.debits_pending,
.debits_posted = src.debits_posted,
.credits_pending = src.credits_pending,
.credits_posted = src.credits_posted,
.user_data_128 = src.user_data_128,
.user_data_64 = src.user_data_64,
.user_data_32 = src.user_data_32,
.reserved = 0,
.ledger = src.ledger,
.code = src.code,
.flags = src.flags,
.timestamp = src.timestamp,
};
}
At minimum pre-allocate a buffer
Very open to suggestions on anything and everything here
I guess maybe a couple allocations per batch isn't terrible necessarily.
Bryce Miller said:
There is some marshaling required for the tigerbeetle account struct because Roc orders the fields differently.
oh yeah I meant to change this so that nominal records try to order the fields sorted by alignment (just like today) and then by declaration order (as opposed to alphabetical like today). would that fix the mismatch?
this is very cool @Bryce Miller! :smiley:
Richard Feldman said:
Bryce Miller said:
There is some marshaling required for the tigerbeetle account struct because Roc orders the fields differently.
oh yeah I meant to change this so that nominal records try to order the fields sorted by alignment (just like today) and then by declaration order (as opposed to alphabetical like today). would that fix the mismatch?
Hmm lemme confirm the layout
Based on the generated header file it looks like that maybe wouldn't fix the issue since the struct has a u64 at the very end. Unless I'm reading this C wrong which is very possible :sweat_smile:
typedef struct tb_account_t {
tb_uint128_t id;
tb_uint128_t debits_pending;
tb_uint128_t debits_posted;
tb_uint128_t credits_pending;
tb_uint128_t credits_posted;
tb_uint128_t user_data_128;
uint64_t user_data_64;
uint32_t user_data_32;
uint32_t reserved;
uint32_t ledger;
uint16_t code;
uint16_t flags;
uint64_t timestamp;
} tb_account_t;
hmm interesting
so the reason I'd want to sort by alignment is to avoid wasted space due to alignment padding (and I wouldn't want to just follow the order specified and insert alignment padding, because then end users would be on the hook for manually ordering their fields correctly to avoid unwanted padding, which would be super annoying)
but in this case it looks like there wouldn't be any alignment padding
because it goes u64, then 2x u32, then 1x u32 followed by 2x u16, and finally the u64 at the end
so I'd see no downside with keeping a field ordering like that exactly as-is :thumbs_up:
we'd just maybe need a fancier reorder-to-avoid-padding algorithm to support that, but it's probably worth it
for scenarios like this
I was going to say that I think your design choice makes perfect sense, but if it were possible to get declaration ordering when it doesn't mess with padding that would be cool.
What is wrong with our existing order by alphabetical (after alignment) rule?
just that this particular host happens to use a different ordering than that
it would be ideal if we could declare a roc type that lines up perfectly with their struct type, because then we wouldn't need to convert between the two
we could just pass it directly to/from the host because the bytes in memory would already be exactly what both sides expect
so perf would improve
I don't have a great intuition for how much of a performance hit the conversion is. On the one hand TB is supposed to be uber fast, but on the other hand we are making a network request. I can't remember whether the TigerBeetle client allows more than one in-flight request. I'm thinking not but I'm not positive?
So you're thinking of having Roc accept configurable ordering of record fields, based on what a platform host wants?
no, just make it so that if you declare a nominal record, we take the ordering you declared for the fields into account more than we do today
ok, just nominal records... that makes sense
in super rare situations this can also matter for perf within your Roc program
It's kind of cool that you can support such a low-level need with that
like if you have a really big record and some sections of it get accessed together whereas other sections don't, it could affect which ones are in cache lines together :stuck_out_tongue:
Now we just need support for variable bit width integers, and we'll start eating Zig's lunch :wink:
yeah I'll aim to make a PR for it tomorrow
(the nominal record field ordering thing - I'll aim for making it possible to have this exact struct work across the boundary)
I'm flabergasted by how fast things are landing lately. Or maybe the pace has been this quick for months and I just started paying attention :sweat_smile:
PR machine goes brrrrr
I feel like its a similar work rate... but instead of enormous PR's with hundreds of commits we're back to more normal sized PR's
We're polishing more now, rather than re-wiring whole sections of the compiler internals
... and now I've gone and said that, Richard will open a PR with hundreds of commits that makes things 10x faster :sweat_smile:
Now there’s one concept with roc that I don’t fully understand yet. Such a lib (for using tigerbeetle if I understand correctly) in theory would need to be used with other platforms not be the platform itself, or not? As in theory you’d want your ?cli app? to use tiger beetle and x and y. Or am I missing something here?
I think for something this low-level it has to be platform-specific. But was there a plan to allow package imports in platform Roc code? If so, would that allow platform devs to share an interface between platforms?
At the very least, I'd like to get the Roc api to a point where anyone can just yoink it for their own platform if they need a TigerBeetle client.
Isn't the key difference here that TB uses a custom protocol... so not something a generic platform would provide?
Or maybe it's just TCP and in theory this could be a Roc package -- with the added responsiblity now of getting all the intermediate parts right
Yeah I think that’s exactly right. I don’t think you can implement it using TCP effects like you can for e.g. Postgres
Yeah, but that’s the same for example for Redis and the like, right? I‘m just wondering right now each underlying „library“ seems to be done as as platform. That to me seems to lead to fracture of the ecosystem as you by chance would need to find something that has exactly your stack which is unlikely which in turn would be more or less the requirement to also know platform dev :sweat_smile: but might be mistaken here
I think their wire, memory, and disk format are all the same bytes so they can just avoid serialization/deserialization
Ah okay, so very specific for tigerbeetle
Yeah I think so. I suppose it might be different if they published an open protocol for it at some point maybe.
same for example for Redis and the like, right?
I think Redis is a TCP socket, with a custom protocol over it... so any Roc platform that provides a TCP primitive will be supportable
Speaking of... I guess the next version of basic-cli should drop to TCP and let roc-lang/http implement the HTTP protocol specifics on top right?
Seems like it would be possible to create a platform that lets you configure some add ons as part of the build process, so you could decide whether you want your webserver to include sqlite, duckdb, tigerbeetle, or none of them. That might reduce the need to fork platforms and customize them.
Maybe some combination of build flags for the host and a script that adds/removes roc modules from main.roc
you can throw all the symbols in the prebuilt host binary, and Roc will just dead code eliminate that when you build -- based on what you actually use
Oh ok yeah than this would be really simple
It's a bigger maintenance surface though... so I think the trend will be towards standardised interfaces like TCP
Certainly to unblock people you can fork a platform, wrap an existing C library (or other Zig/Rust crate etc) and be working in no time... but there is an incentive there to write things in pure Roc
Oh wait are you saying that even if I had a webserver platform that included 5 db clients (both roc interfaces and host stuff), my binary would only include what I actually use?
I was more thinking for situations where there isn’t a standardized interface like TCP available
my binary would only include what I actually use?
yes
Dope!
I might be wrong though... worth double checking with @Richard Feldman -- Roc linking DCE's hosted effects from the host if they aren't called by Roc?
yeah it's set up to do that
I don't think it's been super battle tested, but should be easy enough haha
just add some giant dep to the host, don't use it on the Roc side at all, then roc build and see how much (if at all) the output binary size changes :smile:
also, just a quick note: for anything where the fundamental ting you need to do is send bytes over a socket (redis, any other database, I assume TigerBeetle too), there is a 100% chance you can write that in pure Roc as a platform-agnostic package :smile:
now that said, you may not want to do that - or at least, not at first
it's always been part of the plan to have hosts be able to bootstrap the ecosystem - in other words, at first you reuse existing C/Zig/Rust/etc. code in the host to provide that functionality, but then later you want a pure-Roc, platform-agnostic version, which motivates doing that implementation
so you can get unblocked quickly by bundling it with the platform, and then unbundle it later
as an example, here's a WIP (and not updated to the new compiler) example of a pure-Roc postgres client: https://github.com/agu-z/roc-pg
all the platform has to do is to provide the primitive of "send bytes over a socket" and then this platform-agnostic package does all the postgres-specific stuff
same strategy would work for any other db
Yeah thats a good point. It occurred to me after our earlier conversation that since I have to specify a port when connecting to a TB cluster, it has to be using either TCP or UDP (TCP, as it happens) :sweat_smile:
I do shudder at the idea of trying to re-implement the TigerBeetle client logic in any language. I'm very happy to leave that project to somebody else :sweat_smile:. I doubt it would be easier than adding the official client to a platform (which was pretty easy!)
I think that's probably true of most databases haha
but I also think you'd be surprised! some people shudder at the idea, and other people are excited by it
it's not very often you find an excuse to build a real-world byte-level database client that people will actually use, if that sounds like something you'd find interesting! :smiley:
see roc-pg for example :smile:
Interestingly, I was thinking I might end up wanting to use roc-pg at some point, in which case I might help port it to the new compiler if I have time. After that maybe I’ll eat my words about implementing db clients lol
Richard Feldman said:
(the nominal record field ordering thing - I'll aim for making it possible to have this exact struct work across the boundary)
@Bryce Miller this is on main now btw!
I believe I've finished wiring up all of the TigerBeetle API. You can clone and try it out here https://github.com/sandprickle/roc-tigerbeetle-demo.
Thanks to the recent record layout changes, I was able to go from 4 allocations per batch to 2 allocations per batch, if I'm counting right: One for the request, and one for the response.
Right now I just block the Roc thread while we wait for the client to respond. I think there might be a little bit of perf we could gain by being async, because even thought the TB client only allows one in-flight request at a time, it can do some prep work on batches while it waits for responses.
I attempted to run some benchmarks to compare this to the Go and Rust clients, but my single-node local cluster was performing very inconsistently. Not sure whether there is actually a good way to benchmark these but I certainly haven't found it if so :sweat_smile:.
that sounds like a great start, congrats on getting it working! :smiley:
Thanks in no small part to glue and Luke’s templates :grinning:
Tigerbeetle wants to be run alongside another database though, so now I’m eyeing my next target :sweat_smile:
are there any compiler bugs you still need to work around?
I'm running low on non-in-progress bug reports :laughing:
Well I think maybe dbg only works in the interpreter? That didn't bother me, but there was a test I inherited from the platform template that only works with the interpreter.
Partway through I needed to remove the 'targets' entry in main.roc, I guess it just knows where to look for the build artifacts now? I almost filed an issue when my main.roc stopped parsing but then I figured it out :sweat_smile:
I'm really not thinking of anything though.
Oh! I thought of one! There was one instance where Roc wasn't populating the unnamed fields with 0 and TigerBeetle complained. I want to say it was with a debug build of the Roc compiler but I'd have to double check that. I was able to do a production build and that fixed it.
oh interesting
there's an innate tradeoff there which is that it takes time to zero them out, but maybe that's best
Ah, Is it something I can zero out myself in e.g. TigerBeetle.Account.init()?
not at the moment because you can't assign to unassigned fields :laughing:
but I'll just change it to zero them out, it's probably better for security
Ok that's what I thought but just wanted to make sure I hadn't missed something :sweat_smile:
Yeah in this particular case TigerBeetle insists that all reserved fields are set to zero. Totally something I could do today by just removing the underscore on the field though.
I have an unnamed field with type U32 and the value as seen by the host is ._pad0 = { 1, 0, 0, 0 }
oh that's a good point
yeah I guess that's actually a good idea since that way you can get the perf if that's what you want
Yeah I figured it was at least worth consideration. all the other TigerBeetle clients have their reserved fields exposed and you are expected to set them to zero. So it would be par for the course in that regard.
Right now do unnamed fields just contain whatever bytes happen to be there?
I have no idea how it works now but I'm wondering if I made a ridiculously large unnamed field would I be able to just read whatever was previously in memory there?
*on the host side
yeah but the host can already read whatever it wants to haha
the host has sudo on your process basically
nothing is safe from it
That makes sense. I suppose the operating system prevents the host from reading memory from another process.
So for values that the application developer will construct, I can use normal record fields with a custom number type such as the following for reserved fields:
## 6 byte reserved field that must be 0
Reserved6 :: (U16, U16, U16).{
from_numeral : Numeral -> Try(Reserved6, [InvalidNumeral(Str), ..])
from_numeral = |numeral| match U8.from_numeral(numeral) {
Ok(0) => Ok(Reserved6.(0, 0, 0))
_ => Err(InvalidNumeral("reserved must be 0"))
}
}
But for values the application developer will never construct, I can keep the reserved fields as _reserved
And this feels nice. All tomfoolery is prevented at compile time.
Hey I heard you were looking for bug reports so I made one! #9859
(looks like field ordering change regressed)
oh oops, I forgot to mention this - try adding _ : {} somewhere in the declaration
I had to change it so that we no longer to it by default and that's how you opt in
the problem was that "would we need to insert padding if we kept this ordering?" can have a different answer if you're building for 32-bit vs 64-bit targets
and I started thinking about edge cases related to "what if you're building on a 64-bit machine but targeting 32-bit build" and in the end I decided the least error-prone design is "if you have an unnamed field, we will keep your ordering guaranteed and insert any missing padding as necessary, and if you don't have any unnamed fields then we'll reorder them for you"
Ok, so if I have unnamed fields, I’m accepting declaration order and any padding that may result, even if it’s more than the minimum possible due to inefficient ordering. If I don’t have any unnamed fields, I’m guaranteed the minimum possible padding but fields may be reordered. Is that a correct understanding?
yep!
Got it!
Last updated: Jul 23 2026 at 13:15 UTC