@@ -10,10 +10,13 @@ use md5::Md5;
1010use schemars:: JsonSchema ;
1111use serde:: Deserialize ;
1212use serde:: Serialize ;
13+ use thiserror:: Error ;
1314
1415/// A utility to enable gradual rollout of large codegen changes.
1516/// Can be constructed as the Default which passes or a percentage between 0 and
16- /// 100.
17+ /// 100. Percentages may be fractional with up to two decimal places (e.g.
18+ /// `12.5`), which allows rolling out a change to very large projects in steps
19+ /// smaller than 1%.
1720#[ derive(
1821 Default ,
1922 Debug ,
@@ -24,20 +27,40 @@ use serde::Serialize;
2427 PartialEq ,
2528 JsonSchema
2629) ]
27- pub struct Rollout ( pub Option < u8 > ) ;
30+ #[ serde( try_from = "Option<f32>" ) ]
31+ #[ schemars( extend( "minimum" = 0 , "maximum" = 100 ) ) ]
32+ pub struct Rollout ( pub Option < f32 > ) ;
33+
34+ /// A rollout percentage outside 0 to 100 (or NaN).
35+ #[ derive( Debug , Error ) ]
36+ #[ error( "Expected a rollout percentage between 0 and 100, inclusive, but got {0}" ) ]
37+ pub struct InvalidRolloutPercentage ( f32 ) ;
38+
39+ impl TryFrom < Option < f32 > > for Rollout {
40+ type Error = InvalidRolloutPercentage ;
41+
42+ fn try_from ( percentage : Option < f32 > ) -> Result < Self , Self :: Error > {
43+ match percentage {
44+ Some ( p) if !( 0.0 ..=100.0 ) . contains ( & p) => Err ( InvalidRolloutPercentage ( p) ) ,
45+ _ => Ok ( Self ( percentage) ) ,
46+ }
47+ }
48+ }
2849
2950impl Rollout {
3051 /// Checks some key deterministically and passes on average the given
3152 /// percentage of the rollout.
3253 /// A typical key to pass in could be the fragment or operation name.
3354 pub fn check ( & self , key : impl AsRef < [ u8 ] > ) -> bool {
34- if let Some ( percentage) = self . 0 {
35- let hash = Md5 :: digest ( key. as_ref ( ) ) ;
36- let hash: u16 = ( ( hash[ 1 ] as u16 ) << 8 ) | ( hash[ 0 ] as u16 ) ;
37- ( hash % 100 ) < ( percentage as u16 )
38- } else {
39- true
40- }
55+ let Some ( percentage) = self . 0 else {
56+ return true ;
57+ } ;
58+ let hash = Md5 :: digest ( key. as_ref ( ) ) ;
59+ // The whole-percent bucket comes first, so a whole-number percentage
60+ // selects exactly the keys it did when rollouts were integers.
61+ let bucket = u32:: from ( u16:: from_le_bytes ( [ hash[ 0 ] , hash[ 1 ] ] ) % 100 ) * 100
62+ + u32:: from ( u16:: from_le_bytes ( [ hash[ 2 ] , hash[ 3 ] ] ) % 100 ) ;
63+ bucket < ( percentage * 100.0 ) . round ( ) as u32
4164 }
4265}
4366
@@ -60,3 +83,93 @@ impl RolloutRange {
6083 ( percent) <= ( self . end as u16 ) && ( percent) >= ( self . start as u16 )
6184 }
6285}
86+
87+ #[ cfg( test) ]
88+ mod tests {
89+ use super :: * ;
90+
91+ fn keys ( ) -> impl Iterator < Item = String > {
92+ ( 0 ..20_000 ) . map ( |i| format ! ( "Key{i}" ) )
93+ }
94+
95+ /// The bucketing used before fractional percentages were supported.
96+ fn legacy_check ( percentage : u8 , key : & str ) -> bool {
97+ let hash = Md5 :: digest ( key. as_bytes ( ) ) ;
98+ let hash: u16 = ( ( hash[ 1 ] as u16 ) << 8 ) | ( hash[ 0 ] as u16 ) ;
99+ ( hash % 100 ) < ( percentage as u16 )
100+ }
101+
102+ #[ test]
103+ fn whole_percentages_select_the_same_keys_as_before ( ) {
104+ for percentage in [ 0u8 , 1 , 37 , 50 , 99 , 100 ] {
105+ let rollout = Rollout ( Some ( percentage as f32 ) ) ;
106+ for key in keys ( ) {
107+ assert_eq ! (
108+ rollout. check( & key) ,
109+ legacy_check( percentage, & key) ,
110+ "{percentage}% should select {key} exactly as the integer rollout did" ,
111+ ) ;
112+ }
113+ }
114+ }
115+
116+ #[ test]
117+ fn fractional_percentage_is_between_its_neighbors ( ) {
118+ let lower = Rollout ( Some ( 37.0 ) ) ;
119+ let fractional = Rollout ( Some ( 37.5 ) ) ;
120+ let upper = Rollout ( Some ( 38.0 ) ) ;
121+ for key in keys ( ) {
122+ // A key in 37% is also in 37.5%, and a key in 37.5% is also in 38%.
123+ assert ! ( !lower. check( & key) || fractional. check( & key) , "{key}" ) ;
124+ assert ! ( !fractional. check( & key) || upper. check( & key) , "{key}" ) ;
125+ }
126+ }
127+
128+ #[ test]
129+ fn fractional_percentage_selects_roughly_that_share ( ) {
130+ let count = keys ( ) . filter ( |key| Rollout ( Some ( 37.5 ) ) . check ( key) ) . count ( ) ;
131+ // 37.5% of 20,000 keys is 7,500.
132+ assert ! ( ( 7_200 ..=7_800 ) . contains( & count) , "selected {count}" ) ;
133+ }
134+
135+ #[ test]
136+ fn half_percent_selects_part_of_the_first_bucket ( ) {
137+ let half = keys ( ) . filter ( |key| Rollout ( Some ( 0.5 ) ) . check ( key) ) . count ( ) ;
138+ let one = keys ( ) . filter ( |key| Rollout ( Some ( 1.0 ) ) . check ( key) ) . count ( ) ;
139+ // 0.5% of 20,000 keys is 100; 1% is 200.
140+ assert ! (
141+ half > 0 && half < one,
142+ "0.5% selected {half}, 1% selected {one}"
143+ ) ;
144+ }
145+
146+ #[ test]
147+ fn config_accepts_percentages_from_0_to_100 ( ) {
148+ for json in [ "0" , "0.5" , "100" , "null" ] {
149+ assert ! (
150+ serde_json:: from_str:: <Rollout >( json) . is_ok( ) ,
151+ "{json} should parse"
152+ ) ;
153+ }
154+ }
155+
156+ #[ test]
157+ fn config_rejects_percentages_outside_0_to_100 ( ) {
158+ // A missed decimal point (`1000` for `10.00`) must not silently enable the feature everywhere.
159+ for json in [ "-1" , "100.5" , "1000" ] {
160+ assert ! (
161+ serde_json:: from_str:: <Rollout >( json) . is_err( ) ,
162+ "{json} should be rejected"
163+ ) ;
164+ }
165+ assert ! (
166+ Rollout :: try_from( Some ( f32 :: NAN ) ) . is_err( ) ,
167+ "NaN should be rejected"
168+ ) ;
169+ }
170+
171+ #[ test]
172+ fn default_rollout_passes_every_key ( ) {
173+ assert ! ( keys( ) . all( |key| Rollout ( None ) . check( key) ) ) ;
174+ }
175+ }
0 commit comments