1use std::future::Future;
36use std::pin::Pin;
37use std::sync::Arc;
38use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
39
40use bevy_ecs::entity::Entity;
41use tokio::runtime::Handle;
42use tokio::sync::Notify;
43use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
44use tokio::sync::{OwnedSemaphorePermit, Semaphore};
45use tokio::task::{JoinHandle, JoinSet};
46
47use crate::inference_pool::expect_permit;
48
49pub type ToolExecFuture = Pin<Box<dyn Future<Output = Vec<(String, String)>> + Send>>;
53
54pub type BoxedToolExec = Box<dyn FnOnce() -> ToolExecFuture + Send>;
58
59pub struct ToolJob {
61 pub entity: Entity,
63 pub exec: BoxedToolExec,
65 pub cancel: crate::cancel::CancelToken,
68}
69
70pub struct ToolOutcome {
73 pub entity: Entity,
75 pub results: Vec<(String, String)>,
77 pub elapsed: std::time::Duration,
81}
82
83#[derive(Debug)]
90pub struct ToolLaneStats {
91 queued: AtomicUsize,
92 busy: AtomicUsize,
93 parked: AtomicUsize,
94 workers: AtomicUsize,
97}
98
99impl ToolLaneStats {
100 pub fn new(workers: usize) -> Self {
102 Self {
103 queued: AtomicUsize::new(0),
104 busy: AtomicUsize::new(0),
105 parked: AtomicUsize::new(0),
106 workers: AtomicUsize::new(workers.max(1)),
107 }
108 }
109
110 pub fn enqueued(&self) {
112 self.queued.fetch_add(1, Ordering::Relaxed);
113 }
114
115 fn abandoned(&self) {
118 self.queued.fetch_sub(1, Ordering::Relaxed);
119 }
120
121 fn started(&self) {
123 self.queued.fetch_sub(1, Ordering::Relaxed);
124 self.busy.fetch_add(1, Ordering::Relaxed);
125 }
126
127 fn finished(&self) {
129 self.busy.fetch_sub(1, Ordering::Relaxed);
130 }
131
132 fn began_park(&self) {
135 self.busy.fetch_sub(1, Ordering::Relaxed);
136 self.parked.fetch_add(1, Ordering::Relaxed);
137 }
138
139 fn resumed(&self) {
141 self.parked.fetch_sub(1, Ordering::Relaxed);
142 self.busy.fetch_add(1, Ordering::Relaxed);
143 }
144
145 fn ended_park(&self) {
148 self.parked.fetch_sub(1, Ordering::Relaxed);
149 }
150
151 pub fn queued(&self) -> usize {
153 self.queued.load(Ordering::Relaxed)
154 }
155
156 pub fn busy(&self) -> usize {
158 self.busy.load(Ordering::Relaxed)
159 }
160
161 pub fn parked(&self) -> usize {
163 self.parked.load(Ordering::Relaxed)
164 }
165
166 pub fn workers(&self) -> usize {
168 self.workers.load(Ordering::Relaxed)
169 }
170
171 fn widen(&self, extra: usize) {
173 self.workers.fetch_add(extra, Ordering::Relaxed);
174 }
175
176 #[must_use]
179 pub fn is_saturated(&self) -> bool {
180 self.busy() >= self.workers() && self.queued() > 0
181 }
182}
183
184pub struct ToolLane {
187 permits: Arc<Semaphore>,
189 stats: Arc<ToolLaneStats>,
191 results: UnboundedSender<ToolOutcome>,
193 wake: Arc<Notify>,
196 runtime: Handle,
198}
199
200impl ToolLane {
201 pub fn new(
204 runtime: Handle,
205 results: UnboundedSender<ToolOutcome>,
206 wake: Arc<Notify>,
207 concurrency: usize,
208 stats: Arc<ToolLaneStats>,
209 ) -> Arc<Self> {
210 Arc::new(Self {
211 permits: Arc::new(Semaphore::new(concurrency.max(1))),
212 stats,
213 results,
214 wake,
215 runtime,
216 })
217 }
218
219 pub fn serve(self: &Arc<Self>, jobs: UnboundedReceiver<ToolJob>) -> JoinHandle<()> {
223 let lane = self.clone();
224 self.runtime.clone().spawn(serve_lane(lane, jobs))
225 }
226
227 pub fn relieve(&self, extra: usize) -> usize {
233 if extra == 0 {
234 return 0;
235 }
236 self.permits.add_permits(extra);
237 self.stats.widen(extra);
238 extra
239 }
240}
241
242async fn serve_lane(lane: Arc<ToolLane>, mut jobs: UnboundedReceiver<ToolJob>) {
245 let mut batches = JoinSet::new();
246 loop {
247 tokio::select! {
248 job = jobs.recv() => match job {
249 Some(job) => {
250 batches.spawn_on(run_batch(lane.clone(), job), &lane.runtime);
251 }
252 None => break, },
254 Some(_) = batches.join_next(), if !batches.is_empty() => {}
258 }
259 }
260 while batches.join_next().await.is_some() {}
261}
262
263async fn run_batch(lane: Arc<ToolLane>, job: ToolJob) {
266 let ToolJob {
267 entity,
268 exec,
269 cancel,
270 } = job;
271 let permit = tokio::select! {
274 biased;
275 _ = cancel.cancelled() => {
276 lane.stats.abandoned();
277 return;
278 }
279 permit = lane.permits.clone().acquire_owned() => expect_permit(permit),
280 };
281 lane.stats.started();
282 let ticket = Arc::new(LaneTicket::new(lane.clone(), permit));
283 let started = std::time::Instant::now();
284 let out = LANE_TICKET
289 .scope(ticket, async move {
290 tokio::select! {
291 biased;
292 _ = cancel.cancelled() => None,
293 out = exec() => Some(out),
294 }
295 })
296 .await;
297 let Some(out) = out else { return };
300 let _ = lane.results.send(ToolOutcome {
302 entity,
303 results: out,
304 elapsed: started.elapsed(),
305 });
306 lane.wake.notify_one();
307}
308
309tokio::task_local! {
310 static LANE_TICKET: Arc<LaneTicket>;
318}
319
320struct LaneTicket {
326 lane: Arc<ToolLane>,
327 permit: std::sync::Mutex<Option<OwnedSemaphorePermit>>,
329 parked: AtomicBool,
331}
332
333impl LaneTicket {
334 fn new(lane: Arc<ToolLane>, permit: OwnedSemaphorePermit) -> Self {
335 Self {
336 lane,
337 permit: std::sync::Mutex::new(Some(permit)),
338 parked: AtomicBool::new(false),
339 }
340 }
341
342 fn release(&self) {
344 let held = self.take_permit();
345 drop(held);
349 self.parked.store(true, Ordering::Relaxed);
350 self.lane.stats.began_park();
351 self.lane.wake.notify_one();
352 }
353
354 async fn reacquire(&self) {
358 let permit = expect_permit(self.lane.permits.clone().acquire_owned().await);
359 self.lane.stats.resumed();
362 self.parked.store(false, Ordering::Relaxed);
363 *self
364 .permit
365 .lock()
366 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(permit);
367 }
368
369 fn take_permit(&self) -> Option<OwnedSemaphorePermit> {
370 self.permit
371 .lock()
372 .unwrap_or_else(std::sync::PoisonError::into_inner)
373 .take()
374 }
375}
376
377impl Drop for LaneTicket {
378 fn drop(&mut self) {
379 drop(self.take_permit());
381 match self.parked.load(Ordering::Relaxed) {
382 true => self.lane.stats.ended_park(),
383 false => self.lane.stats.finished(),
384 }
385 self.lane.wake.notify_one();
386 }
387}
388
389pub async fn off_lane<T>(fut: impl Future<Output = T>) -> T {
400 let Ok(ticket) = LANE_TICKET.try_with(Arc::clone) else {
401 return fut.await;
402 };
403 ticket.release();
404 let out = fut.await;
405 ticket.reacquire().await;
406 out
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use std::time::Duration;
413 use tokio::sync::mpsc;
414
415 struct Harness {
418 lane: Arc<ToolLane>,
419 jobs: Option<UnboundedSender<ToolJob>>,
421 outcomes: mpsc::UnboundedReceiver<ToolOutcome>,
422 serving: Option<JoinHandle<()>>,
423 stats: Arc<ToolLaneStats>,
424 }
425
426 impl Harness {
427 fn new(concurrency: usize) -> Self {
428 let (jobs, job_rx) = mpsc::unbounded_channel();
429 let (result_tx, outcomes) = mpsc::unbounded_channel();
430 let stats = Arc::new(ToolLaneStats::new(concurrency));
431 let lane = ToolLane::new(
432 Handle::current(),
433 result_tx,
434 Arc::new(Notify::new()),
435 concurrency,
436 stats.clone(),
437 );
438 let serving = lane.serve(job_rx);
439 Self {
440 lane,
441 jobs: Some(jobs),
442 outcomes,
443 serving: Some(serving),
444 stats,
445 }
446 }
447
448 fn submit(&self, job: ToolJob) {
450 self.stats.enqueued();
451 self.sender().send(job).expect("the lane is serving");
452 }
453
454 fn sender(&self) -> &UnboundedSender<ToolJob> {
455 self.jobs.as_ref().expect("the lane is still open")
456 }
457
458 async fn drain(&mut self) {
460 drop(self.jobs.take());
461 let serving = self.serving.take().expect("the lane was serving");
462 timeout(serving).await.expect("the lane task ended");
463 }
464
465 async fn next_outcome(&mut self) -> ToolOutcome {
466 timeout(self.outcomes.recv())
467 .await
468 .expect("an outcome arrived")
469 }
470
471 async fn next_indices(&mut self, n: usize) -> Vec<u64> {
477 let mut seen = Vec::new();
478 for _ in 0..n {
479 seen.push(self.next_outcome().await.entity.to_bits());
480 }
481 seen.sort_unstable();
482 seen
483 }
484 }
485
486 async fn timeout<T>(fut: impl Future<Output = T>) -> T {
489 tokio::time::timeout(Duration::from_secs(30), fut)
490 .await
491 .expect("the lane made progress")
492 }
493
494 fn sorted_bits(entities: &[Entity]) -> Vec<u64> {
497 let mut bits: Vec<u64> = entities.iter().map(|e| e.to_bits()).collect();
498 bits.sort_unstable();
499 bits
500 }
501
502 fn entity(index: u32) -> Entity {
503 Entity::from_raw_u32(index).expect("a small literal index is a valid entity id")
504 }
505
506 fn job(index: u32, pairs: Vec<(&'static str, &'static str)>) -> ToolJob {
507 job_with(index, pairs, crate::cancel::CancelToken::new())
508 }
509
510 fn job_with(
511 index: u32,
512 pairs: Vec<(&'static str, &'static str)>,
513 cancel: crate::cancel::CancelToken,
514 ) -> ToolJob {
515 ToolJob {
516 entity: entity(index),
517 exec: Box::new(move || {
518 Box::pin(async move {
519 pairs
520 .into_iter()
521 .map(|(a, b)| (a.to_string(), b.to_string()))
522 .collect()
523 })
524 }),
525 cancel,
526 }
527 }
528
529 fn held_job(
536 index: u32,
537 started: Arc<Notify>,
538 release: Arc<Notify>,
539 cancel: crate::cancel::CancelToken,
540 ) -> ToolJob {
541 ToolJob {
542 entity: entity(index),
543 exec: Box::new(move || {
544 Box::pin(async move {
545 started.notify_one();
546 release.notified().await;
547 vec![("held".to_string(), "done".to_string())]
548 })
549 }),
550 cancel,
551 }
552 }
553
554 fn parking_job(
561 index: u32,
562 started: Arc<Notify>,
563 release: Arc<Notify>,
564 cancel: crate::cancel::CancelToken,
565 ) -> ToolJob {
566 ToolJob {
567 entity: entity(index),
568 exec: Box::new(move || {
569 Box::pin(async move {
570 off_lane(async move {
571 started.notify_one();
572 release.notified().await;
573 })
574 .await;
575 vec![("parked".to_string(), "done".to_string())]
576 })
577 }),
578 cancel,
579 }
580 }
581
582 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
583 async fn the_lane_runs_batches_and_reports_them() {
584 let mut h = Harness::new(1);
585 h.submit(job(1, vec![("c", "r")]));
586 h.submit(job(2, vec![("c", "r")]));
587
588 let first = h.next_outcome().await;
589 assert_eq!(
590 first.results,
591 vec![("c".to_string(), "r".to_string())],
592 "the batch reported its call"
593 );
594 let mut seen = vec![first.entity.to_bits()];
595 seen.extend(h.next_indices(1).await);
596 seen.sort_unstable();
597 assert_eq!(
598 seen,
599 sorted_bits(&[entity(1), entity(2)]),
600 "both batches were reported"
601 );
602
603 h.drain().await;
604 assert!(h.outcomes.try_recv().is_err(), "no more outcomes");
605 }
606
607 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
614 async fn a_parked_batch_lets_the_batch_it_waits_on_run() {
615 let mut h = Harness::new(1);
616 let started = Arc::new(Notify::new());
617 let release = Arc::new(Notify::new());
618
619 h.submit(parking_job(
620 1,
621 started.clone(),
622 release.clone(),
623 crate::cancel::CancelToken::new(),
624 ));
625 timeout(started.notified()).await;
626 assert_eq!(
627 (h.stats.busy(), h.stats.parked()),
628 (0, 1),
629 "the waiter gave the lane back"
630 );
631
632 let releaser = release.clone();
635 h.submit(ToolJob {
636 entity: entity(2),
637 exec: Box::new(move || {
638 Box::pin(async move {
639 releaser.notify_one();
640 vec![("c2".to_string(), "r2".to_string())]
641 })
642 }),
643 cancel: crate::cancel::CancelToken::new(),
644 });
645
646 assert_eq!(
647 h.next_indices(2).await,
648 sorted_bits(&[entity(1), entity(2)]),
649 "both batches finished"
650 );
651
652 h.drain().await;
653 }
654
655 #[tokio::test(flavor = "multi_thread", worker_threads = 3)]
658 async fn a_resumed_batch_takes_a_permit_again() {
659 let mut h = Harness::new(1);
660 let started = Arc::new(Notify::new());
661 let release = Arc::new(Notify::new());
662 h.submit(parking_job(
663 1,
664 started.clone(),
665 release.clone(),
666 crate::cancel::CancelToken::new(),
667 ));
668 timeout(started.notified()).await;
669
670 let held_started = Arc::new(Notify::new());
672 let held_release = Arc::new(Notify::new());
673 h.submit(held_job(
674 2,
675 held_started.clone(),
676 held_release.clone(),
677 crate::cancel::CancelToken::new(),
678 ));
679 timeout(held_started.notified()).await;
680 assert_eq!(h.stats.busy(), 1, "the lane is full again");
681
682 release.notify_one();
686 assert!(
687 tokio::time::timeout(Duration::from_millis(250), h.outcomes.recv())
688 .await
689 .is_err(),
690 "the resumed batch waited for a permit instead of running"
691 );
692
693 held_release.notify_one();
694 let first = h.next_outcome().await;
695 assert_eq!(first.entity, entity(2), "the holder finished first");
696 let second = h.next_outcome().await;
697 assert_eq!(second.entity, entity(1), "then the resumed batch");
698
699 h.drain().await;
700 assert_eq!((h.stats.busy(), h.stats.parked()), (0, 0));
701 }
702
703 #[tokio::test]
705 async fn off_lane_outside_the_lane_just_awaits() {
706 assert_eq!(off_lane(async { 7 }).await, 7);
707 }
708
709 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
712 async fn a_cancelled_batch_is_abandoned_and_frees_the_lane() {
713 let mut h = Harness::new(1);
714 let cancel = crate::cancel::CancelToken::new();
715 let started = Arc::new(Notify::new());
716 let release = Arc::new(Notify::new());
717 h.submit(held_job(1, started.clone(), release, cancel.clone()));
718 timeout(started.notified()).await;
719 assert_eq!((h.stats.queued(), h.stats.busy()), (0, 1));
720
721 h.submit(job(2, vec![("c2", "r2")]));
723 cancel.cancel();
724
725 let next = h.next_outcome().await;
726 assert_eq!(next.entity, entity(2), "the queued batch ran");
727 h.drain().await;
728 assert!(
729 h.outcomes.try_recv().is_err(),
730 "the cancelled batch reported nothing"
731 );
732 assert_eq!(h.stats.busy(), 0, "and gave its permit back");
733 }
734
735 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
738 async fn a_cancelled_parked_batch_leaves_the_counters_straight() {
739 let mut h = Harness::new(1);
740 let cancel = crate::cancel::CancelToken::new();
741 let started = Arc::new(Notify::new());
742 let release = Arc::new(Notify::new());
743 h.submit(parking_job(1, started.clone(), release, cancel.clone()));
744 timeout(started.notified()).await;
745 assert_eq!((h.stats.busy(), h.stats.parked()), (0, 1));
746
747 cancel.cancel();
748 h.submit(job(2, vec![("c2", "r2")]));
749 let next = h.next_outcome().await;
750 assert_eq!(next.entity, entity(2));
751
752 h.drain().await;
753 assert_eq!((h.stats.busy(), h.stats.parked()), (0, 0));
754 }
755
756 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
759 async fn a_batch_cancelled_while_queued_never_runs() {
760 let mut h = Harness::new(1);
761 let blocker = crate::cancel::CancelToken::new();
762 let started = Arc::new(Notify::new());
763 let release = Arc::new(Notify::new());
764 h.submit(held_job(1, started.clone(), release.clone(), blocker));
765 timeout(started.notified()).await;
766
767 let cancel = crate::cancel::CancelToken::new();
768 h.submit(job_with(2, vec![("c2", "r2")], cancel.clone()));
769 cancel.cancel();
770 release.notify_one();
771
772 let first = h.next_outcome().await;
773 assert_eq!(first.entity, entity(1));
774 h.drain().await;
775 assert!(
776 h.outcomes.try_recv().is_err(),
777 "the cancelled batch never produced results"
778 );
779 assert_eq!(h.stats.queued(), 0, "and left the queue count clean");
780 }
781
782 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
783 async fn the_lane_runs_batches_concurrently_up_to_its_cap() {
784 let h = Harness::new(3);
785 let barrier = Arc::new(tokio::sync::Barrier::new(3));
791 for i in 1..=3u32 {
792 let barrier = barrier.clone();
793 h.submit(ToolJob {
794 entity: entity(i),
795 exec: Box::new(move || {
796 Box::pin(async move {
797 barrier.wait().await;
798 vec![("c".to_string(), "r".to_string())]
799 })
800 }),
801 cancel: crate::cancel::CancelToken::new(),
802 });
803 }
804 let mut h = h;
805 h.drain().await;
806 for _ in 0..3 {
807 timeout(h.outcomes.recv()).await.expect("outcome present");
808 }
809 }
810
811 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
812 async fn the_lane_survives_a_dropped_outcome_receiver() {
813 let mut h = Harness::new(1);
814 h.submit(job(9, vec![("c", "r")]));
815 h.outcomes.close();
818 h.drain().await;
819 }
820
821 #[tokio::test(flavor = "multi_thread", worker_threads = 3)]
823 async fn relief_widens_the_lane() {
824 let mut h = Harness::new(1);
825 let started = Arc::new(Notify::new());
826 let release = Arc::new(Notify::new());
827 h.submit(held_job(
828 1,
829 started.clone(),
830 release.clone(),
831 crate::cancel::CancelToken::new(),
832 ));
833 timeout(started.notified()).await;
834 h.submit(job(2, vec![("c2", "r2")]));
835 assert!(h.stats.is_saturated(), "full, with a batch behind it");
836
837 assert_eq!(h.lane.relieve(0), 0, "relieving nothing changes nothing");
838 assert_eq!(h.lane.relieve(1), 1);
839 assert_eq!(h.stats.workers(), 2, "the cap moved with the permits");
840
841 let freed = h.next_outcome().await;
842 assert_eq!(freed.entity, entity(2), "the queued batch got in");
843
844 release.notify_one();
845 let held = h.next_outcome().await;
846 assert_eq!(held.entity, entity(1));
847 h.drain().await;
848 }
849
850 #[tokio::test]
854 async fn a_zero_width_lane_is_clamped_to_one() {
855 assert_eq!(ToolLaneStats::new(0).workers(), 1);
856 let mut h = Harness::new(0);
857 h.submit(job(7, vec![("c", "r")]));
858 assert_eq!(h.next_outcome().await.entity, entity(7));
859 h.drain().await;
860 }
861
862 #[test]
863 fn lane_stats_track_queue_depth_and_saturation() {
864 let stats = ToolLaneStats::new(2);
865 assert_eq!((stats.queued(), stats.busy(), stats.parked()), (0, 0, 0));
866 assert!(!stats.is_saturated(), "an idle lane is not saturated");
867
868 stats.enqueued();
869 stats.enqueued();
870 stats.enqueued();
871 assert_eq!(stats.queued(), 3);
872 stats.started();
874 stats.started();
875 assert_eq!((stats.queued(), stats.busy()), (1, 2));
876 assert!(
877 stats.is_saturated(),
878 "the lane is full with a batch still queued"
879 );
880
881 stats.began_park();
883 assert_eq!((stats.busy(), stats.parked()), (1, 1));
884 assert!(!stats.is_saturated(), "parked capacity is capacity");
885 stats.resumed();
886 assert_eq!((stats.busy(), stats.parked()), (2, 0));
887
888 stats.began_park();
889 stats.ended_park();
890 assert_eq!((stats.busy(), stats.parked()), (1, 0));
891
892 stats.finished();
893 stats.abandoned();
894 assert_eq!((stats.queued(), stats.busy()), (0, 0));
895 }
896}