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