You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iggy.apache.org by gk...@apache.org on 2026/07/07 11:05:16 UTC
(iggy) branch master updated: feat(server-ng): add cluster-correct HTTP listener + per-op RBAC (#3622)
This is an automated email from the ASF dual-hosted git repository.
numinnex pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new d892380ee feat(server-ng): add cluster-correct HTTP listener + per-op RBAC (#3622)
d892380ee is described below
commit d892380ee8b29b969773eab5451c51b3e3234334
Author: Hubert Gruszecki <h....@gmail.com>
AuthorDate: Tue Jul 7 13:05:11 2026 +0200
feat(server-ng): add cluster-correct HTTP listener + per-op RBAC (#3622)
server-ng had no HTTP transport, and the legacy HTTP stack is
single-node-coupled with no notion of views, leaders, or replicated
authorization.
Make HTTP a regular client of the dispatch spine. Control writes
await the committed reply; the data plane gets one new primitive, a
request-keyed in-process reply sink on the connection registry.
Auth is stateless JWT/PAT verification bound to a VSR session, with
a per-session gate keeping request ids gap-free; HTTP control
writes are at-least-once since retries mint fresh ids. Per-op RBAC
is enforced in the metadata apply and at dispatch time on every
transport. Reads default to serializable-local; linearizable reads
serve on the primary or 307-redirect. submit_gated replays
TransientNotCommitted server-side (HTTP has no client retry loop).
Produce honors ack=none behind the same admission caps as the acked
path.
GetSnapshot is implemented across binary and HTTP:
authorization-gated on read_servers || manage_servers from day one,
collected on a dedicated OS thread so the compio event loop never
stalls, single-flight, with a typed reject past the 64 MiB binary
frame cap.
Replication fixes ride along. Poll auto_commit offsets replicate
through consensus with a monotone commit-apply, so a late
fire-and-forget op cannot rewind the offset and re-deliver. The
commit-path offset reader tolerates torn files instead of
crash-looping. ChangePassword rejects a wrong current password as a
committed no-op, keeping request ids contiguous. Username bounds
are enforced inside the replicated apply, which raw clients cannot
bypass.
Parity: typed denies replace silent drops and fake successes, PAT
framing is SDK-decodable, legacy login opcodes evict instead of
stalling, re-login works on a live connection, GetClusterMetadata
serves the real roster, the missing partition and segment HTTP
routes are wired, create routes uniformly answer 200.
In the test harness HTTP becomes a first-class VSR transport;
authorization coverage consolidates into one permissions scenario
asserting exact error codes over all four transports; snapshot
tests and the mcp suite run fully under vsr.
---
core/binary_protocol/src/consensus/header.rs | 85 +-
core/common/src/error/eviction.rs | 69 +
core/common/src/error/mod.rs | 1 +
core/common/src/lib.rs | 1 +
core/common/src/traits/binary_impls/mod.rs | 19 +
.../traits/binary_impls/personal_access_tokens.rs | 1 +
core/common/src/traits/binary_impls/users.rs | 1 +
core/common/src/types/consumer/consumer_kind.rs | 2 +-
core/consensus/src/client_table.rs | 156 +-
core/consensus/src/impls.rs | 4 +
core/consensus/src/metadata_helpers.rs | 14 +-
core/consensus/src/plane_helpers.rs | 93 +-
core/harness_derive/src/codegen.rs | 15 +-
core/integration/tests/data_integrity/mod.rs | 12 +-
.../verify_auto_commit_offset_replicates.rs | 188 +++
core/integration/tests/mcp/mod.rs | 16 +-
.../tests/server/cluster_metadata_vsr.rs | 76 +
.../tests/server/concurrent_addition.rs | 12 +-
core/integration/tests/server/flush_vsr.rs | 66 +
core/integration/tests/server/general.rs | 29 +-
core/integration/tests/server/http_client.rs | 188 +++
core/integration/tests/server/http_rbac.rs | 532 +++++++
core/integration/tests/server/http_vsr.rs | 843 +++++++++++
core/integration/tests/server/legacy_login_vsr.rs | 108 ++
core/integration/tests/server/mod.rs | 28 +-
core/integration/tests/server/purge_delete.rs | 21 +-
core/integration/tests/server/scenarios/mod.rs | 8 +-
.../tests/server/scenarios/permissions_scenario.rs | 402 +++++
core/message_bus/src/lib.rs | 106 +-
.../src/lifecycle/connection_registry.rs | 466 +++++-
core/message_bus/src/lifecycle/mod.rs | 2 +-
core/metadata/src/impls/metadata.rs | 302 +++-
core/metadata/src/impls/recovery.rs | 6 +-
.../src/permissioner/permissioner_rules/system.rs | 4 +
core/metadata/src/stm/authz.rs | 760 ++++++++++
core/metadata/src/stm/consumer_group.rs | 52 +-
core/metadata/src/stm/mod.rs | 1 +
core/metadata/src/stm/result.rs | 101 +-
core/metadata/src/stm/stream.rs | 8 +-
core/metadata/src/stm/user.rs | 665 ++++++++-
core/partitions/src/iggy_partition.rs | 595 +++++++-
core/partitions/src/lib.rs | 2 +-
core/partitions/src/offset_storage.rs | 145 +-
core/partitions/src/poll_plan.rs | 258 ++--
core/sdk/src/session.rs | 54 +-
core/sdk/src/vsr.rs | 123 +-
core/server-ng/Cargo.toml | 1 +
core/server-ng/{server-ng.http => server.http} | 35 +-
core/server-ng/src/auth.rs | 63 +-
core/server-ng/src/bootstrap.rs | 96 +-
core/server-ng/src/cluster_meta.rs | 128 ++
core/server-ng/src/dispatch.rs | 804 +++++++---
core/server-ng/src/dispatch/authz.rs | 360 +++++
core/server-ng/src/http.rs | 239 +++
core/server-ng/src/http/admission.rs | 184 +++
core/server-ng/src/http/error.rs | 558 +++++++
core/server-ng/src/http/extractor.rs | 160 ++
core/server-ng/src/http/handlers.rs | 1548 ++++++++++++++++++++
core/server-ng/src/http/jwt.rs | 307 ++++
core/server-ng/src/http/reads.rs | 184 +++
core/server-ng/src/http/reply.rs | 425 ++++++
core/server-ng/src/http/session.rs | 386 +++++
core/server-ng/src/http/state.rs | 283 ++++
core/server-ng/src/http/submit.rs | 404 +++++
core/server-ng/src/http/wire.rs | 544 +++++++
core/server-ng/src/lib.rs | 3 +
core/server-ng/src/pat.rs | 22 +-
core/server-ng/src/responses.rs | 609 ++++++--
core/server-ng/src/session_manager.rs | 19 +
core/server-ng/src/snapshot.rs | 295 ++++
core/server-ng/src/snapshot/procdump.rs | 212 +++
core/server-ng/src/users.rs | 156 +-
core/shard/src/lib.rs | 1 +
core/simulator/src/lib.rs | 5 +-
core/simulator/src/replica.rs | 4 +
core/simulator/src/workload/ops/change_password.rs | 6 +-
.../src/workload/ops/create_consumer_group.rs | 33 +-
core/simulator/src/workload/ops/create_user.rs | 5 +-
core/simulator/src/workload/ops/delete_user.rs | 7 +-
.../src/workload/ops/update_permissions.rs | 2 +
core/simulator/src/workload/ops/update_user.rs | 7 +-
81 files changed, 13917 insertions(+), 818 deletions(-)
diff --git a/core/binary_protocol/src/consensus/header.rs b/core/binary_protocol/src/consensus/header.rs
index ff87bf05f..3913f5f58 100644
--- a/core/binary_protocol/src/consensus/header.rs
+++ b/core/binary_protocol/src/consensus/header.rs
@@ -151,7 +151,13 @@ pub struct RequestHeader {
pub operation_padding: [u8; 7],
pub namespace: u64,
pub session: u64,
- pub reserved: [u8; 56],
+ /// Acting user id, stamped by the metadata primary at admission for every
+ /// gated client op so the in-apply RBAC gate resolves the same identity on
+ /// every replica; on `Register` it carries the freshly authenticated user.
+ /// The submitter's wire value is never trusted. Zero for `Logout`,
+ /// partition-plane, and server-internal ops.
+ pub user_id: u32,
+ pub reserved: [u8; 52],
}
const _: () = {
assert!(size_of::<RequestHeader>() == HEADER_SIZE);
@@ -159,7 +165,10 @@ const _: () = {
offset_of!(RequestHeader, client)
== offset_of!(RequestHeader, reserved_frame) + size_of::<[u8; 66]>()
);
- assert!(offset_of!(RequestHeader, reserved) + size_of::<[u8; 56]>() == HEADER_SIZE);
+ assert!(
+ offset_of!(RequestHeader, user_id) == offset_of!(RequestHeader, session) + size_of::<u64>()
+ );
+ assert!(offset_of!(RequestHeader, reserved) + size_of::<[u8; 52]>() == HEADER_SIZE);
};
impl Default for RequestHeader {
@@ -182,7 +191,8 @@ impl Default for RequestHeader {
operation_padding: [0; 7],
namespace: 0,
session: 0,
- reserved: [0; 56],
+ user_id: 0,
+ reserved: [0; 52],
}
}
}
@@ -272,7 +282,20 @@ pub struct ReplyHeader {
pub operation: Operation,
pub operation_padding: [u8; 7],
pub namespace: u64,
- pub reserved: [u8; 32],
+ /// Request-level status: 0 = ok; nonzero = the `IggyError` code for a
+ /// failure decided before commit (e.g. a dispatch-time authorization
+ /// denial, or the partition primary rejecting a consumer-offset op).
+ ///
+ /// Contract: this is nonzero ONLY on a pre-commit denial, and a deny
+ /// reply always carries an EMPTY body. So this header channel and the
+ /// committed per-sub-op results in the metadata result section are mutually
+ /// exclusive by construction: a reply either commits (status 0, result
+ /// section present) or is denied before commit (status set, no body), and a
+ /// consumer never reconciles the two. Carved from `reserved` exactly like
+ /// `user_id` in `RequestHeader` / `PrepareHeader`; no existing field offset
+ /// moves and `validate` does not inspect it.
+ pub status: u32,
+ pub reserved: [u8; 28],
}
const _: () = {
assert!(size_of::<ReplyHeader>() == HEADER_SIZE);
@@ -280,7 +303,10 @@ const _: () = {
offset_of!(ReplyHeader, request_checksum)
== offset_of!(ReplyHeader, reserved_frame) + size_of::<[u8; 66]>()
);
- assert!(offset_of!(ReplyHeader, reserved) + size_of::<[u8; 32]>() == HEADER_SIZE);
+ assert!(
+ offset_of!(ReplyHeader, status) == offset_of!(ReplyHeader, namespace) + size_of::<u64>()
+ );
+ assert!(offset_of!(ReplyHeader, reserved) + size_of::<[u8; 28]>() == HEADER_SIZE);
};
impl Default for ReplyHeader {
@@ -305,7 +331,8 @@ impl Default for ReplyHeader {
operation: Operation::Reserved,
operation_padding: [0; 7],
namespace: 0,
- reserved: [0; 32],
+ status: 0,
+ reserved: [0; 28],
}
}
}
@@ -591,7 +618,10 @@ pub struct PrepareHeader {
pub operation: Operation,
pub operation_padding: [u8; 7],
pub namespace: u64,
- pub reserved: [u8; 32],
+ /// Acting user id, copied verbatim from the admitted `RequestHeader`; see
+ /// that field for the stamping contract.
+ pub user_id: u32,
+ pub reserved: [u8; 28],
}
const _: () = {
assert!(size_of::<PrepareHeader>() == HEADER_SIZE);
@@ -599,7 +629,11 @@ const _: () = {
offset_of!(PrepareHeader, client)
== offset_of!(PrepareHeader, reserved_frame) + size_of::<[u8; 66]>()
);
- assert!(offset_of!(PrepareHeader, reserved) + size_of::<[u8; 32]>() == HEADER_SIZE);
+ assert!(
+ offset_of!(PrepareHeader, user_id)
+ == offset_of!(PrepareHeader, namespace) + size_of::<u64>()
+ );
+ assert!(offset_of!(PrepareHeader, reserved) + size_of::<[u8; 28]>() == HEADER_SIZE);
};
impl Default for PrepareHeader {
@@ -624,7 +658,8 @@ impl Default for PrepareHeader {
operation: Operation::Reserved,
operation_padding: [0; 7],
namespace: 0,
- reserved: [0; 32],
+ user_id: 0,
+ reserved: [0; 28],
}
}
}
@@ -989,8 +1024,8 @@ impl ConsensusHeader for StartViewHeader {
mod tests {
use super::{
Command2, CommitHeader, ConsensusHeader, DoViewChangeHeader, EvictionHeader,
- EvictionReason, GenericHeader, Operation, PrepareHeader, PrepareOkHeader, ReplyHeader,
- RequestHeader, StartViewChangeHeader, StartViewHeader,
+ EvictionReason, GenericHeader, HEADER_SIZE, Operation, PrepareHeader, PrepareOkHeader,
+ ReplyHeader, RequestHeader, StartViewChangeHeader, StartViewHeader,
};
use aligned_vec::{AVec, ConstAlign};
@@ -1112,6 +1147,34 @@ mod tests {
assert!(header.validate().is_ok());
}
+ // `status` is carved from the reserved tail; the SDK reply funnel peeks it
+ // at this offset before any body decode, so a layout drift must trip here.
+ #[test]
+ fn reply_header_status_offset_and_size_pinned() {
+ use std::mem::offset_of;
+ assert_eq!(size_of::<ReplyHeader>(), HEADER_SIZE);
+ assert_eq!(
+ offset_of!(ReplyHeader, status),
+ offset_of!(ReplyHeader, namespace) + size_of::<u64>()
+ );
+ assert_eq!(
+ offset_of!(ReplyHeader, reserved) + size_of::<[u8; 28]>(),
+ HEADER_SIZE
+ );
+ }
+
+ // A nonzero status rides the reserved region, which reply `validate` does
+ // not inspect: a status-bearing reply stays valid so the SDK can peek it.
+ #[test]
+ fn reply_header_nonzero_status_still_validates() {
+ let header = ReplyHeader {
+ command: Command2::Reply,
+ status: 41,
+ ..ReplyHeader::default()
+ };
+ assert!(header.validate().is_ok());
+ }
+
// Wire-discriminant pin: any change breaks SDK decoders.
#[test]
fn eviction_reason_discriminants_pinned() {
diff --git a/core/common/src/error/eviction.rs b/core/common/src/error/eviction.rs
new file mode 100644
index 000000000..851027425
--- /dev/null
+++ b/core/common/src/error/eviction.rs
@@ -0,0 +1,69 @@
+// 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.
+
+//! Shared grading of a wire [`EvictionReason`] to the typed [`IggyError`] an
+//! evicted session surfaces. The SDK's binary-transport Eviction-frame decoder
+//! (TCP / QUIC / WebSocket) and the server-ng HTTP write path both call this,
+//! so every transport sees one status per reason.
+
+use iggy_binary_protocol::consensus::EvictionReason;
+use iggy_binary_protocol::version::IGGY_PROTOCOL_VERSION;
+
+use super::iggy_error::IggyError;
+
+/// Map a session-terminal [`EvictionReason`], plus the accepted protocol window
+/// an `IncompatibleProtocol` frame carries, to the [`IggyError`] the caller
+/// renders. Session-terminal reasons collapse to re-authentication statuses;
+/// `IncompatibleProtocol` reports the exact window unless it is degenerate (a
+/// zero minimum or an inverted range), which also falls back to
+/// re-authentication.
+///
+/// The two callers extract the fields differently - server-ng from an aligned
+/// `EvictionHeader`, the SDK by wire offset off an unaligned buffer - then grade
+/// through here so the mappings cannot drift apart.
+#[must_use]
+pub fn eviction_reason_to_error(
+ reason: EvictionReason,
+ server_protocol_version: u32,
+ server_protocol_version_min: u32,
+) -> IggyError {
+ match reason {
+ EvictionReason::InvalidCredentials => IggyError::InvalidCredentials,
+ EvictionReason::InvalidToken => IggyError::InvalidPersonalAccessToken,
+ EvictionReason::UserInactive
+ | EvictionReason::SessionError
+ | EvictionReason::NoSession
+ | EvictionReason::SessionTooLow
+ | EvictionReason::SessionReleaseMismatch => IggyError::Unauthenticated,
+ EvictionReason::StaleClient => IggyError::StaleClient,
+ EvictionReason::IncompatibleProtocol => {
+ if server_protocol_version_min == 0
+ || server_protocol_version < server_protocol_version_min
+ {
+ IggyError::Unauthenticated
+ } else {
+ IggyError::IncompatibleProtocolVersion(
+ IGGY_PROTOCOL_VERSION,
+ server_protocol_version_min,
+ server_protocol_version,
+ )
+ }
+ }
+ EvictionReason::MalformedLogin => IggyError::InvalidFormat,
+ _ => IggyError::InvalidCommand,
+ }
+}
diff --git a/core/common/src/error/mod.rs b/core/common/src/error/mod.rs
index 593dde577..ad3b557ed 100644
--- a/core/common/src/error/mod.rs
+++ b/core/common/src/error/mod.rs
@@ -16,6 +16,7 @@
// under the License.
pub(crate) mod client_error;
+pub mod eviction;
pub(crate) mod iggy_error;
// preserves existing internal import paths across
diff --git a/core/common/src/lib.rs b/core/common/src/lib.rs
index 9b8032043..12f139d2a 100644
--- a/core/common/src/lib.rs
+++ b/core/common/src/lib.rs
@@ -26,6 +26,7 @@ mod utils;
pub mod wire_conversions;
pub use error::client_error::ClientError;
+pub use error::eviction::eviction_reason_to_error;
pub use error::iggy_error::{IggyError, IggyErrorDiscriminants};
// Locking is feature gated, thus only mod level re-export.
pub mod locking;
diff --git a/core/common/src/traits/binary_impls/mod.rs b/core/common/src/traits/binary_impls/mod.rs
index a10112253..7a7e7e6d4 100644
--- a/core/common/src/traits/binary_impls/mod.rs
+++ b/core/common/src/traits/binary_impls/mod.rs
@@ -31,6 +31,8 @@ use crate::IggyError;
use crate::http::users::defaults::{
MAX_PASSWORD_LENGTH, MAX_USERNAME_LENGTH, MIN_PASSWORD_LENGTH, MIN_USERNAME_LENGTH,
};
+#[cfg(feature = "vsr")]
+use crate::{BinaryClient, ClientState};
use iggy_binary_protocol::WireDecode;
#[cfg(feature = "vsr")]
use iggy_binary_protocol::{ClientVersionInfo, IGGY_PROTOCOL_VERSION, WireName};
@@ -52,6 +54,23 @@ pub(crate) fn rust_sdk_version_info(sdk_version: &str) -> Result<ClientVersionIn
})
}
+/// Release a live session before a fresh login on the same connection.
+///
+/// server-ng binds a connection to one `(client, session)` and resolves the
+/// acting user from that binding, not the wire header. A re-login on a still-
+/// bound connection is served as an idempotent replay that keeps the old
+/// identity, so switching users on a live connection requires logging out
+/// first: the committed Logout unbinds the connection cluster-wide, so the
+/// following Register takes the full register path and rebinds the newly
+/// authenticated user. No-op when the client is not authenticated.
+#[cfg(feature = "vsr")]
+pub(crate) async fn logout_before_relogin<B: BinaryClient>(client: &B) -> Result<(), IggyError> {
+ if client.get_state().await == ClientState::Authenticated {
+ client.logout_user().await?;
+ }
+ Ok(())
+}
+
/// Same bounds and error every server (HTTP and binary) enforces, applied
/// before encoding so an oversized password can never desync the u8 length
/// prefix on the wire.
diff --git a/core/common/src/traits/binary_impls/personal_access_tokens.rs b/core/common/src/traits/binary_impls/personal_access_tokens.rs
index 01eb40a9f..202b4f41f 100644
--- a/core/common/src/traits/binary_impls/personal_access_tokens.rs
+++ b/core/common/src/traits/binary_impls/personal_access_tokens.rs
@@ -105,6 +105,7 @@ impl<B: BinaryClient> PersonalAccessTokenClient for B {
) -> Result<IdentityInfo, IggyError> {
#[cfg(feature = "vsr")]
{
+ super::logout_before_relogin(self).await?;
// Same bounds the non-vsr branch gets from `WireName::new(token)`;
// the request stores a `SecretString`, so enforce them here to keep
// the u8 length prefix consistent with the realized bytes.
diff --git a/core/common/src/traits/binary_impls/users.rs b/core/common/src/traits/binary_impls/users.rs
index 37bc907b7..1a7f15b70 100644
--- a/core/common/src/traits/binary_impls/users.rs
+++ b/core/common/src/traits/binary_impls/users.rs
@@ -189,6 +189,7 @@ impl<B: BinaryClient> UserClient for B {
super::validate_password(password)?;
#[cfg(feature = "vsr")]
{
+ super::logout_before_relogin(self).await?;
let wire_name = WireName::new(username).map_err(|_| IggyError::InvalidFormat)?;
let response = match self
.send_raw_with_response(
diff --git a/core/common/src/types/consumer/consumer_kind.rs b/core/common/src/types/consumer/consumer_kind.rs
index bf68c6e48..60a33cb20 100644
--- a/core/common/src/types/consumer/consumer_kind.rs
+++ b/core/common/src/types/consumer/consumer_kind.rs
@@ -42,7 +42,7 @@ pub struct Consumer {
}
/// `ConsumerKind` is an enum that represents the type of consumer.
-#[derive(Debug, Serialize, Deserialize, PartialEq, Default, Copy, Clone, ValueEnum)]
+#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Hash, Default, Copy, Clone, ValueEnum)]
#[serde(rename_all = "snake_case")]
pub enum ConsumerKind {
/// `Consumer` represents a regular consumer.
diff --git a/core/consensus/src/client_table.rs b/core/consensus/src/client_table.rs
index cf582de65..ed517d0f2 100644
--- a/core/consensus/src/client_table.rs
+++ b/core/consensus/src/client_table.rs
@@ -79,6 +79,9 @@ pub struct ClientEntry {
/// Session number = commit op of the register. Monotonic across
/// registrations; new register always gets a higher session.
pub session: u64,
+ /// Acting user id captured at register. Fixed for the entry's lifetime;
+ /// lets every replica resolve session -> user without a metadata lookup.
+ pub user_id: u32,
/// Cached reply for client's latest committed request.
pub reply: CachedReply,
}
@@ -241,8 +244,13 @@ impl ClientTable {
/// # Panics
/// If `client_id == 0`, `session == 0`, or `client_id != reply.header().client`.
/// Session mismatch does NOT panic.
- pub fn commit_register<F>(&mut self, client_id: u128, reply: Message<ReplyHeader>, in_flight: F)
- where
+ pub fn commit_register<F>(
+ &mut self,
+ client_id: u128,
+ user_id: u32,
+ reply: Message<ReplyHeader>,
+ in_flight: F,
+ ) where
F: Fn(u128) -> bool,
{
assert!(client_id != 0, "client_id 0 is reserved for internal use");
@@ -291,6 +299,7 @@ impl ClientTable {
let slot_idx = self.first_free_slot().expect("eviction must free a slot");
self.slots[slot_idx] = Some(ClientEntry {
session,
+ user_id,
reply: cached,
});
self.index.insert(client_id, slot_idx);
@@ -462,6 +471,13 @@ impl ClientTable {
self.slots[slot_idx].as_ref().map(|entry| entry.session)
}
+ /// Acting user id captured when the client registered.
+ #[must_use]
+ pub fn get_user_id(&self, client_id: u128) -> Option<u32> {
+ let &slot_idx = self.index.get(&client_id)?;
+ self.slots[slot_idx].as_ref().map(|entry| entry.user_id)
+ }
+
/// Active committed entries.
#[must_use]
pub fn count(&self) -> usize {
@@ -474,6 +490,10 @@ mod tests {
use super::*;
use iggy_binary_protocol::{Command2, Operation};
+ /// Arbitrary non-zero user id for register fixtures; most tests don't
+ /// assert on it (see `register_stores_user_id` for the accessor check).
+ const TEST_USER_ID: u32 = 7;
+
fn make_register_reply(client: u128, commit: u64) -> Message<ReplyHeader> {
let header_size = std::mem::size_of::<ReplyHeader>();
let mut msg = Message::<ReplyHeader>::new(header_size);
@@ -519,7 +539,12 @@ mod tests {
fn table_with_client() -> (ClientTable, u64) {
let mut table = ClientTable::new(10);
let session = 10;
- table.commit_register(1, make_register_reply(1, session), no_in_flight());
+ table.commit_register(
+ 1,
+ TEST_USER_ID,
+ make_register_reply(1, session),
+ no_in_flight(),
+ );
(table, session)
}
@@ -528,11 +553,27 @@ mod tests {
#[test]
fn register_creates_session() {
let mut table = ClientTable::new(10);
- table.commit_register(1, make_register_reply(1, 42), no_in_flight());
+ table.commit_register(1, TEST_USER_ID, make_register_reply(1, 42), no_in_flight());
assert_eq!(table.get_session(1), Some(42));
+ assert_eq!(table.get_user_id(1), Some(TEST_USER_ID));
assert_eq!(table.count(), 1);
}
+ // Each entry keeps the user id it registered with; lookups are per-client.
+ #[test]
+ fn register_stores_user_id() {
+ let mut table = ClientTable::new(10);
+ table.commit_register(1, 11, make_register_reply(1, 10), no_in_flight());
+ table.commit_register(2, 22, make_register_reply(2, 20), no_in_flight());
+ assert_eq!(table.get_user_id(1), Some(11));
+ assert_eq!(table.get_user_id(2), Some(22));
+ assert_eq!(
+ table.get_user_id(3),
+ None,
+ "unregistered client has no user"
+ );
+ }
+
#[test]
fn check_register_new_client() {
let table = ClientTable::new(10);
@@ -696,9 +737,24 @@ mod tests {
#[test]
fn eviction_removes_oldest_commit() {
let mut table = ClientTable::new(2);
- table.commit_register(100, make_register_reply(100, 10), no_in_flight());
- table.commit_register(200, make_register_reply(200, 20), no_in_flight());
- table.commit_register(300, make_register_reply(300, 30), no_in_flight());
+ table.commit_register(
+ 100,
+ TEST_USER_ID,
+ make_register_reply(100, 10),
+ no_in_flight(),
+ );
+ table.commit_register(
+ 200,
+ TEST_USER_ID,
+ make_register_reply(200, 20),
+ no_in_flight(),
+ );
+ table.commit_register(
+ 300,
+ TEST_USER_ID,
+ make_register_reply(300, 30),
+ no_in_flight(),
+ );
assert!(table.get_reply(100).is_none());
assert!(table.get_reply(200).is_some());
assert!(table.get_reply(300).is_some());
@@ -708,9 +764,24 @@ mod tests {
#[test]
fn eviction_is_deterministic_by_slot_index() {
let mut table = ClientTable::new(2);
- table.commit_register(100, make_register_reply(100, 10), no_in_flight());
- table.commit_register(200, make_register_reply(200, 10), no_in_flight());
- table.commit_register(300, make_register_reply(300, 30), no_in_flight());
+ table.commit_register(
+ 100,
+ TEST_USER_ID,
+ make_register_reply(100, 10),
+ no_in_flight(),
+ );
+ table.commit_register(
+ 200,
+ TEST_USER_ID,
+ make_register_reply(200, 10),
+ no_in_flight(),
+ );
+ table.commit_register(
+ 300,
+ TEST_USER_ID,
+ make_register_reply(300, 30),
+ no_in_flight(),
+ );
assert!(table.get_reply(100).is_none());
assert!(table.get_reply(200).is_some());
assert!(table.get_reply(300).is_some());
@@ -719,8 +790,18 @@ mod tests {
#[test]
fn slot_reuse_after_eviction() {
let mut table = ClientTable::new(1);
- table.commit_register(100, make_register_reply(100, 10), no_in_flight());
- table.commit_register(200, make_register_reply(200, 20), no_in_flight());
+ table.commit_register(
+ 100,
+ TEST_USER_ID,
+ make_register_reply(100, 10),
+ no_in_flight(),
+ );
+ table.commit_register(
+ 200,
+ TEST_USER_ID,
+ make_register_reply(200, 20),
+ no_in_flight(),
+ );
assert!(table.get_reply(100).is_none());
assert!(table.get_reply(200).is_some());
assert_eq!(table.count(), 1);
@@ -732,11 +813,21 @@ mod tests {
#[test]
fn eviction_skips_in_flight_clients() {
let mut table = ClientTable::new(2);
- table.commit_register(100, make_register_reply(100, 10), no_in_flight());
- table.commit_register(200, make_register_reply(200, 20), no_in_flight());
+ table.commit_register(
+ 100,
+ TEST_USER_ID,
+ make_register_reply(100, 10),
+ no_in_flight(),
+ );
+ table.commit_register(
+ 200,
+ TEST_USER_ID,
+ make_register_reply(200, 20),
+ no_in_flight(),
+ );
// 100 in-flight; eviction must pick 200.
let in_flight = |c: u128| c == 100;
- table.commit_register(300, make_register_reply(300, 30), in_flight);
+ table.commit_register(300, TEST_USER_ID, make_register_reply(300, 30), in_flight);
assert!(
table.get_reply(100).is_some(),
"in-flight client must survive"
@@ -753,10 +844,25 @@ mod tests {
#[test]
fn eviction_falls_back_to_oldest_when_all_in_flight() {
let mut table = ClientTable::new(2);
- table.commit_register(100, make_register_reply(100, 10), no_in_flight());
- table.commit_register(200, make_register_reply(200, 20), no_in_flight());
+ table.commit_register(
+ 100,
+ TEST_USER_ID,
+ make_register_reply(100, 10),
+ no_in_flight(),
+ );
+ table.commit_register(
+ 200,
+ TEST_USER_ID,
+ make_register_reply(200, 20),
+ no_in_flight(),
+ );
let all_in_flight = |_| true;
- table.commit_register(300, make_register_reply(300, 30), all_in_flight);
+ table.commit_register(
+ 300,
+ TEST_USER_ID,
+ make_register_reply(300, 30),
+ all_in_flight,
+ );
assert!(
table.get_reply(100).is_none(),
"100 evicted (oldest fallback)"
@@ -770,9 +876,9 @@ mod tests {
#[test]
fn commit_register_idempotent_on_replay() {
let mut table = ClientTable::new(10);
- table.commit_register(1, make_register_reply(1, 10), no_in_flight());
+ table.commit_register(1, TEST_USER_ID, make_register_reply(1, 10), no_in_flight());
// Same client_id + session = idempotent (WAL replay).
- table.commit_register(1, make_register_reply(1, 10), no_in_flight());
+ table.commit_register(1, TEST_USER_ID, make_register_reply(1, 10), no_in_flight());
assert_eq!(table.get_session(1), Some(10));
assert_eq!(table.count(), 1);
}
@@ -783,12 +889,12 @@ mod tests {
#[test]
fn commit_register_different_session_logs_and_skips() {
let mut table = ClientTable::new(10);
- table.commit_register(1, make_register_reply(1, 10), no_in_flight());
+ table.commit_register(1, TEST_USER_ID, make_register_reply(1, 10), no_in_flight());
// existing=10, replay=20.
- table.commit_register(1, make_register_reply(1, 20), no_in_flight());
+ table.commit_register(1, TEST_USER_ID, make_register_reply(1, 20), no_in_flight());
assert_eq!(table.get_session(1), Some(10), "first session stays");
// Smaller replay session: same skip.
- table.commit_register(1, make_register_reply(1, 5), no_in_flight());
+ table.commit_register(1, TEST_USER_ID, make_register_reply(1, 5), no_in_flight());
assert_eq!(table.get_session(1), Some(10));
}
@@ -814,8 +920,8 @@ mod tests {
#[test]
fn different_clients_independent_sessions() {
let mut table = ClientTable::new(10);
- table.commit_register(1, make_register_reply(1, 10), no_in_flight());
- table.commit_register(2, make_register_reply(2, 20), no_in_flight());
+ table.commit_register(1, TEST_USER_ID, make_register_reply(1, 10), no_in_flight());
+ table.commit_register(2, TEST_USER_ID, make_register_reply(2, 20), no_in_flight());
assert_eq!(table.get_session(1), Some(10));
assert_eq!(table.get_session(2), Some(20));
assert!(matches!(table.check_request(1, 10, 1), RequestStatus::New));
diff --git a/core/consensus/src/impls.rs b/core/consensus/src/impls.rs
index 53bb8aaac..94be74bf1 100644
--- a/core/consensus/src/impls.rs
+++ b/core/consensus/src/impls.rs
@@ -2108,6 +2108,10 @@ where
timestamp,
operation: old.operation,
namespace: old.namespace,
+ // Copied verbatim: carries the stamped acting user for client
+ // ops (and the authenticated user on Register), so the in-apply
+ // RBAC gate resolves the same identity on every backup.
+ user_id: old.user_id,
..Default::default()
}
})
diff --git a/core/consensus/src/metadata_helpers.rs b/core/consensus/src/metadata_helpers.rs
index b2a4ecfdc..8a180c37c 100644
--- a/core/consensus/src/metadata_helpers.rs
+++ b/core/consensus/src/metadata_helpers.rs
@@ -414,6 +414,10 @@ mod tests {
use iggy_binary_protocol::{Command2, Operation, ReplyHeader};
use message_bus::SendError;
+ /// Acting user for register fixtures; these tests exercise preflight /
+ /// replay, not user resolution, so the exact value is immaterial.
+ const ACTING_USER_ID: u32 = 1;
+
/// Production-sized `ClientTable`.
fn fresh_client_table() -> RefCell<ClientTable> {
RefCell::new(ClientTable::new(CLIENTS_TABLE_MAX))
@@ -475,7 +479,7 @@ mod tests {
let original_checksum = initial_reply.header().checksum;
client_table
.borrow_mut()
- .commit_register(client_id, initial_reply, |_| false);
+ .commit_register(client_id, ACTING_USER_ID, initial_reply, |_| false);
let result =
futures::executor::block_on(register_preflight(&consensus, &client_table, client_id));
@@ -511,7 +515,7 @@ mod tests {
let initial_reply = synthesize_register_reply(&consensus, client_id, session);
client_table
.borrow_mut()
- .commit_register(client_id, initial_reply, |_| false);
+ .commit_register(client_id, ACTING_USER_ID, initial_reply, |_| false);
// SendMessages commits -> cached is no longer the register reply.
let app_reply = synthesize_send_messages_reply(&consensus, client_id, session, 1, 18);
@@ -579,7 +583,7 @@ mod tests {
let initial_reply = synthesize_register_reply(&consensus, client_id, real_session);
client_table
.borrow_mut()
- .commit_register(client_id, initial_reply, |_| false);
+ .commit_register(client_id, ACTING_USER_ID, initial_reply, |_| false);
// Older retry (17 < 99): stale-session case.
let result = futures::executor::block_on(apply_preflight_consensus_plane(
@@ -615,7 +619,7 @@ mod tests {
let initial_reply = synthesize_register_reply(&consensus, client_id, real_session);
client_table
.borrow_mut()
- .commit_register(client_id, initial_reply, |_| false);
+ .commit_register(client_id, ACTING_USER_ID, initial_reply, |_| false);
// Client claims newer session (99 > 17), client bug.
let result = futures::executor::block_on(apply_preflight_consensus_plane(
@@ -677,7 +681,7 @@ mod tests {
let initial_reply = synthesize_register_reply(&consensus, client_id, session);
client_table
.borrow_mut()
- .commit_register(client_id, initial_reply, |_| false);
+ .commit_register(client_id, ACTING_USER_ID, initial_reply, |_| false);
// Cache at request 5 -> request 3 is stale.
let advanced = synthesize_send_messages_reply(&consensus, client_id, session, 5, 100);
client_table
diff --git a/core/consensus/src/plane_helpers.rs b/core/consensus/src/plane_helpers.rs
index 32f027e37..0b454c078 100644
--- a/core/consensus/src/plane_helpers.rs
+++ b/core/consensus/src/plane_helpers.rs
@@ -298,9 +298,7 @@ where
let total_size = header_size + body_len;
let mut buffer = bytes::BytesMut::zeroed(total_size);
- let header = bytemuck::checked::try_from_bytes_mut::<ReplyHeader>(&mut buffer[..header_size])
- .expect("zeroed bytes are valid");
- *header = ReplyHeader {
+ let header = ReplyHeader {
checksum: 0,
checksum_body: 0,
cluster: prepare_header.cluster,
@@ -325,6 +323,8 @@ where
namespace: prepare_header.namespace,
..Default::default()
};
+ // `BytesMut` makes no alignment guarantee, so never cast into it.
+ buffer[..header_size].copy_from_slice(bytemuck::bytes_of(&header));
fill(&mut buffer[header_size..]);
@@ -341,9 +341,9 @@ where
/// request that could not be committed *right now* (not-caught-up / in-flight
/// / pipeline-full / view-change cancel); the SDK decodes it and replays the
/// same `request_id` immediately instead of waiting out its response
-/// read-timeout. Any other code is a TERMINAL rejection (e.g.
-/// `ConsumerOffsetNotFound` / `InvalidOffset` from partition-plane admission)
-/// that the SDK surfaces as the typed error.
+/// read-timeout. Any other code is a TERMINAL rejection (e.g. a committed
+/// metadata rejection like `UserAlreadyExists`) that the SDK surfaces as the
+/// typed error.
///
/// Stamped from the request header (no prepare exists for a rejected op).
/// `commit` is the primary's current commit position and is informational
@@ -366,9 +366,7 @@ pub fn build_result_rejection_reply(
let total_size = header_size + RESULT_BODY_LEN;
let mut buffer = bytes::BytesMut::zeroed(total_size);
- let header = bytemuck::checked::try_from_bytes_mut::<ReplyHeader>(&mut buffer[..header_size])
- .expect("zeroed bytes are valid");
- *header = ReplyHeader {
+ let header = ReplyHeader {
cluster: request_header.cluster,
size: total_size as u32,
view: request_header.view,
@@ -388,6 +386,7 @@ pub fn build_result_rejection_reply(
namespace: request_header.namespace,
..Default::default()
};
+ buffer[..header_size].copy_from_slice(bytemuck::bytes_of(&header));
let body = &mut buffer[header_size..];
body[0..4].copy_from_slice(&1u32.to_le_bytes());
@@ -420,9 +419,7 @@ where
let mut buffer = bytes::BytesMut::zeroed(total_size);
let commit = consensus.commit_max();
- let header = bytemuck::checked::try_from_bytes_mut::<ReplyHeader>(&mut buffer[..header_size])
- .expect("zeroed bytes are valid");
- *header = ReplyHeader {
+ let header = ReplyHeader {
checksum: 0,
checksum_body: 0,
cluster: consensus.cluster(),
@@ -443,6 +440,7 @@ where
namespace: request_header.namespace,
..Default::default()
};
+ buffer[..header_size].copy_from_slice(bytemuck::bytes_of(&header));
if !body.is_empty() {
buffer[header_size..].copy_from_slice(&body);
@@ -452,6 +450,37 @@ where
.expect("reply buffer must contain a valid reply message")
}
+/// Reply that denies a request on the primary before it enters the VSR
+/// pipeline.
+///
+/// The request's frame with an empty body and a nonzero `ReplyHeader.status`
+/// (the request-level error channel the SDK peeks before body decode).
+/// Nothing is prepared or replicated, so backups never see the denied
+/// request; `op` stays 0, the shape reply consumers already read as "nothing
+/// committed".
+///
+/// # Panics
+/// If the constructed message buffer is not valid.
+pub fn build_deny_reply_from_request<B, P>(
+ consensus: &VsrConsensus<B, P>,
+ request_header: &RequestHeader,
+ status: u32,
+) -> Message<ReplyHeader>
+where
+ B: MessageBus,
+ P: Pipeline<Entry = PipelineEntry>,
+{
+ let mut reply = build_reply_from_request(consensus, request_header, bytes::Bytes::new());
+ let header_size = std::mem::size_of::<ReplyHeader>();
+ let header_bytes = &mut reply.as_mut_slice()[..header_size];
+ let mut header = bytemuck::checked::try_pod_read_unaligned::<ReplyHeader>(header_bytes)
+ .expect("freshly built reply header is valid");
+ header.status = status;
+ header.op = 0;
+ header_bytes.copy_from_slice(bytemuck::bytes_of(&header));
+ reply
+}
+
/// Verify hash chain would not break if we add this header.
///
/// # Panics
@@ -539,7 +568,7 @@ pub async fn send_prepare_ok<B, P>(
mod tests {
use super::*;
use crate::{Consensus, LocalPipeline, VsrAction};
- use iggy_binary_protocol::StartViewChangeHeader;
+ use iggy_binary_protocol::{ConsensusHeader, Operation, StartViewChangeHeader};
use message_bus::SendError;
use server_common::{MESSAGE_ALIGN, iobuf::Frozen};
@@ -984,4 +1013,42 @@ mod tests {
Some(7)
);
}
+
+ // A deny echoes the request frame with an empty body, carries the error
+ // code in `status`, and keeps `op` at 0 even when the group has committed
+ // ops -- reply consumers read `op == 0` as "nothing committed", and the
+ // deny must never be mistaken for a committed reply.
+ #[test]
+ fn deny_reply_from_request_stamps_status_and_commits_nothing() {
+ let consensus = VsrConsensus::new(1, 0, 3, 0, NoopBus, LocalPipeline::new());
+ consensus.init();
+ consensus.advance_commit_max(4);
+
+ let request = RequestHeader {
+ command: Command2::Request,
+ operation: Operation::DeleteConsumerOffset2,
+ client: 42,
+ request: 7,
+ namespace: 9,
+ ..Default::default()
+ };
+ let status = 3021;
+ let reply = build_deny_reply_from_request(&consensus, &request, status);
+
+ let header = reply.header();
+ assert_eq!(header.command, Command2::Reply);
+ assert_eq!(header.status, status);
+ assert_eq!(header.op, 0, "a deny commits nothing");
+ assert_eq!(header.commit, 4);
+ assert_eq!(header.client, 42);
+ assert_eq!(header.request, 7);
+ assert_eq!(header.namespace, 9);
+ assert_eq!(header.operation, Operation::DeleteConsumerOffset2);
+ assert_eq!(
+ header.size as usize,
+ std::mem::size_of::<ReplyHeader>(),
+ "deny reply body must be empty"
+ );
+ assert!(header.validate().is_ok());
+ }
}
diff --git a/core/harness_derive/src/codegen.rs b/core/harness_derive/src/codegen.rs
index d31d48e90..95e8e987b 100644
--- a/core/harness_derive/src/codegen.rs
+++ b/core/harness_derive/src/codegen.rs
@@ -124,16 +124,11 @@ fn generate_variants(attrs: &IggyTestAttrs) -> Vec<TestVariant> {
variants
}
-/// HTTP is not served by the next-gen (VSR) server, so HTTP transport variants
-/// must not compile into the test binary under `--features vsr`. The proc macro
-/// cannot read the consuming crate's feature flags at expansion time, so it
-/// emits this `cfg` gate on HTTP variants; the `integration` crate resolves it.
-fn vsr_transport_cfg(transport: Transport) -> TokenStream {
- if matches!(transport, Transport::Http) {
- quote!(#[cfg(not(feature = "vsr"))])
- } else {
- quote!()
- }
+/// No transport is gated out of the VSR test matrix. Retained as the single
+/// seam where a transport could be excluded from `--features vsr` if one is
+/// ever unsupported by the next-gen server again.
+fn vsr_transport_cfg(_transport: Transport) -> TokenStream {
+ quote!()
}
/// Generate test code from attributes and input function.
diff --git a/core/integration/tests/data_integrity/mod.rs b/core/integration/tests/data_integrity/mod.rs
index 6201bc744..ed4f0c1e2 100644
--- a/core/integration/tests/data_integrity/mod.rs
+++ b/core/integration/tests/data_integrity/mod.rs
@@ -21,10 +21,13 @@
#[cfg(not(feature = "vsr"))]
mod verify_after_server_restart;
#[cfg(not(feature = "vsr"))]
-mod verify_no_plaintext_credentials_on_disk;
-#[cfg(not(feature = "vsr"))]
mod verify_user_login_after_restart;
+// Not restart-based: it creates a user + PAT, stops the server, and greps the
+// data dir for plaintext. No replica catch-up needed, and server-ng hashes the
+// password / PAT before either reaches the WAL, so it runs under vsr too.
+mod verify_no_plaintext_credentials_on_disk;
+
// The cooperative-rebalance matrix runs under vsr too: it exercises server-ng's
// consumer-group rebalancing (a VSR feature) and never restarts the cluster, so
// the no-state-transfer limitation above does not apply. Green at 95/95.
@@ -33,3 +36,8 @@ mod verify_consumer_group_partition_assignment;
// Cross-replica on-disk data identity is VSR-only.
#[cfg(feature = "vsr")]
mod verify_cluster_replica_data_identical;
+
+// Auto-commit offset replication is inherently a multi-node (VSR) property: the
+// backup only holds the offset if the poll's auto-commit rode consensus.
+#[cfg(feature = "vsr")]
+mod verify_auto_commit_offset_replicates;
diff --git a/core/integration/tests/data_integrity/verify_auto_commit_offset_replicates.rs b/core/integration/tests/data_integrity/verify_auto_commit_offset_replicates.rs
new file mode 100644
index 000000000..6f26dd983
--- /dev/null
+++ b/core/integration/tests/data_integrity/verify_auto_commit_offset_replicates.rs
@@ -0,0 +1,188 @@
+// 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.
+
+//! A poll with `auto_commit = true` must replicate the consumer offset through
+//! the partition consensus, not merely persist it on the serving node -- else a
+//! failover to a backup silently loses the committed offset.
+//!
+//! Discriminator: drive the poll+auto_commit on the primary (node 0) and read
+//! the offset from a backup's (node 1) on-disk store. The backup holds it only
+//! if the commit rode consensus; the pre-fix node-local persist never reaches
+//! it, so the file stays absent until the deadline and the test fails.
+
+use iggy::prelude::*;
+use integration::harness::TestHarness;
+use integration::iggy_harness;
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+use tokio::time::sleep;
+
+const STREAM_NAME: &str = "auto-commit-repl-stream";
+const TOPIC_NAME: &str = "auto-commit-repl-topic";
+const PARTITION_ID: u32 = 0;
+// Explicit numeric consumer id shared by the poll and the offset read;
+// `Consumer::default()` would carry id 0.
+const CONSUMER_ID: u32 = 7;
+const MESSAGE_COUNT: u32 = 10;
+// Offset of the last message after polling all of `0..MESSAGE_COUNT` from 0.
+const EXPECTED_OFFSET: u64 = (MESSAGE_COUNT - 1) as u64;
+// The primary answers the poll before the auto-commit replicates, so allow the
+// backup a bounded window to catch up rather than a fixed settle sleep.
+const REPLICATION_TIMEOUT: Duration = Duration::from_secs(15);
+const REPLICATION_POLL_INTERVAL: Duration = Duration::from_millis(100);
+
+#[iggy_harness(cluster_nodes = 3)]
+async fn given_cluster_when_poll_auto_commits_then_offset_replicates_to_backup(
+ harness: &TestHarness,
+) {
+ run(harness).await;
+}
+
+async fn run(harness: &TestHarness) {
+ let stream = Identifier::named(STREAM_NAME).unwrap();
+ let topic = Identifier::named(TOPIC_NAME).unwrap();
+ let consumer = Consumer::new(Identifier::numeric(CONSUMER_ID).unwrap());
+
+ // Primary (node 0, the view-0 primary): create the topic, produce, then poll
+ // with auto_commit so the offset is committed server-side.
+ let primary = harness.tcp_root_client().await.unwrap();
+ primary.create_stream(STREAM_NAME).await.unwrap();
+ primary
+ .create_topic(
+ &stream,
+ TOPIC_NAME,
+ 1,
+ CompressionAlgorithm::None,
+ None,
+ IggyExpiry::NeverExpire,
+ MaxTopicSize::ServerDefault,
+ )
+ .await
+ .unwrap();
+
+ let mut messages: Vec<IggyMessage> = (0..MESSAGE_COUNT)
+ .map(|i| {
+ IggyMessage::builder()
+ .payload(format!("auto-commit-{i}").into())
+ .build()
+ .unwrap()
+ })
+ .collect();
+ primary
+ .send_messages(
+ &stream,
+ &topic,
+ &Partitioning::partition_id(PARTITION_ID),
+ &mut messages,
+ )
+ .await
+ .unwrap();
+
+ let polled = primary
+ .poll_messages(
+ &stream,
+ &topic,
+ Some(PARTITION_ID),
+ &consumer,
+ &PollingStrategy::offset(0),
+ MESSAGE_COUNT,
+ true,
+ )
+ .await
+ .unwrap();
+ assert_eq!(
+ polled.messages.len(),
+ MESSAGE_COUNT as usize,
+ "poll must return every produced message so the auto-commit has an offset to store",
+ );
+
+ // Sanity: the serving node holds its own committed offset (eager in-memory
+ // apply). Passes on both the buggy and fixed paths -- the discriminator is
+ // the backup below.
+ let on_primary = primary
+ .get_consumer_offset(&consumer, &stream, &topic, Some(PARTITION_ID))
+ .await
+ .unwrap();
+ assert_eq!(
+ on_primary.map(|info| info.stored_offset),
+ Some(EXPECTED_OFFSET),
+ "the serving node must hold the auto-committed offset",
+ );
+
+ // Discriminator: the backup's on-disk consumer-offset store. A backup does
+ // not serve client logins (Register is a primary-only metadata op), so read
+ // its data dir directly. Only a consensus-replicated auto-commit lands there
+ // -- the pre-fix node-local persist never reaches the backup, so the file
+ // stays absent until the deadline and the test fails.
+ let backup_dir = harness.node(1).data_path();
+ let deadline = tokio::time::Instant::now() + REPLICATION_TIMEOUT;
+ loop {
+ if let Some(stored) = read_replicated_consumer_offset(&backup_dir) {
+ assert_eq!(
+ stored, EXPECTED_OFFSET,
+ "backup replicated a wrong auto-commit offset",
+ );
+ return;
+ }
+ assert!(
+ tokio::time::Instant::now() < deadline,
+ "auto-commit offset never replicated to the backup within {REPLICATION_TIMEOUT:?}; \
+ it stayed node-local (the bug)",
+ );
+ sleep(REPLICATION_POLL_INTERVAL).await;
+ }
+}
+
+/// The u64 offset persisted under any `offsets/consumers/<id>` file in a node's
+/// data dir, or `None` when no such file has been written yet. Walks the tree so
+/// it is robust to the stream/topic/partition id layout; the test drives exactly
+/// one consumer, so at most one such file exists. A zero-length read (persist
+/// truncates before writing the 8 bytes) is treated as not-yet-written.
+fn read_replicated_consumer_offset(data_dir: &Path) -> Option<u64> {
+ let mut stack: Vec<PathBuf> = vec![data_dir.to_path_buf()];
+ while let Some(dir) = stack.pop() {
+ let Ok(entries) = std::fs::read_dir(&dir) else {
+ continue;
+ };
+ for entry in entries.flatten() {
+ let path = entry.path();
+ let Ok(file_type) = entry.file_type() else {
+ continue;
+ };
+ if file_type.is_dir() {
+ stack.push(path);
+ continue;
+ }
+ let is_consumer_offset = path
+ .parent()
+ .and_then(Path::file_name)
+ .is_some_and(|name| name == "consumers")
+ && path
+ .parent()
+ .and_then(Path::parent)
+ .and_then(Path::file_name)
+ .is_some_and(|name| name == "offsets");
+ if is_consumer_offset
+ && let Ok(bytes) = std::fs::read(&path)
+ && let Ok(array) = <[u8; 8]>::try_from(bytes.as_slice())
+ {
+ return Some(u64::from_le_bytes(array));
+ }
+ }
+ }
+ None
+}
diff --git a/core/integration/tests/mcp/mod.rs b/core/integration/tests/mcp/mod.rs
index 6d2f74376..5468c273e 100644
--- a/core/integration/tests/mcp/mod.rs
+++ b/core/integration/tests/mcp/mod.rs
@@ -16,12 +16,11 @@
// under the License.
use iggy_common::{
- ClientInfo, ClientInfoDetails, ConsumerGroup, ConsumerGroupDetails, ConsumerOffsetInfo,
- PersonalAccessTokenExpiry, PersonalAccessTokenInfo, PolledMessages, RawPersonalAccessToken,
- Stats, Stream, StreamDetails, Topic, TopicDetails, UserInfo, UserInfoDetails,
+ ClientInfo, ClientInfoDetails, ClusterMetadata, ConsumerGroup, ConsumerGroupDetails,
+ ConsumerOffsetInfo, PersonalAccessTokenExpiry, PersonalAccessTokenInfo, PolledMessages,
+ RawPersonalAccessToken, Snapshot, Stats, Stream, StreamDetails, Topic, TopicDetails, UserInfo,
+ UserInfoDetails,
};
-#[cfg(not(feature = "vsr"))]
-use iggy_common::{ClusterMetadata, Snapshot};
use integration::{
harness::{McpClient, seeds},
iggy_harness,
@@ -86,8 +85,10 @@ async fn should_handle_ping(harness: &TestHarness) {
invoke_empty(&mcp_client, "ping", None).await;
}
-#[cfg(not(feature = "vsr"))]
-#[iggy_harness(server(cluster.enabled = true, mcp))]
+// Pins two nodes: the default vsr harness spawns three, but this test asserts a
+// two-node roster. Exercises the binary GetClusterMetadata roster end-to-end
+// over MCP (MCP server -> TCP -> iggy).
+#[iggy_harness(cluster_nodes = 2, server(mcp))]
async fn should_return_cluster_metadata(harness: &TestHarness) {
let mcp_client = harness.mcp_client().await.expect("MCP client required");
let cluster: ClusterMetadata = invoke(&mcp_client, "get_cluster_metadata", None).await;
@@ -361,7 +362,6 @@ async fn should_return_clients(harness: &TestHarness) {
assert!(!clients.is_empty());
}
-#[cfg(not(feature = "vsr"))]
#[iggy_harness(server(mcp), seed = seeds::mcp_standard)]
async fn should_handle_snapshot(harness: &TestHarness) {
let mcp_client = harness.mcp_client().await.expect("MCP client required");
diff --git a/core/integration/tests/server/cluster_metadata_vsr.rs b/core/integration/tests/server/cluster_metadata_vsr.rs
new file mode 100644
index 000000000..f15ad4416
--- /dev/null
+++ b/core/integration/tests/server/cluster_metadata_vsr.rs
@@ -0,0 +1,76 @@
+// 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.
+
+//! Binary `GetClusterMetadata` over a real VSR cluster. The reply must carry
+//! the full configured roster with real client ports and live leader/follower
+//! roles - not a single synthesized self node with zeroed ports.
+
+use iggy::prelude::*;
+use integration::iggy_harness;
+
+/// One shard per node so the client connection is always served by shard 0,
+/// where the metadata consensus that marks the leader lives. A request
+/// round-robined to a peer shard would still get the full roster but with no
+/// leader marked, since peer shards run no consensus.
+#[iggy_harness(cluster_nodes = 2, server(system.sharding.cpu_allocation = "0..1"))]
+async fn given_two_node_cluster_when_getting_cluster_metadata_should_return_full_roster(
+ harness: &TestHarness,
+) {
+ let client = harness
+ .node(0)
+ .tcp_client()
+ .expect("tcp client")
+ .with_root_login()
+ .connect()
+ .await
+ .expect("connect to node 0");
+
+ let metadata = client
+ .get_cluster_metadata()
+ .await
+ .expect("get cluster metadata");
+
+ assert_eq!(
+ metadata.nodes.len(),
+ harness.cluster_size(),
+ "every configured node must appear in the roster, got {metadata}"
+ );
+ for node in &metadata.nodes {
+ assert_ne!(
+ node.endpoints.tcp, 0,
+ "node {} must report its real tcp port, not the stub's 0",
+ node.name
+ );
+ }
+
+ let leaders = metadata
+ .nodes
+ .iter()
+ .filter(|node| node.role == ClusterNodeRole::Leader)
+ .count();
+ let followers = metadata
+ .nodes
+ .iter()
+ .filter(|node| node.role == ClusterNodeRole::Follower)
+ .count();
+ assert_eq!(leaders, 1, "exactly one node must lead, got {metadata}");
+ assert_eq!(
+ followers,
+ harness.cluster_size() - 1,
+ "every other node must follow, got {metadata}"
+ );
+}
diff --git a/core/integration/tests/server/concurrent_addition.rs b/core/integration/tests/server/concurrent_addition.rs
index e590078f1..ff1a4347e 100644
--- a/core/integration/tests/server/concurrent_addition.rs
+++ b/core/integration/tests/server/concurrent_addition.rs
@@ -37,19 +37,12 @@ use test_case::test_matrix;
quic.max_idle_timeout = "500s",
quic.keep_alive_interval = "15s"
))]
-// vsr excludes HTTP only (server-ng exposes no HTTP listener).
-#[cfg_attr(not(feature = "vsr"), test_matrix(
+#[test_matrix(
[tcp(), http(), quic(), websocket()],
[user(), stream(), topic(), partition(), consumer_group()],
[hot(), cold()],
[barrier_on(), barrier_off()]
-))]
-#[cfg_attr(feature = "vsr", test_matrix(
- [tcp(), quic(), websocket()],
- [user(), stream(), topic(), partition(), consumer_group()],
- [hot(), cold()],
- [barrier_on(), barrier_off()]
-))]
+)]
async fn matrix(
harness: &TestHarness,
transport: TransportProtocol,
@@ -64,7 +57,6 @@ fn tcp() -> TransportProtocol {
TransportProtocol::Tcp
}
-#[cfg(not(feature = "vsr"))]
fn http() -> TransportProtocol {
TransportProtocol::Http
}
diff --git a/core/integration/tests/server/flush_vsr.rs b/core/integration/tests/server/flush_vsr.rs
new file mode 100644
index 000000000..68f41040e
--- /dev/null
+++ b/core/integration/tests/server/flush_vsr.rs
@@ -0,0 +1,66 @@
+// 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.
+
+//! Flush contract against server-ng (vsr): the server has no on-demand flush
+//! primitive, so `FLUSH_UNSAVED_BUFFER` must surface a typed
+//! `FeatureUnavailable` over the SDK rather than the non-replicated catch-all's
+//! empty-ok, which would fake a durability guarantee.
+
+use iggy::prelude::*;
+use integration::iggy_harness;
+
+// server-ng partition ids are 0-based (CreateTopic assigns them from 0).
+const PARTITION_ID: u32 = 0;
+
+#[iggy_harness(
+ test_client_transport = [Tcp],
+ server(tcp.socket.override_defaults = true, tcp.socket.nodelay = true)
+)]
+async fn given_valid_partition_when_flushing_should_reject_feature_unavailable(
+ harness: &TestHarness,
+) {
+ let client = harness.tcp_root_client().await.expect("tcp root client");
+ client
+ .create_stream("flush-stream")
+ .await
+ .expect("create stream");
+ let stream_id = Identifier::from_str_value("flush-stream").expect("stream identifier");
+ client
+ .create_topic(
+ &stream_id,
+ "flush-topic",
+ 1,
+ CompressionAlgorithm::None,
+ None,
+ IggyExpiry::NeverExpire,
+ MaxTopicSize::ServerDefault,
+ )
+ .await
+ .expect("create topic");
+ let topic_id = Identifier::from_str_value("flush-topic").expect("topic identifier");
+
+ // A valid, existing partition proves the deny is feature-unavailable, not a
+ // target-not-found in disguise; fsync makes it the strongest durability ask.
+ let result = client
+ .flush_unsaved_buffer(&stream_id, &topic_id, PARTITION_ID, true)
+ .await;
+
+ assert!(
+ matches!(&result, Err(error) if error.as_code() == IggyError::FeatureUnavailable.as_code()),
+ "flush over TCP-ng must surface Err(FeatureUnavailable), got {result:?}"
+ );
+}
diff --git a/core/integration/tests/server/general.rs b/core/integration/tests/server/general.rs
index eb1a88d57..cb51ac316 100644
--- a/core/integration/tests/server/general.rs
+++ b/core/integration/tests/server/general.rs
@@ -15,14 +15,12 @@
// specific language governing permissions and limitations
// under the License.
-use crate::server::scenarios::{
- authentication_scenario, consumer_timestamp_polling_scenario, create_message_payload,
- invalid_consumer_offset_scenario, message_headers_scenario, stream_size_validation_scenario,
- system_scenario,
-};
#[cfg(not(feature = "vsr"))]
+use crate::server::scenarios::bench_scenario;
use crate::server::scenarios::{
- bench_scenario, permissions_scenario, snapshot_scenario, user_scenario,
+ authentication_scenario, consumer_timestamp_polling_scenario, create_message_payload,
+ invalid_consumer_offset_scenario, message_headers_scenario, permissions_scenario,
+ snapshot_scenario, stream_size_validation_scenario, system_scenario, user_scenario,
};
use integration::iggy_harness;
@@ -53,14 +51,6 @@ async fn system(harness: &TestHarness) {
system_scenario::run(harness).await;
}
-// Blocked under vsr: the startup-hang is fixed and root `created_at` now
-// resolves, but the scenario issues several sequential `login_user` calls on
-// one connection. Under vsr login == register, and the SDK's one-shot
-// `ConsensusSession` only resets on the reconnect/replay path, so the second
-// deliberate re-login panics `register_request_id already called`
-// (sdk/src/session.rs). Needs an SDK login-lifecycle fix (reset the session
-// on each fresh login).
-#[cfg(not(feature = "vsr"))]
#[iggy_harness(
test_client_transport = [Tcp, Http, Quic, WebSocket],
server(
@@ -74,14 +64,6 @@ async fn user(harness: &TestHarness) {
user_scenario::run(harness).await;
}
-// Blocked under vsr: password hashing and the metadata journal-append
-// race are now fixed, so the scenario runs deep into its permission
-// matrix. Remaining blocker is per-operation authorization: server-ng
-// does not enforce the caller's permissions on metadata ops, so a
-// `read_streams`-only user calling `create_stream` gets `InvalidFormat`
-// instead of `Unauthorized`. Needs the permission-enforcement subsystem
-// wired on the metadata plane.
-#[cfg(not(feature = "vsr"))]
#[iggy_harness(
test_client_transport = [Tcp, Http, Quic, WebSocket],
server(
@@ -164,9 +146,6 @@ async fn consumer_timestamp_polling(harness: &TestHarness) {
consumer_timestamp_polling_scenario::run(harness).await;
}
-// Blocked under vsr: the snapshot-file feature (GET_SNAPSHOT_FILE) is
-// not implemented in server-ng.
-#[cfg(not(feature = "vsr"))]
#[iggy_harness(
test_client_transport = [Tcp, Http, Quic, WebSocket],
server(
diff --git a/core/integration/tests/server/http_client.rs b/core/integration/tests/server/http_client.rs
new file mode 100644
index 000000000..7d9558494
--- /dev/null
+++ b/core/integration/tests/server/http_client.rs
@@ -0,0 +1,188 @@
+// 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.
+
+//! Shared HTTP transport plumbing for the server-ng REST suites (`http_vsr`,
+//! `http_rbac`): one authenticated `reqwest` session with the login-retry gate
+//! and the generic verb helpers. Each suite keeps its own request shapes and
+//! assertions as extension methods on [`HttpClient`], so the wire-contract and
+//! listener-behavior separation between the suites stays intact.
+
+use std::time::{Duration, Instant};
+
+use iggy::prelude::*;
+use integration::harness::TestHarness;
+use reqwest::Response;
+use serde_json::{Value, json};
+use tokio::time::sleep;
+
+/// The harness readiness gate waits for the dumped runtime config, which the
+/// server writes before binding the HTTP listener; a bounded login retry
+/// bridges that gap plus single-node consensus warmup.
+pub const LOGIN_TIMEOUT: Duration = Duration::from_secs(15);
+pub const LOGIN_RETRY_INTERVAL: Duration = Duration::from_millis(50);
+pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
+
+/// One authenticated HTTP session against the test server's shard-0 listener: a
+/// `reqwest` client, the listener base URL, and the bearer to send. The bearer
+/// is either a login JWT or a raw personal access token (server-ng resolves
+/// either on the `Authorization: Bearer` header).
+pub struct HttpClient {
+ pub client: reqwest::Client,
+ pub base_url: String,
+ pub token: String,
+}
+
+impl HttpClient {
+ /// Log in as root, retrying while the listener binds and consensus warms up.
+ pub async fn login_root(harness: &TestHarness) -> Self {
+ let addr = harness
+ .server()
+ .http_addr()
+ .expect("HTTP transport not configured on test server");
+ let base_url = format!("http://{addr}");
+ let client = reqwest::Client::builder()
+ .timeout(REQUEST_TIMEOUT)
+ .build()
+ .expect("build reqwest client");
+
+ let body = json!({
+ "username": DEFAULT_ROOT_USERNAME,
+ "password": DEFAULT_ROOT_PASSWORD,
+ });
+ let deadline = Instant::now() + LOGIN_TIMEOUT;
+ loop {
+ let attempt = client
+ .post(format!("{base_url}/users/login"))
+ .json(&body)
+ .send()
+ .await;
+ match attempt {
+ Ok(response) if response.status().is_success() => {
+ return Self {
+ client,
+ base_url,
+ token: access_token(response).await,
+ };
+ }
+ // Listener not bound yet or consensus still warming up; any
+ // non-5xx rejection (e.g. 401) is a real failure.
+ Ok(response) if response.status().is_server_error() => {}
+ Ok(response) => panic!("root login rejected: {}", response.status()),
+ Err(error) if error.is_connect() => {}
+ Err(error) => panic!("root login request failed: {error}"),
+ }
+ assert!(
+ Instant::now() < deadline,
+ "HTTP root login did not succeed within {LOGIN_TIMEOUT:?}"
+ );
+ sleep(LOGIN_RETRY_INTERVAL).await;
+ }
+ }
+
+ /// Log in as an already-created user; single attempt (the server is warm
+ /// once root exists). Reuses this session's client and base URL.
+ pub async fn login(&self, username: &str, password: &str) -> Self {
+ let body = json!({ "username": username, "password": password });
+ let response = self
+ .client
+ .post(self.url("/users/login"))
+ .json(&body)
+ .send()
+ .await
+ .expect("login request");
+ assert!(
+ response.status().is_success(),
+ "login for {username} failed: {}",
+ response.status()
+ );
+ Self {
+ client: self.client.clone(),
+ base_url: self.base_url.clone(),
+ token: access_token(response).await,
+ }
+ }
+
+ /// A view of this session bound to a different bearer (a refreshed JWT or a
+ /// PAT), reusing the same client and base URL.
+ pub fn with_token(&self, token: String) -> Self {
+ Self {
+ client: self.client.clone(),
+ base_url: self.base_url.clone(),
+ token,
+ }
+ }
+
+ pub fn url(&self, path: &str) -> String {
+ format!("{}{path}", self.base_url)
+ }
+
+ pub async fn get(&self, path: &str) -> Response {
+ self.client
+ .get(self.url(path))
+ .bearer_auth(&self.token)
+ .send()
+ .await
+ .expect("get request")
+ }
+
+ /// Unauthenticated GET (no bearer) for the public `/ping` probe.
+ pub async fn get_anonymous(&self, path: &str) -> Response {
+ self.client
+ .get(self.url(path))
+ .send()
+ .await
+ .expect("anonymous get request")
+ }
+
+ pub async fn post_json(&self, path: &str, body: &Value) -> Response {
+ self.client
+ .post(self.url(path))
+ .bearer_auth(&self.token)
+ .json(body)
+ .send()
+ .await
+ .expect("post request")
+ }
+
+ pub async fn put_json(&self, path: &str, body: &Value) -> Response {
+ self.client
+ .put(self.url(path))
+ .bearer_auth(&self.token)
+ .json(body)
+ .send()
+ .await
+ .expect("put request")
+ }
+
+ pub async fn delete(&self, path: &str) -> Response {
+ self.client
+ .delete(self.url(path))
+ .bearer_auth(&self.token)
+ .send()
+ .await
+ .expect("delete request")
+ }
+}
+
+/// Extract the JWT from a successful login response.
+pub async fn access_token(response: Response) -> String {
+ let identity: IdentityInfo = response.json().await.expect("decode IdentityInfo");
+ identity
+ .access_token
+ .expect("login must return an access token")
+ .token
+}
diff --git a/core/integration/tests/server/http_rbac.rs b/core/integration/tests/server/http_rbac.rs
new file mode 100644
index 000000000..e07fffb5d
--- /dev/null
+++ b/core/integration/tests/server/http_rbac.rs
@@ -0,0 +1,532 @@
+// 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.
+
+//! HTTP wire-contract residue for server-ng's shard-0 REST listener. The RBAC
+//! authorization matrix itself (who may do what, over every transport) lives in
+//! the cross-transport `permissions_scenario` suite; this file keeps only what
+//! the SDK abstracts away and only raw HTTP can show:
+//!
+//! - exact HTTP status codes paired with the typed `{id, code}` JSON error body
+//! (the SDK collapses every non-401/403/404 status into a generic error);
+//! - the SDK-hidden success codes (produce 201, reads 200, mutations 204, and
+//! the server-assigned id in a 200 create-stream body);
+//! - HTTP-only route shapes: the `/users/me` self alias, the caller-scoped
+//! name-sorted PAT-list array, auth-only cluster metadata, and the public ping;
+//! - the change-password status/body shape: a wrong current password renders a
+//! typed 400 `InvalidCredentials` and a missing target a 404 `ResourceNotFound`,
+//! both bodies the SDK hides. Change-password commits over every transport, so
+//! its authz is covered in `permissions_scenario`; only the wire shape is here.
+//!
+//! Each test owns its own server (one spawn per `#[iggy_harness]` fn).
+
+use crate::server::http_client::HttpClient;
+use iggy::prelude::*;
+use iggy_common::IggyMessagesBatch;
+use iggy_common::create_stream::CreateStream;
+use iggy_common::create_topic::CreateTopic;
+use integration::iggy_harness;
+use reqwest::{Response, StatusCode};
+use serde_json::{Value, json};
+
+// server-ng partition ids are 0-based (CreateTopic assigns them from 0).
+const PARTITION_ID: u32 = 0;
+// Explicit consumer id in the poll query (`Consumer::default()` is numeric 0).
+const CONSUMER_ID: u32 = 1;
+
+// `IggyError` discriminants (== the numeric `id` in the JSON error body).
+const UNAUTHORIZED_ID: u64 = 41;
+const INVALID_CREDENTIALS_ID: u64 = 42;
+const CANNOT_DELETE_USER_ID: u64 = 48;
+const CANNOT_CHANGE_PERMISSIONS_ID: u64 = 49;
+// The apply's `ChangePasswordResult::UserNotFound` maps to `ResourceNotFound`.
+const RESOURCE_NOT_FOUND_ID: u64 = 20;
+// Snake-case `IggyError` names (== the `code` string in the JSON error body).
+const UNAUTHORIZED_CODE: &str = "unauthorized";
+const INVALID_CREDENTIALS_CODE: &str = "invalid_credentials";
+const CANNOT_DELETE_USER_CODE: &str = "cannot_delete_user";
+const CANNOT_CHANGE_PERMISSIONS_CODE: &str = "cannot_change_permissions";
+const RESOURCE_NOT_FOUND_CODE: &str = "resource_not_found";
+
+/// Root is the first user (slab id 0) and is undeletable / permission-locked.
+const ROOT_USER_PATH: &str = "0";
+
+/// Wire-contract request shapes layered on the shared [`HttpClient`]: this suite
+/// pins raw HTTP status codes and typed `{id, code}` error bodies, so its
+/// helpers return the raw [`Response`] for the caller to assert on.
+trait ClientExt {
+ async fn create_stream(&self, name: &str) -> u64;
+ async fn create_topic(&self, stream: &str, topic: &str, partitions: u32);
+ async fn create_user(&self, username: &str, password: &str, permissions: Value) -> Response;
+ async fn change_password(&self, user_id: &str, current: &str, new: &str) -> Response;
+ async fn login_attempt(&self, username: &str, password: &str) -> Response;
+ async fn produce(
+ &self,
+ stream: &str,
+ topic: &str,
+ partition_id: u32,
+ messages: Vec<IggyMessage>,
+ ) -> Response;
+ async fn poll(&self, stream: &str, topic: &str, partition_id: u32) -> Response;
+}
+
+impl ClientExt for HttpClient {
+ /// Create a stream and return its server-assigned numeric id (proves the
+ /// SDK-hidden `id` field of a 200 create-stream body).
+ async fn create_stream(&self, name: &str) -> u64 {
+ let response = self
+ .post_json(
+ "/streams",
+ &serde_json::to_value(CreateStream { name: name.into() }).unwrap(),
+ )
+ .await;
+ assert!(
+ response.status().is_success(),
+ "root create stream {name} failed: {}",
+ response.status()
+ );
+ let details: Value = response.json().await.expect("stream details json");
+ details["id"]
+ .as_u64()
+ .expect("stream id in create response")
+ }
+
+ async fn create_topic(&self, stream: &str, topic: &str, partitions: u32) {
+ let command = CreateTopic {
+ stream_id: Identifier::default(),
+ partitions_count: partitions,
+ compression_algorithm: CompressionAlgorithm::None,
+ message_expiry: IggyExpiry::NeverExpire,
+ max_topic_size: MaxTopicSize::ServerDefault,
+ replication_factor: None,
+ name: topic.to_string(),
+ };
+ let response = self
+ .post_json(
+ &format!("/streams/{stream}/topics"),
+ &serde_json::to_value(command).unwrap(),
+ )
+ .await;
+ assert!(
+ response.status().is_success(),
+ "root create topic {topic} failed: {}",
+ response.status()
+ );
+ }
+
+ /// Create a user with the given permissions (`Value::Null` = no grants).
+ async fn create_user(&self, username: &str, password: &str, permissions: Value) -> Response {
+ self.post_json(
+ "/users",
+ &json!({
+ "username": username,
+ "password": password,
+ "status": "active",
+ "permissions": permissions,
+ }),
+ )
+ .await
+ }
+
+ /// Change a user's password via `PUT /users/{user_id}/password`.
+ async fn change_password(&self, user_id: &str, current: &str, new: &str) -> Response {
+ self.put_json(
+ &format!("/users/{user_id}/password"),
+ &json!({ "current_password": current, "new_password": new }),
+ )
+ .await
+ }
+
+ /// Anonymous login attempt returning the raw response, for exercising a
+ /// rejected login (the asserting [`HttpClient::login`] cannot express failure).
+ async fn login_attempt(&self, username: &str, password: &str) -> Response {
+ self.client
+ .post(self.url("/users/login"))
+ .json(&json!({ "username": username, "password": password }))
+ .send()
+ .await
+ .expect("login request")
+ }
+
+ async fn produce(
+ &self,
+ stream: &str,
+ topic: &str,
+ partition_id: u32,
+ messages: Vec<IggyMessage>,
+ ) -> Response {
+ let body = SendMessages {
+ metadata_length: 0,
+ stream_id: Identifier::default(),
+ topic_id: Identifier::default(),
+ partitioning: Partitioning::partition_id(partition_id),
+ batch: IggyMessagesBatch::from(&messages),
+ };
+ self.client
+ .post(self.url(&format!("/streams/{stream}/topics/{topic}/messages")))
+ .bearer_auth(&self.token)
+ .json(&body)
+ .send()
+ .await
+ .expect("produce request")
+ }
+
+ async fn poll(&self, stream: &str, topic: &str, partition_id: u32) -> Response {
+ let path = format!(
+ "/streams/{stream}/topics/{topic}/messages\
+ ?consumer_id={CONSUMER_ID}&partition_id={partition_id}\
+ &kind=offset&value=0&count=10&auto_commit=false"
+ );
+ self.get(&path).await
+ }
+}
+
+fn text_message(id: u128, payload: &str) -> IggyMessage {
+ IggyMessage::builder()
+ .id(id)
+ .payload(payload.to_string().into())
+ .build()
+ .expect("message build")
+}
+
+/// No permissions at all: the permissioner holds no entry, so every rule denies.
+fn no_permissions() -> Value {
+ Value::Null
+}
+
+/// Assert a JSON error body carries the expected typed `id` + `code`. Proves the
+/// error crossed the wire as a real `IggyError` code, not a bare status.
+async fn assert_error_body(response: Response, expected_id: u64, expected_code: &str, ctx: &str) {
+ let body: Value = response.json().await.expect("error body json");
+ assert_eq!(
+ body["id"].as_u64(),
+ Some(expected_id),
+ "{ctx}: error id (body={body})"
+ );
+ assert_eq!(
+ body["code"].as_str(),
+ Some(expected_code),
+ "{ctx}: error code (body={body})"
+ );
+}
+
+/// Decode a PAT-list response body into its token names, asserting 200 first.
+async fn pat_names(response: Response) -> Vec<String> {
+ assert_eq!(
+ response.status(),
+ StatusCode::OK,
+ "PAT list must be 200 for an authenticated caller"
+ );
+ let body: Value = response.json().await.expect("PAT list body must be JSON");
+ body.as_array()
+ .expect("PAT list must be a JSON array")
+ .iter()
+ .map(|token| {
+ token["name"]
+ .as_str()
+ .expect("every token must carry a name")
+ .to_owned()
+ })
+ .collect()
+}
+
+// === Denials carry the exact HTTP status + typed JSON error body ============
+
+/// The SDK reconstructs only 401/403/404 as typed errors and collapses every
+/// other status; raw HTTP is the only place the paired status + `{id, code}`
+/// body is observable. Covers both denial gates and both root-protection rules.
+#[iggy_harness]
+async fn given_http_requests_when_denied_should_carry_typed_status_and_body(harness: &TestHarness) {
+ let root = HttpClient::login_root(harness).await;
+ // A populated stream so the read gate denies over a real listing surface.
+ root.create_stream("rbac-s1").await;
+ root.create_user("alice", "alice-pass", no_permissions())
+ .await;
+ let alice = root.login("alice", "alice-pass").await;
+
+ // Dispatch read gate: an ungranted read is 403 with a typed Unauthorized body.
+ let read = alice.get("/streams").await;
+ assert_eq!(
+ read.status(),
+ StatusCode::FORBIDDEN,
+ "alice GET /streams must be 403"
+ );
+ assert_error_body(
+ read,
+ UNAUTHORIZED_ID,
+ UNAUTHORIZED_CODE,
+ "alice GET /streams",
+ )
+ .await;
+
+ // In-apply control gate: the denial rides the metadata result section back as
+ // a typed Unauthorized body, still under a 403.
+ let create = alice
+ .post_json("/streams", &json!({ "name": "alice-stream" }))
+ .await;
+ assert_eq!(
+ create.status(),
+ StatusCode::FORBIDDEN,
+ "alice POST /streams must be 403"
+ );
+ assert_error_body(
+ create,
+ UNAUTHORIZED_ID,
+ UNAUTHORIZED_CODE,
+ "alice POST /streams",
+ )
+ .await;
+
+ // Business-rule rejections fire in the STM apply AFTER RBAC admits root
+ // (both guards are target-based on slab id 0). The typed code rides the body
+ // and the `IggyError` map yields 400, not 403.
+ let delete_root = root.delete(&format!("/users/{ROOT_USER_PATH}")).await;
+ assert_eq!(
+ delete_root.status(),
+ StatusCode::BAD_REQUEST,
+ "delete root status"
+ );
+ assert_error_body(
+ delete_root,
+ CANNOT_DELETE_USER_ID,
+ CANNOT_DELETE_USER_CODE,
+ "delete root",
+ )
+ .await;
+
+ let change_root = root
+ .put_json(
+ &format!("/users/{ROOT_USER_PATH}/permissions"),
+ &json!({ "permissions": no_permissions() }),
+ )
+ .await;
+ assert_eq!(
+ change_root.status(),
+ StatusCode::BAD_REQUEST,
+ "re-permission root status"
+ );
+ assert_error_body(
+ change_root,
+ CANNOT_CHANGE_PERMISSIONS_ID,
+ CANNOT_CHANGE_PERMISSIONS_CODE,
+ "re-permission root",
+ )
+ .await;
+}
+
+// === Authorized requests carry the SDK-hidden success status codes ==========
+
+/// Root exercises the happy path; the SDK hides these exact status codes behind
+/// `Ok(...)`, so only raw HTTP can pin produce 201 / read 200 / create-stream
+/// 200 + the id body.
+#[iggy_harness]
+async fn given_http_requests_when_authorized_should_carry_sdk_hidden_status_codes(
+ harness: &TestHarness,
+) {
+ let root = HttpClient::login_root(harness).await;
+ let stream_id = root.create_stream("root-canary").await;
+ assert!(stream_id < u64::MAX, "root create stream must return an id");
+ root.create_topic("root-canary", "canary-topic", 1).await;
+
+ assert_eq!(
+ root.produce(
+ "root-canary",
+ "canary-topic",
+ PARTITION_ID,
+ vec![text_message(1, "root-msg")],
+ )
+ .await
+ .status(),
+ StatusCode::CREATED,
+ "root produce must be 201"
+ );
+ assert_eq!(
+ root.poll("root-canary", "canary-topic", PARTITION_ID)
+ .await
+ .status(),
+ StatusCode::OK,
+ "root poll must be 200"
+ );
+ assert_eq!(
+ root.get("/stats").await.status(),
+ StatusCode::OK,
+ "root GET /stats must be 200"
+ );
+}
+
+// === HTTP-only route shapes the SDK does not expose =========================
+
+/// Routes with no SDK equivalent: the `/users/me` self alias, auth-only cluster
+/// metadata (readable by an ungranted caller, so never RBAC-gated), the public
+/// ping, and the caller-scoped name-sorted PAT-list JSON array.
+#[iggy_harness]
+async fn given_http_only_routes_when_requested_should_return_expected_shapes(
+ harness: &TestHarness,
+) {
+ let root = HttpClient::login_root(harness).await;
+ root.create_user("alice", "alice-pass", no_permissions())
+ .await;
+ let alice = root.login("alice", "alice-pass").await;
+
+ // The `me` alias resolves to the caller's own user (self-scoped, no rule).
+ let me = alice.get("/users/me").await;
+ assert_eq!(me.status(), StatusCode::OK, "GET /users/me must be 200");
+ let me_body: Value = me.json().await.expect("users/me body must be JSON");
+ assert_eq!(
+ me_body["username"], "alice",
+ "the me alias must resolve to the caller's own user"
+ );
+
+ // Cluster metadata is auth-only: an ungranted caller still reads it, proving
+ // the route is never RBAC-gated.
+ assert_eq!(
+ alice.get("/cluster/metadata").await.status(),
+ StatusCode::OK,
+ "cluster metadata is auth-only, never RBAC-gated"
+ );
+
+ // Ping is public (no bearer).
+ assert_eq!(
+ alice.get_anonymous("/ping").await.status(),
+ StatusCode::OK,
+ "ping is public"
+ );
+
+ // PAT listing is a JSON array of the caller's own tokens, name-sorted
+ // (created in reverse order to prove the sort). Create returns 200.
+ for name in ["alice-zeta", "alice-alpha"] {
+ assert_eq!(
+ alice
+ .post_json(
+ "/personal-access-tokens",
+ &json!({ "name": name, "expiry": 0 }),
+ )
+ .await
+ .status(),
+ StatusCode::OK,
+ "alice create PAT {name} must be 200"
+ );
+ }
+ assert_eq!(
+ pat_names(alice.get("/personal-access-tokens").await).await,
+ ["alice-alpha", "alice-zeta"],
+ "PAT list must be a name-sorted JSON array of the caller's tokens"
+ );
+}
+
+// === Change-password wire contract (status/body shape) ==========================
+
+/// The current-password check gates a self-service rotation: a wrong current
+/// password is verified against the stored hash on shard 0, commits as a
+/// rejecting no-op, and surfaces as 400 `InvalidCredentials` from the
+/// replicated apply; only the correct one rotates the credential so the old
+/// password stops working and the new one starts.
+#[iggy_harness]
+async fn given_own_password_when_current_wrong_should_reject_and_correct_should_rotate(
+ harness: &TestHarness,
+) {
+ let root = HttpClient::login_root(harness).await;
+ root.create_user("carol", "carol-pass-1", no_permissions())
+ .await;
+ let carol = root.login("carol", "carol-pass-1").await;
+
+ let wrong = carol
+ .change_password("carol", "not-my-password", "carol-pass-2")
+ .await;
+ assert_eq!(
+ wrong.status(),
+ StatusCode::BAD_REQUEST,
+ "wrong current password must be 400"
+ );
+ assert_error_body(
+ wrong,
+ INVALID_CREDENTIALS_ID,
+ INVALID_CREDENTIALS_CODE,
+ "carol change-password wrong current",
+ )
+ .await;
+
+ let correct = carol
+ .change_password("carol", "carol-pass-1", "carol-pass-2")
+ .await;
+ assert_eq!(
+ correct.status(),
+ StatusCode::NO_CONTENT,
+ "correct current password must be 204"
+ );
+
+ assert!(
+ !carol
+ .login_attempt("carol", "carol-pass-1")
+ .await
+ .status()
+ .is_success(),
+ "old password must stop working after a successful rotation"
+ );
+ assert!(
+ carol
+ .login_attempt("carol", "carol-pass-2")
+ .await
+ .status()
+ .is_success(),
+ "new password must work after a successful rotation"
+ );
+}
+
+/// Admin-initiated change (target != caller): the current password is checked
+/// against the TARGET's stored hash, so a wrong one is 400 `InvalidCredentials`;
+/// a target that does not resolve is left to the replicated apply, which
+/// answers 404 `UserNotFound` (the plaintext is still stripped + hashed first).
+#[iggy_harness]
+async fn given_admin_change_password_when_target_current_wrong_or_missing_should_reject(
+ harness: &TestHarness,
+) {
+ let root = HttpClient::login_root(harness).await;
+ root.create_user("dave", "dave-pass", no_permissions())
+ .await;
+
+ let wrong = root
+ .change_password("dave", "not-daves-password", "new-dave-pass")
+ .await;
+ assert_eq!(
+ wrong.status(),
+ StatusCode::BAD_REQUEST,
+ "admin change with the wrong target current password must be 400"
+ );
+ assert_error_body(
+ wrong,
+ INVALID_CREDENTIALS_ID,
+ INVALID_CREDENTIALS_CODE,
+ "admin change dave wrong current",
+ )
+ .await;
+
+ let missing = root
+ .change_password("ghost-does-not-exist", "whatever", "whatever-else")
+ .await;
+ assert_eq!(
+ missing.status(),
+ StatusCode::NOT_FOUND,
+ "change for an unknown target must be 404 from the apply"
+ );
+ assert_error_body(
+ missing,
+ RESOURCE_NOT_FOUND_ID,
+ RESOURCE_NOT_FOUND_CODE,
+ "admin change missing target",
+ )
+ .await;
+}
diff --git a/core/integration/tests/server/http_vsr.rs b/core/integration/tests/server/http_vsr.rs
new file mode 100644
index 000000000..2018d4ae7
--- /dev/null
+++ b/core/integration/tests/server/http_vsr.rs
@@ -0,0 +1,843 @@
+// 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.
+
+//! HTTP data-plane gate for server-ng: produce, poll, and consumer-offset
+//! routes exercised over raw `reqwest` (not the SDK HTTP client) so the wire
+//! contract itself is under test - exact status codes, the
+//! `x-iggy-durability` header, the body-size cap, and cross-request isolation
+//! of concurrent produces on one login session.
+
+use crate::server::http_client::HttpClient;
+use futures::future::join_all;
+use iggy::prelude::*;
+use iggy_common::create_stream::CreateStream;
+use iggy_common::create_topic::CreateTopic;
+use iggy_common::store_consumer_offset::StoreConsumerOffset;
+use iggy_common::{ConsumerOffsetInfo, IggyMessagesBatch};
+use integration::iggy_harness;
+use reqwest::{Response, StatusCode};
+use serde_json::json;
+use std::collections::{BTreeMap, HashMap, HashSet};
+use std::str::FromStr;
+use std::time::{Duration, Instant};
+use tokio::time::sleep;
+
+// server-ng partition ids are 0-based (CreateTopic assigns them from 0).
+const PARTITION_ID: u32 = 0;
+
+/// Explicit consumer id shared by the offset store body and the read/delete
+/// query, so both sides address the same offset-table key
+/// (`Consumer::default()` would carry numeric id 0, not 1).
+const CONSUMER_ID: u32 = 1;
+
+const DURABILITY_HEADER: &str = "x-iggy-durability";
+const DURABILITY_REPLICATED_MEMORY: &str = "replicated-memory";
+const DURABILITY_NONE: &str = "none";
+
+/// `?ack=none` answers before the commit; poll until visible, never unbounded.
+const ASYNC_COMMIT_TIMEOUT: Duration = Duration::from_secs(15);
+const ASYNC_COMMIT_RETRY_INTERVAL: Duration = Duration::from_millis(100);
+
+/// Data-plane request shapes layered on the shared [`HttpClient`]: this suite
+/// exercises the listener's produce / poll / consumer-offset behavior, so its
+/// helpers decode `PolledMessages` and drive multi-partition produces.
+trait HttpSessionExt {
+ async fn create_stream_and_topic(&self, stream: &str, topic: &str, partitions: u32);
+ async fn produce(
+ &self,
+ stream: &str,
+ topic: &str,
+ partition_id: u32,
+ messages: Vec<IggyMessage>,
+ ) -> Response;
+ async fn produce_with_query(
+ &self,
+ stream: &str,
+ topic: &str,
+ partition_id: u32,
+ messages: Vec<IggyMessage>,
+ query: &str,
+ ) -> Response;
+ async fn poll(
+ &self,
+ stream: &str,
+ topic: &str,
+ partition_id: u32,
+ offset: u64,
+ count: u32,
+ ) -> PolledMessages;
+ async fn create_user(&self, username: &str, password: &str);
+}
+
+impl HttpSessionExt for HttpClient {
+ async fn create_stream_and_topic(&self, stream: &str, topic: &str, partitions: u32) {
+ let response = self
+ .client
+ .post(self.url("/streams"))
+ .bearer_auth(&self.token)
+ .json(&CreateStream {
+ name: stream.to_string(),
+ })
+ .send()
+ .await
+ .expect("create stream request");
+ assert!(
+ response.status().is_success(),
+ "create stream failed: {}",
+ response.status()
+ );
+
+ let command = CreateTopic {
+ stream_id: Identifier::default(),
+ partitions_count: partitions,
+ compression_algorithm: CompressionAlgorithm::None,
+ message_expiry: IggyExpiry::NeverExpire,
+ max_topic_size: MaxTopicSize::ServerDefault,
+ replication_factor: None,
+ name: topic.to_string(),
+ };
+ let response = self
+ .client
+ .post(self.url(&format!("/streams/{stream}/topics")))
+ .bearer_auth(&self.token)
+ .json(&command)
+ .send()
+ .await
+ .expect("create topic request");
+ assert!(
+ response.status().is_success(),
+ "create topic failed: {}",
+ response.status()
+ );
+ }
+
+ async fn produce(
+ &self,
+ stream: &str,
+ topic: &str,
+ partition_id: u32,
+ messages: Vec<IggyMessage>,
+ ) -> Response {
+ self.produce_with_query(stream, topic, partition_id, messages, "")
+ .await
+ }
+
+ async fn produce_with_query(
+ &self,
+ stream: &str,
+ topic: &str,
+ partition_id: u32,
+ messages: Vec<IggyMessage>,
+ query: &str,
+ ) -> Response {
+ let body = SendMessages {
+ metadata_length: 0,
+ stream_id: Identifier::default(),
+ topic_id: Identifier::default(),
+ partitioning: Partitioning::partition_id(partition_id),
+ batch: IggyMessagesBatch::from(&messages),
+ };
+ self.client
+ .post(self.url(&format!("/streams/{stream}/topics/{topic}/messages{query}")))
+ .bearer_auth(&self.token)
+ .json(&body)
+ .send()
+ .await
+ .expect("produce request")
+ }
+
+ async fn poll(
+ &self,
+ stream: &str,
+ topic: &str,
+ partition_id: u32,
+ offset: u64,
+ count: u32,
+ ) -> PolledMessages {
+ let path = format!(
+ "/streams/{stream}/topics/{topic}/messages\
+ ?consumer_id={CONSUMER_ID}&partition_id={partition_id}\
+ &kind=offset&value={offset}&count={count}&auto_commit=false"
+ );
+ let response = self
+ .client
+ .get(self.url(&path))
+ .bearer_auth(&self.token)
+ .send()
+ .await
+ .expect("poll request");
+ assert_eq!(response.status(), StatusCode::OK, "poll must answer 200");
+ response.json().await.expect("decode PolledMessages")
+ }
+
+ /// Create an ungranted user (no permissions -> the permissioner holds no
+ /// entry, so every RBAC rule denies). Runs as this session's user.
+ async fn create_user(&self, username: &str, password: &str) {
+ let body = serde_json::json!({
+ "username": username,
+ "password": password,
+ "status": "active",
+ "permissions": null,
+ });
+ let response = self
+ .client
+ .post(self.url("/users"))
+ .bearer_auth(&self.token)
+ .json(&body)
+ .send()
+ .await
+ .expect("create user request");
+ assert!(
+ response.status().is_success(),
+ "create user failed: {}",
+ response.status()
+ );
+ }
+}
+
+fn durability(response: &Response) -> &str {
+ response
+ .headers()
+ .get(DURABILITY_HEADER)
+ .expect("produce response must carry the durability header")
+ .to_str()
+ .expect("durability header is ASCII")
+}
+
+fn text_message(id: u128, payload: String) -> IggyMessage {
+ IggyMessage::builder()
+ .id(id)
+ .payload(payload.into())
+ .build()
+ .expect("message build")
+}
+
+#[iggy_harness]
+async fn given_http_session_when_producing_and_polling_should_round_trip(harness: &TestHarness) {
+ let http = HttpClient::login_root(harness).await;
+ http.create_stream_and_topic("http-stream", "http-topic", 1)
+ .await;
+
+ let header_key = HeaderKey::try_from("trace-id").expect("header key");
+ let header_value = HeaderValue::from_str("trace-42").expect("header value");
+ let messages: Vec<IggyMessage> = (0..3u32)
+ .map(|i| {
+ let builder = IggyMessage::builder()
+ .id(u128::from(i + 1))
+ .payload(format!("round-trip-{i}").into());
+ if i == 0 {
+ let user_headers = BTreeMap::from([(header_key.clone(), header_value.clone())]);
+ builder
+ .user_headers(user_headers)
+ .build()
+ .expect("message build")
+ } else {
+ builder.build().expect("message build")
+ }
+ })
+ .collect();
+
+ let response = http
+ .produce("http-stream", "http-topic", PARTITION_ID, messages)
+ .await;
+ assert_eq!(
+ response.status(),
+ StatusCode::CREATED,
+ "produce must commit"
+ );
+ assert_eq!(durability(&response), DURABILITY_REPLICATED_MEMORY);
+
+ let polled = http
+ .poll("http-stream", "http-topic", PARTITION_ID, 0, 10)
+ .await;
+ assert_eq!(polled.messages.len(), 3, "all sent messages must come back");
+ for (i, message) in polled.messages.iter().enumerate() {
+ assert_eq!(message.header.offset, i as u64, "offsets must be dense");
+ assert_eq!(
+ message.payload,
+ bytes::Bytes::from(format!("round-trip-{i}")),
+ "payload round trip for message {i}"
+ );
+ }
+ let user_headers = polled.messages[0]
+ .user_headers_map()
+ .expect("headers decode")
+ .expect("first message carries user headers");
+ assert_eq!(
+ user_headers.get(&header_key),
+ Some(&header_value),
+ "user header must survive the round trip"
+ );
+}
+
+const CONCURRENT_PRODUCES: usize = 16;
+const CONCURRENT_PARTITIONS: u32 = 4;
+
+#[iggy_harness]
+async fn given_one_session_when_producing_concurrently_should_not_cross_talk(
+ harness: &TestHarness,
+) {
+ let http = HttpClient::login_root(harness).await;
+ http.create_stream_and_topic("http-concurrent", "hammer", CONCURRENT_PARTITIONS)
+ .await;
+
+ let produces = (0..CONCURRENT_PRODUCES).map(|i| {
+ let http = &http;
+ let partition_id = (i as u32) % CONCURRENT_PARTITIONS;
+ async move {
+ let message = text_message(i as u128 + 1, format!("concurrent-{i}"));
+ let response = http
+ .produce("http-concurrent", "hammer", partition_id, vec![message])
+ .await;
+ (i, response.status(), durability(&response).to_string())
+ }
+ });
+ let outcomes = join_all(produces).await;
+
+ for (i, status, durability) in &outcomes {
+ assert_eq!(
+ *status,
+ StatusCode::CREATED,
+ "concurrent produce {i} must commit"
+ );
+ assert_eq!(
+ durability, DURABILITY_REPLICATED_MEMORY,
+ "concurrent produce {i} must attest a replicated commit"
+ );
+ }
+
+ let mut produced: HashMap<u32, HashSet<String>> = HashMap::new();
+ for i in 0..CONCURRENT_PRODUCES {
+ produced
+ .entry((i as u32) % CONCURRENT_PARTITIONS)
+ .or_default()
+ .insert(format!("concurrent-{i}"));
+ }
+
+ // Every partition must return exactly its own produced payloads: nothing
+ // lost, nothing foreign. The produce path is at-least-once, so duplicates
+ // are tolerated but must be byte-identical to a produced payload.
+ let mut polled_union: HashSet<String> = HashSet::new();
+ for partition_id in 0..CONCURRENT_PARTITIONS {
+ let polled = http
+ .poll("http-concurrent", "hammer", partition_id, 0, 100)
+ .await;
+ let expected = &produced[&partition_id];
+ let mut seen: HashSet<String> = HashSet::new();
+ for message in &polled.messages {
+ let payload = String::from_utf8(message.payload.to_vec()).expect("payload is UTF-8");
+ assert!(
+ expected.contains(&payload),
+ "partition {partition_id} returned foreign payload {payload:?}"
+ );
+ seen.insert(payload);
+ }
+ assert_eq!(
+ &seen, expected,
+ "partition {partition_id} must return exactly its own produced payloads"
+ );
+ polled_union.extend(seen);
+ }
+ assert_eq!(
+ polled_union.len(),
+ CONCURRENT_PRODUCES,
+ "every produced payload must be polled back"
+ );
+}
+
+#[iggy_harness]
+async fn given_consumer_offset_when_stored_and_deleted_should_round_trip(harness: &TestHarness) {
+ let http = HttpClient::login_root(harness).await;
+ http.create_stream_and_topic("http-offsets", "offsets", 1)
+ .await;
+
+ // Two committed messages so offset 1 names a real position.
+ let messages = (0..2u32)
+ .map(|i| text_message(u128::from(i + 1), format!("offset-fodder-{i}")))
+ .collect();
+ let response = http
+ .produce("http-offsets", "offsets", PARTITION_ID, messages)
+ .await;
+ assert_eq!(
+ response.status(),
+ StatusCode::CREATED,
+ "produce must commit"
+ );
+
+ let offsets_path = "/streams/http-offsets/topics/offsets/consumer-offsets";
+ let store = StoreConsumerOffset {
+ consumer: Consumer::new(Identifier::numeric(CONSUMER_ID).expect("consumer id")),
+ partition_id: Some(PARTITION_ID),
+ offset: 1,
+ };
+ let response = http
+ .client
+ .put(http.url(offsets_path))
+ .bearer_auth(&http.token)
+ .json(&store)
+ .send()
+ .await
+ .expect("store offset request");
+ assert_eq!(
+ response.status(),
+ StatusCode::NO_CONTENT,
+ "store must commit"
+ );
+
+ let get_path = format!("{offsets_path}?consumer_id={CONSUMER_ID}&partition_id={PARTITION_ID}");
+ let response = http
+ .client
+ .get(http.url(&get_path))
+ .bearer_auth(&http.token)
+ .send()
+ .await
+ .expect("get offset request");
+ assert_eq!(
+ response.status(),
+ StatusCode::OK,
+ "stored offset must be readable"
+ );
+ let info: ConsumerOffsetInfo = response.json().await.expect("decode ConsumerOffsetInfo");
+ assert_eq!(info.stored_offset, 1, "stored offset must round trip");
+ assert_eq!(info.partition_id, PARTITION_ID);
+
+ let delete_path = format!("{offsets_path}/{CONSUMER_ID}?partition_id={PARTITION_ID}");
+ let response = http
+ .client
+ .delete(http.url(&delete_path))
+ .bearer_auth(&http.token)
+ .send()
+ .await
+ .expect("delete offset request");
+ assert_eq!(
+ response.status(),
+ StatusCode::NO_CONTENT,
+ "delete must commit"
+ );
+
+ let response = http
+ .client
+ .get(http.url(&get_path))
+ .bearer_auth(&http.token)
+ .send()
+ .await
+ .expect("get offset request after delete");
+ assert_eq!(
+ response.status(),
+ StatusCode::NOT_FOUND,
+ "deleted offset must be gone"
+ );
+}
+
+/// A DELETE of a never-stored consumer offset is denied by the partition
+/// primary before consensus (`ReplyHeader.status`), so the route must answer
+/// a typed 404 immediately - not wait out the 10s partition-write reply
+/// timeout into a 504 - and the deny gate must not break a store-then-delete
+/// of an offset that does exist.
+#[iggy_harness]
+async fn given_missing_consumer_offset_when_deleting_should_reject_404_fast(harness: &TestHarness) {
+ /// Well under the partition-write reply timeout: a deny is a single
+ /// round trip, a silent drop only answers at the full 10s.
+ const DENY_LATENCY_BUDGET: Duration = Duration::from_secs(5);
+
+ let http = HttpClient::login_root(harness).await;
+ http.create_stream_and_topic("http-missing-offset", "offsets", 1)
+ .await;
+
+ let offsets_path = "/streams/http-missing-offset/topics/offsets/consumer-offsets";
+ let delete_path = format!("{offsets_path}/{CONSUMER_ID}?partition_id={PARTITION_ID}");
+ let started = Instant::now();
+ let response = http
+ .client
+ .delete(http.url(&delete_path))
+ .bearer_auth(&http.token)
+ .send()
+ .await
+ .expect("delete missing offset request");
+ let elapsed = started.elapsed();
+ assert_eq!(
+ response.status(),
+ StatusCode::NOT_FOUND,
+ "missing offset delete must reject 404"
+ );
+ assert!(
+ elapsed < DENY_LATENCY_BUDGET,
+ "deny must answer fast, not ride the reply timeout (took {elapsed:?})"
+ );
+ // The typed deny body, not the generic pre-dispatch not-found shape.
+ let body: serde_json::Value = response.json().await.expect("decode error body");
+ assert_eq!(
+ body["code"], "consumer_offset_not_found",
+ "deny must carry the typed error code, got {body}"
+ );
+
+ // The deny gate must not reject offsets that exist: store, then delete.
+ let messages = vec![text_message(1, "offset-fodder".to_string())];
+ let response = http
+ .produce("http-missing-offset", "offsets", PARTITION_ID, messages)
+ .await;
+ assert_eq!(
+ response.status(),
+ StatusCode::CREATED,
+ "produce must commit"
+ );
+ let store = StoreConsumerOffset {
+ consumer: Consumer::new(Identifier::numeric(CONSUMER_ID).expect("consumer id")),
+ partition_id: Some(PARTITION_ID),
+ offset: 0,
+ };
+ let response = http
+ .client
+ .put(http.url(offsets_path))
+ .bearer_auth(&http.token)
+ .json(&store)
+ .send()
+ .await
+ .expect("store offset request");
+ assert_eq!(
+ response.status(),
+ StatusCode::NO_CONTENT,
+ "store must commit"
+ );
+ let response = http
+ .client
+ .delete(http.url(&delete_path))
+ .bearer_auth(&http.token)
+ .send()
+ .await
+ .expect("delete stored offset request");
+ assert_eq!(
+ response.status(),
+ StatusCode::NO_CONTENT,
+ "existing offset delete must commit"
+ );
+}
+
+/// A store of an out-of-range consumer offset is denied by the partition
+/// primary at admission and must answer a typed 400, NOT a silent 204.
+/// Regression guard: the deny once rode the result-body result section with
+/// `status = 0`; the status-only `classify_partition_reply` read that as a
+/// committed success on a non-empty partition (`op = commit_max >= 1`) and
+/// returned 204 for a rejected store, dropping the offset silently. The two
+/// committed messages below make the partition non-empty so that `op != 0`
+/// path is exercised - an empty partition would only surface a different 404.
+#[iggy_harness]
+async fn given_out_of_range_consumer_offset_when_storing_should_reject_400_fast(
+ harness: &TestHarness,
+) {
+ /// Well under the partition-write reply timeout: an admission deny is a
+ /// single round trip, never the full reply timeout.
+ const DENY_LATENCY_BUDGET: Duration = Duration::from_secs(5);
+
+ let http = HttpClient::login_root(harness).await;
+ http.create_stream_and_topic("http-store-range", "offsets", 1)
+ .await;
+
+ // Two committed messages so the partition is non-empty (current_offset == 1,
+ // commit_max >= 1); the silent-204 path only appears once op != 0.
+ let messages = (0..2u32)
+ .map(|i| text_message(u128::from(i + 1), format!("offset-fodder-{i}")))
+ .collect();
+ let response = http
+ .produce("http-store-range", "offsets", PARTITION_ID, messages)
+ .await;
+ assert_eq!(
+ response.status(),
+ StatusCode::CREATED,
+ "produce must commit"
+ );
+
+ // Offset 5 runs past the committed tail (offset 1), so it is out of range.
+ let offsets_path = "/streams/http-store-range/topics/offsets/consumer-offsets";
+ let store = StoreConsumerOffset {
+ consumer: Consumer::new(Identifier::numeric(CONSUMER_ID).expect("consumer id")),
+ partition_id: Some(PARTITION_ID),
+ offset: 5,
+ };
+ let started = Instant::now();
+ let response = http
+ .client
+ .put(http.url(offsets_path))
+ .bearer_auth(&http.token)
+ .json(&store)
+ .send()
+ .await
+ .expect("store out-of-range offset request");
+ let elapsed = started.elapsed();
+ assert_eq!(
+ response.status(),
+ StatusCode::BAD_REQUEST,
+ "out-of-range store must reject 400, not silently accept as 204"
+ );
+ assert!(
+ elapsed < DENY_LATENCY_BUDGET,
+ "deny must answer fast, not ride the reply timeout (took {elapsed:?})"
+ );
+ // The typed deny body carried in `ReplyHeader.status`, not a swallowed code.
+ let body: serde_json::Value = response.json().await.expect("decode error body");
+ assert_eq!(
+ body["code"], "invalid_offset",
+ "deny must carry the typed error code, got {body}"
+ );
+}
+
+#[iggy_harness]
+async fn given_oversized_body_when_producing_should_reject_413(harness: &TestHarness) {
+ let http = HttpClient::login_root(harness).await;
+ http.create_stream_and_topic("http-oversized", "big", 1)
+ .await;
+
+ // 3 MiB payload -> ~4 MiB JSON after base64: over the 2 MB body cap under
+ // either decimal or binary megabyte semantics.
+ let oversized = IggyMessage::builder()
+ .payload(vec![0x42u8; 3 * 1024 * 1024].into())
+ .build()
+ .expect("message build");
+ let response = http
+ .produce("http-oversized", "big", PARTITION_ID, vec![oversized])
+ .await;
+ assert_eq!(
+ response.status(),
+ StatusCode::PAYLOAD_TOO_LARGE,
+ "oversized body must be rejected"
+ );
+
+ // The rejection must not poison the listener: a normal produce still commits.
+ let normal = text_message(1, "small-after-large".to_string());
+ let response = http
+ .produce("http-oversized", "big", PARTITION_ID, vec![normal])
+ .await;
+ assert_eq!(
+ response.status(),
+ StatusCode::CREATED,
+ "server must stay healthy after a 413"
+ );
+ let polled = http
+ .poll("http-oversized", "big", PARTITION_ID, 0, 10)
+ .await;
+ assert_eq!(polled.messages.len(), 1, "normal produce must be pollable");
+ assert_eq!(
+ polled.messages[0].payload,
+ bytes::Bytes::from("small-after-large")
+ );
+}
+
+#[iggy_harness]
+async fn given_ack_none_when_producing_should_return_202_and_commit(harness: &TestHarness) {
+ let http = HttpClient::login_root(harness).await;
+ http.create_stream_and_topic("http-ack-none", "fire", 1)
+ .await;
+
+ let message = text_message(1, "fire-and-forget".to_string());
+ let response = http
+ .produce_with_query(
+ "http-ack-none",
+ "fire",
+ PARTITION_ID,
+ vec![message],
+ "?ack=none",
+ )
+ .await;
+ assert_eq!(
+ response.status(),
+ StatusCode::ACCEPTED,
+ "ack=none must answer before the commit"
+ );
+ assert_eq!(durability(&response), DURABILITY_NONE);
+
+ // The commit still happens, just asynchronously: poll bounded until the
+ // message becomes visible.
+ let deadline = Instant::now() + ASYNC_COMMIT_TIMEOUT;
+ loop {
+ let polled = http
+ .poll("http-ack-none", "fire", PARTITION_ID, 0, 10)
+ .await;
+ if !polled.messages.is_empty() {
+ assert_eq!(polled.messages.len(), 1, "exactly one message was produced");
+ assert_eq!(
+ polled.messages[0].payload,
+ bytes::Bytes::from("fire-and-forget"),
+ "ack=none payload must round trip"
+ );
+ break;
+ }
+ assert!(
+ Instant::now() < deadline,
+ "ack=none produce did not become pollable within {ASYNC_COMMIT_TIMEOUT:?}"
+ );
+ sleep(ASYNC_COMMIT_RETRY_INTERVAL).await;
+ }
+}
+
+/// End-to-end RBAC proof: an ungranted user is 403 on a metadata read and on a
+/// data-plane produce, root stays 200/201, and the auth-only cluster-metadata
+/// route is never gated. Exercises the HTTP per-op gates (read + partition
+/// write) and the in-apply control gate (user creation as root) in one path.
+#[iggy_harness]
+async fn given_ungranted_user_when_reading_or_producing_should_be_forbidden(harness: &TestHarness) {
+ let root = HttpClient::login_root(harness).await;
+ root.create_stream_and_topic("rbac-stream", "rbac-topic", 1)
+ .await;
+ // Root provisions a user with no permissions, then we log in as them.
+ root.create_user("rbac-nobody", "rbac-nobody-pass").await;
+ let nobody = root.login("rbac-nobody", "rbac-nobody-pass").await;
+
+ // A metadata read: 403 for the ungranted user, 200 for root.
+ assert_eq!(
+ nobody.get("/streams").await.status(),
+ StatusCode::FORBIDDEN,
+ "ungranted read must be 403"
+ );
+ assert_eq!(
+ root.get("/streams").await.status(),
+ StatusCode::OK,
+ "root read must be 200"
+ );
+
+ // A data-plane produce: 403 for the ungranted user, 201 for root.
+ let denied = nobody
+ .produce(
+ "rbac-stream",
+ "rbac-topic",
+ PARTITION_ID,
+ vec![text_message(1, "denied".to_string())],
+ )
+ .await;
+ assert_eq!(
+ denied.status(),
+ StatusCode::FORBIDDEN,
+ "ungranted produce must be 403"
+ );
+ let allowed = root
+ .produce(
+ "rbac-stream",
+ "rbac-topic",
+ PARTITION_ID,
+ vec![text_message(2, "allowed".to_string())],
+ )
+ .await;
+ assert_eq!(
+ allowed.status(),
+ StatusCode::CREATED,
+ "root produce must commit"
+ );
+
+ // The auth-only cluster-metadata route carries no rule: any authenticated
+ // user reaches it, ungranted included.
+ assert_eq!(
+ nobody.get("/cluster/metadata").await.status(),
+ StatusCode::OK,
+ "cluster metadata is auth-only, never RBAC-gated"
+ );
+}
+
+/// The unauthenticated `POST /users/refresh-token` route: the current token
+/// travels in the JSON body, sent with no bearer header.
+trait RefreshExt {
+ async fn refresh(&self, token: &str) -> Response;
+}
+
+impl RefreshExt for HttpClient {
+ async fn refresh(&self, token: &str) -> Response {
+ self.client
+ .post(self.url("/users/refresh-token"))
+ .json(&json!({ "token": token }))
+ .send()
+ .await
+ .expect("refresh-token request")
+ }
+}
+
+#[iggy_harness]
+async fn given_valid_access_token_when_refreshing_should_issue_working_token_without_revoking_old(
+ harness: &TestHarness,
+) {
+ let http = HttpClient::login_root(harness).await;
+
+ // Baseline identity carrying both a token and its expiry; `login_root` keeps
+ // only the token string, so log in once more for the full `IdentityInfo`.
+ let old: IdentityInfo = http
+ .client
+ .post(http.url("/users/login"))
+ .json(&json!({
+ "username": DEFAULT_ROOT_USERNAME,
+ "password": DEFAULT_ROOT_PASSWORD,
+ }))
+ .send()
+ .await
+ .expect("login request")
+ .json()
+ .await
+ .expect("decode IdentityInfo");
+ let old_user_id = old.user_id;
+ let old_token = old.access_token.expect("login returns a token");
+
+ let response = http.refresh(&old_token.token).await;
+ assert_eq!(response.status(), StatusCode::OK, "refresh must answer 200");
+ let refreshed: IdentityInfo = response.json().await.expect("decode IdentityInfo");
+ let new_token = refreshed.access_token.expect("refresh returns a token");
+
+ assert_eq!(
+ refreshed.user_id, old_user_id,
+ "refresh must re-issue for the same user"
+ );
+ assert_ne!(
+ new_token.token, old_token.token,
+ "a fresh jti must yield a different token"
+ );
+ assert!(
+ new_token.expiry >= old_token.expiry,
+ "the refreshed token must not expire earlier than the one it replaced"
+ );
+
+ // The fresh token authenticates: its new jti lazily Registers a session on
+ // first authed use.
+ assert_eq!(
+ http.with_token(new_token.token)
+ .get("/streams")
+ .await
+ .status(),
+ StatusCode::OK,
+ "the refreshed token must authenticate"
+ );
+
+ // Stateless by design: server-ng has no replicated revocation list (P3), so
+ // refreshing never invalidates the token it was minted from - the old token
+ // lives to its natural exp. Deliberate, not a bug; the same posture as
+ // logout, which ends a session without revoking its bearer.
+ assert_eq!(
+ http.with_token(old_token.token)
+ .get("/streams")
+ .await
+ .status(),
+ StatusCode::OK,
+ "the pre-refresh token stays valid (no revocation by design)"
+ );
+}
+
+#[iggy_harness]
+async fn given_garbage_or_empty_token_when_refreshing_should_reject_401(harness: &TestHarness) {
+ let http = HttpClient::login_root(harness).await;
+ for token in ["not-a-jwt", ""] {
+ let response = http.refresh(token).await;
+ assert_eq!(
+ response.status(),
+ StatusCode::UNAUTHORIZED,
+ "refresh with an invalid token ({token:?}) must be 401"
+ );
+ }
+}
diff --git a/core/integration/tests/server/legacy_login_vsr.rs b/core/integration/tests/server/legacy_login_vsr.rs
new file mode 100644
index 000000000..4161a4c3b
--- /dev/null
+++ b/core/integration/tests/server/legacy_login_vsr.rs
@@ -0,0 +1,108 @@
+// 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.
+
+//! Legacy login codes against server-ng (vsr). server-ng authenticates only
+//! through the Register handshake, so the pre-register `LOGIN_USER` (38) and
+//! `LOGIN_WITH_PERSONAL_ACCESS_TOKEN` (44) codes -- which the vsr SDK never
+//! emits (its typed login methods send the register codes, its raw path
+//! rejects session-control codes) -- must be rejected with a typed
+//! `MalformedLogin` eviction, instead of the misleading `NoSession` eviction
+//! the pre-auth guard would send unbound, or the silent empty-ok reply the
+//! bound non-replicated path would send.
+//! Since the SDK cannot send these codes, the frames are hand-crafted on a raw
+//! TCP socket: a header-only non-replicated frame carrying the code in the
+//! reserved command slot.
+
+#![cfg(feature = "vsr")]
+
+use iggy_binary_protocol::HEADER_SIZE;
+use iggy_binary_protocol::codes::{LOGIN_USER_CODE, LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE};
+use iggy_binary_protocol::consensus::{Command2, Operation, RequestHeader};
+use iggy_binary_protocol::namespace::METADATA_CONSENSUS_NAMESPACE;
+use integration::harness::TestHarness;
+use integration::iggy_harness;
+use std::mem::offset_of;
+use std::time::Duration;
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::TcpStream;
+use tokio::time::timeout;
+
+// Wire byte pinned to `EvictionReason::MalformedLogin` in consensus::header.
+const EVICTION_REASON_MALFORMED_LOGIN: u8 = 15;
+
+#[iggy_harness]
+async fn given_legacy_login_user_code_when_sent_raw_should_evict_malformed_login(
+ harness: &TestHarness,
+) {
+ assert_legacy_login_code_evicted(harness, LOGIN_USER_CODE).await;
+}
+
+#[iggy_harness]
+async fn given_legacy_pat_login_code_when_sent_raw_should_evict_malformed_login(
+ harness: &TestHarness,
+) {
+ assert_legacy_login_code_evicted(harness, LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE).await;
+}
+
+/// Send a header-only non-replicated frame carrying `code` in the reserved
+/// command slot and assert the server answers with a `MalformedLogin`
+/// eviction. The reject runs before the session gate, so this unbound socket
+/// exercises the same path a bound connection would.
+async fn assert_legacy_login_code_evicted(harness: &TestHarness, code: u32) {
+ let mut header = RequestHeader {
+ command: Command2::Request,
+ operation: Operation::NonReplicated,
+ size: u32::try_from(HEADER_SIZE).unwrap(),
+ // NonReplicated leaves session / request unchecked, but the header
+ // validator still requires a nonzero client id.
+ client: 0xC0FFEE,
+ session: 0,
+ request: 0,
+ namespace: METADATA_CONSENSUS_NAMESPACE,
+ ..Default::default()
+ };
+ // A non-replicated command code travels in the first 4 reserved bytes.
+ header.reserved[..4].copy_from_slice(&code.to_le_bytes());
+
+ let addr = harness
+ .server()
+ .tcp_addr()
+ .expect("server must expose a TCP address");
+ let mut stream = TcpStream::connect(addr).await.unwrap();
+ stream.write_all(bytemuck::bytes_of(&header)).await.unwrap();
+
+ // Eviction is header-only: exactly 256 bytes. The timeout makes the
+ // fail-fast contract explicit -- a regression that silently drops the
+ // frame trips this instead of hanging until the test wall clock.
+ let mut reply = [0u8; HEADER_SIZE];
+ timeout(Duration::from_secs(5), stream.read_exact(&mut reply))
+ .await
+ .expect("server must answer a legacy login code within 5s, not stall")
+ .expect("reading the eviction frame must succeed");
+
+ let command_offset = offset_of!(RequestHeader, command);
+ assert_eq!(
+ reply[command_offset],
+ Command2::Eviction as u8,
+ "expected an Eviction frame for legacy login code {code}, not a Reply"
+ );
+ assert_eq!(
+ reply[HEADER_SIZE - 1],
+ EVICTION_REASON_MALFORMED_LOGIN,
+ "legacy login code {code} must evict with MalformedLogin"
+ );
+}
diff --git a/core/integration/tests/server/mod.rs b/core/integration/tests/server/mod.rs
index eaed6e591..dde3e6019 100644
--- a/core/integration/tests/server/mod.rs
+++ b/core/integration/tests/server/mod.rs
@@ -15,7 +15,8 @@
// specific language governing permissions and limitations
// under the License.
-// a2a_jwt is HTTP-only (JWT against the HTTP transport); vsr has no HTTP.
+// a2a_jwt exercises trusted-issuer (JWKS) tokens; server-ng's HTTP JWT
+// verifier has no trusted-issuer path.
#[cfg(not(feature = "vsr"))]
mod a2a_jwt;
// Polling-based consumer-group scenarios are not implemented under vsr yet.
@@ -24,6 +25,27 @@ mod cg;
// The ported round-robin membership join scenario runs against server-ng.
#[cfg(feature = "vsr")]
mod cg_vsr;
+// Flush (FLUSH_UNSAVED_BUFFER) has no server-ng primitive; it must deny typed.
+#[cfg(feature = "vsr")]
+mod flush_vsr;
+// Legacy login codes (LOGIN_USER / LOGIN_WITH_PAT) have no server-ng handler;
+// they must evict typed (MalformedLogin), not stall or reply empty-ok.
+#[cfg(feature = "vsr")]
+mod legacy_login_vsr;
+// Shared HTTP transport plumbing (session + verb helpers) for the raw-HTTP
+// server-ng suites below.
+#[cfg(feature = "vsr")]
+mod http_client;
+// Raw-HTTP data-plane contract against server-ng's shard-0 listener.
+#[cfg(feature = "vsr")]
+mod http_vsr;
+// Raw-HTTP wire-contract residue against server-ng (status codes + typed error
+// bodies); the RBAC matrix lives in permissions_scenario.
+#[cfg(feature = "vsr")]
+mod http_rbac;
+// Binary GetClusterMetadata must serve the real roster from a VSR cluster.
+#[cfg(feature = "vsr")]
+mod cluster_metadata_vsr;
// 80-case race matrix with hardcoded HTTP variants (test_matrix bypasses
// the harness transport filter).
mod concurrent_addition;
@@ -34,8 +56,8 @@ mod message_cleanup;
mod message_retrieval;
// Server restarts, consumer-group barriers, and DeleteSegments maintenance.
// `should_delete_segments_without_consumers` is framing-agnostic + async-aware
-// and runs under server-ng; the consumer-group / restart variants stay
-// legacy-shaped (cg polling has its own vsr gaps) and are filtered out there.
+// and runs fully (both restart variants) under server-ng; the consumer-group
+// variants stay legacy-shaped or vsr-gated (cg polling has its own vsr gaps).
mod purge_delete;
mod scenarios;
mod specific;
diff --git a/core/integration/tests/server/purge_delete.rs b/core/integration/tests/server/purge_delete.rs
index d190de21f..92dfe3539 100644
--- a/core/integration/tests/server/purge_delete.rs
+++ b/core/integration/tests/server/purge_delete.rs
@@ -46,12 +46,9 @@ async fn should_delete_segments_and_validate_filesystem(
))]
// Recovery on restart is fixed -- server-ng reads its own 24-byte segment index
// via `segment_recovery` (no more "Index data must be exactly 16 bytes" panic;
-// the server restarts + re-meshes cleanly). restart_on stays vsr-gated on a
-// CLIENT blocker: the SDK `ConsensusSession` cannot reconnect after restart --
-// `register_request_id already called` (core/sdk/src/session.rs:121), the same
-// reconnect/re-register lifecycle `user_scenario` hits. Re-enable once that lands.
-#[cfg_attr(not(feature = "vsr"), test_matrix([restart_off(), restart_on()]))]
-#[cfg_attr(feature = "vsr", test_matrix([restart_off()]))]
+// the server restarts + re-meshes cleanly). The SDK reconnect-after-restart
+// re-login lifecycle is fixed too, so restart_on runs under vsr.
+#[test_matrix([restart_off(), restart_on()])]
async fn should_delete_segments_without_consumers(harness: &mut TestHarness, restart_server: bool) {
purge_delete_scenario::run_no_consumers(harness, restart_server).await;
}
@@ -101,9 +98,12 @@ async fn should_block_deletion_until_all_consumers_pass_segment(
))]
// The scenario asserts the exact [0, 7, 14, 21] layout only on the legacy path;
// under vsr it verifies the framing-agnostic purge outcome (offsets cleared,
-// files deleted, partition reset to a single segment at offset 0). restart_on
-// stays vsr-gated on the SDK reconnect-after-restart lifecycle (see
-// `should_delete_segments_without_consumers`).
+// files deleted, partition reset to a single segment at offset 0). The SDK
+// re-login panic is fixed, but restart_on stays vsr-gated on a separate
+// consumer-group blocker: after restart the heartbeat's group-assignment
+// refresh reconnect path re-binds an already-bound VSR session and fails
+// `AlreadyAuthenticated` (the consumerless `should_delete_segments_without_consumers`
+// restart_on passes, so this is CG-reconnect specific, not the login re-arm).
#[cfg_attr(not(feature = "vsr"), test_matrix([restart_off(), restart_on()]))]
#[cfg_attr(feature = "vsr", test_matrix([restart_off()]))]
async fn should_purge_topic_and_clear_consumer_offsets(
@@ -117,9 +117,6 @@ fn restart_off() -> bool {
false
}
-// Only the `not(vsr)` matrix cell uses restart_on (restart is vsr-gated on the
-// SDK reconnect lifecycle above), so it is unused under the vsr feature.
-#[cfg(not(feature = "vsr"))]
fn restart_on() -> bool {
true
}
diff --git a/core/integration/tests/server/scenarios/mod.rs b/core/integration/tests/server/scenarios/mod.rs
index be99a9ad9..b36884ae1 100644
--- a/core/integration/tests/server/scenarios/mod.rs
+++ b/core/integration/tests/server/scenarios/mod.rs
@@ -31,7 +31,10 @@ pub mod consumer_group_with_multiple_clients_polling_messages_scenario;
pub mod consumer_group_with_single_client_polling_messages_scenario;
pub mod consumer_timestamp_polling_scenario;
pub mod create_message_payload;
-#[cfg(not(feature = "vsr"))]
+// Cross-protocol PAT visibility (create via HTTP, list via TCP across shards,
+// and the reverse). Runs under vsr too: server-ng serves the PAT routes on its
+// shard-0 HTTP listener and the create/delete commit through the metadata STM,
+// so the token replicates to every shard a TCP client may land on.
pub mod cross_protocol_pat_scenario;
#[cfg(not(feature = "vsr"))]
pub mod encryption_scenario;
@@ -45,7 +48,6 @@ pub mod message_cleanup_scenario;
pub mod message_headers_scenario;
pub mod message_size_scenario;
pub mod offset_scenario;
-#[cfg(not(feature = "vsr"))]
pub mod permissions_scenario;
pub mod purge_delete_scenario;
pub mod read_during_persistence_scenario;
@@ -55,7 +57,6 @@ pub mod reconnect_after_restart_scenario;
pub mod restart_offset_skip_scenario;
pub mod segment_rotation_race_scenario;
pub mod single_message_per_batch_scenario;
-#[cfg(not(feature = "vsr"))]
pub mod snapshot_scenario;
pub mod stale_client_consumer_group_scenario;
pub mod stream_size_validation_scenario;
@@ -64,7 +65,6 @@ pub mod stress_produce_consume_scenario;
pub mod system_scenario;
pub mod tcp_tls_scenario;
pub mod timestamp_scenario;
-#[cfg(not(feature = "vsr"))]
pub mod user_scenario;
pub mod websocket_tls_scenario;
diff --git a/core/integration/tests/server/scenarios/permissions_scenario.rs b/core/integration/tests/server/scenarios/permissions_scenario.rs
index f16c168f3..76df9d80f 100644
--- a/core/integration/tests/server/scenarios/permissions_scenario.rs
+++ b/core/integration/tests/server/scenarios/permissions_scenario.rs
@@ -69,6 +69,15 @@ pub async fn run(harness: &TestHarness) {
// Missing resource behavior tests
test_missing_resource_behavior(harness, &root_client).await;
+ // RBAC surface ported from the raw-HTTP server-ng suite (server::http_rbac),
+ // asserting the exact typed IggyError over every transport.
+ test_consumer_offset_permissions(harness, &root_client).await;
+ test_change_password_reply_path(harness, &root_client).await;
+ test_root_user_is_protected(harness, &root_client).await;
+ test_live_permission_revocation(harness, &root_client).await;
+ test_deleted_user_session_denied(harness, &root_client).await;
+ test_personal_access_token_lifecycle(harness, &root_client).await;
+
cleanup(&root_client).await;
}
@@ -2435,10 +2444,374 @@ async fn test_missing_resource_behavior(harness: &TestHarness, root_client: &Igg
delete_test_user(root_client, SCOPED_USER).await;
}
+// =============================================================================
+// Test: Consumer-offset ops honor RBAC (ported from server::http_rbac).
+// A no-permission user is denied on the writes (store/delete surface the exact
+// Unauthorized); the GET is an enumeration-safe read the legacy server answers
+// with Ok(None), so it stays on the lenient helper.
+// =============================================================================
+
+async fn test_consumer_offset_permissions(harness: &TestHarness, root_client: &IggyClient) {
+ const USER: &str = "offset-no-perms-user";
+ create_test_user(root_client, USER, Some(no_permissions())).await;
+ let client = login_user(harness, USER).await;
+
+ let stream_id = Identifier::named(STREAM_1).unwrap();
+ let topic_id = Identifier::named(TOPIC_1).unwrap();
+ let consumer = Consumer::default();
+
+ assert_error_code(
+ client
+ .store_consumer_offset(&consumer, &stream_id, &topic_id, Some(0), 0)
+ .await,
+ IggyError::Unauthorized,
+ "no perms: store_consumer_offset",
+ );
+ assert_error_code(
+ client
+ .delete_consumer_offset(&consumer, &stream_id, &topic_id, Some(0))
+ .await,
+ IggyError::Unauthorized,
+ "no perms: delete_consumer_offset",
+ );
+ // GET is enumeration-safe: legacy answers Ok(None), server-ng denies typed.
+ assert_unauthorized(
+ client
+ .get_consumer_offset(&consumer, &stream_id, &topic_id, Some(0))
+ .await,
+ "no perms: get_consumer_offset",
+ );
+
+ delete_test_user(root_client, USER).await;
+}
+
+// =============================================================================
+// Test: Root (user id 0) is business-rule protected even from a manage_users
+// admin: delete -> CannotDeleteUser, re-permission -> CannotChangePermissions.
+// Both fire AFTER the RBAC gate admits the acting admin.
+// =============================================================================
+
+async fn test_root_user_is_protected(harness: &TestHarness, root_client: &IggyClient) {
+ const ADMIN: &str = "root-guard-admin";
+ create_test_user(
+ root_client,
+ ADMIN,
+ Some(Permissions {
+ global: GlobalPermissions {
+ manage_users: true,
+ ..Default::default()
+ },
+ streams: None,
+ }),
+ )
+ .await;
+ let admin = login_user(harness, ADMIN).await;
+ let root_id = Identifier::numeric(0).unwrap();
+
+ assert_error_code(
+ admin.delete_user(&root_id).await,
+ IggyError::CannotDeleteUser(0),
+ "admin delete root",
+ );
+ assert_error_code(
+ admin
+ .update_permissions(&root_id, Some(no_permissions()))
+ .await,
+ IggyError::CannotChangePermissions(0),
+ "admin re-permission root",
+ );
+
+ delete_test_user(root_client, ADMIN).await;
+}
+
+// =============================================================================
+// Test: Live revocation on a single node. Stripping send from a granted user
+// denies the next send immediately (exact Unauthorized) while the retained poll
+// grant still succeeds.
+// =============================================================================
+
+async fn test_live_permission_revocation(harness: &TestHarness, root_client: &IggyClient) {
+ const USER: &str = "revocation-user";
+ create_test_user(
+ root_client,
+ USER,
+ Some(Permissions {
+ global: GlobalPermissions {
+ poll_messages: true,
+ send_messages: true,
+ ..Default::default()
+ },
+ streams: None,
+ }),
+ )
+ .await;
+ let client = login_user(harness, USER).await;
+
+ let stream_id = Identifier::named(STREAM_1).unwrap();
+ let topic_id = Identifier::named(TOPIC_1).unwrap();
+
+ let mut msgs = test_messages();
+ client
+ .send_messages(
+ &stream_id,
+ &topic_id,
+ &Partitioning::partition_id(0),
+ &mut msgs,
+ )
+ .await
+ .expect("send must work before revocation");
+
+ root_client
+ .update_permissions(
+ &Identifier::named(USER).unwrap(),
+ Some(Permissions {
+ global: GlobalPermissions {
+ poll_messages: true,
+ send_messages: false,
+ ..Default::default()
+ },
+ streams: None,
+ }),
+ )
+ .await
+ .expect("root re-permission (strip send)");
+
+ let mut msgs = test_messages();
+ assert_error_code(
+ client
+ .send_messages(
+ &stream_id,
+ &topic_id,
+ &Partitioning::partition_id(0),
+ &mut msgs,
+ )
+ .await,
+ IggyError::Unauthorized,
+ "send must be denied after send is stripped",
+ );
+ client
+ .poll_messages(
+ &stream_id,
+ &topic_id,
+ Some(0),
+ &Consumer::default(),
+ &PollingStrategy::offset(0),
+ 1,
+ false,
+ )
+ .await
+ .expect("poll must still work (poll grant retained)");
+
+ delete_test_user(root_client, USER).await;
+}
+
+// =============================================================================
+// Test: Deleting a user with a live session denies that session's next read.
+// A read_servers grant makes the pre-delete read a real Ok (get_stats is not
+// enumeration-safe), so the post-delete denial is unambiguous. The binary
+// transports may answer Unauthenticated (session eviction) rather than
+// Unauthorized, so accept either.
+// =============================================================================
+
+async fn test_deleted_user_session_denied(harness: &TestHarness, root_client: &IggyClient) {
+ const USER: &str = "deleted-session-user";
+ create_test_user(
+ root_client,
+ USER,
+ Some(Permissions {
+ global: GlobalPermissions {
+ read_servers: true,
+ ..Default::default()
+ },
+ streams: None,
+ }),
+ )
+ .await;
+ let client = login_user(harness, USER).await;
+ client
+ .get_stats()
+ .await
+ .expect("read_servers get_stats must work before deletion");
+
+ root_client
+ .delete_user(&Identifier::named(USER).unwrap())
+ .await
+ .expect("root delete user");
+
+ let result = client.get_stats().await;
+ assert!(
+ matches!(&result, Err(e)
+ if e.as_code() == IggyError::Unauthorized.as_code()
+ || e.as_code() == IggyError::Unauthenticated.as_code()),
+ "deleted user's live session must be denied (Unauthorized or Unauthenticated), got {result:?}"
+ );
+}
+
+// =============================================================================
+// Test: PAT lifecycle over the SDK. A no-permission user self-manages PATs
+// (create/list/delete bypass RBAC); a fresh session authenticating WITH the raw
+// PAT inherits the owner's empty rule set, so a subsequent op is denied; and the
+// listing is caller-scoped (only the caller's own tokens).
+// =============================================================================
+
+async fn test_personal_access_token_lifecycle(harness: &TestHarness, root_client: &IggyClient) {
+ const USER: &str = "pat-lifecycle-user";
+ const OTHER: &str = "pat-other-user";
+ create_test_user(root_client, USER, Some(no_permissions())).await;
+ create_test_user(root_client, OTHER, Some(no_permissions())).await;
+ let client = login_user(harness, USER).await;
+ let other = login_user(harness, OTHER).await;
+
+ // Self-scoped create/delete bypass RBAC even with no permissions.
+ client
+ .create_personal_access_token("pat-to-delete", PersonalAccessTokenExpiry::NeverExpire)
+ .await
+ .expect("no perms: create own PAT should work");
+ client
+ .delete_personal_access_token("pat-to-delete")
+ .await
+ .expect("no perms: delete own PAT should work");
+
+ // A PAT resolves to its owner, inheriting the owner's (empty) rules.
+ let raw = client
+ .create_personal_access_token("pat-identity", PersonalAccessTokenExpiry::NeverExpire)
+ .await
+ .expect("no perms: create identity PAT should work");
+ let via_pat = create_client(harness).await;
+ via_pat
+ .login_with_personal_access_token(&raw.token)
+ .await
+ .expect("PAT login should succeed");
+ assert_unauthorized(
+ via_pat.get_streams().await,
+ "PAT-bound session inherits the owner's empty rules",
+ );
+
+ // Listing is caller-scoped: OTHER never sees USER's tokens.
+ other
+ .create_personal_access_token("other-pat", PersonalAccessTokenExpiry::NeverExpire)
+ .await
+ .expect("other: create own PAT should work");
+ let owner_tokens: Vec<String> = client
+ .get_personal_access_tokens()
+ .await
+ .expect("owner PAT list")
+ .into_iter()
+ .map(|token| token.name)
+ .collect();
+ assert_eq!(
+ owner_tokens,
+ vec!["pat-identity".to_string()],
+ "owner must list exactly their own live token"
+ );
+ let other_tokens: Vec<String> = other
+ .get_personal_access_tokens()
+ .await
+ .expect("other PAT list")
+ .into_iter()
+ .map(|token| token.name)
+ .collect();
+ assert_eq!(
+ other_tokens,
+ vec!["other-pat".to_string()],
+ "other must never see the owner's tokens"
+ );
+
+ delete_test_user(root_client, USER).await;
+ delete_test_user(root_client, OTHER).await;
+}
+
+// =============================================================================
+// Test: change-password over the consensus reply path (every transport).
+//
+// A wrong current password returns the typed `InvalidCredentials` and a missing
+// target returns `ResourceNotFound`, and in EVERY case the caller's connection
+// stays usable for the next replicated op. Regression guard: the binary
+// transports used to deny a wrong current password pre-consensus, consuming the
+// client's request id without advancing the replicated `ClientTable`, so the
+// next replicated request hit a `RequestGap` and was silently dropped until the
+// socket timed out. The rejection now commits as a no-op, keeping the sequence
+// contiguous.
+// =============================================================================
+
+async fn test_change_password_reply_path(harness: &TestHarness, root_client: &IggyClient) {
+ const USER: &str = "change-password-user";
+ const ADMIN: &str = "change-password-admin";
+ create_test_user(root_client, USER, Some(no_permissions())).await;
+ create_test_user(root_client, ADMIN, Some(manage_users_permissions())).await;
+
+ let user_id = Identifier::named(USER).unwrap();
+ let client = login_user(harness, USER).await;
+
+ // Wrong current password: typed rejection, connection still usable.
+ assert_error_code(
+ client
+ .change_password(&user_id, "wrong-current", "password456")
+ .await,
+ IggyError::InvalidCredentials,
+ "self change with a wrong current password",
+ );
+ client
+ .create_personal_access_token(
+ "change-password-probe-1",
+ PersonalAccessTokenExpiry::NeverExpire,
+ )
+ .await
+ .expect("connection stays usable after a wrong-current rejection");
+
+ // Correct current password: succeeds, session stays live.
+ client
+ .change_password(&user_id, "password123", "password456")
+ .await
+ .expect("self change with the correct current password");
+ client
+ .create_personal_access_token(
+ "change-password-probe-2",
+ PersonalAccessTokenExpiry::NeverExpire,
+ )
+ .await
+ .expect("connection stays usable after a successful self rotation");
+
+ // Admin changing a missing target: typed not-found, connection still usable.
+ let admin = login_user(harness, ADMIN).await;
+ assert_error_code(
+ admin
+ .change_password(
+ &Identifier::named("change-password-ghost").unwrap(),
+ "irrelevant",
+ "password456",
+ )
+ .await,
+ IggyError::ResourceNotFound(String::new()),
+ "admin change for a non-existent target",
+ );
+ admin
+ .create_personal_access_token(
+ "change-password-probe-3",
+ PersonalAccessTokenExpiry::NeverExpire,
+ )
+ .await
+ .expect("connection stays usable after a missing-target rejection");
+
+ delete_test_user(root_client, USER).await;
+ delete_test_user(root_client, ADMIN).await;
+}
+
// =============================================================================
// Helpers
// =============================================================================
+fn manage_users_permissions() -> Permissions {
+ Permissions {
+ global: GlobalPermissions {
+ manage_users: true,
+ read_users: true,
+ ..GlobalPermissions::default()
+ },
+ streams: None,
+ }
+}
+
fn test_messages() -> Vec<IggyMessage> {
vec![
IggyMessage::builder()
@@ -2475,6 +2848,35 @@ fn assert_unauthorized<T: std::fmt::Debug>(result: Result<T, IggyError>, context
}
}
+/// Assert an exact typed `IggyError`. Ported RBAC cases assert the precise code
+/// the server returns, not the enumeration-safe set of [`assert_unauthorized`].
+///
+/// The HTTP SDK reconstructs only 401/403/404 as typed errors and collapses
+/// every other status (notably the 400s carrying `InvalidCredentials`,
+/// `CannotDeleteUser`, and `CannotChangePermissions`) into a generic
+/// `HttpResponseError` wrapping the typed JSON body. Read the exact code back
+/// from that body's `id` so the assertion stays exact over HTTP as well.
+fn assert_error_code<T: std::fmt::Debug>(
+ result: Result<T, IggyError>,
+ expected: IggyError,
+ context: &str,
+) {
+ let expected_code = expected.as_code();
+ match &result {
+ Err(e) if e.as_code() == expected_code => {}
+ Err(IggyError::HttpResponseError(_, body))
+ if http_error_id(body) == Some(expected_code) => {}
+ _ => panic!("{context}: expected error code {expected_code}, got {result:?}"),
+ }
+}
+
+/// The `id` field of an HTTP `ErrorResponse` body is the wire `IggyError` code,
+/// so a status-collapsed `HttpResponseError` can still be matched exactly.
+fn http_error_id(body: &str) -> Option<u32> {
+ let value: serde_json::Value = serde_json::from_str(body).ok()?;
+ value.get("id")?.as_u64().map(|id| id as u32)
+}
+
fn assert_not_found_or_related<T: std::fmt::Debug>(result: Result<T, IggyError>, context: &str) {
// Accept various "not found" error codes that indicate resource absence.
// This is used when an authorized user tries to access a non-existent resource.
diff --git a/core/message_bus/src/lib.rs b/core/message_bus/src/lib.rs
index fc96d7afb..450873350 100644
--- a/core/message_bus/src/lib.rs
+++ b/core/message_bus/src/lib.rs
@@ -95,13 +95,14 @@ pub use installer::conn_info::{
};
pub use lifecycle::{
BusMessage, BusReceiver, BusSender, ConnectionRegistry, DrainOutcome, FusedShutdown,
- ReplicaRegistry, Shutdown, ShutdownToken,
+ InstanceToken, ReplicaRegistry, ReplyRoute, ReplySlotError, ReplySlotGuard, Shutdown,
+ ShutdownToken,
};
pub use transports::tls::TlsServerCredentials;
pub use compio::runtime::JoinHandle;
use configs::server_ng::ServerNgConfig;
-use iggy_binary_protocol::GenericHeader;
+use iggy_binary_protocol::{GenericHeader, ReplyHeader};
use server_common::{MESSAGE_ALIGN, Message, iobuf::Frozen};
use std::array;
use std::cell::{Cell, OnceCell, RefCell};
@@ -1225,13 +1226,21 @@ impl MessageBus for IggyMessageBus {
// Owning shard is encoded in the top 16 bits of client_id.
let owning_shard = client_id_owning_shard(client_id);
if owning_shard == self.shard_id {
- // Fast path: move `message` straight into `try_send`. On no-slot
- // the registry hands it back; we drop it and surface
- // ClientNotFound (matches prior behaviour: SendError did not
- // preserve payload either).
+ // Route by the entry's reply target. Socket is the unchanged
+ // fast path and never decodes. InProcess resolves the waiting
+ // request id from the reply header and fires its oneshot; that
+ // decode stays off the socket path. No slot surfaces
+ // ClientNotFound (prior behaviour: SendError did not preserve
+ // the payload either).
return match self.clients.try_send_or_return(client_id, message) {
- Ok(send_result) => send_result.map_err(map_try_send_err),
- Err(_msg) => Err(SendError::ClientNotFound(client_id)),
+ ReplyRoute::Delivered(send_result) => send_result.map_err(map_try_send_err),
+ ReplyRoute::InProcess(message) => {
+ let request = reply_request_id(&message);
+ self.clients
+ .fire_in_process(client_id, request, message)
+ .map_err(|_| SendError::ClientNotFound(client_id))
+ }
+ ReplyRoute::NoSlot(_message) => Err(SendError::ClientNotFound(client_id)),
};
}
let forward = self
@@ -1313,6 +1322,22 @@ pub const fn client_id_owning_shard(client_id: u128) -> u16 {
(client_id >> 112) as u16
}
+/// Reserved client id stamped on server-generated auto-commit
+/// `StoreConsumerOffset2` ops (a poll's `auto_commit` replicated for failover).
+///
+/// Never belongs to a live connection: `mint_client_id` produces
+/// `(shard << 112) | seq` and no real shard is `u16::MAX`, so `u128::MAX` is
+/// unreachable. The commit path recognises it and skips the (unwaited) reply,
+/// keeping an unrequested frame off a real client's lockstep stream. Nonzero,
+/// so it still satisfies the wire header's `client != 0` validation.
+pub const AUTO_COMMIT_CLIENT_ID: u128 = u128::MAX;
+
+/// Whether `client_id` is the reserved [`AUTO_COMMIT_CLIENT_ID`] sentinel.
+#[must_use]
+pub const fn is_auto_commit_client(client_id: u128) -> bool {
+ client_id == AUTO_COMMIT_CLIENT_ID
+}
+
/// Map an `async_channel::TrySendError` onto the bus-level [`SendError`].
///
/// Shape-matches `Result::map_err` (takes the error by value) so it can be
@@ -1325,6 +1350,21 @@ fn map_try_send_err(e: async_channel::TrySendError<Frozen<MESSAGE_ALIGN>>) -> Se
}
}
+/// Peek `ReplyHeader.request` (the originating request id) from a reply
+/// buffer at its fixed header offset. Only the in-process reply path calls
+/// this; the socket path never decodes.
+fn reply_request_id(reply: &Frozen<MESSAGE_ALIGN>) -> u64 {
+ const OFFSET: usize = std::mem::offset_of!(ReplyHeader, request);
+ reply
+ .as_slice()
+ .get(OFFSET..OFFSET + std::mem::size_of::<u64>())
+ .and_then(|bytes| bytes.try_into().ok())
+ // Native-endian is safe: this id never leaves the process, so writer and
+ // reader share endianness. A wire reader uses little-endian instead (see
+ // the SDK's `read_reply_status`).
+ .map_or(0, u64::from_ne_bytes)
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -1375,6 +1415,56 @@ mod tests {
assert!(matches!(err, SendError::ClientNotFound(_)));
}
+ #[allow(clippy::cast_possible_truncation)]
+ fn reply_message(request: u64) -> Message<ReplyHeader> {
+ Message::<ReplyHeader>::new(HEADER_SIZE).transmute_header(|_, h: &mut ReplyHeader| {
+ h.command = Command2::Reply;
+ h.size = HEADER_SIZE as u32;
+ h.request = request;
+ })
+ }
+
+ /// Full in-process seam: entry + slot installed, `send_to_client`
+ /// resolves the request id from the reply header and fires the oneshot.
+ #[compio::test]
+ #[allow(clippy::future_not_send)]
+ async fn send_to_client_in_process_fires_matching_reply_slot() {
+ let bus = IggyMessageBus::new(0);
+ let client_id = 1u128;
+ bus.clients()
+ .insert_in_process(client_id)
+ .expect("fresh key");
+ let (_guard, reply_rx) = bus
+ .clients()
+ .install_reply_slot(client_id, 42)
+ .expect("slot installs");
+
+ bus.send_to_client(client_id, reply_message(42).into_generic().into_frozen())
+ .await
+ .expect("in-process delivery");
+
+ let received = reply_rx.await.expect("reply delivered");
+ assert_eq!(reply_request_id(&received), 42);
+ }
+
+ /// Reply whose request id has no waiter (timed-out caller already
+ /// removed its slot): shed as `ClientNotFound`, nothing panics.
+ #[compio::test]
+ #[allow(clippy::future_not_send)]
+ async fn send_to_client_in_process_sheds_reply_without_waiter() {
+ let bus = IggyMessageBus::new(0);
+ let client_id = 1u128;
+ bus.clients()
+ .insert_in_process(client_id)
+ .expect("fresh key");
+
+ let err = bus
+ .send_to_client(client_id, reply_message(42).into_generic().into_frozen())
+ .await
+ .unwrap_err();
+ assert!(matches!(err, SendError::ClientNotFound(_)));
+ }
+
#[compio::test]
#[allow(clippy::future_not_send)]
async fn send_to_client_returns_route_missing_when_remote_and_no_forward_fn() {
diff --git a/core/message_bus/src/lifecycle/connection_registry.rs b/core/message_bus/src/lifecycle/connection_registry.rs
index 162d139df..8dc22ad0f 100644
--- a/core/message_bus/src/lifecycle/connection_registry.rs
+++ b/core/message_bus/src/lifecycle/connection_registry.rs
@@ -33,6 +33,7 @@
//! [`ShutdownToken`]: crate::lifecycle::ShutdownToken
use compio::runtime::JoinHandle;
+use futures::channel::oneshot;
use server_common::{MESSAGE_ALIGN, iobuf::Frozen};
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
@@ -40,6 +41,7 @@ use std::collections::hash_map::Entry as HmEntry;
use std::fmt::Debug;
use std::hash::Hash;
use std::time::{Duration, Instant};
+use thiserror::Error;
use tracing::{debug, warn};
/// Opaque per-insert generation token.
@@ -128,9 +130,111 @@ pub struct DrainOutcome {
pub background_force: usize,
}
+/// Where a client reply is delivered.
+///
+/// `Socket` is the wire path taken by every TCP / WS / QUIC client: the
+/// reply is pushed into the per-peer queue exactly as before. `InProcess`
+/// reifies an in-process reply target keyed by request id so a caller
+/// holding the matching [`oneshot::Receiver`] can await its reply without a
+/// socket. Both ends are shard-0-local, hence a single-threaded oneshot.
+#[derive(Debug)]
+enum ReplyTarget {
+ Socket(BusSender),
+ InProcess {
+ by_request: HashMap<u64, oneshot::Sender<BusMessage>>,
+ },
+}
+
+impl ReplyTarget {
+ /// Wire sender for socket targets; `None` for in-process targets.
+ const fn as_socket(&self) -> Option<&BusSender> {
+ match self {
+ Self::Socket(sender) => Some(sender),
+ Self::InProcess { .. } => None,
+ }
+ }
+}
+
+/// Outcome of routing a client reply through an [`Entry`]'s reply target.
+///
+/// `Delivered` is the socket fast path: `try_send` was attempted and its
+/// result is carried through unchanged. `InProcess` hands the message back
+/// so the caller can decode the request id and fire the matching oneshot
+/// via [`ConnectionRegistry::fire_in_process`], keeping that decode off the
+/// socket path. `NoSlot` means no entry exists for the key.
+#[derive(Debug)]
+#[must_use]
+pub enum ReplyRoute {
+ Delivered(Result<(), async_channel::TrySendError<BusMessage>>),
+ InProcess(BusMessage),
+ NoSlot(BusMessage),
+}
+
+/// Rejection reasons for [`ConnectionRegistry::install_reply_slot`].
+#[derive(Debug, Error, PartialEq, Eq)]
+pub enum ReplySlotError {
+ /// No registry entry exists for the key: the in-process entry was
+ /// never installed or has already been torn down.
+ #[error("no registry entry for this key")]
+ EntryNotFound,
+ /// The entry is a socket target. Socket replies go through the
+ /// per-peer queue; a reply slot on such an entry could never fire.
+ #[error("registry entry is a socket target")]
+ NotInProcess,
+ /// A waiter is already registered under this request id. Replacing it
+ /// would drop the first waiter's sender and could deliver its reply
+ /// to the wrong caller, so the second install is rejected instead.
+ #[error("a reply slot for this request id is already installed")]
+ DuplicateRequest,
+}
+
+/// Cancel-safety handle for an installed in-process reply slot.
+///
+/// Dropping the guard removes the slot unless the reply already fired
+/// ([`ConnectionRegistry::fire_in_process`] consumes the slot on delivery,
+/// making the drop a no-op) or the whole entry was replaced (fenced by the
+/// entry's [`InstanceToken`], mirroring `remove_if_token_matches`). A
+/// caller that times out or is cancelled therefore never leaks its oneshot
+/// sender.
+///
+/// Contract: a request id must not be reused under the same key while a
+/// previous guard for that id is still live; the stale guard's drop would
+/// remove the new slot. Callers mint monotonically increasing request ids,
+/// which rules this out.
+#[must_use = "dropping the guard removes the reply slot"]
+#[derive(Debug)]
+pub struct ReplySlotGuard<'a, K>
+where
+ K: Eq + Hash + Copy + Debug + 'static,
+{
+ registry: &'a ConnectionRegistry<K>,
+ key: K,
+ request: u64,
+ token: InstanceToken,
+}
+
+impl<K> Drop for ReplySlotGuard<'_, K>
+where
+ K: Eq + Hash + Copy + Debug + 'static,
+{
+ fn drop(&mut self) {
+ let mut entries = self.registry.entries.borrow_mut();
+ let Some(entry) = entries.get_mut(&self.key) else {
+ return;
+ };
+ if entry.token != self.token {
+ return;
+ }
+ let ReplyTarget::InProcess { by_request } = &mut entry.reply else {
+ return;
+ };
+ by_request.remove(&self.request);
+ }
+}
+
#[derive(Debug)]
struct Entry {
- sender: BusSender,
+ reply: ReplyTarget,
writer_handle: Option<JoinHandle<()>>,
reader_handle: Option<JoinHandle<()>>,
/// Generation token minted by the registry on insert. Entries that
@@ -215,43 +319,105 @@ where
/// `close_peer`.
pub fn with_sender<R>(&self, key: K, f: impl FnOnce(&BusSender) -> R) -> Option<R> {
let entries = self.entries.borrow();
- entries.get(&key).map(|entry| f(&entry.sender))
+ entries
+ .get(&key)
+ .and_then(|entry| entry.reply.as_socket())
+ .map(f)
}
- /// Try to send `msg` to the per-peer queue, returning the message back
- /// when no slot exists.
+ /// Route `msg` to the entry's reply target, handing it back when no
+ /// live target accepts it.
///
- /// Lets `send_to_*` skip the unconditional `Frozen::clone()` the
- /// `with_sender` shape forced (closure consumes a clone, outer caller
- /// retains the original for the slow path the borrow checker cannot
- /// prove unreachable). The consuming variant moves `msg` into
- /// `try_send` on the fast path; on no-slot the message is handed back
- /// so the caller can route it via the slow path or drop it.
+ /// Lets `send_to_client` skip the unconditional `Frozen::clone()` the
+ /// `with_sender` shape forced. Socket targets move `msg` straight into
+ /// `try_send`; in-process targets hand it back via
+ /// [`ReplyRoute::InProcess`] so the caller can resolve the request id
+ /// and fire the oneshot without decoding on the socket path.
///
/// Returns:
- /// - `Ok(Ok(()))`: slot exists and the queue accepted `msg`.
- /// - `Ok(Err(TrySendError))`: slot exists but `try_send` failed
- /// (`Full` or `Closed`); the inner error carries `msg` back per
- /// `async_channel`'s contract.
- /// - `Err(msg)`: no slot for `key`; `msg` returned to the caller.
+ /// - [`ReplyRoute::Delivered`]: socket target; carries the `try_send`
+ /// result (`Full` / `Closed` errors carry `msg` back per
+ /// `async_channel`'s contract).
+ /// - [`ReplyRoute::InProcess`]: in-process target; `msg` handed back.
+ /// - [`ReplyRoute::NoSlot`]: no entry for `key`; `msg` handed back.
+ pub fn try_send_or_return(&self, key: K, msg: BusMessage) -> ReplyRoute {
+ let entries = self.entries.borrow();
+ let Some(entry) = entries.get(&key) else {
+ return ReplyRoute::NoSlot(msg);
+ };
+ match &entry.reply {
+ ReplyTarget::Socket(sender) => ReplyRoute::Delivered(sender.try_send(msg)),
+ ReplyTarget::InProcess { .. } => ReplyRoute::InProcess(msg),
+ }
+ }
+
+ /// Fire the in-process reply registered under `request` for `key`.
+ ///
+ /// Companion to [`ReplyRoute::InProcess`]; the socket path never reaches
+ /// here. Removes the matching oneshot and delivers `msg` on it.
///
/// # Errors
///
- /// The outer `Err(msg)` reports that no entry exists for `key`. The
- /// inner `Err(TrySendError)` reports that the entry's queue is full
- /// or closed.
- pub fn try_send_or_return(
- &self,
- key: K,
- msg: BusMessage,
- ) -> Result<Result<(), async_channel::TrySendError<BusMessage>>, BusMessage> {
- let entries = self.entries.borrow();
- match entries.get(&key) {
- Some(entry) => Ok(entry.sender.try_send(msg)),
+ /// Hands `msg` back when `key` has no in-process target or no waiter is
+ /// registered for `request`, so the caller surfaces the same not-found
+ /// outcome as a missing socket.
+ pub fn fire_in_process(&self, key: K, request: u64, msg: BusMessage) -> Result<(), BusMessage> {
+ let mut entries = self.entries.borrow_mut();
+ let Some(entry) = entries.get_mut(&key) else {
+ return Err(msg);
+ };
+ let ReplyTarget::InProcess { by_request } = &mut entry.reply else {
+ return Err(msg);
+ };
+ match by_request.remove(&request) {
+ Some(reply_tx) => reply_tx.send(msg),
None => Err(msg),
}
}
+ /// Register a oneshot waiter for the reply to `request` on `key`'s
+ /// in-process entry.
+ ///
+ /// The returned receiver resolves when `send_to_client` routes a reply
+ /// carrying this request id (via [`Self::fire_in_process`]), or with
+ /// `Canceled` when the entry is torn down first. The guard removes the
+ /// slot on drop; see [`ReplySlotGuard`] for the exact semantics.
+ ///
+ /// # Errors
+ ///
+ /// [`ReplySlotError::EntryNotFound`] when `key` has no entry,
+ /// [`ReplySlotError::NotInProcess`] when the entry is a socket target,
+ /// [`ReplySlotError::DuplicateRequest`] when a waiter is already
+ /// registered under `request`.
+ pub fn install_reply_slot(
+ &self,
+ key: K,
+ request: u64,
+ ) -> Result<(ReplySlotGuard<'_, K>, oneshot::Receiver<BusMessage>), ReplySlotError> {
+ let mut entries = self.entries.borrow_mut();
+ let Some(entry) = entries.get_mut(&key) else {
+ return Err(ReplySlotError::EntryNotFound);
+ };
+ let token = entry.token;
+ let ReplyTarget::InProcess { by_request } = &mut entry.reply else {
+ return Err(ReplySlotError::NotInProcess);
+ };
+ let HmEntry::Vacant(slot) = by_request.entry(request) else {
+ return Err(ReplySlotError::DuplicateRequest);
+ };
+ let (reply_tx, reply_rx) = oneshot::channel();
+ slot.insert(reply_tx);
+ Ok((
+ ReplySlotGuard {
+ registry: self,
+ key,
+ request,
+ token,
+ },
+ reply_rx,
+ ))
+ }
+
/// Register a new connection. Stores the producer side of the per-peer
/// queue alongside the writer + reader task handles so graceful shutdown
/// can drain everything.
@@ -294,7 +460,7 @@ where
entries.insert(
key,
Entry {
- sender,
+ reply: ReplyTarget::Socket(sender),
writer_handle: Some(writer_handle),
reader_handle: Some(reader_handle),
token,
@@ -304,6 +470,38 @@ where
Ok(token)
}
+ /// Register an in-process reply target for `key`.
+ ///
+ /// In-process peers have no socket and no reader / writer tasks:
+ /// replies routed to `key` resolve request-keyed oneshots installed
+ /// via [`Self::install_reply_slot`]. Teardown goes through
+ /// [`Self::remove`] / [`Self::remove_if_token_matches`]; dropping the
+ /// entry drops every pending sender, so all outstanding receivers
+ /// resolve `Canceled`.
+ ///
+ /// Returns `None` when `key` is already registered (socket or
+ /// in-process); the caller must tear the existing entry down first.
+ pub fn insert_in_process(&self, key: K) -> Option<InstanceToken> {
+ let mut entries = self.entries.borrow_mut();
+ let HmEntry::Vacant(slot) = entries.entry(key) else {
+ return None;
+ };
+ let token = self.mint_token();
+ // Nothing ever waits on this shutdown (no reader to wake); it
+ // exists only because every entry owns one.
+ let (conn_shutdown, _) = super::Shutdown::new();
+ slot.insert(Entry {
+ reply: ReplyTarget::InProcess {
+ by_request: HashMap::new(),
+ },
+ writer_handle: None,
+ reader_handle: None,
+ token,
+ _conn_shutdown: conn_shutdown,
+ });
+ Some(token)
+ }
+
/// Unregister a connection without awaiting its tasks.
///
/// Returns `true` if an entry was removed. Dropping the entry drops the
@@ -360,7 +558,9 @@ where
let Some(mut entry) = self.entries.borrow_mut().remove(&key) else {
return;
};
- entry.sender.close();
+ if let Some(sender) = entry.reply.as_socket() {
+ sender.close();
+ }
if let Some(writer_handle) = entry.writer_handle.take() {
let _ = compio::time::timeout(timeout, writer_handle).await;
}
@@ -389,7 +589,9 @@ where
}
slot.remove()
};
- entry.sender.close();
+ if let Some(sender) = entry.reply.as_socket() {
+ sender.close();
+ }
if let Some(writer_handle) = entry.writer_handle.take() {
let _ = compio::time::timeout(timeout, writer_handle).await;
}
@@ -447,7 +649,9 @@ where
// the shutdown token has not been triggered. Writer is
// awaited before the reader handle is dropped so a
// mid-writev cancellation cannot truncate a frame.
- entry.sender.close();
+ if let Some(sender) = entry.reply.as_socket() {
+ sender.close();
+ }
// Writer gets the full shared deadline so a legitimately-
// flushing `write_vectored_all` never loses budget to any
// other entry in the same drain batch.
@@ -581,22 +785,34 @@ impl ReplicaRegistry {
let slots = self.slots.borrow();
slots[usize::from(key)]
.as_ref()
- .map(|entry| f(&entry.sender))
+ .and_then(|entry| entry.reply.as_socket())
+ .map(f)
}
- /// See [`ConnectionRegistry::try_send_or_return`].
+ /// Try to send `msg` to the replica socket registered under `key`, handing
+ /// `msg` back as `Err` when no slot is present.
+ ///
+ /// Replicas only ever register socket reply targets, so unlike
+ /// [`ConnectionRegistry::try_send_or_return`] (which returns a [`ReplyRoute`]
+ /// also covering the in-process case) this stays a flat
+ /// `Result<try-send outcome, BusMessage>`.
///
/// # Errors
///
- /// Same as [`ConnectionRegistry::try_send_or_return`].
+ /// `Err(msg)` when `key` has no registered slot. The inner `Err` carries a
+ /// `try_send` failure (`Full` / `Closed`), which returns `msg` per
+ /// `async_channel`'s contract.
pub fn try_send_or_return(
&self,
key: u8,
msg: BusMessage,
) -> Result<Result<(), async_channel::TrySendError<BusMessage>>, BusMessage> {
let slots = self.slots.borrow();
- match slots[usize::from(key)].as_ref() {
- Some(entry) => Ok(entry.sender.try_send(msg)),
+ match slots[usize::from(key)]
+ .as_ref()
+ .and_then(|entry| entry.reply.as_socket())
+ {
+ Some(sender) => Ok(sender.try_send(msg)),
None => Err(msg),
}
}
@@ -627,7 +843,7 @@ impl ReplicaRegistry {
}
let token = self.mint_token();
*slot = Some(Entry {
- sender,
+ reply: ReplyTarget::Socket(sender),
writer_handle: Some(writer_handle),
reader_handle: Some(reader_handle),
token,
@@ -672,7 +888,9 @@ impl ReplicaRegistry {
self.len.set(self.len.get() - 1);
entry
};
- entry.sender.close();
+ if let Some(sender) = entry.reply.as_socket() {
+ sender.close();
+ }
if let Some(writer_handle) = entry.writer_handle.take() {
let _ = compio::time::timeout(timeout, writer_handle).await;
}
@@ -697,7 +915,9 @@ impl ReplicaRegistry {
self.len.set(self.len.get() - 1);
removed_entry
};
- entry.sender.close();
+ if let Some(sender) = entry.reply.as_socket() {
+ sender.close();
+ }
if let Some(writer_handle) = entry.writer_handle.take() {
let _ = compio::time::timeout(timeout, writer_handle).await;
}
@@ -1065,7 +1285,7 @@ mod tests {
.expect("insert ok");
let outer = reg.try_send_or_return(1u8, make_bus_msg());
- assert!(matches!(outer, Ok(Ok(()))));
+ assert!(matches!(outer, ReplyRoute::Delivered(Ok(()))));
// Receiver drains the message; closing the writer's rx end via
// close_peer is not necessary for this assertion.
let received = rx.recv().await.expect("queue had message");
@@ -1081,7 +1301,9 @@ mod tests {
let original_len = msg.len();
let outcome = reg.try_send_or_return(99u8, msg);
- let returned = outcome.expect_err("missing slot should return msg");
+ let ReplyRoute::NoSlot(returned) = outcome else {
+ panic!("missing slot should return msg");
+ };
assert_eq!(returned.len(), original_len);
}
@@ -1104,14 +1326,16 @@ mod tests {
.expect("insert ok");
// Saturate the queue.
- reg.try_send_or_return(1u8, make_bus_msg())
- .expect("slot present")
- .expect("first send accepted");
+ assert!(matches!(
+ reg.try_send_or_return(1u8, make_bus_msg()),
+ ReplyRoute::Delivered(Ok(()))
+ ));
// Second send: slot present, queue full.
- let outcome = reg
- .try_send_or_return(1u8, make_bus_msg())
- .expect("slot still present");
- assert!(matches!(outcome, Err(async_channel::TrySendError::Full(_))));
+ let outcome = reg.try_send_or_return(1u8, make_bus_msg());
+ assert!(matches!(
+ outcome,
+ ReplyRoute::Delivered(Err(async_channel::TrySendError::Full(_)))
+ ));
}
/// Mirror of `try_send_or_return_returns_msg_when_slot_missing` for the
@@ -1127,4 +1351,152 @@ mod tests {
let returned = outcome.expect_err("missing slot should return msg");
assert_eq!(returned.len(), original_len);
}
+
+ /// End-to-end registry seam: install entry + slot, route, fire, await.
+ #[compio::test]
+ async fn in_process_slot_receives_fired_reply() {
+ let reg: ConnectionRegistry<u8> = ConnectionRegistry::new();
+ reg.insert_in_process(1u8).expect("fresh key");
+
+ let (guard, reply_rx) = reg.install_reply_slot(1u8, 42).expect("slot installs");
+ let msg = match reg.try_send_or_return(1u8, make_bus_msg()) {
+ ReplyRoute::InProcess(msg) => msg,
+ other => panic!("expected InProcess route, got {other:?}"),
+ };
+ reg.fire_in_process(1u8, 42, msg).expect("waiter present");
+
+ let received = reply_rx.await.expect("reply delivered");
+ assert_eq!(received.len(), HEADER_SIZE);
+ drop(guard);
+ assert!(reg.contains(1u8), "guard drop must not remove the entry");
+ }
+
+ #[compio::test]
+ async fn insert_in_process_duplicate_key_returns_none() {
+ let reg: ConnectionRegistry<u8> = ConnectionRegistry::new();
+ assert!(reg.insert_in_process(1u8).is_some());
+ assert!(reg.insert_in_process(1u8).is_none());
+ assert_eq!(reg.len(), 1);
+ }
+
+ #[compio::test]
+ async fn install_reply_slot_rejects_missing_and_socket_entries() {
+ let reg: ConnectionRegistry<u8> = ConnectionRegistry::new();
+ assert_eq!(
+ reg.install_reply_slot(1u8, 1).unwrap_err(),
+ ReplySlotError::EntryNotFound
+ );
+
+ let (_shutdown, token) = Shutdown::new();
+ let (tx, rx) = async_channel::bounded(8);
+ reg.insert(
+ 1u8,
+ tx,
+ spawn_dummy_writer(rx),
+ spawn_dummy_reader(token),
+ dummy_conn_shutdown(),
+ )
+ .expect("socket insert ok");
+ assert_eq!(
+ reg.install_reply_slot(1u8, 1).unwrap_err(),
+ ReplySlotError::NotInProcess
+ );
+ }
+
+ /// Second install under a live request id must be rejected, and the
+ /// first waiter must stay wired (the reply still reaches it).
+ #[compio::test]
+ async fn install_reply_slot_rejects_duplicate_request() {
+ let reg: ConnectionRegistry<u8> = ConnectionRegistry::new();
+ reg.insert_in_process(1u8).expect("fresh key");
+
+ let (_guard, reply_rx) = reg.install_reply_slot(1u8, 42).expect("first install");
+ assert_eq!(
+ reg.install_reply_slot(1u8, 42).unwrap_err(),
+ ReplySlotError::DuplicateRequest
+ );
+
+ reg.fire_in_process(1u8, 42, make_bus_msg())
+ .expect("first waiter still registered");
+ assert_eq!(reply_rx.await.expect("reply delivered").len(), HEADER_SIZE);
+ }
+
+ /// Reply for a request id nobody waits on: handed back, no panic, and
+ /// the unrelated slot stays registered.
+ #[compio::test]
+ async fn fire_in_process_unknown_request_returns_msg() {
+ let reg: ConnectionRegistry<u8> = ConnectionRegistry::new();
+ reg.insert_in_process(1u8).expect("fresh key");
+ let (_guard, reply_rx) = reg.install_reply_slot(1u8, 42).expect("slot installs");
+
+ let msg = make_bus_msg();
+ let original_len = msg.len();
+ let returned = reg
+ .fire_in_process(1u8, 7, msg)
+ .expect_err("no waiter for request 7");
+ assert_eq!(returned.len(), original_len);
+
+ reg.fire_in_process(1u8, 42, make_bus_msg())
+ .expect("slot 42 untouched");
+ assert_eq!(reply_rx.await.expect("reply delivered").len(), HEADER_SIZE);
+ }
+
+ /// Timeout / cancellation path: dropping the guard removes the slot,
+ /// cancels the receiver, and a later reply is shed as no-waiter.
+ #[compio::test]
+ async fn dropped_guard_removes_slot_and_cancels_receiver() {
+ let reg: ConnectionRegistry<u8> = ConnectionRegistry::new();
+ reg.insert_in_process(1u8).expect("fresh key");
+
+ let (guard, reply_rx) = reg.install_reply_slot(1u8, 42).expect("slot installs");
+ drop(guard);
+
+ assert!(reply_rx.await.is_err(), "sender dropped with the slot");
+ assert!(
+ reg.fire_in_process(1u8, 42, make_bus_msg()).is_err(),
+ "late reply must be handed back, not delivered"
+ );
+ let (_guard, _reply_rx) = reg
+ .install_reply_slot(1u8, 42)
+ .expect("request id reusable after guard drop");
+ }
+
+ /// After the reply fired the slot is gone; the guard's drop is a no-op
+ /// and the request id becomes reusable.
+ #[compio::test]
+ async fn guard_drop_is_noop_after_reply_fired() {
+ let reg: ConnectionRegistry<u8> = ConnectionRegistry::new();
+ reg.insert_in_process(1u8).expect("fresh key");
+
+ let (guard, reply_rx) = reg.install_reply_slot(1u8, 42).expect("slot installs");
+ reg.fire_in_process(1u8, 42, make_bus_msg())
+ .expect("waiter present");
+ drop(guard);
+
+ assert_eq!(reply_rx.await.expect("reply delivered").len(), HEADER_SIZE);
+ let (_guard, _reply_rx) = reg
+ .install_reply_slot(1u8, 42)
+ .expect("request id reusable after fire + drop");
+ }
+
+ /// Entry teardown with a pending waiter: the receiver resolves
+ /// `Canceled` instead of hanging, and the stale guard's later drop
+ /// must not disturb a reinstalled entry's slot (token fence).
+ #[compio::test]
+ async fn entry_teardown_cancels_pending_receiver_and_fences_stale_guard() {
+ let reg: ConnectionRegistry<u8> = ConnectionRegistry::new();
+ reg.insert_in_process(1u8).expect("fresh key");
+ let (stale_guard, reply_rx) = reg.install_reply_slot(1u8, 42).expect("slot installs");
+
+ assert!(reg.remove(1u8));
+ assert!(reply_rx.await.is_err(), "teardown cancels pending waiter");
+
+ reg.insert_in_process(1u8)
+ .expect("reinstall after teardown");
+ let (_guard, reply_rx) = reg.install_reply_slot(1u8, 42).expect("slot installs");
+ drop(stale_guard);
+ reg.fire_in_process(1u8, 42, make_bus_msg())
+ .expect("stale guard must not evict the new slot");
+ assert_eq!(reply_rx.await.expect("reply delivered").len(), HEADER_SIZE);
+ }
}
diff --git a/core/message_bus/src/lifecycle/mod.rs b/core/message_bus/src/lifecycle/mod.rs
index 4fa15b9e0..5081a516a 100644
--- a/core/message_bus/src/lifecycle/mod.rs
+++ b/core/message_bus/src/lifecycle/mod.rs
@@ -26,6 +26,6 @@ pub mod shutdown;
pub use connection_registry::{
BusMessage, BusReceiver, BusSender, ConnectionRegistry, DrainOutcome, InstanceToken,
- RejectedRegistration, ReplicaRegistry,
+ RejectedRegistration, ReplicaRegistry, ReplyRoute, ReplySlotError, ReplySlotGuard,
};
pub use shutdown::{FusedShutdown, Shutdown, ShutdownToken};
diff --git a/core/metadata/src/impls/metadata.rs b/core/metadata/src/impls/metadata.rs
index b331cafc9..0e3b3aec9 100644
--- a/core/metadata/src/impls/metadata.rs
+++ b/core/metadata/src/impls/metadata.rs
@@ -16,6 +16,7 @@
// under the License.
use crate::MuxStateMachine;
+use crate::stm::authz::gated_apply;
use crate::stm::consumer_group::CompleteConsumerGroupRevocationRequest;
use crate::stm::snapshot::{FillSnapshot, MetadataSnapshot, Snapshot, SnapshotError};
use crate::stm::stream::{Streams, TruncatePartitionRequest};
@@ -342,8 +343,8 @@ fn require_shard_zero<'a, T>(
slot
}
-/// Late-bound callback invoked after every successful
-/// `mux_stm.update(prepare)` on shard 0's metadata commit path.
+/// Late-bound callback invoked after every committed op on shard 0's metadata
+/// commit path (via `gated_apply`, including a gated no-op).
///
/// Wired by server-ng bootstrap once the metadata bundle has broadcast;
/// receives the committed [`Operation`] so the recipient can filter (the
@@ -376,9 +377,10 @@ pub struct IggyMetadata<C, J, S, M> {
pub coordinator: Option<SnapshotCoordinator<M>>,
/// Per-client session state (sessions, dedup, eviction). Metadata-only.
pub client_table: RefCell<ClientTable>,
- /// Late-bound post-commit notifier. Fires once per committed
- /// operation after [`crate::stm::StateMachine::update`] succeeds in
- /// both [`Plane::on_ack`] and [`Self::commit_journal`]. `None` until
+ /// Late-bound post-commit notifier. Fires once per committed normal op
+ /// after `gated_apply` returns (including a gated `Unauthorized` no-op that
+ /// never reaches [`crate::stm::StateMachine::update`]) in both
+ /// [`Plane::on_ack`] and [`Self::commit_journal`]. `None` until
/// [`Self::set_commit_notifier`] runs (server-ng bootstrap on shard
/// 0 sets it; peer shards and tests leave it `None`).
commit_notifier: RefCell<Option<CommitNotifier>>,
@@ -825,6 +827,7 @@ where
|c: u128| consensus.pipeline().borrow().has_message_from_client(c);
self.client_table.borrow_mut().commit_register(
prepare_header.client,
+ prepare_header.user_id,
reply.clone(),
in_flight,
);
@@ -847,7 +850,7 @@ where
// Normal op: apply SM, commit_reply. `Err` is decode/corruption
// only; a business rejection commits as a deterministic no-op
// whose `code` rides the reply body, replayed on retry.
- let apply = self.mux_stm.update(prepare).unwrap_or_else(|err| {
+ let apply = gated_apply(&self.mux_stm, prepare).unwrap_or_else(|err| {
panic!(
"on_ack: committed metadata op={} failed to apply: {err}",
prepare_header.op
@@ -985,6 +988,7 @@ where
pub async fn submit_register_in_process(
&self,
client_id: u128,
+ user_id: u32,
) -> Result<u64, MetadataSubmitError> {
assert!(client_id != 0, "client_id 0 is reserved for internal use");
let consensus = self
@@ -1025,7 +1029,7 @@ where
return Err(MetadataSubmitError::PipelineFull);
}
- let request = build_register_request_message(consensus, client_id);
+ let request = build_register_request_message(consensus, client_id, user_id);
// Wire path runs `RequestHeader::validate` at network boundary;
// in-process skips it. debug_assert pins drift.
debug_assert!(
@@ -1410,6 +1414,9 @@ where
.into_generic());
}
+ // The acting-user RBAC stamp lives in the shared `prepare_request`
+ // (op-guarded, fail-closed on an unknown session). `request_preflight`
+ // above proved this client's session is live, so it resolves there.
let Ok(prepare) = self.prepare_request(message) else {
// Structurally-invalid request. Return an eviction frame (relayed to
// the socket by the home shard) rather than `Canceled`, which leaves
@@ -1707,10 +1714,11 @@ where
#[allow(clippy::too_many_lines)]
fn prepare_request(
&self,
- message: Message<RequestHeader>,
+ mut message: Message<RequestHeader>,
) -> Result<Message<PrepareHeader>, iggy_common::IggyError> {
let consensus = self.consensus.as_ref().unwrap();
- let header = *message.header();
+ let operation = message.header().operation;
+ let client_id = message.header().client;
// `TruncatePartition` is server-originated (the owning shard resolves a
// client `DeleteSegments` count to a concrete offset) but replicated AS
// the client's own request, so the commit records the client's request
@@ -1718,10 +1726,39 @@ where
// maps to it, so a client cannot construct one directly -- hence the
// `is_client_allowed` gate excludes it; admit it explicitly. The default
// match arm below projects it through unchanged.
- if !header.operation.is_client_allowed() && header.operation != Operation::TruncatePartition
- {
+ if !operation.is_client_allowed() && operation != Operation::TruncatePartition {
return Err(IggyError::InvalidCommand);
}
+
+ // Stamp the acting user id into the replicated header so the in-apply
+ // RBAC gate (`crate::stm::authz`) resolves the same identity on every
+ // replica, WAL replay included (no session table there). Every client-op
+ // prepare funnels through here, primary-only by construction, so stamping
+ // here -- not in the in-process client path alone -- also covers the
+ // wire-plane ingresses (`on_request` and the request-queue drain). The
+ // wire `user_id` is never trusted: it is overwritten for every gated
+ // client op, and a client whose session is unknown is denied here
+ // (fail-closed), never defaulted to root. `Register` / `Logout` are exempt
+ // (see `resolve_acting_user_id`); server-originated internal ops
+ // (`CompleteConsumerGroupRevocation`, the PAT-cleaner delete) build their
+ // prepare directly, bypassing this path, and keep `user_id` 0 (gate skips).
+ if let Some(acting_user_id) =
+ resolve_acting_user_id(operation, client_id, &self.client_table)?
+ {
+ let request_header = bytemuck::checked::from_bytes_mut::<RequestHeader>(
+ &mut message.as_mut_slice()[..size_of::<RequestHeader>()],
+ );
+ request_header.user_id = acting_user_id;
+ }
+
+ // Must be read AFTER the stamp: the `CreateTopic` / `CreatePartitions`
+ // arms hand this `header` copy to `build_prepare_message`, so it has to
+ // carry the stamped acting user. Reading it before the stamp would ship
+ // those prepares with the untrusted wire `user_id` (0 = root from
+ // well-behaved SDKs, attacker-chosen otherwise), silently bypassing the
+ // authz gate. The default arm projects the mutated buffer directly and
+ // is order-independent.
+ let header = *message.header();
let body = &message.as_slice()[size_of::<RequestHeader>()..header.size as usize];
match header.operation {
@@ -1893,9 +1930,12 @@ where
// Register: commit_register creates session, no SM.
let reply = build_reply_message(&header, &bytes::Bytes::new());
let in_flight = |c: u128| consensus.pipeline().borrow().has_message_from_client(c);
- self.client_table
- .borrow_mut()
- .commit_register(header.client, reply, in_flight);
+ self.client_table.borrow_mut().commit_register(
+ header.client,
+ header.user_id,
+ reply,
+ in_flight,
+ );
} else if header.operation == Operation::Logout {
self.client_table.borrow_mut().remove_client(header.client);
// Mirror the on_ack path: drop the disconnected client from
@@ -1908,7 +1948,7 @@ where
// Normal op: apply SM, commit_reply. `Err` is decode/corruption
// only; a business rejection commits as a deterministic no-op
// whose `code` rides the reply body, replayed on retry.
- let apply = self.mux_stm.update(prepare).unwrap_or_else(|err| {
+ let apply = gated_apply(&self.mux_stm, prepare).unwrap_or_else(|err| {
panic!("commit_journal: committed metadata op={op} failed to apply: {err}");
});
// Post-commit notifier (e.g. partition reconciler
@@ -1993,6 +2033,7 @@ where
fn build_register_request_message<B, P>(
consensus: &VsrConsensus<B, P>,
client_id: u128,
+ user_id: u32,
) -> Message<RequestHeader>
where
B: MessageBus,
@@ -2014,6 +2055,8 @@ where
client: client_id,
session: 0,
request: 0,
+ // Replicated on the prepare so every replica resolves session -> user.
+ user_id,
// Route through the metadata consensus group. The chain-forwarded
// prepare is re-routed on each peer by namespace; a `0` here would
// hash to a non-zero shard with no metadata consensus and be
@@ -2179,10 +2222,11 @@ where
// `created_at` on every CreateStream/CreateTopic/CreatePartitions. The
// in-process callers that bypass `Project::project` build their prepare
// through this helper directly (the CreateTopic/CreatePartitions
- // assignment rewrites, and the PAT-cleaner delete); the stamp is
- // load-bearing for the creates and inert for the delete, whose apply
- // ignores it. Shared `next_monotonic_timestamp` keeps the in-process path
- // on the same monotonic-clock guard as the wire path.
+ // assignment rewrites, the UpdateTopic default-size rewrite, and the
+ // PAT-cleaner delete); the stamp is load-bearing for the creates and inert
+ // for the UpdateTopic rewrite and the delete, whose applies ignore it.
+ // Shared `next_monotonic_timestamp` keeps the in-process path on the same
+ // monotonic-clock guard as the wire path.
let timestamp = consensus.next_monotonic_timestamp();
*new_header = PrepareHeader {
cluster: consensus.cluster(),
@@ -2200,6 +2244,13 @@ where
timestamp,
operation,
namespace: request.namespace,
+ // Carry the acting user id so the in-apply RBAC gate sees the same
+ // identity on every replica. The default projection copies it (see
+ // `Project::project`); this helper builds prepares for the ops it
+ // rewrites (the CreateTopic/CreatePartitions assignment rewrites, the
+ // UpdateTopic default-size rewrite, and the PAT-cleaner delete), which
+ // would otherwise reset it to 0 via `..Default::default()`.
+ user_id: request.user_id,
..Default::default()
};
@@ -2216,12 +2267,48 @@ const fn eviction_reason_for_invalid(operation: Operation) -> EvictionReason {
}
}
+/// Resolve the acting user id to stamp into a client op's replicated
+/// `RequestHeader`, so the in-apply RBAC gate (`crate::stm::authz`) reads the
+/// same identity on every replica (WAL replay has no session table).
+///
+/// - `Ok(Some(id))`: overwrite the header's `user_id` with the committed
+/// session's user; the wire-supplied value is never trusted.
+/// - `Ok(None)`: leave it untouched. `Register` carries the credential-verified
+/// user from login (a mid-`Register` client has no session yet, so resolution
+/// would miss and fail-close, denying every login) and `Logout` is not gated
+/// -- both keep their builder value.
+/// - `Err(Unauthenticated)`: fail-closed. A client op whose `client_id` has no
+/// session cannot be attributed, so it is denied rather than defaulted to
+/// root (`user_id` 0 is the gate's all-allow short-circuit). Every caller
+/// preflights a live session first (`request_preflight` dispatches only on
+/// `New`), so this guards a future ingress that reaches here without one.
+fn resolve_acting_user_id(
+ operation: Operation,
+ client_id: u128,
+ client_table: &RefCell<ClientTable>,
+) -> Result<Option<u32>, IggyError> {
+ if matches!(operation, Operation::Register | Operation::Logout) {
+ return Ok(None);
+ }
+ client_table
+ .borrow()
+ .get_user_id(client_id)
+ .ok_or(IggyError::Unauthenticated)
+ .map(Some)
+}
+
#[cfg(test)]
mod tests {
use super::*;
use crate::stm::stream::Streams;
use crate::stm::user::Users;
+ use consensus::LocalPipeline;
+ use iggy_binary_protocol::requests::topics::CreateTopicRequest;
use iggy_common::variadic;
+ use journal::prepare_journal::PrepareJournal;
+ use message_bus::{ClientForwardFn, ConnectionLostFn, JoinHandle, ReplicaForwardFn, SendError};
+ use server_common::MESSAGE_ALIGN;
+ use server_common::iobuf::Frozen;
use std::cell::RefCell;
use std::rc::Rc;
@@ -2317,4 +2404,181 @@ mod tests {
"cleared notifier must stay quiet"
);
}
+
+ /// Minimal committed `Register` reply for `ClientTable::commit_register`,
+ /// which reads only `client` and `commit` (the assigned session).
+ fn register_reply(client: u128, session: u64) -> Message<ReplyHeader> {
+ let header_size = size_of::<ReplyHeader>();
+ let mut reply = Message::<ReplyHeader>::new(header_size);
+ let header = bytemuck::checked::try_from_bytes_mut::<ReplyHeader>(
+ &mut reply.as_mut_slice()[..header_size],
+ )
+ .expect("zeroed bytes are a valid ReplyHeader");
+ *header = ReplyHeader {
+ client,
+ request: 0,
+ commit: session,
+ command: Command2::Reply,
+ operation: Operation::Register,
+ ..Default::default()
+ };
+ reply
+ }
+
+ #[test]
+ fn resolve_acting_user_id_skips_register_and_logout() {
+ // Session-lifecycle ops keep the user id their request builder set
+ // (Register's login identity, Logout's ungated value); the ClientTable
+ // is never consulted, so an empty table still yields `Ok(None)`.
+ let client_table = RefCell::new(ClientTable::new(CLIENTS_TABLE_MAX));
+ for operation in [Operation::Register, Operation::Logout] {
+ assert!(
+ matches!(
+ resolve_acting_user_id(operation, 1, &client_table),
+ Ok(None)
+ ),
+ "{operation:?} must not be stamped"
+ );
+ }
+ }
+
+ #[test]
+ fn resolve_acting_user_id_stamps_from_client_table() {
+ // A gated client op takes the acting user from the committed session,
+ // independent of any wire-supplied header value.
+ const CLIENT: u128 = 1;
+ const SESSION: u64 = 10;
+ const ACTING_USER: u32 = 7;
+ let mut table = ClientTable::new(CLIENTS_TABLE_MAX);
+ table.commit_register(CLIENT, ACTING_USER, register_reply(CLIENT, SESSION), |_| {
+ false
+ });
+ let client_table = RefCell::new(table);
+
+ match resolve_acting_user_id(Operation::CreateStream, CLIENT, &client_table) {
+ Ok(Some(user_id)) => assert_eq!(user_id, ACTING_USER),
+ other => panic!("expected Ok(Some({ACTING_USER})), got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn resolve_acting_user_id_fails_closed_for_unknown_session() {
+ // A gated client op with no committed session is denied, never
+ // defaulted to root (user id 0).
+ let client_table = RefCell::new(ClientTable::new(CLIENTS_TABLE_MAX));
+ match resolve_acting_user_id(Operation::CreateStream, 999, &client_table) {
+ Err(IggyError::Unauthenticated) => {}
+ other => panic!("expected Err(Unauthenticated), got {other:?}"),
+ }
+ }
+
+ /// No-op bus: `prepare_request` builds a prepare without ever sending, so
+ /// every method is an unused stub.
+ #[derive(Debug, Default)]
+ struct NoopBus;
+
+ impl MessageBus for NoopBus {
+ fn track_background(&self, _handle: JoinHandle<()>) {}
+ async fn send_to_client(
+ &self,
+ _client_id: u128,
+ _data: Frozen<MESSAGE_ALIGN>,
+ ) -> Result<(), SendError> {
+ Ok(())
+ }
+ async fn send_to_replica(
+ &self,
+ _replica: u8,
+ _data: Frozen<MESSAGE_ALIGN>,
+ ) -> Result<(), SendError> {
+ Ok(())
+ }
+ fn set_connection_lost_fn(&self, _f: ConnectionLostFn) {}
+ fn set_replica_forward_fn(&self, _f: ReplicaForwardFn) {}
+ fn set_client_forward_fn(&self, _f: ClientForwardFn) {}
+ }
+
+ /// Single-node metadata plane whose `prepare_request` is callable. `J` is
+ /// `PrepareJournal` in name only (the value is `None`) to satisfy the
+ /// impl-block bound; no journal or snapshot is constructed.
+ fn metadata_plane() -> IggyMetadata<VsrConsensus<NoopBus>, PrepareJournal, (), TestMux> {
+ let consensus = VsrConsensus::new(
+ 1,
+ 0,
+ 1,
+ server_common::sharding::METADATA_CONSENSUS_NAMESPACE,
+ NoopBus,
+ LocalPipeline::new(),
+ );
+ consensus.init();
+ IggyMetadata::new(Some(consensus), None, None, TestMux::default(), None)
+ }
+
+ fn create_topic_request(client: u128, wire_user_id: u32) -> Message<RequestHeader> {
+ let body = CreateTopicRequest {
+ stream_id: WireIdentifier::numeric(1),
+ partitions_count: 1,
+ compression_algorithm: 0,
+ message_expiry: 0,
+ max_topic_size: 0,
+ replication_factor: 1,
+ name: WireName::new("t").unwrap(),
+ }
+ .to_bytes();
+ let header_size = size_of::<RequestHeader>();
+ let total = header_size + body.len();
+ let mut message = Message::<RequestHeader>::new(total);
+ {
+ let slice = message.as_mut_slice();
+ slice[header_size..total].copy_from_slice(&body);
+ let header =
+ bytemuck::checked::from_bytes_mut::<RequestHeader>(&mut slice[..header_size]);
+ *header = RequestHeader {
+ command: Command2::Request,
+ operation: Operation::CreateTopic,
+ size: u32::try_from(total).unwrap(),
+ client,
+ session: 1,
+ request: 1,
+ user_id: wire_user_id,
+ namespace: server_common::sharding::METADATA_CONSENSUS_NAMESPACE,
+ ..Default::default()
+ };
+ }
+ message
+ }
+
+ #[test]
+ fn prepare_request_stamps_create_topic_from_client_table_not_wire() {
+ // `CreateTopic` is the projection that reaches `build_prepare_message`
+ // through the post-stamp `header` copy, so the built prepare must carry
+ // the ClientTable identity, not the (bogus) wire value. This pins the
+ // stamp-then-re-read ordering: a hoist would ship the untrusted wire
+ // value.
+ const CLIENT: u128 = 1;
+ const SESSION: u64 = 10;
+ const ACTING_USER: u32 = 7;
+ const WIRE_USER: u32 = 999;
+ let plane = metadata_plane();
+ plane.client_table.borrow_mut().commit_register(
+ CLIENT,
+ ACTING_USER,
+ register_reply(CLIENT, SESSION),
+ |_| false,
+ );
+
+ let prepare = plane
+ .prepare_request(create_topic_request(CLIENT, WIRE_USER))
+ .expect("CreateTopic is client-allowed");
+ assert_eq!(
+ prepare.header().operation,
+ Operation::CreateTopicWithAssignments,
+ "CreateTopic projects to the enriched form"
+ );
+ assert_eq!(
+ prepare.header().user_id,
+ ACTING_USER,
+ "prepare must carry the ClientTable identity, not the wire value"
+ );
+ }
}
diff --git a/core/metadata/src/impls/recovery.rs b/core/metadata/src/impls/recovery.rs
index a32e558f9..4268c1633 100644
--- a/core/metadata/src/impls/recovery.rs
+++ b/core/metadata/src/impls/recovery.rs
@@ -17,6 +17,7 @@
use crate::impls::metadata::IggySnapshot;
use crate::stm::StateMachine;
+use crate::stm::authz::GatedApply;
use crate::stm::snapshot::{MetadataSnapshot, RestoreSnapshot, Snapshot, SnapshotError};
use iggy_binary_protocol::consensus::PrepareHeader;
use iggy_common::IggyError;
@@ -107,6 +108,7 @@ pub struct RecoveredMetadata<M> {
pub async fn recover<M>(data_dir: &Path) -> Result<RecoveredMetadata<M>, RecoveryError>
where
M: StateMachine<Input = Message<PrepareHeader>, Error = IggyError>
+ + GatedApply
+ RestoreSnapshot<MetadataSnapshot>
+ Default,
{
@@ -148,7 +150,9 @@ where
format!("failed to read journal entry for op={}", header.op),
))
})?;
- mux_stm.update(entry)?;
+ // WAL replay must recompute authorization denials identically to the
+ // primary/backup commit paths, so it goes through the same gate.
+ mux_stm.gated_update(entry)?;
last_applied_op = Some(header.op);
}
diff --git a/core/metadata/src/permissioner/permissioner_rules/system.rs b/core/metadata/src/permissioner/permissioner_rules/system.rs
index 720067afb..4a11c8c96 100644
--- a/core/metadata/src/permissioner/permissioner_rules/system.rs
+++ b/core/metadata/src/permissioner/permissioner_rules/system.rs
@@ -32,6 +32,10 @@ impl Permissioner {
self.get_server_info(user_id)
}
+ pub fn get_snapshot(&self, user_id: u32) -> Result<(), IggyError> {
+ self.get_server_info(user_id)
+ }
+
fn get_server_info(&self, user_id: u32) -> Result<(), IggyError> {
if let Some(global_permissions) = self.users_permissions.get(&user_id)
&& (global_permissions.manage_servers || global_permissions.read_servers)
diff --git a/core/metadata/src/stm/authz.rs b/core/metadata/src/stm/authz.rs
new file mode 100644
index 000000000..00ea17416
--- /dev/null
+++ b/core/metadata/src/stm/authz.rs
@@ -0,0 +1,760 @@
+// 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.
+
+//! In-apply control-plane authorization for the metadata state machine.
+//!
+//! Every committed client op is gated here before its `StateHandler::apply`
+//! runs. A denial returns an `Unauthorized` [`ApplyReply`] WITHOUT touching
+//! state, so the op commits as a deterministic no-op whose error rides the
+//! cached reply and replays on retry, exactly like a business rejection.
+//!
+//! Replay-determinism invariant: [`authorize`] is a pure function of the
+//! prepare header (`operation` + the acting `user_id`, stamped into the
+//! replicated header at submit time) and the committed permission/stream
+//! state as of the op immediately before this one. That state is applied in
+//! the same order on every replica, so the primary (`on_ack`), each backup
+//! (`commit_journal`), and WAL replay (`recover`) all recompute the identical
+//! decision. This is why identity must ride the header rather than being
+//! resolved from a local session table: a backup has no session for the
+//! acting client.
+
+use crate::MuxStateMachine;
+use crate::impls::metadata::StreamsFrontend;
+use crate::permissioner::Permissioner;
+use crate::stm::StateMachine;
+use crate::stm::consumer_group::{JoinConsumerGroupRequest, LeaveConsumerGroupRequest};
+use crate::stm::result::ApplyReply;
+use crate::stm::stream::{Streams, TruncatePartitionRequest};
+use crate::stm::user::Users;
+use iggy_binary_protocol::requests::consumer_groups::{
+ CreateConsumerGroupRequest, DeleteConsumerGroupRequest,
+};
+use iggy_binary_protocol::requests::partitions::{
+ CreatePartitionsWithAssignmentsRequest, DeletePartitionsRequest,
+};
+use iggy_binary_protocol::requests::streams::{
+ DeleteStreamRequest, PurgeStreamRequest, UpdateStreamRequest,
+};
+use iggy_binary_protocol::requests::topics::{
+ CreateTopicWithAssignmentsRequest, DeleteTopicRequest, PurgeTopicRequest, UpdateTopicRequest,
+};
+use iggy_binary_protocol::requests::users::ChangePasswordRequest;
+use iggy_binary_protocol::{Operation, PrepareHeader, WireDecode, WireIdentifier};
+use iggy_common::{IggyError, variadic};
+use server_common::Message;
+use std::mem::size_of;
+
+/// Gate a committed prepare, then apply it. A denial commits as an
+/// `Unauthorized` no-op (the gate never mutates state); an allow proceeds to
+/// the normal state-machine dispatch. This is the single funnel every
+/// production apply path uses, so no committed op can bypass the RBAC check.
+///
+/// The primary (`on_ack`) and backup (`commit_journal`) paths hold the concrete
+/// mux and call this directly; WAL replay (`recover`) is generic over the state
+/// machine and reaches it through [`GatedApply`].
+///
+/// # Errors
+/// Propagates the underlying [`StateMachine::update`] error (decode / corruption
+/// of a committed body), which is fatal on the commit path.
+pub(crate) fn gated_apply<M>(
+ mux: &M,
+ prepare: Message<PrepareHeader>,
+) -> Result<ApplyReply, IggyError>
+where
+ M: StreamsFrontend
+ + StateMachine<Input = Message<PrepareHeader>, Output = ApplyReply, Error = IggyError>,
+{
+ if let Some(denied) = authorize(&prepare, mux.users(), mux.streams()) {
+ return Ok(denied);
+ }
+ mux.update(prepare)
+}
+
+/// Generic entry point to [`gated_apply`] for the WAL-replay path.
+///
+/// The replay path is generic over the state machine and cannot name the
+/// concrete accessors, so it dispatches through this trait. Implemented only
+/// for the metadata mux (and, in tests, the empty state machine used by the
+/// recovery harness).
+pub trait GatedApply:
+ StateMachine<Input = Message<PrepareHeader>, Output = ApplyReply, Error = IggyError>
+{
+ /// See [`gated_apply`].
+ ///
+ /// # Errors
+ /// Propagates the underlying [`StateMachine::update`] error.
+ fn gated_update(&self, prepare: Message<PrepareHeader>) -> Result<ApplyReply, IggyError>;
+}
+
+impl GatedApply for MuxStateMachine<variadic!(Users, Streams)> {
+ fn gated_update(&self, prepare: Message<PrepareHeader>) -> Result<ApplyReply, IggyError> {
+ gated_apply(self, prepare)
+ }
+}
+
+/// The root user is always the first user created (slab id 0) and holds every
+/// grant. Short-circuited below rather than checked against the permissioner:
+/// server-originated ops carry `user_id` 0 with no session, and WAL replay
+/// gates committed ops before the unreplicated root bootstrap
+/// (`ensure_root_user`) has populated the permissioner, so the gate must not
+/// depend on permissioner contents.
+const ROOT_USER_ID: u32 = 0;
+
+/// Decides whether the acting user may run this committed op.
+///
+/// `Some(reply)` denies with a committed `Unauthorized` no-op; `None` allows
+/// the op to reach its `StateHandler::apply`. Only control-plane client ops are
+/// checked; internal / server-originated ops (which carry `user_id` 0), the
+/// self-scoped personal-access-token ops (no legacy RBAC), the pre-projection
+/// wire forms, and partition-plane ops all pass through.
+///
+/// Object ids are resolved against committed stream state first; a resolution
+/// miss passes through so `apply` produces its own `NotFound`, preserving the
+/// legacy notfound-before-permission ordering.
+#[allow(clippy::too_many_lines)]
+pub(crate) fn authorize(
+ prepare: &Message<PrepareHeader>,
+ users: &Users,
+ streams: &Streams,
+) -> Option<ApplyReply> {
+ let header = prepare.header();
+ let user_id = header.user_id;
+
+ if user_id == ROOT_USER_ID {
+ return None;
+ }
+ let body = &prepare.as_slice()[size_of::<PrepareHeader>()..header.size as usize];
+
+ match header.operation {
+ // Streams. `create_stream` is unscoped; the rest resolve the stream id.
+ Operation::CreateStream => check(users, |perm| perm.create_stream(user_id)),
+ Operation::UpdateStream => {
+ let Ok(request) = UpdateStreamRequest::decode_from(body) else {
+ return None;
+ };
+ stream_scoped(users, streams, &request.stream_id, |perm, sid| {
+ perm.update_stream(user_id, sid)
+ })
+ }
+ Operation::DeleteStream => {
+ let Ok(request) = DeleteStreamRequest::decode_from(body) else {
+ return None;
+ };
+ stream_scoped(users, streams, &request.stream_id, |perm, sid| {
+ perm.delete_stream(user_id, sid)
+ })
+ }
+ Operation::PurgeStream => {
+ let Ok(request) = PurgeStreamRequest::decode_from(body) else {
+ return None;
+ };
+ stream_scoped(users, streams, &request.stream_id, |perm, sid| {
+ perm.purge_stream(user_id, sid)
+ })
+ }
+
+ // Topics. The wire `CreateTopic` is projected to
+ // `CreateTopicWithAssignments` before it reaches apply, so only the
+ // enriched form is gated. `create_topic` is stream-scoped.
+ Operation::CreateTopicWithAssignments => {
+ let Ok(request) = CreateTopicWithAssignmentsRequest::decode_from(body) else {
+ return None;
+ };
+ stream_scoped(users, streams, &request.request.stream_id, |perm, sid| {
+ perm.create_topic(user_id, sid)
+ })
+ }
+ Operation::UpdateTopic => {
+ let Ok(request) = UpdateTopicRequest::decode_from(body) else {
+ return None;
+ };
+ topic_scoped(
+ users,
+ streams,
+ &request.stream_id,
+ &request.topic_id,
+ |perm, sid, tid| perm.update_topic(user_id, sid, tid),
+ )
+ }
+ Operation::DeleteTopic => {
+ let Ok(request) = DeleteTopicRequest::decode_from(body) else {
+ return None;
+ };
+ topic_scoped(
+ users,
+ streams,
+ &request.stream_id,
+ &request.topic_id,
+ |perm, sid, tid| perm.delete_topic(user_id, sid, tid),
+ )
+ }
+ Operation::PurgeTopic => {
+ let Ok(request) = PurgeTopicRequest::decode_from(body) else {
+ return None;
+ };
+ topic_scoped(
+ users,
+ streams,
+ &request.stream_id,
+ &request.topic_id,
+ |perm, sid, tid| perm.purge_topic(user_id, sid, tid),
+ )
+ }
+
+ // Partitions. Wire `CreatePartitions` is projected to the enriched
+ // form; `DeleteSegments` is resolved server-side to `TruncatePartition`
+ // carrying the original client's id.
+ Operation::CreatePartitionsWithAssignments => {
+ let Ok(request) = CreatePartitionsWithAssignmentsRequest::decode_from(body) else {
+ return None;
+ };
+ topic_scoped(
+ users,
+ streams,
+ &request.request.stream_id,
+ &request.request.topic_id,
+ |perm, sid, tid| perm.create_partitions(user_id, sid, tid),
+ )
+ }
+ Operation::DeletePartitions => {
+ let Ok(request) = DeletePartitionsRequest::decode_from(body) else {
+ return None;
+ };
+ topic_scoped(
+ users,
+ streams,
+ &request.stream_id,
+ &request.topic_id,
+ |perm, sid, tid| perm.delete_partitions(user_id, sid, tid),
+ )
+ }
+ Operation::TruncatePartition => {
+ let Ok(request) = TruncatePartitionRequest::decode_from(body) else {
+ return None;
+ };
+ topic_scoped(
+ users,
+ streams,
+ &request.stream_id,
+ &request.topic_id,
+ |perm, sid, tid| perm.delete_segments(user_id, sid, tid),
+ )
+ }
+
+ // Consumer groups. Join/Leave carry the enriched request types that add
+ // the VSR client id; Create/Delete use the wire types.
+ Operation::CreateConsumerGroup => {
+ let Ok(request) = CreateConsumerGroupRequest::decode_from(body) else {
+ return None;
+ };
+ topic_scoped(
+ users,
+ streams,
+ &request.stream_id,
+ &request.topic_id,
+ |perm, sid, tid| perm.create_consumer_group(user_id, sid, tid),
+ )
+ }
+ Operation::DeleteConsumerGroup => {
+ let Ok(request) = DeleteConsumerGroupRequest::decode_from(body) else {
+ return None;
+ };
+ topic_scoped(
+ users,
+ streams,
+ &request.stream_id,
+ &request.topic_id,
+ |perm, sid, tid| perm.delete_consumer_group(user_id, sid, tid),
+ )
+ }
+ Operation::JoinConsumerGroup => {
+ let Ok(request) = JoinConsumerGroupRequest::decode_from(body) else {
+ return None;
+ };
+ topic_scoped(
+ users,
+ streams,
+ &request.stream_id,
+ &request.topic_id,
+ |perm, sid, tid| perm.join_consumer_group(user_id, sid, tid),
+ )
+ }
+ Operation::LeaveConsumerGroup => {
+ let Ok(request) = LeaveConsumerGroupRequest::decode_from(body) else {
+ return None;
+ };
+ topic_scoped(
+ users,
+ streams,
+ &request.stream_id,
+ &request.topic_id,
+ |perm, sid, tid| perm.leave_consumer_group(user_id, sid, tid),
+ )
+ }
+
+ // Users. Unscoped `manage_users`, except `ChangePassword` which the
+ // legacy execution layer exempts when the target is the acting user.
+ Operation::CreateUser => check(users, |perm| perm.create_user(user_id)),
+ Operation::DeleteUser => check(users, |perm| perm.delete_user(user_id)),
+ Operation::UpdateUser => check(users, |perm| perm.update_user(user_id)),
+ Operation::UpdatePermissions => check(users, |perm| perm.update_permissions(user_id)),
+ Operation::ChangePassword => {
+ let Ok(request) = ChangePasswordRequest::decode_from(body) else {
+ return None;
+ };
+ match users.read(|inner| inner.resolve_user_id(&request.user_id)) {
+ // Self-service password change: allowed without `manage_users`.
+ Some(target_id) if target_id == user_id as usize => None,
+ Some(_) => check(users, |perm| perm.change_password(user_id)),
+ // Unknown target passes through to apply's `UserNotFound`.
+ None => None,
+ }
+ }
+
+ // Not gated (listed exhaustively so a new op cannot silently skip the
+ // gate): VSR-reserved ops (never reach apply as client ops),
+ // server-originated internal ops (user_id 0), self-scoped PAT ops (no
+ // legacy RBAC), the pre-projection wire forms, and every partition op.
+ Operation::Reserved
+ | Operation::Register
+ | Operation::NonReplicated
+ | Operation::Logout
+ | Operation::RemoveConsumerGroupMember
+ | Operation::CompleteConsumerGroupRevocation
+ | Operation::CreateTopic
+ | Operation::CreatePartitions
+ | Operation::DeleteSegments
+ | Operation::CreatePersonalAccessToken
+ | Operation::DeletePersonalAccessToken
+ | Operation::SendMessages
+ | Operation::StoreConsumerOffset
+ | Operation::DeleteConsumerOffset
+ | Operation::StoreConsumerOffset2
+ | Operation::DeleteConsumerOffset2 => None,
+ }
+}
+
+/// Maps a permissioner rule outcome to a gate decision: any `Err` (always
+/// `Unauthorized`) denies with a committed `Unauthorized` reply.
+fn check(
+ users: &Users,
+ rule: impl FnOnce(&Permissioner) -> Result<(), IggyError>,
+) -> Option<ApplyReply> {
+ match users.read(|inner| rule(&inner.permissioner)) {
+ Ok(()) => None,
+ Err(error) => Some(ApplyReply::err(error.as_code())),
+ }
+}
+
+/// Resolves a stream id, then runs a stream-scoped rule. A resolution miss
+/// passes through (returns `None`) so apply surfaces its own `NotFound`.
+fn stream_scoped(
+ users: &Users,
+ streams: &Streams,
+ stream_id: &WireIdentifier,
+ rule: impl FnOnce(&Permissioner, usize) -> Result<(), IggyError>,
+) -> Option<ApplyReply> {
+ let stream_id = streams.read(|inner| inner.resolve_stream_id(stream_id))?;
+ check(users, |perm| rule(perm, stream_id))
+}
+
+/// Resolves a (stream, topic) pair, then runs a topic-scoped rule. A miss on
+/// either passes through so apply surfaces its own `NotFound`.
+fn topic_scoped(
+ users: &Users,
+ streams: &Streams,
+ stream_id: &WireIdentifier,
+ topic_id: &WireIdentifier,
+ rule: impl FnOnce(&Permissioner, usize, usize) -> Result<(), IggyError>,
+) -> Option<ApplyReply> {
+ let (stream_id, topic_id) = streams.read(|inner| {
+ let stream_id = inner.resolve_stream_id(stream_id)?;
+ let topic_id = inner.resolve_topic_id(stream_id, topic_id)?;
+ Some((stream_id, topic_id))
+ })?;
+ check(users, |perm| rule(perm, stream_id, topic_id))
+}
+
+/// The recovery unit tests exercise WAL-replay mechanics with an empty state
+/// list (`MuxStateMachine<()>`), which has no permission state and so needs no
+/// gate. Production always uses the `variadic!(Users, Streams)` machine above.
+#[cfg(test)]
+impl GatedApply for MuxStateMachine<()> {
+ fn gated_update(&self, prepare: Message<PrepareHeader>) -> Result<ApplyReply, IggyError> {
+ self.update(prepare)
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::cast_possible_truncation)]
+mod tests {
+ use super::*;
+ use iggy_binary_protocol::primitives::partition_assignment::CreatedPartitionAssignment;
+ use iggy_binary_protocol::primitives::permissions::{
+ WireGlobalPermissions, WirePermissions, WireStreamPermissions,
+ };
+ use iggy_binary_protocol::requests::streams::CreateStreamRequest;
+ use iggy_binary_protocol::requests::topics::CreateTopicRequest;
+ use iggy_binary_protocol::requests::users::CreateUserRequest;
+ use iggy_binary_protocol::{Command2, WireEncode, WireName};
+ use iggy_common::UserStatus;
+ use server_common::iobuf::Owned;
+
+ const HEADER_SIZE: usize = size_of::<PrepareHeader>();
+ /// Root is the first user, so it takes slab id 0; the first user created
+ /// after it is id 1.
+ const ROOT: u32 = 0;
+ const ALICE: u32 = 1;
+
+ fn mux() -> MuxStateMachine<variadic!(Users, Streams)> {
+ let users = Users::default();
+ users.ensure_root_user("iggy", "hash");
+ let streams = Streams::default();
+ MuxStateMachine::new(variadic!(users, streams))
+ }
+
+ fn make_prepare(
+ op: u64,
+ operation: Operation,
+ user_id: u32,
+ body: &[u8],
+ ) -> Message<PrepareHeader> {
+ let total = HEADER_SIZE + body.len();
+ let mut buffer = Owned::<4096>::zeroed(total);
+ {
+ let header = bytemuck::checked::from_bytes_mut::<PrepareHeader>(
+ &mut buffer.as_mut_slice()[..HEADER_SIZE],
+ );
+ header.command = Command2::Prepare;
+ header.operation = operation;
+ header.op = op;
+ header.user_id = user_id;
+ header.size = u32::try_from(total).unwrap();
+ }
+ buffer.as_mut_slice()[HEADER_SIZE..].copy_from_slice(body);
+ Message::try_from(buffer).unwrap()
+ }
+
+ fn no_grants() -> WireGlobalPermissions {
+ WireGlobalPermissions {
+ manage_servers: false,
+ read_servers: false,
+ manage_users: false,
+ read_users: false,
+ manage_streams: false,
+ read_streams: false,
+ manage_topics: false,
+ read_topics: false,
+ poll_messages: false,
+ send_messages: false,
+ }
+ }
+
+ fn create_user_body(
+ name: &str,
+ global: WireGlobalPermissions,
+ streams: Vec<WireStreamPermissions>,
+ ) -> bytes::Bytes {
+ CreateUserRequest {
+ username: WireName::new(name).unwrap(),
+ password: "hash".to_string(),
+ status: UserStatus::Active.as_code(),
+ permissions: Some(WirePermissions { global, streams }),
+ }
+ .to_bytes()
+ }
+
+ fn create_stream_body(name: &str) -> bytes::Bytes {
+ CreateStreamRequest {
+ name: WireName::new(name).unwrap(),
+ }
+ .to_bytes()
+ }
+
+ fn create_topic_body(stream_id: u32, name: &str) -> bytes::Bytes {
+ CreateTopicWithAssignmentsRequest {
+ request: CreateTopicRequest {
+ stream_id: WireIdentifier::numeric(stream_id),
+ partitions_count: 1,
+ compression_algorithm: 0,
+ message_expiry: 0,
+ max_topic_size: 0,
+ replication_factor: 1,
+ name: WireName::new(name).unwrap(),
+ },
+ partitions: vec![CreatedPartitionAssignment {
+ partition_id: 0,
+ consensus_group_id: 1,
+ }],
+ }
+ .to_bytes()
+ }
+
+ fn change_password_body(target: &str) -> bytes::Bytes {
+ ChangePasswordRequest {
+ user_id: WireIdentifier::named(target).unwrap(),
+ current_password: String::new(),
+ new_password: "new-hash".to_string(),
+ }
+ .to_bytes()
+ }
+
+ fn stream_manage_topics(stream_id: u32) -> WireStreamPermissions {
+ WireStreamPermissions {
+ stream_id,
+ manage_stream: false,
+ read_stream: false,
+ manage_topics: true,
+ read_topics: false,
+ poll_messages: false,
+ send_messages: false,
+ topics: Vec::new(),
+ }
+ }
+
+ const UNAUTHORIZED: u32 = 41;
+
+ #[test]
+ fn given_ungranted_user_when_create_stream_should_deny_and_leave_state_unchanged() {
+ let mux = mux();
+ assert_eq!(
+ mux.gated_update(make_prepare(
+ 1,
+ Operation::CreateUser,
+ ROOT,
+ &create_user_body("alice", no_grants(), Vec::new()),
+ ))
+ .unwrap()
+ .code,
+ 0,
+ "root may create a user"
+ );
+
+ let reply = mux
+ .gated_update(make_prepare(
+ 2,
+ Operation::CreateStream,
+ ALICE,
+ &create_stream_body("s1"),
+ ))
+ .unwrap();
+
+ assert_eq!(reply.code, UNAUTHORIZED);
+ assert_eq!(
+ mux.streams().read(|inner| inner.items.len()),
+ 0,
+ "denied op must not mutate stream state"
+ );
+ }
+
+ #[test]
+ fn given_manage_streams_grant_when_create_stream_should_allow() {
+ let mux = mux();
+ let grants = WireGlobalPermissions {
+ manage_streams: true,
+ ..no_grants()
+ };
+ mux.gated_update(make_prepare(
+ 1,
+ Operation::CreateUser,
+ ROOT,
+ &create_user_body("alice", grants, Vec::new()),
+ ))
+ .unwrap();
+
+ let reply = mux
+ .gated_update(make_prepare(
+ 2,
+ Operation::CreateStream,
+ ALICE,
+ &create_stream_body("s1"),
+ ))
+ .unwrap();
+
+ assert_eq!(reply.code, 0);
+ assert_eq!(mux.streams().read(|inner| inner.items.len()), 1);
+ }
+
+ #[test]
+ fn given_stream_scoped_topics_grant_when_create_topic_should_allow_only_that_stream() {
+ let mux = mux();
+ // Streams 0 and 1 (StreamsInner slab is separate from the user slab).
+ mux.gated_update(make_prepare(
+ 1,
+ Operation::CreateStream,
+ ROOT,
+ &create_stream_body("s0"),
+ ))
+ .unwrap();
+ mux.gated_update(make_prepare(
+ 2,
+ Operation::CreateStream,
+ ROOT,
+ &create_stream_body("s1"),
+ ))
+ .unwrap();
+ // Alice may manage topics on stream 0 only.
+ mux.gated_update(make_prepare(
+ 3,
+ Operation::CreateUser,
+ ROOT,
+ &create_user_body("alice", no_grants(), vec![stream_manage_topics(0)]),
+ ))
+ .unwrap();
+
+ let allowed = mux
+ .gated_update(make_prepare(
+ 4,
+ Operation::CreateTopicWithAssignments,
+ ALICE,
+ &create_topic_body(0, "t"),
+ ))
+ .unwrap();
+ assert_eq!(allowed.code, 0, "grant covers stream 0");
+
+ let denied = mux
+ .gated_update(make_prepare(
+ 5,
+ Operation::CreateTopicWithAssignments,
+ ALICE,
+ &create_topic_body(1, "t"),
+ ))
+ .unwrap();
+ assert_eq!(denied.code, UNAUTHORIZED, "grant does not cover stream 1");
+ }
+
+ #[test]
+ fn given_change_password_when_target_is_self_should_allow_else_deny() {
+ let mux = mux();
+ mux.gated_update(make_prepare(
+ 1,
+ Operation::CreateUser,
+ ROOT,
+ &create_user_body("alice", no_grants(), Vec::new()),
+ ))
+ .unwrap();
+ mux.gated_update(make_prepare(
+ 2,
+ Operation::CreateUser,
+ ROOT,
+ &create_user_body("bob", no_grants(), Vec::new()),
+ ))
+ .unwrap();
+
+ let own = mux
+ .gated_update(make_prepare(
+ 3,
+ Operation::ChangePassword,
+ ALICE,
+ &change_password_body("alice"),
+ ))
+ .unwrap();
+ assert_eq!(
+ own.code, 0,
+ "self password change is exempt from manage_users"
+ );
+
+ let other = mux
+ .gated_update(make_prepare(
+ 4,
+ Operation::ChangePassword,
+ ALICE,
+ &change_password_body("bob"),
+ ))
+ .unwrap();
+ assert_eq!(
+ other.code, UNAUTHORIZED,
+ "changing another user needs manage_users"
+ );
+ }
+
+ #[test]
+ fn given_internal_revocation_op_when_authorized_should_skip_the_gate() {
+ let mux = mux();
+ let prepare = make_prepare(1, Operation::CompleteConsumerGroupRevocation, ROOT, &[]);
+ assert!(
+ authorize(&prepare, mux.users(), mux.streams()).is_none(),
+ "server-originated revocation must not be gated"
+ );
+ }
+
+ #[test]
+ fn given_non_root_user_when_create_pat_should_skip_the_gate() {
+ let mux = mux();
+ mux.gated_update(make_prepare(
+ 1,
+ Operation::CreateUser,
+ ROOT,
+ &create_user_body("alice", no_grants(), Vec::new()),
+ ))
+ .unwrap();
+
+ let prepare = make_prepare(2, Operation::CreatePersonalAccessToken, ALICE, &[]);
+ assert!(
+ authorize(&prepare, mux.users(), mux.streams()).is_none(),
+ "PAT ops are self-scoped (parity: no legacy RBAC)"
+ );
+ }
+
+ // The linchpin: a sequence containing a denied op must produce byte-identical
+ // codes and final state whether applied once (primary on_ack) or replayed
+ // fresh (backup commit_journal / recover). Identity rides the header, so no
+ // session table is consulted.
+ #[test]
+ fn given_sequence_with_denial_when_replayed_fresh_should_be_state_identical() {
+ fn sequence() -> Vec<Message<PrepareHeader>> {
+ vec![
+ make_prepare(
+ 1,
+ Operation::CreateUser,
+ ROOT,
+ &create_user_body("alice", no_grants(), Vec::new()),
+ ),
+ make_prepare(
+ 2,
+ Operation::CreateStream,
+ ROOT,
+ &create_stream_body("s-root"),
+ ),
+ // Alice lacks manage_streams: denied on both passes.
+ make_prepare(
+ 3,
+ Operation::CreateStream,
+ ALICE,
+ &create_stream_body("s-alice"),
+ ),
+ ]
+ }
+
+ let primary = mux();
+ let primary_codes: Vec<u32> = sequence()
+ .into_iter()
+ .map(|prepare| primary.gated_update(prepare).unwrap().code)
+ .collect();
+
+ let replay = mux();
+ let replay_codes: Vec<u32> = sequence()
+ .into_iter()
+ .map(|prepare| replay.gated_update(prepare).unwrap().code)
+ .collect();
+
+ assert_eq!(primary_codes, replay_codes, "replay must be deterministic");
+ assert_eq!(primary_codes[2], UNAUTHORIZED, "alice's create is denied");
+ assert_eq!(primary.streams().read(|inner| inner.items.len()), 1);
+ assert_eq!(replay.streams().read(|inner| inner.items.len()), 1);
+ }
+}
diff --git a/core/metadata/src/stm/consumer_group.rs b/core/metadata/src/stm/consumer_group.rs
index e99b16fdb..c85390ad9 100644
--- a/core/metadata/src/stm/consumer_group.rs
+++ b/core/metadata/src/stm/consumer_group.rs
@@ -505,8 +505,21 @@ impl StateHandler for CreateConsumerGroupRequest {
state: &mut StreamsInner,
_timestamp: iggy_common::IggyTimestamp,
) -> ApplyReply {
- let Some(topic) = state.topic_mut(&self.stream_id, &self.topic_id) else {
- return ApplyReply::ok(Bytes::new());
+ // A missing parent is a committed rejection, mirroring `CreateTopic`
+ // on a missing stream. Resolve level by level so the error names the
+ // level that missed.
+ let Some(stream_id) = state.resolve_stream_id(&self.stream_id) else {
+ return ApplyReply::err(CreateConsumerGroupResult::StreamNotFound);
+ };
+ let Some(topic_id) = state.resolve_topic_id(stream_id, &self.topic_id) else {
+ return ApplyReply::err(CreateConsumerGroupResult::TopicNotFound);
+ };
+ let Some(topic) = state
+ .items
+ .get_mut(stream_id)
+ .and_then(|stream| stream.topics.get_mut(topic_id))
+ else {
+ return ApplyReply::err(CreateConsumerGroupResult::TopicNotFound);
};
let name: Arc<str> = Arc::from(self.name.as_str());
// Per-(stream,topic) name uniqueness. A same-name group already exists:
@@ -958,6 +971,41 @@ mod tests {
assert!(apply.body.is_empty());
}
+ #[test]
+ fn given_missing_parent_when_apply_create_consumer_group_should_return_not_found() {
+ let mut state = streams_with_topic();
+
+ let missing_stream = StateHandler::apply(
+ &CreateConsumerGroupRequest {
+ stream_id: WireIdentifier::numeric(999),
+ topic_id: WireIdentifier::numeric(0),
+ name: WireName::new("group").unwrap(),
+ },
+ &mut state,
+ IggyTimestamp::now(),
+ );
+ assert_eq!(
+ missing_stream.code,
+ u32::from(CreateConsumerGroupResult::StreamNotFound)
+ );
+ assert!(missing_stream.body.is_empty());
+
+ let missing_topic = StateHandler::apply(
+ &CreateConsumerGroupRequest {
+ stream_id: WireIdentifier::numeric(0),
+ topic_id: WireIdentifier::numeric(999),
+ name: WireName::new("group").unwrap(),
+ },
+ &mut state,
+ IggyTimestamp::now(),
+ );
+ assert_eq!(
+ missing_topic.code,
+ u32::from(CreateConsumerGroupResult::TopicNotFound)
+ );
+ assert!(missing_topic.body.is_empty());
+ }
+
#[test]
fn given_missing_group_when_apply_delete_consumer_group_should_return_not_found() {
let mut state = streams_with_topic();
diff --git a/core/metadata/src/stm/mod.rs b/core/metadata/src/stm/mod.rs
index f86c2d2e3..5fc0c10d1 100644
--- a/core/metadata/src/stm/mod.rs
+++ b/core/metadata/src/stm/mod.rs
@@ -15,6 +15,7 @@
// specific language governing permissions and limitations
// under the License.
+pub mod authz;
pub mod consumer_group;
pub mod mux;
pub mod result;
diff --git a/core/metadata/src/stm/result.rs b/core/metadata/src/stm/result.rs
index 7874729d8..496bf2049 100644
--- a/core/metadata/src/stm/result.rs
+++ b/core/metadata/src/stm/result.rs
@@ -187,14 +187,27 @@ result_enum!(DeletePartitionsResult {
// Users. No dedicated user-not-found code in `IggyError`; `ResourceNotFound = 20`
// stands in until one is added (a separate `IggyError` change).
-result_enum!(CreateUserResult { UserAlreadyExists = 46 });
+result_enum!(CreateUserResult {
+ InvalidUsername = 43,
+ UserAlreadyExists = 46,
+});
result_enum!(UpdateUserResult {
UserNotFound = 20,
+ InvalidUsername = 43,
UsernameAlreadyExists = 46,
});
-result_enum!(DeleteUserResult { UserNotFound = 20 });
-result_enum!(ChangePasswordResult { UserNotFound = 20 });
-result_enum!(UpdatePermissionsResult { UserNotFound = 20 });
+result_enum!(DeleteUserResult {
+ UserNotFound = 20,
+ CannotDeleteUser = 48,
+});
+result_enum!(ChangePasswordResult {
+ UserNotFound = 20,
+ InvalidCredentials = 42,
+});
+result_enum!(UpdatePermissionsResult {
+ UserNotFound = 20,
+ CannotChangePermissions = 49,
+});
// Personal access tokens.
result_enum!(CreatePersonalAccessTokenResult {
@@ -204,17 +217,34 @@ result_enum!(CreatePersonalAccessTokenResult {
result_enum!(DeletePersonalAccessTokenResult { NotFound = 20 });
// Consumer groups.
-result_enum!(CreateConsumerGroupResult { NameAlreadyExists = 5004 });
+result_enum!(CreateConsumerGroupResult {
+ StreamNotFound = 1009,
+ TopicNotFound = 2010,
+ NameAlreadyExists = 5004,
+});
result_enum!(DeleteConsumerGroupResult { NotFound = 5000 });
-/// True if `code` is one this op's result enum declares (`Ok = 0` included).
+/// `IggyError::Unauthorized`. Any control-plane op can commit as an in-apply
+/// authorization no-op, so this code is valid for every op regardless of its
+/// own result enum. Pinned to the wire discriminant in
+/// [`tests::given_result_discriminants_then_match_iggy_error_codes`].
+const UNAUTHORIZED_CODE: u32 = 41;
+
+/// True if `code` is one this op's result enum declares (`Ok = 0` included), or
+/// the global `UNAUTHORIZED_CODE`.
///
-/// The state machine only commits codes from the op's own enum, so an
+/// The state machine only commits codes from the op's own enum, plus the
+/// in-apply authorization gate's global `Unauthorized` denial, so an
/// unrecognized one is a server bug, not a race (a race still yields a declared
/// code). Enriched internal ops map to their client-op enum; ops with no result
/// section (partition plane) return `true`.
#[must_use]
pub const fn result_code_recognized(operation: Operation, code: u32) -> bool {
+ // The in-apply RBAC gate can deny any control-plane op, committing an
+ // `Unauthorized` no-op, so accept it globally rather than per-op enum.
+ if code == UNAUTHORIZED_CODE {
+ return true;
+ }
match operation {
Operation::CreateStream => CreateStreamResult::from_u32(code).is_some(),
Operation::UpdateStream => UpdateStreamResult::from_u32(code).is_some(),
@@ -325,6 +355,20 @@ mod tests {
));
// Partition-plane ops carry no result section, so any code is accepted.
assert!(result_code_recognized(Operation::SendMessages, 12345));
+ // `Unauthorized` commits for any control-plane op via the in-apply gate,
+ // even though no per-op result enum declares it.
+ assert!(result_code_recognized(
+ Operation::CreateStream,
+ UNAUTHORIZED_CODE
+ ));
+ assert!(result_code_recognized(
+ Operation::CreateUser,
+ UNAUTHORIZED_CODE
+ ));
+ assert!(result_code_recognized(
+ Operation::CreateConsumerGroup,
+ UNAUTHORIZED_CODE
+ ));
}
// Pins every result-enum discriminant to the `IggyError` variant its doc
@@ -377,6 +421,10 @@ mod tests {
u32::from(DeletePartitionsResult::StreamNotFound),
stream_not_found
);
+ assert_eq!(
+ u32::from(CreateConsumerGroupResult::StreamNotFound),
+ stream_not_found
+ );
// StreamNameAlreadyExists (1012).
let stream_name_exists = IggyError::StreamNameAlreadyExists(String::new()).as_code();
@@ -402,6 +450,10 @@ mod tests {
u32::from(DeletePartitionsResult::TopicNotFound),
topic_not_found
);
+ assert_eq!(
+ u32::from(CreateConsumerGroupResult::TopicNotFound),
+ topic_not_found
+ );
// TopicNameAlreadyExists (2013).
let topic_name_exists = IggyError::TopicNameAlreadyExists(String::new(), id()).as_code();
@@ -428,6 +480,18 @@ mod tests {
user_exists
);
+ // InvalidUsername (43) - username length outside [MIN,MAX] bytes,
+ // rejected in-apply for create and rename alike.
+ let invalid_username = IggyError::InvalidUsername.as_code();
+ assert_eq!(
+ u32::from(CreateUserResult::InvalidUsername),
+ invalid_username
+ );
+ assert_eq!(
+ u32::from(UpdateUserResult::InvalidUsername),
+ invalid_username
+ );
+
// ResourceNotFound (20) - documented stand-in for user/PAT not-found.
let resource_not_found = IggyError::ResourceNotFound(String::new()).as_code();
assert_eq!(
@@ -451,6 +515,26 @@ mod tests {
resource_not_found
);
+ // CannotDeleteUser (48) - root deletion is rejected in-apply.
+ assert_eq!(
+ u32::from(DeleteUserResult::CannotDeleteUser),
+ IggyError::CannotDeleteUser(0).as_code(),
+ );
+
+ // CannotChangePermissions (49) - root permissions are immutable in-apply.
+ assert_eq!(
+ u32::from(UpdatePermissionsResult::CannotChangePermissions),
+ IggyError::CannotChangePermissions(0).as_code(),
+ );
+
+ // InvalidCredentials (42) - wrong current password on ChangePassword,
+ // committed as a rejecting no-op so the client request sequence stays
+ // contiguous (a pre-consensus deny would gap it).
+ assert_eq!(
+ u32::from(ChangePasswordResult::InvalidCredentials),
+ IggyError::InvalidCredentials.as_code(),
+ );
+
// PersonalAccessTokenAlreadyExists (51).
assert_eq!(
u32::from(CreatePersonalAccessTokenResult::AlreadyExists),
@@ -472,5 +556,8 @@ mod tests {
u32::from(DeleteConsumerGroupResult::NotFound),
IggyError::ConsumerGroupIdNotFound(id(), id()).as_code(),
);
+
+ // Unauthorized (41) - the global in-apply RBAC denial code.
+ assert_eq!(UNAUTHORIZED_CODE, IggyError::Unauthorized.as_code());
}
}
diff --git a/core/metadata/src/stm/stream.rs b/core/metadata/src/stm/stream.rs
index 3844706be..defaaf086 100644
--- a/core/metadata/src/stm/stream.rs
+++ b/core/metadata/src/stm/stream.rs
@@ -628,7 +628,7 @@ impl StreamsInner {
self.pending_revocations_count = count;
}
- fn resolve_stream_id(&self, identifier: &WireIdentifier) -> Option<usize> {
+ pub(crate) fn resolve_stream_id(&self, identifier: &WireIdentifier) -> Option<usize> {
match identifier {
WireIdentifier::Numeric(id) => {
let id = *id as usize;
@@ -642,7 +642,11 @@ impl StreamsInner {
}
}
- fn resolve_topic_id(&self, stream_id: usize, identifier: &WireIdentifier) -> Option<usize> {
+ pub(crate) fn resolve_topic_id(
+ &self,
+ stream_id: usize,
+ identifier: &WireIdentifier,
+ ) -> Option<usize> {
let stream = self.items.get(stream_id)?;
match identifier {
WireIdentifier::Numeric(id) => {
diff --git a/core/metadata/src/stm/user.rs b/core/metadata/src/stm/user.rs
index cda74d0b1..e7784a81e 100644
--- a/core/metadata/src/stm/user.rs
+++ b/core/metadata/src/stm/user.rs
@@ -35,8 +35,9 @@ use iggy_binary_protocol::requests::users::{
use iggy_binary_protocol::responses::users::get_user::UserDetailsResponse;
use iggy_binary_protocol::responses::users::user_response::UserResponse;
use iggy_binary_protocol::{WireIdentifier, WireName};
+use iggy_common::defaults::{DEFAULT_ROOT_USER_ID, MAX_USERNAME_LENGTH, MIN_USERNAME_LENGTH};
use iggy_common::{
- GlobalPermissions, IggyExpiry, IggyTimestamp, Permissions, PersonalAccessToken,
+ GlobalPermissions, IggyError, IggyExpiry, IggyTimestamp, Permissions, PersonalAccessToken,
StreamPermissions, UserId, UserStatus,
};
use serde::{Deserialize, Serialize};
@@ -124,7 +125,7 @@ collect_handlers! {
}
impl UsersInner {
- fn resolve_user_id(&self, identifier: &WireIdentifier) -> Option<usize> {
+ pub(crate) fn resolve_user_id(&self, identifier: &WireIdentifier) -> Option<usize> {
match identifier {
WireIdentifier::Numeric(id) => {
let id = *id as usize;
@@ -138,6 +139,27 @@ impl UsersInner {
}
}
+ /// Stored password hash of the user named by `identifier`, `None` when
+ /// the user does not resolve.
+ #[must_use]
+ pub fn password_hash_of(&self, identifier: &WireIdentifier) -> Option<Arc<str>> {
+ self.resolve_user_id(identifier)
+ .and_then(|user_id| self.items.get(user_id))
+ .map(|user| Arc::clone(&user.password_hash))
+ }
+
+ /// Clone the committed permissions of the user at slab `id`, if any.
+ ///
+ /// The permissioner is a denormalized index of `User.permissions`; both
+ /// apply paths feed it from here, reading the just-stored user so the two
+ /// never drift.
+ #[must_use]
+ fn permissions_of(&self, id: usize) -> Option<Permissions> {
+ self.items
+ .get(id)
+ .and_then(|user| user.permissions.as_deref().cloned())
+ }
+
/// Collect `(user_id, name)` for every expired personal access token.
///
/// Walks the expiry-ordered `personal_access_token_expiry_index`,
@@ -155,6 +177,34 @@ impl UsersInner {
.map(|(_, user_id, name)| (*user_id, Arc::clone(name)))
.collect()
}
+
+ /// Collect `(name, expiry_at)` for every live personal access token of
+ /// one user, sorted by name.
+ ///
+ /// Read-only accessor for the non-replicated list path (the caller lists
+ /// its own tokens). The backing per-user map is an `AHashMap`, whose
+ /// iteration order is seeded per process, so the collected list is
+ /// sorted before it is returned. Never lift this iteration into an apply
+ /// handler: apply must stay deterministic across replicas (see the
+ /// invariant on `personal_access_token_index`).
+ #[must_use]
+ pub fn personal_access_tokens_of(
+ &self,
+ user_id: UserId,
+ ) -> Vec<(Arc<str>, Option<IggyTimestamp>)> {
+ let mut tokens: Vec<_> = self
+ .personal_access_tokens
+ .get(&user_id)
+ .map(|user_tokens| {
+ user_tokens
+ .values()
+ .map(|pat| (Arc::clone(&pat.name), pat.expiry_at))
+ .collect()
+ })
+ .unwrap_or_default();
+ tokens.sort_by(|(left, _), (right, _)| left.cmp(right));
+ tokens
+ }
}
impl Users {
@@ -166,16 +216,45 @@ impl Users {
self.inner.read(f)
}
+ /// Run a permissioner rule against the committed user permissions and
+ /// return its decision. The dispatch-time authorization surface for the
+ /// checks the in-apply RBAC gate cannot cover: non-replicated reads and
+ /// partition-plane ops, both decided on the connection's own shard rather
+ /// than in a replicated apply. The permissioner stays private; callers pass
+ /// the rule closure.
+ ///
+ /// # Errors
+ /// Propagates the rule's `IggyError` (always `Unauthorized`) on denial.
+ pub fn authorize(
+ &self,
+ rule: impl FnOnce(&Permissioner) -> Result<(), IggyError>,
+ ) -> Result<(), IggyError> {
+ self.read(|inner| rule(&inner.permissioner))
+ }
+
/// Ensures a root user exists in an empty user set.
///
/// # Panics
///
- /// Panics if `username` is not a valid wire-format username.
+ /// Panics if `username` is not a valid wire-format username or its length is
+ /// outside `MIN_USERNAME_LENGTH..=MAX_USERNAME_LENGTH`, failing bootstrap fast
+ /// rather than letting the replicated apply reject it as a committed no-op
+ /// that would leave the server with no root user.
pub fn ensure_root_user(&self, username: &str, password_hash: &str) {
if self.read(|users| !users.items.is_empty()) {
return;
}
+ // Fail fast on a misconfigured `IGGY_ROOT_USERNAME`: the replicated apply
+ // enforces this same length bound and would otherwise reject the
+ // bootstrap as a committed no-op (try_apply still returns Ok), leaving the
+ // server running with no root user.
+ let length = username.len();
+ assert!(
+ (MIN_USERNAME_LENGTH..=MAX_USERNAME_LENGTH).contains(&length),
+ "root username length {length} outside {MIN_USERNAME_LENGTH}..={MAX_USERNAME_LENGTH}; fix IGGY_ROOT_USERNAME"
+ );
+
// Boot-only invariant: server-ng calls this before listeners and
// consensus traffic start, on shard 0 initialization. The read/apply
// split cannot race another user creation in that phase.
@@ -330,6 +409,15 @@ impl StateHandler for CreateUserRequest {
type State = UsersInner;
#[allow(clippy::cast_possible_truncation)]
fn apply(&self, state: &mut UsersInner, timestamp: IggyTimestamp) -> ApplyReply {
+ // `WireName` permits 1..=255 bytes, so a raw binary client bypasses the
+ // length bound the edges (HTTP DTO, SDK, login) enforce. Re-check in the
+ // replicated apply -- the choke point every transport shares -- so state
+ // never holds a username no ingress considers valid. Rejected before any
+ // mutation, committing as a deterministic no-op.
+ if !(MIN_USERNAME_LENGTH..=MAX_USERNAME_LENGTH).contains(&self.username.as_str().len()) {
+ return ApplyReply::err(CreateUserResult::InvalidUsername);
+ }
+
let username_arc: Arc<str> = Arc::from(self.username.as_str());
if state.index.contains_key(&username_arc) {
return ApplyReply::err(CreateUserResult::UserAlreadyExists);
@@ -360,6 +448,11 @@ impl StateHandler for CreateUserRequest {
.personal_access_tokens
.insert(id as UserId, AHashMap::default());
+ let user_permissions = state.permissions_of(id);
+ state
+ .permissioner
+ .init_permissions_for_user(id as UserId, user_permissions);
+
// Reply body: the SDK `create_user` decodes a `UserDetailsResponse`.
// Serialization local to this state machine.
ApplyReply::ok(
@@ -390,6 +483,12 @@ impl StateHandler for UpdateUserRequest {
};
if let Some(new_username) = &self.username {
+ // Same bound as CreateUser apply: a rename must not smuggle in a
+ // username the edges reject. Rejected before any mutation.
+ if !(MIN_USERNAME_LENGTH..=MAX_USERNAME_LENGTH).contains(&new_username.as_str().len()) {
+ return ApplyReply::err(UpdateUserResult::InvalidUsername);
+ }
+
let new_username_arc: Arc<str> = Arc::from(new_username.as_str());
if let Some(&existing_id) = state.index.get(&new_username_arc)
&& existing_id != user_id as UserId
@@ -419,6 +518,15 @@ impl StateHandler for DeleteUserRequest {
return ApplyReply::err(DeleteUserResult::UserNotFound);
};
+ // Root (slab id 0) is undeletable, matching legacy `CannotDeleteUser`.
+ // The rejection is deterministic, so every replica keeps slab id 0 as
+ // the root user and the in-apply RBAC gate's `user_id == 0` root
+ // short-circuit can never be inherited by a recreated user (the slab
+ // reuses freed keys, so a deleted root's slot would be reclaimed).
+ if user_id == DEFAULT_ROOT_USER_ID as usize {
+ return ApplyReply::err(DeleteUserResult::CannotDeleteUser);
+ }
+
if let Some(user) = state.items.get(user_id) {
let username = user.username.clone();
state.items.remove(user_id);
@@ -435,6 +543,9 @@ impl StateHandler for DeleteUserRequest {
}
}
}
+ state
+ .permissioner
+ .delete_permissions_for_user(user_id as UserId);
}
ApplyReply::ok(Bytes::new())
}
@@ -447,6 +558,16 @@ impl StateHandler for ChangePasswordRequest {
return ApplyReply::err(ChangePasswordResult::UserNotFound);
};
+ // An empty `new_password` is the primary's signal that the caller's
+ // current password did not match (see server-ng
+ // `verify_and_rewrite_change_password`): the accept path always
+ // replicates a non-empty Argon2 hash, so this is unambiguous. Rejecting
+ // here (rather than denying pre-consensus) commits the op as a no-op,
+ // keeping the client's request sequence contiguous in the ClientTable.
+ if self.new_password.is_empty() {
+ return ApplyReply::err(ChangePasswordResult::InvalidCredentials);
+ }
+
if let Some(user) = state.items.get_mut(user_id) {
user.password_hash = Arc::from(self.new_password.as_str());
}
@@ -456,17 +577,30 @@ impl StateHandler for ChangePasswordRequest {
impl StateHandler for UpdatePermissionsRequest {
type State = UsersInner;
+ #[allow(clippy::cast_possible_truncation)]
fn apply(&self, state: &mut UsersInner, _timestamp: IggyTimestamp) -> ApplyReply {
let Some(user_id) = state.resolve_user_id(&self.user_id) else {
return ApplyReply::err(UpdatePermissionsResult::UserNotFound);
};
+ // Root (slab id 0) permissions are immutable, matching legacy
+ // `CannotChangePermissions`. Rejected before any mutation, so the op is a
+ // committed no-op: root keeps its full grants and the permissioner index
+ // is untouched.
+ if user_id == DEFAULT_ROOT_USER_ID as usize {
+ return ApplyReply::err(UpdatePermissionsResult::CannotChangePermissions);
+ }
+
if let Some(user) = state.items.get_mut(user_id) {
user.permissions = self
.permissions
.as_ref()
.map(|p| Arc::new(Permissions::from(p.clone())));
}
+ let user_permissions = state.permissions_of(user_id);
+ state
+ .permissioner
+ .update_permissions_for_user(user_id as UserId, user_permissions);
ApplyReply::ok(Bytes::new())
}
}
@@ -650,43 +784,18 @@ impl Snapshotable for Users {
})
.collect();
+ // The permissioner is a denormalized index of `User.permissions`.
+ // Its `AHashMap`/`AHashSet` fields iterate in a per-replica-random
+ // order, so serializing them would make the snapshot bytes differ
+ // across replicas. Persist empty vecs (kept only for the positional
+ // msgpack layout) and rebuild the index from the users on restore.
let permissioner = PermissionerSnapshot {
- users_permissions: inner
- .permissioner
- .users_permissions
- .iter()
- .map(|(&k, v)| (k, v.clone()))
- .collect(),
- users_streams_permissions: inner
- .permissioner
- .users_streams_permissions
- .iter()
- .map(|(&k, v)| (k, v.clone()))
- .collect(),
- users_that_can_poll_messages_from_all_streams: inner
- .permissioner
- .users_that_can_poll_messages_from_all_streams
- .iter()
- .copied()
- .collect(),
- users_that_can_send_messages_to_all_streams: inner
- .permissioner
- .users_that_can_send_messages_to_all_streams
- .iter()
- .copied()
- .collect(),
- users_that_can_poll_messages_from_specific_streams: inner
- .permissioner
- .users_that_can_poll_messages_from_specific_streams
- .iter()
- .copied()
- .collect(),
- users_that_can_send_messages_to_specific_streams: inner
- .permissioner
- .users_that_can_send_messages_to_specific_streams
- .iter()
- .copied()
- .collect(),
+ users_permissions: Vec::new(),
+ users_streams_permissions: Vec::new(),
+ users_that_can_poll_messages_from_all_streams: Vec::new(),
+ users_that_can_send_messages_to_all_streams: Vec::new(),
+ users_that_can_poll_messages_from_specific_streams: Vec::new(),
+ users_that_can_send_messages_to_specific_streams: Vec::new(),
};
UsersSnapshot {
@@ -751,38 +860,14 @@ impl Snapshotable for Users {
personal_access_tokens.insert(user_id, token_map);
}
- let permissioner = Permissioner {
- users_permissions: snapshot
- .permissioner
- .users_permissions
- .into_iter()
- .collect(),
- users_streams_permissions: snapshot
- .permissioner
- .users_streams_permissions
- .into_iter()
- .collect(),
- users_that_can_poll_messages_from_all_streams: snapshot
- .permissioner
- .users_that_can_poll_messages_from_all_streams
- .into_iter()
- .collect(),
- users_that_can_send_messages_to_all_streams: snapshot
- .permissioner
- .users_that_can_send_messages_to_all_streams
- .into_iter()
- .collect(),
- users_that_can_poll_messages_from_specific_streams: snapshot
- .permissioner
- .users_that_can_poll_messages_from_specific_streams
- .into_iter()
- .collect(),
- users_that_can_send_messages_to_specific_streams: snapshot
- .permissioner
- .users_that_can_send_messages_to_specific_streams
- .into_iter()
- .collect(),
- };
+ // Rebuild the permissioner from the restored users rather than the
+ // snapshot vecs, which are intentionally empty (see `to_snapshot`).
+ // Slab iteration is ascending by key, so the build is deterministic.
+ let mut permissioner = Permissioner::new();
+ for (user_id, user) in &items {
+ permissioner
+ .init_permissions_for_user(user_id as UserId, user.permissions.as_deref().cloned());
+ }
let inner = UsersInner {
index,
@@ -802,6 +887,8 @@ impl_fill_restore!(Users, users);
#[cfg(test)]
mod tests {
use super::*;
+ use iggy_binary_protocol::primitives::permissions::WireStreamPermissions;
+ use iggy_common::IggyError;
#[test]
fn create_pat_request_roundtrip_preserves_user_id() {
@@ -954,6 +1041,42 @@ mod tests {
);
}
+ #[test]
+ fn personal_access_tokens_of_lists_only_callers_sorted_by_name() {
+ let mut users = UsersInner::new();
+ // Insertion order deliberately not name order; a token for user 9
+ // proves the listing is caller-scoped.
+ for (user_id, name, expiry, fill) in [
+ (5u32, "zeta", 0u64, b'a'),
+ (5u32, "alpha", 1, b'b'),
+ (9u32, "other", 0, b'c'),
+ ] {
+ CreatePersonalAccessTokenRequest {
+ user_id,
+ name: WireName::new(name).unwrap(),
+ expiry,
+ token_hash: [fill; PAT_TOKEN_HASH_BYTES],
+ }
+ .apply(&mut users, IggyTimestamp::zero());
+ }
+
+ let tokens = users.personal_access_tokens_of(5);
+ let names: Vec<&str> = tokens.iter().map(|(name, _)| name.as_ref()).collect();
+ assert_eq!(names, ["alpha", "zeta"], "own tokens only, sorted by name");
+ assert!(
+ tokens[0].1.is_some(),
+ "expiring token must carry its expiry_at"
+ );
+ assert!(
+ tokens[1].1.is_none(),
+ "never-expiring token must carry no expiry_at"
+ );
+ assert!(
+ users.personal_access_tokens_of(42).is_empty(),
+ "a user without tokens lists empty"
+ );
+ }
+
fn create_user(users: &mut UsersInner, username: &str) {
let request = CreateUserRequest {
username: WireName::new(username).unwrap(),
@@ -989,4 +1112,404 @@ mod tests {
let apply = StateHandler::apply(&request, &mut users, IggyTimestamp::now());
assert_eq!(apply.code, u32::from(DeleteUserResult::UserNotFound));
}
+
+ #[test]
+ fn given_root_user_when_apply_delete_user_should_reject_and_keep_root() {
+ // Production seeds root at slab id 0 (`ensure_root_user`), so the first
+ // slab slot is the root user. Deleting it must be rejected in-apply, or
+ // a later `CreateUser` would reclaim slot 0 and inherit the RBAC gate's
+ // `user_id == 0` root short-circuit.
+ let mut users = UsersInner::new();
+ let root_id = create_user_with(&mut users, "iggy", None);
+ assert_eq!(root_id, DEFAULT_ROOT_USER_ID, "root takes slab id 0");
+
+ let delete_root = DeleteUserRequest {
+ user_id: WireIdentifier::numeric(root_id),
+ };
+ let reply = StateHandler::apply(&delete_root, &mut users, IggyTimestamp::now());
+ assert_eq!(reply.code, u32::from(DeleteUserResult::CannotDeleteUser));
+ assert!(
+ users.items.contains(root_id as usize),
+ "root must survive a delete attempt"
+ );
+
+ // A non-root user (slab id 1) stays deletable: the guard is scoped to
+ // root, so there is no over-block regression.
+ let alice_id = create_user_with(&mut users, "alice", None);
+ assert_ne!(alice_id, DEFAULT_ROOT_USER_ID);
+ let delete_alice = DeleteUserRequest {
+ user_id: WireIdentifier::numeric(alice_id),
+ };
+ let reply = StateHandler::apply(&delete_alice, &mut users, IggyTimestamp::now());
+ assert_eq!(reply.code, 0, "non-root deletion still succeeds");
+ assert!(!users.items.contains(alice_id as usize));
+ }
+
+ #[test]
+ fn given_root_user_when_apply_update_permissions_should_reject_and_keep_grants() {
+ // Root occupies slab id 0 (production `ensure_root_user`). Its
+ // permissions must be immutable in-apply, or a delegated manage_users
+ // admin could tamper with root's grants.
+ let mut users = UsersInner::new();
+ let mut root_global = empty_global();
+ root_global.manage_servers = true;
+ root_global.poll_messages = true;
+ let root_id = create_user_with(
+ &mut users,
+ "iggy",
+ Some(WirePermissions {
+ global: root_global,
+ streams: Vec::new(),
+ }),
+ );
+ assert_eq!(root_id, DEFAULT_ROOT_USER_ID, "root takes slab id 0");
+ assert!(users.permissioner.poll_messages(root_id, 1, 1).is_ok());
+
+ // Attempt to strip root's grants: rejected, nothing mutates.
+ let strip = UpdatePermissionsRequest {
+ user_id: WireIdentifier::numeric(root_id),
+ permissions: None,
+ };
+ let reply = StateHandler::apply(&strip, &mut users, IggyTimestamp::now());
+ assert_eq!(
+ reply.code,
+ u32::from(UpdatePermissionsResult::CannotChangePermissions)
+ );
+ assert!(
+ users
+ .items
+ .get(root_id as usize)
+ .and_then(|user| user.permissions.as_ref())
+ .is_some(),
+ "root permissions must survive"
+ );
+ assert!(
+ users.permissioner.poll_messages(root_id, 1, 1).is_ok(),
+ "root gate access unchanged"
+ );
+
+ // A non-root user's permissions can still be updated: no over-block.
+ let alice_id = create_user_with(&mut users, "alice", None);
+ assert_ne!(alice_id, DEFAULT_ROOT_USER_ID);
+ let mut alice_global = empty_global();
+ alice_global.poll_messages = true;
+ let grant = UpdatePermissionsRequest {
+ user_id: WireIdentifier::numeric(alice_id),
+ permissions: Some(WirePermissions {
+ global: alice_global,
+ streams: Vec::new(),
+ }),
+ };
+ let reply = StateHandler::apply(&grant, &mut users, IggyTimestamp::now());
+ assert_eq!(reply.code, 0, "non-root permission update still succeeds");
+ assert!(users.permissioner.poll_messages(alice_id, 1, 1).is_ok());
+ }
+
+ fn empty_global() -> WireGlobalPermissions {
+ WireGlobalPermissions {
+ manage_servers: false,
+ read_servers: false,
+ manage_users: false,
+ read_users: false,
+ manage_streams: false,
+ read_streams: false,
+ manage_topics: false,
+ read_topics: false,
+ poll_messages: false,
+ send_messages: false,
+ }
+ }
+
+ fn create_user_with(
+ users: &mut UsersInner,
+ username: &str,
+ permissions: Option<WirePermissions>,
+ ) -> UserId {
+ let request = CreateUserRequest {
+ username: WireName::new(username).unwrap(),
+ password: "hash".to_owned(),
+ status: 1,
+ permissions,
+ };
+ let reply = StateHandler::apply(&request, users, IggyTimestamp::now());
+ assert_eq!(reply.code, 0);
+ *users.index.get(username).expect("user created")
+ }
+
+ #[test]
+ fn given_global_poll_grant_when_apply_create_user_should_allow_poll_from_any_stream() {
+ let mut users = UsersInner::new();
+ let mut global = empty_global();
+ global.poll_messages = true;
+ let uid = create_user_with(
+ &mut users,
+ "poller",
+ Some(WirePermissions {
+ global,
+ streams: Vec::new(),
+ }),
+ );
+
+ // The global poll bit denormalizes into the can-poll-all set, so the
+ // user may poll any stream/topic without a stream-specific grant.
+ assert!(users.permissioner.poll_messages(uid, 7, 3).is_ok());
+ assert!(
+ users
+ .permissioner
+ .users_that_can_poll_messages_from_all_streams
+ .contains(&uid)
+ );
+ }
+
+ #[test]
+ fn given_poll_grant_when_apply_update_permissions_removes_it_should_return_unauthorized() {
+ let mut users = UsersInner::new();
+ // Occupy slab id 0 with the (immutable) root user so the poller under
+ // test is a non-root user; root permission changes are rejected in-apply.
+ create_user_with(&mut users, "iggy", None);
+ let mut global = empty_global();
+ global.poll_messages = true;
+ let uid = create_user_with(
+ &mut users,
+ "poller",
+ Some(WirePermissions {
+ global,
+ streams: Vec::new(),
+ }),
+ );
+ assert!(users.permissioner.poll_messages(uid, 1, 1).is_ok());
+
+ let update = UpdatePermissionsRequest {
+ user_id: WireIdentifier::numeric(uid),
+ permissions: None,
+ };
+ let reply = StateHandler::apply(&update, &mut users, IggyTimestamp::now());
+ assert_eq!(reply.code, 0);
+
+ assert!(matches!(
+ users.permissioner.poll_messages(uid, 1, 1),
+ Err(IggyError::Unauthorized)
+ ));
+ assert!(
+ !users
+ .permissioner
+ .users_that_can_poll_messages_from_all_streams
+ .contains(&uid)
+ );
+ }
+
+ #[test]
+ fn given_permissioned_user_when_apply_delete_user_should_clear_all_indexes() {
+ let mut users = UsersInner::new();
+ // Occupy slab id 0 with the (undeletable) root user so the victim under
+ // test is a non-root user; root deletion is rejected in-apply.
+ create_user_with(&mut users, "iggy", None);
+ let mut global = empty_global();
+ global.poll_messages = true;
+ global.send_messages = true;
+ let stream = WireStreamPermissions {
+ stream_id: 4,
+ manage_stream: false,
+ read_stream: false,
+ manage_topics: false,
+ read_topics: false,
+ poll_messages: true,
+ send_messages: true,
+ topics: Vec::new(),
+ };
+ let uid = create_user_with(
+ &mut users,
+ "victim",
+ Some(WirePermissions {
+ global,
+ streams: vec![stream],
+ }),
+ );
+ assert!(!users.permissioner.users_permissions.is_empty());
+
+ let delete = DeleteUserRequest {
+ user_id: WireIdentifier::numeric(uid),
+ };
+ let reply = StateHandler::apply(&delete, &mut users, IggyTimestamp::now());
+ assert_eq!(reply.code, 0);
+
+ let permissioner = &users.permissioner;
+ assert!(permissioner.users_permissions.is_empty());
+ assert!(permissioner.users_streams_permissions.is_empty());
+ assert!(
+ permissioner
+ .users_that_can_poll_messages_from_all_streams
+ .is_empty()
+ );
+ assert!(
+ permissioner
+ .users_that_can_send_messages_to_all_streams
+ .is_empty()
+ );
+ assert!(
+ permissioner
+ .users_that_can_poll_messages_from_specific_streams
+ .is_empty()
+ );
+ assert!(
+ permissioner
+ .users_that_can_send_messages_to_specific_streams
+ .is_empty()
+ );
+ }
+
+ #[test]
+ fn given_populated_permissions_when_snapshot_round_trip_should_rebuild_index_and_persist_empty_vecs()
+ {
+ let mut inner = UsersInner::new();
+ let mut global = empty_global();
+ global.poll_messages = true;
+ global.send_messages = true;
+ let stream = WireStreamPermissions {
+ stream_id: 2,
+ manage_stream: false,
+ read_stream: false,
+ manage_topics: false,
+ read_topics: false,
+ poll_messages: true,
+ send_messages: true,
+ topics: Vec::new(),
+ };
+ let uid = create_user_with(
+ &mut inner,
+ "alice",
+ Some(WirePermissions {
+ global,
+ streams: vec![stream],
+ }),
+ );
+ let pre_poll = inner.permissioner.poll_messages(uid, 9, 9).is_ok();
+ let pre_send = inner.permissioner.append_messages(uid, 2, 0).is_ok();
+
+ let users: Users = inner.into();
+ let snapshot = users.to_snapshot();
+
+ // The derived index is not serialized: every permissioner vec is empty.
+ let snap = &snapshot.permissioner;
+ assert!(snap.users_permissions.is_empty());
+ assert!(snap.users_streams_permissions.is_empty());
+ assert!(
+ snap.users_that_can_poll_messages_from_all_streams
+ .is_empty()
+ );
+ assert!(snap.users_that_can_send_messages_to_all_streams.is_empty());
+ assert!(
+ snap.users_that_can_poll_messages_from_specific_streams
+ .is_empty()
+ );
+ assert!(
+ snap.users_that_can_send_messages_to_specific_streams
+ .is_empty()
+ );
+
+ let restored = Users::from_snapshot(snapshot).expect("snapshot restore");
+ let (poll_ok, send_ok, in_poll_all, in_send_specific) = restored.read(|inner| {
+ let permissioner = &inner.permissioner;
+ (
+ permissioner.poll_messages(uid, 9, 9).is_ok(),
+ permissioner.append_messages(uid, 2, 0).is_ok(),
+ permissioner
+ .users_that_can_poll_messages_from_all_streams
+ .contains(&uid),
+ permissioner
+ .users_that_can_send_messages_to_specific_streams
+ .contains(&(uid, 2)),
+ )
+ });
+
+ // Rule results survive the round-trip...
+ assert_eq!(poll_ok, pre_poll);
+ assert_eq!(send_ok, pre_send);
+ // ...because the index was rebuilt from the restored user: the global
+ // poll bit repopulated the can-poll-all set and the stream send bit
+ // repopulated the specific set.
+ assert!(in_poll_all);
+ assert!(in_send_specific);
+ }
+
+ #[test]
+ fn given_root_user_when_ensure_root_after_bootstrap_should_have_root_grants() {
+ let users = Users::default();
+ users.ensure_root_user("iggy", "hash");
+ // Root carries `Permissions::root()`: full grants across every rule.
+ let (can_create_stream, can_get_stats, can_poll) = users.read(|inner| {
+ let root = *inner.index.get("iggy").expect("root user created");
+ (
+ inner.permissioner.create_stream(root).is_ok(),
+ inner.permissioner.get_stats(root).is_ok(),
+ inner.permissioner.poll_messages(root, 1, 1).is_ok(),
+ )
+ });
+ assert!(can_create_stream);
+ assert!(can_get_stats);
+ assert!(can_poll);
+ }
+
+ #[test]
+ fn given_out_of_bound_username_when_apply_create_user_should_reject_without_insert() {
+ let mut users = UsersInner::new();
+
+ // `WireName` accepts 1..=255 bytes, so a raw binary client can submit a
+ // username shorter than the edge-enforced minimum. Apply must reject it.
+ for username in [
+ "a".repeat(MIN_USERNAME_LENGTH - 1),
+ "a".repeat(MAX_USERNAME_LENGTH + 1),
+ ] {
+ let request = CreateUserRequest {
+ username: WireName::new(&username).unwrap(),
+ password: "hash".to_owned(),
+ status: 1,
+ permissions: None,
+ };
+ let reply = StateHandler::apply(&request, &mut users, IggyTimestamp::now());
+ assert_eq!(reply.code, u32::from(CreateUserResult::InvalidUsername));
+ assert!(reply.body.is_empty());
+ }
+ assert!(users.items.is_empty(), "no user inserted on reject");
+ assert!(users.index.is_empty(), "index untouched on reject");
+
+ // The minimum-length boundary is accepted.
+ let username = "a".repeat(MIN_USERNAME_LENGTH);
+ let request = CreateUserRequest {
+ username: WireName::new(&username).unwrap(),
+ password: "hash".to_owned(),
+ status: 1,
+ permissions: None,
+ };
+ let reply = StateHandler::apply(&request, &mut users, IggyTimestamp::now());
+ assert_eq!(reply.code, 0);
+ assert!(users.index.contains_key(username.as_str()));
+ }
+
+ #[test]
+ fn given_too_short_rename_when_apply_update_user_should_reject_and_keep_username() {
+ let mut users = UsersInner::new();
+ create_user_with(&mut users, "iggy", None);
+ let alice_id = create_user_with(&mut users, "alice", None);
+
+ let short = "a".repeat(MIN_USERNAME_LENGTH - 1);
+ let rename = UpdateUserRequest {
+ user_id: WireIdentifier::numeric(alice_id),
+ username: Some(WireName::new(&short).unwrap()),
+ status: None,
+ };
+ let reply = StateHandler::apply(&rename, &mut users, IggyTimestamp::now());
+ assert_eq!(reply.code, u32::from(UpdateUserResult::InvalidUsername));
+
+ assert_eq!(
+ users
+ .items
+ .get(alice_id as usize)
+ .map(|u| u.username.as_ref()),
+ Some("alice"),
+ "username must survive a rejected rename"
+ );
+ assert_eq!(users.index.get("alice"), Some(&alice_id));
+ assert!(!users.index.contains_key(short.as_str()));
+ }
}
diff --git a/core/partitions/src/iggy_partition.rs b/core/partitions/src/iggy_partition.rs
index b3c5098af..7373577d2 100644
--- a/core/partitions/src/iggy_partition.rs
+++ b/core/partitions/src/iggy_partition.rs
@@ -20,7 +20,7 @@ use crate::journal::{MessageLookup, PartitionJournal, PartitionJournalMemStorage
use crate::log::JournalInfo;
use crate::log::SegmentedLog;
use crate::messages_writer::MessagesWriter;
-use crate::offset_storage::{delete_persisted_offset, persist_offset};
+use crate::offset_storage::{delete_persisted_offset, persist_offset, persist_offset_max};
use crate::poll_plan::{
AutoCommitCtx, AutoCommitTarget, DiskReadPlan, DiskSegment, LastPolledCtx, PollPlan, PollTier,
ResidentTailSnapshot,
@@ -33,8 +33,8 @@ use crate::{
use consensus::{
CommitLogEvent, Consensus, PartitionDiagEvent, Pipeline, PipelineEntry, PlaneKind, Project,
ReplicaLogContext, RequestLogEvent, Sequencer, SimEventKind, VsrConsensus, ack_preflight,
- ack_quorum_reached, build_reply_from_request, build_reply_message,
- build_result_rejection_reply, drain_committable_prefix, emit_namespace_progress_event,
+ ack_quorum_reached, build_deny_reply_from_request, build_reply_from_request,
+ build_reply_message, drain_committable_prefix, emit_namespace_progress_event,
emit_partition_diag, emit_sim_event, fence_old_prepare_by_commit, replicate_preflight,
replicate_to_next_in_chain, send_prepare_ok as send_prepare_ok_common,
};
@@ -49,7 +49,7 @@ use iggy_common::{
IggyByteSize, IggyError, IggyExpiry, IggyTimestamp, PartitionStats, PollingKind,
};
use journal::Journal as _;
-use message_bus::{IggyMessageBus, MessageBus};
+use message_bus::{IggyMessageBus, MessageBus, is_auto_commit_client};
use server_common::{
Message, SegmentStorage,
iobuf::Frozen,
@@ -58,7 +58,9 @@ use server_common::{
},
sharding::IggyNamespace,
};
+use std::cell::RefCell;
use std::collections::HashMap;
+use std::hash::Hash;
use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
@@ -105,6 +107,19 @@ where
partition_dir: Option<String>,
consumer_offset_enforce_fsync: bool,
pending_consumer_offset_commits: HashMap<u64, PendingConsumerOffsetCommit>,
+ /// Committed-only mirror of each consumer's persisted offset file: the
+ /// last value this replica durably wrote per (kind, consumer id). Fed
+ /// exclusively by the file-writing paths (replicated commit-apply, the
+ /// primary-local `NoAck` store, purge/delete/reclaim) and never by the
+ /// eager poll-path in-memory apply, so both readers see committed state
+ /// only: the auto-commit persist gate (skip or blind-write, no per-commit
+ /// file read) and the submit-side coalesce gate
+ /// ([`Self::is_auto_commit_offset_covered`]). A cold key (first touch
+ /// after boot) folds against the file once via `persist_offset_max`, so
+ /// the tracker rebuilds from disk lazily and deterministically.
+ /// `RefCell`: mutated from `&self` paths on the single shard thread;
+ /// borrows never cross an await.
+ persisted_offsets: RefCell<HashMap<(ConsumerKind, u32), u64>>,
observed_view: u32,
/// Highest `PurgeTopic` generation this replica has locally applied (reset
/// the partition to empty). The reconciler compares the committed metadata
@@ -131,6 +146,11 @@ struct PendingConsumerOffsetCommit {
kind: ConsumerKind,
consumer_id: u32,
mutation: PendingConsumerOffsetMutation,
+ /// A server auto-commit (a poll's `auto_commit`, replicated via the reserved
+ /// `AUTO_COMMIT_CLIENT_ID`): the commit-apply must be monotone so it cannot
+ /// rewind the eager in-memory offset a newer poll already advanced. Explicit
+ /// client stores leave this `false` (a store may legitimately rewind).
+ auto_commit: bool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -145,6 +165,17 @@ impl PendingConsumerOffsetCommit {
kind,
consumer_id,
mutation: PendingConsumerOffsetMutation::Upsert(offset),
+ auto_commit: false,
+ }
+ }
+
+ /// Monotone-apply variant for a server auto-commit op. See `auto_commit`.
+ const fn upsert_auto_commit(kind: ConsumerKind, consumer_id: u32, offset: u64) -> Self {
+ Self {
+ kind,
+ consumer_id,
+ mutation: PendingConsumerOffsetMutation::Upsert(offset),
+ auto_commit: true,
}
}
@@ -153,6 +184,7 @@ impl PendingConsumerOffsetCommit {
kind,
consumer_id,
mutation: PendingConsumerOffsetMutation::Delete,
+ auto_commit: false,
}
}
@@ -198,6 +230,7 @@ where
partition_dir: None,
consumer_offset_enforce_fsync: false,
pending_consumer_offset_commits: HashMap::new(),
+ persisted_offsets: RefCell::new(HashMap::new()),
observed_view,
applied_purge_generation: 0,
}
@@ -269,8 +302,13 @@ where
kind: ConsumerKind,
consumer_id: u32,
offset: u64,
+ auto_commit: bool,
) {
- let pending = PendingConsumerOffsetCommit::upsert(kind, consumer_id, offset);
+ let pending = if auto_commit {
+ PendingConsumerOffsetCommit::upsert_auto_commit(kind, consumer_id, offset)
+ } else {
+ PendingConsumerOffsetCommit::upsert(kind, consumer_id, offset)
+ };
self.pending_consumer_offset_commits.insert(op, pending);
}
@@ -322,14 +360,67 @@ where
let Some(path) = self.persisted_offset_path(pending.kind, pending.consumer_id) else {
return Ok(());
};
+ let key = (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
+ // 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
+ }
+ None => {
+ persist_offset_max(&path, offset, self.consumer_offset_enforce_fsync)
+ .await?
+ }
+ };
+ self.persisted_offsets.borrow_mut().insert(key, persisted);
+ Ok(())
+ }
PendingConsumerOffsetMutation::Upsert(offset) => {
- persist_offset(&path, offset, self.consumer_offset_enforce_fsync).await
+ persist_offset(&path, offset, self.consumer_offset_enforce_fsync).await?;
+ self.persisted_offsets.borrow_mut().insert(key, offset);
+ Ok(())
+ }
+ PendingConsumerOffsetMutation::Delete => {
+ delete_persisted_offset(&path).await?;
+ self.persisted_offsets.borrow_mut().remove(&key);
+ Ok(())
}
- PendingConsumerOffsetMutation::Delete => delete_persisted_offset(&path).await,
}
}
+ /// Whether the committed high-water for this consumer already covers
+ /// `offset`, so a poll's auto-commit submit cannot advance it and may be
+ /// skipped instead of burning a consensus op. Reads committed state only
+ /// (the tracker is fed at commit-apply, never by the eager poll-path
+ /// apply): an offset covered in memory but not yet committed keeps
+ /// resubmitting until the covering op actually lands, so a dropped
+ /// in-flight op self-heals on the next poll.
+ #[must_use]
+ pub fn is_auto_commit_offset_covered(
+ &self,
+ kind: ConsumerKind,
+ consumer_id: u32,
+ offset: u64,
+ ) -> bool {
+ self.persisted_offsets
+ .borrow()
+ .get(&(kind, consumer_id))
+ .is_some_and(|&high_water| offset <= high_water)
+ }
+
fn apply_consumer_offset_commit(
&self,
pending: PendingConsumerOffsetCommit,
@@ -340,12 +431,19 @@ where
{
let id = pending.consumer_id;
let key = usize::try_from(id).expect("u32 consumer id must fit usize");
- crate::poll_plan::upsert_offset(&self.consumer_offsets, key, offset, || {
+ let create = || {
self.consumer_offsets_path.as_deref().map_or_else(
|| ConsumerOffset::new(ConsumerKind::Consumer, id, 0, String::new()),
|path| ConsumerOffset::default_for_consumer(id, path),
)
- });
+ };
+ upsert_committed_offset(
+ &self.consumer_offsets,
+ key,
+ offset,
+ pending.auto_commit,
+ create,
+ );
Ok(())
}
PendingConsumerOffsetMutation::Upsert(offset)
@@ -355,7 +453,7 @@ where
let key = ConsumerGroupId(
usize::try_from(group_id).expect("u32 group id must fit usize"),
);
- crate::poll_plan::upsert_offset(&self.consumer_group_offsets, key, offset, || {
+ let create = || {
self.consumer_group_offsets_path.as_deref().map_or_else(
|| {
ConsumerOffset::new(
@@ -367,7 +465,14 @@ where
},
|path| ConsumerOffset::default_for_consumer_group(key, path),
)
- });
+ };
+ upsert_committed_offset(
+ &self.consumer_group_offsets,
+ key,
+ offset,
+ pending.auto_commit,
+ create,
+ );
Ok(())
}
// Commit-time apply keeps its invariant check on the PRIMARY:
@@ -443,6 +548,9 @@ where
let mut paths = Vec::with_capacity(dead.len());
for group_id in dead {
pinned.remove(&ConsumerGroupId(group_id as usize));
+ self.persisted_offsets
+ .borrow_mut()
+ .remove(&(ConsumerKind::ConsumerGroup, group_id as u32));
if let Some(path) =
self.persisted_offset_path(ConsumerKind::ConsumerGroup, group_id as u32)
{
@@ -713,10 +821,11 @@ where
}
}
- /// Capture the owned inputs for an auto-commit, if requested. The committed
- /// offset is unknown until the poll completes, so the persist path + fsync
- /// flag (for the disk write) and the lock-free offset-map `Arc` (for the
- /// in-memory apply) are captured here, so both run off the partition borrow.
+ /// Capture the owned inputs for an auto-commit, if requested: the lock-free
+ /// offset-map `Arc` and the target consumer/group id, so the in-memory apply
+ /// runs off the partition borrow once the poll's served offset is known.
+ /// Durability is not captured here: the poll no longer writes the offset
+ /// file, the serving shard replicates the offset through consensus instead.
fn auto_commit_ctx(
&self,
consumer: PollingConsumer,
@@ -726,7 +835,6 @@ where
return None;
}
let pending = PendingConsumerOffsetCommit::try_from_polling_consumer(consumer, 0).ok()?;
- let offset_path = self.persisted_offset_path(pending.kind, pending.consumer_id);
let target = match pending.kind {
ConsumerKind::Consumer => AutoCommitTarget::Consumer {
offsets: self.consumer_offsets.clone(),
@@ -739,11 +847,7 @@ where
create_path: self.consumer_group_offsets_path.clone(),
},
};
- Some(AutoCommitCtx {
- offset_path,
- enforce_fsync: self.consumer_offset_enforce_fsync,
- target,
- })
+ Some(AutoCommitCtx { target })
}
/// Synchronous in-memory journal poll, for the resident tier. Never awaits
@@ -1052,30 +1156,30 @@ where
.with_operation(message.header().operation)
.with_error(error.to_string()),
);
- // Reply with a terminal error instead of dropping the request:
- // the SDK decodes any non-`TransientNotCommitted` result code as a
- // final error, so the client gets `ConsumerOffsetNotFound` at once
- // rather than replaying until its read-timeout (a client hang).
- // The op is NOT admitted/replicated, so no replica ever applies a
- // delete for an offset the primary knows is absent.
- let reply = build_result_rejection_reply(
+ // Deny on the primary before the op enters the pipeline: nothing
+ // replicates, so backups never see the rejected delete, and the
+ // client gets a typed failure instead of waiting out its reply
+ // timeout. The code rides `ReplyHeader.status` (not the result
+ // body): the HTTP listener's `classify_partition_reply` reads the
+ // status field to render the typed 404.
+ Self::send_partition_deny_or_log(
+ consensus,
message.header(),
- self.consensus().commit_max(),
error.as_code(),
- );
- let _ = self
- .consensus()
- .message_bus()
- .send_to_client(client_id, reply.into_generic().into_frozen())
- .await;
+ "delete_consumer_offset deny reply send failed",
+ )
+ .await;
return;
}
- // Reject an out-of-range consumer-offset store at admission with a
- // terminal `InvalidOffset` (result-framed like the delete rejection),
- // mirroring the legacy `validate_partition_offset`: an empty partition
- // accepts no offset, and a stored offset may not run ahead of the
- // committed offset. Done here so the doomed op is never replicated.
+ // Reject an out-of-range consumer-offset store at admission,
+ // mirroring the legacy `validate_partition_offset`: an empty
+ // partition accepts no offset, and a stored offset may not run ahead
+ // of the committed offset. Done here so the doomed op is never
+ // replicated. Like the delete-offset deny above, the typed
+ // `InvalidOffset` rides `ReplyHeader.status` (op=0, empty body): the
+ // status-only `classify_partition_reply` would misread a result-body
+ // code on this committed-shaped frame (op=commit_max) as success.
if matches!(
message.header().operation,
Operation::StoreConsumerOffset | Operation::StoreConsumerOffset2
@@ -1094,16 +1198,13 @@ where
.with_operation(message.header().operation)
.with_error(IggyError::InvalidOffset(requested_offset).to_string()),
);
- let reply = build_result_rejection_reply(
+ Self::send_partition_deny_or_log(
+ consensus,
message.header(),
- self.consensus().commit_max(),
IggyError::InvalidOffset(requested_offset).as_code(),
- );
- let _ = self
- .consensus()
- .message_bus()
- .send_to_client(client_id, reply.into_generic().into_frozen())
- .await;
+ "store_consumer_offset deny reply send failed",
+ )
+ .await;
return;
}
}
@@ -1578,6 +1679,7 @@ where
kind,
consumer_id,
offset.expect("store_consumer_offset must include offset"),
+ is_auto_commit_client(header.client),
);
}
Operation::DeleteConsumerOffset | Operation::DeleteConsumerOffset2 => {
@@ -1839,7 +1941,7 @@ where
let committed_visible_offsets = self.resolve_committed_visible_offsets(&drained).await;
let mut messages_committed = false;
- for mut entry in drained {
+ for entry in drained {
let prepare_header = entry.header;
if !self
.commit_partition_entry(
@@ -1889,20 +1991,17 @@ where
// No reply cache: at-least-once means retries re-commit at new
// offsets. Only primary delivers replies; backups just advance
// commit. Session lifecycle is metadata-only.
- let reply = build_reply_message(
- &prepare_header,
- &committed_reply_body(prepare_header.operation),
- );
-
- // TODO: no production caller yet. Partition has no in-process
- // subscriber (only metadata uses pipeline_message_with_subscriber);
- // wired for forward-compat. Fired AFTER local commit (slot-first
- // ordering analog). Dropped receiver ignored.
- if let Some(sender) = entry.take_reply_sender() {
- let _ = sender.send(reply.clone());
- }
-
- if send_client_replies {
+ //
+ // A server-generated auto-commit op (a poll's `auto_commit`,
+ // replicated for failover) carries the reserved
+ // `AUTO_COMMIT_CLIENT_ID`: no client ever waits on it, so skip the
+ // reply. Emitting it would push an unrequested frame onto a real
+ // client's lockstep reply stream if the sentinel ever routed there.
+ if send_client_replies && !is_auto_commit_client(prepare_header.client) {
+ let reply = build_reply_message(
+ &prepare_header,
+ &committed_reply_body(prepare_header.operation),
+ );
let reply_buffers = reply.into_generic().into_frozen();
emit_sim_event(SimEventKind::ClientReplyEmitted, &event);
@@ -2071,6 +2170,34 @@ where
Self::parse_consumer_offset_payload(operation, body)
}
+ /// Send `header`'s deny reply with `status` on `ReplyHeader.status` (empty
+ /// body, op=0), logging a WARN under `send_fail_label` if the reply send
+ /// fails. Callers deny on the primary, before the op enters the pipeline,
+ /// so nothing replicates.
+ async fn send_partition_deny_or_log(
+ consensus: &VsrConsensus<B>,
+ header: &RequestHeader,
+ status: u32,
+ send_fail_label: &'static str,
+ ) {
+ let reply = build_deny_reply_from_request(consensus, header, status);
+ if let Err(send_error) = consensus
+ .message_bus()
+ .send_to_client(header.client, reply.into_generic().into_frozen())
+ .await
+ {
+ emit_partition_diag(
+ tracing::Level::WARN,
+ &PartitionDiagEvent::new(
+ ReplicaLogContext::from_consensus(consensus, PlaneKind::Partitions),
+ send_fail_label,
+ )
+ .with_operation(header.operation)
+ .with_error(send_error.to_string()),
+ );
+ }
+ }
+
fn parse_staged_consumer_offset_commit(
operation: Operation,
message: &Message<PrepareHeader>,
@@ -2680,6 +2807,10 @@ where
for path in consumer_paths.into_iter().chain(group_paths) {
let _ = delete_persisted_offset(&path).await;
}
+ // The persisted-offset tracker mirrors the files unlinked above; a
+ // stale entry would make a post-purge auto-commit skip its write and
+ // lose the offset on restart.
+ self.persisted_offsets.borrow_mut().clear();
// Clear the ephemeral cooperative-rebalance tracking too: after the
// reset to offset 0 a stale `last_polled` (a high pre-purge offset)
@@ -2715,6 +2846,28 @@ where
}
}
+/// Commit-apply an upserted offset into a lock-free offset map. A server
+/// auto-commit already advanced this offset in memory on the serving poll and
+/// this replicated commit can land behind a newer poll, so it must be
+/// monotone (`fetch_max`) or it rewinds the map and re-serves consumed
+/// messages. An explicit client store keeps the rewinding `store` (an offset
+/// reset is a valid action).
+fn upsert_committed_offset<K>(
+ map: &papaya::HashMap<K, ConsumerOffset>,
+ key: K,
+ offset: u64,
+ auto_commit: bool,
+ create_on_miss: impl FnOnce() -> ConsumerOffset,
+) where
+ K: Hash + Eq + Clone + Send + Sync,
+{
+ if auto_commit {
+ crate::poll_plan::upsert_offset_max(map, key, offset, create_on_miss);
+ } else {
+ crate::poll_plan::upsert_offset(map, key, offset, create_on_miss);
+ }
+}
+
/// The operation tag at the front of a journal entry. Every entry begins with a
/// `PrepareHeader`, so reading the tag is a cheap cast, not a full batch decode;
/// it tells a committed consumer-offset op (no segment bytes) apart from a
@@ -2840,9 +2993,14 @@ mod tests {
use bytes::Bytes;
use compio::io::AsyncWriteAtExt;
use consensus::LocalPipeline;
+ use iggy_binary_protocol::{Command2, ReplyHeader, WireConsumer, WireEncode};
+ use message_bus::SendError;
+ use server_common::MESSAGE_ALIGN;
use server_common::send_messages2::{
COMMAND_HEADER_SIZE, IggyMessage2, IggyMessage2Header, IggyMessages2, SendMessages2Owned,
};
+ use std::cell::RefCell;
+ use std::rc::Rc;
const TEST_CLUSTER: u128 = 1;
@@ -2865,6 +3023,317 @@ mod tests {
)
}
+ /// Client-facing bus that records every `send_to_client` frame so tests
+ /// can assert on reply bytes without a connection registry (whose slot
+ /// guard would borrow the partition across `on_request(&mut self)`).
+ #[derive(Debug, Default)]
+ struct RecordingBus {
+ sent_to_clients: Rc<RefCell<Vec<(u128, Frozen<MESSAGE_ALIGN>)>>>,
+ }
+
+ impl MessageBus for RecordingBus {
+ fn track_background(&self, _handle: message_bus::JoinHandle<()>) {}
+
+ async fn send_to_client(
+ &self,
+ client_id: u128,
+ data: Frozen<MESSAGE_ALIGN>,
+ ) -> Result<(), SendError> {
+ self.sent_to_clients.borrow_mut().push((client_id, data));
+ Ok(())
+ }
+
+ async fn send_to_replica(
+ &self,
+ _replica: u8,
+ _data: Frozen<MESSAGE_ALIGN>,
+ ) -> Result<(), SendError> {
+ Ok(())
+ }
+
+ fn set_connection_lost_fn(&self, _f: message_bus::ConnectionLostFn) {}
+ fn set_replica_forward_fn(&self, _f: message_bus::ReplicaForwardFn) {}
+ fn set_client_forward_fn(&self, _f: message_bus::ClientForwardFn) {}
+ }
+
+ type SentFrames = Rc<RefCell<Vec<(u128, Frozen<MESSAGE_ALIGN>)>>>;
+
+ fn recording_partition() -> (IggyPartition<RecordingBus>, SentFrames) {
+ let namespace = IggyNamespace::new(1, 1, 0);
+ let bus = RecordingBus::default();
+ let sent_to_clients = bus.sent_to_clients.clone();
+ let consensus = VsrConsensus::new(
+ TEST_CLUSTER,
+ 0,
+ 1,
+ namespace.inner(),
+ bus,
+ LocalPipeline::new(),
+ );
+ consensus.init();
+ let partition = IggyPartition::with_in_memory_storage(
+ Arc::new(PartitionStats::default()),
+ consensus,
+ IggyByteSize::from(1024 * 1024),
+ false,
+ );
+ (partition, sent_to_clients)
+ }
+
+ fn delete_offset_request(
+ client_id: u128,
+ request_id: u64,
+ consumer_id: u32,
+ ) -> Message<RequestHeader> {
+ let body = DeleteConsumerOffset2Request {
+ consumer: WireConsumer::consumer(WireIdentifier::Numeric(consumer_id)),
+ stream_id: WireIdentifier::Numeric(1),
+ topic_id: WireIdentifier::Numeric(1),
+ partition_id: Some(0),
+ ack: AckLevel::Quorum,
+ }
+ .to_bytes();
+ let header_size = std::mem::size_of::<RequestHeader>();
+ let total = header_size + body.len();
+ let mut message = Message::<RequestHeader>::new(total);
+ message.as_mut_slice()[header_size..].copy_from_slice(&body);
+ message.transmute_header(|_, header: &mut RequestHeader| {
+ header.command = Command2::Request;
+ header.operation = Operation::DeleteConsumerOffset2;
+ header.client = client_id;
+ header.session = 1;
+ header.request = request_id;
+ header.namespace = IggyNamespace::new(1, 1, 0).inner();
+ header.size = u32::try_from(total).expect("request size fits u32");
+ })
+ }
+
+ /// Deleting a consumer offset that was never stored must answer with a
+ /// typed deny reply (empty body, `status` = `ConsumerOffsetNotFound`,
+ /// `op` 0) before consensus: nothing may enter the pipeline, and an
+ /// awaited client write must fail fast instead of waiting out its reply
+ /// timeout. Once the offset exists, the same request must pass the gate
+ /// into the pipeline without a deny.
+ #[compio::test]
+ async fn on_request_delete_of_missing_offset_replies_typed_deny() {
+ let (mut partition, sent_to_clients) = recording_partition();
+ let client_id: u128 = 42;
+ let consumer_id: u32 = 5;
+
+ partition
+ .on_request(delete_offset_request(client_id, 7, consumer_id))
+ .await;
+
+ {
+ let sent = sent_to_clients.borrow();
+ assert_eq!(sent.len(), 1, "exactly one deny reply");
+ let (reply_client, frame) = &sent[0];
+ assert_eq!(*reply_client, client_id);
+ let header = bytemuck::checked::try_from_bytes::<ReplyHeader>(
+ &frame.as_slice()[..std::mem::size_of::<ReplyHeader>()],
+ )
+ .expect("deny frame starts with a valid reply header");
+ assert_eq!(header.command, Command2::Reply);
+ assert_eq!(
+ header.status,
+ IggyError::ConsumerOffsetNotFound(0).as_code()
+ );
+ assert_eq!(header.op, 0, "a deny commits nothing");
+ assert_eq!(header.request, 7);
+ assert_eq!(
+ header.size as usize,
+ std::mem::size_of::<ReplyHeader>(),
+ "deny reply body must be empty"
+ );
+ }
+ assert_eq!(
+ partition.consensus().pipeline().borrow().len(),
+ 0,
+ "denied delete must not replicate"
+ );
+ assert!(partition.pending_consumer_offset_commits.is_empty());
+
+ // Existing offset: the gate passes and the delete enters the pipeline.
+ partition.consumer_offsets.pin().insert(
+ consumer_id as usize,
+ ConsumerOffset::new(ConsumerKind::Consumer, consumer_id, 3, String::new()),
+ );
+ partition
+ .on_request(delete_offset_request(client_id, 8, consumer_id))
+ .await;
+ assert_eq!(
+ partition.consensus().pipeline().borrow().len(),
+ 1,
+ "existing offset delete must replicate"
+ );
+ }
+
+ fn unique_temp_offset_dir() -> String {
+ let mut dir = std::env::temp_dir();
+ dir.push(format!(
+ "iggy-offset-test-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("system clock after epoch")
+ .as_nanos(),
+ ));
+ dir.to_string_lossy().into_owned()
+ }
+
+ /// A server auto-commit persists monotonically. Disk-tier polls replicate
+ /// their offsets in IO-completion order, so the last committed op can carry
+ /// a lower offset than an earlier one; the file must keep the max or a
+ /// restart reloads the rewound value and re-delivers. An explicit client
+ /// store still overwrites, so a deliberate offset reset holds.
+ #[compio::test]
+ async fn auto_commit_offset_persists_monotonically_explicit_store_rewinds() {
+ let mut partition = test_partition();
+ let dir = unique_temp_offset_dir();
+ partition.consumer_offsets_path = Some(dir.clone());
+ let consumer_id: u32 = 5;
+ let path = format!("{dir}/{consumer_id}");
+ let read_disk = |p: &str| -> u64 {
+ let bytes = std::fs::read(p).expect("offset file exists");
+ u64::from_le_bytes(bytes.try_into().expect("offset file is 8 bytes"))
+ };
+
+ // Reordered auto-commits: the later op (109) trails the earlier (114).
+ partition
+ .persist_consumer_offset_commit(PendingConsumerOffsetCommit::upsert_auto_commit(
+ ConsumerKind::Consumer,
+ consumer_id,
+ 114,
+ ))
+ .await
+ .expect("auto-commit persist 114");
+ partition
+ .persist_consumer_offset_commit(PendingConsumerOffsetCommit::upsert_auto_commit(
+ ConsumerKind::Consumer,
+ consumer_id,
+ 109,
+ ))
+ .await
+ .expect("auto-commit persist 109");
+ assert_eq!(
+ read_disk(&path),
+ 114,
+ "auto-commit must not rewind the file on IO-completion reorder"
+ );
+
+ assert!(
+ partition.is_auto_commit_offset_covered(ConsumerKind::Consumer, consumer_id, 114),
+ "committed high-water covers the persisted offset"
+ );
+ assert!(
+ !partition.is_auto_commit_offset_covered(ConsumerKind::Consumer, consumer_id, 115),
+ "an advancing offset is not covered and must submit"
+ );
+
+ // An explicit client store may deliberately rewind.
+ partition
+ .persist_consumer_offset_commit(PendingConsumerOffsetCommit::upsert(
+ ConsumerKind::Consumer,
+ consumer_id,
+ 109,
+ ))
+ .await
+ .expect("explicit store persist 109");
+ assert_eq!(read_disk(&path), 109, "explicit store may rewind the file");
+ assert!(
+ !partition.is_auto_commit_offset_covered(ConsumerKind::Consumer, consumer_id, 114),
+ "explicit rewind lowers the high-water so a later auto-commit may re-advance"
+ );
+
+ // The accepted edge: an auto-commit racing the explicit rewind
+ // re-advances the file past it.
+ partition
+ .persist_consumer_offset_commit(PendingConsumerOffsetCommit::upsert_auto_commit(
+ ConsumerKind::Consumer,
+ consumer_id,
+ 114,
+ ))
+ .await
+ .expect("auto-commit persist 114 after rewind");
+ assert_eq!(
+ read_disk(&path),
+ 114,
+ "auto-commit re-advances past a rewind"
+ );
+
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ /// The persisted-offset tracker is cold after a restart; the first
+ /// auto-commit folds against the file once (so a pre-existing higher value
+ /// wins, exactly like the old per-commit read-modify-write) and warms the
+ /// tracker with the on-disk value, not the op's. A delete drops both the
+ /// file and the tracker entry so a later auto-commit starts a fresh fold.
+ #[compio::test]
+ async fn auto_commit_cold_key_folds_against_file_once() {
+ let mut partition = test_partition();
+ let dir = unique_temp_offset_dir();
+ partition.consumer_offsets_path = Some(dir.clone());
+ let consumer_id: u32 = 5;
+ let path = format!("{dir}/{consumer_id}");
+ let read_disk = |p: &str| -> u64 {
+ let bytes = std::fs::read(p).expect("offset file exists");
+ u64::from_le_bytes(bytes.try_into().expect("offset file is 8 bytes"))
+ };
+
+ // Simulate the previous process run: the file already holds 114.
+ persist_offset(&path, 114, false)
+ .await
+ .expect("seed offset file");
+ assert!(
+ !partition.is_auto_commit_offset_covered(ConsumerKind::Consumer, consumer_id, 1),
+ "a cold key is never covered; the first submit must go through"
+ );
+
+ partition
+ .persist_consumer_offset_commit(PendingConsumerOffsetCommit::upsert_auto_commit(
+ ConsumerKind::Consumer,
+ consumer_id,
+ 109,
+ ))
+ .await
+ .expect("auto-commit persist 109 on cold key");
+ assert_eq!(
+ read_disk(&path),
+ 114,
+ "cold-key fold must not rewind the pre-existing on-disk value"
+ );
+ assert!(
+ partition.is_auto_commit_offset_covered(ConsumerKind::Consumer, consumer_id, 114),
+ "tracker warms with the on-disk value, not the trailing op offset"
+ );
+
+ partition
+ .persist_consumer_offset_commit(PendingConsumerOffsetCommit::delete(
+ ConsumerKind::Consumer,
+ consumer_id,
+ ))
+ .await
+ .expect("delete persisted offset");
+ assert!(!std::path::Path::new(&path).exists(), "file unlinked");
+ assert!(
+ !partition.is_auto_commit_offset_covered(ConsumerKind::Consumer, consumer_id, 1),
+ "delete drops the tracker entry with the file"
+ );
+
+ partition
+ .persist_consumer_offset_commit(PendingConsumerOffsetCommit::upsert_auto_commit(
+ ConsumerKind::Consumer,
+ consumer_id,
+ 7,
+ ))
+ .await
+ .expect("auto-commit persist 7 after delete");
+ assert_eq!(read_disk(&path), 7, "post-delete auto-commit starts fresh");
+
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
/// `reclaim_dead_group_offsets` must drop exactly the not-`is_live` groups
/// from the in-memory map and hand back their owned persisted-file paths,
/// leaving live groups untouched. The returned `Vec<String>` is what the
diff --git a/core/partitions/src/lib.rs b/core/partitions/src/lib.rs
index 12ba1cde1..64ae0d257 100644
--- a/core/partitions/src/lib.rs
+++ b/core/partitions/src/lib.rs
@@ -39,7 +39,7 @@ pub use iggy_partition::IggyPartition;
pub use iggy_partitions::IggyPartitions;
pub use messages_writer::MessagesWriter;
pub use offset_storage::delete_persisted_offset;
-pub use poll_plan::PollPlan;
+pub use poll_plan::{AutoCommitApplied, PollPlan};
pub use segment::Segment;
use server_common::Message;
pub use server_common::send_messages2::{IggyMessage2, IggyMessage2Header, IggyMessages2};
diff --git a/core/partitions/src/offset_storage.rs b/core/partitions/src/offset_storage.rs
index 249e5d8b0..ebd4cf9de 100644
--- a/core/partitions/src/offset_storage.rs
+++ b/core/partitions/src/offset_storage.rs
@@ -17,11 +17,13 @@
use compio::{
fs::{OpenOptions, create_dir_all, remove_file},
- io::AsyncWriteAtExt,
+ io::{AsyncReadAtExt, AsyncWriteAtExt},
};
use iggy_common::IggyError;
use std::path::Path;
+const OFFSET_SIZE: usize = core::mem::size_of::<u64>();
+
pub async fn persist_offset(path: &str, offset: u64, enforce_fsync: bool) -> Result<(), IggyError> {
if let Some(parent) = Path::new(path).parent()
&& !parent.exists()
@@ -53,6 +55,60 @@ pub async fn persist_offset(path: &str, offset: u64, enforce_fsync: bool) -> Res
Ok(())
}
+/// Monotone counterpart of [`persist_offset`] for a server auto-commit op:
+/// folds `max(current_on_disk, offset)` and returns the value now on disk,
+/// skipping the write when the file already holds it. Disk-tier polls
+/// replicate their auto-committed offsets in IO-completion order, so a
+/// committed op can carry a lower offset than an earlier one; a plain
+/// overwrite would leave the file rewound and a restart would reload the
+/// stale value and re-deliver. The on-disk value is committed-only (this path
+/// never writes the eager serving map), so the fold is identical on every
+/// replica applying the same op order.
+///
+/// The read makes this the cold-key path only: once the caller's
+/// persisted-offset tracker knows the file's value, warm commits persist with
+/// a blind [`persist_offset`] and skip covered offsets without any file read.
+pub async fn persist_offset_max(
+ path: &str,
+ offset: u64,
+ enforce_fsync: bool,
+) -> Result<u64, IggyError> {
+ let on_disk = read_persisted_offset(path).await?;
+ let effective = on_disk.map_or(offset, |current| current.max(offset));
+ if on_disk != Some(effective) {
+ persist_offset(path, effective, enforce_fsync).await?;
+ }
+ Ok(effective)
+}
+
+/// Read a single persisted consumer offset. `None` if the file is absent or
+/// torn (shorter than 8 bytes): a crash between `persist_offset`'s truncate
+/// and write leaves a short file, and the boot-time loader already skips such
+/// files, so the commit-path reader must agree or a torn file turns every
+/// later commit-apply into an error. Real I/O errors still propagate: mapping
+/// them to `None` would silently rewind a valid higher offset.
+async fn read_persisted_offset(path: &str) -> Result<Option<u64>, IggyError> {
+ if !Path::new(path).exists() {
+ return Ok(None);
+ }
+ let file = OpenOptions::new()
+ .read(true)
+ .open(path)
+ .await
+ .map_err(|_| IggyError::CannotOpenConsumerOffsetsFile(path.to_owned()))?;
+ let buf = vec![0u8; OFFSET_SIZE];
+ let compio::BufResult(read, buf) = file.read_exact_at(buf, 0).await;
+ match read {
+ Ok(()) => {}
+ Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
+ Err(_) => return Err(IggyError::CannotReadConsumerOffsets(path.to_owned())),
+ }
+ let bytes: [u8; OFFSET_SIZE] = buf
+ .try_into()
+ .map_err(|_| IggyError::CannotReadConsumerOffsets(path.to_owned()))?;
+ Ok(Some(u64::from_le_bytes(bytes)))
+}
+
/// Unlink a persisted consumer-offset file. A no-op if the file is absent.
///
/// # Errors
@@ -66,3 +122,90 @@ pub async fn delete_persisted_offset(path: &str) -> Result<(), IggyError> {
.await
.map_err(|_| IggyError::CannotDeleteConsumerOffsetFile(path.to_owned()))
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn unique_temp_dir() -> std::path::PathBuf {
+ let dir = std::env::temp_dir().join(format!(
+ "iggy-offset-storage-test-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("system clock after epoch")
+ .as_nanos(),
+ ));
+ std::fs::create_dir_all(&dir).expect("create temp dir");
+ dir
+ }
+
+ #[compio::test]
+ async fn read_persisted_offset_absent_file_is_none() {
+ let dir = unique_temp_dir();
+ let path = dir.join("42").to_string_lossy().into_owned();
+
+ let read = read_persisted_offset(&path).await.expect("absent file");
+ assert_eq!(read, None);
+
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[compio::test]
+ async fn read_persisted_offset_round_trips_persisted_value() {
+ let dir = unique_temp_dir();
+ let path = dir.join("42").to_string_lossy().into_owned();
+
+ persist_offset(&path, 114, false).await.expect("persist");
+ let read = read_persisted_offset(&path).await.expect("valid file");
+ assert_eq!(read, Some(114));
+
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[compio::test]
+ async fn read_persisted_offset_torn_file_is_none_not_error() {
+ let dir = unique_temp_dir();
+ let path = dir.join("42").to_string_lossy().into_owned();
+ std::fs::write(&path, [0xAB, 0xCD, 0xEF]).expect("write torn file");
+
+ let read = read_persisted_offset(&path)
+ .await
+ .expect("torn file must not error the commit path");
+ assert_eq!(read, None, "short read maps to None like the boot loader");
+
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[compio::test]
+ async fn read_persisted_offset_real_io_error_propagates() {
+ // A directory opens read-only but every read fails with EISDIR: a real
+ // I/O error, not a short read. It must surface as Err, never as None
+ // (a blanket None would silently rewind a valid higher offset).
+ let dir = unique_temp_dir();
+ let path = dir.to_string_lossy().into_owned();
+
+ let result = read_persisted_offset(&path).await;
+ assert!(
+ matches!(result, Err(IggyError::CannotReadConsumerOffsets(_))),
+ "real I/O error must propagate, got {result:?}",
+ );
+
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[compio::test]
+ async fn persist_offset_max_recovers_torn_file() {
+ let dir = unique_temp_dir();
+ let path = dir.join("42").to_string_lossy().into_owned();
+ std::fs::write(&path, [0xABu8; 5]).expect("write torn file");
+
+ persist_offset_max(&path, 7, false)
+ .await
+ .expect("torn file folds as absent");
+ let read = read_persisted_offset(&path).await.expect("repaired file");
+ assert_eq!(read, Some(7));
+
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+}
diff --git a/core/partitions/src/poll_plan.rs b/core/partitions/src/poll_plan.rs
index aa9b7c54d..4792760d3 100644
--- a/core/partitions/src/poll_plan.rs
+++ b/core/partitions/src/poll_plan.rs
@@ -22,18 +22,17 @@
//! `&mut` to the same namespace while a poll is parked, dangling the reference.
//! So `IggyPartition::build_poll_plan` captures everything a poll needs
//! synchronously under the borrow into the owned types here, drops the borrow,
-//! then [`PollPlan::execute`] runs the disk read + offset persist on owned data
-//! alone: consumer offsets are already `Arc`, the journal tail is a
-//! point-in-time `Frozen` snapshot, and segment files are re-opened by path. No
-//! value in this module holds a partition reference, so executing a plan is
-//! sound on a detached task concurrently with the pump's own writes.
+//! then [`PollPlan::execute`] runs the disk read + the in-memory auto-commit
+//! apply on owned data alone: consumer offsets are already `Arc`, the journal
+//! tail is a point-in-time `Frozen` snapshot, and segment files are re-opened
+//! by path. No value in this module holds a partition reference, so executing a
+//! plan is sound on a detached task concurrently with the pump's own writes.
use crate::PollFragments;
use crate::journal::{MessageLookup, push_selected_batch_fragments, select_batch_slice};
-use crate::offset_storage::persist_offset;
use compio::io::AsyncReadAtExt;
use iggy_common::{
- ConsumerGroupId, ConsumerGroupOffsets, ConsumerKind, ConsumerOffset, ConsumerOffsets, IggyError,
+ ConsumerGroupId, ConsumerGroupOffsets, ConsumerKind, ConsumerOffset, ConsumerOffsets,
};
use server_common::iobuf::{Frozen, Owned};
use server_common::send_messages2::{COMMAND_HEADER_SIZE, decode_batch_slice};
@@ -60,15 +59,27 @@ pub struct DiskSegment {
pub(crate) persisted: u64,
}
-/// Owned auto-commit inputs, applied off the partition borrow after a poll (see
-/// module docs). The committed offset is unknown until the poll completes, so
-/// the persist path + fsync flag are captured up front for the later disk write.
+/// Owned auto-commit input, applied off the partition borrow after a poll (see
+/// module docs). Only the in-memory apply happens here; durability is the
+/// replicated [`crate::iggy_partition::IggyPartition::apply_staged_consumer_offset_commit`]
+/// path's job on every node, driven by the `StoreConsumerOffset2` op the serving
+/// shard submits from [`AutoCommitApplied`]. A poll-local disk write would be
+/// node-local only and diverge on failover.
pub struct AutoCommitCtx {
- pub(crate) offset_path: Option<String>,
- pub(crate) enforce_fsync: bool,
pub(crate) target: AutoCommitTarget,
}
+/// The offset an `auto_commit` poll applied in memory, surfaced for replication.
+///
+/// The serving shard replicates it through the partition consensus (the only
+/// cross-node durable path); `kind` + `consumer_id` are the offset key the
+/// submitted `StoreConsumerOffset2` op must carry.
+pub struct AutoCommitApplied {
+ pub kind: ConsumerKind,
+ pub consumer_id: u32,
+ pub offset: u64,
+}
+
/// The lock-free offset map this auto-commit updates, captured as an owned
/// `Arc` so the apply needs no partition borrow. `create_path` builds the
/// `ConsumerOffset` entry on first commit for a consumer that has none yet.
@@ -158,30 +169,27 @@ pub struct PollPlan {
}
impl PollPlan {
- /// Whether executing this plan needs off-pump IO: a disk read (`Disk` tier)
- /// or a consumer-offset persist (`auto_commit`). When `false`, the result is
- /// fully resident and the caller can [`Self::execute_resident`] + reply on
- /// the pump without spawning; when `true`, it must spawn [`Self::execute`]
- /// so the pump is not blocked on file IO.
+ /// Whether executing this plan needs off-pump IO: only a `Disk` tier read.
+ /// When `false` the result is fully resident and the caller runs
+ /// [`Self::execute_resident`] + replies on the pump; when `true` it must
+ /// spawn [`Self::execute`] so the pump is not blocked on file IO.
+ ///
+ /// Auto-commit no longer forces a detached task: its in-memory apply is
+ /// synchronous and its durability rides consensus off the serving shard
+ /// (no poll-local disk write), so a fully-resident `auto_commit` poll still
+ /// replies inline.
#[must_use]
pub const fn needs_off_pump_io(&self) -> bool {
- if matches!(self.tier, PollTier::Disk { .. }) {
- return true;
- }
- // A resident poll only needs a detached task when its auto-commit must
- // hit disk. With no `offset_path` (sim/dev) the apply is a sync,
- // in-memory store the pump runs inline via `execute_resident`.
- match &self.auto_commit {
- Some(auto_commit) => auto_commit.needs_persist(),
- None => false,
- }
+ matches!(self.tier, PollTier::Disk { .. })
}
/// Execute this plan off the partition borrow: disk read (if any), straddle
- /// splice into the owned resident-tail snapshot, then persist + apply the
- /// auto-commit on the owned `Arc` offset map. Holds no partition reference
- /// (see module docs), so it is safe on a detached task.
- pub async fn execute(self) -> (PollFragments<4096>, u64) {
+ /// splice into the owned resident-tail snapshot, then apply the auto-commit
+ /// to the owned `Arc` offset map. Holds no partition reference (see module
+ /// docs), so it is safe on a detached task. Returns the served fragments,
+ /// the poll's high-water offset, and the auto-committed offset (if any) for
+ /// the serving shard to replicate through consensus.
+ pub async fn execute(self) -> (PollFragments<4096>, u64, Option<AutoCommitApplied>) {
let commit_offset = self.commit_offset;
let (fragments, last_matching_offset) = match self.tier {
PollTier::Empty => (PollFragments::new(), None),
@@ -240,34 +248,21 @@ impl PollPlan {
},
};
- if let (Some(last_polled), Some(last_offset)) = (&self.last_polled, last_matching_offset) {
- last_polled.record(last_offset);
- }
-
- if let Some(auto_commit) = self.auto_commit
- && !fragments.is_empty()
- && let Some(last_offset) = last_matching_offset
- {
- match auto_commit.persist(last_offset).await {
- // Apply to the in-memory map on the owned `Arc` (no borrow).
- Ok(()) => auto_commit.apply(last_offset),
- Err(err) => warn!(
- target: "iggy.partitions.diag",
- last_offset,
- %err,
- "poll_read: failed to persist consumer offset"
- ),
- }
- }
-
- (fragments, commit_offset)
+ finish(
+ self.last_polled.as_ref(),
+ self.auto_commit,
+ commit_offset,
+ fragments,
+ last_matching_offset,
+ )
}
/// Synchronous fast path for a fully-resident poll
- /// ([`Self::needs_off_pump_io`] is `false`): no disk read, no
- /// consumer-offset persist, so the pump can reply inline without spawning.
+ /// ([`Self::needs_off_pump_io`] is `false`): no disk read, so the pump
+ /// applies the auto-commit in memory and replies inline without spawning.
+ /// The auto-committed offset is returned for the serving shard to replicate.
#[must_use]
- pub fn execute_resident(self) -> (PollFragments<4096>, u64) {
+ pub fn execute_resident(self) -> (PollFragments<4096>, u64, Option<AutoCommitApplied>) {
let commit_offset = self.commit_offset;
let (fragments, last_matching_offset) = match self.tier {
PollTier::Empty => (PollFragments::new(), None),
@@ -281,23 +276,61 @@ impl PollPlan {
unreachable!("execute_resident on Disk tier; needs_off_pump_io guards this")
}
};
- if let (Some(last_polled), Some(last_offset)) = (&self.last_polled, last_matching_offset) {
- last_polled.record(last_offset);
- }
- // An auto-commit reaches the resident fast path only when it needs no
- // disk persist (`needs_off_pump_io` gate), so apply the offset to the
- // in-memory map inline. `execute()` does the same after its persist;
- // skipping it here would silently drop the committed offset.
- if let Some(auto_commit) = self.auto_commit
- && !fragments.is_empty()
- && let Some(last_offset) = last_matching_offset
- {
- auto_commit.apply(last_offset);
- }
- (fragments, commit_offset)
+ finish(
+ self.last_polled.as_ref(),
+ self.auto_commit,
+ commit_offset,
+ fragments,
+ last_matching_offset,
+ )
}
}
+/// Common tail of [`PollPlan::execute`] and [`PollPlan::execute_resident`],
+/// factored out so the high-water record, auto-commit, and returned triple
+/// stay identical across both.
+fn finish(
+ last_polled: Option<&LastPolledCtx>,
+ auto_commit: Option<AutoCommitCtx>,
+ commit_offset: u64,
+ fragments: PollFragments<4096>,
+ last_matching_offset: Option<u64>,
+) -> (PollFragments<4096>, u64, Option<AutoCommitApplied>) {
+ if let (Some(last_polled), Some(last_offset)) = (last_polled, last_matching_offset) {
+ last_polled.record(last_offset);
+ }
+ let auto_commit_applied = apply_auto_commit(auto_commit, &fragments, last_matching_offset);
+ (fragments, commit_offset, auto_commit_applied)
+}
+
+/// Apply an `auto_commit` to the in-memory offset map (monotone) and surface
+/// the committed offset so the serving shard can replicate it through
+/// consensus. `None` when the poll served nothing (empty fragments) or no
+/// auto-commit was requested. Shared by [`PollPlan::execute`] and
+/// [`PollPlan::execute_resident`] so both apply identically.
+///
+/// The eager in-memory apply preserves read-your-own-poll for a tight
+/// `Consumer::Next` loop that reads before the replicated commit lands; the
+/// commit's apply is an idempotent monotone set, so the double-apply converges.
+fn apply_auto_commit(
+ auto_commit: Option<AutoCommitCtx>,
+ fragments: &PollFragments<4096>,
+ last_matching_offset: Option<u64>,
+) -> Option<AutoCommitApplied> {
+ let auto_commit = auto_commit?;
+ if fragments.is_empty() {
+ return None;
+ }
+ let last_offset = last_matching_offset?;
+ auto_commit.apply(last_offset);
+ let (kind, consumer_id) = auto_commit.kind_and_id();
+ Some(AutoCommitApplied {
+ kind,
+ consumer_id,
+ offset: last_offset,
+ })
+}
+
pub enum PollTier {
Empty,
Resident {
@@ -512,18 +545,16 @@ impl DiskReadPlan {
}
impl AutoCommitCtx {
- /// Whether applying this auto-commit needs a real disk persist. `false` for
- /// sim/dev partitions with no `offset_path`, where the apply is a sync,
- /// in-memory `papaya` store that the pump can run inline.
- pub(crate) const fn needs_persist(&self) -> bool {
- self.offset_path.is_some()
- }
-
- /// Persist the committed offset to disk, off the partition borrow.
- pub(crate) async fn persist(&self, offset: u64) -> Result<(), IggyError> {
- match &self.offset_path {
- Some(path) => persist_offset(path, offset, self.enforce_fsync).await,
- None => Ok(()),
+ /// The offset key (kind + numeric id) this auto-commit targets, for the
+ /// replicated `StoreConsumerOffset2` op the serving shard submits.
+ pub(crate) const fn kind_and_id(&self) -> (ConsumerKind, u32) {
+ match &self.target {
+ AutoCommitTarget::Consumer { consumer_id, .. } => {
+ (ConsumerKind::Consumer, *consumer_id)
+ }
+ AutoCommitTarget::ConsumerGroup { group_id, .. } => {
+ (ConsumerKind::ConsumerGroup, *group_id)
+ }
}
}
@@ -610,7 +641,12 @@ pub fn upsert_offset<K>(
/// newer explicit `StoreConsumerOffset` cannot rewind it backward. The
/// on-miss create branch is identical. The explicit pump path keeps
/// [`upsert_offset`] (`store`), since an explicit store may legitimately rewind.
-fn upsert_offset_max<K>(
+///
+/// Also used by the replicated commit-apply for a server auto-commit op
+/// ([`crate::iggy_partition::IggyPartition::apply_consumer_offset_commit`]): its
+/// offset was already advanced in memory by the eager poll-path apply, and this
+/// commit can land behind a newer poll, so it must not `store` (rewind) it.
+pub fn upsert_offset_max<K>(
map: &papaya::HashMap<K, ConsumerOffset>,
key: K,
offset: u64,
@@ -684,14 +720,8 @@ mod tests {
fragments
}
- fn consumer_auto_commit(
- offsets: Arc<ConsumerOffsets>,
- consumer_id: u32,
- offset_path: Option<String>,
- ) -> AutoCommitCtx {
+ fn consumer_auto_commit(offsets: Arc<ConsumerOffsets>, consumer_id: u32) -> AutoCommitCtx {
AutoCommitCtx {
- offset_path,
- enforce_fsync: false,
target: AutoCommitTarget::Consumer {
offsets,
consumer_id,
@@ -701,15 +731,15 @@ mod tests {
}
#[test]
- fn resident_auto_commit_without_offset_path_applies_inline() {
- // sim/dev partition: auto_commit is requested but there is no
- // `offset_path`, so the apply is a sync in-memory store. The plan must
- // stay on the resident fast path (no detached task) AND still record
- // the committed offset, which the resident path used to drop.
+ fn resident_auto_commit_applies_in_memory_and_surfaces_offset() {
+ // A resident auto_commit poll stays on the inline fast path (no detached
+ // task since the poll no longer persists), applies the committed offset
+ // to the in-memory map for read-your-own-poll, AND surfaces it so the
+ // serving shard replicates it through consensus.
let offsets = Arc::new(ConsumerOffsets::with_capacity(1));
let plan = PollPlan {
commit_offset: 42,
- auto_commit: Some(consumer_auto_commit(offsets.clone(), 7, None)),
+ auto_commit: Some(consumer_auto_commit(offsets.clone(), 7)),
last_polled: None,
tier: PollTier::Resident {
fragments: non_empty_fragments(),
@@ -719,13 +749,18 @@ mod tests {
assert!(
!plan.needs_off_pump_io(),
- "no offset_path means the apply is sync; the pump must not spawn",
+ "a resident auto_commit no longer persists on the poll path; the pump must not spawn",
);
- let (fragments, commit_offset) = plan.execute_resident();
+ let (fragments, commit_offset, applied) = plan.execute_resident();
assert!(!fragments.is_empty(), "resident fragments must be returned");
assert_eq!(commit_offset, 42, "commit offset is forwarded verbatim");
+ let applied = applied.expect("auto_commit must surface the applied offset for replication");
+ assert!(matches!(applied.kind, ConsumerKind::Consumer));
+ assert_eq!(applied.consumer_id, 7);
+ assert_eq!(applied.offset, 5);
+
let stored = offsets
.pin()
.get(&7usize)
@@ -738,24 +773,25 @@ mod tests {
}
#[test]
- fn resident_auto_commit_with_offset_path_needs_off_pump_io() {
+ fn empty_resident_poll_surfaces_no_auto_commit() {
+ // Nothing served -> nothing to commit: no offset is surfaced and the
+ // in-memory map stays untouched.
let offsets = Arc::new(ConsumerOffsets::with_capacity(1));
let plan = PollPlan {
- commit_offset: 0,
- auto_commit: Some(consumer_auto_commit(
- offsets,
- 7,
- Some("some/path".to_owned()),
- )),
+ commit_offset: 9,
+ auto_commit: Some(consumer_auto_commit(offsets.clone(), 7)),
last_polled: None,
- tier: PollTier::Resident {
- fragments: non_empty_fragments(),
- last_matching_offset: Some(5),
- },
+ tier: PollTier::Empty,
};
+ let (fragments, _commit_offset, applied) = plan.execute_resident();
+ assert!(fragments.is_empty());
+ assert!(
+ applied.is_none(),
+ "empty poll must not surface an auto-commit"
+ );
assert!(
- plan.needs_off_pump_io(),
- "a real offset_path persist must be spawned off the pump",
+ offsets.pin().get(&7usize).is_none(),
+ "an empty poll must not touch the offset map",
);
}
@@ -764,7 +800,7 @@ mod tests {
// Auto-commit must never rewind a newer offset (anti-rewind via
// fetch_max); an explicit StoreConsumerOffset may legitimately rewind.
let offsets = Arc::new(ConsumerOffsets::with_capacity(1));
- let auto_commit = consumer_auto_commit(offsets.clone(), 7, None);
+ let auto_commit = consumer_auto_commit(offsets.clone(), 7);
auto_commit.apply(10);
let after_high = offsets
diff --git a/core/sdk/src/session.rs b/core/sdk/src/session.rs
index 39f35c43c..ac56c0b33 100644
--- a/core/sdk/src/session.rs
+++ b/core/sdk/src/session.rs
@@ -30,15 +30,17 @@
//!
//! ```text
//! new() - fresh client_id generated, session = None
-//! register_request_id() - returns 0 (for the register operation, once only)
+//! begin_register() - returns 0; re-arms a used session for a re-login
//! bind(session) - session assigned by server after register commits
//! next_request_id() - returns 1, 2, 3, ... (application requests)
//! drop + new() - on disconnect/crash, create a fresh session
//! ```
//!
-//! `ConsensusSession` is **not reusable**: on disconnect/crash drop and
-//! recreate. New `client_id` avoids ambiguous re-register; old entry
-//! stays in server `ClientTable` until evicted.
+//! A single registration is one-shot ([`ConsensusSession::register_request_id`]
+//! panics if reused), but a re-login re-arms the same value in place via
+//! [`ConsensusSession::begin_register`], which mints a fresh `client_id` and
+//! clears the binding. A new `client_id` avoids ambiguous re-register; the old
+//! entry stays in the server `ClientTable` until evicted.
/// Consensus-level session state.
///
@@ -127,6 +129,21 @@ impl ConsensusSession {
0
}
+ /// Begin a registration, re-arming the session if it was already used.
+ ///
+ /// A re-login (fresh credentials, reconnect, or restart replay) reuses the
+ /// same `ConsensusSession`. If it is already bound or a prior register was
+ /// consumed, start a fresh session (new `client_id`, unbound) so the
+ /// Register encodes cleanly. Unlike the one-shot
+ /// [`register_request_id`](Self::register_request_id), this never panics on
+ /// a repeat login. Returns the register request id (always 0).
+ pub fn begin_register(&mut self) -> u64 {
+ if self.register_consumed || self.is_bound() {
+ *self = Self::new();
+ }
+ self.register_request_id()
+ }
+
/// Get the next application request ID and advance the counter.
///
/// Returns 1, 2, 3, ... (request 0 is reserved for register).
@@ -215,6 +232,35 @@ mod tests {
let _ = session.register_request_id();
}
+ #[test]
+ fn begin_register_on_fresh_session_keeps_client_id() {
+ let mut session = ConsensusSession::with_client_id(7);
+ assert_eq!(session.begin_register(), 0);
+ // The first registration keeps the session's client id; only a re-login
+ // re-arms with a fresh one.
+ assert_eq!(session.client_id(), 7);
+ assert!(!session.is_bound());
+ }
+
+ #[test]
+ fn begin_register_re_arms_after_consume_without_bind() {
+ // A prior register that never bound (failed login / dropped connection
+ // before the retry) must not panic the next login.
+ let mut session = ConsensusSession::with_client_id(1);
+ let _ = session.begin_register();
+ assert_eq!(session.begin_register(), 0);
+ assert!(!session.is_bound());
+ }
+
+ #[test]
+ fn begin_register_re_arms_after_bind() {
+ let mut session = ConsensusSession::with_client_id(1);
+ let _ = session.begin_register();
+ session.bind(42);
+ assert_eq!(session.begin_register(), 0);
+ assert!(!session.is_bound());
+ }
+
#[test]
#[should_panic(expected = "next_request_id called before bind")]
fn next_request_id_before_bind_panics() {
diff --git a/core/sdk/src/vsr.rs b/core/sdk/src/vsr.rs
index 54fdb273c..d1e9ab859 100644
--- a/core/sdk/src/vsr.rs
+++ b/core/sdk/src/vsr.rs
@@ -37,9 +37,8 @@ use iggy_binary_protocol::requests::consumer_offsets::{
};
use iggy_binary_protocol::requests::messages::SendMessagesHeader;
use iggy_binary_protocol::requests::segments::DeleteSegmentsRequest;
-use iggy_binary_protocol::version::IGGY_PROTOCOL_VERSION;
use iggy_binary_protocol::{WireIdentifier, WirePartitioning};
-use iggy_common::IggyError;
+use iggy_common::{IggyError, eviction_reason_to_error};
const NON_REPLICATED_CODE_RANGE: std::ops::Range<usize> = 0..4;
@@ -72,7 +71,12 @@ pub(crate) fn encode_request_header(
) -> Result<(RequestHeader, usize), IggyError> {
let (operation, request_id, session_id) = match code {
LOGIN_REGISTER_CODE | LOGIN_REGISTER_WITH_PAT_CODE => {
- (Operation::Register, session.register_request_id(), 0)
+ // A re-login reuses this `ConsensusSession`; `begin_register`
+ // re-arms it (fresh session) so the Register encodes cleanly instead
+ // of tripping the one-shot register guard. Safe under the transport
+ // stream lock: it is lockstep (one request in flight), so no
+ // in-flight request observes the reset.
+ (Operation::Register, session.begin_register(), 0)
}
_ => {
let operation = operation_for_code(code)?;
@@ -114,7 +118,7 @@ pub(crate) fn encode_request_header(
.checked_add(payload.len())
.ok_or(IggyError::InvalidConfiguration)?;
let size = u32::try_from(total_size).map_err(|_| IggyError::InvalidConfiguration)?;
- let mut reserved = [0; 56];
+ let mut reserved = [0; 52];
if operation == Operation::NonReplicated {
reserved[NON_REPLICATED_CODE_RANGE].copy_from_slice(&code.to_le_bytes());
}
@@ -179,6 +183,9 @@ pub(crate) fn decode_response(response: Bytes) -> Result<Bytes, IggyError> {
if response.len() < total_size {
return Err(IggyError::InvalidCommand);
}
+ if let Some(error) = read_reply_status(header_bytes) {
+ return Err(error);
+ }
let operation = read_operation(header_bytes)?;
split_metadata_result(operation, response.slice(HEADER_SIZE..total_size))
}
@@ -206,6 +213,9 @@ pub(crate) fn decode_response_split(
if body.len() < expected_body {
return Err(IggyError::InvalidCommand);
}
+ if let Some(error) = read_reply_status(header_bytes) {
+ return Err(error);
+ }
let operation = read_operation(header_bytes)?;
split_metadata_result(operation, body.slice(..expected_body))
}
@@ -213,6 +223,24 @@ pub(crate) fn decode_response_split(
}
}
+/// Peek `ReplyHeader.status`, the pre-commit deny channel shared by every deny
+/// frame: dispatch-time denies (authorization and a malformed user-password
+/// body) via `build_deny_reply`, and partition-primary admission rejects via
+/// `consensus::build_deny_reply_from_request`. Read before any body decode:
+/// a nonzero status maps to its [`IggyError`] and fails the request, an empty
+/// or partial body notwithstanding. `None` (status 0) lets the reply flow to
+/// the body/result-section decode. Read by wire offset for the same
+/// misalignment reason as [`read_operation`]; a committed metadata reply stamps
+/// 0 here and carries its business rejection in the result section (see
+/// `split_metadata_result`).
+fn read_reply_status(header_bytes: &[u8; HEADER_SIZE]) -> Option<IggyError> {
+ const STATUS_OFFSET: usize = std::mem::offset_of!(ReplyHeader, status);
+ let mut bytes = [0u8; 4];
+ bytes.copy_from_slice(&header_bytes[STATUS_OFFSET..STATUS_OFFSET + 4]);
+ let status = u32::from_le_bytes(bytes);
+ (status != 0).then(|| IggyError::from_code(status))
+}
+
/// Read the [`Operation`] discriminant from a reply header by wire offset.
/// Mirrors [`peek_command`]: a full `ReplyHeader` struct cast is avoided
/// because the response buffers are not 16-aligned, so the leading `u128`
@@ -295,26 +323,11 @@ fn decode_eviction(header_bytes: &[u8; HEADER_SIZE]) -> IggyError {
) else {
return IggyError::Unauthenticated;
};
- match reason {
- EvictionReason::InvalidCredentials => IggyError::InvalidCredentials,
- EvictionReason::InvalidToken => IggyError::InvalidPersonalAccessToken,
- EvictionReason::UserInactive
- | EvictionReason::SessionError
- | EvictionReason::NoSession
- | EvictionReason::SessionTooLow
- | EvictionReason::SessionReleaseMismatch => IggyError::Unauthenticated,
- EvictionReason::StaleClient => IggyError::StaleClient,
- EvictionReason::IncompatibleProtocol => {
- let server_max = read_window_field(header_bytes, VERSION_OFFSET);
- let server_min = read_window_field(header_bytes, VERSION_MIN_OFFSET);
- if server_min == 0 || server_max < server_min {
- return IggyError::Unauthenticated;
- }
- IggyError::IncompatibleProtocolVersion(IGGY_PROTOCOL_VERSION, server_min, server_max)
- }
- EvictionReason::MalformedLogin => IggyError::InvalidFormat,
- _ => IggyError::InvalidCommand,
- }
+ eviction_reason_to_error(
+ reason,
+ read_window_field(header_bytes, VERSION_OFFSET),
+ read_window_field(header_bytes, VERSION_MIN_OFFSET),
+ )
}
fn read_window_field(header_bytes: &[u8; HEADER_SIZE], offset: usize) -> u32 {
@@ -450,6 +463,7 @@ mod tests {
use iggy_binary_protocol::requests::messages::SendMessagesHeader;
use iggy_binary_protocol::requests::streams::CreateStreamRequest;
use iggy_binary_protocol::requests::users::LoginRegisterRequest;
+ use iggy_binary_protocol::version::IGGY_PROTOCOL_VERSION;
use iggy_binary_protocol::{ClientVersionInfo, WireEncode, WireName};
use secrecy::SecretString;
@@ -485,6 +499,35 @@ mod tests {
assert_eq!(header.namespace, METADATA_CONSENSUS_NAMESPACE);
}
+ #[test]
+ fn second_register_on_bound_session_re_arms_instead_of_panicking() {
+ let request = LoginRegisterRequest {
+ version_info: ClientVersionInfo {
+ protocol_version: IGGY_PROTOCOL_VERSION,
+ sdk_name: WireName::new("rust-sdk").unwrap(),
+ sdk_version: WireName::new("1.0.0").unwrap(),
+ },
+ username: WireName::new("admin").unwrap(),
+ password: SecretString::from("secret"),
+ client_context: None,
+ };
+
+ let mut session = ConsensusSession::with_client_id(7);
+ encode_contiguous_request(&mut session, LOGIN_REGISTER_CODE, &request.to_bytes()).unwrap();
+ session.bind(42);
+
+ // A second login on the same bound session must encode a fresh Register
+ // (request 0, session 0), not panic in the one-shot register guard.
+ let bytes =
+ encode_contiguous_request(&mut session, LOGIN_REGISTER_CODE, &request.to_bytes())
+ .unwrap();
+ let header = decode_request_header(&bytes);
+ assert_eq!(header.operation, Operation::Register);
+ assert_eq!(header.request, 0);
+ assert_eq!(header.session, 0);
+ assert!(!session.is_bound());
+ }
+
#[test]
fn eviction_incompatible_protocol_decodes_to_typed_error() {
use iggy_binary_protocol::version::IGGY_PROTOCOL_VERSION_MIN;
@@ -533,6 +576,38 @@ mod tests {
}
}
+ #[test]
+ fn reply_with_nonzero_status_surfaces_as_typed_error() {
+ // A dispatch-time authorization denial rides `ReplyHeader.status`; the
+ // decode funnel surfaces it as the typed error before any body decode,
+ // even though the deny body is empty.
+ let header = ReplyHeader {
+ command: Command2::Reply,
+ size: HEADER_SIZE as u32,
+ status: IggyError::Unauthorized.as_code(),
+ ..Default::default()
+ };
+ let mut buf = [0u8; HEADER_SIZE];
+ buf.copy_from_slice(bytemuck::bytes_of(&header));
+ let result = decode_response_split(&buf, Bytes::new());
+ assert!(matches!(result, Err(IggyError::Unauthorized)));
+ }
+
+ #[test]
+ fn reply_with_zero_status_passes_body_through() {
+ // status 0 is the ok channel: a non-metadata reply returns its body.
+ let header = ReplyHeader {
+ command: Command2::Reply,
+ operation: Operation::NonReplicated,
+ size: (HEADER_SIZE + 3) as u32,
+ ..Default::default()
+ };
+ let mut buf = [0u8; HEADER_SIZE];
+ buf.copy_from_slice(bytemuck::bytes_of(&header));
+ let out = decode_response_split(&buf, Bytes::from_static(b"abc")).unwrap();
+ assert_eq!(&out[..], b"abc");
+ }
+
#[test]
fn replicated_request_increments_request_counter() {
let mut session = ConsensusSession::with_client_id(42);
diff --git a/core/server-ng/Cargo.toml b/core/server-ng/Cargo.toml
index 3de31cf53..9869a1b96 100644
--- a/core/server-ng/Cargo.toml
+++ b/core/server-ng/Cargo.toml
@@ -155,6 +155,7 @@ strum = { workspace = true }
sysinfo = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true }
+tokio = { workspace = true }
toml = { workspace = true }
tower-http = { workspace = true }
tracing = { workspace = true }
diff --git a/core/server-ng/server-ng.http b/core/server-ng/server.http
similarity index 96%
rename from core/server-ng/server-ng.http
rename to core/server-ng/server.http
index 451bca8dd..b0be45ca8 100644
--- a/core/server-ng/server-ng.http
+++ b/core/server-ng/server.http
@@ -34,14 +34,11 @@
@user1_username = user1
@user1_password = secret
@access_token = secret
-@root_id = 1
-@user1_id = 2
+@root_id = 0
+@user1_id = 1
@pat_name = dev_token
@pat_raw_token = secret
-###
-GET {{url}}
-
###
GET {{url}}/ping
@@ -63,11 +60,18 @@ Content-Type: application/json
}
###
-GET {{url}}/metrics
+GET {{url}}/stats
+Authorization: Bearer {{access_token}}
###
-GET {{url}}/stats
+POST {{url}}/snapshot
Authorization: Bearer {{access_token}}
+Content-Type: application/json
+
+{
+ "compression": "Deflated",
+ "snapshot_types": ["All"]
+}
###
GET {{url}}/cluster/metadata
@@ -81,14 +85,6 @@ Authorization: Bearer {{access_token}}
GET {{url}}/clients/{{client_id}}
Authorization: Bearer {{access_token}}
-###
-POST {{url}}/users/refresh-token
-Content-Type: application/json
-
-{
- "token": "{{access_token}}"
-}
-
###
DELETE {{url}}/users/logout
Authorization: Bearer {{access_token}}
@@ -154,7 +150,7 @@ Content-Type: application/json
"send_messages": true
},
"streams": {
- "1": {
+ "0": {
"manage_stream": false,
"read_stream": true,
"manage_topics": false,
@@ -162,7 +158,7 @@ Content-Type: application/json
"poll_messages": true,
"send_messages": true,
"topics": {
- "1": {
+ "0": {
"manage_topic": false,
"read_topic": true,
"poll_messages": true,
@@ -193,9 +189,6 @@ Content-Type: application/json
"expiry": 1000
}
-###
-
-
###
DELETE {{url}}/personal-access-tokens/{{pat_name}}
Authorization: Bearer {{access_token}}
@@ -290,7 +283,7 @@ Authorization: Bearer {{access_token}}
###
### Delete segments
-DELETE {{url}}/streams/1/topics/1/partitions/1?segments_count=3
+DELETE {{url}}/streams/{{stream_id}}/topics/{{topic_id}}/partitions/{{partition_id}}?segments_count=3
Authorization: Bearer {{access_token}}
###
diff --git a/core/server-ng/src/auth.rs b/core/server-ng/src/auth.rs
index 7e6c4627b..118aa674c 100644
--- a/core/server-ng/src/auth.rs
+++ b/core/server-ng/src/auth.rs
@@ -37,8 +37,26 @@ use metadata::impls::metadata::StreamsFrontend;
use server_common::crypto;
use std::cell::RefCell;
use std::rc::Rc;
+use std::sync::LazyLock;
use tracing::warn;
+/// A well-formed Argon2 hash to verify against on the unknown-user login
+/// branch, so a missing username costs the same single `verify_password` a real
+/// user's wrong-password branch costs. Closes the username-existence timing
+/// oracle without changing the returned error. On the unknown-username branch
+/// the verify result is discarded, so even a request presenting the exact dummy
+/// plaintext cannot authenticate; the literal only needs to be a fixed input
+/// hashed by the same Argon2 hasher real users use, so the dummy verify runs an
+/// identical Argon2 KDF.
+static DUMMY_PASSWORD_HASH: LazyLock<String> =
+ LazyLock::new(|| crypto::hash_password("http-login-timing-guard"));
+
+/// Pay the one-time Argon2 cost of [`DUMMY_PASSWORD_HASH`] at boot instead of
+/// inside the first unknown-username login request.
+pub(crate) fn warm_dummy_password_hash() {
+ LazyLock::force(&DUMMY_PASSWORD_HASH);
+}
+
pub(crate) fn verify_login_credentials(
shard: &Rc<ServerNgShard>,
username: &str,
@@ -54,16 +72,25 @@ pub(crate) fn verify_login_credentials(
return Err(LoginRegisterError::InvalidCredentials);
}
shard.plane.metadata().mux_stm.users().read(|users| {
- let Some(user_id) = users.index.get(username).copied() else {
+ let user = users
+ .index
+ .get(username)
+ .copied()
+ .and_then(|user_id| users.items.get(user_id as usize));
+ let Some(user) = user else {
+ // Constant-cost path: verify against a dummy hash so a missing
+ // username is indistinguishable by response timing from a wrong
+ // password (both return InvalidCredentials).
+ let _ = crypto::verify_password(password, DUMMY_PASSWORD_HASH.as_str());
return Err(LoginRegisterError::InvalidCredentials);
};
- let Some(user) = users.items.get(user_id as usize) else {
- return Err(LoginRegisterError::InvalidCredentials);
- };
- if user.status != UserStatus::Active {
- return Err(LoginRegisterError::UserInactive);
- }
- if !crypto::verify_password(password, user.password_hash.as_ref()) {
+ // Verify before the status check and collapse inactive to
+ // InvalidCredentials: an inactive account must answer exactly like a
+ // wrong password (same error, same Argon2 cost), or login could probe
+ // which accounts exist but are disabled.
+ if !crypto::verify_password(password, user.password_hash.as_ref())
+ || user.status != UserStatus::Active
+ {
return Err(LoginRegisterError::InvalidCredentials);
}
Ok(user.id)
@@ -74,6 +101,17 @@ pub(crate) fn verify_pat_credentials(
shard: &Rc<ServerNgShard>,
token: &str,
) -> Result<u32, LoginRegisterError> {
+ verify_pat_credentials_with_expiry(shard, token).map(|(user_id, _)| user_id)
+}
+
+/// Like [`verify_pat_credentials`] but also surfaces the token's expiry (unix
+/// seconds, `u64::MAX` when the PAT never expires). The HTTP extractor keys a
+/// per-token VSR session table on this expiry for lazy eviction; the wire and
+/// login paths only need the user id and go through [`verify_pat_credentials`].
+pub(crate) fn verify_pat_credentials_with_expiry(
+ shard: &Rc<ServerNgShard>,
+ token: &str,
+) -> Result<(u32, u64), LoginRegisterError> {
let token_hash = PersonalAccessToken::hash_token(token);
let now = IggyTimestamp::now();
shard.plane.metadata().mux_stm.users().read(|users| {
@@ -98,7 +136,12 @@ pub(crate) fn verify_pat_credentials(
if user.status != UserStatus::Active {
return Err(LoginRegisterError::UserInactive);
}
- Ok(user.id)
+ // `expiry_at == None` is a never-expiring PAT; map it to `u64::MAX` so
+ // the HTTP session table never expiry-evicts its entry.
+ let expiry = pat
+ .expiry_at
+ .map_or(u64::MAX, |expiry_at| expiry_at.to_secs());
+ Ok((user.id, expiry))
})
}
@@ -144,7 +187,7 @@ pub(crate) async fn complete_login_register(
// optimistic Authenticated transition, so a transient submit failure
// needs no rollback -- the connection stays Connected and the SDK
// read-timeout replays.
- let session = match submit_register_on_owner(shard, vsr_client_id).await {
+ let session = match submit_register_on_owner(shard, vsr_client_id, user_id).await {
Ok(session) => session,
Err(error) => {
return Err(LoginRegisterError::Transient(error));
diff --git a/core/server-ng/src/bootstrap.rs b/core/server-ng/src/bootstrap.rs
index cc75df120..ca7710148 100644
--- a/core/server-ng/src/bootstrap.rs
+++ b/core/server-ng/src/bootstrap.rs
@@ -15,12 +15,15 @@
// specific language governing permissions and limitations
// under the License.
+use crate::auth::warm_dummy_password_hash;
+use crate::cluster_meta::ClusterRoster;
use crate::config_writer::write_current_config;
use crate::dispatch::{
make_client_request_handler, make_deferred_client_request_handler,
make_deferred_replica_message_handler, make_list_clients_handler, make_metadata_submit_handler,
make_partition_read_handler,
};
+use crate::http;
use crate::partition_helpers::{
configure_consumer_offsets, ensure_initial_segment, validate_namespace_bounds,
};
@@ -519,6 +522,7 @@ pub fn bootstrap(
config: ServerNgConfig,
current_replica_id: Option<u8>,
) -> Result<ShardHandles, ServerNgError> {
+ warm_dummy_password_hash();
let allocator = ShardAllocator::new(&config.system.sharding.cpu_allocation)
.map_err(ServerNgError::ShardAllocator)?;
let assignments = allocator
@@ -1048,7 +1052,8 @@ async fn shard_main(
let coord = shard
.coordinator()
.expect("shard 0 always has a coordinator attached by the builder");
- let on_client_request = make_client_request_handler(&shard, &sessions);
+ let on_client_request =
+ make_client_request_handler(&shard, &sessions, Arc::clone(&config.system));
let (accepted_replica, dialed_replica) =
make_replica_delegation_fns(Rc::clone(&coord), &bus);
let accepted_client = make_shard_zero_client_accept_fns(coord, &bus, on_client_request);
@@ -1284,6 +1289,33 @@ fn spawn_shutdown_watchdog(
watchdog.detach();
}
+/// Copy the configured cluster roster plus this node's own client ports into
+/// the shared [`ClusterRoster`] so the binary `GetClusterMetadata` read serves
+/// the real topology. `self_*` back only the cluster-disabled self-synthesis;
+/// the HTTP port is read from config since HTTP binds outside this topology.
+fn build_cluster_roster(config: &ServerNgConfig, topology: &TcpTopology) -> ClusterRoster {
+ let http_port = if config.http.enabled {
+ parse_socket_addr("http.address", &config.http.address)
+ .ok()
+ .map(|addr| addr.port())
+ } else {
+ None
+ };
+ ClusterRoster {
+ enabled: config.cluster.enabled,
+ name: config.cluster.name.clone(),
+ nodes: config.cluster.nodes.clone(),
+ self_ip: topology.client_listen_addr.ip().to_string(),
+ self_ports: configs::cluster::TransportPorts {
+ tcp: Some(topology.client_listen_addr.port()),
+ quic: topology.quic_listen_addr.map(|addr| addr.port()),
+ http: http_port,
+ websocket: topology.ws_listen_addr.map(|addr| addr.port()),
+ tcp_replica: None,
+ },
+ }
+}
+
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
async fn build_shard_for_thread(
shard_id: u16,
@@ -1401,10 +1433,19 @@ async fn build_shard_for_thread(
let shard_handle = Rc::new(RefCell::new(None));
// One per-shard SessionManager, shared by the client-request handler
// (binds sessions) and the get_clients handler (reads them). Created
- // here so both wirings reference the same instance.
+ // here so both wirings reference the same instance. It also carries this
+ // shard's cluster roster for the pre-auth GetClusterMetadata read.
let sessions = Rc::new(RefCell::new(SessionManager::new()));
+ sessions
+ .borrow_mut()
+ .set_cluster_roster(Rc::new(build_cluster_roster(config, topology)));
let on_replica_message = make_deferred_replica_message_handler(&shard_handle);
- let on_client_request = make_deferred_client_request_handler(&bus, &shard_handle, &sessions);
+ let on_client_request = make_deferred_client_request_handler(
+ &bus,
+ &shard_handle,
+ &sessions,
+ Arc::clone(&config.system),
+ );
let on_metadata_submit = make_metadata_submit_handler(&shard_handle);
let on_list_clients = make_list_clients_handler(&sessions);
let on_partition_read = make_partition_read_handler(&shard_handle);
@@ -1816,7 +1857,7 @@ async fn start_tcp_runtime(
accepted_clients: LocalClientAcceptFns,
) -> Result<(), ServerNgError> {
if config.tcp.enabled && !config.tcp.tls.enabled {
- return start_via_replica_io(
+ start_via_replica_io(
shard,
config,
topology,
@@ -1824,18 +1865,45 @@ async fn start_tcp_runtime(
dialed_replica,
accepted_clients,
)
- .await;
+ .await?;
+ } else {
+ start_manual_runtime(
+ shard,
+ config,
+ topology,
+ accepted_replica,
+ dialed_replica,
+ accepted_clients,
+ )
+ .await?;
}
- start_manual_runtime(
- shard,
- config,
- topology,
- accepted_replica,
- dialed_replica,
- accepted_clients,
- )
- .await
+ // HTTP is served over TCP but sits outside the replica_io / manual client
+ // reactor, so it binds independently. Shard-0 gating comes from the sole
+ // caller of this function.
+ if config.http.enabled {
+ let http_addr = parse_socket_addr("http.address", &config.http.address)?;
+ let self_ports = configs::cluster::TransportPorts {
+ tcp: config
+ .tcp
+ .enabled
+ .then(|| topology.client_listen_addr.port()),
+ quic: topology.quic_listen_addr.map(|addr| addr.port()),
+ websocket: topology.ws_listen_addr.map(|addr| addr.port()),
+ ..Default::default()
+ };
+ http::start(
+ shard,
+ http_addr,
+ &config.http,
+ &config.cluster,
+ Arc::clone(&config.system),
+ self_ports,
+ )
+ .await?;
+ }
+
+ Ok(())
}
// ws/wss bindings intentionally mirror the transport names (same convention as
diff --git a/core/server-ng/src/cluster_meta.rs b/core/server-ng/src/cluster_meta.rs
new file mode 100644
index 000000000..28c290187
--- /dev/null
+++ b/core/server-ng/src/cluster_meta.rs
@@ -0,0 +1,128 @@
+// 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.
+
+//! Single source of cluster-metadata assembly.
+//!
+//! Both the HTTP `GET /cluster/metadata` handler and the binary
+//! `GetClusterMetadata` reply build the same view: one entry per configured
+//! roster node with its client ports, the current VSR primary marked
+//! `Leader` and the rest `Follower`. The roster is config-only data, so it is
+//! reported whenever the cluster is enabled; the leader marking is the only
+//! part that needs live consensus, and it is passed in as `primary_index` so
+//! each caller derives it from its own on-shard view (`None` when the serving
+//! shard has no consensus - peer shards - in which case no node is marked
+//! leader, but the full roster is still returned). The self-synthesized single
+//! node is the cluster-disabled fallback, shared by both callers.
+
+use configs::cluster::{ClusterNodeConfig, TransportPorts};
+use iggy_common::{
+ ClusterMetadata, ClusterNode, ClusterNodeRole, ClusterNodeStatus, TransportEndpoints,
+};
+
+/// Node name reported for the synthesized self node when no roster applies.
+const SELF_NODE_NAME: &str = "iggy-node";
+
+/// Cluster name reported when no roster is configured, matching the legacy
+/// single-node label.
+const SINGLE_NODE_CLUSTER_NAME: &str = "single-node";
+
+/// Config-derived cluster topology reported by cluster-metadata reads.
+///
+/// Copied out of `ClusterConfig` at listener/shard start so both handlers stay
+/// synchronous and never borrow live config. `self_*` describe this node and
+/// back the cluster-disabled self-synthesis only.
+pub struct ClusterRoster {
+ pub enabled: bool,
+ pub name: String,
+ pub nodes: Vec<ClusterNodeConfig>,
+ /// This node's own address, reported for the synthesized self node.
+ pub self_ip: String,
+ /// This node's own client ports for the same self node (`None` = transport
+ /// disabled).
+ pub self_ports: TransportPorts,
+}
+
+impl ClusterRoster {
+ /// A cluster-disabled roster with no self address. Used as the pre-bootstrap
+ /// default before the real roster is installed; [`Self::cluster_metadata`]
+ /// on it synthesizes a bare single node.
+ pub fn disabled() -> Self {
+ Self {
+ enabled: false,
+ name: String::new(),
+ nodes: Vec::new(),
+ self_ip: String::new(),
+ self_ports: TransportPorts::default(),
+ }
+ }
+
+ /// Build the [`ClusterMetadata`] view. With a configured, non-empty roster
+ /// emit one node per entry, marking the node at `primary_index` the leader
+ /// and the rest followers; `None` (no on-shard consensus) leaves every node
+ /// a follower. Otherwise synthesize the single self node as the sole
+ /// leader.
+ pub fn cluster_metadata(&self, primary_index: Option<u8>) -> ClusterMetadata {
+ if self.enabled && !self.nodes.is_empty() {
+ let nodes = self
+ .nodes
+ .iter()
+ .map(|node| ClusterNode {
+ name: node.name.clone(),
+ ip: node.ip.clone(),
+ endpoints: ports_to_endpoints(&node.ports),
+ role: role_for(primary_index, node.replica_id),
+ status: ClusterNodeStatus::Healthy,
+ })
+ .collect();
+ ClusterMetadata {
+ name: self.name.clone(),
+ nodes,
+ }
+ } else {
+ self.self_metadata()
+ }
+ }
+
+ fn self_metadata(&self) -> ClusterMetadata {
+ ClusterMetadata {
+ name: SINGLE_NODE_CLUSTER_NAME.to_owned(),
+ nodes: vec![ClusterNode {
+ name: SELF_NODE_NAME.to_owned(),
+ ip: self.self_ip.clone(),
+ endpoints: ports_to_endpoints(&self.self_ports),
+ role: ClusterNodeRole::Leader,
+ status: ClusterNodeStatus::Healthy,
+ }],
+ }
+ }
+}
+
+const fn role_for(primary_index: Option<u8>, replica_id: u8) -> ClusterNodeRole {
+ match primary_index {
+ Some(primary) if primary == replica_id => ClusterNodeRole::Leader,
+ _ => ClusterNodeRole::Follower,
+ }
+}
+
+fn ports_to_endpoints(ports: &TransportPorts) -> TransportEndpoints {
+ TransportEndpoints::new(
+ ports.tcp.unwrap_or(0),
+ ports.quic.unwrap_or(0),
+ ports.http.unwrap_or(0),
+ ports.websocket.unwrap_or(0),
+ )
+}
diff --git a/core/server-ng/src/dispatch.rs b/core/server-ng/src/dispatch.rs
index 552742b78..5d510860a 100644
--- a/core/server-ng/src/dispatch.rs
+++ b/core/server-ng/src/dispatch.rs
@@ -22,59 +22,76 @@
//! run consensus on shard 0, and the login/register, logout, and
//! non-replicated request handlers.
+mod authz;
+
use crate::auth::{
complete_login_register, send_login_failure_reply, surface_login_failure,
verify_login_credentials, verify_pat_credentials,
};
use crate::bootstrap::{ServerNgShard, ServerNgShardHandle};
+use crate::cluster_meta::ClusterRoster;
use crate::consumer_group::{
maybe_rewrite_consumer_group_request, maybe_rewrite_consumer_offset_request,
};
+use crate::dispatch::authz::{
+ authorize_default_read, authorize_partition_op, authorize_partition_read, authorize_uid,
+ send_non_replicated_deny, send_partition_deny_reply,
+};
use crate::login_register::LoginRegisterError;
use crate::pat::maybe_rewrite_pat_request;
use crate::responses::{
- NonReplicatedResponse, build_consumer_offset_body, build_empty_reply, build_get_me_response,
- build_get_personal_access_tokens_response, build_non_replicated_response,
- build_polled_messages_body, build_raw_pat_reply, connected_client_to_response,
- current_metadata_commit, resolve_partition_namespace, resolve_partition_request_namespace,
+ NonReplicatedResponse, build_consumer_offset_body, build_deny_reply, build_empty_reply,
+ build_get_me_response, build_get_personal_access_tokens_response,
+ build_non_replicated_response, build_polled_messages_body, build_raw_pat_reply,
+ connected_client_to_response, current_metadata_commit, resolve_partition_namespace,
+ resolve_partition_request_namespace,
};
use crate::session_manager::SessionManager;
+use crate::snapshot;
use crate::users::maybe_rewrite_user_password_request;
-use crate::wire::request_body;
+use crate::wire::{request_body, usize_to_u32};
use bytes::Bytes;
+use configs::system::SystemConfig;
use consensus::{
- EvictionContext, MetadataHandle, PartitionsHandle, build_eviction_message,
+ Consensus, EvictionContext, MetadataHandle, PartitionsHandle, build_eviction_message,
build_incompatible_protocol_eviction_message,
};
use iggy_binary_protocol::codes::{
GET_CLIENT_CODE, GET_CLIENTS_CODE, GET_CLUSTER_METADATA_CODE, GET_CONSUMER_OFFSET_CODE,
- GET_ME_CODE, GET_PERSONAL_ACCESS_TOKENS_CODE, PING_CODE, POLL_MESSAGES_CODE,
- SYNC_CONSUMER_GROUP_CODE,
+ GET_ME_CODE, GET_PERSONAL_ACCESS_TOKENS_CODE, GET_SNAPSHOT_FILE_CODE, LOGIN_USER_CODE,
+ LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE, PING_CODE, POLL_MESSAGES_CODE, SYNC_CONSUMER_GROUP_CODE,
};
use iggy_binary_protocol::primitives::consumer::WireConsumer;
use iggy_binary_protocol::primitives::polling_strategy::WirePollingStrategy;
use iggy_binary_protocol::requests::consumer_groups::SyncConsumerGroupRequest;
-use iggy_binary_protocol::requests::consumer_offsets::GetConsumerOffsetRequest;
+use iggy_binary_protocol::requests::consumer_offsets::{
+ GetConsumerOffsetRequest, StoreConsumerOffset2Request,
+};
use iggy_binary_protocol::requests::messages::PollMessagesRequest;
use iggy_binary_protocol::requests::segments::DeleteSegmentsRequest;
use iggy_binary_protocol::requests::system::get_client::GetClientRequest;
+use iggy_binary_protocol::requests::system::get_snapshot::GetSnapshotRequest;
use iggy_binary_protocol::requests::users::{LoginRegisterRequest, LoginRegisterWithPatRequest};
use iggy_binary_protocol::responses::clients::client_response::ConsumerGroupInfoResponse;
use iggy_binary_protocol::responses::clients::get_client::ClientDetailsResponse;
use iggy_binary_protocol::responses::clients::get_clients::GetClientsResponse;
use iggy_binary_protocol::responses::consumer_groups::SyncConsumerGroupResponse;
+use iggy_binary_protocol::responses::system::get_snapshot::GetSnapshotResponse;
use iggy_binary_protocol::{
- ClientVersionInfo, EvictionReason, GenericHeader, KIND_CONSUMER_GROUP, Operation,
- ProtocolVersion, RequestHeader, WireDecode, WireEncode, is_protocol_compatible,
+ AckLevel, ClientVersionInfo, Command2, EvictionReason, GenericHeader, HEADER_SIZE,
+ KIND_CONSUMER_GROUP, Operation, ProtocolVersion, RequestHeader, WireDecode, WireEncode,
+ WireIdentifier, is_protocol_compatible,
};
-use iggy_common::{IggyError, PollingStrategy};
+use iggy_common::{IggyError, PollingStrategy, SnapshotCompression, SystemSnapshotType};
use message_bus::client_listener::RequestHandler;
+use message_bus::framing::MAX_MESSAGE_SIZE;
use message_bus::replica::listener::MessageHandler;
-use message_bus::{IggyMessageBus, MessageBus};
+use message_bus::{AUTO_COMMIT_CLIENT_ID, IggyMessageBus, MessageBus};
use metadata::impls::metadata::{
MetadataSubmitError, StreamsFrontend, build_truncate_partition_client_message,
};
-use partitions::{PollPlan, PollingArgs, PollingConsumer};
+use metadata::permissioner::Permissioner;
+use partitions::{AutoCommitApplied, PollPlan, PollingArgs, PollingConsumer};
use secrecy::ExposeSecret;
use server_common::Message;
use server_common::sharding::IggyNamespace;
@@ -86,6 +103,7 @@ use shard::{
use std::cell::RefCell;
use std::collections::{HashMap, HashSet, VecDeque};
use std::rc::Rc;
+use std::sync::Arc;
use tracing::warn;
pub(crate) type ClientRequestQueues = Rc<RefCell<HashMap<u128, VecDeque<Message<GenericHeader>>>>>;
@@ -94,6 +112,7 @@ pub(crate) type ActiveClientRequests = Rc<RefCell<HashSet<u128>>>;
pub(crate) fn make_client_request_handler(
shard: &Rc<ServerNgShard>,
sessions: &Rc<RefCell<SessionManager>>,
+ system_config: Arc<SystemConfig>,
) -> RequestHandler {
let shard = Rc::clone(shard);
let sessions = Rc::clone(sessions);
@@ -115,6 +134,7 @@ pub(crate) fn make_client_request_handler(
enqueue_client_request(
Rc::clone(&shard),
Rc::clone(&sessions),
+ Arc::clone(&system_config),
Rc::clone(&queues),
Rc::clone(&active),
client_id,
@@ -166,10 +186,13 @@ pub(crate) fn make_partition_read_handler(
let _ = reply.try_send(PartitionReadReply::NotFound);
}
Some(plan) if plan.needs_off_pump_io() => {
- spawn_poll_io(namespace, plan, reply);
+ spawn_poll_io(Rc::clone(&shard), namespace, plan, reply);
}
Some(plan) => {
- let (fragments, current_offset) = plan.execute_resident();
+ let (fragments, current_offset, auto_commit) = plan.execute_resident();
+ if let Some(applied) = auto_commit {
+ submit_auto_commit(&shard, namespace, &applied);
+ }
let _ = reply.try_send(PartitionReadReply::Poll {
fragments,
current_offset,
@@ -217,18 +240,20 @@ pub(crate) fn make_partition_read_handler(
})
}
-/// Spawn the off-pump leg of a partition poll: disk read + auto-commit
-/// persist/apply on the OWNED plan (disk descriptors, resident-tail `Frozen`
-/// clones, `Arc` offset map), then send the reply. Holds no partition
-/// reference, so it is sound concurrently with the pump's `&mut` writes.
+/// Spawn the off-pump leg of a partition poll: disk read + auto-commit apply on
+/// the OWNED plan (disk descriptors, resident-tail `Frozen` clones, `Arc` offset
+/// map), then replicate the auto-committed offset and send the reply. Holds no
+/// partition reference across the IO, so it is sound concurrently with the
+/// pump's `&mut` writes; the auto-commit submit re-borrows synchronously after.
fn spawn_poll_io(
+ shard: Rc<ServerNgShard>,
namespace: IggyNamespace,
plan: PollPlan,
reply: shard::Sender<PartitionReadReply>,
) {
compio::runtime::spawn(async move {
let poll_started = std::time::Instant::now();
- let (fragments, current_offset) = plan.execute().await;
+ let (fragments, current_offset, auto_commit) = plan.execute().await;
let elapsed = poll_started.elapsed();
if elapsed > std::time::Duration::from_secs(1) {
warn!(
@@ -237,6 +262,10 @@ fn spawn_poll_io(
"slow partition poll; gather side may have timed out"
);
}
+ // Fire-and-forget: the poll reply is not gated on the offset commit.
+ if let Some(applied) = auto_commit {
+ submit_auto_commit(&shard, namespace, &applied);
+ }
let _ = reply.try_send(PartitionReadReply::Poll {
fragments,
current_offset,
@@ -245,6 +274,121 @@ fn spawn_poll_io(
.detach();
}
+/// Replicate a poll's auto-committed offset through the partition consensus so
+/// it survives failover, mirroring the explicit `StoreConsumerOffset` path: the
+/// same op code, submitted onto the owning shard's own pipeline. Best-effort and
+/// fire-and-forget -- the poll reply never waits on it, and a full inbox drops
+/// the op at WARN rather than backpressuring the reply.
+///
+/// The partition plane admits writes on the primary only (it asserts so), and a
+/// poll is served on whichever node owns the namespace locally, which may be a
+/// backup. So gate on primary status here and drop at WARN otherwise; auto-commit
+/// is server-managed best-effort (at-least-once delivery), so a follower-served
+/// poll simply does not advance the durable offset.
+///
+/// Coalescing: an offset the partition's committed high-water already covers is
+/// dropped without a consensus op (the steady state for a re-poll of committed
+/// data, hence no log). The gate reads committed state only, so an offset that
+/// merely sits in flight keeps resubmitting until its covering op commits -- a
+/// dropped op self-heals on the next poll instead of being suppressed forever.
+fn submit_auto_commit(
+ shard: &Rc<ServerNgShard>,
+ namespace: IggyNamespace,
+ applied: &AutoCommitApplied,
+) {
+ enum AutoCommitGate {
+ Submit,
+ Covered,
+ NotPrimary,
+ }
+ let gate = shard
+ .plane
+ .partitions()
+ .with_partition(&namespace, |partition| {
+ let consensus = partition.consensus();
+ if !(consensus.is_primary() && consensus.is_normal() && !consensus.is_syncing()) {
+ AutoCommitGate::NotPrimary
+ } else if partition.is_auto_commit_offset_covered(
+ applied.kind,
+ applied.consumer_id,
+ applied.offset,
+ ) {
+ AutoCommitGate::Covered
+ } else {
+ AutoCommitGate::Submit
+ }
+ });
+ match gate {
+ Some(AutoCommitGate::Submit) => {}
+ Some(AutoCommitGate::Covered) => return,
+ Some(AutoCommitGate::NotPrimary) | None => {
+ warn!(
+ namespace_raw = namespace.inner(),
+ "auto-commit offset not replicated: partition not primary on this node (best-effort)"
+ );
+ return;
+ }
+ }
+ let message = match build_auto_commit_request(namespace, applied) {
+ Ok(message) => message,
+ Err(error) => {
+ warn!(
+ namespace_raw = namespace.inner(),
+ error = %error,
+ "failed to build auto-commit store-offset request"
+ );
+ return;
+ }
+ };
+ // Routes by namespace to this same (owning, primary) shard's inbox; the pump
+ // admits it next turn exactly like a client store. `dispatch` never blocks.
+ shard.dispatch(message.into_generic());
+}
+
+/// Build the synthetic `StoreConsumerOffset2` request for an auto-commit, keyed
+/// to the resolved numeric consumer/group id and stamped with the reserved
+/// [`AUTO_COMMIT_CLIENT_ID`] so the commit path skips the (unwaited) reply. The
+/// wire stream/topic ids are cosmetic here -- admission and apply key off the
+/// header namespace and the consumer id -- but are set from the namespace for a
+/// well-formed body. `ack` is `Quorum` so the offset actually replicates.
+fn build_auto_commit_request(
+ namespace: IggyNamespace,
+ applied: &AutoCommitApplied,
+) -> Result<Message<RequestHeader>, IggyError> {
+ let request = StoreConsumerOffset2Request {
+ consumer: WireConsumer {
+ kind: applied.kind.as_code(),
+ id: WireIdentifier::Numeric(applied.consumer_id),
+ },
+ stream_id: WireIdentifier::Numeric(usize_to_u32(namespace.stream_id())?),
+ topic_id: WireIdentifier::Numeric(usize_to_u32(namespace.topic_id())?),
+ partition_id: Some(usize_to_u32(namespace.partition_id())?),
+ offset: applied.offset,
+ ack: AckLevel::Quorum,
+ };
+ let body = request.to_bytes();
+ let header_size = std::mem::size_of::<RequestHeader>();
+ let total_size = header_size + body.len();
+ let size = u32::try_from(total_size).map_err(|_| IggyError::InvalidConfiguration)?;
+ let mut message = Message::<RequestHeader>::new(total_size);
+ message.as_mut_slice()[header_size..].copy_from_slice(&body);
+ Ok(message.transmute_header(|_, header: &mut RequestHeader| {
+ *header = RequestHeader {
+ command: Command2::Request,
+ operation: Operation::StoreConsumerOffset2,
+ size,
+ client: AUTO_COMMIT_CLIENT_ID,
+ // The partition plane is sessionless (no `ClientTable` dedup); a
+ // nonzero session + request just satisfy the wire header
+ // validation.
+ session: 1,
+ request: 1,
+ namespace: namespace.inner(),
+ ..Default::default()
+ };
+ }))
+}
+
pub(crate) fn make_deferred_replica_message_handler(
shard_handle: &ServerNgShardHandle,
) -> MessageHandler {
@@ -260,6 +404,7 @@ pub(crate) fn make_deferred_client_request_handler(
bus: &Rc<IggyMessageBus>,
shard_handle: &ServerNgShardHandle,
sessions: &Rc<RefCell<SessionManager>>,
+ system_config: Arc<SystemConfig>,
) -> RequestHandler {
let shard_handle = Rc::clone(shard_handle);
let sessions = Rc::clone(sessions);
@@ -279,6 +424,7 @@ pub(crate) fn make_deferred_client_request_handler(
Rc::new(move |client_id, message| {
let shard_handle = Rc::clone(&shard_handle);
let sessions = Rc::clone(&sessions);
+ let system_config = Arc::clone(&system_config);
let queues = Rc::clone(&queues);
let active = Rc::clone(&active);
queues
@@ -294,7 +440,7 @@ pub(crate) fn make_deferred_client_request_handler(
active.borrow_mut().remove(&client_id);
return;
};
- drain_client_requests(shard, sessions, queues, active, client_id).await;
+ drain_client_requests(shard, sessions, system_config, queues, active, client_id).await;
})
.detach();
})
@@ -319,12 +465,13 @@ pub(crate) fn make_metadata_submit_handler(
match submit {
shard::MetadataSubmit::Register {
vsr_client_id,
+ user_id,
reply,
} => {
let session = shard
.plane
.metadata()
- .submit_register_in_process(vsr_client_id)
+ .submit_register_in_process(vsr_client_id, user_id)
.await
.ok();
let _ = reply.try_send(session);
@@ -389,6 +536,7 @@ pub(crate) fn make_metadata_submit_handler(
fn enqueue_client_request(
shard: Rc<ServerNgShard>,
sessions: Rc<RefCell<SessionManager>>,
+ system_config: Arc<SystemConfig>,
queues: ClientRequestQueues,
active: ActiveClientRequests,
client_id: u128,
@@ -404,7 +552,7 @@ fn enqueue_client_request(
}
compio::runtime::spawn(async move {
- drain_client_requests(shard, sessions, queues, active, client_id).await;
+ drain_client_requests(shard, sessions, system_config, queues, active, client_id).await;
})
.detach();
}
@@ -413,6 +561,7 @@ fn enqueue_client_request(
async fn drain_client_requests(
shard: Rc<ServerNgShard>,
sessions: Rc<RefCell<SessionManager>>,
+ system_config: Arc<SystemConfig>,
queues: ClientRequestQueues,
active: ActiveClientRequests,
client_id: u128,
@@ -421,7 +570,7 @@ async fn drain_client_requests(
let Some(message) = pop_next_client_request(&queues, &active, client_id) else {
return;
};
- handle_client_request(&shard, &sessions, client_id, message).await;
+ handle_client_request(&shard, &sessions, &system_config, client_id, message).await;
}
}
@@ -449,6 +598,7 @@ fn pop_next_client_request(
async fn handle_client_request(
shard: &Rc<ServerNgShard>,
sessions: &Rc<RefCell<SessionManager>>,
+ system_config: &Arc<SystemConfig>,
transport_client_id: u128,
message: Message<iggy_binary_protocol::GenericHeader>,
) {
@@ -478,9 +628,35 @@ async fn handle_client_request(
// legitimately pre-auth (liveness probe + connection bootstrap
// metadata). Every other non-replicated code (`GET_STREAM*`,
// `GET_TOPIC*`, `GET_STATS`, `POLL_MESSAGES`) reads live state and
- // MUST go through Register first, since server-ng has no
- // per-resource authz layer.
+ // MUST go through Register first, which binds the acting user the
+ // per-op authz gates resolve.
let nr_code = u32::from_le_bytes(request.header().reserved[..4].try_into().unwrap());
+ // Legacy (pre-register) login codes. server-ng authenticates only via
+ // the Register handshake (LOGIN_REGISTER / LOGIN_REGISTER_WITH_PAT,
+ // Operation::Register); the vsr SDK funnels both logins there and never
+ // emits these. Reject them uniformly with a typed MalformedLogin (the
+ // SDK maps it to InvalidFormat) before the session gate, so a legacy or
+ // foreign client fails fast instead of getting the misleading
+ // NoSession eviction the pre-auth guard would send unbound, or the
+ // silent empty-ok Reply the bound non-replicated path would send.
+ if matches!(
+ nr_code,
+ LOGIN_USER_CODE | LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE
+ ) {
+ warn!(
+ transport_client_id,
+ code = nr_code,
+ "rejecting legacy login code; server-ng requires the register handshake"
+ );
+ send_login_eviction(
+ shard,
+ transport_client_id,
+ header.client,
+ EvictionReason::MalformedLogin,
+ )
+ .await;
+ return;
+ }
let allowed_pre_auth = matches!(nr_code, PING_CODE | GET_CLUSTER_METADATA_CODE);
if !allowed_pre_auth && sessions.borrow().get_session(transport_client_id).is_none() {
warn!(
@@ -491,7 +667,8 @@ async fn handle_client_request(
send_unauthenticated_eviction(shard, transport_client_id).await;
return;
}
- handle_non_replicated_request(shard, sessions, transport_client_id, request).await;
+ handle_non_replicated_request(shard, sessions, system_config, transport_client_id, request)
+ .await;
return;
}
@@ -549,90 +726,22 @@ async fn handle_client_request(
return;
}
- // Partition data-plane op (SendMessages / consumer-offset writes): the
- // op belongs to the partition's own consensus group, not the metadata
- // group. Route it through the shard mesh by namespace; the owning
- // shard's partitions plane runs at-least-once consensus and replies
- // directly via `send_to_client`. `header.client` therefore stays the
- // TRANSPORT id (home-shard routing bits), not the VSR session id --
- // partition ops are sessionless ("session lifecycle is metadata-only").
if header.operation.is_partition() {
- // The consumer-group offset fence keys on the bound VSR client id (the
- // member id), not the transport id stamped into the partition-op
- // header. `bound` is Some here (unbound transports returned above).
- let vsr_client_id = bound.map_or(0, |(client_id, _)| client_id);
- let namespace = match resolve_partition_request_namespace(
+ // `bound` is Some here (unbound transports returned above).
+ let (vsr_client_id, bound_session) = bound.unwrap_or((0, 0));
+ // `get_session` discards the acting user id the partition gate needs;
+ // resolve it from the same bound connection. A bound transport always
+ // has one, but the gate fails closed on `None` rather than trust that.
+ let acting_user_id = sessions.borrow().get_user_id(transport_client_id);
+ dispatch_partition_request(
shard,
- header.operation,
- request_body(&request),
+ request,
vsr_client_id,
- ) {
- Ok(namespace) => namespace,
- Err(error) => {
- // A partition op against a stream/topic that no longer
- // resolves (e.g. a consumer's trailing auto-commit racing a
- // `delete_stream`). A silent drop wedges the SDK connection
- // forever; reply empty so the client fails fast instead.
- warn!(
- transport_client_id,
- error = %error,
- operation = ?header.operation,
- "partition request with unresolved namespace; replying empty"
- );
- send_empty_partition_reply(shard, transport_client_id, &header).await;
- return;
- }
- };
- // Convergence wait: a CreateTopic commit returns to the client
- // before the per-shard reconcilers seed routing rows and
- // materialise the partition (next wake/periodic tick). The SDK
- // does not replay sends, so an immediately-following partition op
- // would be dropped as unroutable. Absorb that window here with a
- // bounded wait; steady-state sends (row present, partition probed
- // once) skip it entirely.
- if !wait_for_partition_routable(shard, IggyNamespace::from_raw(namespace)).await {
- warn!(
- transport_client_id,
- namespace,
- operation = ?header.operation,
- "partition request not routable within budget; replying empty"
- );
- send_empty_partition_reply(shard, transport_client_id, &header).await;
- return;
- }
- // A group consumer-offset op carries the group NAME on the wire; the
- // partition plane keys the offset by the group's monotonic id (the same
- // key the poll path auto-commits under and the read path resolves), so
- // rewrite the consumer id before replication -- the apply layer has no
- // metadata access to resolve it.
- let request = match maybe_rewrite_consumer_offset_request(shard, request) {
- Ok(rewritten) => rewritten,
- Err(error) => {
- warn!(
- transport_client_id,
- error = %error,
- operation = ?header.operation,
- "failed to rewrite consumer-offset request; replying empty"
- );
- send_empty_partition_reply(shard, transport_client_id, &header).await;
- return;
- }
- };
- let request = request.transmute_header(|header, new_header: &mut RequestHeader| {
- *new_header = header;
- new_header.namespace = namespace;
- new_header.client = transport_client_id;
- // Header validation requires `session > 0 && request > 0` for
- // non-register ops. The partition plane itself is sessionless
- // (at-least-once, no `ClientTable` dedup), so the bound VSR
- // session merely satisfies validation, and a zero request id
- // (the SDK does not number data-plane ops) is normalized.
- if let Some((_, bound_session)) = bound {
- new_header.session = bound_session;
- }
- new_header.request = new_header.request.max(1);
- });
- shard.dispatch(request.into_generic());
+ bound_session,
+ transport_client_id,
+ acting_user_id,
+ )
+ .await;
return;
}
@@ -658,17 +767,36 @@ async fn handle_client_request(
return;
}
};
- // Hash raw passwords on the primary before replication (CreateUser /
- // ChangePassword); see `crate::users`. Replicas store the hash directly.
- let request = match maybe_rewrite_user_password_request(request) {
+ // Hash raw passwords and, for ChangePassword, verify the current password
+ // on the primary before replication; see `crate::users`. Replicas store the
+ // hash directly. A wrong current password is not denied here: it rides
+ // consensus and applies as a committed InvalidCredentials no-op, so the only
+ // Err returned is a malformed body.
+ let request = match maybe_rewrite_user_password_request(shard, request) {
Ok(rewritten) => rewritten,
Err(error) => {
+ // Malformed body: deny fast with InvalidCommand. A silent drop
+ // would wedge every later request on the connection until the
+ // socket read timeout.
warn!(
transport_client_id,
error = %error,
operation = ?header.operation,
- "dropping user request with invalid password payload"
+ "denying user password request"
);
+ let commit = current_metadata_commit(shard);
+ let reply = build_deny_reply(&header, transport_client_id, 0, commit, error.as_code());
+ if let Err(send_error) = shard
+ .bus
+ .send_to_client(transport_client_id, reply.into_generic().into_frozen())
+ .await
+ {
+ warn!(
+ transport_client_id,
+ error = %send_error,
+ "failed to send password-deny reply"
+ );
+ }
return;
}
};
@@ -775,15 +903,148 @@ async fn handle_get_me(
.await;
}
+/// Route a partition data-plane op (`SendMessages` / consumer-offset writes)
+/// through the shard mesh by namespace: the op belongs to the partition's
+/// own consensus group, not the metadata group. The owning shard's
+/// partitions plane runs at-least-once consensus and replies directly via
+/// `send_to_client`. `header.client` therefore stays the TRANSPORT id
+/// (home-shard routing bits), not the VSR session id -- partition ops are
+/// sessionless ("session lifecycle is metadata-only").
+///
+/// Callers must have authenticated the transport already: `vsr_client_id` /
+/// `bound_session` come from its bound VSR session. Every failure before
+/// dispatch replies empty so the client fails fast instead of wedging on a
+/// silent drop.
+///
+/// `vsr_client_id` keys the consumer-group offset fence (the member id),
+/// not the transport id stamped into the partition-op header.
#[allow(clippy::future_not_send)]
+pub(crate) async fn dispatch_partition_request(
+ shard: &Rc<ServerNgShard>,
+ request: Message<RequestHeader>,
+ vsr_client_id: u128,
+ bound_session: u64,
+ transport_client_id: u128,
+ acting_user_id: Option<u32>,
+) {
+ let header = *request.header();
+ let namespace = match resolve_partition_request_namespace(
+ shard,
+ header.operation,
+ request_body(&request),
+ vsr_client_id,
+ ) {
+ Ok(namespace) => namespace,
+ Err(error) => {
+ // A partition op against a stream/topic that no longer
+ // resolves (e.g. a consumer's trailing auto-commit racing a
+ // `delete_stream`). A silent drop wedges the SDK connection
+ // forever; reply empty so the client fails fast instead.
+ warn!(
+ transport_client_id,
+ error = %error,
+ operation = ?header.operation,
+ "partition request with unresolved namespace; replying empty"
+ );
+ send_empty_partition_reply(shard, transport_client_id, &header).await;
+ return;
+ }
+ };
+ // Dispatch-time RBAC. The partition plane is not replicated through the
+ // metadata STM, so the in-apply gate cannot cover it; authorize here, on
+ // the connection's own shard, before burning the routable wait or touching
+ // the plane. The namespace resolved above, so its stream/topic are the
+ // committed slab ids the permissioner keys on directly. A denial replies
+ // the op's frame with an empty body and a nonzero `status` the SDK peeks.
+ //
+ // Consistency: this reads THIS shard's local committed permissioner. On a
+ // peer shard that is a replicated read-mirror, so a permission revocation
+ // takes effect on the partition plane only once this shard applies the
+ // revoking commit -- an apply-lag window bounded by replication lag.
+ // Control-plane ops are exact (gated in-apply, in the same committed order
+ // on every replica); this local-read relaxation on the data plane is the
+ // accepted trade for keeping partition ops off the metadata consensus.
+ let scope = IggyNamespace::from_raw(namespace);
+ if let Some(status) = authorize_partition_op(
+ shard,
+ header.operation,
+ acting_user_id,
+ scope.stream_id(),
+ scope.topic_id(),
+ ) {
+ warn!(
+ transport_client_id,
+ status,
+ operation = ?header.operation,
+ "partition request denied by authorization; replying with status"
+ );
+ send_partition_deny_reply(shard, transport_client_id, &header, status).await;
+ return;
+ }
+ // Convergence wait: a CreateTopic commit returns to the client
+ // before the per-shard reconcilers seed routing rows and
+ // materialise the partition (next wake/periodic tick). The SDK
+ // does not replay sends, so an immediately-following partition op
+ // would be dropped as unroutable. Absorb that window here with a
+ // bounded wait; steady-state sends (row present, partition probed
+ // once) skip it entirely.
+ if !wait_for_partition_routable(shard, IggyNamespace::from_raw(namespace)).await {
+ warn!(
+ transport_client_id,
+ namespace,
+ operation = ?header.operation,
+ "partition request not routable within budget; replying empty"
+ );
+ send_empty_partition_reply(shard, transport_client_id, &header).await;
+ return;
+ }
+ // A group consumer-offset op carries the group NAME on the wire; the
+ // partition plane keys the offset by the group's monotonic id (the same
+ // key the poll path auto-commits under and the read path resolves), so
+ // rewrite the consumer id before replication -- the apply layer has no
+ // metadata access to resolve it.
+ let request = match maybe_rewrite_consumer_offset_request(shard, request) {
+ Ok(rewritten) => rewritten,
+ Err(error) => {
+ warn!(
+ transport_client_id,
+ error = %error,
+ operation = ?header.operation,
+ "failed to rewrite consumer-offset request; replying empty"
+ );
+ send_empty_partition_reply(shard, transport_client_id, &header).await;
+ return;
+ }
+ };
+ let request = request.transmute_header(|header, new_header: &mut RequestHeader| {
+ *new_header = header;
+ new_header.namespace = namespace;
+ new_header.client = transport_client_id;
+ // Header validation requires `session > 0 && request > 0` for
+ // non-register ops. The partition plane itself is sessionless
+ // (at-least-once, no `ClientTable` dedup), so the bound VSR
+ // session merely satisfies validation, and a zero request id
+ // (the SDK does not number data-plane ops) is normalized.
+ new_header.session = bound_session;
+ new_header.request = new_header.request.max(1);
+ });
+ shard.dispatch(request.into_generic());
+}
+
+#[allow(clippy::future_not_send, clippy::too_many_lines)]
async fn handle_non_replicated_request(
shard: &Rc<ServerNgShard>,
sessions: &Rc<RefCell<SessionManager>>,
+ system_config: &Arc<SystemConfig>,
transport_client_id: u128,
request: Message<RequestHeader>,
) {
const CODE_RANGE: std::ops::Range<usize> = 0..4;
let code = u32::from_le_bytes(request.header().reserved[CODE_RANGE].try_into().unwrap());
+ // Acting user for the read gates below, resolved once. `None` only on the
+ // pre-auth path (PING / GET_CLUSTER_METADATA), which serves ungated codes;
+ // the gated arms fail closed on it.
+ let user_id = sessions.borrow().get_user_id(transport_client_id);
match code {
PING_CODE => {
// A ping is the client's liveness proof; reset its staleness clock
@@ -815,6 +1076,11 @@ async fn handle_non_replicated_request(
handle_get_personal_access_tokens(shard, sessions, transport_client_id, &request).await;
}
GET_CLIENTS_CODE => {
+ if let Err(error) = authorize_uid(shard, user_id, Permissioner::get_clients) {
+ send_non_replicated_deny(shard, &request, transport_client_id, error.as_code())
+ .await;
+ return;
+ }
// Shared-nothing: each shard knows only its own connections, so
// gather across all shards (scatter-gather over the mesh).
let infos = shard.list_all_clients().await;
@@ -834,6 +1100,11 @@ async fn handle_non_replicated_request(
.await;
}
GET_CLIENT_CODE => {
+ if let Err(error) = authorize_uid(shard, user_id, Permissioner::get_client) {
+ send_non_replicated_deny(shard, &request, transport_client_id, error.as_code())
+ .await;
+ return;
+ }
// No reverse map from the wire u32 id to a u128 transport id /
// home shard (the u32 is just the seq tail), so gather all and
// filter -- same fan-out as `get_clients`.
@@ -871,16 +1142,32 @@ async fn handle_non_replicated_request(
send_non_replicated_bytes(shard, &request, transport_client_id, bytes, "get_client")
.await;
}
+ GET_SNAPSHOT_FILE_CODE => {
+ handle_get_snapshot(shard, system_config, transport_client_id, &request, user_id).await;
+ }
POLL_MESSAGES_CODE => {
- handle_poll_messages(shard, transport_client_id, &request).await;
+ handle_poll_messages(shard, transport_client_id, &request, user_id).await;
}
GET_CONSUMER_OFFSET_CODE => {
- handle_get_consumer_offset(shard, transport_client_id, &request).await;
+ handle_get_consumer_offset(shard, transport_client_id, &request, user_id).await;
}
SYNC_CONSUMER_GROUP_CODE => {
+ // Self-scoped: serves the caller's own assignment keyed by the
+ // header client id, so it carries no permissioner rule.
handle_sync_consumer_group(shard, transport_client_id, &request).await;
}
- _ => handle_default_non_replicated(shard, transport_client_id, code, &request).await,
+ _ => {
+ let roster = sessions.borrow().cluster_roster();
+ handle_default_non_replicated(
+ shard,
+ transport_client_id,
+ code,
+ &request,
+ user_id,
+ &roster,
+ )
+ .await;
+ }
}
}
@@ -890,8 +1177,17 @@ async fn handle_default_non_replicated(
transport_client_id: u128,
code: u32,
request: &Message<RequestHeader>,
+ user_id: Option<u32>,
+ roster: &ClusterRoster,
) {
- match build_non_replicated_response(shard, code, request_body(request)) {
+ // Gate by command code before the shared builder runs. The builder stays
+ // authz-free (it is byte-shared with the HTTP read path, which gates
+ // separately); a denial replies status!=0 with an empty body.
+ if let Err(error) = authorize_default_read(shard, code, request_body(request), user_id) {
+ send_non_replicated_deny(shard, request, transport_client_id, error.as_code()).await;
+ return;
+ }
+ match build_non_replicated_response(shard, code, request_body(request), user_id, roster) {
Ok(response) => {
let commit = current_metadata_commit(shard);
let reply = response.into_reply(
@@ -914,16 +1210,98 @@ async fn handle_default_non_replicated(
}
}
Err(error) => {
+ // Surface the builder's typed error (unsupported op, undecodable
+ // body, or a not-found parity read) on the same deny channel the
+ // authz gate uses; a silent drop would wedge the client until its
+ // read timeout.
warn!(
transport_client_id,
code,
error = %error,
- "dropping unsupported non-replicated VSR request"
+ "denying non-replicated VSR request"
);
+ send_non_replicated_deny(shard, request, transport_client_id, error.as_code()).await;
}
}
}
+/// Serve `GET_SNAPSHOT_FILE`: gate on the snapshot rule (`read_servers ||
+/// manage_servers`, the legacy gate - the archive dumps host diagnostics, so
+/// plain authentication must not suffice), then await the off-thread
+/// collection (see `snapshot::collect`) and reply with the raw ZIP bytes.
+#[allow(clippy::future_not_send)]
+async fn handle_get_snapshot(
+ shard: &Rc<ServerNgShard>,
+ system_config: &Arc<SystemConfig>,
+ transport_client_id: u128,
+ request: &Message<RequestHeader>,
+ user_id: Option<u32>,
+) {
+ if let Err(error) = authorize_uid(shard, user_id, Permissioner::get_snapshot) {
+ send_non_replicated_deny(shard, request, transport_client_id, error.as_code()).await;
+ return;
+ }
+ let result = match decode_get_snapshot(request_body(request)) {
+ Ok((compression, snapshot_types)) => {
+ snapshot::collect(Arc::clone(system_config), compression, snapshot_types).await
+ }
+ Err(error) => Err(error),
+ };
+ match result {
+ Ok(archive) => {
+ // The reply frames as `[256-byte header][archive]`. The client's
+ // `message_bus::read_message` rejects any frame past `MAX_MESSAGE_SIZE`
+ // (64 MiB) by tearing the connection down untyped, and a frame past
+ // `u32::MAX` would panic `build_reply_with_body`. The archive is the
+ // only unbounded non-replicated body, so refuse an oversized one with a
+ // typed error the SDK decodes. The HTTP path streams via `Body` (not
+ // this framing), so it stays uncapped.
+ let frame_size = HEADER_SIZE + archive.len();
+ if frame_size > MAX_MESSAGE_SIZE {
+ warn!(
+ transport_client_id,
+ frame_size,
+ max = MAX_MESSAGE_SIZE,
+ "snapshot archive exceeds the client frame limit; refusing to send"
+ );
+ send_non_replicated_deny(
+ shard,
+ request,
+ transport_client_id,
+ IggyError::SnapshotFileCompletionFailed.as_code(),
+ )
+ .await;
+ return;
+ }
+ send_non_replicated_bytes(
+ shard,
+ request,
+ transport_client_id,
+ GetSnapshotResponse { data: archive }.to_bytes(),
+ "get_snapshot",
+ )
+ .await;
+ }
+ Err(error) => {
+ warn!(transport_client_id, error = %error, "denying snapshot request");
+ send_non_replicated_deny(shard, request, transport_client_id, error.as_code()).await;
+ }
+ }
+}
+
+fn decode_get_snapshot(
+ body: &[u8],
+) -> Result<(SnapshotCompression, Vec<SystemSnapshotType>), IggyError> {
+ let request = GetSnapshotRequest::decode_from(body).map_err(|_| IggyError::InvalidCommand)?;
+ let compression = SnapshotCompression::from_code(request.compression)?;
+ let snapshot_types = request
+ .snapshot_types
+ .iter()
+ .map(|&code| SystemSnapshotType::from_code(code))
+ .collect::<Result<Vec<_>, _>>()?;
+ Ok((compression, snapshot_types))
+}
+
/// Send a non-replicated reply body to a client, stamping the current
/// metadata commit. Shared by the `get_me` / `get_clients` / `get_client`
/// arms.
@@ -1091,8 +1469,37 @@ async fn handle_poll_messages(
shard: &Rc<ServerNgShard>,
transport_client_id: u128,
request: &Message<RequestHeader>,
+ user_id: Option<u32>,
) {
- let body = match decode_poll_request(shard, request) {
+ let Ok(wire) = PollMessagesRequest::decode_from(request_body(request)) else {
+ // Undecodable poll: keep the fail-fast empty-poll shape.
+ send_non_replicated_bytes(
+ shard,
+ request,
+ transport_client_id,
+ empty_polled_messages_body(0),
+ "poll_messages",
+ )
+ .await;
... 7824 lines suppressed ...