1use crate::config::{RedisSinkConfig, RedisSinkType};
4use async_trait::async_trait;
5use faucet_core::FaucetError;
6use serde_json::Value;
7
8pub struct RedisSink {
13 config: RedisSinkConfig,
14 conn: redis::aio::MultiplexedConnection,
15}
16
17impl RedisSink {
18 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 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 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 let mut conn = self.conn.clone();
89 let mut written = 0usize;
90
91 let effective_chunk = if self.config.batch_size == 0 {
97 records.len()
98 } else {
99 self.config.batch_size
100 };
101
102 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 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 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 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
182fn commit_token_key(scope: &str) -> String {
189 format!("{}:{scope}", faucet_core::idempotency::COMMIT_TOKEN_TABLE)
190}
191
192fn 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 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
239fn 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 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
256fn 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 #[test]
285 fn config_fields_accessible() {
286 let config = RedisSinkConfig::new(
287 "redis://localhost",
288 RedisSinkType::List { key: "test".into() },
289 );
290 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 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 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}