use crate::clock::Clock;
use crate::error::{Fallout, ProcessError};
use crate::metrics::{ShardHot, ShardStats};
use crate::processor::{KeyOf, PanicPolicy, Processor};
use crate::work::{Envelope, Stamped, Work};
use ahash::AHashSet;
use futures::FutureExt;
use futures::future::Either;
use futures::stream::{FuturesUnordered, StreamExt};
use grommet_core::{Admit, ClassId, Completion, Dispatch, Disposition, Scheduler};
use std::panic::AssertUnwindSafe;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
type Book<K, W, S, const CLASSES: usize> = Scheduler<K, Stamped<W>, S, CLASSES>;
type Live<K, I> = AHashSet<(K, I)>;
#[derive(Clone, Copy, Debug)]
pub struct ShardConfig<const CLASSES: usize = 2> {
pub scheduler: grommet_core::Config<CLASSES>,
pub admit_batch: usize,
pub tick: Duration,
pub panic_policy: PanicPolicy,
pub coalesce_duplicates: bool,
}
impl<const CLASSES: usize> ShardConfig<CLASSES> {
pub fn new(max_inflight: [usize; CLASSES]) -> Self {
Self {
scheduler: grommet_core::Config::new(max_inflight),
admit_batch: 64,
tick: Duration::from_secs(1),
panic_policy: PanicPolicy::default(),
coalesce_duplicates: false,
}
}
}
enum Outcome<K, S, I> {
Ran {
completion: Completion<K, S>,
request_id: Option<I>,
panicked: bool,
fallout: Option<Fallout>,
},
Flushed(K),
}
type OutcomeOf<P> =
Outcome<KeyOf<P>, <P as Processor>::State, <<P as Processor>::Work as Work>::Id>;
async fn run_one<P: Processor>(
processor: P,
dispatch: Dispatch<KeyOf<P>, Stamped<P::Work>, P::State>,
) -> OutcomeOf<P> {
let Dispatch { key, class, state, payload } = dispatch;
let request_id = payload.request_id;
let outcome =
AssertUnwindSafe(processor.process(key, state, payload.work)).catch_unwind().await;
let (state, panicked, fallout) = match outcome {
Ok(Ok(state)) => (state, false, None),
Ok(Err(error)) => {
let fallout = error.fallout();
processor.on_error(key, &error);
(Disposition::Drop, false, Some(fallout))
}
Err(_) => (Disposition::Drop, true, Some(Fallout::InDoubt)),
};
Outcome::Ran { completion: Completion { key, class, state }, request_id, panicked, fallout }
}
async fn run_flush<P: Processor>(processor: P, key: KeyOf<P>, state: P::State) -> OutcomeOf<P> {
let _ = AssertUnwindSafe(processor.on_evict(key, state)).catch_unwind().await;
Outcome::Flushed(key)
}
fn admit_one<P: Processor, const CLASSES: usize>(
book: &mut Book<KeyOf<P>, P::Work, P::State, CLASSES>,
live: &mut Live<KeyOf<P>, <P::Work as Work>::Id>,
processor: &P,
hot: &ShardHot,
envelope: Envelope<P::Work>,
now: Duration,
coalesce: bool,
) {
let Envelope { key, class, request_id, expires_at, enqueued, work } = envelope;
let tracked = match request_id {
Some(id) if coalesce => {
if !live.insert((key, id.clone())) {
hot.bump(&hot.coalesced);
processor.on_coalesced(key, work);
return;
}
Some(id)
}
_ => None,
};
hot.bump(&hot.started);
book.admit(
Admit { key, class, expires_at, payload: Stamped { enqueued, request_id: tracked, work } },
now,
);
}
#[allow(clippy::too_many_arguments)]
fn admit_ready<P: Processor, const CLASSES: usize>(
book: &mut Book<KeyOf<P>, P::Work, P::State, CLASSES>,
live: &mut Live<KeyOf<P>, <P::Work as Work>::Id>,
processor: &P,
hot: &ShardHot,
rx: &mut mpsc::Receiver<Envelope<P::Work>>,
first: Envelope<P::Work>,
now: Duration,
cfg: &ShardConfig<CLASSES>,
) {
admit_one::<P, CLASSES>(book, live, processor, hot, first, now, cfg.coalesce_duplicates);
for _ in 1..cfg.admit_batch.max(1) {
if book.is_saturated() {
break;
}
let Ok(envelope) = rx.try_recv() else {
break;
};
admit_one::<P, CLASSES>(book, live, processor, hot, envelope, now, cfg.coalesce_duplicates);
}
}
pub async fn run<P, C, const CLASSES: usize>(
mut rx: mpsc::Receiver<Envelope<P::Work>>,
processor: P,
clock: C,
stats: Arc<ShardStats<CLASSES>>,
cfg: ShardConfig<CLASSES>,
) where
P: Processor,
C: Clock,
{
let hot = ShardHot::default();
let mut book: Book<KeyOf<P>, P::Work, P::State, CLASSES> = Scheduler::new(cfg.scheduler);
let mut live: Live<KeyOf<P>, <P::Work as Work>::Id> = Live::default();
let mut outstanding = FuturesUnordered::new();
let mut flushing = Vec::new();
let mut tick = tokio::time::interval(cfg.tick);
let mut open = true;
let mut final_flush = true;
let mut now = clock.now();
loop {
for class in 0..CLASSES {
while let Some(dispatch) = book.next(class as ClassId, now) {
hot.add(
&hot.queue_wait_nanos,
now.saturating_sub(dispatch.payload.enqueued).as_nanos() as u64,
);
outstanding.push(Either::Left(run_one(processor.clone(), dispatch)));
}
}
while let Some((key, stamped)) = book.pop_expired() {
hot.bump(&hot.expired);
if let Some(id) = stamped.request_id {
live.remove(&(key, id));
}
processor.on_expired(key, stamped.work);
}
debug_assert!(book.check_invariants().is_ok());
if !open && outstanding.is_empty() {
if std::mem::take(&mut final_flush) {
book.evict_all(&mut flushing);
for (key, state) in flushing.drain(..) {
hot.bump(&hot.evicted);
outstanding.push(Either::Right(run_flush(processor.clone(), key, state)));
}
}
if outstanding.is_empty() {
break;
}
}
tokio::select! {
Some(outcome) = outstanding.next(), if !outstanding.is_empty() => {
let at = clock.now();
match outcome {
Outcome::Ran {
completion,
request_id,
panicked,
fallout
} => {
hot.bump(&hot.completed);
if let Some(fallout) = fallout {
hot.bump(&hot.failed);
if fallout.is_in_doubt() {
hot.bump(&hot.in_doubt);
}
}
if panicked {
hot.bump(&hot.panicked);
if cfg.panic_policy == PanicPolicy::Abort {
stats.publish(&hot, &book.snapshot());
std::process::abort();
}
}
if let Some(id) = request_id {
live.remove(&(completion.key, id));
}
book.complete(completion, at);
}
Outcome::Flushed(key) => book.finish_evict(key, at),
}
now = clock.now();
hot.add(&hot.busy_nanos, now.saturating_sub(at).as_nanos() as u64);
}
envelope = rx.recv(), if open && !book.is_saturated() => {
match envelope {
Some(envelope) => {
let at = clock.now();
admit_ready::<P, CLASSES>(
&mut book,
&mut live,
&processor,
&hot,
&mut rx,
envelope,
at,
&cfg,
);
now = clock.now();
hot.add(&hot.busy_nanos, now.saturating_sub(at).as_nanos() as u64);
}
None => open = false,
}
}
_ = tick.tick(), if open => {
stats.publish(&hot, &book.snapshot());
now = clock.now();
book.evict(now, &mut flushing);
for (key, state) in flushing.drain(..) {
hot.bump(&hot.evicted);
outstanding.push(Either::Right(run_flush(processor.clone(), key, state)));
}
}
}
}
stats.publish(&hot, &book.snapshot());
}
#[cfg(test)]
mod tests {
use super::*;
use crate::clock::ManualClock;
use crate::router::Router;
use crate::work::Work;
use std::cell::RefCell;
use std::rc::Rc;
const IO: ClassId = 0;
#[derive(Debug)]
struct Item {
key: u64,
class: ClassId,
ttl: Option<Duration>,
id: Option<u64>,
}
impl Item {
fn new(key: u64) -> Self {
Self { key, class: IO, ttl: None, id: None }
}
fn retry(key: u64, id: u64) -> Self {
Self { id: Some(id), ..Self::new(key) }
}
}
impl Work for Item {
type Key = u64;
type Id = u64;
fn key(&self) -> u64 {
self.key
}
fn class(&self) -> ClassId {
self.class
}
fn time_to_live(&self) -> Option<Duration> {
self.ttl
}
fn request_id(&self) -> Option<u64> {
self.id
}
}
#[derive(Default)]
struct Log {
processed: Vec<(u64, Option<u64>)>,
evicted: Vec<(u64, u64)>,
expired: Vec<u64>,
failed: Vec<u64>,
coalesced: Vec<u64>,
}
#[derive(Debug)]
struct Fault;
impl crate::error::ProcessError for Fault {
fn fallout(&self) -> Fallout {
Fallout::InDoubt
}
}
#[derive(Clone)]
struct Recorder {
log: Rc<RefCell<Log>>,
panic_on: Option<u64>,
fail_on: Option<u64>,
}
impl Recorder {
fn new() -> Self {
Self { log: Rc::new(RefCell::new(Log::default())), panic_on: None, fail_on: None }
}
fn panicking_on(key: u64) -> Self {
Self { panic_on: Some(key), ..Self::new() }
}
fn failing_on(key: u64) -> Self {
Self { fail_on: Some(key), ..Self::new() }
}
}
impl Processor for Recorder {
type Work = Item;
type State = u64;
type Error = Fault;
async fn process(
&self,
key: u64,
state: Option<u64>,
_work: Item,
) -> Result<Disposition<Self::State>, Fault> {
self.log.borrow_mut().processed.push((key, state));
assert_ne!(self.panic_on, Some(key), "deliberate processor panic");
if self.fail_on == Some(key) {
return Err(Fault);
}
Ok(Disposition::Keep(state.unwrap_or(0) + 1))
}
fn on_error(&self, key: u64, _error: &Fault) {
self.log.borrow_mut().failed.push(key);
}
fn on_coalesced(&self, key: u64, _work: Item) {
self.log.borrow_mut().coalesced.push(key);
}
async fn on_evict(&self, key: u64, state: u64) {
self.log.borrow_mut().evicted.push((key, state));
}
fn on_expired(&self, key: u64, _work: Item) {
self.log.borrow_mut().expired.push(key);
}
}
fn config() -> ShardConfig<2> {
let mut cfg = ShardConfig::new([4, 4]);
cfg.tick = Duration::from_millis(1);
cfg.scheduler.evict_after = Duration::from_secs(3600);
cfg
}
async fn drive<F, Fut>(
processor: Recorder,
cfg: ShardConfig<2>,
driver: F,
) -> Arc<ShardStats<2>>
where
F: FnOnce(Router<Item, ManualClock, 2>, ManualClock) -> Fut,
Fut: Future<Output = ()>,
{
let clock = ManualClock::new();
let (tx, rx) = mpsc::channel(64);
let router = Router::<Item, ManualClock, 2>::new(vec![tx], clock.clone());
let stats = Arc::new(ShardStats::<2>::default());
let engine = run(rx, processor, clock.clone(), stats.clone(), cfg);
tokio::join!(engine, driver(router, clock));
stats
}
#[tokio::test(start_paused = true)]
async fn one_key_is_processed_in_submission_order_with_accumulating_state() {
let processor = Recorder::new();
let log = processor.log.clone();
let stats = drive(processor, config(), |router, _clock| async move {
for _ in 0..3 {
router.submit(Item::new(7)).await.unwrap();
}
})
.await;
assert_eq!(
log.borrow().processed,
vec![(7, None), (7, Some(1)), (7, Some(2))],
"state must follow the key across dispatches, in order"
);
assert_eq!(stats.completed.load(std::sync::atomic::Ordering::Relaxed), 3);
assert_eq!(stats.panicked.load(std::sync::atomic::Ordering::Relaxed), 0);
}
#[tokio::test(start_paused = true)]
async fn a_panicking_processor_is_contained_and_the_shard_keeps_serving() {
let processor = Recorder::panicking_on(1);
let log = processor.log.clone();
let stats = drive(processor, config(), |router, _clock| async move {
router.submit(Item::new(1)).await.unwrap();
router.submit(Item::new(1)).await.unwrap();
router.submit(Item::new(2)).await.unwrap();
})
.await;
let log = log.borrow();
assert_eq!(
log.processed,
vec![(1, None), (2, None), (1, None)],
"a panic drops only the panicking key's state, and blocks no other key"
);
let relaxed = std::sync::atomic::Ordering::Relaxed;
assert_eq!(stats.panicked.load(relaxed), 2);
assert_eq!(stats.completed.load(relaxed), 3, "a panicked item still completes its slot");
}
#[tokio::test(start_paused = true)]
async fn work_past_its_deadline_is_shed_at_dispatch_without_being_processed() {
let processor = Recorder::new();
let log = processor.log.clone();
drive(processor, config(), |router, _clock| async move {
let shed = Item { ttl: Some(Duration::ZERO), ..Item::new(5) };
router.submit(shed).await.unwrap();
router.submit(Item::new(6)).await.unwrap();
})
.await;
let log = log.borrow();
assert_eq!(log.expired, vec![5], "the deadline had already passed at dispatch");
assert_eq!(log.processed, vec![(6, None)], "shed work never reaches the processor");
}
#[tokio::test(start_paused = true)]
async fn idle_state_is_flushed_through_on_evict_and_reloaded_afterwards() {
let processor = Recorder::new();
let log = processor.log.clone();
let observed = log.clone();
let mut cfg = config();
cfg.scheduler.evict_after = Duration::ZERO;
drive(processor, cfg, |router, _clock| async move {
router.submit(Item::new(4)).await.unwrap();
for _ in 0..50 {
if !observed.borrow().evicted.is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(1)).await;
}
router.submit(Item::new(4)).await.unwrap();
})
.await;
let log = log.borrow();
assert_eq!(
log.evicted,
vec![(4, 1), (4, 1)],
"the idle sweep flushes the first state, and shutdown flushes the second"
);
assert_eq!(
log.processed,
vec![(4, None), (4, None)],
"a key that was flushed reloads instead of reusing released state"
);
}
#[tokio::test(start_paused = true)]
async fn state_still_resident_at_shutdown_is_flushed_before_the_shard_exits() {
let processor = Recorder::new();
let log = processor.log.clone();
let cfg = config();
drive(processor, cfg, |router, _clock| async move {
router.submit(Item::new(1)).await.unwrap();
router.submit(Item::new(2)).await.unwrap();
router.submit(Item::new(1)).await.unwrap();
drop(router);
})
.await;
let mut evicted = log.borrow().evicted.clone();
evicted.sort_unstable();
assert_eq!(
evicted,
vec![(1, 2), (2, 1)],
"a write-back cache must not drop its writes on a clean shutdown"
);
}
#[tokio::test(start_paused = true)]
async fn an_expired_item_releases_its_request_id_for_a_later_retry() {
let processor = Recorder::new();
let log = processor.log.clone();
let mut cfg = config();
cfg.coalesce_duplicates = true;
drive(processor, cfg, |router, _clock| async move {
router.try_submit(Item { ttl: Some(Duration::ZERO), ..Item::retry(3, 77) }).unwrap();
tokio::time::sleep(Duration::from_millis(5)).await;
router.submit(Item::retry(3, 77)).await.unwrap();
})
.await;
let log = log.borrow();
assert_eq!(log.expired, vec![3]);
assert!(log.coalesced.is_empty(), "the original expired, so nothing was live to match");
assert_eq!(
log.processed,
vec![(3, None)],
"an expired item must release its id rather than blackhole the key's retries"
);
}
#[tokio::test(start_paused = true)]
async fn queued_work_is_drained_after_the_mailbox_closes() {
let processor = Recorder::new();
let log = processor.log.clone();
let mut cfg = config();
cfg.scheduler.max_inflight = [1, 1];
drive(processor, cfg, |router, _clock| async move {
for key in 0..16 {
router.submit(Item::new(key)).await.unwrap();
}
drop(router);
})
.await;
assert_eq!(log.borrow().processed.len(), 16, "shutdown must not discard queued work");
}
#[tokio::test(start_paused = true)]
async fn class_budgets_are_published_per_class() {
let processor = Recorder::new();
let stats = drive(processor, config(), |router, _clock| async move {
router.submit(Item { class: 1, ..Item::new(1) }).await.unwrap();
router.submit(Item { class: 0, ..Item::new(2) }).await.unwrap();
})
.await;
let relaxed = std::sync::atomic::Ordering::Relaxed;
assert_eq!(stats.started.load(relaxed), 2);
assert_eq!(stats.completed.load(relaxed), 2);
assert_eq!(stats.pending.load(relaxed), 0, "everything drained");
assert_eq!(stats.resident.load(relaxed), 0, "shutdown released every key it flushed");
}
#[tokio::test(start_paused = true)]
async fn an_in_doubt_failure_drops_state_and_is_counted_apart_from_other_errors() {
let processor = Recorder::failing_on(1);
let log = processor.log.clone();
let stats = drive(processor, config(), |router, _clock| async move {
router.submit(Item::new(1)).await.unwrap();
router.submit(Item::new(1)).await.unwrap();
router.submit(Item::new(2)).await.unwrap();
})
.await;
let log = log.borrow();
assert_eq!(log.failed, vec![1, 1], "on_error sees every classified failure");
assert_eq!(
log.processed,
vec![(1, None), (2, None), (1, None)],
"an in-doubt failure discards the key's state, so the retry reloads"
);
let relaxed = std::sync::atomic::Ordering::Relaxed;
assert_eq!(stats.failed.load(relaxed), 2);
assert_eq!(stats.in_doubt.load(relaxed), 2, "in-doubt is counted apart, for alerting");
assert_eq!(stats.panicked.load(relaxed), 0, "a returned error is not a panic");
}
#[tokio::test(start_paused = true)]
async fn a_concurrent_retry_is_coalesced_but_a_later_one_is_admitted() {
let processor = Recorder::new();
let log = processor.log.clone();
let mut cfg = config();
cfg.coalesce_duplicates = true;
let stats = drive(processor, cfg, |router, _clock| async move {
router.try_submit(Item::retry(3, 77)).unwrap();
router.try_submit(Item::retry(3, 77)).unwrap();
tokio::time::sleep(Duration::from_millis(5)).await;
router.submit(Item::retry(3, 77)).await.unwrap();
})
.await;
let log = log.borrow();
assert_eq!(log.coalesced, vec![3], "the concurrent retry never ran");
assert_eq!(
log.processed,
vec![(3, None), (3, Some(1))],
"the original ran once, and the later retry ran against its state"
);
assert_eq!(stats.coalesced.load(std::sync::atomic::Ordering::Relaxed), 1);
}
#[tokio::test(start_paused = true)]
async fn duplicate_ids_are_ignored_when_coalescing_is_off() {
let processor = Recorder::new();
let log = processor.log.clone();
drive(processor, config(), |router, _clock| async move {
router.try_submit(Item::retry(4, 9)).unwrap();
router.try_submit(Item::retry(4, 9)).unwrap();
})
.await;
let log = log.borrow();
assert!(log.coalesced.is_empty());
assert_eq!(log.processed.len(), 2, "coalescing is opt-in and off by default");
}
}