Skip to main content

helix_driver_host/
spawner.rs

1//! BoundedSpawner — host 内通用有界并发原语(刀2-driver)。
2//!
3//! Persist / Http(必达) / HttpFire(可丢) 统一成此原语:有界 mpsc 队列(cap=K) + N 个
4//! 常驻 worker task + 溢出策略 + 独立 unbounded reply_tx 回灌。三类只是参数差异,
5//! 背压纪律集中在一处(Dioxus「统一流」取舍:一个背压/批次/调度策略管全部)。
6//!
7//! ## 死锁安全(与现有 Persist 模型同构,已压测验证)
8//!
9//! 抽水在独立 worker task(不是泵自己 drain),PortReply 走独立 unbounded reply_tx,
10//! 故 Block 模式泵阻塞在「入队」是安全的:依赖链是单向 DAG(泵等 worker,worker 不等泵)。
11//! 🔴 两条红线:① reply_tx 必须 unbounded(不变量1)② 抽水绝不放泵循环(不变量2)。
12
13use 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
28/// Engine 专用回灌元素:Tick 与完成入队时刻同处一条 unbounded 消息。
29pub(crate) struct StampedFeedback {
30    pub(crate) tick: Tick,
31    pub(crate) enqueued_at: Instant,
32}
33
34/// Engine 专用回灌发送端;保持 unbounded 死锁不变量,只增加同元素时间戳。
35#[derive(Clone)]
36pub(crate) struct FeedbackSender {
37    tx: mpsc::UnboundedSender<StampedFeedback>,
38}
39
40/// 创建带时间戳的独立回灌通道,不改变其 unbounded 必达语义。
41pub(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/// 溢出策略:有界队列满时怎么办(每实例固定一种)。
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub enum Overflow {
55    /// 队满 `send().await` 阻塞泵 → 背压传导回 tick_tx(慢而不丢)。Persist / Http(必达) 用。
56    Block,
57    /// 队满 try_send 失败即丢**当前(最新)**条目(warn,泵不阻塞)。HttpFire 用(丢失靠 cursor-gate 重发自愈)。
58    ///
59    /// 语义:`try_send` 满时丢的是**本次**(新来的)请求,不是队列里最旧的那条。
60    /// 在 fire-and-forget 自愈场景下等价(两端都靠 cursor-gate 重发补),取最简实现(try_send-Full-drop)。
61    DropNewest,
62}
63
64/// 一条工作单元:可选回报 corr(None = fire-and-forget,不发 PortReply)+ job 载荷。
65pub struct Job<P> {
66    pub corr: Option<Correlation>,
67    pub payload: P,
68}
69
70/// 为 worker 内部保留真实入队时刻,不把 driver 时间概念泄漏到公开 Job 载荷。
71struct QueuedJob<P> {
72    job: Job<P>,
73    enqueued_at: Option<Instant>,
74}
75
76/// 工作池的低基数观测上下文;所有写入都委托有界 AsyncMetricSink。
77#[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
87/// 通用有界并发原语:有界 mpsc(cap=K) + N 个常驻 worker + 溢出策略。
88pub struct BoundedSpawner<P: Send + 'static> {
89    /// 有界 job 发送端(cap = queue_cap)。
90    tx: mpsc::Sender<QueuedJob<P>>,
91    /// N 个 worker task 句柄,shutdown 时 join 确保积压(必达类)全部落地。
92    joins: Vec<JoinHandle<()>>,
93    /// 溢出策略。
94    overflow: Overflow,
95    /// 与队列共生命周期的 O(1) 指标上下文。
96    metrics: SpawnerMetrics,
97}
98
99impl<P: Send + 'static> BoundedSpawner<P> {
100    /// 构造:concurrency=N 个 worker;queue_cap=K;run = 每条 job 的 I/O 执行体
101    /// (async 闭包,返回 `PortOutcome`,仅在 job.corr=Some 时回报)。
102    ///
103    /// N 个 worker 共享单队列(竞争 recv);Persist 用 N=1 保写序。
104    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    /// 构造带指标的工作池,并在公开 seam 上保持与 `new` 相同的执行语义。
127    #[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    /// 汇合 raw 测试兼容入口与生产 stamped 回灌入口的唯一 worker 构造。
153    #[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        // N worker 共享单队列:Arc<Mutex<rx>>,竞争 recv(公平由 tokio Mutex 保证 FIFO-ish)。
172        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                        // 独立 worker 持续抽水。**锁有意跨 recv().await 持有**:tokio mpsc
182                        // Receiver 是单消费者(recv 要 &mut self),N worker 共享须互斥。空队列时
183                        // 仅一个 worker 持锁 park 在 recv,新 job 唤醒它即出块 drop 锁、下一 worker 接力
184                        // (经 mutex 串行交接,非瞬时 N 名额)。真并发在锁外的 run().await(下),故
185                        // N 路 I/O 不受队列锁串行化影响——死锁安全见 AGENTS.md「Block 模式」节。
186                        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; // 执行 I/O(不持泵锁、不持队列锁)
201                        metrics.on_execution_complete(execution_started, &outcome);
202                        if let Some(corr) = corr {
203                            // unbounded send:不会阻塞,回灌必达(不变量1)。
204                            feedback_tx.send(Tick::PortReply { corr, outcome });
205                        }
206                        // corr=None → fire-and-forget,不回报。
207                    }
208                })
209            })
210            .collect();
211        Self {
212            tx,
213            joins,
214            overflow,
215            metrics,
216        }
217    }
218
219    /// 入队一条 job。
220    /// - Block:`send().await`,满则阻塞泵(背压传导回 tick_tx)。
221    /// - DropNewest:try_send,满则丢**本条(最新)** + warn(泵不阻塞)。
222    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                // 队满阻塞——独立 worker 持续抽水会腾位,泵解开(死锁安全见 struct 文档)。
227                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                // DropNewest:try_send 满时丢本条(最新来的请求),队列里已入队的条目继续执行。
240                // 在 fire-and-forget 自愈场景下(靠 cursor-gate 重发补),此行为等价于任意丢弃策略。
241                // 取最简实现(try_send-Full-drop,丢本条),warn 即可(YAGNI,不引 worker 端 skip)。
242                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    /// graceful drain:drop tx → worker recv None 退出 → join 全部。
263    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    /// 有上限 graceful drain(fix/lifecycle-net-decouple):drop tx → join,全程封顶 `limit`。
276    /// 限内全 join 完成 → `true`;超时 → **abort 残留 worker**(放弃在途 job)+ `false`。
277    ///
278    /// 为何需上限:必达 Http reqwest 单条可卡满网络 timeout(连不上 ~30s),无上限会让
279    /// `helix_destroy` graceful drain 等满。**不丢保证**由乐观态持久化(status=Sending) + 重连重发
280    /// 对账承担,不由「drain 必等满网络」承担——abort 在途 Http 不丢数据(消息仍 status=Sending
281    /// 留库,靠重连兜底)。完整语义见 `engine.rs` 五不变量⑤ + driver AGENTS.md。
282    pub async fn shutdown_with_timeout(self, limit: std::time::Duration) -> bool {
283        let Self {
284            tx, joins, metrics, ..
285        } = self;
286        drop(tx); // 关闭入队端 → worker recv None 后自然退出
287                  // 先留存 abort handle(超时后 abort 残留 worker 用),再消费 JoinHandle 顺序 join。
288        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, // 限内全部 worker 已退出(积压 job 全落地)
298            Err(_) => {
299                // 超时:abort 残留 worker(放弃在途 job,如卡 reqwest timeout 的必达 Http)。
300                for h in abort_handles {
301                    h.abort();
302                }
303                false
304            }
305        }
306    }
307}
308
309impl FeedbackSender {
310    /// 把 PortReply/PortProgress 作为同元素 stamped Tick 回灌给 Engine。
311    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    /// 保持 raw 测试兼容,同时让生产回灌携带真实入队时刻。
323    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    /// 返回稳定的低基数标签,避免调试字符串进入 Prometheus series。
335    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    /// 初始化池级原子计数,并发布固定容量与 worker 数量。
345    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    /// 记录成功入队以及 submit 因容量产生的阻塞时间。
367    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    /// 在 worker 取出任务时闭合真实队列驻留时间并减少 depth。
379    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    /// 标记 worker 进入实际 I/O 执行区间。
391    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    /// 闭合 worker 执行耗时并按 PortOutcome 分类状态。
400    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    /// 记录 DropNewest 明确丢弃,禁止把溢出只留在日志。
422    fn on_drop(&self) {
423        self.counter(MetricId::PoolDroppedTotal);
424    }
425
426    /// 记录关闭后的提交,供生命周期排障使用。
427    fn on_closed(&self) {
428        self.counter(MetricId::PoolClosedTotal);
429    }
430
431    /// 只把真实 panic 计入 worker panic,不把主动 abort 混为故障。
432    fn on_worker_panic(&self) {
433        self.counter(MetricId::PoolJobPanicsTotal);
434    }
435
436    /// 构造固定 pool/overflow 标签集合。
437    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    /// 发布池级 Gauge 快照。
444    fn gauge(&self, id: MetricId, value: usize) {
445        self.record(MetricEvent::gauge(id, value as f64, self.labels()));
446    }
447
448    /// 发布池级 Counter 增量。
449    fn counter(&self, id: MetricId) {
450        self.record(MetricEvent::counter(id, 1.0, self.labels()));
451    }
452
453    /// 发布池级耗时直方图。
454    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    /// 将指标写入有界 sink;禁用时保持纯 no-op。
463    fn record(&self, event: MetricEvent) {
464        if self.enabled {
465            let _ = self.sink.try_record(event);
466        }
467    }
468}
469
470/// 原子饱和递减,避免异常关闭路径把 Gauge 下溢成 usize::MAX。
471fn 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;