Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Performance

FerroEHR applies the same discipline to performance that it applies to conformance: a class is a verdict a server earns by measurement on a stated environment, or does not. The performance chapter of the CNF suite runs an open-loop clinical workload at a published offered-load floor, records the result as a re-checkable histogram, and lets the verdict pipeline recompute (earned or not earned) from that artefact. Nothing on this page is hand-typed; every number comes from the committed measurement records or the generated assets below.

The volumetric class ladder

Performance conformance is graded on a small, closed ladder of deployment classes: proof-of-concept (POC), small (S), large (L), and regional (R). Each class fixes an offered-load floor (the peak API arrival rate the server must sustain), a latency budget (a p99 service-level objective, uniform across classes), and an error budget (zero: a failed request under load is a failed class). A class is earned only when a measured run holds every threshold; a class is never declared.

Crucially, a class verdict is environment-bound: it is meaningful only alongside the hardware, core count, memory, storage class, and topology it was measured on, which the runner records in the measurement’s environment block and stamps into every asset. The same binary earns different classes on different hardware, and the artefact always says which.

Where the floors come from

The offered-load floors are anchored to population, so a class corresponds to a real catchment a deployment might serve. The derivation is a short chain of published, official activity statistics:

  • Clinical documents per person per year. Summing the major encounter types that each commit a clinical document gives roughly forty-six documents per capita per year: primary- and specialist-care consultations (OECD, Health at a Glance 2023), inpatient discharges (OECD/Eurostat hospital discharge statistics), emergency-department visits (OECD emergency-care indicators), laboratory reports (Royal College of Pathologists activity data), diagnostic-imaging events (NHS England Diagnostic Imaging Dataset, 2023/24), and dispensed prescriptions (NHS Business Services Authority Prescription Cost Analysis, 2024/25). The result sits between the major-document exchange rates Denmark and Estonia report and Finland Kanta’s all-inclusive figure, which is the sanity check that keeps it arguable.

  • Average write rate. Multiplying a class’s served population by that per-capita rate and dividing by the number of seconds in a year gives the average sustained document-write rate for the class:

  • Busy-hour peak. Real clinical traffic is not flat: it concentrates in ward rounds and clinic hours. Following the ITU-T E.500 busy-hour engineering convention, the average is scaled to a busy-hour peak by a peak factor of eight:

  • Read multiplier. A CDR is a read-heavy OLTP system: charts are read far more often than they are written. Following the read-heavy OLTP convention used by standard database benchmarks (YCSB, OLTP-Bench), the offered load applies a read-to-write ratio of 10:1 on top of the write rate; audit-log evidence of how many record interactions one encounter actually generates bounds the read-heavy band from above.

The floors that fall out of this chain are the published defaults the runner enforces; the concrete rates per class are carried in the class ladder above and the summary table below, never re-typed into this prose.

The durability floor — what a single write can never beat

Every committed version is durable: the transaction’s WAL records are flushed to disk before the server answers, so an acknowledged commit survives a crash. That flush (one fsync on the WAL device per commit) is a physical lower bound on single-client write latency, and no storage design takes a lone sequential client below it. On a laptop-class Docker setup the flush alone dominates a single write’s budget; on server NVMe it is typically an order of magnitude smaller. FerroEHR’s optimization target is therefore everything around the flush: the database’s own per-commit work (one folded commit statement, one merged placement read) stays far below the flush itself. That is also what makes concurrent throughput scale: PostgreSQL group-commits, amortizing one flush across every transaction that reaches it in the same window.

The knob behind this boundary is PostgreSQL’s synchronous_commit. Setting it off makes commits return before the WAL flush: single-client write latency drops to the statement work alone, and a server crash can lose the most recent acknowledged commits (up to three times wal_writer_delay, per the PostgreSQL documentation). The database stays consistent; the tail of acknowledged writes is what is at risk. FerroEHR never defaults this off and does not recommend it for clinical data. It is an operator decision, made per deployment on the database side, defensible only where the record of loss is acceptable (a load-test rig, a reseedable sandbox, an analytics replica). All published FerroEHR numbers are measured with full durability on.

The hospital simulation

The measured workload is not a flat operation mix: it simulates a hospital, end to end. Load arrives as clinical journeys (ordered, time-offset operation sequences drawn from a committed journey catalogue):

  • ADT flow: an admission creates the EHR, sets its status, commits the admission problem list and summary, and opens the per-episode directory folder tree; a discharge writes the discharge summary and closes the episode out.
  • Monitoring: nursing observation rounds commit vital-signs documents at ward cadence.
  • The medication loop: an order is followed by scheduled administration commits at the drug-round interval; medicines reconciliation reads the standing medicines list and amends it as a new version.
  • Order → result pipelines, asynchronous: a laboratory or imaging request is committed at one instant and its result lands as its own arrival after a realistic turnaround drawn from the catalogue; the ordering clinician’s chart review follows later still. Nothing ever blocks on anything else.
  • Clinical review: ward-round chart reads (at version, current, and the revision history), per-patient AQL trends, cross-EHR ward worklists, and a registered stored query executed continuously.
  • Governance: versioned amendments, the occasional logical delete, contribution inspection (the audit trail’s read side), and workflow tagging of hot documents.
  • The platform surface: template listing and retrieval (the integration-engine poll), specialist synoptic reports, registry submissions, and statutory public-health notifications.

Every stage of every journey instance is its own planned arrival instant on the global open-loop schedule (an order at , its administrations at , the result at ) so many patients’ journeys interleave exactly as wards do, and cross-operation state effects (status transitions during active commits, folder consistency under parallel writes, version chains under interleaved amendments, AQL against a mutating corpus) are exercised under load, which a flat four-operation mix can never reach. A dependent stage whose prerequisite has not landed when its instant fires (a stalled server) records honestly as an error; that is the measurement.

The journey payloads commit against published openEHR CKM templates (vital signs, laboratory results, ePrescription, medicines list, problem list, the International Patient Summary, imaging and cancer synoptic reports, registry and public-health forms), vendored with provenance and committed as byte-identical example skeletons so every measured server receives exactly the same bytes.

The envelope stays population-anchored. The derivation above still fixes the aggregate operation arrival rate (the class floor) and the read:write ratio; the journey catalogue only decomposes those totals into many more operation kinds. Each journey cites the activity statistic that grounds its shape (the same register the floors derive from) and the runner’s artefact validator recomputes the expansion on every load: the catalogue-expanded write share must reconcile to the derivation’s read-heavy band (between the 10:1 floor convention and the audit-log-evidenced ceiling), so the mix stays arguable, never arbitrary.

For the extended eight- and twelve-hour holds, the schedule can follow a diurnal day curve (morning and afternoon peaks, shift-change bumps, a night-time trough) applying the same ITU-T E.500 busy-hour convention the peak factor cites: the class floor is then the busy-hour rate, and the off-peak troughs are the design, not a shortfall.

Finally, the conformance certificate prints the workload coverage: the set of claimed capabilities the simulation actually exercised, joined against the claims matrix. A claimed capability the hospital never touches must carry an adjudicated exclusion printed with its reason: a design-time operation like archetype provisioning is not a sustainable per-patient arrival, and saying so beats pretending the load reached it.

How a measured run works

A performance run is deliberately open-loop: the runner plays a seeded arrival schedule (request is due at a planned instant computed before the run starts) rather than a closed loop of virtual users that would slow its own offered load down whenever the server stalls. This makes the run coordinated-omission-free: each request’s latency is measured from its planned arrival instant, so a server that pauses cannot hide the queue it built up behind a handful of fast replies.

A run has two phases: a discarded warmup window (caches fill, pools warm, the JIT of a compared server settles) followed by the sustained measurement window at the class’s offered-load floor. Latencies are accumulated into per-operation HDR V2 histograms, which are base64-encoded verbatim into the measurement record in results.json. Because the full histogram is embedded (not just a handful of pre-computed percentiles) any consumer can re-derive every percentile and re-check every threshold from the committed artefact. The class verdict itself is then recomputed by the verdict pipeline from those records: the runner never writes a verdict it cannot reproduce from the data.

The measured corpus is seeded strictly through the public write path (create EHR, commit composition) and never a database backdoor, so what the run reads is exactly what the server’s own write path produced. The database’s maintenance debt is settled deterministically, and identically for every system under test, outside the measured windows, so neither a stale-statistics plan nor an autovacuum firing mid-window ends up in the record.

Reproducing it

The measured run is a stage of the conformance pipeline. Selecting a class seeds the matching scale corpus, plays the open-loop schedule against the composed server, and merges the measurement records into results.json:

# seed the class corpus, run the open-loop schedule, merge the record
CONF_PERF_CLASS=POC bash scripts/conformance.sh

# hold the same offered load for longer — the hours ladder is 1 (the case's
# normative window and the default), 2, 4, 6, 8, or 12
CONF_PERF_CLASS=POC CONF_PERF_HOURS=8 bash scripts/conformance.sh

There is deliberately no shortened run: the measurement record always covers at least the case’s normative window, so nothing sub-normative can ever be mistaken for a measured result. A longer hold is a stricter demonstration of the same class, not a different one. The record carries the actual warmup and window it held, and the verdict machinery re-derives everything from the embedded histograms. Every run seeds a freshly composed server from empty; there is no seed reuse, so no run ever measures another run’s leftovers.

The runner can also be driven directly against a running server, which is how you point it at a deployment the pipeline does not compose. Its perf subcommand takes the catalogue root, the ixit topology file (whose environment block is mandatory: a measurement without the deployment described is not a measurement), the results.json to merge into, and the class:

veredictum perf --root <catalogue-root> --ixit <ixit.json> \
                --results <results.json> --class POC --hours 8

Stress testing — the second instrument

Beside the class runs sits a deliberately different instrument: a step-load stress test. Where a class run holds a real-life, population-anchored rate for at least an hour, the stress test climbs a geometric ladder of short, intense load steps (about two minutes each, doubling the offered rate every step) until the system leaves the stress envelope, then bisects between the last stable rate and the breached one to locate the boundary precisely, the point performance engineering calls the knee of the latency-throughput curve. The headline it finds is the maximum sustainable throughput: the highest offered rate held inside the latency budget (the same idea TPC benchmarks report as maximum qualified throughput).

veredictum stress --root <catalogue-root> --ixit <ixit.json> \
                  --out <stress.json> --corpus-class POC

The two instruments never blur. A stress report earns no class, never touches results.json, and carries no class vocabulary at all; the class ladder belongs to the measured class runs, and the stress chart shows one thing only: where the system breaks. Their envelopes differ on purpose too: a conformance class demands a zero error budget, while a stress step allows the small error tolerance standard load testing works with, because the instrument’s job is to find the knee rather than to certify anything. Every load step embeds its own re-checkable histograms and its own resource telemetry (the same per-container CPU/memory/I/O series the measured runs record, so a breached step shows where it saturated), a breached step is reported with the exact envelope violation, and a step where the load generator topped out before the server is flagged as such rather than counted against the system.

The optimization probe

Between the two verdict-bearing instruments sits a third, purely diagnostic one: the AQL probe. It seeds the same class corpus fresh, fires the measurement machinery’s own AQL set repeatedly, and records each query’s wire-latency percentiles alongside the database-side cost per SQL statement, so an optimization is argued from attributed evidence on a realistically seeded database, never from a hunch on an empty one. Its report is exploration evidence for the optimization loop: it earns nothing and never touches the conformance record.

veredictum aql-probe --root <catalogue-root> --ixit <ixit.json> \
                     --out <aql-probe.json>

The published assets on this page are rendered from the committed results.json by the runner’s perf-assets subcommand (wrapped by scripts/render/perf-assets.sh); the docs CI job re-renders and diffs them, so a hand-edited or stale asset fails the build.

The storage benchmark harness

A fourth instrument measures the storage layer directly, below the wire:

STORAGE_BENCH_CLASS=poc cargo bench -p ferroehr --bench storage

It seeds a corpus through the ordinary write path, then times the storage layer’s hot paths one at a time: a composition commit, a supersession, the supersession that states the version it replaces, a point read by version uid and one by versioned-object uid, the version at an instant, the revision history, an AQL CONTAINS chain over one EHR and over the whole population, archive and restore of an EHR, and one retention prune.

Beside each operation’s wall-clock it records what the database did for it. The per-relation tuple counters (inserted, updated, updated in place, dead, live) and the relation sizes come from pg_stat_user_tables, the buffer hits and misses from pg_stat_database, and the write-ahead-log bytes from the WAL position either side of the measurement. One representative commit is probed on its own, and the population query is explained with EXPLAIN (ANALYZE, BUFFERS, WAL) inside a transaction that is rolled back, so the plan shape and its index choices land in the record too.

Everything runs through the service and the public storage API, and the relations are discovered from the catalogue, so no table name appears anywhere in the harness and the same file measures a rewritten schema. That is the point: a storage change is argued from a before-and-after pair taken with one instrument. It earns no class and touches no conformance artifact; the record’s shape is documented in docs/benchmarks/storage/README.md. Records are local by default, and the committed baseline is what this page renders.

Running it against a committed baseline

A dispatch-only workflow (.github/workflows/storage-bench.yml) runs the same benchmark on a GitHub-hosted runner, against a PostgreSQL 18 service with its durability left on, and compares every operation with the record committed for that schema generation. The dispatch picks the corpus class and the tolerance. The run summary lists each operation with its baseline p50 and p99, the measured pair, the delta and a verdict, and a p50 or p99 that rose further than the tolerance fails the run. Both machines are named in the summary, because a hosted runner is shared hardware and a latency measured there is read against the box it came from.

That lane is exploration, never a conformance record. It earns no performance class, publishes no number, and writes nothing back to the repository: the fresh record leaves as a run artifact, and the committed baseline under docs/benchmarks/storage/<generation>/ moves only in a pull request that says which machine measured it. A generation with no committed record is reported as such and the run passes; a run whose class differs from the baseline’s shows the deltas and judges nothing, since the two measured different corpora.

Schema generation generation-1, class s

OperationIterationsp50p95p99WALBuffer hits
create_first_version1 2003.6 ms5.3 ms5.3 ms47 MiB1 008 028
supersede87011.2 ms14.4 ms14.4 ms37.5 MiB30 795 283
supersede_if_match85011.4 ms14.5 ms14.5 ms36.5 MiB29 699 039
by_version_uid13 640298.6 µs374.6 µs374.6 µs77.3 KiB250 604
latest_by_versioned_object_uid8 550479.9 µs1 ms1 ms456 B121 845
version_at_time7 260597.3 µs640 µs640 µs136 B122 318
revision_history25 230193.9 µs216.6 µs216.6 µs88 B671 513
contains_one_ehr2 8501.7 ms1.8 ms1.8 ms368 B766 648
contains_population3 4401.1 ms1.1 ms1.1 ms0 B1 294 649
archive_ehr9023.3 ms30.3 ms30.3 ms252.7 MiB6 969 118
restore_ehr16024.9 ms69.1 ms69.1 ms400.5 MiB9 276 746
retention_prune25 650166.7 µs185.7 µs185.7 µs104 B35 104

One representative commit wrote 2.6 MiB of WAL and touched 88 172 buffers. The population CONTAINS statement plans as Gather and reads 55 817 shared blocks.

RelationLive rowsDead rowsUpdatesHOT updatesSize
ehr.audit7 1880001.9 MiB
ehr.contribution6 9880001.3 MiB
ehr.ehr100000112 KiB
ehr.event_outbox6 9880003.9 MiB
ehr.node296 059000271.8 MiB
ehr.template_ref100032 KiB
ehr.template_store1000136 KiB
ehr.vo_archive000040 KiB
ehr.vo_version7 08802 330030.1 MiB

Measured over 100 EHRs and 3 000 compositions of template Vital signs at commit 6ebfdb32b304 on 8 cores against PostgreSQL 18.6.

Schema generation generation-2, class poc

OperationIterationsp50p95p99WALBuffer hits
create_first_version5308.5 ms9.1 ms9.1 ms24.2 MiB395 240
supersede47010.3 ms11.1 ms11.1 ms21.6 MiB905 767
supersede_if_match54010 ms11.2 ms11.2 ms24.1 MiB1 070 922
by_version_uid8 290519.5 µs538.3 µs538.3 µs168 B213 581
latest_by_versioned_object_uid8 630534.3 µs553.9 µs553.9 µs120 B204 101
version_at_time4 290990.6 µs996 µs996 µs0 B135 299
revision_history15 730300.1 µs312.9 µs312.9 µs0 B437 101
contains_one_ehr1 1404 ms4.2 ms4.2 ms128 B605 955
contains_population2 2702.1 ms2.2 ms2.2 ms0 B620 677
flat_2_sibling_leaves21021.9 ms22.7 ms22.7 ms112 B3 639 915
flat_3_sibling_leaves19025.5 ms26.7 ms26.7 ms88 B3 422 117
streaming_2_sibling_leaves8705.9 ms6.2 ms6.2 ms0 B2 524 699
streaming_3_sibling_leaves8006.2 ms6.3 ms6.3 ms0 B2 580 569
contains_exists5808.4 ms8.5 ms8.5 ms0 B6 860 724
node_predicate1 2404 ms4.2 ms4.2 ms0 B2 130 540
where_magnitude11046.4 ms47.3 ms47.3 ms0 B3 399 817
where_coded20023.4 ms25.8 ms25.8 ms0 B3 495 572
where_date_time1 0704.5 ms4.6 ms4.6 ms0 B1 884 349
where_text21023.2 ms24.4 ms24.4 ms0 B3 628 755
wide_projection_one_ehr10053.2 ms55.3 ms55.3 ms56 B464 725
wide_projection_population18029.2 ms32.9 ms32.9 ms0 B3 761 922
select_observation7606.5 ms6.8 ms6.8 ms0 B1 804 991
select_composition_one_ehr8605.5 ms6.7 ms6.7 ms0 B474 289
aql_version_at_time1 2204 ms4.2 ms4.2 ms0 B1 827 733
archive_ehr3091.4 ms101.2 ms101.2 ms52.9 MiB1 038 199
restore_ehr30106.1 ms108.4 ms108.4 ms52.7 MiB1 121 364
retention_prune23 420211.1 µs236.3 µs236.3 µs8.3 KiB33 082

One representative commit wrote 305 KiB of WAL and touched 3 903 buffers. The population CONTAINS statement plans as Nested Loop and reads 43 224 shared blocks.

RelationLive rowsDead rowsUpdatesHOT updatesSize
clinical.commit_audit2 324000632 KiB
clinical.contribution2 284000584 KiB
clinical.ehr2000056 KiB
clinical.event_outbox2 2840001.4 MiB
clinical.node_cold000088 KiB
clinical.node_hot100 33200081.4 MiB
clinical.template_ref200032 KiB
clinical.template_store2000192 KiB
clinical.version_cold0000120 KiB
clinical.version_hot2 3040008.8 MiB
clinical.vo_head1 020603 9483 948312 KiB

Measured over 20 EHRs and 300 compositions of template Vital signs at commit c82f0992d308 on 4 cores against PostgreSQL 18.6.

Schema generation generation-2, class s

OperationIterationsp50p95p99WALBuffer hits
create_first_version5109.7 ms9.9 ms9.9 ms110.9 MiB470 378
supersede5009.9 ms10.4 ms10.4 ms22.8 MiB1 034 920
supersede_if_match54010 ms10.4 ms10.4 ms24.4 MiB1 151 542
by_version_uid8 700488.6 µs493.7 µs493.7 µs2.8 KiB222 174
latest_by_versioned_object_uid9 360489.2 µs512.2 µs512.2 µs128 B218 149
version_at_time4 490946.8 µs992.3 µs992.3 µs56 B146 116
revision_history16 450287.8 µs300.8 µs300.8 µs0 B453 104
contains_one_ehr1 2403.6 ms3.7 ms3.7 ms136 B658 178
contains_population1 7403 ms3 ms3 ms0 B1 020 037
flat_2_sibling_leaves6092.3 ms95.9 ms95.9 ms16.4 KiB33 018 094
flat_3_sibling_leaves6095.9 ms97.8 ms97.8 ms0 B33 030 979
streaming_2_sibling_leaves6607.6 ms7.9 ms7.9 ms0 B2 398 276
streaming_3_sibling_leaves6308 ms8.1 ms8.1 ms0 B2 137 922
contains_exists17030.2 ms34.2 ms34.2 ms0 B11 007 303
node_predicate9305.4 ms5.5 ms5.5 ms0 B2 127 292
where_magnitude50120.1 ms121.4 ms121.4 ms0 B5 504 663
where_coded7206.9 ms7.2 ms7.2 ms0 B2 269 954
where_date_time8206.1 ms6.2 ms6.2 ms0 B1 917 884
where_text7306.9 ms7 ms7 ms0 B2 293 234
wide_projection_one_ehr50114.2 ms115.6 ms115.6 ms0 B1 977 924
wide_projection_population38013.3 ms13.7 ms13.7 ms88 B2 326 270
select_observation6207.9 ms8 ms8 ms0 B1 607 057
select_composition_one_ehr6008.2 ms8.5 ms8.5 ms0 B683 661
aql_version_at_time8006.2 ms6.3 ms6.3 ms0 B6 498 839
archive_ehr20173 ms181.1 ms181.1 ms68 MiB1 320 254
restore_ehr20204.5 ms207.9 ms207.9 ms60.6 MiB1 298 376
retention_prune23 980207.7 µs209 µs209 µs2.1 MiB73 258

One representative commit wrote 297.2 KiB of WAL and touched 960 buffers. The population CONTAINS statement plans as Nested Loop and reads 42 257 shared blocks.

RelationLive rowsDead rowsUpdatesHOT updatesSize
clinical.commit_audit5 4860001.5 MiB
clinical.contribution5 286000960 KiB
clinical.ehr100000152 KiB
clinical.event_outbox5 2860002.9 MiB
clinical.node_cold0000232 KiB
clinical.node_hot252 462000167.6 MiB
clinical.template_ref200032 KiB
clinical.template_store2000192 KiB
clinical.version_cold0000144 KiB
clinical.version_hot5 3347820016.3 MiB
clinical.vo_head3 940134 4304 430936 KiB

Measured over 100 EHRs and 3 000 compositions of template Vital signs at commit c82f0992d308 on 4 cores against PostgreSQL 18.6.

Schema generation generation-3, class poc

OperationIterationsp50p95p99WALBuffer hits
create_first_version8405.8 ms14.6 ms14.6 ms34.8 MiB648 612
supersede61011.4 ms18.9 ms18.9 ms23 MiB13 048 343
supersede_if_match64012.3 ms21.6 ms21.6 ms28 MiB19 143 656
by_version_uid9 920418.8 µs439.7 µs439.7 µs472 B245 293
latest_by_versioned_object_uid11 710404.2 µs428.5 µs428.5 µs0 B301 532
version_at_time4 940874.4 µs886.1 µs886.1 µs0 B151 435
revision_history18 710206.6 µs248.5 µs248.5 µs0 B502 662
contains_one_ehr1 4103.5 ms3.6 ms3.6 ms7 KiB859 578
contains_population2 6901.8 ms1.9 ms1.9 ms8.1 KiB824 801
flat_2_sibling_leaves23019.4 ms20 ms20 ms42.4 KiB5 239 470
flat_3_sibling_leaves21023.4 ms23.8 ms23.8 ms0 B4 934 788
streaming_2_sibling_leaves9805.1 ms5.3 ms5.3 ms3.6 KiB2 960 620
streaming_3_sibling_leaves9005.5 ms5.7 ms5.7 ms0 B3 007 660
contains_exists47010.5 ms11.1 ms11.1 ms0 B8 638 704
node_predicate1 3803.6 ms3.7 ms3.7 ms0 B2 969 000
where_magnitude11047.7 ms48.7 ms48.7 ms0 B5 012 010
where_coded23022.1 ms23.1 ms23.1 ms0 B5 209 927
where_date_time1 2104.1 ms4.2 ms4.2 ms0 B2 292 883
where_text23022.4 ms23.3 ms23.3 ms0 B5 210 914
wide_projection_one_ehr12042.1 ms42.9 ms42.9 ms0 B526 091
wide_projection_population19027.3 ms28.6 ms28.6 ms0 B5 064 088
select_observation8505.7 ms5.8 ms5.8 ms0 B2 121 054
select_composition_one_ehr9904.8 ms5 ms5 ms0 B551 999
aql_version_at_time1 4503.4 ms3.6 ms3.6 ms0 B2 565 200
archive_ehr5051.3 ms54.2 ms54.2 ms87.7 MiB1 896 225
restore_ehr5064.2 ms65.2 ms65.2 ms77.1 MiB1 945 710
retention_prune40 630118.4 µs121.5 µs121.5 µs120 B50 499

One representative commit wrote 532.7 KiB of WAL and touched 1 322 buffers. The population CONTAINS statement plans as Nested Loop and reads 17 067 shared blocks.

RelationLive rowsDead rowsUpdatesHOT updatesSize
clinical.commit_audit3 130000872 KiB
clinical.contribution3 090000640 KiB
clinical.ehr2000064 KiB
clinical.event_outbox3 0900001.8 MiB
clinical.node_cold000088 KiB
clinical.node_hot134 99000089.3 MiB
clinical.template_ref200032 KiB
clinical.template_store2000192 KiB
clinical.version_cold0000136 KiB
clinical.version_hot3 11000012.7 MiB
clinical.vo_head1 458486 3326 332504 KiB

Measured over 20 EHRs and 300 compositions of template Vital signs at commit 59b91af928a4 on 4 cores against PostgreSQL 18.6.

Schema generation generation-3, class s

OperationIterationsp50p95p99WALBuffer hits
create_first_version5607.6 ms8.6 ms8.6 ms21.6 MiB475 947
supersede52012.9 ms16.4 ms16.4 ms21.6 MiB10 171 802
supersede_if_match52013 ms16.7 ms16.7 ms20.1 MiB9 750 360
by_version_uid9 210480 µs495 µs495 µs2.2 KiB226 321
latest_by_versioned_object_uid9 340477.9 µs486.9 µs486.9 µs464 B224 344
version_at_time4 510943.2 µs1 ms1 ms0 B140 807
revision_history17 290268.1 µs271.6 µs271.6 µs0 B471 470
contains_one_ehr1 2503.6 ms3.9 ms3.9 ms0 B695 743
contains_population1 6803 ms3.1 ms3.1 ms15.8 KiB1 115 539
flat_2_sibling_leaves14033.9 ms38.2 ms38.2 ms16.2 KiB4 120 479
flat_3_sibling_leaves14037.9 ms39 ms39 ms232 B4 152 775
streaming_2_sibling_leaves5808.5 ms9.3 ms9.3 ms0 B2 007 543
streaming_3_sibling_leaves5808.7 ms8.8 ms8.8 ms0 B2 152 855
contains_exists20025.6 ms26.5 ms26.5 ms0 B9 295 830
node_predicate8306 ms6.1 ms6.1 ms0 B2 174 425
where_magnitude50105.2 ms106 ms106 ms0 B4 729 616
where_coded7806.2 ms6.6 ms6.6 ms0 B1 888 942
where_date_time7506.8 ms7.1 ms7.1 ms0 B2 001 044
where_text8106.2 ms6.3 ms6.3 ms0 B1 943 864
wide_projection_one_ehr50110 ms111.3 ms111.3 ms0 B1 950 208
wide_projection_population41012.4 ms12.5 ms12.5 ms0 B2 101 466
select_observation5608.7 ms9 ms9 ms0 B1 619 828
select_composition_one_ehr6008.4 ms8.7 ms8.7 ms0 B685 119
aql_version_at_time7106.4 ms7.2 ms7.2 ms0 B5 900 107
archive_ehr20138.1 ms141 ms141 ms65.3 MiB1 558 971
restore_ehr20170.1 ms173.7 ms173.7 ms59.2 MiB1 516 172
retention_prune27 760177.4 µs180.1 µs180.1 µs1.2 MiB71 274

One representative commit wrote 107.4 KiB of WAL and touched 3 784 buffers. The population CONTAINS statement plans as Nested Loop and reads 49 471 shared blocks.

RelationLive rowsDead rowsUpdatesHOT updatesSize
clinical.commit_audit5 4840001.5 MiB
clinical.contribution5 2840001.1 MiB
clinical.ehr10000096 KiB
clinical.event_outbox5 2840003 MiB
clinical.node_cold0000232 KiB
clinical.node_hot254 612000114.1 MiB
clinical.template_ref200032 KiB
clinical.template_store2000192 KiB
clinical.version_cold0000160 KiB
clinical.version_hot5 38400018.1 MiB
clinical.vo_head3 9901044 9584 958968 KiB

Measured over 100 EHRs and 3 000 compositions of template Vital signs at commit 59b91af928a4 on 4 cores against PostgreSQL 18.6.

The latest measured run

The per-operation percentiles below are re-derived at build time from the committed HDR V2 histograms for the proof-of-concept class:

What the run cost the machine

Alongside the latencies, every measured run records its resource telemetry: CPU and resident memory for the server and database containers separately, plus block-device and network I/O, sampled at a fixed interval across the whole window with the warmup shaded. These numbers are capacity-planning context; they never influence whether a class is earned.

The database volume’s on-disk size is probed at four anchors: before the scale seed (the empty baseline), after it, after the standing-ward seed, and after the measured window drained. The first two give the storage cost per committed composition; the last two give the sustained load’s write amplification. An anchor that could not be probed is honestly absent rather than guessed:

Both charts render only from a committed measurement record; nothing on this page is ever mocked.

ClassCorpusOffered-load floorp99 budgetError budgetMeasured sustainedVerdict
POCcnf.scale.10k2/s≤ 1000 ms02.0/sEARNED
Scnf.scale.100k15/s≤ 1000 ms0—not measured
Lcnf.scale.1m150/s≤ 1000 ms0—not measured
Rcnf.scale.10m1500/s≤ 1000 ms0—not measured

Measured run PERF-hospital_sim-class_POC — class POC, offered load 2.04/s sustained over 3600 s (after 300 s warmup), environment: consumer-laptop (8 cores, 16 GB, nvme, single-node docker compose (8-CPU/8GB Docker VM) on Apple M2, the SMART resource-server posture (docker/sut-smart.yml overlays the base stack) with the external-terminology profile composed beside it (a seeded HAPI FHIR R4 server, docker compose –profile terminology + docker/sut-terminology.yml, fail-open); alongside it a second deployment of the same image in the openPGP version-signing posture (project ferroehr-cnf-pgp, docker/sut-signing-pgp.yml + docker/sut-terminology-failclosed.yml + docker/sut-pgp-parallel.yml, host port 8081) declared as the sut_pgp instance, which carries the fail-closed terminology posture — the measured-performance stage drives the primary deployment alone).

OperationRequestsErrorsp50 (ms)p90 (ms)p99 (ms)
adhoc_query7920293864
admin_contribution_report600159176269
analytics_query2306276197
archetype_adl2_list600131828
composition_commit280050106214
composition_commit_flat70425353
composition_delete40283535
composition_read15830243366
composition_read_current8960273657
composition_read_flat70172525
composition_revision_history8890142131
composition_update6804667239
composition_version_read300283549
contribution_commit48054138187
contribution_read820202860
directory_create120254086
directory_read8030172440
directory_update120283442
ehr_create120283233
ehr_extract_export7920149182268
ehr_read720182537
ehr_status_read240162325
ehr_status_update240213655
party_create60222828
party_read60162424
party_relationship_create60263030
party_relationship_read60111717
party_update60152727
readonly_write_denied70304646
smart_configuration_read70152424
stored_query_execute18004155103
system_options70101818
tags_put300243341
tags_read300172638
tdd_import70356060
template_adl2_list610131954
template_example60030102261
template_get60064134240
template_list600538090
terminology_query230192673
unauthenticated_probe70182323
ward_query1800283871

Resources (measured context, never a verdict input) — sampled every 10 s; CPU/RSS derived over the measured phase:

ContainerCPU meanCPU peakRSS peak
sut ferroehr-cnf-ferroehr-13.6%7.7%218 MB
db ferroehr-cnf-ferroehr-postgres-15.9%21.0%1.5 GB

Disk anchors: empty 133 MB → after scale seed 13 GB (≈ 13 KB / composition over 1,000,000 committed) → after ward seed 13 GB → after window 13 GB.