Skip to content

fix: production-readiness fixes for the Kafka and NATS backends#51

Merged
zannis merged 10 commits into
mainfrom
advisor/kafka-nats-prod-fixes
Jul 2, 2026
Merged

fix: production-readiness fixes for the Kafka and NATS backends#51
zannis merged 10 commits into
mainfrom
advisor/kafka-nats-prod-fixes

Conversation

@zannis

@zannis zannis commented Jul 2, 2026

Copy link
Copy Markdown
Owner

Production-readiness fixes for the Kafka and NATS backends, from a focused audit of both. Every fix was verified against real brokers (testcontainers); each commit is one self-contained fix.

Kafka

  • Rebalance-safe offset tracking (86671e0, 6523978): the consumer installed no rebalance callback, so a partition revoked and later reassigned kept a stale next_to_commit — that partition stopped committing for the life of the connection (routine with the autoscaler; reproduced in the new kafka_rebalance integration test, which fails on the old code). A RebalanceContext now forwards assign/revoke deltas to the receive loop, which drops affected tracker entries so reassigned partitions re-seed from actual delivery. While building the test, a second stall surfaced with hard evidence: librdkafka silently drops async commits submitted mid-rebalance (no error, no commit_callback). Fixed via a commit_callback failure hook plus re-offering retained partitions' positions after every rebalance event (re-commits are broker-side no-ops), and a 5s housekeeping tick so retries drain on idle topics. Test passed 8/8 consecutive runs.
  • Synchronous final commit on shutdown (4ad824f): the drain-then-commit on graceful shutdown used CommitMode::Async + .ok() and immediately dropped the consumer — the just-drained batch could be redelivered on every deploy. Now Sync, with the error logged.
  • Autoscaler lag honors auto.offset.reset (a82ac25): a group that had never committed was assigned the full high-watermark as lag — for latest-reset groups that meant a spurious scale-toward-max at startup, and for earliest groups it over-counted once retention truncated the log. Lag now derives from the reset policy (earliest → high−low, latest → 0). Breaking: KafkaQueueStatsProvider::get_queue_stats gains a required reset: KafkaAutoOffsetReset parameter (also newly re-exported from shove::kafka, which the docs already claimed).

NATS

  • ack_wait margin above the handler timeout (7cce999): pull consumers left ack_wait at JetStream's 30s default — exactly equal to the default handler timeout, so a handler running near its limit (or a message queued behind a full prefetch buffer) was redelivered while still in flight. All four consumer-creation sites now set ack_wait = max(3 × handler timeout, 30s); existing group durables are upgraded on redeclare (asserted in a test). Rollout note: FIFO-shard and DLQ durables created before this change keep 30s until recreated (get_or_create_consumer returns existing config verbatim).
  • publish_batch partial-failure accounting (3f74aa1): a mid-batch submission error abandoned every already-submitted ack and reported succeeded=0, so callers re-driving the batch produced duplicates. All submitted acks are now drained and counted; a fault-injection test asserts the reported count equals what JetStream actually stored.

Both backends

  • Reconnect budget bounds consecutive failures, not lifetime (6faadf5): max_reconnect_attempts never reset after a healthy run, so occasional blips over hours would eventually kill a healthy consumer. The budget and backoff now reset after 60s of healthy connection.
  • Truthful consumer-group liveness (b5ecbcc): dead consumer tasks stayed in the group vec forever — active_consumers() reported corpses as capacity, the autoscaler scaled against fiction, and scale_down could "cancel" an already-dead member. Counts now filter on JoinHandle::is_finished(); scale paths prune finished handles, harvesting panics into the shutdown tally so no accounting is lost.
  • #[must_use] on builder types (0cbf109): config.with_tls(tls); with a dropped result compiled silently and shipped without TLS. KafkaConfig/KafkaTls/KafkaSasl/NatsConfig/TopologyBuilder/TopologyDeclarer are now #[must_use] (covers the new topic-config builder methods for free).
  • Docs (3f79f34): KafkaConfig::with_sslwith_tls (copy-paste code didn't compile); the tuning gotcha no longer tells users to set config keys that aren't settable.

Verification

All on this branch, rebased onto e902d7c:
cargo fmt --check / clippy --all-features / clippy --no-default-features / clippy --features kafka-msk-iam all clean; nextest no-default 140/140, kafka lib 257/257, nats lib 235/235, kafka_integration 49/49, kafka_rebalance 1/1, nats_integration 48/48.

🤖 Generated with Claude Code

zannis added 10 commits July 2, 2026 15:59
The shutdown branch of the concurrent receive loop fired the last
offset commit with CommitMode::Async and dropped the result, so a
clean deploy could redeliver the last drained batch if the process
exited before the async commit completed. Commit synchronously and
log (not fail) on error so shutdown still proceeds.
run_with_reconnect never reset its attempt counter or backoff after a
successful stretch of connectivity, so max_reconnect_attempts acted as
a lifetime budget instead of bounding consecutive failures: a consumer
that reconnected occasionally over hours or days would eventually
exhaust the budget and give up even though each individual outage was
brief and isolated. Track how long the last invocation ran before
failing and reset attempts/backoff once it exceeds a 60s threshold.
On a mid-batch submission error, publish_batch abandoned every ack
future collected so far instead of awaiting them, always reporting
succeeded=0 even though NATS had genuinely stored some messages —
callers that re-drove the batch on that signal produced duplicates.
The ack loop also stopped draining on its own first failure. Always
drain every submitted ack; let a submission error take precedence in
the reported error, but let an ack error fill it in only if nothing
has failed yet.
…tted groups

The per-partition lag arithmetic treated a never-committed group
(Offset::Invalid or partition absent from the committed TPL) as
committed-at-zero, reporting the full high watermark as lag. That is
wrong for latest-reset groups (real lag is ~0, causing a spurious
scale-to-max at startup) and wrong for earliest groups once retention
truncates the log (lag is high - low, not high).

Extract the arithmetic into a pure partition_lag(committed, low, high,
reset) function and thread the group's auto.offset.reset policy through
KafkaQueueStatsProvider::get_queue_stats (breaking change to the pub
trait, pre-1.0). fetch_metrics resolves the policy from the group
config, defaulting to Earliest exactly as the consumer does. The
snapshot path addresses the default group and passes Earliest.

Also re-export KafkaAutoOffsetReset from the shove::kafka facade: the
type already appears in public signatures and the docs import it, but
it was unreachable outside the crate, which would have made the new
trait signature unimplementable externally.
KafkaConfig, KafkaTls, KafkaSasl, NatsConfig, TopologyBuilder, and
TopologyDeclarer all have consuming -> Self builder methods, so a
dropped result like `config.with_tls(tls);` silently shipped without
TLS. #[must_use] on the type makes any unused function return of that
type warn, covering all current and future builder methods. No
existing call site tripped the lint.
KafkaConfig::with_ssl does not exist; the method is with_tls. The
rebalance gotcha told users to tune session.timeout.ms and
max.poll.interval.ms in KafkaConfig, but neither is settable — they
are fixed constants (10s / 5min in kafka::constants). Point slow-
handler advice at with_handler_timeout / with_default_handler_timeout
instead.
The concurrent consumer used manual commits with a per-partition
OffsetTracker but installed no rebalance callback and never reset
tracker state when partitions moved. Two failures followed:

1. Commit stall: partition P revoked from consumer A, committed on by
   consumer B, then reassigned to A left A's stale next_to_commit
   waiting for a contiguous run B already consumed — P stopped
   committing for the life of the connection.
2. Stale-partition commits: completions for messages in flight at
   revocation were still committed even though the member no longer
   owned the partition.

Install a RebalanceContext (ConsumerContext) that forwards incremental
assign/revoke deltas from pre_rebalance to the receive loop over a
std::sync::mpsc channel; the loop drops tracker entries for the listed
partitions on BOTH revoke (stop committing unowned partitions) and
assign (re-seed next_to_commit from the first delivered offset).
Events are also applied right before track_received: the callback runs
inside recv()'s poll, so an Assign may already be queued when the first
post-reassignment message arrives from the same poll, and applying it
after tracking would wipe the freshly seeded entry.

Testing against a real broker exposed a second, subtler loss: async
commits submitted between the revoke and assign phases of a cooperative
rebalance can be dropped by librdkafka without any error — no
commit_callback fires — and since drain_committable only yields on new
completions, the lost commit was never retried (a partition with no
follow-up traffic stayed uncommitted indefinitely). Countermeasures:

- commit_callback is now implemented: a rejected commit marks its
  partitions dirty via the same channel and the tracker re-offers
  their current position on the next drain;
- every drained rebalance event re-offers all retained partitions'
  positions (re-committing an already-committed offset is a broker-side
  no-op), closing the silent-drop window because the rebalance always
  ends with an assign round;
- a 5s housekeeping tick wakes the loop so events and retries drain
  even when no messages or completions arrive.

The FIFO and DLQ paths commit per message and keep no tracker; they
receive the wrapper but deliberately drop the event receiver.
MskIamContext's ClientContext overrides (ENABLE_REFRESH_OAUTH_TOKEN,
generate_oauth_token) are forwarded through the wrapper, as are
log/stats/stats_raw/error.
New tests/kafka_rebalance.rs: consumer A owns all 8 partitions and
drains a batch; consumer B joins the group (cooperative rebalance moves
partitions to B, which processes and commits on them); B leaves; a
final batch must be both processed AND committed on every partition
(polled via KafkaLagStatsProvider until lag reaches zero).

On pre-fix code the test fails: partitions with commits in flight
around the rebalance stall below the high watermark and never converge.
On failure the test dumps per-partition low/high/committed state so a
flake report shows exactly which partitions stalled and where.
A consumer task that exited with a non-retryable error (or exhausted
max_reconnect_attempts) was only error-counted and logged; its
JoinHandle stayed in the consumers vec forever. active_consumers()
returned consumers.len(), so a group whose members had all died still
reported full strength: the autoscaler computed capacity from a
fiction and would not scale up to compensate, scale_up refused at
"max capacity" with zero consumers running, and scale_down could
"cancel" an already-dead consumer while live ones kept working.

- active_consumers() now counts handles where !is_finished(). It
  counts on read (with &self) rather than relying on prior pruning,
  because the autoscaler's fetch_metrics reads it through immutable
  registry access.
- scale_up/scale_down first call a new prune_finished() that removes
  finished handles from both the consumers and retiring lists, so the
  min/max gates and the idle-pick operate on live members only.
- Pruned handles are polled once with a no-op waker to harvest their
  result: a panic (non-cancelled JoinError) is counted into
  panic_count, which drain_into already snapshots into ShutdownTally —
  preserving the panic-accounting invariant that previously relied on
  every handle surviving until the final drain. No double counting:
  a harvested handle is removed from the vec and never awaited again.

With truthful counts the existing lag-driven autoscaler self-heals
groups under load (capacity shrinks, lag exceeds the threshold, scale
up spawns replacements). Respawn-to-min for idle groups remains a
deliberately deferred supervision-policy decision.
…imeout

Shove created JetStream pull consumers without setting ack_wait,
leaving the 30s server default — exactly equal to the default handler
timeout. Zero safety margin: a handler legitimately running near its
limit, or a message waiting behind a saturated prefetch buffer before
its handler even starts (the ack_wait clock ticks from delivery),
exceeded ack_wait and was redelivered while still in flight —
duplicate processing under normal slow-handler load. An operator
raising with_handler_timeout(60s) silently made every such handler a
guaranteed duplicate.

Derive ack_wait = max(3 x effective handler timeout, 30s) via a single
derive_ack_wait helper at every consumer-creation site:

- the group durable (declare_pull_consumer gains an ack_wait
  parameter; the registry passes the group's resolved handler
  timeout, registry defaults already folded in);
- the NotFound bootstrap fallback in the concurrent consumer path;
- FIFO shard durables (get_or_create_consumer returns existing
  durables verbatim, so only newly created shards pick up the derived
  value — pre-existing ones keep 30s until recreated);
- the DLQ durable (no handler-timeout knob on run_dlq; uses the
  default-timeout derivation).

The hold-heartbeat pacing already reads the effective ack_wait from
cached_info() at runtime and adapts automatically — unchanged.

Integration coverage asserts the derived value on the group durable
(explicit 20s -> 60s and default -> 90s), on a FIFO shard durable, and
that re-registering against an existing durable (the deployment
upgrade path) successfully updates its ack_wait via the upsert.
@codecov

codecov Bot commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.92357% with 57 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/backends/kafka/consumer.rs 83.08% 46 Missing ⚠️
src/backends/kafka/consumer_group.rs 96.52% 4 Missing ⚠️
src/backends/nats/consumer_group.rs 96.58% 4 Missing ⚠️
src/backends/kafka/backend.rs 0.00% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 2, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
shove-docs 7cce999 Commit Preview URL

Branch Preview URL
Jul 02 2026, 01:16 PM

@zannis
zannis merged commit 6b5c54d into main Jul 2, 2026
16 checks passed
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