Skip to main content

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}