Repository navigation
Helios Integration Engine: Architectural Discussion #1
Replies: 2 comments
The API, assessed against the client UI's designThe client UI now has a decided design for its data section: three modules, Sources, Transforms and Monitoring, plus a five-step "New source" flow. This comment maps what those screens need onto what the engine serves today, and lists the gaps. It replaces the placeholder in "The API" above. The vocabulary below is the UI's; the engine's names are in parentheses where they differ. Vocabulary
What the screens need, and what existsSources, the page of one source. The chain, the facts (last received, usually, brings, connected by whom and when, if it stops), Today (rows read, resources written, failed, next pull), by day for 14 days, last 7 days as a table, and the state (receiving, pulled every N minutes, nightly file, first load running, late, paused, not connected).
Loads. Every load with when, what, method, rows read, written, failed, how long it took; failures grouped by reason with the transform line, sample rows, Retry the N rows, Open the transform with this row.
New source. Step 1 the connector kind; step 2 connect with Test connection and What to read (tables, row count, newest timestamp, the column that marks new rows); step 3 pick a transform and Try it on five real rows; step 4 the load method with one recommended, the first load in bulk, the target server; step 5 review and Start. Then Settings (the same steps with Change), Pause, Check now.
Transforms. A list (name, version, from what rows to which resources, used by which source or "ready"), the code, versions explained in words with who, when and which source used each, Try it on real rows (what each row became, what the transform did), Save version, New transform from empty, a copy or a template; opened from a failed load with the failing line marked and the row ready in Try it.
Monitoring. Status: a "needs you" list (late sources, failed rows), last 24 hours (loads, rows read, failed rows, alerts sent), all sources in one table. Alerts: rules per source as sentences (nothing for 2 hours; more than 1% of a load's rows fail; the file is not there by 06:00; the FHIR server cannot be reached), who is notified, in the app and by email, a default rule born with every source, and what was sent in the last 7 days with when it cleared.
The assistant. The UI's assistant reads sources, loads, failures, transforms and status, and explains; it never connects, starts, pauses, retries or saves. Every one of its tools ( What this changes in the design
Authorization is unchanged: bearer tokens on the API, validated as HFS validates them; roles for who may connect, start or pause a source are an open item on the UI's side too. |
Correction on the
|
Uh oh!
There was an error while loading. Please reload this page.
Introduction
A FHIR server is only as useful as the data inside it. The persistence layer discussion and the bulk export discussion cover how HFS stores data and how data leaves it in volume. This document is about the step before both: how data from the outside world, in whatever shape it arrives, becomes FHIR resources in HFS, reliably, with a record of what happened to every row, and with someone told when it stops arriving.
That is the job of the Helios Integration Engine (HIE). It is a separate service that sits between the systems that produce data (a laboratory information system dropping files in a bucket, a database, another FHIR server) and HFS. It is also what the client applications built on HFS call into to see where their data came from and whether it is fresh.
Like the discussions linked above, this is an architectural strategy document rather than a specification. It explains the building blocks, the technology choices we propose for each, and the Rust trait designs that will shape the engine. It is informed by two things: a hands-on comparison of two integration engines (Apache Camel and the Open Integration Engine, the open-source fork of Mirth Connect), and a working prototype in this repository that already takes a CSV from a folder, or an NDJSON export and Synthea Bundles from a folder or a bucket, to resources in HFS through the full error, retry and alerting path.
Who should read this? Anyone who has had to get a hospital's data into a FHIR server and keep it flowing: integration analysts, data engineers, and the people who get paged when the Tuesday file has seven columns instead of six. Feedback is very welcome.
A note on scope. This document covers getting data into HFS. Outbound integration (sending FHIR data to other systems) is a separate problem. Visual channel authoring, no-code data preparation and a catalog of hundreds of SaaS connectors, which commercial integration platforms sell, are explicitly not goals.
The Lay of the Land: What an Integration Engine Is
Every integration product, from a 2007 open-source engine to a cloud provider's managed service, decomposes into the same handful of blocks. We built the same lab-results flow in two of them and broke it on purpose; the blocks, and what each product did with them, were:
Three conclusions shape everything below.
Idempotency is the first requirement, not a feature. Sources re-send files. Operators re-run jobs. Networks deliver twice. Unless every resource carries a stable identity and delivery is an update on it, the FHIR server fills with duplicates, and the analysis built on top of it is wrong in ways nobody notices.
Data errors and transient errors are different problems. A non-numeric value, an unknown code, a 400 from HFS: no retry will fix these. They go to a dead letter with the file, the line and the reason, and wait for a person. An unreachable HFS, a 503, a timeout: these are retried with backoff and never dropped. An engine that treats both the same either multiplies writes or stops good data.
The queue is not something to build. Mature integration engines carry a heavy persistent-queue capability, and rebuilding one is not where the value is. But "queue" means two different things at two scales, and the right answer differs for each (see the deployment section).
The Essential Flow
The shape of every feed, regardless of source or target, is the same. Following one record, a line in a lab CSV, through the prototype:
received. From this point the engine's store, not the source, is the source of truth: a crash or an HFS outage loses nothing.rejectedwith that reason. A transformed one goes topending. Data that already is FHIR passes through unchanged.resourceType, and an identity: the resource's ownid, or anidentifierwith system and value. Without either there is no idempotency, so the record is rejected.idis written to{type}/{id}; one without is written as a conditional update on its identifier. Either way the first delivery creates and any later one updates.sentwith HFS's response (location, version). A 4xx marks itrejectedwith theOperationOutcometext. A connection failure, 429 or 5xx leaves itpendingwith a next-attempt time that doubles each try up to a ceiling, and raises one outage alert after a configured number of attempts and one recovery alert when delivery resumes.sentorrejected, the object isdoneand is acknowledged at its source (archived). If any records were rejected, one alert names the object, the count and the first reason.A
rejectedrecord can be put back toreceived, optionally with a corrected row; the original row is kept alongside the correction. This is the reprocess button.The First Use Case: A Bucket into HFS
The concrete goal for the first version is narrow on purpose: a data set lands in an S3 bucket, and it is ingested into HFS with confidence. "With confidence" means every one of the properties above holds: nothing lost, nothing duplicated, every rejection explained, and a person told.
Two delivery paths exist, and the choice is a per-feed setting rather than an architectural commitment.
REST, batch Bundles. The engine builds
batchBundles ofPUTentries (PUT Patient/{id}, orPUT Observation?identifier=system|value) and posts them to[base]/. HFS supports both inside Bundles today; the prototype verified 201 on first delivery and 200 on re-delivery. A batch, unlike a transaction, returns a status per entry, so one bad row does not block its neighbours. This path suits feeds of hundreds to tens of thousands of records at a time, anything that needs an answer per record, and everything that goes through a transform.$bulk-submit. HFS implements the Bulk Data Submit operation as the Data Consumer: a client posts$bulk-submitwith a manifest URL, and HFS fetches the manifest and its NDJSON files, ingests them through its bulk engine, and publishes a status manifest with a receipt per line. The first real test case will almost certainly be an NDJSON export landing in a bucket, and for that case the engine does not rewrite the files: it admits each object as above, reads it into records for the history, and then hands HFS the source objects themselves. That path has enough moving parts to deserve its own section, next.NDJSON in a Bucket: The
$bulk-submitPathThe picture: an export lands in a bucket as one NDJSON file per resource type. HIE admits each object, reads it into records, and then a
BulkSubmitDelivererwrites amanifest.jsonwhoseoutput[]entries point at the source objects through pre-signed URLs, posts$bulk-submit, and maps the receipts back to the records. HFS does the ingestion; HIE keeps the history, the dispositions, the alerts and the archive.The flow for one drop:
resourceType, does it have anid. The result is recorded as a prediction on the record, not a disposition.manifest.jsonwith oneoutputentry per object:type, a pre-signedGETURL for the object, and the line count.requiresAccessTokenisfalse, since the URL is the credential. The manifest itself is written to the engine's own prefix in the same bucket, or served by the engine over HTTP in a single-instance deployment.POST [hfs]/$bulk-submitwithsubmitter= the feed id,submissionId= the drop id (below),manifestUrl,fhirBaseUrl= the engine's own base, andsubmissionStatus=in-progress. Every record of the drop moves tosubmitted.OperationOutcomewhen it failed. Receipts are mapped to records by (object, line), which is exactly how the records were stored. Success markssent, an error marksrejectedwith the outcome text: the same two dispositions as the REST path, from the same fields.completedkick-off, acknowledges the objects at the source, and raises the per-object alerts as usual.This is where the open questions sit.
(a) Pass-through records skip the transform and the engine's identity check, so the receipts are the only per-record disposition. Is that acceptable? Yes, with two provisions. First, the pre-flight in step 2 runs on every record anyway: it costs nothing, it needs no Python, and it lets the engine's API show "line 412 has no id" before HFS has even been asked, while HFS's receipt, when it arrives, is authoritative and overwrites the prediction. Second, a receipt carries everything the REST path's disposition carries (type, id, created or updated, outcome,
OperationOutcome), keyed by the line number the engine already has, so the per-record history is as complete on this path as on the other. What a pass-through feed gives up is only the chance to fix a record before HFS sees it, and a feed that wants that declares a transform and takes the rewrite path below.(b) HFS upserts by
idon this path, so pass-through only works when the source resources carry stable ids. Agreed, and it should be an explicit feed setting rather than something guessed per object:ids:source(default)id. The manifest points at the source objects. A line without anidis predicted rejected, and HFS's receipt confirms it.deriveid= UUIDv5 over(feed, identifier.system, identifier.value), and the manifest points at the copy. A line with neitheridnoridentifieris rejected before the copy is written and left out of it.Mixing the two inside one feed is not offered: either the source owns the ids or the engine does. With
source, HFS'sreplaceimport mode is the right one (upsert by id), andHFS_BULK_SUBMIT_SKIP_UNCHANGEDmakes a replayed export leave versions untouched.(c) Acknowledge has to move to after HFS has fetched the file. Yes: on this path an object is acknowledged only when its submission is terminal and its receipts are mapped (step 6), not when its records are stored. Two consequences follow. The source listing will keep returning an object that is in flight, so admission distinguishes "this key, this hash, already admitted and in flight" (skipped silently) from "a new key with content already seen" (recorded as a duplicate). And the pre-signed URL's lifetime must cover the time HFS may take to reach the file; it is a feed setting with a generous default (24 hours). If HFS reports that a file could not be fetched or read to its end, the manifest comes back
failed, the records of that file that received no receipt return topending, and the engine posts a replacement manifest (replacesManifestUrl) with fresh URLs for those files only. The entries HFS committed before the break keep their receipts and are not re-sent.(d) Batch boundary: one manifest per object, or per drop? Per drop, with a cap. An export arrives as a set of files that belong together (
Patient.ndjson,Encounter.ndjson,Observation.ndjson), and that set is what the operator thinks of as "today's data". The drop is the set of objects admitted in one scan pass, split into manifests of at mostmanifest_objects_maxobjects. ItssubmissionIdis derived from the feed and the sorted (key, hash) pairs of its objects, so re-posting the same drop is idempotent: HFS answers409for a submission it has already closed and the engine treats that as "already done". One manifest per object was the alternative: the receipt mapping is marginally simpler, but a nightly export of twenty files would open twenty submissions against HFS's per-tenant cap on concurrent submissions (four by default,429beyond), and the operator would have twenty statuses to watch for one arrival. Failure granularity is not what decides this: within a manifest, HFS reports per line, and a truncated file fails only that file.What stays the same across both delivery paths: the records, their states, the API, the alerts and the archive. What differs is confined to the deliverer, which on this path is asynchronous: it hands work to HFS and collects the answer later. The trait surface below reflects that.
The Architectural Tensions
Business keys come from the source, or not at all. Idempotency needs a key that identifies the same real-world event every time it is sent: an accession number plus a test code, an encounter identifier, a message control id, or the stable
idan exporting system already assigned. The prototype's example key for the lab CSV (patient|code|date) is a placeholder that would merge two same-day results. Every feed definition must say where its key comes from, and a transform that cannot produce one must reject the record rather than invent one.Transform isolation against throughput. A transform written by an analyst can hang, crash or loop. Running it in the engine's process means it can take the engine down; running it in a separate process costs a round trip per record. The prototype runs a long-lived Python worker per feed and exchanges one JSON line per record; three Synthea patients (3,550 resources) went through it to HFS in 17 seconds on a laptop, and the protocol can carry batches when that is not enough.
One Python runtime, or a shared one. Transforms are Python. The analytics side of the product also runs Python. Making this one shared service that both halves call into is worth pursuing: the engine's transform protocol is already a request/response over JSON, so the worker can be a subprocess today and an HTTP or queue-backed service tomorrow without changing the trait. The decision can wait until the shared service exists; the engine should not block on it.
Run it yourself against run it managed. HFS made a choice that worked: the same trait surface serves an embedded single-instance deployment and a fleet, and the operator picks. The engine should do the same. The risk of spending years on thin layers over every cloud product is real; the defence is to keep the set of pluggable points small (source, store, notifier, delivery) and to ship one embedded implementation and one managed implementation of each, no more.
How deep to go. What we will not build: visual channel design, drag-and-drop mapping, no-code data preparation, a catalog of SaaS connectors, CDC replication, stream processing. Those are products. What we will build is the thin, reliable path from a handful of source types to HFS, with the operational properties above. Anyone who needs Kafka in the middle can put it there; the engine reads from a queue as readily as from a bucket.
Single-Instance vs Multi-Instance
Single instance: zero configuration
One process. Sources are polled. The record store is an embedded SQLite file in WAL mode, which is both the queue and the history. Transform workers are subprocesses. Delivery happens in-process; on the
$bulk-submitpath the engine serves manifests itself. The API and metrics are served by the same process. This is the prototype, and it is what every developer, every demo and every single-VM customer runs.Multi instance: shared state, work pool
Several engine instances, possibly a separate worker tier. The record store is PostgreSQL, which is also the planned store for the rest of the product. The queue is a table: workers claim records with
SELECT ... FOR UPDATE SKIP LOCKEDunder a lease with a heartbeat, the same claim strategy the bulk export discussion proposes for HFS, and for the same reason. Object sources are read from S3, and manifests are written there. Arrival can be notified instead of polled: an S3 event to SQS or EventBridge wakes a worker, with polling kept as the safety net. The Python runtime may be the shared service.A table-as-queue in PostgreSQL carries a lab's volumes comfortably and keeps the queue and the history in one transaction, which a separate broker cannot. SQS fits as the notification channel, where at-least-once delivery and a visibility timeout are exactly what is wanted, rather than as the record queue. Kafka and NATS are not proposed: the workloads here are files and batches, not streams, and the operational cost is not justified by the first several customers.
The point, as with HFS, is that the handler code does not change between the two. The traits below are designed so that the embedded and the managed implementations are the only things that differ.
The Recommendation
object_store, already a dependency of HFS, covers S3, S3-compatible stores and an in-memory store for tests with one interfaceSKIP LOCKEDleases$bulk-submitpointing at the source objectshelios-auth; the engine must not be an anonymous writer/metrics, OTLP tracesWhat is deliberately left out of the table: a message broker between steps, a workflow orchestrator, and a visual designer.
Designing the Rust Traits
The prototype implements these as concrete types in one crate. This section proposes the trait surface that lets each concrete type be replaced. Names are proposals.
Types
Source
A source lists what is new, fetches it, and acknowledges it once the engine is done with it. Polling and notification are both expressed through
wait: the folder implementation sleeps for the poll interval, the S3 implementation waits on an SQS receive with the poll interval as a timeout. For the$bulk-submitpath the source also mints a URL that HFS can fetch an object from.Implementations:
FolderSourceandS3Source(both in the prototype, the latter throughobject_store), laterFhirSource(another FHIR server, driven by$exportor history) andDatabaseSource.Reader
Cutting an object into records is independent of where the object came from. A reader never fails an object because of one row.
Implementations in the prototype:
CsvReader,NdjsonReader, andBundleReader, which yields one record per entry and rewritesurn:uuidreferences between entries toType/idso the entries can be delivered independently. Later:Hl7v2Reader.RecordStore
The store is the queue, the history and the coordination point. Everything the API serves comes from it, and everything a worker does goes through it. The multi-instance implementation adds leases; the single-instance one satisfies them trivially.
Implementations:
SqliteStore(prototype),PostgresStore(leases viaSKIP LOCKED, the same shape asBulkExportStorageclaims in HFS).Transformer
A transform is a function from one record to resources, run somewhere else. The trait hides where. A transformer failing (as opposed to rejecting a record) is a transient condition: the record stays claimed and is retried. A pass-through feed has no transformer; its records go from
ReceivedtoPendingafter the engine's own check.Implementations:
PythonSubprocess(prototype; JSON lines over stdin/stdout, restarted on failure),PythonService(the shared runtime over HTTP, same messages), laterFhirMappingLanguage.Deliverer
Delivery takes a batch of records with their resources and keys. The REST path answers at once; the
$bulk-submitpath answers later, so the trait has two steps and the runner polls open submissions on every pass.Implementations:
RestBatchDeliverer(prototype:PUTentries in abatchBundle; a refused Bundle is re-sent one item at a time to isolate the bad one;pollis never called),BulkSubmitDeliverer(groups a drop's objects into manifests that point at the source objects or at the engine's rewritten copies, posts$bulk-submit, and maps HFS's per-line receipts back to records).AlertSink
The engine decides when (once per condition, with recovery) and what (feed, object, count, first reason). The sink decides where: the log, a webhook, later a product. Who is on call is not the engine's concern.
The runner
One
FeedRunnerper feed ties the traits together in a loop whose steps each pick up what the previous pass left: wait on the source, admit new objects, transform claimed records, deliver due records, poll open submissions, complete and acknowledge objects, check silence. A step that fails is simply retried on the next pass, which is what makes the loop safe to restart at any point. In a multi-instance deployment several runners for the same feed coexist because every claim goes through the store's lease.The API
The engine's HTTP API is what a client application calls to render a data-sources page, and what an operator uses to find and fix a rejected row. The shape below is what the prototype serves; the exact needs of the client application are still being gathered, and this section will grow with them.
GET /api/feedsok,silent,no_data_yet), counts by state, last object, last alertGET /api/feeds/{id}/objectsGET /api/records?feed=&status=&object=GET /api/records/{id}POST /api/records/{id}/reprocessGET /api/alertsGET /metricsThe API is protected by the same bearer-token validation HFS uses, from
helios-auth.Configuration: What Operators Will Touch
An engine configuration names HFS, the store, the listen address, the transform runtime and the alert webhook. A feed is one file:
Feeds and transforms are files under version control. Mapping rules are agreements with a data source; they deserve history and review. A UI that edits them should do so through an API that writes these files, not around them.
Environment variables follow the HFS convention:
HIE_CONFIG,HIE_LOG,HIE_RUNTIME_DIR, and laterHIE_STORE_URL,HIE_AUTH_*.Authorization
The engine is a client of HFS and a server to its own API, and both sides use the identity provider.
client_credentialswithprivate_key_jwt), requestingsystem/*.writeor the narrower scopes a feed needs, plussystem/bulk-submiton the bulk path. Tokens are cached and refreshed per feed. On the bulk path HFS reads the files through pre-signed URLs and needs no credential of its own for them; HFS redacts the signed query string wherever it reports a file URL.helios-auth. Reading feed status and reprocessing a record are different permissions.The engine never runs as an anonymous writer, and a feed's identity in HFS audit events should be the feed's client, not a shared service account.
What's Not in Scope (Yet)
ConceptMap/$translate). The example transform carries a local code table; the real one calls HTS.Patientreference. This is its own design problem.$validate.Proposed Next Steps
BulkSubmitDelivereras described above:shareon the source, manifests per drop, thesubmittedstate, receipt mapping by (object, line), replacement manifests for failed files, and acknowledgement after close-out. Measure the two paths on the same export.PostgresStorewith leases, with a testcontainers integration test that runs two runners against one feed.PythonServicebehind the same protocol if it goes that way.Closing Thoughts
The products in this space are large because they try to be the whole pipeline for every industry. What a FHIR platform needs from an integration engine is narrower and harder to get right: a short, dependable path from a few kinds of source into the server, where nothing is lost, nothing is duplicated, every rejection can be explained to the person who owns the data, and silence is noticed.
The trait surface proposed here is small on purpose: a source, a reader, a store, a transformer, a deliverer, an alert sink. Each has one embedded implementation and one managed implementation, and the runner does not know which it holds. That is the same bet HFS made with its persistence layer, and it is what lets the same engine run on a laptop against SQLite and in a fleet against PostgreSQL, S3 and a shared transform service without rewriting the loop in the middle.
Thank you for reading. Feedback, objections and better names are all welcome.
All reactions