Performance
This page describes what we measure on Pathways, how we measure it, and how to run the benchmark
yourself. The shared method every Cosyte package follows (throughput, latency percentiles, memory,
import time and bundle size, and how a pull request is compared with main) is written down in the
performance standard in
cosyte/config's decision records.
The numbers for the current release are in Current numbers below.
The benchmark
pnpm bench (scripts/bench.ts) measures one channel end to end: files in, the transform in its
worker, storage, and files out. It:
- writes a number of synthetic
ADT^A01messages (2,000 by default) as files into a fresh project's inbox; - runs one engine pass, which takes them all in, stores them, transforms each (setting MSH-5), and delivers each to a file destination;
- checks that every message was delivered, and reports the time and messages a second;
- does this twice, with encryption at rest off and on, so the cost of encryption shows.
It runs on SQLite by default, or on PostgreSQL, MySQL or SQL Server at an administrator URL, where it creates a database of its own and drops it afterwards.
What dominates is durable commits. With the default settings and one destination, each message takes
four, each a transaction that reaches the disk: the message is stored, its outputs are recorded, its
delivery is marked as sending before the send (the marker that flags a resend after a crash), and
then marked delivered. Each further destination adds two more, and resendFlag: false saves the
marker, one for each destination. So the disk under the database matters more than
the CPU, and a figure from one machine is context for that machine, not a promise for another. The
benchmark puts SQLite's file in the system temporary folder, often a memory-backed file system; compare
a database server with its data on the same kind of disk, or say which disk each was on.
The bench suite
bench/pathways.bench.mjs measures the built package the way the performance standard asks, on
@cosyte/bench. Every input is
synthetic and built in code by bench/shapes.mjs, in the shapes real traffic has: the small message
a feed sends all day (an HL7 v2 ADT^A01, a FHIR Patient, one X12 837P claim, a C-CDA CCD, a JSON
record), a dense large one (an ORU^R01 with thousands of results, a FHIR Bundle of Observations, a
JSON array of records), and a large opaque payload (an ORU^R01 carrying a base64 PDF, a text body).
- Engine passes. Each benchmark runs the built
pathwayscommand in a fresh process for one pass,pathways start --once, over a project with one channel: a file source, SQLite, encryption at rest, the transform in its worker, and a file destination. A pass with no input gives the fixed cost of a pass (process start, configuration, a fresh database, the worker and its parser); the larger passes show what each message adds, from one message to 1,000 in a row and from 64 KiB to 8 MiB. - The ACK builder.
defaultAckBuilder, which an MLLP source runs for every message it takes in, is measured in process, with its allocations, peak heap and a long run for leaks. - The profile.
bench/profile.mjsreads what a reading cannot: the memory of one whole engine pass (the engine thread's peak heap and external memory, and the whole process's peak resident set, above an empty pass, over the input's bytes), the worker's transform path in process (parse, transform, serialize: throughput from the smallest size to the largest, allocations, peak memory, a long run for leaks), the cold import of the transform worker and the start of the command, a CPU and a sampled heap profile of the hottest paths named by their place insrc/, and the proposed budgets read from those numbers.
The Bench reading workflow takes the reading and the profile on a GitHub-hosted runner, by hand or
on a pull request that changes the bench. On every pull request, CI runs the scaling gates
(pnpm test:perf: test/perf/scaling-gate.perf.ts holds the header read of an MLLP source and the
JSON read of the transform worker to a cost that grows no faster than their input) and times the
base build against the head build with the bench suite, interleaved on one runner. The comparison
is advisory: it reports and never fails a pull request.
The open-loop bench
pnpm bench and the engine passes above are closed loops: every message is already waiting, and
the engine sets the pace. That says how fast the engine drains a backlog, not how it answers
traffic that arrives on its own schedule. bench/open-loop/run.mjs measures that, from outside the
engine, in three processes:
- The engine is the built
pathways start, as production runs it: the engine lease held, the marker written before each send, encryption at rest on, a health endpoint, and the fault points compiled out of the build. Each channel takes synthetic HL7 v2ADT^A01messages, sets MSH-5 in its worker, and delivers to an MLLP receiver (and, in one case, an HTTP receiver and a folder too). - The sender sends MLLP, MLLP over TLS or HTTPS at a fixed rate, in a process of its own. It is an open loop: message n is due at start + n / rate and goes out when it is due, answered or not. Each message's latency runs from when it was due to its ACK, so a sender that falls behind is charged for the wait, rather than quietly sending less while the engine is slow (the coordinated omission that wrk2 describes). Latency is recorded in HdrHistogram and reported as p50, p99, p99.9 and the maximum, never a mean.
- The driver runs the receivers, reads the engine's queue (stored messages not yet transformed, and deliveries not yet done) from the health endpoint once a second, and reads the engine's resident set from the operating system.
Capacity is written down as a rule. A ladder of rates runs each rate for 20 seconds. A rate
holds when every message is answered AA, the p99 is at most 100 ms, and the queue stays flat: its
fitted growth over the step (a Theil-Sen slope, which one outlying sample cannot move) is less than
one second of the step's traffic. The capacity of a run is the highest rate that holds, with every
rate below it holding too.
The cases are MLLP, MLLP over TLS and HTTPS on SQLite; MLLP over TLS on PostgreSQL; four channels with three destinations each; a retry backlog, where the MLLP receiver goes down for 30 seconds while traffic keeps coming and then returns (how long the backlog takes to drain, how long the engine's first delivery on its new connection takes, and how long a new sender connection waits for its first ACK meanwhile); a startup with a backlog, where the engine is stopped with a backlog and started again (the time to answer health, to the first ACK on a new connection, and to drain); an hour at one rate, for the slope of the resident set; and the closed-loop drain, for comparison.
Each case runs five times on one runner, and every run is reported with the spread: none is picked.
Each reading states the message size, the destinations, the storage and the disk under it,
encryption, TLS, the runner, the Node version and the commit, in the results format of
@cosyte/bench. The Bench reading
workflow runs them on GitHub-hosted runners, which share their machines, so the readings are
advisory.
The first published figure
The figure on the Pathways page of cosyte.com, about 1,100 messages a second, is a closed-loop
drain: pnpm bench with 2,000 messages already waiting in a folder, one channel, file to file
through SQLite, on Node 26 and a developer machine's local disk, taken once by hand. It is how fast
that machine drained a backlog, not a rate the engine holds under traffic. The Bench reading
workflow's closed loop job runs the same command on a GitHub-hosted runner, and names the disk it
ran on, beside the open-loop readings.
How the engine keeps a message's cost down
- A body moves; it is not copied. Encryption and decryption write into one buffer. A store that copies what it stores (the SQL store, and any store under encryption) frees its own copy once it is stored or read back; the body moves to the transform worker and its outputs move back; and the file source and destination free a body once it is stored or written. A large payload the engine only passes through is held about once in the engine's own thread, where it was held about nine times.
- Each SQLite statement is prepared once for the connection, and kept.
- A JSON body is read once. Its numbers are checked for a value a JavaScript number cannot hold in one pass over the text, rather than through a reviver on every value.
- Importing the package does not load
@cosyte/hl7.defaultAckBuilderloads it on its first ACK.
What these numbers are not
- Not a promise for a deployment. Message sizes, transforms, the disk and the database decide what a deployment sees.
- Not a comparison with other engines.
- Not a limit. Nothing here changes how Pathways behaves under load.
Run the benchmark yourself
pnpm install --frozen-lockfile
pnpm bench # 2,000 messages through SQLite
pnpm bench 10000 # more messages
PATHWAYS_BENCH_DATABASE_URL="postgres://postgres:secret@127.0.0.1:5432/postgres" pnpm bench 2000 postgres
The bench suite and the profile run against a build:
pnpm build
pnpm bench:reading --out reading.json # every benchmark, in fresh processes
pnpm bench:profile --out profile # memory, the transform path, profiles and budgets
pnpm test:perf # the scaling gates
The open-loop bench runs one case at a time against a build, five runs by default (--runs), and
needs the openssl command for its TLS material. The PostgreSQL case reads an administrator's URL
from PATHWAYS_BENCH_POSTGRES_URL, and makes and drops a database of its own for each run:
pnpm build
node bench/open-loop/run.mjs --case mllps --out mllps.json --summary mllps.md
node bench/open-loop/run.mjs --case soak --runs 1 --seconds 3600
The cases are mllp, mllps, https, postgres, channels, retry-backlog, startup-backlog,
soak and drain.
The database is sqlite, postgres, mysql or sqlserver. A server's URL is an administrator's,
in the same form as the test URLs in .github/workflows/databases.yml.
Current numbers
The numbers for this section are measured on main at each release and filled in when the release
is built. They are never typed by hand.