Hydrators enrich candidates and queries with additional data by calling external services. They run in parallel to maximize performance and can be used at multiple stages of the pipeline.
The Hydrator trait defines the interface for enriching candidates:
candidate-pipeline/hydrator.rs
#[async_trait]pub trait Hydrator<Q, C>: Any + Send + Syncwhere Q: Clone + Send + Sync + 'static, C: Clone + Send + Sync + 'static,{ /// Decide if this hydrator should run for the given query fn enable(&self, _query: &Q) -> bool { true } /// Hydrate candidates by performing async operations. /// Returns candidates with this hydrator's fields populated. /// /// IMPORTANT: The returned vector must have the same candidates in the same order as the input. /// Dropping candidates in a hydrator is not allowed - use a filter stage instead. async fn hydrate(&self, query: &Q, candidates: &[C]) -> Result<Vec<C>, String>; /// Update a single candidate with the hydrated fields. /// Only the fields this hydrator is responsible for should be copied. fn update(&self, candidate: &mut C, hydrated: C); /// Update all candidates with the hydrated fields from `hydrated`. /// Default implementation iterates and calls `update` for each pair. fn update_all(&self, candidates: &mut [C], hydrated: Vec<C>) { for (c, h) in candidates.iter_mut().zip(hydrated) { self.update(c, h); } } fn name(&self) -> &'static str { util::short_type_name(type_name_of_val(self)) }}
The hydrate method must return candidates in the same order as the input. The returned vector must have the same length. Dropping or reordering candidates is not allowed - use a filter instead.
The QueryHydrator trait defines the interface for enriching queries:
candidate-pipeline/query_hydrator.rs
#[async_trait]pub trait QueryHydrator<Q>: Any + Send + Syncwhere Q: Clone + Send + Sync + 'static,{ /// Decide if this query hydrator should run for the given query fn enable(&self, _query: &Q) -> bool { true } /// Hydrate the query by performing async operations. /// Returns a new query with this hydrator's fields populated. async fn hydrate(&self, query: &Q) -> Result<Q, String>; /// Update the query with the hydrated fields. /// Only the fields this hydrator is responsible for should be copied. fn update(&self, query: &mut Q, hydrated: Q); fn name(&self) -> &'static str { util::short_type_name(type_name_of_val(self)) }}
If a hydrator fails or returns mismatched length, the error is logged and candidates remain unmodified. This ensures partial failures don’t break the pipeline.
Always batch external calls to minimize RPC overhead:
let tweet_ids = candidates.iter().map(|c| c.tweet_id).collect();let results = client.get_tweets(tweet_ids).await;
Maintain Order
The hydrated vector must match the input order and length:
let mut hydrated_candidates = Vec::with_capacity(candidates.len());for candidate in candidates { // Process each candidate in order hydrated_candidates.push(hydrated);}
Partial Updates
Only update fields this hydrator is responsible for:
Runs after candidate fetching, before filtering and scoring. Used for features needed for filtering/scoring.
2
Post-Selection Hydration
Runs after selection, only on candidates that will be returned. Used for expensive features only needed for display.
Use post-selection hydration for expensive operations that only need to run on the final selected candidates (e.g., fetching full tweet content, high-resolution images).