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}