Repository navigation
coraine 0.6.0
A lightweight NGSI-LD Context Broker written in C, fully implementing
ETSI GS CIM 009 v1.9.1 and passing the official ETSI NGSI-LD conformance test
suite with a 100% success rate.*
What makes it worth a look:
- Small. 4.8 MiB of files a machine did not already have — the broker
with its in-memory store, and two libraries, both GEOS. One process, no runtime, no
JVM, and no database server to install beside it. - Fast. 12 577 requests/s per core at
limit=20— 251 540 entities/s per
core — and answering 9 ms afterexec. Writes, the store on disk: 54 707 creates/s
per core one at a time, 117 460 in batches of twenty. - Persistent, still without a database server.
--database corDB --dbDir <dir> --troe corDB: the store and its temporal history on disk, in the same process. - Plugin-driven. Storage backend, temporal history and extra API surfaces are
shared libraries loaded at startup. The core speaks NGSI-LD; the plugins decide
where data lives and how the broker talks to the world.
A known issue - fixed in 0.6.1
A Subscription whose expandValues, jsonKeys or langProperties is an array of
strings is accepted, but the array is not applied to its q: the subscription matches as if
the list were absent. As a string (a comma separated list) all three work as before, and a
POST Query body takes both forms. 0.6.1, released the same day,
applies the array in a Subscription too.
Getting started
The image runs on amd64 and arm64 under the same tag. An in-memory store, no external
services — the broker answers NGSI-LD on 1026:
docker run --rm -p 1026:1026 \
quay.io/seamware/coraine:0.6.0 --database corDBOr as a Debian package (Ubuntu 26.04, 24.04, Debian 13), from the signed repository:
curl -fsSL https://seamware.github.io/apt/coraine.gpg | sudo tee /usr/share/keyrings/coraine.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/coraine.gpg] https://seamware.github.io/apt $(. /etc/os-release; echo $VERSION_CODENAME) main" \
| sudo tee /etc/apt/sources.list.d/coraine.list
sudo apt update && sudo apt install coraineThen:
curl -X POST localhost:1026/ngsi-ld/v1/entities \
-H 'Content-Type: application/json' \
-d '{ "id": "urn:ngsi-ld:Sensor:1", "type": "Sensor",
"temperature": { "type": "Property", "value": 21.4 } }'
curl localhost:1026/ngsi-ld/v1/entities/urn:ngsi-ld:Sensor:1To keep the data, add --dbDir <dir> (corDB on disk), or use MongoDB with
--database mongoc. The README walks
through the rest.
Where to look next
- Documentation — installation
and administration, the API walkthrough, the plugin architecture, building from
source, and the roadmap. - Container images —
every published tag.0.6.0is this release; every merge tomainalso
publishes an immutable<version>-<date>-<commit>. - Issues — questions, bugs and
feature requests all belong here; that is where the maintainers are. - README — footprint and
throughput numbers, the plugin catalogue, and how the test suites are run.
What is in 0.6.0
Two and a half weeks since 0.5.0: authorization inside the broker - the FIWARE Data Space Connector's
tokens and policies decided as the connector decides them -, Server-Sent Events, Timepoint, declared
indexes, bridges to OPC UA and CKAN and the FIWARE IoT Agents as bridge plugins, c³ - the control
center that builds, runs and shows a broker - and the stored trees in 14.5 % less RAM.
New
-
Authorization in the broker -
--auth: auth plugins, andfdsc.so, the FIWARE Data Space Connector's
access tokens and ODRL policies decided inside the broker as the connector decides them - 67 cases against
a deployed connector, 0 differences; per entity, per attribute and per tenant with--fdscNgsild,
notifications decided for the subscription's owner; OpenID Connect tokens (oidc.so); the ODRL-PAP's
/policyAPI served by the broker (--policyApiPort).
(Authorization) -
An NGSI-LD plugin for APISIX -
ngsild-pep(corApisixNgsild):
fdsc.so's decision in the gateway the Data Space Connector already uses, in front of any NGSI-LD broker. -
Server-Sent Events - a subscription on
urn:ngsi-ld:selfmade on its own event stream: the201to its
POSTistext/event-stream, its notifications the events, and it lives as long as the stream. Over a
WebSocket,urn:ngsi-ld:selfis the connection.
(Server-Sent Events) -
Timepoint (NGSI-LD v2.1) - an entity, or a query's entities, as they were at a point in time, from the
history inside corDB.
(Timepoint) -
The OPC UA bridge - Variables as attributes, Methods as Service Executions, the server's node tree as
the bridge's inventory, SignAndEncrypt with certificates (--bridges opcua,
corOpcuaBridge). -
The CKAN bridge and DataChannels - a CKAN portal's DataStore resources read into entities (
ckan.so,
corCkanBridge); a DataChannel makes each record into the entities its mapping names. A bridge reports a
Channel's status, and why, at any time.
(DataChannels) -
The FIWARE IoT Agents as bridge plugins - prototypes - JSON, UltraLight 2.0, LwM2M, LoRaWAN, Sigfox and
ISOXML, each a public repository of its own (corIota*Bridge).
(FIWARE IoT Agents) -
What a running broker is -
GET /admin/config(every option it runs with, a secret masked), whether its
build has traces (/admin/log), a bridge's inventory for a mapping tool
(/admin/bridges/{bridge}/inventory). -
c³, the coraine Control Center - Compile: a broker built for a deployment from a web page (features,
bridges, release/PGO/make tune/debug, oncoraine-dev'scoraine-build); Operate: brokers started and
stopped, natively or in a container, their logs, configuration and metrics, and the DDS mapping tool;
Explore: a running broker's entities as a graph and on a map, live; the documentation, offline, and the
same guides at seamware.github.io/docs. -
c³, the control center, is in the
corainepackage (/usr/bin/c3); its Compile page builds brokers
withcoraine-devinstalled.coraine-build --source <dir>builds from a directory of git clones
instead of thecoraine-devtarball, and c³ built from clones builds from them.
(coraine-dev) -
Declared indexes: the attributes whose
qan index of the store serves - narrowed to an entity type or a
tenant if so declared:--dbValueIndexesat start (the attributes' IRIs),/admin/indexeson a running broker; each declaration is
an index in every tenant it covers, a tenant created later included.mongoc: two MongoDB indexes per
declaration;corDB: a value index in the store, which aqon the attribute takes its candidates from,
andGET /admin/indexescounts theqterms it served and the writes that changed it. Aqon a declared
attribute up to x416 (corDB) and x919 (mongoc) faster, a write of it +2.8 / +2.5 µs, a write of another
attribute +0.13 / +0.3 µs; on corDB only the entities with a value of a declared attribute are in its index.
Declare them at start: on corDB a change through the admin API builds every declaration's index again
(1 000 000 entities: 0.87 s for two), the store's writes waiting.
(Declared indexes) -
The declared indexes in
/admin/metrics:ngsild_index_built,_entries,_bytes,_accesses_total,
_updates_total, a series per declaration and tenant - what an index gains and costs on the workload, over
time. (In /admin/metrics) -
Declared indexes of the TimescaleDB history:
--troeValueIndexesat start (IRIs),/admin/troeIndexeson a running
broker; each declaration is two partial indexes oftroe_attrs(v_number,v_text) in every tenant it
covers. The indexes made in the database that serve aqvalue term are found too (pg_indexes) and listed
withdeclaredBy: "database". A temporal query whoseqhas a term an index serves takes its entities from
the index: a temporal query whoseq10 of 100 000 entities match x62 (10.2 -> 638 requests/s), a count of
1 % of them x16; the history's writes -1.6 to -4.3 %. In/admin/metrics:ngsild_troe_index_built,_bytes,
_accesses_total. Trace level 404 traces the selector's SQL.
(TimescaleDB (the history)) -
--daemon(-bg): the broker detaches from its terminal (fork, setsid, chdir /, stdin/stdout/stderr on
/dev/null) and runs in the background; the command returns 0 at once. No pid file: the log names the pid,
with or without-v. It needs--logTo file- with--logTo stdoutorboththe broker refuses to start.
The foreground stays the default. (Running the broker) -
--logTo stdout|file|bothand--logDir(default/var/log/coraine): the log on stdout (the default, as
before), in<logDir>/coraine.log, or both. The directory must exist; a log file that cannot be opened
stops the broker at start. Acoraine.logof an earlier start is renamedcoraine.log.old. The file is
written in append mode: logrotate'scopytruncaterotates it; the broker does not reopen it (no SIGHUP).
CORAINE_DAEMON,CORAINE_LOGTO,CORAINE_LOGDIR. -
corDB with
--dbDir: a log that cannot be written - a full disk, a file-size limit, a failedfdatasync-
pauses the store. Every request that writes is refused before anything is applied: 503
PersistenceUnavailable,Retry-After: 5; GET and POSTentityOperations/queryare served./ready
answers 503 and the health report'sstatusispaused, with apausedmember; metrics
ngsild_store_pausedandngsild_requests_refused_store_paused_total. The log is allocated 8 MiB ahead of
its records, so a full disk pauses the store before a write needs the room. The broker repairs by itself -
a snapshot of each tenant from memory, retried with a backoff up to 16 s - and the pause ends once the disk
has room again. A failure of the history log (--troe corDB) is repaired too: the tenant's history is
written anew from memory, as new segments that replace the old ones.
(A store that cannot write its log) -
A query with an
offsetat or past the end of its result set answers its last entity, as before, and now
says so:NGSILD-Warning: 199 coraine "offset 50 is past the result set (5 entities) - the last entity is returned".
(Pagination) -
A registration PATCHed: the subordinate subscriptions of the matching subscriptions follow it (§ 10.5.2.4) -
PATCHed at the Context Source when the registration's change narrows their entities differently, deleted when
it no longer matches (another type, nocreateSubscription), deleted at the old endpoint and created at the new
one when its endpoint or tenant changes, and created when it matches now. Creation and deletion of a
registration were handled already.
(--distributed) -
Under
--high-availability mongo, the WebSocket subscriptions of an instance that was killed or that
crashed are deleted by the instances alive, also when it never comes back. Each instance writes the
beat of its run in the shared database every 10 s; the subscriptions notifying a connection of a run
with no beat for 60 s are deleted. An instance whose beat cannot be written for 60 s closes its
WebSocket connections (status 1012). Build switchCOR_FEATURE_HA_RUNS.
(High Availability)
Faster
- corDB on disk: a write that updates attributes logs those attributes, not the entity - PATCH +11-13 %.
(Performance) - The TimescaleDB history: TRoE writers and unnested rows, 5-11x the history write rate; the history
database down without the current state going down (held, retried, then counted as lost);--troeSync
for read-your-writes.
(Performance) mongoc: aqvalue term can be served by an index. It still finds a value on any instance of an
attribute, as before; an index on<attr>.@none.valueand one on<attr>.@datasetIdsnow serve it.
(q and the mongoc indexes)- Batch writes with many clients: the tree heap (corTree) keeps a cache of free blocks per thread and size,
so threads writing the store no longer wait for each other on one mutex a size. With 50 clients on corDB
(durable): batch update (20) +79 %, batch create (20) +186 %, batch delete (20) +225 %, merge +8 %;
single-entity writes and reads unchanged, RSS unchanged.
(Threads building trees at once)
Less memory
- The trees the broker keeps - corDB's entities, the subscription, registration and snapshot caches - take
14.5 % less RAM: 100 000 entities of perfRun's larger store, 270 MiB → 231 MiB broker RSS. Their nodes are
in a heap of their own, 8-byte aligned and without malloc's per-block header (−11 %), and a node that is
not an object or an array is 32 bytes there instead of 40 (−3.6 %). On disk nothing changed: corDB's log
and cor:// encode nodes one by one, whatever their layout in memory.
(The stored trees)
Changed behaviour
-
Plugins built for 0.5.x do not load in 0.6.0, of any kind: the tree node (
CorNode) has a new layout, and
every plugin reads nodes by their fields. The broker refuses such a plugin and exits 1, saying which and
why; rebuild it against 0.6.0's Cor-Libs (coraine-dev). A plugin built for 0.6.0 is refused by a 0.5.x
broker the same way (undefined symbol: corNodeLayout2).
(The CorNode layout) -
mongocstorage format 2: an attribute with an instance of a datasetId is marked in its document. A
database is upgraded at the first start of 0.6.0, and 0.5.x then refuses it. Stop every broker on a
database before starting 0.6.0 on it: a rolling upgrade from 0.5.x is not supported.
(Storage format) -
-fg/--foregroundis deprecated: the broker runs in the foreground by default, so it does
nothing. Accepted through 0.7.x, with one warning line at start --fg: the broker runs in the foreground by default now - the option is refused from 0.8.0- and refused from 0.8.0. It is no longer in
coraine -u. The package's systemd unit, the Docker entry point and the scripts here no longer pass it;
drop it from your own.-fgwith--daemonis refused. -
No
--debug/-d(CORAINE_DEBUG), for the broker andcoraine-import: it turned on theDlines of
the log, and nothing wrote one. A start with-dis refused as an unknown option. What shows more is the
trace levels (--traceLevels)./admin/loghas nodebugeither:GETno longer reports it, and
?debug=is refused (400).
(Command-line options) -
A Bool option's environment variable is on for
true,True,TRUE,on,OnorON, off forfalse,
False,FALSE,off,OfforOFF. Any other value stops the start, the variable named
(coraine: CORAINE_VERBOSE=yes: not a Bool - ...) - it was off without a word,yesand1included, and
onwas off too.
(Command-line options) -
The core context is listed by
GET /ngsi-ld/v1/jsonldContextsas kindCached, not
ImplicitlyCreated(§ 5.13.1 - a local copy of the@contextat its external URL, compiled in instead
of downloaded). As everyCached@context, its metadata is served (?details=true) and its body is not:
GET /ngsi-ld/v1/jsonldContexts/<core URL>answers 422OperationNotSupported. -
The transport plugin ABI is 2: a driver has a
dropoperation (every connection closed, with a status).
A transport plugin built for ABI 1 is still loaded, without it.
Fixed
- The image and the packages are built with libmicrohttpd 1.0.10 (was 1.0.2) - the fixes of its 1.0.3 to
1.0.10 releases, request smuggling among them. - A Context Source registered with a
cor://endpoint: a forwarded query returned none of its entities
([]), and a forwarded batch took none of its per-entity answers - a batch create reported201for an
entity the source refused. The same overhttp://was right, and so was a forwarded retrieve. The
temporal query and retrieve and the other batch operations took the same path and are fixed with it. - A default user
@context(--defaultUserContext) that cannot be retrieved answers a query 504
LdContextNotAvailable, as a create:GET /entities?type=Roomanswered 200[]-Roomexpanded with the core
context alone, in silence. - TimescaleDB:
--troePwd,--troeUserand--troeNamego to PostgreSQL as written - each is quoted in the
connection string. A password with a space, a'or a\was cut at its space and the rest taken as
syntax: the start waited--troeConnectTimeoutseconds for a database that was there, and failed. - TimescaleDB: a password the server refuses (or none, where it asks for one) fails the start at once, with the
server's message - it was taken for a database that cannot be reached, tried once a second for
--troeConnectTimeoutseconds (30). As the documentation said, and as MongoDB's (--dbUser/--dbPwd).
(The database down) coraine-build --list-featuresprints the switches' descriptions as they read -"dataChannels","both"were
printed with CMake's backslashes, in the text list and in the--jsonone.- A required option missing from the command line is named, the usage printed, exit 1:
coraine-importwithout
--filecrashed (SIGSEGV; withmongocafter connecting).-u/-Uprint the option list whatever else the
command line holds -coraine -u -p 0exited 1 with the range error of--port. The action options (--usage,
--Usage,--version) have no environment variable:-UlistedCORAINE_VERSION, which was never read. - The start-up log says a size below 1 MiB in KiB:
--memoryLimit 1logged "response size budget: 0 MiB" (it is
64 KiB) and "writes refused above 0 MiB" (921 KiB); likewise the 403 of a response over its budget and the 503
over the memory budget.--healthPorton a port that cannot be listened on says why ("Address already in use"),
as--corPortdoes. corDB:--dbSync,--dbSyncInterval,--dbSnapshotEveryand--dbCompresswithout--dbDirwere not
looked at ---dbSync sometimesstarted, and--dbSync requestpromised a durability there was none of.
Given without--dbDirthey now refuse the start, each one named, asramDBrefuses--dbDir.
(corDB on disk)- Passwords and tokens no longer show in the log. They appear as
***in mongoc's
connection URI (-v, from--dbUser/--dbPwdor from--dbURI), in the notification and
forwarding traces (the endpoint'suser:passwordand the value of a credential header such as
receiverInfo'sAuthorization), in the warnings that name an endpoint, in the auth plugins'
discovery, keys and policies URLs, in the client proxy's error messages, and in a crash
report's command line (the value after--dbPwd,--dbURIor--troePwd). coraine -udescribes its options as Command-line options does. Wrong before:
--authnamed anonevalue there is none of,--distOpTimeoutclaimed notifications and@context
downloads (10 s each, not it),--insecureNotifonly notifications and forwards (it is every outgoing
HTTPS request,mqtts://too),--httpLoops"built-in server only" (the cor:// listener too),
--connectionPoolSize"MHD thread pool" (the workers, on both servers),--ddsSyncTimeoutleft out the
action goals' wait (504),--asyncSnapshotpromised a status in the 201 (no body either way),
--fdscOrganizationshowed no default (it has the PAP's);--bridgeConfig,--contextSourceExtras,
--subStatsFlushInterval,--cooldownMillis,--high-availability,--dbName,--dbUser,--dbPwd,
--dbURI,--troeName,--fdscPolicies,--fdscOwnerMaxAgeand--traceLevelssay their defaults
and limits. The high-availability errors name the option as-ha(they said--ha, which is no option).- A group of one-letter switches holding a letter that is not a switch's is an unknown option, the start
stopped (exit 1):coraine -vtcrashed --t(--traceLevels) takes a value, and the group set it as
a switch.
(Command-line options) CORAINE_PORT=0 coraine -uprinted the port's range error, not the usage: a value of the environment that
is refused is now said after the-u/-Ucheck, as a bad value on the command line is.
(Command-line options)- The profile-guided build stops no more on gcc's fortify check (
null destination pointer) in the metrics'
text, which measured its length into a NULL buffer: the arm64 image is published again (none since
2026-10-22), and the nightly's image builds (performance, the DDS bridge, the fdsc plugin vs the Data
Space Connector) build again. It measures into a byte thrown away now; the TimescaleDB plugin's statement
text the same. - A Channel's
samplesIn(GET /ngsi-ld/v1/channels) counts a sample before its value is written, not after
its notifications and history: a client that saw the new value could read the count still without it. - A request body nested deeper than 100 levels (arrays and objects) is refused with 400
InvalidRequest,
detailrequest body nested deeper than 100 levels, and the broker serves the next request - a body of
enough nested brackets, well under--maxRequestSize, stopped the broker.
(Request size) - corDB with
--dbDir: a write whose record cannot be put in the log - a full disk - is answered 503, with
any--dbSync; it was answered as a success, and lost at the next start. After a failedfdatasync,
every later write is answered 503 until the broker can write its log again (the pause, below), with any--dbSync- a load stops at the
first record it cannot read; with--dbSync request, the writes that sync was for are answered 503 too.
They were all answered as successes, and a--dbSync requestwrite after an earlier failure no longer
waited for its sync.
(--dbSync) - A distributed subscription's notification, re-dispatched from a Context Source to the subscriber, now
carries the subscription'snotification.endpoint.receiverInfoas headers (anAuthorizationamong
them) - it carried none, and an endpoint that requires authentication answered 401. It also carries
the tenant (NGSILD-Tenant), keeps the subscription'stimeout, and goes over the subscription's
transport when the endpoint is not HTTP. - The subordinate subscription created on a Context Source no longer gets the subscriber's
receiverInfoandnotifierInfo- the subscriber's credentials went to the Context Source. A PATCH of
the subscription'snotification.endpointno longer moves the subordinate's endpoint from this
broker's callback to the subscriber's. - The
prevlink of a page past the end pointed before the offset asked for - past the end too (offset=10,
5 entities,limit=5:prevatoffset=5); it is the page before the last entity. - An EntityMap page with an
offsetpast the end of the map was empty ([]); it is the map's last entity, as
for a query. GET /ngsi-ld/v1/csourceRegistrationswith a filter that drops a matching registration -q,scopeQ,
geoQ, a temporal query, or none (a registration with anobservationIntervalormanagementInterval
is then left out) - released the matches wrongly: a dropped registration stayed held, so after its
DELETEits memory was never freed, and a kept one was released twice, so a request forwarding to it
while it was deleted could read freed memory. Each match is now released once.- A request body's numbers follow JSON's grammar:
+5,1., a bare-and1.e5are refused with 400 -
they were taken as 5, 1, 0 and 100000.1e400is refused - it was stored as infinity and renderedinf,
which made every response holding it invalid JSON. - An integer past 64 bits (
12345678901234567890) is stored as a float - it wrapped to a negative integer. - A float is stored as the double nearest to its text:
q=a==0.3did not find a stored0.3on mongoc
(stored as 0.30000000000000004), and decimals past the 12th were dropped.
(Request body) - A
Linkheader (or an@contextURL in a body) naming a@contextthe broker hosts, by the URL the
broker gives it (<--httpEndpoint>/ngsi-ld/v1/jsonldContexts/{id}), is that context - read from the
cache or the database. It was downloaded from the broker itself and cached a second time, as aCached
entry under the URL:GET /jsonldContexts?kind=Cachedlisted the Hosted context too. - A broker without
--distributedcreated subordinate subscriptions at a Context Source when a registration was
created after a matching subscription; it creates none. - The DDS bridge starts with a list of plain values in
dds.ddsmodule.dds, such as
"whitelist-interfaces": ["192.168.1.10"]- the DDS Enabler refused the file and the broker did not start
(corDdsBridge). A change of the file while the broker runs reloadsallowlist,blocklistandtopics
with such a list in it too.
(Bridges and Channels)
* The conformance runs use a corrected fork of the ETSI test suite. The changes
are test-side fixes — the suite has bugs of its own and parts of it do not run as
published — never relaxations of what the broker must do. The fixes are filed
upstream with ETSI.
Built by @kzangeli, with @claude.
Licensed under the Apache License 2.0.