@@ -112,7 +112,9 @@ pub struct QueryInner<T> {
112112 priority : glib:: Priority ,
113113
114114 /// Monotonic counter incremented on each `fetch()`. Used to detect and
115- /// discard stale results from aborted in-flight fetches.
115+ /// discard stale results from superseded in-flight fetches: a fetch that
116+ /// started earlier keeps running (it is not aborted), but its result is
117+ /// dropped when a newer fetch has been started in the meantime.
116118 fetch_generation : u64 ,
117119}
118120
@@ -643,11 +645,15 @@ where
643645 let key = { self . inner . borrow ( ) . key . clone ( ) } ;
644646 debug ! ( resource_key = %key, "Fetch triggered for resource" ) ;
645647 let query_obj = { self . inner . borrow ( ) . query_obj . clone ( ) } ;
646- // Cancel any previous fetch task before starting a new one
647- if let Some ( handle) = self . inner . borrow_mut ( ) . fetch_task_handle . take ( ) {
648- debug ! ( resource_key = %key, "Aborting previous fetch task" ) ;
649- handle. abort ( ) ;
650- }
648+
649+ // Note: we deliberately do NOT abort a previous in-flight fetch here.
650+ // Aborting it would defeat throttling's trailing edge: the trailing
651+ // fetch of a throttle window starts while the previous fetch is still
652+ // running (slow fetcher), and if every new fetch killed the previous
653+ // one, no fetch would ever complete under a steady stream of events.
654+ // Stale results from superseded fetches are discarded by the
655+ // `fetch_generation` check in `execute_fetch`, so the last fetch's
656+ // result still wins while every fetch runs to completion.
651657
652658 // Enter the loading state. The outcome axis (`is_success`/`is_error`)
653659 // is intentionally left untouched so a background refetch keeps
@@ -1043,6 +1049,9 @@ where
10431049#[ cfg( test) ]
10441050mod tests {
10451051 use super :: * ;
1052+ use std:: sync:: Arc ;
1053+ use std:: sync:: Mutex ;
1054+ use std:: sync:: atomic:: { AtomicU32 , Ordering } ;
10461055
10471056 /// Build a `QueryInner` with `last_success_at` set, for staleness/age tests.
10481057 fn inner_with_success_at ( last_success_at : Option < SystemTime > ) -> QueryInner < String > {
@@ -1406,4 +1415,156 @@ mod tests {
14061415 context. iteration ( false ) ;
14071416 }
14081417 }
1418+
1419+ /// Build a query whose fetcher increments a shared counter, with the given
1420+ /// throttle strategy already installed.
1421+ fn throttled_query (
1422+ interval : Duration ,
1423+ trailing : bool ,
1424+ fetch_count : Arc < AtomicU32 > ,
1425+ ) -> Query < i32 > {
1426+ let fc = fetch_count. clone ( ) ;
1427+ let q = Query :: < i32 > :: new ( "test_throttle" . into ( ) , move || {
1428+ let fc = fc. clone ( ) ;
1429+ async move {
1430+ fc. fetch_add ( 1 , Ordering :: SeqCst ) ;
1431+ Ok ( 42 )
1432+ }
1433+ } ) ;
1434+ q. set_refetch_strategy ( Query :: throttle ( interval, trailing) ) ;
1435+ q
1436+ }
1437+
1438+ #[ gtk:: test]
1439+ fn test_throttle_trailing_executes_last_call ( ) {
1440+ // A call that lands inside the throttle window must never be dropped
1441+ // when `trailing` is enabled: even with no further calls, the trailing
1442+ // edge fires after the window elapses.
1443+ let fetch_count = Arc :: new ( AtomicU32 :: new ( 0 ) ) ;
1444+ let q = throttled_query ( Duration :: from_millis ( 50 ) , true , fetch_count. clone ( ) ) ;
1445+
1446+ q. refetch ( ) ; // leading edge: executes immediately
1447+ std:: thread:: sleep ( Duration :: from_millis ( 10 ) ) ;
1448+ q. refetch ( ) ; // throttled: trailing edge scheduled
1449+
1450+ spin_until ( 2 , || fetch_count. load ( Ordering :: SeqCst ) >= 2 ) ;
1451+
1452+ assert_eq ! (
1453+ fetch_count. load( Ordering :: SeqCst ) ,
1454+ 2 ,
1455+ "a throttled call must eventually be executed at the trailing edge"
1456+ ) ;
1457+ }
1458+
1459+ #[ gtk:: test]
1460+ fn test_throttle_trailing_no_starvation ( ) {
1461+ // Calls arriving continuously, faster than the interval, must not
1462+ // starve the trailing edge: every call is eventually executed by
1463+ // either a leading or a trailing fetch.
1464+ let interval = Duration :: from_millis ( 50 ) ;
1465+ let fetch_count = Arc :: new ( AtomicU32 :: new ( 0 ) ) ;
1466+ let fetch_times: Arc < Mutex < Vec < Instant > > > = Arc :: new ( Mutex :: new ( Vec :: new ( ) ) ) ;
1467+
1468+ let fc = fetch_count. clone ( ) ;
1469+ let ft = fetch_times. clone ( ) ;
1470+ let q = Query :: < i32 > :: new ( "throttle_no_starvation" . into ( ) , move || {
1471+ let fc = fc. clone ( ) ;
1472+ let ft = ft. clone ( ) ;
1473+ async move {
1474+ ft. lock ( ) . unwrap ( ) . push ( Instant :: now ( ) ) ;
1475+ fc. fetch_add ( 1 , Ordering :: SeqCst ) ;
1476+ Ok ( 42 )
1477+ }
1478+ } ) ;
1479+ q. set_refetch_strategy ( Query :: throttle ( interval, true ) ) ;
1480+
1481+ let mut call_times = Vec :: new ( ) ;
1482+ for _ in 0 ..9 {
1483+ q. refetch ( ) ;
1484+ call_times. push ( Instant :: now ( ) ) ;
1485+ std:: thread:: sleep ( Duration :: from_millis ( 10 ) ) ;
1486+ }
1487+
1488+ // Let the trailing edge of the last window fire.
1489+ spin_until ( 2 , || fetch_count. load ( Ordering :: SeqCst ) >= 3 ) ;
1490+ std:: thread:: sleep ( interval + Duration :: from_millis ( 20 ) ) ;
1491+ spin_until ( 1 , || false ) ;
1492+
1493+ // Every call must be executed: for each call there must be a fetch
1494+ // that started at or after it.
1495+ let fetch_times = fetch_times. lock ( ) . unwrap ( ) ;
1496+ assert ! (
1497+ fetch_times. len( ) >= 3 ,
1498+ "expected a leading fetch plus trailing edges, got {}" ,
1499+ fetch_times. len( )
1500+ ) ;
1501+ for call in & call_times {
1502+ assert ! (
1503+ fetch_times. iter( ) . any( |f| f >= call) ,
1504+ "a throttled call was never executed ({} fetches total)" ,
1505+ fetch_times. len( )
1506+ ) ;
1507+ }
1508+ }
1509+
1510+ #[ gtk:: test]
1511+ fn test_throttle_without_trailing_drops_window_calls ( ) {
1512+ // With `trailing` disabled, calls inside the window are dropped by
1513+ // design — this documents the contrast with the trailing behavior.
1514+ // The interval is much larger than the sleep between calls so a
1515+ // wall-clock overshoot on a loaded machine cannot flip the second
1516+ // call from throttled to leading.
1517+ let fetch_count = Arc :: new ( AtomicU32 :: new ( 0 ) ) ;
1518+ let q = throttled_query ( Duration :: from_millis ( 200 ) , false , fetch_count. clone ( ) ) ;
1519+
1520+ q. refetch ( ) ; // leading edge: executes immediately
1521+ std:: thread:: sleep ( Duration :: from_millis ( 10 ) ) ;
1522+ q. refetch ( ) ; // throttled, no trailing -> dropped
1523+ std:: thread:: sleep ( Duration :: from_millis ( 250 ) ) ;
1524+ spin_until ( 1 , || false ) ;
1525+
1526+ assert_eq ! ( fetch_count. load( Ordering :: SeqCst ) , 1 ) ;
1527+ }
1528+
1529+ #[ gtk:: test]
1530+ fn test_throttle_trailing_completes_despite_overlap ( ) {
1531+ // The trailing edge must run to completion even when a newer fetch
1532+ // starts before it finishes: superseded fetches may be discarded by
1533+ // the generation check, but they must never be aborted, otherwise a
1534+ // slow fetcher plus a stream of events means no fetch ever completes.
1535+ let fetch_count = Arc :: new ( AtomicU32 :: new ( 0 ) ) ;
1536+ let fc = fetch_count. clone ( ) ;
1537+ let q = Query :: < i32 > :: new ( "throttle_overlap" . into ( ) , move || {
1538+ let fc = fc. clone ( ) ;
1539+ async move {
1540+ glib:: timeout_future ( Duration :: from_millis ( 80 ) ) . await ;
1541+ fc. fetch_add ( 1 , Ordering :: SeqCst ) ;
1542+ Ok ( 42 )
1543+ }
1544+ } ) ;
1545+ q. set_refetch_strategy ( Query :: throttle ( Duration :: from_millis ( 40 ) , true ) ) ;
1546+
1547+ // First fetch starts and is still in flight when the trailing edge of
1548+ // the first window fires and starts a second fetch.
1549+ q. refetch ( ) ;
1550+ std:: thread:: sleep ( Duration :: from_millis ( 10 ) ) ;
1551+ q. refetch ( ) ; // throttled -> trailing scheduled
1552+ std:: thread:: sleep ( Duration :: from_millis ( 70 ) ) ;
1553+ q. refetch ( ) ; // throttled again -> another trailing scheduled
1554+ std:: thread:: sleep ( Duration :: from_millis ( 70 ) ) ;
1555+ q. refetch ( ) ; // one more burst
1556+ std:: thread:: sleep ( Duration :: from_millis ( 10 ) ) ;
1557+
1558+ // All started fetches must run to completion; the count must reflect
1559+ // every fetch that actually executed its fetcher.
1560+ spin_until ( 3 , || fetch_count. load ( Ordering :: SeqCst ) >= 3 ) ;
1561+ std:: thread:: sleep ( Duration :: from_millis ( 120 ) ) ;
1562+ spin_until ( 1 , || false ) ;
1563+
1564+ let count = fetch_count. load ( Ordering :: SeqCst ) ;
1565+ assert ! (
1566+ count >= 3 ,
1567+ "fetches started while an earlier fetch was in flight must still complete: got {count}"
1568+ ) ;
1569+ }
14091570}
0 commit comments