Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ jobs:
- fts_query_parse
- mq_wal_record
- ws_registry_record
- mq_registry_blob
- redis_rdb_load
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@nightly
Expand Down Expand Up @@ -90,6 +92,8 @@ jobs:
- fts_query_parse
- mq_wal_record
- ws_registry_record
- mq_registry_blob
- redis_rdb_load
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@nightly
Expand Down
59 changes: 55 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,67 @@ on the multi-shard master path) is installed authoritatively at
`load_snapshot`, same convention as the graph/vector/text aux blobs. New
`ws_registry_record` cargo-fuzz target covers the blob decoder.

Split the former combined `warn_unreplicated_plane` fail-loud marker
(`handler_monoio/ft.rs`) into per-plane functions — the WS half is retired
now that this plane replicates; `warn_mq_unreplicated` survives for MQ,
which is not yet wired in.
The former combined `warn_unreplicated_plane` fail-loud marker
(`handler_monoio/ft.rs`) is fully retired: its WS half by this WS-plane
work, and its MQ half by the MQ-plane replication entry below.

New `tests/replication_ws.rs`: live-stream parity (id + `created_at`
round-trip through `WS.INFO`/`WS.AUTH` on the replica), snapshot-leg
backfill, `WS.DROP` propagation, and a `--shards 4` leg exercising the
shard-0 hop from connections that may land on any shard.

### Added — MQ-plane replication (Wave B stage 2b)

Durable-queue MQ.* mutations now replicate to attached replicas, closing the
plane gap `warn_unreplicated_plane` previously fail-loud-warned about for
MQ.*. Builds on the stage-2a MQ WAL effect records (PR #291):

- **Live stream**: `shard::mq_exec::replicate_mq_record` emits the SAME
encoded `MqCreate`/`MqPush`/`MqPop`/`MqAck`/`MqTrigger` payload bytes
already built for the WAL as one of five synthetic `MQ._REPL.*`
pseudo-commands (never dispatched to real clients), gated single-shard-only
(`num_shards == 1`) — the same posture graph's own live path takes for its
multi-shard gap. `MQ.PUBLISH`'s TXN-materialization leg
(`server::conn::handler_monoio::txn.rs`) replicates the same way at
commit. `replication::apply::apply_mq` decodes and applies through the
SAME `apply_mq_*` engine boot-time WAL replay uses
(`shard::shared_databases`, generalized over a new `MqApplyTarget` trait so
`ShardSliceInit` boot replay and `ShardSlice` live apply share one codec
instead of two).
- **FULLRESYNC**: a per-shard `MOON_AUX_MQ_REGISTRY` aux blob
(`replication::mq_sync`) carries the durable-queue registry and trigger
registry — the shard-level bookkeeping that lives outside the keyspace.
Installed additively into every replica shard
(`mq_sync::install_mq_registry_many`), mirroring
`graph_sync::install_graph_store_many`.
- **Fixed a pre-existing FULLRESYNC data-loss bug found along the way**: the
PSYNC RDB codec (`persistence::redis_rdb`, distinct from the
`persistence::rdb` SAVE/BGSAVE codec) serialized `Stream` values as a
placeholder `"__stream__:<len>"` string, discarding all entries/PEL/
consumer-group state. A full `RDB_TYPE_STREAM_MOON` (0xC8) codec — ported
from `persistence::rdb`'s battle-tested Stream serializer — now carries
entries, `last_id`, consumer groups (PEL + per-consumer pending), the
`durable` flag, and `max_delivery_count`. Without this fix, MQ FULLRESYNC
backfill would silently lose every durable queue's message content.
**Operator note — upgrade masters and replicas together** if any durable
Streams exist: a pre-fix replica syncing from a fixed master hard-fails
FULLRESYNC on the new `RDB_TYPE_STREAM_MOON` tag (and retries on its
reconnect loop until upgraded), while a fixed replica syncing from a
pre-fix master absorbs the old placeholder as a corrupted string value —
both inherent to fixing a wire-format bug, neither loses data already
durable in the master's WAL.
- Two new `cargo-fuzz` targets: `mq_registry_blob` (the new
`install_mq_registry_many` decoder) and `redis_rdb_load` (the FULLRESYNC
RDB loader as a whole, including the new Stream type tag) — wired into
both PR and nightly fuzz matrices.
- `tests/replication_mq.rs`: live-stream, snapshot-backfill, and a pinned
multi-shard-live-write-not-streamed limitation test (shards=1 scope,
matching graph's own precedent).

A multi-shard master still does not live-stream MQ writes (durability is
unaffected — WAL + FULLRESYNC still cover it); tracked as a known follow-up
alongside graph's own multi-shard live-stream gap.

### Fixed — docs site strict build red since 2026-07-08 (root-relative links)

`docs/guides/tuning.md` (PR #245) and `docs/PRODUCTION-CONTRACT.md` (PR #263)
Expand Down
10 changes: 10 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,13 @@ doc = false
name = "ws_registry_record"
path = "fuzz_targets/ws_registry_record.rs"
doc = false

[[bin]]
name = "mq_registry_blob"
path = "fuzz_targets/mq_registry_blob.rs"
doc = false

[[bin]]
name = "redis_rdb_load"
path = "fuzz_targets/redis_rdb_load.rs"
doc = false
69 changes: 69 additions & 0 deletions fuzz/fuzz_targets/mq_registry_blob.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#![no_main]
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;

use libfuzzer_sys::fuzz_target;
use moon::replication::mq_sync::install_mq_registry_many;
use moon::shard::shared_databases::ShardStoreMemory;
use moon::shard::slice::{ShardSlice, ShardSliceInit, init_shard, with_shard};
use moon::storage::db::Database;
use moon::text::store::TextStore;
use moon::transaction::{DeferredHnswInserts, KvWriteIntents};
use moon::vector::store::VectorStore;

/// Fuzz the Wave B stage 2b MQ-registry FULLRESYNC blob decoder
/// (`replication::mq_sync::install_mq_registry_many`), mirroring
/// `mq_wal_record.rs`'s coverage of the sibling WAL op-blob decoders.
///
/// `data` arrives on the replica exactly like `apply::load_snapshot` feeds
/// it: bytes read straight off a `MOON_AUX_MQ_REGISTRY` RDB aux field sent
/// by a master over the wire — attacker/corruption-controlled (a malicious
/// or buggy master, a torn transfer, a version skew). The decoder
/// (`mq_sync::Cursor` + `install_one`) is documented to return `None` on
/// ANY malformed input, truncated blob, or unknown version byte — never
/// panic, never read out of bounds, never allocate unboundedly off an
/// attacker length prefix (`len_checked` bounds every prefix against the
/// remaining blob before `Bytes::copy_from_slice`).
///
/// `ShardSlice` state lives in a `thread_local!`, so — mirroring
/// `mq_sync.rs`'s own unit tests — each fuzz iteration installs into a
/// fresh `ShardSlice` on a throwaway OS thread.
fuzz_target!(|data: &[u8]| {
let data = data.to_vec();
let _ = std::thread::spawn(move || {
init_shard(make_fuzz_slice());
with_shard(|s: &mut ShardSlice| {
// Should not panic regardless of input.
let _ = install_mq_registry_many(s, &[data]);
});
})
.join();
});

fn make_fuzz_slice() -> ShardSlice {
let databases: Box<[Database]> = (0..1).map(|_| Database::new()).collect();
ShardSlice::new(ShardSliceInit {
shard_id: 0,
databases,
vector_store: VectorStore::new(),
text_store: TextStore::new(),
// moon-fuzz always builds `moon` with the "graph" feature (see
// fuzz/Cargo.toml), so this field is unconditional here even
// though it's `#[cfg(feature = "graph")]` inside moon itself.
graph_store: moon::graph::store::GraphStore::new(),
kv_write_intents: KvWriteIntents::new(),
deferred_hnsw_inserts: DeferredHnswInserts::new(),
temporal_registry: None,
temporal_kv_index: None,
durable_queue_registry: None,
trigger_registry: None,
wal_append_tx: None,
estimated_memory: Arc::new(AtomicUsize::new(0)),
store_memory: Arc::new(ShardStoreMemory {
vector: AtomicUsize::new(0),
text: AtomicUsize::new(0),
graph: AtomicUsize::new(0),
lua: AtomicUsize::new(0),
}),
})
}
22 changes: 22 additions & 0 deletions fuzz/fuzz_targets/redis_rdb_load.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#![no_main]
use libfuzzer_sys::fuzz_target;

/// Fuzz the Redis-compatible RDB loader used by PSYNC FULLRESYNC
/// (`persistence::redis_rdb::load_rdb`), distinct from the SAVE/BGSAVE
/// codec covered by `rdb_load.rs` (`persistence::rdb::load`).
///
/// This is the codec `replication::master` streams to a replica and
/// `replication::apply::load_snapshot` decodes on the replica side; a
/// malicious or corrupted master (or a bit-flipped wire transfer) must
/// never panic the replica. Exercises magic/version validation, type-tag
/// dispatch (including the private `RDB_TYPE_STREAM_MOON` extension added
/// for Wave B stage 2b MQ replication), length-prefixed collection bounds
/// checks (`check_alloc_bound`), and CRC/EOF handling — operates directly
/// on the in-memory byte slice (no temp file needed, unlike `rdb_load.rs`).
fuzz_target!(|data: &[u8]| {
let mut databases: Vec<moon::storage::db::Database> =
(0..1).map(|_| moon::storage::db::Database::new()).collect();

// Should not panic regardless of input.
let _ = moon::persistence::redis_rdb::load_rdb(&mut databases, data);
});
12 changes: 6 additions & 6 deletions src/command/mq.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,12 @@ pub fn parse_mq_subcommand(args: &[Frame]) -> Result<&[u8], Frame> {
///
/// `MQ` is registered blanket-WRITE in `command::metadata::COMMAND_META`
/// (CREATE/PUSH/POP/ACK/TRIGGER/PUBLISH all mutate durable queue state), but
/// DLQLEN only queries the dead-letter depth (same condition
/// `try_handle_mq_command` already uses to skip `warn_unreplicated_plane`).
/// A plain `MQ LEN` is referenced in that same skip-list but has no handler
/// arm in `try_handle_mq_command` today (falls through to
/// `ERR_MQ_UNKNOWN_SUB`) — it is included here defensively so that if it is
/// ever wired up as a read, this classifier does not need a second change.
/// DLQLEN only queries the dead-letter depth (the retired
/// `warn_unreplicated_plane` marker skipped it for the same reason).
/// A plain `MQ LEN` has no handler arm in `try_handle_mq_command` today
/// (falls through to `ERR_MQ_UNKNOWN_SUB`) — it is included here
/// defensively so that if it is ever wired up as a read, this classifier
/// does not need a second change.
/// Any subcommand not in this allow-list (including unknown ones) is
/// conservatively treated as a write so `try_enforce_readonly` never
/// false-negatives.
Expand Down
23 changes: 23 additions & 0 deletions src/mq/trigger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ impl TriggerRegistry {
self.entries.get_mut(key)
}

/// Iterate over all entries (composite key, entry). Used by
/// `replication::mq_sync::export_mq_registry` (Wave B stage 2b) to
/// snapshot every registered trigger for FULLRESYNC.
pub fn iter(&self) -> impl Iterator<Item = (&Bytes, &TriggerEntry)> {
self.entries.iter()
}

/// Return keys of entries whose pending fire time has elapsed.
///
/// An entry is ready to fire when `pending_fire_ms > 0` and
Expand Down Expand Up @@ -306,4 +313,20 @@ mod tests {

assert_eq!(reg.get(b"ws1:q1").unwrap().debounce_ms, 2000);
}

#[test]
fn test_iter() {
let mut reg = TriggerRegistry::new();
reg.register(
Bytes::from_static(b"ws1:q1"),
make_entry(b"q1", b"CMD1", 100),
);
reg.register(
Bytes::from_static(b"ws1:q2"),
make_entry(b"q2", b"CMD2", 200),
);
let mut keys: Vec<&[u8]> = reg.iter().map(|(k, _)| k.as_ref()).collect();
keys.sort();
assert_eq!(keys, vec![&b"ws1:q1"[..], &b"ws1:q2"[..]]);
}
}
53 changes: 53 additions & 0 deletions src/mq/wal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -400,10 +400,63 @@ pub fn decode_mq_trigger(payload: &[u8]) -> Option<(Vec<u8>, Vec<u8>, Vec<u8>, u
Some((trig_key, queue_key, callback_cmd, debounce_ms))
}

// ── Replication wire framing (Wave B stage 2b) ────────────────────────────
//
// MQ effect records are versioned BINARY payloads, not RESP commands (unlike
// GRAPH.*/TEMPORAL.INVALIDATE-AT, which stream deterministic user-syntax
// commands). Replication wraps each one in a synthetic pseudo-command that
// carries the SAME `encode_mq_*` payload verbatim as a single bulk-string
// arg: `MQ._REPL.PUSH <payload bytes>`. These names are NEVER dispatched to
// a real client (no `CommandMeta`/ACL entry, no overlap with
// `is_mq_command`'s `"MQ"` check) — they are matched only by
// `replication::apply::apply_local`'s command-name routing, exactly like
// `GraphReplayCollector::is_graph_command`, and decoded with the SAME
// `decode_mq_*` functions above so the replica's apply arm and boot-time WAL
// replay share one codec.

/// Synthetic replication pseudo-command for [`WalRecordType::MqCreate`].
pub const MQ_REPL_CREATE: &[u8] = b"MQ._REPL.CREATE";
/// Synthetic replication pseudo-command for [`WalRecordType::MqPush`].
pub const MQ_REPL_PUSH: &[u8] = b"MQ._REPL.PUSH";
/// Synthetic replication pseudo-command for [`WalRecordType::MqPop`].
pub const MQ_REPL_POP: &[u8] = b"MQ._REPL.POP";
/// Synthetic replication pseudo-command for [`WalRecordType::MqAck`].
pub const MQ_REPL_ACK: &[u8] = b"MQ._REPL.ACK";
/// Synthetic replication pseudo-command for [`WalRecordType::MqTrigger`].
pub const MQ_REPL_TRIGGER: &[u8] = b"MQ._REPL.TRIGGER";

/// True for any `MQ._REPL.*` synthetic replication pseudo-command emitted by
/// [`crate::shard::mq_exec`]'s live fan-out. Used by
/// `replication::apply::apply_local` to route a replicated command to the
/// MQ apply engine instead of generic dispatch.
///
/// [`WalRecordType`]: crate::persistence::wal_v3::record::WalRecordType
#[inline]
pub fn is_mq_replay_command(cmd: &[u8]) -> bool {
cmd.eq_ignore_ascii_case(MQ_REPL_CREATE)
|| cmd.eq_ignore_ascii_case(MQ_REPL_PUSH)
|| cmd.eq_ignore_ascii_case(MQ_REPL_POP)
|| cmd.eq_ignore_ascii_case(MQ_REPL_ACK)
|| cmd.eq_ignore_ascii_case(MQ_REPL_TRIGGER)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_is_mq_replay_command() {
assert!(is_mq_replay_command(b"MQ._REPL.CREATE"));
assert!(is_mq_replay_command(b"mq._repl.push"));
assert!(is_mq_replay_command(b"MQ._REPL.POP"));
assert!(is_mq_replay_command(b"MQ._REPL.ACK"));
assert!(is_mq_replay_command(b"MQ._REPL.TRIGGER"));
assert!(!is_mq_replay_command(b"MQ"));
assert!(!is_mq_replay_command(b"MQ.PUSH"));
assert!(!is_mq_replay_command(b"GRAPH.ADDNODE"));
assert!(!is_mq_replay_command(b""));
}

// --- MqCreate roundtrip ---

#[test]
Expand Down
Loading
Loading