From 5f52feceaef29653bae39b6da8e1989851125b5a Mon Sep 17 00:00:00 2001 From: Jay Goss Date: Thu, 24 Sep 2026 20:53:28 -0500 Subject: [PATCH 1/3] perf(flags): Avoid BigInt allocations in identity hashing --- clients/rust/Cargo.toml | 2 +- clients/rust/src/features.rs | 36 ++++++++++++++++++++++++++---------- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/clients/rust/Cargo.toml b/clients/rust/Cargo.toml index c631a7a..05482bc 100644 --- a/clients/rust/Cargo.toml +++ b/clients/rust/Cargo.toml @@ -14,10 +14,10 @@ tempfile.workspace = true thiserror.workspace = true sha1.workspace = true arc-swap.workspace = true -num = "0.4.3" tracing.workspace = true [dev-dependencies] +num = "0.4.3" tempfile.workspace = true criterion = { version = "0.5", features = ["html_reports"] } serde_json.workspace = true diff --git a/clients/rust/src/features.rs b/clients/rust/src/features.rs index c7e9833..9f4a8e8 100644 --- a/clients/rust/src/features.rs +++ b/clients/rust/src/features.rs @@ -3,7 +3,6 @@ //! Provides [`FeatureContext`] and [`FeatureChecker`] for evaluating feature //! flags stored in the options system. -use num::bigint::{BigInt, Sign}; use std::cell::Cell; use std::collections::HashMap; @@ -125,15 +124,10 @@ impl FeatureContext { hasher.update(parts.join(":").as_bytes()); let digest = hasher.finalize(); - // Create a BigInt to preserve all the 20bytes of the hash digest. - let bigint = BigInt::from_bytes_be(Sign::Plus, digest.as_slice()); - - // We only need the lower places from the big int to retain compatibility. - // modulo will trim off the u64 overflow, and let us break the bigint - // into its pieces (there will only be one). - let small: BigInt = bigint % 1000000000; - let id_parts = small.to_u64_digits().1; - if id_parts.is_empty() { 0 } else { id_parts[0] } + digest.chunks_exact(4).fold(0_u64, |remainder, word| { + let word = u32::from_be_bytes(word.try_into().unwrap()) as u64; + ((remainder << 32) | word) % 1_000_000_000 + }) } } @@ -644,6 +638,28 @@ mod tests { ); } + #[test] + fn test_feature_context_id_matches_bigint_reference() { + use num::ToPrimitive; + use num::bigint::{BigInt, Sign}; + + for identifier in 0..10_000 { + let identity_value = format!("{}:{identifier}", "x".repeat(identifier % 128)); + let mut context = FeatureContext::new(); + context.insert("organization_id", json!(identity_value)); + context.identity_fields(vec!["organization_id"]); + let input = format!("organization_id:{identity_value}"); + let digest = Sha1::digest(input.as_bytes()); + let bigint = BigInt::from_bytes_be(Sign::Plus, digest.as_slice()); + let expected: BigInt = bigint % 1_000_000_000; + assert_eq!( + context.id(), + expected.to_u64().unwrap(), + "Hash mismatch for {input}" + ); + } + } + #[test] fn test_feature_context_id_deterministic() { let make_ctx = || { From ab71a000e752b85cc50510f022a3fbdbad41c296 Mon Sep 17 00:00:00 2001 From: Jay Goss Date: Thu, 24 Sep 2026 20:56:36 -0500 Subject: [PATCH 2/3] ref(flags): Use fixed-size digest chunks --- clients/rust/src/features.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/clients/rust/src/features.rs b/clients/rust/src/features.rs index 9f4a8e8..cb0fd3b 100644 --- a/clients/rust/src/features.rs +++ b/clients/rust/src/features.rs @@ -124,10 +124,14 @@ impl FeatureContext { hasher.update(parts.join(":").as_bytes()); let digest = hasher.finalize(); - digest.chunks_exact(4).fold(0_u64, |remainder, word| { - let word = u32::from_be_bytes(word.try_into().unwrap()) as u64; - ((remainder << 32) | word) % 1_000_000_000 - }) + digest + .as_chunks::<4>() + .0 + .iter() + .fold(0_u64, |remainder, word| { + let word = u32::from_be_bytes(*word) as u64; + ((remainder << 32) | word) % 1_000_000_000 + }) } } From d180c373751b23cd8ec966df8df98fe9a9545e8a Mon Sep 17 00:00:00 2001 From: Jay Goss Date: Thu, 24 Sep 2026 21:20:33 -0500 Subject: [PATCH 3/3] ref(flags): Clarify digest remainder calculation --- clients/rust/src/features.rs | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/clients/rust/src/features.rs b/clients/rust/src/features.rs index cb0fd3b..b7c298f 100644 --- a/clients/rust/src/features.rs +++ b/clients/rust/src/features.rs @@ -98,12 +98,11 @@ impl FeatureContext { /// Compute the id for a FeatureContext. /// - /// The original python implementation used a bigint value - /// derived from the sha1 hash. - /// - /// This method returns a u64 which contains the lower place - /// values of the bigint so that our rollout modulo math is - /// consistent with the original python implementation. + /// Return the full SHA-1 digest, read as a big-endian integer, modulo + /// 1,000,000,000. This preserves the original Python rollout bucket IDs. + /// Reducing after each 32-bit word gives the same remainder as reducing + /// the full digest once. Each intermediate value is below + /// 1,000,000,000 * 2^32 < 2^62, so it fits in a u64. fn compute_id(&self) -> u64 { let mut identity_fields: Vec<&String> = self .identity_fields @@ -124,14 +123,13 @@ impl FeatureContext { hasher.update(parts.join(":").as_bytes()); let digest = hasher.finalize(); - digest - .as_chunks::<4>() - .0 - .iter() - .fold(0_u64, |remainder, word| { - let word = u32::from_be_bytes(*word) as u64; - ((remainder << 32) | word) % 1_000_000_000 - }) + const ID_MODULUS: u64 = 1_000_000_000; + let mut remainder = 0_u64; + for digest_word in digest.as_chunks::<4>().0 { + let word_value = u64::from(u32::from_be_bytes(*digest_word)); + remainder = ((remainder << 32) + word_value) % ID_MODULUS; + } + remainder } }