1use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Arc;
9use std::time::Duration;
10
11use arrow_array::RecordBatch;
12use arrow_schema::SchemaRef;
13use async_trait::async_trait;
14use crossfire::{mpsc, AsyncRx, TryRecvError};
15use tokio::net::TcpListener;
16use tokio::sync::{watch, Notify};
17use tokio::task::JoinHandle;
18use tonic::transport::server::TcpIncoming;
19
20use opentelemetry_proto::tonic::collector::logs::v1::logs_service_server::LogsServiceServer;
21use opentelemetry_proto::tonic::collector::metrics::v1::metrics_service_server::MetricsServiceServer;
22use opentelemetry_proto::tonic::collector::trace::v1::trace_service_server::TraceServiceServer;
23
24use crate::checkpoint::SourceCheckpoint;
25use crate::config::{ConnectorConfig, ConnectorState};
26use crate::connector::{
27 ConnectorTaskOwner, ConnectorTaskTracker, SourceBatch, SourceConnector, SourceConsistency,
28 SourceContract, SourcePosition, SourceStart, SourceTopology,
29};
30use crate::error::ConnectorError;
31
32use super::config::{OtelSignal, OtelSourceConfig};
33use super::schema::{logs_schema, metrics_schema, traces_schema};
34use super::server::OtelReceiver;
35
36const SERVER_CLOSE_TIMEOUT: Duration = Duration::from_secs(5);
37
38struct TrackedServerTask {
39 handle: Option<JoinHandle<Result<(), ConnectorError>>>,
40}
41
42struct TaskExitNotify(Arc<Notify>);
43
44impl Drop for TaskExitNotify {
45 fn drop(&mut self) {
46 self.0.notify_one();
47 }
48}
49
50enum ServerWait {
51 Completed(Result<(), ConnectorError>),
52 TimedOut,
53}
54
55impl TrackedServerTask {
56 fn spawn(
57 owner: &ConnectorTaskOwner,
58 exit_notify: Arc<Notify>,
59 future: impl std::future::Future<Output = Result<(), ConnectorError>> + Send + 'static,
60 ) -> Result<Self, ConnectorError> {
61 let task_guard = owner.track().ok_or_else(|| {
62 ConnectorError::Internal("OTel source task generation is already retired".into())
63 })?;
64 let handle = tokio::spawn(async move {
65 let _task_guard = task_guard;
66 let _exit_notify = TaskExitNotify(exit_notify);
67 future.await
68 });
69 Ok(Self {
70 handle: Some(handle),
71 })
72 }
73
74 async fn wait_until(&mut self, deadline: tokio::time::Instant) -> ServerWait {
75 let Some(handle) = self.handle.as_mut() else {
76 return ServerWait::Completed(Ok(()));
77 };
78 match tokio::time::timeout_at(deadline, handle).await {
79 Ok(_) if tokio::time::Instant::now() >= deadline => {
80 self.handle.take();
81 ServerWait::TimedOut
82 }
83 Ok(Ok(result)) => {
84 self.handle.take();
85 ServerWait::Completed(result)
86 }
87 Ok(Err(error)) => {
88 self.handle.take();
89 ServerWait::Completed(Err(ConnectorError::Internal(format!(
90 "OTel gRPC server task failed: {error}"
91 ))))
92 }
93 Err(_) => ServerWait::TimedOut,
94 }
95 }
96
97 async fn take_finished(&mut self) -> Option<Result<(), ConnectorError>> {
98 let handle = self.handle.as_ref()?;
99 if !handle.is_finished() {
100 return None;
101 }
102 let result = self.handle.take()?.await;
103 Some(result.unwrap_or_else(|error| {
104 Err(ConnectorError::Internal(format!(
105 "OTel gRPC server task failed: {error}"
106 )))
107 }))
108 }
109
110 fn abort(&mut self) {
111 if let Some(handle) = self.handle.take() {
112 handle.abort();
113 }
114 }
115}
116
117pub struct OtelSource {
123 config: OtelSourceConfig,
124 schema: SchemaRef,
125 state: ConnectorState,
126 batch_rx: Option<AsyncRx<mpsc::Array<RecordBatch>>>,
127 data_ready: Arc<Notify>,
128 server_task: Option<TrackedServerTask>,
129 shutdown_tx: Option<watch::Sender<bool>>,
130 records_received: Arc<AtomicU64>,
132 requests_received: Arc<AtomicU64>,
133 checkpoint_seq: u64,
134 server_failure: Option<String>,
135 task_owner: ConnectorTaskOwner,
136 task_tracker: ConnectorTaskTracker,
137}
138
139impl OtelSource {
140 #[must_use]
142 pub fn new(schema: SchemaRef, _registry: Option<&prometheus::Registry>) -> Self {
143 let (task_owner, task_tracker) = ConnectorTaskOwner::new();
144 Self {
145 config: OtelSourceConfig::default(),
146 schema,
147 state: ConnectorState::Created,
148 batch_rx: None,
149 data_ready: Arc::new(Notify::new()),
150 server_task: None,
151 shutdown_tx: None,
152 records_received: Arc::new(AtomicU64::new(0)),
153 requests_received: Arc::new(AtomicU64::new(0)),
154 checkpoint_seq: 0,
155 server_failure: None,
156 task_owner,
157 task_tracker,
158 }
159 }
160
161 fn request_shutdown(&mut self) {
162 if let Some(shutdown) = self.shutdown_tx.take() {
163 shutdown.send_replace(true);
164 }
165 self.batch_rx.take();
166 }
167
168 async fn observe_server_exit(&mut self) {
169 if self.server_failure.is_some() || self.state != ConnectorState::Running {
170 return;
171 }
172 let Some(server) = self.server_task.as_mut() else {
173 return;
174 };
175 let Some(result) = server.take_finished().await else {
176 return;
177 };
178 self.server_task.take();
179 self.server_failure = Some(match result {
180 Ok(()) => "OTel gRPC server stopped unexpectedly".into(),
181 Err(ConnectorError::ConnectionFailed(message) | ConnectorError::Internal(message)) => {
182 message
183 }
184 Err(error) => error.to_string(),
185 });
186 self.state = ConnectorState::Failed;
187 }
188
189 fn terminal_server_error(message: &str) -> ConnectorError {
190 ConnectorError::InvalidState {
191 expected: "live OTLP gRPC server".into(),
192 actual: format!("server generation terminated: {message}"),
193 }
194 }
195}
196
197impl Drop for OtelSource {
198 fn drop(&mut self) {
199 self.request_shutdown();
200 if let Some(server) = self.server_task.as_mut() {
201 server.abort();
202 }
203 }
204}
205
206#[async_trait]
207impl SourceConnector for OtelSource {
208 fn terminal_task_tracker(&self) -> Option<ConnectorTaskTracker> {
209 Some(self.task_tracker.clone())
210 }
211
212 async fn start(&mut self, request: SourceStart) -> Result<(), ConnectorError> {
213 let (config, position, _) = request.into_parts();
214 if let SourcePosition::Resume { attempt, .. } = position {
215 return Err(ConnectorError::ConfigurationError(format!(
216 "OTLP is an ephemeral source and cannot resume checkpoint attempt {attempt:?}"
217 )));
218 }
219 if !matches!(self.state, ConnectorState::Created | ConnectorState::Closed)
220 || self.server_task.is_some()
221 {
222 return Err(ConnectorError::InvalidState {
223 expected: "Created or fully closed".into(),
224 actual: format!("{}", self.state),
225 });
226 }
227
228 let candidate_config = OtelSourceConfig::from_config(&config)?;
229
230 let candidate_schema = match candidate_config.signals {
231 OtelSignal::Traces => traces_schema(),
232 OtelSignal::Metrics => metrics_schema(),
233 OtelSignal::Logs => logs_schema(),
234 };
235
236 let (batch_tx, batch_rx) =
237 mpsc::bounded_async::<RecordBatch>(candidate_config.channel_capacity);
238
239 let addr = candidate_config.socket_addr();
240
241 let listener = TcpListener::bind(&addr)
244 .await
245 .map_err(|e| ConnectorError::ConnectionFailed(format!("failed to bind {addr}: {e}")))?;
246 let incoming = TcpIncoming::from(listener).with_nodelay(Some(true));
247
248 let (shutdown_tx, shutdown_rx) = watch::channel(false);
249 let service_guard = self.task_owner.track().ok_or_else(|| {
250 ConnectorError::Internal("OTel source task generation is already retired".into())
251 })?;
252
253 let receiver = OtelReceiver::new(
254 batch_tx,
255 Arc::clone(&candidate_schema),
256 Arc::clone(&self.data_ready),
257 Arc::clone(&self.records_received),
258 Arc::clone(&self.requests_received),
259 candidate_config.batch_size,
260 service_guard,
261 );
262
263 let server_task = match candidate_config.signals {
265 OtelSignal::Traces => spawn_grpc_server(
266 &self.task_owner,
267 TraceServiceServer::new(receiver),
268 incoming,
269 shutdown_rx,
270 Arc::clone(&self.data_ready),
271 ),
272 OtelSignal::Metrics => spawn_grpc_server(
273 &self.task_owner,
274 MetricsServiceServer::new(receiver),
275 incoming,
276 shutdown_rx,
277 Arc::clone(&self.data_ready),
278 ),
279 OtelSignal::Logs => spawn_grpc_server(
280 &self.task_owner,
281 LogsServiceServer::new(receiver),
282 incoming,
283 shutdown_rx,
284 Arc::clone(&self.data_ready),
285 ),
286 }?;
287
288 self.config = candidate_config;
289 self.schema = candidate_schema;
290 self.batch_rx = Some(batch_rx);
291 self.shutdown_tx = Some(shutdown_tx);
292 self.server_task = Some(server_task);
293 self.server_failure = None;
294 self.state = ConnectorState::Running;
295
296 tracing::info!(
297 %addr,
298 signals = ?self.config.signals,
299 batch_size = self.config.batch_size,
300 "OTel source connector started"
301 );
302
303 Ok(())
304 }
305
306 async fn poll_batch(
307 &mut self,
308 max_records: usize,
309 ) -> Result<Option<SourceBatch>, ConnectorError> {
310 if let Some(error) = &self.server_failure {
311 return Err(Self::terminal_server_error(error));
312 }
313 let rx = self.batch_rx.as_ref().ok_or(ConnectorError::InvalidState {
314 expected: "Running".into(),
315 actual: format!("{}", self.state),
316 })?;
317
318 let mut total_rows = 0usize;
319 let mut batches: Vec<RecordBatch> = Vec::new();
320 let mut disconnected = false;
321
322 loop {
323 match rx.try_recv() {
324 Ok(batch) => {
325 total_rows += batch.num_rows();
326 batches.push(batch);
327 if total_rows >= max_records {
328 break;
329 }
330 }
331 Err(TryRecvError::Empty) => break,
332 Err(TryRecvError::Disconnected) => {
333 disconnected = true;
334 break;
335 }
336 }
337 }
338
339 self.observe_server_exit().await;
340
341 if batches.is_empty() {
342 return if let Some(error) = &self.server_failure {
343 Err(Self::terminal_server_error(error))
344 } else if disconnected {
345 self.state = ConnectorState::Closed;
346 Err(ConnectorError::Closed)
347 } else {
348 Ok(None)
349 };
350 }
351
352 self.checkpoint_seq += 1;
353
354 if batches.len() == 1 {
355 return Ok(Some(SourceBatch::new(batches.into_iter().next().unwrap())));
356 }
357
358 let schema = batches[0].schema();
359 let combined =
360 arrow_select::concat::concat_batches(&schema, batches.iter()).map_err(|e| {
361 ConnectorError::ReadError(format!("failed to concatenate OTel batches: {e}"))
362 })?;
363
364 Ok(Some(SourceBatch::new(combined)))
365 }
366
367 async fn discover_schema(
368 &mut self,
369 properties: &std::collections::HashMap<String, String>,
370 ) -> Result<(), ConnectorError> {
371 let Some(sig) = properties
372 .get("signals")
373 .or_else(|| properties.get("signal"))
374 else {
375 return Ok(());
376 };
377 let signal = OtelSignal::parse(sig).map_err(|e| {
378 ConnectorError::ConfigurationError(format!("invalid OTel signal '{sig}': {e}"))
379 })?;
380 self.schema = match signal {
381 OtelSignal::Traces => traces_schema(),
382 OtelSignal::Metrics => metrics_schema(),
383 OtelSignal::Logs => logs_schema(),
384 };
385 Ok(())
386 }
387
388 fn schema(&self) -> SchemaRef {
389 Arc::clone(&self.schema)
390 }
391
392 fn checkpoint(&self) -> SourceCheckpoint {
393 let mut cp = SourceCheckpoint::new();
394 cp.set_offset("batch_sequence", self.checkpoint_seq.to_string());
395 cp.set_offset(
396 "records_received",
397 self.records_received.load(Ordering::Relaxed).to_string(),
398 );
399 cp.set_offset(
400 "requests_received",
401 self.requests_received.load(Ordering::Relaxed).to_string(),
402 );
403 cp.set_metadata("connector", "otel");
404 cp.set_metadata("signals", format!("{:?}", self.config.signals));
405 cp
406 }
407
408 async fn close(&mut self) -> Result<(), ConnectorError> {
409 tracing::info!("OTel source connector shutting down");
410
411 self.request_shutdown();
412
413 let mut completed = false;
414 let mut close_error = self
415 .server_failure
416 .as_ref()
417 .map(|error| Self::terminal_server_error(error));
418 if let Some(server) = self.server_task.as_mut() {
419 let deadline = tokio::time::Instant::now() + SERVER_CLOSE_TIMEOUT;
420 match server.wait_until(deadline).await {
421 ServerWait::Completed(Ok(())) => completed = true,
422 ServerWait::Completed(Err(error)) => {
423 completed = true;
424 tracing::warn!(%error, "OTel gRPC server task failed while closing");
425 close_error = Some(error);
426 }
427 ServerWait::TimedOut => {
428 server.abort();
429 completed = true;
430 close_error = Some(ConnectorError::Internal(
431 "OTel gRPC server exceeded its close deadline; connector generation retired"
432 .into(),
433 ));
434 tracing::warn!("OTel gRPC server exceeded its close deadline and was aborted");
435 }
436 }
437 }
438 if completed {
439 self.server_task.take();
440 }
441
442 if let Some(error) = close_error {
443 self.state = ConnectorState::Failed;
444 Err(error)
445 } else {
446 self.state = ConnectorState::Closed;
447 Ok(())
448 }
449 }
450
451 fn data_ready_notify(&self) -> Option<Arc<Notify>> {
452 Some(Arc::clone(&self.data_ready))
453 }
454
455 fn contract(&self, _config: &ConnectorConfig) -> Result<SourceContract, ConnectorError> {
456 Ok(SourceContract::new(
457 SourceConsistency::Ephemeral,
458 SourceTopology::NodeLocalIngress,
459 ))
460 }
461}
462
463fn spawn_grpc_server<S>(
465 owner: &ConnectorTaskOwner,
466 svc: S,
467 incoming: TcpIncoming,
468 mut shutdown_rx: watch::Receiver<bool>,
469 data_ready: Arc<Notify>,
470) -> Result<TrackedServerTask, ConnectorError>
471where
472 S: tonic::codegen::Service<
473 tonic::codegen::http::Request<tonic::body::Body>,
474 Response = tonic::codegen::http::Response<tonic::body::Body>,
475 Error = std::convert::Infallible,
476 > + tonic::server::NamedService
477 + Clone
478 + Send
479 + Sync
480 + 'static,
481 S::Future: Send + 'static,
482{
483 TrackedServerTask::spawn(owner, data_ready, async move {
484 tonic::transport::Server::builder()
485 .add_service(svc)
486 .serve_with_incoming_shutdown(incoming, async move {
487 let _ = shutdown_rx.wait_for(|&v| v).await;
488 })
489 .await
490 .map_err(|error| {
491 tracing::error!(%error, "OTel gRPC server exited with error");
492 ConnectorError::ConnectionFailed(format!(
493 "OTel gRPC server exited with error: {error}"
494 ))
495 })
496 })
497}
498
499impl std::fmt::Debug for OtelSource {
500 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
501 f.debug_struct("OtelSource")
502 .field("state", &self.state)
503 .field("config", &self.config)
504 .field(
505 "records_received",
506 &self.records_received.load(Ordering::Relaxed),
507 )
508 .field(
509 "requests_received",
510 &self.requests_received.load(Ordering::Relaxed),
511 )
512 .field("checkpoint_seq", &self.checkpoint_seq)
513 .field(
514 "server_running",
515 &self
516 .server_task
517 .as_ref()
518 .and_then(|task| task.handle.as_ref().map(|handle| !handle.is_finished())),
519 )
520 .field("has_shutdown_tx", &self.shutdown_tx.is_some())
521 .finish_non_exhaustive()
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528 use crate::connector::DeliveryGuarantee;
529
530 fn start_request() -> SourceStart {
531 let mut config = ConnectorConfig::new("otel");
532 config.set("bind.address", "127.0.0.1");
533 config.set("port", "0");
534 SourceStart::new(
535 config,
536 SourcePosition::Initial,
537 DeliveryGuarantee::BestEffort,
538 )
539 .unwrap()
540 }
541
542 #[tokio::test]
543 async fn clean_close_can_restart_the_same_tracked_generation() {
544 let mut source = OtelSource::new(traces_schema(), None);
545 let terminal = source.terminal_task_tracker().unwrap();
546
547 source.start(start_request()).await.unwrap();
548 assert!(source.server_task.is_some());
549 source.close().await.unwrap();
550 assert!(source.server_task.is_none());
551 assert!(!terminal.is_terminated(), "the generation owner is live");
552
553 source.start(start_request()).await.unwrap();
554 assert!(source.server_task.is_some());
555 drop(source);
556
557 tokio::time::timeout(Duration::from_secs(2), terminal.wait_terminated())
558 .await
559 .expect("restarted OTel server generation did not terminate");
560 }
561
562 #[tokio::test]
563 async fn aborted_close_waiter_aborts_the_owned_server_task() {
564 let mut source = OtelSource::new(traces_schema(), None);
565 let terminal = source.terminal_task_tracker().unwrap();
566 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
567 let (release_tx, release_rx) = tokio::sync::oneshot::channel();
568 source.server_task = Some(
569 TrackedServerTask::spawn(
570 &source.task_owner,
571 Arc::clone(&source.data_ready),
572 async move {
573 let _ = started_tx.send(());
574 let _ = release_rx.await;
575 Ok(())
576 },
577 )
578 .unwrap(),
579 );
580 let (shutdown_tx, shutdown_rx) = watch::channel(false);
581 source.shutdown_tx = Some(shutdown_tx);
582 source.state = ConnectorState::Running;
583 started_rx.await.expect("test server task started");
584
585 let close = tokio::spawn(async move { source.close().await });
586 tokio::task::yield_now().await;
587 assert!(!close.is_finished(), "close must be joining the server");
588 close.abort();
589 assert!(
590 close
591 .await
592 .expect_err("close waiter cancelled")
593 .is_cancelled(),
594 "close waiter must be cancelled"
595 );
596
597 assert!(*shutdown_rx.borrow(), "close must publish shutdown");
598 tokio::time::timeout(Duration::from_secs(2), terminal.wait_terminated())
599 .await
600 .expect("cancelled close left the server generation live");
601 assert!(
602 release_tx.send(()).is_err(),
603 "the aborted server must drop its test receiver"
604 );
605 }
606
607 #[tokio::test(start_paused = true)]
608 async fn close_timeout_aborts_the_server_task() {
609 let mut source = OtelSource::new(traces_schema(), None);
610 let terminal = source.terminal_task_tracker().unwrap();
611 source.server_task = Some(
612 TrackedServerTask::spawn(
613 &source.task_owner,
614 Arc::clone(&source.data_ready),
615 std::future::pending::<Result<(), ConnectorError>>(),
616 )
617 .unwrap(),
618 );
619 let (shutdown_tx, shutdown_rx) = watch::channel(false);
620 source.shutdown_tx = Some(shutdown_tx);
621 source.state = ConnectorState::Running;
622
623 let error = source.close().await.unwrap_err();
624
625 assert!(*shutdown_rx.borrow(), "close must publish shutdown");
626 assert!(error.to_string().contains("close deadline"), "{error}");
627 assert_eq!(source.state, ConnectorState::Failed);
628 assert!(
629 source.server_task.is_none(),
630 "the aborted task must not block restart"
631 );
632 assert!(matches!(
633 source.start(start_request()).await,
634 Err(ConnectorError::InvalidState { .. })
635 ));
636 drop(source);
637 tokio::time::timeout(Duration::from_secs(1), terminal.wait_terminated())
638 .await
639 .expect("aborted OTel server generation did not terminate");
640 }
641
642 #[tokio::test]
643 async fn late_server_completion_is_a_close_timeout() {
644 let owner = ConnectorTaskOwner::new().0;
645 let mut task = TrackedServerTask::spawn(&owner, Arc::new(Notify::new()), async {
646 std::thread::sleep(Duration::from_millis(25));
647 Ok(())
648 })
649 .unwrap();
650
651 let result = task
652 .wait_until(tokio::time::Instant::now() + Duration::from_millis(5))
653 .await;
654
655 assert!(matches!(result, ServerWait::TimedOut));
656 assert!(task.handle.is_none());
657 }
658
659 #[tokio::test]
660 async fn unexpected_server_exit_fails_live_polling() {
661 let mut source = OtelSource::new(traces_schema(), None);
662 let (_batch_tx, batch_rx) = mpsc::bounded_async::<RecordBatch>(1);
663 source.batch_rx = Some(batch_rx);
664 source.server_task = Some(
665 TrackedServerTask::spawn(&source.task_owner, Arc::clone(&source.data_ready), async {
666 Err(ConnectorError::ConnectionFailed(
667 "accept loop failed".into(),
668 ))
669 })
670 .unwrap(),
671 );
672 source.state = ConnectorState::Running;
673 tokio::time::timeout(Duration::from_secs(1), async {
674 while !source
675 .server_task
676 .as_ref()
677 .and_then(|task| task.handle.as_ref())
678 .is_some_and(tokio::task::JoinHandle::is_finished)
679 {
680 tokio::task::yield_now().await;
681 }
682 })
683 .await
684 .expect("test server failure did not become observable");
685 tokio::time::timeout(Duration::from_secs(1), source.data_ready.notified())
686 .await
687 .expect("terminal server exit did not wake source polling");
688
689 let error = source.poll_batch(1).await.unwrap_err();
690
691 assert!(error.to_string().contains("accept loop failed"), "{error}");
692 assert!(
693 !error.is_transient(),
694 "terminal generation errors must not retry"
695 );
696 assert_eq!(source.state, ConnectorState::Failed);
697 assert!(source.server_task.is_none());
698 }
699}