Expand description
A hardware-aware work scheduler: key-affine dispatch and CPU-bound offload, hosted on the async runtime you already use.
Work carries an affine key. Every item for one key is handled by one shard, in submission order, one at a time, so the state behind that key needs no locking and no atomics: while an item is being processed it holds the only copy. Shards are pinned to cores chosen from the machine’s own topology.
Grommet does not drive futures. Polling IO, waking a socket and owning the
thread belong to a host runtime, selected by a Cargo feature: driver-tokio
gives each shard thread its own current-thread tokio runtime. A processor
keeps using the database and HTTP clients it already has, and grommet adds
affinity, fairness and offload above them.
Within a shard, keys are dispatched round-robin from per-class ready rings,
which bounds starvation strictly rather than statistically: a key at
position k runs within k dispatches, no matter how much work a busier
key has queued. Each class has its own in-flight budget, so saturating one
class: CPU-bound work, a slow dependency: cannot starve another.
§What you provide
Work: an item, its affineShardKey, its class, and optionally how long it stays worth doing.Processor: what to do with an item, given the key’s resident state.- Optionally an
Offloadpool for CPU-bound work, so a long computation never stalls the shard core that submitted it.
use std::convert::Infallible;
use std::time::Duration;
use grommet::{Call, ClassId, Disposition, IO, Processor, Scheduler, Work};
struct Job { account: u64, amount: i64, attempt: u128 }
impl Work for Job {
type Key = u64;
type Id = u128;
fn key(&self) -> u64 { self.account }
fn class(&self) -> ClassId { IO }
fn request_id(&self) -> Option<u128> { Some(self.attempt) }
fn time_to_live(&self) -> Option<Duration> { Some(Duration::from_millis(50)) }
}
#[derive(Clone)]
struct Ledger;
impl Processor for Ledger {
// Wrapping in `Call` attaches a reply channel to each item.
type Work = Call<Job, i64>;
type State = i64;
type Error = Infallible;
async fn process(
&self,
_key: u64,
balance: Option<i64>,
call: Call<Job, i64>,
) -> Result<Disposition<i64>, Infallible> {
let (job, responder) = call.into_parts();
let balance = balance.unwrap_or(0) + job.amount;
responder.send(balance);
Ok(Disposition::Keep(balance))
}
}
// The clock defaults to `SystemClock` and the class count to the IO +
// COMPUTE split, so the common case names neither.
let runtime = Scheduler::<Ledger>::builder(4, [2048, 64])
.spawn(|_shard| Ledger)
.expect("start shards");
let balance = runtime.router().call(Job { account: 7, amount: 100, attempt: 1 }).await;§Replies are opt-in
Submission itself is one-way: it reports whether work was accepted, not what
it produced. Wrapping work in a Call as above adds a reply channel and
gives you Router::call, which is what most request/response services
want.
It is a wrapper rather than a built-in because a reply channel costs a heap
allocation and two atomics per item, and plenty of workloads have no caller
to answer: ingestion and feed handling, processors that reply by writing to
their own socket, and anything that wants to answer a batch of items with a
single syscall. Those keep the steady state allocation-free by submitting
plain Work.
§!Send on purpose
Work is Send, because it crosses once from the submitter to its shard.
Nothing after that is: per-key state, processor futures and anything held
across an await stay on one core. That is what makes Rc and Cell correct
here, and it is also a real constraint: code written against Send futures
and work stealing will not fit. If you want that, use an ordinary
multi-threaded executor; this crate is deliberately the other thing.
Re-exports§
pub use clock::Clock;pub use clock::ManualClock;pub use clock::SystemClock;pub use error::Fallout;pub use error::ProcessError;pub use key::RequestId;pub use key::ShardKey;pub use key::mix;pub use mailbox::Inbox;pub use mailbox::Mailbox;pub use mailbox::channel;pub use offload::InlineOffload;pub use offload::Offload;pub use offload::OffloadError;pub use processor::KeyOf;pub use processor::PanicPolicy;pub use processor::Processor;pub use respond::Answer;pub use respond::Call;pub use respond::CallError;pub use respond::Cancelled;pub use respond::Responder;pub use router::BatchError;pub use router::Router;pub use router::SubmitError;pub use scheduler::BuildError;pub use scheduler::Builder;pub use scheduler::Scheduler;pub use scheduler::ShardContext;pub use shard::ShardConfig;pub use topology::PinPolicy;pub use topology::TopologyReport;pub use work::CLASSES;pub use work::COMPUTE;pub use work::Envelope;pub use work::IO;pub use work::Work;
Modules§
- clock
- The only time source the runtime reads.
- error
- Classifying failures by what they imply about durable state.
- key
- Affine keys and the placement hash.
- mailbox
- The queue between a submitter and the shard that owns the key.
- metrics
- Metrics shaped for a thread-per-core hot path.
- offload
- The boundary between a shard’s reactor core and CPU-bound work.
- processor
- The behaviour a shard drives: the one trait every user of this crate writes.
- respond
- Request/response on top of a fire-and-forget scheduler.
- router
- Placement of work onto the shard owning its key.
- scheduler
- Placing shards on the machine and owning the threads that run them.
- shard
- The per-core shard reactor.
- topology
- Where shards and offload workers actually run.
- work
- Work items, and the envelope the runtime stamps around them.
Structs§
- Dispatch
Config - Per-shard scheduling limits.
- Plan
- Shard
Placement - One reactor’s placement.
- Snapshot
- Instantaneous scheduler gauges.
- Workload
- What the workload looks like, which the caller knows and the hardware does not.
Enums§
- Disposition
- What became of a key’s state once its work finished.
Type Aliases§
- ClassId
- Index of a work class, in
0..CLASSES.