Group control and construction, target lifecycle, target-set transactions, send pacing and commitment, and bounded timeout processing into private children of the existing task owner. Keep durable status, outcome history, top-level polling, sealing, and abandonment in task.rs. Preserve phase-owned resources, stop/update synchronization, publication ordering, pacing, and timeout fairness. Move the existing private gate and timeout tests with their implementation without adding coverage or hooks.
366 lines
13 KiB
Rust
366 lines
13 KiB
Rust
//! Burst/stagger send selection and committed-send cadence.
|
|
|
|
use std::{
|
|
task::{Context, Poll},
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
use crate::{
|
|
managed::{schedule::instant_abs_diff, ManagedTargetEndReason, ManagedTargetFailurePhase},
|
|
ClientEvent, SendProbeError,
|
|
};
|
|
|
|
use super::{
|
|
target::{OpenSessionFailureCleanup, TargetPhase},
|
|
ManagedClientTask, TARGET_WORK_BUDGET,
|
|
};
|
|
|
|
impl ManagedClientTask {
|
|
pub(super) fn active_count(&self) -> usize {
|
|
self.targets
|
|
.iter()
|
|
.filter(|target| target.is_paced_active())
|
|
.count()
|
|
}
|
|
|
|
fn active_stagger_spacing(&self) -> Option<Duration> {
|
|
let mut active = 0;
|
|
let mut minimum: Option<Duration> = None;
|
|
for target in &self.targets {
|
|
let TargetPhase::Active { schedule, .. } = target.phase() else {
|
|
continue;
|
|
};
|
|
if !target.membership.is_desired() {
|
|
continue;
|
|
}
|
|
active += 1;
|
|
minimum = minimum.into_iter().chain(Some(schedule.interval())).min();
|
|
}
|
|
minimum.map(|interval| stagger_spacing(interval, active))
|
|
}
|
|
|
|
pub(super) fn stagger_target_added(&mut self, now: Instant) {
|
|
let Some(existing) = self.send_gate.filter(|gate| *gate > now) else {
|
|
self.send_gate = None;
|
|
return;
|
|
};
|
|
let candidate = self
|
|
.last_stagger_send
|
|
.zip(self.active_stagger_spacing())
|
|
.and_then(|(last, spacing)| last.checked_add(spacing))
|
|
.filter(|gate| *gate > now);
|
|
self.send_gate = candidate.map(|candidate| existing.min(candidate));
|
|
}
|
|
|
|
pub(super) fn stagger_target_removed(&mut self, now: Instant) {
|
|
self.send_gate = self.send_gate.filter(|gate| *gate > now);
|
|
}
|
|
|
|
/// The next stagger gate after a send was accepted at `accepted_at`.
|
|
///
|
|
/// The gate advances on its **own** cadence rather than being re-anchored
|
|
/// on every observed accept time. Anchoring on `accepted_at` folds each
|
|
/// send's wakeup latency into the next slot and then into every slot after
|
|
/// it, so the gate — and with it the whole group's send cadence — drifts
|
|
/// later without bound while the per-target schedules stay on their
|
|
/// absolute grid. The drift is not hypothetical: it shows up as a
|
|
/// monotonically growing `EchoSent::timer_error` and, once it exceeds one
|
|
/// interval, as probe slots the schedule then skips.
|
|
///
|
|
/// `accepted_at` is still the anchor in the two cases where continuing the
|
|
/// old cadence would be wrong: there is no previous gate, or the pacer has
|
|
/// fallen a full slot behind it. Re-anchoring there is what keeps a gate
|
|
/// stranded in the past from releasing a burst of catch-up sends once the
|
|
/// group starts moving again.
|
|
fn next_stagger_gate(&self, spacing: Duration, accepted_at: Instant) -> Option<Instant> {
|
|
let base = match self.send_gate {
|
|
Some(gate) if gate <= accepted_at && accepted_at.duration_since(gate) < spacing => gate,
|
|
_ => accepted_at,
|
|
};
|
|
base.checked_add(spacing)
|
|
}
|
|
|
|
fn record_stagger_acceptance(
|
|
&mut self,
|
|
result: SendResult,
|
|
stagger_spacing: Option<Duration>,
|
|
accepted_at: Instant,
|
|
) {
|
|
if !result.accepted() {
|
|
return;
|
|
}
|
|
let Some(spacing) = stagger_spacing else {
|
|
return;
|
|
};
|
|
self.last_stagger_send = Some(accepted_at);
|
|
self.send_gate = self.next_stagger_gate(spacing, accepted_at);
|
|
}
|
|
|
|
fn poll_one_send(
|
|
&mut self,
|
|
index: usize,
|
|
cx: &mut Context<'_>,
|
|
now: Instant,
|
|
stagger_spacing: Option<Duration>,
|
|
) -> SendResult {
|
|
if !self.targets[index].membership.is_desired() {
|
|
if let TargetPhase::Active { send_waiting, .. } = self.targets[index].phase_mut() {
|
|
*send_waiting = false;
|
|
}
|
|
return SendResult::NotAttempted;
|
|
}
|
|
let phase = self.targets[index].take_phase();
|
|
let TargetPhase::Active {
|
|
mut client,
|
|
mut schedule,
|
|
mut send_waiting,
|
|
} = phase
|
|
else {
|
|
self.targets[index].restore_phase(phase);
|
|
return SendResult::NotAttempted;
|
|
};
|
|
// A burst pass may span several slots; validate this target against
|
|
// fresh time rather than the pass's earlier scheduling snapshot.
|
|
let now = now.max(Instant::now());
|
|
if client
|
|
.next_probe_timeout_deadline()
|
|
.is_some_and(|deadline| deadline <= now)
|
|
{
|
|
send_waiting = false;
|
|
self.targets[index].restore_phase(TargetPhase::Active {
|
|
client,
|
|
schedule,
|
|
send_waiting,
|
|
});
|
|
return SendResult::NotAttempted;
|
|
}
|
|
if !schedule.permit_probe_at(now) {
|
|
send_waiting = false;
|
|
let pending = client.has_pending_probes();
|
|
if !pending {
|
|
self.targets[index].sync_packets_sent(&client);
|
|
}
|
|
self.targets[index].restore_phase(TargetPhase::Active {
|
|
client,
|
|
schedule,
|
|
send_waiting,
|
|
});
|
|
if pending {
|
|
return SendResult::NotAttempted;
|
|
}
|
|
self.begin_drain(index, ManagedTargetEndReason::TestComplete, now);
|
|
return SendResult::Ready { accepted: false };
|
|
}
|
|
if schedule
|
|
.next_send_deadline()
|
|
.is_none_or(|deadline| deadline > now)
|
|
{
|
|
send_waiting = false;
|
|
self.targets[index].restore_phase(TargetPhase::Active {
|
|
client,
|
|
schedule,
|
|
send_waiting,
|
|
});
|
|
return SendResult::NotAttempted;
|
|
}
|
|
let commit =
|
|
match schedule.preflight_managed_commit(schedule.next_send_deadline().unwrap(), now) {
|
|
Ok(commit) => commit,
|
|
Err(error) => {
|
|
self.targets[index].restore_phase(TargetPhase::Active {
|
|
client,
|
|
schedule,
|
|
send_waiting,
|
|
});
|
|
self.begin_open_session_failure(
|
|
index,
|
|
ManagedTargetFailurePhase::Timing,
|
|
error,
|
|
now,
|
|
OpenSessionFailureCleanup::Drain,
|
|
);
|
|
return SendResult::Failed { accepted: false };
|
|
}
|
|
};
|
|
let scheduled_at = commit.scheduled_at;
|
|
let result = client.poll_send_probe(cx);
|
|
let receipt = match &result {
|
|
Poll::Ready(Ok(receipt))
|
|
| Poll::Ready(Err(SendProbeError::AfterCommit { receipt, .. })) => Some(*receipt),
|
|
Poll::Pending | Poll::Ready(Err(SendProbeError::NotCommitted(_))) => None,
|
|
};
|
|
let accepted = receipt.is_some();
|
|
let send_result = match &result {
|
|
Poll::Pending => SendResult::Pending,
|
|
Poll::Ready(Ok(_)) => SendResult::Ready { accepted },
|
|
Poll::Ready(Err(_)) => SendResult::Failed { accepted },
|
|
};
|
|
self.record_stagger_acceptance(send_result, stagger_spacing, Instant::now());
|
|
if accepted {
|
|
schedule.commit(commit);
|
|
}
|
|
let schedule_error = if accepted {
|
|
schedule.skip_missed_slots_at(Instant::now()).err()
|
|
} else {
|
|
None
|
|
};
|
|
self.targets[index].sync_packets_sent(&client);
|
|
send_waiting = result.is_pending();
|
|
self.targets[index].restore_phase(TargetPhase::Active {
|
|
client,
|
|
schedule,
|
|
send_waiting,
|
|
});
|
|
// A committed send must reach the lifecycle/accounting stream even
|
|
// when post-send processing fails. Publish before failure cleanup.
|
|
let sent_event = receipt.map(|receipt| {
|
|
let mut event = ClientEvent::from(receipt);
|
|
if let ClientEvent::EchoSent {
|
|
scheduled_at: intended,
|
|
timer_error,
|
|
..
|
|
} = &mut event
|
|
{
|
|
*intended = Some(scheduled_at);
|
|
*timer_error = Some(instant_abs_diff(receipt.sent_at.mono, scheduled_at));
|
|
}
|
|
event
|
|
});
|
|
match result {
|
|
Poll::Pending => SendResult::Pending,
|
|
Poll::Ready(Ok(_)) => {
|
|
self.publish_client_events(index, vec![sent_event.unwrap()]);
|
|
if let Some(error) = schedule_error {
|
|
self.begin_open_session_failure(
|
|
index,
|
|
ManagedTargetFailurePhase::Timing,
|
|
error,
|
|
now,
|
|
OpenSessionFailureCleanup::Drain,
|
|
);
|
|
SendResult::Failed { accepted }
|
|
} else {
|
|
SendResult::Ready { accepted }
|
|
}
|
|
}
|
|
Poll::Ready(Err(error)) => {
|
|
if let Some(event) = sent_event {
|
|
self.publish_client_events(index, vec![event]);
|
|
}
|
|
let error = match error {
|
|
SendProbeError::NotCommitted(source) => source,
|
|
SendProbeError::AfterCommit { source, .. } => *source,
|
|
};
|
|
self.begin_open_session_failure(
|
|
index,
|
|
ManagedTargetFailurePhase::Sending,
|
|
error,
|
|
now,
|
|
OpenSessionFailureCleanup::Drain,
|
|
);
|
|
SendResult::Failed { accepted }
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) fn poll_staggered_send(&mut self, cx: &mut Context<'_>, now: Instant) -> bool {
|
|
if self.active_count() == 0 || self.send_gate.is_some_and(|gate| gate > now) {
|
|
return false;
|
|
}
|
|
if self.stagger_remaining == 0 {
|
|
self.stagger_remaining = self.targets.len();
|
|
}
|
|
let work = self.stagger_remaining.min(TARGET_WORK_BUDGET);
|
|
for _ in 0..work {
|
|
let index = self.send_cursor % self.targets.len();
|
|
self.send_cursor = (self.send_cursor + 1) % self.targets.len();
|
|
self.stagger_remaining -= 1;
|
|
if !self.targets[index].is_paced_active() {
|
|
continue;
|
|
}
|
|
let stagger_spacing = self.active_stagger_spacing();
|
|
let result = self.poll_one_send(index, cx, now, stagger_spacing);
|
|
if result == SendResult::NotAttempted {
|
|
continue;
|
|
}
|
|
self.stagger_remaining = 0;
|
|
return matches!(result, SendResult::Ready { .. } | SendResult::Failed { .. });
|
|
}
|
|
self.stagger_remaining > 0
|
|
}
|
|
|
|
pub(super) fn poll_burst_sends(&mut self, cx: &mut Context<'_>, now: Instant) -> bool {
|
|
if self.targets.is_empty() {
|
|
return false;
|
|
}
|
|
if self.burst_remaining == 0 {
|
|
self.burst_remaining = self.targets.len();
|
|
}
|
|
let work = self.burst_remaining.min(TARGET_WORK_BUDGET);
|
|
let mut immediate = false;
|
|
for _ in 0..work {
|
|
let index = self.send_cursor % self.targets.len();
|
|
self.send_cursor = (self.send_cursor + 1) % self.targets.len();
|
|
self.burst_remaining -= 1;
|
|
let result = self.poll_one_send(index, cx, now, None);
|
|
immediate |= matches!(result, SendResult::Ready { .. } | SendResult::Failed { .. });
|
|
}
|
|
immediate || self.burst_remaining > 0
|
|
}
|
|
}
|
|
|
|
// A release gate delays a send opportunity; it cannot create one.
|
|
pub(super) fn gated_send_deadline(
|
|
cadence: Option<Instant>,
|
|
gate: Option<Instant>,
|
|
) -> Option<Instant> {
|
|
cadence.map(|deadline| gate.map_or(deadline, |gate| deadline.max(gate)))
|
|
}
|
|
|
|
#[derive(Clone, Copy, Eq, PartialEq)]
|
|
enum SendResult {
|
|
NotAttempted,
|
|
Pending,
|
|
Ready { accepted: bool },
|
|
Failed { accepted: bool },
|
|
}
|
|
|
|
impl SendResult {
|
|
fn accepted(self) -> bool {
|
|
matches!(
|
|
self,
|
|
Self::Ready { accepted: true } | Self::Failed { accepted: true }
|
|
)
|
|
}
|
|
}
|
|
|
|
fn stagger_spacing(interval: Duration, active_targets: usize) -> Duration {
|
|
let divisor = u128::try_from(active_targets.max(1)).unwrap_or(u128::MAX);
|
|
let nanos = (interval.as_nanos() / divisor).max(1);
|
|
let seconds = u64::try_from(nanos / 1_000_000_000).unwrap_or(u64::MAX);
|
|
let subsec = u32::try_from(nanos % 1_000_000_000).unwrap_or(999_999_999);
|
|
Duration::new(seconds, subsec)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn release_gate_only_delays_an_existing_send_deadline() {
|
|
let earlier = Instant::now();
|
|
let later = earlier + Duration::from_secs(1);
|
|
for (cadence, gate, expected) in [
|
|
(None, None, None),
|
|
(None, Some(earlier), None),
|
|
(None, Some(later), None),
|
|
(Some(earlier), None, Some(earlier)),
|
|
(Some(later), Some(earlier), Some(later)),
|
|
(Some(earlier), Some(earlier), Some(earlier)),
|
|
(Some(earlier), Some(later), Some(later)),
|
|
] {
|
|
assert_eq!(gated_send_deadline(cadence, gate), expected);
|
|
}
|
|
}
|
|
}
|