ADR 0019: Adopt Apache Iggy as the Message Streaming Service¶
Date: 2026-09-04
Status¶
Accepted
Context¶
Imbi has no durable message stream. Services talk to each other over HTTP and to their stores directly, so anything that happens in one place and matters somewhere else is either written straight into a database or lost. Three kinds of work need a stream that persists messages, keeps them ordered per topic, and lets consumers read at their own pace:
- Analytics writes. Rows for ClickHouse, produced by every service.
- Notifications. Events other Imbi services react to, such as a deployment finishing or a user being mentioned, fanned out to slackbot, email and whatever comes next.
- Metrics. Time-series facts for BI, such as project scores, drift counts and pull-request counts, recorded as they are observed rather than recomputed from the graph on demand.
Apache Iggy is a persistent message streaming server with streams, topics and partitions, server-side consumer-group offsets, TCP, QUIC and HTTP transports, an official Python SDK, and a connectors runtime with sink plugins including ClickHouse. It runs as a single binary with local storage, which suits Imbi's deployment size better than a Kafka cluster would.
This ADR adopts Iggy as that streaming service and decides its first use: moving the ClickHouse writes onto it. The notification and metric streams are named here only so the stream and topic conventions are set with them in mind; each gets its own decision when it is built.
First use: the ClickHouse write path¶
Every Imbi service writes analytics rows straight into ClickHouse through
imbi.common.clickhouse, and most call sites insert one row per request.
The survey below is the full list of write paths at the time of writing.
| Service / member | Call site | Table | Batch today |
|---|---|---|---|
| gateway | notifications.py DeliveryRecorder.record_received |
events |
one row per matched project, version 0 |
| gateway | notifications.py DeliveryRecorder.record_dispositions |
events |
same rows re-inserted with version 1 |
| api | plugins/lifecycle_dispatch.py |
events |
per dispatch |
| api | endpoints/_document_events.py, comments.py, projects.py |
events |
single row |
| api | endpoints/operations_log.py, project_configuration.py, project_deployments.py |
operations_log |
single row |
| api | maintenance/operations.py (backfill) |
operations_log |
per project |
| api | maintenance/log.py |
maintenance_log |
buffered per item, async_insert |
| api | endpoints/_document_history.py |
document_versions |
one or two rows |
| api | endpoints/_document_reads.py |
document_read_events, document_read_sessions |
per read / per sweep |
| api | drift.py |
commit_drift |
per verdict batch |
| api | sbom.py |
release_components, then release_component_batches |
fact rows, then one batch row |
| api | email/__init__.py |
email_audit |
single row |
| common | scoring/history.py |
score_history |
single row |
| scheduler | runs.py record |
scheduler_runs |
single row, several times per run |
| github plugin | commits.py, pull_requests.py |
commits, tags, pull_requests, commit_drift |
per sync page / single row |
Two engines are in use. events, operations_log, pull_requests,
commits, commit_drift, document_versions, tags, scheduler_runs,
document_read_events and document_read_sessions are
ReplacingMergeTree. score_history, release_components,
release_component_batches and maintenance_log are plain MergeTree.
Single-row inserts create one MergeTree part each. The maintenance log
already works around that with async_insert. Moving the writes onto
Iggy lets the ClickHouse sink batch them, decouples request latency from
ClickHouse availability, and gives us a replayable record of what each
service emitted. It is also the smallest useful way to put the streaming
service into production before the notification and metric streams
depend on it.
Constraints found during evaluation¶
- The Python SDK has no prebuilt wheel for Python 3.14, but builds
from source. Every
apache-iggyrelease through 0.9.0.dev6 ships wheels for CPython 3.10 to 3.13 only, matching the upstream wheel workflow. The sdist pins PyO3 0.29, which supports 3.14. Verified:pip install --pre apache-iggy==0.9.0.dev6insidepython:3.14-slimwith rustup installed compiles in about one minute on arm64 and the module imports on 3.14.7. Imbi's image build therefore needs a Rust toolchain in the Python builder stage, or a wheel built once in CI. - Iggy also has a first-class HTTP API (
IGGY_HTTP_ADDRESS,:3000in the Docker image) withPOST /users/loginandPOST /streams/{stream}/topics/{topic}/messages. It is the fallback if the SDK build ever becomes a problem;httpxis already a dependency ofimbi-common. - The ClickHouse sink plugin is not distributed as a binary. The
apache/iggy-connectimage contains only theiggy-connectorsruntime; upstream's publish config states that plugin.sofiles are not bundled. The edge release ships prebuilt plugin tarballs for eight connectors, and ClickHouse is not among them. The sink exists as source undercore/connectors/sinks/clickhouse_sinkwith an integration test fixture. - A sink configuration binds one stream and a static topic list to one table. The runtime opens one consumer group per listed topic when the sink starts. There is no wildcard and no header-based table routing. The list is re-read only on a sink restart.
- Webhooks are created at runtime. A topic per webhook would need
the sink configuration regenerated and the sink restarted on every
webhook create or delete. The event row already records
metadata.webhook_idandintegration, so per-webhook questions are answerable in ClickHouse without a per-webhook topic. - Some code reads what it just wrote.
operations_log.complete_opslog_entryselects the open row byexternal_run_idand re-inserts it completed. The scheduler records a run and its endpoints serve it back.sbom.pyinserts fact rows and then a batch row, and readers treat the batch row as the signal that the fact rows are complete. - Delivery is at-least-once. The sink retries failed inserts with
backoff and has no deduplication token. A duplicate delivery collapses
on a
ReplacingMergeTreetable but persists on a plainMergeTreetable.
Decision¶
1. Topology: a stream per subject, topics as the subject's own dimension¶
A stream is a subject with one row shape. Topics split that stream along whatever dimension is useful for that subject; the dimension is chosen per stream, not globally. Streams that land in ClickHouse map one stream to one table, which is exactly the model the sink expects. Streams are not limited to ClickHouse: the same bus will carry subjects with other consumers, and the naming rule has to hold for those too.
The topic set of a stream is decided in code, not here. What matters for the sink is that it is fixed at build time, so the sink configuration is static and can be generated from one Python mapping. Examples of the shape, including two streams this ADR does not implement:
| Stream | Topic dimension | Example topics | Consumers |
|---|---|---|---|
events |
producing service or feature | gateway, lifecycle, comments |
ClickHouse events |
operations_log |
producing feature | deployments, maintenance |
ClickHouse operations_log |
scheduler_runs |
producing service | scheduler |
ClickHouse scheduler_runs |
metrics (future) |
metric kind | project-score, drift, pull-requests |
ClickHouse metrics, one wide time-series table for BI |
notifications (future) |
notification type | deployment, mention, digest |
slackbot, email, and optionally ClickHouse for audit |
The first three are the existing analytics tables, one stream each. The
metrics stream shows the sink model working with a topic dimension that
is not the producer: every metric kind lands in the same table, so one
sink with a static topic list covers it. The notifications stream shows
a subject whose primary consumers are Imbi services, not ClickHouse.
The gateway publishes every webhook delivery to one topic and puts the
webhook slug and integration slug in user_headers; the row itself
already carries both.
Message payload is the row as a JSON object, serialized by the existing
imbi.common.clickhouse._dump so aliases and enum values match what the
direct insert produces today. This is what the sink's default
json_each_row format consumes. Every topic is created with one
partition and published to with partition id 0, because the Python SDK
exposes only partition-id partitioning at this version; with a single
partition that is equivalent to balanced, and the sink flushes a stream
as one insert per batch, so partition-level order is enough.
2. Producer: imbi.common.iggy on the official apache-iggy SDK¶
A new module in imbi-common mirrors the shape of the ClickHouse client
and wraps apache_iggy.IggyClient over TCP:
settings.IggyreadsIGGY_URL(iggy+tcp://iggy:iggy@iggy:8090, the SDK's connection-string form),connect_timeout,max_connect_attempts.Iggy.get_instance()singleton withinitialize()(build the client from the connection string,connect(), retry with backoff like the ClickHouse client) andaclose().ensure_topic(stream, topic)usesget_stream,create_stream,get_topic,create_topicand caches what it has created or seen so the calls happen once per process.publish(stream, topic, models, *, columns=None)accepts the same inputs asclickhouse.inserttoday plus the topic, builds oneSendMessageper row with the JSON payload, and callssend_messageswith the explicit partition id0. Every topic has one partition, so this is equivalent to balanced partitioning without relying on it. The SDK raisesRuntimeError; the module translates it toiggy.PublishErrorthe wayclickhouse.DatabaseErroris.TOPICS, the stream-to-topics mapping, lives in this module and drivesensure_topic. It is the single source of truth for what the ClickHouse sink consumes: imbi-api renders the sink configuration from it (§3).- Lifespan hooks in api, gateway, scheduler match the
clickhouse_hookpattern.entrypoint.shrequiresIGGY_URLin every mode.
apache-iggy is added to imbi-common as a >=0.9.0.dev6 pre-release
pin until 0.9.0 ships. The image build gains a Rust toolchain in the
python-builder stage of container/Dockerfile so uv sync can compile
the sdist; the runtime stage is unchanged. Developers on Python 3.14 need
rustup locally for uv sync until upstream publishes a 3.14 wheel,
which is a one-line change to their maturin --interpreter list and
worth a PR.
3. Consumer: the official ClickHouse sink in our own Iggy image¶
We maintain our own Iggy image, the way
AWeber-Imbi/postgres wraps
PostgreSQL with Apache AGE and the other extensions Imbi needs. A new
repository, AWeber-Imbi/iggy, holds a Dockerfile and the connector
configuration, and publishes to ghcr.io/aweber-imbi/iggy on its own
release cadence:
- A
rustbuild stage checks outapache/iggyat a pinned commit and runscargo build --release -p iggy_connector_clickhouse_sinkfor the glibc target of the build platform. Upstream does not ship this plugin as a binary and leaves it out of its own edge artifacts. - The image layers the
iggy-connectorsruntime and the compiled sink.soonto the matchingapache/iggyserver image, so one image carries the server, the runtime and the plugin. All three come from the same upstream commit because the plugin FFI is not versioned. - The image carries no sink configuration. The connectors runtime uses
its HTTP configuration provider (
config_type = "http") and fetches the sink definitions from imbi-api at startup. The entrypoint runs the server or the connectors runtime depending on a mode variable, so the same image backs both containers in compose and both deployments in Helm. The Iggy address and credentials reach the runtime throughIGGY_CONNECTORS_*environment variables.
imbi-api implements the provider contract: one sink per ClickHouse-bound
stream in TOPICS, rendered with schema = "json",
insert_format = "json_each_row", batch_length = 1000,
poll_interval = "250ms", a consumer group named after the table, and a
plugin_config whose ClickHouse URL, database and credentials come from
the api's own settings.Clickhouse. The endpoint is protected by a
static api key the runtime sends in a request header, because the
response carries ClickHouse credentials. Environment overrides cannot
reach plugin_config (the runtime marks it as not env-addressable),
which is why the credentials travel in the served configuration rather
than in the connectors container's environment.
Iggy stores consumer-group offsets server side, so a runtime restart
resumes where it left off, and more than one runtime replica can join the
same group. Adding a topic is a change to TOPICS followed by a restart
of the connectors runtime; the runtime's own HTTP API can also restart a
single sink. The two-list failure mode, a topic Imbi publishes to that no
sink consumes, cannot occur: there is one list.
4. Read-after-write paths¶
Under healthy conditions sink lag is close to the poll interval plus one insert, well under a second. That is an estimate, not a bound: sink retries and a ClickHouse outage extend it until the sink recovers. Two paths are still changed to tolerate lag rather than left on the direct client:
operations_log.complete_opslog_entry: keep the read, but when no open row is found retry once after one poll interval before logging the no-op. The start webhook and the completion webhook are seconds to minutes apart in practice, so the retry is a safety net, not the path.- Scheduler runs:
POST /tasks/{slug}/runalready returns theRunobject it built rather than re-reading it.GET /runs/{id}may miss a run for one tick; the UI polls, so no change is needed. The engine's "unsuperseded running row" diagnostic is unaffected. - SBOM batch ordering:
release_componentsandrelease_component_batchesare separate streams consumed by separate sinks, so cross-stream order is not guaranteed.sbom.pypublishes the batch row only after the fact rows are acknowledged, and readers keep ranking by(source, recorded_at)andbatch_id. A batch row visible before its last fact rows is a window of one poll interval.
No table keeps a direct insert path once phase 4 lands. Keeping two write paths is exactly the state this ADR removes.
5. Duplicates on plain MergeTree tables¶
score_history, release_components, release_component_batches and
maintenance_log would keep duplicate rows after a redelivery. Handling:
release_componentsandrelease_component_batchesalready carry abatch_id; readers rank by batch, so a duplicate batch row is harmless. A redelivered fact row lands inside the winning batch, where batch ranking does not remove it, andGET /releases/{id}/dependencieswould list the component twice. Phase 4 addsDISTINCTtocomponent_facts.release_components()and the aggregate readers over the same join before this stream moves off the direct client.maintenance_logis observability; a duplicate attempt row is noise, not a wrong answer. Accept it.score_historyfeedsscore_latestthrough an aggregating view. A duplicate row for the same(project_id, recorded_at)does not change the latest value. Accept it, and note it in the table's comment.
If any of these later needs exact-once semantics, the fix is a
ReplacingMergeTree conversion keyed on the row's natural key, not
consumer-side dedup.
6. Local and CI environment¶
compose.yaml and compose.ci.yaml already carry an iggy service on
the TCP port 8090, which is all the producers need. Switch its image
to ghcr.io/aweber-imbi/iggy, keep the HTTP port enabled for the health
check and for inspection, set IGGY_SYSTEM_SHARDING_PIN_CORES=false
(Iggy 0.9 binds shard memory to NUMA nodes, which the Docker Desktop VM
does not support), and add an iggy-connect service from the same image
in connectors mode that depends on iggy, clickhouse and the api
serving its configuration. moon run root:services writes IGGY_URL
into .env.test next to CLICKHOUSE_URL.
Tests for the producer mock IggyClient at the module boundary. Existing
tests that patch clickhouse.insert move to patching iggy.publish. One
end-to-end test in apps/gateway/tests publishes a delivery and reads
the row back from ClickHouse through the sink, with a bounded wait.
Rollout¶
Each phase is one PR and leaves the tree releasable.
- Infrastructure and common module.
settings.Iggy,imbi.common.iggywithTOPICS, lifespan hooks, compose and.env.testwiring,entrypoint.shand HelmIGGY_URL, README anddocs/deploymenttables. No call site changes. Verify:moon cigreen;imbi.common.iggyunit tests pass against a mockedIggyClient. - Iggy image. The
AWeber-Imbi/iggyrepository: Dockerfile with the plugin build, entrypoint with server and connectors modes, multi-arch publish to ghcr. In this repository: the configuration provider endpoint in imbi-api, compose services, Helm deployment for the connectors runtime, docs page. Verify: the end-to-end test publishes toevents/gatewayand reads the row back from ClickHouse. - Gateway.
DeliveryRecorderpublishes toevents/gatewayfor both phases. Verify:test_notifications.pyassertions oninsertbecome assertions onpublish; the end-to-end test from phase 2 runs against the real recorder. - API, scheduler, plugins. Replace the remaining call sites from the
table above, including the
complete_opslog_entryretry and the sbom publish order. Verify: each member's suite; a grep for.insert(outsideimbi.common.clickhousereturns nothing. - Remove the direct insert path. Delete
clickhouse.insertandClickhouse.insert;setup_schemaandqueryremain. Updatelibraries/common/README.md.
Estimated effort: phase 1 one day, phase 2 one day, phase 3 half a day, phase 4 two days, phase 5 half a day.
Consequences¶
- Rows reach ClickHouse a few hundred milliseconds after the request
instead of before the response. Every reader of these tables already
tolerates
ReplacingMergeTreeconvergence, so this is a narrower change than it sounds, and §4 lists the places that needed a look. - Iggy becomes a required backing service for every mode, like
ClickHouse and PostgreSQL, and the bus that later streams build on
under the conventions in §1. The lifespan hook raises when
iggy.initialize()fails, so a down or unreachable Iggy prevents a service from starting, the same way a down ClickHouse or PostgreSQL does today. A down ClickHouse no longer fails requests that record events, because the stream absorbs the outage. - We maintain an Iggy image alongside the PostgreSQL one. It is a thin layer over upstream plus the connectors runtime and one compiled plugin, all built from one pinned upstream commit, and the connectors runtime in it is the only writer to the analytics tables.
- We carry a Rust toolchain in two image builds: the Iggy image compiles
the ClickHouse plugin, and this repository's Python builder stage
compiles the
apache-iggysdist. The second goes away once upstream ships a CPython 3.14 wheel. - The connectors runtime depends on imbi-api at its own startup to fetch
configuration, and imbi-api holds the only list of sinks. A runtime
that starts before the api retries; a topic added to
TOPICSis consumed after the next runtime restart. - Per-webhook filtering happens in ClickHouse on
metadata.webhook_idandintegration, not by topic. If a webhook ever needs its own topic, the runtime API can version and restart a sink configuration; that path is deliberately not taken here. - Operators can inspect what a service emitted by reading its topic, and replay a stream into ClickHouse by resetting one consumer-group offset.
- Streams whose consumers are Imbi services rather than ClickHouse (the
future
notificationsstream) are out of scope here. The consumer side for those is a separate decision.
Alternatives considered¶
- Valkey Streams. Already in the stack, but no sink ecosystem, no per-topic retention or partitioning, and a history bounded by memory. Fine for a work queue, not for a replayable record.
- Kafka or RabbitMQ streams. Mature, but a cluster and an operator to run for a platform of Imbi's size. Iggy gives the same stream and consumer-group model as a single binary. We also do not need the complex routing topologies that RabbitMQ can provide.
- A Python consumer service (
imbi-ingest). Rejected: it re-implements batching, offsets and retries that the sink already has, and adds a service to the image and chart. It was only attractive when topics were dynamic. - Topic per webhook with a dynamically regenerated sink config. Rejected: imbi-api would gain an outbound dependency on the connectors runtime, and a webhook created while the runtime is down publishes to a topic nothing drains until the next restart.
- Iggy HTTP API through
httpx. Viable and dependency-free, but it re-implements login, token refresh and batching the SDK already has, and gives up the SDK's reconnection handling. Kept as the fallback if the sdist build becomes a burden. - Header-based routing with one stream. Rejected: the sink has no header routing, and a stream that mixes tables cannot be replayed into one table without filtering.
- ClickHouse
async_inserteverywhere. Solves part count but not the coupling to ClickHouse availability, and gives no per-service record.