use std::collections::HashMap;
use std::time::{Duration, Instant};
use rmux_proto::PaneId;
pub const DEFAULT_MAX_SNAPSHOT_NOTIFICATIONS_PER_SECOND: u32 = 60;
#[must_use]
pub const fn min_interval_for_rate(rate: u32) -> Duration {
if rate == 0 {
Duration::MAX
} else {
let nanos_per_second: u64 = 1_000_000_000;
Duration::from_nanos(nanos_per_second / rate as u64)
}
}
#[derive(Debug, Clone, Copy)]
struct EmittedRevision {
revision: u64,
at: Instant,
}
#[derive(Debug, Clone, Copy)]
struct PendingRevision {
revision: u64,
observed_at: Instant,
}
#[derive(Debug, Clone)]
pub struct SnapshotCoalescer {
max_per_second: u32,
min_interval: Duration,
last_emitted: Option<EmittedRevision>,
pending: Option<PendingRevision>,
}
impl SnapshotCoalescer {
#[must_use]
pub fn new(max_per_second: u32) -> Self {
Self {
max_per_second,
min_interval: min_interval_for_rate(max_per_second),
last_emitted: None,
pending: None,
}
}
#[must_use]
pub fn with_default_rate() -> Self {
Self::new(DEFAULT_MAX_SNAPSHOT_NOTIFICATIONS_PER_SECOND)
}
#[must_use]
pub const fn max_per_second(&self) -> u32 {
self.max_per_second
}
#[must_use]
pub const fn min_interval(&self) -> Duration {
self.min_interval
}
pub fn observe(&mut self, revision: u64, now: Instant) -> Option<u64> {
if let Some(emitted) = self.last_emitted {
if emitted.revision == revision {
if let Some(pending) = self.pending {
if pending.revision == revision {
self.pending = None;
}
}
return None;
}
}
if self.may_emit_at(now) {
self.commit_emission(revision, now);
self.pending = None;
return Some(revision);
}
self.pending = Some(PendingRevision {
revision,
observed_at: now,
});
None
}
pub fn poll(&mut self, now: Instant) -> Option<u64> {
let pending = self.pending?;
if !self.may_emit_at(now) {
return None;
}
if let Some(emitted) = self.last_emitted {
if emitted.revision == pending.revision {
self.pending = None;
return None;
}
}
self.commit_emission(pending.revision, now);
self.pending = None;
Some(pending.revision)
}
#[must_use]
pub fn next_deadline(&self) -> Option<Instant> {
let pending = self.pending?;
match self.last_emitted {
None => Some(pending.observed_at),
Some(emitted) => {
let earliest = emitted.at.checked_add(self.min_interval);
match earliest {
Some(earliest) => Some(std::cmp::max(pending.observed_at, earliest)),
None => Some(pending.observed_at),
}
}
}
}
#[must_use]
pub fn last_emitted_revision(&self) -> Option<u64> {
self.last_emitted.map(|emitted| emitted.revision)
}
#[must_use]
pub fn pending_revision(&self) -> Option<u64> {
self.pending.map(|pending| pending.revision)
}
#[must_use]
pub fn may_emit_at(&self, now: Instant) -> bool {
match self.last_emitted {
None => true,
Some(emitted) => now.saturating_duration_since(emitted.at) >= self.min_interval,
}
}
fn commit_emission(&mut self, revision: u64, at: Instant) {
self.last_emitted = Some(EmittedRevision { revision, at });
}
}
impl Default for SnapshotCoalescer {
fn default() -> Self {
Self::with_default_rate()
}
}
#[derive(Debug, Clone)]
pub struct PaneSnapshotCoalescerRegistry {
coalescers: HashMap<PaneId, SnapshotCoalescer>,
max_per_second: u32,
}
impl PaneSnapshotCoalescerRegistry {
#[must_use]
pub fn new(max_per_second: u32) -> Self {
Self {
coalescers: HashMap::new(),
max_per_second,
}
}
#[must_use]
pub fn with_default_rate() -> Self {
Self::new(DEFAULT_MAX_SNAPSHOT_NOTIFICATIONS_PER_SECOND)
}
#[must_use]
pub const fn max_per_second(&self) -> u32 {
self.max_per_second
}
pub fn observe(&mut self, pane_id: PaneId, revision: u64, now: Instant) -> Option<u64> {
self.entry(pane_id).observe(revision, now)
}
pub fn poll(&mut self, pane_id: PaneId, now: Instant) -> Option<u64> {
self.coalescers.get_mut(&pane_id)?.poll(now)
}
#[must_use]
pub fn next_deadline(&self) -> Option<Instant> {
self.coalescers
.values()
.filter_map(SnapshotCoalescer::next_deadline)
.min()
}
#[must_use]
pub fn last_emitted_revision(&self, pane_id: PaneId) -> Option<u64> {
self.coalescers
.get(&pane_id)
.and_then(SnapshotCoalescer::last_emitted_revision)
}
#[must_use]
pub fn pending_revision(&self, pane_id: PaneId) -> Option<u64> {
self.coalescers
.get(&pane_id)
.and_then(SnapshotCoalescer::pending_revision)
}
pub fn forget(&mut self, pane_id: PaneId) -> Option<SnapshotCoalescer> {
self.coalescers.remove(&pane_id)
}
#[must_use]
pub fn len(&self) -> usize {
self.coalescers.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.coalescers.is_empty()
}
fn entry(&mut self, pane_id: PaneId) -> &mut SnapshotCoalescer {
let max_per_second = self.max_per_second;
self.coalescers
.entry(pane_id)
.or_insert_with(|| SnapshotCoalescer::new(max_per_second))
}
}
impl Default for PaneSnapshotCoalescerRegistry {
fn default() -> Self {
Self::with_default_rate()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn at(base: Instant, millis: u64) -> Instant {
base + Duration::from_millis(millis)
}
#[test]
fn min_interval_for_rate_handles_zero_and_default_rate() {
assert_eq!(min_interval_for_rate(0), Duration::MAX);
let interval = min_interval_for_rate(60);
assert_eq!(interval, Duration::from_nanos(1_000_000_000 / 60));
assert!(interval < Duration::from_millis(17));
assert!(interval > Duration::from_millis(16));
}
#[test]
fn first_observation_emits_immediately_and_records_revision() {
let mut coalescer = SnapshotCoalescer::with_default_rate();
let now = Instant::now();
assert_eq!(coalescer.observe(7, now), Some(7));
assert_eq!(coalescer.last_emitted_revision(), Some(7));
assert_eq!(coalescer.pending_revision(), None);
assert_eq!(coalescer.next_deadline(), None);
}
#[test]
fn duplicate_revision_is_suppressed_even_after_window_opens() {
let mut coalescer = SnapshotCoalescer::with_default_rate();
let base = Instant::now();
assert_eq!(coalescer.observe(11, base), Some(11));
assert_eq!(coalescer.observe(11, at(base, 1)), None);
assert_eq!(coalescer.pending_revision(), None);
let after = base + coalescer.min_interval();
assert_eq!(coalescer.observe(11, after), None);
assert_eq!(coalescer.last_emitted_revision(), Some(11));
}
#[test]
fn newest_revision_overwrites_older_pending_inside_window() {
let mut coalescer = SnapshotCoalescer::new(60);
let base = Instant::now();
assert_eq!(coalescer.observe(1, base), Some(1));
assert_eq!(coalescer.observe(2, at(base, 1)), None);
assert_eq!(coalescer.observe(3, at(base, 2)), None);
assert_eq!(coalescer.observe(4, at(base, 3)), None);
assert_eq!(coalescer.pending_revision(), Some(4));
let after = base + coalescer.min_interval();
assert_eq!(coalescer.poll(after), Some(4));
assert_eq!(coalescer.pending_revision(), None);
assert_eq!(coalescer.last_emitted_revision(), Some(4));
}
#[test]
fn poll_returns_none_until_min_interval_has_elapsed() {
let mut coalescer = SnapshotCoalescer::new(60);
let base = Instant::now();
assert_eq!(coalescer.observe(10, base), Some(10));
assert_eq!(coalescer.observe(11, at(base, 1)), None);
let just_before = base + coalescer.min_interval() - Duration::from_nanos(1);
assert_eq!(coalescer.poll(just_before), None);
let at_boundary = base + coalescer.min_interval();
assert_eq!(coalescer.poll(at_boundary), Some(11));
}
#[test]
fn cap_holds_at_most_max_per_second_per_pane_under_dense_observations() {
let mut coalescer = SnapshotCoalescer::new(60);
let base = Instant::now();
let mut emitted: Vec<u64> = Vec::new();
let mut revision: u64 = 0;
let mut cursor: u64 = 0;
while cursor <= 1_000 {
revision = revision.wrapping_add(1);
let now = at(base, cursor);
if let Some(value) = coalescer.observe(revision, now) {
emitted.push(value);
}
cursor += 1;
}
if let Some(value) = coalescer.poll(at(base, cursor)) {
emitted.push(value);
}
assert!(
emitted.len() <= 60,
"coalescer emitted {} notifications (cap is 60/s)",
emitted.len(),
);
assert!(
emitted.len() >= 55,
"expected near-saturation under 1 kHz observations, got {}",
emitted.len(),
);
}
#[test]
fn delivered_revisions_track_the_observation_order_after_resize_or_clear() {
let mut coalescer = SnapshotCoalescer::new(120);
let base = Instant::now();
assert_eq!(
coalescer.observe(0xFFFF_FFFF_FFFF_FFFF, base),
Some(0xFFFF_FFFF_FFFF_FFFF)
);
let after_first = base + coalescer.min_interval();
assert_eq!(
coalescer.observe(0x0000_0000_0000_0001, after_first),
Some(1)
);
assert_eq!(coalescer.last_emitted_revision(), Some(1));
assert_eq!(
coalescer.observe(2, after_first + Duration::from_nanos(1)),
None
);
assert_eq!(
coalescer.observe(3, after_first + Duration::from_nanos(2)),
None
);
assert_eq!(coalescer.pending_revision(), Some(3));
let after_second = after_first + coalescer.min_interval();
assert_eq!(coalescer.poll(after_second), Some(3));
assert_eq!(coalescer.last_emitted_revision(), Some(3));
}
#[test]
fn next_deadline_reports_the_pending_release_time() {
let mut coalescer = SnapshotCoalescer::new(60);
let base = Instant::now();
assert_eq!(coalescer.observe(1, base), Some(1));
assert_eq!(coalescer.next_deadline(), None);
assert_eq!(coalescer.observe(2, at(base, 1)), None);
let deadline = coalescer
.next_deadline()
.expect("pending implies a deadline");
assert_eq!(deadline, base + coalescer.min_interval());
}
#[test]
fn registry_serves_per_pane_state_and_forgets_on_request() {
let mut registry = PaneSnapshotCoalescerRegistry::new(60);
let base = Instant::now();
let pane_a = PaneId::new(1);
let pane_b = PaneId::new(2);
assert_eq!(registry.observe(pane_a, 100, base), Some(100));
assert_eq!(registry.observe(pane_b, 200, base), Some(200));
assert_eq!(registry.observe(pane_a, 101, at(base, 1)), None);
assert_eq!(registry.observe(pane_b, 201, at(base, 1)), None);
assert_eq!(registry.last_emitted_revision(pane_a), Some(100));
assert_eq!(registry.last_emitted_revision(pane_b), Some(200));
assert_eq!(registry.pending_revision(pane_a), Some(101));
assert_eq!(registry.pending_revision(pane_b), Some(201));
let after = base + min_interval_for_rate(registry.max_per_second());
assert_eq!(registry.poll(pane_a, after), Some(101));
assert_eq!(registry.poll(pane_b, after), Some(201));
assert_eq!(registry.len(), 2);
assert!(registry.forget(pane_a).is_some());
assert!(registry.forget(pane_a).is_none());
assert_eq!(registry.len(), 1);
}
#[test]
fn registry_default_uses_rfc_recorded_rate() {
let registry = PaneSnapshotCoalescerRegistry::default();
assert_eq!(
registry.max_per_second(),
DEFAULT_MAX_SNAPSHOT_NOTIFICATIONS_PER_SECOND,
);
assert_eq!(DEFAULT_MAX_SNAPSHOT_NOTIFICATIONS_PER_SECOND, 60);
}
}