Plan: outbox-drainer lease-based three-phase refactor

On this page

Status

Step Description Status

1

Per-service event_outbox migrations adding claimed_at TIMESTAMPTZ NULL, claimed_by TEXT NULL, plus a lease-aware partial index. 19 services — verified by find services -name '*_create_event_outbox.sql' | wc -l. Byte-identical migration body. Filename pattern: {YYYYMMDDHHMMSS}_event_outbox_lease_columns.sql where the implementer chooses the timestamp at implementation time via date -u '+%Y%m%d%H%M%S'; do not reuse the draft date in this plan. New index is CREATE INDEX event_outbox_lease_idx ON event_outbox (claimed_at NULLS FIRST, enqueued_at) WHERE published_at IS NULL. The existing event_outbox_unpublished_idx is not dropped in this MR — observed perf first, removed in a follow-up if redundant.

Done (2026-05-18)

2

crates/canopy-mq/src/outbox_drainer.rs: replace the single drain_once transaction with a three-phase implementation. Phase 1 is a CTE-based statement using the idiomatic Postgres skip-locked-claim form: WITH locked AS (SELECT id … FOR UPDATE SKIP LOCKED LIMIT $batch) UPDATE event_outbox SET claimed_at=now(), claimed_by=$drainer_id FROM locked WHERE event_outbox.id = locked.id RETURNING id, payload, attempts. Phase 2 publishes outside any tx (see Step 3). Phase 3 marks results in two short txes (bulk UPDATE for successes, bulk UPDATE for failures), both guarded WHERE id = ANY($1) AND claimed_by = $drainer_id. Claim does NOT increment attempts — the existing semantics tie attempts to publish failures only. Crashes mid-batch reclaim with attempts intact via the lease.

Done (2026-05-18)

3

crates/canopy-mq/src/outbox_drainer.rs (continued): channel-per-batch publisher confirms. ConnectionManager::current_channel() opens a fresh channel per call (confirmed at crates/canopy-mq/src/connection.rs), so the batch-channel logic lives inline in drain_once. Per batch: channel.confirm_select(…​), then pipeline up to pipeline_depth publishes without mandatory — for a topic event bus, "no queue currently bound" is a valid state; mandatory=true would mark such events failed and retry indefinitely. Consumers with already-declared durable queues catch up on broker restarts via their persistent bindings; events published before any binding existed are dropped (this is the accepted trade-off — see Design rationale). Lapin’s Confirmation enum is matched explicitly: Ack(None) → confirmed; Ack(Some(BasicReturnMessage)) → unexpected (mandatory not set; defensive — treat as failed); Nack(_) → bumped attempts + retry next tick. Pipeline depth is bounded by CANOPY_MQ_DRAINER_PIPELINE_DEPTH (default 32).

Done (2026-05-18)

4

crates/canopy-mq/src/outbox_drainer.rs (continued): error-path handling. (a) Deserialisation / serialisation failure of a single row: clear claim, bump attempts, store last_error. (b) Per-message broker error (Nack, basic_publish Err, defensive Ack-with-return): clear claim, bump attempts, store last_error. (c) Infrastructure-level failure (current_channel, confirm_select): release ALL claims taken by this drainer in Phase 1 via release_claims_only without bumping attempts — the messages are blameless; the next tick retries with a fresh channel. The per-message failure path (a, b) goes through one bulk failure UPDATE guarded by WHERE id = ANY($ids) AND claimed_by = $drainer_id. The infra-failure path (c) uses a separate bulk-clear UPDATE with the same guard. Both run as their own short txes outside any DB transaction.

Done (2026-05-18)

5

New env vars wired via the existing CANOPY_MQ_* naming convention (matches CANOPY_MQ_DRAINER_TICK_MS, CANOPY_MQ_OUTBOX_RETENTION_DAYS, CANOPY_MQ_REPLICA_ID): CANOPY_MQ_DRAINER_BATCH_SIZE (default 100), CANOPY_MQ_DRAINER_LEASE_TTL_SECS (default 60), CANOPY_MQ_DRAINER_PIPELINE_DEPTH (default 32). claimed_by is sourced from the existing CANOPY_MQ_REPLICA_ID env var; fallback to format!("drainer-{hostname}-{pid}-{uuid}") with uuid = Uuid::now_v7() generated once at process start (so multiple drainer processes on the same host disambiguate).

Done (2026-05-18)

6

crates/canopy-mq/src/outbox_drainer.rs::janitor_loop stays at hygiene only. Lease recovery is NOT the janitor’s job — the next drain_once cycle reclaims any row whose claimed_at < now() - lease_ttl because the Phase 1 query already includes that predicate. Janitor’s existing retention-sweep (DELETE published rows older than CANOPY_MQ_OUTBOX_RETENTION_DAYS) is unchanged.

Done (2026-05-18)

7

Tests as a new #[cfg(test)] mod lease_tests block at the end of crates/canopy-mq/src/outbox_drainer.rs (in-source unit tests with access to the crate-private drain_once and DrainerConfig; same pattern as crates/canopy-mq/src/subscriber.rs:834). Four practical tests: (a) expired_lease_is_reclaimed_by_next_drain — insert a row with claimed_at = now() - 2 * lease_ttl, claimed_by='dead-drainer', run drain_once, assert the row is published; (b) successful_confirm_marks_published_and_clears_claim — happy-path bulk; (c) deserialise_failure_bumps_attempts_and_clears_claim — insert a row with malformed JSON payload, assert the per-message failure path clears claim, bumps attempts, sets last_error, and broker received zero messages (deterministic stand-in for the broader per-message-failure class); (d) two_drainers_do_not_double_publish_to_broker — run two drain_once calls concurrently with different drainer_id`s, assert aggregate: all rows published exactly once, sum of `DrainStats.confirmed = N, broker received exactly N messages. Existing integration tests in crates/canopy-mq/tests/outbox_drainer_test.rs (which exercise OutboxDrainer::spawn) remain unchanged.

Done (2026-05-18)

8

CHANGELOG entry under === Changed documenting the drainer refactor. ADR-018 amendment paragraph documenting (a) the lease semantics, (b) the "no broker I/O inside a DB tx" invariant, (c) the at-least-once delivery contract (subscribers must already be idempotent, which they are for any retry/replay scenario). .claude/docs/shared-crates.md updated to reflect the new env vars.

Done (2026-05-18)

9

Precommit Q1-Q8 answered via subagent verification per .githooks/pre-commit. Diff spans 19 migrations + ~250 LOC drainer rewrite + ~180 LOC tests; substantial enough that the subagent verification is warranted.

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:

  1. Awaits a RabbitMQ publish for each row (network round-trip)

  2. Runs UPDATE event_outbox SET published_at = now() WHERE id = $1 per row, inside the same transaction

  3. 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 attempts increment 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_id so a slow drainer can’t mark rows another drainer has reclaimed

  • Deserialisation failures must clear the claim

  • Channel-per-batch with confirm_select called once, not per-row

  • current_channel() opens fresh channels — batch-channel lives in drain_once, not behind the existing try_publish_via_manager helper

  • 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 the CANOPY_OUTBOX__* double-underscore scheme

Scope

In scope:

  • event_outbox schema additions: claimed_at, claimed_by, new partial index

  • OutboxDrainer::drain_once rewrite (three-phase, lease-based)

  • Channel-per-batch publisher confirms with confirm_select and 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_tests inside outbox_drainer.rs to access the crate-private drain_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_inbox consumer 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

CANOPY_MQ_DRAINER_TICK_MS

250ms (existing)

Sleep between drain ticks. Unchanged.

CANOPY_MQ_DRAINER_BATCH_SIZE

100 (matches the existing DRAIN_BATCH_SIZE constant)

Max rows claimed per drain tick. New env var; DrainerConfig::from_env() reads this and the existing DRAIN_BATCH_SIZE constant becomes its default.

CANOPY_MQ_DRAINER_LEASE_TTL_SECS

60

How long a claim survives before another drainer reclaims it. Must exceed worst-case batch publish time.

CANOPY_MQ_DRAINER_PIPELINE_DEPTH

32

Max in-flight publishes before awaiting confirms within a batch.

CANOPY_MQ_REPLICA_ID

format!("drainer-{hostname}-{pid}-{uuid}") (uuid = Uuid::now_v7() generated once at process start)

Source for claimed_by. Reused env var — but the fallback form (used when the env var is unset) must include pid and a UUID, not just hostname, to disambiguate when multiple drainer processes run on the same host (containerised dev devstack, test harness with multiple drainer instances, etc.). The existing crates/canopy-mq/src/subscriber.rs:108 derives a similar identifier for subscribers; mirror that pattern.

CANOPY_MQ_OUTBOX_RETENTION_DAYS

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_inbox ON 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 LOCKED in Phase 1 prevents two drainers from claiming the same row at the same instant. The claimed_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_at set but published_at IS NULL. After lease_ttl_secs, the next claim cycle (any replica) reclaims them. attempts is 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_only which is guarded by claimed_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

serde_json::from_value::<EventEnvelope>(payload) returns Err in Phase 2 inner loop

"deserialise: {e}"

Serialisation

serde_json::to_vec(&env) returns Err

"serialise: {e}"

Per-message publish error

channel.basic_publish(…​).await returns Err

"publish: {e}"

Broker Nack

confirm.await returns Ok(Confirmation::Nack(reason)) — broker rejected (e.g. resource limit, queue full)

"nack: {reason:?}"

Defensive: Ack with returned message

confirm.await returns Ok(Confirmation::Ack(Some(BasicReturnMessage))). With mandatory=true not set, this should not occur; record defensively as failed if observed.

"ack with return: {ret:?}"

Per-message AMQP confirm error

confirm.await returns Err(e)

"amqp: {e}"

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

expired_lease_is_reclaimed_by_next_drain

Insert one row with payload = {valid envelope}, published_at = NULL, claimed_at = now() - 2 * lease_ttl, claimed_by = 'dead-drainer', attempts = 0.

Call drain_once(cfg with drainer_id='alive-drainer'). Assert: row is published (broker received it), DB row has published_at IS NOT NULL, claimed_at IS NULL, claimed_by IS NULL, attempts = 0 (unchanged because publish succeeded). Verifies the lease-recovery path AND that attempts don’t inflate on reclaim.

successful_confirm_marks_published_and_clears_claim

Insert N=3 valid rows.

Call drain_once. Assert: all 3 rows have published_at IS NOT NULL, claimed_at IS NULL, claimed_by IS NULL. Broker received 3 messages on the expected routing key. Happy-path bulk.

deserialise_failure_bumps_attempts_and_clears_claim

Insert one row with a deliberately-malformed JSON payload that does not deserialise as EventEnvelope (e.g., {"not": "an envelope"} as the payload jsonb). With mandatory=true dropped, an unroutable-message broker-nack scenario can’t be triggered reliably from a test — but deserialisation failure exercises the same Phase-3 failure-bulk path and is fully deterministic.

Call drain_once. Assert: row has published_at IS NULL, claimed_at IS NULL, claimed_by IS NULL, attempts = 1, last_error starts with "deserialise: ". Broker received zero messages.

two_drainers_do_not_double_publish_to_broker

Insert N=100 valid rows. Spawn two tokio::task::spawn workers each calling drain_once with different drainer_id values against the same pool — start them within 1ms of each other.

After both return, assert: every row has published_at IS NOT NULL. Sum of DrainStats.confirmed across the two drainers equals 100. Broker received exactly 100 messages (drain a test consumer queue bound to the routing keys; assert message count). Note: claimed_by is cleared on success so per-row ownership cannot be reconstructed; we test the aggregate (100 unique publishes, not 200) which is the property that matters.

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.

shared-crates.md

Update the canopy-mq section to add rows for the three new env vars (CANOPY_MQ_DRAINER_BATCH_SIZE, CANOPY_MQ_DRAINER_LEASE_TTL_SECS, CANOPY_MQ_DRAINER_PIPELINE_DEPTH) in whatever env-var-listing format the existing canopy-mq section uses.

Step 9: Precommit Q1-Q8

Standard hook discipline; subagent verification required given the diff size.

Files Touched

File Change

services/canopy-{19 services}/migrations/{TS}_event_outbox_lease_columns.sql

New migration: add claimed_at, claimed_by, event_outbox_lease_idx. 19 × byte-identical files (see Step 1 for full service list). {TS} chosen at implementation time via date -u '+%Y%m%d%H%M%S', must lexicographically follow the existing per-service event_outbox migration.

crates/canopy-mq/src/outbox_drainer.rs

Rewrite drain_once (three-phase, lease-based). Add DrainerConfig + DrainerConfig::from_env() with batch_size/lease_ttl/pipeline_depth guards. Add env-var reader functions matching the existing drainer_tick()/retention_days() shape. Doc-comment update. Plus the new #[cfg(test)] mod lease_tests block at the end of the file containing the four direct drain_once tests per Step 7 — same in-source-unit-test pattern as subscriber.rs:834.

crates/canopy-mq/tests/outbox_drainer_test.rs

Untouched. Existing integration tests against OutboxDrainer::spawn remain valid.

CHANGELOG.adoc

Entry under === Changed.

docs/modules/ROOT/pages/adrs/adr-018-persistent-outbox.adoc

Amendment paragraph: lease invariant + at-least-once contract.

.claude/docs/shared-crates.md

New env vars table entries.

Verification

Run each step in order. A failure at any step blocks the implementation; do not proceed.

  1. Unit + scenario testscargo nextest run -p canopy-mq passes. The four new in-source lease_tests (lease recovery, happy-path bulk, deserialisation-failure handling, multi-drainer no-double-publish) all green. The existing integration tests in tests/outbox_drainer_test.rs (against OutboxDrainer::spawn) also pass — no regressions.

  2. Schema appliedcargo xtask dev restart succeeds (all 19 services migrate cleanly). Then docker exec canopy-postgres-1 psql -U canopy -d canopy_medicaid -c "\d event_outbox" shows claimed_at | timestamp with time zone and claimed_by | text columns; \di event_outbox_lease_idx returns 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.

  3. Workspace integration greencargo nextest run --workspace --profile integration runs to completion with 0 failures. Previously this run failed 7–12 tests in the canopy-snap::*post_determine*, canopy-tanf::*post_determine*, canopy-caps::*caps_denied* families. Same suite passes now.

  4. Wait-event evidence — re-run step 3 with the diagnostic poller from /tmp/pg_wait_poller.sh (script body in the diagnostic-instrumentation commit 8110430). Inspect /tmp/pg_wait.log afterward: zero rows with state='idle in transaction' and query matching UPDATE event_outbox SET published_at = now(). Zero rows with wait_event='WalSync' AND query='COMMIT' lasting >100ms (sample the file with awk -F'|' '$5=="IO" && $6=="WalSync" {print $0}'). The idle in transaction pattern that motivated this plan must be absent.

  5. Rules-engine timingdocker logs --since 5m canopy-canopy-rules-1 | grep canopy_rules::engine::timing shows persist_ms p99 < 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]}'.

  6. Full validatecargo xtask validate passes end-to-end (fmt + clippy + nextest + e2e + docker build).

  7. Lintcargo clippy --workspace --tests — -D warnings is 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_ms under workspace integration load: from observed ≥6.7s peak → < 50ms

  • Number of idle in transaction rows on UPDATE 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-lib HTTP 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:

  1. Copy the entire AsciiDoc body of this plan (the = Plan: outbox-drainer lease-based three-phase refactor block through == Sequencing …) into docs/modules/ROOT/pages/plans/archive/outbox-drainer-lease-refactor.adoc. That is the durable artifact per ADR-013 plan-lifecycle.

  2. 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.)

  3. 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 the Issues line at the top of the durable plan with the new issue number.

  4. Create branch fix/outbox-drainer-lease off latest main.

  5. Implement Steps 1 → 9 in order. Each step is independently committable; commit after each green compile + relevant test pass.

Edit this page · default