use crate::state::{ChannelId, CorrelationContext, ImState, InflightSync, SyncTrigger};
use helix_core::{Correlation, EffectSink};
use std::collections::{HashMap, HashSet, VecDeque};
pub const MAX_INFLIGHT_SYNC: usize = 8;
pub const PONG_COMPENSATE_MIN_INTERVAL_MS: u64 = 5_000;
#[derive(Debug, Default)]
pub struct SyncScheduler {
pending: VecDeque<(ChannelId, SyncTrigger)>,
enqueued: HashSet<ChannelId>,
inflight: usize,
pong_throttle: HashMap<ChannelId, u64>,
}
impl SyncScheduler {
pub fn new() -> Self {
Self::default()
}
pub fn inflight(&self) -> usize {
self.inflight
}
pub fn pending_len(&self) -> usize {
self.pending.len()
}
pub fn is_idle(&self) -> bool {
self.inflight == 0 && self.pending.is_empty()
}
pub fn has_window(&self) -> bool {
self.inflight < MAX_INFLIGHT_SYNC
}
pub fn enqueue(&mut self, channel_id: ChannelId) -> bool {
self.enqueue_with_trigger(channel_id, SyncTrigger::Routine)
}
pub fn enqueue_with_trigger(&mut self, channel_id: ChannelId, trigger: SyncTrigger) -> bool {
if self.enqueued.insert(channel_id) {
self.pending.push_back((channel_id, trigger));
true
} else {
false
}
}
pub fn prioritize_with_trigger(&mut self, channel_ids: &[ChannelId], trigger: SyncTrigger) {
let prioritized: HashSet<ChannelId> = channel_ids.iter().copied().collect();
self.pending
.retain(|(channel_id, _)| !prioritized.contains(channel_id));
for &channel_id in channel_ids.iter().rev() {
self.enqueued.insert(channel_id);
self.pending.push_front((channel_id, trigger));
}
}
pub fn next_dispatch(&mut self) -> Option<(ChannelId, SyncTrigger)> {
if !self.has_window() {
return None;
}
let (channel_id, trigger) = self.pending.pop_front()?;
self.enqueued.remove(&channel_id);
self.inflight += 1;
Some((channel_id, trigger))
}
pub fn release_window(&mut self) {
self.inflight = self.inflight.saturating_sub(1);
}
pub fn acquire_window(&mut self) {
self.inflight += 1;
}
pub fn should_pong_compensate(&mut self, channel_id: ChannelId, now_ms: u64) -> bool {
match self.pong_throttle.get(&channel_id) {
Some(&last) if now_ms.saturating_sub(last) < PONG_COMPENSATE_MIN_INTERVAL_MS => false,
_ => {
self.pong_throttle.insert(channel_id, now_ms);
true
}
}
}
pub fn reset(&mut self) {
self.pending.clear();
self.enqueued.clear();
self.inflight = 0;
self.pong_throttle.clear();
}
}
pub fn enqueue_and_drain(
state: &mut ImState,
api_base_url: &str,
targets: &[ChannelId],
alloc_corr: &mut dyn FnMut() -> Correlation,
out: &mut EffectSink,
) {
enqueue_and_drain_with_trigger(
state,
api_base_url,
targets,
SyncTrigger::Routine,
alloc_corr,
out,
);
}
pub fn enqueue_and_drain_with_trigger(
state: &mut ImState,
api_base_url: &str,
targets: &[ChannelId],
trigger: SyncTrigger,
alloc_corr: &mut dyn FnMut() -> Correlation,
out: &mut EffectSink,
) {
for &channel_id in targets {
match state.channels.get(&channel_id) {
Some(ch) if ch.is_terminal() || ch.inflight_sync.is_some() => continue,
Some(_) => {}
None => continue,
}
state
.sync_scheduler
.enqueue_with_trigger(channel_id, trigger);
}
drain(state, api_base_url, alloc_corr, out);
}
pub fn prioritize_and_drain_with_trigger(
state: &mut ImState,
api_base_url: &str,
targets: &[ChannelId],
trigger: SyncTrigger,
alloc_corr: &mut dyn FnMut() -> Correlation,
out: &mut EffectSink,
) {
let eligible: Vec<ChannelId> = targets
.iter()
.copied()
.filter(|channel_id| {
state
.channels
.get(channel_id)
.is_some_and(|channel| !channel.is_terminal() && channel.inflight_sync.is_none())
})
.collect();
state
.sync_scheduler
.prioritize_with_trigger(&eligible, trigger);
drain(state, api_base_url, alloc_corr, out);
}
pub fn drain(
state: &mut ImState,
api_base_url: &str,
alloc_corr: &mut dyn FnMut() -> Correlation,
out: &mut EffectSink,
) {
let conn_id = state.connection_id.clone();
while let Some((channel_id, trigger)) = state.sync_scheduler.next_dispatch() {
let sync_corr = alloc_corr();
let from_seq = match state.channels.get_mut(&channel_id) {
Some(ch) if ch.inflight_sync.is_none() => {
ch.inflight_sync = Some(InflightSync(sync_corr));
ch.cursor.value()
}
_ => {
state.sync_scheduler.release_window();
continue;
}
};
state.corr_map.insert(
sync_corr,
CorrelationContext::SyncPull {
channel_id,
trigger,
},
);
tracing::info!(
hop = "sync.dispatch",
corr = sync_corr.raw(),
track_id = crate::acl::sync_http_effects::sync_track_id(sync_corr),
channel_id = channel_id.as_str(),
from_seq = from_seq.0,
trigger = ?trigger,
scheduler_inflight = state.sync_scheduler.inflight(),
scheduler_pending = state.sync_scheduler.pending_len(),
"sync/notify dispatched"
);
out.push(crate::acl::to_effect::sync_notify(
api_base_url,
channel_id,
from_seq,
sync_corr,
conn_id.as_deref(),
));
}
}
#[cfg(test)]
#[path = "scheduler_tests.rs"]
mod tests;