Summary
On a task whose messages carry an ETA/countdown, the count-based flush trigger is reseeded from the number of parked requests:
if len(ready_requests) > 0:
logger.debug("Batches: Ready buffer complete: %s", len(ready_requests))
self.flush(ready_requests)
self._count = count(self._pending.qsize() + 1) # celery_batches/__init__.py#L345
_pending holds requests whose ETA has not elapsed. On a delayed queue that backlog is large and roughly constant, so the next count flush fires after flush_every - len(_pending) further arrivals instead of flush_every. The trigger no longer tracks how full _buffer is, and mean batch size plateaus near half of flush_every at any arrival rate.
Queues with no ETA are unaffected: their _pending is always empty, so count(_pending.qsize() + 1) and count(1) are the same.
Reproduction
Discrete-event simulation driving the real Strategy() handler and _do_flush against a virtual clock. Poisson arrivals, flush_every=16, flush_interval=5, a 120 s countdown on every message, 5 seeds × 1800 s. Only monotonic, to_timestamp and the request factory are faked; the trigger logic is unmodified.
| arrival (msg/s) |
mean batch, current |
mean batch, count(1) |
| 1 |
4.0 |
5.0 |
| 3 |
6.3 |
11.2 |
| 5 |
6.7 |
12.2 |
| 13.9 |
7.6 |
14.4 |
| 40 |
8.0 |
15.4 |
Current behaviour plateaus at 8.0/16 however high arrival goes; reseeding from 1 reaches 15.4/16.
The invariant break is visible directly: at 13.9 msg/s, 212 of 212 count-triggered flushes carried 12 requests against flush_every=16. A count-triggered flush should mean flush_every messages were buffered.
Why the current seed backfires
Seeding with _pending.qsize() + 1 makes the next flush fire sooner when a backlog exists, which reads as "there is parked work, so check more often". But parked work is not gated on checking — only on its ETA elapsing. The extra wake-ups cannot release anything early; they only chop _buffer into smaller batches, which is the opposite of what the count trigger is for.
Suggested fix
- self._count = count(self._pending.qsize() + 1)
+ self._count = count(1)
The counter counts arrivals into _buffer. _pending is a separate population the count trigger does not gate, so folding it into the seed conflates the two.
Happy to open a PR with a regression test if you'd like the change in this shape — and equally happy to be told the current seed is deliberate for a case I've missed.
Summary
On a task whose messages carry an ETA/countdown, the count-based flush trigger is reseeded from the number of parked requests:
_pendingholds requests whose ETA has not elapsed. On a delayed queue that backlog is large and roughly constant, so the next count flush fires afterflush_every - len(_pending)further arrivals instead offlush_every. The trigger no longer tracks how full_bufferis, and mean batch size plateaus near half offlush_everyat any arrival rate.Queues with no ETA are unaffected: their
_pendingis always empty, socount(_pending.qsize() + 1)andcount(1)are the same.Reproduction
Discrete-event simulation driving the real
Strategy()handler and_do_flushagainst a virtual clock. Poisson arrivals,flush_every=16,flush_interval=5, a 120 s countdown on every message, 5 seeds × 1800 s. Onlymonotonic,to_timestampand the request factory are faked; the trigger logic is unmodified.count(1)Current behaviour plateaus at 8.0/16 however high arrival goes; reseeding from 1 reaches 15.4/16.
The invariant break is visible directly: at 13.9 msg/s, 212 of 212 count-triggered flushes carried 12 requests against
flush_every=16. A count-triggered flush should meanflush_everymessages were buffered.Why the current seed backfires
Seeding with
_pending.qsize() + 1makes the next flush fire sooner when a backlog exists, which reads as "there is parked work, so check more often". But parked work is not gated on checking — only on its ETA elapsing. The extra wake-ups cannot release anything early; they only chop_bufferinto smaller batches, which is the opposite of what the count trigger is for.Suggested fix
The counter counts arrivals into
_buffer._pendingis a separate population the count trigger does not gate, so folding it into the seed conflates the two.Happy to open a PR with a regression test if you'd like the change in this shape — and equally happy to be told the current seed is deliberate for a case I've missed.