1use super::config::SinkPoolConfig;
4use super::worker::{ShardWorker, WorkerReport};
5use super::{EncodedChunk, ShardWriter};
6use crate::backpressure::InflightBudget;
7use crate::error::SinkError;
8use crate::metrics::SinkShardMetrics;
9use std::sync::Arc;
10use std::time::Duration;
11use tokio::sync::{mpsc, watch};
12use tokio::task::JoinHandle;
13use tokio::time::Instant;
14
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
17pub struct DrainReport {
18 pub flushed: u64,
20 pub abandoned: u64,
22}
23
24#[derive(Debug)]
31pub struct SinkPool<W: ShardWriter> {
32 writer: Arc<W>,
33 endpoints: Vec<Arc<Vec<W::Endpoint>>>,
34 workers: Vec<JoinHandle<WorkerReport>>,
35 drain_tx: watch::Sender<Option<Instant>>,
36}
37
38impl<W: ShardWriter> SinkPool<W> {
39 #[must_use]
51 #[expect(
52 clippy::too_many_arguments,
53 reason = "construction-time wiring call, used once by the pipeline runtime"
54 )]
55 pub fn spawn(
56 writer: Arc<W>,
57 shard_endpoints: Vec<Vec<W::Endpoint>>,
58 receivers: Vec<mpsc::Receiver<EncodedChunk>>,
59 config: SinkPoolConfig,
60 budget: Arc<InflightBudget>,
61 metrics: Vec<SinkShardMetrics>,
62 pipeline_name: &str,
63 runtime: &tokio::runtime::Handle,
64 ) -> Self {
65 assert_eq!(
66 shard_endpoints.len(),
67 receivers.len(),
68 "one receiver per shard"
69 );
70 assert_eq!(
71 shard_endpoints.len(),
72 metrics.len(),
73 "one metrics set per shard"
74 );
75 assert!(
76 shard_endpoints.iter().all(|r| !r.is_empty()),
77 "every shard needs at least one replica"
78 );
79
80 let (drain_tx, drain_rx) = watch::channel(None);
81 let endpoints: Vec<Arc<Vec<W::Endpoint>>> =
82 shard_endpoints.into_iter().map(Arc::new).collect();
83
84 let nonce = run_nonce();
85 let workers = receivers
86 .into_iter()
87 .zip(metrics)
88 .enumerate()
89 .map(|(shard, (rx, shard_metrics))| {
90 let worker = ShardWorker {
91 writer: Arc::clone(&writer),
92 endpoints: Arc::clone(&endpoints[shard]),
93 rx,
94 cfg: config,
95 budget: Arc::clone(&budget),
96 metrics: Arc::new(shard_metrics),
97 drain_deadline: drain_rx.clone(),
98 token_prefix: format!("{pipeline_name}-{nonce}-{shard}-"),
99 };
100 runtime.spawn(worker.run())
101 })
102 .collect();
103
104 SinkPool {
105 writer,
106 endpoints,
107 workers,
108 drain_tx,
109 }
110 }
111
112 pub async fn probe_all(&self) -> Result<(), SinkError> {
115 for shard in &self.endpoints {
116 for endpoint in shard.iter() {
117 self.writer.probe(endpoint).await?;
118 }
119 }
120 Ok(())
121 }
122
123 pub async fn drain(self, deadline: Duration) -> DrainReport {
130 let _ = self.drain_tx.send(Some(Instant::now() + deadline));
131 let mut report = WorkerReport::default();
132 for handle in self.workers {
133 match handle.await {
134 Ok(r) => report.absorb(r),
135 Err(join_err) => {
136 tracing::error!(error = %join_err, "sink shard worker panicked");
137 }
138 }
139 }
140 DrainReport {
141 flushed: report.flushed,
142 abandoned: report.abandoned,
143 }
144 }
145}
146
147fn run_nonce() -> String {
159 use std::sync::atomic::{AtomicU64, Ordering};
160 static COUNTER: AtomicU64 = AtomicU64::new(0);
161 let nanos = std::time::SystemTime::now()
162 .duration_since(std::time::UNIX_EPOCH)
163 .map(|d| d.as_nanos() as u64)
164 .unwrap_or(0);
165 let pid = u64::from(std::process::id());
166 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
167 format!("{:x}", nanos ^ (pid << 48) ^ (n << 40))
168}
169
170#[cfg(all(test, not(loom)))]
171mod nonce_tests {
172 use super::run_nonce;
173
174 #[test]
175 fn nonces_differ_within_and_across_calls() {
176 let a = run_nonce();
177 let b = run_nonce();
178 assert_ne!(a, b, "two pools in one process must not share tokens");
179 assert!(!a.is_empty() && a.len() <= 16);
180 }
181}