The Hydrator trait defines how candidates are enriched with additional data. Hydrators run in parallel and populate specific fields on candidate objects by making async calls to external services.
Hydrators MUST maintain the same candidate order and count. They cannot drop or reorder candidates - use Filter stages for removal.
Here’s a real example that hydrates tweet author information from the Gizmoduck user service:
use xai_candidate_pipeline::hydrator::Hydrator;use tonic::async_trait;pub struct GizmoduckCandidateHydrator { pub gizmoduck_client: Arc<dyn GizmoduckClient + Send + Sync>,}#[async_trait]impl Hydrator<ScoredPostsQuery, PostCandidate> for GizmoduckCandidateHydrator { async fn hydrate( &self, _query: &ScoredPostsQuery, candidates: &[PostCandidate], ) -> Result<Vec<PostCandidate>, String> { // Collect unique user IDs to fetch let author_ids: Vec<_> = candidates .iter() .map(|c| c.author_id as i64) .collect(); // Batch fetch user data let users = self.gizmoduck_client .get_users(author_ids) .await .map_err(|e| e.to_string())?; // Build hydrated candidates with only the fields we populate let mut hydrated_candidates = Vec::with_capacity(candidates.len()); for candidate in candidates { let user = users.get(&(candidate.author_id as i64)); let author_followers_count = user .and_then(|u| u.counts.as_ref()) .map(|c| c.followers_count as i32); let author_screen_name = user .and_then(|u| u.profile.as_ref()) .map(|p| p.screen_name.clone()); hydrated_candidates.push(PostCandidate { author_followers_count, author_screen_name, ..Default::default() }); } Ok(hydrated_candidates) } fn update(&self, candidate: &mut PostCandidate, hydrated: PostCandidate) { // Only update the fields this hydrator is responsible for candidate.author_followers_count = hydrated.author_followers_count; candidate.author_screen_name = hydrated.author_screen_name; }}