From beff4a451fc0c081de7871d121eb7bc696e842b8 Mon Sep 17 00:00:00 2001 From: Jon Bailey <297513015+Pitchfork-and-Torch@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:12:08 -0400 Subject: [PATCH] Fail closed when socialgraph mute/block relationship reads fail VF already marks a failed relationship RPC as Failed, then assemble defaults that to not muted / not blocked. Drop those candidates so FilterTweets can emit unresolved_author. Mixer BlockedBy hydrators returned Err on the same class of RPC failure. update_all skipped the write, so author_blocks_viewer stayed None and the filter treated that as not-blocked. Stamp Some(true). --- .../blocked_by_hydrator.rs | 16 ++- .../following_blocked_by_hydrator.rs | 16 ++- visibility-filtering/hydration/mod.rs | 114 +++++++++++++++++- 3 files changed, 139 insertions(+), 7 deletions(-) diff --git a/home-mixer/candidate_hydrators/blocked_by_hydrator.rs b/home-mixer/candidate_hydrators/blocked_by_hydrator.rs index b284df4d..587340bf 100644 --- a/home-mixer/candidate_hydrators/blocked_by_hydrator.rs +++ b/home-mixer/candidate_hydrators/blocked_by_hydrator.rs @@ -34,9 +34,19 @@ impl Hydrator for BlockedByHydrator { .await { Ok(ids) => ids, - Err(e) => { - let err_msg = e.to_string(); - return candidates.iter().map(|_| Err(err_msg.clone())).collect(); + Err(_) => { + // Err is skipped by update_all, leaving author_blocks_viewer + // None. AuthorSocialgraphFilter then unwrap_or(false). + // Write Some(true) so the filter drops. + return candidates + .iter() + .map(|_| { + Ok(PostCandidate { + author_blocks_viewer: Some(true), + ..Default::default() + }) + }) + .collect(); } }; candidates diff --git a/home-mixer/candidate_hydrators/following_blocked_by_hydrator.rs b/home-mixer/candidate_hydrators/following_blocked_by_hydrator.rs index d714cbd9..4b57ab13 100644 --- a/home-mixer/candidate_hydrators/following_blocked_by_hydrator.rs +++ b/home-mixer/candidate_hydrators/following_blocked_by_hydrator.rs @@ -33,9 +33,19 @@ impl Hydrator for FollowingBlockedByHydrator { .await { Ok(ids) => ids, - Err(e) => { - let err_msg = e.to_string(); - return candidates.iter().map(|_| Err(err_msg.clone())).collect(); + Err(_) => { + // Same as BlockedByHydrator: Err is skipped by update_all, + // and the filter treats None as not-blocked. Stamp true. + return candidates + .iter() + .map(|candidate| { + Ok(PostCandidate { + author_blocks_viewer: Some(true), + quoted_author_blocks_viewer: candidate.quoted_user_id.map(|_| true), + ..Default::default() + }) + }) + .collect(); } }; candidates diff --git a/visibility-filtering/hydration/mod.rs b/visibility-filtering/hydration/mod.rs index 0ea33a16..10f50c45 100644 --- a/visibility-filtering/hydration/mod.rs +++ b/visibility-filtering/hydration/mod.rs @@ -17,7 +17,7 @@ use crate::models::{ }; use crate::rules::SafetyLevel; use crate::safety_label_source::SafetyLabelSource; -use batch::TweetHydrationBatch; +use batch::{Hydrated, TweetHydrationBatch}; use exclusive_content_hydrator::ExclusiveContentHydrator; use fallback_cache::FallbackCache; use gizmoduck_hydrator::GizmoduckAuthorHydrator; @@ -62,6 +62,26 @@ struct CandidateFeatures { exclusive_content: HashMap>, } +/// Drop candidates whose socialgraph relationship read Failed. +/// `assemble` uses `get_or_default`, which turns Failed into "not muted / +/// not blocked". FilterTweets already maps a missing hydrated candidate to +/// `Verdict::unresolved_author` (Drop). Genuine Found (including all-false) +/// is kept. Logged-out viewers are Found(default), not Failed. +pub(crate) fn retain_candidates_with_usable_relationships( + candidates: Vec, + relationships: &TweetHydrationBatch, +) -> Vec { + candidates + .into_iter() + .filter(|c| { + !matches!( + relationships.hydrated(&c.tweet_id), + Some(Hydrated::Failed(_)) + ) + }) + .collect() +} + impl CandidateFeatures { fn assemble(self, candidates: &[TweetCandidateInput]) -> Vec { candidates @@ -207,6 +227,8 @@ impl HydrationPipeline { label_response, } = safety_labels; + let candidates = + retain_candidates_with_usable_relationships(candidates, &relationships); let tweet_features = self.tes_hydrator.assemble_tweet_features( &candidates, &core_datas, @@ -331,4 +353,94 @@ mod tests { .collect(); assert_eq!(counts, HashMap::from([(10, 2), (20, 1)])); } + + fn tweet(tweet_id: u64, author_id: u64) -> TweetCandidateInput { + resolve_candidate(&raw(tweet_id, Some(author_id)), &HashMap::new()).unwrap() + } + + #[test] + fn retain_drops_failed_relationship_and_keeps_found() { + let relationships = TweetHydrationBatch::from_results( + [TweetId(1), TweetId(2)], + HashMap::from([ + ( + TweetId(1), + Err::, _>("socialgraph unavailable"), + ), + (TweetId(2), Ok(Some(ViewerAuthorRelationship::default()))), + ]), + ); + + let kept = retain_candidates_with_usable_relationships( + vec![tweet(1, 10), tweet(2, 20)], + &relationships, + ); + + assert_eq!( + kept.iter().map(|c| c.tweet_id.0).collect::>(), + vec![2] + ); + } + + #[test] + fn retain_keeps_found_mute_and_block() { + let relationships = TweetHydrationBatch::from_results( + [TweetId(1)], + HashMap::from([( + TweetId(1), + Ok::<_, anyhow::Error>(Some(ViewerAuthorRelationship { + viewer_mutes_author: true, + viewer_blocks_author: true, + ..Default::default() + })), + )]), + ); + + let kept = retain_candidates_with_usable_relationships(vec![tweet(1, 10)], &relationships); + + assert_eq!(kept.len(), 1); + assert_eq!(kept[0].tweet_id.0, 1); + } + + #[test] + fn retain_drops_missing_relationship_key() { + let relationships = TweetHydrationBatch::from_values( + [TweetId(1)], + HashMap::from([(TweetId(1), ViewerAuthorRelationship::default())]), + ); + + let kept = retain_candidates_with_usable_relationships( + vec![tweet(1, 10), tweet(2, 20)], + &relationships, + ); + + assert_eq!( + kept.iter().map(|c| c.tweet_id.0).collect::>(), + vec![1] + ); + } + + #[test] + fn assemble_still_defaults_failed_relationship_if_not_retained() { + let resolved = tweet(1, 10); + let results = CandidateFeatures { + tweet_features: HashMap::new(), + author_features: TweetHydrationBatch::empty(), + safety_labels: HashMap::new(), + relationships: TweetHydrationBatch::from_results( + [TweetId(1)], + HashMap::from([( + TweetId(1), + Err::, _>("socialgraph unavailable"), + )]), + ), + exclusive_content: HashMap::new(), + }; + + let assembled = results.assemble(&[resolved]); + + assert_eq!(assembled.len(), 1); + assert!(!assembled[0].relationship.viewer_mutes_author); + assert!(!assembled[0].relationship.viewer_blocks_author); + } }