@@ -25,6 +25,12 @@ const HISTORY_CAP: usize = 32;
2525/// record change (the "drop TTL to 30s a day before migrating" practice).
2626pub const ADVISORY_TTL : u32 = 3600 ;
2727
28+ /// How far above the fleet's 90th percentile a reported TTL has to sit before
29+ /// it stops counting as the same record's countdown. Honest countdowns for one
30+ /// record all live in `(0, configured_ttl]`, so a 4× gap can't come from
31+ /// caching timing — it's a resolver reporting a number of its own invention.
32+ const TTL_OUTLIER_FACTOR : u32 = 4 ;
33+
2834pub const RECORD_TYPES : & [ RecordType ] = & [
2935 RecordType :: A ,
3036 RecordType :: AAAA ,
@@ -131,6 +137,31 @@ struct Observation {
131137 at : Instant ,
132138}
133139
140+ /// One resolver's reported TTL, attributable back to that resolver.
141+ #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
142+ pub struct TtlReport {
143+ /// Index into `App::resolvers`, so callers can name the resolver.
144+ pub index : usize ,
145+ /// The TTL it reported, in seconds.
146+ pub ttl : u32 ,
147+ }
148+
149+ /// What the fleet's reported TTLs say about the zone's configured TTL.
150+ #[ derive( Debug , Clone , PartialEq , Eq ) ]
151+ pub struct TtlEstimate {
152+ /// Best estimate of the zone's configured TTL, in seconds: the longest
153+ /// countdown reported by a resolver whose number the rest of the fleet
154+ /// corroborates.
155+ pub ttl : u32 ,
156+ /// How many majority rows reported a TTL at all (`ttl` plus `outliers`
157+ /// were drawn from these).
158+ pub samples : usize ,
159+ /// Reports too far above the fleet to be this record's countdown, longest
160+ /// first. Not an error to surface and forget: such a cache keeps serving
161+ /// the old answer well past the zone's stated lifetime.
162+ pub outliers : Vec < TtlReport > ,
163+ }
164+
134165/// Why a resolver is still serving a non-majority answer.
135166#[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
136167pub enum TtlVerdict {
@@ -835,22 +866,69 @@ impl App {
835866 ( now > deadline) . then_some ( TtlVerdict :: PastTtl )
836867 }
837868
838- /// Estimated configured TTL: the max reported TTL across majority rows.
839- /// A resolver that just refetched reports (nearly) the full configured
840- /// value, so the max over the fleet is within seconds of the zone's TTL.
841- pub fn estimated_ttl ( & self , summary : & Summary ) -> Option < u32 > {
842- self . rows
869+ /// Estimate the zone's configured TTL from what the majority rows report.
870+ ///
871+ /// A reported TTL is a *countdown*, not the configured value: a resolver
872+ /// that just refetched reports (nearly) the full TTL, one halfway through
873+ /// its cache entry reports half of it. So the longest report is the best
874+ /// estimate — but only among resolvers reporting this record's countdown.
875+ /// Some public resolvers hand back numbers unrelated to the authoritative
876+ /// record (fixed floors, or values of their own invention), and taking a
877+ /// plain max let one of them speak for the zone: a single resolver
878+ /// reporting 8423s turned a 300s zone into "TTL ≈ 2h23m".
879+ ///
880+ /// So the max is taken over reports within `TTL_OUTLIER_FACTOR` of the
881+ /// fleet's 90th percentile, and anything above that is returned separately
882+ /// for the caller to attribute. Skipping a tenth of the fleet is what
883+ /// bounds the damage: a handful of liars can't move the percentile, and by
884+ /// construction no more than a tenth of the reports can be rejected. A
885+ /// fleet where *most* resolvers fabricate the same long TTL is beyond what
886+ /// resolver-side data can settle — dnsglobe never talks to the
887+ /// authoritative servers, so there is no ground truth to fall back on.
888+ pub fn estimated_ttl ( & self , summary : & Summary ) -> Option < TtlEstimate > {
889+ let mut reports: Vec < TtlReport > = self
890+ . rows
843891 . iter ( )
844892 . enumerate ( )
845893 . filter ( |& ( i, _) | summary. majority_rows [ i] )
846- . filter_map ( |( _ , row) | match row {
894+ . filter_map ( |( index , row) | match row {
847895 RowState :: Done {
848896 result : QueryResult :: Records { min_ttl, .. } ,
849897 ..
850- } => Some ( * min_ttl) ,
898+ } => Some ( TtlReport {
899+ index,
900+ ttl : * min_ttl,
901+ } ) ,
851902 _ => None ,
852903 } )
853- . max ( )
904+ . collect ( ) ;
905+ let samples = reports. len ( ) ;
906+ if samples == 0 {
907+ return None ;
908+ }
909+
910+ reports. sort_unstable_by_key ( |r| std:: cmp:: Reverse ( r. ttl ) ) ;
911+ // Longest report left after skipping the top tenth. Under ten samples
912+ // that tenth is empty and this is just the max, which is what we want:
913+ // a percentile over a handful of resolvers is too thin to convict any
914+ // of them of lying.
915+ let bulk = reports[ samples / 10 ] . ttl ;
916+ let cutoff = bulk. saturating_mul ( TTL_OUTLIER_FACTOR ) ;
917+ // A zero cutoff means most of the fleet is at the end of its countdown
918+ // (or reports no TTL at all); every other report would then look like
919+ // an outlier, so fall back to the plain max.
920+ let outliers = if cutoff == 0 {
921+ 0
922+ } else {
923+ reports. iter ( ) . take_while ( |r| r. ttl > cutoff) . count ( )
924+ } ;
925+ Some ( TtlEstimate {
926+ // `bulk` is never above the cutoff, so a non-outlier always
927+ // remains to speak for the zone.
928+ ttl : reports[ outliers] . ttl ,
929+ samples,
930+ outliers : reports[ ..outliers] . to_vec ( ) ,
931+ } )
854932 }
855933
856934 /// Worst-case wait until every non-majority cache must have refetched:
@@ -1437,11 +1515,15 @@ mod tests {
14371515 assert_eq ! ( round. indices, vec![ 0 , 2 , 3 ] ) ;
14381516 }
14391517
1440- #[ test]
1441- fn estimated_ttl_is_max_over_majority_rows_only ( ) {
1442- let mut app = app_with_answers ( & [ & [ "x" ] , & [ "x" ] , & [ "y" ] ] ) ;
1443- let ttls = [ 300u32 , 3600 , 999_999 ] ;
1444- for ( row, ttl) in app. rows . iter_mut ( ) . zip ( ttls) {
1518+ /// One agreeing row per TTL, so every row lands in the majority.
1519+ fn app_agreeing_with_ttls ( ttls : & [ u32 ] ) -> App {
1520+ let mut app = app_with_answers ( & vec ! [ & [ "x" ] as & [ & str ] ; ttls. len( ) ] ) ;
1521+ set_ttls ( & mut app, ttls) ;
1522+ app
1523+ }
1524+
1525+ fn set_ttls ( app : & mut App , ttls : & [ u32 ] ) {
1526+ for ( row, & ttl) in app. rows . iter_mut ( ) . zip ( ttls) {
14451527 if let RowState :: Done {
14461528 result : QueryResult :: Records { min_ttl, .. } ,
14471529 ..
@@ -1450,9 +1532,111 @@ mod tests {
14501532 * min_ttl = ttl;
14511533 }
14521534 }
1535+ }
1536+
1537+ #[ test]
1538+ fn estimated_ttl_is_max_over_majority_rows_only ( ) {
1539+ let mut app = app_with_answers ( & [ & [ "x" ] , & [ "x" ] , & [ "y" ] ] ) ;
1540+ set_ttls ( & mut app, & [ 300 , 3600 , 999_999 ] ) ;
1541+ let summary = app. summary ( ) ;
1542+ let est = app. estimated_ttl ( & summary) . unwrap ( ) ;
1543+ // The differing row's huge TTL must not leak into the estimate, and
1544+ // under ten samples the longest agreeing report stands unchallenged.
1545+ assert_eq ! ( est. ttl, 3600 ) ;
1546+ assert_eq ! ( est. samples, 2 ) ;
1547+ assert ! ( est. outliers. is_empty( ) ) ;
1548+ }
1549+
1550+ #[ test]
1551+ fn one_fabricated_ttl_does_not_set_the_estimate ( ) {
1552+ // The reported case: 32 resolvers on a 300s zone, plus one reporting
1553+ // 8423s. The headline must stay with the zone, well under the
1554+ // advisory threshold, and the liar must be named instead.
1555+ let mut ttls = vec ! [ 124u32 ; 16 ] ;
1556+ ttls. extend ( [ 300u32 ; 15 ] ) ;
1557+ ttls. push ( 430 ) ;
1558+ ttls. push ( 8423 ) ;
1559+ let app = app_agreeing_with_ttls ( & ttls) ;
1560+ let summary = app. summary ( ) ;
1561+ let est = app. estimated_ttl ( & summary) . unwrap ( ) ;
1562+ assert_eq ! ( est. ttl, 430 ) ;
1563+ assert ! ( est. ttl < ADVISORY_TTL ) ;
1564+ assert_eq ! ( est. samples, 33 ) ;
1565+ assert_eq ! (
1566+ est. outliers,
1567+ vec![ TtlReport {
1568+ index: 32 ,
1569+ ttl: 8423
1570+ } ]
1571+ ) ;
1572+ }
1573+
1574+ #[ test]
1575+ fn a_genuinely_long_ttl_still_triggers_the_advisory ( ) {
1576+ // A 1-day zone sampled mid-countdown: every report is a fraction of
1577+ // 86400, and the freshest ones sit at the full value. Nothing here is
1578+ // an outlier, and the estimate must clear ADVISORY_TTL.
1579+ let mut ttls: Vec < u32 > = ( 0 ..32 ) . map ( |i| 86_400 - i * 2_500 ) . collect ( ) ;
1580+ ttls. push ( 86_400 ) ;
1581+ let app = app_agreeing_with_ttls ( & ttls) ;
1582+ let summary = app. summary ( ) ;
1583+ let est = app. estimated_ttl ( & summary) . unwrap ( ) ;
1584+ assert_eq ! ( est. ttl, 86_400 ) ;
1585+ assert ! ( est. ttl >= ADVISORY_TTL ) ;
1586+ assert ! ( est. outliers. is_empty( ) ) ;
1587+ }
1588+
1589+ #[ test]
1590+ fn outlier_rejection_is_capped_at_a_tenth_of_the_fleet ( ) {
1591+ // Five resolvers agreeing on a long TTL out of twenty are a fifth of
1592+ // the fleet: too many to dismiss as fabrications, so they set the
1593+ // estimate rather than being explained away.
1594+ let mut ttls = vec ! [ 300u32 ; 15 ] ;
1595+ ttls. extend ( [ 86_400u32 ; 5 ] ) ;
1596+ let app = app_agreeing_with_ttls ( & ttls) ;
1597+ let summary = app. summary ( ) ;
1598+ let est = app. estimated_ttl ( & summary) . unwrap ( ) ;
1599+ assert_eq ! ( est. ttl, 86_400 ) ;
1600+ assert ! ( est. outliers. is_empty( ) ) ;
1601+ }
1602+
1603+ #[ test]
1604+ fn estimated_ttl_needs_a_majority_row_with_records ( ) {
1605+ // Nothing queried yet.
1606+ let app = App :: new ( "example.com" . into ( ) ) ;
1607+ assert_eq ! ( app. estimated_ttl( & app. summary( ) ) , None ) ;
1608+
1609+ // Answered, but nothing that carries a TTL: no majority, no estimate.
1610+ let mut app = App :: new ( "example.com" . into ( ) ) ;
1611+ app. rows = vec ! [
1612+ RowState :: Done {
1613+ result: QueryResult :: Error ( "timeout" . into( ) ) ,
1614+ elapsed: Duration :: from_secs( 3 ) ,
1615+ at: Instant :: now( ) ,
1616+ ecs_honored: None ,
1617+ } ,
1618+ RowState :: Done {
1619+ result: QueryResult :: ServFail ,
1620+ elapsed: Duration :: from_millis( 10 ) ,
1621+ at: Instant :: now( ) ,
1622+ ecs_honored: None ,
1623+ } ,
1624+ ] ;
1625+ assert_eq ! ( app. estimated_ttl( & app. summary( ) ) , None ) ;
1626+ }
1627+
1628+ #[ test]
1629+ fn a_fleet_at_the_end_of_its_countdown_falls_back_to_the_max ( ) {
1630+ // Reported TTLs are countdowns, so a fleet polled just before expiry
1631+ // reports zeros. Zero times anything is zero: without a fallback every
1632+ // non-zero report would look like an outlier.
1633+ let mut ttls = vec ! [ 0u32 ; 32 ] ;
1634+ ttls. push ( 300 ) ;
1635+ let app = app_agreeing_with_ttls ( & ttls) ;
14531636 let summary = app. summary ( ) ;
1454- // The differing row's huge TTL must not leak into the estimate.
1455- assert_eq ! ( app. estimated_ttl( & summary) , Some ( 3600 ) ) ;
1637+ let est = app. estimated_ttl ( & summary) . unwrap ( ) ;
1638+ assert_eq ! ( est. ttl, 300 ) ;
1639+ assert ! ( est. outliers. is_empty( ) ) ;
14561640 }
14571641
14581642 #[ test]
0 commit comments