This is an automated email from the ASF dual-hosted git repository.
spetz pushed a commit to branch shard_reconcilation_loop_redispatch_fix
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to
refs/heads/shard_reconcilation_loop_redispatch_fix by this push:
new cc3d0efa7 extend simulator, fix tests
cc3d0efa7 is described below
commit cc3d0efa7b0f3452822bee420a6af849cc96b291
Author: spetz <[email protected]>
AuthorDate: Tue Sep 1 10:19:59 2026 +0200
extend simulator, fix tests
---
bdd/docker-compose.cluster.yml | 6 ++
core/shard/src/lib.rs | 14 +++
core/shard/src/router.rs | 13 +++
core/simulator/src/lib.rs | 198 ++++++++++++++++++++++++++++++++++++++---
4 files changed, 220 insertions(+), 11 deletions(-)
diff --git a/bdd/docker-compose.cluster.yml b/bdd/docker-compose.cluster.yml
index 9976d342b..df773b8a5 100644
--- a/bdd/docker-compose.cluster.yml
+++ b/bdd/docker-compose.cluster.yml
@@ -56,6 +56,9 @@ x-cluster-topology: &cluster-topology
IGGY_ROOT_PASSWORD: iggy
IGGY_CLUSTER_ENABLED: "true"
IGGY_CLUSTER_NAME: test-cluster
+ # Keep role-specific scenarios on view 0 when a loaded CI runner delays
+ # one replica during startup.
+ IGGY_CLUSTER_HEARTBEAT_TIMEOUT: "30s"
IGGY_CLUSTER_NODES_0_NAME: leader-node
IGGY_CLUSTER_NODES_0_IP: 172.28.0.101
IGGY_CLUSTER_NODES_0_REPLICA_ID: "0"
@@ -115,6 +118,9 @@ services:
iggy-follower:
<<: *cluster-node
+ depends_on:
+ iggy-leader:
+ condition: service_healthy
command: [ "--replica-id", "1" ]
healthcheck:
test: [ "CMD", "/usr/local/bin/iggy", "--tcp-server-address",
"172.28.0.102:8092", "ping" ]
diff --git a/core/shard/src/lib.rs b/core/shard/src/lib.rs
index 556b7de08..c9a79546b 100644
--- a/core/shard/src/lib.rs
+++ b/core/shard/src/lib.rs
@@ -3546,6 +3546,10 @@ where
///
/// `created_view` is the view the metadata plane created the namespace in;
/// see `fresh_group_start`.
+ ///
+ /// Once inserted, this also runs the same parked-frame redispatch as
+ /// `ReconcileOp::InsertOwned`. The simulator bypasses the reconciler
build,
+ /// but it must not bypass the pump handoff that follows materialisation.
// `feature = "simulator"` alone, unlike its neighbours: the body names
items
// `partitions` gates the same way, and a `test` arm cannot turn those on.
// Under `cargo test -p shard` that arm fires from shard's own `cfg(test)`
@@ -3563,6 +3567,7 @@ where
created_view: u32,
) where
B: MessageBus + Clone,
+ M: StreamsFrontend,
{
let partitions = self.plane.partitions();
if partitions.contains(&namespace) {
@@ -3666,6 +3671,15 @@ where
// store resumes minting at 0 while its group is at N.
partition.restore_offset_frontier(recovered_state.as_ref());
partitions.insert(namespace, partition);
+ if let Some(epoch) = self
+ .plane
+ .metadata()
+ .mux_stm
+ .streams()
+ .created_revision_for_namespace(namespace)
+ {
+ self.redispatch_parked_frames(namespace, epoch);
+ }
}
/// Resolve the single partition a VSR control frame addresses, keyed by
diff --git a/core/shard/src/router.rs b/core/shard/src/router.rs
index f2b28f8c6..f79aa4315 100644
--- a/core/shard/src/router.rs
+++ b/core/shard/src/router.rs
@@ -357,6 +357,14 @@ where
match frame {
Ok(frame) => {
if self.accept_frame_for_self(&frame) {
+ // No-op in production, where staging happens
only
+ // under `apply_reconcile_ops` and every arm
drains
+ // before selecting again. The simulator's
+ // `init_partition` stages from outside the
pump,
+ // and this frame can be a LATER op of the same
+ // partition: deliver the staged prefix first,
or
+ // the plane's gap check drops the frame.
+ self.drain_redispatched_frames().await;
self.process_frame(frame).await;
self.process_loopback(&mut loopback_buf, &mut
namespace_scratch).await;
// Tail drain catches reconcile ops whose
marker was dropped.
@@ -418,6 +426,11 @@ where
self.process_loopback(&mut loopback_buf, &mut
namespace_scratch)
.await;
self.apply_reconcile_ops();
+ // A dropped ReconcileApply marker can leave an InsertOwned
+ // staged until this shutdown drain. Deliver the parked
prefix
+ // before taking another inbox frame, exactly as the live
pump
+ // does, or a later prepare can reach the gap check first.
+ self.drain_redispatched_frames().await;
if let Some(fault) = self.first_partition_commit_fault() {
fatal = Some(fault);
break;
diff --git a/core/simulator/src/lib.rs b/core/simulator/src/lib.rs
index 7b722eb9b..e8b1080d1 100644
--- a/core/simulator/src/lib.rs
+++ b/core/simulator/src/lib.rs
@@ -902,12 +902,13 @@ impl Simulator {
}
}
- /// Lost-wake tripwire. At executor quiescence every live pump must have
drained
- /// its inbox; a non-empty one on a non-crashed replica means a frame
reached the
- /// channel without waking the target pump. Every pump holds a standing
+ /// Lost-work tripwire. At executor quiescence every live pump must have
drained
+ /// both inbox lanes and its parked-frame redispatch queue. A non-empty
lane on a
+ /// non-crashed replica means a frame reached the channel without waking
the
+ /// target pump. A non-empty redispatch queue means a materialisation
staged work
+ /// without the pump's priority drain completing. Every pump holds a
standing
/// `CONSENSUS_TICK_INTERVAL` timer, so the next `advance_time` would
re-poll and
- /// silently drain it, masking the exact wake-loss class this harness
exists to
- /// catch. Trip here instead.
+ /// silently drain either case, masking the fault. Trip here instead.
///
/// Incomplete by construction: only catches a lost wakeup while the
un-woken
/// frame is still queued at quiescence, since a later frame that does
wake the
@@ -943,6 +944,16 @@ impl Simulator {
self.seed,
self.executor.schedule_hash(),
);
+ let pending_redispatch = shard.redispatched_frame_count();
+ assert_eq!(
+ pending_redispatch,
+ 0,
+ "lost redispatch: replica {replica_id} shard {} priority
queue holds \
+ {pending_redispatch} frame(s) at quiescence (seed {:#x},
schedule hash {:#x})",
+ shard.id,
+ self.seed,
+ self.executor.schedule_hash(),
+ );
}
}
}
@@ -2432,11 +2443,10 @@ mod tests {
/// behind VSR retransmit.
///
/// `park_overflow` is deliberately NOT excluded. The reconciler is
unwired here
- /// (`init_partition` mirrors its outcome directly), so nothing drains the
park
- /// buffer and a parked frame is never re-dispatched or swept. Non-zero
- /// `park_overflow` therefore means frames shed for a namespace that will
never
- /// materialise, which is the fault class this catches rather than the
- /// back-pressure it would be in production.
+ /// and only an explicit `init_partition` mirrors its materialisation
outcome and
+ /// drains the matching park entry. Non-zero `park_overflow` therefore
means
+ /// frames shed for a namespace the driver never materialised, which is
the fault
+ /// class this catches rather than the back-pressure it would be in
production.
///
/// Same reason the park buffer must be empty at quiescence: a frame still
parked
/// has no drainer, so it is neither delivered nor answered.
@@ -2451,7 +2461,8 @@ mod tests {
assert!(
shard.parked_namespaces().is_empty(),
"replica {replica_idx} shard {shard_idx} left partition
frames parked; the \
- simulator wires no reconciler, so nothing will deliver or
answer them"
+ simulator wires no reconciler, so only explicit
materialisation can \
+ deliver or answer them"
);
}
}
@@ -2539,6 +2550,171 @@ mod tests {
);
}
+ fn successful_send_reply_count(replies: &[Message<ReplyHeader>]) -> usize {
+ replies
+ .iter()
+ .filter(|reply| {
+ reply.header().operation ==
iggy_binary_protocol::Operation::SendMessages
+ && reply.header().status == 0
+ })
+ .count()
+ }
+
+ fn retained_prepare(
+ sim: &Simulator,
+ replica: usize,
+ namespace: IggyNamespace,
+ op: u64,
+ ) -> Message<iggy_binary_protocol::PrepareHeader> {
+ let partitions = sim.replicas[replica]
+ .partition_shard(namespace)
+ .plane
+ .partitions();
+ let partition = partitions.get_by_ns(&namespace).expect("partition");
+ let stored = partition
+ .log
+ .journal()
+ .inner
+ .repair_entry(op)
+ .unwrap_or_else(|| panic!("replica {replica} retains prepare op
{op} for repair"));
+
Message::<iggy_binary_protocol::PrepareHeader>::try_from(server_common::iobuf::Owned::<
+ { server_common::MESSAGE_ALIGN },
+ >::copy_from_slice(
+ stored.as_slice()
+ ))
+ .expect("journaled prepare remains a valid frame")
+ }
+
+ /// Leave one backup without the partition while the primary commits two
+ /// sends through the other backup. The lagging replica parks both
replicated
+ /// prepares. A third prepare is withheld from it, then placed on its inbox
+ /// after simulator materialisation stages the parked prefix. Running the
pump
+ /// without advancing its tick forces the inbox arm to drain the prefix
first.
+ fn parked_prepare_redispatch_trace(seed: u64) -> (usize,
Vec<PartitionOffsets>, u64) {
+ const CLIENT_ID: u128 = 1;
+
+
server_common::MemoryPool::init_pool(&server_common::MemoryPoolSettings {
+ enabled: false,
+ size: iggy_common::IggyByteSize::from(0u64),
+ bucket_capacity: 1,
+ });
+
+ let network_opts = packet::PacketSimulatorOptions {
+ node_count: 3,
+ client_count: 1,
+ seed,
+ ..packet::PacketSimulatorOptions::default()
+ };
+ let mut sim = Simulator::with_shards_shell(3, 1,
std::iter::once(CLIENT_ID), network_opts);
+ let namespace = IggyNamespace::new(0, 0, 0);
+ sim.seed_stream_topic_partition(namespace);
+ let created_view = sim.partition_created_views[&namespace];
+
+ // Replica 0 is the view-0 primary. Replica 2 supplies quorum while
+ // replica 1 has committed metadata but no local partition yet.
+ materialise_partition(&sim.replicas[0], namespace, false,
created_view);
+ materialise_partition(&sim.replicas[2], namespace, false,
created_view);
+
+ let client = SimClient::new(CLIENT_ID);
+ sim.shell_login(&client);
+ for payload in [
+ Bytes::from_static(b"parked-redispatch-0"),
+ Bytes::from_static(b"parked-redispatch-1"),
+ ] {
+ let request = client.send_messages(namespace,
std::slice::from_ref(&payload));
+ sim.submit_request(CLIENT_ID, 0, request.into_generic());
+ }
+
+ let lagging_shard = Rc::clone(&sim.replicas[1].shards[0]);
+ let mut successful_replies = 0usize;
+ let mut parked = 0usize;
+ for _ in 0..500 {
+ successful_replies += successful_send_reply_count(&sim.step());
+ parked = lagging_shard.parked_frame_count(namespace);
+ if successful_replies >= 2 && parked >= 2 {
+ break;
+ }
+ }
+ assert!(
+ successful_replies >= 2,
+ "primary never committed both sends"
+ );
+ assert!(parked >= 2, "lagging backup never parked both prepares");
+
+ // Keep the third prepare off replica 1's network path. Replica 0 can
+ // still commit it through replica 2, after which its journal supplies
+ // the exact wire frame to put on the lagging backup's inbox below.
+ sim.network.process_disable(ProcessId::Replica(1));
+ let third = client.send_messages(namespace,
&[Bytes::from_static(b"parked-redispatch-2")]);
+ sim.submit_request(CLIENT_ID, 0, third.into_generic());
+ for _ in 0..500 {
+ successful_replies += successful_send_reply_count(&sim.step());
+ if successful_replies >= 3 {
+ break;
+ }
+ }
+ assert!(
+ successful_replies >= 3,
+ "primary never committed the later send"
+ );
+ let later_prepare = retained_prepare(&sim, 0, namespace, 3);
+ sim.network.process_enable(ProcessId::Replica(1));
+
+ materialise_partition(&sim.replicas[1], namespace, false,
created_view);
+ assert_eq!(lagging_shard.parked_frame_count(namespace), 0);
+ assert_eq!(
+ lagging_shard.redispatched_frame_count(),
+ parked,
+ "materialisation must move every parked prepare to the pump queue"
+ );
+
+ // No virtual-time advance here. The inbox wake alone polls the pump,
+ // so only the inbox arm's queue-first fence can put ops 1 and 2 ahead
+ // of this op 3 frame.
+ lagging_shard.dispatch(later_prepare.into_generic());
+ sim.run_pumps();
+ let lagging_holds_later = sim.replicas[1]
+ .partition_shard(namespace)
+ .plane
+ .partitions()
+ .get_by_ns(&namespace)
+ .is_some_and(|partition|
partition.log.journal().inner.header_by_op(3).is_some());
+ assert!(
+ lagging_holds_later,
+ "inbox op 3 reached the gap check before the redispatched prefix"
+ );
+
+ let expected = sim
+ .offsets(0, namespace)
+ .expect("primary partition offsets");
+ let mut converged = false;
+ for _ in 0..500 {
+ sim.step();
+ converged = (0..3).all(|replica| sim.offsets(replica, namespace)
== Some(expected));
+ if converged {
+ break;
+ }
+ }
+ assert!(
+ converged,
+ "redispatched prepares did not converge the backup"
+ );
+ assert_eq!(lagging_shard.redispatched_frame_count(), 0);
+ assert_no_frame_drops(&sim);
+
+ let offsets = (0..3)
+ .map(|replica| sim.offsets(replica, namespace).expect("partition
offsets"))
+ .collect();
+ (parked, offsets, sim.schedule_hash())
+ }
+
+ #[test]
+ fn parked_prepare_redispatch_is_seed_replayable() {
+ let first = parked_prepare_redispatch_trace(0x5CED_4005);
+ let second = parked_prepare_redispatch_trace(0x5CED_4005);
+ assert_eq!(first, second, "same seed diverged on parked redispatch");
+ }
+
/// The dispatch shell's reason to exist: detect the PR #3557
async-concurrency
/// class, a partition reference held across an `.await` while a sibling
task
/// mutates the partitions vec. Under the deterministic executor a parked
read with