Skip to content

Fix message-processing lifecycle, shutdown, and reconnect bugs#100

Merged
gmr merged 2 commits into
mainfrom
fix/message-lifecycle
Jul 6, 2026
Merged

Fix message-processing lifecycle, shutdown, and reconnect bugs#100
gmr merged 2 commits into
mainfrom
fix/message-lifecycle

Conversation

@gmr

@gmr gmr commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes 17 verified bugs in the message-processing lifecycle across process.py, consumer.py, connection.py, and log.py. These cover the ack/nack path, in-flight tracking, connection reconnect/shutdown, signal handling, and consumer lifecycle hooks.

Problem / Solution

Each fix is surgical and covered by a reproducing test.

Testing

Full suite green (231 tests) + ruff clean. New/updated tests in tests/test_process.py, tests/test_consumer.py, and new tests/test_connection.py.

Closes #68
Closes #69
Closes #70
Closes #71
Closes #72
Closes #73
Closes #74
Closes #75
Closes #78
Closes #80
Closes #81
Closes #82
Closes #84
Closes #85
Closes #86
Closes #87
Closes #88

Summary by CodeRabbit

  • New Features
    • Added per-message correlation ID tracking for better logging during concurrent processing.
    • Republished processing failures now preserve original AMQP message properties and improve retry/error handling.
    • Improved connection lifecycle behavior for smoother reconnects and shutdowns, including safer signal handling.
  • Bug Fixes
    • Fixed duplicate initialization under concurrency and hardened handling of invalid processing-exception headers.
    • Corrected in-flight tracking to avoid delivery-tag collisions and improved ack/reject behavior for returned messages.
    • Enabled SSL hostname verification for safer TLS connections.
  • Tests
    • Expanded unit and async test coverage for the above behaviors.

Fixes a group of verified bugs across the process/consumer/connection
message lifecycle. Each is covered by a new or updated unit test.

Closes #68 - Consumer._process_standard now calls finish() after process()
Closes #69 - _republish preserves original AMQP properties, not just headers
Closes #70 - invoke_consumer pops the in-flight entry on DecodeError
Closes #71 - stop() sets STOP_REQUESTED before shutting down connections so
  the wait-for-in-flight path is no longer dead code
Closes #72 - pending shutdown tasks (consumer.shutdown, codec.close) are run
  to completion after the loop stops instead of being abandoned
Closes #73 - _in_flight is keyed by (connection_name, delivery_tag) to avoid
  cross-connection tag collisions
Closes #74 - Connection.shutdown() is a no-op when closed, guards
  connection.close(), no longer deletes self.connection; reconnect assigns
  the new AsyncioConnection
Closes #75 - a terminal channel error while processing now reconnects rather
  than leaving a zombie process
Closes #78 - the legacy SSL path sets check_hostname before verify_mode
Closes #80 - a non-numeric X-Processing-Exceptions header is treated as 0
  instead of raising ValueError into an infinite requeue loop
Closes #81 - correlation id is carried per-message (contextvar + ctx),
  Consumer runs _pre_execute inside the lock, and initialize() is guarded
Closes #82 - the KeyboardInterrupt handler no longer double-nacks
Closes #84 - a reconnecting connection re-issues its own basic_consume
Closes #85 - reject() is a metrics-only no-op under no_ack instead of raising
Closes #86 - returned messages (delivery_tag=None) skip broker ack/nack
Closes #87 - shutdown_connections also shuts down CONNECTING connections
Closes #88 - SIGABRT/SIGPROF handlers only schedule work via
  call_soon_threadsafe, keeping pika interaction on the loop

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4e657c1e-dffb-4931-b5b0-53be50cc285d

📥 Commits

Reviewing files that changed from the base of the PR and between 280e393 and 4d1a102.

📒 Files selected for processing (4)
  • rejected/connection.py
  • rejected/consumer.py
  • tests/test_consumer.py
  • tests/test_process.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • rejected/connection.py
  • tests/test_consumer.py
  • rejected/consumer.py
  • tests/test_process.py

📝 Walkthrough

Walkthrough

This PR updates connection shutdown and TLS handling, makes consumer initialization and correlation tracking concurrency-safe, preserves AMQP properties on republish, hardens processing-exception parsing, and revises process in-flight tracking, reconnect, shutdown, and signal behavior with new tests.

Changes

Connection Shutdown & TLS Hardening

Layer / File(s) Summary
Connection shutdown and TLS checks
rejected/connection.py, tests/test_connection.py
shutdown() now short-circuits closed connections, guards close calls, preserves connection during failure handling, and enables hostname verification in legacy SSL options, with matching tests.

Consumer Concurrency & Lifecycle Fixes

Layer / File(s) Summary
Initialization and correlation context
rejected/consumer.py, rejected/log.py, tests/test_consumer.py
Consumer initialization is serialized with a per-instance lock, correlation IDs are written per message and read from task-local logging context, and tests cover concurrent isolation and single initialization.
Republish and retry-header handling
rejected/consumer.py, tests/test_consumer.py
Processing-exception counters tolerate invalid values, and republished error messages preserve original AMQP properties alongside updated headers.
KeyboardInterrupt and finish lifecycle
rejected/consumer.py, tests/test_consumer.py
The KeyboardInterrupt path no longer calls reject directly, and the standard execution path now calls finish() after process() while always running on_finish().

Process In-Flight Tracking & Shutdown Reliability

Layer / File(s) Summary
In-flight tracking and broker disposition
rejected/process.py, tests/test_process.py
In-flight entries use connection name plus delivery tag, decode failures clear pending entries, and ack/reject handling skips broker calls for returned or missing-tag messages while updating metrics.
Reconnect, shutdown, and signal flow
rejected/process.py, tests/test_process.py
Connection failure and ready callbacks now reconnect active connections and resume consuming per connection, shutdown covers connecting connections, stop() defers shutdown while processing, pending tasks are completed after loop exit, and SIGABRT/SIGPROF are scheduled onto the event loop.

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

Sequence Diagram(s)

sequenceDiagram
  participant MessageA
  participant MessageB
  participant Consumer
  participant InitLock
  participant LogAdapter
  MessageA->>Consumer: execute()
  MessageB->>Consumer: execute()
  Consumer->>InitLock: _ensure_initialized()
  InitLock-->>Consumer: initialize() runs once
  Consumer->>LogAdapter: set correlation_id context
  LogAdapter-->>Consumer: task-local correlation_id
Loading
sequenceDiagram
  participant Broker
  participant Process
  participant Connection
  participant IOLoop
  Broker--xConnection: failure / close
  Connection->>Process: on_connection_failure()
  Process->>Connection: connect() when processing
  Connection-->>Process: on_connection_ready()
  Process->>Connection: consume()
  Process->>IOLoop: call_soon_threadsafe(stop or _report_stats)
Loading

Possibly related PRs

  • gmr/rejected#54: It also changes rejected/consumer.py execution and initialization behavior in the same consumer flow.
  • gmr/rejected#60: It touches the same connection, consumer, log, and process paths and introduces closely related reliability changes.

Poem

I twitched my nose at every queue,
And wrapped each close in careful glue.
Correlation hops from task to task,
No finish lost behind the mask.
Hop, hop — the process stays awake 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the main change set: lifecycle, shutdown, and reconnect bug fixes.
Linked Issues check ✅ Passed Issues [68-88] appear addressed by the code and tests: lifecycle, republish, decode/shutdown/reconnect, SSL, correlation, no-ack, return, and signal fixes.
Out of Scope Changes check ✅ Passed The changes stay centered on the message-processing lifecycle and its tests, with no clearly unrelated additions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

@gmr

gmr commented Jul 6, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@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: 1

🧹 Nitpick comments (2)
rejected/connection.py (1)

87-93: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Sibling close paths remain unguarded.

The new else branch guards self.connection.close() against AttributeError/ConnectionWrongStateError, but the is_active/self.channelbasic_cancel(...) branch (Lines 79-86) and the elif self.channel: self.channel.close() branch (Line 87-88) have no equivalent guard. A channel already mid-close or in a wrong state could raise the same class of pika exceptions this fix is meant to prevent.

♻️ Suggested guard for the channel-close branch
         elif self.channel:
-            self.channel.close()
+            try:
+                self.channel.close()
+            except (AttributeError, pika.exceptions.ChannelWrongStateError):
+                pass
🤖 Prompt for 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.

In `@rejected/connection.py` around lines 87 - 93, The close logic in the
connection teardown path still leaves the channel-based branches unprotected, so
`ConnectionWrongStateError` or `AttributeError` can escape from
`basic_cancel(...)` or `self.channel.close()`. Update the close flow in
`Connection.close` to apply the same exception guard used for
`self.connection.close()` to the `is_active`/`self.channel.basic_cancel(...)`
path and the `elif self.channel: self.channel.close()` path, using the existing
`self.channel` and `self.connection` branches as the fix points.
tests/test_process.py (1)

428-435: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test covers only state transition, not the actual deferred-shutdown drain.

test_stop_while_processing_defers_shutdown verifies stop() sets STATE_STOP_REQUESTED and skips immediate shutdown_connections, but it does not assert that once _in_flight drains, on_processed() actually invokes shutdown_connections. Since on_processed gates that on is_waiting_to_shutdown while is_processing also matches STATE_STOP_REQUESTED, adding a follow-through test (drain last in-flight message, assert shutdown) would close the gap for bug #71.

🤖 Prompt for 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.

In `@tests/test_process.py` around lines 428 - 435, The current test only checks
that stop() switches Process to STATE_STOP_REQUESTED and skips immediate
shutdown, but it misses the deferred drain path. Extend
test_stop_while_processing_defers_shutdown in test_process.py to follow through
by simulating the last in-flight message finishing and calling on_processed(),
then assert that shutdown_connections is invoked once _in_flight reaches zero.
Use the existing Process methods stop(), on_processed(), and the
is_waiting_to_shutdown/STATE_STOP_REQUESTED flow to verify the deferred shutdown
behavior end to end.
🤖 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 `@rejected/consumer.py`:
- Around line 166-173: The correlation-id handling in FunctionalConsumer is
still using shared instance state and an inconsistent missing-value check.
Update the correlation_id property and the execute/_pre_execute path so
per-message reads use task-local state (or otherwise avoid relying on
self._correlation_id across concurrent process(ctx) calls), keeping the public
property aligned with the contextvar-based logging behavior. Also make the
msg.correlation_id write-back condition match the cid selection logic in the
same branch, so empty-string correlation IDs are treated consistently with None
and the message is republished with the same id that was logged.

---

Nitpick comments:
In `@rejected/connection.py`:
- Around line 87-93: The close logic in the connection teardown path still
leaves the channel-based branches unprotected, so `ConnectionWrongStateError` or
`AttributeError` can escape from `basic_cancel(...)` or `self.channel.close()`.
Update the close flow in `Connection.close` to apply the same exception guard
used for `self.connection.close()` to the
`is_active`/`self.channel.basic_cancel(...)` path and the `elif self.channel:
self.channel.close()` path, using the existing `self.channel` and
`self.connection` branches as the fix points.

In `@tests/test_process.py`:
- Around line 428-435: The current test only checks that stop() switches Process
to STATE_STOP_REQUESTED and skips immediate shutdown, but it misses the deferred
drain path. Extend test_stop_while_processing_defers_shutdown in test_process.py
to follow through by simulating the last in-flight message finishing and calling
on_processed(), then assert that shutdown_connections is invoked once _in_flight
reaches zero. Use the existing Process methods stop(), on_processed(), and the
is_waiting_to_shutdown/STATE_STOP_REQUESTED flow to verify the deferred shutdown
behavior end to end.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: eebc78d2-3106-48b3-9437-abdc55a5464e

📥 Commits

Reviewing files that changed from the base of the PR and between 401dd78 and 280e393.

📒 Files selected for processing (7)
  • rejected/connection.py
  • rejected/consumer.py
  • rejected/log.py
  • rejected/process.py
  • tests/test_connection.py
  • tests/test_consumer.py
  • tests/test_process.py

Comment thread rejected/consumer.py
- consumer.py: make correlation_id write-back use `not msg.correlation_id`
  so an empty-string id is treated the same as None and the message is
  republished with the id that was logged.
- consumer.py: have the public `correlation_id` property prefer the
  task-local contextvar, so concurrent FunctionalConsumer messages don't
  read another in-flight message's id via shared instance state.
- connection.py: guard the channel.close() teardown branch against
  AttributeError/ChannelWrongStateError, matching the connection.close()
  branch (the basic_cancel branch is intentionally left unguarded).
- tests: extend correlation isolation test to assert the property is
  per-message, and add a deferred-shutdown drain follow-through test for
  issue #71.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@gmr

gmr commented Jul 6, 2026

Copy link
Copy Markdown
Owner Author

Addressed the two nitpicks in 4d1a102:

  • connection.py sibling close paths — Applied the guard to the elif self.channel: self.channel.close() branch (AttributeError/ChannelWrongStateError). I deliberately left the basic_cancel(...) branch unguarded: it is gated by is_active, and swallowing an exception there would suppress the on_consumer_cancelled callback that drives shutdown to completion, risking a stalled/hung shutdown rather than the clean teardown a pass implies. The channel.close() path is safe to guard because pika still fires on_channel_closed regardless.

  • test_process.py deferred-shutdown drain — Added test_deferred_shutdown_fires_when_in_flight_drains, which puts the process into STATE_STOP_REQUESTED via stop(), then drives on_processed() with _in_flight empty (mirroring how invoke_consumer pops the tag before calling on_processed) and asserts shutdown_connections() is invoked exactly once, closing the Graceful-shutdown wait in Process.stop() is dead code; STATE_STOP_REQUESTED is never set #71 coverage gap end to end.

@gmr gmr merged commit b669e8b into main Jul 6, 2026
6 checks passed
gmr added a commit that referenced this pull request Jul 6, 2026
Bumps version from 4.0.0a3 to 4.0.0b1. This beta lands 32 verified
bug fixes across the message-processing lifecycle, daemon signal
handling, codecs, and metrics/exception handling (PRs #100-#104).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment