use std::collections::{HashMap, HashSet};
use std::time::{Duration, Instant};
use nodedb_array::sync::hlc::Hlc;
use nodedb_types::sync::wire::ArrayRejectMsg;
use crate::sync::array::{PendingArrayOp, RejectDisposition, reject_disposition};
pub(crate) const ARRAY_PUSH_WINDOW: usize = 256;
pub(crate) const ARRAY_BARRIER_TIMEOUT: Duration = Duration::from_secs(30);
pub(crate) const MAX_SCHEMA_ANNOUNCES: u32 = 3;
const BARRIER_TOKEN_TAG: u64 = 1 << 63;
type HlcKey = [u8; 18];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TickPlan {
Wait,
Drain { after: Option<Hlc> },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AnnounceStep {
Ready,
Send,
GiveUp,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct WindowOutcome {
pub(crate) confirmed: Vec<Hlc>,
pub(crate) parked_unattributed: usize,
}
#[derive(Default)]
pub(crate) struct ArrayPushWindow {
announced: HashMap<String, Hlc>,
announce_count: HashMap<String, u32>,
reannounce: HashSet<String>,
parked_arrays: HashSet<String>,
parked_ops: HashSet<HlcKey>,
cursor: Option<Hlc>,
rewind: bool,
in_flight: Vec<Hlc>,
rejected: HashSet<HlcKey>,
unattributed: bool,
barrier: Option<(u64, Instant)>,
next_token: u64,
}
impl ArrayPushWindow {
pub(crate) fn reset(&mut self) {
let next_token = self.next_token;
*self = Self {
next_token,
..Self::default()
};
}
pub(crate) fn begin_tick(&mut self, now: Instant) -> TickPlan {
if let Some((token, sent_at)) = self.barrier {
if now.duration_since(sent_at) < ARRAY_BARRIER_TIMEOUT {
return TickPlan::Wait;
}
tracing::warn!(
token,
in_flight = self.in_flight.len(),
"array push: no pong for the window; its ops stay queued and are re-sent"
);
self.abandon_window();
}
TickPlan::Drain { after: self.cursor }
}
pub(crate) fn abandon_window(&mut self) {
self.in_flight.clear();
self.rejected.clear();
self.unattributed = false;
self.barrier = None;
self.cursor = None;
self.rewind = false;
}
pub(crate) fn is_held(&self, op: &PendingArrayOp) -> bool {
self.parked_arrays.contains(&op.array) || self.parked_ops.contains(&op.hlc.to_bytes())
}
pub(crate) fn advance(&mut self, hlc: Hlc) {
if self.cursor.is_none_or(|cursor| hlc > cursor) {
self.cursor = Some(hlc);
}
}
pub(crate) fn announce_step(&mut self, array: &str) -> AnnounceStep {
if self.parked_arrays.contains(array) {
return AnnounceStep::GiveUp;
}
if self.announced.contains_key(array) && !self.reannounce.contains(array) {
return AnnounceStep::Ready;
}
let sent = self.announce_count.get(array).copied().unwrap_or(0);
if sent >= MAX_SCHEMA_ANNOUNCES {
tracing::error!(
array,
announces = sent,
"array push: Origin still refuses ops after repeated schema announces; \
the array's ops stay queued until the next session"
);
self.parked_arrays.insert(array.to_owned());
return AnnounceStep::GiveUp;
}
AnnounceStep::Send
}
pub(crate) fn record_announced(&mut self, array: &str, schema_hlc: Hlc) {
self.announced.insert(array.to_owned(), schema_hlc);
self.reannounce.remove(array);
*self.announce_count.entry(array.to_owned()).or_insert(0) += 1;
}
pub(crate) fn park_array(&mut self, array: &str) {
self.parked_arrays.insert(array.to_owned());
}
pub(crate) fn request_rewind(&mut self) {
self.rewind = true;
}
pub(crate) fn record_sent(&mut self, hlc: Hlc) {
self.in_flight.push(hlc);
}
pub(crate) fn has_in_flight(&self) -> bool {
!self.in_flight.is_empty()
}
pub(crate) fn open_barrier(&mut self, now: Instant) -> u64 {
let token = BARRIER_TOKEN_TAG | (self.next_token & !BARRIER_TOKEN_TAG);
self.next_token = self.next_token.wrapping_add(1);
self.barrier = Some((token, now));
token
}
pub(crate) fn finish_idle_tick(&mut self) {
if self.barrier.is_none() && self.rewind {
self.cursor = None;
self.rewind = false;
}
}
pub(crate) fn record_reject(&mut self, msg: &ArrayRejectMsg) {
let disposition = reject_disposition(msg.reason);
if disposition == RejectDisposition::CatchUp {
return;
}
let key = msg.op_hlc_bytes;
if key == Hlc::ZERO.to_bytes() {
if self.has_in_flight() {
self.unattributed = true;
}
return;
}
if self
.announced
.get(&msg.array)
.is_some_and(|schema_hlc| schema_hlc.to_bytes() == key)
{
tracing::error!(
array = %msg.array,
reason = ?msg.reason,
detail = %msg.detail,
"array push: Origin refused the schema announce; the array's ops stay queued \
until the next session"
);
self.parked_arrays.insert(msg.array.clone());
return;
}
self.rejected.insert(key);
match disposition {
RejectDisposition::ResendSchema if !self.parked_arrays.contains(&msg.array) => {
self.reannounce.insert(msg.array.clone());
self.rewind = true;
}
RejectDisposition::Park => {
self.parked_ops.insert(key);
}
RejectDisposition::ResendSchema
| RejectDisposition::Drop
| RejectDisposition::CatchUp => {}
}
}
pub(crate) fn complete_barrier(&mut self, token: u64) -> Option<WindowOutcome> {
let (open_token, _) = self.barrier?;
if open_token != token {
return None;
}
let in_flight = std::mem::take(&mut self.in_flight);
let mut outcome = WindowOutcome::default();
if self.unattributed {
outcome.parked_unattributed = in_flight.len();
self.parked_ops
.extend(in_flight.iter().map(|hlc| hlc.to_bytes()));
} else {
outcome.confirmed = in_flight
.into_iter()
.filter(|hlc| !self.rejected.contains(&hlc.to_bytes()))
.collect();
}
self.rejected.clear();
self.unattributed = false;
self.barrier = None;
if self.rewind {
self.cursor = None;
self.rewind = false;
}
Some(outcome)
}
}
#[cfg(test)]
mod tests {
use super::*;
use nodedb_array::sync::replica_id::ReplicaId;
use nodedb_types::sync::wire::ArrayRejectReason;
fn hlc(ms: u64) -> Hlc {
Hlc::new(ms, 0, ReplicaId::new(1)).expect("valid hlc")
}
fn op(array: &str, ms: u64) -> PendingArrayOp {
PendingArrayOp {
array: array.into(),
hlc: hlc(ms),
op_payload: Vec::new(),
seq: 0,
}
}
fn reject(array: &str, at: Hlc, reason: ArrayRejectReason) -> ArrayRejectMsg {
ArrayRejectMsg {
array: array.into(),
op_hlc_bytes: at.to_bytes(),
reason,
detail: "refused".into(),
}
}
fn send_window(w: &mut ArrayPushWindow, ms: &[u64], now: Instant) -> u64 {
for &m in ms {
w.advance(hlc(m));
w.record_sent(hlc(m));
}
w.open_barrier(now)
}
#[test]
fn open_window_blocks_the_next_drain_until_its_pong() {
let mut w = ArrayPushWindow::default();
let now = Instant::now();
assert_eq!(w.begin_tick(now), TickPlan::Drain { after: None });
let token = send_window(&mut w, &[10, 20], now);
assert_eq!(w.begin_tick(now), TickPlan::Wait);
assert_eq!(w.complete_barrier(token ^ 1), None, "foreign pong ignored");
let outcome = w.complete_barrier(token).expect("window closes");
assert_eq!(outcome.confirmed, vec![hlc(10), hlc(20)]);
assert_eq!(
w.begin_tick(now),
TickPlan::Drain {
after: Some(hlc(20))
}
);
}
#[test]
fn barrier_tokens_never_match_wall_clock_pings() {
let mut w = ArrayPushWindow::default();
let token = w.open_barrier(Instant::now());
assert_ne!(token & BARRIER_TOKEN_TAG, 0);
assert_eq!(w.complete_barrier(1_760_000_000_000), None);
}
#[test]
fn rejected_ops_are_not_confirmed() {
let mut w = ArrayPushWindow::default();
let now = Instant::now();
let token = send_window(&mut w, &[10, 20, 30], now);
w.record_reject(&reject("a", hlc(20), ArrayRejectReason::ShapeInvalid));
let outcome = w.complete_barrier(token).expect("window closes");
assert_eq!(outcome.confirmed, vec![hlc(10), hlc(30)]);
}
#[test]
fn array_unknown_reannounces_and_rewinds() {
let mut w = ArrayPushWindow::default();
let now = Instant::now();
assert_eq!(w.announce_step("a"), AnnounceStep::Send);
w.record_announced("a", hlc(1));
assert_eq!(w.announce_step("a"), AnnounceStep::Ready);
let token = send_window(&mut w, &[10], now);
w.record_reject(&reject("a", hlc(10), ArrayRejectReason::ArrayUnknown));
let outcome = w.complete_barrier(token).expect("window closes");
assert!(outcome.confirmed.is_empty());
assert_eq!(w.begin_tick(now), TickPlan::Drain { after: None });
assert!(!w.is_held(&op("a", 10)), "the op is re-sent");
assert_eq!(w.announce_step("a"), AnnounceStep::Send);
}
#[test]
fn repeated_schema_rejects_park_the_array() {
let mut w = ArrayPushWindow::default();
for _ in 0..MAX_SCHEMA_ANNOUNCES {
assert_eq!(w.announce_step("a"), AnnounceStep::Send);
w.record_announced("a", hlc(1));
w.record_reject(&reject("a", hlc(10), ArrayRejectReason::ArrayUnknown));
}
assert_eq!(w.announce_step("a"), AnnounceStep::GiveUp);
assert!(w.is_held(&op("a", 10)));
}
#[test]
fn refused_schema_announce_parks_the_array() {
let mut w = ArrayPushWindow::default();
w.record_announced("a", hlc(1));
w.record_reject(&reject("a", hlc(1), ArrayRejectReason::EngineRefused));
assert!(w.is_held(&op("a", 10)));
assert!(!w.is_held(&op("b", 10)));
assert_eq!(w.announce_step("a"), AnnounceStep::GiveUp);
}
#[test]
fn transient_reject_parks_the_op_for_the_session() {
let mut w = ArrayPushWindow::default();
let now = Instant::now();
let token = send_window(&mut w, &[10, 20], now);
w.record_reject(&reject("a", hlc(10), ArrayRejectReason::EngineUnavailable));
let outcome = w.complete_barrier(token).expect("window closes");
assert_eq!(outcome.confirmed, vec![hlc(20)]);
assert!(w.is_held(&op("a", 10)));
w.reset();
assert!(!w.is_held(&op("a", 10)), "a new session retries it");
}
#[test]
fn unattributed_reject_holds_the_whole_window() {
let mut w = ArrayPushWindow::default();
let now = Instant::now();
let token = send_window(&mut w, &[10, 20], now);
w.record_reject(&reject("", Hlc::ZERO, ArrayRejectReason::EngineUnavailable));
let outcome = w.complete_barrier(token).expect("window closes");
assert!(outcome.confirmed.is_empty());
assert_eq!(outcome.parked_unattributed, 2);
assert!(w.is_held(&op("a", 10)) && w.is_held(&op("a", 20)));
}
#[test]
fn retention_floor_reject_leaves_the_window_untouched() {
let mut w = ArrayPushWindow::default();
let now = Instant::now();
let token = send_window(&mut w, &[10], now);
w.record_reject(&reject("a", Hlc::ZERO, ArrayRejectReason::RetentionFloor));
let outcome = w.complete_barrier(token).expect("window closes");
assert_eq!(outcome.confirmed, vec![hlc(10)]);
}
#[test]
fn timed_out_window_is_abandoned_and_rewound() {
let mut w = ArrayPushWindow::default();
let start = Instant::now();
let token = send_window(&mut w, &[10], start);
let later = start + ARRAY_BARRIER_TIMEOUT;
assert_eq!(w.begin_tick(later), TickPlan::Drain { after: None });
assert_eq!(
w.complete_barrier(token),
None,
"a late pong confirms nothing"
);
}
#[test]
fn idle_tick_applies_a_requested_rewind() {
let mut w = ArrayPushWindow::default();
w.advance(hlc(10));
w.request_rewind();
w.finish_idle_tick();
assert_eq!(
w.begin_tick(Instant::now()),
TickPlan::Drain { after: None }
);
}
}