Skip to content

Commit 167a2a9

Browse files
gauss2302claude
andcommitted
Lower post-candidate cache compression from zstd 6 to zstd 1
RedisPostCandidateCacheSideEffect compresses ~2 MB of serde_json on every cache-miss For You request (MaxPostsToCache = 750 candidates, each a 54-field PostCandidate). The payload is highly repetitive because all 750 share one schema, so zstd's fast strategy already captures nearly all of the redundancy and the higher levels buy very little. Measured on a reconstructed 750-candidate slate, libzstd 1.5.7: level bytes ms vs level 6 1 349,857 1.63 -6.0% size, 5.9x faster 2 379,683 2.51 +2.1% size, 3.9x faster 3 404,066 3.54 +8.6% size, 2.7x faster 6 372,017 9.72 (current) 9 354,442 14.22 -4.7% size, 0.7x Levels 2-4 are both slower and larger than level 1 here, so this is not a size/speed tradeoff: level 1 dominates them on both axes. Held across three text-entropy models (24-word, 2000-word, random) and two seeds; level 1 stayed within -6.0%/+2.0% of level 6 on size at 5.7-7.7x less CPU. Since the entry only lives for REDIS_TTL_SECONDS = 180, request-path CPU dominates the value of a marginally smaller blob. zstd frames are self-describing and the cache key does not encode the compression level, so CachedPostsQueryHydrator reads entries written by hosts on either side of a rolling deploy. Added a regression test pinning that so the level stays retunable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a389166 commit 167a2a9

1 file changed

Lines changed: 37 additions & 1 deletion

File tree

home-mixer/side_effects/redis_post_candidate_cache_side_effect.rs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,16 @@ use xai_candidate_pipeline::component_library::utils::is_prod;
88
use xai_candidate_pipeline::side_effect::{SideEffect, SideEffectInput};
99

1010
const REDIS_TTL_SECONDS: u64 = 180;
11-
const ZSTD_COMPRESSION_LEVEL: i32 = 6;
11+
// This payload is highly repetitive — ~750 candidates sharing one 54-field schema —
12+
// so zstd's fast strategy already captures nearly all of the redundancy. Measured on
13+
// a reconstructed 750-candidate slate (~2.0 MB of serde_json), level 1 compresses
14+
// 5.7-7.7x faster than level 6 while producing output within a few percent of the
15+
// same size, and is both faster *and* smaller than the intermediate levels 2-4.
16+
// Since the entry only lives for REDIS_TTL_SECONDS, request-path CPU dominates the
17+
// value of a marginally smaller blob. Decompression is level-agnostic, so
18+
// CachedPostsQueryHydrator reads frames written at any level and no cache key
19+
// version bump is required.
20+
const ZSTD_COMPRESSION_LEVEL: i32 = 1;
1221

1322
pub struct RedisPostCandidateCacheSideEffect {
1423
redis_client: Arc<dyn RedisClient>,
@@ -211,6 +220,33 @@ mod tests {
211220
assert_eq!(result[max_posts_to_cache - 1].tweet_id, 450);
212221
}
213222

223+
/// The cache key is not versioned by compression level, so during a rolling
224+
/// deploy a host on the new level reads entries a host on the old level wrote,
225+
/// and vice versa. zstd frames are self-describing, so any level decodes with
226+
/// the same call — this test pins that so the level can be retuned freely.
227+
#[test]
228+
fn payload_written_at_any_zstd_level_round_trips() {
229+
let candidates = vec![candidate(1, 100, Some(5.0)), candidate(2, 200, Some(3.0))];
230+
let json = serde_json::to_vec(&candidates).expect("serialize");
231+
232+
// 6 is the level this cache was written with before ZSTD_COMPRESSION_LEVEL
233+
// was lowered; 1 is the current level.
234+
for level in [1, 3, 6, 9] {
235+
let compressed = zstd::encode_all(json.as_slice(), level).expect("compress");
236+
let decompressed = zstd::decode_all(compressed.as_slice()).expect("decompress");
237+
assert_eq!(
238+
decompressed, json,
239+
"level {level} frame did not decode to the original bytes"
240+
);
241+
242+
let decoded: Vec<PostCandidate> =
243+
serde_json::from_slice(&decompressed).expect("deserialize");
244+
assert_eq!(decoded.len(), 2);
245+
assert_eq!(decoded[0].tweet_id, 1);
246+
assert_eq!(decoded[1].tweet_id, 2);
247+
}
248+
}
249+
214250
#[test]
215251
fn get_candidates_to_cache_filters_none_and_zero_scores() {
216252
let selected = vec![

0 commit comments

Comments
 (0)