Skip to content

♻️ refactor: rename toFHIR → Ignifyr and split the reactor into Community/Enterprise editions - #301

Open
sinaci wants to merge 40 commits into
mainfrom
ignifyr-rename-whole
Open

♻️ refactor: rename toFHIR → Ignifyr and split the reactor into Community/Enterprise editions#301
sinaci wants to merge 40 commits into
mainfrom
ignifyr-rename-whole

Conversation

@sinaci

@sinaci sinaci commented Jul 24, 2026

Copy link
Copy Markdown
Member

Consolidated rename + edition-split branch — both touch the same files, so they land together.

Rename (clean break): packages io.ignifyr.*, coordinates io.ignifyr:ignifyr-*, HOCON ignifyr.*, Docker srdc/ignifyr-*, docs; version → 2.0-SNAPSHOT. Legacy tofhir intentionally survives only in the srdc/tofhir-web image, the tofhir-redcap sibling service, the SwaggerHub link, and git history.

Edition split: the reactor is now module-separable into a public Community Edition (ignifyr-cli + engine/common/sql/file connectors, Apache-2.0) and a private Enterprise Edition (ignifyr-server + fhir-server/kafka/json/delta/streaming/scheduling/redcap/observability/terminology). Everything plugs in through the IgnifyrExtension ServiceLoader SPI — the engine never names a connector or format directly. A maven-enforcer bannedDependencies gate keeps enterprise-only libs out of community modules. Moving a feature between editions is a one-folder move with zero engine changes.

This is the module-level split only. Physical extraction to ignifyr-enterprise is Phase 2, not this PR.

Testing: mvn -B verify green. ⚠️ Requires JDK 11 — under JDK 21 the Spark reflective path surfaces as a spurious IllegalAccessError in ExtensionRegistrySpec; unsupported-JDK symptom, not a defect.

33 commits · 496 files · +18,952 / −11,381

sinaci and others added 30 commits July 9, 2026 09:39
…g, and docs.

Wholesale rename of the legacy tofhir name: Maven coordinates (io.onfhir:tofhir-* -> io.ignifyr:ignifyr-*),
packages (io.tofhir.* -> io.ignifyr.*), ToFhir* classes -> Ignifyr*, module directories, HOCON namespaces
(ignifyr / ignifyr-redcap), REST base path /ignifyr, ignifyr-db folder, Docker images srdc/ignifyr-*,
IGNIFYR_* env vars, log/logger names, docs and agent guides. Migration notes added to README.
Kept until sibling repos rename: srdc/tofhir-web image, tofhir-redcap endpoint value and repo links,
SwaggerHub toFHIR docs link.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ries

Add io.ignifyr.engine.spi: an aggregate IgnifyrExtension SPI discovered via
java.util.ServiceLoader, an ExtensionRegistry indexing contributions by model class,
and provider contracts for source connectors, sinks, terminology/identity services and
CLI commands. The engine's own implementations register through the same mechanism via
CoreExtension (META-INF/services), so moving a feature between editions becomes a module
move with no engine code change.

Convert the four hard-coded dispatch sites to registry lookups (implementations unchanged,
all still in-engine): DataSourceReaderFactory (removed; SourceHandler queries the registry
and raises an actionable MissingConnectorException), FhirWriterFactory and
IntegratedServiceFactory (registry-backed facades), CommandFactory (registry-backed). Job
JSON still parses everywhere because all settings/model classes remain in the engine.

Fold in warts: delete the unimplemented AVRO content-type stub, make the dead --db CLI flag
work (also accept --db-path), default spark.app.name to "Ignifyr", drop the unused
org.reflections direct dependency, and remove the stale ignifyr-server-common_2.13
dependencyManagement entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ne jar

The plugin split makes the engine a library that connector modules depend on, so the
engine can no longer bundle those connectors into its own shaded jar (that would be a
dependency cycle). Introduce ignifyr-cli: a downstream assembly module that depends on
the engine (and, as they are extracted, the community connector/format plugins) and
produces the shaded ignifyr-engine-standalone.jar (Main-Class io.ignifyr.engine.Boot,
ServicesResourceTransformer merges every bundled module's META-INF/services).

Remove the shade execution from ignifyr-engine (it stays a plain library + test-jar).
Point the docker/engine build files and CLAUDE.md at ignifyr-cli/target for the jar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…in module

Move SqlSourceReader out of the engine into a new ignifyr-connector-sql module
(package io.ignifyr.connector.sql), registered via the IgnifyrExtension SPI and
ServiceLoader (SqlConnectorExtension + META-INF/services). The engine drops the reader,
its CoreExtension registration, and the PostgreSQL/DB2 driver dependencies; JDBC drivers
now live with the connector (PostgreSQL bundled; other drivers added by the distribution).
ignifyr-cli depends on the new module so the community standalone jar still bundles it.

A lightweight ServiceLoader registration spec verifies discovery. The full-pipeline
SqlSourceTest (H2 + onFHIR container) is parked pending the Phase 2 testkit, which will
share the engine's folder-based test fixtures across modules; the reader logic is an
unchanged relocation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ts own plugin module

Move FhirServerDataSourceReader out of the engine into a new (enterprise-bound)
ignifyr-connector-fhir-server module (package io.ignifyr.connector.fhirserver), registered
via the IgnifyrExtension SPI + ServiceLoader. The engine drops the reader, its CoreExtension
registration, and the io.onfhir:fhir-api-spark-source dependency — which also lifts that
spark-on-fhir SNAPSHOT out of the community engine's dependency path.

The server (the enterprise runtime that executes jobs and infers schemas) now depends
explicitly on ignifyr-connector-sql and ignifyr-connector-fhir-server, since it no longer
gets those readers transitively through the engine.

A lightweight registration spec verifies discovery; the full-pipeline FhirServerSourceTest
(onFHIR container) is parked for the Phase 2 testkit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…module

SchedulingTest loads test-schedule-mappingjob.json, whose job uses a SQL source. With the
SQL reader now in ignifyr-connector-sql (which the engine's own test classpath cannot depend
on — reactor cycle), the test fails at runtime with the actionable MissingConnectorException.
Park it (preserved in git history) and re-home it in the Phase 1.5 scheduling module
(ignifyr-runtime-scheduling), which can depend on ignifyr-connector-sql without a cycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…om the engine

Move KafkaSourceReader out of the engine into a new (enterprise-bound) ignifyr-connector-kafka
module (package io.ignifyr.connector.kafka), registered via the IgnifyrExtension SPI. The Spark
Kafka connector (spark-sql-kafka-0-10) and the Kafka client libraries leave the community engine
with it.

Introduce a StreamingFailureDescriptor SPI hook: RunningJobRegistry no longer imports
org.apache.kafka UnknownTopicOrPartitionException or casts KafkaSource — on a streaming query
failure it asks the registered descriptors to translate the error, and the Kafka connector
provides the descriptor that names the missing topic(s). Streaming-query tracking itself stays
in the engine (plain Spark SQL).

The server (enterprise runtime) depends on the new connector. A registration spec verifies both
the source connector and the failure descriptor are discovered; KafkaSourceIntegrationTest is
parked for the Phase 2 testkit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nstallable capability

Introduce a StreamingExecutionProvider SPI and a MappingTaskPipeline seam in the engine, and move
Spark structured-streaming execution into a new (enterprise-bound) ignifyr-runtime-streaming module:
StreamingSinkHandler (the foreachBatch wrapper over the engine's batch SinkHandler.writeMappingResult)
and StreamingJobExecutor (the former FhirMappingJobManager.startMappingJobStream body).

FhirMappingJobManager now implements MappingTaskPipeline and its startMappingJobStream delegates to
the installed streaming provider, throwing a clear MissingCapabilityException when none is present —
so the community CLI cleanly refuses streaming jobs while the enterprise server (which depends on the
new module) runs them. SinkHandler.writeStream is removed from the engine; IgnifyrEngine only starts
the file-stream archiver timer when a streaming runtime is installed.

The streaming SinkHandler test moves with the code (rate source + mock, no fixtures). FileStreamingTest
is parked for the file+formats+testkit batch (it needs both the streaming runtime and the file connector).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…allable capability

Move cron4j-based periodic scheduling out of the engine into a new enterprise
ignifyr-runtime-scheduling module behind a SchedulerProvider SPI, mirroring the
streaming seam. The community engine now carries no scheduling dependency; a job
with schedulingSettings and no provider fails with a clear MissingCapabilityException.

Engine (community):
- New SPI SchedulerProvider + IgnifyrExtension.schedulerProvider + ExtensionRegistry.scheduler
  (at-most-one, like the streaming capability).
- FhirMappingJobManager loses its mappingJobScheduler ctor param and scheduleMappingJob/
  runnableMappingJob/getScheduledTimeRange; IFhirMappingJobManager loses scheduleMappingJob.
- RunningJobRegistry sheds the scheduling half (scheduledTasks + registerSchedulingJob/
  descheduleJobExecution/isScheduled/getScheduledExecutions + the DESCHEDULED shutdown hook);
  handleCompletedBatchJob is now public so the module's scheduler listener can call it.
- MappingJobScheduler deleted; cron4j dependency removed from the engine pom.
  (mvn dependency:tree -pl ignifyr-engine | grep cron4j -> empty)

Enterprise module (ignifyr-runtime-scheduling):
- Cron4jSchedulerProvider: cron validation, SQL/plain start-time, last-sync bookkeeping,
  and the scheduling runnable (the old FhirMappingJobManager scheduling body + MappingJobScheduler).
- ScheduledJobRegistry: the scheduled-tasks map + register/deschedule/isScheduled/
  getScheduledExecutions + the whenTerminated DESCHEDULED logging.
- SchedulingRuntimeExtension registers the provider via ServiceLoader.
- Lightweight no-Docker SchedulingRuntimeExtensionSpec (discovery + empty-registry state).

Callers rewired onto ExtensionRegistry.scheduler: server ExecutionService (runJob scheduling
branch + getExecutions + descheduleJobExecution) and CLI CommandLineInterface.runJob. Server pom
gains the module (server = enterprise runtime); ignifyr-cli does not (scheduling is enterprise).

Behavior notes (both intended, verified benign): the server no longer requires ignifyr.db for
non-scheduled jobs (an unconditional, mislabeled guard was removed); CLI-scheduled jobs now
register with the running-job registry and archive per fire, unifying them with the server path.

The Docker/SQL/onFHIR SchedulingTest stays parked and re-homes with the testkit batch
(IgnifyrTestSpec builds repos from folder URIs that do not survive test-jar consumption).

Full mvn -B verify green (all 12 modules, Docker).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…to its own module

Move the offline OMOP terminology-mapping generator (Concept, GeneratorDBAdapter,
TerminologyMappingGenerator) out of the engine into a new enterprise
ignifyr-terminology-tools module (package io.ignifyr.tools.terminology). It is standalone
tooling with its own main — not on any runtime path — and is kept out of the community
distribution because it targets a local OMOP Postgres database with hard-coded dev
connection details (GeneratorDBAdapter).

- New module ignifyr-terminology-tools depends on ignifyr-engine (for
  LocalTerminologyService.ConceptMapFileColumns), jackson-dataformat-csv, and the
  PostgreSQL driver. Not a dependency of the CLI or the server (offline tool).
- TerminologyMappingGeneratorTest re-homes cleanly: it uses only mockito (no IgnifyrTestSpec,
  no Docker), so it runs as a normal module unit test.
- jackson-dataformat-csv stays in the engine (still used by CsvUtil + LocalTerminologyService).

Engine unit 84/84 (was 86; the 2 generator tests moved to the new module); new module 2/2;
whole reactor (13 modules) builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… enterprise module

Move the structured-log encoder (MapMarkerToLogstashMarkerEncoder) and the Logstash/Fluentd
dependencies out of the community engine into a new enterprise ignifyr-observability module
(package io.ignifyr.observability.logback). The community engine now logs plain console +
rolling files; structured (Logstash JSON) audit encoding and Fluentd shipping to the EFK stack
are an enterprise concern, referenced from the server's logback.xml.

- New standalone module ignifyr-observability (no engine dependency) carrying logback-classic,
  logstash-logback-encoder, logback-more-appenders, and fluent-logger.
- Engine pom drops logstash-logback-encoder (encoder moved) and fluent-logger (Fluentd transport,
  used only by the server's DataFluentAppender). It KEEPS logback-more-appenders: ExecutionLogger
  and the mapping-result models use its MapMarker type to attach structured data to log events
  (community logging); only the JSON encoding + shipping that consume those markers are enterprise.
- Engine logback.xml: the mapping/execution audit appender (FILE-AUDIT) now uses a plain
  PatternLayoutEncoder instead of LogstashEncoder + the MapMarker encoder, and the dangling
  FLUENT / ASYNC-AUDIT-FLUENT appender-refs (never defined in the engine config) are removed.
- Server depends on ignifyr-observability (dropping its own fluent-logger + logback-more-appenders)
  and its logback.xml (main + test) references the encoder at its new io.ignifyr.observability.logback
  package.

Whole reactor (14 modules) builds; full mvn -B verify green (Docker), including the server boot
that initializes the Fluentd/Logstash logback pipeline from the observability module.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ispatch seam

- New io.ignifyr.engine.execution.MappingJobLauncher: the one home of the
  batch / streaming / scheduled 3-way dispatch. CommandLineInterface.runJob is
  rewired onto it (the server's ExecutionService follows next). Every launch now
  registers with RunningJobRegistry, so 'run --job' batch executions gain
  execution tracking + post-batch archiving (same deliberate improvement the
  scheduled CLI path got in the scheduling extraction). Streaming now wins over
  schedulingSettings on the (nonsensical) combination of both, matching the
  server's precedence.
- Model virtual dispatch instead of type matches: MappingSourceBinding gains
  withPreprocessSql and FhirMappingTask.substituteBatchParameters (dedupes the
  copies in FhirMappingJobManager and the server); MappingJobSourceSettings
  gains withAsStream so callers can force batch reads without matching on
  KafkaSourceSettings.
- SPI: StreamingFailureDescriptor renamed to SourceFailureDescriptor
  (describeStreamingFailure) + new defaulted describeBatchTaskFailure hook; the
  Kafka connector now describes unknown-topic failures for ad-hoc task runs too.
- SPI: new SourceSchemaInferrer trait + ExtensionRegistry.schemaInferrers
  (keyed by settings class, forced at init) for connector-native schema
  inference; the SQL connector contributes one in the next commit.
…to the extension SPI

- ExecutionService.runJob delegates its batch/streaming/scheduled dispatch to
  the engine's MappingJobLauncher (checkpoint clearing included); only the
  HTTP-level guards (already-running, already-scheduled) stay in the server.
- testMappingWithJob no longer imports Kafka types: streaming sources are
  forced to batch via the model-level withAsStream(false) (now also covering
  streaming file sources, which previously slipped through as unbounded
  streams), and unknown-topic failures are described through the connectors'
  SourceFailureDescriptor.describeBatchTaskFailure hook.
- SchemaDefinitionService.inferSchema dispatches through
  ExtensionRegistry.schemaInferrers instead of matching on SqlSourceSettings;
  the JDBC-metadata inference (getTableSchema/mapSqlTypeToSpark) moves from
  the engine's SchemaConverter into the SQL connector's new SqlSchemaInferrer.
- Drops the server's duplicated substituteBatchParameters (model method now).

Server suite green: 14 suites, 96 tests, 0 failures (Docker/onFHIR included).
…er-extension SPI

- New IgnifyrServerExtension SPI in ignifyr-server-common (ServiceLoader-
  discovered, like the engine's IgnifyrExtension): modules contribute root
  routes, schema-import routes (persisting through a narrow SchemaImportSink),
  and the version of the external component they integrate (for /metadata).
  IgnifyrServer/IgnifyrServerEndpoint/ProjectEndpoint/SchemaDefinitionEndpoint/
  MetadataService are rewired onto the discovered extensions; the hard-coded
  Option[RedCapServiceConfig] threading is gone.
- New enterprise ignifyr-redcap module carrying the whole REDCap surface:
  the /redcap proxy endpoint+service+config (gated on the unchanged
  'ignifyr-redcap' HOCON block), the /projects/{id}/schemas/redcap
  data-dictionary import, RedCapUtil, and the extract-redcap-schemas CLI
  command — contributed through BOTH SPIs (engine CliCommandProvider + server
  extension). The server depends on it (enterprise runtime); the community CLI
  does not and now reports the providing module for the unknown command.
- Engine Boot loses its REDCap string branch: any non-built-in command token
  resolves through ExtensionRegistry.cliCommands, with flags translated to
  positional args by the new CliCommandProvider.argsFromOptions hook;
  nextArg parses generic --flag value pairs; Help appends extension-contributed
  helpText lines instead of hard-coding the REDCap usage.
- RedCapUtil finally gets a direct unit test (module spec, no Docker); the
  endpoint-level REDCap import stays covered by SchemaEndpointTest.

Gates green: engine 84, redcap 4, server 96 tests (Docker/onFHIR included).
The flag's only behaviour was gating environment-variable resolution of the
Kafka sourceUri; the resolver now resolves it unconditionally (topic names
already were). The custom Kafka settings serializer ignores an obsolete
"asRedCap" key in existing job JSONs, so they keep parsing everywhere.
README's RedCAP section and api.yaml drop the field accordingly.

Full `mvn -B verify` green across the 16-module reactor (Docker included);
community CLI smoke: extract-redcap-schemas reports the ignifyr-redcap module
to install; server fat jar merges both extension SPI service files.
SinkContentTypes lived as a nested object inside the FileSystemWriter
companion (the write layer), yet the model class FileSystemSinkSettings
imported it back from there — a model->write back-edge that would become a
reactor cycle once FileSystemWriter is extracted into ignifyr-connector-file.

Promote SinkContentTypes to a top-level object in io.ignifyr.engine.model
(mirroring SourceContentTypes, already there), delete the now-empty
FileSystemWriter companion, and repoint every consumer (the writer, the
engine writer test, and five server test suites) at the model FQN. The
DELTA_LAKE="delta" constant stays in the community model so any job JSON
naming contentType="delta" keeps parsing everywhere; only the Delta write
handler + delta-spark dep migrate to enterprise in a later step.

No behavior change.
…dule

Create ignifyr-testkit and relocate the cross-module test harness out of the
engine so downstream plugin modules can reuse it without the engine forming a
reactor cycle (engine tests must never depend on a module that depends on the
engine).

testkit (src/main) now owns IgnifyrTestSpec, OnFhirTestContainer and the shared
classpath fixtures (test-mappings, test-schemas, terminology-service,
test-data/loop.json). IgnifyrTestSpec's folder-repository wiring is made
jar-safe: a fixture folder served as a jar: URL is materialized to a temp
directory first, so consumption works both inside the reactor (file: on
target/classes) and against a published testkit jar (where new File(jar:...)
would otherwise throw). Its test-support libraries (scalatest, mockito, h2,
testcontainers) are compile-scope so a single test-scope dependency on the
testkit gives a consumer everything transitively.

The three pure-engine behavioural suites that used the harness
(FhirMappingFolderRepositoryTest, FhirPathMappingFunctionsTest,
LocalTerminologyServiceTest) move to the testkit's own tests.
ExtensionRegistrySpec is decoupled from IgnifyrTestSpec (it only needed the
shared Spark session) and stays in the engine. FhirMappingJobManagerTest is
parked (it needs both the harness and the file connector); it is re-homed into
ignifyr-connector-file when that module is extracted.

Retire the engine test-jar: drop the maven-jar-plugin test-jar goal and the
root test-jar dependency entry, and point the server's test scope at the
testkit (its sole use of the old test-jar was OnFhirTestContainer).
…ngTest

With the shared testkit in place, the two heavy fixture-driven suites parked
during the connector-sql / scheduling extractions return to their owning
modules, each in the io.ignifyr.integrationtest package and run in the
integration-test phase (a test/integration-test scalatest split keeps `mvn
test` Docker-free; the lightweight registration specs stay in the unit phase).

SqlSourceTest -> ignifyr-connector-sql, verbatim (its named arguments already
match the concrete FhirMappingJobManager signature); its SQL fixtures +
test-sql-mappingjob.json move with it.

SchedulingTest -> ignifyr-runtime-scheduling, rewritten onto the extracted
Cron4jSchedulerProvider SPI: the provider owns and starts cron4j internally, so
the test schedules through scheduleMappingJob(...) and tears down via
descheduleJobExecution(...) instead of managing a Scheduler and the removed
MappingJobScheduler / 6-arg manager. It depends on connector-sql (test) for the
SqlSource reader and writes scheduler state under a temp directory.

Both modules gain a test application.conf; the shared mappings/schemas come from
the testkit. The engine's orphaned SQL/scheduling fixtures move out with them.
…ble format sub-SPI

Move FileDataSourceReader and FileSystemWriter out of the engine into the new
community ignifyr-connector-file module (package io.ignifyr.connector.file),
contributed through the IgnifyrExtension SPI. CoreExtension no longer registers
the file source/sink; the engine now carries no file-connector code.

Introduce a connector-local file-format sub-SPI, owned by connector-file and
discovered through its own ServiceLoader so the engine stays ignorant of file
formats:
  - FileSourceFormat / FileSinkFormat traits + FileFormatRegistry (keyed by
    content type, fail-fast on duplicates) + FileSinkSupport (the shared
    partition-by-resource-type / HDFS write machinery) + FileFormatHints /
    MissingFileFormatException (actionable "install ..." errors).
  - The reader/writer become thin: the reader resolves the path, validates the
    streaming directory, adds the streaming filename-logging column and applies
    `distinct`, then delegates the per-content-type read; the writer just
    dispatches to the sink format for its content type.
  - Community formats ship here: csv/tsv/parquet source and ndjson/csv/parquet
    sink (FHIR bulk output). JSON/NDJSON *source* and the Delta *sink* handlers
    live here for now too; they are split into the enterprise format-json /
    format-delta modules in the following steps.

connector-file is bundled into ignifyr-cli (community) and ignifyr-server
(enterprise runtime). The file reader/writer suites move here
(io.ignifyr.connector.file, unit) with the unsupported-content-type expectation
updated to MissingFileFormatException and the writer test's accidental
delta-spark encoder import replaced by the standard Spark encoders; the
onFHIR-backed FhirMappingJobManagerTest is un-parked into
io.ignifyr.integrationtest with the file fixtures. ExtensionRegistrySpec drops
its file source/sink assertions (now covered by FileConnectorExtensionSpec).
…se module

Move the JSON/NDJSON file *source* format handler out of the community file
connector into the new enterprise ignifyr-format-json module (package
io.ignifyr.format.json). It contributes a FileSourceFormat through the
connector's format sub-SPI via its own META-INF/services entry, so JSON/NDJSON
files can be used as mapping input only when this module is installed; the
community file connector now discovers just csv/tsv/parquet sources. The
community file *sink* still writes NDJSON — only the source side is enterprise.

The module depends on ignifyr-connector-file (the FileSourceFormat sub-SPI it
implements) and is bundled into ignifyr-server (enterprise runtime), not the
community CLI. The JSON/NDJSON read cases + their fixtures move from the
connector's FileDataSourceReaderTest into ignifyr-format-json's
JsonSourceFormatTest; the connector's reader test keeps the csv/tsv/parquet/zip
cases.
…Spark wiring

Move the Delta Lake file *sink* format out of the community file connector into
the new enterprise ignifyr-format-delta module (package io.ignifyr.format.delta),
carrying the delta-spark dependency with it, and make the Delta Spark-session
wiring contributed rather than hardcoded — completing the removal of Delta from
the community edition.

New sparkConfContributions SPI hook: IgnifyrExtension gains a
sparkConfContributions accessor; ExtensionRegistry aggregates it across installed
modules (comma-concatenating the additive spark.sql.extensions, failing fast on
any other conflicting key); IgnifyrConfig.createSparkConf folds it in below the
user's `spark { }` config. The two hardcoded Delta entries
(spark.sql.extensions=DeltaSparkSessionExtension, spark.sql.catalog.spark_catalog
=DeltaCatalog) leave sparkConfDefaults and are now contributed by
ignifyr-format-delta's DeltaFormatExtension, so the community engine builds its
SparkSession with no Delta wiring and no delta-spark on the classpath.
delta-spark is removed from the engine pom and declared in ignifyr-format-delta.

The Delta writer contributes a FileSinkFormat via the connector's format sub-SPI
(its own META-INF/services entry); the community connector now discovers only
ndjson/csv/parquet sinks. ignifyr-format-delta is bundled into ignifyr-server
(enterprise), not the community CLI. The Delta write cases move from the
connector's FileSystemWriterTest into ignifyr-format-delta's DeltaSinkFormatTest;
the sizeable shared FhirMappingResult test dataset is extracted to the testkit
(FhirMappingResultFixtures) so both modules use one copy instead of duplicating it.
…fail-fast

Follow-ups from an adversarial review of the batch (all low severity; behaviour
and edition-boundary review came back clean):

- Surface the "install the '…' module" hint for a missing file format as the
  top-level error instead of burying it. MissingExtensionException is un-sealed
  and MissingFileFormatException now extends it, so SourceHandler's read path
  rethrows any MissingExtensionException as-is (matching the connector-missing
  and write-path behaviour) rather than wrapping it in a generic
  "Source cannot be read" FhirMappingException.

- Fail fast on a duplicate file-format registration: FileConnectorExtension.
  initialize() forces FileFormatRegistry to materialize at engine startup (it
  only reads the classpath), mirroring ExtensionRegistry.init(), so a duplicate
  content-type provider errors at boot instead of mid-job.

- Cover the install-hint branch: FileConnectorExtensionSpec now asserts that an
  uninstalled JSON source / Delta sink raises MissingFileFormatException naming
  the exact enterprise module coordinates (previously only the generic
  "unsupported" path was exercised, with no message assertion).

- Document the real re-entrancy trap on IgnifyrExtension.initialize(): it can run
  while the shared SparkSession is mid-build (via sparkConfContributions), so it
  must not touch IgnifyrConfig.sparkSession — the caveat previously lived only on
  sparkConfContributions.
The suite asserted a hardcoded folder/file count against the shared context
root (base_path="."), so its children count depended on whatever transient
folders other suites (repository dirs, logs, checkpoints) leave in the context
root during a full reactor run — producing intermittent off-by-one failures.

Point the test at a uniquely-named fixture directory it creates inside the
context root and tears down in afterAll, with a known layout (3 folders + a
top-level file + a nested file). The assertions are now deterministic and
order-independent regardless of what else lives in the module working dir.
`list-plugins` (one-shot `java -jar ignifyr-engine-standalone.jar list-plugins`
or interactive) enumerates the installed IgnifyrExtension modules discovered
through ServiceLoader and everything each contributes — source connectors,
sinks, terminology/identity services, CLI commands, schema inferrers,
streaming/scheduling capabilities and Spark-conf keys. It is the
machine-checkable form of "which plugins ship in this distribution?", a CI gate
on the community vs enterprise edition boundary.

Boot dispatches it without constructing the engine (it reads only the registry,
so no workspace or mapping-job config is needed just to inspect the plugins).

Adds a defaulted `IgnifyrExtension.extraCapabilities` display hook for
contributions the engine's typed registries cannot introspect on their own; the
file connector overrides it to surface its own file-format sub-registry (the
discovered source/sink formats), so those appear in the listing too.
Add a maven-enforcer `bannedDependencies` rule (defined once in the root
pluginManagement as the `ban-enterprise-deps` execution) and opt every community
module into it (engine, common, connector-sql, connector-file, cli, testkit).
The rule fails the build if a community module pulls in a library that belongs
to an enterprise-only module: Kafka (connector-kafka), cron4j (runtime-scheduling),
Delta Lake (format-delta), the DB2 JCC driver, or Logstash/Fluentd log shipping
(observability). searchTransitive catches indirect pulls too.

logback-more-appenders is deliberately NOT banned — the community engine uses its
MapMarker type for structured logging; only the Logstash JSON encoder and Fluentd
shipping are enterprise. Enterprise modules simply do not opt in, so they use
these libraries freely. Makes an edition-boundary regression a build failure
rather than a silent leak into the community distribution.
sinaci added 3 commits July 23, 2026 12:07
The root agent guide predated the split and named only 5 of the 18 modules.
Rewrite its Modules section to group all modules by edition (community vs
enterprise) with the IgnifyrExtension SPI backbone, the file-format sub-SPI,
`sparkConfContributions`, and the maven-enforcer boundary gate.

Fix the engine guide's stale claims: `FileDataSourceReader`/`FileSystemWriter`
now live in ignifyr-connector-file (with a pluggable FileSourceFormat/FileSinkFormat
sub-SPI; JSON-source and Delta-sink are the enterprise format-* modules), heavy
fixture suites use ignifyr-testkit, and note the new `list-plugins` command.
Sync the docs with the community/enterprise split now that Phase 1 has landed.

README: replace the stale flat module list with an "Architecture & Editions"
section — the engine-plus-plugins model, the IgnifyrExtension ServiceLoader SPI
backbone, the read -> map -> sink -> archive data flow, an edition-grouped
module table, and pointers to the per-module guides, api.yaml, and the reference
application.conf. Add an edition caveat to Supported Data Sources.

Add CLAUDE.md agent guides for the significant split-era modules:
connector-file (the two-level format sub-SPI), testkit (jar-safe fixtures,
compile-scope test libs), runtime-streaming and runtime-scheduling (capability
providers), and redcap (dual engine+server SPI registration).

Refresh the existing guides for the split: server (its enterprise-distribution
identity and the SchemaImportSink / MetadataEndpoint / extension-load wiring),
server-common (the server extension SPI it now owns + the IgnifyrError
taxonomy), common (the mapping-function factory + edition/enforcer note), and
rxnorm (edition placement + the RxNormApiFunctionLibraryFactory seam).
@sinaci
sinaci requested review from KeremHmd and Okanmercan99 July 24, 2026 08:09
sinaci added 7 commits July 24, 2026 11:53
- ExtensionHints.describe: a missing sink no longer reads "No FHIR writer
  registered for source type ..."; the noun now matches the lookup kind.
- SinkHandler: the write-problems accumulator name mapped over the characters
  of mappingTaskName (String.map), producing a garbled name; plain
  interpolation instead.
- FileSinkSupport (connector-file): partition-by-resource-type now skips
  mapped results that carry no resourceType discriminator, with a warning —
  instead of writing a literal "null" directory (local path) or throwing on
  resourceType.get (HDFS path). Covered by a new FileSystemWriterTest case.
- Document FhirMappingResult.resourceType as the generic sink-routing
  discriminator (a FHIR resource type or a non-FHIR target/table name),
  never validated against FHIR.
…sink SPI naming

Sinks now mirror the source connectors — one module per sink, discovered
through the IgnifyrExtension SPI. The engine ships no concrete I/O at all:

- New ignifyr-sink-fhir (community): FhirRepositoryWriter moves out of the
  engine together with the FHIR-server-backed terminology/identity service
  providers keyed by FhirRepositorySinkSettings (the same server connection
  doubles as both). CoreExtension shrinks to the built-in CLI commands plus
  the local (CSV-backed) terminology service. No dependency change: the
  FhirRepositorySinkSettings model references onfhir-client types, so
  onfhir-client stays an engine dependency — this is a structural move.
- New ignifyr-sink-file (community): FileSystemWriter, FileSinkSupport and
  the FileSinkFormat sub-SPI (ndjson/csv/parquet handlers + its own
  ServiceLoader registry and install hints) move out of
  ignifyr-connector-file, which is now source-only. ignifyr-format-delta
  re-points its FileSinkFormat services entry and dependency to the new
  module.
- Sink SPI naming neutralized before the API freezes at the first Central
  release (the template output has always been opaque JSON; CSV/relational/
  OMOP targets are on the roadmap): FhirSinkSettings -> SinkSettings,
  BaseFhirWriter -> BaseSinkWriter, FhirWriterFactory -> SinkWriterFactory.
  Concrete model class names (FhirRepositorySinkSettings,
  FileSystemSinkSettings) are job-JSON discriminators and stay unchanged.
- ExtensionHints now points at io.ignifyr:ignifyr-sink-fhir / -sink-file;
  both modules ship in ignifyr-cli and ignifyr-server; the integration
  suites that write to onFHIR (connector-sql, connector-file,
  runtime-scheduling) gain a test-scoped ignifyr-sink-fhir dependency.
Empty ignifyr-sink-omop skeleton reserving the module and its ServiceLoader
registration for the upcoming "map to OMOP" feature: an OmopSinkSettings-keyed
writer typing rows against versioned OMOP CDM schemas (5.3/5.4/6.0) with
FK-ordered table writes, plus an OMOP-vocabulary-backed terminology service
(design recorded in the edition-split plan). Registers nothing yet; ships in
ignifyr-server only.
writePartitionedByResourceType special-cased hdfs:// and wrote newline-joined
raw JSON regardless of the content type, so a parquet or Delta sink on an
hdfs:// path silently produced .txt files (and ndjson lost numOfPartitions,
options and partitioningColumns). Spark's DataFrameWriter resolves hdfs://
natively, so the branch is deleted and one path serves every scheme.

Each resource type is now written from its own filtered Dataset, keeping the
payloads off the driver where the old grouped path collect_list-ed them all.
Comment thread docker/docker-compose.yml
- './data-integration-suite:/usr/local/ignifyr/conf'
- './ignifyr-docker-logs:/usr/local/ignifyr/logs'
ignifyr-web:
image: docker.srdc.com.tr/srdc/tofhir-web:latest

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we change tofhir-web:latest to ignifyr-web:latest?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once this image is pushed with its new name, yes we should change then.

@@ -52,17 +61,18 @@ object ToFhirConfig {
.setMaster(sparkMaster)

val sparkConfEntries =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spark.sql.extensions is a comma-separated list in Spark, and ExtensionRegistry has explicit
code to respect that — line 98 joins contributions from multiple modules instead of overwriting
them. But that merged value is thrown away here: ++ replaces on key collision, and the user's
spark { } block is merged last.

So if a user sets spark.sql.extensions for any unrelated reason (Iceberg, Sedona, a custom
optimizer rule), the value goes from

io.delta.sql.DeltaSparkSessionExtension          (contributed by ignifyr-format-delta)

to

com.example.MyExtension                          (user's value, Delta silently dropped)

The Delta session extension is then never registered, Delta sink writes fail, and nothing in the
error points back at the spark { } block that caused it.

Suggestion: keep the additive rule at this seam too — merge as today, then re-join that one key
from both layers:

val k = "spark.sql.extensions"
val merged = sparkConfDefaults ++ contributed ++ userConf
val joined = Seq(contributed.get(k), userConf.get(k)).flatten
               .flatMap(_.split(",")).map(_.trim).filter(_.nonEmpty).distinct
if (joined.isEmpty) merged else merged + (k -> joined.mkString(","))

Note this only applies to the list-valued key. Delta's other contribution,
spark.sql.catalog.spark_catalog, is genuinely single-valued, so a user override there is
legitimate and should keep winning as it does today.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is a valid problem, we can fix as suggested. If there are other list-valued config parameters other than "spark.sql.extensions", we should add them to "val k" as well.

* Force every registry to materialize — so a duplicate-registration error surfaces at engine
* startup rather than in the middle of a job — and log what was loaded.
*/
def init(): Unit = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scaladoc promises "Force every registry to materialize — so a duplicate-registration error
surfaces at engine startup rather than in the middle of a job", but scheduler is not in the
list, and since these are lazy vals whose bodies contain the conflict check, not reading one
means not checking it.

So with two scheduling modules installed by mistake, the engine starts cleanly and looks
healthy. The IllegalStateException from singleCapability only fires later — at
MappingJobLauncher:91 when a scheduled job is first launched, or on the server at
ExecutionService:133 on the first query about scheduled executions.

Worth noting separately that streaming is covered only incidentally. The line that reads it,
IgnifyrEngine:65, exists to decide whether to start the archive timer:

if (ExtensionRegistry.streaming.isDefined) fileStreamInputArchiver.startStreamingArchiveTask()

Its fail-fast side effect is a coincidence. If that line is ever refactored, moved behind
another condition, or removed, streaming loses its duplicate protection silently — and no test
covers the path, so nothing would catch it.

Making the intent explicit here is a one-liner and removes both problems:

def init(): Unit = {
  sourceConnectors
  sinkProviders
  terminologyServiceProviders
  identityServiceProviders
  cliCommands
  schemaInferrers
  streaming
  scheduler
  ()
}

The other two registries are fine as-is and do not need adding: sourceFailureDescriptors is a
plain flatMap with no uniqueness check so it cannot throw, and sparkConfContributions is
already forced while the SparkSession is built at IgnifyrEngine:37, before this call.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I agree.

case e: Throwable =>
logger.error(
s"Streaming chunk resulted in error for project: ${mappingJobExecution.projectId}, job: ${mappingJobExecution.jobId}, execution: ${mappingJobExecution.id}, mappingTask: $mappingTaskName",
e.getMessage

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

e.getMessage is a String, so this resolves to scala-logging's
error(message: String, args: Any*) overload rather than error(message: String, cause: Throwable).
The varargs form exists to fill {} placeholders, and this message has none (it is fully
s-interpolated), so SLF4J/Logback discards the argument instead of appending it. Logback's usual
rescue — treating a trailing extra argument as the exception — does not apply either, because the
argument is a String, not a Throwable.

Net effect: the log line records that a chunk failed, but nothing about why. No exception type, no
message, no stack trace:

ERROR StreamingSinkHandler - Streaming chunk resulted in error for project: proj-1,
job: job-7, execution: exec-3, mappingTask: patient-mapping

That is worse here than almost anywhere else, because this is the deliberate swallow path. The
module guide states the intent plainly — "catches per-micro-batch exceptions and only logs (the
stream survives bad chunks)" — so the exception goes nowhere else: it is not rethrown, the job is
not marked FAILURE, and the user is not notified. This log line is the only record of the failure
and the only diagnostic available afterwards. A streaming job can run for days quietly dropping
chunks and leave behind nothing but "something went wrong".

One-word fix:

logger.error(s"Streaming chunk resulted in error for project: ...", e)

Pre-existing — main has the identical line in SinkHandler.writeStream, so this is not a
regression from the extraction. Raising it because the code moves in this PR and the fix is
trivial. Note the sibling path at StreamingJobExecutor.scala:62 gets this right: it logs FAILURE
and rethrows, so there the exception does survive.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants