Skip to main content

faucet_sink_redis/
sink.rs

1//! Redis sink executor.
2
3use crate::config::{RedisSinkConfig, RedisSinkType};
4use async_trait::async_trait;
5use faucet_core::FaucetError;
6use serde_json::Value;
7
8/// A configured Redis sink that writes records to Redis data structures.
9///
10/// The connection is established once during construction and reused across
11/// all `write_batch()` calls.
12pub struct RedisSink {
13    config: RedisSinkConfig,
14    conn: redis::aio::MultiplexedConnection,
15}
16
17impl RedisSink {
18    /// Create a new Redis sink from the given configuration.
19    ///
20    /// This opens a multiplexed async connection to Redis immediately.
21    pub async fn new(config: RedisSinkConfig) -> Result<Self, FaucetError> {
22        faucet_core::validate_batch_size(config.batch_size)?;
23        let client = redis::Client::open(config.url.as_str())
24            .map_err(|e| FaucetError::Config(format!("invalid Redis URL: {e}")))?;
25
26        let conn = client
27            .get_multiplexed_async_connection()
28            .await
29            .map_err(|e| FaucetError::Sink(format!("Redis connection failed: {e}")))?;
30
31        Ok(Self { config, conn })
32    }
33}
34
35#[async_trait]
36impl faucet_core::Sink for RedisSink {
37    fn config_schema(&self) -> serde_json::Value {
38        serde_json::to_value(faucet_core::schema_for!(RedisSinkConfig))
39            .expect("schema serialization")
40    }
41
42    fn dataset_uri(&self) -> String {
43        use crate::config::RedisSinkType;
44        let key = match &self.config.sink_type {
45            RedisSinkType::List { key } | RedisSinkType::Stream { key } => format!("?key={key}"),
46            RedisSinkType::KeyValue { key_field } => format!("?key_field={key_field}"),
47        };
48        format!(
49            "{}{}",
50            faucet_core::redact_uri_credentials(&self.config.url),
51            key
52        )
53    }
54
55    /// Non-mutating preflight probe: issue a Redis `PING` over the existing
56    /// multiplexed connection (probe name `"ping"`).
57    async fn check(
58        &self,
59        ctx: &faucet_core::check::CheckContext,
60    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
61        use faucet_core::check::{CheckReport, Probe};
62
63        // MultiplexedConnection is cheaply cloneable; clone to satisfy &self.
64        let mut conn = self.conn.clone();
65        let started = std::time::Instant::now();
66        let hint = "check the Redis url / that the server is reachable and accepting connections";
67
68        let probe = match tokio::time::timeout(
69            ctx.timeout,
70            redis::cmd("PING").query_async::<String>(&mut conn),
71        )
72        .await
73        {
74            Ok(Ok(_)) => Probe::pass("ping", started.elapsed()),
75            Ok(Err(e)) => Probe::fail_hint("ping", started.elapsed(), e.to_string(), hint),
76            Err(_) => Probe::fail_hint("ping", started.elapsed(), "timed out", hint),
77        };
78        Ok(CheckReport::single(probe))
79    }
80
81    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
82        if records.is_empty() {
83            return Ok(0);
84        }
85
86        // MultiplexedConnection is cheaply cloneable (it shares the
87        // underlying connection), so we clone to satisfy the &self receiver.
88        let mut conn = self.conn.clone();
89        let mut written = 0usize;
90
91        // `batch_size = 0` is the "no batching" sentinel: pack the entire
92        // upstream slice into a single Redis pipeline, preserving
93        // `StreamPage` framing. Otherwise re-chunk into `batch_size`-sized
94        // slices so each Redis pipeline stays near the recommended
95        // ~1000-command working set.
96        let effective_chunk = if self.config.batch_size == 0 {
97            records.len()
98        } else {
99            self.config.batch_size
100        };
101
102        // Process in chunks of batch_size using redis pipelines.
103        for chunk in records.chunks(effective_chunk) {
104            let mut pipe = redis::pipe();
105
106            for record in chunk {
107                append_record_command(&mut pipe, &self.config.sink_type, record)?;
108            }
109
110            pipe.query_async::<()>(&mut conn)
111                .await
112                .map_err(|e| FaucetError::Sink(format!("Redis pipeline execution failed: {e}")))?;
113
114            written += chunk.len();
115        }
116
117        tracing::debug!(records = written, "Redis batch written");
118        Ok(written)
119    }
120
121    fn supports_idempotent_writes(&self) -> bool {
122        true
123    }
124
125    /// Write `records` AND durably record `token` for `scope` in one atomic
126    /// Redis transaction (`MULTI`/`EXEC`).
127    ///
128    /// The whole page ships as a single atomic pipeline: every record's
129    /// command for the configured [`RedisSinkType`] plus a final
130    /// `SET _faucet_commit_token:{scope} {token}`. Either all of it commits
131    /// or none of it does, so a crash between "sink wrote" and "state
132    /// persisted" is resolved on resume by `last_committed_token` — zero
133    /// duplicates on replay.
134    ///
135    /// **`batch_size` re-chunking does NOT apply on this path.** Splitting the
136    /// page across multiple `MULTI`/`EXEC` blocks would break atomicity (a
137    /// crash between chunks would commit rows without the watermark), so the
138    /// entire page is one transaction regardless of `batch_size`.
139    async fn write_batch_idempotent(
140        &self,
141        records: &[Value],
142        scope: &str,
143        token: &str,
144    ) -> Result<usize, FaucetError> {
145        let mut conn = self.conn.clone();
146
147        let mut pipe = redis::pipe();
148        pipe.atomic();
149        for record in records {
150            append_record_command(&mut pipe, &self.config.sink_type, record)?;
151        }
152        // The watermark commits in the same MULTI/EXEC as the data. Even an
153        // empty page still advances the token so resume skips it.
154        pipe.cmd("SET").arg(commit_token_key(scope)).arg(token);
155
156        pipe.query_async::<()>(&mut conn).await.map_err(|e| {
157            FaucetError::Sink(format!(
158                "Redis atomic pipeline (MULTI/EXEC) execution failed: {e}"
159            ))
160        })?;
161
162        tracing::debug!(
163            records = records.len(),
164            scope,
165            "Redis atomic batch + commit token written"
166        );
167        Ok(records.len())
168    }
169
170    async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
171        let mut conn = self.conn.clone();
172        // The token is opaque to the sink (it may carry an embedded resume
173        // bookmark after a '#'); never parse it here — just hand it back.
174        redis::cmd("GET")
175            .arg(commit_token_key(scope))
176            .query_async::<Option<String>>(&mut conn)
177            .await
178            .map_err(|e| FaucetError::Sink(format!("Redis commit-token read failed: {e}")))
179    }
180}
181
182/// The Redis key holding the last committed watermark for a pipeline `scope`
183/// (the per-row state key, e.g. `"{name}::{row_id}"`).
184///
185/// Mirrors the SQL sinks' `_faucet_commit_token` watermark table: one plain
186/// string key per scope, namespaced under the same `_faucet_commit_token`
187/// prefix.
188fn commit_token_key(scope: &str) -> String {
189    format!("{}:{scope}", faucet_core::idempotency::COMMIT_TOKEN_TABLE)
190}
191
192/// Render the full Redis command (name first, then arguments) that writes one
193/// record under the given sink mode. Pure — shared by [`append_record_command`]
194/// so `write_batch` and `write_batch_idempotent` build identical commands.
195fn record_command_args(
196    sink_type: &RedisSinkType,
197    record: &Value,
198) -> Result<Vec<String>, FaucetError> {
199    match sink_type {
200        RedisSinkType::List { key } => {
201            let serialized = serde_json::to_string(record)
202                .map_err(|e| FaucetError::Sink(format!("JSON serialization failed: {e}")))?;
203            Ok(vec!["RPUSH".into(), key.clone(), serialized])
204        }
205        RedisSinkType::Stream { key } => {
206            let fields = flatten_record_to_fields(record);
207            let mut args = vec!["XADD".into(), key.clone(), "*".into()];
208            if fields.is_empty() {
209                // XADD requires at least one field.
210                let serialized = serde_json::to_string(record)
211                    .map_err(|e| FaucetError::Sink(format!("JSON serialization failed: {e}")))?;
212                args.push("_data".into());
213                args.push(serialized);
214            } else {
215                for (field_name, field_value) in fields {
216                    args.push(field_name);
217                    args.push(field_value);
218                }
219            }
220            Ok(args)
221        }
222        RedisSinkType::KeyValue { key_field } => {
223            let key = record
224                .get(key_field)
225                .map(|v| match v {
226                    Value::String(s) => s.clone(),
227                    other => other.to_string(),
228                })
229                .ok_or_else(|| {
230                    FaucetError::Sink(format!("record missing key field '{key_field}'"))
231                })?;
232            let serialized = serde_json::to_string(record)
233                .map_err(|e| FaucetError::Sink(format!("JSON serialization failed: {e}")))?;
234            Ok(vec!["SET".into(), key, serialized])
235        }
236    }
237}
238
239/// Append the command that writes `record` to `pipe`. Thin I/O-free shim over
240/// the pure [`record_command_args`].
241fn append_record_command(
242    pipe: &mut redis::Pipeline,
243    sink_type: &RedisSinkType,
244    record: &Value,
245) -> Result<(), FaucetError> {
246    let args = record_command_args(sink_type, record)?;
247    // The command name is just the first protocol argument.
248    let mut cmd = redis::Cmd::new();
249    for arg in &args {
250        cmd.arg(arg);
251    }
252    pipe.add_command(cmd);
253    Ok(())
254}
255
256/// Flatten a JSON record's top-level fields into string key-value pairs
257/// suitable for Redis stream entries.
258fn flatten_record_to_fields(record: &Value) -> Vec<(String, String)> {
259    match record.as_object() {
260        Some(map) => map
261            .iter()
262            .map(|(k, v)| {
263                let val = match v {
264                    Value::String(s) => s.clone(),
265                    other => other.to_string(),
266                };
267                (k.clone(), val)
268            })
269            .collect(),
270        None => Vec::new(),
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::config::RedisSinkConfig;
278    use serde_json::json;
279
280    // dataset_uri test is skipped: RedisSink::new() requires a live Redis
281    // connection (opens a multiplexed connection in new()), and no offline
282    // constructor exists.
283
284    #[test]
285    fn config_fields_accessible() {
286        let config = RedisSinkConfig::new(
287            "redis://localhost",
288            RedisSinkType::List { key: "test".into() },
289        );
290        // RedisSink::new() is async and requires a live Redis connection,
291        // so we only verify the config here.
292        assert_eq!(config.batch_size, faucet_core::DEFAULT_BATCH_SIZE);
293    }
294
295    #[test]
296    fn flatten_object_record() {
297        let record = json!({"name": "Alice", "age": 30});
298        let fields = flatten_record_to_fields(&record);
299        assert_eq!(fields.len(), 2);
300        assert!(fields.iter().any(|(k, v)| k == "name" && v == "Alice"));
301        assert!(fields.iter().any(|(k, v)| k == "age" && v == "30"));
302    }
303
304    #[test]
305    fn flatten_non_object_returns_empty() {
306        let record = json!("just a string");
307        let fields = flatten_record_to_fields(&record);
308        assert!(fields.is_empty());
309    }
310
311    #[test]
312    fn flatten_nested_value_serializes_as_json() {
313        let record = json!({"data": {"nested": true}});
314        let fields = flatten_record_to_fields(&record);
315        assert_eq!(fields.len(), 1);
316        assert_eq!(fields[0].0, "data");
317        assert_eq!(fields[0].1, r#"{"nested":true}"#);
318    }
319
320    #[test]
321    fn commit_token_key_namespaces_scope_under_watermark_prefix() {
322        assert_eq!(
323            commit_token_key("orders::row1"),
324            "_faucet_commit_token:orders::row1"
325        );
326        assert_eq!(commit_token_key(""), "_faucet_commit_token:");
327    }
328
329    #[test]
330    fn list_record_command_is_rpush_key_json() {
331        let args = record_command_args(&RedisSinkType::List { key: "q".into() }, &json!({"id": 1}))
332            .unwrap();
333        assert_eq!(args, vec!["RPUSH", "q", r#"{"id":1}"#]);
334    }
335
336    #[test]
337    fn stream_record_command_is_xadd_with_flattened_fields() {
338        let args = record_command_args(
339            &RedisSinkType::Stream { key: "ev".into() },
340            &json!({"name": "Alice", "age": 30}),
341        )
342        .unwrap();
343        assert_eq!(&args[..3], ["XADD", "ev", "*"]);
344        // Field order depends on serde_json's map backing (preserve_order can
345        // flip it under --all-features), so assert the pair set, not sequence.
346        let pairs: Vec<(&str, &str)> = args[3..]
347            .chunks(2)
348            .map(|p| (p[0].as_str(), p[1].as_str()))
349            .collect();
350        assert_eq!(pairs.len(), 2);
351        assert!(pairs.contains(&("name", "Alice")));
352        assert!(pairs.contains(&("age", "30")));
353    }
354
355    #[test]
356    fn stream_record_command_empty_object_falls_back_to_data_field() {
357        let args =
358            record_command_args(&RedisSinkType::Stream { key: "ev".into() }, &json!({})).unwrap();
359        assert_eq!(args, vec!["XADD", "ev", "*", "_data", "{}"]);
360    }
361
362    #[test]
363    fn stream_record_command_non_object_falls_back_to_data_field() {
364        let args = record_command_args(&RedisSinkType::Stream { key: "ev".into() }, &json!("bare"))
365            .unwrap();
366        assert_eq!(args, vec!["XADD", "ev", "*", "_data", r#""bare""#]);
367    }
368
369    #[test]
370    fn key_value_record_command_is_set_key_json() {
371        let args = record_command_args(
372            &RedisSinkType::KeyValue {
373                key_field: "id".into(),
374            },
375            &json!({"id": "u1", "plan": "pro"}),
376        )
377        .unwrap();
378        assert_eq!(args[0], "SET");
379        assert_eq!(args[1], "u1");
380        let parsed: Value = serde_json::from_str(&args[2]).unwrap();
381        assert_eq!(parsed, json!({"id": "u1", "plan": "pro"}));
382    }
383
384    #[test]
385    fn key_value_record_command_stringifies_non_string_key() {
386        let args = record_command_args(
387            &RedisSinkType::KeyValue {
388                key_field: "id".into(),
389            },
390            &json!({"id": 42}),
391        )
392        .unwrap();
393        assert_eq!(args[1], "42");
394    }
395
396    #[test]
397    fn key_value_record_command_missing_key_field_is_typed_sink_error() {
398        let err = record_command_args(
399            &RedisSinkType::KeyValue {
400                key_field: "id".into(),
401            },
402            &json!({"other": 1}),
403        )
404        .unwrap_err();
405        match err {
406            FaucetError::Sink(m) => assert!(m.contains("missing key field 'id'"), "got: {m}"),
407            other => panic!("expected Sink error, got: {other:?}"),
408        }
409    }
410
411    /// Collect a pipe's commands as flat arg vectors for assertion.
412    fn pipe_commands(pipe: &redis::Pipeline) -> Vec<Vec<String>> {
413        pipe.cmd_iter()
414            .map(|cmd| {
415                cmd.args_iter()
416                    .map(|a| match a {
417                        redis::Arg::Simple(bytes) => String::from_utf8_lossy(bytes).into_owned(),
418                        redis::Arg::Cursor => "<cursor>".to_string(),
419                    })
420                    .collect()
421            })
422            .collect()
423    }
424
425    #[test]
426    fn append_record_command_appends_exactly_the_pure_args() {
427        let sink_type = RedisSinkType::List { key: "q".into() };
428        let records = [json!({"id": 1}), json!({"id": 2})];
429        let mut pipe = redis::pipe();
430        for r in &records {
431            append_record_command(&mut pipe, &sink_type, r).unwrap();
432        }
433        let cmds = pipe_commands(&pipe);
434        assert_eq!(cmds.len(), 2);
435        for (cmd, record) in cmds.iter().zip(&records) {
436            assert_eq!(cmd, &record_command_args(&sink_type, record).unwrap());
437        }
438    }
439
440    #[test]
441    fn append_record_command_propagates_builder_errors() {
442        let sink_type = RedisSinkType::KeyValue {
443            key_field: "id".into(),
444        };
445        let mut pipe = redis::pipe();
446        let err = append_record_command(&mut pipe, &sink_type, &json!({"no": "id"})).unwrap_err();
447        assert!(matches!(err, FaucetError::Sink(_)));
448        assert_eq!(pipe.cmd_iter().count(), 0, "no command must be appended");
449    }
450
451    #[tokio::test]
452    async fn new_rejects_out_of_range_batch_size() {
453        let mut config =
454            RedisSinkConfig::new("redis://localhost", RedisSinkType::List { key: "k".into() });
455        config.batch_size = faucet_core::MAX_BATCH_SIZE + 1;
456        match RedisSink::new(config).await {
457            Err(faucet_core::FaucetError::Config(m)) => {
458                assert!(m.contains("batch_size"), "got: {m}")
459            }
460            _ => panic!("expected a batch_size Config error"),
461        }
462    }
463}