use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use arch_program::pubkey::Pubkey;
use arch_program::sanitized::ArchMessage;
use async_trait::async_trait;
use tokio::sync::{mpsc, oneshot, OwnedSemaphorePermit, Semaphore};
use tokio::time::Instant;
use crate::{
is_retryable, ArchSigner, ArchSignerT, LocalSigner, RemoteSigner, SignError, SignResponse,
};
#[derive(Clone, Copy, Debug)]
struct Tuning {
max_rps: f64,
max_batch: usize,
max_in_flight: usize,
queue_depth: usize,
deadline: Duration,
retries: u32,
}
impl Default for Tuning {
fn default() -> Self {
Self {
max_rps: 8.0,
max_batch: 32,
max_in_flight: 4,
queue_depth: 512,
deadline: Duration::from_secs(2),
retries: 2,
}
}
}
struct Job {
message: ArchMessage,
deadline: Instant,
reply: oneshot::Sender<Result<SignResponse, SignError>>,
}
impl Job {
fn expired(&self) -> bool {
Instant::now() >= self.deadline
}
}
pub struct BatchSigner {
mode: Mode,
}
enum Mode {
Direct(LocalSigner),
Queued(Queued),
}
struct Queued {
signer: Arc<RemoteSigner>,
pubkey: Pubkey,
tuning: Tuning,
dispatcher: OnceLock<mpsc::Sender<Job>>,
}
impl BatchSigner {
pub fn spawn(inner: ArchSigner) -> Self {
let mode = match inner {
ArchSigner::Local(local) => Mode::Direct(local),
ArchSigner::Remote(remote) => {
let pubkey = remote.pubkey();
Mode::Queued(Queued {
signer: Arc::new(remote.with_retries(0)),
pubkey,
tuning: Tuning::default(),
dispatcher: OnceLock::new(),
})
}
};
Self { mode }
}
pub fn is_batching(&self) -> bool {
matches!(self.mode, Mode::Queued(_))
}
pub fn with_max_rps(self, rps: f64) -> Self {
self.tuned(|t| t.max_rps = rps)
}
pub fn with_max_batch(self, n: usize) -> Self {
self.tuned(|t| t.max_batch = n.max(1))
}
pub fn with_max_in_flight(self, n: usize) -> Self {
self.tuned(|t| t.max_in_flight = n.max(1))
}
pub fn with_queue_depth(self, n: usize) -> Self {
self.tuned(|t| t.queue_depth = n.max(1))
}
pub fn with_deadline(self, deadline: Duration) -> Self {
self.tuned(|t| t.deadline = deadline)
}
pub fn with_retries(self, retries: u32) -> Self {
self.tuned(|t| t.retries = retries)
}
fn tuned(mut self, set: impl FnOnce(&mut Tuning)) -> Self {
if let Mode::Queued(queued) = &mut self.mode {
set(&mut queued.tuning);
}
self
}
}
impl std::fmt::Debug for BatchSigner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut out = f.debug_struct("BatchSigner");
out.field("batching", &self.is_batching());
if let Mode::Queued(queued) = &self.mode {
out.field("tuning", &queued.tuning);
}
out.finish()
}
}
impl Queued {
fn sender(&self) -> &mpsc::Sender<Job> {
self.dispatcher.get_or_init(|| {
let (tx, rx) = mpsc::channel(self.tuning.queue_depth);
let signer = Arc::clone(&self.signer);
let tuning = self.tuning;
tokio::spawn(dispatch_loop(signer, rx, tuning));
tx
})
}
fn enqueue(
&self,
message: &ArchMessage,
) -> Result<oneshot::Receiver<Result<SignResponse, SignError>>, SignError> {
let (reply, wait) = oneshot::channel();
let job = Job {
message: message.clone(),
deadline: Instant::now() + self.tuning.deadline,
reply,
};
self.sender().try_send(job).map_err(|e| match e {
mpsc::error::TrySendError::Full(_) => SignError::Signing("batch queue full".into()),
mpsc::error::TrySendError::Closed(_) => {
SignError::Signing("batch dispatcher stopped".into())
}
})?;
Ok(wait)
}
async fn reply(
&self,
wait: oneshot::Receiver<Result<SignResponse, SignError>>,
) -> Result<SignResponse, SignError> {
match tokio::time::timeout(self.tuning.deadline, wait).await {
Ok(Ok(result)) => result,
Ok(Err(_)) => Err(SignError::Signing("batch dispatcher stopped".into())),
Err(_) => Err(SignError::Signing("batch deadline exceeded".into())),
}
}
}
#[async_trait]
impl ArchSignerT for BatchSigner {
fn pubkey(&self) -> Pubkey {
match &self.mode {
Mode::Direct(local) => local.pubkey(),
Mode::Queued(queued) => queued.pubkey,
}
}
async fn sign_message(&self, message: &ArchMessage) -> Result<SignResponse, SignError> {
match &self.mode {
Mode::Direct(local) => local.sign_message(message).await,
Mode::Queued(queued) => {
let wait = queued.enqueue(message)?;
queued.reply(wait).await
}
}
}
async fn sign_messages(
&self,
messages: &[ArchMessage],
) -> Result<Vec<Result<SignResponse, SignError>>, SignError> {
let queued = match &self.mode {
Mode::Direct(local) => return local.sign_messages(messages).await,
Mode::Queued(queued) => queued,
};
let waits: Vec<_> = messages.iter().map(|m| queued.enqueue(m)).collect();
let mut results = Vec::with_capacity(waits.len());
for wait in waits {
results.push(match wait {
Ok(wait) => queued.reply(wait).await,
Err(err) => Err(err),
});
}
Ok(results)
}
}
async fn dispatch_loop(signer: Arc<RemoteSigner>, mut rx: mpsc::Receiver<Job>, tuning: Tuning) {
let in_flight = Arc::new(Semaphore::new(tuning.max_in_flight));
let limiter = Arc::new(RateLimiter::new(tuning.max_rps));
while let Some(first) = rx.recv().await {
let Ok(slot) = Arc::clone(&in_flight).acquire_owned().await else {
break;
};
limiter.acquire().await;
let mut batch = vec![first];
while batch.len() < tuning.max_batch {
match rx.try_recv() {
Ok(job) => batch.push(job),
Err(_) => break,
}
}
let (batch, expired): (Vec<Job>, Vec<Job>) =
batch.into_iter().partition(|job| !job.expired());
for job in expired {
let _ = job
.reply
.send(Err(SignError::Signing("batch deadline exceeded".into())));
}
if batch.is_empty() {
continue;
}
tokio::spawn(dispatch(
Arc::clone(&signer),
Arc::clone(&limiter),
batch,
tuning.retries,
slot,
));
}
}
async fn dispatch(
signer: Arc<RemoteSigner>,
limiter: Arc<RateLimiter>,
batch: Vec<Job>,
retries: u32,
_slot: OwnedSemaphorePermit,
) {
let messages: Vec<ArchMessage> = batch.iter().map(|job| job.message.clone()).collect();
let mut attempt = 0;
let outcome = loop {
match signer.sign_messages(&messages).await {
Ok(results) => break Ok(results),
Err(err) if attempt < retries && is_retryable(&err) => {
attempt += 1;
limiter.acquire().await;
}
Err(err) => break Err(err),
}
};
match outcome {
Ok(results) => {
for (job, result) in batch.into_iter().zip(results) {
let _ = job.reply.send(result);
}
}
Err(err) => {
for job in batch {
let _ = job.reply.send(Err(err.clone()));
}
}
}
}
struct RateLimiter {
interval: Duration,
next: Mutex<Option<Instant>>,
}
impl RateLimiter {
fn new(max_rps: f64) -> Self {
let interval = if max_rps.is_finite() && max_rps > 0.0 {
Duration::from_secs_f64(1.0 / max_rps)
} else {
Duration::ZERO
};
Self {
interval,
next: Mutex::new(None),
}
}
async fn acquire(&self) {
if self.interval.is_zero() {
return;
}
let at = {
let mut next = self
.next
.lock()
.expect("no code panics while holding the rate-limiter lock");
let now = Instant::now();
let at = next.map_or(now, |scheduled| scheduled.max(now));
*next = Some(at + self.interval);
at
};
tokio::time::sleep_until(at).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tuning_defaults_leave_headroom_under_the_sub_org_ceiling() {
let tuning = Tuning::default();
assert!(tuning.max_rps < 10.0, "retries also spend permits");
assert!(tuning.max_batch > 1);
assert!(tuning.queue_depth > tuning.max_batch);
}
#[test]
fn zero_and_negative_rates_are_unlimited_rather_than_stalled() {
for rps in [0.0, -1.0, f64::NAN, f64::INFINITY] {
assert!(
RateLimiter::new(rps).interval.is_zero(),
"rps {rps} must not stall the dispatcher"
);
}
}
#[test]
fn rate_is_the_reciprocal_of_the_interval() {
assert_eq!(RateLimiter::new(8.0).interval, Duration::from_millis(125));
assert_eq!(RateLimiter::new(2.0).interval, Duration::from_millis(500));
}
#[tokio::test]
async fn spacing_is_cumulative_across_acquisitions() {
let limiter = RateLimiter::new(50.0);
let started = Instant::now();
for _ in 0..4 {
limiter.acquire().await;
}
assert!(
started.elapsed() >= Duration::from_millis(55),
"elapsed {:?}",
started.elapsed()
);
}
}