Skip to main content

laminar_connectors/nats/sink/
mod.rs

1//! NATS sink. Core publishes are fire-and-forget; `JetStream` collects
2//! `PublishAckFuture`s and drains them in `flush`.
3//! Optional server-side `Nats-Msg-Id` dedup suppresses replay duplicates within
4//! the broker window, but is not a coordinated checkpoint commit protocol.
5
6use std::collections::VecDeque;
7use std::future::IntoFuture;
8use std::str::FromStr;
9use std::time::Duration;
10
11use arrow_array::{cast::AsArray, Array, RecordBatch, StringArray};
12use arrow_schema::SchemaRef;
13use async_nats::jetstream::{self, context::PublishAckFuture};
14use async_nats::{Client, HeaderMap, HeaderName, HeaderValue, Subject};
15use async_trait::async_trait;
16use futures_util::stream::{FuturesUnordered, StreamExt};
17
18use super::config::{build_connect_options, Mode, NatsSinkConfig, SubjectSpec};
19use super::metrics::NatsSinkMetrics;
20use super::setup::{classify_connect_error, classify_get_stream_error, track_connection_tasks};
21use crate::config::ConnectorConfig;
22use crate::connector::{
23    ConnectorTaskOwner, ConnectorTaskTracker, SinkConnector, SinkConsistency, SinkContract,
24    SinkInputMode, SinkTopology, WriteResult,
25};
26use crate::error::ConnectorError;
27use crate::serde::{self, RecordSerializer};
28
29mod message;
30
31#[cfg(test)]
32use message::encoded_header_len;
33use message::{
34    build_headers, operation_has_prior_output, resolve_utf8, validate_headers_and_encoded_len,
35    validate_message_size, validate_non_null, validate_publish_subjects,
36};
37
38/// A single replica survives a process restart when file-backed, but not a server/node loss.
39/// Three is the smallest `JetStream` quorum that preserves availability and one-fault durability.
40const MIN_DURABLE_REPLICAS: usize = 3;
41
42fn validate_durable_stream(
43    stream_name: &str,
44    config: &jetstream::stream::Config,
45) -> Result<(), ConnectorError> {
46    if config.storage != jetstream::stream::StorageType::File {
47        return Err(err(&format!(
48            "[LDB-5072] JetStream '{stream_name}' uses {:?} storage; durable at-least-once \
49             requires file-backed storage",
50            config.storage
51        )));
52    }
53    if config.num_replicas < MIN_DURABLE_REPLICAS {
54        return Err(err(&format!(
55            "[LDB-5073] JetStream '{stream_name}' has {} replica(s); production durable \
56             at-least-once requires at least {MIN_DURABLE_REPLICAS}",
57            config.num_replicas
58        )));
59    }
60    if config.no_ack {
61        return Err(err(&format!(
62            "[LDB-5074] JetStream '{stream_name}' disables publish acknowledgements; durable \
63             at-least-once requires broker acknowledgements"
64        )));
65    }
66    Ok(())
67}
68
69/// NATS sink — core and `JetStream` modes.
70pub struct NatsSink {
71    schema: SchemaRef,
72    config: Option<NatsSinkConfig>,
73    serializer: Option<Box<dyn RecordSerializer>>,
74    runtime: Option<Runtime>,
75    metrics: NatsSinkMetrics,
76    /// Drained in `flush`. A negative/ambiguous acknowledgement consumes its future, so every
77    /// drain error must propagate to the owning sink task's sticky epoch fence.
78    pending_acks: VecDeque<PublishAckFuture>,
79    /// Core publish only proves enqueue into the client. Keep that uncertainty across batches
80    /// until the client transport has flushed its write buffer.
81    core_dirty: bool,
82    task_owner: ConnectorTaskOwner,
83    task_tracker: ConnectorTaskTracker,
84}
85
86enum Runtime {
87    Core { client: Client },
88    JetStream { context: jetstream::Context },
89}
90
91async fn bounded_nats_setup_until<T, E>(
92    deadline: tokio::time::Instant,
93    total_timeout: Duration,
94    future: impl std::future::Future<Output = Result<T, E>>,
95    classify: impl FnOnce(E) -> ConnectorError,
96) -> Result<T, ConnectorError> {
97    match tokio::time::timeout_at(deadline, future).await {
98        Ok(Ok(value)) => Ok(value),
99        Ok(Err(error)) => Err(classify(error)),
100        Err(_) => Err(ConnectorError::Timeout(timeout_millis(total_timeout))),
101    }
102}
103
104#[derive(Debug)]
105enum PublishEnqueueFailure<E> {
106    Client(E),
107    TimedOut(Duration),
108}
109
110async fn bounded_publish_enqueue<T, E>(
111    deadline: tokio::time::Instant,
112    total_timeout: Duration,
113    future: impl std::future::Future<Output = Result<T, E>>,
114) -> Result<T, PublishEnqueueFailure<E>> {
115    match tokio::time::timeout_at(deadline, future).await {
116        Ok(Ok(value)) => Ok(value),
117        Ok(Err(error)) => Err(PublishEnqueueFailure::Client(error)),
118        Err(_) => Err(PublishEnqueueFailure::TimedOut(total_timeout)),
119    }
120}
121
122fn known_not_dispatched_error(
123    operation: &str,
124    detail: impl std::fmt::Display,
125    retryable: bool,
126    prior_output: bool,
127) -> ConnectorError {
128    if prior_output {
129        ConnectorError::outcome_unknown(
130            format!(
131                "{operation} did not dispatch the current message, but prior NATS output may \
132                 already have been applied: {detail}"
133            ),
134            retryable,
135        )
136    } else if retryable {
137        ConnectorError::WriteError(format!(
138            "{operation} did not dispatch the message: {detail}"
139        ))
140    } else {
141        ConnectorError::ConfigurationError(format!(
142            "{operation} rejected the message before dispatch: {detail}"
143        ))
144    }
145}
146
147fn preserve_prior_applied(error: ConnectorError, applied: usize) -> ConnectorError {
148    if applied == 0 || error.is_outcome_unknown() {
149        error
150    } else {
151        let retryable = error.is_transient();
152        ConnectorError::outcome_unknown(
153            format!(
154                "JetStream acknowledgement failed after {applied} earlier publish(es) were \
155                 acknowledged: {error}"
156            ),
157            retryable,
158        )
159    }
160}
161
162fn classify_core_publish_failure(
163    failure: PublishEnqueueFailure<async_nats::PublishError>,
164    prior_output: bool,
165) -> ConnectorError {
166    let (detail, retryable) = match failure {
167        PublishEnqueueFailure::Client(error) => {
168            let retryable = matches!(error.kind(), async_nats::client::PublishErrorKind::Send);
169            (error.to_string(), retryable)
170        }
171        PublishEnqueueFailure::TimedOut(timeout) => (
172            format!(
173                "client enqueue timed out after {}ms",
174                timeout_millis(timeout)
175            ),
176            true,
177        ),
178    };
179    known_not_dispatched_error("NATS core publish", detail, retryable, prior_output)
180}
181
182fn classify_jetstream_enqueue_failure(
183    failure: PublishEnqueueFailure<jetstream::context::PublishError>,
184    prior_output: bool,
185) -> ConnectorError {
186    let (detail, retryable) = match failure {
187        PublishEnqueueFailure::Client(error) => {
188            use jetstream::context::PublishErrorKind;
189
190            let invalid_subject = std::error::Error::source(&error)
191                .is_some_and(<dyn std::error::Error>::is::<async_nats::SubjectError>);
192            let retryable = match error.kind() {
193                PublishErrorKind::StreamNotFound
194                | PublishErrorKind::WrongLastMessageId
195                | PublishErrorKind::WrongLastSequence => false,
196                PublishErrorKind::Other if invalid_subject => false,
197                PublishErrorKind::TimedOut
198                | PublishErrorKind::BrokenPipe
199                | PublishErrorKind::MaxAckPending
200                | PublishErrorKind::Other => true,
201            };
202            (error.to_string(), retryable)
203        }
204        PublishEnqueueFailure::TimedOut(timeout) => (
205            format!(
206                "client enqueue timed out after {}ms",
207                timeout_millis(timeout)
208            ),
209            true,
210        ),
211    };
212    known_not_dispatched_error("NATS JetStream publish", detail, retryable, prior_output)
213}
214
215fn timeout_millis(timeout: Duration) -> u64 {
216    u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX)
217}
218
219async fn flush_core(
220    client: &Client,
221    timeout: Duration,
222    operation: &'static str,
223) -> Result<(), ConnectorError> {
224    match tokio::time::timeout(timeout, client.flush()).await {
225        Ok(Ok(())) => Ok(()),
226        Ok(Err(error)) => Err(ConnectorError::outcome_unknown(
227            format!(
228                "{operation} failed after Core publishes were enqueued; whether their bytes \
229                 reached the transport is unknown: {error}"
230            ),
231            true,
232        )),
233        Err(_) => Err(ConnectorError::outcome_unknown(
234            format!(
235                "{operation} timed out after {}ms with Core publishes still buffered or in flight",
236                timeout_millis(timeout)
237            ),
238            true,
239        )),
240    }
241}
242
243#[derive(Clone, Copy, Debug, PartialEq, Eq)]
244enum AckCertainty {
245    Rejected,
246    OutcomeUnknown,
247}
248
249#[derive(Debug)]
250struct AckFailure {
251    certainty: AckCertainty,
252    detail: String,
253    retryable: bool,
254}
255
256fn classify_jetstream_ack_failure(error: &jetstream::context::PublishError) -> AckFailure {
257    use jetstream::context::PublishErrorKind;
258
259    let source = std::error::Error::source(error);
260    let (certainty, retryable) = match error.kind() {
261        PublishErrorKind::StreamNotFound
262        | PublishErrorKind::WrongLastMessageId
263        | PublishErrorKind::WrongLastSequence => (AckCertainty::Rejected, false),
264        PublishErrorKind::TimedOut | PublishErrorKind::BrokenPipe => {
265            (AckCertainty::OutcomeUnknown, true)
266        }
267        PublishErrorKind::Other => {
268            if let Some(broker_error) =
269                source.and_then(|source| source.downcast_ref::<jetstream::Error>())
270            {
271                let retryable = matches!(broker_error.code(), 408 | 429 | 500..=599);
272                if retryable {
273                    (AckCertainty::OutcomeUnknown, true)
274                } else {
275                    (AckCertainty::Rejected, false)
276                }
277            } else if source.is_some_and(<dyn std::error::Error>::is::<serde_json::Error>) {
278                // A response arrived, but an invalid ack cannot prove whether persistence happened.
279                (AckCertainty::OutcomeUnknown, false)
280            } else {
281                // `PublishAckFuture` currently reports `Other` only with a broker error or a
282                // malformed acknowledgement as its source. An untyped fallback is a client
283                // contract violation, not a reason to retry forever.
284                (AckCertainty::OutcomeUnknown, false)
285            }
286        }
287        // This kind belongs to pre-dispatch semaphore acquisition. Seeing it on an ack future
288        // would violate the client contract, so fail closed rather than claim a rejection.
289        PublishErrorKind::MaxAckPending => (AckCertainty::OutcomeUnknown, false),
290    };
291    AckFailure {
292        certainty,
293        detail: error.to_string(),
294        retryable,
295    }
296}
297
298struct AckAggregate {
299    total: usize,
300    applied: usize,
301    rejected: usize,
302    ambiguous: usize,
303    first_error: Option<String>,
304    retryable: bool,
305}
306
307impl Default for AckAggregate {
308    fn default() -> Self {
309        Self {
310            total: 0,
311            applied: 0,
312            rejected: 0,
313            ambiguous: 0,
314            first_error: None,
315            retryable: true,
316        }
317    }
318}
319
320impl AckAggregate {
321    fn record_applied(&mut self) {
322        self.total += 1;
323        self.applied += 1;
324    }
325
326    fn record_failure(&mut self, failure: AckFailure) {
327        self.total += 1;
328        self.retryable &= failure.retryable;
329        match failure.certainty {
330            AckCertainty::Rejected => self.rejected += 1,
331            AckCertainty::OutcomeUnknown => self.ambiguous += 1,
332        }
333        self.first_error.get_or_insert(failure.detail);
334    }
335
336    fn record_unresolved_timeout(&mut self, count: usize, timeout: Duration) {
337        self.total += count;
338        self.ambiguous += count;
339        self.first_error.get_or_insert_with(|| {
340            format!(
341                "{count} acknowledgement(s) remained in flight after {}ms",
342                timeout_millis(timeout)
343            )
344        });
345    }
346
347    fn into_result(self) -> Result<usize, ConnectorError> {
348        if self.rejected == 0 && self.ambiguous == 0 {
349            return Ok(self.applied);
350        }
351        let detail = format!(
352            "{} rejected, {} outcome unknown, {} acknowledged out of {}; first error: {}",
353            self.rejected,
354            self.ambiguous,
355            self.applied,
356            self.total,
357            self.first_error.unwrap_or_else(|| "unknown".into())
358        );
359        if self.ambiguous > 0 || self.applied > 0 {
360            Err(ConnectorError::outcome_unknown(
361                format!("JetStream acknowledgement drain was not atomic: {detail}"),
362                self.retryable,
363            ))
364        } else if self.retryable {
365            Err(ConnectorError::WriteError(format!(
366                "JetStream rejected every publish acknowledgement: {detail}"
367            )))
368        } else {
369            Err(ConnectorError::ConfigurationError(format!(
370                "JetStream rejected every publish acknowledgement: {detail}"
371            )))
372        }
373    }
374}
375
376impl NatsSink {
377    /// Metrics register on `registry` if provided.
378    #[must_use]
379    pub fn new(schema: SchemaRef, registry: Option<&prometheus::Registry>) -> Self {
380        let (task_owner, task_tracker) = ConnectorTaskOwner::new();
381        Self {
382            schema,
383            config: None,
384            serializer: None,
385            runtime: None,
386            metrics: NatsSinkMetrics::new(registry),
387            pending_acks: VecDeque::new(),
388            core_dirty: false,
389            task_owner,
390            task_tracker,
391        }
392    }
393
394    /// Available after [`SinkConnector::open`].
395    #[must_use]
396    pub fn config(&self) -> Option<&NatsSinkConfig> {
397        self.config.as_ref()
398    }
399
400    /// Snapshot accessor for the prometheus-backed metrics struct.
401    #[must_use]
402    pub fn metrics_handle(&self) -> &NatsSinkMetrics {
403        &self.metrics
404    }
405}
406
407impl Drop for NatsSink {
408    fn drop(&mut self) {
409        retire_pending_acks(&mut self.pending_acks, &self.metrics, &self.task_owner);
410    }
411}
412
413// `async_trait` reports this cohesive lifecycle implementation as one generated function.
414#[async_trait]
415impl SinkConnector for NatsSink {
416    fn terminal_task_tracker(&self) -> Option<ConnectorTaskTracker> {
417        Some(self.task_tracker.clone())
418    }
419
420    fn contract(&self, config: &ConnectorConfig) -> Result<SinkContract, ConnectorError> {
421        let cfg = NatsSinkConfig::from_config(config)?;
422        let consistency = match cfg.mode {
423            Mode::Core => SinkConsistency::Ephemeral,
424            Mode::JetStream => SinkConsistency::DurableAtLeastOnce,
425        };
426        Ok(SinkContract::new(
427            consistency,
428            SinkTopology::MultiWriter,
429            SinkInputMode::AppendOnly,
430        ))
431    }
432
433    async fn open(&mut self, config: &ConnectorConfig) -> Result<(), ConnectorError> {
434        if self.core_dirty {
435            return Err(ConnectorError::InvalidState {
436                expected: "a new sink generation after an unresolved Core flush".into(),
437                actual: "this sink still has unconfirmed Core publishes".into(),
438            });
439        }
440        let cfg = NatsSinkConfig::from_config(config)?;
441        self.serializer = Some(
442            serde::create_serializer(cfg.format)
443                .map_err(|e| err(&format!("serializer for format {:?}: {e}", cfg.format)))?,
444        );
445        let setup_timeout = cfg.ack_timeout;
446        let setup_deadline = tokio::time::Instant::now() + setup_timeout;
447        let connect_options = track_connection_tasks(
448            build_connect_options(&cfg.auth, &cfg.tls)?,
449            &self.task_owner,
450            "sink",
451        )?;
452        let client = bounded_nats_setup_until(
453            setup_deadline,
454            setup_timeout,
455            Box::pin(connect_options.connect(&cfg.servers)),
456            |error| classify_connect_error(&error),
457        )
458        .await?;
459        self.runtime = Some(match cfg.mode {
460            Mode::Core => Runtime::Core { client },
461            Mode::JetStream => {
462                // Match the connector's retained-ack bound to the client's semaphore and fail
463                // immediately if the invariant is ever violated. Waiting for a permit while this
464                // connector owns every outstanding ack would deadlock the sink actor.
465                let context = jetstream::context::ContextBuilder::new()
466                    .timeout(cfg.ack_timeout)
467                    .ack_timeout(cfg.ack_timeout)
468                    .max_ack_inflight(cfg.max_pending)
469                    .backpressure_on_inflight(false)
470                    .build(client);
471                let stream_name = cfg
472                    .stream
473                    .as_deref()
474                    .expect("NatsSinkConfig validates the JetStream target");
475                let stream = bounded_nats_setup_until(
476                    setup_deadline,
477                    setup_timeout,
478                    context.get_stream(stream_name),
479                    |error| classify_get_stream_error(&error, stream_name),
480                )
481                .await?;
482                let info = stream.cached_info();
483                validate_durable_stream(stream_name, &info.config)?;
484                if cfg.dedup_id_column.is_some() {
485                    let actual = info.config.duplicate_window;
486                    if actual < cfg.min_duplicate_window {
487                        return Err(err(&format!(
488                            "[LDB-5056] stream '{stream_name}' has duplicate_window={actual:?}, \
489                             below the configured minimum {:?}. Replay or redelivery could land \
490                             outside the dedup horizon. Reconfigure the stream or lower \
491                             'min.duplicate.window.ms'.",
492                            cfg.min_duplicate_window,
493                        )));
494                    }
495                }
496                Runtime::JetStream { context }
497            }
498        });
499        self.config = Some(cfg);
500        Ok(())
501    }
502
503    async fn write_batch(&mut self, batch: &RecordBatch) -> Result<WriteResult, ConnectorError> {
504        // Split-borrow: `runtime` &mut, config/serializer &.
505        let Self {
506            config,
507            serializer,
508            runtime,
509            pending_acks,
510            core_dirty,
511            metrics,
512            task_owner,
513            task_tracker: _,
514            schema: _,
515        } = self;
516        let cfg = config.as_ref().ok_or_else(|| err("sink: open() first"))?;
517        // The runtime advertises this exact bound for the complete call. Every enqueue and
518        // acknowledgement wait shares it; row count cannot multiply the configured timeout.
519        let write_timeout = cfg.ack_timeout;
520        let write_deadline = tokio::time::Instant::now() + write_timeout;
521        let ser = serializer
522            .as_ref()
523            .ok_or_else(|| err("sink: open() first"))?;
524        let rt = runtime.as_mut().ok_or_else(|| err("sink: open() first"))?;
525
526        let subject_col = match &cfg.subject {
527            SubjectSpec::Column(name) => Some(resolve_utf8(batch, name)?),
528            SubjectSpec::Literal(_) => None,
529        };
530        let header_cols: Vec<(&HeaderName, &StringArray)> = cfg
531            .header_columns
532            .iter()
533            .map(|name| resolve_utf8(batch, name.as_ref()).map(|array| (name, array)))
534            .collect::<Result<_, _>>()?;
535        // The validated target also fences every publish. A subject that resolves to another
536        // stream fails at the broker instead of being durably acknowledged to the wrong target.
537        let expected_stream = cfg.stream.as_deref();
538        // `dedup.id.column` directly enables bounded `Nats-Msg-Id` deduplication. LDB-5056
539        // validates the stream's `duplicate_window` at open, but the sink contract remains
540        // durable at-least-once because broker dedup is not a coordinated checkpoint commit.
541        let dedup_col = cfg
542            .dedup_id_column
543            .as_deref()
544            .map(|n| resolve_utf8(batch, n).map(|arr| (n, arr)))
545            .transpose()?;
546
547        // Validate required per-row routing metadata before the first publish. A late null would
548        // otherwise turn a deterministic input error into a partially dispatched batch.
549        if let Some(arr) = subject_col {
550            let SubjectSpec::Column(name) = &cfg.subject else {
551                unreachable!("subject column exists only for a column subject")
552            };
553            validate_non_null(arr, "subject.column", name)?;
554        }
555        validate_publish_subjects(&cfg.subject, subject_col, batch.num_rows())?;
556        if let Some((name, arr)) = dedup_col {
557            validate_non_null(arr, "dedup.id.column", name)?;
558        }
559
560        let records = ser
561            .serialize(batch)
562            .map_err(|e| err(&format!("serialize batch: {e}")))?;
563        if records.len() != batch.num_rows() {
564            return Err(ConnectorError::Internal(format!(
565                "NATS serializer returned {} records for a {}-row batch",
566                records.len(),
567                batch.num_rows()
568            )));
569        }
570
571        // Validate every deterministic message property before the first publish. This keeps a
572        // malformed later row from turning an input/configuration error into a partially applied
573        // batch. async-nats checks plain payload size itself, but its header-publish path does not.
574        let max_payload = match &*rt {
575            Runtime::Core { client } => client.server_info().max_payload,
576            Runtime::JetStream { context } => context.client().server_info().max_payload,
577        };
578        for (row, payload) in records.iter().enumerate() {
579            let msg_id = dedup_col.map(|(_, array)| array.value(row));
580            let header_len =
581                validate_headers_and_encoded_len(expected_stream, msg_id, &header_cols, row)?;
582            validate_message_size(row, payload.len(), header_len, max_payload)?;
583        }
584
585        let mut bytes_total: u64 = 0;
586        let mut rows_written: usize = 0;
587        let mut acknowledged_in_write: usize = 0;
588        for (row, payload) in records.into_iter().enumerate() {
589            let subject = match (&cfg.subject, subject_col) {
590                (SubjectSpec::Literal(subject), _) => subject.as_str(),
591                (SubjectSpec::Column(_), Some(array)) => array.value(row),
592                (SubjectSpec::Column(_), None) => unreachable!("resolved above"),
593            };
594            let subject = Subject::from(subject);
595            let msg_id = dedup_col.map(|(_, array)| array.value(row));
596            let headers = build_headers(expected_stream, msg_id, &header_cols, row)?;
597            let payload = bytes::Bytes::from(payload);
598            let payload_len = payload.len() as u64;
599
600            match rt {
601                Runtime::Core { client } => {
602                    let prior_output = *core_dirty;
603                    let result = if let Some(h) = headers {
604                        bounded_publish_enqueue(
605                            write_deadline,
606                            write_timeout,
607                            client.publish_with_headers(subject, h, payload),
608                        )
609                        .await
610                    } else {
611                        bounded_publish_enqueue(
612                            write_deadline,
613                            write_timeout,
614                            client.publish(subject, payload),
615                        )
616                        .await
617                    };
618                    match result {
619                        Ok(()) => *core_dirty = true,
620                        Err(failure) => {
621                            metrics.record_publish_error();
622                            return Err(classify_core_publish_failure(failure, prior_output));
623                        }
624                    }
625                }
626                Runtime::JetStream { context } => {
627                    if pending_acks.len() >= cfg.max_pending {
628                        let acknowledged = drain_acks_until(
629                            pending_acks,
630                            metrics,
631                            task_owner,
632                            write_deadline,
633                            write_timeout,
634                        )
635                        .await
636                        .map_err(|error| preserve_prior_applied(error, acknowledged_in_write))?;
637                        acknowledged_in_write += acknowledged;
638                    }
639                    let prior_output = operation_has_prior_output(
640                        rows_written,
641                        pending_acks.len(),
642                        acknowledged_in_write,
643                    );
644                    let publish_result = if let Some(h) = headers {
645                        bounded_publish_enqueue(
646                            write_deadline,
647                            write_timeout,
648                            context.publish_with_headers(subject, h, payload),
649                        )
650                        .await
651                    } else {
652                        bounded_publish_enqueue(
653                            write_deadline,
654                            write_timeout,
655                            context.publish(subject, payload),
656                        )
657                        .await
658                    };
659                    match publish_result {
660                        Ok(fut) => pending_acks.push_back(fut),
661                        Err(failure) => {
662                            metrics.record_publish_error();
663                            metrics.set_pending_futures(pending_acks.len());
664                            return Err(classify_jetstream_enqueue_failure(failure, prior_output));
665                        }
666                    }
667                }
668            }
669
670            // Per-row so partial failures still credit successes.
671            metrics.record_published_row(payload_len);
672            rows_written += 1;
673            bytes_total += payload_len;
674        }
675
676        metrics.set_pending_futures(pending_acks.len());
677        Ok(WriteResult::new(rows_written, bytes_total))
678    }
679
680    fn schema(&self) -> SchemaRef {
681        self.schema.clone()
682    }
683
684    fn suggested_write_timeout(&self) -> Duration {
685        // The health deadline must include the broker acknowledgement window;
686        // otherwise the runtime cancels a healthy configured ack wait early.
687        self.config
688            .as_ref()
689            .map_or(Duration::from_secs(30), |config| config.ack_timeout)
690    }
691
692    async fn flush(&mut self) -> Result<(), ConnectorError> {
693        let timeout = self
694            .config
695            .as_ref()
696            .map_or(Duration::from_secs(30), |c| c.ack_timeout);
697        if let Some(Runtime::Core { client }) = self.runtime.as_ref() {
698            if !self.core_dirty {
699                return Ok(());
700            }
701            let result = flush_core(client, timeout, "NATS core flush").await;
702            if result.is_ok() {
703                self.core_dirty = false;
704            }
705            return result;
706        }
707        drain_acks(
708            &mut self.pending_acks,
709            &self.metrics,
710            &self.task_owner,
711            timeout,
712        )
713        .await
714        .map(|_| ())
715    }
716
717    async fn close(&mut self) -> Result<(), ConnectorError> {
718        let timeout = self
719            .config
720            .as_ref()
721            .map_or(Duration::from_secs(5), |c| c.ack_timeout);
722        let result = if let Some(Runtime::Core { client }) = self.runtime.as_ref() {
723            // async-nats buffers Core publishes client-side; empty its transport buffer before
724            // drop. This is not a broker acknowledgement, so Core remains explicitly ephemeral.
725            if self.core_dirty {
726                let result = flush_core(client, timeout, "NATS core close flush").await;
727                if result.is_ok() {
728                    self.core_dirty = false;
729                }
730                result
731            } else {
732                Ok(())
733            }
734        } else {
735            drain_acks(
736                &mut self.pending_acks,
737                &self.metrics,
738                &self.task_owner,
739                timeout,
740            )
741            .await
742            .map(|_| ())
743        };
744        self.runtime = None;
745        result
746    }
747}
748
749/// Drain `pending` concurrently, bounded by `timeout`. On deadline,
750/// each still-unresolved ack bumps `record_ack_error` once; the publish
751/// may have landed server-side. Broker dedup can suppress a replay within its
752/// configured window, but the sink contract remains durable at-least-once.
753async fn drain_acks(
754    pending: &mut VecDeque<PublishAckFuture>,
755    metrics: &NatsSinkMetrics,
756    task_owner: &ConnectorTaskOwner,
757    timeout: Duration,
758) -> Result<usize, ConnectorError> {
759    let deadline = tokio::time::Instant::now() + timeout;
760    drain_acks_until(pending, metrics, task_owner, deadline, timeout).await
761}
762
763async fn drain_acks_until(
764    pending: &mut VecDeque<PublishAckFuture>,
765    metrics: &NatsSinkMetrics,
766    task_owner: &ConnectorTaskOwner,
767    deadline: tokio::time::Instant,
768    total_timeout: Duration,
769) -> Result<usize, ConnectorError> {
770    if pending.is_empty() {
771        return Ok(0);
772    }
773    let set: FuturesUnordered<_> = pending.drain(..).map(IntoFuture::into_future).collect();
774    let task_guard = task_owner.track().ok_or_else(|| {
775        ConnectorError::Internal("NATS sink task generation is already retired".into())
776    })?;
777    let task_metrics = metrics.clone();
778    let (result_tx, result_rx) = tokio::sync::oneshot::channel();
779
780    // A caller deadline or cancellation must not drop unresolved PublishAckFutures: async-nats
781    // otherwise hands them to an internal acker whose JoinHandle is not exposed. The owned task
782    // returns the deadline result promptly, then lets every unresolved future reach the client's
783    // own timeout before releasing its generation guard.
784    drop(tokio::spawn(async move {
785        let _task_guard = task_guard;
786        run_ack_drain(set, task_metrics, deadline, total_timeout, result_tx).await;
787    }));
788
789    result_rx.await.map_err(|_| {
790        ConnectorError::Internal("NATS acknowledgement drain task terminated unexpectedly".into())
791    })?
792}
793
794fn retire_pending_acks(
795    pending: &mut VecDeque<PublishAckFuture>,
796    metrics: &NatsSinkMetrics,
797    task_owner: &ConnectorTaskOwner,
798) {
799    if pending.is_empty() {
800        return;
801    }
802    let count = pending.len();
803    let Some(task_guard) = task_owner.track() else {
804        for _ in 0..count {
805            metrics.record_ack_error();
806        }
807        pending.clear();
808        metrics.set_pending_futures(0);
809        return;
810    };
811    let Ok(runtime) = tokio::runtime::Handle::try_current() else {
812        drop(task_guard);
813        for _ in 0..count {
814            metrics.record_ack_error();
815        }
816        pending.clear();
817        metrics.set_pending_futures(0);
818        return;
819    };
820
821    let mut set: FuturesUnordered<_> = pending.drain(..).map(IntoFuture::into_future).collect();
822    let metrics = metrics.clone();
823    drop(runtime.spawn(async move {
824        let _task_guard = task_guard;
825        while let Some(result) = set.next().await {
826            match result {
827                Ok(ack) if ack.duplicate => metrics.record_dedup(),
828                Ok(_) => {}
829                Err(_) => metrics.record_ack_error(),
830            }
831        }
832        metrics.set_pending_futures(0);
833    }));
834}
835
836async fn run_ack_drain<F>(
837    mut set: FuturesUnordered<F>,
838    metrics: NatsSinkMetrics,
839    deadline: tokio::time::Instant,
840    total_timeout: Duration,
841    result_tx: tokio::sync::oneshot::Sender<Result<usize, ConnectorError>>,
842) where
843    F: std::future::Future<
844            Output = Result<jetstream::publish::PublishAck, jetstream::context::PublishError>,
845        > + Send
846        + 'static,
847{
848    let mut aggregate = AckAggregate::default();
849    loop {
850        if set.is_empty() {
851            metrics.set_pending_futures(0);
852            let _ = result_tx.send(aggregate.into_result());
853            return;
854        }
855        match tokio::time::timeout_at(deadline, set.next()).await {
856            Ok(Some(Ok(ack))) => {
857                aggregate.record_applied();
858                if ack.duplicate {
859                    metrics.record_dedup();
860                }
861            }
862            Ok(Some(Err(error))) => {
863                metrics.record_ack_error();
864                aggregate.record_failure(classify_jetstream_ack_failure(&error));
865            }
866            Ok(None) => {
867                metrics.set_pending_futures(0);
868                let _ = result_tx.send(aggregate.into_result());
869                return;
870            }
871            Err(_) => {
872                let unresolved = set.len();
873                for _ in 0..unresolved {
874                    metrics.record_ack_error();
875                }
876                aggregate.record_unresolved_timeout(unresolved, total_timeout);
877                metrics.set_pending_futures(unresolved);
878                let _ = result_tx.send(aggregate.into_result());
879
880                while let Some(result) = set.next().await {
881                    if let Ok(ack) = result {
882                        if ack.duplicate {
883                            metrics.record_dedup();
884                        }
885                    }
886                }
887                metrics.set_pending_futures(0);
888                return;
889            }
890        }
891    }
892}
893
894fn err(msg: &str) -> ConnectorError {
895    ConnectorError::ConfigurationError(msg.to_string())
896}
897
898#[cfg(test)]
899mod tests;