Rust Event Importer Design
Overview
The importer repository is the Rust replacement for the current Java
event-importer CLI. It must preserve the operational workflows that are
already used for portal migrations:
- Import a JSON array of CloudEvents into
event_store_t,outbox_message_t, and pendingnotification_trecords. - Convert a global snapshot JSON file into an ordered JSON array of CreatedEvents that can be imported by the same event import path.
The Rust version should be a standalone command-line tool. It should not start a
service, and it should not depend on Java runtime configuration. It should
connect directly to PostgreSQL and use the same event-store contract as
light-portal.
Existing Java Behavior To Preserve
The Java event-importer exposes two modes.
Import Mode
Default mode:
event-importer --filename events.json
Supported flags:
--filename,-f: JSON array of CloudEvents.--replacement,-r: JSON array of replacement rules.--enrichment,-e: JSON array of enrichment rules.
Per event, the current importer:
- Parses the input event as JSON.
- Applies replacement and enrichment rules with
EventMutator. - Deserializes the mutated JSON as a CloudEvent.
- Reads or defaults
aggregateversion. - Adds missing
aggregatetypefrom event type. - Recomputes
subjectfrom event data when the subject is missing or replacement rules were applied. - Skips duplicate
(subject, aggregateversion)pairs inside the input batch. - Skips events when the target database already has the same or a higher aggregate version for that subject.
- Reserves a fresh nonce from
user_tfor the event user. - Inserts one event at a time so one bad event does not abort the whole file.
The insert path writes the event to:
event_store_toutbox_message_tnotification_twithPENDINGstatus
Snapshot Conversion Mode
Conversion mode:
event-importer --convert \
--filename snapshot.json \
--targetHostId 01964b05-552a-7c4b-9184-6857e7f3dc5f \
--adminUserId 01964b05-5532-7c79-8cde-191dcbd421b8 \
--output events.json
The current converter:
- Reads snapshot JSON with a top-level
tablesobject. - Sorts snapshot tables topologically from database FK metadata.
- Skips runtime/projection-owned tables.
- Maps each table to a CreatedEvent type.
- Merges projection-owned child data into parent event payloads where required:
auth_provider_key_tintoauth_provider_tauth_client_owner_tintoauth_client_tuser_host_t,customer_t, andemployee_tintouser_tapi_endpoint_tand endpoint scopes intoapi_version_t
- Rewrites the source host id to the target host id recursively.
- Emits CloudEvent-compatible JSON with:
- new event id
- target host
- admin user
nonceplaceholdersubjectderived from event type and dataaggregatetypeaggregateversionset to1
The generated JSON is then imported through import mode.
Goals
- Preserve Java CLI compatibility for existing scripts and runbooks.
- Preserve event-store, outbox, notification, nonce, offset, and transaction semantics.
- Keep migration behavior deterministic and testable with golden files.
- Make the Rust implementation easier to deploy as a single static binary.
- Keep snapshot conversion and import logic in one repository, but isolate them into testable modules.
- Support large migration workflows with stdin/stdout piping, bounded-memory conversion options, aggregate-version caching, and configurable batch imports.
Non-Goals
- No online REST service in the first version.
- No schema migration management.
- No reconciliation or diff-based promotion logic.
- No attempt to replay projections directly. Import writes events and outbox rows; existing consumers rebuild projections.
Command Line Interface
Use clap with two subcommands while also accepting Java-compatible flags.
Preferred Rust CLI:
importer import --filename events.json
importer convert --filename snapshot.json --target-host-id ... --admin-user-id ... --output events.json
importer convert --filename snapshot.json --target-host-id ... --admin-user-id ... --output - \
| importer import --filename -
Compatibility CLI:
importer --filename events.json
importer --convert --filename snapshot.json --targetHostId ... --adminUserId ... --output events.json
Import Options
--filename,-f: required event list JSON file.--filename -: read event JSON from stdin.--replacement,-r: JSON string or@filecontaining replacement rules.--enrichment,-e: JSON string or@filecontaining enrichment rules.--dry-run: parse, mutate, validate, and report without writing.--fail-fast: stop on the first failed event. Default is continue.--batch-size: soft event-count target for one physical database commit. It never defines logical transaction membership. Every event in the snapshot-derived JSON array receives a distinct singleton logical transaction. See Fast Snapshot-Derived Database Bootstrap.--summary-json: print machine-readable summary.
Convert Options
--convert,-c: compatibility flag for conversion mode.--filename,-f: required snapshot JSON file.--targetHostId,--target-host-id,-t: required target host id.--adminUserId,--admin-user-id,-u: required user id stamped on events.--output,-o: optional output file. If absent or-, write JSON to stdout and diagnostics to stderr.--schema-source embedded|database: defaultembedded. Embedded mode uses a checked-in dependency graph generated from portal DDL. Database mode uses PostgreSQL metadata and is useful for parity checks and dependency-graph refresh validation.
Configuration
Use environment-first configuration:
DATABASE_URL=postgres://postgres:secret@localhost:5432/configserver importer import -f events.json
Optional config file support can mirror local compose usage:
database:
url: postgres://postgres:secret@localhost:5432/configserver
max_connections: 3
Precedence:
- CLI flags
- Environment variables
- Config file
- Defaults
Use a config resolver such as figment or config so this precedence is
centralized and testable instead of open-coded through the CLI.
Rust Crates And Observability
Recommended crates:
clap: CLI parsing for subcommands and compatibility flags.serdeandserde_json: strongly typed rule/config parsing plus flexible event payload handling.sqlx: PostgreSQL access with explicit SQL.tracingandtracing-subscriber: structured logs with event indexes, aggregate ids, table names, and transaction ids attached as fields.anyhowfor internal error propagation andmiettefor user-facing validation/conversion diagnostics.figmentorconfig: CLI/env/file/default configuration merging.uuid,time, andindexmap: deterministic IDs, timestamps, and stable output ordering where needed.
Logs must go to stderr when stdout is used for generated JSON. Long-running imports should log periodic progress with totals for imported, skipped, and failed events.
Module Design
Proposed Rust modules:
src/
main.rs
cli.rs
config.rs
db.rs
event/
mod.rs
cloud_event.rs
event_type.rs
mutator.rs
normalize.rs
import/
mod.rs
aggregate_cache.rs
batch.rs
importer.rs
report.rs
snapshot/
mod.rs
converter.rs
dependency_graph.rs
stream.rs
table_rules.rs
topology.rs
row_merge.rs
sql/
mod.rs
event_store.rs
nonce.rs
offset.rs
notification.rs
cli
Parses both the new subcommand form and the legacy Java flags. The CLI layer should only validate argument presence and resolve files. It should not contain event mutation, conversion, or SQL logic.
db
Owns the connection pool and transaction helper. Use sqlx with PostgreSQL.
Queries should be explicit SQL, not generated dynamically except for metadata
inspection in snapshot conversion.
event::cloud_event
Represents CloudEvents as structured JSON plus typed helpers for required
fields and extensions. The implementation may use a Rust CloudEvents SDK if it
matches the portal payload exactly. If not, use a serde_json::Value backed
model so the serialized payload stays byte-compatible with existing Java
CloudEvents.
Required fields/extensions:
idsourcetypetimesubjectspecversiondatacontenttypedatahostusernonceaggregatetypeaggregateversion
The importer must preserve unknown CloudEvent extensions in the stored payload
and metadata. The metadata JSON should exclude the core portal extensions just
like Java EventPersistenceImpl excludes host, user, nonce,
aggregatetype, and aggregateversion.
event::event_type
Rust parity for EventTypeUtil.
Responsibilities:
- Derive aggregate type from event type suffixes.
- Derive aggregate id from event type and event
data. - Keep table-to-event overrides aligned with
GlobalSnapshotPersistenceImpl.
This module is a high-risk drift point. Every new portal event type that can be exported or imported must have a test case here.
event::mutator
Rust parity for EventMutator.
Rules should be parsed into strongly typed serde structs during startup. Bad
rule JSON should fail before the importer opens the input event file or starts a
long-running import.
Replacement rules:
[
{"field":"hostId","from":"OLD_HOST_UUID","to":"NEW_HOST_UUID"}
]
Behavior:
- If
fromandtoare UUID-looking strings, recursively replace string occurrences anywhere in the event JSON. - If
fieldis present, recursively replace exact field values matchingfrom. - Accept legacy aliases
fieldName,fromValue, andtoValue.
Enrichment rules:
[
{"field":"id","action":"generateUUID"},
{"field":"originalUserId","action":"mapGenerate","sourceField":"userId"}
]
Behavior:
generateUUID: generate a new UUID for the target field.mapGenerate: use a stable in-memory map keyed byfield + source value.- Accept Java README-style aliases
fieldandmapAndGeneratein addition to Java implementation namesfieldNameandmapGenerate. This keeps old scripts and docs working even though the Java implementation is stricter than the README examples.
The first Rust release should accept unversioned Java-compatible arrays and versioned rule documents. The normalized internal representation should always include a schema version so future rule changes are explicit.
Versioned rule document example:
{
"schemaVersion": 1,
"replacement": [
{"field":"hostId","from":"OLD_HOST_UUID","to":"NEW_HOST_UUID"}
],
"enrichment": [
{"field":"id","action":"generateUUID"}
]
}
Import Flow
read file
-> parse JSON array from file or stdin
-> for each raw event:
mutate JSON
normalize CloudEvent
derive/default aggregate version
derive missing aggregate type
recompute subject if needed
skip duplicate input aggregate version
skip existing target aggregate version using cache-backed lookup
reserve nonce
insert event + outbox + pending notification in one transaction
update summary
Aggregate Version Rules
Input may contain either aggregateversion as a CloudEvent extension or
aggregateVersion as a raw compatibility field. Normalize to
aggregateversion.
Default is 1 when missing.
Skip rules:
- Skip duplicate
(subject, aggregateversion)inside the input file. - Cache target max versions in memory by
aggregate_id. - On first sight of an aggregate id, query target
MAX(aggregate_version)and populate the cache. - After a successful insert, update the cached max version.
- Skip if target max version is greater than or equal to the event version.
This avoids an N-query import path for aggregates with many events in the same file. The cache is scoped to one importer run and is safe because the importer still relies on database constraints as the final authority.
Nonce Rules
Nonce must be reserved from user_t in the same transaction as the event insert:
UPDATE user_t
SET nonce = nonce + 1
WHERE user_id = $1
RETURNING nonce
Do not trust imported nonce values. They are placeholders only.
Offset And Transaction Rules
For each transaction, reserve outbox offsets from log_counter:
UPDATE log_counter
SET next_offset = next_offset + $1
WHERE id = 1
RETURNING next_offset - $1
The supported snapshot-derived array contains synthetic events without source
transaction metadata. Preserve Java’s isolation semantics by assigning one
singleton logical transaction per event, with ordinal 0 and count 1.
--batch-size may place several singleton append requests in one physical
PostgreSQL commit, but it must retain a distinct generated transaction_id for
every event. If a physical commit chunk fails, roll it back and retry its events
individually for diagnosis unless --fail-fast is set. Import of true canonical
event history with original multi-event transaction membership is outside this
snapshot-import design.
Insert SQL
Write both event rows in one transaction:
INSERT INTO event_store_t
(id, host_id, user_id, nonce, aggregate_id, aggregate_version,
aggregate_type, event_type, event_ts, payload, metadata)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb)
INSERT INTO outbox_message_t
(id, host_id, user_id, nonce, aggregate_id, aggregate_version,
aggregate_type, event_type, event_ts, payload, metadata, c_offset,
transaction_id)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb, $12, $13)
Also insert a pending notification row:
INSERT INTO notification_t
(id, host_id, user_id, nonce, event_class, event_json, event_ts, process_ts,
status, error, aggregate_id, aggregate_type, aggregate_version,
event_partition, event_offset, transaction_id)
VALUES
($1, $2, $3, $4, $5, $6::jsonb, $7, now(), 'PENDING', NULL,
$8, $9, $10, NULL, NULL, $11)
ON CONFLICT (host_id, id) DO NOTHING
The pending notification keeps admin visibility consistent with Java imports and the event processor cleanup flow.
Constraint Collisions And Idempotency
Do not add ON CONFLICT DO NOTHING to event_store_t or outbox_message_t.
Their primary keys and unique constraints are part of the import safety model.
The importer should catch PostgreSQL unique-violation errors, inspect the
existing row, and categorize the result:
- Exact duplicate event id and aggregate version with equivalent payload: report as skipped exact duplicate.
- Existing aggregate id/version with different payload, event type, or metadata: report as failed conflict.
- Event id collision pointing at a different aggregate: report as failed conflict.
- Outbox insert collision: roll back the transaction and report as failed conflict unless the corresponding event-store row proves this was an exact duplicate import.
The notification_t insert is different. It may use ON CONFLICT DO NOTHING
because it is a derived admin visibility record and should not make an otherwise
valid idempotent event import fail.
Snapshot Conversion Flow
read snapshot
-> parse top-level object from file or stdin
-> read tables map
-> compute table order
-> build child-row lookup maps
-> for each table in order:
skip runtime/projection-owned tables
derive CreatedEvent type
merge child row payloads when needed
recursively replace source host with target host
set aggregateVersion/newAggregateVersion compatibility fields
derive subject
emit CloudEvent JSON
-> write array to output or stdout
Memory Strategy
The simple implementation can parse the full snapshot into serde_json::Value
when the input is known to fit comfortably in memory. This is easiest for parity
work and golden tests, but it should be documented as roughly multiple times the
JSON file size because parsed JSON objects, strings, and maps add overhead.
For production-sized global snapshots, implement a streaming converter:
- Stream the
tablesobject from the input file. - Materialize only rows required for child merge rules, such as
auth_client_owner_t,auth_provider_key_t,user_host_t,customer_t,employee_t,api_endpoint_t, andapi_endpoint_scope_t. - Stream parent table rows in dependency order and write output events incrementally.
- Preserve JSON array syntax by writing
[once, then comma-separated events, then].
If strict dependency ordering requires parent rows after later child tables, the converter may use temporary spill files under a configured work directory. The default design should avoid holding gigabyte-scale snapshots as a single JSON tree.
Snapshot Format
Expected top-level fields:
exportVersionsourceHostIdexportScopeexportTstables
Each table entry contains rows, where row keys are already camelCase.
Table Ordering
Embedded mode should use a checked-in table dependency graph generated from the portal DDL. Database mode should query PostgreSQL metadata and perform the same Kahn topological sort used by Java:
- parent tables before child tables
- only dependencies between tables present in the snapshot matter
- if a cycle is found, append remaining tables deterministically and warn
The embedded graph should be the default so CI, offline conversion, and local testing do not require a running PostgreSQL instance. Add a validation command or test that compares the embedded graph with database metadata to catch DDL drift.
Table Skip Rules
Keep these sets centralized in snapshot::table_rules.
Auth runtime state skipped in both export/conversion logic:
auth_session_audit_t
auth_session_t
auth_refresh_token_t
auth_code_t
auth_ref_token_t
auth_client_token_t
Projection-owned/runtime tables skipped in conversion:
employee_t
customer_t
notification_t
user_host_t
user_crypto_wallet_t
auth_provider_key_t
auth_client_owner_t
api_endpoint_t
api_endpoint_scope_t
private_conversation_t
private_message_t
private_message_state_t
agent_memory_*
agent_session_history_t
snapshot_*
The exact skip sets must stay aligned with GlobalSnapshotPersistenceImpl. A
test should compare the Rust list against a fixture extracted from the Java
source until the Java importer is retired.
Table-To-Event Overrides
Most tables map from snake case to PascalCase plus CreatedEvent, but several
tables require explicit overrides:
environment_property_t -> ConfigEnvironmentCreatedEvent
instance_api_property_t -> ConfigInstanceApiCreatedEvent
instance_app_property_t -> ConfigInstanceAppCreatedEvent
instance_app_api_property_t -> ConfigInstanceAppApiCreatedEvent
instance_file_t -> ConfigInstanceFileCreatedEvent
deployment_instance_property_t -> ConfigDeploymentInstanceCreatedEvent
instance_property_t -> ConfigInstanceCreatedEvent
product_property_t -> ConfigProductCreatedEvent
product_version_property_t -> ConfigProductVersionCreatedEvent
value_locale_t -> RefLocaleCreatedEvent
relation_type_t -> RefRelationTypeCreatedEvent
relation_t -> RefRelationCreatedEvent
auth_client_t -> ClientCreatedEvent
wf_definition_t -> WorkflowDefinitionCreatedEvent
Child Row Merge Rules
Some tables are not standalone aggregates. Their rows must be embedded into the parent event payload.
Required merge rules:
auth_provider_key_t: group byproviderId; attach key payloads to the matchingauth_provider_trow.auth_client_owner_t: group byownerId; merge owner data into the matchingauth_client_trow soClientCreatedEventhas the replay contract it needs.user_host_t,customer_t,employee_t: group byuserId; merge dependent user state intoUserCreatedEvent.api_endpoint_tandapi_endpoint_scope_t: group endpoint payloads byapiVersionId; attach toApiVersionCreatedEvent.
This is the most important conversion parity area. Missing merge logic can produce an apparently valid event list that later fails replay or silently loses projection state.
Host Rewrite
When sourceHostId exists, recursively replace all exact string occurrences of
the source host id with targetHostId before deriving subject.
This order is required. If the subject is derived before replacement, old-host
aggregate ids survive and can collide on (aggregate_id, aggregate_version).
Reports And Exit Codes
Import summary should include:
{
"file": "events.json",
"total": 100,
"imported": 97,
"skippedDuplicateInput": 1,
"skippedExistingTarget": 1,
"failed": 1
}
Exit code policy:
0: completed; no failed events2: completed with skipped events only3: completed with failed events and--fail-fastwas not set4: validation/config/connection error5: conversion failed
The Java tool currently exits successfully after per-event failures because it logs and continues. The Rust tool should preserve continue behavior by default, but explicit exit codes make automation safer.
Testing Strategy
Unit Tests
- CLI parsing for new and compatibility forms.
- Replacement and enrichment rules, including alias support.
- Versioned rule document parsing and fail-fast validation.
- CloudEvent normalization and metadata extraction.
- Aggregate type and aggregate id derivation.
- Aggregate max-version cache behavior.
- Table skip rules.
- Table-to-event override mapping.
- Child row merge rules.
Golden File Tests
Use committed fixtures from event-importer:
events/bootstrap/*.jsonevents/local/*.json- representative snapshot JSON files
Golden assertions:
- Java-converted snapshot and Rust-converted snapshot produce equivalent event
arrays after ignoring nondeterministic fields (
id,time). - Rust import dry-run summary matches expected counts.
convert --output - | import --filename -works without an intermediate events file.- Host rewrite happens before subject derivation.
auth_client_tconversion includes owner payload.- PII token tables convert only when event/replay support exists.
Database Integration Tests
Use disposable PostgreSQL. H2 is not recommended for Rust because the runtime target is PostgreSQL-specific SQL and JSONB.
Test cases:
- Import one event and verify
event_store_t,outbox_message_t, andnotification_t. - Import duplicate input aggregate version and verify skip count.
- Import when target already has equal/higher aggregate version and verify skip.
- Verify nonce increments from
user_t. - Verify outbox offsets are gapless for successful inserts.
- Verify failed event rolls back without blocking later events.
- Verify unique-constraint collisions are categorized as exact duplicate or failed conflict.
- Verify
--batch-sizepacks singleton append requests into physical commit chunks while retaining a distinct transaction ID, ordinal0, and count1for every event, then falls back to per-event diagnostics when a chunk fails. - Convert a snapshot and import the resulting event list into an empty DB.
- Compare embedded table dependency order against live PostgreSQL metadata.
Rollout Plan
Phase 1: Rust Project Skeleton
- Add Cargo project in
importer. - Add CLI, config loading, logging, and database connection.
- Add dry-run import that parses and normalizes events.
Phase 2: Import Parity
- Implement mutator and aggregate utilities.
- Implement event-store/outbox/notification transaction.
- Add duplicate/existing skip logic and aggregate max-version cache.
- Add
--batch-sizewith failed-batch fallback to one-event isolation. - Validate against existing
events/bootstrapandevents/localfiles.
Phase 3: Snapshot Conversion Parity
- Port table ordering, skip rules, event overrides, and merge rules.
- Add embedded table dependency graph generated from portal DDL.
- Generate ordered CloudEvent JSON.
- Support
--filename -and--output -so conversion can pipe directly into import. - Compare Rust output with Java output using golden fixtures.
- Add streaming conversion or bounded-memory spill-file support before using the tool for multi-gigabyte snapshots.
Phase 4: Operational Hardening
- Add release build and static binary packaging.
- Add wrapper scripts equivalent to
importer.shandconverter.sh. - Add README migration notes.
- Deprecate Java
event-importerafter the Rust importer passes the same snapshot conversion and import scenarios.
Design Decisions
- Include an embedded table dependency graph in the first release and keep database metadata mode as a validation/refresh path.
- Implement
--batch-sizeas a physical commit target during import parity work. Default conservatively and treat every snapshot-derived event as a singleton logical transaction. - Support stdin/stdout in both modes so operators can run
importer convert ... --output - | importer import --filename -. - Formalize replacement/enrichment as versioned
serdeschemas while accepting Java-compatible unversioned arrays for migration compatibility.