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