Performance

Two scheduled runs on dev produce every performance number this project publishes; nothing in this chapter is typed in by hand. Both rent a dedicated-vCPU machine at Hetzner for the run, register it as an ephemeral GitHub runner, and delete it afterwards (perf-janitor sweeps a leaked server or volume by its expiry label). Results land under /reports/perf/latest/ with the raw CSVs next to the tables.

runboxwhat it measurescadence
perf-weeklyccx33 (8 dedicated vCPU, 32 GB), one hourthe request shapes other brokers publish, on the in-memory store; dispatched with store=postgres it adds the same tables against a PostgreSQL container on the box, at pool 20 and 100 (pg-pool<N>/)Saturday
scale-weeklyccx53 (32 dedicated vCPU, 128 GB) + volume, one hourthe design targets on PostgreSQL, at scale 0.01Sunday

scale-weekly sizes the database to the box it rents: shared_buffers a quarter of RAM, maintenance_work_mem a sixteenth, the shared memory segment an eighth, and the broker's connection pool eight per core with the eight-core value pinned to the 100 it was measured at. A number from one server type therefore does not compare with a number from another; the box is named next to every table.

The shapes (perf-weekly)

Every script lives in dev/perf/ and runs on a laptop the same way it runs in CI; k6 is the only tool it needs.

tablescriptmethod
startup and idle footprintstartup.shexec to the first 200 from /q/health, median of five, VmRSS right after, per store
throughput per shapeshapes.sh100 five-attribute entities; GET /entities?type=Vehicle&limit=20 at 50 and 200 concurrent clients, GET /entities/{id} at 50 (SPECS picks other rows, the PostgreSQL dispatch runs 64, 256 and 1 024 clients); five seconds, median of three runs, p99 from the same runs. The facade and facade-twin shapes run the same pair only when the binary under test serves /x/example/things — a shipped build does not
core scalingcore-scale.shbroker pinned to 1, 2, 4, 8 physical cores with taskset, load generator on the remaining cores; refuses a step it cannot isolate. cores used is the broker's CPU time over the window against the cores it was allotted; peak threads is the largest thread count of the process, which is what a store driver that parks threads instead of awaiting shows up in
saturation kneesaturate.shopen model, +500 rps every 30 s until p99 passes 50 ms or errors pass 0.1 %; the knee is the last stage that held, the curve is a CSV; cores used and peak threads as in core scaling, over the whole sweep
noise profilevariance.pythe same commit measured ten times; the fence for a future regression gate is Q3 + 3·IQR of each metric's own history

The load generator shares the machine with the broker, as in every published broker table; the numbers describe that shape and nothing else, and quadrupling the concurrency shows the queue, not the broker. Reproduce them on your own box before quoting them:

cargo build --release -p antares-broker
dev/perf/startup.sh && dev/perf/shapes.sh && dev/perf/core-scale.sh && dev/perf/saturate.sh
python3 dev/perf/report.py results/perf     # index.html + perf.json

The measured ceiling

Two perf-weekly dispatches. The first, on ccx33 (8 logical, 4 physical cores, three passes) at commit b55d554, could isolate only the 1- and 2-core steps: pinning the broker to 4 cores needs 8 physical, and to 8 needs 16. The second, on ccx53 (32 logical, 16 physical cores, two passes) at commit 41610be, runs the whole ladder, and the tables below are that run. store=postgres in both; the load generator shares the machine, so every row is broker plus generator.

Core scaling, one row per store and pool: the broker pinned to N physical cores (SMT siblings excluded), the generator on the rest, query shape at 50 concurrent clients. cores used is the broker's CPU time over the window against the cores it was allotted.

store1 core2 cores4 cores8 coresefficiency at 2 / 4 / 8cores used at 8peak threads at 8
memory3 076 req/s6 050 req/s11 342 req/s12 554 req/s98 % / 92 % / 51 %7.6416
postgres, pool 202 097 req/s3 809 req/s7 093 req/s7 238 req/s91 % / 85 % / 43 %6.4773
postgres, pool 1002 026 req/s3 665 req/s7 157 req/s7 837 req/s90 % / 88 % / 48 %7.0385

The same rows as CPU spent per request (cores used over req/s), which is what the efficiency column is measuring underneath:

store1 core2 cores4 cores8 cores
memory0.31 ms0.32 ms0.34 ms0.61 ms
postgres, pool 200.47 ms0.49 ms0.50 ms0.89 ms
postgres, pool 1000.48 ms0.50 ms0.51 ms0.90 ms

The saturation knee, whole box, open model:

storeshapekneep99 at the kneefirst failing stagecores usedpeak threads
memoryquery5 000 rps1.2 msnone reached0.9639
memorywrite5 000 rps0.6 msnone reached0.4762
postgres, pool 20query5 000 rps3.0 msnone reached2.1777
postgres, pool 20write1 000 rps2.9 ms1 500 rps1.794 038
postgres, pool 100query5 000 rps2.6 msnone reached2.1864
postgres, pool 100write1 000 rps2.8 ms1 500 rps1.804 038

Throughput per shape at 64, 256 and 1 024 concurrent clients, whole box:

storeshapec64c256c1024
memoryquery19 272 req/s, p99 7.7 ms21 254 req/s, p99 36.6 ms21 566 req/s, p99 123.4 ms
memoryretrieve39 766 req/s, p99 3.8 ms43 805 req/s, p99 19.7 ms45 425 req/s, p99 68.1 ms
postgres, pool 20query12 418 req/s, p99 7.3 ms11 948 req/s, p99 23.2 ms11 448 req/s, p99 93.2 ms
postgres, pool 20retrieve12 432 req/s, p99 5.9 ms12 233 req/s, p99 22.3 ms11 766 req/s, p99 90.0 ms
postgres, pool 100query12 426 req/s, p99 7.8 ms11 823 req/s, p99 28.5 ms10 292 req/s, p99 104.0 ms
postgres, pool 100retrieve13 974 req/s, p99 6.0 ms14 144 req/s, p99 20.4 ms13 696 req/s, p99 75.7 ms

The run predates ADR-0022: the Postgres driver still parked a thread per in-flight store call, which is what the four-figure peak threads column records. The knees and the per-request cost are what that shape delivered.

What the run says, in the order it matters:

  • Scaling is close to linear to four cores (98 %, 92 % on memory; 91 %, 85 % and 90 %, 88 % on the two pools) and loses half of that at eight.
  • The eighth core is used, not idle: 7.64 of 8 on memory, 7.03 and 6.47 on the two pools. What changes is the price of a request, which is flat from one core to four and then rises by about 80 % on all three stores. A cost that appears identically in the in-process store, which never parks a thread on a socket, is not the storage driver: it is contention above the store, in the path the three shapes share.
  • The blocking pool is nowhere near its ceiling. The Postgres write shape parks 4 038 live OS threads at its knee against a ceiling of 11 024 (ANTARES_MAX_CONNECTIONS plus 1 024, main.rs), and the query shapes park 64 to 77. Nothing deadlocks and no shape reaches the cap.
  • Pool size barely moves anything on this box. Pool 100 is 8 % faster at eight cores and 10 % slower at 1 024 concurrent query clients; both pools hold the same 1 000 rps write knee and fail at the same 1 500. The ccx33 run's "a larger pool is worse" reads as an artefact of four physical cores, not a property of the pool.
  • The write path is what bends first. Both Postgres pools hold 1 000 rps and fail at 1 500 while every query shape holds the harness ceiling of 5 000 rps; the in-memory write path holds 5 000 rps on 0.47 cores.

The update shape

The ladder above is the query shape. perf-weekly run 33797374897, same ccx53 box, runs it again against the update shape — the write path with the notification pipeline behind it — once per store:

store1 core2 cores4 cores8 coresefficiency at 2 / 4 / 8cores used at 8peak threads at 8
memory13 635 req/s19 498 req/s11 573 req/s12 538 req/s71 % / 21 % / 11 %6.7844
file3 870 req/s4 492 req/s3 744 req/s3 617 req/s58 % / 24 % / 12 %0.9417

Updates do not merely scale worse than queries: past two cores they scale backwards, and eight cores serve fewer requests per second than one while burning 6.78 of them. Something serializes and the cores spend their time arriving at it.

Not the store's map lock, which is what a single-tenant ladder would blame and what tenant sharding would answer: this ladder drives one tenant, so sharding by tenant would leave every request on the same shard and change nothing. What the write path does that the query path does not is hand the worker's queue to another thread — block_in_place — for every document it writes, so the store can commit without stalling an async worker. In file mode that commit is an fsync and the hop is the point. In memory mode there is no commit: the write is a lock and a map insert, and the hop is the whole cost, paid once per write and paid more the more cores there are to hand work between. The hop is now taken only where something blocks; the paths that hold the write section for a whole scan (the 4.22 sweep, the two purges) still take it in either mode.

The exit criterion

perf-weekly run 33683839528 sets what the runtime work has to hold. A change to the request runtime, the drivers or the store keeps all of it:

  • Efficiency at eight allotted physical cores, query at c50: at least 51 % on memory and 43 % on either Postgres pool.
  • CPU per request at eight cores: no more than twice its one-core value on any store. The run itself sits at 1.97 (memory), 1.89 (pool 20) and 1.87 (pool 100).
  • Saturation, whole box: the knee at 5 000 rps or better for every query shape and 1 000 rps or better for the Postgres write shape, p99 at the knee within 3.0 ms.
  • Live OS threads at the write knee: 4 038 or fewer, against the 11 024 ceiling.

Deployment scenarios

dev/perf/scenarios.sh starts one or more brokers from the release binary (dev/perf/fleet.sh), seeds each world over the API and asks one question per scenario. MODE=check runs the conformance assertions of dev/perf/scenario-check.py against a memory-store fleet in seconds; MODE=load runs the same assertions first and then a k6 rate ladder whose every number comes from k6's summary or the sink's counters. A verdict is computed from those numbers and its note names the failing assertion. The fleet notifies a single-process sink.py of its own on port 9810: the load rig's sink on 9800 runs multi-process, and its front door only folds the workers' counters.

MODE=check STORE=memory   ./dev/perf/scenarios.sh              # all nine
MODE=load  STORE=postgres ./dev/perf/scenarios.sh loop fan-in  # a subset

The nine worlds, and what each one asks:

  • S1 hot-entity: one broker, 1 000 vehicles. Concurrent partial updates on one entity (5.6.3, distinct datasetIds) versus the same load spread over all of them: are updates lost, and what does the contention cost in p99?
  • S2 noisy-tenant: one broker, tenants quiet and loud. The quiet tenant's GET p99 is measured alone and under a write flood on loud (4.14 isolation, in time as well as in data).
  • S3 slow-subscriber: one broker, ten fast endpoints and one that answers after 500 ms. Does the slow one hold the fast ones back, given ANTARES_DELIVERY_WIDTH_PER_TENANT?
  • S4 fan-in: one broker, 50 subscriptions matching one entity. How many notifications per second does one update stream fan out to, and are all of them delivered?
  • S5 hub-sources: a hub and two source brokers, registered with the tenant member (a forward never carries the client's tenant). Federated query and retrieve through the hub (4.3.6.1, 5.7.2): complete, merged, 404 for an absent id.
  • S6 collision: the same entity id in the hub and a source. 4.5.5 merge of non-colliding attributes, 4.3.6.2 local data over an auxiliary source, 5.9.2.4 409 for an exclusive or redirect registration that overlaps data already held.
  • S7 loop: brokers A and B registered to each other. A query terminates with each side's data once (6.3.18 Via); a write whose chain already names the receiver runs locally under an inclusive registration and answers 508 (6.3.17) when the only matching source is a redirect. An id-only write carries no type, so every type-only registration matches it: the 508 case needs a tenant whose only registration is the redirect.
  • S8 distributed-subscription: a subscription at the hub, the entity at a source. 5.8.1.4: the hub plants a reduced copy at the source (registration operations includes federationOps) and a change at the source reaches the hub's subscriber, counted against the accepted updates.
  • S9 ha-pair: two broker pods on one PostgreSQL database and one NATS JetStream bus (NATS_URL, PG_URL_BASE; skipped without them). Writes alternate between the pods; notifications are counted against accepted writes, so a duplicate or a loss shows as a mismatch.

The tables land in scenarios/ of the run artifact; report.py folds them into index.html and pdf.py gives each scenario a page (world, question, what CIM 009 requires, verdict, measured numbers).

The design targets (scale-weekly)

The README's target table is a design contract; this run is where each row gets its measured column. SCALE scales every count linearly, so the same rig runs at 0.0001 against a laptop's Postgres and at 1.0 on the rented box:

stagescriptat scale 1.0
entitiesgen.py streaming into dev/bulk-load.sh (one COPY stream)100,000,000 over 10,000 tenants, five attributes and a location each
subscriptionsapi-load.py subscriptions100,000, one per tenant round-robin, HTTP to the sink, every tenth over MQTT
registrationsapi-load.py registrations100,000, one id pattern each, endpoint at the sink
resident setrss.shbroker and Postgres backends sampled at 1 Hz for the whole run, peaks printed as the verdict table. Ceilings are opt-in through BROKER_MIB and PG_GIB, and only a run at scale 1.0 lets them fail the step; neither is set today, because a budget is read off these runs rather than asserted ahead of them
at loadshapes.sh, saturate.shthroughput per tenant, the knee
subscriptions firingfire.shupdate + delete streams over the loaded entities at 100, 200, 500, 1,000, 2,000 and 4,000 rps; every update fires each subscription of its tenant once, so the notifications due are known; the table shows due, delivered, the distinct subscriptions that fired, how long the sink kept receiving after the stream, failed operations by class (no HTTP answer / 4xx / 5xx) and the broker's own counters over the rate (changes the bounded matcher queue dropped, dead letters), so a delivery gap is attributed to the queue, the delivery policy or the receiver; the limit is the last rate that delivered 99% with no failed operation
per-class deliveryfire.shfire-classes.mdthe subscriptions (10 000 by default, the subs dispatch input) fall into eight filter classes (type, q, watchedAttributes, idPattern, geoQ, scopeQ; subs.md) and every one is unique: p = k // tenants parametrises its q threshold, idPattern tail, polygon edge or scopeQ branch, and k6 evaluates the same rule, so due and delivered are reported per class
federated queriesfed.shfive query shapes (type, q, geoQ, scopeQ, idPattern) on random tenants over the registrations (10 000 by default, the regs input; each with its own idPattern, polygon or scope) of eight classes (csr.md: mode, operations, csf properties, headers, expiry, location, scopes); every source is the sink; the row shows queries, failures, queries with a source warning, p99, source calls and calls per query
CPU and memoryrss.shrss.csv, rss.md1 Hz: broker and Postgres RSS, broker and Postgres CPU in cores, and whole-host busy cores against the core count — the saturation check; every fire.md / fed.md row carries the mean over its own window
PDFpdf.pyreport.pdfthe narrated report (what was stored, who called, the conditions of each number) built by dev/perf/pdf.py, next to index.html and perf.json in the downloadable results folder

dev/perf/sink.py is the other end of every subscription and registration: it counts notifications and answers forwarded queries with an empty list, so the fan-out over 100,000 registrations costs the broker the matching and the HTTP round trips and nothing else.

Every run is capped at one hour: the server's TTL, the job timeout and the box's own shutdown timer agree on it. Scale 0.01 (1,000,000 entities, 100 tenants, 1,000 subscriptions, 1,000 registrations) fits with margin and is what the schedule runs; 1.0 does not fit in an hour and is a deliberate dispatch on a bigger box with the TTL raised in the workflow. Bulk load bypasses the broker (no notifications, no history), which is the documented path for initial loads in Operations.

The measured run

scale-weekly run 33863032274 on a ccx33, SCALE=0.01 with the subscription and registration counts raised to 10,000 each: 1,000,000 entities over 100 tenants. The load took 367 s for the entities (one COPY stream), 8 s for the subscriptions and 159 s for the registrations.

storeready in (median of 5)RSS after start
memory39 ms18 MiB
file51 ms19 MiB
postgres358 ms59 MiB
storeshapeconcurrencyreq/sp99
memoryqueryc507 43119.97 ms
memoryqueryc2007 82170.19 ms
memoryretrievec5029 9137.47 ms
postgresqueryc5091261.37 ms
postgresqueryc200933207.85 ms
postgresretrievec501 84129.08 ms
storeshapeknee (rps held)p99 at kneefirst failing stagebroker corespeak threads
postgresquery3 00010.5 ms3 5002.5613
postgreswrite1 0005.2 ms1 5001.6413

Subscriptions firing, over 10,000 subscriptions on 101 tenants:

rate (rps)duedelivereddelivered %POSTs/sdropped by brokerdead lettersPATCH p99broker coreshost busy
10096 49296 492100.01 637.10025.2 ms1.63.5
200193 406193 406100.03 250.80028.1 ms3.06.0
500481 52640 3308.4561.326 39801 184.1 ms4.57.1

Against run 33835261405, the same shape on the same server type before the per-drain @context memo landed:

rate (rps)delivered %POSTs/sdroppedPATCH p99quiet after
200 before99.92 742.71469.6 ms4 s
200 after100.03 250.8028.1 ms0 s
500 before12.7847.925 359645.7 ms4 s
500 after8.4561.326 3981 184.1 ms2 s

Federated queries, over 10,000 registrations, every source the sink:

rate (rps)queriesfailed (conn/4xx/5xx)with a source warningGET p99source callscalls per querybroker coreshost busy
501 4870 (0/0/0)01 821.5 ms50 67534.081.85.9
1002 3460 (0/0/0)012 697.0 ms80 50234.312.27.9
2003 6150 (0/0/0)033 731.2 ms130 09635.992.27.9
5001 9280 (0/0/0)053 754.9 ms159 94382.961.98.0

Resident set and CPU over the whole run, 1 085 samples about 1.3 s apart:

measurevalueceiling
broker RSS peak3 181 MiBno ceiling set
Postgres RSS peak11.86 GiBno ceiling set
broker CPU peak / mean5.3 / 1.1 coresof 8
Postgres CPU peak / mean6.5 / 2.1 coresof 8
host busy peak / mean8.0 / 4.7 coresof 8: saturated when peak ≈ 8

What the run says:

  • Delivery is exact to 200 rps of writes: all 193 406 due notifications arrive, nothing is dropped, and the sink goes quiet in the same second the writes stop. The run before the memo delivered 99.9 % of the same shape, dropped 14 changes and took four seconds to drain. Write latency at that rate falls with it, 69.6 ms to 28.1 ms at the 99th percentile, because the matcher drain is no longer competing with the write path for a core.
  • At 500 rps both runs are past the knee the run itself reports, and the newer one is further past it: the drop count barely moves (25 359 to 26 398) but what survives is delivered more slowly (848 to 561 POSTs/s). Cheaper matching feeds the delivery stage faster, so the queue behind it fills sooner. The limit line reads 200 rps in both runs; what changed is that 200 rps is now met exactly instead of nearly.
  • The registry narrowing holds. Each tenant carries 100 registrations and a type=Vehicle query reaches 34 of them — the three Vehicle-typed classes of the eight in csr.md — so the index decides the fan-out and the forward path is not a broadcast. The ratio is flat from 50 to 200 rps.
  • The distributed path returns no 5xx, no 4xx and no NGSILD-Warning over 9 376 queries: every one of the 421 216 source calls was answered and folded in.
  • Where the broker bends, it is not out of CPU. At the Postgres query knee it holds 3 000 rps on 2.56 of 8 cores and at the write knee 1 000 rps on 1.64; on the federated path it sits at 2.2 cores while Postgres takes 5.1 and the host runs out at 7.9. The component that saturates is the database or the machine, never the broker alone.
  • A federated query costs about eight times the Postgres work of a direct query of the same shape (42.5 against 5.5 mcores). The fan-out is 34 source calls and a registry match over 10,000 rows; the direct row is one local read, so the two are not the same unit of work and the ratio is the price of federation, not a regression against a like-for-like baseline. The broker side of that comparison is not available from this run. The sampler matched broker processes against the whole of the first broker's argv[0], and the shapes, saturate and startup stages start their own brokers from the same binary by a different path form, so it counted none of them and reported the idle first process instead — zero cores through windows serving 912 req/s. saturate.md disagrees with rss.csv for the same window (2.56 cores against zero) because that stage measures the process it started itself. The sampler now matches on the last two path components, so a later run carries a broker CPU column for every stage; the rows above are left as they were measured.

How each measurement works

No rig script is a unit test. Every one starts a real broker, drives it over HTTP, and folds what came back into one Markdown table. The pieces are the same in all of them:

  dev/perf/<script>.sh
        │
        ├── starts ──► antares  (release binary, one store)  :9090
        │                 │
        │                 └── store: memory | file | postgres (docker)
        │
        ├── drives ───► k6 (a dev/perf/k6-*.js scenario)  or  python3
        │                 └── constant-arrival-rate: offered load, not
        │                     closed-loop, so a slow broker shows up as a
        │                     growing queue instead of a slower client
        │
        ├── receives ─► dev/perf/sink.py  :9800
        │                 ├── POST /…      one notification, counted
        │                 └── GET  /csr/k  one forwarded query, answered []
        │
        └── samples ──► dev/perf/rss.sh    1 Hz → rss.csv
                          reads /proc for broker, Postgres, k6, sink, host

$OUT/phase is the thread that ties them together: each script writes the stage name into it before it starts, and rss.sh copies that string into every sample it takes, so any row of rss.csv can be attributed to the stage that caused it.

startup.sh — how long a cold broker takes to answer

Runs the binary, polls GET /q/health until the first 200, records the elapsed time and reads VmRSS out of /proc/<pid>/status immediately after. Five times per store, median reported. Nothing else is running, so the number is the process itself: binary load, config parse, store open, listener bind.

shapes.sh — throughput per request shape

Starts one broker, seeds 100 five-attribute entities through the API, then runs k6-shapes.js closed-loop at a fixed number of clients: a list query at 50 and 200, a single retrieve at 50. Three runs of five seconds, median reported, p99 from the same runs. Closed-loop on purpose — this table answers "how fast is one shape", not "where does it break".

core-scale.sh — does the broker use the cores it is given?

Pins the broker to 1, 2, 4 and 8 physical cores with taskset (SMT siblings excluded) and keeps k6 on the cores left over, refusing any step where the two would share one. The in-memory store on purpose: the question is whether the broker's own work parallelises, so nothing waits on a database.

saturate.sh — where the knee is

Open model. k6-saturate.js raises the arrival rate by 500 rps every stage and keeps going until p99 passes P99_MS or the error rate passes ERR. The knee is the last stage that held both. A run that never fails a stage reports none reached, which means the ladder is shorter than the box — the answer is more stages, not a bigger conclusion.

load.sh — building the dataset

The only stage that does not go through k6:

load.sh
  ├─ sink.py 9800 8        one front door, eight worker processes behind it
  │                        (one CPython process tops out near 5 000 req/s)
  ├─ gen.py | bulk-load.sh entities, one COPY stream straight into Postgres,
  │                        bypassing the broker: no notifications, no history
  ├─ api-load.py subscriptions   through the API, eight filter classes
  └─ api-load.py registrations   through the API, eight CSR classes

Entities bypass the broker because a hundred million of them through the API would measure the loader. Subscriptions and registrations do not: they have to pass validation and land in the matcher's index, which is what the later stages exercise.

fire.sh — do the subscriptions fire, and up to what rate

k6-fire.js                broker :9090                    sink.py :9800
    │                          │                                 │
    │ PATCH …/attrs (update)   │                                 │
    │ DELETE …/entities/{id}   │                                 │
    ├─────────────────────────►│                                 │
    │              204         │ 1. write the change              │
    │◄─────────────────────────┤ 2. match it against this         │
    │                          │    tenant's subscriptions        │
    │                          │ 3. queue it (changeQueue, 1024)  │
    │                          │ 4. deliver, deliveryWidth (64)   │
    │                          │    in flight, 8 per tenant       │
    │                          │                                  │
    │                          │  POST /  {"data": [entity, …]}   │
    │                          ├─────────────────────────────────►│
    │                          │                          204     │
    │                          │◄─────────────────────────────────┤
    │                          │                                  │
    └── k6 stops ──────────────┴── fire.sh polls /stats until the │
                                   count stops moving for 5 s     │

The count due is not measured, it is derived: k6-fire.js knows which subscription classes each write should trigger and evaluates the same rule api-load.py used to create them, so delivered / due is a real ratio and not a guess. quiet after is how long the sink kept receiving once the stream stopped — the drain. dropped by broker comes from the broker's own changesDropped counter, read from /q/health before and after, so a delivery gap can be charged to the queue, to the delivery policy or to the receiver rather than left ambiguous.

The limit is the last rate that delivered 99 % with no failed operation, and the ladder stops at the first rate that misses it.

fed.sh — federated queries over the registrations

The registrations point at sink.py, which answers every forwarded query with an empty array. That is deliberate: an empty answer costs the broker the index lookup, the fan-out and the HTTP round trips and nothing else, so the row measures the federation machinery rather than a source.

k6-fed.js                    broker :9090                        sink.py :9800
    │                             │                                      │
    │ GET /entities?type=Vehicle  │                                      │
    │ NGSILD-Tenant: t42          │                                      │
    ├────────────────────────────►│                                      │
    │                             │ 1. expand the query against @context │
    │                             │                                      │
    │                             │ 2. csource_index lookup (5.12): one  │
    │                             │    SQL query, narrowing on entity     │
    │                             │    type and entity id alone. It may  │
    │                             │    only REMOVE registrations the     │
    │                             │    matcher would reject anyway, so   │
    │                             │    a NULL dimension always survives  │
    │                             │                                      │
    │                             │ 3. the matcher decides the rest in   │
    │                             │    Rust: geoQ, scopeQ, csf, the      │
    │                             │    intervals, the idPattern regex,   │
    │                             │    the Via chain                     │
    │                             │                                      │
    │                             │ 4. fold registrations naming the     │
    │                             │    same source into one request      │
    │                             │    (5.2.9: same endpoint, mode,      │
    │                             │    tenant, alias, headers, localOnly)│
    │                             │                                      │
    │                             │ 5. narrow each forward to what its   │
    │                             │    registration declares (4.3.6.1)   │
    │                             │                                      │
    │                             │ 6. fan out, ANTARES_FED_FANOUT (8)   │
    │                             │    in flight, each bounded by the    │
    │                             │    registration's timeout            │
    │                             │                                      │
    │                             │  GET /csr/17/entities?type=…&attrs=… │
    │                             │  Via: 1.1 broker-a                   │
    │                             ├─────────────────────────────────────►│
    │                             │                        200  []       │
    │                             │◄─────────────────────────────────────┤
    │                             │      × N per query (`calls per       │
    │                             │        query` in the table)          │
    │                             │                                      │
    │                             │ 7. book the outcome on each          │
    │                             │    registration: timesSent,          │
    │                             │    timesFailed, lastSuccess,         │
    │                             │    lastFailure, status               │
    │                             │    (Table 5.2.9-2)                   │
    │                             │                                      │
    │                             │ 8. merge the halves (4.5.5),         │
    │                             │    paginate, answer                  │
    │       200 + entity list     │                                      │
    │◄────────────────────────────┤                                      │

calls per query is the number that matters: it is the fan-out the registry narrowing left behind, and it multiplies everything downstream. A query that reaches 34 sources costs 34 HTTP round trips, 34 timeout budgets and 34 bookkeeping writes.

Step 7 is where the federated path meets the database, once per source call rather than once per query:

one forwarded request
      │
      └─► note_forward (federation.rs)
             └─► CurrentStateDriver::record_forward
                    └─► record_forward_via_mutate (antares-store)
                           └─► PgDocStore::mutate(Kind::Registration)
                                  ├─ SELECT … FOR UPDATE   the row lock
                                  ├─ UPDATE csource_registrations
                                  └─ csource_index: rebuilt only when the
                                     write moved a member the index is
                                     built from — the counters are not

The failed (conn/4xx/5xx) and with a source warning columns are split because they are different faults: a failed query is the broker not answering, while a warning is the broker answering after a source did not (6.3.17), which is the documented outcome and not an error.

rss.sh — the CPU and memory column on every other table

One background sampler for the whole run. It resolves the broker by the last two components of its argv[0], the Postgres container by name, and k6, the sink and mosquitto by their own names, then writes RSS and CPU for each at 1 Hz along with whole-host busy cores. Every fire.md and fed.md row folds the samples that fall inside its own window, which is why a saturation claim can be checked against the phase that made it.

report.py — the artefacts

Folds every table into index.html, perf.json and invokes dev/perf/pdf.py to build report.pdf (the narrated report explaining what was stored, who called, and the conditions of each measured number), next to the raw CSVs, so a later run can be diffed against this one without rerunning anything.

Measuring delivery without the rented runner

fire.sh needs k6, so until now the notification pipeline could only be measured by dispatching scale-weekly. dev/perf/deliver.py drives the same path with the standard library alone, against a memory-store broker and sink.py:

ANTARES_STORE=memory ANTARES_EGRESS_ALLOW_PRIVATE=true antares &
python3 dev/perf/sink.py 9800 8 &
python3 dev/perf/deliver.py --seed --tenants 10 --entities 500
python3 dev/perf/api-load.py subscriptions --count 240 --tenants 10 \
  --sink-workers 8 --broker http://127.0.0.1:9090 --sink http://127.0.0.1:9800
python3 dev/perf/deliver.py --tenants 10 --entities 500 --rate 2000

The number to read is matches_per_second. One match is one (subscription, entity) pair: it is what the matcher evaluates and what a notification carries, so it is the unit both halves of the pipeline scale with. changes_per_second divides that by how many subscriptions each change fires, so it moves whenever the subscription set changes even though the pipeline is doing identical work.

What it measures, on twelve cores with a release build, at an offered 2 000 changes per second over ten tenants and 500 entities:

subscriptions over 10 tenantsmatches/sbroker coreschanges dropped
602 2010.910
1206 0561.240
24011 7811.280
48025 5471.890
96044 8181.901 191

Matches per second rise with the subscription count, because a change is evaluated against more subscriptions and fires more of them. The broker carries the whole offered change rate up to 480 subscriptions over ten tenants, and drops 0.15 per cent of it at 960.

Holding the subscription count at 240 and raising the offered rate instead:

offered changes/sachievedmatches/sbroker coreschanges dropped
2 0002 00012 0741.720
3 0003 00117 9982.180
4 0004 00423 9482.480
6 0005 13130 6832.740

The last row measures the harness rather than the broker: deliver.py runs its writers as threads in one interpreter, and it cannot produce the 6 000 changes per second it was asked for. The broker absorbed every change offered at every rate, dropped none of them, and held 2.74 of twelve cores, so the table gives a floor for the delivery bound rather than the bound itself. Reaching that bound needs the writers in separate processes.

ANTARES_DELIVERY_WIDTH is not what governs this. Over a hundred-tenant shape at 1 000 changes per second, widths of 8, 64, 256 and 1 024 deliver 7 026, 7 028, 7 026 and 7 028 matches per second, none of them dropping a change: the offered load is absorbed whole at every width, so the knob has nothing to arbitrate.

The same ladder against PostgreSQL, which is what a deployment runs (synchronous_commit=off, 1 GiB of shared buffers, a pool of 50, the database sharing the box with the broker, the harness and the sink):

offered changes/sachievedmatches/sbroker coreschanges dropped
5005003 0510.750
1 0001 0006 0621.250
2 0001 67110 1081.390
4 0001 1406 9271.260

A change costs an entity update and a temporal-history insert here rather than a map write, so the knee arrives near 1 671 changes per second against the memory store's 7 800. Two things about that knee matter more than its position. The broker holds 1.39 cores at it while PostgreSQL holds 2.29 on average and peaks at 4.12, so the broker is not the component that runs out — which is why a criterion written as broker core utilization at saturation cannot be met on a rig that shares one box. And goodput goes backwards past the knee: offering 4 000 changes per second delivers less than offering 2 000, with broker CPU falling as it does, because nothing sheds write load before it drives the database past what it can commit. Neither is a property of the delivery path; both are the write path underneath it.

Setting up the rented runner (two repository secrets)

perf-weekly and scale-weekly rent a Hetzner Cloud server for the run (a ccx33 and a ccx53; the design targets add a volume), register it as an ephemeral GitHub runner, and delete it afterwards; perf-janitor sweeps anything past its expiry label. Until the two secrets below exist, both workflows stop at "Create server" with an empty token, so the weekly runs stay red and no perf report is published.

1. HCLOUD_TOKEN — Hetzner Cloud API token

  1. Sign in at https://console.hetzner.cloud/.
  2. Create a project of its own for this (for example antares-perf) so the token cannot touch anything else, and set a spending alert on the project (Project → BillingAlerts).
  3. In that project open SecurityAPI tokensGenerate API token. Name it github-actions, permission Read & Write (the workflow creates and deletes servers and volumes and reads the price list).
  4. Copy the token once; Hetzner never shows it again.

2. RUNNER_PAT — GitHub token that can register a runner

The workflow asks the GitHub API for a runner registration token (POST /repos/<owner>/<repo>/actions/runners/registration-token), which the default GITHUB_TOKEN is not allowed to do.

  1. GitHub → Settings (your account) → Developer settingsPersonal access tokensFine-grained tokensGenerate new token.
  2. Resource owner: the account that owns this repository. Repository access: Only select repositories → this repository.
  3. Repository permissions: Administration: Read and write. Nothing else.
  4. Expiration: one year is the maximum; put the renewal date in your calendar, the workflow fails with HTTP 401 when it lapses.
  5. Copy the token once.

A classic token with the repo scope works too, but grants far more than the workflow needs.

3. Store both as repository secrets

Repository → SettingsSecrets and variablesActionsNew repository secret, twice:

NameValue
HCLOUD_TOKENthe Hetzner token from step 1
RUNNER_PATthe GitHub token from step 2

The names must match exactly; the workflows read them as ${{ secrets.HCLOUD_TOKEN }} and ${{ secrets.RUNNER_PAT }}.

4. Limits on a fresh Hetzner project

A new project starts with small quotas. perf-weekly asks for a ccx33 (8 dedicated cores) and scale-weekly for a ccx53 (32); scale-weekly at 1.0 would add a 500 GB volume, and Hetzner answers dedicated core limit exceeded / volumes size limit exceeded until the limits are raised: Project → Limits → request more dedicated cores and volume storage (a short form, usually approved within a day). The 0.01 dry run needs the core limit only; it shrinks the volume to 10 GB.

5. First run

Actions → scale-weeklyRun workflow with scale = 0.01 (about an hour, a few euros); read the step summary for the cost line and the tables. Then Actions → perf-weeklyRun workflow. Both schedules take over from there (Saturday 03:17 UTC and Sunday 02:17 UTC), and pages folds the newest bundles into /reports/perf/latest/.

If you would rather not rent hardware, disable the two workflows (Actions → workflow → Disable workflow) so the weekly runs stop going red.

What a façade costs

A façade for another standard answers by driving this broker's own NGSI-LD router in process (Façades for another standard). The seam's own cost is a JSON round trip: the inner answer is serialized to bytes, parsed, and re-serialized into the façade's envelope. Everything else about the request happens exactly once.

Measured with the reference façade (GET /x/example/things?kind=Vehicle) against the NGSI-LD request it wraps (GET /entities?type=Vehicle&options=keyValues), both through the same router in the same process, 200 calls each, medians of five repetitions, release build:

answerfaçadethe request it wrapsthe round trip
100 five-attribute Entities265-297 µs199-226 µs66-71 µs
nothing matched112-127 µs107-157 µsunder 10 µs

The comparison is in process on purpose: the number is about a serialize and a parse, and a socket between the two halves would measure the socket. dev/perf/shapes.sh runs the same pair end to end against a built binary (the facade shape, skipped unless the binary was built with the reference plugin); the table above is the per-call figure.

What it decides: the seam has almost no fixed cost — an empty answer's round trip does not clear the noise — and what it does cost is proportional to the answer, about 0.7 µs per Entity on this shape. A typed operations layer, one where a façade reached the handlers through Rust types instead of through JSON, would save exactly that and nothing else. Sixty-six microseconds on a hundred-Entity page is not a reason to build and maintain a second, typed API surface beside the HTTP one, so that box stays closed until a façade measures this as its ceiling rather than as its rounding error.

What the numbers are not

They are one machine, one request shape, one week. A different instance type invalidates the whole history, which is why both workflows pin one. No regression gate exists until the noise profile has ten runs on that instance type; until then the runs report, and the report is the evidence.