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}