Skip to content

feat: pause Kafka partitions instead of blocking backoff for non-keyed messages#31

Open
aradng wants to merge 3 commits into
mainfrom
feat/kafka-non-keyed-retry-backoff
Open

feat: pause Kafka partitions instead of blocking backoff for non-keyed messages#31
aradng wants to merge 3 commits into
mainfrom
feat/kafka-non-keyed-retry-backoff

Conversation

@aradng

@aradng aradng commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Summary

  • Non-keyed Kafka topics have no ordering requirement, but the current retry backoff blocks the whole consumer's poll loop (every partition it owns) while any one message backs off - a real cost when unrelated messages are round-robined onto the same partition.
  • For a message with no key, KafkaSubscriber now pauses just the failing (topic, partition) via confluent_kafka.Consumer.pause()/.resume() and keeps polling, instead of asyncio.sleep-ing inline. Keyed messages are unchanged (still block inline - their partition is already one ordered stream, so blocking it costs nothing extra).
  • docs/adr/01-kafka-non-keyed-retry-backoff.md records the four options considered (numbered retry topics, an external delay store, max_workers concurrency, partition pause/resume) and why the first three were rejected.
  • docs/signals.md's Kafka retry section is trimmed to the operational facts, pointing to the ADR for the "why".

Test plan

  • tests/kafka/test_backoff.py: extended with keyed-vs-non-keyed branch coverage (pause/resume calls recorded on a fake consumer, repeated-failure backoff still doubles correctly).
  • tests/kafka/test_pause_resume_backoff.py: new real-broker (testcontainers) integration test - publishes to partition 0, waits for it to actually fail, then publishes to partition 1 and asserts it's processed well under the backoff delay. Verified this test fails against the pre-fix code (partition 1 blocked ~4.7s) and passes against the fix.
  • poetry run pre-commit run --all-files - all hooks pass.
  • poetry run pytest -n auto - 110 passed.

🤖 Generated with Claude Code

aradng and others added 3 commits July 22, 2026 13:42
…d messages

Non-keyed topics have no ordering requirement, so blocking a whole
consumer's poll loop while one message backs off needlessly delays
unrelated messages on other partitions. For a message with no Kafka key,
KafkaSubscriber now pauses just that partition (confluent_kafka's
Consumer.pause/.resume) and keeps polling, instead of sleeping inline.
Keyed messages are unchanged. See docs/adr/01-kafka-non-keyed-retry-backoff.md
for the alternatives considered (numbered retry topics, an external delay
store, max_workers concurrency) and why each was rejected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- pause() failure no longer swallows the original handler exception -
  falls back to inline backoff instead, preserving the NACK-and-redeliver
  contract.
- resume() retries up to 3 times with backoff before giving up, instead
  of silently stranding a partition paused forever on the first failure.
- KafkaSubscriber now cancels pending resume tasks when the broker stops,
  instead of leaving them to fire against an already-closed consumer.
- _is_keyed now checks every record in a batch (any() defaults to the
  safe/blocking choice) instead of classifying a mixed batch off record 0.
- collapsed _pause_partition/_resume_partition's duplicated
  TopicPartition-build-and-run_in_executor shape into one
  _set_partition_paused helper, reusing FastStream's own
  run_in_executor instead of hand-rolling it.
- tests: fixed-consumer thread pools now get shut down, added coverage
  for both new failure paths, and the integration test now asserts
  partition 0's original message actually gets redelivered and
  succeeds after resume (previously only checked partition 1 wasn't
  blocked), plus polls for topic readiness instead of a fixed sleep.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- moved keyed/non-keyed pause-resume test coverage from SimpleNamespace
  fakes into real-broker testcontainer tests (test_pause_resume_backoff.py)
  - a thin proxy wraps the real confluent Consumer to record/force-fail
  pause()/resume(), everything else (broker, thread pool, message flow)
  stays real. Each test uses its own topic to avoid cross-test message
  replay from auto_offset_reset="earliest" on a shared topic.
- test_backoff.py reverts to its original fake-message shape - it only
  ever exercises the keyed (inline-sleep) branch now, so none of the
  consumer/thread-pool machinery is needed there anymore.
- trimmed the new inline comments in depends.py to one-line pointers at
  docs/adr/01-kafka-retry-backoff.md instead of re-explaining the
  reasoning at each call site.
- renamed the ADR to docs/adr/01-kafka-retry-backoff.md.

Co-Authored-By: Claude Sonnet 5 <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.

1 participant