Skip to content

Repository files navigation

Ledger Beetle

A double-entry accounting service in front of a TigerBeetle cluster. It exposes the same eight ledger operations over two transports (a JSON HTTP API and a gRPC API) on top of one shared service layer, so both speak to the ledger through identical validation, batching and event publishing.

  HTTP :8080  --+
                |
                +--  account/transfer services  --  TigerBeetle cluster
                |             |
  gRPC :9090  --+             +--  events (noop | Kafka | RabbitMQ)

Quick start

cp config.example.yaml config.yaml
make setup   # format the three TigerBeetle replica files into ./data (once)
make up      # build and run the service and the 3-replica cluster

Then:

  • HTTP API on http://localhost:8080, Swagger UI at http://localhost:8080/docs/
  • gRPC API on localhost:9090, with server reflection and the standard health service
  • make down stops the stack; make restart cycles it

make setup is only needed once: it refuses to overwrite replica files that already exist, so starting from an empty ledger means removing ./data first. The directory is gitignored.

Docker Desktop: enable host networking (Settings > Resources > Network > Enable host networking). The compose stack runs every container with network_mode: host as instructed by TigerBeetle.

Configuration

The binary reads config.yaml from its working directory (mounted into the container from the repo root). Every key is optional and falls back to the default. config.example.yaml lists them all with their defaults:

section what it sets
tigerbeetle cluster ID and replica addresses
http listen address and the read/write/idle/shutdown timeouts
grpc listen address, shutdown timeout, max receive message size
logging level (debug/info/warn/error) and format (json/text)
event driver (noop/kafka/rabbitmq), its connection details and drain timeout

config.yaml is gitignored - it is the local copy, not the committed one.

API

Both transports cover the same operations:

operation HTTP gRPC
create accounts POST /account AccountService.CreateAccounts
look up accounts by ID GET /account?id=1,2,3 AccountService.ListAccounts
query accounts by filter GET /account/query AccountService.QueryAccounts
an account's balance history GET /account/{id}/balance AccountService.ListAccountBalances
create transfers POST /transfer TransferService.CreateTransfers
look up transfers by ID GET /transfer?id=1,2,3 TransferService.ListTransfers
query transfers by filter GET /transfer/query TransferService.QueryTransfers
an account's transfers GET /account/{id}/transfer TransferService.ListAccountTransfers

Plus GET /health on HTTP and the standard gRPC health service. Both report not serving while the process drains, so a load balancer takes the instance out of rotation from the health check itself rather than from the error every other route starts answering with.

Both servers run concurrently and share a lifetime: the first to return cancels the context the other runs under, so one failing shuts the process down cleanly rather than leaving half a service listening.

A few conventions the whole API follows:

  • 128-bit values are decimal strings. IDs, amounts and user_data_128 exceed what JSON numbers and protobuf integers carry safely, so they cross the wire as strings on both transports. Timestamps (nanoseconds since the Unix epoch) likewise.
  • Creates are partial. Every request index comes back in exactly one of created or failed: one rejected item fails alone instead of failing the call. A 201/OK therefore does not mean everything landed - check failed. Items that already existed with exactly the fields submitted count as created; existing pairs each of their request indices with its position in created, which is what a recognised retry looks like.
  • Supplying an ID is what makes a retry safe. Resubmitting an identical create is recognised by its ID and reported as existing rather than duplicated. Omitting the ID has the server mint one, which is fine for fire-and-forget work but leaves nothing to retry against. Self-generated IDs should follow TigerBeetle's time-based scheme rather than being random.
  • Validation reports every field at fault, keyed (invalid_user_data_128) so a client can switch on the reason without parsing prose, and indexed by batch position.
  • Batches larger than TigerBeetle's 8189-event request cap are split server-side (src/chunk), without ever cutting a linked chain across two requests. A single chain longer than the cap is rejected rather than folded.

HTTP docs

The HTTP API is described by an OpenAPI 3.1 document served at /openapi.yaml, with a Swagger UI at /docs/. Both are served from the binary itself - the UI's assets are embedded, so the docs work without outbound internet access.

The document is generated from the handler DTOs and the route table in src/httpsrv/openapi_doc.go, and committed to src/httpsrv/openapidoc/openapi.yaml because the image build has no generate step. Regenerate it after changing a DTO or a route:

make openapi

make test fails while the committed document and the generator disagree.

Protocol buffers

The gRPC API is defined in src/grpcsrv/proto/ledger/v1/, and generates the Go package ledger-beetle/grpcsrv/gen/ledger/v1 into src/grpcsrv/gen/ledger/v1/. The generated code is committed, so a .proto change is only complete once the bindings have been regenerated and committed with it:

make proto        # regenerate src/grpcsrv/gen/ledger/v1/*.pb.go
make proto-lint   # check the .proto files against buf's standard rules

src/grpcsrv/gen/ belongs to the generator: make proto prunes anything in it that the current .proto files no longer produce, so nothing handwritten should live there.

Nothing outside the Go toolchain has to be installed. make proto compilesbuf from src/grpcsrv/tools into a gitignored src/grpcsrv/bin/ on first use, and the protoc plugins are tool dependencies of src/go.mod, so both are pinned and every machine generates byte-identical output.

Events

Successful creates are published as JSON, one message per account or transfer, from a background goroutine so publishing never sits in the request path. The driver is chosen by event.driver: noop (the default) drops them, kafkaand rabbitmq publish for real. Shutdown drains the in-flight publishes within event.publish_shutdown_timeout - the items are already committed to the ledger, so they are still published after the request that produced them returns.

Testing

make test        # unit tests, race detector on
make test-e2e    # brings the compose stack up, runs ./e2e against it, tears it down

The e2e suite is behind the e2e build tag and drives both transports against a live server; it points at E2E_BASE_URL / E2E_GRPC_ADDR (defaulted by the Makefile) so it can be aimed at an already-running deployment.

Load testing

cmd/loadtest drives a weighted mix of all eight operations over one transport at a time and reports throughput, latency percentiles and error buckets:

make test-load                                     # http, default flags
make test-load TRANSPORT=grpc ARGS="-duration 10s"
make test-load-docker TRANSPORT=grpc ARGS="-concurrency 5000"

test-load-docker runs the generator as a sibling container on the stack's network, avoiding a host-to-container port-forwarding hop that skews high-concurrency numbers. See src/cmd/loadtest/README.md for the flags and the report format.

Postman

postman/ holds two mirror collections in Postman's multi-file format 3.0 (Ledger Beetle HTTP and Ledger Beetle gRPC), each split into Endpoints (one request per operation, carrying every documented parameter, the optional ones shipped disabled) and Scenarios (the flows the ledger is actually used through, including the recipes from the TigerBeetle docs). Every folder is self-contained: it creates the accounts it needs and stores their IDs in collection variables, so folders run in any order, repeatedly, against a cluster that already holds data.

Import the directory into Postman and point the Dev environment at a running stack. Everything in both collections is a request meant to succeed, and the tests check data.failed as well as the status code, since a 201 from a create is not the same as every item landing.

Limitations

This is a general implementation, not a deployment-ready one. Read this before running it anywhere real.

It is a starting point, meant to be extended. Nothing here is specialised to a particular domain or deployment, and the obvious extension points are left open on purpose: mappings between the ledger's generic fields (user_data_*, code, ledger) and whatever they mean to you, traffic optimization (caching, rate limiting, connection and batch tuning), security(there is no authentication or authorization on any endpoint, and no TLS termination), and HTTP headers. Add what your deployment needs; none of it is assumed here.

The event publishers are fire-and-forget. A create commits to the ledger first and publishes afterwards, from a background goroutine, with no durable record linking the two. The publish is retried in process with backoff, but once the attempts are exhausted the event is logged and dropped, and a crash between the commit and the publish loses it silently. There is no ordering guarantee, no dead-lettering and no way to replay. Production event handling wants something like a transactional outbox, or at minimum a durable queue and a reconciliation path against the ledger.

Tests share the development data directory. make setup formats the replicas in ./data, and make test-e2e and the load generator then run against that same cluster, so test rows and load-generated rows accumulate in the same files as everything else. TigerBeetle never deletes, so this only grows - the three replica files reach gigabytes quickly under load testing. A separate data directory (or a throwaway cluster) for tests is a possible fix; make setup in a scratch directory and a tigerbeetle.addresses override is enough to get one.

There is no CI. Nothing runs on push: make test, make test-e2e and make proto-lint only run manually. make test does catch a stale OpenAPI document, but nothing catches stale protobuf bindings - a .proto edited without make proto still compiles against the previously generated code, so the wire contract and its definition drift apart quietly.

Layout

src/
  main.go        wiring: config, logger, TigerBeetle client, event driver, both servers
  config/        YAML config and its defaults
  account/       account service + TigerBeetle repository
  transfer/      transfer service + TigerBeetle repository
  chunk/         splitting oversized batches without cutting linked chains
  event/         publishers: noop, Kafka, RabbitMQ, and the drain-aware Group
  validation/    field-level validation errors shared by both transports
  logging/       request-scoped logger in the context
  httpsrv/       HTTP handlers, DTOs, OpenAPI generation and the embedded docs
  grpcsrv/       gRPC servers, protos, generated bindings, interceptors
  e2e/           build-tagged end-to-end suite, both transports
  cmd/loadtest/  load generator
  cmd/openapi/   OpenAPI document generator
postman/         Postman collections (format 3.0)

About

A double-entry accounting service in front of a TigerBeetle cluster.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages