Skip to main content

cosigner_client/
batch.rs

1//! Coalescing of concurrent single-message signing into batched proxy calls
2//! under a rate budget.
3//!
4//! A caller that holds one message per request — an HTTP handler, say — cannot
5//! use [`sign_messages`](crate::ArchSignerT::sign_messages) on its own, because
6//! it never has N messages at once. [`BatchSigner`] supplies the missing piece:
7//! requests wait briefly in a bounded queue, and a dispatcher groups whatever
8//! has arrived into one activity.
9//!
10//! ```text
11//!   sign_message ─┐
12//!   sign_message ─┼─► queue ─► dispatcher ─► one POST /v1/sign_batch
13//!   sign_message ─┘             │  ▲
14//!                               │  └── rate limiter: the wait for a permit
15//!                               │      IS the batching window
16//!                               └───── in-flight cap on concurrent activities
17//! ```
18//!
19//! The window is not a fixed linger: under light load a permit is free, the
20//! drain finds nothing, and the batch is one message with no added latency.
21//! Under load, requests pile up while the dispatcher waits for its permit, so
22//! batches grow on their own and the ceiling becomes roughly
23//! `max_rps × max_batch` messages per second.
24//!
25//! No Turnkey limit is hard-coded here: every value is a default the consumer
26//! overrides.
27
28use std::sync::{Arc, Mutex, OnceLock};
29use std::time::Duration;
30
31use arch_program::pubkey::Pubkey;
32use arch_program::sanitized::ArchMessage;
33use async_trait::async_trait;
34use tokio::sync::{mpsc, oneshot, OwnedSemaphorePermit, Semaphore};
35use tokio::time::Instant;
36
37use crate::{
38    is_retryable, ArchSigner, ArchSignerT, LocalSigner, RemoteSigner, SignError, SignResponse,
39};
40
41/// Tuning for the queued path. Every field is a starting point the consumer
42/// overrides; none of them encodes a Turnkey limit.
43#[derive(Clone, Copy, Debug)]
44struct Tuning {
45    max_rps: f64,
46    max_batch: usize,
47    max_in_flight: usize,
48    queue_depth: usize,
49    deadline: Duration,
50    retries: u32,
51}
52
53impl Default for Tuning {
54    fn default() -> Self {
55        Self {
56            // Below the sub-org ceiling so retry attempts, which also spend
57            // permits, and the proxy's own bounded retry have headroom.
58            max_rps: 8.0,
59            max_batch: 32,
60            // At ~300ms per activity this sustains far more than max_rps
61            // allows, so the limiter stays the binding constraint.
62            max_in_flight: 4,
63            queue_depth: 512,
64            deadline: Duration::from_secs(2),
65            retries: 2,
66        }
67    }
68}
69
70/// One queued signing request and the channel its result goes back on.
71struct Job {
72    message: ArchMessage,
73    deadline: Instant,
74    reply: oneshot::Sender<Result<SignResponse, SignError>>,
75}
76
77impl Job {
78    fn expired(&self) -> bool {
79        Instant::now() >= self.deadline
80    }
81}
82
83/// Coalesces concurrent single-message signing into batched proxy calls under a
84/// configurable rate budget. Implements [`ArchSignerT`], so call sites are
85/// unchanged.
86///
87/// A local backend is passed straight through — see
88/// [`is_batching`](Self::is_batching).
89///
90/// # Examples
91///
92/// ```no_run
93/// use std::time::Duration;
94/// use cosigner_client::{ArchSigner, ArchSignerT, BatchSigner};
95///
96/// # async fn run(message: arch_program::sanitized::ArchMessage)
97/// #     -> Result<(), cosigner_client::SignError> {
98/// let signer = BatchSigner::spawn(ArchSigner::from_env()?)
99///     .with_max_rps(8.0)
100///     .with_max_batch(32)
101///     .with_deadline(Duration::from_millis(2000));
102///
103/// // Unchanged call site; concurrent calls share one proxy round trip.
104/// let response = signer.sign_message(&message).await?;
105/// # let _ = response;
106/// # Ok(())
107/// # }
108/// ```
109pub struct BatchSigner {
110    mode: Mode,
111}
112
113enum Mode {
114    /// Local signing has no rate limit and no round trip, so queueing it would
115    /// only import a concurrency cap and a queue-full failure mode into a path
116    /// that has neither.
117    Direct(LocalSigner),
118    Queued(Queued),
119}
120
121struct Queued {
122    signer: Arc<RemoteSigner>,
123    pubkey: Pubkey,
124    tuning: Tuning,
125    /// Started with the first queued request, so the builder setters shape the
126    /// queue they configure even though they run after [`BatchSigner::spawn`].
127    dispatcher: OnceLock<mpsc::Sender<Job>>,
128}
129
130impl BatchSigner {
131    /// Creates the signer and the dispatcher that serves it.
132    ///
133    /// A local `inner` is passed through directly, with no task, channel, or
134    /// limiter. For a remote `inner` the dispatcher task starts with the first
135    /// queued request; apply the builder setters before then, since they
136    /// configure a queue that is already in use afterwards.
137    ///
138    /// `inner`'s own retry budget is cleared: the dispatcher owns retry so that
139    /// every attempt spends a rate-limiter permit.
140    pub fn spawn(inner: ArchSigner) -> Self {
141        let mode = match inner {
142            ArchSigner::Local(local) => Mode::Direct(local),
143            ArchSigner::Remote(remote) => {
144                let pubkey = remote.pubkey();
145                Mode::Queued(Queued {
146                    // Retries here would bypass the rate limiter, turning a
147                    // transient failure into a burst past the configured rate.
148                    signer: Arc::new(remote.with_retries(0)),
149                    pubkey,
150                    tuning: Tuning::default(),
151                    dispatcher: OnceLock::new(),
152                })
153            }
154        };
155        Self { mode }
156    }
157
158    /// Returns whether requests are coalesced, i.e. whether the backend is
159    /// remote.
160    ///
161    /// Worth logging beside the resolved backend: under a local signer the
162    /// batch metrics stay flat, which otherwise reads as a bug.
163    pub fn is_batching(&self) -> bool {
164        matches!(self.mode, Mode::Queued(_))
165    }
166
167    /// Returns this signer with the ceiling on activities per second.
168    ///
169    /// A non-positive or non-finite rate means unlimited.
170    pub fn with_max_rps(self, rps: f64) -> Self {
171        self.tuned(|t| t.max_rps = rps)
172    }
173
174    /// Returns this signer with the largest number of messages per activity.
175    pub fn with_max_batch(self, n: usize) -> Self {
176        self.tuned(|t| t.max_batch = n.max(1))
177    }
178
179    /// Returns this signer with the cap on concurrent in-flight activities.
180    pub fn with_max_in_flight(self, n: usize) -> Self {
181        self.tuned(|t| t.max_in_flight = n.max(1))
182    }
183
184    /// Returns this signer with the bound on queued requests. Beyond it,
185    /// signing fails immediately rather than waiting.
186    pub fn with_queue_depth(self, n: usize) -> Self {
187        self.tuned(|t| t.queue_depth = n.max(1))
188    }
189
190    /// Returns this signer with the per-request deadline. A request still
191    /// queued when it expires is dropped rather than signed.
192    pub fn with_deadline(self, deadline: Duration) -> Self {
193        self.tuned(|t| t.deadline = deadline)
194    }
195
196    /// Returns this signer with the dispatcher's retry budget for a whole
197    /// batch. Each attempt spends a rate-limiter permit.
198    pub fn with_retries(self, retries: u32) -> Self {
199        self.tuned(|t| t.retries = retries)
200    }
201
202    /// Applies `set` to the queued tuning; a no-op in direct mode, where there
203    /// is no budget to configure.
204    fn tuned(mut self, set: impl FnOnce(&mut Tuning)) -> Self {
205        if let Mode::Queued(queued) = &mut self.mode {
206            set(&mut queued.tuning);
207        }
208        self
209    }
210}
211
212impl std::fmt::Debug for BatchSigner {
213    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214        let mut out = f.debug_struct("BatchSigner");
215        out.field("batching", &self.is_batching());
216        if let Mode::Queued(queued) = &self.mode {
217            out.field("tuning", &queued.tuning);
218        }
219        out.finish()
220    }
221}
222
223impl Queued {
224    fn sender(&self) -> &mpsc::Sender<Job> {
225        self.dispatcher.get_or_init(|| {
226            let (tx, rx) = mpsc::channel(self.tuning.queue_depth);
227            let signer = Arc::clone(&self.signer);
228            let tuning = self.tuning;
229            tokio::spawn(dispatch_loop(signer, rx, tuning));
230            tx
231        })
232    }
233
234    /// Queues `message`, returning the channel its result arrives on.
235    ///
236    /// Kept separate from awaiting so a caller can enqueue a whole slice before
237    /// blocking on any of it, letting the dispatcher batch the lot.
238    ///
239    /// # Errors
240    /// [`SignError::Signing`] when the queue is full or the dispatcher has
241    /// stopped. A fast rejection beats unbounded latency when the caller's work
242    /// expires.
243    fn enqueue(
244        &self,
245        message: &ArchMessage,
246    ) -> Result<oneshot::Receiver<Result<SignResponse, SignError>>, SignError> {
247        let (reply, wait) = oneshot::channel();
248        let job = Job {
249            message: message.clone(),
250            deadline: Instant::now() + self.tuning.deadline,
251            reply,
252        };
253        self.sender().try_send(job).map_err(|e| match e {
254            mpsc::error::TrySendError::Full(_) => SignError::Signing("batch queue full".into()),
255            mpsc::error::TrySendError::Closed(_) => {
256                SignError::Signing("batch dispatcher stopped".into())
257            }
258        })?;
259        Ok(wait)
260    }
261
262    /// Awaits one queued result under the deadline.
263    ///
264    /// The timeout is enforced here as well as at drain time, so a dispatcher
265    /// that died cannot leave a caller waiting forever.
266    async fn reply(
267        &self,
268        wait: oneshot::Receiver<Result<SignResponse, SignError>>,
269    ) -> Result<SignResponse, SignError> {
270        match tokio::time::timeout(self.tuning.deadline, wait).await {
271            Ok(Ok(result)) => result,
272            Ok(Err(_)) => Err(SignError::Signing("batch dispatcher stopped".into())),
273            Err(_) => Err(SignError::Signing("batch deadline exceeded".into())),
274        }
275    }
276}
277
278#[async_trait]
279impl ArchSignerT for BatchSigner {
280    fn pubkey(&self) -> Pubkey {
281        match &self.mode {
282            Mode::Direct(local) => local.pubkey(),
283            Mode::Queued(queued) => queued.pubkey,
284        }
285    }
286
287    async fn sign_message(&self, message: &ArchMessage) -> Result<SignResponse, SignError> {
288        match &self.mode {
289            Mode::Direct(local) => local.sign_message(message).await,
290            Mode::Queued(queued) => {
291                let wait = queued.enqueue(message)?;
292                queued.reply(wait).await
293            }
294        }
295    }
296
297    async fn sign_messages(
298        &self,
299        messages: &[ArchMessage],
300    ) -> Result<Vec<Result<SignResponse, SignError>>, SignError> {
301        let queued = match &self.mode {
302            Mode::Direct(local) => return local.sign_messages(messages).await,
303            Mode::Queued(queued) => queued,
304        };
305
306        // Every message is queued as its own job rather than forwarded as one
307        // batch, so the rate budget stays authoritative. All of them are
308        // enqueued before anything is awaited, so the dispatcher can still
309        // group them.
310        let waits: Vec<_> = messages.iter().map(|m| queued.enqueue(m)).collect();
311        let mut results = Vec::with_capacity(waits.len());
312        for wait in waits {
313            results.push(match wait {
314                Ok(wait) => queued.reply(wait).await,
315                Err(err) => Err(err),
316            });
317        }
318        Ok(results)
319    }
320}
321
322/// Groups queued jobs into activities under the rate and concurrency budgets.
323///
324/// Blocking on a permit before draining is what creates the batching window:
325/// whatever arrives during the wait rides along in the next activity.
326async fn dispatch_loop(signer: Arc<RemoteSigner>, mut rx: mpsc::Receiver<Job>, tuning: Tuning) {
327    let in_flight = Arc::new(Semaphore::new(tuning.max_in_flight));
328    let limiter = Arc::new(RateLimiter::new(tuning.max_rps));
329
330    while let Some(first) = rx.recv().await {
331        let Ok(slot) = Arc::clone(&in_flight).acquire_owned().await else {
332            break;
333        };
334        limiter.acquire().await;
335
336        let mut batch = vec![first];
337        while batch.len() < tuning.max_batch {
338            match rx.try_recv() {
339                Ok(job) => batch.push(job),
340                Err(_) => break,
341            }
342        }
343
344        // A request whose deadline passed while it waited is worthless; drop it
345        // rather than spend a signature on it.
346        let (batch, expired): (Vec<Job>, Vec<Job>) =
347            batch.into_iter().partition(|job| !job.expired());
348        for job in expired {
349            let _ = job
350                .reply
351                .send(Err(SignError::Signing("batch deadline exceeded".into())));
352        }
353        if batch.is_empty() {
354            continue;
355        }
356
357        // Dispatch off the loop so the network round trip never blocks the next
358        // batch from forming.
359        tokio::spawn(dispatch(
360            Arc::clone(&signer),
361            Arc::clone(&limiter),
362            batch,
363            tuning.retries,
364            slot,
365        ));
366    }
367}
368
369/// Signs one batch and answers every job in it.
370///
371/// `_slot` is held for the whole activity, so dropping it on return is what
372/// frees the in-flight budget.
373async fn dispatch(
374    signer: Arc<RemoteSigner>,
375    limiter: Arc<RateLimiter>,
376    batch: Vec<Job>,
377    retries: u32,
378    _slot: OwnedSemaphorePermit,
379) {
380    let messages: Vec<ArchMessage> = batch.iter().map(|job| job.message.clone()).collect();
381
382    let mut attempt = 0;
383    let outcome = loop {
384        match signer.sign_messages(&messages).await {
385            Ok(results) => break Ok(results),
386            Err(err) if attempt < retries && is_retryable(&err) => {
387                attempt += 1;
388                // A retry spends a permit like any other attempt, so a
389                // transient failure cannot burst past the configured rate.
390                limiter.acquire().await;
391            }
392            Err(err) => break Err(err),
393        }
394    };
395
396    match outcome {
397        Ok(results) => {
398            for (job, result) in batch.into_iter().zip(results) {
399                let _ = job.reply.send(result);
400            }
401        }
402        Err(err) => {
403            for job in batch {
404                let _ = job.reply.send(Err(err.clone()));
405            }
406        }
407    }
408}
409
410/// Admits at most `max_rps` acquisitions per second by spacing them evenly.
411///
412/// Even spacing rather than a refilling bucket: a burst is exactly what the
413/// per-sub-organization rate limit rejects, so there is nothing to gain by
414/// allowing one.
415struct RateLimiter {
416    interval: Duration,
417    /// Earliest instant the next acquisition may proceed.
418    next: Mutex<Option<Instant>>,
419}
420
421impl RateLimiter {
422    fn new(max_rps: f64) -> Self {
423        // A non-positive or non-finite rate would otherwise mean "never admit",
424        // stalling the dispatcher; treat it as unlimited.
425        let interval = if max_rps.is_finite() && max_rps > 0.0 {
426            Duration::from_secs_f64(1.0 / max_rps)
427        } else {
428            Duration::ZERO
429        };
430        Self {
431            interval,
432            next: Mutex::new(None),
433        }
434    }
435
436    async fn acquire(&self) {
437        if self.interval.is_zero() {
438            return;
439        }
440        let at = {
441            let mut next = self
442                .next
443                .lock()
444                .expect("no code panics while holding the rate-limiter lock");
445            let now = Instant::now();
446            let at = next.map_or(now, |scheduled| scheduled.max(now));
447            *next = Some(at + self.interval);
448            at
449        };
450        tokio::time::sleep_until(at).await;
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    #[test]
459    fn tuning_defaults_leave_headroom_under_the_sub_org_ceiling() {
460        let tuning = Tuning::default();
461        assert!(tuning.max_rps < 10.0, "retries also spend permits");
462        assert!(tuning.max_batch > 1);
463        assert!(tuning.queue_depth > tuning.max_batch);
464    }
465
466    #[test]
467    fn zero_and_negative_rates_are_unlimited_rather_than_stalled() {
468        for rps in [0.0, -1.0, f64::NAN, f64::INFINITY] {
469            assert!(
470                RateLimiter::new(rps).interval.is_zero(),
471                "rps {rps} must not stall the dispatcher"
472            );
473        }
474    }
475
476    #[test]
477    fn rate_is_the_reciprocal_of_the_interval() {
478        assert_eq!(RateLimiter::new(8.0).interval, Duration::from_millis(125));
479        assert_eq!(RateLimiter::new(2.0).interval, Duration::from_millis(500));
480    }
481
482    #[tokio::test]
483    async fn spacing_is_cumulative_across_acquisitions() {
484        let limiter = RateLimiter::new(50.0);
485        let started = Instant::now();
486        for _ in 0..4 {
487            limiter.acquire().await;
488        }
489        // First is immediate, then three 20ms gaps.
490        assert!(
491            started.elapsed() >= Duration::from_millis(55),
492            "elapsed {:?}",
493            started.elapsed()
494        );
495    }
496}