spetz commented on code in PR #4387:
URL: https://github.com/apache/iggy/pull/4387#discussion_r4183150322
##########
core/server/src/http/submit.rs:
##########
@@ -406,75 +410,62 @@ pub(in crate::http) async fn partition_write_replicated(
);
PartitionWriteError::Unavailable
})?;
- dispatch_partition_request(
- &state.shard,
- message,
- session.client_id,
- session.session,
- session.client_id,
- Some(session.user_id),
- None,
- )
- .await;
- drop(next_data_request_id);
- let outcome = compio::time::timeout(PARTITION_WRITE_REPLY_TIMEOUT,
receiver).await;
- // Removes the slot unless the reply already fired, so a late commit
- // reply after a timeout sheds at the bus instead of leaking a waiter.
+ let attachment = session.attachment.borrow().clone();
+ let outcome =
+
compio::time::timeout(deadline.saturating_duration_since(Instant::now()), async
{
+ dispatch_partition_request(
+ &state.shard,
+ message,
+ session.client_id,
+ session.session,
+ session.client_id,
+ Some(session.user_id),
+ Some((session.client_id, attachment)),
+ )
+ .await;
+ if let Some(dispatched) = dispatched.take() {
+ let _ = dispatched.send(Ok(()));
+ }
+ receiver.await
+ })
+ .await;
drop(guard);
match outcome {
Ok(Ok(reply)) => {
let reply = reply.into_contiguous();
classify_partition_reply(&reply).map(|header| (reply, header))
}
- // Cancelled (reply target torn down by session eviction mid-wait) or
- // elapsed: same caller contract either way - outcome unknown, 504.
Ok(Err(_)) | Err(_) => Err(PartitionWriteError::Timeout(operation)),
}
}
-/// Fire-and-forget produce (`?ack=none`): installs no reply slot and never
-/// awaits a commit. The commit still happens; its reply (and any gate-failure
-/// reply) targets a request id with no slot installed and is shed at the bus
by
-/// design.
-///
-/// Admitted against the in-flight caps exactly like the acked path
-/// ([`partition_write_replicated`]): being reply-less does not make it free.
-/// The request still parks inside [`dispatch_partition_request`] for the
-/// routable-wait budget while pinning its buffered body, so it must count
-/// against the per-session and shard-global budgets or it would be an uncapped
-/// bypass. The in-flight guard is held across dispatch and released by `Drop`.
-/// A refusal is a synchronous 429/503, which is honest for `?ack=none`:
-/// admission runs before dispatch, so it is not a commit reply the caller
-/// opted out of - it is the request being turned away up front.
+/// Return after dispatch while a detached task holds the partition gate until
+/// the reply or deadline. A 202 still carries no commit or admission
guarantee.
pub(in crate::http) async fn produce_unacked(
- state: &HttpInner,
- session: &HttpSession,
- body: &[u8],
+ state: &Rc<HttpInner>,
+ session: &Rc<HttpSession>,
+ body: Bytes,
) -> Result<(), PartitionWriteError> {
- let _in_flight = admit_partition_write(&session.in_flight_writes,
&state.in_flight_writes)?;
- // Same gate as the acked path, for the same ordering reason.
- let mut next_data_request_id = session.data_gate.lock().await;
- let request_id = *next_data_request_id;
- *next_data_request_id += 1;
- let message = build_request_message(
- Operation::SendMessages,
- session.client_id,
- session.session,
- request_id,
- body,
- );
- dispatch_partition_request(
- &state.shard,
- message,
- session.client_id,
- session.session,
- session.client_id,
- Some(session.user_id),
- None,
- )
- .await;
- drop(next_data_request_id);
- Ok(())
+ let state = Rc::clone(state);
+ let session = Rc::clone(session);
+ let (sent, dispatched) = oneshot::channel();
+ compio::runtime::spawn(async move {
Review Comment:
The reason for keeping `?ack=none` inside `partition_write()` is clear from
`core/server/src/http/submit.rs:377`, but the admission doc now contradicts it.
`core/server/src/http/admission.rs:40` still says that `?ack=none` produces
install no reply slot and never await a commit, while `produce_unacked` spawns
`partition_write` at `core/server/src/http/submit.rs:458`, which takes the
in-flight permit at `core/server/src/http/submit.rs:385`, installs a reply slot
at `core/server/src/http/submit.rs:402` and awaits the reply at
`core/server/src/http/submit.rs:432`, so an unacked write holds its budget
until the commit or the reply timeout.
##########
core/server/src/consumer_group/liveness.rs:
##########
@@ -233,6 +248,236 @@ where
.unwrap_or(Pass::Drained)
}
+#[allow(clippy::too_many_lines)]
+async fn retire_sessions<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ liveness: &RefCell<ConsumerGroupLiveness>,
+ stop: &Receiver<()>,
+ shutdown: &ShutdownToken,
+ report_interval: Duration,
+) where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header =
PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ let metadata = shard.plane.metadata();
+ let Some(consensus) = metadata.consensus.as_ref() else {
+ return;
+ };
+ if !consensus.is_normal()
+ || consensus.is_transferring()
+ || consensus.commit_min() != consensus.commit_max()
+ {
+ return;
+ }
+ let view = consensus.view();
+ let identity = metadata
+ .client_table
+ .borrow()
+ .ended_sessions()
+ .min_by_key(|identity| (identity.metadata_watermark,
identity.client_id));
Review Comment:
The busy-partition stall is fixed by the `pending_request` check at
`core/partitions/src/iggy_partition.rs:709` and the backup-quorum finalize at
`core/server/src/consumer_group/liveness.rs:290`, but two paths still block
finalization. A tombstoned or fatal namespace never reads as retired
(`core/shard/src/lib.rs:2107`, `core/shard/src/poll.rs:183`,
`core/partitions/src/iggy_partition.rs:705`) and the sweep has no filter for
it, so on a single node, where the primary counts itself only after a complete
sweep at `core/server/src/consumer_group/liveness.rs:449`, the cursor resets
every pass at `core/server/src/consumer_group/liveness.rs:422` and no ended
session is finalized until the table fills and new sessions are refused. Every
applied `TruncatePartition` also bumps the revision at
`core/metadata/src/stm/stream.rs:972` even when the watermark stays put, and
`core/server/src/consumer_group/lease.rs:176` then zeroes the cursor and clears
the reporters, so steady segment deletion s
tarves finalization while the sweep still retires one identity at a time
across every partition (`core/server/src/consumer_group/liveness.rs:281`).
##########
core/server/src/partition_helpers.rs:
##########
@@ -612,7 +603,37 @@ async fn load_partition(
dir: directory.clone(),
source,
},
- })?;
+ };
+ if replica_count == 1
Review Comment:
This still reproduces at bb42eda0. For a partition that metadata names but
whose directory never existed, boot calls `load_partition_or_fence` at
`core/server/src/boot/recovery.rs:209`, the loader takes the `NotFound` arm at
`core/server/src/partition_helpers.rs:255` and creates the hierarchy, and then
the single-node frontier check at `core/server/src/partition_helpers.rs:610`
returns `InvalidData`, which `core/server/src/partition_helpers.rs:588` maps to
`Refused` and `core/server/src/partition_helpers.rs:391` tombstones. The only
fresh-build path for an absent directory is in the reconciler at
`core/server/src/partition_reconciler.rs:792`, and its gate at
`core/server/src/partition_reconciler.rs:656` refuses a namespace that boot
already tombstoned. The fix commit only widened the error mapping, and the
README paragraph at `core/server/README.md:154` predates this comment.
##########
core/binary_protocol/src/version.rs:
##########
@@ -114,17 +110,15 @@ pub const IGGY_PROTOCOL_VERSION: u32 =
parse_packed_semver(env!("CARGO_PKG_VERSI
// minor/patch = compatible, so MIN derives from the current major instead
// of the current minor. Under 0.x a minor bump may break the wire, so the
// minor-scoped window is correct.
-pub const IGGY_PROTOCOL_VERSION_MIN: u32 = IGGY_PROTOCOL_VERSION & !PATCH_MASK;
-// A 0.0.x crate version would pack MIN to 0, which `EvictionHeader::validate`
-// rejects (window requires min >= 1) -- the server would emit eviction frames
-// that fail its own validation.
-const _: () = assert!(IGGY_PROTOCOL_VERSION_MIN > 0);
+pub const IGGY_PROTOCOL_VERSION_MIN: u32 = pack_protocol_version(0, 11, 1);
Review Comment:
This is still open at bb42eda0. Both `IGGY_PROTOCOL_VERSION` and
`IGGY_PROTOCOL_VERSION_MIN` remain `pack_protocol_version(0, 11, 1)` at
`core/binary_protocol/src/version.rs:103` and
`core/binary_protocol/src/version.rs:116`, so a pre-PR client built from
`0.11.1-edge.1` still passes the gate at
`core/server/src/dispatch/session_ops.rs:1114`, and `decode_after_prefix` reads
its username, password and context bytes as the 32-byte bind secret at
`core/binary_protocol/src/requests/users/login_register.rs:146`. That client
gets `MalformedLogin`, or `InvalidCredentials` for a body of 32 bytes or more,
and never `IncompatibleProtocol`. The notes added at
`core/binary_protocol/src/version.rs:100` and `core/server/README.md:94`
describe the clash, while
`core/binary_protocol/src/requests/users/login_register.rs:88` itself requires
a version bump for any wire-incompatible change.
##########
core/server/src/partition_helpers.rs:
##########
@@ -612,7 +603,37 @@ async fn load_partition(
dir: directory.clone(),
source,
},
- })?;
+ };
+ if replica_count == 1
+ &&
!journal::PartitionPrepareJournal::has_published_frontier(&directory)
+ .await
+ .map_err(&wal_error)?
+ {
+ return Err(wal_error(std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ "existing singleton partition is missing its prepare WAL
frontier",
+ )));
+ }
+ if replica_count > 1 && !allow_bootstrap {
Review Comment:
This still reproduces at bb42eda0. Boot passes `allow_bootstrap = false` at
`core/server/src/boot/recovery.rs:218`, so every multi-replica load reaches
`fence_missing_history` at `core/server/src/partition_helpers.rs:620`, and
`core/partitions/src/persistence.rs:504` marks materialization missing whenever
no frontier is published, without reading any segment or WAL bytes. A fenced
replica never raises `recovery_election_allowed`
(`core/partitions/src/iggy_partition.rs:963`), only a caught-up primary builds
a transfer offer at `core/partitions/src/state_transfer.rs:2037`, and the fence
clears only after an install at `core/partitions/src/state_transfer.rs:2982`,
so after a whole-cluster power loss before the first frontier no replica can
elect or serve and the group stays dark. The README text at
`core/server/README.md:157` predates this comment and still sends the operator
to delete and recreate the partition.
##########
core/consensus/src/client_table.rs:
##########
@@ -765,20 +585,196 @@ impl ClientTable {
/// # Panics
/// If the table already holds a client.
pub fn set_capacity(&mut self, max_clients: usize) {
+ if self.capacity_committed {
+ return;
+ }
assert!(
self.index.is_empty(),
"set_capacity must run before any client registers"
);
*self = Self::with_mode(max_clients, self.mode);
}
- /// Snapshot the table for the metadata checkpoint: every occupied slot
with its
- /// index, so positions (and with them deterministic eviction order)
survive, plus
- /// each entry's `client_id` so the index rebuilds on decode.
+ #[must_use]
+ pub const fn capacity_committed(&self) -> bool {
+ self.capacity_committed
+ }
+
+ #[must_use]
+ pub fn contains(&self, client_id: u128) -> bool {
+ self.index.contains_key(&client_id)
+ }
+
+ /// Verify a binding credential, resolving a zero epoch to its original
registration.
+ ///
+ /// # Errors
+ /// Returns `TransientNotCommitted` while registration is not visible,
+ /// `Unauthenticated` for a bad proof or ended session, and
`InvalidSession`
+ /// for a different nonzero epoch.
+ pub fn bind_session(
+ &mut self,
+ client_id: u128,
+ session: u64,
+ secret: &[u8; BIND_SECRET_BYTES],
+ ) -> Result<(u32, u64, SessionAttachment), IggyError> {
+ let user_id = self
+ .get_user_id(client_id)
+ .ok_or(IggyError::TransientNotCommitted)?;
+ let epoch = self
+ .registered_session(
+ client_id,
+ user_id,
+ bind_verifier(client_id, user_id, secret),
+ )?
+ .ok_or(IggyError::TransientNotCommitted)?;
+ if session != 0 && session != epoch {
+ return Err(IggyError::InvalidSession(session));
+ }
+ let attachment = self
+ .attach_session(client_id, epoch, user_id)
+ .ok_or(IggyError::Unauthenticated)?;
+ Ok((user_id, epoch, attachment))
+ }
+
+ /// Resolve an idempotent registration for the same principal and verifier.
+ ///
+ /// # Errors
+ /// Returns `Unauthenticated` when a registered identity has different
credentials.
+ ///
+ /// # Panics
+ /// Only if the private index refers to an unoccupied slot.
+ pub fn registered_session(
+ &self,
+ client_id: u128,
+ user_id: u32,
+ verifier: [u8; BIND_SECRET_BYTES],
+ ) -> Result<Option<u64>, IggyError> {
+ let Some(&slot) = self.index.get(&client_id) else {
+ return Ok(None);
+ };
+ let entry = self.slots[slot].as_ref().expect("index/slot mismatch");
+ if entry.user_id != user_id
+ || entry.ended_op.is_some()
+ || blake3::Hash::from_bytes(entry.bind_verifier) !=
blake3::Hash::from_bytes(verifier)
+ {
+ return Err(IggyError::Unauthenticated);
+ }
+ Ok(Some(entry.epoch))
+ }
+
+ pub fn end_session(
+ &mut self,
+ client_id: u128,
+ user_id: u32,
+ session: u64,
+ ended_op: u64,
+ ) -> bool {
+ if let Some(&slot) = self.index.get(&client_id)
+ && let Some(entry) = &mut self.slots[slot]
+ && entry.user_id == user_id
+ && entry.epoch == session
+ && entry.ended_op.is_none()
+ {
+ entry.ended_op = Some(ended_op);
+ entry.attachment = None;
+ return true;
+ }
+ false
+ }
+
+ /// Retain the exact Logout result before ending its session.
+ ///
+ /// # Errors
+ /// Returns an error if a new Logout receipt cannot be retained.
+ ///
+ /// # Panics
+ /// Only if the private index refers to an unoccupied slot.
+ pub fn commit_logout(
+ &mut self,
+ client_id: u128,
+ user_id: u32,
+ session: u64,
+ reply: Message<ReplyHeader>,
+ ) -> Result<bool, ClientTableWireError> {
+ let Some(&slot) = self.index.get(&client_id) else {
+ return Ok(false);
+ };
+ let entry = self.slots[slot].as_ref().expect("index/slot mismatch");
+ if entry.epoch != session
+ || entry.user_id != user_id
+ || entry.ended_op.is_some()
+ || (reply.header().request <= entry.watermark
+ && reply.header().request != EXPIRED_SESSION_REQUEST_ID)
+ {
+ return Ok(false);
+ }
+ let ended_op = reply.header().commit;
+ if self.commit_reply(client_id, user_id, reply) != CommitReply::Cached
{
+ return Err(ClientTableWireError::InvalidWatermark { client_id });
+ }
+ Ok(self.end_session(client_id, user_id, session, ended_op))
+ }
+
+ pub fn ended_sessions(
+ &self,
+ ) -> impl Iterator<Item =
iggy_binary_protocol::requests::system::SessionIdentity> + '_ {
+ self.slots.iter().flatten().filter_map(|entry| {
+ entry.ended_op.map(
+ |ended_op|
iggy_binary_protocol::requests::system::SessionIdentity {
+ client_id: entry.client_id,
+ session: entry.epoch,
+ metadata_watermark: ended_op,
+ },
+ )
+ })
+ }
+
+ pub fn forget_session(&mut self, client_id: u128, session: u64) -> bool {
+ let Some(&slot) = self.index.get(&client_id) else {
+ return false;
+ };
+ if self.slots[slot]
+ .as_ref()
+ .is_none_or(|entry| entry.epoch != session)
+ {
+ return false;
+ }
+ self.index.remove(&client_id);
+ self.slots[slot] = None;
+ true
+ }
+
+ pub fn finalize_session(
+ &mut self,
+ identity: iggy_binary_protocol::requests::system::SessionIdentity,
+ ) -> bool {
+ if !self.ended_sessions().any(|ended| ended == identity) {
+ return false;
+ }
+ self.forget_session(identity.client_id, identity.session)
+ }
+
+ /// Apply the limit carried by the ordered log, independent of local
configuration.
///
- /// Walks only occupied slots. This runs on shard 0's checkpoint task,
inside the
- /// section where that core is already blocked on the snapshot's fsyncs,
so it must
- /// not also allocate and serde-walk one element per configured slot.
+ /// # Errors
+ /// Refuses an invalid limit, a changed committed limit, or one below live
protection.
+ pub fn commit_capacity(&mut self, capacity: usize) -> Result<(),
ClientTableWireError> {
+ if capacity == 0
+ || capacity > CLIENTS_TABLE_SLOT_MAX
+ || self.count() > capacity
+ || (self.capacity_committed && self.clients_max != capacity)
Review Comment:
The freeze is documented at `core/server/README.md:66`, but the operator
configuration still says the opposite. `core/server/config.toml:863` tells
operators that raising `clients_table_max` lifts the HTTP session cap too,
which holds only for new groups, because the HTTP cap follows the configured
value at `core/server/src/http/session.rs:51` while an existing metadata table
keeps its committed size, and the same text sits in
`core/configs/src/server_config/metadata.rs:113`. A partition that replays its
capacity from the WAL at `core/partitions/src/iggy_partition.rs:6516` without a
receipt checkpoint also adopts the committed `retry_capacity` with no mismatch
warning, unlike the paths at `core/metadata/src/impls/metadata.rs:1048` and
`core/partitions/src/state_transfer.rs:4158`.
##########
core/server/src/dispatch/mod.rs:
##########
@@ -788,6 +793,60 @@ where
.and_then(std::rc::Weak::upgrade)
}
+#[allow(clippy::future_not_send)]
+async fn complete_session_binding<B, MJ, S, SB>(
+ shard: &Rc<ShellShard<B, MJ, S, SB>>,
+ sessions: &Rc<RefCell<SessionManager>>,
+ transport_client_id: u128,
+ identity: SessionIdentity,
+ secret: BindSecret,
+) -> Result<(u32, u64), IggyError>
+where
+ B: ShellBus,
+ MJ: JournalHandle + 'static,
+ MJ::Target: Journal<Entry = Message<PrepareHeader>, Header =
PrepareHeader>,
+ S: 'static,
+ SB: SuperblockStore + 'static,
+{
+ let watermark = identity.metadata_watermark.max(identity.session);
Review Comment:
The owner check at `core/server/src/dispatch/mod.rs:807` closes the
missing-user case but not a reused id. `DeleteUser` at
`core/metadata/src/stm/user.rs:575` frees the slab key without touching the
client table, so the entry keeps the old `user_id` and its verifier, and
`IdSlab::insert` at `core/metadata/src/stm/id_slab.rs:78` hands the lowest
freed key to the next `CreateUser` at `core/metadata/src/stm/user.rs:474`.
After a delete and a new create, the holder of the deleted user's bind secret
passes `bind_session` at `core/consensus/src/client_table.rs:617` and the
active-user check, and then acts with the new user's permissions. Deactivation
only sets `user.status` at `core/metadata/src/stm/user.rs:547` and nothing
after the bind checks it, so an already bound connection keeps passing
`authorize_partition_op` at `core/server/src/dispatch/authz.rs:57`, which makes
the statement at `core/server/README.md:64` that deactivation ends session
access false.
##########
core/message_bus/src/replica/handshake.rs:
##########
@@ -471,3 +523,45 @@ fn build_finish_message(
},
)
}
+
+fn with_identity(
+ message: Message<GenericHeader>,
+ identity: &[u8; auth::IDENTITY_LEN],
+) -> Message<GenericHeader> {
+ message.transmute_header(|old, new: &mut GenericHeader| {
+ *new = old;
+ new.reserved_command[auth::IDENTITY_OFFSET..auth::IDENTITY_OFFSET +
auth::IDENTITY_LEN]
+ .copy_from_slice(identity);
+ })
+}
+
+/// Exact release, protocol, and executable identity for coordinated
deployment.
+/// # Errors
+/// Returns an error if the running executable cannot be opened or read.
+pub fn binary_identity(release: &str) -> std::io::Result<[u8;
auth::IDENTITY_LEN]> {
+ static ARTIFACT: OnceLock<[u8; auth::IDENTITY_LEN]> = OnceLock::new();
+ const READ_BYTES: usize = 32 * 1024;
+ let artifact = if let Some(artifact) = ARTIFACT.get() {
+ *artifact
+ } else {
+ let mut file = std::fs::File::open(std::env::current_exe()?)?;
Review Comment:
The race and the blocking read are fixed, since `bootstrap` warms the
identity at `core/server/src/boot/mod.rs:302` and `OnceLock::get_or_init` at
`core/message_bus/src/replica/handshake.rs:544` hashes once. The executable
hash itself is unchanged: `core/message_bus/src/replica/handshake.rs:545` reads
the whole `current_exe`, `core/message_bus/src/replica/handshake.rs:564` mixes
the digest into the peer identity unconditionally, and no config key, env var
or flag turns it off, so a stripped, distro-packaged or rebuilt binary of the
same release is still rejected with `IncompatibleBuild` at
`core/message_bus/src/replica/handshake.rs:182`. The note at
`core/server/README.md:92` documents the limit, but a rolling replace across
build variants stays impossible.
##########
core/consensus/src/client_table.rs:
##########
@@ -1352,65 +1283,102 @@ impl ClientTable {
entry.latest_commit = entry.latest_commit.max(commit_op);
}
}
- return;
+ return Ok(());
}
- let freed = if self.index.len() >= self.clients_max {
- self.evict_oldest()
- } else {
- None
- };
- let Some(slot_idx) = freed.or_else(|| self.first_free_slot()) else {
- // Only reachable at a zero cap, which config validation rejects.
- return;
+ if self.index.len() >= self.clients_max {
Review Comment:
The doc at `core/consensus/src/impls.rs:189` is fixed, but the configuration
still promises the eviction that this PR removed. `core/server/config.toml:863`
says that the client whose last commit is oldest is evicted and
`core/server/config.toml:973` says that under-sizing degrades rather than
breaks, with the same text in `core/configs/src/server_config/metadata.rs:113`
and `core/configs/src/server_config/partition.rs:209`, while `commit_request`
now returns `TooManyEntries` at `core/consensus/src/client_table.rs:1257` and
admission refuses new writers through `has_retry_capacity` at
`core/partitions/src/iggy_partition.rs:4680`. An operator who under-sizes
`dedup_clients_max` gets `TransientNotAccepted` for new writers, and each freed
slot waits on the serial retirement sweep at
`core/server/src/consumer_group/liveness.rs:281`.
--
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]