redis_server_wrapper/chaos.rs
1//! Fault injection primitives for testing Redis client resilience.
2//!
3//! This module provides operations for simulating failures in Redis
4//! topologies: killing nodes, freezing processes (SIGSTOP/SIGCONT),
5//! triggering failovers, and more. All operations work with the handle
6//! types returned by the server, cluster, and sentinel builders.
7//!
8//! Unix-only: the node-kill and freeze/resume operations send POSIX signals
9//! via `kill`. See the crate-level "Platform Support" docs for details.
10//!
11//! # Example
12//!
13//! ```no_run
14//! use redis_server_wrapper::{RedisCluster, chaos};
15//! use std::time::Duration;
16//!
17//! # async fn example() {
18//! let cluster = RedisCluster::builder()
19//! .masters(3)
20//! .replicas_per_master(1)
21//! .base_port(7100)
22//! .start()
23//! .await
24//! .unwrap();
25//!
26//! // Freeze a node (SIGSTOP) -- it stops processing but stays in memory.
27//! chaos::freeze_node(cluster.node(0));
28//!
29//! // ... test client behavior with a frozen node ...
30//!
31//! // Resume the node (SIGCONT).
32//! chaos::resume_node(cluster.node(0));
33//! # }
34//! ```
35
36#[cfg(feature = "tokio")]
37use crate::cluster::RedisClusterHandle;
38#[cfg(feature = "tokio")]
39use crate::error::Result;
40#[cfg(feature = "tokio")]
41use crate::server::RedisServerHandle;
42
43use std::process::Command;
44#[cfg(feature = "tokio")]
45use std::time::Duration;
46
47// ---------------------------------------------------------------------------
48// Node-level operations
49// ---------------------------------------------------------------------------
50
51/// Kill a node immediately with SIGKILL.
52///
53/// The process is terminated without any chance to clean up. This simulates
54/// a hard crash (e.g., OOM kill, hardware failure).
55#[cfg(feature = "tokio")]
56pub fn kill_node(handle: &RedisServerHandle) {
57 let pid = handle.pid().to_string();
58 let _ = Command::new("kill").args(["-9", &pid]).output();
59}
60
61/// Freeze a node by sending SIGSTOP.
62///
63/// The process is suspended -- it stops processing commands and won't
64/// respond to PING, but stays in memory. Clients will see timeouts.
65/// Use [`resume_node`] to unfreeze it.
66///
67/// This is more useful than [`kill_node`] for testing timeout handling and
68/// partition scenarios because the node can be resumed without losing state.
69#[cfg(feature = "tokio")]
70pub fn freeze_node(handle: &RedisServerHandle) {
71 let pid = handle.pid().to_string();
72 let _ = Command::new("kill").args(["-STOP", &pid]).output();
73}
74
75/// Resume a frozen node by sending SIGCONT.
76///
77/// The process resumes from where it was suspended. Buffered writes and
78/// replication will catch up automatically.
79#[cfg(feature = "tokio")]
80pub fn resume_node(handle: &RedisServerHandle) {
81 let pid = handle.pid().to_string();
82 let _ = Command::new("kill").args(["-CONT", &pid]).output();
83}
84
85/// Freeze a node for a fixed duration, then resume it automatically.
86///
87/// Sends SIGSTOP immediately and returns without blocking. A background
88/// tokio task sleeps for `duration` and then sends SIGCONT, so the node
89/// comes back on its own -- there's no need to call [`resume_node`] or
90/// [`recover`] afterward. Useful for testing timeout handling where the
91/// outage has a bounded, known length.
92#[cfg(feature = "tokio")]
93pub fn pause_node(handle: &RedisServerHandle, duration: Duration) {
94 let pid = handle.pid().to_string();
95 let _ = Command::new("kill").args(["-STOP", &pid]).output();
96 tokio::spawn(async move {
97 tokio::time::sleep(duration).await;
98 let _ = Command::new("kill").args(["-CONT", &pid]).output();
99 });
100}
101
102/// Pause client connections for a duration using `CLIENT PAUSE`.
103///
104/// Unlike [`freeze_node`], the server process stays responsive for
105/// replication and cluster protocol. Only client commands are delayed.
106/// After the duration expires, clients resume automatically.
107#[cfg(feature = "tokio")]
108pub async fn slow_down(handle: &RedisServerHandle, millis: u64) -> Result<String> {
109 handle.run(&["CLIENT", "PAUSE", &millis.to_string()]).await
110}
111
112/// Trigger a background RDB save.
113#[cfg(feature = "tokio")]
114pub async fn trigger_save(handle: &RedisServerHandle) -> Result<String> {
115 handle.run(&["BGSAVE"]).await
116}
117
118/// Flush all data from a node.
119#[cfg(feature = "tokio")]
120pub async fn flushall(handle: &RedisServerHandle) -> Result<String> {
121 handle.run(&["FLUSHALL"]).await
122}
123
124/// Fill a node with `count` keys holding fixed-size values.
125///
126/// Writes keys named `<prefix>0` through `<prefix>{count-1}`, each holding a
127/// 1 KiB value. Useful for exercising `maxmemory` and eviction-policy
128/// behavior with a deterministic, bounded key count.
129#[cfg(feature = "tokio")]
130pub async fn fill_memory(handle: &RedisServerHandle, prefix: &str, count: usize) -> Result<()> {
131 let value = "x".repeat(1024);
132 for i in 0..count {
133 handle
134 .run(&["SET", &format!("{prefix}{i}"), &value])
135 .await?;
136 }
137 Ok(())
138}
139
140// ---------------------------------------------------------------------------
141// Cluster-level operations
142// ---------------------------------------------------------------------------
143
144/// Kill the master node that owns a given hash slot.
145///
146/// Queries `CLUSTER SLOTS` on the seed node to find which node owns the
147/// slot, then sends SIGKILL to that process.
148///
149/// Returns `Ok(port)` of the killed node, or an error if the slot owner
150/// could not be determined.
151#[cfg(feature = "tokio")]
152pub async fn kill_master_by_slot(cluster: &RedisClusterHandle, slot: u16) -> Result<u16> {
153 let owner = find_slot_owner(cluster, slot).await?;
154 let pid = owner.pid().to_string();
155 let _ = Command::new("kill").args(["-9", &pid]).output();
156 Ok(owner.port())
157}
158
159/// Kill the master node that owns the hash slot for a given key.
160///
161/// Computes the slot via `CLUSTER KEYSLOT` then delegates to
162/// [`kill_master_by_slot`].
163#[cfg(feature = "tokio")]
164pub async fn kill_master_by_key(cluster: &RedisClusterHandle, key: &str) -> Result<u16> {
165 let slot = keyslot(cluster, key).await?;
166 kill_master_by_slot(cluster, slot).await
167}
168
169/// Freeze the master node that owns a given hash slot.
170///
171/// Like [`kill_master_by_slot`] but sends SIGSTOP instead of SIGKILL.
172/// The node can be recovered with [`recover`].
173#[cfg(feature = "tokio")]
174pub async fn freeze_master_by_slot(cluster: &RedisClusterHandle, slot: u16) -> Result<u16> {
175 let owner = find_slot_owner(cluster, slot).await?;
176 let pid = owner.pid().to_string();
177 let _ = Command::new("kill").args(["-STOP", &pid]).output();
178 Ok(owner.port())
179}
180
181/// Trigger a `CLUSTER FAILOVER` on a replica node.
182///
183/// If the initial failover fails because the master is down, retries with
184/// `CLUSTER FAILOVER FORCE`.
185#[cfg(feature = "tokio")]
186pub async fn trigger_failover(replica: &RedisServerHandle) -> Result<String> {
187 let result = replica.run(&["CLUSTER", "FAILOVER"]).await?;
188 if result.contains("ERR") {
189 return replica.run(&["CLUSTER", "FAILOVER", "FORCE"]).await;
190 }
191 Ok(result)
192}
193
194/// Simulate a network partition by freezing every node not in `reachable`.
195///
196/// `reachable` holds the indices, matching the order of
197/// [`RedisClusterHandle::nodes`], of the nodes that stay up; every other
198/// node is sent SIGSTOP. Returns the ports of the frozen nodes. Call
199/// [`recover`] to heal the partition.
200#[cfg(feature = "tokio")]
201pub fn partition(cluster: &RedisClusterHandle, reachable: &[usize]) -> Vec<u16> {
202 let mut frozen = Vec::new();
203 for (i, node) in cluster.nodes().iter().enumerate() {
204 if !reachable.contains(&i) {
205 let pid = node.pid().to_string();
206 let _ = Command::new("kill").args(["-STOP", &pid]).output();
207 frozen.push(node.port());
208 }
209 }
210 frozen
211}
212
213/// Resume all nodes in a cluster by sending SIGCONT.
214///
215/// Useful after freezing nodes for partition simulation. Sends SIGCONT to
216/// every node regardless of whether it was frozen.
217#[cfg(feature = "tokio")]
218pub fn recover(cluster: &RedisClusterHandle) {
219 for node in cluster.nodes() {
220 let pid = node.pid().to_string();
221 let _ = Command::new("kill").args(["-CONT", &pid]).output();
222 }
223}
224
225// ---------------------------------------------------------------------------
226// Slot migration (reshard)
227// ---------------------------------------------------------------------------
228
229/// Migrate a single hash slot from one master to another.
230///
231/// Runs the standard Redis Cluster reshard sequence: `CLUSTER SETSLOT
232/// IMPORTING` on `to`, `SETSLOT MIGRATING` on `from`, `CLUSTER
233/// GETKEYSINSLOT` + `MIGRATE` for every key in the slot, then `SETSLOT
234/// NODE` on every master so the new ownership propagates through the
235/// cluster.
236///
237/// Returns the number of keys migrated.
238///
239/// While the migration is in flight, clients that address `from` for a key
240/// already moved to `to` get a `-ASK` redirect. To hold the cluster in that
241/// window deterministically (e.g. to test client ASK handling), use
242/// [`ReshardGuard`] directly instead of this function.
243#[cfg(feature = "tokio")]
244pub async fn migrate_slot(
245 cluster: &RedisClusterHandle,
246 slot: u16,
247 from: &RedisServerHandle,
248 to: &RedisServerHandle,
249) -> Result<usize> {
250 let guard = ReshardGuard::start(cluster, slot, from, to).await?;
251 let moved = guard.migrate_keys().await?;
252 guard.complete().await?;
253 Ok(moved)
254}
255
256/// Migrate a range of hash slots from one master to another.
257///
258/// Calls [`migrate_slot`] for each slot in `slots` in order. Returns the
259/// total number of keys migrated across the whole range.
260#[cfg(feature = "tokio")]
261pub async fn migrate_slots(
262 cluster: &RedisClusterHandle,
263 slots: std::ops::RangeInclusive<u16>,
264 from: &RedisServerHandle,
265 to: &RedisServerHandle,
266) -> Result<usize> {
267 let mut moved = 0;
268 for slot in slots {
269 moved += migrate_slot(cluster, slot, from, to).await?;
270 }
271 Ok(moved)
272}
273
274/// Holds a hash slot in the `MIGRATING`/`IMPORTING` state so tests can
275/// deterministically observe `-ASK` redirects, then finishes or reverts the
276/// migration.
277///
278/// Construct with [`ReshardGuard::start`]. While the guard is alive, `slot`
279/// is `MIGRATING` on the source node and `IMPORTING` on the target node --
280/// the window in which Redis Cluster returns `-ASK` for keys in that slot
281/// that live on the source but haven't been moved yet, and clients that
282/// address the target directly get `-TRYAGAIN`/`MOVED` depending on the
283/// key.
284///
285/// Call [`ReshardGuard::migrate_keys`] any number of times to move keys
286/// without changing ownership (this is what lets a test provoke `-ASK`
287/// deterministically: move some keys, leave others, then issue commands
288/// against them). Call [`ReshardGuard::complete`] to migrate any remaining
289/// keys and hand the slot to the target, or [`ReshardGuard::abort`] to hand
290/// it back to the source.
291///
292/// If the guard is dropped without calling either, it makes a best-effort
293/// synchronous attempt to reset the slot to `STABLE` on both nodes so the
294/// cluster doesn't get stuck straddling the migration.
295///
296/// # Example
297///
298/// ```no_run
299/// use redis_server_wrapper::{RedisCluster, chaos::ReshardGuard};
300///
301/// # async fn example() {
302/// let cluster = RedisCluster::builder()
303/// .masters(2)
304/// .base_port(7200)
305/// .start()
306/// .await
307/// .unwrap();
308///
309/// let guard = ReshardGuard::start(&cluster, 0, cluster.node(0), cluster.node(1))
310/// .await
311/// .unwrap();
312///
313/// // ... issue commands against a key in slot 0 and assert on -ASK ...
314///
315/// guard.complete().await.unwrap();
316/// # }
317/// ```
318#[cfg(feature = "tokio")]
319pub struct ReshardGuard<'a> {
320 cluster: &'a RedisClusterHandle,
321 slot: u16,
322 from: &'a RedisServerHandle,
323 to: &'a RedisServerHandle,
324 to_id: String,
325 resolved: bool,
326}
327
328#[cfg(feature = "tokio")]
329impl<'a> ReshardGuard<'a> {
330 /// Put `slot` into `MIGRATING` on `from` and `IMPORTING` on `to`.
331 ///
332 /// No keys are moved yet -- this only opens the migration window.
333 pub async fn start(
334 cluster: &'a RedisClusterHandle,
335 slot: u16,
336 from: &'a RedisServerHandle,
337 to: &'a RedisServerHandle,
338 ) -> Result<ReshardGuard<'a>> {
339 let from_id = node_id(from).await?;
340 let to_id = node_id(to).await?;
341 let slot_str = slot.to_string();
342 to.run(&["CLUSTER", "SETSLOT", &slot_str, "IMPORTING", &from_id])
343 .await?;
344 from.run(&["CLUSTER", "SETSLOT", &slot_str, "MIGRATING", &to_id])
345 .await?;
346 Ok(ReshardGuard {
347 cluster,
348 slot,
349 from,
350 to,
351 to_id,
352 resolved: false,
353 })
354 }
355
356 /// Migrate every key currently in the slot from `from` to `to`, without
357 /// changing slot ownership.
358 ///
359 /// Safe to call more than once (e.g. to sweep up keys written after a
360 /// previous call). Returns the number of keys moved by this call.
361 pub async fn migrate_keys(&self) -> Result<usize> {
362 let password = self.cluster.password();
363 let to_host = self.to.host().to_string();
364 let to_port = self.to.port().to_string();
365 let mut moved = 0;
366 loop {
367 let keys = get_keys_in_slot(self.from, self.slot, 100).await?;
368 if keys.is_empty() {
369 break;
370 }
371 for key in &keys {
372 let mut args = vec![
373 to_host.as_str(),
374 to_port.as_str(),
375 key.as_str(),
376 "0",
377 "5000",
378 ];
379 if let Some(password) = password {
380 args.push("AUTH");
381 args.push(password);
382 }
383 let mut cmd = vec!["MIGRATE"];
384 cmd.extend(args);
385 self.from.run(&cmd).await?;
386 moved += 1;
387 }
388 }
389 Ok(moved)
390 }
391
392 /// Finish the migration: sweep up any remaining keys, then reassign
393 /// `slot` to the target node on every master.
394 pub async fn complete(mut self) -> Result<usize> {
395 let moved = self.migrate_keys().await?;
396 let slot_str = self.slot.to_string();
397 for node in self.cluster.master_nodes() {
398 node.run(&["CLUSTER", "SETSLOT", &slot_str, "NODE", &self.to_id])
399 .await?;
400 }
401 self.resolved = true;
402 Ok(moved)
403 }
404
405 /// Abort the migration: reset `slot` back to `STABLE` on both nodes,
406 /// leaving ownership with the source.
407 ///
408 /// Any keys already moved to the target by [`ReshardGuard::migrate_keys`]
409 /// stay there -- `MIGRATE` does not roll back, so a partial abort can
410 /// leave a few keys reachable only via the target until the next
411 /// reshard picks them up.
412 pub async fn abort(mut self) -> Result<()> {
413 let slot_str = self.slot.to_string();
414 self.from
415 .run(&["CLUSTER", "SETSLOT", &slot_str, "STABLE"])
416 .await?;
417 self.to
418 .run(&["CLUSTER", "SETSLOT", &slot_str, "STABLE"])
419 .await?;
420 self.resolved = true;
421 Ok(())
422 }
423}
424
425#[cfg(feature = "tokio")]
426impl Drop for ReshardGuard<'_> {
427 fn drop(&mut self) {
428 if self.resolved {
429 return;
430 }
431 let slot_str = self.slot.to_string();
432 self.from
433 .cli()
434 .fire_and_forget(&["CLUSTER", "SETSLOT", &slot_str, "STABLE"]);
435 self.to
436 .cli()
437 .fire_and_forget(&["CLUSTER", "SETSLOT", &slot_str, "STABLE"]);
438 }
439}
440
441/// Get this node's cluster node ID via `CLUSTER MYID`.
442#[cfg(feature = "tokio")]
443async fn node_id(handle: &RedisServerHandle) -> Result<String> {
444 let id = handle.run(&["CLUSTER", "MYID"]).await?;
445 Ok(id.trim().to_string())
446}
447
448/// Get up to `count` keys in `slot` via `CLUSTER GETKEYSINSLOT`.
449#[cfg(feature = "tokio")]
450async fn get_keys_in_slot(
451 handle: &RedisServerHandle,
452 slot: u16,
453 count: u32,
454) -> Result<Vec<String>> {
455 let output = handle
456 .run(&[
457 "CLUSTER",
458 "GETKEYSINSLOT",
459 &slot.to_string(),
460 &count.to_string(),
461 ])
462 .await?;
463 Ok(output
464 .lines()
465 .map(str::trim)
466 .filter(|line| !line.is_empty())
467 .map(String::from)
468 .collect())
469}
470
471// ---------------------------------------------------------------------------
472// Helpers
473// ---------------------------------------------------------------------------
474
475/// Compute the hash slot for a key via `CLUSTER KEYSLOT`.
476#[cfg(feature = "tokio")]
477async fn keyslot(cluster: &RedisClusterHandle, key: &str) -> Result<u16> {
478 let output = cluster.cli().run(&["CLUSTER", "KEYSLOT", key]).await?;
479 let slot: u16 = output
480 .trim()
481 .parse()
482 .map_err(|_| crate::error::Error::Timeout {
483 message: format!("could not parse CLUSTER KEYSLOT response: {output}"),
484 })?;
485 Ok(slot)
486}
487
488/// Find the cluster node that owns a given slot by querying CLUSTER SLOTS.
489///
490/// CLUSTER SLOTS returns ranges like:
491/// ```text
492/// 1) 1) (integer) 0
493/// 2) (integer) 5460
494/// 3) 1) "127.0.0.1"
495/// 2) (integer) 7000
496/// 3) "node-id..."
497/// ```
498///
499/// We parse the port from each range and match it to our node handles.
500#[cfg(feature = "tokio")]
501async fn find_slot_owner(cluster: &RedisClusterHandle, slot: u16) -> Result<&RedisServerHandle> {
502 // Use CLUSTER NODES which gives a simpler text format to parse.
503 // Each line: <id> <ip:port@bus> <flags> <master> <ping> <pong> <epoch> <link> <slot-ranges...>
504 for node in cluster.nodes() {
505 if let Ok(info) = node.run(&["CLUSTER", "NODES"]).await {
506 for line in info.lines() {
507 let parts: Vec<&str> = line.split_whitespace().collect();
508 if parts.len() < 9 {
509 continue;
510 }
511 let flags = parts[2];
512 if !flags.contains("master") {
513 continue;
514 }
515 // Check slot ranges (parts[8..])
516 for range_str in &parts[8..] {
517 if slot_in_range(range_str, slot) {
518 // Extract port from ip:port@bus
519 if let Some(port) = parse_cluster_node_port(parts[1]) {
520 // Find the matching handle
521 for n in cluster.nodes() {
522 if n.port() == port {
523 return Ok(n);
524 }
525 }
526 }
527 }
528 }
529 }
530 // Only need to query one node.
531 break;
532 }
533 }
534 Err(crate::error::Error::Timeout {
535 message: format!("could not find owner of slot {slot}"),
536 })
537}
538
539/// Check if a slot falls within a range string like "0-5460" or "5461".
540fn slot_in_range(range_str: &str, slot: u16) -> bool {
541 // Skip import/migration markers like [123->-node] or [123-<-node]
542 if range_str.starts_with('[') {
543 return false;
544 }
545 if let Some((start, end)) = range_str.split_once('-') {
546 let Ok(start) = start.parse::<u16>() else {
547 return false;
548 };
549 let Ok(end) = end.parse::<u16>() else {
550 return false;
551 };
552 slot >= start && slot <= end
553 } else {
554 range_str.parse::<u16>().ok() == Some(slot)
555 }
556}
557
558/// Parse port from "ip:port@busport" format.
559fn parse_cluster_node_port(addr: &str) -> Option<u16> {
560 let host_port = addr.split('@').next()?;
561 let port_str = host_port.rsplit(':').next()?;
562 port_str.parse().ok()
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568
569 #[test]
570 fn slot_in_range_single() {
571 assert!(slot_in_range("5461", 5461));
572 assert!(!slot_in_range("5461", 5462));
573 }
574
575 #[test]
576 fn slot_in_range_range() {
577 assert!(slot_in_range("0-5460", 0));
578 assert!(slot_in_range("0-5460", 5460));
579 assert!(slot_in_range("0-5460", 1000));
580 assert!(!slot_in_range("0-5460", 5461));
581 }
582
583 #[test]
584 fn slot_in_range_import_marker() {
585 assert!(!slot_in_range("[123->-abc]", 123));
586 assert!(!slot_in_range("[123-<-abc]", 123));
587 }
588
589 #[test]
590 fn parse_port_from_cluster_nodes() {
591 assert_eq!(parse_cluster_node_port("127.0.0.1:7000@17000"), Some(7000));
592 assert_eq!(parse_cluster_node_port("127.0.0.1:7001@17001"), Some(7001));
593 assert_eq!(parse_cluster_node_port("garbage"), None);
594 }
595}