Skip to content

Commit e681b0b

Browse files
committed
query: stop aborting superseded fetches so throttle trailing edge always executes
Query::fetch aborted the previous in-flight fetch task whenever a new fetch started. With Query::throttle(interval, trailing=true) (used by containers_query), each trailing edge starts a new fetch at the end of the window: with a slow fetcher (e.g. distrobox ls) and a steady stream of events, every fetch got aborted by the next one and none ever completed — the right edge never actually executed. Superseded fetches now run to completion; the existing fetch_generation check discards their stale results, so the last fetch's result still wins while every fetch is guaranteed to complete. Add regression tests: trailing edge executes the last call, no starvation under continuous calls, trailing edge completes despite overlapping fetches, and the trailing=false semantics.
1 parent b94c3e9 commit e681b0b

1 file changed

Lines changed: 167 additions & 6 deletions

File tree

src/query/mod.rs

Lines changed: 167 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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)]
10441050
mod 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

Comments
 (0)