blob: 869dc6185725a31a4b6d81274246fd78fd410955 [file]
// 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.
use crate::bus::{SharedSimOutbox, SimOutbox};
use crate::deps::SimSuperblock;
use crate::deps::{MemStorage, SimJournal, SimMuxStateMachine, SimSnapshot};
use configs::server::PersonalAccessTokenConfig;
use configs::server::ServerSystemConfig;
use consensus::{ClientTable, ConsensusClock, LocalPipeline, Sequencer, VsrConsensus, VsrState};
use iggy_common::IggyByteSize;
use iggy_common::variadic;
use journal::Journal;
use metadata::impls::metadata::IggySnapshot;
use metadata::impls::metadata::StreamsFrontend;
use metadata::stm::mux::WithFactory;
use metadata::stm::snapshot::RestoreSnapshot;
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::boot::wire_shell_handlers;
use server::shell::{ShellHandlers, ShellShardHandle};
use server_common::crypto;
use server_common::sharding::{METADATA_GROUP, ShardId};
use shard::shards_table::PapayaShardsTable;
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
// TODO: Make configurable
const CLUSTER_ID: u128 = 1;
/// Deterministic root credentials the dispatch-shell `SimClient` logs in with.
///
/// Fixed (never env-derived) so the login handshake replays with the seed.
/// Both satisfy the 3..=50 / 3..=100 username/password length bounds
/// `verify_login_credentials` enforces.
pub const SHELL_ROOT_USERNAME: &str = "iggy";
pub const SHELL_ROOT_PASSWORD: &str = "iggy";
// For now there is only one shard per replica,
// we will add support for multiple shards per replica in the future.
//
// `PapayaShardsTable` (the production namespace -> shard routing table)
// instead of the always-`None` `()` impl: each shard owns its own table
// instance, exactly as the server wires it. Until rows are seeded the
// router falls back to the deterministic hash assignment, which at one
// shard per replica always resolves to shard 0.
pub type Replica = shard::IggyShard<
SharedSimOutbox,
// MJ: metadata journal, behind an `Rc` so the harness retains it across a
// replica restart; the WAL bytes and index survive the drop.
Rc<SimJournal<MemStorage>>,
SimSnapshot,
SimMuxStateMachine,
PapayaShardsTable,
SimSuperblock,
>;
/// Read-side handoff bundle for the metadata STM.
///
/// Shard 0 (the sole writer) mints one via `factory_bundle`; every peer shard
/// rebuilds a reader-mode mirror from it with `from_factory_bundle`, exactly as
/// the server bootstrap does with its `ServerMetadataBundle`.
/// `Clone + Send + Sync`.
pub type SimMetadataBundle = <variadic!(Users, Streams) as WithFactory>::Bundle;
/// Capacity of each shard inbox in the simulator.
///
/// The router drops frames when an inbox is full (recovered by VSR
/// retransmit in production); sized generously so no drop can happen
/// unless a test injects one on purpose. Tests assert the frame-drop
/// metrics stay zero.
pub const SIM_INBOX_CAPACITY: usize = 8192;
/// Read this replica's persisted metadata checkpoint and its trailer checksum.
///
/// A file that exists but does not load panics rather than reading as `None`:
/// production refuses boot on a torn snapshot, and booting empty instead would turn
/// a storage fault into the silent state loss this path exists to prevent.
///
/// # Panics
/// If `snapshot.bin` exists but cannot be read or decoded.
fn load_local_checkpoint(data_dir: &std::path::Path) -> Option<(IggySnapshot, u128)> {
let path = data_dir
.join(metadata::impls::METADATA_DIR)
.join(metadata::impls::SNAPSHOT_FILE_NAME);
if !path.exists() {
return None;
}
let loaded = IggySnapshot::load(&path).unwrap_or_else(|error| {
panic!(
"metadata checkpoint at {} exists but does not load: {error}",
path.display(),
)
});
Some(loaded)
}
/// Build one shard of a sim replica around the real inter-shard mesh.
///
/// `senders`/`inbox` come from [`shard::shard_mesh_channels`], so the
/// shard routes through the same `dispatch` -> inbox -> pump path as
/// production instead of the old `without_inbox` bypass.
///
/// `shell` selects the dispatch handlers. Off is the fast path: inert
/// no-ops, so the simulator drives raw client frames straight into
/// `IggyShard::on_message`. On wires the server's real deferred dispatch
/// handlers (via [`wire_shell_handlers`]), exactly as production does, so
/// a client request runs as a task concurrent with the pump.
///
/// Mirrors the server bootstrap's single-writer metadata: the consensus
/// group, journal, snapshot, and the only writable metadata STM live on
/// shard 0. Shard 0 mints a [`SimMetadataBundle`] (returned as the second
/// tuple element); every peer shard passes it back in as `reader_bundle`
/// and rebuilds a reader-mode STM that observes shard 0's committed
/// metadata through the shared left-right read handle. So a partition op
/// homing on a peer shard resolves its namespace against consensus-committed
/// streams, rather than a stale per-shard copy. `reader_bundle` is `None`
/// for shard 0 and `Some` for every peer; the return bundle is `Some` only
/// for shard 0.
///
/// # Panics
/// Panics if `senders` is not in canonical order (a bug in the caller's
/// mesh construction), or if `reader_bundle` disagrees with `shard_idx`
/// (peer without a bundle, or shard 0 with one).
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
pub fn new_shard(
replica_id: u8,
shard_idx: u16,
name: String,
bus: &Rc<SimOutbox>,
replica_count: u8,
senders: Vec<shard::TaggedSender>,
inbox: shard::Receiver<shard::ShardFrame>,
reply_inbox: shard::Receiver<shard::ShardFrame>,
clock: ConsensusClock,
shell: bool,
reader_bundle: Option<SimMetadataBundle>,
superblock: Option<Rc<SimSuperblock>>,
metadata_journal: Option<Rc<SimJournal<MemStorage>>>,
recovered_state: Option<VsrState>,
incarnation: u128,
data_dir: Option<std::path::PathBuf>,
seed_namespaces: &[(server_common::sharding::IggyNamespace, u32)],
) -> (Rc<Replica>, Option<SimMetadataBundle>) {
// Metadata is single-writer, mirroring the server bootstrap. Shard 0 owns
// the only writable STM; every peer shard rebuilds a reader-mode mirror from
// shard 0's factory bundle and sees committed metadata through the shared
// left-right read handle (each apply `publish`es, bounding reader staleness
// to one op). Writes never target a peer, so peers seed nothing locally;
// `reader_bundle` is `Some` exactly for them.
debug_assert_eq!(
shard_idx == 0,
reader_bundle.is_none(),
"shard 0 is the metadata writer (no bundle); peers are readers (bundle)"
);
// Head of the retained metadata WAL, shard 0 only and `None` on a peer shard or a
// fresh boot. Its committed prefix drives both the STM replay below and the
// consensus op/commit restore, so a restart recovers committed metadata from its
// own disk instead of an empty state.
let solo = replica_count == 1;
let restored_op = metadata_journal
.as_ref()
.and_then(|journal| journal.last_op());
// Read before the state machine is built: it REPLACES the seeded default rather
// than layering onto it, so without it a checkpointed replica recovers an empty
// baseline and loses every op the drain reclaimed.
let local_checkpoint = data_dir
.as_deref()
.filter(|_| shard_idx == 0)
.and_then(load_local_checkpoint);
let has_checkpoint = local_checkpoint.is_some();
// Off by one from the journal's watermark, deliberately: a checkpoint at op N
// drains `0..=N - 1` and retains N's header for view-change merging, so replaying
// from the journal floor would re-apply N on top of a snapshot that has it.
let checkpoint_seq = local_checkpoint
.as_ref()
.map_or(0, |(snapshot, _)| snapshot.snapshot().sequence_number);
let mux = reader_bundle.map_or_else(
// Writer shard (shard 0). Seed the root user at slab id 0, matching
// production bootstrap (`ensure_root_user`); it is undeletable in-apply,
// so it stays in slot 0 and the workload's users land at slab id 1+, out
// of any delete attempt. Peer shards inherit it through the read handle,
// so the seed runs only here. Shell mode logs a client in against this
// user (the login gate Argon2-checks the stored hash), so seed a real
// hash of `SHELL_ROOT_PASSWORD`; otherwise a deterministic placeholder,
// since login never runs and a random per-run argon2 salt would break
// state equality. Committed metadata replays into it from the retained WAL
// after `IggyMetadata` is built (see below), so the factory bundle is minted
// only then.
|| {
// Seeding on top of a restored checkpoint would mint a second root user
// and shift every workload entity's slab id.
if let Some((snapshot, _)) = local_checkpoint.as_ref() {
return SimMuxStateMachine::restore_snapshot(snapshot.snapshot())
.expect("the local checkpoint decodes into the metadata state machine");
}
let users: Users = UsersInner::new().into();
let root_password_hash = if shell {
crypto::hash_with_fixed_salt(SHELL_ROOT_PASSWORD)
} else {
"hash".to_string()
};
users.ensure_root_user(SHELL_ROOT_USERNAME, &root_password_hash);
let streams: Streams = StreamsInner::new().into();
SimMuxStateMachine::new(variadic!(users, streams))
},
// Peer shard: reader-mode mirror over shard 0's shared read handle.
SimMuxStateMachine::from_factory_bundle,
);
// The production metadata consensus namespace, not 0: the router
// routes Register/Logout and metadata view-change frames to shard 0
// by comparing against this exact value; anything else hashes to an
// arbitrary shard with no metadata consensus.
let metadata_consensus = (shard_idx == 0).then(|| {
let mut consensus = VsrConsensus::with_clock(
CLUSTER_ID,
replica_id,
replica_count,
METADATA_GROUP,
SharedSimOutbox(Rc::clone(bus)),
LocalPipeline::new(),
clock.clone(),
);
// Seed-derived incarnation, bumped per restart by the harness, so a StartView
// from a previous incarnation is ignored while replay stays deterministic; an
// OS-random nonce would break byte-identical replay.
consensus.set_incarnation(incarnation);
// View/log_view come from the durable superblock; op, commit, and the
// last-prepare markers come from the retained WAL. Independent inputs,
// mirroring the server's restore_metadata_consensus.
let last_header = metadata_journal
.as_ref()
.and_then(|journal| journal.last_header());
if let Some(state) = recovered_state {
consensus.set_view(state.view);
consensus.set_log_view(state.log_view);
consensus.mark_superblock_durable(state.view, state.log_view);
} else if let Some(header) = last_header {
// No superblock: a fresh node, one that never changed view, or the
// first boot after an upgrade. Production infers the view from the
// last WAL prepare here, so mirror it -- otherwise the branch every
// existing deployment takes has no sim coverage.
consensus.set_view(header.view);
}
// Prior life is EITHER a non-empty WAL or a recovered superblock: a view
// change persists without touching the WAL, so an empty journal no longer
// proves a fresh boot. A clustered replica with a prior life rejoins as a
// quorum-invisible probing backup, since the live primary re-forms its view
// and re-commits any uncommitted suffix; a solo replica has no peer to ask,
// so it inits and resumes as its own primary. Matches
// restore_metadata_consensus exactly. Production's primary-only re-pipeline
// of an uncommitted suffix is skipped, since a rejoining replica is always a
// backup.
let restored_head = restored_op.unwrap_or(0);
if !solo && (restored_head > 0 || recovered_state.is_some() || has_checkpoint) {
consensus.init_as_backup();
consensus.begin_view_probe();
} else {
consensus.init();
}
if let (Some(journal), Some(head)) = (metadata_journal.as_ref(), restored_op) {
let commit_watermark = journal.recovery_commit_watermark(solo).max(checkpoint_seq);
consensus.sequencer().set_sequence(head);
consensus.restore_commit_state(commit_watermark, commit_watermark);
if let Some(header) = last_header {
consensus.set_last_prepare_checksum(header.checksum);
consensus.observe_prepare_timestamp(header.timestamp);
}
// The suffix past the committed watermark is prepared but not proven
// committed, so gate reads until the cluster re-commits it, as production
// does. Solo has no such suffix: the head IS the commit point.
if commit_watermark < head {
consensus.set_recovery_barrier(head);
}
}
consensus
});
let metadata_snapshot = (shard_idx == 0).then(SimSnapshot::default);
// A data directory arms the `SnapshotCoordinator`; without one
// `checkpoint_if_needed` returns immediately and nothing ever checkpoints.
let metadata = IggyMetadata::new(
metadata_consensus,
metadata_journal,
metadata_snapshot,
superblock,
mux,
data_dir,
);
// Both halves are load-bearing: the pairing keeps a later view-change superblock
// write from regressing to `(0, 0)`, and the folded table is the floor the replayed
// suffix advances, so a session below the drained prefix keeps its watermark.
if let Some((snapshot, checksum)) = local_checkpoint.as_ref() {
metadata.seed_checkpoint_ref(snapshot.snapshot().sequence_number, *checksum);
if let Some(table) = snapshot.snapshot().client_table.clone() {
let capacity = metadata.client_table_capacity();
let restored = ClientTable::from_snapshot(table, capacity)
.expect("the local checkpoint's client table decodes");
assert!(
metadata.install_client_table(restored),
"a freshly built plane holds no sessions, so this must install"
);
}
}
// BEFORE the replay, matching a fresh boot: `CreateStream::apply` takes
// `vacant_key()`, so seeding after would put a restarted replica's `wl-*` streams
// below its fillers, the reverse of every peer. Idempotent, so
// `materialise_partition`'s own seed is then a no-op.
if shard_idx == 0 {
let streams = metadata.mux_stm.streams();
for &(namespace, created_view) in seed_namespaces {
streams.seed_namespace(namespace, namespace.inner(), created_view);
}
}
// Reconstruct shard 0's committed metadata from the retained WAL, the sim analog
// of production's snapshot + WAL replay. `apply_committed_prepare` is the SAME
// apply path the commit walk uses, so it rebuilds BOTH the state machine and the
// client table (sessions + at-most-once dedup), which a restart would otherwise
// reset. The consensus setup above already restored `commit_min` to the committed
// watermark; this only rebuilds derived state, and does nothing on a fresh boot.
// The commit notifier is unwired during recovery, hence the no-op hook.
if let Some(journal) = metadata.journal.as_ref() {
let floor = journal.snapshot_op().max(checkpoint_seq);
let commit_watermark = journal.recovery_commit_watermark(solo).max(checkpoint_seq);
for op in (floor + 1)..=commit_watermark {
let Some(entry) = journal.entry_sync(op) else {
// Applying across a hole would replay effects onto a state machine
// that never saw the missing op; the suffix is left for VSR repair.
tracing::warn!(
replica_id,
op,
floor,
commit_watermark,
"metadata WAL has no entry at this committed op; stopping replay"
);
break;
};
// `true`: the frontier is volatile, so a rebuilt plane starts at zero and
// every op above the floor must mutate the table. A frontier only fences a
// LIVE table a state transfer just replaced.
apply_committed_prepare(
&metadata.mux_stm,
&metadata.client_table,
true,
|_| {},
entry,
);
}
}
// Mint the peers' read-side bundle AFTER reconstruction so it reflects the
// recovered state. Shard 0 only; peers pass it back in as `reader_bundle`.
let metadata_bundle = (shard_idx == 0).then(|| metadata.mux_stm.factory_bundle());
let partitions_config = PartitionsConfig {
messages_required_to_save: 1000,
size_of_messages_required_to_save: IggyByteSize::from(4 * 1024 * 1024),
enforce_fsync: false, //Disable fsync for simulation
validate_checksum: true,
segment_size: IggyByteSize::from(1024 * 1024 * 1024),
preallocate_segments: false,
encryptor: None,
path_layout: PartitionPathLayout::default(),
};
// Shard id is the NODE-LOCAL shard index, never the replica id: the
// router stamps `target_shard` with the mesh index and
// `accept_frame_for_self` drops any frame whose stamp differs from
// `IggyShard::id`. Replica identity lives in `name` and in the VSR
// ids carried by `PartitionConsensusConfig`.
let partitions = IggyPartitions::new(ShardId::new(shard_idx), partitions_config);
// The deferred handlers upgrade this weak self-reference per frame; it
// stays `None` until the shard is built and downgraded into it below.
let shard_handle: ShellShardHandle<
SharedSimOutbox,
Rc<SimJournal<MemStorage>>,
SimSnapshot,
SimSuperblock,
> = Rc::new(RefCell::new(None));
let ShellHandlers {
on_replica_message,
on_client_request,
on_metadata_submit,
on_list_clients,
on_partition_read,
// Step 6 keeps this to register client sessions; unused shell-off.
sessions: _,
} = if shell {
wire_shell_handlers(
&SharedSimOutbox(Rc::clone(bus)),
&shard_handle,
Arc::new(ServerSystemConfig::default()),
// Default-config PAT cap, like the system config above, so sim
// ingress admits exactly what a default-configured server does.
PersonalAccessTokenConfig::default().max_tokens_per_user,
)
} else {
ShellHandlers::noop()
};
let shard = Rc::new(
shard::IggyShard::new(
shard::ShardIdentity::new(shard_idx, name),
SharedSimOutbox(Rc::clone(bus)),
on_replica_message,
on_client_request,
on_metadata_submit,
on_list_clients,
on_partition_read,
metadata,
partitions,
senders,
inbox,
reply_inbox,
PapayaShardsTable::new(),
shard::PartitionConsensusConfig::with_clock(
CLUSTER_ID,
shard::ReplicaTopology::new(replica_id, replica_count),
SharedSimOutbox(Rc::clone(bus)),
clock,
),
None,
shard::metrics::ShardMetrics::for_shard(),
)
.expect("sim mesh senders are built in canonical order"),
);
// Late-bind the deferred handlers' self-reference. Harmless shell-off:
// the no-ops never upgrade it.
*shard_handle.borrow_mut() = Some(Rc::downgrade(&shard));
(shard, metadata_bundle)
}