Skip to main content

fynd_core/algorithm/
registry.rs

1//! Custom [`Algorithm`] implementations, held by name.
2//!
3//! The built-in algorithms are looked up by a fixed list of names that a crate outside this one
4//! cannot add to. A registry carries the ones a caller brought, so a pool configuration naming one
5//! resolves the same way it resolves a built-in.
6//!
7//! ```ignore
8//! // In the deployment's binary:
9//! let algorithms = AlgorithmRegistry::new().with_algorithm("my_algo", MyAlgorithm::new)?;
10//! let solver = FyndBuilder::new(..).with_algorithms(algorithms).build()?;
11//! ```
12//! ```toml
13//! # In worker_pools.toml, exactly as a built-in is named:
14//! [pools.mine]
15//! algorithm = "my_algo"
16//! ```
17
18use std::{collections::HashMap, sync::Arc};
19
20use crate::{
21    algorithm::{Algorithm, AlgorithmConfig},
22    feed::events::MarketEventHandler,
23    graph::EdgeWeightUpdaterWithDerived,
24    worker_pool::{
25        pool::WorkerPoolBuilder,
26        registry::{UnknownAlgorithmError, AVAILABLE_ALGORITHMS},
27    },
28};
29
30/// Points a pool at one registered algorithm.
31///
32/// Behind an `Arc` so the registry can be cloned: a deployment that both serves and benchmarks the
33/// same algorithms registers them once.
34type Configure = Arc<dyn Fn(WorkerPoolBuilder) -> WorkerPoolBuilder + Send + Sync>;
35
36/// Registering an algorithm under a name that is already taken.
37#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
38pub enum RegisterAlgorithmError {
39    /// The name belongs to an algorithm that ships with this crate.
40    #[error("'{name}' is a built-in algorithm; registering it would replace the shipped one")]
41    ShadowsBuiltIn {
42        /// The name that clashes.
43        name: String,
44    },
45
46    /// The name was already registered by an earlier call.
47    #[error("'{name}' is already registered")]
48    AlreadyRegistered {
49        /// The name that clashes.
50        name: String,
51    },
52}
53
54/// Algorithms a caller brought, keyed by the name a pool configuration uses to ask for one.
55///
56/// Empty by default, which is every deployment running only the built-ins.
57#[derive(Default, Clone)]
58pub struct AlgorithmRegistry {
59    by_name: HashMap<String, Configure>,
60}
61
62impl AlgorithmRegistry {
63    /// A registry holding nothing.
64    #[must_use]
65    pub fn new() -> Self {
66        Self::default()
67    }
68
69    /// Registers `factory` under `name`, so a pool asking for that algorithm is served by it.
70    ///
71    /// The factory is called once per worker thread.
72    ///
73    /// # Errors
74    ///
75    /// [`RegisterAlgorithmError::ShadowsBuiltIn`] when `name` is one this crate ships, and
76    /// [`RegisterAlgorithmError::AlreadyRegistered`] when an earlier call took it. Both are
77    /// refused rather than resolved silently: which algorithm a pool runs is not something to
78    /// decide by registration order.
79    pub fn with_algorithm<A, F>(
80        mut self,
81        name: impl Into<String>,
82        factory: F,
83    ) -> Result<Self, RegisterAlgorithmError>
84    where
85        A: Algorithm + 'static,
86        A::GraphManager: MarketEventHandler + EdgeWeightUpdaterWithDerived + 'static,
87        F: Fn(AlgorithmConfig) -> A + Clone + Send + Sync + 'static,
88    {
89        let name = name.into();
90        if AVAILABLE_ALGORITHMS.contains(&name.as_str()) {
91            return Err(RegisterAlgorithmError::ShadowsBuiltIn { name });
92        }
93        if self.by_name.contains_key(&name) {
94            return Err(RegisterAlgorithmError::AlreadyRegistered { name });
95        }
96        let registered = name.clone();
97        self.by_name.insert(
98            name,
99            Arc::new(move |builder: WorkerPoolBuilder| {
100                builder.with_algorithm(registered.clone(), factory.clone())
101            }),
102        );
103        Ok(self)
104    }
105
106    /// The names this registry can serve, for an error that has to say what was available.
107    pub fn names(&self) -> impl Iterator<Item = &str> {
108        self.by_name.keys().map(String::as_str)
109    }
110
111    /// Points `builder` at the algorithm `name` asks for.
112    ///
113    /// A registered name is served from here, a built-in one from the fixed list.
114    ///
115    /// # Errors
116    ///
117    /// [`UnknownAlgorithmError`] when neither holds the name. Raised here rather than at spawn
118    /// time because this is where both sets are known, so the message can list what a caller
119    /// registered as well as what ships.
120    pub(crate) fn configure(
121        &self,
122        name: &str,
123        builder: WorkerPoolBuilder,
124    ) -> Result<WorkerPoolBuilder, UnknownAlgorithmError> {
125        if let Some(configure) = self.by_name.get(name) {
126            return Ok(configure(builder));
127        }
128        if AVAILABLE_ALGORITHMS.contains(&name) {
129            return Ok(builder.algorithm(name));
130        }
131        Err(UnknownAlgorithmError::of(
132            name,
133            self.names()
134                .map(str::to_string)
135                .collect(),
136        ))
137    }
138}
139
140impl std::fmt::Debug for AlgorithmRegistry {
141    /// Names only: the factories behind them cannot be printed.
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        f.debug_struct("AlgorithmRegistry")
144            .field("names", &self.by_name.keys().collect::<Vec<_>>())
145            .finish()
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::algorithm::{most_liquid::MostLiquidAlgorithm, AlgorithmConfig};
153
154    /// `MostLiquidAlgorithm::with_config` is fallible; a factory is not.
155    fn most_liquid(config: AlgorithmConfig) -> MostLiquidAlgorithm {
156        MostLiquidAlgorithm::with_config(config).expect("the default config is valid")
157    }
158
159    fn registry_with(name: &str) -> AlgorithmRegistry {
160        AlgorithmRegistry::new()
161            .with_algorithm(name, most_liquid)
162            .expect("the name is neither built in nor taken")
163    }
164
165    #[test]
166    fn test_registry_is_empty_by_default() {
167        assert_eq!(AlgorithmRegistry::new().names().count(), 0);
168    }
169
170    /// Which algorithm a pool runs is not something to settle by registration order.
171    #[test]
172    fn test_registry_refuses_a_name_already_registered() {
173        let error = registry_with("mine")
174            .with_algorithm("mine", most_liquid)
175            .expect_err("the name is taken");
176
177        assert_eq!(error, RegisterAlgorithmError::AlreadyRegistered { name: "mine".to_string() });
178    }
179
180    /// Shadowing a shipped algorithm would change what a production pool runs, invisibly.
181    #[test]
182    fn test_registry_refuses_a_built_in_name() {
183        let error = AlgorithmRegistry::new()
184            .with_algorithm("most_liquid", most_liquid)
185            .expect_err("the name ships with this crate");
186
187        assert_eq!(
188            error,
189            RegisterAlgorithmError::ShadowsBuiltIn { name: "most_liquid".to_string() }
190        );
191    }
192
193    #[test]
194    fn test_configure_serves_a_registered_name() {
195        let Ok(builder) = registry_with("brought_from_outside")
196            .configure("brought_from_outside", WorkerPoolBuilder::new())
197        else {
198            panic!("a registered name is served");
199        };
200
201        assert!(builder.serves_custom_algorithm(), "a registered name is served by its factory");
202    }
203
204    #[test]
205    fn test_configure_leaves_a_built_in_name_to_the_built_in() {
206        let Ok(builder) = registry_with("mine").configure("water_fill", WorkerPoolBuilder::new())
207        else {
208            panic!("a built-in name is served");
209        };
210
211        assert!(
212            !builder.serves_custom_algorithm(),
213            "a built-in name is not served from the registry"
214        );
215    }
216
217    /// The message has to name what the deployment could actually have served, or an operator who
218    /// mistypes a registered name goes looking in the wrong place.
219    #[test]
220    fn test_configure_rejects_a_name_neither_side_holds() {
221        let Err(error) = registry_with("brought_from_outside")
222            .configure("brought_from_outsid", WorkerPoolBuilder::new())
223        else {
224            panic!("a name neither side holds must be refused");
225        };
226
227        let message = error.to_string();
228        assert!(message.contains("brought_from_outside"), "lists the registered name: {message}");
229        assert!(message.contains("water_fill"), "lists the built-ins too: {message}");
230    }
231}