Plan: outbox-drainer lease-based three-phase refactor
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Per-service |
Done (2026-05-18) |
2 |
|
Done (2026-05-18) |
3 |
|
Done (2026-05-18) |
4 |
|
Done (2026-05-18) |
5 |
New env vars wired via the existing |
Done (2026-05-18) |
6 |
|
Done (2026-05-18) |
7 |
Tests as a new |
Done (2026-05-18) |
8 |
CHANGELOG entry under |
Done (2026-05-18) |
9 |
Precommit Q1-Q8 answered via subagent verification per |
Done (2026-05-18) |
Issues: #478
Branch: fix/outbox-drainer-lease
Labels: priority::high, service::shared-crates, program::infrastructure, type::chore, workflow::ready
Context
The diagnostic
During investigation of consistent ~10s test timeouts under cargo nextest run --workspace --profile integration, pg_stat_activity polling at 100ms intervals captured the following pattern:
22:54:50.491 pid=92 canopy_appeals LWLock:WALWrite COMMIT
22:54:50.491 pid=79 canopy_security LWLock:WALWrite COMMIT
22:54:50.491 pid=638 canopy_appeals IO:WalSync COMMIT
22:54:50.491 pid=638 canopy_appeals idle in transaction UPDATE event_outbox SET published_at = now() WHERE id = $1
22:54:50.763 pid=676 canopy_persons idle in transaction UPDATE event_outbox SET published_at = now() WHERE id = $1
22:54:51.036 pid=674 canopy_applications idle in transaction UPDATE event_outbox SET published_at = now() WHERE id = $1
22:54:51.309 pid=1066 canopy_security IO:WalSync COMMIT (>800ms)
canopy-rules instrumentation also caught a single persist_ms = 6753ms event for the georgia-snap-alien-eligibility ruleset.
The bug
OutboxDrainer::drain_once at crates/canopy-mq/src/outbox_drainer.rs:101 opens a database transaction, locks up to 100 rows with FOR UPDATE SKIP LOCKED, then runs a loop that:
-
Awaits a RabbitMQ publish for each row (network round-trip)
-
Runs
UPDATE event_outbox SET published_at = now() WHERE id = $1per row, inside the same transaction -
Commits at the end
This is the broker-I/O-inside-DB-tx antipattern. With 17 services each running their own drainer, multiple drainers concurrently hold long-lived transactions across N broker calls. Each drainer’s COMMIT batches up N WAL records and serialises behind the WAL writer lock, producing the LWLock:WALWrite + IO:WalSync waits and the multi-second COMMITs observed.
NVMe does not rescue this design. The hardware is fine; pg_test_fsync reports 6.4ms per fdatasync. The pathology is in our drainer code.
Reviewer’s verdict (2026-05-17)
External review signed off on the architectural shape (lease-based three-phase: claim → publish-outside-tx → mark) with corrections that this plan incorporates:
-
No
attemptsincrement at claim — preserves existing publish-failure semantics -
Lease recovery via the claim query, not via the hourly janitor
-
Phase 3 updates guarded by
claimed_by = $drainer_idso a slow drainer can’t mark rows another drainer has reclaimed -
Deserialisation failures must clear the claim
-
Channel-per-batch with
confirm_selectcalled once, not per-row -
current_channel()opens fresh channels — batch-channel lives indrain_once, not behind the existingtry_publish_via_managerhelper -
Lease TTL must dominate worst-case batch publish time; pipeline depth and batch size are bounded
-
Existing partial index kept; new lease-aware index added; redundancy assessed in follow-up
-
Env vars follow existing
CANOPY_MQ_*style, not theCANOPY_OUTBOX__*double-underscore scheme
Scope
In scope:
-
event_outboxschema additions:claimed_at,claimed_by, new partial index -
OutboxDrainer::drain_oncerewrite (three-phase, lease-based) -
Channel-per-batch publisher confirms with
confirm_selectand bounded pipelining -
Lease recovery via the next-tick claim path
-
Four practical in-source unit/scenario tests (see Step 7) — placed as
#[cfg(test)] mod lease_testsinsideoutbox_drainer.rsto access the crate-privatedrain_once -
CHANGELOG + ADR-018 amendment + shared-crates.md update
Out of scope:
-
CDC / logical replication outbox (correct at higher scale; overkill now)
-
Removing the existing
event_outbox_unpublished_idx(deferred — observe plans first) -
Lease renewal mid-batch (unnecessary while pipeline_depth × per-publish latency stays well under lease_ttl)
-
Inbox-side changes (
event_inboxconsumer dedup) — different concern, separate plan -
canopy-rules' eval-tx coalescing (already shipped in !323; not affected)
-
Per-service publish_tx migration (already shipped on
chore/centralize-sqlx-migrate-bootstrap— this plan depends on it being merged)
Design
Three-phase flow
async fn drain_once(
pool: &PgPool,
manager: &ConnectionManager,
cfg: &DrainerConfig, // batch_size, lease_ttl_secs, pipeline_depth, drainer_id
) -> Result<DrainStats, DrainError> {
// ----- Phase 1: claim (one statement, short tx) -----
// CTE form is required: Postgres disallows FOR UPDATE inside a
// subquery used in `WHERE id IN (SELECT …)`. The CTE form is the
// canonical skip-locked-claim pattern.
let claimed: Vec<(Uuid, JsonValue, i32)> = sqlx::query_as(
"WITH locked AS (
SELECT id FROM event_outbox
WHERE published_at IS NULL
AND (claimed_at IS NULL
OR claimed_at < now() - ($2::bigint * interval '1 second'))
ORDER BY enqueued_at
FOR UPDATE SKIP LOCKED
LIMIT $3
)
UPDATE event_outbox
SET claimed_at = now(), claimed_by = $1
FROM locked
WHERE event_outbox.id = locked.id
RETURNING event_outbox.id, event_outbox.payload, event_outbox.attempts"
)
.bind(&cfg.drainer_id)
.bind(cfg.lease_ttl_secs as i64)
.bind(cfg.batch_size)
.fetch_all(pool).await?;
if claimed.is_empty() { return Ok(DrainStats::empty()); }
// ----- Phase 2: publish outside any tx, channel-per-batch -----
//
// Any early-return path here must release the active claims so the
// next drain tick can retry, instead of stranding 100 rows for the
// entire lease TTL. The publish phase is wrapped in an inner closure
// returning Result<(confirmed, failed), DrainError>; on outer Err we
// call `release_claims_only(pool, &all_claimed_ids, drainer_id).await`
// before propagating. `release_claims_only` is a single bulk UPDATE
// that clears `claimed_at`/`claimed_by` without bumping `attempts`
// or touching `last_error` — the failure was infrastructural (channel
// open, confirm_select), not a per-message problem.
//
// We deliberately do NOT set `mandatory=true`. In a fan-out topic
// event bus, "no queue currently bound for this routing key" is
// valid during dev/test or while a subscribing service is down.
// mandatory=true would mark such events as failed and retry them
// forever; instead we treat broker-acceptance as published.
// Consumers with already-declared durable queues + bindings receive
// events that arrive after their bindings exist; events published
// before any binding existed for that routing key are dropped by
// the broker. This is the accepted trade-off — the durable outbox
// guarantees event durability at-the-producer, not subscriber-side
// backfill. Broker-side delivery is the broker's job, not the
// drainer's.
let publish_result: Result<(Vec<Uuid>, Vec<(Uuid, String)>), DrainError> = async {
let channel = manager.current_channel().await?;
channel.confirm_select(ConfirmSelectOptions::default()).await?;
let mut confirmed_ids = Vec::new();
let mut failed: Vec<(Uuid, String)> = Vec::new();
for chunk in claimed.chunks(cfg.pipeline_depth) {
let mut pending = Vec::with_capacity(chunk.len());
for (id, payload, _attempts) in chunk {
let env = match serde_json::from_value::<EventEnvelope>(payload.clone()) {
Err(e) => { failed.push((*id, format!("deserialise: {e}"))); continue; }
Ok(env) => env,
};
let bytes = match serde_json::to_vec(&env) {
Err(e) => { failed.push((*id, format!("serialise: {e}"))); continue; }
Ok(b) => b,
};
// basic_publish returning an error is a per-message
// failure (channel/broker hiccup for this publish);
// record it and proceed, do not bail.
let confirm = match channel.basic_publish(
EVENTS_EXCHANGE.into(),
env.event_type.as_str().into(),
BasicPublishOptions::default(),
&bytes,
BasicProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
).await {
Err(e) => { failed.push((*id, format!("publish: {e}"))); continue; }
Ok(c) => c,
};
pending.push((*id, confirm));
}
for (id, confirm) in pending {
// Lapin's Confirmation enum:
// Ack(None) — broker accepted, normal case → mark published
// Ack(Some(BasicReturnMessage)) — only seen with mandatory=true (unroutable); we
// don't set mandatory, so this should not occur,
// but be defensive: treat as failed publish
// Nack(_) — broker rejected (e.g. resource limit reached)
// → bump attempts, retry next tick
match confirm.await {
Ok(Confirmation::Ack(None)) => confirmed_ids.push(id),
Ok(Confirmation::Ack(Some(ret))) => {
failed.push((id, format!("ack with return: {ret:?}")));
}
Ok(Confirmation::Nack(reason)) => {
failed.push((id, format!("nack: {reason:?}")));
}
Ok(Confirmation::NotRequested) => unreachable!("confirm_select was called"),
Err(e) => failed.push((id, format!("amqp: {e}"))),
}
}
}
Ok((confirmed_ids, failed))
}.await;
let (confirmed_ids, failed) = match publish_result {
Ok(pair) => pair,
Err(e) => {
// Infrastructure failure (channel open, confirm_select). Don't
// bump attempts (nothing was wrong with the messages). Just
// release every claim we took in Phase 1.
let all_ids: Vec<Uuid> = claimed.iter().map(|(id, _, _)| *id).collect();
release_claims_only(pool, &all_ids, &cfg.drainer_id).await?;
return Err(e);
}
};
// ----- Phase 3: mark results in two short txes, guarded by claimed_by -----
if !confirmed_ids.is_empty() {
sqlx::query(
"UPDATE event_outbox
SET published_at = now(), claimed_at = NULL, claimed_by = NULL
WHERE id = ANY($1) AND claimed_by = $2"
)
.bind(&confirmed_ids).bind(&cfg.drainer_id)
.execute(pool).await?;
}
if !failed.is_empty() {
// Bulk failure update: bump attempts, store last_error, clear claim.
// unnest carries the per-row error string in lockstep with the id.
let (ids, errs): (Vec<_>, Vec<_>) = failed.iter().cloned().unzip();
sqlx::query(
"UPDATE event_outbox AS o
SET attempts = o.attempts + 1,
last_error = f.err,
claimed_at = NULL,
claimed_by = NULL
FROM unnest($1::uuid[], $2::text[]) AS f(id, err)
WHERE o.id = f.id AND o.claimed_by = $3"
)
.bind(&ids).bind(&errs).bind(&cfg.drainer_id)
.execute(pool).await?;
}
Ok(DrainStats {
claimed: claimed.len(),
confirmed: confirmed_ids.len(),
failed: failed.len(),
})
}
/// Bulk-clear claims without bumping attempts. Used when Phase 2
/// hits an infrastructure error (channel/confirm_select) before any
/// per-message work — the messages are blameless, the drainer hit
/// transient broker trouble. Releasing the claim lets the next tick
/// retry the batch with a fresh channel.
async fn release_claims_only(
pool: &PgPool,
ids: &[Uuid],
drainer_id: &str,
) -> Result<(), sqlx::Error> {
if ids.is_empty() { return Ok(()); }
sqlx::query(
"UPDATE event_outbox
SET claimed_at = NULL, claimed_by = NULL
WHERE id = ANY($1) AND claimed_by = $2"
)
.bind(ids).bind(drainer_id)
.execute(pool).await?;
Ok(())
}
Index
CREATE INDEX event_outbox_lease_idx
ON event_outbox (claimed_at NULLS FIRST, enqueued_at)
WHERE published_at IS NULL;
Rationale: NULLS FIRST on claimed_at puts unclaimed rows at the front of the index (the common case). The runtime predicate claimed_at IS NULL OR claimed_at < now() - … is evaluated against indexed rows; now() is not allowed inside a partial-index predicate, so we keep the index condition stable and filter at scan time. The existing event_outbox_unpublished_idx stays during transition; remove in a follow-up MR after observing query plans on a realistic workload.
Configuration
| Env var | Default | Purpose |
|---|---|---|
|
250ms (existing) |
Sleep between drain ticks. Unchanged. |
|
100 (matches the existing |
Max rows claimed per drain tick. New env var; |
|
60 |
How long a claim survives before another drainer reclaims it. Must exceed worst-case batch publish time. |
|
32 |
Max in-flight publishes before awaiting confirms within a batch. |
|
|
Source for |
|
7 (existing) |
Janitor sweep threshold for published rows. Unchanged. |
DrainerConfig invariant guards
DrainerConfig::from_env() constructs the config from env vars and must enforce three guards at startup, all assert! so misconfiguration fails fast at boot rather than silently at the first drain tick:
assert!(cfg.batch_size > 0, "CANOPY_MQ_DRAINER_BATCH_SIZE must be > 0; got 0 would make claimed.chunks(0) panic");
assert!(cfg.pipeline_depth > 0, "CANOPY_MQ_DRAINER_PIPELINE_DEPTH must be > 0; got 0 would make claimed.chunks(0) panic");
Then the lease-vs-pipeline relationship:
// Heuristic: assume a worst-case per-publish latency of 100ms (broker
// stress + TLS handshake). pipeline_depth × 100ms must stay well under
// lease_ttl, with ≥3× safety margin so a slow batch can't expire its
// own lease mid-publish.
let assumed_max_publish_ms = 100u64;
let worst_case_batch_ms = (cfg.pipeline_depth as u64) * assumed_max_publish_ms;
let lease_ttl_ms = cfg.lease_ttl_secs * 1000;
assert!(
lease_ttl_ms >= worst_case_batch_ms * 3,
"CANOPY_MQ_DRAINER_LEASE_TTL_SECS ({lease}s) must be ≥ 3× worst-case batch publish time \
(pipeline_depth × 100ms = {worst}ms). Either raise lease_ttl_secs or lower pipeline_depth.",
lease = cfg.lease_ttl_secs,
worst = worst_case_batch_ms,
);
This makes the documented invariant ("lease TTL must dominate worst-case batch publish time") a hard startup check rather than an aspiration. Misconfiguration fails fast, not silently.
Invariants the design preserves
-
No broker I/O inside any DB transaction. Single hard rule. Every other property follows from this.
-
At-least-once delivery. This is unchanged from the original ADR-018 contract. A publish that succeeds but whose Phase 3 mark fails (process crash, lease expiry mid-confirm) will be re-published on a subsequent drain tick. Subscribers must be idempotent via the
event_inboxON CONFLICT DO NOTHING pattern (issues #437 / #433). Duplicate publishes are a property of this design, not an antipattern to be eliminated. -
No concurrent active claim on the same row.
FOR UPDATE SKIP LOCKEDin Phase 1 prevents two drainers from claiming the same row at the same instant. Theclaimed_by-guarded Phase 3 prevents a slow drainer whose lease has already expired from marking rows another drainer has reclaimed. This is stronger than "no double-mark" but weaker than "no double-publish across replicas" — the latter is not achievable while preserving at-least-once. -
Crash safety. A drainer that crashes mid-batch leaves rows with
claimed_atset butpublished_at IS NULL. Afterlease_ttl_secs, the next claim cycle (any replica) reclaims them.attemptsis not bumped at claim time, so replays don’t inflate the counter. A crash between publish-success and Phase-3-mark produces a duplicate publish on retry (see at-least-once above). -
Forward-only schema migrations (ADR-016 compliance). Adding nullable columns + a new partial index — additive only.
-
No "release-orphaned-claim" race. Phase 2’s infrastructure-error path uses
release_claims_onlywhich is guarded byclaimed_by = $drainer_id. If a slow drainer’s lease has already expired and another drainer reclaimed the row, the release UPDATE no-ops on the rebound claim.
Steps
Step 1: Schema migrations (19 services)
Files: services/canopy-{appeals,applications,caps,eligibility,enrollment,exchange,medicaid,notices,persons,portal,renewals,reporting,rules,security,snap,tanf,verification,web,wic}/migrations/{TS}_event_outbox_lease_columns.sql
This is 19 services, not 13. Run find services -name '*_create_event_outbox.sql' before starting to confirm the current set. Every service that has an existing event_outbox migration must also get the lease migration — including canopy-rules, canopy-portal, canopy-web, canopy-verification, canopy-exchange, canopy-reporting which were originally omitted from this plan. If any service is missed, that service’s drainer will fail at startup with column "claimed_at" does not exist.
{TS} is $(date -u '+%Y%m%d%H%M%S') computed at implementation time. Do not reuse the timestamp from the plan draft. The existing per-service event_outbox migration is 20260508000000_create_event_outbox.sql; the new migration must sort lexicographically after it. Verify with ls services/canopy-medicaid/migrations/ and use a timestamp strictly greater than the latest existing migration.
Byte-identical content across all 19 services:
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Outbox-drainer lease columns + lease-aware partial index.
-- See: docs/modules/ROOT/pages/plans/archive/outbox-drainer-lease-refactor.adoc
ALTER TABLE event_outbox
ADD COLUMN claimed_at TIMESTAMPTZ NULL,
ADD COLUMN claimed_by TEXT NULL;
CREATE INDEX event_outbox_lease_idx
ON event_outbox (claimed_at NULLS FIRST, enqueued_at)
WHERE published_at IS NULL;
Forward-only per ADR-016. The existing event_outbox_unpublished_idx is intentionally not dropped in this migration.
Step 2: Drainer refactor
Files: crates/canopy-mq/src/outbox_drainer.rs
Replace drain_once. Add a DrainerConfig struct holding batch_size, lease_ttl_secs, pipeline_depth, drainer_id. Read env vars at OutboxDrainer::spawn and pass through.
Step 3: Channel-per-batch publisher confirms
Files: crates/canopy-mq/src/outbox_drainer.rs (same file as Step 2 — this is the publish-phase implementation detail)
current_channel() opens fresh channels per call (see crates/canopy-mq/src/connection.rs), so we acquire one channel for the entire batch, call confirm_select once on it, then publish + await confirms in pipelined chunks. The existing try_publish_via_manager helper at crates/canopy-mq/src/publisher.rs:175 is used by crates/canopy-mq/src/replay.rs:94 (operator replay path); leave it alone. The drainer’s old call site at the current outbox_drainer.rs:145 is removed when drain_once is replaced.
We deliberately do NOT set mandatory=true. Rationale documented inline in the pseudocode comment: in a topic event bus, "no queue currently bound for this routing key" is a normal condition (subscriber down, dev/test without all services). With mandatory=true, unroutable messages return via BasicReturnMessage carried inside Confirmation::Ack(Some(_)) and would be retried indefinitely. Without mandatory, broker-accept is sufficient: the broker’s exchange→queue routing is the broker’s job, and an unbound routing key drops the message — the outbox row is retired as published.
The trade-off this accepts: an event published while no subscriber binding exists is lost to that subscriber. The deployment expectation is that subscribers register their durable queues + bindings at process start before any event traffic for their routing keys; recovering "events I missed before I bound" is not a property the outbox provides and is not the goal here. If we ever need that semantic, the path is per-subscriber dead-letter / catch-up queues, not mandatory=true on the producer side.
Step 4: Error path: clear claim on every failure
Files: crates/canopy-mq/src/outbox_drainer.rs
Three failure classes, all merged into one failed: Vec<(Uuid, String)> accumulator inside drain_once, then handled by one bulk UPDATE in Phase 3:
| Class | Source | Error string format |
|---|---|---|
Deserialisation |
|
|
Serialisation |
|
|
Per-message publish error |
|
|
Broker Nack |
|
|
Defensive: Ack with returned message |
|
|
Per-message AMQP confirm error |
|
|
All six classes accumulate into the same failed: Vec<(Uuid, String)> and are handled by one bulk Phase-3 UPDATE.
Separately, infrastructure-level errors (channel = manager.current_channel().await? or channel.confirm_select(…).await?) abort Phase 2 entirely; their handling is the release_claims_only fallback in the outer match on publish_result. These do not bump attempts because no message-level work happened — the next tick retries the whole batch with a fresh channel.
A row that hits ANY of these is pushed into failed. The Phase 3 bulk-failure UPDATE then runs:
UPDATE event_outbox AS o
SET attempts = o.attempts + 1,
last_error = f.err,
claimed_at = NULL,
claimed_by = NULL
FROM unnest($1::uuid[], $2::text[]) AS f(id, err)
WHERE o.id = f.id AND o.claimed_by = $3
Partial-failure invariant: if 80 of a 100-row batch confirm and 20 fail, the 80 get published_at set + claim cleared, the 20 get attempts++ + last_error set + claim cleared. No row exits Phase 3 still claimed. No row sits in claimed_at != NULL waiting for lease TTL.
The claimed_by = $3 guard means a slow drainer whose lease has already expired (and whose rows were reclaimed by another drainer) silently no-ops on the UPDATE — the other drainer’s claim wins. The slow drainer’s stale view doesn’t corrupt state.
Step 5: Config
Files: crates/canopy-mq/src/outbox_drainer.rs
Three new env var readers (batch_size(), lease_ttl_secs(), pipeline_depth()) matching the existing drainer_tick() / retention_days() shape. batch_size() defaults to the existing DRAIN_BATCH_SIZE constant (100); lease_ttl_secs() defaults to 60; pipeline_depth() defaults to 32. Each is consumed by DrainerConfig::from_env() which then applies the zero-size and lease-vs-pipeline guards (see Design section). Update the module-level doc-comment block listing all tunable env vars.
Step 6: Janitor unchanged
Files: crates/canopy-mq/src/outbox_drainer.rs
No code change in janitor_loop. Just a doc-comment update noting that lease recovery is handled by the claim path, not the janitor.
Step 7: Tests
Files: crates/canopy-mq/src/outbox_drainer.rs (new #[cfg(test)] mod lease_tests { … } at end of file)
drain_once is currently private. The four new tests need direct control over drain_once to exercise lease semantics deterministically (an integration test going through OutboxDrainer::spawn only observes outcomes, not the per-tick lifecycle). Put the new tests as a [cfg(test)] mod lease_tests block at the end of outbox_drainer.rs; they then have access to all crate-private items including drain_once and DrainerConfig. This is the same pattern used by the existing [cfg(test)] mod tests block at the bottom of crates/canopy-mq/src/subscriber.rs. Existing integration tests in crates/canopy-mq/tests/outbox_drainer_test.rs remain as-is — those exercise the public OutboxDrainer::spawn surface and are still valid.
Four practical tests per the reviewer’s enumerated list. Use the existing canopy_test_lib::EphemeralSchema pattern for schema isolation (one schema per test, dropped at end). Stand up a real RabbitMQ via the existing devstack.
Broker fixture pattern (read existing tests first)
Before writing the new tests, read crates/canopy-mq/tests/outbox_drainer_test.rs (existing) and crates/canopy-mq/tests/reconnect_test.rs to copy the broker-setup pattern those tests already use — they handle channel creation, exchange/queue declaration, and cleanup. The new tests reuse that scaffolding rather than introducing a new pattern.
For test 3 (deserialise_failure_bumps_attempts_and_clears_claim), no special broker setup is needed. The test inserts a row whose payload JSONB does not deserialise as EventEnvelope (e.g., the literal jsonb {"not": "an envelope"}). The drainer’s Phase 2 catches the serde_json::from_value error, pushes a ("deserialise: …", id) entry into failed, and proceeds. No publish is attempted for that row. The test asserts the DB state after drain_once returns (attempts = 1, claim cleared, last_error starts with "deserialise: ") and that the broker received zero messages. This is fully deterministic and does not depend on any broker behaviour.
For test 4 (two_drainers_do_not_double_publish_to_broker), use a unique-per-test routing key for the 100 valid rows (e.g., format!("test.drainer.{}", uuid::Uuid::now_v7())). Bind a test consumer queue to that routing key before the drainers start. After both drainers return, drain the test queue and assert message count == 100. Per-row attribution (which drainer published which) is intentionally not asserted because claimed_by clears on Phase 3 success; the property under test is the aggregate "no double publish to broker", not the bookkeeping detail of which drainer won which row.
| Test fn | Setup | Assertion |
|---|---|---|
|
Insert one row with |
Call |
|
Insert N=3 valid rows. |
Call |
|
Insert one row with a deliberately-malformed JSON payload that does not deserialise as |
Call |
|
Insert N=100 valid rows. Spawn two |
After both return, assert: every row has |
The four tests collectively verify every property the reviewer flagged: lease recovery, successful happy-path, per-row failure handling (deserialisation as a deterministic stand-in for any failure class), and multi-drainer no-double-publish (within drainer lifetimes; at-least-once still permits duplicates across crashes).
Step 8: Docs
Files: CHANGELOG.adoc, docs/modules/ROOT/pages/adrs/adr-018-persistent-outbox.adoc, .claude/docs/shared-crates.md
CHANGELOG entry
Insert under == Unreleased → === Changed:
* *canopy-mq outbox drainer refactored to lease-based three-phase pattern.*
Previous `drain_once` held a Postgres transaction across N RabbitMQ
publishes; on workspace-integration concurrency this serialised foreground
COMMITs behind WAL fsync and produced multi-second tail latency (#477
follow-up). New design: Phase 1 claim with `claimed_at`/`claimed_by`
columns + skip-locked CTE; Phase 2 publishes outside any transaction with
channel-per-batch publisher confirms; Phase 3 bulk-marks results.
At-least-once delivery contract unchanged — broker-side consumers already
idempotent via `event_inbox`. No external API change.
ADR-018 amendment
Append to adr-018-persistent-outbox.adoc as a new === Amendment (YYYY-MM-DD): lease-based drainer section. Template:
=== Amendment ({implementation_date}): lease-based drainer
The drainer's original implementation opened a single transaction spanning
the batch's RabbitMQ publishes. Under workspace integration load this
produced multi-second COMMITs as the per-batch UPDATEs accumulated WAL
records the foreground COMMITs had to fsync behind.
This amendment establishes one hard invariant: *no broker I/O inside a
database transaction.* The drainer now operates in three phases — claim,
publish (no tx), mark — using two new lease columns (`claimed_at`,
`claimed_by`) on `event_outbox`. Crashes mid-batch are recovered by the
next drainer tick claiming rows whose `claimed_at` is older than
`CANOPY_MQ_DRAINER_LEASE_TTL_SECS`. The producer-side guarantee from the
original ADR (event durably written iff domain transaction commits) is
unchanged. The at-least-once delivery contract is also unchanged —
subscribers must remain idempotent via the `event_inbox` ON CONFLICT
pattern from xref:adrs/adr-018-persistent-outbox.adoc[ADR-018]'s consumer
half (issues #437 / #433).
See xref:plans/archive/outbox-drainer-lease-refactor.adoc[outbox-drainer-lease-refactor]
for design details and verification.
Files Touched
| File | Change |
|---|---|
|
New migration: add |
|
Rewrite |
|
Untouched. Existing integration tests against |
|
Entry under |
|
Amendment paragraph: lease invariant + at-least-once contract. |
|
New env vars table entries. |
Verification
Run each step in order. A failure at any step blocks the implementation; do not proceed.
-
Unit + scenario tests —
cargo nextest run -p canopy-mqpasses. The four new in-sourcelease_tests(lease recovery, happy-path bulk, deserialisation-failure handling, multi-drainer no-double-publish) all green. The existing integration tests intests/outbox_drainer_test.rs(againstOutboxDrainer::spawn) also pass — no regressions. -
Schema applied —
cargo xtask dev restartsucceeds (all 19 services migrate cleanly). Thendocker exec canopy-postgres-1 psql -U canopy -d canopy_medicaid -c "\d event_outbox"showsclaimed_at | timestamp with time zoneandclaimed_by | textcolumns;\di event_outbox_lease_idxreturns the new partial index. Spot-check four services that span the publishing-service / passive-service distinction:canopy_medicaid(heavy publisher),canopy_rules(eval-audit publisher),canopy_web(originally omitted from the 13-service list — must be present),canopy_portal(also originally omitted). If any of the four does not have the new columns, Step 1 was incomplete. -
Workspace integration green —
cargo nextest run --workspace --profile integrationruns to completion with 0 failures. Previously this run failed 7–12 tests in thecanopy-snap::*post_determine*,canopy-tanf::*post_determine*,canopy-caps::*caps_denied*families. Same suite passes now. -
Wait-event evidence — re-run step 3 with the diagnostic poller from
/tmp/pg_wait_poller.sh(script body in the diagnostic-instrumentation commit8110430). Inspect/tmp/pg_wait.logafterward: zero rows withstate='idle in transaction'andquerymatchingUPDATE event_outbox SET published_at = now(). Zero rows withwait_event='WalSync'ANDquery='COMMIT'lasting >100ms (sample the file withawk -F'|' '$5=="IO" && $6=="WalSync" {print $0}'). Theidle in transactionpattern that motivated this plan must be absent. -
Rules-engine timing —
docker logs --since 5m canopy-canopy-rules-1 | grep canopy_rules::engine::timingshowspersist_msp99 < 50ms. A jq one-liner suffices:… | jq -r 'select(.persist_ms != null) | .persist_ms' | sort -n | awk 'BEGIN{c=0}{a[c++]=$1}END{print "p50="a[int(c*0.5)]" p99="a[int(c*0.99)]" max="a[c-1]}'. -
Full validate —
cargo xtask validatepasses end-to-end (fmt + clippy + nextest + e2e + docker build). -
Lint —
cargo clippy --workspace --tests — -D warningsis clean. No new#[allow(clippy::*)]was introduced.
After all seven verification steps pass, revert the diagnostic-instrumentation commit (8110430 on chore/centralize-sqlx-migrate-bootstrap) as a separate small commit on this branch. Lease refactor stays; diagnostic plumbing departs.
Quantitative success criteria
-
p99 of canopy-rules
persist_msunder workspace integration load: from observed ≥6.7s peak → < 50ms -
Number of
idle in transactionrows onUPDATE event_outbox …: from regular bursts in the poller log → 0 -
Workspace integration suite pass rate: from intermittent 7–12 failures per run → 1725/1725 passing
Documentation Updates
-
CHANGELOG.adoc— entry under=== Changed -
docs/modules/ROOT/pages/adrs/adr-018-persistent-outbox.adoc— amendment -
.claude/docs/shared-crates.md— env vars -
.claude/docs/services.md— no changes (no API surface change)
Sequencing with the in-flight publish_tx MR
This plan depends on the publish_tx migration (currently on branch chore/centralize-sqlx-migrate-bootstrap) being merged first. That MR’s foreground hot-path improvement is independently valuable; the drainer-lease fix here addresses a separate (and partially overlapping) cause of the same observed test-suite timeouts.
Decision point for the publish_tx MR itself, to be resolved by the human reviewer before this plan’s implementation begins:
-
(a) Hold publish_tx until lease refactor lands. Clean sequencing, costs publish_tx time.
-
(b) Land publish_tx with a temporary 10s → 30s bump on
canopy-rules-client+canopy-test-libHTTP timeouts as a documented time-boxed mitigation. Revert the timeout bump in the same commit that lands this plan.
The plan as written assumes (b) for narrative continuity; either ordering implements the same end state.
Implementation-day housekeeping
When a fresh agent or human picks this plan up:
-
Copy the entire AsciiDoc body of this plan (the
= Plan: outbox-drainer lease-based three-phase refactorblock through== Sequencing …) intodocs/modules/ROOT/pages/plans/archive/outbox-drainer-lease-refactor.adoc. That is the durable artifact per ADR-013 plan-lifecycle. -
After the durable plan file is committed, delete the scratchpad
~/.claude/plans/elegant-tinkering-pudding.md. (Plans don’t live in~/.claude/plans/; they live in the repo.) -
File a GitLab issue with the title "Refactor outbox drainer to lease-based three-phase pattern (#477 follow-up)" and labels
priority::high,service::shared-crates,program::infrastructure,type::fix,workflow::ready. Update theIssuesline at the top of the durable plan with the new issue number. -
Create branch
fix/outbox-drainer-leaseoff latestmain. -
Implement Steps 1 → 9 in order. Each step is independently committable; commit after each green compile + relevant test pass.