Antares
Antares is an NGSI-LD Context Broker written in Rust: one native binary that stores entities, answers queries, keeps attribute history, delivers notifications over HTTP and MQTT, and federates with other brokers through Context Source Registrations. The same crates compile to WebAssembly and run the whole broker inside a web page.
What it implements
ETSI GS CIM 009 V1.9.1, the NGSI-LD API, clause by clause: the information model (4.x), the operations (5.x), the HTTP binding (6.x) and the MQTT notification binding (7). The Conformance chapter carries the ledger, one file per clause, and the ETSI test suite runs against every storage backend in CI. NGSI-LD 2.0 splits the same material into a core specification and an HTTP binding; Antares keeps the operation semantics and the binding in separate modules for that reason, and the Extending Antares chapter says what a new binding attaches to.
Three deployment shapes
| shape | store | bus | when |
|---|---|---|---|
| single binary | memory or file (redb on a volume) | in-process | development, edge devices, one node, the browser build |
| postgres | postgres (PostGIS) | in-process or NATS | production on one broker |
| scaled | postgres or timescale | NATS JetStream | several stateless broker pods, role split, rolling updates |
Current state and history are chosen independently: ANTARES_STORE
picks where entities live, ANTARES_TEMPORAL where their history goes,
including none. Storage drivers has the ladder and the
measured costs.
What the broker does not do
Authentication, rate limiting and quotas belong to the policy enforcement
point in front of the broker (an API gateway), and so does every
authorization decision: the broker ships no policy engine, only the seam
one attaches to (ADR-0020). The broker validates, stores, serves, notifies
and federates; it trusts the NGSILD-Tenant header it receives and
enforces tenant isolation in the store. The Shared crates chapter shows how a gateway
uses the broker's own parser, query engine and matcher to make its
decisions with identical semantics.
Which chapter
| you want to | read |
|---|---|
| run a broker in five minutes | Getting started |
| set every knob | Configuration |
| run it in production, back it up, roll it | Deployment, Operations |
| receive notifications | Subscriptions and notifications |
| query history | Temporal API |
| connect brokers | Federation |
| run it in a browser | Browser & WebAssembly |
| see the health, metrics and admin routes | Admin API |
| know what is conformant | Conformance |
| add a backend or a hook | Extending Antares |
| compare with other brokers | Ecosystem & positioning |
Links: live conformance report, browser playground, source, ADRs.
Getting started
Every snippet on this page was executed against a broker built from this repository before being committed.
Install
Docker (multi-arch, amd64 + arm64):
docker run --rm -p 9090:9090 ghcr.io/joinedcontext/antares-broker:latest
From source (Rust toolchain per rust-toolchain.toml):
cargo run -p antares-broker # serves http://0.0.0.0:9090
Release binary: attached to GitHub releases (from v0.1.1 on).
The default configuration needs zero infrastructure: in-memory store, in-process bus, all roles in one process. Check it is up:
curl -s localhost:9090/q/health # {"status":"UP","store":"memory",...}
First entity
curl -i -X POST localhost:9090/ngsi-ld/v1/entities \
-H 'Content-Type: application/ld+json' \
-d '{
"id": "urn:ngsi-ld:TemperatureSensor:001",
"type": "TemperatureSensor",
"temperature": {"type": "Property", "value": 21.5, "unitCode": "CEL"},
"@context": "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.9.jsonld"
}'
# HTTP/1.1 201 Created
# Location: /ngsi-ld/v1/entities/urn:ngsi-ld:TemperatureSensor:001
curl -s 'localhost:9090/ngsi-ld/v1/entities?type=TemperatureSensor'
First subscription
Start something that shows incoming HTTP requests — nc -l 9491 is enough —
then subscribe:
curl -i -X POST localhost:9090/ngsi-ld/v1/subscriptions \
-H 'Content-Type: application/ld+json' \
-d '{
"id": "urn:ngsi-ld:Subscription:demo",
"type": "Subscription",
"entities": [{"type": "TemperatureSensor"}],
"watchedAttributes": ["temperature"],
"notification": {"endpoint": {"uri": "http://127.0.0.1:9491/notify"}},
"@context": "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.9.jsonld"
}'
# HTTP/1.1 201 Created
Change the watched attribute — the fragment is application/ld+json (with
Content-Type: application/json the @context would have to travel in the
Link header instead; mixing both is rejected per CIM 009 clause 6.3.5):
curl -i -X PATCH \
localhost:9090/ngsi-ld/v1/entities/urn:ngsi-ld:TemperatureSensor:001/attrs/temperature \
-H 'Content-Type: application/ld+json' \
-d '{"type": "Property", "value": 42.0,
"@context": "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.9.jsonld"}'
# HTTP/1.1 204 No Content
The listener receives:
{"id": "urn:ngsi-ld:Notification:ca63483a-bc7e-4731-a36f-1168f455eb97", "type": "Notification",
"subscriptionId": "urn:ngsi-ld:Subscription:demo", "notifiedAt": "2026-08-26T16:16:00.851Z",
"data": [{"id": "urn:ngsi-ld:TemperatureSensor:001", "type": "TemperatureSensor",
"temperature": {"type": "Property", "unitCode": "CEL", "value": 42.0}}]}
MQTT delivery works the same way with an mqtt[s]:// endpoint URI.
First federation pair
Two brokers, one Context Source Registration. Local processes talk over
loopback, which the egress policy allows by default; on an
internet-facing deployment set ANTARES_EGRESS_ALLOW_PRIVATE=false
(see Configuration):
ANTARES_HTTP_PORT=9391 antares & # broker A
ANTARES_HTTP_PORT=9392 antares & # broker B
Create an entity only broker B knows:
curl -s -X POST localhost:9392/ngsi-ld/v1/entities \
-H 'Content-Type: application/ld+json' \
-d '{"id": "urn:ngsi-ld:ParkingSpot:B:042", "type": "ParkingSpot",
"status": {"type": "Property", "value": "free"},
"@context": "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.9.jsonld"}'
Tell broker A that broker B serves ParkingSpot entities:
curl -i -X POST localhost:9391/ngsi-ld/v1/csourceRegistrations \
-H 'Content-Type: application/ld+json' \
-d '{
"id": "urn:ngsi-ld:ContextSourceRegistration:brokerB",
"type": "ContextSourceRegistration",
"information": [{"entities": [{"type": "ParkingSpot"}]}],
"endpoint": "http://127.0.0.1:9392",
"@context": "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.9.jsonld"
}'
# HTTP/1.1 201 Created
Query broker A — the result comes from broker B through the registration:
curl -s 'localhost:9391/ngsi-ld/v1/entities?type=ParkingSpot'
# [{"id":"urn:ngsi-ld:ParkingSpot:B:042","type":"ParkingSpot",
# "status":{"type":"Property","value":"free"}}]
That is the whole federation model: registrations route queries (and writes, subscriptions, temporal queries) to the sources that declared the matching types and id patterns.
Next
- Subscriptions and notifications: formats,
showChanges, periodic delivery, throttling, MQTT, retries. - Temporal API: how history is recorded and queried.
- Federation: registration modes, loop protection, partial failures, context source subscriptions.
- Deployment and Operations when it leaves the laptop.
Deployment
Every shape on this page is exercised by CI: the compose stacks run the
full ETSI suite in the matrix cells, the HA/rolling shape runs weekly
(roll-weekly) and in the two -nats cells, and the k8s manifests encode
the same constraints. Resource numbers are measured by the matrix run
(1 Hz sampling of every broker process, tables in each run's CI summary).
Sizing (measured, full ETSI suite as workload)
| Shape | RSS avg | RSS peak | Notes |
|---|---|---|---|
| Native broker, any store | ~35 MiB | 38–59 MiB | peak is the Subscription suite |
| Idle | ~9 MiB | — | memory store, no traffic |
| wasm Node shim | 74–111 MiB | up to 185 MiB | Node runtime overhead, not the broker |
The CI resource gate enforces 350 MiB during the suite. Postgres sizing follows standard PostgreSQL tuning; the weekly scale run publishes the measured resident set of both.
Sizing under load (measured, weekly scale run)
One broker process holding every role, 1 000 000 entities over 100 tenants, 10 000 subscriptions and 10 000 registrations, on eight cores:
| Workload | broker RSS peak |
|---|---|
| Queries and retrieves, saturated | 102 MiB |
| Notifications, 100–500 updates/s | 388 → 786 MiB |
| Forwarded reads, 50 queries/s (34 sources each) | 809 MiB |
| Forwarded reads, 500 queries/s | 5297 MiB |
The last row is a queue of unfinished requests, not a working set: the p99
is 34 s and one query in seven answers 5xx, while 50 queries/s answers every
one with a p99 of 385 ms. Between those rows the broker's resident set grows
by about 1.8 MiB per forwarded read in flight, and what bounds that number is
ANTARES_MAX_CONNECTIONS — so a pod's memory limit and its connection
ceiling are one decision, not two. The default ceiling of 10 000 admits far
more than a small pod can hold: the reference manifests set 512 against a
1 GiB limit.
Single node, no database
# memory: state dies with the process — tests, demos
docker run --rm -p 9090:9090 ghcr.io/joinedcontext/antares-broker:latest
# file: durable via redb, fsync-before-ack; the data dir MUST be a volume
docker run --rm -p 9090:9090 \
-e ANTARES_STORE=file -e ANTARES_DATA_DIR=/data \
-v antares-data:/data \
ghcr.io/joinedcontext/antares-broker:latest
file mode constraints (measured, documented in the README store table):
queries run on in-memory maps (~19 KB RSS for a small entity of a few
hundred bytes — comfortable to ~10k of those; the 1.5 KB entity
Storage drivers measures costs 37.6 KB,
so size from your own payload), one writer at ~3.1k fsynced writes/s, backup is
stop-copy only (redb holds an exclusive lock). Beyond that, move to
postgres.
Single node with PostgreSQL
docker compose -f compose-files/docker-compose.yml up
Broker + PostGIS. timescale differs only in the image and
ANTARES_STORE; temporal data lands in a hypertable. Set
ANTARES_REQUIRE_RLS=1 in shared-schema multi-tenant deployments so the
broker refuses a DB role that bypasses Row-Level Security.
Tenants are created implicitly by the first write; listing and purging
them is described under operations.
HA: replicas behind a load balancer
docker compose -f compose-files/docker-compose-ha.yml up
Two broker replicas + haproxy + NATS JetStream + PostGIS — the rolling-update shape. The contract that makes rolls invisible:
stop_grace_period(30 s in the compose file) MUST exceedANTARES_DRAIN_DELAY_MS+ANTARES_DRAIN_DEADLINE_SECS(default 2 s + 20 s), ordocker stopturns the drain into a kill. Docker's own 10 s default does not.- Replicas of one logical broker share
ANTARES_HOST_ALIAS— behind the LB they are one hop for federation loop detection. /q/healthanswers 503 DRAINING during the drain window; the LB pulls the pod before the socket closes.
Role-split fleet (scale-out)
Five roles × two replicas from the same binary — only api pods serve
HTTP; matcher/notifier/temporal/registry consume the JetStream streams:
STORE=postgres docker compose -f compose-files/docker-compose-etsi.yml \
-f compose-files/docker-compose-roles.yml --profile db up -d
dev/roles-smoke.sh # notify chain fires EXACTLY once
STORE=postgres ROLES_SPLIT=1 bash dev/rolling-update.sh # roll all 10 in role-group order
This is the exact shape the postgres-nats/timescale-nats CI cells run
the whole ETSI suite against — while the fleet rolls continuously.
Kubernetes
Reference manifests in deploy/k8s/
encode the constraints the store mode dictates instead of leaving them as
deployment choices:
broker-file.yamlis hard-codedRecreate: redb takes an exclusive file lock — a rolling update would deadlock on the volume.broker-postgres.yamlrolls normally; readiness =/q/ready(store ping + bus connected).nats.yamlis a 3-node JetStream StatefulSet (ANTARES_NATS_REPLICAS=3).postgres-cnpg.yamluses the CloudNativePG operator for primary/replica failover;postgres-dev.yamlis a single labelled-dev pod.
Upgrades
Blue/green is the recommended path for major upgrades: deploy the new
version empty, replay declarative state (entities/subscriptions/
registrations) through the standard API, verify, switch traffic — the
broker's config-plane companion pattern. In-place minor upgrades follow
the rolling contract above. The file store carries a format version and
refuses a mismatched file rather than serving partial data.
Subscriptions and notifications
Every example below runs against the quickstart broker
(examples/quickstart/compose.yml, or cargo run -p antares-broker) with
its three TemperatureSensor entities from seed.sh. Notifications are
received by a small HTTP server on port 9380 that answers 200 and prints
each body. $U is http://localhost:9090/ngsi-ld/v1 and $CTX the core
context URL used by seed.sh.
Create a subscription
curl -si -X POST $U/subscriptions -H 'Content-Type: application/ld+json' -d '{
"id": "urn:ngsi-ld:Subscription:hot", "type": "Subscription",
"entities": [{"type": "TemperatureSensor"}],
"q": "temperature>30",
"watchedAttributes": ["temperature"],
"notification": {
"attributes": ["temperature"],
"endpoint": {"uri": "http://localhost:9380/notify", "accept": "application/json"}
},
"@context": "'$CTX'"}'
HTTP/1.1 201 Created
Location: /ngsi-ld/v1/subscriptions/urn:ngsi-ld:Subscription:hot
A change that matches sends one notification. After
PATCH $U/entities/urn:ngsi-ld:TemperatureSensor:qs:1/attrs with
temperature 31.7, the receiver gets:
{
"id": "urn:ngsi-ld:Notification:e5fb42a7-f1c4-49d2-bbb8-bf2255448669",
"type": "Notification",
"subscriptionId": "urn:ngsi-ld:Subscription:hot",
"notifiedAt": "2026-08-26T15:55:28.489Z",
"data": [
{"id": "urn:ngsi-ld:TemperatureSensor:qs:1", "type": "TemperatureSensor",
"temperature": {"type": "Property", "unitCode": "CEL", "value": 31.7}}
]
}
With accept: application/json the @context travels in the Link
header:
Content-Type: application/json
Link: <https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.9.jsonld>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"
With accept: application/ld+json it is a member of the body instead.
What triggers a notification
entities(type, id, idPattern) andq,geoQ,scopeQselect the entities;watchedAttributeslimits which attribute changes count; with nowatchedAttributesevery attribute of a matching entity counts.notificationTriggerdefaults to["attributeCreated", "attributeUpdated"], as the stored subscription above shows; addattributeDeleted,entityCreated,entityUpdatedorentityDeletedto hear about those.- An update that changes nothing sends nothing.
timeIntervalreplaces change-driven delivery with a periodic one, see below. A subscription cannot carry bothtimeIntervalandwatchedAttributes.
Notification shape
notification.format picks the entity representation:
| format | data entry |
|---|---|
normalized (default) | full attributes, as above |
keyValues (alias simplified) | "temperature": 29.0, GeoProperty values as bare GeoJSON |
concise | attributes without the type member where it can be inferred |
notification.attributes restricts the attributes in each entry.
sysAttrs: true adds createdAt/modifiedAt at entity and attribute
level. showChanges: true adds previousValue (or previousObject,
previousLanguageMap) next to the new value; it requires a normalized
format:
HTTP/1.1 400 Bad Request
{"detail":"showChanges cannot be true when format is keyValues (5.2.14)","title":"BadRequestData",...}
One patch of temperature to 29.0, seen by a showChanges + sysAttrs
subscription and by a keyValues one:
{"id": "urn:ngsi-ld:TemperatureSensor:qs:1", "type": "TemperatureSensor",
"createdAt": "2026-08-26T15:55:28.402Z", "modifiedAt": "2026-08-26T15:56:04.370Z",
"temperature": {"type": "Property", "value": 29.0, "previousValue": 31.7, "unitCode": "CEL",
"createdAt": "2026-08-26T15:55:28.402Z", "modifiedAt": "2026-08-26T15:56:04.370Z"}}
{"id": "urn:ngsi-ld:TemperatureSensor:qs:1", "type": "TemperatureSensor",
"location": {"type": "Point", "coordinates": [19.15, 48.73]}, "temperature": 29.0}
Grouped delivery
One request produces at most one notification per subscription, however
many entities it touched. A batch upsert of two sensors reaches the
keyValues subscription as one POST:
{
"id": "urn:ngsi-ld:Notification:c06c6141-a3fc-4553-8369-ddeb69aa56a2",
"type": "Notification",
"subscriptionId": "urn:ngsi-ld:Subscription:kv",
"notifiedAt": "2026-08-26T15:56:06.437Z",
"data": [
{"id": "urn:ngsi-ld:TemperatureSensor:qs:2", "type": "TemperatureSensor", "temperature": 27.5},
{"id": "urn:ngsi-ld:TemperatureSensor:qs:3", "type": "TemperatureSensor", "temperature": 34.9}
]
}
and timesSent moves by one. A notification body is capped at 4 MiB,
the same limit the broker accepts on inbound bodies. A grouped delivery
over the cap is split at whole-entity boundaries into several
notifications; a single entity larger than the cap is sent alone.
Periodic delivery: timeInterval
{"id": "urn:ngsi-ld:Subscription:tick", "type": "Subscription",
"entities": [{"type": "TemperatureSensor"}], "timeInterval": 2,
"notification": {"format": "keyValues",
"endpoint": {"uri": "http://localhost:9380/tick", "accept": "application/json"}}}
Every 2 seconds the broker sends all matching entities, changed or not; five seconds after creation the receiver holds two notifications with all three sensors each. Any interval greater than zero is accepted, fractions included; the broker checks due subscriptions twice a second, so an interval below that is served at the tick rate. With NATS and several broker pods, one pod claims each tick, so an interval fires once per fleet.
Throttling
"throttling": 30 sends at most one notification per 30 seconds per
subscription; changes inside the window are dropped, not queued. Three
patches in a row produced one delivery and timesSent: 1. With several
pods on NATS the window is kept per pod.
Delivery bookkeeping
GET $U/subscriptions/{id} shows the fields of CIM 009 5.8.6. After the
first successful notification:
"notification": {
"endpoint": {"accept": "application/json", "uri": "http://localhost:9380/notify"},
"lastNotification": "2026-08-26T15:55:28.489Z",
"lastSuccess": "2026-08-26T15:55:28.489Z",
"status": "ok",
"timesSent": 1
}
A subscription pointing at a closed port after one matching change:
"notification": {
"endpoint": {"accept": "application/json", "uri": "http://localhost:9399/nobody"},
"lastNotification": "2026-08-26T15:55:37.662Z",
"lastFailure": "2026-08-26T15:55:37.663Z",
"status": "failed",
"timesFailed": 1,
"timesSent": 1
}
timesSent counts notifications, not attempts: a delivery that is retried
and then succeeds still adds one. status flips back to ok on the next
successful delivery.
Retry and dead letters
The default is one attempt per notification. ANTARES_NOTIFY_ATTEMPTS,
ANTARES_NOTIFY_BACKOFF_MS and ANTARES_NOTIFY_MAX_AGE_SECS turn on
retries with exponential backoff on a separate task; a notification whose
attempts or age run out becomes a dead letter, listed and replayable
through /q/dead-letters and counted on /q/health as deadLetters.
See Operations and the
Admin API.
Egress
Delivery goes through the same egress policy as @context fetches and
federation forwards: http, https, mqtt and mqtts only, redirects
capped, DNS pinned, response size capped, and a per-host breaker that
pauses a failing endpoint. Private and loopback destinations are allowed
by default; ANTARES_EGRESS_ALLOW_PRIVATE=false denies them for an
internet-facing deployment, and a refused delivery is booked as a failure.
A scheme no notification binding serves is refused when the subscription is
created — the endpoint is input data that does not meet the requirements of
the operation (5.8.1.4, Table 5.5.2-1), so the error is BadRequestData:
{"detail":"no notification binding registered for endpoint scheme \"ftp\" (6.3.8)","status":400,"title":"BadRequestData",...}
MQTT endpoints
An endpoint URI of the form mqtt[s]://[user[:pass]@]host[:port]/topic
delivers notifications as MQTT publishes (CIM 009 clause 7). The message
is {"metadata": {...}, "body": <Notification>}; protocol parameters go
in notifierInfo:
"endpoint": {
"uri": "mqtt://localhost:1883/antares/hot",
"accept": "application/json",
"notifierInfo": [{"key": "MQTT-Version", "value": "mqtt5.0"}, {"key": "MQTT-QoS", "value": "1"}]
}
The broker validates the URI and the parameters at creation (201 above,
with no MQTT broker running) and connects on the first delivery. Sessions
are pooled per endpoint and credentials; the password never appears in
error bodies or logs. The binding is the mqtt cargo feature, on by
default.
Temporal API
The temporal API (CIM 009 clauses 5.6.11–5.6.16, 5.7.3, 5.7.4) serves the
history of attribute values. Every example below runs against the
quickstart broker seeded by examples/quickstart/seed.sh; $U is
http://localhost:9090/ngsi-ld/v1 and $L the Link header carrying the
core @context.
How history is recorded
Every write through the entity endpoints produces temporal events for the
attribute instances it changed. The events are buffered for the request
and drained to the temporal driver after the handler returns and before
the response leaves, so a temporal read that follows a write sees it. An
update that changes no value produces no event; ANTARES_TEMPORAL_RECORD
narrows what enters history to observed instances or to nothing. A driver
failure in the drain never changes the response already produced; it is
counted as temporalDrainErrors on /q/health. The gates, the driver
choice ANTARES_TEMPORAL and retention are described in
Storage drivers.
Three patches of temperature with an observedAt each:
curl -X POST $U/entities/urn:ngsi-ld:TemperatureSensor:qs:1/attrs \
-H 'Content-Type: application/json' -H "$L" \
-d '{"temperature":{"type":"Property","value":24.1,"unitCode":"CEL","observedAt":"2026-08-26T09:00:00Z"}}'
The seed value 21.5 carried no observedAt, so it appears only under
timeproperty=modifiedAt (below), never in the default observedAt
history.
Querying
timerel is before, after or between around timeAt (and
endTimeAt for between), applied to timeproperty (observedAt by
default; createdAt, modifiedAt, deletedAt). Clause 4.11 fixes the
bounds: after includes the timeAt instant, before excludes it, and
between includes timeAt and excludes endTimeAt. attrs restricts the
attributes.
curl -si "$U/temporal/entities/urn:ngsi-ld:TemperatureSensor:qs:1?timerel=after&timeAt=2026-08-26T08:30:00Z&attrs=temperature"
{"id": "urn:ngsi-ld:TemperatureSensor:qs:1", "type": "TemperatureSensor",
"temperature": [
{"type": "Property", "instanceId": "urn:ngsi-ld:Instance:6e0d857e-8845-57ee-9e5b-bbcc34d89a0b",
"observedAt": "2026-08-26T09:00:00Z", "unitCode": "CEL", "value": 24.1},
{"type": "Property", "instanceId": "urn:ngsi-ld:Instance:19536e56-b044-51cd-8658-2600fb344d98",
"observedAt": "2026-08-26T10:00:00Z", "unitCode": "CEL", "value": 26.8}]}
Each instance carries an instanceId; it is the handle for the
instance-level operations below. GET $U/temporal/entities?type=…
queries several entities with the same parameters plus q, geoQ,
scopeQ, id, idPattern and paging; POST $U/temporal/entityOperations/query takes the same query as a Query
body with a temporalQ member:
curl -X POST $U/temporal/entityOperations/query -H 'Content-Type: application/json' -H "$L" -d '{
"type": "Query", "entities": [{"type": "TemperatureSensor"}],
"temporalQ": {"timerel": "after", "timeAt": "2026-08-25T10:00:00Z"},
"attrs": ["temperature"], "q": "temperature>25"}'
Errors are BadRequestData 400 with the reason in detail:
{"detail":"invalid timerel \"since\"", ...}
{"detail":"timeAt must be a valid ISO 8601 DateTime (4.11)", ...}
Representation
format=temporalValues (or options=temporalValues) collapses each
attribute to [value, time] pairs:
{"id": "urn:ngsi-ld:TemperatureSensor:qs:1", "type": "TemperatureSensor",
"temperature": {"type": "Property",
"values": [[22.4, "2026-08-26T08:00:00Z"], [24.1, "2026-08-26T09:00:00Z"], [26.8, "2026-08-26T10:00:00Z"]]}}
timeproperty=modifiedAt keys the history on the write time instead, so
the seed value shows up:
"values": [[21.5, "2026-08-26T15:59:33.332Z"], [22.4, "2026-08-26T15:59:33.409Z"],
[24.1, "2026-08-26T15:59:33.416Z"], [26.8, "2026-08-26T15:59:33.423Z"]]
lastN=2 keeps the two newest instances of each attribute, newest first:
"temperature": [
{"type": "Property", "observedAt": "2026-08-26T10:00:00Z", "value": 26.8, ...},
{"type": "Property", "observedAt": "2026-08-26T09:00:00Z", "value": 24.1, ...}]
lastN must be a positive integer; pick/omit and sysAttrs apply as
on the entity endpoints.
Aggregation
aggrMethods (totalCount, distinctCount, sum, avg, min, max,
stddev, sumsq) with aggrPeriodDuration returns one row per bucket
as [value, bucketStart, bucketEnd], buckets anchored at timeAt:
curl "$U/temporal/entities/urn:ngsi-ld:TemperatureSensor:qs:1?timerel=between&timeAt=2026-08-26T08:00:00Z&endTimeAt=2026-08-26T11:00:00Z&attrs=temperature&aggrMethods=avg,max&aggrPeriodDuration=PT1H"
"temperature": {"type": "Property",
"avg": [[22.4, "2026-08-26T08:00:00Z", "2026-08-26T09:00:00Z"],
[24.1, "2026-08-26T09:00:00Z", "2026-08-26T10:00:00Z"],
[26.8, "2026-08-26T10:00:00Z", "2026-08-26T11:00:00Z"]],
"max": [[22.4, "2026-08-26T08:00:00Z", "2026-08-26T09:00:00Z"], ...]}
Without aggrPeriodDuration (or with PT0S) the whole range is one
bucket, ending one second after the last instance:
"avg": [[24.433333333333334, "2026-08-26T08:00:00Z", "2026-08-26T10:00:01Z"]],
"sum": [[73.3, "2026-08-26T08:00:00Z", "2026-08-26T10:00:01Z"]],
"totalCount": [[3, "2026-08-26T08:00:00Z", "2026-08-26T10:00:01Z"]]
Which methods apply to which value type follows Tables 4.5.19.1-1 to -3;
a method that does not apply to the attribute's values is a 400. A value
carrying a Date, DateTime or Time datatype — written as a JSON-LD
typed value, {"@type": "DateTime", "@value": "..."}, or as a string with
valueType — is ordered by that datatype, so min and max apply to it
and a Time also has an avg, returned as a Time. A plain JSON string
is ordered lexicographically and has no average.
On postgres and timescale the aggregation runs in SQL when the query is
exact there: no q, geoQ or scopeQ, the page pushed down, no omit,
second-granular period, and only numeric or boolean values in the
window. Any other shape, and every query on the memory and file drivers,
reconstructs the instances and aggregates in the broker. The result is
the same; the SQL path was measured at 5.7 s → 0.59 s for 50 entities
with 150k instances.
Pagination
One response carries at most nine instances per attribute. Beyond that
the broker cuts the whole entity at one instant, answers 206 Partial Content and names the window it served in Content-Range. Twelve hourly
instances of a sensor:
HTTP/1.1 206 Partial Content
Content-Range: date-time 2026-08-25T00:00:00Z-2026-08-25T08:00:00Z/*
{"id": "urn:ngsi-ld:TemperatureSensor:qs:2", "type": "TemperatureSensor",
"temperature": {"type": "Property", "values": [[20.0, "2026-08-25T00:00:00Z"], ..., [28.0, "2026-08-25T08:00:00Z"]]}}
Continue from the instant after the range end; the last page answers 200:
GET …?timerel=after&timeAt=2026-08-25T09:00:00Z&attrs=temperature&format=temporalValues
HTTP/1.1 200 OK
"values": [[29.0, "2026-08-25T09:00:00Z"], [30.0, "2026-08-25T10:00:00Z"], [31.0, "2026-08-25T11:00:00Z"]]
Every attribute is trimmed to the same boundary, so no instance of any
attribute falls between two pages. Aggregated representations are
computed over the whole evolution and are never cut. Entity-level paging
of GET /temporal/entities uses limit/offset and Link rel="next"
as the entity endpoints do.
Temporal entity maps
entityMap=true on a multi-entity query pins the matched id set for the
following pages, so a client walking rel="next" links sees a stable set
even while entities change:
HTTP/1.1 201 Created
Link: </ngsi-ld/v1/temporal/entities?entityMap=true&limit=1&offset=1&…>; rel="next";type="application/json"
The map lives under /temporal/entityMaps/{id} for one hour by default;
a client may set expiresAt on the map, capped at 24 hours (6.4.3.2-1).
It is stored in the current-state driver, so it survives a restart on
file and postgres.
Writing and deleting history
| operation | clause | request |
|---|---|---|
| create a temporal entity with its instances | 5.6.11 | POST $U/temporal/entities |
| add instances to an attribute | 5.6.12 | POST $U/temporal/entities/{id}/attrs with {"temperature": [instance, …]} |
| delete an attribute's history | 5.6.13 | DELETE $U/temporal/entities/{id}/attrs/{attr}, ?datasetId= for one instance set, ?deleteAll=true for every set |
| modify one instance | 5.6.14 | PATCH $U/temporal/entities/{id}/attrs/{attr}/{instanceId} |
| delete one instance | 5.6.15 | DELETE $U/temporal/entities/{id}/attrs/{attr}/{instanceId} |
| purge an entity's history | 5.6.16 | DELETE $U/temporal/entities/{id} |
All answer 204; a missing instance or entity is ResourceNotFound 404:
{"detail":"instance urn:ngsi-ld:Instance:cbcc1cff-46bb-4b7f-b82e-f42a60d75542 not found","status":404,"title":"ResourceNotFound", ...}
Purging a temporal entity removes its history only; GET $U/entities/{id} still answers 200 with the current state. Deleting the
current-state entity mirrors a deletion instance into history, so the
entity's last state remains queryable under timeproperty=deletedAt.
Instances added through the temporal endpoints are stored as sent.
Instances the entity endpoints record with an observedAt are keyed on
(entity, attribute, datasetId, observedAt), so a sensor re-sending
the same measurement replaces the instance instead of duplicating it.
Retention and none
ANTARES_TEMPORAL_RETENTION_DAYS starts a sweep on the postgres or
timescale half that drops instances older than the horizon; unset keeps
everything. With ANTARES_TEMPORAL=none every temporal endpoint answers
OperationNotSupported 422 (Table 6.3.2-1) and the entity endpoints
record nothing:
HTTP/1.1 422 Unprocessable Entity
{"detail":"no temporal store is configured","status":422,"title":"OperationNotSupported","type":"https://uri.etsi.org/ngsi-ld/errors/OperationNotSupported"}
/q/health names both halves: "store": "memory", "temporal": "none".
Federation
Antares implements the CIM 009 distributed-operations model (4.3.6, 5.9–5.11, 6.3.17, 6.3.18). Context Source Registrations (CSRs) are the routing table: a broker holding CSRs forwards matching requests to the registered sources, merges the answers and reports per-source problems without failing the request. The DistributedOperations and IOP suites (134 + 286 tests) cover this surface in every CI cell.
Every example below uses two brokers, broker-a on 9090 and broker-b
on 9091, as in examples/federation/compose.yml:
cd examples/federation && docker compose up -d && ./run.sh
run.sh creates an entity on B, registers B at A and queries A:
entity created on B
CSR registered on A -> B
federated query via A:
[{"id":"urn:ngsi-ld:ParkingSpot:fed:042","type":"ParkingSpot","status":{"type":"Property","value":"free"}}]
OK: B's entity served by A
With two local binaries instead of compose, set ANTARES_HOST_ALIAS
per process and run B_FROM_A=http://localhost:9091 ./run.sh.
Registrations
A CSR declares what a source holds (information[]: entity types, ids,
idPattern, propertyNames, relationshipNames) and how to treat it
(mode):
| mode | meaning | reads | writes |
|---|---|---|---|
inclusive (default) | one of possibly many holders | forwarded and merged (4.5.5) | forwarded, 207 on partial failure |
exclusive | the only holder of the registered entity and attributes | forwarded | forwarded |
redirect | the broker proxies, keeps nothing locally | forwarded | forwarded |
auxiliary | consulted only when nobody else answers | forwarded last | never |
An exclusive registration names one entity id and its attributes
(4.3.6.3); a type-only or pattern registration is refused:
{"detail":"an exclusive registration shall name an entity id — an id pattern or Entity type defining a group of entities is not supported (4.3.6.3)","status":400,"title":"BadRequestData", ...}
operations bounds what may be forwarded (default federationOps);
contextSourceInfo key/value pairs travel as headers on every forward
to that source (4.3.6.5); localOnly adds local=true to every
forward (4.3.6.4). observationInterval and managementInterval gate
temporal forwards to the sources whose window overlaps the query.
A contextSourceInfo value of urn:ngsi-ld:request is not sent as that
string: it copies the same-named header off the request that triggered the
forward, and sends nothing when the triggering request carried no such
header (4.3.6.5, 6.3.19). {"key": "Authorization", "value": "urn:ngsi-ld:request"} is how a source behind the same identity provider is
given the caller's credential, and it is the reason creating a registration
is a privileged operation: the key names any header and the endpoint is
whatever the registration says. Two groups of keys never reach the source as
a raw header. Headers the registration's other members already decide take
precedence and cannot be overridden (6.3.19), which is NGSILD-Tenant, and
so do the ones the binding sets itself: Host, Via, Link, Connection,
Content-Type, Content-Length. The accept, contentType,
jsonldContext and ngsildConformance keys are the ones 4.3.6.6 and
4.3.6.8 give their own meaning; the forward acts on them, and passing them
through raw would corrupt the negotiation they steer.
A contextSourceInfo value goes on the wire and nowhere else. The broker
names the registration, and the endpoint with its userinfo stripped, in the
log line, the warning, the error body and the dead letter that a failed
forward produces; it never writes the pairs themselves, so a bearer token
handed over for one connection is not later readable from an operator's log
or from /q/dead-letters. The registration document itself still carries
the value, because 5.9.4 serves the registration back as it was written.
The registered @context
"jsonldContext" names a @context the Context Source reads its terms in,
and the forward is recompacted into it: the payload, the term-bearing query
parameters (attrs, type, geoproperty) and — for the resources that
name one Attribute in the path, /entities/{id}/attrs/{name} and its
temporal and value variants — the path segment itself. The segment is not
one of the two things 4.3.6.6 lists, but it carries a term the Context
Source expands with the @context the forward advertises, so a request that
switched the context and left the segment alone would write to a different
Attribute.
The whole request travels in one @context or none of it does. A payload
the broker cannot express in the registered context — a batch array, a body
that does not re-expand — makes the forward fall back to the request's own
context, logged as a warning, because advertising the registered context
over terms that are not in it is how a peer writes Attributes nobody named.
Timeout and cooldown
management.timeout (5.2.34) bounds one forward in milliseconds; the
broker caps it at 8 seconds, and a registration without it gets the cap.
management.cooldown keeps a source that failed out of the fan-out for
that many milliseconds; inside the window the forward is answered as a
timeout without contacting the source. A registration with
"management": {"timeout": 500, "cooldown": 10000} pointing at a closed
port answers in 7 ms:
HTTP/1.1 200 OK
Ngsild-Warning: 199 broker-b "no response was received from the registration endpoint within the timeout period"
Forward history
Every forward that reaches the wire is booked on the registration it was made for, in the five read-only members of Table 5.2.9-2:
{
"id": "urn:ngsi-ld:ContextSourceRegistration:weather",
"type": "ContextSourceRegistration",
"endpoint": "http://source-b:8080/ngsi-ld/v1",
"information": [{"entities": [{"type": "WeatherObserved"}]}],
"timesSent": 412,
"timesFailed": 3,
"lastSuccess": "2026-05-05T11:02:44.118Z",
"lastFailure": "2026-05-04T22:17:09.640Z",
"status": "ok"
}
timesSent counts every attempt, failures included; timesFailed counts
the ones the table calls failures, which in the HTTP binding is any response
code other than 2xx, a timeout and a refused connection. status names the
last attempt alone, so a source that has recovered reads "ok" however
large timesFailed is. A member appears when it first has something to say:
a registration that has never failed carries no timesFailed and no
lastFailure.
Three outcomes are deliberately not counted, because the operation never
left this broker: a destination the egress policy refuses, a source the
circuit breaker is holding open, and a registration inside its own
management.cooldown window. The breaker only opens after failures that
were attempted and booked, so a source that has gone away reads "failed"
before the first forward is suppressed.
They are read-only. A create or update that carries any of them has that member dropped, not refused, which is what 5.2.9 asks for.
Same source, several registrations
Registrations naming the same source (same endpoint, mode, tenant,
contextSourceAlias, contextSourceInfo and localOnly) fold into one
forwarded request whose attribute and entity scopes are the union. A
different contextSourceAlias behind the same endpoint is a different
source (5.2.9) and is contacted separately.
Distributed reads
A read that matches CSRs fans out concurrently, ANTARES_FED_FANOUT at
a time (default 8), each forward bounded by the registration timeout and
ANTARES_MAX_FED_RESPONSE_BYTES. The forwarded request is narrowed to
what the registration declares (4.3.6.1): a registration with
propertyNames: ["status"] receives attrs=status even when the client
asked for attrs=status,owner, and any owner the source returns anyway
is dropped from the merge. The forward carries the Via hop and the
client's Link context, never the client's NGSILD-Tenant (4.14); the
registration's own tenant member names the tenant to address at the
source:
GET /ngsi-ld/v1/entities?options=sysAttrs&type=ParkingSpot&attrs=status
Accept: application/json
Via: 1.1 broker-a
Link: <https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.9.jsonld>; rel="http://www.w3.org/ns/json-ld#context"; type="application/ld+json"
Entity halves from several sources merge per 4.5.5 before pagination.
Partial failures
A source that fails does not fail the request. Each problem becomes one
NGSILD-Warning header (6.3.17, Table 6.3.17-1) naming the broker that
saw it:
| code | when |
|---|---|
| 199 | no response within the timeout, or the source is in cooldown |
| 299 | the source answered an error status other than 404 |
| 111 | the source answered 2xx with a payload that is not NGSI-LD |
A 404 from a source is a miss, not a warning. Warnings a peer returns
travel back to the client next to the broker's own, up to eight per
source (maxPeerWarnings in /q/health): the list is written by the
source but sent by this broker, and eight carries a real cascade without
letting one source outgrow the fan-out or bury what the broker has to
say about the others. A source that sends more has the rest dropped and
a line written to the log. Beyond the registration cooldown, a per-host
breaker pauses an endpoint whose forwards keep timing out; a source that
answers, even with an error, is never paused.
Distributed writes
Writes forward by registration mode and the registered operations.
An exclusive or redirect registration whose operations exclude the
write is an error of type Conflict (409), because the data lives only
there; an inclusive one that excludes it is skipped. Batch operations
return per-entity success and error arrays with the remote results
folded in; a partial failure across inclusive sources is 207.
Loop protection
Every forward carries a Via hop (6.3.18) whose pseudonym is this
broker's alias for the tenant (ADR-0011): ANTARES_HOST_ALIAS for the
default tenant, {alias}~{tenant} otherwise. /info/sourceIdentity
answers the same value per tenant, and that is what a peer stores as the
registration's contextSourceAlias:
GET /ngsi-ld/v1/info/sourceIdentity
{"id":"urn:ngsi-ld:ContextSourceIdentity:broker-a","type":"ContextSourceIdentity","contextSourceAlias":"broker-a", ...}
GET /ngsi-ld/v1/info/sourceIdentity NGSILD-Tenant: odpady
{"id":"urn:ngsi-ld:ContextSourceIdentity:broker-a~odpady","type":"ContextSourceIdentity","contextSourceAlias":"broker-a~odpady", ...}
Two rules follow from the inbound chain:
- A registration whose
contextSourceAliasalready appears in the chain is not a matching registration (Table 6.3.18-2); the request has been there. - A request whose chain names this broker itself runs locally without
re-forwarding. When the only matching registration for a write is a
single
exclusiveorredirectsource, the loop closes on data that lives nowhere else and the answer is 508 (6.3.17):
POST /ngsi-ld/v1/entities/urn:ngsi-ld:Loop:1/attrs Via: 1.1 broker-b
HTTP/1.1 508 Loop Detected
{"detail":"the Via chain already contains this broker","status":508,"title":"Loop Detected", ...}
A chain longer than 32 hops is treated as a loop whatever it names. Replicas of one logical broker behind a load balancer share one alias on purpose; they are one hop. Changing an alias breaks every peer's loop detection, so treat it as a published identifier.
Context source subscriptions
POST /csourceSubscriptions (5.11) watches the registrations instead of
the entities. A subscription on ParkingSpot receives the matching
registrations at creation and each later change with a triggerReason:
{"id": "urn:ngsi-ld:ContextSourceNotification:bafc4692-…", "type": "ContextSourceNotification",
"subscriptionId": "urn:ngsi-ld:Subscription:csr-watch", "notifiedAt": "2026-08-26T16:02:43.945Z",
"triggerReason": "newlyMatching",
"data": [{"id": "urn:ngsi-ld:ContextSourceRegistration:broker-c", "type": "ContextSourceRegistration",
"endpoint": "http://localhost:9092", "information": [{"entities": [{"type": ["ParkingSpot"]}]}]}]}
csf filters registrations by their Context Source Properties (4.9).
Distributed subscriptions
An entity subscription whose scope matches CSRs is reduced per source and
created at the remote broker (5.8); the remote notifies back to
POST {ANTARES_PUBLIC_URL}/ex/v1/remote-notify, so set that variable
whenever the default http://{host_alias}:{port} is not routable from
peers. That endpoint is the one non-standard route a federated deployment
must leave reachable from its context sources: CIM 009 defines no path for
it (5.8.1.4 says only that the copy carries "the notification endpoint of
the local Broker", and 5.2.15 makes a notification endpoint any URI), so it
lives outside the /ngsi-ld prefix ETSI owns rather than inside it, and it
is versioned on its own (ADR-0019). Other brokers place it elsewhere:
Orion-LD and coraine serve POST /ngsi-ld/ex/v1/notifications/{subId},
Scorpio POST /remotenotify/{id}.
What arrives there is routed by the mapping alone. The forwarded copy
carries a broker-generated subscription id, never the subscriber's own, so
a context source learns nothing about the subscriber and cannot address any
other subscription; the tenant comes from the stored mapping and not from
the request, and the notified entities are re-filtered against the local
subscription's own selector before delivery. Reduced copies
follow the local subscription's lifecycle (update, delete); the
registration's csf gates which sources take part. Inbound
notifications from peers are matched against local subscriptions like
local changes.
Pagination without amplification
The first distributed query can build an EntityMap (5.14): entity id to
contributing registrations. Later pages contact only the sources that
hold the page's entities instead of re-broadcasting the query. Maps
expire (expiresAt, one hour by default) and are honoured on retrieve
and temporal paths.
A federated map is merged from the maps the Context Sources return, so
what a source sends is held to Table 5.2.39-2 before it becomes part of
a document this broker stores under its own id and serves. A key that is
not an Entity id is dropped, per key rather than per source, and so is
the @none a source uses for what it holds locally — that marker is
about the source, and this broker has no Entity id to record it under.
A source's own map id reaches linkedMaps only if it is a valid URI,
because it travels back out as the NGSILD-EntityMap header of every
later forwarded page; a source without a usable one simply re-runs its
query when the page arrives. One source contributes at most as many
entries as the broker's own page ceiling (5.5.9, 1 000 by default),
which is the ceiling the local half of the map already carries.
Tenancy across the federation
The client's NGSILD-Tenant never propagates to forwards (4.14); a CSR
addresses a specific tenant of a remote source through its tenant
member, and the ~-suffixed alias keeps each (source, tenant) pair
distinct in loop detection. A registration pointing back at the same
broker for another tenant is a legitimate federation shape, not a loop.
The five-broker stack
The IOP worked example, five brokers and no Docker:
dev/run-five.sh # ports 9090..9094, aliases antares1..antares5
Each broker gets ANTARES_PUBLIC_URL=http://localhost:PORT; this is the
stack the 286-test IOP tree runs against in CI.
Antares operations runbook
Everything here re-states what the code, manifests and workflows already enforce — no claim without a test behind it (the workflow proving each claim is named inline).
Deploy
Docker (single node): see the README quickstart — memory needs zero
infrastructure; file needs a mounted volume; postgres/timescale need
ANTARES_DATABASE_URL. Compose stacks in compose-files/
(docker-compose.yml one broker + PostGIS; docker-compose-ha.yml adds a
second broker1 replica + haproxy + NATS — the rolling-update shape;
docker-compose-roles.yml is the TRUE role split: 5 roles × 2 replicas =
10 broker containers — api×2 behind haproxy, matcher/notifier/temporal/
registry ×2 as ops-only worker pods — one shared PG, ANTARES_BUS=nats;
these stacks are what CI's ETSI cells run).
Kubernetes (reference manifests, deploy/k8s/): namespace.yaml,
nats.yaml (3-replica JetStream), postgres-dev.yaml (dev-only single PG;
production uses a CNPG cluster, postgres-cnpg.yaml is lint-only),
broker-postgres.yaml (an antares-api Deployment with ANTARES_ROLES=api
and an antares-worker Deployment with matcher/notifier/temporal/registry),
broker-file.yaml (single-replica file mode, Recreate strategy),
networkpolicy.yaml (deny-by-default ingress in the namespace, one allow
per flow the other manifests use; egress stays open because notification
endpoints, Context Sources and @context URLs are client data, gated by
ANTARES_EGRESS_ALLOW_PRIVATE and the scheme allowlist rather than by a
CIDR list). Both broker pod specs set enableServiceLinks: false (kubelet's injected
ANTARES_* service-link vars would otherwise trip the unknown-config check;
the broker also exempts those exact shapes). Proven by k8s-smoke.yml's k8s-manifests
kind smoke (dispatch): apply + every rollout status green.
- A worker pod serves the
/qadmin surface and nothing else — health, readiness, metrics, the tenant calls and the dead-letter admin. The NGSI-LD API exists only on pods with theapirole. ANTARES_BUS=natsrequires a shared store (postgres/timescale) and refuses to boot otherwise;bus=localrequires all roles in one process.- Production gates: set
ANTARES_REQUIRE_RLS=1(refuses a DB role that bypasses RLS) and require auth on NATS (the broker logs a loud warning on an unauthenticated JetStream).
Health, readiness, metrics
| Endpoint | Meaning |
|---|---|
/q/health | Liveness + store mode and the temporal backend (temporal: memory, file, postgres, timescale or none), file-mode commit queue, resource limits + rejection counters, jemalloc heap, and under bus=nats the bus state {mode, connected, reconnects}. 503 = DRAINING (a roll in progress). |
/q/ready | Readiness: not draining ∧ store answers (SELECT 1 on Pg) ∧ bus connected. The k8s readinessProbe polls this; liveness stays on /q/health (a restart does not fix a lost DB). |
/q/metrics | Prometheus text (antares_ prefix; every metric in the admin API). |
The NATS-outage contract (proven by nats_e2e::nats_outage_flips_health_and_recovers):
during an outage the API keeps serving (writes land in the transactional
outbox), /q/ready goes 503, and on reconnect the outbox drains — the
outage-time notifications arrive, none lost.
Sizing the connection pool
ANTARES_PG_POOL (default 20) is how many PostgreSQL connections one broker
process may hold. It is a ceiling on concurrent database work, not a
throughput dial, and the measured runs say the dial does very little: on a
16-physical-core box pool 100 is 8 % faster than pool 20 at eight allotted
cores and 10 % slower at 1 024 concurrent query clients, and both pools hold
the same 1 000 rps write knee and fail at the same 1 500
(performance). Raise it to buy
concurrency the database can actually serve, never to buy speed.
Size it from the database, not from the broker:
ANTARES_PG_POOL <= (max_connections - superuser_reserved_connections - other clients)
/ number of broker replicas
A pool larger than the server's share does not fail at startup — it fails
later, at the first burst, as FATAL: sorry, too many clients already from
whichever client asks last. Leave headroom for the migration job, the
replicas rolling during an update, and anything else on the same database.
Three signals say the pool is the constraint:
| Signal | Where | Reading |
|---|---|---|
antares_pg_transaction_begin_seconds | /q/metrics | time to get a pooled connection and open a transaction. Sub-millisecond when the pool is idle; it grows toward the 5 s acquire timeout as the pool empties. |
antares_pg_pool_timeouts_total | /q/metrics | requests that waited the whole acquire timeout and got nothing. Any non-zero rate means clients are being turned away. |
storeInfo.poolSize, storeInfo.poolAcquireTimeoutSeconds | /q/health | what this process was configured with, so a dashboard does not have to trust the deployment manifest. |
When the pool has nothing to give inside its acquire timeout, the request is
answered 503 with a Retry-After header and no body. The operation was
never attempted, so the client may retry the same request unchanged; a batch
that had already written part of its array reports the failure per item
instead, and never as a 503 the client would retry into duplicates. This is
an Antares decision, not a CIM 009 requirement: Table 6.3.2-1 has no error
type for an overloaded server, and clause 6.3.2 requires the HTTP binding's
own status codes beside it.
Observability
Three signals, one switch (ANTARES_TELEMETRY):
| Signal | Where it goes |
|---|---|
| Traces | OTLP/HTTP to ANTARES_OTLP_ENDPOINT (…/v1/traces), batch exported. |
| Metrics | Prometheus text at /q/metrics, scraped. |
| Logs | Stdout always; with the endpoint set, also OTLP/HTTP to its …/v1/logs twin, same service.name resource, batch exported from a bounded queue on its own thread. A collector that does not answer drops records and never slows a request. |
Log records exported while a request span is open carry that span's trace id, so a collector joins the three signals per request.
Tenants
Tenants come to exist implicitly (CIM 009 5.5.10): the first create
operation carrying an NGSILD-Tenant header creates the tenant, and the
default tenant always exists. The NGSI-LD API has no operation to list or
remove tenants; the admin surface has both.
| Endpoint | Meaning |
|---|---|
GET /q/tenants | The tenant names, sorted, and nothing else: the customer accounts, never the tenants the broker mints for its own bookkeeping. A deployment runs up to 10 000 tenants (ADR-0001); counting every kind for every one of them on a list call is a cost the list does not pay. |
GET /q/tenants/{tenant} | What one tenant holds: {tenant, createdAt, counts: {entities, subscriptions, csourceSubscriptions, registrations, snapshots, entityMaps, distSubs, attrInstances}}. 404 for a tenant that does not exist, 400 for a name outside the tenant grammar. createdAt is present on Postgres, where the tenants table records it. |
DELETE /q/tenants/{tenant} | Purge: every document of the tenant leaves the current-state backend and the temporal backend in one transaction each. 204 when done, 404 for a tenant that does not exist, 409 while a distributed subscription of the tenant still holds a copy at a Context Source (delete those subscriptions first, which removes the copies at their source), 400 for a name outside the tenant grammar. The default tenant is emptied and keeps existing. |
The path names the tenant; an NGSILD-Tenant header on these calls is
ignored. Like the rest of /q/*, the routes sit outside /ngsi-ld/v1 and
belong behind the gateway. What the gateway owns instead: who may create a
tenant, quotas and rate limits per tenant, authentication.
Reserved tenant names
The broker keeps part of its own state under tenants it names itself (ADR-0012), and a client can never name one of them:
| Name | Holds |
|---|---|
any name starting with snap- | the frozen data of one snapshot (snap-<32 hex digits>) and the snapshot index (snap-index) |
distsub-index | the index of distributed subscriptions received from other brokers |
A request whose NGSILD-Tenant is one of these answers
400 BadRequestData on every operation, reads included, so such a
tenant can be neither read, written nor created from the API, whether or
not the broker has minted it yet. /q/tenants/{tenant} and the ?tenant=
of the admin calls refuse the same names, and GET /q/tenants never
lists them.
Do not start a tenant name with snap-. The match is literal,
case-sensitive and anchored at the start: snapshots-team,
city-snap-1 and SNAP-index are ordinary tenants.
The content of a snapshot is read the way CIM 009 6.3.22 defines: send
the normal query under your own tenant and add NGSILD-Snapshot: <snapshot id>. The broker resolves the header to its internal tenant
after the reserved-name check. A notification from a subscription
created inside a snapshot carries the owner tenant and the
NGSILD-Snapshot header, never the internal name.
Notification delivery
CIM 009 5.8.6 books every notification once: timesSent moves by one,
lastNotification is stamped, then either lastSuccess or lastFailure
plus status: "failed". The broker sends once by default. Retries are an
operator choice (ANTARES_NOTIFY_ATTEMPTS, ANTARES_NOTIFY_BACKOFF_MS,
ANTARES_NOTIFY_MAX_AGE_SECS, configuration)
and are transport under that one notification: the first attempt is booked
as above the moment it resolves, the retries run on their own task (a slow
endpoint never delays another subscription), a retry that succeeds sets
lastSuccess and status: "ok" without touching timesSent or
timesFailed, and an exhausted policy leaves a dead letter: the exact
request (endpoint, headers, payload) plus the attempt history, stored under
the subscription's tenant in every store mode.
| Call | Effect |
|---|---|
GET /q/dead-letters?tenant=&subscription=&limit= | Letters of one tenant (default tenant when tenant is absent), newest first, limit 100 by default. Endpoint userinfo and every credential the letter carries (receiverInfo, notifierInfo, the rendered headers of an older letter) are blanked in the listing; the stored letter keeps them for a replay. |
POST /q/dead-letters/{id}/replay?tenant= | One more attempt through the same binding under the egress policy of the moment: 204 and the letter is deleted, or 502 with the failure text and the letter kept (attempts, lastError, lastAt extended). |
DELETE /q/dead-letters/{id}?tenant= | Drop the letter. 404 when the tenant holds no such letter. |
/q/health reports deadLetters, the letters this process wrote since
start; the letters themselves are rows, so they survive restarts on the
file, postgres and timescale stores and a tenant purge removes them with
the rest of the tenant. Egress-policy refusals (private ranges, blocked
schemes) are never retried and never dead-lettered: a policy verdict is
not a transport failure.
The notifications in flight at one moment are bounded broker-wide, and one
tenant may hold only a share of that bound. A subscription belongs to one
tenant, and a delivery to an endpoint that accepts the connection and never
answers holds its slot until endpoint.timeout expires — up to 30 seconds.
Without the per-tenant share, one tenant pointing enough subscriptions at
dead endpoints would hold every slot for that long and nothing would leave
the broker for anybody else. The share is a fraction of the bound rather
than an equal split of it, so a tenant delivering alone still runs several
notifications at once.
Backup and restore, per store mode
| Mode | Backup |
|---|---|
memory | nothing to back up; the process is the data |
file | stop-copy only: stop the broker, copy antares.redb, restart |
postgres / timescale | ordinary Postgres backup or PITR; entities, subscriptions, registrations, outbox, dead letters, entity maps and the temporal tables all live in the one database |
postgres. A custom-format dump restores with --clean, so the same
command works on an empty and on a populated database:
pg_dump -U postgres -Fc antares > antares.dump
pg_restore -U postgres -d antares --clean --if-exists antares.dump
Drill on a database holding four entities: SELECT count(*) FROM entities
answers 4, DELETE FROM entities brings it to 0, pg_restore exits 0 and
the count is 4 again. Stop the brokers before restoring; they cache
nothing, but a write during the restore lands in a table that is about to
be replaced.
timescale. The same tools; wrap the restore in TimescaleDB's
SELECT timescaledb_pre_restore(); and SELECT timescaledb_post_restore();
so the hypertable catalog is restored with the data.
file. redb holds an exclusive lock, so a copy of a running broker's file can tear. Stop, copy, restart:
kill -TERM $(pidof antares) # drains, then exits
cp -r "$ANTARES_DATA_DIR" /backup/antares-$(date +%F)
Drill: an entity created with an observedAt, broker stopped with SIGTERM,
directory copied, a broker started on the copy answers
GET /entities/urn:ngsi-ld:Vehicle:f:1 and its temporal history. A second
broker on a directory that is already open fails at startup:
Error: "open …/antares.redb: Database already open. Cannot acquire lock."
Background jobs
One sweep loop per process, every ANTARES_SWEEP_SECS (default 900):
- Expired entities (
expiresAt, 4.22) are deleted. - Registrations, snapshots and entity maps (5.14; one hour by default, a
client-set
expiresAtcapped at 24 hours) carry their own expiry and are deleted by the same loop. A read never deletes one: it refuses the expired document and leaves the row for the sweep, so a broker pointed at a read replica serves GET without writing. - With
ANTARES_TEMPORAL_RETENTION_DAYSset, attribute instances older than the horizon are pruned from the postgres or timescale temporal half, wherever that half lives (afilestore withpostgreshistory runs the job too). Drill withANTARES_TEMPORAL_RETENTION_DAYS=30 ANTARES_SWEEP_SECS=2: an entity with one instance observed seven months back and one observed this hour shows both before the sweep and only the recent one after it.
The outbox drainer (ANTARES_OUTBOX_DRAIN) is the other loop; it hands
committed changes to the matcher and can be moved to a dedicated process.
A change whose document exceeds the bus message ceiling (256 KB) travels as
a claim-check reference, and its outbox row is kept instead of deleted: that
row holds the bodies the message dropped, and the matcher reads them back by
the event's sequence number. Such rows carry a published_at stamp, sit out
of the drain's page, and the maintenance pass frees them 24 hours later. A
matcher lagging further behind than that resolves nothing — the change is
logged and counted on antares_claim_check_unresolved_total. A non-zero
counter means the consumer side, not the store, needs attention.
Egress breaker
Every broker-initiated request (notification, forward, @context fetch)
passes the egress policy: scheme allowlist, private-range rule
(ANTARES_EGRESS_ALLOW_PRIVATE), redirect cap, DNS pinning and response
size caps. On top of it a per-destination breaker tracks timeouts: five
consecutive timeouts trip the destination open; while open, one probe per
30 seconds is admitted (half-open) and a success closes it again. A
destination that answers, even with an error status, never trips; only
silence does. At most 4096 destinations are tracked. A refused or tripped
delivery is booked on the subscription as a failure (lastFailure,
status: failed) and, with a delivery policy configured, is not retried.
An @context fetch is the one broker-initiated request that gets a second
attempt: a connection carrying no response at all — refused, or dropped
before a status line — is sent once more before the client is answered
LdContextNotAvailable. A timeout, a redirect-cap breach and any response
that did arrive are answers rather than accidents, so none of them is
repeated, and the two attempts share one fetch deadline.
Drain
On SIGTERM the broker flips /q/health to 503, keeps serving for
ANTARES_DRAIN_DELAY_MS (default 2000) so the load balancer notices, then
waits up to ANTARES_DRAIN_DEADLINE_SECS (default 20) for in-flight
requests before exiting. Set the container's stop grace period above the
sum. The rolling-update section below relies on exactly this sequence.
Bulk load (postgres, timescale)
dev/bulk-load.sh loads entities straight into the entities table for
initial loads and migrations. It bypasses the broker: no notification
fires, no history is recorded, and the secondary indexes are dropped for
the duration, so run it against a database no broker is serving.
DATABASE_URL=postgres://postgres:postgres@localhost:5432/antares \
dev/bulk-load.sh vehicles.ndjson # tenant "default"
DATABASE_URL=… dev/bulk-load.sh vehicles.ndjson odpady
Input is NDJSON, one entity per line in the store's internal form:
attribute names as expanded IRIs, each attribute an array of instances,
id/type/scope short. type and scope may be a string or an
array; the loader stores both as arrays, which is the shape the query
evaluator reads. A line may name its own tenant as a prefix separated by
the byte 0x02 (t42<0x02>{"id":…}), which lets one file load many
tenants; a bare JSON line lands in the tenant given as the argument. The
file may be a FIFO, so a generator can stream straight into the loader:
python3 dev/perf/gen.py --entities 1000000 --tenants 100 > /tmp/e.fifo &
DATABASE_URL=… dev/bulk-load.sh /tmp/e.fifo
{"id":"urn:ngsi-ld:Vehicle:bulk:1","type":"https://uri.etsi.org/ngsi-ld/default-context/Vehicle","https://uri.etsi.org/ngsi-ld/default-context/speed":[{"type":"Property","value":42}],"https://uri.etsi.org/ngsi-ld/location":[{"type":"GeoProperty","value":{"type":"Point","coordinates":[19.15,48.73]}}]}
{"id":"urn:ngsi-ld:Vehicle:bulk:2","type":"https://uri.etsi.org/ngsi-ld/default-context/Vehicle","scope":"/city/east","https://uri.etsi.org/ngsi-ld/default-context/speed":[{"type":"Property","value":7}]}
The script, step by step:
\copythe file into anUNLOGGEDstaging table; thejsonbcast is the only parser the payload meets.- Drop the five secondary indexes (
i_entities_location,i_entities_jsonb,i_entities_types,i_entities_loc_ambiguous,i_entities_expires); the primary key stays because the insert needs it. - Derive the columns the store derives on write:
types,scopes,created_at/modified_at(the document's values, elsenow()),expires_at, andlocationfrom the default GeoProperty when it has exactly one instance holding a GeoJSON geometry; any other shape with the GeoProperty present setslocation_ambiguous, and geo queries then judge that row in the broker instead of the index. INSERT … ON CONFLICT (tenant_id, id) DO NOTHING: a row that already exists is left as it is, the loader never overwrites API-written data.- Rebuild the five indexes and
ANALYZE entities.
Three lines offered, one of them an id the API had already created:
COPY 3
DROP INDEX
INSERT 0 2
DROP TABLE
CREATE INDEX
…
ANALYZE
bulk load done: 3 lines offered into tenant 'default'
Verify through the broker once it is back up; every query kind must see the loaded rows next to the API-written ones:
curl "$U/entities?type=Vehicle&q=speed>20&options=keyValues"
curl "$U/entities?type=Vehicle&georel=near;maxDistance==1000&geometry=Point&coordinates=[19.15,48.73]&options=keyValues"
curl "$U/entities?type=Vehicle&scopeQ=/city/%23&options=keyValues"
or in SQL, before restarting the broker:
SELECT id, types[1], scopes, ST_AsText(location), location_ambiguous
FROM entities WHERE tenant_id = 'default' ORDER BY id;
Rolling update
dev/rolling-update.sh — one instance at a time against the HA compose
stack: SIGTERM → /q/health flips 503 → haproxy ejects within 400 ms →
in-flight requests finish → recreate on the current image → wait healthy +
rise window before the next instance. Preconditions and env are documented
in the script header. file mode cannot roll (redb allows one process per
volume): use a Recreate strategy there, as broker-file.yaml does.
Role fleet: ROLES_SPLIT=1 dev/rolling-update.sh rolls all 10 pods of
the role-split stack in role-group order — the same-group peer must be
healthy before its twin goes down, so no role ever has 0 live pods (api
pods gate on /q/health + the LB rise window; workers on /q/ready).
Measured: full roll ≈ 43 s (the api pod pays the
~21 s drain, workers ~2 s each), 52/52 LB requests answered 200 across the
whole roll.
Proven: the roll-weekly workflow (Tue 04:17 UTC + dispatch) runs
the FULL ETSI suite through the LB while the replicas roll in a loop — the
suite has no retries, so any red TP is a real drain bug. The per-push
postgres-nats/timescale-nats matrix cells do the same over the
10-container role fleet. On k8s the same contract holds via the readiness
probe + terminationGracePeriodSeconds exceeding drain delay + deadline.
State reset (test/staging discipline)
API-level delete PAIRED with DB truncate — dev/reset-broker.sh plus the
suite's clean_db.sh; never raw-SQL-truncate or container-restart alone.
Federation/temporal state is only truly cleared by a volume-wiping teardown.
After a reset, restart the broker before measured runs (in-VM subscription
maps survive an external clean).
Upgrades
Minor versions roll in place under the rolling-update contract above.
Major upgrades go blue/green: deploy the new version EMPTY, replay
declarative state (entities, subscriptions, registrations) through the
standard NGSI-LD API from your configuration source of truth, verify with
smoke queries, switch traffic. Because the broker is vanilla CIM 009, the
replay needs no Antares-specific tooling — any GitOps/city-as-code plane
that speaks the standard API can drive it (this is requirement CC-50/51 of
the companion configuration-plane spec). Temporal history is NOT part of
the replay — restore it from database backup (per-store recipes above).
The file store carries a format version: a downgraded or corrupted file
is refused at startup rather than partially served.
Rolling a minor version in place is proven on every release tag rather than
asserted: upgrade-path builds the previous tag's binary and this one, lets
the old binary write an entity, its history and a subscription through the
standard API, then points the new binary at that same file and postgres
store and requires all three back — the entity at its last value, the
history intact, and the stored subscription firing on a write the new binary
accepts. Run it against any two binaries with
dev/upgrade-path.sh OLD NEW (ANTARES_TEST_DATABASE_URL adds the postgres
half).
Where the proofs run
| Claim | Workflow |
|---|---|
| ETSI conformance, per-commit gate (file/postgres/timescale × 10 suites) | ci.yml → etsi-matrix.yml preset: quick (every push) |
| ETSI conformance, FULL seven cells (memory/file/postgres/timescale + the two rolling role-fleet cells + wasm-file) × 10 suites | full.yml (twice a week + v* tags + dispatch); its bundle feeds the report page + per-cell badges |
| The browser artifact serves the full API from a container (file store, serial suites + IOP) | the wasm-file matrix cell (WASM=1 WASM_DOCKER=1 STORE=file through the one pipeline — Dockerfile.wasm, the same www/pkg bytes a page loads) |
| Zero-downtime rolling update | roll-weekly (Tue 04:17 UTC + dispatch) + the full-run -nats matrix cells (10-pod fleet rolling under the whole suite) |
| Role-pair exactly-once semantics (duplicated matcher/notifier/temporal/registry pods) | ci.yml nats job (nats_e2e::role_pairs_exactly_once_semantics, live PG + NATS) |
| NATS bus + role split e2e | ci.yml nats job (nats_e2e, live PG + NATS) |
| Data written by the previous release is served by this one (file + postgres) | full.yml upgrade-path on every v* tag: the two release binaries are built and pointed at one store in turn (dev/upgrade-path.sh) |
| k8s manifests boot | k8s-smoke.yml kind smoke (dispatch) |
| Coverage | strict.yml coverage job (daily line-coverage floor) + etsi-coverage.yml (Mon 04:41 UTC) → merged lcov/html on the report page |
Browser & WebAssembly
The same broker crates compile to wasm32-unknown-unknown and run inside
a web page — an NGSI-LD broker with zero installation. Current artifact:
4.05 MB raw, 1.55 MB gzipped (budgets: 8 MB / 3 MB, the build fails
over budget). Try it: https://antaresbroker.joinedcontext.com/demo/.
Two ways to use it in a page
- Service Worker: the worker intercepts
fetchand answers/ngsi-ld/v1/*for the whole origin — existing NGSI-LD client code works unchanged against the page's own URL. - In-page API:
await broker.fetch(request)with the browser's ownRequest/Responseobjects — a caller cannot tell it from a network broker.
Stores in the browser: memory, or persistent via
AntaresBroker.persistentWithHandle(...) over an OPFS sync-access handle
(the browser's origin-private file system) — the same redb format as the
native file store.
What a page cannot do (structural, not missing features)
- No inbound sockets and CORS: other systems cannot call into a page,
so inbound federation and external HTTP notification callbacks are out
of reach. Outbound notifications and forwards still leave via
fetch. - No MQTT, NATS, Postgres, or role-split — those need an OS process.
Content-Lengthis a forbidden browser header: the wasm seam stamps it from the buffered body, since a page can never send it (CIM 009 6.3.4 is enforced on the wire truth by whatever fronts the broker — in the Node tier, the shim).
The Node tier
www/node-shim.mjs serves the SAME .wasm bytes behind a real TCP port
(Node ≥ 18) — this is how the browser artifact is conformance-tested: the
wasm-file CI cell runs the serial ETSI suites + IOP against five
dockerized shims over the redb file store. Per-shim env: ANTARES_STORE
(memory/file), ANTARES_FILE (redb path), ANTARES_HOST_ALIAS, and
globalThis.ANTARES_PUBLIC_URL for distributed subscriptions (wasm has no
process env — the shim wires these through JS globals).
Build it
./dev/install-wasm-tools.sh # wasm-bindgen (lockfile-matched) + wasm-opt
./dev/wasm-build.sh # → www/pkg; fails if over the size budgets
node www/node-shim.mjs 9090 # the artifact behind a TCP port
./dev/wasm-test.sh # Node smoke + headless-Chromium page test
www/index.html is the playground: create entities, subscribe, watch
notifications arrive in-page — including a loopback federation demo where
one browser tab hosts multiple context spaces federating through CSRs.
Configuration reference
All configuration is environment variables — no config file. Unknown
ANTARES_STORE/ANTARES_BUS values are fatal at startup, never silently
defaulted. This table is checked against the source by
dev/check-env-docs.sh (CI fails when a variable exists in code but not
here).
Core
| Variable | Default | Effect |
|---|---|---|
ANTARES_STORE | memory | Store mode: memory, file, postgres, timescale. Unknown value = fatal. |
ANTARES_TEMPORAL | follows ANTARES_STORE | Temporal driver: a store mode, or none — history off. Mix freely with ANTARES_STORE, e.g. file current state with timescale history; temporal reads answer OperationNotSupported (422, CIM 009 Table 6.3.2-1) and nothing is recorded. A backend different from the store builds a second store instance used only for history. |
ANTARES_HTTP_PORT | 9090 | HTTP listen port. |
ANTARES_ROLES | all | Comma list of roles this process runs: api, matcher, notifier, temporal, registry — the role-split fleet shape. |
ANTARES_BUS | local | Change-event bus: local (in-process, single node) or nats (JetStream, multi-pod). Unknown value = fatal. |
ANTARES_HOST_ALIAS | antares | This broker's name in federation Via chains (CIM 009 6.3.18) — loop detection identity. Two LB'd replicas of one logical broker share one alias. |
ANTARES_PUBLIC_URL | http://{host_alias}:{port} | The URL peers can reach this broker at: forwarded subscription copies notify {ANTARES_PUBLIC_URL}/ex/v1/remote-notify (5.8.1.4). Set it whenever the default is not routable from peers. |
Store backends
| Variable | Default | Effect |
|---|---|---|
ANTARES_DATA_DIR | — (required for file) | Directory for the redb file. Must be a mounted volume — data never lives inside the image. |
ANTARES_DATABASE_URL | — (required for postgres/timescale) | PostgreSQL connection string; PostGIS required, TimescaleDB for timescale. Bounded startup retry while the DB boots. |
ANTARES_REQUIRE_RLS | unset | 1/true: refuse to start when the DB role bypasses Row-Level Security (defense-in-depth for shared-schema multi-tenancy). |
ANTARES_PG_POOL | 20 | Connection-pool size for postgres/timescale. Unparsable value = fatal. Sessions carry lock_timeout 5 s. A request that waits the pool's 5 s acquire timeout without getting a connection is answered 503 with Retry-After — see the sizing formula in the operations runbook. |
ANTARES_PG_STATEMENT_TIMEOUT_MS | 30000 | Per-session statement_timeout on every pooled connection: a query past it is cancelled and answered InternalError (500, "database statement timeout"; CIM 009 5.5.2 names database timeouts as InternalError). Migrations are exempt. Not a positive integer = fatal. |
ANTARES_MIGRATE | on | 0/false skips running migrations from this process, so serving replicas do not race the DDL — run them once from a job or init container instead. |
ANTARES_ALLOW_SHARED_LOCAL | unset | 1 permits bus=local with a postgres/timescale store — safe ONLY for a strictly single-process deployment; two such processes double-fire notifications. |
ANTARES_TEMPORAL_RECORD | all | History gate for the entity endpoints. all: every changed attribute instance is recorded. observed: only instances carrying observedAt enter history — a metadata-only write (no observedAt) updates current state and its modifiedAt but leaves no history, so timeproperty=modifiedAt/createdAt temporal queries return nothing for never-observed attributes. none: the entity endpoints record nothing; the temporal API still stores and serves what it is given directly (unlike ANTARES_TEMPORAL=none, which switches the temporal seam off). The ETSI temporal suites assume all, which is why it stays the default. Unknown value = fatal. |
ANTARES_TEMPORAL_RETENTION_DAYS | unset (keep forever) | Temporal history retention; the sweep job prunes older attribute instances. Applies to the temporal half wherever it lives: a file store with postgres history still runs the job. |
ANTARES_SWEEP_SECS | 900 | Cadence of the background GC sweep (expired entities/registrations, 4.22) — identical across store modes. |
NATS scale-out
| Variable | Default | Effect |
|---|---|---|
ANTARES_NATS_URL | — (required for bus=nats) | NATS server URL; JetStream streams and the subscription KV bucket are asserted at startup. |
ANTARES_NATS_REPLICAS | 1 | JetStream replica count for streams/KV (set 3 on a 3-node NATS cluster). |
ANTARES_OUTBOX_DRAIN | on | off disables the notification outbox drainer in this process (crash-drill lever / dedicated-drainer split). |
Federation & egress hardening
| Variable | Default | Effect |
|---|---|---|
ANTARES_EGRESS_ALLOW_PRIVATE | true | Broker-initiated HTTP and MQTT (notifications, forwards, @context fetches) may reach loopback, link-local and RFC 1918 destinations. Set false (or 0) on an internet-exposed deployment to deny those together with carrier-grade NAT (100.64.0.0/10), 0.0.0.0/8, the IETF assignment and benchmarking blocks and the reserved space above 240.0.0.0. The cloud instance-metadata endpoints are refused whatever this is set to, in every IPv6 spelling. The scheme allowlist, redirect cap, DNS pinning and response-size caps apply regardless. A refused delivery is booked as a failure (lastFailure, status: failed) and never retried. |
ANTARES_FED_FANOUT | 8 | Concurrent forwards per distributed read (4.3.6.1 orders the merge, not the requests). |
ANTARES_FED_INFLIGHT | 256 | Forwarded requests in flight for the whole process; callers over the cap wait. Bounds the buffers and connections open federated queries hold (6 000 open queries × 34 sources once reached 7.7 GB). |
ANTARES_MAX_FED_RESPONSE_BYTES | 16777216 (16 MiB) | Ceiling on one forwarded response body — one misbehaving peer cannot balloon broker memory. Over-cap parts fail as warning 111 (Table 6.3.17-1). |
ANTARES_MAX_BATCH_ITEMS | 1000 | Batch entity-count cap (DoS bound; the spec sets none). Raise for trusted bulk producers. |
ANTARES_MAX_BODY_BYTES | 4194304 (4 MiB) | Request body cap, answered with a bare 413 (6.3.4). One number governs the extractor limit and the bounds wall. |
ANTARES_POLICY | allow-all | The policy engine every operation is asked about (ADR-0020). The shipped binary is built with allow-all alone, which decides nothing; an unknown name is fatal at startup and names the shelf the binary was built with, so a typo cannot quietly serve every request wide open. An engine that refuses is answered 403 with the ProblemDetails type urn:antares:error:AccessDenied and the engine's own reason — this broker's own URN, because Table 6.3.2-1 names no access-denied error and none is invented under the ETSI namespace. An engine that narrows instead conjoins its condition into the query the store runs and drops the members it named from every document served; a read narrowed that way answers Antares-Results-Restricted: true when the engine asked for the marker, and is otherwise silent. A single Entity outside the narrowing answers 404 like an absent one, so a caller cannot tell the two apart. The engine is also asked about each notification before it is sent: it may drop one — which is no delivery attempt, so timesSent and lastNotification do not move — or project the entities it carries. |
ANTARES_POLICY_SUBJECT_HEADERS | unset (no headers) | Comma list of request headers copied into the subject the engine is given, matched case-insensitively. The broker never interprets them and never lets them leave: they are stripped from forwarded requests and absent from notifications, dead letters and logs. One copy is persisted: a Subscription, an EntityMap and a Snapshot each keep their creator's headers, because all three are used again after the request that made them — 5.8.6 delivery is broker-initiated, a snapshot's fill runs once its request has been answered, and an EntityMap presented by a different subject is treated as one that cannot be accessed (5.5.14), so a new one is built for that request. That copy is a broker-internal member — no representation renders it, no client can set it, and the 5.8.1.4 copy forwarded to a Context Source is stripped of it — but it does live in the store, so name a header that identifies the subscriber, not one that authenticates them. |
ANTARES_POLICY_TIMEOUT_MS | 250 | How long the policy engine has to answer one request before the seam stops waiting and denies (ADR-0020). The engine the broker ships allows everything and never waits, so this matters only to a deployment that attached its own. |
ANTARES_CORS_ORIGINS | unset (no CORS headers) | Browser origins allowed, comma-separated, or *. Preflights are answered for every method and header; Link, NGSILD-Tenant and NGSILD-Results-Count are exposed. |
ANTARES_API_SURFACES | admin | Comma list of HTTP surfaces mounted beside the NGSI-LD API root, each under its own reserved prefix (admin serves /q). An unknown name is fatal at startup and names the shelf the binary was built with; a selection that leaves out admin serves no /q at all, probes included. |
ANTARES_EXTRA_CA_FILE | unset | PEM bundle of ADDITIONAL trust anchors for egress TLS (private CAs). Egress TLS trusts the host's certificate store (/etc/ssl/certs on Linux; the shipped image carries one), and this widens it. Verification itself is never disableable. |
Notification delivery
| Variable | Default | Effect |
|---|---|---|
ANTARES_NOTIFY_ATTEMPTS | 1 | Delivery attempts per notification, first one included. 1 is 5.8.6 as written: one send, the outcome booked. Higher values retry on their own task with exponential backoff; the retries never move timesSent again. |
ANTARES_NOTIFY_BACKOFF_MS | 1000 | Delay before the first retry; doubles per retry (±20 % jitter, 60 s ceiling). |
ANTARES_NOTIFY_MAX_AGE_SECS | 300 | No retry starts later than this after the first attempt. When the attempts or the age run out the notification becomes a dead letter (/q/dead-letters, see operations). |
ANTARES_DELIVERY_WIDTH | 64 | Notifications in flight at once across the whole broker. A slot is held until the endpoint answers, so the number that fits depends on the subscribers: local sinks free a slot in milliseconds, a remote endpoint sitting at its 30 s timeout (Table 5.2.15-1) holds one for the whole timeout. Published as deliveryWidth in /q/health. |
ANTARES_DELIVERY_WIDTH_PER_TENANT | 8 | Of that width, what one tenant may hold, capped at the width. Without it a single tenant with enough dead endpoints holds every slot and nothing leaves the broker for anyone else. Published as deliveryWidthPerTenant. |
Lifecycle & observability
| Variable | Default | Effect |
|---|---|---|
ANTARES_HEADER_READ_TIMEOUT_MS | 10000 | A connection that has not finished its request HEAD within this window is closed (slow-loris bound). |
ANTARES_MAX_CONNECTIONS | 10000 | Concurrent-connection ceiling; further accepts are dropped. Counts keep-alive and LB health-check connections too — size accordingly. |
ANTARES_DISCOVERY_SCAN_MAX | 100000 | Documents one unpaginated whole-tenant fold may read or hold: the /types//attributes discovery folds (5.7.5-5.7.10 define no pagination) and the registration query (5.10.2.4 filters before it pages, so the whole match set is held). Past it the answer is 403 TooManyResults (5.5.6) instead of an unbounded scan. Published as maxFoldDocs in /q/health. |
ANTARES_DRAIN_DELAY_MS | 2000 | Rolling update, step 2: keep serving this long after /q/health flips to 503 — the load balancer's notice window, sized so a health poll actually observes the 503 before the socket goes. |
ANTARES_DRAIN_DEADLINE_SECS | 20 | Bound on waiting for in-flight connections during drain. Container stop_grace_period / terminationGracePeriodSeconds MUST exceed delay + deadline. |
ANTARES_TELEMETRY | off | Any value but an off spelling enables the metrics recorder and, with the endpoint, the OTLP span and log pipelines. |
ANTARES_OTLP_ENDPOINT | unset | OTLP/HTTP collector for traces and logs, e.g. http://collector:4318/v1/traces; log records go to the v1/logs twin of that URL with the same resource attributes. Unset costs nothing. |
Compile-time bounds (no variable sets them; spec-shaped rejections): URI
8 KiB → 414, JSON depth 64 → 400. The body cap is ANTARES_MAX_BODY_BYTES
above. Every bound in force is reported live by GET /q/health under
limits.
Node-shim (wasm tier) extras: ANTARES_FILE (redb path per shim) — see
the browser guide.
Admin API
This chapter is every route the broker serves OUTSIDE /ngsi-ld/v1.
There are three groups and no others: /q/ is the operator surface,
/ex/v1/ is the peer-facing wire between brokers, and /x/ is whatever a
deployment mounted. Everything else the broker answers is CIM 009, and the
conformance ledger owns it.
/q/ carries no NGSI-LD semantics and belongs behind the gateway. A worker
pod (ANTARES_ROLES without api) serves only these routes. Errors use the
same problem-details shape as the NGSI-LD API.
The same routes as an OpenAPI document, with every status code and response
schema: docs/openapi/antares-admin.yaml, rendered at
https://antaresbroker.joinedcontext.com/openapi/admin.html (see the
API reference).
| Route | Purpose |
|---|---|
GET /q/health | Liveness and the process view |
GET /q/ready | Readiness for the load balancer |
GET /q/metrics | Prometheus text |
GET /q/tenants | Tenant names |
GET /q/tenants/{tenant} | What one tenant holds |
DELETE /q/tenants/{tenant} | Tenant purge |
GET /q/dead-letters | Notifications the delivery policy gave up on |
POST /q/dead-letters/{id}/replay | One more delivery attempt |
DELETE /q/dead-letters/{id} | Drop a dead letter |
POST /ex/v1/remote-notify | The 5.8.1.4 receiver other brokers post to |
GET /q/health
200 with the process view, 503 while the instance drains (a roll in
progress). A memory-store broker answers:
{
"status": "UP",
"store": "memory",
"temporal": "memory",
"version": "0.1.0",
"commit": "3432674",
"notificationSchemes": ["http", "https", "mqtt", "mqtts"],
"deadLetters": 0,
"changesDropped": 0,
"taskPanics": 0,
"temporalDrainErrors": 0,
"policy": { "engine": "allow-all", "timeoutMs": 250 },
"surfaces": { "admin": { "prefix": "/q", "routes": 8 } },
"limits": {
"changeQueue": 1024,
"deliveryWidth": 64,
"deliveryWidthPerTenant": 8,
"maxBatchItems": 1000,
"maxBodyBytes": 4194304,
"maxContextFetches": 32,
"maxFedFanout": 8,
"maxFedInflight": 256,
"maxFedResponseBytes": 16777216,
"maxFoldDocs": 100000,
"maxGeoVertices": 1024,
"maxInProcessCallDepth": 8,
"maxJoinLevel": 10,
"maxJsonDepth": 64,
"maxPeerWarnings": 8,
"maxQLinkLookups": 512,
"maxQNodes": 512,
"maxRegexCache": 1024,
"maxRegexCacheBytes": 67108864,
"maxRegexProgramBytes": 262144,
"maxTrackedDestinations": 4096,
"maxUriBytes": 8192,
"rejectedBodyTooDeep": 0,
"rejectedBodyTooLarge": 0,
"rejectedUriTooLong": 0
},
"memory": { "allocatedBytes": 5671592, "residentBytes": 24051712 }
}
| Field | Meaning |
|---|---|
status | UP, or DRAINING with status 503 once shutdown began. |
store | The current-state backend: memory, file, postgres, timescale. |
temporal | The history backend: one of the four, or none when history is off (ANTARES_TEMPORAL). |
storeInfo, temporalInfo | What each driver actually runs on: {engine} for the built-in stores (memory or redb), and on Postgres {engine, poolSize, poolAcquireTimeoutSeconds, server, postgis?, timescaledb?} — the pool's own shape always, the server probe read once at startup and omitted if it failed. Absent when a driver has nothing to add to its name (none history). Two deployments answering postgres are told apart here. |
version, commit | Workspace version and the git hash the binary was built from. |
notificationSchemes | The notification.endpoint.uri schemes this build can deliver to — the registered bindings (6.3.8, clause 7, and any a deployment added). A subscription naming a scheme absent here is refused at creation with BadRequestData. |
deadLetters | Dead letters this process wrote since start (notification delivery). |
changesDropped | Changes the bounded matcher queue refused since start, each one a notification never matched: delivery is slower than the write rate. |
taskPanics | Panics absorbed at the notification-task boundary since start, each one a lost notification. Reported here because the Prometheus recorder is off unless ANTARES_TELEMETRY is set. |
temporalDrainErrors | Post-response history writes that failed since start; the client's 2xx stands, the counter and a warning record the loss. |
policy | The policy engine this binary was started with (ANTARES_POLICY, allow-all unless a deployment registered another) and the deadline one decision gets (ANTARES_POLICY_TIMEOUT_MS). An engine that overruns it is a refusal, not a delay. |
surfaces | The mounted admin surfaces by name, each with its prefix and route count. |
limits | The bounds wall: every max* cap in force and the rejected* counters of requests refused by it, plus the notification-pipeline ceilings (changeQueue, deliveryWidth, deliveryWidthPerTenant, maxTrackedDestinations). No request is rejected against those four — they are published because reaching one is what a dropped change or a stalled fan-out looks like from outside. |
memory | jemalloc live (allocatedBytes) and resident (residentBytes) bytes. |
commitQueueDepth, commitQueuePeak | Present only for a store with a single committer to queue behind (file, and the browser build over OPFS): writers queued now and at peak. |
bus | ANTARES_BUS=nats only: {mode, connected, reconnects}. |
GET /q/ready
200 {"status":"READY","store":true} when the instance is not draining,
the store answers (SELECT 1 on Postgres) and, under bus=nats, the bus
is connected; otherwise 503 {"status":"NOT_READY", …} with the failing
member false. Point the readiness probe here and the liveness probe at
/q/health: a restart does not cure a lost database.
GET /q/metrics
Prometheus text with the antares_ prefix:
| Metric | Meaning |
|---|---|
antares_http_requests_total | Requests served, by method and status class. |
antares_http_request_duration_seconds | Request service time histogram. |
antares_limit_rejections_total | Bounds-wall rejections, by limit. |
antares_commit_queue_depth | file store: writers queued behind the redb committer. |
antares_memory_allocated_bytes, antares_memory_resident_bytes | jemalloc live and resident bytes. |
antares_uptime_seconds | Seconds since process start. |
antares_draining | 1 while this instance drains. |
antares_temporal_drain_errors_total | Failed post-response history writes. |
antares_notifications_sent_total, antares_notifications_retried_total, antares_notifications_failed_total | Notification deliveries by outcome. |
antares_notification_changes_dropped_total | Change events the notifier dropped under back-pressure. |
antares_claim_check_unresolved_total | Changes too big for the bus whose kept outbox row was already reaped: the before-image is gone and the change notifies nobody. Non-zero means a matcher lagged past the retention window. |
antares_notification_task_panics_total | Delivery tasks that panicked (a bug, never expected). |
antares_change_lag_seconds | Age of the change a notifier is handling. |
antares_policy_failures_total | Decisions the policy seam had to make itself because the engine did not, by reason (panic, timeout). Always zero under the built-in allow-all engine. |
antares_pg_transaction_begin_seconds | postgres/timescale: time to obtain a pooled connection and open a transaction — the pool wait plus one BEGIN round trip. |
antares_pg_pool_timeouts_total | postgres/timescale: acquire timeouts, each one a request answered 503 with Retry-After. |
Every _seconds metric is a true histogram, bucketed at 5 ms, 10 ms, 25 ms,
50 ms, 100 ms, 250 ms, 500 ms, 1 s, 2.5 s, 5 s, 10 s, 30 s and 60 s. The
bounds are what histogram_quantile() can resolve, and the top ones exist
because service time reaches tens of seconds once the accept path saturates.
Tenants
GET /q/tenants
Answers the names, sorted, and nothing else:
GET /q/tenants
["default","acme","zoo"]
Names only on purpose. A deployment runs up to 10 000 tenants (ADR-0001), and
a list carrying per-kind counts would cost a count per kind per tenant on one
request. 200 always; the route takes no parameters. Pick a name, then read
its detail:
The list is the customer accounts. The broker mints tenants of its own — one per Snapshot, plus the two indexes — and none of them appears here or is addressable through the routes below.
GET /q/tenants/
GET /q/tenants/acme
{"tenant":"acme","counts":{"entities":0,"subscriptions":0,"csourceSubscriptions":0,
"registrations":0,"snapshots":0,"entityMaps":0,"distSubs":0,"attrInstances":0}}
200, 404 for a tenant that does not exist, 400 for a name outside the
tenant grammar or one of the broker's internal names. createdAt is present
on Postgres, where the tenants table records it. The default tenant always
exists (5.5.10) and is always readable, even when empty.
DELETE /q/tenants/
Purges the tenant: 204 when done, 404 for
an unknown tenant ({"title":"ResourceNotFound","detail":"tenant nope"}),
409 while a distributed subscription of it still holds a copy at a
Context Source, 400 for a name outside the tenant grammar. The default
tenant is emptied and keeps existing. The path names the tenant; an
NGSILD-Tenant header is ignored. Background in
operations.
Dead letters
A letter carries the subscription id, tenant, endpoint, headers, body, attempt count, first and last error and timestamps. What produces one and why an egress refusal never does: notification delivery.
GET /q/dead-letters
?tenant=&subscription=&limit= — letters of one tenant (the default tenant
when tenant is absent), newest first, limit 100 by default; 400 for a
limit that is not a positive integer or a tenant outside the grammar.
Endpoint userinfo, receiverInfo, notifierInfo and the rendered headers
of an older letter are shown blanked; the stored letter keeps them so a
replay still authenticates.
POST /q/dead-letters/{id}/replay
?tenant= — one attempt through the same binding under the egress policy of
the moment: 204 and the letter is gone, 502 with the failure text and the
letter kept, 404 when the tenant holds no such letter.
DELETE /q/dead-letters/
?tenant= — 204, or 404 when the tenant holds no such letter.
The peer-facing wire
POST /ex/v1/remote-notify
Where a Context Source posts a notification for a distributed subscription
this broker created (5.8.1.4). It is the one non-standard route that cannot
be firewalled off with /q/: every Context Source a subscription copy was
forwarded to has to reach it, and ANTARES_PUBLIC_URL is what that copy
advertises. It sits outside the /ngsi-ld prefix ETSI owns, and v1
versions the broker-to-broker wire independently of the NGSI-LD API version
(ADR-0019).
The body is an NGSI-LD Notification. Its subscriptionId is the routing
key, and it is a broker-generated UUID rather than the subscriber's own
Subscription id, so a Context Source learns nothing about the subscriber and
cannot address any other subscription. That key resolves through the stored
mapping to the tenant and the local subscription; the request's
NGSILD-Tenant header is not read on this route at all.
200 when the notification was accepted — including when the mapping
resolves but nothing was left to deliver. 400 for a body that is not JSON,
a body without subscriptionId, or a data array carrying more Entities
than ANTARES_MAX_BATCH_ITEMS (the cap is applied before any store touch:
one notification drives one local retrieve and one federated fan-out per
Entity, so an uncapped array is an amplification lever). 404 when no
distributed subscription maps the key. Like /q/, this route carries the
body limit and the bounds wall itself — a peer-facing write path must not be
the one route where the documented caps do not apply.
Deployment surfaces under /x
/x, and any path below it, belongs to the deployment. A surface registered
there (extending) mounts its own routes and
documents them itself; the broker only guarantees the ground rules. A prefix
outside /q, /x and below-/x is refused at startup, as is one that
overlaps a surface already mounted, so a deployment route can never shadow a
spec resource or race another surface for a path. ANTARES_API_SURFACES
names which surfaces are mounted, and /q/health reports each one's prefix
and route count under surfaces.
The shipped binary mounts admin and nothing else. The reference plugin
(examples/plugin-example, off by default) mounts /x/example and is the
worked example of both a surface and a façade.
Storage drivers
The broker holds storage behind two object-safe traits in the
dependency-free antares-store crate (crates/antares-store/src/lib.rs,
ADR-0013): the core names the traits and no backend.
| Trait | Surface | Chosen by |
|---|---|---|
CurrentStateDriver | create / get / delete / list / upsert, the batch operations, query_entities, matching_registrations, sweep_expired, tenant inventory and purge, @context documents, ping / close / version, commit-queue depth, change hook and outbox wiring. | ANTARES_STORE |
TemporalDriver | temporal_append, query_temporal, get_temporal, the temporal delete paths (CIM 009 5.6.13 to 5.6.16), the temporal-entity documents, and the event intake (event / event_list) that the post-response drain feeds. supported() tells the API whether history exists at all, and close() / version_info() mirror the current-state seam — a temporal driver on a backend of its own has its own pool to drain and its own version to report. | ANTARES_TEMPORAL |
AppState carries one Arc<dyn CurrentStateDriver> and one
Arc<dyn TemporalDriver>. Both traits keep generic mutate<T, E> ergonomics
through the *Ext extension traits over a boxed mutate_boxed. Dynamic
dispatch runs a handful of times per request against a database round trip
or a JSON-LD expansion, so it costs nothing measurable.
The store ladder
memory → file → postgres → timescale (ADR-0004): one binary, one
configuration value. The mem/ and pg/ folders under
crates/antares-sql/src/store/ are the two implementations; any.rs is the
AnyStore dispatcher that implements both traits. The folder README
(crates/antares-sql/src/store/README.md) lists the steps for adding a
backend.
| Mode | Current state lives in | History lives in | Survives |
|---|---|---|---|
memory | process maps | process maps | nothing; the unit-test default and the read-only-rootfs mode |
file | the same maps, with a redb write-through shadow in ANTARES_DATA_DIR/antares.redb | the same file | process restart, kill -9 after the 2xx (commit before ack) |
postgres | one shared-schema database, tenant_id on every row under Row-Level Security | attr_instances, range-partitioned by observed_at | restart, replica failover, ordinary Postgres backup |
timescale | the same schema | attr_instances as a hypertable (7-day chunks, native compression) | as postgres |
browser (wasm) | the memory maps, or AntaresBroker.persistentWithHandle(...) over an OPFS sync-access handle | the same | the origin's private file system; the redb format is the native file one (Browser & WebAssembly) |
file is not a second store: the redb shadow holds one table per resource
family (entities, subscriptions, csource_registrations,
csource_subscriptions, temporal_entities, jsonld_contexts, snapshots,
entity_map_docs, dist_subs, dead_letters, plus meta for the format
version), keyed tenant\0id, value the expanded JSON. Every commit is
Durability::Immediate inside the store's write-critical section, so the
fsync completes before the HTTP answer leaves. A failed commit aborts the
process rather than acknowledge a write the file does not hold. Boot rebuilds
the maps from the file and refuses a format-version mismatch. redb keeps an
exclusive lock: one broker per volume, stop-copy backup, Recreate-only
rollouts. Measured on the dev box with 1.5 KB entities: about 3,100 fsynced
writes per second, commit p50 0.21 ms, p99 0.85 ms.
Choosing the temporal driver
ANTARES_TEMPORAL names the history backend and defaults to following
ANTARES_STORE. Any pairing works: file current state with timescale
history, postgres current state with memory history. A backend different
from the store builds a second store instance used only for history; a
Postgres half runs its own maintenance and retention job wherever the
current state lives, and /q/health reports both halves (store,
temporal).
ANTARES_TEMPORAL=none installs the NoTemporal driver: supported() is
false, the recorder and the bookkeeping paths become no-ops, and every
client-facing temporal read answers OperationNotSupported with status 422
(CIM 009 Table 6.3.2-1):
{
"type": "https://uri.etsi.org/ngsi-ld/errors/OperationNotSupported",
"title": "OperationNotSupported",
"status": 422,
"detail": "no temporal store is configured"
}
What enters history
History is fed after the response, from a queue of TemporalEvents the
entity endpoints produce. An event enters history only when every gate admits
it (crates/antares-api/src/history.rs):
- Value change, in the producer: an attribute instance whose value did not change never becomes an event.
ANTARES_TEMPORAL_RECORD:all(the default) admits everything;observedkeeps only instances carryingobservedAt, so a write without one updates current state and leaves no history;nonerecords nothing from the entity endpoints while the temporal API itself still stores what it is given.
observed is a narrowing, not a tidying: an Entity created through the Core
API with no observedAt has no temporal evolution at all under it, and a
temporal query over such an Entity answers empty rather than answering with
its values. That is why all is the default — five of the ETSI temporal
conformance tests create their fixtures without an observedAt and expect to
read the history back. Choose observed only where every producer stamps its
own observation times.
A drain that fails leaves the client's 2xx standing and increments
temporalDrainErrors on /q/health. ANTARES_TEMPORAL_RETENTION_DAYS prunes
attribute instances older than the window from the maintenance job on the
temporal half; unset keeps everything. The migration sets no retention on
purpose: a schema that silently drops data is the wrong surprise.
Measured storage cost
Measured on a 3.9 KB expanded entity (1.5 KB compact), and on attribute instances of the same shape:
| Where | Per entity | Per attribute instance |
|---|---|---|
| Postgres, plain | 3.1 KB, 938 B of it the whole-document GIN index | 1,387 B |
| TimescaleDB after columnstore compression | as plain | 120 B, read latency unchanged |
Process memory (serde_json::Value, all modes) | 37.6 KB resident | as the entity |
Two consequences shape the design. The in-memory Value costs nine times its
text, so memory-mode capacity is bounded by RAM, not by the file. And
expanded JSON-LD costs 4.8 times the compact text but only 1.8 times on disk,
because Postgres compression absorbs the repeated IRIs; storing the compact
form would buy nothing.
Migrations
crates/antares-sql/migrations/, applied by the first process to start
unless ANTARES_MIGRATE=0 hands the job to an init container.
| Migration | Adds |
|---|---|
0001_init | The PostGIS and btree_gin extensions; tenants; entities with the GiST location index, the jsonb_path_ops GIN serving q=, the tenant-scoped type index and the expiry index; subscriptions, csource_subscriptions, csource_registrations with the csource_index match table; jsonld_contexts; entity_maps and entity_map_docs (5.5.9.3 distributed pagination); outbox; snapshots; dist_subs; temporal_entities; maintenance_jobs; attr_instances as a hypertable when TimescaleDB is present, otherwise range-partitioned with a default partition, plus the lookup index and the idempotent-upsert key (tenant_id, entity_id, attr_id, instance_id, observed_at). Row-Level Security with a tenant_isolation policy on every tenant-scoped table. |
0002_dead_letters | dead_letters (tenant_id, id, doc), same RLS belt, read through the admin API. |
0003_comma_seconds_fraction | Rewrites try_timestamptz to accept the comma decimal separator 4.6.3 permits in requests. The stamps live in jsonb as the client wrote them, and a NULL from this function means "no expiry" to NOT_EXPIRED and to the 4.22 instance reap — so a comma-stamped expiresAt made the document immortal instead of raising. |
0004_drop_entity_maps | Drops the entity_maps row store 0001_init created. EntityMaps (5.14) are documents in entity_map_docs, which is what every read and write path uses; nothing ever wrote a row to the table. |
0005_service_escape_by_command | Splits the tenant_isolation policy on entities, outbox and (plain mode) attr_instances into one policy per command. 0001_init wrote a single FOR ALL policy whose USING clause named the antares.service escape, and USING is what PostgreSQL applies to the existing row of an UPDATE: a role that armed the escape could move another tenant's row into its own, because the WITH CHECK only sees the new row. The escape now reaches SELECT and DELETE, which is all the outbox drain and the two 4.22 reaps use. |
0006_context_tenant | Gives jsonld_contexts a tenant_id column and the RLS belt every other tenant-bearing table carries (ADR-0021). The column is GENERATED from the row: NULL for a Cached copy of a public document, which belongs to no tenant and is readable by all of them, and otherwise the owner member the row already carried — the default tenant for a row written before that member existed. No antares.service escape: nothing about a stored @context is cross-tenant work. |
0007_outbox_claim_check | Gives outbox a published_at stamp and the two partial indexes that keep the drain's page and the reap off each other's rows. An event whose bodies exceed the bus message ceiling travels as a claim-check reference, and this row is the only remaining copy of what it references — the store's current row is the after-image, so the before-image the notification diff needs is not recoverable from it. The drain stamps such a row published instead of deleting it, the consumer reads the event back by seq, and the maintenance pass frees it 24 hours later. |
Conformance
Antares implements ETSI GS CIM 009 V1.9.1. Conformance is tracked in two
places that check each other: the clause ledger in docs/spec/, and the
ETSI Robot Framework suite vendored at ngsi-ld-test-suite/, run in a
matrix of store modes on every push.
Every claim on this page is a claim about the broker as it ships: the
built-in allow-all policy engine, no addon crate, no feature this
repository leaves off by default. A deployment that attaches its own
policy engine (Policy engines) narrows what
its callers are answered with, and the conformance statement no longer
describes what those callers see.
The ledger
docs/spec/ holds one file per clause of CIM 009, 947 files, each
carrying the clause text, the PDF pages it came from, and three
hand-maintained fields: status, evidence (code and test anchors) and
notes. The robot: list is generated: python3 dev/spec.py robot
scans the suite for [Tags] in the clause form (5_6_6) and writes the
matching TP names into the clause file.
| status | meaning |
|---|---|
implemented | every SHALL of the clause holds, with evidence anchors |
partial | a named gap in notes |
not-implemented | audited, not yet built |
staged-v1x | deferred to a later spec version by decision |
informative | a heading, an umbrella clause or an informative annex; the requirements are audited in the leaf clauses it delegates to |
Current counts (python3 dev/spec.py status):
947 sections
implemented 477
informative 468
partial 2
robot-tagged 193
A partial names its gap in notes: and is the honest status for a clause
whose normative surface is not fully closed; not-implemented is empty.
python3 dev/spec.py check fails on a malformed file, a stale robot: list
or a count in this chapter that no longer matches the ledger; dev/spec.py gaps lists leaf clauses without a TP.
The suite
The suite directory holds 671 .robot files under
TP/NGSI-LD/{CommonBehaviours, ContextInformation, ContextSource, DistributedOperations, jsonldContext}. Most carry ETSI's own numbering
(002_01, D018_01); the rest are additions written here for normative
surface the official set leaves untested, either clause-numbered (566_01
for 5.6.6, 5510_01 for 5.5.10, 4233_01 for 4.23.3) or slotted into the
ETSI family they extend, and all following the same conventions and tagged
with their clause so the ledger picks them up. Every file expands to test
cases; one full run of a native cell is 1822 test cases:
| suite | test cases |
|---|---|
| CommonBehaviours | 65 |
| Consumption | 535 |
| EntityMap | 22 |
| Provision | 406 |
| Snapshot | 5 |
| Subscription | 156 |
| ContextSource | 145 |
| DistributedOperations | 134 |
| IOP | 286 |
| jsonldContext | 68 |
Behaviour Antares defines for itself, where CIM 009 is silent, does not go
in TP/ — that directory is run against other brokers in interoperability
campaigns, so every file in it has to assert a SHALL the spec text carries.
Those tests live in ngsi-ld-test-suite/AntaresSpecificTests/ instead, and
each says in its own documentation that it is an Antares decision rather
than a CIM 009 requirement, with the reason the behaviour exists.
The matrix
The same 1822 cases run once per native cell; wasm-file runs 1810 of
them, the twelve MQTT cases having no broker socket to run against in the
browser build. Every push gates on the quick preset; the full preset runs
twice a week, on v* tags and on dispatch, and is what the report page and
the badges render.
| cell | store | preset | what it adds |
|---|---|---|---|
| file | redb file store | quick, full | durability across restart |
| postgres | PostGIS | quick, full | the production current-state path |
| timescale | PostGIS + TimescaleDB | quick, full | hypertable history, columnstore |
| memory | in-RAM | full | the zero-dependency binary |
| postgres-nats | PostGIS + NATS JetStream | full | ten containers in split roles, rolled during the run |
| timescale-nats | as above on TimescaleDB | full | same, on the temporal-heavy backend |
| wasm-file | browser artifact over the file store | full | five Node shims driving the WebAssembly build; MQTT excluded, the browser has no broker socket |
A native cell passes at 1822/1822, wasm-file at 1810/1810.
dev/etsi-matrix-summary.py folds the per-cell results into one table and
lists every failure across the matrix; a release requires that list to be
empty.
Running a suite locally
One store mode per run, the one the change touches:
dev/etsi-local.sh # workspace tests + memory cell
STORE=timescale dev/etsi-local.sh # one cell, all suites
STORE=all dev/etsi-local.sh # the quick trio, serially
STORE=postgres STOP_ON_ERROR=1 dev/etsi-pipeline.sh # halt at the first red TP
STORE=file SUITES=Consumption,Subscription SKIP_BUILD=1 dev/etsi-pipeline.sh
dev/etsi-pipeline.sh knobs: STORE, STOP_ON_ERROR (default 1, CI
sets 0), SKIP_BUILD (reuse the local image), SUITES (comma list),
MQTT=1 (include the 058_* MQTT cases), KEEP_UP=1 (leave the stack
running), MEM_LIMIT_MB (per-broker peak-RSS gate), ROLES_SPLIT=1 ROLL_DURING_RUN=1 (reproduce a -nats cell), WASM=1 WASM_DOCKER=1 STORE=file (reproduce the wasm cell). Results land in results/$STORE
with Robot's own log.html per suite.
For a single clause during development, one broker without Docker is
enough. resources/variables.py carries the suite's own compose addresses
(scorpio1 for the broker, 172.28.0.18 for the notification and
context-source mocks), which the runners rewrite and a bare robot does
not, so the recipe overrides them itself:
cargo build -q -p antares-broker -j 2
ANTARES_HTTP_PORT=9377 ./target/debug/antares &
cd ngsi-ld-test-suite && robot --variable url:http://localhost:9377/ngsi-ld/v1 \
--variable temporal_api_url:http://localhost:9377/ngsi-ld/v1 \
--variable notification_server_host:127.0.0.1 \
--variable context_source_host:127.0.0.1 \
--variable context_server_host:127.0.0.1 \
--outputdir /tmp/robot-566 TP/NGSI-LD/ContextInformation/Provision/Entities/DeleteEntity/566_01.robot
Suite and spec defects
A red TP is proven against the clause text before any broker change. When
the text says the TP or the spec is wrong, the finding goes to
docs/upstream/etsi-raises.md as a ready-to-file issue, and the fork
carries the fix. The current list:
| # | target | finding |
|---|---|---|
| 1 | suite | D018_01 asserts 508 Loop Detected for an inclusive registration |
| 2 | suite | nine official _exc TPs create exclusive registrations that 4.3.6.3 forbids |
| 3 | suite | LdContextNotAvailable fixtures assert 503; V1.9.1 mandates 504 |
| 4 | spec | 5.3.4 SnapshotNotification: member naming conflict and a phantom snapshotReady |
| 5 | spec | Table 6.6.3.2-2 (Update Attributes 207): Data Type and Remarks conflict |
| 6 | spec | 5.7.4.4 / Table 5.2.21-1: lastN versus values-filter ordering is unspecified |
| 7 | spec | CIM 029 A.5.2.26 cites clause 5.15.4, which does not exist in CIM 009 V1.9.1 |
| 8 | openapi | v1.8.1 temporal GET operations declare the options parameter twice |
| 9 | suite | three temporal TPs assert Content-Range unit date-time; 6.3.10 mandates DateTime |
| 10 | spec | 4.5.19.0's unbounded period count contradicts the aggregation fixtures |
| 11 | spec | Annex B maps attributeCount/attributeDetails to bare terms |
Filing is manual; each entry carries the clause quotation and the proposed fix so it can be pasted into the ETSI tracker as is.
Spec-statement coverage
The ledger says implemented or partial per clause; this table says which
of those clauses no Robot test exercises at all. python3 dev/spec.py statements counts the SHALL statements in each leaf clause's text against
the TPs tagged with its number (or its operation's number) and the
code/test anchors its evidence cites. It adds no tests; it names where the
next ones belong.
310 leaf clauses carry 1661 SHALL statements; 117 of them have no Robot TP (338 SHALLs), 63 cite no code/test anchor.
The fifteen untested clauses with the most SHALL statements:
| clause | title | SHALL | robot TPs | code/test anchors |
|---|---|---|---|---|
| 6.18.3.2 | Resource methods › GET | 23 | 0 | 1 |
| 6.8.3.2 | Resource methods › GET | 17 | 0 | 0 |
| 6.5.3.1 | Resource methods › GET | 11 | 0 | 0 |
| 5.2.39 | EntityMap | 10 | 0 | 4 |
| 4.2.3 | Cross Domain Ontology | 8 | 0 | 1 |
| 5.2.35 | VocabProperty | 8 | 0 | 0 |
| 5.2.38 | JsonProperty | 8 | 0 | 0 |
| 7.2 | Notification behaviour | 8 | 0 | 2 |
| 5.2.36 | ListProperty | 7 | 0 | 0 |
| 5.2.5 | Property | 7 | 0 | 0 |
| 5.2.7 | GeoProperty | 7 | 0 | 0 |
| 6.3.8 | Notification behaviour | 7 | 0 | 6 |
| 5.2.37 | ListRelationship | 6 | 0 | 0 |
| 5.2.6 | Relationship | 6 | 0 | 0 |
| 5.3.4 | SnapshotNotification | 6 | 0 | 2 |
A SHALL count is a proxy: one sentence can carry several rules, and a clause's unit tests (the anchors column) may assert what no TP does. The counts are a snapshot to regenerate after ledger or suite changes, not a gate.
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.
| run | box | what it measures | cadence |
|---|---|---|---|
perf-weekly | ccx33 (8 dedicated vCPU, 32 GB), one hour | the 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-weekly | ccx53 (32 dedicated vCPU, 128 GB) + volume, one hour | the design targets on PostgreSQL, at scale 0.01 | Sunday |
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.
| table | script | method |
|---|---|---|
| startup and idle footprint | startup.sh | exec to the first 200 from /q/health, median of five, VmRSS right after, per store |
| throughput per shape | shapes.sh | 100 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 scaling | core-scale.sh | broker 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 knee | saturate.sh | open 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 profile | variance.py | the 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.
| store | 1 core | 2 cores | 4 cores | 8 cores | efficiency at 2 / 4 / 8 | cores used at 8 | peak threads at 8 |
|---|---|---|---|---|---|---|---|
| memory | 3 076 req/s | 6 050 req/s | 11 342 req/s | 12 554 req/s | 98 % / 92 % / 51 % | 7.64 | 16 |
| postgres, pool 20 | 2 097 req/s | 3 809 req/s | 7 093 req/s | 7 238 req/s | 91 % / 85 % / 43 % | 6.47 | 73 |
| postgres, pool 100 | 2 026 req/s | 3 665 req/s | 7 157 req/s | 7 837 req/s | 90 % / 88 % / 48 % | 7.03 | 85 |
The same rows as CPU spent per request (cores used over req/s), which is
what the efficiency column is measuring underneath:
| store | 1 core | 2 cores | 4 cores | 8 cores |
|---|---|---|---|---|
| memory | 0.31 ms | 0.32 ms | 0.34 ms | 0.61 ms |
| postgres, pool 20 | 0.47 ms | 0.49 ms | 0.50 ms | 0.89 ms |
| postgres, pool 100 | 0.48 ms | 0.50 ms | 0.51 ms | 0.90 ms |
The saturation knee, whole box, open model:
| store | shape | knee | p99 at the knee | first failing stage | cores used | peak threads |
|---|---|---|---|---|---|---|
| memory | query | 5 000 rps | 1.2 ms | none reached | 0.96 | 39 |
| memory | write | 5 000 rps | 0.6 ms | none reached | 0.47 | 62 |
| postgres, pool 20 | query | 5 000 rps | 3.0 ms | none reached | 2.17 | 77 |
| postgres, pool 20 | write | 1 000 rps | 2.9 ms | 1 500 rps | 1.79 | 4 038 |
| postgres, pool 100 | query | 5 000 rps | 2.6 ms | none reached | 2.18 | 64 |
| postgres, pool 100 | write | 1 000 rps | 2.8 ms | 1 500 rps | 1.80 | 4 038 |
Throughput per shape at 64, 256 and 1 024 concurrent clients, whole box:
| store | shape | c64 | c256 | c1024 |
|---|---|---|---|---|
| memory | query | 19 272 req/s, p99 7.7 ms | 21 254 req/s, p99 36.6 ms | 21 566 req/s, p99 123.4 ms |
| memory | retrieve | 39 766 req/s, p99 3.8 ms | 43 805 req/s, p99 19.7 ms | 45 425 req/s, p99 68.1 ms |
| postgres, pool 20 | query | 12 418 req/s, p99 7.3 ms | 11 948 req/s, p99 23.2 ms | 11 448 req/s, p99 93.2 ms |
| postgres, pool 20 | retrieve | 12 432 req/s, p99 5.9 ms | 12 233 req/s, p99 22.3 ms | 11 766 req/s, p99 90.0 ms |
| postgres, pool 100 | query | 12 426 req/s, p99 7.8 ms | 11 823 req/s, p99 28.5 ms | 10 292 req/s, p99 104.0 ms |
| postgres, pool 100 | retrieve | 13 974 req/s, p99 6.0 ms | 14 144 req/s, p99 20.4 ms | 13 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_CONNECTIONSplus 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:
| store | 1 core | 2 cores | 4 cores | 8 cores | efficiency at 2 / 4 / 8 | cores used at 8 | peak threads at 8 |
|---|---|---|---|---|---|---|---|
| memory | 13 635 req/s | 19 498 req/s | 11 573 req/s | 12 538 req/s | 71 % / 21 % / 11 % | 6.78 | 44 |
| file | 3 870 req/s | 4 492 req/s | 3 744 req/s | 3 617 req/s | 58 % / 24 % / 12 % | 0.94 | 17 |
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, distinctdatasetIds) 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, tenantsquietandloud. The quiet tenant's GET p99 is measured alone and under a write flood onloud(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, givenANTARES_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 thetenantmember (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.18Via); 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 (registrationoperationsincludesfederationOps) 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:
| stage | script | at scale 1.0 |
|---|---|---|
| entities | gen.py streaming into dev/bulk-load.sh (one COPY stream) | 100,000,000 over 10,000 tenants, five attributes and a location each |
| subscriptions | api-load.py subscriptions | 100,000, one per tenant round-robin, HTTP to the sink, every tenth over MQTT |
| registrations | api-load.py registrations | 100,000, one id pattern each, endpoint at the sink |
| resident set | rss.sh | broker 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 load | shapes.sh, saturate.sh | throughput per tenant, the knee |
| subscriptions firing | fire.sh | update + 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 delivery | fire.sh → fire-classes.md | the 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 queries | fed.sh | five 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 memory | rss.sh → rss.csv, rss.md | 1 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 |
pdf.py → report.pdf | the 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.
| store | ready in (median of 5) | RSS after start |
|---|---|---|
| memory | 39 ms | 18 MiB |
| file | 51 ms | 19 MiB |
| postgres | 358 ms | 59 MiB |
| store | shape | concurrency | req/s | p99 |
|---|---|---|---|---|
| memory | query | c50 | 7 431 | 19.97 ms |
| memory | query | c200 | 7 821 | 70.19 ms |
| memory | retrieve | c50 | 29 913 | 7.47 ms |
| postgres | query | c50 | 912 | 61.37 ms |
| postgres | query | c200 | 933 | 207.85 ms |
| postgres | retrieve | c50 | 1 841 | 29.08 ms |
| store | shape | knee (rps held) | p99 at knee | first failing stage | broker cores | peak threads |
|---|---|---|---|---|---|---|
| postgres | query | 3 000 | 10.5 ms | 3 500 | 2.56 | 13 |
| postgres | write | 1 000 | 5.2 ms | 1 500 | 1.64 | 13 |
Subscriptions firing, over 10,000 subscriptions on 101 tenants:
| rate (rps) | due | delivered | delivered % | POSTs/s | dropped by broker | dead letters | PATCH p99 | broker cores | host busy |
|---|---|---|---|---|---|---|---|---|---|
| 100 | 96 492 | 96 492 | 100.0 | 1 637.1 | 0 | 0 | 25.2 ms | 1.6 | 3.5 |
| 200 | 193 406 | 193 406 | 100.0 | 3 250.8 | 0 | 0 | 28.1 ms | 3.0 | 6.0 |
| 500 | 481 526 | 40 330 | 8.4 | 561.3 | 26 398 | 0 | 1 184.1 ms | 4.5 | 7.1 |
Against run 33835261405, the same shape on the same server type before the
per-drain @context memo landed:
| rate (rps) | delivered % | POSTs/s | dropped | PATCH p99 | quiet after |
|---|---|---|---|---|---|
| 200 before | 99.9 | 2 742.7 | 14 | 69.6 ms | 4 s |
| 200 after | 100.0 | 3 250.8 | 0 | 28.1 ms | 0 s |
| 500 before | 12.7 | 847.9 | 25 359 | 645.7 ms | 4 s |
| 500 after | 8.4 | 561.3 | 26 398 | 1 184.1 ms | 2 s |
Federated queries, over 10,000 registrations, every source the sink:
| rate (rps) | queries | failed (conn/4xx/5xx) | with a source warning | GET p99 | source calls | calls per query | broker cores | host busy |
|---|---|---|---|---|---|---|---|---|
| 50 | 1 487 | 0 (0/0/0) | 0 | 1 821.5 ms | 50 675 | 34.08 | 1.8 | 5.9 |
| 100 | 2 346 | 0 (0/0/0) | 0 | 12 697.0 ms | 80 502 | 34.31 | 2.2 | 7.9 |
| 200 | 3 615 | 0 (0/0/0) | 0 | 33 731.2 ms | 130 096 | 35.99 | 2.2 | 7.9 |
| 500 | 1 928 | 0 (0/0/0) | 0 | 53 754.9 ms | 159 943 | 82.96 | 1.9 | 8.0 |
Resident set and CPU over the whole run, 1 085 samples about 1.3 s apart:
| measure | value | ceiling |
|---|---|---|
| broker RSS peak | 3 181 MiB | no ceiling set |
| Postgres RSS peak | 11.86 GiB | no ceiling set |
| broker CPU peak / mean | 5.3 / 1.1 cores | of 8 |
| Postgres CPU peak / mean | 6.5 / 2.1 cores | of 8 |
| host busy peak / mean | 8.0 / 4.7 cores | of 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=Vehiclequery reaches 34 of them — the three Vehicle-typed classes of the eight incsr.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-Warningover 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 theshapes,saturateandstartupstages 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.mddisagrees withrss.csvfor 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 tenants | matches/s | broker cores | changes dropped |
|---|---|---|---|
| 60 | 2 201 | 0.91 | 0 |
| 120 | 6 056 | 1.24 | 0 |
| 240 | 11 781 | 1.28 | 0 |
| 480 | 25 547 | 1.89 | 0 |
| 960 | 44 818 | 1.90 | 1 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/s | achieved | matches/s | broker cores | changes dropped |
|---|---|---|---|---|
| 2 000 | 2 000 | 12 074 | 1.72 | 0 |
| 3 000 | 3 001 | 17 998 | 2.18 | 0 |
| 4 000 | 4 004 | 23 948 | 2.48 | 0 |
| 6 000 | 5 131 | 30 683 | 2.74 | 0 |
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/s | achieved | matches/s | broker cores | changes dropped |
|---|---|---|---|---|
| 500 | 500 | 3 051 | 0.75 | 0 |
| 1 000 | 1 000 | 6 062 | 1.25 | 0 |
| 2 000 | 1 671 | 10 108 | 1.39 | 0 |
| 4 000 | 1 140 | 6 927 | 1.26 | 0 |
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
- Sign in at https://console.hetzner.cloud/.
- 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 → Billing → Alerts). - In that project open Security → API tokens → Generate API token.
Name it
github-actions, permission Read & Write (the workflow creates and deletes servers and volumes and reads the price list). - 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.
- GitHub → Settings (your account) → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token.
- Resource owner: the account that owns this repository. Repository access: Only select repositories → this repository.
- Repository permissions: Administration: Read and write. Nothing else.
- Expiration: one year is the maximum; put the renewal date in your calendar, the workflow fails with HTTP 401 when it lapses.
- 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 → Settings → Secrets and variables → Actions → New repository secret, twice:
| Name | Value |
|---|---|
HCLOUD_TOKEN | the Hetzner token from step 1 |
RUNNER_PAT | the 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-weekly → Run 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-weekly → Run 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:
| answer | façade | the request it wraps | the round trip |
|---|---|---|---|
| 100 five-attribute Entities | 265-297 µs | 199-226 µs | 66-71 µs |
| nothing matched | 112-127 µs | 107-157 µs | under 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.
Coverage
Two jobs measure which broker code no test executes; both publish rather than gate, except for the unit floor.
| job | what runs under instrumentation | where the numbers land |
|---|---|---|
strict → Coverage floor (daily) | the workspace unit and integration tests, with live PostGIS, MQTT and NATS so the integration tests count instead of skipping | Two floors, never one blended number: the unit surface (--lib --bins, less two things no in-crate test can execute — the test-kit-gated store contract kit, and the PostgreSQL driver, which needs a live database and a multi-thread runtime where an in-crate #[tokio::test] is current-thread; both are measured by the API surface instead) at 82 % lines / 79 % functions and the API surface (the integration binaries of antares-api, antares-broker, antares-bus, antares-sql) at 78 % lines / 77 % functions. Gating each test source separately is what stops one rising while the other falls. Floors only ratchet up after a green run — never lowered to make a red one pass. The run also publishes a coverage-map artifact — the two summaries and an lcov file per surface — so the uncovered lines can be read by name; the artifact's totals count every object left in the coverage map and are not the gated figures. |
etsi-coverage (weekly, per store) | the workspace tests and the whole Robot suite against an instrumented broker, once per store mode (memory, file, postgres, timescale), then merged | /reports/coverage/ with the per-store and merged HTML, the badge on the README, and the step summary's lcov --list table |
A zero-count line in the merged view means no Rust test and no ETSI test
procedure in any store mode ever ran it. dev/coverage-attribution.py
splits the merged profile into lines only the unit tests reach, lines only
the Robot suite reaches, and lines both reach, so a clause whose only
witness is a unit test shows up as such (the spec-statement
table is the clause-level view
of the same question).
The merged table's function columns count source functions, not compiled
ones. An lcov tracefile names functions mangled, so one generic appears once
per instantiation and one closure once per test binary that linked it — and a
binary that never linked an instantiation records it as a miss. Counted that
way the workspace has about twice as many functions as it has, half of them
permanently uncovered, and the merged figure cannot be compared with the
floors above. The table therefore keys a function by the start line of its
FN record, which reproduces what cargo llvm-cov --summary-only reports to
within a point. dev/coverage-attribution.py --selftest pins that, and
workspace.yml runs it.
Reading the uncovered lines
Uncovered code falls into two kinds, and they call for different work:
- Reachable but untested: a request shape no test sends yet (an optional URL parameter, a rarely combined pair of options, a tenant header on an admin route). The fix is a test, usually a Robot TP with the clause tag so the ledger picks it up.
- Needs fault injection: the error arms behind a store that fails
mid-transaction, a peer that answers with a truncated body, a
notification endpoint that hangs. No request from the outside reaches
them on a healthy stack; they need a failing dependency (the
nats_e2eandmqtt_notifytests do this for their subsystems) or a mock that misbehaves (federationtests use one).
The weekly ratchet fails the job on a drop of more than one point against the published run, so a change that removes a test's reach is visible before the badge moves.
Reproducing locally
dev/etsi-coverage.sh memory # one store mode, same script CI runs
cargo llvm-cov --workspace --html # the unit half only, target/llvm-cov/html
Both need cargo-llvm-cov and the llvm-tools-preview component.
Shared crates
The broker is built from crates a gateway, a PEP or a proxy can depend on without pulling in the broker: same parsing, same validation, same query semantics. Scorpio and Stellio each keep one query AST with two evaluation backends so the query path and the notification path cannot drift; here a gateway is the third consumer of that same AST.
| crate | what it gives an embedder | example |
|---|---|---|
antares-model | NgsiError (Table 6.3.2-1 problem details), TenantId/EntityId parse-don't-validate identifiers, dt_key DateTime ordering | — |
antares-ql | parse_q → QNode (Serialize, Clone, Display renders back to q=), eval::eval_q in-memory evaluation, sql::compile_q bind-parameter jsonpath lowering, the bounded regex cache | cargo run -p antares-ql --example gateway_filter |
antares-jsonld | Loader (per-instance @context cache over the caller's own HTTP client via Loader::with_client), expand_entity — the broker's request validation, usable at the edge | cargo run -p antares-jsonld --example gateway_expand |
antares-matcher | subscription matching against an in-memory entity: entity selector (5.2.33), q/scopeQ/geoQ conditions, activity and throttling | cargo run -p antares-matcher --example would_notify |
antares-store | the two storage driver traits, TemporalEvent, NoTemporal | — |
The backends behind those traits live one folder each under
crates/antares-sql/src/store/; its README.md is the procedure for adding
one.
CI builds and tests each of them standalone and fails on any dependency
path back into the broker, the API crate or a storage backend
(shared-crates job in workspace.yml).
The same job checks that antares-api itself names no storage backend:
a consumer composes its two drivers and hands them to
AppState::with_drivers; the built-in memory and file store behind
AppState::new is a dev-dependency the crate's own tests enable.
antares-api — what a host binary compiles against
antares-api is not one of the five: it is the broker's HTTP surface, and
the only crates that depend on it are hosts that run that surface —
antares-broker, antares-wasm and examples/plugin-example. Its API is
small on purpose, because everything a host needs it reaches through the
router or through AppState.
AppState::call is how a surface reaches the NGSI-LD API without a socket:
it serves one request through this broker's own router, carrying the
caller's NGSILD-Tenant, NGSILD-Snapshot, Link and policy subject
headers into it. There is no second data path — a façade for another
standard is a translation in front of the same handlers, so negotiation,
the bounds wall, tenancy, the policy seam, history and notifications apply
to its callers exactly as they do to an NGSI-LD client.
At the crate root: router(state) builds the NGSI-LD router,
ops_router(state) the operational one behind Admin::PATHS, and
wire(&mut state) installs the notification pipeline that a read-only host
never has to pay for. spawn and background_tasks() are the crate's
task bookkeeping, Admin names the operational paths, ApiSurface is the
trait another standard's surface implements, and GIT_HASH is what
/q/health reports. AppState and TemporalRecord come from state;
DeliveryPolicy, page_sink and scope_matches are re-exported from the
crates that own them so a host names one dependency instead of three.
Eleven modules stay public because a host or a surface reaches into them:
| module | what a host uses it for |
|---|---|
bounds | every request limit in one place — the MAX_* constants and the env-read statics behind them, re-exporting the ones antares-ql and antares-jsonld own — plus LimitStats::snapshot for /q/health |
conformance | prefer_version_layer, the middleware that answers the version Prefer |
egress | Egress, the notification egress policy and its per-registration record |
geo | antares_ql::geo re-exported, so a surface parses a geo query (GeoQuery::from_params) the way the broker does |
history | drain_errors, the count of post-response history writes a driver failed |
mirror | Mirror, DocMirror, SubMirror and the Change tuple: the change pipeline a bus driver feeds |
negotiate | ApiError/ApiResult, tenant_from, CleanParams, QUERY_PARAMS — what an ApiSurface needs to answer like the broker |
policy | the policy seam (ADR-0020): PolicyEngine, Subject, Operation, Decision, Filter, NotifyDecision, the built-in AllowAll, and the fail-closed decide/pre_notify an engine is called through |
notify | seed_mirror, process_change, record_temporal_change, interval_tick: the pipeline steps a host drives |
eval | antares_ql::eval re-exported: eval_q, in-memory q evaluation over a stored document |
state | AppState, its builders (with_drivers, with_store, with_sink, with_surface, with_surfaces, with_policy) and call, the in-process handle a façade surface drives the NGSI-LD router through |
Everything else is pub(crate). There is no ratchet on this: once an item
is crate-visible the compiler's dead-code lint is the gate, and it fires
the moment an item loses its last caller. A helper that only a test
reaches is pub behind the test-kit feature, so a release build does not
carry it and the lint keeps seeing the crate as the shipped binary sees it.
Stability
All five are workspace-shared, not published: publish = false, path
dependencies, one version for the whole workspace. Publishing (crates.io or
a git tag consumers pin) is deferred until a consumer outside this
repository exists — cutting a public API before that would freeze surfaces
that are still moving with the conformance work. When that happens the
public surface of each crate is reviewed like an API change and the crate
gets its own semver line; until then breaking changes inside the workspace
are ordinary commits.
The PEP boundary
Authentication, rate limiting and request transforms stay in the gateway in
front of the broker, and the broker ships no policy engine (SECURITY.md
states the same boundary). It does carry a policy seam — one trait, one
built-in allow-all engine, every other engine an addon crate outside the
workspace (ADR-0020) — for the three decisions a gateway cannot make from
outside: narrowing the query the store runs, filtering one subscription's
notification, and filtering a federated result before it is rendered. These crates are how a gateway does that job with
broker-identical semantics — rewrite the incoming q= with an authorization
predicate (gateway_filter), refuse a payload the broker would refuse
before it costs a hop (gateway_expand), or answer "would this change
notify subscription X" without a broker (would_notify). Stellio's
entity-level authorization (a permission CTE injected into the main query)
is the reference for doing the same on the SQL side with antares-ql::sql.
API reference
Three generated references are published next to this book:
- NGSI-LD HTTP API — ETSI's own OpenAPI document (
docs/openapi/ngsi-ld-api.yaml, vendored at a pinned tag), rendered with ReDoc: https://antaresbroker.joinedcontext.com/openapi/ngsi-ld.html - Operational API — the broker's own OpenAPI document for every route outside
/ngsi-ld/v1(docs/openapi/antares-admin.yaml:/q/and the peer wire/ex/v1/), rendered the same way: https://antaresbroker.joinedcontext.com/openapi/admin.html - Rust crates — rustdoc for every workspace crate, the entry point for the shared crates: https://antaresbroker.joinedcontext.com/api/
The broker serves the NGSI-LD API under /ngsi-ld/v1; the prose for the routes under /q/ is the Admin API chapter. A unit test in antares-api holds the operational document to the router: every path and method it lists is mounted, and every mounted one is listed.
Extending Antares
Antares grows in three layers. Each one has a fixed seam in the code; an extension attaches to a seam, it never adds one. The rule behind every seam: a boundary is crossed once per request or once per drained batch, never once per attribute or per matched subscription. That keeps the cost of an extension at one dynamic call per request instead of one marshalling round per value.
| layer | what it is | how it is chosen |
|---|---|---|
| Component drivers | storage, temporal history, notification sinks, the HTTP surfaces beside the API root | a name in the environment at startup |
| Lifecycle hooks | five named phases in the request lifecycle | compiled in behind a cargo feature; settings are data |
| Dynamic tier | loadable or sandboxed code | not built; the two driver traits are the only coupling it would need |
Cargo features
Every optional capability is one cargo feature plus a registration in
antares-broker. Removing a feature never touches a core crate.
| crate | feature | default | what it compiles |
|---|---|---|---|
antares-api | mqtt | on | the MQTT notification binding (antares-notifier/mqtt) |
antares-api | test-kit, postgres | off | the in-crate test harness (AppState::new over the built-in store) and, with postgres, the Postgres-backed integration tests; both are reached only through this crate's own dev-dependency, never by a binary |
antares-api | sonic | off | sonic-rs on the batch-ingest hot path (x86_64 / aarch64); serde_json is the compiled fallback |
antares-sql | postgres | on | the Postgres and TimescaleDB backend (sqlx); off in the browser build |
antares-notifier | mqtt | on | rumqttc + rustls |
antares-broker | console | off | tokio-console support; only arms under RUSTFLAGS="--cfg tokio_unstable" |
antares-broker | mqtt | on | forwards antares-api/mqtt; off, MQTT endpoints fail at subscription creation. Measured on one release build: 27.2 → 26.0 MB binary, 58.1 → 55.4 MiB idle RSS |
antares-broker | plugin-example | off | the reference plugin (examples/plugin-example): one more backend, surface, notification binding, policy engine and façade route, all from outside crates/. Never in a shipped build |
The browser artifact (antares-wasm) is the one build with postgres
off: it drives the same router over the memory store and the OPFS shadow.
No other capability gets a flag: the native binary serves every store
mode behind one ANTARES_STORE value, so postgres stays compiled in
(the browser build is the deployment that sheds it), and the NATS bus,
the telemetry stack and the admin routes are runtime switches that
allocate nothing until enabled. A flag earns its place with a measured
saving, and cargo build -p antares-broker --no-default-features is
checked in CI so the smallest build keeps compiling.
Layer 1: component drivers
The core knows two storage traits, CurrentStateDriver and
TemporalDriver (crates/antares-store/src/lib.rs), and nothing about
redb, sqlx or TimescaleDB. Arc<dyn Trait> is the plugin interface.
The Storage drivers chapter describes what each backend
persists; this section describes how one is selected and added.
The registry
build_drivers in crates/antares-broker/src/main.rs is the registry:
a match from the configured names to constructors. The shelf it selects
from is a list of NAMES, not an enumeration — store_shelf() chains the
built-in StoreMode values with whatever backends were compiled in from
outside the workspace, so adding one never edits a core crate.
ANTARES_STOREpicks the current-state backend throughbuild_store, one arm per backend.ANTARES_TEMPORALpicks the history backend. Absent, or the same name as the store, means one shared instance serves both seams.noneinstallsNoTemporal: the recorder produces nothing and temporal reads answerOperationNotSupported422. Any other backend name builds a second store used only through its temporal half.- Every Postgres half, primary or temporal-only, gets its own maintenance loop (partitions, retention).
Both traits carry the same two lifecycle methods. version_info() answers
what the driver runs on — engine, server version, extensions — from state
captured at startup, never by querying on the call: /q/health is polled,
and it prints the answer as storeInfo and temporalInfo. close() is the
drain: the shutdown path closes both seams, because a deployment whose
ANTARES_TEMPORAL names a second backend has two pools to close. One
instance can serve both seams, so it is closed through each of them and
close() must be idempotent.
An unknown name is fatal at startup and lists what the binary was built with:
ANTARES_TEMPORAL: unknown backend "mongo"; built with memory|file|postgres|timescale (temporal also: none)
/q/health reports both choices as store and temporal.
Notification sinks
NotificationSink (crates/antares-notifier/src/lib.rs) is the delivery
seam. A sink declares the URI schemes it serves, validates its own endpoints
at subscription creation (parse_endpoint), and delivers one prepared
notification (deliver). SinkRegistry keys sinks by scheme and is the only
way a binding is chosen: a subscription naming a scheme no sink serves is
rejected when it is created, with BadRequestData (400), and a stored row
that names one is dropped rather than delivered through some other binding.
HTTP is always registered; MQTT registers behind the mqtt feature. Add one
with AppState::with_sink. A WebSocket binding would be a sink registration
plus a router merge, with no change to a core crate.
The egress policy — allowlist, private-range and metadata-address deny,
per-destination circuit breaker — runs in the caller before deliver, so a
sink cannot step around the verdict on the endpoint as written. What that
check cannot do is judge a name it does not resolve: under the default
ANTARES_EGRESS_ALLOW_PRIVATE=true an endpoint host given as a name passes
it, and the addresses the name stands for are judged where they are dialled.
A sink that opens its own socket therefore owes that filter —
EgressPolicy::ip_is_metadata and ip_is_private over the resolved answer,
before connecting, as the MQTT binding does in checked_addr and every
reqwest client does through PolicyResolver. A sink that opens no socket
says so by returning false from network(); every binding shipped here
returns the default true, and a unit test holds that.
API surfaces
ApiSurface (crates/antares-api/src/surface.rs) is the routing seam: a
name, a prefix, an axum::Router<AppState> and a version_info() object
that /q/health prints under surfaces. The broker's own operational
routes are one such surface, admin, mounted at /q.
A surface may only claim a reserved prefix — /q, /x, or a path below
/x. The NGSI-LD API root belongs to the spec: a surface that could shadow
a spec resource would make conformance a function of deployment
configuration, so AppState::with_surface refuses any other prefix, and
refuses a prefix that overlaps one already mounted rather than leaving the
winner to route-matching order. Both refusals are startup errors.
ANTARES_API_SURFACES names the selection, comma-separated; absent means
admin. An unknown name is fatal at startup and lists the shelf, the same
way an unknown backend does. A selection that leaves admin out serves no
/q at all, health and readiness included, so an empty selection is
refused outright.
Adding one is a struct implementing the trait plus an entry in
SURFACE_SHELF (crates/antares-broker/src/main.rs); no core crate
changes.
Policy engines
PolicyEngine (crates/antares-api/src/policy.rs) is the authorization
seam, and it is the narrowest of the five on purpose. Authentication, rate
limiting and request transforms belong to the gateway in front of the
broker (SECURITY.md says so, and ADR-0020 records why); what a gateway
cannot do from outside is narrow the query the store runs, project one
subscription's notification, and filter a federated result before it is
rendered. Those three are what an engine is for.
An engine answers two questions. decide is asked once per operation and
returns Allow, Deny(reason) — a 403 carrying
urn:antares:error:AccessDenied, this broker's own URN because Table
6.3.2-1 names no access-denied error — or Filter, the narrowing the
answer is built under. pre_notify is asked once per notification, just
before it is sent, and returns Deliver, Drop (no delivery attempt at
all: timesSent and lastNotification do not move) or a Filter
projecting the entities it carries.
The Operation an engine is given names the clause, the ids, types and
attributes the request selects, its q, scopeQ and geo query, and a
write's body — every name already expanded, so an engine never has to
carry a JSON-LD context. A batch is one operation carrying every Entity id
its array names, asked once before any item is written, so a rule written
over ids reaches the same verdict whichever surface the caller used. The
same holds for the resources that are not Entities: a Subscription, a
Context Source Registration, a Snapshot, an EntityMap or a stored
@context is named by its own id, whether the request carries it in the
path or chose it in the body of a create. The Subject is the tenant plus the request
headers ANTARES_POLICY_SUBJECT_HEADERS names, and it stays in this
process: the seam strips those headers from every forwarded request and
keeps them out of notifications, logs and dead letters.
A Filter is only honoured where the broker can serve less than it was
asked for: retrieve and query Entities, the two temporal reads, and the
EntityMap creations behind them (policy::FILTERABLE). Return one on any
other operation — a create, a delete, a subscription, a registration — and
the seam answers 403 rather than dropping it; decide those with Allow or
Deny.
A Filter may only narrow. Its q and scopeQ are conjoined into the
condition the store already had — a scopeQ by the 4.19 distribution, so a
caller filtering by Scope of its own is served the Scopes both select and
never a Scope the engine ruled out — and pick/omit project members out
of every document the answer carries, the Entities join= reaches over a
Relationship included; there is no member it can add and no row it can
widen the answer to. A pick/omit name is read in the core @context,
never in the caller's, so a request cannot rebind the term a rule names
and step out from under it; write the rule as an IRI and it is used as written, write it
as a short name and it means what the default @context says. Set
restricted and a narrowed read answers Antares-Results-Restricted: true,
so an operator can tell a short answer from an empty one. Three limits are the seam's, not the engine's: an
operation over everything the tenant holds — delete-by-type, purge, the
whole-tenant snapshot clauses — is Allow or Deny and never a Filter,
because doing it to less than it says is a data-loss bug; a Filter on a
notification carrying a q is a Drop, because the broker cannot re-run a
query there and must not report a narrowing it did not apply; and an engine
that panics or overruns ANTARES_POLICY_TIMEOUT_MS is a Deny. Fail
closed is the whole posture: a deployment that wires in a broken engine
loses service, never its access rules.
ANTARES_POLICY names the engine, allow-all by default. That built-in
engine decides nothing, and it is the one the shipped image and every CI
gate run — conformance is asserted against it, never against an addon.
Selecting it attaches no engine at all, so a broker on the default pays a
branch per operation instead of a boxed decision and a timer; /q/health
still reports allow-all, because that is the decision it takes.
An unknown name is fatal at startup and lists the shelf the binary was
built with, so a typo cannot quietly serve every request wide open.
That an addon stays out of a release is a gate, not a convention:
dev/check-no-addon.sh runs on every ci, before the release binary
build and before the image build, and refuses a dependency tree naming a
crate under examples/ or any engine or façade crate; k8s-smoke then
asserts the deployed image reports allow-all and no surface beyond
admin.
Adding one is a struct implementing the trait plus an entry in
POLICY_SHELF (crates/antares-broker/src/main.rs); no core crate
changes. Hold it to antares_api::policy::run_policy_contract (behind the
test-kit feature) from the crate's own tests: it asserts through the seam
the three things an engine can get wrong — it stops answering, it hands
back an answer the seam has to override, or it puts a member into an answer
that was not there.
examples/plugin-example/src/policy.rs is the worked one. Its rules are a
JSON document named by ANTARES_POLICY_RULES, one entry per tenant:
{
"acme": {
"denyTypes": ["Secret"],
"omit": ["price"],
"q": "speed<100"
}
}
A tenant with no entry is unrestricted. denyTypes refuses any operation
naming those Entity types — the request's own selector and a write's body
type, matched against the short name or the expanded IRI; omit drops
those Attributes from every document served and every notification sent;
q is conjoined into every query that tenant runs. Rules are read once, at
startup, and rules the engine cannot read make it refuse everything rather
than allow everything. examples/plugin-example/tests/policy.rs runs the
contract and then answers real requests through the router;
ngsi-ld-test-suite/AntaresSpecificTests/policy_engine.robot does the same
against a running broker, and .github/workflows/examples.yml runs the
conformance suite on allow-all and that folder on the engine, in one job.
Façades for another standard
A SensorThings, OGC API, WFS or OData surface is an ApiSurface like any
other, with one extra rule: it answers by driving this broker's NGSI-LD API
in process, through AppState::call, and never by reaching the store. That
is the whole design. Every façade request becomes an NGSI-LD request, so
negotiation, the bounds wall, tenancy, the policy seam, history and
notifications happen once, in the code that already implements CIM 009 —
and there is no second data path to keep in step with the first.
async fn things(State(st): State<AppState>, headers: HeaderMap) -> Response {
let req = Request::get("/ngsi-ld/v1/entities?type=Device&options=keyValues")
.body(Body::empty())?;
let resp = st.call(&headers, req).await; // the broker answers
// ...translate the answer into the standard's own shape
}
examples/plugin-example/src/surface.rs is the worked one, and
examples/plugin-example/tests/facade.rs is the contract it is held to.
The tenant rule. A façade maps its own notion of a caller — a path
segment, a subdomain, a header its standard defines — to NGSILD-Tenant,
and to nothing else. AppState::call copies NGSILD-Tenant,
NGSILD-Snapshot, Link and the policy subject headers from the outer
request when the inner one does not set them, so a façade that has no
tenant notion of its own inherits the caller's and cannot get it wrong. A
façade that DOES map its own sets the header on the inner request, and the
copy stands aside. What a façade must never do is derive a tenant from
anything the broker did not validate: NGSILD-Tenant is checked once, in
negotiate, and that check is the whole of 6.3.14.
The depth rule. A façade may call the broker, and a façade may be what
the broker's route reaches, so a translation can legitimately sit several
frames deep. What it may not do is close the circle: a route that translates
into a request its own surface serves would call itself for as long as the
stack lasts. AppState::call counts the frames one task is already inside
and answers the ninth with InternalError
(maxInProcessCallDepth in /q/health). The count is per task, so work a
handler spawns starts a new chain — a notification is not inside the request
that caused it.
The representation to ask for. Most of a mapping is already an option on the NGSI-LD request, and asking for the right one is the difference between a translation and a rewrite:
| the façade wants | ask for | clause |
|---|---|---|
| values without the NGSI-LD envelope | options=keyValues | 4.5.4 |
| the envelope, minus what is inferable | options=concise | 4.5.2.3 ff. |
| GeoJSON Features | Accept: application/geo+json | 6.3.15 |
| history rather than current state | the /temporal/entities resources | 6.18-6.22 |
| only some Attributes | attrs= | 6.4.3.2 |
| a count of what matched | count=true → NGSILD-Results-Count | 6.3.13 |
The error table. A façade keeps the broker's status — it is the verdict on the operation, and the façade has no better one — and re-renders the body in its own vocabulary. A caller of a SensorThings API is not expecting Table 6.3.2-1 ProblemDetails, and a façade that passed them through would be telling its clients to parse a second error model. What each type means to a translation:
| NGSI-LD error (Table 6.3.2-1) | status | what the façade's caller did |
|---|---|---|
InvalidRequest, BadRequestData | 400 | sent something the broker will not accept — a bad filter, a malformed body |
TooComplexQuery, TooManyResults | 403 | asked for more than a cap allows; the façade's own paging is what avoids it |
ResourceNotFound, NonexistentTenant | 404 | named an entity, or a tenant, that is not there |
AlreadyExists, Conflict | 409 | created something twice, or a registration that clashes |
OperationNotSupported | 422 | asked for an operation the broker does not offer on that resource |
NoMultiTenantSupport | 501 | named a tenant a single-tenant deployment cannot serve |
InternalError | 500 | nothing; the broker failed |
LdContextNotAvailable | 504 | named an @context the broker could not fetch |
6.3.4's own statuses (411, 413, 414, 415, 406) carry no body at all, so a façade fills the message from the reason phrase rather than from a payload that is not there.
The paging map. The broker pages with limit/offset and answers with
RFC 8288 Link headers, rel="next" and rel="prev", carrying the
response media type; count=true adds NGSILD-Results-Count (6.3.10,
6.3.13). Each standard renders the same two facts — where the next page is,
and how many there are — its own way:
| standard | next page | total |
|---|---|---|
| SensorThings 1.1 | @iot.nextLink, an absolute URL | @iot.count with $count=true |
| OData | $skip/$top on the next request | @odata.count |
| OGC API — Features | a rel="next" entry in links | numberMatched, numberReturned |
The façade rewrites the broker's Link into its own form rather than
re-deriving the offsets: the broker already knows whether there IS a next
page, and a façade that recomputed it would page differently from the API
it fronts.
The write rule. A façade writes through the NGSI-LD write resources —
POST /entities, the /entityOperations/* batch endpoints, the temporal
resources — and never through AppState::store. A store call skips
expansion, validation, the policy seam, the history recording layer and the
change hook that feeds subscriptions: an Entity written that way is in the
database, absent from every notification, and absent from history. The
batch endpoints are the right target for a façade that receives many
records at once (an STA POST /Observations array), because one batch
request is one pass through that machinery instead of N.
The four mappings
These are recorded, not built. Each becomes its own crate behind its own off-by-default feature the day a deployment names the client that needs it; until then the seam above is the deliverable, and this is the design work already done so that day is a translation exercise rather than a research one. What each mapping shows is how little of it is new code: almost every line is a query parameter the NGSI-LD API already has.
SensorThings 1.1 (OGC 18-088). The entity model maps onto Smart Data
Models types: Thing and Sensor are Entities (Device),
ObservedProperty is the Attribute name, Datastream is the pairing of
the two — a Device and one of its Attributes — and Location and
FeatureOfInterest are the Entity's location GeoProperty. Observation
is the one that decides the shape of the whole façade: an Observation is an
Attribute instance at a point in time, which is history, not current state.
So Observations?$filter=phenomenonTime ge <t> is
GET /temporal/entities?timerel=after&timeAt=<t>, and the temporal
resources (6.18-6.22) carry the bulk of the API rather than
/entities. The query options are near-identities: $filter is a subset
of q, $expand is join=inline, $select is attrs, and $top/$skip
are limit/offset. $orderby is the one that needs care — NGSI-LD
orders temporal results by observedAt, so an $orderby on anything else
is either refused or paid for in the façade.
OGC API — Features Core (OGC 17-069r4). The nearest to free of the
four. collections is GET /types (6.25), a collection's items is
GET /entities?type=<type> with Accept: application/geo+json, which the
broker already renders as a FeatureCollection (6.3.15). bbox is
georel=within with a Polygon geometry; datetime is the temporal API
again, timerel=between for an interval and timerel=after/before for
an open one. limit is limit, numberMatched comes from
count=true → NGSILD-Results-Count (6.3.13), and numberReturned is the
length of what was answered. Only CRS84 is offered, which is what
IETF RFC 7946 fixes and what the broker stores.
WFS 2.0 (OGC 09-025r2). An XML rendering over the OGC API Features
mapping above, for a client that cannot move — never a second mapping.
GetCapabilities is built from /types, DescribeFeatureType from
/types/{type} (6.26), and GetFeature is the Features items request
with the answer serialized as GML instead of GeoJSON. A Filter element is
translated into the same q subset the STA $filter uses. Nothing in a
WFS façade may reach the broker except through the Features translation:
two mappings for one data model is how they drift.
OData 4.01. There is no separate mapping. SensorThings' query options
ARE an OData subset, so the parser that serves $filter, $orderby,
$select, $top, $skip and $count for STA is the same one an OData
façade uses; what differs is the entity model in front of it. An OData
façade is therefore the STA crate with a different naming layer, and the
day one is asked for, that is the shape to build.
How to add a storage backend
examples/plugin-example is the worked answer: a crate outside crates/
that implements CurrentStateDriver, TemporalDriver, one ApiSurface,
one NotificationSink and one PolicyEngine, and reaches a running broker
through one cargo feature. Read it first — it is short, and it is built and tested
with the workspace so it cannot drift from the seams it demonstrates.
A backend from outside the workspace:
- A crate depending on
antares-store(the two driver traits) and, if it also brings a surface or a binding,antares-apiandantares-notifier. Nothing depends on it in return. - Implement
CurrentStateDriverandTemporalDriverfor one type. Methods the backend does not support keep the trait defaults, which return an unsupported error instead of panicking. A driver may over-return fromquery_entities— answeringdecided: falsehands every predicate back to the API — but it may never drop a matching row and never cross a tenant. - Hold it to the driver contract.
antares-store'stest-kitfeature exportsrun_current_state_contractandrun_temporal_contract(crates/antares-store/src/contract.rs): the rulesantares-apiwrites against and no backend decides for itself — a missing row answersNone, a mutate never inserts (ADR-0005, ETSI 047_06), a rejected mutate commits nothing, batch results align with the input ids,upsertandbatch_upsertanswer opposite polarities, a query never drops a matching row and never crosses a tenant, and a stored@contextanswers only the tenant that stored it — aCachedcopy of a public document belongs to none and every tenant reaches it (ADR-0021). Call both from the crate's own tests, the wayexamples/plugin-example/tests/contract.rsdoes. A driver whose calls block on an async runtime needs a multi-threaded runtime context. - Register it in
antares-broker: an optional dependency, one feature that turns it on, and the name in the three shelves it belongs to —store_shelf()for the backend,SURFACE_SHELFfor a surface,AppState::with_sinkfor a binding,POLICY_SHELFfor a policy engine. Each is one#[cfg(feature = …)]line. Name the environment variables the backend reads in its doc comment;dev/check-env-docs.shrequires a row for each indocs/src/configuration.md. - Prove it against the conformance suite, not only against the contract:
run the broker with
ANTARES_STORE=<name>and put the ETSI suite through it..github/workflows/examples.ymldoes exactly that for the reference plugin.
A backend that wants to be a built-in instead — one of the arms the shipped
binary carries — takes the same first three steps and then joins the store
ladder: an arm in AnyStore (crates/antares-sql/src/store/any.rs), a
value in StoreMode (crates/antares-store/src/lib.rs), an arm in
build_builtin, a background job next to the expiry sweep if it needs one,
a row per pairing in crates/antares-broker/tests/store_combos.rs, and a
cell in .github/workflows/etsi-matrix.yml. The full preset runs seven
cells today: memory, file, postgres, timescale, postgres-nats,
timescale-nats and wasm-file; every cell must pass the whole suite before
the backend is part of a release. The API test suite runs once per built-in
store — AppState::new composes a fresh store per state from
ANTARES_TEST_STORE, and workspace.yml runs cargo nextest -p antares-api under each value; that harness reaches only backends
antares-api can construct, which is why an outside driver proves itself
through the suite in step 5.
SQL assembled at runtime stays inside crates/antares-sql/src/.
workspace.yml fails the build on AssertSqlSafe or an sqlx query built
with format! anywhere else under crates/ or examples/. Integration
tests under a tests/ directory are exempt: a test that creates its own
scratch database has no bind-parameter alternative. Everything a request
supplies reaches the database as a bind parameter, and a new backend keeps
that property.
Layer 2: lifecycle hooks
Five phases exist. Extensions attach to a phase; they never define one.
| phase | fires | seam in code | failure policy |
|---|---|---|---|
on_request | after parse and validation, before the operation | policy::decide, called by the gate! in each handler | fail-closed |
on_change | after commit, with the before/after documents | ChangeHook (antares-store); on Postgres the same change rides the transactional outbox to the bus | fail-open |
temporal_event | in the post-response drain, with the whole request's events | history::drain and the gate chain in crates/antares-api/src/history.rs | fail-open |
pre_notify | notification built, before send | policy::pre_notify, then the NotificationSink | fail-closed: an engine that panics drops the notification |
on_response | render and annotate | tower layer | fail-closed |
Failure policy follows the hook's role. An observer (metrics, audit)
fails open: a broken observer loses its own data and the request
completes. A gate fails closed: a broken gate refuses, it never waves a
request through. A failed temporal drain is the worked example: the
write keeps its 2xx, the failure is logged, counted in
antares_temporal_drain_errors_total and shown as temporalDrainErrors
on /q/health.
The history gate chain shows the granularity rule. GATES is an ordered
list of fn(&AppState, &TemporalEvent) -> bool; every event of a request
passes the chain once, in the drain, and the surviving events reach the
temporal driver in one event_list call. Adding a gate is one entry in
that list. Producers and drivers do not change.
Which hooks are active, and their settings, are data: they can be reloaded at runtime from the stores the broker already has. Hook code is a cargo feature. Nothing in the typed NGSI-LD path dispatches through a generic plugin chain, so conformance stays a property of the binary, not of a deployment's configuration.
HTTP-level concerns that belong to a gateway (authentication, rate limiting, request transforms) stay in the gateway in front of the broker. The shared crates give a gateway the broker's own parsing, expansion and matching for that job; see Shared crates. Authorization is the one concern that is split: the broker ships no policy engine, but it carries the seam an engine attaches to, because a query has to be narrowed before the store answers it and a notification has to be filtered on its way out — neither is visible from in front of the broker (ADR-0020).
Layer 3: dynamic loading
Not built. Rust has no stable ABI, so a loadable driver needs a C ABI
with a version check that turns a mismatch into a link error, and native
modules cannot be sandboxed. When a third party who cannot recompile
Antares needs a driver, the shape is either #[repr(C)] vtables over the
same two traits or a WebAssembly component driver for untrusted code.
Until then the traits stay the only coupling, which keeps that loader the
small half of the work.
Antares in the NGSI-LD ecosystem
A compliant peer, not a fork
NGSI-LD (ETSI GS CIM 009) is the context-management standard behind FIWARE-style smart-city platforms, and its whole point is that brokers are interchangeable: the same entities, queries, subscriptions and federation registrations work against any compliant implementation. Antares is a from-scratch Rust implementation of that standard — it shares no code with Orion-LD, Scorpio or Stellio, follows their naming tradition, and federates with them over the standard distributed- operations API. A deployment can mix brokers per site and migrate between them by replaying declarative state; conformance is the contract, and Antares publishes its evidence continuously on the ETSI conformance report page.
Where Antares fits best
- Resource-constrained and edge deployments — a ~35 MiB broker reaches places a JVM stack does not: industrial gateways, in-vehicle units, one-per-site municipal boxes (deployment).
- The browser and offline-first tooling — the wasm build is an NGSI-LD broker with zero installation: training environments, demos that need no backend, per-user sandboxes, edge UIs that keep working disconnected (wasm).
- High-density multi-tenancy — one shared schema with Row-Level Security and a 10,000-tenant design target makes per-user or per-department context spaces cheap, instead of one broker per tenant.
The configuration plane
Antares deliberately stays a vanilla data-plane engine: no YAML bootstrap, no vendor config API. Declarative city configuration — entities, subscriptions, registrations, pipelines as Git-versioned manifests, reconciled through the standard API — is a companion-project concern (the "city-as-code" pattern described in operations → upgrades). That split is what keeps the broker upgradable and replaceable, and it works with any compliant broker, not just Antares.
Standards posture
Implementation is spec-first against CIM 009 V1.9.1 (one ledger file per
clause under docs/spec/); suspected defects in the official test
suite are proven from the spec text and raised upstream at ETSI rather
than worked around. Smart Data Models payloads work as-is — see the
smart-city example dataset in the repository.
Architecture Decision Records
One file per irreversible decision, numbered, never rewritten.
Index
| ADR | decision | status |
|---|---|---|
| ADR-0001 | Shared-schema multi-tenancy (tenant_id + RLS) | accepted, amended (tenant inventory) |
| ADR-0002 | NATS JetStream as the change bus (with a local mode) | accepted |
| ADR-0003 | WebSocket binding deferred out of v1 | accepted |
| ADR-0004 | Store ladder, redb as the file-mode durability shadow | accepted |
| ADR-0005 | AnyStore enum + synchronous Pg facade | accepted · enum seam superseded by ADR-0013, sync facade by ADR-0022 |
| ADR-0006 | RLS and Timescale compression collide on attr_instances | accepted |
| ADR-0007 | temporal auto-recording stays in the write path | accepted (reverses the earlier bus-consumer design) |
| ADR-0008 | The browser build: one crate, the same router, no fourth backend | accepted |
| ADR-0009 | Temporal read cutover: attr_instances becomes the read path | accepted, implemented |
| ADR-0010 | Private-range egress allowed by default | accepted, implemented |
| ADR-0011 | The Via pseudonym identifies a (Context Source, Tenant) pair | accepted, implemented |
| ADR-0012 | Internal broker state lives in the store as doc kinds, keyed under reserved tenants | accepted, implemented |
| ADR-0013 | storage drivers — current-state and temporal as separate traits | accepted; supersedes the enum half of ADR-0005; the driver-identity consequence superseded by ADR-0017 |
| ADR-0014 | extension hooks — fixed phases, batch granularity | accepted; sink paragraph superseded by ADR-0016; two phases given a named user, and rule 1 an exception, by ADR-0020 |
| ADR-0015 | Notification delivery policy: one attempt by default, retries as transport, dead letters in the store | accepted, implemented |
| ADR-0016 | Notification bindings behind the sink registry | accepted, implemented |
| ADR-0017 | A driver is identified by its name, not by an enum value | accepted, implemented |
| ADR-0018 | CI actions are pinned by tag, third-party binaries by version | accepted, implemented |
| ADR-0019 | The distributed-subscription notification receiver lives outside the ETSI namespace | accepted, implemented |
| ADR-0020 | The policy seam: one trait, one built-in engine, every engine an addon | accepted, amended (the narrowing marker header) |
| ADR-0021 | A stored @context belongs to the Tenant that stored it, a Cached copy to none | accepted, implemented |
| ADR-0022 | The storage drivers are async; nothing blocks on a store call | accepted, implemented; supersedes the sync-facade half of ADR-0005 |
Format
Nygard's fields — Title, Status, Context, Decision, Consequences — plus one borrowed from MADR:
- Confirmation: how compliance with the decision can be checked — a named test, a CI job, a grep, or an explicit "manual review only". Every new ADR names its own fitness check; a decision nobody can verify drifts silently.
Shorter ADRs fold Context/Decision/Consequences into prose sections, as the existing files do; the five concerns must all be answerable from the text either way.
Immutability policy
Append-with-status. An accepted ADR's body is frozen; when a decision
changes, a NEW ADR supersedes it and the old one's Status line gains
superseded by ADR-00XX (see ADR-0005/ADR-0013 and the reversal recorded
in ADR-0007). Never edit an old ADR's Decision to match new reality —
the record of what was believed, and when it stopped being true, is the
point of keeping them.