diff --git a/sdk/rust/Cargo.lock b/sdk/rust/Cargo.lock index 957a8d9c..09073d8c 100644 --- a/sdk/rust/Cargo.lock +++ b/sdk/rust/Cargo.lock @@ -38,6 +38,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "arcstr" version = "1.2.0" @@ -61,6 +70,15 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", +] + [[package]] name = "bitflags" version = "2.11.1" @@ -355,6 +373,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -925,11 +952,14 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f44e94c96d8870a387d88ce3de3fdd608cbfc0705f03cb343cdde91509d3e49a" dependencies = [ + "arc-swap", "arcstr", "async-lock", + "backon", "bytes", "cfg-if", "combine", + "futures-channel", "futures-util", "itoa", "native-tls", diff --git a/sdk/rust/Cargo.toml b/sdk/rust/Cargo.toml index eadd167f..c140706e 100644 --- a/sdk/rust/Cargo.toml +++ b/sdk/rust/Cargo.toml @@ -10,7 +10,7 @@ keywords = ["redis", "vector-search", "graph", "database", "async"] categories = ["database", "asynchronous", "network-programming"] [dependencies] -redis = { version = "1.1", features = ["tokio-comp", "aio"] } +redis = { version = "1.1", features = ["tokio-comp", "aio", "connection-manager"] } tokio = { version = "1", features = ["rt", "net", "macros"] } thiserror = "2" bytes = "1" diff --git a/sdk/rust/src/cache.rs b/sdk/rust/src/cache.rs index c8336744..45642aec 100644 --- a/sdk/rust/src/cache.rs +++ b/sdk/rust/src/cache.rs @@ -9,7 +9,7 @@ use crate::util::parse_cache_search_results; /// /// Obtain via [`MoonClient::cache`](crate::MoonClient::cache). pub struct CacheClient { - pub(crate) conn: redis::aio::MultiplexedConnection, + pub(crate) conn: redis::aio::ConnectionManager, } impl CacheClient { diff --git a/sdk/rust/src/client.rs b/sdk/rust/src/client.rs index c8bfbc08..4cf50add 100644 --- a/sdk/rust/src/client.rs +++ b/sdk/rust/src/client.rs @@ -30,7 +30,7 @@ macro_rules! cmd { /// use moondb::MoonClient; /// /// #[tokio::main] -/// async fn main() -> moon::Result<()> { +/// async fn main() -> moondb::Result<()> { /// let mut client = MoonClient::connect("redis://127.0.0.1:6399").await?; /// client.set("hello", "world").await?; /// let v: String = client.get("hello").await?; @@ -38,9 +38,19 @@ macro_rules! cmd { /// Ok(()) /// } /// ``` +/// The connection is a [`redis::aio::ConnectionManager`], NOT a bare +/// `MultiplexedConnection`: the manager transparently RECONNECTS (with +/// backoff) when the underlying socket drops — a server restart, an +/// idle-timeout reap, a transient network blip. A `MultiplexedConnection` +/// opens one socket at connect time and never rebuilds it, so after any drop +/// every later command fails with `broken pipe` forever until the whole +/// client is reconstructed. That wedged Lunaris in production after a live +/// Moon daemon flip; see `sdk/rust/tests/reconnect.rs`. `ConnectionManager` +/// is `Clone` and shares its reconnect state across clones via `Arc`, so a +/// heal on any handle repairs every sub-client. #[derive(Clone)] pub struct MoonClient { - pub(crate) conn: redis::aio::MultiplexedConnection, + pub(crate) conn: redis::aio::ConnectionManager, } impl MoonClient { @@ -52,7 +62,7 @@ impl MoonClient { /// - `rediss://127.0.0.1:6399` — TLS (requires `tls-rustls` or `tls-native-tls` feature) pub async fn connect(url: impl redis::IntoConnectionInfo) -> Result { let client = redis::Client::open(url)?; - let conn = client.get_multiplexed_async_connection().await?; + let conn = client.get_connection_manager().await?; Ok(Self { conn }) } @@ -65,16 +75,21 @@ impl MoonClient { response_timeout: std::time::Duration, ) -> Result { let client = redis::Client::open(url)?; - let config = redis::AsyncConnectionConfig::new().set_response_timeout(Some(response_timeout)); - let conn = client - .get_multiplexed_async_connection_with_config(&config) - .await?; + // `ConnectionManagerConfig` carries the same per-command response + // timeout as the old `AsyncConnectionConfig` path, so the io-failsafe + // contract (every command is response-bounded) is preserved — while + // ALSO gaining transparent reconnect on a dropped socket. + let config = + redis::aio::ConnectionManagerConfig::new().set_response_timeout(Some(response_timeout)); + let conn = client.get_connection_manager_with_config(config).await?; Ok(Self { conn }) } - /// Access the raw underlying multiplexed connection for operations not covered - /// by the typed API. - pub fn inner_mut(&mut self) -> &mut redis::aio::MultiplexedConnection { + /// Access the raw underlying reconnecting connection for operations not + /// covered by the typed API. `ConnectionManager` implements `ConnectionLike`, + /// so `redis::cmd(..).query_async(client.inner_mut())` works exactly as it + /// did against the old `MultiplexedConnection`. + pub fn inner_mut(&mut self) -> &mut redis::aio::ConnectionManager { &mut self.conn } diff --git a/sdk/rust/src/graph.rs b/sdk/rust/src/graph.rs index 00d42f2d..704d1ba0 100644 --- a/sdk/rust/src/graph.rs +++ b/sdk/rust/src/graph.rs @@ -11,7 +11,7 @@ use crate::util::{parse_query_result, value_to_i64}; /// use moondb::MoonClient; /// /// #[tokio::main] -/// async fn main() -> moon::Result<()> { +/// async fn main() -> moondb::Result<()> { /// let client = MoonClient::connect("redis://127.0.0.1:6399").await?; /// let mut g = client.graph(); /// g.create("social").await?; @@ -26,7 +26,7 @@ use crate::util::{parse_query_result, value_to_i64}; /// } /// ``` pub struct GraphClient { - pub(crate) conn: redis::aio::MultiplexedConnection, + pub(crate) conn: redis::aio::ConnectionManager, } impl GraphClient { diff --git a/sdk/rust/src/mq.rs b/sdk/rust/src/mq.rs index b4ad483d..aa1e515d 100644 --- a/sdk/rust/src/mq.rs +++ b/sdk/rust/src/mq.rs @@ -9,7 +9,7 @@ use crate::util::value_to_string; /// /// Obtain via [`MoonClient::mq`](crate::MoonClient::mq). pub struct MqClient { - pub(crate) conn: redis::aio::MultiplexedConnection, + pub(crate) conn: redis::aio::ConnectionManager, } impl MqClient { diff --git a/sdk/rust/src/session.rs b/sdk/rust/src/session.rs index e9cc3f25..87a30f68 100644 --- a/sdk/rust/src/session.rs +++ b/sdk/rust/src/session.rs @@ -9,7 +9,7 @@ use crate::util::parse_search_results; /// /// Obtain via [`MoonClient::session`](crate::MoonClient::session). pub struct SessionClient { - pub(crate) conn: redis::aio::MultiplexedConnection, + pub(crate) conn: redis::aio::ConnectionManager, } impl SessionClient { diff --git a/sdk/rust/src/temporal.rs b/sdk/rust/src/temporal.rs index 69dff1a3..dcc32423 100644 --- a/sdk/rust/src/temporal.rs +++ b/sdk/rust/src/temporal.rs @@ -7,7 +7,7 @@ use crate::error::Result; /// /// Obtain via [`MoonClient::temporal`](crate::MoonClient::temporal). pub struct TemporalClient { - pub(crate) conn: redis::aio::MultiplexedConnection, + pub(crate) conn: redis::aio::ConnectionManager, } impl TemporalClient { diff --git a/sdk/rust/src/text.rs b/sdk/rust/src/text.rs index d3b555ec..ea791ec2 100644 --- a/sdk/rust/src/text.rs +++ b/sdk/rust/src/text.rs @@ -101,7 +101,7 @@ fn encode_filter(cmd: &mut redis::Cmd, filter: &HybridFilter, top_level: bool) { /// /// Obtain via [`MoonClient::text`](crate::MoonClient::text). pub struct TextClient { - pub(crate) conn: redis::aio::MultiplexedConnection, + pub(crate) conn: redis::aio::ConnectionManager, } impl TextClient { @@ -174,9 +174,7 @@ impl TextClient { // Client-side filter validation (same depth/leaf limits as the server parser). if let Some(f) = filter { let mut validator = FilterValidator::new(); - validator - .validate(f, 0) - .map_err(MoonError::invalid_arg)?; + validator.validate(f, 0).map_err(MoonError::invalid_arg)?; } let blob = encode_vector(query_vec); @@ -222,7 +220,9 @@ impl TextClient { /// /// # Example /// ```no_run + /// # use moondb::MoonClient; /// use moondb::types::Reducer; + /// # async fn demo(client: &mut MoonClient) -> moondb::Result<()> { /// let rows = client.text().aggregate( /// "docs", /// "*", @@ -231,6 +231,8 @@ impl TextClient { /// Some(("count", false)), /// Some(100), /// ).await?; + /// # Ok(()) + /// # } /// ``` pub async fn aggregate( &mut self, diff --git a/sdk/rust/src/vector.rs b/sdk/rust/src/vector.rs index 7120c280..ad2fba71 100644 --- a/sdk/rust/src/vector.rs +++ b/sdk/rust/src/vector.rs @@ -11,7 +11,7 @@ use crate::util::{parse_cache_search_results, parse_index_info, parse_search_res /// use moondb::{MoonClient, types::{VectorIndexOptions, DistanceMetric}}; /// /// #[tokio::main] -/// async fn main() -> moon::Result<()> { +/// async fn main() -> moondb::Result<()> { /// let client = MoonClient::connect("redis://127.0.0.1:6399").await?; /// let mut v = client.vector(); /// v.create_index("idx", VectorIndexOptions::new(384, DistanceMetric::Cosine)).await?; @@ -20,7 +20,7 @@ use crate::util::{parse_cache_search_results, parse_index_info, parse_search_res /// } /// ``` pub struct VectorClient { - pub(crate) conn: redis::aio::MultiplexedConnection, + pub(crate) conn: redis::aio::ConnectionManager, } impl VectorClient { diff --git a/sdk/rust/src/workspace.rs b/sdk/rust/src/workspace.rs index ca471a4f..49169229 100644 --- a/sdk/rust/src/workspace.rs +++ b/sdk/rust/src/workspace.rs @@ -11,7 +11,7 @@ use std::collections::HashMap; /// /// Obtain via [`MoonClient::workspace`](crate::MoonClient::workspace). pub struct WorkspaceClient { - pub(crate) conn: redis::aio::MultiplexedConnection, + pub(crate) conn: redis::aio::ConnectionManager, } impl WorkspaceClient { diff --git a/sdk/rust/tests/integration.rs b/sdk/rust/tests/integration.rs index 81249585..4a361620 100644 --- a/sdk/rust/tests/integration.rs +++ b/sdk/rust/tests/integration.rs @@ -331,7 +331,7 @@ async fn test_graph_explain() { fn vector_encode_decode_roundtrip() { let original = vec![0.1_f32, -0.5, 1.23456, f32::MIN_POSITIVE]; let encoded = encode_vector(&original); - let decoded = moon::decode_vector(&encoded).unwrap(); + let decoded = moondb::decode_vector(&encoded).unwrap(); for (a, b) in original.iter().zip(decoded.iter()) { assert!((a - b).abs() < 1e-7, "mismatch: {a} vs {b}"); } @@ -340,5 +340,5 @@ fn vector_encode_decode_roundtrip() { #[test] fn vector_decode_invalid_length() { let bad = vec![0u8, 1, 2]; // 3 bytes — not a multiple of 4 - assert!(moon::decode_vector(&bad).is_err()); + assert!(moondb::decode_vector(&bad).is_err()); } diff --git a/sdk/rust/tests/reconnect.rs b/sdk/rust/tests/reconnect.rs new file mode 100644 index 00000000..f79e69ef --- /dev/null +++ b/sdk/rust/tests/reconnect.rs @@ -0,0 +1,155 @@ +//! Reconnect regression test: a long-lived `MoonClient` MUST survive a +//! backend restart. +//! +//! ## Why this exists +//! +//! `redis::aio::MultiplexedConnection` (the type `MoonClient` wrapped before +//! this fix) opens ONE socket at connect time and NEVER rebuilds it. When the +//! server drops the connection — a restart, an idle-timeout reap, a transient +//! network blip — every subsequent command fails with `broken pipe` FOREVER, +//! and the only recovery is to reconstruct the client (i.e. restart the whole +//! process). This bit Lunaris in production: after a live Moon daemon flip, +//! the `lunaris-mcp` server's pooled connection went permanently dead and +//! `memory.recall` returned `broken pipe` on every call until the MCP process +//! was restarted. +//! +//! `redis::aio::ConnectionManager` is the reconnecting variant: on a broken +//! socket it transparently re-establishes the connection (with backoff) so +//! later commands recover on their own. +//! +//! ## The discriminator +//! +//! Spawn a real `redis-server` (Moon speaks the same RESP wire protocol) on an +//! ephemeral port, drive one PING through a long-lived client, KILL the server, +//! restart it on the SAME port, then assert a PING on THE SAME client succeeds +//! within a bounded window. +//! +//! * RED (MultiplexedConnection): the post-restart PING never succeeds — the +//! client is wedged on the dead socket. The test times out and fails. +//! * GREEN (ConnectionManager): the client reconnects and PING returns PONG. +//! +//! The test spawns its own server, so it is self-contained. If `redis-server` +//! is not on `PATH` (some minimal dev boxes) it SKIPS with a message rather +//! than failing — it still discriminates everywhere a server is available +//! (developer machines + moon CI, which ships `redis-server`). + +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use moondb::MoonClient; + +/// Grab an ephemeral TCP port by binding to :0 and immediately releasing it. +/// Small TOCTOU window, acceptable for a spawn-a-throwaway-server test. +fn free_port() -> u16 { + let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port"); + let port = l.local_addr().expect("local_addr").port(); + drop(l); + port +} + +/// Spawn a throwaway `redis-server` on `port` with `dir` as its working dir, +/// persistence disabled. Returns `None` if `redis-server` is not on `PATH`. +fn spawn_server(port: u16, dir: &std::path::Path) -> Option { + Command::new("redis-server") + .args([ + "--port", + &port.to_string(), + "--dir", + dir.to_str().expect("utf8 dir"), + "--save", + "", + "--appendonly", + "no", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok() +} + +/// Poll a FRESH client until the server at `url` answers PING, or `budget` +/// elapses. Used only for readiness gating — the actual subject-under-test is a +/// separate long-lived client. +async fn wait_ready(url: &str, budget: Duration) -> bool { + let deadline = Instant::now() + budget; + while Instant::now() < deadline { + if let Ok(mut c) = MoonClient::connect(url).await + && c.ping().await.is_ok() + { + return true; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + false +} + +#[tokio::test] +async fn client_survives_backend_restart() { + let port = free_port(); + let url = format!("redis://127.0.0.1:{port}"); + let dir = + std::env::temp_dir().join(format!("moondb-reconnect-{}-{}", port, std::process::id())); + std::fs::create_dir_all(&dir).expect("create temp dir"); + + // ── Bring up the backend ──────────────────────────────────────────────── + let Some(mut child) = spawn_server(port, &dir) else { + eprintln!("SKIP client_survives_backend_restart: `redis-server` not on PATH"); + let _ = std::fs::remove_dir_all(&dir); + return; + }; + assert!( + wait_ready(&url, Duration::from_secs(10)).await, + "backend never became ready on first boot" + ); + + // ── The subject under test: ONE long-lived client across the restart ───── + let mut client = MoonClient::connect_with_timeout(url.as_str(), Duration::from_secs(5)) + .await + .expect("initial connect"); + assert_eq!( + client.ping().await.expect("first ping").to_uppercase(), + "PONG", + "sanity: client talks to the live backend before the restart" + ); + + // ── Drop the backend out from under the live client ────────────────────── + let _ = child.kill(); + let _ = child.wait(); + + // ── Restart on the SAME port ───────────────────────────────────────────── + let mut child2 = match spawn_server(port, &dir) { + Some(c) => c, + None => panic!("restart spawn failed (redis-server vanished mid-test?)"), + }; + assert!( + wait_ready(&url, Duration::from_secs(10)).await, + "backend never came back on restart" + ); + + // ── DISCRIMINATOR: the SAME long-lived client must recover ─────────────── + // MultiplexedConnection: broken-pipe forever → this loop never succeeds → fail. + // ConnectionManager: transparently reconnects → PONG within the window. + let mut recovered = false; + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if let Ok(p) = client.ping().await + && p.to_uppercase() == "PONG" + { + recovered = true; + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + // ── Cleanup (best-effort) ──────────────────────────────────────────────── + let _ = child2.kill(); + let _ = child2.wait(); + let _ = std::fs::remove_dir_all(&dir); + + assert!( + recovered, + "long-lived client did NOT recover after a backend restart — the \ + connection never reconnected (MultiplexedConnection wedges on the dead \ + socket; ConnectionManager is required)" + ); +}