Plan: canopy-mq Persistent Outbox (Issue #388, ADR-018)
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Publisher API change. Add |
Done (2026-05-08) — |
2 |
13-migration batch. Stamp |
Done (2026-05-08) — 13 byte-identical files under |
3 |
Drainer task. New |
Done (2026-05-08) — |
4 |
Service-main wiring. Bootstrap ( |
Done (2026-05-08) — |
5 |
Back-compat for |
Done (2026-05-08) — both APIs first-class; |
6 |
|
Done (2026-05-08) — |
7 |
Drainer unit tests. Tests live in |
Done (2026-05-08) — |
8 |
RabbitMQ-outage integration test. The plan called for a separate |
Done (2026-05-08) — |
9 |
Docs. CHANGELOG entry under |
Done (2026-05-08) — CHANGELOG |
Issue: #388
Branch: feat/e1-canopy-mq-outbox
Labels: type::feature, priority::medium, service::shared-crates, program::infrastructure, compliance::pub-1075, workflow::ready
Context
crates/canopy-mq/src/publisher.rs:66-73 uses a bounded VecDeque<EventEnvelope> (buffer_max, default 1024 via CANOPY_MQ_BUFFER_MAX) as the only retry buffer when RabbitMQ is unreachable. The buffer-full path at lines 135-151 returns PublishError::BufferFull — the foreground call site logs and drops the event. On process restart, the buffer is gone too: any envelopes that arrived between the broker outage and the restart are lost regardless of buffer fill.
This is incompatible with three commitments:
-
ADR-014's hash-chain breach detection emits Pub 1075 §9-reportable events that MUST NOT drop.
-
ADR-002 determinations publish
*.determinedevents that downstream consumers treat as the system of record. -
ADR-004's
audit_eventschain extends only when its event publishes; a dropped event creates a hole that looks identical to a chain breach.
ADR-018 decided per-service event_outbox tables. This plan implements the decision.
Code references
-
crates/canopy-mq/src/publisher.rs:66-73— bounded VecDeque definition. -
crates/canopy-mq/src/publisher.rs:135-151— buffer-full drop path. -
crates/canopy-mq/src/publisher.rs:217-276—flush_loopbackground task; replaced by drainer. -
crates/canopy-mq/src/connection.rs—ConnectionManager::reconnectsingle-flight; reused by drainer.
Scope
In scope:
-
Publisher::publish_txAPI. -
13 byte-identical migrations (one per publishing service).
-
OutboxDrainerbackground task. -
outbox_pending_countmetric. -
7-day janitor for published rows.
-
RabbitMQ-outage integration test.
-
FTI hash-chain regression test across an outage.
Out of scope:
-
Cross-database 2PC. The outbox row writes in one transaction with the domain row; if a service writes to two DBs, only one carries the outbox guarantee.
-
Exactly-once delivery to consumers — at-least-once into RabbitMQ; consumer dedup remains the consumer’s job (envelope id is already a stable UUID).
-
Outbox compaction across replays. After a long outage the drainer drains in arrival order, period.
-
Per-routing-key priority drain. All keys drain FIFO.
-
Removing
CANOPY_MQ_BUFFER_MAXenv var. Becomes a no-op; documented in CHANGELOG; full removal in a follow-up MR after one release cycle so deployers don’t see "unrecognised variable" warnings.
Dependencies
-
ADR-018 must be merged first (lands in MR 217 / branch
docs/adr-018-persistent-outbox). -
No prerequisite plans on disk.
Design
Canonical migration (byte-identical across 13 services):
CREATE TABLE event_outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
routing_key TEXT NOT NULL,
payload JSONB NOT NULL,
enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ,
attempts INT NOT NULL DEFAULT 0,
last_error TEXT
);
CREATE INDEX event_outbox_unpublished_idx
ON event_outbox (enqueued_at)
WHERE published_at IS NULL;
Publisher API:
impl Publisher {
pub async fn publish_tx(
&self,
tx: &mut Transaction<'_, Postgres>,
envelope: &EventEnvelope,
) -> Result<(), PublishError> {
validate_payload(&envelope.payload)?;
let mut envelope = envelope.clone();
envelope.trace_context = Self::inject_trace_context();
sqlx::query!(
"INSERT INTO event_outbox (id, routing_key, payload) VALUES ($1, $2, $3)",
envelope.id,
envelope.event_type,
serde_json::to_value(&envelope)?,
)
.execute(&mut **tx)
.await?;
Ok(())
}
pub async fn publish(&self, envelope: &EventEnvelope) -> Result<(), PublishError> {
let mut tx = self.inner.pool.begin().await?;
self.publish_tx(&mut tx, envelope).await?;
tx.commit().await?;
Ok(())
}
}
Drainer loop:
async fn drain_loop(pool: PgPool, manager: ConnectionManager) {
let tick = parse_tick_ms_env();
loop {
tokio::time::sleep(Duration::from_millis(tick)).await;
let rows = sqlx::query_as::<_, OutboxRow>(
"SELECT id, routing_key, payload, attempts FROM event_outbox \
WHERE published_at IS NULL \
ORDER BY enqueued_at LIMIT 100 FOR UPDATE SKIP LOCKED",
)
.fetch_all(&pool)
.await;
for row in rows.into_iter().flatten() {
let envelope: EventEnvelope = serde_json::from_value(row.payload)?;
match try_publish_via_manager(&manager, &envelope).await {
Ok(()) => mark_published(&pool, row.id).await,
Err(e) => mark_failed(&pool, row.id, &e).await,
}
}
}
}
Janitor (separate tokio::spawn in service main):
DELETE FROM event_outbox
WHERE published_at IS NOT NULL
AND published_at < now() - INTERVAL '7 days';
Files Touched
| File | Change |
|---|---|
|
Add |
|
New module — drain loop + janitor loop in one file |
|
Re-export |
|
Add |
|
13 byte-identical new migrations |
|
Construct publisher via |
|
Test-fixture publisher constructions updated to |
|
Add |
|
Delete in-memory-buffer tests ( |
|
New file: 2 devstack-gated drainer regressions |
|
|
Verification
-
cargo nextest run -p canopy-mq— unit tests pass. -
cargo xtask dev start && cargo nextest run -p canopy-mq --test outage_test --run-ignored only— outage test passes; FTI hash chain extends without hole. -
Per-service migrations run cleanly:
cargo xtask migrate runagainst a fresh DB across all 13 services. -
cargo xtask validate— full battery green. -
Manual smoke: stop RabbitMQ, fire 100 events via
canopy snap determine, restart canopy-snap, restart RabbitMQ, confirm events arrive at the subscriber. -
After events arrive,
SELECT count(*) FROM event_outbox WHERE published_at IS NULLreturns 0 across all 13 service DBs.
Documentation Updates
-
CHANGELOG.adoc— entry under== Unreleased/=== Changedciting ADR-018 (2026-05-08) -
.claude/docs/architecture.md— Event Bus section gained outbox/drainer/janitor description (2026-05-08) -
.claude/docs/services.md— "Cross-cutting tables" section documents theevent_outboxtable once for all publishing services (2026-05-08) -
Plan archive: move to
plans/archive/post-merge