Skip to main content

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}