Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0529edf
perf: work out how to read a statement's rows once, not once a query
Taure Aug 10, 2026
ed0cd58
fix: a connection's statement prefix is always a binary
Taure Aug 10, 2026
62eab2a
test: measure the work a query costs, not the wait
Taure Aug 10, 2026
1418f0a
spike: the socket module as a second transport, opt in
Taure Aug 10, 2026
ed7ae57
feat: TLS and LISTEN over the socket transport
Taure Aug 10, 2026
2e4c8fe
feat: the socket transport waits the way Windows waits, and CI proves it
Taure Aug 10, 2026
e9c09c5
test: a peer that hangs up mid-startup is not called the same thing e…
Taure Aug 10, 2026
e41d8e5
fix: connect on Windows, and stop asserting the bits of a NaN
Taure Aug 10, 2026
9ac75e5
test: two suites that assumed a machine, found by running them on ano…
Taure Aug 10, 2026
a82d6b9
test: match a span's stop to its own start by context
Taure Aug 10, 2026
3f8a670
test: a refusal Windows resets before it can be read
Taure Aug 10, 2026
924fe97
ci: give the Windows runner a certificate, so TLS is tested there too
Taure Aug 10, 2026
9c067ac
ci: two PostgreSQLs on Windows, plain and TLS, as the compose file gi…
Taure Aug 10, 2026
7e73bcd
ci: split the Windows common test run so a hang names its suite
Taure Aug 10, 2026
9df70ad
ci: the split needs one argument per line, not a folded one
Taure Aug 10, 2026
970d436
ci: put the server certificate where the suite reads it
Taure Aug 10, 2026
8eceb93
ci: one common test step again, now that it says nothing new
Taure Aug 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions .github/workflows/windows.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
name: Windows

on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'guides/**'
- 'LICENSE'
- '.gitignore'
pull_request:
branches: [main]
paths-ignore:
- '**.md'
- 'guides/**'
- 'LICENSE'
- '.gitignore'

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true

# The socket transport waits for bytes differently here: Windows completes a
# read and hands the bytes over in the message, where POSIX says the socket is
# readable and leaves them in it. Nothing on a Linux runner exercises that path,
# so it runs here or it is not tested at all.
jobs:
windows:
runs-on: windows-latest
timeout-minutes: 20
env:
MINATO_PG_HOST: 127.0.0.1
MINATO_PG_PORT: '5432'
MINATO_PG_USER: minato
MINATO_PG_PASSWORD: minato
MINATO_PG_DATABASE: minato_test
MINATO_TLS_HOST: localhost
MINATO_TLS_PORT: '55433'
steps:
- uses: actions/checkout@v4

- uses: erlef/setup-beam@v1
with:
otp-version: '28'
rebar3-version: '3'

# The compose file gives Linux two servers, one plain and one with TLS on,
# and the suites are written against that. There is no compose here, so
# the runner's own server is the plain one and a second cluster is made
# for TLS rather than turning TLS on for both - a test that a server
# declining TLS is refused needs a server that declines.
- name: Certificates for the TLS server
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path test/certs | Out-Null
openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes `
-keyout ca.key -out test/certs/ca.crt -subj "/CN=minato test CA" `
-addext "basicConstraints=critical,CA:TRUE"
openssl req -newkey rsa:2048 -nodes -keyout server.key -out server.csr `
-subj "/CN=localhost"
Set-Content -Path server.ext -Value @(
'subjectAltName=DNS:localhost',
'basicConstraints=CA:FALSE',
'extendedKeyUsage=serverAuth'
)
openssl x509 -req -in server.csr -CA test/certs/ca.crt -CAkey ca.key `
-CAcreateserial -out test/certs/server.crt -days 3650 -sha256 -extfile server.ext

- name: Start the plain PostgreSQL the runner already has
shell: pwsh
run: |
$service = Get-Service -Name 'postgresql*' | Select-Object -First 1
if (-not $service) { throw 'no PostgreSQL service on this runner' }
Set-Service -Name $service.Name -StartupType Manual
Start-Service -Name $service.Name
$env:PGPASSWORD = 'root'
& "$env:PGBIN\psql" -U postgres -c "CREATE USER minato WITH PASSWORD 'minato' SUPERUSER"
& "$env:PGBIN\psql" -U postgres -c "CREATE DATABASE minato_test OWNER minato"
& "$env:PGBIN\psql" -U postgres -c "SHOW ssl"

- name: Start a second PostgreSQL with TLS on
shell: pwsh
run: |
$data = "$env:RUNNER_TEMP\minato-tls"
Set-Content -Path "$env:RUNNER_TEMP\pw.txt" -Value 'root' -NoNewline
& "$env:PGBIN\initdb" -D $data -U postgres --auth=scram-sha-256 `
--pwfile="$env:RUNNER_TEMP\pw.txt" -E UTF8
Copy-Item test/certs/server.crt "$data\server.crt"
Copy-Item server.key "$data\server.key"
Add-Content -Path "$data\postgresql.conf" -Value @(
'ssl = on',
"ssl_cert_file = 'server.crt'",
"ssl_key_file = 'server.key'",
'port = 55433',
"timezone = 'UTC'"
)
& "$env:PGBIN\pg_ctl" -D $data -l "$env:RUNNER_TEMP\tls.log" start
Start-Sleep -Seconds 5
$env:PGPASSWORD = 'root'
& "$env:PGBIN\psql" -U postgres -h localhost -p 55433 `
-c "CREATE USER minato WITH PASSWORD 'minato' SUPERUSER"
& "$env:PGBIN\psql" -U postgres -h localhost -p 55433 `
-c "CREATE DATABASE minato_test OWNER minato"
& "$env:PGBIN\psql" -U postgres -h localhost -p 55433 -c "SHOW ssl"

- name: Compile
run: rebar3 compile

- name: EUnit
run: rebar3 eunit

- name: Common Test
timeout-minutes: 10
run: rebar3 ct

- name: What the TLS server logged, if anything went wrong
if: failure()
shell: pwsh
run: Get-Content "$env:RUNNER_TEMP\tls.log" -Tail 50
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ rebar3.crashdump
doc/
bom.xml
bench/js/node_modules/
bench/py/.venv/
test/certs/*.crt
test/certs/*.key
90 changes: 63 additions & 27 deletions bench/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ bench/run.sh 2000
```

The Erlang clients always run. The JavaScript ones need `cd bench/js && npm
install`, and the Python ones need `asyncpg` and `psycopg[binary]` importable;
the runner says which it skipped rather than leaving them silently out.
install` and the Python ones need `bench/py/install.sh`, which puts `asyncpg`
and `psycopg[binary]` in `bench/py/.venv`; the runner says which it skipped
rather than leaving them silently out.

`pgo` here is `erleans/pgo` from Hex, not the fork this repository pins as a
test transport.
Expand Down Expand Up @@ -47,39 +48,74 @@ clients in the same run, and anything under about ten percent is noise.
## A run

2000 iterations, OTP 29, PostgreSQL 17 in Docker on the same machine, times in
microseconds per operation.

| workload | minato | epgsql | pgo | node-postgres | postgres.js |
| --- | --- | --- | --- | --- | --- |
| simple | **313** | 337 | - | 354 | 384 |
| unnamed | 612 | 678 | - | **401** | 711 |
| cached | 342 | **307** | 436 | 374 | 384 |
| rows_1000 | 834 | **589** | 3842 | 905 | 745 |
| rows_5000 | 2248 | **1806** | 16076 | 2257 | 2197 |
| wide_row | 381 | **380** | 646 | 457 | 444 |
| insert | 378 | 350 | 471 | **310** | 346 |
| concurrent | 913 | 1501 | 1226 | 877 | **828** |
microseconds per operation, mean.

| workload | minato | epgsql | pgo | node-postgres | postgres.js | asyncpg | psycopg3 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| simple | **322** | 347 | - | 366 | 351 | 380 | 379 |
| unnamed | 629 | 666 | - | 414 | 750 | **399** | 432 |
| cached | 360 | **339** | 467 | 354 | 377 | 392 | 389 |
| rows_1000 | 639 | **576** | 3662 | 728 | 842 | 584 | 580 |
| rows_5000 | **1429** | 1861 | 15428 | 1868 | 1994 | 1584 | 1581 |
| wide_row | 373 | **371** | 663 | 384 | 439 | 389 | 403 |
| insert | 413 | 369 | 489 | **354** | 386 | 405 | 379 |
| concurrent | 1037 | 1451 | 976 | **752** | 847 | 1751 | 1167 |

What the numbers say:

- **Bulk framing is worth what it claimed.** `pgo` reads a header and then a
payload per message, and at 5000 rows it costs 16 ms against everybody else's
2 ms. That is the one enormous difference in the table, and it is the
payload per message, and at 5000 rows it costs 15 ms against everybody else's
1.5 to 2 ms. That is the one enormous difference in the table, and it is the
difference this client was written around.
- **epgsql is fast, and faster than minato on row-heavy reads.** 589 us against
834 at a thousand rows. epgsql has had fifteen years of tuning and it shows;
the gap is worth chasing and there is now a number to chase it against.
- **epgsql is still ahead at a thousand rows and behind at five thousand.**
minato does about thirty percent fewer reductions than epgsql at both sizes,
so whatever is left at a thousand is not decoding work.
- **Describing before binding costs a round trip**, which is what the `unnamed`
row is: 612 us against 342 for the same query cached. That is the price of not
row is: 629 us against 360 for the same query cached. That is the price of not
guessing parameter types, and `minato:query/3` pays it once per connection
rather than once per call.
- **The Node clients are not slower than the BEAM ones.** Anybody expecting a
scripting language to lose here should look at `insert` and `concurrent`
again. Most of a query's cost is waiting for the server, and every client here
is good at waiting.
rather than once per call. asyncpg and node-postgres are quicker on that row
because they infer the types instead of asking.
- **The Node and Python clients are not slower than the BEAM ones.** Anybody
expecting a scripting language to lose here should look at `insert` and
`concurrent` again. Most of a query's cost is waiting for the server, and
every client here is good at waiting.
- **`concurrent` is where a pool shows up.** epgsql has none of its own, so its
eight callers get eight connections and it still comes last; minato and the
Node pools are within noise of each other.
eight callers get eight connections and it is still second from last.

## Where the work goes

Wall clock against a database on the same machine is mostly the round trip, and
the differences between clients hide in the noise of whatever else the machine
is doing. `minato_profile` measures the work instead:

```
rebar3 as bench compile
erl -noshell -pa _build/bench/lib/*/ebin _build/bench/lib/minato/bench \
-eval 'minato_profile:cost(2000), init:stop().'
```

Reductions are not microseconds, but they do not move with the machine, the
server or the load, so a five percent change is a change rather than a
run-to-run wobble. Per operation, same run as the table above:

| workload | minato | epgsql | pgo |
| --- | --- | --- | --- |
| simple | 439 | **289** | - |
| unnamed | 766 | **677** | - |
| cached | 434 | **348** | 1205 |
| rows_1000 | **20303** | 29497 | 152118 |
| rows_5000 | **101072** | 151245 | 757563 |
| wide_row | **1335** | 1657 | 2744 |
| insert | 426 | **391** | 1001 |

minato does less work per row than either and more work per statement. The
per-statement difference is most of what it charges for the things epgsql does
not do at all: a telemetry span around every query, a deadline that cancels the
statement on the server rather than abandoning the connection, and a lookup in
the per-connection statement cache.

`minato_profile:eprof(Workload)` runs one workload under `eprof`, for when the
question is which function rather than how much.

## Honesty about this benchmark

Expand Down
2 changes: 2 additions & 0 deletions bench/minato_bench.erl
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ clients on the same run, and a difference under about ten percent is noise.
""".

-export([main/1, run/1, iterations/1, json/1]).
%% For minato_profile, which measures the same workloads a different way.
-export([setup/1, prepare_schema/2, parsed/2, runner/3, teardown/2]).

-define(DEFAULT_ITERATIONS, 2000).
-define(DEFAULT_WARMUP, 200).
Expand Down
150 changes: 150 additions & 0 deletions bench/minato_profile.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
-module(minato_profile).
-moduledoc """
Where a query's time goes, rather than how long it takes.

`bench/run.sh` measures wall clock, which against a database on the same machine
is mostly the round trip: every client waits the same and the differences hide in
the noise. This module measures the work instead.

- `cost/0` runs each workload on each Erlang client and reports total system
reductions and scheduler run time per operation. Reductions are not
microseconds, but they do not move with the machine, the server or the load,
so a change of five percent is a change rather than a run-to-run wobble.
- `eprof/1` runs one minato workload under `eprof` and prints where the calls
are.

```
docker compose -f test/docker-compose.yml up -d
rebar3 as bench compile
erl -noshell -pa _build/bench/lib/*/ebin _build/bench/lib/minato/bench \\
-eval 'minato_profile:cost(), init:stop().'
```
""".

-export([cost/0, cost/1, eprof/1, eprof/2]).

-define(DEFAULT_ITERATIONS, 2000).
-define(DEFAULT_EPROF_ITERATIONS, 200).

-doc "Reductions and run time per operation, every workload on every client.".
-spec cost() -> ok.
cost() ->
cost(iterations()).

-doc "`cost/0` over a given number of iterations.".
-spec cost(pos_integer()) -> ok.
cost(Iterations) ->
_ = erlang:system_flag(scheduler_wall_time, true),
Clients = [minato, pgo, epgsql],
Ready = [{Client, setup(Client)} || Client <- Clients],
Rows = [
{Workload, Client, measure(Client, Workload, Setup, Iterations)}
|| Workload <- workloads(), {Client, Setup} <- Ready
],
report(Rows, Iterations),
_ = [teardown(Client, Setup) || {Client, Setup} <- Ready],
ok.

-doc "Run one minato workload under `eprof` and print the profile.".
-spec eprof(atom()) -> ok.
eprof(Workload) ->
eprof(Workload, ?DEFAULT_EPROF_ITERATIONS).

-doc "`eprof/1` over a given number of iterations.".
-spec eprof(atom(), pos_integer()) -> ok.
eprof(Workload, Iterations) ->
Setup = setup(minato),
Run = runner(minato, Workload, Setup),
repeat(Run, 100),
{ok, _Pid} = eprof:start(),
profiling = eprof:start_profiling(processes()),
repeat(Run, Iterations),
profiling_stopped = eprof:stop_profiling(),
eprof:analyze(total),
eprof:stop(),
teardown(minato, Setup),
ok.

%%----------------------------------------------------------------------
%% Measuring the work rather than the wait
%%----------------------------------------------------------------------

measure(Client, Workload, Setup, Iterations) ->
case runner(Client, Workload, Setup) of
unsupported ->
unsupported;
Run ->
Count = count(Workload, Iterations),
repeat(Run, min(200, Count)),
_ = erlang:statistics(reductions),
Before = scheduler_time(),
{_Total, _Since} = erlang:statistics(reductions),
Start = erlang:monotonic_time(microsecond),
repeat(Run, Count),
Elapsed = erlang:monotonic_time(microsecond) - Start,
{_Again, Reductions} = erlang:statistics(reductions),
Busy = scheduler_time() - Before,
#{
reductions => Reductions / Count,
busy_us => Busy / Count,
elapsed_us => Elapsed / Count
}
end.

scheduler_time() ->
lists:sum([Active || {_Id, Active, _Total} <- erlang:statistics(scheduler_wall_time)]) /
1000.

report(Rows, Iterations) ->
io:format("~nminato cost profile, ~w iterations~n~n", [Iterations]),
io:format("~-12s ~-10s ~12s ~12s ~12s~n", [
"workload", "client", "reductions", "busy us", "elapsed us"
]),
io:format("~s~n", [lists:duplicate(62, $-)]),
_ = [line(Workload, Client, Measured) || {Workload, Client, Measured} <- Rows],
ok.

line(_Workload, _Client, unsupported) ->
ok;
line(Workload, Client, #{reductions := Reductions, busy_us := Busy, elapsed_us := Elapsed}) ->
io:format("~-12s ~-10s ~12.1f ~12.1f ~12.1f~n", [Workload, Client, Reductions, Busy, Elapsed]).

repeat(_Run, 0) ->
ok;
repeat(Run, N) ->
ok = Run(),
repeat(Run, N - 1).

workloads() ->
[simple, unnamed, cached, rows_1000, rows_5000, wide_row, insert].

count(rows_5000, Iterations) -> max(1, Iterations div 20);
count(rows_1000, Iterations) -> max(1, Iterations div 5);
count(_Workload, Iterations) -> Iterations.

iterations() ->
case os:getenv("MINATO_BENCH_ITERATIONS") of
false -> ?DEFAULT_ITERATIONS;
"" -> ?DEFAULT_ITERATIONS;
Value -> list_to_integer(Value)
end.

%%----------------------------------------------------------------------
%% Everything below borrows the benchmark's own setup and workloads
%%----------------------------------------------------------------------

setup(Client) ->
_ = application:ensure_started(crypto),
Setup = minato_bench_setup(Client),
Setup.

minato_bench_setup(Client) ->
Setup = minato_bench:setup(Client),
ok = minato_bench:prepare_schema(Client, Setup),
minato_bench:parsed(Client, Setup).

runner(Client, Workload, Setup) ->
minato_bench:runner(Client, Workload, Setup).

teardown(Client, Setup) ->
minato_bench:teardown(Client, Setup).
Loading
Loading