Skip to content

Add configurable timeout for break_on_empty - #155

Merged
eandersson merged 3 commits into
mainfrom
empty_timeout
Jun 26, 2026
Merged

Add configurable timeout for break_on_empty#155
eandersson merged 3 commits into
mainfrom
empty_timeout

Conversation

@eandersson

@eandersson eandersson commented Jun 24, 2026

Copy link
Copy Markdown
Owner

Replace the hardcoded 1.0s empty-queue wait in build_inbound_messages with a configurable empty_timeout param (default unchanged). process_data_events now uses empty_timeout=None so start_consuming returns as soon as the queue drains, restoring 3.0.x consume responsiveness without dropping in-flight messages.

@eandersson

Copy link
Copy Markdown
Owner Author

@tichi73 would you be open to reviewing this change?

@tichi73

tichi73 commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Thanks for the follow-up @eandersson — making the grace configurable and having process_data_events pass empty_timeout=None cleanly restores the 3.0.x responsiveness while keeping a knob for direct build_inbound_messages callers. I validated the branch against RabbitMQ 4.3.1 (8 runs): large-message start_consuming() is back to a ~1450 MB/s median (vs ~112 on stock 3.1.x), a no_ack post-cancel test loses nothing, and unit tests + flake8 are green.

One thing I'd suggest fixing — a post-cancel in-flight message can be dropped. Moving if not self.consumer_tags: break ahead of the break_on_empty/empty guard means build_inbound_messages breaks while _inbound still holds a lone Basic.Deliver whose header/body haven't been read yet — dropping a message the broker had already started delivering. Deterministic repro (no broker; identical build_inbound_messages(break_on_empty=True) call): Deliver enqueued → consumer cancelled → header+body arrive:

this branch (empty_timeout): delivered per run: [0, 0, 0, 0, 0]   # dropped
with the fix below:          delivered per run: [1, 1, 1, 1, 1]

The window is microscopic on loopback (which is why a no_ack post-cancel throughput test shows no loss), but it widens under network latency/load, and it's on the normal start_consuming path (the break fires regardless of empty_timeout). The one-line fix is to break only when the queue is also empty:

-                if not self.consumer_tags:
+                if not self.consumer_tags and not self._inbound:
                     break

Repro: https://gist.github.com/tichi73/b753d22734163e5e40d14aa563fdc03c

A few smaller things — entirely your call:

  • start_consuming() with no consumer now returns silently rather than raising AMQPChannelError('no consumer callback defined') (the :raises still in the docstring).
  • With the current placement, build_inbound_messages also exits on an empty queue with no consumer even when break_on_empty=False, so break_on_empty no longer fully controls exit-on-empty.
  • The empty_timeout docstring says only None exits immediately, but any falsy value (e.g. 0) does too — and it isn't validated (non-numeric → TypeError, negative → immediate break).
  • The new empty_timeout parameter has no dedicated test yet (None/0 → immediate; a custom value → waits that long).

I prototyped fixes + tests for all of these on a branch (validated: the drop repro goes 0/5, throughput unchanged, 426 unit tests green) — happy to open a PR if useful. The in-flight drop is the only one I'd consider important; the rest are at your discretion.

@eandersson

Copy link
Copy Markdown
Owner Author

Thanks for the follow-up @eandersson — making the grace configurable and having process_data_events pass empty_timeout=None cleanly restores the 3.0.x responsiveness while keeping a knob for direct build_inbound_messages callers. I validated the branch against RabbitMQ 4.3.1 (8 runs): large-message start_consuming() is back to a ~1450 MB/s median (vs ~112 on stock 3.1.x), a no_ack post-cancel test loses nothing, and unit tests + flake8 are green.

One thing I'd suggest fixing — a post-cancel in-flight message can be dropped. Moving if not self.consumer_tags: break ahead of the break_on_empty/empty guard means build_inbound_messages breaks while _inbound still holds a lone Basic.Deliver whose header/body haven't been read yet — dropping a message the broker had already started delivering. Deterministic repro (no broker; identical build_inbound_messages(break_on_empty=True) call): Deliver enqueued → consumer cancelled → header+body arrive:

this branch (empty_timeout): delivered per run: [0, 0, 0, 0, 0]   # dropped
with the fix below:          delivered per run: [1, 1, 1, 1, 1]

The window is microscopic on loopback (which is why a no_ack post-cancel throughput test shows no loss), but it widens under network latency/load, and it's on the normal start_consuming path (the break fires regardless of empty_timeout). The one-line fix is to break only when the queue is also empty:

-                if not self.consumer_tags:
+                if not self.consumer_tags and not self._inbound:
                     break

Repro: https://gist.github.com/tichi73/b753d22734163e5e40d14aa563fdc03c

A few smaller things — entirely your call:

  • start_consuming() with no consumer now returns silently rather than raising AMQPChannelError('no consumer callback defined') (the :raises still in the docstring).
  • With the current placement, build_inbound_messages also exits on an empty queue with no consumer even when break_on_empty=False, so break_on_empty no longer fully controls exit-on-empty.
  • The empty_timeout docstring says only None exits immediately, but any falsy value (e.g. 0) does too — and it isn't validated (non-numeric → TypeError, negative → immediate break).
  • The new empty_timeout parameter has no dedicated test yet (None/0 → immediate; a custom value → waits that long).

I prototyped fixes + tests for all of these on a branch (validated: the drop repro goes 0/5, throughput unchanged, 426 unit tests green) — happy to open a PR if useful. The in-flight drop is the only one I'd consider important; the rest are at your discretion.

Thanks for the thorough review! I've fixed the most critical issues and left the rest to keep this PR manageable. If it looks good to you and you have time, a follow-up on the remaining points after it's merged would be much appreciated.

@tichi73

tichi73 commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Confirmed on 0f47f0d: the in-flight drop is gone (repro now 1/1 kept, 0/5 dropped) and start_consuming raises again — moving the consumer_tags break inside the guard also resolves the break_on_empty=False point. LGTM. Once this merges I'll send the follow-up for the empty_timeout docstring/validation (+ a focused test for the param). Thanks!

@eandersson
eandersson merged commit 413150b into main Jun 26, 2026
15 checks passed
@eandersson

Copy link
Copy Markdown
Owner Author

Confirmed on 0f47f0d: the in-flight drop is gone (repro now 1/1 kept, 0/5 dropped) and start_consuming raises again — moving the consumer_tags break inside the guard also resolves the break_on_empty=False point. LGTM. Once this merges I'll send the follow-up for the empty_timeout docstring/validation (+ a focused test for the param). Thanks!

Thanks again!

tichi73 added a commit to tichi73/amqpstorm that referenced this pull request Jun 26, 2026
Follow-up to eandersson#155: build_inbound_messages now raises
AMQPInvalidArgument when empty_timeout is not None and not a real,
non-negative number (rejecting non-numeric values, bool, negatives and NaN)
instead of failing later inside the loop, and the docstring notes that any
falsy value (None or 0) exits immediately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tichi73 added a commit to tichi73/amqpstorm that referenced this pull request Jun 26, 2026
Follow-up to eandersson#155: build_inbound_messages now raises
AMQPInvalidArgument when empty_timeout is not None and not a real,
non-negative number (rejecting non-numeric values, bool, negatives and NaN)
instead of failing later inside the loop, and the docstring notes that any
falsy value (None or 0) exits immediately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tichi73 added a commit to tichi73/amqpstorm that referenced this pull request Jun 29, 2026
Follow-up to eandersson#155: build_inbound_messages now raises
AMQPInvalidArgument when empty_timeout is not None and not a real,
non-negative number (rejecting non-numeric values, bool, negatives and NaN)
instead of failing later inside the loop, and the docstring notes that any
falsy value (None or 0) exits immediately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
eandersson pushed a commit that referenced this pull request Jul 6, 2026
…#155) (#156)

* Validate empty_timeout and align its docstring with the implementation

Follow-up to #155: build_inbound_messages now raises
AMQPInvalidArgument when empty_timeout is not None and not a real,
non-negative number (rejecting non-numeric values, bool, negatives and NaN)
instead of failing later inside the loop, and the docstring notes that any
falsy value (None or 0) exits immediately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Add tests for empty_timeout values and validation

- a falsy empty_timeout (None or 0) exits immediately without consulting
  the timer.
- a custom empty_timeout waits that long.
- invalid empty_timeout (non-numeric, negative, bool, NaN) raises
  AMQPInvalidArgument.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants