Docs/SDKs & toolsv0.0.1-preview

Go SDK

A dependency-free Go client for the TulaDB wire protocol — the raw client surface, by design.

On this page
  1. First transfer
  2. Connecting
  3. The surface
  4. Buffered writes
  5. Decimal amounts
  6. Errors

The Go SDK (github.com/TulaDB/tuladb-sdk/go, package tuladb) is a deliberate port of the raw client: one connection, the full write/read surface, batching and pipelining, plus an auto-batching BufferedLedgerClient in the next SDK release (below). It has no pool or leader-aware routing. If you want those, use the Python or Java SDK, or own routing in your service. For retries, tuladb.Retry wraps one call (every write is idempotent, so retries are safe).

Requires Go 1.21+. The module is not on a public proxy: it is tuladb-go-sdk-v0.0.1-preview.zip in the sdk download, and The module path inside it, github.com/TulaDB/tuladb-sdk/go, goes in go.mod, but nothing is fetched from it (the repository is private): unpack the zip and point a replace at it.

V=0.0.1-preview
unzip -q tuladb-go-sdk-v$V.zip -d third_party
go mod edit -require=github.com/TulaDB/tuladb-sdk/go@v$V \
  -replace=github.com/TulaDB/tuladb-sdk/go=./third_party/github.com/TulaDB/tuladb-sdk/go@v$V

First transfer

import "github.com/TulaDB/tuladb-sdk/go"

c, err := tuladb.Connect("127.0.0.1:7700")
if err != nil { /* ... */ }
defer c.Close()

cur := uint32(840)
res, err := c.Transfer(tuladb.TransferCreate{
    SourceID: 1, LedgerID: 1, Currency: &cur,
    DebitAccountID:  tuladb.U128FromUint64(1001),
    CreditAccountID: tuladb.U128FromUint64(1002),
    Amount: 250, TxnID: "order-42",
})

tid, _ := c.DeriveTransferID(1, 1, 840, "order-42")

bal, err := c.GetBalance(tuladb.NewAccountKey(1, 840, tuladb.U128FromUint64(1001)))

Currency is a *uint32 — always set it. Left nil, the SDK sends LedgerID × 100, a legacy default that names no real currency for most ledgers, and the engine declines the transfer as a currency mismatch. Account ids are a first-class U128 type with helpers: U128FromUint64, U128FromParts(hi, lo), U128FromString. Account-addressed calls take an AccountKey{LedgerID, Currency, ID} (NewAccountKey, or AccountCreate.Key()).

Connecting

tuladb.Connect(addr)                    // refuses non-loopback plaintext
tuladb.ConnectTLS(addr, &tls.Config{RootCAs: pool})   // TLS / mTLS
tuladb.ConnectPlaintext(addr)           // explicit cleartext opt-in

Connect enforces the same rule as every TulaDB SDK: no plaintext ledger traffic off-box. Use ConnectTLS with a standard *tls.Config (add client certificates for mTLS), or ConnectPlaintext only when you genuinely mean it.

The surface

func (c *LedgerClient) Transfer(t TransferCreate) (TransferResult, error)
func (c *LedgerClient) Reserve(t TransferCreate) (TransferResult, error)
func (c *LedgerClient) Commit(transferID string) (TransferResult, error)
func (c *LedgerClient) Rollback(transferID string) (TransferResult, error)
func (c *LedgerClient) Reject(r RejectCreate) (TransferResult, error)   // durable rejected outcome; code recorded verbatim
func (c *LedgerClient) CreateAccount(a AccountCreate) error
func (c *LedgerClient) GetBalance(key AccountKey) (Balance, error)
func (c *LedgerClient) GetBalanceAfter(key AccountKey, minBatchSeq uint64) (Balance, error)   // read-your-writes
func (c *LedgerClient) ListAccountBalances(ledgerID uint32, accountID U128) ([]AccountBalance, error)
func (c *LedgerClient) GetTransaction(transferID string) (TxnRecord, error)
func (c *LedgerClient) GetHistory(key AccountKey, afterSeq uint64, limit uint32) ([]TxnRecord, error)
func (c *LedgerClient) GetHistoryEx(key AccountKey, afterSeq uint64, afterIndex, limit uint32) ([]TxnRecord, error)   // composite cursor
func (c *LedgerClient) TransferBatch(transfers []TransferCreate) ([]TransferResult, error)
func (c *LedgerClient) TransferBatchPipelined(batches [][]TransferCreate, depth int) ([][]TransferResult, error)
func (c *LedgerClient) GetClusterStatus() (ClusterStatus, error)
func (c *LedgerClient) Ping() error

Plus ledger/source/currency registry management (CreateLedger, LedgerSetMaxAmount, LedgerSetExpiry, CreateSource, CreateCurrency, …), account limit and activation updates by AccountKey, the list/search reads, and SetDefaultFlags for connection-wide flag defaults. Page with the composite cursor: GetHistoryEx, and ListTransfers / ListPendingReservations / ListRejectedTransfers(afterSeq, limit, afterIndex) resume from the last record’s BatchSeq and BatchIndex — GetHistory alone can’t resume mid-batch (see pagination). Every write is a batch on the wire: the single-op methods send a batch of one.

Buffered writes

Next SDK release. BufferedLedgerClient is on the SDK’s feat/go-rust-buffered-client branch and ships with the next SDK release. It works the way the Python and Java buffered clients do, with the same defaults.

Many goroutines each posting one transfer at a time get one transfer per round trip on a plain client. BufferedLedgerClient wraps one connection and sends their calls as batch frames. Each caller still gets its own result.

c, err := tuladb.Connect("127.0.0.1:7700")
if err != nil { /* ... */ }
b := tuladb.NewBufferedLedgerClient(c, tuladb.BufferedOptions{}) // the defaults
defer b.Close() // sends anything still queued, then closes c

// from any number of goroutines:
res, err := b.Transfer(ctx, t)
if err != nil { /* invalid request, whole-frame refusal, transport failure, or ctx */ }
if res.Status != tuladb.StatusOK { /* this item was declined */ }
BufferedOptions Default Meaning
BatchSize 32 a frame goes as soon as this many items wait; also the smallest frame
FlushInterval 2 ms the longest the oldest item waits for company; sub-millisecond values work; negative sends whenever the connection is free
MaxBatch 1024 the largest frame
PipelineDepth 1 frames in flight per round trip
  • Transfer, Reserve, Commit, Rollback, the tiered four and Reject are buffered, each kind in its own queue.
  • Frame size follows demand: it doubles after a full frame and halves only after 64 frames in a row under a quarter full, never below BatchSize. A slow engine never shrinks it.
  • As with TransferBatch, a declined item comes back with its Status, not an error. A whole-frame refusal or transport failure is the error of every item in that frame. An item whose ctx ends before it is sent is withdrawn.
  • Reads and other calls flush first, then run directly (Do(ctx, fn) covers anything not wrapped). Flush(ctx) sends what is queued and waits for it.

On a single fsync’d node with 400 goroutines at 10,000 transfers/s, it sustained 10,000 TPS at about 58 transfers per frame (p99 12.6 ms), where the same goroutines calling Transfer on one plain client reached 160 TPS.

Decimal amounts

Next SDK release. These helpers are on the SDK’s feat/decimal-amounts branch and ship with the next SDK release. Every SDK passes the same shared conversion vectors; see Amounts.

scales := tuladb.NewCurrencyScales(client)        // registry-backed cache
amount, err := scales.ToMinor(124, "800.00")      // 80000 for CAD (scale 2)
shown, _ := scales.Format(124, 80_000)            // "800.00"

_, err = tuladb.ToMinor("800.000", 2)             // *AmountError, Reason "too_many_decimals"
// errors.Is(err, tuladb.ErrCurrencyNotRegistered) for a currency with no scale

Found scales are cached for good; “not registered” is remembered for two seconds; scales.CreateCurrency(...) registers and caches at once.

Errors

A single-op write returns err == nil when the item is accepted (0), a duplicate (1), or an explicit reject you asked for (41); res.Status tells those apart. Any other item status is returned as a *tuladb.LedgerError{Status, Message}. Transport and framing problems return ordinary wrapped errors:

res, err := c.Transfer(t)
var le *tuladb.LedgerError
switch {
case errors.As(err, &le):    // a ledger verdict: le.Status is the engine status (2 = ERR_LIMIT, ...)
case err != nil:             // transport or protocol failure — reconnect and resubmit the same identity
case res.Status == tuladb.StatusOKDuplicate:   // idempotent replay — treat as success
default:                     // accepted
}

Batch calls (TransferBatch, TransferBatchPipelined) return a status per item instead of an error.

tuladb.StatusIsFinal and tuladb.StatusIsRetryable classify a status, and tuladb.ErrorIsRetryable classifies an error. This SDK has no leader-aware client, so tuladb.Retry wraps one call instead:

// The third argument is the number of retries after the first attempt
// (tuladb.DefaultRetryAttempts = 1; 0 = call once).
err := tuladb.Retry(ctx, tuladb.DefaultRetryPolicy(), tuladb.DefaultRetryAttempts, func() error {
	_, err := c.Transfer(req) // the same req (same TxnID) on every attempt
	return err
})

tuladb.Retry retries two things:

  • retryable statuses;
  • transport errors, meaning any error that isn’t a *tuladb.LedgerError. Reconnect inside the closure after a dropped connection.

It stops at once on a final or “other” status. The first transport retry is immediate. Every other retry waits a full-jitter delay in [0, min(MaxDelay, InitialDelay × 2ⁿ)]. The defaults are 50 ms initial delay, 2 s maximum delay and 15 s maximum elapsed time. It returns the last error when one of these happens:

  • the budget is spent;
  • ctx is done;
  • the next sleep would end after MaxElapsed or ctx’s deadline.

Don’t wrap an operation that has no identity, such as CreateLedger with LedgerID 0, where the server assigns the id. A retry after a transport error could run it twice.

See Errors and rejections for the full status table.