This is an automated email from the ASF dual-hosted git repository.
hubcio 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 0270b76b6 refactor(server): make the module graph a DAG and enforce it
(#4023)
0270b76b6 is described below
commit 0270b76b6b23f45d9fe0ab9a0c8a280f2e396d99
Author: Hubert Gruszecki <[email protected]>
AuthorDate: Tue Sep 1 14:59:56 2026 +0200
refactor(server): make the module graph a DAG and enforce it (#4023)
---
Cargo.lock | 16 +-
Cargo.toml | 2 -
core/partitions/src/send_messages2.rs | 17 --
core/server/Cargo.toml | 7 +-
core/server/build.rs | 23 ++
core/server/src/auth.rs | 14 +-
core/server/src/bootstrap.rs | 187 +-----------
core/server/src/consumer_group.rs | 6 +-
core/server/src/dispatch.rs | 58 ++--
core/server/src/dispatch/authz.rs | 2 +-
core/server/src/http.rs | 8 +-
core/server/src/http/forward.rs | 19 +-
core/server/src/http/handlers.rs | 67 +++++
core/server/src/http/metrics.rs | 101 +------
core/server/src/http/reads.rs | 2 +-
core/server/src/http/state.rs | 23 +-
core/server/src/http/submit.rs | 2 +-
core/server/src/{ => http}/web.rs | 0
core/server/src/lib.rs | 53 ++--
core/server/src/partition_helpers.rs | 4 +-
core/server/src/partition_reconciler.rs | 2 +-
core/server/src/pat.rs | 4 +-
core/server/src/personal_access_token_cleaner.rs | 2 +-
core/server/src/responses.rs | 40 +--
core/server/src/segment_cleaner.rs | 2 +-
core/server/src/server_error.rs | 2 +-
.../src/lib.rs => server/src/shard_allocator.rs} | 51 ++--
core/server/src/shell.rs | 202 +++++++++++++
core/server/src/users.rs | 4 +-
core/server/src/wire.rs | 12 +-
core/server/tests/module_graph.rs | 319 +++++++++++++++++++++
core/shard_allocator/Cargo.toml | 41 ---
core/shard_allocator/build.rs | 43 ---
core/simulator/src/replica.rs | 3 +-
34 files changed, 811 insertions(+), 527 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 251f7f309..5a622dab6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -12048,6 +12048,7 @@ dependencies = [
"compio",
"configs",
"consensus",
+ "cpu_allocation",
"crossfire",
"ctrlc",
"cyper",
@@ -12082,7 +12083,9 @@ dependencies = [
"opentelemetry_sdk",
"papaya",
"partitions",
+ "proc-macro2",
"prometheus-client",
+ "quote",
"rand 0.10.2",
"ringbuffer",
"rmp-serde",
@@ -12097,9 +12100,9 @@ dependencies = [
"serde_json",
"server_common",
"shard",
- "shard_allocator",
"socket2 0.6.5",
"strum 0.28.0",
+ "syn 2.0.119",
"sysinfo 0.39.6",
"system_stats",
"tempfile",
@@ -12233,17 +12236,6 @@ dependencies = [
"tracing",
]
-[[package]]
-name = "shard_allocator"
-version = "0.1.0"
-dependencies = [
- "cpu_allocation",
- "hwlocality",
- "nix",
- "thiserror 2.0.19",
- "tracing",
-]
-
[[package]]
name = "sharded-slab"
version = "0.1.7"
diff --git a/Cargo.toml b/Cargo.toml
index 7872f4093..0f339f28c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -64,7 +64,6 @@ members = [
"core/server",
"core/server_common",
"core/shard",
- "core/shard_allocator",
"core/simulator",
"core/system_stats",
"core/tools",
@@ -303,7 +302,6 @@ serial_test = "3.5.0"
server = { path = "core/server" }
server_common = { path = "core/server_common" }
shard = { path = "core/shard" }
-shard_allocator = { path = "core/shard_allocator" }
simd-json = { version = "0.17.3", features = ["serde_impl"] }
smallvec = "1.15"
socket2 = "0.6.5"
diff --git a/core/partitions/src/send_messages2.rs
b/core/partitions/src/send_messages2.rs
deleted file mode 100644
index 5cd17fb5a..000000000
--- a/core/partitions/src/send_messages2.rs
+++ /dev/null
@@ -1,17 +0,0 @@
-// 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.
-
diff --git a/core/server/Cargo.toml b/core/server/Cargo.toml
index 22d6ba2f0..1d5911488 100644
--- a/core/server/Cargo.toml
+++ b/core/server/Cargo.toml
@@ -45,12 +45,10 @@ ignored = [
"figlet-rs",
"hash32",
"human-repr",
- "hwlocality",
"jsonwebtoken",
"left-right",
"mimalloc",
"mime_guess",
- "nix",
"opentelemetry",
"opentelemetry-appender-tracing",
"opentelemetry-otlp",
@@ -103,6 +101,7 @@ clap = { workspace = true }
compio = { workspace = true }
configs = { workspace = true }
consensus = { workspace = true }
+cpu_allocation = { workspace = true }
crossfire = { workspace = true }
ctrlc = { workspace = true }
cyper = { workspace = true }
@@ -151,7 +150,6 @@ serde = { workspace = true }
serde_json = { workspace = true }
server_common = { workspace = true }
shard = { workspace = true }
-shard_allocator = { workspace = true }
socket2 = { workspace = true }
strum = { workspace = true }
sysinfo = { workspace = true }
@@ -179,6 +177,8 @@ vergen-git2 = { workspace = true }
[dev-dependencies]
assert_cmd = { workspace = true }
bytemuck = { workspace = true }
+proc-macro2 = { workspace = true }
+quote = { workspace = true }
# Reconciler unit tests assert on `ShardMetrics` snapshots and
# `IggyShard::parked_frame_count`, gated to test/simulator so they cannot grow
# production callers. `shard`'s own `cfg(test)` is false when compiled as our
@@ -186,6 +186,7 @@ bytemuck = { workspace = true }
# keeps a dev-dependency's features out of non-test targets, so a production
# build still links `shard` without `simulator`.
shard = { workspace = true, features = ["simulator"] }
+syn = { workspace = true }
tokio = { workspace = true, features = ["full", "test-util"] }
[lints.clippy]
diff --git a/core/server/build.rs b/core/server/build.rs
index 421dd918a..ad152b723 100644
--- a/core/server/build.rs
+++ b/core/server/build.rs
@@ -23,11 +23,34 @@ const WEB_ASSETS_PATH: &str = "web/build/static";
const WEB_INDEX_FILE: &str = "web/build/static/index.html";
fn main() -> Result<(), Box<dyn error::Error>> {
+ stub_libm_for_musl();
verify_web_assets_if_enabled();
emit_vergen_instructions()?;
Ok(())
}
+// Vendored `hwloc` references `cbrt`, which makes the linker pull in a
+// `libm`. musl folds the math functions into its `libc`, so the Rust
+// musl sysroot ships no `libm.a`. Without one, `-lm` falls through to
+// the host glibc's `libm.a`, whose `cbrt` needs glibc-internal
+// `__frexp`/`__ldexp` symbols that do not exist on musl, and the static
+// link fails. Drop an empty `libm.a` stub on the search path so `-lm`
+// resolves to nothing and `cbrt` is satisfied later by musl's own
+// `libc`. No effect on non-musl targets.
+fn stub_libm_for_musl() {
+ if env::var("CARGO_CFG_TARGET_ENV").as_deref() != Ok("musl") {
+ return;
+ }
+
+ let out_dir = env::var("OUT_DIR").expect("OUT_DIR is set by cargo for
build scripts");
+ let stub = PathBuf::from(&out_dir).join("libm.a");
+
+ // `!<arch>\n` is the canonical header of an empty `ar` archive.
+ std::fs::write(&stub, b"!<arch>\n").expect("write empty libm.a stub");
+
+ println!("cargo:rustc-link-search=native={out_dir}");
+}
+
/// Returns the workspace root (iggy/), two levels up from core/server.
fn workspace_root() -> PathBuf {
PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap())
diff --git a/core/server/src/auth.rs b/core/server/src/auth.rs
index 589214852..1851281c6 100644
--- a/core/server/src/auth.rs
+++ b/core/server/src/auth.rs
@@ -22,11 +22,11 @@
//! surfaced as typed `Eviction` frames, transient ones as result-framed
//! replay hints.
-use crate::bootstrap::{ShellBus, ShellShard};
use crate::dispatch::{send_login_eviction, submit_register_on_owner};
use crate::login_register::LoginRegisterError;
use crate::responses::{build_login_register_reply, current_metadata_commit};
use crate::session_manager::{ClientSdkInfo, SessionManager};
+use crate::shell::{ShellBus, ShellShard};
use consensus::{MetadataHandle, build_result_rejection_reply};
use iggy_binary_protocol::PrepareHeader;
use iggy_binary_protocol::{ClientVersionInfo, EvictionReason,
RoutedRequestHeader};
@@ -58,11 +58,11 @@ static DUMMY_PASSWORD_HASH: LazyLock<String> =
/// 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() {
+pub fn warm_dummy_password_hash() {
LazyLock::force(&DUMMY_PASSWORD_HASH);
}
-pub(crate) fn verify_login_credentials<B, MJ, S, SB>(
+pub fn verify_login_credentials<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
username: &str,
password: &str,
@@ -109,7 +109,7 @@ where
})
}
-pub(crate) fn verify_pat_credentials<B, MJ, S, SB>(
+pub fn verify_pat_credentials<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
token: &str,
) -> Result<u32, LoginRegisterError>
@@ -127,7 +127,7 @@ where
/// 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<B, MJ, S, SB>(
+pub fn verify_pat_credentials_with_expiry<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
token: &str,
) -> Result<(u32, u64), LoginRegisterError>
@@ -179,7 +179,7 @@ where
}
#[allow(clippy::future_not_send)]
-pub(crate) async fn complete_login_register<B, MJ, S, SB>(
+pub async fn complete_login_register<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
sessions: &Rc<RefCell<SessionManager>>,
transport_client_id: u128,
@@ -294,7 +294,7 @@ where
/// SDK surfaces the real reason (every frame transport decodes
/// `Command::Eviction`) instead of a decode error or a timeout.
#[allow(clippy::future_not_send)]
-pub(crate) async fn surface_login_failure<B, MJ, S, SB>(
+pub async fn surface_login_failure<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
transport_client_id: u128,
request_header: &RoutedRequestHeader,
diff --git a/core/server/src/bootstrap.rs b/core/server/src/bootstrap.rs
index 4320c3bc5..186325ff4 100644
--- a/core/server/src/bootstrap.rs
+++ b/core/server/src/bootstrap.rs
@@ -33,14 +33,19 @@ use crate::server_error::{
PartitionRecoveryRefusal, ServerError, ShardJoinFailure,
ShardJoinFailureKind,
};
use crate::session_manager::SessionManager;
+use crate::shard_allocator::{ShardAllocator, ShardInfo};
+use crate::shell::{
+ ServerMetadata, ServerMetadataBundle, ServerMuxStateMachine, ServerShard,
ShellBus,
+ ShellHandlers, ShellShardHandle, consensus_timers, repair_retry_ticks,
+};
use compio::runtime::ResumeUnwind;
use configs::server::{ServerConfig, ServerSystemConfig};
use configs::sharding::{
INBOX_CAPACITY_MAX, SHUTDOWN_DRAIN_TIMEOUT_MAX, SHUTDOWN_POLL_INTERVAL_MAX,
};
use consensus::{
- ClientTable, ConsensusTimers, JoinMode, LocalPipeline, MetadataHandle,
PartitionsHandle,
- PipelineEntry, Sequencer, VsrConsensus, VsrRestore,
+ ClientTable, JoinMode, LocalPipeline, MetadataHandle, PartitionsHandle,
PipelineEntry,
+ Sequencer, VsrConsensus, VsrRestore,
};
// `try_send` / `try_recv` resolve through these traits on `MAsyncTx` /
// `MAsyncRx`; the metadata-handoff loops below depend on the
@@ -53,8 +58,7 @@ use iggy_common::defaults::{
MIN_PASSWORD_LENGTH, MIN_USERNAME_LENGTH,
};
use iggy_common::{
- Aes256GcmEncryptor, EncryptorKind, IggyByteSize, IggyError, PartitionStats,
- TopicRuntimeOptions, variadic,
+ Aes256GcmEncryptor, EncryptorKind, IggyByteSize, IggyError,
PartitionStats, TopicRuntimeOptions,
};
use journal::prepare_journal::PrepareJournal;
use journal::superblock::{PingPongSuperblock, SuperblockStore};
@@ -65,7 +69,7 @@ use message_bus::installer::conn_info::{ClientConnMeta,
ClientTransportKind};
use message_bus::replica::auth::{self, ReplicaAuth};
use message_bus::replica::handshake::{ReplicaHandshakeCtx, ReplicaTlsCtx};
use message_bus::replica::io as replica_io;
-use message_bus::replica::listener::{self as replica_listener, MessageHandler};
+use message_bus::replica::listener::{self as replica_listener};
use message_bus::transports::quic::server_config_with_cert;
use message_bus::transports::tls::{
AcceptAnyServerCert, REPLICA_ALPN, TlsServerCredentials,
install_default_crypto_provider,
@@ -76,15 +80,11 @@ use message_bus::{
AcceptedWsClientFn, AcceptedWssClientFn, ConnectionInstaller,
DialedReplicaFn, IggyMessageBus,
MAX_INFLIGHT_REPLICA_HANDSHAKES, MessageBus, ReplicaOwnerTable, connector,
};
-use metadata::IggyMetadata;
-use metadata::MuxStateMachine;
use metadata::ReplicaIdentity;
use metadata::impls::metadata::{IggySnapshot, StreamsFrontend};
use metadata::impls::recovery::recover;
-use metadata::stm::mux::WithFactory;
use metadata::stm::snapshot::Snapshot;
-use metadata::stm::stream::{Partition, Streams};
-use metadata::stm::user::Users;
+use metadata::stm::stream::Partition;
use partitions::{
FatalCommit, IggyIndexWriter, IggyPartition, IggyPartitions,
MessagesWriter, PartitionsConfig,
};
@@ -100,18 +100,16 @@ use shard::builder::IggyShardBuilder;
use shard::metrics::{ShardMetrics, frame_drop_reason, frame_drop_variant};
use shard::shards_table::{PapayaShardsTable, ShardsTable,
calculate_shard_assignment};
use shard::{
- CoordinatorConfig, IggyShard, LifecycleFrame, ListClientsHandler,
MetadataSubmitHandler,
- PartitionConsensusConfig, PartitionReadHandler, Receiver as ShardReceiver,
ShardFrame,
- ShardIdentity, TaggedSender, channel, shard_mesh_channels,
+ CoordinatorConfig, LifecycleFrame, PartitionConsensusConfig, Receiver as
ShardReceiver,
+ ShardFrame, ShardIdentity, TaggedSender, channel, shard_mesh_channels,
};
-use shard_allocator::{ShardAllocator, ShardInfo};
use std::cell::RefCell;
use std::collections::HashMap;
use std::env;
use std::net::{IpAddr, SocketAddr};
use std::panic;
use std::path::{Path, PathBuf};
-use std::rc::{Rc, Weak};
+use std::rc::Rc;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::thread;
@@ -123,76 +121,6 @@ const SHARD_REPLICA_ID: u8 = 0;
pub const IGGY_ROOT_USERNAME_ENV: &str = "IGGY_ROOT_USERNAME";
pub const IGGY_ROOT_PASSWORD_ENV: &str = "IGGY_ROOT_PASSWORD";
-type ServerMuxStateMachine = MuxStateMachine<variadic!(Users, Streams)>;
-
-/// Cross-thread bundle carrying one `ReadHandleFactory` per metadata
-/// state. Shard 0 mints one after `recover()` and broadcasts a clone to
-/// every peer shard; each peer rebuilds a reader-mode
-/// [`ServerMuxStateMachine`] on its own runtime, skipping the WAL.
-type ServerMetadataBundle = <variadic!(Users, Streams) as WithFactory>::Bundle;
-
-pub(crate) type ServerMetadata = IggyMetadata<
- VsrConsensus<Rc<IggyMessageBus>>,
- PrepareJournal,
- IggySnapshot,
- ServerMuxStateMachine,
->;
-
-/// The shard type the dispatch layer is generic over.
-///
-/// `B`/`MJ`/`S`/`SB` are free; the metadata state machine (`M`) and shards
-/// table (`T`) are pinned, being identical in production and the simulator.
-/// Production instantiates it as [`ServerShard`], defaulting `SB` to the
-/// on-disk [`PingPongSuperblock`]; the simulator supplies its own
-/// `B`/`MJ`/`S`/`SB`.
-pub type ShellShard<B, MJ, S, SB = PingPongSuperblock> =
- IggyShard<B, MJ, S, ServerMuxStateMachine, PapayaShardsTable, SB>;
-
-/// Late-bound self-reference the deferred dispatch handlers upgrade per frame.
-pub type ShellShardHandle<B, MJ, S, SB = PingPongSuperblock> =
- Rc<RefCell<Option<Weak<ShellShard<B, MJ, S, SB>>>>>;
-
-/// Bus bounds the dispatch/pump path needs (matches `run_message_pump`).
-/// Blanket-impl'd, so it is only shorthand for the four underlying bounds.
-pub trait ShellBus: MessageBus + ConnectionInstaller + Clone + 'static {}
-impl<B: MessageBus + ConnectionInstaller + Clone + 'static> ShellBus for B {}
-
-/// The five dispatch handlers a shard is built with, plus the
-/// [`SessionManager`] the request-plane pair shares.
-///
-/// Both production (`build_shard_for_thread`) and the simulator's shell
-/// mode construct these through [`wire_shell_handlers`], so the request
-/// plane is wired one way. The simulator's shell-off fast path uses
-/// [`ShellHandlers::noop`] instead.
-pub struct ShellHandlers {
- pub on_replica_message: MessageHandler,
- pub on_client_request: RequestHandler,
- pub on_metadata_submit: MetadataSubmitHandler,
- pub on_list_clients: ListClientsHandler,
- pub on_partition_read: PartitionReadHandler,
- /// Bound by the client-request handler, read by the get-clients
- /// handler; the caller keeps it to reach locally-homed sessions.
- pub sessions: Rc<RefCell<SessionManager>>,
-}
-
-impl ShellHandlers {
- /// Inert handlers for the shell-off fast path: every callback is a
- /// no-op over an empty [`SessionManager`]. Behaviorally identical to
- /// hand-written no-op closures, so a caller can keep one destructure
- /// site across both toggle states.
- #[must_use]
- pub fn noop() -> Self {
- Self {
- on_replica_message: Rc::new(|_, _| {}),
- on_client_request: Rc::new(|_, _| {}),
- on_metadata_submit: Rc::new(|_| {}),
- on_list_clients: Rc::new(|_| {}),
- on_partition_read: Rc::new(|_, _, _| {}),
- sessions: Rc::new(RefCell::new(SessionManager::new())),
- }
- }
-}
-
/// Build the deferred dispatch handlers for `shard_handle` against `bus`.
///
/// They share one fresh [`SessionManager`]. The caller must set the weak
@@ -228,8 +156,6 @@ where
}
}
-pub type ServerShard = ShellShard<Rc<IggyMessageBus>, PrepareJournal,
IggySnapshot>;
-
/// Result of a multi-shard bootstrap.
///
/// Carries the cross-thread shutdown flag and one OS-thread `JoinHandle`
@@ -727,7 +653,7 @@ fn validate_sharding_runtime_knobs(
/// `(senders, inboxes)` channels, and spawns one OS thread per shard.
///
/// Each thread pins itself (`nix::sched::sched_setaffinity` on Linux via
-/// [`ShardInfo::bind_cpu`]), binds memory to its NUMA node when
+/// `ShardInfo::bind_cpu`), binds memory to its NUMA node when
/// configured, builds a fresh `compio::runtime::Runtime` (one
/// `io_uring` instance per shard), and runs `shard_main` inside it.
///
@@ -2255,13 +2181,6 @@ const _: () =
const _: () =
assert!(consensus::DVC_HEADERS_MAX ==
iggy_binary_protocol::consensus::DVC_HEADERS_MAX);
const _: () = assert!(consensus::DVC_HEADERS_MAX == u128::BITS as usize);
-/// Convert a consensus-timer interval to whole ticks, floored at one tick so a
-/// sub-tick value still fires and saturated on overflow.
-fn duration_to_ticks(interval: Duration) -> u64 {
- let ticks = interval.as_millis() /
shard::CONSENSUS_TICK_INTERVAL.as_millis();
- u64::try_from(ticks.max(1)).unwrap_or(u64::MAX)
-}
-
/// `[cluster] superblock_wedged_fatal_timeout` as a consecutive-failure count.
/// Retries pin at the backoff cap after warmup, so the window divided by
/// [`journal::superblock::SUPERBLOCK_RETRY_BACKOFF_MAX_MICROS`] bounds how
@@ -2284,14 +2203,6 @@ fn superblock_window_to_failures(window: Duration) ->
u64 {
u64::try_from((window.as_micros() / cap_micros).max(1)).unwrap_or(u64::MAX)
}
-/// `[cluster] heartbeat_timeout` in consensus ticks. Every consensus group
-/// (metadata and per-partition planes alike) gets the same window: the failure
-/// it guards against - a primary that stopped heartbeating - is host-level,
not
-/// per-plane.
-pub(crate) fn cluster_heartbeat_ticks(config: &ServerConfig) -> u64 {
- duration_to_ticks(config.cluster.heartbeat_timeout.get_duration())
-}
-
/// Floor for the post-restart read-recovery deadline (see
/// [`recovery_barrier_deadline`]). At and below the 5s default heartbeat the
/// worst-case recovery is dominated by the heartbeat-independent term - the
@@ -2327,76 +2238,6 @@ pub(crate) fn recovery_barrier_deadline(
.max(RECOVERY_BARRIER_DEADLINE_FLOOR)
}
-/// `[cluster] commit_broadcast_interval` in consensus ticks: how often the
-/// primary broadcasts its commit point, the cluster's liveness feed. Applied
-/// to every consensus group, matching `cluster_heartbeat_ticks`.
-pub(crate) fn commit_broadcast_ticks(config: &ServerConfig) -> u64 {
- duration_to_ticks(config.cluster.commit_broadcast_interval.get_duration())
-}
-
-/// `[cluster] prepare_retransmit_interval` in consensus ticks: how often the
-/// primary retransmits un-acked prepares. Applied to every consensus group,
-/// matching `cluster_heartbeat_ticks`.
-pub(crate) fn prepare_retransmit_ticks(config: &ServerConfig) -> u64 {
-
duration_to_ticks(config.cluster.prepare_retransmit_interval.get_duration())
-}
-
-/// `[cluster] view_change_retransmit_interval` in consensus ticks: how often a
-/// replica retransmits its `StartViewChange` / `DoViewChange` during a view
-/// change. Applied to every consensus group, matching
`cluster_heartbeat_ticks`.
-pub(crate) fn view_change_retransmit_ticks(config: &ServerConfig) -> u64 {
- duration_to_ticks(
- config
- .cluster
- .view_change_retransmit_interval
- .get_duration(),
- )
-}
-
-/// `[cluster] view_change_status_timeout` in consensus ticks: the stalled
-/// view-change backstop before escalating to a fresh election. Applied to
every
-/// consensus group, matching `cluster_heartbeat_ticks`.
-pub(crate) fn view_change_status_ticks(config: &ServerConfig) -> u64 {
- duration_to_ticks(config.cluster.view_change_status_timeout.get_duration())
-}
-
-/// `[cluster] request_start_view_retransmit_interval` in consensus ticks: how
-/// often a recovering or view-change backup re-requests the current
`StartView`.
-/// Applied to every consensus group, matching `cluster_heartbeat_ticks`.
-pub(crate) fn request_start_view_ticks(config: &ServerConfig) -> u64 {
- duration_to_ticks(
- config
- .cluster
- .request_start_view_retransmit_interval
- .get_duration(),
- )
-}
-
-/// The full `[cluster]` timer set every consensus group boots with, built
-/// once so the planes cannot diverge in what they apply.
-pub(crate) fn consensus_timers(config: &ServerConfig) -> ConsensusTimers {
- ConsensusTimers {
- normal_heartbeat_ticks: cluster_heartbeat_ticks(config),
- commit_message_ticks: commit_broadcast_ticks(config),
- prepare_ticks: prepare_retransmit_ticks(config),
- view_change_retransmit_ticks: view_change_retransmit_ticks(config),
- view_change_status_ticks: view_change_status_ticks(config),
- request_start_view_ticks: request_start_view_ticks(config),
- probe_attempts_max: config.cluster.view_probe_attempts_max,
- }
-}
-
-/// `[cluster] repair_retry_interval` in consensus ticks: how long a stalled
-/// journal-repair stream waits before re-requesting its window. Both planes'
-/// repair loops share it, so it is applied once per shard (not per consensus
-/// group). Clamped to `u32`, the width of the session idle-tick counter.
-pub(crate) fn repair_retry_ticks(config: &ServerConfig) -> u32 {
- u32::try_from(duration_to_ticks(
- config.cluster.repair_retry_interval.get_duration(),
- ))
- .unwrap_or(u32::MAX)
-}
-
/// Shard 0's half of a metadata recovery: everything [`recover`] produced
except the
/// state machine, which every shard receives through the factory bundle.
///
diff --git a/core/server/src/consumer_group.rs
b/core/server/src/consumer_group.rs
index 00a6df558..d4b77eb95 100644
--- a/core/server/src/consumer_group.rs
+++ b/core/server/src/consumer_group.rs
@@ -25,8 +25,8 @@
//! primary enriches the op here before replication, mirroring the PAT mint
//! in [`crate::pat`] and the password hash in [`crate::users`].
-use crate::bootstrap::{ShellBus, ShellShard};
use crate::responses::resolve_partition_namespace;
+use crate::shell::{ShellBus, ShellShard};
use crate::wire::{request_body, rewrite_request_body};
use consensus::MetadataHandle;
use iggy_binary_protocol::PrepareHeader;
@@ -60,7 +60,7 @@ use std::rc::Rc;
/// (via the partition-read mesh), so the cooperative rebalance pending-revokes
/// only those and hands off never-polled/drained partitions synchronously at
/// join. Every other operation passes through.
-pub(crate) async fn maybe_rewrite_consumer_group_request<B, MJ, S, SB>(
+pub async fn maybe_rewrite_consumer_group_request<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
request: Message<RoutedRequestHeader>,
) -> Result<Message<RoutedRequestHeader>, IggyError>
@@ -205,7 +205,7 @@ where
/// purge agree, and a re-created group (new id) never inherits a stale offset.
/// Individual-consumer ops and every other operation pass through untouched.
#[allow(clippy::cast_possible_truncation)]
-pub(crate) fn maybe_rewrite_consumer_offset_request<B, MJ, S, SB>(
+pub fn maybe_rewrite_consumer_offset_request<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
request: Message<RoutedRequestHeader>,
) -> Result<Message<RoutedRequestHeader>, IggyError>
diff --git a/core/server/src/dispatch.rs b/core/server/src/dispatch.rs
index 0a494f524..784b86bdf 100644
--- a/core/server/src/dispatch.rs
+++ b/core/server/src/dispatch.rs
@@ -28,7 +28,6 @@ use crate::auth::{
complete_login_register, surface_login_failure, verify_login_credentials,
verify_pat_credentials,
};
-use crate::bootstrap::{ShellBus, ShellShard, ShellShardHandle};
use crate::cluster_meta::ClusterRoster;
use crate::consumer_group::{
maybe_rewrite_consumer_group_request,
maybe_rewrite_consumer_offset_request,
@@ -48,6 +47,7 @@ use crate::responses::{
};
use crate::segment_cleaner::UNENFORCEABLE_TOPIC_SIZE_WARN;
use crate::session_manager::SessionManager;
+use crate::shell::{ShellBus, ShellShard, ShellShardHandle};
use crate::snapshot;
use crate::users::maybe_rewrite_user_password_request;
use crate::wire::{request_body, usize_to_u32, verify_request_checksum};
@@ -130,10 +130,10 @@ use std::sync::Arc;
use std::time::Duration;
use tracing::{debug, warn};
-pub(crate) type ClientRequestQueues = Rc<RefCell<HashMap<u128,
VecDeque<Message<GenericHeader>>>>>;
-pub(crate) type ActiveClientRequests = Rc<RefCell<HashSet<u128>>>;
+pub type ClientRequestQueues = Rc<RefCell<HashMap<u128,
VecDeque<Message<GenericHeader>>>>>;
+pub type ActiveClientRequests = Rc<RefCell<HashSet<u128>>>;
-pub(crate) fn make_client_request_handler<B, MJ, S, SB>(
+pub fn make_client_request_handler<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
sessions: &Rc<RefCell<SessionManager>>,
system_config: Arc<ServerSystemConfig>,
@@ -181,9 +181,7 @@ where
/// its `SessionManager` and push them back over the reply sender. The
/// aggregation across all shards happens in
/// [`shard::IggyShard::list_all_clients`].
-pub(crate) fn make_list_clients_handler(
- sessions: &Rc<RefCell<SessionManager>>,
-) -> ListClientsHandler {
+pub fn make_list_clients_handler(sessions: &Rc<RefCell<SessionManager>>) ->
ListClientsHandler {
let sessions = Rc::clone(sessions);
Rc::new(move |reply| {
let clients: Vec<ConnectedClientInfo> =
sessions.borrow().iter_clients().collect();
@@ -198,7 +196,7 @@ pub(crate) fn make_list_clients_handler(
/// against the local partitions plane and push the result back over the
/// carried reply sender. The requesting shard bounds the wait with a
/// timeout, so a dropped reply degrades to a client-visible read failure.
-pub(crate) fn make_partition_read_handler<B, MJ, S, SB>(
+pub fn make_partition_read_handler<B, MJ, S, SB>(
shard_handle: &ShellShardHandle<B, MJ, S, SB>,
) -> PartitionReadHandler
where
@@ -451,7 +449,7 @@ fn build_auto_commit_request(
)
}
-pub(crate) fn make_deferred_replica_message_handler<B, MJ, S, SB>(
+pub fn make_deferred_replica_message_handler<B, MJ, S, SB>(
shard_handle: &ShellShardHandle<B, MJ, S, SB>,
) -> MessageHandler
where
@@ -469,7 +467,7 @@ where
})
}
-pub(crate) fn make_deferred_client_request_handler<B, MJ, S, SB>(
+pub fn make_deferred_client_request_handler<B, MJ, S, SB>(
bus: &B,
shard_handle: &ShellShardHandle<B, MJ, S, SB>,
sessions: &Rc<RefCell<SessionManager>>,
@@ -538,7 +536,7 @@ where
/// proposal. Spawns a task so the awaiting peer is woken once the op
/// commits. Submit failures are returned verbatim so the peer can preserve
/// unknown-outcome retry semantics.
-pub(crate) fn make_metadata_submit_handler<B, MJ, S, SB>(
+pub fn make_metadata_submit_handler<B, MJ, S, SB>(
shard_handle: &ShellShardHandle<B, MJ, S, SB>,
) -> shard::MetadataSubmitHandler
where
@@ -790,7 +788,7 @@ fn pop_next_client_request(
/// Zero passes here because a zero-partition TOPIC is legal (legacy
/// `create_topic` admits `0..=MAX`); the add/remove requests reject it in
/// [`validate_partitions_change_count`].
-pub(crate) const fn validate_partitions_count(partitions_count: u32) ->
Result<(), IggyError> {
+pub const fn validate_partitions_count(partitions_count: u32) -> Result<(),
IggyError> {
if partitions_count > MAX_PARTITIONS_PER_REQUEST {
return Err(IggyError::TooManyPartitions);
}
@@ -803,9 +801,7 @@ pub(crate) const fn
validate_partitions_count(partitions_count: u32) -> Result<(
/// force every shard through a rebalance pass. Legacy rejects it with
/// `TooManyPartitions` in both handlers (`1..=MAX` on create, `== 0` on
/// delete), so the code matches rather than inventing a new one.
-pub(crate) const fn validate_partitions_change_count(
- partitions_count: u32,
-) -> Result<(), IggyError> {
+pub const fn validate_partitions_change_count(partitions_count: u32) ->
Result<(), IggyError> {
if partitions_count == 0 {
return Err(IggyError::TooManyPartitions);
}
@@ -820,7 +816,7 @@ pub(crate) const fn validate_partitions_change_count(
/// `segment_size_bytes` is the topic's RESOLVED segment size (explicit
/// option, else this node's default), so a per-topic segment above the
/// global default still floors the topic cap.
-pub(crate) fn validate_topic_bounds(
+pub fn validate_topic_bounds(
partitions_count: u32,
max_topic_size: MaxTopicSize,
segment_size_bytes: u64,
@@ -832,7 +828,7 @@ pub(crate) fn validate_topic_bounds(
/// A topic cap below one segment can never be enforced: the first segment
/// already exceeds it. Split out of [`validate_topic_bounds`] because update
/// admission checks the cap without a partitions count to check.
-pub(crate) fn validate_topic_size_floor(
+pub fn validate_topic_size_floor(
max_topic_size: MaxTopicSize,
segment_size_bytes: u64,
) -> Result<(), IggyError> {
@@ -858,7 +854,7 @@ pub(crate) fn validate_topic_size_floor(
///
/// Warns rather than rejects: which caps are accepted is client-visible wire
/// behavior, and tightening it would break topics that already exist.
-pub(crate) fn warn_unenforceable_topic_size(
+pub fn warn_unenforceable_topic_size(
max_topic_size: MaxTopicSize,
segment_size_bytes: u64,
max_message_size_bytes: usize,
@@ -888,7 +884,7 @@ pub(crate) fn warn_unenforceable_topic_size(
/// partition shrinks the share: a cap that cleared the floor when the topic
was
/// created can stop clearing it here. The request carries only the delta, so
/// the stored cap, segment size and current partition count come from
metadata.
-pub(crate) fn warn_unenforceable_topic_size_on_partition_add(
+pub fn warn_unenforceable_topic_size_on_partition_add(
streams: &Streams,
stream_id: &WireIdentifier,
topic_id: &WireIdentifier,
@@ -920,7 +916,7 @@ pub(crate) fn
warn_unenforceable_topic_size_on_partition_add(
/// keys are rejected rather than skipped: a silently ignored knob would hand
/// the client server defaults without it ever learning. Streams and users
/// have no catalog keys yet, so `known` is empty for both until one lands.
-pub(crate) fn validate_option_keys(options: &WireOptions, known: &[&str]) ->
Result<(), IggyError> {
+pub fn validate_option_keys(options: &WireOptions, known: &[&str]) ->
Result<(), IggyError> {
for entry in options {
// Wire validation already enforced UTF-8 string keys.
let key = String::from_utf8_lossy(entry.key);
@@ -1468,7 +1464,7 @@ async fn handle_get_me<B, MJ, S, SB>(
/// `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<B, MJ, S, SB>(
+pub async fn dispatch_partition_request<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
request: Message<RoutedRequestHeader>,
vsr_client_id: u128,
@@ -2007,7 +2003,7 @@ async fn send_unauthenticated_eviction<B, MJ, S, SB>(
/// groups + rebalances via the replicated `Logout`) and sends a session-
/// terminal `Eviction(StaleClient)` so the client fails fast and can
reconnect.
#[allow(clippy::future_not_send)]
-pub(crate) async fn run_heartbeat_verifier<B, MJ, S, SB>(
+pub async fn run_heartbeat_verifier<B, MJ, S, SB>(
shard: Rc<ShellShard<B, MJ, S, SB>>,
sessions: Rc<RefCell<SessionManager>>,
interval: std::time::Duration,
@@ -2474,14 +2470,14 @@ fn empty_polled_messages_body(partition_id: u32) ->
Bytes {
Bytes::from(body)
}
-pub(crate) type DecodedPollRequest = (IggyNamespace, u32, PollingConsumer,
PollingArgs);
+pub type DecodedPollRequest = (IggyNamespace, u32, PollingConsumer,
PollingArgs);
/// Resolve a decoded poll request into its owning-shard read: namespace,
/// partition, polling consumer, and args. Shared by the TCP dispatch (client
/// id = the connection's bound VSR client) and the HTTP route (client id 0,
/// which fences group polls closed).
#[allow(clippy::cast_possible_truncation)]
-pub(crate) fn resolve_poll_request<B, MJ, S, SB>(
+pub fn resolve_poll_request<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
wire: &PollMessagesRequest,
client_id: u128,
@@ -2548,7 +2544,7 @@ where
/// namespace, partition, and polling consumer. Shared by the TCP dispatch and
/// the HTTP route; needs no client id because offset reads are not fenced
/// (any client may read a group's offset, member or not).
-pub(crate) fn resolve_consumer_offset_request<B, MJ, S, SB>(
+pub fn resolve_consumer_offset_request<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
wire: &GetConsumerOffsetRequest,
) -> Result<(IggyNamespace, u32, PollingConsumer), IggyError>
@@ -3082,7 +3078,7 @@ fn build_forward_logout_result_message(
/// reply (shard-0 inbox full / shutdown) maps to a transient `Canceled`, which
/// the caller wraps so the SDK replays.
#[allow(clippy::future_not_send)]
-pub(crate) async fn submit_register_on_owner<B, MJ, S, SB>(
+pub async fn submit_register_on_owner<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
vsr_client_id: u128,
user_id: u32,
@@ -3112,7 +3108,7 @@ where
/// Logout counterpart of [`submit_register_on_owner`].
#[allow(clippy::future_not_send)]
-pub(crate) async fn submit_logout_on_owner<B, MJ, S, SB>(
+pub async fn submit_logout_on_owner<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
vsr_client_id: u128,
session: u64,
@@ -3283,7 +3279,7 @@ async fn handle_delete_segments_request<B, MJ, S, SB>(
/// the error.
#[allow(clippy::future_not_send)]
#[allow(clippy::cast_possible_truncation)]
-pub(crate) async fn resolve_delete_segments_truncate<B, MJ, S, SB>(
+pub async fn resolve_delete_segments_truncate<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
template: &RoutedRequestHeader,
client_id: u128,
@@ -3445,7 +3441,7 @@ fn submit_disconnect_logout<B, MJ, S, SB>(
/// `client` id (it's the VSR id, not the transport/home-shard-encoding id).
/// `None` = transient submit failure (SDK read-timeout replays).
#[allow(clippy::future_not_send)]
-pub(crate) async fn submit_client_request_on_owner<B, MJ, S, SB>(
+pub async fn submit_client_request_on_owner<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
request: Message<RoutedRequestHeader>,
) -> Option<Message<GenericHeader>>
@@ -3759,7 +3755,7 @@ async fn handle_login_register_request<B, MJ, S, SB>(
/// metadata shard and zeroed elsewhere -- the SDK only reads the reason,
/// plus the protocol window on `IncompatibleProtocol`.
#[allow(clippy::future_not_send)]
-pub(crate) async fn send_login_eviction<B, MJ, S, SB>(
+pub async fn send_login_eviction<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
transport_client_id: u128,
vsr_client_id: u128,
@@ -3799,7 +3795,7 @@ pub(crate) async fn send_login_eviction<B, MJ, S, SB>(
}
}
-pub(crate) fn upgrade_shard_handle<B, MJ, S, SB>(
+pub fn upgrade_shard_handle<B, MJ, S, SB>(
shard_handle: &ShellShardHandle<B, MJ, S, SB>,
) -> Option<Rc<ShellShard<B, MJ, S, SB>>>
where
diff --git a/core/server/src/dispatch/authz.rs
b/core/server/src/dispatch/authz.rs
index 5e8790e7a..ee113e0d1 100644
--- a/core/server/src/dispatch/authz.rs
+++ b/core/server/src/dispatch/authz.rs
@@ -50,10 +50,10 @@ use metadata::permissioner::Permissioner;
use server_common::Message;
use tracing::warn;
-use crate::bootstrap::{ShellBus, ShellShard};
use crate::responses::{
build_deny_reply, current_metadata_commit, resolve_stream_id,
resolve_topic_id,
};
+use crate::shell::{ShellBus, ShellShard};
/// Authorize a partition-plane op on its resolved (stream, topic) for the
/// acting user, returning the deny status code or `None` to proceed. The
diff --git a/core/server/src/http.rs b/core/server/src/http.rs
index ed670883d..25a91eff5 100644
--- a/core/server/src/http.rs
+++ b/core/server/src/http.rs
@@ -36,6 +36,8 @@ mod session;
mod state;
mod submit;
mod tls;
+#[cfg(feature = "iggy-web")]
+mod web;
mod wire;
use std::cell::{Cell, RefCell};
@@ -64,7 +66,6 @@ use send_wrapper::SendWrapper;
use tower_http::cors::{AllowOrigin, CorsLayer};
use tracing::{error, info, warn};
-use crate::bootstrap::ServerShard;
use crate::cluster_meta::{ClusterRoster, resolved_roster_nodes};
use crate::http::handlers::{
change_password, create_cg, create_partitions, create_pat, create_stream,
create_topic,
@@ -80,6 +81,7 @@ use crate::http::jwt::JwtManager;
use crate::http::session::RegistrationBarrier;
use crate::http::state::{HttpInner, HttpState, insert_view_header};
use crate::server_error::ServerError;
+use crate::shell::ServerShard;
/// Bind the shard-0 HTTP listener and spawn the `cyper-axum` serve loop as a
/// background task on shard 0's compio runtime. Serves HTTPS when
@@ -300,7 +302,7 @@ fn router(
.route("/clients", get(get_clients))
.route("/clients/{client_id}", get(get_client));
let local = match metrics_endpoint {
- Some(endpoint) => local.route(endpoint, get(metrics::get_metrics)),
+ Some(endpoint) => local.route(endpoint, get(handlers::get_metrics)),
None => local,
};
let router = Router::new()
@@ -458,7 +460,7 @@ fn merge_web_ui(router: Router, web_ui: bool) -> Router {
#[cfg(feature = "iggy-web")]
let router = if web_ui {
info!("Web UI enabled at /ui");
- router.merge(crate::web::router())
+ router.merge(web::router())
} else {
router
};
diff --git a/core/server/src/http/forward.rs b/core/server/src/http/forward.rs
index 8c1acadcd..b0ead9be5 100644
--- a/core/server/src/http/forward.rs
+++ b/core/server/src/http/forward.rs
@@ -82,7 +82,7 @@ use crate::http::error::{
CustomError, error_response, gateway_timeout_response,
primary_http_socket, with_retry_after,
};
use crate::http::extractor::{bearer_token, resolve_credential};
-use crate::http::state::{HttpInner, VIEW_HEADER};
+use crate::http::state::{ForwardState, HttpInner, VIEW_HEADER};
use crate::server_error::ServerError;
/// Marker stamped on every forwarded request. Loop guard only: a node that is
@@ -135,23 +135,6 @@ const RESPONSE_CAPACITY_HINT: usize = 64 * 1024;
/// (the view layer only fills the header when absent).
const RELAYED_RESPONSE_HEADERS: [HeaderName; 3] = [CONTENT_TYPE, RETRY_AFTER,
VIEW_HEADER];
-/// Per-node forwarding context hung off `HttpInner`: the outbound client
-/// (pinned-cert TLS when the listener serves HTTPS), the scheme it dials, the
-/// request-body buffer bound, and the in-flight budget.
-pub(in crate::http) struct ForwardState {
- /// False when no cluster-wide bearer key material exists (no configured
- /// JWT secret, no cluster PSK): a forwarded bearer would 401 on the
- /// primary, so the middleware passes through and followers answer with
- /// the transient 503 instead.
- active: bool,
- client: cyper::Client,
- /// Also read by the 307 redirect builder: the primary is assumed to serve
- /// the same scheme as this node (uniform cluster HTTP config).
- pub(in crate::http) scheme: &'static str,
- body_limit: usize,
- in_flight: Cell<u32>,
-}
-
/// Build the [`ForwardState`] at listener startup.
///
/// With `http.tls.enabled` the forward hop dials `https` and verifies the peer
diff --git a/core/server/src/http/handlers.rs b/core/server/src/http/handlers.rs
index c1172a4cc..cdce21b5c 100644
--- a/core/server/src/http/handlers.rs
+++ b/core/server/src/http/handlers.rs
@@ -129,6 +129,7 @@ use crate::http::error::{
ReadError, WriteError,
};
use crate::http::extractor::{Authenticated, Identity};
+use crate::http::metrics::gauge_value;
use crate::http::reads::{
authorize_data_plane, authorize_read, read_local, resolve_gate_stream,
resolve_gate_topic,
resolve_gate_topic_ids, resolve_gate_user,
@@ -600,6 +601,72 @@ pub(in crate::http) async fn get_stats(
Ok(Json(Stats::from(response)))
}
+/// `GET <http.metrics.endpoint>`: the metric set in prometheus text
+/// exposition. Auth-only, like `/stats`: the `Identity` extractor rejects a
+/// missing or invalid bearer with 401, and any authenticated user may scrape
+/// (no RBAC rule guards it). Scrapers present a JWT or a raw PAT the same way
+/// every read route accepts them.
+///
+/// The entity gauges sample the same reads `/stats` serves: the metadata STM
+/// stream and user maps plus the stats-registry rollups, whose partition-plane
+/// increments are relaxed, so scraped values are approximate while writes are
+/// in flight. The clients count scatter-gathers the per-shard session managers
+/// exactly like `GET /clients` and turns partial when a shard misses the reply
+/// deadline.
+pub(in crate::http) async fn get_metrics(
+ State(state): State<HttpState>,
+ _identity: Identity,
+) -> String {
+ let (streams_count, topics_count, partitions_count, segments_count,
messages_count) = state
+ .shard
+ .plane
+ .metadata()
+ .mux_stm
+ .streams()
+ .read(|streams| {
+ let mut topics_count = 0u64;
+ let mut partitions_count = 0u64;
+ let mut segments_count = 0u64;
+ let mut messages_count = 0u64;
+ for (_, stream) in &streams.items {
+ topics_count = topics_count.saturating_add(stream.topics.len()
as u64);
+ segments_count = segments_count
+
.saturating_add(u64::from(stream.stats.segments_count_inconsistent()));
+ messages_count =
+
messages_count.saturating_add(stream.stats.messages_count_inconsistent());
+ for (_, topic) in &stream.topics {
+ partitions_count =
+ partitions_count.saturating_add(topic.partitions.len()
as u64);
+ }
+ }
+ (
+ streams.items.len() as u64,
+ topics_count,
+ partitions_count,
+ segments_count,
+ messages_count,
+ )
+ });
+ let users_count = state
+ .shard
+ .plane
+ .metadata()
+ .mux_stm
+ .users()
+ .read(|users| users.items.len() as u64);
+ let clients_count =
SendWrapper::new(state.shard.list_all_clients()).await.len() as u64;
+
+ let metrics = &state.metrics;
+ metrics.streams.set(gauge_value(streams_count));
+ metrics.topics.set(gauge_value(topics_count));
+ metrics.partitions.set(gauge_value(partitions_count));
+ metrics.segments.set(gauge_value(segments_count));
+ metrics.messages.set(gauge_value(messages_count));
+ metrics.users.set(gauge_value(users_count));
+ metrics.clients.set(gauge_value(clients_count));
+ metrics.formatted_output()
+}
+
/// `POST /snapshot`: collect a diagnostic archive and return it as a ZIP
/// download with the same headers the legacy server sets.
///
diff --git a/core/server/src/http/metrics.rs b/core/server/src/http/metrics.rs
index 3fdb1b423..6546c00c1 100644
--- a/core/server/src/http/metrics.rs
+++ b/core/server/src/http/metrics.rs
@@ -16,40 +16,35 @@
// under the License.
//! The `[http.metrics]` scrape surface: the legacy-parity metric registry
-//! (entity gauges plus the request counter), its public scrape handler, and
-//! the config gate deciding whether the route is mounted.
+//! (entity gauges plus the request counter) and the config gate deciding
+//! whether the route is mounted. The scrape handler itself lives with the
+//! other route handlers so this leaf never imports the state hub.
-use axum::extract::State;
use configs::http::HttpMetricsConfig;
-use consensus::MetadataHandle;
use iggy_common::IggyError;
-use metadata::impls::metadata::StreamsFrontend;
use prometheus_client::encoding::text::encode;
use prometheus_client::metrics::counter::Counter;
use prometheus_client::metrics::gauge::Gauge;
use prometheus_client::registry::Registry;
-use send_wrapper::SendWrapper;
use tracing::error;
-use crate::http::extractor::Identity;
-use crate::http::state::HttpState;
-
/// The legacy server's metric set, registered under the same names and help
/// texts so existing dashboards and alerts keep working unchanged.
///
/// Unlike the legacy server, the entity gauges are not counted at mutation
-/// sites: [`get_metrics`] samples the live state on every scrape, so a gauge
-/// can never drift from the state it describes.
+/// sites: the scrape handler (`http::handlers::get_metrics`) samples the live
+/// state on every scrape, so a gauge can never drift from the state it
+/// describes.
pub(in crate::http) struct HttpMetrics {
registry: Registry,
http_requests: Counter,
- streams: Gauge,
- topics: Gauge,
- partitions: Gauge,
- segments: Gauge,
- messages: Gauge,
- users: Gauge,
- clients: Gauge,
+ pub(in crate::http) streams: Gauge,
+ pub(in crate::http) topics: Gauge,
+ pub(in crate::http) partitions: Gauge,
+ pub(in crate::http) segments: Gauge,
+ pub(in crate::http) messages: Gauge,
+ pub(in crate::http) users: Gauge,
+ pub(in crate::http) clients: Gauge,
}
impl HttpMetrics {
@@ -111,7 +106,7 @@ impl HttpMetrics {
self.http_requests.clone()
}
- fn formatted_output(&self) -> String {
+ pub(in crate::http) fn formatted_output(&self) -> String {
let mut buffer = String::new();
if let Err(error) = encode(&mut buffer, &self.registry) {
error!(%error, "failed to encode metrics");
@@ -147,75 +142,9 @@ pub(in crate::http) fn validated_endpoint(
Ok(Some(config.endpoint.clone()))
}
-/// `GET <http.metrics.endpoint>`: the metric set in prometheus text
-/// exposition. Auth-only, like `/stats`: the `Identity` extractor rejects a
-/// missing or invalid bearer with 401, and any authenticated user may scrape
-/// (no RBAC rule guards it). Scrapers present a JWT or a raw PAT the same way
-/// every read route accepts them.
-///
-/// The entity gauges sample the same reads `/stats` serves: the metadata STM
-/// stream and user maps plus the stats-registry rollups, whose partition-plane
-/// increments are relaxed, so scraped values are approximate while writes are
-/// in flight. The clients count scatter-gathers the per-shard session managers
-/// exactly like `GET /clients` and turns partial when a shard misses the reply
-/// deadline.
-pub(in crate::http) async fn get_metrics(
- State(state): State<HttpState>,
- _identity: Identity,
-) -> String {
- let (streams_count, topics_count, partitions_count, segments_count,
messages_count) = state
- .shard
- .plane
- .metadata()
- .mux_stm
- .streams()
- .read(|streams| {
- let mut topics_count = 0u64;
- let mut partitions_count = 0u64;
- let mut segments_count = 0u64;
- let mut messages_count = 0u64;
- for (_, stream) in &streams.items {
- topics_count = topics_count.saturating_add(stream.topics.len()
as u64);
- segments_count = segments_count
-
.saturating_add(u64::from(stream.stats.segments_count_inconsistent()));
- messages_count =
-
messages_count.saturating_add(stream.stats.messages_count_inconsistent());
- for (_, topic) in &stream.topics {
- partitions_count =
- partitions_count.saturating_add(topic.partitions.len()
as u64);
- }
- }
- (
- streams.items.len() as u64,
- topics_count,
- partitions_count,
- segments_count,
- messages_count,
- )
- });
- let users_count = state
- .shard
- .plane
- .metadata()
- .mux_stm
- .users()
- .read(|users| users.items.len() as u64);
- let clients_count =
SendWrapper::new(state.shard.list_all_clients()).await.len() as u64;
-
- let metrics = &state.metrics;
- metrics.streams.set(gauge_value(streams_count));
- metrics.topics.set(gauge_value(topics_count));
- metrics.partitions.set(gauge_value(partitions_count));
- metrics.segments.set(gauge_value(segments_count));
- metrics.messages.set(gauge_value(messages_count));
- metrics.users.set(gauge_value(users_count));
- metrics.clients.set(gauge_value(clients_count));
- metrics.formatted_output()
-}
-
/// Clamp a count into the gauge's `i64` domain; only `messages` can pass
/// `i64::MAX` even in theory, the rest are bounded far below it.
-fn gauge_value(count: u64) -> i64 {
+pub(in crate::http) fn gauge_value(count: u64) -> i64 {
i64::try_from(count).unwrap_or(i64::MAX)
}
diff --git a/core/server/src/http/reads.rs b/core/server/src/http/reads.rs
index 08e743f47..3859b28ae 100644
--- a/core/server/src/http/reads.rs
+++ b/core/server/src/http/reads.rs
@@ -19,7 +19,7 @@
//! metadata-STM read entry, and the wire/domain identifier resolvers the read
//! and data-plane routes ground their scopes through.
-use crate::bootstrap::ServerShard;
+use crate::shell::ServerShard;
use bytes::Bytes;
use consensus::MetadataHandle;
use iggy_binary_protocol::WireIdentifier;
diff --git a/core/server/src/http/state.rs b/core/server/src/http/state.rs
index 8ea1bb486..c0c67e7de 100644
--- a/core/server/src/http/state.rs
+++ b/core/server/src/http/state.rs
@@ -37,17 +37,17 @@ use send_wrapper::SendWrapper;
use tokio::sync::Mutex;
use tracing::warn;
-use crate::bootstrap::ServerShard;
use crate::cluster_meta::ClusterRoster;
use crate::dispatch::submit_register_on_owner;
use crate::http::error::{AuthError, ReadError, primary_redirect_location};
-use crate::http::forward::ForwardState;
+
use crate::http::jwt::JwtManager;
use crate::http::metrics::HttpMetrics;
use crate::http::session::{
BarrierEntry, FIRST_REQUEST_ID, FRESH_ENTRY_WATERMARK, HttpSession,
RegistrationBarrier,
forget_if_same, live_entry, sweep_expired,
};
+use crate::shell::ServerShard;
/// Response header carrying the current VSR view number. Stamped by
/// `insert_view_header` on success and redirect responses only (never on
@@ -63,6 +63,25 @@ pub(in crate::http) const VIEW_HEADER: HeaderName =
HeaderName::from_static("igg
/// same thread that builds this state. Never touch it off that thread.
pub(in crate::http) type HttpState = SendWrapper<Rc<HttpInner>>;
+/// Per-node forwarding context hung off `HttpInner`: the outbound client
+/// (pinned-cert TLS when the listener serves HTTPS), the scheme it dials, the
+/// request-body buffer bound, and the in-flight budget. Built by
+/// `http::forward::build_forward_state`; lives here so the state hub never
+/// imports the forwarding middleware.
+pub(in crate::http) struct ForwardState {
+ /// False when no cluster-wide bearer key material exists (no configured
+ /// JWT secret, no cluster PSK): a forwarded bearer would 401 on the
+ /// primary, so the middleware passes through and followers answer with
+ /// the transient 503 instead.
+ pub(in crate::http) active: bool,
+ pub(in crate::http) client: cyper::Client,
+ /// Also read by the 307 redirect builder: the primary is assumed to serve
+ /// the same scheme as this node (uniform cluster HTTP config).
+ pub(in crate::http) scheme: &'static str,
+ pub(in crate::http) body_limit: usize,
+ pub(in crate::http) in_flight: Cell<u32>,
+}
+
/// Shared shard-0 HTTP state.
///
/// Groups the shard handle, the JWT issuer/verifier, and the per-credential
VSR
diff --git a/core/server/src/http/submit.rs b/core/server/src/http/submit.rs
index ca763d836..eeaf9dc1a 100644
--- a/core/server/src/http/submit.rs
+++ b/core/server/src/http/submit.rs
@@ -33,7 +33,6 @@ use metadata::impls::metadata::StreamsFrontend;
use server_common::Message;
use tracing::warn;
-use crate::bootstrap::ServerShard;
use crate::dispatch::{
dispatch_partition_request, resolve_delete_segments_truncate,
submit_client_request_on_owner,
submit_logout_on_owner,
@@ -47,6 +46,7 @@ use crate::http::session::HttpSession;
use crate::http::state::HttpInner;
use crate::http::wire::build_request_message;
use crate::pat::rewrite_pat_request_for_user;
+use crate::shell::ServerShard;
use crate::users::maybe_rewrite_user_password_request;
use crate::wire::request_body;
diff --git a/core/server/src/web.rs b/core/server/src/http/web.rs
similarity index 100%
rename from core/server/src/web.rs
rename to core/server/src/http/web.rs
diff --git a/core/server/src/lib.rs b/core/server/src/lib.rs
index 2f5a00932..46ce02fb7 100644
--- a/core/server/src/lib.rs
+++ b/core/server/src/lib.rs
@@ -30,28 +30,43 @@ static GLOBAL: MiMalloc = MiMalloc;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const SEMANTIC_VERSION: SemanticVersion =
SemanticVersion::parse_const(VERSION);
-pub mod auth;
+// Visibility rule: `pub` = named external consumer. main.rs consumes
+// `bootstrap`, `server_error`, and `systemd`; the simulator consumes `shell`,
+// `bootstrap::wire_shell_handlers`, and (through `ShellHandlers.sessions`)
+// `session_manager`. Everything else is crate-internal.
+
+// boot: process entry, shard threads, recovery orchestration.
pub mod bootstrap;
-pub(crate) mod cluster_meta;
-pub mod config_writer;
-pub mod consumer_group;
-pub mod dispatch;
+pub(crate) mod config_writer;
+pub(crate) mod shard_allocator;
+#[cfg(feature = "systemd")]
+pub mod systemd;
+
+// spine: the request path - shell vocabulary, dispatch funnel, per-domain ops.
+pub(crate) mod auth;
+pub(crate) mod consumer_group;
+pub(crate) mod dispatch;
+pub(crate) mod login_register;
+pub(crate) mod pat;
+pub(crate) mod responses;
+pub mod session_manager;
+pub mod shell;
+pub(crate) mod users;
+pub(crate) mod wire;
+
+// http: the REST spine (role-leaf tree).
pub(crate) mod http;
-pub mod login_register;
-pub(crate) mod offset_recovery;
-pub mod partition_helpers;
-pub mod partition_reconciler;
-pub mod pat;
+
+// background: per-shard maintenance loops.
+pub(crate) mod partition_reconciler;
pub(crate) mod personal_access_token_cleaner;
-pub mod responses;
pub(crate) mod segment_cleaner;
+pub(crate) mod snapshot;
+
+// support: shared plumbing and the crash-recovery readers
+// (the readers move beside their writers in core/partitions later).
+pub(crate) mod cluster_meta;
+pub(crate) mod offset_recovery;
+pub(crate) mod partition_helpers;
pub(crate) mod segment_recovery;
pub mod server_error;
-pub mod session_manager;
-pub(crate) mod snapshot;
-#[cfg(feature = "systemd")]
-pub mod systemd;
-pub mod users;
-#[cfg(feature = "iggy-web")]
-pub(crate) mod web;
-pub mod wire;
diff --git a/core/server/src/partition_helpers.rs
b/core/server/src/partition_helpers.rs
index a6f709880..91610720b 100644
--- a/core/server/src/partition_helpers.rs
+++ b/core/server/src/partition_helpers.rs
@@ -432,7 +432,7 @@ pub async fn ensure_initial_segment(
/// [`ServerError::PartitionSuperblockIo`] when the directory or a slot
/// cannot be read; the `VersionUnknown` / `Unverifiable` / `Undecodable` /
/// `IdentityMismatch` variants when a record exists but cannot be trusted.
-pub(crate) async fn open_partition_superblock(
+pub async fn open_partition_superblock(
partition_dir: &str,
identity: ReplicaIdentity,
) -> Result<(Rc<PingPongSuperblock>, Option<VsrState>), ServerError> {
@@ -609,7 +609,7 @@ pub async fn build_partition_fresh(
// Request queue holds 2x the prepare depth (buffered requests drain as
// prepares commit); depth is the per-partition `[partition]` knob.
let prepare_queue_depth = config.partition.prepare_queue_depth;
- let timers = crate::bootstrap::consensus_timers(config);
+ let timers = crate::shell::consensus_timers(config);
let consensus = VsrConsensus::restored(
cluster_id,
self_replica_id,
diff --git a/core/server/src/partition_reconciler.rs
b/core/server/src/partition_reconciler.rs
index dea36c9c0..5cbd5f19a 100644
--- a/core/server/src/partition_reconciler.rs
+++ b/core/server/src/partition_reconciler.rs
@@ -169,8 +169,8 @@
//! discriminator, like `checkpoint_id` on every prepare
//! -- `PrepareHeader.reserved` has room, but it is a `#[repr(C)]` wire change.
-use crate::bootstrap::ServerShard;
use crate::partition_helpers::{build_partition_fresh,
delete_partitions_from_disk};
+use crate::shell::ServerShard;
use ahash::{AHashMap, AHashSet};
use configs::server::ServerConfig;
use consensus::{MetadataHandle, PartitionsHandle};
diff --git a/core/server/src/pat.rs b/core/server/src/pat.rs
index 26c8ea6db..dfaf3f50f 100644
--- a/core/server/src/pat.rs
+++ b/core/server/src/pat.rs
@@ -37,7 +37,7 @@ use server_common::Message;
use std::cell::RefCell;
use std::rc::Rc;
-pub(crate) fn maybe_rewrite_pat_request(
+pub fn maybe_rewrite_pat_request(
sessions: &Rc<RefCell<SessionManager>>,
transport_client_id: u128,
max_tokens_per_user: u32,
@@ -65,7 +65,7 @@ pub(crate) fn maybe_rewrite_pat_request(
/// form scoped to `user_id` (`only_if_expired` false); every other operation
/// passes through unchanged with `None` (the HTTP write core routes all of
/// its ops here, so the non-PAT arm is a no-op, not `unreachable`).
-pub(crate) fn rewrite_pat_request_for_user(
+pub fn rewrite_pat_request_for_user(
user_id: u32,
max_tokens_per_user: u32,
pat_count_of: impl FnOnce(u32) -> usize,
diff --git a/core/server/src/personal_access_token_cleaner.rs
b/core/server/src/personal_access_token_cleaner.rs
index a55093605..61e7f585a 100644
--- a/core/server/src/personal_access_token_cleaner.rs
+++ b/core/server/src/personal_access_token_cleaner.rs
@@ -22,7 +22,7 @@
//! proposes it once and every replica applies the commit. Backups never
//! propose, so cleanup cannot race across the cluster.
-use crate::bootstrap::ServerShard;
+use crate::shell::ServerShard;
use consensus::MetadataHandle;
use iggy_binary_protocol::WireName;
use iggy_common::IggyTimestamp;
diff --git a/core/server/src/responses.rs b/core/server/src/responses.rs
index 8165bdf63..6575456ae 100644
--- a/core/server/src/responses.rs
+++ b/core/server/src/responses.rs
@@ -23,9 +23,9 @@
//! `NonReplicatedResponse` dispatch shim and the partition-namespace
//! resolvers.
-use crate::bootstrap::{ShellBus, ShellShard};
use crate::cluster_meta::ClusterRoster;
use crate::session_manager::SessionManager;
+use crate::shell::{ShellBus, ShellShard};
use crate::wire::{transport_kind_to_wire, usize_to_u32};
use bytes::{Bytes, BytesMut};
use consensus::{MetadataHandle, VsrConsensus};
@@ -106,7 +106,7 @@ use system_stats::SystemProbe;
/// (`user_id`, transport kind, peer address) comes from the per-shard
/// [`SessionManager`]; the `consumer_groups` list is read from the
/// (replicated) consumer-group STM by the connection's bound VSR client id.
-pub(crate) fn build_get_personal_access_tokens_response<B, MJ, S, SB>(
+pub fn build_get_personal_access_tokens_response<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
sessions: &Rc<RefCell<SessionManager>>,
transport_client_id: u128,
@@ -143,7 +143,7 @@ where
})
}
-pub(crate) fn build_get_me_response<B, MJ, S, SB>(
+pub fn build_get_me_response<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
sessions: &Rc<RefCell<SessionManager>>,
transport_client_id: u128,
@@ -218,7 +218,7 @@ where
/// `consumer_groups_count` is resolved from the connection's bound VSR client
/// id against the replicated `Streams` STM (memberships are keyed by VSR id,
not
/// transport id). Connections that never bound (pre-register) count 0.
-pub(crate) fn connected_client_to_response<B, MJ, S, SB>(
+pub fn connected_client_to_response<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
info: &ConnectedClientInfo,
) -> ClientResponse
@@ -326,7 +326,7 @@ where
resolve_partition_namespace(shard, stream_id, topic_id, partition_id)
}
-pub(crate) fn resolve_partition_request_namespace<B, MJ, S, SB>(
+pub fn resolve_partition_request_namespace<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
operation: Operation,
body: &[u8],
@@ -431,7 +431,7 @@ where
)
}
-pub(crate) fn resolve_partition_namespace<B, MJ, S, SB>(
+pub fn resolve_partition_namespace<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
stream_id: &WireIdentifier,
topic_id: &WireIdentifier,
@@ -494,7 +494,7 @@ fn wire_identifier_for_display(id: &WireIdentifier) ->
Identifier {
/// connected-client total, used only by the stats read: it comes from the
async
/// `ListClients` scatter-gather, which this sync builder cannot run, so both
/// transport callers gather it up front (0 for every other opcode).
-pub(crate) fn build_non_replicated_response<B, MJ, S, SB>(
+pub fn build_non_replicated_response<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
code: u32,
body: &[u8],
@@ -886,7 +886,7 @@ static STATS_DATA_PATH: OnceLock<PathBuf> = OnceLock::new();
/// Capture the configured data directory for `GetStats` disk reporting.
/// Idempotent: only the first call (process bootstrap) takes effect.
-pub(crate) fn init_stats_data_path(path: PathBuf) {
+pub fn init_stats_data_path(path: PathBuf) {
let _ = STATS_DATA_PATH.set(path);
}
@@ -1279,7 +1279,7 @@ fn topic_not_found(stream_id: &WireIdentifier, topic_id:
&WireIdentifier) -> Igg
)
}
-pub(crate) fn resolve_stream_id(
+pub fn resolve_stream_id(
streams: &metadata::stm::stream::StreamsInner,
identifier: &WireIdentifier,
) -> Option<usize> {
@@ -1292,7 +1292,7 @@ pub(crate) fn resolve_stream_id(
}
}
-pub(crate) fn resolve_topic_id(
+pub fn resolve_topic_id(
streams: &metadata::stm::stream::StreamsInner,
stream_id: usize,
identifier: &WireIdentifier,
@@ -1409,7 +1409,7 @@ fn partition_response(
})
}
-pub(crate) enum NonReplicatedResponse {
+pub enum NonReplicatedResponse {
Empty,
Bytes(Bytes),
}
@@ -1431,7 +1431,7 @@ impl NonReplicatedResponse {
}
}
-pub(crate) fn build_empty_reply(
+pub fn build_empty_reply(
request_header: &RoutedRequestHeader,
client_id: u128,
session: u64,
@@ -1447,7 +1447,7 @@ pub(crate) fn build_empty_reply(
/// body, nonzero status); op carries the builder's session argument like every
/// reply, and only the partition primary's pre-pipeline deny pins it to 0,
/// stamped through `consensus::build_deny_reply_from_request`.
-pub(crate) fn build_deny_reply(
+pub fn build_deny_reply(
request_header: &RoutedRequestHeader,
client_id: u128,
session: u64,
@@ -1504,7 +1504,7 @@ fn build_result_framed_reply(
)
}
-pub(crate) fn build_login_register_reply(
+pub fn build_login_register_reply(
request_header: &RoutedRequestHeader,
client_id: u128,
session: u64,
@@ -1523,7 +1523,7 @@ pub(crate) fn build_login_register_reply(
build_result_framed_reply(request_header, client_id, session, commit,
&payload)
}
-pub(crate) fn build_reply_from_bytes(
+pub fn build_reply_from_bytes(
request_header: &RoutedRequestHeader,
client_id: u128,
session: u64,
@@ -1546,7 +1546,7 @@ pub(crate) fn build_reply_from_bytes(
/// reusing the confirmed commit position from the committed reply. Otherwise
/// (no token, a committed business rejection, or an eviction frame) the
/// committed reply passes through unchanged.
-pub(crate) fn build_raw_pat_reply(
+pub fn build_raw_pat_reply(
request_header: &RoutedRequestHeader,
committed: Message<GenericHeader>,
raw_token: Option<String>,
@@ -1600,7 +1600,7 @@ pub(crate) fn build_raw_pat_reply(
Ok(reply.into_generic())
}
-pub(crate) fn build_reply_with_body(
+pub fn build_reply_with_body(
request_header: &RoutedRequestHeader,
client_id: u128,
session: u64,
@@ -1636,7 +1636,7 @@ pub(crate) fn build_reply_with_body(
reply
}
-pub(crate) fn current_metadata_commit<B, MJ, S, SB>(shard: &Rc<ShellShard<B,
MJ, S, SB>>) -> u64
+pub fn current_metadata_commit<B, MJ, S, SB>(shard: &Rc<ShellShard<B, MJ, S,
SB>>) -> u64
where
B: ShellBus,
MJ: JournalHandle + 'static,
@@ -1663,7 +1663,7 @@ where
/// decrypt point, so encrypted records are rebuilt over the plaintext.
///
/// Body layout: `[partition_id:4][current_offset:8][count:4][batch
records...]`.
-pub(crate) fn build_polled_messages_body(
+pub fn build_polled_messages_body(
partition_id: u32,
current_offset: u64,
fragments: PollFragments,
@@ -1715,7 +1715,7 @@ pub(crate) fn build_polled_messages_body(
/// Build the `ConsumerOffsetResponse` reply body:
/// `[partition_id:4][current_offset:8][stored_offset:8]`.
-pub(crate) fn build_consumer_offset_body(
+pub fn build_consumer_offset_body(
partition_id: u32,
current_offset: u64,
stored_offset: u64,
diff --git a/core/server/src/segment_cleaner.rs
b/core/server/src/segment_cleaner.rs
index eac91c156..724666b24 100644
--- a/core/server/src/segment_cleaner.rs
+++ b/core/server/src/segment_cleaner.rs
@@ -26,7 +26,7 @@
//! with reads. This mirrors the legacy server's `MessagesCleaner` ->
//! message-pump `CleanTopicMessages` path.
-use crate::bootstrap::ServerShard;
+use crate::shell::ServerShard;
use consensus::{MetadataHandle, PartitionsHandle};
use iggy_common::{IggyExpiry, IggyTimestamp, MaxTopicSize};
use metadata::impls::metadata::StreamsFrontend;
diff --git a/core/server/src/server_error.rs b/core/server/src/server_error.rs
index d5343d137..53e84e46d 100644
--- a/core/server/src/server_error.rs
+++ b/core/server/src/server_error.rs
@@ -15,11 +15,11 @@
// specific language governing permissions and limitations
// under the License.
+use crate::shard_allocator::ShardingError;
use consensus::VsrStateError;
use metadata::impls::recovery::RecoveryError;
use server_common::log::LogError;
use shard::ShardCtorError;
-use shard_allocator::ShardingError;
use std::path::PathBuf;
use thiserror::Error;
diff --git a/core/shard_allocator/src/lib.rs
b/core/server/src/shard_allocator.rs
similarity index 93%
rename from core/shard_allocator/src/lib.rs
rename to core/server/src/shard_allocator.rs
index f0aadae26..e71ddbfac 100644
--- a/core/shard_allocator/src/lib.rs
+++ b/core/server/src/shard_allocator.rs
@@ -15,10 +15,10 @@
// specific language governing permissions and limitations
// under the License.
-//! `shard_allocator`: decide which CPU cores each shard lives on.
+//! Decide which CPU cores each shard lives on.
//!
//! The server makes many shards and wants each one to run on its own
-//! core so they do not fight over CPU time. This crate reads the
+//! core so they do not fight over CPU time. This module reads the
//! operator's choice ([`CpuAllocation`] from the config), looks at the
//! real machine with `hwloc`, and hands back one [`ShardInfo`] per
//! shard. On Linux it also pins each shard's thread to its core and
@@ -97,7 +97,7 @@ pub struct NumaTopology {
impl NumaTopology {
/// Ask `hwloc` to read this machine's NUMA layout right now.
/// Errors if hwloc fails or the machine reports no NUMA nodes.
- pub fn detect() -> Result<NumaTopology, ShardingError> {
+ pub fn detect() -> Result<Self, ShardingError> {
let topology =
Topology::new().map_err(|e| ShardingError::TopologyDetection {
msg: e.to_string() })?;
@@ -113,20 +113,19 @@ impl NumaTopology {
let mut logical_cores_per_node = Vec::new();
for node in numa_nodes {
- let cpuset = node.cpuset().ok_or(ShardingError::TopologyDetection {
- msg: "NUMA node has no CPU set".to_string(),
- })?;
+ let cpuset = node
+ .cpuset()
+ .ok_or_else(|| ShardingError::TopologyDetection {
+ msg: "NUMA node has no CPU set".to_string(),
+ })?;
let logical_cores = cpuset.weight().unwrap_or(0);
let physical_cores = topology
.objects_with_type(ObjectType::Core)
.filter(|core| {
- if let Some(core_cpuset) = core.cpuset() {
- !(cpuset & core_cpuset).is_empty()
- } else {
- false
- }
+ core.cpuset()
+ .is_some_and(|core_cpuset| !(cpuset &
core_cpuset).is_empty())
})
.count();
@@ -153,15 +152,15 @@ impl NumaTopology {
self.logical_cores_per_node.get(node).copied().unwrap_or(0)
}
- fn filter_physical_cores(&self, node_cpuset: CpuSet) -> CpuSet {
+ fn filter_physical_cores(&self, node_cpuset: &CpuSet) -> CpuSet {
let mut physical_cpuset = CpuSet::new();
for core in self.topology.objects_with_type(ObjectType::Core) {
if let Some(core_cpuset) = core.cpuset() {
- let intersection = node_cpuset.clone() & core_cpuset;
+ let intersection = node_cpuset & core_cpuset;
if !intersection.is_empty()
&& let Some(first_cpu) = intersection.iter_set().min()
{
- physical_cpuset.set(first_cpu)
+ physical_cpuset.set(first_cpu);
}
}
}
@@ -182,14 +181,16 @@ impl NumaTopology {
available: self.node_count,
})?;
- let cpuset_ref = node.cpuset().ok_or(ShardingError::TopologyDetection {
- msg: format!("Node {} has no CPU set", node_id),
- })?;
+ let cpuset_ref = node
+ .cpuset()
+ .ok_or_else(|| ShardingError::TopologyDetection {
+ msg: format!("Node {node_id} has no CPU set"),
+ })?;
let cpuset = SpecializedBitmapRef::to_owned(&cpuset_ref);
if avoid_hyperthread {
- Ok(self.filter_physical_cores(cpuset))
+ Ok(self.filter_physical_cores(&cpuset))
} else {
Ok(cpuset)
}
@@ -199,6 +200,8 @@ impl NumaTopology {
/// One shard's home: which CPU cores it may run on, and which NUMA
/// node its memory should sit near (`None` means do not pin memory).
#[derive(Debug, Clone)]
+// The only readers are the Linux-gated bind paths below.
+#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub struct ShardInfo {
pub cpu_set: HashSet<usize>,
pub numa_node: Option<usize>,
@@ -249,7 +252,7 @@ impl ShardInfo {
let node = topology
.objects_with_type(ObjectType::NUMANode)
.nth(node_id)
- .ok_or(ShardingError::InvalidNode {
+ .ok_or_else(|| ShardingError::InvalidNode {
requested: node_id,
available:
topology.objects_with_type(ObjectType::NUMANode).count(),
})?;
@@ -324,10 +327,7 @@ pub struct ShardAllocator {
impl ShardAllocator {
/// Build an allocator for the given choice. Only `NumaAware` reads
/// the machine topology up front; the simpler modes do not.
- pub fn new(
- allocation: &CpuAllocation,
- pin_cores: bool,
- ) -> Result<ShardAllocator, ShardingError> {
+ pub fn new(allocation: &CpuAllocation, pin_cores: bool) -> Result<Self,
ShardingError> {
let topology = if matches!(allocation, CpuAllocation::NumaAware(_)) {
let numa_topology = NumaTopology::detect()?;
@@ -353,7 +353,7 @@ impl ShardAllocator {
// quota-restricted process gets proportionally fewer shards.
let available_cpus = available_parallelism()
.map_err(|err| ShardingError::Other {
- msg: format!("Failed to get available_parallelism:
{:?}", err),
+ msg: format!("Failed to get available_parallelism:
{err:?}"),
})?
.get();
@@ -431,7 +431,7 @@ impl ShardAllocator {
}
CpuAllocation::NumaAware(numa_config) => {
let topology =
self.topology.as_ref().ok_or(ShardingError::NoTopology)?;
- let assignments = self.compute_numa_assignments(topology,
numa_config)?;
+ let assignments = Self::compute_numa_assignments(topology,
numa_config)?;
if !self.pin_cores {
tracing::warn!(
@@ -446,7 +446,6 @@ impl ShardAllocator {
}
fn compute_numa_assignments(
- &self,
topology: &NumaTopology,
numa: &NumaConfig,
) -> Result<Vec<ShardInfo>, ShardingError> {
diff --git a/core/server/src/shell.rs b/core/server/src/shell.rs
new file mode 100644
index 000000000..bc05b40a5
--- /dev/null
+++ b/core/server/src/shell.rs
@@ -0,0 +1,202 @@
+// 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.
+
+//! The shell vocabulary.
+//!
+//! The shard/metadata type aliases the dispatch layer is generic over, the
+//! [`ShellBus`] bound, the [`ShellHandlers`] slot struct, and the
+//! `[cluster]` timer-to-tick translation every consensus group boots with.
+//! Everything here is type- and config-level; construction (wiring the
+//! handlers against a live bus) stays in [`crate::bootstrap`].
+
+use crate::session_manager::SessionManager;
+use configs::server::ServerConfig;
+use consensus::{ConsensusTimers, VsrConsensus};
+use iggy_common::variadic;
+use journal::prepare_journal::PrepareJournal;
+use journal::superblock::PingPongSuperblock;
+use message_bus::client_listener::RequestHandler;
+use message_bus::replica::listener::MessageHandler;
+use message_bus::{ConnectionInstaller, IggyMessageBus, MessageBus};
+use metadata::IggyMetadata;
+use metadata::MuxStateMachine;
+use metadata::impls::metadata::IggySnapshot;
+use metadata::stm::mux::WithFactory;
+use metadata::stm::stream::Streams;
+use metadata::stm::user::Users;
+use shard::shards_table::PapayaShardsTable;
+use shard::{IggyShard, ListClientsHandler, MetadataSubmitHandler,
PartitionReadHandler};
+use std::cell::RefCell;
+use std::rc::{Rc, Weak};
+use std::time::Duration;
+
+pub(crate) type ServerMuxStateMachine = MuxStateMachine<variadic!(Users,
Streams)>;
+
+/// Cross-thread bundle carrying one `ReadHandleFactory` per metadata
+/// state. Shard 0 mints one after `recover()` and broadcasts a clone to
+/// every peer shard; each peer rebuilds a reader-mode
+/// [`ServerMuxStateMachine`] on its own runtime, skipping the WAL.
+pub(crate) type ServerMetadataBundle = <variadic!(Users, Streams) as
WithFactory>::Bundle;
+
+pub(crate) type ServerMetadata = IggyMetadata<
+ VsrConsensus<Rc<IggyMessageBus>>,
+ PrepareJournal,
+ IggySnapshot,
+ ServerMuxStateMachine,
+>;
+
+/// The shard type the dispatch layer is generic over.
+///
+/// `B`/`MJ`/`S`/`SB` are free; the metadata state machine (`M`) and shards
+/// table (`T`) are pinned, being identical in production and the simulator.
+/// Production instantiates it as [`ServerShard`], defaulting `SB` to the
+/// on-disk [`PingPongSuperblock`]; the simulator supplies its own
+/// `B`/`MJ`/`S`/`SB`.
+pub type ShellShard<B, MJ, S, SB = PingPongSuperblock> =
+ IggyShard<B, MJ, S, ServerMuxStateMachine, PapayaShardsTable, SB>;
+
+/// Late-bound self-reference the deferred dispatch handlers upgrade per frame.
+pub type ShellShardHandle<B, MJ, S, SB = PingPongSuperblock> =
+ Rc<RefCell<Option<Weak<ShellShard<B, MJ, S, SB>>>>>;
+
+/// Bus bounds the dispatch/pump path needs (matches `run_message_pump`).
+/// Blanket-impl'd, so it is only shorthand for the four underlying bounds.
+pub trait ShellBus: MessageBus + ConnectionInstaller + Clone + 'static {}
+impl<B: MessageBus + ConnectionInstaller + Clone + 'static> ShellBus for B {}
+
+/// The five dispatch handlers a shard is built with, plus the
+/// [`SessionManager`] the request-plane pair shares.
+///
+/// Both production (`build_shard_for_thread`) and the simulator's shell
+/// mode construct these through [`crate::bootstrap::wire_shell_handlers`],
+/// so the request plane is wired one way. The simulator's shell-off fast
+/// path uses [`ShellHandlers::noop`] instead.
+pub struct ShellHandlers {
+ pub on_replica_message: MessageHandler,
+ pub on_client_request: RequestHandler,
+ pub on_metadata_submit: MetadataSubmitHandler,
+ pub on_list_clients: ListClientsHandler,
+ pub on_partition_read: PartitionReadHandler,
+ /// Bound by the client-request handler, read by the get-clients
+ /// handler; the caller keeps it to reach locally-homed sessions.
+ pub sessions: Rc<RefCell<SessionManager>>,
+}
+
+impl ShellHandlers {
+ /// Inert handlers for the shell-off fast path: every callback is a
+ /// no-op over an empty [`SessionManager`]. Behaviorally identical to
+ /// hand-written no-op closures, so a caller can keep one destructure
+ /// site across both toggle states.
+ #[must_use]
+ pub fn noop() -> Self {
+ Self {
+ on_replica_message: Rc::new(|_, _| {}),
+ on_client_request: Rc::new(|_, _| {}),
+ on_metadata_submit: Rc::new(|_| {}),
+ on_list_clients: Rc::new(|_| {}),
+ on_partition_read: Rc::new(|_, _, _| {}),
+ sessions: Rc::new(RefCell::new(SessionManager::new())),
+ }
+ }
+}
+
+pub type ServerShard = ShellShard<Rc<IggyMessageBus>, PrepareJournal,
IggySnapshot>;
+
+/// Convert a consensus-timer interval to whole ticks, floored at one tick so a
+/// sub-tick value still fires and saturated on overflow.
+fn duration_to_ticks(interval: Duration) -> u64 {
+ let ticks = interval.as_millis() /
shard::CONSENSUS_TICK_INTERVAL.as_millis();
+ u64::try_from(ticks.max(1)).unwrap_or(u64::MAX)
+}
+
+/// `[cluster] heartbeat_timeout` in consensus ticks. Every consensus group
+/// (metadata and per-partition planes alike) gets the same window: the failure
+/// it guards against - a primary that stopped heartbeating - is host-level,
not
+/// per-plane.
+pub(crate) fn cluster_heartbeat_ticks(config: &ServerConfig) -> u64 {
+ duration_to_ticks(config.cluster.heartbeat_timeout.get_duration())
+}
+
+/// `[cluster] commit_broadcast_interval` in consensus ticks: how often the
+/// primary broadcasts its commit point, the cluster's liveness feed. Applied
+/// to every consensus group, matching `cluster_heartbeat_ticks`.
+pub(crate) fn commit_broadcast_ticks(config: &ServerConfig) -> u64 {
+ duration_to_ticks(config.cluster.commit_broadcast_interval.get_duration())
+}
+
+/// `[cluster] prepare_retransmit_interval` in consensus ticks: how often the
+/// primary retransmits un-acked prepares. Applied to every consensus group,
+/// matching `cluster_heartbeat_ticks`.
+pub(crate) fn prepare_retransmit_ticks(config: &ServerConfig) -> u64 {
+
duration_to_ticks(config.cluster.prepare_retransmit_interval.get_duration())
+}
+
+/// `[cluster] view_change_retransmit_interval` in consensus ticks: how often a
+/// replica retransmits its `StartViewChange` / `DoViewChange` during a view
+/// change. Applied to every consensus group, matching
`cluster_heartbeat_ticks`.
+pub(crate) fn view_change_retransmit_ticks(config: &ServerConfig) -> u64 {
+ duration_to_ticks(
+ config
+ .cluster
+ .view_change_retransmit_interval
+ .get_duration(),
+ )
+}
+
+/// `[cluster] view_change_status_timeout` in consensus ticks: the stalled
+/// view-change backstop before escalating to a fresh election. Applied to
every
+/// consensus group, matching `cluster_heartbeat_ticks`.
+pub(crate) fn view_change_status_ticks(config: &ServerConfig) -> u64 {
+ duration_to_ticks(config.cluster.view_change_status_timeout.get_duration())
+}
+
+/// `[cluster] request_start_view_retransmit_interval` in consensus ticks: how
+/// often a recovering or view-change backup re-requests the current
`StartView`.
+/// Applied to every consensus group, matching `cluster_heartbeat_ticks`.
+pub(crate) fn request_start_view_ticks(config: &ServerConfig) -> u64 {
+ duration_to_ticks(
+ config
+ .cluster
+ .request_start_view_retransmit_interval
+ .get_duration(),
+ )
+}
+
+/// The full `[cluster]` timer set every consensus group boots with, built
+/// once so the planes cannot diverge in what they apply.
+pub(crate) fn consensus_timers(config: &ServerConfig) -> ConsensusTimers {
+ ConsensusTimers {
+ normal_heartbeat_ticks: cluster_heartbeat_ticks(config),
+ commit_message_ticks: commit_broadcast_ticks(config),
+ prepare_ticks: prepare_retransmit_ticks(config),
+ view_change_retransmit_ticks: view_change_retransmit_ticks(config),
+ view_change_status_ticks: view_change_status_ticks(config),
+ request_start_view_ticks: request_start_view_ticks(config),
+ probe_attempts_max: config.cluster.view_probe_attempts_max,
+ }
+}
+
+/// `[cluster] repair_retry_interval` in consensus ticks: how long a stalled
+/// journal-repair stream waits before re-requesting its window. Both planes'
+/// repair loops share it, so it is applied once per shard (not per consensus
+/// group). Clamped to `u32`, the width of the session idle-tick counter.
+pub(crate) fn repair_retry_ticks(config: &ServerConfig) -> u32 {
+ u32::try_from(duration_to_ticks(
+ config.cluster.repair_retry_interval.get_duration(),
+ ))
+ .unwrap_or(u32::MAX)
+}
diff --git a/core/server/src/users.rs b/core/server/src/users.rs
index 129bbfca8..3b146cb88 100644
--- a/core/server/src/users.rs
+++ b/core/server/src/users.rs
@@ -35,7 +35,7 @@
//! the caller's request sequence stays contiguous; see
//! `verify_and_rewrite_change_password`.
-use crate::bootstrap::{ShellBus, ShellShard};
+use crate::shell::{ShellBus, ShellShard};
use crate::wire::{request_body, rewrite_request_body};
use bytes::Bytes;
use consensus::MetadataHandle;
@@ -58,7 +58,7 @@ use std::rc::Rc;
/// rejection (see [`verify_and_rewrite_change_password`]). Every other
operation
/// passes through unchanged. Returns [`IggyError::InvalidCommand`] only on an
/// undecodable password body.
-pub(crate) fn maybe_rewrite_user_password_request<B, MJ, S, SB>(
+pub fn maybe_rewrite_user_password_request<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
request: Message<RoutedRequestHeader>,
) -> Result<Message<RoutedRequestHeader>, IggyError>
diff --git a/core/server/src/wire.rs b/core/server/src/wire.rs
index d869a2155..737d72440 100644
--- a/core/server/src/wire.rs
+++ b/core/server/src/wire.rs
@@ -26,7 +26,7 @@ use iggy_common::IggyError;
use message_bus::installer::conn_info::ClientTransportKind;
use server_common::Message;
-pub(crate) fn request_body(request: &Message<RoutedRequestHeader>) -> &[u8] {
+pub fn request_body(request: &Message<RoutedRequestHeader>) -> &[u8] {
&request.as_slice()[std::mem::size_of::<RoutedRequestHeader>()..request.header().size
as usize]
}
@@ -38,9 +38,7 @@ pub(crate) fn request_body(request:
&Message<RoutedRequestHeader>) -> &[u8] {
///
/// # Errors
/// [`IggyError::InvalidFormat`] when the stamp disagrees with the body.
-pub(crate) fn verify_request_checksum(
- request: &Message<RoutedRequestHeader>,
-) -> Result<(), IggyError> {
+pub fn verify_request_checksum(request: &Message<RoutedRequestHeader>) ->
Result<(), IggyError> {
let stamped = request.header().request_checksum;
if stamped == 0 ||
u128::from(iggy_common::calculate_checksum(request_body(request))) == stamped
{
@@ -53,7 +51,7 @@ pub(crate) fn verify_request_checksum(
/// (`1=TCP, 2=QUIC, 4=WebSocket`); TLS variants report their base
/// transport. `ClientTransportKind` is `#[non_exhaustive]`, so any other
/// (TCP, TCP-TLS, or a future) variant falls back to TCP.
-pub(crate) const fn transport_kind_to_wire(kind: ClientTransportKind) -> u8 {
+pub const fn transport_kind_to_wire(kind: ClientTransportKind) -> u8 {
match kind {
ClientTransportKind::Quic => 2,
ClientTransportKind::Ws | ClientTransportKind::Wss => 4,
@@ -61,7 +59,7 @@ pub(crate) const fn transport_kind_to_wire(kind:
ClientTransportKind) -> u8 {
}
}
-pub(crate) fn usize_to_u32(value: usize) -> Result<u32, IggyError> {
+pub fn usize_to_u32(value: usize) -> Result<u32, IggyError> {
u32::try_from(value).map_err(|_| IggyError::InvalidIdentifier)
}
@@ -69,7 +67,7 @@ pub(crate) fn usize_to_u32(value: usize) -> Result<u32,
IggyError> {
/// preserving the header (and fixing `size`). Used by the primary-side
/// request rewrites that swap a secret-bearing wire body for the
/// hash-carrying replicated body before consensus.
-pub(crate) fn rewrite_request_body(
+pub fn rewrite_request_body(
request: &Message<RoutedRequestHeader>,
body: &Bytes,
) -> Result<Message<RoutedRequestHeader>, IggyError> {
diff --git a/core/server/tests/module_graph.rs
b/core/server/tests/module_graph.rs
new file mode 100644
index 000000000..d9d978a56
--- /dev/null
+++ b/core/server/tests/module_graph.rs
@@ -0,0 +1,319 @@
+// 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.
+
+//! Module-graph guard: the crate's module dependency graph must stay a DAG.
+//!
+//! The http/ role-leaf pattern rotted into two state-hub cycles while nothing
+//! enforced it, and two of the historical bootstrap cycles were invisible to
+//! `use`-greps (an inline-qualified `crate::...` call and `use super::`
+//! imports). So this test parses every source file with `syn` and scans full
+//! token streams - it sees qualified call sites and macro arguments, not just
+//! use declarations.
+//!
+//! Granularity is the module FILE. Parent<->child edges are exempt: a root
+//! composing its children (and children reaching items the root defines) is
+//! the pattern working as intended. Sibling and cross-tree cycles are the
+//! rot this guard exists to stop.
+//!
+//! `WHITELIST` carries the known survivors. Each entry must still be a live
+//! cycle - a stale entry fails the test, so the list can only shrink.
+
+use proc_macro2::{TokenStream, TokenTree};
+use quote::ToTokens;
+use std::collections::{BTreeMap, BTreeSet};
+use std::path::{Path, PathBuf};
+
+/// Known mutual edges, as unordered pairs of module paths. Burned down per
+/// refactor PR; the auth<->dispatch cycle dies with the dispatch/ login merge.
+const WHITELIST: [(&str, &str); 1] = [("auth", "dispatch")];
+
+type Module = Vec<String>;
+
+#[test]
+fn module_graph_is_a_dag_modulo_whitelist() {
+ let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
+ let modules = collect_modules(&src);
+ let module_set: BTreeSet<Module> = modules.iter().map(|(module, _)|
module.clone()).collect();
+
+ let mut edges: BTreeMap<Module, BTreeSet<Module>> = BTreeMap::new();
+ for (module, file) in &modules {
+ let source = std::fs::read_to_string(file)
+ .unwrap_or_else(|error| panic!("cannot read {}: {error}",
file.display()));
+ let ast = syn::parse_file(&source)
+ .unwrap_or_else(|error| panic!("cannot parse {}: {error}",
file.display()));
+ let mut paths = Vec::new();
+ scan_items(&ast.items, &mut paths);
+ let targets = edges.entry(module.clone()).or_default();
+ for raw in paths {
+ if let Some(target) = resolve(module, &raw, &module_set)
+ && target != *module
+ && !is_ancestor(&target, module)
+ && !is_ancestor(module, &target)
+ {
+ targets.insert(target);
+ }
+ }
+ }
+
+ let whitelist: BTreeSet<(Module, Module)> = WHITELIST
+ .iter()
+ .flat_map(|(a, b)| {
+ let a = parse_module(a);
+ let b = parse_module(b);
+ [(a.clone(), b.clone()), (b, a)]
+ })
+ .collect();
+
+ for (from, to) in &whitelist {
+ assert!(
+ edges.get(from).is_some_and(|targets| targets.contains(to)),
+ "stale whitelist entry {} -> {}: the edge is gone, remove it",
+ render(from),
+ render(to),
+ );
+ }
+
+ for (from, targets) in &mut edges {
+ targets.retain(|to| !whitelist.contains(&(from.clone(), to.clone())));
+ }
+
+ if let Some(cycle) = find_cycle(&edges) {
+ let chain = cycle.iter().map(render).collect::<Vec<_>>();
+ panic!(
+ "module cycle: {}\nBreak it by moving the shared vocabulary into \
+ a leaf both sides import, or point the edge one way.",
+ chain.join(" -> "),
+ );
+ }
+}
+
+/// Map every source file under `src/` to its module path. `lib.rs` is the
+/// crate root `[]`; `main.rs`/`args.rs` belong to the bin target and are
+/// skipped (their `crate::` is a different crate).
+fn collect_modules(src: &Path) -> Vec<(Module, PathBuf)> {
+ let mut files = Vec::new();
+ walk(src, &mut files);
+ files.sort();
+ files
+ .into_iter()
+ .filter_map(|file| {
+ let relative = file
+ .strip_prefix(src)
+ .unwrap_or_else(|_| panic!("{} outside src", file.display()));
+ let mut module: Vec<String> = relative
+ .components()
+ .map(|c| c.as_os_str().to_string_lossy().into_owned())
+ .collect();
+ let last = module.pop().unwrap_or_default();
+ match last.as_str() {
+ "main.rs" | "args.rs" => return None,
+ "lib.rs" | "mod.rs" => {}
+ _ => module.push(last.trim_end_matches(".rs").to_owned()),
+ }
+ Some((module, file))
+ })
+ .collect()
+}
+
+fn walk(dir: &Path, files: &mut Vec<PathBuf>) {
+ let entries = std::fs::read_dir(dir)
+ .unwrap_or_else(|error| panic!("cannot read dir {}: {error}",
dir.display()));
+ for entry in entries {
+ let path = entry
+ .unwrap_or_else(|error| panic!("cannot read entry in {}: {error}",
dir.display()))
+ .path();
+ if path.is_dir() {
+ walk(&path, files);
+ } else if path.extension().is_some_and(|ext| ext == "rs") {
+ files.push(path);
+ }
+ }
+}
+
+/// Collect every `crate::`/`super::`-rooted path in the token streams of
+/// `items`, recursing through nested modules and skipping `#[cfg(test)]` ones.
+fn scan_items(items: &[syn::Item], paths: &mut Vec<Vec<String>>) {
+ for item in items {
+ if let syn::Item::Mod(module) = item {
+ if is_cfg_test(&module.attrs) {
+ continue;
+ }
+ if let Some((_, nested)) = &module.content {
+ scan_items(nested, paths);
+ }
+ continue;
+ }
+ scan_tokens(item.to_token_stream(), paths);
+ }
+}
+
+fn is_cfg_test(attrs: &[syn::Attribute]) -> bool {
+ attrs.iter().any(|attr| {
+ attr.path().is_ident("cfg")
+ && attr
+ .parse_args::<TokenStream>()
+ .is_ok_and(tokens_mention_test)
+ })
+}
+
+/// True when the cfg predicate names the bare `test` ident anywhere,
+/// `cfg(all(test, ...))` included.
+fn tokens_mention_test(tokens: TokenStream) -> bool {
+ tokens.into_iter().any(|tree| match tree {
+ TokenTree::Ident(ident) => ident == "test",
+ TokenTree::Group(group) => tokens_mention_test(group.stream()),
+ _ => false,
+ })
+}
+
+/// Token-level scan: catches inline-qualified calls and macro arguments that
+/// an AST `use`-only walk would miss. Doc comments and string literals are
+/// literals, not idents, so they never produce edges.
+fn scan_tokens(tokens: TokenStream, paths: &mut Vec<Vec<String>>) {
+ let trees: Vec<TokenTree> = tokens.into_iter().collect();
+ let mut index = 0;
+ while index < trees.len() {
+ match &trees[index] {
+ TokenTree::Group(group) => {
+ scan_tokens(group.stream(), paths);
+ index += 1;
+ }
+ TokenTree::Ident(ident) => {
+ let name = ident.to_string();
+ if name == "crate" || name == "super" {
+ let (segments, consumed) = read_path(&trees, index);
+ if segments.len() > 1 {
+ paths.push(segments);
+ }
+ index += consumed;
+ } else {
+ index += 1;
+ }
+ }
+ _ => index += 1,
+ }
+ }
+}
+
+/// Read `ident (:: ident)*` starting at `start`, descending into no groups.
+fn read_path(trees: &[TokenTree], start: usize) -> (Vec<String>, usize) {
+ let mut segments = Vec::new();
+ let mut index = start;
+ while let Some(TokenTree::Ident(ident)) = trees.get(index) {
+ segments.push(ident.to_string());
+ index += 1;
+ let double_colon = matches!(trees.get(index),
Some(TokenTree::Punct(p)) if p.as_char() == ':')
+ && matches!(trees.get(index + 1), Some(TokenTree::Punct(p)) if
p.as_char() == ':');
+ if double_colon {
+ index += 2;
+ } else {
+ break;
+ }
+ }
+ (segments, index - start)
+}
+
+/// Resolve a raw `crate::`/`super::` path to the deepest known module it
+/// names. Returns `None` for paths into the crate root's own items.
+fn resolve(current: &Module, raw: &[String], modules: &BTreeSet<Module>) ->
Option<Module> {
+ let (base, rest): (Module, &[String]) = match
raw.first().map(String::as_str) {
+ Some("crate") => (Vec::new(), &raw[1..]),
+ Some("super") => {
+ let supers = raw.iter().take_while(|s| *s == "super").count();
+ if supers > current.len() {
+ return None;
+ }
+ (current[..current.len() - supers].to_vec(), &raw[supers..])
+ }
+ _ => return None,
+ };
+ let mut best: Option<Module> = if base.is_empty() {
+ None
+ } else {
+ Some(base.clone())
+ };
+ let mut candidate = base;
+ for segment in rest {
+ candidate.push(segment.clone());
+ if modules.contains(&candidate) {
+ best = Some(candidate.clone());
+ } else {
+ break;
+ }
+ }
+ best
+}
+
+fn is_ancestor(shorter: &Module, longer: &Module) -> bool {
+ shorter.len() < longer.len() && longer[..shorter.len()] == shorter[..]
+}
+
+fn render(module: &Module) -> String {
+ if module.is_empty() {
+ "crate".to_owned()
+ } else {
+ module.join("::")
+ }
+}
+
+fn parse_module(path: &str) -> Module {
+ path.split("::").map(str::to_owned).collect()
+}
+
+/// DFS three-color cycle search; returns one cycle as a module chain.
+fn find_cycle(edges: &BTreeMap<Module, BTreeSet<Module>>) ->
Option<Vec<Module>> {
+ let mut visiting = BTreeSet::new();
+ let mut done = BTreeSet::new();
+ let mut stack = Vec::new();
+ for start in edges.keys() {
+ if let Some(cycle) = dfs(start, edges, &mut visiting, &mut done, &mut
stack) {
+ return Some(cycle);
+ }
+ }
+ None
+}
+
+fn dfs(
+ node: &Module,
+ edges: &BTreeMap<Module, BTreeSet<Module>>,
+ visiting: &mut BTreeSet<Module>,
+ done: &mut BTreeSet<Module>,
+ stack: &mut Vec<Module>,
+) -> Option<Vec<Module>> {
+ if done.contains(node) {
+ return None;
+ }
+ if visiting.contains(node) {
+ let from = stack.iter().position(|n| n == node).unwrap_or(0);
+ let mut cycle = stack[from..].to_vec();
+ cycle.push(node.clone());
+ return Some(cycle);
+ }
+ visiting.insert(node.clone());
+ stack.push(node.clone());
+ if let Some(targets) = edges.get(node) {
+ for target in targets {
+ if let Some(cycle) = dfs(target, edges, visiting, done, stack) {
+ return Some(cycle);
+ }
+ }
+ }
+ stack.pop();
+ visiting.remove(node);
+ done.insert(node.clone());
+ None
+}
diff --git a/core/shard_allocator/Cargo.toml b/core/shard_allocator/Cargo.toml
deleted file mode 100644
index 1d51d3a8d..000000000
--- a/core/shard_allocator/Cargo.toml
+++ /dev/null
@@ -1,41 +0,0 @@
-# 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.
-
-[package]
-name = "shard_allocator"
-version = "0.1.0"
-description = "CPU and NUMA shard allocation for the iggy server, backed by
hwloc."
-edition = "2024"
-license = "Apache-2.0"
-publish = false
-
-[dependencies]
-cpu_allocation = { workspace = true }
-thiserror = { workspace = true }
-tracing = { workspace = true }
-
-[target.'cfg(not(target_env = "musl"))'.dependencies]
-hwlocality = { workspace = true }
-
-[target.'cfg(target_env = "musl")'.dependencies]
-hwlocality = { workspace = true, features = ["vendored"] }
-
-[target.'cfg(target_os = "linux")'.dependencies]
-nix = { workspace = true }
-
-[lints]
-workspace = true
diff --git a/core/shard_allocator/build.rs b/core/shard_allocator/build.rs
deleted file mode 100644
index d66e93f07..000000000
--- a/core/shard_allocator/build.rs
+++ /dev/null
@@ -1,43 +0,0 @@
-// 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.
-
-// Vendored `hwloc` references `cbrt`, which makes the linker pull in a
-// `libm`. musl folds the math functions into its `libc`, so the Rust
-// musl sysroot ships no `libm.a`. Without one, `-lm` falls through to
-// the host glibc's `libm.a`, whose `cbrt` needs glibc-internal
-// `__frexp`/`__ldexp` symbols that do not exist on musl, and the static
-// link fails. Drop an empty `libm.a` stub on the search path so `-lm`
-// resolves to nothing and `cbrt` is satisfied later by musl's own
-// `libc`. No effect on non-musl targets.
-
-use std::env;
-use std::fs;
-use std::path::Path;
-
-fn main() {
- if env::var("CARGO_CFG_TARGET_ENV").as_deref() != Ok("musl") {
- return;
- }
-
- let out_dir = env::var("OUT_DIR").expect("OUT_DIR is set by cargo for
build scripts");
- let stub = Path::new(&out_dir).join("libm.a");
-
- // `!<arch>\n` is the canonical header of an empty `ar` archive.
- fs::write(&stub, b"!<arch>\n").expect("write empty libm.a stub");
-
- println!("cargo:rustc-link-search=native={out_dir}");
-}
diff --git a/core/simulator/src/replica.rs b/core/simulator/src/replica.rs
index 638a61dce..8e57773ea 100644
--- a/core/simulator/src/replica.rs
+++ b/core/simulator/src/replica.rs
@@ -32,7 +32,8 @@ use metadata::stm::stream::{Streams, StreamsInner};
use metadata::stm::user::{Users, UsersInner};
use metadata::{IggyMetadata, apply_committed_prepare};
use partitions::{IggyPartitions, PartitionPathLayout, PartitionsConfig};
-use server::bootstrap::{ShellHandlers, ShellShardHandle, wire_shell_handlers};
+use server::bootstrap::wire_shell_handlers;
+use server::shell::{ShellHandlers, ShellShardHandle};
use server_common::crypto;
use server_common::sharding::{METADATA_GROUP, ShardId};
use shard::shards_table::PapayaShardsTable;