numinnex commented on code in PR #4063:
URL: https://github.com/apache/iggy/pull/4063#discussion_r3939951642
##########
core/partitions/src/iggy_partition.rs:
##########
@@ -2421,6 +2776,30 @@ where
}
};
+ if args.auto_commit
+ && self.consensus.is_primary()
+ && let Ok(pending) =
PendingConsumerOffsetCommit::try_from_polling_consumer(consumer, 0)
+ {
+ let capacity = self.consumer_offset_capacity_for(pending.kind);
+ if !capacity.is_uncertain()
+ && self.consumer_offset_map_count(pending.kind) >=
capacity.limit()
+ {
Review Comment:
**`papaya::HashMap::len()` on every auto-commit poll.**
`len()` is `Counter::sum()` over
`available_parallelism().next_power_of_two()` `CachePadded<AtomicIsize>`
shards, so on a 64-128 core host this is 4-16 KiB of cache-line loads per poll
to compare against a limit that is almost never reached.
The `contains_key` probe at :2787-2796 is the cheaper test and is already
written — on a hit neither ordering does anything, so probing first is
semantics-preserving and free.
##########
core/partitions/src/iggy_partition.rs:
##########
@@ -2355,14 +2613,111 @@ where
ReplicaLogContext::from_consensus(self.consensus(),
PlaneKind::Partitions)
}
- fn clear_pending_consumer_offset_commits_if_view_changed(&mut self) {
+ fn store_offset_range_error(&self, offset: u64) -> Option<IggyError> {
+ let current = self.stats.current_offset();
+ (offset > current || (current == 0 &&
self.stats.messages_count_inconsistent() == 0))
+ .then_some(IggyError::InvalidOffset(offset))
+ }
+
+ fn resynchronize_consumer_offset_reservations(&mut self) {
let current_view = self.consensus.view();
- if current_view == self.observed_view {
+ if current_view == self.observed_view &&
!self.offset_reservations_need_resync.get() {
return;
}
- self.pending_consumer_offset_commits.clear();
+ if current_view != self.observed_view {
+ self.queued_auto_commit_reservations.borrow_mut().clear();
+ }
+
+ let from_op = self
Review Comment:
**Scan floors at `commit_min`, but eviction is bounded by `commit_max`.**
`flush_committed_messages` evicts `committed_prefix(commit_max())` (:4516),
so ops in `(commit_min, commit_max]` lose their resident header and survive
only in the evicted ring — capped at `EVICTED_RING_CAPACITY = 4096` *or*
`EVICTED_RING_BYTES_MAX = 16 MiB`, whichever trips first, which is a handful of
batches on a produce-heavy partition. `repair_headers_in` can then legitimately
return fewer than `expected`, latching the `uncertain` flag at :2643.
This fires at promotion: a new primary runs resync from
`on_commit`/`on_replicate`/`on_request` before `VsrAction::CommitJournal` has
caught `commit_min` up. `consensus/src/impls.rs:2880-2883` states the rule for
the identical problem — "`commit_max`, not `commit_min` … `commit_min` can lag
far enough to overflow the rebuild".
Note a second trigger that flooring at `commit_max` does **not** fix:
`truncate_uncommitted_from` (:4353) drops entries without rewinding the
sequencer and arms the resync at :4436, leaving `to_op > journal head`.
Bounding `to_op` by `journal().inner.last_op()` covers both.
##########
core/server/src/partition_helpers.rs:
##########
@@ -253,15 +269,51 @@ pub fn configure_consumer_offsets(
.enforce_fsync
.unwrap_or(iggy_common::DEFAULT_ENFORCE_FSYNC);
partition.configure_consumer_offset_storage(
- consumer_offsets_path,
- consumer_group_offsets_path,
+ consumer_offsets_path.clone(),
+ consumer_group_offsets_path.clone(),
consumer_offsets,
consumer_group_offsets,
enforce_fsync,
);
+ for consumer_id in numeric_offset_file_ids(&consumer_offsets_path) {
+ if partition.seed_stranded_consumer_offset(ConsumerKind::Consumer,
consumer_id) {
+ warn!(stream_id, topic_id, partition_id, consumer_id, path =
%consumer_offsets_path,
+ "unloaded consumer offset file retains its capacity slot until
updated or deleted");
+ }
+ }
+ for group_id in numeric_offset_file_ids(&consumer_group_offsets_path) {
+ if
partition.seed_stranded_consumer_offset(ConsumerKind::ConsumerGroup, group_id) {
+ warn!(stream_id, topic_id, partition_id, group_id, path =
%consumer_group_offsets_path,
+ "unloaded group offset file retains its capacity slot until
repaired or reclaimed");
+ }
+ }
+ for kind in [ConsumerKind::Consumer, ConsumerKind::ConsumerGroup] {
+ let count = partition.occupied_consumer_offset_count(kind);
+ if count > config.partition.consumer_offsets_max {
+ warn!(
+ stream_id,
+ topic_id,
+ partition_id,
+ ?kind,
+ count,
+ limit = config.partition.consumer_offsets_max,
+ "recovered consumer offsets exceed the configured admission
limit"
+ );
+ }
+ }
Ok(())
}
+fn numeric_offset_file_ids(path: &str) -> Vec<u32> {
+ let Ok(entries) = std::fs::read_dir(path) else {
+ return Vec::new();
+ };
+ entries
+ .filter_map(Result::ok)
+ .filter_map(|entry| entry.file_name().to_str()?.parse().ok())
Review Comment:
**No `is_file()` filter, and this is a second walk of a directory already
read.**
A numerically-named *subdirectory* under the offsets dir parses fine here
and is seeded as a stranded key, permanently burning a quota slot that nothing
can clear — `clear_stranded` only fires on a successful persist or unlink for
that id. The sibling loader skips dirs (`offset_recovery.rs:132`), and this
PR's own harness helper checks `is_file()`
(`integration/src/harness/disk.rs:60`), so it is a three-way asymmetry.
It is also a second blocking `std::fs::read_dir` over the two dirs
`load_consumer_offsets` just walked, and not boot-only — `load_partition` runs
from the reconciler at runtime (`partition_reconciler.rs:734`), so it blocks a
serving shard thread.
Returning the id set (filtered) from the existing walk closes both.
Worth noting this is also the no-fault way to populate `stranded`, which is
what makes the widened delete admission at `iggy_partition.rs:2597-2601`
reachable on a mixed-version cluster.
##########
core/server/src/partition_reconciler.rs:
##########
@@ -960,47 +994,108 @@ async fn tear_down_owned_partition(
counters.removed_local += 1;
}
-/// Reclaim consumer-group offsets left behind by a `DeleteConsumerGroup` whose
-/// topic still exists (a topic/stream delete already drops the whole partition
-/// directory, offsets included). For each owned partition, any stored
-/// consumer-group offset whose group id is no longer present in the topic's
-/// committed metadata is removed (in-memory entry + persisted file).
Monotonic,
-/// never-reused group ids make this purely reclamation -- a recreated group
-/// gets a fresh id and never reads a dead group's offset -- so it is safe to
do
-/// lazily on the reconcile pass rather than synchronously on delete.
-async fn reconcile_consumer_group_offsets(ctx: &ReconcilerCtx, counters: &mut
PassCounters) {
+/// Reclaim deleted groups through ordered offset deletes. Replicas must see
+/// each delete before a replacement store can reuse its durable slot.
+fn reconcile_consumer_group_offsets(ctx: &ReconcilerCtx, counters: &mut
PassCounters) {
let live_groups = snapshot_topic_live_groups(ctx);
let partitions = ctx.shard.plane.partitions();
let owned: Vec<IggyNamespace> = partitions.namespaces().copied().collect();
- for ns in owned {
- let live = live_groups.get(&(ns.stream_id(), ns.topic_id()));
- // Take the in-memory removes + owned unlink paths under a
closure-scoped
- // borrow that cannot escape into the await below. Holding a raw
- // `&IggyPartition` across `delete_persisted_offset().await` would let
the
- // pump task realloc the partitions vec underneath us (a UAF).
- let paths = partitions.with_partition(&ns, |partition| {
- partition.reclaim_dead_group_offsets(|group_id| {
- live.is_some_and(|set| set.contains(&group_id))
- })
- });
- let Some(paths) = paths else {
+ for namespace in owned {
+ if ctx
+ .group_offset_cleanup_inflight
+ .borrow()
+ .contains(&namespace)
+ {
continue;
- };
- for path in paths {
- if let Err(err) = delete_persisted_offset(&path).await {
- warn!(
- shard = ctx.shard.id,
- ns_raw = ns.inner(),
- error = %err,
- "reconciler failed to reclaim deleted consumer-group
offset"
- );
- continue;
+ }
+ let live = live_groups.get(&(namespace.stream_id(),
namespace.topic_id()));
Review Comment:
**`live_groups` miss conflates "no live groups" with "STM not caught up" —
and now issues replicated deletes.**
`snapshot_topic_live_groups` skips topics whose `consumer_groups` map is
empty, so a topic absent from the snapshot and a topic with zero groups are the
same `None` here. `live.is_some_and(...)` at :1015 then answers false for
*every* group, and the pass enqueues a delete for every group offset on the
partition.
Pre-PR that cost a node-local unlink other replicas could restore. Post-PR
it submits durable, quorum-committed deletes (:1024), so a shard whose metadata
STM is still replaying group creates — the boot window — can permanently
destroy every live group's committed offset cluster-wide. Nothing surfaces to
the client; consumers silently re-consume from the retention floor.
Distinguishing "topic absent from this snapshot" from "topic present with
zero groups" before any delete is submitted would close it.
##########
core/server/src/consumer_group.rs:
##########
@@ -226,15 +226,28 @@ where
let body = request_body(&request);
// The store/delete ops differ only in the decode type; this collapses
// their identical decode -> resolve group id -> rewrite consumer id ->
- // re-encode bodies. A non-group consumer or unresolved group returns the
- // request untouched (the apply/read path handles the miss).
+ // re-encode bodies. Individual consumers pass through. A group identifier
+ // that metadata cannot resolve is rejected before it can create a raw file
+ // in the group-offset directory.
macro_rules! rewrite_group_offset {
($ty:ty) => {{
let mut wire = <$ty>::decode_from(body).map_err(|_|
IggyError::InvalidCommand)?;
+ if wire.consumer.kind != KIND_CONSUMER_GROUP {
+ return Ok(request);
+ }
let Some(group_id) =
resolve_group_offset_id(shard, &wire.consumer,
(&wire.stream_id, &wire.topic_id))
else {
- return Ok(request);
+ // An unknown stream or topic is not a missing group: let the
+ // namespace resolution below answer it with the same
+ // not-found every other partition op reports.
+ if !topic_exists(shard, &wire.stream_id, &wire.topic_id) {
+ return Ok(request);
+ }
+ return Err(missing_consumer_group_error(
Review Comment:
**Wire-visible error-code change, undocumented.**
Store/delete consumer offset against a missing group returned
`ResourceNotFound` (20); this now returns `ConsumerGroupIdNotFound` (5000) or
`ConsumerGroupNameNotFound` (5003) depending on the identifier form. Unknown
stream/topic still returns 20 (:244-245), which is the discriminator.
Any client branching on 20 for this op breaks. Go is the sharpest case —
`ierror.ErrResourceNotFound` is a typed error matched with `errors.Is`, so this
reroutes control flow silently.
Only the Go BDD was updated
(`bdd/go/tests/tcp_test/offset_feature_delete.go:133`) out of seven SDKs, and
there is no changelog entry or coverage for the name form (5003).
##########
core/common/src/error/iggy_error.rs:
##########
@@ -333,6 +333,8 @@ pub enum IggyError {
NotResolvedConsumer(Identifier) = 3022,
#[error("Cannot open consumer offsets file for path: {0}")]
CannotOpenConsumerOffsetsFile(String) = 3023,
+ #[error("Too many consumer offsets for partition")]
Review Comment:
**Message names neither the limit nor the knob.**
Since 3024 is terminal in every SDK retry classifier, this string is the
operator's entire remediation surface — and it does not say what the limit is
or that `[partition] consumer_offsets_max` controls it. `config.toml` is the
only doc surface in the repo, so nothing else closes the gap.
Something like "per-partition consumer offset limit reached (see
`[partition] consumer_offsets_max`)" would make it actionable.
##########
core/partitions/src/iggy_partition.rs:
##########
@@ -2355,14 +2613,111 @@ where
ReplicaLogContext::from_consensus(self.consensus(),
PlaneKind::Partitions)
}
- fn clear_pending_consumer_offset_commits_if_view_changed(&mut self) {
+ fn store_offset_range_error(&self, offset: u64) -> Option<IggyError> {
+ let current = self.stats.current_offset();
+ (offset > current || (current == 0 &&
self.stats.messages_count_inconsistent() == 0))
+ .then_some(IggyError::InvalidOffset(offset))
+ }
+
+ fn resynchronize_consumer_offset_reservations(&mut self) {
let current_view = self.consensus.view();
- if current_view == self.observed_view {
+ if current_view == self.observed_view &&
!self.offset_reservations_need_resync.get() {
return;
}
- self.pending_consumer_offset_commits.clear();
+ if current_view != self.observed_view {
+ self.queued_auto_commit_reservations.borrow_mut().clear();
+ }
+
+ let from_op = self
+ .consensus
+ .commit_min()
+ .max(self.purge_floor_op)
+ .saturating_add(1);
+ let to_op = self.consensus.sequencer().current_sequence();
+ let mut rebuilt = HashMap::new();
+ let headers =
self.log.journal().inner.repair_headers_in(from_op..=to_op);
+ let expected = to_op
+ .checked_sub(from_op)
+ .map_or(0, |span| span.saturating_add(1));
+ let mut decode_failed = headers.len() as u64 != expected;
+ for (op, header) in headers {
+ if !matches!(
+ header.operation,
+ Operation::StoreConsumerOffset |
Operation::DeleteConsumerOffset
+ ) {
+ continue;
+ }
+ match self.restage_consumer_offset_from_journal(op) {
+ Ok(pending) => {
+ rebuilt.insert(op, pending);
+ }
+ Err(error) => {
+ error!(
+ target: "iggy.partitions.diag",
+ plane = "partitions",
+ replica_id = self.consensus.replica(),
+ namespace_raw = self.namespace().inner(),
+ op,
+ %error,
+ "failed to rebuild consumer offset reservations after
view change"
+ );
+ decode_failed = true;
+ break;
+ }
+ }
+ }
+ self.pending_consumer_offset_commits = rebuilt;
+ if decode_failed {
+ self.consumer_offset_capacity.mark_uncertain();
+ self.consumer_group_offset_capacity.mark_uncertain();
+ } else {
+ let consumer_ids = self
+ .pending_consumer_offset_commits
+ .values()
+ .filter(|pending| {
+ pending.kind == ConsumerKind::Consumer
+ && matches!(pending.mutation,
PendingConsumerOffsetMutation::Upsert(_))
+ })
+ .map(|pending| pending.consumer_id);
+ self.consumer_offset_capacity
+ .rebuild(&self.durable_consumer_offsets, consumer_ids);
+ let group_ids = self
+ .pending_consumer_offset_commits
+ .values()
+ .filter(|pending| {
+ pending.kind == ConsumerKind::ConsumerGroup
+ && matches!(pending.mutation,
PendingConsumerOffsetMutation::Upsert(_))
+ })
+ .map(|pending| pending.consumer_id);
+ self.consumer_group_offset_capacity
+ .rebuild(&self.durable_consumer_offsets, group_ids);
+ }
self.observed_view = current_view;
+ self.consumer_group_offsets_need_reconcile.set(true);
+ // Repair and truncation rearm this flag when journal contents change.
+ // A failed decode alone must not cause a full scan on every tick.
+ self.offset_reservations_need_resync.set(false);
+ if !decode_failed && self.consensus.is_primary() {
+ self.reclaim_phantom_offsets(ConsumerKind::Consumer);
Review Comment:
**Phantom reclaim is primary-gated; phantom *creation* is not.**
Backup-served auto-commit polls create live-map entries (`poll_plan.rs`) via
the `primary == Some(Ok(false))` → `mark_served()` branch — never replicated,
never reclaimed, and both reclaim call sites (here and :2780) are behind
`is_primary()`.
A backup's map therefore saturates at `consumer_offsets_max` and never
drains. Every new consumer polling that backup then gets terminal
`TooManyConsumerOffsets` while the leader still has room, healed only by
promotion. 3024 is terminal in every first-party SDK classifier (Go
`tcp_core.go:859`, Java `:786`, Node `:1039`, .NET `:292`; Rust has no arm) —
no retry, no failover, no leader redirect.
Reclaim is node-local and touches no replicated state, so it is safe on a
backup. On a backup `provisional` is provably empty, which makes the predicate
strictly cheaper there.
##########
core/partitions/src/iggy_partition.rs:
##########
@@ -2355,14 +2613,111 @@ where
ReplicaLogContext::from_consensus(self.consensus(),
PlaneKind::Partitions)
}
- fn clear_pending_consumer_offset_commits_if_view_changed(&mut self) {
+ fn store_offset_range_error(&self, offset: u64) -> Option<IggyError> {
+ let current = self.stats.current_offset();
+ (offset > current || (current == 0 &&
self.stats.messages_count_inconsistent() == 0))
+ .then_some(IggyError::InvalidOffset(offset))
+ }
+
+ fn resynchronize_consumer_offset_reservations(&mut self) {
let current_view = self.consensus.view();
- if current_view == self.observed_view {
+ if current_view == self.observed_view &&
!self.offset_reservations_need_resync.get() {
return;
}
- self.pending_consumer_offset_commits.clear();
+ if current_view != self.observed_view {
+ self.queued_auto_commit_reservations.borrow_mut().clear();
+ }
+
+ let from_op = self
+ .consensus
+ .commit_min()
+ .max(self.purge_floor_op)
+ .saturating_add(1);
+ let to_op = self.consensus.sequencer().current_sequence();
+ let mut rebuilt = HashMap::new();
+ let headers =
self.log.journal().inner.repair_headers_in(from_op..=to_op);
+ let expected = to_op
+ .checked_sub(from_op)
+ .map_or(0, |span| span.saturating_add(1));
+ let mut decode_failed = headers.len() as u64 != expected;
Review Comment:
**`uncertain` latches permanently — nothing re-arms it.**
`decode_failed` sets the latch, and this same pass then stamps
`observed_view = current_view` (:2696) and clears
`offset_reservations_need_resync` (:2700). Both are the gate conditions at
:2624, so every later call early-returns and only `rebuild()` clears
`uncertain` — reachable solely from a *next* view change, truncate, reanchor,
install or purge.
While latched, every new offset key answers `TransientNotAccepted` and every
auto-commit poll for a non-durable consumer returns zero messages. Clients
treat 58 as replayable, so each request burns a full roster walk and then
fails; admission is primary-only, so re-targeting can never succeed.
Suggest re-arming `offset_reservations_need_resync` on journal growth (or
retrying the rebuild per tick) rather than clearing it after a failed decode.
##########
core/partitions/src/iggy_partition.rs:
##########
@@ -2008,54 +2110,95 @@ where
// durably stored; the in-memory update is idempotent on replay
// because we look up by (kind, id).
self.persist_consumer_offset_commit(pending).await?;
- self.apply_consumer_offset_commit(pending)?;
+ self.apply_consumer_offset_commit(pending);
self.pending_consumer_offset_commits.remove(&op);
+ self.refresh_consumer_offset_reservation(pending.kind,
pending.consumer_id);
Ok(())
}
async fn persist_consumer_offset_commit(
&self,
pending: PendingConsumerOffsetCommit,
) -> Result<(), IggyError> {
- let Some(path) = self.persisted_offset_path(pending.kind,
pending.consumer_id) else {
- return Ok(());
- };
- let key = (pending.kind, pending.consumer_id);
+ let path = self.persisted_offset_path(pending.kind,
pending.consumer_id);
+ let capacity = self.consumer_offset_capacity_for(pending.kind);
+ let creates_group = pending.kind == ConsumerKind::ConsumerGroup
+ && !self
+ .durable_consumer_offsets
+ .contains(pending.kind, pending.consumer_id);
match pending.mutation {
// A server auto-commit persists monotonically: its op offset can
// trail the durably-recorded value (disk-tier polls replicate in
// IO-completion order), so a plain overwrite would rewind the file
- // and re-deliver on restart. The `persisted_offsets` tracker keeps
+ // and re-deliver on restart. The durable offset tracker keeps
// the fold off the file: a covered offset skips the write, an
// advancing one blind-writes, and only a cold key (first commit
// after boot) reads the file once. Explicit client stores
// overwrite, so a deliberate offset reset still holds. Mirrors the
// in-memory `upsert_offset_max` vs `upsert_offset` split in the
// commit-apply.
PendingConsumerOffsetMutation::Upsert(offset) if
pending.auto_commit => {
- let tracked =
self.persisted_offsets.borrow().get(&key).copied();
- let persisted = match tracked {
- Some(high_water) if offset <= high_water => return Ok(()),
- Some(_) => {
- persist_offset(&path, offset,
self.consumer_offset_enforce_fsync).await?;
- offset
+ let tracked = self
+ .durable_consumer_offsets
+ .get(pending.kind, pending.consumer_id);
+ let persisted_high_water = match (path.as_deref(), tracked) {
+ (None, _) => offset,
+ (Some(_), Some(state))
+ if state
+ .persisted_high_water
+ .is_some_and(|high_water| offset <= high_water) =>
+ {
+ state.persisted_high_water.expect("covered persisted
value")
+ }
+ (Some(path), Some(state)) => {
+ let value = state.committed_offset.max(offset);
+ persist_offset(path, value,
self.consumer_offset_enforce_fsync).await?;
+ value
}
- None => {
- persist_offset_max(&path, offset,
self.consumer_offset_enforce_fsync)
- .await?
+ (Some(path), None) => {
+ persist_offset_max(path, offset,
self.consumer_offset_enforce_fsync).await?
}
};
- self.persisted_offsets.borrow_mut().insert(key, persisted);
+ self.durable_consumer_offsets.record_auto_commit(
+ pending.kind,
+ pending.consumer_id,
+ if tracked.is_none() {
+ persisted_high_water
+ } else {
+ offset
+ },
+ persisted_high_water,
+ );
+ capacity.clear_stranded(pending.consumer_id);
+ if creates_group {
+ self.consumer_group_offsets_need_reconcile.set(true);
+ }
Ok(())
}
PendingConsumerOffsetMutation::Upsert(offset) => {
- persist_offset(&path, offset,
self.consumer_offset_enforce_fsync).await?;
- self.persisted_offsets.borrow_mut().insert(key, offset);
+ if let Some(path) = path.as_deref() {
+ persist_offset(path, offset,
self.consumer_offset_enforce_fsync).await?;
+ }
+ self.durable_consumer_offsets.record_explicit(
+ pending.kind,
+ pending.consumer_id,
+ offset,
+ Some(offset),
+ );
+ capacity.clear_stranded(pending.consumer_id);
+ if creates_group {
+ self.consumer_group_offsets_need_reconcile.set(true);
+ }
Ok(())
}
PendingConsumerOffsetMutation::Delete => {
- delete_persisted_offset(&path).await?;
- self.persisted_offsets.borrow_mut().remove(&key);
+ if let Some(path) = path.as_deref() {
+ delete_persisted_offset(path).await?;
Review Comment:
**Committed offset delete unlinks with no parent-directory fsync.**
A crash after a quorum-acked delete resurrects the file at boot, and
`configure_consumer_offsets` re-seeds both the live map and `durable` from it —
so the cursor *and* its capacity slot come back. Nothing re-issues the delete:
the op is committed and gone, and plain consumer keys have no reaper.
The crate already does this everywhere else for the stated reason: purge at
:6263-6269 ("a crash right after the purge otherwise resurrects the offset
files at boot") and install at `state_transfer.rs:2928-2935`.
It also does not stay local — the resurrected key is re-exported through
`committed_entries`, so serving one state transfer undoes the deletion
cluster-wide, which contradicts the PR's "keeps file counts identical on all
replicas".
Worth matching the existing precedents' *batch* shape rather than fsyncing
per delete: the reconciler submits up to `GROUP_OFFSET_DELETES_PER_PASS = 32`
deletes per pass, so a per-delete dir fsync is 3-30 ms of pump stall per pass.
Marking the dir dirty and issuing one `fsync_dir` per commit-walk batch gives
the same guarantee — but the flush has to be ordered *before* that batch's
replies, or the client is told "deleted" before the unlink is durable.
##########
core/partitions/src/consumer_offset_capacity.rs:
##########
@@ -0,0 +1,530 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use iggy_common::ConsumerKind;
+use std::cell::{Cell, RefCell};
+use std::collections::{HashMap, HashSet};
+use std::rc::Rc;
+use std::sync::{Arc, Weak};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct DurableOffsetState {
+ pub(crate) committed_offset: u64,
+ pub(crate) persisted_high_water: Option<u64>,
+}
+
+#[derive(Debug, Default)]
+pub struct DurableConsumerOffsets {
+ consumers: RefCell<HashMap<u32, DurableOffsetState>>,
+ groups: RefCell<HashMap<u32, DurableOffsetState>>,
+}
+
+impl DurableConsumerOffsets {
+ pub(crate) fn get(&self, kind: ConsumerKind, id: u32) ->
Option<DurableOffsetState> {
+ self.entries(kind).borrow().get(&id).copied()
+ }
+
+ pub(crate) fn contains(&self, kind: ConsumerKind, id: u32) -> bool {
+ self.entries(kind).borrow().contains_key(&id)
+ }
+
+ pub(crate) fn count(&self, kind: ConsumerKind) -> usize {
+ self.entries(kind).borrow().len()
+ }
+
+ pub(crate) fn covers(&self, kind: ConsumerKind, id: u32, offset: u64) ->
bool {
+ self.get(kind, id).is_some_and(|state| {
+ state.committed_offset >= offset
+ && state
+ .persisted_high_water
+ .is_some_and(|persisted| persisted >= offset)
+ })
+ }
+
+ pub(crate) fn record_explicit(
+ &self,
+ kind: ConsumerKind,
+ id: u32,
+ committed_offset: u64,
+ persisted_high_water: Option<u64>,
+ ) {
+ self.entries(kind).borrow_mut().insert(
+ id,
+ DurableOffsetState {
+ committed_offset,
+ persisted_high_water,
+ },
+ );
+ }
+
+ pub(crate) fn record_auto_commit(
+ &self,
+ kind: ConsumerKind,
+ id: u32,
+ committed_offset: u64,
+ persisted_high_water: u64,
+ ) {
+ let mut entries = self.entries(kind).borrow_mut();
+ let state = entries.entry(id).or_insert(DurableOffsetState {
+ committed_offset,
+ persisted_high_water: None,
+ });
+ state.committed_offset = state.committed_offset.max(committed_offset);
+ state.persisted_high_water = Some(
+ state
+ .persisted_high_water
+ .unwrap_or(0)
+ .max(persisted_high_water),
+ );
+ }
+
+ pub(crate) fn mark_persisted(&self, kind: ConsumerKind, id: u32,
high_water: u64) {
+ if let Some(state) = self.entries(kind).borrow_mut().get_mut(&id) {
+ state.persisted_high_water = Some(high_water);
+ }
+ }
+
+ pub(crate) fn remove(&self, kind: ConsumerKind, id: u32) -> bool {
+ self.entries(kind).borrow_mut().remove(&id).is_some()
+ }
+
+ pub(crate) fn clear(&self) {
+ self.consumers.borrow_mut().clear();
+ self.groups.borrow_mut().clear();
+ }
+
+ pub(crate) fn committed_entries(&self, kind: ConsumerKind) -> Vec<(u32,
u64)> {
+ self.entries(kind)
+ .borrow()
+ .iter()
+ .map(|(id, state)| (*id, state.committed_offset))
+ .collect()
+ }
+
+ const fn entries(&self, kind: ConsumerKind) -> &RefCell<HashMap<u32,
DurableOffsetState>> {
+ match kind {
+ ConsumerKind::Consumer => &self.consumers,
+ ConsumerKind::ConsumerGroup => &self.groups,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ConsumerOffsetCapacityError {
+ pub kind: ConsumerKind,
+ pub occupied: usize,
+ pub limit: usize,
+ pub first_in_episode: bool,
+ pub uncertain: bool,
+}
+
+impl From<ConsumerOffsetCapacityError> for iggy_common::IggyError {
+ fn from(error: ConsumerOffsetCapacityError) -> Self {
+ if error.uncertain {
+ Self::TransientNotAccepted
+ } else {
+ Self::TooManyConsumerOffsets
+ }
+ }
+}
+
+#[derive(Debug)]
+pub struct ConsumerOffsetCapacity {
+ kind: ConsumerKind,
+ limit: Cell<usize>,
+ pending: RefCell<HashMap<u32, usize>>,
+ provisional: RefCell<HashMap<u32, Weak<()>>>,
+ stranded: RefCell<HashSet<u32>>,
+ uncertain: Cell<bool>,
+ durable_warned: Cell<bool>,
+ map_warned: Cell<bool>,
+}
+
+impl ConsumerOffsetCapacity {
+ pub(crate) fn new(kind: ConsumerKind, limit: usize) -> Self {
+ Self {
+ kind,
+ limit: Cell::new(limit),
+ pending: RefCell::new(HashMap::new()),
+ provisional: RefCell::new(HashMap::new()),
+ stranded: RefCell::new(HashSet::new()),
+ uncertain: Cell::new(false),
+ durable_warned: Cell::new(false),
+ map_warned: Cell::new(false),
+ }
+ }
+
+ pub(crate) fn set_limit(&self, limit: usize) {
+ self.limit.set(limit);
+ }
+
+ pub(crate) const fn limit(&self) -> usize {
+ self.limit.get()
+ }
+
+ pub(crate) fn try_reserve(
+ &self,
+ id: u32,
+ durable: &DurableConsumerOffsets,
+ ) -> Result<(), ConsumerOffsetCapacityError> {
+ self.check(id, durable)?;
+ *self.pending.borrow_mut().entry(id).or_default() += 1;
+ Ok(())
+ }
+
+ pub(crate) fn check(
+ &self,
+ id: u32,
+ durable: &DurableConsumerOffsets,
+ ) -> Result<(), ConsumerOffsetCapacityError> {
+ self.rearm_if_below_limit(durable);
Review Comment:
**Admission gate degrades exactly when it engages.**
`check()` calls `rearm_if_below_limit` unconditionally. Once a partition has
denied once, `durable_warned` is set (:216), so that call reaches `occupied()`
(:356) on **every** offset store and auto-commit poll — an O(|pending| +
|provisional| + |stranded|) walk with a `Weak::strong_count` atomic per
element. The latch clears only when `occupied() < limit`, which never happens
while the table is full, so the state the feature exists to handle is the state
that makes it O(n). ~4k at the default, 262144 at the ceiling, on the shared
pump — one saturated partition degrades every partition on that shard.
`d9` compounds it: the dead-`Weak` prune at :206 sits *past* the early
return at :195-204, so a workload of already-known ids never prunes and
`provisional` fills to the distinct-id count.
Cheapest correct first step is to delete this call and drive the rearm from
the mutators that can lower occupancy (`release_reservation`,
`set_pending_count(0)`, `DurableConsumerOffsets::remove`/`clear`,
`clear_stranded`) — it removes the unconditional walk without touching the
accounting.
If an O(1) count is wanted later, note a scalar counter is unsound here:
`occupied()` is a set *union* minus `durable`, so a scalar drifts on the first
overlap, and the drift direction is a partition that silently refuses every new
key forever. It needs per-id set membership (one bit per set, count moved only
on 0↔nonzero transitions) plus a real `Drop` on the reservation — releasing at
named call sites misses the frame-dropped-by-a-closing-inbox path.
##########
core/partitions/src/consumer_offset_capacity.rs:
##########
@@ -0,0 +1,530 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use iggy_common::ConsumerKind;
+use std::cell::{Cell, RefCell};
+use std::collections::{HashMap, HashSet};
+use std::rc::Rc;
+use std::sync::{Arc, Weak};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct DurableOffsetState {
+ pub(crate) committed_offset: u64,
+ pub(crate) persisted_high_water: Option<u64>,
+}
+
+#[derive(Debug, Default)]
+pub struct DurableConsumerOffsets {
+ consumers: RefCell<HashMap<u32, DurableOffsetState>>,
+ groups: RefCell<HashMap<u32, DurableOffsetState>>,
+}
+
+impl DurableConsumerOffsets {
+ pub(crate) fn get(&self, kind: ConsumerKind, id: u32) ->
Option<DurableOffsetState> {
+ self.entries(kind).borrow().get(&id).copied()
+ }
+
+ pub(crate) fn contains(&self, kind: ConsumerKind, id: u32) -> bool {
+ self.entries(kind).borrow().contains_key(&id)
+ }
+
+ pub(crate) fn count(&self, kind: ConsumerKind) -> usize {
+ self.entries(kind).borrow().len()
+ }
+
+ pub(crate) fn covers(&self, kind: ConsumerKind, id: u32, offset: u64) ->
bool {
+ self.get(kind, id).is_some_and(|state| {
+ state.committed_offset >= offset
+ && state
+ .persisted_high_water
+ .is_some_and(|persisted| persisted >= offset)
+ })
+ }
+
+ pub(crate) fn record_explicit(
+ &self,
+ kind: ConsumerKind,
+ id: u32,
+ committed_offset: u64,
+ persisted_high_water: Option<u64>,
+ ) {
+ self.entries(kind).borrow_mut().insert(
+ id,
+ DurableOffsetState {
+ committed_offset,
+ persisted_high_water,
+ },
+ );
+ }
+
+ pub(crate) fn record_auto_commit(
+ &self,
+ kind: ConsumerKind,
+ id: u32,
+ committed_offset: u64,
+ persisted_high_water: u64,
+ ) {
+ let mut entries = self.entries(kind).borrow_mut();
+ let state = entries.entry(id).or_insert(DurableOffsetState {
+ committed_offset,
+ persisted_high_water: None,
+ });
+ state.committed_offset = state.committed_offset.max(committed_offset);
+ state.persisted_high_water = Some(
+ state
+ .persisted_high_water
+ .unwrap_or(0)
+ .max(persisted_high_water),
+ );
+ }
+
+ pub(crate) fn mark_persisted(&self, kind: ConsumerKind, id: u32,
high_water: u64) {
+ if let Some(state) = self.entries(kind).borrow_mut().get_mut(&id) {
+ state.persisted_high_water = Some(high_water);
+ }
+ }
+
+ pub(crate) fn remove(&self, kind: ConsumerKind, id: u32) -> bool {
+ self.entries(kind).borrow_mut().remove(&id).is_some()
+ }
+
+ pub(crate) fn clear(&self) {
+ self.consumers.borrow_mut().clear();
+ self.groups.borrow_mut().clear();
+ }
+
+ pub(crate) fn committed_entries(&self, kind: ConsumerKind) -> Vec<(u32,
u64)> {
+ self.entries(kind)
+ .borrow()
+ .iter()
+ .map(|(id, state)| (*id, state.committed_offset))
+ .collect()
+ }
+
+ const fn entries(&self, kind: ConsumerKind) -> &RefCell<HashMap<u32,
DurableOffsetState>> {
+ match kind {
+ ConsumerKind::Consumer => &self.consumers,
+ ConsumerKind::ConsumerGroup => &self.groups,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ConsumerOffsetCapacityError {
+ pub kind: ConsumerKind,
+ pub occupied: usize,
+ pub limit: usize,
+ pub first_in_episode: bool,
+ pub uncertain: bool,
+}
+
+impl From<ConsumerOffsetCapacityError> for iggy_common::IggyError {
+ fn from(error: ConsumerOffsetCapacityError) -> Self {
+ if error.uncertain {
+ Self::TransientNotAccepted
+ } else {
+ Self::TooManyConsumerOffsets
+ }
+ }
+}
+
+#[derive(Debug)]
+pub struct ConsumerOffsetCapacity {
+ kind: ConsumerKind,
+ limit: Cell<usize>,
+ pending: RefCell<HashMap<u32, usize>>,
+ provisional: RefCell<HashMap<u32, Weak<()>>>,
+ stranded: RefCell<HashSet<u32>>,
+ uncertain: Cell<bool>,
+ durable_warned: Cell<bool>,
+ map_warned: Cell<bool>,
+}
+
+impl ConsumerOffsetCapacity {
+ pub(crate) fn new(kind: ConsumerKind, limit: usize) -> Self {
+ Self {
+ kind,
+ limit: Cell::new(limit),
+ pending: RefCell::new(HashMap::new()),
+ provisional: RefCell::new(HashMap::new()),
+ stranded: RefCell::new(HashSet::new()),
+ uncertain: Cell::new(false),
+ durable_warned: Cell::new(false),
+ map_warned: Cell::new(false),
+ }
+ }
+
+ pub(crate) fn set_limit(&self, limit: usize) {
+ self.limit.set(limit);
+ }
+
+ pub(crate) const fn limit(&self) -> usize {
+ self.limit.get()
+ }
+
+ pub(crate) fn try_reserve(
+ &self,
+ id: u32,
+ durable: &DurableConsumerOffsets,
+ ) -> Result<(), ConsumerOffsetCapacityError> {
+ self.check(id, durable)?;
+ *self.pending.borrow_mut().entry(id).or_default() += 1;
+ Ok(())
+ }
+
+ pub(crate) fn check(
+ &self,
+ id: u32,
+ durable: &DurableConsumerOffsets,
+ ) -> Result<(), ConsumerOffsetCapacityError> {
+ self.rearm_if_below_limit(durable);
+ if durable.contains(self.kind, id)
+ || self.pending.borrow().contains_key(&id)
+ || self
+ .provisional
+ .borrow()
+ .get(&id)
+ .is_some_and(|token| token.strong_count() > 0)
+ || self.stranded.borrow().contains(&id)
+ {
+ return Ok(());
+ }
+ self.provisional
+ .borrow_mut()
+ .retain(|_, token| token.strong_count() > 0);
+ let occupied = self.occupied(durable);
+ let limit = self.limit.get();
+ if self.uncertain.get() || occupied >= limit {
+ return Err(ConsumerOffsetCapacityError {
+ kind: self.kind,
+ occupied,
+ limit,
+ first_in_episode: !self.durable_warned.replace(true),
+ uncertain: self.uncertain.get(),
+ });
+ }
+ Ok(())
+ }
+
+ pub(crate) fn reserve_provisional(
+ self: &Rc<Self>,
+ id: u32,
+ durable: &Rc<DurableConsumerOffsets>,
+ ) -> Result<AutoCommitReservation, ConsumerOffsetCapacityError> {
+ self.check(id, durable)?;
+ let mut provisional = self.provisional.borrow_mut();
+ let token = provisional
+ .get(&id)
+ .and_then(Weak::upgrade)
+ .unwrap_or_else(|| {
+ let token = Arc::new(());
+ provisional.insert(id, Arc::downgrade(&token));
+ token
+ });
+ Ok(AutoCommitReservation {
+ token,
+ kind: self.kind,
+ consumer_id: id,
+ })
+ }
+
+ pub(crate) fn owns(&self, reservation: &AutoCommitReservation) -> bool {
+ reservation.kind == self.kind
+ && self
+ .provisional
+ .borrow()
+ .get(&reservation.consumer_id)
+ .is_some_and(|token| std::ptr::eq(token.as_ptr(),
Arc::as_ptr(&reservation.token)))
+ }
+
+ pub(crate) fn protects(&self, id: u32, durable: &DurableConsumerOffsets)
-> bool {
+ durable.contains(self.kind, id)
+ || self.pending.borrow().contains_key(&id)
+ || self
+ .provisional
+ .borrow()
+ .get(&id)
+ .is_some_and(|token| token.strong_count() > 0)
+ }
+
+ pub(crate) fn set_pending_count(&self, id: u32, count: usize) {
+ if count == 0 {
+ self.pending.borrow_mut().remove(&id);
+ } else {
+ self.pending.borrow_mut().insert(id, count);
+ }
+ }
+
+ pub(crate) fn release_reservation(&self, id: u32) {
+ let mut pending = self.pending.borrow_mut();
+ let Some(count) = pending.get_mut(&id) else {
+ return;
+ };
+ if *count == 1 {
+ pending.remove(&id);
+ } else {
+ *count -= 1;
+ }
+ }
+
+ pub(crate) const fn is_uncertain(&self) -> bool {
+ self.uncertain.get()
+ }
+
+ pub(crate) fn rebuild(
+ &self,
+ durable: &DurableConsumerOffsets,
+ pending_ids: impl IntoIterator<Item = u32>,
+ ) {
+ let mut pending = self.pending.borrow_mut();
+ pending.clear();
+ for id in pending_ids {
+ *pending.entry(id).or_default() += 1;
+ }
+ drop(pending);
+ self.uncertain.set(false);
+ self.rearm_if_below_limit(durable);
+ }
+
+ pub(crate) fn mark_uncertain(&self) {
+ self.pending.borrow_mut().clear();
+ self.uncertain.set(true);
+ }
+
+ pub(crate) fn record_stranded(&self, id: u32) {
+ self.stranded.borrow_mut().insert(id);
+ }
+
+ pub(crate) fn clear_stranded(&self, id: u32) {
+ self.stranded.borrow_mut().remove(&id);
+ }
+
+ pub(crate) fn stranded_ids(&self) -> Vec<u32> {
+ self.stranded.borrow().iter().copied().collect()
+ }
+
+ pub(crate) fn is_stranded(&self, id: u32) -> bool {
+ self.stranded.borrow().contains(&id)
+ }
+
+ pub(crate) fn rearm_if_below_limit(&self, durable:
&DurableConsumerOffsets) {
+ if !self.durable_warned.get() || self.uncertain.get() {
+ return;
+ }
+ if self.occupied(durable) < self.limit.get() {
+ self.durable_warned.set(false);
+ }
+ }
+
+ pub(crate) const fn admit_local_map_key(
Review Comment:
**Map-full denial is terminal, but the condition is transient.**
`uncertain: false` here makes this `TooManyConsumerOffsets`, which every SDK
classifier treats as non-retryable. The condition is not terminal though: the
phantoms are reclaimable, and pre-reclaim runs at plan build while
`apply_local_offset` runs later on the detached disk task — requests landing in
that window get a permanent code for a condition that would have cleared.
Worth pairing with the SDK backoff: `sdk/src/clients/consumer.rs:1358-1366`
sleeps only for `Disconnected | Unauthenticated | StaleClient`, so
reclassifying this to 58 alone would relocate the hot-loop rather than fix it —
and 58 additionally triggers a full-roster walk (`leader_aware.rs:352` marks it
"safe to re-issue anywhere"), turning one tight loop into an N× fan-out. The
SDK change wants to land first.
##########
core/partitions/src/state_transfer.rs:
##########
@@ -2754,6 +2861,12 @@ where
entry.offset.store(value, Ordering::Release);
let path = entry.path.clone();
self.consumer_offsets.pin().insert(*id as usize, entry);
+ self.durable_consumer_offsets.record_explicit(
Review Comment:
**Durable membership is recorded before the files land.**
`record_explicit(..., None)` runs here, but `mark_persisted` only runs for
writes that succeeded (:2911). On the degraded `offsets_written == false` path
the partition holds durable membership for keys with no file on disk.
The safe half works — `covers()` is false for `None`, so the next
auto-commit blind-writes and repairs. The unsafe half: a key that never commits
again is still counted by `occupied()` and still exported by
`committed_entries`, so this node advertises in its own transfer artifact an
offset it will lose on its own restart.
The guard at :1998-2015 tests *live-map* membership rather than file
durability, so it cannot catch this.
##########
core/partitions/src/iggy_partition.rs:
##########
@@ -2421,6 +2776,30 @@ where
}
};
+ if args.auto_commit
+ && self.consensus.is_primary()
+ && let Ok(pending) =
PendingConsumerOffsetCommit::try_from_polling_consumer(consumer, 0)
+ {
+ let capacity = self.consumer_offset_capacity_for(pending.kind);
+ if !capacity.is_uncertain()
+ && self.consumer_offset_map_count(pending.kind) >=
capacity.limit()
+ {
+ let exists = match pending.kind {
+ ConsumerKind::Consumer => self
+ .consumer_offsets
+ .pin()
+ .contains_key(&(pending.consumer_id as usize)),
+ ConsumerKind::ConsumerGroup => self
+ .consumer_group_offsets
+ .pin()
+ .contains_key(&ConsumerGroupId(pending.consumer_id as
usize)),
+ };
+ if !exists {
Review Comment:
**Unmemoized O(limit) sweep on the poll path, and the SDK closes the loop.**
`reclaim_phantom_offsets` is a full papaya `retain` whose predicate does up
to 3 hash lookups plus an atomic per entry. The guard above (`map_count >=
limit && !exists`) is exactly the saturated steady state, so while the map is
full every poll from a non-member repeats the whole scan — on the pump that
serves every partition on the shard.
It self-amplifies: refusal → `sdk/src/clients/consumer.rs:1358-1366` backs
off only for `Disconnected | Unauthenticated | StaleClient`, so 3024/58 fall
through with no sleep → immediate re-poll under `without_poll_interval()` →
this sweep again. Any client with poll rights and fresh consumer ids arms it;
no rate limit anywhere in the loop.
A `Cell<u64>` epoch bumped only by protection-losing events
(`durable.remove`, `release_reservation`, `set_pending_count`,
`rollback_created`, view change), skipping the `retain` when nothing changed
since the last sweep that reclaimed nothing, makes this O(1) amortized — and
cheap enough to also run on backups, which is what :2702 needs.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]