Skip to content

Add bounded backpressure for producer and consumer event sequences#237

Open
ssikka100 wants to merge 2 commits into
swift-server:mainfrom
ssikka100:fix/producer-backpressure
Open

Add bounded backpressure for producer and consumer event sequences#237
ssikka100 wants to merge 2 commits into
swift-server:mainfrom
ssikka100:fix/producer-backpressure

Conversation

@ssikka100

@ssikka100 ssikka100 commented May 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

Add watermark-based backpressure to producer and consumer event sequences to prevent unbounded memory growth when events are consumed slower than they are produced.

Problem

The events AsyncSequence (from makeProducerWithEvents/makeConsumerWithEvents) used NoBackPressure strategy. If users produce faster than they consume events, delivery reports accumulate without limit — potential OOM under sustained load. Go's confluent-kafka-go solves this with a bounded 1M channel that blocks the poller when full.

Fix

  • Switch from NoBackPressure to HighLowWatermark strategy (configurable, defaults: 100k low / 1M high matching Go)
  • When high watermark is hit, event loop continues polling librdkafka but stops yielding to the events sequence
  • sendAndAwait continuations are always serviced regardless of backpressure state (matching Go's per-message channel behavior)
  • Natural backpressure cascade: Swift buffer full → librdkafka internal queue fills → send() returns QueueFull
  • Consumer always yields during shutdown to ensure rebalance/close signals are delivered
  • Additionally fixes a pre-existing memory leak where rd_kafka_error_t* was not destroyed on produce failure, and switches to safer RD_KAFKA_VTYPE_HEADERS ownership pattern (matching Go and Rust)

Test plan

  • producerBackpressureSaturatesCQueue — events not consumed → QueueFull
  • sendAndAwaitWorksWhileBackpressured — continuations serviced during backpressure
  • producerResumesAfterEventsDrained — send works again after draining events
  • gracefulShutdownWhileBackpressured — shutdown completes without hanging
  • producerBackpressure (integration) — end-to-end with real broker
  • sendAndAwaitWorksDuringBackpressure (integration) — real ack returned during backpressure

🤖 Generated with Claude Code

When a topic does not exist or fails authorization, the librdkafka partitioner
synchronously fails. If  is used, librdkafka allocates
an internal headers list and transfers ownership to the message. Upon partitioner
failure, librdkafka frees the message (which also frees the headers) and then
attempts to free the internal headers pointer again, leading to a double-free crash.

This fix switches to , manually creating the headers
list and allowing  to correctly leave memory lifecycle management
to the caller when the partitioner fails. This prevents the double-free crash.
It also fixes a pre-existing memory leak where the error pointer from
rd_kafka_produceva was not being destroyed.
@ssikka100 ssikka100 changed the title Fix produce memory leak and add bounded backpressure for event sequences Add bounded backpressure for producer and consumer event sequences May 11, 2026
@ssikka100 ssikka100 added the 🆕 semver/minor Adds new public API. label May 11, 2026
@ssikka100 ssikka100 force-pushed the fix/producer-backpressure branch 3 times, most recently from e4b1030 to d8033f5 Compare May 11, 2026 04:03
Motivation:
The events AsyncSequence (used by makeProducerWithEvents and
makeConsumerWithEvents) used NIOAsyncSequenceProducer with NoBackPressure
strategy. If the user produces messages faster than they consume events,
delivery reports accumulate without limit, eventually causing OOM. Go's
confluent-kafka-go solves this with a bounded 1M channel that blocks the
poller when full. Rust avoids it entirely via per-message oneshots.

Modifications:
- Switch from NoBackPressure to HighLowWatermark strategy for both
  producer and consumer event sequences
- Add configurable eventsBackpressureLowWatermark and
  eventsBackpressureHighWatermark to KafkaProducerConfig and
  KafkaConsumerConfig (defaults: 100k/1M matching Go)
- When high watermark is hit, event loop continues polling librdkafka
  but stops yielding to the events sequence. This ensures sendAndAwait
  continuations are always serviced (matching Go's per-message channel
  behavior) while letting librdkafka's internal queue fill naturally
  until send() returns QueueFull
- Consumer always yields during shutdown regardless of backpressure
  state to ensure rebalance/close signals are delivered
- Add isYieldingPaused flag to state machines (producer and consumer)
  with pauseYielding()/resumeYielding() transitions

Result:
Producers using send() + events get natural backpressure: buffer fills
to 1M events, then librdkafka's C-queue saturates, then send() throws
QueueFull. sendAndAwait always works regardless of backpressure state.
Graceful shutdown always completes. No more unbounded memory growth.
@ssikka100 ssikka100 force-pushed the fix/producer-backpressure branch from d8033f5 to 6ed9f72 Compare May 11, 2026 04:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🆕 semver/minor Adds new public API.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant