dynomite/embed/extension.rs
1//! Command-dispatch extension hook.
2//!
3//! The cluster substrate ships the parser, the dispatcher, and
4//! the standard data-plane commands (GET / SET / HSET / ...).
5//! Layered surfaces - notably the RediSearch FT.* commands -
6//! plug in via the [`CommandExtension`] trait so the substrate
7//! does not need to know about them at compile time.
8//!
9//! # Lifecycle
10//!
11//! 1. The embedder constructs a
12//! [`crate::embed::ServerBuilder`].
13//! 2. The embedder (or a helper crate such as
14//! `dynomite-search`) attaches a [`CommandExtension`] via
15//! [`crate::embed::ServerBuilder::with_command_extension`]
16//! or [`crate::embed::ServerBuilder::set_command_extension`].
17//! 3. The dispatcher consults the extension in the hot path:
18//! * For commands the parser tags as
19//! [`crate::msg::MsgType::ReqRedisFtCreate`] /
20//! [`crate::msg::MsgType::ReqRedisFtSearch`] /
21//! [`crate::msg::MsgType::ReqRedisFtInfo`] /
22//! [`crate::msg::MsgType::ReqRedisFtList`] /
23//! [`crate::msg::MsgType::ReqRedisFtDropindex`] /
24//! [`crate::msg::MsgType::ReqRedisFtRegex`] /
25//! [`crate::msg::MsgType::ReqRedisFtSugadd`] /
26//! [`crate::msg::MsgType::ReqRedisFtSugget`] /
27//! [`crate::msg::MsgType::ReqRedisFtSugdel`] /
28//! [`crate::msg::MsgType::ReqRedisFtSuglen`] /
29//! [`crate::msg::MsgType::ReqRedisFtUnknown`] the
30//! dispatcher checks
31//! [`CommandExtension::handles_msg_type`] and, if true,
32//! delegates execution to
33//! [`CommandExtension::try_dispatch`].
34//! * Every HSET request is offered to
35//! [`CommandExtension::try_intercept_hset`] before the
36//! standard fan-out path runs.
37//! 4. When no extension is wired the dispatcher behaves
38//! exactly as it did before this hook existed: FT.* keywords
39//! are forwarded to the local datastore (which typically
40//! rejects them with `-ERR unknown command`).
41//!
42//! Implementations are object-safe; the dispatcher holds an
43//! [`std::sync::Arc<dyn CommandExtension>`] and clones the
44//! handle freely across tasks.
45
46use std::fmt::Debug;
47
48use crate::msg::MsgType;
49
50/// Outcome of [`CommandExtension::try_intercept_hset`].
51///
52/// The HSET interception path runs before the dispatcher's
53/// routing planner. The extension can either absorb the write
54/// (the standard storage write still fires; the engine just
55/// got a free side-effect), reject it with a structured error
56/// reply, or pass through.
57#[derive(Clone, Debug, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum HsetOutcome {
60 /// The HSET key matched a registered prefix and the
61 /// extension absorbed the write side-effect. The
62 /// dispatcher proceeds with the standard storage write so
63 /// the underlying hash document still lands on the backend.
64 Absorbed,
65 /// The HSET key did not match any registered prefix.
66 /// Equivalent to no extension being installed for this
67 /// command.
68 NotIndexed,
69 /// The HSET key matched a registered prefix but the
70 /// payload was malformed. The dispatcher synthesises a
71 /// `-ERR <message>\r\n` reply and returns it directly to
72 /// the client without writing to the backend.
73 Error(String),
74}
75
76/// Pluggable command-dispatch hook.
77///
78/// Implementors short-circuit dispatcher routing for the
79/// command families they own; everything else falls through
80/// to the standard substrate. See the module-level docs for
81/// the lifecycle and the standard-library hook used by
82/// `dynomite-search`.
83pub trait CommandExtension: Send + Sync + Debug {
84 /// True when the parsed `MsgType` is one this extension
85 /// wants to dispatch. The dispatcher only invokes
86 /// [`Self::try_dispatch`] when this returns `true`.
87 fn handles_msg_type(&self, ty: MsgType) -> bool;
88
89 /// Try to dispatch a command. `args` is the parsed RESP
90 /// argument vector starting with the command keyword
91 /// (e.g. `[b"FT.SEARCH", b"idx", ...]`).
92 ///
93 /// Returns `Some(resp_bytes)` when the extension produced
94 /// a complete RESP reply for the client; `None` to fall
95 /// through to the standard dispatch path. The dispatcher
96 /// only consults this method after
97 /// [`Self::handles_msg_type`] returns `true`, so a
98 /// well-behaved implementation may safely assume the
99 /// command keyword is one of the families it advertised.
100 fn try_dispatch(&self, args: &[&[u8]]) -> Option<Vec<u8>>;
101
102 /// Inspect an HSET argument list and, if it matches a
103 /// registered prefix / shape, perform any side-effects the
104 /// extension wants. `args` is `[key, f1, v1, f2, v2, ...]`
105 /// (without the leading `HSET` keyword).
106 ///
107 /// See [`HsetOutcome`] for the response shape. The default
108 /// impl returns [`HsetOutcome::NotIndexed`] so trait
109 /// implementors that do not care about HSET interception
110 /// only need to implement [`Self::try_dispatch`].
111 fn try_intercept_hset(&self, _args: &[&[u8]]) -> HsetOutcome {
112 HsetOutcome::NotIndexed
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 #[derive(Debug)]
121 struct NoOp;
122
123 impl CommandExtension for NoOp {
124 fn handles_msg_type(&self, _ty: MsgType) -> bool {
125 false
126 }
127 fn try_dispatch(&self, _args: &[&[u8]]) -> Option<Vec<u8>> {
128 None
129 }
130 }
131
132 #[test]
133 fn default_hset_is_not_indexed() {
134 let ext = NoOp;
135 let outcome = ext.try_intercept_hset(&[b"key" as &[u8], b"f", b"v"]);
136 assert_eq!(outcome, HsetOutcome::NotIndexed);
137 }
138}