Skip to content

fix: restore consumer bindings deleted behind its back - #26

Merged
KARTIKrocks merged 2 commits into
mainfrom
fix/consumer-topology-refresh
Aug 1, 2026
Merged

fix: restore consumer bindings deleted behind its back#26
KARTIKrocks merged 2 commits into
mainfrom
fix/consumer-topology-refresh

Conversation

@KARTIKrocks

@KARTIKrocks KARTIKrocks commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Summary

Deleting an exchange destroys its bindings but leaves the consumer's queue, channel and consume entirely valid: no error, no basic.cancel, no channel close, and so nothing to trigger a re-setup. The consumer stayed alive bound to nothing for the rest of the process while publishes kept succeeding — a confirm means the broker accepted the message, not that it routed — so every message was silently dropped at the exchange.

This is the half of channel-level recovery that v0.12.0 could not reach. A publisher notices because its next publish to the missing exchange draws a 404 that closes its channel; there is no equivalent signal on the consumer side, which made the result worse than no recovery at all: the topology looks healthy and nothing anywhere reports a problem.

AMQP announces nothing here, so the only way to notice is to declare again. A consumer that declares topology now re-applies it every 30s by default, configurable (or disabled) with WithTopologyRefresh. Every declaration is idempotent, so a refresh is a no-op unless something is missing, and a consumer that declares no topology of its own never starts the loop.

The refresh runs on its own short-lived channel rather than the consuming one: a declaration that cannot succeed — an exchange re-created with a different type, say — is then reported each interval instead of closing the channel deliveries are consumed on, which would drop every unacknowledged message with it. Server-named queues are deliberately not re-declared, since an empty name asks the broker for a new queue; channel setup still owns those.

applyTopology is split into declareExchanges/declareQueue/applyBindings so setup and refresh share one definition of the declared topology.

Motivation

Fixes #

Changes

Checklist

  • fmt, vet, lint, test, build passes (make all)
  • New code has tests where appropriate
  • Breaking changes are documented

Summary by CodeRabbit

  • New Features

    • Consumer topology is now automatically refreshed every 30 seconds by default.
    • Deleted exchanges, queues, and bindings can be restored without reconnecting.
    • Refresh intervals can be customized or disabled.
    • Anonymous queue names remain stable during refreshes.
  • Bug Fixes

    • Topology refresh failures are reported as warnings without interrupting consumption.
  • Documentation

    • Added guidance for configuring topology refresh and understanding recovery behavior.

Deleting an exchange destroys its bindings but leaves the consumer's queue,
channel and consume entirely valid: no error, no basic.cancel, no channel
close, and so nothing to trigger a re-setup. The consumer stayed alive bound
to nothing for the rest of the process while publishes kept succeeding — a
confirm means the broker accepted the message, not that it routed — so every
message was silently dropped at the exchange.

This is the half of channel-level recovery that v0.12.0 could not reach. A
publisher notices because its next publish to the missing exchange draws a 404
that closes its channel; there is no equivalent signal on the consumer side,
which made the result worse than no recovery at all: the topology looks
healthy and nothing anywhere reports a problem.

AMQP announces nothing here, so the only way to notice is to declare again.
A consumer that declares topology now re-applies it every 30s by default,
configurable (or disabled) with WithTopologyRefresh. Every declaration is
idempotent, so a refresh is a no-op unless something is missing, and a
consumer that declares no topology of its own never starts the loop.

The refresh runs on its own short-lived channel rather than the consuming one:
a declaration that cannot succeed — an exchange re-created with a different
type, say — is then reported each interval instead of closing the channel
deliveries are consumed on, which would drop every unacknowledged message with
it. Server-named queues are deliberately not re-declared, since an empty name
asks the broker for a new queue; channel setup still owns those.

applyTopology is split into declareExchanges/declareQueue/applyBindings so
setup and refresh share one definition of the declared topology.
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@KARTIKrocks, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 39 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5f06d88a-8e5d-48f9-bb8e-9aa08e77467d

📥 Commits

Reviewing files that changed from the base of the PR and between ae07bbd and db3d876.

📒 Files selected for processing (2)
  • consumer.go
  • consumer_test.go

Walkthrough

Consumers with declarative topology now refresh exchanges, queues, dead-letter topology, and bindings every 30 seconds by default. The interval can be configured or disabled. Refreshes use a dedicated channel and stop safely during consumer closure.

Changes

Consumer topology refresh

Layer / File(s) Summary
Topology configuration and declaration setup
consumer.go
ConsumerConfig adds TopologyRefreshInterval, TopologyRefreshDisabled, and WithTopologyRefresh. Topology declaration now separates exchanges, queues, and bindings and returns resolved queue names.
Refresh execution and shutdown
consumer.go
Consumers start a ticker-based refresh loop when enabled topology exists. Refresh failures remain isolated to the refresh channel. Shutdown signals the loop and waits with a five-second bound.
Behavior validation and public documentation
consumer_test.go, integration_test.go, README.md, doc.go, CHANGELOG.md
Tests cover configuration, recovery, failures, anonymous queues, reconnects, and close races. Documentation describes configuration and publisher recovery behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Consumer
  participant RefreshLoop
  participant RefreshChannel
  participant RabbitMQ
  Consumer->>RefreshLoop: Start enabled topology refresh
  RefreshLoop->>RefreshChannel: Acquire dedicated channel
  RefreshChannel->>RabbitMQ: Re-declare topology
  RabbitMQ-->>RefreshChannel: Return declaration result
  RefreshLoop-->>Consumer: Preserve consuming channel
  Consumer->>RefreshLoop: Stop during close
Loading

Possibly related PRs

Poem

Exchanges return, bindings align,
A quiet channel keeps the line.
Queues hold fast through every test,
Refresh loops pause when consumers rest.
Rabbit topology stays in tune.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: restoring consumer bindings deleted without triggering consumer-side recovery.
Description check ✅ Passed The description contains the required sections and clearly explains the motivation and implementation, despite an empty Changes list and unchecked checklist.
Docstring Coverage ✅ Passed Docstring coverage is 80.95% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/consumer-topology-refresh

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@consumer_test.go`:
- Around line 670-690: Add a test case in the existing topology refresh test
table with TopologyRefreshInterval explicitly set to zero, ensuring the guard
does not rewrite it before c.startTopologyRefresh(). Keep the long-interval
setup for other cases so the test remains deterministic, and verify the zero
interval starts the refresh loop through the default interval branch.

In `@consumer.go`:
- Around line 1079-1098: Update awaitTopologyRefreshStopped to accept the
caller’s context and select on ctx.Done() alongside completion and the existing
5-second timeout. Pass the CloseWithContext context at its call site, while
keeping Close’s context.Background() behavior so the fixed cap remains in effect
there.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1e65cdef-242e-43fc-a0e1-8373df334577

📥 Commits

Reviewing files that changed from the base of the PR and between b9fed5b and ae07bbd.

📒 Files selected for processing (6)
  • CHANGELOG.md
  • README.md
  • consumer.go
  • consumer_test.go
  • doc.go
  • integration_test.go

Comment thread consumer_test.go
Comment thread consumer.go
@greptile-apps

greptile-apps Bot commented Aug 1, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds periodic topology refresh to consumers, fixing a silent data-loss bug where deleting an exchange destroys its bindings but leaves the consumer's channel and basic.consume completely intact — no error, no basic.cancel, nothing to trigger recovery. Messages published to the re-created exchange are silently dropped indefinitely.

  • applyTopology is refactored into declareExchanges / declareQueue / applyBindings so both channel setup and the new refresh path share one definition of declared topology.
  • topologyRefreshLoop runs on its own goroutine with a dedicated channel (not the consuming one), so a failing declaration is reported as a warning each interval rather than killing the channel that holds in-flight deliveries.
  • WithTopologyRefresh is added to ConsumerConfig; enabled at 30 s by default, disabled explicitly via TopologyRefreshDisabled, and a no-op for consumers that declare no topology of their own.

Confidence Score: 5/5

Safe to merge — the change is additive, well-tested with both unit and integration coverage, and the shutdown ordering is correct.

The refresh goroutine lifecycle is carefully managed: started only when topology is declared, stopped under the mutex before channel teardown, and bounded by a dual timeout so a blocked broker call cannot hold Close open. The dedicated refresh channel correctly isolates declaration failures from the consuming channel. Server-named queues are deliberately excluded from re-declaration with a matching stale-name check. Integration tests cover all key scenarios including the close/race path under -race.

Files Needing Attention: No files require special attention. The consumer.go changes are the core of the PR and are well-structured.

Important Files Changed

Filename Overview
consumer.go Core change: adds stopRefresh/refreshWg to Consumer, refactors applyTopology into three helpers, and introduces topologyRefreshLoop / refreshTopology / applyRefresh. Shutdown ordering (stopRefresh closed under lock, then awaitTopologyRefreshStopped with dual-timeout) is correct. Server-named queue guard in applyRefresh and stale-name detection are handled.
consumer_test.go Adds unit tests for WithTopologyRefresh and startTopologyRefresh; covers enabled/disabled/zero-interval cases and goroutine lifecycle without a live broker.
integration_test.go Adds 7 integration tests covering the primary bug scenario, foreign-exchange rebind, disabled refresh, server-named queue stability, channel isolation on failure, reconnect survival, and close/race. Test coverage is comprehensive.
CHANGELOG.md Adds v0.13.0 entry documenting the fix and the new WithTopologyRefresh option with examples.
README.md Adds "Topology refresh" subsection explaining the deletion scenario, the default 30 s interval, and the isolation guarantee of the dedicated channel.
doc.go Package-level doc updated with one paragraph describing the periodic refresh and how to tune it.

Sequence Diagram

sequenceDiagram
    participant NC as NewConsumer
    participant C as Consumer
    participant RL as topologyRefreshLoop (goroutine)
    participant RCh as refresh Channel
    participant Broker as RabbitMQ Broker
    participant CWC as CloseWithContext

    NC->>C: setupChannel() — initial topology declared
    NC->>C: startTopologyRefresh()
    C->>RL: go topologyRefreshLoop(interval)
    RL->>RCh: conn.Channel() — open dedicated refresh channel

    loop every interval
        RL->>RCh: applyRefresh(ch)
        RCh->>Broker: ExchangeDeclare / QueueDeclare / QueueBind (idempotent)
        alt topology intact
            Broker-->>RCh: OK (no-op)
        else binding/exchange deleted
            Broker-->>RCh: OK (restored)
        else wrong exchange type
            Broker-->>RCh: PRECONDITION_FAILED
            RCh->>RCh: ch.Close() — refresh channel spent
            RL->>RL: log.Warnf — consuming channel unaffected
            Note over RL: next tick opens a new refresh channel
        else connection down
            RCh-->>RL: ErrNotConnected / amqp.ErrClosed
            RL->>RL: log.Debugf — deferred, self-correcting
        end
    end

    CWC->>C: close(stopRefresh) [under mu]
    CWC->>C: awaitTopologyRefreshStopped(ctx)
    RL-->>RL: stopRefresh fires, return
    RL->>RCh: deferred ch.Close()
    C-->>CWC: refreshWg.Done() — close proceeds
Loading

Reviews (2): Last reviewed commit: "fix: apply topology refresh review feedb..." | Re-trigger Greptile

Comment thread consumer.go Outdated
Comment thread consumer.go
- Close honours its context while waiting for the refresh loop to stop, as it
  already does for in-flight handlers, and is a true no-op for a consumer that
  never started one instead of spawning a goroutine per Close.

- A bind that fails because a reconnect renamed the server-named queue between
  reading the name and the round-trip is logged at debug, not warned about: it
  is transient and the next tick binds the new name. Detected by re-reading the
  name after the failure, so a NOT_FOUND against a stable name — a missing
  exchange nobody re-creates, the case the refresh exists to report — still
  warns.

- The refresh-loop test table covers an explicitly zero interval, exercising
  the branch that substitutes the default.
@KARTIKrocks
KARTIKrocks merged commit 62d95d5 into main Aug 1, 2026
12 checks passed
@KARTIKrocks
KARTIKrocks deleted the fix/consumer-topology-refresh branch August 1, 2026 16:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant