1use crate::llm::actions::{
4 protocol_trait::{ActionResult, Protocol, Server},
5 ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::server::connection::ConnectionId;
10use crate::state::app_state::AppState;
11use anyhow::{Context, Result};
12use serde_json::json;
13use std::sync::atomic::{AtomicU8, Ordering};
14use std::sync::{Arc, LazyLock};
15use tokio::sync::mpsc;
16use tracing::{debug, warn};
17
18pub struct RedisProtocol {
20 #[allow(dead_code)]
21 connection_id: ConnectionId,
22 #[allow(dead_code)]
23 app_state: Arc<AppState>,
24 status_tx: mpsc::UnboundedSender<String>,
25 protover: Arc<AtomicU8>,
29 shared: Option<Arc<super::RedisShared>>,
32}
33
34impl RedisProtocol {
35 pub fn new(
36 connection_id: ConnectionId,
37 app_state: Arc<AppState>,
38 status_tx: mpsc::UnboundedSender<String>,
39 ) -> Self {
40 Self {
41 connection_id,
42 app_state,
43 status_tx,
44 protover: Arc::new(AtomicU8::new(2)),
45 shared: None,
46 }
47 }
48
49 pub fn for_connection(
51 connection_id: ConnectionId,
52 app_state: Arc<AppState>,
53 status_tx: mpsc::UnboundedSender<String>,
54 shared: Arc<super::RedisShared>,
55 ) -> Self {
56 Self {
57 shared: Some(shared),
58 ..Self::new(connection_id, app_state, status_tx)
59 }
60 }
61
62 pub fn protocol_version(&self) -> u8 {
64 self.protover.load(Ordering::Relaxed)
65 }
66
67 pub fn set_protocol_version(&self, version: u8) {
68 self.protover.store(version, Ordering::Relaxed);
69 }
70
71 pub fn protover_cell(&self) -> Arc<AtomicU8> {
73 self.protover.clone()
74 }
75}
76
77impl Protocol for RedisProtocol {
79 fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
80 vec![
81 crate::llm::actions::ParameterDefinition {
82 name: "send_first".to_string(),
83 type_hint: "boolean".to_string(),
84 description: "Unsupported by this server and refused if set to true. Redis: a RESP server answers commands and never speaks first; an unsolicited reply would desynchronise every client's command pipeline."
85 .to_string(),
86 required: false,
87 example: json!(false),
88 default: None,
89 },
90 crate::llm::actions::ParameterDefinition {
91 name: "first_byte_timeout_secs".to_string(),
92 type_hint: "number".to_string(),
93 description: "Seconds a connected peer may send no command at all before \
94 the server closes it. Default 300, matching the window a \
95 `manual` rule gives a human to answer one event - the peer \
96 is often NetGet's own Redis client, which opens a socket and \
97 sends nothing until someone uses [ send message ]. Lower it \
98 (30 was the old value) for a listener exposed to strangers."
99 .to_string(),
100 required: false,
101 example: json!(300),
102 default: Some(serde_json::json!(super::FIRST_COMMAND_READ_TIMEOUT.as_secs())),
103 },
104 crate::llm::actions::ParameterDefinition {
105 name: "idle_timeout_secs".to_string(),
106 type_hint: "number".to_string(),
107 description: "Seconds an established connection may go without a further \
108 command before the server closes it. Default 300. Real Redis \
109 ships `timeout 0` and every pooling client holds connections \
110 unused between bursts, so raise it rather than lower it if \
111 your clients pool."
112 .to_string(),
113 required: false,
114 example: json!(300),
115 default: Some(serde_json::json!(super::IDLE_BETWEEN_COMMANDS_TIMEOUT.as_secs())),
116 },
117 crate::llm::actions::ParameterDefinition {
118 name: "cluster_nodes".to_string(),
119 type_hint: "array".to_string(),
120 description: "Run in cluster mode as one master of the Redis Cluster this \
121 array describes. Each node is {\"host\", \"port\", \
122 \"slots\": [\"0-5460\", ...], \"id\" (optional, 40 hex), \
123 \"myself\": true for this server}; this server's own host and \
124 port default to where it is listening. The server then answers \
125 CLUSTER SLOTS/SHARDS/NODES/INFO/KEYSLOT/MYID itself and \
126 redirects a key in a slot another node owns with -MOVED, so a \
127 cluster client can navigate several NetGet servers given the \
128 same array. Omit it for a standalone server."
129 .to_string(),
130 required: false,
131 example: json!([
132 {"host": "127.0.0.1", "port": 7000, "slots": ["0-8191"], "myself": true},
133 {"host": "127.0.0.1", "port": 7001, "slots": ["8192-16383"]}
134 ]),
135 default: None,
136 },
137 ]
138 }
139 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
140 vec![
144 redis_publish_message_action(),
145 redis_cluster_set_slot_action(),
146 ]
147 }
148 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
149 vec![
150 redis_simple_string_action(),
151 redis_bulk_string_action(),
152 redis_array_action(),
153 redis_integer_action(),
154 redis_error_action(),
155 redis_null_action(),
156 close_this_connection_action(),
157 redis_accept_subscription_action(),
158 redis_deliver_message_action(),
159 redis_publish_message_action(),
160 redis_cluster_set_slot_action(),
161 ]
162 .into_iter()
163 .chain(resp3_actions())
164 .collect()
165 }
166 fn protocol_name(&self) -> &'static str {
167 "Redis"
168 }
169 fn get_event_types(&self) -> Vec<EventType> {
170 get_redis_event_types()
171 }
172 fn stack_name(&self) -> &'static str {
173 "ETH>IP>TCP>Redis"
174 }
175 fn keywords(&self) -> Vec<&'static str> {
176 vec!["redis"]
177 }
178 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
179 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
180
181 ProtocolMetadataV2::builder()
182 .state(DevelopmentState::Beta)
187 .well_known_port(6379)
188 .implementation("redis-protocol v6.0 (RESP2 request parsing), manual RESP2/RESP3 encoding per connection after HELLO")
189 .llm_control(
190 "Every data command (GET, SET, INCR, ...); pub/sub policy (whether a SUBSCRIBE is \
191 allowed, whether a PUBLISH is delivered and what it returns) and messages of its \
192 own; cluster slot ownership",
193 )
194 .e2e_testing(
195 "Two independent clients. redis-rs (tests/server/redis/e2e_test.rs, \
196 llm_failure_test.rs): one test per RESP2 reply type, each deserialised into \
197 the Rust type the test asked for. redis-cli — the C client, valkey-cli here \
198 (real_client_test.rs, hard-fails when the binary is absent): seven commands on \
199 one connection, asserted as one ordered list of what --no-raw printed, so each \
200 reply type is read off the wire rather than coerced (simple string, bulk \
201 string quoted, (integer) n, a two-element multi-bulk, (nil) distinguished from \
202 an empty bulk string, and (error) …) and a reply landing against the wrong \
203 command fails as a mismatched line. \
204 RESP3 (resp3_test.rs): HELLO 3 answered in Rust, every RESP3 type read by \
205 redis-cli -3 and by redis-py 5's own RESP3 parser (with credentials sent in \
206 HELLO and a push ahead of a reply), and the RESP2 downgrades by both. \
207 Pub/sub (pubsub_test.rs): redis-cli subscribers on RESP2 and RESP3 and on a \
208 pattern receive what redis-cli publishes and what the model publishes, and \
209 redis-rs's RESP2 PubSub and RESP3 push sender read message and pmessage; the \
210 model's refusal and withholding hold. Cluster (cluster_test.rs): three servers \
211 sharing a cluster_nodes topology are navigated by redis-cli -c, pass \
212 redis-cli --cluster check, and serve redis-rs's ClusterClient, which follows \
213 MOVED after the model moves a slot; ASK/ASKING for a migrating slot. Hash slots \
214 and glob matching equal a real redis-server's CLUSTER KEYSLOT and KEYS \
215 (keys_oracle_test.rs). Every pub/sub bound and the slow-consumer disconnect \
216 are pinned from raw sockets (pubsub_bounds_test.rs). \
217 NOT proven: inline commands, pipelining \
218 (neither client sends two commands in one write), AUTH/SELECT/MULTI, \
219 cluster-wide PUBLISH (there is no bus), and TLS.",
220 )
221 .notes("RESP2, and RESP3 after HELLO 3 (no attributes, no streamed types, no client-side caching); HELLO with AUTH is judged by the handler. Pub/sub: SUBSCRIBE/PSUBSCRIBE/SSUBSCRIBE (model-approved), UNSUBSCRIBE family, PUBLISH/SPUBLISH (model decides delivery), PUBSUB introspection, RESP2 subscribed mode, redis_publish_message for the model's own messages; bounded per connection, per server and per message, and a subscriber 8 MiB behind is disconnected. Cluster mode (cluster_nodes): CLUSTER SLOTS/SHARDS/NODES/INFO/KEYSLOT/MYID, MOVED/ASK/CROSSSLOT/CLUSTERDOWN routing by CRC16 hash slot, redis_cluster_set_slot; no bus, no replicas. No SELECT/MULTI semantics, no inline commands")
222 .max_inbound_bytes(crate::server::redis::MAX_PENDING_FRAME_BYTES)
223 .build()
224 }
225 fn description(&self) -> &'static str {
226 "Redis in-memory data store"
227 }
228 fn example_prompt(&self) -> &'static str {
229 "Start a Redis server on port 6379"
230 }
231 fn group_name(&self) -> &'static str {
232 "Database"
233 }
234
235 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
236 use crate::llm::actions::StartupExamples;
237 use serde_json::json;
238
239 let script = r#"import json, sys
242data = json.load(sys.stdin)
243event = data["event"]
244if data["event_type_id"] == "redis_command":
245 parts = event.get("command", "").split()
246 verb = parts[0].upper() if parts else ""
247 if verb == "PING":
248 actions = [{"type": "redis_simple_string", "value": "PONG"}]
249 elif verb == "SET":
250 actions = [{"type": "redis_simple_string", "value": "OK"}]
251 elif verb == "GET":
252 actions = [{"type": "redis_bulk_string", "value": "cached-value"}]
253 elif verb == "INCR":
254 actions = [{"type": "redis_integer", "value": 1}]
255 else:
256 actions = [{"type": "redis_error", "message": "ERR unknown command"}]
257else:
258 actions = []
259print(json.dumps({"actions": actions}))"#;
260
261 StartupExamples::new(
262 json!({
264 "type": "open_server",
265 "port": 6379,
266 "base_stack": "redis",
267 "instruction": "Act as a Redis server backing a session cache. Remember values set with SET and return them on GET, treat INCR/DECR as maintaining running counters, and answer TTL/EXISTS consistently with what has been stored so far in this connection."
268 }),
269 json!({
271 "type": "open_server",
272 "port": 6379,
273 "base_stack": "redis",
274 "event_handlers": [{
275 "event_pattern": "redis_command",
276 "handler": {
277 "type": "script",
278 "language": "python",
279 "code": script
280 }
281 }]
282 }),
283 json!({
285 "type": "open_server",
286 "port": 6379,
287 "base_stack": "redis",
288 "event_handlers": [{
289 "event_pattern": "redis_command",
290 "handler": {
291 "type": "static",
292 "actions": [{
293 "type": "redis_simple_string",
294 "value": "OK"
295 }]
296 }
297 }]
298 }),
299 )
300 }
301}
302
303impl Server for RedisProtocol {
305 fn spawn(
306 &self,
307 ctx: crate::protocol::SpawnContext,
308 ) -> std::pin::Pin<
309 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
310 > {
311 Box::pin(async move {
312 use crate::server::redis::RedisServer;
313 let send_first = ctx
319 .startup_params
320 .as_ref()
321 .map(|p| p.get_optional_bool("send_first"))
322 .transpose()?
323 .flatten()
324 .unwrap_or(false);
325 if send_first {
326 return Err(anyhow::anyhow!(
327 "send_first is not supported by the Redis server: a RESP server answers commands and never speaks first; an unsolicited reply would desynchronise every client's command pipeline"
328 ));
329 }
330
331 let secs = |name: &str| -> anyhow::Result<Option<u64>> {
334 Ok(ctx
335 .startup_params
336 .as_ref()
337 .map(|p| p.get_optional_u64(name))
338 .transpose()?
339 .flatten())
340 };
341 let first_byte_timeout_secs = secs("first_byte_timeout_secs")?;
342 let idle_timeout_secs = secs("idle_timeout_secs")?;
343 let cluster_nodes = ctx
344 .startup_params
345 .as_ref()
346 .map(|p| p.get_optional_array("cluster_nodes"))
347 .transpose()?
348 .flatten()
349 .map(|nodes| serde_json::Value::Array(nodes.clone()));
350
351 RedisServer::spawn_with_llm_actions(
352 ctx.legacy_listen_addr(),
353 ctx.llm_client,
354 ctx.state,
355 ctx.status_tx,
356 send_first,
357 ctx.server_id,
358 super::RedisServerOptions {
359 first_byte_timeout_secs,
360 idle_timeout_secs,
361 cluster_nodes,
362 },
363 )
364 .await
365 })
366 }
367
368 fn execute_action_with_state<'a>(
371 &'a self,
372 action: serde_json::Value,
373 state: AppState,
374 server_id: Option<crate::state::ServerId>,
375 ) -> std::pin::Pin<
376 Box<dyn std::future::Future<Output = anyhow::Result<ActionResult>> + Send + 'a>,
377 > {
378 Box::pin(async move {
379 let needs_server = matches!(
380 action.get("type").and_then(|v| v.as_str()),
381 Some("redis_publish_message" | "redis_cluster_set_slot")
382 );
383 if needs_server && self.shared.is_none() {
384 let shared = match server_id {
385 Some(id) => state.server_handle::<super::RedisShared>(id).await,
386 None => None,
387 };
388 let Some(shared) = shared else {
389 anyhow::bail!(
390 "this action needs a running Redis server; name it with server_id \
391 (it is dispatched through execute_action_with_state)"
392 );
393 };
394 return execute_on_shared(&shared, &action, &self.status_tx);
395 }
396 self.execute_action(action)
397 })
398 }
399 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
400 let action_type = action
401 .get("type")
402 .and_then(|v| v.as_str())
403 .context("Missing 'type' field in action")?;
404
405 match action_type {
406 "redis_simple_string" => self.execute_redis_simple_string(action),
407 "redis_bulk_string" => self.execute_redis_bulk_string(action),
408 "redis_array" => self.execute_redis_array(action),
409 "redis_integer" => self.execute_redis_integer(action),
410 "redis_error" => self.execute_redis_error(action),
411 "redis_null" => self.execute_redis_null(action),
412 "redis_map"
413 | "redis_set"
414 | "redis_double"
415 | "redis_boolean"
416 | "redis_big_number"
417 | "redis_verbatim_string"
418 | "redis_push" => self.execute_resp3(action_type, &action),
419 "close_this_connection" => Ok(ActionResult::CloseConnection),
420 "redis_accept_subscription" => Ok(ActionResult::Custom {
423 name: ACCEPT_SUBSCRIPTION.to_string(),
424 data: json!({}),
425 }),
426 "redis_deliver_message" => Ok(ActionResult::Custom {
427 name: DELIVER_MESSAGE.to_string(),
428 data: json!({}),
429 }),
430 "redis_publish_message" | "redis_cluster_set_slot" => match &self.shared {
431 Some(shared) => execute_on_shared(shared, &action, &self.status_tx),
432 None => Err(anyhow::anyhow!(
433 "{action_type} needs the running server; it is dispatched through \
434 execute_action_with_state with a server_id"
435 )),
436 },
437 "close_connection" => Ok(ActionResult::CloseConnection),
441 _ => Err(anyhow::anyhow!("Unknown Redis action: {}", action_type)),
442 }
443 }
444}
445
446impl RedisProtocol {
447 fn execute_redis_simple_string(&self, action: serde_json::Value) -> Result<ActionResult> {
453 let value = action
454 .get("value")
455 .and_then(|v| v.as_str())
456 .context("Missing 'value' field")?;
457
458 debug!("Redis simple string response: {}", value);
459 let _ = self
460 .status_tx
461 .send(format!("[DEBUG] Redis → Simple string: {}", value));
462
463 Ok(ActionResult::Output(encode_simple_string(value)))
464 }
465
466 fn execute_redis_bulk_string(&self, action: serde_json::Value) -> Result<ActionResult> {
467 let value = action.get("value");
468
469 let result = if let Some(v) = value {
470 if v.is_null() {
471 None
472 } else if let Some(s) = v.as_str() {
473 Some(s.as_bytes().to_vec())
474 } else {
475 Some(v.to_string().as_bytes().to_vec())
476 }
477 } else {
478 None
479 };
480
481 debug!("Redis bulk string response: {:?}", result);
482 let _ = self.status_tx.send(format!(
483 "[DEBUG] Redis → Bulk string: {} bytes",
484 result.as_ref().map(|v| v.len()).unwrap_or(0)
485 ));
486
487 Ok(ActionResult::Output(match result {
488 Some(bytes) => encode_bulk_string(&bytes),
489 None => super::resp3::null(self.protocol_version()),
490 }))
491 }
492
493 fn execute_redis_array(&self, action: serde_json::Value) -> Result<ActionResult> {
494 let values = action
495 .get("values")
496 .and_then(|v| v.as_array())
497 .context("Missing 'values' array")?;
498
499 debug!("Redis array response: {} elements", values.len());
500 let _ = self
501 .status_tx
502 .send(format!("[DEBUG] Redis → Array: {} elements", values.len()));
503
504 Ok(ActionResult::Output(super::resp3::array(
505 values,
506 self.protocol_version(),
507 )?))
508 }
509
510 fn execute_redis_integer(&self, action: serde_json::Value) -> Result<ActionResult> {
511 let value = action
512 .get("value")
513 .and_then(|v| v.as_i64())
514 .context("Missing 'value' field")?;
515
516 debug!("Redis integer response: {}", value);
517 let _ = self
518 .status_tx
519 .send(format!("[DEBUG] Redis → Integer: {}", value));
520
521 Ok(ActionResult::Output(encode_integer(value)))
522 }
523
524 fn execute_redis_error(&self, action: serde_json::Value) -> Result<ActionResult> {
525 let message = action
526 .get("message")
527 .and_then(|v| v.as_str())
528 .context("Missing 'message' field")?;
529
530 debug!(
535 "Redis connection {} decision=model_reject: {}",
536 self.connection_id,
537 crate::utils::truncate_for_log(message, 512)
538 );
539 let _ = self
540 .status_tx
541 .send(format!("[DEBUG] Redis ✗ Error: {}", message));
542
543 Ok(ActionResult::Output(encode_error(message)))
544 }
545
546 fn execute_redis_null(&self, _action: serde_json::Value) -> Result<ActionResult> {
547 debug!("Redis null response");
548 let _ = self.status_tx.send("[DEBUG] Redis → Null".to_string());
549
550 Ok(ActionResult::Output(super::resp3::null(
551 self.protocol_version(),
552 )))
553 }
554
555 fn execute_resp3(&self, kind: &str, action: &serde_json::Value) -> Result<ActionResult> {
557 use super::resp3;
558 let proto = self.protocol_version();
559 let items = || -> Result<&Vec<serde_json::Value>> {
560 action
561 .get("values")
562 .and_then(|v| v.as_array())
563 .context("Missing 'values' array")
564 };
565 let bytes = match kind {
566 "redis_map" => resp3::map(
567 &resp3::entries(action.get("entries").context("Missing 'entries'")?)?,
568 proto,
569 )?,
570 "redis_set" => resp3::set(items()?, proto)?,
571 "redis_double" => {
572 resp3::double(action.get("value").context("Missing 'value'")?, proto)?
573 }
574 "redis_boolean" => resp3::boolean(
575 action
576 .get("value")
577 .and_then(|v| v.as_bool())
578 .context("Missing boolean 'value'")?,
579 proto,
580 ),
581 "redis_big_number" => resp3::big_number(
582 action
583 .get("value")
584 .and_then(|v| v.as_str())
585 .context("Missing 'value' (a string of digits)")?,
586 proto,
587 )?,
588 "redis_verbatim_string" => resp3::verbatim(
589 action
590 .get("format")
591 .and_then(|v| v.as_str())
592 .unwrap_or("txt"),
593 action
594 .get("text")
595 .and_then(|v| v.as_str())
596 .context("Missing 'text'")?,
597 proto,
598 )?,
599 _ => resp3::push(items()?, proto)?,
600 };
601 debug!(
602 "Redis {} reply (RESP{}): {} bytes",
603 kind,
604 proto,
605 bytes.len()
606 );
607 let _ = self
608 .status_tx
609 .send(format!("[DEBUG] Redis → {kind} (RESP{proto})"));
610 Ok(ActionResult::Output(bytes))
611 }
612}
613
614fn sanitize_simple_payload(s: &str, action: &str) -> String {
640 let cleaned = crate::utils::sanitize::line_field(s);
641 if cleaned == s {
642 return cleaned;
643 }
644 warn!(
645 "Redis: {} payload contained a control character, which for CR/LF would have ended the \
646 RESP frame early and desynchronised the connection; mapping each to a space (as Redis \
647 does)",
648 action
649 );
650 cleaned
651}
652
653pub fn encode_simple_string(s: &str) -> Vec<u8> {
655 format!("+{}\r\n", sanitize_simple_payload(s, "simple string")).into_bytes()
656}
657
658pub fn encode_bulk_string(bytes: &[u8]) -> Vec<u8> {
660 let mut result = format!("${}\r\n", bytes.len()).into_bytes();
661 result.extend_from_slice(bytes);
662 result.extend_from_slice(b"\r\n");
663 result
664}
665
666pub fn encode_null() -> Vec<u8> {
668 b"$-1\r\n".to_vec()
669}
670
671pub fn encode_integer(i: i64) -> Vec<u8> {
673 format!(":{}\r\n", i).into_bytes()
674}
675
676pub fn encode_error(msg: &str) -> Vec<u8> {
678 format!("-{}\r\n", sanitize_simple_payload(msg, "error")).into_bytes()
679}
680
681pub fn encode_array(values: &[serde_json::Value]) -> Vec<u8> {
688 let mut result = format!("*{}\r\n", values.len()).into_bytes();
689
690 for value in values {
691 match value {
692 serde_json::Value::String(s) => {
693 result.extend_from_slice(&encode_bulk_string(s.as_bytes()));
694 }
695 serde_json::Value::Number(n) => {
696 if let Some(i) = n.as_i64() {
697 result.extend_from_slice(&encode_integer(i));
698 } else {
699 let s = n.to_string();
701 result.extend_from_slice(&encode_bulk_string(s.as_bytes()));
702 }
703 }
704 serde_json::Value::Bool(b) => {
705 let s = if *b { "1" } else { "0" };
706 result.extend_from_slice(&encode_bulk_string(s.as_bytes()));
707 }
708 serde_json::Value::Null => {
709 result.extend_from_slice(&encode_null());
710 }
711 serde_json::Value::Array(_) | serde_json::Value::Object(_) => {
712 let s = value.to_string();
714 result.extend_from_slice(&encode_bulk_string(s.as_bytes()));
715 }
716 }
717 }
718
719 result
720}
721
722pub fn redis_simple_string_action() -> ActionDefinition {
724 ActionDefinition {
725 name: "redis_simple_string".to_string(),
726 description: "Send a simple string response (e.g., '+OK\\r\\n')".to_string(),
727 parameters: vec![Parameter {
728 name: "value".to_string(),
729 type_hint: "string".to_string(),
730 description: "The string value to send (without RESP encoding)".to_string(),
731 required: true,
732 }],
733 example: json!({
734 "type": "redis_simple_string",
735 "value": "OK"
736 }),
737 log_template: Some(
738 LogTemplate::new()
739 .with_info("-> Redis +{value}")
740 .with_debug("Redis redis_simple_string: value={value}"),
741 ),
742 }
743}
744
745pub fn redis_bulk_string_action() -> ActionDefinition {
747 ActionDefinition {
748 name: "redis_bulk_string".to_string(),
749 description: "Send a bulk string response (e.g., '$5\\r\\nhello\\r\\n'). Use null for nil bulk string".to_string(),
750 parameters: vec![Parameter {
751 name: "value".to_string(),
752 type_hint: "string".to_string(),
753 description: "The string value to send, or null for nil bulk string".to_string(),
754 required: false,
755 }],
756 example: json!({
757 "type": "redis_bulk_string",
758 "value": "hello world"
759 }),
760 log_template: Some(
761 LogTemplate::new()
762 .with_info("-> Redis ${value_len}B")
763 .with_debug("Redis redis_bulk_string: {value_len} bytes"),
764 ),
765 }
766}
767
768pub fn redis_array_action() -> ActionDefinition {
770 ActionDefinition {
771 name: "redis_array".to_string(),
772 description: "Send an array response, for commands that return a list (KEYS, MGET, LRANGE, SCAN...)".to_string(),
773 parameters: vec![Parameter {
774 name: "values".to_string(),
775 type_hint: "array".to_string(),
776 description: "Array of values. A string becomes a bulk string, an integer becomes a RESP integer, true/false become the bulk strings \"1\"/\"0\", null becomes a nil element, and a nested array or object is sent as its JSON text in a bulk string".to_string(),
777 required: true,
778 }],
779 example: json!({
780 "type": "redis_array",
781 "values": ["value1", "value2", "value3"]
782 }),
783 log_template: Some(
784 LogTemplate::new()
785 .with_info("-> Redis *{values_len} elements")
786 .with_debug("Redis redis_array: {values_len} elements"),
787 ),
788 }
789}
790
791pub fn redis_integer_action() -> ActionDefinition {
793 ActionDefinition {
794 name: "redis_integer".to_string(),
795 description: "Send an integer response (e.g., ':42\\r\\n')".to_string(),
796 parameters: vec![Parameter {
797 name: "value".to_string(),
798 type_hint: "integer".to_string(),
799 description: "The integer value to send".to_string(),
800 required: true,
801 }],
802 example: json!({
803 "type": "redis_integer",
804 "value": 42
805 }),
806 log_template: Some(
807 LogTemplate::new()
808 .with_info("-> Redis :{value}")
809 .with_debug("Redis redis_integer: value={value}"),
810 ),
811 }
812}
813
814pub fn redis_error_action() -> ActionDefinition {
816 ActionDefinition {
817 name: "redis_error".to_string(),
818 description: "Send an error response (e.g., '-ERR message\\r\\n')".to_string(),
819 parameters: vec![Parameter {
820 name: "message".to_string(),
821 type_hint: "string".to_string(),
822 description: "The error message to send".to_string(),
823 required: true,
824 }],
825 example: json!({
826 "type": "redis_error",
827 "message": "ERR unknown command 'foobar'"
828 }),
829 log_template: Some(
830 LogTemplate::new()
831 .with_info("-> Redis -{message}")
832 .with_debug("Redis redis_error: {message}"),
833 ),
834 }
835}
836
837pub fn redis_null_action() -> ActionDefinition {
839 ActionDefinition {
840 name: "redis_null".to_string(),
841 description: "Send a null response ('$-1\\r\\n')".to_string(),
842 parameters: vec![],
843 example: json!({
844 "type": "redis_null"
845 }),
846 log_template: Some(
847 LogTemplate::new()
848 .with_info("-> Redis (nil)")
849 .with_debug("Redis redis_null"),
850 ),
851 }
852}
853
854pub fn close_this_connection_action() -> ActionDefinition {
856 ActionDefinition {
857 name: "close_this_connection".to_string(),
858 description: "Close the current Redis connection".to_string(),
859 parameters: vec![],
860 example: json!({"type": "close_this_connection"}),
861 log_template: Some(
862 LogTemplate::new()
863 .with_info("Redis connection closed")
864 .with_debug("Redis close_this_connection"),
865 ),
866 }
867}
868
869pub const ACCEPT_SUBSCRIPTION: &str = "redis_accept_subscription";
871pub const DELIVER_MESSAGE: &str = "redis_deliver_message";
873
874fn execute_on_shared(
876 shared: &super::RedisShared,
877 action: &serde_json::Value,
878 status_tx: &mpsc::UnboundedSender<String>,
879) -> Result<ActionResult> {
880 match action.get("type").and_then(|v| v.as_str()) {
881 Some("redis_publish_message") => {
882 let channel = action
883 .get("channel")
884 .and_then(|v| v.as_str())
885 .context("Missing 'channel'")?;
886 let message = action
887 .get("message")
888 .and_then(|v| v.as_str())
889 .context("Missing 'message'")?;
890 let shard = action
891 .get("shard")
892 .and_then(|v| v.as_bool())
893 .unwrap_or(false);
894 let kind = if shard {
895 super::pubsub::Kind::Shard
896 } else {
897 super::pubsub::Kind::Channel
898 };
899 super::pubsub::check_name(kind, channel.as_bytes()).map_err(anyhow::Error::msg)?;
900 super::pubsub::check_message(message.as_bytes()).map_err(anyhow::Error::msg)?;
901 let receivers = shared
902 .hub
903 .publish(channel.as_bytes(), message.as_bytes(), shard);
904 debug!(
905 "Redis decision=model_publish channel='{}' receivers={}",
906 crate::utils::truncate_for_log(channel, 128),
907 receivers
908 );
909 let _ = status_tx.send(format!(
910 "[INFO] Redis published to '{}': {} receiver(s)",
911 crate::utils::truncate_for_log(channel, 128),
912 receivers
913 ));
914 Ok(ActionResult::NoAction)
915 }
916 Some("redis_cluster_set_slot") => {
917 let cluster = shared.cluster.as_ref().context(
918 "this server is not in cluster mode (start it with the cluster_nodes parameter)",
919 )?;
920 let slots =
921 super::cluster::parse_slots(action.get("slots").context("Missing 'slots'")?)?;
922 let state = action
923 .get("state")
924 .and_then(|v| v.as_str())
925 .unwrap_or("node");
926 let node = action.get("node").and_then(|v| v.as_str());
927 let summary = cluster
928 .lock()
929 .unwrap_or_else(|p| p.into_inner())
930 .set_slot(slots, state, node)?;
931 let _ = status_tx.send(format!("[INFO] Redis cluster: {summary}"));
932 debug!("Redis cluster slot change: {}", summary);
933 Ok(ActionResult::NoAction)
934 }
935 other => Err(anyhow::anyhow!("not a server-state action: {other:?}")),
936 }
937}
938
939pub fn redis_accept_subscription_action() -> ActionDefinition {
941 ActionDefinition {
942 name: "redis_accept_subscription".to_string(),
943 description: "Allow the subscription a redis_subscribe event asks for. The server then \
944 registers it and sends the client one confirmation per channel; to refuse \
945 instead, answer with redis_error (e.g. NOPERM ...)."
946 .to_string(),
947 parameters: vec![],
948 example: json!({"type": "redis_accept_subscription"}),
949 log_template: Some(
950 LogTemplate::new()
951 .with_info("-> Redis subscription accepted")
952 .with_debug("Redis redis_accept_subscription"),
953 ),
954 }
955}
956
957pub fn redis_deliver_message_action() -> ActionDefinition {
959 ActionDefinition {
960 name: "redis_deliver_message".to_string(),
961 description: "Deliver the message a redis_publish event is about to every subscriber of \
962 its channel (and of every matching pattern), and answer the publisher with \
963 the number of deliveries. To answer without delivering, send redis_integer \
964 instead; to refuse, redis_error."
965 .to_string(),
966 parameters: vec![],
967 example: json!({"type": "redis_deliver_message"}),
968 log_template: Some(
969 LogTemplate::new()
970 .with_info("-> Redis message delivered")
971 .with_debug("Redis redis_deliver_message"),
972 ),
973 }
974}
975
976pub fn redis_publish_message_action() -> ActionDefinition {
978 ActionDefinition {
979 name: "redis_publish_message".to_string(),
980 description: "Publish a message of your own to every client of this server subscribed \
981 to the channel (or to a matching pattern), as if a client had sent PUBLISH. \
982 Writes nothing to the client whose command you are answering, so still \
983 answer it."
984 .to_string(),
985 parameters: vec![
986 param("channel", "string", "The channel to publish on", true),
987 param("message", "string", "The message text", true),
988 param(
989 "shard",
990 "boolean",
991 "true to publish on the shard channel (SPUBLISH) rather than the ordinary one",
992 false,
993 ),
994 ],
995 example: json!({"type": "redis_publish_message", "channel": "news", "message": "hello"}),
996 log_template: Some(
997 LogTemplate::new()
998 .with_info("-> Redis PUBLISH {channel}")
999 .with_debug("Redis redis_publish_message: channel={channel}"),
1000 ),
1001 }
1002}
1003
1004pub fn redis_cluster_set_slot_action() -> ActionDefinition {
1006 ActionDefinition {
1007 name: "redis_cluster_set_slot".to_string(),
1008 description: "Change this server's view of the cluster, as CLUSTER SETSLOT does (cluster \
1009 mode only). state node: the slots now belong to node, so this server \
1010 redirects them there with -MOVED; migrating: this server's slots are moving \
1011 to node and it answers -ASK; importing: the slots are arriving from node and \
1012 are served after ASKING; stable: clear migrating/importing."
1013 .to_string(),
1014 parameters: vec![
1015 param(
1016 "slots",
1017 "string",
1018 "A slot (\"42\") or an inclusive range (\"0-100\")",
1019 true,
1020 ),
1021 param(
1022 "state",
1023 "string",
1024 "node (default), migrating, importing or stable",
1025 false,
1026 ),
1027 param(
1028 "node",
1029 "string",
1030 "The other node, as host:port or its 40-character id (not needed for stable)",
1031 false,
1032 ),
1033 ],
1034 example: json!({"type": "redis_cluster_set_slot", "slots": "0-100", "state": "stable"}),
1035 log_template: Some(
1036 LogTemplate::new()
1037 .with_info("-> Redis cluster slots {slots} {state}")
1038 .with_debug("Redis redis_cluster_set_slot: slots={slots} state={state}"),
1039 ),
1040 }
1041}
1042
1043pub static REDIS_SIMPLE_STRING_ACTION: LazyLock<ActionDefinition> =
1048 LazyLock::new(|| redis_simple_string_action());
1049pub static REDIS_BULK_STRING_ACTION: LazyLock<ActionDefinition> =
1050 LazyLock::new(|| redis_bulk_string_action());
1051pub static REDIS_ARRAY_ACTION: LazyLock<ActionDefinition> = LazyLock::new(|| redis_array_action());
1052pub static REDIS_INTEGER_ACTION: LazyLock<ActionDefinition> =
1053 LazyLock::new(|| redis_integer_action());
1054pub static REDIS_ERROR_ACTION: LazyLock<ActionDefinition> = LazyLock::new(|| redis_error_action());
1055pub static REDIS_NULL_ACTION: LazyLock<ActionDefinition> = LazyLock::new(|| redis_null_action());
1056pub static REDIS_CLOSE_CONNECTION_ACTION: LazyLock<ActionDefinition> =
1057 LazyLock::new(|| close_this_connection_action());
1058
1059pub static REDIS_COMMAND_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1065 EventType::new(
1066 "redis_command",
1067 "Redis command received from client",
1068 json!({"type": "placeholder", "event_id": "redis_command"}),
1069 )
1070 .with_parameters(vec![
1071 Parameter {
1072 name: "command".to_string(),
1073 type_hint: "string".to_string(),
1074 description: "The Redis command string sent by the client".to_string(),
1075 required: true,
1076 },
1077 Parameter {
1078 name: "protocol".to_string(),
1079 type_hint: "number".to_string(),
1080 description: "The RESP version this connection speaks: 2, or 3 after the client sent HELLO 3. RESP3-only replies (map, set, double, boolean, big number, verbatim) are downgraded on RESP2; a push needs RESP3".to_string(),
1081 required: true,
1082 },
1083 ])
1084 .with_actions(vec![
1085 REDIS_SIMPLE_STRING_ACTION.clone(),
1086 REDIS_BULK_STRING_ACTION.clone(),
1087 REDIS_ARRAY_ACTION.clone(),
1088 REDIS_INTEGER_ACTION.clone(),
1089 REDIS_ERROR_ACTION.clone(),
1090 REDIS_NULL_ACTION.clone(),
1091 REDIS_CLOSE_CONNECTION_ACTION.clone(),
1092 redis_publish_message_action(),
1093 redis_cluster_set_slot_action(),
1094 ]
1095 .into_iter()
1096 .chain(resp3_actions())
1097 .collect())
1098 .with_log_template(
1099 LogTemplate::new()
1100 .with_info("Redis: {command}")
1101 .with_debug("Redis command: {command}")
1102 .with_trace("Redis: {json_pretty(.)}"),
1103 )
1104});
1105
1106pub static REDIS_SUBSCRIBE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1109 EventType::new(
1110 "redis_subscribe",
1111 "A client asked to subscribe to channels or patterns",
1112 json!({"type": "redis_accept_subscription"}),
1113 )
1114 .with_parameters(vec![
1115 param(
1116 "command",
1117 "string",
1118 "SUBSCRIBE (channels), PSUBSCRIBE (glob patterns) or SSUBSCRIBE (shard channels)",
1119 true,
1120 ),
1121 param(
1122 "channels",
1123 "array",
1124 "The channels or patterns requested, in order",
1125 true,
1126 ),
1127 param(
1128 "subscriptions",
1129 "number",
1130 "How many subscriptions this connection already holds",
1131 true,
1132 ),
1133 param(
1134 "protocol",
1135 "number",
1136 "The connection's RESP version: messages arrive as pushes on 3, arrays on 2",
1137 true,
1138 ),
1139 ])
1140 .with_actions(vec![
1141 redis_accept_subscription_action(),
1142 REDIS_ERROR_ACTION.clone(),
1143 redis_publish_message_action(),
1144 REDIS_CLOSE_CONNECTION_ACTION.clone(),
1145 ])
1146 .with_log_template(
1147 LogTemplate::new()
1148 .with_info("Redis: {command} {channels}")
1149 .with_debug("Redis subscribe: {command} {channels}"),
1150 )
1151});
1152
1153pub static REDIS_PUBLISH_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1155 EventType::new(
1156 "redis_publish",
1157 "A client published a message to a channel",
1158 json!({"type": "redis_deliver_message"}),
1159 )
1160 .with_parameters(vec![
1161 param(
1162 "command",
1163 "string",
1164 "PUBLISH, or SPUBLISH for a shard channel",
1165 true,
1166 ),
1167 param("channel", "string", "The channel the message is for", true),
1168 param("message", "string", "The message text", true),
1169 param(
1170 "receivers",
1171 "number",
1172 "How many deliveries redis_deliver_message would make right now",
1173 true,
1174 ),
1175 param(
1176 "protocol",
1177 "number",
1178 "The publisher's RESP version (2 or 3)",
1179 true,
1180 ),
1181 ])
1182 .with_actions(vec![
1183 redis_deliver_message_action(),
1184 REDIS_INTEGER_ACTION.clone(),
1185 REDIS_ERROR_ACTION.clone(),
1186 redis_publish_message_action(),
1187 REDIS_CLOSE_CONNECTION_ACTION.clone(),
1188 ])
1189 .with_log_template(
1190 LogTemplate::new()
1191 .with_info("Redis: {command} {channel}")
1192 .with_debug("Redis publish: {command} {channel} ({receivers} receivers)"),
1193 )
1194});
1195
1196pub fn get_redis_event_types() -> Vec<EventType> {
1198 vec![
1199 REDIS_COMMAND_EVENT.clone(),
1200 REDIS_SUBSCRIBE_EVENT.clone(),
1201 REDIS_PUBLISH_EVENT.clone(),
1202 ]
1203}
1204
1205fn resp3_action(
1206 name: &str,
1207 description: &str,
1208 parameters: Vec<Parameter>,
1209 example: serde_json::Value,
1210 info: &str,
1211) -> ActionDefinition {
1212 ActionDefinition {
1213 name: name.to_string(),
1214 description: description.to_string(),
1215 parameters,
1216 example,
1217 log_template: Some(
1218 LogTemplate::new()
1219 .with_info(info)
1220 .with_debug(format!("Redis {name}")),
1221 ),
1222 }
1223}
1224
1225fn param(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
1226 Parameter {
1227 name: name.to_string(),
1228 type_hint: type_hint.to_string(),
1229 description: description.to_string(),
1230 required,
1231 }
1232}
1233
1234pub fn resp3_actions() -> Vec<ActionDefinition> {
1237 vec![
1238 resp3_action(
1239 "redis_map",
1240 "Send a map (HGETALL, CONFIG GET, XINFO…): a RESP3 map, or on RESP2 the flat key/value array Redis sends there",
1241 vec![param("entries", "object", "The map as a JSON object, or [[key, value], …] when order matters. Values follow redis_array's rules (on RESP3 a nested object is a map, an array an array, a fraction a double, true/false booleans, null a null)", true)],
1242 json!({"type": "redis_map", "entries": {"name": "Ada", "visits": 3}}),
1243 "-> Redis map",
1244 ),
1245 resp3_action(
1246 "redis_set",
1247 "Send a set (SMEMBERS, SINTER…): a RESP3 set, or an array on RESP2",
1248 vec![param("values", "array", "The members, encoded as redis_array encodes its elements", true)],
1249 json!({"type": "redis_set", "values": ["red", "green"]}),
1250 "-> Redis set {values_len} members",
1251 ),
1252 resp3_action(
1253 "redis_double",
1254 "Send a floating-point number (ZSCORE, INCRBYFLOAT…): a RESP3 double, or a bulk string on RESP2",
1255 vec![param("value", "number", "The number, or the string inf, -inf or nan", true)],
1256 json!({"type": "redis_double", "value": 2.5}),
1257 "-> Redis double {value}",
1258 ),
1259 resp3_action(
1260 "redis_boolean",
1261 "Send true or false: a RESP3 boolean, or the integer 1/0 on RESP2",
1262 vec![param("value", "boolean", "The boolean to send: true or false (1 or 0 on RESP2)", true)],
1263 json!({"type": "redis_boolean", "value": true}),
1264 "-> Redis boolean {value}",
1265 ),
1266 resp3_action(
1267 "redis_big_number",
1268 "Send an integer too large for 64 bits: a RESP3 big number, or a bulk string on RESP2",
1269 vec![param("value", "string", "The decimal digits, optionally with a leading minus sign", true)],
1270 json!({"type": "redis_big_number", "value": "3492890328409238509324850943850943825024385"}),
1271 "-> Redis big number",
1272 ),
1273 resp3_action(
1274 "redis_verbatim_string",
1275 "Send text a client should show as is (INFO, LATENCY DOCTOR): a RESP3 verbatim string, or a bulk string on RESP2",
1276 vec![
1277 param("text", "string", "The text, newlines allowed", true),
1278 param("format", "string", "Three letters naming the text's format: txt (default) or mkd", false),
1279 ],
1280 json!({"type": "redis_verbatim_string", "text": "# Server\nredis_version:7.4.0", "format": "txt"}),
1281 "-> Redis verbatim string",
1282 ),
1283 resp3_action(
1284 "redis_push",
1285 "Send an out-of-band push (pub/sub message, client-side-caching invalidation). RESP3 only: refused on a RESP2 connection",
1286 vec![param("values", "array", "The push's elements, the first naming its kind, e.g. [\"message\", \"news\", \"hello\"]", true)],
1287 json!({"type": "redis_push", "values": ["message", "news", "hello"]}),
1288 "-> Redis push {values_len} elements",
1289 ),
1290 ]
1291}