Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Fixed
- **Cross-shard fan-out no longer drops messages silently on a full SPSC ring
- **Replicas now apply streamed `SWAPDB` (#386), and the record reaches the
wire exactly once per client call.** Two stacked defects: (1) the replica's
apply path had no SWAPDB intercept — generic dispatch hard-errors ("must be
issued at the connection handler level") and the error was only logged, so
every streamed SWAPDB silently no-op'd and the replica served pre-swap data
for both databases until a full resync; (2) a multi-shard master emitted the
record once per REMOTE shard leg and never for the coordinator's own leg —
against today's single merged replica stream that means N−1 swaps, a net
no-op whenever N−1 is even (e.g. `--shards 3`). The coordinator now emits
the replication record exactly once, after the durability gate and the
local swap (an aborted SWAPDB can never ship to replicas); remote SPSC legs
keep their per-shard AOF/WAL writes but stay off the replication plane; the
tokio single-shard handler emits it too. Replicas apply it with the same
slice-split swap as WAL replay, skipping (with a warning) indexes outside
their own `--databases` range instead of poisoning the stream.
Comment on lines +10 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Move the SWAPDB entry after the existing changelog item.

Line 10 starts a new bullet before the item that continues at Line 25 ends. Markdown attaches the c10k text to the new SWAPDB bullet and leaves the prior item incomplete. Move lines 10-24 after the complete existing item.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` around lines 10 - 24, Move the SWAPDB changelog bullet so it
follows the complete existing changelog item that begins before the current
entry and ends after it. Preserve the SWAPDB text unchanged and ensure the
preceding c10k item remains a single complete Markdown bullet.

(c10k E1/E3), and cross-shard reply awaits are bounded (E4).** PUBLISH
fan-out (immediate, batched, and EXEC-queued) and SCRIPT LOAD propagation
used a single `try_push` — a transiently-full ring lost the message with no
Expand Down
112 changes: 112 additions & 0 deletions src/replication/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,17 @@ pub(crate) fn apply_local(
return apply_temporal_invalidate(s, cmd, args);
}

// SWAPDB (#386): the wire carries exactly ONE record per client
// SWAPDB (emitted by the master's coordinator after its durability
// gate; remote SPSC legs write AOF/WAL only). Generic `dispatch()`
// hard-errors on SWAPDB ("must be issued at the connection handler
// level") and `warn_on_error` only logs — so without this intercept
// every streamed SWAPDB silently no-ops on the replica.
if cmd.eq_ignore_ascii_case(b"SWAPDB") {
apply_swapdb(cmd, args, &mut s.databases);
return true;
}

// MOVE / cross-db COPY touch two databases at once and are intercepted
// BEFORE generic dispatch on the master (see `spsc_two_db`). Generic
// `dispatch()` cannot apply them — it returns an error for MOVE and
Expand Down Expand Up @@ -786,6 +797,39 @@ fn warn_on_error(cmd: &[u8], resp: &Frame) {
}
}

/// Apply a streamed `SWAPDB a b` (#386) against the replica's full database
/// slice — same slice-split swap as the WAL replay intercept
/// (`persistence/replay.rs`). Out-of-range / same-index / malformed args skip
/// with a warn: the replica must never poison its stream over an index the
/// master accepted (e.g. a replica configured with fewer `--databases`), it
/// just can't honor it.
fn apply_swapdb(cmd: &[u8], args: &[Frame], databases: &mut [crate::storage::Database]) {
let parse_idx = |f: &Frame| match f {
Frame::BulkString(b) => std::str::from_utf8(b).ok()?.parse::<usize>().ok(),
Frame::Integer(n) => usize::try_from(*n).ok(),
_ => None,
};
match (
args.first().and_then(parse_idx),
args.get(1).and_then(parse_idx),
) {
(Some(a), Some(b)) if a != b && a < databases.len() && b < databases.len() => {
let (lo, hi) = if a < b { (a, b) } else { (b, a) };
// Split the slice to get two non-overlapping mutable references.
let (left, right) = databases.split_at_mut(lo + 1);
std::mem::swap(&mut left[lo], &mut right[hi - lo - 1]);
}
(Some(a), Some(b)) if a == b => {} // same-index: no-op, matches Redis
_ => {
tracing::warn!(
"replication apply: skipping {} with unusable args (out of range for {} local dbs)",
String::from_utf8_lossy(cmd),
databases.len()
);
}
}
}
Comment on lines +806 to +831

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject extra SWAPDB arguments.

Line 812 parses only the first two arguments. A malformed record such as SWAPDB 0 1 extra still swaps databases. Require args.len() == 2 before parsing. Warn and skip all other argument counts. Add a test for the extra-argument case.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/replication/apply.rs` around lines 806 - 831, The apply_swapdb function
currently ignores arguments beyond the first two, allowing malformed SWAPDB
records to execute. Require args.len() == 2 before parsing and swapping,
otherwise follow the existing warning-and-skip path; add a test covering an
extra-argument record and confirming databases remain unchanged.


/// Apply `MOVE` / cross-db `COPY ... DB n` on the replica using the same core
/// helpers as the master's two-db intercept. Returns `None` for a same-db /
/// no-`DB`-clause COPY (caller falls through to generic dispatch), `Some(resp)`
Expand Down Expand Up @@ -1483,6 +1527,74 @@ mod tests {
assert_eq!(db, 3);
}

// ── #386: streamed SWAPDB apply ─────────────────────────────────────

/// Marker key in db `i` so a swap is observable.
fn dbs_with_markers(n: usize) -> Vec<crate::storage::Database> {
(0..n)
.map(|i| {
let mut db = crate::storage::Database::new();
db.set(
Bytes::copy_from_slice(format!("marker:{i}").as_bytes()),
crate::storage::Entry::new_string(Bytes::copy_from_slice(
format!("from-db-{i}").as_bytes(),
)),
);
db
})
.collect()
}

fn has_marker(db: &mut crate::storage::Database, origin: usize) -> bool {
db.get(format!("marker:{origin}").as_bytes()).is_some()
}

#[test]
fn apply_swapdb_swaps_databases() {
let mut dbs = dbs_with_markers(4);
let args = [
Frame::BulkString(Bytes::from_static(b"0")),
Frame::BulkString(Bytes::from_static(b"2")),
];
apply_swapdb(b"SWAPDB", &args, &mut dbs);
assert!(has_marker(&mut dbs[0], 2), "db0 must now hold db2's data");
assert!(has_marker(&mut dbs[2], 0), "db2 must now hold db0's data");
assert!(has_marker(&mut dbs[1], 1), "db1 untouched");
assert!(has_marker(&mut dbs[3], 3), "db3 untouched");
}

#[test]
fn apply_swapdb_integer_args_and_reversed_order() {
let mut dbs = dbs_with_markers(3);
// Integer frames + b > a ordering must both work.
let args = [Frame::Integer(2), Frame::Integer(1)];
apply_swapdb(b"SWAPDB", &args, &mut dbs);
assert!(has_marker(&mut dbs[1], 2));
assert!(has_marker(&mut dbs[2], 1));
}

#[test]
fn apply_swapdb_out_of_range_and_same_index_are_noops() {
let mut dbs = dbs_with_markers(2);
// Out of range for this replica's db_count — skip, don't panic.
let oor = [Frame::Integer(0), Frame::Integer(9)];
apply_swapdb(b"SWAPDB", &oor, &mut dbs);
assert!(has_marker(&mut dbs[0], 0));
// Same index — no-op.
let same = [Frame::Integer(1), Frame::Integer(1)];
apply_swapdb(b"SWAPDB", &same, &mut dbs);
assert!(has_marker(&mut dbs[1], 1));
// Malformed (missing / non-numeric args) — skip, don't panic.
apply_swapdb(b"SWAPDB", &[], &mut dbs);
let junk = [
Frame::BulkString(Bytes::from_static(b"x")),
Frame::Integer(1),
];
apply_swapdb(b"SWAPDB", &junk, &mut dbs);
assert!(has_marker(&mut dbs[0], 0));
assert!(has_marker(&mut dbs[1], 1));
}

#[test]
fn integer_select_arg_parses() {
// SELECT sent with an integer arg instead of bulk string.
Expand Down
38 changes: 22 additions & 16 deletions src/server/conn/handler_single.rs
Original file line number Diff line number Diff line change
Expand Up @@ -865,29 +865,27 @@ pub async fn handle_connection(
// - appendfsync=everysec/no → fire-and-forget (fast)
// On any Err the caller aborts and leaves both DBs
// untouched, preserving atomicity from the WAL's perspective.
let mut a_buf = itoa::Buffer::new();
let mut b_buf = itoa::Buffer::new();
let wal_frame = Frame::Array(crate::framevec![
Frame::BulkString(Bytes::from_static(b"SWAPDB")),
Frame::BulkString(Bytes::copy_from_slice(
a_buf.format(a).as_bytes()
)),
Frame::BulkString(Bytes::copy_from_slice(
b_buf.format(b).as_bytes()
)),
]);
let serialized =
crate::persistence::aof::serialize_command(&wal_frame);
let wal_ok = if let Some(ref pool) = aof_pool {
let mut a_buf = itoa::Buffer::new();
let mut b_buf = itoa::Buffer::new();
let wal_frame = Frame::Array(crate::framevec![
Frame::BulkString(Bytes::from_static(b"SWAPDB")),
Frame::BulkString(Bytes::copy_from_slice(
a_buf.format(a).as_bytes()
)),
Frame::BulkString(Bytes::copy_from_slice(
b_buf.format(b).as_bytes()
)),
]);
let serialized =
crate::persistence::aof::serialize_command(
&wal_frame,
);
// Single-shard mode — shard_id = 0.
let lsn = crate::persistence::aof::AofWriterPool::issue_append_lsn(&repl_state, 0, serialized.len());
// task #35: SWAPDB affects both `a` and
// `b` — no single db context applies;
// pass 0 (writer may emit a harmless
// redundant SELECT 0).
pool.try_send_append_durable(0, lsn, 0, serialized)
pool.try_send_append_durable(0, lsn, 0, serialized.clone())
.await
.is_ok()
} else {
Expand All @@ -904,6 +902,14 @@ pub async fn handle_connection(
let mut guard_lo = db[lo].write();
let mut guard_hi = db[hi].write();
std::mem::swap(&mut *guard_lo, &mut *guard_hi);
drop(guard_hi);
drop(guard_lo);
// #386 — replication plane, exactly once per
// client SWAPDB, AFTER the durability gate and
// the swap itself (mirrors the coordinator leg).
crate::replication::state::record_local_write_global(
0, serialized,
);
Comment on lines +910 to +912

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Tokio calls shard-only fanout 🐞 Bug ≡ Correctness

handler_single calls replication::state::record_local_write_global, but that function explicitly
requires running on the shard OS thread because it pushes to the shard thread-local self_msg
queue; calling it from the tokio connection handler can cause the SWAPDB replication record to never
be fanned out (and can also corrupt replication offsets). This breaks the stated “exactly-once on
the wire” contract whenever handler_single is exercised.
Agent Prompt
## Issue description
`src/server/conn/handler_single.rs` invokes `crate::replication::state::record_local_write_global(...)` from a **tokio** connection handler. That helper pushes to `crate::shard::self_msg` (thread-local queue) and is documented as **shard-thread-only**; tokio tasks must not push there. This means the replication fanout for SWAPDB can be silently lost.

## Issue Context
- `record_local_write_global` explicitly states the caller must be on the shard OS thread and warns that tokio tasks must not push to `self_msg`.
- `shard::self_msg` module docs reiterate the same constraint.
- The PR added this call specifically for SWAPDB replication plane emission.

## Fix Focus Areas
- src/server/conn/handler_single.rs[905-913]
- src/replication/state.rs[473-507]
- src/shard/self_msg.rs[26-34]

## Suggested fix approach
- **Do not call** `record_local_write_global` from `handler_single`.
- Either:
  1) Implement/use a **tokio-safe** replication emission path for the single-thread handler (e.g., append to the replication backlog + fanout via a tokio-owned sender list / channel that is actually drained in this runtime), or
  2) If `handler_single` is truly non-production / non-replicating, remove the SWAPDB replication emission and document that this handler does not support replication-plane emission.

Acceptance criteria:
- No shard-thread-only (`self_msg`) APIs are invoked from tokio handler code.
- SWAPDB replication emission from this handler is either correct (delivered) or intentionally absent with explicit documentation.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Frame::SimpleString(Bytes::from_static(b"OK"))
}
}
Expand Down
25 changes: 20 additions & 5 deletions src/shard/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2907,10 +2907,10 @@ pub async fn coordinate_swapdb(
aof_pool: Option<&Arc<crate::persistence::aof::AofWriterPool>>,
repl_state: ReplStateRef<'_>,
) -> Frame {
debug_assert!(
num_shards > 1,
"coordinate_swapdb is the multi-shard path (single-shard SWAPDB lives in handler_single.rs)"
);
// Serves num_shards >= 1: the tokio single-shard SWAPDB lives in
// handler_single.rs, but the monoio handler routes ALL shard counts here
// (at shards=1 the remote loop is simply empty).
debug_assert!(num_shards >= 1);
// Local shard first: durable append BEFORE the swap AND before any
// remote dispatch. SWAPDB has no command-level rollback; anything that
// can fail must fail while NOTHING in the cluster has mutated —
Expand Down Expand Up @@ -2949,7 +2949,10 @@ pub async fn coordinate_swapdb(
my_shard,
serialized.len(),
);
match pool.send_append_group(my_shard, lsn, 0, serialized).await {
match pool
.send_append_group(my_shard, lsn, 0, serialized.clone())
.await
{
Ok(needs_barrier) => {
if needs_barrier && pool.fsync_barrier(my_shard).await.is_err() {
return Frame::Error(bytes::Bytes::from_static(
Expand All @@ -2972,6 +2975,18 @@ pub async fn coordinate_swapdb(
s.databases.swap(a, b);
}
});

// #386 — replication plane, exactly once per client SWAPDB. Today's
// replica applies the merged wire as ONE stream, so the record must
// appear on it exactly once: the coordinator emits it here, AFTER
// the durability gate (an abort above never reaches this line, so a
// failed SWAPDB can never ship to replicas) and after the local
// swap; the remote legs' SPSC arms write AOF/WAL only. Safe on both
// runtimes: this runs on the shard's own OS thread (monoio shard
// thread / tokio per-shard LocalSet), whose event loop drains
// `self_msg`. When #406 lands per-shard demuxed replicas this must
// flip to per-shard emission.
crate::replication::state::record_local_write_global(my_shard, serialized);
Comment on lines +2978 to +2989

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Confirm every shard leg before global replication emission.

The coordinator records SWAPDB before remote legs report completion. A remote leg also discards a failed AOF append and still sends its success acknowledgement. A closed reply channel can return an error after the replica receives the global swap. An AOF enqueue failure can return +OK with no recoverable remote record.

  • src/shard/coordinator.rs#L2978-L2989: retain serialized and call record_local_write_global only after every remote leg confirms successful application and required durability.
  • src/shard/spsc_handler.rs#L2479-L2499: propagate send_append_bounded_blocking failure to the coordinator. Do not swap or acknowledge success when the remote AOF append fails.
📍 Affects 2 files
  • src/shard/coordinator.rs#L2978-L2989 (this comment)
  • src/shard/spsc_handler.rs#L2479-L2499
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/shard/coordinator.rs` around lines 2978 - 2989, Delay the coordinator’s
record_local_write_global call in src/shard/coordinator.rs:2978-2989 until every
remote leg confirms successful application and required durability, retaining
serialized for that final emission; update src/shard/spsc_handler.rs:2479-2499
to propagate send_append_bounded_blocking failures to the coordinator and
prevent SWAPDB or success acknowledgement when the remote AOF append fails.

}

// ChannelMesh has no self-send slot (target_index panics when my_id == target_id).
Expand Down
43 changes: 29 additions & 14 deletions src/shard/spsc_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2449,7 +2449,7 @@ pub(crate) fn handle_shard_message_shared(
// WAL-before-swap: emit the SWAPDB record so that crash-recovery
// replay can re-apply the swap in the correct order. The record
// is written even when wal_writer/wal_writer are None (the
// fast-path in wal_append_and_fanout will skip it cheaply).
// fast-path skips it cheaply).
//
// Serialise "SWAPDB <a> <b>" without heap allocation on the number
// formatting (itoa writes into a stack buffer).
Expand All @@ -2463,25 +2463,40 @@ pub(crate) fn handle_shard_message_shared(
crate::protocol::Frame::BulkString(bytes::Bytes::copy_from_slice(b_str.as_bytes())),
]);
let serialized = aof::serialize_command(&wal_frame);
// #386 exactly-once wire contract: remote legs write ONLY the
// durability planes (WAL v3 + this shard's AOF — per-shard
// recovery replays each shard's own record). The REPLICATION
// plane record is emitted exactly once, by the coordinator's
// local leg (`coordinate_swapdb`), because today's replica
// applies the merged wire as one stream: per-remote-leg emission
// made the replica swap N-1 times (net no-op at odd shard
// counts). When #406 lands per-shard demuxed replicas this must
// flip back to per-shard emission.
//
// No per-client response frame exists here (coordinator broadcast,
// reply is `()`): an AOF-append loss is already counted +
// error!-logged inside the pool, so the result is discarded.
let mut aof_budget = crate::persistence::aof::AOF_SPSC_BACKPRESSURE_BOUND;
let _ = wal_append_and_fanout(
&serialized,
if wal_kv_log {
if let Some(w) = wal_writer {
w.append(
crate::persistence::wal_v3::record::WalRecordType::Command,
&serialized,
);
}
}
if let Some(pool) = aof_pool {
let mut aof_budget = crate::persistence::aof::AOF_SPSC_BACKPRESSURE_BOUND;
// task #35: SWAPDB affects both `a` and `b` — no single db
// context applies; pass 0 (writer may emit a harmless
// redundant SELECT 0 if last_db was already non-zero).
0,
wal_writer,
repl_backlog,
replica_txs,
repl_state,
shard_id,
aof_pool, // FIX-W1-2
wal_kv_log,
&mut aof_budget,
);
let _ = pool.send_append_bounded_blocking(
shard_id,
0,
0,
bytes::Bytes::copy_from_slice(&serialized),
&mut aof_budget,
Comment on lines +2494 to +2497

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Informational

4. Unneeded bytes copy in swapdb 🐞 Bug ➹ Performance

The SWAPDB SPSC arm allocates and copies the already-owned Bytes from serialize_command via
Bytes::copy_from_slice(&serialized) before sending it to send_append_bounded_blocking, adding
avoidable heap work on each SWAPDB remote leg. This should pass serialized.clone() (cheap) or move
serialized when possible.
Agent Prompt
## Issue description
In the SWAPDB SPSC arm, `serialized` is already a `bytes::Bytes` (from `aof::serialize_command`). The code currently does `Bytes::copy_from_slice(&serialized)` before calling `send_append_bounded_blocking`, which allocates and copies the payload unnecessarily.

## Issue Context
- `aof::serialize_command` returns `Bytes`.
- `AofWriterPool::send_append_bounded_blocking` takes `Bytes` by value.
- Therefore, `serialized.clone()` is the correct low-cost way to pass ownership.

## Fix Focus Areas
- src/shard/spsc_handler.rs[2487-2499]
- src/persistence/aof/mod.rs[509-514]
- src/persistence/aof/pool.rs[498-505]

## Suggested fix approach
- Replace `bytes::Bytes::copy_from_slice(&serialized)` with `serialized.clone()` (or move `serialized` if no longer needed after WAL append).
- Keep WAL append using `&serialized` as-is.

Result:
- Eliminates an avoidable allocation/copy on SWAPDB remote legs.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

);
}

// Perform the in-place swap via ShardSlice (thread-local, no locks needed).
crate::shard::slice::with_shard(|s| {
Expand Down
Loading
Loading