1use std::sync::atomic::{AtomicUsize, Ordering};
14use std::sync::Arc;
15use std::time::Instant;
16
17use tokio::sync::{mpsc, Mutex};
18use tokio::task::JoinHandle;
19
20use helix_core::effect::Correlation;
21use helix_core::tick::PortOutcome;
22use helix_core::Tick;
23
24use crate::metrics::{
25 AsyncMetricSink, LabelKey, MetricEvent, MetricId, MetricLabels, NoopMetricSink,
26};
27
28pub(crate) struct StampedFeedback {
30 pub(crate) tick: Tick,
31 pub(crate) enqueued_at: Instant,
32}
33
34#[derive(Clone)]
36pub(crate) struct FeedbackSender {
37 tx: mpsc::UnboundedSender<StampedFeedback>,
38}
39
40pub(crate) fn feedback_channel() -> (FeedbackSender, mpsc::UnboundedReceiver<StampedFeedback>) {
42 let (tx, rx) = mpsc::unbounded_channel();
43 (FeedbackSender { tx }, rx)
44}
45
46#[derive(Clone)]
47pub(crate) enum FeedbackSink {
48 Raw(mpsc::UnboundedSender<Tick>),
49 Stamped(FeedbackSender),
50}
51
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub enum Overflow {
55 Block,
57 DropNewest,
62}
63
64pub struct Job<P> {
66 pub corr: Option<Correlation>,
67 pub payload: P,
68}
69
70struct QueuedJob<P> {
72 job: Job<P>,
73 enqueued_at: Option<Instant>,
74}
75
76#[derive(Clone)]
78struct SpawnerMetrics {
79 sink: Arc<dyn AsyncMetricSink>,
80 pool: &'static str,
81 overflow: &'static str,
82 depth: Arc<AtomicUsize>,
83 inflight: Arc<AtomicUsize>,
84 enabled: bool,
85}
86
87pub struct BoundedSpawner<P: Send + 'static> {
89 tx: mpsc::Sender<QueuedJob<P>>,
91 joins: Vec<JoinHandle<()>>,
93 overflow: Overflow,
95 metrics: SpawnerMetrics,
97}
98
99impl<P: Send + 'static> BoundedSpawner<P> {
100 pub fn new<F, Fut>(
105 concurrency: usize,
106 queue_cap: usize,
107 overflow: Overflow,
108 reply_tx: mpsc::UnboundedSender<Tick>,
109 run: F,
110 ) -> Self
111 where
112 F: Fn(Option<Correlation>, P) -> Fut + Clone + Send + 'static,
113 Fut: std::future::Future<Output = PortOutcome> + Send + 'static,
114 {
115 Self::new_observed(
116 concurrency,
117 queue_cap,
118 overflow,
119 reply_tx,
120 Arc::new(NoopMetricSink),
121 "unobserved",
122 run,
123 )
124 }
125
126 #[allow(clippy::too_many_arguments)]
128 pub fn new_observed<F, Fut>(
129 concurrency: usize,
130 queue_cap: usize,
131 overflow: Overflow,
132 reply_tx: mpsc::UnboundedSender<Tick>,
133 sink: Arc<dyn AsyncMetricSink>,
134 pool: &'static str,
135 run: F,
136 ) -> Self
137 where
138 F: Fn(Option<Correlation>, P) -> Fut + Clone + Send + 'static,
139 Fut: std::future::Future<Output = PortOutcome> + Send + 'static,
140 {
141 Self::new_with_feedback(
142 concurrency,
143 queue_cap,
144 overflow,
145 FeedbackSink::Raw(reply_tx),
146 sink,
147 pool,
148 run,
149 )
150 }
151
152 #[allow(clippy::too_many_arguments)]
154 pub(crate) fn new_with_feedback<F, Fut>(
155 concurrency: usize,
156 queue_cap: usize,
157 overflow: Overflow,
158 feedback_tx: FeedbackSink,
159 sink: Arc<dyn AsyncMetricSink>,
160 pool: &'static str,
161 run: F,
162 ) -> Self
163 where
164 F: Fn(Option<Correlation>, P) -> Fut + Clone + Send + 'static,
165 Fut: std::future::Future<Output = PortOutcome> + Send + 'static,
166 {
167 let queue_cap = queue_cap.max(1);
168 let concurrency = concurrency.max(1);
169 let (tx, rx) = mpsc::channel::<QueuedJob<P>>(queue_cap);
170 let metrics = SpawnerMetrics::new(sink, pool, overflow, queue_cap, concurrency);
171 let rx = Arc::new(Mutex::new(rx));
173 let joins = (0..concurrency)
174 .map(|_| {
175 let rx = Arc::clone(&rx);
176 let feedback_tx = feedback_tx.clone();
177 let run = run.clone();
178 let metrics = metrics.clone();
179 tokio::spawn(async move {
180 loop {
181 let job = {
187 let mut guard = rx.lock().await;
188 guard.recv().await
189 };
190 let Some(QueuedJob {
191 job: Job { corr, payload },
192 enqueued_at,
193 }) = job
194 else {
195 break;
196 };
197 metrics.on_dequeue(enqueued_at);
198 let execution_started = metrics.enabled.then(Instant::now);
199 metrics.on_execution_start();
200 let outcome = run(corr, payload).await; metrics.on_execution_complete(execution_started, &outcome);
202 if let Some(corr) = corr {
203 feedback_tx.send(Tick::PortReply { corr, outcome });
205 }
206 }
208 })
209 })
210 .collect();
211 Self {
212 tx,
213 joins,
214 overflow,
215 metrics,
216 }
217 }
218
219 pub async fn submit(&self, job: Job<P>) {
223 let submit_started = self.metrics.enabled.then(Instant::now);
224 match self.overflow {
225 Overflow::Block => {
226 match self.tx.reserve().await {
228 Ok(permit) => {
229 self.metrics.on_enqueue(submit_started);
230 permit.send(QueuedJob {
231 job,
232 enqueued_at: self.metrics.enabled.then(Instant::now),
233 });
234 }
235 Err(_) => self.metrics.on_closed(),
236 }
237 }
238 Overflow::DropNewest => {
239 match self.tx.try_reserve() {
243 Ok(permit) => {
244 self.metrics.on_enqueue(submit_started);
245 permit.send(QueuedJob {
246 job,
247 enqueued_at: self.metrics.enabled.then(Instant::now),
248 });
249 }
250 Err(mpsc::error::TrySendError::Full(())) => {
251 self.metrics.on_drop();
252 tracing::warn!(
253 "HttpFire 队列满,丢弃本条(满即丢当前,fire-and-forget 自愈请求,靠 cursor-gate 重发兜底)"
254 );
255 }
256 Err(mpsc::error::TrySendError::Closed(())) => self.metrics.on_closed(),
257 }
258 }
259 }
260 }
261
262 pub async fn shutdown(self) {
264 let Self {
265 tx, joins, metrics, ..
266 } = self;
267 drop(tx);
268 for join in joins {
269 if join.await.is_err_and(|error| error.is_panic()) {
270 metrics.on_worker_panic();
271 }
272 }
273 }
274
275 pub async fn shutdown_with_timeout(self, limit: std::time::Duration) -> bool {
283 let Self {
284 tx, joins, metrics, ..
285 } = self;
286 drop(tx); let abort_handles: Vec<_> = joins.iter().map(|j| j.abort_handle()).collect();
289 let join_fut = async move {
290 for join in joins {
291 if join.await.is_err_and(|error| error.is_panic()) {
292 metrics.on_worker_panic();
293 }
294 }
295 };
296 match tokio::time::timeout(limit, join_fut).await {
297 Ok(()) => true, Err(_) => {
299 for h in abort_handles {
301 h.abort();
302 }
303 false
304 }
305 }
306 }
307}
308
309impl FeedbackSender {
310 pub(crate) fn send(&self, tick: Tick) {
312 self.tx
313 .send(StampedFeedback {
314 tick,
315 enqueued_at: Instant::now(),
316 })
317 .ok();
318 }
319}
320
321impl FeedbackSink {
322 pub(crate) fn send(&self, tick: Tick) {
324 match self {
325 Self::Raw(tx) => {
326 tx.send(tick).ok();
327 }
328 Self::Stamped(tx) => tx.send(tick),
329 }
330 }
331}
332
333impl Overflow {
334 const fn as_metric_label(self) -> &'static str {
336 match self {
337 Self::Block => "block",
338 Self::DropNewest => "drop_newest",
339 }
340 }
341}
342
343impl SpawnerMetrics {
344 fn new(
346 sink: Arc<dyn AsyncMetricSink>,
347 pool: &'static str,
348 overflow: Overflow,
349 queue_cap: usize,
350 workers: usize,
351 ) -> Self {
352 let enabled = sink.is_enabled();
353 let metrics = Self {
354 sink,
355 pool,
356 overflow: overflow.as_metric_label(),
357 depth: Arc::new(AtomicUsize::new(0)),
358 inflight: Arc::new(AtomicUsize::new(0)),
359 enabled,
360 };
361 metrics.gauge(MetricId::PoolQueueCapacity, queue_cap);
362 metrics.gauge(MetricId::PoolWorkers, workers);
363 metrics
364 }
365
366 fn on_enqueue(&self, started: Option<Instant>) {
368 if !self.enabled {
369 return;
370 }
371 if let Some(started) = started {
372 self.histogram(MetricId::PoolEnqueueBlockSeconds, started.elapsed());
373 }
374 let depth = self.depth.fetch_add(1, Ordering::Relaxed) + 1;
375 self.gauge(MetricId::PoolQueueDepth, depth);
376 }
377
378 fn on_dequeue(&self, enqueued_at: Option<Instant>) {
380 if !self.enabled {
381 return;
382 }
383 let depth = decrement_saturating(&self.depth);
384 self.gauge(MetricId::PoolQueueDepth, depth);
385 if let Some(enqueued_at) = enqueued_at {
386 self.histogram(MetricId::PoolQueueResidencySeconds, enqueued_at.elapsed());
387 }
388 }
389
390 fn on_execution_start(&self) {
392 if !self.enabled {
393 return;
394 }
395 let inflight = self.inflight.fetch_add(1, Ordering::Relaxed) + 1;
396 self.gauge(MetricId::PoolInflight, inflight);
397 }
398
399 fn on_execution_complete(&self, started: Option<Instant>, outcome: &PortOutcome) {
401 if !self.enabled {
402 return;
403 }
404 let inflight = decrement_saturating(&self.inflight);
405 self.gauge(MetricId::PoolInflight, inflight);
406 let Some(started) = started else {
407 return;
408 };
409 let status = if matches!(outcome, PortOutcome::Ok(_)) {
410 "ok"
411 } else {
412 "error"
413 };
414 self.record(MetricEvent::histogram(
415 MetricId::PoolExecutionSeconds,
416 started.elapsed().as_secs_f64(),
417 self.labels().with(LabelKey::Status, status),
418 ));
419 }
420
421 fn on_drop(&self) {
423 self.counter(MetricId::PoolDroppedTotal);
424 }
425
426 fn on_closed(&self) {
428 self.counter(MetricId::PoolClosedTotal);
429 }
430
431 fn on_worker_panic(&self) {
433 self.counter(MetricId::PoolJobPanicsTotal);
434 }
435
436 fn labels(&self) -> MetricLabels {
438 MetricLabels::one(LabelKey::Stage, "effect")
439 .with(LabelKey::Pool, self.pool)
440 .with(LabelKey::Overflow, self.overflow)
441 }
442
443 fn gauge(&self, id: MetricId, value: usize) {
445 self.record(MetricEvent::gauge(id, value as f64, self.labels()));
446 }
447
448 fn counter(&self, id: MetricId) {
450 self.record(MetricEvent::counter(id, 1.0, self.labels()));
451 }
452
453 fn histogram(&self, id: MetricId, value: std::time::Duration) {
455 self.record(MetricEvent::histogram(
456 id,
457 value.as_secs_f64(),
458 self.labels(),
459 ));
460 }
461
462 fn record(&self, event: MetricEvent) {
464 if self.enabled {
465 let _ = self.sink.try_record(event);
466 }
467 }
468}
469
470fn decrement_saturating(value: &AtomicUsize) -> usize {
472 value
473 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
474 Some(current.saturating_sub(1))
475 })
476 .unwrap_or_default()
477 .saturating_sub(1)
478}
479
480#[cfg(test)]
481#[path = "spawner_tests.rs"]
482mod tests;