Streaming ingestion of large FHIR CodeSystems
DRAFT — documents an in-development feature (streaming CodeSystem ingest). Behaviour and parameters may change before release.
Why
A standard FHIR create/update of a CodeSystem requires HAPI to deserialise the entire resource into memory and Ontoserver to re-serialise it back to JSON for storage. For a multi-gigabyte CodeSystem (millions of concepts — large extensional or translated catalogues) this exhausts the heap and the load fails.
The admin operation POST /api/indexFhirCodeSystem provides a dedicated ingestion path that takes the CodeSystem as the request body (optionally gzip-encoded). The concept-less “summary” of the CodeSystem is persisted, while the concepts themselves are streamed one-at-a-time into the Lucene index. Concept content is never held in memory as a whole and is never stored in the relational database — the Lucene binary index becomes the sole durable copy of the concepts (recovered, like SNOMED CT / LOINC, via the binary-index-in-ATOM mechanism).
The body is uploaded to the server, never fetched from a URL: the endpoint deliberately does not reach out to a caller-supplied remote host (that would violate Ontoserver's egress posture; outbound fetches happen only via configuration- or syndication-supplied URLs). The uploaded body is spooled to a server-local temporary file — kept compressed on disk when the request is gzip-encoded — over which the heap-bounded build runs; the temp file is deleted when the build finishes.
The build is two-pass: the first pass reads only each concept's hierarchy edges (its parent/child properties, plus any nested concept.concept children — the enclosing concept is a nested child's parent — without parsing the rest of the concept) to assemble the is-a graph and compute the transitive closure; the second pass re-reads the source, flattening any nested children into their own documents and building each concept's Lucene document with its ancestors/parents/children already resolved, then writing and discarding it one-at-a-time. Nested and property-based hierarchy carry identical semantics to ordinary (non-streaming) FHIR CodeSystem indexing. Heap during ingest is therefore bounded by the hierarchy edge graph and its closure, not by the number of concepts — no per-concept document is retained across the build.
The operation
POST /api/indexFhirCodeSystem[?conceptStorage=inline|index-only]
Content-Type: application/fhir+json (or application/json)
Content-Encoding: gzip (optional)
<CodeSystem JSON in the request body>
This is a proprietary admin operation under /api/*, not part of the FHIR API. It requires write permission for the CodeSystem (see Security model) and is rejected if the server is in read-only mode.
Parameters
| Parameter | In | Required | Description |
|---|---|---|---|
| (body) | body | yes | The CodeSystem JSON (application/fhir+json or application/json). May be Content-Encoding: gzip — the gzip stream is kept compressed on disk and decompressed during the build, so a multi-gigabyte source uploads (and stores) as a fraction of its size. |
conceptStorage |
query | no | inline or index-only — forces where concepts are stored (see Concept storage). Omitted → the server's size threshold decides. An unrecognised value is rejected. |
The operation takes no id parameter: the FHIR resource id is derived deterministically from the source's url + version (the same scheme SNOMED CT and LOINC use), so every party recomputes the same id rather than carrying it around.
The CodeSystem's url, version and content are taken from the source resource itself, not from the API — all are required FHIR elements and the ingest is rejected if any is absent (url+version are additionally required up-front because the derived resource id is computed from them).
Concept storage
A streaming ingest can persist the built CodeSystem in one of two ways:
INLINE— the concepts are kept in the resourcejsonBlob, exactly like an ordinary created/updated CodeSystem: aGET [base]/CodeSystem/{id}returns them, the resource is syndicated as a normal FHIR resource, and a lost index is rebuilt from the database. This is the natural choice for small CodeSystems and is the default when the source is within the size threshold.INDEX_ONLY— only the concept-less summary is persisted; the concepts live solely in the Lucene binary index, which is retained as a durableBINARYsyndication artifact (the sole copy, recovered via the binary-index-in-ATOM mechanism; see Syndication). AGETreturns the concept-less summary. This is the choice for very large CodeSystems, where holding every concept in the database is undesirable.
The mode is chosen as follows:
- an explicit
?conceptStorage=inlineor?conceptStorage=index-onlyalways wins; - otherwise the decoded source size is compared against
ontoserver.fhir.streaming.inlineMaxBytes(default104857600, i.e. 100 MB): at or below the bound →INLINE, above it →INDEX_ONLY. The bound is measured against the decoded content, so it is unaffected by gzip compression on the wire.
In all cases the concepts are streamed and validated identically and the same Lucene index is built and served — the mode only affects what is persisted in the database and how the resource is recovered and syndicated. The CLI --fhir ingest always uses INDEX_ONLY (its purpose is to publish a binary index).
The source document must be a FHIR CodeSystem whose top-level (metadata) fields appear before the concept array — concepts are streamed as they are read, so any top-level field appearing after concept is rejected as an ordering error.
Asynchronous job model
The operation returns immediately with HTTP 201 and a job status handle — it does not block on the (potentially hours-long) build. The response body is a JSON job status and the Location header points at the job-polling endpoint:
{ "id": "indexFhirCodeSystem-20260630-101500-123", "status": "running", "detail": [] }
Poll the job until it reaches a terminal state:
GET /api/jobs?jobId={id}
status is one of running, succeeded, or failed. A failed job reports failed: {correlation-id} and carries an actionable message in the detail array.
Example
# 1. Kick off the ingest by POSTing the CodeSystem body (returns a job id). gzip is recommended for
# large sources — the body is kept compressed on disk and decompressed during the build.
gzip -c big-codesystem.json | curl -sS -X POST \
"https://my-ontoserver/api/indexFhirCodeSystem" \
-H "Content-Type: application/fhir+json" \
-H "Content-Encoding: gzip" \
--data-binary @-
# {"id":"indexFhirCodeSystem-20260630-101500-123","status":"running","detail":[]}
# (uncompressed equivalent)
# curl -sS -X POST "https://my-ontoserver/api/indexFhirCodeSystem" \
# -H "Content-Type: application/fhir+json" --data-binary @big-codesystem.json
# 2. Poll until terminal
curl -sS "https://my-ontoserver/api/jobs?jobId=indexFhirCodeSystem-20260630-101500-123"
# {"id":"...","status":"succeeded","detail":["..."]}
Once the job has succeeded the concepts are queryable exactly like any other indexed CodeSystem — $lookup, $validate-code, $expand, $subsumes and concept search all work normally:
GET /fhir/CodeSystem/$lookup?system={url}&version=1.0.0&code={code}
The resource id
The ingested CodeSystem is assigned a FHIR resource id derived deterministically from its url + version — a readable <slug>-<version>-<hash> (e.g. big-codesystem-1.0.0-3a7b9e1c), mirroring the structured ids SNOMED CT (<module>-<effectiveTime>) and LOINC (LOINC-<version>) already use. Because the id is a pure function of (url, version), every party — this API, the CLI, the syndication producer and a downstream consumer — recomputes the same id independently; nothing carries it around. It is set on the persisted summary and embedded in the binary artifact, so a GET /fhir/CodeSystem/{derived-id} resolves to the same resource on every server that ingested or consumed it.
What gets stored
A GET of the CodeSystem resource returns a valid concept-less summary (metadata, properties, filters) — the content remains truthful (complete/fragment) so the server still uses it for terminology resolution, but the (potentially multi-gigabyte) concept list is served only via the terminology operations, never inline in the resource.
Internally the row is marked conceptStorage = INDEX_ONLY: its concepts are durable only in the binary Lucene index, which is published as a BINARY artifact in the syndication feed regardless of the per-resource syndication setting.
Updates, idempotency and concurrency
- Idempotent re-submission. Re-submitting the same source (matched on its SHA-256) for the same CodeSystem is a no-op — no new version is built and the existing row is left untouched.
- Update. Submitting a new source builds a fresh index version and promotes it atomically; the existing index keeps serving until the new build succeeds.
- Failure isolation. A failed or interrupted ingest leaves the previously-live index intact.
- Serialised builds. Concurrent loads of the same CodeSystem are rejected (the per-CodeSystem “already indexing” guard) so two builds never race.
Validation and rejection
The ingest fails fast with an actionable message in the job detail when the source is invalid:
- a missing required element —
url,version, orcontent; - a duplicate concept
code; - a dangling hierarchy reference (a
parent/childproperty pointing at an undefined code); - an out-of-order document (a top-level field after the
conceptarray); - a
supplement(streaming ingest does not support CodeSystem supplements).
A rejected load promotes no row and, for an update, leaves the previously-live index serving.
Recovery
Because the concepts of an INDEX_ONLY CodeSystem live only in the binary index, recovery is from the binary artifact, never from the (concept-less) database summary:
- a lost or corrupt index is restored from its retained binary artifact;
- if no binary artifact is available the CodeSystem is marked
FAILED(rather than silently rebuilt as an empty index from the summary); - a stuck
INDEXINGstate from a crashed ingest is reconciled on startup.
Self-sufficient binary artifacts
The binary index embeds the concept-less summary, so a server that consumes the BINARY artifact (via syndication) and has no pre-existing row for the CodeSystem can materialize the CodeSystemR4 row directly from the index and immediately serve the concepts — no re-ingest of the source is required.
Limitations
- The source must be a single FHIR
CodeSystemresource (not aBundle). - CodeSystem
supplements are not supported via this path. - The normal FHIR
create/updatepath is unaffected; use it for small CodeSystems that fit comfortably in heap.

Ontoserver