Docs/SDKs & toolsv0.0.1-preview
Java SDK
A feature-complete JVM client — raw, leader-aware, pooled, and buffered — for Java 21 services.
The Java SDK (com.tuladb:tuladb-client) has feature parity with the Python reference client: LedgerClient, SmartLedgerClient, LedgerClientPool, and BufferedLedgerClient, with the full write, read, and batch surface. It targets Java 21. It is not on Maven Central: the jar, its POM, sources and javadoc are in the sdk download. The jar has no dependencies, so it can go straight on the classpath; for Maven, install it into your local repository and depend on com.tuladb:tuladb-client:0.0.1-preview:
mvn install:install-file -Dfile=tuladb-client-0.0.1-preview.jar \
-DpomFile=tuladb-client-0.0.1-preview.pom \
-Dsources=tuladb-client-0.0.1-preview-sources.jar \
-Djavadoc=tuladb-client-0.0.1-preview-javadoc.jar
One ergonomic difference to know up front: account ids are 128-bit values passed as hi/lo long pairs — four longs inside TransferCreate for the debit and credit accounts. Account-addressed reads and updates take an AccountKey(ledgerId, currency, idHi, idLo); AccountKey.of(ledgerId, currency, idLo) covers ids that fit in 64 bits.
First transfer
import com.tuladb.LedgerClient;
import com.tuladb.Models;
import com.tuladb.Models.AccountKey;
try (LedgerClient c = new LedgerClient("127.0.0.1", 7700, 30_000);
LedgerClient q = new LedgerClient("127.0.0.1", 7800, 30_000)) {
c.connect(); // connect() is explicit in Java
q.connect();
var ledger = c.createLedger(Models.LedgerInstance.ofDefault(840));
int ledgerId = ledger.ledgerId();
// (accountHi, accountLo, ledgerId, currency, tier, flags, limitLow, limitHigh);
// the debit side gets a negative floor so it can send before it is funded
c.createAccount(new Models.AccountCreate(0, 1, ledgerId, 840, (short) 0, (short) 1, -1_000_000_000_000L, 1_000_000_000_000L));
c.createAccount(new Models.AccountCreate(0, 2, ledgerId, 840, (short) 0, (short) 2, 0, 1_000_000_000_000L));
var res = c.transfer(new Models.TransferCreate(
1L, // sourceId
ledgerId, // ledgerId
840, // currency
0, 1, // debit account (hi, lo)
0, 2, // credit account (hi, lo)
100, // amount
String.format("java-sdk-%d-000001", ledgerId),
(byte) 0, // flags
0L // ttlNs
));
System.out.printf("%016x%016x%n", res.transferHi(), res.transferLo());
System.out.println(q.getBalance(AccountKey.of(ledgerId, 840, 2)).availableBalance()); // 100
}
TLS
TlsOptions is a record — the argument order is (enabled, verifyPeer, caFile, certFile, keyFile, serverName):
var tls = new LedgerClient.TlsOptions(
true, true,
"/run/secrets/tuladb-ca.crt",
"/run/secrets/client.crt",
"/run/secrets/client.key",
"node1.internal"
);
try (LedgerClient c = new LedgerClient("node1", 7700, 30_000, true, tls)) {
c.connect();
c.ping();
}
TlsOptions.fromEnv() reads the TULADB_CLIENT_TLS_* environment variables. The client pins TLS 1.3/1.2 with modern cipher suites and enforces hostname verification when verifying. Like every TulaDB SDK, it refuses plaintext to non-loopback hosts unless you pass the explicit allowPlaintext constructor argument.
Two-phase transfers
var rr = c.reserve(transferCreate);
String id = com.tuladb.Codec.deriveTransferId(1, 1, 840, "order-42", true);
var rc = c.commit(id); // or c.rollback(id)
Codec.deriveTransferId recomputes the transfer’s identity from the idempotency tuple (sourceId, ledgerId, currency, txnId) — the final boolean selects the 35-character Base62 form (true, the default across SDKs) or 64-character hex.
Recording rejects
An explicit upstream reject records a durable rejected outcome without moving value:
var res = c.reject(new Models.RejectCreate(1L, 1, 840, "order-43", 2_500L, 4207, null));
// sourceId, ledgerId, currency, txnId, amount, reasonCode, custom
// or by key: c.rejectByKey(sourceId, currency, txnId, amount, ledgerId, reasonCode, custom)
The record’s status() is engine-fixed (ERR_UPSTREAM_REJECTED, 41); reasonCode is your own classification, recorded verbatim.
Decimal amounts
Next SDK release. These helpers are on the SDK’s
feat/decimal-amountsbranch and ship with the next SDK release. Every SDK passes the same shared conversion vectors; see Amounts.
CurrencyScales scales = new CurrencyScales(client::getCurrency, client::createCurrency);
long amount = scales.toMinor(124, "800.00"); // 80000 for CAD (scale 2)
String shown = scales.format(124, 80_000L); // "800.00"
Amounts.toMinor("800.000", 2); // AmountFormatException, reason() "too_many_decimals"
Strings and BigDecimal only — never double. Found scales are cached for good; “not registered” (CurrencyNotRegisteredException) is remembered for two seconds. CurrencyCatalog.useRegistry(scales) makes the existing catalog take scales from the engine instead of the bundled ISO file.
Results and errors
A single-op write returns its TransferResult when the item is accepted (0), a duplicate (1), or an explicit reject you asked for (41). Any other item status throws LedgerClient.LedgerProtocolException — a checked IOException — whose statusCode() carries the engine status and whose result() carries the declined item’s decoded result:
try {
var res = c.transfer(t);
} catch (LedgerClient.LedgerProtocolException e) {
Integer status = e.statusCode(); // null for a framing violation; the engine status for a decline
// 2 = ERR_LIMIT, 42 = ERR_AMOUNT_EXCEEDS_MAX, ... a durable decline: handle by code
}
Batch calls never throw for an item: each TransferResult carries its own status(). Check it, or enforce with assertAccepted:
for (var r : c.transferBatch(items)) {
LedgerClient.assertAccepted(r); // throws LedgerTxnStatusException on decline
LedgerClient.assertAccepted(r, false); // also throws LedgerDuplicateTxnException on replay
}
LedgerClient.statusIsFinal(code) tells you whether a status is the same on every retry, and LedgerClient.statusIsRetryable(code) whether nothing was written and a retry with the same identity is safe. LedgerProtocolException classifies its own status with isFinal() / isRetryable(). Transport problems surface as checked IOException; LedgerTxnStatusException (from assertAccepted) is unchecked, with statusCode() and status(). See Errors and rejections.
Retries
SmartLedgerClient (and each LedgerClientPool member) retries up to retryAttempts times after the first attempt. The default is SmartLedgerClient.DEFAULT_RETRY_ATTEMPTS (1).
- Final status. Thrown at once.
- Any other server status. The client sleeps, refreshes the route and retries.
- Transport failure or not-the-leader. The client retries at once the first time, then after the same backoff.
Backoff is full jitter: backed-off retry n sleeps a random time in [0, min(maxDelayMs, initialDelayMs × 2ⁿ)]. Once the budget is spent, or the next sleep would end more than maxElapsedMs after the first attempt, the last failure is thrown.
smart.setRetryPolicy(new SmartLedgerClient.RetryPolicy(50, 2_000, 15_000)); // the defaults
pool.setRetryPolicy(SmartLedgerClient.RetryPolicy.defaults());
Batch replies aren’t retried. Resubmit the items whose status is statusIsRetryable, under their same identities. promoteStandby, transferLeadership, reconfig and createLedger with a server-assigned id (ledgerId 0) have no idempotency key. They are retried only when the failure proves nothing ran: no connection, a not-leader rejection, or a retryable status.
Pooled, buffered writes
One connection
Next SDK release. Java’s single auto-batching client is
BufferedLedgerClient;LedgerClient.Bufferedis removed. The release notes list the migration (transferSync(t)becomestransfer(t)).
Over one connection BufferedLedgerClient is a complete client. Every kind of transfer is queued and coalesced into batch frames: transfer, reserve, commit, rollback, their by-key and tiered forms, and rejects. Whatever queued while the previous frame was on the wire goes out as the next one, so a lone call is sent at once and frames grow with the load, up to 1,024 items. Reads, registry and admin calls wait for what is queued first, then run on the same connection.
try (BufferedLedgerClient buffered = new BufferedLedgerClient(
new LedgerClient("127.0.0.1", 7700, 30_000))) {
buffered.connect();
Models.TransferResult r = buffered.transfer(transfer); // from any thread
Models.Balance b = buffered.getBalance(AccountKey.of(1, 840, accountId));
}
// At most 256 items per frame:
new BufferedLedgerClient(client, /* flushIntervalMs */ 2.0, /* maxBatch */ 256);
A declined item comes back with its status; a refused frame or a transport failure is thrown only to the callers whose items were in it.
Over a pool
LedgerClientPool pool = new LedgerClientPool("ledger://n1:7700,n2:7700", 8, 30_000);
try (BufferedLedgerClient buffered = new BufferedLedgerClient(
BufferedLedgerClient.of(pool), /* flushIntervalMs */ 2.0,
/* pipelineDepth */ 4, /* flushWorkers */ 8)) {
Models.TransferResult r = buffered.transfer(transfer); // safe from any thread
}
Over a pool, only the batched writes go through the buffered client. Anything the pool doesn’t wrap directly is reachable through a borrowed member:
var balance = pool.withClient(c -> c.getBalance(AccountKey.of(1, 840, accountId)));
Explicit batches (transferBatch(items, pipelineDepth)) carry up to 1,024 transfers per frame with up to 64 frames in flight, and are safe to resubmit whole — entries that already landed return status 1 (OK_DUPLICATE), and declined entries answer their original decline.
Note. Every write is a batch on the wire: the single-op methods (
transfer,reserve,commit,createAccount, …) send a batch of one, because the engine accepts only the batch write messages.