♻️ refactor: rename toFHIR → Ignifyr and split the reactor into Community/Enterprise editions - #301
♻️ refactor: rename toFHIR → Ignifyr and split the reactor into Community/Enterprise editions#301sinaci wants to merge 40 commits into
Conversation
…afmt config + plugin
…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.
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).
- 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.
| - './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 |
There was a problem hiding this comment.
Should we change tofhir-web:latest to ignifyr-web:latest?
There was a problem hiding this comment.
Once this image is pushed with its new name, yes we should change then.
| @@ -52,17 +61,18 @@ object ToFhirConfig { | |||
| .setMaster(sparkMaster) | |||
|
|
|||
| val sparkConfEntries = | |||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 = { |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
Consolidated rename + edition-split branch — both touch the same files, so they land together.
Rename (clean break): packages
io.ignifyr.*, coordinatesio.ignifyr:ignifyr-*, HOCONignifyr.*, Dockersrdc/ignifyr-*, docs; version →2.0-SNAPSHOT. Legacytofhirintentionally survives only in thesrdc/tofhir-webimage, thetofhir-redcapsibling 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 theIgnifyrExtensionServiceLoader SPI — the engine never names a connector or format directly. A maven-enforcerbannedDependenciesgate 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-enterpriseis Phase 2, not this PR.Testing:⚠️ Requires JDK 11 — under JDK 21 the Spark reflective path surfaces as a spurious
mvn -B verifygreen.IllegalAccessErrorinExtensionRegistrySpec; unsupported-JDK symptom, not a defect.33 commits · 496 files · +18,952 / −11,381