この資料は英語です
chobo のリファレンスのうち、言語・形式・出力先の三つは英語だけで書いています。日本語で読めるのは、このサイトの日本語のページと、診断コードの一覧です。
What chobo build writes
A book becomes code for PostgreSQL, for TigerBeetle or for the MySQL family (MySQL, TiDB and
Vitess), and a client that calls it from TypeScript, Python, Go or Rust. Every target answers every
scenario as the reference interpreter does: the tests run each scenario of the examples and the
test books through every client, on PostgreSQL, TigerBeetle, MySQL, TiDB and Vitess, and compare
what each client sent with chobo run --show.
| Target | What it writes |
|---|---|
postgres |
<book>.sql: a schema, its tables and constraints, and a function for each operation |
postgres-typescript |
<book>.ts: a client that calls those functions through pg |
postgres-python |
<book>.py: the same, through a DB-API connection (psycopg 3 or psycopg2) |
postgres-go |
<package>/book.go and runtime.go: the same, through pgx |
postgres-rust |
<module>.rs: the same, an async client through sqlx |
tigerbeetle-typescript |
<book>.ts: a client that sends TigerBeetle one chain of transfers for each operation, through tigerbeetle-node |
tigerbeetle-python |
<book>.py: the same, through tigerbeetle (ClientSync) |
tigerbeetle-go |
<package>/book.go and runtime.go: the same, through tigerbeetle-go |
tigerbeetle-rust |
<module>.rs: the same, an async client through the tigerbeetle crate |
mysql |
<book>.sql: the book's three tables and their constraints, for MySQL, TiDB and Vitess |
mysql-typescript |
<book>.ts: a client that makes each operation one transaction on those tables, through mysql2 |
mysql-python |
<book>.py: the same, through a DB-API connection (PyMySQL or mysqlclient) |
mysql-go |
<package>/book.go and runtime.go: the same, through database/sql and go-sql-driver/mysql |
mysql-rust |
<module>.rs: the same, an async client through sqlx |
The clients of every target have the same names, arguments and answers, so changing the database
changes how the book value is made, and nothing else. chobo api lists the names each target
uses, for a tool that calls them without reading the code (formats.md). In a project
of ritsu's languages, ritsu gen --books postgres, --books tigerbeetle or --books mysql
(PostgreSQL unless it is given) writes the clients of every book into the project's package for
each language, under books/, and the SQL of PostgreSQL or of the MySQL family beside them as
books/<book>.sql. There, an amount of money or of a quantity that an operation takes is the
package's type of its unit, the one the package's rules take and return: the sale of a book in
USD scale 2 takes to_shop: units.USDc in TypeScript and Python and To_shop units.USDc in Go,
so code written by hand that gives the book what a rule returns is held to the book's unit by the
type checker. An amount of a count (pcs, seats) stays a plain integer, as it does everywhere
chobo build writes it, and so does a balance.
PostgreSQL
writes db/inventory.sql, which can run any number of times: it makes what is missing and
replaces the functions. A book is a schema ("inventory") with four tables: accounts (a row an
account, with its balance and the bounds it was made with), holds, keys (every key used, what
it was called with, and what came of it) and entries (every change to an account, so that a
balance can be added up again). Each operation is a function, "<transfer>_<operation>", taking
a tenant (a set of balances of its own; '' for one book of balances) and the transfer's
parameters, and answering a result and a reason. With psql:
select * from inventory.receive_do('', 'd-1', 'A-1', 3);
select * from inventory.reserve_hold('', 'o-1', 'A-1', 2);
select * from inventory.reserve_hold('', 'o-2', 'A-1', 2);
select * from inventory.reserve_post('', 'o-1', 'A-1');
select inventory.reserve_status('', 'o-1', 'A-1') as state;
select * from inventory.balance_stock('', 'A-1');
result | reason
--------+--------
done |
(1 row)
result | reason
--------+--------
done |
(1 row)
result | reason
---------+--------------
refused | out_of_stock
(1 row)
result | reason
--------+--------
done |
(1 row)
state
--------
posted
(1 row)
posted | held_in | held_out
--------+---------+----------
1 | 0 | 0
(1 row)
- One write. A function locks the rows of the accounts it moves, in the order of their IDs, checks every move against the balances it read, and writes them only when every move goes through. READ COMMITTED is enough: the check reads nothing but the rows it locked. Since every function takes the account rows in the same order, two of them never wait on each other in a circle.
- Your transaction. A function runs in the caller's transaction, so an order and the hold of its stock can commit together. The clients neither commit nor roll back. Under REPEATABLE READ or SERIALIZABLE, a call can fail to serialize; the clients retry it with the same arguments, up to thirty times, about a second and a half of waits in all (the key keeps it from moving twice).
- Expiry. PostgreSQL has no clock that acts on its own. A hold past its expiry answers
expiredto a post or a void at once, but what it holds still counts untilexpire()lets it go: callselect inventory.expire()from pg_cron or a job of your own, one at a time. It lets go of up to 1000 holds a call, and answers how many. - A last guard. Two
CHECKconstraints onaccountsstop a write that does not go through the functions from breaking a bound. An account row whose bounds differ from the book's (a bound changed without a new account kind, whichchobo check --diff-baserefuses) stops every call that names it with the errorCB001. - Names. A schema, a function or a parameter has to fit PostgreSQL's 63 bytes (21 characters of Japanese), or the build stops (E061): PostgreSQL would cut the name short without a word.
TigerBeetle
TigerBeetle runs no code of yours, so the client does the work: for each operation it sends one chain of linked transfers in one request, which TigerBeetle applies whole or not at all.
- The accounts. Every account of the book is an account of TigerBeetle, whose balance is
credits less debits, as chobo's is what came in less what went out. A move is a transfer from
the account it takes from (debit) to the one it puts into (credit). A unit is a ledger, and the
IDs are made from the keys, so that a retry sends the same IDs and TigerBeetle answers
exists. - The bounds. A lower bound of 0 is the flag
debits_must_not_exceed_credits. Any other bound is kept with accounts and transfers chobo adds to the chain: a lower bound above 0 by a hold of that much made and voided right after the move, a lower bound below 0 by an account of the room down to it, and an upper bound by an account of the room up to it, each moved in the same chain as the move. One move becomes six transfers at the most. - Before a post or a void, the client reads the hold; a hold that is not there answers
no_such_holdwithout sending anything, so that a post that comes before its hold never makes the hold impossible to post. The accounts are made before the first operation that names them. - Expiry is TigerBeetle's own: a hold's transfers carry its timeout, and TigerBeetle lets them go when it passes.
- The state of a hold is read by sending a chain TigerBeetle always refuses: a void of the hold, linked to a transfer from an account to itself. Why the void would have been refused says whether the hold is held, posted, voided or expired, and nothing is written.
- Size. A chain has to fit one request: 253 transfers on a replica started with
--development, which is what chobo is tested on, and 8189 on one started without it. The build stops at 253 (E060), so that a book that builds also runs on the replica it can be tried on. Past it, split the transfer into kinds with fewer moves.
Two answers differ from the reference interpreter, and the tests do not reach either:
- When a key a bound refused comes again with other amounts, the reference interpreter
answers
already_refused. TigerBeetle forbids only the ID of the transfer it refused, so a transfer before it in the new chain can be refused first, by its own bound, and the client answers that bound's reason. Nothing moves either way; only the name of the reason differs. A retry with the same arguments answersalready_refusedon both. - TigerBeetle keeps balances of 128 bits, so an operation that takes a balance past what 64 bits
hold goes through, and reading the balance with
balancethen fails; the reference interpreter and PostgreSQL fail the operation itself.
MySQL, TiDB and Vitess
writes db/refunds.sql, which can run any number of times: it makes what is missing. MySQL 8.4 or
later, TiDB 8.5 or later and Vitess all speak MySQL's protocol and read this SQL alike. It holds no
function: TiDB has no stored procedures, and a service that runs Vitess may allow neither stored
routines nor making a database. So the client sends the statements of each call itself, in one
transaction. The checks are in those statements, which chobo writes, and the client only fills in
the values and picks the next statement from what the last one answered. The three tables are made
in the database the SQL runs in, named after the book:
refunds_accounts: a row an account, with its balance (posted,held_in,held_out) and the bounds it was made with, under two checks,refunds_within_lowerandrefunds_within_upper;refunds_calls: a row eachdoandholdthat used its key, with what it was called with and what came of it; the row of a hold is the hold, with its state, its moves, its expiry and what was posted;refunds_entries: every change to an account, so that a balance can be added up again.
chobo run --show mysql prints what a client sends for each operation of a scenario, one
statement at a time, with the values in place. Before the first call that names an account, a book
value makes sure of the account: it reads the row, and makes it when it is not there.
$ chobo run examples/refunds/refunds.book --scenario examples/refunds/refunds.more.json --show mysql
examples/refunds/refunds.book (more: two refunds of 20.00 at the same time on a sale of 30.00, then one of 10.00): 3 steps, as sent to MySQL (tenant "")
1 sale.do(order: A-100, amount: 3000)
makes sure of the accounts: 069c57ca75a83bb1b62d3735a168e8ae, 5e57413e175e6a7135612aa241f9cdb7
begin /*T! pessimistic */
insert into `refunds_calls` (`tenant`, `id`, `tenant_name`, `kind`, `op`, `key`, `content`, `definition`, `result`, `at`) values (X'e73bedd05928880def5276190c9048f5', X'be4165a032199c2adfdc384159a28ab2', _utf8mb4 X'', 'sale', 'do', _utf8mb4 X'5b22412d313030225d', _utf8mb4 X'7b226f72646572223a22412d313030222c22616d6f756e74223a333030307d', '82c5af592e8dc8dc57e86ce5aad47be5', 'done', utc_timestamp(6))
insert into `refunds_entries` (`tenant`, `call_id`, `n`, `account`, `at`, `kind`, `op`, `key`, `d_posted`, `d_held_in`, `d_held_out`, `folded`) values (X'e73bedd05928880def5276190c9048f5', X'be4165a032199c2adfdc384159a28ab2', 1, X'069c57ca75a83bb1b62d3735a168e8ae', utc_timestamp(6), 'sale', 'do', _utf8mb4 X'5b22412d313030225d', -3000, 0, 0, 0), (X'e73bedd05928880def5276190c9048f5', X'be4165a032199c2adfdc384159a28ab2', 2, X'5e57413e175e6a7135612aa241f9cdb7', utc_timestamp(6), 'sale', 'do', _utf8mb4 X'5b22412d313030225d', 3000, 0, 0, 1)
update `refunds_accounts` set `posted` = `posted` + (case when `id` = X'5e57413e175e6a7135612aa241f9cdb7' then 3000 else 0 end) where `tenant` = X'e73bedd05928880def5276190c9048f5' and `id` in (X'5e57413e175e6a7135612aa241f9cdb7')
commit
…
3 refund.hold(request: R-3, order: A-100, amount: 1000)
makes sure of the accounts: 5e57413e175e6a7135612aa241f9cdb7, 32282c2ff4dcedae062c27e267ffe99c
begin /*T! pessimistic */
insert into `refunds_calls` (`tenant`, `id`, `tenant_name`, `kind`, `op`, `key`, `content`, `definition`, `result`, `at`, `state`, `moves`, `deadline`) values (X'e73bedd05928880def5276190c9048f5', X'005e0f73c51630b29d539a3d028722a2', _utf8mb4 X'', 'refund', 'hold', _utf8mb4 X'5b22522d33225d', _utf8mb4 X'7b2272657175657374223a22522d33222c226f72646572223a22412d313030222c22616d6f756e74223a313030307d', 'dbcdce25aa3073392b15088650849fad', 'done', utc_timestamp(6), 'held', '[{"from":"5e57413e175e6a7135612aa241f9cdb7","to":"32282c2ff4dcedae062c27e267ffe99c","amount":"1000"}]', utc_timestamp(6) + interval 604800 second)
select hex(`id`), case when `id` = X'5e57413e175e6a7135612aa241f9cdb7' and `posted` - (`held_out`) - 1000 < 0 then 1 else 0 end from `refunds_accounts` where `tenant` = X'e73bedd05928880def5276190c9048f5' and `id` in (X'5e57413e175e6a7135612aa241f9cdb7') order by `id` for update
insert into `refunds_entries` (`tenant`, `call_id`, `n`, `account`, `at`, `kind`, `op`, `key`, `d_posted`, `d_held_in`, `d_held_out`, `folded`) values (X'e73bedd05928880def5276190c9048f5', X'005e0f73c51630b29d539a3d028722a2', 1, X'5e57413e175e6a7135612aa241f9cdb7', utc_timestamp(6), 'refund', 'hold', _utf8mb4 X'5b22522d33225d', 0, 0, 1000, 1), (X'e73bedd05928880def5276190c9048f5', X'005e0f73c51630b29d539a3d028722a2', 2, X'32282c2ff4dcedae062c27e267ffe99c', utc_timestamp(6), 'refund', 'hold', _utf8mb4 X'5b22522d33225d', 0, 1000, 0, 0)
update `refunds_accounts` set `held_out` = `held_out` + (case when `id` = X'5e57413e175e6a7135612aa241f9cdb7' then 1000 else 0 end) where `tenant` = X'e73bedd05928880def5276190c9048f5' and `id` in (X'5e57413e175e6a7135612aa241f9cdb7')
commit
- One write. A call is one transaction. A
door aholdfirst inserts its own row intorefunds_calls: a key used before stops it there, with a duplicate key (1062), and the client reads the earlier call to answerdone_before,key_conflictoralready_refused. Then it locks the rows of the accounts with bounds, in the order of their IDs, and the read itself says, for each row, the first move that would take it past a bound (thecase, 0 for none); the earliest of them is the move refused. The update after it works the new balances out itself. A transfer with no bound to check, such as the sale above, reads nothing: its update locks the rows as it writes them. Every read in a transaction is a locking read, which sees the latest commit, so the calls work alike under READ COMMITTED and REPEATABLE READ. - Your transaction, or one of its own. A call is part of the transaction its connection is
in, and a transaction of its own when the connection is in none. Given a pool (a
Poolof mysql2, a*sql.DB) or a*sql.Conn, each call is a transaction of its own; a*sql.Txis the caller's. A connection of mysql2 is asked first, at every call that writes (set @chobo = 0, one round trip more), and a DB-API connection says it itself: in a transaction the caller began, or with autocommit off, the call is part of it; with autocommit on and no transaction open, it is one of its own. mysqlclient does not say whetherbegin()opened a transaction, so on its connection a transaction of yours is one with autocommit off. In a transaction of its own, after a deadlock (1213), a lock wait timeout (1205) or a write conflict of TiDB's (9007, 8022, 8028), the client rolls it back and makes it again with the same arguments, up to thirty times (the key keeps it from moving twice). In the caller's, it sends neitherbeginnorcommit, and an error comes back as it is, for the caller to roll back. An order and the hold of its stock commit together, or neither does. The book value then makes sure of the accounts at every call, since a rollback may take away a row it made. A connection, like a transaction, serves one call at a time: two calls at once on one would mix their statements, so give calls at the same time a pool. batch. A book value made withbatchsends the statements a call sends in a row as one request, which the pool or the connection has to allow (multipleStatementsin mysql2,CLIENT.MULTI_STATEMENTSin PyMySQL,multiStatements=truein go-sql-driver/mysql). Adoor aholdthat checks a bound then keeps its locks over one round trip, and any other call over none. The answers are the same.- Expiry. As on PostgreSQL, nothing acts on the clock by itself: a hold past its expiry answers
expiredto a post or a void at once, but what it holds still counts untilexpire()lets it go. Call it from a job of your own, one at a time. It lets go of up to 1000 holds a call, each in a transaction of its own, and answers how many. - The accounts without bounds. No check reads an account without bounds, such as one outside
the book, so the clients never lock it: a move into it or out of it is an entry, and its balance
is its row with the entries not yet added to it.
expire()adds them. Two refunds wait on each other only when they take from the same order'srefundable, never forrefunded, which every refund puts into. - Keys as bytes. A key and a tenant are compared as the bytes of their IDs, never as text:
MySQL's default collation takes
A-1anda-1for one key, andutf8mb4_binignores spaces at the end. The values in a statement are hex and digits, so it means the same under anysql_mode,ANSI_QUOTESandNO_BACKSLASH_ESCAPESamong them, whatever a key holds. - A last guard. The two checks stop a write that does not go through the clients from breaking
a bound. An account row whose bounds differ from the book's stops every call that names it with
the error
CB001. - Names. A name in MySQL is 64 characters at most, and holds no character beyond the Basic
Multilingual Plane (𠮷 and the like), which MySQL writes as
?with only a warning. The tables and the checks are named after the book, so the build stops (E062) for a book's name of more than 51 characters (_within_lowerfollows it), or with such a character in it. A character of Japanese counts as one, so a name can be longer than on PostgreSQL. - TiDB. Run the SQL with
tidb_enable_check_constraintON, a global variable that is OFF by default: a table made without it keeps no check, and turning it on later adds none. The clients keep the bounds either way.begin /*T! pessimistic */starts a pessimistic transaction on TiDB whatevertidb_txn_modesays, and MySQL and Vitess read what followsbeginas a comment. - Vitess. The SQL and the clients keep to what a service that runs Vitess allows: they make no
database, call no stored routine, and need no
sql_modethat changes how a statement is read, which vtgate lets no session set. In a sharded keyspace, shard the three tables bytenant, the first column of each (a vindex such asxxhash): a tenant's rows are then on one shard, and so is every call. Vitess does not commit across shards all or nothing by default, so a transaction of yours that writes rows of your own with a call keeps them on the tenant's shard too. The tests run every scenario on Vitess 24.0.5 (vttestserver), in one shard and in two by the tenant, where a transaction across shards fails.
Calling it from TypeScript, Python, Go and Rust
postgres(db, { tenant }), tigerbeetle(client, { tenant }) and mysql(db, { tenant, batch })
make the book value in TypeScript; in Python, postgres(conn, tenant=""),
tigerbeetle(client, tenant="") and mysql(conn, tenant="", batch=False) take the same names as
keyword arguments; in Go, Postgres(q, tenant), TigerBeetle(c, tenant) and
MySQL(q, tenant, batch) take a context.Context first in every call. An amount is a bigint in
TypeScript, an int in Python and an int64 in Go. A refusal comes back as an answer; a failure
(no connection, a wrong argument) as an exception, or an error in Go.
TypeScript, on a TigerBeetle replica (chobo build examples/inventory/inventory.book --target
tigerbeetle-typescript):
import { createClient } from "tigerbeetle-node";
import { tigerbeetle } from "./inventory.ts";
const client = createClient({ cluster_id: 0n, replica_addresses: ["127.0.0.1:3399"] });
const book = tigerbeetle(client);
console.log(await book.receive.do({ delivery: "d-1", sku: "A-1", qty: 3n }));
console.log(await book.reserve.hold({ order: "o-1", sku: "A-1", qty: 2n }));
console.log(await book.reserve.hold({ order: "o-2", sku: "A-1", qty: 2n }));
console.log(await book.reserve.post({ order: "o-1", sku: "A-1" }, { qty: 1n }));
console.log(await book.reserve.status({ order: "o-1", sku: "A-1" }));
console.log(await book.balance.stock({ sku: "A-1" }));
client.destroy();
{ result: 'done' }
{ result: 'done' }
{ result: 'refused', reason: 'out_of_stock' }
{ result: 'done' }
posted
{ posted: 2n, held_in: 0n, held_out: 0n }
The post of 1 of the 2 held lets the other one go back on the shelf. The same calls on MySQL
(--target mysql-typescript, beside the tables of --target mysql in a database shop) answer
the same; only the book value is made otherwise, here from a pool of mysql2 that allows more than
one statement a request, so that the calls go in batches:
import { createPool } from "mysql2/promise";
import { mysql } from "./inventory.ts";
const pool = createPool({ host: "127.0.0.1", port: 33316, user: "root", database: "shop", multipleStatements: true });
const book = mysql(pool, { tenant: "shop-4", batch: true });
console.log(await book.receive.do({ delivery: "d-1", sku: "A-1", qty: 3n }));
console.log(await book.reserve.hold({ order: "o-1", sku: "A-1", qty: 2n }));
console.log(await book.reserve.hold({ order: "o-2", sku: "A-1", qty: 2n }));
console.log(await book.reserve.post({ order: "o-1", sku: "A-1" }, { qty: 1n }));
console.log(await book.reserve.status({ order: "o-1", sku: "A-1" }));
console.log(await book.balance.stock({ sku: "A-1" }));
console.log(await book.expire());
await pool.end();
{ result: 'done' }
{ result: 'done' }
{ result: 'refused', reason: 'out_of_stock' }
{ result: 'done' }
posted
{ posted: 2n, held_in: 0n, held_out: 0n }
0
No hold was past its expiry, so expire() let none go. Python, on PostgreSQL
(--target postgres-python):
import psycopg
from inventory import postgres
with psycopg.connect("host=/tmp/pg port=5432 dbname=shop", autocommit=True) as conn:
book = postgres(conn, tenant="shop-2")
print(book.receive.do(delivery="d-1", sku="A-1", qty=3))
print(book.reserve.hold(order="o-1", sku="A-1", qty=2))
print(book.reserve.hold(order="o-1", sku="A-1", qty=2))
print(book.reserve.hold(order="o-1", sku="A-1", qty=1))
print(book.reserve.void(order="o-1", sku="A-1"))
print(book.reserve.status(order="o-1", sku="A-1"))
print(book.balance.stock(sku="A-1"))
Result(result='done', reason=None)
Result(result='done', reason=None)
Result(result='done_before', reason=None)
Result(result='refused', reason='key_conflict')
Result(result='done', reason=None)
voided
Balance(posted=3, held_in=0, held_out=0)
Python, on TiDB (--target mysql-python), in the caller's transaction: a connection of PyMySQL
has autocommit off unless it is asked for, so each call is part of the transaction the caller
commits or rolls back, and the hold rolled back is not there to read.
import pymysql
from inventory import mysql
conn = pymysql.connect(host="127.0.0.1", port=4016, user="root", database="shop")
book = mysql(conn, tenant="shop-5")
print(book.receive.do(delivery="d-1", sku="A-1", qty=3))
conn.commit()
print(book.reserve.hold(order="o-1", sku="A-1", qty=2))
conn.rollback()
print(book.reserve.status(order="o-1", sku="A-1"))
print(book.reserve.hold(order="o-1", sku="A-1", qty=2))
conn.commit()
print(book.reserve.status(order="o-1", sku="A-1"))
print(book.balance.stock(sku="A-1"))
Result(result='done', reason=None)
Result(result='done', reason=None)
None
Result(result='done', reason=None)
held
Balance(posted=3, held_in=0, held_out=2)
The key of the hold rolled back was never used, so the same hold goes through after it. Go, on
PostgreSQL (--target postgres-go), with a pgx pool:
b := inventory.Postgres(pool, "shop-3")
fmt.Println(b.Receive.Do(ctx, inventory.ReceiveArgs{Delivery: "d-1", Sku: "A-1", Qty: 3}))
fmt.Println(b.Reserve.Hold(ctx, inventory.ReserveArgs{Order: "o-1", Sku: "A-1", Qty: 5}))
fmt.Println(b.Reserve.Hold(ctx, inventory.ReserveArgs{Order: "o-2", Sku: "A-1", Qty: 3}))
fmt.Println(b.Reserve.Post(ctx, inventory.ReserveKey{Order: "o-2", Sku: "A-1"}, nil))
fmt.Println(b.Reserve.Status(ctx, inventory.ReserveKey{Order: "o-2", Sku: "A-1"}))
fmt.Println(b.Balance.Stock(ctx, inventory.StockAccount{Sku: "A-1"}))
fmt.Println(b.Expire(ctx))
{done } <nil>
{refused out_of_stock} <nil>
{done } <nil>
{done } <nil>
posted <nil>
{0 0 0} <nil>
0 <nil>
Rust's clients are async, and every call is an async fn: postgres(db, tenant),
tigerbeetle(client, tenant) and mysql(db, tenant, batch) make the book value, whose method for
each transfer kind gives its operations (book.reserve().hold(…); do is written r#do, a
keyword of Rust's). An argument is a struct of the transfer's parameters, the strings borrowed
(ReserveArgs { order: "o-1", sku: "A-1", qty: 2 }), and an amount is an i64. A call answers
Result<Outcome, Error>: Outcome::Done, Outcome::DoneBefore, or Outcome::Refused(Reason),
where Reason is an enum of the book's reasons and chobo's own (Reason::OutOfStock,
Reason::KeyConflict, …; as_str() gives the name as the book writes it), and Error is a
failure, no answer. Rust on PostgreSQL (--target postgres-rust), with a pool of sqlx, and in a
transaction of the caller's:
mod inventory;
use inventory::{ReceiveArgs, ReserveAmounts, ReserveArgs, ReserveKey, StockAccount};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let pool = sqlx::PgPool::connect("postgres://localhost/shop").await?;
let book = inventory::postgres(pool.clone(), "shop-6");
println!("{:?}", book.receive().r#do(ReceiveArgs { delivery: "d-1", sku: "A-1", qty: 3 }).await?);
println!("{:?}", book.reserve().hold(ReserveArgs { order: "o-1", sku: "A-1", qty: 2 }).await?);
println!("{:?}", book.reserve().hold(ReserveArgs { order: "o-2", sku: "A-1", qty: 2 }).await?);
println!("{:?}", book.reserve().post(ReserveKey { order: "o-1", sku: "A-1" }, Some(ReserveAmounts { qty: 1 })).await?);
println!("{:?}", book.reserve().status(ReserveKey { order: "o-1", sku: "A-1" }).await?);
println!("{:?}", book.balance().stock(StockAccount { sku: "A-1" }).await?);
// in a transaction of the caller's: the hold goes with its rollback
let mut tx = pool.begin().await?;
let in_tx = inventory::postgres(tokio::sync::Mutex::new(&mut *tx), "shop-6");
println!("{:?}", in_tx.reserve().hold(ReserveArgs { order: "o-3", sku: "A-1", qty: 1 }).await?);
drop(in_tx);
tx.rollback().await?;
println!("{:?}", book.reserve().status(ReserveKey { order: "o-3", sku: "A-1" }).await?);
println!("{:?}", book.expire().await?);
Ok(())
}
Done
Done
Refused(OutOfStock)
Done
Some(Posted)
Balance { posted: 2, held_in: 0, held_out: 0 }
Done
None
0
A book value takes the pool itself, or one connection or transaction of sqlx's behind a
tokio::sync::Mutex, through which its calls go one at a time: what a call is given is a trait of
the client's (Database), which these implement, and which a type of yours can implement too, to
count or log what is sent. On TigerBeetle (--target tigerbeetle-rust), the book value takes the
crate's Client, a reference to it, or an Arc of it:
let client = tigerbeetle::Client::new(0, "127.0.0.1:3399")?;
let book = inventory::tigerbeetle(&client, "");
println!("{:?}", book.receive().r#do(ReceiveArgs { delivery: "d-1", sku: "A-1", qty: 3 }).await?);
println!("{:?}", book.reserve().hold(ReserveArgs { order: "o-1", sku: "A-1", qty: 2 }).await?);
println!("{:?}", book.reserve().hold(ReserveArgs { order: "o-2", sku: "A-1", qty: 2 }).await?);
println!("{:?}", book.reserve().void(ReserveKey { order: "o-1", sku: "A-1" }).await?);
println!("{:?}", book.reserve().status(ReserveKey { order: "o-1", sku: "A-1" }).await?);
println!("{:?}", book.balance().stock(StockAccount { sku: "A-1" }).await?);
On the MySQL family (--target mysql-rust), the book value takes sqlx's MySqlPool, each call a
transaction of its own on a connection of the pool, or a connection behind a tokio::sync::Mutex:
a call on a Transaction of sqlx's is part of it, and the caller commits it; on a connection in no
transaction of sqlx's, a call is a transaction of its own there. sqlx does not say whether the
server has a transaction open, so a transaction begun with a begin of your own, or with
autocommit set off, is not told apart: begin it with sqlx (pool.begin()). sqlx asks MySQL for
PIPES_AS_CONCAT when it connects, which Vitess's vtgate does not let a session set; on Vitess,
connect with .pipes_as_concat(false) (the statements of chobo read the same either way). sqlx
always lets a request hold more than one statement, so batch needs nothing of the connection.
The outputs are what these printed, against a throwaway PostgreSQL cluster, a TigerBeetle replica
started with --development, a MySQL 8.4.11 server, and a TiDB 8.5.8 cluster whose tables were
made with tidb_enable_check_constraint ON; the connection strings of psycopg and sqlx stand for
the ones they ran with. A name of the book that is Japanese is exported in Go with an X in front
(b.X引当.Hold), a name that is a keyword of Python gets a _ after it, and one that is a keyword
of Rust is written as a raw identifier (r#type; self_ for the four it cannot be). No transfer
kind is called balance or expire in a client, whatever the book calls it. A Rust client is one
module: it holds what every book shares beside the book's own part, as the TypeScript and Python
files do, and its head says the crates it needs, at the versions its tests run. The module is the
book's name in lower case when that is an ASCII identifier, else the file's name, else book, as
Go's package: a module named in Japanese would take a #[path] attribute in Rust. rustc warns of a
name with a character NFKC would change, such as a full-width letter or a half-width kana
(uncommon_codepoints), and the warning is turned off only at the root of the crate the module is
in (#![allow(uncommon_codepoints)]), not in the module.
The clients' own needs
| Target | What the client is given |
|---|---|
postgres-typescript |
something with query(text, values): a pg Client, Pool or PoolClient |
postgres-python |
a DB-API connection of psycopg 3 or psycopg2 |
postgres-go |
something with QueryRow: pgx's *pgx.Conn, *pgxpool.Pool or pgx.Tx |
tigerbeetle-typescript |
a client of tigerbeetle-node 0.17.9 |
tigerbeetle-python |
a ClientSync of tigerbeetle 0.17.9 |
tigerbeetle-go |
a client of github.com/tigerbeetle/tigerbeetle-go v0.17.9, which uses cgo |
mysql-typescript |
a pool or a connection of mysql2's promise API (mysql2/promise); for batch, one made with multipleStatements |
mysql-python |
a DB-API connection of PyMySQL or mysqlclient; for batch, one made with CLIENT.MULTI_STATEMENTS |
mysql-go |
database/sql's *sql.DB, *sql.Conn or *sql.Tx through github.com/go-sql-driver/mysql, which the client imports to read the error numbers; for batch, multiStatements=true in the DSN |
postgres-rust |
sqlx's PgPool, or a tokio::sync::Mutex of a &mut PgConnection, a Transaction or a PoolConnection of sqlx's; in the crate's Cargo.toml, sqlx 0.9.0 with runtime-tokio and postgres, and tokio with sync and time |
tigerbeetle-rust |
the Client of the tigerbeetle crate 0.17.9, a reference to one, or an Arc of one; in Cargo.toml, tigerbeetle = "=0.17.9" |
mysql-rust |
sqlx's MySqlPool, or a tokio::sync::Mutex of a &mut MySqlConnection, a Transaction or a PoolConnection; in Cargo.toml, sqlx 0.9.0 with runtime-tokio and mysql, and tokio with sync and time |
The TypeScript clients use only types that can be stripped as they are, so Node.js runs them as
written, and tsc --strict compiles them. The Rust clients build on the 2021 and the 2024
editions with no warning, clippy's lints among them; TigerBeetle's client does not depend on an
async runtime, sqlx's runs on tokio. The tests run them with Node.js 23.11, Python 3.13, Go 1.26
and Rust 1.94, on PostgreSQL 18.0, TigerBeetle 0.17.9, MySQL 8.4.11, TiDB 8.5.8 and Vitess 24.0.5,
with pg 8.23.1, psycopg 3.3.6, pgx 5.11.0, mysql2 3.24.5, PyMySQL 1.2.3, go-sql-driver/mysql
1.10.1, sqlx 0.9.0, tokio 1.54.1 and the tigerbeetle crate 0.17.9; mysqlclient 2.3.0 answered as
PyMySQL does in a run by hand.