This is an automated email from the ASF dual-hosted git repository.

numinnex pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new db14a4b17 fix(cluster): make the advertised leader accept partition 
writes (#3985)
db14a4b17 is described below

commit db14a4b1718f16a0cc0f74088e0d028e80321306
Author: Grzegorz Koszyk <[email protected]>
AuthorDate: Fri Aug 28 16:53:09 2026 +0200

    fix(cluster): make the advertised leader accept partition writes (#3985)
    
    A client is told which node leads the cluster, sends a partition
    write there, and is refused for its whole budget. The node it was
    told about is genuinely the leader; the write is genuinely unroutable.
    
    Each node runs a metadata consensus group plus one per partition,
    each with its own view counter, and both pick a primary as
    `view % replica_count`. `get_cluster_metadata` marks a node Leader
    from the metadata plane alone and `ClusterNode` carries one `role`,
    so a partition group's primary cannot be expressed on the wire.
    Partition ops route within a node, never to a peer, so the
    advertised leader cannot forward one either.
    
    A group materialised with no durable state started at view 0,
    naming replica 0 wherever the metadata plane had got to, so any
    metadata election opened the gap for every topic created after.
    
    Seed a fresh group from the metadata view shard 0 already publishes
    for the roster's leader marking. Both planes then name the same
    node by construction, with no wire or client change. The seed sets
    `log_view` too: a primary whose `log_view` lags counts as
    mid-transition and answers no `RequestStartView`. A replica that
    has not seen a view defers materialising rather than reading the
    unknown sentinel as 0, and that deferral counts toward the pass so
    it cannot arm the reconciler's fast-skip.
    
    This aligns the planes once, at creation. A later election on
    either side parts them again and nothing forwards a write across
    the gap, so the symptom returns for that group.
---
 core/consensus/src/impls.rs                        | 116 +++++++++++-
 core/harness_derive/src/attrs.rs                   |  12 ++
 core/harness_derive/src/codegen.rs                 |   6 +
 core/integration/src/harness/disk.rs               |  19 +-
 .../src/harness/orchestrator/harness.rs            | 145 ++++++++++++---
 core/integration/tests/cluster/mod.rs              |   2 +
 .../tests/cluster/partition_primary_routing.rs     | 198 +++++++++++++++++++++
 .../tests/cluster/staggered_bootstrap.rs           | 180 +++++++++++++++++++
 core/server/src/bootstrap.rs                       |   9 +
 core/server/src/cluster_meta.rs                    |   8 +-
 core/server/src/partition_helpers.rs               |  45 +++--
 core/server/src/partition_reconciler.rs            | 166 ++++++++++++++++-
 core/server/src/responses.rs                       |   2 +-
 core/shard/src/lib.rs                              |  53 ++++--
 core/simulator/src/lib.rs                          | 136 +++++++++++++-
 15 files changed, 1031 insertions(+), 66 deletions(-)

diff --git a/core/consensus/src/impls.rs b/core/consensus/src/impls.rs
index 2e4741dab..28865344f 100644
--- a/core/consensus/src/impls.rs
+++ b/core/consensus/src/impls.rs
@@ -1127,7 +1127,7 @@ pub struct ConsensusTimers {
 }
 
 /// How a restored replica joins its group.
-#[derive(Debug, Clone, Copy)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum JoinMode {
     /// Fresh group or solo replica: plain init; the group needs its view-0
     /// primary to exist.
@@ -1151,11 +1151,72 @@ pub struct VsrRestore<'a> {
     /// View inferred from the last journaled prepare, consulted only when no
     /// durable record exists; `log_view` cannot be inferred and stays 0.
     pub view_fallback: Option<u32>,
+    /// Starting `(view, log_view)` for a group with NO history of its own,
+    /// consulted only when neither of the two above applies.
+    ///
+    /// Sets `log_view` as well as `view`, unlike `view_fallback`: the log is
+    /// empty, so there is no history to misattribute to the view, and a
+    /// primary whose `log_view` lags its `view` is treated as mid-transition
+    /// and answers no `RequestStartView` probe (see
+    /// [`VsrConsensus::handle_request_start_view`]) - it would hold its own 
group's
+    /// probes open forever.
+    ///
+    /// Deliberately NOT marked superblock-durable: nothing has been written
+    /// yet, and claiming otherwise would let a restart resume a view no record
+    /// holds. The group persists on its first tick instead.
+    pub seed_view: Option<u32>,
     /// Non-zero boot incarnation; `None` keeps the default.
     pub incarnation: Option<u128>,
     pub join: JoinMode,
 }
 
+/// How a group with no consensus state in memory should come up.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct FreshGroupStart {
+    pub join: JoinMode,
+    /// Starting view for a group with no durable record; see
+    /// [`VsrRestore::seed_view`]. Always `None` when a durable record exists,
+    /// so a caller can apply the two in either order.
+    pub seed_view: Option<u32>,
+}
+
+/// Decide how a group being materialised should join, and what view it starts
+/// in when it has no record of its own.
+///
+/// One function because there are two materialisation paths and they must not
+/// drift: `build_partition_fresh` on the server, and the simulator's
+/// `init_partition`, which cannot call it (that builder does real filesystem
+/// work, and the simulator runs on in-memory storage). Both previously decided
+/// this for themselves, which is how the simulator came to exercise neither
+/// the seed nor the split it closes.
+///
+/// `restarted` is the caller's own evidence of a prior life, since the two
+/// paths read it differently: a partition directory already on disk for the
+/// server, a retained in-memory log for the simulator.
+#[must_use]
+pub const fn fresh_group_start(
+    restarted: bool,
+    durable_view: Option<(u32, u32)>,
+    metadata_view: Option<u32>,
+) -> FreshGroupStart {
+    let join = if restarted {
+        JoinMode::ProbeAsBackup {
+            await_state_transfer: false,
+        }
+    } else {
+        JoinMode::Init
+    };
+    // A durable record outranks the seed, and a probing backup takes neither:
+    // it must sit at or below the group's real view for the primary's
+    // `StartView` to move it forward, and seeded above that the reply reads as
+    // stale and is dropped.
+    let seed_view = match (durable_view, join) {
+        (None, JoinMode::Init) => metadata_view,
+        _ => None,
+    };
+    FreshGroupStart { join, seed_view }
+}
+
 impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> VsrConsensus<B, P> {
     /// # Panics
     /// - If `replica >= replica_count`.
@@ -1230,6 +1291,14 @@ impl<B: MessageBus, P: Pipeline<Entry = PipelineEntry>> 
VsrConsensus<B, P> {
             consensus.mark_superblock_durable(view, log_view);
         } else if let Some(view) = restore.view_fallback {
             consensus.set_view(view);
+        } else if let Some(view) = restore.seed_view {
+            tracing::info!(
+                group,
+                view,
+                "seeded a group with no history of its own into the current 
view"
+            );
+            consensus.set_view(view);
+            consensus.set_log_view(view);
         }
         match restore.join {
             JoinMode::Init => consensus.init(),
@@ -3988,6 +4057,51 @@ where
     }
 }
 
+#[cfg(test)]
+mod fresh_group_start_tests {
+    use super::{JoinMode, fresh_group_start};
+
+    const METADATA_VIEW: u32 = 4;
+
+    /// The case the seed exists for: a group created after the metadata plane
+    /// elected must not start at view 0, or it names a replica the roster does
+    /// not advertise and no client can be routed to it.
+    #[test]
+    fn 
given_a_fresh_group_when_the_metadata_view_moved_should_seed_that_view() {
+        let start = fresh_group_start(false, None, Some(METADATA_VIEW));
+        assert_eq!(start.join, JoinMode::Init);
+        assert_eq!(start.seed_view, Some(METADATA_VIEW));
+    }
+
+    /// No published view is "no opinion", not "view 0". The caller defers
+    /// materialising rather than seeding; this only asserts nothing is
+    /// invented here.
+    #[test]
+    fn given_a_fresh_group_when_no_metadata_view_is_known_should_not_seed() {
+        let start = fresh_group_start(false, None, None);
+        assert_eq!(start.join, JoinMode::Init);
+        assert_eq!(start.seed_view, None);
+    }
+
+    /// A durable record outranks the seed: it is what this replica actually
+    /// promised, and the seed is a guess about someone else's plane.
+    #[test]
+    fn given_a_durable_record_when_seeding_should_prefer_the_record() {
+        let start = fresh_group_start(false, Some((7, 7)), 
Some(METADATA_VIEW));
+        assert_eq!(start.seed_view, None);
+    }
+
+    /// A probing backup must sit at or below the group's real view for the
+    /// primary's `StartView` to move it forward. Seeded above it, the reply
+    /// reads as stale and the replica never rejoins.
+    #[test]
+    fn given_a_prior_life_when_seeding_should_probe_without_a_seed() {
+        let start = fresh_group_start(true, None, Some(METADATA_VIEW));
+        assert!(matches!(start.join, JoinMode::ProbeAsBackup { .. }));
+        assert_eq!(start.seed_view, None);
+    }
+}
+
 #[cfg(test)]
 mod request_queue_tests {
     use super::*;
diff --git a/core/harness_derive/src/attrs.rs b/core/harness_derive/src/attrs.rs
index 9d1f07cbd..136b37901 100644
--- a/core/harness_derive/src/attrs.rs
+++ b/core/harness_derive/src/attrs.rs
@@ -57,6 +57,12 @@ pub struct IggyTestAttrs {
     pub seed_fn: Option<syn::Path>,
     pub cluster_nodes: ClusterNodesValue,
     pub jwks_server: Option<JwksAttrs>,
+    /// Skip the generated `start()` call so the test brings nodes up itself.
+    /// For boot-ordering tests, which need a cluster that begins serving
+    /// before every configured node has arrived; `start` waits for all of
+    /// them. Pairs with `TestHarness::start_nodes` / `start_node`. A `seed`
+    /// cannot run under it, since no node is up when it would fire.
+    pub manual_start: bool,
 }
 
 /// JWKS server attributes.
@@ -90,6 +96,7 @@ impl IggyTestAttrs {
             seed_fn: None,
             cluster_nodes: ClusterNodesValue::None,
             jwks_server: None,
+            manual_start: false,
         }
     }
 }
@@ -265,6 +272,9 @@ impl Parse for IggyTestAttrs {
                 AttrItem::JwksServer(jwks) => {
                     attrs.jwks_server = Some(jwks);
                 }
+                AttrItem::ManualStart => {
+                    attrs.manual_start = true;
+                }
             }
         }
 
@@ -282,6 +292,7 @@ enum AttrItem {
     Seed(syn::Path),
     ClusterNodes(ClusterNodesValue),
     JwksServer(JwksAttrs),
+    ManualStart,
 }
 
 impl Parse for AttrItem {
@@ -317,6 +328,7 @@ impl Parse for AttrItem {
                 let jwks = parse_jwks_attrs(&content)?;
                 Ok(AttrItem::JwksServer(jwks))
             }
+            "manual_start" => Ok(AttrItem::ManualStart),
             _ => Err(syn::Error::new(
                 ident.span(),
                 format!("unknown attribute: {ident_str}"),
diff --git a/core/harness_derive/src/codegen.rs 
b/core/harness_derive/src/codegen.rs
index 481e39af4..591f11fad 100644
--- a/core/harness_derive/src/codegen.rs
+++ b/core/harness_derive/src/codegen.rs
@@ -557,6 +557,11 @@ fn generate_harness_setup(
 /// after server but before MCP and connectors runtime (which may depend on 
seed data).
 /// Fixture seeds are combined with the global seed.
 fn generate_start_and_seed(attrs: &IggyTestAttrs, fixture_seed: TokenStream) 
-> TokenStream {
+    // `manual_start` hands the bring-up to the test body, so there is nothing
+    // to emit and no client for a seed to run against.
+    if attrs.manual_start {
+        return TokenStream::new();
+    }
     let has_fixture_seed = !fixture_seed.is_empty();
     match (&attrs.seed_fn, has_fixture_seed) {
         (Some(seed_fn), true) => {
@@ -837,6 +842,7 @@ mod tests {
             },
             seed_fn: None,
             cluster_nodes: crate::attrs::ClusterNodesValue::None,
+            manual_start: false,
             jwks_server: None,
         };
         let variants = generate_variants(&attrs);
diff --git a/core/integration/src/harness/disk.rs 
b/core/integration/src/harness/disk.rs
index 332a531ef..97d42b16c 100644
--- a/core/integration/src/harness/disk.rs
+++ b/core/integration/src/harness/disk.rs
@@ -448,14 +448,29 @@ pub fn find_partition_superblock_dir(root: &Path) -> 
Option<PathBuf> {
 /// Index of the node the metadata roster marks as leader, resolved by matching
 /// the roster's TCP port against each node's bound address.
 ///
+/// Reads through node 0. Use [`leader_node_index_via`] where node 0 may be
+/// down or where the roster must be read from a named node.
+///
 /// # Panics
 /// If no root client connects, the roster query fails, no node is marked
 /// leader, or the leader's port matches no harness node.
 pub async fn leader_node_index(harness: &TestHarness) -> usize {
+    leader_node_index_via(harness, 0).await
+}
+
+/// [`leader_node_index`] reading the roster through node `via`.
+///
+/// Named separately because which node answers matters once node 0 may be
+/// down: the roster read is auth-gated, so it needs a node that can complete a
+/// login, and a client built against a dead node cannot.
+///
+/// # Panics
+/// As [`leader_node_index`].
+pub async fn leader_node_index_via(harness: &TestHarness, via: usize) -> usize 
{
     let client = harness
-        .root_client_for_node(0)
+        .root_client_for_node(via)
         .await
-        .expect("a root client (redirecting to the leader if node 0 is not 
it)");
+        .expect("a root client (redirecting to the leader if the dialed node 
is not it)");
     let metadata = client
         .get_cluster_metadata()
         .await
diff --git a/core/integration/src/harness/orchestrator/harness.rs 
b/core/integration/src/harness/orchestrator/harness.rs
index f324ca39b..60324adc1 100644
--- a/core/integration/src/harness/orchestrator/harness.rs
+++ b/core/integration/src/harness/orchestrator/harness.rs
@@ -216,7 +216,6 @@ impl TestHarness {
 
             const CLUSTER_READY_TIMEOUT: Duration = Duration::from_secs(15);
             const CLUSTER_READY_RETRY_INTERVAL: Duration = 
Duration::from_millis(200);
-            const LOGIN_ATTEMPT_TIMEOUT: Duration = Duration::from_millis(750);
 
             let deadline = Instant::now() + CLUSTER_READY_TIMEOUT;
 
@@ -247,32 +246,96 @@ impl TestHarness {
                 });
             }
 
-            let mut last_error = None;
+            self.wait_for_login_ready(deadline).await
+        }
+    }
 
-            while Instant::now() < deadline {
-                match timeout(LOGIN_ATTEMPT_TIMEOUT, 
self.tcp_root_client()).await {
-                    Ok(Ok(client)) => {
-                        let _ = client.disconnect().await;
-                        return Ok(());
-                    }
-                    Ok(Err(error)) => {
-                        last_error = Some(error.to_string());
-                        sleep(CLUSTER_READY_RETRY_INTERVAL).await;
-                    }
-                    Err(_) => {
-                        last_error = Some("login attempt timed 
out".to_string());
-                        sleep(CLUSTER_READY_RETRY_INTERVAL).await;
-                    }
+    /// Poll a root login until one succeeds or `deadline` passes.
+    ///
+    /// A root login is a replicated `Register`, so it cannot commit without a
+    /// commit quorum. One succeeding is therefore direct evidence that the
+    /// nodes which ARE up formed a working quorum, which is what the mesh
+    /// marker cannot tell you: that marker only reports peer TCP links, and
+    /// `mesh_expected_peers` counts every CONFIGURED peer, so a deliberately
+    /// partial cluster never emits it however healthy its quorum is.
+    async fn wait_for_login_ready(&self, deadline: Instant) -> Result<(), 
TestBinaryError> {
+        const RETRY_INTERVAL: Duration = Duration::from_millis(200);
+        const LOGIN_ATTEMPT_TIMEOUT: Duration = Duration::from_millis(750);
+
+        let mut last_error = None;
+        while Instant::now() < deadline {
+            match timeout(LOGIN_ATTEMPT_TIMEOUT, self.tcp_root_client()).await 
{
+                Ok(Ok(client)) => {
+                    let _ = client.disconnect().await;
+                    return Ok(());
+                }
+                Ok(Err(error)) => {
+                    last_error = Some(error.to_string());
+                    sleep(RETRY_INTERVAL).await;
+                }
+                Err(_) => {
+                    last_error = Some("login attempt timed out".to_string());
+                    sleep(RETRY_INTERVAL).await;
                 }
             }
+        }
 
-            Err(TestBinaryError::InvalidState {
-                message: format!(
-                    "Timed out waiting for VSR cluster readiness: {}",
-                    last_error.unwrap_or_else(|| "unknown error".to_string())
-                ),
-            })
+        Err(TestBinaryError::InvalidState {
+            message: format!(
+                "Timed out waiting for VSR cluster readiness: {}",
+                last_error.unwrap_or_else(|| "unknown error".to_string())
+            ),
+        })
+    }
+
+    /// Spawn only the nodes at `indexes` and wait for them to form a quorum.
+    ///
+    /// The counterpart of [`Self::start`] for boot-ordering tests: it models a
+    /// cluster that begins serving before every configured node has arrived,
+    /// which is what production does (nothing in the server's bootstrap waits
+    /// for peers) and what `start` deliberately does not.
+    ///
+    /// Readiness is a root login succeeding, not the all-nodes mesh gate
+    /// `start` uses: `mesh_expected_peers` counts every CONFIGURED peer, so a
+    /// deliberately partial cluster never reports a complete mesh however
+    /// healthy its quorum is. A login is a replicated `Register` and cannot
+    /// commit without quorum, which is the stronger signal anyway.
+    ///
+    /// Dependents (MCP, connectors runtime) and the configured clients are NOT
+    /// started: their addresses resolve against nodes this call deliberately
+    /// left down. Bring the rest up with [`Self::start_node`] and build 
clients
+    /// per node with [`Self::root_client_for_node`].
+    pub async fn start_nodes(&mut self, indexes: &[usize]) -> Result<(), 
TestBinaryError> {
+        if self.started {
+            return Err(TestBinaryError::AlreadyStarted);
+        }
+        const READY_TIMEOUT: Duration = Duration::from_secs(15);
+
+        for &index in indexes {
+            let server = self
+                .servers
+                .get_mut(index)
+                .ok_or(TestBinaryError::MissingServer)?;
+            server.start()?;
         }
+        self.started = true;
+
+        let deadline = Instant::now() + READY_TIMEOUT;
+        self.wait_for_login_ready(deadline).await
+    }
+
+    /// Spawn one node into an already-running cluster, without waiting for it.
+    ///
+    /// The late-joiner half of [`Self::start_nodes`]. No readiness wait: what
+    /// "ready" means for a node joining an established cluster is the caller's
+    /// question (rejoin at the live view, journal repair, state transfer), and
+    /// each has its own observable.
+    pub fn start_node(&mut self, index: usize) -> Result<(), TestBinaryError> {
+        let server = self
+            .servers
+            .get_mut(index)
+            .ok_or(TestBinaryError::MissingServer)?;
+        server.start()
     }
 
     async fn start_dependents(&mut self) -> Result<(), TestBinaryError> {
@@ -298,7 +361,9 @@ impl TestHarness {
         Ok(())
     }
 
-    /// Restart the primary server and reconnect all clients.
+    /// Restart the node at index 0 and reconnect all clients. Index 0 is not
+    /// "the primary" (see [`Self::running_server`]); use
+    /// [`Self::restart_node`] to name a different one.
     pub async fn restart_server(&mut self) -> Result<(), TestBinaryError> {
         if self.servers.is_empty() {
             return Err(TestBinaryError::MissingServer);
@@ -416,12 +481,38 @@ impl TestHarness {
         Ok(())
     }
 
-    /// Get reference to the first (primary) server handle.
+    /// The node client helpers dial when the caller names none: node 0 while
+    /// it is alive, otherwise the lowest-indexed node whose process is.
+    ///
+    /// Index 0 is a default, not a role. It carries no leadership: the
+    /// metadata primary is `view % replica_count` and moves on every
+    /// election, and the SDK redirects to whoever that is on connect. Pinning
+    /// these helpers to node 0 meant every client helper failed outright once
+    /// a test stopped it, which is why cluster tests reach for
+    /// [`Self::root_client_for_node`] instead. Preferring 0 keeps the common
+    /// case deterministic; falling through keeps the harness usable with it
+    /// down.
+    pub fn running_server(&self) -> Result<&ServerHandle, TestBinaryError> {
+        if self.servers.is_empty() {
+            return Err(TestBinaryError::MissingServer);
+        }
+        self.servers
+            .iter()
+            .find(|server| server.is_running())
+            .ok_or(TestBinaryError::MissingServer)
+    }
+
+    /// Get reference to the node at index 0.
+    ///
+    /// Index 0 is not "the primary": see [`Self::running_server`]. Use
+    /// [`Self::node`] when the index matters and `running_server` when any
+    /// live node will do.
     pub fn server(&self) -> &ServerHandle {
         self.servers.first().expect("No servers configured")
     }
 
-    /// Get mutable reference to the first (primary) server handle.
+    /// Get mutable reference to the node at index 0. Index 0 is not "the
+    /// primary": see [`Self::running_server`].
     pub fn server_mut(&mut self) -> &mut ServerHandle {
         self.servers.first_mut().expect("No servers configured")
     }
@@ -556,7 +647,7 @@ impl TestHarness {
         &self,
         transport: TransportProtocol,
     ) -> Result<ClientBuilder, TestBinaryError> {
-        let server = 
self.servers.first().ok_or(TestBinaryError::MissingServer)?;
+        let server = self.running_server()?;
         match transport {
             TransportProtocol::Tcp => server.tcp_client(),
             TransportProtocol::Http => server.http_client(),
@@ -583,7 +674,7 @@ impl TestHarness {
         &self,
         transport: TransportProtocol,
     ) -> Result<IggyClient, TestBinaryError> {
-        let server = 
self.servers.first().ok_or(TestBinaryError::MissingServer)?;
+        let server = self.running_server()?;
         let builder = match transport {
             TransportProtocol::Tcp => server.tcp_client()?,
             TransportProtocol::Http => server.http_client()?,
diff --git a/core/integration/tests/cluster/mod.rs 
b/core/integration/tests/cluster/mod.rs
index 05f42ba21..c1fafecdc 100644
--- a/core/integration/tests/cluster/mod.rs
+++ b/core/integration/tests/cluster/mod.rs
@@ -24,5 +24,7 @@ mod failover_client_continuity;
 mod metadata_checkpoint_restart;
 mod metadata_state_transfer;
 mod multi_shard_partition_convergence;
+mod partition_primary_routing;
 mod partition_state_transfer;
 mod register_forwarding;
+mod staggered_bootstrap;
diff --git a/core/integration/tests/cluster/partition_primary_routing.rs 
b/core/integration/tests/cluster/partition_primary_routing.rs
new file mode 100644
index 000000000..064f0134b
--- /dev/null
+++ b/core/integration/tests/cluster/partition_primary_routing.rs
@@ -0,0 +1,198 @@
+// 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 node a client is told is "the leader" must be the node that accepts a
+//! partition write.
+//!
+//! `get_cluster_metadata` marks a node `Leader` from the METADATA plane's
+//! `primary_index` alone, while a partition write is only accepted by the
+//! primary of that partition's OWN consensus group. Both planes pick their
+//! primary as `view % replica_count`, but their views are independent
+//! counters, so the two answers agree only while the views are congruent mod
+//! the replica count. Every other 3-node test happens to run with both planes
+//! at view 0, where node 0 is leader and partition primary at once, so none of
+//! them can see the split.
+//!
+//! This test forces the views apart: it moves the metadata plane off view 0,
+//! brings every node back, then creates a topic whose partition group is brand
+//! new. Seeded from the metadata view it lands on the advertised leader; left
+//! at view 0 it would name node 0, and no client could be told to go there.
+//!
+//! Both assertions are about the SAME node, and deliberately so. The SDK
+//! follows the roster's leader on connect, so a client asking for node 0 does
+//! not stay there - which is itself worth pinning down, since a test that
+//! believes it is exercising node 0 while sitting on the leader proves
+//! nothing. One assertion records where the client actually lands, the other
+//! is the contract: the node the roster advertises accepts a partition write.
+
+use std::str::FromStr;
+use std::time::Duration;
+
+use iggy::prelude::*;
+use integration::harness::disk::leader_node_index_via;
+use integration::iggy_harness;
+use tokio::time::{Instant, sleep};
+
+const STREAM_NAME: &str = "partition-routing-stream";
+const TOPIC_NAME: &str = "partition-routing-topic";
+const PARTITION_ID: u32 = 0;
+
+/// Long enough for the backups to miss `cluster.heartbeat_timeout` (5s by
+/// default) and conclude an election.
+const ELECTION_SETTLE: Duration = Duration::from_secs(15);
+/// Long enough for the restarted node 0 to rejoin at the new view.
+const REJOIN_SETTLE: Duration = Duration::from_secs(10);
+/// Under the SDK's own `RESPONSE_READ_TIMEOUT` (30s), so this fires first and
+/// names the failure. Above it the SDK's timeout always wins and the budget is
+/// dead code.
+const SEND_BUDGET: Duration = Duration::from_secs(20);
+/// How long the metadata plane gets to settle on a leader that is not node 0,
+/// the state this test needs before it can observe anything.
+const PRECONDITION_BUDGET: Duration = Duration::from_secs(20);
+const PRECONDITION_POLL: Duration = Duration::from_millis(500);
+
+fn message(payload: &str) -> IggyMessage {
+    IggyMessage::from_str(payload).expect("build message")
+}
+
+#[iggy_harness(cluster_nodes = 3)]
+async fn 
given_metadata_view_moved_when_producing_to_a_fresh_topic_should_reach_the_advertised_leader(
+    harness: &mut TestHarness,
+) {
+    // Kill node 0: it is the view-0 primary of BOTH planes, so the metadata
+    // plane must elect someone else. Nothing has been written yet, so no
+    // partition group exists to move with it. Fixed waits rather than polling:
+    // dialing a leaderless cluster blocks for the SDK's own budget, and a poll
+    // loop that opens a fresh connection each round never converges.
+    harness.kill_node(0).expect("kill node 0");
+    sleep(ELECTION_SETTLE).await;
+    harness.restart_node(0).expect("restart node 0");
+    sleep(REJOIN_SETTLE).await;
+
+    // Read through node 1: node 0 has only just restarted, and the roster read
+    // is auth-gated, so it needs a node that can complete a login now.
+    //
+    // A SETUP PRECONDITION, not an invariant of the system. `primary_index` is
+    // `view % replica_count` with no `Status::Normal` gate, so a cluster that
+    // elected three times is back to advertising node 0 while perfectly
+    // healthy. Polled rather than asserted once: the split this test is about
+    // is only observable while the planes disagree, and one kill normally
+    // lands view 1 immediately.
+    let leader = {
+        let deadline = Instant::now() + PRECONDITION_BUDGET;
+        loop {
+            let index = leader_node_index_via(harness, 1).await;
+            if index != 0 {
+                break index;
+            }
+            assert!(
+                Instant::now() < deadline,
+                "the metadata plane never settled on a leader other than node 
0 within \
+                 {PRECONDITION_BUDGET:?}; with the leader at node 0 both 
planes agree and \
+                 the split this test is about cannot show"
+            );
+            sleep(PRECONDITION_POLL).await;
+        }
+    };
+
+    // A brand-new topic. Its partition group is seeded from the metadata view,
+    // so its primary is the advertised leader; left at view 0 it would be
+    // replica 0, the node that was just killed and restarted.
+    let setup = harness
+        .root_client_for_node(leader)
+        .await
+        .expect("root client on the metadata leader");
+    setup
+        .create_stream(STREAM_NAME)
+        .await
+        .expect("create stream");
+    let stream_id = Identifier::named(STREAM_NAME).expect("stream identifier");
+    setup
+        .create_topic(
+            &stream_id,
+            TOPIC_NAME,
+            &TopicCreateOptions {
+                partitions_count: Some(1),
+                message_expiry: Some(IggyExpiry::NeverExpire),
+                ..TopicCreateOptions::default()
+            },
+        )
+        .await
+        .expect("create topic");
+    let topic_id = Identifier::named(TOPIC_NAME).expect("topic identifier");
+    let partitioning = Partitioning::partition_id(PARTITION_ID);
+
+    // Where a client asking for node 0 actually ends up.
+    let leader_address = harness
+        .node(leader)
+        .tcp_addr()
+        .expect("the leader exposes a TCP endpoint")
+        .to_string();
+    let on_node_zero = harness
+        .root_client_for_node(0)
+        .await
+        .expect("root client on node 0");
+    let landed_on = on_node_zero.get_connection_info().await.server_address;
+
+    // Asserted, not printed. `root_client_for_node` signs in, and sign-in ends
+    // in the SDK's leader check, so this client is on the LEADER whatever node
+    // it dialed. Pinning that down is what stops the send below from being
+    // read as "node 0 accepted it": nothing here ever reaches node 0, and a
+    // reader who assumes otherwise draws the opposite conclusion from a pass.
+    assert_eq!(
+        landed_on, leader_address,
+        "a signed-in client follows the roster's leader, so one dialing node 0 
must settle on \
+         node {leader}; landing anywhere else means the redirect did not run 
and the send below \
+         is testing a different node than this test claims"
+    );
+
+    // The contract: the node the roster advertises accepts a partition write.
+    // Seeded from the metadata view the group's primary IS that node; left at
+    // view 0 it would be replica 0, and every client would be steered away
+    // from the only node that could accept.
+    let accepted_by_leader = send_once(&on_node_zero, &stream_id, &topic_id, 
&partitioning).await;
+    assert!(
+        accepted_by_leader.is_ok(),
+        "node {leader} is advertised as the cluster leader, so a partition 
write sent there must \
+         be accepted (or forwarded), got {accepted_by_leader:?}"
+    );
+}
+
+/// One send, bounded. The SDK replays `TransientNotAccepted` and then hands 
the
+/// request to its failover path, which re-reads the same roster and returns to
+/// the same wrong node, so with the defect present the send burns its whole
+/// budget. The timeout fires before the SDK's own and names which it was.
+async fn send_once(
+    client: &IggyClient,
+    stream_id: &Identifier,
+    topic_id: &Identifier,
+    partitioning: &Partitioning,
+) -> Result<(), String> {
+    let mut messages = vec![message("probe")];
+    match tokio::time::timeout(
+        SEND_BUDGET,
+        client.send_messages(stream_id, topic_id, partitioning, &mut messages),
+    )
+    .await
+    {
+        Ok(Ok(_)) => Ok(()),
+        Ok(Err(error)) => Err(format!("{error:?}")),
+        Err(_) => Err(format!(
+            "no answer within {SEND_BUDGET:?} (client livelocked)"
+        )),
+    }
+}
diff --git a/core/integration/tests/cluster/staggered_bootstrap.rs 
b/core/integration/tests/cluster/staggered_bootstrap.rs
new file mode 100644
index 000000000..a9979906f
--- /dev/null
+++ b/core/integration/tests/cluster/staggered_bootstrap.rs
@@ -0,0 +1,180 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! A cluster whose replica 0 arrives after the others have already elected.
+//!
+//! Nothing in the server's bootstrap waits for peers: 
`await_bootstrap_complete`
+//! is intra-node (shard 0 waiting on its sibling shards), and each node binds
+//! its listeners as soon as its own shards load. So a slow replica 0 does not
+//! hold the cluster back. Replicas 1 and 2 miss its heartbeats, conclude an
+//! election, and start serving at a view whose primary is not replica 0.
+//!
+//! Every other cluster test starts through `TestHarness::start`, which waits
+//! for all nodes to mesh before the first client op, so none of them can reach
+//! this state. `partition_primary_routing` reaches the same view split by
+//! killing node 0 mid-test; this reaches it the way production does, and the
+//! two entry points exercise different code (a group materialised on a node
+//! that was never in the founding quorum, versus one materialised on a node
+//! that was).
+//!
+//! Two independent things are asserted, because they can fail separately:
+//!
+//! - A topic created while replica 0 is absent is writable at the advertised
+//!   leader. This is the routing contract, and it holds only because a fresh
+//!   partition group seeds its view from the metadata plane rather than
+//!   starting at view 0 (which would name replica 0, the node that was late).
+//! - The late replica converges. It missed the ops committed before it
+//!   arrived, and the harness's usual all-nodes mesh gate is what normally
+//!   guarantees no node is ever in that position.
+
+use std::str::FromStr;
+use std::time::Duration;
+
+use iggy::prelude::*;
+use integration::harness::disk::leader_node_index_via;
+use integration::iggy_harness;
+use tokio::time::{Instant, sleep};
+
+const STREAM_NAME: &str = "staggered-bootstrap-stream";
+const TOPIC_NAME: &str = "staggered-bootstrap-topic";
+const PARTITION_ID: u32 = 0;
+
+/// Long enough for replicas 1 and 2 to miss `cluster.heartbeat_timeout` (5s by
+/// default) and conclude an election without replica 0.
+const ELECTION_SETTLE: Duration = Duration::from_secs(15);
+/// Long enough for the late replica 0 to probe for the live view and rejoin.
+const REJOIN_SETTLE: Duration = Duration::from_secs(10);
+/// Under the SDK's own `RESPONSE_READ_TIMEOUT` (30s), so this fires first and
+/// names the failure rather than being shadowed by it.
+const SEND_BUDGET: Duration = Duration::from_secs(20);
+/// How long the late replica gets to show it has caught up.
+const CONVERGE_BUDGET: Duration = Duration::from_secs(30);
+const MARKER_POLL: Duration = Duration::from_millis(250);
+
+/// The late replica joining the view the others elected without it. Its own
+/// recorded view is 0, which names ITSELF primary, so this line is where it
+/// gives that up.
+const VIEW_ADOPTED_MARKER: &str = "adopting view from StartView";
+
+/// Markers that each independently prove the late replica pulled committed
+/// state it did not have. Which one fires depends on whether the gap sits
+/// above or below the serving peers' retained journal floor: repair refills
+/// from the peers' journals, state transfer is what a gap below the retained
+/// floor converts into. A cluster this small usually stays above the floor and
+/// repairs, but the size of the founding quorum's log is not something this
+/// test fixes, so either counts.
+const CAUGHT_UP_MARKERS: [&str; 2] = [
+    "metadata journal repair walked",
+    "metadata state transfer installed",
+];
+
+fn message(payload: &str) -> IggyMessage {
+    IggyMessage::from_str(payload).expect("build message")
+}
+
+#[iggy_harness(cluster_nodes = 3, manual_start)]
+async fn 
given_replica_zero_arrives_late_when_producing_to_a_fresh_topic_should_reach_the_advertised_leader(
+    harness: &mut TestHarness,
+) {
+    // Replicas 1 and 2 only. A successful root login inside `start_nodes` is a
+    // committed Register, so returning at all proves the two of them formed a
+    // quorum with replica 0 still absent.
+    harness
+        .start_nodes(&[1, 2])
+        .await
+        .expect("replicas 1 and 2 must form a quorum without replica 0");
+    sleep(ELECTION_SETTLE).await;
+
+    // Read through node 1: node 0 is not running, so it can answer no login,
+    // and the roster read is auth-gated.
+    //
+    // Unlike the restart-driven twin of this test, this one is an invariant
+    // rather than a precondition: replica 0 has never been started, so no view
+    // it could be elected in exists yet.
+    let leader = leader_node_index_via(harness, 1).await;
+    assert_ne!(
+        leader, 0,
+        "replica 0 was never started, so it cannot be the metadata leader"
+    );
+
+    // Replica 0 arrives into a cluster that has already elected past it.
+    harness.start_node(0).expect("start the late replica 0");
+    sleep(REJOIN_SETTLE).await;
+
+    let setup = harness
+        .root_client_for_node(leader)
+        .await
+        .expect("root client on the metadata leader");
+    setup
+        .create_stream(STREAM_NAME)
+        .await
+        .expect("create stream");
+    let stream_id = Identifier::named(STREAM_NAME).expect("stream identifier");
+    setup
+        .create_topic(
+            &stream_id,
+            TOPIC_NAME,
+            &TopicCreateOptions {
+                partitions_count: Some(1),
+                message_expiry: Some(IggyExpiry::NeverExpire),
+                ..TopicCreateOptions::default()
+            },
+        )
+        .await
+        .expect("create topic");
+    let topic_id = Identifier::named(TOPIC_NAME).expect("topic identifier");
+    let partitioning = Partitioning::partition_id(PARTITION_ID);
+
+    // The routing contract. The partition group is brand new, so its view is
+    // whatever it was seeded with: the metadata view, which is not 0 here.
+    // Seeded at 0 instead it would name replica 0, the node that arrived late,
+    // and no client could be told to go there.
+    let mut messages = vec![message("probe")];
+    let accepted = tokio::time::timeout(
+        SEND_BUDGET,
+        setup.send_messages(&stream_id, &topic_id, &partitioning, &mut 
messages),
+    )
+    .await;
+    assert!(
+        matches!(accepted, Ok(Ok(_))),
+        "node {leader} is advertised as the cluster leader, so a partition 
write sent there must \
+         be accepted, got {accepted:?}"
+    );
+
+    // The late replica missed every op committed before it arrived. Read off
+    // its own log rather than through a client: the SDK redirects to the
+    // leader on connect, so a client dialing node 0 reports the leader's 
state,
+    // not node 0's.
+    let deadline = Instant::now() + CONVERGE_BUDGET;
+    loop {
+        let late = harness.node(0);
+        let adopted_view = late.stdout_contains(VIEW_ADOPTED_MARKER);
+        let caught_up = CAUGHT_UP_MARKERS
+            .iter()
+            .any(|marker| late.stdout_contains(marker));
+        if adopted_view && caught_up {
+            break;
+        }
+        assert!(
+            Instant::now() < deadline,
+            "the late replica 0 never converged within {CONVERGE_BUDGET:?} \
+             (adopted the live view: {adopted_view}, caught up on committed 
ops: {caught_up}); \
+             the harness's all-nodes mesh gate is what normally keeps a node 
out of this position"
+        );
+        sleep(MARKER_POLL).await;
+    }
+}
diff --git a/core/server/src/bootstrap.rs b/core/server/src/bootstrap.rs
index 587744f5d..7d8a27716 100644
--- a/core/server/src/bootstrap.rs
+++ b/core/server/src/bootstrap.rs
@@ -1282,6 +1282,7 @@ async fn shard_main(
         topology.cluster_id,
         topology.self_replica_id,
         topology.replica_count,
+        Arc::clone(&metadata_view),
     ));
     let reconcile_periodic = config
         .system
@@ -2034,6 +2035,10 @@ async fn build_shard_for_thread(
                     topology.cluster_id,
                     topology.self_replica_id,
                     topology.replica_count,
+                    // Quarantine-and-rebuild always finds a partition
+                    // directory already there, so this joins as a probing
+                    // backup and learns the live view; nothing to seed.
+                    None,
                     Rc::clone(&bus),
                 )
                 .await?
@@ -2448,6 +2453,9 @@ fn restore_metadata_consensus(
             // re-derives, and it re-probes as a backup.
             durable_view: recovered_state.map(|state| (state.view, 
state.log_view)),
             view_fallback: last_header.map(|header| header.view),
+            // Metadata, not a partition group: it has a journal to infer from
+            // and no second plane to line up with.
+            seed_view: None,
             // Fresh random incarnation each boot, so a StartView addressed to
             // a previous incarnation still in flight is ignored
             // (`handle_start_view` guard). `| 1` guarantees the non-zero the
@@ -2639,6 +2647,7 @@ async fn load_partition(
                 .as_ref()
                 .map(|state| (state.view, state.log_view)),
             view_fallback: None,
+            seed_view: None,
             incarnation: None,
             join,
         },
diff --git a/core/server/src/cluster_meta.rs b/core/server/src/cluster_meta.rs
index 7372d3e59..c251ef602 100644
--- a/core/server/src/cluster_meta.rs
+++ b/core/server/src/cluster_meta.rs
@@ -83,9 +83,13 @@ impl ClusterRoster {
         }
     }
 
-    /// The current metadata primary's roster index, from the shard-0-published
+    /// The current metadata primary's REPLICA ID, from the shard-0-published
     /// view; `None` until the first publish or with no roster.
-    pub fn current_primary_index(&self) -> Option<u8> {
+    ///
+    /// A replica id, not a position in [`Self::nodes`]: `role_for` compares it
+    /// against each node's configured `replica_id`, and the two coincide only
+    /// while the roster is listed in replica-id order.
+    pub fn current_primary_replica_id(&self) -> Option<u8> {
         if self.nodes.is_empty() {
             return None;
         }
diff --git a/core/server/src/partition_helpers.rs 
b/core/server/src/partition_helpers.rs
index 5a2f49fd2..f548f5de7 100644
--- a/core/server/src/partition_helpers.rs
+++ b/core/server/src/partition_helpers.rs
@@ -28,7 +28,9 @@ use crate::offset_recovery::{load_consumer_group_offsets, 
load_consumer_offsets}
 use crate::server_error::ServerError;
 use compio::fs::create_dir_all;
 use configs::server::ServerConfig;
-use consensus::{JoinMode, LocalPipeline, VsrConsensus, VsrRestore, VsrState};
+use consensus::{
+    FreshGroupStart, LocalPipeline, VsrConsensus, VsrRestore, VsrState, 
fresh_group_start,
+};
 use iggy_common::{
     ConsumerGroupOffsets, ConsumerOffsets, IggyByteSize, IggyError, 
IggyTimestamp, PartitionStats,
     TopicRuntimeOptions,
@@ -516,6 +518,12 @@ pub(crate) async fn open_partition_superblock(
 /// The namespace arrives packed, so its components are in range by
 /// construction. Metadata admission is what bounds them.
 ///
+/// `view_seed` is the view a group with no durable record of its own starts
+/// in, and it is the metadata plane's current view: see the `seed_view`
+/// comment below for why a group left at view 0 is unreachable. `None` keeps
+/// the historical view-0 start, and is also what a restart materialization
+/// gets, since it probes for the live view instead.
+///
 /// The returned partition's `offset` / `dirty_offset` are `0` and
 /// `should_increment_offset` is `false`, mirroring a clean append starting
 /// at the empty segment.
@@ -534,6 +542,7 @@ pub async fn build_partition_fresh(
     cluster_id: u128,
     self_replica_id: u8,
     replica_count: u8,
+    view_seed: Option<u32>,
     bus: Rc<IggyMessageBus>,
 ) -> Result<IggyPartition<Rc<IggyMessageBus>>, ServerError> {
     let stream_id = namespace.stream_id();
@@ -590,13 +599,12 @@ pub async fn build_partition_fresh(
     // peer, byte-identical by the deterministic-roll/replicated-ciphertext
     // design. A truly fresh create keeps the plain init: every group needs
     // its view-0 primary to exist.
-    let join = if restarted {
-        JoinMode::ProbeAsBackup {
-            await_state_transfer: false,
-        }
-    } else {
-        JoinMode::Init
-    };
+    let durable_view = recovered_state
+        .as_ref()
+        .map(|state| (state.view, state.log_view));
+    // Shared with the simulator's `init_partition`, which cannot call this
+    // builder; see `fresh_group_start`.
+    let FreshGroupStart { join, seed_view } = fresh_group_start(restarted, 
durable_view, view_seed);
     // 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;
@@ -610,10 +618,25 @@ pub async fn build_partition_fresh(
         LocalPipeline::with_capacities(prepare_queue_depth, 
prepare_queue_depth * 2),
         VsrRestore {
             timers: &timers,
-            durable_view: recovered_state
-                .as_ref()
-                .map(|state| (state.view, state.log_view)),
+            durable_view,
             view_fallback: None,
+            // Both planes pick their primary as `view % replica_count` from
+            // their OWN view counter. A group left at view 0 while the
+            // metadata plane sits elsewhere therefore names a different node
+            // than the roster advertises as leader, and nothing routes a
+            // partition write across that gap: the client is sent to the
+            // metadata leader and refused there for the whole budget. Seeding
+            // from the metadata view keeps the two congruent for a group born
+            // after a metadata election.
+            //
+            // Replicas can still disagree on the seed: each publishes its own
+            // metadata view on a 100ms poll, so one may read V while another
+            // has yet to see the election. That resolves the way any view
+            // disagreement does, the higher view winning through `StartView` -
+            // but only because the seed sets `log_view` too. The caller is
+            // what keeps a replica from seeding a view it has no opinion on;
+            // see `ReconcilerCtx::partition_view_seed`.
+            seed_view,
             incarnation: None,
             join,
         },
diff --git a/core/server/src/partition_reconciler.rs 
b/core/server/src/partition_reconciler.rs
index 699e9c94d..051d81c69 100644
--- a/core/server/src/partition_reconciler.rs
+++ b/core/server/src/partition_reconciler.rs
@@ -170,6 +170,7 @@
 //! -- `PrepareHeader.reserved` has room, but it is a `#[repr(C)]` wire change.
 
 use crate::bootstrap::ServerShard;
+use crate::cluster_meta::METADATA_VIEW_UNKNOWN;
 use crate::partition_helpers::{build_partition_fresh, 
delete_partitions_from_disk};
 use ahash::{AHashMap, AHashSet};
 use configs::server::ServerConfig;
@@ -187,6 +188,7 @@ use shard::{Receiver, Sender};
 use std::cell::{Cell, RefCell};
 use std::rc::Rc;
 use std::sync::Arc;
+use std::sync::atomic::{AtomicU64, Ordering};
 use std::time::{Duration, Instant};
 use tracing::{debug, error, trace, warn};
 
@@ -225,6 +227,11 @@ pub struct ReconcilerCtx {
     pub cluster_id: u128,
     pub self_replica_id: u8,
     pub replica_count: u8,
+    /// The metadata plane's view, published by shard 0 and shared with every
+    /// shard's roster. Read when materialising a partition so its consensus
+    /// group starts in the same view the roster's advertised leader comes
+    /// from; the unknown-view sentinel until the first publish.
+    pub metadata_view: Arc<AtomicU64>,
     failure_state: RefCell<AHashMap<(IggyNamespace, FailureCause), 
FailureRecord>>,
     /// `Streams::revision` observed at the end of the last pass that fully
     /// converged. Paired with `last_pass_noop` for the fast-skip in
@@ -244,6 +251,7 @@ impl ReconcilerCtx {
         cluster_id: u128,
         self_replica_id: u8,
         replica_count: u8,
+        metadata_view: Arc<AtomicU64>,
     ) -> Self {
         Self {
             shard,
@@ -252,12 +260,48 @@ impl ReconcilerCtx {
             cluster_id,
             self_replica_id,
             replica_count,
+            metadata_view,
             failure_state: RefCell::new(AHashMap::new()),
             last_revision: Cell::new(None),
             last_pass_noop: Cell::new(false),
         }
     }
 
+    /// The view a partition group materialised now should start in.
+    ///
+    /// `None` means this replica has no opinion yet, NOT "start at view 0":
+    /// shard 0 publishes the unknown-view sentinel before its first tick and
+    /// for as long as it has ceded a recovered view's primaryship. Treating
+    /// that as 0 is how a single replica of a group ends up view-0 while its
+    /// peers are at V, which is the split this seed exists to close. Callers
+    /// on a replicated group defer materialising instead; see
+    /// [`Self::partition_view_seed_ready`].
+    ///
+    /// # Panics
+    /// If the published view does not fit a `u32`. The publisher writes
+    /// `u64::from(consensus.view())` or the sentinel handled above, so a value
+    /// past `u32::MAX` is memory corruption, not a state a retry can clear.
+    /// Silently falling back to `None` here would restore the view-0 start
+    /// this change exists to remove, on the one path nobody would look at.
+    fn partition_view_seed(&self) -> Option<u32> {
+        let view = self.metadata_view.load(Ordering::Relaxed);
+        if view == METADATA_VIEW_UNKNOWN {
+            return None;
+        }
+        Some(u32::try_from(view).expect("published metadata view must fit a 
u32"))
+    }
+
+    /// Whether a group may be materialised now.
+    ///
+    /// A solo replica is always ready: with one replica every view names it
+    /// primary, so there is no peer to disagree with and no split to cause.
+    /// A replicated group waits until this replica knows the metadata view,
+    /// so it cannot seed a view-0 group underneath peers that already moved.
+    /// The wait is one reconcile pass; shard 0 republishes every 100ms.
+    fn partition_view_seed_ready(&self) -> bool {
+        self.replica_count <= 1 || self.partition_view_seed().is_some()
+    }
+
     fn is_backed_off(&self, ns: IggyNamespace, cause: FailureCause, now: 
Instant) -> bool {
         let state = self.failure_state.borrow();
         if state.is_empty() {
@@ -417,6 +461,12 @@ struct PassCounters {
     /// has not applied yet. Counted so the pass does not arm the fast-skip
     /// while work is in flight; applying it bumps no revision.
     already_staged: usize,
+    /// Materialisations held back because this replica has no metadata view to
+    /// seed from yet. Counted so the pass does not arm the fast-skip: shard 0
+    /// publishing a view bumps no `Streams::revision` and does not wake the
+    /// reconciler, so an armed skip would strand every deferred group until
+    /// some unrelated commit happened to bump the revision.
+    view_unpublished: usize,
 }
 
 impl PassCounters {
@@ -433,6 +483,7 @@ impl PassCounters {
             + self.deferred
             + self.parked_reclaimed
             + self.already_staged
+            + self.view_unpublished
     }
 }
 
@@ -659,6 +710,22 @@ async fn reconcile_additions(
             continue;
         }
 
+        // Materialising before this replica knows the metadata view would seed
+        // the group at view 0 under peers that already elected past it. Defer
+        // the whole pass rather than the namespace: every group on this shard
+        // reads the same view, so none of them can be seeded correctly yet.
+        if !ctx.partition_view_seed_ready() {
+            debug!(
+                shard = shard_id,
+                "metadata view not published yet; deferring partition 
materialisation"
+            );
+            // Every remaining namespace reads the same view, so none of them
+            // can be seeded either. Counted before breaking so the pass does
+            // not read as converged, which would fast-skip the retry.
+            counters.view_unpublished += 1;
+            break;
+        }
+
         // Resolve the shared stats `Arc` only for namespaces actually
         // built, not once per committed partition every pass. A topic that
         // vanished between the target snapshot and this read defers to the
@@ -676,6 +743,7 @@ async fn reconcile_additions(
             ctx.cluster_id,
             ctx.self_replica_id,
             ctx.replica_count,
+            ctx.partition_view_seed(),
             Rc::clone(&ctx.shard.bus),
         )
         .await
@@ -1211,8 +1279,8 @@ pub fn install_tick_handler(shard: &Rc<ServerShard>, 
wake_tx: WakeTx) {
 #[cfg(test)]
 mod tests {
     use super::{
-        FailureCause, FailureRecord, ReconcilerCtx, build_partition_fresh,
-        delete_partitions_from_disk, fetch_partition_stats, reconcile_once,
+        AtomicU64, FailureCause, FailureRecord, METADATA_VIEW_UNKNOWN, 
ReconcilerCtx,
+        build_partition_fresh, delete_partitions_from_disk, 
fetch_partition_stats, reconcile_once,
     };
     use configs::server::{ServerConfig, ServerSystemConfig};
     use consensus::{MetadataHandle, PartitionsHandle};
@@ -1664,6 +1732,31 @@ mod tests {
             CLUSTER_ID,
             0,
             1,
+            Arc::new(AtomicU64::new(METADATA_VIEW_UNKNOWN)),
+        ))
+    }
+
+    /// [`make_ctx`] for a REPLICATED group, sharing `metadata_view` with the
+    /// caller so a test can publish a view mid-run.
+    ///
+    /// Replica count is what decides whether materialisation waits on a
+    /// published view: a solo replica is primary in every view, so there is no
+    /// peer to disagree with and nothing to wait for. Every other test here
+    /// runs solo and takes that short circuit.
+    fn make_cluster_ctx(
+        shard: Rc<TestShard>,
+        total_shards: u16,
+        config: Rc<ServerConfig>,
+        metadata_view: Arc<AtomicU64>,
+    ) -> Rc<ReconcilerCtx> {
+        Rc::new(ReconcilerCtx::new(
+            shard,
+            total_shards,
+            config,
+            CLUSTER_ID,
+            0,
+            3,
+            metadata_view,
         ))
     }
 
@@ -1809,6 +1902,7 @@ mod tests {
             CLUSTER_ID,
             0,
             1,
+            None,
             Rc::clone(&ctx.shard.bus),
         )
         .await
@@ -1838,6 +1932,7 @@ mod tests {
             CLUSTER_ID,
             0,
             1,
+            None,
             Rc::clone(&ctx.shard.bus),
         )
         .await
@@ -1961,6 +2056,73 @@ mod tests {
     /// `CreatePartitions` on an existing topic adds new namespaces; the
     /// reconciler picks them up on the next pass without touching the
     /// partitions it already materialised.
+    /// A replicated group is NOT materialised while this replica has no
+    /// metadata view to seed from, and IS once one is published.
+    ///
+    /// Seeding is a local read: shard 0 publishes the unknown sentinel before
+    /// its first tick and for as long as it has ceded a recovered view. Taking
+    /// that for view 0 would start the group naming replica 0 underneath peers
+    /// that already elected past it, which is the split the seed exists to
+    /// close, reintroduced one replica at a time. Waiting costs one reconcile
+    /// pass; the publisher reposts every 100ms.
+    #[compio::test]
+    async fn 
given_no_published_metadata_view_when_reconciling_should_defer_materialisation()
 {
+        let tmp = TempDir::new().expect("tempdir for system path");
+        let config = test_config(&tmp);
+        let mux = TestMux::default();
+        seed_stream(&mux, 1, "stream-deferred");
+        seed_topic(&mux, 2, 0, "topic-deferred", vec![assignment(0, 1)]);
+
+        let shard = build_test_shard(0, &config, mux);
+        let metadata_view = Arc::new(AtomicU64::new(METADATA_VIEW_UNKNOWN));
+        let ctx = make_cluster_ctx(
+            Rc::clone(&shard),
+            1,
+            Rc::new(config),
+            Arc::clone(&metadata_view),
+        );
+
+        reconcile_pass(&ctx).await;
+        assert_eq!(
+            shard.plane.partitions().len(),
+            0,
+            "a replicated group must not materialise before this replica knows 
the metadata \
+             view: seeded at 0 it names replica 0 whatever the metadata plane 
elected"
+        );
+
+        // The publisher posts a real view; the deferred pass now converges.
+        metadata_view.store(1, Ordering::Relaxed);
+        reconcile_pass(&ctx).await;
+        assert_eq!(
+            shard.plane.partitions().len(),
+            1,
+            "once a view is published the deferred group must materialise on 
the next pass"
+        );
+    }
+
+    /// A SOLO replica never waits. It is primary in every view, so there is no
+    /// peer for it to disagree with, and blocking on a publisher that a
+    /// single-node deployment may never run would wedge materialisation
+    /// outright.
+    #[compio::test]
+    async fn 
given_a_solo_replica_when_no_view_is_published_should_still_materialise() {
+        let tmp = TempDir::new().expect("tempdir for system path");
+        let config = test_config(&tmp);
+        let mux = TestMux::default();
+        seed_stream(&mux, 1, "stream-solo");
+        seed_topic(&mux, 2, 0, "topic-solo", vec![assignment(0, 1)]);
+
+        let shard = build_test_shard(0, &config, mux);
+        let ctx = make_ctx(Rc::clone(&shard), 1, Rc::new(config));
+
+        reconcile_pass(&ctx).await;
+        assert_eq!(
+            shard.plane.partitions().len(),
+            1,
+            "a solo replica must materialise without waiting on a published 
metadata view"
+        );
+    }
+
     #[compio::test]
     async fn reconcile_picks_up_create_partitions_increments() {
         let tmp = TempDir::new().expect("tempdir for system path");
diff --git a/core/server/src/responses.rs b/core/server/src/responses.rs
index f4e6bc4e5..21b8e6497 100644
--- a/core/server/src/responses.rs
+++ b/core/server/src/responses.rs
@@ -682,7 +682,7 @@ where
             (!(consensus.has_ceded_primaryship() && primary_index == 
consensus.replica()))
                 .then_some(primary_index)
         })
-        .or_else(|| roster.current_primary_index());
+        .or_else(|| roster.current_primary_replica_id());
     let metadata = roster.cluster_metadata(primary_index, client_ip);
     ClusterMetadataResponse {
         name: metadata.name,
diff --git a/core/shard/src/lib.rs b/core/shard/src/lib.rs
index 358b8e62b..9098c7d63 100644
--- a/core/shard/src/lib.rs
+++ b/core/shard/src/lib.rs
@@ -3567,6 +3567,7 @@ where
         recovered_state: Option<consensus::VsrState>,
         retained: Option<partitions::RetainedPartitionState>,
         restore_frontier: bool,
+        metadata_view: Option<u32>,
     ) where
         B: MessageBus + Clone,
     {
@@ -3584,26 +3585,42 @@ where
             LocalPipeline::new(),
             self.partition_consensus.clock.clone(),
         );
+        // The SAME decision `build_partition_fresh` makes, not a copy of it.
+        // This path cannot call that builder (it does real filesystem work and
+        // this runs on in-memory storage), and while the two decided
+        // separately the simulator exercised neither the metadata-view seed 
nor
+        // the plane split it closes. `retained` is populated only by the
+        // restart path, which is this path's evidence of a prior life.
+        let durable_view = recovered_state
+            .as_ref()
+            .map(|state| (state.view, state.log_view));
+        let restarted = retained.is_some() && 
self.partition_consensus.replica_count > 1;
+        let consensus::FreshGroupStart { join, seed_view } =
+            consensus::fresh_group_start(restarted, durable_view, 
metadata_view);
+
         // Recorded view first, exactly as the two boot paths order it: 
restoring
         // after `init` would advertise a view older than the recorded one.
-        if let Some(state) = recovered_state.as_ref() {
-            consensus.set_view(state.view);
-            consensus.set_log_view(state.log_view);
-            consensus.mark_superblock_durable(state.view, state.log_view);
-        }
-        // Boot as `load_partition` does. A rebuilt replica cannot know the 
group's
-        // `(op, commit)`: the partition journal is in-memory and segments 
carry no
-        // op numbers. So in a cluster it joins quorum-invisible and asks the 
view's
-        // primary rather than resuming as a primary its peers may have 
replaced.
-        // Plain `init` would set `Status::Normal` and arm the commit 
broadcast on
-        // whichever replica is primary-by-index, the split-brain 
`init_as_backup`
-        // exists to prevent. A first materialisation has no view to rejoin and
-        // keeps the plain init; `retained` is populated only by the restart 
path.
-        if retained.is_some() && self.partition_consensus.replica_count > 1 {
-            consensus.init_as_backup();
-            consensus.begin_view_probe();
-        } else {
-            consensus.init();
+        if let Some((view, log_view)) = durable_view {
+            consensus.set_view(view);
+            consensus.set_log_view(log_view);
+            consensus.mark_superblock_durable(view, log_view);
+        } else if let Some(view) = seed_view {
+            consensus.set_view(view);
+            consensus.set_log_view(view);
+        }
+        // A rebuilt replica cannot know the group's `(op, commit)`: the
+        // partition journal is in-memory and segments carry no op numbers. So
+        // in a cluster it joins quorum-invisible and asks the view's primary
+        // rather than resuming as a primary its peers may have replaced. Plain
+        // `init` would set `Status::Normal` and arm the commit broadcast on
+        // whichever replica is primary-by-index, the split-brain
+        // `init_as_backup` exists to prevent.
+        match join {
+            consensus::JoinMode::ProbeAsBackup { .. } => {
+                consensus.init_as_backup();
+                consensus.begin_view_probe();
+            }
+            consensus::JoinMode::Init => consensus.init(),
         }
 
         let stats = Arc::new(PartitionStats::default());
diff --git a/core/simulator/src/lib.rs b/core/simulator/src/lib.rs
index 2a2f396ba..19d89dc87 100644
--- a/core/simulator/src/lib.rs
+++ b/core/simulator/src/lib.rs
@@ -1297,6 +1297,33 @@ impl Simulator {
     /// `--crash-primary`, so a crash-triggered view change can run mid-run 
and this
     /// may name a stale or crashed primary. Callers needing a real answer run
     /// `workload::oracle::settle_to_stable_view` first.
+    /// The replica the METADATA plane currently names primary, read from the
+    /// first live replica that owns a metadata consensus.
+    ///
+    /// The twin of [`Self::primary_index`], which answers for a partition
+    /// group. The two planes count views independently, so they agree only
+    /// while their views are congruent mod the replica count, and a group
+    /// materialised after a metadata election is the case where they part.
+    ///
+    /// Test-only. The workload oracle deliberately does NOT assert the two
+    /// planes agree: that holds for a group at the view it was seeded in, and
+    /// a later election on either plane parts them again with nothing to pull
+    /// them back, so a live invariant would fire on correct runs.
+    #[cfg(test)]
+    #[must_use]
+    pub(crate) fn metadata_primary_index(&self) -> Option<u8> {
+        (0..self.replica_count)
+            .filter(|replica_idx| !self.crashed.contains(replica_idx))
+            .find_map(|replica_idx| {
+                let consensus = 
self.replicas[usize::from(replica_idx)].shards[0]
+                    .plane
+                    .metadata()
+                    .consensus
+                    .as_ref()?;
+                Some(consensus.primary_index(consensus.view()))
+            })
+    }
+
     #[must_use]
     pub(crate) fn primary_index(&self, namespace: IggyNamespace) -> Option<u8> 
{
         (0..self.replica_count)
@@ -1355,12 +1382,24 @@ fn materialise_partition(replica: &SimReplica, 
namespace: IggyNamespace, restore
     // a second materialisation with no restart between would otherwise 
resurrect a
     // log the live partition has moved past.
     let retained = replica.partition_logs.borrow_mut().remove(&namespace);
+    // The view this replica's metadata plane is in, which is what a fresh
+    // group seeds from. Production reads it off the roster value shard 0
+    // publishes; here shard 0's consensus is right there. Without it the
+    // simulator materialises every group at view 0 and can never produce the
+    // plane split that costs production its writes.
+    let metadata_view = replica.shards[0]
+        .plane
+        .metadata()
+        .consensus
+        .as_ref()
+        .map(consensus::VsrConsensus::view);
     replica.shards[usize::from(owner)].init_partition(
         namespace,
         Some(superblock),
         recovered_state,
         retained,
         restore_frontier,
+        metadata_view,
     );
     for shard in &replica.shards {
         shard.shards_table().insert(
@@ -1475,6 +1514,99 @@ mod tests {
         );
     }
 
+    /// A partition group materialised AFTER a metadata election starts in the
+    /// metadata plane's view, so both planes name the same primary.
+    ///
+    /// Left at view 0 the group names replica 0 whatever the metadata plane 
has
+    /// got to. Nothing on the wire can express a partition primary
+    /// (`ClusterNode` carries one cluster-wide `role`) and partition ops route
+    /// within a node rather than to a peer, so the node clients are sent to
+    /// refuses every write to that group and the SDK burns its budget
+    /// rediscovering the same wrong answer.
+    ///
+    /// The simulator reaches this where the integration tests cannot: no
+    /// client, no transport, just the two planes' views read directly. It is
+    /// also the only place the SEED itself is asserted rather than inferred
+    /// from a send succeeding.
+    #[test]
+    fn 
given_a_metadata_election_when_a_group_materialises_should_seed_the_metadata_view()
 {
+        
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
+            enabled: false,
+            size: iggy_common::IggyByteSize::from(0u64),
+            bucket_capacity: 1,
+        });
+
+        let replica_count: u8 = 3;
+        let client_id: u128 = 1;
+        let network_opts = packet::PacketSimulatorOptions {
+            node_count: replica_count,
+            client_count: 1,
+            ..packet::PacketSimulatorOptions::default()
+        };
+        let mut sim = Simulator::new(
+            replica_count as usize,
+            std::iter::once(client_id),
+            network_opts,
+        );
+
+        // Move the metadata plane off view 0 by crashing its view-0 primary.
+        // Nothing has been written, so no partition group exists to move with
+        // it: the group created below is genuinely fresh.
+        sim.replica_crash(0);
+        for _ in 0..800 {
+            sim.step();
+        }
+
+        let metadata_primary = sim
+            .metadata_primary_index()
+            .expect("a live replica must own metadata consensus");
+        assert_ne!(
+            metadata_primary, 0,
+            "crashing replica 0 must have moved the metadata plane off view 0; 
with the \
+             primary back at replica 0 both planes agree and the split cannot 
show"
+        );
+
+        // Materialise a brand-new group. Every live replica seeds from its own
+        // metadata view, which is the value production reads off the roster.
+        let namespace = IggyNamespace::new(1, 1, 0);
+        sim.init_partition(namespace);
+
+        let partition_primary = sim
+            .primary_index(namespace)
+            .expect("the group must exist on a live replica after 
materialisation");
+        assert_eq!(
+            partition_primary, metadata_primary,
+            "a group materialised after a metadata election must name the same 
primary as the \
+             metadata plane; seeded at view 0 instead it names replica 0, 
which no client can \
+             be routed to"
+        );
+
+        // Per replica, not just the aggregate: the seed is read locally on 
each
+        // one, so a single replica left at view 0 would still elect itself
+        // primary of that group while its peers disagree, and the aggregate
+        // read above would not see it.
+        for replica_idx in 0..replica_count {
+            if sim.is_crashed(replica_idx) {
+                continue;
+            }
+            let state = sim
+                .partition_consensus_state(usize::from(replica_idx), namespace)
+                .expect("a live replica must host the freshly materialised 
group");
+            // Compared as primaries, not as view numbers: the two coincide 
only
+            // while the view is below `replica_count`, and pinning the view
+            // itself would make this fail on a second election for no reason.
+            let seeded_primary = u8::try_from(state.view % 
u32::from(replica_count))
+                .expect("a value modulo replica_count fits the u8 
replica_count");
+            assert_eq!(
+                seeded_primary, metadata_primary,
+                "replica {replica_idx} seeded its group at view {}, naming 
replica \
+                 {seeded_primary} primary while the metadata plane names 
{metadata_primary}; \
+                 replicas that seed different views disagree on their own 
group's primary",
+                state.view
+            );
+        }
+    }
+
     /// A replica that advanced its view, persisted it through the superblock 
gate,
     /// then crashed recovers that view from its own disk, not a fresh 0. The
     /// split-brain guarantee: a replica never forgets a view it acted in. 
Impossible
@@ -2389,7 +2521,7 @@ mod tests {
             executor.run_until_stalled(POLL_BUDGET); // borrow acquired; task 
parks
             let grow = Rc::clone(&sim.replicas[0].shards[0]);
             executor.spawn(async move {
-                grow.init_partition(ns_grow, None, None, None, false);
+                grow.init_partition(ns_grow, None, None, None, false, None);
             });
             executor.run_until_stalled(POLL_BUDGET); // grow while the borrow 
is live
         }))
@@ -2424,7 +2556,7 @@ mod tests {
         executor.run_until_stalled(POLL_BUDGET);
         let grow = Rc::clone(&sim.replicas[0].shards[0]);
         executor.spawn(async move {
-            grow.init_partition(ns_grow, None, None, None, false);
+            grow.init_partition(ns_grow, None, None, None, false, None);
         });
         executor.run_until_stalled(POLL_BUDGET);
 

Reply via email to